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:
parent
0a54178723
commit
e352dff531
BIN
assets/icons/misc/Crosshair.png
Normal file
BIN
assets/icons/misc/Crosshair.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@ -9,13 +9,14 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
|
import cairo
|
||||||
import gi
|
import gi
|
||||||
|
|
||||||
gi.require_version("Gtk", "4.0")
|
gi.require_version("Gtk", "4.0")
|
||||||
gi.require_version("Gdk", "4.0")
|
gi.require_version("Gdk", "4.0")
|
||||||
from gi.repository import Gdk, Gtk # noqa: E402
|
from gi.repository import Gdk, Gtk # noqa: E402
|
||||||
|
|
||||||
from . import ballistics, solver
|
from . import ballistics, icons, solver
|
||||||
from .models import LARGE_X, Board, Target
|
from .models import LARGE_X, Board, Target
|
||||||
|
|
||||||
COLS, ROWS = 20, 10
|
COLS, ROWS = 20, 10
|
||||||
@ -34,6 +35,11 @@ MIN_ZOOM = 1.0 # the whole 20x10 map fits, the default
|
|||||||
MAX_ZOOM = 10.0
|
MAX_ZOOM = 10.0
|
||||||
ZOOM_STEP = 1.15 # per scroll-wheel notch
|
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
|
# Everything needed to convert between km-space (the 20x10 grid) and
|
||||||
# widget pixels for one frame, bundled so every draw/hit-test method
|
# 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
|
# 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)
|
BLAST_RADIUS = (0.95, 0.40, 0.10)
|
||||||
PLACEMENT_PREVIEW = (0.95, 0.85, 0.20)
|
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):
|
class GridCanvas(Gtk.DrawingArea):
|
||||||
def __init__(self, board: Board) -> None:
|
def __init__(self, board: Board) -> None:
|
||||||
@ -528,7 +564,8 @@ class GridCanvas(Gtk.DrawingArea):
|
|||||||
obj.name, width, height,
|
obj.name, width, height,
|
||||||
dim=(category == "target" and not obj.alive) or obj.hidden,
|
dim=(category == "target" and not obj.alive) or obj.hidden,
|
||||||
selected=(obj is self.selected), coord=obj.coord,
|
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():
|
for category, obj in self.board.ambiguous_entities_all():
|
||||||
if self._excluded_from_map(obj):
|
if self._excluded_from_map(obj):
|
||||||
@ -565,7 +602,7 @@ class GridCanvas(Gtk.DrawingArea):
|
|||||||
|
|
||||||
def _draw_marker(self, cr, view, point_km, color, label,
|
def _draw_marker(self, cr, view, point_km, color, label,
|
||||||
canvas_width, canvas_height, *, hollow=False, dim=False,
|
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)
|
x, y = self._km_to_px(view, point_km)
|
||||||
r, g, b = color
|
r, g, b = color
|
||||||
alpha = 0.45 if dim else 1.0
|
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.arc(x, y, 9, 0, 2 * math.pi)
|
||||||
cr.stroke()
|
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.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_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_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.show_text(extra_line)
|
||||||
cr.set_font_size(11)
|
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:
|
def _draw_firing_arrows(self, cr, view) -> None:
|
||||||
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected.
|
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected.
|
||||||
Points at exactly the hovered/selected candidate when one is known
|
Points at exactly the hovered/selected candidate when one is known
|
||||||
|
|||||||
@ -17,6 +17,7 @@ gi.require_version("GdkPixbuf", "2.0")
|
|||||||
gi.require_version("Gtk", "4.0")
|
gi.require_version("Gtk", "4.0")
|
||||||
from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402
|
from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402
|
||||||
|
|
||||||
|
from .models import TargetType
|
||||||
from .shells import Shell
|
from .shells import Shell
|
||||||
|
|
||||||
_ICONS_DIR = Path(__file__).resolve().parent.parent.parent / "assets" / "icons"
|
_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"
|
_ICON_BUTTON_CSS_CLASS = "fenigma-icon-button"
|
||||||
_css_loaded = False
|
_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:
|
def _ensure_icon_button_css() -> None:
|
||||||
"""A plain 'flat' Gtk.Button still carries libadwaita's normal button
|
"""A plain 'flat' Gtk.Button still carries libadwaita's normal button
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user