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}