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:
co-authored by
Claude Fable 5.1
parent
65e88df523
commit
a071494f4a
@@ -7,6 +7,7 @@
|
||||
// Every live event batch plays through the same fx pipeline the
|
||||
// instant replay uses, so spells, doors, and conjurations perform
|
||||
// here first.
|
||||
import { motion } from "./motion";
|
||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||
import type { FpvTarget } from "./fpv/raycast";
|
||||
import { objectArt, tokenArt } from "./art";
|
||||
@@ -161,6 +162,8 @@
|
||||
* still fire — your own magic always turns your head. */
|
||||
let manualUntil = 0;
|
||||
let turning = false;
|
||||
let turnRaf = 0;
|
||||
onDestroy(() => cancelAnimationFrame(turnRaf));
|
||||
|
||||
/** Manual steering holds the director's idle aims off this long. */
|
||||
const STEER_HOLD_MS = 4000;
|
||||
@@ -173,14 +176,15 @@
|
||||
manualUntil = performance.now() + STEER_HOLD_MS;
|
||||
const from = cam.facing;
|
||||
const to = from + dir * (Math.PI / 2);
|
||||
if (motion.reduced) { cam.facing = to; turning = false; return; }
|
||||
const t0 = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const w = Math.min(1, (now - t0) / 180);
|
||||
const w = motion.reduced ? 1 : Math.min(1, (now - t0) / 180);
|
||||
cam.facing = from + (to - from) * (smoothstep(w));
|
||||
if (w < 1) requestAnimationFrame(tick);
|
||||
if (w < 1) turnRaf = requestAnimationFrame(tick);
|
||||
else turning = false;
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
turnRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
/** Pane clicks: a possessed mount claims them first — adjacent floor
|
||||
* steps it, a same-square enemy takes its blow — and only the rider's
|
||||
@@ -225,14 +229,23 @@
|
||||
let actorPos = $state<Record<string, { x: number; y: number }>>({});
|
||||
|
||||
// --- The fx: each batch plays once, on its own beat. -----------------
|
||||
// Track ONLY the batch: the server sends `events` then `state` back to
|
||||
// Track the batch and motion policy: the server sends `events` then `state` back to
|
||||
// back, so a wider dependency set re-runs this within the same frame,
|
||||
// before a zero-delay timer can fire. No per-run cleanup for the same
|
||||
// reason — timers die only with the component.
|
||||
// reason — timers are cleared when effects are disabled or the pane closes.
|
||||
let playedBatch = 0;
|
||||
const fxTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
$effect(() => {
|
||||
const enabled = motion.effects;
|
||||
const b = batch;
|
||||
if (!enabled) {
|
||||
fxTimers.forEach(clearTimeout);
|
||||
fxTimers.clear();
|
||||
fpFx = [];
|
||||
// Consume suppressed batches: enabling effects must not replay them.
|
||||
if (b) playedBatch = b.n;
|
||||
return;
|
||||
}
|
||||
if (!b) return;
|
||||
untrack(() => {
|
||||
if (b.n === playedBatch || !me) return;
|
||||
@@ -240,6 +253,7 @@
|
||||
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
|
||||
const starter = setTimeout(() => {
|
||||
fxTimers.delete(starter);
|
||||
if (!motion.effects) return;
|
||||
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
||||
const ender = setTimeout(() => {
|
||||
fxTimers.delete(ender);
|
||||
@@ -251,7 +265,7 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
onDestroy(() => fxTimers.forEach(clearTimeout));
|
||||
onDestroy(() => { fxTimers.forEach(clearTimeout); fxTimers.clear(); });
|
||||
|
||||
// --- Other bodies glide between views. -------------------------------
|
||||
let prevView: GameView | null = null;
|
||||
@@ -259,6 +273,7 @@
|
||||
const v = view;
|
||||
const before = prevView;
|
||||
prevView = v;
|
||||
if (motion.reduced) { actorPos = {}; return; }
|
||||
if (!before || before === v || !me) return;
|
||||
const moves = gatherGlides(before, v, povId);
|
||||
if (moves.length === 0) { actorPos = {}; return; }
|
||||
@@ -283,6 +298,7 @@
|
||||
// --- The camera: the director's ladder, live. ------------------------
|
||||
let directedBatch = 0;
|
||||
$effect(() => {
|
||||
const reduced = motion.reduced;
|
||||
const b = batch;
|
||||
const m = me;
|
||||
if (!m) { camReady = false; return; }
|
||||
@@ -385,7 +401,9 @@
|
||||
// pointed your own head is where it stays, brick or no brick.
|
||||
targetFacing = deepestFacing(v, tx, ty);
|
||||
}
|
||||
if (willCut) {
|
||||
// Under reduced motion every move is a cut: the director still picks
|
||||
// where to look, and the eye lands there without the pan or the walk.
|
||||
if (willCut || reduced) {
|
||||
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
|
||||
camReady = true;
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user