@vworlds/vecs 1.0.0 → 1.0.2

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/src/world.ts DELETED
@@ -1,534 +0,0 @@
1
- import {
2
- Component,
3
- ComponentClassOrType,
4
- ComponentMeta,
5
- Hook,
6
- } from "./component.js";
7
- import { Entity } from "./entity.js";
8
- import { System } from "./system.js";
9
- import { ArrayMap } from "./util/array_map.js";
10
- import { IPhase, Phase } from "./phase.js";
11
-
12
- const LOCAL_COMPONENT_MIN = 256;
13
-
14
- /**
15
- * The central ECS container.
16
- *
17
- * A `World` owns all entities, components, systems, and the update pipeline.
18
- * Typical lifecycle:
19
- *
20
- * 1. **Register components** — call {@link registerComponent} (and optionally
21
- * {@link registerComponentType}) for every component class.
22
- * 2. **Register systems** — call {@link system} (or {@link addSystem}) to
23
- * create and configure {@link System | systems}.
24
- * 3. **Start** — call {@link start} to freeze registration and sort systems
25
- * into their phases.
26
- * 4. **Run loop** — call {@link runPhase} once per frame for each phase.
27
- *
28
- * ```ts
29
- * const world = new World();
30
- *
31
- * world.registerComponent(Position);
32
- * world.registerComponent(Velocity);
33
- *
34
- * world.system("Move")
35
- * .requires(Position, Velocity)
36
- * .update(Position, (pos) => { pos.x += vel.x; });
37
- *
38
- * world.start();
39
- *
40
- * // game loop:
41
- * world.runPhase(updatePhase, Date.now(), 16);
42
- * ```
43
- */
44
- export class World {
45
- private entities = new Map<number, Entity>(); // maps entity Id to Entity
46
- private componentNameTypeMap = new Map<string, number>();
47
- private archChangeQueue: Entity[] = [];
48
- private destroyedEntities: Entity[] = [];
49
- private pendingSystems: System[] = [];
50
- private allSystems: System[] = [];
51
-
52
- private Class2Meta = new Map<typeof Component, ComponentMeta>();
53
- private Type2Meta = new ArrayMap<ComponentMeta>();
54
- private updatedComponents: Component[] = [];
55
- private localComponentCounter = LOCAL_COMPONENT_MIN;
56
- private componentRegistrationDisabled = false;
57
- private systemRegistrationDisabled = false;
58
- private pipeline = new Map<string, Phase>();
59
- private eidCounter = 0;
60
- constructor() {}
61
-
62
- /**
63
- * Return the entity with id `eid`, creating it if it does not yet exist.
64
- *
65
- * Used by networking code to materialise server-assigned entities:
66
- *
67
- * ```ts
68
- * const e = world.getOrCreateEntity(snapshot.eid, (e) => {
69
- * networkEntities.add(e);
70
- * });
71
- * const c = e.add(snapshot.type, false);
72
- * ```
73
- *
74
- * @param eid - The entity id to look up or create.
75
- * @param onCreateCallback - Optional callback invoked only when a **new**
76
- * entity is created, before it is returned. Use this to initialise
77
- * bookkeeping (e.g. tracking it in a local set).
78
- * @returns The existing or newly created entity.
79
- */
80
- public getOrCreateEntity(
81
- eid: number,
82
- onCreateCallback?: (e: Entity) => void
83
- ) {
84
- let e = this.entities.get(eid);
85
- if (!e) {
86
- e = new Entity(this, eid);
87
- this.entities.set(eid, e);
88
- if (onCreateCallback) onCreateCallback(e);
89
- }
90
- return e;
91
- }
92
-
93
- /**
94
- * Look up an entity by id.
95
- *
96
- * @param id - Numeric entity id.
97
- * @returns The entity, or `undefined` if no entity with that id exists.
98
- */
99
- public entity(id: number): Entity | undefined {
100
- return this.entities.get(id);
101
- }
102
-
103
- /**
104
- * Create a new entity with an auto-assigned id and register it in the world.
105
- *
106
- * The id counter starts at 0 (or at the value set by
107
- * {@link setEntityIdRange}) and increments by one for each call.
108
- *
109
- * @returns The new entity.
110
- */
111
- public createEntity(): Entity {
112
- const eid = this.eidCounter++;
113
- const e = new Entity(this, eid);
114
- this.entities.set(eid, e);
115
- return e;
116
- }
117
-
118
- /**
119
- * Set the starting value for the auto-incrementing entity id counter.
120
- *
121
- * Must be called **before** {@link start} (or
122
- * {@link disableComponentRegistration}). Useful when the world runs alongside
123
- * a server that owns a different id range — for example, locally-created
124
- * client entities can start at a high offset to avoid collisions with
125
- * server-assigned ids.
126
- *
127
- * @param min - The first id that will be assigned by {@link createEntity}.
128
- * @throws If called after registration has been disabled.
129
- */
130
- public setEntityIdRange(min: number) {
131
- if (this.componentRegistrationDisabled)
132
- throw "setEntityIdRange must be called before component registration is disabled";
133
- this.eidCounter = min;
134
- }
135
-
136
- private getComponentInstance(
137
- typeOrClass: ComponentClassOrType,
138
- entity: Entity
139
- ) {
140
- const meta = this.getComponentMeta(typeOrClass);
141
- const c = new meta.Class(entity, meta);
142
- const hook = meta["onAddHandler"];
143
- if (hook) hook(c);
144
-
145
- return c;
146
- }
147
-
148
- /**
149
- * Retrieve the {@link ComponentMeta} record for a registered component.
150
- *
151
- * @param typeOrClass - A component class constructor or a numeric type id.
152
- * @returns The corresponding `ComponentMeta`.
153
- * @throws If no component with that class or type id has been registered.
154
- */
155
- public getComponentMeta(typeOrClass: ComponentClassOrType) {
156
- let meta: ComponentMeta | undefined;
157
- if (typeof typeOrClass === "function") {
158
- meta = this.Class2Meta.get(typeOrClass);
159
- } else {
160
- meta = this.Type2Meta.get(typeOrClass);
161
- }
162
- if (!meta)
163
- throw `unregistered component meta for component type or class '${typeOrClass}'`;
164
- return meta;
165
- }
166
-
167
- /**
168
- * Resolve a component class or type id to its numeric type id.
169
- *
170
- * @param typeOrClass - A component class constructor or a numeric type id.
171
- * @returns The numeric type id.
172
- */
173
- public getComponentType(typeOrClass: ComponentClassOrType) {
174
- if (typeof typeOrClass === "function") {
175
- return this.getComponentMeta(typeOrClass).type;
176
- }
177
- return typeOrClass;
178
- }
179
-
180
- /**
181
- * Mark an entity's archetype as changed, queuing it for re-evaluation
182
- * against all system queries at the end of the current system run.
183
- *
184
- * Also recursively marks all children as changed so that `{ PARENT: ... }`
185
- * queries are re-evaluated.
186
- *
187
- * @internal Called automatically by {@link Entity.add} and
188
- * {@link Entity.remove}.
189
- */
190
- public archetypeChanged(e: Entity) {
191
- if (e._archetypeChanged) return;
192
- e._archetypeChanged = true;
193
- this.archChangeQueue.push(e);
194
- e.children.forEach((child) => this.archetypeChanged(child));
195
- }
196
-
197
- /** @internal */
198
- public _notifyComponentAdded(e: Entity, c: Component) {
199
- this.archetypeChanged(e);
200
- }
201
-
202
- /** @internal */
203
- public _notifyComponentRemoved(e: Entity, c: Component) {
204
- const hook = c.meta["onRemoveHandler"];
205
- if (hook) hook(c);
206
-
207
- this.archetypeChanged(e);
208
- }
209
-
210
- /** @internal */
211
- public _notifyEntityDestroyed(e: Entity) {
212
- if (!this.entities.delete(e.eid)) return;
213
- e.forEachComponent((c) => {
214
- e.remove(c.type);
215
- });
216
- this.destroyedEntities.push(e);
217
- }
218
-
219
- private updateArchetypes() {
220
- if (this.archChangeQueue.length > 0) {
221
- this.allSystems.forEach((s) => {
222
- this.archChangeQueue.forEach((e) => {
223
- if (s.belongs(e)) {
224
- e._addSystem(s);
225
- } else {
226
- e._removeSystem(s);
227
- }
228
- });
229
- });
230
- this.archChangeQueue.forEach((e) => {
231
- e.clearDeletedComponents();
232
- });
233
- }
234
-
235
- this.destroyedEntities.forEach((e) => {
236
- e["_destroy"]();
237
- });
238
- this.destroyedEntities.length = 0;
239
-
240
- this.updatedComponents.forEach((c) => {
241
- const hook = c.meta["onSetHandler"];
242
- if (hook) hook(c);
243
- c.entity._notifyModified(c);
244
- c["dirty"] = false;
245
- });
246
- this.archChangeQueue.forEach((e) => {
247
- e._updateSystems();
248
- e._archetypeChanged = false;
249
- });
250
- this.archChangeQueue.length = 0;
251
- this.updatedComponents.length = 0;
252
- }
253
-
254
- /** @internal Queues a component for onSet / update delivery. */
255
- public _queueUpdatedComponent(c: Component) {
256
- if (c["dirty"]) return;
257
- c["dirty"] = true;
258
- this.updatedComponents.push(c);
259
- }
260
-
261
- /**
262
- * Register a component class with the world.
263
- *
264
- * Must be called before any entity can use the component. Registration is
265
- * disabled once {@link start} is called.
266
- *
267
- * **Overloads:**
268
- * - `registerComponent(Class)` — type id auto-assigned from the name map, or
269
- * from a local counter (≥ 256) if the name is not yet mapped.
270
- * - `registerComponent(Class, type)` — explicit numeric type id.
271
- * - `registerComponent(Class, componentName)` — auto-assigned id, custom
272
- * display name (useful when the class name differs from the network name).
273
- * - `registerComponent(Class, type, componentName)` — explicit id + name.
274
- *
275
- * @param ComponentClass - The component class to register.
276
- * @throws If the class has already been registered, or if registration is
277
- * disabled.
278
- */
279
- public registerComponent(ComponentClass: typeof Component): void;
280
- public registerComponent(
281
- ComponentClass: typeof Component,
282
- type: number
283
- ): void;
284
- public registerComponent(
285
- ComponentClass: typeof Component,
286
- componentName?: string
287
- ): void;
288
- public registerComponent(
289
- ComponentClass: typeof Component,
290
- type: number,
291
- componentName: string
292
- ): void;
293
- public registerComponent(
294
- ComponentClass: typeof Component,
295
- typeOrComponentName?: number | string,
296
- componentName?: string
297
- ): void {
298
- if (this.componentRegistrationDisabled) {
299
- throw "World component registartion is disabled";
300
- }
301
- let type: number | undefined = undefined;
302
-
303
- // Determine if the second argument is type or componentName based on its type
304
- if (typeof typeOrComponentName === "number") {
305
- type = typeOrComponentName;
306
- } else if (typeof typeOrComponentName === "string") {
307
- componentName = typeOrComponentName;
308
- }
309
-
310
- componentName = componentName || ComponentClass.name;
311
- let local = false;
312
- if (type === undefined) {
313
- // attempt to get type id from name->type map
314
- type = this.componentNameTypeMap.get(componentName);
315
- if (type === undefined) {
316
- type = this.localComponentCounter++;
317
- local = true;
318
- }
319
- }
320
-
321
- let meta = this.Class2Meta.get(ComponentClass);
322
- if (meta) {
323
- if (local) this.localComponentCounter--;
324
- throw `Trying to register ${componentName} with type=${type} which is already registered to ${meta.componentName}`;
325
- }
326
- this.registerComponentType(componentName, type);
327
- meta = new ComponentMeta(ComponentClass, type, componentName);
328
- this.Class2Meta.set(ComponentClass, meta);
329
- this.Type2Meta.set(type, meta);
330
- console.log(
331
- "Registered component %s with type=%d as %s component",
332
- componentName,
333
- type,
334
- local ? "local" : "networked"
335
- );
336
- }
337
-
338
- /**
339
- * Pre-register a component name → type id mapping without associating a
340
- * class.
341
- *
342
- * Useful when network messages refer to components by type id and the
343
- * corresponding class may be registered later. Call this before
344
- * {@link registerComponent} to ensure the class picks up the server-assigned
345
- * id rather than a locally generated one.
346
- *
347
- * @param componentName - The string name used in network payloads.
348
- * @param type - The numeric type id assigned by the server.
349
- */
350
- public registerComponentType(componentName: string, type: number) {
351
- this.componentNameTypeMap.set(componentName, type);
352
- }
353
-
354
- /**
355
- * Register a pre-built {@link System} with the world.
356
- *
357
- * In most cases it is more convenient to use {@link system} which both
358
- * creates and registers in one call. Use `addSystem` when you need to
359
- * subclass `System` directly.
360
- *
361
- * @param s - The system to add.
362
- * @throws If system registration is disabled (after {@link start}).
363
- */
364
- public addSystem(s: System) {
365
- if (this.systemRegistrationDisabled)
366
- throw "System registration is disabled";
367
- this.pendingSystems.push(s);
368
- }
369
-
370
- /**
371
- * Create a new {@link System}, register it, and return it for configuration.
372
- *
373
- * This is the primary way to define systems:
374
- *
375
- * ```ts
376
- * world.system("Render")
377
- * .phase("update")
378
- * .requires(Position, Sprite)
379
- * .enter([Sprite], (e, [sprite]) => sprite.initialize(scene))
380
- * .update(Position, (pos) => { ... });
381
- * ```
382
- *
383
- * @param name - A unique display name for the system.
384
- * @returns The new `System` instance.
385
- */
386
- public system(name: string) {
387
- const system = new System(name, this);
388
- this.addSystem(system);
389
- return system;
390
- }
391
-
392
- /**
393
- * Prevent any further calls to {@link registerComponent}.
394
- *
395
- * Called automatically by {@link start}. Can be called early if you want to
396
- * lock component registration before systems are fully configured.
397
- */
398
- public disableComponentRegistration() {
399
- this.componentRegistrationDisabled = true;
400
- }
401
-
402
- /**
403
- * Freeze registration and prepare the world for running.
404
- *
405
- * Disables both component and system registration, then distributes all
406
- * pending systems into their assigned pipeline phases (defaulting to
407
- * `"update"`). Logs the resulting phase → system order to the console.
408
- *
409
- * Call this once, after all components and systems are registered but before
410
- * the first {@link runPhase} call.
411
- */
412
- public start() {
413
- this.componentRegistrationDisabled = true;
414
- this.systemRegistrationDisabled = true;
415
- this.reindexSystems();
416
- }
417
-
418
- private reindexSystems() {
419
- let _defaultPhase = this.pipeline.get("update");
420
- if (!_defaultPhase) {
421
- _defaultPhase = new Phase("update", this);
422
- this.pipeline.set(_defaultPhase.name, _defaultPhase);
423
- }
424
-
425
- const defaultPhase = _defaultPhase;
426
-
427
- this.pendingSystems.forEach((s) => {
428
- let phase = s._phase as Phase | undefined;
429
- if (typeof phase === "string") {
430
- phase = this.pipeline.get(phase);
431
- }
432
- phase = phase || defaultPhase;
433
- phase.systems.push(s);
434
- });
435
- this.pendingSystems.length = 0;
436
-
437
- this.allSystems.length = 0;
438
- this.pipeline.forEach((phase) => {
439
- this.allSystems.push(...phase.systems);
440
- console.log(
441
- "Phase %s : %s",
442
- phase.name,
443
- phase.systems.map((s) => s.name).join(" -> ")
444
- );
445
- });
446
- }
447
-
448
- /**
449
- * Return the {@link Hook} for a component class.
450
- *
451
- * Hooks let you react to component lifecycle events (add / remove / set)
452
- * without building a full {@link System}. The hook is backed by the
453
- * component's {@link ComponentMeta} and the same object is returned on every
454
- * call.
455
- *
456
- * ```ts
457
- * world.hook(Sprite)
458
- * .onAdd(c => c.initialize(scene))
459
- * .onRemove(c => c.destroy());
460
- * ```
461
- *
462
- * @param C - The component class.
463
- * @returns The `Hook` for that component type.
464
- */
465
- public hook<T extends typeof Component>(C: T): Hook<InstanceType<T>> {
466
- return this.getComponentMeta(C) as any;
467
- }
468
-
469
- /**
470
- * Add a named phase to the update pipeline and return it.
471
- *
472
- * Phases are executed in insertion order when you call {@link runPhase} for
473
- * each one. Systems are assigned to a phase via {@link System.phase}.
474
- *
475
- * ```ts
476
- * const preUpdate = world.addPhase("preupdate");
477
- * const update = world.addPhase("update");
478
- * const send = world.addPhase("send");
479
- * ```
480
- *
481
- * @param name - Unique phase name. Systems can reference it by this string.
482
- * @returns The new {@link IPhase}.
483
- */
484
- public addPhase(name: string): IPhase {
485
- const phase = new Phase(name, this);
486
- this.pipeline.set(name, phase);
487
- return phase;
488
- }
489
-
490
- /**
491
- * Execute all systems in the given phase for one tick.
492
- *
493
- * After each system runs, pending archetype changes (entity add/remove
494
- * component events) are flushed so that `enter` / `exit` callbacks are
495
- * delivered before the next system in the same phase executes.
496
- *
497
- * @param phase - The {@link IPhase} to run (returned by {@link addPhase}).
498
- * @param now - Absolute timestamp in milliseconds (e.g. `Date.now()`).
499
- * @param delta - Milliseconds elapsed since the previous tick.
500
- */
501
- public runPhase(phase: IPhase, now: number, delta: number) {
502
- (phase as Phase).systems.forEach((s) => {
503
- s._run(now, delta);
504
- this.updateArchetypes();
505
- });
506
- }
507
-
508
- /**
509
- * Run every phase in the pipeline in insertion order (the order phases were
510
- * registered via {@link addPhase}). Equivalent to calling
511
- * {@link runPhase} for each phase manually.
512
- *
513
- * @param now - Absolute timestamp in milliseconds (e.g. `Date.now()`).
514
- * @param delta - Milliseconds elapsed since the previous tick.
515
- */
516
- public progress(now: number, delta: number) {
517
- this.pipeline.forEach((phase) => {
518
- this.runPhase(phase, now, delta);
519
- });
520
- }
521
-
522
- /**
523
- * Destroy every entity currently tracked by the world.
524
- *
525
- * Triggers all `onRemove` hooks and `exit` callbacks. Useful when
526
- * transitioning between game sessions or resetting to a clean state.
527
- */
528
- public clearAllEntities() {
529
- this.entities.forEach((e) => {
530
- e.destroy();
531
- });
532
- this.entities.clear();
533
- }
534
- }
package/tests/_helpers.ts DELETED
@@ -1,30 +0,0 @@
1
- import { World } from "../src/index.js";
2
- import type { IPhase } from "../src/phase.js";
3
-
4
- /**
5
- * Build a world with a single phase that has at least one (no-op) system,
6
- * so that calling `tick()` always flushes archetype changes.
7
- *
8
- * Returns the world, the phase, and a `tick()` shorthand.
9
- */
10
- export function makeWorldWithFlushPhase(name = "p") {
11
- const w = new World();
12
- const phase = w.addPhase(name);
13
- // dummy system on this phase guarantees runPhase(phase) calls updateArchetypes.
14
- w.system("__flush__").phase(phase).run(() => {});
15
- return {
16
- w,
17
- phase,
18
- tick(now = 0, delta = 0) {
19
- w.runPhase(phase, now, delta);
20
- },
21
- start() {
22
- w.start();
23
- },
24
- } as {
25
- w: World;
26
- phase: IPhase;
27
- tick: (now?: number, delta?: number) => void;
28
- start: () => void;
29
- };
30
- }
@@ -1,68 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { ArrayMap } from "../src/util/array_map.js";
3
-
4
- describe("ArrayMap", () => {
5
- it("starts empty", () => {
6
- const m = new ArrayMap<string>();
7
- expect(m.size).toBe(0);
8
- expect(m.get(0)).toBeUndefined();
9
- expect(m.has(0)).toBe(false);
10
- });
11
-
12
- it("set/get/has round trip", () => {
13
- const m = new ArrayMap<string>();
14
- m.set(5, "hello");
15
- expect(m.get(5)).toBe("hello");
16
- expect(m.has(5)).toBe(true);
17
- expect(m.size).toBe(1);
18
- });
19
-
20
- it("size only increments for new keys", () => {
21
- const m = new ArrayMap<number>();
22
- m.set(1, 100);
23
- m.set(1, 200); // overwrite
24
- expect(m.size).toBe(1);
25
- expect(m.get(1)).toBe(200);
26
- });
27
-
28
- it("delete removes entry and decrements size", () => {
29
- const m = new ArrayMap<number>();
30
- m.set(2, 42);
31
- m.set(7, 99);
32
- expect(m.size).toBe(2);
33
- m.delete(2);
34
- expect(m.has(2)).toBe(false);
35
- expect(m.get(2)).toBeUndefined();
36
- expect(m.size).toBe(1);
37
- });
38
-
39
- it("delete on missing key is a no-op", () => {
40
- const m = new ArrayMap<number>();
41
- m.set(1, 1);
42
- m.delete(50);
43
- expect(m.size).toBe(1);
44
- });
45
-
46
- it("forEach skips undefined slots and reports key+value", () => {
47
- const m = new ArrayMap<string>();
48
- m.set(0, "a");
49
- m.set(2, "c");
50
- m.set(5, "f");
51
- const seen: Array<[string, number]> = [];
52
- m.forEach((v, k) => seen.push([v, k]));
53
- expect(seen).toEqual([
54
- ["a", 0],
55
- ["c", 2],
56
- ["f", 5],
57
- ]);
58
- });
59
-
60
- it("clear empties the map", () => {
61
- const m = new ArrayMap<number>();
62
- m.set(1, 1);
63
- m.set(2, 2);
64
- m.clear();
65
- expect(m.has(1)).toBe(false);
66
- expect(m.has(2)).toBe(false);
67
- });
68
- });