Source code for yasuki_gui.field_view

import tkinter as tk
from collections.abc import Callable, Iterable
from types import MappingProxyType

from yasuki_core.engine.players import PlayerId
from yasuki_core.engine.table import BoardPos, DeckKey, TableState, ZoneKey, ZoneRole
from yasuki_core.engine.intents import Event, Intent, apply_intent
from yasuki_core.engine.redaction import ViewSnapshot
from yasuki_core.game_pieces.dynasty import DynastyPersonality
from yasuki_gui import theme
from yasuki_gui.config import DEFAULT_HOTKEYS, Hotkeys
from yasuki_gui.constants import CARD_H, CARD_W, HOME_STACK_OFFSET
from yasuki_gui.controller import FieldController
from yasuki_gui.layout import (
    divider_y,
    from_canvas,
    hand_box,
    home_stack_positions,
    province_positions,
    to_canvas,
)
from yasuki_gui.services.hittest import resolve_tag_at as hittest_resolve_tag_at
from yasuki_gui.tags import card_id_for_tag, card_tag, zone_tag
from yasuki_gui.ui.images import ImageProvider
from yasuki_gui.visuals import CardSpriteVisual, HandVisual, ZoneVisual
from yasuki_gui.visuals.cardface import RenderCard, to_render_card


_ZONE_LABELS: dict[ZoneRole, str] = {
    ZoneRole.PROVINCE: "Province",
    ZoneRole.FATE_DISCARD: "Fate Discard",
    ZoneRole.FATE_BANISH: "Fate Banish",
    ZoneRole.DYNASTY_DISCARD: "Dynasty Discard",
    ZoneRole.DYNASTY_BANISH: "Dynasty Banish",
}


def _zone_label(key: ZoneKey) -> str:
    return _ZONE_LABELS.get(key.role, key.role.value)


