diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index d0d72be..401827b 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -202,16 +202,48 @@ (e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" || e.type === "retreatedInHorror") && e.player === povId); const actor = v.players.find((p) => p.id === step.actor); + // Where the eyes' own magic went this step: a wizard looks where + // they aim, so the camera turns to watch its own spells land. + const aim = (() => { + for (const e of step.events) { + let cell: { x: number; y: number } | null = null; + if (e.type === "spellCast" && e.caster === povId) { + cell = e.targetCell ?? v.players.find((p) => p.id === e.target)?.position ?? null; + } else if (e.type === "attackResolved" && e.attacker === povId) { + cell = v.players.find((p) => p.id === e.defender)?.position ?? null; + } else if (e.type === "objectThrown" && e.attacker === povId) { + cell = e.landedAt; + } + if (cell && (cell.x !== me.position.x || cell.y !== me.position.y)) return cell; + } + return null; + })(); let targetFacing = camF; - if (dist > 0.05 && !hurled) targetFacing = Math.atan2(dy, dx); - else if (actor && actor.id !== povId && + let aimed = false; + if (dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; } + else if (aim) { + targetFacing = Math.atan2(aim.y + 0.5 - ty, aim.x + 0.5 - tx); + aimed = true; + } else if (actor && actor.id !== povId && (actor.position.x !== me.position.x || actor.position.y !== me.position.y)) { // Turn toward the actor — but only if these eyes could actually see // them: an unbroken straight sight line, no warps bending it. const ax = actor.position.x + 0.5, ay = actor.position.y + 0.5; const toActor = Math.hypot(ax - tx, ay - ty); const ray = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx)); - if (!ray.warped && ray.dist > toActor - 0.2) targetFacing = Math.atan2(ay - ty, ax - tx); + if (!ray.warped && ray.dist > toActor - 0.2) { + targetFacing = Math.atan2(ay - ty, ax - tx); + aimed = true; + } + } + if (!camReady && !aimed) { + // The reel opens on a wizard standing still: face the longest + // corridor rather than whatever wall the default heading found. + let deepest = -1; + for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { + const h = castRay(v, tx, ty, a); + if (h.dist > deepest) { deepest = h.dist; targetFacing = a; } + } } if (!camReady || (dist > 1.6 && !hurled)) { // First frame, or a leap the legs cannot explain: cut.