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
164 lines
6.6 KiB
TypeScript
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 };
|
|
}
|