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
+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>