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:
parent
9b3dccf4b5
commit
74f8d0c926
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }] };
|
||||
|
|
|
|||
Loading…
Reference in New Issue