diff --git a/src/fenigma/app.py b/src/fenigma/app.py index d6698cc..70ae5fd 100644 --- a/src/fenigma/app.py +++ b/src/fenigma/app.py @@ -120,6 +120,41 @@ def _row( return box +def _scout_flight_row(sf, *, on_replot, on_remove, on_toggle_hidden) -> Gtk.Widget: + """One scout-flight list entry: unlike _row(), there's no coordinate + input/screenshot/geo-overlay, just where it's plotted, a hide toggle, + and remove.""" + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + box.set_margin_top(4) + box.set_margin_bottom(4) + box.set_margin_start(8) + box.set_margin_end(8) + + label = Gtk.Label(xalign=0, hexpand=True, label=f"{sf.name} (bearing {sf.bearing_deg:05.1f}°)") + if sf.hidden: + label.add_css_class("dim-label") + box.append(label) + + replot_btn = Gtk.Button(icon_name="find-location-symbolic", tooltip_text="Replot on map") + replot_btn.connect("clicked", lambda _b: on_replot()) + box.append(replot_btn) + + hide_btn = Gtk.Button( + icon_name="view-reveal-symbolic" if sf.hidden else "view-conceal-symbolic", + tooltip_text="Show on map" if sf.hidden else "Hide from map", + ) + hide_btn.add_css_class("flat") + hide_btn.connect("clicked", lambda _b: on_toggle_hidden()) + box.append(hide_btn) + + rm_btn = Gtk.Button(icon_name="user-trash-symbolic", tooltip_text="Remove") + rm_btn.add_css_class("flat") + rm_btn.connect("clicked", lambda _b: on_remove()) + box.append(rm_btn) + + return box + + def _location_status(obj) -> str: if obj.coord is not None: return obj.coord.label() @@ -193,6 +228,7 @@ class MainWindow(Adw.ApplicationWindow): ("Spotters", self._build_spotters_popover), ("Reference Points", self._build_rp_popover), ("Targets", self._build_targets_popover), + ("Scout Flights", self._build_scout_flights_popover), ): header.pack_start(self._make_menu_button(label, popover_builder)) @@ -201,6 +237,11 @@ class MainWindow(Adw.ApplicationWindow): strike_btn.connect("clicked", lambda _b: self._add_strike()) header.pack_start(strike_btn) + scout_btn = Gtk.Button(icon_name="airplane-mode-symbolic") + scout_btn.set_tooltip_text("Plan scout flight") + scout_btn.connect("clicked", lambda _b: self._add_scout_flight()) + header.pack_start(scout_btn) + clear_btn = Gtk.Button(icon_name="edit-clear-all-symbolic") clear_btn.set_tooltip_text("Clear board (drop everything)") clear_btn.connect("clicked", lambda _b: self._clear_board()) @@ -378,11 +419,13 @@ class MainWindow(Adw.ApplicationWindow): def _clear_board(self) -> None: board = self.board - if board.nest.coord is None and not board.spotters and not board.reference_points and not board.targets: + if (board.nest.coord is None and not board.spotters and not board.reference_points + and not board.targets and not board.scout_flights): return # nothing to clear dialog = Adw.AlertDialog( heading="Clear board?", - body="Drops the Nest position and every spotter, reference point, and target. This can't be undone.", + body="Drops the Nest position and every spotter, reference point, target, and scout flight. " + "This can't be undone.", ) dialog.add_response("cancel", "Cancel") dialog.add_response("clear", "Clear") @@ -780,6 +823,57 @@ class MainWindow(Adw.ApplicationWindow): self.board.add_target(type_ or TargetType.UNKNOWN, location, id_) self._refresh() + # -- Scout Flights ------------------------------------------------------------ + def _build_scout_flights_popover(self, rebuild) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + box.set_margin_top(6) + box.set_margin_bottom(6) + + for sf in list(self.board.scout_flights): + box.append(_scout_flight_row( + sf, + on_replot=lambda sf=sf: self._replot_scout_flight(sf), + on_remove=lambda sf=sf: self._remove_scout_flight(sf, rebuild), + on_toggle_hidden=lambda sf=sf: self._toggle_hidden(sf, rebuild), + )) + + box.append(Gtk.Separator()) + add_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6, + margin_top=4, margin_bottom=4, margin_start=8, margin_end=8) + add_btn = Gtk.Button(label="Add scout flight") + add_btn.connect("clicked", lambda _b: self._add_scout_flight()) + add_row.append(add_btn) + box.append(add_row) + return box + + def _add_scout_flight(self) -> None: + """Click-to-place, like Add Strike: the anchor snaps to the center + of whatever large grid square the cursor is in, the bearing is + read off where in that square the cursor actually sits (see + GridCanvas._scout_flight_anchor).""" + self.canvas.start_scout_flight_placement(self._commit_scout_flight) + self.toast("Click the map to plot the scout flight, Esc to cancel.") + + def _commit_scout_flight(self, center_km, bearing_deg) -> None: + self.board.add_scout_flight(center_km, bearing_deg) + self._refresh() + + def _replot_scout_flight(self, sf) -> None: + self.canvas.start_scout_flight_placement( + lambda center, bearing, sf=sf: self._apply_scout_flight_replot(sf, center, bearing) + ) + self.toast(f"Click the map to replot {sf.name}, Esc to cancel.") + + def _apply_scout_flight_replot(self, sf, center_km, bearing_deg) -> None: + sf.center = center_km + sf.bearing_deg = bearing_deg + self._refresh() + + def _remove_scout_flight(self, sf, rebuild) -> None: + self.board.remove_scout_flight(sf) + self._refresh() + rebuild() + def _add_strike(self) -> None: """Two-step add: pick a shell (for blast radius) first, then click the map to place it, a Strike is just a Target with diff --git a/src/fenigma/grid_widget.py b/src/fenigma/grid_widget.py index 4ac361a..03b7fed 100644 --- a/src/fenigma/grid_widget.py +++ b/src/fenigma/grid_widget.py @@ -14,7 +14,7 @@ gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") from gi.repository import Gdk, Gtk # noqa: E402 -from . import solver +from . import ballistics, solver from .models import LARGE_X, Board, Target COLS, ROWS = 20, 10 @@ -35,6 +35,7 @@ CATEGORY_COLOR = { "rp": (0.95, 0.78, 0.20), "target": (0.92, 0.30, 0.28), } +SCOUT_FLIGHT = (0.70, 0.45, 0.92) BG = (0.13, 0.12, 0.10) GRID_LINE = (1.0, 1.0, 1.0, 0.20) @@ -66,8 +67,13 @@ class GridCanvas(Gtk.DrawingArea): # placement_callback(Coord) instead of doing the normal # select/hit-test, and (if placement_preview_radius_km is set) a # circle of that radius follows the cursor as a preview. + # placement_kind == "scout_flight" is a different shape entirely + # (see start_scout_flight_placement): the callback there gets + # (center_km, bearing_deg) instead of a Coord, and the preview is + # the scout flight's oriented rectangle instead of a circle. self.placement_callback = None self.placement_preview_radius_km = None + self.placement_kind = "point" self._placement_cursor_km = None self.set_hexpand(True) @@ -100,6 +106,19 @@ class GridCanvas(Gtk.DrawingArea): def start_placement(self, callback, preview_radius_km=None) -> None: self.placement_callback = callback self.placement_preview_radius_km = preview_radius_km + self.placement_kind = "point" + self.set_cursor_from_name("crosshair") + self.queue_draw() + + def start_scout_flight_placement(self, callback) -> None: + """Like start_placement(), but the next click calls + callback(center_km, bearing_deg) instead of callback(Coord): the + anchor is the large grid square the cursor is in (not wherever + exactly it's pointing), and the bearing is derived from where in + that square the cursor sits, see _scout_flight_anchor().""" + self.placement_callback = callback + self.placement_preview_radius_km = None + self.placement_kind = "scout_flight" self.set_cursor_from_name("crosshair") self.queue_draw() @@ -108,9 +127,25 @@ class GridCanvas(Gtk.DrawingArea): return self.placement_callback = None self.placement_preview_radius_km = None + self.placement_kind = "point" self.set_cursor_from_name(None) self.queue_draw() + def _scout_flight_anchor(self, cursor_km) -> tuple[tuple[float, float], float]: + """(center_km, bearing_deg) for scout-flight placement: the center + of the large grid square the cursor is in, and the bearing from + that center out toward the actual cursor position, this is what + lets the anchor snap to a clean cell center while direction stays + under fine mouse control. Clamped so a cursor right at the map's + edge still resolves to that edge cell rather than one off the + grid.""" + col, row = cursor_km + cell_col = min(max(math.floor(col), 0), COLS - 1) + cell_row = min(max(math.floor(row), 0), ROWS - 1) + center_km = (cell_col + 0.5, cell_row + 0.5) + bearing = ballistics.bearing_deg_point(center_km, cursor_km) + return center_km, bearing + def _on_key_pressed(self, _controller, keyval, _keycode, _state) -> bool: if keyval == Gdk.KEY_Escape and self.placement_callback is not None: self.cancel_placement() @@ -220,11 +255,16 @@ class GridCanvas(Gtk.DrawingArea): if self.placement_callback is not None: cell_w, cell_h = self._cell_size(self.get_width(), self.get_height()) grid_h = cell_h * ROWS - coord = solver.point_to_coord(self._px_to_km(x, y, cell_w, cell_h, grid_h)) - callback = self.placement_callback + cursor_km = self._px_to_km(x, y, cell_w, cell_h, grid_h) + callback, kind = self.placement_callback, self.placement_kind self.cancel_placement() - if coord is not None: - callback(coord) + if kind == "scout_flight": + center_km, bearing = self._scout_flight_anchor(cursor_km) + callback(center_km, bearing) + else: + coord = solver.point_to_coord(cursor_km) + if coord is not None: + callback(coord) return hit, coord = self._hit_test(x, y) @@ -299,6 +339,16 @@ class GridCanvas(Gtk.DrawingArea): hollow=True, dim=obj.hidden or (category == "target" and not obj.alive), selected=is_selected, coord=candidate) + for sf in self.board.scout_flights: + if sf.hidden: + continue # hidden means gone from the map, not just darkened, no selection to reinstate it + self._draw_scout_flight_rect(cr, sf.center, sf.bearing_deg, cell_w, cell_h, grid_h) + cx, cy = self._km_to_px(sf.center, cell_w, cell_h, grid_h) + cr.set_font_size(11) + cr.set_source_rgba(*LABEL, 1.0) + cr.move_to(cx + LABEL_PAD, cy - 7) + cr.show_text(sf.name) + def _draw_marker(self, cr, point_km, color, label, cell_w, cell_h, grid_h, canvas_width, canvas_height, *, hollow=False, dim=False, selected=False, coord=None) -> None: @@ -413,12 +463,39 @@ class GridCanvas(Gtk.DrawingArea): cr.set_line_width(2) cr.stroke() + def _draw_scout_flight_rect(self, cr, center_km, bearing_deg, cell_w, cell_h, grid_h, *, + dashed=False, alpha_mult=1.0) -> None: + corners = solver.scout_flight_corners(center_km, bearing_deg) + px_corners = [self._km_to_px(p, cell_w, cell_h, grid_h) for p in corners] + r, g, b = SCOUT_FLIGHT + + cr.new_path() + cr.move_to(*px_corners[0]) + for p in px_corners[1:]: + cr.line_to(*p) + cr.close_path() + cr.set_source_rgba(r, g, b, 0.15 * alpha_mult) + cr.fill_preserve() + cr.set_source_rgba(r, g, b, 0.85 * alpha_mult) + cr.set_line_width(1.5 if dashed else 2) + if dashed: + cr.set_dash([3, 2]) + cr.stroke() + if dashed: + cr.set_dash([]) + def _draw_placement_preview(self, cr, cell_w, cell_h, grid_h) -> None: """While armed to place/reposition something, a small crosshair dot - follows the cursor, plus a blast-radius preview circle if one was - given (e.g. placing a Strike, see its shell before you commit).""" + follows the cursor, plus a preview of whatever shape is being + placed: a blast-radius circle (e.g. a Strike, see its shell before + you commit) or a scout flight's oriented rectangle.""" if self.placement_callback is None or self._placement_cursor_km is None: return + + if self.placement_kind == "scout_flight": + center_km, bearing = self._scout_flight_anchor(self._placement_cursor_km) + self._draw_scout_flight_rect(cr, center_km, bearing, cell_w, cell_h, grid_h, dashed=True) + x, y = self._km_to_px(self._placement_cursor_km, cell_w, cell_h, grid_h) if self.placement_preview_radius_km is not None: diff --git a/src/fenigma/models.py b/src/fenigma/models.py index e8b9178..d036f34 100644 --- a/src/fenigma/models.py +++ b/src/fenigma/models.py @@ -310,7 +310,27 @@ class Target: self.location.coord = value -SAVE_FORMAT_VERSION = 2 +@dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members +class ScoutFlight: + """A planned scout overflight: a rectangle anchored on a large grid + square's center and oriented along a bearing, both chosen by clicking + the map (see GridCanvas's scout-flight placement mode and + solver.scout_flight_corners for the actual rectangle geometry). + Unlike Nest/Spotter/RP/Target, its position isn't a single Coord, a + (col, row) km pair is the natural representation since the anchor is + a cell center, not necessarily hittable by the integer sub-grid.""" + + id: int + center: tuple[float, float] # (col, row) in board-units km + bearing_deg: float + hidden: bool = False + + @property + def name(self) -> str: + return f"ScoutFlight#{self.id}" + + +SAVE_FORMAT_VERSION = 3 class Board: @@ -321,7 +341,9 @@ class Board: self.spotters: list[Spotter] = [] self.reference_points: list[ReferencePoint] = [] self.targets: list[Target] = [] + self.scout_flights: list[ScoutFlight] = [] self._spotter_seq = 0 + self._scout_flight_seq = 0 # -- lookup by name, for resolving clue references ---------------------- def find_by_name(self, name: str): @@ -384,16 +406,35 @@ class Board: def remove_target(self, target: Target) -> None: self.targets.remove(target) + # -- scout flights -------------------------------------------------- + def next_scout_flight_id(self) -> int: + return self._scout_flight_seq + 1 + + def add_scout_flight( + self, center: tuple[float, float], bearing_deg: float, id_: int | None = None + ) -> ScoutFlight: + if id_ is None: + id_ = self.next_scout_flight_id() + self._scout_flight_seq = max(self._scout_flight_seq, id_) + sf = ScoutFlight(id=id_, center=center, bearing_deg=bearing_deg) + self.scout_flights.append(sf) + return sf + + def remove_scout_flight(self, sf: ScoutFlight) -> None: + self.scout_flights.remove(sf) + # -- reset ---------------------------------------------------------- def clear(self) -> None: """Drop everything: Nest position, spotters, reference points, - targets. Used by the "clear board" action for a fresh start - without restarting the app.""" + targets, scout flights. Used by the "clear board" action for a + fresh start without restarting the app.""" self.nest = Nest() self.spotters.clear() self.reference_points.clear() self.targets.clear() + self.scout_flights.clear() self._spotter_seq = 0 + self._scout_flight_seq = 0 def reorder_target(self, target: Target, new_index: int) -> None: """Manual drag-order: `self.targets`' list order is itself the @@ -488,6 +529,15 @@ class Board: } for t in self.targets ], + "scout_flights": [ + { + "id": sf.id, + "center": {"col": sf.center[0], "row": sf.center[1]}, + "bearing_deg": sf.bearing_deg, + "hidden": sf.hidden, + } + for sf in self.scout_flights + ], } def load_from_dict(self, data: dict) -> None: @@ -535,3 +585,14 @@ class Board: ) for t in data.get("targets", []) ] + + self.scout_flights = [ + ScoutFlight( + id=sf["id"], + center=(sf["center"]["col"], sf["center"]["row"]), + bearing_deg=sf["bearing_deg"], + hidden=sf.get("hidden", False), + ) + for sf in data.get("scout_flights", []) + ] + self._scout_flight_seq = max((sf.id for sf in self.scout_flights), default=0) diff --git a/src/fenigma/solver.py b/src/fenigma/solver.py index a7a7c52..b48428d 100644 --- a/src/fenigma/solver.py +++ b/src/fenigma/solver.py @@ -50,6 +50,29 @@ def point_from_bearing_distance(origin: Point, bearing_deg: float, distance_km: return origin[0] + dcol, origin[1] + drow +# A scout flight's plotted path: a rectangle anchored on a chosen large +# grid square's center, oriented along a chosen bearing. +SCOUT_FLIGHT_BACK_KM = 0.92 +SCOUT_FLIGHT_SIDE_KM = 1.21 +SCOUT_FLIGHT_FORWARD_KM = 13.04 + + +def scout_flight_corners(center: Point, bearing_deg: float) -> list[Point]: + """The 4 corners of a scout flight's rectangle: SCOUT_FLIGHT_BACK_KM + behind `center` along `bearing_deg` to SCOUT_FLIGHT_FORWARD_KM ahead of + it, SCOUT_FLIGHT_SIDE_KM to either side. Order: back-left, back-right, + forward-right, forward-left, a closed loop when drawn in that order.""" + fwd = bearing_distance_to_delta(bearing_deg, 1.0) + side = bearing_distance_to_delta((bearing_deg + 90) % 360, 1.0) + back = (center[0] - fwd[0] * SCOUT_FLIGHT_BACK_KM, center[1] - fwd[1] * SCOUT_FLIGHT_BACK_KM) + front = (center[0] + fwd[0] * SCOUT_FLIGHT_FORWARD_KM, center[1] + fwd[1] * SCOUT_FLIGHT_FORWARD_KM) + + def offset(p: Point, sign: float) -> Point: + return (p[0] + side[0] * SCOUT_FLIGHT_SIDE_KM * sign, p[1] + side[1] * SCOUT_FLIGHT_SIDE_KM * sign) + + return [offset(back, -1), offset(back, 1), offset(front, 1), offset(front, -1)] + + def ray_circle_intersections( origin: Point, bearing_deg: float, center: Point, radius_km: float ) -> list[Point]: