@daneren2005/shared-memory-ecs 1.2.0 → 1.2.2
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 +119 -2
- package/dist/component-definition.d.ts +1 -1
- package/dist/create-component-worker-Bd1BTUkq.js +126 -0
- package/dist/create-component-worker-Bd1BTUkq.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +132 -238
- package/dist/index.js.map +1 -1
- package/dist/memory-component.d.ts +3 -1
- package/dist/systems/component-system.d.ts +2 -1
- package/dist/systems/entity-system.d.ts +1 -1
- package/dist/systems/system.d.ts +2 -1
- package/dist/systems/workers/component-worker-message.d.ts +4 -0
- package/dist/worker.d.ts +8 -0
- package/dist/worker.js +2 -0
- package/dist/world.d.ts +2 -5
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -20,7 +20,8 @@ fog of war, sub-classed entities, required components, etc).
|
|
|
20
20
|
needs the `type` plus the entity's serialization. `BaseWorld#loadEntity` always goes through the factory.
|
|
21
21
|
- **`BaseWorld<C>`** – builds one `MemoryComponent` per registered component (attached to that component's
|
|
22
22
|
definition as `world.registry[name].memoryComponent`) and runs systems. It is generic over your component
|
|
23
|
-
map `C`, so `world.registry`, `entity.components`, `setComponent`, etc. are fully typed.
|
|
23
|
+
map `C`, so `world.registry`, `entity.components`, `setComponent`, etc. are fully typed. Its entities live
|
|
24
|
+
in `world.entities`, a `Map` keyed by `eid` — see [Iterating entities](#iterating-entities).
|
|
24
25
|
- **`BaseEntity<C>`** – an `eid`, an optional `id`, and a bag of memory-backed components. It has no
|
|
25
26
|
direct property accessors and only loads/saves component data. Every entity has an `entity` component
|
|
26
27
|
whose `type` (a plain, worker-invisible string) records the factory template it was built from.
|
|
@@ -109,10 +110,68 @@ console.log(goblin.save()); // { type: 'goblin', health: 10 } - no templated max
|
|
|
109
110
|
world.loadEntity(goblin.save());
|
|
110
111
|
```
|
|
111
112
|
|
|
113
|
+
## Iterating entities
|
|
114
|
+
|
|
115
|
+
`world.entities` is a `Map` keyed by `eid`, not an array, and so is `entities` on `EntitySystem` and
|
|
116
|
+
`ComponentSystem`:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
world.entities.forEach(entity => { ... }); // in the order they were added
|
|
120
|
+
world.entities.get(eid); // same as world.getEntityByEid(eid)
|
|
121
|
+
world.entities.size;
|
|
122
|
+
for(const entity of world.entities.values()) { ... }
|
|
123
|
+
|
|
124
|
+
// For the array methods a Map does not have:
|
|
125
|
+
Array.from(world.entities.values()).filter(entity => !!entity.components.health);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
An entity leaves from anywhere in the middle of that collection — every death is one — and finding it in an
|
|
129
|
+
array meant a scan of the whole world, then a shift of everything after it, repeated for each system that
|
|
130
|
+
held it as well. A few thousand entities with a few dozen deaths a run made that the most expensive thing the
|
|
131
|
+
main thread did on a busy frame; a `Map` deletes in constant time. It also replaced the separate
|
|
132
|
+
`entitiesByEid` lookup, so there is only ever one collection to keep straight.
|
|
133
|
+
|
|
134
|
+
Iteration order is still insertion order, so `load` / `save` round-trip in the order they always did.
|
|
135
|
+
|
|
136
|
+
## Reading components on a hot path
|
|
137
|
+
|
|
138
|
+
`entity.components.health!.health` is the way to read a component, and for almost everything it is the right
|
|
139
|
+
one: it is typed, it is readable, and one read costs nothing worth measuring.
|
|
140
|
+
|
|
141
|
+
It is not free, though, and the cost shows up in exactly one situation — reading the same component off
|
|
142
|
+
*thousands* of entities, *every frame*. The accessor walks several objects to get there and ends in a getter
|
|
143
|
+
closure over the shared block, and because every entity has its own closure those call sites go megamorphic
|
|
144
|
+
once enough entities are alive, so none of it inlines. Measured over ~10,000 entities, reading four values per
|
|
145
|
+
entity cost **~950ns through the accessors against ~140ns straight off the block** — the difference between
|
|
146
|
+
10ms a frame and 1.5ms.
|
|
147
|
+
|
|
148
|
+
Where that matters, hold the block instead. It is the same memory the accessors read, so nothing changes about
|
|
149
|
+
what you get, and it is what the update functions already work on:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { TRANSFORM_X_INDEX } from '@daneren2005/shared-memory-physics';
|
|
153
|
+
|
|
154
|
+
// Resolve once, when whatever is doing the reading is set up.
|
|
155
|
+
const health = entity.components.health!;
|
|
156
|
+
const block = world.registry.health.memoryComponent.getBlock(health.index) as Int32Array;
|
|
157
|
+
|
|
158
|
+
// Then per frame, per entity:
|
|
159
|
+
block[HEALTH_INDEX];
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Two things come with that. The block is only valid while the component is: resolve it again if the component
|
|
163
|
+
can be removed and re-added, or hang it off something that dies with the entity. And it is indexed rather than
|
|
164
|
+
named, so the offsets have to be exported alongside the definition — which is why every component in the
|
|
165
|
+
physics library exports its `*_INDEX` constants.
|
|
166
|
+
|
|
167
|
+
Reach for this when a profile says to, not by default. A menu, a save, a system that touches a dozen entities:
|
|
168
|
+
use the accessors.
|
|
169
|
+
|
|
112
170
|
## ComponentSystem workers
|
|
113
171
|
|
|
114
172
|
`ComponentSystem` needs a `getWorker()` that returns a real `Worker`, and an `updateFunction`. Your
|
|
115
|
-
worker entry file calls `createComponentWorker(self, updateFunction)
|
|
173
|
+
worker entry file calls `createComponentWorker(self, updateFunction)`, importing it from the
|
|
174
|
+
[`/worker` subpath](#importing-in-workers) so the worker bundle stays small. When Web Workers or
|
|
116
175
|
`SharedArrayBuffer` are unavailable it transparently falls back to running the same update function on
|
|
117
176
|
the main thread. Attach any extra per-run data (the equivalent of the old faction/fog-of-war fields)
|
|
118
177
|
by overriding `addDataToWorld(world)`. Declare its shape with the `W` type parameter (an interface
|
|
@@ -131,6 +190,29 @@ class DamageSystem extends ComponentSystem<Components, { health: Int32Array }, D
|
|
|
131
190
|
}
|
|
132
191
|
```
|
|
133
192
|
|
|
193
|
+
### Importing in workers
|
|
194
|
+
|
|
195
|
+
Each worker entry file is bundled on its own, and a single-entry bundle cannot tree-shake this package's
|
|
196
|
+
barrel: importing `createComponentWorker` from `@daneren2005/shared-memory-ecs` drags the whole library -
|
|
197
|
+
`BaseWorld`, every system, their `@daneren2005/shared-memory-objects` dependencies - into the worker, even
|
|
198
|
+
though a worker never runs any of it (easily ~20kb of dead code per worker). Import worker-side helpers from
|
|
199
|
+
the `@daneren2005/shared-memory-ecs/worker` subpath instead. It exposes only what runs in a worker -
|
|
200
|
+
`createComponentWorker`, `createEntityWorker`, `killEntityWorker`, `DEAD_INDEX` (plus the worker-relevant
|
|
201
|
+
types) - so the bundle stays tiny:
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
// damage.worker.ts - the worker entry file
|
|
205
|
+
import { createComponentWorker } from '@daneren2005/shared-memory-ecs/worker';
|
|
206
|
+
import { damageUpdate } from './damage-update';
|
|
207
|
+
|
|
208
|
+
createComponentWorker(self, damageUpdate);
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The same applies to any module the worker file pulls in: an update function that calls `createEntityWorker`
|
|
212
|
+
or `killEntityWorker` should import them from `/worker` too. Type-only imports (`EntityUpdateFunction`,
|
|
213
|
+
`ComponentSystemWorld`, ...) can come from either path since types are erased, and main-thread code
|
|
214
|
+
(`ComponentSystem`, `BaseWorld`, `EntityFactory`, ...) keeps importing from the package root.
|
|
215
|
+
|
|
134
216
|
### Reporting back to the main thread
|
|
135
217
|
|
|
136
218
|
An update function runs on shared memory, so anything it writes is already visible on the main thread. What
|
|
@@ -156,6 +238,41 @@ no class instances. Nothing about the name or the args is checked against your c
|
|
|
156
238
|
event is the system's own concept rather than a component, so export both alongside the update function that
|
|
157
239
|
emits them.
|
|
158
240
|
|
|
241
|
+
### Reporting something that happens to everything, every run
|
|
242
|
+
|
|
243
|
+
Every callback above costs an event object per entity: an allocation in the worker, a structured clone of it,
|
|
244
|
+
an eid lookup on the main thread, and an emit on that entity. That is fine for a death or a hit, which happen
|
|
245
|
+
to a handful of entities a run. It is not fine for a move, which happens to nearly all of them, every run -
|
|
246
|
+
at ten thousand entities that is ten thousand objects and ten thousand emits a frame, and it can easily cost
|
|
247
|
+
more than the simulation it is reporting.
|
|
248
|
+
|
|
249
|
+
`emitSystemEvent` is the version of that with everything avoidable taken out. Name the event and give it an
|
|
250
|
+
entity id; the whole run arrives on the **system** as one call with one array of ids:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
// in the update function - just the id, once per entity that moved
|
|
254
|
+
callbacks.emitSystemEvent('position-updated', entityId);
|
|
255
|
+
|
|
256
|
+
// on the main thread - one call for the run, however many entities are in it
|
|
257
|
+
system.on('position-updated', (entityIds: Array<number>) => {
|
|
258
|
+
for(const eid of entityIds) {
|
|
259
|
+
const sprite = sprites.get(eid);
|
|
260
|
+
// The worker wrote the position into shared memory, so it is already here to read.
|
|
261
|
+
if(sprite) {
|
|
262
|
+
const transform = world.getEntityByEid(eid)?.components.transform;
|
|
263
|
+
...
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Nothing travels with the id on purpose. The blocks the update just wrote are shared memory, so the main
|
|
270
|
+
thread already holds the values - sending them along would only pay to copy what is already there. Reach for
|
|
271
|
+
`emitEntityEvent` when the thing you want to report is *not* in a component block, and for this when it is.
|
|
272
|
+
|
|
273
|
+
System events are dispatched before the per-entity events of the same run, so an entity a run both moved and
|
|
274
|
+
killed is still in the world when its move is reported.
|
|
275
|
+
|
|
159
276
|
## Measuring performance
|
|
160
277
|
|
|
161
278
|
`PerformanceTiming` watches a world and reports what running it costs. Hand it the world and it hooks itself
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TypedArrayConstructor } from '@daneren2005/shared-memory-objects';
|
|
1
|
+
import type { TypedArrayConstructor } from '@daneren2005/shared-memory-objects/interfaces/typed-array-constructor';
|
|
2
2
|
import type MemoryComponent from './memory-component';
|
|
3
3
|
import type { ComponentTypedArray } from './memory-component';
|
|
4
4
|
import type BaseEntity from './entity';
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
//#region src/systems/workers/apply-query-delta.ts
|
|
2
|
+
function e(e, t) {
|
|
3
|
+
if (t.removed.length) {
|
|
4
|
+
let n = new Set(t.removed);
|
|
5
|
+
e = e.filter((e) => !n.has(e.entityId));
|
|
6
|
+
}
|
|
7
|
+
if (t.added.length) {
|
|
8
|
+
let n = /* @__PURE__ */ new Map();
|
|
9
|
+
e.forEach((e, t) => n.set(e.entityId, t));
|
|
10
|
+
for (let r of t.added) {
|
|
11
|
+
let t = n.get(r.entityId);
|
|
12
|
+
t === void 0 ? (n.set(r.entityId, e.length), e.push(r)) : e[t] = r;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return e;
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/entity-component.ts
|
|
19
|
+
var t = 0, n = 1, r = {
|
|
20
|
+
type: Uint32Array,
|
|
21
|
+
size: 2,
|
|
22
|
+
loadProperties: ["type", "isStatic"],
|
|
23
|
+
load(e, t, n) {
|
|
24
|
+
let r = t.create([+!!n.dead, +!!n.isStatic]), i = t.getBlock(r);
|
|
25
|
+
return {
|
|
26
|
+
index: r,
|
|
27
|
+
type: n.type,
|
|
28
|
+
get dead() {
|
|
29
|
+
return i[0] === 1;
|
|
30
|
+
},
|
|
31
|
+
set dead(e) {
|
|
32
|
+
i[0] = +!!e;
|
|
33
|
+
},
|
|
34
|
+
get isStatic() {
|
|
35
|
+
return i[1] === 1;
|
|
36
|
+
},
|
|
37
|
+
set isStatic(e) {
|
|
38
|
+
i[1] = +!!e;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
save(e) {
|
|
43
|
+
let t = {};
|
|
44
|
+
return e.type && (t.type = e.type), e.dead && (t.dead = !0), t;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/actions/kill-entity-worker.ts
|
|
49
|
+
function i(e, t, n) {
|
|
50
|
+
let r = t.entity;
|
|
51
|
+
r && (r[0] = 1), n.entityDied(e);
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/actions/create-entity-worker.ts
|
|
55
|
+
function a(e, t) {
|
|
56
|
+
t.createEntity(e);
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/systems/workers/create-component-worker.ts
|
|
60
|
+
function o(t, n) {
|
|
61
|
+
let r = [], i = {};
|
|
62
|
+
t.onmessage = function(a) {
|
|
63
|
+
let o = a.data;
|
|
64
|
+
if (o.type === "init") s(t, { type: "loaded" });
|
|
65
|
+
else if (o.type === "run") {
|
|
66
|
+
let a = performance.now(), c = [], l = {}, u = [];
|
|
67
|
+
r = e(r, o.entities);
|
|
68
|
+
let d = {};
|
|
69
|
+
Object.entries(o.queries).forEach(([t, n]) => {
|
|
70
|
+
let r = e(i[t] ?? [], n);
|
|
71
|
+
i[t] = r, d[t] = r;
|
|
72
|
+
});
|
|
73
|
+
let f = {
|
|
74
|
+
entityComponentChanged(e, t, n, r) {
|
|
75
|
+
c.push({
|
|
76
|
+
entityId: e,
|
|
77
|
+
event: "component-property-updated",
|
|
78
|
+
args: [
|
|
79
|
+
t,
|
|
80
|
+
n,
|
|
81
|
+
r
|
|
82
|
+
]
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
emitEntityEvent(e, t, ...n) {
|
|
86
|
+
c.push({
|
|
87
|
+
entityId: e,
|
|
88
|
+
event: t,
|
|
89
|
+
args: n
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
emitSystemEvent(e, t) {
|
|
93
|
+
(l[e] ?? (l[e] = [])).push(t);
|
|
94
|
+
},
|
|
95
|
+
entityDied(e) {
|
|
96
|
+
c.push({
|
|
97
|
+
entityId: e,
|
|
98
|
+
event: "death",
|
|
99
|
+
args: []
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
createEntity(e) {
|
|
103
|
+
u.push(e);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
n.preRun && n.preRun(o.world, r, d, f), r.forEach((e) => {
|
|
107
|
+
n(o.world, e.entityId, e.components, d, f);
|
|
108
|
+
}), n.entityRemoved && o.entities.removed.forEach((e) => {
|
|
109
|
+
n.entityRemoved(o.world, e, f);
|
|
110
|
+
}), s(t, {
|
|
111
|
+
type: "run-complete",
|
|
112
|
+
runTime: performance.now() - a,
|
|
113
|
+
events: c,
|
|
114
|
+
systemEvents: l,
|
|
115
|
+
created: u
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function s(e, t) {
|
|
121
|
+
e.postMessage(t);
|
|
122
|
+
}
|
|
123
|
+
//#endregion
|
|
124
|
+
export { n as a, t as i, a as n, r as o, i as r, e as s, o as t };
|
|
125
|
+
|
|
126
|
+
//# sourceMappingURL=create-component-worker-Bd1BTUkq.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-component-worker-Bd1BTUkq.js","names":[],"sources":["../src/systems/workers/apply-query-delta.ts","../src/entity-component.ts","../src/actions/kill-entity-worker.ts","../src/actions/create-entity-worker.ts","../src/systems/workers/create-component-worker.ts"],"sourcesContent":["import type { EntityUpdateComponents, QueryDelta, UpdateEntityConfigObject } from '../component-system';\n\n// A worker keeps one persistent list per query and applies the delta each run carries: drop the entities that\n// left, then upsert the ones that joined (or whose component blocks changed). Because the main thread only\n// sends changes, a steady-state run - where membership is unchanged - carries empty arrays and this returns the\n// existing list untouched with no allocation, which is the whole point of the delta protocol: we stop\n// re-transmitting (and re-cloning) every entity id every single frame.\n//\n// The list is returned (rather than mutated in place) because removals rebuild it via filter; callers must store\n// the returned reference back as their persistent list.\nexport function applyQueryDelta<T extends EntityUpdateComponents>(\n\tlist: Array<UpdateEntityConfigObject<T>>,\n\tdelta: QueryDelta<T>,\n): Array<UpdateEntityConfigObject<T>> {\n\tif(delta.removed.length) {\n\t\tconst removedSet = new Set(delta.removed);\n\t\tlist = list.filter(entry => !removedSet.has(entry.entityId));\n\t}\n\n\tif(delta.added.length) {\n\t\t// Map existing members so a re-added entity (its component set changed) replaces its entry in place\n\t\t// instead of being duplicated; genuinely new entities are appended.\n\t\tconst indexByEid = new Map<number, number>();\n\t\tlist.forEach((entry, index) => indexByEid.set(entry.entityId, index));\n\n\t\tfor(let entity of delta.added) {\n\t\t\tconst existing = indexByEid.get(entity.entityId);\n\t\t\tif(existing !== undefined) {\n\t\t\t\tlist[existing] = entity;\n\t\t\t} else {\n\t\t\t\tindexByEid.set(entity.entityId, list.length);\n\t\t\t\tlist.push(entity);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn list;\n}\n","import type { ComponentDefinition } from './component-definition';\n\n// The one component every entity is required to have. `dead` and `isStatic` are boolean flags stored in\n// the shared-memory block so a worker that pulls the `entity` component into its query can read them too.\n// `type` is a plain string that names the EntityFactory template this entity was built from; it lives off\n// to the side (not in shared memory), so - unlike `dead`/`isStatic` - it is NOT visible to workers.\n// Games that need a stable string id (or any other per-entity data) add their own component for it.\nexport interface EntityComponent {\n\tindex: number\n\ttype: string\n\tdead: boolean\n\tisStatic: boolean\n}\n\n// The defining config for the entity component. `type` is required - every entity is created from a factory\n// template, named by its type - while `isStatic` is an optional flag supplied up front.\nexport interface EntityComponentConfig {\n\ttype: string\n\tisStatic?: boolean\n}\n\n// Runtime-derived state plus `type`: `dead` is only meaningful once the entity is alive, and `type` is kept\n// so a save can be re-expanded through the factory. `isStatic` is intentionally absent - it comes back from\n// the type's template config, not the save.\nexport interface EntityComponentSerialization {\n\ttype?: string\n\tdead?: boolean\n}\n\nexport const DEAD_INDEX = 0;\nexport const STATIC_INDEX = 1;\n\nexport const entityDefinition: ComponentDefinition<EntityComponent, Uint32Array, EntityComponentConfig, EntityComponentSerialization> = {\n\ttype: Uint32Array,\n\tsize: 2,\n\t// The entity component is always loaded by BaseEntity's constructor, so this never actually gates\n\t// loading; it documents the defining config props and keeps it consistent with game components.\n\tloadProperties: ['type', 'isStatic'],\n\tload(entity, memory, config) {\n\t\tconst index = memory.create([config.dead ? 1 : 0, config.isStatic ? 1 : 0]);\n\t\tconst block = memory.getBlock(index);\n\n\t\treturn {\n\t\t\tindex,\n\t\t\t// Plain (non-memory) property, so it is main-thread only and not readable from workers.\n\t\t\ttype: config.type,\n\t\t\tget dead() {\n\t\t\t\treturn block[DEAD_INDEX] === 1;\n\t\t\t},\n\t\t\tset dead(value: boolean) {\n\t\t\t\tblock[DEAD_INDEX] = value ? 1 : 0;\n\t\t\t},\n\t\t\tget isStatic() {\n\t\t\t\treturn block[STATIC_INDEX] === 1;\n\t\t\t},\n\t\t\tset isStatic(value: boolean) {\n\t\t\t\tblock[STATIC_INDEX] = value ? 1 : 0;\n\t\t\t},\n\t\t};\n\t},\n\tsave(component) {\n\t\t// `type` + `dead` are serialized; `isStatic` (and the rest of the template) come back from the type's\n\t\t// config on reload, so they are deliberately left out.\n\t\tconst config: EntityComponentSerialization = {};\n\t\tif(component.type) {\n\t\t\tconfig.type = component.type;\n\t\t}\n\t\tif(component.dead) {\n\t\t\tconfig.dead = true;\n\t\t}\n\n\t\treturn config;\n\t},\n};\n","import type { ComponentTypedArray } from '../memory-component';\nimport type { ComponentSystemCallbacks, EntityUpdateComponents } from '../systems/component-system';\nimport { DEAD_INDEX } from '../entity-component';\n\n// The worker-thread counterpart of killEntity. Called from inside a component system's update function,\n// it flags the entity dead directly in its shared-memory block and reports the death back to the main\n// thread through the callbacks so the world can run the same cleanup killEntity would. The entity\n// component must be part of the system's query for its block to be available here.\nexport default function killEntityWorker(entityId: number, components: EntityUpdateComponents, callbacks: ComponentSystemCallbacks): void {\n\tconst block = (components as { entity?: ComponentTypedArray }).entity;\n\tif(block) {\n\t\tblock[DEAD_INDEX] = 1;\n\t}\n\n\tcallbacks.entityDied(entityId);\n}\n","import type { ComponentSystemCallbacks, CreateEntityConfig } from '../systems/component-system';\n\n// The worker-thread counterpart of loadEntity. Called from inside a component system's update function, it\n// can't build the entity itself (eid generation, shared-memory allocation, and factory expansion all live on\n// the main thread), so it buffers the flat config through the callbacks. The main thread creates the entity\n// via world.loadEntity once the run completes, meaning it first exists on the following frame.\nexport default function createEntityWorker(config: CreateEntityConfig, callbacks: ComponentSystemCallbacks): void {\n\tcallbacks.createEntity(config);\n}\n","import type ComponentWorkerMessage from './component-worker-message';\nimport type { EntityEvent, SystemEvents } from './component-worker-message';\nimport type { ComponentMap } from '../../component-definition';\nimport type { ComponentSystemCallbacks, ComponentSystemWorld, EntityQueryComponents, EntityUpdateComponents, EntityUpdateFunction, QueryDelta, UpdateEntityConfigObject } from '../component-system';\nimport { applyQueryDelta } from './apply-query-delta';\n\n// The slice of the Web Worker global scope createComponentWorker actually touches. Worker files pass the\n// real `self` (e.g. `createComponentWorker(self, fn)`); passing it explicitly also lets test runners that\n// inject `self` as a module local rather than a true global - like @vitest/web-worker - drive the worker.\nexport interface ComponentWorkerScope {\n\tonmessage: ((e: MessageEvent) => void) | null\n\tpostMessage(message: ComponentWorkerMessage): void\n}\n\n// Entry point a game's worker file calls with its update function. It wires up `scope.onmessage`,\n// caches component blocks by entity id (so subsequent runs only need the id), and posts results back.\nexport default function createComponentWorker<\n\tC extends ComponentMap,\n\tT extends EntityUpdateComponents<C>,\n\tW extends ComponentSystemWorld = ComponentSystemWorld,\n>(scope: ComponentWorkerScope, updateFunction: EntityUpdateFunction<C, T, W>) {\n\t// The worker's persistent iteration lists. The main thread now sends only membership changes, so these are\n\t// carried across runs and mutated by the deltas each run brings (see applyQueryDelta).\n\tlet entities: Array<UpdateEntityConfigObject<T>> = [];\n\tconst queryEntities: { [key: string]: Array<UpdateEntityConfigObject<T>> } = {};\n\n\tscope.onmessage = function(e) {\n\t\tconst message = e.data as ComponentWorkerMessage<W>;\n\n\t\tif(message.type === 'init') {\n\t\t\tpostMessageTyped(scope, {\n\t\t\t\ttype: 'loaded',\n\t\t\t});\n\t\t} else if(message.type === 'run') {\n\t\t\tconst start = performance.now();\n\t\t\tlet entityEvents: Array<EntityEvent> = [];\n\t\t\tlet systemEvents: SystemEvents = {};\n\t\t\tlet createdEntities: Array<Record<string, unknown>> = [];\n\n\t\t\tentities = applyQueryDelta(entities, message.entities as QueryDelta<T>);\n\n\t\t\tlet queries: EntityQueryComponents<C> = {};\n\t\t\tObject.entries(message.queries).forEach(([queryKey, delta]) => {\n\t\t\t\tconst list = applyQueryDelta(queryEntities[queryKey] ?? [], delta as QueryDelta<T>);\n\t\t\t\tqueryEntities[queryKey] = list;\n\t\t\t\tqueries[queryKey] = list;\n\t\t\t});\n\n\t\t\tlet callbacks: ComponentSystemCallbacks<C> = {\n\t\t\t\tentityComponentChanged<K extends keyof C, P extends keyof C[K]>(entityId: number, componentName: K, prop: P, value: C[K][P]) {\n\t\t\t\t\tentityEvents.push({\n\t\t\t\t\t\tentityId,\n\t\t\t\t\t\tevent: 'component-property-updated',\n\t\t\t\t\t\targs: [componentName, prop, value],\n\t\t\t\t\t});\n\t\t\t\t},\n\t\t\t\temitEntityEvent(entityId: number, event: string, ...args: Array<unknown>) {\n\t\t\t\t\tentityEvents.push({\n\t\t\t\t\t\tentityId,\n\t\t\t\t\t\tevent,\n\t\t\t\t\t\targs,\n\t\t\t\t\t});\n\t\t\t\t},\n\t\t\t\temitSystemEvent(event: string, entityId: number) {\n\t\t\t\t\t// The list per event name is only made the first time that event comes up in a run, so a system\n\t\t\t\t\t// that has an event it rarely reports does not pay an empty array for it every run.\n\t\t\t\t\t(systemEvents[event] ?? (systemEvents[event] = [])).push(entityId);\n\t\t\t\t},\n\t\t\t\tentityDied(entityId: number) {\n\t\t\t\t\tentityEvents.push({\n\t\t\t\t\t\tentityId,\n\t\t\t\t\t\tevent: 'death',\n\t\t\t\t\t\targs: [],\n\t\t\t\t\t});\n\t\t\t\t},\n\t\t\t\tcreateEntity(config: Record<string, unknown>) {\n\t\t\t\t\tcreatedEntities.push(config);\n\t\t\t\t},\n\t\t\t};\n\t\t\tif(updateFunction.preRun) {\n\t\t\t\tupdateFunction.preRun(message.world, entities, queries, callbacks);\n\t\t\t}\n\n\t\t\tentities.forEach(entity => {\n\t\t\t\tupdateFunction(message.world, entity.entityId, entity.components, queries, callbacks);\n\t\t\t});\n\n\t\t\t// applyQueryDelta already dropped the removed entities from the persistent lists above; the hook just\n\t\t\t// lets the update function react to entities that left the system's main query.\n\t\t\tif(updateFunction.entityRemoved) {\n\t\t\t\tmessage.entities.removed.forEach(entityId => {\n\t\t\t\t\tupdateFunction.entityRemoved!(message.world, entityId, callbacks);\n\t\t\t\t});\n\t\t\t}\n\t\t\tconst runTime = performance.now() - start;\n\n\t\t\tpostMessageTyped(scope, {\n\t\t\t\ttype: 'run-complete',\n\t\t\t\trunTime,\n\t\t\t\tevents: entityEvents,\n\t\t\t\tsystemEvents,\n\t\t\t\tcreated: createdEntities,\n\t\t\t});\n\t\t}\n\t};\n}\n\nfunction postMessageTyped(scope: ComponentWorkerScope, message: ComponentWorkerMessage) {\n\tscope.postMessage(message);\n}\n"],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -24,4 +24,4 @@ export { default as WebWorker } from './systems/workers/web-worker';
|
|
|
24
24
|
export { default as ComponentWebWorker } from './systems/workers/component-web-worker';
|
|
25
25
|
export { default as createComponentWorker } from './systems/workers/create-component-worker';
|
|
26
26
|
export type { default as ComponentWorkerMessage } from './systems/workers/component-worker-message';
|
|
27
|
-
export type { EntityEvent } from './systems/workers/component-worker-message';
|
|
27
|
+
export type { EntityEvent, SystemEvents } from './systems/workers/component-worker-message';
|