Reduced motion is honored everywhere the table moves

One shared motion policy: the OS's reduce-motion setting, read live
through Svelte's own media query, folds into the flourishes switch.
Under it the board plays no flourishes, the reel skips its die
interlude and slate wipes, bodies jump between squares instead of
gliding, the canvas holds its ambient shimmers still, and the camera
cuts wherever the director would have panned — in the cockpit as in
the reel, so the eye still lands on what the director chose to watch.
Timers and animation frames are cleared when the policy flips or the
pane closes. A first turn and two reels stepped clean under the
setting with no errors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-15 22:42:08 -04:00
co-authored by Claude Fable 5.1
parent 65e88df523
commit a071494f4a
6 changed files with 96 additions and 45 deletions
+21 -17
View File
@@ -3,6 +3,7 @@
// GameView. Columns of wall shaded by distance and facing; token art
// billboarded for whatever stands in the corridors, occluded per column
// by the same depth buffer the walls wrote.
import { motion } from "../motion";
import { billboards, castRay, warpMotion, type Billboard, type FpvTarget } from "./raycast";
import { paintFist, paintPow, punchPose, paintWeb, paintFloorRing } from "./paintedFx";
import { materialTextures } from "./textures";
@@ -197,6 +198,9 @@
}
function draw(time: number) {
// Keep the final game state and static hazard/target cues visible.
const activeFx = motion.effects ? fx : [];
const ambientTime = motion.effects ? time : 0;
const ctx = canvas?.getContext("2d");
if (!ctx) return;
ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew
@@ -207,7 +211,7 @@
// Active shakes wobble the eye a hair off true, fading as they run.
let ex = x, ey = y;
for (const f of fx) {
for (const f of activeFx) {
if (f.kind !== "shake") continue;
const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue;
@@ -221,7 +225,7 @@
let growing: Record<string, number> | undefined;
const spawn: Record<string, number> = {};
const surging: Record<number, number> = {};
for (const f of fx) {
for (const f of activeFx) {
if (f.kind === "door") {
const a = doorOpenness((time - f.t0) / f.dur);
if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a);
@@ -321,7 +325,7 @@
// FEAR's aura stains the forbidden ground itself, as the board draws
// its dashed diamond — the same warp-walking yardstick behind both.
const dreadGrid = dreadGridOf(view);
const dreadBlend = dreadGrid ? 0.16 + 0.07 * Math.sin(time / 400) : 0;
const dreadBlend = dreadGrid ? 0.16 + 0.07 * Math.sin(ambientTime / 400) : 0;
for (let row = 0; row < H; row++) {
const below = row > half;
const dz = below ? row - half : half - row;
@@ -440,7 +444,7 @@
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
if (hit.kind === "firewall") {
const flicker = 0.15 + 0.15 * Math.sin(time / 90 + hit.worldU * 17 + col * 0.15);
const flicker = 0.15 + 0.15 * Math.sin(ambientTime / 90 + hit.worldU * 17 + col * 0.15);
ctx.fillStyle = `rgba(255,140,40,${Math.max(0, flicker)})`;
ctx.fillRect(col, top, 1, wallH);
dark *= 0.5; // the fire lights itself
@@ -448,13 +452,13 @@
if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") {
// The same tell the board gives: an untested illusion's face
// shimmers faintly — maybe stone, maybe not.
const tell = 0.06 + 0.05 * Math.sin(time / 260 + hit.worldU * 13);
const tell = 0.06 + 0.05 * Math.sin(ambientTime / 260 + hit.worldU * 13);
ctx.fillStyle = `rgba(190,160,255,${Math.max(0, tell)})`;
ctx.fillRect(col, top, 1, wallH);
}
if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") {
// A jammed lock seethes: a rusty seal pulsing across the wood.
const seethe = 0.10 + 0.08 * Math.sin(time / 160 + hit.worldU * 22);
const seethe = 0.10 + 0.08 * Math.sin(ambientTime / 160 + hit.worldU * 22);
ctx.fillStyle = `rgba(210,70,20,${Math.max(0, seethe)})`;
ctx.fillRect(col, top + wallH * 0.35, 1, wallH * 0.3);
}
@@ -464,13 +468,13 @@
}
if (hit.warped) {
// Seen through a warp: the far side swims in violet haze.
const haze = 0.16 + 0.05 * Math.sin(time / 300 + col * 0.05);
const haze = 0.16 + 0.05 * Math.sin(ambientTime / 300 + col * 0.05);
ctx.fillStyle = `rgba(120,70,220,${haze})`;
ctx.fillRect(col, top, 1, wallH);
}
if (edgeSelect && !hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) {
// The raised edge-card's invitation: faces breathe gold.
const offer = 0.10 + 0.05 * Math.sin(time / 420 + hit.worldU * 3);
const offer = 0.10 + 0.05 * Math.sin(ambientTime / 420 + hit.worldU * 3);
ctx.fillStyle = `rgba(201,167,42,${Math.max(0, offer)})`;
ctx.fillRect(col, top, 1, wallH);
}
@@ -487,7 +491,7 @@
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.5, aspect: 2, rise: 0 }, ex, ey);
if (s) sprites.push({ ...s, fallback: "rubble" });
}
for (const f of fx) {
for (const f of activeFx) {
if (f.kind !== "projectile" && f.kind !== "impact") continue;
const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue;
@@ -545,13 +549,13 @@
const w = s.right - s.left;
if (seen && s.dread && s.hit) {
floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(9, w * 0.5), Math.max(3, w * 0.155),
"160,30,30", 0.45 + 0.25 * Math.sin(time / 300), 0.12);
"160,30,30", 0.45 + 0.25 * Math.sin(ambientTime / 300), 0.12);
}
if (seen && ontarget && aimBeings && litCells && s.hit && s.cell &&
litCells.has(`${s.cell.x},${s.cell.y}`) &&
!(s.hit.kind === "player" && s.hit.id === view.you)) {
floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(7, w * 0.42), Math.max(3, w * 0.13),
"232,160,60", 0.55 + 0.25 * Math.sin(time / 220), 0.16);
"232,160,60", 0.55 + 0.25 * Math.sin(ambientTime / 220), 0.16);
}
// Painted art composites normally (inks stay true); light glows
// additively. Either way translucency applies.
@@ -607,7 +611,7 @@
const vTop = half - vh / 2;
const wt = textures.warp!;
const s = surging[vl.warp] ?? 0;
const swirl = 0.1 + 0.1 * Math.sin(time / (240 - 170 * s) + vl.u * 9 + vl.col * 0.02);
const swirl = 0.1 + 0.1 * Math.sin(ambientTime / (240 - 170 * s) + vl.u * 9 + vl.col * 0.02);
// The ripple: a ring of light widening from the veil's center over
// the surge, one full sweep to the edges; ~a fifth of the veil wide.
const ring = s > 0 ? Math.max(0, 1 - Math.abs(Math.abs(vl.u - 0.5) * 2 - (1 - s)) / 0.2) : 0;
@@ -636,7 +640,7 @@
const top0 = half + full / 2 - grown;
const tex = textures[ri.kind] ?? textures.wall!;
const swirl = (0.35 * (1 - ri.g) + 0.08) *
(0.7 + 0.3 * Math.sin(time / 90 + ri.worldU * 21));
(0.7 + 0.3 * Math.sin(ambientTime / 90 + ri.worldU * 21));
for (const [segTop, segH] of maskedSegs(ri.col, ri.depth, top0, grown)) {
ctx.globalAlpha = 0.55 + 0.45 * ri.g;
ctx.drawImage(tex, Math.min(tex.width - 1, ri.u * tex.width),
@@ -674,7 +678,7 @@
const gHft = Math.min(H * 2.5, H / Math.max(gh.depth, 0.05));
const gTop = half - gHft / 2;
const tex = textures.wall!;
const ripple = 0.10 + 0.07 * Math.sin(time / 200 + gh.worldU * 11 + gHft * 0.01);
const ripple = 0.10 + 0.07 * Math.sin(ambientTime / 200 + gh.worldU * 11 + gHft * 0.01);
for (const [segTop, segH] of maskedSegs(gh.col, gh.depth, gTop, gHft)) {
ctx.globalAlpha = 0.2;
ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width),
@@ -695,7 +699,7 @@
ctx.fillRect(0, 0, W, H);
// Being hit is felt: a whole-screen wash that fades as it goes.
for (const f of fx) {
for (const f of activeFx) {
if (f.kind !== "flash") continue;
const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue;
@@ -709,7 +713,7 @@
// as it goes, and snaps back; one thrown at you swells from the
// middle of the pane until it fills it. Either way the blow lands
// with a comic starburst.
for (const f of fx) {
for (const f of activeFx) {
if (f.kind !== "fist") continue;
const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue;
@@ -731,7 +735,7 @@
// A change of eyes: the slate wipes across, names the wizard whose
// sight this now is, and wipes away to reveal it.
for (const f of fx) {
for (const f of activeFx) {
if (f.kind !== "slate") continue;
const [l, r] = slateBand((time - f.t0) / f.dur);
if (r <= l) continue;