@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/entity.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an entity *is*, and the allocator that hands them out.
|
|
3
|
+
*
|
|
4
|
+
* An entity is a handle and nothing else — no object, no class, no fields. What it *has* lives in
|
|
5
|
+
* component stores keyed by it, which is the whole arrangement, and it is why an entity can be a
|
|
6
|
+
* number in a typed array rather than something a frame has to chase a pointer to.
|
|
7
|
+
*
|
|
8
|
+
* ---
|
|
9
|
+
*
|
|
10
|
+
* ## The generation, and why it may not wrap
|
|
11
|
+
*
|
|
12
|
+
* **An index freed and reused makes every old reference to it silently valid** — pointing at a
|
|
13
|
+
* different thing, of a different kind, with no error anywhere. That is the failure the generation
|
|
14
|
+
* exists to prevent, so the only question worth deciding is how long before it wraps and brings the
|
|
15
|
+
* failure back.
|
|
16
|
+
*
|
|
17
|
+
* The conventional answer is a packed 32-bit handle, twenty-four bits of index and eight of
|
|
18
|
+
* generation. It makes a slot's 257th reuse hand back a handle **identical** to its first. Here the
|
|
19
|
+
* split is 26 and 27, combined arithmetically rather than by shifting, because a shift is 32-bit in
|
|
20
|
+
* JavaScript and a double is exact to 53: sixty-seven million entities, and 134 million reuses of
|
|
21
|
+
* each slot before a repeat.
|
|
22
|
+
*
|
|
23
|
+
* **26 + 27 is the whole 53-bit budget, with nothing spare**, so the largest handle is
|
|
24
|
+
* `Number.MAX_SAFE_INTEGER` itself. A third field in a handle, or one more bit of either, stops the
|
|
25
|
+
* arithmetic being exact — and the failure then is a handle that compares equal to a different one,
|
|
26
|
+
* which is the same hole the generation was added to close. A test asserts the top handle rather
|
|
27
|
+
* than leaving that to be discovered.
|
|
28
|
+
*
|
|
29
|
+
* **The cost is that a handle is eight bytes rather than four**, because it lives in a
|
|
30
|
+
* `Float64Array` and not a `Uint32Array`. That is paid on entity *lists* rather than on component
|
|
31
|
+
* data, which is where the bytes actually are.
|
|
32
|
+
*
|
|
33
|
+
* **What would make this wrong** is a consumer with more than 67 million live entities, at which
|
|
34
|
+
* point the split moves and every stored scene has to be rewritten — which is why the two numbers
|
|
35
|
+
* are here, once, and every site goes through `packEntity` rather than writing the arithmetic.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** One past the highest index a handle can carry: 2^26, or 67,108,864 entities. */
|
|
39
|
+
export const ENTITY_INDEX_CEILING = 67_108_864;
|
|
40
|
+
|
|
41
|
+
/** One past the highest generation: 2^27, or 134,217,728 reuses of one slot. */
|
|
42
|
+
export const ENTITY_GENERATION_CEILING = 134_217_728;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* An entity handle.
|
|
46
|
+
*
|
|
47
|
+
* A plain number so it stores in a typed array and compares with `===`. It is opaque: reading the
|
|
48
|
+
* index or the generation out of one is what `entityIndex` and `entityGeneration` are for, and code
|
|
49
|
+
* that does the arithmetic itself is code that has to be found when the split moves.
|
|
50
|
+
*/
|
|
51
|
+
export type Entity = number;
|
|
52
|
+
|
|
53
|
+
export function packEntity(index: number, generation: number): Entity {
|
|
54
|
+
return index + generation * ENTITY_INDEX_CEILING;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function entityIndex(entity: Entity): number {
|
|
58
|
+
return entity % ENTITY_INDEX_CEILING;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function entityGeneration(entity: Entity): number {
|
|
62
|
+
return Math.floor(entity / ENTITY_INDEX_CEILING);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* An allocator's whole position, for a rewind.
|
|
67
|
+
*
|
|
68
|
+
* **Reused between saves rather than made per save.** A rollback loop takes one of these sixty
|
|
69
|
+
* times a second, so the arrays are grown to the allocator's own capacity once and then written
|
|
70
|
+
* over; the fields are mutable for that reason and not because a caller should touch them.
|
|
71
|
+
*
|
|
72
|
+
* The free queue is held as a count and a typed array instead of the `number[]` the allocator
|
|
73
|
+
* keeps, so a save is a memcpy. **Its order is part of the state**, not an incidental detail: the
|
|
74
|
+
* queue is first-in-first-out precisely so slot wear spreads, and restoring the same slots in a
|
|
75
|
+
* different order hands out different handles on the next create.
|
|
76
|
+
*/
|
|
77
|
+
export interface AllocatorSnapshot {
|
|
78
|
+
generations: Uint32Array;
|
|
79
|
+
live: Uint8Array;
|
|
80
|
+
free: Int32Array;
|
|
81
|
+
freeCount: number;
|
|
82
|
+
highWater: number;
|
|
83
|
+
aliveCount: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createAllocatorSnapshot(): AllocatorSnapshot {
|
|
87
|
+
return {
|
|
88
|
+
generations: new Uint32Array(0),
|
|
89
|
+
live: new Uint8Array(0),
|
|
90
|
+
free: new Int32Array(0),
|
|
91
|
+
freeCount: 0,
|
|
92
|
+
highWater: 0,
|
|
93
|
+
aliveCount: 0,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface EntityAllocatorOptions {
|
|
98
|
+
/**
|
|
99
|
+
* How many slots this allocator may ever hand out.
|
|
100
|
+
*
|
|
101
|
+
* Present so a test can reach the refusal without allocating sixty-seven million slots. A
|
|
102
|
+
* consumer has no reason to set it, and setting it low does not save memory — the arrays grow on
|
|
103
|
+
* demand either way.
|
|
104
|
+
*/
|
|
105
|
+
readonly ceiling?: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Slots, their generations, and a queue of the ones that are free.
|
|
110
|
+
*
|
|
111
|
+
* **The free list is first-in-first-out**, and that is what makes the generation ceiling a number
|
|
112
|
+
* about the pool rather than about one slot. A stack hands the same slot straight back, so one
|
|
113
|
+
* spawn-and-destroy pair in a loop burns that slot's whole range while every other slot sits
|
|
114
|
+
* unused. A queue spreads the wear.
|
|
115
|
+
*
|
|
116
|
+
* The cost is that a freed slot is not reused until every other freed slot has been, so a workload
|
|
117
|
+
* that churns a handful of entities touches more of the arrays than a stack would. That is a cache
|
|
118
|
+
* argument against a correctness one, and correctness wins.
|
|
119
|
+
*/
|
|
120
|
+
export class EntityAllocator {
|
|
121
|
+
/** Generation per slot. Index `i` holds the generation of whatever lives at index `i` now. */
|
|
122
|
+
private generations: Uint32Array = new Uint32Array(64);
|
|
123
|
+
/** Whether slot `i` is currently handed out. */
|
|
124
|
+
private live: Uint8Array = new Uint8Array(64);
|
|
125
|
+
/** Freed slots, oldest first. A plain array: a queue that grows, drained from the front. */
|
|
126
|
+
private readonly free: number[] = [];
|
|
127
|
+
/** How far into the arrays this allocator has ever reached. */
|
|
128
|
+
private highWater = 0;
|
|
129
|
+
private alive_ = 0;
|
|
130
|
+
private readonly ceiling: number;
|
|
131
|
+
|
|
132
|
+
constructor(options: EntityAllocatorOptions = {}) {
|
|
133
|
+
this.ceiling = options.ceiling ?? ENTITY_INDEX_CEILING;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
get liveCount(): number {
|
|
137
|
+
return this.alive_;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
create(): Entity {
|
|
141
|
+
/* `shift` is O(n) on a large array and this is not a per-frame path — an entity is created when
|
|
142
|
+
something enters the world. A ring buffer is the fix if a profile ever says so. */
|
|
143
|
+
const recycled = this.free.shift();
|
|
144
|
+
if (recycled !== undefined) {
|
|
145
|
+
this.live[recycled] = 1;
|
|
146
|
+
this.alive_ += 1;
|
|
147
|
+
return packEntity(recycled, this.generations[recycled] as number);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (this.highWater >= this.ceiling) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`this world has handed out ${this.ceiling} entities, which is its index ceiling. Nothing ` +
|
|
153
|
+
'is wrapped or reused: a handle beyond the ceiling would repeat one that is already ' +
|
|
154
|
+
'live, and answering the wrong entity is worse than refusing.',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const index = this.highWater;
|
|
159
|
+
this.highWater += 1;
|
|
160
|
+
this.grow(index + 1);
|
|
161
|
+
this.live[index] = 1;
|
|
162
|
+
this.alive_ += 1;
|
|
163
|
+
return packEntity(index, this.generations[index] as number);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Free an entity's slot. `false` when the handle was already stale, which is not an error.
|
|
168
|
+
*
|
|
169
|
+
* A caller destroying something twice is a caller that lost track, and the honest answer is that
|
|
170
|
+
* there was nothing to destroy — the same shape `patchModule` and `Scope.leave` use.
|
|
171
|
+
*/
|
|
172
|
+
destroy(entity: Entity): boolean {
|
|
173
|
+
if (!this.alive(entity)) return false;
|
|
174
|
+
const index = entityIndex(entity);
|
|
175
|
+
this.live[index] = 0;
|
|
176
|
+
/*
|
|
177
|
+
* The generation moves on **destroy**, not on the next create, so a handle stops being live the
|
|
178
|
+
* moment it is destroyed rather than the moment somebody happens to take its slot. Moving it
|
|
179
|
+
* later would leave a window in which a destroyed handle still answered `alive`.
|
|
180
|
+
*/
|
|
181
|
+
const next = ((this.generations[index] as number) + 1) % ENTITY_GENERATION_CEILING;
|
|
182
|
+
this.generations[index] = next;
|
|
183
|
+
this.free.push(index);
|
|
184
|
+
this.alive_ -= 1;
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
alive(entity: Entity): boolean {
|
|
189
|
+
const index = entityIndex(entity);
|
|
190
|
+
if (index >= this.highWater) return false;
|
|
191
|
+
return this.live[index] === 1 && this.generations[index] === entityGeneration(entity);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Copy this allocator's position into a slot, growing the slot's arrays if it has not held one
|
|
196
|
+
* this large before.
|
|
197
|
+
*
|
|
198
|
+
* **`highWater` bounds every copy**, because slots beyond it have never been handed out and their
|
|
199
|
+
* generations are zero on both sides. That is what keeps the cost proportional to the world rather
|
|
200
|
+
* than to the arrays' capacity.
|
|
201
|
+
*/
|
|
202
|
+
saveInto(slot: AllocatorSnapshot): void {
|
|
203
|
+
const reach = this.highWater;
|
|
204
|
+
if (slot.generations.length < reach) slot.generations = new Uint32Array(reach);
|
|
205
|
+
if (slot.live.length < reach) slot.live = new Uint8Array(reach);
|
|
206
|
+
if (slot.free.length < this.free.length) slot.free = new Int32Array(this.free.length);
|
|
207
|
+
|
|
208
|
+
for (let i = 0; i < reach; i++) {
|
|
209
|
+
slot.generations[i] = this.generations[i] as number;
|
|
210
|
+
slot.live[i] = this.live[i] as number;
|
|
211
|
+
}
|
|
212
|
+
for (let i = 0; i < this.free.length; i++) slot.free[i] = this.free[i] as number;
|
|
213
|
+
|
|
214
|
+
slot.freeCount = this.free.length;
|
|
215
|
+
slot.highWater = reach;
|
|
216
|
+
slot.aliveCount = this.alive_;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Put this allocator back to a saved position.
|
|
221
|
+
*
|
|
222
|
+
* **Slots between the saved `highWater` and the current one are cleared rather than left**, and
|
|
223
|
+
* that is the whole difference between a restore and a partial one. An entity created after the
|
|
224
|
+
* save has a live slot the save knows nothing about; leaving it live would leave an entity in the
|
|
225
|
+
* world that the snapshot says does not exist, and its handle would keep answering `alive`.
|
|
226
|
+
*/
|
|
227
|
+
loadFrom(slot: AllocatorSnapshot): void {
|
|
228
|
+
this.grow(slot.highWater);
|
|
229
|
+
for (let i = 0; i < slot.highWater; i++) {
|
|
230
|
+
this.generations[i] = slot.generations[i] as number;
|
|
231
|
+
this.live[i] = slot.live[i] as number;
|
|
232
|
+
}
|
|
233
|
+
for (let i = slot.highWater; i < this.highWater; i++) {
|
|
234
|
+
this.generations[i] = 0;
|
|
235
|
+
this.live[i] = 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this.free.length = 0;
|
|
239
|
+
for (let i = 0; i < slot.freeCount; i++) this.free.push(slot.free[i] as number);
|
|
240
|
+
|
|
241
|
+
this.highWater = slot.highWater;
|
|
242
|
+
this.alive_ = slot.aliveCount;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private grow(needed: number): void {
|
|
246
|
+
if (needed <= this.generations.length) return;
|
|
247
|
+
let size = this.generations.length;
|
|
248
|
+
while (size < needed) size *= 2;
|
|
249
|
+
const generations = new Uint32Array(size);
|
|
250
|
+
generations.set(this.generations);
|
|
251
|
+
this.generations = generations;
|
|
252
|
+
const live = new Uint8Array(size);
|
|
253
|
+
live.set(this.live);
|
|
254
|
+
this.live = live;
|
|
255
|
+
}
|
|
256
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
|
|
2
|
+
/**
|
|
3
|
+
* The entity model's public surface.
|
|
4
|
+
*
|
|
5
|
+
* **This package imports no other engine package**, which is a stronger rule than the one every
|
|
6
|
+
* package obeys and is the reason it can be depended on by anything. An entity model that knew what
|
|
7
|
+
* a `Transform` was would be an entity model that knew it had a scene — and the whole argument for
|
|
8
|
+
* declared reads and writes is that a declaration is about a component *id* rather than about what
|
|
9
|
+
* the component means.
|
|
10
|
+
*
|
|
11
|
+
* It declares no component types at all, for the same reason. A consumer writes the ones its world
|
|
12
|
+
* needs, or takes them from whichever package owns the subsystem.
|
|
13
|
+
*
|
|
14
|
+
* The one dependency is `driftscript`, for `Schema` and `migrate` — 406 bytes gzipped, measured.
|
|
15
|
+
* That is the design's spine rather than a convenience: storage layout, serialization, migration
|
|
16
|
+
* and a future inspector all read **one** description of a component, so there is no second one to
|
|
17
|
+
* keep in sync.
|
|
18
|
+
*/
|
|
19
|
+
export type { ComponentStoreOptions, ComponentType, StoreSnapshot } from './store.ts';
|
|
20
|
+
export { ComponentStore, createStoreSnapshot, defineComponent } from './store.ts';
|
|
21
|
+
|
|
22
|
+
export type { ComponentView, ViewColumn } from './view.ts';
|
|
23
|
+
|
|
24
|
+
export type { CursorHost } from './query.ts';
|
|
25
|
+
export { QueryCursor } from './query.ts';
|
|
26
|
+
|
|
27
|
+
export type { WorldSnapshot } from './world.ts';
|
|
28
|
+
export { World, createWorldSnapshot } from './world.ts';
|
|
29
|
+
|
|
30
|
+
export type { SystemDefinition, SystemView } from './system.ts';
|
|
31
|
+
export { BoundSystem } from './system.ts';
|
|
32
|
+
|
|
33
|
+
export type { Schedule } from './schedule.ts';
|
|
34
|
+
export { buildSchedule, runSchedule } from './schedule.ts';
|
|
35
|
+
|
|
36
|
+
export type { Prefab } from './prefab.ts';
|
|
37
|
+
export { definePrefab, instantiate } from './prefab.ts';
|
|
38
|
+
|
|
39
|
+
export type { LoadResult, SerializedScene } from './scene.ts';
|
|
40
|
+
export { deserializeWorld, serializeWorld } from './scene.ts';
|
|
41
|
+
|
|
42
|
+
export type { AllocatorSnapshot, Entity, EntityAllocatorOptions } from './entity.ts';
|
|
43
|
+
export {
|
|
44
|
+
ENTITY_GENERATION_CEILING,
|
|
45
|
+
ENTITY_INDEX_CEILING,
|
|
46
|
+
EntityAllocator,
|
|
47
|
+
createAllocatorSnapshot,
|
|
48
|
+
entityGeneration,
|
|
49
|
+
entityIndex,
|
|
50
|
+
packEntity,
|
|
51
|
+
} from './entity.ts';
|
package/src/prefab.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A prefab is a **value**, and instantiating it writes that value into a fresh entity.
|
|
3
|
+
*
|
|
4
|
+
* **There is no link back**, and that is a decision rather than a limitation. A live link is what an
|
|
5
|
+
* *editor* wants — change the prefab and every instance changes — and it costs every instance a
|
|
6
|
+
* record of which fields it has overridden, checked on every write. Track K is what needs that, and
|
|
7
|
+
* it can add the record without changing what a prefab is.
|
|
8
|
+
*
|
|
9
|
+
* Overrides are applied at instantiation and are not remembered for the same reason: an override
|
|
10
|
+
* that was remembered would be half of that record, doing none of its job.
|
|
11
|
+
*/
|
|
12
|
+
import type { Entity } from './entity.ts';
|
|
13
|
+
import type { ComponentType } from './store.ts';
|
|
14
|
+
import type { World } from './world.ts';
|
|
15
|
+
|
|
16
|
+
export interface Prefab {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly components: readonly (readonly [ComponentType, Readonly<Record<string, unknown>>])[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function definePrefab(
|
|
22
|
+
name: string,
|
|
23
|
+
components: readonly (readonly [ComponentType, Readonly<Record<string, unknown>>])[],
|
|
24
|
+
): Prefab {
|
|
25
|
+
return { name, components };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Make one.
|
|
30
|
+
*
|
|
31
|
+
* Overrides are keyed by component **name**, because that is what a caller has in front of them —
|
|
32
|
+
* a `ComponentType` key would mean holding the type to override a field of it, which is exactly the
|
|
33
|
+
* import a scene file or an editor panel does not have.
|
|
34
|
+
*
|
|
35
|
+
* An override naming a component the prefab does not carry is **refused**. Adding it instead would
|
|
36
|
+
* make a typo a silent extra component, and adding nothing would make a typo a silent no-op; the
|
|
37
|
+
* only answer that cannot be mistaken for working is to say so.
|
|
38
|
+
*/
|
|
39
|
+
export function instantiate(
|
|
40
|
+
world: World,
|
|
41
|
+
prefab: Prefab,
|
|
42
|
+
overrides: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {},
|
|
43
|
+
): Entity {
|
|
44
|
+
const named = new Set(prefab.components.map(([type]) => type.name));
|
|
45
|
+
for (const name of Object.keys(overrides)) {
|
|
46
|
+
if (named.has(name)) continue;
|
|
47
|
+
throw new Error(
|
|
48
|
+
`\`${prefab.name}\` has no \`${name}\` to override. It has ${[...named].join(', ')}. An ` +
|
|
49
|
+
'override for something absent is a typo either way — added silently, or ignored silently.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const entity = world.create();
|
|
54
|
+
for (const [type, values] of prefab.components) {
|
|
55
|
+
const override = overrides[type.name];
|
|
56
|
+
/*
|
|
57
|
+
* **Spread would apply a key whose value is `undefined`, which is not an override.** A caller
|
|
58
|
+
* building one from optional data — `{ hp: config.hp }` where `config.hp` may be absent — means
|
|
59
|
+
* "leave it alone", and a spread means "set it to nothing": the component is then written with
|
|
60
|
+
* `undefined` where a number belongs. Measured elsewhere in this repository as the same shape
|
|
61
|
+
* taking a camera's arm to `NaN` on its first step. Nothing here can see it, because this
|
|
62
|
+
* repository does not run `exactOptionalPropertyTypes`.
|
|
63
|
+
*/
|
|
64
|
+
world.add(entity, type, override === undefined ? values : applyOverride(values, override));
|
|
65
|
+
}
|
|
66
|
+
return entity;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `values` with every *defined* key of `override` applied over it. */
|
|
70
|
+
function applyOverride(
|
|
71
|
+
values: Readonly<Record<string, unknown>>,
|
|
72
|
+
override: Readonly<Record<string, unknown>>,
|
|
73
|
+
): Record<string, unknown> {
|
|
74
|
+
const merged: Record<string, unknown> = { ...values };
|
|
75
|
+
for (const key of Object.keys(override)) {
|
|
76
|
+
const value = override[key];
|
|
77
|
+
if (value !== undefined) merged[key] = value;
|
|
78
|
+
}
|
|
79
|
+
return merged;
|
|
80
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Iterating the entities that have a set of components, without allocating.
|
|
3
|
+
*
|
|
4
|
+
* **An iterator allocated per frame is the per-frame allocation `AGENTS.md` forbids**, arriving
|
|
5
|
+
* through the most-used call in the package. So a cursor is its own iterator, returns a **reused**
|
|
6
|
+
* result record, and comes from a pool the world owns — the same shape the render graph's arena
|
|
7
|
+
* uses, and for the same reason.
|
|
8
|
+
*
|
|
9
|
+
* The cost is that **a query result may not outlive the loop it was made in**. A caller that stores
|
|
10
|
+
* one gets whatever the next query writes into it. That is stated here rather than discovered, and
|
|
11
|
+
* it is the bargain the arena makes too.
|
|
12
|
+
*
|
|
13
|
+
* ---
|
|
14
|
+
*
|
|
15
|
+
* ## The walk is over the smallest set
|
|
16
|
+
*
|
|
17
|
+
* A query over three components walks one dense array and tests membership in the other two, so the
|
|
18
|
+
* work is bounded by the *smallest* of them. Walking the first named instead would make
|
|
19
|
+
* `query(Position, Dying)` cost the whole world when two entities are dying.
|
|
20
|
+
*
|
|
21
|
+
* **The order is not sorted and is not promised.** It follows the chosen dense array, whose order is
|
|
22
|
+
* insertion modified by swap-removal — deterministic given the same history, which is what a replay
|
|
23
|
+
* needs, and not a stable property of the set. A consumer needing a stable order sorts by entity,
|
|
24
|
+
* and the fact that it is a choice is why this says so.
|
|
25
|
+
*/
|
|
26
|
+
import type { Entity } from './entity.ts';
|
|
27
|
+
import type { ComponentStore, ComponentType } from './store.ts';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a cursor needs from the world it belongs to.
|
|
31
|
+
*
|
|
32
|
+
* An interface rather than the `World` class, because a cursor importing the world and the world
|
|
33
|
+
* importing the cursor is a cycle — and because what a cursor actually needs is two functions.
|
|
34
|
+
*/
|
|
35
|
+
export interface CursorHost {
|
|
36
|
+
store(type: ComponentType): ComponentStore;
|
|
37
|
+
releaseCursor(cursor: QueryCursor): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What `next` hands back. One object, rewritten — see the header. */
|
|
41
|
+
interface Step {
|
|
42
|
+
value: Entity;
|
|
43
|
+
done: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class QueryCursor implements Iterator<Entity>, Iterable<Entity> {
|
|
47
|
+
/**
|
|
48
|
+
* Stores every yielded entity must be in, including the one being walked.
|
|
49
|
+
*
|
|
50
|
+
* **Reused with a count rather than emptied**, and that is not a micro-optimisation. Setting
|
|
51
|
+
* `length = 0` and pushing again makes V8 re-grow the backing store, which allocates — measured:
|
|
52
|
+
* half a million `length = 0` and two pushes collected as often as a control allocating two
|
|
53
|
+
* objects per iteration, while writing in place collected nothing. The whole point of a pooled
|
|
54
|
+
* cursor is undone by emptying its arrays.
|
|
55
|
+
*/
|
|
56
|
+
private readonly required: ComponentStore[] = [];
|
|
57
|
+
private requiredCount = 0;
|
|
58
|
+
/** Stores no yielded entity may be in. Same arrangement, same reason. */
|
|
59
|
+
private readonly excluded: ComponentStore[] = [];
|
|
60
|
+
private excludedCount = 0;
|
|
61
|
+
private walking: ComponentStore | null = null;
|
|
62
|
+
private at = 0;
|
|
63
|
+
private readonly step: Step = { value: 0, done: false };
|
|
64
|
+
private host: CursorHost | null = null;
|
|
65
|
+
private open_ = false;
|
|
66
|
+
|
|
67
|
+
constructor(host: CursorHost) {
|
|
68
|
+
this.host = host;
|
|
69
|
+
this.owner = host;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private readonly owner: CursorHost;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Configure and take ownership.
|
|
76
|
+
*
|
|
77
|
+
* **Four component types rather than a rest parameter**, because `...types` allocates an array on
|
|
78
|
+
* every call — once per system per frame, which is exactly the hot path this file exists for.
|
|
79
|
+
* Four covers every query in the corpus; a fifth is `.with()`, which is one more call and no
|
|
80
|
+
* array either.
|
|
81
|
+
*/
|
|
82
|
+
open(a: ComponentType, b?: ComponentType, c?: ComponentType, d?: ComponentType): this {
|
|
83
|
+
this.requiredCount = 0;
|
|
84
|
+
this.excludedCount = 0;
|
|
85
|
+
this.require(a);
|
|
86
|
+
if (b !== undefined) this.require(b);
|
|
87
|
+
if (c !== undefined) this.require(c);
|
|
88
|
+
if (d !== undefined) this.require(d);
|
|
89
|
+
this.walking = null;
|
|
90
|
+
this.at = 0;
|
|
91
|
+
this.host = this.owner;
|
|
92
|
+
this.open_ = true;
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Require a component without yielding anything about it. */
|
|
97
|
+
with(type: ComponentType): this {
|
|
98
|
+
this.require(type);
|
|
99
|
+
return this;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Exclude everything that has this component. */
|
|
103
|
+
without(type: ComponentType): this {
|
|
104
|
+
const store = this.owner.store(type);
|
|
105
|
+
if (this.excludedCount < this.excluded.length) this.excluded[this.excludedCount] = store;
|
|
106
|
+
else this.excluded.push(store);
|
|
107
|
+
this.excludedCount += 1;
|
|
108
|
+
return this;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private require(type: ComponentType): void {
|
|
112
|
+
const store = this.owner.store(type);
|
|
113
|
+
/* Written in place where the slot exists; pushed only the first time the cursor is asked for a
|
|
114
|
+
query this wide, so a pool of cursors settles at the widest query a consumer makes. */
|
|
115
|
+
if (this.requiredCount < this.required.length) this.required[this.requiredCount] = store;
|
|
116
|
+
else this.required.push(store);
|
|
117
|
+
this.requiredCount += 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
[Symbol.iterator](): Iterator<Entity> {
|
|
121
|
+
/*
|
|
122
|
+
* The cursor is its own iterator. Returning a fresh object here would allocate one per loop,
|
|
123
|
+
* which is the whole thing this file exists to avoid — and it is what makes `for…of` over a
|
|
124
|
+
* cursor twice at once wrong, which the pool is what stops.
|
|
125
|
+
*/
|
|
126
|
+
return this;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
next(): IteratorResult<Entity> {
|
|
130
|
+
if (!this.open_) return this.step.done ? this.step : this.finish();
|
|
131
|
+
if (this.walking === null) this.chooseSmallest();
|
|
132
|
+
const walking = this.walking;
|
|
133
|
+
if (walking === null) return this.finish();
|
|
134
|
+
|
|
135
|
+
const dense = walking.dense;
|
|
136
|
+
for (; this.at < walking.size; this.at += 1) {
|
|
137
|
+
const entity = dense[this.at] as Entity;
|
|
138
|
+
if (!this.matches(entity, walking)) continue;
|
|
139
|
+
this.at += 1;
|
|
140
|
+
this.step.value = entity;
|
|
141
|
+
this.step.done = false;
|
|
142
|
+
return this.step;
|
|
143
|
+
}
|
|
144
|
+
return this.finish();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Called by `for…of` when a loop breaks, which is what stops an abandoned cursor leaking. */
|
|
148
|
+
return(): IteratorResult<Entity> {
|
|
149
|
+
return this.finish();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private matches(entity: Entity, walking: ComponentStore): boolean {
|
|
153
|
+
for (let i = 0; i < this.requiredCount; i += 1) {
|
|
154
|
+
const store = this.required[i] as ComponentStore;
|
|
155
|
+
if (store !== walking && !store.has(entity)) return false;
|
|
156
|
+
}
|
|
157
|
+
for (let i = 0; i < this.excludedCount; i += 1) {
|
|
158
|
+
if ((this.excluded[i] as ComponentStore).has(entity)) return false;
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private chooseSmallest(): void {
|
|
164
|
+
let smallest: ComponentStore | null = null;
|
|
165
|
+
for (let i = 0; i < this.requiredCount; i += 1) {
|
|
166
|
+
const store = this.required[i] as ComponentStore;
|
|
167
|
+
if (smallest === null || store.size < smallest.size) smallest = store;
|
|
168
|
+
}
|
|
169
|
+
this.walking = smallest;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private finish(): IteratorResult<Entity> {
|
|
173
|
+
this.walking = null;
|
|
174
|
+
if (this.open_) {
|
|
175
|
+
this.open_ = false;
|
|
176
|
+
/* Through the host rather than a closure handed in at `open`: a closure per query is an
|
|
177
|
+
allocation per system per frame, which is the thing this file refuses. */
|
|
178
|
+
this.owner.releaseCursor(this);
|
|
179
|
+
}
|
|
180
|
+
this.step.value = 0;
|
|
181
|
+
this.step.done = true;
|
|
182
|
+
return this.step;
|
|
183
|
+
}
|
|
184
|
+
}
|