"""GridCanvas: draws the 20x10 map, every placed entity, ambiguous solver candidates, and geo-description overlays (bearing/distance clues) for whatever's hovered or pinned via show_geo_desc. Also handles click-to-select and hover notification so the firing-commands panel can stay in sync with the map (see app.py).""" from __future__ import annotations import math from collections import namedtuple import cairo import gi import numpy as np gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") gi.require_version("Adw", "1") from gi.repository import Adw, Gdk, Gtk # noqa: E402 from . import ballistics, icons, solver from .models import LARGE_X, Board, Target COLS, ROWS = 20, 10 MARGIN_LEFT = 34 MARGIN_TOP = 30 MARGIN_RIGHT = 50 MARGIN_BOTTOM = 30 LABEL_PAD = 8 # gap between a marker and its name label HOVER_RADIUS_PX = 12 # An imported screenshot is a backdrop, not the subject: slightly transparent so # the grid lines and markers drawn over it stay legible. SCREENSHOT_ALPHA = 0.88 OVERLAY_RAY_LENGTH_KM = 30.0 # long enough to cross the 20x10 map from any origin MIN_ZOOM = 1.0 # the whole 20x10 map fits, the default MAX_ZOOM = 10.0 ZOOM_STEP = 1.15 # per scroll-wheel notch # Below this cell width, a marker's game icon wouldn't read as anything # but a smudge, a plain dot is more honest about the zoom level than a # barely-legible picture. ICON_MIN_CELL_PX = 42 # Everything needed to convert between km-space (the 20x10 grid) and # widget pixels for one frame, bundled so every draw/hit-test method # takes one argument instead of threading cell_w/cell_h/grid_h/pan # separately through a dozen call sites. cell_w/cell_h differ only when # square_cells is off (the default): the map then stretches to fill the # widget exactly, cell_w == cell_h only when the widget's own aspect # ratio happens to match the grid's. pad_x/pad_y are the letterboxing # margin added on whichever axis has leftover space when square_cells # is on. ox/oy are the visible viewport's origin in km-space (0,0 # unless zoomed in and panned). _View = namedtuple("_View", "cell_w cell_h grid_w grid_h pad_x pad_y ox oy vis_cols vis_rows") # Everything below (CATEGORY_COLOR through PLACEMENT_PREVIEW) is a # module-level name deliberately kept mutable: _apply_palette() below # reassigns all of them via `global`, in place, whenever the app's # light/dark scheme changes (see GridCanvas.__init__, which hooks # Adw.StyleManager's own dark/light detection, including live updates # if the system theme changes while running). Every draw method # references these bare names directly (`cr.set_source_rgb(*BG)` etc.) # rather than threading a palette object through every call, reassigning # the names in place is what makes that keep working without touching # every call site. The values set here at import time are the dark # palette, _apply_palette(is_dark=True) (called from __init__) reapplies # the same values, it's the light branch that actually changes anything # the first time it runs. CATEGORY_COLOR = { "nest": (0.35, 0.60, 0.95), "spotter": (0.35, 0.78, 0.40), "rp": (0.95, 0.78, 0.20), "target": (0.92, 0.30, 0.28), "ally": (0.30, 0.85, 0.85), # cyan, distinct from every other category's color } 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) SUBGRID_LINE = (0.72, 0.70, 0.65, 0.15) # verified by actually computing the blended-over-BG # pixel values, not eyeballing it: alpha 0.35 (a previous version) blended this same RGB out to # (86, 83, 75), BRIGHTER than GRID_LINE's own blended (77, 76, 72), backwards from the intent. # 0.15 blends to (56, 53, 47): sits between BG (33, 31, 26) and GRID_LINE (77, 76, 72), the RGB # tint stays visible but the line itself reads as genuinely fainter, not louder. HOVER_LEGEND = (0.45, 0.65, 0.95) # blue, not yellow, for the highlighted X/Y legend label LABEL = (0.88, 0.86, 0.80) COORD_LABEL = (0.60, 0.58, 0.54) YELLOW = (0.95, 0.85, 0.20) WHITE = (1.0, 1.0, 1.0) # not literally "white" any more in the light palette, see _LIGHT_PALETTE: # its role is "a neutral that maximally contrasts with BG", the name stuck around from when this # only ever ran on a dark background. FIRING_ARROW = (0.95, 0.15, 0.15) SELECTION_RING = (1.0, 1.0, 1.0) BLAST_RADIUS = (0.95, 0.40, 0.10) PLACEMENT_PREVIEW = (0.95, 0.85, 0.20) _DARK_PALETTE = dict( CATEGORY_COLOR={ "nest": (0.35, 0.60, 0.95), "spotter": (0.35, 0.78, 0.40), "rp": (0.95, 0.78, 0.20), "target": (0.92, 0.30, 0.28), "ally": (0.30, 0.85, 0.85), }, 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), SUBGRID_LINE=(0.72, 0.70, 0.65, 0.15), HOVER_LEGEND=(0.45, 0.65, 0.95), LABEL=(0.88, 0.86, 0.80), COORD_LABEL=(0.60, 0.58, 0.54), YELLOW=(0.95, 0.85, 0.20), WHITE=(1.0, 1.0, 1.0), FIRING_ARROW=(0.95, 0.15, 0.15), SELECTION_RING=(1.0, 1.0, 1.0), BLAST_RADIUS=(0.95, 0.40, 0.10), PLACEMENT_PREVIEW=(0.95, 0.85, 0.20), ) # Same relative brightness relationships as the dark palette (main grid # line vs. the fainter subgrid one, category colors distinct from each # other), just inverted for a light background: every color that needs # to contrast against BG got darkened instead of brightened. First-pass # guesses, flagged the same way the Shell descriptions were, correct # whichever look off once actually seen on a real light-themed desktop. _LIGHT_PALETTE = dict( CATEGORY_COLOR={ "nest": (0.15, 0.35, 0.75), "spotter": (0.10, 0.50, 0.15), "rp": (0.65, 0.50, 0.05), "target": (0.75, 0.12, 0.10), "ally": (0.05, 0.45, 0.45), }, SCOUT_FLIGHT=(0.45, 0.20, 0.65), BG=(0.96, 0.95, 0.93), GRID_LINE=(0.08, 0.08, 0.08, 0.20), SUBGRID_LINE=(0.08, 0.08, 0.08, 0.15), HOVER_LEGEND=(0.10, 0.35, 0.75), LABEL=(0.15, 0.14, 0.12), COORD_LABEL=(0.42, 0.40, 0.37), YELLOW=(0.65, 0.48, 0.02), WHITE=(0.10, 0.10, 0.10), FIRING_ARROW=(0.80, 0.10, 0.10), SELECTION_RING=(0.05, 0.05, 0.05), BLAST_RADIUS=(0.80, 0.35, 0.05), PLACEMENT_PREVIEW=(0.65, 0.48, 0.02), ) def _apply_palette(is_dark: bool) -> None: global CATEGORY_COLOR, SCOUT_FLIGHT, BG, GRID_LINE, SUBGRID_LINE, HOVER_LEGEND, LABEL, \ COORD_LABEL, YELLOW, WHITE, FIRING_ARROW, SELECTION_RING, BLAST_RADIUS, PLACEMENT_PREVIEW p = _DARK_PALETTE if is_dark else _LIGHT_PALETTE CATEGORY_COLOR = p["CATEGORY_COLOR"] SCOUT_FLIGHT = p["SCOUT_FLIGHT"] BG = p["BG"] GRID_LINE = p["GRID_LINE"] SUBGRID_LINE = p["SUBGRID_LINE"] HOVER_LEGEND = p["HOVER_LEGEND"] LABEL = p["LABEL"] COORD_LABEL = p["COORD_LABEL"] YELLOW = p["YELLOW"] WHITE = p["WHITE"] FIRING_ARROW = p["FIRING_ARROW"] SELECTION_RING = p["SELECTION_RING"] BLAST_RADIUS = p["BLAST_RADIUS"] PLACEMENT_PREVIEW = p["PLACEMENT_PREVIEW"] # path -> loaded cairo.ImageSurface (or None for a path that failed to # load, so a missing/bad icon file only ever gets one failed attempt, # not one per frame). Module-level, not per-canvas: the icon set is # fixed at import time, no reason to reload it per GridCanvas instance. _ICON_SURFACE_CACHE: dict = {} def _icon_surface(path) -> cairo.ImageSurface | None: if path is None: return None if path not in _ICON_SURFACE_CACHE: try: _ICON_SURFACE_CACHE[path] = cairo.ImageSurface.create_from_png(str(path)) except Exception: _ICON_SURFACE_CACHE[path] = None return _ICON_SURFACE_CACHE[path] def _icon_for(category: str, obj) -> cairo.ImageSurface | None: """Whichever game icon fits `obj`'s type, or None to fall back to the plain dot (see ICON_MIN_CELL_PX for when that fallback actually kicks in).""" if category == "nest": return _icon_surface(icons.NEST_ICON_PATH) if category == "target": return _icon_surface(icons.target_icon_path(obj.type, is_ally=False)) if category == "ally": return _icon_surface(icons.target_icon_path(obj.type, is_ally=True)) return None class GridCanvas(Gtk.DrawingArea): def __init__(self, board: Board) -> None: super().__init__() self.board = board # Follow the app's light/dark scheme (system setting, or an # in-app override if one's ever added later) for every color # this canvas draws with, live: if the scheme changes while # running, redraw with the other palette rather than staying # stuck on whichever was active at startup. style_manager = Adw.StyleManager.get_default() _apply_palette(style_manager.get_dark()) style_manager.connect("notify::dark", self._on_style_changed) self.hovered = None self.hovered_point = None # which candidate, when obj has more than one point self.selected = None self.selected_point = None # which candidate, when obj has more than one point self.on_select = None # callback(obj | None, point | None), fired on click self.on_hover_change = None # callback(obj | None, point | None), fired on hover change self.on_cursor_move = None # callback((col, row) km | None), fired on every motion/leave # callback(proposal, x, y): fired when an imported screenshot's pending # proposal is clicked with either button. A proposal exists only to be # accepted or rejected, so plain clicking it offers that rather than # selecting something the board doesn't contain yet. self.on_proposal_click = None # callback(Coord, x, y, obj, point): fired on right-click unless placing. # obj/point are the entity under the cursor when there is one (same # hit test as left-click selection), so the handler can offer actions # on that entity instead of the place-something-here menu. self.on_right_click = None self.hide_dead_from_map = False # off by default; toggled from the firing panel toolbar # An imported map screenshot, rectified into board space, drawn under # everything else, plus the units detected in it as [(proposal, Coord)]. # Proposals are kept separate from board entities on purpose: they are # not on the board until accepted, so nothing that walks the board can # see them, and they get their own hit test. self._screenshot = None # (cairo surface, backing array, px_per_km) self.proposals = [] # Which large cell the cursor is currently over, (col, row) both # floored, or None off the map/off the widget entirely. Redrawn # only when this actually changes cell (not on every pixel of # motion within the same one), see _draw_hover_subgrid(). self._hover_cell: tuple[int, int] | None = None # Placement mode: while armed, the next left-click calls # 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 # square_cells: off by default (the map stretches to fill the # widget, cell_w != cell_h unless the widget's own aspect ratio # happens to match 20:10), toggled from the header (see app.py). # zoom/pan_km: scroll-wheel zoom state, pan_km is the km-space # point the current view is centered on; _view() clamps it so # the visible viewport never hangs off the grid's edge. # _last_pointer_px: tracked from motion events so a scroll event # (which doesn't carry its own pointer position) has somewhere # to zoom towards. self.square_cells = False self.zoom = MIN_ZOOM self.pan_km = (COLS / 2.0, ROWS / 2.0) self._last_pointer_px: tuple[float, float] | None = None # Drag-to-pan state, see _on_drag_begin/_on_drag_update. # _drag_did_pan tracks whether the in-progress/just-finished # drag actually moved the view (past a small pixel threshold, # not just an ordinary click's own tiny jitter), so _on_click # can skip treating that same gesture as a select/click too. self._drag_start_pan_km: tuple[float, float] | None = None self._drag_start_view: _View | None = None self._drag_did_pan = False self.set_hexpand(True) self.set_vexpand(True) self.set_draw_func(self._draw) motion = Gtk.EventControllerMotion() motion.connect("motion", self._on_motion) motion.connect("leave", self._on_leave) self.add_controller(motion) scroll = Gtk.EventControllerScroll(flags=Gtk.EventControllerScrollFlags.VERTICAL) scroll.connect("scroll", self._on_scroll) self.add_controller(scroll) # Only actually pans once zoomed in (see _on_drag_update), at # the default zoom the whole map's already on screen, nothing # to drag to. drag = Gtk.GestureDrag() drag.connect("drag-begin", self._on_drag_begin) drag.connect("drag-update", self._on_drag_update) drag.connect("drag-end", self._on_drag_end) self.add_controller(drag) click = Gtk.GestureClick() click.connect("released", self._on_click) self.add_controller(click) right_click = Gtk.GestureClick() right_click.set_button(Gdk.BUTTON_SECONDARY) right_click.connect("released", self._on_right_click) self.add_controller(right_click) keys = Gtk.EventControllerKey() keys.connect("key-pressed", self._on_key_pressed) self.set_focusable(True) self.add_controller(keys) def refresh(self) -> None: self.queue_draw() def _on_style_changed(self, style_manager, _pspec) -> None: _apply_palette(style_manager.get_dark()) self.queue_draw() # -- placement mode ----------------------------------------------------------- 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() def cancel_placement(self) -> None: if self.placement_callback is None: 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() return True return False def set_selected(self, obj, point=None) -> None: if obj is not self.selected or point != self.selected_point: self.selected = obj self.selected_point = point self.queue_draw() # -- geometry ------------------------------------------------------------- def set_square_cells(self, square: bool) -> None: self.square_cells = square self.queue_draw() def _visible_extent(self, avail_w: float, avail_h: float) -> tuple[float, float]: """(vis_cols, vis_rows): how much of the 20x10 grid the current zoom level actually shows. Locked to the grid's own 20:10 shape unless square_cells is on AND actually zoomed in, in which case there's no reason a cropped view has to keep the whole map's fixed shape (only the *full* map has a reason to look like that), so it follows the canvas's own aspect instead: cells come out square with zero letterboxing, rather than the same fixed-size padding band persisting at every zoom level and eating a bigger and bigger share of an already-zoomed-in view. Clamped to ROWS/COLS so an extreme canvas aspect can't ask for a viewport bigger than the whole map itself. Shared by _view() and _on_scroll(), which both need the exact same numbers, a mismatch between them would throw off the scroll-to-zoom anchor math.""" vis_cols = COLS / self.zoom if self.square_cells and self.zoom > MIN_ZOOM: vis_rows = min(vis_cols * avail_h / avail_w, ROWS) vis_cols = min(vis_rows * avail_w / avail_h, COLS) else: vis_rows = ROWS / self.zoom return vis_cols, vis_rows def _view(self, width: int, height: int) -> _View: """Everything needed to convert km-space <-> pixels for one frame, see _View's own docstring. zoom==MIN_ZOOM (the default) recovers the exact pre-zoom/pre-square_cells behavior: the whole grid stretched edge to edge, no letterboxing.""" avail_w = max(width - MARGIN_LEFT - MARGIN_RIGHT, 1) avail_h = max(height - MARGIN_TOP - MARGIN_BOTTOM, 1) vis_cols, vis_rows = self._visible_extent(avail_w, avail_h) cx, cy = self.pan_km ox = min(max(cx - vis_cols / 2, 0.0), COLS - vis_cols) oy = min(max(cy - vis_rows / 2, 0.0), ROWS - vis_rows) cell_w = avail_w / vis_cols cell_h = avail_h / vis_rows pad_x = pad_y = 0.0 if self.square_cells: cell = min(cell_w, cell_h) grid_w, grid_h = cell * vis_cols, cell * vis_rows pad_x = (avail_w - grid_w) / 2 pad_y = (avail_h - grid_h) / 2 cell_w = cell_h = cell else: grid_w, grid_h = avail_w, avail_h return _View(cell_w, cell_h, grid_w, grid_h, pad_x, pad_y, ox, oy, vis_cols, vis_rows) def _km_to_px(self, view: _View, point_km) -> tuple[float, float]: col, row = point_km x = MARGIN_LEFT + view.pad_x + (col - view.ox) * view.cell_w y = MARGIN_TOP + view.pad_y + view.grid_h - (row - view.oy) * view.cell_h return x, y def _px_to_km(self, view: _View, x, y) -> tuple[float, float]: col = (x - MARGIN_LEFT - view.pad_x) / view.cell_w + view.ox row = (view.grid_h - (y - MARGIN_TOP - view.pad_y)) / view.cell_h + view.oy return col, row def _on_scroll(self, _controller, _dx, dy) -> bool: """Zoom in/out anchored at the last known cursor position (a scroll event carries no position of its own), so the km point under the cursor stays under it after the zoom level changes instead of the view just re-centering on the grid's middle.""" width, height = self.get_width(), self.get_height() view_before = self._view(width, height) px, py = self._last_pointer_px or ( MARGIN_LEFT + view_before.pad_x + view_before.grid_w / 2, MARGIN_TOP + view_before.pad_y + view_before.grid_h / 2, ) anchor_km = self._px_to_km(view_before, px, py) self.zoom = min(max(self.zoom * (ZOOM_STEP ** -dy), MIN_ZOOM), MAX_ZOOM) avail_w = max(width - MARGIN_LEFT - MARGIN_RIGHT, 1) avail_h = max(height - MARGIN_TOP - MARGIN_BOTTOM, 1) vis_cols, vis_rows = self._visible_extent(avail_w, avail_h) frac_x = (px - MARGIN_LEFT - view_before.pad_x) / view_before.grid_w if view_before.grid_w else 0.5 frac_y = 1 - (py - MARGIN_TOP - view_before.pad_y) / view_before.grid_h if view_before.grid_h else 0.5 # _view() clamps this back onto the grid itself if it would # otherwise hang the viewport off an edge, no separate bounds # check needed here. self.pan_km = ( anchor_km[0] + (0.5 - frac_x) * vis_cols, anchor_km[1] + (0.5 - frac_y) * vis_rows, ) self.queue_draw() return True def _on_drag_begin(self, _gesture, _x, _y) -> None: if self.zoom <= MIN_ZOOM or self.placement_callback is not None: return # the whole map's already on screen, nothing to pan to self._drag_start_pan_km = self.pan_km self._drag_start_view = self._view(self.get_width(), self.get_height()) self._drag_did_pan = False def _on_drag_update(self, _gesture, offset_x: float, offset_y: float) -> None: if self._drag_start_view is None: return if math.hypot(offset_x, offset_y) > 3: self._drag_did_pan = True view = self._drag_start_view # Dragging pans opposite to how zooming re-centers: the content # follows the cursor (drag right -> content moves right, drag # down -> content moves down), like dragging a piece of paper, # not like moving a camera. Derived directly from _km_to_px()'s # own formula: solving for how much ox/oy (and so pan_km, which # is just their re-centered form) has to change for a given # point_km to land `offset` pixels away from where it started. cx, cy = self._drag_start_pan_km self.pan_km = (cx - offset_x / view.cell_w, cy + offset_y / view.cell_h) self.queue_draw() def _on_drag_end(self, _gesture, _offset_x, _offset_y) -> None: self._drag_start_pan_km = None self._drag_start_view = None def _excluded_from_map(self, obj) -> bool: """True if `obj` should be dropped from the map view entirely, it's hidden, or it's a dead target with the map's dead-hiding toggle on, unless it's the current selection, in which case it's still drawn (darkened) so it stays reachable/un-hideable.""" if obj is self.selected: return False if obj.hidden: return True if self.hide_dead_from_map and isinstance(obj, Target) and not obj.alive: return True return False def _all_positions(self): """Yield (obj, coord) for every point drawn on the map, including each ambiguous candidate separately (hover/click targets each of them individually, but they all resolve to the same obj). Hidden entities, and (if toggled) dead targets, are excluded from the map entirely unless they're the current selection (so they can still be un-hidden/interacted with once selected some other way, e.g. from the firing panel).""" for _category, obj in self.board.placed_entities_all(): if self._excluded_from_map(obj): continue yield obj, obj.coord for _category, obj in self.board.ambiguous_entities_all(): if self._excluded_from_map(obj): continue for candidate in obj.location.potential_coords: yield obj, candidate # -- imported screenshot --------------------------------------------------- def set_screenshot(self, bgra, px_per_km: int) -> None: """Show a rectified map screenshot as the board's backdrop. `bgra` covers the whole board (COLS x ROWS km at px_per_km), transparent wherever the screenshot didn't reach, so a partial view of the table doesn't blank out the rest of the map. Pre-warping into board space is what makes this drawable at all: cairo has no projective transform, but once the image is rectified a plain scale and translate places it. """ if bgra is None: self._screenshot = None self.queue_draw() return buf = np.ascontiguousarray(bgra) h, w = buf.shape[:2] surface = cairo.ImageSurface.create_for_data( memoryview(buf), cairo.FORMAT_ARGB32, w, h, w * 4) # The array must outlive the surface: create_for_data does not copy. self._screenshot = (surface, buf, px_per_km) self.queue_draw() def has_screenshot(self) -> bool: return self._screenshot is not None def set_proposals(self, proposals) -> None: """proposals is [(proposal, Coord)]; the widget only reads the Coord and the proposal's accepted/rejected flags, so it stays ignorant of map_import's own coordinate format.""" self.proposals = list(proposals) self.queue_draw() def _pending_proposals(self): return [(p, c) for p, c in self.proposals if p.pending] def hit_test_proposal(self, view: _View, x: float, y: float): """The pending proposal nearest the cursor within range, or None.""" best, best_dist = None, HOVER_RADIUS_PX for p, coord in self._pending_proposals(): px, py = self._km_to_px(view, coord.as_fraction()) dist = math.hypot(px - x, py - y) if dist < best_dist: best_dist, best = dist, p return best def _draw_screenshot(self, cr, view) -> None: surface, buf, px_per_km = self._screenshot # Board space runs col 0..COLS rightward and row 0..ROWS upward, so the # image's top-left pixel is (col 0, row ROWS) -- the top-left corner. x0, y0 = self._km_to_px(view, (0, ROWS)) x1, y1 = self._km_to_px(view, (COLS, 0)) ih, iw = buf.shape[:2] if iw <= 0 or ih <= 0: return cr.save() cr.translate(x0, y0) cr.scale((x1 - x0) / iw, (y1 - y0) / ih) cr.set_source_surface(surface, 0, 0) cr.get_source().set_filter(cairo.FILTER_GOOD) cr.paint_with_alpha(SCREENSHOT_ALPHA) cr.restore() def _draw_proposals(self, cr, view, width, height) -> None: """Detected-but-unconfirmed units. Drawn hollow, the same shape the map already uses for "this might be where it is", because that is exactly what a proposal is until the user accepts it.""" for p, coord in self._pending_proposals(): color = CATEGORY_COLOR["ally" if p.side == "friendly" else "target"] self._draw_marker(cr, view, coord.as_fraction(), color, f"? {coord.label()}", width, height, hollow=True, coord=coord) def _hit_test(self, view: _View, x: float, y: float): """Returns (obj, coord) of the nearest marker within range, or (None, None), coord disambiguates which candidate of an ambiguous obj was actually hit, since it can have several points.""" best_obj, best_coord, best_dist = None, None, HOVER_RADIUS_PX for obj, coord in self._all_positions(): px, py = self._km_to_px(view, coord.as_fraction()) dist = math.hypot(px - x, py - y) if dist < best_dist: best_dist, best_obj, best_coord = dist, obj, coord return best_obj, best_coord # -- hover / click ------------------------------------------------------------ def _on_motion(self, _controller, x: float, y: float) -> None: self._last_pointer_px = (x, y) view = self._view(self.get_width(), self.get_height()) cursor_km = self._px_to_km(view, x, y) if self.on_cursor_move is not None: self.on_cursor_move(cursor_km) col, row = cursor_km new_cell = (int(col), int(row)) if (0 <= col < COLS and 0 <= row < ROWS) else None if new_cell != self._hover_cell: self._hover_cell = new_cell self.queue_draw() if self.placement_callback is not None: self._placement_cursor_km = cursor_km self.queue_draw() return # no hover/select while placing, the map's just a target picker right now hit, coord = self._hit_test(view, x, y) if hit is not self.hovered or coord != self.hovered_point: self.hovered = hit self.hovered_point = coord self.queue_draw() if self.on_hover_change is not None: self.on_hover_change(hit, coord) def _on_leave(self, _controller) -> None: self._last_pointer_px = None if self.on_cursor_move is not None: self.on_cursor_move(None) if self._hover_cell is not None: self._hover_cell = None self.queue_draw() if self.placement_callback is not None: self._placement_cursor_km = None self.queue_draw() if self.hovered is not None: self.hovered = None self.hovered_point = None self.queue_draw() if self.on_hover_change is not None: self.on_hover_change(None, None) def _on_click(self, _gesture, _n_press, x: float, y: float) -> None: if self._drag_did_pan: # The GestureDrag that just finished actually panned the # view (past the jitter threshold), don't also treat its # release as a click-to-select, that would either select # whatever ended up under the cursor after the pan or # deselect the current selection, neither of which is what # a drag gesture was for. self._drag_did_pan = False return view = self._view(self.get_width(), self.get_height()) if self.placement_callback is not None: cursor_km = self._px_to_km(view, x, y) callback, kind = self.placement_callback, self.placement_kind 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: callback(coord) return proposal = self.hit_test_proposal(view, x, y) if proposal is not None and self.on_proposal_click is not None: self.on_proposal_click(proposal, x, y) return hit, coord = self._hit_test(view, x, y) self.set_selected(hit, coord) if self.on_select is not None: self.on_select(hit, coord) def _on_right_click(self, _gesture, _n_press, x: float, y: float) -> None: if self.placement_callback is not None: self.cancel_placement() return if self.on_right_click is None: return view = self._view(self.get_width(), self.get_height()) # A pending proposal wins over a board entity underneath it: it is the # thing the user is being asked to decide about, and it disappears as # soon as they do, so whatever it overlaps becomes reachable again. hit = self.hit_test_proposal(view, x, y) point = None if hit is None: hit, point = self._hit_test(view, x, y) coord = solver.point_to_coord(self._px_to_km(view, x, y)) if coord is None and hit is None: return self.on_right_click(coord, x, y, hit, point) # -- drawing ---------------------------------------------------------------- def _draw(self, _area, cr, width, height) -> None: cr.set_source_rgb(*BG) cr.paint() view = self._view(width, height) # Grid lines only for integer boundaries actually within the # visible viewport, not always 0..COLS/0..ROWS, once zoomed in # most of the grid isn't on screen at all. A previous version # over-generated a couple of lines past the true edge (meant to # cover a trailing partial cell, which doesn't need its own line # in the first place, just the whole-integer lines bounding it, # already included here) and those, drawn before anything gets # clipped, showed up as stray lines bleeding into the label # margin above/left of the actual grid. ceil/floor here means # every c/r produced is guaranteed to already land inside the # grid rectangle, nothing to clip. first_col, last_col = math.ceil(view.ox), math.floor(view.ox + view.vis_cols) first_row, last_row = math.ceil(view.oy), math.floor(view.oy + view.vis_rows) cr.set_source_rgba(*GRID_LINE) cr.set_line_width(1) for c in range(max(first_col, 0), min(last_col, COLS) + 1): x, _ = self._km_to_px(view, (c, 0)) cr.move_to(x, MARGIN_TOP + view.pad_y) cr.line_to(x, MARGIN_TOP + view.pad_y + view.grid_h) for r in range(max(first_row, 0), min(last_row, ROWS) + 1): _, y = self._km_to_px(view, (0, r)) cr.move_to(MARGIN_LEFT + view.pad_x, y) cr.line_to(MARGIN_LEFT + view.pad_x + view.grid_w, y) cr.stroke() # Column/row labels: one per whole large-cell that's at least # partly visible (its own span overlaps the viewport), not # pegged to the gridline boundaries above, a partially-visible # edge cell still gets its letter/number shown. Whichever # column/row the cursor is actually over gets called out # (accent color + underline), an easy way to read the current # cell off the legend at a glance instead of counting gridlines. hover_col, hover_row = self._hover_cell if self._hover_cell is not None else (None, None) cr.set_font_size(11) for i in range(max(math.floor(view.ox), 0), min(math.ceil(view.ox + view.vis_cols), COLS)): x, _ = self._km_to_px(view, (i + 0.5, 0)) label_x, label_y = x - 4, MARGIN_TOP + view.pad_y - 10 is_hover = i == hover_col cr.set_source_rgb(*(HOVER_LEGEND if is_hover else LABEL)) cr.move_to(label_x, label_y) cr.show_text(LARGE_X[i]) if is_hover: text_w = cr.text_extents(LARGE_X[i]).width cr.new_path() cr.set_line_width(1.5) cr.move_to(label_x, label_y + 3) cr.line_to(label_x + max(text_w, 6), label_y + 3) cr.stroke() for r in range(max(math.floor(view.oy), 0), min(math.ceil(view.oy + view.vis_rows), ROWS)): _, y = self._km_to_px(view, (0, r + 0.5)) label_x, label_y = 4, y + 4 is_hover = r == hover_row cr.set_source_rgb(*(HOVER_LEGEND if is_hover else LABEL)) cr.move_to(label_x, label_y) cr.show_text(str(r + 1)) if is_hover: text_w = cr.text_extents(str(r + 1)).width cr.new_path() cr.set_line_width(1.5) cr.move_to(label_x, label_y + 3) cr.line_to(label_x + max(text_w, 6), label_y + 3) cr.stroke() # Everything below projects a km position to a pixel one with no # inherent bound, an entity that's genuinely elsewhere on the # map (outside the current zoomed viewport) would otherwise # still get drawn whenever its projected pixel position happens # to land inside the canvas's own bounds (including the # letterbox padding bands), showing up as markers/lines that # look like they're floating off the visible grid. Clipping to # the grid's own drawable rectangle is a single fix for all of # it (markers, overlays, arrows, scout flights) rather than # teaching every draw call its own visibility check. cr.save() cr.rectangle(MARGIN_LEFT + view.pad_x, MARGIN_TOP + view.pad_y, view.grid_w, view.grid_h) cr.clip() # Under everything: the imported screenshot is the backdrop the rest of # the map is drawn on top of. if self._screenshot is not None: self._draw_screenshot(cr, view) self._draw_hover_subgrid(cr, view) self._draw_geo_overlays(cr, view) self._draw_firing_arrows(cr, view) self._draw_blast_radius(cr, view) self._draw_placement_preview(cr, view) for category, obj in self.board.placed_entities_all(): if self._excluded_from_map(obj): continue # hidden/dead-and-toggled-off entities are removed, not just darkened self._draw_marker(cr, view, obj.coord.as_fraction(), CATEGORY_COLOR[category], obj.name, width, height, dim=(category == "target" and not obj.alive) or obj.hidden, selected=(obj is self.selected), coord=obj.coord, extra_line=getattr(obj, "requested_time", None), icon_surface=_icon_for(category, obj)) for category, obj in self.board.ambiguous_entities_all(): if self._excluded_from_map(obj): continue color = CATEGORY_COLOR[category] for i, candidate in enumerate(obj.location.potential_coords): is_selected = obj is self.selected and candidate == self.selected_point self._draw_marker(cr, view, candidate.as_fraction(), color, f"{obj.name}? ({i + 1})", width, height, hollow=True, dim=obj.hidden or (category == "target" and not obj.alive), selected=is_selected, coord=candidate, extra_line=getattr(obj, "requested_time", None)) self._draw_proposals(cr, view, width, height) 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, view, sf.center, sf.bearing_deg) cx, cy = self._km_to_px(view, sf.center) start_coord = solver.point_to_coord(sf.center) grid_name = f"{start_coord.X}{start_coord.Y}" if start_coord is not None else "?" 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) cr.set_font_size(9) cr.set_source_rgba(*COORD_LABEL, 1.0) cr.move_to(cx + LABEL_PAD, cy + 5) cr.show_text(f"{grid_name} {sf.bearing_deg:05.1f}°") cr.set_font_size(11) cr.restore() def _draw_hover_subgrid(self, cr, view) -> None: """The fine 10x10 x:y subdivision lines for whichever large cell the cursor is currently over, for precise sub-cell targeting, most useful once zoomed in enough that a single large cell actually has room to show them meaningfully.""" if self._hover_cell is None: return cell_col, cell_row = self._hover_cell cr.set_source_rgba(*SUBGRID_LINE) cr.set_line_width(1) x0, y0 = self._km_to_px(view, (cell_col, cell_row)) x1, y1 = self._km_to_px(view, (cell_col + 1, cell_row + 1)) for i in range(1, 10): x, _ = self._km_to_px(view, (cell_col + i / 10, cell_row)) cr.move_to(x, y0) cr.line_to(x, y1) for i in range(1, 10): _, y = self._km_to_px(view, (cell_col, cell_row + i / 10)) cr.move_to(x0, y) cr.line_to(x1, y) cr.stroke() def _draw_marker(self, cr, view, point_km, color, label, canvas_width, canvas_height, *, hollow=False, dim=False, selected=False, coord=None, extra_line=None, icon_surface=None) -> None: x, y = self._km_to_px(view, point_km) r, g, b = color alpha = 0.45 if dim else 1.0 if selected: cr.new_path() cr.set_source_rgba(*SELECTION_RING, 0.9) cr.set_line_width(2) cr.arc(x, y, 9, 0, 2 * math.pi) cr.stroke() # An ambiguous candidate (hollow) always stays a dashed ring, # never the icon, that shape is deliberately how "this might be # where it is" reads, an icon there would look too confident # about a position that isn't actually confirmed. Below # ICON_MIN_CELL_PX a game icon would be an illegible smudge, a # plain filled dot is more honest about the current zoom level. if not hollow and icon_surface is not None and view.cell_w >= ICON_MIN_CELL_PX: self._draw_icon_marker(cr, x, y, icon_surface, alpha) elif hollow: cr.new_path() # cairo's arc() draws a line from any stale current cr.set_source_rgba(r, g, b, alpha) # point (e.g. the last label's cr.set_line_width(1.5) # show_text position) to the arc's start, cr.set_dash([3, 2]) # this is what stops that connector line. cr.arc(x, y, 5.5, 0, 2 * math.pi) cr.stroke() cr.set_dash([]) else: cr.new_path() cr.set_source_rgba(r, g, b, alpha) cr.arc(x, y, 5.5, 0, 2 * math.pi) cr.fill() cr.new_path() cr.set_source_rgba(0, 0, 0, 0.6 * alpha) cr.arc(x, y, 5.5, 0, 2 * math.pi) cr.set_line_width(1) cr.stroke() cr.set_font_size(11) text_width = cr.text_extents(label).width coord_text = coord.label() if coord is not None else None coord_width = cr.text_extents(coord_text).width if coord_text else 0 # Flip to the left of the marker if the label would run past the # right edge; clamp vertically so it doesn't clip top/bottom either. label_x = x + LABEL_PAD if label_x + max(text_width, coord_width) > canvas_width - 4: label_x = x - LABEL_PAD - max(text_width, coord_width) label_y = max(10, min(y - 7, canvas_height - 20)) cr.set_source_rgba(*LABEL, alpha) cr.move_to(label_x, label_y) cr.show_text(label) if coord_text: cr.set_font_size(9) cr.set_source_rgba(*COORD_LABEL, alpha) cr.move_to(label_x, label_y + 12) cr.show_text(coord_text) cr.set_font_size(11) if extra_line: cr.set_font_size(9) cr.set_source_rgba(*COORD_LABEL, alpha) cr.move_to(label_x, label_y + (24 if coord_text else 12)) cr.show_text(extra_line) cr.set_font_size(11) def _draw_icon_marker(self, cr, x, y, surface, alpha) -> None: """The game's own icon for this entity's type, centered on (x, y), scaled to a fixed box regardless of the source image's own resolution (uniform scale from whichever of width/height is larger, so the icon never comes out stretched).""" box = 32.0 sw, sh = surface.get_width(), surface.get_height() scale = box / max(sw, sh) cr.save() cr.translate(x - sw * scale / 2, y - sh * scale / 2) cr.scale(scale, scale) cr.set_source_surface(surface, 0, 0) cr.paint_with_alpha(alpha) cr.restore() def _draw_firing_arrows(self, cr, view) -> None: """Red arrow(s) Nest -> Target, for whatever's hovered or selected. Points at exactly the hovered/selected candidate when one is known (mouse over/click on a specific ambiguous marker) rather than every candidate of that target, same reasoning as the selection ring: drawing to all of them makes it impossible to tell which is which.""" nest = self.board.nest if nest.coord is None: return nest_km = nest.coord.as_fraction() points: list = [] for target, point in ((self.hovered, self.hovered_point), (self.selected, self.selected_point)): if not isinstance(target, Target): continue if target.coord is not None: points.append(target.coord) elif point is not None: points.append(point) else: points.extend(target.location.potential_coords) seen = set() for candidate in points: if candidate in seen: continue seen.add(candidate) tx, ty = self._km_to_px(view, candidate.as_fraction()) nx, ny = self._km_to_px(view, nest_km) cr.set_source_rgb(*FIRING_ARROW) cr.set_line_width(2) self._draw_arrow(cr, nx, ny, tx, ty) def _draw_blast_radius(self, cr, view) -> None: """Every Target's effective shell's blast radius, for whichever ones are selected or pinned via the same "always show geo" show_geo_desc toggle the bearing/distance overlay uses (not on plain hover, unlike that overlay -- a blast radius circle flickering in on every hover was judged too noisy, selection/ pinning is a deliberate choice). Uses the specific selected candidate point if an ambiguous target is the selected one; skipped per-target if there's no known point yet, or the shell's blast radius isn't known.""" targets = [ t for t in self.board.targets if not self._excluded_from_map(t) and (t is self.selected or t.show_geo_desc) ] for target in targets: point = target.coord if target.coord is not None else ( self.selected_point if target is self.selected else None ) if point is None: continue radius_km = target.effective_shell.blast_radius_km if radius_km is None: continue x, y = self._km_to_px(view, point.as_fraction()) rx, ry = view.cell_w * radius_km, view.cell_h * radius_km self._draw_ellipse(cr, x, y, rx, ry) cr.set_source_rgba(*BLAST_RADIUS, 0.18) cr.fill_preserve() cr.set_source_rgba(*BLAST_RADIUS, 0.85) cr.set_line_width(2) cr.stroke() def _draw_scout_flight_rect(self, cr, view, center_km, bearing_deg, *, dashed=False, alpha_mult=1.0) -> None: corners = solver.scout_flight_corners(center_km, bearing_deg) px_corners = [self._km_to_px(view, p) 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, view) -> None: """While armed to place/reposition something, a small crosshair dot 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, view, center_km, bearing, dashed=True) x, y = self._km_to_px(view, self._placement_cursor_km) if self.placement_preview_radius_km is not None: rx, ry = view.cell_w * self.placement_preview_radius_km, view.cell_h * self.placement_preview_radius_km self._draw_ellipse(cr, x, y, rx, ry) cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.15) cr.fill_preserve() cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.8) cr.set_line_width(1.5) cr.set_dash([3, 2]) cr.stroke() cr.set_dash([]) cr.new_path() cr.set_source_rgba(*PLACEMENT_PREVIEW, 0.9) cr.set_line_width(1.5) cr.move_to(x - 7, y) cr.line_to(x + 7, y) cr.move_to(x, y - 7) cr.line_to(x, y + 7) cr.stroke() def _draw_geo_overlays(self, cr, view) -> None: """Bearing/distance overlay lines for whatever's hovered, pinned via show_geo_desc, or currently selected. Selection matters even for a target that never resolved at all (no coord, no potential_coords, e.g. two clues that don't quite geometrically agree), it's not in placed_entities()/ambiguous_entities() either way, so this looks at every RP/Target directly rather than those, the only way to let the user eyeball a bad-but-close reading against what it should have crossed.""" # Nest/Spotter never carry clues (always given as a direct grid # coord, no relative-bearing mechanic for them), so leaving them # out here wouldn't visibly change anything -- but Allies DO get # clues from OCR ("FriendlyTank#1 Spotted. 088, 12.10km from # Spotter#1") and also have a show_geo_desc pin in the UI (see # app.py's per-card "always show geo" toggle), so omitting them # here meant pinning one silently did nothing. candidates = list(self.board.reference_points) + list(self.board.targets) + list(self.board.allies) to_show = [ obj for obj in candidates if obj.location.clues and not self._excluded_from_map(obj) and (obj is self.hovered or obj.show_geo_desc or obj is self.selected) ] for obj in to_show: for clue in obj.location.clues: ref = self.board.find_by_name(clue.reference) if ref is None or ref.coord is None: continue ref_km = ref.coord.as_fraction() rx, ry = self._km_to_px(view, ref_km) if clue.bearing_deg is not None and clue.distance_km is not None: target_km = solver.point_from_bearing_distance(ref_km, clue.bearing_deg, clue.distance_km) tx, ty = self._km_to_px(view, target_km) cr.set_source_rgb(*YELLOW) cr.set_line_width(2) self._draw_arrow(cr, rx, ry, tx, ty) elif clue.bearing_deg is not None and clue.bearing_tolerance_deg is not None: # A compass word ('West') names a whole sector, not a # single ray, solve_location() never tries to # triangulate this into an exact point (see its own # docstring), draw the actual sector instead of # pretending it's more precise than it is. lo_km = solver.point_from_bearing_distance( ref_km, clue.bearing_deg - clue.bearing_tolerance_deg, OVERLAY_RAY_LENGTH_KM) hi_km = solver.point_from_bearing_distance( ref_km, clue.bearing_deg + clue.bearing_tolerance_deg, OVERLAY_RAY_LENGTH_KM) lx, ly = self._km_to_px(view, lo_km) hx, hy = self._km_to_px(view, hi_km) cr.new_path() cr.move_to(rx, ry) cr.line_to(lx, ly) cr.line_to(hx, hy) cr.close_path() cr.set_source_rgba(*YELLOW, 0.15) cr.fill_preserve() cr.set_source_rgba(*YELLOW, 0.85) cr.set_line_width(1.5) cr.set_dash([3, 2]) cr.stroke() cr.set_dash([]) elif clue.bearing_deg is not None: far_km = solver.point_from_bearing_distance(ref_km, clue.bearing_deg, OVERLAY_RAY_LENGTH_KM) fx, fy = self._km_to_px(view, far_km) cr.set_source_rgb(*YELLOW) cr.set_line_width(1.5) cr.move_to(rx, ry) cr.line_to(fx, fy) cr.stroke() elif clue.distance_km is not None: radius_x, radius_y = view.cell_w * clue.distance_km, view.cell_h * clue.distance_km self._draw_ellipse(cr, rx, ry, radius_x, radius_y) cr.set_source_rgba(*WHITE, 0.85) cr.set_line_width(1.5) cr.stroke() # Radius indicator: a line from center to an actual point # on the circle, the intersection with another of this # entity's clues if one pairs with it (same geometry the # solver would use; picks the nearer of two candidates), # else straight up as a last-resort fallback with nothing # to intersect against yet. radius_target_km = None for other in obj.location.clues: if other is clue or other.bearing_deg is None or other.distance_km is not None: continue other_ref = self.board.find_by_name(other.reference) if other_ref is None or other_ref.coord is None: continue points = solver.ray_circle_intersections( other_ref.coord.as_fraction(), other.bearing_deg, ref_km, clue.distance_km ) if points: radius_target_km = points[0] break if radius_target_km is None: radius_target_km = (ref_km[0], ref_km[1] + clue.distance_km) tx, ty = self._km_to_px(view, radius_target_km) cr.new_path() cr.set_source_rgba(*WHITE, 0.85) cr.set_line_width(1) cr.set_dash([1.5, 2.5]) cr.move_to(rx, ry) cr.line_to(tx, ty) cr.stroke() cr.set_dash([]) def _draw_arrow(self, cr, x0, y0, x1, y1, head_size=8) -> None: cr.new_path() cr.move_to(x0, y0) cr.line_to(x1, y1) cr.stroke() angle = math.atan2(y1 - y0, x1 - x0) cr.new_path() for delta in (math.pi * 5 / 6, -math.pi * 5 / 6): cr.move_to(x1, y1) cr.line_to(x1 + head_size * math.cos(angle + delta), y1 + head_size * math.sin(angle + delta)) cr.stroke() def _draw_ellipse(self, cr, cx, cy, rx, ry, steps=72) -> None: # Points computed explicitly (not via cr.scale) so the stroke width # stays uniform regardless of rx/ry, a scaled CTM would stretch it. cr.new_path() cr.move_to(cx + rx, cy) for i in range(1, steps + 1): theta = 2 * math.pi * i / steps cr.line_to(cx + rx * math.cos(theta), cy + ry * math.sin(theta))