diff --git a/deploy/scene-goldens/springing-the-trap-s5-fpv.png b/deploy/scene-goldens/springing-the-trap-s5-fpv.png index e0ba288..c8e04b9 100644 Binary files a/deploy/scene-goldens/springing-the-trap-s5-fpv.png and b/deploy/scene-goldens/springing-the-trap-s5-fpv.png differ diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index 57c59cf..1955b55 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -377,7 +377,17 @@ const GROUNDWORK = new Set(["spellCast", "squareFilled", "creatureCreated", "treasurePickedUp", "treasureDropped", "objectDropped"]); const groundwork = aim?.self && step.events.some((e) => GROUNDWORK.has(e.type)); - if (camReady && !willCut && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; } + /** A stride that ends with something to watch — an ambush that + * fired as the step landed — turns once more after the walk. */ + let afterArc = 0; + if (camReady && !willCut && dist > 0.05 && !hurled) { + targetFacing = Math.atan2(dy, dx); + aimed = true; + if (aim && !aim.self) { + const h = sightline(v, tx, ty, aim.x + 0.5, aim.y + 0.5); + if (h !== null) afterArc = shortestArc(targetFacing, h); + } + } else if (groundwork || underfoot) { targetPitch = LOOK_DOWN; aimed = true; @@ -448,8 +458,8 @@ // The reel's LAST step may be a stride into a corner: the walk keeps // its own facing, then the camera turns once more to a view worth // ending on. - let closingArc = 0; - if (atEnd && aimed && castRay(v, tx, ty, targetFacing).dist < 1.2) { + let closingArc = afterArc; + if (closingArc === 0 && atEnd && aimed && castRay(v, tx, ty, targetFacing).dist < 1.2) { const current = viewScoreAt(tx, ty, targetFacing); let bestA = targetFacing, bestScore = current; for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { @@ -498,8 +508,12 @@ const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed; const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed; const tiltMs = (Math.abs(targetPitch - fromP) > 0.01 ? 340 : 0) / speed; - const clearSlate = setSlate(aimed && (aim || underfoot) ? Math.max(turnMs, tiltMs) + 120 / speed : 0); const closingMs = Math.min(300, Math.abs(closingArc) * 200) / speed; + // The outcome waits until the eyes face it: after the turn (and, + // for a stride that turns at its end, after the walk and that turn). + const clearSlate = setSlate(aimed && (aim || underfoot) + ? Math.max(turnMs, tiltMs) + (afterArc !== 0 ? walkMs + closingMs : 0) + 120 / speed + : 0); const t0 = performance.now() + dieMs; let raf = 0; const tick = (now: number) => { diff --git a/packages/web/src/fpv/director.ts b/packages/web/src/fpv/director.ts index 40a1783..659e166 100644 --- a/packages/web/src/fpv/director.ts +++ b/packages/web/src/fpv/director.ts @@ -41,6 +41,12 @@ export function aimOfEvents( cell = e.targetCell ?? view.players.find((p) => p.id === e.target)?.position ?? null; } else if (e.type === "attackResolved" && e.attacker === povId) { cell = view.players.find((p) => p.id === e.defender)?.position ?? null; + } else if (e.type === "spellCast" && e.target === povId && e.caster !== povId) { + // Magic thrown AT these eyes: they turn to where it came from — + // the ambusher down the corridor, or through the mouth. + cell = view.players.find((p) => p.id === e.caster)?.position ?? null; + } else if (e.type === "punched" && e.target === povId) { + cell = view.players.find((p) => p.id === e.attacker)?.position ?? null; } else if (e.type === "objectThrown" && e.attacker === povId) { cell = e.landedAt; } else if (