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:
co-authored by
Claude Fable 5
parent
9d59ac9ec1
commit
045e868190
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user