Fix ally marker detection; expand TargetType icons and pickers
map_vision.py: marker shape test only matched diamonds, so friendly
(rectangle) markers could never be detected regardless of color match.
diamonds() now takes a per-side ideal shape (diamond for hostile, full
rectangle for friendly) with fill-ratio bands measured off real markers.
Merged tests/fixtures/map_shots/more/ into the main fixture set: 2
screenshots that solve fine (now 15.png/16.png, with hand-transcribed
ground truth) and 8 that are too low native resolution for the label
reader (same class as the existing 12.png) into too_hard/ as
17.png-24.png, with an explanatory README entry. Updated map_vision.py's
docstring numbers (9/12 solve, 104/131 points correct) to match.
TargetType: expanded from 11 to 44 members to cover every icon in
assets/icons/targets/{enemy,friendly}/, including 7 friendly-only types
(King, Police, General, Hospital, Fort, Civil-Military, Mechanized
Anti-Tank) with no enemy equivalent. UNKNOWN/ENEMY stay icon-less by
design (both are literal words the game's OCR'd text uses, confirmed via
ocr.py's _TYPE_BY_SHORT, so neither can be dropped without breaking real
parsing) and draw the same plain-dot fallback the map itself uses.
icons.py: collapsed the icon lookup into one canonical table
(_TARGET_ICON: TargetType -> (enemy_basename, friendly_basename), one
explicit row per type) instead of a basename table plus two exception
dicts layered on top -- with a startup assertion that every TargetType
has a row. Added build_target_type_grid(), an icon-grid picker (icon +
name, same idea as the existing Shell picker) that replaces the old
plain-text dropdown/list everywhere a type is chosen, and only offers
types the given side actually has real art for.
coord_dialog.py: Add/Edit Target and Add Ally now use the icon grid
instead of Adw.ComboRow. Fixed a resulting horizontal-scroll bug (an
unbreakable long word was blowing out cell width) and locked the
coordinate pickers back to 5 columns.
app.py: right-click quick-add now offers Spotter/RP alongside
Target/Ally/Strike, opens a real modal (not a Popover, which turned out
unreliable for a wide multi-row grid) to ask for a type instead of
silently defaulting to UNKNOWN, and doesn't repeat the coordinate on
every row. The type grid listens for "clicked" rather than "toggled" --
a grouped ToggleButton doesn't emit "toggled" when you click the one
that's already active, which meant confirming the pre-selected default
type silently did nothing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ -917,14 +917,15 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
return changed
|
return changed
|
||||||
|
|
||||||
def _open_coord_dialog(
|
def _open_coord_dialog(
|
||||||
self, *, title, on_submit, show_id=False, show_type=False, id_placeholder=None,
|
self, *, title, on_submit, show_id=False, show_type=False, is_ally=False,
|
||||||
initial_location=None, initial_id=None, initial_type=None,
|
id_placeholder=None, initial_location=None, initial_id=None, initial_type=None,
|
||||||
):
|
):
|
||||||
dialog = CoordDialog(
|
dialog = CoordDialog(
|
||||||
title=title,
|
title=title,
|
||||||
on_submit=on_submit,
|
on_submit=on_submit,
|
||||||
show_id=show_id,
|
show_id=show_id,
|
||||||
show_type=show_type,
|
show_type=show_type,
|
||||||
|
is_ally=is_ally,
|
||||||
id_placeholder=id_placeholder,
|
id_placeholder=id_placeholder,
|
||||||
initial_location=initial_location,
|
initial_location=initial_location,
|
||||||
initial_id=initial_id,
|
initial_id=initial_id,
|
||||||
@ -1117,6 +1118,7 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
on_submit=lambda loc, id_, type_: self._add_target(loc, id_, type_),
|
on_submit=lambda loc, id_, type_: self._add_target(loc, id_, type_),
|
||||||
show_id=True,
|
show_id=True,
|
||||||
show_type=True,
|
show_type=True,
|
||||||
|
is_ally=False,
|
||||||
), close))
|
), close))
|
||||||
return box
|
return box
|
||||||
|
|
||||||
@ -1186,6 +1188,7 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
on_submit=lambda loc, id_, type_: self._add_ally(loc, id_, type_),
|
on_submit=lambda loc, id_, type_: self._add_ally(loc, id_, type_),
|
||||||
show_id=True,
|
show_id=True,
|
||||||
show_type=True,
|
show_type=True,
|
||||||
|
is_ally=True,
|
||||||
), close))
|
), close))
|
||||||
return box
|
return box
|
||||||
|
|
||||||
@ -1380,14 +1383,19 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
def show_type():
|
def show_type():
|
||||||
box = page()
|
box = page()
|
||||||
heading(box, "Type")
|
heading(box, "Type")
|
||||||
|
# propagate_natural_WIDTH too, not just height: without it a
|
||||||
|
# ScrolledWindow doesn't grow to fit a wide child (the 5-column
|
||||||
|
# icon grid), which squeezes the popover down to a
|
||||||
|
# near-unusable sliver -- same bug the icon grid's own
|
||||||
|
# docstring and _open_quick_add_menu's identical scroller ran
|
||||||
|
# into first.
|
||||||
scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
|
scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
|
||||||
|
propagate_natural_width=True,
|
||||||
max_content_height=340,
|
max_content_height=340,
|
||||||
hscrollbar_policy=Gtk.PolicyType.NEVER)
|
hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||||
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
scroller.set_child(icons.build_target_type_grid(
|
||||||
for t in TargetType:
|
obj.type, lambda t: set_type(t), is_ally=isinstance(obj, Ally),
|
||||||
label = f"• {t.value}" if t is obj.type else f" {t.value}"
|
))
|
||||||
button(inner, label, lambda t=t: set_type(t))
|
|
||||||
scroller.set_child(inner)
|
|
||||||
box.append(scroller)
|
box.append(scroller)
|
||||||
popover.set_child(box)
|
popover.set_child(box)
|
||||||
|
|
||||||
@ -1477,37 +1485,108 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
field = self._id_field_of(obj)
|
field = self._id_field_of(obj)
|
||||||
return getattr(obj, field) if field else ""
|
return getattr(obj, field) if field else ""
|
||||||
|
|
||||||
|
def _open_type_dialog(self, title: str, is_ally: bool, on_pick) -> None:
|
||||||
|
"""A real top-level modal (Adw.Dialog, same shape as CoordDialog)
|
||||||
|
showing the TargetType icon grid, for contexts that need "pick a
|
||||||
|
type" on its own with no coordinate/id fields alongside it.
|
||||||
|
|
||||||
|
Deliberately NOT a Gtk.Popover: a transient popover anchored to a
|
||||||
|
screen point re-derives its own size/position from its content
|
||||||
|
every time that content is swapped in, and a wide multi-row icon
|
||||||
|
grid nested inside a ScrolledWindow inside a Popover turned out
|
||||||
|
unreliable there in practice -- sometimes rendering squeezed down
|
||||||
|
to a near-unusable sliver, sometimes not registering clicks at
|
||||||
|
all. A dialog has a fixed, predictable content_width/height and
|
||||||
|
the exact same grid+scroller construction CoordDialog already
|
||||||
|
uses successfully for its own Type section, so reusing that shape
|
||||||
|
here sidesteps the whole class of popover-sizing bug rather than
|
||||||
|
debugging it further."""
|
||||||
|
dialog = Adw.Dialog(title=title, content_width=420, content_height=520)
|
||||||
|
toolbar_view = Adw.ToolbarView()
|
||||||
|
dialog.set_child(toolbar_view)
|
||||||
|
toolbar_view.add_top_bar(Adw.HeaderBar())
|
||||||
|
|
||||||
|
def pick(t):
|
||||||
|
dialog.close()
|
||||||
|
on_pick(t)
|
||||||
|
|
||||||
|
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL,
|
||||||
|
margin_top=16, margin_bottom=16, margin_start=20, margin_end=20)
|
||||||
|
# selected=None: this is a pick-one-and-close picker, not an editor
|
||||||
|
# showing a current value, so nothing starts pre-highlighted.
|
||||||
|
outer.append(icons.build_target_type_grid(None, pick, is_ally=is_ally))
|
||||||
|
scroller = Gtk.ScrolledWindow(child=outer, hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||||
|
toolbar_view.set_content(scroller)
|
||||||
|
dialog.present(self)
|
||||||
|
|
||||||
def _open_quick_add_menu(self, coord, x: float, y: float) -> None:
|
def _open_quick_add_menu(self, coord, x: float, y: float) -> None:
|
||||||
"""Right-click on empty map: quick-add a Target or Strike
|
"""Right-click on empty map: quick-add anything right there, no
|
||||||
right there, no dialog, for when you already know exactly where
|
coordinate dialog, for when you already know exactly where you're
|
||||||
you're pointing and don't need to type coordinates."""
|
pointing. Target/Ally still ask for a type (via _open_type_dialog)
|
||||||
|
rather than silently defaulting to TargetType.UNKNOWN -- picking
|
||||||
|
the wrong generic type and having to notice and fix it later is
|
||||||
|
worse than one extra click now.
|
||||||
|
|
||||||
|
The coordinate is shown once, as this menu's own heading, not
|
||||||
|
repeated on every row ("Add target at K5 4:3", "Add ally at K5
|
||||||
|
4:3", ...) -- it's the same point for every option here (that's
|
||||||
|
the whole premise of a menu anchored to where you clicked), so
|
||||||
|
repeating it back on each row is just noise, not information.
|
||||||
|
"""
|
||||||
if coord is None:
|
if coord is None:
|
||||||
return
|
return
|
||||||
popover = self._popover_at(x, y)
|
popover = self._popover_at(x, y)
|
||||||
|
location = Location.from_coord(coord)
|
||||||
|
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
|
||||||
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
|
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
|
||||||
|
heading = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2)
|
||||||
|
heading.set_markup(f"<b>{GLib.markup_escape_text(coord.label())}</b>")
|
||||||
|
box.append(heading)
|
||||||
|
|
||||||
def add_target(_b):
|
def row(label, handler):
|
||||||
self.board.add_target(TargetType.UNKNOWN, coord)
|
btn = Gtk.Button(label=label)
|
||||||
|
btn.add_css_class("flat")
|
||||||
|
btn.connect("clicked", lambda _b: handler())
|
||||||
|
box.append(btn)
|
||||||
|
|
||||||
|
def pick_target_type(t):
|
||||||
|
self.board.add_target(t, location)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def pick_ally_type(t):
|
||||||
|
self.board.add_ally(t, location)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def add_target():
|
||||||
|
popover.popdown()
|
||||||
|
self._open_type_dialog("Add target", is_ally=False, on_pick=pick_target_type)
|
||||||
|
|
||||||
|
def add_ally():
|
||||||
|
popover.popdown()
|
||||||
|
self._open_type_dialog("Add ally", is_ally=True, on_pick=pick_ally_type)
|
||||||
|
|
||||||
|
def add_spotter():
|
||||||
|
self.board.add_spotter(location)
|
||||||
self._refresh()
|
self._refresh()
|
||||||
popover.popdown()
|
popover.popdown()
|
||||||
|
|
||||||
def add_strike(_b):
|
def add_rp():
|
||||||
|
self.board.add_reference_point(location)
|
||||||
|
self._refresh()
|
||||||
|
popover.popdown()
|
||||||
|
|
||||||
|
def add_strike():
|
||||||
target = self.board.add_target(TargetType.STRIKE, coord)
|
target = self.board.add_target(TargetType.STRIKE, coord)
|
||||||
self.board.reorder_target(target, 0) # new strikes go to the front of the list
|
self.board.reorder_target(target, 0) # new strikes go to the front of the list
|
||||||
self._refresh()
|
self._refresh()
|
||||||
popover.popdown()
|
popover.popdown()
|
||||||
|
|
||||||
target_btn = Gtk.Button(label=f"Add target at {coord.label()}")
|
row("Add target", add_target)
|
||||||
target_btn.add_css_class("flat")
|
row("Add ally", add_ally)
|
||||||
target_btn.connect("clicked", add_target)
|
row("Add spotter", add_spotter)
|
||||||
box.append(target_btn)
|
row("Add reference point", add_rp)
|
||||||
|
row("Add strike", add_strike)
|
||||||
strike_btn = Gtk.Button(label=f"Add strike at {coord.label()}")
|
|
||||||
strike_btn.add_css_class("flat")
|
|
||||||
strike_btn.connect("clicked", add_strike)
|
|
||||||
box.append(strike_btn)
|
|
||||||
|
|
||||||
popover.set_child(box)
|
popover.set_child(box)
|
||||||
popover.popup()
|
popover.popup()
|
||||||
@ -1555,6 +1634,7 @@ class MainWindow(Adw.ApplicationWindow):
|
|||||||
initial_location=target.location,
|
initial_location=target.location,
|
||||||
show_id=True,
|
show_id=True,
|
||||||
show_type=True,
|
show_type=True,
|
||||||
|
is_ally=False,
|
||||||
id_placeholder=f"ID (current: {target.id})",
|
id_placeholder=f"ID (current: {target.id})",
|
||||||
initial_id=target.id,
|
initial_id=target.id,
|
||||||
initial_type=target.type,
|
initial_type=target.type,
|
||||||
|
|||||||
@ -23,7 +23,7 @@ gi.require_version("Adw", "1")
|
|||||||
gi.require_version("Gdk", "4.0")
|
gi.require_version("Gdk", "4.0")
|
||||||
from gi.repository import Adw, Gdk, Gtk # noqa: E402
|
from gi.repository import Adw, Gdk, Gtk # noqa: E402
|
||||||
|
|
||||||
from . import ocr
|
from . import icons, ocr
|
||||||
from .models import LARGE_X, Coord, Location, TargetType
|
from .models import LARGE_X, Coord, Location, TargetType
|
||||||
|
|
||||||
|
|
||||||
@ -37,6 +37,7 @@ class CoordDialog(Adw.Dialog):
|
|||||||
on_submit: Callable[[Location, str | None, TargetType | None], None],
|
on_submit: Callable[[Location, str | None, TargetType | None], None],
|
||||||
show_id: bool = False,
|
show_id: bool = False,
|
||||||
show_type: bool = False,
|
show_type: bool = False,
|
||||||
|
is_ally: bool = False,
|
||||||
id_placeholder: str | None = None,
|
id_placeholder: str | None = None,
|
||||||
initial_location: Location | None = None,
|
initial_location: Location | None = None,
|
||||||
initial_id: str | None = None,
|
initial_id: str | None = None,
|
||||||
@ -46,10 +47,12 @@ class CoordDialog(Adw.Dialog):
|
|||||||
self._on_submit = on_submit
|
self._on_submit = on_submit
|
||||||
self._show_id = show_id
|
self._show_id = show_id
|
||||||
self._show_type = show_type
|
self._show_type = show_type
|
||||||
|
self._is_ally = is_ally
|
||||||
self._id_placeholder = id_placeholder or "ID (blank = auto)"
|
self._id_placeholder = id_placeholder or "ID (blank = auto)"
|
||||||
self._initial_location = initial_location or Location()
|
self._initial_location = initial_location or Location()
|
||||||
self._initial_id = initial_id
|
self._initial_id = initial_id
|
||||||
self._initial_type = initial_type
|
self._initial_type = initial_type
|
||||||
|
self._type_val = initial_type or TargetType.UNKNOWN
|
||||||
|
|
||||||
toolbar_view = Adw.ToolbarView()
|
toolbar_view = Adw.ToolbarView()
|
||||||
self.set_child(toolbar_view)
|
self.set_child(toolbar_view)
|
||||||
@ -91,7 +94,14 @@ class CoordDialog(Adw.Dialog):
|
|||||||
flow.set_homogeneous(True)
|
flow.set_homogeneous(True)
|
||||||
flow.set_row_spacing(4)
|
flow.set_row_spacing(4)
|
||||||
flow.set_column_spacing(4)
|
flow.set_column_spacing(4)
|
||||||
flow.set_max_children_per_line(10)
|
# Fixed at 5, not a min/max range: the dialog's width now varies
|
||||||
|
# with whatever else is in it (e.g. the Type grid, see
|
||||||
|
# build_target_type_grid), and a FlowBox reflows to fit whatever
|
||||||
|
# width it's given -- letting it range up to 10 made X (A-T) jump
|
||||||
|
# to 7-wide rows whenever the dialog happened to be wider, which
|
||||||
|
# read as broken rather than deliberate. 5 was the one that looked
|
||||||
|
# right at the dialog's normal size.
|
||||||
|
flow.set_max_children_per_line(5)
|
||||||
flow.set_min_children_per_line(5)
|
flow.set_min_children_per_line(5)
|
||||||
|
|
||||||
buttons = []
|
buttons = []
|
||||||
@ -130,8 +140,8 @@ class CoordDialog(Adw.Dialog):
|
|||||||
spacing=16,
|
spacing=16,
|
||||||
margin_top=16,
|
margin_top=16,
|
||||||
margin_bottom=16,
|
margin_bottom=16,
|
||||||
margin_start=16,
|
margin_start=20,
|
||||||
margin_end=16,
|
margin_end=20,
|
||||||
)
|
)
|
||||||
|
|
||||||
initial = self._initial_location.coord
|
initial = self._initial_location.coord
|
||||||
@ -175,24 +185,29 @@ class CoordDialog(Adw.Dialog):
|
|||||||
self._stage_widgets = [Y_group, x_group, y_group, None]
|
self._stage_widgets = [Y_group, x_group, y_group, None]
|
||||||
|
|
||||||
self.row_id = None
|
self.row_id = None
|
||||||
self.row_type = None
|
|
||||||
if self._show_id or self._show_type:
|
if self._show_id or self._show_type:
|
||||||
extra_group = Adw.PreferencesGroup(title="Identity")
|
identity_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
|
||||||
if self._show_id:
|
if self._show_id:
|
||||||
|
extra_group = Adw.PreferencesGroup(title="Identity")
|
||||||
self.row_id = Adw.EntryRow(title=self._id_placeholder)
|
self.row_id = Adw.EntryRow(title=self._id_placeholder)
|
||||||
if self._initial_id is not None:
|
if self._initial_id is not None:
|
||||||
self.row_id.set_text(str(self._initial_id))
|
self.row_id.set_text(str(self._initial_id))
|
||||||
extra_group.add(self.row_id)
|
extra_group.add(self.row_id)
|
||||||
|
identity_box.append(extra_group)
|
||||||
if self._show_type:
|
if self._show_type:
|
||||||
self.row_type = Adw.ComboRow(
|
# An icon grid (same idea as the Shell picker), not a plain
|
||||||
title="Type",
|
# text dropdown -- with ~35 types now, seeing the actual
|
||||||
model=Gtk.StringList.new([t.short for t in TargetType]),
|
# marker art is the difference between recognizing the
|
||||||
)
|
# right one and reading a wall of similar-sounding names.
|
||||||
if self._initial_type is not None:
|
identity_box.append(self._picker_group(
|
||||||
self.row_type.set_selected(list(TargetType).index(self._initial_type))
|
"Type",
|
||||||
extra_group.add(self.row_type)
|
icons.build_target_type_grid(
|
||||||
outer.append(extra_group)
|
self._type_val, lambda t: setattr(self, "_type_val", t),
|
||||||
self._stage_widgets[3] = extra_group
|
is_ally=self._is_ally,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
outer.append(identity_box)
|
||||||
|
self._stage_widgets[3] = identity_box
|
||||||
|
|
||||||
submit = Gtk.Button(label="Set coordinates")
|
submit = Gtk.Button(label="Set coordinates")
|
||||||
submit.add_css_class("suggested-action")
|
submit.add_css_class("suggested-action")
|
||||||
@ -201,7 +216,9 @@ class CoordDialog(Adw.Dialog):
|
|||||||
submit.connect("clicked", self._on_submit_clicked)
|
submit.connect("clicked", self._on_submit_clicked)
|
||||||
outer.append(submit)
|
outer.append(submit)
|
||||||
|
|
||||||
self._exact_scroller = Gtk.ScrolledWindow(child=outer)
|
self._exact_scroller = Gtk.ScrolledWindow(
|
||||||
|
child=outer, hscrollbar_policy=Gtk.PolicyType.NEVER
|
||||||
|
)
|
||||||
self._exact_content = outer
|
self._exact_content = outer
|
||||||
return self._exact_scroller
|
return self._exact_scroller
|
||||||
|
|
||||||
@ -262,7 +279,7 @@ class CoordDialog(Adw.Dialog):
|
|||||||
|
|
||||||
def _id_and_type(self) -> tuple[str | None, TargetType | None]:
|
def _id_and_type(self) -> tuple[str | None, TargetType | None]:
|
||||||
id_ = self.row_id.get_text().strip() or None if self.row_id is not None else None
|
id_ = self.row_id.get_text().strip() or None if self.row_id is not None else None
|
||||||
type_ = list(TargetType)[self.row_type.get_selected()] if self.row_type is not None else None
|
type_ = self._type_val if self._show_type else None
|
||||||
return id_, type_
|
return id_, type_
|
||||||
|
|
||||||
def _on_submit_clicked(self, _button: Gtk.Button) -> None:
|
def _on_submit_clicked(self, _button: Gtk.Button) -> None:
|
||||||
|
|||||||
@ -15,7 +15,9 @@ import gi
|
|||||||
gi.require_version("Gdk", "4.0")
|
gi.require_version("Gdk", "4.0")
|
||||||
gi.require_version("GdkPixbuf", "2.0")
|
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, Pango # noqa: E402
|
||||||
|
|
||||||
|
import cairo
|
||||||
|
|
||||||
from .models import TargetType
|
from .models import TargetType
|
||||||
from .shells import Shell
|
from .shells import Shell
|
||||||
@ -28,30 +30,103 @@ _css_loaded = False
|
|||||||
NEST_ICON_PATH = _ICONS_DIR / "nest" / "IronNest.png"
|
NEST_ICON_PATH = _ICONS_DIR / "nest" / "IronNest.png"
|
||||||
STRIKE_ICON_PATH = _ICONS_DIR / "misc" / "Crosshair.png"
|
STRIKE_ICON_PATH = _ICONS_DIR / "misc" / "Crosshair.png"
|
||||||
|
|
||||||
# TargetType -> the shared basename suffix, after 'Enemy_'/'Friendly_',
|
# TargetType -> (enemy_basename, friendly_basename), after 'Enemy_'/
|
||||||
# for whichever of the game's own unit icons fits best. Best-effort
|
# 'Friendly_'. Either half is None where the game draws no icon for that
|
||||||
# guesses (flagged the same way the Shell descriptions were): the game
|
# type on that side at all -- that's not rare enough on either side to
|
||||||
# doesn't have a dedicated icon for every one of our types, TANK reuses
|
# treat as an exception list bolted onto a shared-name table (the earlier
|
||||||
# the Armor_Mechanized artwork, and FDC/PILLBOX/ENEMY only exist on the
|
# shape of this code: one basename table plus two separate patch dicts for
|
||||||
# enemy side at all (see target_icon_path()'s fallback). No entry means
|
# "actually the friendly filename differs" and "actually this side has
|
||||||
# no icon exists worth drawing, the caller falls back to a plain dot
|
# none at all", which was easy to update inconsistently and silently do
|
||||||
# (only UNKNOWN now, STRIKE has its own crosshair below).
|
# the wrong thing for one side). One explicit pair per type, covering
|
||||||
_TARGET_ICON_BASENAME = {
|
# EVERY TargetType, is the actual shape of the data: a basename shared by
|
||||||
TargetType.SUPPLY_CACHE: "Ammunition Cache.png",
|
# both sides, a basename that differs (a genuine filename mismatch in the
|
||||||
TargetType.FDC: "Fire Direction Center.png",
|
# source assets, not a difference in what's drawn, see assets/icons/
|
||||||
TargetType.INFANTRY: "Infantry.png",
|
# README.md), or a basename that exists on only one side.
|
||||||
TargetType.MECHANIZED: "Armor_Mechanized.png",
|
#
|
||||||
TargetType.ARTILLERY: "Field Artillery.png",
|
# UNKNOWN/ENEMY are deliberately (None, None): generic/ad-hoc, not a unit
|
||||||
TargetType.TANK: "Armor_Mechanized.png",
|
# the game draws specific art for (see their comments on TargetType).
|
||||||
TargetType.PILLBOX: "Heavy_Gun_Bunker.png",
|
# STRIKE isn't in this table at all: its crosshair isn't an Enemy_/
|
||||||
TargetType.MARINE_GARRISON: "Marine.png",
|
# Friendly_ file, it's handled as a special case in target_icon_path().
|
||||||
TargetType.ENEMY: "Base.png",
|
# completeness of this table (every TargetType except STRIKE has a row) is
|
||||||
|
# asserted below, not just hoped for.
|
||||||
|
_TARGET_ICON = {
|
||||||
|
TargetType.UNKNOWN: (None, None),
|
||||||
|
TargetType.ENEMY: (None, None),
|
||||||
|
|
||||||
|
TargetType.ANTI_AIR: ("AA.png", "AA.png"),
|
||||||
|
TargetType.ANTI_TANK: ("AntiTank.png", "AntiTank.png"),
|
||||||
|
TargetType.ARTILLERY: ("Field Artillery.png", "Field Artillery.png"),
|
||||||
|
TargetType.ARTILLERY_OBSERVER: ("Field Artillery Observer.png", "Field Artillery Observer.png"),
|
||||||
|
TargetType.HEAVY_GUN_TURRET: ("Heavy_Gun_Turret.png", None),
|
||||||
|
TargetType.INFANTRY: ("Infantry.png", "Infantry.png"),
|
||||||
|
TargetType.INFANTRY_MECHANIZED: ("Infantry_mechanized.png", "Infantry_Mechanized.png"), # case differs
|
||||||
|
TargetType.MECH_ANTI_TANK: (None, "Mech_AntiTank.png"), # friendly-only
|
||||||
|
TargetType.MECHANIZED: ("Armor_Mechanized.png", "Armor_Mechanized.png"),
|
||||||
|
TargetType.PILLBOX: ("Heavy_Gun_Bunker.png", None),
|
||||||
|
TargetType.TANK: ("Armor_Mechanized.png", "Armor_Mechanized.png"), # shares MECHANIZED's art, see TargetType
|
||||||
|
|
||||||
|
TargetType.BASE: ("Base.png", "Military Base.png"), # name differs
|
||||||
|
TargetType.COMMANDER: ("Commander.png", "Commander.png"),
|
||||||
|
TargetType.FDC: ("Fire Direction Center.png", None),
|
||||||
|
TargetType.FORT: (None, "Fort.png"), # friendly-only
|
||||||
|
TargetType.GENERAL: (None, "General.png"), # friendly-only
|
||||||
|
TargetType.KING: (None, "King.png"), # friendly-only
|
||||||
|
TargetType.MARINE_GARRISON: ("Marine.png", "Marine.png"),
|
||||||
|
TargetType.POLICE: (None, "Police.png"), # friendly-only
|
||||||
|
TargetType.SUPPLY_CACHE: ("Ammunition Cache.png", "Ammunition Cache.png"),
|
||||||
|
TargetType.UNDERGROUND_FORT: ("Underground Fort.png", None),
|
||||||
|
|
||||||
|
TargetType.EMERGENCY_MEDICAL: ("Emergency Medical Operation.png", "Emergency Medical Operation.png"),
|
||||||
|
TargetType.HOSPITAL: (None, "Hospital.png"), # friendly-only
|
||||||
|
TargetType.MEDICAL: ("Medical.png", "Medical.png"),
|
||||||
|
TargetType.MEDICAL_FACILITY: ("Medical Treatment Facility.png", "Medical Treatment Facility.png"),
|
||||||
|
|
||||||
|
TargetType.CIVIL_MILITARY: (None, "Civil–Military.png"), # friendly-only
|
||||||
|
TargetType.CIVILIAN: ("Civ.png", "Civilian.png"), # name differs
|
||||||
|
TargetType.CIVIL_RIOTING: ("Civil Rioting.png", "Civil Rioting.png"),
|
||||||
|
TargetType.RIOTING: ("Rioting.png", None),
|
||||||
|
TargetType.TV_RADIO_PROPAGANDA: ("TV and Radio Propaganda.png", "TV and Radio Propaganda.png"),
|
||||||
|
|
||||||
|
TargetType.PORT: ("Port.png", "Port.png"),
|
||||||
|
TargetType.SHIP: ("Ship.png", None),
|
||||||
|
TargetType.SHIP_ENGINE: ("Ship_Engine.png", None),
|
||||||
|
TargetType.SHIP_FDC: ("Ship_FDC.png", None),
|
||||||
|
TargetType.SHIP_STRIPE: ("Ship_Stripe.png", "Ship_stripe.png"), # case differs
|
||||||
|
TargetType.SHIP_TURRET: ("Ship_Turret.png", None),
|
||||||
|
|
||||||
|
TargetType.TRAIN_LOCOMOTIVE: ("Train_Locomotive.png", None),
|
||||||
|
TargetType.TRAIN_STATION: ("Train_Station.png", "Train_Station.png"),
|
||||||
|
TargetType.TRAIN_TRANSPORT: ("Train_Transport.png", None),
|
||||||
|
|
||||||
|
TargetType.RECON: ("Recon.png", "Reconnaissance.png"), # name differs
|
||||||
|
TargetType.RECON_LISTENING: ("Recon_Listening.png", "Recon_Listening.png"),
|
||||||
}
|
}
|
||||||
|
assert {*_TARGET_ICON} | {TargetType.STRIKE} == {*TargetType}, (
|
||||||
|
"every TargetType needs a row in _TARGET_ICON (STRIKE is the one "
|
||||||
|
"deliberate exception, see the comment above it)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _icon_for_side(target_type: TargetType, is_ally: bool) -> Path | None:
|
||||||
|
"""This SIDE's own icon for target_type specifically, with no
|
||||||
|
cross-side fallback -- used both by target_icon_path() (which adds
|
||||||
|
the fallback back on top) and by _has_own_icon() (which needs to know
|
||||||
|
whether this side has real art of its own, not whether *some* art is
|
||||||
|
available after falling back)."""
|
||||||
|
entry = _TARGET_ICON.get(target_type)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
basename = entry[1 if is_ally else 0]
|
||||||
|
if basename is None:
|
||||||
|
return None
|
||||||
|
folder, prefix = ("friendly", "Friendly_") if is_ally else ("enemy", "Enemy_")
|
||||||
|
path = _ICONS_DIR / "targets" / folder / f"{prefix}{basename}"
|
||||||
|
return path if path.exists() else None
|
||||||
|
|
||||||
|
|
||||||
def target_type_from_icon(basename: str | None) -> TargetType | None:
|
def target_type_from_icon(basename: str | None) -> TargetType | None:
|
||||||
"""Inverse of _TARGET_ICON_BASENAME, for the map-vision marker classifier,
|
"""Inverse of _TARGET_ICON, for the map-vision marker classifier, which
|
||||||
which names what it matched by icon file rather than by TargetType.
|
names what it matched by icon file rather than by TargetType.
|
||||||
|
|
||||||
Not injective: MECHANIZED and TANK share Armor_Mechanized.png, so that one
|
Not injective: MECHANIZED and TANK share Armor_Mechanized.png, so that one
|
||||||
resolves to MECHANIZED and the user retypes it if it was a Tank (map
|
resolves to MECHANIZED and the user retypes it if it was a Tank (map
|
||||||
@ -64,31 +139,26 @@ def target_type_from_icon(basename: str | None) -> TargetType | None:
|
|||||||
for prefix in ("Enemy_", "Friendly_"):
|
for prefix in ("Enemy_", "Friendly_"):
|
||||||
if name.startswith(prefix):
|
if name.startswith(prefix):
|
||||||
name = name[len(prefix):]
|
name = name[len(prefix):]
|
||||||
for type_, base in _TARGET_ICON_BASENAME.items():
|
for type_, (enemy_basename, friendly_basename) in _TARGET_ICON.items():
|
||||||
if base == name:
|
if name in (enemy_basename, friendly_basename):
|
||||||
return type_
|
return type_
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def target_icon_path(target_type: TargetType, is_ally: bool = False) -> Path | None:
|
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
|
"""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,
|
good one. `is_ally` picks the friendly side of _TARGET_ICON over the
|
||||||
falling back to Enemy_ if that particular basename has no friendly
|
enemy one, falling back to the enemy icon if this particular type has
|
||||||
version (the two sets aren't the same size, see assets/icons/
|
no friendly art of its own at all (the two sets aren't the same size,
|
||||||
README.md). STRIKE (a planned impact point, not a unit) gets its
|
see assets/icons/README.md). STRIKE (a planned impact point, not a
|
||||||
own crosshair rather than a unit icon, it doesn't fit the Enemy_/
|
unit) gets its own crosshair rather than a unit icon, it doesn't fit
|
||||||
Friendly_ naming scheme at all."""
|
the Enemy_/Friendly_ naming scheme at all."""
|
||||||
if target_type is TargetType.STRIKE:
|
if target_type is TargetType.STRIKE:
|
||||||
return STRIKE_ICON_PATH
|
return STRIKE_ICON_PATH
|
||||||
basename = _TARGET_ICON_BASENAME.get(target_type)
|
own = _icon_for_side(target_type, is_ally)
|
||||||
if basename is None:
|
if own is not None:
|
||||||
return None
|
return own
|
||||||
if is_ally:
|
return _icon_for_side(target_type, is_ally=False) if is_ally else None
|
||||||
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:
|
||||||
@ -200,10 +270,10 @@ def _shell_cell(s: Shell) -> Gtk.Widget:
|
|||||||
return cell
|
return cell
|
||||||
|
|
||||||
|
|
||||||
def _build_shell_grid(make_button) -> Gtk.Widget:
|
def _build_icon_grid(items, columns, make_button) -> Gtk.Widget:
|
||||||
"""Shared grid layout: rows of up to _GRID_COLUMNS buttons, one per
|
"""Shared grid layout: rows of up to `columns` buttons, one per item in
|
||||||
Shell, each built by `make_button(shell) -> Gtk.Widget`. Used by
|
`items`, each built by `make_button(item) -> Gtk.Widget`. Used by every
|
||||||
both the popover picker and the inline radio-style grid below.
|
icon-grid picker in this module (shells, target types).
|
||||||
|
|
||||||
A plain nested Gtk.Box grid, not a Gtk.FlowBox, on purpose, after
|
A plain nested Gtk.Box grid, not a Gtk.FlowBox, on purpose, after
|
||||||
two FlowBox attempts both broke in different ways: a ScrolledWindow
|
two FlowBox attempts both broke in different ways: a ScrolledWindow
|
||||||
@ -213,24 +283,28 @@ def _build_shell_grid(make_button) -> Gtk.Widget:
|
|||||||
no fixed allocation yet) turned out to mean 'fit every child on one
|
no fixed allocation yet) turned out to mean 'fit every child on one
|
||||||
line', ignoring max_children_per_line entirely, so min/max-content-
|
line', ignoring max_children_per_line entirely, so min/max-content-
|
||||||
width on a ScrolledWindow around it never actually took effect
|
width on a ScrolledWindow around it never actually took effect
|
||||||
(verified directly: it kept ballooning out to fit every shell in a
|
(verified directly: it kept ballooning out to fit every item in a
|
||||||
single row regardless of what those properties were set to). Shell
|
single row regardless of what those properties were set to). Each of
|
||||||
is a small, fixed, known set, there's no real need for FlowBox's
|
these sets is small and fixed, there's no real need for FlowBox's
|
||||||
dynamic reflow-to-fewer-columns behavior here, a manual grid of
|
dynamic reflow-to-fewer-columns behavior here, a manual grid of
|
||||||
fixed-size rows has a fully deterministic natural width (columns *
|
fixed-size rows has a fully deterministic natural width (columns *
|
||||||
cell width, nothing else involved) and sidesteps the whole class of
|
cell width, nothing else involved) and sidesteps the whole class of
|
||||||
bug."""
|
bug."""
|
||||||
shells = list(Shell)
|
items = list(items)
|
||||||
grid = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4,
|
grid = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4,
|
||||||
margin_top=8, margin_bottom=8, margin_start=8, margin_end=8)
|
margin_top=8, margin_bottom=8, margin_start=8, margin_end=8)
|
||||||
for start in range(0, len(shells), _GRID_COLUMNS):
|
for start in range(0, len(items), columns):
|
||||||
row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, homogeneous=True)
|
row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, homogeneous=True)
|
||||||
for s in shells[start:start + _GRID_COLUMNS]:
|
for it in items[start:start + columns]:
|
||||||
row_box.append(make_button(s))
|
row_box.append(make_button(it))
|
||||||
grid.append(row_box)
|
grid.append(row_box)
|
||||||
return grid
|
return grid
|
||||||
|
|
||||||
|
|
||||||
|
def _build_shell_grid(make_button) -> Gtk.Widget:
|
||||||
|
return _build_icon_grid(Shell, _GRID_COLUMNS, make_button)
|
||||||
|
|
||||||
|
|
||||||
def build_shell_popover(on_pick) -> Gtk.Popover:
|
def build_shell_popover(on_pick) -> Gtk.Popover:
|
||||||
"""Popover with a grid of every Shell (icon + blast radius under it,
|
"""Popover with a grid of every Shell (icon + blast radius under it,
|
||||||
full description as a tooltip), replacing a plain text dropdown/list
|
full description as a tooltip), replacing a plain text dropdown/list
|
||||||
@ -321,3 +395,166 @@ def build_shell_button(selected: Shell, on_pick, *, show_label: bool = True, ico
|
|||||||
render(selected)
|
render(selected)
|
||||||
btn.set_popover(build_shell_popover(handle_pick))
|
btn.set_popover(build_shell_popover(handle_pick))
|
||||||
return btn
|
return btn
|
||||||
|
|
||||||
|
|
||||||
|
# ---- TargetType icon-grid picker, same idea as the Shell picker above ----
|
||||||
|
|
||||||
|
_TYPE_GRID_ICON_WIDTH = 40 # smaller than the shell grid's: ~35 types vs 9 shells,
|
||||||
|
_TYPE_GRID_COLUMNS = 5 # needs to fit a lot more cells in the same dialog width
|
||||||
|
|
||||||
|
|
||||||
|
# Mirrors grid_widget.py's CATEGORY_COLOR["target"]/["ally"] (the colors the
|
||||||
|
# map itself draws the plain-dot fallback in). Duplicated rather than
|
||||||
|
# imported: grid_widget.py already imports this module for icon lookups, an
|
||||||
|
# import the other way would be circular. Unlike that module's palette,
|
||||||
|
# these two are not theme-swapped live -- the picker is a modal dialog, not
|
||||||
|
# the persistent map, redrawing it on a theme change isn't worth the wiring.
|
||||||
|
_DOT_COLOR = {False: (0.92, 0.30, 0.28), True: (0.30, 0.85, 0.85)}
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_dot(is_ally: bool, width: int) -> Gtk.Widget:
|
||||||
|
"""The same 'plain dot' fallback the map itself draws for a type with
|
||||||
|
no dedicated icon (see grid_widget.py's _icon_for), so a type with no
|
||||||
|
icon reads as 'this type has no special marker' rather than as a
|
||||||
|
rendering gap in the picker."""
|
||||||
|
area = Gtk.DrawingArea()
|
||||||
|
area.set_content_width(width)
|
||||||
|
area.set_content_height(width)
|
||||||
|
|
||||||
|
def draw(_area, cr, w, h):
|
||||||
|
cr.set_source_rgb(*_DOT_COLOR[is_ally])
|
||||||
|
cr.arc(w / 2, h / 2, min(w, h) * 0.32, 0, 2 * 3.141592653589793)
|
||||||
|
cr.fill()
|
||||||
|
|
||||||
|
area.set_draw_func(draw)
|
||||||
|
return area
|
||||||
|
|
||||||
|
|
||||||
|
def target_type_icon_image(target_type: "TargetType", is_ally: bool = False, width: int = _TYPE_GRID_ICON_WIDTH) -> Gtk.Widget:
|
||||||
|
"""A widget showing target_type's icon (friendly or enemy art per
|
||||||
|
`is_ally`), scaled to `width` px wide. Falls back to the same plain dot
|
||||||
|
the map itself draws for the types with no dedicated icon (UNKNOWN,
|
||||||
|
ENEMY -- see icons.py's _TARGET_ICON_BASENAME comment; STRIKE always
|
||||||
|
has its crosshair), so every cell in the grid stays the same size
|
||||||
|
whether or not it has real art."""
|
||||||
|
path = target_icon_path(target_type, is_ally=is_ally)
|
||||||
|
if path is not None and 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(is_ally, width)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
this side has none of its own (see its own docstring). Used to keep
|
||||||
|
that cross-side fallback out of the picker grids entirely: showing a
|
||||||
|
red diamond as an option for 'Add ally', or offering 'King'/'Police'/
|
||||||
|
etc. (friendly-only, see _TARGET_ICON) as an enemy type, reads as a
|
||||||
|
real option of the wrong side rather than a missing-icon placeholder.
|
||||||
|
UNKNOWN and ENEMY are the exception: deliberately generic/icon-less on
|
||||||
|
BOTH sides (see their comments on TargetType), always offered
|
||||||
|
regardless."""
|
||||||
|
if t in (TargetType.UNKNOWN, TargetType.ENEMY):
|
||||||
|
return True
|
||||||
|
return _icon_for_side(t, is_ally) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def available_target_types(is_ally: bool = False):
|
||||||
|
"""TargetType members worth offering in a picker for this side.
|
||||||
|
|
||||||
|
STRIKE is never offered: it's not a unit type at all (a planned
|
||||||
|
impact point, not a contact), it's always created through its own
|
||||||
|
dedicated "Add strike" action (see app.py's _open_quick_add_menu),
|
||||||
|
never by picking a type from this generic grid -- there's no such
|
||||||
|
thing as a Strike-typed Ally either, offering it there is just
|
||||||
|
confusing, not merely unlikely.
|
||||||
|
|
||||||
|
Otherwise: each side only offers types it actually has its own art
|
||||||
|
for (see _has_own_icon / _TARGET_ICON) -- some types are enemy-only
|
||||||
|
and some are friendly-only (King, Police, a friendly hospital, ...),
|
||||||
|
the game simply doesn't draw an installation of every kind on both
|
||||||
|
sides."""
|
||||||
|
return [t for t in TargetType if t is not TargetType.STRIKE and _has_own_icon(t, is_ally)]
|
||||||
|
|
||||||
|
|
||||||
|
def _target_type_label(t: "TargetType", is_ally: bool) -> str:
|
||||||
|
"""Display text for a picker cell/tooltip. TargetType.ENEMY's own
|
||||||
|
value is literally 'Enemy' (it's the word the game's OCR'd text uses
|
||||||
|
for an ad-hoc *hostile* installation, see TargetType's own comment) --
|
||||||
|
exactly right in the enemy picker, but confusing in the Ally one,
|
||||||
|
where the very same generic/ad-hoc-named-unit case reads as 'Enemy'
|
||||||
|
is somehow a kind of Ally. Cosmetic only: the underlying TargetType
|
||||||
|
stored on the entity is still ENEMY either way, only the label shown
|
||||||
|
while picking it changes."""
|
||||||
|
if is_ally and t is TargetType.ENEMY:
|
||||||
|
return "Ally"
|
||||||
|
return t.value
|
||||||
|
|
||||||
|
|
||||||
|
def _target_type_cell(t: "TargetType", is_ally: bool) -> Gtk.Widget:
|
||||||
|
"""Icon + name, both a FIXED size regardless of how long the name is --
|
||||||
|
a real cell size that varies with its label text (three-line names next
|
||||||
|
to one-line ones) makes every row in the grid a different height, which
|
||||||
|
reads as broken/uneven rather than a grid. One line, ellipsized, with
|
||||||
|
the full name in the button's tooltip (see build_target_type_grid)
|
||||||
|
covers the names a single line can't fit."""
|
||||||
|
cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
|
||||||
|
margin_top=4, margin_bottom=4, margin_start=2, margin_end=2)
|
||||||
|
cell.append(target_type_icon_image(t, is_ally=is_ally))
|
||||||
|
name_label = Gtk.Label(label=_target_type_label(t, is_ally), wrap=False, single_line_mode=True,
|
||||||
|
justify=Gtk.Justification.CENTER, width_chars=9,
|
||||||
|
max_width_chars=9, ellipsize=Pango.EllipsizeMode.END)
|
||||||
|
name_label.add_css_class("caption")
|
||||||
|
name_label.add_css_class("dim-label")
|
||||||
|
cell.append(name_label)
|
||||||
|
return cell
|
||||||
|
|
||||||
|
|
||||||
|
def build_target_type_grid(selected: "TargetType | None", on_pick, *, is_ally: bool = False) -> Gtk.Widget:
|
||||||
|
"""Inline radio-style grid of every available TargetType (icon + name
|
||||||
|
below, same idea as build_shell_grid), replacing the old plain-text
|
||||||
|
dropdown/list. `is_ally` both picks the friendly icon set over the
|
||||||
|
enemy one and restricts the offered types to ones with real friendly
|
||||||
|
art (see available_target_types). Exactly one cell is ever
|
||||||
|
highlighted (`selected`, or none if `selected` is None or not offered
|
||||||
|
on this side).
|
||||||
|
|
||||||
|
`on_pick(target_type)` fires on every click, including a click on the
|
||||||
|
already-selected cell -- deliberately listening for "clicked", not
|
||||||
|
"toggled": a ToggleButton in a radio group doesn't emit "toggled" when
|
||||||
|
you click the one that's already active (nothing about its state
|
||||||
|
changed), which meant clicking the pre-selected default -- usually
|
||||||
|
exactly the type someone wants, e.g. plain "Target" -- silently did
|
||||||
|
nothing. "clicked" fires every time regardless, so confirming the
|
||||||
|
default now works the same as picking anything else.
|
||||||
|
|
||||||
|
See _target_type_cell for why the name label is single-line and
|
||||||
|
ellipsized rather than wrapped: an unbounded label size, besides
|
||||||
|
making uneven-height rows, could also (being inside a homogeneous
|
||||||
|
row) stretch every cell in that row wide enough to force the whole
|
||||||
|
dialog into horizontal scrolling -- coord_dialog.py's ScrolledWindow
|
||||||
|
has hscrollbar_policy=NEVER as a backstop against that same failure."""
|
||||||
|
_ensure_icon_button_css()
|
||||||
|
leader: Gtk.ToggleButton | None = None
|
||||||
|
|
||||||
|
def make_button(t: "TargetType") -> Gtk.Widget:
|
||||||
|
nonlocal leader
|
||||||
|
btn = Gtk.ToggleButton(child=_target_type_cell(t, is_ally))
|
||||||
|
btn.add_css_class("flat")
|
||||||
|
btn.add_css_class(_ICON_BUTTON_CSS_CLASS)
|
||||||
|
btn.set_tooltip_text(_target_type_label(t, is_ally))
|
||||||
|
if leader is None:
|
||||||
|
leader = btn
|
||||||
|
else:
|
||||||
|
btn.set_group(leader)
|
||||||
|
if t is selected:
|
||||||
|
btn.set_active(True)
|
||||||
|
btn.connect("clicked", lambda _b, t=t: on_pick(t))
|
||||||
|
return btn
|
||||||
|
|
||||||
|
return _build_icon_grid(available_target_types(is_ally), _TYPE_GRID_COLUMNS, make_button)
|
||||||
|
|||||||
@ -29,12 +29,15 @@ Shape of the solution:
|
|||||||
therefore selects scale, axis assignment, direction, phase and anchor
|
therefore selects scale, axis assignment, direction, phase and anchor
|
||||||
together.
|
together.
|
||||||
|
|
||||||
Measured on the 10 fixtures in tests/fixtures/map_shots: solves 7 of them,
|
Measured on the 12 fixtures in tests/fixtures/map_shots: solves 9 of them,
|
||||||
with 100% of each solved shot's annotated points landing in the correct cell
|
with 100% of each solved shot's annotated points landing in the correct cell
|
||||||
(85 of 112 overall) and a residual spread of 0.005-0.033 cells. The other
|
(104 of 131 overall) and a residual spread of 0.005-0.033 cells. The other
|
||||||
three are rejected rather than guessed at, and no fixture has ever produced
|
three are rejected rather than guessed at, and no fixture has ever produced
|
||||||
a plausible-but-wrong grid. Rejection is a supported outcome -- a silently
|
a plausible-but-wrong grid. Rejection is a supported outcome -- a silently
|
||||||
misplaced target is far worse than a refusal.
|
misplaced target is far worse than a refusal. A further 11 screenshots sit
|
||||||
|
in tests/fixtures/map_shots/too_hard/, kept for the record but excluded from
|
||||||
|
the evaluation set: see its README.md for why each one is a legitimate
|
||||||
|
rejection rather than a bug.
|
||||||
|
|
||||||
Over the 122 typewriter screenshots this was checked against, solve()
|
Over the 122 typewriter screenshots this was checked against, solve()
|
||||||
accepted none, which is what makes it safe to route clipboard images through
|
accepted none, which is what makes it safe to route clipboard images through
|
||||||
@ -744,12 +747,29 @@ def solve_path(path):
|
|||||||
|
|
||||||
_ICON_BANK: dict = {}
|
_ICON_BANK: dict = {}
|
||||||
ICON_SIZE = 64
|
ICON_SIZE = 64
|
||||||
DIAMOND_IOU = 0.64 # blob-vs-ideal-diamond overlap needed to be a marker.
|
MARKER_IOU = 0.64 # blob-vs-ideal-shape overlap needed to be a marker.
|
||||||
# Swept against verified counts: 0.64 keeps every shot
|
# Swept against verified counts: 0.64 keeps every shot
|
||||||
# confirmed correct by hand (5/2/2/3 markers) while cutting
|
# confirmed correct by hand (5/2/2/3 markers) while cutting
|
||||||
# ribbon+hatching false positives from 43 to 2 on the worst
|
# ribbon+hatching false positives from 43 to 2 on the worst
|
||||||
# fixture. Loosening to 0.50 regains one real marker on one
|
# fixture. Loosening to 0.50 regains one real marker on one
|
||||||
# shot but quadruples the false positives.
|
# shot but quadruples the false positives.
|
||||||
|
#
|
||||||
|
# The ideal shape depends on SIDE: hostile markers are
|
||||||
|
# diamonds, friendly ones are rectangles (see
|
||||||
|
# `_ideal_shape`) -- the game draws the two factions with
|
||||||
|
# different marker geometry, not just different colours.
|
||||||
|
# Fitting only the diamond shape used to mean no cyan
|
||||||
|
# rectangle could ever pass this gate, however good its
|
||||||
|
# colour match.
|
||||||
|
FILL_RANGE = { # min/max of (blob area / bbox area); the diamond and
|
||||||
|
# rectangle marker families genuinely differ here (a diamond covers half
|
||||||
|
# its bounding box, a solid rectangle covers nearly all of it), so one
|
||||||
|
# shared band would either admit hatching as "diamonds" or reject real
|
||||||
|
# rectangles as "not filled enough". Measured off real markers: diamonds
|
||||||
|
# 0.30-0.85, rectangles (minus their border and X-crossing) 0.75-0.97.
|
||||||
|
"diamond": (0.30, 0.85),
|
||||||
|
"rect": (0.55, 0.97),
|
||||||
|
}
|
||||||
SYMBOL_KEEP = 0.52 # central fraction of the marker that carries the symbol
|
SYMBOL_KEEP = 0.52 # central fraction of the marker that carries the symbol
|
||||||
|
|
||||||
|
|
||||||
@ -852,16 +872,32 @@ def marker_masks(img):
|
|||||||
for m in (hostile, friendly)]
|
for m in (hostile, friendly)]
|
||||||
|
|
||||||
|
|
||||||
def diamonds(mask, cell_px):
|
def _ideal_mask(shape, w, h):
|
||||||
|
"""The filled-in silhouette a marker of this SHAPE should have, inscribed
|
||||||
|
in a w x h bounding box: a diamond for hostile markers, the full box
|
||||||
|
itself for friendly ones (they are drawn as solid rectangles, so their
|
||||||
|
own bounding box IS their ideal silhouette)."""
|
||||||
|
if shape == "rect":
|
||||||
|
return np.ones((h, w), bool)
|
||||||
|
ideal = np.zeros((h, w), np.uint8)
|
||||||
|
cv2.fillConvexPoly(ideal, np.array(
|
||||||
|
[[w // 2, 0], [w - 1, h // 2], [w // 2, h - 1], [0, h // 2]], np.int32), 1)
|
||||||
|
return ideal.astype(bool)
|
||||||
|
|
||||||
|
|
||||||
|
def diamonds(mask, cell_px, shape="diamond"):
|
||||||
"""Marker-sized, marker-shaped blobs.
|
"""Marker-sized, marker-shaped blobs.
|
||||||
|
|
||||||
The markers scale with the map, so a solved grid tells us how big one
|
The markers scale with the map, so a solved grid tells us how big one
|
||||||
must be (~0.14 of a cell). A diamond also fills about half its bounding
|
must be (~0.14 of a cell). SHAPE selects which silhouette a blob must
|
||||||
box, which rejects the long thin territory hatching and front-line
|
match -- "diamond" for hostile markers, "rect" for friendly ones, which
|
||||||
ribbons that share the markers' colours.
|
the game draws as solid rectangles rather than diamonds. Matching the
|
||||||
|
shape (not just the size and colour) rejects the long thin territory
|
||||||
|
hatching and front-line ribbons that share the markers' colours.
|
||||||
"""
|
"""
|
||||||
want = 0.14 * cell_px
|
want = 0.14 * cell_px
|
||||||
lo, hi = 0.55 * want, 1.9 * want
|
lo, hi = 0.55 * want, 1.9 * want
|
||||||
|
fill_lo, fill_hi = FILL_RANGE[shape]
|
||||||
n, lab, stats, cent = cv2.connectedComponentsWithStats(mask, 8)
|
n, lab, stats, cent = cv2.connectedComponentsWithStats(mask, 8)
|
||||||
out = []
|
out = []
|
||||||
for i in range(1, n):
|
for i in range(1, n):
|
||||||
@ -870,33 +906,33 @@ def diamonds(mask, cell_px):
|
|||||||
continue
|
continue
|
||||||
if not (0.55 <= w / h <= 1.8):
|
if not (0.55 <= w / h <= 1.8):
|
||||||
continue
|
continue
|
||||||
if not (0.30 <= a / float(w * h) <= 0.85):
|
if not (fill_lo <= a / float(w * h) <= fill_hi):
|
||||||
continue
|
continue
|
||||||
# Actually test for a DIAMOND. A bounding-box fill ratio near 0.5 is
|
# Actually test the SHAPE. A bounding-box fill ratio alone is not
|
||||||
# not enough: a chunk of the territory hatching or of a front-line
|
# enough: a chunk of the territory hatching or of a front-line
|
||||||
# ribbon hits the same ratio and the same colour, which is where the
|
# ribbon hits the same ratio and the same colour, which is where the
|
||||||
# tens of spurious markers came from. Compare the blob against an
|
# tens of spurious markers came from. Compare the blob against the
|
||||||
# ideal diamond inscribed in its own bounding box.
|
# ideal silhouette inscribed in its own bounding box.
|
||||||
blob = (lab[y:y + h, x:x + w] == i)
|
blob = (lab[y:y + h, x:x + w] == i)
|
||||||
ideal = np.zeros((h, w), np.uint8)
|
ideal = _ideal_mask(shape, w, h)
|
||||||
cv2.fillConvexPoly(ideal, np.array(
|
|
||||||
[[w // 2, 0], [w - 1, h // 2], [w // 2, h - 1], [0, h // 2]], np.int32), 1)
|
|
||||||
ideal = ideal.astype(bool)
|
|
||||||
union = int(np.logical_or(blob, ideal).sum())
|
union = int(np.logical_or(blob, ideal).sum())
|
||||||
if union == 0:
|
if union == 0:
|
||||||
continue
|
continue
|
||||||
if int(np.logical_and(blob, ideal).sum()) / union < DIAMOND_IOU:
|
if int(np.logical_and(blob, ideal).sum()) / union < MARKER_IOU:
|
||||||
continue
|
continue
|
||||||
out.append((float(cent[i][0]), float(cent[i][1]), (int(x), int(y), int(w), int(h))))
|
out.append((float(cent[i][0]), float(cent[i][1]), (int(x), int(y), int(w), int(h))))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
MARKER_SHAPE = {"hostile": "diamond", "friendly": "rect"}
|
||||||
|
|
||||||
|
|
||||||
def find_markers(img, sol):
|
def find_markers(img, sol):
|
||||||
"""-> list of dicts: side, unit, label, sub_x, sub_y, coord, centre, box."""
|
"""-> list of dicts: side, unit, label, sub_x, sub_y, coord, centre, box."""
|
||||||
cell = max(sol.steps)
|
cell = max(sol.steps)
|
||||||
found = []
|
found = []
|
||||||
for side, mask in zip(("hostile", "friendly"), marker_masks(img)):
|
for side, mask in zip(("hostile", "friendly"), marker_masks(img)):
|
||||||
for (cx, cy, box) in diamonds(mask, cell):
|
for (cx, cy, box) in diamonds(mask, cell, MARKER_SHAPE[side]):
|
||||||
c = sol.cell_of(cx, cy)
|
c = sol.cell_of(cx, cy)
|
||||||
if c is None:
|
if c is None:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -44,21 +44,76 @@ class TargetType(Enum):
|
|||||||
collection from Target (see Board.allies), not this same type with
|
collection from Target (see Board.allies), not this same type with
|
||||||
a flag flipped, targets and allies don't share an id-namespace or a
|
a flag flipped, targets and allies don't share an id-namespace or a
|
||||||
firing-relevant shape (no shell/powder_charges/assignment)."""
|
firing-relevant shape (no shell/powder_charges/assignment)."""
|
||||||
|
# Declaration order is also picker order (icons.build_target_type_grid
|
||||||
|
# and the old plain-text dropdown both just iterate TargetType), so
|
||||||
|
# it's grouped by category, most-reached-for category first, and
|
||||||
|
# alphabetical by value within a category -- not the order these were
|
||||||
|
# added to the codebase.
|
||||||
|
|
||||||
|
# -- Generic / non-unit -------------------------------------------
|
||||||
UNKNOWN = "Target" # generic contact, spotted but not yet identified; default choice
|
UNKNOWN = "Target" # generic contact, spotted but not yet identified; default choice
|
||||||
SUPPLY_CACHE = "Supply Cache"
|
|
||||||
FDC = "FDC" # Fire Direction Center, coordinates counter-battery fire
|
|
||||||
INFANTRY = "Infantry" # ground troops
|
|
||||||
MECHANIZED = "Mechanized" # armored/vehicle unit
|
|
||||||
ARTILLERY = "Artillery" # "Coastal Battery" is just this, see _TYPE_WORD_ALIASES in ocr.py
|
|
||||||
TANK = "Tank"
|
|
||||||
PILLBOX = "Pillbox" # armoured emplacement, fixed position
|
|
||||||
MARINE_GARRISON = "Marine Garrison" # requests fire support (see Target.requested_time)
|
|
||||||
ENEMY = "Enemy" # ad-hoc installation named directly in the intel text
|
ENEMY = "Enemy" # ad-hoc installation named directly in the intel text
|
||||||
# ("Enemy Signal Station", "Enemy Field Command"), not one of the
|
# ("Enemy Signal Station", "Enemy Field Command"), not one of the
|
||||||
# game's fixed unit types, its id is the rest of that name with
|
# game's fixed unit types, its id is the rest of that name with
|
||||||
# spaces stripped, see ocr.py's squash_enemy_names()
|
# spaces stripped, see ocr.py's squash_enemy_names()
|
||||||
STRIKE = "Strike" # a planned impact point, not an enemy contact
|
STRIKE = "Strike" # a planned impact point, not an enemy contact
|
||||||
|
|
||||||
|
# -- Ground combat units -------------------------------------------
|
||||||
|
ANTI_AIR = "Anti-Air"
|
||||||
|
ANTI_TANK = "Anti-Tank"
|
||||||
|
ARTILLERY = "Artillery" # "Coastal Battery" is just this, see _TYPE_WORD_ALIASES in ocr.py
|
||||||
|
ARTILLERY_OBSERVER = "Field Artillery Observer"
|
||||||
|
HEAVY_GUN_TURRET = "Heavy Gun Turret"
|
||||||
|
INFANTRY = "Infantry" # ground troops
|
||||||
|
INFANTRY_MECHANIZED = "Mechanized Infantry"
|
||||||
|
MECH_ANTI_TANK = "Mechanized Anti-Tank" # friendly-only, no Enemy_ art (icons.py's
|
||||||
|
# _FRIENDLY_ONLY_BASENAME), unlike ANTI_TANK which both sides draw
|
||||||
|
MECHANIZED = "Mechanized" # armored/vehicle unit
|
||||||
|
PILLBOX = "Pillbox" # armoured emplacement, fixed position
|
||||||
|
TANK = "Tank"
|
||||||
|
|
||||||
|
# -- Command & installations ----------------------------------------
|
||||||
|
BASE = "Base"
|
||||||
|
COMMANDER = "Commander"
|
||||||
|
FDC = "FDC" # Fire Direction Center, coordinates counter-battery fire
|
||||||
|
FORT = "Fort" # friendly-only; UNDERGROUND_FORT is the enemy-side equivalent concept
|
||||||
|
GENERAL = "General" # friendly-only, no Enemy_ art
|
||||||
|
KING = "King" # friendly-only, no Enemy_ art
|
||||||
|
MARINE_GARRISON = "Marine Garrison" # requests fire support (see Target.requested_time)
|
||||||
|
POLICE = "Police" # friendly-only, no Enemy_ art
|
||||||
|
SUPPLY_CACHE = "Supply Cache"
|
||||||
|
UNDERGROUND_FORT = "Underground Fort"
|
||||||
|
|
||||||
|
# -- Medical ----------------------------------------------------------
|
||||||
|
EMERGENCY_MEDICAL = "Emergency Medical Operation"
|
||||||
|
HOSPITAL = "Hospital" # friendly-only; MEDICAL_FACILITY is the enemy-side equivalent concept
|
||||||
|
MEDICAL = "Medical"
|
||||||
|
MEDICAL_FACILITY = "Medical Treatment Facility"
|
||||||
|
|
||||||
|
# -- Civil --------------------------------------------------------------
|
||||||
|
CIVIL_MILITARY = "Civil–Military" # friendly-only, no Enemy_ art
|
||||||
|
CIVILIAN = "Civilian"
|
||||||
|
CIVIL_RIOTING = "Civil Rioting"
|
||||||
|
RIOTING = "Rioting"
|
||||||
|
TV_RADIO_PROPAGANDA = "TV and Radio Propaganda"
|
||||||
|
|
||||||
|
# -- Naval ------------------------------------------------------------
|
||||||
|
PORT = "Port"
|
||||||
|
SHIP = "Ship"
|
||||||
|
SHIP_ENGINE = "Ship Engine"
|
||||||
|
SHIP_FDC = "Ship FDC"
|
||||||
|
SHIP_STRIPE = "Ship (Stripe)"
|
||||||
|
SHIP_TURRET = "Ship Turret"
|
||||||
|
|
||||||
|
# -- Rail -----------------------------------------------------------
|
||||||
|
TRAIN_LOCOMOTIVE = "Train Locomotive"
|
||||||
|
TRAIN_STATION = "Train Station"
|
||||||
|
TRAIN_TRANSPORT = "Train Transport"
|
||||||
|
|
||||||
|
# -- Reconnaissance ---------------------------------------------------
|
||||||
|
RECON = "Recon"
|
||||||
|
RECON_LISTENING = "Recon (Listening)"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def short(self) -> str:
|
def short(self) -> str:
|
||||||
"""Compact form used in item names, e.g. 'SupplyCache' / 'FDC'."""
|
"""Compact form used in item names, e.g. 'SupplyCache' / 'FDC'."""
|
||||||
|
|||||||
BIN
tests/fixtures/map_shots/15.png
vendored
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
tests/fixtures/map_shots/16.png
vendored
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
tests/fixtures/map_shots/too_hard/17.png
vendored
Normal file
|
After Width: | Height: | Size: 791 KiB |
BIN
tests/fixtures/map_shots/too_hard/18.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
tests/fixtures/map_shots/too_hard/19.png
vendored
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
tests/fixtures/map_shots/too_hard/20.png
vendored
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
tests/fixtures/map_shots/too_hard/21.png
vendored
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
tests/fixtures/map_shots/too_hard/22.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
tests/fixtures/map_shots/too_hard/23.png
vendored
Normal file
|
After Width: | Height: | Size: 570 KiB |
BIN
tests/fixtures/map_shots/too_hard/24.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
9
tests/fixtures/map_shots/too_hard/README.md
vendored
@ -10,5 +10,14 @@ them would mean guessing:
|
|||||||
- `12.png` — 710x594 native. Too few pixels per cell for the label glyphs to
|
- `12.png` — 710x594 native. Too few pixels per cell for the label glyphs to
|
||||||
correlate; measured, the best label score stays ~0.44 at every working
|
correlate; measured, the best label score stays ~0.44 at every working
|
||||||
resolution, so it is not a tuning problem.
|
resolution, so it is not a tuning problem.
|
||||||
|
- `17.png`-`24.png` — same class as `12.png`: native resolution too low (605-1789px
|
||||||
|
wide, vs. 1392-2400px for the fixtures that do solve) for the label glyphs to
|
||||||
|
correlate once warped to the canonical cell size; measured, the best label
|
||||||
|
score stays ~0.45-0.52 at every working resolution tried (including the
|
||||||
|
2400px retry pass), well short of LABEL_ACCEPT (0.62) and inside the
|
||||||
|
documented "wrong read" band (0.40-0.56), not close enough to call it a
|
||||||
|
tuning problem. Two of the ten screenshots this batch came from (now
|
||||||
|
`15.png`/`16.png` in the main set) were high enough resolution to solve --
|
||||||
|
the same camera distance/game zoom just wasn't consistent across the batch.
|
||||||
|
|
||||||
"Too zoomed in" and "too low resolution" are legitimate hard rejections.
|
"Too zoomed in" and "too low resolution" are legitimate hard rejections.
|
||||||
|
|||||||
103
tests/fixtures/map_shots_gt.json
vendored
@ -636,5 +636,104 @@
|
|||||||
1659
|
1659
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
}
|
"15.png": [
|
||||||
|
[
|
||||||
|
"N8",
|
||||||
|
140,
|
||||||
|
859
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"N9",
|
||||||
|
153,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"O8",
|
||||||
|
1042,
|
||||||
|
859
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"P8",
|
||||||
|
1949,
|
||||||
|
859
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"16.png": [
|
||||||
|
[
|
||||||
|
"J5",
|
||||||
|
62,
|
||||||
|
573
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"J6",
|
||||||
|
80,
|
||||||
|
289
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"J7",
|
||||||
|
97,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"K5",
|
||||||
|
370,
|
||||||
|
572
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"K6",
|
||||||
|
374,
|
||||||
|
289
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"K7",
|
||||||
|
377,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"L5",
|
||||||
|
678,
|
||||||
|
572
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"L6",
|
||||||
|
667,
|
||||||
|
289
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"L7",
|
||||||
|
657,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"M5",
|
||||||
|
985,
|
||||||
|
572
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"M6",
|
||||||
|
959,
|
||||||
|
288
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"M7",
|
||||||
|
936,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"N5",
|
||||||
|
1293,
|
||||||
|
571
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"N6",
|
||||||
|
1252,
|
||||||
|
288
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"N7",
|
||||||
|
1215,
|
||||||
|
31
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||