Library filters: - "Only spare copies" quick filter (Library): cards with more copies owned than the decks' main boards use, the opposite of the ⚠ over-assignment warning. Rows show "N spare" while it's on. - Rarity toggles (C/U/R/M) in the shared criteria panel, so Library, Card Search and the set browser all get them. Any printing counts, since the library tracks cards rather than printings; within a set it's that set's printing. Arena-only printings don't count for a card that also exists on paper, so a digital re-rarity can't make a paper common read as rare. Suggestions: - "From my collection" toggle (remembered) narrows candidates to owned cards before scoring and paging, so "Suggest more" keeps finding them. - Each suggestion reports copies other decks already use, shown as "N spare" / "all in other decks". suggest_cards now takes a SuggestOptions struct instead of a bare `unusual` flag. Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a running change bubble, poison and per-opponent commander damage (which also comes off life), out-of-the-game markers, dice and a coin, and a random first player. A seat's deck puts its commander's art behind the life total. On phones the board fills the screen with the far row turned to face across the table, and asks to keep the screen awake. The game is per-device scratch state in localStorage, not synced. Plain filter chips now show an accent background when active; before, "Unusual picks" only changed its text colour.
862 lines
27 KiB
TypeScript
862 lines
27 KiB
TypeScript
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<string, number>;
|
||
};
|
||
|
||
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<Seat>) => void;
|
||
}) {
|
||
const nextColor = () =>
|
||
onChange({
|
||
color: PLAYER_COLORS[(PLAYER_COLORS.indexOf(seat.color) + 1) % PLAYER_COLORS.length],
|
||
});
|
||
return (
|
||
<div className="game-seat">
|
||
<button
|
||
type="button"
|
||
className="game-seat-color"
|
||
style={{ background: seat.color }}
|
||
title="Change color"
|
||
aria-label="Change color"
|
||
onClick={nextColor}
|
||
/>
|
||
<input
|
||
className="game-seat-name"
|
||
value={seat.name}
|
||
placeholder={`Player ${index + 1}`}
|
||
onChange={(e) => onChange({ name: e.target.value })}
|
||
/>
|
||
<select
|
||
className="game-seat-deck"
|
||
value={seat.deckId ?? ""}
|
||
onChange={(e) => onChange({ deckId: e.target.value === "" ? null : Number(e.target.value) })}
|
||
>
|
||
<option value="">No deck</option>
|
||
{decks.map((d) => (
|
||
<option key={d.id} value={d.id}>
|
||
{d.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GameSetup({
|
||
current,
|
||
onStart,
|
||
onResume,
|
||
}: {
|
||
current: Game | null;
|
||
onStart: (game: Game) => void;
|
||
onResume: () => void;
|
||
}) {
|
||
const [stored, setStored] = usePersistedState<Setup>("game-setup", DEFAULT_SETUP);
|
||
const setup: Setup = { ...DEFAULT_SETUP, ...stored };
|
||
const patch = (p: Partial<Setup>) => setStored({ ...setup, ...p });
|
||
const [decks, setDecks] = useState<Deck[]>([]);
|
||
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 (
|
||
<div className="game-setup">
|
||
<div className="page-header">
|
||
<h2 className="page-header-title">Game night</h2>
|
||
</div>
|
||
|
||
{current && (
|
||
<div className="panel game-resume">
|
||
<div>
|
||
<strong>Game in progress</strong>
|
||
<div className="muted" style={{ fontSize: 13 }}>
|
||
{current.players.map((p) => `${p.name} ${p.life}`).join(" · ")}
|
||
</div>
|
||
</div>
|
||
<button className="primary" onClick={onResume}>
|
||
Back to the table
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="panel game-setup-form">
|
||
<div className="field">
|
||
<label>Players</label>
|
||
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||
{[2, 3, 4, 5, 6].map((n) => (
|
||
<button
|
||
key={n}
|
||
type="button"
|
||
className={`filter-chip ${setup.count === n ? "active" : ""}`}
|
||
onClick={() => patch({ count: n })}
|
||
>
|
||
{n}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="field">
|
||
<label>Starting life</label>
|
||
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||
{LIFE_PRESETS.map((p) => (
|
||
<button
|
||
key={p.life}
|
||
type="button"
|
||
className={`filter-chip ${setup.startingLife === p.life ? "active" : ""}`}
|
||
onClick={() => patch({ startingLife: p.life, commanderDamage: p.commander })}
|
||
>
|
||
{p.label} · {p.life}
|
||
</button>
|
||
))}
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
style={{ width: 72 }}
|
||
aria-label="Custom starting life"
|
||
value={setup.startingLife}
|
||
onChange={(e) => {
|
||
const n = Math.floor(Number(e.target.value));
|
||
if (n > 0) patch({ startingLife: n });
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<label className="row" style={{ gap: 8, cursor: "pointer" }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={setup.commanderDamage}
|
||
onChange={(e) => patch({ commanderDamage: e.target.checked })}
|
||
/>
|
||
Track commander damage
|
||
</label>
|
||
|
||
<div className="field">
|
||
<label>Seats, in turn order</label>
|
||
<div className="game-seats">
|
||
{setup.seats.slice(0, setup.count).map((seat, i) => (
|
||
<SeatRow
|
||
key={i}
|
||
index={i}
|
||
seat={seat}
|
||
decks={decks}
|
||
onChange={(p) =>
|
||
patch({ seats: setup.seats.map((s, j) => (j === i ? { ...s, ...p } : s)) })
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
{decks.length > 0 && (
|
||
<div className="hint" style={{ marginTop: 6 }}>
|
||
A deck with a commander puts its art behind that player's life total.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="row">
|
||
<button
|
||
className="primary"
|
||
disabled={starting}
|
||
onClick={() => (current ? setConfirmReplace(true) : start())}
|
||
>
|
||
{starting ? "Setting up…" : "Start game"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{confirmReplace && (
|
||
<ConfirmDialog
|
||
title="Start a new game?"
|
||
message="The game in progress will be ended."
|
||
confirmLabel="Start new game"
|
||
danger
|
||
onConfirm={start}
|
||
onCancel={() => setConfirmReplace(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- 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<ReturnType<typeof setTimeout> | 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 (
|
||
<button
|
||
type="button"
|
||
className={`game-life-half ${sign > 0 ? "plus" : "minus"}`}
|
||
aria-label={sign > 0 ? "Gain 1 life" : "Lose 1 life"}
|
||
onPointerDown={(e) => {
|
||
if (e.button !== 0) return;
|
||
stop();
|
||
pressed.current = true;
|
||
held.current = false;
|
||
const tick = () => {
|
||
held.current = true;
|
||
onChangeRef.current(sign * 10);
|
||
timer.current = setTimeout(tick, 600);
|
||
};
|
||
timer.current = setTimeout(tick, 450);
|
||
}}
|
||
onPointerUp={() => {
|
||
if (pressed.current && !held.current) onChangeRef.current(sign);
|
||
stop();
|
||
}}
|
||
onPointerLeave={stop}
|
||
onPointerCancel={stop}
|
||
// A long press would otherwise open the webview's context menu.
|
||
onContextMenu={(e) => e.preventDefault()}
|
||
// Keyboard activation; pointer clicks (detail > 0) were handled above.
|
||
onClick={(e) => e.detail === 0 && onChangeRef.current(sign)}
|
||
>
|
||
<span aria-hidden>{sign > 0 ? "+" : "−"}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/** "−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<ReturnType<typeof setTimeout> | 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 (
|
||
<div
|
||
className={["game-player", rotated ? "rotated" : "", out ? "out" : "", isFirst ? "first" : ""]
|
||
.filter(Boolean)
|
||
.join(" ")}
|
||
style={
|
||
{
|
||
"--player-color": player.color,
|
||
backgroundImage: player.art ? `url("${player.art}")` : undefined,
|
||
} as CSSProperties
|
||
}
|
||
>
|
||
<LifeHalf sign={-1} onChange={(d) => dispatch({ type: "life", id: player.id, delta: d })} />
|
||
<LifeHalf sign={1} onChange={(d) => dispatch({ type: "life", id: player.id, delta: d })} />
|
||
|
||
<div className="game-player-head">
|
||
<span className="game-player-name">{player.name}</span>
|
||
{player.deckName && <span className="game-player-deck">{player.deckName}</span>}
|
||
</div>
|
||
|
||
<div className="game-life">
|
||
{player.life}
|
||
{delta !== 0 && (
|
||
<span className={`game-delta ${delta > 0 ? "up" : "down"}`}>
|
||
{delta > 0 ? `+${delta}` : `−${-delta}`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{out && <div className="game-out-label">{out}</div>}
|
||
|
||
<div className="game-player-foot">
|
||
{isFirst && (
|
||
<button
|
||
type="button"
|
||
className="game-badge first"
|
||
title="Goes first — tap to dismiss"
|
||
onClick={() => dispatch({ type: "first", id: null })}
|
||
>
|
||
★ First
|
||
</button>
|
||
)}
|
||
{player.poison > 0 && (
|
||
<span className="game-badge" title="Poison counters">
|
||
☠ {player.poison}
|
||
</span>
|
||
)}
|
||
{game.commanderDamage && worstCommander > 0 && (
|
||
<span className="game-badge" title="Most commander damage from one opponent">
|
||
⚔ {worstCommander}
|
||
</span>
|
||
)}
|
||
<button type="button" className="game-badge game-badge-btn" onClick={onOpenCounters}>
|
||
Counters
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Stepper({
|
||
value,
|
||
onChange,
|
||
big,
|
||
}: {
|
||
value: number;
|
||
onChange: (delta: number) => void;
|
||
/** Adds ±5 buttons, for life. */
|
||
big?: boolean;
|
||
}) {
|
||
return (
|
||
<span className="game-stepper">
|
||
{big && <button onClick={() => onChange(-5)}>−5</button>}
|
||
<button onClick={() => onChange(-1)} aria-label="Decrease">
|
||
−
|
||
</button>
|
||
<span className="game-stepper-value">{value}</span>
|
||
<button onClick={() => onChange(1)} aria-label="Increase">
|
||
+
|
||
</button>
|
||
{big && <button onClick={() => onChange(5)}>+5</button>}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<DialogFrame title={player.name} onCancel={onClose}>
|
||
<div className="game-counters">
|
||
<div className="game-counter-row">
|
||
<span>Life</span>
|
||
<Stepper big value={player.life} onChange={(d) => dispatch({ type: "life", id: player.id, delta: d })} />
|
||
</div>
|
||
<div className="game-counter-row">
|
||
<span>
|
||
Poison <span className="muted">· out at {POISON_LETHAL}</span>
|
||
</span>
|
||
<Stepper
|
||
value={player.poison}
|
||
onChange={(d) => dispatch({ type: "poison", id: player.id, delta: d })}
|
||
/>
|
||
</div>
|
||
{game.commanderDamage && (
|
||
<>
|
||
<div className="game-counter-heading">
|
||
Commander damage taken
|
||
<span className="muted"> · out at {COMMANDER_LETHAL} from one, and it comes off life too</span>
|
||
</div>
|
||
{opponents.map((o) => (
|
||
<div className="game-counter-row" key={o.id}>
|
||
<span className="row" style={{ gap: 6 }}>
|
||
<span className="game-dot" style={{ background: o.color }} />
|
||
{o.name}
|
||
</span>
|
||
<Stepper
|
||
value={player.commanderDamage[o.id] ?? 0}
|
||
onChange={(d) => dispatch({ type: "commander", id: player.id, from: o.id, delta: d })}
|
||
/>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="dialog-actions">
|
||
<button className="primary" onClick={onClose} autoFocus>
|
||
Done
|
||
</button>
|
||
</div>
|
||
</DialogFrame>
|
||
);
|
||
}
|
||
|
||
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<Roll[]>([]);
|
||
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 (
|
||
<DialogFrame title="Dice" onCancel={onClose}>
|
||
<div className="game-dice-buttons">
|
||
{DICE.map((n) => (
|
||
<button key={n} onClick={() => push(`d${n}`, String(randomInt(n) + 1))}>
|
||
d{n}
|
||
</button>
|
||
))}
|
||
<button onClick={() => push("Coin", randomInt(2) === 0 ? "Heads" : "Tails")}>Coin</button>
|
||
</div>
|
||
<div className="game-dice-result">
|
||
{latest ? (
|
||
// Keyed per roll so the pop-in replays even when the number repeats.
|
||
<div key={latest.id} className="game-dice-latest">
|
||
<span className="game-dice-value">{latest.result}</span>
|
||
<span className="muted">{latest.label}</span>
|
||
</div>
|
||
) : (
|
||
<span className="muted">Pick a die to roll.</span>
|
||
)}
|
||
</div>
|
||
{earlier.length > 0 && (
|
||
<div className="game-dice-history muted">
|
||
Earlier: {earlier.map((r) => `${r.label} ${r.result}`).join(" · ")}
|
||
</div>
|
||
)}
|
||
<div className="dialog-actions">
|
||
<button className="primary" onClick={onClose} autoFocus>
|
||
Done
|
||
</button>
|
||
</div>
|
||
</DialogFrame>
|
||
);
|
||
}
|
||
|
||
/** 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<void> };
|
||
const wakeLock = (
|
||
navigator as Navigator & { wakeLock?: { request: (type: "screen") => Promise<Sentinel> } }
|
||
).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<string | null>(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) => (
|
||
<div className="game-row">
|
||
{row.map((p) => (
|
||
<PlayerPanel
|
||
key={`${p.id}-${game.epoch}`}
|
||
player={p}
|
||
game={game}
|
||
rotated={faceToFace && r === 0}
|
||
dispatch={dispatch}
|
||
onOpenCounters={() => setCountersFor(p.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="game-board">
|
||
{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. */}
|
||
<div className="game-strip">
|
||
<button
|
||
type="button"
|
||
className="game-hub"
|
||
aria-label="Game menu"
|
||
title="Game menu"
|
||
onClick={() => setMenuOpen(true)}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M12 2l8.66 5v10L12 22l-8.66-5V7z" />
|
||
<path d="M12 2v20M3.34 7L20.66 17M20.66 7L3.34 17" opacity="0.45" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
{renderRow(rows[1], 1)}
|
||
|
||
{menuOpen && (
|
||
<DialogFrame title="Game" onCancel={() => setMenuOpen(false)}>
|
||
<div className="game-menu">
|
||
<button
|
||
onClick={() => {
|
||
setMenuOpen(false);
|
||
setDiceOpen(true);
|
||
}}
|
||
>
|
||
Roll dice / flip a coin
|
||
</button>
|
||
<button onClick={pickFirst}>Who goes first?</button>
|
||
<button
|
||
onClick={() => {
|
||
setMenuOpen(false);
|
||
setConfirmReset(true);
|
||
}}
|
||
>
|
||
Restart this game
|
||
</button>
|
||
<label className="row" style={{ gap: 8, cursor: "pointer", padding: "6px 2px" }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={faceToFace}
|
||
onChange={(e) => setFaceToFace(e.target.checked)}
|
||
/>
|
||
Turn the far side to face across the table
|
||
</label>
|
||
<button
|
||
onClick={() => {
|
||
setMenuOpen(false);
|
||
onLeave();
|
||
}}
|
||
>
|
||
Leave the table
|
||
</button>
|
||
</div>
|
||
</DialogFrame>
|
||
)}
|
||
{diceOpen && <DiceDialog onClose={() => setDiceOpen(false)} />}
|
||
{confirmReset && (
|
||
<ConfirmDialog
|
||
title="Restart this game?"
|
||
message={`Everyone goes back to ${game.startingLife} life with no counters. Same players and decks.`}
|
||
confirmLabel="Restart"
|
||
danger
|
||
onConfirm={() => {
|
||
setConfirmReset(false);
|
||
dispatch({ type: "reset" });
|
||
}}
|
||
onCancel={() => setConfirmReset(false)}
|
||
/>
|
||
)}
|
||
{countersPlayer && (
|
||
<CountersDialog
|
||
player={countersPlayer}
|
||
game={game}
|
||
dispatch={dispatch}
|
||
onClose={() => setCountersFor(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- 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 <GameBoard game={game} dispatch={dispatch} onLeave={() => setSeated(false)} />;
|
||
}
|
||
return (
|
||
<GameSetup
|
||
current={game}
|
||
onResume={() => setSeated(true)}
|
||
onStart={(g) => {
|
||
dispatch({ type: "start", game: g });
|
||
setSeated(true);
|
||
}}
|
||
/>
|
||
);
|
||
}
|