// The directorial eye the reel and the live pane share: given a batch // of events, where should the camera look — and which bodies glide // between one view and the next. One ladder, so the replay and the // table never learn different instincts. import { edgeMid } from "./raycast"; import { fpFxForEvents } from "./fx3d"; import type { GameEvent, GameView } from "@wizwar/engine"; import { castRay } from "./raycast"; export function shortestArc(from: number, to: number): number { let d = (to - from) % (2 * Math.PI); if (d > Math.PI) d -= 2 * Math.PI; if (d < -Math.PI) d += 2 * Math.PI; return d; } export interface Aim { x: number; y: number; /** The deed lands on the wizard's OWN square — a summon at their * feet, invisible from inside their own eyes. Take the cutaway. */ self?: boolean; } /** * Where this batch's deeds should point the pov wizard's eyes: their * own casts, attacks, throws, summons, and edge-deeds first; then, when * the pov is the acting player, wherever the fx layer will visibly play. * Null when nothing asks to be watched. */ export function aimOfEvents( events: GameEvent[], view: GameView, povId: string, myCell: { x: number; y: number }, actor: string, ): Aim | null { for (const e of events) { let cell: { x: number; y: number } | null = null; if (e.type === "creatureCreated" && "controller" in e && e.controller === povId) { cell = (e as { at: { x: number; y: number } }).at; } else if (e.type === "spellCast" && e.caster === povId) { 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 === "objectThrown" && e.attacker === povId) { cell = e.landedAt; } else if ( // Deeds done to an EDGE — a lock picked or jammed, a door // unlocked, a wall raised, razed, or drowned — turn the eyes // to the edge's midpoint just the same. "edge" in e && typeof e.edge === "object" && e.edge !== null && "cell" in e.edge && (("caster" in e && e.caster === povId) || ("player" in e && e.player === povId)) ) { const edge = e.edge as { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" }; const m = edgeMid(edge.cell, edge.side); // edgeMid sits ON the boundary; step half a texel toward the // cell so the same-cell check below behaves. if (Math.abs(m.x - (myCell.x + 0.5)) > 0.3 || Math.abs(m.y - (myCell.y + 0.5)) > 0.3) { return { x: m.x - 0.5, y: m.y - 0.5 }; } continue; } if (cell && (cell.x !== myCell.x || cell.y !== myCell.y)) return cell; if (cell) return { ...cell, self: true }; } // Last resort, only for the pov wizard's own step: wherever the fx // layer is about to play something visible — an impact, a // projectile's landing — the eyes should already be. if (actor === povId) { for (const e of events) { if (e.type === "creatureMoved" && "by" in e && e.by === povId) { const to = (e as { to: { x: number; y: number } }).to; if (to.x !== myCell.x || to.y !== myCell.y) return to; } if ( (e.type === "treasurePickedUp" || e.type === "treasureDropped" || e.type === "objectDropped") && "player" in e && (e as { player: string }).player === povId ) { const at = (e as { at: { x: number; y: number } }).at; return { ...at, self: at.x === myCell.x && at.y === myCell.y }; } } for (const { fx } of fpFxForEvents(events, view, povId)) { // A surging veil is watched from the mouth's own cell — through // the warp, if that is where the eyes just came from. const spot = fx.kind === "impact" ? fx.at : fx.kind === "projectile" ? fx.to : fx.kind === "surge" ? { x: view.board.warps[fx.warp]!.from.cell.x + 0.5, y: view.board.warps[fx.warp]!.from.cell.y + 0.5 } : null; if (!spot) continue; const cx = Math.floor(spot.x), cy = Math.floor(spot.y); if (cx === myCell.x && cy === myCell.y) return { x: cx, y: cy, self: true }; return { x: cx, y: cy }; } } return null; } /** Does this batch hurl the pov wizard bodily — a blow to glide with, * eyes held steady, rather than a stride or a cut? */ export function hurledIn(events: GameEvent[], povId: string): boolean { return events.some((e) => (e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" || e.type === "retreatedInHorror") && e.player === povId); } export interface Glide { id: string; fx: number; fy: number; tx: number; ty: number; } /** Bodies that walked between two views: each glides rather than * blinking cell to cell. A leap across the maze is a teleport and * simply arrives. */ export function gatherGlides(before: GameView, after: GameView, povId: string): Glide[] { const moves: Glide[] = []; const gather = ( id: string, now: { x: number; y: number }, was: { x: number; y: number } | undefined, ) => { if (!was) return; const d = Math.hypot(now.x - was.x, now.y - was.y); if (d > 0.05 && d <= 3.5) { moves.push({ id, fx: was.x + 0.5, fy: was.y + 0.5, tx: now.x + 0.5, ty: now.y + 0.5 }); } }; for (const p of after.players) { if (!p.alive || p.id === povId) continue; const was = before.players.find((q) => q.id === p.id && q.alive); gather(p.id, p.position, was?.position); } for (const c of after.creatures) { gather(c.id, c.position, before.creatures.find((q) => q.id === c.id)?.position); } return moves; } /** The facing that stares down the longest corridor from a square — the * default view wherever nothing asks to be watched. */ export function deepestFacing(view: GameView, x: number, y: number): number { let deepest = -1, face = 0; for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { const h = castRay(view, x, y, a); if (h.dist > deepest) { deepest = h.dist; face = a; } } return face; } /** Where a cutaway camera may STAND back from a square: rays thread warp * mouths but a body cannot, so a warped ray's ground ends at the mouth. * Returns the richest heading and how far back real floor allows. */ export function cutawayStand(view: GameView, x: number, y: number): { facing: number; depth: number } { let deepest = -1, facing = 0; for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { const h = castRay(view, x, y, a); const d = h.warped && h.warpDist !== undefined ? Math.min(h.dist, h.warpDist) : h.dist; if (d > deepest) { deepest = d; facing = a; } } return { facing, depth: deepest }; }