@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/dist/store.js
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { entityIndex } from './entity.js';
|
|
2
|
+
import { refreshView } from './view.js';
|
|
3
|
+
/**
|
|
4
|
+
* The presence column's name for an optional field.
|
|
5
|
+
*
|
|
6
|
+
* A suffix rather than a parallel map, because `grow`, `remove` and the view all already walk
|
|
7
|
+
* `this.columns` — and a second map would be one more thing each of them has to remember. What it
|
|
8
|
+
* gives up is a field literally named `of$present`, which the language refuses at declaration and
|
|
9
|
+
* which `$` makes unwritable in a `.drs` file anyway. **What would make it wrong** is a component
|
|
10
|
+
* that is mostly optional, where a bitset over all of its optional fields beats a byte each — a
|
|
11
|
+
* change to how presence is stored, not to what an option is, so it moves nothing a save holds.
|
|
12
|
+
*/
|
|
13
|
+
export const presenceColumn = (field) => `${field}$present`;
|
|
14
|
+
/**
|
|
15
|
+
* `option:f64` → `f64`, and `undefined` for a type that is not an option.
|
|
16
|
+
*
|
|
17
|
+
* A prefix rather than a structured type because a schema's field type is a *key* — one string
|
|
18
|
+
* compared for equality by a migration — and parsing it here is cheaper than carrying a second
|
|
19
|
+
* representation that could disagree with it.
|
|
20
|
+
*/
|
|
21
|
+
export function optionInner(type) {
|
|
22
|
+
return type.startsWith('option:') ? type.slice('option:'.length) : undefined;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Which typed array carries which field type.
|
|
26
|
+
*
|
|
27
|
+
* `i64` and `u64` are `Float64Array` rather than `BigInt64Array` because the language they come
|
|
28
|
+
* from represents them as JavaScript numbers — exact to 2^53 — and a `BigInt` column would be a
|
|
29
|
+
* different representation on this side of a boundary that is supposed to be one.
|
|
30
|
+
*
|
|
31
|
+
* **That was inferred here before DriftScript said it, and now it is the language's own rule.**
|
|
32
|
+
* 1.10.0 defines the domain of `i64` and `u64` as `±(2^53 - 1)` and `0 … 2^53 - 1`, refuses a
|
|
33
|
+
* literal outside it, and refuses wrapping arithmetic on either width because the true result is
|
|
34
|
+
* rounded before anything can reduce it. So this column is not a compromise the engine chose; it is
|
|
35
|
+
* the width the language guarantees, and the two agree by contract rather than by coincidence.
|
|
36
|
+
*
|
|
37
|
+
* `Entity` is here too, and `Float64Array` is what makes a handle survive: a handle spends the
|
|
38
|
+
* whole 53-bit budget, so anything narrower silently truncates the generation, which is the failure
|
|
39
|
+
* the handle's width exists to prevent.
|
|
40
|
+
*/
|
|
41
|
+
const COLUMNS = {
|
|
42
|
+
f32: Float32Array,
|
|
43
|
+
f64: Float64Array,
|
|
44
|
+
i8: Int8Array,
|
|
45
|
+
i16: Int16Array,
|
|
46
|
+
i32: Int32Array,
|
|
47
|
+
i64: Float64Array,
|
|
48
|
+
u8: Uint8Array,
|
|
49
|
+
u16: Uint16Array,
|
|
50
|
+
u32: Uint32Array,
|
|
51
|
+
u64: Float64Array,
|
|
52
|
+
bool: Uint8Array,
|
|
53
|
+
Entity: Float64Array,
|
|
54
|
+
};
|
|
55
|
+
/** Field types held in a plain array because they are not numbers. */
|
|
56
|
+
const BOXED = new Set(['String']);
|
|
57
|
+
/**
|
|
58
|
+
* Whether a field type is a fieldless enum, which the language writes as `enum:<Name>`.
|
|
59
|
+
*
|
|
60
|
+
* **A discriminant is an integer and always was**, so this is a column rather than a boxed value —
|
|
61
|
+
* which is the whole of why it can be added without deciding anything about layout.
|
|
62
|
+
*
|
|
63
|
+
* **The gap it closes is between a language feature and its usable shape.** DriftScript has `enum`,
|
|
64
|
+
* and `match` over one refuses to compile until every variant is handled; that exhaustiveness is
|
|
65
|
+
* the language's stated advantage over a Lua version which would silently do nothing for a state
|
|
66
|
+
* nobody wrote a branch for. But a state machine's state has to survive between ticks, which means
|
|
67
|
+
* a *component* field, and there was no column for one — so the language's own corpus keeps its
|
|
68
|
+
* `Alertness` in a `data` record, which is not what a system iterates, and a consumer shipped
|
|
69
|
+
* `mood: i32` with the three values written out in a comment. The feature and the place it is
|
|
70
|
+
* needed could not meet.
|
|
71
|
+
*/
|
|
72
|
+
function enumName(type) {
|
|
73
|
+
return type.startsWith('enum:') ? type.slice('enum:'.length) : undefined;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The column a discriminant goes in: `Int32Array`, and the width is the decision.
|
|
77
|
+
*
|
|
78
|
+
* **A byte would hold every enum anybody is likely to write and is not what this uses**, because
|
|
79
|
+
* the failure mode of guessing too small is silent. `defineComponent` is handed `enum:Mood` and a
|
|
80
|
+
* name is all it gets — the variant list belongs to the module that declared it, which this package
|
|
81
|
+
* does not read — so it cannot know whether 256 is enough. Four bytes a field costs three more than
|
|
82
|
+
* a byte for a small enum and cannot truncate a large one.
|
|
83
|
+
*
|
|
84
|
+
* **What would make it wrong** is a consumer measuring enum columns as a real share of their
|
|
85
|
+
* component memory, at which point the width belongs to whoever declares the enum and can count its
|
|
86
|
+
* variants, and this stops choosing.
|
|
87
|
+
*/
|
|
88
|
+
const ENUM_COLUMN = Int32Array;
|
|
89
|
+
let nextComponentId = 0;
|
|
90
|
+
/**
|
|
91
|
+
* Declare a component type, from a schema or from a name and a field table.
|
|
92
|
+
*
|
|
93
|
+
* **The schema overload is what a language-declared component uses**, and it exists because a field
|
|
94
|
+
* id is `(declaring module, declaring record, field name)`. A `.drs` file that declares `Health` is
|
|
95
|
+
* the declaring module; building the id here would file every language-declared component under
|
|
96
|
+
* `entities` and lose the half that makes a save survive a rename. The record overload goes on
|
|
97
|
+
* building `entities::<Name>::<field>`, because for a component declared in TypeScript this package
|
|
98
|
+
* genuinely *is* where the shape is declared.
|
|
99
|
+
*
|
|
100
|
+
* The ids are still never hand-written in the record overload, for the reason that has always been
|
|
101
|
+
* here: a hand-written id is a chance to write two the same, which `driftscript` refuses at
|
|
102
|
+
* `DS0284` for a record and which nothing would catch on this side.
|
|
103
|
+
*
|
|
104
|
+
* **What this costs** is two entry points to one concept. **What would make it wrong** is a third
|
|
105
|
+
* caller wanting a different id scheme, at which point the id belongs entirely to whoever declares
|
|
106
|
+
* the field and this function stops building one at all.
|
|
107
|
+
*/
|
|
108
|
+
export function defineComponent(first, fields) {
|
|
109
|
+
const schema = typeof first === 'string'
|
|
110
|
+
? {
|
|
111
|
+
name: first,
|
|
112
|
+
fields: Object.entries(fields ?? {}).map(([field, type]) => ({
|
|
113
|
+
id: `entities::${first}::${field}`,
|
|
114
|
+
name: field,
|
|
115
|
+
type,
|
|
116
|
+
})),
|
|
117
|
+
}
|
|
118
|
+
: first;
|
|
119
|
+
for (const field of schema.fields) {
|
|
120
|
+
const inner = optionInner(field.type) ?? field.type;
|
|
121
|
+
if (COLUMNS[inner] === undefined && !BOXED.has(inner) && enumName(inner) === undefined) {
|
|
122
|
+
throw new Error(`\`${schema.name}.${field.name}\` is declared \`${field.type}\`, which has no column. The ` +
|
|
123
|
+
`types with one are ${[...Object.keys(COLUMNS), ...BOXED].join(', ')}, a fieldless ` +
|
|
124
|
+
'`enum:<Name>`, and an option of any of them. A type that has to be boxed is a decision ' +
|
|
125
|
+
'about layout, so it is added here rather than assumed.');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
name: schema.name,
|
|
130
|
+
id: nextComponentId++,
|
|
131
|
+
schema,
|
|
132
|
+
/* An optional handle is still a handle, so a scene load has to rewrite it through the id map
|
|
133
|
+
exactly as it rewrites a required one. Leaving it out would serialise a handle into a world
|
|
134
|
+
whose indices mean something else. */
|
|
135
|
+
entityFields: schema.fields
|
|
136
|
+
.filter((f) => f.type === 'Entity' || f.type === 'option:Entity')
|
|
137
|
+
.map((f) => f.name),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export function createStoreSnapshot() {
|
|
141
|
+
return { count: 0, dense: new Float64Array(0), columns: new Map() };
|
|
142
|
+
}
|
|
143
|
+
export class ComponentStore {
|
|
144
|
+
type;
|
|
145
|
+
/**
|
|
146
|
+
* The entities that have this component, in insertion order modified by swap-removal.
|
|
147
|
+
*
|
|
148
|
+
* **Deterministic given the same history, and not a stable property of the set** — two worlds
|
|
149
|
+
* holding the same entities in a different removal order iterate differently. That is what replay
|
|
150
|
+
* needs and is not what a consumer wanting a stable order gets; they sort by entity, and the fact
|
|
151
|
+
* that it is a choice is why this says so.
|
|
152
|
+
*/
|
|
153
|
+
denseArray;
|
|
154
|
+
/** Entity index → position in `dense`, or -1. */
|
|
155
|
+
sparse;
|
|
156
|
+
columns = new Map();
|
|
157
|
+
count = 0;
|
|
158
|
+
/**
|
|
159
|
+
* The live view of these columns, made on first ask.
|
|
160
|
+
*
|
|
161
|
+
* Lazy rather than built in the constructor, because a store that nobody indexes directly should
|
|
162
|
+
* not carry an object per field name it will never hand out.
|
|
163
|
+
*/
|
|
164
|
+
viewObject = null;
|
|
165
|
+
constructor(type, options = {}) {
|
|
166
|
+
this.type = type;
|
|
167
|
+
const capacity = Math.max(1, options.capacity ?? 16);
|
|
168
|
+
this.denseArray = new Float64Array(capacity);
|
|
169
|
+
this.sparse = new Int32Array(64).fill(-1);
|
|
170
|
+
for (const field of type.schema.fields) {
|
|
171
|
+
const inner = optionInner(field.type);
|
|
172
|
+
const declared = inner ?? field.type;
|
|
173
|
+
/* A discriminant is an integer, so it takes an integer column rather than being boxed. */
|
|
174
|
+
const Column = enumName(declared) === undefined ? COLUMNS[declared] : ENUM_COLUMN;
|
|
175
|
+
this.columns.set(field.name, Column === undefined
|
|
176
|
+
? new Array(capacity)
|
|
177
|
+
: new Column(capacity));
|
|
178
|
+
/* An option keeps its inner type's column and gains a byte per entity saying whether the
|
|
179
|
+
value in it means anything. Without the second column, an optional `Entity` would have to
|
|
180
|
+
live in a boxed array so `null` could stand for absence — which is the whole bulk-data
|
|
181
|
+
rule given up for one bit. */
|
|
182
|
+
if (inner !== undefined)
|
|
183
|
+
this.columns.set(presenceColumn(field.name), new Uint8Array(capacity));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
get size() {
|
|
187
|
+
return this.count;
|
|
188
|
+
}
|
|
189
|
+
/** The entities this store holds, oldest first. Valid up to `size`; the rest is capacity. */
|
|
190
|
+
get dense() {
|
|
191
|
+
return this.denseArray;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The live columns of this store, addressable by field name.
|
|
195
|
+
*
|
|
196
|
+
* The same object every call, refreshed after every reallocation — see `view.ts` for why that is
|
|
197
|
+
* the contract rather than a convenience.
|
|
198
|
+
*/
|
|
199
|
+
view() {
|
|
200
|
+
if (this.viewObject === null)
|
|
201
|
+
this.viewObject = { sparse: this.sparse };
|
|
202
|
+
refreshView(this.viewObject, this.sparse, this.columns);
|
|
203
|
+
return this.viewObject;
|
|
204
|
+
}
|
|
205
|
+
/** Point the view at the arrays that exist now. Called after either kind of growth. */
|
|
206
|
+
refresh() {
|
|
207
|
+
if (this.viewObject === null)
|
|
208
|
+
return;
|
|
209
|
+
refreshView(this.viewObject, this.sparse, this.columns);
|
|
210
|
+
}
|
|
211
|
+
/** One field's storage, for a caller iterating it directly rather than through `read`. */
|
|
212
|
+
column(field) {
|
|
213
|
+
const found = this.columns.get(field);
|
|
214
|
+
if (found === undefined)
|
|
215
|
+
throw new Error(this.noField(field));
|
|
216
|
+
return found;
|
|
217
|
+
}
|
|
218
|
+
has(entity) {
|
|
219
|
+
const at = this.positionOf(entity);
|
|
220
|
+
return at >= 0;
|
|
221
|
+
}
|
|
222
|
+
add(entity, values = {}) {
|
|
223
|
+
if (this.has(entity)) {
|
|
224
|
+
for (const [field, value] of Object.entries(values))
|
|
225
|
+
this.write(entity, field, value);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const at = this.count;
|
|
229
|
+
this.grow(at + 1);
|
|
230
|
+
this.growSparse(entityIndex(entity) + 1);
|
|
231
|
+
this.denseArray[at] = entity;
|
|
232
|
+
this.sparse[entityIndex(entity)] = at;
|
|
233
|
+
this.count += 1;
|
|
234
|
+
/*
|
|
235
|
+
* Every field written, including the ones the caller omitted.
|
|
236
|
+
*
|
|
237
|
+
* A column is reused storage — the slot may hold whatever the entity that was there last left
|
|
238
|
+
* — so an omitted field would silently inherit a stranger's value. Zeroing is what makes
|
|
239
|
+
* `add(e, { x: 5 })` mean what it reads like.
|
|
240
|
+
*/
|
|
241
|
+
for (const field of this.type.schema.fields) {
|
|
242
|
+
const column = this.columns.get(field.name);
|
|
243
|
+
if (column === undefined)
|
|
244
|
+
continue;
|
|
245
|
+
const given = values[field.name];
|
|
246
|
+
if (Array.isArray(column))
|
|
247
|
+
column[at] = given ?? null;
|
|
248
|
+
else
|
|
249
|
+
column[at] = typeof given === 'number' ? given : Number(given ?? 0);
|
|
250
|
+
/*
|
|
251
|
+
* Presence is written here rather than left at whatever the slot held.
|
|
252
|
+
*
|
|
253
|
+
* This loop walks the *schema's* fields and the presence columns are not among them, so a
|
|
254
|
+
* reused slot would otherwise inherit the presence of whichever entity was there last —
|
|
255
|
+
* an optional field that reads as set, holding a number the caller never wrote.
|
|
256
|
+
*/
|
|
257
|
+
const present = this.columns.get(presenceColumn(field.name));
|
|
258
|
+
if (present !== undefined)
|
|
259
|
+
present[at] = given === undefined ? 0 : 1;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Drop an entity's component. `false` when it never had one, which is not an error.
|
|
264
|
+
*
|
|
265
|
+
* The last entry is swapped into the hole and **its** sparse entry is fixed — the fix-up being
|
|
266
|
+
* the whole of what makes a constant-time removal correct.
|
|
267
|
+
*
|
|
268
|
+
* **The order of the two sparse writes is load-bearing**, and it is the only thing that is. When
|
|
269
|
+
* the entry being removed *is* the last, the swap is with itself and `moved` is `entity`: writing
|
|
270
|
+
* the position first and then the -1 leaves it absent, which is right; the other way round leaves
|
|
271
|
+
* it pointing into the space past `size`.
|
|
272
|
+
*
|
|
273
|
+
* There was a `position < size` guard in `positionOf` as well, which made the order not matter —
|
|
274
|
+
* and made a perturbation of the order pass every test. Two defences for one case is one defence
|
|
275
|
+
* and one comment that cannot be checked, so the guard is gone and the order is what the test
|
|
276
|
+
* for removing the last entry actually holds.
|
|
277
|
+
*/
|
|
278
|
+
remove(entity) {
|
|
279
|
+
const at = this.positionOf(entity);
|
|
280
|
+
if (at < 0)
|
|
281
|
+
return false;
|
|
282
|
+
const last = this.count - 1;
|
|
283
|
+
const moved = this.denseArray[last];
|
|
284
|
+
this.denseArray[at] = moved;
|
|
285
|
+
for (const column of this.columns.values()) {
|
|
286
|
+
if (Array.isArray(column))
|
|
287
|
+
column[at] = column[last];
|
|
288
|
+
else
|
|
289
|
+
column[at] = column[last];
|
|
290
|
+
}
|
|
291
|
+
this.sparse[entityIndex(moved)] = at;
|
|
292
|
+
this.sparse[entityIndex(entity)] = -1;
|
|
293
|
+
this.count -= 1;
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
read(entity, field) {
|
|
297
|
+
const column = this.columns.get(field);
|
|
298
|
+
if (column === undefined)
|
|
299
|
+
throw new Error(this.noField(field));
|
|
300
|
+
const at = this.positionOf(entity);
|
|
301
|
+
if (at < 0)
|
|
302
|
+
return undefined;
|
|
303
|
+
const present = this.columns.get(presenceColumn(field));
|
|
304
|
+
if (present !== undefined && present[at] === 0)
|
|
305
|
+
return undefined;
|
|
306
|
+
return column[at];
|
|
307
|
+
}
|
|
308
|
+
write(entity, field, value) {
|
|
309
|
+
const column = this.columns.get(field);
|
|
310
|
+
if (column === undefined)
|
|
311
|
+
throw new Error(this.noField(field));
|
|
312
|
+
const at = this.positionOf(entity);
|
|
313
|
+
if (at < 0)
|
|
314
|
+
return;
|
|
315
|
+
/* `undefined` is how an optional field is cleared. On a non-optional field there is no presence
|
|
316
|
+
column and the value goes through the same coercion it always did. */
|
|
317
|
+
const present = this.columns.get(presenceColumn(field));
|
|
318
|
+
if (present !== undefined)
|
|
319
|
+
present[at] = value === undefined ? 0 : 1;
|
|
320
|
+
if (Array.isArray(column))
|
|
321
|
+
column[at] = value;
|
|
322
|
+
else
|
|
323
|
+
column[at] = typeof value === 'number' ? value : Number(value ?? 0);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Where an entity sits, or -1.
|
|
327
|
+
*
|
|
328
|
+
* **The dense array holds the whole handle**, so the generation is compared for free: a stale
|
|
329
|
+
* handle whose slot is live finds a different number and is refused. A sparse array alone could
|
|
330
|
+
* only compare indices, which is the silent-reuse failure one level down.
|
|
331
|
+
*/
|
|
332
|
+
positionOf(entity) {
|
|
333
|
+
const index = entityIndex(entity);
|
|
334
|
+
if (index >= this.sparse.length)
|
|
335
|
+
return -1;
|
|
336
|
+
const at = this.sparse[index];
|
|
337
|
+
if (at < 0)
|
|
338
|
+
return -1;
|
|
339
|
+
return this.denseArray[at] === entity ? at : -1;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Copy this store's contents into a slot, growing the slot to match if it is short.
|
|
343
|
+
*
|
|
344
|
+
* **`count` bounds every copy, not capacity.** A store that once held ten thousand entities and
|
|
345
|
+
* now holds four has ten thousand slots of stale numbers behind the live four, and copying them
|
|
346
|
+
* would make a snapshot's cost depend on the world's history instead of its size.
|
|
347
|
+
*
|
|
348
|
+
* A boxed column copies references. That is correct rather than a shortcut: the one boxed field
|
|
349
|
+
* type is `String`, and a string cannot be mutated behind the snapshot's back.
|
|
350
|
+
*/
|
|
351
|
+
saveInto(slot) {
|
|
352
|
+
const count = this.count;
|
|
353
|
+
if (slot.dense.length < count)
|
|
354
|
+
slot.dense = new Float64Array(this.denseArray.length);
|
|
355
|
+
for (let i = 0; i < count; i++)
|
|
356
|
+
slot.dense[i] = this.denseArray[i];
|
|
357
|
+
for (const [field, column] of this.columns) {
|
|
358
|
+
let into = slot.columns.get(field);
|
|
359
|
+
if (into === undefined || into.length < count) {
|
|
360
|
+
into = Array.isArray(column)
|
|
361
|
+
? new Array(column.length)
|
|
362
|
+
: new column.constructor(column.length);
|
|
363
|
+
slot.columns.set(field, into);
|
|
364
|
+
}
|
|
365
|
+
if (Array.isArray(column)) {
|
|
366
|
+
const target = into;
|
|
367
|
+
for (let i = 0; i < count; i++)
|
|
368
|
+
target[i] = column[i];
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
const source = column;
|
|
372
|
+
const target = into;
|
|
373
|
+
for (let i = 0; i < count; i++)
|
|
374
|
+
target[i] = source[i];
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
slot.count = count;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Put this store back to a saved slot, rebuilding `sparse` from the restored `dense`.
|
|
381
|
+
*
|
|
382
|
+
* **The order of the two sparse passes is load-bearing.** The entries this store's *current* live
|
|
383
|
+
* set occupies are cleared first, while `dense` still says which they are; overwriting `dense`
|
|
384
|
+
* first loses that record and leaves stale positions behind, and a stale position is worse than a
|
|
385
|
+
* missing one because `positionOf` would find a live slot holding a different handle.
|
|
386
|
+
*/
|
|
387
|
+
loadFrom(slot) {
|
|
388
|
+
for (let at = 0; at < this.count; at++) {
|
|
389
|
+
this.sparse[entityIndex(this.denseArray[at])] = -1;
|
|
390
|
+
}
|
|
391
|
+
/* Before the columns are read out of the map: `grow` replaces every array in it. */
|
|
392
|
+
this.grow(Math.max(1, slot.count));
|
|
393
|
+
for (let i = 0; i < slot.count; i++)
|
|
394
|
+
this.denseArray[i] = slot.dense[i];
|
|
395
|
+
for (const [field, column] of this.columns) {
|
|
396
|
+
const from = slot.columns.get(field);
|
|
397
|
+
if (from === undefined)
|
|
398
|
+
continue;
|
|
399
|
+
if (Array.isArray(column)) {
|
|
400
|
+
const source = from;
|
|
401
|
+
for (let i = 0; i < slot.count; i++)
|
|
402
|
+
column[i] = source[i];
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
const source = from;
|
|
406
|
+
const target = column;
|
|
407
|
+
for (let i = 0; i < slot.count; i++)
|
|
408
|
+
target[i] = source[i];
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
this.count = slot.count;
|
|
412
|
+
for (let at = 0; at < slot.count; at++) {
|
|
413
|
+
const index = entityIndex(this.denseArray[at]);
|
|
414
|
+
this.growSparse(index + 1);
|
|
415
|
+
this.sparse[index] = at;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
noField(field) {
|
|
419
|
+
return (`\`${this.type.name}\` has no field \`${field}\`. It has ` +
|
|
420
|
+
`${this.type.schema.fields.map((f) => `\`${f.name}\``).join(', ')}.`);
|
|
421
|
+
}
|
|
422
|
+
grow(needed) {
|
|
423
|
+
if (needed <= this.denseArray.length)
|
|
424
|
+
return;
|
|
425
|
+
let size = this.denseArray.length;
|
|
426
|
+
while (size < needed)
|
|
427
|
+
size *= 2;
|
|
428
|
+
const dense = new Float64Array(size);
|
|
429
|
+
dense.set(this.denseArray);
|
|
430
|
+
this.denseArray = dense;
|
|
431
|
+
for (const [field, column] of this.columns) {
|
|
432
|
+
if (Array.isArray(column)) {
|
|
433
|
+
column.length = size;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
const grown = new column.constructor(size);
|
|
437
|
+
grown.set(column);
|
|
438
|
+
this.columns.set(field, grown);
|
|
439
|
+
}
|
|
440
|
+
this.refresh();
|
|
441
|
+
}
|
|
442
|
+
growSparse(needed) {
|
|
443
|
+
if (needed <= this.sparse.length)
|
|
444
|
+
return;
|
|
445
|
+
let size = this.sparse.length;
|
|
446
|
+
while (size < needed)
|
|
447
|
+
size *= 2;
|
|
448
|
+
const sparse = new Int32Array(size).fill(-1);
|
|
449
|
+
sparse.set(this.sparse);
|
|
450
|
+
this.sparse = sparse;
|
|
451
|
+
this.refresh();
|
|
452
|
+
}
|
|
453
|
+
}
|
package/dist/system.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
export interface SystemView {
|
|
36
|
+
query(a: ComponentType, b?: ComponentType, c?: ComponentType, d?: ComponentType): QueryCursor;
|
|
37
|
+
/**
|
|
38
|
+
* The live columns of a component, checked against this system's declarations first.
|
|
39
|
+
*
|
|
40
|
+
* `forWriting` decides which check: a caller that will only read takes a readable view, and one
|
|
41
|
+
* that will write takes a writable one. A compiler that can see the body supplies it; a caller
|
|
42
|
+
* writing this by hand says what it is about to do.
|
|
43
|
+
*/
|
|
44
|
+
view(type: ComponentType, forWriting: boolean): ComponentView;
|
|
45
|
+
read(entity: Entity, type: ComponentType, field: string): unknown;
|
|
46
|
+
write(entity: Entity, type: ComponentType, field: string, value: unknown): void;
|
|
47
|
+
has(entity: Entity, type: ComponentType): boolean;
|
|
48
|
+
create(): Entity;
|
|
49
|
+
add(entity: Entity, type: ComponentType, values?: Readonly<Record<string, unknown>>): void;
|
|
50
|
+
/** Deferred to after this system runs. See the header. */
|
|
51
|
+
destroy(entity: Entity): void;
|
|
52
|
+
/** Deferred to after this system runs. See the header. */
|
|
53
|
+
remove(entity: Entity, type: ComponentType): void;
|
|
54
|
+
}
|
|
55
|
+
export interface SystemDefinition {
|
|
56
|
+
readonly name: string;
|
|
57
|
+
readonly reads?: readonly ComponentType[];
|
|
58
|
+
readonly writes?: readonly ComponentType[];
|
|
59
|
+
/**
|
|
60
|
+
* Run once every this many fixed steps. Absent means every step.
|
|
61
|
+
*
|
|
62
|
+
* **A stride and never a rate in seconds.** A rate in wall-clock time leaves the determinism
|
|
63
|
+
* contract on its first dropped frame: two runs of the same recording would run the system a
|
|
64
|
+
* different number of times. A tick count is the same in every replay of the same input.
|
|
65
|
+
*/
|
|
66
|
+
readonly everyTicks?: number;
|
|
67
|
+
/** Systems this one must run after, by name. A cycle here is refused at build. */
|
|
68
|
+
readonly after?: readonly string[];
|
|
69
|
+
run(view: SystemView): void;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The view one system sees, reused across ticks.
|
|
73
|
+
*
|
|
74
|
+
* One per system rather than one per run, because a system runs every tick for the life of a world
|
|
75
|
+
* and an object per tick per system is the allocation this package refuses everywhere else.
|
|
76
|
+
*/
|
|
77
|
+
export declare class BoundSystem implements SystemView {
|
|
78
|
+
readonly definition: SystemDefinition;
|
|
79
|
+
private readonly world;
|
|
80
|
+
private readonly readable;
|
|
81
|
+
private readonly writable;
|
|
82
|
+
/** Entities to destroy after this system returns. Reused, drained with a count. */
|
|
83
|
+
private readonly pendingDestroy;
|
|
84
|
+
private destroyCount;
|
|
85
|
+
private readonly pendingRemoveEntity;
|
|
86
|
+
private readonly pendingRemoveType;
|
|
87
|
+
private removeCount;
|
|
88
|
+
constructor(world: World, definition: SystemDefinition);
|
|
89
|
+
run(): void;
|
|
90
|
+
query(a: ComponentType, b?: ComponentType, c?: ComponentType, d?: ComponentType): QueryCursor;
|
|
91
|
+
/**
|
|
92
|
+
* The live columns of a component, checked first.
|
|
93
|
+
*
|
|
94
|
+
* **This is the seam where one mistake disables every declaration in this file.** Every other
|
|
95
|
+
* accessor here checks before it answers, and a view that went straight to the store would hand
|
|
96
|
+
* out the raw columns of a component this system never declared — after which `reads` and
|
|
97
|
+
* `writes` are reachable *around* rather than through, and nothing in the schedule derived from
|
|
98
|
+
* them means anything.
|
|
99
|
+
*
|
|
100
|
+
* **What this costs** is that a caller must say in advance whether it will write, before it has
|
|
101
|
+
* written anything. **What would make it wrong** is a view narrow enough to enforce per field,
|
|
102
|
+
* which would be a second object per field and the allocation this package refuses.
|
|
103
|
+
*/
|
|
104
|
+
view(type: ComponentType, forWriting: boolean): ComponentView;
|
|
105
|
+
read(entity: Entity, type: ComponentType, field: string): unknown;
|
|
106
|
+
write(entity: Entity, type: ComponentType, field: string, value: unknown): void;
|
|
107
|
+
has(entity: Entity, type: ComponentType): boolean;
|
|
108
|
+
create(): Entity;
|
|
109
|
+
add(entity: Entity, type: ComponentType, values?: Readonly<Record<string, unknown>>): void;
|
|
110
|
+
destroy(entity: Entity): void;
|
|
111
|
+
remove(entity: Entity, type: ComponentType): void;
|
|
112
|
+
/** Apply what was deferred. Drained by count, never by emptying — see `IMPROVEMENTS.md`. */
|
|
113
|
+
private drain;
|
|
114
|
+
private requireReadable;
|
|
115
|
+
private requireWritable;
|
|
116
|
+
}
|
package/dist/system.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view one system sees, reused across ticks.
|
|
3
|
+
*
|
|
4
|
+
* One per system rather than one per run, because a system runs every tick for the life of a world
|
|
5
|
+
* and an object per tick per system is the allocation this package refuses everywhere else.
|
|
6
|
+
*/
|
|
7
|
+
export class BoundSystem {
|
|
8
|
+
definition;
|
|
9
|
+
world;
|
|
10
|
+
readable = new Set();
|
|
11
|
+
writable = new Set();
|
|
12
|
+
/** Entities to destroy after this system returns. Reused, drained with a count. */
|
|
13
|
+
pendingDestroy = [];
|
|
14
|
+
destroyCount = 0;
|
|
15
|
+
pendingRemoveEntity = [];
|
|
16
|
+
pendingRemoveType = [];
|
|
17
|
+
removeCount = 0;
|
|
18
|
+
constructor(world, definition) {
|
|
19
|
+
this.world = world;
|
|
20
|
+
this.definition = definition;
|
|
21
|
+
for (const type of definition.writes ?? []) {
|
|
22
|
+
this.writable.add(type.id);
|
|
23
|
+
/* `writes` implies `reads` — see the header. */
|
|
24
|
+
this.readable.add(type.id);
|
|
25
|
+
}
|
|
26
|
+
for (const type of definition.reads ?? [])
|
|
27
|
+
this.readable.add(type.id);
|
|
28
|
+
}
|
|
29
|
+
run() {
|
|
30
|
+
this.definition.run(this);
|
|
31
|
+
this.drain();
|
|
32
|
+
}
|
|
33
|
+
query(a, b, c, d) {
|
|
34
|
+
this.requireReadable(a);
|
|
35
|
+
if (b !== undefined)
|
|
36
|
+
this.requireReadable(b);
|
|
37
|
+
if (c !== undefined)
|
|
38
|
+
this.requireReadable(c);
|
|
39
|
+
if (d !== undefined)
|
|
40
|
+
this.requireReadable(d);
|
|
41
|
+
return this.world.query(a, b, c, d);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The live columns of a component, checked first.
|
|
45
|
+
*
|
|
46
|
+
* **This is the seam where one mistake disables every declaration in this file.** Every other
|
|
47
|
+
* accessor here checks before it answers, and a view that went straight to the store would hand
|
|
48
|
+
* out the raw columns of a component this system never declared — after which `reads` and
|
|
49
|
+
* `writes` are reachable *around* rather than through, and nothing in the schedule derived from
|
|
50
|
+
* them means anything.
|
|
51
|
+
*
|
|
52
|
+
* **What this costs** is that a caller must say in advance whether it will write, before it has
|
|
53
|
+
* written anything. **What would make it wrong** is a view narrow enough to enforce per field,
|
|
54
|
+
* which would be a second object per field and the allocation this package refuses.
|
|
55
|
+
*/
|
|
56
|
+
view(type, forWriting) {
|
|
57
|
+
if (forWriting)
|
|
58
|
+
this.requireWritable(type);
|
|
59
|
+
else
|
|
60
|
+
this.requireReadable(type);
|
|
61
|
+
return this.world.view(type);
|
|
62
|
+
}
|
|
63
|
+
read(entity, type, field) {
|
|
64
|
+
this.requireReadable(type);
|
|
65
|
+
return this.world.read(entity, type, field);
|
|
66
|
+
}
|
|
67
|
+
write(entity, type, field, value) {
|
|
68
|
+
this.requireWritable(type);
|
|
69
|
+
this.world.write(entity, type, field, value);
|
|
70
|
+
}
|
|
71
|
+
has(entity, type) {
|
|
72
|
+
this.requireReadable(type);
|
|
73
|
+
return this.world.has(entity, type);
|
|
74
|
+
}
|
|
75
|
+
create() {
|
|
76
|
+
return this.world.create();
|
|
77
|
+
}
|
|
78
|
+
add(entity, type, values = {}) {
|
|
79
|
+
this.requireWritable(type);
|
|
80
|
+
this.world.add(entity, type, values);
|
|
81
|
+
}
|
|
82
|
+
destroy(entity) {
|
|
83
|
+
if (this.destroyCount < this.pendingDestroy.length)
|
|
84
|
+
this.pendingDestroy[this.destroyCount] = entity;
|
|
85
|
+
else
|
|
86
|
+
this.pendingDestroy.push(entity);
|
|
87
|
+
this.destroyCount += 1;
|
|
88
|
+
}
|
|
89
|
+
remove(entity, type) {
|
|
90
|
+
this.requireWritable(type);
|
|
91
|
+
if (this.removeCount < this.pendingRemoveEntity.length) {
|
|
92
|
+
this.pendingRemoveEntity[this.removeCount] = entity;
|
|
93
|
+
this.pendingRemoveType[this.removeCount] = type;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
this.pendingRemoveEntity.push(entity);
|
|
97
|
+
this.pendingRemoveType.push(type);
|
|
98
|
+
}
|
|
99
|
+
this.removeCount += 1;
|
|
100
|
+
}
|
|
101
|
+
/** Apply what was deferred. Drained by count, never by emptying — see `IMPROVEMENTS.md`. */
|
|
102
|
+
drain() {
|
|
103
|
+
for (let i = 0; i < this.removeCount; i += 1) {
|
|
104
|
+
this.world.remove(this.pendingRemoveEntity[i], this.pendingRemoveType[i]);
|
|
105
|
+
}
|
|
106
|
+
this.removeCount = 0;
|
|
107
|
+
for (let i = 0; i < this.destroyCount; i += 1)
|
|
108
|
+
this.world.destroy(this.pendingDestroy[i]);
|
|
109
|
+
this.destroyCount = 0;
|
|
110
|
+
}
|
|
111
|
+
requireReadable(type) {
|
|
112
|
+
if (this.readable.has(type.id))
|
|
113
|
+
return;
|
|
114
|
+
throw new Error(`\`${this.definition.name}\` reads \`${type.name}\` and did not declare it. The schedule is ` +
|
|
115
|
+
'derived from what systems declare, so one that touches more than it says makes every ' +
|
|
116
|
+
'derivation wrong — add it to `reads`, or to `writes` if it also writes it.');
|
|
117
|
+
}
|
|
118
|
+
requireWritable(type) {
|
|
119
|
+
if (this.writable.has(type.id))
|
|
120
|
+
return;
|
|
121
|
+
throw new Error(`\`${this.definition.name}\` writes \`${type.name}\` and did not declare it. Add it to ` +
|
|
122
|
+
'`writes`; over-declaring is conservative and only costs schedule width.');
|
|
123
|
+
}
|
|
124
|
+
}
|