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
This commit is contained in:
Eric Wagoner
2026-09-02 12:43:28 -04:00
co-authored by Claude Fable 5.1
parent 8488e9a8d1
commit a7f77367a6
3 changed files with 56 additions and 12 deletions
+23 -7
View File
@@ -5,7 +5,7 @@
// by the same depth buffer the walls wrote. // by the same depth buffer the walls wrote.
import { billboards, castRay, warpMotion, type FpvTarget } from "./raycast"; import { billboards, castRay, warpMotion, type FpvTarget } from "./raycast";
import { materialTextures } from "./textures"; import { materialTextures } from "./textures";
import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d"; import { doorOpenness, fxFallback, growProgress, surgeIntensity, type FpFx } from "./fx3d";
import { terrainFallback, TERRAIN3D } from "./terrain3d"; import { terrainFallback, TERRAIN3D } from "./terrain3d";
import { tokenArt } from "../art"; import { tokenArt } from "../art";
import { PLAYER_COLORS } from "../colors"; import { PLAYER_COLORS } from "../colors";
@@ -201,14 +201,19 @@
ey += f.mag * (1 - p) * Math.cos(time * 0.087); ey += f.mag * (1 - p) * Math.cos(time * 0.087);
} }
// Doors mid-swing and conjurations mid-growth this frame. // Doors mid-swing, conjurations mid-growth, and warp veils
// mid-surge this frame.
let doors: Record<string, number> | undefined; let doors: Record<string, number> | undefined;
let growing: Record<string, number> | undefined; let growing: Record<string, number> | undefined;
const spawn: Record<string, number> = {}; const spawn: Record<string, number> = {};
const surging: Record<number, number> = {};
for (const f of fx) { for (const f of fx) {
if (f.kind === "door") { if (f.kind === "door") {
const a = doorOpenness((time - f.t0) / f.dur); const a = doorOpenness((time - f.t0) / f.dur);
if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a); if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a);
} else if (f.kind === "surge") {
const s = surgeIntensity((time - f.t0) / f.dur);
if (s > 0) surging[f.warp] = Math.max(surging[f.warp] ?? 0, s);
} else if (f.kind === "grow") { } else if (f.kind === "grow") {
const p = (time - f.t0) / f.dur; const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue; if (p < 0 || p >= 1) continue;
@@ -335,7 +340,7 @@
const warpDistCol = new Float64Array(W).fill(Infinity); const warpDistCol = new Float64Array(W).fill(Infinity);
// Each mouth a ray passed through hangs a translucent veil of the // Each mouth a ray passed through hangs a translucent veil of the
// warp texture at its own depth, drawn with the other overlays. // warp texture at its own depth, drawn with the other overlays.
const veils: { col: number; depth: number; u: number }[] = []; const veils: { col: number; depth: number; u: number; warp: number }[] = [];
// Known illusions: the ray passes, but a translucent ghost of a wall // Known illusions: the ray passes, but a translucent ghost of a wall
// stands at the crossing — drawn after the sprites so bodies show // stands at the crossing — drawn after the sprites so bodies show
// through it, shimmering so nobody mistakes it for stone. // through it, shimmering so nobody mistakes it for stone.
@@ -352,7 +357,7 @@
if (hit.warpId !== undefined) { if (hit.warpId !== undefined) {
warpIdCol[col] = hit.warpId; warpIdCol[col] = hit.warpId;
warpDistCol[col] = (hit.warpDist ?? 0) * Math.cos(rayAngle - facing); warpDistCol[col] = (hit.warpDist ?? 0) * Math.cos(rayAngle - facing);
veils.push({ col, depth: warpDistCol[col]!, u: hit.warpU ?? 0 }); veils.push({ col, depth: warpDistCol[col]!, u: hit.warpU ?? 0, warp: hit.warpId });
} }
if (hit.ghost) { if (hit.ghost) {
ghosts.push({ ghosts.push({
@@ -598,14 +603,21 @@
// Warp mouths wear their veil: the warp texture at the opening, // Warp mouths wear their veil: the warp texture at the opening,
// translucent, swirling — the doorway between rooms that are not // translucent, swirling — the doorway between rooms that are not
// neighbors announces itself. // neighbors announces itself. A mouth something just passed through
// surges: the texture floods in, the swirl quickens and pales, and
// a bright ripple runs out from the crossing point to the jambs.
for (const vl of veils) { for (const vl of veils) {
const vh = Math.min(H * 2.5, H / Math.max(vl.depth, 0.05)); const vh = Math.min(H * 2.5, H / Math.max(vl.depth, 0.05));
const vTop = half - vh / 2; const vTop = half - vh / 2;
const wt = textures.warp!; const wt = textures.warp!;
const swirl = 0.1 + 0.1 * Math.sin(time / 240 + vl.u * 9 + vl.col * 0.02); const s = surging[vl.warp] ?? 0;
const swirl = 0.1 + 0.1 * Math.sin(time / (240 - 170 * s) + vl.u * 9 + vl.col * 0.02);
// The ripple: a ring of light widening from the veil's center over
// the surge, one full sweep to the edges; ~a fifth of the veil wide.
const ring = s > 0 ? Math.max(0, 1 - Math.abs(Math.abs(vl.u - 0.5) * 2 - (1 - s)) / 0.2) : 0;
const glow = s * (0.45 * ring + 0.22);
for (const [segTop, segH] of maskedSegs(vl.col, vl.depth, vTop, vh)) { for (const [segTop, segH] of maskedSegs(vl.col, vl.depth, vTop, vh)) {
ctx.globalAlpha = 0.3; ctx.globalAlpha = 0.3 + 0.5 * s;
ctx.drawImage(wt, Math.min(wt.width - 1, vl.u * wt.width), ctx.drawImage(wt, Math.min(wt.width - 1, vl.u * wt.width),
((segTop - vTop) / vh) * wt.height, ((segTop - vTop) / vh) * wt.height,
Math.max(1, wt.width / 96), (segH / vh) * wt.height, Math.max(1, wt.width / 96), (segH / vh) * wt.height,
@@ -613,6 +625,10 @@
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`; ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
ctx.fillRect(vl.col, segTop, 1, segH); ctx.fillRect(vl.col, segTop, 1, segH);
if (glow > 0) {
ctx.fillStyle = `rgba(235,220,255,${Math.min(0.85, glow)})`;
ctx.fillRect(vl.col, segTop, 1, segH);
}
} }
} }
+6 -1
View File
@@ -80,7 +80,12 @@ export function aimOfEvents(
} }
} }
for (const { fx } of fpFxForEvents(events, view, povId)) { for (const { fx } of fpFxForEvents(events, view, povId)) {
const spot = fx.kind === "impact" ? fx.at : fx.kind === "projectile" ? fx.to : null; // 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; if (!spot) continue;
const cx = Math.floor(spot.x), cy = Math.floor(spot.y); 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 }; if (cx === myCell.x && cy === myCell.y) return { x: cx, y: cy, self: true };
+27 -4
View File
@@ -21,8 +21,21 @@ export type FpFx =
| { id: number; kind: "flash"; color: string; peak: number; t0: number; dur: number } | { id: number; kind: "flash"; color: string; peak: number; t0: number; dur: number }
| { id: number; kind: "shake"; mag: number; t0: number; dur: number } | { id: number; kind: "shake"; mag: number; t0: number; dur: number }
| { id: number; kind: "door"; edge: string; t0: number; dur: number } | { id: number; kind: "door"; edge: string; t0: number; dur: number }
/** A body passed through a warp mouth (or the mouth just opened): the
* veil hung in that opening surges — the warp's own face animates,
* no billboard over whoever stands in it. */
| { id: number; kind: "surge"; warp: number; t0: number; dur: number }
| { id: number; kind: "grow"; slot: "solid" | "sprite"; key: string; t0: number; dur: number }; | { id: number; kind: "grow"; slot: "solid" | "sprite"; key: string; t0: number; dur: number };
/** How hard a surging veil glows at progress p: a snap to full as the
* body breaks the surface, then a long settle back to the idle swirl. */
export function surgeIntensity(p: number): number {
if (p < 0 || p >= 1) return 0;
if (p < 0.12) return p / 0.12;
const q = (p - 0.12) / 0.88;
return (1 - q) * (1 - q);
}
/** Eased growth 0..1 for a conjuration's rise. */ /** Eased growth 0..1 for a conjuration's rise. */
export function growProgress(p: number): number { export function growProgress(p: number): number {
if (p <= 0) return 0.01; if (p <= 0) return 0.01;
@@ -112,10 +125,20 @@ export function fpFxForEvents(
}); });
} }
} else if (fx.kind === "portal") { } else if (fx.kind === "portal") {
out.push({ // The board ripples the mouth; here the mouth's own veil surges.
fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: fx.cell.x + 0.5, y: fx.cell.y + 0.5 }, t0: 0, dur: 550 }, // Every mouth heads one entry of board.warps, the index the
delay, // raycaster tags its columns with.
}); const warp = view.board.warps.findIndex(
(w) => w.from.cell.x === fx.cell.x && w.from.cell.y === fx.cell.y && w.from.side === fx.side,
);
if (warp >= 0) {
out.push({ fx: { id: nextId++, kind: "surge", warp, t0: 0, dur: 900 }, delay });
} else {
out.push({
fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: fx.cell.x + 0.5, y: fx.cell.y + 0.5 }, t0: 0, dur: 550 },
delay,
});
}
} else if ("at" in fx && IMPACT_ART[fx.kind]) { } else if ("at" in fx && IMPACT_ART[fx.kind]) {
out.push({ out.push({
fx: { id: nextId++, kind: "impact", art: IMPACT_ART[fx.kind]!, at: { x: fx.at.x + 0.5, y: fx.at.y + 0.5 }, t0: 0, dur: 550 }, fx: { id: nextId++, kind: "impact", art: IMPACT_ART[fx.kind]!, at: { x: fx.at.x + 0.5, y: fx.at.y + 0.5 }, t0: 0, dur: 550 },