From e352dff5310e614a0148e7eb71bc9d8eed7f2030 Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Sun, 9 Aug 2026 20:45:15 +0200 Subject: [PATCH] Map: render entity game icons instead of plain dots once zoomed in Below ICON_MIN_CELL_PX (42px cell width) everything still draws as the plain colored dot it always has, a game icon at that size would just be an illegible smudge. Past that threshold, Nest/Target/Ally markers switch to the game's own unit icon for their type, drawn as a Cairo surface (raw Cairo draw_func, not GTK widgets, so this loads PNGs directly via cairo.ImageSurface.create_from_png(), cached per path so a repeat draw doesn't re-hit disk). icons.py's target_icon_path(type, is_ally) is the TargetType -> icon file mapping, best-effort guesses the same way the Shell descriptions were (flagged for correction): most types map onto the game's own Enemy_*/Friendly_* unit icons (is_ally picks which set, falling back to Enemy_ if a given type has no Friendly_ counterpart), TANK reuses the Armor_Mechanized artwork (no dedicated tank icon exists), and STRIKE gets its own crosshair (assets/icons/misc/Crosshair.png, not a unit icon at all, a planned impact point) rather than a unit icon. An ambiguous candidate (hollow, dashed-ring marker) never switches to the icon regardless of zoom, an icon there would look more confident about an unconfirmed position than the dashed ring is supposed to convey. Verified with rendered screenshots at both zoom levels: plain dots below the threshold, real icons above it (Tank/Infantry/MarineGarrison/ Nest all confirmed showing their correct icons), and the Strike crosshair specifically. --- assets/icons/misc/Crosshair.png | Bin 0 -> 17685 bytes src/fenigma/grid_widget.py | 68 ++++++++++++++++++++++++++++++-- src/fenigma/icons.py | 45 +++++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 assets/icons/misc/Crosshair.png diff --git a/assets/icons/misc/Crosshair.png b/assets/icons/misc/Crosshair.png new file mode 100644 index 0000000000000000000000000000000000000000..8bacb00cb6ea10a9603773360f879c150bc1c951 GIT binary patch literal 17685 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 literal 0 HcmV?d00001 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