Fix silent proposal-popover repaint bug; add underground target marker
"Accept as..." (proposal type-picker) and "Change type" (entity-edit) opened to a visibly empty/unchanged popover with no traceback: swapping an already-open Popover's child and re-popup()ing it reported the right size internally but the compositor never repainted the reused surface. Confirmed live via temporary debug instrumentation, not guessed. Fixed by popping the old popover down and opening a genuinely new one at the same anchor point instead of resizing in place. Also adds a Target.underground_tier (1-3) marker: a "Mark underground" entry in the entity-edit popover, rendered as the game's own Armor-tier additive badge stacked directly on the unit icon. The badge is scaled/positioned off its real opaque content (PIL bbox), not its PNG canvas, since the additive art carries a lot of off-center transparent padding; and overlaps down into the icon by a fixed pixel amount, since both shapes taper to a point at the seam and exact bbox-touching still read as a visible gap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
29
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
|
accept attempt after that silently died before the ally/target
|
||||||
ever got added, popover already closed by the time it happened.
|
ever got added, popover already closed by the time it happened.
|
||||||
Fixed there; not a separate bug.
|
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
|
## Needs more scope / your input before I keep going
|
||||||
|
|
||||||
|
|||||||
BIN
assets/icons/targets/additives/Additive_1Star.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
assets/icons/targets/additives/Additive_2Star.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
assets/icons/targets/additives/Additive_3Star.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
assets/icons/targets/additives/Additive_4Star.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
assets/icons/targets/additives/Additive_5Star.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
assets/icons/targets/additives/Additive_Armor1.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
assets/icons/targets/additives/Additive_Armor2.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
assets/icons/targets/additives/Additive_Armor3.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
@ -14,6 +14,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import pickle
|
||||||
|
import signal
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -272,6 +274,15 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
self._import_job = None # in-flight map_import.ImportJob, if any
|
self._import_job = None # in-flight map_import.ImportJob, if any
|
||||||
self.screenshot_import = None # the map screenshot currently on the board
|
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.toast_overlay = Adw.ToastOverlay()
|
||||||
self.set_content(self.toast_overlay)
|
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._drop_shot_btn.set_visible(imp is not None)
|
||||||
self._accept_all_btn.set_sensitive(bool(imp is not None and imp.pending()))
|
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:
|
def _accept_proposal(self, proposal, type_=None) -> None:
|
||||||
coord = _coord_from_proposal(proposal)
|
coord = _coord_from_proposal(proposal)
|
||||||
if coord is None:
|
if coord is None:
|
||||||
@ -794,15 +847,29 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
# real pickable unit types (see their own comments in
|
# real pickable unit types (see their own comments in
|
||||||
# models.py) and shouldn't have been offered as "what this
|
# models.py) and shouldn't have been offered as "what this
|
||||||
# detected marker actually is".
|
# 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,
|
scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
|
||||||
propagate_natural_width=True,
|
propagate_natural_width=True,
|
||||||
max_content_height=340,
|
max_content_height=340,
|
||||||
hscrollbar_policy=Gtk.PolicyType.NEVER)
|
hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||||
scroller.set_child(icons.build_target_type_grid(
|
scroller.set_child(grid)
|
||||||
detected, lambda t: accept(t), is_ally=(proposal.side == "friendly"),
|
|
||||||
))
|
|
||||||
box.append(scroller)
|
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)
|
popover.set_child(box)
|
||||||
|
old_popover.popdown()
|
||||||
|
popover.popup()
|
||||||
|
|
||||||
show_main()
|
show_main()
|
||||||
popover.popup()
|
popover.popup()
|
||||||
@ -1521,10 +1588,31 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
# panel's own alive button offers, just reachable from the
|
# panel's own alive button offers, just reachable from the
|
||||||
# map too rather than only from the sidebar.
|
# map too rather than only from the sidebar.
|
||||||
button(box, "Mark destroyed" if obj.alive else "Mark alive", toggle_alive)
|
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):
|
if not isinstance(obj, Nest):
|
||||||
button(box, "Delete", delete, css="destructive-action")
|
button(box, "Delete", delete, css="destructive-action")
|
||||||
popover.set_child(box)
|
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():
|
def show_type():
|
||||||
box = page()
|
box = page()
|
||||||
heading(box, "Type")
|
heading(box, "Type")
|
||||||
@ -1542,7 +1630,7 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
obj.type, lambda t: set_type(t), is_ally=isinstance(obj, Ally),
|
obj.type, lambda t: set_type(t), is_ally=isinstance(obj, Ally),
|
||||||
))
|
))
|
||||||
box.append(scroller)
|
box.append(scroller)
|
||||||
popover.set_child(box)
|
_reopen_with(box)
|
||||||
|
|
||||||
def set_type(t):
|
def set_type(t):
|
||||||
obj.type = t
|
obj.type = t
|
||||||
@ -1551,6 +1639,26 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
self.toast(f"{_display_name(obj)} is now a "
|
self.toast(f"{_display_name(obj)} is now a "
|
||||||
f"{icons.target_type_label(t, isinstance(obj, Ally))}.")
|
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():
|
def show_id():
|
||||||
box = page()
|
box = page()
|
||||||
heading(box, "ID")
|
heading(box, "ID")
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from collections import namedtuple
|
|||||||
import cairo
|
import cairo
|
||||||
import gi
|
import gi
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
gi.require_version("Gtk", "4.0")
|
gi.require_version("Gtk", "4.0")
|
||||||
gi.require_version("Gdk", "4.0")
|
gi.require_version("Gdk", "4.0")
|
||||||
@ -195,6 +196,44 @@ def _icon_for(category: str, obj) -> cairo.ImageSurface | None:
|
|||||||
return 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):
|
class GridCanvas(Gtk.DrawingArea):
|
||||||
def __init__(self, board: Board) -> None:
|
def __init__(self, board: Board) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@ -817,7 +856,8 @@ class GridCanvas(Gtk.DrawingArea):
|
|||||||
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))
|
icon_surface=_icon_for(category, obj),
|
||||||
|
additive=_additive_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):
|
||||||
@ -878,7 +918,8 @@ 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, 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)
|
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
|
||||||
@ -898,6 +939,8 @@ class GridCanvas(Gtk.DrawingArea):
|
|||||||
# plain filled dot is more honest about the current zoom level.
|
# 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:
|
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)
|
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:
|
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
|
||||||
@ -962,6 +1005,48 @@ class GridCanvas(Gtk.DrawingArea):
|
|||||||
cr.paint_with_alpha(alpha)
|
cr.paint_with_alpha(alpha)
|
||||||
cr.restore()
|
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:
|
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
|
||||||
|
|||||||
@ -449,6 +449,76 @@ def target_type_icon_image(target_type: "TargetType", is_ally: bool = False, wid
|
|||||||
return _plain_dot(is_ally, width)
|
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:
|
def _has_own_icon(t: "TargetType", is_ally: bool) -> bool:
|
||||||
"""Whether THIS side specifically has real art for t -- as opposed to
|
"""Whether THIS side specifically has real art for t -- as opposed to
|
||||||
target_icon_path() quietly handing back the other side's icon because
|
target_icon_path() quietly handing back the other side's icon because
|
||||||
|
|||||||
@ -411,6 +411,10 @@ class Target:
|
|||||||
# raw string as printed, this app doesn't track a game clock to compare
|
# 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.
|
# it against, it's shown as-is for the player's own reference.
|
||||||
requested_time: str | None = None
|
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
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@ -770,6 +774,7 @@ class Board:
|
|||||||
"shell": t.shell.name if t.shell is not None else None,
|
"shell": t.shell.name if t.shell is not None else None,
|
||||||
"assignment": t.assignment,
|
"assignment": t.assignment,
|
||||||
"requested_time": t.requested_time,
|
"requested_time": t.requested_time,
|
||||||
|
"underground_tier": t.underground_tier,
|
||||||
}
|
}
|
||||||
for t in self.targets
|
for t in self.targets
|
||||||
],
|
],
|
||||||
@ -837,6 +842,7 @@ class Board:
|
|||||||
shell=Shell[t["shell"]] if t.get("shell") else None,
|
shell=Shell[t["shell"]] if t.get("shell") else None,
|
||||||
assignment=t.get("assignment", "unassigned"),
|
assignment=t.get("assignment", "unassigned"),
|
||||||
requested_time=t.get("requested_time"),
|
requested_time=t.get("requested_time"),
|
||||||
|
underground_tier=t.get("underground_tier"),
|
||||||
)
|
)
|
||||||
for t in data.get("targets", [])
|
for t in data.get("targets", [])
|
||||||
]
|
]
|
||||||
|
|||||||