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 = []; };
|
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user