diff --git a/assets/images/wolfenstein/doors.png b/assets/images/wolfenstein/doors.png new file mode 100644 index 0000000..ebb855e Binary files /dev/null and b/assets/images/wolfenstein/doors.png differ diff --git a/assets/images/wolfenstein/doors.psd b/assets/images/wolfenstein/doors.psd new file mode 100644 index 0000000..cbbf73d Binary files /dev/null and b/assets/images/wolfenstein/doors.psd differ diff --git a/data/wolfenstein-artwork.json b/data/wolfenstein-artwork.json index a429348..6887189 100644 --- a/data/wolfenstein-artwork.json +++ b/data/wolfenstein-artwork.json @@ -2,7 +2,8 @@ "_readme": "Drop-in art manifest. Every entry has path: null until painted — the game renders procedurally via WolfensteinArt.js until then. Fill in a path and it lazy-loads automatically via assetManifest.js's wolfenstein entry; no code changes needed. sheets.walls expects a 4-column strip (one column per wall type in WolfensteinArt.WALL_COLORS) so WolfensteinView's per-column drawImage can sample a texture-x slice per wall type.", "sheets": { "walls": { "key": "wolfenstein-walls", "path": "assets/images/wolfenstein/walls.png", "frameWidth": 64, "frameHeight": 64 }, - "guard": { "key": "wolfenstein-guard-sheet", "path": null, "frameWidth": 128, "frameHeight": 128 } + "guard": { "key": "wolfenstein-guard-sheet", "path": null, "frameWidth": 128, "frameHeight": 128 }, + "doors": { "key": "wolfenstein-doors", "path": "assets/images/wolfenstein/doors.png", "frameWidth": 64, "frameHeight": 64 } }, "artwork": [ { "key": "wolfenstein-item-pistol", "path": null }, diff --git a/src/games/wolfenstein/WolfensteinRaycaster.js b/src/games/wolfenstein/WolfensteinRaycaster.js index c2a3941..4b67d5b 100644 --- a/src/games/wolfenstein/WolfensteinRaycaster.js +++ b/src/games/wolfenstein/WolfensteinRaycaster.js @@ -13,11 +13,14 @@ // "exactly at the point dir was aimed at"). // // Door cells get real recessed-mid-plane geometry (see intersectDoorMidplane) -// when a caller opts in via `doorAware` — WolfensteinView's render raycast -// does; collision/line-of-sight/bullet callers in WolfensteinLogic don't, so -// a door stays a simple full-cell solid for gameplay purposes exactly as -// before (see stepDoors' note on why passability only flips at slide===1). -// This is a rendering-only enhancement, not a change to what's walkable. +// when a caller opts in by passing `doors` (WolfensteinView's render raycast +// does, with state.doors — the slide-open portion genuinely isn't a hit, so +// the DDA carries on through to render whatever's actually beyond); a +// collision/line-of-sight/bullet caller in WolfensteinLogic omits it, so a +// door stays a simple full-cell solid for gameplay purposes exactly as +// before (see stepDoors' note on why passability only flips at slide===1) — +// this is rendering-only, not a change to what's walkable/shootable/visible +// to AI, even though the player can now visually see further than that. const DEFAULT_MAX_STEPS = 256; @@ -41,31 +44,41 @@ export function isWallCell(map, cellX, cellY) { * walled off), not the doors[] `orientation` field, which nothing actually * sets to anything but 'vertical'. * - * Returns null when the ray's own trajectory would clear the cell sideways - * (cross into a neighboring cell) before ever reaching that mid-plane — the - * caller should then keep stepping the DDA as if this cell were transparent - * rather than treat it as a miss; stepping on lets the DDA reach the actual - * flanking wall cell and hit it at its own true (nearer, unrecessed) - * distance, which is what makes that wall's inward face visible at grazing - * angles — the two verticals of the "H," rendered as ordinary wall hits, - * not anything faked here. + * Returns null in two cases the caller treats identically — keep stepping + * the DDA as if this cell were transparent, rather than a miss: + * - the ray's own trajectory clears the cell sideways (crosses into a + * neighboring cell) before ever reaching the mid-plane at all — letting + * the DDA carry on reaches the actual flanking wall cell and hits it at + * its own true (nearer, unrecessed) distance, which is what makes that + * wall's inward face visible at grazing angles: the two verticals of the + * "H," rendered as ordinary wall hits, not anything faked here; + * - the plane crossing falls in the portion of the door that has already + * slid open (`along >= slide`, the panel having translated `slide` + * cell-widths into its pocket) — there's genuinely nothing solid there + * now, so the DDA carries on to whatever's actually beyond the doorway. + * + * For the still-covered portion, `textureX` comes back pre-shifted by + * `-slide` — the same panel-translation math, so sampling straight from it + * (see WolfensteinView._drawDoorColumn) makes the door texture visibly + * slide sideways into the wall as it opens, not just get cropped in place. */ -function intersectDoorMidplane(map, x, y, dirX, dirY, cellX, cellY) { +function intersectDoorMidplane(map, x, y, dirX, dirY, cellX, cellY, slide) { const blocksEW = isWallCell(map, cellX, cellY - 1) && isWallCell(map, cellX, cellY + 1); + let t, along; if (blocksEW) { if (Math.abs(dirX) < 1e-9) return null; - const t = (cellX + 0.5 - x) / dirX; + t = (cellX + 0.5 - x) / dirX; if (t <= 0) return null; - const along = y + t * dirY - cellY; - if (along < 0 || along > 1) return null; - return { perpDist: t, side: 0, textureX: along }; + along = y + t * dirY - cellY; + } else { + if (Math.abs(dirY) < 1e-9) return null; + t = (cellY + 0.5 - y) / dirY; + if (t <= 0) return null; + along = x + t * dirX - cellX; } - if (Math.abs(dirY) < 1e-9) return null; - const t = (cellY + 0.5 - y) / dirY; - if (t <= 0) return null; - const along = x + t * dirX - cellX; - if (along < 0 || along > 1) return null; - return { perpDist: t, side: 1, textureX: along }; + if (along < 0 || along > 1) return null; // clears the cell sideways + if (along < slide) return null; // this point has already slid open (opens from along=0 outward) + return { perpDist: t, side: blocksEW ? 0 : 1, textureX: along - slide }; } /** @@ -73,11 +86,12 @@ function intersectDoorMidplane(map, x, y, dirX, dirY, cellX, cellY) { * length; distances/`perpDist` come out scaled to that vector's own length — * until it exits the first solid cell. Returns null if it runs off the map * edge or exceeds maxSteps without a hit (should never happen inside a - * validated closed level). `doorAware` opts into recessed door-plane - * geometry (see intersectDoorMidplane) instead of treating a door cell as an - * ordinary flush solid. + * validated closed level). Passing `doors` (an array of `{x, y, slide}`, as + * on WolfensteinLogic's sim state) opts a door cell into recessed, + * partially-passable mid-plane geometry (see intersectDoorMidplane) instead + * of treating it as an ordinary flush full-cell solid. */ -export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS, doorAware = false) { +export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS, doors = null) { let mapX = Math.floor(x); let mapY = Math.floor(y); @@ -100,10 +114,11 @@ export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS, doo const wallType = map.walls[mapY][mapX]; if (wallType === 0) continue; - if (doorAware && wallType === DOOR_WALL_TYPE) { - const doorHit = intersectDoorMidplane(map, x, y, dirX, dirY, mapX, mapY); + if (doors && wallType === DOOR_WALL_TYPE) { + const door = doors.find((d) => d.x === mapX && d.y === mapY); + const doorHit = intersectDoorMidplane(map, x, y, dirX, dirY, mapX, mapY, door?.slide ?? 0); if (doorHit) return { ...doorHit, mapX, mapY, wallType }; - continue; // ray grazes past this door cell without reaching its mid-plane + continue; // ray grazes past this door cell, or passes through its already-open portion } const perpDist = side === 0 ? (sideDistX - deltaDistX) : (sideDistY - deltaDistY); @@ -125,8 +140,8 @@ export function makeCamera(x, y, angle, fov) { return { x, y, angle, dirX, dirY, planeX: -dirY * planeScale, planeY: dirX * planeScale }; } -/** One castRay per screen column, camera-space (fisheye-free). `doorAware` — see castRay — is what WolfensteinView passes to get recessed door geometry. */ -export function castColumns(map, camera, numColumns, doorAware = false) { +/** One castRay per screen column, camera-space (fisheye-free). `doors` — see castRay — is what WolfensteinView passes (state.doors) to get recessed, slide-aware door geometry. */ +export function castColumns(map, camera, numColumns, doors = null) { const { x, y, dirX, dirY, planeX, planeY } = camera; const maxSteps = fullMapSteps(map); const out = new Array(numColumns); @@ -134,7 +149,7 @@ export function castColumns(map, camera, numColumns, doorAware = false) { const cameraX = (2 * col) / numColumns - 1; const rdx = dirX + planeX * cameraX; const rdy = dirY + planeY * cameraX; - out[col] = castRay(map, x, y, rdx, rdy, maxSteps, doorAware); + out[col] = castRay(map, x, y, rdx, rdy, maxSteps, doors); } return out; } diff --git a/src/games/wolfenstein/WolfensteinView.js b/src/games/wolfenstein/WolfensteinView.js index 9f3b398..1a136d1 100644 --- a/src/games/wolfenstein/WolfensteinView.js +++ b/src/games/wolfenstein/WolfensteinView.js @@ -27,17 +27,12 @@ export const VIEW_H = 940; export const NUM_COLUMNS = 480; // wallType -> frame index in the `wolfenstein-walls` sheet, matching -// WolfensteinArt.WALL_COLORS' order. No entry for type 9 (door) — doors are -// never texture-sampled, they get their own recessed-slide treatment in -// _drawDoorColumn regardless of whether painted wall art exists. +// WolfensteinArt.WALL_COLORS' order. No entry for type 9 (door) — doors +// sample their own `wolfenstein-doors` sheet (frame 0) via _drawDoorColumn, +// which also handles their recessed/sliding geometry — not part of the +// generic per-column wall-texture path below at all. const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3 }; -// A dark, unshaded fill for the portion of a door that's already slid past — -// meant to read as a shadowed pocket the panel has receded into, not more -// wall. Kept flat (no side/fog shading) so it reads as a distinct material, -// not just a darker version of the door itself. -const DOOR_SOCKET_COLOR = '#141018'; - export default class WolfensteinView { constructor(scene, rules) { this.scene = scene; @@ -57,6 +52,8 @@ export default class WolfensteinView { this.wallTexture = scene.textures.exists('wolfenstein-walls') ? scene.textures.get('wolfenstein-walls') : null; + this.doorTexture = scene.textures.exists('wolfenstein-doors') + ? scene.textures.get('wolfenstein-doors') : null; } render(state, camera) { @@ -71,7 +68,7 @@ export default class WolfensteinView { ctx.fillStyle = '#4a4a4a'; ctx.fillRect(0, VIEW_H / 2, VIEW_W, VIEW_H / 2); - const cols = castColumns(map, camera, NUM_COLUMNS, true); + const cols = castColumns(map, camera, NUM_COLUMNS, doors); for (let i = 0; i < NUM_COLUMNS; i++) { const hit = cols[i]; this.depthBuffer[i] = hit ? hit.perpDist : Infinity; @@ -88,7 +85,7 @@ export default class WolfensteinView { const h = Math.ceil(drawEnd - drawStart); if (hit.wallType === DOOR_WALL_TYPE) { - this._drawDoorColumn(ctx, x, y, w, h, hit, doors); + this._drawDoorColumn(ctx, x, y, w, h, hit); continue; } @@ -117,29 +114,43 @@ export default class WolfensteinView { } /** - * hit.perpDist already IS the recessed mid-plane distance here — the - * render raycast opts into door-aware geometry (see castColumns(..., - * true) above and WolfensteinRaycaster's intersectDoorMidplane), so this - * column's (x,y,w,h), computed by the caller exactly like any other wall, - * is already correctly smaller/farther than a flush wall would be. A - * grazing column whose ray clears the door cell sideways instead hits the - * real flanking wall cell at ITS true distance via the normal wall path - * above — that's what makes the wall's inward face visible next to the - * door, the two verticals of the "H." Nothing extra to fake here. - * - * `textureX` is where this column's ray crosses the door's face (0..1 - * along it, computed at the mid-plane); a door slides such that the low- - * textureX edge stays put and the far edge recedes first, so as `slide` - * climbs, columns flip from "still there" to "already receded" in - * textureX order — the door visibly sliding sideways into its socket. + * By the time a column gets here, the raycaster (see castColumns(..., + * doors) above and WolfensteinRaycaster's intersectDoorMidplane) has + * already resolved everything door-specific: this hit only exists because + * that particular point on the door is still covering the doorway, at its + * true recessed mid-plane distance — an already-open point along the same + * ray instead passes straight through to whatever's actually beyond + * (rendered by the ordinary wall path, not this method at all), which is + * how you end up able to see past the door as it slides open. `textureX` + * comes back already shifted by `-slide`, so sampling it straight makes + * the door texture visibly slide sideways into the wall, not just crop in + * place. So this is really just "draw a wall column, but from the + * `wolfenstein-doors` sheet" — same technique as the main wall path + * (source-texel column, vertical crop for close-up magnification, + * multiply-shaded), falling back to the flat placeholder tan if no + * sheet is loaded. */ - _drawDoorColumn(ctx, x, y, w, h, hit, doors) { - const door = doors.find((d) => d.x === hit.mapX && d.y === hit.mapY); - const visibleFrac = 1 - (door?.slide ?? 0); - ctx.fillStyle = hit.textureX < visibleFrac - ? shadeColor(WALL_COLORS[DOOR_WALL_TYPE] ?? 0xb08040, hit) - : DOOR_SOCKET_COLOR; + _drawDoorColumn(ctx, x, y, w, h, hit) { + const frame = this.doorTexture?.frames[0]; + if (!frame) { + ctx.fillStyle = shadeColor(WALL_COLORS[DOOR_WALL_TYPE] ?? 0xb08040, hit); + ctx.fillRect(x, y, w, h); + return; + } + + const lineHeight = VIEW_H / hit.perpDist; + const rawStart = -lineHeight / 2 + VIEW_H / 2; + const rawEnd = lineHeight / 2 + VIEW_H / 2; + const drawStart = Math.max(0, rawStart); + const drawEnd = Math.min(VIEW_H, rawEnd); + const srcX = frame.cutX + Math.min(frame.width - 1, Math.floor(hit.textureX * frame.width)); + const srcY = frame.cutY + ((drawStart - rawStart) / lineHeight) * frame.height; + const srcH = ((drawEnd - drawStart) / lineHeight) * frame.height; + ctx.drawImage(frame.source.image, srcX, srcY, 1, srcH, x, y, w, h); + ctx.globalCompositeOperation = 'multiply'; + ctx.fillStyle = shadeGrey(hit); ctx.fillRect(x, y, w, h); + ctx.globalCompositeOperation = 'source-over'; } _drawSprites(state, camera) { diff --git a/src/games/wolfenstein/sprites.md b/src/games/wolfenstein/sprites.md index 4c1102e..9def9e9 100644 --- a/src/games/wolfenstein/sprites.md +++ b/src/games/wolfenstein/sprites.md @@ -13,28 +13,41 @@ loaded as a plain Phaser spritesheet (frames read left-to-right, top-to-bottom 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 — `WolfensteinView._drawDoorColumn` -(as of 2026-08-21) handles type 9 as a special case before the texture path -even runs, rendering the flat placeholder tan color (`WALL_COLORS[9]`) for -whatever fraction of the door hasn't yet slid open, and a flat dark "socket" -fill for the rest (see the sliding-door doc below) — so a 5th sheet frame -wouldn't currently be read even if added. Painting a real door texture means -teaching `_drawDoorColumn` to sample a frame the same way the wall path -does, not just adding a 320×64 sheet. +are **not** part of this sheet at all — see `sheets.doors` below. -### Sliding, recessed doors +## 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). That's unrelated to the visual, though: doors also render -recessed to the middle of the wall's depth — real geometry, not a flat-color -trick — via `WolfensteinRaycaster.js`'s `intersectDoorMidplane`, which -`WolfensteinView`'s render-only raycast opts into with `castColumns(..., -true)` (the `doorAware` flag; every gameplay raycast in `WolfensteinLogic.js` -omits it and still sees a door as an ordinary flush full-cell solid, so this -never touches what's actually walkable/shootable). +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 @@ -42,25 +55,34 @@ 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. Critically, a ray whose -trajectory would clear the door cell sideways (cross into a neighboring -cell) *before* reaching that mid-plane isn't given a fake hit at all — -`castRay` just keeps stepping the DDA, so the ray goes on to hit the actual -flanking wall cell at *its own* true, unrecessed distance. That's what -produces the "H" shape: the door is 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 (see -git history around 2026-08-21 for the script) showing a symmetric run of -door hits at the recessed distance, flanked on both sides by wall hits at -their own, nearer distances, with no discontinuity at the transition. +0.5 relative to where a flush wall would sit. -`_drawDoorColumn` itself is now simple: `hit.perpDist`/`(x,y,w,h)` already -reflect the recessed mid-plane (computed by the caller exactly like any -other wall), so it only has to pick door-texture-tan vs. dark socket color -per column, using `hit.textureX` (now measured at the mid-plane, 0..1 along -the door's face) against `1 - slide` — the same left-to-right slide-open -wipe as before, just riding on correct geometry instead of a projection -trick. +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