feat: Add traps

This commit is contained in:
Peter Stockings
2026-01-25 16:37:46 +11:00
parent 18d4f0cdd4
commit 9552364a60
14 changed files with 2225 additions and 11 deletions

View 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
View 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
View 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
View 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;
}
}

View 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);
});
});

View 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);
});
});
});
});

View 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");
});
});
});

View File

@@ -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;

View 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;
}

View 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);
}
}

View File

@@ -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;

View File

@@ -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>();

View File

@@ -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() {

View File

@@ -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({