@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/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
+ }
@@ -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
+ }