diff --git a/.changeset/dirty-flies-approve.md b/.changeset/dirty-flies-approve.md new file mode 100644 index 00000000..715ef558 --- /dev/null +++ b/.changeset/dirty-flies-approve.md @@ -0,0 +1,6 @@ +--- +"@arancini/core": minor +"arancini": minor +--- + +feat: rename world.destroy() to world.reset(), add world.destroy(entity: Entity) diff --git a/.changeset/funny-pigs-own.md b/.changeset/funny-pigs-own.md new file mode 100644 index 00000000..b12d5c60 --- /dev/null +++ b/.changeset/funny-pigs-own.md @@ -0,0 +1,6 @@ +--- +"@arancini/core": minor +"arancini": minor +--- + +feat: refactor query manager diff --git a/.changeset/great-flowers-doubt.md b/.changeset/great-flowers-doubt.md new file mode 100644 index 00000000..9614fc66 --- /dev/null +++ b/.changeset/great-flowers-doubt.md @@ -0,0 +1,5 @@ +--- +"@arancini/react": patch +--- + +feat: export createECS return type, 'ECS' diff --git a/.changeset/olive-dancers-itch.md b/.changeset/olive-dancers-itch.md new file mode 100644 index 00000000..e80519d0 --- /dev/null +++ b/.changeset/olive-dancers-itch.md @@ -0,0 +1,5 @@ +--- +"@arancini/react": patch +--- + +feat: refactor useQuery component diff --git a/.changeset/shy-rice-ring.md b/.changeset/shy-rice-ring.md new file mode 100644 index 00000000..034f2a16 --- /dev/null +++ b/.changeset/shy-rice-ring.md @@ -0,0 +1,6 @@ +--- +"@arancini/core": minor +"arancini": minor +--- + +feat: world.entities is now a list, was a map diff --git a/.changeset/twelve-ghosts-leave.md b/.changeset/twelve-ghosts-leave.md new file mode 100644 index 00000000..2166fbcc --- /dev/null +++ b/.changeset/twelve-ghosts-leave.md @@ -0,0 +1,17 @@ +--- +"arancini": minor +"@arancini/core": minor +--- + +feat: class components are no longer object pooled by default, they must opted in with the `@objectPooled` annotation, or by setting the `objectPooled` property on the component definition + +```ts +@objectPooled() +class MyComponent extends Component { /* ... */ } + +// or + += class MyComponent extends Component { /* ... */ }; + +MyComponent.objectPooled = true; +``` diff --git a/apps/benchmarks/src/arancini.js b/apps/benchmarks/src/arancini.js index 62992c1d..7a8525df 100644 --- a/apps/benchmarks/src/arancini.js +++ b/apps/benchmarks/src/arancini.js @@ -1,4 +1,4 @@ -import { World, Component, System } from 'arancini' +import { World, Component, System } from '@arancini/core' class Position extends Component { construct() { @@ -6,6 +6,7 @@ class Position extends Component { this.y = 0 } } +Position.objectPooled = true class Velocity extends Component { construct() { @@ -13,13 +14,12 @@ class Velocity extends Component { this.dy = Math.random() - 0.5 } } +Velocity.objectPooled = true let updateCount = 0 class MovementSystem extends System { - onInit() { - this.movement = this.query([Velocity, Position]) - } + movement = this.query([Velocity, Position]) onUpdate() { for (let i = 0; i < this.movement.entities.length; i++) { diff --git a/apps/react-demo/src/App.tsx b/apps/react-demo/src/App.tsx index e47057c4..8cfbdf50 100644 --- a/apps/react-demo/src/App.tsx +++ b/apps/react-demo/src/App.tsx @@ -1,15 +1,9 @@ import { Canvas, useFrame } from '@react-three/fiber' import { Component, System, World } from 'arancini' import { createECS } from 'arancini/react' -import { Object3D, Vector3, Vector3Tuple } from 'three' +import { Vector3, Vector3Tuple } from 'three' -class ThreeComponent extends Component { - object!: Object3D - - construct(object: Object3D) { - this.object = object - } -} +const Object3DComponent = Component.object('Object3D') class AngularVelocity extends Component { linvel!: Vector3 @@ -20,11 +14,11 @@ class AngularVelocity extends Component { } class LinearVelocitySystem extends System { - linvel = this.query([AngularVelocity, ThreeComponent]) + linvel = this.query([AngularVelocity, Object3DComponent]) onUpdate(delta: number): void { for (const entity of this.linvel) { - const { object } = entity.get(ThreeComponent) + const object = entity.get(Object3DComponent) const { linvel } = entity.get(AngularVelocity) object.rotation.x += linvel.x * delta @@ -36,7 +30,7 @@ class LinearVelocitySystem extends System { const world = new World() -world.registerComponent(ThreeComponent) +world.registerComponent(Object3DComponent) world.registerComponent(AngularVelocity) world.registerSystem(LinearVelocitySystem) world.init() @@ -51,7 +45,7 @@ const App = () => { return ( <> - + diff --git a/packages/arancini-core/.storybook/stories/find-the-bomb/find-the-bomb.stories.tsx b/packages/arancini-core/.storybook/stories/find-the-bomb/find-the-bomb.stories.tsx index 626d3c5a..7d9417b0 100644 --- a/packages/arancini-core/.storybook/stories/find-the-bomb/find-the-bomb.stories.tsx +++ b/packages/arancini-core/.storybook/stories/find-the-bomb/find-the-bomb.stories.tsx @@ -3,7 +3,9 @@ import React, { useEffect } from 'react' import './find-the-bomb.css' -const GameState = Component.object<{ clicks: number; foundBomb: boolean }>('GameState') +const GameState = Component.object<{ clicks: number; foundBomb: boolean }>( + 'GameState' +) const Emoji = Component.object<{ revealed: boolean @@ -13,7 +15,9 @@ const Emoji = Component.object<{ const Position = Component.object<{ x: number; y: number }>('Position') -const DistanceToTarget = Component.object<{ distance: number }>('DistanceToTarget') +const DistanceToTarget = Component.object<{ distance: number }>( + 'DistanceToTarget' +) const Target = Component.tag('Target') @@ -219,7 +223,7 @@ export const FindTheBomb = () => { } return () => { - world.destroy() + world.reset() } } @@ -257,7 +261,7 @@ export const FindTheBomb = () => { return () => { running = false - world.destroy() + world.reset() } }) diff --git a/packages/arancini-core/.storybook/stories/overlapping-circles/components.ts b/packages/arancini-core/.storybook/stories/overlapping-circles/components.ts index 6458ee9e..50139d45 100644 --- a/packages/arancini-core/.storybook/stories/overlapping-circles/components.ts +++ b/packages/arancini-core/.storybook/stories/overlapping-circles/components.ts @@ -1,6 +1,7 @@ -import { Component } from '@arancini/core' +import { Component, objectPooled } from '@arancini/core' import { Vector2 } from './utils' +@objectPooled() export class Movement extends Component { velocity: Vector2 acceleration: Vector2 @@ -11,6 +12,7 @@ export class Movement extends Component { } } +@objectPooled() export class Circle extends Component { position: Vector2 radius: number diff --git a/packages/arancini-core/.storybook/stories/overlapping-circles/overlapping-circles.stories.tsx b/packages/arancini-core/.storybook/stories/overlapping-circles/overlapping-circles.stories.tsx index bbc40d19..472d36f4 100644 --- a/packages/arancini-core/.storybook/stories/overlapping-circles/overlapping-circles.stories.tsx +++ b/packages/arancini-core/.storybook/stories/overlapping-circles/overlapping-circles.stories.tsx @@ -76,7 +76,7 @@ export const OverlappingCircles = () => { return () => { running = false - world.destroy() + world.reset() } }) diff --git a/packages/arancini-core/.storybook/stories/player-inventory.stories.tsx b/packages/arancini-core/.storybook/stories/player-inventory.stories.tsx index 8bc6acc5..bc5b4c57 100644 --- a/packages/arancini-core/.storybook/stories/player-inventory.stories.tsx +++ b/packages/arancini-core/.storybook/stories/player-inventory.stories.tsx @@ -100,7 +100,7 @@ export const PlayerInventoryEvents = () => { return () => { running = false - world.destroy() + world.reset() } }) diff --git a/packages/arancini-core/.storybook/stories/random-walkers.stories.tsx b/packages/arancini-core/.storybook/stories/random-walkers.stories.tsx index 75edc909..b9703076 100644 --- a/packages/arancini-core/.storybook/stories/random-walkers.stories.tsx +++ b/packages/arancini-core/.storybook/stories/random-walkers.stories.tsx @@ -187,7 +187,7 @@ export const RandomColorChangingWalkers = () => { return () => { running = false - world.destroy() + world.reset() } }) diff --git a/packages/arancini-core/src/component.ts b/packages/arancini-core/src/component.ts index 493f6ace..bd367160 100644 --- a/packages/arancini-core/src/component.ts +++ b/packages/arancini-core/src/component.ts @@ -1,15 +1,9 @@ import { Entity } from './entity' -export type ComponentClass = { - new (...args: unknown[]): T - componentIndex: number - type: typeof ComponentDefinitionType.CLASS -} - export const ComponentDefinitionType = { - CLASS: 0, - OBJECT: 1, - TAG: 2, + CLASS: 1, + OBJECT: 2, + TAG: 3, } as const /** @@ -18,22 +12,17 @@ export const ComponentDefinitionType = { export type InternalComponentInstanceProperties = { _arancini_component_definition?: ComponentDefinition _arancini_id?: string - _arancini_entity?: Entity } -export type ComponentValue = Component | unknown - -export type ClassComponentDefinition = { - type: typeof ComponentDefinitionType.CLASS - +export type ClassComponentDefinition = { name?: string componentIndex: number - new (...args: unknown[]): T + objectPooled: boolean T?: T -} +} & { type: typeof ComponentDefinitionType.CLASS; new (): T } -export type ObjectComponentDefinition = { +export type ObjectComponentDefinition = { type: typeof ComponentDefinitionType.OBJECT name?: string @@ -51,18 +40,16 @@ export type TagComponentDefinition = { T: undefined } -export type ComponentDefinition = +export type ComponentDefinition = | ClassComponentDefinition | ObjectComponentDefinition | TagComponentDefinition -export type ComponentDefinitionInstance< - T extends ComponentDefinition -> = +export type ComponentInstance> = // class T['type'] extends typeof ComponentDefinitionType.CLASS ? T extends { - new (...args: unknown[]): infer U + new (...args: any[]): infer U } ? U : never @@ -78,7 +65,7 @@ export type ComponentDefinitionArgs> = // class T['type'] extends typeof ComponentDefinitionType.CLASS ? T extends { - new (...args: unknown[]): infer U + new (args: any[]): infer U } ? U extends { construct(...args: infer Args): void } ? Args @@ -93,48 +80,39 @@ export type ComponentDefinitionArgs> = : never /** - * There are multiple ways to define a component in Arancini. - * - * You can define: - * - an object component with the `Component.object` method - * - a tag component with the `Component.tag` method - * - a class component by extending the `Component` class - * - * To get the most out of arancini, you should use class components where possible. - * Class components let you utilise arancini's object pooling and lifecycle features. + * Decorator for opting ia class component into being object objectPooled. * - * @example defining an object component + * @example defining an object pooled component using the @objectPool decorator * ```ts - * import { Component, World } from '@arancini/core' + * import { Component, objectPooled, World } from '@arancini/core' * - * const PositionComponent = Component.object<{ x: number, y: number }>('Position') - * - * const world = new World() - * world.registerComponent(PositionComponent) - * - * const entity = world.create() - * - * entity.add(PositionComponent, { x: 1, y: 2 }) + * @objectPooled() + * class ExampleComponent extends Component { + * } * ``` * - * @example defining a tag component - * ```ts - * import { Component, World } from '@arancini/core' - * - * const PoweredUpComponent = Component.tag('PoweredUp') - * - * const world = new World() - * world.registerComponent(PoweredUpComponent) + * This can also be achieved by setting the `objectPooled` property on a component class + * @example vanilla js + * ```js + * import { Component } from '@arancini/core' * - * const entity = world.create() - * - * entity.add(PoweredUpComponent) + * class ExampleComponent extends Component {} + * ExampleComponent.objectPooled = true * ``` + */ +export const objectPooled = + () => (target: { new (): Component; objectPooled: boolean }, _value: any) => { + target.objectPooled = true + } + +/** + * The base class for class components. * - * @example defining and creating a class component that extends the `Component` class + * * @example defining and creating a class component that extends the `Component` class and uses the `@objectPooled` decorator * ```ts - * import { Component, World } from '@arancini/core' + * import { Component, objectPooled, World } from '@arancini/core' * + * @objectPooled() * class ExampleComponent extends Component { * // When using typescript, the `!:` not null assertion can be used as a "late-init" syntax. * // You must take care to set all class properties in the `construct` method. @@ -172,82 +150,49 @@ export type ComponentDefinitionArgs> = * entity.add(ExampleComponent, x, y) * ``` */ -export abstract class Component { - /** - * This component instances unique id - * @private internal - */ - _arancini_id!: string - - /** - * The entity this component belongs to. - * @private internal - */ - _arancini_entity!: Entity - - /** - * The class the component was constructed from - * @private internal - */ - _arancini_component_definition!: ComponentDefinition +export class Component { + construct(..._args: any[]): void {} + onInit() {} + onDestroy() {} static componentIndex: number - static type = ComponentDefinitionType.CLASS + static objectPooled = false as const - /** - * Properties can be be initialised with arguments with the `construct` method. - * - * Component instances are object pooled. To prevent unexpected behavior properties should be initialised or reset in the `construct` method. - * - * @example - * ```ts - * class MyComponent extends Component { - * exampleNumber!: number; - * - * exampleMap = new Map(); - * - * construct(): void { - * this.exampleNumber = 0; - * - * this.exampleMap.clear(); - * } - * - * onInit(): void { - * // because we used the not-null operator `!:` the type of `this.exampleProperty` here will be `number`, as opposed to `number | undefined` - * this.exampleProperty += 1; - * } - * } - * ``` - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - construct(..._args: any[] | []) {} + entity!: Entity /** - * Destruction logic + * @ignore */ - onDestroy(): void {} + _arancini_id!: string /** - * Initialisation logic + * @ignore */ - onInit(): void {} + _arancini_component_definition!: ComponentDefinition /** * Creates an object component definition with the given type. * - * @param name an optional name for the component, useful for debugging + * @param name a name for the component * @return object component definition * - * @example + * * @example defining an object component * ```ts - * import { object } from '@arancini/core' + * import { Component, World } from '@arancini/core' + * + * const PositionComponent = Component.object<{ x: number, y: number }>('Position') + * + * const world = new World() + * world.registerComponent(PositionComponent) + * + * const entity = world.create() * - * const PositionComponent = object<{ x: number, y: number }>('Position') + * entity.add(PositionComponent, { x: 1, y: 2 }) * ``` */ - static object(name?: string) { - const componentDefinition: ComponentDefinition = { + static object(name: string) { + const componentDefinition: ObjectComponentDefinition = { name, type: ComponentDefinitionType.OBJECT, componentIndex: -1, @@ -259,18 +204,25 @@ export abstract class Component { /** * Creates a tag component definition. - * @param name an optional name for the component, useful for debugging + * @param name an name for the component * @returns tag component definition * - * @example + * @example defining a tag component * ```ts - * import { tag } from '@arancini/core' + * import { Component, World } from '@arancini/core' * - * const PoweredUpComponent = tag('PoweredUp') + * const PoweredUpComponent = Component.tag('PoweredUp') + * + * const world = new World() + * world.registerComponent(PoweredUpComponent) + * + * const entity = world.create() + * + * entity.add(PoweredUpComponent) * ``` */ - static tag(name?: string) { - const componentDefinition: ComponentDefinition = { + static tag(name: string) { + const componentDefinition: TagComponentDefinition = { name, type: ComponentDefinitionType.TAG, componentIndex: -1, @@ -280,3 +232,19 @@ export abstract class Component { return componentDefinition } } + +export const cloneComponentDefinition = < + T extends ComponentDefinition, +>( + componentDefinition: T +): T => { + if (componentDefinition.type === ComponentDefinitionType.CLASS) { + const clone = class extends (componentDefinition as any) {} as T + clone.componentIndex = -1 + return clone + } + + const clone = { ...componentDefinition } + clone.componentIndex = -1 + return clone +} diff --git a/packages/arancini-core/src/entity-container.ts b/packages/arancini-core/src/entity-container.ts new file mode 100644 index 00000000..7785808a --- /dev/null +++ b/packages/arancini-core/src/entity-container.ts @@ -0,0 +1,83 @@ +import { Entity } from './entity' +import { Topic } from './topic' + +/** + * @private internal + */ +export class EntityContainer { + version = 0 + + entities: Entity[] = [] + + onEntityAdded = new Topic<[Entity]>() + + onEntityRemoved = new Topic<[Entity]>() + + private entityPositions = new Map() + + get first(): Entity | undefined { + return this.entities[0] || undefined + } + + [Symbol.iterator]() { + let index = this.entities.length + + const result: { + value: Entity + done: boolean + } = { + value: undefined!, + done: false, + } + + return { + next: () => { + result.value = this.entities[--index] + result.done = index < 0 + return result + }, + } + } + + has(entity: Entity): boolean { + return this.entityPositions.has(entity) + } + + /** + * @ignore internal + */ + _addEntity(entity: Entity): void { + if (entity && !this.has(entity)) { + this.entities.push(entity) + this.entityPositions.set(entity, this.entities.length - 1) + + this.version++ + + this.onEntityAdded.emit(entity) + } + } + + /** + * @ignore internal + */ + _removeEntity(entity: Entity): void { + if (!this.has(entity)) { + return + } + + const index = this.entityPositions.get(entity)! + this.entityPositions.delete(entity) + + const other = this.entities[this.entities.length - 1] + if (other !== entity) { + this.entities[index] = other + this.entityPositions.set(other, index) + } + + this.entities.pop() + + this.version++ + + this.onEntityRemoved.emit(entity) + } +} diff --git a/packages/arancini-core/src/entity-manager.ts b/packages/arancini-core/src/entity-manager.ts deleted file mode 100644 index 5f61694a..00000000 --- a/packages/arancini-core/src/entity-manager.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { - Component, - ComponentClass, - ComponentDefinition, - ComponentDefinitionArgs, - ComponentDefinitionInstance, - ComponentDefinitionType, - InternalComponentInstanceProperties, -} from './component' -import type { Entity } from './entity' -import { ComponentPool, EntityPool } from './pools' -import { uniqueId } from './utils' -import { World } from './world' - -export class EntityManager { - /** - * Object pool for components - */ - componentPool: ComponentPool - - /** - * Object pool for entities - */ - entityPool: EntityPool - - /** - * The World the entity manager is part of - */ - private world: World - - constructor(world: World) { - this.world = world - this.componentPool = new ComponentPool() - this.entityPool = new EntityPool(this.world) - } - - /** - * Initialise all entities - */ - init(): void { - for (const entity of this.world.entities.values()) { - this.initialiseEntity(entity) - } - } - - /** - * Destroy all entities - */ - destroy(): void { - for (const entity of this.world.entities.values()) { - this.destroyEntity(entity) - } - } - - /** - * Creates a new entity - * @returns the new entity - */ - createEntity(): Entity { - const entity = this.entityPool.request() - - this.world.entities.set(entity.id, entity) - - if (this.world.initialised) { - this.initialiseEntity(entity) - } - - return entity - } - - /** - * Destroys an entity and releases it to the entity object pool - * @param entity the entity to release - */ - destroyEntity(entity: Entity): void { - this.world.entities.delete(entity.id) - - for (const component of Object.values(entity._components)) { - const internal = component as InternalComponentInstanceProperties - this.removeComponentFromEntity( - entity, - internal._arancini_component_definition!, - false - ) - } - - this.world.queryManager.onEntityRemoved(entity) - - this.entityPool.recycle(entity) - } - - /** - * Adds a component to an entity - * @param entity the entity to add to - * @param componentDefinition the component to add - */ - addComponentToEntity>( - entity: Entity, - componentDefinition: T, - args: ComponentDefinitionArgs - ): ComponentDefinitionInstance { - if (entity._components[componentDefinition.componentIndex]) { - throw new Error( - `Cannot add component ${componentDefinition.name}, entity with id ${entity.id} already has this component` - ) - } - - let component: ComponentDefinitionInstance - - if (componentDefinition.type === ComponentDefinitionType.CLASS) { - const classComponent = this.componentPool.request( - componentDefinition as ComponentClass - ) - classComponent.construct(...(args ?? [])) - component = classComponent as ComponentDefinitionInstance - } else if (componentDefinition.type === ComponentDefinitionType.OBJECT) { - component = args[0] as ComponentDefinitionInstance - } else { - component = {} as ComponentDefinitionInstance - } - - const internal = component as InternalComponentInstanceProperties - internal._arancini_entity = entity - internal._arancini_id = uniqueId() - internal._arancini_component_definition = componentDefinition - - entity._components[componentDefinition.componentIndex] = component - entity._componentsBitSet.add(componentDefinition.componentIndex) - - if (entity.initialised && component instanceof Component) { - component.onInit() - } - - return component as ComponentDefinitionInstance - } - - /** - * Removes a component from an entity - * @param entity the entity to remove from - * @param componentDefinition the component to remove - */ - removeComponentFromEntity>( - entity: Entity, - componentDefinition: T, - updateBitSet: boolean - ): void { - const component = entity.find(componentDefinition) - if (component === undefined) { - throw new Error('Component does not exist in Entity') - } - - const internal = component as InternalComponentInstanceProperties - const { componentIndex } = internal._arancini_component_definition! - - const isClass = - internal._arancini_component_definition!.type === - ComponentDefinitionType.CLASS - - delete entity._components[componentIndex] - - if (updateBitSet) { - entity._componentsBitSet.remove(componentIndex) - } - - if (isClass) { - ;(component as Component)?.onDestroy() - - internal._arancini_id = uniqueId() - internal._arancini_entity = undefined - this.componentPool.recycle(component as Component) - } else { - delete internal._arancini_entity - delete internal._arancini_component_definition - delete internal._arancini_id - } - } - - /** - * Initialises an entity - * @param entity the entity to initialise - */ - private initialiseEntity(entity: Entity): void { - entity.initialised = true - - entity._componentsBitSet.resize( - this.world.componentRegistry.currentComponentIndex - ) - - for (const component of Object.values(entity._components)) { - if (component instanceof Component) { - component.onInit() - } - } - } -} diff --git a/packages/arancini-core/src/entity.ts b/packages/arancini-core/src/entity.ts index d2ff94c1..c0fe7bf8 100644 --- a/packages/arancini-core/src/entity.ts +++ b/packages/arancini-core/src/entity.ts @@ -1,7 +1,10 @@ -import type { - ComponentDefinition, - ComponentDefinitionArgs, - ComponentDefinitionInstance, +import { + ComponentDefinitionType, + InternalComponentInstanceProperties, + type ComponentDefinition, + type ComponentDefinitionArgs, + type ComponentInstance, + Component, } from './component' import { uniqueId } from './utils' import { BitSet } from './utils/bit-set' @@ -18,28 +21,28 @@ import type { World } from './world' * import { Component, World } from '@arancini/core' * * // example tag component without any data or behavior - * class ExampleComponent extends Component {} + * const TagComponent = Component.tag('TagComponent') * * // create a world and register the component * const world = new World() - * world.registerComponent(ExampleComponent) + * world.registerComponent(TagComponent) * * // create an entitty * const entity = world.create() * * // try retrieving a component that isn't in the entity - * entity.find(ExampleComponent) // returns `undefined` - * entity.get(ExampleComponent) // throws Error + * entity.find(TagComponent) // returns `undefined` + * entity.get(TagComponent) // throws Error * - * // add ExampleComponent to the entity - * const exampleComponent = entity.add(ExampleComponent) + * // add TagComponent to the entity + * const tagComponent = entity.add(TagComponent) * - * entity.has(ExampleComponent) // returns `true` - * entity.get(ExampleComponent) // returns `exampleComponent` - * entity.get(ExampleComponent) // returns `exampleComponent` + * entity.has(TagComponent) // returns `true` + * entity.get(TagComponent) // returns `tagComponent` + * entity.get(TagComponent) // returns `tagComponent` * * // remove the component - * entity.remove(ExampleComponent); + * entity.remove(TagComponent); * * // destroy the entity * entity.destroy(); @@ -74,12 +77,15 @@ export class Entity { _components: { [index: string]: unknown } = {} /** - * Whether to update queries when components are added or removed - * Used by the `bulk` method to control when queries are updated - * @private + * @private internal */ _updateQueries = true + /** + * @private internal + */ + _updateBitSet = true + /** * Adds a component to the entity * @param componentDefinition the component to add @@ -87,13 +93,42 @@ export class Entity { add>( componentDefinition: C, ...args: ComponentDefinitionArgs - ): ComponentDefinitionInstance { - // add the component to this entity - const component = this.world.entityManager.addComponentToEntity( - this, - componentDefinition, - args - ) + ): ComponentInstance { + if (this._components[componentDefinition.componentIndex]) { + throw new Error( + `Cannot add component ${componentDefinition.name}, entity with id ${this.id} already has this component` + ) + } + + let component: ComponentInstance + + if (componentDefinition.type === ComponentDefinitionType.CLASS) { + component = ( + componentDefinition.objectPooled + ? this.world.componentPool.request(componentDefinition) + : new (componentDefinition as any)() + ) as ComponentInstance + ;(component as Component).construct(...args) + } else if (componentDefinition.type === ComponentDefinitionType.OBJECT) { + component = args[0] as ComponentInstance + } else { + component = {} as ComponentInstance + } + + const internal = component as InternalComponentInstanceProperties + internal._arancini_id = uniqueId() + internal._arancini_component_definition = componentDefinition + + this._components[componentDefinition.componentIndex] = component + this._componentsBitSet.add(componentDefinition.componentIndex) + + if (componentDefinition.type === ComponentDefinitionType.CLASS) { + ;(component as Component).entity = this + + if (this.initialised) { + ;(component as Component).onInit!() + } + } if (this._updateQueries) { this.world.queryManager.onEntityComponentChange(this) @@ -106,8 +141,41 @@ export class Entity { * Removes a component from the entity and destroys it * @param value the component to remove and destroy */ - remove(component: ComponentDefinition): Entity { - this.world.entityManager.removeComponentFromEntity(this, component, true) + remove>( + componentDefinition: T + ): Entity { + const component = this.find(componentDefinition) + + if (component === undefined) { + throw new Error('Component does not exist in Entity') + } + + const internal = component as InternalComponentInstanceProperties + const { componentIndex } = internal._arancini_component_definition! + + delete this._components[componentIndex] + + if (this._updateBitSet) { + this._componentsBitSet.remove(componentIndex) + } + + if ( + internal._arancini_component_definition!.type === + ComponentDefinitionType.CLASS + ) { + const classComponent = component as Component + + classComponent.onDestroy() + classComponent.entity = undefined! + + if (internal._arancini_component_definition!.objectPooled) { + internal._arancini_id = uniqueId() + this.world.componentPool.recycle(component) + } + } else { + delete internal._arancini_component_definition + delete internal._arancini_id + } if (this._updateQueries) { this.world.queryManager.onEntityComponentChange(this) @@ -119,13 +187,13 @@ export class Entity { /** * Utility method for adding and removing components in bulk. * - * Wrap multiple `add` and `remove` calls in `entity.bulk(() => { ... })` to prevent queries from updating until all components have been added or removed. + * Wrap multiple `add` and `remove` calls in `entity.bulk(() => { ... })` to update queries once after adding or removing multiple components. * * @param updateFn callback to update the Entity * * @example * ```ts - * world.create().bulk((entity) => { + * entity.bulk((entity) => { * entity.add(TestComponentOne) * entity.add(TestComponentTwo) * entity.remove(TestComponentThree) @@ -146,23 +214,23 @@ export class Entity { /** * Retrieves a component on an entity by type, throws an error if the component is not in the entity - * @param value a constructor for the component type to retrieve + * @param componentDefinition the component to to get * @returns the component */ get>( - value: T - ): ComponentDefinitionInstance { - const component = this._components[value.componentIndex] + componentDefinition: T + ): ComponentInstance { + const component = this._components[componentDefinition.componentIndex] - if (component) { - return component as ComponentDefinitionInstance + if (!component) { + throw new Error( + `Component ${componentDefinition}} with componentIndex ${ + componentDefinition.componentIndex + } not in entity ${this.id} - ${Object.keys(this._components)}` + ) } - throw new Error( - `Component ${value}} with componentIndex ${ - value.componentIndex - } not in entity ${this.id} - ${Object.keys(this._components)}` - ) + return component as ComponentInstance } /** @@ -180,9 +248,9 @@ export class Entity { */ find>( value: T - ): ComponentDefinitionInstance | undefined { + ): ComponentInstance | undefined { return this._components[value.componentIndex] as - | ComponentDefinitionInstance + | ComponentInstance | undefined } @@ -196,11 +264,9 @@ export class Entity { } /** - * Destroy the Entity's components and remove the Entity from the world + * Destroys the Entity */ destroy(): void { - if (!this.world) return - - this.world.entityManager.destroyEntity(this) + this.world?.destroy(this) } } diff --git a/packages/arancini-core/src/events/index.ts b/packages/arancini-core/src/events/index.ts deleted file mode 100644 index 9edbc48d..00000000 --- a/packages/arancini-core/src/events/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './topic' diff --git a/packages/arancini-core/src/index.ts b/packages/arancini-core/src/index.ts index 7a2e1aca..82d1585f 100644 --- a/packages/arancini-core/src/index.ts +++ b/packages/arancini-core/src/index.ts @@ -1,7 +1,7 @@ export * from './component' export * from './entity' -export * from './events' -export * from './pools/object-pool' +export { ObjectPool } from './pools' export * from './query' export * from './system' +export * from './topic' export * from './world' diff --git a/packages/arancini-core/src/pools.ts b/packages/arancini-core/src/pools.ts new file mode 100644 index 00000000..e61946ec --- /dev/null +++ b/packages/arancini-core/src/pools.ts @@ -0,0 +1,309 @@ +import { + type ClassComponentDefinition, + type ComponentInstance, +} from './component' +import { Entity } from './entity' +import { uniqueId } from './utils' +import { World } from './world' + +/** + * Pools of objects of a given type + * + * @param T the type of object to pool + * + * @example + * ```ts + * // create a new pool + * const pool = new ObjectPool(() => new MyObject()) + * + * // expand the pool + * pool.expand(10) + * + * // request an object from the pool + * const object = pool.request() + * + * // release the object back into the pool + * pool.release(object) + * ``` + */ +export class ObjectPool { + /** + * An array of available objects + */ + availableObjects: T[] = [] + + /** + * Factory method for creating a new object to add to the pool + */ + factory: () => T + + /** + * Returns the number of available objects in the object pool + */ + get available(): number { + return this.availableObjects.length + } + + /** + * Returns the number of used objects in the object pool + */ + get used(): number { + return this.size - this.availableObjects.length + } + + /** + * The number of objects in the pool + */ + size = 0 + + /** + * Constructor for a new object pool + * @param factory factory method for creating a new object + */ + constructor(factory: () => T, size?: number) { + this.factory = factory + if (size !== undefined) { + this.grow(size) + } + } + + /** + * Grows the object pool by a given amount + * @param count the count of objects to expand the object pool by + */ + grow(count: number): void { + for (let i = 0; i < count; i++) { + this.availableObjects.push(this.factory()) + } + this.size += count + } + + /** + * Frees a given number of currently available objects + * @param count the number of available objects to free + */ + free(count: number): void { + for (let i = 0; i < count; i++) { + const object = this.availableObjects.pop() + + if (object) { + this.size-- + } else { + break + } + } + } + + /** + * Requests an object from the object pool and returns it + * @returns an object from the object pool + */ + request(): T { + // grow the list by ~20% if there are no more available objects + if (this.availableObjects.length <= 0) { + this.grow(Math.round(this.size * 0.2) + 1) + } + + return this.availableObjects.pop() as T + } + + /** + * Recycles an object into the object pool + * @param object the object to recycle + */ + recycle(object: T): void { + this.availableObjects.push(object) + } +} + +/** + * EntityPool that manages reuse of entity objects + * + * @private internal class, do not use directly + */ +export class EntityPool { + /** + * The object pool for the entity pool + */ + private objectPool = new ObjectPool(() => { + const entity = new Entity() + entity.world = this.world + return entity + }) + + /** + * The size of the entity pool + */ + get size(): number { + return this.objectPool.size + } + + /** + * The number of available objects in the entity pool + */ + get available(): number { + return this.objectPool.available + } + + /** + * The number of used objects in the entity pool + */ + get used(): number { + return this.objectPool.used + } + + /** + * The world the entity pool is part of + */ + private world: World + + constructor(world: World) { + this.world = world + } + + /** + * Requests an entity from the pool + */ + request(): Entity { + return this.objectPool.request() + } + + /** + * Recycles an entity into the pool + * @param e the entity to recycle + */ + recycle(e: Entity): void { + e.id = uniqueId() + e.initialised = false + e._componentsBitSet.reset() + + this.objectPool.recycle(e) + } + + /** + * Grows the entity pool by the specified amount + * @param count the count of entities to expand the pool by + */ + grow(count: number): void { + this.objectPool.grow(count) + } + + /** + * Frees a given number of currently available entities + * @param count the number of available entities to free + */ + free(count: number): void { + this.objectPool.free(count) + } +} + +/** + * @private internal + */ +export class ComponentPool { + /** + * The total number of component pools + */ + get totalPools(): number { + return this.objectPools.size + } + + /** + * The total size of all component object pools + */ + get size(): number { + let total = 0 + for (const pool of this.objectPools.values()) { + total += pool.size + } + return total + } + + /** + * The number of available objects in the component object pools + */ + get available(): number { + let total = 0 + for (const pool of this.objectPools.values()) { + total += pool.available + } + return total + } + + /** + * The number of used objects in the component object pools + */ + get used(): number { + let total = 0 + for (const pool of this.objectPools.values()) { + total += pool.used + } + return total + } + + /** + * The a map of component names to object pools + */ + private objectPools: Map> = new Map() + + /** + * Requests a component from the pool + */ + request>( + componentDefinition: T + ): ComponentInstance { + const pool = this.getPool(componentDefinition) + + return pool.request() as ComponentInstance + } + + /** + * Recycles a component into the pool + * @param component the component to release + */ + recycle(component: ComponentInstance): void { + this.objectPools + .get(component._arancini_component_definition.componentIndex) + ?.recycle(component) + } + + /** + * Grows the component pool by the specified amount + * @param componentDefinition the component class to grow the pool for + * @param count the count of components to expand the pool by + */ + grow( + componentDefinition: ClassComponentDefinition, + count: number + ): void { + this.getPool(componentDefinition).grow(count) + } + + /** + * Frees a given number of currently available components + * @param componentDefinition the component to free the components for + * @param count the number of available components to free + */ + free( + componentDefinition: ClassComponentDefinition, + count: number + ): void { + this.getPool(componentDefinition).free(count) + } + + private getPool( + componentDefinition: ClassComponentDefinition + ): ObjectPool { + let pool = this.objectPools.get(componentDefinition.componentIndex) + + if (pool === undefined) { + pool = new ObjectPool(() => { + return new (componentDefinition as any)() + }) + + this.objectPools.set(componentDefinition.componentIndex, pool) + } + + return pool + } +} diff --git a/packages/arancini-core/src/pools/component-pool.ts b/packages/arancini-core/src/pools/component-pool.ts deleted file mode 100644 index 7361bd6e..00000000 --- a/packages/arancini-core/src/pools/component-pool.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { - Component, - ComponentClass, - ComponentDefinition, - ComponentDefinitionInstance, -} from '../component' -import { ObjectPool } from './object-pool' - -/** - * @private internal - */ -export class ComponentPool { - /** - * The total number of component pools - */ - get totalPools(): number { - return this.objectPools.size - } - - /** - * The total size of all component object pools - */ - get size(): number { - let total = 0 - for (const pool of this.objectPools.values()) { - total += pool.size - } - return total - } - - /** - * The number of available objects in the component object pools - */ - get available(): number { - let total = 0 - for (const pool of this.objectPools.values()) { - total += pool.available - } - return total - } - - /** - * The number of used objects in the component object pools - */ - get used(): number { - let total = 0 - for (const pool of this.objectPools.values()) { - total += pool.used - } - return total - } - - /** - * The a map of component names to object pools - */ - private objectPools: Map> = new Map() - - /** - * Requests a component from the pool - */ - request & ComponentClass>( - componentDefinition: T - ): ComponentDefinitionInstance { - const pool = this.getPool(componentDefinition) - - return pool.request() as ComponentDefinitionInstance - } - - /** - * Recycles a component into the pool - * @param component the component to release - */ - recycle(component: Component): void { - const pool = this.objectPools.get( - component._arancini_component_definition.componentIndex - ) - - if (pool) { - pool.recycle(component) - } - } - - /** - * Grows the component pool by the specified amount - * @param componentDefinition the component class to grow the pool for - * @param count the count of components to expand the pool by - */ - grow( - componentDefinition: ComponentDefinition & ComponentClass, - count: number - ): void { - this.getPool(componentDefinition).grow(count) - } - - /** - * Frees a given number of currently available components - * @param clazz the component class to free the components for - * @param count the number of available components to free - */ - free( - clazz: ComponentDefinition & ComponentClass, - count: number - ): void { - this.getPool(clazz).free(count) - } - - private getPool( - componentDefinition: ComponentDefinition & ComponentClass - ): ObjectPool { - let pool = this.objectPools.get(componentDefinition.componentIndex) - - if (pool === undefined) { - pool = new ObjectPool(() => { - const Clazz = componentDefinition as ComponentClass - const component = new Clazz() - - return component - }) - - this.objectPools.set(componentDefinition.componentIndex, pool) - } - - return pool - } -} diff --git a/packages/arancini-core/src/pools/entity-pool.ts b/packages/arancini-core/src/pools/entity-pool.ts deleted file mode 100644 index 9dbbcfc7..00000000 --- a/packages/arancini-core/src/pools/entity-pool.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Entity } from '../entity' -import { uniqueId } from '../utils' -import { World } from '../world' -import { ObjectPool } from './object-pool' - -/** - * EntityPool that manages reuse of entity objects - * - * @private internal class, do not use directly - */ -export class EntityPool { - /** - * The object pool for the entity pool - */ - private objectPool = new ObjectPool(() => { - const entity = new Entity() - entity.world = this.world - return entity - }) - - /** - * The size of the entity pool - */ - get size(): number { - return this.objectPool.size - } - - /** - * The number of available objects in the entity pool - */ - get available(): number { - return this.objectPool.available - } - - /** - * The number of used objects in the entity pool - */ - get used(): number { - return this.objectPool.used - } - - /** - * The world the entity pool is part of - */ - private world: World - - constructor(world: World) { - this.world = world - } - - /** - * Requests an entity from the pool - */ - request(): Entity { - return this.objectPool.request() - } - - /** - * Recycles an entity into the pool - * @param e the entity to recycle - */ - recycle(e: Entity): void { - e.id = uniqueId() - e.initialised = false - e._componentsBitSet.reset() - - this.objectPool.recycle(e) - } - - /** - * Grows the entity pool by the specified amount - * @param count the count of entities to expand the pool by - */ - grow(count: number): void { - this.objectPool.grow(count) - } - - /** - * Frees a given number of currently available entities - * @param count the number of available entities to free - */ - free(count: number): void { - this.objectPool.free(count) - } -} diff --git a/packages/arancini-core/src/pools/index.ts b/packages/arancini-core/src/pools/index.ts deleted file mode 100644 index 346e9da3..00000000 --- a/packages/arancini-core/src/pools/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './component-pool' -export * from './entity-pool' diff --git a/packages/arancini-core/src/pools/object-pool.ts b/packages/arancini-core/src/pools/object-pool.ts deleted file mode 100644 index aef25283..00000000 --- a/packages/arancini-core/src/pools/object-pool.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Pools of objects of a given type - * - * @param T the type of object to pool - * - * @example - * ```ts - * // create a new pool - * const pool = new ObjectPool(() => new MyObject()) - * - * // expand the pool - * pool.expand(10) - * - * // request an object from the pool - * const object = pool.request() - * - * // release the object back into the pool - * pool.release(object) - * ``` - */ -export class ObjectPool { - /** - * An array of available objects - */ - availableObjects: T[] = [] - - /** - * Factory method for creating a new object to add to the pool - */ - factory: () => T - - /** - * Returns the number of available objects in the object pool - */ - get available(): number { - return this.availableObjects.length - } - - /** - * Returns the number of used objects in the object pool - */ - get used(): number { - return this.size - this.availableObjects.length - } - - /** - * The number of objects in the pool - */ - size = 0 - - /** - * Constructor for a new object pool - * @param factory factory method for creating a new object - */ - constructor(factory: () => T, size?: number) { - this.factory = factory - if (size !== undefined) { - this.grow(size) - } - } - - /** - * Grows the object pool by a given amount - * @param count the count of objects to expand the object pool by - */ - grow(count: number): void { - for (let i = 0; i < count; i++) { - this.availableObjects.push(this.factory()) - } - this.size += count - } - - /** - * Frees a given number of currently available objects - * @param count the number of available objects to free - */ - free(count: number): void { - for (let i = 0; i < count; i++) { - const object = this.availableObjects.pop() - - if (object) { - this.size-- - } else { - break - } - } - } - - /** - * Requests an object from the object pool and returns it - * @returns an object from the object pool - */ - request(): T { - // grow the list by ~20% if there are no more available objects - if (this.availableObjects.length <= 0) { - this.grow(Math.round(this.size * 0.2) + 1) - } - - return this.availableObjects.pop() as T - } - - /** - * Recycles an object into the object pool - * @param object the object to recycle - */ - recycle(object: T): void { - this.availableObjects.push(object) - } -} diff --git a/packages/arancini-core/src/query-manager.ts b/packages/arancini-core/src/query-manager.ts deleted file mode 100644 index 5be6211a..00000000 --- a/packages/arancini-core/src/query-manager.ts +++ /dev/null @@ -1,247 +0,0 @@ -import type { Entity } from './entity' -import type { QueryBitSets, QueryDescription } from './query' -import { Query } from './query' -import { BitSet } from './utils/bit-set' -import type { World } from './world' - -type DedupedQuery = { - dedupeString: string - instances: Set - description: QueryDescription - bitSets: QueryBitSets - entities: Entity[] - entitySet: Set -} - -/** - * QueryManager is an internal class that manages Query instances - * - * @private internal class, do not use directly - */ -export class QueryManager { - /** - * Deduped Queries in the QueryManager - */ - dedupedQueries: Map = new Map() - - /** - * The World the QueryManager is in - */ - private world: World - - /** - * Constructor for a QueryManager - * @param world the World the QueryManager is in - */ - constructor(world: World) { - this.world = world - } - - /** - * Creates a new query by a query description - * @param queryDescription the description of the query to create - */ - createQuery(queryDescription: QueryDescription): Query { - const dedupeString = Query.getDescriptionDedupeString(queryDescription) - - let dedupedQuery = this.dedupedQueries.get(dedupeString) - - if (dedupedQuery === undefined) { - const isArray = Array.isArray(queryDescription) - if ( - (isArray && queryDescription.length === 0) || - (!isArray && - ((!queryDescription.all && - !queryDescription.any && - !queryDescription.not) || - (queryDescription.all && queryDescription.all.length === 0) || - (queryDescription.any && queryDescription.any.length === 0) || - (queryDescription.not && queryDescription.not.length === 0))) - ) { - throw new Error('Query must have at least one condition') - } - - dedupedQuery = { - dedupeString, - instances: new Set(), - description: queryDescription, - bitSets: this.getQueryBitSets(queryDescription), - entities: [], - entitySet: new Set(), - } - - const matches = this.getQueryResults(dedupedQuery.bitSets) - - for (const entity of matches) { - dedupedQuery.entities.push(entity) - dedupedQuery.entitySet.add(entity) - } - - this.dedupedQueries.set(dedupeString, dedupedQuery) - } - - const newQueryInstance = new Query(this.world, dedupeString) - newQueryInstance.entities = dedupedQuery.entities - - dedupedQuery.instances.add(newQueryInstance) - - return newQueryInstance - } - - /** - * Returns whether the query manager has the query - * @param queryDescription the query description to check for - */ - hasQuery(queryDescription: QueryDescription): boolean { - const dedupeString = Query.getDescriptionDedupeString(queryDescription) - return this.dedupedQueries.has(dedupeString) - } - - /** - * Updates queries after a component has been added to or removed from an entity - * @param entity the query - */ - onEntityComponentChange(entity: Entity): void { - for (const query of this.dedupedQueries.values()) { - const entityInQuery = query.entitySet.has(entity) - - const matchesQuery = this.matchesQueryConditions(query.bitSets, entity) - - if (matchesQuery && !entityInQuery) { - query.entities.push(entity) - query.entitySet.add(entity) - - for (const queryInstance of query.instances) { - queryInstance.onEntityAdded.emit(entity) - } - } else if (!matchesQuery && entityInQuery) { - const index = query.entities.indexOf(entity, 0) - if (index !== -1) { - query.entities.splice(index, 1) - } - query.entitySet.delete(entity) - - for (const queryInstance of query.instances) { - queryInstance.onEntityRemoved.emit(entity) - } - } - } - } - - /** - * Updates queries after a query has been removed from the World - * @param entity the query - */ - onEntityRemoved(entity: Entity): void { - for (const dedupedQuery of this.dedupedQueries.values()) { - const index = dedupedQuery.entities.indexOf(entity, 0) - if (index !== -1) { - dedupedQuery.entities.splice(index, 1) - } - dedupedQuery.entitySet.delete(entity) - - for (const queryInstance of dedupedQuery.instances) { - queryInstance.onEntityRemoved.emit(entity) - } - } - } - - /** - * Executes a query and returns a set of the matching Entities. - * By default the query is freshly evaluated, regardless of whether a query with the same description already exists in the world. - * If `options.useExisting` is true, results are taken from an existing query if present. - * @param queryDescription the query description - */ - find(queryDescription: QueryDescription): Entity[] { - const key = Query.getDescriptionDedupeString(queryDescription) - - const query = this.dedupedQueries.get(key) - - if (query) { - return query.entities - } - - return this.getQueryResults(this.getQueryBitSets(queryDescription)) - } - - /** - * Removes a query from the query manager - * @param query the query to remove - */ - removeQuery(query: Query): void { - const dedupedQuery = this.dedupedQueries.get(query.key) - if (dedupedQuery === undefined || !dedupedQuery.instances.has(query)) { - return - } - - dedupedQuery.instances.delete(query) - query.onEntityAdded.clear() - query.onEntityRemoved.clear() - - if (dedupedQuery.instances.size === 0) { - this.dedupedQueries.delete(dedupedQuery.dedupeString) - } - } - - private matchesQueryConditions( - queryBitSets: QueryBitSets, - entity: Entity - ): boolean { - if ( - queryBitSets.all && - !entity._componentsBitSet.containsAll(queryBitSets.all) - ) { - return false - } - - if ( - queryBitSets.any && - !entity._componentsBitSet.containsAny(queryBitSets.any) - ) { - return false - } - - if ( - queryBitSets.not && - entity._componentsBitSet.containsAny(queryBitSets.not) - ) { - return false - } - - return true - } - - private getQueryResults(queryBitSets: QueryBitSets): Entity[] { - const matches: Entity[] = [] - - for (const entity of this.world.entities.values()) { - if (this.matchesQueryConditions(queryBitSets, entity)) { - matches.push(entity) - } - } - - return matches - } - - private getQueryBitSets(queryDescription: QueryDescription): QueryBitSets { - const { all, any, not } = Array.isArray(queryDescription) - ? { all: queryDescription, any: undefined, not: undefined } - : queryDescription - - const queryBitSets: QueryBitSets = {} - - queryBitSets.all = all - ? new BitSet(all.map((component) => component.componentIndex)) - : undefined - - queryBitSets.any = any - ? new BitSet(any.map((component) => component.componentIndex)) - : undefined - - queryBitSets.not = not - ? new BitSet(not.map((component) => component.componentIndex)) - : undefined - - return queryBitSets - } -} diff --git a/packages/arancini-core/src/query.ts b/packages/arancini-core/src/query.ts index deab2036..9bdd2d1c 100644 --- a/packages/arancini-core/src/query.ts +++ b/packages/arancini-core/src/query.ts @@ -1,17 +1,9 @@ import type { ComponentDefinition } from './component' import type { Entity } from './entity' -import { Topic } from './events' -import type { BitSet } from './utils/bit-set' +import { EntityContainer } from './entity-container' +import { BitSet } from './utils/bit-set' import type { World } from './world' - -/** - * Enum for query condition types - */ -export const QueryConditionType = { - ALL: 'all', - ANY: 'any', - NOT: 'not', -} as const +export type QueryConditionType = 'all' | 'any' | 'not' /** * Type for query conditions @@ -19,9 +11,9 @@ export const QueryConditionType = { export type QueryDescription = | ComponentDefinition[] | { - [QueryConditionType.ALL]?: ComponentDefinition[] - [QueryConditionType.NOT]?: ComponentDefinition[] - [QueryConditionType.ANY]?: ComponentDefinition[] + all?: ComponentDefinition[] + not?: ComponentDefinition[] + any?: ComponentDefinition[] } export type QueryBitSets = { @@ -39,7 +31,7 @@ export type QueryBitSets = { * * Changes to Entity Components are queued, and Query results are updated as part of the World update loop. * - * Query results can also be retrieved once-off without creating a persistent query with `world.query(...)`. + * Query results can also be retrieved once-off without creating a persistent query with `world.find(...)`. * * ```ts * import { Component, System, World, QueryDescription } from '@arancini/core' @@ -64,7 +56,7 @@ export type QueryBitSets = { * } * * // get once-off query results, re-using existing query results if available - * world.query(simpleQueryDescription) + * world.find(simpleQueryDescription) * * // get a query that will update every world update * const query = world.query({ @@ -85,102 +77,253 @@ export type QueryBitSets = { * world.registerSystem(ExampleSystem) * ``` */ -export class Query { +export class Query extends EntityContainer { /** - * The query dedupe string + * Constructor for a new query instance + * @param world the world the query is in + * @param queryKey the key for the query */ - key: string + constructor( + public world: World, + public key: string, + public description: QueryDescription, + public bitSets: QueryBitSets + ) { + super() + } /** - * The current entities matched by the query + * Destroys the Query */ - entities: Entity[] = [] + destroy(): void { + this.world.queryManager.removeQuery(this) + } +} - /** - * Returns the first entity within this archetype. - * */ - get first(): Entity | undefined { - return this.entities[0] || undefined +export const getQueryDedupeString = ( + queryDescription: QueryDescription +): string => { + if (Array.isArray(queryDescription)) { + return queryDescription.map((c) => `${c.componentIndex}`).join('&') } - /** - * Event dispatcher for when an Entity is added to the query - */ - onEntityAdded = new Topic<[Entity]>() + return Object.entries(queryDescription) + .flatMap(([type, components]) => { + if (type === 'all') { + return components.map((c) => `${c.componentIndex}`).sort() + } + + return [`${type}:${components.sort().map((c) => c.componentIndex)}`] + }) + .sort() + .join('&') +} + +/** + * QueryManager is an internal class that manages Query instances + * + * @private internal class, do not use directly + */ +export class QueryManager { + queries: Map = new Map() + + queryOwners: Map = new Map() + + private world: World + + constructor(world: World) { + this.world = world + } /** - * Event dispatcher for when an Entity is removed from the query + * Creates a new query by a query description + * @param queryDescription the description of the query to create */ - onEntityRemoved = new Topic<[Entity]>(); + createQuery( + queryDescription: QueryDescription, + owner: unknown = 'standalone' + ): Query { + const dedupe = getQueryDedupeString(queryDescription) + + let query = this.queries.get(dedupe) + + if (query === undefined) { + const isArray = Array.isArray(queryDescription) + if ( + (isArray && queryDescription.length === 0) || + (!isArray && + ((!queryDescription.all && + !queryDescription.any && + !queryDescription.not) || + (queryDescription.all && queryDescription.all.length === 0) || + (queryDescription.any && queryDescription.any.length === 0) || + (queryDescription.not && queryDescription.not.length === 0))) + ) { + throw new Error('Query must have at least one condition') + } + + query = new Query( + this.world, + dedupe, + queryDescription, + this.getQueryBitSets(queryDescription) + ) + + const matches = this.getQueryResults(query.bitSets) + + for (const entity of matches) { + query._addEntity(entity) + } + + this.queries.set(dedupe, query) + } + + if (owner) { + const queryOwners = this.queryOwners.get(dedupe) ?? [] + queryOwners.push(owner) + this.queryOwners.set(dedupe, queryOwners) + } + + return query + } /** - * Iterator for entities matched by the query. Iterates over matching entities in reverse order. + * Removes a query from the query manager + * @param query the query to remove */ - [Symbol.iterator]() { - let index = this.entities.length - - const result: { - value: Entity - done: boolean - } = { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - value: undefined!, - done: false, + removeQuery(query: Query, owner: unknown = 'standalone'): void { + if (!this.queries.has(query.key)) { + return } - return { - next: () => { - result.value = this.entities[--index] - result.done = index < 0 - return result - }, + let usages = this.queryOwners.get(query.key) ?? [] + + usages = usages.filter((usage) => usage !== owner) + + if (usages.length > 0) { + this.queryOwners.set(query.key, usages) + return } + + this.queries.delete(query.key) + this.queryOwners.delete(query.key) + query.onEntityAdded.clear() + query.onEntityRemoved.clear() } /** - * The World the Query is in + * Returns whether the query manager has the query + * @param queryDescription the query description to check for */ - private world: World + hasQuery(queryDescription: QueryDescription): boolean { + const dedupeString = getQueryDedupeString(queryDescription) + return this.queries.has(dedupeString) + } /** - * Constructor for a new query instance - * @param world the world the query is in - * @param queryKey the key for the query + * Updates queries after a component has been added to or removed from an entity + * @param entity the query */ - constructor(world: World, queryKey: string) { - this.world = world - this.key = queryKey + onEntityComponentChange(entity: Entity): void { + for (const query of this.queries.values()) { + const matchesQuery = this.matchesQueryConditions(query.bitSets, entity) + const inQuery = query.has(entity) + + if (matchesQuery && !inQuery) { + query._addEntity(entity) + } else if (!matchesQuery && inQuery) { + query._removeEntity(entity) + } + } } /** - * Destroys the Query + * Updates queries after a query has been removed from the World + * @param entity the query */ - destroy(): void { - this.world.queryManager.removeQuery(this) + onEntityRemoved(entity: Entity): void { + for (const query of this.queries.values()) { + query._removeEntity(entity) + } } /** - * Returns a string that identifies a query description + * Executes a query and returns a set of the matching Entities. + * By default the query is freshly evaluated, regardless of whether a query with the same description already exists in the world. + * If `options.useExisting` is true, results are taken from an existing query if present. * @param queryDescription the query description - * @returns a string that identifies a query description - * @private called internally, do not call directly */ - static getDescriptionDedupeString( - queryDescription: QueryDescription - ): string { - if (Array.isArray(queryDescription)) { - return queryDescription.map((c) => `${c.componentIndex}`).join('&') + find(queryDescription: QueryDescription): Entity[] { + const key = getQueryDedupeString(queryDescription) + + const query = this.queries.get(key) + + if (query) { + return query.entities } - return Object.entries(queryDescription) - .flatMap(([type, components]) => { - if (type === QueryConditionType.ALL) { - return components.map((c) => `${c.componentIndex}`).sort() - } + return this.getQueryResults(this.getQueryBitSets(queryDescription)) + } + + private matchesQueryConditions( + queryBitSets: QueryBitSets, + entity: Entity + ): boolean { + if ( + queryBitSets.all && + !entity._componentsBitSet.containsAll(queryBitSets.all) + ) { + return false + } + + if ( + queryBitSets.any && + !entity._componentsBitSet.containsAny(queryBitSets.any) + ) { + return false + } + + if ( + queryBitSets.not && + entity._componentsBitSet.containsAny(queryBitSets.not) + ) { + return false + } + + return true + } + + private getQueryResults(queryBitSets: QueryBitSets): Entity[] { + const matches: Entity[] = [] + + for (const entity of this.world.entities.values()) { + if (this.matchesQueryConditions(queryBitSets, entity)) { + matches.push(entity) + } + } + + return matches + } + + private getQueryBitSets(queryDescription: QueryDescription): QueryBitSets { + const { all, any, not } = Array.isArray(queryDescription) + ? { all: queryDescription, any: undefined, not: undefined } + : queryDescription + + const queryBitSets: QueryBitSets = {} + + queryBitSets.all = all + ? new BitSet(all.map((component) => component.componentIndex)) + : undefined + + queryBitSets.any = any + ? new BitSet(any.map((component) => component.componentIndex)) + : undefined + + queryBitSets.not = not + ? new BitSet(not.map((component) => component.componentIndex)) + : undefined - return [`${type}:${components.sort().map((c) => c.componentIndex)}`] - }) - .sort() - .join('&') + return queryBitSets } } diff --git a/packages/arancini-core/src/system-manager.ts b/packages/arancini-core/src/system-manager.ts deleted file mode 100644 index 95babbe5..00000000 --- a/packages/arancini-core/src/system-manager.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { ComponentDefinition } from './component' -import type { Query, QueryDescription } from './query' -import type { System, SystemClass, SystemQueryOptions } from './system' -import { isSubclassMethodOverridden } from './utils' -import type { World } from './world' - -export type SystemAttributes = { - priority?: number -} - -export type SystemSingletonPlaceholder = { - __internal: { - placeholder: true - componentDefinition: ComponentDefinition - options?: SystemQueryOptions - } -} - -/** - * SystemManager is an internal class that manages Systems and calls their lifecycle hooks. - * - * Handles adding and removing systems and providing them with queries via the `QueryManager`. - * - * Maintains the usage of queries by systems and removes queries from the `QueryManager` if no systems are - * using a query. - * - * @private internal class, do not use directly - */ -export class SystemManager { - /** - * Systems in the System Manager - */ - systems: Map = new Map() - - /** - * Systems sorted by priority and registration order - */ - private sortedSystems: System[] = [] - - /** - * Counter for the number of systems registered, used to give systems a registration order - */ - private systemCounter = 0 - - /** - * The World the system manager belongs in - */ - private world: World - - /** - * Constructor for the SystemManager - * @param world the World for the SystemManager - */ - constructor(world: World) { - this.world = world - } - - /** - * Initialises the system manager - */ - init(): void { - for (const system of this.systems.values()) { - system.onInit() - } - - this.sortSystems() - } - - /** - * Updates Systems in the SystemManager - * @param delta the time elapsed in seconds - * @param time the current time in seconds - */ - update(delta: number, time: number): void { - for (const system of this.sortedSystems) { - if (!system.enabled) { - continue - } - - if ( - system.__internal.requiredQueries.length > 0 && - system.__internal.requiredQueries.some((q) => q.entities.length === 0) - ) { - continue - } - - system.onUpdate(delta, time) - } - } - - /** - * Destroys all systems - */ - destroy(): void { - for (const system of this.systems.values()) { - system.onDestroy() - } - } - - /** - * Adds a system to the system manager - * @param Clazz the system class to add - */ - registerSystem(Clazz: SystemClass, attributes?: SystemAttributes): void { - if (this.systems.has(Clazz)) { - throw new Error(`System "${Clazz.name}" has already been registered`) - } - - /* instantiate the system */ - this.systemCounter++ - const system = new Clazz(this.world) - this.systems.set(Clazz, system) - - /* set internal properties */ - system.__internal.class = Clazz - system.__internal.priority = attributes?.priority ?? 0 - system.__internal.order = this.systemCounter - - /* replace singleton placeholders */ - // eslint-disable-next-line guard-for-in - for (const fieldName in system) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _system = system as any - - const field = _system[fieldName] - - if (field?.__internal?.placeholder) { - const { - __internal: { componentDefinition, options }, - } = field as SystemSingletonPlaceholder - - const query = this.createSystemQuery( - system, - [componentDefinition], - options - ) - - const onQueryChange = () => { - _system[fieldName] = query.first?.get(componentDefinition) - } - - query.onEntityAdded.add(onQueryChange) - query.onEntityRemoved.add(onQueryChange) - onQueryChange() - } - } - - // if the system has an onUpdate method, add it to the sorted systems. - // systems are sorted immediately if the system manager is initialised, otherwise - // they are sorted on initialisation. - const hasOnUpdate = isSubclassMethodOverridden(Clazz, 'onUpdate') - if (hasOnUpdate) { - this.sortedSystems.push(system) - } - - if (this.world.initialised) { - system.onInit() - - if (hasOnUpdate) { - this.sortSystems() - } - } - } - - /** - * Unregisters a System from the SystemManager - * @param clazz the System to remove - */ - unregisterSystem(clazz: SystemClass): void { - const system = this.systems.get(clazz) - if (!system) { - return - } - - this.systems.delete(clazz) - this.sortedSystems = this.sortedSystems.filter( - (s) => s.__internal.class !== clazz - ) - - system.__internal.queries.forEach((query: Query) => { - this.world.queryManager.removeQuery(query) - }) - system.__internal.requiredQueries = [] - - system.onDestroy() - } - - /** - * Creates a query for a system - * @param system the system to create the query for - * @param queryDescription the query description - * @param options the options for the query - */ - createSystemQuery( - system: System, - queryDescription: QueryDescription, - options?: SystemQueryOptions - ): Query { - const query = this.world.queryManager.createQuery(queryDescription) - - system.__internal.queries.add(query) - - if (options?.required) { - system.__internal.requiredQueries.push(query) - } - - return query - } - - private sortSystems(): void { - this.sortedSystems.sort((a, b) => { - return ( - // higher priority runs first - b.__internal.priority - a.__internal.priority || - // default to order system was registered - a.__internal.order - b.__internal.order - ) - }) - } -} diff --git a/packages/arancini-core/src/system.ts b/packages/arancini-core/src/system.ts index cf2c8880..0a355ef5 100644 --- a/packages/arancini-core/src/system.ts +++ b/packages/arancini-core/src/system.ts @@ -1,7 +1,7 @@ -import { ComponentDefinition, ComponentDefinitionInstance } from './component' +import { ComponentDefinition, ComponentInstance } from './component' import type { QueryDescription } from './query' import { Query } from './query' -import type { SystemSingletonPlaceholder } from './system-manager' +import { isSubclassMethodOverridden } from './utils' import type { World } from './world' export type SystemQueryOptions = { @@ -117,9 +117,9 @@ export abstract class System { onUpdate(_delta: number, _time: number) {} /** - * Destroys the system and removes it from the World + * Unregisters the system */ - destroy(): void { + unregister(): void { this.world.unregisterSystem(this.__internal.class) } @@ -142,20 +142,294 @@ export abstract class System { /** * Shortcut for creating a query for a singleton component. - * @param clazz the singleton component class + * @param componentDefinition the singleton component */ protected singleton>( componentDefinition: T, options?: SystemQueryOptions - ): ComponentDefinitionInstance | undefined { - const placeholder: SystemSingletonPlaceholder = { + ): ComponentInstance | undefined { + const placeholder: SingletonQueryPlaceholder = { __internal: { - placeholder: true, + systemSingletonPlaceholder: true, componentDefinition, options, }, } - return placeholder as ComponentDefinitionInstance | undefined + return placeholder as ComponentInstance | undefined + } + + /** + * Returns a reference to another system that updates as systems are registered and unregistered. + * @param systemClass + * @returns a reference to the system that will be set just before the onInit method is called. + */ + protected attach>(systemClass: T) { + const placeholder: AttachedSystemPlaceholder = { + __internal: { + attachedSystemPlaceholder: true, + systemClass, + }, + } + + return placeholder as InstanceType | undefined + } +} + +export type SystemAttributes = { + priority?: number +} + +type AttachedSystemPlaceholder = { + __internal: { + attachedSystemPlaceholder: true + systemClass: SystemClass + } +} + +type SingletonQueryPlaceholder = { + __internal: { + systemSingletonPlaceholder: true + componentDefinition: ComponentDefinition + options?: SystemQueryOptions + } +} + +/** + * SystemManager is an internal class that manages Systems and calls their lifecycle hooks. + * + * Handles adding and removing systems and providing them with queries via the `QueryManager`. + * + * Maintains the usage of queries by systems and removes queries from the `QueryManager` if no systems are + * using a query. + * + * @private internal class, do not use directly + */ +export class SystemManager { + /** + * Systems in the System Manager + */ + systems: Map = new Map() + + private sortedSystems: System[] = [] + + private systemCounter = 0 + + private systemAttachments: Map< + System, + { field: string; systemClass: SystemClass }[] + > = new Map() + + private world: World + + constructor(world: World) { + this.world = world + } + + /** + * Initialises the system manager + */ + init(): void { + for (const system of this.systems.values()) { + this.initSystem(system) + } + + this.sortSystems() + } + + /** + * Updates Systems in the SystemManager + * @param delta the time elapsed in seconds + * @param time the current time in seconds + */ + update(delta: number, time: number): void { + for (const system of this.sortedSystems.values()) { + if (!system.enabled) { + continue + } + + if ( + system.__internal.requiredQueries.length > 0 && + system.__internal.requiredQueries.some((q) => q.entities.length === 0) + ) { + continue + } + + system.onUpdate(delta, time) + } + } + + /** + * Destroys all systems + */ + destroy(): void { + for (const system of this.systems.values()) { + system.onDestroy() + } + } + + /** + * Adds a system to the system manager + * @param Clazz the system class to add + */ + registerSystem(Clazz: SystemClass, attributes?: SystemAttributes): void { + if (this.systems.has(Clazz)) { + throw new Error(`System "${Clazz.name}" has already been registered`) + } + + /* instantiate the system */ + this.systemCounter++ + const system = new Clazz(this.world) + this.systems.set(Clazz, system) + + /* set internal properties */ + system.__internal.class = Clazz + system.__internal.priority = attributes?.priority ?? 0 + system.__internal.order = this.systemCounter + + this.initSingletonQueries(system) + this.updateAllSystemAttachments() + + // if the system has an onUpdate method, add it to the sorted systems. + // systems are sorted immediately if the system manager is initialised, otherwise + // they are sorted on initialisation. + const hasOnUpdate = isSubclassMethodOverridden(Clazz, 'onUpdate') + if (hasOnUpdate) { + this.sortedSystems.push(system) + } + + if (this.world.initialised) { + this.initSystem(system) + + if (hasOnUpdate) { + this.sortSystems() + } + } + } + + /** + * Unregisters a System from the SystemManager + * @param clazz the System to remove + */ + unregisterSystem(clazz: SystemClass): void { + const system = this.systems.get(clazz) + if (!system) { + return + } + + this.systems.delete(clazz) + this.sortedSystems = this.sortedSystems.filter( + (s) => s.__internal.class !== clazz + ) + + system.__internal.queries.forEach((query: Query) => { + this.world.queryManager.removeQuery(query, system) + }) + system.__internal.requiredQueries = [] + + system.onDestroy() + + this.updateAllSystemAttachments() + } + + /** + * Creates a query for a system + * @param system the system to create the query for + * @param queryDescription the query description + * @param options the options for the query + */ + createSystemQuery( + system: System, + queryDescription: QueryDescription, + options?: SystemQueryOptions + ): Query { + const query = this.world.queryManager.createQuery(queryDescription, system) + + system.__internal.queries.add(query) + + if (options?.required) { + system.__internal.requiredQueries.push(query) + } + + return query + } + + private initSystem(system: System) { + system.onInit() + } + + private initSingletonQueries(system: System | any) { + for (const fieldName in system) { + const _system = system as any + + const field = _system[fieldName] + + if (field?.__internal?.systemSingletonPlaceholder) { + const { + __internal: { componentDefinition, options }, + } = field as SingletonQueryPlaceholder + + const query = this.createSystemQuery( + system, + [componentDefinition], + options + ) + + const onQueryChange = () => { + _system[fieldName] = query.first?.get(componentDefinition) + } + + query.onEntityAdded.add(onQueryChange) + query.onEntityRemoved.add(onQueryChange) + onQueryChange() + } + } + } + + private updateSystemAttachments(system: System | any) { + // check for placeholders + for (const fieldName in system) { + const field = system[fieldName] + + if (field?.__internal?.attachedSystemPlaceholder) { + const systemAttachments = this.systemAttachments.get(system) ?? [] + + systemAttachments.push({ + field: fieldName, + systemClass: field.__internal.systemClass, + }) + + this.systemAttachments.set(system, systemAttachments) + + const { + __internal: { systemClass }, + } = field as AttachedSystemPlaceholder + + system[fieldName] = this.world.getSystem(systemClass) + } + } + + // update existing attachments + const systemAttachments = this.systemAttachments.get(system) ?? [] + for (const { field, systemClass } of systemAttachments) { + system[field] = this.world.getSystem(systemClass) + } + } + + private updateAllSystemAttachments() { + for (const system of this.systems.values()) { + this.updateSystemAttachments(system) + } + } + + private sortSystems(): void { + this.sortedSystems.sort((a, b) => { + return ( + // higher priority runs first + b.__internal.priority - a.__internal.priority || + // default to order system was registered + a.__internal.order - b.__internal.order + ) + }) } } diff --git a/packages/arancini-core/src/events/topic.ts b/packages/arancini-core/src/topic.ts similarity index 100% rename from packages/arancini-core/src/events/topic.ts rename to packages/arancini-core/src/topic.ts diff --git a/packages/arancini-core/src/world.ts b/packages/arancini-core/src/world.ts index 589554a6..92535d07 100644 --- a/packages/arancini-core/src/world.ts +++ b/packages/arancini-core/src/world.ts @@ -1,12 +1,17 @@ -import type { ComponentDefinition } from './component' +import { + InternalComponentInstanceProperties, + type ComponentDefinition, + ComponentDefinitionType, + Component, +} from './component' import { ComponentRegistry } from './component-registry' import type { Entity } from './entity' -import { EntityManager } from './entity-manager' +import { EntityContainer } from './entity-container' +import { ComponentPool, EntityPool } from './pools' import type { Query, QueryDescription } from './query' -import { QueryManager } from './query-manager' -import type { System, SystemClass } from './system' -import type { SystemAttributes } from './system-manager' -import { SystemManager } from './system-manager' +import { QueryManager } from './query' +import type { System, SystemAttributes, SystemClass } from './system' +import { SystemManager } from './system' /** * A World that can contain Entities, Systems, and Queries. @@ -27,11 +32,11 @@ import { SystemManager } from './system-manager' * // (Systems will be called with a delta of 0.1) * world.update(0.1) * - * // destroy the world, removing all entities - * world.destroy() + * // reset the world, removing all entities + * world.reset() * ``` */ -export class World { +export class World extends EntityContainer { /** * Whether the World has been initialised */ @@ -42,12 +47,6 @@ export class World { */ time = 0 - /** - * The EntityManager for the World - * Manages Entities and Components - */ - entityManager: EntityManager - /** * The QueryManager for the World * Manages and updates Queries @@ -67,18 +66,25 @@ export class World { componentRegistry: ComponentRegistry /** - * Entities in the World + * Object pool for components */ - entities: Map = new Map() + componentPool: ComponentPool + + /** + * Object pool for entities + */ + entityPool: EntityPool /** * Constructor for a World */ constructor() { + super() this.componentRegistry = new ComponentRegistry(this) - this.entityManager = new EntityManager(this) this.queryManager = new QueryManager(this) this.systemManager = new SystemManager(this) + this.componentPool = new ComponentPool() + this.entityPool = new EntityPool(this) } /** @@ -86,7 +92,11 @@ export class World { */ init(): void { this.initialised = true - this.entityManager.init() + + for (const entity of this.entities) { + this.initialiseEntity(entity) + } + this.systemManager.init() } @@ -100,13 +110,20 @@ export class World { } /** - * Destroys the World + * Resets the World. + * + * This removes all entities, and calls onDestroy on all Systems. + * Components and Systems will remain registered. + * The World will need to be initialised again after this. */ - destroy(): void { + reset(): void { this.time = 0 this.initialised = false this.systemManager.destroy() - this.entityManager.destroy() + + for (const entity of this.entities.values()) { + this.destroy(entity) + } } /** @@ -131,15 +148,43 @@ export class World { * ``` */ create(initFn?: (entity: Entity) => void): Entity { - const entity = this.entityManager.createEntity() + const entity = this.entityPool.request() + + if (this.initialised) { + this.initialiseEntity(entity) + } if (initFn) { entity.bulk(initFn) } + this._addEntity(entity) + return entity } + /** + * Destroys an Entity + * @param entity the Entity to destroy + */ + destroy(entity: Entity): void { + this._removeEntity(entity) + + entity._updateQueries = false + entity._updateBitSet = false + + for (const component of Object.values(entity._components)) { + const internal = component as InternalComponentInstanceProperties + entity.remove(internal._arancini_component_definition!) + } + + entity._updateQueries = true + entity._updateBitSet = true + + this.queryManager.onEntityRemoved(entity) + this.entityPool.recycle(entity) + } + /** * Creates a Query * @param queryDescription the query description @@ -208,4 +253,18 @@ export class World { getSystems(): System[] { return Array.from(this.systemManager.systems.values()) } + + private initialiseEntity(e: Entity): void { + e.initialised = true + + for (const component of Object.values(e._components)) { + const internal = component as InternalComponentInstanceProperties + if ( + internal._arancini_component_definition?.type === + ComponentDefinitionType.CLASS + ) { + ;(component as Component).onInit() + } + } + } } diff --git a/packages/arancini-core/tsconfig.json b/packages/arancini-core/tsconfig.json index c1186514..a0d934ea 100644 --- a/packages/arancini-core/tsconfig.json +++ b/packages/arancini-core/tsconfig.json @@ -18,7 +18,8 @@ "allowSyntheticDefaultImports": true, "noEmit": false, "declaration": true, - "outDir": "./dist" + "outDir": "./dist", + "experimentalDecorators": true, }, "files": ["./src/index.ts"] } diff --git a/packages/arancini-core/tst/component-registry.spec.ts b/packages/arancini-core/tst/component-registry.spec.ts index 6e036231..f045d096 100644 --- a/packages/arancini-core/tst/component-registry.spec.ts +++ b/packages/arancini-core/tst/component-registry.spec.ts @@ -43,14 +43,16 @@ describe('ComponentRegistry', () => { }) it('should support registering tag components', () => { - const TagComponent = Component.tag() + const TagComponent = Component.tag('tag') world.registerComponent(TagComponent) expect(TagComponent.componentIndex).toBe(0) }) it('should support registering object components', () => { - const ObjectComponent = Component.object<{ x: number; y: number }>() + const ObjectComponent = Component.object<{ x: number; y: number }>( + 'Object Component' + ) world.registerComponent(ObjectComponent) expect(ObjectComponent.componentIndex).toBe(0) diff --git a/packages/arancini-core/tst/entities-and-components.spec.ts b/packages/arancini-core/tst/entities-and-components.spec.ts index 43a869f4..604cec3d 100644 --- a/packages/arancini-core/tst/entities-and-components.spec.ts +++ b/packages/arancini-core/tst/entities-and-components.spec.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, test, vi } from 'vitest' -import { Component, World } from '../src' +import { + Component, + World, + cloneComponentDefinition, +} from '../src' import { InternalComponentInstanceProperties } from '../dist' describe('Entities and Components', () => { @@ -19,10 +23,10 @@ describe('Entities and Components', () => { // assert the entity has been reset expect(entity.id).not.toEqual(id) - expect(world.entities.has(entity.id)).toBe(false) + expect(world.has(entity)).toBe(false) }) - describe('class component', () => { + describe('pooled class component', () => { test('with no construct args', () => { class TestComponent extends Component {} @@ -58,7 +62,9 @@ describe('Entities and Components', () => { }) test('object component', () => { - const TestComponent = Component.object<{ x: number; y: number }>() + const TestComponent = Component.object<{ x: number; y: number }>( + 'TestComponent' + ) world.registerComponent(TestComponent) @@ -75,19 +81,17 @@ describe('Entities and Components', () => { const internal = testComponent as unknown as InternalComponentInstanceProperties - expect(internal._arancini_entity).toBe(entity) expect(internal._arancini_id).toBeTruthy() expect(internal._arancini_component_definition).toBe(TestComponent) entity.remove(TestComponent) - expect(internal._arancini_entity).toBeUndefined() expect(internal._arancini_id).toBeUndefined() expect(internal._arancini_component_definition).toBeUndefined() }) test('tag component', () => { - const TagComponent = Component.tag() + const TagComponent = Component.tag('TagComponent') world.registerComponent(TagComponent) @@ -99,6 +103,27 @@ describe('Entities and Components', () => { entity.remove(TagComponent) }) + test('cloneComponentDefinition', () => { + const TestComponent = Component.object<{ x: number; y: number }>( + 'TestComponent' + ) + const Clone = cloneComponentDefinition(TestComponent) + + + world.registerComponent(TestComponent) + world.registerComponent(Clone) + + expect(world.componentRegistry.components.size).toBe(2) + + class TestClassComponent extends Component {} + const ClassClone = cloneComponentDefinition(TestClassComponent) + + world.registerComponent(TestClassComponent) + world.registerComponent(ClassClone) + + expect(world.componentRegistry.components.size).toBe(4) + }) + test('creating an entity with initial components', () => { class TestComponent extends Component {} @@ -283,7 +308,9 @@ describe('Entities and Components', () => { }) describe('find', () => { - class TestComponentOne extends Component {} + class TestComponentOne extends Component { + value = 0 + } beforeEach(() => { world.registerComponent(TestComponentOne) @@ -292,7 +319,9 @@ describe('Entities and Components', () => { it('should return undefined if the component is not in the entity', () => { const entity = world.create() - expect(entity.find(TestComponentOne)).toBeUndefined() + const result = entity.find(TestComponentOne) + + expect(result).toBeUndefined() }) it('should return the component instance if the component is in the entity', () => { @@ -300,7 +329,10 @@ describe('Entities and Components', () => { entity.add(TestComponentOne) - expect(entity.find(TestComponentOne)).toBeInstanceOf(TestComponentOne) + const result = entity.find(TestComponentOne) + + expect(result!.value).toBe(0) + expect(result).toBeInstanceOf(TestComponentOne) }) }) }) @@ -362,9 +394,9 @@ describe('Entities and Components', () => { expect(entity.getAll()).toEqual([]) - entity.add(TestComponentOne) + const testComponentOne = entity.add(TestComponentOne) - expect(entity.getAll()).toEqual([expect.any(TestComponentOne)]) + expect(entity.getAll()).toEqual([testComponentOne]) entity.remove(TestComponentOne) diff --git a/packages/arancini-core/tst/pools/component-pool.spec.ts b/packages/arancini-core/tst/pools/component-pool.spec.ts index 57c20097..f86ac390 100644 --- a/packages/arancini-core/tst/pools/component-pool.spec.ts +++ b/packages/arancini-core/tst/pools/component-pool.spec.ts @@ -1,10 +1,13 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { Component } from '../../src/component' -import { ComponentPool } from '../../src/pools/component-pool' +import { Component, objectPooled } from '../../src/component' +import { ComponentPool } from '../../src/pools' import { World } from '../../src/world' describe('ComponentPool', () => { + @objectPooled() class ExampleComponentOne extends Component {} + + @objectPooled() class ExampleComponentTwo extends Component {} let world: World @@ -14,7 +17,7 @@ describe('ComponentPool', () => { world = new World() world.registerComponent(ExampleComponentOne) world.registerComponent(ExampleComponentTwo) - pool = world.entityManager.componentPool + pool = world.componentPool }) it('should create a new pool on retrieving a component for the first time', () => { @@ -55,8 +58,9 @@ describe('ComponentPool', () => { expect(pool.size).toBe(0) expect(pool.available).toBe(0) expect(pool.used).toBe(0) - - const component = world.create().add(ExampleComponentOne) + + const entity = world.create() + const component = entity.add(ExampleComponentOne) expect(pool.totalPools).toBe(1) expect(pool.size).toBe(1) @@ -66,7 +70,7 @@ describe('ComponentPool', () => { expect(component).toBeTruthy() expect(component instanceof ExampleComponentOne).toBeTruthy() - pool.recycle(component) + entity.remove(ExampleComponentOne) expect(pool.totalPools).toBe(1) expect(pool.size).toBe(1) diff --git a/packages/arancini-core/tst/pools/entity-pool.spec.ts b/packages/arancini-core/tst/pools/entity-pool.spec.ts index 03bbd344..bcf3c8eb 100644 --- a/packages/arancini-core/tst/pools/entity-pool.spec.ts +++ b/packages/arancini-core/tst/pools/entity-pool.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' import { Entity } from '../../src/entity' -import { EntityPool } from '../../src/pools/entity-pool' +import { EntityPool } from '../../src/pools' import { World } from '../../src/world' describe('EntityPool', () => { @@ -9,7 +9,7 @@ describe('EntityPool', () => { beforeEach(() => { world = new World() - pool = world.entityManager.entityPool + pool = world.entityPool }) it('should return an entity on request', () => { diff --git a/packages/arancini-core/tst/pools/object-pool.spec.ts b/packages/arancini-core/tst/pools/object-pool.spec.ts index ab422f35..0b2682f1 100644 --- a/packages/arancini-core/tst/pools/object-pool.spec.ts +++ b/packages/arancini-core/tst/pools/object-pool.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable max-classes-per-file */ import { describe, expect, it } from 'vitest' -import { ObjectPool } from '../../src/pools/object-pool' +import { ObjectPool } from '../../src/pools' describe('ObjectPool', () => { it('should construct with and without an initial size argument', () => { diff --git a/packages/arancini-core/tst/query.spec.ts b/packages/arancini-core/tst/query.spec.ts index 5ef6c39d..99237aaa 100644 --- a/packages/arancini-core/tst/query.spec.ts +++ b/packages/arancini-core/tst/query.spec.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Entity, QueryDescription } from '../src' -import { Component, Query, System, World } from '../src' +import { + Component, + Query, + System, + World, + getQueryDedupeString, +} from '../src' class TestComponentOne extends Component {} class TestComponentTwo extends Component {} @@ -87,14 +93,18 @@ describe('Query', () => { // creating an entity matching the removed query should not update the query const entityTwo = world.create() entityTwo.add(TestComponentOne) - expect(query).toBeTruthy() expect(query.entities.length).toBe(1) expect(query.entities.includes(entityOne)).toBeTruthy() expect(query.entities.includes(entityTwo)).toBeFalsy() // removing a query that isn't in the world is swallowed silently world.queryManager.removeQuery( - new Query({} as World, 'some key not in the query manager') + new Query( + {} as World, + 'some key not in the query manager', + description, + undefined! + ) ) // removing an already removed query is swallowed silently @@ -253,9 +263,7 @@ describe('Query', () => { entity.remove(TestComponentOne) expect( - world.queryManager.dedupedQueries - .get(system.testQuery.key) - ?.entitySet.has(entity) + world.queryManager.queries.get(system.testQuery.key)?.has(entity) ).toBe(false) expect(system.testQuery.entities.length).toBe(0) @@ -416,17 +424,13 @@ describe('Query', () => { entityTwo.add(TestComponentOne) entityTwo.add(TestComponentTwo) - expect( - world.queryManager.dedupedQueries - .get(query.key) - ?.entitySet.has(entityOne) - ).toBe(true) + expect(world.queryManager.queries.get(query.key)?.has(entityOne)).toBe( + true + ) - expect( - world.queryManager.dedupedQueries - .get(query.key) - ?.entitySet.has(entityTwo) - ).toBe(true) + expect(world.queryManager.queries.get(query.key)?.has(entityTwo)).toBe( + true + ) expect(query.entities.length).toBe(2) expect(query.entities.includes(entityOne)).toBeTruthy() @@ -438,17 +442,13 @@ describe('Query', () => { // destroy entityOne, removing it from the query entityOne.destroy() - expect( - world.queryManager.dedupedQueries - .get(query.key) - ?.entitySet.has(entityOne) - ).toBe(false) + expect(world.queryManager.queries.get(query.key)?.has(entityOne)).toBe( + false + ) - expect( - world.queryManager.dedupedQueries - .get(query.key) - ?.entitySet.has(entityTwo) - ).toBe(true) + expect(world.queryManager.queries.get(query.key)?.has(entityTwo)).toBe( + true + ) expect(query.entities.length).toBe(1) expect(query.entities.includes(entityOne)).toBeFalsy() @@ -462,7 +462,7 @@ describe('Query', () => { all: [TestComponentOne, TestComponentTwo], } - expect(Query.getDescriptionDedupeString(queryOne)).toEqual('0&1') + expect(getQueryDedupeString(queryOne)).toEqual('0&1') }) it('should return the same key for two matching query descriptions', () => { @@ -478,8 +478,8 @@ describe('Query', () => { any: [TestComponentTwo, TestComponentOne], } - expect(Query.getDescriptionDedupeString(queryOne)).toEqual( - Query.getDescriptionDedupeString(queryTwo) + expect(getQueryDedupeString(queryOne)).toEqual( + getQueryDedupeString(queryTwo) ) }) @@ -492,9 +492,9 @@ describe('Query', () => { all: [TestComponentOne], } - expect( - Query.getDescriptionDedupeString(differentComponentsOne) - ).not.toEqual(Query.getDescriptionDedupeString(differentComponentsTwo)) + expect(getQueryDedupeString(differentComponentsOne)).not.toEqual( + getQueryDedupeString(differentComponentsTwo) + ) const differentConditionOne: QueryDescription = { all: [TestComponentOne], @@ -504,9 +504,9 @@ describe('Query', () => { not: [TestComponentOne], } - expect( - Query.getDescriptionDedupeString(differentConditionOne) - ).not.toEqual(Query.getDescriptionDedupeString(differentConditionTwo)) + expect(getQueryDedupeString(differentConditionOne)).not.toEqual( + getQueryDedupeString(differentConditionTwo) + ) const partiallyDifferentOne: QueryDescription = { all: [TestComponentOne], @@ -517,9 +517,9 @@ describe('Query', () => { not: [TestComponentTwo], } - expect( - Query.getDescriptionDedupeString(partiallyDifferentOne) - ).not.toEqual(Query.getDescriptionDedupeString(partiallyDifferentTwo)) + expect(getQueryDedupeString(partiallyDifferentOne)).not.toEqual( + getQueryDedupeString(partiallyDifferentTwo) + ) }) }) }) diff --git a/packages/arancini-core/tst/system.spec.ts b/packages/arancini-core/tst/system.spec.ts index 7d4c7e70..155d655f 100644 --- a/packages/arancini-core/tst/system.spec.ts +++ b/packages/arancini-core/tst/system.spec.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest' -import { Component, Entity, System, World } from '../src' +import { Entity, Component, System, World } from '../src' class TestComponentOne extends Component {} class TestComponentTwo extends Component {} @@ -167,7 +167,7 @@ describe('System', () => { testSystem.enabled = false world.update() - world.destroy() + world.reset() expect(systemInitJestFn).toHaveBeenCalledTimes(1) @@ -260,14 +260,7 @@ describe('System', () => { test('systems can be removed, and queries will be removed if they are no longer used by any systems', () => { world.registerSystem(TestSystemWithQuery) - const systemOne = world.getSystem( - TestSystemWithQuery - ) as TestSystemWithQuery - world.registerSystem(AnotherTestSystemWithQuery) - const systemTwo = world.getSystem( - AnotherTestSystemWithQuery - ) as AnotherTestSystemWithQuery expect( world.queryManager.hasQuery({ @@ -275,7 +268,7 @@ describe('System', () => { }) ).toBe(true) - systemOne.destroy() + world.unregisterSystem(TestSystemWithQuery) expect( world.queryManager.hasQuery({ @@ -283,7 +276,7 @@ describe('System', () => { }) ).toBe(true) - systemTwo.destroy() + world.unregisterSystem(AnotherTestSystemWithQuery) expect( world.queryManager.hasQuery({ @@ -314,23 +307,15 @@ describe('System', () => { ).toBe(true) // destroy both systems using the query - systemOne.destroy() - systemTwo.destroy() + systemOne.unregister() + systemTwo.unregister() - expect( - world.queryManager.hasQuery({ - all: [TestComponentOne], - }) - ).toBe(true) + expect(world.queryManager.hasQuery(testSystemQueryDescription)).toBe(true) // remove the query manually query.destroy() - expect( - world.queryManager.hasQuery({ - all: [TestComponentOne], - }) - ).toBe(false) + expect(world.queryManager.hasQuery(testSystemQueryDescription)).toBe(false) }) test('onUpdate will not be called if any required queries have no results', () => { @@ -381,7 +366,6 @@ describe('System', () => { const testComponentOne = testEntity.add(TestComponentOne) expect(system.singletonComponent).toBe(testComponentOne) - expect(system.singletonComponent?._arancini_entity).toBe(testEntity) // system should update as the singleton is now defined world.update() @@ -392,17 +376,34 @@ describe('System', () => { expect(system.singletonComponent).toBe(undefined) // ensure the old entity and component is not re-used - world.entityManager.entityPool.free(1) - world.entityManager.componentPool.free(TestComponentOne, 1) + world.entityPool.free(1) + world.componentPool.free(TestComponentOne, 1) // singletonComponent should be set after a new entity with the component is created const newTestEntity = world.create() const newTestComponentOne = newTestEntity.add(TestComponentOne) expect(system.singletonComponent).toBe(newTestComponentOne) - expect(system.singletonComponent?._arancini_entity).toBe(newTestEntity) expect(system.singletonComponent).not.toBe(testComponentOne) expect(system.singletonComponent).not.toBe(testEntity) }) + + test('systems can attach other systems', () => { + class TestSystemWithAttachedSystem extends System { + systemOne = this.attach(SystemOne) + } + + world.registerSystem(TestSystemWithAttachedSystem) + world.registerSystem(SystemOne) + + const system = world.getSystem(TestSystemWithAttachedSystem)! + + expect(system.systemOne).toBeTruthy() + expect(system.systemOne).toBeInstanceOf(SystemOne) + + world.unregisterSystem(SystemOne) + + expect(system.systemOne).toBe(undefined) + }) }) diff --git a/packages/arancini-core/tst/world.spec.ts b/packages/arancini-core/tst/world.spec.ts index 8b13a66c..3ecbb5a1 100644 --- a/packages/arancini-core/tst/world.spec.ts +++ b/packages/arancini-core/tst/world.spec.ts @@ -28,15 +28,15 @@ describe('World', () => { expect(systems[0].__internal.class).toBe(TestSystem) }) - test('destroy', () => { + test('reset', () => { world.create() expect(world.initialised).toBe(true) - expect(world.entities.size).toBe(1) + expect(world.entities.length).toBe(1) - world.destroy() + world.reset() expect(world.initialised).toBe(false) - expect(world.entities.size).toBe(0) + expect(world.entities.length).toBe(0) }) }) diff --git a/packages/arancini-react/.storybook/stories/ExistingWorld.stories.tsx b/packages/arancini-react/.storybook/stories/ExistingWorld.stories.tsx index c550cbc5..2d7172d2 100644 --- a/packages/arancini-react/.storybook/stories/ExistingWorld.stories.tsx +++ b/packages/arancini-react/.storybook/stories/ExistingWorld.stories.tsx @@ -1,5 +1,5 @@ -import * as A from '@arancini/core' -import { Html, Text } from '@react-three/drei' +import { World } from '@arancini/core' +import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' import React, { useEffect, useState } from 'react' import { Lifetime, Repeat } from 'timeline-composer' @@ -10,7 +10,7 @@ export default { title: 'Existing World', } -const world = new A.World() +const world = new World() world.init() const ECS = createECS(world) @@ -28,7 +28,7 @@ export const ExistingWorld = () => { useEffect(() => { const interval = setInterval(() => { - const n = world.entities.size + const n = world.entities.length setWorldStats(`${n} ${n === 1 ? 'entity' : 'entities'}`) }, 1 / 10) diff --git a/packages/arancini-react/.storybook/stories/ExternalPhysicsLibrary.stories.tsx b/packages/arancini-react/.storybook/stories/ExternalPhysicsLibrary.stories.tsx index 627dabe0..758bccf6 100644 --- a/packages/arancini-react/.storybook/stories/ExternalPhysicsLibrary.stories.tsx +++ b/packages/arancini-react/.storybook/stories/ExternalPhysicsLibrary.stories.tsx @@ -21,13 +21,7 @@ const boxBoxContactMaterial = new P2.ContactMaterial( const Object3DComponent = Component.object('Object3D') -class RigidBodyComponent extends Component { - body!: P2.Body - - construct(body: P2.Body) { - this.body = body - } -} +const RigidBodyComponent = Component.object('RigidBody') class PhysicsSystem extends System { bodiesQuery = this.query([RigidBodyComponent]) @@ -41,7 +35,7 @@ class PhysicsSystem extends System { this.physicsWorld.addContactMaterial(boxBoxContactMaterial) this.bodiesQuery.onEntityAdded.add((added) => { - const body = added.get(RigidBodyComponent).body + const body = added.get(RigidBodyComponent) this.bodies.set(added.id, body) this.physicsWorld.addBody(body) }) @@ -62,7 +56,7 @@ class PhysicsSystem extends System { const object3D = entity.find(Object3DComponent) if (object3D === undefined) continue - const { body } = entity.get(RigidBodyComponent) + const body = entity.get(RigidBodyComponent) object3D.position.set(body.position[0], body.position[1], 0) object3D.rotation.set(0, 0, body.angle) } @@ -77,10 +71,6 @@ world.init() const ECS = createECS(world) -const Queries = { - TO_RENDER: ECS.world.query([RigidBodyComponent]), -} - const Plane = () => { const planeBody = useMemo(() => { const body = new P2.Body({ @@ -147,18 +137,18 @@ const App = () => { {/* render rigid bodies */} - + {(entity) => { - const colliderComponent = entity.get(RigidBodyComponent) + const body = entity.get(RigidBodyComponent) const boxes: P2.Box[] = [] const planes: P2.Plane[] = [] - for (const shape of colliderComponent.body.shapes) { - if (shape instanceof P2.Box) { - boxes.push(shape) - } else if (shape instanceof P2.Plane) { - planes.push(shape) + for (const shape of body.shapes) { + if (shape.type === P2.Shape.BOX) { + boxes.push(shape as P2.Box) + } else if (shape.type === P2.Shape.PLANE) { + planes.push(shape as P2.Plane) } } diff --git a/packages/arancini-react/.storybook/stories/RandomWalkers.stories.tsx b/packages/arancini-react/.storybook/stories/RandomWalkers.stories.tsx index bdea5dd4..ddca7850 100644 --- a/packages/arancini-react/.storybook/stories/RandomWalkers.stories.tsx +++ b/packages/arancini-react/.storybook/stories/RandomWalkers.stories.tsx @@ -1,8 +1,6 @@ -import * as A from '@arancini/core' +import { Component, System, World } from '@arancini/core' import { OrbitControls } from '@react-three/drei' import { useFrame } from '@react-three/fiber' - -import { Component, System, World } from '@arancini/core' import React from 'react' import { createECS } from '../../src' import { Setup } from '../Setup' @@ -13,7 +11,7 @@ export default { const WalkingComponent = Component.tag('Walking') -const Object3DComponent = A.Component.object('Object3D') +const Object3DComponent = Component.object('Object3D') class WalkingSystem extends System { walking = this.query([Object3DComponent, WalkingComponent]) diff --git a/packages/arancini-react/.storybook/stories/Selection.stories.tsx b/packages/arancini-react/.storybook/stories/Selection.stories.tsx index 9362a96a..391f4933 100644 --- a/packages/arancini-react/.storybook/stories/Selection.stories.tsx +++ b/packages/arancini-react/.storybook/stories/Selection.stories.tsx @@ -1,6 +1,6 @@ -import * as A from '@arancini/core' +import { Component, System, World } from '@arancini/core' import { Bounds, PerspectiveCamera } from '@react-three/drei' -import { useFrame, Vector3 } from '@react-three/fiber' +import { Vector3, useFrame } from '@react-three/fiber' import React, { useState } from 'react' import * as THREE from 'three' import { createECS } from '../../src' @@ -12,15 +12,15 @@ export default { /* components */ -const SelectedComponent = A.Component.tag('Selected') +const SelectedComponent = Component.tag('Selected') -const CameraComponent = A.Component.object('Camera') +const CameraComponent = Component.object('Camera') -const Object3DComponent = A.Component.object('Object3D') +const Object3DComponent = Component.object('Object3D') /* systems */ -class CameraSystem extends A.System { +class CameraSystem extends System { selectedQuery = this.query([SelectedComponent, Object3DComponent]) camera = this.singleton(CameraComponent, { required: true })! @@ -46,7 +46,7 @@ class CameraSystem extends A.System { } } -const world = new A.World() +const world = new World() world.registerComponent(Object3DComponent) world.registerComponent(SelectedComponent) world.registerComponent(CameraComponent) diff --git a/packages/arancini-react/src/hooks.ts b/packages/arancini-react/src/hooks.ts index 783ec84d..49ca31f3 100644 --- a/packages/arancini-react/src/hooks.ts +++ b/packages/arancini-react/src/hooks.ts @@ -1,11 +1,4 @@ -import { useLayoutEffect, useEffect, useCallback, useState } from 'react' +import { useEffect, useLayoutEffect } from 'react' export const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect - -export function useRerender(): () => void { - const [_, setVersion] = useState(0) - return useCallback(() => { - setVersion((v) => v + 1) - }, []) -} diff --git a/packages/arancini-react/src/index.tsx b/packages/arancini-react/src/index.tsx index f999ccdd..5df96229 100644 --- a/packages/arancini-react/src/index.tsx +++ b/packages/arancini-react/src/index.tsx @@ -10,7 +10,7 @@ import React, { useMemo, useState, } from 'react' -import { useIsomorphicLayoutEffect, useRerender } from './hooks' +import { useIsomorphicLayoutEffect } from './hooks' type EntityProviderContext = { entity: A.Entity @@ -42,6 +42,8 @@ export type ComponentProps> = { children?: ReactNode } +export type ECS = ReturnType + export const createECS = (world: A.World) => { const entityContext = createContext(null! as EntityProviderContext) @@ -110,7 +112,11 @@ export const createECS = (world: A.World) => { return world.query(q) }, [q]) - const rerender = useRerender() + const [, setVersion] = useState(-1) + + const rerender = () => { + setVersion((v) => v + 1) + } useIsomorphicLayoutEffect(() => { query.onEntityAdded.add(rerender) @@ -120,17 +126,20 @@ export const createECS = (world: A.World) => { query.onEntityAdded.remove(rerender) query.onEntityRemoved.remove(rerender) } - }, [rerender]) + }, []) useIsomorphicLayoutEffect(rerender, []) return query } - const QueryEntities = ({ query, children }: QueryEntitiesProps) => { - const { entities } = useQuery(query) + const QueryEntities = ({ + query: queryDescription, + children, + }: QueryEntitiesProps) => { + const query = useQuery(queryDescription) - return + return } const Component = >({ diff --git a/packages/arancini-react/tst/index.spec.tsx b/packages/arancini-react/tst/index.spec.tsx index 59043151..274fab95 100644 --- a/packages/arancini-react/tst/index.spec.tsx +++ b/packages/arancini-react/tst/index.spec.tsx @@ -1,11 +1,10 @@ import * as A from '@arancini/core' +import '@testing-library/jest-dom' import { act, render, renderHook } from '@testing-library/react' import React, { forwardRef, useImperativeHandle } from 'react' import { describe, expect, it, vi } from 'vitest' import { createECS } from '../src' -import '@testing-library/jest-dom' - class ExampleComponent extends A.Component {} class ExampleComponentWithArgs extends A.Component { @@ -36,7 +35,7 @@ describe('createECS', () => { render() - expect(world.entities.size).toBe(1) + expect(world.entities.length).toBe(1) }) it('should support taking an existing entity via props', () => { @@ -49,8 +48,8 @@ describe('createECS', () => { render() - expect(world.entities.size).toBe(1) - expect(world.entities.has(entity.id)).toBe(true) + expect(world.entities.length).toBe(1) + expect(world.has(entity)).toBe(true) }) it('supports refs', () => { diff --git a/packages/arancini/README.md b/packages/arancini/README.md index ebf0acf4..45c11548 100644 --- a/packages/arancini/README.md +++ b/packages/arancini/README.md @@ -55,17 +55,17 @@ React glue for arancini. A world represents your game or simulation. It maintains the entities, components, queries and systems in the ECS. ```ts -import { World } from "arancini"; +import { World } from 'arancini' // create a world -const world = new World(); +const world = new World() // register components and systems -world.registerComponent(MyComponent); -world.registerSystem(MySystem); +world.registerComponent(MyComponent) +world.registerSystem(MySystem) // initialise the world -world.init(); +world.init() ``` ### 🍱 Entity @@ -74,10 +74,10 @@ An entity is a container for components. ```ts // create an entity -const entity = world.create(); +const entity = world.create() // remove all components and remove from the world -entity.destroy(); +entity.destroy() ``` > **Note:** You should avoid storing references to entities and components. Use queries to find entities that have certain components, run logic on them, and then discard the references. @@ -88,73 +88,84 @@ Components are containers for data. There are multiple ways to define components #### Class Components -To get the most out of arancini, you should define your components as classes that extend the `Component` class. Components defined this way will be object pooled by arancini. +To get the most out of arancini, you should define components as class components. Components defined this way can exploit all of arancini's features. -You can define a `construct` method on your components, which will be called every time a component object is created or re-used. You can also define `onInit` and `onDestroy` methods, which will be called when the component is added or removed from an entity. +To define a class component, create a new class extending the `Component` class. ```ts -class PositionComponent extends Component { - x!: number; - y!: number; +class ExampleComponent extends Component { + // **Note:** In typescript you can use the not null `!:` syntax to indicate that properties set in `construct` will be be defined + x!: number + y!: number construct(x: number, y: number) { - this.x = x; - this.y = y; + this.x = x + this.y = y + } + + onInit() { + // called on initialising the component + } + + onDestroy() { + // called on destroying the component } } +// optionally object pool the component - this helps avoid garbage collection +@objectPooled() class InventoryComponent extends Component { - inventory = new Map(); + inventory = new Map() construct() { - this.inventory.clear(); + this.inventory.clear() } } -world.registerComponent(PositionComponent); -world.registerComponent(InventoryComponent); +world.registerComponent(PositionComponent) +world.registerComponent(InventoryComponent) -entity.add(Position, 10, 20); -entity.add(Inventory); +entity.add(Position, 10, 20) +entity.add(Inventory) -entity.remove(Position); -entity.remove(Inventory); +entity.remove(Position) +entity.remove(Inventory) ``` -> **Note:** In typescript you can use the not null `!:` syntax to indicate that the properties should be defined if they are set in the `construct` method. - #### Object Components -You can use `Component.object()` to create a object component definition. Object components are not pooled by arancini. They are ideal for objects from external libraries that don't benefit from object pooling. +You can use `Component.object()` to create a object component definition. + +Object components are useful for integrating with libraries. For example, you might create an object component for a three.js Object3D, or your physics library's physics body type. ```ts -import { Component } from "arancini"; -import { Object3D } from "three"; +import { Component } from 'arancini' +import { Object3D } from 'three' -const Object3DComponent = Component.object(); +const Object3DComponent = Component.object() -world.registerComponent(Object3DComponent); +world.registerComponent(Object3DComponent) -const entity = world.create(); +const entity = world.create() -const object3D = entity.add(Object3DComponent, new Object3D()); +const object3D = entity.add(Object3DComponent, new Object3D()) ``` #### Tag Components -You can use the `Component.tag` utility to define a tag component. Tag components are useful for marking entities without storing any data. +You can use `Component.tag()` to define a tag component. Tag components are useful for components that don't need to store any data. ```ts -import { Component } from "arancini"; +import { Component } from 'arancini' -const PlayerComponent = Component.tag(); +const PlayerComponent = Component.tag() -world.registerComponent(PlayerComponent); +world.registerComponent(PlayerComponent) -const entity = world.create(); -entity.add(PlayerComponent); +const entity = world.create() +entity.add(PlayerComponent) -console.log(entity.has(PlayerComponent)); // true +console.log(entity.has(PlayerComponent)) // true ``` #### Registering components @@ -167,32 +178,32 @@ Adding or removing a component from an entity will cause queries to be updated. ```ts entity.bulk(() => { - entity.add(Position, 10, 20); - entity.add(Velocity, 1, 2); - entity.remove(Health); -}); + entity.add(Position, 10, 20) + entity.add(Velocity, 1, 2) + entity.remove(Health) +}) ``` #### Using components in multiple worlds -**You can only register a component with one world.** If you want to use the same component in multiple worlds, you can create a base class and extend it for each world. For example: +**You can only register a component with one world.** You can use the `cloneComponentDefinition` utility to create a copy of a component definition that can be registered with another world. ```ts /* lib.ts */ -import { Component } from "arancini"; +import { Component } from 'arancini' export class MyComponent extends Component { /* ... */ } /* some-world.ts */ -import { World } from "arancini"; -import { MyComponent as MyComponentImpl } from "./lib"; +import { World } from 'arancini' +import { MyComponent as MyComponentImpl } from './lib' -class MyComponent extends MyComponentImpl {} +const MyComponent = cloneComponentDefinition(MyComponentImpl) -const world = new World(); -world.registerComponent(MyComponent); +const world = new World() +world.registerComponent(MyComponent) ``` ### 🔎 Query @@ -200,13 +211,13 @@ world.registerComponent(MyComponent); You can use queries to find entities that have certain components. Queries support `all`, `one`, and `none` filters. Queries with the same filters are deduplicated by arancini, so you can create multiple queries with the same filters without performance penalty. ```ts -const basicQuery = world.query([Position]); +const basicQuery = world.query([Position]) const advancedQuery = world.query({ all: [Position, Velocity], one: [EitherThisComponent, OrThisComponent], none: [NotThisComponent], -}); +}) ``` #### Iterating over query results @@ -216,7 +227,7 @@ The `Query` class has a `Symbol.iterator` method which can be used to iterate ov Alternatively, you can simply iterate over entities in `query.entities`. ```ts -const query = world.query([Position]); +const query = world.query([Position]) for (const entity of query) { // iterates over entities in reverse order @@ -232,17 +243,17 @@ for (const entity of query.entities) { Queries are reactive and can emit events when entities are added or removed from the query. ```ts -const query = world.query([Position]); +const query = world.query([Position]) const handler = (entity: Entity) => { // ... -}; +} -query.onEntityAdded.add(handler); -query.onEntityRemoved.add(handler); +query.onEntityAdded.add(handler) +query.onEntityRemoved.add(handler) -query.onEntityAdded.remove(handler); -query.onEntityRemoved.remove(handler); +query.onEntityAdded.remove(handler) +query.onEntityRemoved.remove(handler) ``` ### 🧠 System @@ -254,8 +265,8 @@ Arancini has built-in support for systems, but you can also use queries alone to If you have systems registered in the world, you can use `world.update()` to run the systems. If you don't have any systems registered, you don't need to call `update`! Arancini is fully reactive, queries will be updated as the composition of entities change. ```ts -const delta = 1 / 60; -world.update(delta); +const delta = 1 / 60 +world.update(delta) ``` #### System lifecycle methods @@ -285,15 +296,15 @@ You can use `this.query` to create a query linked to the system. These queries w ```ts class MovementSystem extends System { - moving = this.query([Position, Velocity]); + moving = this.query([Position, Velocity]) onUpdate() { for (const entity of this.moving) { - const position = entity.get(Position); - const velocity = entity.get(Velocity); + const position = entity.get(Position) + const velocity = entity.get(Velocity) - position.x += velocity.x; - position.y += velocity.y; + position.x += velocity.x + position.y += velocity.y } } } @@ -303,14 +314,23 @@ System queries can be marked as 'required', meaning that the system will only be ```ts class ExampleSystem extends System { - requiredQuery = this.query([ExampleComponent], { required: true }); + requiredQuery = this.query([ExampleComponent], { required: true }) onUpdate() { - const { data } = this.requiredQuery.first!.get(ExampleComponent); + const { data } = this.requiredQuery.first!.get(ExampleComponent) } } ``` +#### Execution Order + +Systems can be registered with a priority. The order systems run in is first determined by priority, then by the order systems were registered. + +```ts +const priority = 10 +world.registerSystem(MovementSystem, priority) +``` + #### Singleton components Singleton components queries can be defined for cases where systems need to access shared data, like a camera or player component. @@ -319,25 +339,16 @@ The `singleton` method creates a query for a single component, and sets the prop ```ts class ExampleSystem extends System { - player = this.singleton(PlayerComponent, { required: true }); + player = this.singleton(PlayerComponent, { required: true }) onUpdate() { - player.ENERGY -= 1; + player.ENERGY -= 1 } } ``` > **Note:** Singleton components must be defined on a top-level property of the system. The property must not be a ES2022 private field (prefixed with `#`). -#### Execution Order - -Systems can be registered with a priority. The order systems run in is first determined by priority, then by the order systems were registered. - -```ts -const priority = 10; -world.registerSystem(MovementSystem, priority); -``` - ## Example Let's use arancini to make a simple random walk simulation! @@ -345,34 +356,34 @@ Let's use arancini to make a simple random walk simulation! ### 1. Import everything we need ```ts -import { Component, Query, System, World } from "arancini"; +import { Component, Query, System, World } from 'arancini' ``` ### 2. Create components to store data ```ts class Position extends Component { - x!: number; - y!: number; + x!: number + y!: number construct(x: number, y: number) { - this.x = x; - this.y = y; + this.x = x + this.y = y } } class Color extends Component { - color!: "red" | "blue"; + color!: 'red' | 'blue' - construct(color: "red" | "blue") { - this.color = color; + construct(color: 'red' | 'blue') { + this.color = color } } class CanvasContext extends Component { - ctx!: CanvasRenderingContext2D; - width!: number; - height!: number; + ctx!: CanvasRenderingContext2D + width!: number + height!: number } ``` @@ -380,33 +391,33 @@ class CanvasContext extends Component { ```ts class DrawSystem extends System { - canvasContext = this.query([CanvasContext]); + canvasContext = this.query([CanvasContext]) boxesToDraw = this.query({ all: [Position, Color], - }); + }) onUpdate() { - const context = this.canvasContext.first!.get(CanvasContext); + const context = this.canvasContext.first!.get(CanvasContext) - context.ctx.clearRect(0, 0, context.width, context.height); + context.ctx.clearRect(0, 0, context.width, context.height) - const xOffset = context.width / 2; - const yOffset = context.height / 2; + const xOffset = context.width / 2 + const yOffset = context.height / 2 - const boxSize = 10; + const boxSize = 10 for (const entity of this.boxesToDraw.entities) { - const { x, y } = entity.get(Position); - const { color } = entity.get(Color); + const { x, y } = entity.get(Position) + const { color } = entity.get(Color) - context.ctx.fillStyle = color; + context.ctx.fillStyle = color context.ctx.fillRect( xOffset + (x - boxSize / 2), yOffset + (y - boxSize / 2), boxSize, boxSize - ); + ) } } } @@ -415,26 +426,26 @@ class DrawSystem extends System { ### 4. Create a System that moves entities with a `Position` Component ```ts -const TIME_BETWEEN_MOVEMENTS = 0.05; // seconds +const TIME_BETWEEN_MOVEMENTS = 0.05 // seconds class WalkSystem extends System { - movementCountdown = TIME_BETWEEN_MOVEMENTS; + movementCountdown = TIME_BETWEEN_MOVEMENTS walkers = this.query({ all: [Position], - }); + }) onUpdate(delta: number) { - this.movementCountdown -= delta; + this.movementCountdown -= delta if (this.movementCountdown <= 0) { for (const entity of this.walkers.entities) { - const position = entity.get(Position); - position.x += (Math.random() - 0.5) * 3; - position.y += (Math.random() - 0.5) * 3; + const position = entity.get(Position) + position.x += (Math.random() - 0.5) * 3 + position.y += (Math.random() - 0.5) * 3 } - this.movementCountdown = TIME_BETWEEN_MOVEMENTS; + this.movementCountdown = TIME_BETWEEN_MOVEMENTS } } } @@ -445,58 +456,58 @@ class WalkSystem extends System { First, create a new `World` ```ts -const world = new World(); +const world = new World() ``` Next, let's register the Components and Systems we created. ```ts -world.registerComponent(Position); -world.registerComponent(Color); -world.registerComponent(CanvasContext); +world.registerComponent(Position) +world.registerComponent(Color) +world.registerComponent(CanvasContext) -world.registerSystem(WalkSystem); -world.registerSystem(DrawSystem); -world.registerSystem(FlipSystem); +world.registerSystem(WalkSystem) +world.registerSystem(DrawSystem) +world.registerSystem(FlipSystem) ``` Now let's create some random walkers. We'll create 100 random walkers, and give them a random position and color. ```ts -const N = 100; +const N = 100 -const randomPosition = () => Math.random() * 10 - 5; -const randomColor = () => (Math.random() > 0.5 ? "red" : "blue"); +const randomPosition = () => Math.random() * 10 - 5 +const randomColor = () => (Math.random() > 0.5 ? 'red' : 'blue') for (let i = 0; i < N; i++) { - const entity = world.create(); - entity.add(Position, randomPosition(), randomPosition()); - entity.add(Color, randomColor()); + const entity = world.create() + entity.add(Position, randomPosition(), randomPosition()) + entity.add(Color, randomColor()) } ``` Next we'll create an entity with the `CanvasContext` component, which will contain the HTML canvas context. We'll also add a handler for window resizing. ```ts -const canvasContext = world.create(); +const canvasContext = world.create() const canvasElement = document.querySelector( - "#example-canvas" -) as HTMLCanvasElement; -canvasElement.width = window.innerWidth; -canvasElement.height = window.innerHeight; + '#example-canvas' +) as HTMLCanvasElement +canvasElement.width = window.innerWidth +canvasElement.height = window.innerHeight -const canvasComponent = canvasContext.add(CanvasContext); -canvasComponent.ctx = canvasElement.getContext("2d")!; -canvasComponent.width = canvasElement.width; -canvasComponent.height = canvasElement.height; +const canvasComponent = canvasContext.add(CanvasContext) +canvasComponent.ctx = canvasElement.getContext('2d')! +canvasComponent.width = canvasElement.width +canvasComponent.height = canvasElement.height const resize = () => { - canvasComponent.width = canvasElement.width = window.innerWidth; - canvasComponent.height = canvasElement.height = window.innerHeight; -}; -window.addEventListener("resize", resize, false); -resize(); + canvasComponent.width = canvasElement.width = window.innerWidth + canvasComponent.height = canvasElement.height = window.innerHeight +} +window.addEventListener('resize', resize, false) +resize() ``` ### 6. The loop @@ -504,21 +515,21 @@ resize(); Finally, let's initialise the World and run our simulation! ```ts -world.init(); +world.init() -const now = () => performance.now() / 1000; +const now = () => performance.now() / 1000 -let lastTime = now(); +let lastTime = now() const loop = () => { - requestAnimationFrame(loop); + requestAnimationFrame(loop) - const time = now(); - const delta = time - lastTime; - lastTime = time; + const time = now() + const delta = time - lastTime + lastTime = time - world.update(delta); -}; + world.update(delta) +} -loop(); +loop() ```