@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/scene.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A world as data: entities, their components, and every value under a **stable field id**.
|
|
3
|
+
*
|
|
4
|
+
* Ids rather than names, so a field renamed with an explicit id still loads; never positions,
|
|
5
|
+
* because a field inserted in the middle would renumber every field after it and a save would load
|
|
6
|
+
* the right names with the wrong values — worse than failing, because it looks like it worked.
|
|
7
|
+
*
|
|
8
|
+
* ---
|
|
9
|
+
*
|
|
10
|
+
* ## Loading across a change goes through `migrate`, unchanged
|
|
11
|
+
*
|
|
12
|
+
* The file carries the **schema each component had when it was written**, so a load has both sides
|
|
13
|
+
* and can hand them to the migration Track N's Phase 5 built. A field added since arrives with its
|
|
14
|
+
* default, one removed is dropped, and one whose *type* changed is refused naming it rather than
|
|
15
|
+
* coerced. Nothing here re-implements any of that; if the migration needs changing, the change
|
|
16
|
+
* belongs in `driftscript` and serves both callers.
|
|
17
|
+
*
|
|
18
|
+
* ## Entity references are remapped, which is why the type has to be declared
|
|
19
|
+
*
|
|
20
|
+
* A saved entity's slot may be live in the world being loaded into, so a scene that kept its
|
|
21
|
+
* handles would overwrite whatever held them. Every entity is created fresh and every stored
|
|
22
|
+
* reference is rewritten through the map — which only works for fields **declared** `Entity`. A
|
|
23
|
+
* number that happens to hold one is a number, and is left alone. That is the cost of not guessing.
|
|
24
|
+
*
|
|
25
|
+
* ## It writes nothing until it can write everything
|
|
26
|
+
*
|
|
27
|
+
* A refusal on the fifth entity must not leave four in the world. Every migration runs first, into
|
|
28
|
+
* plain objects; the world is touched only once all of them have succeeded.
|
|
29
|
+
*/
|
|
30
|
+
import { type Schema, migrate } from 'driftscript';
|
|
31
|
+
import type { Entity } from './entity.ts';
|
|
32
|
+
import type { ComponentType } from './store.ts';
|
|
33
|
+
import type { World } from './world.ts';
|
|
34
|
+
|
|
35
|
+
export interface SerializedScene {
|
|
36
|
+
readonly version: 1;
|
|
37
|
+
/** What each component type looked like when this was written. The `from` half of a migration. */
|
|
38
|
+
readonly schemas: Readonly<Record<string, Schema>>;
|
|
39
|
+
readonly entities: readonly {
|
|
40
|
+
/** Component name → field id → value. Entity-typed values hold a scene index, not a handle. */
|
|
41
|
+
readonly components: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
|
|
42
|
+
}[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type LoadResult =
|
|
46
|
+
| { readonly loaded: true; readonly entities: readonly Entity[] }
|
|
47
|
+
| { readonly loaded: false; readonly reason: string };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Write a world out.
|
|
51
|
+
*
|
|
52
|
+
* The component types are passed rather than discovered, because a world holds a store for every
|
|
53
|
+
* type anything has ever asked it about — including empty ones a query created — and a scene is a
|
|
54
|
+
* decision about what to save rather than everything that happens to exist.
|
|
55
|
+
*/
|
|
56
|
+
export function serializeWorld(world: World, types: readonly ComponentType[]): SerializedScene {
|
|
57
|
+
/* Scene index per entity, so a stored reference is a position in this file rather than a handle
|
|
58
|
+
that means nothing in another world. */
|
|
59
|
+
/*
|
|
60
|
+
* Membership through a `Set`, not `Array.includes`.
|
|
61
|
+
*
|
|
62
|
+
* It was `includes`, which is a linear scan per entity and made this quadratic: **8.8 seconds**
|
|
63
|
+
* for a hundred thousand entities across three components, measured. With a set it is a fraction
|
|
64
|
+
* of that. The shape is worth naming because it reads as obviously correct — the list is the
|
|
65
|
+
* thing being built, so testing it is the natural move — and the cost is invisible until the
|
|
66
|
+
* world is large.
|
|
67
|
+
*/
|
|
68
|
+
const indexOf = new Map<Entity, number>();
|
|
69
|
+
const living: Entity[] = [];
|
|
70
|
+
for (const type of types) {
|
|
71
|
+
const store = world.store(type);
|
|
72
|
+
for (let i = 0; i < store.size; i += 1) {
|
|
73
|
+
const entity = store.dense[i] as Entity;
|
|
74
|
+
if (indexOf.has(entity)) continue;
|
|
75
|
+
indexOf.set(entity, living.length);
|
|
76
|
+
living.push(entity);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const schemas: Record<string, Schema> = {};
|
|
81
|
+
for (const type of types) schemas[type.name] = type.schema;
|
|
82
|
+
|
|
83
|
+
const entities = living.map((entity) => {
|
|
84
|
+
const components: Record<string, Record<string, unknown>> = {};
|
|
85
|
+
for (const type of types) {
|
|
86
|
+
if (!world.has(entity, type)) continue;
|
|
87
|
+
const fields: Record<string, unknown> = {};
|
|
88
|
+
for (const field of type.schema.fields) {
|
|
89
|
+
const value = world.read(entity, type, field.name);
|
|
90
|
+
fields[field.id] = field.type === 'Entity' ? (indexOf.get(value as Entity) ?? -1) : value;
|
|
91
|
+
}
|
|
92
|
+
components[type.name] = fields;
|
|
93
|
+
}
|
|
94
|
+
return { components };
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return { version: 1, schemas, entities };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Read a world in, or refuse in words having written nothing. */
|
|
101
|
+
export function deserializeWorld(
|
|
102
|
+
world: World,
|
|
103
|
+
scene: SerializedScene,
|
|
104
|
+
types: readonly ComponentType[],
|
|
105
|
+
): LoadResult {
|
|
106
|
+
const byName = new Map(types.map((type) => [type.name, type]));
|
|
107
|
+
|
|
108
|
+
/* Everything checked and migrated before the world is touched at all. */
|
|
109
|
+
const planned: { components: { type: ComponentType; values: Record<string, unknown> }[] }[] = [];
|
|
110
|
+
|
|
111
|
+
for (const record of scene.entities) {
|
|
112
|
+
const components: { type: ComponentType; values: Record<string, unknown> }[] = [];
|
|
113
|
+
for (const [name, saved] of Object.entries(record.components)) {
|
|
114
|
+
const type = byName.get(name);
|
|
115
|
+
if (type === undefined) {
|
|
116
|
+
return {
|
|
117
|
+
loaded: false,
|
|
118
|
+
reason:
|
|
119
|
+
`this scene holds a \`${name}\`, and no component type by that name was supplied. ` +
|
|
120
|
+
'Loading it as nothing would drop data the file still holds.',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const from = scene.schemas[name];
|
|
124
|
+
if (from === undefined) {
|
|
125
|
+
return {
|
|
126
|
+
loaded: false,
|
|
127
|
+
reason:
|
|
128
|
+
`this scene holds a \`${name}\` and no schema for it, so there is nothing to match ` +
|
|
129
|
+
'its fields by. A scene written without one cannot be loaded across any change at all.',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* `migrate` takes an instance keyed by the *saved* names, so the ids in the file are read
|
|
134
|
+
through the saved schema. That is what keeps the file addressed by id and the migration
|
|
135
|
+
unchanged from the one the language uses. */
|
|
136
|
+
const instance: Record<string, unknown> = {};
|
|
137
|
+
for (const field of from.fields) instance[field.name] = saved[field.id];
|
|
138
|
+
|
|
139
|
+
const defaults: Record<string, unknown> = {};
|
|
140
|
+
for (const field of type.schema.fields)
|
|
141
|
+
defaults[field.name] = field.type === 'String' ? null : 0;
|
|
142
|
+
|
|
143
|
+
const result = migrate(instance, from, type.schema, defaults);
|
|
144
|
+
if (!result.migrated) return { loaded: false, reason: result.reason };
|
|
145
|
+
components.push({ type, values: result.value });
|
|
146
|
+
}
|
|
147
|
+
planned.push({ components });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const made = planned.map(() => world.create());
|
|
151
|
+
planned.forEach((record, index) => {
|
|
152
|
+
const entity = made[index] as Entity;
|
|
153
|
+
for (const { type, values } of record.components) {
|
|
154
|
+
/* Scene indices become handles here, and only for fields the schema declares `Entity`. */
|
|
155
|
+
for (const field of type.schema.fields) {
|
|
156
|
+
if (field.type !== 'Entity') continue;
|
|
157
|
+
const at = values[field.name];
|
|
158
|
+
values[field.name] = typeof at === 'number' && at >= 0 && at < made.length ? made[at] : 0;
|
|
159
|
+
}
|
|
160
|
+
world.add(entity, type, values);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return { loaded: true, entities: made };
|
|
165
|
+
}
|
package/src/schedule.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The order systems run in, and what the declarations buy beyond it.
|
|
3
|
+
*
|
|
4
|
+
* **Order is declaration order, adjusted only by explicit `after`.** The design's first draft said
|
|
5
|
+
* the order was derived from the conflict graph — it cannot be: a conflict is *symmetric* (one
|
|
6
|
+
* writes what the other reads or writes), so every conflict would be a two-cycle and a topological
|
|
7
|
+
* sort would refuse every real schedule. What is genuinely directional is what an author states.
|
|
8
|
+
*
|
|
9
|
+
* So there are two things here, and they answer different questions:
|
|
10
|
+
*
|
|
11
|
+
* - **`after` gives ordering**, is directional, is topologically sorted with declaration order as
|
|
12
|
+
* the tie-break, and a cycle in it is refused naming both systems. That is a real cycle, because
|
|
13
|
+
* an author asked for two things each to follow the other.
|
|
14
|
+
* - **Conflicts give grouping.** Adjacent systems that touch nothing in common could run together,
|
|
15
|
+
* and `groups` reports which. It is **reported and not taken**: running two systems in workers
|
|
16
|
+
* means transferring or sharing component storage, which is a decision about `SharedArrayBuffer`
|
|
17
|
+
* and a consumer's deployment rather than about scheduling. The declarations are what make it
|
|
18
|
+
* decidable when somebody wants to decide it.
|
|
19
|
+
*/
|
|
20
|
+
import type { BoundSystem as BoundSystemType } from './system.ts';
|
|
21
|
+
import { BoundSystem, type SystemDefinition } from './system.ts';
|
|
22
|
+
import type { World } from './world.ts';
|
|
23
|
+
|
|
24
|
+
export interface Schedule {
|
|
25
|
+
/** The systems, in the order they run. */
|
|
26
|
+
readonly order: readonly SystemDefinition[];
|
|
27
|
+
/**
|
|
28
|
+
* Runs of adjacent systems that touch nothing in common.
|
|
29
|
+
*
|
|
30
|
+
* Reported rather than used. A group of one is a system that conflicts with its neighbour.
|
|
31
|
+
*/
|
|
32
|
+
readonly groups: readonly (readonly SystemDefinition[])[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Order the systems, refusing what cannot be ordered.
|
|
37
|
+
*
|
|
38
|
+
* Deterministic: the same definitions in the same order produce the same schedule on every machine
|
|
39
|
+
* and every run, which is what a replay of a recorded session depends on.
|
|
40
|
+
*/
|
|
41
|
+
export function buildSchedule(systems: readonly SystemDefinition[]): Schedule {
|
|
42
|
+
const byName = new Map<string, number>();
|
|
43
|
+
systems.forEach((system, index) => {
|
|
44
|
+
if (byName.has(system.name)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`\`${system.name}\` is declared more than once. \`after\` addresses a system by name, so ` +
|
|
47
|
+
'two of a name is an ordering constraint nobody can write.',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
byName.set(system.name, index);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
for (const system of systems) {
|
|
54
|
+
for (const name of system.after ?? []) {
|
|
55
|
+
if (!byName.has(name)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`\`${system.name}\` says it runs after \`${name}\`, which no system declares. A ` +
|
|
58
|
+
'constraint naming nothing is a constraint that silently does nothing.',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
* Kahn's algorithm over the `after` edges, taking the **lowest-numbered** ready system each time.
|
|
66
|
+
* That is the tie-break: among systems whose constraints are all satisfied, declaration order
|
|
67
|
+
* decides, so the result is a total order rather than whichever the set happened to yield.
|
|
68
|
+
*/
|
|
69
|
+
const order: SystemDefinition[] = [];
|
|
70
|
+
const done = new Set<string>();
|
|
71
|
+
|
|
72
|
+
for (let placed = 0; placed < systems.length; placed += 1) {
|
|
73
|
+
let chosen = -1;
|
|
74
|
+
for (let i = 0; i < systems.length; i += 1) {
|
|
75
|
+
const system = systems[i] as SystemDefinition;
|
|
76
|
+
if (done.has(system.name)) continue;
|
|
77
|
+
const waiting = (system.after ?? []).some((name) => !done.has(name));
|
|
78
|
+
if (waiting) continue;
|
|
79
|
+
chosen = i;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (chosen < 0) {
|
|
84
|
+
const stuck = systems.filter((system) => !done.has(system.name)).map((s) => s.name);
|
|
85
|
+
throw new Error(
|
|
86
|
+
`these systems each say they run after another of them, so none can go first: ` +
|
|
87
|
+
`${stuck.join(', ')}. One of the \`after\` clauses has to give.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const system = systems[chosen] as SystemDefinition;
|
|
92
|
+
done.add(system.name);
|
|
93
|
+
order.push(system);
|
|
94
|
+
}
|
|
95
|
+
return { order, groups: group(order) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Which of a component type's ids a system touches at all. */
|
|
99
|
+
function touched(system: SystemDefinition): Set<number> {
|
|
100
|
+
const ids = new Set<number>();
|
|
101
|
+
for (const type of system.reads ?? []) ids.add(type.id);
|
|
102
|
+
for (const type of system.writes ?? []) ids.add(type.id);
|
|
103
|
+
return ids;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether two systems must not run at the same time: one writes what the other touches. */
|
|
107
|
+
function conflicts(a: SystemDefinition, b: SystemDefinition): boolean {
|
|
108
|
+
const bTouched = touched(b);
|
|
109
|
+
for (const type of a.writes ?? []) if (bTouched.has(type.id)) return true;
|
|
110
|
+
const aTouched = touched(a);
|
|
111
|
+
for (const type of b.writes ?? []) if (aTouched.has(type.id)) return true;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function group(order: readonly SystemDefinition[]): (readonly SystemDefinition[])[] {
|
|
116
|
+
const groups: SystemDefinition[][] = [];
|
|
117
|
+
let current: SystemDefinition[] = [];
|
|
118
|
+
for (const system of order) {
|
|
119
|
+
if (current.length > 0 && current.some((other) => conflicts(other, system))) {
|
|
120
|
+
groups.push(current);
|
|
121
|
+
current = [];
|
|
122
|
+
}
|
|
123
|
+
current.push(system);
|
|
124
|
+
}
|
|
125
|
+
if (current.length > 0) groups.push(current);
|
|
126
|
+
return groups;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Systems bound to a world, made once and reused for the life of it. */
|
|
130
|
+
const bound = new WeakMap<World, Map<SystemDefinition, BoundSystemType>>();
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Systems whose failure has already been reported, per world.
|
|
134
|
+
*
|
|
135
|
+
* A declaration error repeats every step by construction, so the message is said once. Keyed by
|
|
136
|
+
* definition rather than counted, because what a reader needs is the name of each broken system
|
|
137
|
+
* and not how many times it has failed.
|
|
138
|
+
*/
|
|
139
|
+
const reported = new WeakMap<World, Set<SystemDefinition>>();
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Run one fixed step of a schedule.
|
|
143
|
+
*
|
|
144
|
+
* `tick` is the simulation's step count and is what `everyTicks` strides over — so a system at
|
|
145
|
+
* stride 60 runs on tick 0, 60, 120, in every replay of the same input.
|
|
146
|
+
*
|
|
147
|
+
* **A system that throws does not take the rest of the tick with it, since 2026-08-28.** It used
|
|
148
|
+
* to: the throw left this function, so every system scheduled after the failing one never ran —
|
|
149
|
+
* once per tick, for as long as the game was up. Reported from outside, and the way it was found is
|
|
150
|
+
* the argument for containing it. A system queried a component it had not declared, which throws by
|
|
151
|
+
* design; the system that stopped was three places later and was the one stepping a vehicle; the
|
|
152
|
+
* symptom was *a car moving in jerks*, and the only evidence was a console line no headless check
|
|
153
|
+
* reads. A broken system must not be able to silently starve its neighbours.
|
|
154
|
+
*
|
|
155
|
+
* So each system's failure is caught, reported **once** naming the system and the reason, and the
|
|
156
|
+
* schedule carries on. Nothing is disabled: a declaration error will throw again next tick and be
|
|
157
|
+
* caught again, which costs a throw per tick and keeps a transient failure recoverable — and this
|
|
158
|
+
* function no longer throws, which is what `AGENTS.md` requires of anything the frame loop calls.
|
|
159
|
+
*
|
|
160
|
+
* **What it gives up** is the stack reaching a debugger's uncaught-exception break, which is how
|
|
161
|
+
* somebody working on a system would rather meet it. **What would change it** is a consumer wanting
|
|
162
|
+
* failures fatal in development; the answer then is a reporter this takes as a parameter rather than
|
|
163
|
+
* a rule chosen here for everybody.
|
|
164
|
+
*/
|
|
165
|
+
export function runSchedule(world: World, schedule: Schedule, tick: number): void {
|
|
166
|
+
let forWorld = bound.get(world);
|
|
167
|
+
if (forWorld === undefined) {
|
|
168
|
+
forWorld = new Map();
|
|
169
|
+
bound.set(world, forWorld);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const definition of schedule.order) {
|
|
173
|
+
const stride = definition.everyTicks ?? 1;
|
|
174
|
+
if (stride > 1 && tick % stride !== 0) continue;
|
|
175
|
+
|
|
176
|
+
let system = forWorld.get(definition);
|
|
177
|
+
if (system === undefined) {
|
|
178
|
+
system = new BoundSystem(world, definition);
|
|
179
|
+
forWorld.set(definition, system);
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
system.run();
|
|
183
|
+
} catch (error) {
|
|
184
|
+
announce(world, definition, error);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Say what failed, once per system per world. */
|
|
190
|
+
function announce(world: World, definition: SystemDefinition, error: unknown): void {
|
|
191
|
+
let seen = reported.get(world);
|
|
192
|
+
if (seen === undefined) {
|
|
193
|
+
seen = new Set();
|
|
194
|
+
reported.set(world, seen);
|
|
195
|
+
}
|
|
196
|
+
if (seen.has(definition)) return;
|
|
197
|
+
seen.add(definition);
|
|
198
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
199
|
+
console.error(
|
|
200
|
+
`[driftengine] the system \`${definition.name}\` threw and was skipped; every other system ` +
|
|
201
|
+
`in the schedule still ran. This is said once, and it will keep happening every tick until ` +
|
|
202
|
+
`it is fixed: ${reason}`,
|
|
203
|
+
);
|
|
204
|
+
}
|