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;
|
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) {
|
export function fireWeapon(state, rules) {
|
||||||
const p = state.player;
|
const p = state.player;
|
||||||
const w = rules.weaponById[p.weapon];
|
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,
|
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,
|
speed: w.speed, damage: w.damage, hitRadius: w.hitRadius, ttlSec: w.ttlSec, friendly: true,
|
||||||
});
|
});
|
||||||
|
alertEnemiesInPlayerRoom(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnProjectile(state, rules, opts) {
|
function spawnProjectile(state, rules, opts) {
|
||||||
|
|
|
||||||
|
|
@ -341,8 +341,9 @@ export default class WolfensteinView {
|
||||||
* guard always shows it head-on regardless of viewing angle (attacking
|
* 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
|
* 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
|
* them anyway). `flip` mirrors the single "facing right" pose for the
|
||||||
* left side — if left/right ever reads backwards in-game, flip the sign
|
* left side — was `rel > 0` originally, but that read backwards in-game
|
||||||
* on the `rel > 0` line below, nothing else needs to change.
|
* (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) {
|
_guardFacing(e, camera, now) {
|
||||||
if (e.state === 'attack') return { frame: GUARD_FRAME.shoot, flip: false };
|
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) {
|
if (absRel > (3 * Math.PI) / 4) {
|
||||||
return { frame: walkOn ? GUARD_FRAME.backWalk : GUARD_FRAME.backIdle, flip: false };
|
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) {
|
_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(','));
|
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.
|
// 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 }] };
|
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