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);
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
);
|
|||
|
|
}
|