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:
parent
419674b5af
commit
186e2c4d6d
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user