Close door after walking through again, and add more test coverage
This commit is contained in:
39
src/core/__tests__/math.test.ts
Normal file
39
src/core/__tests__/math.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { seededRandom, manhattan, lerp } from '../math';
|
||||
|
||||
describe('Math Utilities', () => {
|
||||
describe('seededRandom', () => {
|
||||
it('should return consistent results for the same seed', () => {
|
||||
const rng1 = seededRandom(12345);
|
||||
const rng2 = seededRandom(12345);
|
||||
|
||||
expect(rng1()).toBe(rng2());
|
||||
expect(rng1()).toBe(rng2());
|
||||
expect(rng1()).toBe(rng2());
|
||||
});
|
||||
|
||||
it('should return different results for different seeds', () => {
|
||||
const rng1 = seededRandom(12345);
|
||||
const rng2 = seededRandom(67890);
|
||||
|
||||
expect(rng1()).not.toBe(rng2());
|
||||
});
|
||||
});
|
||||
|
||||
describe('manhattan', () => {
|
||||
it('should calculate correct distance', () => {
|
||||
expect(manhattan({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(7);
|
||||
expect(manhattan({ x: 1, y: 1 }, { x: 4, y: 5 })).toBe(7);
|
||||
expect(manhattan({ x: -1, y: -1 }, { x: -2, y: -2 })).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lerp', () => {
|
||||
it('should interpolate correctly', () => {
|
||||
expect(lerp(0, 10, 0.5)).toBe(5);
|
||||
expect(lerp(0, 10, 0)).toBe(0);
|
||||
expect(lerp(0, 10, 1)).toBe(10);
|
||||
expect(lerp(10, 20, 0.5)).toBe(15);
|
||||
});
|
||||
});
|
||||
});
|
||||
54
src/core/__tests__/terrain.test.ts
Normal file
54
src/core/__tests__/terrain.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
TileType,
|
||||
isBlocking,
|
||||
isDestructible,
|
||||
blocksSight,
|
||||
getDestructionResult,
|
||||
isDestructibleByWalk
|
||||
} from '../terrain';
|
||||
|
||||
describe('Terrain', () => {
|
||||
describe('Tile Definitions', () => {
|
||||
it('should correctly identify blocking tiles', () => {
|
||||
expect(isBlocking(TileType.WALL)).toBe(true);
|
||||
expect(isBlocking(TileType.WALL_DECO)).toBe(true);
|
||||
expect(isBlocking(TileType.WATER)).toBe(true);
|
||||
|
||||
expect(isBlocking(TileType.EMPTY)).toBe(false);
|
||||
expect(isBlocking(TileType.GRASS)).toBe(false);
|
||||
expect(isBlocking(TileType.EXIT)).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly identify destructible tiles', () => {
|
||||
expect(isDestructible(TileType.GRASS)).toBe(true);
|
||||
expect(isDestructible(TileType.DOOR_CLOSED)).toBe(true);
|
||||
|
||||
expect(isDestructible(TileType.WALL)).toBe(false);
|
||||
expect(isDestructible(TileType.EMPTY)).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly identify tiles blocking sight', () => {
|
||||
expect(blocksSight(TileType.WALL)).toBe(true);
|
||||
expect(blocksSight(TileType.WALL_DECO)).toBe(true);
|
||||
expect(blocksSight(TileType.DOOR_CLOSED)).toBe(true);
|
||||
expect(blocksSight(TileType.GRASS)).toBe(true); // Grass blocks vision in this game logic
|
||||
|
||||
expect(blocksSight(TileType.EMPTY)).toBe(false);
|
||||
expect(blocksSight(TileType.EXIT)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return correct destruction result', () => {
|
||||
expect(getDestructionResult(TileType.GRASS)).toBe(TileType.GRASS_SAPLINGS);
|
||||
expect(getDestructionResult(TileType.DOOR_CLOSED)).toBe(TileType.DOOR_OPEN);
|
||||
|
||||
expect(getDestructionResult(TileType.WALL)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should correctly identify tiles destructible by walk', () => {
|
||||
expect(isDestructibleByWalk(TileType.GRASS)).toBe(true);
|
||||
expect(isDestructibleByWalk(TileType.DOOR_CLOSED)).toBe(true);
|
||||
expect(isDestructibleByWalk(TileType.WALL)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
12
src/core/__tests__/utils.test.ts
Normal file
12
src/core/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { key } from '../utils';
|
||||
|
||||
describe('Utils', () => {
|
||||
describe('key', () => {
|
||||
it('should generate correct key string', () => {
|
||||
expect(key(1, 2)).toBe('1,2');
|
||||
expect(key(0, 0)).toBe('0,0');
|
||||
expect(key(-5, 10)).toBe('-5,10');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,7 @@ export const TILE_DEFINITIONS: Record<number, TileBehavior> = {
|
||||
[TileType.EXIT]: { id: TileType.EXIT, isBlocking: false, isDestructible: false },
|
||||
[TileType.WATER]: { id: TileType.WATER, isBlocking: true, isDestructible: false },
|
||||
[TileType.DOOR_CLOSED]: { id: TileType.DOOR_CLOSED, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: true, destructsTo: TileType.DOOR_OPEN },
|
||||
[TileType.DOOR_OPEN]: { id: TileType.DOOR_OPEN, isBlocking: false, isDestructible: false }
|
||||
[TileType.DOOR_OPEN]: { id: TileType.DOOR_OPEN, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: false, destructsTo: TileType.DOOR_CLOSED }
|
||||
};
|
||||
|
||||
export function isBlocking(tile: number): boolean {
|
||||
|
||||
@@ -6,7 +6,7 @@ export type Tile = number;
|
||||
export type EnemyType = "rat" | "bat";
|
||||
export type ActorType = "player" | EnemyType;
|
||||
|
||||
export type EnemyAIState = "wandering" | "alerted" | "pursuing";
|
||||
export type EnemyAIState = "wandering" | "alerted" | "pursuing" | "searching";
|
||||
|
||||
export type Action =
|
||||
| { type: "move"; dx: number; dy: number }
|
||||
|
||||
@@ -174,5 +174,53 @@ describe('World Generator', () => {
|
||||
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies5.length;
|
||||
expect(avgHp5).toBeGreaterThan(avgHp1);
|
||||
});
|
||||
it('should generate doors on dungeon floors', () => {
|
||||
const runState = {
|
||||
stats: {
|
||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||
passiveNodes: []
|
||||
},
|
||||
inventory: { gold: 0, items: [] }
|
||||
};
|
||||
|
||||
// Generate a few worlds to ensure we hit the 50% door chance at least once
|
||||
let foundDoor = false;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const { world } = generateWorld(1, runState); // Floor 1 is Uniform (Dungeon)
|
||||
if (world.tiles.some(t => t === 5 || t === 6)) { // 5=DOOR_CLOSED, 6=DOOR_OPEN
|
||||
foundDoor = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(foundDoor).toBe(true);
|
||||
});
|
||||
|
||||
it('should ensure player spawns on safe tile (not grass)', () => {
|
||||
const runState = {
|
||||
stats: {
|
||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||
passiveNodes: []
|
||||
},
|
||||
inventory: { gold: 0, items: [] }
|
||||
};
|
||||
|
||||
// Generate multiple worlds to stress test spawn placement
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const { world, playerId } = generateWorld(1, runState);
|
||||
const player = world.actors.get(playerId)!;
|
||||
|
||||
// Check tile under player
|
||||
const tileIdx = player.pos.y * world.width + player.pos.x;
|
||||
const tile = world.tiles[tileIdx];
|
||||
|
||||
// Should be EMPTY (1), specifically NOT GRASS (15) which blocks vision
|
||||
expect(tile).toBe(1); // TileType.EMPTY
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
83
src/engine/__tests__/pathfinding.test.ts
Normal file
83
src/engine/__tests__/pathfinding.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { findPathAStar } from '../world/pathfinding';
|
||||
import { type World } from '../../core/types';
|
||||
import { TileType } from '../../core/terrain';
|
||||
|
||||
describe('Pathfinding', () => {
|
||||
const createTestWorld = (width: number, height: number, tileType: number = TileType.EMPTY): World => ({
|
||||
width,
|
||||
height,
|
||||
tiles: new Array(width * height).fill(tileType),
|
||||
actors: new Map(),
|
||||
exit: { x: 0, y: 0 }
|
||||
});
|
||||
|
||||
it('should find a path between two reachable points', () => {
|
||||
const world = createTestWorld(10, 10);
|
||||
const seen = new Uint8Array(100).fill(1);
|
||||
|
||||
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
||||
|
||||
expect(path.length).toBe(4); // 0,0 -> 0,1 -> 0,2 -> 0,3
|
||||
expect(path[0]).toEqual({ x: 0, y: 0 });
|
||||
expect(path[3]).toEqual({ x: 0, y: 3 });
|
||||
});
|
||||
|
||||
it('should return empty array if target is a wall', () => {
|
||||
const world = createTestWorld(10, 10);
|
||||
world.tiles[30] = TileType.WALL; // Wall at 0,3
|
||||
const seen = new Uint8Array(100).fill(1);
|
||||
|
||||
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
||||
|
||||
expect(path).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array if no path exists', () => {
|
||||
const world = createTestWorld(10, 10);
|
||||
// Create a wall blockage
|
||||
for(let x=0; x<10; x++) world.tiles[10 + x] = TileType.WALL;
|
||||
|
||||
const seen = new Uint8Array(100).fill(1);
|
||||
|
||||
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 5 });
|
||||
|
||||
expect(path).toEqual([]);
|
||||
});
|
||||
|
||||
it('should respect ignoreBlockedTarget option', () => {
|
||||
const world = createTestWorld(10, 10);
|
||||
// Place an actor at target
|
||||
world.actors.set(1, { id: 1, pos: { x: 0, y: 3 }, type: 'rat' } as any);
|
||||
|
||||
const seen = new Uint8Array(100).fill(1);
|
||||
|
||||
// Without option, it should be blocked (because actor is there)
|
||||
// Wait, default pathfinding might treat actors as blocking unless specified.
|
||||
// Let's check `isBlocked` usage in `pathfinding.ts`.
|
||||
// It calls `isBlocked` which checks actors.
|
||||
|
||||
// However, findPathAStar has logic:
|
||||
// if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.em)) return [];
|
||||
|
||||
const pathBlocked = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
||||
expect(pathBlocked).toEqual([]);
|
||||
|
||||
const pathIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreBlockedTarget: true });
|
||||
expect(pathIgnored.length).toBeGreaterThan(0);
|
||||
expect(pathIgnored[pathIgnored.length - 1]).toEqual({ x: 0, y: 3 });
|
||||
});
|
||||
|
||||
it('should respect ignoreSeen option', () => {
|
||||
const world = createTestWorld(10, 10);
|
||||
const seen = new Uint8Array(100).fill(0); // Nothing seen
|
||||
|
||||
// Without ignoreSeen, should fail because target/path is unseen
|
||||
const pathUnseen = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
||||
expect(pathUnseen).toEqual([]);
|
||||
|
||||
// With ignoreSeen, should succeed
|
||||
const pathSeenIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreSeen: true });
|
||||
expect(pathSeenIgnored.length).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { applyAction, decideEnemyAction } from '../simulation/simulation';
|
||||
import { applyAction, decideEnemyAction, stepUntilPlayerTurn } from '../simulation/simulation';
|
||||
import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
|
||||
import { EntityManager } from '../EntityManager';
|
||||
|
||||
@@ -126,5 +126,115 @@ describe('Combat Simulation', () => {
|
||||
const decision = decideEnemyAction(world, enemy, player, entityManager);
|
||||
expect(decision.action).toEqual({ type: "attack", targetId: 1 });
|
||||
});
|
||||
|
||||
it("should transition to alerted when spotting player", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats() } as any;
|
||||
const enemy = {
|
||||
id: 2,
|
||||
category: "combatant",
|
||||
isPlayer: false,
|
||||
pos: { x: 0, y: 0 },
|
||||
stats: createTestStats(),
|
||||
aiState: "wandering"
|
||||
} as any;
|
||||
actors.set(1, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(actors);
|
||||
|
||||
const decision = decideEnemyAction(world, enemy, player, new EntityManager(world));
|
||||
|
||||
expect(enemy.aiState).toBe("alerted");
|
||||
expect(decision.justAlerted).toBe(true);
|
||||
});
|
||||
|
||||
it("should transition from pursuing to searching when sight is lost", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
// Player far away (unseen)
|
||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 9, y: 9 }, stats: createTestStats() } as any;
|
||||
const enemy = {
|
||||
id: 2,
|
||||
category: "combatant",
|
||||
isPlayer: false,
|
||||
pos: { x: 0, y: 0 },
|
||||
stats: createTestStats(),
|
||||
aiState: "pursuing", // Currently pursuing
|
||||
lastKnownPlayerPos: { x: 5, y: 5 }
|
||||
} as any;
|
||||
actors.set(1, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(actors);
|
||||
|
||||
// Should switch to searching because can't see player
|
||||
decideEnemyAction(world, enemy, player, new EntityManager(world));
|
||||
|
||||
expect(enemy.aiState).toBe("searching");
|
||||
});
|
||||
|
||||
it("should transition from searching to alerted when sight regained", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
// Player adjacent (visible)
|
||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 1, y: 0 }, stats: createTestStats() } as any;
|
||||
const enemy = {
|
||||
id: 2,
|
||||
category: "combatant",
|
||||
isPlayer: false,
|
||||
pos: { x: 0, y: 0 },
|
||||
stats: createTestStats(),
|
||||
aiState: "searching",
|
||||
lastKnownPlayerPos: { x: 5, y: 5 }
|
||||
} as any;
|
||||
actors.set(1, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(actors);
|
||||
|
||||
const decision = decideEnemyAction(world, enemy, player, new EntityManager(world));
|
||||
|
||||
expect(enemy.aiState).toBe("alerted");
|
||||
expect(decision.justAlerted).toBe(true);
|
||||
});
|
||||
|
||||
it("should transition from searching to wandering when reached target", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
// Player far away (unseen) - Manhattan dist > 8
|
||||
// Enemy at 9,9. Player at 0,0. Dist = 18.
|
||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats() } as any;
|
||||
const enemy = {
|
||||
id: 2,
|
||||
category: "combatant",
|
||||
isPlayer: false,
|
||||
pos: { x: 9, y: 9 }, // At target
|
||||
stats: createTestStats(),
|
||||
aiState: "searching",
|
||||
lastKnownPlayerPos: { x: 9, y: 9 }
|
||||
} as any;
|
||||
actors.set(1, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(actors);
|
||||
|
||||
decideEnemyAction(world, enemy, player, new EntityManager(world));
|
||||
|
||||
expect(enemy.aiState).toBe("wandering");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepUntilPlayerTurn", () => {
|
||||
it("should process enemy turns", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
// Player is slow, enemy is fast. Enemy should move before player returns.
|
||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats() } as any;
|
||||
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, speed: 100, stats: createTestStats() } as any;
|
||||
|
||||
actors.set(1, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(actors);
|
||||
const em = new EntityManager(world);
|
||||
|
||||
const result = stepUntilPlayerTurn(world, 1, em);
|
||||
|
||||
// Enemy should have taken at least one action
|
||||
expect(result.events.length).toBeGreaterThan(0);
|
||||
expect(result.awaitingPlayerId).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { idx, inBounds, isWall, isBlocked } from '../world/world-logic';
|
||||
import { idx, inBounds, isWall, isBlocked, tryDestructTile, isPlayerOnExit } from '../world/world-logic';
|
||||
import { type World, type Tile } from '../../core/types';
|
||||
import { TileType } from '../../core/terrain';
|
||||
|
||||
@@ -114,4 +114,65 @@ describe('World Utilities', () => {
|
||||
expect(isBlocked(world, 10, 10)).toBe(true);
|
||||
});
|
||||
});
|
||||
describe('tryDestructTile', () => {
|
||||
it('should destruct a destructible tile', () => {
|
||||
const tiles = new Array(100).fill(TileType.EMPTY);
|
||||
tiles[0] = TileType.GRASS;
|
||||
const world = createTestWorld(10, 10, tiles);
|
||||
|
||||
const result = tryDestructTile(world, 0, 0);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(world.tiles[0]).toBe(TileType.GRASS_SAPLINGS);
|
||||
});
|
||||
|
||||
it('should not destruct a non-destructible tile', () => {
|
||||
const tiles = new Array(100).fill(TileType.EMPTY);
|
||||
tiles[0] = TileType.WALL;
|
||||
const world = createTestWorld(10, 10, tiles);
|
||||
|
||||
const result = tryDestructTile(world, 0, 0);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(world.tiles[0]).toBe(TileType.WALL);
|
||||
});
|
||||
|
||||
it('should return false for out of bounds', () => {
|
||||
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
||||
expect(tryDestructTile(world, -1, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPlayerOnExit', () => {
|
||||
it('should return true when player is on exit', () => {
|
||||
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
||||
world.exit = { x: 5, y: 5 };
|
||||
world.actors.set(1, {
|
||||
id: 1,
|
||||
pos: { x: 5, y: 5 },
|
||||
isPlayer: true
|
||||
} as any);
|
||||
|
||||
expect(isPlayerOnExit(world, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when player is not on exit', () => {
|
||||
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
||||
world.exit = { x: 5, y: 5 };
|
||||
world.actors.set(1, {
|
||||
id: 1,
|
||||
pos: { x: 4, y: 4 },
|
||||
isPlayer: true
|
||||
} as any);
|
||||
|
||||
expect(isPlayerOnExit(world, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when player does not exist', () => {
|
||||
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
||||
world.exit = { x: 5, y: 5 };
|
||||
|
||||
expect(isPlayerOnExit(world, 999)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -305,36 +305,41 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
|
||||
|
||||
// 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";
|
||||
if (canSee) {
|
||||
if (enemy.aiState === "wandering" || enemy.aiState === "searching") {
|
||||
// Spotted player (or re-spotted)! Transition to alerted state
|
||||
enemy.aiState = "alerted";
|
||||
enemy.alertedAt = Date.now();
|
||||
enemy.lastKnownPlayerPos = { ...player.pos };
|
||||
justAlerted = true;
|
||||
} else if (enemy.aiState === "pursuing") {
|
||||
// Keep pursuing, update last known
|
||||
enemy.lastKnownPlayerPos = { ...player.pos };
|
||||
}
|
||||
} 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 {
|
||||
// Cannot see player
|
||||
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") {
|
||||
// Lost sight while pursuing -> switch to searching
|
||||
enemy.aiState = "searching";
|
||||
} else if (enemy.aiState === "searching") {
|
||||
// Check if 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 {
|
||||
enemy.aiState = "wandering";
|
||||
}
|
||||
} else {
|
||||
// No last known position, return to wandering
|
||||
enemy.aiState = "wandering";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,7 +349,7 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
|
||||
}
|
||||
|
||||
if (enemy.aiState === "alerted") {
|
||||
// During alert, stay still (or could do small movement)
|
||||
// During alert, stay still
|
||||
return { action: { type: "wait" }, justAlerted };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user