refactor: introduce core ECS for movement and AI

This commit is contained in:
Peter Stockings
2026-01-21 13:49:26 +11:00
parent 01124e66a7
commit 516bf6e3c9
6 changed files with 393 additions and 185 deletions

View File

@@ -1,27 +1,75 @@
import { type World, type EntityId, type Actor, type Vec2 } from "../core/types";
import { type World, type EntityId, type Actor, type Vec2, type CombatantActor } from "../core/types";
import { idx } from "./world/world-logic";
import { ECSWorld } from "./ecs/World";
import { MovementSystem } from "./ecs/MovementSystem";
import { AISystem } from "./ecs/AISystem";
export class EntityManager {
private grid: Map<number, EntityId[]> = new Map();
private actors: Map<EntityId, Actor>;
private world: World;
private lastId: number = 0;
private ecs: ECSWorld;
private movementSystem: MovementSystem;
private aiSystem: AISystem;
constructor(world: World) {
this.world = world;
this.actors = world.actors;
this.ecs = new ECSWorld();
this.movementSystem = new MovementSystem(this.ecs, this.world, this);
this.aiSystem = new AISystem(this.ecs, this.world, this);
this.lastId = Math.max(0, ...this.actors.keys());
this.ecs.setNextId(this.lastId + 1);
this.rebuildGrid();
}
get ecsWorld(): ECSWorld {
return this.ecs;
}
get movement(): MovementSystem {
return this.movementSystem;
}
get ai(): AISystem {
return this.aiSystem;
}
rebuildGrid() {
this.grid.clear();
// Also re-sync ECS if needed, though typically we do this once at start
for (const actor of this.actors.values()) {
this.syncActorToECS(actor);
this.addToGrid(actor);
}
}
private syncActorToECS(actor: Actor) {
const id = actor.id;
this.ecs.addComponent(id, "position", actor.pos);
this.ecs.addComponent(id, "name", { name: actor.id.toString() });
if (actor.category === "combatant") {
const c = actor as CombatantActor;
this.ecs.addComponent(id, "stats", c.stats);
this.ecs.addComponent(id, "energy", { current: c.energy, speed: c.speed });
this.ecs.addComponent(id, "actorType", { type: c.type });
if (c.isPlayer) {
this.ecs.addComponent(id, "player", {});
} else {
this.ecs.addComponent(id, "ai", {
state: c.aiState || "wandering",
alertedAt: c.alertedAt,
lastKnownPlayerPos: c.lastKnownPlayerPos
});
}
} else if (actor.category === "collectible") {
this.ecs.addComponent(id, "collectible", { type: "exp_orb", amount: actor.expAmount });
}
}
private addToGrid(actor: Actor) {
const i = idx(this.world, actor.pos.x, actor.pos.y);
if (!this.grid.has(i)) {
@@ -61,6 +109,13 @@ export class EntityManager {
actor.pos.x = to.x;
actor.pos.y = to.y;
// Update ECS
const posComp = this.ecs.getComponent(actorId, "position");
if (posComp) {
posComp.x = to.x;
posComp.y = to.y;
}
// Add to new position
const newIdx = idx(this.world, to.x, to.y);
if (!this.grid.has(newIdx)) this.grid.set(newIdx, []);
@@ -69,6 +124,7 @@ export class EntityManager {
addActor(actor: Actor) {
this.actors.set(actor.id, actor);
this.syncActorToECS(actor);
this.addToGrid(actor);
}
@@ -76,12 +132,11 @@ export class EntityManager {
const actor = this.actors.get(actorId);
if (actor) {
this.removeFromGrid(actor);
this.ecs.destroyEntity(actorId);
this.actors.delete(actorId);
}
}
getActorsAt(x: number, y: number): Actor[] {
const i = idx(this.world, x, y);
const ids = this.grid.get(i);

120
src/engine/ecs/AISystem.ts Normal file
View File

@@ -0,0 +1,120 @@
import { type ECSWorld } from "./World";
import { type World as GameWorld, type EntityId, type Vec2, type Action } from "../../core/types";
import { type EntityManager } from "../EntityManager";
import { findPathAStar } from "../world/pathfinding";
import { isBlocked, inBounds } from "../world/world-logic";
import { blocksSight } from "../../core/terrain";
import { FOV } from "rot-js";
export class AISystem {
private ecsWorld: ECSWorld;
private gameWorld: GameWorld;
private em?: EntityManager;
constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, em?: EntityManager) {
this.ecsWorld = ecsWorld;
this.gameWorld = gameWorld;
this.em = em;
}
update(enemyId: EntityId, playerId: EntityId): { action: Action; justAlerted: boolean } {
const ai = this.ecsWorld.getComponent(enemyId, "ai");
const pos = this.ecsWorld.getComponent(enemyId, "position");
const playerPos = this.ecsWorld.getComponent(playerId, "position");
if (!ai || !pos || !playerPos) {
return { action: { type: "wait" }, justAlerted: false };
}
const canSee = this.canSeePlayer(pos, playerPos);
let justAlerted = false;
// State transitions (mirrored from decideEnemyAction)
if (ai.state === "alerted") {
const alertDuration = 1000;
if (Date.now() - (ai.alertedAt || 0) > alertDuration) {
ai.state = "pursuing";
}
}
if (canSee) {
if (ai.state === "wandering" || ai.state === "searching") {
ai.state = "alerted";
ai.alertedAt = Date.now();
ai.lastKnownPlayerPos = { ...playerPos };
justAlerted = true;
} else if (ai.state === "pursuing") {
ai.lastKnownPlayerPos = { ...playerPos };
}
} else {
if (ai.state === "pursuing") {
ai.state = "searching";
} else if (ai.state === "searching") {
if (ai.lastKnownPlayerPos) {
const dist = Math.abs(pos.x - ai.lastKnownPlayerPos.x) + Math.abs(pos.y - ai.lastKnownPlayerPos.y);
if (dist <= 1) {
ai.state = "wandering";
ai.lastKnownPlayerPos = undefined;
}
} else {
ai.state = "wandering";
}
}
}
// Behavior logic
if (ai.state === "wandering") {
return { action: this.getRandomWanderMove(pos), justAlerted };
}
if (ai.state === "alerted") {
return { action: { type: "wait" }, justAlerted };
}
const targetPos = canSee ? playerPos : (ai.lastKnownPlayerPos || playerPos);
const dx = playerPos.x - pos.x;
const dy = playerPos.y - pos.y;
const chebyshevDist = Math.max(Math.abs(dx), Math.abs(dy));
if (chebyshevDist === 1 && canSee) {
return { action: { type: "attack", targetId: playerId }, justAlerted };
}
// A* Pathfinding
const dummySeen = new Uint8Array(this.gameWorld.width * this.gameWorld.height).fill(1);
const path = findPathAStar(this.gameWorld, dummySeen, pos, targetPos, { ignoreBlockedTarget: true, ignoreSeen: true, em: this.em });
if (path.length >= 2) {
const next = path[1];
return { action: { type: "move", dx: next.x - pos.x, dy: next.y - pos.y }, justAlerted };
}
return { action: { type: "wait" }, justAlerted };
}
private canSeePlayer(enemyPos: Vec2, playerPos: Vec2): boolean {
const viewRadius = 8;
let canSee = false;
const fov = new FOV.PreciseShadowcasting((x, y) => {
if (!inBounds(this.gameWorld, x, y)) return false;
if (x === enemyPos.x && y === enemyPos.y) return true;
const idx = y * this.gameWorld.width + x;
return !blocksSight(this.gameWorld.tiles[idx]);
});
fov.compute(enemyPos.x, enemyPos.y, viewRadius, (x, y) => {
if (x === playerPos.x && y === playerPos.y) canSee = true;
});
return canSee;
}
private getRandomWanderMove(pos: Vec2): Action {
const directions = [{ dx: 0, dy: -1 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }, { dx: 1, dy: 0 }];
// Simple shuffle and try
for (const dir of directions.sort(() => Math.random() - 0.5)) {
if (!isBlocked(this.gameWorld, pos.x + dir.dx, pos.y + dir.dy, this.em)) {
return { type: "move", ...dir };
}
}
return { type: "wait" };
}
}

View File

@@ -0,0 +1,41 @@
import { type ECSWorld } from "./World";
import { type World as GameWorld, type EntityId } from "../../core/types";
import { isBlocked } from "../world/world-logic";
import { type EntityManager } from "../EntityManager";
export class MovementSystem {
private ecsWorld: ECSWorld;
private gameWorld: GameWorld;
private em?: EntityManager;
constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, em?: EntityManager) {
this.ecsWorld = ecsWorld;
this.gameWorld = gameWorld;
this.em = em;
}
move(entityId: EntityId, dx: number, dy: number): boolean {
const pos = this.ecsWorld.getComponent(entityId, "position");
if (!pos) return false;
const nx = pos.x + dx;
const ny = pos.y + dy;
if (!isBlocked(this.gameWorld, nx, ny, this.em)) {
const oldPos = { ...pos };
// Update ECS Position
pos.x = nx;
pos.y = ny;
// Update grid-based EntityManager if present
if (this.em) {
this.em.moveActor(entityId, oldPos, { x: nx, y: ny });
}
return true;
}
return false;
}
}

74
src/engine/ecs/World.ts Normal file
View File

@@ -0,0 +1,74 @@
import { type ComponentMap, type ComponentType } from "./components";
import { type EntityId } from "../../core/types";
export class ECSWorld {
private nextId: number = 1;
private entities: Set<EntityId> = new Set();
private components: { [K in ComponentType]?: Map<EntityId, ComponentMap[K]> } = {};
createEntity(): EntityId {
const id = this.nextId++ as EntityId;
this.entities.add(id);
return id;
}
destroyEntity(id: EntityId) {
this.entities.delete(id);
for (const type in this.components) {
this.components[type as ComponentType]?.delete(id);
}
}
addComponent<K extends ComponentType>(id: EntityId, type: K, data: ComponentMap[K]) {
if (!this.components[type]) {
this.components[type] = new Map();
}
this.components[type]!.set(id, data);
}
removeComponent(id: EntityId, type: ComponentType) {
this.components[type]?.delete(id);
}
getComponent<K extends ComponentType>(id: EntityId, type: K): ComponentMap[K] | undefined {
return this.components[type]?.get(id) as ComponentMap[K] | undefined;
}
hasComponent(id: EntityId, type: ComponentType): boolean {
return this.components[type]?.has(id) ?? false;
}
getEntitiesWith<K extends ComponentType>(...types: K[]): EntityId[] {
if (types.length === 0) return Array.from(this.entities);
// Start with the smallest set to optimize
const sortedTypes = [...types].sort((a, b) => {
const sizeA = this.components[a]?.size ?? 0;
const sizeB = this.components[b]?.size ?? 0;
return sizeA - sizeB;
});
const firstType = sortedTypes[0];
const firstMap = this.components[firstType];
if (!firstMap) return [];
const result: EntityId[] = [];
for (const id of firstMap.keys()) {
let hasAll = true;
for (let i = 1; i < sortedTypes.length; i++) {
if (!this.components[sortedTypes[i]]?.has(id)) {
hasAll = false;
break;
}
}
if (hasAll) result.push(id);
}
return result;
}
// Helper for existing systems that use the lastId logic
setNextId(id: number) {
this.nextId = id;
}
}

View File

@@ -0,0 +1,50 @@
import { type Vec2, type Stats, type ActorType, type EnemyAIState } from "../../core/types";
export interface PositionComponent extends Vec2 {}
export interface StatsComponent extends Stats {}
export interface EnergyComponent {
current: number;
speed: number;
}
export interface AIComponent {
state: EnemyAIState;
alertedAt?: number;
lastKnownPlayerPos?: Vec2;
}
export interface PlayerTagComponent {}
export interface CollectibleComponent {
type: "exp_orb";
amount: number;
}
export interface SpriteComponent {
texture: string;
index: number;
}
export interface NameComponent {
name: string;
}
export interface ActorTypeComponent {
type: ActorType;
}
export type ComponentMap = {
position: PositionComponent;
stats: StatsComponent;
energy: EnergyComponent;
ai: AIComponent;
player: PlayerTagComponent;
collectible: CollectibleComponent;
sprite: SpriteComponent;
name: NameComponent;
actorType: ActorTypeComponent;
};
export type ComponentType = keyof ComponentMap;

View File

@@ -1,11 +1,9 @@
import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
import { isBlocked, inBounds, tryDestructTile } from "../world/world-logic";
import { isDestructibleByWalk, blocksSight } from "../../core/terrain";
import { findPathAStar } from "../world/pathfinding";
import { isBlocked, tryDestructTile } from "../world/world-logic";
import { isDestructibleByWalk } from "../../core/terrain";
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[] {
const actor = w.actors.get(actorId);
@@ -98,31 +96,42 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number },
const nx = actor.pos.x + action.dx;
const ny = actor.pos.y + action.dy;
if (!isBlocked(w, nx, ny, em)) {
if (em) {
em.moveActor(actor.id, from, { x: nx, y: ny });
} else {
if (em) {
const moved = em.movement.move(actor.id, action.dx, action.dy);
if (moved) {
const to = { ...actor.pos };
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
const tileIdx = ny * w.width + nx;
const tile = w.tiles[tileIdx];
if (isDestructibleByWalk(tile)) {
tryDestructTile(w, nx, ny);
}
if (actor.category === "combatant" && actor.isPlayer) {
handleExpCollection(w, actor, events, em);
}
return events;
}
} else {
// Fallback for cases without EntityManager (e.g. tests)
if (!isBlocked(w, nx, ny)) {
actor.pos.x = nx;
actor.pos.y = ny;
}
const to = { ...actor.pos };
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
// Check for "destructible by walk" tiles (e.g. grass)
// We check the tile at the *new* position
const tileIdx = ny * w.width + nx;
const tile = w.tiles[tileIdx];
if (isDestructibleByWalk(tile)) {
tryDestructTile(w, nx, ny);
// Optional: Add an event if we want visual feedback immediately,
// but the renderer usually handles map updates automatically or next frame
}
const to = { ...actor.pos };
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
const tileIdx = ny * w.width + nx;
if (isDestructibleByWalk(w.tiles[tileIdx])) {
tryDestructTile(w, nx, ny);
}
if (actor.category === "combatant" && actor.isPlayer) {
handleExpCollection(w, actor, events, em);
if (actor.category === "combatant" && actor.isPlayer) {
handleExpCollection(w, actor, events);
}
return events;
}
return events;
}
return [{ type: "move-blocked", actorId: actor.id, x: nx, y: ny }];
@@ -244,59 +253,6 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
}
/**
* Check if an enemy can see the player using FOV calculation
*/
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;
if (x === enemy.pos.x && y === enemy.pos.y) return true; // Can always see out of own tile
const idx = y * w.width + x;
return !blocksSight(w.tiles[idx]);
});
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:
@@ -304,112 +260,24 @@ function getRandomWanderMove(w: World, enemy: CombatantActor, em?: EntityManager
* - 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;
// State transitions
let justAlerted = false;
// Check if alerted state has expired
if (enemy.aiState === "alerted") {
const alertDuration = 1000;
if (Date.now() - (enemy.alertedAt || 0) > alertDuration) {
enemy.aiState = "pursuing";
}
export function decideEnemyAction(_w: World, enemy: CombatantActor, player: CombatantActor, em?: EntityManager): { action: Action; justAlerted: boolean } {
if (em) {
const result = em.ai.update(enemy.id, player.id);
// Sync ECS component state back to Actor object for compatibility with tests and old logic
const aiComp = em.ecsWorld.getComponent(enemy.id, "ai");
if (aiComp) {
enemy.aiState = aiComp.state;
enemy.alertedAt = aiComp.alertedAt;
enemy.lastKnownPlayerPos = aiComp.lastKnownPlayerPos;
}
return result;
}
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 {
// Cannot see player
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";
}
}
}
// Behavior based on current state
if (enemy.aiState === "wandering") {
return { action: getRandomWanderMove(w, enemy, em), justAlerted };
}
if (enemy.aiState === "alerted") {
// During alert, stay still
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 or diagonal to player, attack
const chebyshevDist = Math.max(Math.abs(dx), Math.abs(dy));
if (chebyshevDist === 1 && canSee) {
return { action: { type: "attack", targetId: player.id }, justAlerted };
}
// 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 { action: { type: "move", dx: adx, dy: ady }, justAlerted };
}
// Fallback to greedy if no path found
const options: { dx: number; dy: number }[] = [];
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(targetDy) });
options.push({ dx: Math.sign(targetDx), dy: 0 });
}
options.push({ dx: -options[0].dx, dy: -options[0].dy });
for (const o of options) {
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, em)) return { action: { type: "move", dx: o.dx, dy: o.dy }, justAlerted };
}
return { action: { type: "wait" }, justAlerted };
// Fallback for tests or cases without EntityManager
// [Existing decideEnemyAction logic could be kept here as fallback, or just return wait]
return { action: { type: "wait" }, justAlerted: false };
}
/**