diff --git a/.vscode/forge.code-snippets b/.vscode/forge.code-snippets new file mode 100644 index 00000000..f9ccab89 --- /dev/null +++ b/.vscode/forge.code-snippets @@ -0,0 +1,38 @@ +{ + "Forge: New ECS Component": { + "prefix": "fcomp", + "body": [ + "import { createComponentId } from '../../new-ecs/ecs-component.js';", + "", + "/**", + " * ECS-style component interface for ${1:ComponentDescription}.", + " */", + "export interface ${2:ComponentName}EcsComponent {", + " ${3:field}: ${4:type};", + "}", + "", + "export const ${2}Id = createComponentId<${2}EcsComponent>('${5:${2}}');", + ], + "description": "Create a new ECS component.", + "scope": "typescript", + }, + "Forge: New ECS System": { + "prefix": "fsys", + "body": [ + "import { EcsSystem } from '../../new-ecs/ecs-system.js';", + "", + "/**", + " * Creates an ECS system to handle ${1:description}.", + " */", + "export const create${2:systemName}EcsSystem = (): EcsSystem<[${3:CompA}]> => ({", + " query: [${4:compAId}],", + " run: (result) => {", + " const [${5:compA}] = result.components;", + " // TODO: implement system logic", + " },", + "});", + ], + "description": "Create a new ECS system.", + "scope": "typescript", + }, +} diff --git a/demo/src/animationDemo.ts b/demo/src/animationDemo.ts index 25afe225..24a66d94 100644 --- a/demo/src/animationDemo.ts +++ b/demo/src/animationDemo.ts @@ -9,8 +9,13 @@ import { Axis2dAction, buttonMoments, createAnimation, - FlipComponent, + createResetInputsEcsSystem, + createSpriteAnimationEcsSystem, + createUpdateInputEcsSystem, + flipId, Game, + InputManager, + inputsId, KeyboardAxis1dBinding, KeyboardAxis2dBinding, KeyboardInputSource, @@ -18,24 +23,23 @@ import { keyCodes, MouseAxis2dBinding, MouseInputSource, - PositionComponent, - registerInputs, - ScaleComponent, + positionId, + scaleId, Sprite, - SpriteAnimationComponent, - SpriteComponent, + spriteAnimationId, + spriteId, Time, TriggerAction, Vector2, - World, } from '../../src'; +import { EcsWorld } from '../../src/new-ecs'; import { FiniteStateMachine } from '../../src/finite-state-machine/finite-state-machine'; import { Transition } from '../../src/finite-state-machine/transition'; import { ADVENTURER_ANIMATIONS, SHIP_ANIMATIONS } from './animationEnums'; -import { ControlAdventurerComponent } from './control-adventurer-component'; +import { controlAdventurerId } from './control-adventurer-component'; export function setupAnimationsDemo( - world: World, + world: EcsWorld, game: Game, time: Time, shipSprite: Sprite, @@ -43,6 +47,9 @@ export function setupAnimationsDemo( ): ReturnType { const inputs = setupInputs(world, game, time); + // Add animation system + world.addSystem(createSpriteAnimationEcsSystem(time)); + const ShipController = createShipAnimationController(); buildShipEntities(world, shipSprite, ShipController); @@ -58,7 +65,7 @@ export function setupAnimationsDemo( return inputs; } -function setupInputs(world: World, game: Game, time: Time) { +function setupInputs(world: EcsWorld, game: Game, time: Time) { const gameInputGroup = 'game'; const attackInput = new TriggerAction('attack', gameInputGroup); @@ -78,22 +85,28 @@ function setupInputs(world: World, game: Game, time: Time) { actionResetTypes.noReset, ); - const { inputsManager } = registerInputs(world, time, { - triggerActions: [ - attackInput, - runRInput, - runLInput, - jumpInput, - takeDamageInput, - ], - axis2dActions: [axis2dInput], - axis1dActions: [axis1dInput], - }); + // Create input manager and register actions + const inputsManager = new InputManager(gameInputGroup); + inputsManager.addTriggerActions( + attackInput, + runRInput, + runLInput, + jumpInput, + takeDamageInput, + ); + inputsManager.addAxis2dActions(axis2dInput); + inputsManager.addAxis1dActions(axis1dInput); + + // Create an entity with inputs component + const inputEntity = world.createEntity(); + world.addComponent(inputEntity, inputsId, { inputManager: inputsManager }); - inputsManager.setActiveGroup(gameInputGroup); + // Add input systems + world.addSystem(createUpdateInputEcsSystem(time)); + world.addSystem(createResetInputsEcsSystem()); const keyboardInputSource = new KeyboardInputSource(inputsManager); - const mouseInputSource = new MouseInputSource(inputsManager, game); + const mouseInputSource = new MouseInputSource(inputsManager, game.container); keyboardInputSource.axis2dBindings.add( new KeyboardAxis2dBinding( @@ -234,32 +247,65 @@ function createAdventurerControllableInputs() { } function buildShipEntities( - world: World, + world: EcsWorld, shipSprite: Sprite, stateMachine: FiniteStateMachine, ) { const animationInputs = new AnimationInputs(); - world.buildAndAddEntity([ - new PositionComponent(-500, -150), - new SpriteComponent(shipSprite), - new ScaleComponent(0.5, 0.5), - new SpriteAnimationComponent(stateMachine, animationInputs), - ]); + const entity = world.createEntity(); + world.addComponent(entity, positionId, { + local: new Vector2(-500, -150), + world: new Vector2(-500, -150), + }); + world.addComponent(entity, spriteId, { + sprite: shipSprite, + enabled: true, + }); + world.addComponent(entity, scaleId, { + local: new Vector2(0.5, 0.5), + world: new Vector2(0.5, 0.5), + }); + world.addComponent(entity, spriteAnimationId, { + animationFrameIndex: 0, + playbackSpeed: 1, + frameDurationMilliseconds: 33.3333, + lastFrameChangeTimeInSeconds: 0, + animationInputs, + stateMachine, + }); } function buildAdventurerControllableEntities( - world: World, + world: EcsWorld, adventurerSprite: Sprite, stateMachine: FiniteStateMachine, animationInputs: AnimationInputs, ) { - world.buildAndAddEntity([ - new PositionComponent(400, 0), - new SpriteComponent(adventurerSprite), - new ScaleComponent(0.3, 0.6), - new SpriteAnimationComponent(stateMachine, animationInputs, 33.3333, 0.3), - new ControlAdventurerComponent(), - new FlipComponent(), - ]); + const entity = world.createEntity(); + world.addComponent(entity, positionId, { + local: new Vector2(400, 0), + world: new Vector2(400, 0), + }); + world.addComponent(entity, spriteId, { + sprite: adventurerSprite, + enabled: true, + }); + world.addComponent(entity, scaleId, { + local: new Vector2(0.3, 0.6), + world: new Vector2(0.3, 0.6), + }); + world.addComponent(entity, spriteAnimationId, { + animationFrameIndex: 0, + playbackSpeed: 0.3, + frameDurationMilliseconds: 33.3333, + lastFrameChangeTimeInSeconds: 0, + animationInputs, + stateMachine, + }); + world.addTag(entity, controlAdventurerId); + world.addComponent(entity, flipId, { + flipX: false, + flipY: false, + }); } diff --git a/demo/src/control-adventurer-component.ts b/demo/src/control-adventurer-component.ts index a6366398..93c94b6e 100644 --- a/demo/src/control-adventurer-component.ts +++ b/demo/src/control-adventurer-component.ts @@ -1,3 +1,3 @@ -import { Component } from '../../src'; +import { createTagId } from '../../src/new-ecs/ecs-component'; -export class ControlAdventurerComponent extends Component {} +export const controlAdventurerId = createTagId('control-adventurer'); diff --git a/demo/src/control-adventurer-system.ts b/demo/src/control-adventurer-system.ts index 3422c15c..56a645a9 100644 --- a/demo/src/control-adventurer-system.ts +++ b/demo/src/control-adventurer-system.ts @@ -1,54 +1,32 @@ import { - Entity, - FlipComponent, - PositionComponent, - SpriteAnimationComponent, - System, + FlipEcsComponent, + flipId, + PositionEcsComponent, + positionId, + SpriteAnimationEcsComponent, + spriteAnimationId, TriggerAction, } from '../../src'; -import { ControlAdventurerComponent } from './control-adventurer-component'; - -export class ControlAdventurerSystem extends System { - private readonly _attackTriggerInput: TriggerAction; - private readonly _runRTriggerInput: TriggerAction; - private readonly _runLTriggerInput: TriggerAction; - private readonly _jumpTriggerInput: TriggerAction; - private readonly _takeDamageTriggerInput: TriggerAction; - - constructor( - attackTriggerInput: TriggerAction, - runRTriggerInput: TriggerAction, - runLTriggerInput: TriggerAction, - jumpTriggerInput: TriggerAction, - takeDamageTriggerInput: TriggerAction, - ) { - super( - [ - ControlAdventurerComponent, - SpriteAnimationComponent, - FlipComponent, - PositionComponent, - ], - 'ControlAdventurerSystem', - ); - - this._attackTriggerInput = attackTriggerInput; - this._runRTriggerInput = runRTriggerInput; - this._runLTriggerInput = runLTriggerInput; - this._jumpTriggerInput = jumpTriggerInput; - this._takeDamageTriggerInput = takeDamageTriggerInput; - } - - public run(entity: Entity): void { - const spriteAnimationComponent = entity.getComponentRequired( - SpriteAnimationComponent, - ); - - const flipComponent = entity.getComponentRequired(FlipComponent); +import { EcsSystem } from '../../src/new-ecs'; +import { controlAdventurerId } from './control-adventurer-component'; + +export const createControlAdventurerEcsSystem = ( + attackTriggerInput: TriggerAction, + runRTriggerInput: TriggerAction, + runLTriggerInput: TriggerAction, + jumpTriggerInput: TriggerAction, + takeDamageTriggerInput: TriggerAction, +): EcsSystem< + [SpriteAnimationEcsComponent, FlipEcsComponent, PositionEcsComponent] +> => ({ + query: [spriteAnimationId, flipId, positionId], + tags: [controlAdventurerId], + run: (result) => { + const [spriteAnimationComponent, flipComponent] = result.components; const animationInputs = spriteAnimationComponent.animationInputs; - if (this._jumpTriggerInput.isTriggered) { + if (jumpTriggerInput.isTriggered) { console.log('Jumping!'); animationInputs.setTrigger('jump'); @@ -56,14 +34,14 @@ export class ControlAdventurerSystem extends System { return; } - if (this._runLTriggerInput.isTriggered) { + if (runLTriggerInput.isTriggered) { animationInputs.setToggle('run', true); flipComponent.flipX = true; return; } - if (this._runRTriggerInput.isTriggered) { + if (runRTriggerInput.isTriggered) { animationInputs.setToggle('run', true); flipComponent.flipX = false; @@ -72,13 +50,13 @@ export class ControlAdventurerSystem extends System { animationInputs.setToggle('run', false); - if (this._attackTriggerInput.isTriggered) { + if (attackTriggerInput.isTriggered) { animationInputs.setText('attack', 'attack is being set'); return; } - if (this._takeDamageTriggerInput.isTriggered) { + if (takeDamageTriggerInput.isTriggered) { const health = animationInputs.getNumber('health'); if (!health) { @@ -87,5 +65,5 @@ export class ControlAdventurerSystem extends System { health.value = Math.max(0, health.value - 50); } - } -} + }, +}); diff --git a/demo/src/create-batch.ts b/demo/src/create-batch.ts deleted file mode 100644 index 59d42dc7..00000000 --- a/demo/src/create-batch.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - createImageSprite, - Entity, - PositionComponent, - Random, - ScaleComponent, - SpriteComponent, - World, -} from '../../src'; -import { RenderContext } from '../../src/rendering/render-context'; - -export const createBatch = async ( - imageSrc: string, - world: World, - renderContext: RenderContext, - cameraEntity: Entity, - size: number = 1000, -): Promise => { - const sprite = await createImageSprite(imageSrc, renderContext, cameraEntity); - - const random = new Random(imageSrc); - const entities: Entity[] = []; - - for (let i = 0; i < size; i++) { - const entity = world.buildAndAddEntity([ - new PositionComponent( - random.randomFloat(-window.innerWidth / 2, window.innerWidth / 2), - random.randomFloat(-window.innerHeight / 2, window.innerHeight / 2), - ), - new SpriteComponent(sprite), - new ScaleComponent(0.1, 0.1), - ]); - - entities.push(entity); - } - - return entities; -}; diff --git a/demo/src/fire-system.ts b/demo/src/fire-system.ts index 203eac96..ad75ce06 100644 --- a/demo/src/fire-system.ts +++ b/demo/src/fire-system.ts @@ -1,23 +1,18 @@ -import { HoldAction, InputsComponent, System, TriggerAction } from '../../src'; +import { HoldAction, InputsEcsComponent, inputsId, TriggerAction } from '../../src'; +import { EcsSystem } from '../../src/new-ecs'; -export class FireSystem extends System { - private readonly _fireAction: TriggerAction; - private readonly _runAction: HoldAction; - - constructor(fireAction: TriggerAction, runAction: HoldAction) { - super([InputsComponent], 'FireSystem'); - - this._fireAction = fireAction; - this._runAction = runAction; - } - - public run(): void { - if (this._fireAction.isTriggered) { +export const createFireEcsSystem = ( + fireAction: TriggerAction, + runAction: HoldAction, +): EcsSystem<[InputsEcsComponent]> => ({ + query: [inputsId], + run: () => { + if (fireAction.isTriggered) { console.log(`Fire action triggered`); } - if (this._runAction.isHeld) { + if (runAction.isHeld) { console.log(`Run action is being held`); } - } -} + }, +}); diff --git a/demo/src/game.ts b/demo/src/game.ts index 7d7166a6..bfa6aa9c 100644 --- a/demo/src/game.ts +++ b/demo/src/game.ts @@ -1,79 +1,105 @@ import { - Axis1dAction, + cameraId, + createCameraEcsSystem, createGame, createImageSprite, - MouseAxis1dBinding, - MouseInputSource, - PositionComponent, + createRenderEcsSystem, + positionId, + Random, Rect, - registerCamera, - registerInputs, - RenderLayer, - RenderLayerComponent, - RenderSystem, - ScaleComponent, - SpriteComponent, + spriteId, Vector2, } from '../../src'; +import { moveId } from './move-component'; +import { createMoveEcsSystem } from './move-system'; -const { game, world, renderContext, time } = createGame('demo-container'); +enum RenderLayer { + background = 1 << 0, + default = 1 << 1, + foreground = 1 << 2, +} -const zoomInput = new Axis1dAction('zoom'); +const { game, world, renderContext, time } = createGame('demo-container'); -const { inputsManager } = registerInputs(world, time, { - axis1dActions: [zoomInput], +// Create camera entity +const cameraEntity = world.createEntity(); +world.addComponent(cameraEntity, positionId, { + world: Vector2.zero, + local: Vector2.zero, }); - -const mouseInputSource = new MouseInputSource(inputsManager, game); - -mouseInputSource.axis1dBindings.add(new MouseAxis1dBinding(zoomInput)); - -const leftCameraEntity = registerCamera(world, time, { - zoomInput, +world.addComponent(cameraEntity, cameraId, { + zoom: 1, + zoomSensitivity: 0.1, + panSensitivity: 1, + minZoom: 0.1, + maxZoom: 10, + isStatic: false, + layerMask: RenderLayer.default | RenderLayer.foreground, scissorRect: new Rect(Vector2.zero, new Vector2(0.5, 1)), }); -const rightCameraEntity = registerCamera(world, time, { - zoomInput, - scissorRect: new Rect(new Vector2(0.5, 0), new Vector2(0.5, 1)), -}); - const planetSprite = await createImageSprite( 'planet.png', renderContext, - leftCameraEntity, -); - -const shipSprite = await createImageSprite( - 'planet02.png', - renderContext, - rightCameraEntity, + RenderLayer.foreground, ); +const rand = new Random(); -const renderLayer = new RenderLayer(); - -for (let i = 0; i < 1000; i++) { - const planetEntity = world.buildAndAddEntity([ - new SpriteComponent(planetSprite), - new PositionComponent(0, 0), - new ScaleComponent(0.1, 0.1), - ]); - - renderLayer.addEntity(planetSprite.renderable, planetEntity); -} +const planetEntity = world.createEntity(); -for (let i = 0; i < 1000; i++) { - const shipEntity = world.buildAndAddEntity([ - new SpriteComponent(shipSprite), - new PositionComponent(0, 0), - new ScaleComponent(0.1, 0.1), - ]); +world.addComponent(planetEntity, positionId, { + world: Vector2.zero, + local: Vector2.zero, +}); - renderLayer.addEntity(shipSprite.renderable, shipEntity); -} +world.addComponent(planetEntity, spriteId, { + sprite: planetSprite, + enabled: true, +}); -world.buildAndAddEntity([new RenderLayerComponent(renderLayer)]); +world.addComponent(planetEntity, moveId, { + center: new Vector2( + rand.randomInt(-window.innerWidth / 2, window.innerWidth / 2), + rand.randomInt(-window.innerHeight / 2, window.innerHeight / 2), + ), + amount: 100, + offset: 0, +}); -world.addSystem(new RenderSystem(renderContext)); +let x = 0; +const batch = 1000; + +setInterval(() => { + console.log(`fps: ${time.fps} - entities: ${x}`); + + for (let i = 0; i < batch; i++) { + const planetEntity = world.createEntity(); + + world.addComponent(planetEntity, positionId, { + world: Vector2.zero, + local: Vector2.zero, + }); + + world.addComponent(planetEntity, spriteId, { + sprite: planetSprite, + enabled: true, + }); + + world.addComponent(planetEntity, moveId, { + center: new Vector2( + rand.randomInt(-window.innerWidth / 2, window.innerWidth / 2), + rand.randomInt(-window.innerHeight / 2, window.innerHeight / 2), + ), + amount: rand.randomInt(50, 150), + offset: rand.randomInt(0, 360), + }); + } + + x += batch; +}, 1000); + +world.addSystem(createMoveEcsSystem(time)); +world.addSystem(createCameraEcsSystem(time)); +world.addSystem(createRenderEcsSystem(renderContext)); game.run(); diff --git a/demo/src/move-component.ts b/demo/src/move-component.ts new file mode 100644 index 00000000..a7702be2 --- /dev/null +++ b/demo/src/move-component.ts @@ -0,0 +1,10 @@ +import { Vector2 } from '../../src'; +import { createComponentId } from '../../src/new-ecs/ecs-component'; + +export interface MoveEcsComponent { + center: Vector2; + amount: number; + offset: number; +} + +export const moveId = createComponentId('move'); diff --git a/demo/src/move-system.ts b/demo/src/move-system.ts new file mode 100644 index 00000000..2019fa6c --- /dev/null +++ b/demo/src/move-system.ts @@ -0,0 +1,20 @@ +import { PositionEcsComponent, positionId, Time } from '../../src'; +import { EcsSystem } from '../../src/new-ecs'; +import { MoveEcsComponent, moveId } from './move-component'; + +export const createMoveEcsSystem = ( + time: Time, +): EcsSystem<[PositionEcsComponent, MoveEcsComponent]> => ({ + query: [positionId, moveId], + run: (result) => { + const [positionComponent, moveComponent] = result.components; + + positionComponent.world.x = + moveComponent.center.x + + moveComponent.amount * Math.cos(time.timeInSeconds); + + positionComponent.world.y = + moveComponent.center.y + + moveComponent.amount * Math.sin(time.timeInSeconds); + }, +}); diff --git a/documentation-site/docs/docs/ecs/component.md b/documentation-site/docs/docs/ecs/component.md index cc3db3e6..b8beb064 100644 --- a/documentation-site/docs/docs/ecs/component.md +++ b/documentation-site/docs/docs/ecs/component.md @@ -2,42 +2,56 @@ sidebar_position: 4 --- -# Component +# Components -A [`Component`](../../api/classes/Component) is a simple data container. It has no logic. +A component is a simple data container. It has no logic. +There are 2 types of components; standard components and tags. -## Creating a component +## Standard components -To create a component, you need to define a class that implements the [`Component`](../../api/classes/Component) interface. The interface enforces a [`name`](../../api/classes/Component#name) property. +Standard components hold data that influences how an entity might behave -:::info +To create a standard component, you will need to create an interface that describes the pieces of data you component will contain. These pieces of data ad known as properties and are generally considered to be mutable [mutable](https://web.mit.edu/6.005/www/fa15/classes/09-immutability/#mutability). -The name property is a [symbol](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol). This ensures that even if 2 different components have the same name, they can still be uniquely identified. It is good practice to give your components unique names. - -::: +```ts +interface FireEcsComponent { + temperature: float; + color: Color; +} +``` -You can then add more properties. +Then you will need to create an ID for your component, this ID is sometimes referred to as a "component key". -:::info +```ts +const fireId = createComponentId('fire'); +``` -These properties represent game state and are generally expected to be [mutable](https://web.mit.edu/6.005/www/fa15/classes/09-immutability/#mutability) and [public](https://www.typescriptlang.org/docs/handbook/2/classes.html#member-visibility) as they will be accessed and updated frequently by your ECS systems. +The ID is used to quickly store and retrieve your components data from the ECS world. -::: +Components are meant to be composed together to make an entity. Try to find a balance between grouping related data together that will be updated together and having too much data coupled together in one component. +Components should be small and represent one concept. -Here is an example of the [`RotationComponent`](../../api/classes/RotationComponent): +To add a component to an entity: ```ts -export class RotationComponent extends Component { - public radians: number; +world.addComponent(planetEntity, positionId, { + world: Vector2.zero, + local: Vector2.zero, +}); +``` - constructor(degrees: number) { - super(); +## Tags - this.name = RotationComponent; - this.radians = (degrees * Math.PI) / 180; - } -} +Tags are simply components that do not have any properties and are used to distinguish 2 entities that would otherwise have the same set of standard components. This is particularly useful when you have 1 standard component that needs to be mutated differently based on some strategy. For example, you might have a game where there is a "player" entity and some amount of "enemy" entities. But some enemies are other human players and others are AI players. if you wanted a system to control AI behavior you might have an AI tag that can be attached to AI enemies. + +To create a tag, you will not need an interface (as there is no data), but you will still need to create an ID. + +```ts +const aiPlayerId = createTagId('ai-player'); ``` -Components are meant to be composed together to make an entity. Try to find a balance between grouping related data together that will be updated together and having too much data coupled together in one component. -Components should be small and represent one concept. +To add a tag to an entity: + +```ts +world.addTag(enemyEntity, aiPlayerId); +``` diff --git a/documentation-site/docs/docs/ecs/entity.md b/documentation-site/docs/docs/ecs/entity.md index 030d9842..b0834bd9 100644 --- a/documentation-site/docs/docs/ecs/entity.md +++ b/documentation-site/docs/docs/ecs/entity.md @@ -4,185 +4,4 @@ sidebar_position: 3 # Entity -An [`Entity`](../../api/classes/Entity) is a collection of related components with a unique ID. - -## Creating an entity and adding it to your world - -You can either create the entity and add it to the world as 2 distinct steps or you can use the [`buildAndAddEntity`](../../api/classes/World#buildandaddentity) helper on the world instance. - -:::tip - -Using the helper is cleaner and has less boilerplate. It is recommend that you use it unless you have a specific reason not to. It also ensures that you won't run into a [logic error](https://en.wikipedia.org/wiki/Logic_error) by forgetting to add the entity to the world after creating it. - -::: - -```ts title="With helper" -const playerEntity = world.buildAndAddEntity([ - new PositionComponent(10, 15), - new ScaleComponent(), - new RotationComponent(), - new SpriteComponent(sprite), - new PlayerComponent(), -]); -``` - -```ts title="Verbose, without helper" -const playerEntity = new Entity(world, [ - new PositionComponent(10, 15), - new ScaleComponent(), - new RotationComponent(), - new SpriteComponent(sprite), - new PlayerComponent(), -]); - -world.addEntity(entity); -``` - -### Creating entities with parent-child relationships - -You can define a parent entity at the time of creation by passing an options object with a `parent` property. - -```ts title="Using Entity constructor" -const parent = new Entity(world, [new PositionComponent(100, 100)]); - -// Create child with parent at construction time -const child = new Entity(world, [new PositionComponent(10, 10)], { - parent, -}); -``` - -```ts title="Using buildAndAddEntity helper" -const parent = world.buildAndAddEntity([new PositionComponent(100, 100)]); - -// Create child with parent at construction time -const child = world.buildAndAddEntity( - 'child', - [new PositionComponent(10, 10)], - { parent }, -); -``` - -### Creating disabled entities - -You can create entities that are initially disabled by passing an options object with `enabled: false`. - -```ts title="Using buildAndAddEntity helper" -// Create a disabled entity -const disabledEntity = world.buildAndAddEntity( - 'hidden-entity', - [new PositionComponent(0, 0)], - { enabled: false }, -); - -// Enable it later when needed -disabledEntity.enabled = true; -``` - -```ts title="Creating disabled child with parent" -const parent = world.buildAndAddEntity([]); - -// Create a disabled child entity with a parent -const child = world.buildAndAddEntity([], { - enabled: false, - parent, -}); -``` - -## Adding and removing components - -When creating an entity you will provide it with some initial components. -However, you will often find the need to add or remove components through out the game's lifecycle as your entity takes on new characteristics. - -### Adding a component - -The [`addComponent`](../../api/classes/Entity.md#addcomponent) instance method will add the component to the entity if it isn't already present. - -```ts -playerEntity.addComponent(new HealthBuffComponent(100)); -``` - -:::info - -The underlying component store is a [Set](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set). Which means that it will prevent you from accidentally adding the same component _instance_ twice. - -Although it won't prevent you from adding the same _type_ of component twice. If you do this, it will likely result in some unintended behavior. - -It is recommend that you do not add more than one instance of a component type to an entity. If you feel like you need to do this, consider the pattern used in the [animation feature](https://github.com/Forge-Game-Engine/Forge/tree/6eae4e51dbdc502818b1c2f3a3ffce9e4a1fd125/src/animations). - -::: - -### Removing a component - -The [`removeComponent`](../../api/classes/Entity.md#removecomponent) instance method will remove the component from the entity if it is present. - -```ts -playerEntity.removeComponent(HealthBuffComponent); -``` - -## Disabling an Entity - -If you want to temporarily disable an entity, you can do so by setting the [`enabled`](../../api/classes/Entity.md#enabled-1) property. -This means it will be ignored when sending entities to the systems. - -```ts -playerEntity.enabled = false; -``` - -You can re-enable it later by setting it to true - -```ts -playerEntity.enabled = true; -``` - -## Querying an entity for components - -There are several ways to query for components on a specific entity. - -### Check if an entity has a list of components - -If you want to check if an entity contains a list of components, you can use the [`containsAllComponents`](../../api/classes/Entity.md#containsallcomponents) instance method. - -```ts -const canBeFullyTransformed = playerEntity.containsAllComponents([ - PositionComponent, - RotationComponent, - ScaleComponent -]); - -if (canBeFullyTransformed) { - ... -} -``` - -### Get a single component - -If you want to get a single component. You can use the [`getComponent`](../../api/classes/Entity.md#getcomponent) or the [`getComponentRequired`](../../api/classes/Entity.md#getcomponentrequired) instance methods. - -The `getComponent` method will return the component if it exists or `null` if it does not exist. - -```ts -const scaleComponent = playerEntity.getComponent(ScaleComponent); - -if (scaleComponent) { - // scaleComponent could be null - // if the player entity has a scale, double it! - scaleComponent.x *= 2; - scaleComponent.y *= 2; -} -``` - -The `getComponentRequired` method will return the component if it exists or throw an error if it does not exist. - -```ts -const scaleComponent = playerEntity.getComponentRequired(ScaleComponent); // will throw if the player does not have a scale - -// the player entity definitely has a scale, double it! -scaleComponent.x *= 2; -scaleComponent.y *= 2; -``` - -:::info - -Although there are legitimate use-cases for `getComponent`. You should always use `getComponentRequired` over `getComponent` if you expect the entity to have a specific component. It prevents the need to do null checks and also ensures that your game will not silently fail if the component is missing. - -::: +An entity is simply a number. It represents a unique set of related components in the world. Through it does not hold reference to these components directly. \ No newline at end of file diff --git a/documentation-site/docs/docs/ecs/game.md b/documentation-site/docs/docs/ecs/game.md index 21f0fdac..03746830 100644 --- a/documentation-site/docs/docs/ecs/game.md +++ b/documentation-site/docs/docs/ecs/game.md @@ -3,118 +3,3 @@ sidebar_position: 1 --- # Game - -When creating a game, we need to create an instance of the [Game](../../api/classes/Game.md) class. - -The game instance is responsible for hosting [worlds](../ecs/world.md) and the main [game loop](https://gamedev.stackexchange.com/questions/651/what-should-a-main-game-loop-do). - -## Creating a game instance - -Generally you will only have one single game instance, although you can have more. - -The constructor accepts an [HTMLDivElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDivElement) or string as an optional argument. If one is not provided, a new div is appended to the document with an id of `forge-demo-game`. - -```ts -// new div appended with id "forge-demo-game" -const game = new Game(); - -// new div appended with id "game-container" -const game = new Game('game-container'); - -// use existing element -const myCustomElement = createContainer('bring-your-own-element'); -const game = new Game(myCustomElement); -``` - -## World management - -### Adding a world - -You can add a world to your game by using the [registerWorld](../../api/classes/Game.md#registerworld) method. - -```ts -const world = new World('home-town'); - -game.registerWorld(world); -``` - -:::tip - -If you're using the [`createWorld`](../../api/functions/createWorld.md) utility, it will automatically add the world to your game. - -::: - -You can register multiple worlds in a single game. It is common to only use one world. - -### Removing a world - -You can remove a world from your game by using the [deregisterWorld](../../api/classes/Game.md#deregisterworld) method. - -```ts -const world = new World('home-town'); - -game.registerWorld(world); -... -// later based on some event (e.g. clicking the "exit to main menu" button) -game.deregisterWorld(world); -``` - -### Swapping a world - -You might find that you often want to deregister a world and register a new one. You could do these using the methods described above, or you could use the [swapToWorld](../../api/classes/Game.md#swaptoworld) - -```ts -const homeTownWorld = new World('home-town'); -const arenaWorld = new World('arena'); - -game.registerWorld(homeTownWorld); -... -game.swapToWorld(arenaWorld); // This will deregister the "home-town" world and register the "arena" world -``` - -:::info - -The [swapToWorld](../../api/classes/Game.md#swaptoworld) method will deregister **all** currently registered worlds on a game. - -::: - -## Game life cycles - -### Running the game - -Once you have added your worlds, you can start the game loop by calling the [`run`](../../api/classes/Game.md#run) method. - -```ts -const game = new Game(); - -const world = new World('home-town'); -game.registerWorld(world); - -game.run(); -``` - -### Stopping the game - -You can stop the game by calling the [`stop`](../../api/classes/Game.md#stop) which is an implementation of the [`Stoppable`](../../api/interfaces/Stoppable.md) interface. - -```ts -const game = new Game(); - -... - -game.run(); -... -game.stop(); // also stops all worlds -``` - -## Handling a window resize - -When creating a game, a new [`ForgeEvent`](../../api/classes/ForgeEvent.md) is created that gets raised when the window resizes. - -```ts -const game = new Game(); - -game.onWindowResize.registerListener(() => { - console.log('window resized!'); -}); -``` diff --git a/documentation-site/docs/docs/ecs/query.md b/documentation-site/docs/docs/ecs/query.md index 3cae6b34..c78b9362 100644 --- a/documentation-site/docs/docs/ecs/query.md +++ b/documentation-site/docs/docs/ecs/query.md @@ -3,29 +3,3 @@ sidebar_position: 6 --- # Query - -A [query](../../api/type-aliases/Query.md) is an [alias](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-aliases) for an array of component symbols, usually used to define the components required on an entity to be considered when running a system. - -This is how you would define a query: - -```ts -const transformQuery = [PositionComponent, RotationComponent, ScaleComponent]; -``` - -But usually you would just pass the array into the super constructor: - -```ts -export class MovementSystem extends System { - constructor() { - super('MovementSystem', [ - PositionComponent, - VelocityComponent, - ] // this is a query - ); - } - - public run(entity: Entity): void { - ... - } -} -``` diff --git a/documentation-site/docs/docs/ecs/system.md b/documentation-site/docs/docs/ecs/system.md index f66f9f3a..c224ade3 100644 --- a/documentation-site/docs/docs/ecs/system.md +++ b/documentation-site/docs/docs/ecs/system.md @@ -3,97 +3,3 @@ sidebar_position: 5 --- # System - -A [`System`](../../api/classes/System) is the logic layer of the architecture. Systems are responsible for updating and processing entities that have specific components. While components hold data and entities group components, systems define **behavior**. - -A system iterates over all entities that contain a required set of components and performs logic on them. For example, a `PhysicsSystem` might update the position of all entities that have both a `PositionComponent` and a `PhysicsBodyComponent`. - -## Creating a system - -To create a system, you extend the [`System`](../../api/classes/System) class and implement the [`run`](../../api/classes/System#run) method, which is called once per update cycle(frame) for each entity that matches the system's [`query`](../ecs/query.md). You need to provide the [`query`](../ecs/query.md) to the [`super`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/super)(`System`) constructor. - -```ts -export class MovementSystem extends System { - constructor() { - super('MovementSystem', [ - PositionComponent, - VelocityComponent, - ]); // the second argument is the query - } - - public run(entity: Entity): void { - ... - } -} -``` - -:::info - -The entity being passed into the run method is guaranteed to contain the components specified in the [`query`](../ecs/query.md). You should always use `getComponentRequired` over `getComponent` in this situation. More details can be found [here](../ecs/entity.md#get-a-single-component). - -::: - -## The run method - -The run method is where you logic lives. Here is where you will read and mutate you components. - -### Early exit - -If your `run` method returns `true` for an entity, the system will stop processing further entities for that update cycle. This can be useful for systems that only need to act on the first matching entity. - -```ts -public run(entity: Entity): boolean | void { - // ...logic... - if (someCondition) { - return true; // Stop processing further entities - } -} -``` - -## Adding a system to the world - -Systems are registered with the world so they can be run during the game loop. - -```ts -const movementSystem = new MovementSystem(); -world.addSystems(movementSystem); -``` - -## Enabling and disabling systems - -You can temporarily disable a system by setting its `isEnabled` property to `false`. Disabled systems will not process any entities. - -```ts -movementSystem.isEnabled = false; // Will not run -movementSystem.isEnabled = true; // Will run again -``` - -## Pre-processing entities - -Systems can override the `beforeAll` method to perform logic before processing entities, such as filtering or sorting the entity list. - -```ts -public beforeAll(entities: Entity[]): Entity[] { - return entities.sort((entity) => entity.zIndex); -} -``` - -:::warning[**You may not need to use this hook!**] - -This hook causes you system to loop through entities twice. Which means it can be expensive. - -Entities will run through your system in the order that they were added to the world. If you intend on using the this function to sort entities, consider updating the registration order instead. - -Entities are already filtered (with some smart caching) by the components that they contain. If you need to filter in the `beforeAll` hook consider adding a new [tag component](https://github.com/SanderMertens/ecs-faq?tab=readme-ov-file#tag) to your entities and system query to discriminate between entities. - -::: - -## Cleaning up a system - -You can also override the `stop` method to perform cleanup when the system is stopped or removed. - -```ts -public stop() { - // Cleanup logic here -} -``` diff --git a/documentation-site/docs/docs/ecs/world.md b/documentation-site/docs/docs/ecs/world.md index c79ebd4c..a68c23fb 100644 --- a/documentation-site/docs/docs/ecs/world.md +++ b/documentation-site/docs/docs/ecs/world.md @@ -3,146 +3,3 @@ sidebar_position: 2 --- # World - -The [`World`](../../api/classes/World) class is the central manager in the Entity-Component-System (ECS) architecture. It is responsible for holding all entities and systems, coordinating updates, and managing the lifecycle of your game's logic. - -The world: - -- **Stores all entities and systems:** The world keeps track of every [`Entity`](../ecs/entity.md) and [`System`](../ecs/system.md) in your game. -- **Runs systems:** On each update, the world finds all entities that match each system's [`query`](../ecs/query.md) and calls the system's `run` method for each matching entity. -- **Manages entity and system lifecycles:** You can add, remove, or build entities and systems at runtime. -- **Hosts the [time instance](../common/time.md)** - -## Creating a World - -You can chose to create a world manually or using the [`createWorld`](../../api/functions/createWorld.md) utility. - -```ts -const world = new World('my-world'); -game.registerWorld(world); - -// or using the utility - -const { world } = createWorld('my-world', game); -``` - -:::tip - -If you're using the [`createWorld`](../../api/functions/createWorld.md) utility, it will automatically add the world to your game. - -::: - -## Adding Systems - -Systems define the logic that operates on entities with specific components. - -```ts -const movementSystem = new MovementSystem(); -const renderSystem = new RenderSystem(); - -world.addSystems(movementSystem, renderSystem); -``` - -## Adding Entities - -Entities are added to the world either by building them with the helper: - -```ts -const player = world.buildAndAddEntity([ - new PositionComponent(0, 0), - new SpriteComponent(sprite), -]); -``` - -Or by creating them directly and then adding: - -```ts -const enemy = new Entity(world, [new PositionComponent(10, 5)]); -world.addEntity(enemy); -``` - -## Querying Entities - -There are many ways to query the world for a specific entity. - -:::warning - -Querying the world is an expensive operation as the query loops through all the entities in the world. - -Do not query in loops (including the [`run`](../../api/classes/System.md#run) and [`beforeAll`](../../api/classes/System.md#beforeall) system methods). Cache queried values where possible. - -::: - -### Querying for multiple Entities - -If you want to find all the entities in the world that match a [`query`](./query.md), you can use the [`queryEntities`](../../api/classes/World.md#queryentities) method. - -```ts -const world = new World('main-world'); - -... - -const movableEntities = world.queryEntities([PositionComponent]); -``` - -### Querying for a single Entity - -If you want to find a specific entity, usually singleton type entities (camera, inputs, etc.), in the world that match a [`query`](./query.md), you can use the [`queryEntity`](../../api/classes/World.md#queryentity) or [`queryEntityRequired`](../../api/classes/World.md#queryentityrequired) method. - -```ts -const world = new World('main-world'); - -... -// returns Entity or null if no entity is found -const inputsEntity = world.queryEntity([InputsComponent]); - -// returns Entity or throws an error no entity is found -const cameraEntity = world.queryEntityRequired([CameraComponent]); -``` - -:::info - -Although there are legitimate use-cases for `queryEntity`. You should always use `queryEntityRequired` over `queryEntity` if you expect the entity to exist in the world at the time query is made. It prevents the need to do null checks and also ensures that your game will not silently fail if the entity is missing. - -::: - -:::info - -If multiple entities exist in the world that match the query, only one is returned. There is no guarantee which entity will be be returned. - -::: - -## Removing Entities and Systems - -You can remove entities and systems at any time: - -```ts -world.removeEntity(player); -world.removeSystem(movementSystem); -``` - -## Stopping the World - -When you want to clean up (e.g., on game over), call: - -```ts -world.stop(); -``` - -This will call `stop` on all systems and clear all entities. - -## Listening for Changes - -You can register callbacks to react to changes in the world's entities or systems: - -```ts -world.onEntitiesChanged((entities) => { - console.log('Entities changed:', entities); -}); - -world.onSystemsChanged((systems) => { - console.log('Systems changed:', systems); -}); -``` - -You can remove these callbacks with `removeOnEntitiesChangedCallback` and `removeOnSystemsChangedCallback`. diff --git a/documentation-site/package-lock.json b/documentation-site/package-lock.json index 2ebd8d41..d28f00fb 100644 --- a/documentation-site/package-lock.json +++ b/documentation-site/package-lock.json @@ -13,6 +13,7 @@ "@forge-game-engine/forge": "file:..", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", + "howler": "^2.2.4", "prism-react-renderer": "^2.3.0", "raw-loader": "^4.0.2", "react": "^19.2.3", @@ -22,6 +23,7 @@ "@docusaurus/module-type-aliases": "^3.9.2", "@docusaurus/tsconfig": "^3.9.2", "@docusaurus/types": "^3.9.2", + "@types/howler": "^2.2.12", "docusaurus-plugin-typedoc": "^1.4.0", "typedoc": "^0.28.5", "typedoc-plugin-markdown": "^4.6.4", @@ -33,7 +35,7 @@ }, "..": { "name": "@forge-game-engine/forge", - "version": "0.17.6", + "version": "0.17.7", "license": "MIT", "dependencies": { "@types/imurmurhash": "^0.1.4", @@ -5011,6 +5013,13 @@ "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", "license": "MIT" }, + "node_modules/@types/howler": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.12.tgz", + "integrity": "sha512-hy769UICzOSdK0Kn1FBk4gN+lswcj1EKRkmiDtMkUGvFfYJzgaDXmVXkSShS2m89ERAatGIPnTUlp2HhfkVo5g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -9132,6 +9141,12 @@ "react-is": "^16.7.0" } }, + "node_modules/howler": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz", + "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==", + "license": "MIT" + }, "node_modules/hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", diff --git a/documentation-site/package.json b/documentation-site/package.json index a9c91ef6..78cf5e77 100644 --- a/documentation-site/package.json +++ b/documentation-site/package.json @@ -20,6 +20,7 @@ "@forge-game-engine/forge": "file:..", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", + "howler": "^2.2.4", "prism-react-renderer": "^2.3.0", "raw-loader": "^4.0.2", "react": "^19.2.3", @@ -29,6 +30,7 @@ "@docusaurus/module-type-aliases": "^3.9.2", "@docusaurus/tsconfig": "^3.9.2", "@docusaurus/types": "^3.9.2", + "@types/howler": "^2.2.12", "docusaurus-plugin-typedoc": "^1.4.0", "typedoc": "^0.28.5", "typedoc-plugin-markdown": "^4.6.4", diff --git a/documentation-site/src/pages/demos/space-shooter/_background.component.ts b/documentation-site/src/pages/demos/space-shooter/_background.component.ts index 7cb52cd3..511a9e0c 100644 --- a/documentation-site/src/pages/demos/space-shooter/_background.component.ts +++ b/documentation-site/src/pages/demos/space-shooter/_background.component.ts @@ -1,3 +1,3 @@ -import { Component } from '@forge-game-engine/forge/ecs'; +import { createTagId } from '@forge-game-engine/forge/ecs'; -export class BackgroundComponent extends Component {} +export const backgroundId = createTagId('background'); diff --git a/documentation-site/src/pages/demos/space-shooter/_background.system.ts b/documentation-site/src/pages/demos/space-shooter/_background.system.ts index c64649e2..65fa84a8 100644 --- a/documentation-site/src/pages/demos/space-shooter/_background.system.ts +++ b/documentation-site/src/pages/demos/space-shooter/_background.system.ts @@ -1,19 +1,21 @@ -import { Entity, System } from '@forge-game-engine/forge/ecs'; +import { EcsSystem } from '@forge-game-engine/forge/ecs'; import { Time } from '@forge-game-engine/forge/common'; -import { SpriteComponent } from '@forge-game-engine/forge/rendering'; -import { BackgroundComponent } from './_background.component'; +import { + SpriteEcsComponent, + spriteId, +} from '@forge-game-engine/forge/rendering'; +import { backgroundId } from './_background.component'; -export class BackgroundSystem extends System { - private readonly _time: Time; +export const createBackgroundEcsSystem = ( + time: Time, +): EcsSystem<[SpriteEcsComponent]> => ({ + query: [spriteId, backgroundId], + run: (result) => { + const [spriteComponent] = result.components; - constructor(time: Time) { - super([BackgroundComponent, SpriteComponent], 'BackgroundSystem'); - this._time = time; - } - - public run(entity: Entity): void { - const { sprite } = entity.getComponentRequired(SpriteComponent); - - sprite.renderable.material.setUniform('u_time', this._time.timeInSeconds); - } -} + spriteComponent.sprite.renderable.material.setUniform( + 'u_time', + time.timeInSeconds, + ); + }, +}); diff --git a/documentation-site/src/pages/demos/space-shooter/_bullet.component.ts b/documentation-site/src/pages/demos/space-shooter/_bullet.component.ts index 52800a95..b748e075 100644 --- a/documentation-site/src/pages/demos/space-shooter/_bullet.component.ts +++ b/documentation-site/src/pages/demos/space-shooter/_bullet.component.ts @@ -1,10 +1,7 @@ -import { Component } from '@forge-game-engine/forge/ecs'; +import { createComponentId } from '@forge-game-engine/forge/ecs'; -export class BulletComponent extends Component { - public speed: number; - - constructor(speed: number) { - super(); - this.speed = speed; - } +export interface BulletEcsComponent { + speed: number; } + +export const bulletId = createComponentId('Bullet'); diff --git a/documentation-site/src/pages/demos/space-shooter/_bullet.system.ts b/documentation-site/src/pages/demos/space-shooter/_bullet.system.ts index dff0fa2f..1991ce80 100644 --- a/documentation-site/src/pages/demos/space-shooter/_bullet.system.ts +++ b/documentation-site/src/pages/demos/space-shooter/_bullet.system.ts @@ -1,20 +1,19 @@ -import { Entity, System } from '@forge-game-engine/forge/ecs'; -import { PositionComponent, Time } from '@forge-game-engine/forge/common'; -import { BulletComponent } from './_bullet.component'; +import { EcsSystem } from '@forge-game-engine/forge/ecs'; +import { + PositionEcsComponent, + positionId, + Time, +} from '@forge-game-engine/forge/common'; +import { BulletEcsComponent, bulletId } from './_bullet.component'; -export class BulletSystem extends System { - private readonly _time: Time; - - constructor(time: Time) { - super([BulletComponent, PositionComponent], 'BulletSystem'); - this._time = time; - } - - public run(entity: Entity): void { - const bulletComponent = entity.getComponentRequired(BulletComponent); - const positionComponent = entity.getComponentRequired(PositionComponent); +export const createBulletEcsSystem = ( + time: Time, +): EcsSystem<[BulletEcsComponent, PositionEcsComponent]> => ({ + query: [bulletId, positionId], + run: (result) => { + const [bulletComponent, positionComponent] = result.components; positionComponent.world.y += - -bulletComponent.speed * this._time.deltaTimeInSeconds; - } -} + bulletComponent.speed * time.deltaTimeInSeconds; + }, +}); diff --git a/documentation-site/src/pages/demos/space-shooter/_create-background.ts b/documentation-site/src/pages/demos/space-shooter/_create-background.ts index 92241a6e..232d2f7f 100644 --- a/documentation-site/src/pages/demos/space-shooter/_create-background.ts +++ b/documentation-site/src/pages/demos/space-shooter/_create-background.ts @@ -4,20 +4,19 @@ import { createTextureFromImage, Material, RenderContext, - RenderLayer, - SpriteComponent, + spriteId, } from '@forge-game-engine/forge/rendering'; -import { Entity, World } from '@forge-game-engine/forge/ecs'; -import { PositionComponent } from '@forge-game-engine/forge/common'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { positionId } from '@forge-game-engine/forge/common'; import { backgroundShader } from './_background.shader'; -import { BackgroundComponent } from './_background.component'; +import { backgroundId } from './_background.component'; import { getAssetUrl } from '@site/src/utils/get-asset-url'; +import { Vector2 } from '../../../../../dist'; export async function createBackground( - world: World, - cameraEntity: Entity, - renderLayer: RenderLayer, + world: EcsWorld, renderContext: RenderContext, + renderLayer: number, ): Promise { renderContext.shaderCache.addShader(backgroundShader); @@ -53,16 +52,22 @@ export async function createBackground( const backgroundSprite = createSprite( backgroundMaterial, renderContext, - cameraEntity, + renderLayer, renderContext.canvas.width, renderContext.canvas.height, ); - const backgroundEntity = world.buildAndAddEntity([ - new SpriteComponent(backgroundSprite), - new PositionComponent(0, 0), - new BackgroundComponent(), - ]); + const backgroundEntity = world.createEntity(); - renderLayer.addEntity(backgroundSprite.renderable, backgroundEntity); + world.addComponent(backgroundEntity, spriteId, { + sprite: backgroundSprite, + enabled: true, + }); + + world.addComponent(backgroundEntity, positionId, { + local: Vector2.zero, + world: Vector2.zero, + }); + + world.addTag(backgroundEntity, backgroundId); } diff --git a/documentation-site/src/pages/demos/space-shooter/_create-game.ts b/documentation-site/src/pages/demos/space-shooter/_create-game.ts index b438e47f..affaa943 100644 --- a/documentation-site/src/pages/demos/space-shooter/_create-game.ts +++ b/documentation-site/src/pages/demos/space-shooter/_create-game.ts @@ -1,45 +1,46 @@ -import { Game, registerCamera } from '@forge-game-engine/forge/ecs'; -import { RenderSystem } from '@forge-game-engine/forge/rendering'; -import { createGame } from '@forge-game-engine/forge/utilities'; import { - ParticleEmitterSystem, - ParticlePositionSystem, -} from '@forge-game-engine/forge/particles'; -import { AudioSystem } from '@forge-game-engine/forge/audio'; + addCamera, + createCameraEcsSystem, + createRenderEcsSystem, +} from '@forge-game-engine/forge/rendering'; +import { createGame, Game } from '@forge-game-engine/forge/utilities'; +import { createAudioEcsSystem } from '@forge-game-engine/forge/audio'; import { - LifetimeTrackingSystem, - RemoveFromWorldLifecycleSystem, + createLifetimeTrackingEcsSystem, + createRemoveFromWorldEcsSystem, } from '@forge-game-engine/forge/lifecycle'; -import { MovementSystem } from './_movement.system'; +import { createMovementEcsSystem } from './_movement.system'; import { createBackground } from './_create-background'; -import { BackgroundSystem } from './_background.system'; +import { createBackgroundEcsSystem } from './_background.system'; import { createMusic } from './_create-music'; import { createInputs } from './_create-inputs'; -import { createRenderLayer } from './_create-render-layer'; import { createPlayer } from './_create-player'; -import { BulletSystem } from './_bullet.system'; -import { GunSystem } from './_gun.system'; +import { createBulletEcsSystem } from './_bullet.system'; +import { createGunEcsSystem } from './_gun.system'; + +const renderLayers = { + background: 1 << 0, + foreground: 1 << 1, +}; export const createSpaceShooterGame = async (): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - const cameraEntity = registerCamera(world, time); + addCamera(world); const { moveInput, shootInput } = createInputs(world, time, game); - const renderLayer = createRenderLayer(world); - await createBackground(world, cameraEntity, renderLayer, renderContext); - await createPlayer(renderContext, cameraEntity, world, renderLayer); + await createBackground(world, renderContext, renderLayers.background); + await createPlayer(renderContext, world, renderLayers.foreground); createMusic(world); - world.addSystem(new RenderSystem(renderContext)); - world.addSystem(new MovementSystem(moveInput, time)); - world.addSystem(new BackgroundSystem(time)); - world.addSystem(new ParticleEmitterSystem(world, time)); - world.addSystem(new ParticlePositionSystem(time)); - world.addSystem(new AudioSystem(world)); - world.addSystem(new LifetimeTrackingSystem(time)); - world.addSystem(new RemoveFromWorldLifecycleSystem(world)); - world.addSystem(new GunSystem(time, shootInput, world)); - world.addSystem(new BulletSystem(time)); + world.addSystem(createCameraEcsSystem(time)); + world.addSystem(createRenderEcsSystem(renderContext)); + world.addSystem(createMovementEcsSystem(moveInput, time)); + world.addSystem(createBackgroundEcsSystem(time)); + world.addSystem(createAudioEcsSystem()); + world.addSystem(createLifetimeTrackingEcsSystem(time)); + world.addSystem(createRemoveFromWorldEcsSystem()); + world.addSystem(createGunEcsSystem(time, world, shootInput)); + world.addSystem(createBulletEcsSystem(time)); return game; }; diff --git a/documentation-site/src/pages/demos/space-shooter/_create-inputs.ts b/documentation-site/src/pages/demos/space-shooter/_create-inputs.ts index ad197bdc..79fbe6a8 100644 --- a/documentation-site/src/pages/demos/space-shooter/_create-inputs.ts +++ b/documentation-site/src/pages/demos/space-shooter/_create-inputs.ts @@ -1,4 +1,4 @@ -import { Game, registerInputs, World } from '@forge-game-engine/forge/ecs'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; import { Time } from '@forge-game-engine/forge/common'; import { actionResetTypes, @@ -11,10 +11,12 @@ import { mouseButtons, MouseHoldBinding, MouseInputSource, + registerInputs, } from '@forge-game-engine/forge/input'; +import { Game } from '@forge-game-engine/forge/utilities'; export function createInputs( - world: World, + world: EcsWorld, time: Time, game: Game, ): { @@ -24,13 +26,13 @@ export function createInputs( const moveInput = new Axis2dAction('move', null, actionResetTypes.noReset); const shootInput = new HoldAction('shoot'); - const { inputsManager } = registerInputs(world, time, { + const inputManager = registerInputs(world, time, { axis2dActions: [moveInput], holdActions: [shootInput], }); - const keyboardInputSource = new KeyboardInputSource(inputsManager); - const mouseInputSource = new MouseInputSource(inputsManager, game); + const keyboardInputSource = new KeyboardInputSource(inputManager); + const mouseInputSource = new MouseInputSource(inputManager, game.container); keyboardInputSource.axis2dBindings.add( new KeyboardAxis2dBinding( diff --git a/documentation-site/src/pages/demos/space-shooter/_create-music.ts b/documentation-site/src/pages/demos/space-shooter/_create-music.ts index 6c783f1c..e32a9c83 100644 --- a/documentation-site/src/pages/demos/space-shooter/_create-music.ts +++ b/documentation-site/src/pages/demos/space-shooter/_create-music.ts @@ -1,16 +1,17 @@ -import { World } from '@forge-game-engine/forge/ecs'; -import { AudioComponent } from '@forge-game-engine/forge/audio'; +import { Howl } from 'howler'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { audioId } from '@forge-game-engine/forge/audio'; import { getAssetUrl } from '@site/src/utils/get-asset-url'; -export function createMusic(world: World): void { - world.buildAndAddEntity([ - new AudioComponent( - { - src: getAssetUrl('audio/background-space-music.mp3'), - loop: true, - volume: 0.5, - }, - true, - ), - ]); +export function createMusic(world: EcsWorld): void { + const musicEntity = world.createEntity(); + + world.addComponent(musicEntity, audioId, { + sound: new Howl({ + src: getAssetUrl('audio/background-space-music.mp3'), + loop: true, + volume: 0.3, + }), + playSound: true, + }); } diff --git a/documentation-site/src/pages/demos/space-shooter/_create-player.ts b/documentation-site/src/pages/demos/space-shooter/_create-player.ts index 20a13b3e..ae04f546 100644 --- a/documentation-site/src/pages/demos/space-shooter/_create-player.ts +++ b/documentation-site/src/pages/demos/space-shooter/_create-player.ts @@ -1,45 +1,68 @@ import { getAssetUrl } from '@site/src/utils/get-asset-url'; -import { Entity, World } from '@forge-game-engine/forge/ecs'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; import { - Color, createImageSprite, RenderContext, - RenderLayer, - SpriteComponent, + spriteId, } from '@forge-game-engine/forge/rendering'; -import { PositionComponent, RotationComponent, ScaleComponent } from '@forge-game-engine/forge/common'; -import { PlayerComponent } from './_player.component'; -import { GunComponent } from './_gun.component'; -import { degreesToRadians } from '../../../../../dist'; +import { + positionId, + rotationId, + scaleId, +} from '@forge-game-engine/forge/common'; +import { Vector2 } from '@forge-game-engine/forge/math'; +import { PlayerId } from './_player.component'; +import { gunId } from './_gun.component'; export async function createPlayer( renderContext: RenderContext, - cameraEntity: Entity, - world: World, - renderLayer: RenderLayer, -): Promise { + world: EcsWorld, + renderLayer: number, +): Promise { const playerSprite = await createImageSprite( getAssetUrl('img/space-shooter/Spaceship_6.png'), renderContext, - cameraEntity, + renderLayer, ); const bulletSprite = await createImageSprite( getAssetUrl('img/space-shooter/bullet-yellow.png'), renderContext, - cameraEntity, + renderLayer, ); - const playerEntity = world.buildAndAddEntity([ - new SpriteComponent(playerSprite), - new PositionComponent(0, 250), - new PlayerComponent(50, -300, 300, -100, 270), - new ScaleComponent(0.15, 0.15), - new RotationComponent(degreesToRadians(180)), - new GunComponent(0.2, bulletSprite, renderLayer), - ]); + const playerEntity = world.createEntity(); + + world.addComponent(playerEntity, spriteId, { + sprite: playerSprite, + enabled: true, + }); + world.addComponent(playerEntity, positionId, { + local: new Vector2(0, -250), + world: new Vector2(0, -250), + }); + world.addComponent(playerEntity, PlayerId, { + speed: 50, + minX: -300, + maxX: 300, + minY: -270, + maxY: 100, + }); + + world.addComponent(playerEntity, scaleId, { + local: new Vector2(0.15, 0.15), + world: new Vector2(0.15, 0.15), + }); - renderLayer.addEntity(playerSprite.renderable, playerEntity); + world.addComponent(playerEntity, rotationId, { + local: Math.PI, + world: Math.PI, + }); - return playerEntity; + world.addComponent(playerEntity, gunId, { + timeBetweenShots: 0.2, + bulletSprite: bulletSprite, + renderLayer: renderLayer, + nextAllowedShotTime: 0, + }); } diff --git a/documentation-site/src/pages/demos/space-shooter/_gun.component.ts b/documentation-site/src/pages/demos/space-shooter/_gun.component.ts index 4297ec6f..c20a95e2 100644 --- a/documentation-site/src/pages/demos/space-shooter/_gun.component.ts +++ b/documentation-site/src/pages/demos/space-shooter/_gun.component.ts @@ -1,21 +1,11 @@ -import { Component } from '@forge-game-engine/forge/ecs'; -import { RenderLayer, Sprite } from '@forge-game-engine/forge/rendering'; +import { createComponentId } from '@forge-game-engine/forge/ecs'; +import { Sprite } from '@forge-game-engine/forge/rendering'; -export class GunComponent extends Component { - public timeBetweenShots: number; - public nextAllowedShotTime: number; - public readonly bulletSprite: Sprite; - public readonly renderLayer: RenderLayer; - - constructor( - timeBetweenShots: number, - bulletSprite: Sprite, - renderLayer: RenderLayer, - ) { - super(); - this.timeBetweenShots = timeBetweenShots; - this.nextAllowedShotTime = 0; - this.bulletSprite = bulletSprite; - this.renderLayer = renderLayer; - } +export interface GunEcsComponent { + timeBetweenShots: number; + nextAllowedShotTime: number; + bulletSprite: Sprite; + renderLayer: number; } + +export const gunId = createComponentId('Gun'); diff --git a/documentation-site/src/pages/demos/space-shooter/_gun.system.ts b/documentation-site/src/pages/demos/space-shooter/_gun.system.ts index 0d0b593f..d76b1a1d 100644 --- a/documentation-site/src/pages/demos/space-shooter/_gun.system.ts +++ b/documentation-site/src/pages/demos/space-shooter/_gun.system.ts @@ -1,73 +1,104 @@ -import { Entity, System, World } from '@forge-game-engine/forge/ecs'; +import { Howl } from 'howler'; +import { EcsSystem, EcsWorld } from '@forge-game-engine/forge/ecs'; import { - PositionComponent, - RotationComponent, - ScaleComponent, + PositionEcsComponent, + positionId, + rotationId, + scaleId, Time, } from '@forge-game-engine/forge/common'; import { HoldAction } from '@forge-game-engine/forge/input'; -import { degreesToRadians } from '@forge-game-engine/forge/math'; -import { SpriteComponent } from '@forge-game-engine/forge/rendering'; +import { degreesToRadians, Vector2 } from '@forge-game-engine/forge/math'; +import { spriteId } from '@forge-game-engine/forge/rendering'; import { - LifetimeComponent, - RemoveFromWorldStrategyComponent, + lifetimeId, + RemoveFromWorldLifetimeStrategyId, } from '@forge-game-engine/forge/lifecycle'; -import { BulletComponent } from './_bullet.component'; -import { GunComponent } from './_gun.component'; -import { AudioComponent } from '../../../../../dist'; +import { bulletId } from './_bullet.component'; +import { GunEcsComponent, gunId } from './_gun.component'; +import { audioId } from '../../../../../dist'; import { getAssetUrl } from '@site/src/utils/get-asset-url'; -export class GunSystem extends System { - private readonly _time: Time; - private readonly _shootAction: HoldAction; - private readonly _world: World; +export const createGunEcsSystem = ( + time: Time, + world: EcsWorld, + shootAction: HoldAction, +): EcsSystem<[GunEcsComponent, PositionEcsComponent]> => ({ + query: [gunId, positionId], + run: (result) => { + const [gunComponent, positionComponent] = result.components; - constructor(time: Time, shootAction: HoldAction, world: World) { - super([GunComponent, PositionComponent], 'GunSystem'); - this._time = time; - this._shootAction = shootAction; - this._world = world; - } - - public run(entity: Entity): void { - if (!this._shootAction.isHeld) { + if (!shootAction.isHeld) { return; } - const gunComponent = entity.getComponentRequired(GunComponent); - - if (gunComponent.nextAllowedShotTime > this._time.timeInSeconds) { + if (gunComponent.nextAllowedShotTime > time.timeInSeconds) { return; } - const positionComponent = entity.getComponentRequired(PositionComponent); - - const bullet = this._world.buildAndAddEntity([ - new SpriteComponent(gunComponent.bulletSprite), - new PositionComponent( - positionComponent.world.x, - positionComponent.world.y, - ), - new RotationComponent(degreesToRadians(270)), - new ScaleComponent(0.2, 0.2), - new BulletComponent(700), - new LifetimeComponent(2), - new RemoveFromWorldStrategyComponent(), - new AudioComponent( - { - src: getAssetUrl('audio/laser.mp3'), - volume: 0.3, - }, - true, - ), - ]); - - gunComponent.renderLayer.addEntity( - gunComponent.bulletSprite.renderable, - bullet, + createBulletWithOffset( + world, + gunComponent, + positionComponent, + new Vector2(20, 20), + ); + createBulletWithOffset( + world, + gunComponent, + positionComponent, + new Vector2(-20, 20), ); gunComponent.nextAllowedShotTime = - this._time.timeInSeconds + gunComponent.timeBetweenShots; - } + time.timeInSeconds + gunComponent.timeBetweenShots; + }, +}); + +function createBulletWithOffset( + world: EcsWorld, + gunComponent: GunEcsComponent, + positionComponent: PositionEcsComponent, + offset: Vector2, +) { + const bullet = world.createEntity(); + + world.addComponent(bullet, spriteId, { + sprite: gunComponent.bulletSprite, + enabled: true, + }); + + world.addComponent(bullet, positionId, { + local: positionComponent.world.add(offset), + world: positionComponent.world.add(offset), + }); + + world.addComponent(bullet, rotationId, { + local: degreesToRadians(270), + world: degreesToRadians(270), + }); + + world.addComponent(bullet, scaleId, { + local: new Vector2(0.2, 0.2), + world: new Vector2(0.2, 0.2), + }); + + world.addComponent(bullet, bulletId, { + speed: 700, + }); + + world.addComponent(bullet, lifetimeId, { + durationSeconds: 2, + elapsedSeconds: 0, + hasExpired: false, + }); + + world.addTag(bullet, RemoveFromWorldLifetimeStrategyId); + + world.addComponent(bullet, audioId, { + playSound: true, + sound: new Howl({ + src: getAssetUrl('audio/laser.mp3'), + volume: 0.3, + }), + }); } diff --git a/documentation-site/src/pages/demos/space-shooter/_movement.system.ts b/documentation-site/src/pages/demos/space-shooter/_movement.system.ts index 6809afb4..701f8ac9 100644 --- a/documentation-site/src/pages/demos/space-shooter/_movement.system.ts +++ b/documentation-site/src/pages/demos/space-shooter/_movement.system.ts @@ -1,39 +1,37 @@ -import { Entity, System } from '@forge-game-engine/forge/ecs'; -import { PositionComponent, Time } from '@forge-game-engine/forge/common'; +import { EcsSystem } from '@forge-game-engine/forge/ecs'; +import { + PositionEcsComponent, + positionId, + Time, +} from '@forge-game-engine/forge/common'; import { clamp } from '@forge-game-engine/forge/math'; import { Axis2dAction } from '@forge-game-engine/forge/input'; -import { PlayerComponent } from './_player.component'; +import { PlayerEcsComponent, PlayerId } from './_player.component'; -export class MovementSystem extends System { - private readonly _moveAction: Axis2dAction; - private readonly _time: Time; +export const createMovementEcsSystem = ( + moveAction: Axis2dAction, + time: Time, +): EcsSystem<[PlayerEcsComponent, PositionEcsComponent]> => ({ + query: [PlayerId, positionId], + run: (result) => { + const [playerComponent, positionComponent] = result.components; - constructor(moveAction: Axis2dAction, time: Time) { - super([PlayerComponent, PositionComponent], 'MovementSystem'); - this._moveAction = moveAction; - this._time = time; - } + const { speed, minX, maxX, minY, maxY } = playerComponent; - public run(entity: Entity): void { - const { speed, minX, maxX, minY, maxY } = - entity.getComponentRequired(PlayerComponent); - - const playerPosition = entity.getComponentRequired(PositionComponent); - - const movementVector = this._moveAction.value + const movementVector = moveAction.value .multiply(speed * 10) - .multiply(this._time.deltaTimeInSeconds); + .multiply(time.deltaTimeInSeconds); - playerPosition.world.x = clamp( - playerPosition.world.x + movementVector.x, + positionComponent.world.x = clamp( + positionComponent.world.x + movementVector.x, minX, maxX, ); - playerPosition.world.y = clamp( - playerPosition.world.y - movementVector.y, + positionComponent.world.y = clamp( + positionComponent.world.y + movementVector.y, minY, maxY, ); - } -} + }, +}); diff --git a/documentation-site/src/pages/demos/space-shooter/_player.component.ts b/documentation-site/src/pages/demos/space-shooter/_player.component.ts index 4811597f..fac3e4d4 100644 --- a/documentation-site/src/pages/demos/space-shooter/_player.component.ts +++ b/documentation-site/src/pages/demos/space-shooter/_player.component.ts @@ -1,25 +1,11 @@ -import { Component } from '@forge-game-engine/forge/ecs'; +import { createComponentId } from '@forge-game-engine/forge/ecs'; -export class PlayerComponent extends Component { - public readonly speed: number; - public readonly minX: number; - public readonly maxX: number; - public readonly minY: number; - public readonly maxY: number; - - constructor( - speed: number, - minX: number, - maxX: number, - minY: number, - maxY: number, - ) { - super(); - - this.speed = speed; - this.minX = minX; - this.maxX = maxX; - this.minY = minY; - this.maxY = maxY; - } +export interface PlayerEcsComponent { + speed: number; + minX: number; + maxX: number; + minY: number; + maxY: number; } + +export const PlayerId = createComponentId('Player'); diff --git a/package.json b/package.json index 072d52fe..b5ac87ba 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ }, "./ecs": { "import": { - "types": "./dist/ecs/index.d.ts", - "default": "./dist/ecs/index.js" + "types": "./dist/new-ecs/index.d.ts", + "default": "./dist/new-ecs/index.js" } }, "./events": { @@ -69,12 +69,6 @@ "default": "./dist/physics/index.js" } }, - "./pooling": { - "import": { - "types": "./dist/pooling/index.d.ts", - "default": "./dist/pooling/index.js" - } - }, "./rendering": { "import": { "types": "./dist/rendering/index.d.ts", diff --git a/src/animations/components/animation-component.ts b/src/animations/components/animation-component.ts index cd8e0bbf..c1e4bc53 100644 --- a/src/animations/components/animation-component.ts +++ b/src/animations/components/animation-component.ts @@ -1,6 +1,5 @@ -import { Component } from '../../ecs/index.js'; -import { enforceArray } from '../../utilities/index.js'; import { linear } from '../easing-functions/index.js'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; /** * Represents the properties of an animated object. @@ -8,19 +7,16 @@ import { linear } from '../easing-functions/index.js'; export interface AnimatedProperty { /** * The starting value of the animation. - * @default 0 */ startValue?: number; /** * The ending value of the animation. - * @default 1 */ endValue?: number; /** * The elapsed time of the animation. - * @default 0 */ elapsed?: number; @@ -31,25 +27,21 @@ export interface AnimatedProperty { /** * The callback function to update the animated value. - * @param value - The current value of the animation. */ updateCallback: (value: number) => void; /** * The easing function to use for the animation. - * @default linear */ easing?: (t: number) => number; /** * The loop mode of the animation. - * @default 'none' */ loop?: LoopMode; /** * The number of times the animation should loop. -1 means that it will loop indefinitely. - * @default -1 */ loopCount?: number; @@ -59,61 +51,31 @@ export interface AnimatedProperty { finishedCallback?: () => void; } -const animationDefaults = { +export type LoopMode = 'none' | 'loop' | 'pingpong'; + +/** + * ECS-style component interface for animations. + */ +export interface AnimationEcsComponent { + animations: Required[]; +} + +export const animationId = + createComponentId('animation'); + +export const animationDefaults = { startValue: 0, endValue: 1, elapsed: 0, easing: linear, loop: 'none' as LoopMode, loopCount: -1, - finishedCallback: () => undefined, + finishedCallback: (): void => undefined, }; -export type LoopMode = 'none' | 'loop' | 'pingpong'; - -/** - * Represents an animation component that manages a collection of animations. - */ -export class AnimationComponent extends Component { - private readonly _animations: Required[]; - - /** - * Gets the list of animations managed by this component. - * @returns An array of AnimatedProperty objects. - */ - get animations(): Required[] { - return this._animations; - } - - /** - * Creates an instance of AnimationComponent. - * @param animations - An AnimatedProperty or array of AnimatedProperties to initialize the component with. - * @example - * const animation = new AnimationComponent({ - * duration: 1000, - * updateCallback: (value) => console.log(value), - * easing: (t) => t * t, - * loop: 'loop', - * loopCount: 3, - * }); - */ - constructor(animations: AnimatedProperty[] | AnimatedProperty = []) { - super(); - - this._animations = []; - - for (const animation of enforceArray(animations)) { - this.addAnimation(animation); - } - } - - /** - * Adds a new animation to the component. - * @param animation - The AnimatedProperty object to add. - */ - public addAnimation(animation: AnimatedProperty): void { - const mergedAnimation = { ...animationDefaults, ...animation }; - - this._animations.push(mergedAnimation); - } -} +export const createAnimatedProperty = ( + animatedProperty: AnimatedProperty, +): Required => ({ + ...animatedProperty, + ...animationDefaults, +}); diff --git a/src/animations/components/sprite-animation-component.ts b/src/animations/components/sprite-animation-component.ts index 7e65580c..24c1961b 100644 --- a/src/animations/components/sprite-animation-component.ts +++ b/src/animations/components/sprite-animation-component.ts @@ -1,63 +1,18 @@ -import { Component } from '../../ecs/index.js'; import { AnimationClip, AnimationInputs } from '../types/index.js'; import { FiniteStateMachine } from '../../finite-state-machine/finite-state-machine.js'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; /** - * Component to store sprite animation information for entities, such as from sprite sheets. + * ECS-style component interface for sprite animations. */ -export class SpriteAnimationComponent extends Component { - /** - * The current frame index of the animation being played. - */ - public animationFrameIndex: number; - - /** - * The speed multiplier for the animation playback. Larger values result in faster playback. - */ - public playbackSpeed: number; - - /** - * The duration (in milliseconds) of each frame in the animation. - */ - public frameDurationMilliseconds: number; - - /** - * The last time (in seconds) the animation frame was changed. - */ - public lastFrameChangeTimeInSeconds: number; - - /** - * The inputs used to determine the current animation from animation transitions. - */ - public animationInputs: AnimationInputs; - - /** - * The animation controller responsible for managing the animations. - */ - public stateMachine: FiniteStateMachine; - - /** - * Creates an instance of SpriteAnimationComponent. - * @param stateMachine - The FiniteStateMachine managing the animations. - * @param startingInputs - The inputs used to determine the current animation. - * @param frameDurationMilliseconds - The duration (in milliseconds) of each frame in the animation. Defaults to 33.3333 ms (30 fps). - * @param playbackSpeed - The speed multiplier for the animation playback. Defaults to 1. - */ - constructor( - stateMachine: FiniteStateMachine, - startingInputs: AnimationInputs, - frameDurationMilliseconds: number = 33.3333, // 30 fps - playbackSpeed: number = 1, - ) { - super(); - - this.animationFrameIndex = 0; - this.lastFrameChangeTimeInSeconds = 0; - this.playbackSpeed = playbackSpeed; - this.frameDurationMilliseconds = frameDurationMilliseconds; - this.animationInputs = startingInputs; - this.stateMachine = stateMachine; - - stateMachine.update(startingInputs); - } +export interface SpriteAnimationEcsComponent { + animationFrameIndex: number; + playbackSpeed: number; + frameDurationMilliseconds: number; + lastFrameChangeTimeInSeconds: number; + animationInputs: AnimationInputs; + stateMachine: FiniteStateMachine; } + +export const spriteAnimationId = + createComponentId('sprite-animation'); diff --git a/src/animations/systems/animation-system.test.ts b/src/animations/systems/animation-system.test.ts index 61780cbe..6f6f7b9f 100644 --- a/src/animations/systems/animation-system.test.ts +++ b/src/animations/systems/animation-system.test.ts @@ -1,35 +1,39 @@ import { describe, expect, it, vi } from 'vitest'; -import { AnimationSystem } from './animation-system'; -import { Entity, World } from '../../ecs'; +import { createAnimationEcsSystem } from './animation-system'; import { Time } from '../../common'; -import { type AnimatedProperty, AnimationComponent } from '../components'; - -describe('AnimationSystem', () => { - const world = new World('test-world'); +import { type AnimationEcsComponent, animationId } from '../components'; +import { EcsWorld } from '../../new-ecs'; +describe('createAnimationEcsSystem', () => { it('should update animations and call updateCallback', () => { const mockUpdateCallback = vi.fn(); const mockFinishedCallback = vi.fn(); const time = new Time(); time.update(1000); - const animation: Required = { - startValue: 0, - endValue: 1, - elapsed: 0, - duration: 1, - updateCallback: mockUpdateCallback, - easing: (t) => t, - loop: 'none', - loopCount: 1, - finishedCallback: mockFinishedCallback, + const ecsWorld = new EcsWorld(); + const animationSystem = createAnimationEcsSystem(time); + ecsWorld.addSystem(animationSystem); + + const entity = ecsWorld.createEntity(); + const animationComponent: AnimationEcsComponent = { + animations: [ + { + startValue: 0, + endValue: 1, + elapsed: 0, + duration: 1, + updateCallback: mockUpdateCallback, + easing: (t) => t, + loop: 'none', + loopCount: 1, + finishedCallback: mockFinishedCallback, + }, + ], }; - const animationComponent = new AnimationComponent([animation]); - const entity = new Entity(world, [animationComponent]); - const animationSystem = new AnimationSystem(time); - - animationSystem.run(entity); + ecsWorld.addComponent(entity, animationId, animationComponent); + ecsWorld.update(); expect(mockUpdateCallback).toHaveBeenCalledWith(1); expect(mockFinishedCallback).toHaveBeenCalled(); @@ -41,23 +45,29 @@ describe('AnimationSystem', () => { const time = new Time(); time.update(1000); - const animation: Required = { - startValue: 0, - endValue: 1, - elapsed: 0, - duration: 1000, - updateCallback: mockUpdateCallback, - easing: (t) => t, - loop: 'loop', - loopCount: 2, - finishedCallback: () => void 0, + const ecsWorld = new EcsWorld(); + const animationSystem = createAnimationEcsSystem(time); + ecsWorld.addSystem(animationSystem); + + const entity = ecsWorld.createEntity(); + const animationComponent: AnimationEcsComponent = { + animations: [ + { + startValue: 0, + endValue: 1, + elapsed: 0, + duration: 1000, + updateCallback: mockUpdateCallback, + easing: (t) => t, + loop: 'loop', + loopCount: 2, + finishedCallback: () => void 0, + }, + ], }; - const animationComponent = new AnimationComponent([animation]); - const entity = new Entity(world, [animationComponent]); - const animationSystem = new AnimationSystem(time); - - animationSystem.run(entity); + ecsWorld.addComponent(entity, animationId, animationComponent); + ecsWorld.update(); expect(mockUpdateCallback).toHaveBeenCalledWith(1); expect(animationComponent.animations.length).toBe(1); @@ -69,23 +79,29 @@ describe('AnimationSystem', () => { const time = new Time(); time.update(1000); - const animation: Required = { - startValue: 0, - endValue: 1, - elapsed: 0, - duration: 1000, - updateCallback: mockUpdateCallback, - easing: (t) => t, - loop: 'pingpong', - loopCount: 2, - finishedCallback: () => void 0, + const ecsWorld = new EcsWorld(); + const animationSystem = createAnimationEcsSystem(time); + ecsWorld.addSystem(animationSystem); + + const entity = ecsWorld.createEntity(); + const animationComponent: AnimationEcsComponent = { + animations: [ + { + startValue: 0, + endValue: 1, + elapsed: 0, + duration: 1000, + updateCallback: mockUpdateCallback, + easing: (t) => t, + loop: 'pingpong', + loopCount: 2, + finishedCallback: () => void 0, + }, + ], }; - const animationComponent = new AnimationComponent([animation]); - const entity = new Entity(world, [animationComponent]); - const animationSystem = new AnimationSystem(time); - - animationSystem.run(entity); + ecsWorld.addComponent(entity, animationId, animationComponent); + ecsWorld.update(); expect(mockUpdateCallback).toHaveBeenCalledWith(1); expect(animationComponent.animations.length).toBe(1); @@ -99,23 +115,29 @@ describe('AnimationSystem', () => { const time = new Time(); time.update(1000); - const animation: Required = { - startValue: 0, - endValue: 1, - elapsed: 0, - duration: 1000, - updateCallback: mockUpdateCallback, - easing: (t) => t, - loop: 'none', - loopCount: 1, - finishedCallback: () => void 0, + const ecsWorld = new EcsWorld(); + const animationSystem = createAnimationEcsSystem(time); + ecsWorld.addSystem(animationSystem); + + const entity = ecsWorld.createEntity(); + const animationComponent: AnimationEcsComponent = { + animations: [ + { + startValue: 0, + endValue: 1, + elapsed: 0, + duration: 1000, + updateCallback: mockUpdateCallback, + easing: (t) => t, + loop: 'none', + loopCount: 1, + finishedCallback: () => void 0, + }, + ], }; - const animationComponent = new AnimationComponent([animation]); - const entity = new Entity(world, [animationComponent]); - const animationSystem = new AnimationSystem(time); - - animationSystem.run(entity); + ecsWorld.addComponent(entity, animationId, animationComponent); + ecsWorld.update(); expect(mockUpdateCallback).toHaveBeenCalledWith(1); expect(animationComponent.animations.length).toBe(0); diff --git a/src/animations/systems/animation-system.ts b/src/animations/systems/animation-system.ts index 5bf8c1f9..521c109d 100644 --- a/src/animations/systems/animation-system.ts +++ b/src/animations/systems/animation-system.ts @@ -1,110 +1,103 @@ -import { Entity, System } from '../../ecs/index.js'; import { Time } from '../../common/index.js'; import { type AnimatedProperty, - AnimationComponent, + type AnimationEcsComponent, + animationId, } from '../components/index.js'; +import { EcsSystem } from '../../new-ecs/index.js'; /** - * System that manages and updates animations for entities. + * Updates a single animation. + * @param animation - The animation to update. + * @returns True if the animation is complete, false otherwise. */ -export class AnimationSystem extends System { - private readonly _time: Time; - - /** - * Creates an instance of AnimationSystem. - * @param time - The Time instance. - */ - constructor(time: Time) { - super([AnimationComponent], 'animation'); - this._time = time; - } +const updateAnimation = ( + animation: Required, + time: Time, +): boolean => { + animation.elapsed += time.deltaTimeInMilliseconds; - /** - * Runs the animation system for a given entity. - * @param entity - The entity to update animations for. - */ - public run(entity: Entity): void { - const animationComponent = entity.getComponentRequired(AnimationComponent); + let t = animation.elapsed / animation.duration; - if (animationComponent.animations.length === 0) { - return; - } + if (t > 1) { + t = 1; + } - // Iterate backwards so we can safely remove animations - for (let i = animationComponent.animations.length - 1; i >= 0; i--) { - const animation = animationComponent.animations[i]; - const animationComplete = this._updateAnimation(animation); + const factor = animation.easing ? animation.easing(t) : t; + const currentValue = + animation.startValue + (animation.endValue - animation.startValue) * factor; - if (animationComplete) { - animation.updateCallback(animation.endValue); + animation.updateCallback(currentValue); - const shouldRemove = !this._handleLooping(animation); + return t >= 1; +}; - if (shouldRemove) { - animation.finishedCallback?.(); - animationComponent.animations.splice(i, 1); - } - } - } +/** + * Handles looping for an animation. + * @param animation - The animation to handle looping for. + * @returns True if the animation should continue, false if it should be removed. + */ +const handleLooping = (animation: Required): boolean => { + if (!animation.loop || animation.loop === 'none') { + return false; } - /** - * Updates a single animation. - * @param animation - The animation to update. - * @returns True if the animation is complete, false otherwise. - */ - private _updateAnimation(animation: Required): boolean { - animation.elapsed += this._time.deltaTimeInMilliseconds; - - let t = animation.elapsed / animation.duration; - - if (t > 1) { - t = 1; + if (animation.loopCount > -1) { + if (animation.loopCount === 0) { + return false; } - const factor = animation.easing ? animation.easing(t) : t; - const currentValue = - animation.startValue + - (animation.endValue - animation.startValue) * factor; + animation.loopCount--; + } - animation.updateCallback(currentValue); + animation.elapsed = 0; - return t >= 1; + if (animation.loop === 'loop') { + animation.updateCallback(animation.startValue); + } else if (animation.loop === 'pingpong') { + // Swap start and end for next iteration + const originalStartValue = animation.startValue; + animation.startValue = animation.endValue; + animation.endValue = originalStartValue; + + // Start again at the new startValue + animation.updateCallback(animation.startValue); } - /** - * Handles looping for an animation. - * @param animation - The animation to handle looping for. - * @returns True if the animation should continue, false if it should be removed. - */ - private _handleLooping(animation: Required): boolean { - if (!animation.loop || animation.loop === 'none') { - return false; - } + return true; +}; - if (animation.loopCount > -1) { - if (animation.loopCount === 0) { - return false; - } +/** + * Creates a new ECS-style animation system. + * @param time - The Time instance. + * @returns An ECS system that updates animations. + */ +export const createAnimationEcsSystem = ( + time: Time, +): EcsSystem<[AnimationEcsComponent]> => ({ + query: [animationId], + run: (result) => { + const [animationComponent] = result.components; - animation.loopCount--; + if (animationComponent.animations.length === 0) { + return; } - animation.elapsed = 0; + // Iterate backwards so we can safely remove animations + for (let i = animationComponent.animations.length - 1; i >= 0; i--) { + const animation = animationComponent.animations[i]; + const animationComplete = updateAnimation(animation, time); - if (animation.loop === 'loop') { - animation.updateCallback(animation.startValue); - } else if (animation.loop === 'pingpong') { - // Swap start and end for next iteration - const originalStartValue = animation.startValue; - animation.startValue = animation.endValue; - animation.endValue = originalStartValue; + if (animationComplete) { + animation.updateCallback(animation.endValue); - // Start again at the new startValue - animation.updateCallback(animation.startValue); - } + const shouldRemove = !handleLooping(animation); - return true; - } -} + if (shouldRemove) { + animation.finishedCallback?.(); + animationComponent.animations.splice(i, 1); + } + } + } + }, +}); diff --git a/src/animations/systems/sprite-animation-system.ts b/src/animations/systems/sprite-animation-system.ts index a73d3824..fa1ec1b0 100644 --- a/src/animations/systems/sprite-animation-system.ts +++ b/src/animations/systems/sprite-animation-system.ts @@ -1,31 +1,21 @@ -import { Entity, System } from '../../ecs/index.js'; import { Time } from '../../common/index.js'; -import { SpriteAnimationComponent } from '../components/index.js'; +import { + type SpriteAnimationEcsComponent, + spriteAnimationId, +} from '../components/index.js'; +import { EcsSystem } from '../../new-ecs/index.js'; /** - * System that manages and updates sprite animations for entities, such as from sprite sheets. + * Creates a new ECS-style sprite animation system. + * @param time - The Time instance. + * @returns An ECS system that updates sprite animations. */ -export class SpriteAnimationSystem extends System { - private readonly _time: Time; - - /** - * Creates an instance of SpriteAnimationSystem. - * @param time - The Time instance. - */ - constructor(time: Time) { - super([SpriteAnimationComponent], 'sprite-animation'); - - this._time = time; - } - - /** - * Runs the animation system for a given entity. - * @param entity - The entity to update animations for. - */ - public run(entity: Entity): void { - const spriteAnimationComponent = entity.getComponentRequired( - SpriteAnimationComponent, - ); +export const createSpriteAnimationEcsSystem = ( + time: Time, +): EcsSystem<[SpriteAnimationEcsComponent]> => ({ + query: [spriteAnimationId], + run: (result) => { + const [spriteAnimationComponent] = result.components; const { lastFrameChangeTimeInSeconds, @@ -37,7 +27,7 @@ export class SpriteAnimationSystem extends System { const { currentState: currentAnimationClip } = stateMachine; const secondsElapsedSinceLastFrameChange = - this._time.timeInSeconds - lastFrameChangeTimeInSeconds; + time.timeInSeconds - lastFrameChangeTimeInSeconds; const scaledFrameDurationInSeconds = frameDurationMilliseconds / @@ -68,7 +58,6 @@ export class SpriteAnimationSystem extends System { spriteAnimationComponent.animationFrameIndex++; } - spriteAnimationComponent.lastFrameChangeTimeInSeconds = - this._time.timeInSeconds; - } -} + spriteAnimationComponent.lastFrameChangeTimeInSeconds = time.timeInSeconds; + }, +}); diff --git a/src/animations/types/AnimationClip.test.ts b/src/animations/types/AnimationClip.test.ts index b37d13d9..867bd9c4 100644 --- a/src/animations/types/AnimationClip.test.ts +++ b/src/animations/types/AnimationClip.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { AnimationClip } from './AnimationClip'; import { AnimationFrame } from './AnimationFrame'; -import { ParameterizedForgeEvent } from '../../events'; +import { ForgeEvent, ParameterizedForgeEvent } from '../../events'; describe('Animation', () => { const mockFrame1 = {} as AnimationFrame; @@ -14,12 +14,8 @@ describe('Animation', () => { expect(animation.name).toBe('TestAnimation'); expect(animation.frames).toEqual(mockFrames); - expect(animation.onAnimationStartEvent).toBeInstanceOf( - ParameterizedForgeEvent, - ); - expect(animation.onAnimationEndEvent).toBeInstanceOf( - ParameterizedForgeEvent, - ); + expect(animation.onAnimationStartEvent).toBeInstanceOf(ForgeEvent); + expect(animation.onAnimationEndEvent).toBeInstanceOf(ForgeEvent); expect(animation.onAnimationFrameChangeEvent).toBeInstanceOf( ParameterizedForgeEvent, ); diff --git a/src/animations/types/AnimationClip.ts b/src/animations/types/AnimationClip.ts index 50155d18..8ad38103 100644 --- a/src/animations/types/AnimationClip.ts +++ b/src/animations/types/AnimationClip.ts @@ -1,16 +1,10 @@ -import { Entity } from '../../ecs/index.js'; -import { ParameterizedForgeEvent } from '../../events/index.js'; +import { ForgeEvent, ParameterizedForgeEvent } from '../../events/index.js'; import { AnimationFrame } from './AnimationFrame.js'; -/** - * Event type that is raised when the animation changes. - */ -export type OnAnimationChangeEvent = ParameterizedForgeEvent; /** * Event type that is raised when the animation frame changes. */ export type OnAnimationFrameChangeEvent = ParameterizedForgeEvent<{ - entity: Entity; animationFrame: AnimationFrame; }>; @@ -31,12 +25,12 @@ export class AnimationClip { /** * Event that is raised when the animation starts. */ - public readonly onAnimationStartEvent: OnAnimationChangeEvent; + public readonly onAnimationStartEvent: ForgeEvent; /** * Event that is raised when the animation ends. */ - public readonly onAnimationEndEvent: OnAnimationChangeEvent; + public readonly onAnimationEndEvent: ForgeEvent; /** * Event that is raised every frame change. This includes the first and last frame change of an animation @@ -64,14 +58,9 @@ export class AnimationClip { this.frames = frames; this.playbackSpeed = playbackSpeed; - this.onAnimationStartEvent = new ParameterizedForgeEvent( - 'AnimationStartEvent', - ); - this.onAnimationEndEvent = new ParameterizedForgeEvent( - 'AnimationEndEvent', - ); + this.onAnimationStartEvent = new ForgeEvent('AnimationStartEvent'); + this.onAnimationEndEvent = new ForgeEvent('AnimationEndEvent'); this.onAnimationFrameChangeEvent = new ParameterizedForgeEvent<{ - entity: Entity; animationFrame: AnimationFrame; }>('AnimationFrameChangeEvent'); } diff --git a/src/audio/components/audio-component.ts b/src/audio/components/audio-component.ts index 71c5177d..7ae3267e 100644 --- a/src/audio/components/audio-component.ts +++ b/src/audio/components/audio-component.ts @@ -1,30 +1,12 @@ -import { Howl, type HowlOptions } from 'howler'; -import { Component } from '../../ecs/index.js'; +import { Howl } from 'howler'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; /** - * Component to manage audio in the game. + * ECS-style component interface for audio. */ -export class AudioComponent extends Component { - public sound: Howl; - public playSound: boolean; - - /** - * Creates an instance of AudioComponent. - * @param options - The HowlOptions to configure the sound. - * @param playSound - A boolean indicating whether to play the sound immediately. Default is false. - * - * @see {@link https://github.com/goldfire/howler.js#documentation|Howler.js Documentation} - * - * @example - * const audioComponent = new AudioComponent({ - * src: ['sound.mp3'], - * volume: 0.5, - * }, true); - */ - constructor(options: HowlOptions, playSound = false) { - super(); - - this.sound = new Howl(options); - this.playSound = playSound; - } +export interface AudioEcsComponent { + sound: Howl; + playSound: boolean; } + +export const audioId = createComponentId('audio'); diff --git a/src/audio/systems/audio-system.test.ts b/src/audio/systems/audio-system.test.ts index f38ba0b0..86ec755c 100644 --- a/src/audio/systems/audio-system.test.ts +++ b/src/audio/systems/audio-system.test.ts @@ -1,40 +1,144 @@ import { describe, expect, it, vi } from 'vitest'; -import { AudioSystem } from './audio-system'; -import { Entity, World } from '../../ecs'; -import { AudioComponent } from '../components'; import { Howl } from 'howler'; +import { createAudioEcsSystem } from './audio-system'; +import { type AudioEcsComponent, audioId } from '../components'; +import { EcsWorld } from '../../new-ecs'; -describe('AudioSystem', () => { - const world = new World('test-world'); +vi.mock(import('howler'), { spy: true }); - it('should play audio if playSound is true', () => { - const mockPlay = vi.fn(); - const mockHowl = { play: mockPlay } as unknown as Howl; - const audioComponent = new AudioComponent({ src: ['sound.mp3'] }); - audioComponent.sound = mockHowl; - audioComponent.playSound = true; +describe('createAudioEcsSystem (Audio)', () => { + it('should play sound when playSound is true', () => { + const ecsWorld = new EcsWorld(); + const audioSystem = createAudioEcsSystem(); + ecsWorld.addSystem(audioSystem); + + const entity = ecsWorld.createEntity(); + const audioComponent: AudioEcsComponent = { + sound: new Howl({ + src: ['test-sound.mp3'], + }), + playSound: true, + }; + + ecsWorld.addComponent(entity, audioId, audioComponent); + ecsWorld.update(); + + expect(audioComponent.sound.play).toHaveBeenCalledTimes(1); + expect(audioComponent.playSound).toBe(false); + }); + + it('should not play sound when playSound is false', () => { + const ecsWorld = new EcsWorld(); + const audioSystem = createAudioEcsSystem(); + ecsWorld.addSystem(audioSystem); + + const entity = ecsWorld.createEntity(); + const audioComponent: AudioEcsComponent = { + sound: new Howl({ + src: ['test-sound.mp3'], + }), + playSound: false, + }; + + ecsWorld.addComponent(entity, audioId, audioComponent); + ecsWorld.update(); + + expect(audioComponent.sound.play).not.toHaveBeenCalled(); + expect(audioComponent.playSound).toBe(false); + }); + + it('should reset playSound to false after playing', () => { + const ecsWorld = new EcsWorld(); + const audioSystem = createAudioEcsSystem(); + ecsWorld.addSystem(audioSystem); - const entity = new Entity(world, [audioComponent]); - const audioSystem = new AudioSystem(world); + const entity = ecsWorld.createEntity(); + const audioComponent: AudioEcsComponent = { + sound: new Howl({ + src: ['test-sound.mp3'], + }), + playSound: true, + }; - audioSystem.run(entity); + ecsWorld.addComponent(entity, audioId, audioComponent); - expect(mockPlay).toHaveBeenCalled(); + // First update should play the sound + ecsWorld.update(); + expect(audioComponent.sound.play).toHaveBeenCalledTimes(1); expect(audioComponent.playSound).toBe(false); + + // Second update should not play the sound + ecsWorld.update(); + expect(audioComponent.sound.play).toHaveBeenCalledTimes(1); }); - it('should not play audio if playSound is false', () => { - const mockPlay = vi.fn(); - const mockHowl = { play: mockPlay } as unknown as Howl; - const audioComponent = new AudioComponent({ src: ['sound.mp3'] }); - audioComponent.sound = mockHowl; - audioComponent.playSound = false; + it('should handle multiple entities with audio components', () => { + const ecsWorld = new EcsWorld(); + const audioSystem = createAudioEcsSystem(); + ecsWorld.addSystem(audioSystem); + + const entity1 = ecsWorld.createEntity(); + const audioComponent1: AudioEcsComponent = { + sound: new Howl({ + src: ['test-sound.mp3'], + }), + playSound: true, + }; + + const entity2 = ecsWorld.createEntity(); + const audioComponent2: AudioEcsComponent = { + sound: new Howl({ + src: ['test-sound.mp3'], + }), + playSound: false, + }; + + const entity3 = ecsWorld.createEntity(); + const audioComponent3: AudioEcsComponent = { + sound: new Howl({ + src: ['test-sound.mp3'], + }), + playSound: true, + }; - const entity = new Entity(world, [audioComponent]); - const audioSystem = new AudioSystem(world); + ecsWorld.addComponent(entity1, audioId, audioComponent1); + ecsWorld.addComponent(entity2, audioId, audioComponent2); + ecsWorld.addComponent(entity3, audioId, audioComponent3); + + ecsWorld.update(); + + expect(audioComponent1.sound.play).toHaveBeenCalledTimes(1); + expect(audioComponent2.sound.play).not.toHaveBeenCalled(); + expect(audioComponent3.sound.play).toHaveBeenCalledTimes(1); + expect(audioComponent1.playSound).toBe(false); + expect(audioComponent2.playSound).toBe(false); + expect(audioComponent3.playSound).toBe(false); + }); - audioSystem.run(entity); + it('should allow re-triggering sound playback', () => { + const ecsWorld = new EcsWorld(); + const audioSystem = createAudioEcsSystem(); + ecsWorld.addSystem(audioSystem); - expect(mockPlay).not.toHaveBeenCalled(); + const entity = ecsWorld.createEntity(); + const audioComponent: AudioEcsComponent = { + sound: new Howl({ + src: ['test-sound.mp3'], + }), + playSound: true, + }; + + ecsWorld.addComponent(entity, audioId, audioComponent); + + // First play + ecsWorld.update(); + expect(audioComponent.sound.play).toHaveBeenCalledTimes(1); + expect(audioComponent.playSound).toBe(false); + + // Re-trigger the sound + audioComponent.playSound = true; + ecsWorld.update(); + expect(audioComponent.sound.play).toHaveBeenCalledTimes(2); + expect(audioComponent.playSound).toBe(false); }); }); diff --git a/src/audio/systems/audio-system.ts b/src/audio/systems/audio-system.ts index 97651d8b..ba6bfc9c 100644 --- a/src/audio/systems/audio-system.ts +++ b/src/audio/systems/audio-system.ts @@ -1,45 +1,20 @@ -import { AudioComponent } from '../components/index.js'; -import { Entity, System, World } from '../../ecs/index.js'; +import { AudioEcsComponent, audioId } from '../components/index.js'; +import { EcsSystem } from '../../new-ecs/ecs-system.js'; + +// TODO: needs an unload? /** - * System to manage and play audio for entities with a AudioComponent. + * Creates an ECS system to handle audio playback. + * @returns An ECS system that manages audio playback for entities with AudioEcsComponent. */ -export class AudioSystem extends System { - private readonly _world: World; - - /** - * Creates an instance of AudioSystem. - */ - constructor(world: World) { - super([AudioComponent], 'audio'); - - this._world = world; - } - - /** - * Runs the audio system for a given entity. - * @param entity - The entity to update and play sounds for. - * @returns A promise. - */ - public run(entity: Entity): void { - const audioComponent = entity.getComponentRequired(AudioComponent); +export const createAudioEcsSystem = (): EcsSystem<[AudioEcsComponent]> => ({ + query: [audioId], + run: (result) => { + const [audioComponent] = result.components; if (audioComponent.playSound) { audioComponent.sound.play(); audioComponent.playSound = false; } - } - - /** - * Stops the audio system and unloads all sounds. - */ - public stop(): void { - const allEntitiesWithAudio = this._world.queryEntities([AudioComponent]); - - for (const entityWithAudio of allEntitiesWithAudio) { - const audioComponent = - entityWithAudio.getComponentRequired(AudioComponent); - audioComponent.sound.unload(); - } - } -} + }, +}); diff --git a/src/common/components/age-scale-component.ts b/src/common/components/age-scale-component.ts index f76c0321..a0b793e0 100644 --- a/src/common/components/age-scale-component.ts +++ b/src/common/components/age-scale-component.ts @@ -1,32 +1,13 @@ -import { Component } from '../../ecs/index.js'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; /** - * Component to track how an entity's scale changes with age over its lifetime + * ECS-style component interface for age-based scaling. */ -export class AgeScaleComponent extends Component { - public originalScaleX: number; - public originalScaleY: number; - public finalLifetimeScaleX: number; - public finalLifetimeScaleY: number; - - /** - * Creates an instance of the AgeScaleComponent. - * @param originalScaleX - The original x scale of the entity. - * @param originalScaleY - The original y scale of the entity. - * @param finalLifetimeScaleX - The final x scale the entity will have at the end of its lifetime - * @param finalLifetimeScaleY - The final y scale the entity will have at the end of its lifetime - */ - constructor( - originalScaleX: number, - originalScaleY: number, - finalLifetimeScaleX: number, - finalLifetimeScaleY: number, - ) { - super(); - - this.originalScaleX = originalScaleX; - this.originalScaleY = originalScaleY; - this.finalLifetimeScaleX = finalLifetimeScaleX; - this.finalLifetimeScaleY = finalLifetimeScaleY; - } +export interface AgeScaleEcsComponent { + originalScaleX: number; + originalScaleY: number; + finalLifetimeScaleX: number; + finalLifetimeScaleY: number; } + +export const ageScaleId = createComponentId('ageScale'); diff --git a/src/common/components/depth-component.ts b/src/common/components/depth-component.ts new file mode 100644 index 00000000..61ae4e5d --- /dev/null +++ b/src/common/components/depth-component.ts @@ -0,0 +1,11 @@ +import { createComponentId } from '../../new-ecs/ecs-component.js'; + +/** + * ECS-style component interface for Depth. + */ +export interface DepthEcsComponent { + depth: number; + isDirty: boolean; +} + +export const depthId = createComponentId('Depth'); diff --git a/src/common/components/flip-component.ts b/src/common/components/flip-component.ts index 4a9f4716..07915bca 100644 --- a/src/common/components/flip-component.ts +++ b/src/common/components/flip-component.ts @@ -1,21 +1,11 @@ -import { Component } from '../../ecs/index.js'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; /** - * Component to flip an entity's rendering in the x or y direction + * ECS-style component interface for flipping sprites. */ -export class FlipComponent extends Component { - public flipX: boolean; - public flipY: boolean; - - /** - * Creates an instance of FlipComponent. - * @param flipX - Whether to flip the entity in the x direction. - * @param flipY - Whether to flip the entity in the y direction. - */ - constructor(flipX: boolean = false, flipY: boolean = false) { - super(); - - this.flipX = flipX; - this.flipY = flipY; - } +export interface FlipEcsComponent { + flipX: boolean; + flipY: boolean; } + +export const flipId = createComponentId('flip'); diff --git a/src/common/components/parent-component.ts b/src/common/components/parent-component.ts new file mode 100644 index 00000000..3005ef41 --- /dev/null +++ b/src/common/components/parent-component.ts @@ -0,0 +1,10 @@ +import { createComponentId } from '../../new-ecs/ecs-component.js'; + +/** + * ECS-style component interface for Parent. + */ +export interface ParentEcsComponent { + parent: number; +} + +export const parentId = createComponentId('Parent'); diff --git a/src/common/components/position-component.ts b/src/common/components/position-component.ts index b9c1ab11..c1901a3f 100644 --- a/src/common/components/position-component.ts +++ b/src/common/components/position-component.ts @@ -1,29 +1,12 @@ -import { Component } from '../../ecs/index.js'; import { Vector2 } from '../../math/index.js'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; /** - * Component to represent the position of an entity in 2D space. + * ECS-style component interface for position. */ -export class PositionComponent extends Component { - /** - * The local position of the entity relative to its parent (if any). - */ - public local: Vector2; - - /** - * The world position of the entity in the global coordinate space. - */ - public world: Vector2; - - /** - * Creates an instance of PositionComponent. - * @param x - The x-coordinate of the position. - * @param y - The y-coordinate of the position. - */ - constructor(x: number = 0, y: number = 0) { - super(); - - this.local = new Vector2(x, y); - this.world = new Vector2(x, y); - } +export interface PositionEcsComponent { + local: Vector2; + world: Vector2; } + +export const positionId = createComponentId('position'); diff --git a/src/common/components/rotation-component.ts b/src/common/components/rotation-component.ts index cc967c64..003a6e29 100644 --- a/src/common/components/rotation-component.ts +++ b/src/common/components/rotation-component.ts @@ -1,36 +1,11 @@ -import { Component } from '../../ecs/index.js'; -import { degreesToRadians } from '../../math/index.js'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; /** - * Component to represent the rotation of an entity in 2D space. + * ECS-style component interface for rotation. */ -export class RotationComponent extends Component { - /** - * The local rotation angle in radians. - */ - public local: number; - /** - * The world rotation angle in radians. - */ - public world: number; - - /** - * Creates an instance of RotationComponent. - * @param radians - The rotation angle in radians. - */ - constructor(radians: number = 0) { - super(); - - this.local = radians; - this.world = radians; - } - - /** - * Creates a RotationComponent from an angle in degrees. - * @param degrees - The rotation angle in degrees. - * @returns A new instance of RotationComponent. - */ - public static fromDegrees(degrees: number): RotationComponent { - return new RotationComponent(degreesToRadians(degrees)); - } +export interface RotationEcsComponent { + local: number; + world: number; } + +export const rotationId = createComponentId('rotation'); diff --git a/src/common/components/scale-component.ts b/src/common/components/scale-component.ts index c609a6fe..ff05692e 100644 --- a/src/common/components/scale-component.ts +++ b/src/common/components/scale-component.ts @@ -1,28 +1,12 @@ -import { Component } from '../../ecs/index.js'; import { Vector2 } from '../../math/index.js'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; /** - * Component to represent the scale of an entity in 2D space. + * ECS-style component interface for scale. */ -export class ScaleComponent extends Component { - /** - * The local scale of the entity relative to its parent (if any). - */ - public local: Vector2; - /** - * The world scale of the entity in the global coordinate space. - */ - public world: Vector2; - - /** - * Creates an instance of ScaleComponent. - * @param x - The scale factor along the x-axis. - * @param y - The scale factor along the y-axis. - */ - constructor(x: number = 1, y: number = 1) { - super(); - - this.local = new Vector2(x, y); - this.world = new Vector2(x, y); - } +export interface ScaleEcsComponent { + local: Vector2; + world: Vector2; } + +export const scaleId = createComponentId('scale'); diff --git a/src/common/components/speed-component.ts b/src/common/components/speed-component.ts index 20b48521..e5f09edf 100644 --- a/src/common/components/speed-component.ts +++ b/src/common/components/speed-component.ts @@ -1,18 +1,10 @@ -import { Component } from '../../ecs/index.js'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; /** - * Component to track an entities speed. + * ECS-style component interface for speed. */ -export class SpeedComponent extends Component { - public speed: number; - - /** - * Creates an instance of SpeedComponent. - * @param speed - the entity's speed - */ - constructor(speed: number) { - super(); - - this.speed = speed; - } +export interface SpeedEcsComponent { + speed: number; } + +export const speedId = createComponentId('speed'); diff --git a/src/common/systems/age-scale-system.test.ts b/src/common/systems/age-scale-system.test.ts index 07fd0005..015b5355 100644 --- a/src/common/systems/age-scale-system.test.ts +++ b/src/common/systems/age-scale-system.test.ts @@ -1,28 +1,54 @@ import { describe, expect, it } from 'vitest'; -import { Entity, World } from '../../ecs'; -import { AgeScaleSystem } from './age-scale-system'; -import { LifetimeComponent } from '../../lifecycle/components/lifetime-component'; -import { AgeScaleComponent, ScaleComponent } from '../../common'; +import { LifetimeEcsComponent, lifetimeId } from '../../lifecycle'; +import { EcsWorld, QueryResult } from '../../new-ecs'; +import { + AgeScaleEcsComponent, + ageScaleId, + ScaleEcsComponent, + scaleId, +} from '../components'; +import { Vector2 } from '../../math'; +import { createAgeScaleEcsSystem } from './age-scale-system'; describe('AgeScaleSystem', () => { - const world = new World('test'); + const world = new EcsWorld(); it('should correctly update the scale based on lifetime ratio', () => { // Arrange - const lifetimeComponent = new LifetimeComponent(10); // elapsedSeconds = 5, durationSeconds = 10 - lifetimeComponent.elapsedSeconds = 5; - const ageScaleComponent = new AgeScaleComponent(1, 1, 0.5, 0.1); // originalScale = (1,1), lifetimeScaleReduction = (0.5,0.1) - const scaleComponent = new ScaleComponent(1, 1); + const lifetimeComponent: LifetimeEcsComponent = { + elapsedSeconds: 5, + durationSeconds: 10, + hasExpired: false, + }; - const entity = new Entity(world, [ - lifetimeComponent, - scaleComponent, - ageScaleComponent, - ]); + const ageScaleComponent: AgeScaleEcsComponent = { + originalScaleX: 1, + originalScaleY: 1, + finalLifetimeScaleX: 0.5, + finalLifetimeScaleY: 0.1, + }; - const system = new AgeScaleSystem(); + const scaleComponent: ScaleEcsComponent = { + local: new Vector2(1, 1), + world: new Vector2(1, 1), + }; + + const entity = world.createEntity(); + + world.addComponent(entity, lifetimeId, lifetimeComponent); + world.addComponent(entity, ageScaleId, ageScaleComponent); + world.addComponent(entity, scaleId, scaleComponent); + + const system = createAgeScaleEcsSystem(); + + const queryResult: QueryResult< + [LifetimeEcsComponent, ScaleEcsComponent, AgeScaleEcsComponent] + > = { + entity, + components: [lifetimeComponent, scaleComponent, ageScaleComponent], + }; // Act - system.run(entity); + system.run(queryResult, world, null); // Assert const expectedScaleX = 0.75; // Calculated as: 1 * (1 - 0.5) + 0.5 * 0.5 @@ -33,23 +59,42 @@ describe('AgeScaleSystem', () => { it('should show the end scale at the end of the lifetime', () => { // Arrange - const lifetimeComponent = new LifetimeComponent(10); // elapsedSeconds = 10, durationSeconds = 10 - lifetimeComponent.elapsedSeconds = 10; - const ageScaleComponent = new AgeScaleComponent(2, 3, 0, 0.3); // originalScale = (2,3), lifetimeScaleReduction = (0,0.3) + const lifetimeComponent: LifetimeEcsComponent = { + elapsedSeconds: 10, + durationSeconds: 10, + hasExpired: false, + }; + const ageScaleComponent: AgeScaleEcsComponent = { + originalScaleX: 2, + originalScaleY: 3, + finalLifetimeScaleX: 0, + finalLifetimeScaleY: 0.3, + }; + const scaleComponent: ScaleEcsComponent = { + local: new Vector2(1, 1), + world: new Vector2(1, 1), + }; + const expectedScaleX = 0; const expectedScaleY = 0.3; - const scaleComponent = new ScaleComponent(1, 1); - const entity = new Entity(world, [ - lifetimeComponent, - scaleComponent, - ageScaleComponent, - ]); + const entity = world.createEntity(); + + world.addComponent(entity, lifetimeId, lifetimeComponent); + world.addComponent(entity, ageScaleId, ageScaleComponent); + world.addComponent(entity, scaleId, scaleComponent); + + const system = createAgeScaleEcsSystem(); - const system = new AgeScaleSystem(); + const queryResult: QueryResult< + [LifetimeEcsComponent, ScaleEcsComponent, AgeScaleEcsComponent] + > = { + entity, + components: [lifetimeComponent, scaleComponent, ageScaleComponent], + }; // Act - system.run(entity); + system.run(queryResult, world, null); // Assert expect(scaleComponent.local.x).toBe(expectedScaleX); diff --git a/src/common/systems/age-scale-system.ts b/src/common/systems/age-scale-system.ts index d293f4be..b50b5cb7 100644 --- a/src/common/systems/age-scale-system.ts +++ b/src/common/systems/age-scale-system.ts @@ -1,27 +1,25 @@ -import { Entity, System } from '../../ecs/index.js'; -import { ScaleComponent } from '../../common/index.js'; -import { LifetimeComponent } from '../../lifecycle/components/lifetime-component.js'; -import { AgeScaleComponent } from '../components/age-scale-component.js'; +import { ScaleEcsComponent, scaleId } from '../../common/index.js'; +import { + LifetimeEcsComponent, + lifetimeId, +} from '../../lifecycle/components/lifetime-component.js'; +import { + AgeScaleEcsComponent, + ageScaleId, +} from '../components/age-scale-component.js'; +import { EcsSystem } from '../../new-ecs/ecs-system.js'; /** - * System that manages the scale of entities with lifetime + * Creates an ECS system to handle age-based scaling of entities. + * @returns An ECS system that updates the scale of entities based on their lifetime. */ -export class AgeScaleSystem extends System { - /** - * Creates an instance of AgeScaleSystem. - */ - constructor() { - super([LifetimeComponent, ScaleComponent, AgeScaleComponent], 'age-scale'); - } - - /** - * Updates the entity's scale based on its lifetime, interpolating between the original scale and the lifetime scale reduction. - * @param entity - The entity whose scale will be updated according to its lifetime. - */ - public run(entity: Entity): void { - const lifetimeComponent = entity.getComponentRequired(LifetimeComponent); - const scaleComponent = entity.getComponentRequired(ScaleComponent); - const ageScaleComponent = entity.getComponentRequired(AgeScaleComponent); +export const createAgeScaleEcsSystem = (): EcsSystem< + [LifetimeEcsComponent, ScaleEcsComponent, AgeScaleEcsComponent] +> => ({ + query: [lifetimeId, scaleId, ageScaleId], + run: (result) => { + const [lifetimeComponent, scaleComponent, ageScaleComponent] = + result.components; const lifetimeRatio = lifetimeComponent.elapsedSeconds / lifetimeComponent.durationSeconds; @@ -33,5 +31,5 @@ export class AgeScaleSystem extends System { ageScaleComponent.finalLifetimeScaleY * lifetimeRatio; scaleComponent.local.x = newScaleX; scaleComponent.local.y = newScaleY; - } -} + }, +}); diff --git a/src/common/systems/parent-position-system.test.ts b/src/common/systems/parent-position-system.test.ts new file mode 100644 index 00000000..f944343c --- /dev/null +++ b/src/common/systems/parent-position-system.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { PositionEcsComponent, positionId } from '../components'; +import { EcsWorld } from '../../new-ecs'; +import { Vector2 } from '../../math'; +import { ParentEcsComponent, parentId } from '../components/parent-component'; +import { createParentPositionEcsSystem } from './parent-position-system'; + +describe('parent-position-system', () => { + let world: EcsWorld; + + beforeEach(() => { + world = new EcsWorld(); + world.addSystem(createParentPositionEcsSystem()); + }); + + it('root transforms should have the same world and local values', () => { + const entity = world.createEntity(); + + const positionComponent: PositionEcsComponent = { + local: new Vector2(10, 20), + world: new Vector2(0, 0), + }; + + world.addComponent(entity, positionId, positionComponent); + + world.update(); + + expect(positionComponent.world.x).toBe(10); + expect(positionComponent.local.x).toBe(10); + expect(positionComponent.world.y).toBe(20); + expect(positionComponent.local.y).toBe(20); + }); + + it('should compute world positions for a parent-child hierarchy', () => { + const parent = world.createEntity(); + const child = world.createEntity(); + + const parentPosition: PositionEcsComponent = { + local: new Vector2(10, 20), + world: new Vector2(0, 0), + }; + + const childPosition: PositionEcsComponent = { + local: new Vector2(5, 5), + world: new Vector2(0, 0), + }; + + const parentComponent: ParentEcsComponent = { parent }; + + world.addComponent(parent, positionId, parentPosition); + world.addComponent(child, positionId, childPosition); + world.addComponent(child, parentId, parentComponent); + world.update(); + + expect(parentPosition.world.x).toBe(10); + expect(parentPosition.local.x).toBe(10); + expect(parentPosition.world.y).toBe(20); + expect(parentPosition.local.y).toBe(20); + + expect(childPosition.world.x).toBe(15); + expect(childPosition.local.x).toBe(5); + expect(childPosition.world.y).toBe(25); + expect(childPosition.local.y).toBe(5); + }); + + it('should compute world positions for a parent-child-grandchild hierarchy', () => { + const parent = world.createEntity(); + const child = world.createEntity(); + const grandchild = world.createEntity(); + + const parentPosition: PositionEcsComponent = { + local: new Vector2(10, 20), + world: new Vector2(0, 0), + }; + + const childPosition: PositionEcsComponent = { + local: new Vector2(5, 5), + world: new Vector2(0, 0), + }; + + const grandchildPosition: PositionEcsComponent = { + local: new Vector2(2, 2), + world: new Vector2(0, 0), + }; + + world.addComponent(parent, positionId, parentPosition); + world.addComponent(child, positionId, childPosition); + world.addComponent(grandchild, positionId, grandchildPosition); + + world.addComponent(child, parentId, { parent }); + world.addComponent(grandchild, parentId, { parent: child }); + + world.update(); + + expect(parentPosition.world.x).toBe(10); + expect(parentPosition.local.x).toBe(10); + expect(parentPosition.world.y).toBe(20); + expect(parentPosition.local.y).toBe(20); + + expect(childPosition.world.x).toBe(15); + expect(childPosition.local.x).toBe(5); + expect(childPosition.world.y).toBe(25); + expect(childPosition.local.y).toBe(5); + + expect(grandchildPosition.world.x).toBe(17); + expect(grandchildPosition.local.x).toBe(2); + expect(grandchildPosition.world.y).toBe(27); + expect(grandchildPosition.local.y).toBe(2); + }); + + it('should compute world positions for a parent-child-grandchild hierarchy (out-of-order registration)', () => { + const parent = world.createEntity(); + const grandchild = world.createEntity(); + const child = world.createEntity(); + + const parentPosition: PositionEcsComponent = { + local: new Vector2(10, 20), + world: new Vector2(0, 0), + }; + + const childPosition: PositionEcsComponent = { + local: new Vector2(5, 5), + world: new Vector2(0, 0), + }; + + const grandchildPosition: PositionEcsComponent = { + local: new Vector2(2, 2), + world: new Vector2(0, 0), + }; + + world.addComponent(grandchild, positionId, grandchildPosition); + world.addComponent(parent, positionId, parentPosition); + world.addComponent(child, positionId, childPosition); + + world.addComponent(grandchild, parentId, { parent: child }); + world.addComponent(child, parentId, { parent }); + + world.update(); + + expect(parentPosition.world.x).toBe(10); + expect(parentPosition.local.x).toBe(10); + expect(parentPosition.world.y).toBe(20); + expect(parentPosition.local.y).toBe(20); + + expect(childPosition.world.x).toBe(15); + expect(childPosition.local.x).toBe(5); + expect(childPosition.world.y).toBe(25); + expect(childPosition.local.y).toBe(5); + + expect(grandchildPosition.world.x).toBe(17); + expect(grandchildPosition.local.x).toBe(2); + expect(grandchildPosition.world.y).toBe(27); + expect(grandchildPosition.local.y).toBe(2); + }); + + it('should compute world positions for a parent-child-grandchild hierarchy after they move', () => { + const parent = world.createEntity(); + const child = world.createEntity(); + const grandchild = world.createEntity(); + + const parentPosition: PositionEcsComponent = { + local: new Vector2(10, 20), + world: new Vector2(0, 0), + }; + + const childPosition: PositionEcsComponent = { + local: new Vector2(5, 5), + world: new Vector2(0, 0), + }; + + const grandchildPosition: PositionEcsComponent = { + local: new Vector2(2, 2), + world: new Vector2(0, 0), + }; + + world.addComponent(parent, positionId, parentPosition); + world.addComponent(child, positionId, childPosition); + world.addComponent(grandchild, positionId, grandchildPosition); + + world.addComponent(child, parentId, { parent }); + world.addComponent(grandchild, parentId, { parent: child }); + + world.update(); + + childPosition.local.x = 120; + + world.update(); + + expect(parentPosition.world.x).toBe(10); + expect(parentPosition.local.x).toBe(10); + expect(parentPosition.world.y).toBe(20); + expect(parentPosition.local.y).toBe(20); + + expect(childPosition.world.x).toBe(130); + expect(childPosition.local.x).toBe(120); + expect(childPosition.world.y).toBe(25); + expect(childPosition.local.y).toBe(5); + + expect(grandchildPosition.world.x).toBe(132); + expect(grandchildPosition.local.x).toBe(2); + expect(grandchildPosition.world.y).toBe(27); + expect(grandchildPosition.local.y).toBe(2); + }); +}); diff --git a/src/common/systems/parent-position-system.ts b/src/common/systems/parent-position-system.ts new file mode 100644 index 00000000..392fd088 --- /dev/null +++ b/src/common/systems/parent-position-system.ts @@ -0,0 +1,86 @@ +import { EcsSystem } from '../../new-ecs/ecs-system'; +import { EcsWorld } from '../../new-ecs/ecs-world.js'; +import { PositionEcsComponent, positionId } from '../components/index.js'; +import { parentId } from '../components/parent-component'; +import { createTransformCache, resetTransformCache } from './transform-cache'; + +const cache = createTransformCache(); + +function computeWorld(entity: number, world: EcsWorld): void { + if (cache.computed.has(entity)) { + return; + } + + // Cycle detection: if we re-enter an entity, break the cycle by treating it as a root. + if (cache.visiting.has(entity)) { + const positionComponent = world.getComponent(entity, positionId); + + if (positionComponent) { + positionComponent.world.x = positionComponent.local.x; + positionComponent.world.y = positionComponent.local.y; + } + + cache.computed.add(entity); + + return; + } + + cache.visiting.add(entity); + + const positionComponent = world.getComponent(entity, positionId); + + if (!positionComponent) { + cache.visiting.delete(entity); + + return; + } + + const parentComponent = world.getComponent(entity, parentId); + + if (!parentComponent) { + if (positionComponent) { + positionComponent.world.x = positionComponent.local.x; + positionComponent.world.y = positionComponent.local.y; + } + + cache.visiting.delete(entity); + cache.computed.add(entity); + + return; + } + + const parentEntity = parentComponent.parent; + + computeWorld(parentEntity, world); + + const parentPosition = world.getComponent(parentEntity, positionId); + + if (positionComponent) { + if (parentPosition) { + positionComponent.world.x = + parentPosition.world.x + positionComponent.local.x; + positionComponent.world.y = + parentPosition.world.y + positionComponent.local.y; + } else { + positionComponent.world.x = positionComponent.local.x; + positionComponent.world.y = positionComponent.local.y; + } + } + + cache.visiting.delete(entity); + cache.computed.add(entity); +} + +type TransformSystem = EcsSystem<[PositionEcsComponent], void> & { + beforeQuery: (world: EcsWorld) => void; +}; + +export const createParentPositionEcsSystem = (): TransformSystem => ({ + query: [positionId], + beforeQuery: () => resetTransformCache(cache), + run: (result, world) => { + const entity = result.entity; + + computeWorld(entity, world); + }, +}); diff --git a/src/common/systems/parent-rotation-system.test.ts b/src/common/systems/parent-rotation-system.test.ts new file mode 100644 index 00000000..b5a2f7dd --- /dev/null +++ b/src/common/systems/parent-rotation-system.test.ts @@ -0,0 +1,179 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { RotationEcsComponent, rotationId } from '../components'; +import { EcsWorld } from '../../new-ecs'; +import { ParentEcsComponent, parentId } from '../components/parent-component'; +import { createParentRotationEcsSystem } from './parent-rotation-system'; + +describe('parent-rotation-system', () => { + let world: EcsWorld; + + beforeEach(() => { + world = new EcsWorld(); + world.addSystem(createParentRotationEcsSystem()); + }); + + it('root rotations should have the same world and local values', () => { + const entity = world.createEntity(); + + const rotationComponent: RotationEcsComponent = { + local: 10, + world: 0, + }; + + world.addComponent(entity, rotationId, rotationComponent); + + world.update(); + + expect(rotationComponent.world).toBe(10); + expect(rotationComponent.local).toBe(10); + }); + + it('should compute world rotations for a parent-child hierarchy', () => { + const parent = world.createEntity(); + const child = world.createEntity(); + + const parentRotation: RotationEcsComponent = { + local: 10, + world: 0, + }; + + const childRotation: RotationEcsComponent = { + local: 5, + world: 0, + }; + + const parentComponent: ParentEcsComponent = { parent }; + + world.addComponent(parent, rotationId, parentRotation); + world.addComponent(child, rotationId, childRotation); + world.addComponent(child, parentId, parentComponent); + world.update(); + + expect(parentRotation.world).toBe(10); + expect(parentRotation.local).toBe(10); + + expect(childRotation.world).toBe(15); + expect(childRotation.local).toBe(5); + }); + + it('should compute world rotations for a parent-child-grandchild hierarchy', () => { + const parent = world.createEntity(); + const child = world.createEntity(); + const grandchild = world.createEntity(); + + const parentRotation: RotationEcsComponent = { + local: 10, + world: 0, + }; + + const childRotation: RotationEcsComponent = { + local: 5, + world: 0, + }; + + const grandchildRotation: RotationEcsComponent = { + local: 2, + world: 0, + }; + + world.addComponent(parent, rotationId, parentRotation); + world.addComponent(child, rotationId, childRotation); + world.addComponent(grandchild, rotationId, grandchildRotation); + + world.addComponent(child, parentId, { parent }); + world.addComponent(grandchild, parentId, { parent: child }); + + world.update(); + + expect(parentRotation.world).toBe(10); + expect(parentRotation.local).toBe(10); + + expect(childRotation.world).toBe(15); + expect(childRotation.local).toBe(5); + + expect(grandchildRotation.world).toBe(17); + expect(grandchildRotation.local).toBe(2); + }); + + it('should compute world rotations for a parent-child-grandchild hierarchy (out-of-order registration)', () => { + const parent = world.createEntity(); + const grandchild = world.createEntity(); + const child = world.createEntity(); + + const parentRotation: RotationEcsComponent = { + local: 10, + world: 0, + }; + + const childRotation: RotationEcsComponent = { + local: 5, + world: 0, + }; + + const grandchildRotation: RotationEcsComponent = { + local: 2, + world: 0, + }; + + world.addComponent(grandchild, rotationId, grandchildRotation); + world.addComponent(parent, rotationId, parentRotation); + world.addComponent(child, rotationId, childRotation); + + world.addComponent(grandchild, parentId, { parent: child }); + world.addComponent(child, parentId, { parent }); + + world.update(); + + expect(parentRotation.world).toBe(10); + expect(parentRotation.local).toBe(10); + + expect(childRotation.world).toBe(15); + expect(childRotation.local).toBe(5); + + expect(grandchildRotation.world).toBe(17); + expect(grandchildRotation.local).toBe(2); + }); + + it('should compute world rotations for a parent-child-grandchild hierarchy after they rotate', () => { + const parent = world.createEntity(); + const child = world.createEntity(); + const grandchild = world.createEntity(); + + const parentRotation: RotationEcsComponent = { + local: 10, + world: 0, + }; + + const childRotation: RotationEcsComponent = { + local: 5, + world: 0, + }; + + const grandchildRotation: RotationEcsComponent = { + local: 2, + world: 0, + }; + + world.addComponent(parent, rotationId, parentRotation); + world.addComponent(child, rotationId, childRotation); + world.addComponent(grandchild, rotationId, grandchildRotation); + + world.addComponent(child, parentId, { parent }); + world.addComponent(grandchild, parentId, { parent: child }); + + world.update(); + + childRotation.local = 7; + + world.update(); + + expect(parentRotation.world).toBe(10); + expect(parentRotation.local).toBe(10); + + expect(childRotation.world).toBe(17); + expect(childRotation.local).toBe(7); + + expect(grandchildRotation.world).toBe(19); + expect(grandchildRotation.local).toBe(2); + }); +}); diff --git a/src/common/systems/parent-rotation-system.ts b/src/common/systems/parent-rotation-system.ts new file mode 100644 index 00000000..2ee5668f --- /dev/null +++ b/src/common/systems/parent-rotation-system.ts @@ -0,0 +1,80 @@ +import { EcsSystem } from '../../new-ecs/ecs-system'; +import { EcsWorld } from '../../new-ecs/ecs-world.js'; +import { RotationEcsComponent, rotationId } from '../components/index.js'; +import { parentId } from '../components/parent-component'; +import { createTransformCache, resetTransformCache } from './transform-cache'; + +const cache = createTransformCache(); + +function computeWorld(entity: number, world: EcsWorld): void { + if (cache.computed.has(entity)) { + return; + } + + // Cycle detection: if we re-enter an entity, break the cycle by treating it as a root. + if (cache.visiting.has(entity)) { + const rotationComponent = world.getComponent(entity, rotationId); + + if (rotationComponent) { + rotationComponent.world = rotationComponent.local; + } + + cache.computed.add(entity); + + return; + } + + cache.visiting.add(entity); + + const rotationComponent = world.getComponent(entity, rotationId); + + if (!rotationComponent) { + cache.visiting.delete(entity); + + return; + } + + const parentComponent = world.getComponent(entity, parentId); + + if (!parentComponent) { + if (rotationComponent) { + rotationComponent.world = rotationComponent.local; + } + + cache.visiting.delete(entity); + cache.computed.add(entity); + + return; + } + + const parentEntity = parentComponent.parent; + + computeWorld(parentEntity, world); + + const parentRotation = world.getComponent(parentEntity, rotationId); + + if (rotationComponent) { + if (parentRotation) { + rotationComponent.world = parentRotation.world + rotationComponent.local; + } else { + rotationComponent.world = rotationComponent.local; + } + } + + cache.visiting.delete(entity); + cache.computed.add(entity); +} + +type TransformSystem = EcsSystem<[RotationEcsComponent], void> & { + beforeQuery: (world: EcsWorld) => void; +}; + +export const createParentRotationEcsSystem = (): TransformSystem => ({ + query: [rotationId], + beforeQuery: () => resetTransformCache(cache), + run: (result, world) => { + const entity = result.entity; + + computeWorld(entity, world); + }, +}); diff --git a/src/common/systems/parent-scale-system.test.ts b/src/common/systems/parent-scale-system.test.ts new file mode 100644 index 00000000..19914ea8 --- /dev/null +++ b/src/common/systems/parent-scale-system.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { ScaleEcsComponent, scaleId } from '../components'; +import { EcsWorld } from '../../new-ecs'; +import { Vector2 } from '../../math'; +import { ParentEcsComponent, parentId } from '../components/parent-component'; +import { createParentScaleEcsSystem } from './parent-scale-system'; + +describe('parent-scale-system', () => { + let world: EcsWorld; + + beforeEach(() => { + world = new EcsWorld(); + world.addSystem(createParentScaleEcsSystem()); + }); + + it('root transforms should have the same world and local values', () => { + const entity = world.createEntity(); + + const scaleComponent: ScaleEcsComponent = { + local: new Vector2(10, 20), + world: new Vector2(0, 0), + }; + + world.addComponent(entity, scaleId, scaleComponent); + + world.update(); + + expect(scaleComponent.world.x).toBe(10); + expect(scaleComponent.local.x).toBe(10); + expect(scaleComponent.world.y).toBe(20); + expect(scaleComponent.local.y).toBe(20); + }); + + it('should compute world scales for a parent-child hierarchy', () => { + const parent = world.createEntity(); + const child = world.createEntity(); + + const parentScale: ScaleEcsComponent = { + local: new Vector2(10, 20), + world: new Vector2(0, 0), + }; + + const childScale: ScaleEcsComponent = { + local: new Vector2(5, 5), + world: new Vector2(0, 0), + }; + + const parentComponent: ParentEcsComponent = { parent }; + + world.addComponent(parent, scaleId, parentScale); + world.addComponent(child, scaleId, childScale); + world.addComponent(child, parentId, parentComponent); + world.update(); + + expect(parentScale.world.x).toBe(10); + expect(parentScale.local.x).toBe(10); + expect(parentScale.world.y).toBe(20); + expect(parentScale.local.y).toBe(20); + + expect(childScale.world.x).toBe(50); + expect(childScale.local.x).toBe(5); + expect(childScale.world.y).toBe(100); + expect(childScale.local.y).toBe(5); + }); + + it('should compute world scales for a parent-child-grandchild hierarchy', () => { + const parent = world.createEntity(); + const child = world.createEntity(); + const grandchild = world.createEntity(); + + const parentScale: ScaleEcsComponent = { + local: new Vector2(10, 20), + world: new Vector2(0, 0), + }; + + const childScale: ScaleEcsComponent = { + local: new Vector2(5, 5), + world: new Vector2(0, 0), + }; + + const grandchildScale: ScaleEcsComponent = { + local: new Vector2(2, 2), + world: new Vector2(0, 0), + }; + + world.addComponent(parent, scaleId, parentScale); + world.addComponent(child, scaleId, childScale); + world.addComponent(grandchild, scaleId, grandchildScale); + + world.addComponent(child, parentId, { parent }); + world.addComponent(grandchild, parentId, { parent: child }); + + world.update(); + + expect(parentScale.world.x).toBe(10); + expect(parentScale.local.x).toBe(10); + expect(parentScale.world.y).toBe(20); + expect(parentScale.local.y).toBe(20); + + expect(childScale.world.x).toBe(50); + expect(childScale.local.x).toBe(5); + expect(childScale.world.y).toBe(100); + expect(childScale.local.y).toBe(5); + + expect(grandchildScale.world.x).toBe(100); + expect(grandchildScale.local.x).toBe(2); + expect(grandchildScale.world.y).toBe(200); + expect(grandchildScale.local.y).toBe(2); + }); + + it('should compute world scales for a parent-child-grandchild hierarchy (out-of-order registration)', () => { + const parent = world.createEntity(); + const grandchild = world.createEntity(); + const child = world.createEntity(); + + const parentScale: ScaleEcsComponent = { + local: new Vector2(10, 20), + world: new Vector2(0, 0), + }; + + const childScale: ScaleEcsComponent = { + local: new Vector2(5, 5), + world: new Vector2(0, 0), + }; + + const grandchildScale: ScaleEcsComponent = { + local: new Vector2(2, 2), + world: new Vector2(0, 0), + }; + + world.addComponent(grandchild, scaleId, grandchildScale); + world.addComponent(parent, scaleId, parentScale); + world.addComponent(child, scaleId, childScale); + + world.addComponent(grandchild, parentId, { parent: child }); + world.addComponent(child, parentId, { parent }); + + world.update(); + + expect(parentScale.world.x).toBe(10); + expect(parentScale.local.x).toBe(10); + expect(parentScale.world.y).toBe(20); + expect(parentScale.local.y).toBe(20); + + expect(childScale.world.x).toBe(50); + expect(childScale.local.x).toBe(5); + expect(childScale.world.y).toBe(100); + expect(childScale.local.y).toBe(5); + + expect(grandchildScale.world.x).toBe(100); + expect(grandchildScale.local.x).toBe(2); + expect(grandchildScale.world.y).toBe(200); + expect(grandchildScale.local.y).toBe(2); + }); + + it('should compute world scales for a parent-child-grandchild hierarchy after they scale', () => { + const parent = world.createEntity(); + const child = world.createEntity(); + const grandchild = world.createEntity(); + + const parentScale: ScaleEcsComponent = { + local: new Vector2(10, 20), + world: new Vector2(0, 0), + }; + + const childScale: ScaleEcsComponent = { + local: new Vector2(5, 5), + world: new Vector2(0, 0), + }; + + const grandchildScale: ScaleEcsComponent = { + local: new Vector2(2, 2), + world: new Vector2(0, 0), + }; + + world.addComponent(parent, scaleId, parentScale); + world.addComponent(child, scaleId, childScale); + world.addComponent(grandchild, scaleId, grandchildScale); + + world.addComponent(child, parentId, { parent }); + world.addComponent(grandchild, parentId, { parent: child }); + + world.update(); + + childScale.local.x = 120; + + world.update(); + + expect(parentScale.world.x).toBe(10); + expect(parentScale.local.x).toBe(10); + expect(parentScale.world.y).toBe(20); + expect(parentScale.local.y).toBe(20); + + expect(childScale.world.x).toBe(1200); + expect(childScale.local.x).toBe(120); + expect(childScale.world.y).toBe(100); + expect(childScale.local.y).toBe(5); + + expect(grandchildScale.world.x).toBe(2400); + expect(grandchildScale.local.x).toBe(2); + expect(grandchildScale.world.y).toBe(200); + expect(grandchildScale.local.y).toBe(2); + }); +}); diff --git a/src/common/systems/parent-scale-system.ts b/src/common/systems/parent-scale-system.ts new file mode 100644 index 00000000..3140bef3 --- /dev/null +++ b/src/common/systems/parent-scale-system.ts @@ -0,0 +1,84 @@ +import { EcsSystem } from '../../new-ecs/ecs-system'; +import { EcsWorld } from '../../new-ecs/ecs-world.js'; +import { ScaleEcsComponent, scaleId } from '../components/index.js'; +import { parentId } from '../components/parent-component'; +import { createTransformCache, resetTransformCache } from './transform-cache'; + +const cache = createTransformCache(); + +function computeWorld(entity: number, world: EcsWorld): void { + if (cache.computed.has(entity)) { + return; + } + + // Cycle detection: if we re-enter an entity, break the cycle by treating it as a root. + if (cache.visiting.has(entity)) { + const scaleComponent = world.getComponent(entity, scaleId); + + if (scaleComponent) { + scaleComponent.world.x = scaleComponent.local.x; + scaleComponent.world.y = scaleComponent.local.y; + } + + cache.computed.add(entity); + + return; + } + + cache.visiting.add(entity); + + const scaleComponent = world.getComponent(entity, scaleId); + + if (!scaleComponent) { + cache.visiting.delete(entity); + + return; + } + + const parentComponent = world.getComponent(entity, parentId); + + if (!parentComponent) { + if (scaleComponent) { + scaleComponent.world.x = scaleComponent.local.x; + scaleComponent.world.y = scaleComponent.local.y; + } + + cache.visiting.delete(entity); + cache.computed.add(entity); + + return; + } + + const parentEntity = parentComponent.parent; + + computeWorld(parentEntity, world); + + const parentScale = world.getComponent(parentEntity, scaleId); + + if (scaleComponent) { + if (parentScale) { + scaleComponent.world.x = parentScale.world.x * scaleComponent.local.x; + scaleComponent.world.y = parentScale.world.y * scaleComponent.local.y; + } else { + scaleComponent.world.x = scaleComponent.local.x; + scaleComponent.world.y = scaleComponent.local.y; + } + } + + cache.visiting.delete(entity); + cache.computed.add(entity); +} + +type TransformSystem = EcsSystem<[ScaleEcsComponent], void> & { + beforeQuery: (world: EcsWorld) => void; +}; + +export const createParentScaleEcsSystem = (): TransformSystem => ({ + query: [scaleId], + beforeQuery: () => resetTransformCache(cache), + run: (result, world) => { + const entity = result.entity; + + computeWorld(entity, world); + }, +}); diff --git a/src/common/systems/transform-cache.ts b/src/common/systems/transform-cache.ts new file mode 100644 index 00000000..5f7e1e6c --- /dev/null +++ b/src/common/systems/transform-cache.ts @@ -0,0 +1,14 @@ +export type TransformCache = { + computed: Set; + visiting: Set; +}; + +export const createTransformCache = (): TransformCache => ({ + computed: new Set(), + visiting: new Set(), +}); + +export const resetTransformCache = (cache: TransformCache): void => { + cache.computed.clear(); + cache.visiting.clear(); +}; diff --git a/src/common/systems/transform-system.test.ts b/src/common/systems/transform-system.test.ts deleted file mode 100644 index 04d093e5..00000000 --- a/src/common/systems/transform-system.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Entity } from '../../ecs/entity'; -import { World } from '../../ecs/world'; -import { PositionComponent } from '../components/position-component'; -import { RotationComponent } from '../components/rotation-component'; -import { ScaleComponent } from '../components/scale-component'; -import { TransformSystem } from './transform-system'; - -describe('TransformSystem', () => { - it('should set world transform equal to local transform for root entities', () => { - const world = new World('test-world'); - const system = new TransformSystem(); - - const entity = new Entity(world, [ - new PositionComponent(10, 20), - new RotationComponent(Math.PI / 4), - new ScaleComponent(2, 3), - ]); - - system.run(entity); - - const position = entity.getComponent(PositionComponent); - const rotation = entity.getComponent(RotationComponent); - const scale = entity.getComponent(ScaleComponent); - - expect(position?.world.x).toBe(10); - expect(position?.world.y).toBe(20); - expect(rotation?.world).toBe(Math.PI / 4); - expect(scale?.world.x).toBe(2); - expect(scale?.world.y).toBe(3); - }); - - it('should apply parent position to child', () => { - const world = new World('test-world'); - const system = new TransformSystem(); - - const parent = new Entity(world, [ - new PositionComponent(100, 200), - new RotationComponent(0), - new ScaleComponent(1, 1), - ]); - - const child = new Entity(world, [ - new PositionComponent(10, 20), - new RotationComponent(0), - new ScaleComponent(1, 1), - ]); - - child.parentTo(parent); - - // Process parent first to set its world transform - system.run(parent); - // Then process child - system.run(child); - - const childPosition = child.getComponent(PositionComponent); - - expect(childPosition?.world.x).toBe(110); - expect(childPosition?.world.y).toBe(220); - }); - - it('should apply parent rotation to child position', () => { - const world = new World('test-world'); - const system = new TransformSystem(); - - const parent = new Entity(world, [ - new PositionComponent(0, 0), - new RotationComponent(Math.PI / 2), // 90 degrees - new ScaleComponent(1, 1), - ]); - - const child = new Entity(world, [ - new PositionComponent(10, 0), - new RotationComponent(0), - new ScaleComponent(1, 1), - ]); - - child.parentTo(parent); - - system.run(parent); - system.run(child); - - const childPosition = child.getComponent(PositionComponent); - - // After 90-degree rotation, (10, 0) becomes approximately (0, 10) - expect(childPosition?.world.x).toBeCloseTo(0, 5); - expect(childPosition?.world.y).toBeCloseTo(10, 5); - }); - - it('should apply parent scale to child position', () => { - const world = new World('test-world'); - const system = new TransformSystem(); - - const parent = new Entity(world, [ - new PositionComponent(0, 0), - new RotationComponent(0), - new ScaleComponent(2, 3), - ]); - - const child = new Entity(world, [ - new PositionComponent(10, 20), - new RotationComponent(0), - new ScaleComponent(1, 1), - ]); - - child.parentTo(parent); - - system.run(parent); - system.run(child); - - const childPosition = child.getComponent(PositionComponent); - - // Child position scaled by parent scale - expect(childPosition?.world.x).toBe(20); - expect(childPosition?.world.y).toBe(60); - }); - - it('should combine parent rotation with child rotation', () => { - const world = new World('test-world'); - const system = new TransformSystem(); - - const parent = new Entity(world, [ - new PositionComponent(0, 0), - new RotationComponent(Math.PI / 4), - new ScaleComponent(1, 1), - ]); - - const child = new Entity(world, [ - new PositionComponent(0, 0), - new RotationComponent(Math.PI / 4), - new ScaleComponent(1, 1), - ]); - - child.parentTo(parent); - - system.run(parent); - system.run(child); - - const childRotation = child.getComponent(RotationComponent); - - // Child rotation = parent rotation + child local rotation - expect(childRotation?.world).toBeCloseTo(Math.PI / 2, 5); - }); - - it('should multiply parent scale with child scale', () => { - const world = new World('test-world'); - const system = new TransformSystem(); - - const parent = new Entity(world, [ - new PositionComponent(0, 0), - new RotationComponent(0), - new ScaleComponent(2, 3), - ]); - - const child = new Entity(world, [ - new PositionComponent(0, 0), - new RotationComponent(0), - new ScaleComponent(4, 5), - ]); - - child.parentTo(parent); - - system.run(parent); - system.run(child); - - const childScale = child.getComponent(ScaleComponent); - - // Child scale = parent scale * child local scale - expect(childScale?.world.x).toBe(8); - expect(childScale?.world.y).toBe(15); - }); - - it('should work with deeply nested hierarchies', () => { - const world = new World('test-world'); - const system = new TransformSystem(); - - const grandparent = new Entity(world, [ - new PositionComponent(100, 100), - new RotationComponent(0), - new ScaleComponent(2, 2), - ]); - - const parent = new Entity(world, [ - new PositionComponent(10, 10), - new RotationComponent(0), - new ScaleComponent(2, 2), - ]); - - const child = new Entity(world, [ - new PositionComponent(5, 5), - new RotationComponent(0), - new ScaleComponent(2, 2), - ]); - - parent.parentTo(grandparent); - child.parentTo(parent); - - system.run(grandparent); - system.run(parent); - system.run(child); - - const childPosition = child.getComponent(PositionComponent); - const childScale = child.getComponent(ScaleComponent); - - // Position: grandparent(100,100) + parent_local(10,10)*2 + child_local(5,5)*4 - // = 100 + 20 + 20 = 140 - expect(childPosition?.world.x).toBe(140); - expect(childPosition?.world.y).toBe(140); - - // Scale: 2 * 2 * 2 = 8 - expect(childScale?.world.x).toBe(8); - expect(childScale?.world.y).toBe(8); - }); - - it('should handle entities with missing components gracefully', () => { - const world = new World('test-world'); - const system = new TransformSystem(); - - const parent = new Entity(world, [new PositionComponent(10, 20)]); - - const child = new Entity(world, [new PositionComponent(5, 5)]); - - child.parentTo(parent); - - // Should not throw - expect(() => { - system.run(parent); - system.run(child); - }).not.toThrow(); - - const childPosition = child.getComponent(PositionComponent); - - expect(childPosition?.world.x).toBe(15); - expect(childPosition?.world.y).toBe(25); - }); -}); diff --git a/src/common/systems/transform-system.ts b/src/common/systems/transform-system.ts index c06037d1..70fcf80b 100644 --- a/src/common/systems/transform-system.ts +++ b/src/common/systems/transform-system.ts @@ -1,150 +1,147 @@ -import { Entity, System } from '../../ecs/index.js'; +import { EcsSystem } from '../../new-ecs/ecs-system'; +import { EcsWorld } from '../../new-ecs/ecs-world.js'; import { - PositionComponent, - RotationComponent, - ScaleComponent, -} from '../../common/index.js'; - -/** - * System that manages the scale of entities with age - */ -export class TransformSystem extends System { - /** - * Creates an instance of TransformSystem. - */ - constructor() { - super([], 'transform'); + PositionEcsComponent, + positionId, + rotationId, + scaleId, +} from '../components/index.js'; +import { parentId } from '../components/parent-component.js'; + +type TransformCache = { + computed: Set; + visiting: Set; +}; + +function setLocalAsWorldIfExists(entity: number, world: EcsWorld): void { + const positionComponent = world.getComponent(entity, positionId); + + if (positionComponent) { + positionComponent.world.x = positionComponent.local.x; + positionComponent.world.y = positionComponent.local.y; } - /** - * Updates the entity's world transform based on its parent's world transform and its local transform. - * @param entity - The entity whose world transform will be updated. - */ - public run(entity: Entity): void { - const positionComponent = entity.getComponent(PositionComponent); - const rotationComponent = entity.getComponent(RotationComponent); - const scaleComponent = entity.getComponent(ScaleComponent); - - const parent = entity.parent; - - if (parent === null) { - this._applyLocalToWorld( - positionComponent, - rotationComponent, - scaleComponent, - ); - } else { - this._applyParentTransform( - parent, - positionComponent, - rotationComponent, - scaleComponent, - ); - } + const rotationComponent = world.getComponent(entity, rotationId); + + if (rotationComponent) { + rotationComponent.world = rotationComponent.local; + } + + const scaleComponent = world.getComponent(entity, scaleId); + + if (scaleComponent) { + scaleComponent.world = scaleComponent.local; } +} - /** - * Copies local transform to world transform for root entities. - */ - private _applyLocalToWorld( - positionComponent: PositionComponent | null, - rotationComponent: RotationComponent | null, - scaleComponent: ScaleComponent | null, - ): void { - if (positionComponent) { +function composeWithParent( + entity: number, + parentEntity: number, + world: EcsWorld, +): void { + const positionComponent = world.getComponent(entity, positionId); + const rotationComponent = world.getComponent(entity, rotationId); + const scaleComponent = world.getComponent(entity, scaleId); + + const parentPosition = world.getComponent(parentEntity, positionId); + const parentRotation = world.getComponent(parentEntity, rotationId); + const parentScale = world.getComponent(parentEntity, scaleId); + + if (positionComponent) { + if (parentPosition) { + positionComponent.world.x = + parentPosition.world.x + positionComponent.local.x; + positionComponent.world.y = + parentPosition.world.y + positionComponent.local.y; + } else { positionComponent.world.x = positionComponent.local.x; positionComponent.world.y = positionComponent.local.y; } + } - if (rotationComponent) { + if (rotationComponent) { + if (parentRotation) { + rotationComponent.world = parentRotation.world + rotationComponent.local; + } else { rotationComponent.world = rotationComponent.local; } + } - if (scaleComponent) { + if (scaleComponent) { + if (parentScale) { + scaleComponent.world.x = parentScale.world.x * scaleComponent.local.x; + scaleComponent.world.y = parentScale.world.y * scaleComponent.local.y; + } else { scaleComponent.world.x = scaleComponent.local.x; scaleComponent.world.y = scaleComponent.local.y; } } +} - /** - * Applies parent's world transform to child's local transform to compute child's world transform. - */ - private _applyParentTransform( - parent: Entity, - positionComponent: PositionComponent | null, - rotationComponent: RotationComponent | null, - scaleComponent: ScaleComponent | null, - ): void { - const parentPosition = parent.getComponent(PositionComponent); - const parentRotation = parent.getComponent(RotationComponent); - const parentScale = parent.getComponent(ScaleComponent); - - this._applyParentRotation(rotationComponent, parentRotation); - this._applyParentScale(scaleComponent, parentScale); - this._applyParentPosition( - positionComponent, - parentPosition, - parentRotation, - parentScale, - ); +function computeWorld( + entity: number, + cache: TransformCache, + world: EcsWorld, +): void { + if (cache.computed.has(entity)) { + return; } - /** - * Applies parent rotation to child rotation. - */ - private _applyParentRotation( - rotationComponent: RotationComponent | null, - parentRotation: RotationComponent | null, - ): void { - if (rotationComponent) { - const parentWorldRotation = parentRotation ? parentRotation.world : 0; - rotationComponent.world = parentWorldRotation + rotationComponent.local; - } - } + // Cycle detection: if we re-enter an entity, break the cycle by treating it as a root. + if (cache.visiting.has(entity)) { + setLocalAsWorldIfExists(entity, world); + cache.computed.add(entity); - /** - * Applies parent scale to child scale. - */ - private _applyParentScale( - scaleComponent: ScaleComponent | null, - parentScale: ScaleComponent | null, - ): void { - if (scaleComponent) { - const parentWorldScaleX = parentScale ? parentScale.world.x : 1; - const parentWorldScaleY = parentScale ? parentScale.world.y : 1; - scaleComponent.world.x = parentWorldScaleX * scaleComponent.local.x; - scaleComponent.world.y = parentWorldScaleY * scaleComponent.local.y; - } + return; } - /** - * Applies parent transform to child position. - */ - private _applyParentPosition( - positionComponent: PositionComponent | null, - parentPosition: PositionComponent | null, - parentRotation: RotationComponent | null, - parentScale: ScaleComponent | null, - ): void { - if (!positionComponent) { - return; - } + cache.visiting.add(entity); - const parentWorldPosX = parentPosition ? parentPosition.world.x : 0; - const parentWorldPosY = parentPosition ? parentPosition.world.y : 0; - const parentWorldRotation = parentRotation ? parentRotation.world : 0; - const parentWorldScaleX = parentScale ? parentScale.world.x : 1; - const parentWorldScaleY = parentScale ? parentScale.world.y : 1; + const hasPosition = world.getComponent(entity, positionId); + const hasRotation = world.getComponent(entity, rotationId); + const hasScale = world.getComponent(entity, scaleId); - const scaledLocalX = positionComponent.local.x * parentWorldScaleX; - const scaledLocalY = positionComponent.local.y * parentWorldScaleY; + if (!hasPosition && !hasRotation && !hasScale) { + cache.visiting.delete(entity); + + return; + } - const cos = Math.cos(parentWorldRotation); - const sin = Math.sin(parentWorldRotation); - const rotatedX = scaledLocalX * cos - scaledLocalY * sin; - const rotatedY = scaledLocalX * sin + scaledLocalY * cos; + const parentComponent = world.getComponent(entity, parentId); - positionComponent.world.x = parentWorldPosX + rotatedX; - positionComponent.world.y = parentWorldPosY + rotatedY; + if (!parentComponent) { + setLocalAsWorldIfExists(entity, world); + cache.visiting.delete(entity); + cache.computed.add(entity); + + return; } + + const parentEntity = parentComponent.parent; + + computeWorld(parentEntity, cache, world); + + composeWithParent(entity, parentEntity, world); + + cache.visiting.delete(entity); + cache.computed.add(entity); } + +type TransformSystem = EcsSystem<[PositionEcsComponent], TransformCache> & { + beforeQuery: (world: EcsWorld) => TransformCache; +}; + +export const createTransformEcsSystem = (): TransformSystem => ({ + query: [positionId], + + beforeQuery: () => ({ + computed: new Set(), + visiting: new Set(), + }), + + run: (result, world, cache) => { + const entity = result.entity; + + computeWorld(entity, cache, world); + }, +}); diff --git a/src/ecs/constants/index.ts b/src/ecs/constants/index.ts deleted file mode 100644 index b4208086..00000000 --- a/src/ecs/constants/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './system-registration-position.js'; diff --git a/src/ecs/constants/system-registration-position.ts b/src/ecs/constants/system-registration-position.ts deleted file mode 100644 index fd97c8df..00000000 --- a/src/ecs/constants/system-registration-position.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type SystemRegistrationPosition = - (typeof systemRegistrationPositions)[keyof typeof systemRegistrationPositions]; - -export const systemRegistrationPositions = { - early: 5000, - normal: 10000, - late: 15000, -} as const; diff --git a/src/ecs/entity.test.ts b/src/ecs/entity.test.ts deleted file mode 100644 index e4b4c71a..00000000 --- a/src/ecs/entity.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { Entity } from './entity'; -import { expect, test } from 'vitest'; -import { Component } from './types'; -import { World } from './world'; - -class MockComponent1 extends Component {} -class MockComponent2 extends Component {} - -const world = new World('test-world'); - -test('creating an entity with no name', () => { - const entity = new Entity(world, []); - - expect(entity).not.toBe(null); - expect(entity.name).toBe('[anonymous entity]'); -}); - -test('creating an entity', () => { - const entity = new Entity(world, [], { - name: 'player', - }); - - expect(entity).not.toBe(null); - expect(entity.name).toBe('player'); -}); - -test('adding a component', () => { - const entity = new Entity(world, []); - const component = new MockComponent1(); - - entity.addComponents(component); - - expect(entity.getComponent(MockComponent1)).not.toBeNull(); -}); - -test('removing a component', () => { - const component = new MockComponent1(); - const entity = new Entity(world, [component]); - - entity.removeComponents(MockComponent1); - - expect(entity.getComponent(MockComponent1)).toBeNull(); -}); - -test("parentTo sets the parent and adds to parent's children", () => { - const parent = new Entity(world, []); - const child = new Entity(world, []); - - child.parentTo(parent); - - expect(child.parent).toBe(parent); - expect(parent.children.has(child)).toBe(true); -}); - -test("parentTo removes child from previous parent's children", () => { - const parent1 = new Entity(world, []); - const parent2 = new Entity(world, []); - const child = new Entity(world, []); - - child.parentTo(parent1); - expect(parent1.children.has(child)).toBe(true); - - child.parentTo(parent2); - expect(child.parent).toBe(parent2); - expect(parent2.children.has(child)).toBe(true); - expect(parent1.children.has(child)).toBe(false); -}); - -test('removeParent removes the parent', () => { - const parent1 = new Entity(world, []); - const child = new Entity(world, []); - - child.parentTo(parent1); - expect(parent1.children.has(child)).toBe(true); - - child.removeParent(); - expect(child.parent).toBe(null); - expect(parent1.children.has(child)).toBe(false); -}); - -test('creating an entity with a parent in constructor', () => { - const parent = new Entity(world, []); - const child = new Entity(world, [], { parent }); - - expect(child.parent).toBe(parent); - expect(parent.children.has(child)).toBe(true); -}); - -test('creating an entity with enabled=false in constructor', () => { - const entity = new Entity(world, [], { enabled: false }); - - expect(entity.enabled).toBe(false); -}); - -test('addComponents throws when adding a component that already exists', () => { - const entity = new Entity(world, [new MockComponent1()]); - - expect(() => entity.addComponents(new MockComponent1())).toThrowError( - `Unable to add component "${MockComponent1.id.toString()}" to entity "${entity.name}", it already exists on the entity.`, - ); -}); - -test('addComponents does not throw when adding a second component that does not exists', () => { - const entity = new Entity(world, [new MockComponent1()]); - - expect(() => entity.addComponents(new MockComponent2())).not.toThrowError(); -}); diff --git a/src/ecs/entity.ts b/src/ecs/entity.ts deleted file mode 100644 index a999fbd7..00000000 --- a/src/ecs/entity.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { ForgeEvent } from '../events/forge-event.js'; -import { Component, ComponentCtor } from './types/index.js'; -import type { Query } from './types/Query.js'; -import type { World } from './world.js'; - -/** - * Options for configuring an Entity. - */ -export interface EntityOptions { - /** - * Indicates whether the entity is enabled. - */ - enabled: boolean; - - /** - * The name of the entity. - */ - name?: string; - - /** - * The optional parent entity to assign at creation. - */ - parent?: Entity; -} - -/** - * Default options for an Entity. - */ -const defaultEntityOptions: EntityOptions = { - enabled: true, -}; - -/** - * Represents an entity in the Entity-Component-System (ECS) architecture. - * An entity is a container for components and has a unique identifier. - */ -export class Entity { - /** - * The name of the entity. - */ - public name: string; - - /** - * Indicates whether the entity is enabled. - */ - public enabled: boolean; - - /** - * The world to which this entity belongs. - */ - public world: World; - - /** - * Event triggered when the entity is removed from the world. - */ - public onRemovedFromWorld: ForgeEvent; - - /** - * The unique identifier of the entity. - */ - private readonly _id: number; - - /** - * The map of components associated with this entity, keyed by component name. - */ - private readonly _components: Map; - - /** - * The parent entity, if any. - */ - private _parent: Entity | null = null; - - /** - * The child entities of this entity. - */ - private readonly _children: Set = new Set(); - - /** - * The counter for generating unique identifiers. - */ - private static _idCounter: number = 0; - - /** - * Creates a new Entity instance. - * @param world - The world to which this entity belongs. - * @param initialComponents - The initial components to associate with the entity. - * @param options - Optional configuration for the entity. - */ - constructor( - world: World, - initialComponents: Component[], - options: Partial = {}, - ) { - const mergedOptions: EntityOptions = { - ...defaultEntityOptions, - ...options, - }; - - this._id = Entity._generateId(); - this._components = new Map( - initialComponents.map((component) => [ - (component.constructor as ComponentCtor).id, - component, - ]), - ); - this.world = world; - this.onRemovedFromWorld = new ForgeEvent('entityRemovedFromWorld'); - this.enabled = mergedOptions.enabled; - this.name = mergedOptions.name ?? '[anonymous entity]'; - - if (mergedOptions.parent) { - this.parentTo(mergedOptions.parent); - } - } - - /** - * Generates a unique identifier for the entity. - * @returns The unique identifier. - */ - private static _generateId() { - return Entity._idCounter++; - } - - /** - * Gets the unique identifier of the entity. - */ - get id(): number { - return this._id; - } - - /** - * Sets the parent of the entity. - * @param parent - The parent entity. - */ - public parentTo(parent: Entity): void { - if (this._parent) { - this._parent._children.delete(this); - } - - this._parent = parent; - parent._children.add(this); - } - - /** - * Removes the parent relationship from this entity, if it has one. - * This will also remove this entity from its parent's set of children. - * If the entity does not have a parent, this method does nothing. - */ - public removeParent(): void { - if (this._parent) { - this._parent._children.delete(this); - this._parent = null; - } - } - - /** - * Gets the parent of the entity. - * @returns The parent entity, or null if there is no parent. - */ - get parent(): Entity | null { - return this._parent; - } - - /** - * Gets the child entities of this entity. - * @returns A set of child entities. - */ - get children(): Set { - return new Set(this._children); - } - - /** - * Adds components to the entity. - * @param components - The components to add. - * @throws An error if a component with the same name already exists on the entity. - */ - public addComponents(...components: Component[]): void { - for (const component of components) { - const type = component.constructor as ComponentCtor; - const key = type.id; - - if (this._components.has(key)) { - throw new Error( - `Unable to add component "${key.toString()}" to entity "${this.name}", it already exists on the entity.`, - ); - } - - this._components.set(key, component); - this.world.updateSystemEntities(this); - } - } - - /** - * Checks if the entity contains all specified components. - * @param query - The types of the components to check. - * @returns True if the entity contains all specified components, otherwise false. - */ - public containsAllComponents(query: Query): boolean { - for (const ComponentType of query) { - if (!this._components.has(ComponentType.id)) { - return false; - } - } - - return true; - } - - /** - * Gets a component by its name. - * @param componentType - The type of the component to get. - * @returns The component if found, otherwise null. - */ - public getComponent( - componentType: C, - ): InstanceType | null { - return (this._components.get(componentType.id) as InstanceType) ?? null; - } - - /** - * Gets a component by its name. - * @param componentType - The type of the component to get. - * @returns The component if found. - * @throws An error if the component is not found. - */ - public getComponentRequired( - componentType: C, - ): InstanceType { - const componentInstance = this.getComponent(componentType); - - if (componentInstance === null) { - throw new Error( - `Tried to get required component "${componentType.id.toString()}" but it is null on the entity "${this.name}"`, - ); - } - - return componentInstance; - } - - /** - * Removes components from the entity. - * @param componentTypes - The types of the components to remove. - */ - public removeComponents(...componentTypes: ComponentCtor[]): void { - for (const ComponentType of componentTypes) { - this._components.delete(ComponentType.id); - } - - this.world.updateSystemEntities(this); - } -} diff --git a/src/ecs/game.test.ts b/src/ecs/game.test.ts deleted file mode 100644 index 2ae49570..00000000 --- a/src/ecs/game.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { Game } from './game'; -import { createContainer } from '../utilities'; -import { World } from './world'; -import { Time } from '../common'; - -interface GameMock { - registerWorld: Mock; - deregisterWorld: Mock; - run: Mock; - stop: Mock; - onWindowResize: { - raise: Mock; - }; - _worlds: Set; -} - -describe('Game', () => { - let game: GameMock; - let world: World; - let time: Time; - - beforeEach(() => { - time = new Time(); - game = new Game(time, createContainer('test')) as unknown as GameMock; - world = new World('foo'); - }); - - it('should initialize with an empty set of worlds', () => { - expect(game['_worlds'].size).toBe(0); - }); - - it('should register a world to the game', () => { - game.registerWorld(world); - expect(game['_worlds'].has(world)).toBe(true); - }); - - it('should deregister a world from the game', () => { - game.registerWorld(world); - game.deregisterWorld(world); - expect(game['_worlds'].has(world)).toBe(false); - }); - - it('should stop all registered worlds and remove resize event listener', () => { - game.registerWorld(world); - const stopSpy = vi.spyOn(world, 'stop'); - const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); - game.stop(); - expect(stopSpy).toHaveBeenCalled(); - expect(removeEventListenerSpy).toHaveBeenCalledWith( - 'resize', - game.onWindowResize.raise, - ); - }); - - it('should raise the onWindowResize event when the window is resized', () => { - const raiseSpy = vi.spyOn(game.onWindowResize, 'raise'); - window.dispatchEvent(new Event('resize')); - expect(raiseSpy).toHaveBeenCalled(); - }); -}); diff --git a/src/ecs/game.ts b/src/ecs/game.ts deleted file mode 100644 index 87e13756..00000000 --- a/src/ecs/game.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* eslint-disable @typescript-eslint/unbound-method */ -import { type Stoppable, Time } from '../common/index.js'; -import type { World } from './world.js'; -import { ForgeEvent } from '../events/index.js'; -import { createContainer } from '../utilities/index.js'; - -/** - * A game that manages worlds and handles the game loop. - */ -export class Game implements Stoppable { - /** - * Event triggered when the window is resized. - */ - public readonly onWindowResize: ForgeEvent; - - /** - * The container element for the game. - * This is where the game will render its worlds. - */ - public readonly container: HTMLElement; - - /** - * The set of worlds managed by the game. - */ - private readonly _worlds: Set; - - /** - * The time instance for the game. - */ - private readonly _time: Time; - - /** - * Creates a new Game instance. - * @param container - The HTML element that will contain the game. - */ - constructor(time: Time, container?: HTMLElement | string) { - this._time = time; - this._worlds = new Set(); - this.container = this._determineContainer(container); - - this.onWindowResize = new ForgeEvent('window-resize'); - - window.addEventListener('resize', () => { - this.onWindowResize.raise(); - }); - } - - /** - * Starts the game loop. - * @param time - The initial time value. - */ - public run(time = 0): void { - this._time.update(time); - - for (const world of this._worlds) { - world.update(); - } - - requestAnimationFrame((t) => this.run(t)); - } - - /** - * Registers a world to the game. - * @param world - The world to register. - */ - public registerWorld(world: World): void { - this._worlds.add(world); - } - - /** - * Deregisters a world from the game. - * @param world - The world to deregister. - */ - public deregisterWorld(world: World): void { - world.stop(); - this._worlds.delete(world); - } - - /** - * Swaps the current world with a new one. - * This deregisters all existing worlds and registers the new world. - * @param world - The new world to switch to. - */ - public swapToWorld(world: World): void { - for (const existingWorld of this._worlds) { - this.deregisterWorld(existingWorld); - } - - this.registerWorld(world); - } - - /** - * Stops the game and all registered worlds. - */ - public stop(): void { - window.removeEventListener('resize', this.onWindowResize.raise); - - for (const world of this._worlds) { - world.stop(); - } - } - - private _determineContainer(container?: HTMLElement | string): HTMLElement { - if (typeof container === 'string') { - return createContainer(container); - } - - if (container instanceof HTMLElement) { - return container as HTMLDivElement; - } - - return createContainer('forge-game-container'); - } -} diff --git a/src/ecs/index.ts b/src/ecs/index.ts deleted file mode 100644 index 00c8a005..00000000 --- a/src/ecs/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './entity.js'; -export * from './types/index.js'; -export * from './world.js'; -export * from './game.js'; -export * from './utilities/index.js'; diff --git a/src/ecs/types/Component.ts b/src/ecs/types/Component.ts deleted file mode 100644 index b993e7f5..00000000 --- a/src/ecs/types/Component.ts +++ /dev/null @@ -1,28 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -const componentIds = new WeakMap(); - -/** - * Represents a component in the Entity-Component-System (ECS) architecture. - * Each component has a unique id represented by a symbol. - */ -export abstract class Component { - /** - * The unique id of the component. - */ - static get id(): symbol { - let id = componentIds.get(this); - - if (!id) { - id = Symbol(this.name); - componentIds.set(this, id); - } - - return id; - } -} - -export type ComponentCtor = { - readonly id: symbol; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - new (...args: any[]): T; -}; diff --git a/src/ecs/types/Query.ts b/src/ecs/types/Query.ts deleted file mode 100644 index adc98d71..00000000 --- a/src/ecs/types/Query.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ComponentCtor } from './Component.js'; - -export type Query = ReadonlyArray; diff --git a/src/ecs/types/System.test.ts b/src/ecs/types/System.test.ts deleted file mode 100644 index 20e8523f..00000000 --- a/src/ecs/types/System.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { System } from './System'; -import { Entity } from '../entity'; -import { World } from '../world'; -import { PositionComponent } from '../../common'; - -class TestSystem extends System { - public run(): void { - // Implement the abstract method for testing - } -} - -class AnotherTestSystem extends System { - public run(): void { - // Implement the abstract method for testing - } -} - -describe('System', () => { - let system: TestSystem; - let entities: Entity[]; - let world: World; - - beforeEach(() => { - world = new World('TestWorld'); - system = new TestSystem([PositionComponent], 'TestSystem'); - entities = [new Entity(world, []), new Entity(world, [])]; - }); - - it('should initialize with given name and components', () => { - expect(system.name).toBe('TestSystem'); - expect(system.query.length).toBe(1); - }); - - it('should initialize with default name', () => { - const defaultSystem = new TestSystem([]); - expect(defaultSystem.name).toBe('[anonymous system]'); - }); - - it('should have a unique id for each system class', () => { - const id1 = TestSystem.id; - const id2 = TestSystem.id; - const id3 = AnotherTestSystem.id; - - // Same class should return the same id - expect(id1).toBe(id2); - // Different classes should have different ids - expect(id1).not.toBe(id3); - // IDs should be symbols - expect(typeof id1).toBe('symbol'); - expect(typeof id3).toBe('symbol'); - }); - - it('should run system on enabled entities', () => { - const runSpy = vi.spyOn(system, 'run'); - system.runSystem(entities); - expect(runSpy).toHaveBeenCalledTimes(2); - }); - - it('should not run system if it is disabled', () => { - system.isEnabled = false; - const runSpy = vi.spyOn(system, 'run'); - system.runSystem(entities); - expect(runSpy).not.toHaveBeenCalled(); - }); - - it('should call beforeAll hook before running system', () => { - const beforeAllSpy = vi.spyOn(system, 'beforeAll'); - system.runSystem(entities); - expect(beforeAllSpy).toHaveBeenCalledWith(entities); - }); - - it('should stop the system', () => { - const stopSpy = vi.spyOn(system, 'stop'); - system.stop(); - expect(stopSpy).toHaveBeenCalled(); - }); -}); diff --git a/src/ecs/types/System.ts b/src/ecs/types/System.ts deleted file mode 100644 index 038fd1b7..00000000 --- a/src/ecs/types/System.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { Stoppable } from '../../common/index.js'; -import { Entity } from '../entity.js'; -import type { Query } from './Query.js'; - -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -const systemIds = new WeakMap(); - -/** - * Represents a system in the Entity-Component-System (ECS) architecture. - * A system operates on entities that contain specific components. - * Systems are responsible for updating the state of entities. - */ -export abstract class System implements Stoppable { - /** - * The unique id of the system. - */ - static get id(): symbol { - let id = systemIds.get(this); - - if (!id) { - id = Symbol(this.name); - systemIds.set(this, id); - } - - return id; - } - /** - * The name of the system. - */ - public name: string; - - /** - * The components that this system operates on. - */ - public query: Query; - - /** - * Indicates whether the system is enabled. - */ - public isEnabled: boolean = true; - - /** - * Creates a new System instance. - * @param name - The name of the system. Defaults to '[anonymous system]'. - * @param query - The components that this system operates on. - */ - constructor(query: Query, name?: string) { - this.name = name ?? '[anonymous system]'; - this.query = query; - } - - /** - * Runs the system on the provided entities. - * @param entities - The entities to run the system on. - */ - public runSystem(entities: Entity[]): void { - if (!this.isEnabled) { - return; - } - - const modifiedEntities = this.beforeAll(entities); - - for (const entity of modifiedEntities) { - const shouldEarlyExit = this.run(entity); - - if (shouldEarlyExit) { - break; - } - } - } - - /** - * Abstract method to run the system on a single entity. - * Must be implemented by subclasses. - * @param entity - The entity to run the system on. - * @return void | boolean - Returns void or a boolean indicating whether to exit early. - */ - public abstract run(entity: Entity): void | boolean; - - /** - * Hook method that is called before running the system on all entities. - * Can be overridden by subclasses to modify the entities before processing. - * @param entities - The entities to be processed. - * @returns The modified entities. - */ - public beforeAll(entities: Entity[]): Entity[] { - return entities; - } - - /** - * Stops the system. This method can be overridden by subclasses. - */ - public stop(): void { - return; - } -} - -export type SystemCtor = { - readonly id: symbol; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - new (...args: any[]): T; -}; diff --git a/src/ecs/types/index.ts b/src/ecs/types/index.ts deleted file mode 100644 index 501701b3..00000000 --- a/src/ecs/types/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './Component.js'; -export * from './System.js'; -export * from './Query.js'; diff --git a/src/ecs/utilities/create-world.ts b/src/ecs/utilities/create-world.ts deleted file mode 100644 index 4a16f00f..00000000 --- a/src/ecs/utilities/create-world.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Game } from '../game.js'; -import { World } from '../world.js'; - -/** - * Creates a new world with the specified name and registers it with the game. - * - * @param name - The name of the world to create. - * @param game - The game instance to register the world with. - * @returns The new ECS world instance. - */ -export function createWorld(game: Game, name?: string): World { - const world = new World(name); - game.registerWorld(world); - - return world; -} diff --git a/src/ecs/utilities/index.ts b/src/ecs/utilities/index.ts deleted file mode 100644 index ab2fe013..00000000 --- a/src/ecs/utilities/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './create-world.js'; -export * from './register-camera.js'; -export * from './register-inputs.js'; diff --git a/src/ecs/utilities/register-camera.ts b/src/ecs/utilities/register-camera.ts deleted file mode 100644 index 418f5b2d..00000000 --- a/src/ecs/utilities/register-camera.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { PositionComponent, Time } from '../../common/index.js'; -import { Vector2 } from '../../math/index.js'; -import { - CameraComponent, - CameraComponentOptions, - CameraSystem, -} from '../../rendering/index.js'; -import type { Entity } from '../entity.js'; -import { World } from '../world.js'; - -export const registerCamera = ( - world: World, - time: Time, - cameraOptions: Partial = {}, - entityPosition: Vector2 = Vector2.zero, - entityName: string = 'camera', -): Entity => { - const cameraEntity = world.buildAndAddEntity( - [ - new CameraComponent(cameraOptions), - new PositionComponent(entityPosition.x, entityPosition.y), - ], - { name: entityName }, - ); - - world.addSystem(new CameraSystem(time)); - - return cameraEntity; -}; diff --git a/src/ecs/utilities/register-inputs.ts b/src/ecs/utilities/register-inputs.ts deleted file mode 100644 index 4a58ecfb..00000000 --- a/src/ecs/utilities/register-inputs.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Time } from '../../common/index.js'; -import { - Axis1dAction, - Axis2dAction, - HoldAction, - InputsComponent, - ResetInputSystem, - TriggerAction, - UpdateInputSystem, -} from '../../input/index.js'; -import { InputManager } from '../../input/input-manager.js'; -import { systemRegistrationPositions } from '../constants/index.js'; -import type { Entity } from '../entity.js'; -import { World } from '../world.js'; - -export const registerInputs = ( - world: World, - time: Time, - options: { - entityName?: string; - triggerActions?: TriggerAction[]; - axis1dActions?: Axis1dAction[]; - axis2dActions?: Axis2dAction[]; - holdActions?: HoldAction[]; - } = {}, -): { - inputsEntity: Entity; - inputsManager: InputManager; -} => { - const inputsManager = new InputManager(); - const inputsComponent = new InputsComponent(inputsManager); - - const { - entityName = 'inputs', - triggerActions = [], - axis1dActions = [], - axis2dActions = [], - holdActions = [], - } = options; - - const inputsEntity = world.buildAndAddEntity([inputsComponent], { - name: entityName, - }); - - inputsManager.addTriggerActions(...triggerActions); - inputsManager.addAxis1dActions(...axis1dActions); - inputsManager.addAxis2dActions(...axis2dActions); - inputsManager.addHoldActions(...holdActions); - - world.addSystem( - new UpdateInputSystem(time), - systemRegistrationPositions.early, - ); - world.addSystem(new ResetInputSystem(), systemRegistrationPositions.late); - - return { - inputsEntity, - inputsManager, - }; -}; diff --git a/src/ecs/world.test.ts b/src/ecs/world.test.ts deleted file mode 100644 index 68c2fbb1..00000000 --- a/src/ecs/world.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { World } from './world'; -import { Entity } from './entity'; -import { Component, System } from './types'; - -describe('World', () => { - let world: World; - - const runMock = vi.fn(); - const stopMock = vi.fn(); - - // Mock System - class MockSystem extends System { - public run(entity: Entity): void { - runMock(entity); - } - - public stop(): void { - stopMock(); - } - } - - class MockComponent1 extends Component {} - class MockComponent2 extends Component {} - - beforeEach(() => { - world = new World('test-world'); - }); - - it('should build and add an entity', () => { - const entity = world.buildAndAddEntity([new MockComponent1()]); - - expect(entity.getComponent(MockComponent1)).not.toBeNull(); - }); - - it('should get an entity by its id', () => { - const entity = world.buildAndAddEntity([new MockComponent1()]); - const retrievedEntity = world.getEntityById(entity.id); - const nonExistingEntity = world.getEntityById(999); - expect(entity).toEqual(retrievedEntity); - expect(nonExistingEntity).toBe(null); - }); - - it('should call runSystem on each system during update with enabled entities', () => { - const system = new MockSystem([MockComponent1]); - - world.addSystem(system); - - const entity1 = world.buildAndAddEntity([new MockComponent1()]); - const entity2 = world.buildAndAddEntity([new MockComponent1()]); - entity2.enabled = false; - - world.update(); - - // Only enabled entities should be passed - expect(runMock).toHaveBeenCalledWith(entity1); - expect(runMock).not.toHaveBeenCalledWith(entity2); - }); - - it('should call runSystem on each system during update with matching entities', () => { - const system = new MockSystem([MockComponent1]); - - const entity1 = world.buildAndAddEntity([new MockComponent1()]); - const entity2 = world.buildAndAddEntity([new MockComponent2()]); - - world.addSystem(system); - - world.update(); - - // Only enabled entities should be passed - expect(runMock).toHaveBeenCalledWith(entity1); - expect(runMock).not.toHaveBeenCalledWith(entity2); - }); - - it('should call stop on all systems when stopped', () => { - const system1 = new MockSystem([MockComponent1]); - const system2 = new MockSystem([MockComponent1]); - world.addSystems(system1, system2); - - world.stop(); - - expect(stopMock).toHaveBeenCalledTimes(2); - }); - - it('should remove onEntitiesChanged callbacks', () => { - const callback = vi.fn(); - world.onEntitiesChanged(callback); - world.removeOnEntitiesChangedCallback(callback); - - world.buildAndAddEntity([]); - - expect(callback).not.toHaveBeenCalled(); - }); - - it('should return all entities matching the query', () => { - const entity1 = world.buildAndAddEntity([new MockComponent1()]); - const entity2 = world.buildAndAddEntity([ - new MockComponent1(), - new MockComponent2(), - ]); - const entity3 = world.buildAndAddEntity([new MockComponent2()]); - - // Query for entities with mock1Component - const result = world.queryEntities([MockComponent1]); - expect(result.has(entity1)).toBe(true); - expect(result.has(entity2)).toBe(true); - expect(result.has(entity3)).toBe(false); - expect(result.size).toBe(2); - - // Query for entities with both mock1Component and mock2Component - const resultBoth = world.queryEntities([MockComponent1, MockComponent2]); - expect(resultBoth.has(entity2)).toBe(true); - expect(resultBoth.has(entity1)).toBe(false); - expect(resultBoth.has(entity3)).toBe(false); - expect(resultBoth.size).toBe(1); - - // Query for entities with mock2Component - const result2 = world.queryEntities([MockComponent2]); - expect(result2.has(entity2)).toBe(true); - expect(result2.has(entity3)).toBe(true); - expect(result2.has(entity1)).toBe(false); - expect(result2.size).toBe(2); - }); - - it('should return the first entity matching the query in queryEntity', () => { - const entity1 = world.buildAndAddEntity([new MockComponent1()]); - const entity2 = world.buildAndAddEntity([ - new MockComponent1(), - new MockComponent2(), - ]); - - world.buildAndAddEntity([new MockComponent2()]); - - // Should return entity1, as it is the first matching entity - const result = world.queryEntity([MockComponent1]); - expect(result).toBe(entity1); - - // Should return entity2, as it is the first entity with both components - const resultBoth = world.queryEntity([MockComponent1, MockComponent2]); - expect(resultBoth).toBe(entity2); - - // Should return entity3, as it is the first entity with mock2Component only - const result2 = world.queryEntity([MockComponent2]); - expect(result2).toBe(entity2); // entity2 is added before entity3 and has mock2Component - }); - - it('should return the first entity matching the query in queryEntityRequired', () => { - const entity1 = world.buildAndAddEntity([new MockComponent1()]); - const entity2 = world.buildAndAddEntity([ - new MockComponent1(), - new MockComponent2(), - ]); - - // Should return entity1, as it is the first matching entity - const result = world.queryEntityRequired([MockComponent1]); - expect(result).toBe(entity1); - - // Should return entity2, as it is the first entity with both components - const resultBoth = world.queryEntityRequired([ - MockComponent1, - MockComponent2, - ]); - expect(resultBoth).toBe(entity2); - }); - - it('should throw an error if no entity matches the query in queryEntityRequired', () => { - world.buildAndAddEntity([new MockComponent1()]); - - expect(() => world.queryEntityRequired([MockComponent2])).toThrowError( - 'No entity found matching the query: MockComponent2', - ); - }); - - it('should build and add an entity with parent parameter', () => { - const parent = world.buildAndAddEntity([new MockComponent1()]); - const child = world.buildAndAddEntity([new MockComponent2()], { - parent, - }); - - expect(child.parent).toBe(parent); - expect(parent.children.has(child)).toBe(true); - }); - - it('should build and add an entity with enabled=false', () => { - const entity = world.buildAndAddEntity([new MockComponent1()], { - enabled: false, - }); - - expect(entity.enabled).toBe(false); - expect(world.getEntityById(entity.id)).toBe(entity); - }); - - it('should build and add an entity with both enabled and parent parameters', () => { - const parent = world.buildAndAddEntity([new MockComponent1()]); - const child = world.buildAndAddEntity([new MockComponent2()], { - enabled: false, - parent, - }); - - expect(child.enabled).toBe(false); - expect(child.parent).toBe(parent); - expect(parent.children.has(child)).toBe(true); - }); - - it('should remove an entity and its children recursively', () => { - const parent = world.buildAndAddEntity([new MockComponent1()]); - const child = world.buildAndAddEntity([new MockComponent1()], { - parent, - }); - const grandchild = world.buildAndAddEntity([new MockComponent1()], { - parent: child, - }); - - expect(world.getEntityById(parent.id)).toBe(parent); - expect(world.getEntityById(child.id)).toBe(child); - expect(world.getEntityById(grandchild.id)).toBe(grandchild); - - world.removeEntity(parent); - - expect(world.getEntityById(parent.id)).toBeNull(); - expect(world.getEntityById(child.id)).toBeNull(); - expect(world.getEntityById(grandchild.id)).toBeNull(); - }); - - it('should remove entity from systems when removed', () => { - const system = new MockSystem([MockComponent1]); - world.addSystem(system); - - runMock.mockClear(); - const entity = world.buildAndAddEntity([new MockComponent1()]); - - // ensure system sees the entity initially - world.update(); - expect(runMock).toHaveBeenCalledWith(entity); - - runMock.mockClear(); - world.removeEntity(entity); - - // after removal the system should no longer receive the entity - world.update(); - expect(runMock).not.toHaveBeenCalled(); - }); - - it('should raise onEntitiesChanged for each removed entity during recursive removal', () => { - const callback = vi.fn(); - world.onEntitiesChanged(callback); - - const parent = world.buildAndAddEntity([new MockComponent1()]); - world.buildAndAddEntity([new MockComponent1()], { - parent, - }); - - callback.mockClear(); - - world.removeEntity(parent); - - // removeEntity is called for child then parent, so the callback should be invoked twice - expect(callback).toHaveBeenCalledTimes(2); - }); - - it('should not throw when adding the same system instance twice and should warn', () => { - const system = new MockSystem([MockComponent1]); - - world.addSystem(system); - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - expect(() => world.addSystem(system)).not.toThrow(); - expect(warnSpy).toHaveBeenCalled(); - warnSpy.mockRestore(); - }); -}); diff --git a/src/ecs/world.ts b/src/ecs/world.ts deleted file mode 100644 index 162cc642..00000000 --- a/src/ecs/world.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { type Stoppable, type Updatable } from '../common/index.js'; -import { isString } from '../utilities/index.js'; -import { systemRegistrationPositions } from './constants/index.js'; -import { Entity, type EntityOptions } from './entity.js'; -import type { Component, Query, System } from './types/index.js'; - -interface SystemOrderPair { - system: System; - order: number; -} - -/** - * Represents the world in the Entity-Component-System (ECS) architecture. - * The world manages entities and systems, and updates systems with the entities they operate on. - */ -export class World implements Updatable, Stoppable { - /** - * The name of the world. - */ - public readonly name: string; - - /** - * A map of systems to the entities they operate on. - */ - private readonly _systemEntities = new Map>(); - - /** - * A temporary array to hold enabled entities for system updates. - */ - private readonly _enabledEntities = new Array(); - - /** - * Callbacks to be invoked when systems change. - */ - private readonly _onSystemsChangedCallbacks = new Set< - (systems: Set) => void - >(); - - /** - * Callbacks to be invoked when entities change. - */ - private readonly _onEntitiesChangedCallbacks = new Set< - (entities: Map) => void - >(); - - /** - * The set of systems in the world. - */ - private readonly _systems = new Set(); - - /** - * The set of entities in the world. - */ - private readonly _entities = new Map(); - - /** - * Creates a new World instance. - * @param name - The name of the world. - */ - constructor(name?: string) { - this.name = name ?? '[anonymous world]'; - } - - /** - * Updates all systems in the world. - */ - public update(): void { - for (const { system } of this._systems) { - const entities = this._systemEntities.get(system); - - if (!entities) { - throw new Error(`Unable to get entities for system ${system.name}`); - } - - this._enabledEntities.length = 0; - - for (const entity of entities) { - if (entity.enabled) { - this._enabledEntities.push(entity); - } - } - - system.runSystem(this._enabledEntities); - } - } - - /** - * Gets the entities in the world with the given entityId. - * @param entityId - The Id of the entity you would like to get. - * @returns The entity with the matching id, or null if no entity with that id exists. - */ - public getEntityById(entityId: number): Entity | null { - return this._entities.get(entityId) ?? null; - } - - /** - * Gets all entities in the world that match the given query. - * @returns An array of all entities. - */ - public queryEntities(componentSymbols: Query): Set { - const entities = new Set(); - - for (const entity of this._entities.values()) { - if (entity.containsAllComponents(componentSymbols)) { - entities.add(entity); - } - } - - return entities; - } - - /** - * Gets the first entity that matches the given query. - * @param query - The query to match against the entities. - * @returns The first matching entity, or null if no entity matches. - */ - public queryEntity(query: Query): Entity | null { - for (const entity of this._entities.values()) { - if (entity.containsAllComponents(query)) { - return entity; - } - } - - return null; - } - - /** - * Gets the first entity that matches the given query, or throws an error if no entity matches. - * @param query - The query to match against the entities. - * @returns The first matching entity. - * @throws An error if no entity matches the query. - */ - public queryEntityRequired(query: Query): Entity { - const entity = this.queryEntity(query); - - if (entity === null) { - throw new Error( - `No entity found matching the query: ${query.map((s) => s.id.description).join(', ')}`, - ); - } - - return entity; - } - - /** - * Registers a callback to be invoked when systems change. - * @param callback - The callback to register. - */ - public onSystemsChanged( - callback: (systems: Set) => void, - ): void { - this._onSystemsChangedCallbacks.add(callback); - } - - /** - * Registers a callback to be invoked when entities change. - * @param callback - The callback to register. - */ - public onEntitiesChanged( - callback: (entities: Map) => void, - ): void { - this._onEntitiesChangedCallbacks.add(callback); - } - - /** - * Removes a callback for systems changed events. - * @param callback - The callback to remove. - */ - public removeOnSystemsChangedCallback( - callback: (systems: Set) => void, - ): void { - this._onSystemsChangedCallbacks.delete(callback); - } - - /** - * Removes a callback for entities changed events. - * @param callback - The callback to remove. - */ - public removeOnEntitiesChangedCallback( - callback: (entities: Map) => void, - ): void { - this._onEntitiesChangedCallbacks.delete(callback); - } - - /** - * Raises the systems changed event. - */ - public raiseOnSystemsChangedEvent(): void { - for (const callback of this._onSystemsChangedCallbacks) { - callback(this._systems); - } - } - - /** - * Raises the entities changed event. - */ - public raiseOnEntitiesChangedEvent(): void { - for (const callback of this._onEntitiesChangedCallbacks) { - callback(this._entities); - } - } - - /** - * Adds a system to the world. - * @param system - The system to add. - * @returns The world instance. - */ - public addSystem( - system: System, - order: number = systemRegistrationPositions.normal, - ): this { - // Check if the system instance is already added - for (const { system: existingSystem } of this._systems) { - if (existingSystem === system) { - console.warn( - `System instance "${system.name}" is already added to world "${this.name}". Skipping.`, - ); - - return this; - } - } - - this._systems.add({ system, order }); - // Reorder the set by 'order' after adding - const sorted = Array.from(this._systems).sort((a, b) => a.order - b.order); - this._systems.clear(); - - for (const pair of sorted) { - this._systems.add(pair); - } - - this._systemEntities.set(system, this.queryEntities(system.query)); - - this.raiseOnSystemsChangedEvent(); - - return this; - } - - /** - * Adds multiple systems to the world. - * @param systems - The systems to add. - * @returns The world instance. - */ - public addSystems(order: number, ...systems: System[]): this; - public addSystems(...systems: System[]): this; - public addSystems(...args: [number, ...System[]] | System[]): this { - let order: number; - let systems: System[]; - - if (typeof args[0] === 'number') { - order = args[0]; - systems = args.slice(1) as System[]; - } else { - order = systemRegistrationPositions.normal; - systems = args as System[]; - } - - for (const system of systems) { - this.addSystem(system, order); - } - - return this; - } - - /** - * Removes a system from the world. - * @param system - The system to remove. - * @returns The world instance. - */ - public removeSystem(systemToRemove: string | System): this { - for (const systemOrderPair of this._systems) { - const { system } = systemOrderPair; - const isNameMatch = - isString(systemToRemove) && system.name === systemToRemove; - const isSystemMatch = system === systemToRemove; - - if (isNameMatch || isSystemMatch) { - this._systems.delete(systemOrderPair); - - this._systemEntities.delete(system); - this.raiseOnSystemsChangedEvent(); - } - } - - return this; - } - - /** - * Adds an entity to the world. - * @param entity - The entity to add. - * @returns The world instance. - */ - public addEntity(entity: Entity): this { - this._entities.set(entity.id, entity); - - this.updateSystemEntities(entity); - this.raiseOnEntitiesChangedEvent(); - - return this; - } - - /** - * Updates the entities in the systems based on the components of the given entity. - * @param entity - The entity to update. - */ - public updateSystemEntities(entity: Entity): void { - for (const { system } of this._systems) { - const entities = this._systemEntities.get(system); - - if (!entities) { - throw new Error(`Unable to get entities for system ${system.name}`); - } - - if (entity.containsAllComponents(system.query)) { - entities.add(entity); - } else { - entities.delete(entity); - } - } - } - - /** - * Builds and adds an entity to the world. - * @param components - The components to add to the entity. - * @param options - Optional configuration for the entity. - * @returns The created entity. - */ - public buildAndAddEntity( - components: Component[], - options: Partial = {}, - ): Entity { - const entity = new Entity(this, components, options); - this.addEntity(entity); - - return entity; - } - - /** - * Adds multiple entities to the world. - * @param entities - The entities to add. - * @returns The world instance. - */ - public addEntities(entities: Entity[]): this { - for (const entity of entities) { - this.addEntity(entity); - } - - this.raiseOnEntitiesChangedEvent(); - - return this; - } - - /** - * Removes an entity from the world. - * @param entity - The entity to remove. - * @returns The world instance. - */ - public removeEntity(entity: Entity): this { - for (const childEntity of entity.children) { - this.removeEntity(childEntity); - } - - this._entities.delete(entity.id); - entity.onRemovedFromWorld.raise(); - - for (const entities of this._systemEntities.values()) { - entities.delete(entity); - } - - this.raiseOnEntitiesChangedEvent(); - - return this; - } - - /** - * Stops all systems in the world. - */ - public stop(): void { - for (const { system } of this._systems) { - system.stop(); - } - - this._entities.clear(); - } - - /** - * Gets the number of entities in the world. - * @returns The number of entities. - */ - get entityCount(): number { - return this._entities.size; - } -} diff --git a/src/index.ts b/src/index.ts index cc82c7ea..a6cd05c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ export * from './particles/index.js'; export * from './asset-loading/index.js'; export * from './audio/index.js'; export * from './common/index.js'; -export * from './ecs/index.js'; +export * from './new-ecs/index.js'; export * from './events/index.js'; export * from './input/index.js'; export * from './lifecycle/index.js'; @@ -12,5 +12,4 @@ export * from './physics/index.js'; export * from './rendering/index.js'; export * from './timer/index.js'; export * from './utilities/index.js'; -export * from './pooling/index.js'; export * from './finite-state-machine/index.js'; diff --git a/src/input/components/inputs-component.ts b/src/input/components/inputs-component.ts index c71e58de..41c61b4e 100644 --- a/src/input/components/inputs-component.ts +++ b/src/input/components/inputs-component.ts @@ -1,19 +1,11 @@ -import { Component } from '../../ecs/index.js'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; import { InputManager } from '../input-manager.js'; /** - * Component that provides access to the InputManager for an entity. + * ECS-style component interface for inputs. */ -export class InputsComponent extends Component { - /** The InputManager instance. */ - public readonly inputManager: InputManager; - - /** Creates a new InputsComponent. - * @param inputManager - The InputManager instance to associate with this component. - */ - constructor(inputManager: InputManager) { - super(); - - this.inputManager = inputManager; - } +export interface InputsEcsComponent { + inputManager: InputManager; } + +export const inputsId = createComponentId('inputs'); diff --git a/src/input/index.ts b/src/input/index.ts index e40cb80a..3788e22d 100644 --- a/src/input/index.ts +++ b/src/input/index.ts @@ -6,3 +6,4 @@ export * from './input-sources/index.js'; export * from './input-manager.js'; export * from './mouse/index.js'; export * from './keyboard/index.js'; +export * from './register-inputs.js'; diff --git a/src/input/mouse/input-sources/mouse-input-source.ts b/src/input/mouse/input-sources/mouse-input-source.ts index 408d02b9..6429af04 100644 --- a/src/input/mouse/input-sources/mouse-input-source.ts +++ b/src/input/mouse/input-sources/mouse-input-source.ts @@ -1,4 +1,3 @@ -import { Game } from '../../../ecs/index.js'; import { Vector2 } from '../../../math/index.js'; import { buttonMoments, @@ -42,7 +41,7 @@ export class MouseInputSource public readonly holdBindings = new Set(); private readonly _inputManager: InputManager; - private readonly _game: Game; + private readonly _container: HTMLElement; private readonly _containerBoundingClientRect: DOMRect; private readonly _mouseButtonPresses = new Set(); @@ -54,17 +53,17 @@ export class MouseInputSource /** Constructs a new MouseInputSource. * @param inputManager - The input manager to register with. - * @param game - The game instance. + * @param container - The HTML container element to attach mouse events to. */ - constructor(inputManager: InputManager, game: Game) { + constructor(inputManager: InputManager, container: HTMLElement) { this._inputManager = inputManager; - this._game = game; - this._containerBoundingClientRect = game.container.getBoundingClientRect(); + this._container = container; + this._containerBoundingClientRect = container.getBoundingClientRect(); - game.container.addEventListener('mousedown', this._onMouseDownHandler); - game.container.addEventListener('mouseup', this._onMouseUpHandler); - game.container.addEventListener('wheel', this._onWheelHandler); - game.container.addEventListener('mousemove', this._onMouseMoveHandler); + container.addEventListener('mousedown', this._onMouseDownHandler); + container.addEventListener('mouseup', this._onMouseUpHandler); + container.addEventListener('wheel', this._onWheelHandler); + container.addEventListener('mousemove', this._onMouseMoveHandler); this._inputManager.addResettable(this); @@ -82,16 +81,10 @@ export class MouseInputSource } public stop(): void { - this._game.container.removeEventListener( - 'mousedown', - this._onMouseDownHandler, - ); - this._game.container.removeEventListener('mouseup', this._onMouseUpHandler); - this._game.container.removeEventListener('wheel', this._onWheelHandler); - this._game.container.removeEventListener( - 'mousemove', - this._onMouseMoveHandler, - ); + this._container.removeEventListener('mousedown', this._onMouseDownHandler); + this._container.removeEventListener('mouseup', this._onMouseUpHandler); + this._container.removeEventListener('wheel', this._onWheelHandler); + this._container.removeEventListener('mousemove', this._onMouseMoveHandler); this._inputManager.removeResettable(this); } diff --git a/src/input/register-inputs.ts b/src/input/register-inputs.ts new file mode 100644 index 00000000..45242969 --- /dev/null +++ b/src/input/register-inputs.ts @@ -0,0 +1,53 @@ +import { Time } from '../common/index.js'; +import { EcsWorld, SystemRegistrationOrder } from '../new-ecs/index.js'; +import { + Axis1dAction, + Axis2dAction, + HoldAction, + TriggerAction, +} from './actions/index.js'; +import { inputsId } from './components/index.js'; +import { InputManager } from './input-manager.js'; +import { + createResetInputsEcsSystem, + createUpdateInputEcsSystem, +} from './systems/index.js'; + +export const registerInputs = ( + world: EcsWorld, + time: Time, + options: { + triggerActions?: TriggerAction[]; + axis1dActions?: Axis1dAction[]; + axis2dActions?: Axis2dAction[]; + holdActions?: HoldAction[]; + } = {}, +): InputManager => { + const inputManager = new InputManager(); + + const { + triggerActions = [], + axis1dActions = [], + axis2dActions = [], + holdActions = [], + } = options; + + const inputsEntity = world.createEntity(); + + world.addComponent(inputsEntity, inputsId, { + inputManager, + }); + + inputManager.addTriggerActions(...triggerActions); + inputManager.addAxis1dActions(...axis1dActions); + inputManager.addAxis2dActions(...axis2dActions); + inputManager.addHoldActions(...holdActions); + + world.addSystem( + createUpdateInputEcsSystem(time), + SystemRegistrationOrder.early, + ); + world.addSystem(createResetInputsEcsSystem(), SystemRegistrationOrder.late); + + return inputManager; +}; diff --git a/src/input/systems/reset-inputs-system.ts b/src/input/systems/reset-inputs-system.ts index 61414885..304eb861 100644 --- a/src/input/systems/reset-inputs-system.ts +++ b/src/input/systems/reset-inputs-system.ts @@ -1,16 +1,18 @@ -import { Entity, System } from '../../ecs/index.js'; -import { InputsComponent } from '../components/index.js'; - -/** A system that resets all input states at the end of each frame. */ -export class ResetInputSystem extends System { - /** Constructs a new ResetInputSystem. */ - constructor() { - super([InputsComponent], 'reset-inputs'); - } - - public run(entity: Entity): void { - const inputsComponent = entity.getComponentRequired(InputsComponent); +import { EcsSystem } from '../../new-ecs/ecs-system.js'; +import { + InputsEcsComponent, + inputsId, +} from '../components/inputs-component.js'; +/** + * Creates an ECS system to handle resetting inputs. + */ +export const createResetInputsEcsSystem = (): EcsSystem< + [InputsEcsComponent] +> => ({ + query: [inputsId], + run: (result) => { + const [inputsComponent] = result.components; inputsComponent.inputManager.reset(); - } -} + }, +}); diff --git a/src/input/systems/update-inputs-system.ts b/src/input/systems/update-inputs-system.ts index 45e3cc93..b94be18e 100644 --- a/src/input/systems/update-inputs-system.ts +++ b/src/input/systems/update-inputs-system.ts @@ -1,20 +1,19 @@ -import { Entity, System } from '../../ecs/index.js'; -import { Time } from '../../index.js'; -import { InputsComponent } from '../components/index.js'; +import { Time } from '../../common'; +import { EcsSystem } from '../../new-ecs/ecs-system.js'; +import { + InputsEcsComponent, + inputsId, +} from '../components/inputs-component.js'; -/** A system that updates all input states each frame. */ -export class UpdateInputSystem extends System { - private readonly _time: Time; - - /** Constructs a new UpdateInputSystem. */ - constructor(time: Time) { - super([InputsComponent], 'update-input'); - this._time = time; - } - - public run(entity: Entity): void { - const inputsComponent = entity.getComponentRequired(InputsComponent); - - inputsComponent.inputManager.update(this._time.deltaTimeInMilliseconds); - } -} +/** + * Creates an ECS system to handle updating inputs. + */ +export const createUpdateInputEcsSystem = ( + time: Time, +): EcsSystem<[InputsEcsComponent]> => ({ + query: [inputsId], + run: (result) => { + const [inputsComponent] = result.components; + inputsComponent.inputManager.update(time.deltaTimeInMilliseconds); + }, +}); diff --git a/src/lifecycle/components/lifetime-component.test.ts b/src/lifecycle/components/lifetime-component.test.ts deleted file mode 100644 index be9da1d4..00000000 --- a/src/lifecycle/components/lifetime-component.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { LifetimeComponent } from './lifetime-component'; - -describe('LifetimeComponent', () => { - it('should initialize with correct default values', () => { - // Arrange & Act - const component = new LifetimeComponent(5); - - expect(component.elapsedSeconds).toBe(0); - expect(component.durationSeconds).toBe(5); - expect(component.hasExpired).toBe(false); - }); - - it('should store the duration seconds value', () => { - // Arrange & Act - const component = new LifetimeComponent(10.5); - - // Assert - expect(component.durationSeconds).toBe(10.5); - }); - - it('should be a data-only component', () => { - // Arrange & Act - const component = new LifetimeComponent(5); - - // Assert - verify it's a simple data container - expect(component.elapsedSeconds).toBe(0); - expect(component.durationSeconds).toBe(5); - expect(component.hasExpired).toBe(false); - }); -}); diff --git a/src/lifecycle/components/lifetime-component.ts b/src/lifecycle/components/lifetime-component.ts index c154ff89..46e2e6e2 100644 --- a/src/lifecycle/components/lifetime-component.ts +++ b/src/lifecycle/components/lifetime-component.ts @@ -1,29 +1,12 @@ -import { Component } from '../../ecs/index.js'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; /** - * Component that tracks the elapsed time and duration of an entity's lifetime. - * This is a pure data component with no logic. + * ECS-style component interface for managing entity lifetime. */ -export class LifetimeComponent extends Component { - public elapsedSeconds: number; - public durationSeconds: number; - public hasExpired: boolean; - - /** - * Creates an instance of the LifetimeComponent. - * @param durationSeconds - The total duration of the entity's lifetime in seconds. - */ - constructor(durationSeconds: number) { - super(); - - this.elapsedSeconds = 0; - this.durationSeconds = durationSeconds; - this.hasExpired = false; - } - - public reset(durationSeconds: number = this.durationSeconds): void { - this.durationSeconds = durationSeconds; - this.elapsedSeconds = 0; - this.hasExpired = false; - } +export interface LifetimeEcsComponent { + elapsedSeconds: number; + durationSeconds: number; + hasExpired: boolean; } + +export const lifetimeId = createComponentId('lifetime'); diff --git a/src/lifecycle/strategies/index.ts b/src/lifecycle/strategies/index.ts index 9e9458eb..47315c22 100644 --- a/src/lifecycle/strategies/index.ts +++ b/src/lifecycle/strategies/index.ts @@ -1,2 +1 @@ export * from './remove-from-world-strategy-component.js'; -export * from './return-to-pool-strategy-component.js'; diff --git a/src/lifecycle/strategies/remove-from-world-strategy-component.ts b/src/lifecycle/strategies/remove-from-world-strategy-component.ts index ff96718f..fa0526f0 100644 --- a/src/lifecycle/strategies/remove-from-world-strategy-component.ts +++ b/src/lifecycle/strategies/remove-from-world-strategy-component.ts @@ -1,7 +1,5 @@ -import { Component } from '../../ecs/index.js'; +import { createTagId } from '../../new-ecs/ecs-component.js'; -/** - * Strategy component that marks an entity to be removed from the world when its lifetime expires. - * This is a pure data component with no logic - it acts as a marker/tag. - */ -export class RemoveFromWorldStrategyComponent extends Component {} +export const RemoveFromWorldLifetimeStrategyId = createTagId( + 'removeFromWorldLifetimeStrategy', +); diff --git a/src/lifecycle/strategies/return-to-pool-strategy-component.ts b/src/lifecycle/strategies/return-to-pool-strategy-component.ts deleted file mode 100644 index 51be09a3..00000000 --- a/src/lifecycle/strategies/return-to-pool-strategy-component.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Component } from '../../ecs/index.js'; -import type { ObjectPool } from '../../pooling/index.js'; - -/** - * Strategy component that marks an entity to be returned to a pool when its lifetime expires. - * This is a pure data component with no logic - it stores the pool reference. - */ -export class ReturnToPoolStrategyComponent< - T extends NonNullable, -> extends Component { - public pool: ObjectPool; - - /** - * Creates an instance of the ReturnToPoolStrategyComponent. - * @param pool - The object pool to return the entity to. - */ - constructor(pool: ObjectPool) { - super(); - - this.pool = pool; - } -} diff --git a/src/lifecycle/systems/index.ts b/src/lifecycle/systems/index.ts index dbaf5298..940a57b4 100644 --- a/src/lifecycle/systems/index.ts +++ b/src/lifecycle/systems/index.ts @@ -1,3 +1,2 @@ export * from './lifetime-tracking-system.js'; export * from './remove-from-world-lifecycle-system.js'; -export * from './return-to-pool-lifecycle-system.js'; diff --git a/src/lifecycle/systems/lifetime-tracking-system.test.ts b/src/lifecycle/systems/lifetime-tracking-system.test.ts index 43bf9a45..d8f8bb26 100644 --- a/src/lifecycle/systems/lifetime-tracking-system.test.ts +++ b/src/lifecycle/systems/lifetime-tracking-system.test.ts @@ -1,24 +1,33 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { LifetimeTrackingSystem } from './lifetime-tracking-system'; -import { World } from '../../ecs'; -import { LifetimeComponent } from '../components/lifetime-component'; +import { createLifetimeTrackingEcsSystem } from './lifetime-tracking-system'; +import { + LifetimeEcsComponent, + lifetimeId, +} from '../components/lifetime-component'; import { Time } from '../../common'; +import { EcsWorld } from '../../new-ecs'; describe('LifetimeTrackingSystem', () => { - let world: World; + let world: EcsWorld; let time: Time; beforeEach(() => { - world = new World('test'); + world = new EcsWorld(); time = new Time(); + world.addSystem(createLifetimeTrackingEcsSystem(time)); }); it('should update elapsed time each frame', () => { // Arrange - const lifetimeComponent = new LifetimeComponent(5); - world.buildAndAddEntity([lifetimeComponent]); - const system = new LifetimeTrackingSystem(time); - world.addSystem(system); + const lifetimeComponent: LifetimeEcsComponent = { + durationSeconds: 5, + elapsedSeconds: 0, + hasExpired: false, + }; + + const entity = world.createEntity(); + + world.addComponent(entity, lifetimeId, lifetimeComponent); // Act time.update(0.1); @@ -31,10 +40,15 @@ describe('LifetimeTrackingSystem', () => { it('should handle entities with elapsed time within duration', () => { // Arrange - const lifetimeComponent = new LifetimeComponent(5); - world.buildAndAddEntity([lifetimeComponent]); - const system = new LifetimeTrackingSystem(time); - world.addSystem(system); + const lifetimeComponent: LifetimeEcsComponent = { + durationSeconds: 5, + elapsedSeconds: 0, + hasExpired: false, + }; + + const entity = world.createEntity(); + + world.addComponent(entity, lifetimeId, lifetimeComponent); // Act lifetimeComponent.elapsedSeconds = 3; // Set elapsed to less than duration @@ -48,10 +62,15 @@ describe('LifetimeTrackingSystem', () => { it('should set hasExpired to true when elapsed time equals duration', () => { // Arrange - const lifetimeComponent = new LifetimeComponent(5); - world.buildAndAddEntity([lifetimeComponent]); - const system = new LifetimeTrackingSystem(time); - world.addSystem(system); + const lifetimeComponent: LifetimeEcsComponent = { + durationSeconds: 5, + elapsedSeconds: 0, + hasExpired: false, + }; + + const entity = world.createEntity(); + + world.addComponent(entity, lifetimeId, lifetimeComponent); // Act lifetimeComponent.elapsedSeconds = 5; @@ -65,10 +84,15 @@ describe('LifetimeTrackingSystem', () => { it('should set hasExpired to true when elapsed time exceeds duration', () => { // Arrange - const lifetimeComponent = new LifetimeComponent(5); - world.buildAndAddEntity([lifetimeComponent]); - const system = new LifetimeTrackingSystem(time); - world.addSystem(system); + const lifetimeComponent: LifetimeEcsComponent = { + durationSeconds: 5, + elapsedSeconds: 0, + hasExpired: false, + }; + + const entity = world.createEntity(); + + world.addComponent(entity, lifetimeId, lifetimeComponent); // Act lifetimeComponent.elapsedSeconds = 5.1; @@ -82,11 +106,15 @@ describe('LifetimeTrackingSystem', () => { it('should not remove entity from world - only track lifetime', () => { // Arrange - const lifetimeComponent = new LifetimeComponent(5); - world.buildAndAddEntity([lifetimeComponent]); - const system = new LifetimeTrackingSystem(time); - world.addSystem(system); + const lifetimeComponent: LifetimeEcsComponent = { + durationSeconds: 5, + elapsedSeconds: 0, + hasExpired: false, + }; + + const entity = world.createEntity(); + world.addComponent(entity, lifetimeId, lifetimeComponent); // Act lifetimeComponent.elapsedSeconds = 5; time.update(0.1); @@ -94,6 +122,5 @@ describe('LifetimeTrackingSystem', () => { // Assert expect(lifetimeComponent.hasExpired).toBe(true); - expect(world.entityCount).toBe(1); // Entity should still be in world }); }); diff --git a/src/lifecycle/systems/lifetime-tracking-system.ts b/src/lifecycle/systems/lifetime-tracking-system.ts index 82eaa0d0..9d8dde6c 100644 --- a/src/lifecycle/systems/lifetime-tracking-system.ts +++ b/src/lifecycle/systems/lifetime-tracking-system.ts @@ -1,35 +1,24 @@ -import { Entity, System } from '../../ecs/index.js'; -import { Time } from '../../common/index.js'; -import { LifetimeComponent } from '../components/lifetime-component.js'; +import { Time } from '../../common'; +import { EcsSystem } from '../../new-ecs/ecs-system.js'; +import { + LifetimeEcsComponent, + lifetimeId, +} from '../components/lifetime-component.js'; /** - * System that tracks entity lifetime and updates the hasExpired flag. - * This system only updates the elapsed time and expiration status - it does not remove entities. - * Removal or other lifecycle actions are handled by separate strategy-specific systems. + * Creates an ECS system to handle tracking lifetimes. */ -export class LifetimeTrackingSystem extends System { - private readonly _time: Time; +export const createLifetimeTrackingEcsSystem = ( + time: Time, +): EcsSystem<[LifetimeEcsComponent]> => ({ + query: [lifetimeId], + run: (result) => { + const [lifetimeComponent] = result.components; - /** - * Creates an instance of LifetimeTrackingSystem. - * @param time - The Time instance. - */ - constructor(time: Time) { - super([LifetimeComponent], 'lifetime-tracking'); - this._time = time; - } - - /** - * Updates the elapsed time for an entity and sets the hasExpired flag if needed. - * @param entity - The entity to update. - */ - public run(entity: Entity): void { - const lifetimeComponent = entity.getComponentRequired(LifetimeComponent); - - lifetimeComponent.elapsedSeconds += this._time.deltaTimeInSeconds; + lifetimeComponent.elapsedSeconds += time.deltaTimeInSeconds; if (lifetimeComponent.elapsedSeconds >= lifetimeComponent.durationSeconds) { lifetimeComponent.hasExpired = true; } - } -} + }, +}); diff --git a/src/lifecycle/systems/remove-from-world-lifecycle-system.test.ts b/src/lifecycle/systems/remove-from-world-lifecycle-system.test.ts index b88ee29e..58c0c6d6 100644 --- a/src/lifecycle/systems/remove-from-world-lifecycle-system.test.ts +++ b/src/lifecycle/systems/remove-from-world-lifecycle-system.test.ts @@ -1,99 +1,96 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { RemoveFromWorldLifecycleSystem } from './remove-from-world-lifecycle-system'; -import { World } from '../../ecs'; -import { LifetimeComponent } from '../components/lifetime-component'; -import { RemoveFromWorldStrategyComponent } from '../strategies/remove-from-world-strategy-component'; +import { createRemoveFromWorldEcsSystem } from './remove-from-world-lifecycle-system'; +import { lifetimeId } from '../components/lifetime-component'; +import { RemoveFromWorldLifetimeStrategyId } from '../strategies/remove-from-world-strategy-component'; import { Time } from '../../common'; +import { EcsWorld } from '../../new-ecs'; describe('RemoveFromWorldLifecycleSystem', () => { - let world: World; + let world: EcsWorld; let time: Time; beforeEach(() => { - world = new World('test'); + world = new EcsWorld(); time = new Time(); + world.addSystem(createRemoveFromWorldEcsSystem()); }); it('should not remove entity when hasExpired is false', () => { // Arrange - const lifetimeComponent = new LifetimeComponent(5); - const strategyComponent = new RemoveFromWorldStrategyComponent(); - world.buildAndAddEntity([lifetimeComponent, strategyComponent]); - const system = new RemoveFromWorldLifecycleSystem(world); - world.addSystem(system); + const entity = world.createEntity(); - // Act - time.update(0.1); - world.update(); + world.addComponent(entity, lifetimeId, { + durationSeconds: 5, + elapsedSeconds: 0, + hasExpired: false, + }); - // Assert - expect(world.entityCount).toBe(1); - }); + world.addTag(entity, RemoveFromWorldLifetimeStrategyId); - it('should remove entity when hasExpired is true', () => { - // Arrange - const lifetimeComponent = new LifetimeComponent(5); - lifetimeComponent.hasExpired = true; - const strategyComponent = new RemoveFromWorldStrategyComponent(); - world.buildAndAddEntity([lifetimeComponent, strategyComponent]); - const system = new RemoveFromWorldLifecycleSystem(world); - world.addSystem(system); - - // Act - time.update(0.1); world.update(); + const entitiesMatchingQuery: number[] = []; + + world.queryEntities( + [lifetimeId, RemoveFromWorldLifetimeStrategyId], + entitiesMatchingQuery, + ); + // Assert - expect(world.entityCount).toBe(0); + expect(entitiesMatchingQuery.length).toBe(1); }); - it('should only process entities with both LifetimeComponent and RemoveFromWorldStrategyComponent', () => { + it('should remove entity when hasExpired is true', () => { // Arrange - const lifetimeOnlyComponent = new LifetimeComponent(5); - lifetimeOnlyComponent.hasExpired = true; - world.buildAndAddEntity([lifetimeOnlyComponent]); + const entity = world.createEntity(); - const lifetimeComponent = new LifetimeComponent(5); - lifetimeComponent.hasExpired = true; - const strategyComponent = new RemoveFromWorldStrategyComponent(); - world.buildAndAddEntity([lifetimeComponent, strategyComponent]); + world.addComponent(entity, lifetimeId, { + durationSeconds: 5, + elapsedSeconds: 0, + hasExpired: true, + }); - const system = new RemoveFromWorldLifecycleSystem(world); - world.addSystem(system); + world.addTag(entity, RemoveFromWorldLifetimeStrategyId); - // Act - time.update(0.1); world.update(); + const entitiesMatchingQuery: number[] = []; + + world.queryEntities( + [lifetimeId, RemoveFromWorldLifetimeStrategyId], + entitiesMatchingQuery, + ); + // Assert - expect(world.entityCount).toBe(1); // Only the entity without strategy remains + expect(entitiesMatchingQuery.length).toBe(0); }); - it('should handle multiple entities with different expiration states', () => { + it('should only process entities with both LifetimeComponent and RemoveFromWorldStrategyComponent', () => { // Arrange - const expiredComponent1 = new LifetimeComponent(5); - expiredComponent1.hasExpired = true; - const strategyComponent1 = new RemoveFromWorldStrategyComponent(); - world.buildAndAddEntity([expiredComponent1, strategyComponent1]); - - const activeComponent = new LifetimeComponent(5); - activeComponent.hasExpired = false; - const strategyComponent2 = new RemoveFromWorldStrategyComponent(); - world.buildAndAddEntity([activeComponent, strategyComponent2]); + const entity1 = world.createEntity(); + const entity2 = world.createEntity(); - const expiredComponent2 = new LifetimeComponent(5); - expiredComponent2.hasExpired = true; - const strategyComponent3 = new RemoveFromWorldStrategyComponent(); - world.buildAndAddEntity([expiredComponent2, strategyComponent3]); + world.addComponent(entity1, lifetimeId, { + durationSeconds: 5, + elapsedSeconds: 0, + hasExpired: true, + }); - const system = new RemoveFromWorldLifecycleSystem(world); - world.addSystem(system); + world.addTag(entity1, RemoveFromWorldLifetimeStrategyId); + world.addTag(entity2, RemoveFromWorldLifetimeStrategyId); // Act time.update(0.1); world.update(); + const entitiesMatchingQuery: number[] = []; + + world.queryEntities( + [RemoveFromWorldLifetimeStrategyId], + entitiesMatchingQuery, + ); + // Assert - expect(world.entityCount).toBe(1); // Only active entity remains + expect(entitiesMatchingQuery.length).toBe(1); }); }); diff --git a/src/lifecycle/systems/remove-from-world-lifecycle-system.ts b/src/lifecycle/systems/remove-from-world-lifecycle-system.ts index e8e69363..3f07f471 100644 --- a/src/lifecycle/systems/remove-from-world-lifecycle-system.ts +++ b/src/lifecycle/systems/remove-from-world-lifecycle-system.ts @@ -1,36 +1,24 @@ -import { Entity, System, World } from '../../ecs/index.js'; -import { LifetimeComponent } from '../components/lifetime-component.js'; -import { RemoveFromWorldStrategyComponent } from '../strategies/remove-from-world-strategy-component.js'; +import { + LifetimeEcsComponent, + lifetimeId, +} from '../components/lifetime-component.js'; +import { RemoveFromWorldLifetimeStrategyId } from '../strategies/remove-from-world-strategy-component.js'; + +import { EcsSystem } from '../../new-ecs/ecs-system.js'; /** - * System that removes entities from the world when they have expired. - * This system handles only the RemoveFromWorldStrategy - it queries for entities - * with both LifetimeComponent and RemoveFromWorldStrategyComponent. + * Creates an ECS system to handle removing expired entities from the world. */ -export class RemoveFromWorldLifecycleSystem extends System { - private readonly _world: World; - - /** - * Creates an instance of RemoveFromWorldLifecycleSystem. - * @param world - The World instance. - */ - constructor(world: World) { - super( - [LifetimeComponent, RemoveFromWorldStrategyComponent], - 'remove-from-world-lifecycle', - ); - this._world = world; - } - - /** - * Removes the entity from the world if it has expired. - * @param entity - The entity to check and potentially remove. - */ - public run(entity: Entity): void { - const lifetimeComponent = entity.getComponentRequired(LifetimeComponent); +export const createRemoveFromWorldEcsSystem = (): EcsSystem< + [LifetimeEcsComponent] +> => ({ + query: [lifetimeId], + tags: [RemoveFromWorldLifetimeStrategyId], + run: (result, world) => { + const [lifetimeComponent] = result.components; if (lifetimeComponent.hasExpired) { - this._world.removeEntity(entity); + world.removeEntity(result.entity); } - } -} + }, +}); diff --git a/src/lifecycle/systems/return-to-pool-lifecycle-system.ts b/src/lifecycle/systems/return-to-pool-lifecycle-system.ts deleted file mode 100644 index 5ea6d663..00000000 --- a/src/lifecycle/systems/return-to-pool-lifecycle-system.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Entity, System } from '../../ecs/index.js'; -import { LifetimeComponent } from '../components/lifetime-component.js'; -import { ReturnToPoolStrategyComponent } from '../strategies/return-to-pool-strategy-component.js'; - -/** - * System that returns entities to their pool when they have expired. - * This system handles only the ReturnToPoolStrategy - it queries for entities - * with both LifetimeComponent and ReturnToPoolStrategyComponent. - */ -export class ReturnToPoolLifecycleSystem extends System { - /** - * Creates an instance of ReturnToPoolLifecycleSystem. - */ - constructor() { - super( - [LifetimeComponent, ReturnToPoolStrategyComponent], - 'return-to-pool-lifecycle', - ); - } - - /** - * Returns the entity to its pool and removes it from the world if it has expired. - * @param entity - The entity to check and potentially return to pool. - */ - public run(entity: Entity): void { - const lifetimeComponent = entity.getComponentRequired(LifetimeComponent); - - if (lifetimeComponent.hasExpired) { - const poolStrategyComponent = entity.getComponentRequired( - ReturnToPoolStrategyComponent, - ); - - // Return to pool - poolStrategyComponent.pool.release(entity); - } - } -} diff --git a/src/new-ecs/ecs-component.ts b/src/new-ecs/ecs-component.ts new file mode 100644 index 00000000..449930ec --- /dev/null +++ b/src/new-ecs/ecs-component.ts @@ -0,0 +1,20 @@ +import { Brand } from '../utilities/index.js'; + +export type ComponentKey = Brand; +export type TagKey = Brand; + +export function createComponentId(name: string): ComponentKey { + return Symbol(name) as ComponentKey; +} + +export function createTagId(name: string): TagKey { + return Symbol(name) as TagKey; +} + +export type ComponentsFromKeys[]> = { + [I in keyof Q]: Q[I] extends ComponentKey ? C : never; +}; + +export type KeysFromComponents = { + [I in keyof T]: ComponentKey; +}; diff --git a/src/new-ecs/ecs-system.ts b/src/new-ecs/ecs-system.ts new file mode 100644 index 00000000..9e9554dd --- /dev/null +++ b/src/new-ecs/ecs-system.ts @@ -0,0 +1,18 @@ +import { KeysFromComponents, TagKey } from './ecs-component'; +import { EcsWorld, QueryResult } from './ecs-world'; + +export interface EcsSystem< + Q extends readonly unknown[] = readonly unknown[], + K = null, +> { + query: KeysFromComponents; + tags?: TagKey[]; + run(components: QueryResult, world: EcsWorld, beforeQueryResult: K): void; + beforeQuery?(world: EcsWorld): K; +} + +export const SystemRegistrationOrder = { + early: -10_000, + normal: 0, + late: 10_000, +}; diff --git a/src/new-ecs/ecs-world.test.ts b/src/new-ecs/ecs-world.test.ts new file mode 100644 index 00000000..083718f9 --- /dev/null +++ b/src/new-ecs/ecs-world.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EcsWorld } from './ecs-world'; +import { EcsSystem } from './ecs-system'; +import { + PositionEcsComponent, + positionId, + RotationEcsComponent, + rotationId, + SpeedEcsComponent, + speedId, +} from '../common/index.js'; +import { createComponentId } from './ecs-component'; +import { Vector2 } from '../math/index.js'; + +describe('EcsWorld', () => { + it('queries entities with multiple components', () => { + const world = new EcsWorld(); + + const entity1 = world.createEntity(); + const entity2 = world.createEntity(); + + const pos1: PositionEcsComponent = { + local: new Vector2(1, 0), + world: new Vector2(1, 0), + }; + const rot1: RotationEcsComponent = { local: 10, world: 10 }; + const pos2: PositionEcsComponent = { + local: new Vector2(2, 0), + world: new Vector2(2, 0), + }; + + world.addComponent(entity1, positionId, pos1); + world.addComponent(entity1, rotationId, rot1); + world.addComponent(entity2, positionId, pos2); + + const run = vi.fn(); + const system: EcsSystem<[PositionEcsComponent, RotationEcsComponent]> = { + query: [positionId, rotationId], + run, + }; + + world.addSystem(system); + world.update(); + + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + entity: entity1, + components: [pos1, rot1], + }), + world, + null, + ); + }); + + it('queries single component returns all entities', () => { + const world = new EcsWorld(); + const tagId = createComponentId<{ value: string }>('tag'); + + const entity1 = world.createEntity(); + const entity2 = world.createEntity(); + + const tag1 = { value: 'a' }; + const tag2 = { value: 'b' }; + + world.addComponent(entity1, tagId, tag1); + world.addComponent(entity2, tagId, tag2); + + const results: Array<{ entity: number; component: { value: string } }> = []; + const system: EcsSystem<[{ value: string }]> = { + query: [tagId], + run: (result) => { + results.push({ + entity: result.entity, + component: result.components[0] as { value: string }, + }); + }, + }; + + world.addSystem(system); + world.update(); + + expect(results).toHaveLength(2); + expect(results[0].entity).toBe(entity1); + expect(results[0].component).toBe(tag1); + expect(results[1].entity).toBe(entity2); + expect(results[1].component).toBe(tag2); + }); + + it('skips entities missing some components', () => { + const world = new EcsWorld(); + + const entity1 = world.createEntity(); + const entity2 = world.createEntity(); + + const position1: PositionEcsComponent = { + local: new Vector2(1, 0), + world: new Vector2(1, 0), + }; + const position2: PositionEcsComponent = { + local: new Vector2(2, 0), + world: new Vector2(2, 0), + }; + const speed2: SpeedEcsComponent = { speed: 3 }; + + world.addComponent(entity1, positionId, position1); + world.addComponent(entity2, positionId, position2); + world.addComponent(entity2, speedId, speed2); + + const run = vi.fn(); + const system: EcsSystem<[PositionEcsComponent, SpeedEcsComponent]> = { + query: [positionId, speedId], + run, + }; + + world.addSystem(system); + world.update(); + + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + entity: entity2, + components: [position2, speed2], + }), + world, + null, + ); + }); + + it('does not throw when no components found for the given names', () => { + const world = new EcsWorld(); + type TestComponent = { test: number }; + const nonexistentId = createComponentId('nonexistent'); + + const system: EcsSystem<[TestComponent]> = { + query: [nonexistentId], + run: () => {}, + }; + + world.addSystem(system); + + expect(() => world.update()).not.toThrow(); + }); + + it('runs systems with query results', () => { + const world = new EcsWorld(); + + const entity1 = world.createEntity(); + const entity2 = world.createEntity(); + + const pos1: PositionEcsComponent = { + local: new Vector2(-5, 0), + world: new Vector2(-5, 0), + }; + const pos2: PositionEcsComponent = { + local: new Vector2(5, 0), + world: new Vector2(5, 0), + }; + + world.addComponent(entity1, positionId, pos1); + world.addComponent(entity2, positionId, pos2); + + const run = vi.fn( + (result: { entity: number; components: [PositionEcsComponent] }) => { + const [position] = result.components; + + position.local.x += 10; + }, + ); + + const system: EcsSystem<[PositionEcsComponent]> = { + query: [positionId], + run, + }; + + world.addSystem(system); + + world.update(); + + expect(run).toHaveBeenCalledTimes(2); + expect(pos1.local.x).toBe(5); + expect(pos2.local.x).toBe(15); + }); + + it('invokes multiple systems independently', () => { + const world = new EcsWorld(); + + const entity1 = world.createEntity(); + const entity2 = world.createEntity(); + + const pos1: PositionEcsComponent = { + local: new Vector2(-5, 0), + world: new Vector2(-5, 0), + }; + const rot1: RotationEcsComponent = { local: 1, world: 1 }; + const pos2: PositionEcsComponent = { + local: new Vector2(5, 0), + world: new Vector2(5, 0), + }; + const rot2: RotationEcsComponent = { local: 2, world: 2 }; + + world.addComponent(entity1, positionId, pos1); + world.addComponent(entity1, rotationId, rot1); + world.addComponent(entity2, positionId, pos2); + world.addComponent(entity2, rotationId, rot2); + + const positionSystem: EcsSystem<[PositionEcsComponent]> = { + query: [positionId], + run: vi.fn( + (result: { entity: number; components: [PositionEcsComponent] }) => { + const [position] = result.components; + + position.local.x += 10; + }, + ), + }; + + const rotationSystem: EcsSystem<[RotationEcsComponent]> = { + query: [rotationId], + run: vi.fn( + (result: { entity: number; components: [RotationEcsComponent] }) => { + const [rotation] = result.components; + + rotation.local *= 2; + }, + ), + }; + + world.addSystem(positionSystem); + world.addSystem(rotationSystem); + + world.update(); + + expect(positionSystem.run).toHaveBeenCalledTimes(2); + expect(pos1.local.x).toBe(5); + expect(pos2.local.x).toBe(15); + + expect(rotationSystem.run).toHaveBeenCalledTimes(2); + expect(rot1.local).toBe(2); + expect(rot2.local).toBe(4); + }); +}); diff --git a/src/new-ecs/ecs-world.ts b/src/new-ecs/ecs-world.ts new file mode 100644 index 00000000..d9b5a83e --- /dev/null +++ b/src/new-ecs/ecs-world.ts @@ -0,0 +1,260 @@ +import { Updatable } from '../common/index.js'; +import { SortedSet, SparseSet } from '../utilities/index.js'; +import { ComponentKey, TagKey } from './ecs-component.js'; +import { EcsSystem, SystemRegistrationOrder } from './ecs-system.js'; + +export type QueryResult = { + entity: number; + components: T; +}; + +export class EcsWorld implements Updatable { + private readonly _componentSets: Map>; + + private readonly _freeEntityIds: number[] = []; + private _nextEntityId = 0; + + private readonly _queryResultBuffer: QueryResult = { + entity: -1, + components: [], + }; + + private readonly _systems: SortedSet>; + + constructor() { + this._componentSets = new Map(); + this._systems = new SortedSet(); + } + + public addSystem( + system: EcsSystem, + registrationOrder: number = SystemRegistrationOrder.normal, + ): void { + this._systems.add(system, registrationOrder); + } + + public removeSystem(system: EcsSystem): void { + this._systems.delete(system); + } + + public update(): void { + for (const system of this._systems) { + const beforeQueryResult = system.beforeQuery?.(this) ?? null; + + this.operate(system, beforeQueryResult); + } + } + + public addComponent( + entity: number, + componentKey: ComponentKey, + componentData: T, + ): T { + const componentSet = this._getComponentOrCreateSetByKey(componentKey); + + componentSet.add(entity, componentData); + + return componentData; + } + + public addTag(entity: number, tagKey: TagKey): void { + const componentSet = this._getComponentOrCreateSetByKey(tagKey, true); + + componentSet.add(entity, true); + } + + public getComponent( + entity: number, + componentKey: ComponentKey, + ): T | null { + const componentSet = this._componentSets.get(componentKey) as + | SparseSet + | undefined; + + return componentSet?.get(entity) ?? null; + } + + public removeComponent( + entity: number, + componentKey: ComponentKey, + ): void { + const componentSet = this._componentSets.get(componentKey); + + componentSet?.remove(entity); + + for (const componentSet of this._componentSets.values()) { + if (componentSet.has(entity)) { + return; + } + } + + this._freeEntityIds.push(entity); + } + + public createEntity(): number { + const id = this._generateEntityId(); + + return id; + } + + public removeEntity(entity: number): void { + for (const componentSet of this._componentSets.values()) { + componentSet.remove(entity); + } + + this._freeEntityIds.push(entity); + } + + public operate( + system: EcsSystem, + beforeQueryResult?: unknown, + ): void { + const driver = this._getDriverComponentSet(system.query, system.tags); + + if (!driver) { + return; + } + + for (let i = 0; i < driver.size; i++) { + const entity = driver.denseEntities[i]; + let hasAll = true; + + this._queryResultBuffer.components.length = 0; + + for (const name of system.query) { + const set = this._componentSets.get(name); + + if (!set?.has(entity)) { + hasAll = false; + + break; + } + + if (!set.isTag) { + this._queryResultBuffer.components.push(set.get(entity)); + } + } + + if (hasAll) { + this._queryResultBuffer.entity = entity; + system.run(this._queryResultBuffer, this, beforeQueryResult); + } + } + } + + public queryEntities(componentNames: symbol[], out: number[]): void { + out.length = 0; + + const driver = this._getDriverComponentSet(componentNames); + + if (!driver) { + return; + } + + for (let i = 0; i < driver.size; i++) { + const entity = driver.denseEntities[i]; + + let hasAllComponents = true; + + for (const name of componentNames) { + const componentSet = this._componentSets.get(name); + + if (!componentSet?.has(entity)) { + hasAllComponents = false; + + break; + } + } + + if (hasAllComponents) { + out.push(entity); + } + } + } + + private _getComponentOrCreateSetByKey( + key: symbol, + isTag: boolean = false, + ): SparseSet { + let componentSet = this._componentSets.get(key); + + if (!componentSet) { + componentSet = new SparseSet(isTag); + this._componentSets.set(key, componentSet); + } + + return componentSet as SparseSet; + } + + private _generateEntityId(): number { + if (this._freeEntityIds.length > 0) { + return this._freeEntityIds.pop() as number; + } + + const id = this._nextEntityId; + this._nextEntityId += 1; + + return id; + } + + private _getDriverComponentSet( + componentKeys: ComponentKey[], + tags: TagKey[] = [], + ): SparseSet | null { + if (componentKeys.length === 0) { + return null; + } + + let driver: SparseSet | null = this._getComponentSet( + componentKeys[0], + ); + + for (const name of componentKeys) { + const componentSet = this._getComponentSet(name); + + if (!componentSet) { + continue; + } + + if (!driver) { + driver = componentSet; + + continue; + } + + if (componentSet.size < driver.size) { + driver = componentSet; + } + } + + for (const name of tags) { + const componentSet = this._getComponentSet(name); + + if (!componentSet) { + continue; + } + + if (!driver) { + driver = componentSet; + + continue; + } + + if (componentSet.size < driver.size) { + driver = componentSet; + } + } + + return driver; + } + + private _getComponentSet(componentName: symbol): SparseSet | null { + const componentSet = this._componentSets.get(componentName); + + if (!componentSet) { + return null; + } + + return componentSet; + } +} diff --git a/src/new-ecs/index.ts b/src/new-ecs/index.ts new file mode 100644 index 00000000..46caa1ea --- /dev/null +++ b/src/new-ecs/index.ts @@ -0,0 +1,3 @@ +export * from './ecs-component.js'; +export * from './ecs-system.js'; +export * from './ecs-world.js'; diff --git a/src/particles/components/particle-component.ts b/src/particles/components/particle-component.ts index 19bdd9c1..f5ab6012 100644 --- a/src/particles/components/particle-component.ts +++ b/src/particles/components/particle-component.ts @@ -1,31 +1,10 @@ -import { Component } from '../../ecs/index.js'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; /** - * Represents the properties of a particle. + * ECS-style component interface for a particle. */ -export interface ParticleOptions { - /** - * The speed at which the particle rotates. - */ +export interface ParticleEcsComponent { rotationSpeed: number; } -/** - * Represents a particle component. - * This class is used to define properties and behavior for particles, such as their rotation speed. - */ -export class ParticleComponent extends Component { - public rotationSpeed: number; - - /** - * Creates an instance of ParticleComponent. - * @param options - The configuration options for the particle component. - */ - constructor(options: ParticleOptions) { - super(); - - const { rotationSpeed } = options; - - this.rotationSpeed = rotationSpeed; - } -} +export const ParticleId = createComponentId('Particle'); diff --git a/src/particles/components/particle-emitter-component.ts b/src/particles/components/particle-emitter-component.ts index 93a29655..ae097aca 100644 --- a/src/particles/components/particle-emitter-component.ts +++ b/src/particles/components/particle-emitter-component.ts @@ -1,20 +1,13 @@ -import { Component } from '../../ecs/index.js'; import { ParticleEmitter } from './particle-emitter.js'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; + /** - * Represents a component responsible for managing particle emitters in a system. - * One particle emitter component can hold many particle emitters + * ECS-style component interface for the particle emitter. */ -export class ParticleEmitterComponent extends Component { - public emitters: Map; - - /** - * Creates a new instance of the ParticleEmitterComponent. - * @param emitters - A map of particle emitters, with keys used to identify each emitter. - */ - constructor(emitters: Map) { - super(); - - this.emitters = emitters; - } +export interface ParticleEmitterEcsComponent { + emitters: Map; } + +export const ParticleEmitterId = + createComponentId('ParticleEmitter'); diff --git a/src/particles/components/particle-emitter.ts b/src/particles/components/particle-emitter.ts index 207f7d6a..4c9e4f19 100644 --- a/src/particles/components/particle-emitter.ts +++ b/src/particles/components/particle-emitter.ts @@ -1,9 +1,10 @@ -import { RenderLayer, Sprite } from '../../rendering/index.js'; +import { Vector2 } from '../../math/vector2.js'; +import { Sprite } from '../../rendering/index.js'; /** * Type for a function that returns a number representing the X and Y spawn position of the particle */ -export type ParticleSpawnPositionFunction = () => { x: number; y: number }; +export type ParticleSpawnPositionFunction = () => Vector2; /** * Interface for range values with a min and max value. @@ -81,7 +82,7 @@ const defaultOptions: ParticleEmitterOptions = { lifetimeSecondsRange: { min: 1, max: 3 }, lifetimeScaleReduction: 0, emitDurationSeconds: 0, - spawnPosition: () => ({ x: 0, y: 0 }), + spawnPosition: () => Vector2.zero, }; /** @@ -91,7 +92,7 @@ const defaultOptions: ParticleEmitterOptions = { */ export class ParticleEmitter { public sprite: Sprite; - public renderLayer: RenderLayer; + public renderLayer: number; public spawnPosition: ParticleSpawnPositionFunction; public numParticlesRange: Range; public speedRange: Range; @@ -115,7 +116,7 @@ export class ParticleEmitter { */ constructor( sprite: Sprite, - renderLayer: RenderLayer, + renderLayer: number, options: Partial = {}, ) { const { diff --git a/src/particles/systems/particle-emitter-system.test.ts b/src/particles/systems/particle-emitter-system.test.ts index 7df2b548..5a86f1c0 100644 --- a/src/particles/systems/particle-emitter-system.test.ts +++ b/src/particles/systems/particle-emitter-system.test.ts @@ -1,359 +1,142 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ParticleEmitterSystem } from './particle-emitter-system'; -import { Entity, World } from '../../ecs'; -import { ParticleEmitter, ParticleEmitterComponent } from '../components'; -import { RenderLayer, Sprite } from '../../rendering'; +import { createParticleEcsSystem } from './particle-emitter-system'; +import { EcsWorld } from '../../new-ecs'; +import { + ParticleEmitter, + ParticleEmitterEcsComponent, + ParticleEmitterId, + ParticleId, +} from '../components'; import { Time } from '../../common'; - -describe('_startEmittingParticles', () => { - const world: World = new World('test'); - const time: Time = new Time(); - const system = new ParticleEmitterSystem(world, time); - const mockSprite = {} as Sprite; // Mock sprite object - const mockRenderLayer = { - addEntity: vi.fn(), - } as unknown as RenderLayer; // Mock render layer object - - it('should start emitting when startEmitting is true', () => { - const emitter = new ParticleEmitter(mockSprite, mockRenderLayer, { - numParticlesRange: { min: 5, max: 10 }, - }); - const emitterComponent = new ParticleEmitterComponent( - new Map([['testEmitter', emitter]]), - ); - const entity = new Entity(world, [emitterComponent]); - - expect(emitter.startEmitting).toBe(false); - expect(emitter.currentlyEmitting).toBe(false); - - emitter.startEmitting = true; - system.run(entity); - - expect(emitter.startEmitting).toBe(false); - expect(emitter.currentEmitDuration).toBe(0); - expect(emitter.emitCount).toBeGreaterThan(0); - expect(emitter.currentlyEmitting).toBe(true); - expect(emitter.totalAmountToEmit).toBeGreaterThanOrEqual(5); - expect(emitter.totalAmountToEmit).toBeLessThanOrEqual(10); - }); - - it('should not start emitting when startEmitting is false', () => { - const emitter = new ParticleEmitter(mockSprite, mockRenderLayer, { - numParticlesRange: { min: 5, max: 10 }, - }); - const emitterComponent = new ParticleEmitterComponent( - new Map([['testEmitter', emitter]]), - ); - const entity = new Entity(world, [emitterComponent]); - - expect(emitter.startEmitting).toBe(false); - expect(emitter.currentlyEmitting).toBe(false); - - system.run(entity); - - expect(emitter.startEmitting).toBe(false); - expect(emitter.currentEmitDuration).toBe(0); - expect(emitter.emitCount).toBe(0); - expect(emitter.currentlyEmitting).toBe(false); - expect(emitter.totalAmountToEmit).toBeLessThan(5); - }); -}); +import { Random, Vector2 } from '../../math'; +import { Sprite } from '../../rendering'; describe('ParticleEmitterSystem', () => { - let world: World; + let world: EcsWorld; let time: Time; + let random: Random; + let mockSprite: Sprite; beforeEach(() => { - world = new World('test'); + world = new EcsWorld(); time = new Time(); + random = new Random('test-seed'); + world.addSystem(createParticleEcsSystem(time, random)); + + // Create a mock sprite object with all required properties + mockSprite = { + width: 10, + height: 10, + bleed: 1, + pivot: new Vector2(0.5, 0.5), + tintColor: { r: 1, g: 1, b: 1, a: 1 }, + renderable: { + geometry: vi.fn(), + material: vi.fn(), + floatsPerInstance: 0, + layer: 0, + bindInstanceData: vi.fn(), + setupInstanceAttributes: vi.fn(), + bind: vi.fn(), + draw: vi.fn(), + }, + } as unknown as Sprite; }); - it('should process all emitters in the entity', () => { - const system = new ParticleEmitterSystem(world, time); - const mockSprite = {} as Sprite; - const mockRenderLayer = {} as RenderLayer; - - // Create multiple emitters - const emitter1 = new ParticleEmitter(mockSprite, mockRenderLayer, { - numParticlesRange: { min: 5, max: 10 }, - }); - const emitter2 = new ParticleEmitter(mockSprite, mockRenderLayer, { - numParticlesRange: { min: 3, max: 7 }, - }); - - const emitterComponent = new ParticleEmitterComponent( - new Map([ - ['emitter1', emitter1], - ['emitter2', emitter2], - ]), - ); - - const entity = world.buildAndAddEntity([emitterComponent]); - - // Mock time delta - vi.spyOn(time, 'deltaTimeInSeconds', 'get').mockReturnValue(0.1); - - const initialDuration1 = emitter1.currentEmitDuration; - const initialDuration2 = emitter2.currentEmitDuration; - - system.run(entity); - - // Both emitters should have their duration updated - expect(emitter1.currentEmitDuration).toBe(initialDuration1 + 0.1); - expect(emitter2.currentEmitDuration).toBe(initialDuration2 + 0.1); - }); -}); - -describe('_emitNewParticles', () => { - let world: World; - let time: Time; - let system: ParticleEmitterSystem; - - const mockSprite = {} as Sprite; - const mockRenderLayer = { - addEntity: vi.fn(), - } as unknown as RenderLayer; - - beforeEach(() => { - world = new World('test'); - time = new Time(); - system = new ParticleEmitterSystem(world, time); - world.addSystem(system); - }); - - it('should not emit when not currently emitting', () => { - const emitter = new ParticleEmitter(mockSprite, mockRenderLayer, { - numParticlesRange: { min: 5, max: 10 }, - }); - - emitter.currentlyEmitting = false; - - const emitterComponent = new ParticleEmitterComponent( - new Map([['emitter', emitter]]), - ); - - world.buildAndAddEntity([emitterComponent]); - - const initialEntityCount = world.entityCount; - - time.update(0.1); - world.update(); - - expect(world.entityCount).toBe(initialEntityCount); - expect(emitter.currentlyEmitting).toBe(false); - }); + it('should start emitting when startEmitting is true', () => { + const entity = world.createEntity(); - it('should not emit when emit count exceeds total amount', () => { - const emitter = new ParticleEmitter(mockSprite, mockRenderLayer, { + const emitter = new ParticleEmitter(mockSprite, 0, { numParticlesRange: { min: 5, max: 10 }, + speedRange: { min: 50, max: 100 }, + scaleRange: { min: 1, max: 1 }, + lifetimeSecondsRange: { min: 1, max: 2 }, + rotationRange: { min: 0, max: 360 }, + rotationSpeedRange: { min: 0, max: 0 }, + emitDurationSeconds: 0, + spawnPosition: () => Vector2.zero, }); - emitter.currentlyEmitting = true; - emitter.totalAmountToEmit = 5; - emitter.emitCount = 5; - - const emitterComponent = new ParticleEmitterComponent( - new Map([['emitter', emitter]]), - ); + emitter.startEmitting = true; - world.buildAndAddEntity([emitterComponent]); + const emitterComponent: ParticleEmitterEcsComponent = { + emitters: new Map([['testEmitter', emitter]]), + }; - const initialEntityCount = world.entityCount; + world.addComponent(entity, ParticleEmitterId, emitterComponent); - time.update(0.1); + time.update(100); world.update(); - expect(world.entityCount).toBe(initialEntityCount); - expect(emitter.currentlyEmitting).toBe(false); + expect(emitter.currentlyEmitting).toBe(true); + expect(emitter.startEmitting).toBe(false); + expect(emitter.totalAmountToEmit).toBeGreaterThanOrEqual(5); + expect(emitter.totalAmountToEmit).toBeLessThanOrEqual(10); }); it('should emit particles when conditions are met', () => { - const emitter = new ParticleEmitter(mockSprite, mockRenderLayer, { - numParticlesRange: { min: 3, max: 3 }, - emitDurationSeconds: 0, // Immediate emission - }); - - emitter.startEmitting = true; - - const emitterComponent = new ParticleEmitterComponent( - new Map([['emitter', emitter]]), - ); - - world.buildAndAddEntity([emitterComponent]); - - const initialEntityCount = world.entityCount; - - time.update(0.1); - world.update(); + const entity = world.createEntity(); - expect(world.entityCount).toBe(initialEntityCount + 3); - expect(emitter.emitCount).toBe(3); - expect(emitter.currentlyEmitting).toBe(true); - }); - it('should stop emitting after emitting everything', () => { - const emitter = new ParticleEmitter(mockSprite, mockRenderLayer, { - numParticlesRange: { min: 10, max: 10 }, - emitDurationSeconds: 0.5, + const emitter = new ParticleEmitter(mockSprite, 0, { + numParticlesRange: { min: 3, max: 3 }, + speedRange: { min: 50, max: 100 }, + scaleRange: { min: 1, max: 1 }, + lifetimeSecondsRange: { min: 1, max: 2 }, + rotationRange: { min: 0, max: 360 }, + rotationSpeedRange: { min: 0, max: 0 }, + emitDurationSeconds: 0, + spawnPosition: () => Vector2.zero, }); emitter.startEmitting = true; - const emitterComponent = new ParticleEmitterComponent( - new Map([['emitter', emitter]]), - ); - - world.buildAndAddEntity([emitterComponent]); - - time.update(0 * 1000); - world.update(); + const emitterComponent: ParticleEmitterEcsComponent = { + emitters: new Map([['testEmitter', emitter]]), + }; - expect(emitter.currentlyEmitting).toBe(true); - expect(world.entityCount).toBe(1); - time.update(0.5 * 1000); - world.update(); + world.addComponent(entity, ParticleEmitterId, emitterComponent); - expect(emitter.currentlyEmitting).toBe(true); - expect(world.entityCount).toBe(11); - time.update(1 * 1000); + time.update(100); world.update(); - expect(emitter.currentlyEmitting).toBe(false); - expect(world.entityCount).toBe(11); - }); -}); - -describe('_getAmountToEmitBasedOnDuration', () => { - let world: World; - let time: Time; - let system: ParticleEmitterSystem; - - const mockSprite = {} as Sprite; - const mockRenderLayer = { - addEntity: vi.fn(), - } as unknown as RenderLayer; + // Query for particles after they should have been created + const particlesAfter: number[] = []; + world.queryEntities([ParticleId], particlesAfter); - beforeEach(() => { - world = new World('test'); - time = new Time(); - system = new ParticleEmitterSystem(world, time); + // Should have emitted 3 particles immediately (emitDurationSeconds: 0) + expect(particlesAfter.length).toBe(3); + expect(emitter.emitCount).toBe(3); }); - it('should calculate correct amount based on progress', () => { - const emitter = new ParticleEmitter(mockSprite, mockRenderLayer, { - emitDurationSeconds: 2, - }); - - emitter.currentlyEmitting = true; - emitter.totalAmountToEmit = 10; - emitter.currentEmitDuration = 1; // 50% progress - - const emitterComponent = new ParticleEmitterComponent( - new Map([['emitter', emitter]]), - ); - - const entity = world.buildAndAddEntity([emitterComponent]); - - expect(world.entityCount).toBe(1); - system.run(entity); + it('should stop emitting after reaching total amount', () => { + const entity = world.createEntity(); - expect(world.entityCount).toBe(1 + 5); - }); - - it('should not exceed total amount when progress is complete', () => { - const emitter = new ParticleEmitter(mockSprite, mockRenderLayer, { - emitDurationSeconds: 2, + const emitter = new ParticleEmitter(mockSprite, 0, { + numParticlesRange: { min: 10, max: 10 }, + speedRange: { min: 50, max: 100 }, + scaleRange: { min: 1, max: 1 }, + lifetimeSecondsRange: { min: 1, max: 2 }, + rotationRange: { min: 0, max: 360 }, + rotationSpeedRange: { min: 0, max: 0 }, + emitDurationSeconds: 0.5, + spawnPosition: () => Vector2.zero, }); + // Manually set emitter state as if it has already emitted all particles emitter.currentlyEmitting = true; + emitter.emitCount = 10; emitter.totalAmountToEmit = 10; - emitter.currentEmitDuration = 4; // 200% progress - - const emitterComponent = new ParticleEmitterComponent( - new Map([['emitter', emitter]]), - ); + emitter.currentEmitDuration = 1; - const entity = world.buildAndAddEntity([emitterComponent]); + const emitterComponent: ParticleEmitterEcsComponent = { + emitters: new Map([['testEmitter', emitter]]), + }; - expect(world.entityCount).toBe(1); - system.run(entity); - - expect(world.entityCount).toBe(1 + 10); - }); -}); - -describe('_getRandomValueInRange', () => { - let world: World; - let time: Time; - let system: ParticleEmitterSystem; - - const mockSprite = {} as Sprite; - const mockRenderLayer = { - addEntity: vi.fn(), - } as unknown as RenderLayer; - - beforeEach(() => { - world = new World('test'); - time = new Time(); - system = new ParticleEmitterSystem(world, time); - world.addSystem(system); - }); - - it('should return value within normal range', () => { - const emitter = new ParticleEmitter(mockSprite, mockRenderLayer, { - numParticlesRange: { min: 5, max: 7 }, - emitDurationSeconds: 0, - }); - - emitter.startEmitting = true; - - const emitterComponent = new ParticleEmitterComponent( - new Map([['emitter', emitter]]), - ); - - world.buildAndAddEntity([emitterComponent]); - - world.update(); - - expect(emitter.totalAmountToEmit).toBeLessThanOrEqual(7); - expect(emitter.totalAmountToEmit).toBeGreaterThanOrEqual(5); - }); - - it('should handle when min > max', () => { - const emitter = new ParticleEmitter(mockSprite, mockRenderLayer, { - numParticlesRange: { min: 10, max: 8 }, - emitDurationSeconds: 0, - }); - - emitter.startEmitting = true; - - const emitterComponent = new ParticleEmitterComponent( - new Map([['emitter', emitter]]), - ); - - world.buildAndAddEntity([emitterComponent]); + world.addComponent(entity, ParticleEmitterId, emitterComponent); + time.update(100); world.update(); - expect(emitter.totalAmountToEmit).toBeLessThanOrEqual(10); - expect(emitter.totalAmountToEmit).toBeGreaterThanOrEqual(8); - }); - - it('should handle equal min and max values', () => { - const emitter = new ParticleEmitter(mockSprite, mockRenderLayer, { - numParticlesRange: { min: 12, max: 12 }, - emitDurationSeconds: 0, - }); - - emitter.startEmitting = true; - - const emitterComponent = new ParticleEmitterComponent( - new Map([['emitter', emitter]]), - ); - - world.buildAndAddEntity([emitterComponent]); - - world.update(); - - expect(emitter.totalAmountToEmit).toBe(12); + expect(emitter.currentlyEmitting).toBe(false); }); }); diff --git a/src/particles/systems/particle-emitter-system.ts b/src/particles/systems/particle-emitter-system.ts index 3e66d405..d27aac57 100644 --- a/src/particles/systems/particle-emitter-system.ts +++ b/src/particles/systems/particle-emitter-system.ts @@ -1,200 +1,193 @@ -import { Entity, System, World } from '../../ecs/index.js'; import { - AgeScaleComponent, - PositionComponent, - RotationComponent, - ScaleComponent, - SpeedComponent, + ageScaleId, + positionId, + rotationId, + scaleId, + speedId, Time, } from '../../common/index.js'; +import { spriteId } from '../../rendering/index.js'; +import { Random, Vector2 } from '../../math/index.js'; import { - LifetimeComponent, - RemoveFromWorldStrategyComponent, -} from '../../lifecycle/index.js'; - -import { SpriteComponent } from '../../rendering/index.js'; -import { Random } from '../../math/index.js'; -import { - ParticleComponent, ParticleEmitter, - ParticleEmitterComponent, - Range, + ParticleEmitterEcsComponent, + ParticleEmitterId, + ParticleId, } from '../index.js'; +import { EcsSystem } from '../../new-ecs/ecs-system.js'; +import { EcsWorld } from '../../new-ecs/ecs-world.js'; +import { + lifetimeId, + RemoveFromWorldLifetimeStrategyId, +} from '../../lifecycle/index.js'; -/** - * System that emits particles based on ParticleEmitters - */ -export class ParticleEmitterSystem extends System { - private readonly _time: Time; - private readonly _world: World; - private readonly _random: Random; - - /** - * Creates an instance of ParticleEmitterSystem. - * @param world - The World instance. - * @param time - The Time instance for managing time-related operations. - */ - constructor(world: World, time: Time) { - super([ParticleEmitterComponent], 'particle-emitter'); - this._time = time; - this._world = world; - this._random = new Random(); - } - - /** - * Runs the particle emitter system for a given entity. - * @param entity - The entity to update particle emitters for. - */ - public run(entity: Entity): void { - const particleEmitterComponent = entity.getComponentRequired( - ParticleEmitterComponent, +function startEmittingParticles( + particleEmitter: ParticleEmitter, + random: Random, +) { + if (particleEmitter.startEmitting) { + particleEmitter.currentEmitDuration = 0; + particleEmitter.startEmitting = false; + particleEmitter.emitCount = 0; + particleEmitter.currentlyEmitting = true; + particleEmitter.totalAmountToEmit = Math.round( + random.randomFloat( + particleEmitter.numParticlesRange.min, + particleEmitter.numParticlesRange.max, + ), ); - - for (const particleEmitter of particleEmitterComponent.emitters.values()) { - particleEmitter.currentEmitDuration += this._time.deltaTimeInSeconds; - - this._startEmittingParticles(particleEmitter); - - this._emitNewParticles(particleEmitter); - } } +} - /** - * Starts emitting particles from the emitter if `startEmitting` is set to true. - * This will reset the current emit duration and start the emission process, - * as well as choose a number of particles to emit. - * @param particleEmitter The particle emitter to start emitting from. - */ - private _startEmittingParticles(particleEmitter: ParticleEmitter) { - if (particleEmitter.startEmitting) { - particleEmitter.currentEmitDuration = 0; - particleEmitter.startEmitting = false; - particleEmitter.emitCount = 0; - particleEmitter.currentlyEmitting = true; - particleEmitter.totalAmountToEmit = Math.round( - this._getRandomValueInRange(particleEmitter.numParticlesRange), - ); - } - } +function getRandomValueInRangeDegrees( + min: number, + max: number, + random: Random, +): number { + const range = (max - min) % 360; - /** - * Emits new particles from the emitter. - * Emits a portion of the total amount to emit, based on emit duration. - * If emit duration is zero, it will immediately emit all particles. - * @param particleEmitter The particle emitter to emit particles from. - */ - private _emitNewParticles(particleEmitter: ParticleEmitter) { - if ( - !particleEmitter.currentlyEmitting || - particleEmitter.emitCount >= particleEmitter.totalAmountToEmit - ) { - particleEmitter.currentlyEmitting = false; - - return; - } + if (range === 0 && max !== min) { + return random.randomFloat(0, 360); + } - const currentAmountToEmit = - this._getAmountToEmitBasedOnDuration(particleEmitter); + return random.randomFloat(min, min + range); +} - for (let i = 0; i < currentAmountToEmit; i++) { - this._emitParticle(particleEmitter); - } +function emitParticle( + particleEmitter: ParticleEmitter, + random: Random, + world: EcsWorld, +) { + const speed = random.randomFloat( + particleEmitter.speedRange.min, + particleEmitter.speedRange.max, + ); + + const originalScale = random.randomFloat( + particleEmitter.scaleRange.min, + particleEmitter.scaleRange.max, + ); + + const lifetimeSeconds = random.randomFloat( + particleEmitter.lifetimeSecondsRange.min, + particleEmitter.lifetimeSecondsRange.max, + ); + + const rotation = getRandomValueInRangeDegrees( + particleEmitter.rotationRange.min, + particleEmitter.rotationRange.max, + random, + ); + + const rotationSpeed = random.randomFloat( + particleEmitter.rotationSpeedRange.min, + particleEmitter.rotationSpeedRange.max, + ); + + const spawnPosition = particleEmitter.spawnPosition(); + + const particleEntity = world.createEntity(); + + world.addComponent(particleEntity, spriteId, { + sprite: particleEmitter.sprite, + enabled: true, + }); + + world.addComponent(particleEntity, ParticleId, { + rotationSpeed, + }); + + world.addComponent(particleEntity, lifetimeId, { + durationSeconds: lifetimeSeconds, + elapsedSeconds: 0, + hasExpired: false, + }); + + world.addTag(particleEntity, RemoveFromWorldLifetimeStrategyId); + + world.addComponent(particleEntity, ageScaleId, { + originalScaleX: originalScale, + originalScaleY: originalScale, + finalLifetimeScaleX: particleEmitter.lifetimeScaleReduction, + finalLifetimeScaleY: particleEmitter.lifetimeScaleReduction, + }); + + world.addComponent(particleEntity, positionId, { + world: spawnPosition.clone(), + local: spawnPosition.clone(), + }); + + world.addComponent(particleEntity, scaleId, { + world: Vector2.one, + local: new Vector2(originalScale, originalScale), + }); + + world.addComponent(particleEntity, rotationId, { + world: 0, + local: rotation, + }); + + world.addComponent(particleEntity, speedId, { + speed, + }); +} - particleEmitter.emitCount += currentAmountToEmit; +function getAmountToEmitBasedOnDuration(particleEmitter: ParticleEmitter) { + if (particleEmitter.emitDurationSeconds <= 0) { + return particleEmitter.totalAmountToEmit - particleEmitter.emitCount; } - /** - * Emits a single particle from the given particle emitter. - * @param particleEmitter The particle emitter to emit a particle from. - */ - private _emitParticle(particleEmitter: ParticleEmitter) { - const speed = this._getRandomValueInRange(particleEmitter.speedRange); + const emitProgress = Math.min( + particleEmitter.currentEmitDuration / particleEmitter.emitDurationSeconds, + 1, + ); + const targetEmitCount = Math.ceil( + emitProgress * particleEmitter.totalAmountToEmit, + ); - const originalScale = this._getRandomValueInRange( - particleEmitter.scaleRange, - ); + return targetEmitCount - particleEmitter.emitCount; +} - const lifetimeSeconds = this._getRandomValueInRange( - particleEmitter.lifetimeSecondsRange, - ); +function emitNewParticles( + particleEmitter: ParticleEmitter, + random: Random, + world: EcsWorld, +) { + if ( + !particleEmitter.currentlyEmitting || + particleEmitter.emitCount >= particleEmitter.totalAmountToEmit + ) { + particleEmitter.currentlyEmitting = false; + + return; + } - const rotation = this._getRandomValueInRangeDegrees( - particleEmitter.rotationRange, - ); + const currentAmountToEmit = getAmountToEmitBasedOnDuration(particleEmitter); - const rotationSpeed = this._getRandomValueInRange( - particleEmitter.rotationSpeedRange, - ); - - const spawnPosition = particleEmitter.spawnPosition(); - - const particleEntity = this._world.buildAndAddEntity([ - new SpriteComponent(particleEmitter.sprite), - new ParticleComponent({ - rotationSpeed, - }), - new LifetimeComponent(lifetimeSeconds), - new RemoveFromWorldStrategyComponent(), - new AgeScaleComponent( - originalScale, - originalScale, - particleEmitter.lifetimeScaleReduction, - particleEmitter.lifetimeScaleReduction, - ), - new PositionComponent(spawnPosition.x, spawnPosition.y), - new ScaleComponent(originalScale, originalScale), - new RotationComponent(rotation), - new SpeedComponent(speed), - ]); - - particleEmitter.renderLayer.addEntity( - particleEmitter.sprite.renderable, - particleEntity, - ); + for (let i = 0; i < currentAmountToEmit; i++) { + emitParticle(particleEmitter, random, world); } - /** - * Gets the amount of particles to emit based on the current emit duration. - * @param particleEmitter The particle emitter to get the amount from. - * @returns The number of particles to emit. - */ - private _getAmountToEmitBasedOnDuration(particleEmitter: ParticleEmitter) { - if (particleEmitter.emitDurationSeconds <= 0) { - return particleEmitter.totalAmountToEmit - particleEmitter.emitCount; - } - - const emitProgress = Math.min( - particleEmitter.currentEmitDuration / particleEmitter.emitDurationSeconds, - 1, - ); - const targetEmitCount = Math.ceil( - emitProgress * particleEmitter.totalAmountToEmit, - ); + particleEmitter.emitCount += currentAmountToEmit; +} - return targetEmitCount - particleEmitter.emitCount; - } +/** + * Creates an ECS system to handle particles. + */ +export const createParticleEcsSystem = ( + time: Time, + random: Random, +): EcsSystem<[ParticleEmitterEcsComponent]> => ({ + query: [ParticleEmitterId], + run: (result, world) => { + const [particleEmitterComponent] = result.components; - /** - * Gets a random value within the specified range. - * @param minMax The range to get the random value from. - * @returns A random value within the specified range. - */ - private _getRandomValueInRange({ min, max }: Range): number { - return this._random.randomFloat(min, max); - } + for (const particleEmitter of particleEmitterComponent.emitters.values()) { + particleEmitter.currentEmitDuration += time.deltaTimeInSeconds; - /** - * Gets a random value within the specified range in degrees. - * @param minMax The range to get the random value from. - * @returns A random value within the specified range, from 0-360 degrees - */ - private _getRandomValueInRangeDegrees({ min, max }: Range): number { - const range = (max - min) % 360; + startEmittingParticles(particleEmitter, random); - if (range === 0 && max !== min) { - return this._random.randomFloat(0, 360); + emitNewParticles(particleEmitter, random, world); } - - return this._random.randomFloat(min, min + range); - } -} + }, +}); diff --git a/src/particles/systems/particle-position-system.test.ts b/src/particles/systems/particle-position-system.test.ts index b4e30230..13cb5c9b 100644 --- a/src/particles/systems/particle-position-system.test.ts +++ b/src/particles/systems/particle-position-system.test.ts @@ -1,87 +1,130 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { ParticlePositionSystem } from './particle-position-system'; -import { Entity, World } from '../../ecs'; -import { ParticleComponent } from '../components/particle-component'; +import { createParticlePositionEcsSystem } from './particle-position-system'; +import { EcsWorld } from '../../new-ecs'; import { - PositionComponent, - RotationComponent, - SpeedComponent, + ParticleEcsComponent, + ParticleId, +} from '../components/particle-component'; +import { + PositionEcsComponent, + positionId, + RotationEcsComponent, + rotationId, + SpeedEcsComponent, + speedId, Time, } from '../../common'; +import { Vector2 } from '../../math'; describe('ParticlePositionSystem', () => { - const time = { deltaTimeInSeconds: 0.016 } as Time; - const system = new ParticlePositionSystem(time); - let entity: Entity; + let world: EcsWorld; + let time: Time; beforeEach(() => { - entity = new Entity({} as World, [ - new PositionComponent(0, 0), - new RotationComponent(20), - new SpeedComponent(10), - new ParticleComponent({ - rotationSpeed: Math.PI, - }), - ]); + world = new EcsWorld(); + time = new Time(); + world.addSystem(createParticlePositionEcsSystem(time)); }); - it('should update rotation based on rotation speed and delta time', () => { - const rotationComponent = entity.getComponentRequired(RotationComponent); - const initialRadians = rotationComponent.local; + it('should update particle position based on speed and rotation', () => { + const entity = world.createEntity(); + + const positionComponent: PositionEcsComponent = { + local: new Vector2(0, 0), + world: new Vector2(0, 0), + }; + + const rotationComponent: RotationEcsComponent = { + local: 0, // facing up + world: 0, + }; + + const speedComponent: SpeedEcsComponent = { + speed: 100, + }; + + const particleComponent: ParticleEcsComponent = { + rotationSpeed: 0, + }; - system.run(entity); + world.addComponent(entity, positionId, positionComponent); + world.addComponent(entity, rotationId, rotationComponent); + world.addComponent(entity, speedId, speedComponent); + world.addComponent(entity, ParticleId, particleComponent); - expect(rotationComponent.local).toBeCloseTo( - initialRadians + Math.PI * time.deltaTimeInSeconds, - ); + time.update(100); + world.update(); + + expect(positionComponent.local.x).toBeCloseTo(0); + expect(positionComponent.local.y).toBeCloseTo(-10); }); - it('should update position based on speed, rotation, and delta time', () => { - const positionComponent = entity.getComponentRequired(PositionComponent); - const rotationComponent = entity.getComponentRequired(RotationComponent); - const speedComponent = entity.getComponentRequired(SpeedComponent); - - const expectedX = - positionComponent.local.x + - speedComponent.speed * - time.deltaTimeInSeconds * - Math.sin(rotationComponent.local); - const expectedY = - positionComponent.local.y - - speedComponent.speed * - time.deltaTimeInSeconds * - Math.cos(rotationComponent.local); - - system.run(entity); - expect(positionComponent.local.x).toBeCloseTo(expectedX); - expect(positionComponent.local.y).toBeCloseTo(expectedY); + it('should update particle rotation based on rotation speed', () => { + const entity = world.createEntity(); + + const positionComponent: PositionEcsComponent = { + local: new Vector2(0, 0), + world: new Vector2(0, 0), + }; + + const rotationComponent: RotationEcsComponent = { + local: 0, + world: 0, + }; + + const speedComponent: SpeedEcsComponent = { + speed: 0, + }; + + const particleComponent: ParticleEcsComponent = { + rotationSpeed: Math.PI, + }; + + world.addComponent(entity, positionId, positionComponent); + world.addComponent(entity, rotationId, rotationComponent); + world.addComponent(entity, speedId, speedComponent); + world.addComponent(entity, ParticleId, particleComponent); + + time.update(500); + world.update(); + + expect(rotationComponent.local).toBeCloseTo(Math.PI / 2); }); - it('should handle multiple runs correctly', () => { - const positionComponent = entity.getComponentRequired(PositionComponent); - const rotationComponent = entity.getComponentRequired(RotationComponent); - const speedComponent = entity.getComponentRequired(SpeedComponent); - - const newRotation = - rotationComponent.local + Math.PI * time.deltaTimeInSeconds; - const expectedRotation = newRotation + Math.PI * time.deltaTimeInSeconds; - - const expectedX = - positionComponent.local.x + - speedComponent.speed * - time.deltaTimeInSeconds * - (Math.sin(rotationComponent.local) + Math.sin(newRotation)); - const expectedY = - positionComponent.local.y - - speedComponent.speed * - time.deltaTimeInSeconds * - (Math.cos(rotationComponent.local) + Math.cos(newRotation)); - - system.run(entity); - system.run(entity); - - expect(rotationComponent.local).toBeCloseTo(expectedRotation); - expect(positionComponent.local.x).toBeCloseTo(expectedX); - expect(positionComponent.local.y).toBeCloseTo(expectedY); + it('should handle multiple particles independently', () => { + const entity1 = world.createEntity(); + const entity2 = world.createEntity(); + + const pos1: PositionEcsComponent = { + local: new Vector2(0, 0), + world: new Vector2(0, 0), + }; + + const pos2: PositionEcsComponent = { + local: new Vector2(10, 10), + world: new Vector2(10, 10), + }; + + world.addComponent(entity1, positionId, pos1); + world.addComponent(entity1, rotationId, { local: 0, world: 0 }); + world.addComponent(entity1, speedId, { speed: 50 }); + world.addComponent(entity1, ParticleId, { rotationSpeed: 0 }); + + world.addComponent(entity2, positionId, pos2); + world.addComponent(entity2, rotationId, { + local: Math.PI / 2, + world: Math.PI / 2, + }); + world.addComponent(entity2, speedId, { speed: 100 }); + world.addComponent(entity2, ParticleId, { rotationSpeed: 0 }); + + time.update(100); + world.update(); + + expect(pos1.local.x).toBeCloseTo(0); + expect(pos1.local.y).toBeCloseTo(-5); + + expect(pos2.local.x).toBeCloseTo(20); + expect(pos2.local.y).toBeCloseTo(10); }); }); diff --git a/src/particles/systems/particle-position-system.ts b/src/particles/systems/particle-position-system.ts index de40758d..55168bfe 100644 --- a/src/particles/systems/particle-position-system.ts +++ b/src/particles/systems/particle-position-system.ts @@ -1,50 +1,48 @@ -import { Entity, System } from '../../ecs/index.js'; import { - PositionComponent, - RotationComponent, - SpeedComponent, + PositionEcsComponent, + positionId, + RotationEcsComponent, + rotationId, + SpeedEcsComponent, + speedId, Time, } from '../../common/index.js'; -import { ParticleComponent } from '../index.js'; +import { ParticleEcsComponent, ParticleId } from '../index.js'; + +import { EcsSystem } from '../../new-ecs/ecs-system.js'; + /** - * System that manages and updates particle position. + * Creates an ECS system to handle updating particle positions. */ -export class ParticlePositionSystem extends System { - private readonly _time: Time; - /** - * Creates an instance of ParticlePositionSystem. - * @param time - The Time instance. - */ - constructor(time: Time) { - super( - [ParticleComponent, PositionComponent, RotationComponent, SpeedComponent], - 'particle-position', - ); - this._time = time; - } - - /** - * Runs the particle position system for a given entity. - * This method updates the rotation based on the particle's rotation speed, - * and updates the position based on the speed and rotation. - * @param entity - The entity to update particle position for. - */ - public run(entity: Entity): void { - const particleComponent = entity.getComponentRequired(ParticleComponent); - const positionComponent = entity.getComponentRequired(PositionComponent); - const rotationComponent = entity.getComponentRequired(RotationComponent); - const speedComponent = entity.getComponentRequired(SpeedComponent); +export const createParticlePositionEcsSystem = ( + time: Time, +): EcsSystem< + [ + PositionEcsComponent, + RotationEcsComponent, + SpeedEcsComponent, + ParticleEcsComponent, + ] +> => ({ + query: [positionId, rotationId, speedId, ParticleId], + run: (result) => { + const [ + positionComponent, + rotationComponent, + speedComponent, + particleComponent, + ] = result.components; positionComponent.local.x += speedComponent.speed * - this._time.deltaTimeInSeconds * + time.deltaTimeInSeconds * Math.sin(rotationComponent.local); positionComponent.local.y -= speedComponent.speed * - this._time.deltaTimeInSeconds * + time.deltaTimeInSeconds * Math.cos(rotationComponent.local); rotationComponent.local += - particleComponent.rotationSpeed * this._time.deltaTimeInSeconds; - } -} + particleComponent.rotationSpeed * time.deltaTimeInSeconds; + }, +}); diff --git a/src/physics/components/physics-body-component.ts b/src/physics/components/physics-body-component.ts index 31fbc50c..779bb7ba 100644 --- a/src/physics/components/physics-body-component.ts +++ b/src/physics/components/physics-body-component.ts @@ -1,24 +1,12 @@ import type { Body } from 'matter-js'; -import { Component } from '../../ecs/index.js'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; /** - * Component to manage physics bodies in the game. - * This component is used to represent a physics body in the game. + * ECS-style component interface for a physics body. */ -export class PhysicsBodyComponent extends Component { - /** - * The physics body associated with this component. - * This is the Matter.js body that represents the physical properties of the entity. - */ - public physicsBody: Body; - - /** - * Creates an instance of PhysicsBodyComponent. - * This component is used to represent a physics body in the game. - */ - constructor(physicsBody: Body) { - super(); - - this.physicsBody = physicsBody; - } +export interface PhysicsBodyEcsComponent { + physicsBody: Body; } + +export const PhysicsBodyId = + createComponentId('PhysicsBody'); diff --git a/src/physics/systems/physics.system.test.ts b/src/physics/systems/physics.system.test.ts index 54e86cf9..1fe32fdf 100644 --- a/src/physics/systems/physics.system.test.ts +++ b/src/physics/systems/physics.system.test.ts @@ -1,74 +1,149 @@ import { beforeEach, describe, expect, it } from 'vitest'; +import { Bodies, Engine } from 'matter-js'; +import { createPhysicsEcsSystem } from './physics.system'; +import { EcsWorld } from '../../new-ecs'; import { - Bodies, - Body, - Engine as MatterJsPhysicsEngine, - World as MatterJsWorld, -} from 'matter-js'; -import { PhysicsSystem } from './physics.system'; -import { Entity, World } from '../../ecs'; -import { PositionComponent, RotationComponent, Time } from '../../common'; -import { PhysicsBodyComponent } from '../components'; -import { degreesToRadians } from '../../math'; + PositionEcsComponent, + positionId, + RotationEcsComponent, + rotationId, + Time, +} from '../../common'; +import { PhysicsBodyEcsComponent, PhysicsBodyId } from '../components'; +import { Vector2 } from '../../math'; describe('PhysicsSystem', () => { + let world: EcsWorld; let time: Time; - let engine: MatterJsPhysicsEngine; - let physicsSystem: PhysicsSystem; - let entity: Entity; - let world: World; + let engine: Engine; beforeEach(() => { - world = new World('test-world'); + world = new EcsWorld(); time = new Time(); + engine = Engine.create({ gravity: { x: 0, y: 0 } }); + world.addSystem(createPhysicsEcsSystem(engine, time)); + }); + + it('should update position and rotation from physics body for dynamic bodies', () => { + const entity = world.createEntity(); + + const physicsBody = Bodies.rectangle(100, 200, 50, 50, { isStatic: false }); + const physicsBodyComponent: PhysicsBodyEcsComponent = { + physicsBody, + }; + + const positionComponent: PositionEcsComponent = { + local: Vector2.zero, + world: Vector2.zero, + }; + + const rotationComponent: RotationEcsComponent = { + local: 0, + world: 0, + }; + + world.addComponent(entity, PhysicsBodyId, physicsBodyComponent); + world.addComponent(entity, positionId, positionComponent); + world.addComponent(entity, rotationId, rotationComponent); time.update(16); - engine = MatterJsPhysicsEngine.create(); - physicsSystem = new PhysicsSystem(time, engine); + world.update(); - const physicsBody = Bodies.rectangle(0, 0, 10, 20); + expect(positionComponent.world.x).toBe(physicsBody.position.x); + expect(positionComponent.world.y).toBe(physicsBody.position.y); + expect(rotationComponent.world).toBe(physicsBody.angle); + }); - MatterJsWorld.add(engine.world, [physicsBody]); + it('should update physics body from position and rotation for static bodies', () => { + const entity = world.createEntity(); + + const physicsBody = Bodies.rectangle(0, 0, 50, 50, { isStatic: true }); + const physicsBodyComponent: PhysicsBodyEcsComponent = { + physicsBody, + }; + + const positionComponent: PositionEcsComponent = { + local: new Vector2(100, 200), + world: new Vector2(100, 200), + }; + + const rotationComponent: RotationEcsComponent = { + local: Math.PI / 4, + world: Math.PI / 4, + }; + + world.addComponent(entity, PhysicsBodyId, physicsBodyComponent); + world.addComponent(entity, positionId, positionComponent); + world.addComponent(entity, rotationId, rotationComponent); + + time.update(16); + world.update(); - entity = new Entity(world, [ - new PositionComponent(0, 0), - new RotationComponent(0), - new PhysicsBodyComponent(physicsBody), - ]); + expect(physicsBody.position.x).toBe(100); + expect(physicsBody.position.y).toBe(200); + expect(physicsBody.angle).toBe(Math.PI / 4); }); - it('should update entity position and rotation in run', () => { - const { physicsBody } = entity.getComponentRequired(PhysicsBodyComponent); - const positionComponent = entity.getComponentRequired(PositionComponent); - const rotationComponent = entity.getComponentRequired(RotationComponent); + it('should handle multiple physics bodies', () => { + const entity1 = world.createEntity(); + const entity2 = world.createEntity(); - Body.applyForce(physicsBody, { x: -10, y: -20 }, { x: 0.1, y: 0.1 }); + const body1 = Bodies.rectangle(50, 50, 30, 30, { isStatic: false }); + const body2 = Bodies.rectangle(150, 150, 40, 40, { isStatic: false }); - physicsSystem.beforeAll([entity]); - physicsSystem.run(entity); + world.addComponent(entity1, PhysicsBodyId, { physicsBody: body1 }); + world.addComponent(entity1, positionId, { + local: Vector2.zero, + world: Vector2.zero, + }); + world.addComponent(entity1, rotationId, { local: 0, world: 0 }); - expect(physicsBody.position.y).toBe(128.256); - expect(positionComponent.world.x).toBe(128); - expect(positionComponent.world.y).toBe(128.256); - expect(rotationComponent.world).toBeCloseTo(7.68); + world.addComponent(entity2, PhysicsBodyId, { physicsBody: body2 }); + world.addComponent(entity2, positionId, { + local: Vector2.zero, + world: Vector2.zero, + }); + world.addComponent(entity2, rotationId, { local: 0, world: 0 }); + + time.update(16); + world.update(); + + const pos1 = world.getComponent(entity1, positionId); + const pos2 = world.getComponent(entity2, positionId); + + expect(pos1).toBeDefined(); + expect(pos2).toBeDefined(); + + if (pos1 && pos2) { + expect(pos1.world.x).toBe(body1.position.x); + expect(pos1.world.y).toBe(body1.position.y); + expect(pos2.world.x).toBe(body2.position.x); + expect(pos2.world.y).toBe(body2.position.y); + } }); - it('should sync static body position and angle from components', () => { - // Create a static body and entity - const staticBody = Bodies.rectangle(5, 10, 10, 10, { isStatic: true }); - staticBody.angle = 0.5; - const staticEntity = new Entity(world, [ - new PositionComponent(42, 99), - new RotationComponent(degreesToRadians(90)), - new PhysicsBodyComponent(staticBody), - ]); - - // Run system - physicsSystem.run(staticEntity); - - // The body should be updated from the components - expect(staticBody.position.x).toBe(42); - expect(staticBody.position.y).toBe(99); - expect(staticBody.angle).toBe(degreesToRadians(90)); + it('should synchronize dynamic body position with ECS', () => { + const entity = world.createEntity(); + + const physicsBody = Bodies.rectangle(100, 200, 50, 50, { isStatic: false }); + + world.addComponent(entity, PhysicsBodyId, { physicsBody }); + world.addComponent(entity, positionId, { + local: Vector2.zero, + world: Vector2.zero, + }); + world.addComponent(entity, rotationId, { local: 0, world: 0 }); + + time.update(16); + world.update(); + + const position = world.getComponent(entity, positionId); + + expect(position).toBeDefined(); + + if (position) { + expect(position.world.x).toBe(physicsBody.position.x); + expect(position.world.y).toBe(physicsBody.position.y); + } }); }); diff --git a/src/physics/systems/physics.system.ts b/src/physics/systems/physics.system.ts index d0f10950..f8f06796 100644 --- a/src/physics/systems/physics.system.ts +++ b/src/physics/systems/physics.system.ts @@ -1,40 +1,33 @@ import { Body, Engine } from 'matter-js'; import { - PositionComponent, - RotationComponent, + PositionEcsComponent, + positionId, + RotationEcsComponent, + rotationId, type Time, } from '../../common/index.js'; -import { Entity, System } from '../../ecs/index.js'; -import { PhysicsBodyComponent } from '../components/index.js'; - -export class PhysicsSystem extends System { - private readonly _time: Time; - private readonly _engine: Engine; - - constructor(time: Time, engine: Engine) { - super( - [PositionComponent, RotationComponent, PhysicsBodyComponent], - 'physics', - ); - - this._time = time; - this._engine = engine; - } - - public override beforeAll(entities: Entity[]): Entity[] { - Engine.update(this._engine, this._time.deltaTimeInMilliseconds); - - return entities; - } - - public override run(entity: Entity): void { - const physicsBodyComponent = - entity.getComponentRequired(PhysicsBodyComponent); - - const positionComponent = entity.getComponentRequired(PositionComponent); - - const rotationComponent = entity.getComponentRequired(RotationComponent); +import { PhysicsBodyEcsComponent, PhysicsBodyId } from '../components/index.js'; + +import { EcsSystem } from '../../new-ecs/ecs-system.js'; + +/** + * Creates an ECS system to handle physics. + */ +export const createPhysicsEcsSystem = ( + engine: Engine, + time: Time, +): EcsSystem< + [PhysicsBodyEcsComponent, PositionEcsComponent, RotationEcsComponent], + void +> => ({ + query: [PhysicsBodyId, positionId, rotationId], + beforeQuery: () => { + Engine.update(engine, time.deltaTimeInMilliseconds); + }, + run: (result) => { + const [physicsBodyComponent, positionComponent, rotationComponent] = + result.components; if (physicsBodyComponent.physicsBody.isStatic) { Body.setPosition(physicsBodyComponent.physicsBody, { @@ -49,5 +42,5 @@ export class PhysicsSystem extends System { rotationComponent.world = physicsBodyComponent.physicsBody.angle; } - } -} + }, +}); diff --git a/src/pooling/index.ts b/src/pooling/index.ts deleted file mode 100644 index b389814b..00000000 --- a/src/pooling/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './object-pool.js'; diff --git a/src/pooling/object-pool.test.ts b/src/pooling/object-pool.test.ts deleted file mode 100644 index 28ec168e..00000000 --- a/src/pooling/object-pool.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ObjectPool } from './object-pool'; - -describe('ObjectPool', () => { - let pool: ObjectPool; - let createCallback: () => number; - let disposeCallback: (instance: number) => void; - let hydrateCallback: (instance: number) => void; - - beforeEach(() => { - createCallback = vi.fn(() => Math.random()); - disposeCallback = vi.fn(); - hydrateCallback = vi.fn(); - pool = new ObjectPool({ - createCallback, - disposeCallback, - hydrateCallback, - }); - }); - - describe('create', () => { - it('should create a new instance when the pool is empty', () => { - const instance = pool.getOrCreate(); - - expect(createCallback).toHaveBeenCalled(); - expect(instance).toBeDefined(); - }); - - it('should create a new instance if the pool is empty and getOrCreate is called', () => { - const instance = pool.getOrCreate(); - - expect(createCallback).toHaveBeenCalled(); - expect(instance).toBeDefined(); - }); - - it('should not add newly created instances to the pool until released', () => { - const instance = pool.getOrCreate(); - - // Ensure the pool is still empty after creating a new instance - expect(() => pool.get()).toThrow('Pool is empty'); - - // Release the instance and ensure it is now in the pool - pool.release(instance); - const reusedInstance = pool.get(); - - expect(reusedInstance).toBe(instance); - }); - }); - - describe('release', () => { - it('should call the dispose callback when releasing an instance', () => { - const instance = pool.getOrCreate(); - pool.release(instance); - - expect(disposeCallback).toHaveBeenCalledWith(instance); - }); - - it('should add the instance back to the pool after release', () => { - const instance = pool.getOrCreate(); - pool.release(instance); - - const reusedInstance = pool.getOrCreate(); - expect(reusedInstance).toBe(instance); - }); - - it('should handle multiple releases correctly', () => { - const instance1 = pool.getOrCreate(); - const instance2 = pool.getOrCreate(); - - pool.release(instance1); - pool.release(instance2); - - const reusedInstance1 = pool.getOrCreate(); - const reusedInstance2 = pool.getOrCreate(); - - expect(reusedInstance1).toBe(instance2); // Last released is reused first - expect(reusedInstance2).toBe(instance1); - }); - }); - - describe('hydrate', () => { - it('should call the hydrate callback when getting from pool', () => { - const instance = pool.getOrCreate(); - pool.release(instance); - - // Create a fresh pool with spy to test hydrate callback - const hydrateSpy = vi.fn(); - const testPool = new ObjectPool({ - createCallback, - disposeCallback, - hydrateCallback: hydrateSpy, - }); - - const testInstance = testPool.getOrCreate(); - testPool.release(testInstance); - - const reusedInstance = testPool.get(); - - expect(hydrateSpy).toHaveBeenCalledWith(reusedInstance); - expect(hydrateSpy).toHaveBeenCalledTimes(1); - }); - - it('should not call hydrate callback when creating new instance', () => { - const hydrateSpy = vi.fn(); - const testPool = new ObjectPool({ - createCallback, - disposeCallback, - hydrateCallback: hydrateSpy, - }); - - testPool.getOrCreate(); - - expect(hydrateSpy).not.toHaveBeenCalled(); - }); - - it('should hydrate before returning instance from getOrCreate', () => { - const hydrateSpy = vi.fn(); - const testPool = new ObjectPool({ - createCallback, - disposeCallback, - hydrateCallback: hydrateSpy, - }); - - const instance = testPool.getOrCreate(); - testPool.release(instance); - - const reusedInstance = testPool.getOrCreate(); - - expect(hydrateSpy).toHaveBeenCalledWith(reusedInstance); - }); - }); - - describe('get', () => { - it('should throw an error when trying to get from an empty pool', () => { - expect(() => pool.get()).toThrow('Pool is empty'); - }); - - it('should reuse an instance from the pool if available', () => { - const instance1 = pool.getOrCreate(); - pool.release(instance1); - - const instance2 = pool.getOrCreate(); - - expect(createCallback).toHaveBeenCalledTimes(1); // Only called once - expect(instance2).toBe(instance1); // Reused instance - }); - - it('should return instances in LIFO order', () => { - const instance1 = pool.getOrCreate(); - const instance2 = pool.getOrCreate(); - const instance3 = pool.getOrCreate(); - - pool.release(instance1); - pool.release(instance2); - pool.release(instance3); - - expect(pool.get()).toBe(instance3); // Last released, first returned - expect(pool.get()).toBe(instance2); - expect(pool.get()).toBe(instance1); - }); - }); - - describe('initialization', () => { - it('should initialize with a starting pool', () => { - const startingPool = [1, 2, 3]; - const initializedPool = new ObjectPool( - { - createCallback, - disposeCallback, - hydrateCallback, - }, - startingPool, - ); - - expect(initializedPool.get()).toBe(3); // Last-in, first-out - expect(initializedPool.get()).toBe(2); - expect(initializedPool.get()).toBe(1); - expect(() => initializedPool.get()).toThrow('Pool is empty'); - }); - - it('should work without dispose callback', () => { - const poolWithoutDispose = new ObjectPool({ - createCallback, - }); - - const instance = poolWithoutDispose.getOrCreate(); - - // Should not throw when releasing without dispose callback - poolWithoutDispose.release(instance); - - // Verify instance was added back to pool - const reusedInstance = poolWithoutDispose.get(); - expect(reusedInstance).toBe(instance); - }); - - it('should work without hydrate callback', () => { - const poolWithoutHydrate = new ObjectPool({ - createCallback, - disposeCallback, - }); - - const instance = poolWithoutHydrate.getOrCreate(); - poolWithoutHydrate.release(instance); - - // Should not throw when getting without hydrate callback - const reusedInstance = poolWithoutHydrate.get(); - expect(reusedInstance).toBe(instance); - }); - }); -}); diff --git a/src/pooling/object-pool.ts b/src/pooling/object-pool.ts deleted file mode 100644 index bb38a441..00000000 --- a/src/pooling/object-pool.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Entity } from '../ecs/index.js'; - -type PoolCreateCallback = () => T; -type PoolDisposeCallback = (instance: T) => void; -type PoolHydrateCallback = (instance: T) => void; - -type PoolingOptions = { - createCallback: PoolCreateCallback; - disposeCallback?: PoolDisposeCallback; - hydrateCallback?: PoolHydrateCallback; -}; - -export class ObjectPool = Entity> { - private readonly _pool: Array; - private readonly _createCallback: PoolCreateCallback; - private readonly _disposeCallback?: PoolDisposeCallback; - private readonly _hydrateCallback?: PoolHydrateCallback; - - constructor(options: PoolingOptions, startingPool: Array = []) { - const { createCallback, disposeCallback, hydrateCallback } = options; - - this._createCallback = createCallback; - this._disposeCallback = disposeCallback; - this._hydrateCallback = hydrateCallback; - this._pool = startingPool; - } - - public getOrCreate = (): T => { - if (this._pool.length === 0) { - return this._create(); - } - - return this.get(); - }; - - public get = (): T => { - if (this._pool.length === 0) { - throw new Error('Pool is empty'); - } - - const item = this._pool.pop(); - - if (!item) { - throw new Error('Pooled item is undefined'); - } - - this._hydrateCallback?.(item); - - return item; - }; - - public release = (instance: T): void => { - this._disposeCallback?.(instance); - - this._pool.push(instance); - }; - - private readonly _create = (): T => { - const instance = this._createCallback(); - - return instance; - }; -} diff --git a/src/rendering/components/camera-component.ts b/src/rendering/components/camera-component.ts index 1d84546d..ec2402e7 100644 --- a/src/rendering/components/camera-component.ts +++ b/src/rendering/components/camera-component.ts @@ -1,116 +1,18 @@ -import { Component } from '../../ecs/index.js'; import { Axis1dAction, Axis2dAction } from '../../input/index.js'; import { Rect } from '../../math/index.js'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; -/** - * Options for configuring the `CameraComponent`. - */ -export type CameraComponentOptions = { - /** The current zoom level of the camera. */ +export interface CameraEcsComponent { zoom: number; - - /** The sensitivity of the zoom controls. */ zoomSensitivity: number; - - /** The sensitivity of the panning controls. */ panSensitivity: number; - - /** The minimum zoom level allowed. */ minZoom: number; - - /** The maximum zoom level allowed. */ maxZoom: number; - - /** Indicates if the camera is static (non-movable). */ isStatic: boolean; - - /** Optional input action for zooming the camera. */ + layerMask: number; + scissorRect?: Rect; zoomInput?: Axis1dAction; - - /** Optional input action for panning the camera. */ panInput?: Axis2dAction; - - /** Optional scissor rectangle to limit the camera's rendering area. */ - scissorRect?: Rect; -}; - -/** - * Default options for the `CameraComponent`. - */ -const defaultOptions: CameraComponentOptions = { - zoomSensitivity: 1, - panSensitivity: 3, - minZoom: 0.5, - maxZoom: 3, - isStatic: false, - zoom: 1, -}; - -/** - * The `CameraComponent` class implements the `Component` interface and represents - * a camera in the rendering system. It provides properties for zooming and panning - * sensitivity, as well as options to restrict zoom levels and enable/disable panning - * and zooming. - */ -export class CameraComponent extends Component { - /** The current zoom level of the camera. */ - public zoom: number; - - /** The sensitivity of the zoom controls. */ - public zoomSensitivity: number; - - /** The sensitivity of the panning controls. */ - public panSensitivity: number; - - /** The minimum zoom level allowed. */ - public minZoom: number; - - /** The maximum zoom level allowed. */ - public maxZoom: number; - - /** Indicates if the camera is static (non-movable). */ - public isStatic: boolean; - - public scissorRect?: Rect; - - public zoomInput?: Axis1dAction; - - public panInput?: Axis2dAction; - - /** - * Constructs a new instance of the `CameraComponent` class with the given options. - * @param options - Partial options to configure the camera component. - */ - constructor(options: Partial = defaultOptions) { - super(); - - const mergedOptions: CameraComponentOptions = { - ...defaultOptions, - ...options, - }; - - this.zoom = mergedOptions.zoom; - this.zoomSensitivity = mergedOptions.zoomSensitivity; - this.panSensitivity = mergedOptions.panSensitivity; - this.minZoom = mergedOptions.minZoom; - this.maxZoom = mergedOptions.maxZoom; - this.isStatic = mergedOptions.isStatic; - this.zoomInput = mergedOptions.zoomInput; - this.panInput = mergedOptions.panInput; - this.scissorRect = mergedOptions.scissorRect; - } - - /** - * Creates a default camera component with the option to set it as static. - * @param isStatic - Indicates if the camera should be static (default: false). - * @returns A new `CameraComponent` instance with default settings. - */ - public static createDefaultCamera( - isStatic: boolean = false, - ): CameraComponent { - const camera = new CameraComponent(); - camera.isStatic = isStatic; - - return camera; - } } + +export const cameraId = createComponentId('camera'); diff --git a/src/rendering/components/sprite-component.ts b/src/rendering/components/sprite-component.ts index a887e599..c895a764 100644 --- a/src/rendering/components/sprite-component.ts +++ b/src/rendering/components/sprite-component.ts @@ -1,26 +1,9 @@ -import { Component } from '../../ecs/index.js'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; import { Sprite } from '../sprite.js'; -/** - * The `SpriteComponent` class implements the `Component` interface and represents - * a component that contains a `Sprite`. - */ -export class SpriteComponent extends Component { - /** The `Sprite` instance associated with this component. */ - public sprite: Sprite; - - /** Indicates whether the sprite is enabled or not. */ - public enabled: boolean; - - /** - * Constructs a new instance of the `SpriteComponent` class with the given `Sprite`. - * @param sprite - The `Sprite` instance to associate with this component. - * @param enabled - Indicates whether the sprite is enabled or not (default: true). - */ - constructor(sprite: Sprite, enabled: boolean = true) { - super(); - - this.sprite = sprite; - this.enabled = enabled; - } +export interface SpriteEcsComponent { + sprite: Sprite; + enabled: boolean; } + +export const spriteId = createComponentId('sprite'); diff --git a/src/rendering/index.ts b/src/rendering/index.ts index bd9ff35a..9686428a 100644 --- a/src/rendering/index.ts +++ b/src/rendering/index.ts @@ -1,5 +1,4 @@ export * from './components/index.js'; -export * from './render-layers/index.js'; export * from './sprite.js'; export * from './systems/index.js'; export * from './shaders/index.js'; diff --git a/src/rendering/render-layers/index.ts b/src/rendering/render-layers/index.ts deleted file mode 100644 index 5839423a..00000000 --- a/src/rendering/render-layers/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './render-layer-component.js'; -export * from './instance-batch.js'; -export * from './render-layer.js'; diff --git a/src/rendering/render-layers/render-layer-component.ts b/src/rendering/render-layers/render-layer-component.ts deleted file mode 100644 index 24528c5e..00000000 --- a/src/rendering/render-layers/render-layer-component.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Component } from '../../ecs/index.js'; -import { RenderLayer } from './render-layer.js'; - -/** - * The `ForgeRenderLayer` class represents a rendering layer with its own canvas and WebGL context. - */ -export class RenderLayerComponent extends Component { - /** The render layer associated with this component. */ - public renderLayer: RenderLayer; - - /** The order of the render layer (lower numbers are rendered first). */ - public order: number; - - /** - * Constructs a new instance of the `RenderLayerComponent` class. - * @param renderLayer - The render layer associated with this component. - * @param order - The order of the render layer (lower numbers are rendered first). - * @throws An error if the WebGL2 context is not found. - */ - constructor(renderLayer: RenderLayer, order: number = 0) { - super(); - - this.renderLayer = renderLayer; - this.order = order; - } -} diff --git a/src/rendering/render-layers/render-layer.test.ts b/src/rendering/render-layers/render-layer.test.ts deleted file mode 100644 index a0a328ff..00000000 --- a/src/rendering/render-layers/render-layer.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { RenderLayer } from './render-layer'; -import { Entity, World } from '../../ecs'; -import { Renderable } from '../renderable'; - -describe('RenderLayer', () => { - let renderLayer: RenderLayer; - let world: World; - let entity1: Entity; - let entity2: Entity; - let mockRenderable: Renderable; - - beforeEach(() => { - world = new World('test'); - entity1 = new Entity(world, []); - entity2 = new Entity(world, []); - - // Create a minimal mock Renderable with required properties - mockRenderable = { - geometry: {}, - material: {}, - cameraEntity: entity1, - floatsPerInstance: 16, - bindInstanceData: () => {}, - setupInstanceAttributes: () => {}, - bind: () => {}, - } as unknown as Renderable; - }); - - describe('constructor', () => { - it('should create a RenderLayer with default sortEntities as false', () => { - renderLayer = new RenderLayer(); - - expect(renderLayer.sortEntities).toBe(false); - expect(renderLayer.renderables).toBeInstanceOf(Map); - expect(renderLayer.renderables.size).toBe(0); - }); - - it('should create a RenderLayer with sortEntities set to true', () => { - renderLayer = new RenderLayer(true); - - expect(renderLayer.sortEntities).toBe(true); - expect(renderLayer.renderables).toBeInstanceOf(Map); - }); - - it('should create a RenderLayer with sortEntities set to false explicitly', () => { - renderLayer = new RenderLayer(false); - - expect(renderLayer.sortEntities).toBe(false); - }); - }); - - describe('addEntity', () => { - beforeEach(() => { - renderLayer = new RenderLayer(); - }); - - it('should add an entity to a new renderable', () => { - renderLayer.addEntity(mockRenderable, entity1); - - expect(renderLayer.renderables.has(mockRenderable)).toBe(true); - const batch = renderLayer.renderables.get(mockRenderable); - expect(batch).toBeDefined(); - expect(batch!.entities.has(entity1)).toBe(true); - expect(batch!.entities.size).toBe(1); - }); - - it('should add multiple entities to the same renderable', () => { - renderLayer.addEntity(mockRenderable, entity1); - renderLayer.addEntity(mockRenderable, entity2); - - const batch = renderLayer.renderables.get(mockRenderable); - expect(batch).toBeDefined(); - expect(batch!.entities.has(entity1)).toBe(true); - expect(batch!.entities.has(entity2)).toBe(true); - expect(batch!.entities.size).toBe(2); - }); - - it('should create separate batches for different renderables', () => { - const mockRenderable2 = { ...mockRenderable } as Renderable; - - renderLayer.addEntity(mockRenderable, entity1); - renderLayer.addEntity(mockRenderable2, entity2); - - expect(renderLayer.renderables.size).toBe(2); - expect( - renderLayer.renderables.get(mockRenderable)!.entities.has(entity1), - ).toBe(true); - expect( - renderLayer.renderables.get(mockRenderable2)!.entities.has(entity2), - ).toBe(true); - }); - - it('should handle adding the same entity multiple times to the same renderable', () => { - renderLayer.addEntity(mockRenderable, entity1); - renderLayer.addEntity(mockRenderable, entity1); - - const batch = renderLayer.renderables.get(mockRenderable); - // Set should only contain one instance of entity1 - expect(batch!.entities.size).toBe(1); - expect(batch!.entities.has(entity1)).toBe(true); - }); - }); - - describe('removeEntity', () => { - beforeEach(() => { - renderLayer = new RenderLayer(); - }); - - it('should remove an entity from a renderable', () => { - renderLayer.addEntity(mockRenderable, entity1); - renderLayer.removeEntity(mockRenderable, entity1); - - // The batch should be removed when empty - expect(renderLayer.renderables.has(mockRenderable)).toBe(false); - }); - - it('should keep the batch if other entities remain', () => { - renderLayer.addEntity(mockRenderable, entity1); - renderLayer.addEntity(mockRenderable, entity2); - renderLayer.removeEntity(mockRenderable, entity1); - - expect(renderLayer.renderables.has(mockRenderable)).toBe(true); - const batch = renderLayer.renderables.get(mockRenderable); - expect(batch!.entities.has(entity1)).toBe(false); - expect(batch!.entities.has(entity2)).toBe(true); - expect(batch!.entities.size).toBe(1); - }); - - it('should remove the batch when the last entity is removed', () => { - renderLayer.addEntity(mockRenderable, entity1); - renderLayer.addEntity(mockRenderable, entity2); - renderLayer.removeEntity(mockRenderable, entity1); - renderLayer.removeEntity(mockRenderable, entity2); - - expect(renderLayer.renderables.has(mockRenderable)).toBe(false); - expect(renderLayer.renderables.size).toBe(0); - }); - - it('should handle removing an entity from a non-existent renderable gracefully', () => { - expect(() => { - renderLayer.removeEntity(mockRenderable, entity1); - }).not.toThrow(); - - expect(renderLayer.renderables.size).toBe(0); - }); - - it('should handle removing a non-existent entity from an existing renderable gracefully', () => { - renderLayer.addEntity(mockRenderable, entity1); - - expect(() => { - renderLayer.removeEntity(mockRenderable, entity2); - }).not.toThrow(); - - const batch = renderLayer.renderables.get(mockRenderable); - expect(batch!.entities.size).toBe(1); - expect(batch!.entities.has(entity1)).toBe(true); - }); - }); - - describe('renderables property', () => { - beforeEach(() => { - renderLayer = new RenderLayer(); - }); - - it('should be readonly and not allow reassignment', () => { - const originalMap = renderLayer.renderables; - renderLayer.addEntity(mockRenderable, entity1); - - // The same map instance should be used - expect(renderLayer.renderables).toBe(originalMap); - }); - }); -}); diff --git a/src/rendering/render-layers/render-layer.ts b/src/rendering/render-layers/render-layer.ts deleted file mode 100644 index b612d6f1..00000000 --- a/src/rendering/render-layers/render-layer.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Entity } from '../../ecs/index.js'; -import { Renderable } from '../renderable.js'; -import { InstanceBatch } from './instance-batch.js'; - -/** - * Manages batches of entities for efficient instanced rendering. - */ -export class RenderLayer { - /** The map of renderables to their associated instance batches. */ - public readonly renderables: Map; - - /** Whether to sort entities by their y position before rendering. */ - public sortEntities: boolean; - - /** - * Creates a new RenderLayer instance. - * @param sortEntities - Whether to sort entities by their y position before rendering. Defaults to false. - */ - constructor(sortEntities: boolean = false) { - this.sortEntities = sortEntities; - this.renderables = new Map(); - } - - /** - * Adds an entity to this render layer for a specific renderable. - * @param renderable - The renderable that defines how the entity should be rendered. - * @param entity - The entity to add to this layer. - */ - public addEntity(renderable: Renderable, entity: Entity): void { - if (!this.renderables.has(renderable)) { - this.renderables.set(renderable, new InstanceBatch()); - } - - this.renderables.get(renderable)!.entities.add(entity); - - entity.onRemovedFromWorld.registerListener(() => { - this.removeEntity(renderable, entity); - }); - } - - /** - * Removes an entity from this render layer for a specific renderable. - * @param renderable - The renderable associated with the entity. - * @param entity - The entity to remove from this layer. - */ - public removeEntity(renderable: Renderable, entity: Entity): void { - if (this.renderables.has(renderable)) { - this.renderables.get(renderable)!.entities.delete(entity); - - if (this.renderables.get(renderable)!.entities.size === 0) { - this.renderables.delete(renderable); - } - } - } -} diff --git a/src/rendering/renderable.test.ts b/src/rendering/renderable.test.ts index 007701f7..3f21d9b9 100644 --- a/src/rendering/renderable.test.ts +++ b/src/rendering/renderable.test.ts @@ -1,13 +1,12 @@ import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { Entity } from '../ecs/entity.js'; import { Geometry } from './geometry/geometry.js'; import { Material } from './materials/material.js'; import { Renderable } from './renderable.js'; +import { EcsWorld } from '../new-ecs/ecs-world.js'; describe('Renderable', () => { let mockGeometry: Geometry; let mockMaterial: Material; - let mockCameraEntity: Entity; let mockGl: WebGL2RenderingContext; let mockProgram: WebGLProgram; @@ -29,9 +28,6 @@ describe('Renderable', () => { program: mockProgram, } as unknown as Material; - // Create mock camera entity - mockCameraEntity = {} as Entity; - // Create mock WebGL context mockGl = {} as WebGL2RenderingContext; @@ -45,8 +41,8 @@ describe('Renderable', () => { const renderable = new Renderable( mockGeometry, mockMaterial, - mockCameraEntity, 10, + 0, mockBindInstanceData, mockSetupInstanceAttributes, ); @@ -58,8 +54,8 @@ describe('Renderable', () => { const renderable = new Renderable( mockGeometry, mockMaterial, - mockCameraEntity, 10, + 0, mockBindInstanceData, mockSetupInstanceAttributes, ); @@ -68,24 +64,25 @@ describe('Renderable', () => { }); it('should initialize with provided camera entity', () => { + const layer = 5; const renderable = new Renderable( mockGeometry, mockMaterial, - mockCameraEntity, 10, + layer, mockBindInstanceData, mockSetupInstanceAttributes, ); - expect(renderable.cameraEntity).toBe(mockCameraEntity); + expect(renderable.layer).toBe(layer); }); it('should initialize with provided floatsPerInstance', () => { const renderable = new Renderable( mockGeometry, mockMaterial, - mockCameraEntity, 17, + 0, mockBindInstanceData, mockSetupInstanceAttributes, ); @@ -97,8 +94,8 @@ describe('Renderable', () => { const renderable = new Renderable( mockGeometry, mockMaterial, - mockCameraEntity, 10, + 0, mockBindInstanceData, mockSetupInstanceAttributes, ); @@ -110,8 +107,8 @@ describe('Renderable', () => { const renderable = new Renderable( mockGeometry, mockMaterial, - mockCameraEntity, 10, + 0, mockBindInstanceData, mockSetupInstanceAttributes, ); @@ -123,18 +120,19 @@ describe('Renderable', () => { it('should initialize all properties correctly', () => { const floatsPerInstance = 15; + const layer = 3; const renderable = new Renderable( mockGeometry, mockMaterial, - mockCameraEntity, floatsPerInstance, + layer, mockBindInstanceData, mockSetupInstanceAttributes, ); expect(renderable.geometry).toBe(mockGeometry); expect(renderable.material).toBe(mockMaterial); - expect(renderable.cameraEntity).toBe(mockCameraEntity); + expect(renderable.layer).toBe(layer); expect(renderable.floatsPerInstance).toBe(floatsPerInstance); expect(renderable.bindInstanceData).toBe(mockBindInstanceData); expect(renderable.setupInstanceAttributes).toBe( @@ -148,8 +146,8 @@ describe('Renderable', () => { const renderable = new Renderable( mockGeometry, mockMaterial, - mockCameraEntity, 10, + 0, mockBindInstanceData, mockSetupInstanceAttributes, ); @@ -163,8 +161,8 @@ describe('Renderable', () => { const renderable = new Renderable( mockGeometry, mockMaterial, - mockCameraEntity, 10, + 0, mockBindInstanceData, mockSetupInstanceAttributes, ); @@ -178,8 +176,8 @@ describe('Renderable', () => { const renderable = new Renderable( mockGeometry, mockMaterial, - mockCameraEntity, 10, + 0, mockBindInstanceData, mockSetupInstanceAttributes, ); @@ -200,30 +198,36 @@ describe('Renderable', () => { describe('callbacks', () => { it('should allow bindInstanceData callback to be called', () => { - const entity = {} as Entity; + const entity = 1; + const world = new EcsWorld(); const buffer = new Float32Array(10); const offset = 5; const renderable = new Renderable( mockGeometry, mockMaterial, - mockCameraEntity, 10, + 0, mockBindInstanceData, mockSetupInstanceAttributes, ); - renderable.bindInstanceData(entity, buffer, offset); + renderable.bindInstanceData(entity, world, buffer, offset); - expect(mockBindInstanceData).toHaveBeenCalledWith(entity, buffer, offset); + expect(mockBindInstanceData).toHaveBeenCalledWith( + entity, + world, + buffer, + offset, + ); }); it('should allow setupInstanceAttributes callback to be called', () => { const renderable = new Renderable( mockGeometry, mockMaterial, - mockCameraEntity, 10, + 0, mockBindInstanceData, mockSetupInstanceAttributes, ); @@ -242,8 +246,8 @@ describe('Renderable', () => { const renderable = new Renderable( mockGeometry, mockMaterial, - mockCameraEntity, 10, + 0, mockBindInstanceData, mockSetupInstanceAttributes, ); @@ -258,8 +262,8 @@ describe('Renderable', () => { const renderable = new Renderable( mockGeometry, mockMaterial, - mockCameraEntity, 10, + 0, mockBindInstanceData, mockSetupInstanceAttributes, ); @@ -273,8 +277,8 @@ describe('Renderable', () => { const renderable = new Renderable( mockGeometry, mockMaterial, - mockCameraEntity, 10, + 0, mockBindInstanceData, mockSetupInstanceAttributes, ); diff --git a/src/rendering/renderable.ts b/src/rendering/renderable.ts index 13738809..3a62831c 100644 --- a/src/rendering/renderable.ts +++ b/src/rendering/renderable.ts @@ -1,4 +1,4 @@ -import { Entity } from '../ecs/entity.js'; +import { EcsWorld } from '../new-ecs/ecs-world.js'; import type { Geometry } from './geometry/index.js'; import type { Material } from './materials/index.js'; @@ -11,7 +11,8 @@ import type { Material } from './materials/index.js'; * @param offset - The offset within the buffer where this instance's data should start */ type BindInstanceDataCallback = ( - entity: Entity, + entity: number, + world: EcsWorld, instanceDataBuffer: Float32Array, offset: number, ) => void; @@ -76,11 +77,8 @@ export class Renderable { */ public readonly floatsPerInstance: number; - /** - * The camera entity used to view this renderable. - * The camera's position, zoom, and other properties affect how the renderable is displayed. - */ - public readonly cameraEntity: Entity; + /** The rendering layer this renderable belongs to. */ + public layer: number; /** * Callback function that binds instance-specific data for an entity into a buffer. @@ -107,15 +105,15 @@ export class Renderable { constructor( geometry: Geometry, material: Material, - cameraEntity: Entity, floatsPerInstance: number, + layer: number, bindInstanceData: BindInstanceDataCallback, setupInstanceAttributes: SetupInstanceAttributes, ) { this.geometry = geometry; this.material = material; - this.cameraEntity = cameraEntity; this.floatsPerInstance = floatsPerInstance; + this.layer = layer; this.bindInstanceData = bindInstanceData; this.setupInstanceAttributes = setupInstanceAttributes; } diff --git a/src/rendering/shaders/sprite/sprite.frag.glsl b/src/rendering/shaders/sprite/sprite.frag.glsl index 61ace6b0..7944984e 100644 --- a/src/rendering/shaders/sprite/sprite.frag.glsl +++ b/src/rendering/shaders/sprite/sprite.frag.glsl @@ -11,6 +11,8 @@ in vec4 v_tint; // Tint color out vec4 fragColor; // Output color void main() { + vec4 tex = texture(u_texture, v_texCoord); - fragColor = vec4(tex.rgb * v_tint.rgb, tex.a); + fragColor = tex; + // fragColor = vec4(tex.rgb * v_tint.rgb, tex.a); } diff --git a/src/rendering/systems/camera-system.test.ts b/src/rendering/systems/camera-system.test.ts index 64fce175..50573632 100644 --- a/src/rendering/systems/camera-system.test.ts +++ b/src/rendering/systems/camera-system.test.ts @@ -1,66 +1,109 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { CameraSystem } from './camera-system'; -import { Entity, World } from '../../ecs'; +import { createCameraEcsSystem } from './camera-system'; import { Axis1dAction, Axis2dAction } from '../../input'; -import { CameraComponent } from '../components'; -import { PositionComponent, Time } from '../../common'; +import { CameraEcsComponent, cameraId } from '../components'; +import { PositionEcsComponent, positionId, Time } from '../../common'; +import { EcsWorld } from '../../new-ecs'; +import { Vector2 } from '../../math'; describe('CameraSystem', () => { - let cameraSystem: CameraSystem; - let entity: Entity; - let cameraComponent: CameraComponent; - let positionComponent: PositionComponent; - let world: World; + let world: EcsWorld; let time: Time; let zoomInput: Axis1dAction; let panInput: Axis2dAction; beforeEach(() => { time = new Time(); - world = new World('test'); + world = new EcsWorld(); panInput = new Axis2dAction('pan', 'default'); zoomInput = new Axis1dAction('zoom', 'default'); - cameraSystem = new CameraSystem(time); - - cameraComponent = new CameraComponent({ - panInput, - zoomInput, - }); - positionComponent = new PositionComponent(); - - entity = new Entity(world, [cameraComponent, positionComponent]); - - world.addEntity(entity); - world.addSystem(cameraSystem); + world.addSystem(createCameraEcsSystem(time)); }); it('should update the camera zoom(out) based on scroll input', () => { - cameraComponent.minZoom = 0.00001; - cameraComponent.maxZoom = 10000; + const entity = world.createEntity(); + + const cameraComponent: CameraEcsComponent = { + zoom: 1, + isStatic: false, + zoomInput: zoomInput, + panInput: panInput, + zoomSensitivity: 0.1, + panSensitivity: 1, + minZoom: 0.000001, + maxZoom: 10000, + layerMask: 0xffffffff, + }; + + const positionComponent: PositionEcsComponent = { + local: Vector2.zero, + world: Vector2.zero, + }; + + world.addComponent(entity, cameraId, cameraComponent); + world.addComponent(entity, positionId, positionComponent); zoomInput.set(1); time.update(16.6666); world.update(); - expect(cameraComponent.zoom).toBe(0.5); + expect(cameraComponent.zoom).toBe(0.9090909090909091); }); it('should update the camera zoom(in) based on scroll input', () => { - cameraComponent.minZoom = 0.00001; - cameraComponent.maxZoom = 10000; + const entity = world.createEntity(); + + const cameraComponent: CameraEcsComponent = { + zoom: 1, + isStatic: false, + zoomInput: zoomInput, + panInput: panInput, + zoomSensitivity: 0.1, + panSensitivity: 1, + minZoom: 0.000001, + maxZoom: 10000, + layerMask: 0xffffffff, + }; + + const positionComponent: PositionEcsComponent = { + local: Vector2.zero, + world: Vector2.zero, + }; + + world.addComponent(entity, cameraId, cameraComponent); + world.addComponent(entity, positionId, positionComponent); zoomInput.set(-1); time.update(16.6666); world.update(); - expect(cameraComponent.zoom).toBe(2); + expect(cameraComponent.zoom).toBe(1.1); }); it('should update the camera zoom(out) when scrolled twice', () => { - cameraComponent.minZoom = 0.00001; - cameraComponent.maxZoom = 10000; + const entity = world.createEntity(); + + const cameraComponent: CameraEcsComponent = { + zoom: 1, + isStatic: false, + zoomInput: zoomInput, + panInput: panInput, + zoomSensitivity: 0.1, + panSensitivity: 1, + minZoom: 0.000001, + maxZoom: 10000, + layerMask: 0xffffffff, + }; + + const positionComponent: PositionEcsComponent = { + local: Vector2.zero, + world: Vector2.zero, + }; + + world.addComponent(entity, cameraId, cameraComponent); + world.addComponent(entity, positionId, positionComponent); zoomInput.set(1); time.update(16.6666); @@ -70,12 +113,31 @@ describe('CameraSystem', () => { time.update(16.6666); world.update(); - expect(cameraComponent.zoom).toBe(0.25); + expect(cameraComponent.zoom).toBe(0.8264462809917354); }); it('should return to the same position when zooming in and out again', () => { - cameraComponent.minZoom = 0.00001; - cameraComponent.maxZoom = 10000; + const entity = world.createEntity(); + + const cameraComponent: CameraEcsComponent = { + zoom: 1, + isStatic: false, + zoomInput: zoomInput, + panInput: panInput, + zoomSensitivity: 0.1, + panSensitivity: 1, + minZoom: 0.000001, + maxZoom: 10000, + layerMask: 0xffffffff, + }; + + const positionComponent: PositionEcsComponent = { + local: Vector2.zero, + world: Vector2.zero, + }; + + world.addComponent(entity, cameraId, cameraComponent); + world.addComponent(entity, positionId, positionComponent); zoomInput.set(-1); time.update(16.6666); @@ -97,9 +159,32 @@ describe('CameraSystem', () => { }); it('should clamp the camera zoom to the min and max zoom levels', () => { + const entity = world.createEntity(); + + const cameraComponent: CameraEcsComponent = { + zoom: 1, + isStatic: false, + zoomInput: zoomInput, + panInput: panInput, + zoomSensitivity: 0.1, + panSensitivity: 1, + minZoom: 0.000001, + maxZoom: 10000, + layerMask: 0xffffffff, + }; + + const positionComponent: PositionEcsComponent = { + local: Vector2.zero, + world: Vector2.zero, + }; + + world.addComponent(entity, cameraId, cameraComponent); + world.addComponent(entity, positionId, positionComponent); + zoomInput.set(2000); - cameraSystem.run(entity); + time.update(16.6666); + world.update(); expect(cameraComponent.zoom).toBe(cameraComponent.minZoom); @@ -112,6 +197,28 @@ describe('CameraSystem', () => { }); it('should update the camera position based on key inputs', () => { + const entity = world.createEntity(); + + const cameraComponent: CameraEcsComponent = { + zoom: 1, + isStatic: false, + zoomInput: zoomInput, + panInput: panInput, + zoomSensitivity: 0.1, + panSensitivity: 1, + minZoom: 0.000001, + maxZoom: 10000, + layerMask: 0xffffffff, + }; + + const positionComponent: PositionEcsComponent = { + local: Vector2.zero, + world: Vector2.zero, + }; + + world.addComponent(entity, cameraId, cameraComponent); + world.addComponent(entity, positionId, positionComponent); + panInput.set(50, -30); time.update(16.6666); @@ -122,7 +229,28 @@ describe('CameraSystem', () => { }); it('should not update the camera if it is static', () => { - cameraComponent.isStatic = true; + const entity = world.createEntity(); + + const cameraComponent: CameraEcsComponent = { + zoom: 1, + isStatic: true, + zoomInput: zoomInput, + panInput: panInput, + zoomSensitivity: 0.1, + panSensitivity: 1, + minZoom: 0.000001, + maxZoom: 10000, + layerMask: 0xffffffff, + }; + + const positionComponent: PositionEcsComponent = { + local: Vector2.zero, + world: Vector2.zero, + }; + + world.addComponent(entity, cameraId, cameraComponent); + world.addComponent(entity, positionId, positionComponent); + panInput.set(50, -30); zoomInput.set(-5000); diff --git a/src/rendering/systems/camera-system.ts b/src/rendering/systems/camera-system.ts index 74d3ba9d..d246d4b9 100644 --- a/src/rendering/systems/camera-system.ts +++ b/src/rendering/systems/camera-system.ts @@ -1,32 +1,19 @@ -import { PositionComponent, Time } from '../../common/index.js'; +import { PositionEcsComponent, positionId, Time } from '../../common/index.js'; import * as math from '../../math/index.js'; -import { Entity, System } from '../../ecs/index.js'; -import { CameraComponent } from '../components/index.js'; +import { CameraEcsComponent, cameraId } from '../components/index.js'; +import { EcsSystem } from '../../new-ecs/ecs-system.js'; /** - * The `CameraSystem` class manages the camera's - * zooming and panning based on user inputs. + * Creates a camera system that updates camera zoom and position based on input. + * @param time The time instance + * @returns The camera ECS system */ -export class CameraSystem extends System { - private readonly _time: Time; - - /** - * Constructs a new instance of the `CameraSystem` class. - * @param time - The `Time` instance for managing time-related operations. - */ - constructor(time: Time) { - super([CameraComponent, PositionComponent], 'camera'); - - this._time = time; - } - - /** - * Runs the camera system for the given entity, updating the camera's zoom and position - * based on user inputs. - * @param entity - The entity that contains the `CameraComponent` and `PositionComponent`. - */ - public run(entity: Entity): void { - const cameraComponent = entity.getComponentRequired(CameraComponent); +export const createCameraEcsSystem = ( + time: Time, +): EcsSystem<[CameraEcsComponent, PositionEcsComponent]> => ({ + query: [cameraId, positionId], + run: (result) => { + const [cameraComponent, position] = result.components; const { isStatic, @@ -42,8 +29,6 @@ export class CameraSystem extends System { return; } - const position = entity.getComponentRequired(PositionComponent); - if (zoomInput) { // Use multiplicative (exponential) scaling so scrolling has consistent effect // regardless of current zoom level. Positive zoomInput.value will reduce zoom, @@ -56,10 +41,10 @@ export class CameraSystem extends System { const zoomPanMultiplier = cameraComponent.panSensitivity * (1 / cameraComponent.zoom) * - this._time.rawDeltaTimeInMilliseconds; + time.rawDeltaTimeInMilliseconds; position.local.y += panInput.value.y * zoomPanMultiplier; position.local.x += panInput.value.x * zoomPanMultiplier; } - } -} + }, +}); diff --git a/src/rendering/render-layers/instance-batch.ts b/src/rendering/systems/instance-batch.ts similarity index 87% rename from src/rendering/render-layers/instance-batch.ts rename to src/rendering/systems/instance-batch.ts index 06a1b287..62017f5b 100644 --- a/src/rendering/render-layers/instance-batch.ts +++ b/src/rendering/systems/instance-batch.ts @@ -1,5 +1,3 @@ -import { Entity } from '../../ecs'; - export interface InstanceBatchOptions { initialBufferSize?: number; bufferGrowthFactor?: number; @@ -7,7 +5,7 @@ export interface InstanceBatchOptions { const defaultOptions: Required = { initialBufferSize: 1000, - bufferGrowthFactor: 2, + bufferGrowthFactor: 1.2, }; /** @@ -16,7 +14,7 @@ const defaultOptions: Required = { export class InstanceBatch { /** The set of entities in this batch. */ - public entities: Set; + public entities: number[]; /** The buffer holding instance data. */ public buffer: Float32Array; /** The initial size of the instance data array. */ @@ -30,7 +28,7 @@ export class InstanceBatch { ...options, }; - this.entities = new Set(); + this.entities = []; this.buffer = new Float32Array(initialBufferSize); this.initialBufferSize = initialBufferSize; this.bufferGrowthFactor = bufferGrowthFactor; diff --git a/src/rendering/systems/render-system.test.ts b/src/rendering/systems/render-system.test.ts deleted file mode 100644 index 9b9d61da..00000000 --- a/src/rendering/systems/render-system.test.ts +++ /dev/null @@ -1,606 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { RenderSystem } from './render-system'; -import { Entity, World } from '../../ecs'; -import { PositionComponent } from '../../common'; -import { CameraComponent } from '../components'; -import { RenderContext } from '../render-context'; -import { RenderLayerComponent } from '../render-layers/render-layer-component'; -import { RenderLayer } from '../render-layers/render-layer'; -import { InstanceBatch } from '../render-layers/instance-batch'; -import { Renderable } from '../renderable'; -import { Material } from '../materials'; -import { Geometry } from '../geometry'; -import { Rect, Vector2 } from '../../math'; - -describe('RenderSystem', () => { - let renderSystem: RenderSystem; - let renderContext: RenderContext; - let world: World; - let gl: WebGL2RenderingContext; - let canvas: HTMLCanvasElement; - - beforeEach(() => { - // Create a mock canvas - canvas = { - width: 800, - height: 600, - getContext: vi.fn(), - } as unknown as HTMLCanvasElement; - - // Create a mock WebGL2RenderingContext with all necessary methods - gl = { - /* eslint-disable @typescript-eslint/naming-convention */ - BLEND: 0x0be2, - SRC_ALPHA: 0x0302, - ONE_MINUS_SRC_ALPHA: 0x0303, - COLOR_BUFFER_BIT: 0x4000, - ARRAY_BUFFER: 0x8892, - DYNAMIC_DRAW: 0x88e8, - TRIANGLES: 0x0004, - SCISSOR_TEST: 0x0c11, - /* eslint-enable @typescript-eslint/naming-convention */ - enable: vi.fn(), - blendFunc: vi.fn(), - clear: vi.fn(), - bindVertexArray: vi.fn(), - bindBuffer: vi.fn(), - bufferData: vi.fn(), - drawArraysInstanced: vi.fn(), - scissor: vi.fn(), - createBuffer: vi.fn().mockReturnValue({}), - canvas, - } as unknown as WebGL2RenderingContext; - - // Create a mock RenderContext - renderContext = { - gl, - canvas, - shaderCache: new Map(), - imageCache: new Map(), - instanceBuffer: {}, - clearStrategy: 'blank', - setGlobalUniformValue: vi.fn(), - getGlobalUniformValue: vi.fn(), - } as unknown as RenderContext; - - world = new World('test'); - renderSystem = new RenderSystem(renderContext); - }); - - describe('constructor', () => { - it('should create a RenderSystem with correct query', () => { - expect(renderSystem.query).toEqual([RenderLayerComponent]); - expect(renderSystem.name).toBe('renderer'); - }); - - it('should enable blending on initialization', () => { - expect(gl.enable).toHaveBeenCalledWith(gl.BLEND); - expect(gl.blendFunc).toHaveBeenCalledWith( - gl.SRC_ALPHA, - gl.ONE_MINUS_SRC_ALPHA, - ); - }); - }); - - describe('beforeAll', () => { - it('should sort entities by render layer order', () => { - const renderLayer1 = new RenderLayer(); - const renderLayer2 = new RenderLayer(); - const renderLayer3 = new RenderLayer(); - - const entity1 = new Entity(world, [ - new RenderLayerComponent(renderLayer1, 2), - ]); - const entity2 = new Entity(world, [ - new RenderLayerComponent(renderLayer2, 0), - ]); - const entity3 = new Entity(world, [ - new RenderLayerComponent(renderLayer3, 1), - ]); - - const entities = [entity1, entity2, entity3]; - const sortedEntities = renderSystem.beforeAll(entities); - - expect(sortedEntities[0]).toBe(entity2); // order 0 - expect(sortedEntities[1]).toBe(entity3); // order 1 - expect(sortedEntities[2]).toBe(entity1); // order 2 - }); - - it('should clear the color buffer', () => { - const entities: Entity[] = []; - renderSystem.beforeAll(entities); - - expect(gl.clear).toHaveBeenCalledWith(gl.COLOR_BUFFER_BIT); - }); - - it('should return the sorted entities array', () => { - const renderLayer = new RenderLayer(); - const entity = new Entity(world, [ - new RenderLayerComponent(renderLayer, 0), - ]); - const entities = [entity]; - - const result = renderSystem.beforeAll(entities); - - expect(result).toBe(entities); - expect(result.length).toBe(1); - }); - }); - - describe('run', () => { - it('should bind vertex array to null after processing renderables', () => { - const renderLayer = new RenderLayer(); - const entity = new Entity(world, [ - new RenderLayerComponent(renderLayer, 0), - ]); - - renderSystem.run(entity); - - expect(gl.bindVertexArray).toHaveBeenCalledWith(null); - }); - - it('should process each renderable in the render layer', () => { - const renderLayer = new RenderLayer(); - const cameraEntity = new Entity(world, [ - new PositionComponent(0, 0), - new CameraComponent(), - ]); - - // Create mock material and geometry - const material = { - setUniform: vi.fn(), - bind: vi.fn(), - program: {}, - } as unknown as Material; - - const geometry = { - bind: vi.fn(), - } as unknown as Geometry; - - const renderable = new Renderable( - geometry, - material, - cameraEntity, - 16, // floatsPerInstance - vi.fn(), - vi.fn(), - ); - - const batch = new InstanceBatch(); - renderLayer.renderables.set(renderable, batch); - - const entity = new Entity(world, [ - new RenderLayerComponent(renderLayer, 0), - ]); - - renderSystem.run(entity); - - expect(gl.bindVertexArray).toHaveBeenCalledWith(null); - }); - - it('should not render when render layer has no renderables', () => { - const renderLayer = new RenderLayer(); - const entity = new Entity(world, [ - new RenderLayerComponent(renderLayer, 0), - ]); - - renderSystem.run(entity); - - expect(gl.drawArraysInstanced).not.toHaveBeenCalled(); - }); - }); - - describe('stop', () => { - it('should clear the color buffer when stopped', () => { - renderSystem.stop(); - - expect(gl.clear).toHaveBeenCalledWith(gl.COLOR_BUFFER_BIT); - }); - }); - - describe('_includeBatch', () => { - it('should skip empty batches', () => { - const cameraEntity = new Entity(world, [ - new PositionComponent(0, 0), - new CameraComponent(), - ]); - - const material = { - setUniform: vi.fn(), - bind: vi.fn(), - program: {}, - } as unknown as Material; - - const geometry = { - bind: vi.fn(), - } as unknown as Geometry; - - const renderable = new Renderable( - geometry, - material, - cameraEntity, - 16, - vi.fn(), - vi.fn(), - ); - - const batch = new InstanceBatch(); - const renderLayer = new RenderLayer(); - renderLayer.renderables.set(renderable, batch); - - const entity = new Entity(world, [ - new RenderLayerComponent(renderLayer, 0), - ]); - - renderSystem.run(entity); - - // Should not call draw methods for empty batch - expect(gl.drawArraysInstanced).not.toHaveBeenCalled(); - }); - - it('should set up projection matrix and bind material', () => { - const cameraEntity = new Entity(world, [ - new PositionComponent(10, 20), - new CameraComponent(), - ]); - - const material = { - setUniform: vi.fn(), - bind: vi.fn(), - program: {}, - } as unknown as Material; - - const geometry = { - bind: vi.fn(), - } as unknown as Geometry; - - const bindInstanceData = vi.fn(); - const setupInstanceAttributes = vi.fn(); - - const renderable = new Renderable( - geometry, - material, - cameraEntity, - 16, - bindInstanceData, - setupInstanceAttributes, - ); - - const batch = new InstanceBatch(); - const renderEntity = new Entity(world, [new PositionComponent(5, 5)]); - batch.entities.add(renderEntity); - - const renderLayer = new RenderLayer(); - renderLayer.renderables.set(renderable, batch); - - const entity = new Entity(world, [ - new RenderLayerComponent(renderLayer, 0), - ]); - - renderSystem.run(entity); - - // Verify the projection matrix was set (it's a Matrix3x3 object) - expect(material.setUniform).toHaveBeenCalledTimes(1); - const calls = vi.mocked(material.setUniform).mock.calls; - expect(calls[0][0]).toBe('u_projection'); - expect(calls[0][1]).toBeDefined(); - expect(typeof calls[0][1]).toBe('object'); - expect(material.bind).toHaveBeenCalledWith(gl); - expect(geometry.bind).toHaveBeenCalledWith(gl, material.program); - }); - - it('should enable scissor test when camera has scissor rect', () => { - const camera = new CameraComponent(); - camera.scissorRect = new Rect( - new Vector2(0.25, 0.25), - new Vector2(0.5, 0.5), - ); - - const cameraEntity = new Entity(world, [ - new PositionComponent(0, 0), - camera, - ]); - - const material = { - setUniform: vi.fn(), - bind: vi.fn(), - program: {}, - } as unknown as Material; - - const geometry = { - bind: vi.fn(), - } as unknown as Geometry; - - const renderable = new Renderable( - geometry, - material, - cameraEntity, - 16, - vi.fn(), - vi.fn(), - ); - - const batch = new InstanceBatch(); - const renderEntity = new Entity(world, [new PositionComponent(0, 0)]); - batch.entities.add(renderEntity); - - const renderLayer = new RenderLayer(); - renderLayer.renderables.set(renderable, batch); - - const entity = new Entity(world, [ - new RenderLayerComponent(renderLayer, 0), - ]); - - renderSystem.run(entity); - - expect(gl.enable).toHaveBeenCalledWith(gl.SCISSOR_TEST); - expect(gl.scissor).toHaveBeenCalledWith( - Math.floor(0.25 * 800), // origin.x * canvas.width - Math.floor(0.25 * 600), // origin.y * canvas.height - Math.floor(0.5 * 800), // size.x * canvas.width - Math.floor(0.5 * 600), // size.y * canvas.height - ); - }); - - it('should allocate buffer if not present', () => { - const cameraEntity = new Entity(world, [ - new PositionComponent(0, 0), - new CameraComponent(), - ]); - - const material = { - setUniform: vi.fn(), - bind: vi.fn(), - program: {}, - } as unknown as Material; - - const geometry = { - bind: vi.fn(), - } as unknown as Geometry; - - const bindInstanceData = vi.fn(); - const setupInstanceAttributes = vi.fn(); - - const renderable = new Renderable( - geometry, - material, - cameraEntity, - 16, // floatsPerInstance - bindInstanceData, - setupInstanceAttributes, - ); - - const batch = new InstanceBatch(); - const renderEntity = new Entity(world, [new PositionComponent(0, 0)]); - batch.entities.add(renderEntity); - - const renderLayer = new RenderLayer(); - renderLayer.renderables.set(renderable, batch); - - const entity = new Entity(world, [ - new RenderLayerComponent(renderLayer, 0), - ]); - - renderSystem.run(entity); - - expect(batch.buffer).toBeDefined(); - expect(batch.buffer.length).toBeGreaterThanOrEqual(16); // At least floatsPerInstance - }); - - it('should grow buffer if required size is larger', () => { - const cameraEntity = new Entity(world, [ - new PositionComponent(0, 0), - new CameraComponent(), - ]); - - const material = { - setUniform: vi.fn(), - bind: vi.fn(), - program: {}, - } as unknown as Material; - - const geometry = { - bind: vi.fn(), - } as unknown as Geometry; - - const renderable = new Renderable( - geometry, - material, - cameraEntity, - 16, - vi.fn(), - vi.fn(), - ); - - const batch = new InstanceBatch(); - batch.buffer = new Float32Array(10); // Small buffer - - const renderEntity = new Entity(world, [new PositionComponent(0, 0)]); - batch.entities.add(renderEntity); - - const renderLayer = new RenderLayer(); - renderLayer.renderables.set(renderable, batch); - - const entity = new Entity(world, [ - new RenderLayerComponent(renderLayer, 0), - ]); - - renderSystem.run(entity); - - expect(batch.buffer.length).toBeGreaterThan(10); // Buffer grew - }); - - it('should call bindInstanceData for each entity', () => { - const cameraEntity = new Entity(world, [ - new PositionComponent(0, 0), - new CameraComponent(), - ]); - - const material = { - setUniform: vi.fn(), - bind: vi.fn(), - program: {}, - } as unknown as Material; - - const geometry = { - bind: vi.fn(), - } as unknown as Geometry; - - const bindInstanceData = vi.fn(); - const setupInstanceAttributes = vi.fn(); - - const renderable = new Renderable( - geometry, - material, - cameraEntity, - 16, - bindInstanceData, - setupInstanceAttributes, - ); - - const batch = new InstanceBatch(); - const entity1 = new Entity(world, [new PositionComponent(1, 1)]); - const entity2 = new Entity(world, [new PositionComponent(2, 2)]); - batch.entities.add(entity1); - batch.entities.add(entity2); - - const renderLayer = new RenderLayer(); - renderLayer.renderables.set(renderable, batch); - - const entity = new Entity(world, [ - new RenderLayerComponent(renderLayer, 0), - ]); - - renderSystem.run(entity); - - expect(bindInstanceData).toHaveBeenCalledTimes(2); - expect(bindInstanceData).toHaveBeenCalledWith( - entity1, - expect.any(Float32Array), - 0, - ); - expect(bindInstanceData).toHaveBeenCalledWith( - entity2, - expect.any(Float32Array), - 16, - ); - }); - - it('should upload buffer data and draw instances', () => { - const cameraEntity = new Entity(world, [ - new PositionComponent(0, 0), - new CameraComponent(), - ]); - - const material = { - setUniform: vi.fn(), - bind: vi.fn(), - program: {}, - } as unknown as Material; - - const geometry = { - bind: vi.fn(), - } as unknown as Geometry; - - const setupInstanceAttributes = vi.fn(); - - const renderable = new Renderable( - geometry, - material, - cameraEntity, - 16, - vi.fn(), - setupInstanceAttributes, - ); - - const batch = new InstanceBatch(); - const renderEntity = new Entity(world, [new PositionComponent(0, 0)]); - batch.entities.add(renderEntity); - - const renderLayer = new RenderLayer(); - renderLayer.renderables.set(renderable, batch); - - const entity = new Entity(world, [ - new RenderLayerComponent(renderLayer, 0), - ]); - - renderSystem.run(entity); - - expect(gl.bindBuffer).toHaveBeenCalledWith( - gl.ARRAY_BUFFER, - renderContext.instanceBuffer, - ); - expect(gl.bufferData).toHaveBeenCalledWith( - gl.ARRAY_BUFFER, - expect.any(Float32Array), - gl.DYNAMIC_DRAW, - ); - expect(setupInstanceAttributes).toHaveBeenCalledWith(gl, renderable); - expect(gl.drawArraysInstanced).toHaveBeenCalledWith( - gl.TRIANGLES, - 0, - 6, - 1, // batch length - ); - }); - }); - - describe('integration', () => { - it('should render multiple entities with multiple render layers in order', () => { - const cameraEntity = new Entity(world, [ - new PositionComponent(0, 0), - new CameraComponent(), - ]); - - const material = { - setUniform: vi.fn(), - bind: vi.fn(), - program: {}, - } as unknown as Material; - - const geometry = { - bind: vi.fn(), - } as unknown as Geometry; - - const renderable = new Renderable( - geometry, - material, - cameraEntity, - 16, - vi.fn(), - vi.fn(), - ); - - // Create two render layers with different orders - const renderLayer1 = new RenderLayer(); - const batch1 = new InstanceBatch(); - batch1.entities.add(new Entity(world, [new PositionComponent(1, 1)])); - renderLayer1.renderables.set(renderable, batch1); - - const renderLayer2 = new RenderLayer(); - const batch2 = new InstanceBatch(); - batch2.entities.add(new Entity(world, [new PositionComponent(2, 2)])); - renderLayer2.renderables.set(renderable, batch2); - - const entity1 = new Entity(world, [ - new RenderLayerComponent(renderLayer1, 1), // Higher order - ]); - const entity2 = new Entity(world, [ - new RenderLayerComponent(renderLayer2, 0), // Lower order - ]); - - const entities = [entity1, entity2]; - const sortedEntities = renderSystem.beforeAll(entities); - - // Verify sorting - expect(sortedEntities[0]).toBe(entity2); - expect(sortedEntities[1]).toBe(entity1); - - // Run both - renderSystem.run(sortedEntities[0]); - renderSystem.run(sortedEntities[1]); - - expect(gl.drawArraysInstanced).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/src/rendering/systems/render-system.ts b/src/rendering/systems/render-system.ts index 8fa4832d..e8113dc1 100644 --- a/src/rendering/systems/render-system.ts +++ b/src/rendering/systems/render-system.ts @@ -1,184 +1,144 @@ -import { PositionComponent } from '../../common/index.js'; -import { Entity, System } from '../../ecs/index.js'; -import { CameraComponent } from '../components/index.js'; +import { PositionEcsComponent, positionId } from '../../common/index.js'; +import { Matrix3x3 } from '../../math/index.js'; +import { EcsSystem } from '../../new-ecs/ecs-system.js'; +import { EcsWorld } from '../../new-ecs/index.js'; +import { matchesLayerMask } from '../../utilities/matches-layer-mask.js'; +import { + CameraEcsComponent, + cameraId, + SpriteEcsComponent, + spriteId, +} from '../components/index.js'; import { RenderContext } from '../render-context.js'; -import { InstanceBatch, RenderLayerComponent } from '../render-layers/index.js'; import { Renderable } from '../renderable.js'; import { createProjectionMatrix } from '../shaders/index.js'; +import { InstanceBatch } from './instance-batch.js'; + +const setupInstanceAttributesAndDraw = ( + renderContext: RenderContext, + renderable: Renderable, + batchLength: number, +) => { + const { gl } = renderContext; + + gl.bindBuffer(gl.ARRAY_BUFFER, renderContext.instanceBuffer); + + renderable.setupInstanceAttributes(gl, renderable); + + gl.enable(gl.BLEND); // TODO: these might need to be material specific + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // TODO: these might need to be material specific & is already called in _setupGLState + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, batchLength); // TODO: still assumes a quad for sprites +}; + +const includeBatch = ( + renderable: Renderable, + batch: InstanceBatch, + renderContext: RenderContext, + world: EcsWorld, + projectionMatrix: Matrix3x3, +) => { + const { entities } = batch; + const { gl } = renderContext; + + if (entities.length === 0) { + return; + } -/** - * A system responsible for rendering entities with render layers to the screen. - */ -export class RenderSystem extends System { - private readonly _renderContext: RenderContext; + renderable.material.setUniform('u_projection', projectionMatrix); - /** - * Creates a new RenderSystem instance. - * - * @param renderContext - The rendering context. - */ - constructor(renderContext: RenderContext) { - super([RenderLayerComponent], 'renderer'); + renderable.bind(gl); - this._renderContext = renderContext; + const requiredBatchSize = entities.length * renderable.floatsPerInstance; - this._setupGLState(); - } - - /** - * Pre-processes entities before rendering. - * - * This method sorts entities by their render layer order (lower numbers are rendered first) - * and clears the color buffer to prepare for rendering. - * - * @param entities - The entities with render layers to be sorted. - * @returns The sorted array of entities. - */ - public override beforeAll(entities: Entity[]): Entity[] { - entities.sort((a, b) => { - const orderA = a.getComponentRequired(RenderLayerComponent).order; - const orderB = b.getComponentRequired(RenderLayerComponent).order; - - return orderA - orderB; - }); - - this._renderContext.gl.clear(this._renderContext.gl.COLOR_BUFFER_BIT); - - return entities; + if (!batch.buffer || batch.buffer.length < requiredBatchSize) { + batch.buffer = new Float32Array( + requiredBatchSize * batch.bufferGrowthFactor, + ); } - /** - * Renders all renderables in the entity's render layer. - * - * @param entity - The entity containing a {@link RenderLayerComponent}. - */ - public run(entity: Entity): void { - const layerComponent = entity.getComponentRequired(RenderLayerComponent); - - const { gl } = this._renderContext; + let instanceDataOffset = 0; - for (const [renderable, batch] of layerComponent.renderLayer.renderables) { - this._includeBatch(renderable, batch, gl); - } - - gl.bindVertexArray(null); - } + for (const entity of entities) { + renderable.bindInstanceData( + entity, + world, + batch.buffer, + instanceDataOffset, + ); - /** - * Cleans up the render system when stopped. - * - * Clears the color buffer to reset the rendering state. - * This is called when the system is stopped or removed from the world. - */ - public override stop(): void { - this._renderContext.gl.clear(this._renderContext.gl.COLOR_BUFFER_BIT); + instanceDataOffset += renderable.floatsPerInstance; } - /** - * Configures the WebGL state for rendering. - * - * Sets up alpha blending to support transparent sprites and other - * semi-transparent rendering. This is called once during system initialization. - * - * @private - */ - private _setupGLState(): void { - const { gl } = this._renderContext; - - gl.enable(gl.BLEND); // TODO: these might need to be material specific - gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // TODO: these might need to be material specific - } + // Upload instance transform buffer + gl.bindBuffer(gl.ARRAY_BUFFER, renderContext.instanceBuffer); + gl.bufferData(gl.ARRAY_BUFFER, batch.buffer, gl.DYNAMIC_DRAW); - /** - * Renders a batch of entities using instanced rendering. - * - * @param renderable - The renderable containing material, geometry, and camera. - * @param batch - The instance batch containing entities to render. - * @param gl - The WebGL2 rendering context. - * @private - */ - private _includeBatch( - renderable: Renderable, - batch: InstanceBatch, - gl: WebGL2RenderingContext, - ): void { - const { entities } = batch; - - if (entities.size === 0) { - return; - } + setupInstanceAttributesAndDraw(renderContext, renderable, entities.length); +}; - const { cameraEntity } = renderable; +const spriteEntityBuffer: number[] = []; +const renderables: Map = new Map(); - const cameraPosition = cameraEntity.getComponentRequired(PositionComponent); +/** + * Creates a render system that batches and renders sprites based on the camera view. + * @param renderContext The rendering context + * @returns The render ECS system + */ +export const createRenderEcsSystem = ( + renderContext: RenderContext, +): EcsSystem<[CameraEcsComponent, PositionEcsComponent], void> => ({ + query: [cameraId, positionId], + beforeQuery: (world) => world.queryEntities([spriteId], spriteEntityBuffer), + run: (result, world) => { + const [cameraComponent, positionComponent] = result.components; - const camera = cameraEntity.getComponentRequired(CameraComponent); + const { gl } = renderContext; const projectionMatrix = createProjectionMatrix( gl.canvas.width, gl.canvas.height, - cameraPosition.world, - camera.zoom, + positionComponent.world, + cameraComponent.zoom, ); - renderable.material.setUniform('u_projection', projectionMatrix); + for (const batch of renderables.values()) { + batch.entities.length = 0; + } - renderable.bind(gl); + for (const spriteEntity of spriteEntityBuffer) { + const spriteComponent = world.getComponent( + spriteEntity, + spriteId, + )!; - if (camera.scissorRect) { - gl.enable(gl.SCISSOR_TEST); - gl.scissor( - Math.floor(camera.scissorRect.origin.x * gl.canvas.width), - Math.floor(camera.scissorRect.origin.y * gl.canvas.height), - Math.floor(camera.scissorRect.size.x * gl.canvas.width), - Math.floor(camera.scissorRect.size.y * gl.canvas.height), - ); - } + if (!spriteComponent.enabled) { + continue; + } - const requiredBatchSize = entities.size * renderable.floatsPerInstance; + const { renderable } = spriteComponent.sprite; - if (!batch.buffer || batch.buffer.length < requiredBatchSize) { - batch.buffer = new Float32Array( - requiredBatchSize * batch.bufferGrowthFactor, + const layerMaskMatches = matchesLayerMask( + renderable.layer, + cameraComponent.layerMask, ); - } - let instanceDataOffset = 0; + if (!layerMaskMatches) { + continue; + } - for (const entity of entities) { - renderable.bindInstanceData(entity, batch.buffer, instanceDataOffset); + if (!renderables.has(renderable)) { + renderables.set(renderable, new InstanceBatch()); + } - instanceDataOffset += renderable.floatsPerInstance; - } + const batch = renderables.get(renderable)!; - // Upload instance transform buffer - gl.bindBuffer(gl.ARRAY_BUFFER, this._renderContext.instanceBuffer); - gl.bufferData(gl.ARRAY_BUFFER, batch.buffer, gl.DYNAMIC_DRAW); + batch.entities.push(spriteEntity); + } - this._setupInstanceAttributesAndDraw(gl, renderable, entities.size); - } + for (const [renderable, batch] of renderables) { + includeBatch(renderable, batch, renderContext, world, projectionMatrix); + } - /** - * Sets up instance attributes and performs the draw call. - * - * This method configures the vertex attribute pointers for instanced rendering - * and executes the draw call to render all instances in the batch. - * - * @param gl - The WebGL2 rendering context. - * @param renderable - The renderable containing setup information. - * @param batchLength - The number of instances to draw. - * @private - */ - private _setupInstanceAttributesAndDraw( - gl: WebGL2RenderingContext, - renderable: Renderable, - batchLength: number, - ) { - gl.bindBuffer(gl.ARRAY_BUFFER, this._renderContext.instanceBuffer); - - renderable.setupInstanceAttributes(gl, renderable); - - gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, batchLength); // TODO: still assumes a quad for sprites - gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // TODO: these might need to be material specific & is already called in _setupGLState - } -} + gl.bindVertexArray(null); + }, +}); diff --git a/src/rendering/utilities/add-camera.ts b/src/rendering/utilities/add-camera.ts index ddc36488..5a12f4ee 100644 --- a/src/rendering/utilities/add-camera.ts +++ b/src/rendering/utilities/add-camera.ts @@ -1,29 +1,38 @@ -import { PositionComponent, Time } from '../../common/index.js'; -import type { Entity, World } from '../../ecs/index.js'; -import { - CameraComponent, - type CameraComponentOptions, -} from '../components/index.js'; -import { CameraSystem } from '../systems/index.js'; +import { PositionEcsComponent, positionId } from '../../common/index.js'; +import { EcsWorld } from '../../new-ecs/ecs-world.js'; +import { Vector2 } from '../../math/index.js'; +import { CameraEcsComponent, cameraId } from '../components/index.js'; /** * Adds a camera entity to the world with the specified options. - * @param world - The world to which the camera will be added. - * @param time - The Time instance for managing time-related operations. + * + * @param world - The ECS world to which the camera will be added. * @param cameraOptions - Options for configuring the camera. - * @returns The entity that contains the `CameraComponent`. */ export function addCamera( - world: World, - time: Time, - cameraOptions: Partial, -): Entity { - const cameraEntity = world.buildAndAddEntity( - [new CameraComponent(cameraOptions), new PositionComponent(0, 0)], - { name: 'camera' }, - ); + world: EcsWorld, + cameraOptions: Partial = {}, +): void { + const cameraEntity = world.createEntity(); - world.addSystem(new CameraSystem(time)); + const cameraComponent: CameraEcsComponent = { + zoom: cameraOptions.zoom ?? 1, + zoomSensitivity: cameraOptions.zoomSensitivity ?? 0.1, + panSensitivity: cameraOptions.panSensitivity ?? 1, + minZoom: cameraOptions.minZoom ?? 0.1, + maxZoom: cameraOptions.maxZoom ?? 10, + isStatic: cameraOptions.isStatic ?? false, + layerMask: cameraOptions.layerMask ?? 0xffffffff, + scissorRect: cameraOptions.scissorRect, + zoomInput: cameraOptions.zoomInput, + panInput: cameraOptions.panInput, + }; - return cameraEntity; + const positionComponent: PositionEcsComponent = { + local: Vector2.zero, + world: Vector2.zero, + }; + + world.addComponent(cameraEntity, cameraId, cameraComponent); + world.addComponent(cameraEntity, positionId, positionComponent); } diff --git a/src/rendering/utilities/create-image-sprite.ts b/src/rendering/utilities/create-image-sprite.ts index db155acd..81506a32 100644 --- a/src/rendering/utilities/create-image-sprite.ts +++ b/src/rendering/utilities/create-image-sprite.ts @@ -1,4 +1,3 @@ -import { Entity } from '../../ecs/entity.js'; import { Material } from '../materials/index.js'; import { RenderContext } from '../render-context.js'; import { createTextureFromImage } from '../shaders/index.js'; @@ -17,7 +16,7 @@ import { createSprite } from './create-sprite.js'; export async function createImageSprite( imagePath: string, renderContext: RenderContext, - cameraEntity: Entity, + layer: number, vertexShaderName: string = 'sprite.vert', fragmentShaderName: string = 'sprite.frag', ): Promise { @@ -35,7 +34,7 @@ export async function createImageSprite( const sprite = createSprite( material, renderContext, - cameraEntity, + layer, image.width, image.height, ); diff --git a/src/rendering/utilities/create-sprite.ts b/src/rendering/utilities/create-sprite.ts index 02e4c063..9bb15dab 100644 --- a/src/rendering/utilities/create-sprite.ts +++ b/src/rendering/utilities/create-sprite.ts @@ -1,15 +1,23 @@ +import { + FlipEcsComponent, + flipId, + PositionEcsComponent, + positionId, + RotationEcsComponent, + rotationId, + ScaleEcsComponent, + scaleId, +} from '../../common/index.js'; import { AnimationFrame, - SpriteAnimationComponent, + SpriteAnimationEcsComponent, + spriteAnimationId, } from '../../animations/index.js'; +import { EcsWorld } from '../../new-ecs/ecs-world.js'; import { - FlipComponent, - PositionComponent, - RotationComponent, - ScaleComponent, -} from '../../common/index.js'; -import { Entity } from '../../ecs/entity.js'; -import { SpriteComponent } from '../components/sprite-component.js'; + SpriteEcsComponent, + spriteId, +} from '../components/sprite-component.js'; import { createQuadGeometry } from '../geometry/index.js'; import { Material } from '../materials/index.js'; import { RenderContext } from '../render-context.js'; @@ -39,21 +47,27 @@ import { setupInstanceAttribute } from './setup-instance-attribute.js'; const floatsPerInstance = 17; function bindInstanceData( - entity: Entity, + entity: number, + world: EcsWorld, instanceDataBufferArray: Float32Array, offset: number, ) { - const position = entity.getComponentRequired(PositionComponent); + const position = world.getComponent( + entity, + positionId, + )!; - const rotation = entity.getComponent(RotationComponent); + const rotation = world.getComponent(entity, rotationId); - const scale = entity.getComponent(ScaleComponent); + const scale = world.getComponent(entity, scaleId); - const spriteComponent = entity.getComponentRequired(SpriteComponent); - const flipComponent = entity.getComponent(FlipComponent); - const spriteAnimationComponent = entity.getComponent( - SpriteAnimationComponent, - ); + const spriteComponent = world.getComponent( + entity, + spriteId, + )!; + const flipComponent = world.getComponent(entity, flipId); + const spriteAnimationComponent = + world.getComponent(entity, spriteAnimationId); let animationFrame: AnimationFrame | null = null; @@ -65,7 +79,7 @@ function bindInstanceData( // Position instanceDataBufferArray[offset + POSITION_X_OFFSET] = position.world.x; - instanceDataBufferArray[offset + POSITION_Y_OFFSET] = position.world.y; + instanceDataBufferArray[offset + POSITION_Y_OFFSET] = -position.world.y; // Rotation instanceDataBufferArray[offset + ROTATION_OFFSET] = rotation?.world ?? 0; @@ -162,15 +176,15 @@ function setupInstanceAttributes( export function createSprite( material: Material, renderContext: RenderContext, - cameraEntity: Entity, + layer: number, width: number, height: number, ): Sprite { const renderable = new Renderable( createQuadGeometry(renderContext.gl), material, - cameraEntity, floatsPerInstance, + layer, bindInstanceData, setupInstanceAttributes, ); diff --git a/src/timer/components/timer-component.ts b/src/timer/components/timer-component.ts index 749e2ca2..a185fe56 100644 --- a/src/timer/components/timer-component.ts +++ b/src/timer/components/timer-component.ts @@ -1,4 +1,4 @@ -import { Component } from '../../ecs/index.js'; +import { createComponentId } from '../../new-ecs/ecs-component.js'; /** * Represents a single timer task that can execute a callback after a delay. @@ -41,58 +41,10 @@ export interface TimerTask { } /** - * Component that holds a list of timer tasks for an entity. - * Timer tasks can be one-shot (execute once after a delay) or repeating (execute periodically). - * - * @example - * ```typescript - * // Create entity with timer component - * const entity = world.buildAndAddEntity([new TimerComponent()]); - * const timer = entity.getComponentRequired(TimerComponent); - * - * // Add a one-shot timer (executes once after 1 second) - * timer.addTask({ - * callback: () => console.log('Timer fired!'), - * delay: 1000, - * elapsed: 0, - * }); - * - * // Add a repeating timer (executes every 500ms) - * timer.addTask({ - * callback: () => console.log('Tick!'), - * delay: 500, - * elapsed: 0, - * repeat: true, - * interval: 500, - * runsSoFar: 0, - * }); - * ``` + * ECS-style component interface for a timer component. */ -export class TimerComponent extends Component { - /** - * Array of timer tasks to be processed by the TimerSystem. - */ - public tasks: TimerTask[]; - - /** - * Creates a new TimerComponent. - * @param tasks - Optional initial array of timer tasks. - */ - constructor(tasks: TimerTask[] = []) { - super(); - - this.tasks = tasks; - } - - /** - * Adds a new timer task to the component. - * Automatically initializes elapsed to 0 and runsSoFar to 0 if not already set. - * @param task - The timer task to add. - */ - public addTask(task: TimerTask): void { - // Initialize defaults - task.elapsed = 0; - task.runsSoFar = task.runsSoFar ?? 0; - this.tasks.push(task); - } +export interface TimerEcsComponent { + tasks: TimerTask[]; } + +export const TimerId = createComponentId('Timer'); diff --git a/src/timer/systems/timer-system.test.ts b/src/timer/systems/timer-system.test.ts index e3145fe6..5f801dfd 100644 --- a/src/timer/systems/timer-system.test.ts +++ b/src/timer/systems/timer-system.test.ts @@ -1,34 +1,34 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { TimerSystem } from './timer-system'; -import { TimerComponent } from '../components/timer-component'; -import { Entity, World } from '../../ecs'; +import { createTimerEcsSystem } from './timer-system'; +import { TimerEcsComponent, TimerId } from '../components/timer-component'; +import { EcsWorld } from '../../new-ecs'; import { Time } from '../../common'; describe('TimerSystem', () => { let time: Time; - let timerSystem: TimerSystem; - let world: World; - let entity: Entity; - let timerComponent: TimerComponent; + let world: EcsWorld; + let entity: number; + let timerComponent: TimerEcsComponent; beforeEach(() => { time = new Time(); - timerSystem = new TimerSystem(time); - world = new World('test-world'); - timerComponent = new TimerComponent(); - entity = new Entity(world, [timerComponent]); + world = new EcsWorld(); + entity = world.createEntity(); + timerComponent = { tasks: [] }; + world.addComponent(entity, TimerId, timerComponent); + world.addSystem(createTimerEcsSystem(time)); }); it('should not execute when tasks list is empty', () => { time.update(16); - timerSystem.run(entity); + world.update(); expect(timerComponent.tasks.length).toBe(0); }); it('should execute one-shot timer after delay', () => { const callback = vi.fn(); - timerComponent.addTask({ + timerComponent.tasks.push({ callback, delay: 100, elapsed: 0, @@ -36,37 +36,37 @@ describe('TimerSystem', () => { // Before delay time.update(50); - timerSystem.run(entity); + world.update(); expect(callback).not.toHaveBeenCalled(); expect(timerComponent.tasks.length).toBe(1); // After delay time.update(100); - timerSystem.run(entity); + world.update(); expect(callback).toHaveBeenCalledTimes(1); expect(timerComponent.tasks.length).toBe(0); }); it('should track elapsed time correctly', () => { const callback = vi.fn(); - timerComponent.addTask({ + timerComponent.tasks.push({ callback, delay: 100, elapsed: 0, }); time.update(30); - timerSystem.run(entity); + world.update(); expect(timerComponent.tasks[0].elapsed).toBe(30); time.update(60); - timerSystem.run(entity); + world.update(); expect(timerComponent.tasks[0].elapsed).toBe(60); }); it('should execute repeating timer multiple times', () => { const callback = vi.fn(); - timerComponent.addTask({ + timerComponent.tasks.push({ callback, delay: 50, elapsed: 0, @@ -77,7 +77,7 @@ describe('TimerSystem', () => { // First execution time.update(60); - timerSystem.run(entity); + world.update(); expect(callback).toHaveBeenCalledTimes(1); expect(timerComponent.tasks.length).toBe(1); expect(timerComponent.tasks[0].elapsed).toBe(0); @@ -85,7 +85,7 @@ describe('TimerSystem', () => { // Second execution time.update(110); - timerSystem.run(entity); + world.update(); expect(callback).toHaveBeenCalledTimes(2); expect(timerComponent.tasks.length).toBe(1); expect(timerComponent.tasks[0].runsSoFar).toBe(2); @@ -93,7 +93,7 @@ describe('TimerSystem', () => { it('should use interval for subsequent runs after first delay', () => { const callback = vi.fn(); - timerComponent.addTask({ + timerComponent.tasks.push({ callback, delay: 100, elapsed: 0, @@ -104,19 +104,19 @@ describe('TimerSystem', () => { // First execution after initial delay time.update(110); - timerSystem.run(entity); + world.update(); expect(callback).toHaveBeenCalledTimes(1); expect(timerComponent.tasks[0].delay).toBe(50); // Should now use interval // Second execution after interval time.update(160); - timerSystem.run(entity); + world.update(); expect(callback).toHaveBeenCalledTimes(2); }); it('should stop repeating timer after maxRuns', () => { const callback = vi.fn(); - timerComponent.addTask({ + timerComponent.tasks.push({ callback, delay: 50, elapsed: 0, @@ -128,19 +128,19 @@ describe('TimerSystem', () => { // Run 1 time.update(60); - timerSystem.run(entity); + world.update(); expect(callback).toHaveBeenCalledTimes(1); expect(timerComponent.tasks.length).toBe(1); // Run 2 time.update(110); - timerSystem.run(entity); + world.update(); expect(callback).toHaveBeenCalledTimes(2); expect(timerComponent.tasks.length).toBe(1); // Run 3 (final run) time.update(160); - timerSystem.run(entity); + world.update(); expect(callback).toHaveBeenCalledTimes(3); expect(timerComponent.tasks.length).toBe(0); // Task removed }); @@ -150,19 +150,19 @@ describe('TimerSystem', () => { const callback2 = vi.fn(); const callback3 = vi.fn(); - timerComponent.addTask({ + timerComponent.tasks.push({ callback: callback1, delay: 50, elapsed: 0, }); - timerComponent.addTask({ + timerComponent.tasks.push({ callback: callback2, delay: 100, elapsed: 0, }); - timerComponent.addTask({ + timerComponent.tasks.push({ callback: callback3, delay: 150, elapsed: 0, @@ -170,7 +170,7 @@ describe('TimerSystem', () => { // Execute first task time.update(60); - timerSystem.run(entity); + world.update(); expect(callback1).toHaveBeenCalledTimes(1); expect(callback2).not.toHaveBeenCalled(); expect(callback3).not.toHaveBeenCalled(); @@ -178,35 +178,35 @@ describe('TimerSystem', () => { // Execute second task time.update(120); - timerSystem.run(entity); + world.update(); expect(callback2).toHaveBeenCalledTimes(1); expect(callback3).not.toHaveBeenCalled(); expect(timerComponent.tasks.length).toBe(1); // Execute third task time.update(180); - timerSystem.run(entity); + world.update(); expect(callback3).toHaveBeenCalledTimes(1); expect(timerComponent.tasks.length).toBe(0); }); it('should handle task execution when elapsed equals delay exactly', () => { const callback = vi.fn(); - timerComponent.addTask({ + timerComponent.tasks.push({ callback, delay: 100, elapsed: 0, }); time.update(100); - timerSystem.run(entity); + world.update(); expect(callback).toHaveBeenCalledTimes(1); expect(timerComponent.tasks.length).toBe(0); }); it('should accumulate elapsed time over multiple frames', () => { const callback = vi.fn(); - timerComponent.addTask({ + timerComponent.tasks.push({ callback, delay: 100, elapsed: 0, @@ -214,26 +214,26 @@ describe('TimerSystem', () => { // Frame 1: 16ms time.update(16); - timerSystem.run(entity); + world.update(); expect(callback).not.toHaveBeenCalled(); expect(timerComponent.tasks[0].elapsed).toBe(16); // Frame 2: 33ms total time.update(33); - timerSystem.run(entity); + world.update(); expect(callback).not.toHaveBeenCalled(); expect(timerComponent.tasks[0].elapsed).toBe(33); // Frame 3: 101ms total (exceeds delay) time.update(101); - timerSystem.run(entity); + world.update(); expect(callback).toHaveBeenCalledTimes(1); expect(timerComponent.tasks.length).toBe(0); }); it('should handle repeating timer without maxRuns indefinitely', () => { const callback = vi.fn(); - timerComponent.addTask({ + timerComponent.tasks.push({ callback, delay: 50, elapsed: 0, @@ -245,7 +245,7 @@ describe('TimerSystem', () => { // Run multiple times for (let i = 1; i <= 10; i++) { time.update(50 * i + 10); - timerSystem.run(entity); + world.update(); } expect(callback).toHaveBeenCalledTimes(10); @@ -254,7 +254,7 @@ describe('TimerSystem', () => { it('should properly reset elapsed time for repeating tasks', () => { const callback = vi.fn(); - timerComponent.addTask({ + timerComponent.tasks.push({ callback, delay: 50, elapsed: 0, @@ -265,19 +265,19 @@ describe('TimerSystem', () => { // First run time.update(60); - timerSystem.run(entity); + world.update(); expect(timerComponent.tasks[0].elapsed).toBe(0); // Reset after execution expect(callback).toHaveBeenCalledTimes(1); // Add more elapsed time time.update(90); - timerSystem.run(entity); + world.update(); expect(timerComponent.tasks[0].elapsed).toBe(30); // Accumulated from reset expect(callback).toHaveBeenCalledTimes(1); // Not called yet // Second run time.update(120); - timerSystem.run(entity); + world.update(); expect(timerComponent.tasks[0].elapsed).toBe(0); // Reset again expect(callback).toHaveBeenCalledTimes(2); }); diff --git a/src/timer/systems/timer-system.ts b/src/timer/systems/timer-system.ts index 3d812998..cd3480ba 100644 --- a/src/timer/systems/timer-system.ts +++ b/src/timer/systems/timer-system.ts @@ -1,75 +1,26 @@ -import { Entity, System } from '../../ecs/index.js'; import { Time } from '../../common/index.js'; -import { TimerComponent } from '../components/timer-component.js'; +import { TimerEcsComponent, TimerId } from '../components/timer-component.js'; + +import { EcsSystem } from '../../new-ecs/ecs-system.js'; /** - * System that processes timer tasks on entities with TimerComponent. - * Updates elapsed time and executes callbacks when delays are reached. - * - * Supports both one-shot timers (execute once after a delay) and repeating timers - * (execute periodically with an optional maximum run count). - * - * @example - * ```typescript - * const world = new World('game'); - * const timerSystem = new TimerSystem(world.time); - * world.addSystem(timerSystem); - * - * const entity = world.buildAndAddEntity([new TimerComponent()]); - * const timer = entity.getComponentRequired(TimerComponent); - * - * // One-shot timer - * timer.addTask({ - * callback: () => console.log('Fired!'), - * delay: 1000, - * elapsed: 0, - * }); - * - * // Repeating timer with maxRuns - * timer.addTask({ - * callback: () => console.log('Tick!'), - * delay: 500, - * elapsed: 0, - * repeat: true, - * interval: 500, - * maxRuns: 5, - * runsSoFar: 0, - * }); - * ``` + * Creates an ECS system to handle timers. */ -export class TimerSystem extends System { - private readonly _time: Time; - - /** - * Creates an instance of TimerSystem. - * @param time - The Time instance used to track delta time. - */ - constructor(time: Time) { - super([TimerComponent], 'timer'); - this._time = time; - } - - /** - * Processes all timer tasks on an entity. - * Updates elapsed time and executes callbacks when their delays are reached. - * One-shot tasks are removed after execution. - * Repeating tasks reset their elapsed time and continue until maxRuns is reached (if specified). - * - * @param entity - The entity with TimerComponent to process. - */ - public run(entity: Entity): void { - const timerComponent = entity.getComponentRequired(TimerComponent); +export const createTimerEcsSystem = ( + time: Time, +): EcsSystem<[TimerEcsComponent]> => ({ + query: [TimerId], + run: (result) => { + const [timerComponent] = result.components; if (timerComponent.tasks.length === 0) { return; } - const deltaTime = this._time.deltaTimeInMilliseconds; - // Iterate backwards to safely remove tasks as needed for (let i = timerComponent.tasks.length - 1; i >= 0; i--) { const task = timerComponent.tasks[i]; - task.elapsed += deltaTime; + task.elapsed += time.deltaTimeInMilliseconds; if (task.elapsed >= task.delay) { task.callback(); @@ -94,5 +45,5 @@ export class TimerSystem extends System { } } } - } -} + }, +}); diff --git a/src/utilities/create-game.ts b/src/utilities/create-game.ts index 34dcc6fc..d4ab0ef3 100644 --- a/src/utilities/create-game.ts +++ b/src/utilities/create-game.ts @@ -1,22 +1,24 @@ import { Time } from '../common/index.js'; -import { createWorld, Game, World } from '../ecs/index.js'; +import { EcsWorld } from '../new-ecs/ecs-world.js'; import { createCanvas, createRenderContext, RenderContext, } from '../rendering/index.js'; +import { Game } from './game.js'; export function createGame(containerId: string): { game: Game; - world: World; + world: EcsWorld; renderContext: RenderContext; time: Time; } { const time = new Time(); - const game = new Game(time, document.getElementById(containerId)!); - const world = createWorld(game); + const world = new EcsWorld(); + const container = document.getElementById(containerId)!; + const game = new Game(time, world, container); - const canvas = createCanvas(game.container); + const canvas = createCanvas(container); const renderContext = createRenderContext(canvas); diff --git a/src/utilities/game.ts b/src/utilities/game.ts new file mode 100644 index 00000000..37673a96 --- /dev/null +++ b/src/utilities/game.ts @@ -0,0 +1,64 @@ +import { Stoppable, Time } from '../common/index.js'; +import { EcsWorld } from '../new-ecs/ecs-world.js'; + +/** + * Manages the game loop and coordinates updates between systems. + */ +export class Game implements Stoppable { + /** + * The HTML element that contains the game canvas. + */ + public readonly container: HTMLElement; + private _isRunning = false; + private _animationFrameId: number | null = null; + + private readonly _time: Time; + private readonly _world: EcsWorld; + + /** + * Creates a new Game instance. + * @param time - The Time instance for managing time-related operations. + * @param world - The ECS world containing all entities and systems. + * @param container - The HTML element that contains the game canvas. + */ + constructor(time: Time, world: EcsWorld, container: HTMLElement) { + this._time = time; + this._world = world; + this.container = container; + } + + /** + * Starts the game loop. + */ + public run(): void { + if (this._isRunning) { + return; + } + + this._isRunning = true; + this._gameLoop(performance.now()); + } + + /** + * Stops the game loop. + */ + public stop(): void { + this._isRunning = false; + + if (this._animationFrameId !== null) { + cancelAnimationFrame(this._animationFrameId); + this._animationFrameId = null; + } + } + + private readonly _gameLoop = (currentTime: number): void => { + if (!this._isRunning) { + return; + } + + this._time.update(currentTime); + this._world.update(); + + this._animationFrameId = requestAnimationFrame(this._gameLoop); + }; +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 2aed2c22..bd94ce2b 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -4,5 +4,10 @@ export * from './is-number.js'; export * from './enforce-array.js'; export * from './chain.js'; export * from './types/index.js'; +export * from './game.js'; export * from './create-game.js'; export * from './assert-never.js'; +export * from './shallow-array-equals.js'; +export * from './matches-layer-mask.js'; +export * from './sparse-set.js'; +export * from './sorted-set.js'; diff --git a/src/utilities/matches-layer-mask.test.ts b/src/utilities/matches-layer-mask.test.ts new file mode 100644 index 00000000..39951a6a --- /dev/null +++ b/src/utilities/matches-layer-mask.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { matchesLayerMask } from './matches-layer-mask'; + +describe('matchesLayerMask', () => { + it('returns true when layer has the mask bit set', () => { + expect(matchesLayerMask(0b001, 0b001)).toBe(true); + expect(matchesLayerMask(0b101, 0b100)).toBe(true); + expect(matchesLayerMask(0b110, 0b010)).toBe(true); + }); + + it('returns false when no bits overlap', () => { + expect(matchesLayerMask(0b001, 0b010)).toBe(false); + expect(matchesLayerMask(0b000, 0b001)).toBe(false); + expect(matchesLayerMask(0b1000, 0b0011)).toBe(false); + }); + + it('returns false when mask is zero', () => { + expect(matchesLayerMask(0b111, 0b000)).toBe(false); + expect(matchesLayerMask(0, 0)).toBe(false); + }); + + it('works with multiple bits set in mask', () => { + // mask has bits 0 and 2 set (0b101); layer matches bit 2 + expect(matchesLayerMask(0b100, 0b101)).toBe(true); + // layer matches none + expect(matchesLayerMask(0b010, 0b101)).toBe(false); + }); +}); diff --git a/src/utilities/matches-layer-mask.ts b/src/utilities/matches-layer-mask.ts new file mode 100644 index 00000000..f49d0428 --- /dev/null +++ b/src/utilities/matches-layer-mask.ts @@ -0,0 +1,3 @@ +export function matchesLayerMask(layer: number, mask: number): boolean { + return (layer & mask) !== 0; +} diff --git a/src/utilities/shallow-array-equals.test.ts b/src/utilities/shallow-array-equals.test.ts new file mode 100644 index 00000000..37f22136 --- /dev/null +++ b/src/utilities/shallow-array-equals.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { shallowArraysEqual } from './shallow-array-equals'; + +describe('shallowArraysEqual', () => { + it('returns true when both references are same', () => { + const arr = [1, 2, 3]; + expect(shallowArraysEqual(arr, arr)).toBe(true); + }); + + it('returns true for equal primitive contents in different arrays', () => { + const a = [1, 2, 3]; + const b = [1, 2, 3]; + expect(shallowArraysEqual(a, b)).toBe(true); + }); + + it('returns false for different lengths', () => { + expect(shallowArraysEqual([1, 2], [1, 2, 3])).toBe(false); + }); + + it('returns false when same items but different order', () => { + expect(shallowArraysEqual([1, 2, 3], [3, 2, 1])).toBe(false); + }); + + it('compares object references shallowly', () => { + const obj = { x: 1 }; + const a = [obj, { y: 2 }]; + const b = [obj, { y: 2 }]; + + // first element is same reference, second is different object with same shape + expect(shallowArraysEqual(a, b)).toBe(false); + + const c = [obj, { y: 2 }]; + const d = [obj, { y: 2 }]; + + // replace second with same reference + c[1] = d[1]; + expect(shallowArraysEqual(c, d)).toBe(true); + }); + + it('handles empty arrays', () => { + expect(shallowArraysEqual([], [])).toBe(true); + }); + + it('works with readonly arrays', () => { + const a: readonly number[] = [1, 2]; + const b: readonly number[] = [1, 2]; + expect(shallowArraysEqual(a, b)).toBe(true); + }); +}); diff --git a/src/utilities/shallow-array-equals.ts b/src/utilities/shallow-array-equals.ts new file mode 100644 index 00000000..05708d29 --- /dev/null +++ b/src/utilities/shallow-array-equals.ts @@ -0,0 +1,20 @@ +export function shallowArraysEqual( + a: readonly T[], + b: readonly T[], +): boolean { + if (a === b) { + return true; + } + + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + + return true; +} diff --git a/src/utilities/sorted-set.test.ts b/src/utilities/sorted-set.test.ts new file mode 100644 index 00000000..005b0c1f --- /dev/null +++ b/src/utilities/sorted-set.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import SortedSet from './sorted-set'; + +describe('SortedSet', () => { + let set: SortedSet<{ name: string }>; + + beforeEach(() => { + set = new SortedSet(); + }); + + it('orders by priority ascending and preserves insertion order for equal priorities', () => { + const a = { name: 'a' }; + const b = { name: 'b' }; + const c = { name: 'c' }; + + set.add(a, 2); + set.add(b, 1); + set.add(c, 1); + + const values = Array.from(set.values()); + + expect(values).toEqual([b, c, a]); + }); + + it('does not allow duplicate items (by reference) and returns false when no change', () => { + const x = { name: 'x' }; + + const first = set.add(x, 5); + const second = set.add(x, 5); + + expect(first).toBe(true); + expect(second).toBe(false); + expect(set.size).toBe(1); + }); + + it('updates priority and reorders items', () => { + const a = { name: 'a' }; + const b = { name: 'b' }; + + set.add(a, 2); + set.add(b, 3); + + // move b to higher priority (lower number) + const changed = set.add(b, 1); + + expect(changed).toBe(true); + expect(Array.from(set.values())).toEqual([b, a]); + expect(set.getPriority(b)).toBe(1); + }); + + it('delete, has, getPriority work as expected', () => { + const a = { name: 'a' }; + + set.add(a, 1); + + expect(set.has(a)).toBe(true); + expect(set.getPriority(a)).toBe(1); + + const removed = set.delete(a); + expect(removed).toBe(true); + expect(set.has(a)).toBe(false); + expect(set.getPriority(a)).toBeUndefined(); + }); + + it('clear empties the set and resets ordering behavior', () => { + const a = { name: 'a' }; + const b = { name: 'b' }; + + set.add(a, 1); + set.add(b, 1); + + expect(set.size).toBe(2); + + set.clear(); + + expect(set.size).toBe(0); + + const c = { name: 'c' }; + const d = { name: 'd' }; + + set.add(c, 1); + set.add(d, 1); + + expect(Array.from(set.values())).toEqual([c, d]); + }); + + it('entries yields [item, priority] pairs in order and forEach works', () => { + const a = { name: 'a' }; + const b = { name: 'b' }; + + set.add(a, 2); + set.add(b, 1); + + const entries = Array.from(set.entries()); + expect(entries[0][0]).toBe(b); + expect(entries[0][1]).toBe(1); + expect(entries[1][0]).toBe(a); + expect(entries[1][1]).toBe(2); + + const seen: string[] = []; + set.forEach((item) => seen.push(item.name)); + expect(seen).toEqual(['b', 'a']); + }); +}); diff --git a/src/utilities/sorted-set.ts b/src/utilities/sorted-set.ts new file mode 100644 index 00000000..6e1e7a4b --- /dev/null +++ b/src/utilities/sorted-set.ts @@ -0,0 +1,157 @@ +/** + * A small SortedSet implementation keyed by item value with a numeric priority. + * - Lower priority values come first. + * - Items with the same priority preserve insertion order (stable). + * - No duplicate items (by reference equality) are allowed. + * - Iteration uses an internal cached, sorted array for efficiency. + */ +export class SortedSet { + private readonly _map: Map; + private _cache: Array<{ item: T; priority: number }> | null; + private _seqCounter: number; + + /** + * Create a new SortedSet. + */ + constructor() { + this._map = new Map(); + this._cache = null; + this._seqCounter = 0; + } + + /** + * Add an item with the provided priority. + * @param item - The item to add (unique by reference). + * @param priority - Numeric priority; lower numbers sort earlier. + * @returns `true` when the set changed (item added or priority updated), + * `false` when the item already existed with the same priority. + */ + public add(item: T, priority: number): boolean { + const existingMeta = this._map.get(item); + + if (existingMeta) { + if (existingMeta.priority === priority) { + return false; + } + + existingMeta.priority = priority; + + this._invalidateCache(); + + return true; + } + + this._map.set(item, { priority, seq: this._seqCounter++ }); + + this._invalidateCache(); + + return true; + } + + /** Remove an item. Returns true if the item was present. */ + public delete(item: T): boolean { + const removed = this._map.delete(item); + + if (removed) { + this._invalidateCache(); + } + + return removed; + } + + /** Returns true when the item exists in the set. */ + public has(item: T): boolean { + return this._map.has(item); + } + + /** Remove all items. */ + public clear(): void { + if (this._map.size === 0) { + return; + } + + this._map.clear(); + this._invalidateCache(); + this._seqCounter = 0; + } + + /** Number of items in the set. */ + get size(): number { + return this._map.size; + } + + /** Get the priority for an item or undefined if not present. */ + /** + * Get the numeric priority for an item. + * @param item - The item to query. + * @returns The priority number or `undefined` when the item is not present. + */ + public getPriority(item: T): number | undefined { + const meta = this._map.get(item); + + if (!meta) { + return undefined; + } + + return meta.priority; + } + + /** Iterate items in sorted order (by priority asc, then insertion order). */ + public *values(): IterableIterator { + for (const e of this._getCache()) { + yield e.item; + } + } + + /** Iterate entries as [item, priority]. */ + public *entries(): IterableIterator { + for (const e of this._getCache()) { + yield [e.item, e.priority] as const; + } + } + + /** + * forEach helper that visits entries in sorted order. + * @param callback - Invoked with `(item, priority)` for each entry. + */ + public forEach(callback: (item: T, priority: number) => void): void { + for (const e of this._getCache()) { + callback(e.item, e.priority); + } + } + + /** Default iterator yields values. */ + public [Symbol.iterator](): IterableIterator { + return this.values(); + } + + private _invalidateCache(): void { + this._cache = null; + } + + private _getCache(): Array<{ item: T; priority: number }> { + if (this._cache) { + return this._cache; + } + + const arr: Array<{ item: T; priority: number; seq: number }> = []; + + for (const [item, meta] of this._map.entries()) { + arr.push({ item, priority: meta.priority, seq: meta.seq }); + } + + arr.sort((a, b) => { + if (a.priority !== b.priority) { + return a.priority - b.priority; + } + + return a.seq - b.seq; + }); + + this._cache = arr.map((x) => ({ item: x.item, priority: x.priority })); + + return this._cache; + } +} + +export default SortedSet; diff --git a/src/utilities/sparse-set.test.ts b/src/utilities/sparse-set.test.ts new file mode 100644 index 00000000..4bc94930 --- /dev/null +++ b/src/utilities/sparse-set.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { SparseSet } from './sparse-set'; + +describe('SparseSet', () => { + let set: SparseSet; + + beforeEach(() => { + set = new SparseSet(); + }); + + it('adds, has and gets components', () => { + set.add(1, 'one'); + expect(set.has(1)).toBe(true); + expect(set.get(1)).toBe('one'); + expect(set.get(2)).toBeNull(); + expect(set.has(2)).toBe(false); + }); + + it('updates component when adding existing entity', () => { + set.add(1, 'first'); + expect(set.get(1)).toBe('first'); + set.add(1, 'second'); + expect(set.get(1)).toBe('second'); + }); + + it('handles entity 0 correctly', () => { + set.add(0, 'zero'); + expect(set.has(0)).toBe(true); + expect(set.get(0)).toBe('zero'); + set.remove(0); + expect(set.has(0)).toBe(false); + expect(set.get(0)).toBeNull(); + }); + + it('remove swaps last element into removed slot and updates mappings', () => { + // Add three entities + set.add(2, 'a'); + set.add(5, 'b'); + set.add(7, 'c'); + + // Remove middle entity (5). Last (7) should move into 5's slot. + set.remove(5); + expect(set.has(5)).toBe(false); + expect(set.get(5)).toBeNull(); + + // 7 should still be present and return its component + expect(set.has(7)).toBe(true); + expect(set.get(7)).toBe('c'); + + // 2 should be unaffected + expect(set.has(2)).toBe(true); + expect(set.get(2)).toBe('a'); + + // Now remove the last element (which may now be at the swapped index) + set.remove(7); + expect(set.has(7)).toBe(false); + expect(set.get(7)).toBeNull(); + + // 2 still present + expect(set.has(2)).toBe(true); + expect(set.get(2)).toBe('a'); + }); + + it('removing non-existent entity is a no-op', () => { + set.add(10, 'ten'); + expect(set.has(10)).toBe(true); + // remove an entity that was never added + set.remove(99); + // original remains intact + expect(set.has(10)).toBe(true); + expect(set.get(10)).toBe('ten'); + }); + + it('size reflects number of stored components', () => { + expect(set.size).toBe(0); + + set.add(1, 'one'); + expect(set.size).toBe(1); + + set.add(2, 'two'); + expect(set.size).toBe(2); + + // updating existing entity does not change size + set.add(1, 'one-updated'); + expect(set.size).toBe(2); + + // removing existing decreases size + set.remove(2); + expect(set.size).toBe(1); + + // removing non-existent is no-op + set.remove(99); + expect(set.size).toBe(1); + + set.remove(1); + expect(set.size).toBe(0); + }); + + it('size accounts for entity 0 correctly', () => { + expect(set.size).toBe(0); + set.add(0, 'zero'); + expect(set.size).toBe(1); + set.add(0, 'zero-updated'); + expect(set.size).toBe(1); + set.remove(0); + expect(set.size).toBe(0); + }); +}); diff --git a/src/utilities/sparse-set.ts b/src/utilities/sparse-set.ts new file mode 100644 index 00000000..4369e841 --- /dev/null +++ b/src/utilities/sparse-set.ts @@ -0,0 +1,62 @@ +export class SparseSet { + public readonly sparseArray: Array; + public readonly denseEntities: Array; + public readonly denseComponents: Array; + public readonly isTag: boolean; + + constructor(isTag: boolean = false) { + this.sparseArray = []; + this.denseEntities = []; + this.denseComponents = []; + this.isTag = isTag; + } + + public has(entity: number): boolean { + const index = this.sparseArray[entity]; + + return ( + index != undefined && index !== -1 && this.denseEntities[index] === entity + ); + } + + public get(entity: number): T | null { + return this.has(entity) + ? this.denseComponents[this.sparseArray[entity]] + : null; + } + + public add(entity: number, component: T): void { + if (this.has(entity)) { + this.denseComponents[this.sparseArray[entity]] = component; + + return; + } + + const index = this.denseEntities.length; + this.denseEntities.push(entity); + this.denseComponents.push(component); + this.sparseArray[entity] = index; + } + + public remove(entity: number): void { + if (!this.has(entity)) { + return; + } + + const index = this.sparseArray[entity]; + const lastIndex = this.denseEntities.length - 1; + const lastEntity = this.denseEntities[lastIndex]; + + this.denseEntities[index] = lastEntity; + this.denseComponents[index] = this.denseComponents[lastIndex]; + this.sparseArray[lastEntity] = index; + + this.denseEntities.pop(); + this.denseComponents.pop(); + this.sparseArray[entity] = -1; + } + + get size(): number { + return this.denseEntities.length; + } +} diff --git a/src/utilities/types/brand.ts b/src/utilities/types/brand.ts new file mode 100644 index 00000000..77a31f76 --- /dev/null +++ b/src/utilities/types/brand.ts @@ -0,0 +1,2 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +export type Brand = T & { readonly __brandTag?: Tag }; diff --git a/src/utilities/types/index.ts b/src/utilities/types/index.ts index 717ff0ef..df7b5bb2 100644 --- a/src/utilities/types/index.ts +++ b/src/utilities/types/index.ts @@ -1,2 +1,3 @@ export * from './at-least-one.js'; export * from './predicate.js'; +export * from './brand.js';