// Rendering — the TAWorldView.js role. Owns no simulation state; each frame // it reads WolfensteinLogic's state and turns it into pixels. // // Walls: drawn into a dynamic canvas texture (scene.textures.createCanvas + // .refresh()) rather than a WebGL shader — renderer-agnostic (works under // both Phaser CANVAS and WEBGL, unlike Super Kart's Mode 7 shader), and // conceptually the same canvas-manipulation approach Excitebike and Super // Kart's track pre-rasterizer already use elsewhere in this repo. Per column, // a single source-texel-wide slice of the `wolfenstein-walls` sheet (picked // via the raycaster's `textureX`) is drawImage-stretched to the column's // screen width, then darkened with a 'multiply' composite rect to reproduce // the same side/fog shading flat colors used. Falls back to a flat-shaded // fillRect (the original placeholder look) per wall type whose frame isn't // loaded — lets art land wall-type-by-wall-type instead of all at once. // // Sprites (enemies/pickups/in-flight bullets): pooled billboarded Phaser // Images, positioned via the same camera-space projection math as the walls, // 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'; import { DOOR_WALL_TYPE } from './WolfensteinLogic.js'; export const VIEW_W = 1920; export const VIEW_H = 940; export const NUM_COLUMNS = 480; // wallType -> frame index in the `wolfenstein-walls` sheet, matching // WolfensteinArt.WALL_COLORS' order. No entry for type 9 (door) — doors // sample their own `wolfenstein-doors` sheet (frame 0) via _drawDoorColumn, // which also handles their recessed/sliding geometry — not part of the // generic per-column wall-texture path below at all. const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3 }; // Frame indices in the `wolfenstein-guard-sheet` sheet (128x128 cells, // row-major — see sprites.md). Only the sheet's first row (frames 0-8) is // populated; further columns/rows sit unused until more enemy types land. const GUARD_FRAME = { shoot: 0, frontIdle: 1, frontWalk: 2, sideIdle: 3, sideWalk: 4, backIdle: 5, backWalk: 6, fall: 7, ground: 8, stunned: 9, }; // 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; const DEATH_GROUND_MS = 2000; const DEATH_FADE_MS = 800; // POV weapon viewmodel — sits above the 3D view canvas (depth 10) and the // sprite billboards, below the bottom HUD bar (depth 20, see // WolfensteinGame._buildHud) so it peeks up from behind the ammo bar like a // classic FPS viewmodel, and below the crosshair (depth 15). const WEAPON_DEPTH = 12; // Bob/sway share one phase accumulator that only advances while the player // is moving; WEAPON_BOB_SPEED is radians of that phase per ms. const WEAPON_BOB_SPEED = 0.012; const WEAPON_BOB_AMP_Y = 16; // px, vertical footstep bounce (|sin|, so it's a bounce not a swing) const WEAPON_SWAY_AMP_X = 10; // px, horizontal side-to-side sway const WEAPON_BOB_SMOOTH_MS = 220; // how fast bob strength ramps in/out on start/stop export default class WolfensteinView { constructor(scene, rules) { this.scene = scene; this.rules = rules; ensureSprites(scene); const key = 'wolf-3dview'; if (scene.textures.exists(key)) scene.textures.remove(key); this.canvasTexture = scene.textures.createCanvas(key, VIEW_W, VIEW_H); this.ctx = this.canvasTexture.context; this.ctx.imageSmoothingEnabled = false; this.image = scene.add.image(0, 0, key).setOrigin(0, 0).setDepth(10); this.colWidth = VIEW_W / NUM_COLUMNS; this.depthBuffer = new Array(NUM_COLUMNS).fill(Infinity); this.spritePool = new Map(); // enemy id -> { startMs } for the falling/on-ground/fade-out sequence // (view-only, transient — WolfensteinLogic's enemies just have `dead`, // no death-animation phase, since that's not simulation state). this.deathAnim = new Map(); this.wallTexture = scene.textures.exists('wolfenstein-walls') ? scene.textures.get('wolfenstein-walls') : null; this.wallArtTexture = scene.textures.exists('wolfenstein-wall-art') ? scene.textures.get('wolfenstein-wall-art') : null; this.doorTexture = scene.textures.exists('wolfenstein-doors') ? scene.textures.get('wolfenstein-doors') : null; this.guardTexture = scene.textures.exists('wolfenstein-guard-sheet') ? scene.textures.get('wolfenstein-guard-sheet') : null; // Real weapon_pistol.png is authored at GAME_WIDTH x GAME_HEIGHT // (1920x1080) with the gun already positioned bottom-center against a // transparent background, so it's just placed at the origin. The // procedural fallback is a small bottom-anchored placeholder instead // (see WolfensteinArt.paintWeaponPistol), so the two branches need // different origin/base-position setup. this.hasRealWeaponArt = scene.textures.exists('wolfenstein-weapon-pistol'); this.weaponKey = this.hasRealWeaponArt ? 'wolfenstein-weapon-pistol' : 'wolf-weapon-pistol'; if (this.hasRealWeaponArt) { this.weaponImage = scene.add.image(0, 0, this.weaponKey).setOrigin(0, 0).setDepth(WEAPON_DEPTH).setVisible(false); this._weaponBaseX = 0; this._weaponBaseY = 0; } else { this.weaponImage = scene.add.image(VIEW_W / 2, VIEW_H, this.weaponKey).setOrigin(0.5, 1).setDepth(WEAPON_DEPTH).setVisible(false); this._weaponBaseX = VIEW_W / 2; this._weaponBaseY = VIEW_H; } this._weaponBobPhase = 0; this._weaponBobStrength = 0; this._lastWeaponNow = null; } render(state, camera) { this._drawWalls(state.map, state.doors, camera); this._drawSprites(state, camera); this._drawWeapon(state); } _drawWalls(map, doors, camera) { const ctx = this.ctx; ctx.fillStyle = '#2b2b2b'; ctx.fillRect(0, 0, VIEW_W, VIEW_H / 2); ctx.fillStyle = '#4a4a4a'; ctx.fillRect(0, VIEW_H / 2, VIEW_W, VIEW_H / 2); // x,y -> wall-art frame index. map.wallArt is small, level-authored, // static data — cheap enough to rebuild this lookup every call rather // than caching it across frames. const wallArtByCell = new Map((map.wallArt ?? []).map((w) => [`${w.x},${w.y}`, w.frame])); const cols = castColumns(map, camera, NUM_COLUMNS, doors); for (let i = 0; i < NUM_COLUMNS; i++) { const hit = cols[i]; this.depthBuffer[i] = hit ? hit.perpDist : Infinity; if (!hit) continue; const lineHeight = VIEW_H / hit.perpDist; const rawStart = -lineHeight / 2 + VIEW_H / 2; const rawEnd = lineHeight / 2 + VIEW_H / 2; const drawStart = Math.max(0, rawStart); const drawEnd = Math.min(VIEW_H, rawEnd); if (drawEnd <= drawStart) continue; const x = Math.floor(i * this.colWidth); const w = Math.ceil(this.colWidth) + 1; const y = Math.floor(drawStart); const h = Math.ceil(drawEnd - drawStart); if (hit.wallType === DOOR_WALL_TYPE) { this._drawDoorColumn(ctx, x, y, w, h, hit); continue; } // rawStart/rawEnd (not drawStart/drawEnd) are the true, unclipped // extent of this column — up close that's far taller than the screen. // Crop the source by the same fraction that got clipped off the // destination so the vertical zoom keeps pace with the horizontal one // instead of freezing once the wall overflows VIEW_H. Wall art below // reuses this exact same fraction so a decal lines up pixel-for-pixel // with the wall face beneath it at any distance. const frame = this.wallTexture?.frames[WALL_FRAME[hit.wallType]]; if (frame) { const srcX = frame.cutX + Math.min(frame.width - 1, Math.floor(hit.textureX * frame.width)); const srcY = frame.cutY + ((drawStart - rawStart) / lineHeight) * frame.height; const srcH = ((drawEnd - drawStart) / lineHeight) * frame.height; ctx.drawImage(frame.source.image, srcX, srcY, 1, srcH, x, y, w, h); } else { ctx.fillStyle = `#${(WALL_COLORS[hit.wallType] ?? 0xaaaaaa).toString(16).padStart(6, '0')}`; ctx.fillRect(x, y, w, h); } // Wall art: an optional decal painted on top of the wall's own // texture (a poster/flag, not a different wall type), sampled with // the identical per-column source-texel technique above so it lines // up with the wall face beneath it, drawn BEFORE the shading pass // below so it darkens with distance/side the same as the wall does — // otherwise it'd read as a flat unlit sticker glued onto a dim wall. const artFrameIndex = wallArtByCell.get(`${hit.mapX},${hit.mapY}`); const artFrame = artFrameIndex != null ? this.wallArtTexture?.frames[artFrameIndex] : null; if (artFrame) { const artSrcX = artFrame.cutX + Math.min(artFrame.width - 1, Math.floor(hit.textureX * artFrame.width)); const artSrcY = artFrame.cutY + ((drawStart - rawStart) / lineHeight) * artFrame.height; const artSrcH = ((drawEnd - drawStart) / lineHeight) * artFrame.height; ctx.drawImage(artFrame.source.image, artSrcX, artSrcY, 1, artSrcH, x, y, w, h); } ctx.globalCompositeOperation = 'multiply'; ctx.fillStyle = shadeGrey(hit); ctx.fillRect(x, y, w, h); ctx.globalCompositeOperation = 'source-over'; } this.canvasTexture.refresh(); } /** * By the time a column gets here, the raycaster (see castColumns(..., * doors) above and WolfensteinRaycaster's intersectDoorMidplane) has * already resolved everything door-specific: this hit only exists because * that particular point on the door is still covering the doorway, at its * true recessed mid-plane distance — an already-open point along the same * ray instead passes straight through to whatever's actually beyond * (rendered by the ordinary wall path, not this method at all), which is * how you end up able to see past the door as it slides open. `textureX` * comes back already shifted by `-slide`, so sampling it straight makes * the door texture visibly slide sideways into the wall, not just crop in * place. So this is really just "draw a wall column, but from the * `wolfenstein-doors` sheet" — same technique as the main wall path * (source-texel column, vertical crop for close-up magnification, * multiply-shaded), falling back to the flat placeholder tan if no * sheet is loaded. */ _drawDoorColumn(ctx, x, y, w, h, hit) { const frame = this.doorTexture?.frames[0]; if (!frame) { ctx.fillStyle = shadeColor(WALL_COLORS[DOOR_WALL_TYPE] ?? 0xb08040, hit); ctx.fillRect(x, y, w, h); return; } const lineHeight = VIEW_H / hit.perpDist; const rawStart = -lineHeight / 2 + VIEW_H / 2; const rawEnd = lineHeight / 2 + VIEW_H / 2; const drawStart = Math.max(0, rawStart); const drawEnd = Math.min(VIEW_H, rawEnd); const srcX = frame.cutX + Math.min(frame.width - 1, Math.floor(hit.textureX * frame.width)); const srcY = frame.cutY + ((drawStart - rawStart) / lineHeight) * frame.height; const srcH = ((drawEnd - drawStart) / lineHeight) * frame.height; ctx.drawImage(frame.source.image, srcX, srcY, 1, srcH, x, y, w, h); ctx.globalCompositeOperation = 'multiply'; ctx.fillStyle = shadeGrey(hit); ctx.fillRect(x, y, w, h); ctx.globalCompositeOperation = 'source-over'; } _drawSprites(state, camera) { const live = new Set(); const sprites = []; const now = this.scene.time.now; for (const e of state.enemies) { if (e.dead) { const dead = this._deadGuardSprite(e, now); if (dead) sprites.push(dead); continue; } const facing = this._guardFacing(e, camera, now); sprites.push({ key: `enemy:${e.id}`, x: e.x, y: e.y, tex: 'wolf-guard', scale: 0.9, guardFrame: facing.frame, guardFlip: facing.flip, }); } for (const pk of state.pickups) { if (pk.taken) continue; sprites.push({ key: `pickup:${pk.id}`, x: pk.x, y: pk.y, tex: `wolf-item-${pk.itemId}`, scale: 0.45 }); } for (const proj of state.projectiles) { sprites.push({ key: `proj:${proj.id}`, x: proj.x, y: proj.y, tex: 'wolf-bullet', scale: 0.1 }); } const invDet = 1 / (camera.planeX * camera.dirY - camera.dirX * camera.planeY); for (const s of sprites) { const sx = s.x - camera.x, sy = s.y - camera.y; s._tx = invDet * (camera.dirY * sx - camera.dirX * sy); s._depth = invDet * (-camera.planeY * sx + camera.planeX * sy); } sprites.sort((a, b) => b._depth - a._depth); // back-to-front for (const s of sprites) { 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 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); img.setFlipX(s.guardFlip); } else { img.setTexture(s.tex); img.setFlipX(false); } 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); } /** * POV pistol viewmodel. Visible only while pistol is the equipped weapon * (`p.weapon`) — fists has no viewmodel art, so switching to it just hides * this image rather than swapping textures. Bob (vertical "footstep" * bounce) and sway (horizontal drift) are both driven off one phase * accumulator that only advances while the player has forward/strafe * input held; `_weaponBobStrength` is lerped toward 1 while moving and 0 * while still, so starting/stopping fades the motion in/out instead of * snapping to it. */ _drawWeapon(state) { const p = state.player; const img = this.weaponImage; if (p.weapon !== 'pistol' || p.dead) { img.setVisible(false); return; } const now = this.scene.time.now; const dt = this._lastWeaponNow != null ? Math.max(0, now - this._lastWeaponNow) : 0; this._lastWeaponNow = now; const moving = p.moveForward !== 0 || p.moveStrafe !== 0; const smoothT = Math.min(1, dt / WEAPON_BOB_SMOOTH_MS); this._weaponBobStrength += ((moving ? 1 : 0) - this._weaponBobStrength) * smoothT; if (moving) this._weaponBobPhase += dt * WEAPON_BOB_SPEED; const bobY = Math.abs(Math.sin(this._weaponBobPhase)) * WEAPON_BOB_AMP_Y * this._weaponBobStrength; const swayX = Math.sin(this._weaponBobPhase * 0.5) * WEAPON_SWAY_AMP_X * this._weaponBobStrength; img.setPosition(this._weaponBaseX + swayX, this._weaponBaseY + bobY); img.setVisible(true); } /** * Falling -> on-the-ground -> fade-out sequence for a just-killed guard, * keyed by enemy id in `this.deathAnim` (first call after `e.dead` flips * true starts the clock). Angle-independent, like the shoot pose — frames * 7/8 have no directional variants. Returns null once the sequence has * fully faded, after tearing down the pooled sprite so it doesn't linger. * The `deathAnim` entry itself is kept forever (not deleted) once finished * — `e.dead` stays true for the rest of the level, so a deleted entry * would just read as "never started" on the next render call and restart * the whole fall/ground/fade sequence from scratch, looping it forever. */ _deadGuardSprite(e, now) { let anim = this.deathAnim.get(e.id); if (!anim) { anim = { startMs: now }; this.deathAnim.set(e.id, anim); } const elapsed = now - anim.startMs; const total = DEATH_FALL_MS + DEATH_GROUND_MS + DEATH_FADE_MS; if (elapsed >= total) { this._destroySprite(`enemy:${e.id}`); return null; } let frame = GUARD_FRAME.fall; let alpha = 1; if (elapsed >= DEATH_FALL_MS) { frame = GUARD_FRAME.ground; const fadeElapsed = elapsed - DEATH_FALL_MS - DEATH_GROUND_MS; if (fadeElapsed > 0) alpha = Math.max(0, 1 - fadeElapsed / DEATH_FADE_MS); } return { key: `enemy:${e.id}`, x: e.x, y: e.y, tex: 'wolf-guard', scale: 0.9, guardFrame: frame, guardFlip: false, alpha, }; } /** * Which of the guard sheet's frames to show for enemy `e`, given where * the camera is standing relative to it. `e.angle` is the guard's own * heading (set by stepEnemyAI/stepPatrol in WolfensteinLogic) — comparing * it against the bearing from the guard to the camera buckets the view * into front/back/side, the same billboard-selection idea classic * sprite-based FPSes use. Only one shooting pose exists, so an attacking * guard always shows it head-on regardless of viewing angle (attacking * guards don't move, so the player is almost always roughly in front of * them anyway). `flip` mirrors the single "facing right" pose for the * left side — was `rel > 0` originally, but that read backwards in-game * (2026-08-21), so it's `rel < 0` now. If it's ever wrong again, flip * this one sign; nothing else needs to change. */ _guardFacing(e, camera, now) { // Stun takes visual priority over e.state — stepEnemyAI freezes state // entirely while stunned, so it may still read 'attack'/'chase' from // right before the hit landed. if (e.stunMs > 0) return { frame: GUARD_FRAME.stunned, flip: false }; if (e.state === 'attack') return { frame: GUARD_FRAME.shoot, flip: false }; const isMoving = e.state === 'chase' || e.state === 'alert' || (e.state === 'idle' && e.patrol && e.patrol.length > 0); const walkOn = isMoving && Math.floor(now / WALK_CYCLE_MS) % 2 === 0; const toCam = Math.atan2(camera.y - e.y, camera.x - e.x); let rel = toCam - e.angle; rel = Math.atan2(Math.sin(rel), Math.cos(rel)); const absRel = Math.abs(rel); if (absRel < Math.PI / 4) { return { frame: walkOn ? GUARD_FRAME.frontWalk : GUARD_FRAME.frontIdle, flip: false }; } if (absRel > (3 * Math.PI) / 4) { return { frame: walkOn ? GUARD_FRAME.backWalk : GUARD_FRAME.backIdle, flip: false }; } return { frame: walkOn ? GUARD_FRAME.sideWalk : GUARD_FRAME.sideIdle, flip: rel < 0 }; } _ensureSprite(key, tex) { let img = this.spritePool.get(key); if (!img) { img = this.scene.add.image(0, 0, tex).setOrigin(0.5, 0.5); this.spritePool.set(key, img); } return img; } _hideSprite(key) { const img = this.spritePool.get(key); if (img) img.setVisible(false); } _destroySprite(key) { const img = this.spritePool.get(key); if (img) { img.destroy(); this.spritePool.delete(key); } } destroy() { for (const img of this.spritePool.values()) img.destroy(); this.spritePool.clear(); this.image?.destroy(); this.weaponImage?.destroy(); if (this.scene.textures.exists('wolf-3dview')) this.scene.textures.remove('wolf-3dview'); } } function shadeMul(hit) { const sideMul = hit.side === 1 ? 0.72 : 1; const fog = Math.max(0.28, 1 - hit.perpDist / 14); return sideMul * fog; } function shadeColor(hex, hit) { let r = (hex >> 16) & 255, g = (hex >> 8) & 255, b = hex & 255; const m = shadeMul(hit); r = Math.round(r * m); g = Math.round(g * m); b = Math.round(b * m); return `rgb(${r},${g},${b})`; } // A solid grey drawn with 'multiply' compositing scales every channel of // whatever's underneath by the same factor shadeColor() applies to a flat // fill — the same side/fog darkening, applied to a drawn texture instead. function shadeGrey(hit) { const v = Math.round(255 * shadeMul(hit)); return `rgb(${v},${v},${v})`; }