Ensure enemies only lock onto player once they have line of sight

This commit is contained in:
Peter Stockings
2026-01-05 14:46:04 +11:00
parent dba0f054db
commit 45a1ed2253
6 changed files with 199 additions and 33 deletions

View File

@@ -1,9 +1,10 @@
import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
import { isBlocked } from "../world/world-logic";
import { isBlocked, inBounds, isWall } from "../world/world-logic";
import { findPathAStar } from "../world/pathfinding";
import { GAME_CONFIG } from "../../core/config/GameConfig";
import { type EntityManager } from "../EntityManager";
import { FOV } from "rot-js";
export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] {
@@ -222,40 +223,148 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
/**
* Very basic enemy AI:
* - if adjacent to player, attack
* - else step toward player using greedy Manhattan
* Check if an enemy can see the player using FOV calculation
*/
export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor, em?: EntityManager): Action {
function canEnemySeePlayer(w: World, enemy: CombatantActor, player: CombatantActor): boolean {
const viewRadius = 8; // Enemy vision range
let canSee = false;
const fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
if (!inBounds(w, x, y)) return false;
return !isWall(w, x, y);
});
fov.compute(enemy.pos.x, enemy.pos.y, viewRadius, (x: number, y: number) => {
if (x === player.pos.x && y === player.pos.y) {
canSee = true;
}
});
return canSee;
}
/**
* Get a random wander move for an enemy
*/
function getRandomWanderMove(w: World, enemy: CombatantActor, em?: EntityManager): Action {
const directions = [
{ dx: 0, dy: -1 }, // up
{ dx: 0, dy: 1 }, // down
{ dx: -1, dy: 0 }, // left
{ dx: 1, dy: 0 }, // right
];
// Shuffle directions
for (let i = directions.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[directions[i], directions[j]] = [directions[j], directions[i]];
}
// Try each direction, return first valid one
for (const dir of directions) {
const nx = enemy.pos.x + dir.dx;
const ny = enemy.pos.y + dir.dy;
if (!isBlocked(w, nx, ny, em)) {
return { type: "move", ...dir };
}
}
// If no valid move, wait
return { type: "wait" };
}
/**
* Enemy AI with state machine:
* - Wandering: Random movement when can't see player
* - Alerted: Brief period after spotting player (shows "!")
* - Pursuing: Chase player while in FOV or toward last known position
*/
export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor, em?: EntityManager): { action: Action; justAlerted: boolean } {
// Initialize AI state if not set
if (!enemy.aiState) {
enemy.aiState = "wandering";
}
const canSee = canEnemySeePlayer(w, enemy, player);
const dx = player.pos.x - enemy.pos.x;
const dy = player.pos.y - enemy.pos.y;
const dist = Math.abs(dx) + Math.abs(dy);
if (dist === 1) {
return { type: "attack", targetId: player.id };
// State transitions
let justAlerted = false;
if (canSee && enemy.aiState === "wandering") {
// Spotted player! Transition to alerted state
enemy.aiState = "alerted";
enemy.alertedAt = Date.now();
enemy.lastKnownPlayerPos = { ...player.pos };
justAlerted = true;
} else if (enemy.aiState === "alerted") {
// Check if alert period is over (1 second = 1000ms)
const alertDuration = 1000;
if (Date.now() - (enemy.alertedAt || 0) > alertDuration) {
enemy.aiState = "pursuing";
}
} else if (enemy.aiState === "pursuing") {
if (canSee) {
// Update last known position
enemy.lastKnownPlayerPos = { ...player.pos };
} else {
// Lost sight - check if we've reached last known position
if (enemy.lastKnownPlayerPos) {
const distToLastKnown = Math.abs(enemy.pos.x - enemy.lastKnownPlayerPos.x) +
Math.abs(enemy.pos.y - enemy.lastKnownPlayerPos.y);
if (distToLastKnown <= 1) {
// Reached last known position, return to wandering
enemy.aiState = "wandering";
enemy.lastKnownPlayerPos = undefined;
}
} else {
// No last known position, return to wandering
enemy.aiState = "wandering";
}
}
}
// Behavior based on current state
if (enemy.aiState === "wandering") {
return { action: getRandomWanderMove(w, enemy, em), justAlerted };
}
if (enemy.aiState === "alerted") {
// During alert, stay still (or could do small movement)
return { action: { type: "wait" }, justAlerted };
}
// Pursuing state - chase player or last known position
const targetPos = canSee ? player.pos : (enemy.lastKnownPlayerPos || player.pos);
const targetDx = targetPos.x - enemy.pos.x;
const targetDy = targetPos.y - enemy.pos.y;
// If adjacent to player, attack
if (dist === 1 && canSee) {
return { action: { type: "attack", targetId: player.id }, justAlerted };
}
// Use A* for smarter pathfinding
const dummySeen = new Uint8Array(w.width * w.height).fill(1); // Enemies "know" the map
const path = findPathAStar(w, dummySeen, enemy.pos, player.pos, { ignoreBlockedTarget: true, ignoreSeen: true, em });
// Use A* for smarter pathfinding to target
const dummySeen = new Uint8Array(w.width * w.height).fill(1);
const path = findPathAStar(w, dummySeen, enemy.pos, targetPos, { ignoreBlockedTarget: true, ignoreSeen: true, em });
if (path.length >= 2) {
const next = path[1];
const adx = next.x - enemy.pos.x;
const ady = next.y - enemy.pos.y;
return { type: "move", dx: adx, dy: ady };
return { action: { type: "move", dx: adx, dy: ady }, justAlerted };
}
// Fallback to greedy if no path found
const options: { dx: number; dy: number }[] = [];
if (Math.abs(dx) >= Math.abs(dy)) {
options.push({ dx: Math.sign(dx), dy: 0 });
options.push({ dx: 0, dy: Math.sign(dy) });
if (Math.abs(targetDx) >= Math.abs(targetDy)) {
options.push({ dx: Math.sign(targetDx), dy: 0 });
options.push({ dx: 0, dy: Math.sign(targetDy) });
} else {
options.push({ dx: 0, dy: Math.sign(dy) });
options.push({ dx: Math.sign(dx), dy: 0 });
options.push({ dx: 0, dy: Math.sign(targetDy) });
options.push({ dx: Math.sign(targetDx), dy: 0 });
}
options.push({ dx: -options[0].dx, dy: -options[0].dy });
@@ -264,9 +373,10 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
if (o.dx === 0 && o.dy === 0) continue;
const nx = enemy.pos.x + o.dx;
const ny = enemy.pos.y + o.dy;
if (!isBlocked(w, nx, ny)) return { type: "move", dx: o.dx, dy: o.dy };
if (!isBlocked(w, nx, ny, em)) return { action: { type: "move", dx: o.dx, dy: o.dy }, justAlerted };
}
return { type: "wait" };
return { action: { type: "wait" }, justAlerted };
}
/**
@@ -299,8 +409,19 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityMan
return { awaitingPlayerId: actor.id, events };
}
const action = decideEnemyAction(w, actor, player, em);
events.push(...applyAction(w, actor.id, action, em));
const decision = decideEnemyAction(w, actor, player, em);
// Emit alert event if enemy just spotted player
if (decision.justAlerted) {
events.push({
type: "enemy-alerted",
enemyId: actor.id,
x: actor.pos.x,
y: actor.pos.y
});
}
events.push(...applyAction(w, actor.id, decision.action, em));
// Check if player was killed by this action
if (!w.actors.has(playerId)) {