diff --git a/assets/images/wolfenstein/enemies.png b/assets/images/wolfenstein/enemies.png new file mode 100644 index 0000000..3324be7 Binary files /dev/null and b/assets/images/wolfenstein/enemies.png differ diff --git a/assets/images/wolfenstein/enemies.psd b/assets/images/wolfenstein/enemies.psd new file mode 100644 index 0000000..0a5e8fe Binary files /dev/null and b/assets/images/wolfenstein/enemies.psd differ diff --git a/assets/images/wolfenstein/weapon_pistol.png b/assets/images/wolfenstein/weapon_pistol.png new file mode 100644 index 0000000..2767f1b Binary files /dev/null and b/assets/images/wolfenstein/weapon_pistol.png differ diff --git a/data/wolfenstein-artwork.json b/data/wolfenstein-artwork.json index 6887189..43a0e18 100644 --- a/data/wolfenstein-artwork.json +++ b/data/wolfenstein-artwork.json @@ -2,7 +2,7 @@ "_readme": "Drop-in art manifest. Every entry has path: null until painted — the game renders procedurally via WolfensteinArt.js until then. Fill in a path and it lazy-loads automatically via assetManifest.js's wolfenstein entry; no code changes needed. sheets.walls expects a 4-column strip (one column per wall type in WolfensteinArt.WALL_COLORS) so WolfensteinView's per-column drawImage can sample a texture-x slice per wall type.", "sheets": { "walls": { "key": "wolfenstein-walls", "path": "assets/images/wolfenstein/walls.png", "frameWidth": 64, "frameHeight": 64 }, - "guard": { "key": "wolfenstein-guard-sheet", "path": null, "frameWidth": 128, "frameHeight": 128 }, + "guard": { "key": "wolfenstein-guard-sheet", "path": "assets/images/wolfenstein/enemies.png", "frameWidth": 128, "frameHeight": 128 }, "doors": { "key": "wolfenstein-doors", "path": "assets/images/wolfenstein/doors.png", "frameWidth": 64, "frameHeight": 64 } }, "artwork": [ diff --git a/src/games/wolfenstein/WolfensteinGame.js b/src/games/wolfenstein/WolfensteinGame.js index fda32b0..3b0c00c 100644 --- a/src/games/wolfenstein/WolfensteinGame.js +++ b/src/games/wolfenstein/WolfensteinGame.js @@ -44,7 +44,7 @@ export default class WolfensteinGame extends Phaser.Scene { this._mouseFireHeld = false; this._lastAutosave = 0; - this.keys = this.input.keyboard.addKeys('W,A,S,D,CTRL,ONE,TWO,ESC,SPACE'); + this.keys = this.input.keyboard.addKeys('W,A,S,D,E,CTRL,ONE,TWO,ESC,SPACE'); this._bindPointerLock(); @@ -220,7 +220,9 @@ export default class WolfensteinGame extends Phaser.Scene { Logic.setFireHeld(this.state, this._mouseFireHeld || k.CTRL.isDown); if (Phaser.Input.Keyboard.JustDown(k.ONE)) Logic.switchWeapon(this.state, 'fists'); if (Phaser.Input.Keyboard.JustDown(k.TWO)) Logic.switchWeapon(this.state, 'pistol'); - if (Phaser.Input.Keyboard.JustDown(k.SPACE)) Logic.openNearestDoor(this.state); + if (Phaser.Input.Keyboard.JustDown(k.SPACE) || Phaser.Input.Keyboard.JustDown(k.E)) { + Logic.openNearestDoor(this.state); + } } // ------------------------------------------------------------- loop @@ -298,8 +300,8 @@ export default class WolfensteinGame extends Phaser.Scene { objs.crosshair = this.add.text(GAME_WIDTH / 2, VIEW_H / 2, '+', { fontSize: '38px', color: COLORS.textHex }).setOrigin(0.5).setDepth(15); objs.lockHint = this.add.text(GAME_WIDTH / 2, 60, 'Click to aim', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0.5).setDepth(21); // Doors no longer open automatically on approach (see openNearestDoor) — - // without this, there's no way to discover that Space is the interact key. - objs.doorHint = this.add.text(GAME_WIDTH / 2, VIEW_H / 2 + 46, '[SPACE] Open', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21); + // without this, there's no way to discover that Space/E is the interact key. + objs.doorHint = this.add.text(GAME_WIDTH / 2, VIEW_H / 2 + 46, '[SPACE/E] Open', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21); return objs; } diff --git a/src/games/wolfenstein/WolfensteinView.js b/src/games/wolfenstein/WolfensteinView.js index 1a136d1..427bc8f 100644 --- a/src/games/wolfenstein/WolfensteinView.js +++ b/src/games/wolfenstein/WolfensteinView.js @@ -33,6 +33,23 @@ export const NUM_COLUMNS = 480; // 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; + export default class WolfensteinView { constructor(scene, rules) { this.scene = scene; @@ -49,11 +66,17 @@ export default class WolfensteinView { 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; } render(state, camera) { @@ -156,9 +179,18 @@ export default class WolfensteinView { _drawSprites(state, camera) { const live = new Set(); const sprites = []; + const now = this.scene.time.now; for (const e of state.enemies) { - if (e.dead) continue; - sprites.push({ key: `enemy:${e.id}`, x: e.x, y: e.y, tex: 'wolf-guard', scale: 0.9 }); + 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; @@ -186,15 +218,90 @@ export default class WolfensteinView { } const spriteSize = Math.abs(VIEW_H / s._depth) * s.scale; const img = this._ensureSprite(s.key, s.tex); - img.setTexture(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); } + /** + * 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 — if left/right ever reads backwards in-game, flip the sign + * on the `rel > 0` line below, 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) { @@ -209,6 +316,11 @@ export default class WolfensteinView { 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(); diff --git a/src/games/wolfenstein/sprites.md b/src/games/wolfenstein/sprites.md index 9def9e9..bdbf402 100644 --- a/src/games/wolfenstein/sprites.md +++ b/src/games/wolfenstein/sprites.md @@ -99,23 +99,64 @@ entered — no per-frame lookup cost. ## Guard enemy — `sheets.guard` -Per the artwork JSON: `frameWidth: 128, frameHeight: 128`. **Minimum useful -size: a single 128×128 PNG (1 frame)** — that's all the current renderer can -show. `WolfensteinView._drawSprites` calls `img.setTexture('wolf-guard')` -with no frame index, so Phaser always displays frame 0 (top-left) of -whatever sheet is loaded under that key; there is no facing/animation frame -selection logic anywhere in the code yet. Painting extra frames into the -sheet is harmless (they'll just sit unused) but won't animate or turn to -face the player until that selection logic is written. +**Wired up as of 2026-08-21, extended 2026-08-21.** Per the artwork JSON: +`frameWidth: 128, frameHeight: 128`, painted sheet is +`assets/images/wolfenstein/enemies.png` (1152×896, a 9×7 grid loaded as a +plain row-major Phaser spritesheet — only the first row, frames 0-8, is +used; the remaining rows sit unused until a second enemy type lands there). +Frame meaning (`GUARD_FRAME` in `WolfensteinView.js`): `0` shooting, `1` +facing forward (idle), `2` facing forward walking, `3` facing right (idle, +profile), `4` facing right walking, `5` facing away (idle), `6` facing away +walking, `7` falling (death), `8` on the ground (death). There is no "facing +left" pose — `WolfensteinView` shows the "facing right" frames with +`img.setFlipX(true)` for that case instead, per the sheet's own convention. +Frames 7/8 are likewise angle-independent — no directional death poses. -If/when directional billboarding is worth building, the natural layout to -target is a grid, frame index = `row * cols + col` in Phaser's row-major -order: **8 rows (one per 45° facing angle, starting from "facing camera" and -going clockwise) × N columns** for whatever animation set is wanted per -angle, e.g. `{idle, walk1, walk2, shoot, die1, die2}` (6 cols → a 768×1024 -sheet). That's a proposal, not a spec anything reads — pick a smaller set -(e.g. idle + walk + shoot only, 3 cols) if painting 48 frames is too much -up front, since nothing currently depends on the exact column count. +`WolfensteinView._guardFacing(e, camera, now)` picks the frame every render: +- `e.state === 'attack'` always shows the shooting pose (frame 0), unflipped, + regardless of viewing angle — there's only one shooting pose, and + attacking guards stand still and roughly face the player anyway. +- Otherwise it compares the guard's own heading (`e.angle`, set by + `stepEnemyAI`/`stepPatrol` in `WolfensteinLogic.js`) against the bearing + from the guard to the camera, bucketing the relative angle into + front/back/side (±45°/±135° thresholds) the way classic sprite-based FPS + billboarding does — front when the guard is looking at the camera, back + when its back is turned, side otherwise (mirrored via `flipX` depending on + which side). If left/right ever reads backwards in-game, it's a one-line + sign flip in that method, not a rethink. +- Walking-vs-idle pose alternates on a shared `WALK_CYCLE_MS` (300ms) timer + whenever the guard is actually moving — `state === 'chase'`, `'alert'` + (which also moves per `stepEnemyAI`), or `'idle'` while following a + patrol route with waypoints. An idle guard with no patrol just stands + (frame 1/3/5, whichever facing applies), never animating. + +`_guardFacing` only runs for live guards (`!e.dead`). Once `e.dead` flips +true, `WolfensteinView._deadGuardSprite(e, now)` takes over instead and +drives a **view-only** death sequence — `WolfensteinLogic.js`'s enemies just +have a `dead` boolean, no animation-phase field, since that's presentation +state, not simulation state (it doesn't round-trip through save/load and +isn't checked by `tools/verifyWolfenstein.js`). Keyed by enemy id in +`this.deathAnim` (a `Map`, separate from the position/depth sprite pool), +the first render tick after death records a `startMs` and the elapsed time +since then drives three phases: `DEATH_FALL_MS` (350ms) showing frame 7 +(falling), then `DEATH_GROUND_MS` (2000ms) showing frame 8 fully opaque +(on the ground), then `DEATH_FADE_MS` (800ms) still on frame 8 while alpha +linearly ramps `1 -> 0`. Once the total elapsed time clears all three +phases, the method destroys the pooled `Image` outright (not just hides +it — a dead guard never comes back, unlike a live one flickering in/out of +the depth buffer) so the body's sprite doesn't linger, and stops returning a +sprite for it at all (excluded from that frame's list, so it's no longer +drawn, occlusion-tested, or depth-sorted). The `deathAnim` entry itself is +kept forever rather than deleted — `e.dead` stays true for the rest of the +level, so deleting it would make the next render call read as "just died" +again and restart the fall/ground/fade sequence from scratch on a loop. + +Falls back to the old flat placeholder (`WolfensteinArt.js`'s single-frame +procedural `wolf-guard` texture, always frame 0, never flipped) if the sheet +isn't loaded, same convention as the wall/door texture fallbacks — this +applies to the death sequence too (dead guards would just render as the +one flat placeholder frame, never flipped, for the same three phases/timing, +alpha fade included, until removed). ## Standalone images — `artwork[]`