Add match, turn and history views with a dark theme
- Top bar switching between Deck, Match, Turn and History views - Match view: life, player counters, commanders, emblems, zone counts, turn timer and timeouts, land drops used/allowed, play/draw and Bo3 draw counts, with the deck view below - Turn view: per-player and total stats per turn with history, plus whole-game totals and land drops - History: every finished game (colours, winner, who was on the play); click to reopen it in the other views; imports Player-prev.log - Customize menu to show/hide items and an Arrange dialog to reorder deck sections and turn stats, remembered across sessions - Static dark theme with mint accent, rounded corners, larger text - Commanders tracked separately from emblems, with location and tax - Quieter, non-sticky warning for cards lost to MTGA log gaps - Correct Scryfall images for tokens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7dc2ce0da8
commit
5b3c8c8e63
22 files changed
+2599
-193
No files matched your search
@@ -0,0 +1,287 @@
|
||||
"""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, 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="bigNumber")
|
||||
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("Turn", "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)
|
||||
|
||||
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)
|
||||
|
||||
# The shared DeckView is re-parented into this slot when the match view is shown.
|
||||
self.deck_slot = QWidget()
|
||||
self.deck_slot_layout = QVBoxLayout(self.deck_slot)
|
||||
self.deck_slot_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
split = QSplitter(Qt.Orientation.Vertical)
|
||||
split.addWidget(top)
|
||||
split.addWidget(self.deck_slot)
|
||||
split.setChildrenCollapsible(False)
|
||||
split.setSizes([1, 1])
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.addWidget(split)
|
||||
|
||||
def render(self, t: MatchTracker, now_ms: int) -> None:
|
||||
g = t.game
|
||||
for w in (self.me, self.opp, self.info):
|
||||
w.setVisible(g is not None)
|
||||
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(str(g.turn or "–"))
|
||||
self.turn_caption.setText(whose if g.turn else "pre-game")
|
||||
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)
|
||||
Reference in new issue
Block a user