@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/view.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * A live handle on a component's columns, for a caller that indexes them directly.
3
+ *
4
+ * **A holder of this object holds the object and never an array.** A column grows by
5
+ * reallocation, so a hoisted `Float64Array` goes stale the first time anything adds a component —
6
+ * and `system.ts` makes `add` immediate on purpose, so that happens *during* a walk. The failure
7
+ * would be a write into an array nothing reads any more: no error, no wrong type, just a value
8
+ * that never arrives.
9
+ *
10
+ * So the store refreshes this object's properties after a reallocation rather than handing back a
11
+ * new object. A caller that hoisted it at the top of a loop is still correct at the bottom.
12
+ *
13
+ * **What this costs** is one live object per store, and a property load on every field access
14
+ * rather than a captured local. **What would make it wrong** is a caller holding one past its
15
+ * world's disposal — which is why a generated script reaches one through a call per loop rather
16
+ * than caching it across frames.
17
+ *
18
+ * ---
19
+ *
20
+ * ## Absence is a column, not a value
21
+ *
22
+ * An optional field has two entries here: the value column under its own name, and a `Uint8Array`
23
+ * under `presenceColumn(field)`. A reader that consults only the value column reads whatever the
24
+ * slot last held, which for a cleared field is the value it was cleared from.
25
+ */
26
+ /** Every array shape a column can be. `unknown[]` is the boxed case — today, `String`. */
27
+ export type ViewColumn = Int8Array | Int16Array | Int32Array | Uint8Array | Uint16Array | Uint32Array | Float32Array | Float64Array | unknown[];
28
+ /**
29
+ * A component's storage, addressable by field name.
30
+ *
31
+ * `sparse` maps an entity's *index* — not its handle — to a position in every column here. Taking
32
+ * the index from a handle is `entity % 2 ** 26`, which is what a caller that cannot import
33
+ * `entityIndex` writes inline.
34
+ */
35
+ export interface ComponentView {
36
+ sparse: Int32Array;
37
+ [field: string]: ViewColumn;
38
+ }
39
+ /**
40
+ * Point a view at the arrays a store holds now.
41
+ *
42
+ * Called when a view is first asked for and again after every reallocation. It rewrites the
43
+ * existing object rather than returning a new one, because the object identity is the contract: a
44
+ * caller hoisted it, and handing back a second object would leave the first one pointing at
45
+ * storage nothing reads.
46
+ *
47
+ * It lives here rather than on the store so that the two halves of this mechanism — what a view
48
+ * *is* and what keeps one current — are one file. **What that costs** is passing the store's two
49
+ * internals in. **What would make it wrong** is a store whose columns are not a map, at which point
50
+ * this signature is the thing to change rather than the call sites.
51
+ */
52
+ export declare function refreshView(view: ComponentView, sparse: Int32Array, columns: ReadonlyMap<string, ViewColumn>): void;
package/dist/view.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A live handle on a component's columns, for a caller that indexes them directly.
3
+ *
4
+ * **A holder of this object holds the object and never an array.** A column grows by
5
+ * reallocation, so a hoisted `Float64Array` goes stale the first time anything adds a component —
6
+ * and `system.ts` makes `add` immediate on purpose, so that happens *during* a walk. The failure
7
+ * would be a write into an array nothing reads any more: no error, no wrong type, just a value
8
+ * that never arrives.
9
+ *
10
+ * So the store refreshes this object's properties after a reallocation rather than handing back a
11
+ * new object. A caller that hoisted it at the top of a loop is still correct at the bottom.
12
+ *
13
+ * **What this costs** is one live object per store, and a property load on every field access
14
+ * rather than a captured local. **What would make it wrong** is a caller holding one past its
15
+ * world's disposal — which is why a generated script reaches one through a call per loop rather
16
+ * than caching it across frames.
17
+ *
18
+ * ---
19
+ *
20
+ * ## Absence is a column, not a value
21
+ *
22
+ * An optional field has two entries here: the value column under its own name, and a `Uint8Array`
23
+ * under `presenceColumn(field)`. A reader that consults only the value column reads whatever the
24
+ * slot last held, which for a cleared field is the value it was cleared from.
25
+ */
26
+ /**
27
+ * Point a view at the arrays a store holds now.
28
+ *
29
+ * Called when a view is first asked for and again after every reallocation. It rewrites the
30
+ * existing object rather than returning a new one, because the object identity is the contract: a
31
+ * caller hoisted it, and handing back a second object would leave the first one pointing at
32
+ * storage nothing reads.
33
+ *
34
+ * It lives here rather than on the store so that the two halves of this mechanism — what a view
35
+ * *is* and what keeps one current — are one file. **What that costs** is passing the store's two
36
+ * internals in. **What would make it wrong** is a store whose columns are not a map, at which point
37
+ * this signature is the thing to change rather than the call sites.
38
+ */
39
+ export function refreshView(view, sparse, columns) {
40
+ view.sparse = sparse;
41
+ for (const [field, column] of columns)
42
+ view[field] = column;
43
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * A world: the entities, the stores keyed by component type, and the cursor pool.
3
+ *
4
+ * **What a world adds over a bag of stores is that an entity which is gone is gone from all of
5
+ * them.** Without that, destroying something leaves its components behind, a query goes on yielding
6
+ * it, and a scene serialises a handle nothing can reach — three symptoms of one missing sweep.
7
+ */
8
+ import { type AllocatorSnapshot, type Entity, type EntityAllocatorOptions } from './entity.ts';
9
+ import { type CursorHost, QueryCursor } from './query.ts';
10
+ import { ComponentStore, type ComponentType, type StoreSnapshot } from './store.ts';
11
+ import type { ComponentView } from './view.ts';
12
+ /**
13
+ * A world's whole state at one instant, for a rewind. **Not a save file**, and the two are not
14
+ * interchangeable.
15
+ *
16
+ * `serializeWorld` in `scene.ts` writes a scene: it carries every component's schema so a load can
17
+ * migrate across a change, keys values by stable field id so a renamed field still loads, and
18
+ * *creates* entities so a scene can be loaded into a world that already holds things. Every one of
19
+ * those properties is wrong here. A rewind needs the handles it had — an input recorded for entity
20
+ * 4,194,307 means nothing if the restore hands that slot a new generation — and it needs to cost
21
+ * nothing per tick, which rules out an object per component per entity.
22
+ *
23
+ * | | `SerializedScene` | `WorldSnapshot` |
24
+ * |---|---|---|
25
+ * | Keyed by | stable field id | column position |
26
+ * | Handles | remapped, all of them change | preserved, generations included |
27
+ * | On load | creates, appending | overwrites in place |
28
+ * | Migration | yes | no |
29
+ * | Allocates | per entity, per component | nothing after the first save |
30
+ * | Goes on a wire | yes | never |
31
+ *
32
+ * **A rewind covers every store the world holds, and takes no list of types.** That is the other
33
+ * half of the difference: `serializeWorld` is handed the types because a scene is a decision about
34
+ * what to save, and a rewind is not a decision at all. A component type left out of a restore is
35
+ * state that survived the rewind, which is the definition of a desync.
36
+ */
37
+ export interface WorldSnapshot {
38
+ readonly allocator: AllocatorSnapshot;
39
+ /** By `ComponentType.id`. Holds a slot for every store the world had when this was written. */
40
+ readonly stores: Map<number, StoreSnapshot>;
41
+ }
42
+ /**
43
+ * An empty slot, to be filled by `World.saveInto` and reused for the life of a rewind ring.
44
+ *
45
+ * Its arrays are zero-length and grow to the world's shape on the first save, so a ring of eight
46
+ * costs eight small objects up front and allocates nothing on the ticks that matter.
47
+ */
48
+ export declare function createWorldSnapshot(): WorldSnapshot;
49
+ export declare class World implements CursorHost {
50
+ private readonly entities;
51
+ private readonly stores;
52
+ /**
53
+ * Cursors not currently in a loop.
54
+ *
55
+ * A pool rather than a fresh cursor, because a query runs per frame per system and an object per
56
+ * query is the allocation this whole design refuses. It grows to the deepest nesting a consumer
57
+ * reaches and never shrinks, which for any real workload is a handful.
58
+ */
59
+ private readonly idle;
60
+ private inUse;
61
+ constructor(options?: EntityAllocatorOptions);
62
+ get liveCount(): number;
63
+ /** How many cursors are inside a loop. For a consumer looking at a leak rather than inferring one. */
64
+ get cursorsInUse(): number;
65
+ create(): Entity;
66
+ alive(entity: Entity): boolean;
67
+ /**
68
+ * Destroy an entity and sweep it out of every store.
69
+ *
70
+ * Every store, not the ones it is known to be in: a world keeps no per-entity list of its
71
+ * components, because that list is a second description of what the stores already know and it
72
+ * would be the thing that goes stale. The sweep is one `remove` per registered type, which is a
73
+ * number of component *types* rather than of entities.
74
+ */
75
+ destroy(entity: Entity): boolean;
76
+ /** The store for a component type, made on first ask. */
77
+ store(type: ComponentType): ComponentStore;
78
+ /**
79
+ * The live columns of a component, for a caller indexing them directly.
80
+ *
81
+ * **Unenforced, and that is not an oversight**: a world outside a system has no declarations to
82
+ * enforce against. Inside one, go through `SystemView.view`, which checks first — see the note
83
+ * there for what happens if anything routes around it.
84
+ */
85
+ view(type: ComponentType): ComponentView;
86
+ add(entity: Entity, type: ComponentType, values?: Readonly<Record<string, unknown>>): void;
87
+ remove(entity: Entity, type: ComponentType): boolean;
88
+ has(entity: Entity, type: ComponentType): boolean;
89
+ read(entity: Entity, type: ComponentType, field: string): unknown;
90
+ write(entity: Entity, type: ComponentType, field: string, value: unknown): void;
91
+ /**
92
+ * Everything that has all of these components.
93
+ *
94
+ * The cursor is borrowed and is given back when the loop ends — including when it `break`s, which
95
+ * `for…of` reports through the iterator's `return`. A caller that keeps the result past its loop
96
+ * gets whatever the next query writes into it; see `query.ts`.
97
+ */
98
+ query(a: ComponentType, b?: ComponentType, c?: ComponentType, d?: ComponentType): QueryCursor;
99
+ /** Called by a cursor when its loop ends, including when the loop breaks. Not for a consumer. */
100
+ releaseCursor(cursor: QueryCursor): void;
101
+ /**
102
+ * Write this world's whole state into a slot. See `WorldSnapshot` for why this is not a save.
103
+ *
104
+ * **Every store, and the slot keeps one for each.** A store is never removed from a world, so a
105
+ * later restore always finds the stores this save described still present — which is why nothing
106
+ * here has to record a component *type*, only its id.
107
+ */
108
+ saveInto(slot: WorldSnapshot): void;
109
+ /**
110
+ * Put this world back to a saved slot.
111
+ *
112
+ * **A store the slot does not mention is emptied rather than skipped.** Such a store was created
113
+ * after the save — by a query, or by something adding a component the world had never seen — and
114
+ * leaving its contents alone would leave components attached to entities the restored allocator
115
+ * says do not exist. Emptying it is what the snapshot actually claims.
116
+ *
117
+ * The cursor pool is untouched: a borrowed cursor belongs to a loop that is running, and a
118
+ * rewind happens between ticks.
119
+ */
120
+ loadFrom(slot: WorldSnapshot): void;
121
+ }
package/dist/world.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * A world: the entities, the stores keyed by component type, and the cursor pool.
3
+ *
4
+ * **What a world adds over a bag of stores is that an entity which is gone is gone from all of
5
+ * them.** Without that, destroying something leaves its components behind, a query goes on yielding
6
+ * it, and a scene serialises a handle nothing can reach — three symptoms of one missing sweep.
7
+ */
8
+ import { EntityAllocator, createAllocatorSnapshot, } from './entity.js';
9
+ import { QueryCursor } from './query.js';
10
+ import { ComponentStore, createStoreSnapshot, } from './store.js';
11
+ /**
12
+ * An empty slot, to be filled by `World.saveInto` and reused for the life of a rewind ring.
13
+ *
14
+ * Its arrays are zero-length and grow to the world's shape on the first save, so a ring of eight
15
+ * costs eight small objects up front and allocates nothing on the ticks that matter.
16
+ */
17
+ export function createWorldSnapshot() {
18
+ return { allocator: createAllocatorSnapshot(), stores: new Map() };
19
+ }
20
+ export class World {
21
+ entities;
22
+ stores = new Map();
23
+ /**
24
+ * Cursors not currently in a loop.
25
+ *
26
+ * A pool rather than a fresh cursor, because a query runs per frame per system and an object per
27
+ * query is the allocation this whole design refuses. It grows to the deepest nesting a consumer
28
+ * reaches and never shrinks, which for any real workload is a handful.
29
+ */
30
+ idle = [];
31
+ inUse = 0;
32
+ constructor(options = {}) {
33
+ this.entities = new EntityAllocator(options);
34
+ }
35
+ get liveCount() {
36
+ return this.entities.liveCount;
37
+ }
38
+ /** How many cursors are inside a loop. For a consumer looking at a leak rather than inferring one. */
39
+ get cursorsInUse() {
40
+ return this.inUse;
41
+ }
42
+ create() {
43
+ return this.entities.create();
44
+ }
45
+ alive(entity) {
46
+ return this.entities.alive(entity);
47
+ }
48
+ /**
49
+ * Destroy an entity and sweep it out of every store.
50
+ *
51
+ * Every store, not the ones it is known to be in: a world keeps no per-entity list of its
52
+ * components, because that list is a second description of what the stores already know and it
53
+ * would be the thing that goes stale. The sweep is one `remove` per registered type, which is a
54
+ * number of component *types* rather than of entities.
55
+ */
56
+ destroy(entity) {
57
+ if (!this.entities.alive(entity))
58
+ return false;
59
+ for (const store of this.stores.values())
60
+ store.remove(entity);
61
+ return this.entities.destroy(entity);
62
+ }
63
+ /** The store for a component type, made on first ask. */
64
+ store(type) {
65
+ const existing = this.stores.get(type.id);
66
+ if (existing !== undefined)
67
+ return existing;
68
+ const made = new ComponentStore(type);
69
+ this.stores.set(type.id, made);
70
+ return made;
71
+ }
72
+ /**
73
+ * The live columns of a component, for a caller indexing them directly.
74
+ *
75
+ * **Unenforced, and that is not an oversight**: a world outside a system has no declarations to
76
+ * enforce against. Inside one, go through `SystemView.view`, which checks first — see the note
77
+ * there for what happens if anything routes around it.
78
+ */
79
+ view(type) {
80
+ return this.store(type).view();
81
+ }
82
+ add(entity, type, values = {}) {
83
+ if (!this.entities.alive(entity)) {
84
+ throw new Error(`entity ${entity} is not alive, so \`${type.name}\` has nowhere to go. Storing it anyway ` +
85
+ 'would leave a component keyed to a handle nothing can reach, which a query would skip ' +
86
+ 'and a scene would serialise.');
87
+ }
88
+ this.store(type).add(entity, values);
89
+ }
90
+ remove(entity, type) {
91
+ return this.store(type).remove(entity);
92
+ }
93
+ has(entity, type) {
94
+ return this.store(type).has(entity);
95
+ }
96
+ read(entity, type, field) {
97
+ return this.store(type).read(entity, field);
98
+ }
99
+ write(entity, type, field, value) {
100
+ this.store(type).write(entity, field, value);
101
+ }
102
+ /**
103
+ * Everything that has all of these components.
104
+ *
105
+ * The cursor is borrowed and is given back when the loop ends — including when it `break`s, which
106
+ * `for…of` reports through the iterator's `return`. A caller that keeps the result past its loop
107
+ * gets whatever the next query writes into it; see `query.ts`.
108
+ */
109
+ query(a, b, c, d) {
110
+ const cursor = this.idle.pop() ?? new QueryCursor(this);
111
+ this.inUse += 1;
112
+ return cursor.open(a, b, c, d);
113
+ }
114
+ /** Called by a cursor when its loop ends, including when the loop breaks. Not for a consumer. */
115
+ releaseCursor(cursor) {
116
+ this.inUse -= 1;
117
+ this.idle.push(cursor);
118
+ }
119
+ /**
120
+ * Write this world's whole state into a slot. See `WorldSnapshot` for why this is not a save.
121
+ *
122
+ * **Every store, and the slot keeps one for each.** A store is never removed from a world, so a
123
+ * later restore always finds the stores this save described still present — which is why nothing
124
+ * here has to record a component *type*, only its id.
125
+ */
126
+ saveInto(slot) {
127
+ this.entities.saveInto(slot.allocator);
128
+ for (const [id, store] of this.stores) {
129
+ let into = slot.stores.get(id);
130
+ if (into === undefined) {
131
+ into = createStoreSnapshot();
132
+ slot.stores.set(id, into);
133
+ }
134
+ store.saveInto(into);
135
+ }
136
+ }
137
+ /**
138
+ * Put this world back to a saved slot.
139
+ *
140
+ * **A store the slot does not mention is emptied rather than skipped.** Such a store was created
141
+ * after the save — by a query, or by something adding a component the world had never seen — and
142
+ * leaving its contents alone would leave components attached to entities the restored allocator
143
+ * says do not exist. Emptying it is what the snapshot actually claims.
144
+ *
145
+ * The cursor pool is untouched: a borrowed cursor belongs to a loop that is running, and a
146
+ * rewind happens between ticks.
147
+ */
148
+ loadFrom(slot) {
149
+ this.entities.loadFrom(slot.allocator);
150
+ for (const [id, store] of this.stores) {
151
+ store.loadFrom(slot.stores.get(id) ?? EMPTY_STORE);
152
+ }
153
+ }
154
+ }
155
+ /**
156
+ * The slot a store restores from when the snapshot never knew about it: count zero.
157
+ *
158
+ * Shared and never written to — `ComponentStore.loadFrom` only reads its slot — so this costs one
159
+ * object for the process instead of one per rewind.
160
+ */
161
+ const EMPTY_STORE = createStoreSnapshot();
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@driftengine/entities",
3
+ "version": "3.61.0",
4
+ "description": "Entities with generational identity, component storage, systems and scenes",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "drift-source": "./src/index.ts",
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./package.json": "./package.json",
16
+ "./*": "./*"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "!src/**/*.test.mjs",
23
+ "!src/**/__snapshots__",
24
+ "README.md",
25
+ "LICENSE",
26
+ "NOTICE"
27
+ ],
28
+ "sideEffects": false,
29
+ "dependencies": {
30
+ "driftscript": "1.13.0"
31
+ },
32
+ "author": "Drift Technologies",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/drftrun/driftengine.git",
36
+ "directory": "packages/entities"
37
+ },
38
+ "homepage": "https://github.com/drftrun/driftengine#readme",
39
+ "bugs": "https://github.com/drftrun/driftengine/issues",
40
+ "keywords": [
41
+ "driftengine",
42
+ "3d",
43
+ "webgl",
44
+ "webgpu",
45
+ "typescript",
46
+ "ecs",
47
+ "entity-component-system",
48
+ "prefab",
49
+ "scene"
50
+ ],
51
+ "engines": {
52
+ "node": ">=22.12.0"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ }
57
+ }