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

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