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
+18 -7
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { motion } from "./motion";
import Board from "./Board.svelte";
import FirstPerson from "./fpv/FirstPerson.svelte";
import { untrack } from "svelte";
@@ -8,7 +9,6 @@
import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d";
import { castRay, edgeMid } from "./fpv/raycast";
import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director";
import { prefs } from "./prefs.svelte";
import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
@@ -53,7 +53,7 @@
let playing = $state(true);
let speed = $state(1);
/** Watch the board from above, or relive it through your own eyes. */
let fp = $state(moment);
let fp = $state(untrack(() => moment));
/** Whose eyes the first-person camera wears: the wizard whose turn it
* is, ALWAYS — their position seen with the viewer's knowledge of the
@@ -90,12 +90,12 @@
const dieOf = (st: { events: GameEvent[] } | undefined) =>
st?.events.find((e): e is Extract<GameEvent, { type: "dieRolled" }> => e.type === "dieRolled");
/** How long this step's interlude holds the world before it moves. */
const dieMs = $derived(dieOf(step) && prefs.flourishes ? DIE_MS / speed : 0);
const dieMs = $derived(dieOf(step) && motion.effects ? DIE_MS / speed : 0);
let die = $state<{ idx: number; player: string; roll: number; purpose: string; face: number; landed: boolean } | null>(null);
$effect(() => {
const i = Math.min(idx, steps.length - 1);
const d = dieOf(steps[i]);
if (!d || !prefs.flourishes) { die = null; return; }
if (!d || !motion.effects) { die = null; return; }
const t0 = performance.now();
const dur = DIE_MS / speed;
const who = d.player ?? "The maze";
@@ -169,6 +169,7 @@
$effect(() => {
const who = povId;
if (!fp) { lastPov = null; return; }
if (!motion.effects) { lastPov = who; return; }
if (lastPov !== null && lastPov !== who) {
const p = step.view.players.find((x) => x.id === who);
const fx: FpFx = {
@@ -179,7 +180,7 @@
fpFx = [...untrack(() => fpFx), fx];
const t = setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80);
lastPov = who;
return () => clearTimeout(t);
return () => { clearTimeout(t); fpFx = fpFx.filter((f) => f.id !== fx.id); };
}
lastPov = who;
});
@@ -210,7 +211,7 @@
let boardFx = $state<BoardFx[]>([]);
$effect(() => {
const step = steps[Math.min(idx, steps.length - 1)];
if (!step || !prefs.flourishes) return;
if (!step || !motion.effects) return;
let cancel = () => {};
const wait = setTimeout(() => {
cancel = scheduleFx(
@@ -237,7 +238,7 @@
$effect(() => {
const i = Math.min(idx, steps.length - 1);
const st = steps[i];
if (!fp || !st || !prefs.flourishes) return;
if (!fp || !st || !motion.effects) return;
const plan = camPlan;
if (plan?.idx !== i) return; // the camera has not set this step's slate yet
const timers: ReturnType<typeof setTimeout>[] = [];
@@ -280,6 +281,7 @@
const v = st.view;
const before = prevView;
prevView = v;
if (motion.reduced) { actorPos = {}; return; }
if (!before || before === v) return;
const moves = gatherGlides(before, v, povId);
if (moves.length === 0) { actorPos = {}; return; }
@@ -316,6 +318,7 @@
"treasurePickedUp", "treasureDropped", "objectDropped"]);
let camReady = false;
$effect(() => {
const reduced = motion.reduced;
if (!fp) { camReady = false; return; }
const v = step.view;
const me = v.players.find((p) => p.id === povId);
@@ -360,6 +363,7 @@
cutawayShot = true;
const i2 = Math.min(idx, steps.length - 1);
const pv = i2 > 0 ? steps[i2 - 1]!.view : null;
if (reduced) { heldView = null; camPlan = { idx: i2, holdMs: 0 }; return; }
heldView = pv;
camPlan = { idx: i2, holdMs: pv ? 420 / speed + dieMs : 0 };
if (pv) {
@@ -483,6 +487,13 @@
}
return () => {};
};
if (reduced) {
cam.x = tx; cam.y = ty; cam.facing = targetFacing + closingArc; cam.pitch = targetPitch;
camReady = true;
heldView = null;
camPlan = { idx: stepIdx, holdMs: 0 };
return;
}
if (willCut) {
// First frame, or a leap the legs cannot explain: cut. An aimed
// cut still holds a beat of the before-world once the reel is