Files
coorl-lost-cities/web/src/ui/useCardMotion.ts
T
coolguyandClaude Opus 4.8 1d3b29aadb Ship borealis to the browser, and add the classic three-round mode
The web client now plays borealis (data/models.json), trained on the three-round
match and taking the 501-dim match view rather than a bare round. Only the actor
trunk is exported -- the critic exists to grade moves in training and never plays,
so the graph physically cannot leak the opponent's hand or the deck, which beats
promising not to call it.

The TypeScript match layer and observation mirror match.py and match_obs.py. They
have to agree to the bit: a mismatch throws nowhere, the ONNX policy just consumes
a wrong vector and plays worse for reasons nobody can see. So the port is not
trusted -- generate_match_parity_fixture.py emits 282 positions from real JAX play
(mid-round, both seats, past a roll-over, with a live carry) and the TS output is
checked against them to float32 round-off.

Match mode is a menu toggle. A seed fixes all three deals and the coin flips, so a
match stays a pure function of it. One-deal mode is unchanged from the player's
side; borealis simply sees it as round one at a carry of zero, a position it has
seen a great many times.

Two bugs found by driving the built app in a browser, both silent:

- The result card totalled the round, not the match. It read "-11 : 3" while the
  match stood at -96 : 66 -- it would have named the wrong winner. It now headlines
  the match total and breaks the round out beneath it.
- Game records were being rejected. The client's schema went to v2 (it now records
  which model played; the old records stored the on-screen label, which stops
  identifying anything once there are two models) while serve_web_with_logs.py
  still only accepted v1, so every record would have 400'd into a console warning.
  v1 stays accepted -- the 111 existing games are altair.

npm test and tsc were green through both. Hence web/.claude/skills/verify, which
records the recipe and the selectors so the next session drives the app instead of
re-deriving how.

.gitignore excluded the new model, which would have shipped a 404: the deploy
builds straight from the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
2026-07-15 07:06:29 +09:00

200 lines
6.7 KiB
TypeScript

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;
}
// getBoundingClientRect includes a transform applied by WAAPI. A render
// while a deal/move is still in flight (for example when the policy
// finishes loading) must retain the intended destination instead of
// treating the animated visual position as a new layout and launching a
// second flight.
const previous = rects.current.get(card);
if (element.getAnimations().some((animation) => animation.playState === "running")) {
if (previous) next.set(card, previous);
continue;
}
const to = element.getBoundingClientRect();
next.set(card, to);
if (reduced) continue;
const from = previous;
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;
},
};
}