From 45a1ed2253a3704de7e488a571078fad678a9ca5 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Mon, 5 Jan 2026 14:46:04 +1100 Subject: [PATCH] Ensure enemies only lock onto player once they have line of sight --- src/core/types.ts | 12 +- src/engine/__tests__/simulation.test.ts | 8 +- src/engine/simulation/simulation.ts | 165 ++++++++++++++++++++---- src/rendering/DungeonRenderer.ts | 4 + src/rendering/FxRenderer.ts | 31 +++++ src/scenes/GameScene.ts | 12 +- 6 files changed, 199 insertions(+), 33 deletions(-) diff --git a/src/core/types.ts b/src/core/types.ts index 0dab8ab..44832ec 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -3,9 +3,11 @@ export type EntityId = number; export type Vec2 = { x: number; y: number }; export type Tile = number; -export type EnemyType = "rat" | "bat" | "spider"; +export type EnemyType = "rat" | "bat"; export type ActorType = "player" | EnemyType; +export type EnemyAIState = "wandering" | "alerted" | "pursuing"; + export type Action = | { type: "move"; dx: number; dy: number } | { type: "attack"; targetId: EntityId } @@ -22,7 +24,8 @@ export type SimEvent = | { type: "waited"; actorId: EntityId } | { type: "exp-collected"; actorId: EntityId; amount: number; 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 = { @@ -115,6 +118,11 @@ export interface CombatantActor extends BaseActor { stats: Stats; inventory?: Inventory; equipment?: Equipment; + + // Enemy AI state + aiState?: EnemyAIState; + alertedAt?: number; + lastKnownPlayerPos?: Vec2; } export interface CollectibleActor extends BaseActor { diff --git a/src/engine/__tests__/simulation.test.ts b/src/engine/__tests__/simulation.test.ts index 91de6eb..189aa31 100644 --- a/src/engine/__tests__/simulation.test.ts +++ b/src/engine/__tests__/simulation.test.ts @@ -77,9 +77,9 @@ describe('Combat Simulation', () => { world.tiles[3 * 10 + 4] = 4; // Wall 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", () => { @@ -92,8 +92,8 @@ describe('Combat Simulation', () => { const world = createTestWorld(actors); entityManager = new EntityManager(world); - const action = decideEnemyAction(world, enemy, player, entityManager); - expect(action).toEqual({ type: "attack", targetId: 1 }); + const decision = decideEnemyAction(world, enemy, player, entityManager); + expect(decision.action).toEqual({ type: "attack", targetId: 1 }); }); }); }); diff --git a/src/engine/simulation/simulation.ts b/src/engine/simulation/simulation.ts index 6149ec3..ae653e9 100644 --- a/src/engine/simulation/simulation.ts +++ b/src/engine/simulation/simulation.ts @@ -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)) { diff --git a/src/rendering/DungeonRenderer.ts b/src/rendering/DungeonRenderer.ts index cfe9e95..6ab265a 100644 --- a/src/rendering/DungeonRenderer.ts +++ b/src/rendering/DungeonRenderer.ts @@ -324,4 +324,8 @@ export class DungeonRenderer { showLevelUp(x: number, y: number) { this.fxRenderer.showLevelUp(x, y); } + + showAlert(x: number, y: number) { + this.fxRenderer.showAlert(x, y); + } } diff --git a/src/rendering/FxRenderer.ts b/src/rendering/FxRenderer.ts index c01aac2..6040fc3 100644 --- a/src/rendering/FxRenderer.ts +++ b/src/rendering/FxRenderer.ts @@ -188,4 +188,35 @@ export class FxRenderer { 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() + }); + } } diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 521ec98..19ae26b 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -281,11 +281,13 @@ export class GameScene extends Phaser.Scene { } else if (ev.type === "orb-spawned") { this.dungeonRenderer.spawnOrb(ev.orbId, ev.x, ev.y); } else if (ev.type === "exp-collected" && ev.actorId === this.playerId) { - this.dungeonRenderer.collectOrb(ev.actorId, ev.amount, ev.x, ev.y); - } else if (ev.type === "leveled-up" && ev.actorId === this.playerId) { - this.dungeonRenderer.showLevelUp(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) { + this.dungeonRenderer.showLevelUp(ev.x, ev.y); + } else if (ev.type === "enemy-alerted") { + this.dungeonRenderer.showAlert(ev.x, ev.y); + } + } // Check if player died