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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user