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 = []; };
});
/** 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,
* 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[]>([]);
$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;
const plan = camPlan;
if (plan?.idx !== i) return; // the camera has not set this step's slate yet
const timers: ReturnType<typeof setTimeout>[] = [];
const started: number[] = [];
for (const { fx, delay } of fpFxForEvents(st.events, st.view, povId)) {
@@ -130,7 +140,7 @@
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 / speed));
}, plan.holdMs + delay / speed));
}
return () => {
timers.forEach(clearTimeout);
@@ -267,8 +277,8 @@
if (sight.warped || sight.dist < toAim - 0.4) {
// The deed lands beyond these eyes — a summon across the maze, a
// spell through walls. Cut away to the spot like a broadcast
// camera: stand back down its deepest corridor, facing it, for
// this one beat; the next step cuts home to the wizard.
// camera: stand back down its deepest corridor, facing it, and
// hold a beat of the world-before so the deed happens ON screen.
let best = { d: -1, a: 0 };
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const h = castRay(v, ax, ay, a);
@@ -279,6 +289,14 @@
cam.y = ay + Math.sin(best.a) * back;
cam.facing = best.a + Math.PI;
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;
}
targetFacing = Math.atan2(ay - ty, ax - tx);
@@ -333,16 +351,34 @@
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) {
// 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;
camReady = true;
return;
return setSlate(wasReady && aim ? 380 / speed : 0);
}
const fromX = camX, fromY = camY, fromF = camF;
const arc = shortestArc(fromF, targetFacing);
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
const clearSlate = setSlate(aimed && aim ? turnMs + 120 / speed : 0);
const t0 = performance.now();
let raf = 0;
const tick = (now: number) => {
@@ -366,7 +402,10 @@
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
return () => {
cancelAnimationFrame(raf);
clearSlate();
};
});
// --- Save a video: the first-person view composited into a shareable
@@ -472,7 +511,7 @@
</header>
<div class="replay-board" bind:this={stageEl}>
{#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}
fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
{:else}