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;