"""Match view: both players' status and game info on top, the deck view below.""" from __future__ import annotations import re from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QFrame, QGridLayout, QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidget, ) from ..carddb import CardDB from ..gamestate import GameState, MatchTracker from . import theme from .card_list import CardList, Row, Section from .toggles import Toggles MATCH_TOGGLES = [ ("game_info", "Game info (turn, phase, score)"), ("play_draw", "On the play / on the draw"), ("duration", "Game duration"), ("life", "Life totals"), ("counters", "Player counters (poison, energy…)"), ("zones", "Hand / library / graveyard counts"), ("land_drop", "Land drop (played/allowed)"), ("timer", "Turn timer"), ("timeouts", "Timeouts used"), ("commander", "Commander"), ("emblems", "Emblems"), ] def pretty_enum(value: str) -> str: """'Phase_Main1' -> 'Main 1', 'Step_BeginCombat' -> 'Begin combat'.""" name = value.split("_", 1)[-1] words = re.findall(r"[A-Z][a-z]*|\d+", name) return " ".join([words[0]] + [w.lower() for w in words[1:]]) if words else "" def fmt_seconds(sec: float) -> str: sec = int(sec) h, rem = divmod(sec, 3600) m, s = divmod(rem, 60) return f"{h}:{m:02}:{s:02}" if h else f"{m}:{s:02}" def _label(text: str = "", name: str | None = None) -> QLabel: lbl = QLabel(text) if name: lbl.setObjectName(name) return lbl class PlayerCard(QFrame): hovered = Signal(int) def __init__(self, cards: CardDB, toggles: Toggles, parent=None): super().__init__(parent) self.setObjectName("card") self.cards = cards self.toggles = toggles self.title = _label(name="cardTitle") self.life = _label(name="bigNumber") self.life_caption = _label("life", "muted") self.counters = _label() self.counters.setWordWrap(True) self.zones = _label() self.timer = _label() self.timeouts = _label(name="muted") self.land = _label() self.commander = CardList() self.commander.setMaximumHeight(90) self.commander.hovered.connect(self.hovered) self.emblems = CardList() self.emblems.setMaximumHeight(120) self.emblems.hovered.connect(self.hovered) life_row = QHBoxLayout() life_row.addWidget(self.life) life_row.addWidget(self.life_caption, 0, Qt.AlignmentFlag.AlignBottom) life_row.addStretch(1) timer_col = QVBoxLayout() timer_col.addWidget(self.timer, 0, Qt.AlignmentFlag.AlignRight) timer_col.addWidget(self.timeouts, 0, Qt.AlignmentFlag.AlignRight) timer_col.addWidget(self.land, 0, Qt.AlignmentFlag.AlignRight) life_row.addLayout(timer_col) lay = QVBoxLayout(self) lay.setContentsMargins(14, 10, 14, 12) lay.setSpacing(6) lay.addWidget(self.title) lay.addLayout(life_row) lay.addWidget(self.counters) lay.addWidget(self.zones) lay.addWidget(self.commander) lay.addWidget(self.emblems) lay.addStretch(1) def render(self, g: GameState, seat: int, name: str, now_ms: int) -> None: t = self.toggles active = " · active" % theme.MINT_TEXT if g.active_player == seat else "" play = "" if t.on("play_draw") and g.on_play is not None: on_play = g.on_play == seat play = (f" · " f"{'▶ on the play' if on_play else 'on the draw'}") self.title.setText(f"{name}{active}{play}") life = g.life.get(seat) self.life.setText(str(life) if life is not None else "–") self.life.setVisible(t.on("life")) self.life_caption.setVisible(t.on("life")) counters = {k: v for k, v in (g.player_counters.get(seat) or {}).items() if v} self.counters.setText(" ".join(f"{v} {self.cards.counter_name(k).lower()}" for k, v in sorted(counters.items())) or f"No counters") self.counters.setVisible(t.on("counters")) self.zones.setText( f"Hand {g.zone_size('ZoneType_Hand', seat)} · " f"Library {g.zone_size('ZoneType_Library', seat)} · " f"Graveyard {g.zone_size('ZoneType_Graveyard', seat)} · " # Exile is a shared zone, so count the seat's visible cards in it. f"Exile {sum(g.cards_in('ZoneType_Exile', owner=seat).values())}" ) self.zones.setVisible(t.on("zones")) self.render_timer(g, seat, now_ms) drop = g.land_drop(seat) if drop is None: self.land.setText(f"Land drop –") self.land.setToolTip("Lands can only be played on your own turn") else: used, allowed = drop color = theme.MINT_TEXT if used < allowed else theme.MUTED self.land.setText(f"Land drop {used}/{allowed}") self.land.setToolTip(f"{used} of {allowed} land plays used this turn" + (" (still available)" if used < allowed else "")) self.land.setVisible(t.on("land_drop") and g.turn > 0) if g.max_timeouts and seat in g.timeouts_used: self.timeouts.setText(f"Timeouts {g.timeouts_used[seat]}/{g.max_timeouts}") elif g.max_timeouts: self.timeouts.setText(f"Timeouts –/{g.max_timeouts}") else: self.timeouts.setText("") self.timeouts.setVisible(t.on("timeouts") and bool(g.max_timeouts)) status = g.commander_status(seat) if status: tax = g.commander_tax(seat) title = "Commander" + (f" (tax +{tax})" if tax else "") self.commander.set_sections([Section("commander", title, [ Row(self.cards.get(grp), 0, where or "hidden") for grp, where in status ])]) self.commander.setVisible(t.on("commander") and bool(status)) emblems = g.emblems(seat) self.emblems.set_sections([Section("emblems", "Emblems", [ Row(self.cards.get(grp), n) for grp, n in emblems.items() ])] if emblems else []) self.emblems.setVisible(t.on("emblems") and bool(emblems)) def render_timer(self, g: GameState, seat: int, now_ms: int) -> None: timer = g.turn_timer(seat, now_ms) if not g.complete else None if timer is None: self.timer.setText("") else: remaining, warn, running = timer if not running: color = theme.MUTED else: color = theme.DANGER if remaining <= warn else theme.MINT_TEXT self.timer.setText(f"" f"◷ {fmt_seconds(remaining)}") self.timer.setToolTip("Turn timer running" if running else "Time available before the turn timer runs out (starts when the rope appears)") self.timer.setVisible(self.toggles.on("timer") and timer is not None) class MatchView(QWidget): hovered = Signal(int) def __init__(self, cards: CardDB, toggles: Toggles, turn_view: QWidget, deck_view: QWidget, parent=None): super().__init__(parent) self.cards = cards self.toggles = toggles self.me = PlayerCard(cards, toggles) self.opp = PlayerCard(cards, toggles) self.me.hovered.connect(self.hovered) self.opp.hovered.connect(self.hovered) self.info = QFrame() self.info.setObjectName("card") self.turn_label = _label(name="statValue") self.turn_caption = _label(name="muted") self.phase = _label() self.score = _label() self.draws = _label(name="muted") self.draws.setAlignment(Qt.AlignmentFlag.AlignCenter) self.duration = _label(name="muted") grid = QGridLayout(self.info) grid.setContentsMargins(14, 10, 14, 12) for row, w in enumerate([_label("Match", "cardTitle"), self.turn_label, self.turn_caption, self.phase, self.score, self.draws, self.duration]): grid.addWidget(w, row, 0, Qt.AlignmentFlag.AlignHCenter) grid.setRowStretch(7, 1) self.turn_view = turn_view top = self.top = QWidget() top_lay = QHBoxLayout(top) top_lay.setContentsMargins(0, 0, 0, 0) top_lay.addWidget(self.me, 3) top_lay.addWidget(self.info, 2) top_lay.addWidget(self.opp, 3) # Players + match info, then turn stats, then the deck lists; heights are draggable. self.split = QSplitter(Qt.Orientation.Vertical) self.split.addWidget(top) self.split.addWidget(turn_view) self.split.addWidget(deck_view) self.split.setChildrenCollapsible(False) self.split.setStretchFactor(0, 2) self.split.setStretchFactor(1, 3) self.split.setStretchFactor(2, 5) lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.addWidget(self.split) self._panels = {"you": True, "match": True, "opponent": True} self._has_game = False def show_panels(self, you: bool, match: bool, opponent: bool, turns: bool) -> None: """Hide/show panels; a row with every panel hidden collapses out of the splitter.""" self._panels = {"you": you, "match": match, "opponent": opponent} self.top.setVisible(you or match or opponent) self.turn_view.setVisible(turns) self._apply_panels() def _apply_panels(self) -> None: # render() toggles these for "no game yet", so combine both conditions. self.me.setVisible(self._has_game and self._panels["you"]) self.info.setVisible(self._has_game and self._panels["match"]) self.opp.setVisible(self._has_game and self._panels["opponent"]) def render(self, t: MatchTracker, now_ms: int) -> None: g = t.game self._has_game = g is not None self._apply_panels() if g is None: return self.me.render(g, g.my_seat, "You", now_ms) self.opp.render(g, g.opp_seat, t.opponent_name or "Opponent", now_ms) on = self.toggles.on whose = "your turn" if g.active_player == g.my_seat else "their turn" self.turn_label.setText(f"Turn {g.turn}" if g.turn else "Pre-game") self.turn_caption.setText(whose if g.turn else "") phase = pretty_enum(g.phase) if g.phase else "" step = pretty_enum(g.step) if g.step else "" self.phase.setText(" · ".join(x for x in (phase, step) if x)) wins = t.game_results.count("Won") score = f"Game {g.game_number}" if t.game_results: score += f" · {wins}–{len(t.game_results) - wins}" if not t.in_match and t.last_result: score += f" · match {t.last_result.lower()}" self.score.setText(score) for w in (self.turn_label, self.turn_caption, self.phase, self.score): w.setVisible(on("game_info")) # Best-of-three: who has been on the draw more often (the loser of a game chooses next). draws = t.games_on_draw() if g.best_of == 3 and t.on_play: mine, theirs = draws.get(g.my_seat, 0), draws.get(g.opp_seat, 0) more = "you" if mine > theirs else "they" if theirs > mine else "" self.draws.setText(f"On the draw: you {mine} · them {theirs}" + (f"
{more} had more games on the draw" if more else "
even so far")) self.draws.setToolTip(" · ".join( f"Game {n}: {'you' if seat == g.my_seat else 'they'} on the play" for n, seat in sorted(t.on_play.items()))) self.draws.setVisible(on("play_draw") and g.best_of == 3 and bool(t.on_play)) self.tick(t, now_ms) def tick(self, t: MatchTracker, now_ms: int) -> None: """Cheap per-second update of the clocks.""" g = t.game if g is None: return self.me.render_timer(g, g.my_seat, now_ms) self.opp.render_timer(g, g.opp_seat, now_ms) if g.started_ms is not None: end = g.last_ms if g.complete or not t.in_match else max(now_ms, g.last_ms or now_ms) self.duration.setText(f"Game time {fmt_seconds((end - g.started_ms) / 1000)}") self.duration.setVisible(self.toggles.on("duration") and g.started_ms is not None)