@driftengine/entities 3.61.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/src/system.ts ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * A system, and the view that holds it to what it declared.
3
+ *
4
+ * **The declaration earns its place by being enforced, not by being documentation.** A system that
5
+ * writes a component it did not declare is refused naming both, because the whole value of the
6
+ * declaration is what can be derived from it — parallel grouping, ordering, profiling — and
7
+ * anything derived from a lie is worse than nothing derived at all.
8
+ *
9
+ * `writes` implies `reads`. A system that can write a component and not read it can only overwrite
10
+ * it, which nothing wants, and making every author write both would be a rule that is obeyed by
11
+ * copying.
12
+ *
13
+ * ---
14
+ *
15
+ * ## Structural change, and the one that is deferred
16
+ *
17
+ * `create` and `add` happen immediately. Neither can corrupt a walk in progress: a handle comes
18
+ * from the allocator and touches no store, and appending to a dense array only grows it. **A newly
19
+ * added entity may be visited in the same pass** if the query is walking the store it was added to,
20
+ * which is surprising and is stated rather than prevented — preventing it means snapshotting the
21
+ * size at the start of the walk, and a system that spawns and expects to see its own spawn is as
22
+ * likely as one that does not.
23
+ *
24
+ * **`destroy` and `remove` are deferred**, and that is not symmetry for its own sake. A removal
25
+ * swaps the last dense entry into the hole, so removing mid-walk moves an entity the cursor has
26
+ * already passed into a position it has already visited — and that entity is **never seen**,
27
+ * silently, and only whichever one happened to be last. Deferring is the smallest fix that is
28
+ * correct; the alternative is a tombstone per entry and a compaction pass, which costs every read.
29
+ */
30
+ import type { Entity } from './entity.ts';
31
+ import type { QueryCursor } from './query.ts';
32
+ import type { ComponentType } from './store.ts';
33
+ import type { ComponentView } from './view.ts';
34
+ import type { World } from './world.ts';
35
+
36
+ export interface SystemView {
37
+ query(a: ComponentType, b?: ComponentType, c?: ComponentType, d?: ComponentType): QueryCursor;
38
+ /**
39
+ * The live columns of a component, checked against this system's declarations first.
40
+ *
41
+ * `forWriting` decides which check: a caller that will only read takes a readable view, and one
42
+ * that will write takes a writable one. A compiler that can see the body supplies it; a caller
43
+ * writing this by hand says what it is about to do.
44
+ */
45
+ view(type: ComponentType, forWriting: boolean): ComponentView;
46
+ read(entity: Entity, type: ComponentType, field: string): unknown;
47
+ write(entity: Entity, type: ComponentType, field: string, value: unknown): void;
48
+ has(entity: Entity, type: ComponentType): boolean;
49
+ create(): Entity;
50
+ add(entity: Entity, type: ComponentType, values?: Readonly<Record<string, unknown>>): void;
51
+ /** Deferred to after this system runs. See the header. */
52
+ destroy(entity: Entity): void;
53
+ /** Deferred to after this system runs. See the header. */
54
+ remove(entity: Entity, type: ComponentType): void;
55
+ }
56
+
57
+ export interface SystemDefinition {
58
+ readonly name: string;
59
+ readonly reads?: readonly ComponentType[];
60
+ readonly writes?: readonly ComponentType[];
61
+ /**
62
+ * Run once every this many fixed steps. Absent means every step.
63
+ *
64
+ * **A stride and never a rate in seconds.** A rate in wall-clock time leaves the determinism
65
+ * contract on its first dropped frame: two runs of the same recording would run the system a
66
+ * different number of times. A tick count is the same in every replay of the same input.
67
+ */
68
+ readonly everyTicks?: number;
69
+ /** Systems this one must run after, by name. A cycle here is refused at build. */
70
+ readonly after?: readonly string[];
71
+ run(view: SystemView): void;
72
+ }
73
+
74
+ /**
75
+ * The view one system sees, reused across ticks.
76
+ *
77
+ * One per system rather than one per run, because a system runs every tick for the life of a world
78
+ * and an object per tick per system is the allocation this package refuses everywhere else.
79
+ */
80
+ export class BoundSystem implements SystemView {
81
+ readonly definition: SystemDefinition;
82
+ private readonly world: World;
83
+ private readonly readable = new Set<number>();
84
+ private readonly writable = new Set<number>();
85
+ /** Entities to destroy after this system returns. Reused, drained with a count. */
86
+ private readonly pendingDestroy: Entity[] = [];
87
+ private destroyCount = 0;
88
+ private readonly pendingRemoveEntity: Entity[] = [];
89
+ private readonly pendingRemoveType: ComponentType[] = [];
90
+ private removeCount = 0;
91
+
92
+ constructor(world: World, definition: SystemDefinition) {
93
+ this.world = world;
94
+ this.definition = definition;
95
+ for (const type of definition.writes ?? []) {
96
+ this.writable.add(type.id);
97
+ /* `writes` implies `reads` — see the header. */
98
+ this.readable.add(type.id);
99
+ }
100
+ for (const type of definition.reads ?? []) this.readable.add(type.id);
101
+ }
102
+
103
+ run(): void {
104
+ this.definition.run(this);
105
+ this.drain();
106
+ }
107
+
108
+ query(a: ComponentType, b?: ComponentType, c?: ComponentType, d?: ComponentType): QueryCursor {
109
+ this.requireReadable(a);
110
+ if (b !== undefined) this.requireReadable(b);
111
+ if (c !== undefined) this.requireReadable(c);
112
+ if (d !== undefined) this.requireReadable(d);
113
+ return this.world.query(a, b, c, d);
114
+ }
115
+
116
+ /**
117
+ * The live columns of a component, checked first.
118
+ *
119
+ * **This is the seam where one mistake disables every declaration in this file.** Every other
120
+ * accessor here checks before it answers, and a view that went straight to the store would hand
121
+ * out the raw columns of a component this system never declared — after which `reads` and
122
+ * `writes` are reachable *around* rather than through, and nothing in the schedule derived from
123
+ * them means anything.
124
+ *
125
+ * **What this costs** is that a caller must say in advance whether it will write, before it has
126
+ * written anything. **What would make it wrong** is a view narrow enough to enforce per field,
127
+ * which would be a second object per field and the allocation this package refuses.
128
+ */
129
+ view(type: ComponentType, forWriting: boolean): ComponentView {
130
+ if (forWriting) this.requireWritable(type);
131
+ else this.requireReadable(type);
132
+ return this.world.view(type);
133
+ }
134
+
135
+ read(entity: Entity, type: ComponentType, field: string): unknown {
136
+ this.requireReadable(type);
137
+ return this.world.read(entity, type, field);
138
+ }
139
+
140
+ write(entity: Entity, type: ComponentType, field: string, value: unknown): void {
141
+ this.requireWritable(type);
142
+ this.world.write(entity, type, field, value);
143
+ }
144
+
145
+ has(entity: Entity, type: ComponentType): boolean {
146
+ this.requireReadable(type);
147
+ return this.world.has(entity, type);
148
+ }
149
+
150
+ create(): Entity {
151
+ return this.world.create();
152
+ }
153
+
154
+ add(entity: Entity, type: ComponentType, values: Readonly<Record<string, unknown>> = {}): void {
155
+ this.requireWritable(type);
156
+ this.world.add(entity, type, values);
157
+ }
158
+
159
+ destroy(entity: Entity): void {
160
+ if (this.destroyCount < this.pendingDestroy.length)
161
+ this.pendingDestroy[this.destroyCount] = entity;
162
+ else this.pendingDestroy.push(entity);
163
+ this.destroyCount += 1;
164
+ }
165
+
166
+ remove(entity: Entity, type: ComponentType): void {
167
+ this.requireWritable(type);
168
+ if (this.removeCount < this.pendingRemoveEntity.length) {
169
+ this.pendingRemoveEntity[this.removeCount] = entity;
170
+ this.pendingRemoveType[this.removeCount] = type;
171
+ } else {
172
+ this.pendingRemoveEntity.push(entity);
173
+ this.pendingRemoveType.push(type);
174
+ }
175
+ this.removeCount += 1;
176
+ }
177
+
178
+ /** Apply what was deferred. Drained by count, never by emptying — see `IMPROVEMENTS.md`. */
179
+ private drain(): void {
180
+ for (let i = 0; i < this.removeCount; i += 1) {
181
+ this.world.remove(
182
+ this.pendingRemoveEntity[i] as Entity,
183
+ this.pendingRemoveType[i] as ComponentType,
184
+ );
185
+ }
186
+ this.removeCount = 0;
187
+ for (let i = 0; i < this.destroyCount; i += 1)
188
+ this.world.destroy(this.pendingDestroy[i] as Entity);
189
+ this.destroyCount = 0;
190
+ }
191
+
192
+ private requireReadable(type: ComponentType): void {
193
+ if (this.readable.has(type.id)) return;
194
+ throw new Error(
195
+ `\`${this.definition.name}\` reads \`${type.name}\` and did not declare it. The schedule is ` +
196
+ 'derived from what systems declare, so one that touches more than it says makes every ' +
197
+ 'derivation wrong — add it to `reads`, or to `writes` if it also writes it.',
198
+ );
199
+ }
200
+
201
+ private requireWritable(type: ComponentType): void {
202
+ if (this.writable.has(type.id)) return;
203
+ throw new Error(
204
+ `\`${this.definition.name}\` writes \`${type.name}\` and did not declare it. Add it to ` +
205
+ '`writes`; over-declaring is conservative and only costs schedule width.',
206
+ );
207
+ }
208
+ }
package/src/view.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * A live handle on a component's columns, for a caller that indexes them directly.
3
+ *
4
+ * **A holder of this object holds the object and never an array.** A column grows by
5
+ * reallocation, so a hoisted `Float64Array` goes stale the first time anything adds a component —
6
+ * and `system.ts` makes `add` immediate on purpose, so that happens *during* a walk. The failure
7
+ * would be a write into an array nothing reads any more: no error, no wrong type, just a value
8
+ * that never arrives.
9
+ *
10
+ * So the store refreshes this object's properties after a reallocation rather than handing back a
11
+ * new object. A caller that hoisted it at the top of a loop is still correct at the bottom.
12
+ *
13
+ * **What this costs** is one live object per store, and a property load on every field access
14
+ * rather than a captured local. **What would make it wrong** is a caller holding one past its
15
+ * world's disposal — which is why a generated script reaches one through a call per loop rather
16
+ * than caching it across frames.
17
+ *
18
+ * ---
19
+ *
20
+ * ## Absence is a column, not a value
21
+ *
22
+ * An optional field has two entries here: the value column under its own name, and a `Uint8Array`
23
+ * under `presenceColumn(field)`. A reader that consults only the value column reads whatever the
24
+ * slot last held, which for a cleared field is the value it was cleared from.
25
+ */
26
+
27
+ /** Every array shape a column can be. `unknown[]` is the boxed case — today, `String`. */
28
+ export type ViewColumn =
29
+ | Int8Array
30
+ | Int16Array
31
+ | Int32Array
32
+ | Uint8Array
33
+ | Uint16Array
34
+ | Uint32Array
35
+ | Float32Array
36
+ | Float64Array
37
+ | unknown[];
38
+
39
+ /**
40
+ * A component's storage, addressable by field name.
41
+ *
42
+ * `sparse` maps an entity's *index* — not its handle — to a position in every column here. Taking
43
+ * the index from a handle is `entity % 2 ** 26`, which is what a caller that cannot import
44
+ * `entityIndex` writes inline.
45
+ */
46
+ export interface ComponentView {
47
+ sparse: Int32Array;
48
+ [field: string]: ViewColumn;
49
+ }
50
+
51
+ /**
52
+ * Point a view at the arrays a store holds now.
53
+ *
54
+ * Called when a view is first asked for and again after every reallocation. It rewrites the
55
+ * existing object rather than returning a new one, because the object identity is the contract: a
56
+ * caller hoisted it, and handing back a second object would leave the first one pointing at
57
+ * storage nothing reads.
58
+ *
59
+ * It lives here rather than on the store so that the two halves of this mechanism — what a view
60
+ * *is* and what keeps one current — are one file. **What that costs** is passing the store's two
61
+ * internals in. **What would make it wrong** is a store whose columns are not a map, at which point
62
+ * this signature is the thing to change rather than the call sites.
63
+ */
64
+ export function refreshView(
65
+ view: ComponentView,
66
+ sparse: Int32Array,
67
+ columns: ReadonlyMap<string, ViewColumn>,
68
+ ): void {
69
+ view.sparse = sparse;
70
+ for (const [field, column] of columns) view[field] = column;
71
+ }
package/src/world.ts ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * A world: the entities, the stores keyed by component type, and the cursor pool.
3
+ *
4
+ * **What a world adds over a bag of stores is that an entity which is gone is gone from all of
5
+ * them.** Without that, destroying something leaves its components behind, a query goes on yielding
6
+ * it, and a scene serialises a handle nothing can reach — three symptoms of one missing sweep.
7
+ */
8
+ import {
9
+ type AllocatorSnapshot,
10
+ type Entity,
11
+ EntityAllocator,
12
+ type EntityAllocatorOptions,
13
+ createAllocatorSnapshot,
14
+ } from './entity.ts';
15
+ import { type CursorHost, QueryCursor } from './query.ts';
16
+ import {
17
+ ComponentStore,
18
+ type ComponentType,
19
+ type StoreSnapshot,
20
+ createStoreSnapshot,
21
+ } from './store.ts';
22
+ import type { ComponentView } from './view.ts';
23
+
24
+ /**
25
+ * A world's whole state at one instant, for a rewind. **Not a save file**, and the two are not
26
+ * interchangeable.
27
+ *
28
+ * `serializeWorld` in `scene.ts` writes a scene: it carries every component's schema so a load can
29
+ * migrate across a change, keys values by stable field id so a renamed field still loads, and
30
+ * *creates* entities so a scene can be loaded into a world that already holds things. Every one of
31
+ * those properties is wrong here. A rewind needs the handles it had — an input recorded for entity
32
+ * 4,194,307 means nothing if the restore hands that slot a new generation — and it needs to cost
33
+ * nothing per tick, which rules out an object per component per entity.
34
+ *
35
+ * | | `SerializedScene` | `WorldSnapshot` |
36
+ * |---|---|---|
37
+ * | Keyed by | stable field id | column position |
38
+ * | Handles | remapped, all of them change | preserved, generations included |
39
+ * | On load | creates, appending | overwrites in place |
40
+ * | Migration | yes | no |
41
+ * | Allocates | per entity, per component | nothing after the first save |
42
+ * | Goes on a wire | yes | never |
43
+ *
44
+ * **A rewind covers every store the world holds, and takes no list of types.** That is the other
45
+ * half of the difference: `serializeWorld` is handed the types because a scene is a decision about
46
+ * what to save, and a rewind is not a decision at all. A component type left out of a restore is
47
+ * state that survived the rewind, which is the definition of a desync.
48
+ */
49
+ export interface WorldSnapshot {
50
+ readonly allocator: AllocatorSnapshot;
51
+ /** By `ComponentType.id`. Holds a slot for every store the world had when this was written. */
52
+ readonly stores: Map<number, StoreSnapshot>;
53
+ }
54
+
55
+ /**
56
+ * An empty slot, to be filled by `World.saveInto` and reused for the life of a rewind ring.
57
+ *
58
+ * Its arrays are zero-length and grow to the world's shape on the first save, so a ring of eight
59
+ * costs eight small objects up front and allocates nothing on the ticks that matter.
60
+ */
61
+ export function createWorldSnapshot(): WorldSnapshot {
62
+ return { allocator: createAllocatorSnapshot(), stores: new Map() };
63
+ }
64
+
65
+ export class World implements CursorHost {
66
+ private readonly entities: EntityAllocator;
67
+ private readonly stores = new Map<number, ComponentStore>();
68
+ /**
69
+ * Cursors not currently in a loop.
70
+ *
71
+ * A pool rather than a fresh cursor, because a query runs per frame per system and an object per
72
+ * query is the allocation this whole design refuses. It grows to the deepest nesting a consumer
73
+ * reaches and never shrinks, which for any real workload is a handful.
74
+ */
75
+ private readonly idle: QueryCursor[] = [];
76
+ private inUse = 0;
77
+
78
+ constructor(options: EntityAllocatorOptions = {}) {
79
+ this.entities = new EntityAllocator(options);
80
+ }
81
+
82
+ get liveCount(): number {
83
+ return this.entities.liveCount;
84
+ }
85
+
86
+ /** How many cursors are inside a loop. For a consumer looking at a leak rather than inferring one. */
87
+ get cursorsInUse(): number {
88
+ return this.inUse;
89
+ }
90
+
91
+ create(): Entity {
92
+ return this.entities.create();
93
+ }
94
+
95
+ alive(entity: Entity): boolean {
96
+ return this.entities.alive(entity);
97
+ }
98
+
99
+ /**
100
+ * Destroy an entity and sweep it out of every store.
101
+ *
102
+ * Every store, not the ones it is known to be in: a world keeps no per-entity list of its
103
+ * components, because that list is a second description of what the stores already know and it
104
+ * would be the thing that goes stale. The sweep is one `remove` per registered type, which is a
105
+ * number of component *types* rather than of entities.
106
+ */
107
+ destroy(entity: Entity): boolean {
108
+ if (!this.entities.alive(entity)) return false;
109
+ for (const store of this.stores.values()) store.remove(entity);
110
+ return this.entities.destroy(entity);
111
+ }
112
+
113
+ /** The store for a component type, made on first ask. */
114
+ store(type: ComponentType): ComponentStore {
115
+ const existing = this.stores.get(type.id);
116
+ if (existing !== undefined) return existing;
117
+ const made = new ComponentStore(type);
118
+ this.stores.set(type.id, made);
119
+ return made;
120
+ }
121
+
122
+ /**
123
+ * The live columns of a component, for a caller indexing them directly.
124
+ *
125
+ * **Unenforced, and that is not an oversight**: a world outside a system has no declarations to
126
+ * enforce against. Inside one, go through `SystemView.view`, which checks first — see the note
127
+ * there for what happens if anything routes around it.
128
+ */
129
+ view(type: ComponentType): ComponentView {
130
+ return this.store(type).view();
131
+ }
132
+
133
+ add(entity: Entity, type: ComponentType, values: Readonly<Record<string, unknown>> = {}): void {
134
+ if (!this.entities.alive(entity)) {
135
+ throw new Error(
136
+ `entity ${entity} is not alive, so \`${type.name}\` has nowhere to go. Storing it anyway ` +
137
+ 'would leave a component keyed to a handle nothing can reach, which a query would skip ' +
138
+ 'and a scene would serialise.',
139
+ );
140
+ }
141
+ this.store(type).add(entity, values);
142
+ }
143
+
144
+ remove(entity: Entity, type: ComponentType): boolean {
145
+ return this.store(type).remove(entity);
146
+ }
147
+
148
+ has(entity: Entity, type: ComponentType): boolean {
149
+ return this.store(type).has(entity);
150
+ }
151
+
152
+ read(entity: Entity, type: ComponentType, field: string): unknown {
153
+ return this.store(type).read(entity, field);
154
+ }
155
+
156
+ write(entity: Entity, type: ComponentType, field: string, value: unknown): void {
157
+ this.store(type).write(entity, field, value);
158
+ }
159
+
160
+ /**
161
+ * Everything that has all of these components.
162
+ *
163
+ * The cursor is borrowed and is given back when the loop ends — including when it `break`s, which
164
+ * `for…of` reports through the iterator's `return`. A caller that keeps the result past its loop
165
+ * gets whatever the next query writes into it; see `query.ts`.
166
+ */
167
+ query(a: ComponentType, b?: ComponentType, c?: ComponentType, d?: ComponentType): QueryCursor {
168
+ const cursor = this.idle.pop() ?? new QueryCursor(this);
169
+ this.inUse += 1;
170
+ return cursor.open(a, b, c, d);
171
+ }
172
+
173
+ /** Called by a cursor when its loop ends, including when the loop breaks. Not for a consumer. */
174
+ releaseCursor(cursor: QueryCursor): void {
175
+ this.inUse -= 1;
176
+ this.idle.push(cursor);
177
+ }
178
+
179
+ /**
180
+ * Write this world's whole state into a slot. See `WorldSnapshot` for why this is not a save.
181
+ *
182
+ * **Every store, and the slot keeps one for each.** A store is never removed from a world, so a
183
+ * later restore always finds the stores this save described still present — which is why nothing
184
+ * here has to record a component *type*, only its id.
185
+ */
186
+ saveInto(slot: WorldSnapshot): void {
187
+ this.entities.saveInto(slot.allocator);
188
+ for (const [id, store] of this.stores) {
189
+ let into = slot.stores.get(id);
190
+ if (into === undefined) {
191
+ into = createStoreSnapshot();
192
+ slot.stores.set(id, into);
193
+ }
194
+ store.saveInto(into);
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Put this world back to a saved slot.
200
+ *
201
+ * **A store the slot does not mention is emptied rather than skipped.** Such a store was created
202
+ * after the save — by a query, or by something adding a component the world had never seen — and
203
+ * leaving its contents alone would leave components attached to entities the restored allocator
204
+ * says do not exist. Emptying it is what the snapshot actually claims.
205
+ *
206
+ * The cursor pool is untouched: a borrowed cursor belongs to a loop that is running, and a
207
+ * rewind happens between ticks.
208
+ */
209
+ loadFrom(slot: WorldSnapshot): void {
210
+ this.entities.loadFrom(slot.allocator);
211
+ for (const [id, store] of this.stores) {
212
+ store.loadFrom(slot.stores.get(id) ?? EMPTY_STORE);
213
+ }
214
+ }
215
+ }
216
+
217
+ /**
218
+ * The slot a store restores from when the snapshot never knew about it: count zero.
219
+ *
220
+ * Shared and never written to — `ComponentStore.loadFrom` only reads its slot — so this costs one
221
+ * object for the process instead of one per rewind.
222
+ */
223
+ const EMPTY_STORE: StoreSnapshot = createStoreSnapshot();