fertig-classic-games/src/games/wolfenstein/WolfensteinView.js

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})`;
}