"""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)