# 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 — a single horizontal row is simplest). **Exact size: a 256×64 PNG, 4 frames, one 64×64 tile per wall type**, in `WolfensteinArt.WALL_COLORS` insertion order: frame 0 = type 1 (stone), frame 1 = type 2 (wood), frame 2 = type 3 (blue-tile), frame 3 = type 4 (green-tile). Doors (`DOOR_WALL_TYPE`, type 9) are **not** part of this sheet at all — see `sheets.doors` below. ## Door textures — `sheets.doors` `frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls. **Wired up as of 2026-08-21: frame 0 is the one and only door type ("Normal" — the editor's Door category has no other option yet) and is fully live.** The current painted sheet is 256×256 (a 4×4 grid, 16 frames), of which only frame 0 is read; the other 15 sit unused until a second door type exists (matching how the editor's Door dropdown would need a second `option` in `WolfensteinEditor.js`'s `CATEGORIES`, with the new tool id mapped to a frame index the same way `WALL_FRAME` maps wall types today — no such mapping exists yet since there's only the one type). `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) — falls back to the flat placeholder tan 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). ### 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 `drawImage`s 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. ## Guard enemy — `sheets.guard` **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. `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[]` 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-item-pistol`, `wolfenstein-item-ammo`, `wolfenstein-item-health` — pickup icons. Placeholder is 64×64; square, transparent background. Rendered at billboard scale 0.45 (`WolfensteinView._drawSprites`). - `wolfenstein-bullet` — pistol round in flight. Placeholder is 16×16, a small bright circle; rendered at scale 0.18, so keep it simple/legible at tiny sizes. - `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. ## Sound effects None wired up yet — `WolfensteinGame._onSimEvent` has hooks for every event (`weaponFired`, `meleeHit`, `enemyDied`, `enemyMelee`, `doorOpen`/`doorClose`, `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.