@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/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +93 -0
- package/dist/entity.d.ts +136 -0
- package/dist/entity.js +201 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +9 -0
- package/dist/prefab.d.ts +31 -0
- package/dist/prefab.js +47 -0
- package/dist/query.d.ts +81 -0
- package/dist/query.js +140 -0
- package/dist/scene.d.ts +59 -0
- package/dist/scene.js +136 -0
- package/dist/schedule.d.ts +44 -0
- package/dist/schedule.js +161 -0
- package/dist/store.d.ts +183 -0
- package/dist/store.js +453 -0
- package/dist/system.d.ts +116 -0
- package/dist/system.js +124 -0
- package/dist/view.d.ts +52 -0
- package/dist/view.js +43 -0
- package/dist/world.d.ts +121 -0
- package/dist/world.js +161 -0
- package/package.json +57 -0
- package/src/entity.ts +256 -0
- package/src/index.ts +51 -0
- package/src/prefab.ts +80 -0
- package/src/query.ts +184 -0
- package/src/scene.ts +165 -0
- package/src/schedule.ts +204 -0
- package/src/store.ts +556 -0
- package/src/system.ts +208 -0
- package/src/view.ts +71 -0
- package/src/world.ts +223 -0
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();
|