From cd9ce9fddf56d061e64ac37cf9b8d0940c3f0ab9 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 23 Aug 2026 20:07:41 -0400 Subject: [PATCH] Sprites respect the window they are seen through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DUKK's floor-to-ceiling white sliver, decoded: the sprite gate only asked "did this column's ray warp?" — so a REAL body standing in front of a warp mouth was clipped out of the mouth's columns (leaving a baffling sliver of a huge close sprite), and a body virtualized through warp A could paint columns bent by warp B. Every column now remembers WHICH warp bent its ray and how far away that mouth stood; a real body paints a column only nearer than its mouth (in front of the window), and a virtual one only through its own warp's columns, beyond the mouth. Projectile legs carry the same identity. Co-Authored-By: Claude Fable 5 --- packages/web/src/fpv/FirstPerson.svelte | 22 ++++++++++++++----- packages/web/src/fpv/fx3d.ts | 13 ++++++------ packages/web/src/fpv/raycast.ts | 28 ++++++++++++++++++------- 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 3e43fab..9c6d2c8 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -238,7 +238,12 @@ // side must MATCH its column's, or bodies near a far mouth would // ghost into real corridors the unrolled space happens to overlap. const zbuf = new Float64Array(W); - const warpedCol = new Uint8Array(W); + // Per column: which warp (if any) the ray bent through, and how far + // away that mouth stood. A REAL body paints a column only if it is + // nearer than the mouth (it stands in front of the window); a + // VIRTUAL one only through its OWN warp's columns, beyond the mouth. + const warpIdCol = new Int32Array(W).fill(-1); + const warpDistCol = new Float64Array(W).fill(Infinity); // 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. @@ -250,7 +255,10 @@ const hit = castRay(view, ex, ey, rayAngle, doors); const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye zbuf[col] = depth; - warpedCol[col] = hit.warped ? 1 : 0; + if (hit.warpId !== undefined) { + warpIdCol[col] = hit.warpId; + warpDistCol[col] = (hit.warpDist ?? 0) * Math.cos(rayAngle - facing); + } if (hit.ghost) { ghosts.push({ col, depth: hit.ghost.dist * Math.cos(rayAngle - facing), @@ -341,6 +349,7 @@ scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p, rise: f.kind === "impact" ? (f.rise ?? 0.3) : 0.3, warped: f.kind === "projectile" ? f.warped : undefined, + warpId: f.kind === "projectile" ? f.warpId : undefined, }, ex, ey); if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art }); } @@ -359,7 +368,9 @@ if (s.alpha !== undefined || s.glow) ctx.globalAlpha = s.alpha ?? 1; for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) { if (s.depth >= zbuf[col]!) continue; - if ((s.warped ? 1 : 0) !== warpedCol[col]!) continue; + if (s.warped) { + if (warpIdCol[col] !== (s.warpId ?? -2) || s.depth <= warpDistCol[col]!) continue; + } else if (s.depth >= warpDistCol[col]!) continue; const texX = ((col - s.left) / (s.right - s.left)); if (img) { ctx.drawImage( @@ -437,6 +448,7 @@ top: number; bottom: number; warped?: boolean; + warpId?: number; glow?: boolean; alpha?: number; fallback?: string; @@ -444,7 +456,7 @@ function project( b: { x: number; y: number; src: string; scale: number; rise: number; aspect?: number; alpha?: number; glow?: boolean; bias?: number; - fallback?: string; warped?: boolean }, + fallback?: string; warped?: boolean; warpId?: number }, ex: number, ey: number, ): Projected | null { const relX = b.x - ex, relY = b.y - ey; @@ -464,7 +476,7 @@ : size; const bottom = half + wallH / 2 - b.rise * wallH; return { - src: b.src, depth: depth - (b.bias ?? 0), warped: b.warped, + src: b.src, depth: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId, alpha: b.alpha, glow: b.glow, fallback: b.fallback, left: screenX - wide / 2, right: screenX + wide / 2, top: bottom - size, bottom, diff --git a/packages/web/src/fpv/fx3d.ts b/packages/web/src/fpv/fx3d.ts index 27b3392..f36c139 100644 --- a/packages/web/src/fpv/fx3d.ts +++ b/packages/web/src/fpv/fx3d.ts @@ -13,8 +13,8 @@ import type { GameEvent, GameView } from "@wizwar/engine"; export type FpFx = | { id: number; kind: "projectile"; art: string; from: { x: number; y: number }; to: { x: number; y: number }; t0: number; dur: number; /** This leg flies in a warp mouth's virtual space: visible only - * through the opening, on columns whose rays bent. */ - warped?: boolean } + * through THAT warp's opening, beyond its mouth. */ + warped?: boolean; warpId?: number } | { id: number; kind: "impact"; art: string; at: { x: number; y: number }; t0: number; dur: number; /** Lifted off the floor (fireworks burst overhead); default 0.3. */ rise?: number } @@ -62,19 +62,20 @@ const IMPACT_ART: Record = { * clipped by the depth buffer to the side the camera stands on. */ function projectileLegs( view: GameView, a: { x: number; y: number }, b: { x: number; y: number }, -): { from: { x: number; y: number }; to: { x: number; y: number }; warped?: boolean }[] { +): { from: { x: number; y: number }; to: { x: number; y: number }; warped?: boolean; warpId?: number }[] { const len = Math.hypot(b.x - a.x, b.y - a.y); if (len < 0.05) return [{ from: a, to: b }]; const direct = castRay(view, a.x, a.y, Math.atan2(b.y - a.y, b.x - a.x)); if (!direct.warped && direct.dist >= len - 0.4) return [{ from: a, to: b }]; - for (const w of view.board.warps) { + for (let wi = 0; wi < view.board.warps.length; wi++) { + const w = view.board.warps[wi]!; const motion = warpMotion(w); const vt = motion.toVirtual(b); const vlen = Math.hypot(vt.x - a.x, vt.y - a.y); const hit = castRay(view, a.x, a.y, Math.atan2(vt.y - a.y, vt.x - a.x)); if (hit.warped && hit.dist >= vlen - 0.4) { return [ - { from: a, to: vt, warped: true }, + { from: a, to: vt, warped: true, warpId: wi }, { from: motion.toReal(a), to: b }, ]; } @@ -96,7 +97,7 @@ export function fpFxForEvents( out.push({ fx: { id: nextId++, kind: "projectile", art, - from: leg.from, to: leg.to, warped: leg.warped, + from: leg.from, to: leg.to, warped: leg.warped, warpId: leg.warpId, t0: 0, dur: PROJECTILE_DUR[art]!, }, delay, diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts index 3eabb8f..84d74db 100644 --- a/packages/web/src/fpv/raycast.ts +++ b/packages/web/src/fpv/raycast.ts @@ -26,6 +26,11 @@ export interface Hit { edge?: string; /** The eye reached this through a warp: haze it other-worldly. */ warped?: boolean; + /** Which warp (index in board.warps) the ray first bent through, and + * how far along the ray that mouth stood — the gate a sprite must be + * beyond (its own warp) or in front of (any) to paint this column. */ + warpId?: number; + warpDist?: number; /** The first known-illusion edge the ray crossed before its solid hit: * the eye passes, but a translucent ghost of a wall stands there. */ ghost?: { dist: number; u: number; worldU: number; axis: "x" | "y" }; @@ -108,6 +113,8 @@ export function castRay( // a mouth re-enters at the paired mouth and marches on, its hits hazed. let baseDist = 0; let warped = false; + let warpId: number | undefined; + let warpDist: number | undefined; let ghost: Hit["ghost"]; let doorway: Hit["doorway"]; for (let traversal = 0; traversal < 3; traversal++) { @@ -171,7 +178,7 @@ export function castRay( // A sliding door carries its texture with it: sample past the gap. return { dist: baseDist + b.t, kind: b.kind, u: Math.max(0, u - b.slide), - axis: b.axis, worldU: along, edge: b.edge, warped, ghost, doorway, + axis: b.axis, worldU: along, edge: b.edge, warped, warpId, warpDist, ghost, doorway, ...(b.frame ? { frame: true } : {}), }; } @@ -194,10 +201,12 @@ export function castRay( const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })]; if (offBoard) { const side: Side = crossSide; - const warp = view.board.warps.find( + const wIdx = view.board.warps.findIndex( (w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side, ); - if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, ghost, doorway }; + const warp = wIdx >= 0 ? view.board.warps[wIdx]! : undefined; + if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, warpId, warpDist, ghost, doorway }; + if (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; } // Step through: re-enter at the paired mouth, heading inward, the // lane carried by the same proper rotation the heading turns by — // mirrored for the pairings whose axes land head-to-head. @@ -215,14 +224,14 @@ export function castRay( break; } if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") { - return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, ghost, doorway }; + return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, warpId, warpDist, ghost, doorway }; } cx = nx; cy = ny; } if (!warped || traversal === 2) break; } - return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, ghost, doorway }; + return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, ghost, doorway }; } /** Can a wizard's body (not just their eye) cross this edge? Workshop @@ -277,8 +286,10 @@ export interface Billboard { bias?: number; /** Procedural stand-in name while the painted file loads. */ fallback?: string; - /** A reflection seen through a warp mouth, not the thing itself. */ + /** A reflection seen through a warp mouth, not the thing itself — + * visible only through THAT warp's columns, beyond its mouth. */ warped?: boolean; + warpId?: number; } /** The along=0 corner of a warp mouth — the anchor castRay's lane @@ -407,7 +418,8 @@ export function billboards( // the same rigid motion the rays use — the zbuffer clips it to the // opening, since everything around the mouth is nearer wall. const real = out.slice(); - for (const w of view.board.warps) { + for (let wi = 0; wi < view.board.warps.length; wi++) { + const w = view.board.warps[wi]!; const motion = warpMotion(w); const a2 = mouthAnchor(w.to); const inward = SIDE_ANGLE[OPPOSITE[w.to.side]]; @@ -415,7 +427,7 @@ export function billboards( for (const b of real) { const rx = b.x - a2.x, ry = b.y - a2.y; if (rx * inX + ry * inY < -0.2 || Math.hypot(rx, ry) > 8) continue; - out.push({ ...b, ...motion.toVirtual(b), warped: true }); + out.push({ ...b, ...motion.toVirtual(b), warped: true, warpId: wi }); } } return out;