Feature/Refactor to use ecs architecture (#14)
All checks were successful
Build and Publish Docker Image / Build and Validate (push) Successful in 7s
Build and Publish Docker Image / Publish to Registry (push) Successful in 9s

Reviewed-on: #14
Co-authored-by: Juan Sebastian Montoya <juansmm@outlook.com>
Co-committed-by: Juan Sebastian Montoya <juansmm@outlook.com>
This commit is contained in:
Juan Sebastián Montoya 2025-11-26 15:39:05 -05:00 committed by Juan Sebastián Montoya
parent e0436e7769
commit cec1fccc22
23 changed files with 1709 additions and 650 deletions

53
src/ecs/System.js Normal file
View file

@ -0,0 +1,53 @@
/**
* Base class for all systems.
* Systems contain logic that operates on entities with specific components.
*
* @typedef {import('./World.js').EntityId} EntityId
* @typedef {import('./World.js').ComponentClass} ComponentClass
*/
export class System {
constructor() {
/** @type {import('./World.js').World|null} */
this.world = null;
/** @type {boolean} */
this.enabled = true;
}
/**
* Called once when the system is added to the world
*/
init() {}
/**
* Called every frame
* @param {number} deltaTime - Time since last frame in seconds
*/
update(_deltaTime) {}
/**
* Called when the system is removed or world is cleaned up
*/
cleanup() {}
/**
* Helper to get entities with specific components
* @param {...ComponentClass} componentClasses - The component classes to filter by
* @returns {EntityId[]} Array of entity IDs
*/
getEntities(...componentClasses) {
return this.world.getEntitiesWithComponents(...componentClasses);
}
/**
* Helper to get a component from an entity
* @template T
* @param {EntityId} entityId - The entity ID
* @param {new (...args: any[]) => T} componentClass - The component class
* @returns {T|undefined} The component instance or undefined
*/
getComponent(entityId, componentClass) {
return this.world.getComponent(entityId, componentClass);
}
}

170
src/ecs/World.js Normal file
View file

@ -0,0 +1,170 @@
/**
* World manages all entities, components, and systems in the ECS architecture.
*
* @typedef {number} EntityId - Unique identifier for an entity
* @typedef {Function} ComponentClass - A component class constructor
* @typedef {Object} Component - A component instance (data only)
*/
export class World {
constructor() {
/** @type {Map<EntityId, Set<ComponentClass>>} */
this.entities = new Map(); // entityId -> Set of component classes
/** @type {Map<ComponentClass, Map<EntityId, Component>>} */
this.components = new Map(); // componentClass -> Map(entityId -> component)
/** @type {import('./System.js').System[]} */
this.systems = [];
/** @type {EntityId} */
this.nextEntityId = 1;
}
/**
* Create a new entity and return its ID
* @returns {EntityId} The newly created entity ID
*/
createEntity() {
const id = this.nextEntityId++;
this.entities.set(id, new Set());
return id;
}
/**
* Remove an entity and all its components
* @param {EntityId} entityId - The entity to remove
*/
removeEntity(entityId) {
if (!this.entities.has(entityId)) return;
// Remove all components for this entity
const componentClasses = this.entities.get(entityId);
for (const componentClass of componentClasses) {
const componentMap = this.components.get(componentClass);
if (componentMap) {
componentMap.delete(entityId);
}
}
this.entities.delete(entityId);
}
/**
* Add a component instance to an entity. Component class is inferred from the instance.
* @param {EntityId} entityId - The entity to add the component to
* @param {Component} component - The component instance (must have a constructor)
* @throws {Error} If entity doesn't exist
*/
addComponent(entityId, component) {
if (!this.entities.has(entityId)) {
throw new Error(`Entity ${entityId} does not exist`);
}
const componentClass = component.constructor;
// Track that this entity has this component class
this.entities.get(entityId).add(componentClass);
// Store the component instance
if (!this.components.has(componentClass)) {
this.components.set(componentClass, new Map());
}
this.components.get(componentClass).set(entityId, component);
}
/**
* Get a component from an entity
* @template T
* @param {EntityId} entityId - The entity ID
* @param {new (...args: any[]) => T} componentClass - The component class
* @returns {T|undefined} The component instance or undefined
*/
getComponent(entityId, componentClass) {
const componentMap = this.components.get(componentClass);
return componentMap ? componentMap.get(entityId) : undefined;
}
/**
* Check if an entity has a component
* @param {EntityId} entityId - The entity ID
* @param {ComponentClass} componentClass - The component class
* @returns {boolean} True if the entity has the component
*/
hasComponent(entityId, componentClass) {
return this.entities.get(entityId)?.has(componentClass) || false;
}
/**
* Remove a component from an entity
* @param {EntityId} entityId - The entity ID
* @param {ComponentClass} componentClass - The component class to remove
*/
removeComponent(entityId, componentClass) {
const entityComponents = this.entities.get(entityId);
if (entityComponents) {
entityComponents.delete(componentClass);
}
const componentMap = this.components.get(componentClass);
if (componentMap) {
componentMap.delete(entityId);
}
}
/**
* Get all entities that have ALL of the specified component classes
* @param {...ComponentClass} componentClasses - The component classes to filter by
* @returns {EntityId[]} Array of entity IDs that have all specified components
*/
getEntitiesWithComponents(...componentClasses) {
const result = [];
for (const [entityId, entityComponentClasses] of this.entities.entries()) {
const hasAll = componentClasses.every(cls => entityComponentClasses.has(cls));
if (hasAll) {
result.push(entityId);
}
}
return result;
}
/**
* Add a system to the world
* @param {import('./System.js').System} system - The system to add
*/
addSystem(system) {
system.world = this;
this.systems.push(system);
if (system.init) {
system.init();
}
}
/**
* Update all systems with delta time
* @param {number} deltaTime - Time elapsed since last frame (in seconds)
*/
update(deltaTime) {
for (const system of this.systems) {
if (system.enabled !== false) {
system.update(deltaTime);
}
}
}
/**
* Cleanup all systems and clear all entities/components
*/
cleanup() {
for (const system of this.systems) {
if (system.cleanup) {
system.cleanup();
}
}
this.entities.clear();
this.components.clear();
this.systems = [];
}
}