feat(wolfenstein): guard sprite animation, death sequence, and E-key door interact

- Add directional billboarding for guards: front/side/back idle & walk
  frames selected by relative angle to camera, with flipX for left side
- Add shooting pose (always shown when guard is in attack state)
- Add 2-frame walk cycle (300ms) for moving guards (chase/alert/patrol)
- Add death sequence: fall (350ms) → ground (2s) → fade (800ms),
  view-only state in WolfensteinView, pooled sprite destroyed on completion
- Wire guard sheet texture from artwork JSON (enemies.png, 9×7 grid,
  frames 0-8 used)
- Add E key as alternate interact key for opening doors
- Update door hint text to show [SPACE/E]
- Update sprites.md with full animation/facing/death documentation
This commit is contained in:
Brian Fertig 2026-08-21 19:22:22 -06:00
parent f6081de0a8
commit 965e4308c9
7 changed files with 179 additions and 24 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

View File

@ -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.", "_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": { "sheets": {
"walls": { "key": "wolfenstein-walls", "path": "assets/images/wolfenstein/walls.png", "frameWidth": 64, "frameHeight": 64 }, "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 } "doors": { "key": "wolfenstein-doors", "path": "assets/images/wolfenstein/doors.png", "frameWidth": 64, "frameHeight": 64 }
}, },
"artwork": [ "artwork": [

View File

@ -44,7 +44,7 @@ export default class WolfensteinGame extends Phaser.Scene {
this._mouseFireHeld = false; this._mouseFireHeld = false;
this._lastAutosave = 0; 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(); this._bindPointerLock();
@ -220,7 +220,9 @@ export default class WolfensteinGame extends Phaser.Scene {
Logic.setFireHeld(this.state, this._mouseFireHeld || k.CTRL.isDown); 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.ONE)) Logic.switchWeapon(this.state, 'fists');
if (Phaser.Input.Keyboard.JustDown(k.TWO)) Logic.switchWeapon(this.state, 'pistol'); 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 // ------------------------------------------------------------- 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.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); 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) — // Doors no longer open automatically on approach (see openNearestDoor) —
// without this, there's no way to discover that Space is the interact key. // 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] Open', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21); 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; return objs;
} }

View File

