12 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
— 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, 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.
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 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).
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 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.
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.