35 KiB
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, wherealongis the 0..1 position along the door's face and the panel is modeled as having physically translatedslidecell-widths into its pocket, opening fromalong = 0outward) — 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 > 0always shows the stunned pose (frame 9), unflipped, and takes priority over every other check below it —stepEnemyAIfreezese.stateentirely 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 bystepEnemyAI/stepPatrolinWolfensteinLogic.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 viaflipXdepending 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 perstepEnemyAI), 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 (seeWolfensteinArt.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 awolf-muzzleimage instance yet, painted or not) — wire it up onweaponFiredevents once a sfx/vfx pass lands.wolfenstein-title— optional main-menu backdrop art. Not read anywhere inWolfensteinScreens.jstoday; the menu renders a flat panel + title text only (backdrop()'sGAME_WIDTH×GAME_HEIGHTrectangle, matchingTAScreens.js's style beforeta-backgroundwas 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 stayswolfenstein-weapon- plasmarifleto match the weapon's own id inrules.json; a sheet'spathis free to differ from its key).WolfensteinViewbuilds one image per projectile weapon inrules.weapons, keyed by id (generalized from a single hardcoded pistol image 2026-08-22), and shows/hides them bystate.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-center —WolfensteinViewplaces 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._drawWeaponadds 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; aWEAPON_BOB_SMOOTH_MSlerp 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 ownWolfensteinArt.paintWeapon<Id>(a small 360×260 procedural placeholder, bottom-center anchored viasetOrigin(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.