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.
This commit is contained in:
Dominik Moritz Roth 2026-08-09 20:45:15 +02:00
parent 0a54178723
commit e352dff531
3 changed files with 109 additions and 4 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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

View File

@ -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