First person comes to the living table (branch work)

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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 23:29:03 -04:00
co-authored by Claude Fable 5
parent 2932d1e500
commit e6ef5f6315
5 changed files with 373 additions and 93 deletions
+13 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { attentionLabel, net, spellName } from "./net.svelte";
import Board from "./Board.svelte";
import LiveFirstPerson from "./LiveFirstPerson.svelte";
import { colorIndexOf, PLAYER_COLORS, wizardColor } from "./colors";
import Faq from "./Faq.svelte";
import Card from "./Card.svelte";
@@ -14,7 +15,7 @@
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { local } from "./local.svelte";
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
import type { CardInstance, Side } from "@wizwar/engine";
import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
net.connect();
net.startGamePolling();
@@ -51,6 +52,8 @@
});
/** Live spell flourishes on the board (cosmetic, self-expiring). */
let boardFx = $state<BoardFx[]>([]);
/** The live pane's feed: each event batch, numbered, with its view. */
let fpBatch = $state<{ n: number; events: GameEvent[]; view: GameView } | null>(null);
let fxCancels: (() => void)[] = [];
/** A trap drawn by YOU earns a modal; buried log lines get missed. */
let trapNotice = $state<string | null>(null);
@@ -126,6 +129,7 @@
wardBite = { owner: e.owner, amount: dmg?.type === "damaged" ? dmg.amount : 0 };
}
}
if (view) fpBatch = { n: (fpBatch?.n ?? 0) + 1, events, view };
if (!prefs.flourishes) return;
fxCancels.push(scheduleFx(
events, view,
@@ -1383,6 +1387,11 @@
onchange={(e) => setPref("flourishes", e.currentTarget.checked)} />
<span>Spell flourishes (the animated effects)</span>
</label>
<label class="pref-row">
<input type="checkbox" checked={prefs.liveFp}
onchange={(e) => setPref("liveFp", e.currentTarget.checked)} />
<span>Through your eyes: a first-person pane above the board during play</span>
</label>
<label class="pref-row">
<input type="checkbox" checked={prefs.instantReplay}
onchange={(e) => setPref("instantReplay", e.currentTarget.checked)} />
@@ -1860,6 +1869,9 @@
{:else if view}
<div class="game">
<section class="board-zone">
{#if prefs.liveFp && view.you && !net.spectating}
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)} />
{/if}
<Board
{view}
edgeSelectMode={edgeSelectMode && yourMoment}
+219
View File
@@ -0,0 +1,219 @@
<script lang="ts">
// The living maze through your own eyes: a pane that rides above the
// board during play. The board below stays the tactical truth — this
// is the experience. The camera carries the reel's instincts: it
// walks your strides, turns to your aims and to actors you can see,
// cuts away to deeds beyond your sight, and never idles nose-to-brick.
// Every live event batch plays through the same fx pipeline the
// instant replay uses, so spells, doors, and conjurations perform
// here first.
import FirstPerson from "./fpv/FirstPerson.svelte";
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
import { castRay } from "./fpv/raycast";
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
import { untrack } from "svelte";
import type { GameEvent, GameView } from "@wizwar/engine";
let { view, batch, onhide }: {
view: GameView;
/** The latest live event batch, numbered so each plays once, with
* the view the server sent alongside it. */
batch: { n: number; events: GameEvent[]; view: GameView } | null;
onhide: () => void;
} = $props();
const povId = $derived(view.you);
const me = $derived(view.players.find((p) => p.id === povId && p.alive) ?? null);
const cam = $state({ x: 0, y: 0, facing: 0 });
let camReady = false;
let cutawayShot = $state(false);
let fpFx = $state<FpFx[]>([]);
let actorPos = $state<Record<string, { x: number; y: number }>>({});
// --- The fx: each batch plays once, on its own beat. -----------------
let playedBatch = 0;
$effect(() => {
const b = batch;
if (!b || b.n === playedBatch || !me) return;
playedBatch = b.n;
const timers: ReturnType<typeof setTimeout>[] = [];
const started: number[] = [];
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
timers.push(setTimeout(() => {
started.push(fx.id);
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
}, delay));
}
return () => {
timers.forEach(clearTimeout);
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id)));
};
});
// --- Other bodies glide between views. -------------------------------
let prevView: GameView | null = null;
$effect(() => {
const v = view;
const before = prevView;
prevView = v;
if (!before || before === v || !me) return;
const moves = gatherGlides(before, v, povId);
if (moves.length === 0) { actorPos = {}; return; }
const t0 = performance.now();
const dur = 400;
let raf = 0;
const tick = (now: number) => {
const w = Math.min(1, Math.max(0, (now - t0) / dur));
const ease = w * w * (3 - 2 * w);
const next: Record<string, { x: number; y: number }> = {};
for (const m of moves) {
next[m.id] = { x: m.fx + (m.tx - m.fx) * ease, y: m.fy + (m.ty - m.fy) * ease };
}
actorPos = next;
if (w < 1) raf = requestAnimationFrame(tick);
else actorPos = {};
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
});
// --- The camera: the director's ladder, live. ------------------------
let directedBatch = 0;
$effect(() => {
const b = batch;
const m = me;
if (!m) { camReady = false; return; }
const v = view;
const tx = m.position.x + 0.5;
const ty = m.position.y + 0.5;
// Untracked camera reads: this effect's own tween writes cam every
// frame, and a tracked read would restart the ease per frame.
const camX = untrack(() => cam.x);
const camY = untrack(() => cam.y);
const camF = untrack(() => cam.facing);
const dx = tx - camX;
const dy = ty - camY;
const dist = Math.hypot(dx, dy);
const events = b && b.n !== directedBatch ? b.events : [];
if (b) directedBatch = b.n;
const hurled = hurledIn(events, povId);
const willCut = !camReady || (dist > 1.6 && !hurled);
cutawayShot = false;
const takeCutaway = (ax: number, ay: number) => {
let best = { d: -1, a: 0 };
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const h = castRay(v, ax, ay, a);
if (h.dist > best.d) best = { d: h.dist, a };
}
const back = Math.min(1.6, Math.max(0.35, best.d - 0.4));
cam.x = ax + Math.cos(best.a) * back;
cam.y = ay + Math.sin(best.a) * back;
cam.facing = best.a + Math.PI;
camReady = true;
cutawayShot = true;
};
const actor = v.activePlayerId;
const aim = aimOfEvents(events, v, povId, m.position, actor);
let targetFacing = camF;
let aimed = false;
if (camReady && !willCut && dist > 0.05 && !hurled) {
targetFacing = Math.atan2(dy, dx);
aimed = true;
} else if (aim?.self) {
return takeCutaway(m.position.x + 0.5, m.position.y + 0.5);
} else if (aim) {
const ax = aim.x + 0.5, ay = aim.y + 0.5;
const toAim = Math.hypot(ax - tx, ay - ty);
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
if (sight.warped || sight.dist < toAim - 0.4) return takeCutaway(ax, ay);
targetFacing = Math.atan2(ay - ty, ax - tx);
aimed = true;
} else {
// Another wizard acting in your sight line turns your head.
const other = actor !== povId ? v.players.find((p) => p.id === actor && p.alive) : null;
if (other && (other.position.x !== m.position.x || other.position.y !== m.position.y)) {
const ax = other.position.x + 0.5, ay = other.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);
aimed = true;
}
}
}
if (!aimed && (willCut || castRay(v, tx, ty, targetFacing).dist < 0.8)) {
// Nothing asks to be watched and the eyes would idle nose-to-brick:
// face the deepest corridor from where the wizard stands.
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 (willCut) {
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
camReady = true;
return;
}
const fromX = camX, fromY = camY, fromF = camF;
const arc = shortestArc(fromF, targetFacing);
const turnMs = hurled ? 0 : Math.min(260, Math.abs(arc) * 180);
const walkMs = dist > 0.05 ? (hurled ? 260 : 380) : 0;
const t0 = performance.now();
let raf = 0;
const tick = (now: number) => {
const t = Math.max(0, now - t0);
if (turnMs > 0 && t < turnMs) {
cam.facing = fromF + arc * (t / turnMs);
} else if (walkMs > 0 && t < turnMs + walkMs) {
cam.facing = fromF + arc;
const w = (t - turnMs) / walkMs;
const ease = w * w * (3 - 2 * w);
cam.x = fromX + (tx - fromX) * ease;
cam.y = fromY + (ty - fromY) * ease;
} else {
cam.facing = fromF + arc;
cam.x = tx; cam.y = ty;
return;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
});
</script>
{#if me}
<div class="live-fp">
<FirstPerson {view} povId={cutawayShot ? "" : povId}
x={cam.x} y={cam.y} facing={cam.facing} width={720} height={280}
fx={fpFx} posOverride={actorPos} />
<button class="live-fp-hide" onclick={onhide} title="hide (re-enable in preferences)"></button>
</div>
{/if}
<style>
.live-fp {
position: relative;
margin-bottom: 0.6rem;
}
.live-fp :global(.fpv-canvas) {
max-height: 34dvh;
object-fit: cover;
}
.live-fp-hide {
position: absolute;
top: 0.4rem;
right: 0.4rem;
background: rgba(13, 12, 18, 0.65);
border: 1px solid #5a5342;
border-radius: 3px;
color: #a49c86;
cursor: pointer;
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
}
.live-fp-hide:hover { color: #e9e1cb; }
</style>
+4 -91
View File
@@ -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.
+133
View File
@@ -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;
}
+4 -1
View File
@@ -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;