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
+9 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { motion } from "./motion";
import { attentionLabel, net, spellName } from "./net.svelte"; import { attentionLabel, net, spellName } from "./net.svelte";
import { FISTS } from "./fpv/paintedFx"; import { FISTS } from "./fpv/paintedFx";
import Board from "./Board.svelte"; import Board from "./Board.svelte";
@@ -205,7 +206,7 @@
} }
} }
if (view) fpBatch = { n: (fpBatch?.n ?? 0) + 1, events, view }; if (view) fpBatch = { n: (fpBatch?.n ?? 0) + 1, events, view };
if (!prefs.flourishes) return; if (!motion.effects) return;
fxCancels.push(scheduleFx( fxCancels.push(scheduleFx(
events, view, events, view,
(fx) => (boardFx = [...boardFx, fx]), (fx) => (boardFx = [...boardFx, fx]),
@@ -222,6 +223,12 @@
fxCancels = []; fxCancels = [];
}; };
}); });
$effect(() => {
if (motion.effects) return;
fxCancels.forEach((cancel) => cancel());
fxCancels = [];
boardFx = [];
});
const openingRolls = $derived(net.openingRolls ?? local.openingRolls); const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
function dismissRolls() { function dismissRolls() {
net.dismissRolls(); net.dismissRolls();
@@ -1877,7 +1884,7 @@
<label class="pref-row pref-check"> <label class="pref-row pref-check">
<input type="checkbox" checked={prefs.flourishes} <input type="checkbox" checked={prefs.flourishes}
onchange={(e) => setPref("flourishes", e.currentTarget.checked)} /> onchange={(e) => setPref("flourishes", e.currentTarget.checked)} />
<span>Spell flourishes (the animated effects)</span> <span>Spell flourishes (the animated effects — off as well whenever your system asks for reduced motion)</span>
</label> </label>
<label class="pref-row pref-check"> <label class="pref-row pref-check">
<input type="checkbox" checked={prefs.liveFp} <input type="checkbox" checked={prefs.liveFp}
+25 -7
View File
@@ -7,6 +7,7 @@
// Every live event batch plays through the same fx pipeline the // Every live event batch plays through the same fx pipeline the
// instant replay uses, so spells, doors, and conjurations perform // instant replay uses, so spells, doors, and conjurations perform
// here first. // here first.
import { motion } from "./motion";
import FirstPerson from "./fpv/FirstPerson.svelte"; import FirstPerson from "./fpv/FirstPerson.svelte";
import type { FpvTarget } from "./fpv/raycast"; import type { FpvTarget } from "./fpv/raycast";
import { objectArt, tokenArt } from "./art"; import { objectArt, tokenArt } from "./art";
@@ -161,6 +162,8 @@
* still fire — your own magic always turns your head. */ * still fire — your own magic always turns your head. */
let manualUntil = 0; let manualUntil = 0;
let turning = false; let turning = false;
let turnRaf = 0;
onDestroy(() => cancelAnimationFrame(turnRaf));
/** Manual steering holds the director's idle aims off this long. */ /** Manual steering holds the director's idle aims off this long. */
const STEER_HOLD_MS = 4000; const STEER_HOLD_MS = 4000;
@@ -173,14 +176,15 @@
manualUntil = performance.now() + STEER_HOLD_MS; manualUntil = performance.now() + STEER_HOLD_MS;
const from = cam.facing; const from = cam.facing;
const to = from + dir * (Math.PI / 2); const to = from + dir * (Math.PI / 2);
if (motion.reduced) { cam.facing = to; turning = false; return; }
const t0 = performance.now(); const t0 = performance.now();
const tick = (now: number) => { 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)); cam.facing = from + (to - from) * (smoothstep(w));
if (w < 1) requestAnimationFrame(tick); if (w < 1) turnRaf = requestAnimationFrame(tick);
else turning = false; else turning = false;
}; };
requestAnimationFrame(tick); turnRaf = requestAnimationFrame(tick);
} }
/** Pane clicks: a possessed mount claims them first — adjacent floor /** Pane clicks: a possessed mount claims them first — adjacent floor
* steps it, a same-square enemy takes its blow — and only the rider's * 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 }>>({}); let actorPos = $state<Record<string, { x: number; y: number }>>({});
// --- The fx: each batch plays once, on its own beat. ----------------- // --- 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, // 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 // 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; let playedBatch = 0;
const fxTimers = new Set<ReturnType<typeof setTimeout>>(); const fxTimers = new Set<ReturnType<typeof setTimeout>>();
$effect(() => { $effect(() => {
const enabled = motion.effects;
const b = batch; 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; if (!b) return;
untrack(() => { untrack(() => {
if (b.n === playedBatch || !me) return; if (b.n === playedBatch || !me) return;
@@ -240,6 +253,7 @@
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) { for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
const starter = setTimeout(() => { const starter = setTimeout(() => {
fxTimers.delete(starter); fxTimers.delete(starter);
if (!motion.effects) return;
fpFx = [...fpFx, { ...fx, t0: performance.now() }]; fpFx = [...fpFx, { ...fx, t0: performance.now() }];
const ender = setTimeout(() => { const ender = setTimeout(() => {
fxTimers.delete(ender); fxTimers.delete(ender);
@@ -251,7 +265,7 @@
} }
}); });
}); });
onDestroy(() => fxTimers.forEach(clearTimeout)); onDestroy(() => { fxTimers.forEach(clearTimeout); fxTimers.clear(); });
// --- Other bodies glide between views. ------------------------------- // --- Other bodies glide between views. -------------------------------
let prevView: GameView | null = null; let prevView: GameView | null = null;
@@ -259,6 +273,7 @@
const v = view; const v = view;
const before = prevView; const before = prevView;
prevView = v; prevView = v;
if (motion.reduced) { actorPos = {}; return; }
if (!before || before === v || !me) return; if (!before || before === v || !me) return;
const moves = gatherGlides(before, v, povId); const moves = gatherGlides(before, v, povId);
if (moves.length === 0) { actorPos = {}; return; } if (moves.length === 0) { actorPos = {}; return; }
@@ -283,6 +298,7 @@
// --- The camera: the director's ladder, live. ------------------------ // --- The camera: the director's ladder, live. ------------------------
let directedBatch = 0; let directedBatch = 0;
$effect(() => { $effect(() => {
const reduced = motion.reduced;
const b = batch; const b = batch;
const m = me; const m = me;
if (!m) { camReady = false; return; } if (!m) { camReady = false; return; }
@@ -385,7 +401,9 @@
// pointed your own head is where it stays, brick or no brick. // pointed your own head is where it stays, brick or no brick.
targetFacing = deepestFacing(v, tx, ty); 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; cam.x = tx; cam.y = ty; cam.facing = targetFacing;
camReady = true; camReady = true;
return; return;
+18 -7
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { motion } from "./motion";
import Board from "./Board.svelte"; import Board from "./Board.svelte";
import FirstPerson from "./fpv/FirstPerson.svelte"; import FirstPerson from "./fpv/FirstPerson.svelte";
import { untrack } from "svelte"; import { untrack } from "svelte";
@@ -8,7 +9,6 @@
import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d"; import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d";
import { castRay, edgeMid } from "./fpv/raycast"; import { castRay, edgeMid } from "./fpv/raycast";
import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director"; import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director";
import { prefs } from "./prefs.svelte";
import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine"; import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine"; import type { GameEvent, GameView } from "@wizwar/engine";
@@ -53,7 +53,7 @@
let playing = $state(true); let playing = $state(true);
let speed = $state(1); let speed = $state(1);
/** Watch the board from above, or relive it through your own eyes. */ /** 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 /** Whose eyes the first-person camera wears: the wizard whose turn it
* is, ALWAYS — their position seen with the viewer's knowledge of the * is, ALWAYS — their position seen with the viewer's knowledge of the
@@ -90,12 +90,12 @@
const dieOf = (st: { events: GameEvent[] } | undefined) => const dieOf = (st: { events: GameEvent[] } | undefined) =>
st?.events.find((e): e is Extract<GameEvent, { type: "dieRolled" }> => e.type === "dieRolled"); 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. */ /** 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); let die = $state<{ idx: number; player: string; roll: number; purpose: string; face: number; landed: boolean } | null>(null);
$effect(() => { $effect(() => {
const i = Math.min(idx, steps.length - 1); const i = Math.min(idx, steps.length - 1);
const d = dieOf(steps[i]); const d = dieOf(steps[i]);
if (!d || !prefs.flourishes) { die = null; return; } if (!d || !motion.effects) { die = null; return; }
const t0 = performance.now(); const t0 = performance.now();
const dur = DIE_MS / speed; const dur = DIE_MS / speed;
const who = d.player ?? "The maze"; const who = d.player ?? "The maze";
@@ -169,6 +169,7 @@
$effect(() => { $effect(() => {
const who = povId; const who = povId;
if (!fp) { lastPov = null; return; } if (!fp) { lastPov = null; return; }
if (!motion.effects) { lastPov = who; return; }
if (lastPov !== null && lastPov !== who) { if (lastPov !== null && lastPov !== who) {
const p = step.view.players.find((x) => x.id === who); const p = step.view.players.find((x) => x.id === who);
const fx: FpFx = { const fx: FpFx = {
@@ -179,7 +180,7 @@
fpFx = [...untrack(() => fpFx), fx]; fpFx = [...untrack(() => fpFx), fx];
const t = setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80); const t = setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80);
lastPov = who; lastPov = who;
return () => clearTimeout(t); return () => { clearTimeout(t); fpFx = fpFx.filter((f) => f.id !== fx.id); };
} }
lastPov = who; lastPov = who;
}); });
@@ -210,7 +211,7 @@
let boardFx = $state<BoardFx[]>([]); let boardFx = $state<BoardFx[]>([]);
$effect(() => { $effect(() => {
const step = steps[Math.min(idx, steps.length - 1)]; const step = steps[Math.min(idx, steps.length - 1)];
if (!step || !prefs.flourishes) return; if (!step || !motion.effects) return;
let cancel = () => {}; let cancel = () => {};
const wait = setTimeout(() => { const wait = setTimeout(() => {
cancel = scheduleFx( cancel = scheduleFx(
@@ -237,7 +238,7 @@
$effect(() => { $effect(() => {
const i = Math.min(idx, steps.length - 1); const i = Math.min(idx, steps.length - 1);
const st = steps[i]; const st = steps[i];
if (!fp || !st || !prefs.flourishes) return; if (!fp || !st || !motion.effects) return;
const plan = camPlan; const plan = camPlan;
if (plan?.idx !== i) return; // the camera has not set this step's slate yet if (plan?.idx !== i) return; // the camera has not set this step's slate yet
const timers: ReturnType<typeof setTimeout>[] = []; const timers: ReturnType<typeof setTimeout>[] = [];
@@ -280,6 +281,7 @@
const v = st.view; const v = st.view;
const before = prevView; const before = prevView;
prevView = v; prevView = v;
if (motion.reduced) { actorPos = {}; return; }
if (!before || before === v) return; if (!before || before === v) return;
const moves = gatherGlides(before, v, povId); const moves = gatherGlides(before, v, povId);
if (moves.length === 0) { actorPos = {}; return; } if (moves.length === 0) { actorPos = {}; return; }
@@ -316,6 +318,7 @@
"treasurePickedUp", "treasureDropped", "objectDropped"]); "treasurePickedUp", "treasureDropped", "objectDropped"]);
let camReady = false; let camReady = false;
$effect(() => { $effect(() => {
const reduced = motion.reduced;
if (!fp) { camReady = false; return; } if (!fp) { camReady = false; return; }
const v = step.view; const v = step.view;
const me = v.players.find((p) => p.id === povId); const me = v.players.find((p) => p.id === povId);
@@ -360,6 +363,7 @@
cutawayShot = true; cutawayShot = true;
const i2 = Math.min(idx, steps.length - 1); const i2 = Math.min(idx, steps.length - 1);
const pv = i2 > 0 ? steps[i2 - 1]!.view : null; const pv = i2 > 0 ? steps[i2 - 1]!.view : null;
if (reduced) { heldView = null; camPlan = { idx: i2, holdMs: 0 }; return; }
heldView = pv; heldView = pv;
camPlan = { idx: i2, holdMs: pv ? 420 / speed + dieMs : 0 }; camPlan = { idx: i2, holdMs: pv ? 420 / speed + dieMs : 0 };
if (pv) { if (pv) {
@@ -483,6 +487,13 @@
} }
return () => {}; 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) { if (willCut) {
// First frame, or a leap the legs cannot explain: cut. An aimed // 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 // cut still holds a beat of the before-world once the reel is
+21 -17
View File
@@ -3,6 +3,7 @@
// GameView. Columns of wall shaded by distance and facing; token art // GameView. Columns of wall shaded by distance and facing; token art
// billboarded for whatever stands in the corridors, occluded per column // billboarded for whatever stands in the corridors, occluded per column
// by the same depth buffer the walls wrote. // by the same depth buffer the walls wrote.
import { motion } from "../motion";
import { billboards, castRay, warpMotion, type Billboard, type FpvTarget } from "./raycast"; import { billboards, castRay, warpMotion, type Billboard, type FpvTarget } from "./raycast";
import { paintFist, paintPow, punchPose, paintWeb, paintFloorRing } from "./paintedFx"; import { paintFist, paintPow, punchPose, paintWeb, paintFloorRing } from "./paintedFx";
import { materialTextures } from "./textures"; import { materialTextures } from "./textures";
@@ -197,6 +198,9 @@
} }
function draw(time: number) { 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"); const ctx = canvas?.getContext("2d");
if (!ctx) return; if (!ctx) return;
ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew 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. // Active shakes wobble the eye a hair off true, fading as they run.
let ex = x, ey = y; let ex = x, ey = y;
for (const f of fx) { for (const f of activeFx) {
if (f.kind !== "shake") continue; if (f.kind !== "shake") continue;
const p = (time - f.t0) / f.dur; const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue; if (p < 0 || p >= 1) continue;
@@ -221,7 +225,7 @@
let growing: Record<string, number> | undefined; let growing: Record<string, number> | undefined;
const spawn: Record<string, number> = {}; const spawn: Record<string, number> = {};
const surging: Record<number, number> = {}; const surging: Record<number, number> = {};
for (const f of fx) { for (const f of activeFx) {
if (f.kind === "door") { if (f.kind === "door") {
const a = doorOpenness((time - f.t0) / f.dur); const a = doorOpenness((time - f.t0) / f.dur);
if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a); 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 // FEAR's aura stains the forbidden ground itself, as the board draws
// its dashed diamond — the same warp-walking yardstick behind both. // its dashed diamond — the same warp-walking yardstick behind both.
const dreadGrid = dreadGridOf(view); 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++) { for (let row = 0; row < H; row++) {
const below = row > half; const below = row > half;
const dz = below ? row - half : half - row; const dz = below ? row - half : half - row;
@@ -440,7 +444,7 @@
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45)); let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8; if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
if (hit.kind === "firewall") { 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.fillStyle = `rgba(255,140,40,${Math.max(0, flicker)})`;
ctx.fillRect(col, top, 1, wallH); ctx.fillRect(col, top, 1, wallH);
dark *= 0.5; // the fire lights itself dark *= 0.5; // the fire lights itself
@@ -448,13 +452,13 @@
if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") { if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") {
// The same tell the board gives: an untested illusion's face // The same tell the board gives: an untested illusion's face
// shimmers faintly — maybe stone, maybe not. // 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.fillStyle = `rgba(190,160,255,${Math.max(0, tell)})`;
ctx.fillRect(col, top, 1, wallH); ctx.fillRect(col, top, 1, wallH);
} }
if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") { if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") {
// A jammed lock seethes: a rusty seal pulsing across the wood. // 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.fillStyle = `rgba(210,70,20,${Math.max(0, seethe)})`;
ctx.fillRect(col, top + wallH * 0.35, 1, wallH * 0.3); ctx.fillRect(col, top + wallH * 0.35, 1, wallH * 0.3);
} }
@@ -464,13 +468,13 @@
} }
if (hit.warped) { if (hit.warped) {
// Seen through a warp: the far side swims in violet haze. // 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.fillStyle = `rgba(120,70,220,${haze})`;
ctx.fillRect(col, top, 1, wallH); ctx.fillRect(col, top, 1, wallH);
} }
if (edgeSelect && !hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) { if (edgeSelect && !hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) {
// The raised edge-card's invitation: faces breathe gold. // 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.fillStyle = `rgba(201,167,42,${Math.max(0, offer)})`;
ctx.fillRect(col, top, 1, wallH); 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); 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" }); 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; if (f.kind !== "projectile" && f.kind !== "impact") continue;
const p = (time - f.t0) / f.dur; const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue; if (p < 0 || p >= 1) continue;
@@ -545,13 +549,13 @@
const w = s.right - s.left; const w = s.right - s.left;
if (seen && s.dread && s.hit) { 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), 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 && if (seen && ontarget && aimBeings && litCells && s.hit && s.cell &&
litCells.has(`${s.cell.x},${s.cell.y}`) && litCells.has(`${s.cell.x},${s.cell.y}`) &&
!(s.hit.kind === "player" && s.hit.id === view.you)) { !(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), 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 // Painted art composites normally (inks stay true); light glows
// additively. Either way translucency applies. // additively. Either way translucency applies.
@@ -607,7 +611,7 @@
const vTop = half - vh / 2; const vTop = half - vh / 2;
const wt = textures.warp!; const wt = textures.warp!;
const s = surging[vl.warp] ?? 0; 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 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. // 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; 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 top0 = half + full / 2 - grown;
const tex = textures[ri.kind] ?? textures.wall!; const tex = textures[ri.kind] ?? textures.wall!;
const swirl = (0.35 * (1 - ri.g) + 0.08) * 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)) { for (const [segTop, segH] of maskedSegs(ri.col, ri.depth, top0, grown)) {
ctx.globalAlpha = 0.55 + 0.45 * ri.g; ctx.globalAlpha = 0.55 + 0.45 * ri.g;
ctx.drawImage(tex, Math.min(tex.width - 1, ri.u * tex.width), 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 gHft = Math.min(H * 2.5, H / Math.max(gh.depth, 0.05));
const gTop = half - gHft / 2; const gTop = half - gHft / 2;
const tex = textures.wall!; 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)) { for (const [segTop, segH] of maskedSegs(gh.col, gh.depth, gTop, gHft)) {
ctx.globalAlpha = 0.2; ctx.globalAlpha = 0.2;
ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width), ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width),
@@ -695,7 +699,7 @@
ctx.fillRect(0, 0, W, H); ctx.fillRect(0, 0, W, H);
// Being hit is felt: a whole-screen wash that fades as it goes. // 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; if (f.kind !== "flash") continue;
const p = (time - f.t0) / f.dur; const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue; if (p < 0 || p >= 1) continue;
@@ -709,7 +713,7 @@
// as it goes, and snaps back; one thrown at you swells from the // 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 // middle of the pane until it fills it. Either way the blow lands
// with a comic starburst. // with a comic starburst.
for (const f of fx) { for (const f of activeFx) {
if (f.kind !== "fist") continue; if (f.kind !== "fist") continue;
const p = (time - f.t0) / f.dur; const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue; if (p < 0 || p >= 1) continue;
@@ -731,7 +735,7 @@
// A change of eyes: the slate wipes across, names the wizard whose // A change of eyes: the slate wipes across, names the wizard whose
// sight this now is, and wipes away to reveal it. // 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; if (f.kind !== "slate") continue;
const [l, r] = slateBand((time - f.t0) / f.dur); const [l, r] = slateBand((time - f.t0) / f.dur);
if (r <= l) continue; if (r <= l) continue;
+9 -9
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { motion } from "../motion";
import { smoothstep } from "./fx3d"; import { smoothstep } from "./fx3d";
// The first-person workshop (/?fpv): a real dealt game, one wizard's // The first-person workshop (/?fpv): a real dealt game, one wizard's
// eyes, free-fly controls. No rules run here — the camera walks where // eyes, free-fly controls. No rules run here — the camera walks where
@@ -9,7 +10,7 @@
import FirstPerson from "./FirstPerson.svelte"; import FirstPerson from "./FirstPerson.svelte";
import Replay from "../Replay.svelte"; import Replay from "../Replay.svelte";
import { canWalk, edgeMid, SIDE_ANGLE, OPPOSITE } from "./raycast"; import { canWalk, edgeMid, SIDE_ANGLE, OPPOSITE } from "./raycast";
import { buildScreenplaySteps, screenplayByName, SCREENPLAYS, type ScreenplayStep } from "./screenplays"; import { buildScreenplaySteps, screenplayByName, SCREENPLAYS } from "./screenplays";
const q = new URLSearchParams(location.search); const q = new URLSearchParams(location.search);
const seed = Number(q.get("seed") ?? 42); const seed = Number(q.get("seed") ?? 42);
@@ -145,7 +146,7 @@
const tick = (now: number) => { const tick = (now: number) => {
if (!anim) startNext(now); if (!anim) startNext(now);
if (anim) { if (anim) {
const w = Math.min(1, (now - anim.t0) / anim.dur); const w = motion.reduced ? 1 : Math.min(1, (now - anim.t0) / anim.dur);
if (anim.kind === "glide") { if (anim.kind === "glide") {
x = anim.fx + (anim.tx - anim.fx) * ease(w); x = anim.fx + (anim.tx - anim.fx) * ease(w);
y = anim.fy + (anim.ty - anim.fy) * ease(w); y = anim.fy + (anim.ty - anim.fy) * ease(w);
@@ -205,17 +206,16 @@
// a refused move reports itself instead of a blank stage. // a refused move reports itself instead of a blank stage.
const scriptName = q.get("script"); const scriptName = q.get("script");
const scriptPlay = scriptName ? screenplayByName(scriptName) : null; const scriptPlay = scriptName ? screenplayByName(scriptName) : null;
let scriptSteps: ScreenplayStep[] = []; const { scriptSteps, scriptError } = (() => {
let scriptError: string | null = null;
if (scriptName && !scriptPlay) { if (scriptName && !scriptPlay) {
scriptError = `No screenplay named "${scriptName}". The catalog: ${SCREENPLAYS.map((sp) => sp.name).join(", ")}`; return { scriptSteps: [], scriptError: `No screenplay named "${scriptName}". The catalog: ${SCREENPLAYS.map((sp) => sp.name).join(", ")}` };
} else if (scriptPlay) { }
try { try {
scriptSteps = buildScreenplaySteps(scriptPlay); return { scriptSteps: scriptPlay ? buildScreenplaySteps(scriptPlay) : [], scriptError: null };
} catch (e) { } catch (e) {
scriptError = e instanceof Error ? e.message : String(e); return { scriptSteps: [], scriptError: e instanceof Error ? e.message : String(e) };
}
} }
})();
// Minimap geometry (top-down, one small square per cell). // Minimap geometry (top-down, one small square per cell).
const MM = 9; const MM = 9;
+11
View File
@@ -0,0 +1,11 @@
import { prefersReducedMotion } from "svelte/motion";
import { prefs } from "./prefs.svelte";
// Shared by the board, the reel, and the canvas. Svelte's own media query
// tracks the OS setting live and releases its listener when no reactive
// consumer remains.
export const motion = {
get reduced(): boolean { return prefersReducedMotion.current; },
/** Flourishes play only when the player wants them AND the OS allows motion. */
get effects(): boolean { return prefs.flourishes && !prefersReducedMotion.current; },
};