Files
wizwar6e/packages/web/src/fpv/director.ts
T
Eric WagonerandClaude Fable 5.1 a7f77367a6 A warp crossing surges the veil itself, not a billboard over the wizard
Every board effect the cockpit had no art for landed as the same purple
lightning-ring sprite, so a body stepping through a warp mouth wore the
ring over its own portrait. Eric wanted the purple wall to do the
animating. The portal effect now becomes a "surge" keyed by the mouth's
index in board.warps: the veil hung in that opening floods with its
texture, the swirl quickens and pales, and a ripple runs out to the
jambs before it settles back — nothing drawn over whoever stands in it.

The director used to steer the eyes toward that ring's cell on the pov
wizard's own crossing; it now aims at a surging mouth the same way, so
the heist's pinned frames render as before. Illusion and Pass Wall
crossings keep the shimmer sprite — stone has no veil to surge.

Scene gate: 29 of 30 frames unchanged. stone-gaze-s10-board drifts
0.21% with or without this change (the dashed sight line's phase), a
pre-existing flake left unblessed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-02 12:43:28 -04:00

164 lines
6.6 KiB
TypeScript

// 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";
import { castRay } from "./raycast";
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)) {
// A surging veil is watched from the mouth's own cell — through
// the warp, if that is where the eyes just came from.
const spot = fx.kind === "impact" ? fx.at
: fx.kind === "projectile" ? fx.to
: fx.kind === "surge" ? { x: view.board.warps[fx.warp]!.from.cell.x + 0.5, y: view.board.warps[fx.warp]!.from.cell.y + 0.5 }
: 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;
}
/** The facing that stares down the longest corridor from a square — the
* default view wherever nothing asks to be watched. */
export function deepestFacing(view: GameView, x: number, y: number): number {
let deepest = -1, face = 0;
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const h = castRay(view, x, y, a);
if (h.dist > deepest) { deepest = h.dist; face = a; }
}
return face;
}
/** Where a cutaway camera may STAND back from a square: rays thread warp
* mouths but a body cannot, so a warped ray's ground ends at the mouth.
* Returns the richest heading and how far back real floor allows. */
export function cutawayStand(view: GameView, x: number, y: number): { facing: number; depth: number } {
let deepest = -1, facing = 0;
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const h = castRay(view, x, y, a);
const d = h.warped && h.warpDist !== undefined ? Math.min(h.dist, h.warpDist) : h.dist;
if (d > deepest) { deepest = d; facing = a; }
}
return { facing, depth: deepest };
}