diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 75b78a7..4ed47e3 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1,6 +1,7 @@ + +{#if me} +
+ + +
+{/if} + + diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index 757c459..8e48dda 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -7,6 +7,7 @@ import { scheduleFx, type BoardFx } from "./fx"; import { fpFxForEvents, type FpFx } from "./fpv/fx3d"; import { castRay, edgeMid } from "./fpv/raycast"; + import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director"; import { prefs } from "./prefs.svelte"; import { stackSightTrace } from "@wizwar/engine"; import type { GameEvent, GameView } from "@wizwar/engine"; @@ -187,28 +188,7 @@ const before = prevView; prevView = v; if (!before || before === v) return; - const moves: { id: string; fx: number; fy: number; tx: number; ty: number }[] = []; - 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); - // A single stride or shove glides; a leap across the maze is a - // teleport and should simply be there. - 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 v.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 v.creatures) { - gather(c.id, c.position, before.creatures.find((q) => q.id === c.id)?.position); - } + const moves = gatherGlides(before, v, povId); if (moves.length === 0) { actorPos = {}; return; } const t0 = performance.now(); const dur = 400 / speed; @@ -234,12 +214,6 @@ // still, the eye turns toward whoever acted. const cam = $state({ x: 0, y: 0, facing: 0 }); let camReady = false; - 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; - } $effect(() => { if (!fp) { camReady = false; return; } const v = step.view; @@ -261,72 +235,11 @@ // actor, when someone else moved the world; wherever it was, otherwise. // A blow that hurls the body glides fast and straight, however far, // eyes still where they were; only unexplained leaps (teleports) cut. - const hurled = step.events.some((e) => - (e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" || - e.type === "retreatedInHorror") && e.player === povId); + const hurled = hurledIn(step.events, 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 === "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 ?? 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; - } 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 - (me.position.x + 0.5)) > 0.3 || Math.abs(m.y - (me.position.y + 0.5)) > 0.3) { - return { x: m.x - 0.5, y: m.y - 0.5 }; - } - continue; - } - if (cell && (cell.x !== me.position.x || cell.y !== me.position.y)) return cell; - // The deed lands on the wizard's OWN square — a summon at their - // feet, invisible from inside their own eyes. Mark it for the - // cutaway: step outside yourself and watch. - if (cell) return { ...cell, self: true }; - } - // Last resort, only for the reel's own wizard's step: wherever the - // fx layer is about to play something visible — an impact, a - // projectile's landing — the eyes should already be. - if (step.actor === povId) { - for (const e of step.events) { - if (e.type === "creatureMoved" && "by" in e && e.by === povId) { - const to = (e as { to: { x: number; y: number } }).to; - if (to.x !== me.position.x || to.y !== me.position.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 === me.position.x && at.y === me.position.y }; - } - } - for (const { fx } of fpFxForEvents(step.events, v, povId)) { - const spot = fx.kind === "impact" ? fx.at : fx.kind === "projectile" ? fx.to : null; - if (!spot) continue; - const cx = Math.floor(spot.x), cy = Math.floor(spot.y); - if (cx === me.position.x && cy === me.position.y) return { x: cx, y: cy, self: true }; - return { x: cx, y: cy }; - } - } - return null; - })() as ({ x: number; y: number; self?: boolean } | null); + const aim = aimOfEvents(step.events, v, povId, me.position, step.actor); // A leap the legs cannot explain (first frame, teleport, the return // from a cutaway) is a CUT, and a cut is not a stride: the direction // of travel means nothing at the far end. diff --git a/packages/web/src/fpv/director.ts b/packages/web/src/fpv/director.ts new file mode 100644 index 0000000..283c345 --- /dev/null +++ b/packages/web/src/fpv/director.ts @@ -0,0 +1,133 @@ +// 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"; + +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)) { + const spot = fx.kind === "impact" ? fx.at : fx.kind === "projectile" ? fx.to : 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; +} diff --git a/packages/web/src/prefs.svelte.ts b/packages/web/src/prefs.svelte.ts index dab42e3..e267f97 100644 --- a/packages/web/src/prefs.svelte.ts +++ b/packages/web/src/prefs.svelte.ts @@ -20,6 +20,8 @@ export interface Prefs { botTier: "apprentice" | "adept" | "archmage"; /** Instant replay: notable chronicle lines offer a first-person reel. */ instantReplay: boolean; + /** The live first-person pane above the board during play. */ + liveFp: boolean; } const KEY = "wizwar-prefs"; @@ -27,7 +29,7 @@ const KEY = "wizwar-prefs"; function load(): Prefs { const fallback: Prefs = { art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null, - cautions: true, botTier: "adept", instantReplay: true, + cautions: true, botTier: "adept", instantReplay: true, liveFp: true, }; try { const raw = localStorage.getItem(KEY); @@ -42,6 +44,7 @@ function load(): Prefs { cautions: p.cautions !== false, botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept", instantReplay: p.instantReplay !== false, + liveFp: p.liveFp !== false, }; } catch { return fallback;