fertig-classic-games/src/games/wolfenstein/sprites.md

35 KiB
Raw Blame History

Wolfenstein 3D — art spec

Everything currently renders procedurally (see WolfensteinArt.js for the placeholder palette/textures). Drop real files in and fill in the matching path in data/wolfenstein-artwork.json — no code changes needed once a path is set (see assetManifest.js's wolfenstein entry).

Wall textures — sheets.walls

Per data/wolfenstein-artwork.json: frameWidth: 64, frameHeight: 64, loaded as a plain Phaser spritesheet (frames read left-to-right, top-to-bottom). Grew 2026-08-22 from a 256×64 PNG (4 frames, one row) to 256×256 (a 4×4 grid, 16 frames of headroom, frames 0-8 assigned so far, 9-15 still blank). WolfensteinView.WALL_FRAME maps wall type -> sheet frame (kept in sync by hand with WolfensteinArt.WALL_COLORS and WolfensteinEditor.WALL_TOOLS, all three keyed off the same type values): frame 0 = type 1 (Stone), frame 1 = type 2 (Wood), frame 2 = type 3 (Blue), frame 3 = type 4 (Green), frame 4 = type 5 (Wood Planks — plain vertical-plank timber, distinct from type 2's fancier paneled wood), frame 5 = type 6 (Blue Plaster — pale blue-gray plaster over a wood wainscot), frame 6 = type 7 (Gray Plaster — same plaster-over-wainscot composition as type 6, warm gray instead of blue), frame 7 = type 8 (Red Brick), frame 8 = type 10 (Cream Tile). Type 9 is permanently skipped — reserved for DOOR_WALL_TYPE (WolfensteinLogic.js), never a plain wall type, which is why the type numbering (not the sheet's own frame indices, which have no gap) jumps straight from 8 to 10. Doors are not part of this sheet at all — see sheets.doors below.

Wall art decals — sheets.wallArt

frameWidth: 64, frameHeight: 64, same spritesheet convention as walls — but unlike walls/doors this isn't a wall type, it's an optional decal (poster/flag/etc.) painted on top of whichever wall type is already there. Wired up 2026-08-22, extended 2026-08-22. Painted sheet is assets/images/wolfenstein/wall-art.png (512×512, an 8×8 grid = 64 frames of headroom; frames 0-2 assigned so far). Frame names (not just indices) live in a separate JSON registry, data/wolfenstein-wallart.json{ frames: [{ frame, id, name }] } — read directly by WolfensteinEditor.js's Wall Art tool dropdown at startup (fetch('data/wolfenstein-wallart.json'), see refreshWallArtOptions). To add a new decal: paint the next tile into wall-art.png, append one entry to that registry — no code changes needed on either side. Current frames: 0 = Nazi Flag, 1 = Hitler Pic, 2 = Exit Sign.

Level data: level.wallArt: [{ x, y, frame }], a sparse per-cell list (like doors/items, not a dense grid) — one entry per decorated wall cell, folded into buildLevelModel/validateLevel in WolfensteinLogic.js (an entry must sit on an actual wall cell, walls[y][x] > 0) and carried statically on state.map.wallArt (round-trips through save/load; a save predating this feature just deserializes to [], no SAVE_VERSION bump needed since it's purely cosmetic data with no simulation behavior).

Rendering (WolfensteinView._drawWalls): sampled with the exact same per-column source-texel technique as the wall texture itself (same srcX from hit.textureX, same vertical crop math), so a decal lines up pixel-for-pixel with the wall face beneath it at any distance — it's drawn directly on top of the base wall draw, before the shared multiply shading pass, so it darkens with distance/side exactly like the wall instead of reading as a flat unlit sticker. Falls back to showing nothing (just the plain wall) if the sheet isn't loaded — there's no procedural placeholder for wall art, unlike walls/doors/guard.

Editor (WolfensteinEditor.js): a "Wall Art" tool category whose dropdown is the one populated dynamically (from the JSON registry above) rather than hardcoded in CATEGORIES — every other category's options are static. Clicking an existing wall cell with a frame selected toggles that cell's entry in level.wallArt (apply if absent, remove if present, regardless of which frame is currently selected — switching an already-decorated wall to a different decal is two clicks). Repainting a wall's type/color keeps its wall art (applyTool's WALL_TOOLS branch); erasing a wall or placing anything that turns the cell to floor clears it (clearWallArtAt, called alongside every floor-creating branch). Decorated walls get a persistent yellow outline on the board regardless of which tool is active, the same "always visible" treatment doors get.

Objects (obstacle props) — sheets.objects

frameWidth: 64, frameHeight: 64, same spritesheet convention as walls — but unlike wall art this is a floor-standing prop, rendered as a billboard sprite (like an enemy/pickup), not baked into a wall column. Wired up 2026-08-22, extended 2026-08-22. Painted sheet is assets/images/wolfenstein/objects.png (512×512, 8×8 = 64 frames of headroom; frames 0-10 assigned so far). Frame names live in data/wolfenstein-objects.json — same { frames: [{ frame, id, name }] } shape and same "paint the next tile, append one entry, no code changes" workflow as wolfenstein-wallart.json. Current frames: 0 = Tall Bush, 1 = Gold Eagle, 2 = Nazi Banner, 3 = Suit of Armor, 4 = Oil Drum, 5 = Blue Vase, 6 = Round Topiary, 7 = Floor Lamp, 8 = Round Table, 9 = Wooden Chair, 10 = Podium.

Gameplay contract: an object blocks movement (player and enemies alike) but not sight or bullets — you can shoot through one, an enemy can see and fire through one, but nothing can walk through it. Level data: level.objects: [{ x, y, frame }], a sparse per-cell list (like wallArt/items), validated to sit on open floor (walls[y][x] === 0 — the exact opposite requirement of wall art's "must be on a wall"). At runtime it's a top-level state.objects list (rendered like pickups) that also derives a state.map.objectBlocked Set ("x,y" keys) purely for movement collision — WolfensteinLogic.isWallCell (used only by the player/enemy movement-collision path, circleHitsWall/moveWithCollision) checks this Set in addition to the wall grid, while the raycaster (castRay/hasLineOfSight, driving both sight and every bullet's wall check in stepProjectiles) never reads it at all — objects are never written into map.walls, which is what keeps them see-through/shoot-through by construction rather than by a special-case exception somewhere. Static data — round-trips through save/load, degrading to "no objects" for a save predating this feature (no SAVE_VERSION bump needed, same reasoning as wall art, except objects DO have collision behavior — but that's rebuilt fresh from the restored objects array on load, never itself serialized).

Rendering: pushed into WolfensteinView._drawSprites' shared sprite list exactly like a pickup, with objFrame selecting the sheet frame the same way guardFrame does for guards — occluded against the wall depth buffer (and clipped by a wall corner) through the same shared per-column mechanism every other sprite uses, no special-casing needed. Falls back to a flat placeholder block (WolfensteinArt.paintObject, wolf-object texture) if the sheet isn't loaded, same convention as every other sheet here.

Editor (WolfensteinEditor.js): an "Object" tool category, dynamic dropdown like Wall Art's (populated from the JSON registry above). Clicking open floor with a frame selected toggles that cell's entry in level.objects (apply if absent, remove if present, regardless of which frame is currently selected — same convention as Wall Art). A click on a wall cell is a no-op (erase the wall first) rather than force-converting it to floor the way door/start/exit/enemy/pickup placement does — an object sitting inside a wall makes no sense, so this tool deliberately doesn't offer that convenience. Placing a wall over a cell (or erasing it) clears any object there via clearEntitiesAt; placing another entity type (door/enemy/item) on the same cell as an existing object is not specially prevented — nothing else in this editor enforces mutual exclusivity between entity types either. Rendered on the board as a plain teal square (no per-frame visual distinction — the editor doesn't load real object art for preview, same as every other entity type here).

Pickups (weapons/ammo/health) — sheets.pickups

frameWidth: 64, frameHeight: 64, same spritesheet convention as objects — a floor-standing item, rendered as a billboard sprite, not baked into a wall column. Wired up 2026-08-22. Painted sheet is assets/images/wolfenstein/pickups.png (512×512, 8×8 = 64 frames of headroom; frames 0-13 assigned so far). Unlike Wall Art/Objects, there is no separate data/wolfenstein-pickups.json frame registry — data/ wolfenstein-rules.json's existing items[] array (already the one source of truth for pickup gameplay semantics) carries a frame field per entry instead, since a pickup needs kind-specific fields (health amount, ammo type+amount, granted weapon+ammo bonus) that a bare frame-name registry doesn't have anywhere to put. Current frames: 0=Pistol, 1=Shotgun, 2=Machine Gun, 3=Gatling Gun, 4=Plasma Rifle, 5=Pistol Clip (10 9mm ammo), 6=Ammo Box (50 9mm ammo), 7=Shotgun Shells (5 shells ammo), 8=Plasma Cell (30 plasma ammo), 9=Small Medpack (25 HP), 10=Large Medpack (50 HP), 11=Blue Key, 12=Red Key, 13=Yellow Key.

Gameplay contract: unlike an object, a pickup is not solid — walking within constants.pickupRadius of one removes it and applies its effect (WolfensteinLogic.stepPickups). Ammo is pooled by ammo type (rules.ammoTypes: 9mm/shells/plasma), not by weapon id — pistol, machine gun and gatling gun all draw from the same 9mm pool, which is why Pistol Clip and Ammo Box are both generic "bullet ammo" pickups rather than pistol-specific ones. A kind: 'weapon' pickup both grants the weapon (pushed onto player.weapons if not already owned), tops up its ammo type's pool by the item's ammo bonus, and auto-equips it. A kind: 'key' pickup (wired up 2026-08-22, see "Colored keys and locked doors" below) adds its color to player.keys if not already held — no weapon/ammo/health side effect. Level data: level.items: [{ x, y, type }] (type is the item's id in rules.json), a sparse per-cell list like wallArt/objects. At runtime, state.pickups is a top-level list ({ id, itemId, x, y, taken }) — taken flips true and rendering skips it once collected; no fade/animation, immediate disappearance (contrast a dead guard's multi-phase death sequence — overkill for a pickup).

Rendering: pushed into WolfensteinView._drawSprites' shared sprite list exactly like an object, with pkFrame (looked up from rules.itemById[pk.itemId].frame) selecting the sheet frame the same way objFrame does for objects — same shared occlusion/depth-sort/wall-corner- crop path every sprite uses. Falls back to one flat placeholder block (WolfensteinArt.paintPickup, wolf-pickup texture, gold rounded square) for every frame if the sheet isn't loaded, same convention as objects.

Editor (WolfensteinEditor.js): a "Pickup" tool category, dynamic dropdown like Object's, except populated from data/wolfenstein-rules.json's items[] (refreshPickupOptions) rather than a dedicated frame-registry file — adding a new pickup still needs a rules.json entry (it has gameplay effects, unlike a cosmetic object/decal), but no other code changes. Clicking a cell bulldozes it to open floor and toggles that cell's entry in level.items (apply if absent, remove if present, regardless of which pickup is currently selected) — same bulldoze-onto-floor convention as Door/Enemy/Start/Exit, not Object/Wall Art's stricter floor-only/wall-only gating. Board icon: a shape keyed by the item's kind (ammo=gold square, health=red circle, weapon=cyan triangle, key=color-filled diamond — see below), not per-frame art.

Colored keys and locked doors

Wired up 2026-08-22. Three key items (key-blue/key-red/key-yellow in data/wolfenstein-rules.json, kind: 'key', pickup frames 11-13 above) and a matching color field on a doors[] entry ('blue'/'red'/'yellow', WolfensteinLogic.DOOR_COLORS is the one place that enumerates the valid set) — a door with no color (or color: null) is the original "Normal" door, openable by anyone in range, unchanged from before this feature.

Gameplay contract (WolfensteinLogic.js): walking over a key item pushes its color onto player.keys (stepPickups' kind === 'key' branch, deduped — no effect if already held) — a plain array, not a Set, since it round-trips through JSON.stringify in serialize() for free. player.keys resets to [] every level (createState is called fresh per mission, see WolfensteinGame._beginLevel — nothing in this engine ever carries player state between missions), which is exactly "a key is lost on level completion" with no explicit clearing logic needed anywhere. openNearestDoor (the Space/E interact handler) now checks a door's color against player.keys before setting target = 1: missing the key pushes a doorLocked event ({ color }) instead of doorOpen and leaves the door shut. nearestDoorInfo(state) is a read-only variant of the same nearest-door lookup for the HUD prompt — null / { color, locked } — so WolfensteinGame can show "Locked — need the Blue Key" instead of only finding out after a wasted keypress. stepDoors' enemy-push-through branch (enemyNearDoor) is gated !d.color — guards never carry keys, so a locked door is a hard barrier to them too, not just the player without the key.

Level authoring / solvability: buildLevelModel already round-trips color for free (its doors/items mapping just spreads every field). validateLevel flags an unrecognized door.color as an authoring error (not one of DOOR_COLORS). More importantly, bfsReachable is key-aware: a colored door only counts as passable once its matching key has already been reached (without needing that same door) — implemented as a fixed-point loop, not a single flood-fill: flood-fill with the current keyset, harvest any newly-reachable key cells into it, repeat until a pass finds nothing new (keys are never spent, so the reachable region only ever grows). A key placed behind its own locked door, or a locked door with its key nowhere in the level, correctly reports the exit unreachable — same "exit is not reachable from playerStart" issue a level with no path at all gets. This only works because a door cell's walls[y][x] entry is 0 (plain floor) in every authored level JSON — DOOR_WALL_TYPE (9) only ever gets baked in by createState for the runtime map, which validateLevel never sees — so the color lookup is keyed off level.doors directly, not the walls grid. A key item's type is matched to a door's color by naming convention (key-<color>) rather than by importing rules.json into WolfensteinLogic.js — kept in sync by hand, the same tradeoff DOOR_WALL_TYPE already makes with WolfensteinRaycaster.js.

Editor (WolfensteinEditor.js): the Door category grew from one option ("Normal") to four (door:normal/door:blue/door:red/door:yellow, DOOR_PREFIX), same toggle-on-click convention as before (click an existing door of any color to remove it; switching an already-placed door to a different color is two clicks, not an implicit recolor). Board rendering makes a locked door visually unmistakable rather than just a differently- colored wall tile: the cell fills with the color (LOCK_COLORS, shared with the key pickup diamond below), plus a bold dark border and a small dark "keyhole" glyph (circle + triangle stem) drawn on top. A key pickup renders as a diamond filled with that same color — a fourth shape distinct from the existing ammo-square/weapon-triangle/health-circle icons, chosen so its color visually pairs it with the door(s) it opens.

Door textures — sheets.doors

frameWidth: 64, frameHeight: 64, same spritesheet convention as walls. Wired up as of 2026-08-21, extended 2026-08-22 for locked doors. Frame 0 is the plain/"Normal" door; frames 1-3 are the Blue/Red/Yellow locked-door variants (WolfensteinView.DOOR_COLOR_FRAME{ blue: 1, red: 2, yellow: 3 } — matching the editor's Door category options and WolfensteinLogic.DOOR_COLORS). The current painted sheet is 256×256 (a 4×4 grid, 16 frames), of which frames 0-3 are read; the remaining 12 sit unused until a further door type exists. WolfensteinView.js grabs the texture once in the constructor (this.doorTexture) and _drawDoorColumn samples it exactly like the wall path samples sheets.walls (1px source-texel column, drawImage-stretched, same vertical source-crop for close-up magnification, same multiply-composited side/fog shading), now picking DOOR_COLOR_FRAME[color] instead of a hardcoded 0 — the door's color is looked up per-cell by _drawWalls (from state.doors, since a raycaster hit only carries the cell's mapX/mapY) and passed into _drawDoorColumn. Falls back to a flat color (DOOR_COLOR_HEX, the same blue/red/yellow the editor's board glyph uses) for a locked door, or the original flat tan for a normal one, if the sheet isn't loaded. See the sliding/recessed-door notes below for how that texture actually gets positioned in the scene (mid-cell plane, slide-driven visible fraction) — unaffected by color, which only changes which frame/flat color gets sampled.

Sliding, recessed, see-through doors

Doors animate open/closed (WolfensteinLogic.js's stepDoors, slide field 0→1 over DOOR_SLIDE_MS) rather than popping instantly, and the cell stays solid for gameplay (collision/LOS/bullets) through the entire animation — it only becomes passable once slide reaches exactly 1 (see stepDoors' comment for why: no squeezing through a half-open door). That's unrelated to the visual, though, which is real mid-cell geometry, not a projection trick: WolfensteinRaycaster.js's intersectDoorMidplane, opted into by passing state.doors to castColumns/castRay (every gameplay raycast in WolfensteinLogic.js omits it and still sees a door as an ordinary flush full-cell solid — this never touches what's actually walkable/shootable/ visible to AI, only what the player's screen shows).

A door's blocking axis is inferred from geometry (which pair of neighbor cells is walled off) rather than the doors[] orientation field, which nothing in the codebase actually sets to anything but 'vertical'. Given that axis, the door plane sits at the cell's exact midpoint (e.g. x = doorCellX + 0.5 for a door blocking east-west travel) instead of at the near face like a normal wall — a straight-on ray is perpDist-recessed by 0.5 relative to where a flush wall would sit.

Two things make a ray pass straight through a door cell instead of hitting it — both handled by intersectDoorMidplane returning null, which castRay treats as "keep stepping the DDA," not a miss:

  • the ray's own trajectory clears the cell sideways (crosses into a neighboring cell) before ever reaching the mid-plane — the DDA carries on to the real flanking wall cell, hit at its own true, unrecessed distance, which is what produces the "H" shape (door as the crossbar, visibly set back between two verticals that are ordinary wall hits, not anything faked in _drawDoorColumn) — confirmed with a sweep of rays across a doorway showing a symmetric run of door hits at the recessed distance flanked by wall hits at their own nearer distances, no discontinuity at the transition;
  • the crossing point has already slid open (along < slide, where along is the 0..1 position along the door's face and the panel is modeled as having physically translated slide cell-widths into its pocket, opening from along = 0 outward) — the DDA carries on past the door entirely, so a column here renders whatever's genuinely beyond the doorway (through the ordinary wall path, at that room's own true distance), not a stand-in color — you can see into the next room as the door opens, exactly as far as the geometry actually allows.

For a still-covered point, the returned textureX is along - slide, not raw along — sampling that directly (_drawDoorColumn, same source-texel technique the wall path uses) is what makes the door texture visibly translate sideways into the wall as it opens, rather than the closed portion just being a static crop that shrinks in place.

Now wired up (as of 2026-08-21): WolfensteinView._drawWalls reads hit.textureX (the raycaster's fractional wall-face position) and drawImages a 1px-wide source-texel column, stretched to the destination column's screen width, then darkens it with a 'multiply'-composited grey rect using the same side/fog factor the flat-color fallback uses — matches the shading, just applied to a texture instead of a solid fill. Falls back to the old flat-shaded fillRect per wall type whose frame isn't loaded (this.wallTexture missing, or no WALL_FRAME entry for that type — true for type 9/doors today), so art can land wall-type-by-wall-type. The texture is grabbed once in the constructor (scene.textures.get('wolfenstein-walls')), which the manifest loader has already populated by the time a game scene is entered — no per-frame lookup cost.

Secret doors

Wired up 2026-08-22. No art of its own — a secret door reuses whatever wall texture (any of the sheets.walls types) already sits on its cell, which is the entire point: it must be visually indistinguishable from a plain wall until triggered. No HUD hint either (unlike a normal door's [SPACE/E] Open prompt) — the player has to guess and press Space/E next to a suspicious wall on a hunch. Level data: level.secretDoors: [{ x, y, dir }]dir is degrees, the same 0=E/90=S/180=W/270=N convention playerStart.angle and an enemy's facing already use. Unlike a normal door, (x,y) must sit ON an existing wall cell (opposite of Door's "bulldozes to floor" requirement) and that cell's wall type is never touched — see WolfensteinLogic.createState's secretDoors mapping, which captures it as wallType and leaves the live map.walls grid exactly as authored.

Mechanically, once triggered (WolfensteinLogic.triggerNearestSecretDoor, called from the same Space/E handler as openNearestDoor — see WolfensteinGame._pollKeys), the wall cell slides — as real, continuous, multi-cell geometry, not a cosmetic stand-in — in its authored direction until it reaches the first solid cell, then permanently lodges there (no auto-close; a found secret stays found). How far it can travel (restDist) is scanned ONCE at level load against the level's own static walls (scanSecretDoorTravel) — nothing changes what's in that corridor before the door itself opens, so there's nothing to rescan later. Gameplay (collision/line-of-sight/bullets) stays exactly as authored — origin cell solid, corridor open, rest cell open — for the ENTIRE slide, flipping (origin → open, rest cell → solid, carrying the door's own wallType) in one atomic step only once progress reaches restDist (WolfensteinLogic.stepSecretDoors) — same "no squeezing through a half-open door" principle normal doors already follow, just over several seconds (SECRET_DOOR_MS_PER_CELL, 600ms/cell) instead of DOOR_SLIDE_MS. Enemies never trigger one — no equivalent of a normal door's enemyNearDoor push-through exists here; a secret is a player-only find, matching genre convention.

The multi-cell visual slide is real geometry precisely because collision stays simple: only WolfensteinView's render raycast ever sees a mid-slide door (state.secretDoors filtered to state === 'sliding', passed into _drawWalls/castColumns as a NEW optional secretDoors param alongside the existing doors one). WolfensteinRaycaster.secretDoorSlab(sd) precomputes, once per frame per active door (not per ray), the block's current 1-cell-wide solid span along its slide axis (blockLo/blockHi, sliding continuously from [origin, origin+1) at progress 0 to [rest, rest+1) at progress restDist) and the full range of cells it could ever occupy (corridorMin/corridorMax). castRay's main DDA loop checks every active slab for the cell it's just stepped into (checkSecretDoorCell, new): a cell outside the slab's row/column and range falls through to the ordinary static map.walls check untouched; a corridor cell the block isn't currently occupying reads as open regardless of what the (deliberately unchanged) static grid still says there; and the cell(s) the block currently spans get a genuine ray-vs-moving-plane intersection (same "solve for the ray's parametric distance to a face, then check the resulting in-plane coordinate," side/textureX shape intersectDoorMidplane already uses for a normal door's single FIXED mid-plane — just against a plane at a continuously moving position instead, and picking whichever face — near or far — the ray's own direction sign reaches first, so a ray approaching from behind an already-passed door correctly sees its trailing face too). The returned hit's wallType is the door's own captured texture, an ordinary type 1-10 value — NOT DOOR_WALL_TYPE — so it flows through _drawWalls' completely unmodified normal-wall-texture branch (WALL_FRAME[hit.wallType], same shading, same wall-art lookup) with zero special-casing needed there; the "secret door" rendering is entirely contained in the raycaster's per-cell hit-testing, not a separate draw path.

Editor (WolfensteinEditor.js): a "Secret Door" tool category (a mode, not a dropdown — same shape as Patrol/Erase). Clicking an existing wall cell toggles it (same remove-on-second-click convention as every other dynamic-entity tool); a freshly placed one immediately opens the same N/S/E/W direction picker Enemy placement uses (pendingSecretDoor, mirrors pendingFacingEnemy). Board marker is deliberately NOT a filled tile (that would hide the wall's own color, which the author still needs to see) — a magenta dashed outline plus a direction arrow (reusing drawEnemyArrow), shown at all times regardless of which tool is active, same "always visible" treatment doors/wall art get. validateLevel flags one that isn't on a wall cell, or has nowhere to go (restDist scanned the same way createState does) — but, unlike a normal or colored door, a secret door is deliberately NOT assumed passable for the exit-reachability check (bfsReachable has no special-casing for it at all — it's just an ordinary nonzero walls cell there): a secret is optional bonus content, so a level whose only path runs through an unopened one correctly reports the exit unreachable, the same as routing through a plain permanent wall would.

Guard enemy — sheets.guard

Wired up as of 2026-08-21, extended 2026-08-21 and 2026-08-22. 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 plus the first cell of the second row, frames 0-9, is used; the rest 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), 9 stunned (flinching from a non-lethal ranged hit — see stunMs in WolfensteinLogic.js's stepEnemyAI/stepProjectiles). 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/9 are likewise angle-independent — no directional death or stun poses.

WolfensteinView._guardFacing(e, camera, now) picks the frame every render:

  • e.stunMs > 0 always shows the stunned pose (frame 9), unflipped, and takes priority over every other check below it — stepEnemyAI freezes e.state entirely while stunned, so it may still read 'attack'/'chase' from right before the hit landed, and the stun pose must win regardless.
  • 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) and to stun (a stunned guard just reads as the same flat placeholder frame — no visible flinch — until frame 9 is painted).

Standalone images — artwork[]

These are loaded as plain images (no frame slicing), so any resolution works, but matching the placeholder's proportions keeps in-world scale consistent with WolfensteinView's s.scale multipliers (setDisplaySize is applied on top, so a differently-sized source image just gets stretched — same aspect ratio is what matters most):

  • wolfenstein-bullet — pistol round in flight. Placeholder is 12×12 (see WolfensteinArt.paintBullet: a brass/copper body with a bright tracer core and a small highlight, three concentric circles); rendered at scale 0.1 (WolfensteinView._drawSprites, shrunk from 0.18 on 2026-08-21 to read as a small round rather than a floating dot) — keep any replacement simple/legible at tiny sizes, same as before.
  • wolfenstein-muzzle — muzzle-flash sprite. Placeholder is 48×48. Not currently spawned by the scene at all (no code creates a wolf-muzzle image instance yet, painted or not) — wire it up on weaponFired events once a sfx/vfx pass lands.
  • wolfenstein-title — optional main-menu backdrop art. Not read anywhere in WolfensteinScreens.js today; the menu renders a flat panel + title text only (backdrop()'s GAME_WIDTH×GAME_HEIGHT rectangle, matching TAScreens.js's style before ta-background was painted). If added, size it to the shared canvas, 1920×1080, and code would need to draw it behind that panel.
  • wolfenstein-weapon-pistol, wolfenstein-weapon-shotgun, wolfenstein-weapon-machinegun, wolfenstein-weapon-gatling, wolfenstein-weapon-plasmarifle — POV weapon viewmodels, one per projectile weapon (fists has none — melee has no viewmodel art at all). All five painted as of 2026-08-22 (weapon_pistol.png, weapon_shotgun.png, weapon_machinegun.png, weapon_gatling.png, weapon_plasma.png — note the plasma rifle's file is named _plasma, not _plasmarifle, but the manifest key stays wolfenstein-weapon- plasmarifle to match the weapon's own id in rules.json; a sheet's path is free to differ from its key). WolfensteinView builds one image per projectile weapon in rules.weapons, keyed by id (generalized from a single hardcoded pistol image 2026-08-22), and shows/hides them by state.player.weapon. Each real PNG is sized to the full shared canvas, 1920×1080 (GAME_WIDTH×GAME_HEIGHT), transparent background, gun art pre-positioned bottom-centerWolfensteinView places that whole image at the origin (0, 0) rather than treating it as a small floating icon, so the gun's position in the frame comes from where it's drawn in the PNG, not from any offset in code. Depth 12: above the 3D view canvas and sprite billboards (10), below the crosshair (15) and bottom HUD bar (20) — the lower part of the gun art tucks behind the ammo bar exactly like a classic FPS viewmodel. WolfensteinView._drawWeapon adds a subtle bob (vertical bounce, Math.abs(Math.sin(phase))) and sway (horizontal drift, Math.sin(phase * 0.5)) while the player has forward/strafe input held, both driven off one shared phase accumulator (WEAPON_BOB_SPEED) that freezes when the player stops moving; a WEAPON_BOB_SMOOTH_MS lerp ramps the motion's strength in and out instead of snapping, so starting/stopping a step doesn't jerk the gun. An unpainted weapon (e.g. a future addition) falls back to its own WolfensteinArt.paintWeapon<Id> (a small 360×260 procedural placeholder, bottom-center anchored via setOrigin(0.5, 1) instead of the origin, one simple distinct silhouette per weapon) — the one case in this file where the real-art and placeholder branches use genuinely different Phaser image setup (origin/base position) per weapon, not just a different texture key, because the real PNG carries its own positioning and the placeholder can't.

Sound effects

None wired up yet — WolfensteinGame._onSimEvent has hooks for every event (weaponFired, meleeHit, enemyDied, enemyMelee, doorOpen/doorClose/ doorLocked, secretFound, pickup, missionWon/missionLost) but plays no audio. Once clips exist, add them to assetManifest.js's wolfenstein entry (see the commented-out note there) and call this.sound.play(...) from the matching event branch.