diff --git a/assets/icons/misc/Crosshair.png b/assets/icons/misc/Crosshair.png new file mode 100644 index 0000000..8bacb00 Binary files /dev/null and b/assets/icons/misc/Crosshair.png differ diff --git a/src/fenigma/grid_widget.py b/src/fenigma/grid_widget.py index f411de8..fafdf99 100644 --- a/src/fenigma/grid_widget.py +++ b/src/fenigma/grid_widget.py @@ -9,13 +9,14 @@ from __future__ import annotations import math from collections import namedtuple +import cairo import gi gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") from gi.repository import Gdk, Gtk # noqa: E402 -from . import ballistics, solver +from . import ballistics, icons, solver from .models import LARGE_X, Board, Target COLS, ROWS = 20, 10 @@ -34,6 +35,11 @@ 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 @@ -66,6 +72,36 @@ SELECTION_RING = (1.0, 1.0, 1.0) BLAST_RADIUS = (0.95, 0.40, 0.10) PLACEMENT_PREVIEW = (0.95, 0.85, 0.20) +# 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: @@ -528,7 +564,8 @@ class GridCanvas(Gtk.DrawingArea): 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)) + 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): @@ -565,7 +602,7 @@ class GridCanvas(Gtk.DrawingArea): 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) -> None: + 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 @@ -577,7 +614,15 @@ class GridCanvas(Gtk.DrawingArea): cr.arc(x, y, 9, 0, 2 * math.pi) cr.stroke() - if hollow: + # 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, @@ -626,6 +671,21 @@ class GridCanvas(Gtk.DrawingArea): 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 diff --git a/src/fenigma/icons.py b/src/fenigma/icons.py index c302ff6..171b53a 100644 --- a/src/fenigma/icons.py +++ b/src/fenigma/icons.py @@ -17,6 +17,7 @@ gi.require_version("GdkPixbuf", "2.0") gi.require_version("Gtk", "4.0") from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402 +from .models import TargetType from .shells import Shell _ICONS_DIR = Path(__file__).resolve().parent.parent.parent / "assets" / "icons" @@ -24,6 +25,50 @@ _ICONS_DIR = Path(__file__).resolve().parent.parent.parent / "assets" / "icons" _ICON_BUTTON_CSS_CLASS = "fenigma-icon-button" _css_loaded = False +NEST_ICON_PATH = _ICONS_DIR / "nest" / "IronNest.png" +STRIKE_ICON_PATH = _ICONS_DIR / "misc" / "Crosshair.png" + +# TargetType -> the shared basename suffix, after 'Enemy_'/'Friendly_', +# for whichever of the game's own unit icons fits best. Best-effort +# guesses (flagged the same way the Shell descriptions were): the game +# doesn't have a dedicated icon for every one of our types, TANK reuses +# the Armor_Mechanized artwork, and FDC/PILLBOX/ENEMY only exist on the +# enemy side at all (see target_icon_path()'s fallback). No entry means +# no icon exists worth drawing, the caller falls back to a plain dot +# (only UNKNOWN now, STRIKE has its own crosshair below). +_TARGET_ICON_BASENAME = { + TargetType.SUPPLY_CACHE: "Ammunition Cache.png", + TargetType.FDC: "Fire Direction Center.png", + TargetType.INFANTRY: "Infantry.png", + TargetType.MECHANIZED: "Armor_Mechanized.png", + TargetType.ARTILLERY: "Field Artillery.png", + TargetType.TANK: "Armor_Mechanized.png", + TargetType.PILLBOX: "Heavy_Gun_Bunker.png", + TargetType.MARINE_GARRISON: "Marine.png", + TargetType.ENEMY: "Base.png", +} + + +def target_icon_path(target_type: TargetType, is_ally: bool = False) -> Path | None: + """Icon file for a Target or Ally's type, or None if there isn't a + good one. `is_ally` picks the Friendly_ set over the Enemy_ one, + falling back to Enemy_ if that particular basename has no friendly + version (the two sets aren't the same size, see assets/icons/ + README.md). STRIKE (a planned impact point, not a unit) gets its + own crosshair rather than a unit icon, it doesn't fit the Enemy_/ + Friendly_ naming scheme at all.""" + if target_type is TargetType.STRIKE: + return STRIKE_ICON_PATH + basename = _TARGET_ICON_BASENAME.get(target_type) + if basename is None: + return None + if is_ally: + friendly = _ICONS_DIR / "targets" / "friendly" / f"Friendly_{basename}" + if friendly.exists(): + return friendly + enemy = _ICONS_DIR / "targets" / "enemy" / f"Enemy_{basename}" + return enemy if enemy.exists() else None + def _ensure_icon_button_css() -> None: """A plain 'flat' Gtk.Button still carries libadwaita's normal button