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:
co-authored by
Claude Fable 5
parent
2932d1e500
commit
e6ef5f6315
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user