The cockpit's spells fire: fx timers survive the state message

Two lightning blasts at an adjacent wizard drew nothing in first
person. The fx pipeline staged the bolt correctly — then died unfired:
the server sends `events` and `state` back to back, the state message
re-derives `me`, and the fx effect tracked it, so Svelte ran the
cleanup — which cleared every pending timer and stripped in-flight
sprites — before the bolt's zero-delay start could fire. Every live
cast effect in the pane died this way since the pane was born.

The effect now tracks only the batch; everything else reads under
untrack, and timers are cleared only when the component goes down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-27 19:15:55 -04:00
co-authored by Claude Fable 5
parent 773f0a7d21
commit 825a73b154
+21 -11
View File
@@ -224,25 +224,35 @@
let actorPos = $state<Record<string, { x: number; y: number }>>({});
// --- The fx: each batch plays once, on its own beat. -----------------
// The effect must track ONLY the batch: the server sends `events` then
// `state` back to back, so anything else in the dependency set (me, the
// view) re-runs this before a zero-delay timer can fire — and an eager
// cleanup would cancel every spell before it left the wand. Timers die
// only with the component.
let playedBatch = 0;
const fxTimers = new Set<ReturnType<typeof setTimeout>>();
$effect(() => {
const b = batch;
if (!b || b.n === playedBatch || !me) return;
if (!b) return;
untrack(() => {
if (b.n === playedBatch || !me) return;
playedBatch = b.n;
const timers: ReturnType<typeof setTimeout>[] = [];
const started: number[] = [];
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
timers.push(setTimeout(() => {
started.push(fx.id);
const starter = setTimeout(() => {
fxTimers.delete(starter);
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
}, delay));
const ender = setTimeout(() => {
fxTimers.delete(ender);
fpFx = fpFx.filter((f) => f.id !== fx.id);
}, fx.dur + 80);
fxTimers.add(ender);
}, delay);
fxTimers.add(starter);
}
return () => {
timers.forEach(clearTimeout);
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id)));
};
});
return undefined;
});
$effect(() => () => fxTimers.forEach(clearTimeout));
// --- Other bodies glide between views. -------------------------------
let prevView: GameView | null = null;