diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index bab150f..2658ee3 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -363,10 +363,22 @@ if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art }); } sprites.sort((a, b) => b.sort - a.sort); - // The nearest sprite each column carries: the overlay passes (veils, - // ghosts, lintels, risings) must not paint over a body standing in - // front of them. + // The nearest sprite each column carries — depth AND vertical span — + // so the overlay passes (veils, ghosts, lintels, risings) can paint + // around a body standing in front of them instead of over it. const spriteZ = new Float64Array(W).fill(Infinity); + const sprTop = new Float64Array(W); + const sprBot = new Float64Array(W); + /** The parts of an overlay column NOT hidden behind the column's + * nearest sprite: whole, split around it, or nothing. */ + const maskedSegs = (col: number, depth: number, top: number, h: number): [number, number][] => { + if (depth < spriteZ[col]!) return [[top, h]]; + const segs: [number, number][] = []; + const bottom = top + h; + if (sprTop[col]! > top) segs.push([top, Math.min(h, sprTop[col]! - top)]); + if (sprBot[col]! < bottom) segs.push([sprBot[col]!, bottom - sprBot[col]!]); + return segs; + }; for (const s of sprites) { const img = imageFor(s.src) ?? (s.fallback @@ -385,7 +397,11 @@ if (warpIdCol[col] !== s.warpId || s.depth <= warpDistCol[col]!) continue; } else if (s.depth >= warpDistCol[col]!) continue; if (s.clampL !== undefined && (col < s.clampL || col > s.clampR!)) continue; - if (s.depth < spriteZ[col]!) spriteZ[col] = s.depth; + if (s.depth < spriteZ[col]!) { + spriteZ[col] = s.depth; + sprTop[col] = s.top; + sprBot[col] = s.bottom; + } const texX = ((col - s.left) / (s.right - s.left)); if (img) { ctx.drawImage( @@ -411,65 +427,79 @@ // translucent, swirling — the doorway between rooms that are not // neighbors announces itself. for (const vl of veils) { - if (vl.depth >= spriteZ[vl.col]!) continue; const vh = Math.min(H * 2.5, H / Math.max(vl.depth, 0.05)); const vTop = half - vh / 2; const wt = textures.warp!; - ctx.globalAlpha = 0.3; - ctx.drawImage(wt, Math.min(wt.width - 1, vl.u * wt.width), 0, - Math.max(1, wt.width / 96), wt.height, vl.col, vTop, 1, vh); - ctx.globalAlpha = 1; const swirl = 0.1 + 0.1 * Math.sin(time / 240 + vl.u * 9 + vl.col * 0.02); - ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`; - ctx.fillRect(vl.col, vTop, 1, vh); + for (const [segTop, segH] of maskedSegs(vl.col, vl.depth, vTop, vh)) { + ctx.globalAlpha = 0.3; + 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, + vl.col, segTop, 1, segH); + ctx.globalAlpha = 1; + ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`; + ctx.fillRect(vl.col, segTop, 1, segH); + } } // Conjurations rising: the wall (or stone) grows bottom-up where it // will stand, glowing swirls running over the young face. for (const ri of risings) { - if (ri.depth >= spriteZ[ri.col]!) continue; const full = Math.min(H * 2.5, H / Math.max(ri.depth, 0.05)); - const top0 = half + full / 2 - full * ri.g; + const grown = full * ri.g; + const top0 = half + full / 2 - grown; const tex = textures[ri.kind] ?? textures.wall!; - ctx.globalAlpha = 0.55 + 0.45 * ri.g; - ctx.drawImage(tex, Math.min(tex.width - 1, ri.u * tex.width), 0, - Math.max(1, tex.width / 96), tex.height, ri.col, top0, 1, full * ri.g); - ctx.globalAlpha = 1; const swirl = (0.35 * (1 - ri.g) + 0.08) * (0.7 + 0.3 * Math.sin(time / 90 + ri.worldU * 21)); - ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`; - ctx.fillRect(ri.col, top0, 1, full * ri.g); + for (const [segTop, segH] of maskedSegs(ri.col, ri.depth, top0, grown)) { + ctx.globalAlpha = 0.55 + 0.45 * ri.g; + ctx.drawImage(tex, Math.min(tex.width - 1, ri.u * tex.width), + ((segTop - top0) / grown) * tex.height, + Math.max(1, tex.width / 96), (segH / grown) * tex.height, + ri.col, segTop, 1, segH); + ctx.globalAlpha = 1; + ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`; + ctx.fillRect(ri.col, segTop, 1, segH); + } } // Doorway lintels: the frame's top rows hung across each opening. for (const li of lintels) { - if (li.depth >= spriteZ[li.col]!) continue; const lh = Math.min(H * 2.5, H / Math.max(li.depth, 0.05)); const lTop = half - lh / 2; + const band = lh * 0.16; const ft = textures.doorframe!; - ctx.drawImage(ft, Math.min(ft.width - 1, li.u * ft.width), 0, - Math.max(1, ft.width / 96), ft.height * 0.16, li.col, lTop, 1, lh * 0.16); const dark = 1 - Math.min(1, 1.35 / (1 + li.depth * 0.45)); - if (dark > 0.02) { - ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`; - ctx.fillRect(li.col, lTop, 1, lh * 0.16); + for (const [segTop, segH] of maskedSegs(li.col, li.depth, lTop, band)) { + ctx.drawImage(ft, Math.min(ft.width - 1, li.u * ft.width), + ((segTop - lTop) / band) * ft.height * 0.16, + Math.max(1, ft.width / 96), (segH / band) * ft.height * 0.16, + li.col, segTop, 1, segH); + if (dark > 0.02) { + ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`; + ctx.fillRect(li.col, segTop, 1, segH); + } } } // The ghosts of walls you know are lies: drawn over everything at // their columns, thin as breath, rippling. for (const gh of ghosts) { - if (gh.depth >= spriteZ[gh.col]!) continue; const gHft = Math.min(H * 2.5, H / Math.max(gh.depth, 0.05)); const gTop = half - gHft / 2; const tex = textures.wall!; - ctx.globalAlpha = 0.2; - ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width), 0, - Math.max(1, tex.width / 96), tex.height, gh.col, gTop, 1, gHft); - ctx.globalAlpha = 1; const ripple = 0.10 + 0.07 * Math.sin(time / 200 + gh.worldU * 11 + gHft * 0.01); - ctx.fillStyle = `rgba(190,160,255,${Math.max(0, ripple)})`; - ctx.fillRect(gh.col, gTop, 1, gHft); + for (const [segTop, segH] of maskedSegs(gh.col, gh.depth, gTop, gHft)) { + ctx.globalAlpha = 0.2; + ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width), + ((segTop - gTop) / gHft) * tex.height, + Math.max(1, tex.width / 96), (segH / gHft) * tex.height, + gh.col, segTop, 1, segH); + ctx.globalAlpha = 1; + ctx.fillStyle = `rgba(190,160,255,${Math.max(0, ripple)})`; + ctx.fillRect(gh.col, segTop, 1, segH); + } } // A whisper of vignette holds the torchlit mood together.