[docs] class FieldView(tk.Canvas): """Tkinter canvas that renders a single authoritative ``TableState`` and drives it through ``apply_intent``. The state is the sole source of truth; every visual is a keyed projection of it, rebuilt by :meth:`reconcile_all` after each :meth:`dispatch`. Card identity is stable (cards mutate in place), so a sprite keeps its card reference across mutations and reconciliation only tracks membership and battlefield position. """
[docs] def __init__(self, master: tk.Misc, width: int = 800, height: int = 600): super().__init__(master, width=width, height=height, bg=theme.SURFACE, highlightthickness=0) self._cw = width self._ch = height self.state: TableState | None = None # When set (rules mode), the board renders from this redacted projection instead of the raw # table; the manual sandbox leaves it None and renders the full TableState directly. self._snapshot: ViewSnapshot | None = None self.seat: PlayerId = PlayerId.P1 # The viewer's gold pool, drawn as a coin in the battlefield corner; set by the host before # each render. The rules engine owns the real value. self.gold: int = 0 self._sprites: dict[str, CardSpriteVisual] = {} self._zones: dict[str, ZoneVisual] = {} self._hands: dict[str, HandVisual] = {} self._tag_to_key: dict[str, ZoneKey | DeckKey] = {} self._hotkeys: Hotkeys = DEFAULT_HOTKEYS self._selected: set[str] = set() self._marquee_start: tuple[int, int] | None = None self._marquee_rect: int | None = None # Decision selection: when the engine awaits a choice, _selectable holds the candidate ids # (None when not choosing) and _selection the chosen subset, both rendered on the board. # Selection order is tracked so the last pick can be undone (Ctrl+Z during a payment). self._selectable: frozenset[str] | None = None self._selection: list[str] = [] # When choosing how to pay, selected producers preview as bowed (tapped for gold). self._selection_bows: bool = False # Producers whose yield can be boosted as they bow, and the subset the player boosted. self._boostable: frozenset[str] = frozenset() self._boosted: list[str] = [] # Optional UI callbacks the host app installs. self.on_local_player_changed: Callable[[], None] | None = None self.apply_profile_to_panels: Callable[[], None] | None = None self.on_selection_changed: Callable[[], None] | None = None self.on_card_activated: Callable[[str], None] | None = None # Fires when a boostable producer is picked, so the host can ask whether to boost it; the # host answers by calling resolve_boost. self.on_boost_request: Callable[[str], None] | None = None self._controller = FieldController(self) self._images = ImageProvider(self) self.bind("<Enter>", lambda e: self.focus_set()) self.bind("<Configure>", self._on_configure)
# ----- public API ------------------------------------------------------- @property def local_player(self) -> PlayerId: # Retained name for the controller/permissions affordance layer; the viewing seat is the # acting seat in single-player. return self.seat @local_player.setter def local_player(self, seat: PlayerId) -> None: self.seat = seat @property def _flipped(self) -> bool: """Whether the battlefield is rendered 180° from the canonical P1 frame (debug other-seat view). Positions are stored in P1's frame, so viewing as P2 flips them.""" return self.seat is PlayerId.P2
[docs] def load_state(self, state: TableState, seat: PlayerId) -> None: """Adopt ``state`` as the rendered table, viewed and controlled from ``seat``.""" self.state = state self.seat = seat self.delete("all") self._sprites.clear() self._zones.clear() self._hands.clear() self._tag_to_key.clear() self._selected.clear() self.reconcile_all()
[docs] def dispatch(self, intent: Intent) -> list[Event]: """Apply ``intent`` as the acting seat, reconcile the visuals, and return the events. A no-op in rules mode (a projection is set): the rules engine owns mutations there, so the sandbox intent path is disabled to keep the engine authoritative.""" if self.state is None or self._snapshot is not None: return [] events = apply_intent(self.state, self.seat, intent) if events: self.reconcile_all() return events
[docs] def render_snapshot(self, snapshot: ViewSnapshot, seat: PlayerId) -> None: """Render the board from a redacted projection (rules mode), viewed from ``seat``.""" self._snapshot = snapshot self.seat = seat self.reconcile_all()
@property def rules_mode(self) -> bool: """Whether the board is engine-driven (a projection is set), so clicks act on cards rather than dragging the sandbox.""" return self._snapshot is not None # ----- decision selection (cards chosen on the board for a pending decision) --------- @property def selecting(self) -> bool: """Whether the board is in selection mode, awaiting a choice from the player.""" return self._selectable is not None @property def selection(self) -> frozenset[str]: """The ids currently selected for the pending decision.""" return frozenset(self._selection) @property def boosted(self) -> frozenset[str]: """The selected producers whose bow-time boost the player took.""" return frozenset(self._boosted)
[docs] def begin_selection( self, candidates: Iterable[str], *, render_bowed: bool = False, boostable: Iterable[str] = (), ) -> None: """Enter selection mode: only ``candidates`` are selectable, none chosen yet. When ``render_bowed`` is set, selected cards preview as bowed (a producer tapped to pay). Picking a ``boostable`` producer defers to :attr:`on_boost_request` before it enters the selection.""" self._selectable = frozenset(candidates) self._selection = [] self._selection_bows = render_bowed self._boostable = frozenset(boostable) self._boosted = []
[docs] def end_selection(self) -> None: """Leave selection mode and clear the selection.""" self._selectable = None self._selection = [] self._selection_bows = False self._boostable = frozenset() self._boosted = []
[docs] def toggle_selection(self, card_id: str) -> None: """Toggle ``card_id`` in the selection if it is a candidate, and notify the listener. Picking a boostable producer instead defers to :attr:`on_boost_request`; it enters the selection only once its boost question is answered through :meth:`resolve_boost`.""" if self._selectable is None or card_id not in self._selectable: return if card_id in self._selection: self._selection.remove(card_id) self._forget_boost(card_id) elif card_id in self._boostable and self.on_boost_request is not None: self.on_boost_request(card_id) return else: self._selection.append(card_id) if self.on_selection_changed is not None: self.on_selection_changed()
[docs] def resolve_boost(self, card_id: str, take: bool) -> None: """Add a boostable producer to the selection once its boost question is answered, taking the boost when ``take``.""" if self._selectable is None or card_id not in self._selectable: return if card_id not in self._selection: self._selection.append(card_id) if take and card_id not in self._boosted: self._boosted.append(card_id) if self.on_selection_changed is not None: self.on_selection_changed()
[docs] def undo_last_selection(self) -> None: """Drop the most recently selected id (Ctrl+Z while paying), and notify the listener.""" if not self._selection: return self._forget_boost(self._selection.pop()) if self.on_selection_changed is not None: self.on_selection_changed()
def _forget_boost(self, card_id: str) -> None: if card_id in self._boosted: self._boosted.remove(card_id)
[docs] def configure_hotkeys(self, hotkeys: Hotkeys) -> None: self._hotkeys = hotkeys self._controller.configure_hotkeys(hotkeys)
[docs] def resolve_tag_at(self, event: tk.Event) -> str | None: return hittest_resolve_tag_at(self, event)
[docs] def key_for_tag(self, tag: str) -> ZoneKey | DeckKey | None: return self._tag_to_key.get(tag)
[docs] def canonical_pos(self, x: int, y: int) -> BoardPos: """Turn a canvas pixel into the seat-neutral battlefield position the engine stores.""" w, h = self._canvas_size() return from_canvas(x, y, flipped=self._flipped, canvas_w=w, canvas_h=h)
[docs] @staticmethod def card_id_for_tag(tag: str) -> str | None: return card_id_for_tag(tag)
# ----- exposed collections (read-only views) ---------------------------- @property def zones(self): return MappingProxyType(self._zones) @property def hands(self): return MappingProxyType(self._hands) @property def sprites(self): return MappingProxyType(self._sprites) # ----- selection (visual only) ------------------------------------------ def _clear_selection(self) -> None: if not self._selected: return for tag in list(self._selected): sprite = self._sprites.get(tag) if sprite: sprite.update_selection(self, False) self._selected.clear() def _set_selection(self, tags: set[str]) -> None: if tags == self._selected: return old = self._selected self._selected = set(tags) for tag in old - self._selected: sp = self._sprites.get(tag) if sp: sp.update_selection(self, False) for tag in self._selected - old: sp = self._sprites.get(tag) if sp: sp.update_selection(self, True) # ----- geometry helpers for the controller/hittest ----------------------
[docs] def bbox_for_zone(self, ztag: str) -> tuple[int, int, int, int]: zv = self._zones.get(ztag) if zv is not None: return zv.bbox hv = self._hands.get(ztag) return hv.bbox if hv else (0, 0, -1, -1)
[docs] def redraw_zone(self, tag: str) -> None: self.delete(tag) if tag in self._zones: self._zones[tag].draw(self) elif tag in self._hands: self._hands[tag].draw(self)
# ----- reconciliation ---------------------------------------------------
[docs] def reconcile(self, events: list[Event]) -> None: # The board is small, so a full reconcile after every accepted intent stays cheap and avoids # any chance of a stale projection. Event-targeted redraw can specialise this later. self.reconcile_all()
[docs] def reconcile_all(self) -> None: if self.state is None and self._snapshot is None: return self.delete("all") self._draw_table() self._reconcile_zones() self._reconcile_sprites() if self.rules_mode and self.gold > 0: self._draw_gold()
def _draw_gold(self) -> None: """A gold coin and the viewer's pool in the bottom-left of the battlefield.""" _, h = self._canvas_size() cx, cy, r = 30, h - 30, 15 self.create_oval( cx - r, cy - r, cx + r, cy + r, fill=theme.GOLD, outline=theme.GOLD_HOVER, width=2, tags=("gold",), ) self.create_oval(cx - 7, cy - 7, cx + 7, cy + 7, outline=theme.GOLD_HOVER, tags=("gold",)) self.create_text( cx + r + 8, cy, text=str(self.gold), fill=theme.INK, anchor="w", font=theme.serif(16, "bold"), tags=("gold",), ) # The render source is the redacted projection in rules mode, else the raw sandbox table. These # accessors yield uniform render-data from whichever is active, so reconcile is source-agnostic. def _render_decks(self): if self._snapshot is not None: for key, deck_view in self._snapshot.decks.items(): top = to_render_card(deck_view.top) if deck_view.top is not None else None yield key, deck_view.count, top else: for key, deck in self.state.decks.items(): yield key, len(deck.cards), to_render_card(deck.cards[-1]) if deck.cards else None def _render_zones(self): if self._snapshot is not None: for key, zone_view in self._snapshot.zones.items(): yield key, [to_render_card(card_view) for card_view in zone_view.cards] else: for key, zone in self.state.zones.items(): yield key, [to_render_card(card) for card in zone.cards] def _render_battlefield(self): if self._snapshot is not None: for bf_view in self._snapshot.battlefield: yield to_render_card(bf_view.card), bf_view.pos else: for card in self.state.battlefield.cards: yield to_render_card(card), self.state.positions.get(card.id) def _render_seats(self): return self._snapshot.seats if self._snapshot is not None else self.state.seats def _zone_keys(self): source = self._snapshot.zones if self._snapshot is not None else self.state.zones return source.keys() def _draw_table(self) -> None: """A faint gold midline splitting the two seats' halves, drawn behind every card.""" w, h = self._canvas_size() y = divider_y(h) self.create_line(int(w * 0.08), y, int(w * 0.92), y, fill=theme.MIDLINE, tags=("table",)) def _reconcile_zones(self) -> None: """Draw the on-board zones only: every seat's provinces and the viewer's own hand. Decks, discards, and banishes live in the off-board info panels, and the opponent's hand is never shown — those are read through the accessors below, not drawn here.""" w, h = self._canvas_size() province_keys = self._province_keys_by_owner() wanted_zones: set[str] = set() wanted_hands: set[str] = set() for key, cards in self._render_zones(): seat_at_bottom = key.owner is self.seat if key.role is ZoneRole.HAND: if key.owner is not self.seat: continue # the opponent's hand is never drawn tag = zone_tag(key) self._tag_to_key[tag] = key wanted_hands.add(tag) bx, by, bw, bh = hand_box(w, h, seat_at_bottom=seat_at_bottom) hv = self._hands.get(tag) if hv is None: hv = HandVisual(cards, key.owner, bx, by, bw, bh, tag, images=self._images) self._hands[tag] = hv hv.cards, hv.owner = cards, key.owner hv.x, hv.y, hv.w, hv.h = bx, by, bw, bh hv.selected_ids = self.selection hv.draw(self) continue if key.role is not ZoneRole.PROVINCE: continue # discards/banishes are off-board (info panel), not drawn here tag = zone_tag(key) self._tag_to_key[tag] = key wanted_zones.add(tag) ordered = province_keys[key.owner] positions = province_positions(w, h, len(ordered), seat_at_bottom=seat_at_bottom) px, py = positions[ordered.index(key)] label = _zone_label(key) zv = self._zones.get(tag) if zv is None: zv = ZoneVisual( cards, True, label, px, py, CARD_W, CARD_H, tag, images=self._images ) self._zones[tag] = zv zv.cards, zv.is_province, zv.name = cards, True, label zv.x, zv.y, zv.w, zv.h = px, py, CARD_W, CARD_H zv.draw(self) for tag in set(self._zones) - wanted_zones: self._zones.pop(tag, None) self._tag_to_key.pop(tag, None) for tag in set(self._hands) - wanted_hands: self._hands.pop(tag, None) self._tag_to_key.pop(tag, None) # ----- off-board reads (decks/discards/banishes/hand counts for the info panels) ---------
[docs] def deck_summary(self, key: DeckKey) -> tuple[int, RenderCard | None]: """The card count and top render-card of a deck, from the active render source.""" for deck_key, count, top in self._render_decks(): if deck_key == key: return count, top return 0, None
[docs] def zone_render_cards(self, key: ZoneKey) -> list[RenderCard]: """The render-cards held in a zone (e.g. a discard or banish pile), bottom to top, from the active render source. Empty if the zone is absent.""" for zone_key, cards in self._render_zones(): if zone_key == key: return cards return []
[docs] def hand_count(self, seat: PlayerId) -> int: """How many cards ``seat`` holds, from the active render source.""" return len(self.zone_render_cards(ZoneKey(seat, ZoneRole.HAND)))
def _reconcile_sprites(self) -> None: w, h = self._canvas_size() wanted: set[str] = set() rendered = list(self._render_battlefield()) home = self._home_positions(rendered, w, h) for rc, pos in rendered: tag = card_tag(rc.id) wanted.add(tag) x, y = home.get(rc.id) or to_canvas(pos, flipped=self._flipped, canvas_w=w, canvas_h=h) sp = self._sprites.get(tag) if sp is None: sp = CardSpriteVisual(rc, x, y, tag, images=self._images) self._sprites[tag] = sp sp.card, sp.x, sp.y = rc, x, y chosen = rc.id in self._selection sp.bowed_preview = chosen and self._selection_bows sp.draw(self, selected=tag in self._selected or chosen) for tag in set(self._sprites) - wanted: self._sprites.pop(tag, None) self._selected.discard(tag) def _home_positions(self, rendered, w: int, h: int) -> dict[str, tuple[int, int]]: """Stacked home-row positions for the unplaced cards among ``rendered``, grouped per owner: copies of one printed card share a column and step down by ``HOME_STACK_OFFSET``, while the stronghold, sensei, and distinct holdings each take their own column. Personalities lay out in the front (personalities) row; everything else in the holdings row.""" holdings: dict[PlayerId | None, list[tuple[str, object]]] = {} personalities: dict[PlayerId | None, list[tuple[str, object]]] = {} for rc, pos in rendered: if pos is None or pos.x < 0 or pos.y < 0: key = getattr(rc, "printed_id", None) or rc.id bucket = personalities if isinstance(rc, DynastyPersonality) else holdings bucket.setdefault(rc.owner, []).append((rc.id, key)) positions: dict[str, tuple[int, int]] = {} for personality_row, by_owner in ((False, holdings), (True, personalities)): for owner, unplaced in by_owner.items(): seat_at_bottom = (owner or self.seat) is self.seat positions.update( home_stack_positions( unplaced, w, h, seat_at_bottom=seat_at_bottom, offset=HOME_STACK_OFFSET, personality_row=personality_row, ) ) return positions def _province_keys_by_owner(self) -> dict[PlayerId, list[ZoneKey]]: by_owner: dict[PlayerId, list[ZoneKey]] = {seat: [] for seat in self._render_seats()} for key in self._zone_keys(): if key.role is ZoneRole.PROVINCE: by_owner.setdefault(key.owner, []).append(key) for keys in by_owner.values(): keys.sort(key=lambda k: k.idx or 0) return by_owner def _canvas_size(self) -> tuple[int, int]: w, h = self.winfo_width(), self.winfo_height() return (max(w, self._cw), max(h, self._ch)) def _on_configure(self, event: tk.Event) -> None: if event.width > 1 and event.height > 1: self._cw, self._ch = event.width, event.height if self.state is not None: self.reconcile_all()