diff --git a/TODO.md b/TODO.md index c1262db..3ea6eb2 100644 --- a/TODO.md +++ b/TODO.md @@ -119,6 +119,35 @@ Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input accept attempt after that silently died before the ally/target ever got added, popover already closed by the time it happened. Fixed there; not a separate bug. +- [x] "Accept as…" (the type-picker submenu on a proposal, and "Change + type" on an already-placed entity) opening to a visibly empty/ + unchanged popover. This one left no traceback at all -- confirmed + live with temporary debug prints that the button's `clicked` signal + fires, the icon grid builds successfully (all N types), and + `Popover.set_child()` on the already-open outer popover reports the + right `visible=True`/width/height afterward... but the compositor + never actually repaints that reused surface, so nothing new ever + appeared on screen. Fixed by not resizing the existing open + popover at all: popping it down and opening a genuinely new one + (fresh native surface) at the same anchor point instead. Same fix + applied to both call sites (`_open_proposal_menu`'s `show_type`, + `_open_entity_menu`'s `show_type`, the latter refactored to share + the same `_reopen_with()` helper). +- [x] New: mark a Target as underground, at a hardening tier (1-3), + rendered as the game's own Armor-tier additive badge stacked on + the icon. `Target.underground_tier: int | None`, a "Mark + underground" entry in the entity-edit popover (tier picker reusing + the same fresh-popover fix above), and `GridCanvas` draws the + badge above the marker's icon, overlapping down into it by + `_ADDITIVE_OVERLAP_PX` -- both the diamond icon's top corner and + the badge's bottom are tapered to a near-point, not a flat edge, + so bbox-exact touching still read as a gap; a real pixel overlap + is what actually looks contiguous (confirmed against the game's + own stacked-badge screenshots). Badge is scaled/positioned off the + art's real opaque content (PIL `getbbox()`), not its PNG canvas -- + the additive files carry a lot of off-center transparent padding + that made the badge look tiny and floating if sized off the raw + canvas. ## Needs more scope / your input before I keep going diff --git a/assets/icons/targets/additives/Additive_1Star.png b/assets/icons/targets/additives/Additive_1Star.png new file mode 100644 index 0000000..8f43361 Binary files /dev/null and b/assets/icons/targets/additives/Additive_1Star.png differ diff --git a/assets/icons/targets/additives/Additive_2Star.png b/assets/icons/targets/additives/Additive_2Star.png new file mode 100644 index 0000000..811ca0d Binary files /dev/null and b/assets/icons/targets/additives/Additive_2Star.png differ diff --git a/assets/icons/targets/additives/Additive_3Star.png b/assets/icons/targets/additives/Additive_3Star.png new file mode 100644 index 0000000..a85f47e Binary files /dev/null and b/assets/icons/targets/additives/Additive_3Star.png differ diff --git a/assets/icons/targets/additives/Additive_4Star.png b/assets/icons/targets/additives/Additive_4Star.png new file mode 100644 index 0000000..73bc6be Binary files /dev/null and b/assets/icons/targets/additives/Additive_4Star.png differ diff --git a/assets/icons/targets/additives/Additive_5Star.png b/assets/icons/targets/additives/Additive_5Star.png new file mode 100644 index 0000000..526d95c Binary files /dev/null and b/assets/icons/targets/additives/Additive_5Star.png differ diff --git a/assets/icons/targets/additives/Additive_Armor1.png b/assets/icons/targets/additives/Additive_Armor1.png new file mode 100644 index 0000000..23a0487 Binary files /dev/null and b/assets/icons/targets/additives/Additive_Armor1.png differ diff --git a/assets/icons/targets/additives/Additive_Armor2.png b/assets/icons/targets/additives/Additive_Armor2.png new file mode 100644 index 0000000..4ff94b5 Binary files /dev/null and b/assets/icons/targets/additives/Additive_Armor2.png differ diff --git a/assets/icons/targets/additives/Additive_Armor3.png b/assets/icons/targets/additives/Additive_Armor3.png new file mode 100644 index 0000000..b0c6090 Binary files /dev/null and b/assets/icons/targets/additives/Additive_Armor3.png differ diff --git a/src/fenigma/app.py b/src/fenigma/app.py index 91e4b50..623695a 100644 --- a/src/fenigma/app.py +++ b/src/fenigma/app.py @@ -14,6 +14,8 @@ from __future__ import annotations import io import json +import pickle +import signal import tempfile from pathlib import Path @@ -272,6 +274,15 @@ class MainWindow(Adw.ApplicationWindow): self._import_job = None # in-flight map_import.ImportJob, if any self.screenshot_import = None # the map screenshot currently on the board + # Dev-only: SIGUSR1 pickles {board, screenshot_import} to a fixed + # path so a `kill -USR1` + relaunch (e.g. while bisecting a live + # bug) can restore the in-progress board/screenshot/proposals + # instead of losing them. One-shot: the restore consumes and + # deletes the file. Not wired to any UI -- debugging aid only. + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGUSR1, + self._dev_dump_session_for_restart) + self._dev_maybe_restore_session() + self.toast_overlay = Adw.ToastOverlay() self.set_content(self.toast_overlay) @@ -685,6 +696,48 @@ class MainWindow(Adw.ApplicationWindow): self._drop_shot_btn.set_visible(imp is not None) self._accept_all_btn.set_sensitive(bool(imp is not None and imp.pending())) + _DEV_SESSION_PATH = Path(tempfile.gettempdir()) / "fenigma_dev_session.pkl" + + def _dev_dump_session_for_restart(self, *_a) -> bool: + """SIGUSR1 handler: pickle {board, screenshot_import} so a + following relaunch can pick this session right back up. See the + SIGUSR1 registration in __init__ for why this exists.""" + try: + with open(self._DEV_SESSION_PATH, "wb") as f: + pickle.dump({"board": self.board, "screenshot_import": self.screenshot_import}, f) + print(f"fenigma: dev session dumped to {self._DEV_SESSION_PATH}", flush=True) + except Exception as exc: + print(f"fenigma: dev session dump failed: {exc!r}", flush=True) + return GLib.SOURCE_CONTINUE + + def _dev_maybe_restore_session(self) -> None: + """Counterpart to `_dev_dump_session_for_restart`: one-shot restore + on startup if a dump is sitting there. Sets `self.board`/ + `self.screenshot_import` directly (before the rest of __init__ + builds the widgets that reference them) but defers the actual + redraw to an idle callback, since `self.canvas` doesn't exist yet + at this point in __init__.""" + if not self._DEV_SESSION_PATH.exists(): + return + try: + with open(self._DEV_SESSION_PATH, "rb") as f: + data = pickle.load(f) + self.board = data["board"] + self.screenshot_import = data["screenshot_import"] + self._DEV_SESSION_PATH.unlink() + print("fenigma: dev session restored", flush=True) + except Exception as exc: + print(f"fenigma: dev session restore failed: {exc!r}", flush=True) + return + GLib.idle_add(self._dev_finish_session_restore) + + def _dev_finish_session_restore(self) -> bool: + self._refresh() + self._refresh_proposals() + if self.screenshot_import is not None: + self.canvas.set_screenshot(self.screenshot_import.overlay, self.screenshot_import.px_per_km) + return GLib.SOURCE_REMOVE + def _accept_proposal(self, proposal, type_=None) -> None: coord = _coord_from_proposal(proposal) if coord is None: @@ -794,15 +847,29 @@ class MainWindow(Adw.ApplicationWindow): # real pickable unit types (see their own comments in # models.py) and shouldn't have been offered as "what this # detected marker actually is". + grid = icons.build_target_type_grid( + detected, lambda t: accept(t), is_ally=(proposal.side == "friendly"), + ) scroller = Gtk.ScrolledWindow(propagate_natural_height=True, propagate_natural_width=True, max_content_height=340, hscrollbar_policy=Gtk.PolicyType.NEVER) - scroller.set_child(icons.build_target_type_grid( - detected, lambda t: accept(t), is_ally=(proposal.side == "friendly"), - )) + scroller.set_child(grid) box.append(scroller) + # Swapping the child of an ALREADY-open Popover and re-popup()ing + # it reports the right size internally (visible=True, sane + # width/height) but the compositor never actually repaints the + # reused surface -- confirmed live: nothing appears on screen no + # matter how many times it's reopened. Popping the OLD popover + # down and opening a genuinely NEW one (fresh native surface, + # same anchor point) instead of resizing the existing one + # sidesteps that. + nonlocal popover + old_popover = popover + popover = self._popover_at(x, y) popover.set_child(box) + old_popover.popdown() + popover.popup() show_main() popover.popup() @@ -1521,10 +1588,31 @@ class MainWindow(Adw.ApplicationWindow): # panel's own alive button offers, just reachable from the # map too rather than only from the sidebar. button(box, "Mark destroyed" if obj.alive else "Mark alive", toggle_alive) + # Underground is Target-only too, see Target.underground_tier's + # own comment -- no such thing as an underground Ally. + ug_label = ("Mark underground" if obj.underground_tier is None + else f"Underground (tier {obj.underground_tier})") + button(box, ug_label, show_underground) if not isinstance(obj, Nest): button(box, "Delete", delete, css="destructive-action") popover.set_child(box) + def _reopen_with(box) -> None: + """Swapping an ALREADY-open Popover's child via set_child() + alone reports the right size (visible=True, sane width/height) + but the compositor never actually repaints the reused surface + on some setups -- confirmed live, nothing appears on screen no + matter how many times it's reopened. Popping the OLD popover + down and opening a genuinely NEW one at the same anchor point + (fresh native surface, not an in-place resize) sidesteps it. + Shared by every page past show_main() in this menu.""" + nonlocal popover + old_popover = popover + popover = self._popover_at(x, y) + popover.set_child(box) + old_popover.popdown() + popover.popup() + def show_type(): box = page() heading(box, "Type") @@ -1542,7 +1630,7 @@ class MainWindow(Adw.ApplicationWindow): obj.type, lambda t: set_type(t), is_ally=isinstance(obj, Ally), )) box.append(scroller) - popover.set_child(box) + _reopen_with(box) def set_type(t): obj.type = t @@ -1551,6 +1639,26 @@ class MainWindow(Adw.ApplicationWindow): self.toast(f"{_display_name(obj)} is now a " f"{icons.target_type_label(t, isinstance(obj, Ally))}.") + def show_underground(): + box = page() + heading(box, "Underground") + scroller = Gtk.ScrolledWindow(propagate_natural_height=True, + propagate_natural_width=True, + max_content_height=340, + hscrollbar_policy=Gtk.PolicyType.NEVER) + scroller.set_child(icons.build_underground_tier_grid( + obj.underground_tier, lambda tier: set_underground(tier), + )) + box.append(scroller) + _reopen_with(box) + + def set_underground(tier): + obj.underground_tier = tier + self._refresh() + popover.popdown() + self.toast(f"{_display_name(obj)} is no longer underground." if tier is None + else f"{_display_name(obj)} is now underground (tier {tier}).") + def show_id(): box = page() heading(box, "ID") diff --git a/src/fenigma/grid_widget.py b/src/fenigma/grid_widget.py index 60843e1..005a4ed 100644 --- a/src/fenigma/grid_widget.py +++ b/src/fenigma/grid_widget.py @@ -12,6 +12,7 @@ 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") @@ -195,6 +196,44 @@ def _icon_for(category: str, obj) -> cairo.ImageSurface | None: 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__() @@ -817,7 +856,8 @@ class GridCanvas(Gtk.DrawingArea): 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)) + 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): @@ -878,7 +918,8 @@ 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, icon_surface=None) -> None: + 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 @@ -898,6 +939,8 @@ class GridCanvas(Gtk.DrawingArea): # 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 @@ -962,6 +1005,48 @@ class GridCanvas(Gtk.DrawingArea): 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 diff --git a/src/fenigma/icons.py b/src/fenigma/icons.py index 1ef7efe..5a845d7 100644 --- a/src/fenigma/icons.py +++ b/src/fenigma/icons.py @@ -449,6 +449,76 @@ def target_type_icon_image(target_type: "TargetType", is_ally: bool = False, wid return _plain_dot(is_ally, width) +_ADDITIVES_DIR = _ICONS_DIR / "targets" / "additives" +UNDERGROUND_TIERS = (1, 2, 3) + + +def underground_icon_path(tier: int) -> Path: + """The badge overlaid on a Target's own icon when it's marked + underground at this tier (1..3, harder to hit = higher). Reuses the + game's own Armor-tier additive art (assets/icons/targets/additives/ + Additive_Armor{1,2,3}.png) rather than inventing bespoke "underground" + art of our own -- there's nothing else in the game's icon set for + "buried/fortified", and Armor's own visual (a plate) already reads + right for that.""" + return _ADDITIVES_DIR / f"Additive_Armor{tier}.png" + + +def underground_tier_image(tier: int | None, width: int = _TYPE_GRID_ICON_WIDTH) -> Gtk.Widget: + """A widget for one cell of the underground-tier picker: the additive + badge itself for a real tier, or a plain dot (this module's usual + 'nothing chosen' placeholder) for the "not underground" cell.""" + if tier is None: + return _plain_dot(False, width) + path = underground_icon_path(tier) + if path.exists(): + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(path), width, -1, True) + picture = Gtk.Picture.new_for_pixbuf(pixbuf) + picture.set_content_fit(Gtk.ContentFit.CONTAIN) + picture.set_can_shrink(True) + picture.set_size_request(pixbuf.get_width(), pixbuf.get_height()) + return picture + return _plain_dot(False, width) + + +def build_underground_tier_grid(selected: int | None, on_pick) -> Gtk.Widget: + """Same radio-style grid idea as build_target_type_grid, just over + (None, 1, 2, 3) instead of TargetType -- None first, as "not + underground" (clearing an existing tier) is exactly as valid a pick + as any real tier, not a separate "remove" action bolted on + afterward.""" + _ensure_icon_button_css() + leader: Gtk.ToggleButton | None = None + items = [None, *UNDERGROUND_TIERS] + + def make_button(tier: int | None) -> Gtk.Widget: + nonlocal leader + cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, + margin_top=4, margin_bottom=4, margin_start=2, margin_end=2) + cell.append(underground_tier_image(tier)) + label = Gtk.Label(label="None" if tier is None else f"Tier {tier}", + wrap=False, single_line_mode=True, + justify=Gtk.Justification.CENTER, width_chars=9, + max_width_chars=9, ellipsize=Pango.EllipsizeMode.END) + label.add_css_class("caption") + label.add_css_class("dim-label") + cell.append(label) + btn = Gtk.ToggleButton(child=cell) + btn.add_css_class("flat") + btn.add_css_class(_ICON_BUTTON_CSS_CLASS) + btn.set_tooltip_text("Not underground" if tier is None else f"Underground, tier {tier}") + if leader is None: + leader = btn + else: + btn.set_group(leader) + if tier is selected: + btn.set_active(True) + btn.connect("clicked", lambda _b, tier=tier: on_pick(tier)) + return btn + + return _build_icon_grid(items, _TYPE_GRID_COLUMNS, make_button) + + def _has_own_icon(t: "TargetType", is_ally: bool) -> bool: """Whether THIS side specifically has real art for t -- as opposed to target_icon_path() quietly handing back the other side's icon because diff --git a/src/fenigma/models.py b/src/fenigma/models.py index d810469..2a9b26f 100644 --- a/src/fenigma/models.py +++ b/src/fenigma/models.py @@ -411,6 +411,10 @@ class Target: # raw string as printed, this app doesn't track a game clock to compare # it against, it's shown as-is for the player's own reference. requested_time: str | None = None + # None = not underground. 1..3 = underground, at that hardening tier + # (see icons.UNDERGROUND_TIERS) -- higher survives more. Target-only: + # there's no such thing as an underground Ally in this game. + underground_tier: int | None = None @property def name(self) -> str: @@ -770,6 +774,7 @@ class Board: "shell": t.shell.name if t.shell is not None else None, "assignment": t.assignment, "requested_time": t.requested_time, + "underground_tier": t.underground_tier, } for t in self.targets ], @@ -837,6 +842,7 @@ class Board: shell=Shell[t["shell"]] if t.get("shell") else None, assignment=t.get("assignment", "unassigned"), requested_time=t.get("requested_time"), + underground_tier=t.get("underground_tier"), ) for t in data.get("targets", []) ]