Add real door textures with sliding-see-through rendering

The raycaster's door geometry now takes the live `doors[]` state instead of a boolean flag, so partially-open door cells let render rays pass through the already-slid portion to show what's genuinely beyond, rather than painting a flat "socket" fill. `intersectDoorMidplane` returns a slide-shifted `textureX` so the door texture visibly translates into the wall as it opens.

`WolfensteinView` samples the new `wolfenstein-doors` sheet the same way it does walls (source-texel column, vertical crop, multiply shading), falling back to the flat tan placeholder. Added the doors.png/.psd assets and a `sheets.doors` entry in the art manifest. Updated sprites.md to document the new sheet, the see-through behavior, and that this stays render-only (gameplay raycasts are unaffected).
This commit is contained in:
Brian Fertig 2026-08-21 17:54:36 -06:00
parent a336376a93
commit f6081de0a8
6 changed files with 150 additions and 101 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

View File

@ -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 },

View File

@ -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;
const t = (cellY + 0.5 - y) / dirY;
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 };
along = x + t * dirX - cellX;
}
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;
}

View File

@ -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) {

View File

@ -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