Add hints, per-action undo/redo, and card motion

The rival's policy now exposes a ranking, which drives both its own move and a
new HINT button: it highlights the card to play, where to put it, and where to
draw, with the model's confidence.

Undo and redo work per action rather than per turn — picking a card, choosing
its destination, and drawing are separate steps, as are the rival's moves — so a
finished game can be stepped back through from the result screen. The rival is
suspended while undone moves are pending, and PLAY FROM HERE resumes from the
reviewed position.

Cards now travel between zones instead of teleporting: a motion layer measures
each card's old and new position and animates the difference, flying cards out
of the deck face-down and flipping them over, and back into it on undo.

Also: the result screen gets a per-expedition score breakdown mirroring
human_play.py, and a rival card revealed by a discard-pile draw no longer
renders at full size in the card-back-sized rival hand row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LmyprzuzanXRhpomc3Ga1i
This commit is contained in:
2026-07-14 21:33:53 +09:00
co-authored by Claude Opus 4.8
parent be5226bd3b
commit 8b7348cbce
11 changed files with 904 additions and 130 deletions
+17
View File
@@ -21,6 +21,23 @@ the exact same deal, so a game can be shared, replayed, or reported with a bug.
Seeds are arbitrary text; the deal is derived from a deterministic PRNG in
`src/game/random.ts` and is independent of the Python shuffle bank.
The seed shuffles the deck and nothing else. The policy is deterministic: it
always plays its highest-logit legal action, so the same deal and the same moves
reproduce the same game.
## Hints, undo, and review
`HINT` asks the same policy that drives the rival what it would play in your
seat, and highlights the card, its destination, and the draw source (with the
policy's confidence when the ONNX model is loaded).
Undo and redo work per *action*, not per turn: choosing a card, choosing its
destination, and drawing are separate steps, and each is undone on its own. The
rival's moves are steps too, so a finished game can be stepped through from the
result screen (`REVIEW GAME`) all the way back to the deal. While undone moves
are pending the rival is suspended; `PLAY FROM HERE` drops them and resumes play
from the position on screen.
## Setup
Install and run the checked-in final policy:
+197 -59
View File
@@ -1,11 +1,14 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Board } from "./components/Board";
import { Board, type BoardHint } from "./components/Board";
import { Card, CardBack } from "./components/Card";
import { ResultCard } from "./components/ResultCard";
import { describeAction } from "./game/actions";
import { cardColor, cardName } from "./game/cards";
import {
boardScore,
currentHandSorted,
decodeAction,
encodeAction,
legalActionMask,
previewPlacement,
@@ -13,12 +16,30 @@ import {
step,
} from "./game/engine";
import { deckOrderFromSeed, normalizeSeed, randomSeed } from "./game/random";
import { DISCARD, DRAW_DECK, N_CARDS, PLAY, type GameState, type PlaceType } from "./game/types";
import { DRAW_DECK, N_CARDS, PLAY, type GameState, type PlaceType } from "./game/types";
import { fallbackHeuristicPolicy, loadPolicy, type Policy } from "./model/policy";
import { useCardMotion } from "./ui/useCardMotion";
interface Hint {
action: number;
text: string;
probability: number | null;
}
type Selection = { handSlot: number | null; placeType: PlaceType | null };
const EMPTY_SELECTION: Selection = { handSlot: null, placeType: null };
/**
* One undoable step. A turn is several of these — picking a card, choosing where
* it goes, then drawing — and each is undone on its own, so undo never throws
* away a whole turn's thinking. A rival move is one frame too, which is what
* makes a finished game reviewable ply by ply.
*/
interface Frame {
state: GameState;
selection: Selection;
}
// The hand row is centered, so the binding constraint is the score plaque
// column on the left (mirrored as a keep-out on the right to stay centered).
// Available width for the hand = viewport - 2 * (plaque right edge + gutter).
@@ -52,8 +73,8 @@ function initialSeed(): string {
return seed === "" ? randomSeed() : seed;
}
function gameFromSeed(seed: string): GameState {
return resetFromOrder(deckOrderFromSeed(seed));
function openingFrame(seed: string): Frame {
return { state: resetFromOrder(deckOrderFromSeed(seed)), selection: EMPTY_SELECTION };
}
/** Keep the address bar in sync so the current deal stays shareable. */
@@ -65,18 +86,27 @@ function publishSeed(seed: string): void {
function App() {
const [seed, setSeed] = useState<string>(initialSeed);
const [state, setState] = useState<GameState>(() => gameFromSeed(seed));
const [frames, setFrames] = useState<Frame[]>(() => [openingFrame(seed)]);
const [cursor, setCursor] = useState(0);
const [seedDraft, setSeedDraft] = useState("");
const [history, setHistory] = useState<GameState[]>([]);
const [selection, setSelection] = useState<Selection>(EMPTY_SELECTION);
const [resultOpen, setResultOpen] = useState(true);
const [policy, setPolicy] = useState<Policy | null>(null);
const [modelMessage, setModelMessage] = useState("LOADING FINAL PPO");
const [thinking, setThinking] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth);
const [hint, setHint] = useState<Hint | null>(null);
const [hintPending, setHintPending] = useState(false);
const generation = useRef(0);
const { cardRef, deckRef, overlayRef, resetMotion } = useCardMotion();
const { state, selection } = frames[cursor];
const canUndo = cursor > 0;
const canRedo = cursor < frames.length - 1;
const latestState = useRef(state);
useEffect(() => { publishSeed(seed); }, [seed]);
useEffect(() => { latestState.current = state; }, [state]);
useEffect(() => {
function onResize() { setViewportWidth(window.innerWidth); }
@@ -107,8 +137,19 @@ function App() {
return () => { cancelled = true; };
}, []);
/** Record a new step and make it the position on screen. Anything that had
* been undone is discarded — acting is what branches the game. */
function pushFrame(frame: Frame) {
setFrames((items) => [...items.slice(0, cursor + 1), frame]);
setCursor(cursor + 1);
}
// Suspended while there are undone moves ahead: otherwise stepping back into
// the rival's turn would instantly replay the very move being reviewed.
const rivalSuspended = canRedo;
useEffect(() => {
if (!policy || state.done || state.toMove !== 1) return;
if (!policy || rivalSuspended || state.done || state.toMove !== 1) return;
const currentGeneration = generation.current;
const timer = window.setTimeout(async () => {
setThinking(true);
@@ -125,8 +166,7 @@ function App() {
setModelMessage("HEURISTIC FALLBACK (MODEL ERROR)");
}
if (generation.current !== currentGeneration) return;
setHistory((items) => [...items, state]);
setState(step(state, action));
pushFrame({ state: step(state, action), selection: EMPTY_SELECTION });
} catch (error) {
console.error("AI action failed even with heuristic fallback", error);
setModelMessage("MODEL INFERENCE ERROR");
@@ -135,17 +175,19 @@ function App() {
}
}, 620);
return () => window.clearTimeout(timer);
}, [policy, state]);
}, [policy, rivalSuspended, state]);
/** Deal a game. Without a seed this rolls a fresh one; the same seed always
* reproduces the same deal, so `restart(seed)` also replays the current one. */
function restart(nextSeed: string = randomSeed()) {
generation.current += 1;
resetMotion();
setSeed(nextSeed);
setState(gameFromSeed(nextSeed));
setFrames([openingFrame(nextSeed)]);
setCursor(0);
setSeedDraft("");
setHistory([]);
setSelection(EMPTY_SELECTION);
setResultOpen(true);
setHint(null);
setThinking(false);
setMenuOpen(false);
}
@@ -155,48 +197,82 @@ function App() {
if (requested !== "") restart(requested);
}
function undoTurn() {
if (!history.length) return;
function moveCursor(next: number) {
if (next < 0 || next >= frames.length) return;
generation.current += 1;
let index = history.length - 1;
while (index > 0 && history[index].toMove !== 0) index -= 1;
setState(history[index]);
setHistory(history.slice(0, index));
setSelection(EMPTY_SELECTION);
setCursor(next);
setHint(null);
setThinking(false);
setMenuOpen(false);
}
const undo = () => moveCursor(cursor - 1);
const redo = () => moveCursor(cursor + 1);
/** Leave the reviewed position as the live one and let the rival play on. */
function resumeFromHere() {
setFrames((items) => items.slice(0, cursor + 1));
setMenuOpen(false);
}
/** Ask the policy driving the rival what it would do in your seat. */
async function requestHint() {
if (!policy || state.done || state.toMove !== 0 || hintPending) return;
const position = state;
setHintPending(true);
setMenuOpen(false);
try {
const ranked = await policy.rank(position).catch(async (error) => {
console.error("hint inference failed; falling back to heuristic", error);
return fallbackHeuristicPolicy().rank(position);
});
const [best] = ranked;
// The position can move on while inference runs — a hint for a stale
// position would point at the wrong hand slot.
if (best === undefined || latestState.current !== position) return;
setHint({
action: best.action,
text: describeAction(position, best.action),
probability: best.probability,
});
} catch (error) {
console.error("hint failed", error);
} finally {
setHintPending(false);
}
}
/** A hint answers one position; drop it as soon as anything is done or undone. */
useEffect(() => { setHint(null); }, [cursor, frames]);
function chooseCard(handSlot: number) {
if (state.toMove !== 0 || state.done) return;
setSelection((current) => {
if (current.handSlot !== handSlot) return { handSlot, placeType: null };
if (current.placeType !== null) return { handSlot, placeType: null };
return EMPTY_SELECTION;
});
const next = selection.handSlot !== handSlot || selection.placeType !== null
? { handSlot, placeType: null }
: EMPTY_SELECTION;
pushFrame({ state, selection: next });
}
function choosePlace(placeType: PlaceType) {
if (selection.handSlot === null) return;
setSelection({ ...selection, placeType });
pushFrame({ state, selection: { ...selection, placeType } });
}
function cancelPlace() {
setSelection((current) => current.handSlot === null ? current : { ...current, placeType: null });
if (selection.handSlot === null || selection.placeType === null) return;
pushFrame({ state, selection: { ...selection, placeType: null } });
}
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
setSelection((current) => {
if (current.placeType !== null) return { ...current, placeType: null };
if (current.handSlot !== null) return EMPTY_SELECTION;
return current;
});
if (event.target instanceof HTMLInputElement) return;
if (event.key === "Escape") undo();
if (event.key === "ArrowLeft") undo();
if (event.key === "ArrowRight") redo();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
});
function legalDraw(drawSource: number): boolean {
if (selection.handSlot === null || selection.placeType === null) return false;
@@ -206,9 +282,7 @@ function App() {
function commit(drawSource: number) {
if (!legalDraw(drawSource) || selection.handSlot === null || selection.placeType === null) return;
const action = encodeAction(selection.handSlot, selection.placeType, drawSource);
setHistory((items) => [...items, state]);
setState(step(state, action));
setSelection(EMPTY_SELECTION);
pushFrame({ state: step(state, action), selection: EMPTY_SELECTION });
}
// Cards actually drawn in the hand row (the selected card moves into the
@@ -224,11 +298,36 @@ function App() {
{ length: 6 },
(_, draw) => legal[encodeAction(selection.handSlot!, PLAY, draw)],
).some(Boolean);
const status = state.done
? scores[0] === scores[1] ? "DRAW" : scores[0] > scores[1] ? "YOU WIN" : "THE RIVAL WINS"
: state.toMove === 1 ? thinking ? "The rival is thinking ···" : "The rival's turn"
: selection.handSlot === null ? "Play or discard a card"
: selection.placeType === null ? `Choose a destination for ${cardName(selectedCard!)}`
const hintMove = hint === null ? null : decodeAction(hint.action);
const hintedCard = hintMove === null ? undefined : humanHand[hintMove.handSlot];
const boardHint: BoardHint | null = hintMove === null || hintedCard === undefined ? null : {
color: cardColor(hintedCard),
place: hintMove.placeType,
drawSource: hintMove.drawSource,
};
const hintConfidence = hint !== null && hint.probability !== null
? `${Math.round(hint.probability * 100)}%`
: null;
const canHint = policy !== null && !state.done && state.toMove === 0;
const outcome = scores[0] === scores[1]
? "DRAW"
: scores[0] > scores[1] ? "YOU WIN" : "THE RIVAL WINS";
// While the rival is paused on its own turn (or on the final position) nothing
// will happen until the move is redone or play is resumed — say so. On your own
// turn the normal prompt still applies: you can simply play on from here.
const reviewingRival = rivalSuspended && (state.done || state.toMove === 1);
const status = reviewingRival
? `Reviewing ply ${state.stepCount} — redo, or play on from here`
: state.done
? outcome
: state.toMove === 1
? thinking ? "The rival is thinking ···" : "The rival's turn"
: selection.handSlot === null
? "Play or discard a card"
: selection.placeType === null
? `Choose a destination for ${cardName(selectedCard!)}`
: "Draw — deck or a discard pile";
return (
@@ -239,7 +338,9 @@ function App() {
<div className="opponent-hand" aria-label="Rival hand">
{opponentHand.map((card) => (
state.handPublic[card] ? <Card compact card={card} key={card} /> : <CardBack key={card} />
state.handPublic[card]
? <Card mini card={card} key={card} innerRef={cardRef(card)} />
: <CardBack key={card} innerRef={cardRef(card)} />
))}
</div>
@@ -248,19 +349,19 @@ function App() {
{menuOpen && (
<div className="menu-popover">
<button onClick={() => restart()}>NEW GAME</button>
<button onClick={undoTurn} disabled={!history.length}>UNDO TURN</button>
<button onClick={() => restart(seed)}>REPLAY THIS SEED</button>
<label className="menu-seed">
<span>SEED · {seed}</span>
<span>DEAL SEED · {seed}</span>
<input
value={seedDraft}
placeholder="load a seed…"
placeholder="load a deal seed…"
onChange={(event) => setSeedDraft(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") loadSeed(); }}
aria-label="Load a game by seed"
aria-label="Load a deal by seed"
/>
</label>
<button onClick={loadSeed} disabled={normalizeSeed(seedDraft) === ""}>DEAL THIS SEED</button>
<button onClick={() => restart(seed)}>REPLAY THIS SEED</button>
<span>THE SEED SHUFFLES THE DECK ONLY · THE POLICY IS DETERMINISTIC</span>
<span>{policy ? `${policy.provider.toUpperCase()} POLICY` : "LOADING POLICY"}</span>
</div>
)}
@@ -273,6 +374,8 @@ function App() {
selectedPlace={selection.placeType}
canPlay={canPlaySelected}
canDraw={legalDraw}
hint={boardHint}
cardRef={cardRef}
onChoosePlace={choosePlace}
onCancelPlace={cancelPlace}
onDraw={commit}
@@ -281,17 +384,29 @@ function App() {
<button
type="button"
className={`deck-stack ${legalDraw(DRAW_DECK) ? "is-draw-target" : ""}`}
className={`deck-stack ${legalDraw(DRAW_DECK) ? "is-draw-target" : ""} ${hintMove?.drawSource === DRAW_DECK ? "is-hinted" : ""}`}
disabled={!legalDraw(DRAW_DECK)}
onClick={() => commit(DRAW_DECK)}
aria-label={`Draw from deck, ${N_CARDS - state.drawPtr} cards left`}
>
<CardBack />
<CardBack innerRef={deckRef} />
<strong>{N_CARDS - state.drawPtr}</strong>
<span>CARDS LEFT</span>
</button>
<p className={`turn-prompt ${thinking ? "turn-prompt--thinking" : ""}`}>{status}</p>
<div className="prompt-row">
<p className={`turn-prompt ${thinking ? "turn-prompt--thinking" : ""} ${reviewingRival ? "turn-prompt--review" : ""}`}>
{hint ? `Hint — ${hint.text}${hintConfidence ? ` (${hintConfidence})` : ""}` : status}
</p>
<button
type="button"
className={`hint-button ${hint ? "is-active" : ""}`}
onClick={requestHint}
disabled={!canHint || hintPending}
>
{hintPending ? "THINKING…" : "HINT"}
</button>
</div>
<section className="human-hand" aria-label="Your hand">
{renderedHand.map(({ card, slot }, position) => (
@@ -299,9 +414,11 @@ function App() {
card={card}
key={card}
selected={selection.handSlot === slot}
hinted={hintedCard === card}
disabled={state.toMove !== 0 || state.done}
onClick={() => chooseCard(slot)}
style={position === 0 ? undefined : { marginLeft: handCardMarginLeft }}
innerRef={cardRef(card)}
/>
))}
</section>
@@ -310,14 +427,35 @@ function App() {
<div><strong>YOU <i /></strong><small>{state.toMove === 0 && !state.done ? "YOUR TURN" : "EXPEDITION LEAD"}</small></div><b>{scores[0]}</b>
</section>
{state.done && (
<div className="result-overlay">
<div className="result-card">
<p>ROUND COMPLETE</p><h1>{status}</h1><strong>{scores[0]} <i>:</i> {scores[1]}</strong>
<small>SEED {seed}</small>
<button onClick={() => restart()}>PLAY AGAIN</button>
</div>
<div className="history-bar">
<button type="button" onClick={undo} disabled={!canUndo} aria-label="Undo one action" title="Undo (←)">
<span>UNDO</span>
</button>
<button type="button" onClick={redo} disabled={!canRedo} aria-label="Redo one action" title="Redo (→)">
<span>REDO</span>
</button>
{rivalSuspended && (
<button type="button" className="history-bar__resume" onClick={resumeFromHere}>
PLAY FROM HERE
</button>
)}
{state.done && !resultOpen && (
<button type="button" onClick={() => setResultOpen(true)}>SCORE</button>
)}
</div>
{/* Cards in flight back to the deck (undo of a draw) are re-parented here
after React has already removed them from the table. */}
<div className="motion-overlay" ref={overlayRef} aria-hidden="true" />
{state.done && resultOpen && (
<ResultCard
state={state}
outcome={outcome}
seed={seed}
onReview={() => setResultOpen(false)}
onPlayAgain={() => restart()}
/>
)}
</main>
);
+23 -5
View File
@@ -9,12 +9,21 @@ import {
import { cardsOnBoard } from "../game/engine";
import { DISCARD, PLAY, type GameState, type PlaceType } from "../game/types";
/** Where the hint says to put the card and where to draw from, if hinting. */
export interface BoardHint {
color: number;
place: PlaceType;
drawSource: number;
}
interface BoardProps {
state: GameState;
selectedColor: number | null;
selectedPlace: PlaceType | null;
canPlay: boolean;
canDraw: (source: number) => boolean;
hint: BoardHint | null;
cardRef: (card: number) => (element: HTMLElement | null) => void;
onChoosePlace: (place: PlaceType) => void;
onCancelPlace: () => void;
onDraw: (source: number) => void;
@@ -49,6 +58,8 @@ export function Board({
selectedPlace,
canPlay,
canDraw,
hint,
cardRef,
onChoosePlace,
onCancelPlace,
onDraw,
@@ -64,6 +75,9 @@ export function Board({
const drawTarget = selectedPlace !== null && canDraw(color + 1);
const discardChosen = selectedPlace === DISCARD && selectedColor === color;
const playChosen = selectedPlace === PLAY && selectedColor === color;
const hintPlay = hint?.place === PLAY && hint.color === color;
const hintDiscard = hint?.place === DISCARD && hint.color === color;
const hintDraw = hint?.drawSource === color + 1;
return (
<article
className="lane"
@@ -73,7 +87,7 @@ export function Board({
<div className="lane__zone lane__zone--opponent">
{opponent.length ? (
<div className="card-stack card-stack--opponent" style={stackOverlapStyle(opponent.length)}>
{opponent.map((card) => <Card compact card={card} key={card} />)}
{opponent.map((card) => <Card compact card={card} key={card} innerRef={cardRef(card)} />)}
</div>
) : (
<span className="lane__ghost">{COLOR_GLYPHS[color]}</span>
@@ -83,26 +97,30 @@ export function Board({
<button
type="button"
className={`lane__discard ${discardTarget ? "is-target" : ""} ${drawTarget ? "is-draw-target" : ""} ${discardChosen ? "is-chosen" : ""}`}
className={`lane__discard ${discardTarget ? "is-target" : ""} ${drawTarget ? "is-draw-target" : ""} ${discardChosen ? "is-chosen" : ""} ${hintDiscard || hintDraw ? "is-hinted" : ""}`}
onClick={() => drawTarget ? onDraw(color + 1) : discardChosen ? onCancelPlace() : discardTarget ? onChoosePlace(DISCARD) : undefined}
disabled={!discardTarget && !drawTarget && !discardChosen}
aria-label={discardChosen ? `Cancel discarding to ${name} pile` : `${name} discard pile`}
>
{discard.length ? <Card compact card={discard.at(-1)!} /> : <span className="lane__ghost">{COLOR_GLYPHS[color]}</span>}
{discard.length ? (
<Card compact card={discard.at(-1)!} innerRef={cardRef(discard.at(-1)!)} />
) : (
<span className="lane__ghost">{COLOR_GLYPHS[color]}</span>
)}
{discard.length > 1 && <b className="pile-count">×{discard.length}</b>}
</button>
<span className="lane__name">{name}</span>
<button
type="button"
className={`lane__zone lane__zone--mine ${playTarget ? "is-target" : ""} ${playChosen ? "is-chosen" : ""}`}
className={`lane__zone lane__zone--mine ${playTarget ? "is-target" : ""} ${playChosen ? "is-chosen" : ""} ${hintPlay ? "is-hinted" : ""}`}
onClick={() => playChosen ? onCancelPlace() : playTarget ? onChoosePlace(PLAY) : undefined}
disabled={!playTarget && !playChosen}
aria-label={playChosen ? `Cancel playing to your ${name} expedition` : `Your ${name} expedition`}
>
{mine.length ? (
<div className="card-stack card-stack--mine" style={stackOverlapStyle(mine.length)}>
{mine.map((card) => <Card compact card={card} key={card} />)}
{mine.map((card) => <Card compact card={card} key={card} innerRef={cardRef(card)} />)}
</div>
) : (
<span className="lane__ghost">{COLOR_GLYPHS[color]}</span>
+50 -6
View File
@@ -10,17 +10,32 @@ import {
interface CardProps {
card: number;
compact?: boolean;
/** Card-back sized face-up card, for the rival's publicly known cards: the
* hand row is only as tall as a card back, so a full card would burst it. */
mini?: boolean;
selected?: boolean;
hinted?: boolean;
disabled?: boolean;
onClick?: () => void;
style?: React.CSSProperties;
innerRef?: (element: HTMLElement | null) => void;
}
export function Card({ card, compact = false, selected = false, disabled = false, onClick, style: styleOverride }: CardProps) {
export function Card({
card,
compact = false,
mini = false,
selected = false,
hinted = false,
disabled = false,
onClick,
style: styleOverride,
innerRef,
}: CardProps) {
const color = cardColor(card);
const value = cardValue(card);
const handshake = isHandshake(card);
const className = `card ${compact ? "card--compact" : ""} ${selected ? "card--selected" : ""}`;
const className = `card ${compact ? "card--compact" : ""} ${mini ? "card--mini" : ""} ${selected ? "card--selected" : ""} ${hinted ? "card--hinted" : ""}`;
const style = { "--card-color": COLOR_HEX[color], ...styleOverride } as React.CSSProperties;
const content = (
<>
@@ -37,17 +52,46 @@ export function Card({ card, compact = false, selected = false, disabled = false
<span className="card__corner card__corner--bottom">
<b>{value}</b><i>{COLOR_GLYPHS[color]}</i>
</span>
{/* Covers the mirrored face while a flipping card is turned past edge-on;
the motion layer fades it out at the halfway point of the flip. */}
<span className="card__flip-cover" aria-hidden="true"><i></i></span>
</>
);
if (!onClick) {
return <div className={className} style={style} aria-label={`${COLOR_NAMES[color]} ${value}`}>{content}</div>;
return (
<div ref={innerRef} className={className} style={style} aria-label={`${COLOR_NAMES[color]} ${value}`}>
{content}
</div>
);
}
return <button type="button" className={className} style={style} onClick={onClick} disabled={disabled} aria-label={`${COLOR_NAMES[color]} ${value}`}>{content}</button>;
return (
<button
ref={innerRef}
type="button"
className={className}
style={style}
onClick={onClick}
disabled={disabled}
aria-label={`${COLOR_NAMES[color]} ${value}`}
>
{content}
</button>
);
}
export function CardBack({ count }: { count?: number }) {
export function CardBack({
count,
innerRef,
}: {
count?: number;
innerRef?: (element: HTMLElement | null) => void;
}) {
return (
<div className="card card--back" aria-label={count === undefined ? "Hidden card" : `${count} cards`}>
<div
ref={innerRef}
className="card card--back"
aria-label={count === undefined ? "Hidden card" : `${count} cards`}
>
<span className="card__back-frame"><i></i></span>
{count !== undefined && <span className="card__count">{count}</span>}
</div>
+91
View File
@@ -0,0 +1,91 @@
import { COLOR_GLYPHS, COLOR_HEX, EXPEDITION_NAMES } from "../game/cards";
import { scoreBreakdown } from "../game/scoring";
import type { GameState } from "../game/types";
interface ResultCardProps {
state: GameState;
outcome: string;
seed: string;
onReview: () => void;
onPlayAgain: () => void;
}
function signed(value: number): string {
return value > 0 ? `+${value}` : String(value);
}
export function ResultCard({ state, outcome, seed, onReview, onPlayAgain }: ResultCardProps) {
const you = scoreBreakdown(state, 0);
const rival = scoreBreakdown(state, 1);
return (
<div className="result-overlay">
<section className="result-card" aria-label="Final score">
<header className="result-card__head">
<p>ROUND COMPLETE</p>
<h1>{outcome}</h1>
<strong>{you.total} <i>:</i> {rival.total}</strong>
</header>
<table className="result-table">
<thead>
<tr>
<th scope="col">EXPEDITION</th>
<th scope="col" colSpan={2}>YOU</th>
<th scope="col" colSpan={2}>THE RIVAL</th>
</tr>
<tr className="result-table__subhead">
<th scope="col" />
<th scope="col">CARDS</th>
<th scope="col">SCORE</th>
<th scope="col">CARDS</th>
<th scope="col">SCORE</th>
</tr>
</thead>
<tbody>
{EXPEDITION_NAMES.map((name, color) => {
const mine = you.colors[color];
const theirs = rival.colors[color];
return (
<tr key={name}>
<th scope="row" style={{ "--lane-color": COLOR_HEX[color] } as React.CSSProperties}>
<i>{COLOR_GLYPHS[color]}</i>{name}
</th>
<td className="result-table__detail">
{mine.cards ? `${mine.cards}${mine.handshakes ? ` ·×${mine.handshakes + 1}` : ""}` : "—"}
</td>
<td className={mine.score < 0 ? "is-negative" : mine.score > 0 ? "is-positive" : ""}>
{mine.cards ? signed(mine.score) : "—"}
</td>
<td className="result-table__detail">
{theirs.cards ? `${theirs.cards}${theirs.handshakes ? ` ·×${theirs.handshakes + 1}` : ""}` : "—"}
</td>
<td className={theirs.score < 0 ? "is-negative" : theirs.score > 0 ? "is-positive" : ""}>
{theirs.cards ? signed(theirs.score) : "—"}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<th scope="row">TOTAL</th>
<td className="result-table__detail" />
<td>{signed(you.total)}</td>
<td className="result-table__detail" />
<td>{signed(rival.total)}</td>
</tr>
</tfoot>
</table>
<footer className="result-card__foot">
<small>DEAL SEED {seed}</small>
<div>
<button type="button" onClick={onReview}>REVIEW GAME</button>
<button type="button" className="is-primary" onClick={onPlayAgain}>PLAY AGAIN</button>
</div>
</footer>
</section>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { EXPEDITION_NAMES, cardColor, cardName } from "./cards";
import { currentHandSorted, decodeAction } from "./engine";
import { DRAW_DECK, PLAY, type GameState } from "./types";
/** Human-readable move, e.g. "Play Red 7 · draw from the Ocean discard". */
export function describeAction(state: GameState, action: number): string {
const { handSlot, placeType, drawSource } = decodeAction(action);
const card = currentHandSorted(state)[handSlot];
if (card === undefined) return "—";
const place = placeType === PLAY
? `Play ${cardName(card)} on ${EXPEDITION_NAMES[cardColor(card)]}`
: `Discard ${cardName(card)}`;
const draw = drawSource === DRAW_DECK
? "draw from the deck"
: `draw the ${EXPEDITION_NAMES[drawSource - 1]} discard`;
return `${place} · ${draw}`;
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, test } from "vitest";
import { encodeAction, legalActionMask, resetFromOrder, step } from "./engine";
import { scoreBreakdown } from "./scoring";
import { DRAW_DECK, N_CARDS, PLAY } from "./types";
import { boardScore } from "./engine";
const orderedDeck = Array.from({ length: N_CARDS }, (_, index) => index);
describe("score breakdown", () => {
test("an untouched board scores nothing everywhere", () => {
const breakdown = scoreBreakdown(resetFromOrder(orderedDeck), 0);
expect(breakdown.total).toBe(0);
expect(breakdown.colors).toHaveLength(5);
expect(breakdown.colors.every((color) => color.cards === 0 && color.score === 0)).toBe(true);
});
test("an opened expedition costs 20 and handshakes multiply it", () => {
// Player 0 holds cards 0..7 of the red suit: three handshakes then 2,3,4,5.
let state = resetFromOrder(orderedDeck);
const mask = legalActionMask(state);
expect(mask[encodeAction(0, PLAY, DRAW_DECK)]).toBe(true);
state = step(state, encodeAction(0, PLAY, DRAW_DECK)); // red handshake
const red = scoreBreakdown(state, 0).colors[0];
expect(red.cards).toBe(1);
expect(red.handshakes).toBe(1);
expect(red.rankSum).toBe(0);
// (0 - 20) * (1 + 1) = -40
expect(red.score).toBe(-40);
});
test("totals agree with the engine's board score", () => {
let state = resetFromOrder(orderedDeck);
for (let ply = 0; ply < 12; ply += 1) {
const action = legalActionMask(state).findIndex((legal, index) => legal && index % 6 === 0);
state = step(state, action);
}
const engine = boardScore(state);
expect(scoreBreakdown(state, 0).total).toBe(engine[0]);
expect(scoreBreakdown(state, 1).total).toBe(engine[1]);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { cardRank, isHandshake } from "./cards";
import { cardsOnBoard } from "./engine";
import { N_COLORS, type GameState } from "./types";
export interface ColorScore {
color: number;
cards: number;
handshakes: number;
rankSum: number;
score: number;
}
export interface PlayerScore {
colors: ColorScore[];
total: number;
}
/** Per-color scoring, mirroring `score_breakdown` in
* `src/lost_cities_jax/human_play.py`: an opened expedition costs 20, is
* multiplied by 1 + handshakes, and earns 20 more at eight cards. */
export function scoreBreakdown(state: GameState, player: number): PlayerScore {
const colors: ColorScore[] = [];
let total = 0;
for (let color = 0; color < N_COLORS; color += 1) {
const cards = cardsOnBoard(state, player, color);
const handshakes = cards.filter(isHandshake).length;
const rankSum = cards.reduce((sum, card) => sum + cardRank(card), 0);
const score = cards.length === 0
? 0
: (rankSum - 20) * (1 + handshakes) + (cards.length >= 8 ? 20 : 0);
total += score;
colors.push({ color, cards: cards.length, handshakes, rankSum, score });
}
return { colors, total };
}
+48 -27
View File
@@ -9,47 +9,69 @@ export type ExecutionProvider = "webgpu" | "wasm" | "heuristic";
const MODEL_URL = `${import.meta.env.BASE_URL}models/jax-ppo.onnx`;
/** A legal action with the policy's confidence in it, if the policy has one. */
export interface RankedAction {
action: number;
probability: number | null;
}
export interface Policy {
readonly provider: ExecutionProvider;
/** Legal actions, best first. Drives both the rival's move and the hint. */
rank(state: GameState): Promise<RankedAction[]>;
action(state: GameState): Promise<number>;
}
class OnnxPolicy implements Policy {
abstract class RankingPolicy implements Policy {
abstract readonly provider: ExecutionProvider;
abstract rank(state: GameState): Promise<RankedAction[]>;
async action(state: GameState): Promise<number> {
const [best] = await this.rank(state);
if (best === undefined) throw new Error("state has no legal actions");
return best.action;
}
}
/** Softmax over the legal actions only, so the reported confidence is a share
* of what the policy could actually have chosen. */
function softmaxOverLegal(scores: number[], legal: boolean[]): RankedAction[] {
const legalScores = scores.filter((_, action) => legal[action]);
const max = Math.max(...legalScores);
const total = legalScores.reduce((sum, score) => sum + Math.exp(score - max), 0);
return scores
.flatMap((score, action) =>
legal[action] ? [{ action, probability: Math.exp(score - max) / total }] : [],
)
.sort((left, right) => right.probability - left.probability);
}
class OnnxPolicy extends RankingPolicy {
constructor(
private readonly session: ort.InferenceSession,
readonly provider: ExecutionProvider,
) {}
) {
super();
}
async action(state: GameState): Promise<number> {
async rank(state: GameState): Promise<RankedAction[]> {
const obs = observation(state, state.toMove);
const result = await this.session.run({ obs: new ort.Tensor("float32", obs, [1, obs.length]) });
const logits = result.logits?.data;
if (!logits) throw new Error("ONNX model did not return a logits output");
const legal = legalActionMask(state);
let bestAction = -1;
let bestLogit = Number.NEGATIVE_INFINITY;
for (let action = 0; action < legal.length; action += 1) {
const logit = Number(logits[action]);
if (legal[action] && logit > bestLogit) {
bestAction = action;
bestLogit = logit;
}
}
if (bestAction < 0) throw new Error("model received a state without legal actions");
return bestAction;
return softmaxOverLegal(legal.map((_, action) => Number(logits[action])), legal);
}
}
export class HeuristicPolicy implements Policy {
export class HeuristicPolicy extends RankingPolicy {
readonly provider = "heuristic" as const;
async action(state: GameState): Promise<number> {
async rank(state: GameState): Promise<RankedAction[]> {
const legal = legalActionMask(state);
const hand = currentHandSorted(state);
let bestAction = -1;
let bestScore = Number.NEGATIVE_INFINITY;
for (let action = 0; action < legal.length; action += 1) {
if (!legal[action]) continue;
const ranked = legal.flatMap((isLegal, action) => {
if (!isLegal) return [];
const handSlot = Math.floor(action / 12);
const place = Math.floor((action % 12) / 6);
const draw = action % 6;
@@ -57,13 +79,12 @@ export class HeuristicPolicy implements Policy {
let score = place === PLAY ? cardRank(card) + (isHandshake(card) ? 7 : 0) : -cardRank(card);
if (draw !== DRAW_DECK) score += 2;
if (place === DISCARD && draw > 0 && draw - 1 === cardColor(card)) score -= 100;
if (score > bestScore) {
bestScore = score;
bestAction = action;
}
}
if (bestAction < 0) throw new Error("state has no legal actions");
return bestAction;
return [{ action, score }];
});
// No calibrated probability to report — this is a hand-written score.
return ranked
.sort((left, right) => right.score - left.score)
.map(({ action }) => ({ action, probability: null }));
}
}
+193 -31
View File
@@ -289,31 +289,55 @@ button.lane__discard:disabled { opacity: 1; }
.lane__discard.is-chosen .card { box-shadow: 0 0 0 2px var(--gold), 0 0 28px rgba(201, 163, 75, 0.25); }
@keyframes target-pulse { 50% { filter: brightness(1.22); } }
/* Cards are keyed by card id, so only a card that just moved into a zone
mounts there — these entry animations are its motion between zones. */
@keyframes card-place {
from { transform: translateY(-30px) scale(0.94) rotate(-1.5deg); opacity: 0; }
to { transform: none; opacity: 1; }
/* Card travel between zones is played by the motion layer (src/ui/useCardMotion),
which measures each card's old and new position and animates the difference.
Only the flip cover it fades in and out lives here. */
.card__flip-cover {
position: absolute;
inset: -2px;
z-index: 12;
display: grid;
place-items: center;
border: 2px solid #67552d;
border-radius: 11px;
background: repeating-linear-gradient(135deg, #20252a 0 5px, #181c20 5px 10px);
color: var(--gold);
opacity: 0;
pointer-events: none;
}
@keyframes card-draw {
from { transform: translateY(34px) scale(0.9); opacity: 0; }
to { transform: none; opacity: 1; }
.card--compact .card__flip-cover { border-radius: 9px; }
.card__flip-cover i { font: 24px/1 Georgia, serif; font-style: normal; }
.card--back .card__flip-cover { display: none; }
.motion-overlay {
position: fixed;
inset: 0;
z-index: 30;
pointer-events: none;
}
/* `backwards`, not `both`: a lingering final transform would outrank the
hover-lift and .card--selected transforms once the animation ends. */
.card-stack .card,
.lane__discard .card { animation: card-place 0.26s ease-out backwards; }
.human-hand .card,
.opponent-hand .card { animation: card-draw 0.3s ease-out backwards; }
/* The hinted card, its destination, and its draw source. */
.card--hinted { box-shadow: 0 0 0 3px var(--gold), 0 0 26px rgba(201, 163, 75, 0.35); }
.lane__zone.is-hinted,
.lane__discard.is-hinted,
.deck-stack.is-hinted { animation: target-pulse 1.6s ease-in-out infinite; }
.lane__zone.is-hinted .lane__ghost,
.lane__discard.is-hinted .lane__ghost {
border-color: var(--gold);
border-style: dashed;
opacity: 0.9;
}
.lane__zone.is-hinted .card,
.lane__discard.is-hinted .card,
.deck-stack.is-hinted .card { box-shadow: 0 0 0 2px var(--gold), 0 0 26px rgba(201, 163, 75, 0.3); }
@media (prefers-reduced-motion: reduce) {
.card-stack .card,
.lane__discard .card,
.human-hand .card,
.opponent-hand .card { animation: none; }
.lane__zone.is-target,
.lane__discard.is-target,
.lane__discard.is-draw-target,
.lane__zone.is-hinted,
.lane__discard.is-hinted,
.deck-stack.is-hinted,
.deck-stack.is-draw-target { animation: none; }
}
@@ -386,6 +410,23 @@ button.card:disabled { cursor: default; }
.card--compact .card__center--handshake i { font-size: 31px; }
.card--compact .card__center small { font-size: 6px; }
/* Same footprint as a card back, so a revealed rival card sits in the hand row
instead of bursting out of it. */
.card--mini {
width: 44px;
height: 58px;
border-width: 2px;
border-radius: 5px;
}
.card--mini .card__corner { display: none; }
.card--mini .card__center { inset: 4px; }
.card--mini .card__center b { font-size: 20px; }
.card--mini .card__center i { margin-top: 2px; font-size: 11px; }
.card--mini .card__center--handshake i { font-size: 19px; }
.card--mini .card__center small { display: none; }
.card--mini::after { inset: 3px; border-radius: 3px; }
.card--mini .card__flip-cover { border-radius: 5px; }
.card--back {
width: 44px;
height: 58px;
@@ -431,12 +472,20 @@ button.card:disabled { cursor: default; }
.deck-stack > span { margin-top: 5px; color: #7e817c; font: 700 9px Arial, sans-serif; letter-spacing: 0.2em; }
.deck-stack.is-draw-target .card { box-shadow: 0 0 0 3px var(--gold), 7px 8px 0 #171b1e, 0 0 32px rgba(201, 163, 75, 0.3); }
.turn-prompt {
/* Insets keep the row clear of the history bar parked at the right. */
.prompt-row {
position: absolute;
right: 260px;
right: 330px;
bottom: 202px;
left: 260px;
left: 330px;
z-index: 7;
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
}
.turn-prompt {
min-width: 0;
margin: 0;
overflow: hidden;
color: #dedbd3;
@@ -446,6 +495,22 @@ button.card:disabled { cursor: default; }
white-space: nowrap;
}
.turn-prompt--thinking { color: #c6b887; }
.turn-prompt--review { color: #9fb4c8; }
.hint-button {
flex: 0 0 auto;
height: 32px;
padding: 0 14px;
border: 1px solid var(--gold-dim);
border-radius: 9px;
background: rgba(7, 13, 12, 0.9);
font: 700 9px Arial, sans-serif;
letter-spacing: 0.16em;
cursor: pointer;
}
.hint-button:hover:not(:disabled) { border-color: var(--gold); }
.hint-button:disabled { opacity: 0.35; cursor: default; }
.hint-button.is-active { border-color: var(--gold); color: var(--gold); }
.human-hand {
position: absolute;
@@ -471,18 +536,113 @@ button.card:disabled { cursor: default; }
backdrop-filter: blur(10px);
}
.result-card {
width: min(430px, calc(100% - 40px));
padding: 42px;
width: min(520px, calc(100% - 40px));
max-height: calc(100vh - 40px);
overflow-y: auto;
padding: 32px 34px 26px;
border: 1px solid var(--gold-dim);
border-radius: 18px;
background: #0b1211;
text-align: center;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.55);
}
.result-card__head { text-align: center; }
.result-card__head p { margin: 0; color: #8d8d84; font: 700 9px Arial, sans-serif; letter-spacing: 0.2em; }
.result-card__head h1 { margin: 10px 0 14px; color: #eee9dd; font: 700 32px Georgia, serif; }
.result-card__head strong { display: block; color: var(--gold); font: 700 26px Georgia, serif; }
.result-card__head i { color: #716b5b; font-style: normal; }
.result-table {
width: 100%;
margin: 24px 0 20px;
border-collapse: collapse;
font: 12px Arial, sans-serif;
}
.result-table th,
.result-table td { padding: 7px 6px; text-align: right; }
.result-table thead th {
color: #8d8d84;
font: 700 9px Arial, sans-serif;
letter-spacing: 0.14em;
}
.result-table thead tr:first-child th { border-bottom: 1px solid rgba(226, 220, 202, 0.14); }
.result-table__subhead th { padding-top: 6px; color: #6d716e; }
.result-table tbody th[scope="row"] {
display: flex;
align-items: center;
gap: 8px;
color: #cfcabd;
font: 700 11px Arial, sans-serif;
letter-spacing: 0.08em;
text-align: left;
}
.result-table tbody th[scope="row"] i {
color: var(--lane-color);
font: 15px/1 Georgia, serif;
font-style: normal;
}
.result-table tbody td { color: #ddd8ca; font: 700 13px Georgia, serif; }
.result-table__detail { color: #7d817c !important; font: 11px Arial, sans-serif !important; }
.result-table td.is-positive { color: #86c08c; }
.result-table td.is-negative { color: #c98b7f; }
.result-table tfoot th,
.result-table tfoot td {
padding-top: 12px;
border-top: 1px solid rgba(226, 220, 202, 0.14);
color: var(--gold);
font: 700 15px Georgia, serif;
}
.result-table tfoot th { text-align: left; letter-spacing: 0.1em; }
.result-card__foot { display: grid; gap: 14px; justify-items: center; }
.result-card__foot small { color: #6d716e; font: 700 9px Arial, sans-serif; letter-spacing: 0.14em; }
.result-card__foot div { display: flex; gap: 10px; }
.result-card button {
height: 42px;
padding: 0 24px;
border: 1px solid var(--gold-dim);
border-radius: 10px;
background: transparent;
color: #ece8dc;
font: 700 10px Arial, sans-serif;
letter-spacing: 0.12em;
cursor: pointer;
}
.result-card button:hover { border-color: var(--gold); }
.result-card button.is-primary { border-color: var(--gold); color: var(--gold); }
/* Sits in the band above the hand: the hand row is centered and its width is
computed against the score plaques, so a wide bar down at the hand's level
would overlap the leftmost/rightmost cards. */
.history-bar {
position: absolute;
right: 24px;
bottom: 196px;
z-index: 12;
display: flex;
align-items: center;
gap: 8px;
}
.history-bar button {
height: 32px;
padding: 0 11px;
display: flex;
align-items: center;
gap: 6px;
border: 1px solid var(--gold-dim);
border-radius: 9px;
background: rgba(7, 13, 12, 0.92);
font: 700 13px/1 Georgia, serif;
cursor: pointer;
}
.history-bar button span { font: 700 9px Arial, sans-serif; letter-spacing: 0.14em; }
.history-bar button:hover:not(:disabled) { border-color: var(--gold); }
.history-bar button:disabled { opacity: 0.3; cursor: default; }
.history-bar__resume {
border-color: var(--gold) !important;
color: var(--gold);
font: 700 9px Arial, sans-serif;
letter-spacing: 0.14em;
}
.result-card p { color: #8d8d84; font: 700 9px Arial, sans-serif; letter-spacing: 0.2em; }
.result-card h1 { margin: 16px 0; color: #eee9dd; font: 700 34px Georgia, serif; }
.result-card strong { display: block; margin: 24px; color: var(--gold); font: 700 28px Georgia, serif; }
.result-card i { color: #716b5b; font-style: normal; }
.result-card button { height: 42px; padding: 0 28px; border: 1px solid var(--gold); border-radius: 10px; background: transparent; color: #ece8dc; font: 700 10px Arial, sans-serif; letter-spacing: 0.12em; cursor: pointer; }
@media (max-width: 1250px) {
.table-center { width: 600px; }
@@ -490,7 +650,8 @@ button.card:disabled { cursor: default; }
.lane__ghost, .card--compact { width: 78px; height: 110px; }
.human-hand .card { width: 98px; height: 142px; margin-left: 7px; }
.human-hand { height: 146px; }
.turn-prompt { bottom: 177px; }
.prompt-row { bottom: 177px; }
.history-bar { bottom: 172px; }
.table-center { bottom: 210px; }
/* Narrow viewports: the plaques are the keep-out that squeezes the hand row,
so they become compact chips (model-name line dropped) to give the cards
@@ -513,7 +674,8 @@ button.card:disabled { cursor: default; }
.table-center { top: 64px; bottom: 196px; }
.human-hand { bottom: 18px; height: 138px; }
.human-hand .card { width: 94px; height: 134px; }
.turn-prompt { bottom: 165px; }
.prompt-row { bottom: 165px; }
.history-bar { bottom: 160px; }
.score-plaque--human { bottom: 9px; }
.deck-stack { transform: scale(0.86); }
}
+188
View File
@@ -0,0 +1,188 @@
import { useLayoutEffect, useRef } from "react";
/**
* Cards are keyed by card id, so the same card that moves between zones (hand →
* expedition, discard pile → hand, board → hand on undo) is the same logical
* element even though React mounts it in a different subtree. This hook records
* where every card was painted last render and, after the DOM updates, plays the
* difference as real motion (FLIP): the card is placed back at its old position
* and animated to its new one, so it travels instead of teleporting.
*
* A card arriving from the deck was not on screen before, so it flies out of the
* deck stack face-down and flips over on the way in. Undoing a deck draw sends it
* back the same way: the detached element is re-parented into an overlay and
* animated home.
*/
const MOVE_MS = 400;
const FLIP_MS = 520;
const EASE_OUT = "cubic-bezier(0.22, 0.61, 0.36, 1)";
const EASE_IN_OUT = "cubic-bezier(0.45, 0.05, 0.55, 0.95)";
const DEAL_STAGGER_MS = 45;
export interface CardMotion {
cardRef: (card: number) => (element: HTMLElement | null) => void;
deckRef: (element: HTMLElement | null) => void;
overlayRef: (element: HTMLDivElement | null) => void;
/** Drop the recorded layout so a fresh deal animates in rather than flying
* every card of the previous game back to the deck. */
resetMotion: () => void;
}
function center(rect: DOMRect): { x: number; y: number } {
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
function prefersReducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
/** Slide a card from where it used to be to where it now is. */
function animateMove(element: HTMLElement, from: DOMRect, to: DOMRect): void {
const dx = from.left - to.left;
const dy = from.top - to.top;
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return;
element.animate(
[
{ transform: `translate(${dx}px, ${dy}px)`, zIndex: 30 },
{ transform: `translate(${dx * 0.4}px, ${dy * 0.4}px) scale(1.06)`, zIndex: 30, offset: 0.5 },
{ transform: "none", zIndex: 30 },
],
{ duration: MOVE_MS, easing: EASE_OUT },
);
}
/** Fly a card out of the deck and flip it face-up on the way. The cover hides
* the mirrored face while the card is turned past edge-on. */
function animateFromDeck(element: HTMLElement, deck: DOMRect, to: DOMRect, delay: number): void {
const start = center(deck);
const end = center(to);
const dx = start.x - end.x;
const dy = start.y - end.y;
const timing = { duration: FLIP_MS, easing: EASE_OUT, delay, fill: "backwards" as const };
element.animate(
[
{
transform: `translate(${dx}px, ${dy}px) perspective(900px) rotateY(-180deg) scale(0.82)`,
zIndex: 30,
},
{ transform: "none", zIndex: 30 },
],
timing,
);
element.querySelector<HTMLElement>(".card__flip-cover")?.animate(
[
{ opacity: 1, offset: 0 },
{ opacity: 1, offset: 0.46 },
{ opacity: 0, offset: 0.54 },
{ opacity: 0, offset: 1 },
],
timing,
);
}
/** Undo of a deck draw: the card has left the tree, so re-parent the detached
* element into the overlay and fly it back into the deck, flipping face-down. */
function animateToDeck(
element: HTMLElement,
from: DOMRect,
deck: DOMRect,
overlay: HTMLDivElement,
): void {
const start = center(from);
const end = center(deck);
const dx = end.x - start.x;
const dy = end.y - start.y;
Object.assign(element.style, {
position: "fixed",
left: `${from.left}px`,
top: `${from.top}px`,
width: `${from.width}px`,
height: `${from.height}px`,
margin: "0",
pointerEvents: "none",
});
overlay.append(element);
const timing = { duration: FLIP_MS, easing: EASE_IN_OUT };
const animation = element.animate(
[
{ transform: "none" },
{
transform: `translate(${dx}px, ${dy}px) perspective(900px) rotateY(180deg) scale(0.82)`,
},
],
timing,
);
element.querySelector<HTMLElement>(".card__flip-cover")?.animate(
[
{ opacity: 0, offset: 0 },
{ opacity: 0, offset: 0.46 },
{ opacity: 1, offset: 0.54 },
{ opacity: 1, offset: 1 },
],
timing,
);
animation.finished.then(() => element.remove(), () => element.remove());
}
export function useCardMotion(): CardMotion {
const elements = useRef(new Map<number, HTMLElement>());
const rects = useRef(new Map<number, DOMRect>());
const deck = useRef<HTMLElement | null>(null);
const overlay = useRef<HTMLDivElement | null>(null);
const dealing = useRef(true);
// No dependency list: every render is a chance for a card to have moved.
useLayoutEffect(() => {
const reduced = prefersReducedMotion();
const deckRect = deck.current?.getBoundingClientRect() ?? null;
const next = new Map<number, DOMRect>();
let dealt = 0;
for (const [card, element] of [...elements.current]) {
// React re-attaches the ref when a card remounts in another zone, so a
// still-detached element means the card left the table (back to the deck).
if (!element.isConnected) {
const from = rects.current.get(card);
elements.current.delete(card);
if (!reduced && from && deckRect && overlay.current) {
animateToDeck(element, from, deckRect, overlay.current);
}
continue;
}
const to = element.getBoundingClientRect();
next.set(card, to);
if (reduced) continue;
const from = rects.current.get(card);
if (from) {
animateMove(element, from, to);
} else if (deckRect) {
animateFromDeck(element, deckRect, to, dealing.current ? dealt * DEAL_STAGGER_MS : 0);
dealt += 1;
}
}
rects.current = next;
dealing.current = false;
});
const cardRef = useRef((card: number) => (element: HTMLElement | null) => {
// Ignore unmount (null): the element is kept so a card that left the tree can
// still be flown back to the deck, and it is dropped once that is handled.
if (element) elements.current.set(card, element);
}).current;
return {
cardRef,
deckRef: (element) => { deck.current = element; },
overlayRef: (element) => { overlay.current = element; },
resetMotion: () => {
rects.current.clear();
dealing.current = true;
},
};
}