diff --git a/packages/web/src/LiveFirstPerson.svelte b/packages/web/src/LiveFirstPerson.svelte index d55a910..28c0cd9 100644 --- a/packages/web/src/LiveFirstPerson.svelte +++ b/packages/web/src/LiveFirstPerson.svelte @@ -224,25 +224,35 @@ let actorPos = $state>({}); // --- 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>(); $effect(() => { const b = batch; - if (!b || b.n === playedBatch || !me) return; - playedBatch = b.n; - const timers: ReturnType[] = []; - const started: number[] = []; - for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) { - timers.push(setTimeout(() => { - started.push(fx.id); - fpFx = [...fpFx, { ...fx, t0: performance.now() }]; - timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80)); - }, delay)); - } - return () => { - timers.forEach(clearTimeout); - started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id))); - }; + if (!b) return; + untrack(() => { + if (b.n === playedBatch || !me) return; + playedBatch = b.n; + for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) { + const starter = setTimeout(() => { + fxTimers.delete(starter); + fpFx = [...fpFx, { ...fx, t0: performance.now() }]; + 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 undefined; }); + $effect(() => () => fxTimers.forEach(clearTimeout)); // --- Other bodies glide between views. ------------------------------- let prevView: GameView | null = null;