Files
mtga-assistant/src/mtga_assistant/ui/match_view.py
T
Aeth3randClaude Opus 5.5 5f3c7c13d0 Add section toggles, simplify Customize, scope header to Match
- 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>
2026-10-16 13:10:35 -04:00

303 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)