Source code for yasuki_gui.visuals.sprite

from dataclasses import dataclass

from yasuki_gui.visuals.cardface import RenderCard
from yasuki_gui import theme
from yasuki_gui.constants import (
    CARD_W,
    CARD_H,
    CARD_TAG,
    ART_TAG,
    BORDER_TAG,
    SELECT_TAG,
    LABEL_TAG,
    NOTE_TAG,
    COUNTER_TAG,
    COUNTER_BADGE_R,
)
from yasuki_gui.ui.images import load_image, load_back_image, ImageProvider
from yasuki_gui.visuals.visual import Visual
from yasuki_core.game_pieces.counters import SINCERITY, WEALTH
import tkinter as tk

# Per-counter badge colors — (fill, count-text) — so a card carrying more than one kind reads at a
# glance. Light text on the dark gold, dark text on the light powder blue.
_COUNTER_STYLE = {
    WEALTH.key: (theme.GOLD, theme.ON_DARK),
    SINCERITY.key: (theme.POWDER_BLUE, theme.INK),
}
_DEFAULT_COUNTER_STYLE = (theme.GOLD, theme.ON_DARK)


[docs] @dataclass class CardSpriteVisual(Visual): card: RenderCard x: int y: int tag: str images: ImageProvider | None = None # Show the card as bowed before the engine commits it — used to preview a producer being tapped # for gold during a payment, so the bow is undoable until the player confirms. bowed_preview: bool = False # Keep a strong reference to the last PhotoImage used when drawing art _last_image: object | None = None @property def _bowed(self) -> bool: return self.card.bowed or self.bowed_preview @property def size(self) -> tuple[int, int]: w, h = CARD_W, CARD_H return (h, w) if self._bowed else (w, h) @property def bbox(self) -> tuple[int, int, int, int]: w, h = self.size x0 = self.x - w // 2 y0 = self.y - h // 2 x1 = self.x + w // 2 y1 = self.y + h // 2 return x0, y0, x1, y1 def _side_outline(self) -> str: return theme.CARD_BORDER def _base_border_w(self) -> int: return 1 def _subtag(self, name: str) -> str: return f"{self.tag}:{name}" def _draw_art(self, canvas: tk.Canvas) -> bool: x, y = self.x, self.y w, h = self.size bowed = self._bowed inverted = self.card.inverted face_up = self.card.face_up # The presented art is the active face: a double-faced card flipped to its back shows that # back's front art, while a single-faced card is its own active face. front_art = self.card.active_face.image_front img = None if self.images is not None: if face_up: img = self.images.front(front_art, bowed, inverted) else: img = self.images.back(self.card.side, bowed, inverted, self.card.image_back) else: # fallback no-cache path img = ( load_image(front_art, bowed, inverted, master=canvas) if face_up else load_back_image( self.card.side, bowed, inverted, self.card.image_back, master=canvas ) ) if img is not None: # retain reference to prevent Tk image GC self._last_image = img canvas.create_image( x, y, image=img, tags=(self.tag, CARD_TAG, self._subtag(ART_TAG)), ) return True # No art on hand: a cream face titled with the card name, or a plain brown back when down. self._last_image = None fill = theme.CARD_FACE if face_up else theme.CARD_BACK canvas.create_rectangle( x - w // 2, y - h // 2, x + w // 2, y + h // 2, fill=fill, outline="", tags=(self.tag, CARD_TAG, self._subtag(ART_TAG)), ) if face_up: canvas.create_text( x, y, text=self.card.active_face.name, fill=theme.INK, font=theme.serif(10, "bold"), width=w - 10, justify="center", tags=(self.tag, CARD_TAG, self._subtag(LABEL_TAG)), ) return False def _draw_border(self, canvas: tk.Canvas) -> None: x, y = self.x, self.y w, h = self.size canvas.create_rectangle( x - w // 2, y - h // 2, x + w // 2, y + h // 2, outline=self._side_outline(), width=self._base_border_w(), tags=(self.tag, CARD_TAG, self._subtag(BORDER_TAG)), ) def _draw_note(self, canvas: tk.Canvas) -> None: # A bold note over the bottom half of a face-up card, the first thing read while the art # above stays visible. if not (self.card.note and self.card.face_up): return x, y = self.x, self.y w, h = self.size strip_top = y # the card centre, so the strip covers the bottom half bottom = y + h // 2 canvas.create_rectangle( x - w // 2, strip_top, x + w // 2, bottom, fill=theme.NOTE_BG, outline="", tags=(self.tag, CARD_TAG, self._subtag(NOTE_TAG)), ) canvas.create_text( x, (strip_top + bottom) // 2, text=self.card.note, fill=theme.NOTE_FG, font=theme.serif(10, "bold"), width=w - 6, justify="center", tags=(self.tag, CARD_TAG, self._subtag(NOTE_TAG)), ) def _draw_counters(self, canvas: tk.Canvas) -> None: # A badge per counter kind in the top-right corner, coloured by kind, with the count inside; # badges stack downward in a fixed order so a card's counters read consistently. counters = getattr(self.card, "counters", None) if not counters: return w, h = self.size cx = self.x + w // 2 - COUNTER_BADGE_R - 2 cy = self.y - h // 2 + COUNTER_BADGE_R + 2 # Iterate the counters actually on the card (sorted for a stable stack), not the whole # registry — so the badge system doesn't scale with the catalogue's size. for key, count in sorted(counters.items()): if count <= 0: continue fill, text_fill = _COUNTER_STYLE.get(key, _DEFAULT_COUNTER_STYLE) canvas.create_oval( cx - COUNTER_BADGE_R, cy - COUNTER_BADGE_R, cx + COUNTER_BADGE_R, cy + COUNTER_BADGE_R, fill=fill, outline=theme.CARD_BORDER, width=1, tags=(self.tag, CARD_TAG, self._subtag(COUNTER_TAG)), ) canvas.create_text( cx, cy, text=str(count), fill=text_fill, font=theme.serif(9, "bold"), tags=(self.tag, CARD_TAG, self._subtag(COUNTER_TAG)), ) cy += 2 * COUNTER_BADGE_R + 2 def _draw_selection(self, canvas: tk.Canvas, selected: bool) -> None: canvas.delete(self._subtag(SELECT_TAG)) if not selected: return x, y = self.x, self.y w, h = self.size canvas.create_rectangle( x - w // 2, y - h // 2, x + w // 2, y + h // 2, outline=theme.SELECT, width=2, tags=(self.tag, CARD_TAG, self._subtag(SELECT_TAG)), )
[docs] def draw(self, canvas: tk.Canvas, selected: bool = False) -> None: canvas.delete(self.tag) self._draw_art(canvas) self._draw_border(canvas) self._draw_note(canvas) self._draw_counters(canvas) self._draw_selection(canvas, selected) canvas.tag_raise(self._subtag(SELECT_TAG))
[docs] def update_selection(self, canvas: tk.Canvas, selected: bool) -> None: self._draw_selection(canvas, selected) canvas.tag_raise(self._subtag(SELECT_TAG))
[docs] def move_to(self, canvas: tk.Canvas, x: int, y: int) -> None: dx, dy = x - self.x, y - self.y if dx == 0 and dy == 0: return self.x, self.y = x, y # Move layers together; no redraw for layer in (ART_TAG, BORDER_TAG, SELECT_TAG, LABEL_TAG): canvas.move(self._subtag(layer), dx, dy) # Also move top-level tag to keep bbox queries consistent canvas.move(self.tag, 0, 0) # no-op but keeps tag grouping predictable
[docs] def refresh_face_state(self, canvas: tk.Canvas) -> None: # Called after flip/bow/invert changes; redraw art+border and keep selection overlay in sync # Detect if selection overlay currently exists so we can re-draw it with new geometry had_selection = bool(canvas.find_withtag(self._subtag(SELECT_TAG))) # Clear layers canvas.delete(self._subtag(ART_TAG)) canvas.delete(self._subtag(BORDER_TAG)) canvas.delete(self._subtag(LABEL_TAG)) canvas.delete(self._subtag(NOTE_TAG)) canvas.delete(self._subtag(COUNTER_TAG)) canvas.delete(self._subtag(SELECT_TAG)) # Redraw art and border self._draw_art(canvas) self._draw_border(canvas) self._draw_note(canvas) self._draw_counters(canvas) # Recreate selection overlay if it was present if had_selection: self._draw_selection(canvas, True) canvas.tag_raise(self._subtag(SELECT_TAG))