diff --git a/src/games/wolfenstein/WolfensteinView.js b/src/games/wolfenstein/WolfensteinView.js index 2696273..3334b78 100644 --- a/src/games/wolfenstein/WolfensteinView.js +++ b/src/games/wolfenstein/WolfensteinView.js @@ -15,8 +15,11 @@ // // Sprites (enemies/pickups/in-flight bullets): pooled billboarded Phaser // Images, positioned via the same camera-space projection math as the walls, -// occluded per-sprite (not per-pixel) against the wall depth buffer — an -// approximation, acceptable for this MVP. +// occluded per-column (not per-pixel) against the wall depth buffer built by +// _drawWalls — cheap enough to stay a single quad per sprite (via +// Image.setCrop, not a redraw-per-column like the walls themselves), while +// still letting a wall corner hide just the part of a sprite behind it +// instead of an all-or-nothing per-sprite test (see _drawSprites). import { castColumns } from './WolfensteinRaycaster.js'; import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js'; @@ -44,6 +47,14 @@ const GUARD_FRAME = { // How long each half of the 2-frame walk cycle (idle/walk pose) holds, ms. const WALK_CYCLE_MS = 300; +// Occlusion tolerance, in world units, for sprite-vs-wall depth comparisons +// (both the initial per-sprite gate and the per-column walk in +// _drawSprites) — without slack, a sprite sitting exactly against a wall +// (its own depth equal to the wall's, e.g. a body that died mid-collision +// against it) would flicker in and out as float error tips the compare +// either way. +const OCCLUSION_EPS = 0.15; + // Death sequence timing, ms: fall pose -> hold on the ground -> fade out. // Angle-independent (no directional fall/ground art), same as the shoot pose. const DEATH_FALL_MS = 350; @@ -244,11 +255,15 @@ export default class WolfensteinView { live.add(s.key); if (s._depth <= 0.05) { this._hideSprite(s.key); continue; } const screenColF = (NUM_COLUMNS / 2) * (1 + s._tx / s._depth); - const col = Math.floor(screenColF); - if (col < 0 || col >= NUM_COLUMNS || s._depth > this.depthBuffer[col] + 0.15) { + const centerCol = Math.floor(screenColF); + if (centerCol < 0 || centerCol >= NUM_COLUMNS || s._depth > this.depthBuffer[centerCol] + OCCLUSION_EPS) { this._hideSprite(s.key); continue; } const spriteSize = Math.abs(VIEW_H / s._depth) * s.scale; + const centerX = screenColF * this.colWidth; + const leftPx = centerX - spriteSize / 2; + const rightPx = centerX + spriteSize / 2; + const img = this._ensureSprite(s.key, s.tex); if (s.guardFrame != null && this.guardTexture) { img.setTexture('wolfenstein-guard-sheet', s.guardFrame); @@ -257,10 +272,43 @@ export default class WolfensteinView { img.setTexture(s.tex); img.setFlipX(false); } - img.setPosition(screenColF * this.colWidth, VIEW_H / 2); + img.setPosition(centerX, VIEW_H / 2); img.setDisplaySize(spriteSize, spriteSize); img.setDepth(10 + Math.max(0, 100 - s._depth)); img.setAlpha(s.alpha ?? 1); + + // A sprite wide enough to span several columns can have a wall corner + // cut across it — the depth-buffer test above only proved its CENTER + // column is unoccluded, so a wide sprite (most visibly a dead guard's + // body, sitting still against a wall for seconds) could otherwise + // render fully on top of a wall that should hide part of it. Walk + // outward from the known-visible center column and stop at the first + // occluded neighbor on each side, then crop the image to just that + // visible span — cheaper than a per-pixel/per-column redraw (unlike + // the wall pass, which already draws per column) while still + // resolving the common single-corner case correctly. + const colStart = Math.max(0, Math.floor(leftPx / this.colWidth)); + const colEnd = Math.min(NUM_COLUMNS - 1, Math.floor((rightPx - 1e-6) / this.colWidth)); + let visLeftCol = centerCol, visRightCol = centerCol; + while (visLeftCol > colStart && s._depth <= this.depthBuffer[visLeftCol - 1] + OCCLUSION_EPS) visLeftCol--; + while (visRightCol < colEnd && s._depth <= this.depthBuffer[visRightCol + 1] + OCCLUSION_EPS) visRightCol++; + + if (visLeftCol === colStart && visRightCol === colEnd) { + img.setCrop(); // fully visible — clear any crop left over from a previous frame + } else { + const visLeftPx = Math.max(leftPx, visLeftCol * this.colWidth); + const visRightPx = Math.min(rightPx, (visRightCol + 1) * this.colWidth); + const fracLeft = Math.max(0, Math.min(1, (visLeftPx - leftPx) / spriteSize)); + const fracRight = Math.max(0, Math.min(1, (visRightPx - leftPx) / spriteSize)); + const fw = img.frame.width, fh = img.frame.height; + // setCrop's x/width are in the frame's own (unflipped) texture + // space — Phaser accounts for setFlipX internally when mapping that + // rect to screen UVs, so a flipped sprite's screen-left visible + // fraction corresponds to the frame's RIGHT edge, not its left. + const cropX = (s.guardFlip ? 1 - fracRight : fracLeft) * fw; + const cropW = Math.max(1, (fracRight - fracLeft) * fw); + img.setCrop(cropX, 0, cropW, fh); + } img.setVisible(true); } for (const [key, img] of this.spritePool) if (!live.has(key)) img.setVisible(false);