The outcome waits until the eyes arrive

Eric dispelled an object and it un-rendered off-screen, before the
camera had turned to look. The reel now sequences like a director:
each step the camera writes a slate — how long the PREVIOUS world
stays on screen while the eyes turn toward the recipient — and both
the view swap and the step's effects reveal together when the turn
lands. Cutaways hold a beat of the world-before too, so a thing
destroyed across the maze is SEEN existing, then seen ending. Cuts
that open the reel still reveal at once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 19:56:21 -04:00
co-authored by Claude Fable 5
parent 9d59ac9ec1
commit 045e868190
+48 -9
View File
@@ -117,12 +117,22 @@
return () => { cancel(); boardFx = []; }; return () => { cancel(); boardFx = []; };
}); });
/** The director's slate: the camera effect writes how long this step's
* outcome stays HIDDEN — the old world held on screen — while the eyes
* turn to face the recipient. The fx and the view reveal together. */
let camPlan = $state<{ idx: number; holdMs: number } | null>(null);
let heldView = $state<GameView | null>(null);
/** In first person the same events become projectiles, impacts, /** In first person the same events become projectiles, impacts,
* flashes, and shakes — scheduled on this step's beat. */ * flashes, and shakes — scheduled on this step's beat, after the
* camera's turn has brought the recipient into frame. */
let fpFx = $state<FpFx[]>([]); let fpFx = $state<FpFx[]>([]);
$effect(() => { $effect(() => {
const st = steps[Math.min(idx, steps.length - 1)]; const i = Math.min(idx, steps.length - 1);
const st = steps[i];
if (!fp || !st || !prefs.flourishes) return; if (!fp || !st || !prefs.flourishes) return;
const plan = camPlan;
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>[] = [];
const started: number[] = []; const started: number[] = [];
for (const { fx, delay } of fpFxForEvents(st.events, st.view, povId)) { for (const { fx, delay } of fpFxForEvents(st.events, st.view, povId)) {
@@ -130,7 +140,7 @@
started.push(fx.id); started.push(fx.id);
fpFx = [...fpFx, { ...fx, t0: performance.now() }]; fpFx = [...fpFx, { ...fx, t0: performance.now() }];
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80)); timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
}, delay / speed)); }, plan.holdMs + delay / speed));
} }
return () => { return () => {
timers.forEach(clearTimeout); timers.forEach(clearTimeout);
@@ -267,8 +277,8 @@
if (sight.warped || sight.dist < toAim - 0.4) { if (sight.warped || sight.dist < toAim - 0.4) {
// The deed lands beyond these eyes — a summon across the maze, a // The deed lands beyond these eyes — a summon across the maze, a
// spell through walls. Cut away to the spot like a broadcast // spell through walls. Cut away to the spot like a broadcast
// camera: stand back down its deepest corridor, facing it, for // camera: stand back down its deepest corridor, facing it, and
// this one beat; the next step cuts home to the wizard. // hold a beat of the world-before so the deed happens ON screen.
let best = { d: -1, a: 0 }; let best = { d: -1, a: 0 };
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const h = castRay(v, ax, ay, a); const h = castRay(v, ax, ay, a);
@@ -279,6 +289,14 @@
cam.y = ay + Math.sin(best.a) * back; cam.y = ay + Math.sin(best.a) * back;
cam.facing = best.a + Math.PI; cam.facing = best.a + Math.PI;
camReady = true; camReady = true;
const i2 = Math.min(idx, steps.length - 1);
const pv = i2 > 0 ? steps[i2 - 1]!.view : null;
heldView = pv;
camPlan = { idx: i2, holdMs: pv ? 420 / speed : 0 };
if (pv) {
const t = setTimeout(() => (heldView = null), 420 / speed);
return () => clearTimeout(t);
}
return; return;
} }
targetFacing = Math.atan2(ay - ty, ax - tx); targetFacing = Math.atan2(ay - ty, ax - tx);
@@ -333,16 +351,34 @@
targetFacing = bestA; targetFacing = bestA;
} }
} }
const stepIdx = Math.min(idx, steps.length - 1);
const prevView = stepIdx > 0 ? steps[stepIdx - 1]!.view : null;
/** Hold the OLD world on screen this long, so the outcome lands only
* once the eyes face its recipient: a cutaway gets a beat of the
* "before"; a turn holds until the turn is done. */
const setSlate = (holdMs: number) => {
heldView = holdMs > 60 && prevView ? prevView : null;
camPlan = { idx: stepIdx, holdMs: heldView ? holdMs : 0 };
if (heldView) {
const t = setTimeout(() => (heldView = null), holdMs);
return () => clearTimeout(t);
}
return () => {};
};
if (willCut) { if (willCut) {
// First frame, or a leap the legs cannot explain: cut. // 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
// rolling; the opening frame reveals at once.
const wasReady = camReady;
cam.x = tx; cam.y = ty; cam.facing = targetFacing; cam.x = tx; cam.y = ty; cam.facing = targetFacing;
camReady = true; camReady = true;
return; return setSlate(wasReady && aim ? 380 / speed : 0);
} }
const fromX = camX, fromY = camY, fromF = camF; const fromX = camX, fromY = camY, fromF = camF;
const arc = shortestArc(fromF, targetFacing); const arc = shortestArc(fromF, targetFacing);
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed; const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed; const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
const clearSlate = setSlate(aimed && aim ? turnMs + 120 / speed : 0);
const t0 = performance.now(); const t0 = performance.now();
let raf = 0; let raf = 0;
const tick = (now: number) => { const tick = (now: number) => {
@@ -366,7 +402,10 @@
raf = requestAnimationFrame(tick); raf = requestAnimationFrame(tick);
}; };
raf = requestAnimationFrame(tick); raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf); return () => {
cancelAnimationFrame(raf);
clearSlate();
};
}); });
// --- Save a video: the first-person view composited into a shareable // --- Save a video: the first-person view composited into a shareable
@@ -472,7 +511,7 @@
</header> </header>
<div class="replay-board" bind:this={stageEl}> <div class="replay-board" bind:this={stageEl}>
{#if fp} {#if fp}
<FirstPerson view={step.view} {povId} <FirstPerson view={heldView ?? step.view} {povId}
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360} x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360}
fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} /> fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
{:else} {:else}