diff --git a/.changeset/healthy-dragons-think.md b/.changeset/healthy-dragons-think.md new file mode 100644 index 00000000..b4cfddb5 --- /dev/null +++ b/.changeset/healthy-dragons-think.md @@ -0,0 +1,25 @@ +--- +"@miniplex/core": patch +--- + +Aaaaah, another rewrite of the core library! `@miniplex/core` kept the same lightweight core, but the `World` is now much more aware of archetypes and what kind of entities they represent. This was done to allow for better introspection and to fix some remaining issues like [#204](https://github.com/hmans/miniplex/issues/204)]. + +The `WithRequiredKeys` type has been renamed to `WithComponents`. + +`world.archetype()` now allows two forms: + +```ts +world.archetype("position", "velocity") +world.archetype({ all: ["position", "velocity"] }) +``` + +The second form involves a query object that can also have `any` and `none` keys: + +```ts +world.archetype({ + all: ["position", "velocity"], + none: ["dead"] +}) +``` + +**Breaking Change:** `bucket.derive()` has been removed. It was cool and fun and cute, but also a little too generic to be useful. Similar to Miniplex 1.0, there is only the _world_ and a series of _archetypes_ now. (But both share the same lightweight `Bucket` base class that can also be used standalone.) diff --git a/.changeset/quiet-bats-fetch.md b/.changeset/quiet-bats-fetch.md new file mode 100644 index 00000000..03034be2 --- /dev/null +++ b/.changeset/quiet-bats-fetch.md @@ -0,0 +1,11 @@ +--- +"@miniplex/react": patch +--- + +`` has been changed to match the new query capabilities of the core library's `world.archetype` function. All of these are now valid: + +```tsx + + + +``` diff --git a/apps/demo/src/entities/Asteroids.tsx b/apps/demo/src/entities/Asteroids.tsx index f8362fb3..c7f62bae 100644 --- a/apps/demo/src/entities/Asteroids.tsx +++ b/apps/demo/src/entities/Asteroids.tsx @@ -1,5 +1,5 @@ import { Composable, Modules } from "material-composer-r3f" -import { WithRequiredKeys } from "miniplex" +import { Archetype, WithComponents } from "miniplex" import { insideCircle, power } from "randomish" import { useLayoutEffect } from "react" import { $, Input, InstanceID, Lerp } from "shader-composer" @@ -49,11 +49,13 @@ export const Asteroids = () => { {segmentedAsteroids.entities.map((segment, i) => ( ))} + + {/* */} ) } -export type Asteroid = WithRequiredKeys< +export type Asteroid = WithComponents< Entity, | "isAsteroid" | "transform" @@ -63,10 +65,7 @@ export type Asteroid = WithRequiredKeys< | "render" > -export const isAsteroid = (entity: Entity): entity is Asteroid => - "isAsteroid" in entity - -const asteroids = ECS.world.derive(isAsteroid) +const asteroids = ECS.world.archetype("isAsteroid") as Archetype const tmpVec3 = new Vector3() diff --git a/apps/demo/src/entities/Bullets.tsx b/apps/demo/src/entities/Bullets.tsx index 85f796ee..3c1a4984 100644 --- a/apps/demo/src/entities/Bullets.tsx +++ b/apps/demo/src/entities/Bullets.tsx @@ -12,7 +12,7 @@ export const Bullets = () => ( - + ) diff --git a/apps/demo/src/entities/RenderableEntity.tsx b/apps/demo/src/entities/RenderableEntity.tsx index 21469d8e..9a6ae0ec 100644 --- a/apps/demo/src/entities/RenderableEntity.tsx +++ b/apps/demo/src/entities/RenderableEntity.tsx @@ -1,8 +1,8 @@ -import { WithRequiredKeys } from "miniplex" +import { WithComponents } from "miniplex" import { Entity } from "../state" export const RenderableEntity = ({ entity }: { - entity: WithRequiredKeys + entity: WithComponents }) => <>{entity.render} diff --git a/apps/demo/src/systems/physicsSystem.ts b/apps/demo/src/systems/physicsSystem.ts index 7942b470..c4950a11 100644 --- a/apps/demo/src/systems/physicsSystem.ts +++ b/apps/demo/src/systems/physicsSystem.ts @@ -1,9 +1,9 @@ import { useFrame } from "@react-three/fiber" -import { WithRequiredKeys } from "miniplex" +import { WithComponents } from "miniplex" import { MathUtils, Vector3 } from "three" import { ECS, Entity } from "../state" -type PhysicsEntity = WithRequiredKeys +type PhysicsEntity = WithComponents const entities = ECS.world.archetype("transform", "physics") diff --git a/apps/demo/src/systems/playerSystem.ts b/apps/demo/src/systems/playerSystem.ts index 706ec147..fdab6819 100644 --- a/apps/demo/src/systems/playerSystem.ts +++ b/apps/demo/src/systems/playerSystem.ts @@ -1,5 +1,5 @@ import { useFrame } from "@react-three/fiber" -import { WithRequiredKeys } from "miniplex" +import { Archetype, WithComponents } from "miniplex" import { Vector3 } from "three" import { spawnBullet } from "../entities/Bullets" import { ECS, Entity } from "../state" @@ -7,12 +7,10 @@ import { useKeyboard } from "../util/useKeyboard" const tmpVec3 = new Vector3() -const isPlayer = ( - entity: Entity -): entity is WithRequiredKeys => - !!entity.isPlayer +type Player = WithComponents -const players = ECS.world.derive(isPlayer) +// const players = ECS.world.archetype(isPlayer) +const players = ECS.world.archetype("isPlayer") as Archetype let lastFireTime = 0 diff --git a/packages/miniplex-core/src/Archetype.ts b/packages/miniplex-core/src/Archetype.ts new file mode 100644 index 00000000..3f0e3e08 --- /dev/null +++ b/packages/miniplex-core/src/Archetype.ts @@ -0,0 +1,33 @@ +import { Bucket } from "./Bucket" +import { IEntity, Query } from "./types" + +/** + * A bucket type that stores entities belonging to a specific archetype. + * This archetype is expressed as a `Query` object, stored in the archetype's + * `query` property. + */ +export class Archetype extends Bucket { + constructor(public query: Query) { + super() + } + + matchesEntity(entity: E): boolean { + return this.matchesComponents(Object.keys(entity)) + } + + matchesComponents(components: (keyof E)[]): boolean { + const all = + this.query.all === undefined || + this.query.all.every((key) => components.includes(key)) + + const any = + this.query.any === undefined || + this.query.any.some((key) => components.includes(key)) + + const none = + this.query.none === undefined || + this.query.none.every((key) => !components.includes(key)) + + return all && any && none + } +} diff --git a/packages/miniplex-core/src/Bucket.ts b/packages/miniplex-core/src/Bucket.ts index 6cf5585a..c01359dd 100644 --- a/packages/miniplex-core/src/Bucket.ts +++ b/packages/miniplex-core/src/Bucket.ts @@ -1,14 +1,9 @@ import { Event } from "@hmans/event" -import { Predicate } from "./types" export type BucketOptions = { entities?: E[] } -/** - * A bucket is a collection of entities. Entities can be added, removed, and - * touched; the bucket exposes events for each of these operations. - */ export class Bucket { [Symbol.iterator]() { let index = this.entities.length @@ -21,80 +16,54 @@ export class Bucket { } } - constructor({ entities = [] }: BucketOptions = {}) { - this.entities = entities - } - - /** The entities in the bucket. */ + /** + * All entities stored in this bucket. + */ entities: E[] /** - * An internal map of entities to their positions in the `entities` array. - * This is used to quickly find the position of an entity in the array. + * A map of entities to their positions within the `entities` array. + * Used internally for performance optimizations. */ private entityPositions = new Map() /** - * The event that is emitted when an entity is added to this bucket. + * An event that is emitted when an entity is added to this bucket. */ onEntityAdded = new Event() /** - * The event that is emitted when an entity is removed from this bucket. + * An event that is emitted when an entity is removed from this bucket. */ onEntityRemoved = new Event() - /** - * The event that is emitted when an entity is touched in this bucket. - */ - onEntityTouched = new Event() - - /** - * The event that is emitted when the bucket is cleared. - */ - onCleared = new Event() - - /** - * The event that is emitted when the bucket is being disposed. - */ - onDisposed = new Event() + constructor({ entities = [] }: BucketOptions = {}) { + this.entities = entities - /** - * A cache of derived buckets. This is used to ensure that we don't create - * multiple derived buckets for the same predicate. - */ - derivedBuckets = new Map() + /* Fill entity positions */ + for (let i = 0; i < entities.length; i++) { + this.entityPositions.set(entities[i], i) + } + } /** - * Returns the size of this bucket (the number of entities it contains). + * Returns the size of this bucket (equal to the number of entities stored + * within it.) */ get size() { return this.entities.length } /** - * Returns true if this bucket is currently tracking the given entity. - * - * @param entity The entity to check for. - * @returns True if the entity is being tracked. - */ - has(entity: E) { - return this.entityPositions.has(entity) - } - - /** - * Adds the entity to this bucket. If the entity is already in the bucket, it - * does nothing. + * Adds an entity to this bucket. * - * @param entity The entity to add. + * @param entity The entity to add to this bucket. * @returns The entity that was added. */ add(entity: D): E & D { - /* Add the entity if we don't already have it */ - if (entity && !this.has(entity)) { + if (entity !== undefined && !this.has(entity)) { this.entities.push(entity) this.entityPositions.set(entity, this.entities.length - 1) - this.onEntityAdded.emit(entity) } @@ -102,32 +71,16 @@ export class Bucket { } /** - * Touches the entity, signaling this bucket that the entity was updated, and should - * be re-evaluated by any buckets derived from this one. - * - * @param entity The entity to touch. - * @returns The entity that was touched. - */ - touch(entity: E) { - if (entity && this.has(entity)) { - this.onEntityTouched.emit(entity) - } - - return entity - } - - /** - * Removes the entity from this bucket. If the entity is not in the bucket, - * it does nothing. + * Removes an entity from this bucket. * - * @param entity The entity to remove. - * @returns The entity that was removed. + * @param entity The entity to remove from this bucket. + * @returns The entity. */ remove(entity: E) { - /* Only act if we know about the entity */ - if (entity && this.has(entity)) { + const index = this.entityPositions.get(entity) + + if (index !== undefined) { /* Remove entity from our list */ - const index = this.entityPositions.get(entity)! this.entityPositions.delete(entity) const other = this.entities[this.entities.length - 1] @@ -139,100 +92,27 @@ export class Bucket { this.entities.pop() - /* Emit event */ this.onEntityRemoved.emit(entity) } return entity } - /** - * Removes all entities from this bucket. This will emit the `onEntityRemoved` event - * for each entity, giving derived buckets a chance to remove the entity as well. - */ clear() { for (const entity of this) { this.remove(entity) } - this.onCleared.emit() - } - - /** - * Dispose of this bucket. This will remove all entities from the bucket, dispose of all - * known derived buckets, and clear all event listeners. - */ - dispose() { - /* Emit onDisposed event */ - this.onDisposed.emit() - - /* Clear all state */ - this.derivedBuckets.clear() - this.entities = [] this.entityPositions.clear() - this.onCleared.clear() - this.onDisposed.clear() - this.onEntityAdded.clear() - this.onEntityRemoved.clear() - this.onEntityTouched.clear() } /** - * Create a new bucket derived from this bucket. The derived bucket will contain - * only entities that match the given predicate, and will be updated reactively - * as entities are added, removed, or touched. + * Returns `true` if the given entity is stored within this bucket. * - * @param predicate The predicate to use to filter entities. - * @returns The new derived bucket. + * @param entity The entity to check for. + * @returns `true` if the given entity is stored within this bucket. */ - derive( - predicate: Predicate | ((entity: E) => boolean) = () => true - ): Bucket { - /* Check if we already have a derived bucket for this predicate */ - const existingBucket = this.derivedBuckets.get(predicate) - if (existingBucket) return existingBucket - - /* Create bucket */ - const bucket = new Bucket({ - entities: this.entities.filter(predicate) as D[] - }) - - /* Add to cache */ - this.derivedBuckets.set(predicate, bucket) - - /* Listen for new entities */ - bucket.onDisposed.add( - this.onEntityAdded.add((entity) => { - if (predicate(entity)) { - bucket.add(entity) - } - }) - ) - - /* Listen for removed entities */ - bucket.onDisposed.add( - this.onEntityRemoved.add((entity) => { - bucket.remove(entity as D) - }) - ) - - /* Listen for changed entities */ - bucket.onDisposed.add( - this.onEntityTouched.add((entity) => { - if (predicate(entity)) { - bucket.add(entity) - bucket.touch(entity) - } else { - bucket.remove(entity as D) - } - }) - ) - - /* React to this bucket being disposed */ - this.onDisposed.add(() => { - bucket.dispose() - }) - - return bucket as Bucket + has(entity: E) { + return this.entityPositions.has(entity) } } diff --git a/packages/miniplex-core/src/World.ts b/packages/miniplex-core/src/World.ts index fa330360..7c92d87d 100644 --- a/packages/miniplex-core/src/World.ts +++ b/packages/miniplex-core/src/World.ts @@ -1,128 +1,152 @@ -import { archetype } from "./archetypes" -import { Bucket } from "./Bucket" -import { IEntity } from "./types" - -/** - * `World` is a special type of `Bucket` that manages ECS-like entities. - * It provides an API for adding and removing components to and from entities, - * as well as an `archetype` function that will return a derived bucket - * containing all entities of the specified archetype (ie. that have all of - * the specified components.) - */ +import { Archetype } from "./Archetype" +import { Bucket, BucketOptions } from "./Bucket" +import { normalizeQuery, serializeQuery } from "./queries" +import { IEntity, Query, WithComponents } from "./types" + +export type WorldOptions = BucketOptions + export class World extends Bucket { - constructor(...args: ConstructorParameters>) { - super(...args) + /* Archetypes */ + private archetypes = new Map>() + + /* Entity IDs */ + private nextID = 0 + private entityToID = new Map() + private idToEntity = new Map() + + constructor(options: WorldOptions = {}) { + super(options) + + this.onEntityAdded.add((entity) => { + /* Add entity to matching archetypes */ + for (const archetype of this.archetypes.values()) { + if (archetype.matchesEntity(entity)) { + archetype.add(entity) + } + } + }) - /* Forget the ID again when an entity is removed */ this.onEntityRemoved.add((entity) => { - if (this.entityToId.has(entity)) { - this.idToEntity.delete(this.entityToId.get(entity)!) - this.entityToId.delete(entity) + /* Remove entity from all archetypes */ + for (const archetype of this.archetypes.values()) { + archetype.remove(entity) } + + /* Remove IDs */ + const id = this.entityToID.get(entity)! + this.idToEntity.delete(id) + this.entityToID.delete(entity) }) } - /* ID generation */ - private nextId = 0 - private entityToId = new Map() - private idToEntity = new Map() - - /** - * Returns the ID of the given entity. If the entity is not known to this world, - * it returns `undefined`. - * - * @param entity The entity to get the ID of. - * @returns The ID of the entity, or `undefined` if the entity is not known to this world. - */ id(entity: E) { - /* Only return IDs for entities we know about */ - if (!this.has(entity)) return undefined + if (!this.has(entity)) return - /* Return existing ID if we have one */ - const id = this.entityToId.get(entity) - if (id !== undefined) return id + if (!this.entityToID.has(entity)) { + const id = this.nextID++ + this.entityToID.set(entity, id) + this.idToEntity.set(id, entity) + + return id + } - this.entityToId.set(entity, this.nextId) - this.idToEntity.set(this.nextId, entity) - return this.nextId++ + return this.entityToID.get(entity) } - /** - * Given an ID, returns the entity with that ID. If the ID is not known to this world, - * it returns `undefined`. - * - * @param id The ID of the entity to get. - * @returns The entity with the given ID, or `undefined` if the ID is not known to this world. - */ entity(id: number) { return this.idToEntity.get(id) } - /** - * Adds a component to an entity. - * If the entity already has the component, this function will log a warning - * and return `false`. Otherwise, it will add the component and return `true`. - * - * @param entity The entity to add the property to. - * @param component The component to add. - * @param value The value of the component. - * @returns `true` if the entity was updated, `false` otherwise. - */ - addComponent

(entity: E, component: P, value: E[P]) { - if (entity[component] !== undefined) { - console.warn( - "Tried to add a component, but it was already present:", - component - ) - - return false - } + addComponent(entity: E, component: C, value: E[C]) { + /* Don't overwrite existing components */ + if (entity[component] !== undefined) return entity[component] = value - this.touch(entity) - return true + /* Re-check known archetypes */ + if (this.has(entity)) + for (const archetype of this.archetypes.values()) + archetype.matchesEntity(entity) + ? archetype.add(entity) + : archetype.remove(entity) } - /** - * Removes a component from an entity. If the entity does not have the component, - * this function will do nothing. - * - * @param entity The entity to remove the component from. - * @param component The component to remove. - * @returns `true` if the entity was updated, `false` otherwise. - */ - removeComponent

(entity: E, component: P) { - if (entity[component] === undefined) return false + removeComponent(entity: E, component: C) { + /* Return early if component doesn't exist on entity */ + if (entity[component] === undefined) return - delete entity[component] - this.touch(entity) + /* Re-check known archetypes */ + if (this.has(entity)) { + const copy = { ...entity } + delete copy[component] + + for (const archetype of this.archetypes.values()) + archetype.matchesEntity(copy) + ? archetype.add(entity) + : archetype.remove(entity) + } - return true + /* At this point, all relevant callbacks will have executed. Now it's + safe to remove the component. */ + + delete entity[component] } /** - * Updates the value of a component on the given entity. - * If the entity does not have the component, this function will do nothing. + * Returns an archetype bucket holding all entities that have all of the specified + * components. * - * @param entity The entity to update. - * @param component The component to update. - * @param value The new value of the component. - * @returns `true` if the entity was updated, `false` otherwise. + * @param components One or multiple components to query for */ - setComponent

(entity: E, component: P, value: E[P]) { - if (entity[component] === undefined) { - console.warn("Tried to set a component, but it was missing:", component) - return false + archetype( + ...components: C[] + ): Archetype> + + /** + * Returns an archetype bucket holding all entities that match the specified + * query. The query is a simple object with optional `all`, `any` and `none` + * keys. Each key should be an array of component names. + * + * The `all` key specifies that all of the components in the array must be present. + * The `any` key specifies that at least one of the components in the array must be present. + * The `none` key specifies that none of the components in the array must be present. + * + * @param query + */ + archetype(query: { + all?: C[] + any?: (keyof E)[] + none?: (keyof E)[] + }): Archetype> + + archetype( + query: Query | C, + ...extra: C[] + ): Archetype> { + /* If the query is not a query object, turn it into one and call + ourselves. Yay overloading in TypeScript! */ + if (typeof query !== "object") { + return this.archetype({ all: [query, ...extra] }) } - entity[component] = value - this.touch(entity) + /* Build a normalized query object and key */ + const normalizedQuery = normalizeQuery(query) + const key = serializeQuery(normalizedQuery) - return true - } + /* If we haven't seen this query before, create a new archetype */ + if (!this.archetypes.has(key)) { + const archetype = new Archetype(normalizedQuery) + this.archetypes.set(key, archetype) + + /* Check existing entities for matches */ + for (const entity of this.entities) { + if (archetype.matchesEntity(entity)) { + archetype.add(entity) + } + } + } - archetype

(...components: P[]) { - return this.derive(archetype(...components)) + /* We're done, return the archetype! */ + return this.archetypes.get(key)! as Archetype> } } diff --git a/packages/miniplex-core/src/archetypes.ts b/packages/miniplex-core/src/archetypes.ts deleted file mode 100644 index 5748669f..00000000 --- a/packages/miniplex-core/src/archetypes.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Predicate, WithRequiredKeys, IEntity } from "./types" - -export const isArchetype = ( - entity: E, - ...properties: P[] -): entity is WithRequiredKeys => { - for (const key of properties) if (entity[key] === undefined) return false - return true -} - -const archetypeCache = new Map() - -export const archetype = ( - ...properties: P[] -): Predicate> => { - const normalizedProperties = properties.sort().filter((p) => !!p && p !== "") - const key = JSON.stringify(normalizedProperties) - - if (archetypeCache.has(key)) return archetypeCache.get(key) - - const predicate = (entity: E): entity is WithRequiredKeys => - isArchetype(entity, ...properties) - - archetypeCache.set(key, predicate) - - return predicate -} diff --git a/packages/miniplex-core/src/index.ts b/packages/miniplex-core/src/index.ts index deb7be5d..bf4aac4d 100644 --- a/packages/miniplex-core/src/index.ts +++ b/packages/miniplex-core/src/index.ts @@ -1,4 +1,4 @@ -export * from "./archetypes" +export * from "./Archetype" export * from "./Bucket" export * from "./types" export * from "./World" diff --git a/packages/miniplex-core/src/queries.ts b/packages/miniplex-core/src/queries.ts new file mode 100644 index 00000000..35983346 --- /dev/null +++ b/packages/miniplex-core/src/queries.ts @@ -0,0 +1,14 @@ +import { IEntity, Query } from "./types" + +export const normalizeComponents = ( + components: (keyof E)[] +) => [...new Set(components.sort().filter((c) => !!c && c !== ""))] + +export const normalizeQuery = (query: Query) => ({ + all: query.all && normalizeComponents(query.all), + any: query.any && normalizeComponents(query.any), + none: query.none && normalizeComponents(query.none) +}) + +export const serializeQuery = (query: Query) => + JSON.stringify(query) diff --git a/packages/miniplex-core/src/types.ts b/packages/miniplex-core/src/types.ts index 1fbf858e..e2a517c3 100644 --- a/packages/miniplex-core/src/types.ts +++ b/packages/miniplex-core/src/types.ts @@ -2,6 +2,12 @@ export interface IEntity { [key: string]: any } -export type WithRequiredKeys = E & { [K in P]-?: E[K] } +export type WithComponents = E & { + [K in P]-?: E[K] +} -export type Predicate = (entity: E) => entity is D +export interface Query { + all?: All[] + any?: (keyof E)[] + none?: (keyof E)[] +} diff --git a/packages/miniplex-core/test/Archetype.test.ts b/packages/miniplex-core/test/Archetype.test.ts new file mode 100644 index 00000000..e42848da --- /dev/null +++ b/packages/miniplex-core/test/Archetype.test.ts @@ -0,0 +1,88 @@ +import { World } from "../src" +import { Archetype } from "../src/Archetype" + +describe("Archetype", () => { + type Entity = { name: string; age?: number } + + it("is constructed with a query that represents the entities of this archetype", () => { + const archetype = new Archetype({ all: ["name"] }) + expect(archetype.query).toEqual({ all: ["name"] }) + }) + + describe("matchesEntity", () => { + it("returns true if the entity has 'all' components", () => { + const archetype = new Archetype({ all: ["age"] }) + const entity = { name: "John", age: 42 } + expect(archetype.matchesEntity(entity)).toBe(true) + }) + + it("returns false if the entity doesn't have 'all' components", () => { + const archetype = new Archetype({ all: ["age"] }) + const entity = { name: "John" } + expect(archetype.matchesEntity(entity)).toBe(false) + }) + + it("returns true if the entity has 'any' components", () => { + const archetype = new Archetype({ any: ["age"] }) + const entity = { name: "John", age: 42 } + expect(archetype.matchesEntity(entity)).toBe(true) + }) + + it("returns false if the entity doesn't have 'any' components", () => { + const archetype = new Archetype({ any: ["age"] }) + const entity = { name: "John" } + expect(archetype.matchesEntity(entity)).toBe(false) + }) + + it("returns true if the entity doesn't have 'none' components", () => { + const archetype = new Archetype({ none: ["age"] }) + const entity = { name: "John" } + expect(archetype.matchesEntity(entity)).toBe(true) + }) + + it("returns false if the entity has 'none' components", () => { + const archetype = new Archetype({ none: ["age"] }) + const entity = { name: "John", age: 42 } + expect(archetype.matchesEntity(entity)).toBe(false) + }) + }) + + describe("onEntityRemoved", () => { + it("is invoked when an entity leaves the archetype", () => { + const world = new World() + + const archetype = world.archetype({ all: ["age"] }) + const callback = jest.fn() + archetype.onEntityRemoved.add(callback) + + const entity = world.add({ name: "John", age: 42 }) + expect(callback).not.toHaveBeenCalled() + + world.removeComponent(entity, "age") + + expect(callback).toHaveBeenCalledWith(entity) + }) + + it("is invoked before the component is actually removed from the entity", () => { + const world = new World() + const archetype = world.archetype({ all: ["age"] }) + + /* Set up a callback */ + let age: number | undefined + const callback = jest.fn((entity: Entity) => { + age = entity.age + }) + archetype.onEntityRemoved.add(callback) + + /* Add an entity */ + const entity = world.add({ name: "John", age: 42 }) + + /* Remove the 'age' component */ + world.removeComponent(entity, "age") + + /* Verify that the callback was invoked before the component was removed */ + expect(callback).toHaveBeenCalledWith(entity) + expect(age).toBe(42) + }) + }) +}) diff --git a/packages/miniplex-core/test/Bucket.test.ts b/packages/miniplex-core/test/Bucket.test.ts index 6bf195d1..43761772 100644 --- a/packages/miniplex-core/test/Bucket.test.ts +++ b/packages/miniplex-core/test/Bucket.test.ts @@ -1,324 +1,71 @@ -import { archetype, Bucket } from "../src" +import { Bucket } from "../src/Bucket" -describe("new Bucket", () => { - it("creates a bucket", () => { - const bucket = new Bucket() - expect(bucket).toBeDefined() +describe("Bucket", () => { + it("can be constructed with a list of entities", () => { + const world = new Bucket({ entities: [{ id: 1 }, { id: 2 }] }) + expect(world.entities).toHaveLength(2) }) - it("allows the user to pass an initial list of entities", () => { - const entities = [1, 2, 3] - const bucket = new Bucket({ entities }) - expect(bucket.size).toBe(3) - expect(bucket.entities).toBe(entities) - }) -}) - -describe("has", () => { - it("returns true if the bucket has the entity", () => { - const bucket = new Bucket() - const entity = { id: 1 } - bucket.add(entity) - expect(bucket.has(entity)).toBe(true) - }) - - it("returns false if the bucket does not have the entity", () => { - const bucket = new Bucket() - const entity = { id: 1 } - expect(bucket.has(entity)).toBe(false) - }) - - it("returns true when the entity was added, and false after it was removed", () => { - const bucket = new Bucket() - const entity = { id: 1 } - - bucket.add(entity) - expect(bucket.has(entity)).toBe(true) - - bucket.remove(entity) - expect(bucket.has(entity)).toBe(false) - }) -}) - -describe("add", () => { - it("writes an entity into the bucket", () => { - const bucket = new Bucket() - bucket.add({ count: 1 }) - expect(bucket.entities).toEqual([{ count: 1 }]) - }) - - it("returns the object that is passed in to it", () => { - const bucket = new Bucket() - const entity = {} - expect(bucket.add(entity)).toBe(entity) - }) - - it("checks the bucket's type", () => { - const bucket = new Bucket<{ count: number }>() - bucket.add({ count: 1 }) - expect(bucket.entities).toEqual([{ count: 1 }]) - }) - - it("is idempotent", () => { - const bucket = new Bucket() - const entity = { count: 1 } - bucket.add(entity) - bucket.add(entity) - expect(bucket.entities).toEqual([entity]) - }) - - it("emits an event", () => { - const bucket = new Bucket<{ count: number }>() - const listener = jest.fn() - bucket.onEntityAdded.add(listener) - - const entity = bucket.add({ count: 1 }) - expect(listener).toHaveBeenCalledWith(entity) - }) + describe("add", () => { + it("adds an entity to the world", () => { + const world = new Bucket() + const entity = { id: 0 } - it("does not emit an event when an entity is added twice", () => { - const entity = { count: 1 } - const bucket = new Bucket<{ count: number }>() - const listener = jest.fn() - bucket.onEntityAdded.add(listener) + world.add(entity) - bucket.add(entity) - bucket.add(entity) - expect(listener).toHaveBeenCalledTimes(1) - }) -}) - -describe("update", () => { - it("emits an event", () => { - const bucket = new Bucket<{ count: number }>() - const listener = jest.fn() - bucket.onEntityTouched.add(listener) - - const entity = bucket.add({ count: 1 }) - bucket.touch(entity) - expect(listener).toHaveBeenCalledWith(entity) - }) -}) - -describe("remove", () => { - it("removes an entity from the bucket", () => { - const entity = { count: 1 } - const bucket = new Bucket() - - bucket.add(entity) - expect(bucket.entities).toEqual([entity]) - - bucket.remove(entity) - expect(bucket.entities).toEqual([]) - }) - - it("is idempotent", () => { - const entity = { count: 1 } - const bucket = new Bucket() - bucket.add(entity) - bucket.remove(entity) - bucket.remove(entity) - expect(bucket.entities).toEqual([]) - }) - - it("emits an event when the entity is removed", () => { - const bucket = new Bucket<{ count: number }>() - const listener = jest.fn() - bucket.onEntityRemoved.add(listener) + expect(world.entities).toEqual([entity]) + }) - const entity = bucket.add({ count: 1 }) - bucket.remove(entity) - expect(listener).toHaveBeenCalledWith(entity) - }) -}) + it("doesn't add the same entity twice", () => { + const world = new Bucket() + const entity = { id: 0 } -describe("derive", () => { - it("creates a new bucket", () => { - const bucket = new Bucket() - const derivedBucket = bucket.derive() - expect(derivedBucket).toBeDefined() - }) + world.add(entity) + world.add(entity) - it("if no predicate is given the derived bucket will receive the same entities", () => { - const bucket = new Bucket() - const derivedBucket = bucket.derive() - bucket.add({ count: 1 }) - expect(derivedBucket.entities).toEqual([{ count: 1 }]) - }) + expect(world.entities).toEqual([entity]) + }) - it("if a predicate is given the derived bucket will only receive entities that match the predicate", () => { - const bucket = new Bucket<{ count: number }>() + it("returns the entity", () => { + const world = new Bucket() + const entity = { id: 0 } - const derivedBucket = bucket.derive((entity) => entity.count > 1) + const result = world.add(entity) - bucket.add({ count: 1 }) - expect(derivedBucket.entities).toEqual([]) - - bucket.add({ count: 2 }) - expect(derivedBucket.entities).toEqual([{ count: 2 }]) + expect(result).toBe(entity) + }) }) - it("it properly captures predicate type guards", () => { - type Entity = { name?: string; age?: number } - - const world = new Bucket() - const withName = world.derive(archetype("name")) - - const entity = world.add({ name: "Bob", age: 20 }) - expect(withName.entities).toEqual([entity]) - - withName.entities[0].name.length - /* No real test, just making sure the type is correct */ - }) + describe("remove", () => { + it("removes an entity from the world", () => { + const entity = { name: "John" } + const bucket = new Bucket({ entities: [entity] }) + expect(bucket.entities).toEqual([entity]) - it("given equal predicates, it returns the same bucket", () => { - type Entity = { count: number } - - const bucket = new Bucket() - const predicate = (entity: Entity) => entity.count > 1 - - const derivedBucket = bucket.derive(predicate) - const derivedBucket2 = bucket.derive(predicate) - expect(derivedBucket).toBe(derivedBucket2) - }) - - it("given different predicates, it returns different buckets", () => { - type Entity = { count: number } - - const bucket = new Bucket() - const derivedBucket = bucket.derive((entity) => entity.count > 1) - const derivedBucket2 = bucket.derive((entity) => entity.count > 2) - - expect(derivedBucket).not.toBe(derivedBucket2) - }) - - it("given the same two archetype predicates, it returns the same bucket", () => { - type Entity = { count: number } - - const bucket = new Bucket() - const derivedBucket = bucket.derive(archetype("count")) - const derivedBucket2 = bucket.derive(archetype("count")) - - expect(derivedBucket).toBe(derivedBucket2) - }) -}) - -describe("clear", () => { - it("removes all entities from the bucket", () => { - const bucket = new Bucket() - bucket.add({ count: 1 }) - bucket.add({ count: 2 }) - expect(bucket.entities).toEqual([{ count: 1 }, { count: 2 }]) - - bucket.clear() - expect(bucket.entities).toEqual([]) - }) - - it("emits an event for each entity that is removed", () => { - const bucket = new Bucket<{ count: number }>() - const listener = jest.fn() - bucket.onEntityRemoved.add(listener) - - bucket.add({ count: 1 }) - bucket.add({ count: 2 }) - bucket.clear() - expect(listener).toHaveBeenCalledTimes(2) - }) - - it("emits the onCleared event", () => { - const bucket = new Bucket() - const listener = jest.fn() - bucket.onCleared.add(listener) - - bucket.clear() - expect(listener).toHaveBeenCalledTimes(1) - }) -}) - -describe("dispose", () => { - it("removes all entities from the bucket", () => { - const bucket = new Bucket() - bucket.add({ count: 1 }) - bucket.add({ count: 2 }) - expect(bucket.entities).toEqual([{ count: 1 }, { count: 2 }]) - - bucket.dispose() - expect(bucket.entities).toEqual([]) - }) - - it("also disposes any derived buckets", () => { - const bucket = new Bucket() - const derivedBucket = bucket.derive() - bucket.add({ count: 1 }) - expect(derivedBucket.entities).toEqual([{ count: 1 }]) - - bucket.dispose() - expect(derivedBucket.entities).toEqual([]) - }) - - it("also disposes buckets derived from derived buckets", () => { - const bucket = new Bucket() - const derivedBucket = bucket.derive() - const derivedBucket2 = derivedBucket.derive() - bucket.add({ count: 1 }) - expect(derivedBucket2.entities).toEqual([{ count: 1 }]) - - bucket.dispose() - expect(derivedBucket2.entities).toEqual([]) - }) - - it("when a derived bucket is disposed, remove its listeners from us", () => { - const bucket = new Bucket() - const derivedBucket = bucket.derive() - expect(bucket.onEntityAdded.listeners.size).toEqual(1) - - derivedBucket.dispose() - expect(bucket.onEntityAdded.listeners.size).toEqual(0) - }) -}) + bucket.remove(entity) + expect(bucket.entities).toEqual([]) + }) -describe("size", () => { - it("returns the size of the world", () => { - const bucket = new Bucket() - bucket.add({}) - expect(bucket.size).toBe(1) - }) + it("no-ops if it doesn't have the entity", () => { + const world = new Bucket() + expect(world.entities).toEqual([]) - it("is equal to the amount of entities stored in the bucket", () => { - const bucket = new Bucket() - bucket.add({}) - expect(bucket.size).toBe(bucket.entities.length) + world.remove({ id: 0 }) + expect(world.entities).toEqual([]) + }) }) -}) -describe("Symbol.iterator", () => { - it("iterating through a bucket happens in reverse order", () => { - const bucket = new Bucket() - const entity1 = bucket.add({}) - const entity2 = bucket.add({}) - const entity3 = bucket.add({}) - - const entities = [] - for (const entity of bucket) { - entities.push(entity) - } - - expect(entities).toEqual([entity3, entity2, entity1]) + describe("size", () => { + it("returns the number of entities in the bucket", () => { + const bucket = new Bucket({ entities: [{ id: 0 }, { id: 1 }] }) + expect(bucket.size).toBe(2) + }) }) - it("allows for safe entity deletions", () => { - const bucket = new Bucket() - const entity1 = bucket.add({}) - const entity2 = bucket.add({}) - const entity3 = bucket.add({}) - - const entities = [] - for (const entity of bucket) { - entities.push(entity) - bucket.remove(entity) - } - - expect(entities).toEqual([entity3, entity2, entity1]) - expect(bucket.entities).toEqual([]) + describe("Symbol.iterator", () => { + it("iterates over entities in a reversed order", () => { + const bucket = new Bucket({ entities: [{ id: 0 }, { id: 1 }] }) + expect([...bucket]).toEqual([{ id: 1 }, { id: 0 }]) + }) }) }) diff --git a/packages/miniplex-core/test/World.test.ts b/packages/miniplex-core/test/World.test.ts index 6f520761..f0514092 100644 --- a/packages/miniplex-core/test/World.test.ts +++ b/packages/miniplex-core/test/World.test.ts @@ -1,191 +1,159 @@ -import { World, Bucket } from "../src" +import { Archetype } from "../src/Archetype" +import { World } from "../src/World" describe("World", () => { - it("inherits from Bucket", () => { - const world = new World() - expect(world).toBeDefined() - expect(world).toBeInstanceOf(Bucket) - }) + describe("archetype", () => { + it("creates an archetype for the given query", () => { + const world = new World<{ name: string; age?: number }>() + const archetype = world.archetype("name") + expect(archetype).toBeDefined() + expect(archetype).toBeInstanceOf(Archetype) + }) - describe("id", () => { - it("returns a world-unique ID for the given entity", () => { - const world = new World() - const entity = world.add({}) - expect(world.id(entity)).toBe(0) + it("supports a list of components as a shortcut", () => { + const world = new World<{ name: string; age?: number }>() + const archetype1 = world.archetype("name") + const archetype2 = world.archetype({ all: ["name"] }) + expect(archetype1).toBe(archetype2) + }) - /* It should always return the same ID for the same entity */ - expect(world.id(entity)).toBe(0) + it("returns the same archetype if it already exists", () => { + const world = new World<{ name: string; age?: number }>() + const archetype1 = world.archetype({ all: ["name"] }) + const archetype2 = world.archetype({ all: ["name"] }) + expect(archetype1).toBe(archetype2) }) - it("returns undefined for entities that are not known to the world", () => { - const world = new World() - const entity = {} - expect(world.id(entity)).toBeUndefined() + it("properly normalizes queries before matching against existing archetypes", () => { + const world = new World<{ name: string; age?: number }>() + const archetype1 = world.archetype({ all: ["age", "name"] }) + const archetype2 = world.archetype({ all: ["name", "age"] }) + expect(archetype1).toBe(archetype2) }) - it("forgets about IDs when the entity is removed", () => { - const world = new World() + it("adds existing entities that match the query to the archetype", () => { + const world = new World<{ name: string; age?: number }>() + const entity = world.add({ name: "John", age: 42 }) + world.add({ name: "Alice" }) - const entity = world.add({}) - expect(world.id(entity)).toBe(0) + const archetype = world.archetype({ all: ["age"] }) + expect(archetype.entities).toEqual([entity]) + }) + }) - world.remove(entity) - expect(world.id(entity)).toBeUndefined() + describe("add", () => { + it("adds the entity to matching archetypes", () => { + const world = new World<{ name: string; age?: number }>() + const archetype = world.archetype({ all: ["age"] }) + + const entity = world.add({ name: "John", age: 42 }) + expect(archetype.entities).toEqual([entity]) }) + }) - it("when an entity is removed and re-added, it will receive a new ID", () => { - const world = new World() + describe("remove", () => { + it("removes the entity from all archetypes", () => { + const world = new World<{ name: string; age?: number }>() + const archetype = world.archetype({ all: ["age"] }) - const entity = world.add({}) - expect(world.id(entity)).toBe(0) + const entity = world.add({ name: "John", age: 42 }) + expect(archetype.entities).toEqual([entity]) world.remove(entity) - expect(world.id(entity)).toBeUndefined() - - world.add(entity) - expect(world.id(entity)).toBe(1) + expect(archetype.entities).toEqual([]) }) }) describe("addComponent", () => { - it("adds a component to an entity", () => { - const world = new World() - const entity = world.add({ count: 1 }) - world.addComponent(entity, "name", "foo") - expect(entity).toEqual({ count: 1, name: "foo" }) + it("adds the component to the entity", () => { + const world = new World<{ name: string; age?: number }>() + const entity = world.add({ name: "John" }) + world.addComponent(entity, "age", 42) + expect(entity).toEqual({ name: "John", age: 42 }) }) - it("if the component is already on the entity, it does nothing", () => { - const world = new World() - const entity = world.add({ count: 1 }) - world.addComponent(entity, "count", 2) - expect(entity).toEqual({ count: 1 }) + it("it doesn't overwrite the component if it already exists", () => { + const world = new World<{ name: string; age?: number }>() + const entity = world.add({ name: "John", age: 42 }) + world.addComponent(entity, "age", 43) + expect(entity).toEqual({ name: "John", age: 42 }) }) - it("touches the entity", () => { - const world = new World() - const entity = world.add({ count: 1 }) - const listener = jest.fn() - world.onEntityTouched.add(listener) - world.addComponent(entity, "name", "foo") - expect(listener).toHaveBeenCalledWith(entity) - }) + it("adds the entity to matching archetypes", () => { + const world = new World<{ name: string; age?: number }>() + const archetype = world.archetype({ all: ["age"] }) + const entity = world.add({ name: "John" }) - it("returns true if the entity was updated", () => { - const world = new World() - const entity = world.add({ count: 1 }) - expect(world.addComponent(entity, "name", "foo")).toBe(true) - }) + expect(archetype.entities).toEqual([]) - it("returns false if the entity was not updated", () => { - const world = new World() - const entity = world.add({ count: 1 }) - expect(world.addComponent(entity, "count", 2)).toBe(false) + world.addComponent(entity, "age", 42) + expect(archetype.entities).toEqual([entity]) }) }) describe("removeComponent", () => { - it("removes a component from an entity", () => { - const world = new World() - const entity = world.add({ count: 1, name: "foo" }) - world.removeComponent(entity, "name") - expect(entity).toEqual({ count: 1 }) + it("removes the component from the entity", () => { + const world = new World<{ name: string; age?: number }>() + const entity = world.add({ name: "John", age: 42 }) + world.removeComponent(entity, "age") + expect(entity).toEqual({ name: "John" }) }) - it("if the component is not on the entity, it does nothing", () => { - const world = new World() - const entity = world.add({ count: 1 }) - const listener = jest.fn() + it("removes the entity from archetype it no longer matches with", () => { + const world = new World<{ name: string; age?: number }>() + const archetype = world.archetype({ all: ["age"] }) + const entity = world.add({ name: "John", age: 42 }) - world.onEntityTouched.add(listener) - world.removeComponent(entity, "name") - expect(entity).toEqual({ count: 1 }) - expect(listener).not.toHaveBeenCalledWith(entity) - }) + expect(archetype.entities).toEqual([entity]) - it("touches the entity", () => { - const world = new World() - const entity = world.add({ count: 1 }) - const listener = jest.fn() - world.onEntityTouched.add(listener) - world.removeComponent(entity, "count") - expect(listener).toHaveBeenCalledWith(entity) - }) - - it("returns true if the entity was updated", () => { - const world = new World() - const entity = world.add({ count: 1 }) - expect(world.removeComponent(entity, "count")).toBe(true) - }) - - it("returns false if the entity was not updated", () => { - const world = new World() - const entity = world.add({ count: 1 }) - expect(world.removeComponent(entity, "name")).toBe(false) + world.removeComponent(entity, "age") + expect(archetype.entities).toEqual([]) }) }) - describe("setComponent", () => { - it("updates the value of a component on an entity", () => { - const world = new World() - const entity = world.add({ count: 1 }) - world.setComponent(entity, "count", 2) - expect(entity).toEqual({ count: 2 }) - }) - - it("if the component is not on the entity, it does nothing", () => { - const world = new World() - const entity = world.add({ count: 1 }) - const listener = jest.fn() - - world.onEntityTouched.add(listener) - world.setComponent(entity, "name", "foo") - expect(entity).toEqual({ count: 1 }) - expect(listener).not.toHaveBeenCalledWith(entity) - }) - - it("touches the entity", () => { - const world = new World() - const entity = world.add({ count: 1 }) - const listener = jest.fn() - world.onEntityTouched.add(listener) - world.setComponent(entity, "count", 2) - expect(listener).toHaveBeenCalledWith(entity) - }) + describe("id", () => { + it("returns a unique ID for the given entity", () => { + const world = new World<{ name: string; age?: number }>() + const entity = world.add({ name: "John" }) + expect(world.id(entity)).toEqual(0) - it("returns true if the entity was updated", () => { - const world = new World() - const entity = world.add({ count: 1 }) - expect(world.setComponent(entity, "count", 2)).toBe(true) + const entity2 = world.add({ name: "Alice" }) + expect(world.id(entity2)).toEqual(1) }) - it("returns false if the entity was not updated", () => { - const world = new World() - const entity = world.add({ count: 1 }) - expect(world.setComponent(entity, "name", "foo")).toBe(false) + it("returns undefined if the entity is not part of the world", () => { + const world = new World<{ name: string; age?: number }>() + const entity = { name: "John" } + expect(world.id(entity)).toBeUndefined() }) }) - describe("archetype", () => { - it("creates a derived bucket that holds entities with a specific set of components", () => { - const world = new World() - const entity = world.add({ count: 1, name: "foo" }) - const bucket = world.archetype("count") - expect(bucket.entities).toEqual([entity]) - }) - - it("returns the same bucket object for the same components", () => { - const world = new World() - const bucket1 = world.archetype("count") - const bucket2 = world.archetype("count") - expect(bucket1).toBe(bucket2) + describe("entity", () => { + it("returns the entity matching the given ID", () => { + const world = new World<{ name: string; age?: number }>() + const entity = world.add({ name: "John" }) + const id = world.id(entity)! + expect(world.entity(id)).toEqual(entity) }) + }) - it("normalizes the specified components for the equality check", () => { - const world = new World() - const bucket1 = world.archetype("name", "count", "") - const bucket2 = world.archetype("count", undefined!, "name") - expect(bucket1).toBe(bucket2) + describe("clear", () => { + it("removes all known entities from the world", () => { + const world = new World<{ name: string; age?: number }>() + world.add({ name: "John", age: 42 }) + world.add({ name: "Alice" }) + world.clear() + expect(world.entities).toEqual([]) + }) + + it("also removes all entities from known archetypes", () => { + const world = new World<{ name: string; age?: number }>() + const archetype = world.archetype({ all: ["age"] }) + const john = world.add({ name: "John", age: 42 }) + world.add({ name: "Alice" }) + expect(archetype.entities).toEqual([john]) + world.clear() + expect(archetype.entities).toEqual([]) }) }) }) diff --git a/packages/miniplex-core/test/queries.test.ts b/packages/miniplex-core/test/queries.test.ts new file mode 100644 index 00000000..bf3f6468 --- /dev/null +++ b/packages/miniplex-core/test/queries.test.ts @@ -0,0 +1,63 @@ +import { + normalizeComponents, + normalizeQuery, + serializeQuery +} from "../src/queries" + +describe("normalizeComponents", () => { + it("sorts the given list of components alphabetically", () => { + expect(normalizeComponents(["b", "a"])).toEqual(["a", "b"]) + }) + + it("removes duplicates", () => { + expect(normalizeComponents(["a", "a"])).toEqual(["a"]) + }) + + it("removes empty strings", () => { + expect(normalizeComponents(["a", ""])).toEqual(["a"]) + }) + + it("removes falsy values", () => { + expect(normalizeComponents(["a", undefined!])).toEqual(["a"]) + }) +}) + +describe("normalizeQuery", () => { + it("normalizes the 'all' components", () => { + expect(normalizeQuery({ all: ["b", "a"] })).toEqual({ all: ["a", "b"] }) + }) + + it("normalizes the 'any' components", () => { + expect(normalizeQuery({ any: ["b", "a"] })).toEqual({ any: ["a", "b"] }) + }) + + it("normalizes the 'none' components", () => { + expect(normalizeQuery({ none: ["b", "a"] })).toEqual({ none: ["a", "b"] }) + }) + + it("does everything at once, too!", () => { + expect( + normalizeQuery({ + all: ["b", undefined!, "a"], + any: ["d", "c", "c"], + none: ["f", "e", null!] + }) + ).toEqual({ + all: ["a", "b"], + any: ["c", "d"], + none: ["e", "f"] + }) + }) +}) + +describe("serializeQuery", () => { + it("serializes the query as JSON", () => { + const query = { + all: ["a", "b"], + any: ["c", "d"], + none: ["e", "f"] + } + + expect(serializeQuery(query)).toEqual(JSON.stringify(query)) + }) +}) diff --git a/packages/miniplex-react/src/createReactAPI.tsx b/packages/miniplex-react/src/createReactAPI.tsx index dc827a3c..0a886b9e 100644 --- a/packages/miniplex-react/src/createReactAPI.tsx +++ b/packages/miniplex-react/src/createReactAPI.tsx @@ -1,12 +1,5 @@ import { useConst } from "@hmans/use-const" -import { - archetype, - Bucket, - IEntity, - Predicate, - WithRequiredKeys, - World -} from "@miniplex/core" +import { Bucket, IEntity, Query, WithComponents, World } from "@miniplex/core" import React, { createContext, FunctionComponent, @@ -84,37 +77,38 @@ export const createReactAPI = (world: World) => { ) const RawBucket = ({ - bucket: _bucket, + bucket, ...props }: { - bucket: Bucket | Predicate + bucket: Bucket children?: EntityChildren as?: FunctionComponent<{ entity: D; children?: ReactNode }> }) => { - const source = - typeof _bucket === "function" ? world.derive(_bucket) : _bucket - - const entities = useEntities(source) + const entities = useEntities(bucket) return } const Bucket = memo(RawBucket) as typeof RawBucket - const Archetype = ({ - components, + const Archetype = ({ + query, ...props }: { - components: A[] | A - children?: EntityChildren> + query: Query | C | C[] + children?: EntityChildren> as?: FunctionComponent<{ - entity: WithRequiredKeys + entity: WithComponents children?: ReactNode }> }) => ( ) @@ -144,7 +138,7 @@ export const createReactAPI = (world: World) => { /* Handle updates to existing component */ useIsomorphicLayoutEffect(() => { if (props.value === undefined) return - world.setComponent(entity, props.name, props.value) + entity[props.name] = props.value }, [entity, props.name, props.value]) /* Handle setting of child value */ diff --git a/packages/miniplex-react/test/createReactAPI.test.tsx b/packages/miniplex-react/test/createReactAPI.test.tsx index 822f1471..a08ff1f5 100644 --- a/packages/miniplex-react/test/createReactAPI.test.tsx +++ b/packages/miniplex-react/test/createReactAPI.test.tsx @@ -232,7 +232,7 @@ describe("", () => { const world = new World<{ name: string }>() const { Bucket } = createReactAPI(world) - world.add({ name: "Alice" }) + const alice = world.add({ name: "Alice" }) world.add({ name: "Bob" }) render({(entity) =>

{entity.name}

}) @@ -247,6 +247,14 @@ describe("", () => { expect(screen.getByText("Alice")).toBeInTheDocument() expect(screen.getByText("Bob")).toBeInTheDocument() expect(screen.getByText("Charlie")).toBeInTheDocument() + + act(() => { + world.remove(alice) + }) + + expect(screen.queryByText("Alice")).toBeNull() + expect(screen.getByText("Bob")).toBeInTheDocument() + expect(screen.getByText("Charlie")).toBeInTheDocument() }) describe("given an `as` prop", () => { @@ -277,9 +285,7 @@ describe("", () => { world.add({ name: "Bob" }) render( - - {(entity) =>

{entity.name}

} -
+ {(entity) =>

{entity.name}

}
) expect(screen.getByText("Alice")).toBeInTheDocument() @@ -287,16 +293,14 @@ describe("", () => { }) it("re-renders the entities when the bucket contents change", () => { - const world = new World<{ name: string }>() + const world = new World<{ name: string; age?: number }>() const { Archetype } = createReactAPI(world) world.add({ name: "Alice" }) world.add({ name: "Bob" }) - const { rerender } = render( - - {(entity) =>

{entity.name}

} -
+ render( + {(entity) =>

{entity.name}

}
) expect(screen.getByText("Alice")).toBeInTheDocument() @@ -322,12 +326,30 @@ describe("", () => { const User = (props: { entity: Entity }) =>
{props.entity.name}
- render() + render() expect(screen.getByText("Alice")).toBeInTheDocument() expect(screen.getByText("Bob")).toBeInTheDocument() }) }) + + it("accepts a query object as its `query` prop", () => { + type Entity = { name: string; age?: number } + const world = new World() + const { Archetype } = createReactAPI(world) + + world.add({ name: "Alice" }) + world.add({ name: "Bob", age: 100 }) + + render( + + {(entity) =>

{entity.name}

} +
+ ) + + expect(screen.getByText("Alice")).toBeInTheDocument() + expect(screen.getByText("Bob")).toBeInTheDocument() + }) }) describe("useArchetype", () => { diff --git a/tsconfig.json b/tsconfig.json index 2913ac73..664e1434 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "ES2017", - "module": "ES2015", + "target": "esnext", + "module": "esnext", "moduleResolution": "node", "declaration": true, "strict": true,