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.",
"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": [

View File

@ -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;
}

View File

@ -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);
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();

View File

@ -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[]`