feat: Add traps
This commit is contained in:
243
src/engine/ecs/EntityBuilder.ts
Normal file
243
src/engine/ecs/EntityBuilder.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { type ECSWorld } from "./World";
|
||||
import { type ComponentMap } from "./components";
|
||||
import { type EntityId, type Stats, type EnemyAIState, type ActorType } from "../../core/types";
|
||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||
|
||||
/**
|
||||
* Fluent builder for creating ECS entities with components.
|
||||
* Makes entity creation declarative and easy to extend.
|
||||
*
|
||||
* @example
|
||||
* // Create a simple trap
|
||||
* EntityBuilder.create(world)
|
||||
* .withPosition(5, 10)
|
||||
* .asTrap(15)
|
||||
* .build();
|
||||
*
|
||||
* @example
|
||||
* // Create an enemy
|
||||
* EntityBuilder.create(world)
|
||||
* .withPosition(3, 7)
|
||||
* .asEnemy("rat")
|
||||
* .build();
|
||||
*/
|
||||
export class EntityBuilder {
|
||||
private world: ECSWorld;
|
||||
private entityId: EntityId;
|
||||
private components: Partial<{ [K in keyof ComponentMap]: ComponentMap[K] }> = {};
|
||||
|
||||
private constructor(world: ECSWorld) {
|
||||
this.world = world;
|
||||
this.entityId = world.createEntity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start building a new entity.
|
||||
*/
|
||||
static create(world: ECSWorld): EntityBuilder {
|
||||
return new EntityBuilder(world);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a position component.
|
||||
*/
|
||||
withPosition(x: number, y: number): this {
|
||||
this.components.position = { x, y };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a name component.
|
||||
*/
|
||||
withName(name: string): this {
|
||||
this.components.name = { name };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a sprite component.
|
||||
*/
|
||||
withSprite(texture: string, index: number): this {
|
||||
this.components.sprite = { texture, index };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add stats component with partial stats (fills defaults).
|
||||
*/
|
||||
withStats(stats: Partial<Stats>): this {
|
||||
const defaultStats: Stats = {
|
||||
maxHp: 10, hp: 10,
|
||||
maxMana: 0, mana: 0,
|
||||
attack: 1, defense: 0,
|
||||
level: 1, exp: 0, expToNextLevel: 10,
|
||||
critChance: 0, critMultiplier: 100, accuracy: 100, lifesteal: 0,
|
||||
evasion: 0, blockChance: 0, luck: 0,
|
||||
statPoints: 0, skillPoints: 0,
|
||||
strength: 10, dexterity: 10, intelligence: 10,
|
||||
passiveNodes: []
|
||||
};
|
||||
this.components.stats = { ...defaultStats, ...stats };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add energy component for turn scheduling.
|
||||
*/
|
||||
withEnergy(speed: number, current: number = 0): this {
|
||||
this.components.energy = { current, speed };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add AI component for enemy behavior.
|
||||
*/
|
||||
withAI(state: EnemyAIState = "wandering"): this {
|
||||
this.components.ai = { state };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add player tag component.
|
||||
*/
|
||||
asPlayer(): this {
|
||||
this.components.player = {};
|
||||
this.components.actorType = { type: "player" };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure as an enemy with stats from GameConfig.
|
||||
*/
|
||||
asEnemy(type: ActorType): this {
|
||||
if (type === "player") {
|
||||
throw new Error("Use asPlayer() for player entities");
|
||||
}
|
||||
|
||||
this.components.actorType = { type };
|
||||
this.withAI("wandering");
|
||||
|
||||
// Apply enemy stats from config
|
||||
const config = GAME_CONFIG.enemies[type as keyof typeof GAME_CONFIG.enemies];
|
||||
if (config) {
|
||||
const speed = config.minSpeed + Math.random() * (config.maxSpeed - config.minSpeed);
|
||||
this.withStats({
|
||||
maxHp: config.baseHp,
|
||||
hp: config.baseHp,
|
||||
attack: config.baseAttack,
|
||||
defense: config.baseDefense
|
||||
});
|
||||
this.withEnergy(speed);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure as a trap that deals damage when stepped on.
|
||||
*/
|
||||
asTrap(damage: number, oneShot: boolean = false): this {
|
||||
this.components.trigger = {
|
||||
onEnter: true,
|
||||
oneShot,
|
||||
damage
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure as a trigger zone (pressure plate, etc).
|
||||
*/
|
||||
asTrigger(options: {
|
||||
onEnter?: boolean;
|
||||
onExit?: boolean;
|
||||
oneShot?: boolean;
|
||||
effect?: string;
|
||||
effectDuration?: number;
|
||||
}): this {
|
||||
this.components.trigger = {
|
||||
onEnter: options.onEnter ?? true,
|
||||
onExit: options.onExit,
|
||||
oneShot: options.oneShot,
|
||||
effect: options.effect,
|
||||
effectDuration: options.effectDuration
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure as a destructible object.
|
||||
*/
|
||||
asDestructible(hp: number, maxHp?: number, options?: {
|
||||
destroyedTile?: number;
|
||||
lootTable?: string;
|
||||
}): this {
|
||||
this.components.destructible = {
|
||||
hp,
|
||||
maxHp: maxHp ?? hp,
|
||||
destroyedTile: options?.destroyedTile,
|
||||
lootTable: options?.lootTable
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure as a collectible (exp orb, etc).
|
||||
*/
|
||||
asCollectible(type: "exp_orb", amount: number): this {
|
||||
this.components.collectible = { type, amount };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure as an item on the ground.
|
||||
*/
|
||||
asGroundItem(itemId: string, quantity: number = 1): this {
|
||||
this.components.groundItem = { itemId, quantity };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add initial status effects.
|
||||
*/
|
||||
withStatusEffects(effects: ComponentMap["statusEffects"]["effects"]): this {
|
||||
this.components.statusEffects = { effects };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add combat tracking component.
|
||||
*/
|
||||
withCombat(): this {
|
||||
this.components.combat = {};
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a raw component directly.
|
||||
*/
|
||||
with<K extends keyof ComponentMap>(type: K, data: ComponentMap[K]): this {
|
||||
this.components[type] = data;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize and register all components with the ECS world.
|
||||
* @returns The created entity ID
|
||||
*/
|
||||
build(): EntityId {
|
||||
for (const [type, data] of Object.entries(this.components)) {
|
||||
if (data !== undefined) {
|
||||
this.world.addComponent(this.entityId, type as keyof ComponentMap, data as any);
|
||||
}
|
||||
}
|
||||
return this.entityId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity ID (even before build is called).
|
||||
*/
|
||||
getId(): EntityId {
|
||||
return this.entityId;
|
||||
}
|
||||
}
|
||||
152
src/engine/ecs/EventBus.ts
Normal file
152
src/engine/ecs/EventBus.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { type EntityId } from "../../core/types";
|
||||
import { type ComponentType } from "./components";
|
||||
|
||||
/**
|
||||
* Game events for cross-system communication.
|
||||
* Systems can emit and subscribe to these events to react to gameplay changes.
|
||||
*/
|
||||
export type GameEvent =
|
||||
// Combat events
|
||||
| { type: "damage"; entityId: EntityId; amount: number; source?: EntityId }
|
||||
| { type: "heal"; entityId: EntityId; amount: number; source?: EntityId }
|
||||
| { type: "death"; entityId: EntityId; killedBy?: EntityId }
|
||||
|
||||
// Component lifecycle events
|
||||
| { type: "component_added"; entityId: EntityId; componentType: ComponentType }
|
||||
| { type: "component_removed"; entityId: EntityId; componentType: ComponentType }
|
||||
| { type: "entity_created"; entityId: EntityId }
|
||||
| { type: "entity_destroyed"; entityId: EntityId }
|
||||
|
||||
// Movement & trigger events
|
||||
| { type: "stepped_on"; entityId: EntityId; x: number; y: number }
|
||||
| { type: "trigger_activated"; triggerId: EntityId; activatorId: EntityId }
|
||||
|
||||
// Status effect events
|
||||
| { type: "status_applied"; entityId: EntityId; status: string; duration: number }
|
||||
| { type: "status_expired"; entityId: EntityId; status: string }
|
||||
| { type: "status_tick"; entityId: EntityId; status: string; remaining: number };
|
||||
|
||||
export type GameEventType = GameEvent["type"];
|
||||
|
||||
type EventHandler<T extends GameEvent = GameEvent> = (event: T) => void;
|
||||
|
||||
/**
|
||||
* Lightweight event bus for cross-system communication.
|
||||
* Enables reactive gameplay like status effects, triggers, and combat feedback.
|
||||
*/
|
||||
export class EventBus {
|
||||
private listeners: Map<string, Set<EventHandler>> = new Map();
|
||||
private onceListeners: Map<string, Set<EventHandler>> = new Map();
|
||||
private eventQueue: GameEvent[] = [];
|
||||
|
||||
/**
|
||||
* Subscribe to events of a specific type.
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
on<T extends GameEventType>(
|
||||
eventType: T,
|
||||
handler: EventHandler<Extract<GameEvent, { type: T }>>
|
||||
): () => void {
|
||||
if (!this.listeners.has(eventType)) {
|
||||
this.listeners.set(eventType, new Set());
|
||||
}
|
||||
this.listeners.get(eventType)!.add(handler as EventHandler);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.listeners.get(eventType)?.delete(handler as EventHandler);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a single occurrence of an event type.
|
||||
* The handler is automatically removed after being called once.
|
||||
*/
|
||||
once<T extends GameEventType>(
|
||||
eventType: T,
|
||||
handler: EventHandler<Extract<GameEvent, { type: T }>>
|
||||
): void {
|
||||
if (!this.onceListeners.has(eventType)) {
|
||||
this.onceListeners.set(eventType, new Set());
|
||||
}
|
||||
this.onceListeners.get(eventType)!.add(handler as EventHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all registered listeners.
|
||||
*/
|
||||
emit(event: GameEvent): void {
|
||||
const eventType = event.type;
|
||||
|
||||
// Call regular listeners
|
||||
const handlers = this.listeners.get(eventType);
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
handler(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Call once listeners and remove them
|
||||
const onceHandlers = this.onceListeners.get(eventType);
|
||||
if (onceHandlers) {
|
||||
for (const handler of onceHandlers) {
|
||||
handler(event);
|
||||
}
|
||||
this.onceListeners.delete(eventType);
|
||||
}
|
||||
|
||||
// Add to queue for drain()
|
||||
this.eventQueue.push(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain all queued events and return them.
|
||||
* Clears the internal queue.
|
||||
*/
|
||||
drain(): GameEvent[] {
|
||||
const events = [...this.eventQueue];
|
||||
this.eventQueue = [];
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all listeners for a specific event type.
|
||||
*/
|
||||
off(eventType: GameEventType): void {
|
||||
this.listeners.delete(eventType);
|
||||
this.onceListeners.delete(eventType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all listeners for all event types.
|
||||
*/
|
||||
clear(): void {
|
||||
this.listeners.clear();
|
||||
this.onceListeners.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any listeners for a specific event type.
|
||||
*/
|
||||
hasListeners(eventType: GameEventType): boolean {
|
||||
return (
|
||||
(this.listeners.get(eventType)?.size ?? 0) > 0 ||
|
||||
(this.onceListeners.get(eventType)?.size ?? 0) > 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for global event bus (optional - can also create instances per world)
|
||||
let globalEventBus: EventBus | null = null;
|
||||
|
||||
export function getEventBus(): EventBus {
|
||||
if (!globalEventBus) {
|
||||
globalEventBus = new EventBus();
|
||||
}
|
||||
return globalEventBus;
|
||||
}
|
||||
|
||||
export function resetEventBus(): void {
|
||||
globalEventBus?.clear();
|
||||
globalEventBus = null;
|
||||
}
|
||||
209
src/engine/ecs/Prefabs.ts
Normal file
209
src/engine/ecs/Prefabs.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { type ECSWorld } from "./World";
|
||||
import { EntityBuilder } from "./EntityBuilder";
|
||||
import { type EntityId } from "../../core/types";
|
||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||
|
||||
/**
|
||||
* Pre-defined entity templates for common entity types.
|
||||
* Use these for quick spawning of standard game entities.
|
||||
*
|
||||
* @example
|
||||
* const ratId = Prefabs.rat(world, 5, 10);
|
||||
* const trapId = Prefabs.spikeTrap(world, 3, 7, 15);
|
||||
*/
|
||||
export const Prefabs = {
|
||||
/**
|
||||
* Create a rat enemy at the given position.
|
||||
*/
|
||||
rat(world: ECSWorld, x: number, y: number, floorBonus: number = 0): EntityId {
|
||||
const config = GAME_CONFIG.enemies.rat;
|
||||
return EntityBuilder.create(world)
|
||||
.withPosition(x, y)
|
||||
.withName("Rat")
|
||||
.withSprite("rat", 0)
|
||||
.asEnemy("rat")
|
||||
.withStats({
|
||||
maxHp: config.baseHp + floorBonus,
|
||||
hp: config.baseHp + floorBonus,
|
||||
attack: config.baseAttack + Math.floor(floorBonus / 2),
|
||||
defense: config.baseDefense
|
||||
})
|
||||
.withCombat()
|
||||
.build();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a bat enemy at the given position.
|
||||
*/
|
||||
bat(world: ECSWorld, x: number, y: number, floorBonus: number = 0): EntityId {
|
||||
const config = GAME_CONFIG.enemies.bat;
|
||||
return EntityBuilder.create(world)
|
||||
.withPosition(x, y)
|
||||
.withName("Bat")
|
||||
.withSprite("bat", 0)
|
||||
.asEnemy("bat")
|
||||
.withStats({
|
||||
maxHp: config.baseHp + floorBonus,
|
||||
hp: config.baseHp + floorBonus,
|
||||
attack: config.baseAttack + Math.floor(floorBonus / 2),
|
||||
defense: config.baseDefense
|
||||
})
|
||||
.withCombat()
|
||||
.build();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create an experience orb collectible.
|
||||
*/
|
||||
expOrb(world: ECSWorld, x: number, y: number, amount: number): EntityId {
|
||||
return EntityBuilder.create(world)
|
||||
.withPosition(x, y)
|
||||
.withName("Experience Orb")
|
||||
.withSprite("items", 0) // Adjust sprite index as needed
|
||||
.asCollectible("exp_orb", amount)
|
||||
.build();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a poison trap (sprite 17 - green).
|
||||
* Applies poison status effect when stepped on.
|
||||
*/
|
||||
poisonTrap(world: ECSWorld, x: number, y: number, duration: number = 5, magnitude: number = 2): EntityId {
|
||||
return EntityBuilder.create(world)
|
||||
.withPosition(x, y)
|
||||
.withName("Poison Trap")
|
||||
.withSprite("dungeon", 17)
|
||||
.asTrigger({
|
||||
onEnter: true,
|
||||
oneShot: true,
|
||||
effect: "poison",
|
||||
effectDuration: duration
|
||||
})
|
||||
.with("trigger", {
|
||||
onEnter: true,
|
||||
oneShot: true,
|
||||
effect: "poison",
|
||||
effectDuration: duration,
|
||||
damage: magnitude // Store magnitude as damage for effect processing
|
||||
})
|
||||
.build();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a fire trap (sprite 19 - orange).
|
||||
* Applies burning status effect when stepped on.
|
||||
*/
|
||||
fireTrap(world: ECSWorld, x: number, y: number, duration: number = 3, magnitude: number = 4): EntityId {
|
||||
return EntityBuilder.create(world)
|
||||
.withPosition(x, y)
|
||||
.withName("Fire Trap")
|
||||
.withSprite("dungeon", 19)
|
||||
.asTrigger({
|
||||
onEnter: true,
|
||||
oneShot: true,
|
||||
effect: "burning",
|
||||
effectDuration: duration
|
||||
})
|
||||
.with("trigger", {
|
||||
onEnter: true,
|
||||
oneShot: true,
|
||||
effect: "burning",
|
||||
effectDuration: duration,
|
||||
damage: magnitude
|
||||
})
|
||||
.build();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a paralysis trap (sprite 21 - yellow).
|
||||
* Applies frozen status effect when stepped on.
|
||||
*/
|
||||
paralysisTrap(world: ECSWorld, x: number, y: number, duration: number = 2): EntityId {
|
||||
return EntityBuilder.create(world)
|
||||
.withPosition(x, y)
|
||||
.withName("Paralysis Trap")
|
||||
.withSprite("dungeon", 21)
|
||||
.asTrigger({
|
||||
onEnter: true,
|
||||
oneShot: true,
|
||||
effect: "frozen",
|
||||
effectDuration: duration
|
||||
})
|
||||
.build();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a pressure plate trigger.
|
||||
*/
|
||||
pressurePlate(world: ECSWorld, x: number, y: number, effect?: string, duration?: number): EntityId {
|
||||
return EntityBuilder.create(world)
|
||||
.withPosition(x, y)
|
||||
.withName("Pressure Plate")
|
||||
.withSprite("dungeon", 34) // Adjust sprite index as needed
|
||||
.asTrigger({
|
||||
onEnter: true,
|
||||
onExit: true,
|
||||
effect,
|
||||
effectDuration: duration
|
||||
})
|
||||
.build();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a destructible barrel.
|
||||
*/
|
||||
barrel(world: ECSWorld, x: number, y: number, lootTable?: string): EntityId {
|
||||
return EntityBuilder.create(world)
|
||||
.withPosition(x, y)
|
||||
.withName("Barrel")
|
||||
.withSprite("dungeon", 48) // Adjust sprite index as needed
|
||||
.asDestructible(5, 5, { lootTable })
|
||||
.build();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a destructible crate.
|
||||
*/
|
||||
crate(world: ECSWorld, x: number, y: number, lootTable?: string): EntityId {
|
||||
return EntityBuilder.create(world)
|
||||
.withPosition(x, y)
|
||||
.withName("Crate")
|
||||
.withSprite("dungeon", 49) // Adjust sprite index as needed
|
||||
.asDestructible(8, 8, { lootTable })
|
||||
.build();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create an item drop on the ground.
|
||||
*/
|
||||
itemDrop(world: ECSWorld, x: number, y: number, itemId: string, quantity: number = 1, spriteIndex: number = 0): EntityId {
|
||||
return EntityBuilder.create(world)
|
||||
.withPosition(x, y)
|
||||
.withName("Item")
|
||||
.withSprite("items", spriteIndex)
|
||||
.asGroundItem(itemId, quantity)
|
||||
.build();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a player entity at the given position.
|
||||
*/
|
||||
player(world: ECSWorld, x: number, y: number): EntityId {
|
||||
const config = GAME_CONFIG.player;
|
||||
return EntityBuilder.create(world)
|
||||
.withPosition(x, y)
|
||||
.withName("Player")
|
||||
.withSprite("warrior", 0)
|
||||
.asPlayer()
|
||||
.withStats(config.initialStats)
|
||||
.withEnergy(config.speed)
|
||||
.withCombat()
|
||||
.build();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Type for prefab factory functions.
|
||||
* Useful for creating maps of spawnable entities.
|
||||
*/
|
||||
export type PrefabFactory = (world: ECSWorld, x: number, y: number, ...args: any[]) => EntityId;
|
||||
259
src/engine/ecs/System.ts
Normal file
259
src/engine/ecs/System.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { type ECSWorld } from "./World";
|
||||
import { type ComponentType } from "./components";
|
||||
import { type EntityId } from "../../core/types";
|
||||
import { type EventBus } from "./EventBus";
|
||||
|
||||
/**
|
||||
* Abstract base class for all ECS systems.
|
||||
* Systems operate on entities that have specific component combinations.
|
||||
*
|
||||
* @example
|
||||
* class StatusEffectSystem extends System {
|
||||
* readonly name = "StatusEffect";
|
||||
* readonly requiredComponents = ["statusEffects", "stats"] as const;
|
||||
*
|
||||
* update(entities: EntityId[], world: ECSWorld) {
|
||||
* for (const id of entities) {
|
||||
* // Process status effects...
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export abstract class System {
|
||||
/**
|
||||
* Human-readable name for debugging and logging.
|
||||
*/
|
||||
abstract readonly name: string;
|
||||
|
||||
/**
|
||||
* Components required for this system to operate on an entity.
|
||||
* Only entities with ALL these components will be passed to update().
|
||||
*/
|
||||
abstract readonly requiredComponents: readonly ComponentType[];
|
||||
|
||||
/**
|
||||
* Priority for execution order (lower = earlier).
|
||||
* Default is 0. Use negative for early systems, positive for late.
|
||||
*/
|
||||
readonly priority: number = 0;
|
||||
|
||||
/**
|
||||
* Whether this system is currently enabled.
|
||||
*/
|
||||
enabled: boolean = true;
|
||||
|
||||
/**
|
||||
* Optional reference to the event bus for emitting/subscribing to events.
|
||||
*/
|
||||
protected eventBus?: EventBus;
|
||||
|
||||
/**
|
||||
* Called by the registry to inject the event bus.
|
||||
*/
|
||||
setEventBus(eventBus: EventBus): void {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main update method called each game tick.
|
||||
* @param entities - All entities that have the required components
|
||||
* @param world - The ECS world for component access
|
||||
* @param dt - Delta time since last update (optional)
|
||||
*/
|
||||
abstract update(entities: EntityId[], world: ECSWorld, dt?: number): void;
|
||||
|
||||
/**
|
||||
* Optional: Called when a matching entity is added to the world.
|
||||
*/
|
||||
onEntityAdded?(entityId: EntityId, world: ECSWorld): void;
|
||||
|
||||
/**
|
||||
* Optional: Called when a matching entity is removed from the world.
|
||||
*/
|
||||
onEntityRemoved?(entityId: EntityId, world: ECSWorld): void;
|
||||
|
||||
/**
|
||||
* Optional: Called once when the system is registered.
|
||||
*/
|
||||
onRegister?(world: ECSWorld): void;
|
||||
|
||||
/**
|
||||
* Optional: Called once when the system is unregistered.
|
||||
*/
|
||||
onUnregister?(world: ECSWorld): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages registration and execution of all ECS systems.
|
||||
* Handles entity queries, execution order, and lifecycle hooks.
|
||||
*
|
||||
* @example
|
||||
* const registry = new SystemRegistry(world, eventBus);
|
||||
* registry.register(new StatusEffectSystem());
|
||||
* registry.register(new TriggerSystem());
|
||||
*
|
||||
* // In game loop:
|
||||
* registry.updateAll(deltaTime);
|
||||
*/
|
||||
export class SystemRegistry {
|
||||
private systems: System[] = [];
|
||||
private world: ECSWorld;
|
||||
private eventBus?: EventBus;
|
||||
private queryCache: Map<string, EntityId[]> = new Map();
|
||||
private queryCacheDirty: boolean = true;
|
||||
|
||||
constructor(world: ECSWorld, eventBus?: EventBus) {
|
||||
this.world = world;
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new system.
|
||||
* Systems are sorted by priority (lower = earlier execution).
|
||||
*/
|
||||
register(system: System): void {
|
||||
if (this.eventBus) {
|
||||
system.setEventBus(this.eventBus);
|
||||
}
|
||||
|
||||
this.systems.push(system);
|
||||
this.systems.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
system.onRegister?.(this.world);
|
||||
this.invalidateCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a system by instance or name.
|
||||
*/
|
||||
unregister(systemOrName: System | string): boolean {
|
||||
const index = typeof systemOrName === "string"
|
||||
? this.systems.findIndex(s => s.name === systemOrName)
|
||||
: this.systems.indexOf(systemOrName);
|
||||
|
||||
if (index !== -1) {
|
||||
const [removed] = this.systems.splice(index, 1);
|
||||
removed.onUnregister?.(this.world);
|
||||
this.invalidateCache();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a system by name.
|
||||
*/
|
||||
get<T extends System>(name: string): T | undefined {
|
||||
return this.systems.find(s => s.name === name) as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a system is registered.
|
||||
*/
|
||||
has(name: string): boolean {
|
||||
return this.systems.some(s => s.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all enabled systems in priority order.
|
||||
*/
|
||||
updateAll(dt?: number): void {
|
||||
for (const system of this.systems) {
|
||||
if (!system.enabled) continue;
|
||||
|
||||
const entities = this.getEntitiesForSystem(system);
|
||||
system.update(entities, this.world, dt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific system by name.
|
||||
*/
|
||||
updateSystem(name: string, dt?: number): boolean {
|
||||
const system = this.get(name);
|
||||
if (!system || !system.enabled) return false;
|
||||
|
||||
const entities = this.getEntitiesForSystem(system);
|
||||
system.update(entities, this.world, dt);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable a system by name.
|
||||
*/
|
||||
setEnabled(name: string, enabled: boolean): boolean {
|
||||
const system = this.get(name);
|
||||
if (system) {
|
||||
system.enabled = enabled;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered systems.
|
||||
*/
|
||||
getSystems(): readonly System[] {
|
||||
return this.systems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the entity cache as dirty (call after entity changes).
|
||||
*/
|
||||
invalidateCache(): void {
|
||||
this.queryCacheDirty = true;
|
||||
this.queryCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify systems that an entity was added.
|
||||
*/
|
||||
notifyEntityAdded(entityId: EntityId): void {
|
||||
this.invalidateCache();
|
||||
for (const system of this.systems) {
|
||||
if (system.onEntityAdded && this.entityMatchesSystem(entityId, system)) {
|
||||
system.onEntityAdded(entityId, this.world);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify systems that an entity was removed.
|
||||
*/
|
||||
notifyEntityRemoved(entityId: EntityId): void {
|
||||
for (const system of this.systems) {
|
||||
if (system.onEntityRemoved) {
|
||||
system.onEntityRemoved(entityId, this.world);
|
||||
}
|
||||
}
|
||||
this.invalidateCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entities matching a system's required components.
|
||||
*/
|
||||
private getEntitiesForSystem(system: System): EntityId[] {
|
||||
const cacheKey = system.requiredComponents.join(",");
|
||||
|
||||
if (!this.queryCacheDirty && this.queryCache.has(cacheKey)) {
|
||||
return this.queryCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const entities = this.world.getEntitiesWith(...system.requiredComponents);
|
||||
this.queryCache.set(cacheKey, entities);
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an entity has all components required by a system.
|
||||
*/
|
||||
private entityMatchesSystem(entityId: EntityId, system: System): boolean {
|
||||
for (const component of system.requiredComponents) {
|
||||
if (!this.world.hasComponent(entityId, component)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
231
src/engine/ecs/__tests__/EntityBuilder.test.ts
Normal file
231
src/engine/ecs/__tests__/EntityBuilder.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { EntityBuilder } from "../EntityBuilder";
|
||||
import { Prefabs } from "../Prefabs";
|
||||
import { ECSWorld } from "../World";
|
||||
|
||||
describe("EntityBuilder", () => {
|
||||
let world: ECSWorld;
|
||||
|
||||
beforeEach(() => {
|
||||
world = new ECSWorld();
|
||||
});
|
||||
|
||||
describe("basic entity creation", () => {
|
||||
it("should create an entity with position", () => {
|
||||
const id = EntityBuilder.create(world)
|
||||
.withPosition(5, 10)
|
||||
.build();
|
||||
|
||||
const pos = world.getComponent(id, "position");
|
||||
expect(pos).toEqual({ x: 5, y: 10 });
|
||||
});
|
||||
|
||||
it("should create an entity with name", () => {
|
||||
const id = EntityBuilder.create(world)
|
||||
.withName("TestEntity")
|
||||
.build();
|
||||
|
||||
const name = world.getComponent(id, "name");
|
||||
expect(name).toEqual({ name: "TestEntity" });
|
||||
});
|
||||
|
||||
it("should create an entity with sprite", () => {
|
||||
const id = EntityBuilder.create(world)
|
||||
.withSprite("items", 5)
|
||||
.build();
|
||||
|
||||
const sprite = world.getComponent(id, "sprite");
|
||||
expect(sprite).toEqual({ texture: "items", index: 5 });
|
||||
});
|
||||
|
||||
it("should chain multiple components", () => {
|
||||
const id = EntityBuilder.create(world)
|
||||
.withPosition(3, 7)
|
||||
.withName("ChainedEntity")
|
||||
.withSprite("dungeon", 10)
|
||||
.build();
|
||||
|
||||
expect(world.getComponent(id, "position")).toEqual({ x: 3, y: 7 });
|
||||
expect(world.getComponent(id, "name")).toEqual({ name: "ChainedEntity" });
|
||||
expect(world.getComponent(id, "sprite")).toEqual({ texture: "dungeon", index: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("withStats", () => {
|
||||
it("should create stats with defaults and overrides", () => {
|
||||
const id = EntityBuilder.create(world)
|
||||
.withStats({ maxHp: 50, hp: 50, attack: 10 })
|
||||
.build();
|
||||
|
||||
const stats = world.getComponent(id, "stats");
|
||||
expect(stats?.maxHp).toBe(50);
|
||||
expect(stats?.hp).toBe(50);
|
||||
expect(stats?.attack).toBe(10);
|
||||
// Check defaults are applied
|
||||
expect(stats?.defense).toBe(0);
|
||||
expect(stats?.level).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("asPlayer", () => {
|
||||
it("should add player tag and actorType", () => {
|
||||
const id = EntityBuilder.create(world)
|
||||
.asPlayer()
|
||||
.build();
|
||||
|
||||
expect(world.hasComponent(id, "player")).toBe(true);
|
||||
expect(world.getComponent(id, "actorType")).toEqual({ type: "player" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("asEnemy", () => {
|
||||
it("should configure as enemy with AI", () => {
|
||||
const id = EntityBuilder.create(world)
|
||||
.withPosition(0, 0)
|
||||
.asEnemy("rat")
|
||||
.build();
|
||||
|
||||
expect(world.getComponent(id, "actorType")).toEqual({ type: "rat" });
|
||||
expect(world.hasComponent(id, "ai")).toBe(true);
|
||||
expect(world.getComponent(id, "ai")?.state).toBe("wandering");
|
||||
});
|
||||
|
||||
it("should throw for player type", () => {
|
||||
expect(() => {
|
||||
EntityBuilder.create(world).asEnemy("player" as any);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("asTrap", () => {
|
||||
it("should create a trap with damage", () => {
|
||||
const id = EntityBuilder.create(world)
|
||||
.withPosition(5, 5)
|
||||
.asTrap(15)
|
||||
.build();
|
||||
|
||||
const trigger = world.getComponent(id, "trigger");
|
||||
expect(trigger?.onEnter).toBe(true);
|
||||
expect(trigger?.damage).toBe(15);
|
||||
expect(trigger?.oneShot).toBe(false);
|
||||
});
|
||||
|
||||
it("should create a one-shot trap", () => {
|
||||
const id = EntityBuilder.create(world)
|
||||
.asTrap(10, true)
|
||||
.build();
|
||||
|
||||
const trigger = world.getComponent(id, "trigger");
|
||||
expect(trigger?.oneShot).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("asDestructible", () => {
|
||||
it("should create destructible with hp", () => {
|
||||
const id = EntityBuilder.create(world)
|
||||
.asDestructible(20)
|
||||
.build();
|
||||
|
||||
const destructible = world.getComponent(id, "destructible");
|
||||
expect(destructible?.hp).toBe(20);
|
||||
expect(destructible?.maxHp).toBe(20);
|
||||
});
|
||||
|
||||
it("should create destructible with loot table", () => {
|
||||
const id = EntityBuilder.create(world)
|
||||
.asDestructible(10, 10, { lootTable: "barrel_loot" })
|
||||
.build();
|
||||
|
||||
const destructible = world.getComponent(id, "destructible");
|
||||
expect(destructible?.lootTable).toBe("barrel_loot");
|
||||
});
|
||||
});
|
||||
|
||||
describe("asCollectible", () => {
|
||||
it("should create an exp orb collectible", () => {
|
||||
const id = EntityBuilder.create(world)
|
||||
.asCollectible("exp_orb", 25)
|
||||
.build();
|
||||
|
||||
const collectible = world.getComponent(id, "collectible");
|
||||
expect(collectible?.type).toBe("exp_orb");
|
||||
expect(collectible?.amount).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withCombat", () => {
|
||||
it("should add combat tracking component", () => {
|
||||
const id = EntityBuilder.create(world)
|
||||
.withCombat()
|
||||
.build();
|
||||
|
||||
expect(world.hasComponent(id, "combat")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getId", () => {
|
||||
it("should return entity id before build", () => {
|
||||
const builder = EntityBuilder.create(world);
|
||||
const id = builder.getId();
|
||||
|
||||
expect(typeof id).toBe("number");
|
||||
expect(id).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prefabs", () => {
|
||||
let world: ECSWorld;
|
||||
|
||||
beforeEach(() => {
|
||||
world = new ECSWorld();
|
||||
});
|
||||
|
||||
it("should create a rat with all required components", () => {
|
||||
const id = Prefabs.rat(world, 5, 10);
|
||||
|
||||
expect(world.getComponent(id, "position")).toEqual({ x: 5, y: 10 });
|
||||
expect(world.getComponent(id, "name")).toEqual({ name: "Rat" });
|
||||
expect(world.getComponent(id, "actorType")).toEqual({ type: "rat" });
|
||||
expect(world.hasComponent(id, "ai")).toBe(true);
|
||||
expect(world.hasComponent(id, "stats")).toBe(true);
|
||||
expect(world.hasComponent(id, "combat")).toBe(true);
|
||||
});
|
||||
|
||||
it("should create a bat with all required components", () => {
|
||||
const id = Prefabs.bat(world, 3, 7);
|
||||
|
||||
expect(world.getComponent(id, "position")).toEqual({ x: 3, y: 7 });
|
||||
expect(world.getComponent(id, "actorType")).toEqual({ type: "bat" });
|
||||
});
|
||||
|
||||
it("should create poison trap", () => {
|
||||
const id = Prefabs.poisonTrap(world, 2, 4, 5, 3);
|
||||
|
||||
expect(world.getComponent(id, "position")).toEqual({ x: 2, y: 4 });
|
||||
expect(world.getComponent(id, "trigger")?.effect).toBe("poison");
|
||||
expect(world.getComponent(id, "trigger")?.onEnter).toBe(true);
|
||||
});
|
||||
|
||||
it("should create barrel", () => {
|
||||
const id = Prefabs.barrel(world, 1, 1, "gold_loot");
|
||||
|
||||
expect(world.getComponent(id, "destructible")?.hp).toBe(5);
|
||||
expect(world.getComponent(id, "destructible")?.lootTable).toBe("gold_loot");
|
||||
});
|
||||
|
||||
it("should create exp orb", () => {
|
||||
const id = Prefabs.expOrb(world, 0, 0, 50);
|
||||
|
||||
expect(world.getComponent(id, "collectible")).toEqual({ type: "exp_orb", amount: 50 });
|
||||
});
|
||||
|
||||
it("should create player", () => {
|
||||
const id = Prefabs.player(world, 10, 10);
|
||||
|
||||
expect(world.hasComponent(id, "player")).toBe(true);
|
||||
expect(world.getComponent(id, "actorType")).toEqual({ type: "player" });
|
||||
expect(world.hasComponent(id, "stats")).toBe(true);
|
||||
expect(world.hasComponent(id, "energy")).toBe(true);
|
||||
});
|
||||
});
|
||||
192
src/engine/ecs/__tests__/EventBus.test.ts
Normal file
192
src/engine/ecs/__tests__/EventBus.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { EventBus, type GameEvent } from "../EventBus";
|
||||
|
||||
describe("EventBus", () => {
|
||||
let eventBus: EventBus;
|
||||
|
||||
beforeEach(() => {
|
||||
eventBus = new EventBus();
|
||||
});
|
||||
|
||||
describe("on() and emit()", () => {
|
||||
it("should call handler when matching event is emitted", () => {
|
||||
const handler = vi.fn();
|
||||
eventBus.on("damage", handler);
|
||||
|
||||
const event: GameEvent = { type: "damage", entityId: 1, amount: 10 };
|
||||
eventBus.emit(event);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it("should not call handler for non-matching event types", () => {
|
||||
const damageHandler = vi.fn();
|
||||
const healHandler = vi.fn();
|
||||
|
||||
eventBus.on("damage", damageHandler);
|
||||
eventBus.on("heal", healHandler);
|
||||
|
||||
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
|
||||
|
||||
expect(damageHandler).toHaveBeenCalledTimes(1);
|
||||
expect(healHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call multiple handlers for the same event type", () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
eventBus.on("death", handler1);
|
||||
eventBus.on("death", handler2);
|
||||
|
||||
eventBus.emit({ type: "death", entityId: 5 });
|
||||
|
||||
expect(handler1).toHaveBeenCalledTimes(1);
|
||||
expect(handler2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should allow handler to be called multiple times", () => {
|
||||
const handler = vi.fn();
|
||||
eventBus.on("damage", handler);
|
||||
|
||||
eventBus.emit({ type: "damage", entityId: 1, amount: 5 });
|
||||
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
|
||||
eventBus.emit({ type: "damage", entityId: 1, amount: 15 });
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsubscribe", () => {
|
||||
it("should return unsubscribe function that removes handler", () => {
|
||||
const handler = vi.fn();
|
||||
const unsubscribe = eventBus.on("damage", handler);
|
||||
|
||||
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
eventBus.emit({ type: "damage", entityId: 1, amount: 20 });
|
||||
expect(handler).toHaveBeenCalledTimes(1); // Still 1, not called again
|
||||
});
|
||||
});
|
||||
|
||||
describe("once()", () => {
|
||||
it("should call handler only once then auto-remove", () => {
|
||||
const handler = vi.fn();
|
||||
eventBus.once("status_applied", handler);
|
||||
|
||||
eventBus.emit({ type: "status_applied", entityId: 1, status: "poison", duration: 5 });
|
||||
eventBus.emit({ type: "status_applied", entityId: 1, status: "poison", duration: 5 });
|
||||
eventBus.emit({ type: "status_applied", entityId: 1, status: "poison", duration: 5 });
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call all once handlers for the same event", () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
eventBus.once("death", handler1);
|
||||
eventBus.once("death", handler2);
|
||||
|
||||
eventBus.emit({ type: "death", entityId: 1 });
|
||||
|
||||
expect(handler1).toHaveBeenCalledTimes(1);
|
||||
expect(handler2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("off()", () => {
|
||||
it("should remove all listeners for a specific event type", () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
eventBus.on("damage", handler1);
|
||||
eventBus.on("damage", handler2);
|
||||
eventBus.once("damage", vi.fn());
|
||||
|
||||
eventBus.off("damage");
|
||||
|
||||
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
|
||||
|
||||
expect(handler1).not.toHaveBeenCalled();
|
||||
expect(handler2).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear()", () => {
|
||||
it("should remove all listeners for all event types", () => {
|
||||
const damageHandler = vi.fn();
|
||||
const healHandler = vi.fn();
|
||||
|
||||
eventBus.on("damage", damageHandler);
|
||||
eventBus.on("heal", healHandler);
|
||||
|
||||
eventBus.clear();
|
||||
|
||||
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
|
||||
eventBus.emit({ type: "heal", entityId: 1, amount: 10 });
|
||||
|
||||
expect(damageHandler).not.toHaveBeenCalled();
|
||||
expect(healHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasListeners()", () => {
|
||||
it("should return true when listeners exist", () => {
|
||||
eventBus.on("damage", vi.fn());
|
||||
expect(eventBus.hasListeners("damage")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when no listeners exist", () => {
|
||||
expect(eventBus.hasListeners("damage")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for once listeners", () => {
|
||||
eventBus.once("death", vi.fn());
|
||||
expect(eventBus.hasListeners("death")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false after unsubscribe", () => {
|
||||
const unsubscribe = eventBus.on("damage", vi.fn());
|
||||
expect(eventBus.hasListeners("damage")).toBe(true);
|
||||
|
||||
unsubscribe();
|
||||
expect(eventBus.hasListeners("damage")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("event types", () => {
|
||||
it("should handle all defined event types", () => {
|
||||
const handlers = {
|
||||
damage: vi.fn(),
|
||||
heal: vi.fn(),
|
||||
death: vi.fn(),
|
||||
component_added: vi.fn(),
|
||||
stepped_on: vi.fn(),
|
||||
status_applied: vi.fn(),
|
||||
trigger_activated: vi.fn(),
|
||||
};
|
||||
|
||||
Object.entries(handlers).forEach(([type, handler]) => {
|
||||
eventBus.on(type as any, handler);
|
||||
});
|
||||
|
||||
// Emit various events
|
||||
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
|
||||
eventBus.emit({ type: "heal", entityId: 1, amount: 5 });
|
||||
eventBus.emit({ type: "death", entityId: 1 });
|
||||
eventBus.emit({ type: "component_added", entityId: 1, componentType: "stats" });
|
||||
eventBus.emit({ type: "stepped_on", entityId: 1, x: 5, y: 10 });
|
||||
eventBus.emit({ type: "status_applied", entityId: 1, status: "poison", duration: 3 });
|
||||
eventBus.emit({ type: "trigger_activated", triggerId: 1, activatorId: 2 });
|
||||
|
||||
Object.values(handlers).forEach((handler) => {
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
288
src/engine/ecs/__tests__/SystemRegistry.test.ts
Normal file
288
src/engine/ecs/__tests__/SystemRegistry.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { System, SystemRegistry } from "../System";
|
||||
import { ECSWorld } from "../World";
|
||||
import { EventBus } from "../EventBus";
|
||||
import { type EntityId } from "../../../core/types";
|
||||
import { type ComponentType } from "../components";
|
||||
|
||||
// Test system implementations
|
||||
class TestSystemA extends System {
|
||||
readonly name = "TestA";
|
||||
readonly requiredComponents: readonly ComponentType[] = ["position"];
|
||||
readonly priority = 0;
|
||||
|
||||
updateCalls: EntityId[][] = [];
|
||||
|
||||
update(entities: EntityId[], _world: ECSWorld): void {
|
||||
this.updateCalls.push([...entities]);
|
||||
}
|
||||
}
|
||||
|
||||
class TestSystemB extends System {
|
||||
readonly name = "TestB";
|
||||
readonly requiredComponents: readonly ComponentType[] = ["position", "stats"];
|
||||
readonly priority = 10; // Lower priority = runs later
|
||||
|
||||
updateCalls: EntityId[][] = [];
|
||||
|
||||
update(entities: EntityId[], _world: ECSWorld): void {
|
||||
this.updateCalls.push([...entities]);
|
||||
}
|
||||
}
|
||||
|
||||
class TestSystemWithHooks extends System {
|
||||
readonly name = "TestWithHooks";
|
||||
readonly requiredComponents: readonly ComponentType[] = ["position"];
|
||||
|
||||
registered = false;
|
||||
unregistered = false;
|
||||
addedEntities: EntityId[] = [];
|
||||
removedEntities: EntityId[] = [];
|
||||
|
||||
update(_entities: EntityId[], _world: ECSWorld): void {}
|
||||
|
||||
onRegister(_world: ECSWorld): void {
|
||||
this.registered = true;
|
||||
}
|
||||
|
||||
onUnregister(_world: ECSWorld): void {
|
||||
this.unregistered = true;
|
||||
}
|
||||
|
||||
onEntityAdded(entityId: EntityId, _world: ECSWorld): void {
|
||||
this.addedEntities.push(entityId);
|
||||
}
|
||||
|
||||
onEntityRemoved(entityId: EntityId, _world: ECSWorld): void {
|
||||
this.removedEntities.push(entityId);
|
||||
}
|
||||
}
|
||||
|
||||
describe("SystemRegistry", () => {
|
||||
let world: ECSWorld;
|
||||
let registry: SystemRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
world = new ECSWorld();
|
||||
registry = new SystemRegistry(world);
|
||||
});
|
||||
|
||||
describe("register()", () => {
|
||||
it("should register a system", () => {
|
||||
const system = new TestSystemA();
|
||||
registry.register(system);
|
||||
|
||||
expect(registry.has("TestA")).toBe(true);
|
||||
});
|
||||
|
||||
it("should call onRegister when registering", () => {
|
||||
const system = new TestSystemWithHooks();
|
||||
registry.register(system);
|
||||
|
||||
expect(system.registered).toBe(true);
|
||||
});
|
||||
|
||||
it("should inject event bus into system", () => {
|
||||
const eventBus = new EventBus();
|
||||
const registryWithEvents = new SystemRegistry(world, eventBus);
|
||||
|
||||
const system = new TestSystemA();
|
||||
const setEventBusSpy = vi.spyOn(system, "setEventBus");
|
||||
|
||||
registryWithEvents.register(system);
|
||||
|
||||
expect(setEventBusSpy).toHaveBeenCalledWith(eventBus);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unregister()", () => {
|
||||
it("should unregister by instance", () => {
|
||||
const system = new TestSystemA();
|
||||
registry.register(system);
|
||||
|
||||
const result = registry.unregister(system);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(registry.has("TestA")).toBe(false);
|
||||
});
|
||||
|
||||
it("should unregister by name", () => {
|
||||
registry.register(new TestSystemA());
|
||||
|
||||
const result = registry.unregister("TestA");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(registry.has("TestA")).toBe(false);
|
||||
});
|
||||
|
||||
it("should call onUnregister when unregistering", () => {
|
||||
const system = new TestSystemWithHooks();
|
||||
registry.register(system);
|
||||
registry.unregister(system);
|
||||
|
||||
expect(system.unregistered).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for unknown system", () => {
|
||||
const result = registry.unregister("Unknown");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get()", () => {
|
||||
it("should return system by name", () => {
|
||||
const system = new TestSystemA();
|
||||
registry.register(system);
|
||||
|
||||
expect(registry.get("TestA")).toBe(system);
|
||||
});
|
||||
|
||||
it("should return undefined for unknown system", () => {
|
||||
expect(registry.get("Unknown")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateAll()", () => {
|
||||
it("should update all systems", () => {
|
||||
const systemA = new TestSystemA();
|
||||
const systemB = new TestSystemB();
|
||||
|
||||
registry.register(systemA);
|
||||
registry.register(systemB);
|
||||
|
||||
// Create entity with position
|
||||
const id1 = world.createEntity();
|
||||
world.addComponent(id1, "position", { x: 0, y: 0 });
|
||||
|
||||
registry.updateAll();
|
||||
|
||||
expect(systemA.updateCalls.length).toBe(1);
|
||||
expect(systemA.updateCalls[0]).toContain(id1);
|
||||
});
|
||||
|
||||
it("should pass only matching entities to each system", () => {
|
||||
const systemA = new TestSystemA(); // needs position
|
||||
const systemB = new TestSystemB(); // needs position + stats
|
||||
|
||||
registry.register(systemA);
|
||||
registry.register(systemB);
|
||||
|
||||
// Entity with only position
|
||||
const id1 = world.createEntity();
|
||||
world.addComponent(id1, "position", { x: 0, y: 0 });
|
||||
|
||||
// Entity with position and stats
|
||||
const id2 = world.createEntity();
|
||||
world.addComponent(id2, "position", { x: 1, y: 1 });
|
||||
world.addComponent(id2, "stats", { hp: 10, maxHp: 10 } as any);
|
||||
|
||||
registry.updateAll();
|
||||
|
||||
// SystemA should get both entities
|
||||
expect(systemA.updateCalls[0]).toContain(id1);
|
||||
expect(systemA.updateCalls[0]).toContain(id2);
|
||||
|
||||
// SystemB should only get entity with both components
|
||||
expect(systemB.updateCalls[0]).not.toContain(id1);
|
||||
expect(systemB.updateCalls[0]).toContain(id2);
|
||||
});
|
||||
|
||||
it("should respect priority order", () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
class PrioritySystemLow extends System {
|
||||
readonly name = "Low";
|
||||
readonly requiredComponents: readonly ComponentType[] = ["position"];
|
||||
readonly priority = 100;
|
||||
update() { callOrder.push("Low"); }
|
||||
}
|
||||
|
||||
class PrioritySystemHigh extends System {
|
||||
readonly name = "High";
|
||||
readonly requiredComponents: readonly ComponentType[] = ["position"];
|
||||
readonly priority = -10;
|
||||
update() { callOrder.push("High"); }
|
||||
}
|
||||
|
||||
// Register in reverse order
|
||||
registry.register(new PrioritySystemLow());
|
||||
registry.register(new PrioritySystemHigh());
|
||||
|
||||
const id = world.createEntity();
|
||||
world.addComponent(id, "position", { x: 0, y: 0 });
|
||||
|
||||
registry.updateAll();
|
||||
|
||||
expect(callOrder).toEqual(["High", "Low"]);
|
||||
});
|
||||
|
||||
it("should skip disabled systems", () => {
|
||||
const system = new TestSystemA();
|
||||
registry.register(system);
|
||||
|
||||
const id = world.createEntity();
|
||||
world.addComponent(id, "position", { x: 0, y: 0 });
|
||||
|
||||
system.enabled = false;
|
||||
registry.updateAll();
|
||||
|
||||
expect(system.updateCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setEnabled()", () => {
|
||||
it("should enable/disable system by name", () => {
|
||||
const system = new TestSystemA();
|
||||
registry.register(system);
|
||||
|
||||
registry.setEnabled("TestA", false);
|
||||
expect(system.enabled).toBe(false);
|
||||
|
||||
registry.setEnabled("TestA", true);
|
||||
expect(system.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for unknown system", () => {
|
||||
expect(registry.setEnabled("Unknown", false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entity notifications", () => {
|
||||
it("should notify systems when entity is added", () => {
|
||||
const system = new TestSystemWithHooks();
|
||||
registry.register(system);
|
||||
|
||||
const id = world.createEntity();
|
||||
world.addComponent(id, "position", { x: 0, y: 0 });
|
||||
|
||||
registry.notifyEntityAdded(id);
|
||||
|
||||
expect(system.addedEntities).toContain(id);
|
||||
});
|
||||
|
||||
it("should notify systems when entity is removed", () => {
|
||||
const system = new TestSystemWithHooks();
|
||||
registry.register(system);
|
||||
|
||||
const id = world.createEntity();
|
||||
world.addComponent(id, "position", { x: 0, y: 0 });
|
||||
|
||||
registry.notifyEntityRemoved(id);
|
||||
|
||||
expect(system.removedEntities).toContain(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSystems()", () => {
|
||||
it("should return all registered systems", () => {
|
||||
registry.register(new TestSystemA());
|
||||
registry.register(new TestSystemB());
|
||||
|
||||
const systems = registry.getSystems();
|
||||
|
||||
expect(systems.length).toBe(2);
|
||||
expect(systems.map(s => s.name)).toContain("TestA");
|
||||
expect(systems.map(s => s.name)).toContain("TestB");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Vec2, type Stats, type ActorType, type EnemyAIState } from "../../core/types";
|
||||
import { type Vec2, type Stats, type ActorType, type EnemyAIState, type EntityId } from "../../core/types";
|
||||
|
||||
export interface PositionComponent extends Vec2 {}
|
||||
|
||||
@@ -35,7 +35,75 @@ export interface ActorTypeComponent {
|
||||
type: ActorType;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// New Components for Extended Gameplay
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* For traps, pressure plates, AOE zones, etc.
|
||||
* Entities with this component react when other entities step on/off them.
|
||||
*/
|
||||
export interface TriggerComponent {
|
||||
onEnter?: boolean; // Trigger when entity steps on this tile
|
||||
onExit?: boolean; // Trigger when entity leaves this tile
|
||||
oneShot?: boolean; // Destroy/disable after triggering once
|
||||
triggered?: boolean; // Has already triggered (for oneShot triggers)
|
||||
damage?: number; // Damage to deal on trigger (for traps)
|
||||
effect?: string; // Status effect to apply (e.g., "poison", "slow")
|
||||
effectDuration?: number; // Duration of applied effect
|
||||
}
|
||||
|
||||
/**
|
||||
* Status effect instance applied to an entity.
|
||||
*/
|
||||
export interface StatusEffect {
|
||||
type: string; // "poison", "burning", "frozen", "slow", "regen", etc.
|
||||
duration: number; // Remaining turns
|
||||
magnitude?: number; // Damage per turn, slow %, heal per turn, etc.
|
||||
source?: EntityId; // Who/what applied this effect
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for multiple status effects on an entity.
|
||||
* Systems can iterate through effects each turn to apply them.
|
||||
*/
|
||||
export interface StatusEffectsComponent {
|
||||
effects: StatusEffect[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Combat-specific tracking data.
|
||||
* Separates combat state from general stats for cleaner systems.
|
||||
*/
|
||||
export interface CombatComponent {
|
||||
lastAttackTurn?: number; // Turn when entity last attacked
|
||||
lastDamageTurn?: number; // Turn when entity last took damage
|
||||
damageTakenThisTurn?: number; // Accumulated damage this turn
|
||||
damageDealtThisTurn?: number; // Accumulated damage dealt this turn
|
||||
killCount?: number; // Total kills by this entity
|
||||
comboCount?: number; // Consecutive hits for combo systems
|
||||
}
|
||||
|
||||
/**
|
||||
* For destructible objects like barrels, crates, doors, etc.
|
||||
*/
|
||||
export interface DestructibleComponent {
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
destroyedTile?: number; // Tile type to become when destroyed (e.g., rubble)
|
||||
lootTable?: string; // ID of loot table to roll from on destruction
|
||||
}
|
||||
|
||||
/**
|
||||
* For items laying on the ground that can be picked up.
|
||||
*/
|
||||
export interface GroundItemComponent {
|
||||
itemId: string; // Reference to item definition
|
||||
quantity: number; // Stack size
|
||||
}
|
||||
|
||||
export type ComponentMap = {
|
||||
// Core components
|
||||
position: PositionComponent;
|
||||
stats: StatsComponent;
|
||||
energy: EnergyComponent;
|
||||
@@ -45,6 +113,14 @@ export type ComponentMap = {
|
||||
sprite: SpriteComponent;
|
||||
name: NameComponent;
|
||||
actorType: ActorTypeComponent;
|
||||
|
||||
// Extended gameplay components
|
||||
trigger: TriggerComponent;
|
||||
statusEffects: StatusEffectsComponent;
|
||||
combat: CombatComponent;
|
||||
destructible: DestructibleComponent;
|
||||
groundItem: GroundItemComponent;
|
||||
};
|
||||
|
||||
export type ComponentType = keyof ComponentMap;
|
||||
|
||||
|
||||
183
src/engine/ecs/systems/StatusEffectSystem.ts
Normal file
183
src/engine/ecs/systems/StatusEffectSystem.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { System } from "../System";
|
||||
import { type ECSWorld } from "../World";
|
||||
import { type ComponentType, type StatusEffect } from "../components";
|
||||
import { type EntityId } from "../../../core/types";
|
||||
|
||||
/**
|
||||
* Processes status effects on entities each turn.
|
||||
* Applies damage/healing, decrements durations, and removes expired effects.
|
||||
*
|
||||
* @example
|
||||
* registry.register(new StatusEffectSystem());
|
||||
*
|
||||
* // Apply poison to an entity
|
||||
* world.addComponent(entityId, "statusEffects", {
|
||||
* effects: [{ type: "poison", duration: 5, magnitude: 3 }]
|
||||
* });
|
||||
*/
|
||||
export class StatusEffectSystem extends System {
|
||||
readonly name = "StatusEffect";
|
||||
readonly requiredComponents: readonly ComponentType[] = ["statusEffects", "stats"];
|
||||
readonly priority = 10; // Run after movement/triggers
|
||||
|
||||
update(entities: EntityId[], world: ECSWorld, _dt?: number): void {
|
||||
for (const entityId of entities) {
|
||||
const statusEffects = world.getComponent(entityId, "statusEffects");
|
||||
const stats = world.getComponent(entityId, "stats");
|
||||
|
||||
if (!statusEffects || !stats) continue;
|
||||
|
||||
const expiredEffects: StatusEffect[] = [];
|
||||
|
||||
for (const effect of statusEffects.effects) {
|
||||
this.processEffect(entityId, effect, stats);
|
||||
|
||||
effect.duration--;
|
||||
|
||||
if (effect.duration <= 0) {
|
||||
expiredEffects.push(effect);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove expired effects
|
||||
if (expiredEffects.length > 0) {
|
||||
statusEffects.effects = statusEffects.effects.filter(
|
||||
e => !expiredEffects.includes(e)
|
||||
);
|
||||
|
||||
// Emit events for expired effects
|
||||
for (const expired of expiredEffects) {
|
||||
this.eventBus?.emit({
|
||||
type: "status_expired",
|
||||
entityId,
|
||||
status: expired.type
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Emit tick events for remaining effects
|
||||
for (const effect of statusEffects.effects) {
|
||||
this.eventBus?.emit({
|
||||
type: "status_tick",
|
||||
entityId,
|
||||
status: effect.type,
|
||||
remaining: effect.duration
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the effect of a single status effect.
|
||||
*/
|
||||
private processEffect(
|
||||
entityId: EntityId,
|
||||
effect: StatusEffect,
|
||||
stats: { hp: number; maxHp: number; [key: string]: any }
|
||||
): void {
|
||||
const magnitude = effect.magnitude ?? 1;
|
||||
|
||||
switch (effect.type) {
|
||||
case "poison":
|
||||
case "burning":
|
||||
// Damage over time
|
||||
const damage = magnitude;
|
||||
stats.hp = Math.max(0, stats.hp - damage);
|
||||
this.eventBus?.emit({
|
||||
type: "damage",
|
||||
entityId,
|
||||
amount: damage,
|
||||
source: effect.source
|
||||
});
|
||||
break;
|
||||
|
||||
case "regen":
|
||||
case "healing":
|
||||
// Heal over time
|
||||
const heal = magnitude;
|
||||
stats.hp = Math.min(stats.maxHp, stats.hp + heal);
|
||||
this.eventBus?.emit({
|
||||
type: "heal",
|
||||
entityId,
|
||||
amount: heal
|
||||
});
|
||||
break;
|
||||
|
||||
case "slow":
|
||||
// Slow is typically checked elsewhere (movement system)
|
||||
// This just maintains the effect tracking
|
||||
break;
|
||||
|
||||
case "frozen":
|
||||
// Frozen prevents actions (checked by AI/input systems)
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown effect type - custom handlers can subscribe to status_tick
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to apply a status effect to an entity.
|
||||
*/
|
||||
export function applyStatusEffect(
|
||||
world: ECSWorld,
|
||||
entityId: EntityId,
|
||||
effect: StatusEffect
|
||||
): void {
|
||||
let statusEffects = world.getComponent(entityId, "statusEffects");
|
||||
|
||||
if (!statusEffects) {
|
||||
statusEffects = { effects: [] };
|
||||
world.addComponent(entityId, "statusEffects", statusEffects);
|
||||
}
|
||||
|
||||
// Check for existing effect of same type
|
||||
const existing = statusEffects.effects.find(e => e.type === effect.type);
|
||||
|
||||
if (existing) {
|
||||
// Refresh duration and update magnitude if higher
|
||||
existing.duration = Math.max(existing.duration, effect.duration);
|
||||
if (effect.magnitude !== undefined) {
|
||||
existing.magnitude = Math.max(existing.magnitude ?? 0, effect.magnitude);
|
||||
}
|
||||
} else {
|
||||
statusEffects.effects.push({ ...effect });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to remove a status effect from an entity.
|
||||
*/
|
||||
export function removeStatusEffect(
|
||||
world: ECSWorld,
|
||||
entityId: EntityId,
|
||||
effectType: string
|
||||
): boolean {
|
||||
const statusEffects = world.getComponent(entityId, "statusEffects");
|
||||
|
||||
if (!statusEffects) return false;
|
||||
|
||||
const index = statusEffects.effects.findIndex(e => e.type === effectType);
|
||||
|
||||
if (index !== -1) {
|
||||
statusEffects.effects.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if an entity has a specific status effect.
|
||||
*/
|
||||
export function hasStatusEffect(
|
||||
world: ECSWorld,
|
||||
entityId: EntityId,
|
||||
effectType: string
|
||||
): boolean {
|
||||
const statusEffects = world.getComponent(entityId, "statusEffects");
|
||||
return statusEffects?.effects.some(e => e.type === effectType) ?? false;
|
||||
}
|
||||
166
src/engine/ecs/systems/TriggerSystem.ts
Normal file
166
src/engine/ecs/systems/TriggerSystem.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { System } from "../System";
|
||||
import { type ECSWorld } from "../World";
|
||||
import { type ComponentType } from "../components";
|
||||
import { type EntityId } from "../../../core/types";
|
||||
import { applyStatusEffect } from "./StatusEffectSystem";
|
||||
|
||||
/**
|
||||
* Processes trigger entities when other entities step on them.
|
||||
* Handles traps (damage), status effects, and one-shot triggers.
|
||||
*
|
||||
* @example
|
||||
* registry.register(new TriggerSystem());
|
||||
*
|
||||
* // Create a spike trap
|
||||
* world.addComponent(trapId, "trigger", {
|
||||
* onEnter: true,
|
||||
* damage: 15
|
||||
* });
|
||||
*/
|
||||
export class TriggerSystem extends System {
|
||||
readonly name = "Trigger";
|
||||
readonly requiredComponents: readonly ComponentType[] = ["trigger", "position"];
|
||||
readonly priority = 5; // Run before status effects
|
||||
|
||||
/**
|
||||
* Track which entities are currently on which triggers.
|
||||
* Used to detect enter/exit events.
|
||||
*/
|
||||
private entityPositions: Map<EntityId, { x: number; y: number }> = new Map();
|
||||
|
||||
update(entities: EntityId[], world: ECSWorld, _dt?: number): void {
|
||||
// Get all entities with positions (potential activators)
|
||||
const allWithPosition = world.getEntitiesWith("position");
|
||||
|
||||
for (const triggerId of entities) {
|
||||
const trigger = world.getComponent(triggerId, "trigger");
|
||||
const triggerPos = world.getComponent(triggerId, "position");
|
||||
|
||||
if (!trigger || !triggerPos) continue;
|
||||
if (trigger.triggered && trigger.oneShot) continue; // Already triggered one-shot
|
||||
|
||||
// Check for entities at this trigger's position
|
||||
for (const entityId of allWithPosition) {
|
||||
if (entityId === triggerId) continue; // Skip self
|
||||
|
||||
const entityPos = world.getComponent(entityId, "position");
|
||||
if (!entityPos) continue;
|
||||
|
||||
const isOnTrigger = entityPos.x === triggerPos.x && entityPos.y === triggerPos.y;
|
||||
const wasOnTrigger = this.wasEntityOnTrigger(entityId, triggerPos);
|
||||
|
||||
// Handle enter
|
||||
if (trigger.onEnter && isOnTrigger && !wasOnTrigger) {
|
||||
this.activateTrigger(triggerId, entityId, trigger, world);
|
||||
}
|
||||
|
||||
// Handle exit
|
||||
if (trigger.onExit && !isOnTrigger && wasOnTrigger) {
|
||||
this.eventBus?.emit({
|
||||
type: "trigger_activated",
|
||||
triggerId,
|
||||
activatorId: entityId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update entity positions for next frame
|
||||
this.updateEntityPositions(allWithPosition, world);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a trigger on an entity.
|
||||
*/
|
||||
private activateTrigger(
|
||||
triggerId: EntityId,
|
||||
activatorId: EntityId,
|
||||
trigger: {
|
||||
damage?: number;
|
||||
effect?: string;
|
||||
effectDuration?: number;
|
||||
oneShot?: boolean;
|
||||
triggered?: boolean;
|
||||
},
|
||||
world: ECSWorld
|
||||
): void {
|
||||
// Emit trigger event
|
||||
this.eventBus?.emit({
|
||||
type: "trigger_activated",
|
||||
triggerId,
|
||||
activatorId
|
||||
});
|
||||
|
||||
// Apply damage if trap
|
||||
if (trigger.damage && trigger.damage > 0) {
|
||||
const stats = world.getComponent(activatorId, "stats");
|
||||
if (stats) {
|
||||
stats.hp = Math.max(0, stats.hp - trigger.damage);
|
||||
|
||||
this.eventBus?.emit({
|
||||
type: "damage",
|
||||
entityId: activatorId,
|
||||
amount: trigger.damage,
|
||||
source: triggerId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply status effect if specified
|
||||
if (trigger.effect) {
|
||||
applyStatusEffect(world, activatorId, {
|
||||
type: trigger.effect,
|
||||
duration: trigger.effectDuration ?? 3,
|
||||
source: triggerId
|
||||
});
|
||||
|
||||
this.eventBus?.emit({
|
||||
type: "status_applied",
|
||||
entityId: activatorId,
|
||||
status: trigger.effect,
|
||||
duration: trigger.effectDuration ?? 3
|
||||
});
|
||||
}
|
||||
|
||||
// Mark as triggered for one-shot triggers and update sprite
|
||||
if (trigger.oneShot) {
|
||||
trigger.triggered = true;
|
||||
|
||||
// Change sprite to triggered appearance (dungeon sprite 23)
|
||||
const sprite = world.getComponent(triggerId, "sprite");
|
||||
if (sprite) {
|
||||
sprite.index = 23; // Triggered/spent trap appearance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an entity was previously on a trigger position.
|
||||
*/
|
||||
private wasEntityOnTrigger(entityId: EntityId, triggerPos: { x: number; y: number }): boolean {
|
||||
const lastPos = this.entityPositions.get(entityId);
|
||||
if (!lastPos) return false;
|
||||
return lastPos.x === triggerPos.x && lastPos.y === triggerPos.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cached entity positions for next frame comparison.
|
||||
*/
|
||||
private updateEntityPositions(entities: EntityId[], world: ECSWorld): void {
|
||||
this.entityPositions.clear();
|
||||
for (const entityId of entities) {
|
||||
const pos = world.getComponent(entityId, "position");
|
||||
if (pos) {
|
||||
this.entityPositions.set(entityId, { x: pos.x, y: pos.y });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the system is registered - initialize position tracking.
|
||||
*/
|
||||
onRegister(world: ECSWorld): void {
|
||||
const allWithPosition = world.getEntitiesWith("position");
|
||||
this.updateEntityPositions(allWithPosition, world);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
} from "../../core/config/Items";
|
||||
import { seededRandom } from "../../core/math";
|
||||
import * as ROT from "rot-js";
|
||||
import { ECSWorld } from "../ecs/World";
|
||||
import { Prefabs } from "../ecs/Prefabs";
|
||||
|
||||
|
||||
interface Room {
|
||||
x: number;
|
||||
@@ -23,9 +26,9 @@ interface Room {
|
||||
* Generates a procedural dungeon world with rooms and corridors using rot-js Uniform algorithm
|
||||
* @param floor The floor number (affects difficulty)
|
||||
* @param runState Player's persistent state across floors
|
||||
* @returns Generated world and player ID
|
||||
* @returns Generated world, player ID, and ECS world with traps
|
||||
*/
|
||||
export function generateWorld(floor: number, runState: RunState): { world: World; playerId: EntityId } {
|
||||
export function generateWorld(floor: number, runState: RunState): { world: World; playerId: EntityId; ecsWorld: ECSWorld } {
|
||||
const width = GAME_CONFIG.map.width;
|
||||
const height = GAME_CONFIG.map.height;
|
||||
const tiles: Tile[] = new Array(width * height).fill(TileType.WALL);
|
||||
@@ -80,6 +83,13 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
||||
|
||||
placeEnemies(floor, rooms, actors, random);
|
||||
|
||||
// Create ECS world and place traps
|
||||
const ecsWorld = new ECSWorld();
|
||||
const occupiedPositions = new Set<string>();
|
||||
occupiedPositions.add(`${playerX},${playerY}`); // Don't place traps on player start
|
||||
occupiedPositions.add(`${exit.x},${exit.y}`); // Don't place traps on exit
|
||||
placeTraps(floor, rooms, ecsWorld, tiles, width, random, occupiedPositions);
|
||||
|
||||
// Place doors for dungeon levels (Uniform/Digger)
|
||||
// Caves (Floors 10+) shouldn't have manufactured doors
|
||||
if (floor <= 9) {
|
||||
@@ -94,7 +104,8 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
||||
|
||||
return {
|
||||
world: { width, height, tiles, actors, exit },
|
||||
playerId
|
||||
playerId,
|
||||
ecsWorld
|
||||
};
|
||||
}
|
||||
|
||||
@@ -426,6 +437,70 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Place traps randomly in dungeon rooms.
|
||||
* Trap density increases with floor depth.
|
||||
*/
|
||||
function placeTraps(
|
||||
floor: number,
|
||||
rooms: Room[],
|
||||
ecsWorld: ECSWorld,
|
||||
tiles: Tile[],
|
||||
width: number,
|
||||
random: () => number,
|
||||
occupiedPositions: Set<string>
|
||||
): void {
|
||||
// Trap configuration
|
||||
const trapTypes = ["poison", "fire", "paralysis"] as const;
|
||||
|
||||
// Number of traps scales with floor (1-2 on floor 1, up to 5-6 on floor 10)
|
||||
const minTraps = 1 + Math.floor(floor / 3);
|
||||
const maxTraps = minTraps + 2;
|
||||
const numTraps = minTraps + Math.floor(random() * (maxTraps - minTraps + 1));
|
||||
|
||||
for (let i = 0; i < numTraps; i++) {
|
||||
// Pick a random room (not the starting room)
|
||||
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
|
||||
const room = rooms[roomIdx];
|
||||
|
||||
// Try to find a valid position
|
||||
for (let attempts = 0; attempts < 10; attempts++) {
|
||||
const tx = room.x + 1 + Math.floor(random() * (room.width - 2));
|
||||
const ty = room.y + 1 + Math.floor(random() * (room.height - 2));
|
||||
const key = `${tx},${ty}`;
|
||||
|
||||
// Check if position is valid (floor tile, not occupied)
|
||||
const tileIdx = ty * width + tx;
|
||||
const isFloor = tiles[tileIdx] === TileType.EMPTY ||
|
||||
tiles[tileIdx] === TileType.EMPTY_DECO ||
|
||||
tiles[tileIdx] === TileType.GRASS_SAPLINGS;
|
||||
|
||||
if (isFloor && !occupiedPositions.has(key)) {
|
||||
// Pick a random trap type
|
||||
const trapType = trapTypes[Math.floor(random() * trapTypes.length)];
|
||||
|
||||
// Scale effect duration/magnitude with floor
|
||||
const duration = 3 + Math.floor(floor / 3);
|
||||
const magnitude = 2 + Math.floor(floor / 2);
|
||||
|
||||
switch (trapType) {
|
||||
case "poison":
|
||||
Prefabs.poisonTrap(ecsWorld, tx, ty, duration, magnitude);
|
||||
break;
|
||||
case "fire":
|
||||
Prefabs.fireTrap(ecsWorld, tx, ty, Math.ceil(duration / 2), magnitude + 2);
|
||||
break;
|
||||
case "paralysis":
|
||||
Prefabs.paralysisTrap(ecsWorld, tx, ty, Math.max(2, Math.ceil(duration / 2)));
|
||||
break;
|
||||
}
|
||||
|
||||
occupiedPositions.add(key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const makeTestWorld = generateWorld;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FovManager } from "./FovManager";
|
||||
import { MinimapRenderer } from "./MinimapRenderer";
|
||||
import { FxRenderer } from "./FxRenderer";
|
||||
import { ItemSpriteFactory } from "./ItemSpriteFactory";
|
||||
import { type ECSWorld } from "../engine/ecs/World";
|
||||
|
||||
export class DungeonRenderer {
|
||||
private scene: Phaser.Scene;
|
||||
@@ -24,6 +25,8 @@ export class DungeonRenderer {
|
||||
private fxRenderer: FxRenderer;
|
||||
|
||||
private world!: World;
|
||||
private ecsWorld?: ECSWorld;
|
||||
private trapSprites: Map<number, Phaser.GameObjects.Sprite> = new Map();
|
||||
|
||||
constructor(scene: Phaser.Scene) {
|
||||
this.scene = scene;
|
||||
@@ -32,10 +35,17 @@ export class DungeonRenderer {
|
||||
this.fxRenderer = new FxRenderer(scene);
|
||||
}
|
||||
|
||||
initializeFloor(world: World, playerId: EntityId) {
|
||||
initializeFloor(world: World, playerId: EntityId, ecsWorld?: ECSWorld) {
|
||||
this.world = world;
|
||||
this.ecsWorld = ecsWorld;
|
||||
this.fovManager.initialize(world);
|
||||
|
||||
// Clear old trap sprites
|
||||
for (const [, sprite] of this.trapSprites) {
|
||||
sprite.destroy();
|
||||
}
|
||||
this.trapSprites.clear();
|
||||
|
||||
// Setup Tilemap
|
||||
if (this.map) this.map.destroy();
|
||||
this.map = this.scene.make.tilemap({
|
||||
@@ -80,6 +90,26 @@ export class DungeonRenderer {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create sprites for ECS trap entities
|
||||
if (this.ecsWorld) {
|
||||
const traps = this.ecsWorld.getEntitiesWith("trigger", "position", "sprite");
|
||||
for (const trapId of traps) {
|
||||
const pos = this.ecsWorld.getComponent(trapId, "position");
|
||||
const spriteData = this.ecsWorld.getComponent(trapId, "sprite");
|
||||
if (pos && spriteData) {
|
||||
const sprite = this.scene.add.sprite(
|
||||
pos.x * TILE_SIZE + TILE_SIZE / 2,
|
||||
pos.y * TILE_SIZE + TILE_SIZE / 2,
|
||||
spriteData.texture,
|
||||
spriteData.index
|
||||
);
|
||||
sprite.setDepth(5); // Below actors, above floor
|
||||
sprite.setVisible(false); // Hidden until FOV reveals
|
||||
this.trapSprites.set(trapId, sprite);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,6 +175,36 @@ export class DungeonRenderer {
|
||||
}
|
||||
});
|
||||
|
||||
// Update trap sprites visibility and appearance
|
||||
if (this.ecsWorld) {
|
||||
for (const [trapId, sprite] of this.trapSprites) {
|
||||
const pos = this.ecsWorld.getComponent(trapId, "position");
|
||||
const spriteData = this.ecsWorld.getComponent(trapId, "sprite");
|
||||
|
||||
if (pos && spriteData) {
|
||||
const i = idx(this.world, pos.x, pos.y);
|
||||
const isSeen = seen[i] === 1;
|
||||
const isVis = visible[i] === 1;
|
||||
|
||||
sprite.setVisible(isSeen);
|
||||
|
||||
// Update sprite frame in case trap was triggered
|
||||
if (sprite.frame.name !== String(spriteData.index)) {
|
||||
sprite.setFrame(spriteData.index);
|
||||
}
|
||||
|
||||
// Dim if not currently visible
|
||||
if (isSeen && !isVis) {
|
||||
sprite.setAlpha(0.4);
|
||||
sprite.setTint(0x888888);
|
||||
} else {
|
||||
sprite.setAlpha(1);
|
||||
sprite.clearTint();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actors (Combatants)
|
||||
const activeEnemyIds = new Set<EntityId>();
|
||||
const activeOrbIds = new Set<EntityId>();
|
||||
|
||||
@@ -24,6 +24,11 @@ import { ItemManager } from "./systems/ItemManager";
|
||||
import { TargetingSystem } from "./systems/TargetingSystem";
|
||||
import { UpgradeManager } from "../engine/systems/UpgradeManager";
|
||||
import { InventoryOverlay } from "../ui/components/InventoryOverlay";
|
||||
import { ECSWorld } from "../engine/ecs/World";
|
||||
import { SystemRegistry } from "../engine/ecs/System";
|
||||
import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem";
|
||||
import { StatusEffectSystem } from "../engine/ecs/systems/StatusEffectSystem";
|
||||
import { EventBus } from "../engine/ecs/EventBus";
|
||||
|
||||
export class GameScene extends Phaser.Scene {
|
||||
private world!: World;
|
||||
@@ -53,6 +58,11 @@ export class GameScene extends Phaser.Scene {
|
||||
private progressionManager: ProgressionManager = new ProgressionManager();
|
||||
private targetingSystem!: TargetingSystem;
|
||||
|
||||
// ECS for traps and status effects
|
||||
private ecsWorld!: ECSWorld;
|
||||
private ecsRegistry!: SystemRegistry;
|
||||
private ecsEventBus!: EventBus;
|
||||
|
||||
private turnCount = 0; // Track turns for mana regen
|
||||
|
||||
constructor() {
|
||||
@@ -604,6 +614,54 @@ export class GameScene extends Phaser.Scene {
|
||||
if (pickedItem) {
|
||||
this.emitUIUpdate();
|
||||
}
|
||||
|
||||
// Sync player position to ECS for trap detection
|
||||
const playerEcs = this.ecsWorld.getEntitiesWith("player");
|
||||
if (playerEcs.length > 0) {
|
||||
const playerEcsId = playerEcs[0];
|
||||
const ecsPos = this.ecsWorld.getComponent(playerEcsId, "position");
|
||||
if (ecsPos) {
|
||||
ecsPos.x = player.pos.x;
|
||||
ecsPos.y = player.pos.y;
|
||||
}
|
||||
}
|
||||
|
||||
// Process traps and status effects
|
||||
this.ecsRegistry.updateAll();
|
||||
|
||||
// Handle trap events from ECS
|
||||
const trapEvents = this.ecsEventBus.drain();
|
||||
for (const ev of trapEvents) {
|
||||
if (ev.type === "trigger_activated") {
|
||||
// Get trap trigger data for status effect display
|
||||
const trapTrigger = this.ecsWorld.getComponent(ev.triggerId, "trigger");
|
||||
|
||||
if (trapTrigger?.effect) {
|
||||
// Show status effect text
|
||||
const effectColors: Record<string, string> = {
|
||||
poison: "#00ff00",
|
||||
burning: "#ff6600",
|
||||
frozen: "#00ffff"
|
||||
};
|
||||
const effectNames: Record<string, string> = {
|
||||
poison: "Poisoned!",
|
||||
burning: "Burning!",
|
||||
frozen: "Paralyzed!"
|
||||
};
|
||||
const color = effectColors[trapTrigger.effect] ?? "#ffffff";
|
||||
const text = effectNames[trapTrigger.effect] ?? trapTrigger.effect;
|
||||
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, text, color);
|
||||
}
|
||||
} else if (ev.type === "damage") {
|
||||
// Show damage number from trap
|
||||
this.dungeonRenderer.showDamage(player.pos.x, player.pos.y, ev.amount);
|
||||
} else if (ev.type === "status_applied") {
|
||||
// Already handled above via trigger_activated
|
||||
} else if (ev.type === "status_tick" && ev.entityId) {
|
||||
// Show DOT damage tick
|
||||
// Optional: could show small floating text here
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
|
||||
@@ -682,32 +740,43 @@ export class GameScene extends Phaser.Scene {
|
||||
this.floorIndex = floor;
|
||||
this.cameraController.enableFollowMode();
|
||||
|
||||
const { world, playerId } = generateWorld(floor, this.runState);
|
||||
const { world, playerId, ecsWorld } = generateWorld(floor, this.runState);
|
||||
this.world = world;
|
||||
this.playerId = playerId;
|
||||
this.entityManager = new EntityManager(this.world);
|
||||
this.itemManager.updateWorld(this.world, this.entityManager);
|
||||
|
||||
// Initialize ECS for traps and status effects
|
||||
this.ecsWorld = ecsWorld;
|
||||
this.ecsEventBus = new EventBus();
|
||||
this.ecsRegistry = new SystemRegistry(this.ecsWorld, this.ecsEventBus);
|
||||
this.ecsRegistry.register(new TriggerSystem());
|
||||
this.ecsRegistry.register(new StatusEffectSystem());
|
||||
|
||||
// Add player to ECS for trap detection
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
if (player) {
|
||||
const playerEcsId = this.ecsWorld.createEntity();
|
||||
this.ecsWorld.addComponent(playerEcsId, "position", { x: player.pos.x, y: player.pos.y });
|
||||
this.ecsWorld.addComponent(playerEcsId, "stats", player.stats);
|
||||
this.ecsWorld.addComponent(playerEcsId, "player", {});
|
||||
}
|
||||
|
||||
this.playerPath = [];
|
||||
this.awaitingPlayer = false;
|
||||
|
||||
this.cameraController.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE);
|
||||
|
||||
this.dungeonRenderer.initializeFloor(this.world, this.playerId);
|
||||
this.dungeonRenderer.initializeFloor(this.world, this.playerId, this.ecsWorld);
|
||||
|
||||
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
|
||||
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
|
||||
|
||||
|
||||
this.dungeonRenderer.computeFov(this.playerId);
|
||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||
this.cameraController.centerOnTile(player.pos.x, player.pos.y);
|
||||
this.dungeonRenderer.render(this.playerPath);
|
||||
this.emitUIUpdate();
|
||||
|
||||
// Create daggers for testing if none exist (redundant if generator does it, but good for safety)
|
||||
// Removed to rely on generator.ts
|
||||
}
|
||||
|
||||
private syncRunStateFromPlayer() {
|
||||
|
||||
@@ -164,9 +164,20 @@ describe('GameScene', () => {
|
||||
};
|
||||
mockWorld.actors.set(1, mockPlayer);
|
||||
|
||||
// Mock ecsWorld with required methods
|
||||
const mockEcsWorld = {
|
||||
createEntity: vi.fn(() => 99),
|
||||
addComponent: vi.fn(),
|
||||
getComponent: vi.fn(),
|
||||
hasComponent: vi.fn(() => false),
|
||||
getEntitiesWith: vi.fn(() => []),
|
||||
removeEntity: vi.fn(),
|
||||
};
|
||||
|
||||
(generator.generateWorld as any).mockReturnValue({
|
||||
world: mockWorld,
|
||||
playerId: 1,
|
||||
ecsWorld: mockEcsWorld,
|
||||
});
|
||||
|
||||
(simulation.stepUntilPlayerTurn as any).mockReturnValue({
|
||||
|
||||
Reference in New Issue
Block a user