From a7f77367a6b2a2a0349ad7ba7926cf63f3e44bb6 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 2 Sep 2026 12:43:28 -0400 Subject: [PATCH] A warp crossing surges the veil itself, not a billboard over the wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- packages/web/src/fpv/FirstPerson.svelte | 30 ++++++++++++++++++------ packages/web/src/fpv/director.ts | 7 +++++- packages/web/src/fpv/fx3d.ts | 31 +++++++++++++++++++++---- 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 2449b3f..892ac0b 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -5,7 +5,7 @@ // by the same depth buffer the walls wrote. import { billboards, castRay, warpMotion, type FpvTarget } from "./raycast"; 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 { tokenArt } from "../art"; import { PLAYER_COLORS } from "../colors"; @@ -201,14 +201,19 @@ 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 | undefined; let growing: Record | undefined; const spawn: Record = {}; + const surging: Record = {}; for (const f of fx) { if (f.kind === "door") { const a = doorOpenness((time - f.t0) / f.dur); 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") { const p = (time - f.t0) / f.dur; if (p < 0 || p >= 1) continue; @@ -335,7 +340,7 @@ const warpDistCol = new Float64Array(W).fill(Infinity); // Each mouth a ray passed through hangs a translucent veil of the // 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 // stands at the crossing — drawn after the sprites so bodies show // through it, shimmering so nobody mistakes it for stone. @@ -352,7 +357,7 @@ if (hit.warpId !== undefined) { warpIdCol[col] = hit.warpId; 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) { ghosts.push({ @@ -598,14 +603,21 @@ // Warp mouths wear their veil: the warp texture at the opening, // 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) { const vh = Math.min(H * 2.5, H / Math.max(vl.depth, 0.05)); const vTop = half - vh / 2; 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)) { - ctx.globalAlpha = 0.3; + ctx.globalAlpha = 0.3 + 0.5 * s; ctx.drawImage(wt, Math.min(wt.width - 1, vl.u * wt.width), ((segTop - vTop) / vh) * wt.height, Math.max(1, wt.width / 96), (segH / vh) * wt.height, @@ -613,6 +625,10 @@ ctx.globalAlpha = 1; ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`; 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); + } } } diff --git a/packages/web/src/fpv/director.ts b/packages/web/src/fpv/director.ts index 33777fd..fe9f2cf 100644 --- a/packages/web/src/fpv/director.ts +++ b/packages/web/src/fpv/director.ts @@ -80,7 +80,12 @@ export function aimOfEvents( } } 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; 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 }; diff --git a/packages/web/src/fpv/fx3d.ts b/packages/web/src/fpv/fx3d.ts index 4217c28..5fb2eb5 100644 --- a/packages/web/src/fpv/fx3d.ts +++ b/packages/web/src/fpv/fx3d.ts @@ -21,8 +21,21 @@ export type FpFx = | { 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: "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 }; +/** 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. */ export function growProgress(p: number): number { if (p <= 0) return 0.01; @@ -112,10 +125,20 @@ export function fpFxForEvents( }); } } else if (fx.kind === "portal") { - 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, - }); + // The board ripples the mouth; here the mouth's own veil surges. + // Every mouth heads one entry of board.warps, the index the + // 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]) { 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 },