import tkinter as tk
import yasuki_gui.config as gui_config
from yasuki_core.engine.players import PlayerId
from yasuki_core.engine.table import BATTLEFIELD, ZoneKey, ZoneRole
from yasuki_core.engine.intents import (
Event,
MoveCard,
ReorderHand,
SetCardPos,
SetCardPositions,
)
from yasuki_core.game_pieces.cards import L5RCard
from yasuki_gui import theme
from yasuki_gui.config import DEFAULT_HOTKEYS, Hotkeys
from yasuki_gui.constants import CARD_H, CARD_W
from yasuki_gui.layout import card_view_placement
from yasuki_gui.services.actions import REGISTRY as ACTIONS, ActionContext
from yasuki_gui.services.drag import Drag, DragKind
from yasuki_gui.services.hittest import (
bounds_contains as hittest_bounds_contains,
resolve_drop_target as hittest_resolve_drop_target,
)
from yasuki_gui.services.permissions import can_interact
from yasuki_gui.tags import card_tag
from yasuki_gui.ui.images import load_back_image as _lbi, load_image as _li
from yasuki_gui.visuals import MarqueeBoxVisual
# How much larger than its on-board size the V-key card preview renders.
_PREVIEW_SCALE = 3.6
[docs]
class FieldController:
[docs]
def __init__(self, view) -> None:
self.view = view
self.drag: Drag = Drag()
self._hotkeys: Hotkeys = DEFAULT_HOTKEYS
self._hover_card_tag: str | None = None
self._hover_zone_tag: str | None = None
self._card_view_item: int | None = None
self._card_view_photo: object | None = None
self._marquee_start: tuple[int, int] | None = None
self._marquee_rect: int | None = None
self._hand_ghost_id: int | None = None
self._hand_ghost_photo: object | None = None
self._group_drag_init: dict[str, tuple[int, int]] = {}
self._group_mouse_start: tuple[int, int] | None = None
v = self.view
v.bind("<Button-1>", self.on_press)
v.bind("<B1-Motion>", self.on_motion)
v.bind("<Motion>", self.on_move)
v.bind("<ButtonRelease-1>", self.on_release)
v.bind("<Double-Button-1>", self.on_double_click)
v.bind("<KeyPress-Escape>", self.on_escape)
v.bind_all("<Control-t>", self.on_toggle_player)
# ----- helpers ----------------------------------------------------------
def _owner_of(self, tag: str) -> PlayerId | None:
if tag.startswith("card:"):
sp = self.view.sprites.get(tag)
return sp.card.owner if sp else None
key = self.view.key_for_tag(tag)
return key.owner if key is not None else None
def _from_canvas(self, x: int, y: int):
return self.view.canonical_pos(x, y)
def _update_hover(self, e: tk.Event) -> None:
tag = self.view.resolve_tag_at(e)
self._hover_card_tag = tag if tag and tag.startswith("card:") else None
self._hover_zone_tag = tag if tag and tag.startswith("zone:") else None
def _start_marquee(self, x: int, y: int) -> None:
self._marquee_start = (x, y)
if self._marquee_rect is None:
self._marquee_rect = self.view.create_rectangle(
x, y, x, y, outline=theme.SELECT, width=2, dash=(4, 2), tags=("marquee",)
)
else:
self.view.coords(self._marquee_rect, x, y, x, y)
self.view.itemconfig(self._marquee_rect, outline=theme.SELECT, width=2, dash=(4, 2))
self.view.tag_raise(self._marquee_rect)
def _update_marquee(self, x: int, y: int) -> None:
if self._marquee_start is None or self._marquee_rect is None:
return
x0, y0 = self._marquee_start
self.view.coords(self._marquee_rect, x0, y0, x, y)
self.view.tag_raise(self._marquee_rect)
rect_visual = MarqueeBoxVisual((min(x0, x), min(y0, y), max(x0, x), max(y0, y)))
new_sel = {tag for tag, sp in self.view.sprites.items() if sp.intersects(rect_visual)}
self.view._set_selection(new_sel)
def _end_marquee(self) -> None:
self._marquee_start = None
if self._marquee_rect is not None:
self.view.delete(self._marquee_rect)
self._marquee_rect = None
def _clear_hand_ghost(self) -> None:
if self._hand_ghost_id is not None:
self.view.delete(self._hand_ghost_id)
self._hand_ghost_id = None
self._hand_ghost_photo = None
def _draw_hand_ghost(self, card: L5RCard, x: int, y: int) -> None:
self._clear_hand_ghost()
viewer = self.view.seat
owner = card.owner
show_front = not (owner is not None and owner != viewer and not card.shown)
photo = (
_li(card.image_front, card.bowed, card.inverted, master=self.view)
if show_front
else _lbi(card.side, card.bowed, card.inverted, card.image_back, master=self.view)
)
if photo is None:
w, h = (CARD_H, CARD_W) if card.bowed else (CARD_W, CARD_H)
self._hand_ghost_id = self.view.create_rectangle(
x - w // 2,
y - h // 2,
x + w // 2,
y + h // 2,
outline=theme.SELECT,
dash=(3, 3),
width=2,
tags=("hand-ghost",),
)
return
self._hand_ghost_photo = photo
self._hand_ghost_id = self.view.create_image(x, y, image=photo, tags=("hand-ghost",))
# ----- press / motion / release -----------------------------------------
def _toggle_selection_at(self, tag: str | None, e: tk.Event) -> None:
"""While the engine awaits a choice, a click on a candidate card toggles its selection
(the field ignores non-candidates); clicks elsewhere do nothing."""
card_id = self._card_at(tag, e)
if card_id is not None:
self.view.toggle_selection(card_id)
def _card_at(self, tag: str | None, e: tk.Event) -> str | None:
"""The id of the card clicked — a battlefield sprite, one of your own hand cards, or the
card in one of your own provinces."""
if not tag:
return None
if tag.startswith("card:"):
return self.view.card_id_for_tag(tag)
if tag.startswith("zone:"):
hv = self.view.hands.get(tag)
if hv is not None:
if hv.owner is not self.view.seat:
return None
idx = hv.index_at(e.x)
if idx is None or idx >= len(hv.cards):
return None
return hv.cards[idx].id
return self._province_card_at(tag)
return None
def _province_card_at(self, tag: str) -> str | None:
"""The id of the card in your own province ``tag``, or None if it is not your province or is
empty."""
key = self.view.key_for_tag(tag)
if not isinstance(key, ZoneKey) or key.role is not ZoneRole.PROVINCE:
return None
if key.owner is not self.view.seat:
return None
zv = self.view.zones.get(tag)
return zv.cards[-1].id if zv is not None and zv.cards else None
def _activate_card_at(self, tag: str | None, e: tk.Event) -> None:
"""In rules mode, a click on a card invokes the action it offers (e.g. recruit a holding);
the host resolves which action that card offers."""
card_id = self._card_at(tag, e)
if card_id is not None and self.view.on_card_activated is not None:
self.view.on_card_activated(card_id)
[docs]
def on_press(self, e: tk.Event) -> None:
self.view.focus_set()
self._hide_card_view() # any pointer action dismisses a floating preview
tag = self.view.resolve_tag_at(e)
if self.view.selecting:
self._toggle_selection_at(tag, e)
return
if self.view.rules_mode:
self._activate_card_at(tag, e)
return
if not tag:
self.view._clear_selection()
self._start_marquee(e.x, e.y)
self.drag = Drag()
return
owner = self._owner_of(tag)
if not can_interact(self.view, owner):
self.drag = Drag()
return
if tag.startswith("card:"):
sel = getattr(self.view, "_selected", set())
if tag not in sel:
self.view._set_selection({tag})
sel = {tag}
if len(sel) > 1:
self._group_drag_init = {
t: (sp.x, sp.y)
for t in sel
if (sp := self.view.sprites.get(t)) and can_interact(self.view, sp.card.owner)
}
self._group_mouse_start = (e.x, e.y)
else:
self._group_drag_init = {}
self._group_mouse_start = None
sp = self.view.sprites.get(tag)
if sp:
self.drag = Drag(
kind=DragKind.CARD,
src_tag=tag,
sprite_tag=tag,
card=sp.card,
offset=(e.x - sp.x, e.y - sp.y),
)
return
if tag.startswith("zone:"):
hv = self.view.hands.get(tag)
if hv is not None:
idx = hv.index_at(e.x)
if idx is None or idx >= len(hv.cards):
return
card = hv.cards[idx]
self.drag = Drag(
kind=DragKind.HAND,
src_tag=tag,
card=card,
src_bbox=hv.bbox,
hand_origin_index=idx,
offset=(CARD_W // 2, CARD_H // 2),
)
self._draw_hand_ghost(card, e.x, e.y)
return
[docs]
def on_motion(self, e: tk.Event) -> None:
d = self.drag
if self._marquee_start is not None:
self._update_marquee(e.x, e.y)
if d.kind is DragKind.CARD and self._group_mouse_start and self._group_drag_init:
self._drag_group(e)
return
if d.kind is DragKind.HAND and d.src_tag and d.card:
if d.left_source(e.x, e.y):
self._lift_hand_card_to_battlefield(e)
else:
self._draw_hand_ghost(d.card, e.x, e.y)
return
if d.kind is DragKind.CARD and d.sprite_tag:
sp = self.view.sprites.get(d.sprite_tag)
if sp:
sp.move_to(self.view, e.x - d.offset[0], e.y - d.offset[1])
[docs]
def on_move(self, e: tk.Event) -> None:
d = self.drag
if d.kind is DragKind.CARD and self._group_mouse_start and self._group_drag_init:
self._drag_group(e)
return
if d.kind is DragKind.HAND and d.src_tag and d.card:
if d.left_source(e.x, e.y):
self._lift_hand_card_to_battlefield(e)
else:
self._draw_hand_ghost(d.card, e.x, e.y)
return
self._update_hover(e)
if self._marquee_start is not None and d.kind is DragKind.NONE:
self._update_marquee(e.x, e.y)
if d.kind is DragKind.CARD and d.sprite_tag and not self._group_drag_init:
sp = self.view.sprites.get(d.sprite_tag)
if sp:
sp.move_to(self.view, e.x - d.offset[0], e.y - d.offset[1])
def _drag_group(self, e: tk.Event) -> None:
dx = e.x - self._group_mouse_start[0]
dy = e.y - self._group_mouse_start[1]
for t, (x0, y0) in self._group_drag_init.items():
sp = self.view.sprites.get(t)
if sp:
sp.move_to(self.view, x0 + dx, y0 + dy)
def _lift_hand_card_to_battlefield(self, e: tk.Event) -> None:
self._clear_hand_ghost()
card = self.drag.card
events = self.view.dispatch(
MoveCard(card.id, BATTLEFIELD, position=self._from_canvas(e.x, e.y))
)
self.drag = self._grab_moved_sprite(events)
def _grab_moved_sprite(self, events: list[Event]) -> Drag:
"""Pick up the sprite a hand drag-out just created so the gesture keeps dragging it."""
for event in events:
intent = event.intent
if isinstance(intent, MoveCard) and intent.to == BATTLEFIELD:
tag = card_tag(intent.card_id)
sp = self.view.sprites.get(tag)
if sp:
return Drag(kind=DragKind.CARD, src_tag=tag, sprite_tag=tag, card=sp.card)
return Drag()
[docs]
def on_release(self, e: tk.Event) -> None:
if self._marquee_start is not None:
self._end_marquee()
d = self.drag
self.drag = Drag()
self._clear_hand_ghost()
group_init = self._group_drag_init
self._group_drag_init = {}
self._group_mouse_start = None
if d.kind is DragKind.HAND and d.src_tag and d.card:
hv = self.view.hands.get(d.src_tag)
if hv and hittest_bounds_contains(hv.bbox, e.x, e.y):
idx = hv.index_at(e.x)
if idx is None:
idx = len(hv.cards)
self.view.dispatch(ReorderHand(d.card.id, idx))
return
if d.kind is not DragKind.CARD or not d.sprite_tag:
return
sp = self.view.sprites.get(d.sprite_tag)
if not sp:
return
if group_init:
moves = tuple(
(self.view.card_id_for_tag(t), *self._from_canvas(s.x, s.y))
for t in group_init
if (s := self.view.sprites.get(t))
)
self.view.dispatch(SetCardPositions(moves))
return
drop = hittest_resolve_drop_target(self.view, sp.x, sp.y)
key = self.view.key_for_tag(drop) if drop else None
if isinstance(key, ZoneKey):
self.view.dispatch(MoveCard(d.card.id, key))
return
pos = self._from_canvas(sp.x, sp.y)
self.view.dispatch(SetCardPos(d.card.id, pos.x, pos.y))
# ----- double click / context / keys ------------------------------------
[docs]
def on_double_click(self, e: tk.Event) -> None:
tag = self.view.resolve_tag_at(e)
if not tag:
return
if tag.startswith("zone:"):
key = self.view.key_for_tag(tag)
zone = self.view.state.zones.get(key) if isinstance(key, ZoneKey) else None
if zone is None or key.role is ZoneRole.HAND or not zone.cards:
return
if not can_interact(self.view, key.owner):
return
zv = self.view.zones.get(tag)
pos = self._from_canvas(zv.x, zv.y) if zv else None
self.view.dispatch(MoveCard(zone.cards[-1].id, BATTLEFIELD, position=pos))
return
if tag.startswith("card:"):
ctx = ActionContext(card_tag=tag, event=e, owner=self._owner_of(tag))
act = ACTIONS["card.toggle_bow"]
if act.when(self.view, ctx):
act.run(self.view, ctx)
[docs]
def on_escape(self, e: tk.Event) -> None:
self.view._clear_selection()
[docs]
def on_key(self, e: tk.Event) -> None:
key = getattr(e, "keysym", "").lower()
hk = self._hotkeys
if key == hk.view:
# Toggle: a second V closes the preview; otherwise open it for the card under the
# pointer (works over any zone — battlefield, hand, or province).
if self._preview_showing():
self._hide_card_view()
else:
self._show_card_view(e)
return
self._hide_card_view() # any other key dismisses a floating preview
if self._hover_zone_tag and key in {hk.flip, hk.fill, hk.destroy, hk.invert}:
ctx = ActionContext(
zone_tag=self._hover_zone_tag, event=e, owner=self._owner_of(self._hover_zone_tag)
)
action_id = {
hk.flip: "zone.toggle_flip",
hk.fill: "zone.fill",
hk.destroy: "zone.destroy",
hk.invert: "zone.discard",
}[key]
self._run_if_enabled(action_id, ctx)
return
sel = getattr(self.view, "_selected", set())
target_tag = next(iter(sel)) if sel else self._hover_card_tag
if not target_tag:
return
ctx = ActionContext(card_tag=target_tag, event=e, owner=self._owner_of(target_tag))
action_id = {
hk.bow: "card.toggle_bow",
hk.flip: "card.toggle_flip",
hk.invert: "card.toggle_invert",
}.get(key)
if action_id:
self._run_if_enabled(action_id, ctx)
def _run_if_enabled(self, action_id: str, ctx: ActionContext) -> None:
act = ACTIONS[action_id]
if act.when(self.view, ctx):
act.run(self.view, ctx)
def _preview_showing(self) -> bool:
# A board redraw deletes every canvas item, so verify our item still exists rather than
# trusting the stored id, which a redraw would leave stale and desync the toggle.
return self._card_view_item is not None and bool(
self.view.find_withtag(self._card_view_item)
)
def _hide_card_view(self) -> None:
if self._card_view_item is not None:
self.view.delete(self._card_view_item)
self._card_view_item = None
self._card_view_photo = None
def _card_under_pointer(self, e: tk.Event) -> tuple[L5RCard, int, int] | None:
"""The card under the pointer and its canvas centre, across any zone: a battlefield sprite,
the hovered card in a hand, or the top card of a province or pile. None if none is there."""
tag = self.view.resolve_tag_at(e)
if not tag:
return None
if tag.startswith("card:"):
sprite = self.view.sprites.get(tag)
return (sprite.card, sprite.x, sprite.y) if sprite else None
key = self.view.key_for_tag(tag)
if key is not None and key.role is ZoneRole.HAND:
hand = self.view.hands.get(tag)
if hand is None or not hand.cards:
return None
px = self.view.winfo_pointerx() - self.view.winfo_rootx()
idx = hand.index_at(px)
idx = len(hand.cards) - 1 if idx is None else idx
cx, cy = hand.center_for_index(idx)
return (hand.cards[idx], cx, cy)
zone = self.view.zones.get(tag)
if zone is None or not zone.cards:
return None
return (zone.cards[-1], zone.x, zone.y) # a province or pile shows its top card
def _show_card_view(self, e: tk.Event) -> None:
"""Float an enlarged, upright preview of the card under the pointer beside it — its front
when face up, else its back, so a hidden front never shows."""
found = self._card_under_pointer(e)
if found is None:
return
card, cx, cy = found
canvas_h = self.view.winfo_height()
view_h = min(int(_PREVIEW_SCALE * CARD_H), max(CARD_H, canvas_h - 20))
view_w = view_h * CARD_W // CARD_H
if card.face_up:
photo = _li(card.active_face.image_front, False, False, self.view, (view_w, view_h))
else:
photo = _lbi(card.side, False, False, card.image_back, self.view, (view_w, view_h))
if photo is None:
return
left, top = card_view_placement(
cx, cy, CARD_W, CARD_H, view_w, view_h, self.view.winfo_width(), canvas_h
)
self._card_view_photo = photo
self._card_view_item = self.view.create_image(left, top, image=photo, anchor="nw")
[docs]
def on_toggle_player(self, e: tk.Event) -> None:
"""Switch the viewing/acting seat (debug only), flipping the board to that seat's view."""
if not getattr(gui_config, "DEBUG_MODE", False):
return
self.view.seat = PlayerId.P2 if self.view.seat is PlayerId.P1 else PlayerId.P1
self.view.reconcile_all()
if self.view.on_local_player_changed is not None:
self.view.on_local_player_changed()