Ensure enemies only lock onto player once they have line of sight
This commit is contained in:
@@ -3,9 +3,11 @@ export type EntityId = number;
|
|||||||
export type Vec2 = { x: number; y: number };
|
export type Vec2 = { x: number; y: number };
|
||||||
|
|
||||||
export type Tile = number;
|
export type Tile = number;
|
||||||
export type EnemyType = "rat" | "bat" | "spider";
|
export type EnemyType = "rat" | "bat";
|
||||||
export type ActorType = "player" | EnemyType;
|
export type ActorType = "player" | EnemyType;
|
||||||
|
|
||||||
|
export type EnemyAIState = "wandering" | "alerted" | "pursuing";
|
||||||
|
|
||||||
export type Action =
|
export type Action =
|
||||||
| { type: "move"; dx: number; dy: number }
|
| { type: "move"; dx: number; dy: number }
|
||||||
| { type: "attack"; targetId: EntityId }
|
| { type: "attack"; targetId: EntityId }
|
||||||
@@ -22,7 +24,8 @@ export type SimEvent =
|
|||||||
| { type: "waited"; actorId: EntityId }
|
| { type: "waited"; actorId: EntityId }
|
||||||
| { type: "exp-collected"; actorId: EntityId; amount: number; x: number; y: number }
|
| { type: "exp-collected"; actorId: EntityId; amount: number; x: number; y: number }
|
||||||
| { type: "orb-spawned"; orbId: EntityId; x: number; y: number }
|
| { type: "orb-spawned"; orbId: EntityId; x: number; y: number }
|
||||||
| { type: "leveled-up"; actorId: EntityId; level: number; x: number; y: number };
|
| { type: "leveled-up"; actorId: EntityId; level: number; x: number; y: number }
|
||||||
|
| { type: "enemy-alerted"; enemyId: EntityId; x: number; y: number };
|
||||||
|
|
||||||
|
|
||||||
export type Stats = {
|
export type Stats = {
|
||||||
@@ -115,6 +118,11 @@ export interface CombatantActor extends BaseActor {
|
|||||||
stats: Stats;
|
stats: Stats;
|
||||||
inventory?: Inventory;
|
inventory?: Inventory;
|
||||||
equipment?: Equipment;
|
equipment?: Equipment;
|
||||||
|
|
||||||
|
// Enemy AI state
|
||||||
|
aiState?: EnemyAIState;
|
||||||
|
alertedAt?: number;
|
||||||
|
lastKnownPlayerPos?: Vec2;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CollectibleActor extends BaseActor {
|
export interface CollectibleActor extends BaseActor {
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ describe('Combat Simulation', () => {
|
|||||||
world.tiles[3 * 10 + 4] = 4; // Wall
|
world.tiles[3 * 10 + 4] = 4; // Wall
|
||||||
|
|
||||||
entityManager = new EntityManager(world);
|
entityManager = new EntityManager(world);
|
||||||
const action = decideEnemyAction(world, enemy, player, entityManager);
|
const decision = decideEnemyAction(world, enemy, player, entityManager);
|
||||||
|
|
||||||
expect(action.type).toBe("move");
|
expect(decision.action.type).toBe("move");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should attack if player is adjacent", () => {
|
it("should attack if player is adjacent", () => {
|
||||||
@@ -92,8 +92,8 @@ describe('Combat Simulation', () => {
|
|||||||
const world = createTestWorld(actors);
|
const world = createTestWorld(actors);
|
||||||
entityManager = new EntityManager(world);
|
entityManager = new EntityManager(world);
|
||||||
|
|
||||||
const action = decideEnemyAction(world, enemy, player, entityManager);
|
const decision = decideEnemyAction(world, enemy, player, entityManager);
|
||||||
expect(action).toEqual({ type: "attack", targetId: 1 });
|
expect(decision.action).toEqual({ type: "attack", targetId: 1 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
|
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 { findPathAStar } from "../world/pathfinding";
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
import { type EntityManager } from "../EntityManager";
|
import { type EntityManager } from "../EntityManager";
|
||||||
|
import { FOV } from "rot-js";
|
||||||
|
|
||||||
|
|
||||||
export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] {
|
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:
|
* Check if an enemy can see the player using FOV calculation
|
||||||
* - if adjacent to player, attack
|
|
||||||
* - else step toward player using greedy Manhattan
|
|
||||||
*/
|
*/
|
||||||
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 dx = player.pos.x - enemy.pos.x;
|
||||||
const dy = player.pos.y - enemy.pos.y;
|
const dy = player.pos.y - enemy.pos.y;
|
||||||
const dist = Math.abs(dx) + Math.abs(dy);
|
const dist = Math.abs(dx) + Math.abs(dy);
|
||||||
|
|
||||||
if (dist === 1) {
|
// State transitions
|
||||||
return { type: "attack", targetId: player.id };
|
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
|
// Use A* for smarter pathfinding to target
|
||||||
const dummySeen = new Uint8Array(w.width * w.height).fill(1); // Enemies "know" the map
|
const dummySeen = new Uint8Array(w.width * w.height).fill(1);
|
||||||
const path = findPathAStar(w, dummySeen, enemy.pos, player.pos, { ignoreBlockedTarget: true, ignoreSeen: true, em });
|
const path = findPathAStar(w, dummySeen, enemy.pos, targetPos, { ignoreBlockedTarget: true, ignoreSeen: true, em });
|
||||||
|
|
||||||
|
|
||||||
if (path.length >= 2) {
|
if (path.length >= 2) {
|
||||||
const next = path[1];
|
const next = path[1];
|
||||||
const adx = next.x - enemy.pos.x;
|
const adx = next.x - enemy.pos.x;
|
||||||
const ady = next.y - enemy.pos.y;
|
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
|
// Fallback to greedy if no path found
|
||||||
const options: { dx: number; dy: number }[] = [];
|
const options: { dx: number; dy: number }[] = [];
|
||||||
|
|
||||||
if (Math.abs(dx) >= Math.abs(dy)) {
|
if (Math.abs(targetDx) >= Math.abs(targetDy)) {
|
||||||
options.push({ dx: Math.sign(dx), dy: 0 });
|
options.push({ dx: Math.sign(targetDx), dy: 0 });
|
||||||
options.push({ dx: 0, dy: Math.sign(dy) });
|
options.push({ dx: 0, dy: Math.sign(targetDy) });
|
||||||
} else {
|
} else {
|
||||||
options.push({ dx: 0, dy: Math.sign(dy) });
|
options.push({ dx: 0, dy: Math.sign(targetDy) });
|
||||||
options.push({ dx: Math.sign(dx), dy: 0 });
|
options.push({ dx: Math.sign(targetDx), dy: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
options.push({ dx: -options[0].dx, dy: -options[0].dy });
|
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;
|
if (o.dx === 0 && o.dy === 0) continue;
|
||||||
const nx = enemy.pos.x + o.dx;
|
const nx = enemy.pos.x + o.dx;
|
||||||
const ny = enemy.pos.y + o.dy;
|
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 };
|
return { awaitingPlayerId: actor.id, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = decideEnemyAction(w, actor, player, em);
|
const decision = decideEnemyAction(w, actor, player, em);
|
||||||
events.push(...applyAction(w, actor.id, action, 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
|
// Check if player was killed by this action
|
||||||
if (!w.actors.has(playerId)) {
|
if (!w.actors.has(playerId)) {
|
||||||
|
|||||||
@@ -324,4 +324,8 @@ export class DungeonRenderer {
|
|||||||
showLevelUp(x: number, y: number) {
|
showLevelUp(x: number, y: number) {
|
||||||
this.fxRenderer.showLevelUp(x, y);
|
this.fxRenderer.showLevelUp(x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showAlert(x: number, y: number) {
|
||||||
|
this.fxRenderer.showAlert(x, y);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,4 +188,35 @@ export class FxRenderer {
|
|||||||
onComplete: () => text.destroy()
|
onComplete: () => text.destroy()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showAlert(x: number, y: number) {
|
||||||
|
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
const screenY = y * TILE_SIZE - 8;
|
||||||
|
|
||||||
|
const text = this.scene.add.text(screenX, screenY, "!", {
|
||||||
|
fontSize: "24px",
|
||||||
|
color: "#ffaa00",
|
||||||
|
stroke: "#000",
|
||||||
|
strokeThickness: 3,
|
||||||
|
fontStyle: "bold"
|
||||||
|
}).setOrigin(0.5, 1).setDepth(210);
|
||||||
|
|
||||||
|
// Exclamation mark stays visible for alert duration
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: text,
|
||||||
|
y: screenY - 8,
|
||||||
|
duration: 200,
|
||||||
|
yoyo: true,
|
||||||
|
repeat: 3, // Bounce a few times
|
||||||
|
ease: "Sine.inOut"
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: text,
|
||||||
|
alpha: 0,
|
||||||
|
delay: 900, // Start fading out near end of alert period
|
||||||
|
duration: 300,
|
||||||
|
onComplete: () => text.destroy()
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,11 +281,13 @@ export class GameScene extends Phaser.Scene {
|
|||||||
} else if (ev.type === "orb-spawned") {
|
} else if (ev.type === "orb-spawned") {
|
||||||
this.dungeonRenderer.spawnOrb(ev.orbId, ev.x, ev.y);
|
this.dungeonRenderer.spawnOrb(ev.orbId, ev.x, ev.y);
|
||||||
} else if (ev.type === "exp-collected" && ev.actorId === this.playerId) {
|
} else if (ev.type === "exp-collected" && ev.actorId === this.playerId) {
|
||||||
this.dungeonRenderer.collectOrb(ev.actorId, ev.amount, ev.x, ev.y);
|
this.dungeonRenderer.collectOrb(ev.actorId, ev.amount, ev.x, ev.y);
|
||||||
} else if (ev.type === "leveled-up" && ev.actorId === this.playerId) {
|
} else if (ev.type === "leveled-up" && ev.actorId === this.playerId) {
|
||||||
this.dungeonRenderer.showLevelUp(ev.x, ev.y);
|
this.dungeonRenderer.showLevelUp(ev.x, ev.y);
|
||||||
}
|
} else if (ev.type === "enemy-alerted") {
|
||||||
}
|
this.dungeonRenderer.showAlert(ev.x, ev.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Check if player died
|
// Check if player died
|
||||||
|
|||||||
Reference in New Issue
Block a user