diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index 72e6aaa..a632490 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -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(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([]); $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[] = []; 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 @@
{#if fp} - {:else}