diff --git a/src/games/wolfenstein/WolfensteinLogic.js b/src/games/wolfenstein/WolfensteinLogic.js index 049c86f..9979612 100644 --- a/src/games/wolfenstein/WolfensteinLogic.js +++ b/src/games/wolfenstein/WolfensteinLogic.js @@ -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) { diff --git a/src/games/wolfenstein/WolfensteinView.js b/src/games/wolfenstein/WolfensteinView.js index 1fdae22..576362a 100644 --- a/src/games/wolfenstein/WolfensteinView.js +++ b/src/games/wolfenstein/WolfensteinView.js @@ -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) { diff --git a/tools/verifyWolfenstein.js b/tools/verifyWolfenstein.js index 583ad40..2637c08 100644 --- a/tools/verifyWolfenstein.js +++ b/tools/verifyWolfenstein.js @@ -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 }] };