@ -33,6 +33,23 @@ export const NUM_COLUMNS = 480;
// generic per-column wall-texture path below at all. // generic per-column wall-texture path below at all.
const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3 }; 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 { export default class WolfensteinView {
constructor(scene, rules) { constructor(scene, rules) {
this.scene = scene; this.scene = scene;
@ -49,11 +66,17 @@ export default class WolfensteinView {
this.colWidth = VIEW_W / NUM_COLUMNS; this.colWidth = VIEW_W / NUM_COLUMNS;
this.depthBuffer = new Array(NUM_COLUMNS).fill(Infinity); this.depthBuffer = new Array(NUM_COLUMNS).fill(Infinity);
this.spritePool = new Map(); 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') this.wallTexture = scene.textures.exists('wolfenstein-walls')
? scene.textures.get('wolfenstein-walls') : null; ? scene.textures.get('wolfenstein-walls') : null;
this.doorTexture = scene.textures.exists('wolfenstein-doors') this.doorTexture = scene.textures.exists('wolfenstein-doors')
? scene.textures.get('wolfenstein-doors') : null; ? scene.textures.get('wolfenstein-doors') : null;
this.guardTexture = scene.textures.exists('wolfenstein-guard-sheet')
? scene.textures.get('wolfenstein-guard-sheet') : null;
} }
render(state, camera) { render(state, camera) {
@ -156,9 +179,18 @@ export default class WolfensteinView {
_drawSprites(state, camera) { _drawSprites(state, camera) {
const live = new Set(); const live = new Set();
const sprites = []; const sprites = [];
const now = this.scene.time.now;
for (const e of state.enemies) { for (const e of state.enemies) {
if (e.dead) continue; if (e.dead) {
sprites.push({ key: `enemy:${e.id}`, x: e.x, y: e.y, tex: 'wolf-guard', scale: 0.9 }); 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) { for (const pk of state.pickups) {
if (pk.taken) continue; if (pk.taken) continue;
@ -186,15 +218,90 @@ export default class WolfensteinView {
} }
const spriteSize = Math.abs(VIEW_H / s._depth) * s.scale; const spriteSize = Math.abs(VIEW_H / s._depth) * s.scale;
const img = this._ensureSprite(s.key, s.tex); 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.setPosition(screenColF * this.colWidth, VIEW_H / 2);
img.setDisplaySize(spriteSize, spriteSize); img.setDisplaySize(spriteSize, spriteSize);
img.setDepth(10 + Math.max(0, 100 - s._depth)); img.setDepth(10 + Math.max(0, 100 - s._depth));
img.setAlpha(s.alpha ?? 1);
img.setVisible(true); img.setVisible(true);
} }
for (const [key, img] of this.spritePool) if (!live.has(key)) img.setVisible(false); 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) { _ensureSprite(key, tex) {
let img = this.spritePool.get(key); let img = this.spritePool.get(key);
if (!img) { if (!img) {
@ -209,6 +316,11 @@ export default class WolfensteinView {
if (img) img.setVisible(false); if (img) img.setVisible(false);
} }
_destroySprite(key) {
const img = this.spritePool.get(key);
if (img) { img.destroy(); this.spritePool.delete(key); }
}
destroy() { destroy() {
for (const img of this.spritePool.values()) img.destroy(); for (const img of this.spritePool.values()) img.destroy();
this.spritePool.clear(); this.spritePool.clear();

View File

@ -99,23 +99,64 @@ entered — no per-frame lookup cost.
## Guard enemy — `sheets.guard` ## Guard enemy — `sheets.guard`
Per the artwork JSON: `frameWidth: 128, frameHeight: 128`. **Minimum useful **Wired up as of 2026-08-21, extended 2026-08-21.** Per the artwork JSON:
size: a single 128×128 PNG (1 frame)** — that's all the current renderer can `frameWidth: 128, frameHeight: 128`, painted sheet is
show. `WolfensteinView._drawSprites` calls `img.setTexture('wolf-guard')` `assets/images/wolfenstein/enemies.png` (1152×896, a 9×7 grid loaded as a
with no frame index, so Phaser always displays frame 0 (top-left) of plain row-major Phaser spritesheet — only the first row, frames 0-8, is
whatever sheet is loaded under that key; there is no facing/animation frame used; the remaining rows sit unused until a second enemy type lands there).
selection logic anywhere in the code yet. Painting extra frames into the Frame meaning (`GUARD_FRAME` in `WolfensteinView.js`): `0` shooting, `1`
sheet is harmless (they'll just sit unused) but won't animate or turn to facing forward (idle), `2` facing forward walking, `3` facing right (idle,
face the player until that selection logic is written. 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 `WolfensteinView._guardFacing(e, camera, now)` picks the frame every render:
target is a grid, frame index = `row * cols + col` in Phaser's row-major - `e.state === 'attack'` always shows the shooting pose (frame 0), unflipped,
order: **8 rows (one per 45° facing angle, starting from "facing camera" and regardless of viewing angle — there's only one shooting pose, and
going clockwise) × N columns** for whatever animation set is wanted per attacking guards stand still and roughly face the player anyway.
angle, e.g. `{idle, walk1, walk2, shoot, die1, die2}` (6 cols → a 768×1024 - Otherwise it compares the guard's own heading (`e.angle`, set by
sheet). That's a proposal, not a spec anything reads — pick a smaller set `stepEnemyAI`/`stepPatrol` in `WolfensteinLogic.js`) against the bearing
(e.g. idle + walk + shoot only, 3 cols) if painting 48 frames is too much from the guard to the camera, bucketing the relative angle into
up front, since nothing currently depends on the exact column count. 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[]` ## Standalone images — `artwork[]`