From e6ef5f6315b35c35152814a9c8bfbd3ec4c448e4 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 23 Aug 2026 23:29:03 -0400 Subject: [PATCH] First person comes to the living table (branch work) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pane above the board plays the game through your own eyes while you play it — the board below stays the tactical truth. One new module, fpv/director.ts, extracts the reel's directorial instincts (the aim ladder, the hurl test, the glide gatherer) so the replay and the live pane share one eye and can never drift; Replay is refactored onto it unchanged. LiveFirstPerson.svelte feeds the shared FirstPerson renderer from the live event stream: every batch plays through the same fx pipeline as the instant replay — projectiles, door swings, conjurations growing — while the camera walks your strides, turns to your aims and to actors in your sight, cuts away (with your own body in frame) to deeds beyond it, and never idles nose-to-brick. Bodies glide between server views. A ✕ hides the pane; the liveFp preference (on by default) brings it back. Co-Authored-By: Claude Fable 5 --- packages/web/src/App.svelte | 14 +- packages/web/src/LiveFirstPerson.svelte | 219 ++++++++++++++++++++++++ packages/web/src/Replay.svelte | 95 +--------- packages/web/src/fpv/director.ts | 133 ++++++++++++++ packages/web/src/prefs.svelte.ts | 5 +- 5 files changed, 373 insertions(+), 93 deletions(-) create mode 100644 packages/web/src/LiveFirstPerson.svelte create mode 100644 packages/web/src/fpv/director.ts 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;