| D[]
114 | }): JSX.Element {
115 | if (source instanceof Bucket) {
116 | return
117 | } else {
118 | return
119 | }
120 | }
121 |
122 | const Component = (props: {
123 | name: P
124 | data?: E[P]
125 | children?: ReactNode
126 | }) => {
127 | const entity = useContext(EntityContext)
128 | const ref = useRef(null!)
129 |
130 | if (!entity) {
131 | throw new Error(" must be a child of ")
132 | }
133 |
134 | /* Handle creation and removal of component with a value prop */
135 | useIsomorphicLayoutEffect(() => {
136 | world.addComponent(entity, props.name, props.data || ref.current)
137 | return () => world.removeComponent(entity, props.name)
138 | }, [entity, props.name])
139 |
140 | /* Handle updates to existing component */
141 | useIsomorphicLayoutEffect(() => {
142 | if (props.data === undefined) return
143 | entity[props.name] = (props.data || ref.current) as (typeof entity)[P]
144 | }, [entity, props.name, props.data, ref.current])
145 |
146 | /* Handle setting of child value */
147 | if (props.children) {
148 | const child = React.Children.only(props.children) as ReactElement
149 |
150 | return React.cloneElement(child, {
151 | ref: mergeRefs([(child as any).ref, ref])
152 | })
153 | }
154 |
155 | return null
156 | }
157 |
158 | return {
159 | world,
160 | Component,
161 | Entity,
162 | Entities,
163 | useCurrentEntity
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/packages/bucket/src/Bucket.ts:
--------------------------------------------------------------------------------
1 | import { Event } from "eventery"
2 |
3 | /**
4 | * A class wrapping an array of entities of a specific type, providing
5 | * performance-optimized methods for adding, looking up and removing entities, and events
6 | * for when entities are added or removed.
7 | */
8 | export class Bucket implements Iterable {
9 | /* VERSIONING */
10 | protected _version = 0
11 |
12 | /**
13 | * The current version of the bucket. Increases every time an entity is
14 | * added or removed.
15 | */
16 | get version() {
17 | return this._version
18 | }
19 |
20 | /**
21 | * An array of all entities within the bucket. Please note that for iterating
22 | * over the entities in this bucket, it is recommended that you use the
23 | * `for (const entity of bucket)` iterator form.
24 | */
25 | get entities() {
26 | return this._entities
27 | }
28 |
29 | /* Custom iterator that iterates over all entities in reverse order. */
30 | [Symbol.iterator]() {
31 | let index = this._entities.length
32 |
33 | const result = {
34 | value: undefined as E,
35 | done: false
36 | }
37 |
38 | return {
39 | next: () => {
40 | result.value = this._entities[--index]
41 | result.done = index < 0
42 | return result
43 | }
44 | }
45 | }
46 |
47 | constructor(protected _entities: E[] = []) {
48 | this.add = this.add.bind(this)
49 | this.remove = this.remove.bind(this)
50 |
51 | /* Register all entity positions */
52 | for (let i = 0; i < _entities.length; i++) {
53 | this.entityPositions.set(_entities[i], i)
54 | }
55 | }
56 |
57 | /**
58 | * Fired when an entity has been added to the bucket.
59 | */
60 | onEntityAdded = new Event<[entity: E]>()
61 |
62 | /**
63 | * Fired when an entity is about to be removed from the bucket.
64 | */
65 | onEntityRemoved = new Event<[entity: E]>()
66 |
67 | /**
68 | * A map of entity positions, used for fast lookups.
69 | */
70 | private entityPositions = new Map()
71 |
72 | /**
73 | * Returns the total size of the bucket, i.e. the number of entities it contains.
74 | */
75 | get size() {
76 | return this.entities.length
77 | }
78 |
79 | /**
80 | * Returns the first entity in the bucket, or `undefined` if the bucket is empty.
81 | */
82 | get first(): E | undefined {
83 | return this.entities[0]
84 | }
85 |
86 | /**
87 | * Returns true if the bucket contains the given entity.
88 | *
89 | * @param entity The entity to check for.
90 | * @returns `true` if the specificed entity is in this bucket, `false` otherwise.
91 | */
92 | has(entity: any): entity is E {
93 | return this.entityPositions.has(entity)
94 | }
95 |
96 | /**
97 | * Adds the given entity to the bucket. If the entity is already in the bucket, it is
98 | * not added again.
99 | *
100 | * @param entity The entity to add to the bucket.
101 | * @returns The entity passed into this function (regardless of whether it was added or not).
102 | */
103 | add(entity: D): D & E {
104 | if (entity && !this.has(entity)) {
105 | this.entities.push(entity)
106 | this.entityPositions.set(entity, this.entities.length - 1)
107 |
108 | /* Increase version */
109 | this._version++
110 |
111 | /* Emit our own onEntityAdded event */
112 | this.onEntityAdded.emit(entity)
113 | }
114 |
115 | return entity
116 | }
117 |
118 | /**
119 | * Removes the given entity from the bucket. If the entity is not in the bucket, nothing
120 | * happens.
121 | *
122 | * @param entity The entity to remove from the bucket.
123 | * @returns The entity passed into this function (regardless of whether it was removed or not).
124 | */
125 | remove(entity: E) {
126 | /* TODO: Return early if entity is not in bucket. */
127 | if (this.has(entity)) {
128 | /* Emit our own onEntityRemoved event. */
129 | this.onEntityRemoved.emit(entity)
130 |
131 | /* Get the entity's current position. */
132 | const index = this.entityPositions.get(entity)!
133 | this.entityPositions.delete(entity)
134 |
135 | /* Perform shuffle-pop if there is more than one entity. */
136 | const other = this.entities[this.entities.length - 1]
137 | if (other !== entity) {
138 | this.entities[index] = other
139 | this.entityPositions.set(other, index)
140 | }
141 |
142 | /* Remove the entity from the entities array. */
143 | this.entities.pop()
144 |
145 | /* Bump version */
146 | this._version++
147 | }
148 |
149 | return entity
150 | }
151 |
152 | /**
153 | * Removes all entities from the bucket. Will cause the `onEntityRemoved` event to be
154 | * fired for each entity.
155 | */
156 | clear() {
157 | for (const entity of this) {
158 | this.remove(entity)
159 | }
160 | }
161 | }
162 |
--------------------------------------------------------------------------------
/packages/react/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 2.0.1
4 |
5 | ### Patch Changes
6 |
7 | - 2303870: Bump minimum version of `miniplex` peer dependency.
8 |
9 | ## 2.0.0
10 |
11 | ### Major Changes
12 |
13 | - 8a7a315: - The library has been significantly simplified and an almost mind-boggling number of bugs have beens quashed.
14 |
15 | - The main import and initialization have changed:
16 |
17 | ```js
18 | import { World } from "miniplex"
19 | import createReactAPI from "miniplex-react" // !
20 |
21 | /* It now expects a world as its argument, so you need to create one first: */
22 | const world = new World()
23 | const ECS = createReactAPI(world)
24 | ```
25 |
26 | - All lists of entities are now rendered through the upgraded `` component, which takes an array of entities or a query (or even a world) as its `in` prop:
27 |
28 | ```jsx
29 | {/* ... */}
30 | ```
31 |
32 | If you're passing in a query or a world, the component will automatically re-render when the entities appear or disappear. If you don't want this, you can also just pass in a plain array containing entities:
33 |
34 | ```jsx
35 | {/* ... */}
36 | ```
37 |
38 | - **`` has been removed.** You were probably not using it. If you were, you can replicate the same behavior using a combination of the `` component and a `useEffect` hook.
39 | - The `useEntity` hook has been renamed to `useCurrentEntity`.
40 | - The world-scoped `useArchetype` hook has been removed, and superseded by the new global `useEntities` hook:
41 |
42 | ```js
43 | /* Before */
44 | const entities = useArchetype("position", "velocity")
45 |
46 | /* Now */
47 | const entities = useEntities(world.with("position", "velocity"))
48 | ```
49 |
50 | ## 1.0.1
51 |
52 | ### Patch Changes
53 |
54 | - a43c734: **Fixed:** When `` re-renders, it is expected to reactively update the component's data to the value of its `data` prop, or the `ref` of its React child. It has so far been doing that by removing and re-adding the entire component, which had the side-effect of making the entity disappear from and then reappear in archetypes indexing that component. This has now been fixed.
55 |
56 | The component will only be added and removed once (at the beginning and the end of the React component's lifetime, respectively); in re-renders during its lifetime, the data will simply be updated directly when a change is detected. This allows you to connect a `` to the usual reactive mechanisms in React.
57 |
58 | ## 1.0.0
59 |
60 | ### Major Changes
61 |
62 | - ce9cfb4: **Breaking Change:** The `useEntity` hook has been renamed to `useCurrentEntity` to better express what it does, and to make way for future `useEntity` and `useEntities` hooks that will create and destroy entities.
63 |
64 | ### Patch Changes
65 |
66 | - c102f2d: **New:** ``, a new component that (reactively) renders all entities of the specified archetype. This can be used as a replacement for the combination of `useArchetype` and ``, except now your component won't re-render when entities appear or disappear, because the subscription will be scoped to ``.
67 |
68 | Where before you may have done this:
69 |
70 | ```tsx
71 | const MyComponent = () => {
72 | const { entities } = useArchetype("my-archetype")
73 | /* This component will now re-render every time the archetype is updated */
74 | return
75 | }
76 | ```
77 |
78 | You can now do this:
79 |
80 | ```tsx
81 | const MyComponent = () => {
82 | /* This component will not rerender */
83 | return
84 | }
85 | ```
86 |
87 | The component will also accept arrays of component names:
88 |
89 | ```tsx
90 | const EnemyShips = () => {
91 | return
92 | }
93 | ```
94 |
95 | - c38d7e5: **Fixed:** A couple of components were using `useEffect` where it should have been `useLayoutEffect`.
96 | - 54bb5ef: **Fixed:** no longer re-renders once after mounting.
97 | - 551dcd9: **New:** The `createECS` function now allows you to pass in an existing `World` instance as its first argument. If no world is passed, it will create a new one (using the specified type, if any), as it has previously.
98 |
99 | ## 1.0.0-next.8
100 |
101 | ### Patch Changes
102 |
103 | - 877dac5: **Fixed:** Make use of `useIsomorphicLayoutEffect`.
104 |
105 | ## 1.0.0-next.7
106 |
107 | ### Patch Changes
108 |
109 | - c38d7e5: **Fixed:** A couple of components were using `useEffect` where it should have been `useLayoutEffect`.
110 | - 54bb5ef: **Fixed:** no longer re-renders once after mounting.
111 |
112 | ## 1.0.0-next.6
113 |
114 | ### Patch Changes
115 |
116 | - efa21f2: Typing tweaks.
117 |
118 | ## 1.0.0-next.4
119 |
120 | ### Patch Changes
121 |
122 | - c102f2d: **New:** ``, a new component that (reactively) renders all entities of the specified archetype.
123 |
124 | ## 1.0.0-next.3
125 |
126 | ### Patch Changes
127 |
128 | - 1950b9b: General cleanup and typing improvements.
129 |
130 | ## 1.0.0-next.2
131 |
132 | ### Patch Changes
133 |
134 | - 551dcd9: The `createECS` function now allows you to pass in an existing `World` instance as its first argument. If no world is passed, it will create a new one (using the specified type, if any), as it has previously.
135 |
136 | ## 1.0.0-next.1
137 |
138 | ### Major Changes
139 |
140 | - 4016fb2: 1.0!
141 |
142 | ### Patch Changes
143 |
144 | - Updated dependencies [410e0f6]
145 | - Updated dependencies [4016fb2]
146 | - miniplex@1.0.0-next.1
147 |
148 | ## 0.4.3-next.0
149 |
150 | ### Patch Changes
151 |
152 | - dd047e9: This package now loads `miniplex` as a direct dependency; it is no longer necessary to install miniplex as a peer dependency.
153 | - Updated dependencies [769dba7]
154 | - Updated dependencies [b8b2c9b]
155 | - Updated dependencies [cb6d078]
156 | - Updated dependencies [4d9e51b]
157 | - miniplex@0.11.0-next.0
158 |
159 | ## 0.4.2
160 |
161 | ### Patch Changes
162 |
163 | - 1422853: Fixed return type of `useArchetype`.
164 |
165 | ## 0.4.1
166 |
167 | ### Patch Changes
168 |
169 | - cb09f35: **Fixed:** When you're passing a complete React element (through JSX) to a ``, you were not able to set a `ref` on it. This has now been fixed.
170 |
171 | ## 0.4.0
172 |
173 | ### Minor Changes
174 |
175 | - 0f01a94: **Breaking Change:** `` has been renamed to ``.
176 | - 0ad0e86: **Breaking Change:** `useEntity` has been changed back to its original functionality of returning the current entity context. `useEntities` has been removed.
177 |
178 | ## 0.3.1
179 |
180 | ### Patch Changes
181 |
182 | - db987cd: Improve typings within `useEntities`.
183 |
184 | ## 0.3.0
185 |
186 | ### Minor Changes
187 |
188 | - cc4032d: **New:** `useEntities` is a new hook that will create and return a specified number of entities, initialized through an optional entity factory. `useEntity` does the same, but just for a single entity.
189 |
190 | ## 0.2.4
191 |
192 | ### Patch Changes
193 |
194 | - 68cff32: Fix React 18 Strict Mode compatibility in ``.
195 |
196 | ## 0.2.3
197 |
198 | ### Patch Changes
199 |
200 | - c23681c: More tweaks to the sanity checks
201 |
202 | ## 0.2.2
203 |
204 | ### Patch Changes
205 |
206 | - 48e785d: Fix sanity check in ``
207 |
208 | ## 0.2.1
209 |
210 | ### Patch Changes
211 |
212 | - 0c1ce64: Now uses `useEffect` instead of `useLayoutEffect`, which should make it easier to use the components in server-side React.
213 |
214 | ## 0.2.0
215 |
216 | ### Minor Changes
217 |
218 | - b4fa0b4: `` and `` now use the new `addComponent` API introduced with miniplex 0.8.0.
219 |
220 | ### Patch Changes
221 |
222 | - Updated dependencies [011c384]
223 | - miniplex@0.8.1
224 |
225 | ## 0.1.0
226 |
227 | - First release
228 |
--------------------------------------------------------------------------------
/packages/core/benchmark.ts:
--------------------------------------------------------------------------------
1 | import { World } from "./src"
2 | import chalk from "chalk"
3 |
4 | const entityCount = 1_000_000
5 |
6 | const heading = (text: string) => {
7 | console.log()
8 | console.log(chalk.bgCyanBright(` ${text} `))
9 | console.log()
10 | }
11 |
12 | const profile = (name: string, setup: () => () => () => boolean) => {
13 | const test = setup()
14 | const before = performance.now()
15 | const assertion = test()
16 | const after = performance.now()
17 |
18 | /* Check assertion */
19 | if (!assertion()) {
20 | throw new Error("Assertion failed!")
21 | }
22 |
23 | /* Results */
24 | const duration = after - before
25 | const ops = entityCount / (after - before)
26 |
27 | console.log(
28 | `${name.padStart(50)} ${duration.toFixed(2).padStart(8)}ms ${ops
29 | .toFixed(1)
30 | .padStart(10)} ops/ms`
31 | )
32 | }
33 |
34 | type Vector = {
35 | x: number
36 | y: number
37 | z: number
38 | }
39 |
40 | type Entity = {
41 | position: Vector
42 | velocity?: Vector
43 | }
44 |
45 | console.log(`Entity count: ${entityCount}\n`)
46 |
47 | heading("Entity Addition")
48 |
49 | profile("add", () => {
50 | const world = new World()
51 |
52 | return () => {
53 | for (let i = 0; i < entityCount; i++) {
54 | world.add({
55 | position: { x: 0, y: i, z: 0 },
56 | velocity: { x: 0, y: 0, z: 0 }
57 | })
58 | }
59 |
60 | return () => world.size === entityCount
61 | }
62 | })
63 |
64 | profile("add (with archetypes)", () => {
65 | const world = new World()
66 | const withPosition = world.with("position").connect()
67 | const withVelocity = world.with("velocity").connect()
68 |
69 | return () => {
70 | for (let i = 0; i < entityCount; i++) {
71 | world.add({
72 | position: { x: 0, y: i, z: 0 },
73 | velocity: { x: 0, y: 0, z: 0 }
74 | })
75 | }
76 |
77 | return () => world.size === entityCount
78 | }
79 | })
80 |
81 | heading("Entity Removal")
82 |
83 | profile("remove (random)", () => {
84 | const world = new World()
85 | for (let i = 0; i < entityCount; i++)
86 | world.add({
87 | position: { x: 0, y: i, z: 0 },
88 | velocity: { x: 0, y: 0, z: 0 }
89 | })
90 |
91 | return () => {
92 | while (world.size > 0) {
93 | /* Get a random entity... */
94 | const entity = world.entities[Math.floor(Math.random() * world.size)]
95 |
96 | /* ...and delete it */
97 | world.remove(entity)
98 | }
99 |
100 | return () => world.size === 0
101 | }
102 | })
103 |
104 | profile("remove (random, with archetypes)", () => {
105 | const world = new World()
106 | const withPosition = world.with("position").connect()
107 | const withVelocity = world.with("velocity").connect()
108 |
109 | for (let i = 0; i < entityCount; i++)
110 | world.add({
111 | position: { x: 0, y: i, z: 0 },
112 | velocity: { x: 0, y: 0, z: 0 }
113 | })
114 |
115 | return () => {
116 | while (world.size > 0) {
117 | /* Get a random entity... */
118 | const entity = world.entities[Math.floor(Math.random() * world.size)]
119 |
120 | /* ...and delete it */
121 | world.remove(entity)
122 | }
123 |
124 | return () =>
125 | world.size === 0 && withPosition.size === 0 && withVelocity.size === 0
126 | }
127 | })
128 |
129 | profile("clear", () => {
130 | const world = new World()
131 | for (let i = 0; i < entityCount; i++)
132 | world.add({
133 | position: { x: 0, y: i, z: 0 },
134 | velocity: { x: 0, y: 0, z: 0 }
135 | })
136 |
137 | return () => {
138 | world.clear()
139 |
140 | return () => world.size === 0
141 | }
142 | })
143 |
144 | profile("clear (with archetypes)", () => {
145 | const world = new World()
146 | const withPosition = world.with("position").connect()
147 | const withVelocity = world.with("velocity").connect()
148 |
149 | for (let i = 0; i < entityCount; i++)
150 | world.add({
151 | position: { x: 0, y: i, z: 0 },
152 | velocity: { x: 0, y: 0, z: 0 }
153 | })
154 |
155 | return () => {
156 | world.clear()
157 |
158 | return () =>
159 | world.size === 0 && withPosition.size === 0 && withVelocity.size === 0
160 | }
161 | })
162 |
163 | heading("Iteration")
164 |
165 | profile("simulate (iterator, world)", () => {
166 | const world = new World()
167 |
168 | for (let i = 0; i < entityCount; i++)
169 | world.add({
170 | position: { x: Math.random() * 200 - 100, y: i, z: 0 },
171 | velocity: { x: 1, y: 2, z: 3 }
172 | })
173 |
174 | return () => {
175 | let i = 0
176 | for (const { position, velocity } of world) {
177 | i++
178 | if (!velocity) continue
179 | position.x += velocity.x
180 | position.y += velocity.y
181 | position.z += velocity.z
182 | }
183 |
184 | return () => i === entityCount
185 | }
186 | })
187 |
188 | profile("simulate (iterator, archetype)", () => {
189 | const world = new World()
190 | const withVelocity = world.with("velocity").connect()
191 |
192 | for (let i = 0; i < entityCount; i++)
193 | world.add({
194 | position: { x: Math.random() * 200 - 100, y: i, z: 0 },
195 | velocity: { x: 1, y: 2, z: 3 }
196 | })
197 |
198 | return () => {
199 | let i = 0
200 |
201 | for (const { position, velocity } of withVelocity) {
202 | i++
203 | position.x += velocity.x
204 | position.y += velocity.y
205 | position.z += velocity.z
206 | }
207 |
208 | return () => i === entityCount
209 | }
210 | })
211 |
212 | profile("simulate (iterator, array)", () => {
213 | const world = new World()
214 |
215 | for (let i = 0; i < entityCount; i++)
216 | world.add({
217 | position: { x: Math.random() * 200 - 100, y: i, z: 0 },
218 | velocity: { x: 1, y: 2, z: 3 }
219 | })
220 |
221 | return () => {
222 | let i = 0
223 | for (const { position, velocity } of world.entities) {
224 | i++
225 | if (!velocity) continue
226 | position.x += velocity.x
227 | position.y += velocity.y
228 | position.z += velocity.z
229 | }
230 |
231 | return () => i === entityCount
232 | }
233 | })
234 |
235 | profile("simulate (for, array)", () => {
236 | const world = new World()
237 |
238 | for (let i = 0; i < entityCount; i++)
239 | world.add({
240 | position: { x: Math.random() * 200 - 100, y: i, z: 0 },
241 | velocity: { x: 1, y: 2, z: 3 }
242 | })
243 |
244 | return () => {
245 | let count = 0
246 |
247 | for (let i = 0; i < world.entities.length; i++) {
248 | count++
249 | const { position, velocity } = world.entities[i]
250 | if (!velocity) continue
251 |
252 | position.x += velocity.x
253 | position.y += velocity.y
254 | position.z += velocity.z
255 | }
256 |
257 | return () => count === entityCount
258 | }
259 | })
260 |
261 | heading("Iteration with predicates")
262 |
263 | profile(".where() query", () => {
264 | const world = new World()
265 |
266 | const positiveX = world.where((e) => e.position.x > 0).connect()
267 |
268 | for (let i = 0; i < entityCount; i++)
269 | world.add({
270 | position: { x: Math.random() * 200 - 100, y: i, z: 0 },
271 | velocity: { x: 1, y: 2, z: 3 }
272 | })
273 |
274 | return () => {
275 | let i = 0
276 |
277 | for (const { position, velocity } of positiveX) {
278 | i++
279 | if (!velocity) continue
280 | position.x += velocity.x
281 | position.y += velocity.y
282 | position.z += velocity.z
283 | }
284 |
285 | return () => i > 0
286 | }
287 | })
288 |
289 | profile("value predicate check (filter 👎)", () => {
290 | const world = new World()
291 |
292 | for (let i = 0; i < entityCount; i++)
293 | world.add({
294 | position: { x: Math.random() * 200 - 100, y: i, z: 0 },
295 | velocity: { x: 1, y: 2, z: 3 }
296 | })
297 |
298 | return () => {
299 | let i = 0
300 |
301 | for (const { position, velocity } of world.entities.filter(
302 | (e) => e.position.x > 0
303 | )) {
304 | i++
305 | if (!velocity) continue
306 | position.x += velocity.x
307 | position.y += velocity.y
308 | position.z += velocity.z
309 | }
310 |
311 | return () => i > 0
312 | }
313 | })
314 |
315 | heading("ooflorent's packed_5")
316 |
317 | profile("1000x for entity of 1000 entities", () => {
318 | const ecs = new World()
319 |
320 | for (let i = 0; i < 1000; i++) {
321 | ecs.add({ A: 1, B: 1, C: 1, D: 1, E: 1 })
322 | }
323 |
324 | const withA = ecs.with("A")
325 | const withB = ecs.with("B")
326 | const withC = ecs.with("C")
327 | const withD = ecs.with("D")
328 | const withE = ecs.with("E")
329 |
330 | return () => {
331 | for (let i = 0; i < 1000; i++) {
332 | for (const entity of withA.entities) entity.A *= 2
333 | for (const entity of withB.entities) entity.B *= 2
334 | for (const entity of withC.entities) entity.C *= 2
335 | for (const entity of withD.entities) entity.D *= 2
336 | for (const entity of withE.entities) entity.E *= 2
337 | }
338 |
339 | return () => true
340 | }
341 | })
342 |
343 | profile("1000x iterating over iterator with 1000 entities", () => {
344 | const ecs = new World()
345 |
346 | for (let i = 0; i < 1000; i++) {
347 | ecs.add({ A: 1, B: 1, C: 1, D: 1, E: 1 })
348 | }
349 |
350 | const withA = ecs.with("A")
351 | const withB = ecs.with("B")
352 | const withC = ecs.with("C")
353 | const withD = ecs.with("D")
354 | const withE = ecs.with("E")
355 |
356 | return () => {
357 | for (let i = 0; i < 1000; i++) {
358 | for (const entity of withA) entity.A *= 2
359 | for (const entity of withB) entity.B *= 2
360 | for (const entity of withC) entity.C *= 2
361 | for (const entity of withD) entity.D *= 2
362 | for (const entity of withE) entity.E *= 2
363 | }
364 |
365 | return () => true
366 | }
367 | })
368 |
--------------------------------------------------------------------------------
/packages/react/test/createReactAPI.test.tsx:
--------------------------------------------------------------------------------
1 | import "@testing-library/jest-dom"
2 | import { act, render, screen } from "@testing-library/react"
3 | import { World } from "miniplex"
4 | import React, { StrictMode } from "react"
5 | import createReactAPI from "../src"
6 |
7 | type Entity = {
8 | name: string
9 | age?: number
10 | height?: number
11 | }
12 |
13 | /*
14 | Hide errors thrown by React (we're testing for them.)
15 | See: https://dev.to/martinemmert/hide-red-console-error-log-wall-while-testing-errors-with-jest-2bfn
16 | */
17 | beforeEach(() => {
18 | jest.spyOn(console, "error")
19 | // @ts-ignore jest.spyOn adds this functionallity
20 | console.error.mockImplementation(() => null)
21 | })
22 |
23 | afterEach(() => {
24 | // @ts-ignore jest.spyOn adds this functionallity
25 | console.error.mockRestore()
26 | })
27 |
28 | describe("", () => {
29 | it("creates an entity", () => {
30 | const world = new World()
31 | const { Entity } = createReactAPI(world)
32 |
33 | expect(world.entities.length).toBe(0)
34 | render()
35 | expect(world.entities.length).toBe(1)
36 | })
37 |
38 | it("allows ref forwarding", () => {
39 | const world = new World()
40 | const { Entity } = createReactAPI(world)
41 | const ref = React.createRef()
42 |
43 | render()
44 |
45 | expect(ref.current).not.toBeNull()
46 | expect(ref.current).toBe(world.first)
47 | })
48 |
49 | it("keeps the entity when the component is rerendered", () => {
50 | const world = new World()
51 | const { Entity } = createReactAPI(world)
52 |
53 | expect(world.entities.length).toBe(0)
54 |
55 | /* Create a new entity and make sure the component is not memozied. */
56 | const Test = () => {Math.random()}
57 |
58 | const { rerender } = render()
59 | expect(world.entities.length).toBe(1)
60 | const entity = world.first
61 |
62 | rerender()
63 | expect(world.entities.length).toBe(1)
64 | expect(world.first).toBe(entity)
65 | })
66 |
67 | it("removes the entity on unmount", () => {
68 | const world = new World()
69 | const { Entity } = createReactAPI(world)
70 |
71 | const { unmount } = render()
72 | expect(world.entities.length).toBe(1)
73 | unmount()
74 | expect(world.entities.length).toBe(0)
75 | })
76 |
77 | it("accepts a function as its child", () => {
78 | const world = new World()
79 | const { Entity } = createReactAPI(world)
80 |
81 | const entity = world.add({ name: "John" })
82 |
83 | render(
84 | {(entity) => {entity.name}
}
85 | )
86 |
87 | expect(world.entities[0].name).toBe("John")
88 | expect(screen.getByText("John")).toBeInTheDocument()
89 | })
90 |
91 | it("accepts a React function component as a child", () => {
92 | const world = new World()
93 | const { Entity } = createReactAPI(world)
94 |
95 | const entity = world.add({ name: "Alice", age: 30 })
96 |
97 | const User = (entity: Entity) => Name: {entity.name}
98 |
99 | render()
100 |
101 | expect(screen.getByText("Name: Alice")).toBeInTheDocument()
102 | })
103 |
104 | describe("with a given entity that is not yet part of the bucket", () => {
105 | it("adds the entity to the bucket", () => {
106 | const world = new World()
107 | const { Entity } = createReactAPI(world)
108 | const entity = { name: "John" }
109 |
110 | expect(world.entities.length).toBe(0)
111 | render()
112 | expect(world.entities.length).toBe(1)
113 | expect(world.entities[0]).toBe(entity)
114 | })
115 |
116 | it("removes the entity on unmount", () => {
117 | const world = new World()
118 | const { Entity } = createReactAPI(world)
119 | const entity = { name: "John" }
120 |
121 | const { unmount } = render()
122 | expect(world.entities.length).toBe(1)
123 | unmount()
124 | expect(world.entities.length).toBe(0)
125 | })
126 | })
127 |
128 | describe("given `children` prop", () => {
129 | it("renders the entity using that component, passing the entity to it", () => {
130 | const world = new World()
131 | const { Entity } = createReactAPI(world)
132 |
133 | const entity = world.add({ name: "John" })
134 |
135 | const Person = ({ name }: { name: string }) => {name}
136 |
137 | render()
138 |
139 | expect(screen.getByText("John")).toBeInTheDocument()
140 | })
141 | })
142 | })
143 |
144 | describe("", () => {
145 | it("assigns the specified component", () => {
146 | const world = new World()
147 | const { Entity, Component } = createReactAPI(world)
148 |
149 | render(
150 |
151 |
152 |
153 | )
154 | expect(world.entities[0]).toMatchObject({})
155 | expect(world.entities[0].name).toBe("John")
156 | })
157 |
158 | it("updates the specified component on re-rendering", () => {
159 | const world = new World()
160 | const { Entity, Component } = createReactAPI(world)
161 |
162 | const { rerender } = render(
163 |
164 |
165 |
166 | )
167 | expect(world.entities[0].name).toBe("John")
168 |
169 | rerender(
170 |
171 |
172 |
173 | )
174 | expect(world.entities[0].name).toBe("Jane")
175 | })
176 |
177 | it("removes the component when the component is unmounted", () => {
178 | const world = new World()
179 | const entity = world.add({ name: "John" })
180 | const { Entity, Component } = createReactAPI(world)
181 |
182 | const { unmount } = render(
183 |
184 |
185 |
186 | )
187 | expect(world.entities[0].age).toBe(50)
188 |
189 | unmount()
190 | expect(world.entities[0]).toEqual({ name: "John" })
191 | })
192 |
193 | it("captures the ref of the child when it has one", () => {
194 | const world = new World<{ div?: HTMLDivElement }>()
195 | const entity = world.add({})
196 |
197 | const { Entity, Component } = createReactAPI(world)
198 |
199 | const ref = React.createRef()
200 |
201 | const { unmount } = render(
202 |
203 |
204 |
205 |
206 |
207 | )
208 |
209 | expect(entity.div).toBe(ref.current)
210 |
211 | unmount()
212 |
213 | expect(entity.div).toBe(undefined)
214 | })
215 |
216 | describe("when the entity already has the component", () => {
217 | it("updates the component", () => {
218 | const world = new World()
219 | const { Entity, Component } = createReactAPI(world)
220 | const entity = world.add({ name: "John" })
221 |
222 | render(
223 |
224 |
225 |
226 | )
227 |
228 | expect(world.entities[0].name).toBe("Jane")
229 | })
230 | })
231 | })
232 |
233 | describe("", () => {
234 | describe("with an array of entities", () => {
235 | it("renders the entities", () => {
236 | const world = new World()
237 | const { Entities } = createReactAPI(world)
238 |
239 | const entities = [
240 | world.add({ name: "John" }),
241 | world.add({ name: "Jane" })
242 | ]
243 |
244 | render(
245 | {(entity) => {entity.name}
}
246 | )
247 |
248 | expect(screen.getByText("John")).toBeInTheDocument()
249 | expect(screen.getByText("Jane")).toBeInTheDocument()
250 | })
251 | })
252 |
253 | describe("with a bucket", () => {
254 | it("renders the entities within the given bucket", () => {
255 | const world = new World()
256 | const { Entities } = createReactAPI(world)
257 |
258 | world.add({ name: "Alice" })
259 | world.add({ name: "Bob" })
260 |
261 | render({(entity) => {entity.name}
})
262 |
263 | expect(screen.getByText("Alice")).toBeInTheDocument()
264 | expect(screen.getByText("Bob")).toBeInTheDocument()
265 | })
266 |
267 | it("re-renders the entities when the bucket contents change", () => {
268 | const world = new World()
269 | const { Entities } = createReactAPI(world)
270 |
271 | const alice = world.add({ name: "Alice" })
272 | world.add({ name: "Bob" })
273 |
274 | render(
275 |
276 | {(entity) => {entity.name}
}
277 |
278 | )
279 |
280 | expect(screen.getByText("Alice")).toBeInTheDocument()
281 | expect(screen.getByText("Bob")).toBeInTheDocument()
282 |
283 | act(() => {
284 | world.add({ name: "Charlie" })
285 | })
286 |
287 | expect(screen.getByText("Alice")).toBeInTheDocument()
288 | expect(screen.getByText("Bob")).toBeInTheDocument()
289 | expect(screen.getByText("Charlie")).toBeInTheDocument()
290 |
291 | act(() => {
292 | world.remove(alice)
293 | })
294 |
295 | expect(screen.queryByText("Alice")).toBeNull()
296 | expect(screen.getByText("Bob")).toBeInTheDocument()
297 | expect(screen.getByText("Charlie")).toBeInTheDocument()
298 | })
299 | })
300 |
301 | describe("given an `children` prop", () => {
302 | it("renders the entities using the given component, passing the entity to it", () => {
303 | const world = new World()
304 | const { Entities } = createReactAPI(world)
305 |
306 | world.add({ name: "Alice" })
307 | world.add({ name: "Bob" })
308 |
309 | const User = ({ name }: { name: string }) => {name}
310 |
311 | render()
312 |
313 | expect(screen.getByText("Alice")).toBeInTheDocument()
314 | expect(screen.getByText("Bob")).toBeInTheDocument()
315 | })
316 | })
317 | })
318 |
319 | describe("useCurrentEntity", () => {
320 | describe("when invoked within an entity context", () => {
321 | it("returns the context's entity", () => {
322 | const world = new World()
323 | const { Entity, useCurrentEntity } = createReactAPI(world)
324 |
325 | const entity = world.add({ name: "John" })
326 |
327 | const Test = () => {
328 | const currentEntity = useCurrentEntity()
329 | return {currentEntity.name}
330 | }
331 |
332 | render(
333 |
334 |
335 |
336 | )
337 |
338 | expect(screen.getByText("John")).toBeInTheDocument()
339 | })
340 | })
341 |
342 | describe("when invoked outside of an entity context", () => {
343 | it("throws an error", () => {
344 | const { useCurrentEntity } = createReactAPI(new World())
345 |
346 | const Test = () => {
347 | useCurrentEntity()
348 | return null
349 | }
350 |
351 | expect(() => render()).toThrow(
352 | "useCurrentEntity must be called from a child of ."
353 | )
354 | })
355 | })
356 | })
357 |
358 | describe("world", () => {
359 | it("is a reference to the world originally passed into createReactAPI", () => {
360 | const world = new World<{ name: string }>()
361 | const api = createReactAPI(world)
362 | expect(api.world).toBe(world)
363 | })
364 | })
365 |
--------------------------------------------------------------------------------
/packages/core/test/core.test.ts:
--------------------------------------------------------------------------------
1 | import { Query, World } from "../src/core"
2 |
3 | type Entity = {
4 | name: string
5 | age?: number
6 | height?: number
7 | }
8 |
9 | describe(World, () => {
10 | it("can be instantiated with a list of entities", () => {
11 | const world = new World([
12 | { name: "John", age: 30 },
13 | { name: "Jane", age: 28 }
14 | ])
15 | expect(world.entities).toHaveLength(2)
16 | })
17 |
18 | describe("add", () => {
19 | it("adds an entity", () => {
20 | const world = new World()
21 | const entity = world.add({ name: "John" })
22 | expect(world.entities).toEqual([entity])
23 | })
24 | })
25 |
26 | describe("remove", () => {
27 | it("removes an entity", () => {
28 | const world = new World()
29 | const entity = world.add({ name: "John" })
30 | expect(world.entities).toEqual([entity])
31 |
32 | world.remove(entity)
33 | expect(world.entities).toEqual([])
34 | })
35 | })
36 |
37 | describe("update", () => {
38 | it("updates the entity", () => {
39 | const world = new World()
40 | const entity = world.add({ name: "John" })
41 | expect(entity.name).toEqual("John")
42 |
43 | world.update(entity, { name: "Jane" })
44 | expect(entity.name).toEqual("Jane")
45 | })
46 |
47 | it("triggers a reindexing of the entity", () => {
48 | const world = new World()
49 | const entity = world.add({ name: "John" })
50 | expect(entity.name).toEqual("John")
51 |
52 | const hasAge = world.with("age")
53 | expect(hasAge.entities).toEqual([])
54 |
55 | world.update(entity, { age: 28 })
56 | expect(hasAge.entities).toEqual([entity])
57 | })
58 |
59 | it("returns the entity", () => {
60 | const world = new World()
61 | const entity = world.add({ name: "John" })
62 | expect(world.update(entity, { name: "Jane" })).toEqual(entity)
63 | })
64 |
65 | it("accepts a function that returns an object containing changes", () => {
66 | const world = new World()
67 | const entity = world.add({ name: "John" })
68 | expect(entity.name).toEqual("John")
69 |
70 | world.update(entity, () => ({ name: "Jane" }))
71 | expect(entity.name).toEqual("Jane")
72 | })
73 |
74 | it("accepts a function that mutates the entity directly", () => {
75 | const world = new World()
76 | const entity = world.add({ name: "John" })
77 | expect(entity.name).toEqual("John")
78 |
79 | world.update(entity, (entity) => {
80 | entity.name = "Jane"
81 | })
82 |
83 | expect(entity.name).toEqual("Jane")
84 | })
85 |
86 | it("accepts a component name and value", () => {
87 | const world = new World()
88 | const entity = world.add({ name: "John" })
89 | expect(entity.name).toEqual("John")
90 |
91 | world.update(entity, "name", "Jane")
92 | expect(entity.name).toEqual("Jane")
93 | })
94 |
95 | it("can be called without a second argument to trigger the reindexing only", () => {
96 | const world = new World()
97 | const entity = world.add({ name: "John" })
98 | expect(entity.name).toEqual("John")
99 |
100 | const hasAge = world.with("age")
101 | expect(hasAge.entities).toEqual([])
102 |
103 | entity.age = 45
104 | world.update(entity)
105 | expect(hasAge.entities).toEqual([entity])
106 | })
107 | })
108 |
109 | describe("id", () => {
110 | it("returns undefined for entities not in the world", () => {
111 | const world = new World()
112 | const entity = { name: "John" }
113 | expect(world.id(entity)).toBeUndefined()
114 | })
115 |
116 | it("returns a unique ID for each entity", () => {
117 | const world = new World()
118 | const entity1 = world.add({ name: "John" })
119 | const entity2 = world.add({ name: "Jane" })
120 | expect(world.id(entity1)).not.toBeUndefined()
121 | expect(world.id(entity2)).not.toBeUndefined()
122 | expect(world.id(entity1)).not.toEqual(world.id(entity2))
123 | })
124 | })
125 |
126 | describe("entity", () => {
127 | it("returns undefined for IDs not in the world", () => {
128 | const world = new World()
129 | expect(world.entity(0)).toBeUndefined()
130 | })
131 |
132 | it("returns the entity for a given ID", () => {
133 | const world = new World()
134 | const entity = world.add({ name: "John" })
135 | const id = world.id(entity)!
136 | expect(world.entity(id)).toBe(entity)
137 | })
138 | })
139 |
140 | describe("produceQuery", () => {
141 | it("returns a query for the given configuration", () => {
142 | const world = new World()
143 |
144 | const query = world.query({
145 | with: ["age"],
146 | without: ["height"],
147 | predicates: []
148 | })
149 |
150 | expect(query).toBeInstanceOf(Query)
151 | expect(query.config).toEqual({
152 | with: ["age"],
153 | without: ["height"],
154 | predicates: []
155 | })
156 | })
157 |
158 | it("normalizes the incoming query configuration", () => {
159 | const world = new World()
160 |
161 | const query = world.query({
162 | with: ["age", "age"],
163 | without: ["height", "height", "dead"],
164 | predicates: []
165 | })
166 |
167 | expect(query.config).toEqual({
168 | with: ["age"],
169 | without: ["dead", "height"],
170 | predicates: []
171 | })
172 | })
173 |
174 | it("reuses existing connected queries if they have the same configuration", () => {
175 | const world = new World()
176 |
177 | const query1 = world.query({
178 | with: ["age"],
179 | without: ["height"],
180 | predicates: []
181 | })
182 |
183 | const query2 = world.query({
184 | with: ["age"],
185 | without: ["height"],
186 | predicates: []
187 | })
188 |
189 | expect(query1).toBe(query2)
190 | })
191 | })
192 |
193 | describe("with and without", () => {
194 | it("returns a new query instance", () => {
195 | const world = new World()
196 | const withAge = world.with("age")
197 | expect(withAge).toBeInstanceOf(Query)
198 | })
199 |
200 | it("reuses existing query instances", () => {
201 | const world = new World()
202 | const withAge = world.with("age")
203 |
204 | const withAge2 = world.with("age")
205 | expect(withAge).toBe(withAge2)
206 | })
207 |
208 | it("can be chained", () => {
209 | const world = new World()
210 | const withAge = world.with("age")
211 | const withoutHeight = withAge.without("height")
212 |
213 | expect(withAge).toBeInstanceOf(Query)
214 | expect(withoutHeight).toBeInstanceOf(Query)
215 |
216 | for (const _ of withoutHeight) {
217 | /* no-op */
218 | }
219 |
220 | expect(withAge.isConnected).toBe(false)
221 | expect(withoutHeight.isConnected).toBe(true)
222 | })
223 |
224 | it("when chained, also reuses matching query instances", () => {
225 | const world = new World()
226 | const queryA = world.with("age").without("height")
227 | const queryB = world.without("height").with("age")
228 |
229 | expect(queryA).toBe(queryB)
230 | })
231 |
232 | it("does not automatically connect the new query instance", () => {
233 | const world = new World()
234 | const withAge = world.with("age")
235 | expect(withAge.isConnected).toBe(false)
236 |
237 | withAge.connect()
238 | expect(withAge.isConnected).toBe(true)
239 | })
240 |
241 | it("automatically connects the query the first time its entities are accessed", () => {
242 | const world = new World()
243 | const withAge = world.with("age")
244 | expect(withAge.isConnected).toBe(false)
245 |
246 | withAge.entities
247 | expect(withAge.isConnected).toBe(true)
248 | })
249 |
250 | it("automatically connects the query the first time it is iterated over", () => {
251 | const world = new World()
252 | world.add({ name: "John", age: 30 })
253 |
254 | const withAge = world.with("age")
255 | expect(withAge.isConnected).toBe(false)
256 |
257 | for (const entity of withAge) {
258 | expect(entity).toBeDefined()
259 | }
260 |
261 | expect(withAge.isConnected).toBe(true)
262 | })
263 |
264 | it("automatically connects the query when something subscribes to onEntityAdded", () => {
265 | const world = new World()
266 | const withAge = world.with("age")
267 | expect(withAge.isConnected).toBe(false)
268 |
269 | withAge.onEntityAdded.subscribe(() => {})
270 | expect(withAge.isConnected).toBe(true)
271 | })
272 |
273 | it("automatically connects the query when something subscribes to onEntityRemoves", () => {
274 | const world = new World()
275 | const withAge = world.with("age")
276 | expect(withAge.isConnected).toBe(false)
277 |
278 | withAge.onEntityRemoved.subscribe(() => {})
279 | expect(withAge.isConnected).toBe(true)
280 | })
281 | })
282 |
283 | describe("where", () => {
284 | it("returns a query that applies the given predicate", () => {
285 | const world = new World()
286 |
287 | const john = world.add({ name: "John", age: 40 })
288 | const jane = world.add({ name: "Jane", age: 25 })
289 | const paul = world.add({ name: "Paul", age: 32 })
290 |
291 | const over30 = world.with("age").where(({ age }) => age > 30)
292 | expect(over30.entities).toEqual([paul, john])
293 |
294 | const startsWithJ = over30.where(({ name }) => name.startsWith("J"))
295 | expect(startsWithJ.entities).toEqual([john])
296 | })
297 |
298 | it("requires the use of reindex in order to react to changes", () => {
299 | const world = new World()
300 |
301 | const john = world.add({ name: "John", age: 40 })
302 | const paul = world.add({ name: "Paul", age: 29 })
303 |
304 | const over30 = world.with("age").where(({ age }) => age >= 30)
305 | expect(over30.entities).toEqual([john])
306 |
307 | /* Mutate an entity to make it match the predicate */
308 | paul.age++
309 |
310 | /* The query is not updated automatically */
311 | expect(over30.entities).toEqual([john])
312 |
313 | /* Reindex the entity */
314 | world.reindex(paul)
315 |
316 | /* The query is updated */
317 | expect(over30.entities).toEqual([john, paul])
318 | })
319 |
320 | it("supports type narrowing", () => {
321 | type NamedPerson = { name: string }
322 |
323 | function isNamedPerson(entity: Entity): entity is NamedPerson {
324 | return "name" in entity
325 | }
326 |
327 | const world = new World()
328 | const named = world.where(isNamedPerson)
329 | })
330 |
331 | it("returns the same query for the same predicate", () => {
332 | const world = new World()
333 | const john = world.add({ name: "John", age: 40 })
334 | const jane = world.add({ name: "Jane", age: 25 })
335 | const paul = world.add({ name: "Paul", age: 32 })
336 |
337 | const isOver30 = ({ age }: { age: number }) => age > 30
338 |
339 | const over30s = world.with("age").where(isOver30)
340 |
341 | expect(over30s).toBe(world.with("age").where(isOver30))
342 | expect(over30s.entities).toEqual([paul, john])
343 |
344 | const isOver35 = ({ age }: { age: number }) => age > 35
345 |
346 | const over35s = over30s.where(isOver35)
347 |
348 | expect(over35s).not.toBe(over30s)
349 | expect(over35s.entities).toEqual([john])
350 | })
351 | })
352 |
353 | describe("addComponent", () => {
354 | it("adds a component to an entity", () => {
355 | const world = new World()
356 | const entity = world.add({ name: "John" })
357 | world.addComponent(entity, "age", 30)
358 | expect(entity).toEqual({ name: "John", age: 30 })
359 | })
360 |
361 | it("adds the entity to any relevant queries", () => {
362 | const world = new World()
363 | const query = world.with("age").without("height")
364 | const entity = world.add({ name: "John" })
365 | world.addComponent(entity, "age", 30)
366 | expect(query.entities).toEqual([entity])
367 | })
368 | })
369 |
370 | describe("removeComponent", () => {
371 | it("removes a component from an entity", () => {
372 | const world = new World()
373 | const entity = world.add({ name: "John", age: 30 })
374 | world.removeComponent(entity, "age")
375 | expect(entity).toEqual({ name: "John" })
376 | })
377 |
378 | it("removes the entity from any relevant archetypes", () => {
379 | const world = new World()
380 | const query = world.with("age").without("height")
381 | const entity = world.add({ name: "John", age: 30 })
382 | expect(query.entities).toEqual([entity])
383 |
384 | world.removeComponent(entity, "age")
385 | expect(query.entities).toEqual([])
386 | })
387 |
388 | it("uses a future check, so in onEntityRemoved, the entity is still intact", () => {
389 | const world = new World()
390 | const query = world.with("age").without("height")
391 | const entity = world.add({ name: "John", age: 30 })
392 | expect(query.entities).toEqual([entity])
393 |
394 | query.onEntityRemoved.subscribe((removedEntity) => {
395 | expect(removedEntity).toEqual(entity)
396 | expect(removedEntity.age).toEqual(30)
397 | })
398 |
399 | world.removeComponent(entity, "age")
400 | expect(query.entities).toEqual([])
401 | })
402 | })
403 | })
404 |
--------------------------------------------------------------------------------
/packages/react/README.md:
--------------------------------------------------------------------------------
1 | 
2 | [](https://www.npmjs.com/package/miniplex-react)
3 | [](https://bundlephobia.com/result?p=miniplex-react)
4 |
5 | # miniplex-react
6 |
7 | ### React glue for [miniplex], the gentle game entity manager.
8 |
9 | > **Note** This package contains the React glue for Miniplex. This documentation assumes that you are familiar with how Miniplex works. If you haven't done so already, please read the [Miniplex documentation](https://github.com/hmans/miniplex/tree/main/packages/core#readme) first.
10 |
11 | ## Installation
12 |
13 | Add `miniplex-react` and its peer dependency `miniplex` to your application using your favorite package manager, eg.
14 |
15 | ```sh
16 | npm install miniplex miniplex-react
17 | yarn add miniplex miniplex-react
18 | pnpm add miniplex miniplex-react
19 | ```
20 |
21 | ## Usage
22 |
23 | This package's default export is a function that returns an object with React bindings for an existing miniplex world.
24 |
25 | It is recommended that you invoke this function from a module in your application that exports the generated object, and then have the rest of your project import that module, similar to how you would provide a global store:
26 |
27 | ```ts
28 | /* state.ts */
29 | import { World } from "miniplex"
30 | import createReactAPI from "miniplex-react"
31 |
32 | /* Our entity type */
33 | export type Entity = {
34 | /* ... */
35 | }
36 |
37 | /* Create a Miniplex world that holds our entities */
38 | const world = new World()
39 |
40 | /* Create and export React bindings */
41 | export const ECS = createReactAPI(world)
42 | ```
43 |
44 | **TypeScript note:** `createReactAPI` will automatically pick up the entity type attached to your world. All the React components and hooks will automatically make use of this type.
45 |
46 | ### The World
47 |
48 | The object returned by `createReactAPI` includes a `world` property containing the actual ECS world. You can interact with it like you would usually do to imperatively create, modify and destroy entities:
49 |
50 | ```ts
51 | const entity = ECS.world.add({ position: { x: 0, y: 0 } })
52 | ```
53 |
54 | For more details on how to interact with the ECS world, please refer to the [miniplex] core package's documentation.
55 |
56 | ### Describing Entities and Components
57 |
58 | As a first step, let's add a single entity to your React application. We use `` to declare the entity, and `` to add components to it.
59 |
60 | ```tsx
61 | import { ECS } from "./state"
62 |
63 | const Player = () => (
64 |
65 |
66 |
67 |
68 | )
69 | ```
70 |
71 | This will, once mounted, create a single entity in your ECS world, and add the `position` and `health` components to it. Once unmounted, it will also automatically destroy the entity.
72 |
73 | ### Capturing object refs into components
74 |
75 | If your components are designed to store rich objects, and these can be expressed as React components providing Refs, you can pass a single React child to ``, and its Ref value will automatically be picked up. For example, let's imagine a react-three-fiber based game that allows entities to have a scene object stored on the `three` component:
76 |
77 | ```tsx
78 | import { ECS } from "./state"
79 |
80 | const Player = () => (
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | )
92 | ```
93 |
94 | Now the player's `three` component will be set to a reference to the Three.js scene object created by the `` element.
95 |
96 | ### Enhancing existing entities
97 |
98 | `` can also represent _previously created_ entities, which can be used to enhance them with additional components. This is useful if your entities are created somewhere else, but at the time when they are rendered, you still need to enhance them with additional components. For example:
99 |
100 | ```tsx
101 | import { ECS } from "./state"
102 |
103 | const Game = () => {
104 | const [player] = useState(() =>
105 | ECS.world.add({
106 | position: { x: 0, y: 0, z: 0 },
107 | health: 100
108 | })
109 | )
110 |
111 | return (
112 | <>
113 | {/* All sorts of stuff */}
114 |
115 | {/* More stuff */}
116 | >
117 | )
118 | }
119 |
120 | const RenderPlayer = ({ player }) => (
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 | )
130 | ```
131 |
132 | When `` is used to represent and enhance an existing entity, the entity will _not_ be destroyed once the component is unmounted.
133 |
134 | ### Rendering lists of entities using ``
135 |
136 | The `` component will render a list of entities. It takes a `in` prop that can be either a Miniplex query, world, or just an array of entities. It is most commonly used together with a Miniplex query:
137 |
138 | ```tsx
139 | import { ECS } from "./state"
140 | import { AsteroidModel } from "./models"
141 |
142 | const asterois = ECS.world.with("isAsteroid")
143 |
144 | const Asteroids = () => (
145 |
146 |
147 |
148 |
149 |
150 | )
151 | ```
152 |
153 | When used this way, it will automatically re-render every time the list of entities represented by the given query changes. If for some reason you do _not_ want it to re-render in those cases, you can just pass an array of entities instead:
154 |
155 | ```tsx
156 | import { ECS } from "./state"
157 | import { AsteroidModel } from "./models"
158 |
159 | const asterois = ECS.world.with("isAsteroid")
160 |
161 | /* Note the .entities property! */
162 | const Asteroids = () => (
163 |
164 |
165 |
166 |
167 |
168 | )
169 | ```
170 |
171 | ## Using `useEntities` to react to changes
172 |
173 | This package also provides the `useEntities` hook that will subscribe your React component to changes in a query or world and will automatically re-render it every time entities are added or removed. This can be useful for implementing side effects that need to run for one-off entities:
174 |
175 | ```tsx
176 | const cameraTargets = ECS.world.with("cameraTarget", "object3d")
177 |
178 | const MyCamera = () => {
179 | const camera = useRef()
180 |
181 | /* Grab the first entity that matches the query */
182 | const [cameraTarget] = useEntities(cameraTargets)
183 |
184 | /* Run a side effect when the camera target changes */
185 | useEffect(() => {
186 | if (!camera.current) return
187 | if (!cameraTarget) return
188 |
189 | camera.current.lookAt(cameraTarget.object3d.position)
190 | }, [cameraTarget])
191 |
192 | return
193 | }
194 | ```
195 |
196 | ### Using Render Props
197 |
198 | `` and `` support the optional use of [children render props](https://reactjs.org/docs/render-props.html), where instead of JSX children, you pass a _function_ that receives each entity as its first and only argument, and is expected to _return_ the JSX that is to be rendered. This is useful if you're rendering a collection of entities and need access to their data, or need some code to run _for each entity_, for example when setting random values like in this example:
199 |
200 | ```tsx
201 | const enemies = ECS.world.with("enemy")
202 |
203 | const EnemyShips = () => (
204 |
205 | {(entity) => {
206 | const health = Math.random() * 1000
207 |
208 | return (
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 | )
217 | }}
218 |
219 | )
220 | ```
221 |
222 | ### Hooking into the current entities
223 |
224 | When you're composing entities from nested components, you may need to get the current entity context the React component is in. You can do this using the `useCurrentEntity` hook:
225 |
226 | ```tsx
227 | const Health = () => {
228 | /* Retrieve the entity represented by the neares `` component */
229 | const entity = ECS.useCurrentEntity()
230 |
231 | useEffect(() => {
232 | /* Do something with the entity here */
233 | })
234 |
235 | return null
236 | }
237 | ```
238 |
239 | ## Recommended Patterns and Best Practices
240 |
241 | ### Implementing Systems
242 |
243 | Since Miniplex doesn't have any built-in notion of what a system is, their implementation is entirely left up to you. This is by design; while other ECS implementations often force their own system scheduler setup on you, Miniplex neatly snuggles into your existing codebase and lets you use it with whatever scheduling functionality the framework you're using provides.
244 |
245 | In a react-three-fiber application, for example, you would use the `useFrame` hook to execute a system function once per frame:
246 |
247 | ```tsx
248 | import { useEntities } from "miniplex-react"
249 | import { useFrame } from "@react-three/fiber"
250 | import { ECS } from "./state"
251 |
252 | const movingEntities = ECS.world.with("position", "velocity")
253 |
254 | const MovementSystem = () => {
255 | useFrame((_, dt) => {
256 | for (const entity of movingEntities) {
257 | entity.position.x += entity.velocity.x * dt
258 | entity.position.y += entity.velocity.y * dt
259 | entity.position.z += entity.velocity.z * dt
260 | }
261 | })
262 |
263 | return null
264 | }
265 | ```
266 |
267 | ### Write imperative code for mutating the world
268 |
269 | While the `` component can be used to spawn (and later destroy) a new entity, you will typically only use this for one-off entities (like the player, or some other entity that only exists once and is expected to be managed by a React component.)
270 |
271 | For everything else, you should write imperative code that mutates the world, and design your React components to _react_ to these changes. Consider the following module, which co-locates both an `` component that renders the currently active enemies, and a `spawnEnemy` function that spawns a new one:
272 |
273 | ```tsx
274 | const enemies = ECS.world.with("enemy")
275 |
276 | export const Enemies = () => (
277 |
278 |
279 |
280 |
281 |
282 | )
283 |
284 | export const spawnEnemy = () =>
285 | ECS.world.add({
286 | position: { x: 0, y: 0, z: 0 },
287 | velocity: { x: 0, y: 0, z: 0 },
288 | health: 100,
289 | enemy: true
290 | })
291 | ```
292 |
293 | In another React component that manages your game's state, you may now use this function to spawn an initial number of enemies:
294 |
295 | ```tsx
296 | import { spawnEnemy } from "./enemies"
297 |
298 | export const GameState = () => {
299 | useEffect(() => {
300 | /* Initialize game state */
301 | for (let i = 0; i < 10; i++) {
302 | spawnEnemy()
303 | }
304 |
305 | /* When unmounting, reset game state */
306 | return () => {
307 | ECS.world.clear()
308 | }
309 | }, [])
310 | }
311 | ```
312 |
313 | ### Using the `children` prop on ``
314 |
315 | You've already seen how the `` component optionally accepts a render prop as its child; this can be used to defer the rendering of an entity to a separate function:
316 |
317 | ```tsx
318 | const enemies = ECS.world.with("enemy")
319 |
320 | export const Enemies = () =>
321 |
322 | export const Enemy = (entity) => (
323 |
324 |
325 |
326 |
327 |
328 | )
329 | ```
330 |
331 | This is particularly useful if you want to provide a component that renders out a single entity of a specific type, and then want to re-use it when rendering a complete list of them. The `` component above is functionally equivalent to:
332 |
333 | ```tsx
334 | export const Enemies = () => (
335 | {(entity) => }
336 | )
337 | ```
338 |
339 | Or event this, since React components are just functions:
340 |
341 | ```tsx
342 | export const Enemies = () => {Enemy}
343 | ```
344 |
345 | ## Questions?
346 |
347 | If you have questions about this package, you're invited to post them in our [Discussions section](https://github.com/hmans/miniplex/discussions) on GitHub.
348 |
349 | [miniplex]: https://github.com/hmans/miniplex
350 |
351 | ## License
352 |
353 | ```
354 |
355 | Copyright (c) 2023 Hendrik Mans
356 |
357 | Permission is hereby granted, free of charge, to any person obtaining
358 | a copy of this software and associated documentation files (the
359 | "Software"), to deal in the Software without restriction, including
360 | without limitation the rights to use, copy, modify, merge, publish,
361 | distribute, sublicense, and/or sell copies of the Software, and to
362 | permit persons to whom the Software is furnished to do so, subject to
363 | the following conditions:
364 |
365 | The above copyright notice and this permission notice shall be
366 | included in all copies or substantial portions of the Software.
367 |
368 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
369 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
370 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
371 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
372 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
373 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
374 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
375 |
376 | ```
377 |
378 | ```
379 |
380 | ```
381 |
--------------------------------------------------------------------------------
/packages/core/src/core.ts:
--------------------------------------------------------------------------------
1 | import { Bucket } from "@miniplex/bucket"
2 | import { id } from "@hmans/id"
3 | export * from "@miniplex/bucket"
4 |
5 | export type Predicate =
6 | | ((v: E) => v is D)
7 | | ((entity: E) => boolean)
8 |
9 | /**
10 | * A utility type that marks the specified properties as required.
11 | */
12 | export type With = E & Required>
13 |
14 | export type Without = Omit
15 |
16 | /**
17 | * A utility type that removes all optional properties.
18 | */
19 | export type Strict = WithoutOptional
20 |
21 | /* Utility types */
22 |
23 | type OptionalKeys = {
24 | [K in keyof T]-?: undefined extends T[K] ? K : never
25 | }
26 |
27 | type WithoutOptional = Pick[keyof T]>>
28 |
29 | /* Query configuration */
30 |
31 | type QueryConfiguration = {
32 | with: any[]
33 | without: any[]
34 | predicates: Function[]
35 | }
36 |
37 | interface IQueryableBucket {
38 | /**
39 | * Queries for entities that have all of the given components. If this is called on
40 | * an existing query, the query will be extended to include this new criterion.
41 | *
42 | * @param components The components to query for.
43 | */
44 | with(...components: C[]): Query>
45 |
46 | /**
47 | * Queries for entities that have none of the given components. If this is called on
48 | * an existing query, the query will be extended to include this new criterion.
49 | *
50 | * @param components The components to query for.
51 | */
52 | without(...components: C[]): Query>
53 |
54 | /**
55 | * Queries for entities that match the given predicate. If this is called on
56 | * an existing query, the query will be extended to include this new criterion.
57 | *
58 | * @param predicate The predicate to query for.
59 | */
60 | where(predicate: Predicate): Query
61 | }
62 |
63 | export class World
64 | extends Bucket
65 | implements IQueryableBucket
66 | {
67 | constructor(entities: E[] = []) {
68 | super(entities)
69 |
70 | /* When entities are added, reindex them immediately */
71 | this.onEntityAdded.subscribe((entity) => {
72 | this.reindex(entity)
73 | })
74 |
75 | /* When entities are removed, remove them from all known queries, and delete
76 | their IDs */
77 | this.onEntityRemoved.subscribe((entity) => {
78 | this.queries.forEach((query) => query.remove(entity))
79 |
80 | if (this.entityToId.has(entity)) {
81 | const id = this.entityToId.get(entity)!
82 | this.idToEntity.delete(id)
83 | this.entityToId.delete(entity)
84 | }
85 | })
86 | }
87 |
88 | update(entity: E): E
89 |
90 | update(entity: E, component: C, value: E[C]): E
91 |
92 | update(entity: E, update: Partial): E
93 |
94 | update(entity: E, fun: (entity: E) => Partial | void): E
95 |
96 | update(
97 | entity: E,
98 | update?: Partial | keyof E | ((entity: E) => Partial | void),
99 | value?: any
100 | ) {
101 | /* Apply the update */
102 | if (typeof update === "function") {
103 | const partial = update(entity)
104 | partial && Object.assign(entity, partial)
105 | } else if (typeof update === "string") {
106 | entity[update] = value
107 | } else if (update) {
108 | Object.assign(entity, update)
109 | }
110 |
111 | /* If this world knows about the entity, reindex it. */
112 | this.reindex(entity)
113 |
114 | return entity
115 | }
116 |
117 | /**
118 | * Adds a component to an entity. If the entity already has the component, the
119 | * existing component will not be overwritten.
120 | *
121 | * After the component was added, the entity will be reindexed, causing it to be
122 | * added to or removed from any queries depending on their criteria.
123 | *
124 | * @param entity The entity to modify.
125 | * @param component The name of the component to add.
126 | * @param value The value of the component to add.
127 | */
128 | addComponent(entity: E, component: C, value: E[C]) {
129 | /* Return early if the entity already has the component. */
130 | if (entity[component] !== undefined) return
131 |
132 | /* Set the component */
133 | entity[component] = value
134 |
135 | /* Trigger a reindexing */
136 | this.reindex(entity)
137 | }
138 |
139 | /**
140 | * Removes a component from an entity. If the entity does not have the component,
141 | * this function does nothing.
142 | *
143 | * After the component was removed, the entity will be reindexed, causing it to be
144 | * added to or removed from any queries depending on their criteria.
145 | *
146 | * @param entity The entity to modify.
147 | * @param component The name of the component to remove.
148 | */
149 | removeComponent(entity: E, component: keyof E) {
150 | /* Return early if the entity doesn't even have the component. */
151 | if (entity[component] === undefined) return
152 |
153 | /* If this world knows about the entity, notify any derived buckets about the change. */
154 | if (this.has(entity)) {
155 | const future = { ...entity }
156 | delete future[component]
157 |
158 | this.reindex(entity, future)
159 | }
160 |
161 | /* Remove the component. */
162 | delete entity[component]
163 | }
164 |
165 | /* QUERIES */
166 |
167 | protected queries = new Set>()
168 |
169 | /**
170 | * Creates (or reuses) a query that matches the given configuration.
171 | *
172 | * @param config The query configuration.
173 | * @returns A query that matches the given configuration.
174 | */
175 | query(config: QueryConfiguration): Query {
176 | const normalizedConfig = normalizeQueryConfiguration(config)
177 | const key = configKey(normalizedConfig)
178 |
179 | /* Use existing query if we can find one */
180 | for (const query of this.queries) {
181 | if (query.key === key) {
182 | return query as Query
183 | }
184 | }
185 |
186 | /* Otherwise, create new query */
187 | const query = new Query(this, normalizedConfig)
188 | this.queries.add(query)
189 | return query
190 | }
191 |
192 | /**
193 | * Creates (or reuses) a query that holds entities that have all of the specified
194 | * components.
195 | *
196 | * @param components One or more component names to query for.
197 | * @returns A query that holds entities that have all of the given components.
198 | */
199 | with(...components: C[]) {
200 | return this.query>({
201 | with: components,
202 | without: [],
203 | predicates: []
204 | })
205 | }
206 |
207 | /**
208 | * Creates (or reuses) a query that holds entities that do not have any of the
209 | * specified components.
210 | *
211 | * @param components One or more component names to query for.
212 | * @returns A query that holds entities that do not have any of the given components.
213 | */
214 | without(...components: C[]) {
215 | return this.query>({
216 | with: [],
217 | without: components,
218 | predicates: []
219 | })
220 | }
221 |
222 | /**
223 | * Creates (or reuses) a query that holds entities that match the given predicate.
224 | * Please note that as soon as you are building queries that use predicates, you
225 | * will need to explicitly reindex entities when their properties change.
226 | *
227 | * @param predicate The predicate that entities must match.
228 | * @returns A query that holds entities that match the given predicate.
229 | */
230 | where(predicate: Predicate) {
231 | return this.query({
232 | with: [],
233 | without: [],
234 | predicates: [predicate]
235 | })
236 | }
237 |
238 | /**
239 | * Reindexes the specified entity. This will iteratere over all registered queries
240 | * and ask them to reevaluate the entity.
241 | *
242 | * If the `future` parameter is specified,
243 | * it will be used in the evaluation instead of the entity itself. This is useful
244 | * if you are about to perform a destructive change on the entity (like removing
245 | * a component), but want emitted events to still have access to the unmodified entity
246 | * before the change.
247 | *
248 | * @param entity The entity to reindex.
249 | * @param future The entity that the entity will become in the future.
250 | */
251 | reindex(entity: E, future = entity) {
252 | /* Return early if this world doesn't know about the entity. */
253 | if (!this.has(entity)) return
254 |
255 | /* Notify all queries about the change. */
256 | for (const query of this.queries) {
257 | query.evaluate(entity, future)
258 | }
259 | }
260 |
261 | /* IDs */
262 |
263 | private entityToId = new Map()
264 | private idToEntity = new Map()
265 | private nextId = 0
266 |
267 | /**
268 | * Generate and return a numerical identifier for the given entity. The ID can later
269 | * be used to retrieve the entity again through the `entity(id)` method.
270 | *
271 | * @param entity The entity to get the ID for.
272 | * @returns An ID for the entity, or undefined if the entity is not in the world.
273 | */
274 | id(entity: E) {
275 | /* We only ever want to generate IDs for entities that are actually in the world. */
276 | if (!this.has(entity)) return undefined
277 |
278 | /* Lazily generate an ID. */
279 | if (!this.entityToId.has(entity)) {
280 | const id = this.nextId++
281 | this.entityToId.set(entity, id)
282 | this.idToEntity.set(id, entity)
283 | }
284 |
285 | return this.entityToId.get(entity)!
286 | }
287 |
288 | /**
289 | * Given an entity ID that was previously generated through the `id(entity)` function,
290 | * returns the entity matching that ID, or undefined if no such entity exists.
291 | *
292 | * @param id The ID of the entity to retrieve.
293 | * @returns The entity with the given ID, or undefined if no such entity exists.
294 | */
295 | entity(id: number) {
296 | return this.idToEntity.get(id)
297 | }
298 | }
299 |
300 | export class Query extends Bucket implements IQueryableBucket {
301 | protected _isConnected = false
302 |
303 | /**
304 | * True if this query is connected to the world, and will automatically
305 | * re-evaluate when entities are added or removed.
306 | */
307 | get isConnected() {
308 | return this._isConnected
309 | }
310 |
311 | /**
312 | * A unique, string-based key for this query, based on its configuration.
313 | */
314 | public readonly key: string
315 |
316 | constructor(public world: World, public config: QueryConfiguration) {
317 | super()
318 |
319 | this.key = configKey(config)
320 |
321 | /* Automatically connect this query if event listeners are added to our
322 | onEntityAdded or onEntityRemoved events. */
323 | this.onEntityAdded.onSubscribe.subscribe(() => this.connect())
324 | this.onEntityRemoved.onSubscribe.subscribe(() => this.connect())
325 | }
326 |
327 | /**
328 | * An array containing all entities that match this query. For iteration, it
329 | * is recommended to use the `for (const entity of query) {}` syntax instead.
330 | */
331 | get entities() {
332 | if (!this._isConnected) this.connect()
333 | return super.entities
334 | }
335 |
336 | [Symbol.iterator]() {
337 | if (!this._isConnected) this.connect()
338 | return super[Symbol.iterator]()
339 | }
340 |
341 | /**
342 | * Connects this query to the world. While connected, it will automatically
343 | * re-evaluate when entities are added or removed, and store those that match
344 | * its query configuration.
345 | *
346 | * @returns The query object.
347 | */
348 | connect() {
349 | if (!this._isConnected) {
350 | this._isConnected = true
351 |
352 | /* Evaluate all entities in the world */
353 | for (const entity of this.world) {
354 | this.evaluate(entity)
355 | }
356 | }
357 |
358 | return this
359 | }
360 |
361 | /**
362 | * Disconnects this query from the world. This essentially stops the query from
363 | * automatically receiving entities.
364 | */
365 | disconnect() {
366 | this._isConnected = false
367 | return this
368 | }
369 |
370 | /**
371 | * Returns a new query that extends this query and also matches entities that
372 | * have all of the components specified.
373 | *
374 | * @param components The components that entities must have.
375 | * @returns A new query representing the extended query configuration.
376 | */
377 | with(...components: C[]) {
378 | return this.world.query>({
379 | ...this.config,
380 | with: [...this.config.with, ...components]
381 | })
382 | }
383 |
384 | /**
385 | * Returns a new query that extends this query and also matches entities that
386 | * have none of the components specified.
387 | *
388 | * @param components The components that entities must not have.
389 | * @returns A new query representing the extended query configuration.
390 | */
391 | without(...components: C[]) {
392 | return this.world.query>({
393 | ...this.config,
394 | without: [...this.config.without, ...components]
395 | })
396 | }
397 |
398 | /**
399 | * Returns a new query that extends this query and also matches entities that
400 | * match the given predicate.
401 | *
402 | * @param predicate The predicate that entities must match.
403 | * @returns A new query representing the extended query configuration.
404 | */
405 | where(predicate: Predicate) {
406 | return this.world.query({
407 | ...this.config,
408 | predicates: [...this.config.predicates, predicate]
409 | })
410 | }
411 |
412 | /**
413 | * Checks the given entity against this query's configuration, and returns
414 | * true if the entity matches the query, false otherwise.
415 | *
416 | * @param entity The entity to check.
417 | * @returns True if the entity matches this query, false otherwise.
418 | */
419 | want(entity: E) {
420 | return (
421 | this.config.with.every(
422 | (component) => entity[component as keyof typeof entity] !== undefined
423 | ) &&
424 | this.config.without.every(
425 | (component) => entity[component as keyof typeof entity] === undefined
426 | ) &&
427 | this.config.predicates.every((predicate) => predicate(entity))
428 | )
429 | }
430 |
431 | /**
432 | * Evaluate the given entity against this query's configuration, and add or
433 | * remove it from the query if necessary.
434 | *
435 | * If `future` is specified, the entity will be evaluated against that entity
436 | * instead. This is useful for checking if an entity will match the query
437 | * after some potentially destructive change has been made to it, before
438 | * actually applying that change to the entity itself.
439 | *
440 | * @param entity The entity to evaluate.
441 | * @param future The entity to evaluate against. If not specified, the entity will be evaluated against itself.
442 | */
443 | evaluate(entity: any, future = entity) {
444 | if (!this.isConnected) return
445 |
446 | const wanted = this.want(future)
447 | const has = this.has(entity)
448 |
449 | if (wanted && !has) {
450 | this.add(entity)
451 | } else if (!wanted && has) {
452 | this.remove(entity)
453 | }
454 | }
455 | }
456 |
457 | const normalizeComponents = (components: any[]) => [
458 | ...new Set(components.sort().filter((c) => !!c && c !== ""))
459 | ]
460 |
461 | function normalizePredicates(predicates: Function[]) {
462 | return [...new Set(predicates)]
463 | }
464 |
465 | function normalizeQueryConfiguration(config: QueryConfiguration) {
466 | return {
467 | with: normalizeComponents(config.with),
468 | without: normalizeComponents(config.without),
469 | predicates: normalizePredicates(config.predicates)
470 | }
471 | }
472 |
473 | function configKey(config: QueryConfiguration) {
474 | return `${config.with.join(",")}:${config.without.join(
475 | ","
476 | )}:${config.predicates.map((p) => id(p)).join(",")}`
477 | }
478 |
--------------------------------------------------------------------------------
/packages/core/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # miniplex
2 |
3 | ## 2.0.0
4 |
5 | ### Major Changes
6 |
7 | - 8a7a315: Miniplex 2.0 is a complete rewrite of the library, with a heavy focus on further improving the developer experience, and squashing some significant bugs.
8 |
9 | While it does bring some breaking changes, it will still allow you to do everything that you've been doing with 1.0; when upgrading a 1.0 project to 2.0, most changes you will need to do are related to things having been renamed.
10 |
11 | The headline changes in 2.0:
12 |
13 | - **A lot more relaxed and lightweight!** Where Miniplex 1.0 would immediately crash your entire application when, for example, adding a component to an entity that already has the component, Miniplex 2.0 will simply no-op and continue.
14 | - **Much more flexible!** Miniplex 2.0 simplifies and extends the query API (formerly often referred to as "archetypes"); you can now create queries through `world.with("componentName")`, chain these together, use the new `without`, or even create advanced predicate-based queries using `where`.
15 | - **Improved type support!** If you're using TypeScript, you will be happy to hear that type support has been significantly improved, with much better type narrowing for queries.
16 | - The **React API** has been significantly simplified, and some pretty big bugs have been squashed. (Turns out Miniplex 1.0's React API really didn't like React's ``` much. Whoops!)
17 |
18 | Some more details on the changes:
19 |
20 | - `world.createEntity` has been renamed and simplified to just `world.add` (which now returns the correct type for the entity, too), and `world.destroyEntity` to `world.remove`.
21 |
22 | ```js
23 | const entity = world.add({ position: { x: 0, y: 0 } })
24 | world.addComponent(entity, "velocity", { x: 0, y: 0 })
25 | world.remove(entity)
26 | ```
27 |
28 | - The `Tag` type and constant have been removed. For tag-like components, simply use `true` (which `Tag` was just an alias for.)
29 | - Entities added to a world no longer receive a `__miniplex` component. This component has always been an internal implementation detail, but you might have used it in the past to get a unique identifier for an entity. This can now be done through `world.id(entity)`, with ID lookups being available through `world.entity(id)`.
30 | - Queries can now be iterated over directly. Example:
31 |
32 | ```js
33 | const moving = world.with("position", "velocity")
34 |
35 | for (const { position, velocity } of moving) {
36 | position.x += velocity.x
37 | position.y += velocity.y
38 | }
39 | ```
40 |
41 | This is, in fact, now the recommended way to iterate over entities, since it is extremely efficient, _and_ will make sure that the list of entities is being iterated over _in reverse_, which makes it safe to modify it during iteration.
42 |
43 | You can also use this to neatly fetch the first entity from an archetype that you only expect to have a single entity in it:
44 |
45 | ```js
46 | const [player] = world.with("player")
47 | ```
48 |
49 | - **The queuing functionality that was built into the `World` class has been removed.** If you've relied on this in the past, `miniplex` now exports a `queue` object that you can use instead. Example:
50 |
51 | ```js
52 | import { queue } from "miniplex"
53 |
54 | queue(() => {
55 | // Do something
56 | })
57 |
58 | /* Later */
59 | queue.flush()
60 | ```
61 |
62 | Please note that this is being provided to make upgrading to 2.0 a little easier, and will likely be removed in a future version.
63 |
64 | - `with` and `without` are the new API for building queries. Examples:
65 |
66 | ```js
67 | const moving = world.with("position", "velocity")
68 | const alive = world.without("dead")
69 | ```
70 |
71 | - The new `world.where` now allows you to build predicate-based queries! You can use this as an escape hatch for creating any kind of query based on any conditions you specify. Example:
72 |
73 | ```js
74 | const almostDead = world.where((entity) => entity.health < 10)
75 | ```
76 |
77 | Please note that his requires entities with the `health` component to be reindexed using the `world.reindex` function in order to keep the archetype up to date. Please refer to the documentation for more details.
78 |
79 | - You can use `where` to create a predicate-based iterator. This allows you to quickly filter a set of entities without creating new archetypes or other objects. Example:
80 |
81 | ```js
82 | for (const entity of world.where((entity) => entity.health < 10)) {
83 | // Do something
84 | }
85 | ```
86 |
87 | - **All of these can be chained!**
88 |
89 | ```js
90 | world
91 | .with("position", "velocity")
92 | .without("dead")
93 | .where((entity) => entity.health < 10)
94 | ```
95 |
96 | - Entities fetched from a query will have much improved types, but you can also specify a type to narrow to via these functions' generics:
97 |
98 | ```ts
99 | const player = world.with("player")
100 | ```
101 |
102 | - Miniplex provides the new `Strict` and `With` types which you can use to compose types from your entity main type:
103 |
104 | ```ts
105 | type Entity = {
106 | position: { x: number; y: number }
107 | velocity: { x: number; y: number }
108 | }
109 |
110 | type Player = Strict>
111 |
112 | const player = world.archetype("player")
113 | ```
114 |
115 | - The event library Miniplex uses has been changed to [Eventery](https://github.com/hmans/eventery), which brings a change in API. Where before you would have done `onEntityAdded.add(listener)`, you will now to `onEntityAdded.subscribe(listener)`.
116 |
117 | ### Patch Changes
118 |
119 | - Updated dependencies [8a7a315]
120 | - @miniplex/bucket@2.0.0
121 |
122 | ## 1.0.0
123 |
124 | ### Major Changes
125 |
126 | - 769dba7: **Major Breaking Change:** The signature of `addComponent` has been simplified to accept an entity, a component name, and the value of the component:
127 |
128 | ```ts
129 | /* Before */
130 | world.addComponent(entity, { position: { x: 0, y: 0 } })
131 |
132 | /* After */
133 | world.addComponent(entity, "position", { x: 0, y: 0 })
134 | ```
135 |
136 | The previous API for `addComponent` is now available as `extendEntity`, with the caveat that it now only accepts two arguments, the entity and the component object:
137 |
138 | ```ts
139 | world.extendEntity(entity, {
140 | position: { x: 0, y: 0 },
141 | velocity: { x: 10, y: 20 }
142 | })
143 | ```
144 |
145 | - b8b2c9b: **Major Breaking Change:** The API signature of `createEntity` has been simplified in order to improve clarity of the API and reduce complexity in both implementation and types. `createEntity` now only supports a single argument, which _must_ satisfy the world's entity type.
146 |
147 | This will only affect you if you have been using `createEntity` with more than one argument in order to compose entities from partial entities, like so:
148 |
149 | ```js
150 | const entity = createEntity(position(0, 0), velocity(1, 1), health(100))
151 | ```
152 |
153 | This always had the issue of `createEntity` not checking the initial state of the entity against the world's entity type. Theoretically, the library could invest some additional effort into complex type assembly to ensure that the entity is valid, but there are enough object composition tools available already, so it felt like an unneccessary duplication.
154 |
155 | Instead, composition is now deferred into userland, where one of the most simple tools is the spread operator:
156 |
157 | ```js
158 | const entity = createEntity({
159 | ...position(0, 0),
160 | ...velocity(1, 1),
161 | ...health(100)
162 | })
163 | ```
164 |
165 | - 54c59c8: **Breaking Change:** The `Archetype.first` getter has been removed in the interest of reducing API surface where things can also be expressed using common JavaScript constructs:
166 |
167 | ```jsx
168 | /* Before: */
169 | const player = world.archetype("player").first
170 |
171 | /* Now: */
172 | const [player] = world.archetype("player")
173 | ```
174 |
175 | - cb6d078: **Breaking Change:** When destroying entities, they are now removed from the world's global list of entities as well as the archetypes' lists of entities using the shuffle-and-pop pattern. This has the following side-effects that _may_ impact your code:
176 |
177 | - Entities are no longer guaranteed to stay in the same order.
178 | - The entity ID storied in its internal `__miniplex` component no longer corresponds to its index in the `entities` array.
179 |
180 | This change provides significantly improved performance in situations where a large number of entities are continuously being created and destroyed.
181 |
182 | - 4d9e51b: **Breaking Change:** Removed the `EntityID` and `ComponentData` types.
183 | - c08f39a: **Breaking Change:** The `ComponentName` type has been removed in favor of just using `keyof T`.
184 |
185 | ### Patch Changes
186 |
187 | - 410e0f6: **New:** The `World` class can now be instantiated with an initial list of entities like so:
188 |
189 | ```js
190 | const world = new World({ entities: [entity1, entity2] })
191 | ```
192 |
193 | - c12dfc1: **Fixed:** `createEntity` was not checking against the world's entity type; this has been fixed.
194 |
195 | ## 1.0.0-next.6
196 |
197 | ### Patch Changes
198 |
199 | - efa21f2: Typing tweaks.
200 |
201 | ## 1.0.0-next.5
202 |
203 | ### Patch Changes
204 |
205 | - c12dfc1: **Fixed:** Improved typing of `createEntity`.
206 |
207 | ## 1.0.0-next.1
208 |
209 | ### Major Changes
210 |
211 | - 4016fb2: 1.0!
212 |
213 | ### Patch Changes
214 |
215 | - 410e0f6: The `World` class can now be instantiated with an initial list of entities like so:
216 |
217 | ```js
218 | const world = new World({ entities: [entity1, entity2] })
219 | ```
220 |
221 | ## 0.11.0-next.0
222 |
223 | ### Minor Changes
224 |
225 | - 769dba7: **Major Breaking Change:** The signature of `addComponent` has been simplified to accept an entity, a component name, and the value of the component:
226 |
227 | ```ts
228 | /* Before */
229 | world.addComponent(entity, { position: { x: 0, y: 0 } })
230 |
231 | /* After */
232 | world.addComponent(entity, "position", { x: 0, y: 0 })
233 | ```
234 |
235 | The previous API for `addComponent` is now available as `extendEntity`, but _marked as deprecated_.
236 |
237 | - b8b2c9b: **Breaking Change:** The API signature of `createEntity` has been simplified in order to improve clarity of the API and reduce complexity in both implementation and types. `createEntity` now only supports a single argument, which _must_ satisfy the world's entity type.
238 |
239 | This will only affect you if you have been using `createEntity` with more than one argument in order to compose entities from partial entities, like so:
240 |
241 | ```js
242 | const entity = createEntity(position(0, 0), velocity(1, 1), health(100))
243 | ```
244 |
245 | This always had the issue of `createEntity` not checking the initial state of the entity against the world's entity type. Theoretically, the library could invest some additional effort into complex type assembly to ensure that the entity is valid, but there are enough object composition tools available already, so it felt like an unneccessary duplication.
246 |
247 | Instead, composition is now deferred into userland, where one of the most simple tools is the spread operator:
248 |
249 | ```js
250 | const entity = createEntity({
251 | ...position(0, 0),
252 | ...velocity(1, 1),
253 | ...health(100)
254 | })
255 | ```
256 |
257 | - cb6d078: **Breaking Change:** When destroying entities, they are now removed from the world's global list of entities as well as the archetypes' lists of entities using the shuffle-and-pop pattern. This has the following side-effects that _may_ impact your code:
258 |
259 | - Entities are no longer guaranteed to stay in the same order.
260 | - The entity ID storied in its internal `__miniplex` component no longer corresponds to its index in the `entities` array.
261 |
262 | This change provides significantly improved performance in situations where a large number of entities are continuously being created and destroyed.
263 |
264 | - 4d9e51b: **Breaking Change:** Removed the `EntityID` and `ComponentData` types.
265 |
266 | ## 0.10.5
267 |
268 | ### Patch Changes
269 |
270 | - 5ef5f95: Included RegisteredEntity in ArchetypeEntity. (@benwest)
271 | - c680bdd: Narrowed return type for createEntity (with one argument). (@benwest)
272 |
273 | ## 0.10.4
274 |
275 | ### Patch Changes
276 |
277 | - 74e34c7: **Fixed:** Fixed an issue with the new iterator syntax on archetypes. (@benwest)
278 |
279 | ## 0.10.3
280 |
281 | ### Patch Changes
282 |
283 | - 1cee12c: Typing improvements, thanks to @benwest.
284 | - 65d2b77: **Added:** Archtypes now implement a `[Symbol.iterator]`, meaning they can be iterated over directly:
285 |
286 | ```js
287 | const withVelocity = world.archetype("velocity")
288 |
289 | for (const { velocity } of withVelocity) {
290 | /* ... */
291 | }
292 | ```
293 |
294 | (Thanks @benwest.)
295 |
296 | ## 0.10.2
297 |
298 | ### Patch Changes
299 |
300 | - 821a45c: **Fixed:** When the world is cleared, archetypes now also get their entities lists cleared.
301 |
302 | ## 0.10.1
303 |
304 | ### Patch Changes
305 |
306 | - cca39cd: **New:** Archetypes now expose a `first` getter that returns the first of the entities in the archetype (or `null` if it doesn't have any entities.) This streamlines situations where you deal with singleton entities (like a player, camera, and so on.) For example, in `miniplex-react`, you can now do the following:
307 |
308 | ```tsx
309 | export const CameraRigSystem: FC = () => {
310 | const player = ECS.useArchetype("isPlayer").first
311 | const camera = ECS.useArchetype("isCamera").first
312 |
313 | /* Do things with player and camera */
314 | }
315 | ```
316 |
317 | ## 0.10.0
318 |
319 | ### Minor Changes
320 |
321 | - cc4032d: **Breaking Change:** `createEntity` will now, like in earlier versions of this library, mutate the first argument that is passed into it (and return it). This allows for patterns where you create the actual entity _object_ before you actually convert it into an entity through _createEntity_.
322 |
323 | ### Patch Changes
324 |
325 | - b93a831: The internal IDs that are being generated for entities have been changed slightly, as they now start at `0` (instead of `1`) and are always equal to the position of the entity within the world's `entities` array. The behavior of `destroyEntity` has also been changed to `null` the destroyed entity's entry in that array, instead of cutting the entity from it.
326 |
327 | This change allows you to confidently and reliably use the entity ID (found in the internal miniplex component, `entity.__miniplex.id`) when integrating with non-miniplex systems, including storing data in TypedArrays (for which miniplex may gain built-in support at some point in the future; this change is also in preparation for that.)
328 |
329 | ## 0.9.2
330 |
331 | ### Patch Changes
332 |
333 | - 92cf103: Safer sanity check in `addComponent`
334 |
335 | ## 0.9.1
336 |
337 | ### Patch Changes
338 |
339 | - 48e785d: Fix sanity check in `removeComponent`
340 |
341 | ## 0.9.0
342 |
343 | ### Minor Changes
344 |
345 | - b4cee80: **Breaking Change:** `createEntity` will now always return a new object, and not return the one passed to it.
346 | - 544f231: **Typescript:** You no longer need to mix in `IEntity` into your own entity types, as part of a wider refactoring of the library's typings. Also, `createWorld` will now return a `RegisteredEntity` type that reflects the presence of the automatically added internal `__miniplex` component, and makes a lot of interactions with the world instance safer than it was previously.
347 | - 544f231: **Breaking Change:** Miniplex will no longer automatically add an `id` component to created entities. If your project has been making use of these automatically generated IDs, you will now need to add them yourself.
348 |
349 | Example:
350 |
351 | ```js
352 | let nextId = 0
353 |
354 | /* Some component factories */
355 | const id = () => ({ id: nextId++ })
356 | const name = (name) => ({ name })
357 |
358 | const world = new World()
359 | const entity = world.createEntity(id(), name("Alice"))
360 | ```
361 |
362 | **Note:** Keep in mind that Miniplex doesn't care about entity IDs much, since all interactions with the world are done through object references. Your project may not need to add IDs to entities at all; if it does, this can now be done using any schema that your project requires (numerical IDs, UUIDs, ...).
363 |
364 | ### Patch Changes
365 |
366 | - b4cee80: `createEntity` now allows you to pass multiple parameters, each representing a partial entity. This makes the use of component factory functions more convenient. Example:
367 |
368 | ```js
369 | /* Provide a bunch of component factories */
370 | const position = (x = 0, y = 0) => ({ position: { x, y } })
371 | const velocity = (x = 0, y = 0) => ({ velocity: { x, y } })
372 | const health = (initial) => ({
373 | health: { max: initial, current: initial }
374 | })
375 |
376 | const world = new World()
377 |
378 | const entity = world.createEntity(
379 | position(0, 0),
380 | velocity(5, 7),
381 | health(1000)
382 | )
383 | ```
384 |
385 | **Typescript Note:** The first argument will always be typechecked against your entity type, so if your entity type has required components, you will need to pass a first argument that satisfies these. The remaining arguments are expected to be partials of your entity type.
386 |
387 | - b4cee80: **Breaking Change:** `world.queue.createEntity` no longer returns an entity (which didn't make a whole lot of semantic sense to begin with.)
388 |
389 | ## 0.8.1
390 |
391 | ### Patch Changes
392 |
393 | - 011c384: Change the API signature of `addComponent` to expect a partial entity instead of name and value, to provide a better interface for component factories:
394 |
395 | ```ts
396 | const position = (x: number = 0, y: number = 0) => ({ position: { x, y } })
397 | const health = (amount: number) => ({
398 | health: { max: amount, current: amount }
399 | })
400 |
401 | world.addComponent(entity, { ...position(), ...health(100) })
402 | ```
403 |
--------------------------------------------------------------------------------
/packages/core/README.md:
--------------------------------------------------------------------------------
1 | 
2 | [](https://www.npmjs.com/package/miniplex)
3 | 
4 | [](https://www.npmjs.com/package/miniplex)
5 | [](https://bundlephobia.com/result?p=miniplex)
6 |
7 | # Miniplex - the gentle game entity manager.
8 |
9 | - 🚀 Manages your game entities using the Entity Component System pattern.
10 | - 🍳 Focuses on ease of use and developer experience.
11 | - 💪 Can power your entire project, or just parts of it.
12 | - 🧩 Written in TypeScript, for TypeScript. (But works in plain JavaScript, too!)
13 | - ⚛️ [React bindings available](https://www.npmjs.com/package/miniplex-react). They're great! (But Miniplex works in any environment.)
14 | - 📦 Tiny package size and minimal dependencies.
15 |
16 | ## Testimonials
17 |
18 | From [verekia](https://twitter.com/verekia):
19 |
20 | > **Miniplex has been the backbone of my games for the past year and it has been a delightful experience.** The TypeScript support and React integration are excellent, and the API is very clear and easy to use, even as a first ECS experience.
21 |
22 | From [Brian Breiholz](https://twitter.com/BrianBreiholz/status/1577182839509962752):
23 |
24 | > Tested @hmans' Miniplex library over the weekend and after having previously implemented an ECS for my wip browser game, I have to say **Miniplex feels like the "right" way to do ECS in #r3f**.
25 |
26 | From [VERYBOMB](https://twitter.com/verybomb):
27 |
28 | > Rewrote my game with Miniplex and my **productivity has improved immeasurably** ever since. Everything about it is so intuitive and elegant.
29 |
30 | ## Table of Contents
31 |
32 | - [Example](#example)
33 | - [Overview](#overview)
34 | - [Installation](#installation)
35 | - [Basic Usage](#basic-usage)
36 | - [Advanced Usage](#advanced-usage)
37 | - [Best Practices](#best-practices)
38 |
39 | ## Example
40 |
41 | ```ts
42 | /* Define an entity type */
43 | type Entity = {
44 | position: { x: number; y: number }
45 | velocity?: { x: number; y: number }
46 | health?: {
47 | current: number
48 | max: number
49 | }
50 | poisoned?: true
51 | }
52 |
53 | /* Create a world with entities of that type */
54 | const world = new World()
55 |
56 | /* Create an entity */
57 | const player = world.add({
58 | position: { x: 0, y: 0 },
59 | velocity: { x: 0, y: 0 },
60 | health: { current: 100, max: 100 }
61 | })
62 |
63 | /* Create another entity */
64 | const enemy = world.add({
65 | position: { x: 10, y: 10 },
66 | velocity: { x: 0, y: 0 },
67 | health: { current: 100, max: 100 }
68 | })
69 |
70 | /* Create some queries: */
71 | const queries = {
72 | moving: world.with("position", "velocity"),
73 | health: world.with("health"),
74 | poisoned: queries.health.with("poisoned")
75 | }
76 |
77 | /* Create functions that perform actions on entities: */
78 | function damage({ health }: With, amount: number) {
79 | health.current -= amount
80 | }
81 |
82 | function poison(entity: With) {
83 | world.addComponent(entity, "poisoned", true)
84 | }
85 |
86 | /* Create a bunch of systems: */
87 | function moveSystem() {
88 | for (const { position, velocity } of queries.moving) {
89 | position.x += velocity.x
90 | position.y += velocity.y
91 | }
92 | }
93 |
94 | function poisonSystem() {
95 | for (const { health, poisoned } of queries.poisoned) {
96 | health.current -= 1
97 | }
98 | }
99 |
100 | function healthSystem() {
101 | for (const entity of queries.health) {
102 | if (entity.health.current <= 0) {
103 | world.remove(entity)
104 | }
105 | }
106 | }
107 |
108 | /* React to entities appearing/disappearing in queries: */
109 | queries.poisoned.onEntityAdded.subscribe((entity) => {
110 | console.log("Poisoned:", entity)
111 | })
112 | ```
113 |
114 | ## Overview
115 |
116 | **Miniplex is an entity management system for games and similarly demanding applications.** Instead of creating separate buckets for different types of entities (eg. asteroids, enemies, pickups, the player, etc.), you throw all of them into a single store, describe their properties through components, and then write code that performs updates on entities that have specific component configurations.
117 |
118 | If you're familiar with **Entity Component System** architecture, this will sound familiar to you – and rightfully so, for Miniplex is, first and foremost, a very straight-forward implementation of this pattern!
119 |
120 | If you're hearing about this approach for the first time, maybe it will sound a little counter-intuitive – but once you dive into it, you will understand how it can help you decouple concerns and keep your codebase well-structured and maintainable. A nice forum post that I can't link to because it's gone offline had a nice explanation:
121 |
122 | > An ECS library can essentially be thought of as an API for performing a loop over a homogeneous set of entities, filtering them by some condition, and pulling out a subset of the data associated with each entity. The goal of the library is to provide a usable API for this, and to do it as fast as possible.
123 |
124 | For a more in-depth explanation, please also see Sander Mertens' wonderful [Entity Component System FAQ](https://github.com/SanderMertens/ecs-faq).
125 |
126 | ### Differences from other ECS libraries
127 |
128 | If you've used other Entity Component System libraries before, here's how Miniplex is different from some of them:
129 |
130 | #### Entities are just normal JavaScript objects
131 |
132 | Entities are just **plain JavaScript objects**, and components are just **properties on those objects**. Component data can be **anything** you need, from primitive values to entire class instances, or even [entire reactive stores](https://github.com/hmans/statery). Miniplex puts developer experience first, and the most important way it does this is by making its usage feel as natural as possible in a JavaScript environment.
133 |
134 | Miniplex does not expect you to programmatically declare component types before using them; if you're using TypeScript, you can provide a type describing your entities and Miniplex will provide full edit- and compile-time type hints and safety. (Hint: you can even write some classes and use their instances as entities!)
135 |
136 | #### Miniplex does not have a built-in notion of systems
137 |
138 | Unlike the majority of ECS libraries, Miniplex does not have any built-in notion of systems, and does not perform any of its own scheduling. This is by design; your project will likely already have an opinion on how to schedule code execution, informed by whatever framework you are using; instead of providing its own and potentially conflicting setup, Miniplex will neatly snuggle into the one you already have.
139 |
140 | Systems are extremely straight-forward: just write simple functions that operate on the Miniplex world, and run them in whatever fashion fits best to your project (`setInterval`, `requestAnimationFrame`, `useFrame`, your custom ticker implementation, and so on.)
141 |
142 | #### Archetypal Queries
143 |
144 | Entity queries are performed through **archetypal queries**, with individual queries indexing and holding a subset of your world's entities that have (or don't have) a specific set of components.
145 |
146 | #### Focus on Object Identities over numerical IDs
147 |
148 | Most interactions with Miniplex are using **object identity** to identify entities (instead of numerical IDs). Miniplex provides an optional lightweight mechanism to generate unique IDs for your entities if you need them. In more complex projects that need stable entity IDs, especially when synchronizing entities across the network, the user is encouraged to implement their own ID generation and management.
149 |
150 | ## Installation
151 |
152 | Add the `miniplex` package to your project using your favorite package manager:
153 |
154 | ```bash
155 | npm add miniplex
156 | yarn add miniplex
157 | pnpm add miniplex
158 | ```
159 |
160 | ## Basic Usage
161 |
162 | Miniplex can be used in any JavaScript or TypeScript project, regardless of which extra frameworks you might be using. This document focuses on how to use Miniplex without a framework, but please also check out the framework-specific documentation available:
163 |
164 | - [miniplex-react](https://github.com/hmans/miniplex/blob/main/packages/react/README.md)
165 |
166 | ### Creating a World
167 |
168 | Miniplex manages entities in **worlds**, which act as containers for entities as well as an API for interacting with them. You can have one big world in your project, or several smaller worlds handling separate sections of your game.
169 |
170 | ```ts
171 | import { World } from "miniplex"
172 |
173 | const world = new World()
174 | ```
175 |
176 | ### Typing your Entities (optional, but recommended!)
177 |
178 | If you're using TypeScript, you can define a type that describes your entities and provide it to the `World` constructor to get full type support in all interactions with it:
179 |
180 | ```ts
181 | import { World } from "miniplex"
182 |
183 | type Entity = {
184 | position: { x: number; y: number; z: number }
185 | velocity?: { x: number; y: number; z: number }
186 | health?: number
187 | paused?: true
188 | }
189 |
190 | const world = new World()
191 | ```
192 |
193 | ### Creating Entities
194 |
195 | The main interactions with a Miniplex world are creating and destroying entities, and adding or removing components from these entities. Entities are just plain JavaScript objects that you pass into the world's `add` and `remove` functions, like here:
196 |
197 | ```ts
198 | const entity = world.add({ position: { x: 0, y: 0, z: 0 } })
199 | ```
200 |
201 | We've directly added a `position` component to the entity. If you're using TypeScript, the component values here will be type-checked against the type you provided to the `World` constructor.
202 |
203 | > **Note** Adding the entity will make it known to the world and all relevant queries, but it will not change the entity object itself in any way. In Miniplex, entities can _live in multiple worlds at the same time_! This allows you to split complex simulations into entirely separate worlds, each with their own queries, even though they might share some (or all) entities.
204 |
205 | ### Adding Components
206 |
207 | The `World` instance provides `addComponent` and `removeComponent` functions for adding and removing components from entities. Let's add a `velocity` component to the entity. Note that we're passing the entity itself as the first argument:
208 |
209 | ```ts
210 | world.addComponent(entity, "velocity", { x: 10, y: 0, z: 0 })
211 | ```
212 |
213 | Now the entity has two components: `position` and `velocity`.
214 |
215 | ### Querying Entities
216 |
217 | Let's write some code that moves entities, which have a `position`, according to their `velocity`. You will typically implement this as something called a **system**, which, in Miniplex, is typically just a normal function that fetches the entities it is interested in, and then performs some operation on them.
218 |
219 | Fetching only the entities that a system is interested in is the most important part in all this, and it is done through something called **queries** that can be thought of as something similar to database indices.
220 |
221 | Since we're going to _move_ entities, we're interested in entities that have both the `position` and `velocity` components, so let's create a query for that:
222 |
223 | ```ts
224 | /* Get all entities with position and velocity */
225 | const movingEntities = world.with("position", "velocity")
226 | ```
227 |
228 | > **Note** There is also `without`, which will return all entities that do _not_ have the specified components:
229 | >
230 | > ```ts
231 | > const active = world.without("paused")
232 | > ```
233 | >
234 | > Queries can also be nested:
235 | >
236 | > ```ts
237 | > const movingEntities = world.with("position", "velocity").without("paused")
238 | > ```
239 |
240 | ### Implementing Systems
241 |
242 | Now we can implement a system that operates on these entities! Miniplex doesn't have an opinion on how you implement systems – they can be as simple as a function. Here's a system that uses the `movingEntities` query we created in the previous step, iterates over all entities in it, and moves them according to their velocity:
243 |
244 | ```ts
245 | function movementSystem() {
246 | for (const { position, velocity } of movingEntities) {
247 | position.x += velocity.x
248 | position.y += velocity.y
249 | position.z += velocity.z
250 | }
251 | }
252 | ```
253 |
254 | **Note:** Since entities are just plain JavaScript objects, they can easily be destructured into their components, like we're doing above.
255 |
256 | Now all we need to do is make sure that this system is run on a regular basis. If you're writing a game, the framework you are using will already have a mechanism that allows you to execute code once per frame; just call the `movementSystem` function from there!
257 |
258 | ### Destroying Entities
259 |
260 | At some point we may want to remove an entity from the world (for example, an enemy spaceship that got destroyed by the player). We can do this through the world's `remove` function:
261 |
262 | ```ts
263 | world.remove(entity)
264 | ```
265 |
266 | This will immediately remove the entity from the Miniplex world and all existing queries.
267 |
268 | > **Note** While this will remove the entity object from the world, it will not destroy or otherwise change the object itself. In fact, you can just add it right back into the world if you want to!
269 |
270 | ## Advanced Usage
271 |
272 | We're about to dive into some advanced usage patterns. Please make sure you're familiar with the basics before continuing.
273 |
274 | ### Reacting to added/removed entities
275 |
276 | Instances of `World` and `Query` provide the built-in `onEntityAdded` and `onEntityRemoved` events that you can subscribe to to be notified about entities appearing or disappearing.
277 |
278 | For example, in order to be notified about any entity being added to the world, you may do this:
279 |
280 | ```ts
281 | world.onEntityAdded.subscribe((entity) => {
282 | console.log("A new entity has been spawned:", entity)
283 | })
284 | ```
285 |
286 | This is useful for running system-specific initialization code on entities that appear in specific queries:
287 |
288 | ```ts
289 | const withHealth = world.with("health")
290 |
291 | withHealth.onEntityAdded.subscribe((entity) => {
292 | entity.health.current = entity.health.max
293 | })
294 | ```
295 |
296 | ### Predicate Queries using `where`
297 |
298 | Typically, you'll want to build queries the check entities for the _presence_ of specific components; you have been using the `with` and `without` functions for this so far. But there may be the rare case where you want to query by _value_; for this, Miniplex provides the `where` function. It allows you to specify a predicate function that your entity will be checked against:
299 |
300 | ```ts
301 | const damagedEntities = world
302 | .with("health")
303 | .where(({ health }) => health.current < health.max)
304 |
305 | const deadEntities = world.with("health").where(({ health }) => health <= 0)
306 | ```
307 |
308 | It is _extremely_ important to note that queries that use `where` are in no way reactive; if the values within the entity change in a way that would change the result of your predicate function, Miniplex will _not_ pick this up automatically.
309 |
310 | Instead, once you know that you are using `where` to inspect component _values_, you are required to signal an updated entity by calling the `reindex` function:
311 |
312 | ```ts
313 | function damageEntity(entity: With, amount: number) {
314 | entity.health.current -= amount
315 | world.reindex(entity)
316 | }
317 | ```
318 |
319 | Depending on the total number of queries you've created, reindexing can be a relatively expensive operation, so it is recommended that you use this functionality with care. Most of the time, it is more efficient to model things using additional components. The above example could, for example, be rewritten like this:
320 |
321 | ```ts
322 | const damagedEntities = world.with("health", "damaged")
323 |
324 | const deadEntities = world.with("health", "dead")
325 |
326 | function damageEntity(entity: With, amount: number) {
327 | entity.health.current -= amount
328 |
329 | if (entity.health.current < entity.health.max) {
330 | world.addComponent(entity, "damaged")
331 | }
332 |
333 | if (entity.health.current <= 0) {
334 | world.addComponent(entity, "dead")
335 | }
336 | }
337 | ```
338 |
339 | ### ID Generation
340 |
341 | When interacting with Miniplex, entities are typically identified using their _object identities_, which is one of the ways where Miniplex is different from typical ECS implementations, which usually make use of numerical IDs.
342 |
343 | Most Miniplex workloads can be implemented without the use of numerical IDs, but if you ever _do_ need such an identifier for your entities – possibly because you're wiring them up to another non-Miniplex system that expects them – Miniplex worlds provide a lightweight mechanism to generate them:
344 |
345 | ```ts
346 | const entity = world.add({ count: 10 })
347 | const id = world.id(entity)
348 | ```
349 |
350 | You can later use this ID to look up the entity in the world:
351 |
352 | ```ts
353 | const entity = world.entity(id)
354 | ```
355 |
356 | ## Best Practices
357 |
358 | ### Use `addComponent` and `removeComponent` for adding and removing components
359 |
360 | Since entities are just normal objects, you might be tempted to just add new properties to (or delete properties from) them directly. **This is a bad idea** because it will skip the indexing step needed to make sure the entity is listed in the correct queries. Please always go through `addComponent` and `removeComponent`!
361 |
362 | It is perfectly fine to mutate component _values_ directly, though.
363 |
364 | ```ts
365 | /* ✅ This is fine: */
366 | const entity = world.add({ position: { x: 0, y: 0, z: 0 } })
367 | entity.position.x = 10
368 |
369 | /* ⛔️ This is not: */
370 | const entity = world.add({ position: { x: 0, y: 0, z: 0 } })
371 | entity.velocity = { x: 10, y: 0, z: 0 }
372 | ```
373 |
374 | ### Iterate over queries using `for...of`
375 |
376 | The world as well as all queries derived from it are _iterable_, meaning you can use them in `for...of` loops. This is the recommended way to iterate over entities in a query, as it is highly performant, and iterates over the entities _in reverse order_, which allows you to safely remove entities from within the loop.
377 |
378 | ```ts
379 | const withHealth = world.with("health")
380 |
381 | /* ✅ Recommended: */
382 | for (const entity of withHealth) {
383 | if (entity.health <= 0) {
384 | world.remove(entity)
385 | }
386 | }
387 |
388 | /* ⛔️ Avoid: */
389 | for (const entity of withHealth.entities) {
390 | if (entity.health <= 0) {
391 | world.remove(entity)
392 | }
393 | }
394 |
395 | /* ⛔️ Especially avoid: */
396 | withHealth.entities.forEach((entity) => {
397 | if (entity.health <= 0) {
398 | world.remove(entity)
399 | }
400 | })
401 | ```
402 |
403 | ### Reuse queries where possible
404 |
405 | The functions creating and returning queries (`with`, `without`, `where`) aim to be idempotent and will reuse existing queries for the same set of query attributes. Checking if a query for a specific set of query attributes already exists is a comparatively heavyweight function, though, and you are advised to, wherever possible, reuse previously created queries.
406 |
407 | ```ts
408 | /* ✅ Recommended: */
409 | const movingEntities = world.with("position", "velocity")
410 |
411 | function movementSystem() {
412 | for (const { position, velocity } of movingEntities) {
413 | position.x += velocity.x
414 | position.y += velocity.y
415 | position.z += velocity.z
416 | }
417 | }
418 |
419 | /* ⛔️ Avoid: */
420 | function movementSystem(world) {
421 | /* This will work, but now the world needs to check if a query for "position" and "velocity" already exists every time this function is called, which is pure overhead. */
422 | for (const { position, velocity } of world.with("position", "velocity")) {
423 | position.x += velocity.x
424 | position.y += velocity.y
425 | position.z += velocity.z
426 | }
427 | }
428 | ```
429 |
430 | ## Questions?
431 |
432 | If you have questions about Miniplex, you're invited to post them in our [Discussions section](https://github.com/hmans/miniplex/discussions) on GitHub.
433 |
434 | ## License
435 |
436 | ```
437 | Copyright (c) 2023 Hendrik Mans
438 |
439 | Permission is hereby granted, free of charge, to any person obtaining
440 | a copy of this software and associated documentation files (the
441 | "Software"), to deal in the Software without restriction, including
442 | without limitation the rights to use, copy, modify, merge, publish,
443 | distribute, sublicense, and/or sell copies of the Software, and to
444 | permit persons to whom the Software is furnished to do so, subject to
445 | the following conditions:
446 |
447 | The above copyright notice and this permission notice shall be
448 | included in all copies or substantial portions of the Software.
449 |
450 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
451 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
452 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
453 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
454 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
455 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
456 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
457 | ```
458 |
--------------------------------------------------------------------------------