417 lines
18 KiB
JavaScript
417 lines
18 KiB
JavaScript
// 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-sprite (not per-pixel) against the wall depth buffer — an
|
|
// approximation, acceptable for this MVP.
|
|
|
|
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,
|
|
};
|
|
|
|
// How long each half of the 2-frame walk cycle (idle/walk pose) holds, ms.
|
|
const WALK_CYCLE_MS = 300;
|
|
|
|
// 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.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);
|
|
|
|
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;
|
|
}
|
|
|
|
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));
|
|
// 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.
|
|
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';
|
|
} else {
|
|
const base = WALL_COLORS[hit.wallType] ?? 0xaaaaaa;
|
|
ctx.fillStyle = shadeColor(base, hit);
|
|
ctx.fillRect(x, y, w, h);
|
|
}
|
|
}
|
|
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 col = Math.floor(screenColF);
|
|
if (col < 0 || col >= NUM_COLUMNS || s._depth > this.depthBuffer[col] + 0.15) {
|
|
this._hideSprite(s.key); continue;
|
|
}
|
|
const spriteSize = Math.abs(VIEW_H / s._depth) * s.scale;
|
|
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(screenColF * this.colWidth, VIEW_H / 2);
|
|
img.setDisplaySize(spriteSize, spriteSize);
|
|
img.setDepth(10 + Math.max(0, 100 - s._depth));
|
|
img.setAlpha(s.alpha ?? 1);
|
|
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) {
|
|
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})`;
|
|
}
|