wolfenstein: alert room-mates on gunfire, fix guard side-sprite flip

Add computeRooms + alertEnemiesInPlayerRoom: a gunshot now alerts every
idle guard sharing the player's room regardless of range/LOS/cone. A
door cell is always treated as a room boundary for this purpose, even
when fully open — two rooms joined only by a doorway stay distinct
("gunfire carries through the room, not through doorways").

Fix _guardFacing side-walk/side-idle flip: was `rel > 0`, read
backwards in-game, now `rel < 0`.

Cover with three verifyWolfenstein cases: same-room alert fires,
behind-closed-door does not, through-open-door does not (door forced
slide=1 / wall zeroed to exercise the slide-independent path).
This commit is contained in:
Brian Fertig 2026-08-21 20:31:39 -06:00
parent 9b3dccf4b5
commit 74f8d0c926
3 changed files with 113 additions and 3 deletions

View File

@ -359,6 +359,68 @@ function isInMeleeArc(px, py, pAngle, tx, ty, range, arcRad) {
return Math.abs(diff) <= arcRad / 2;
}
/**
* Flood-fills the map's floor cells into rooms: a maximal connected region
* of open (wallType 0) cells, where a door cell whatever its live
* open/closed `slide` always counts as a boundary, never as floor. That's
* why this can't just read `map.walls[y][x] > 0` on its own: stepDoors
* zeroes a door cell's wall-grid entry while it's fully open (see
* DOOR_WALL_TYPE's other callers), which is exactly right for
* movement/LOS/rendering but wrong here two rooms joined only by a door
* must stay two rooms, open or shut, per alertEnemiesInPlayerRoom's
* "gunfire carries through the room, not through doorways" contract. So a
* door's cell is always excluded via `doors` (permanent x/y entries, unlike
* `slide`) regardless of what the live grid says at that cell right now.
* Cheap enough (every level here is well under 1000 cells) to just
* recompute on demand rather than caching on state.
*/
function computeRooms(map, doors) {
const doorCells = new Set(doors.map((d) => `${d.x},${d.y}`));
const blocked = (x, y) => doorCells.has(`${x},${y}`) || map.walls[y][x] > 0;
const roomOf = Array.from({ length: map.height }, () => new Array(map.width).fill(-1));
let nextRoom = 0;
for (let y = 0; y < map.height; y++) {
for (let x = 0; x < map.width; x++) {
if (roomOf[y][x] !== -1 || blocked(x, y)) continue;
const id = nextRoom++;
const stack = [[x, y]];
roomOf[y][x] = id;
while (stack.length) {
const [cx, cy] = stack.pop();
for (const [nx, ny] of [[cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]]) {
if (nx < 0 || ny < 0 || nx >= map.width || ny >= map.height) continue;
if (roomOf[ny][nx] !== -1 || blocked(nx, ny)) continue;
roomOf[ny][nx] = id;
stack.push([nx, ny]);
}
}
}
}
return roomOf;
}
/**
* A gunshot alerts every idle guard sharing the player's room, regardless
* of range/LOS/facing-cone those only gate the "spotted visually" path in
* stepEnemyAI; this is "heard it," a separate trigger. Already-alert/
* chase/attack guards are left alone (nothing to escalate), and a shot
* fired from inside a door cell (roomOf has no entry for doorways
* themselves) simply alerts no one via this path.
*/
function alertEnemiesInPlayerRoom(state) {
const roomOf = computeRooms(state.map, state.doors);
const p = state.player;
const playerRoom = roomOf[Math.floor(p.y)]?.[Math.floor(p.x)] ?? -1;
if (playerRoom < 0) return;
for (const e of state.enemies) {
if (e.dead || e.state !== 'idle') continue;
if (roomOf[Math.floor(e.y)]?.[Math.floor(e.x)] === playerRoom) {
e.state = 'alert';
state.events.push({ t: 'enemyAlert', id: e.id });
}
}
}
export function fireWeapon(state, rules) {
const p = state.player;
const w = rules.weaponById[p.weapon];
@ -390,6 +452,7 @@ export function fireWeapon(state, rules) {
ownerId: 'player', x: p.x, y: p.y, angle: p.angle, weapon: w.id,
speed: w.speed, damage: w.damage, hitRadius: w.hitRadius, ttlSec: w.ttlSec, friendly: true,
});
alertEnemiesInPlayerRoom(state);
}
function spawnProjectile(state, rules, opts) {

View File

@ -341,8 +341,9 @@ export default class WolfensteinView {
* guard always shows it head-on regardless of viewing angle (attacking
* guards don't move, so the player is almost always roughly in front of
* them anyway). `flip` mirrors the single "facing right" pose for the
* left side if left/right ever reads backwards in-game, flip the sign
* on the `rel > 0` line below, nothing else needs to change.
* left side was `rel > 0` originally, but that read backwards in-game
* (2026-08-21), so it's `rel < 0` now. If it's ever wrong again, flip
* this one sign; nothing else needs to change.
*/
_guardFacing(e, camera, now) {
if (e.state === 'attack') return { frame: GUARD_FRAME.shoot, flip: false };
@ -362,7 +363,7 @@ export default class WolfensteinView {
if (absRel > (3 * Math.PI) / 4) {
return { frame: walkOn ? GUARD_FRAME.backWalk : GUARD_FRAME.backIdle, flip: false };
}
return { frame: walkOn ? GUARD_FRAME.sideWalk : GUARD_FRAME.sideIdle, flip: rel > 0 };
return { frame: walkOn ? GUARD_FRAME.sideWalk : GUARD_FRAME.sideIdle, flip: rel < 0 };
}
_ensureSprite(key, tex) {

View File

@ -278,6 +278,52 @@ section('4. Enemy AI');
check('a 2-waypoint route closes into a one-way loop, never reversing', cyclesForward, indices.slice(0, 9).join(','));
}
// Gunfire alerts every idle guard sharing the player's room
// (alertEnemiesInPlayerRoom/computeRooms), independent of range/LOS/cone
// — and independent of whether a connecting door is open or closed, since
// a door is always a room boundary for this purpose. No L.tick() calls
// anywhere in this block, so nothing but fireWeapon's new room-alert path
// can move a guard out of 'idle' here — a clean isolation from the
// ordinary spot-the-player AI in stepEnemyAI.
{
const W = 11, H = 5;
const walls = [];
for (let y = 0; y < H; y++) {
const row = [];
for (let x = 0; x < W; x++) {
const border = x === 0 || y === 0 || x === W - 1 || y === H - 1;
const divider = x === 5 && (y === 1 || y === 3); // leaves (5,2) open for the door
row.push(border || divider ? 1 : 0);
}
walls.push(row);
}
const doors = [{ x: 5, y: 2, orientation: 'vertical' }];
const twoRoomLevel = (enemyX, enemyY) => ({
width: W, height: H, cellSize: 64, walls, doors, items: [],
playerStart: { x: 2.5, y: 2.5, angle: 0 },
enemies: [{ type: 'guard', x: enemyX, y: enemyY, facing: 180 }],
exit: { x: 9.5, y: 1.5, radius: 0.6 },
});
{
const state = L.createState(twoRoomLevel(3.5, 1.5), rules); // same room (left, x 1-4)
L.fireWeapon(state, rules);
check('gunshot alerts an idle guard sharing the room', state.enemies[0].state === 'alert', state.enemies[0].state);
}
{
const state = L.createState(twoRoomLevel(7.5, 1.5), rules); // other room (right, x 6-9), door closed
L.fireWeapon(state, rules);
check('gunshot does not alert a guard behind a closed door', state.enemies[0].state === 'idle', state.enemies[0].state);
}
{
const state = L.createState(twoRoomLevel(7.5, 1.5), rules); // same, door forced fully open
state.doors[0].slide = 1; state.doors[0].target = 1;
state.map.walls[2][5] = 0;
L.fireWeapon(state, rules);
check('gunshot does not alert a guard through an open door either', state.enemies[0].state === 'idle', state.enemies[0].state);
}
}
// A dead guard stops acting and can't be hit/killed twice.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] };