- Board.clear() now also drops allies; the "clear board?" guard checks
allies too. New Board.clear_units() + Clear button right-click menu
("clear enemies, units & flights", keeps Nest/spotters/RPs).
- An Ally with the ad-hoc TargetType.ENEMY showed "Enemy" on the map
popover/toast instead of "Ally" (icons.target_type_label already had
the fix for the picker, now reused everywhere else via app.py's
_display_name).
- Firing panel drag-reorder no longer triggers a full app refresh
(solver + dedupe + map redraw) on every drop, just a local rebuild.
- "Always show geo" didn't draw for Allies (missing from the overlay
candidate list); blast radius only respected selection, not the
show_geo_desc pin.
- ocr.py: added a second fire-support-request grammar ("Infantry#N
taking fire ... Requesting X Shell on our position at <coord> before
<time>", plus a bearing/distance-from-position variant), distinct
from the existing Marine Garrison one.
- New debug_capture.py: saves screenshots (+ metadata) the app handled
badly, for later tuning of map_vision/ocr against real failures:
map-read errors, user grid corrections (paired with the auto-detected
grid), screenshots that read as text but may have been a map, and
marker-detection ground truth (every proposal's accept/reject verdict
plus units added with no matching proposal) captured whenever a
screenshot stops being the active one.
- README: Known issues section (map screenshot reading, grid + unit
detection, is unreliable and fails often).
- 14 new tests (tests/test_models.py, tests/test_debug_capture.py, +
additions to tests/test_ocr.py), 38/38 passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1214 lines
55 KiB
Python
1214 lines
55 KiB
Python
"""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))
|