"Accept as..." (proposal type-picker) and "Change type" (entity-edit) opened to a visibly empty/unchanged popover with no traceback: swapping an already-open Popover's child and re-popup()ing it reported the right size internally but the compositor never repainted the reused surface. Confirmed live via temporary debug instrumentation, not guessed. Fixed by popping the old popover down and opening a genuinely new one at the same anchor point instead of resizing in place. Also adds a Target.underground_tier (1-3) marker: a "Mark underground" entry in the entity-edit popover, rendered as the game's own Armor-tier additive badge stacked directly on the unit icon. The badge is scaled/positioned off its real opaque content (PIL bbox), not its PNG canvas, since the additive art carries a lot of off-center transparent padding; and overlaps down into the icon by a fixed pixel amount, since both shapes taper to a point at the seam and exact bbox-touching still read as a visible gap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1299 lines
59 KiB
Python
1299 lines
59 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
|
|
from PIL import Image as PILImage
|
|
|
|
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
|
|
|
|
|
|
# path -> (surface, content_bbox) for additive badges specifically.
|
|
# Separate from _ICON_SURFACE_CACHE because these also need their real
|
|
# opaque content's bounding box: unlike the unit icons (already ~edge to
|
|
# edge in their own canvas, see _draw_icon_marker), the additive art
|
|
# (assets/icons/targets/additives/) sits inside a lot of transparent
|
|
# padding that isn't even centered -- scaling/positioning off the full
|
|
# 256x256 canvas made the badge look tiny and float with a visible gap
|
|
# above the icon it's supposed to touch. bbox is None for a path that
|
|
# failed to load, or (l, t, r, b) of its actual opaque pixels.
|
|
_ADDITIVE_CACHE: dict = {}
|
|
|
|
|
|
def _additive_surface(path) -> tuple:
|
|
if path not in _ADDITIVE_CACHE:
|
|
surface, bbox = None, None
|
|
try:
|
|
surface = cairo.ImageSurface.create_from_png(str(path))
|
|
bbox = PILImage.open(str(path)).getbbox()
|
|
except Exception:
|
|
pass
|
|
_ADDITIVE_CACHE[path] = (surface, bbox)
|
|
return _ADDITIVE_CACHE[path]
|
|
|
|
|
|
def _additive_for(category: str, obj) -> tuple | None:
|
|
"""The underground-tier badge overlaid on top of a Target's own icon,
|
|
or None. Target-only (see Target.underground_tier's own comment)."""
|
|
if category != "target":
|
|
return None
|
|
tier = getattr(obj, "underground_tier", None)
|
|
if tier is None:
|
|
return None
|
|
surface, bbox = _additive_surface(icons.underground_icon_path(tier))
|
|
if surface is None:
|
|
return None
|
|
return (surface, bbox)
|
|
|
|
|
|
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),
|
|
additive=_additive_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,
|
|
additive=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)
|
|
if additive is not None:
|
|
self._draw_additive_badge(cr, x, y, additive, 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()
|
|
|
|
# How far the badge's content bbox sinks into the icon's, in the
|
|
# icon's own 32px box units. Both the diamond's top corner and the
|
|
# Armor badge's bottom are tapered to a near-point, not a flat edge
|
|
# (see assets/icons/targets/enemy/Enemy_Infantry.png and the Armor
|
|
# additives) -- lining up their bboxes exactly *touching* leaves them
|
|
# meeting at a single pixel with no visual mass on either side of it,
|
|
# which still reads as a gap. A real pixel overlap is what actually
|
|
# looks contiguous, confirmed against the game's own stacked-badge
|
|
# screenshots (stars/helmet/diamond all overlapping, not edge-to-edge).
|
|
_ADDITIVE_OVERLAP_PX = 10.0
|
|
|
|
def _draw_additive_badge(self, cr, x, y, additive, alpha) -> None:
|
|
"""A badge (underground tier, currently the only additive) drawn
|
|
directly north of the icon marker, overlapping down into it by
|
|
`_ADDITIVE_OVERLAP_PX`, at the same full size as the marker
|
|
itself -- stacked above it rather than shrunk into a corner, so
|
|
it reads as its own clearly-legible symbol, not a tiny decoration
|
|
obscuring the unit icon it modifies.
|
|
|
|
Scaled/positioned off the source art's actual opaque content
|
|
(`bbox`), not its full canvas: the additive PNGs carry a lot of
|
|
transparent padding that isn't even centered (see _ADDITIVE_CACHE's
|
|
comment), so sizing/placing off the raw canvas made the badge look
|
|
tiny and float with a visible gap above the icon -- using bbox
|
|
instead makes what's actually drawn sit right against it."""
|
|
surface, bbox = additive
|
|
sw, sh = surface.get_width(), surface.get_height()
|
|
left, top, right, bottom = bbox if bbox is not None else (0, 0, sw, sh)
|
|
content_w, content_h = right - left, bottom - top
|
|
if content_w <= 0 or content_h <= 0:
|
|
return
|
|
box = 32.0 # same visual size as the icon marker's own box
|
|
scale = box / max(content_w, content_h)
|
|
icon_top = y - 16 # _draw_icon_marker's own box=32, centered on y
|
|
ty = icon_top - bottom * scale + self._ADDITIVE_OVERLAP_PX
|
|
cr.save()
|
|
cr.translate(x - (left + right) / 2 * scale, ty)
|
|
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))
|