Sprites respect the window they are seen through

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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 20:07:41 -04:00
co-authored by Claude Fable 5
parent 045e868190
commit cd9ce9fddf
3 changed files with 44 additions and 19 deletions
+17 -5
View File
@@ -238,7 +238,12 @@
// side must MATCH its column's, or bodies near a far mouth would // side must MATCH its column's, or bodies near a far mouth would
// ghost into real corridors the unrolled space happens to overlap. // ghost into real corridors the unrolled space happens to overlap.
const zbuf = new Float64Array(W); 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 // 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.
@@ -250,7 +255,10 @@
const hit = castRay(view, ex, ey, rayAngle, doors); const hit = castRay(view, ex, ey, rayAngle, doors);
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
zbuf[col] = depth; 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) { if (hit.ghost) {
ghosts.push({ ghosts.push({
col, depth: hit.ghost.dist * Math.cos(rayAngle - facing), col, depth: hit.ghost.dist * Math.cos(rayAngle - facing),
@@ -341,6 +349,7 @@
scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p, scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p,
rise: f.kind === "impact" ? (f.rise ?? 0.3) : 0.3, rise: f.kind === "impact" ? (f.rise ?? 0.3) : 0.3,
warped: f.kind === "projectile" ? f.warped : undefined, warped: f.kind === "projectile" ? f.warped : undefined,
warpId: f.kind === "projectile" ? f.warpId : undefined,
}, ex, ey); }, ex, ey);
if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art }); 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; 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++) { for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
if (s.depth >= zbuf[col]!) continue; 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)); const texX = ((col - s.left) / (s.right - s.left));
if (img) { if (img) {
ctx.drawImage( ctx.drawImage(
@@ -437,6 +448,7 @@
top: number; top: number;
bottom: number; bottom: number;
warped?: boolean; warped?: boolean;
warpId?: number;
glow?: boolean; glow?: boolean;
alpha?: number; alpha?: number;
fallback?: string; fallback?: string;
@@ -444,7 +456,7 @@
function project( function project(
b: { x: number; y: number; src: string; scale: number; rise: number; b: { x: number; y: number; src: string; scale: number; rise: number;
aspect?: number; alpha?: number; glow?: boolean; bias?: number; aspect?: number; alpha?: number; glow?: boolean; bias?: number;
fallback?: string; warped?: boolean }, fallback?: string; warped?: boolean; warpId?: number },
ex: number, ey: number, ex: number, ey: number,
): Projected | null { ): Projected | null {
const relX = b.x - ex, relY = b.y - ey; const relX = b.x - ex, relY = b.y - ey;
@@ -464,7 +476,7 @@
: size; : size;
const bottom = half + wallH / 2 - b.rise * wallH; const bottom = half + wallH / 2 - b.rise * wallH;
return { 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, alpha: b.alpha, glow: b.glow, fallback: b.fallback,
left: screenX - wide / 2, right: screenX + wide / 2, left: screenX - wide / 2, right: screenX + wide / 2,
top: bottom - size, bottom, top: bottom - size, bottom,
+7 -6
View File
@@ -13,8 +13,8 @@ import type { GameEvent, GameView } from "@wizwar/engine";
export type FpFx = export type FpFx =
| { id: number; kind: "projectile"; art: string; from: { x: number; y: number }; to: { x: number; y: number }; t0: number; dur: number; | { 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 /** This leg flies in a warp mouth's virtual space: visible only
* through the opening, on columns whose rays bent. */ * through THAT warp's opening, beyond its mouth. */
warped?: boolean } warped?: boolean; warpId?: number }
| { id: number; kind: "impact"; art: string; at: { x: number; y: number }; t0: number; dur: 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. */ /** Lifted off the floor (fireworks burst overhead); default 0.3. */
rise?: number } rise?: number }
@@ -62,19 +62,20 @@ const IMPACT_ART: Record<string, string> = {
* clipped by the depth buffer to the side the camera stands on. */ * clipped by the depth buffer to the side the camera stands on. */
function projectileLegs( function projectileLegs(
view: GameView, a: { x: number; y: number }, b: { x: number; y: number }, 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); const len = Math.hypot(b.x - a.x, b.y - a.y);
if (len < 0.05) return [{ from: a, to: b }]; 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)); 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 }]; 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 motion = warpMotion(w);
const vt = motion.toVirtual(b); const vt = motion.toVirtual(b);
const vlen = Math.hypot(vt.x - a.x, vt.y - a.y); 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)); 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) { if (hit.warped && hit.dist >= vlen - 0.4) {
return [ return [
{ from: a, to: vt, warped: true }, { from: a, to: vt, warped: true, warpId: wi },
{ from: motion.toReal(a), to: b }, { from: motion.toReal(a), to: b },
]; ];
} }
@@ -96,7 +97,7 @@ export function fpFxForEvents(
out.push({ out.push({
fx: { fx: {
id: nextId++, kind: "projectile", art, 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]!, t0: 0, dur: PROJECTILE_DUR[art]!,
}, },
delay, delay,
+20 -8
View File
@@ -26,6 +26,11 @@ export interface Hit {
edge?: string; edge?: string;
/** The eye reached this through a warp: haze it other-worldly. */ /** The eye reached this through a warp: haze it other-worldly. */
warped?: boolean; 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 first known-illusion edge the ray crossed before its solid hit:
* the eye passes, but a translucent ghost of a wall stands there. */ * the eye passes, but a translucent ghost of a wall stands there. */
ghost?: { dist: number; u: number; worldU: number; axis: "x" | "y" }; 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. // a mouth re-enters at the paired mouth and marches on, its hits hazed.
let baseDist = 0; let baseDist = 0;
let warped = false; let warped = false;
let warpId: number | undefined;
let warpDist: number | undefined;
let ghost: Hit["ghost"]; let ghost: Hit["ghost"];
let doorway: Hit["doorway"]; let doorway: Hit["doorway"];
for (let traversal = 0; traversal < 3; traversal++) { 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. // A sliding door carries its texture with it: sample past the gap.
return { return {
dist: baseDist + b.t, kind: b.kind, u: Math.max(0, u - b.slide), 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 } : {}), ...(b.frame ? { frame: true } : {}),
}; };
} }
@@ -194,10 +201,12 @@ export function castRay(
const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })]; const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })];
if (offBoard) { if (offBoard) {
const side: Side = crossSide; 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, (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 // Step through: re-enter at the paired mouth, heading inward, the
// lane carried by the same proper rotation the heading turns by — // lane carried by the same proper rotation the heading turns by —
// mirrored for the pairings whose axes land head-to-head. // mirrored for the pairings whose axes land head-to-head.
@@ -215,14 +224,14 @@ export function castRay(
break; break;
} }
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") { 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; cx = nx;
cy = ny; cy = ny;
} }
if (!warped || traversal === 2) break; 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 /** Can a wizard's body (not just their eye) cross this edge? Workshop
@@ -277,8 +286,10 @@ export interface Billboard {
bias?: number; bias?: number;
/** Procedural stand-in name while the painted file loads. */ /** Procedural stand-in name while the painted file loads. */
fallback?: string; 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; warped?: boolean;
warpId?: number;
} }
/** The along=0 corner of a warp mouth — the anchor castRay's lane /** 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 // the same rigid motion the rays use — the zbuffer clips it to the
// opening, since everything around the mouth is nearer wall. // opening, since everything around the mouth is nearer wall.
const real = out.slice(); 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 motion = warpMotion(w);
const a2 = mouthAnchor(w.to); const a2 = mouthAnchor(w.to);
const inward = SIDE_ANGLE[OPPOSITE[w.to.side]]; const inward = SIDE_ANGLE[OPPOSITE[w.to.side]];
@@ -415,7 +427,7 @@ export function billboards(
for (const b of real) { for (const b of real) {
const rx = b.x - a2.x, ry = b.y - a2.y; const rx = b.x - a2.x, ry = b.y - a2.y;
if (rx * inX + ry * inY < -0.2 || Math.hypot(rx, ry) > 8) continue; 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; return out;