Add scout flight planning: click-to-place rectangle, new dropdown category

A ScoutFlight anchors to the center of whatever large grid square the
cursor is in, with its bearing read off where in that square the
cursor actually sits, decoupling a clean anchor point from fine
direction control. The plotted rectangle extends 0.92km back and
13.04km forward along that bearing, 1.21km to each side
(solver.scout_flight_corners), previewed live while placing (see
GridCanvas.start_scout_flight_placement) and drawn as a filled
rectangle once committed.

New 'Scout Flights' header dropdown lists them with replot/hide/remove
per entry, plus a header button to plan a new one. Wired into
Board.clear() and save/load (SAVE_FORMAT_VERSION bumped to 3, old
saves load fine via the new key's default).
This commit is contained in:
Dominik Moritz Roth 2026-08-08 23:32:54 +02:00
parent 419674b5af
commit 186e2c4d6d
4 changed files with 267 additions and 12 deletions

View File

@ -120,6 +120,41 @@ def _row(
return box 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: def _location_status(obj) -> str:
if obj.coord is not None: if obj.coord is not None:
return obj.coord.label() return obj.coord.label()
@ -193,6 +228,7 @@ class MainWindow(Adw.ApplicationWindow):
("Spotters", self._build_spotters_popover), ("Spotters", self._build_spotters_popover),
("Reference Points", self._build_rp_popover), ("Reference Points", self._build_rp_popover),
("Targets", self._build_targets_popover), ("Targets", self._build_targets_popover),
("Scout Flights", self._build_scout_flights_popover),
): ):
header.pack_start(self._make_menu_button(label, popover_builder)) 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()) strike_btn.connect("clicked", lambda _b: self._add_strike())
header.pack_start(strike_btn) 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 = Gtk.Button(icon_name="edit-clear-all-symbolic")
clear_btn.set_tooltip_text("Clear board (drop everything)") clear_btn.set_tooltip_text("Clear board (drop everything)")
clear_btn.connect("clicked", lambda _b: self._clear_board()) clear_btn.connect("clicked", lambda _b: self._clear_board())
@ -378,11 +419,13 @@ class MainWindow(Adw.ApplicationWindow):
def _clear_board(self) -> None: def _clear_board(self) -> None:
board = self.board 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 return # nothing to clear
dialog = Adw.AlertDialog( dialog = Adw.AlertDialog(
heading="Clear board?", 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("cancel", "Cancel")
dialog.add_response("clear", "Clear") dialog.add_response("clear", "Clear")
@ -780,6 +823,57 @@ class MainWindow(Adw.ApplicationWindow):
self.board.add_target(type_ or TargetType.UNKNOWN, location, id_) self.board.add_target(type_ or TargetType.UNKNOWN, location, id_)
self._refresh() 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: def _add_strike(self) -> None:
"""Two-step add: pick a shell (for blast radius) first, then click """Two-step add: pick a shell (for blast radius) first, then click
the map to place it, a Strike is just a Target with the map to place it, a Strike is just a Target with

View File

@ -14,7 +14,7 @@ gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0") gi.require_version("Gdk", "4.0")
from gi.repository import Gdk, Gtk # noqa: E402 from gi.repository import Gdk, Gtk # noqa: E402
from . import solver from . import ballistics, solver
from .models import LARGE_X, Board, Target from .models import LARGE_X, Board, Target
COLS, ROWS = 20, 10 COLS, ROWS = 20, 10
@ -35,6 +35,7 @@ CATEGORY_COLOR = {
"rp": (0.95, 0.78, 0.20), "rp": (0.95, 0.78, 0.20),
"target": (0.92, 0.30, 0.28), "target": (0.92, 0.30, 0.28),
} }
SCOUT_FLIGHT = (0.70, 0.45, 0.92)
BG = (0.13, 0.12, 0.10) BG = (0.13, 0.12, 0.10)
GRID_LINE = (1.0, 1.0, 1.0, 0.20) 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 # placement_callback(Coord) instead of doing the normal
# select/hit-test, and (if placement_preview_radius_km is set) a # select/hit-test, and (if placement_preview_radius_km is set) a
# circle of that radius follows the cursor as a preview. # 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_callback = None
self.placement_preview_radius_km = None self.placement_preview_radius_km = None
self.placement_kind = "point"
self._placement_cursor_km = None self._placement_cursor_km = None
self.set_hexpand(True) self.set_hexpand(True)
@ -100,6 +106,19 @@ class GridCanvas(Gtk.DrawingArea):
def start_placement(self, callback, preview_radius_km=None) -> None: def start_placement(self, callback, preview_radius_km=None) -> None:
self.placement_callback = callback self.placement_callback = callback
self.placement_preview_radius_km = preview_radius_km 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.set_cursor_from_name("crosshair")
self.queue_draw() self.queue_draw()
@ -108,9 +127,25 @@ class GridCanvas(Gtk.DrawingArea):
return return
self.placement_callback = None self.placement_callback = None
self.placement_preview_radius_km = None self.placement_preview_radius_km = None
self.placement_kind = "point"
self.set_cursor_from_name(None) self.set_cursor_from_name(None)
self.queue_draw() 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: def _on_key_pressed(self, _controller, keyval, _keycode, _state) -> bool:
if keyval == Gdk.KEY_Escape and self.placement_callback is not None: if keyval == Gdk.KEY_Escape and self.placement_callback is not None:
self.cancel_placement() self.cancel_placement()
@ -220,9 +255,14 @@ class GridCanvas(Gtk.DrawingArea):
if self.placement_callback is not None: if self.placement_callback is not None:
cell_w, cell_h = self._cell_size(self.get_width(), self.get_height()) cell_w, cell_h = self._cell_size(self.get_width(), self.get_height())
grid_h = cell_h * ROWS grid_h = cell_h * ROWS
coord = solver.point_to_coord(self._px_to_km(x, y, cell_w, cell_h, grid_h)) cursor_km = self._px_to_km(x, y, cell_w, cell_h, grid_h)
callback = self.placement_callback callback, kind = self.placement_callback, self.placement_kind
self.cancel_placement() self.cancel_placement()
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: if coord is not None:
callback(coord) callback(coord)
return return
@ -299,6 +339,16 @@ class GridCanvas(Gtk.DrawingArea):
hollow=True, dim=obj.hidden or (category == "target" and not obj.alive), hollow=True, dim=obj.hidden or (category == "target" and not obj.alive),
selected=is_selected, coord=candidate) 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, def _draw_marker(self, cr, point_km, color, label, cell_w, cell_h, grid_h,
canvas_width, canvas_height, *, hollow=False, dim=False, canvas_width, canvas_height, *, hollow=False, dim=False,
selected=False, coord=None) -> None: selected=False, coord=None) -> None:
@ -413,12 +463,39 @@ class GridCanvas(Gtk.DrawingArea):
cr.set_line_width(2) cr.set_line_width(2)
cr.stroke() 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: def _draw_placement_preview(self, cr, cell_w, cell_h, grid_h) -> None:
"""While armed to place/reposition something, a small crosshair dot """While armed to place/reposition something, a small crosshair dot
follows the cursor, plus a blast-radius preview circle if one was follows the cursor, plus a preview of whatever shape is being
given (e.g. placing a Strike, see its shell before you commit).""" 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: if self.placement_callback is None or self._placement_cursor_km is None:
return 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) 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: if self.placement_preview_radius_km is not None:

View File

@ -310,7 +310,27 @@ class Target:
self.location.coord = value 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: class Board:
@ -321,7 +341,9 @@ class Board:
self.spotters: list[Spotter] = [] self.spotters: list[Spotter] = []
self.reference_points: list[ReferencePoint] = [] self.reference_points: list[ReferencePoint] = []
self.targets: list[Target] = [] self.targets: list[Target] = []
self.scout_flights: list[ScoutFlight] = []
self._spotter_seq = 0 self._spotter_seq = 0
self._scout_flight_seq = 0
# -- lookup by name, for resolving clue references ---------------------- # -- lookup by name, for resolving clue references ----------------------
def find_by_name(self, name: str): def find_by_name(self, name: str):
@ -384,16 +406,35 @@ class Board:
def remove_target(self, target: Target) -> None: def remove_target(self, target: Target) -> None:
self.targets.remove(target) 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 ---------------------------------------------------------- # -- reset ----------------------------------------------------------
def clear(self) -> None: def clear(self) -> None:
"""Drop everything: Nest position, spotters, reference points, """Drop everything: Nest position, spotters, reference points,
targets. Used by the "clear board" action for a fresh start targets, scout flights. Used by the "clear board" action for a
without restarting the app.""" fresh start without restarting the app."""
self.nest = Nest() self.nest = Nest()
self.spotters.clear() self.spotters.clear()
self.reference_points.clear() self.reference_points.clear()
self.targets.clear() self.targets.clear()
self.scout_flights.clear()
self._spotter_seq = 0 self._spotter_seq = 0
self._scout_flight_seq = 0
def reorder_target(self, target: Target, new_index: int) -> None: def reorder_target(self, target: Target, new_index: int) -> None:
"""Manual drag-order: `self.targets`' list order is itself the """Manual drag-order: `self.targets`' list order is itself the
@ -488,6 +529,15 @@ class Board:
} }
for t in self.targets 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: def load_from_dict(self, data: dict) -> None:
@ -535,3 +585,14 @@ class Board:
) )
for t in data.get("targets", []) 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)

View File

@ -50,6 +50,29 @@ def point_from_bearing_distance(origin: Point, bearing_deg: float, distance_km:
return origin[0] + dcol, origin[1] + drow 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( def ray_circle_intersections(
origin: Point, bearing_deg: float, center: Point, radius_km: float origin: Point, bearing_deg: float, center: Point, radius_km: float
) -> list[Point]: ) -> list[Point]: