@daneren2005/shared-memory-ecs 1.5.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +222 -44
- package/dist/actions/add-component-worker.d.ts +3 -0
- package/dist/actions/build-worker-entity.d.ts +4 -2
- package/dist/actions/create-entity-worker.d.ts +2 -2
- package/dist/actions/kill-entity-worker.d.ts +2 -2
- package/dist/actions/remove-component-worker.d.ts +3 -0
- package/dist/component-definition.d.ts +2 -0
- package/dist/create-system-worker-DURlIMia.js +345 -0
- package/dist/create-system-worker-DURlIMia.js.map +1 -0
- package/dist/entity-class.d.ts +51 -0
- package/dist/entity-component.d.ts +7 -2
- package/dist/entity-factory.d.ts +21 -5
- package/dist/entity.d.ts +11 -2
- package/dist/index.d.ts +15 -8
- package/dist/index.js +379 -166
- package/dist/index.js.map +1 -1
- package/dist/systems/{component-system.d.ts → entity-worker-system.d.ts} +39 -18
- package/dist/systems/system.d.ts +1 -1
- package/dist/systems/worker-system.d.ts +22 -0
- package/dist/systems/workers/apply-query-delta.d.ts +1 -1
- package/dist/systems/workers/create-entity-system-worker.d.ts +8 -0
- package/dist/systems/workers/create-system-worker.d.ts +9 -0
- package/dist/systems/workers/entity-system-web-worker.d.ts +18 -0
- package/dist/systems/workers/{component-worker-message.d.ts → entity-system-worker-message.d.ts} +8 -4
- package/dist/worker.d.ts +10 -6
- package/dist/worker.js +2 -2
- package/dist/world.d.ts +17 -17
- package/package.json +2 -1
- package/dist/create-component-worker-BM942gjg.js +0 -288
- package/dist/create-component-worker-BM942gjg.js.map +0 -1
- package/dist/systems/workers/component-web-worker.d.ts +0 -17
- package/dist/systems/workers/create-component-worker.d.ts +0 -8
package/README.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
A small, reusable Entity / Component / System core backed by shared memory
|
|
4
4
|
([`@daneren2005/shared-memory-objects`](https://www.npmjs.com/package/@daneren2005/shared-memory-objects)).
|
|
5
|
-
It contains only
|
|
6
|
-
|
|
5
|
+
It contains only engine-agnostic ECS concepts. Games supply their own components, entity classes, templates,
|
|
6
|
+
systems, and domain data.
|
|
7
7
|
|
|
8
8
|
## Concepts
|
|
9
9
|
|
|
@@ -14,24 +14,29 @@ fog of war, sub-classed entities, required components, etc).
|
|
|
14
14
|
config to the raw block values) and `attach(entity, memory, index)` (builds the accessor over that block).
|
|
15
15
|
Loading a component is `attach(entity, memory, memory.create(toBlock(config)))`; splitting it this way lets a
|
|
16
16
|
worker build the block off-thread (it calls only `toBlock`) and the main thread wrap it (`attach`) when it
|
|
17
|
-
adopts the entity. Plus an optional `save(component)
|
|
18
|
-
`Config` (defining props supplied up front, e.g. `maxHealth`) and
|
|
19
|
-
e.g. current `health`); `toBlock` sees `Config & Serialization` while
|
|
20
|
-
slice. `free` runs when the component is torn down (see
|
|
17
|
+
adopts the entity. Plus an optional `save(component)`, `free(component)`, and `died(component, entity, world)`.
|
|
18
|
+
A component's data splits into `Config` (defining props supplied up front, e.g. `maxHealth`) and
|
|
19
|
+
`Serialization` (runtime-derived state, e.g. current `health`); `toBlock` sees `Config & Serialization` while
|
|
20
|
+
`save` returns only the `Serialization` slice. `free` runs when the component is torn down (see
|
|
21
|
+
[Freeing extra resources](#freeing-extra-resources)); `died` runs when the entity is killed, with it still
|
|
22
|
+
readable (see [Reacting to death](#reacting-to-death)).
|
|
21
23
|
- **`ComponentRegistry<C>`** – the map of all component definitions for a game.
|
|
22
24
|
- **`EntityFactory<C>`** – maps an entity `type` name to a base (template) config. Loading an entity layers
|
|
23
25
|
the caller's config over its type's template, so shared static data lives in one place and a save only
|
|
24
|
-
needs the `type` plus the entity's serialization.
|
|
26
|
+
needs the `type` plus the entity's serialization. Optionally, a template's immutable `class` selects a
|
|
27
|
+
game-provided `BaseEntity` subclass and restricts which components that template may load.
|
|
25
28
|
- **`BaseWorld<C>`** – builds one `MemoryComponent` per registered component (attached to that component's
|
|
26
29
|
definition as `world.registry[name].memoryComponent`) and runs systems. It is generic over your component
|
|
27
30
|
map `C`, so `world.registry`, `entity.components`, `setComponent`, etc. are fully typed. Its entities live
|
|
28
31
|
in `world.entities`, a `Map` keyed by `eid` — see [Iterating entities](#iterating-entities).
|
|
29
|
-
- **`BaseEntity<C>`** – an `eid`,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
- **`BaseEntity<C>`** – an `eid`, its `id` alias, immutable `type`, `dead` state, and a bag of memory-backed
|
|
33
|
+
components. Every entity has an `entity` component whose immutable `type` and optional immutable `class`
|
|
34
|
+
are interned strings stored in shared memory. A game can subclass `BaseEntity` to expose direct convenience
|
|
35
|
+
getters for one entity class.
|
|
32
36
|
- **Systems** – `System`, `IterableSystem`, `EntitySystem` (main-thread iteration over entities with a
|
|
33
|
-
given set of components)
|
|
34
|
-
off-thread when Web Workers + `SharedArrayBuffer` are available)
|
|
37
|
+
given set of components), `EntityWorkerSystem` (runs an update function over raw memory blocks,
|
|
38
|
+
off-thread when Web Workers + `SharedArrayBuffer` are available), and `WorkerSystem` (an `EntityWorkerSystem`
|
|
39
|
+
that runs once per tick over its sub-queries instead of once per entity — see [Per-tick systems](#per-tick-systems-workersystem)).
|
|
35
40
|
|
|
36
41
|
## Defining components
|
|
37
42
|
|
|
@@ -129,10 +134,68 @@ and the `entity` block stores only a pointer to it at `TYPE_INDEX`. Reading `ent
|
|
|
129
134
|
resolves that pointer back to the string through the world's cache; a worker can do the same (see
|
|
130
135
|
[Reading an entity's type in a worker](#reading-an-entitys-type-in-a-worker)).
|
|
131
136
|
|
|
137
|
+
### Entity classes and scoped templates
|
|
138
|
+
|
|
139
|
+
An entity `type` names one concrete template. Its optional `class` names the stable family that chooses a
|
|
140
|
+
wrapper constructor and the components that family permits. Callers still pass only `{ type, ...overrides }`:
|
|
141
|
+
the factory resolves class from the template, and neither identity can change after construction.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import {
|
|
145
|
+
BaseEntity,
|
|
146
|
+
BaseWorld,
|
|
147
|
+
EntityFactory,
|
|
148
|
+
defineEntityClasses,
|
|
149
|
+
defineEntityConfigs,
|
|
150
|
+
} from '@daneren2005/shared-memory-ecs';
|
|
151
|
+
import type { ComponentsOf, EntityConfigOf, EntityInstancesOf } from '@daneren2005/shared-memory-ecs';
|
|
152
|
+
|
|
153
|
+
const registry = {
|
|
154
|
+
health: healthDefinition,
|
|
155
|
+
value: valueDefinition,
|
|
156
|
+
};
|
|
157
|
+
type Components = ComponentsOf<typeof registry>;
|
|
158
|
+
type Config = EntityConfigOf<typeof registry>;
|
|
159
|
+
|
|
160
|
+
class ActorEntity extends BaseEntity<Components, Config> {
|
|
161
|
+
get health() { return this.components.health?.health; }
|
|
162
|
+
}
|
|
163
|
+
class ItemEntity extends BaseEntity<Components, Config> {
|
|
164
|
+
get value() { return this.components.value?.value; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const classes = defineEntityClasses(registry, {
|
|
168
|
+
actor: { entity: ActorEntity, components: ['health'] },
|
|
169
|
+
item: { entity: ItemEntity, components: ['value'] },
|
|
170
|
+
} as const);
|
|
171
|
+
|
|
172
|
+
const templates = defineEntityConfigs(classes, {
|
|
173
|
+
guardian: { type: 'guardian', class: 'actor', maxHealth: 100 },
|
|
174
|
+
token: { type: 'token', class: 'item', value: 5 },
|
|
175
|
+
} as const);
|
|
176
|
+
|
|
177
|
+
type Entities = EntityInstancesOf<typeof classes>;
|
|
178
|
+
const factory = new EntityFactory<Components, Config, Entities>(templates, classes);
|
|
179
|
+
const world = new BaseWorld<typeof registry, Components, Config, Entities>(registry, { factory });
|
|
180
|
+
|
|
181
|
+
const item = world.loadEntity({ type: 'token' });
|
|
182
|
+
console.log(item instanceof ItemEntity); // true
|
|
183
|
+
console.log(item.components.entity.class); // "item"
|
|
184
|
+
console.log(item.id, item.type, item.dead); // BaseEntity identity accessors
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`defineEntityConfigs` checks each template against its class's component definitions, so adding a health-only
|
|
188
|
+
property to the item template is a type error. The factory repeats the boundary at runtime: loading or attaching
|
|
189
|
+
a component outside the class allowlist throws, protecting dynamically loaded data too. Client/editor metadata
|
|
190
|
+
that no component consumes should remain in a separate authoring type and be transformed into the scoped ECS
|
|
191
|
+
template.
|
|
192
|
+
|
|
193
|
+
Classless factories keep the original behavior: they create `BaseEntity` and scan the complete registry.
|
|
194
|
+
|
|
132
195
|
## Iterating entities
|
|
133
196
|
|
|
134
197
|
`world.entities` is a `Map` keyed by `eid`, not an array, and so is `entities` on `EntitySystem` and
|
|
135
|
-
`
|
|
198
|
+
`EntityWorkerSystem`:
|
|
136
199
|
|
|
137
200
|
```ts
|
|
138
201
|
world.entities.forEach(entity => { ... }); // in the order they were added
|
|
@@ -186,25 +249,25 @@ physics library exports its `*_INDEX` constants.
|
|
|
186
249
|
Reach for this when a profile says to, not by default. A menu, a save, a system that touches a dozen entities:
|
|
187
250
|
use the accessors.
|
|
188
251
|
|
|
189
|
-
##
|
|
252
|
+
## EntityWorkerSystem workers
|
|
190
253
|
|
|
191
|
-
`
|
|
192
|
-
worker entry file calls `
|
|
254
|
+
`EntityWorkerSystem` needs a `getWorker()` that returns a real `Worker`, and an `updateFunction`. Your
|
|
255
|
+
worker entry file calls `createEntitySystemWorker(self, updateFunction)`, importing it from the
|
|
193
256
|
[`/worker` subpath](#importing-in-workers) so the worker bundle stays small. When Web Workers or
|
|
194
257
|
`SharedArrayBuffer` are unavailable it transparently falls back to running the same update function on
|
|
195
258
|
the main thread. Attach any extra per-run data (the equivalent of the old faction/fog-of-war fields)
|
|
196
259
|
by overriding `addDataToWorld(world)`. Declare its shape with the `W` type parameter (an interface
|
|
197
|
-
extending `
|
|
260
|
+
extending `EntityWorkerSystemWorld`) so both `addDataToWorld` and the `updateFunction` see it typed:
|
|
198
261
|
|
|
199
262
|
```ts
|
|
200
|
-
interface DamageWorld extends
|
|
263
|
+
interface DamageWorld extends EntityWorkerSystemWorld {
|
|
201
264
|
damage: number
|
|
202
265
|
}
|
|
203
266
|
|
|
204
267
|
const damageUpdate: EntityUpdateFunction<Components, { health: Int32Array }, DamageWorld> =
|
|
205
268
|
(world, entityId, components) => { components.health[0] -= world.damage; };
|
|
206
269
|
|
|
207
|
-
class DamageSystem extends
|
|
270
|
+
class DamageSystem extends EntityWorkerSystem<Components, { health: Int32Array }, DamageWorld> {
|
|
208
271
|
addDataToWorld(world: DamageWorld) { world.damage = 5; }
|
|
209
272
|
}
|
|
210
273
|
```
|
|
@@ -220,11 +283,11 @@ itself. Both are optional properties on the `EntityUpdateFunction`.
|
|
|
220
283
|
(structured-cloned across the boundary) and returns a `Partial<W>` that is merged onto `world` on every
|
|
221
284
|
subsequent run. Use it for state that must be seeded from the main thread but then persist inside the worker
|
|
222
285
|
— a seeded RNG, a lookup table, a config object — without paying to re-send it each frame. Type the init
|
|
223
|
-
data with the `D` type parameter (the fourth on `EntityUpdateFunction` / `
|
|
286
|
+
data with the `D` type parameter (the fourth on `EntityUpdateFunction` / `EntityWorkerSystem`) so `getInitData`
|
|
224
287
|
and the `init` hook agree on its shape; it defaults to `unknown` when a system has no init data:
|
|
225
288
|
|
|
226
289
|
```ts
|
|
227
|
-
interface DamageWorld extends
|
|
290
|
+
interface DamageWorld extends EntityWorkerSystemWorld {
|
|
228
291
|
damage: number
|
|
229
292
|
}
|
|
230
293
|
interface DamageInitData {
|
|
@@ -238,7 +301,7 @@ const damageUpdate: EntityUpdateFunction<Components, { health: Int32Array }, Dam
|
|
|
238
301
|
// `DamageInitData | undefined` (undefined when the system supplies no getInitData).
|
|
239
302
|
damageUpdate.init = (data) => ({ damage: data?.baseDamage ?? 1 });
|
|
240
303
|
|
|
241
|
-
class DamageSystem extends
|
|
304
|
+
class DamageSystem extends EntityWorkerSystem<Components, { health: Int32Array }, DamageWorld, DamageInitData> {
|
|
242
305
|
constructor(world: BaseWorld<Components>) {
|
|
243
306
|
super(world, {
|
|
244
307
|
name: 'DamageSystem',
|
|
@@ -253,7 +316,7 @@ class DamageSystem extends ComponentSystem<Components, { health: Int32Array }, D
|
|
|
253
316
|
```
|
|
254
317
|
|
|
255
318
|
Unlike `addDataToWorld` (an overridable method on the system), `getInitData` is a config option — it lives in
|
|
256
|
-
the options passed to the `
|
|
319
|
+
the options passed to the `EntityWorkerSystem` constructor, so a system used without a subclass can supply it
|
|
257
320
|
there directly.
|
|
258
321
|
|
|
259
322
|
**`preRun`** runs once per run, before any entity is updated, with the run's `world`, the full entity list,
|
|
@@ -270,33 +333,77 @@ damageUpdate.preRun = (world, entities, queries, callbacks) => {
|
|
|
270
333
|
(An `entityRemoved(world, entityId, callbacks)` hook completes the set — it fires once per entity that left
|
|
271
334
|
the system this run, so a worker can release any per-entity state it was holding.)
|
|
272
335
|
|
|
336
|
+
### Per-tick systems: `WorkerSystem`
|
|
337
|
+
|
|
338
|
+
Some systems have no entities of their own — they run **once per tick** to scan or count other entities, and
|
|
339
|
+
maybe spawn something. A `WorkerSystem` is an `EntityWorkerSystem` whose function is called **once per run** over
|
|
340
|
+
the named sub-queries instead of once per entity. It has no main query, so it runs every interval even with
|
|
341
|
+
zero entities, and it reuses the whole worker/query/`createsEntities`/`addDataToWorld` machinery:
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
// once per run: no per-entity loop, just the queries
|
|
345
|
+
const spawnerUpdate: WorkerSystemRunFunction<Components, SpawnerWorld> = (world, queries, callbacks) => {
|
|
346
|
+
const asteroids = queries.asteroids ?? [];
|
|
347
|
+
if(asteroids.length < world.minAsteroids) {
|
|
348
|
+
createEntityWorker(world, { type: 'asteroid', x: 0, y: 0 }, callbacks);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
class SpawnerSystem extends WorkerSystem<Components, SpawnerWorld> {
|
|
353
|
+
minAsteroids = 20;
|
|
354
|
+
constructor(world: TestWorld) {
|
|
355
|
+
super(world, {
|
|
356
|
+
name: 'Spawner',
|
|
357
|
+
updateFunction: spawnerUpdate,
|
|
358
|
+
createsEntities: true,
|
|
359
|
+
deltaBetweenRuns: 1000,
|
|
360
|
+
getWorker: () => new Worker(SPAWNER_WORKER_URL, { type: 'module' }),
|
|
361
|
+
queries: { asteroids: { required: ['asteroid'] } },
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
// Fresh per-run data — structured-cloned to the worker each run, so keep it plain/cloneable.
|
|
365
|
+
addDataToWorld(world: SpawnerWorld) { world.minAsteroids = this.minAsteroids; }
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Its worker entry uses `createSystemWorker` (the run-function counterpart of `createEntitySystemWorker`),
|
|
370
|
+
passing the registry only when it creates entities or adds components:
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
import { createSystemWorker } from '@daneren2005/shared-memory-ecs/worker';
|
|
374
|
+
createSystemWorker(self, spawnerUpdate, registry);
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
The run function may carry the same `init` / `entityRemoved` hooks as an `updateFunction`. Everything else —
|
|
378
|
+
callbacks, worker fallback, off-thread creation — behaves exactly as an `EntityWorkerSystem`.
|
|
379
|
+
|
|
273
380
|
### Importing in workers
|
|
274
381
|
|
|
275
382
|
Each worker entry file is bundled on its own, and a single-entry bundle cannot tree-shake this package's
|
|
276
|
-
barrel: importing `
|
|
383
|
+
barrel: importing `createEntitySystemWorker` from `@daneren2005/shared-memory-ecs` drags the whole library -
|
|
277
384
|
`BaseWorld`, every system, their `@daneren2005/shared-memory-objects` dependencies - into the worker, even
|
|
278
385
|
though a worker never runs any of it (easily ~20kb of dead code per worker). Import worker-side helpers from
|
|
279
386
|
the `@daneren2005/shared-memory-ecs/worker` subpath instead. It exposes only what runs in a worker -
|
|
280
|
-
`
|
|
281
|
-
|
|
387
|
+
`createEntitySystemWorker`, `createSystemWorker`, `createEntityWorker`, `addComponentWorker`,
|
|
388
|
+
`removeComponentWorker`, `killEntityWorker`, `DEAD_INDEX`,
|
|
389
|
+
`TYPE_INDEX` (plus the worker-relevant types) - so the bundle stays tiny:
|
|
282
390
|
|
|
283
391
|
```ts
|
|
284
392
|
// damage.worker.ts - the worker entry file
|
|
285
|
-
import {
|
|
393
|
+
import { createEntitySystemWorker } from '@daneren2005/shared-memory-ecs/worker';
|
|
286
394
|
import { damageUpdate } from './damage-update';
|
|
287
395
|
|
|
288
|
-
|
|
396
|
+
createEntitySystemWorker(self, damageUpdate);
|
|
289
397
|
```
|
|
290
398
|
|
|
291
399
|
The same applies to any module the worker file pulls in: an update function that calls `createEntityWorker`
|
|
292
400
|
or `killEntityWorker` should import them from `/worker` too. Type-only imports (`EntityUpdateFunction`,
|
|
293
|
-
`
|
|
294
|
-
(`
|
|
401
|
+
`EntityWorkerSystemWorld`, ...) can come from either path since types are erased, and main-thread code
|
|
402
|
+
(`EntityWorkerSystem`, `BaseWorld`, `EntityFactory`, ...) keeps importing from the package root.
|
|
295
403
|
|
|
296
|
-
A worker that creates entities
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
whatever it imports) into that worker's bundle.
|
|
404
|
+
A worker that creates entities or adds components passes your component registry as the third argument -
|
|
405
|
+
`createEntitySystemWorker(self, shipUpdate, registry)` - so it has each component's `toBlock`. Only do this in
|
|
406
|
+
workers that need it; it pulls the registry (and whatever it imports) into that worker's bundle.
|
|
300
407
|
|
|
301
408
|
### Reading an entity's type in a worker
|
|
302
409
|
|
|
@@ -314,7 +421,7 @@ const update: EntityUpdateFunction<Components, { entity: Uint32Array }> = (world
|
|
|
314
421
|
// ...branch on type, etc.
|
|
315
422
|
};
|
|
316
423
|
|
|
317
|
-
class TypedSystem extends
|
|
424
|
+
class TypedSystem extends EntityWorkerSystem<Components, { entity: Uint32Array }> {
|
|
318
425
|
constructor(world: BaseWorld<Components>) {
|
|
319
426
|
super(world, { name: 'TypedSystem', required: ['entity'], updateFunction: update, getWorker: () => new Worker(/* ... */) });
|
|
320
427
|
}
|
|
@@ -331,7 +438,8 @@ on the main thread) resolves the same way.
|
|
|
331
438
|
An update function runs on shared memory, so anything it writes is already visible on the main thread. What
|
|
332
439
|
it cannot do from there is touch the world, so the things that have to happen back on it go through
|
|
333
440
|
`callbacks`: `entityComponentChanged` (emitted on the entity as `component-property-updated`), `entityDied`
|
|
334
|
-
(as `death`),
|
|
441
|
+
(as `death`), `createEntity` (see [Creating entities from a worker](#creating-entities-from-a-worker)), and
|
|
442
|
+
component changes requested by `addComponentWorker` / `removeComponentWorker`.
|
|
335
443
|
All of them are collected during the run and applied once it completes.
|
|
336
444
|
|
|
337
445
|
`emitEntityEvent` is the escape hatch for an event of your own: name it whatever you like and give it
|
|
@@ -401,20 +509,22 @@ createEntityWorker(world, { type: 'ship', x: 100, y: 150 }, callbacks);
|
|
|
401
509
|
|
|
402
510
|
`createEntityWorker` layers your overrides over the `ship` template, mints a unique id from a shared atomic
|
|
403
511
|
counter (so it never collides with one the main thread or another worker hands out), and for each component
|
|
404
|
-
the merged config triggers, pushes a block into its pool and writes the values - all off-thread.
|
|
512
|
+
the merged config triggers, pushes a block into its pool and writes the values - all off-thread. With entity
|
|
513
|
+
classes registered, the worker also resolves the template's class and considers only its allowed components. It reports
|
|
405
514
|
back an id-plus-block-indexes descriptor; when the run completes the main thread *adopts* it, building the
|
|
406
|
-
always-present `entity` component there (interning
|
|
407
|
-
worker wrote - no block is copied or re-allocated. Like
|
|
515
|
+
always-present `entity` component there (interning `type` and `class` is a main-thread job), asking the factory
|
|
516
|
+
for the registered wrapper, and attaching each block the worker wrote - no block is copied or re-allocated. Like
|
|
517
|
+
every other worker report-back, the new entity first
|
|
408
518
|
exists on the following frame, so the system picks it up next run.
|
|
409
519
|
|
|
410
520
|
Two things make this work, both opt-in so only the workers that create entities pay for them:
|
|
411
521
|
|
|
412
522
|
- Register the system with `createsEntities: true`. That ships the factory templates to its worker on load.
|
|
413
|
-
- In that system's worker entry, pass your component registry to `
|
|
523
|
+
- In that system's worker entry, pass your component registry to `createEntitySystemWorker`, so the worker has
|
|
414
524
|
each component's block builder:
|
|
415
525
|
|
|
416
526
|
```ts
|
|
417
|
-
|
|
527
|
+
createEntitySystemWorker(self, shipUpdate, registry);
|
|
418
528
|
```
|
|
419
529
|
|
|
420
530
|
Every component is already defined as two halves for exactly this — `toBlock(config)` (the block values) and
|
|
@@ -436,9 +546,48 @@ const shipHealth: ComponentDefinition<HealthComponent, Int32Array, HealthConfig>
|
|
|
436
546
|
};
|
|
437
547
|
```
|
|
438
548
|
|
|
439
|
-
This runs identically on the main-thread fallback.
|
|
440
|
-
|
|
441
|
-
the
|
|
549
|
+
This runs identically on the main-thread fallback. A `loadInFinishLoading` component (one that reads other
|
|
550
|
+
entities) is skipped, since the worker has no world to read. A worker does not receive constructors; it returns
|
|
551
|
+
the serializable class key, and the main-thread factory constructs the wrapper during adoption.
|
|
552
|
+
|
|
553
|
+
### Adding and removing components at runtime
|
|
554
|
+
|
|
555
|
+
On the main thread, use the entity directly:
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
ship.loadComponent('shield', { capacity: 100 });
|
|
559
|
+
ship.removeComponent('shield');
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
Both operations notify every system. Required and excluded (`not`) queries change membership as needed. An
|
|
563
|
+
entity already present through other required components is re-sent to worker systems too, so their cached
|
|
564
|
+
component bundle gains or loses an optional component on the next run.
|
|
565
|
+
|
|
566
|
+
Inside an update function, use the worker helpers instead. They record structural changes without changing the
|
|
567
|
+
component snapshot currently being iterated:
|
|
568
|
+
|
|
569
|
+
```ts
|
|
570
|
+
import { addComponentWorker, removeComponentWorker } from '@daneren2005/shared-memory-ecs/worker';
|
|
571
|
+
|
|
572
|
+
if(equippedShieldGenerator && !components.shield) {
|
|
573
|
+
addComponentWorker(world, entityId, 'shield', { capacity: 100 }, callbacks);
|
|
574
|
+
} else if(!equippedShieldGenerator && components.shield) {
|
|
575
|
+
removeComponentWorker(entityId, 'shield', callbacks);
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
The main thread applies those commands after the worker run completes, emits the normal component-added or
|
|
580
|
+
component-removed event, and queues query deltas. The originating run therefore keeps a stable snapshot and all
|
|
581
|
+
systems see the new structure no later than their next run. Removing a component uses the same deferred-free
|
|
582
|
+
safety described below.
|
|
583
|
+
|
|
584
|
+
Worker-side addition needs two opt-ins because it allocates a block:
|
|
585
|
+
|
|
586
|
+
- Set `addsComponents: true` on the `EntityWorkerSystem`.
|
|
587
|
+
- Pass the component registry to that system's `createEntitySystemWorker(self, update, registry)` call.
|
|
588
|
+
|
|
589
|
+
`addComponentWorker` calls the definition's worker-safe `toBlock(config)` and cannot add the always-present
|
|
590
|
+
`entity` component or a `loadInFinishLoading` component. Worker-side removal needs no allocation opt-in.
|
|
442
591
|
|
|
443
592
|
### Freeing component memory safely
|
|
444
593
|
|
|
@@ -485,6 +634,35 @@ It is deferred to the same safe point as the component's own block (see above),
|
|
|
485
634
|
entity dies — so a system still mid-run over that memory can't see your resource released early. Reload calls
|
|
486
635
|
it without any `death` event, so it is the reliable place to avoid leaks when a world is reused.
|
|
487
636
|
|
|
637
|
+
### Reacting to death
|
|
638
|
+
|
|
639
|
+
To *react* when an entity dies — spawn drops at its position, credit a kill, play an effect — a component
|
|
640
|
+
definition can carry an optional `died(component, entity, world)`. It fires exactly once per death, on the main
|
|
641
|
+
thread, with the dying entity **still fully readable** (its blocks are only *deferred*-freed, never torn down
|
|
642
|
+
before the hook runs), for both a worker kill (`killEntityWorker`) and a main-thread `killEntity`:
|
|
643
|
+
|
|
644
|
+
```ts
|
|
645
|
+
const oreDepositDefinition: ComponentDefinition<OreDeposit, Int32Array, OreDepositConfig> = {
|
|
646
|
+
type: Int32Array,
|
|
647
|
+
size: 3,
|
|
648
|
+
loadProperties: ['oreAmount'],
|
|
649
|
+
toBlock(config) { /* ... */ },
|
|
650
|
+
attach(entity, memory, index) { /* ... */ },
|
|
651
|
+
died(component, entity, world) {
|
|
652
|
+
// The asteroid is still readable here, so spawn its drop where it was.
|
|
653
|
+
const { x, y } = entity.components.position!;
|
|
654
|
+
world.loadEntity({ type: 'Ore', x, y, amount: component.oreAmount });
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
The hook is engine-agnostic: attach death behavior to the component that represents it. A throwing `died` can't
|
|
660
|
+
abort the death — it is caught and surfaced as a `system-error` event (`phase: 'died'`) while the entity is still
|
|
661
|
+
removed. Unlike `free`, `died` fires **only** on a real death, never on `world.load()`/`clear()` teardown.
|
|
662
|
+
|
|
663
|
+
For a non-declarative hook, the world also emits `world.on('entity-died', entity)` at the same point (distinct
|
|
664
|
+
from `entity-removed`, which fires for every removal, reloads included).
|
|
665
|
+
|
|
488
666
|
### Reusing a world: `load` and `clear`
|
|
489
667
|
|
|
490
668
|
A world is meant to be reused rather than rebuilt. `world.load(config)` swaps in a fresh scenario — it removes
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { ComponentMap } from '../component-definition';
|
|
2
|
+
import type { EntityWorkerSystemCallbacks, EntityWorkerSystemWorld } from '../systems/entity-worker-system';
|
|
3
|
+
export default function addComponentWorker<C extends ComponentMap, K extends keyof C>(world: EntityWorkerSystemWorld, entityId: number, componentName: K, config: Record<string, unknown>, callbacks: EntityWorkerSystemCallbacks<C>): void;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { WorkerAllocator, WorkerCreateEntityConfig, WorkerCreatedEntity } from '../systems/
|
|
1
|
+
import type { WorkerAllocator, WorkerCreateEntityConfig, WorkerCreatedComponent, WorkerCreatedEntity } from '../systems/entity-worker-system';
|
|
2
|
+
import type { WorkerEntityClassRegistry } from '../entity-class';
|
|
2
3
|
export interface WorkerCreatableComponent {
|
|
3
4
|
loadProperties: Array<string>;
|
|
4
5
|
loadInFinishLoading?: boolean;
|
|
@@ -10,4 +11,5 @@ export type WorkerCreateRegistry = {
|
|
|
10
11
|
export type FactoryConfigs = {
|
|
11
12
|
[type: string]: Record<string, unknown>;
|
|
12
13
|
};
|
|
13
|
-
export declare function buildWorkerEntity(config: WorkerCreateEntityConfig, factoryConfigs: FactoryConfigs, registry: WorkerCreateRegistry, allocator: WorkerAllocator): WorkerCreatedEntity;
|
|
14
|
+
export declare function buildWorkerEntity(config: WorkerCreateEntityConfig, factoryConfigs: FactoryConfigs, registry: WorkerCreateRegistry, allocator: WorkerAllocator, classes?: WorkerEntityClassRegistry): WorkerCreatedEntity;
|
|
15
|
+
export declare function buildWorkerComponent(name: string, config: Record<string, unknown>, registry: WorkerCreateRegistry, allocator: WorkerAllocator): WorkerCreatedComponent;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export default function createEntityWorker(world:
|
|
1
|
+
import type { EntityWorkerSystemCallbacks, EntityWorkerSystemWorld, WorkerCreateEntityConfig } from '../systems/entity-worker-system';
|
|
2
|
+
export default function createEntityWorker(world: EntityWorkerSystemWorld, config: WorkerCreateEntityConfig, callbacks: EntityWorkerSystemCallbacks): void;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export default function killEntityWorker(entityId: number, components: EntityUpdateComponents, callbacks:
|
|
1
|
+
import type { EntityWorkerSystemCallbacks, EntityUpdateComponents } from '../systems/entity-worker-system';
|
|
2
|
+
export default function killEntityWorker(entityId: number, components: EntityUpdateComponents, callbacks: EntityWorkerSystemCallbacks): void;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { ComponentMap } from '../component-definition';
|
|
2
|
+
import type { EntityWorkerSystemCallbacks } from '../systems/entity-worker-system';
|
|
3
|
+
export default function removeComponentWorker<C extends ComponentMap, K extends keyof C>(entityId: number, componentName: K, callbacks: EntityWorkerSystemCallbacks<C>): void;
|
|
@@ -2,6 +2,7 @@ import type { TypedArrayConstructor } from '@daneren2005/shared-memory-objects/i
|
|
|
2
2
|
import type MemoryComponent from './memory-component';
|
|
3
3
|
import type { ComponentTypedArray } from './memory-component';
|
|
4
4
|
import type BaseEntity from './entity';
|
|
5
|
+
import type BaseWorld from './world';
|
|
5
6
|
import type { EntityComponent, EntityComponentConfig, EntityComponentSerialization } from './entity-component';
|
|
6
7
|
export interface BaseComponent {
|
|
7
8
|
index: number;
|
|
@@ -17,6 +18,7 @@ export interface ComponentDefinition<Component extends BaseComponent, T extends
|
|
|
17
18
|
attach(entity: BaseEntity, memory: MemoryComponent<T>, index: number): Component;
|
|
18
19
|
save?(component: Component): Serialization;
|
|
19
20
|
free?(component: Component): void;
|
|
21
|
+
died?(component: Component, entity: BaseEntity, world: BaseWorld): void;
|
|
20
22
|
}
|
|
21
23
|
export type RegisteredComponentDefinition<Component extends BaseComponent, T extends ComponentTypedArray = ComponentTypedArray, Config = any, Serialization = object> = ComponentDefinition<Component, T, Config, Serialization> & {
|
|
22
24
|
memoryComponent: MemoryComponent<T>;
|