import { CSSProperties, useEffect, useReducer, useRef, useState } from "react"; import { api, Deck } from "../api"; import { ConfirmDialog, DialogFrame, usePersistedState } from "../components"; import { useBackButton } from "../backButton"; // --- Game state ------------------------------------------------------------ type Player = { id: string; name: string; /** Subtitle under the name; the deck being played, if one was picked. */ deckName: string | null; /** Art crop of that deck's commander, for the panel background. */ art: string | null; color: string; life: number; poison: number; /** Commander damage taken, keyed by the id of the player whose commander * dealt it — 21 from any single one is lethal, so they can't be summed. */ commanderDamage: Record; }; type Game = { players: Player[]; startingLife: number; commanderDamage: boolean; /** Who won the "who goes first?" roll, until it's dismissed. */ firstPlayerId: string | null; /** Bumped by a reset, so panels drop their running "−3" bubbles rather * than showing the whole jump back to starting life as one change. */ epoch: number; }; type Action = | { type: "start"; game: Game } | { type: "life"; id: string; delta: number } | { type: "poison"; id: string; delta: number } | { type: "commander"; id: string; from: string; delta: number } | { type: "first"; id: string | null } | { type: "reset" } | { type: "end" }; const POISON_LETHAL = 10; const COMMANDER_LETHAL = 21; function updatePlayer(game: Game, id: string, f: (p: Player) => Player): Game { return { ...game, players: game.players.map((p) => (p.id === id ? f(p) : p)) }; } function reducer(game: Game | null, action: Action): Game | null { if (action.type === "start") return action.game; if (action.type === "end" || !game) return null; switch (action.type) { case "life": return updatePlayer(game, action.id, (p) => ({ ...p, life: p.life + action.delta })); case "poison": return updatePlayer(game, action.id, (p) => ({ ...p, poison: Math.max(0, p.poison + action.delta), })); case "commander": // Commander damage is still damage: it comes off life as well, which is // what you'd otherwise have to remember to do by hand every time. return updatePlayer(game, action.id, (p) => { const before = p.commanderDamage[action.from] ?? 0; const after = Math.max(0, before + action.delta); return { ...p, life: p.life - (after - before), commanderDamage: { ...p.commanderDamage, [action.from]: after }, }; }); case "first": return { ...game, firstPlayerId: action.id }; case "reset": return { ...game, epoch: game.epoch + 1, firstPlayerId: null, players: game.players.map((p) => ({ ...p, life: game.startingLife, poison: 0, commanderDamage: {}, })), }; } } /** Why a player is out, or null while they're still in. Shown, not enforced: * life totals get corrected after mistakes, so nothing is locked. */ function outReason(p: Player, game: Game): string | null { if (p.life <= 0) return "Out of life"; if (p.poison >= POISON_LETHAL) return "Poisoned"; if (game.commanderDamage && Object.values(p.commanderDamage).some((d) => d >= COMMANDER_LETHAL)) { return "Commander damage"; } return null; } /** Uniform in [0, n), without the modulo bias of `% n` — these are dice. */ function randomInt(n: number): number { const buf = new Uint32Array(1); const limit = Math.floor(0x100000000 / n) * n; do { crypto.getRandomValues(buf); } while (buf[0] >= limit); return buf[0] % n; } /** Scryfall serves every crop size at the same path under a different * segment, so the stored "normal" card image gives us the art alone. */ function artCrop(imageUri: string): string | null { if (!imageUri) return null; return imageUri.replace(/(cards\.scryfall\.io\/)(normal|large|small|png)\//, "$1art_crop/"); } const PLAYER_COLORS = ["#c8553d", "#3a8fd0", "#479b63", "#8a63c9", "#e0a34a", "#3fa9a5"]; const LIFE_PRESETS = [ { life: 20, label: "Constructed", commander: false }, { life: 25, label: "Brawl", commander: false }, { life: 40, label: "Commander", commander: true }, ]; // --- Setup ------------------------------------------------------------------- type Seat = { name: string; color: string; deckId: number | null }; type Setup = { count: number; startingLife: number; commanderDamage: boolean; /** Always six long, so names and decks survive turning the count down and * back up again. */ seats: Seat[]; }; const DEFAULT_SETUP: Setup = { count: 4, startingLife: 40, commanderDamage: true, seats: PLAYER_COLORS.map((color) => ({ name: "", color, deckId: null })), }; function SeatRow({ index, seat, decks, onChange, }: { index: number; seat: Seat; decks: Deck[]; onChange: (patch: Partial) => void; }) { const nextColor = () => onChange({ color: PLAYER_COLORS[(PLAYER_COLORS.indexOf(seat.color) + 1) % PLAYER_COLORS.length], }); return (
); } function GameSetup({ current, onStart, onResume, }: { current: Game | null; onStart: (game: Game) => void; onResume: () => void; }) { const [stored, setStored] = usePersistedState("game-setup", DEFAULT_SETUP); const setup: Setup = { ...DEFAULT_SETUP, ...stored }; const patch = (p: Partial) => setStored({ ...setup, ...p }); const [decks, setDecks] = useState([]); const [starting, setStarting] = useState(false); const [confirmReplace, setConfirmReplace] = useState(false); useEffect(() => { api.listDecks().then(setDecks).catch(() => setDecks([])); }, []); async function start() { setConfirmReplace(false); setStarting(true); try { const seats = setup.seats.slice(0, setup.count); const players: Player[] = await Promise.all( seats.map(async (seat, i) => { const deck = decks.find((d) => d.id === seat.deckId) ?? null; let art: string | null = null; if (deck?.commander_oracle_id) { // Art is decoration; a deck that fails to load still plays. const contents = await api.deckContents(deck.id).catch(() => []); const commander = contents.find((e) => e.oracle_id === deck.commander_oracle_id); art = commander ? artCrop(commander.image_uri) : null; } return { id: `p${i}-${Date.now()}`, name: seat.name.trim() || `Player ${i + 1}`, deckName: deck?.name ?? null, art, color: seat.color, life: setup.startingLife, poison: 0, commanderDamage: {}, }; }), ); onStart({ players, startingLife: setup.startingLife, commanderDamage: setup.commanderDamage, firstPlayerId: null, epoch: 0, }); } finally { setStarting(false); } } return (

Game night

{current && (
Game in progress
{current.players.map((p) => `${p.name} ${p.life}`).join(" · ")}
)}
{[2, 3, 4, 5, 6].map((n) => ( ))}
{LIFE_PRESETS.map((p) => ( ))} { const n = Math.floor(Number(e.target.value)); if (n > 0) patch({ startingLife: n }); }} />
{setup.seats.slice(0, setup.count).map((seat, i) => ( patch({ seats: setup.seats.map((s, j) => (j === i ? { ...s, ...p } : s)) }) } /> ))}
{decks.length > 0 && (
A deck with a commander puts its art behind that player's life total.
)}
{confirmReplace && ( setConfirmReplace(false)} /> )}
); } // --- Board ------------------------------------------------------------------- /** One half of a player's panel: tap for ±1, hold for ±10 (repeating). */ function LifeHalf({ sign, onChange }: { sign: 1 | -1; onChange: (delta: number) => void }) { const onChangeRef = useRef(onChange); onChangeRef.current = onChange; const timer = useRef | null>(null); // Down on this button and not dragged off it: only then does lifting count. const pressed = useRef(false); // The hold already applied its ±10s, so lifting mustn't add a ±1 as well. const held = useRef(false); const stop = () => { pressed.current = false; if (timer.current) clearTimeout(timer.current); timer.current = null; }; useEffect(() => stop, []); return ( ); } /** "−7"-style running total of recent changes, cleared after a pause — how * you check a big swing was entered right without doing the subtraction. */ function useRecentDelta(life: number): number { const [delta, setDelta] = useState(0); const prev = useRef(life); const timer = useRef | null>(null); useEffect(() => { const diff = life - prev.current; prev.current = life; if (diff === 0) return; setDelta((d) => d + diff); if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(() => setDelta(0), 1800); }, [life]); useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []); return delta; } function PlayerPanel({ player, game, rotated, dispatch, onOpenCounters, }: { player: Player; game: Game; rotated: boolean; dispatch: (a: Action) => void; onOpenCounters: () => void; }) { const delta = useRecentDelta(player.life); const out = outReason(player, game); const worstCommander = Math.max(0, ...Object.values(player.commanderDamage)); const isFirst = game.firstPlayerId === player.id; return (
dispatch({ type: "life", id: player.id, delta: d })} /> dispatch({ type: "life", id: player.id, delta: d })} />
{player.name} {player.deckName && {player.deckName}}
{player.life} {delta !== 0 && ( 0 ? "up" : "down"}`}> {delta > 0 ? `+${delta}` : `−${-delta}`} )}
{out &&
{out}
}
{isFirst && ( )} {player.poison > 0 && ( ☠ {player.poison} )} {game.commanderDamage && worstCommander > 0 && ( ⚔ {worstCommander} )}
); } function Stepper({ value, onChange, big, }: { value: number; onChange: (delta: number) => void; /** Adds ±5 buttons, for life. */ big?: boolean; }) { return ( {big && } {value} {big && } ); } function CountersDialog({ player, game, dispatch, onClose, }: { player: Player; game: Game; dispatch: (a: Action) => void; onClose: () => void; }) { const opponents = game.players.filter((p) => p.id !== player.id); return (
Life dispatch({ type: "life", id: player.id, delta: d })} />
Poison · out at {POISON_LETHAL} dispatch({ type: "poison", id: player.id, delta: d })} />
{game.commanderDamage && ( <>
Commander damage taken · out at {COMMANDER_LETHAL} from one, and it comes off life too
{opponents.map((o) => (
{o.name} dispatch({ type: "commander", id: player.id, from: o.id, delta: d })} />
))} )}
); } type Roll = { id: number; label: string; result: string }; const DICE = [4, 6, 8, 10, 12, 20]; function DiceDialog({ onClose }: { onClose: () => void }) { const [rolls, setRolls] = useState([]); const nextId = useRef(0); const push = (label: string, result: string) => setRolls((r) => [{ id: nextId.current++, label, result }, ...r].slice(0, 8)); const [latest, ...earlier] = rolls; return (
{DICE.map((n) => ( ))}
{latest ? ( // Keyed per roll so the pop-in replays even when the number repeats.
{latest.result} {latest.label}
) : ( Pick a die to roll. )}
{earlier.length > 0 && (
Earlier: {earlier.map((r) => `${r.label} ${r.result}`).join(" · ")}
)}
); } /** Keep the screen on while the board is up — a phone dimming mid-game is the * most common annoyance with a life counter. Best effort: not every webview * implements the Wake Lock API, and it's dropped whenever the app is hidden, * so it's re-requested on return. */ function useScreenWakeLock() { useEffect(() => { type Sentinel = { release: () => Promise }; const wakeLock = ( navigator as Navigator & { wakeLock?: { request: (type: "screen") => Promise } } ).wakeLock; if (!wakeLock) return; let sentinel: Sentinel | null = null; let active = true; const request = () => { if (document.visibilityState !== "visible") return; wakeLock .request("screen") .then((s) => { if (active) sentinel = s; else s.release().catch(() => {}); }) .catch(() => {}); }; request(); document.addEventListener("visibilitychange", request); return () => { active = false; document.removeEventListener("visibilitychange", request); sentinel?.release().catch(() => {}); }; }, []); } function GameBoard({ game, dispatch, onLeave, }: { game: Game; dispatch: (a: Action) => void; onLeave: () => void; }) { // Phones lie flat in the middle of the table, so by default the far row is // turned to face the players sitting across from it. On a desktop screen // everyone's looking from the same side. const [faceToFace, setFaceToFace] = usePersistedState( "game-face-to-face", window.matchMedia("(max-width: 720px)").matches, ); const [menuOpen, setMenuOpen] = useState(false); const [diceOpen, setDiceOpen] = useState(false); const [confirmReset, setConfirmReset] = useState(false); const [countersFor, setCountersFor] = useState(null); useScreenWakeLock(); // On a phone the board covers the whole screen, bottom nav included, so // back is the way off it. useBackButton(onLeave); const n = game.players.length; const nearCount = Math.ceil(n / 2); // Seats run clockwise: along the near row left to right, then back along // the far row — which, turned around, means right to left as drawn. const rows = faceToFace ? [game.players.slice(nearCount).reverse(), game.players.slice(0, nearCount)] : [game.players.slice(0, nearCount), game.players.slice(nearCount)]; const countersPlayer = game.players.find((p) => p.id === countersFor) ?? null; function pickFirst() { setMenuOpen(false); dispatch({ type: "first", id: game.players[randomInt(n)].id }); } const renderRow = (row: Player[], r: number) => (
{row.map((p) => ( setCountersFor(p.id)} /> ))}
); return (
{renderRow(rows[0], 0)} {/* Its own strip rather than floating over the panels: with an odd number of seats in a row, the centre is the middle player's name. */}
{renderRow(rows[1], 1)} {menuOpen && ( setMenuOpen(false)}>
)} {diceOpen && setDiceOpen(false)} />} {confirmReset && ( { setConfirmReset(false); dispatch({ type: "reset" }); }} onCancel={() => setConfirmReset(false)} /> )} {countersPlayer && ( setCountersFor(null)} /> )}
); } // --- View -------------------------------------------------------------------- function loadGame(): Game | null { try { const raw = localStorage.getItem("game-current"); return raw ? (JSON.parse(raw) as Game) : null; } catch { return null; } } /** * Game-night tools: a life counter for 2–6 players with poison and commander * damage, dice, and a "who goes first" pick. The game lives in localStorage * rather than the library — it's per-device scratch state, not something to * sync — so switching tabs or the app being killed mid-game loses nothing. */ export function GameView() { const [game, dispatch] = useReducer(reducer, null, loadGame); // Remembered so coming back to the tab returns to the table, not setup. const [seated, setSeated] = usePersistedState("game-seated", false); useEffect(() => { if (game) localStorage.setItem("game-current", JSON.stringify(game)); else localStorage.removeItem("game-current"); }, [game]); if (game && seated) { return setSeated(false)} />; } return ( setSeated(true)} onStart={(g) => { dispatch({ type: "start", game: g }); setSeated(true); }} /> ); }