- Customize menu: a "Sections" group to show/hide each Match panel (You, Match, Opponent, Turn stats, My deck, Preview, Opponent's cards); rows collapse when all their panels are hidden. Player panel details follow, then Arrange. The submenus duplicating Arrange are gone. - The top bar's match summary only shows on the Match tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
303 lines
13 KiB
Python
303 lines
13 KiB
Python
"""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 = " · <span style='color:%s'>active</span>" % 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" · <span style='color:{theme.MINT_TEXT if on_play else theme.MUTED}'>"
|
||
f"{'▶ on the play' if on_play else 'on the draw'}</span>")
|
||
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"<b>{v}</b> {self.cards.counter_name(k).lower()}"
|
||
for k, v in sorted(counters.items()))
|
||
or f"<span style='color:{theme.MUTED}'>No counters</span>")
|
||
self.counters.setVisible(t.on("counters"))
|
||
|
||
self.zones.setText(
|
||
f"Hand <b>{g.zone_size('ZoneType_Hand', seat)}</b> · "
|
||
f"Library <b>{g.zone_size('ZoneType_Library', seat)}</b> · "
|
||
f"Graveyard <b>{g.zone_size('ZoneType_Graveyard', seat)}</b> · "
|
||
# Exile is a shared zone, so count the seat's visible cards in it.
|
||
f"Exile <b>{sum(g.cards_in('ZoneType_Exile', owner=seat).values())}</b>"
|
||
)
|
||
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"<span style='color:{theme.MUTED}'>Land drop –</span>")
|
||
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 <span style='color:{color}; font-weight:bold'>{used}/{allowed}</span>")
|
||
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"<span style='font-size:{theme.FONT_PT * 2}pt; color:{color}; font-weight:bold'>"
|
||
f"◷ {fmt_seconds(remaining)}</span>")
|
||
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"<br><b>{more} had more games on the draw</b>" if more else "<br>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)
|