@codehz/ecs 0.10.0 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codehz/ecs",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "license": "MIT",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -3,6 +3,8 @@ import { ComponentEntityStore } from "../../component/entity-store";
3
3
  import { component, createEntityId, relation, type EntityId } from "../../entity";
4
4
  import { World } from "../../world/world";
5
5
 
6
+ function expectType<T>(_value: T): void {}
7
+
6
8
  describe("World - Singleton Component", () => {
7
9
  type GlobalConfig = { debug: boolean; version: string };
8
10
  type GameState = { score: number; level: number };
@@ -26,21 +28,64 @@ describe("World - Singleton Component", () => {
26
28
  const world = new World();
27
29
  const singleton = world.singleton(GlobalConfigId);
28
30
  const Marker = component<void>();
31
+ const originalWarn = console.warn;
32
+ const warnings: string[] = [];
33
+
34
+ console.warn = (...args: unknown[]) => {
35
+ warnings.push(args.join(" "));
36
+ };
37
+
38
+ try {
39
+ world.set(GlobalConfigId, Marker);
40
+ } finally {
41
+ console.warn = originalWarn;
42
+ }
29
43
 
30
- world.set(GlobalConfigId, Marker);
31
44
  world.sync();
32
45
 
33
46
  expect(world.has(GlobalConfigId, Marker)).toBe(true);
34
47
  expect(singleton.has()).toBe(false);
48
+ expect(warnings).toHaveLength(0);
35
49
  });
36
50
 
37
- it("should reject the removed singleton data shorthand at runtime", () => {
51
+ it("should support the deprecated singleton data shorthand for non-number values", () => {
38
52
  const world = new World();
39
53
  const config: GlobalConfig = { debug: true, version: "1.0.0" };
54
+ const originalWarn = console.warn;
55
+ const warnings: string[] = [];
40
56
 
41
- expect(() => {
42
- world.set(GlobalConfigId as any, config as any);
43
- }).toThrow("Invalid component type");
57
+ if (false) {
58
+ expectType<void>(world.set(GlobalConfigId, config));
59
+ }
60
+
61
+ console.warn = (...args: unknown[]) => {
62
+ warnings.push(args.join(" "));
63
+ };
64
+
65
+ try {
66
+ world.set(GlobalConfigId, config);
67
+ } finally {
68
+ console.warn = originalWarn;
69
+ }
70
+
71
+ world.sync();
72
+
73
+ expect(world.get(GlobalConfigId)).toEqual(config);
74
+ expect(warnings).toHaveLength(1);
75
+ expect(warnings[0]).toContain("deprecated");
76
+ expect(warnings[0]).toContain("world.singleton(componentId).set(value)");
77
+ });
78
+
79
+ it("should not expose the deprecated shorthand for numeric singleton types at the type level", () => {
80
+ const world = new World();
81
+ const Score = component<number>();
82
+
83
+ if (false) {
84
+ // @ts-expect-error Numeric singleton shorthand is intentionally unsupported.
85
+ expectType<void>(world.set(Score, 123));
86
+ }
87
+
88
+ expect(true).toBe(true);
44
89
  });
45
90
 
46
91
  it("should manage singleton data through an explicit handle", () => {
@@ -1,5 +1,5 @@
1
1
  import type { ComponentId, EntityId } from "../entity";
2
- import { getDetailedIdType } from "../entity";
2
+ import { getDetailedIdType, isComponentId } from "../entity";
3
3
 
4
4
  /**
5
5
  * Validation and overload-resolution helpers extracted from World.
@@ -51,14 +51,26 @@ export function resolveSetOperation(
51
51
  entityId: EntityId | ComponentId,
52
52
  componentTypeOrComponent?: EntityId | any,
53
53
  maybeComponent?: any,
54
+ argCount = 3,
54
55
  exists: (id: EntityId) => boolean = () => true, // default permissive for tests / internal
55
- ): { entityId: EntityId; componentType: EntityId; component: any } {
56
+ ): { entityId: EntityId; componentType: EntityId; component: any; deprecatedSingletonShorthand: boolean } {
56
57
  const targetEntityId = entityId as EntityId;
58
+
59
+ if (argCount === 2 && isComponentId(targetEntityId) && typeof componentTypeOrComponent !== "number") {
60
+ assertEntityExists(targetEntityId, "Component entity", exists);
61
+ return {
62
+ entityId: targetEntityId,
63
+ componentType: targetEntityId,
64
+ component: componentTypeOrComponent,
65
+ deprecatedSingletonShorthand: true,
66
+ };
67
+ }
68
+
57
69
  const componentType = componentTypeOrComponent as EntityId;
58
70
  assertEntityExists(targetEntityId, "Entity", exists);
59
71
  assertSetComponentTypeValid(componentType);
60
72
 
61
- return { entityId: targetEntityId, componentType, component: maybeComponent };
73
+ return { entityId: targetEntityId, componentType, component: maybeComponent, deprecatedSingletonShorthand: false };
62
74
  }
63
75
 
64
76
  /**
@@ -11,8 +11,9 @@ export interface SingletonHandleOps<T> {
11
11
  /**
12
12
  * Explicit handle for a singleton component (component-as-entity).
13
13
  *
14
- * This provides an explicit and concise API for singleton components without
15
- * overloading `world.set()` semantics.
14
+ * This is the preferred API for singleton components.
15
+ * `world.set(componentId, value)` remains available only as a deprecated
16
+ * compatibility shorthand.
16
17
  *
17
18
  * @example
18
19
  * const config = world.singleton(Config);
@@ -52,6 +52,9 @@ import { SingletonHandle } from "./singleton";
52
52
  * Manages entities and components
53
53
  */
54
54
  export class World {
55
+ private static readonly DEPRECATED_SINGLETON_SET_SHORTHAND_WARNING =
56
+ "world.set(componentId, value) for singleton components is deprecated; use world.singleton(componentId).set(value) or world.set(componentId, componentId, value) instead.";
57
+
55
58
  // Core data structures for entity and archetype management
56
59
  private entityIdManager = new EntityIdManager();
57
60
  private entityReferences: EntityReferencesMap = new Map();
@@ -270,46 +273,59 @@ export class World {
270
273
  }
271
274
 
272
275
  /**
273
- * Adds or updates a component on an entity (or marks void component as present).
276
+ * Marks a void component as present on an entity.
274
277
  * The change is buffered and takes effect after calling `world.sync()`.
275
- * If the entity does not exist, throws an error.
276
278
  *
277
- * @overload set(entityId: EntityId, componentType: EntityId<void>): void
278
- * Marks a void component as present on the entity
279
+ * @throws {Error} If the entity does not exist
280
+ * @throws {Error} If the component type is invalid or is a wildcard relation
281
+ *
282
+ * @example
283
+ * world.set(entity, Marker);
284
+ * world.sync();
285
+ */
286
+ set(entityId: EntityId, componentType: EntityId<void>): void;
287
+ /**
288
+ * @deprecated Use `world.singleton(componentId).set(value)` or `world.set(componentId, componentId, value)` instead.
289
+ * Compatibility shorthand for singleton component data when the second argument is not a number.
279
290
  *
280
- * @overload set<T>(entityId: EntityId, componentType: EntityId<T>, component: NoInfer<T>): void
281
- * Adds or updates a component with data on the entity
291
+ * @throws {Error} If the component entity does not exist
292
+ *
293
+ * @example
294
+ * world.set(GlobalConfig, { debug: true });
295
+ * world.sync();
296
+ */
297
+ set<T>(componentId: ComponentId<T>, component: Exclude<NoInfer<T>, number>): void;
298
+ /**
299
+ * Adds or updates component data on an entity.
300
+ * The change is buffered and takes effect after calling `world.sync()`.
282
301
  *
283
302
  * @throws {Error} If the entity does not exist
284
303
  * @throws {Error} If the component type is invalid or is a wildcard relation
285
304
  *
286
305
  * @example
287
306
  * world.set(entity, Position, { x: 10, y: 20 });
288
- * world.set(entity, Marker); // void component
289
- * world.singleton(GlobalConfig).set({ debug: true }); // singleton component
290
- * world.sync(); // Apply changes
307
+ * world.sync();
291
308
  */
292
- set(entityId: EntityId, componentType: EntityId<void>): void;
293
309
  set<T>(entityId: EntityId, componentType: EntityId<T>, component: NoInfer<T>): void;
310
+ /** Internal implementation for `set()` overloads. */
294
311
  set(entityId: EntityId | ComponentId, componentTypeOrComponent?: EntityId | any, maybeComponent?: any): void {
295
312
  const {
296
313
  entityId: targetEntityId,
297
314
  componentType,
298
315
  component,
299
- } = resolveSetOperation(entityId, componentTypeOrComponent, maybeComponent, (id) => this.exists(id));
316
+ deprecatedSingletonShorthand,
317
+ } = resolveSetOperation(entityId, componentTypeOrComponent, maybeComponent, arguments.length, (id) =>
318
+ this.exists(id),
319
+ );
320
+ if (deprecatedSingletonShorthand) {
321
+ console.warn(World.DEPRECATED_SINGLETON_SET_SHORTHAND_WARNING);
322
+ }
300
323
  this.commandBuffer.set(targetEntityId, componentType, component);
301
324
  }
302
325
 
303
326
  /**
304
327
  * Removes a component from an entity.
305
328
  * The change is buffered and takes effect after calling `world.sync()`.
306
- * If the entity does not exist, throws an error.
307
- *
308
- * @overload remove<T>(entityId: EntityId, componentType: EntityId<T>): void
309
- * Removes a component from an entity.
310
- *
311
- * @overload remove<T>(componentId: ComponentId<T>): void
312
- * Removes a singleton component (shorthand for remove(componentId, componentId)).
313
329
  *
314
330
  * @template T - The component data type
315
331
  * @param entityId - The entity identifier
@@ -320,11 +336,22 @@ export class World {
320
336
  *
321
337
  * @example
322
338
  * world.remove(entity, Position);
339
+ * world.sync(); // Apply changes
340
+ */
341
+ remove<T>(entityId: EntityId, componentType: EntityId<T>): void;
342
+ /**
343
+ * Removes a singleton component (shorthand for remove(componentId, componentId)).
344
+ * The change is buffered and takes effect after calling `world.sync()`.
345
+ *
346
+ * @template T - The component data type
347
+ *
348
+ * @throws {Error} If the component entity does not exist
349
+ *
350
+ * @example
323
351
  * world.remove(GlobalConfig); // Remove singleton component
324
352
  * world.sync(); // Apply changes
325
353
  */
326
354
  remove<T>(componentId: ComponentId<T>): void;
327
- remove<T>(entityId: EntityId, componentType: EntityId<T>): void;
328
355
  remove<T>(entityId: EntityId | ComponentId, componentType?: EntityId<T>): void {
329
356
  const { entityId: targetEntityId, componentType: targetComponentType } = resolveRemoveOperation(
330
357
  entityId,
@@ -384,15 +411,8 @@ export class World {
384
411
  *
385
412
  * Immediately reflects the current state without waiting for `sync()`.
386
413
  *
387
- * @overload has<T>(entityId: EntityId, componentType: EntityId<T>): boolean
388
- * Checks if a specific component type is present on the entity.
389
- *
390
- * @overload has<T>(componentId: ComponentId<T>): boolean
391
- * Shorthand for checking a **singleton component** — a component that is its own
392
- * entity (component-as-entity pattern). Equivalent to `has(componentId, componentId)`.
393
- *
394
414
  * @template T - The component data type
395
- * @param entityId - The entity identifier, or a singleton component ID
415
+ * @param entityId - The entity identifier
396
416
  * @param componentType - The component type to check
397
417
  * @returns `true` if the entity has the component, `false` otherwise
398
418
  *
@@ -401,17 +421,24 @@ export class World {
401
421
  * if (world.has(entity, Position)) {
402
422
  * const pos = world.get(entity, Position);
403
423
  * }
424
+ */
425
+ has<T>(entityId: EntityId, componentType: EntityId<T>): boolean;
426
+ /**
427
+ * Checks if a **singleton component** (component-as-entity) is present.
428
+ * Equivalent to `has(componentId, componentId)`.
429
+ *
430
+ * Immediately reflects the current state without waiting for `sync()`.
404
431
  *
405
- * // Check a singleton component (component-as-entity)
432
+ * @template T - The component data type
433
+ * @param componentId - The singleton component ID
434
+ * @returns `true` if the singleton component exists, `false` otherwise
435
+ *
436
+ * @example
406
437
  * if (world.has(GlobalConfig)) {
407
438
  * const config = world.get(GlobalConfig);
408
439
  * }
409
- *
410
- * // Use exists() for entity liveness checks
411
- * if (world.exists(entity)) { ... }
412
440
  */
413
441
  has<T>(componentId: ComponentId<T>): boolean;
414
- has<T>(entityId: EntityId, componentType: EntityId<T>): boolean;
415
442
  has<T>(entityId: EntityId | ComponentId, componentType?: EntityId<T>): boolean {
416
443
  // Handle singleton component overload: has(componentId)
417
444
  if (componentType === undefined) {
@@ -445,28 +472,45 @@ export class World {
445
472
 
446
473
  /**
447
474
  * Retrieves a component from an entity.
448
- * For wildcard relations, returns all relations of that type.
449
475
  * Throws an error if the component does not exist; use `has()` to check first or use `getOptional()`.
450
476
  *
451
- * @overload get<T>(entityId: EntityId<T>): T
452
- * When called with only an entity ID, retrieves the entity's primary component.
477
+ * @template T - The component data type
478
+ * @param entityId - The entity identifier
479
+ * @param componentType - The component type to retrieve
453
480
  *
454
- * @overload get<T>(entityId: EntityId, componentType: WildcardRelationId<T>): [EntityId<unknown>, T][]
455
- * For wildcard relations, returns an array of [target entity, component value] pairs.
481
+ * @throws {Error} If the entity does not exist
482
+ * @throws {Error} If the component does not exist on the entity
456
483
  *
457
- * @overload get<T>(entityId: EntityId, componentType: EntityId<T>): T
458
- * Retrieves a specific component from the entity.
484
+ * @example
485
+ * const position = world.get(entity, Position);
486
+ */
487
+ get<T>(entityId: EntityId, componentType: EntityId<T>): T;
488
+ /**
489
+ * Retrieves all relations of a given wildcard type for an entity.
490
+ * Returns an array of [target entity, component value] pairs.
491
+ *
492
+ * @template T - The component data type
493
+ * @param entityId - The entity identifier
494
+ * @param componentType - The wildcard relation type
495
+ * @returns Array of [target entity, component value] pairs
459
496
  *
460
497
  * @throws {Error} If the entity does not exist
461
- * @throws {Error} If the component does not exist on the entity
462
498
  *
463
499
  * @example
464
- * const position = world.get(entity, Position); // Throws if no Position
465
- * const relations = world.get(entity, relation(Parent, "*")); // Wildcard relation
500
+ * const relations = world.get(entity, relation(Parent, "*"));
466
501
  */
467
- get<T>(entityId: EntityId<T>): T;
468
502
  get<T>(entityId: EntityId, componentType: WildcardRelationId<T>): [EntityId<unknown>, T][];
469
- get<T>(entityId: EntityId, componentType: EntityId<T>): T;
503
+ /**
504
+ * Retrieves the entity's primary component when called with only an entity ID.
505
+ *
506
+ * @template T - The component data type
507
+ * @param entityId - The entity identifier
508
+ * @returns The component value
509
+ *
510
+ * @throws {Error} If the entity does not exist
511
+ * @throws {Error} If the component does not exist on the entity
512
+ */
513
+ get<T>(entityId: EntityId<T>): T;
470
514
  get<T>(
471
515
  entityId: EntityId,
472
516
  componentType: EntityId<T> | WildcardRelationId<T> = entityId as EntityId<T>,
@@ -505,17 +549,11 @@ export class World {
505
549
  /**
506
550
  * Safely retrieves a component from an entity without throwing an error.
507
551
  * Returns `undefined` if the component does not exist.
508
- * For wildcard relations, returns `undefined` if there are no relations.
509
552
  *
510
553
  * @template T - The component data type
511
- * @overload getOptional<T>(entityId: EntityId<T>): { value: T } | undefined
512
- * Retrieves the entity's primary component safely.
513
- *
514
- * @overload getOptional<T>(entityId: EntityId, componentType: WildcardRelationId<T>): { value: [EntityId<unknown>, T][] } | undefined
515
- * Retrieves all matching relation values safely.
516
- *
517
- * @overload getOptional<T>(entityId: EntityId, componentType: EntityId<T>): { value: T } | undefined
518
- * Retrieves a specific component safely.
554
+ * @param entityId - The entity identifier
555
+ * @param componentType - The component type to retrieve
556
+ * @returns The component value wrapped in `{ value }`, or `undefined` if absent
519
557
  *
520
558
  * @throws {Error} If the entity does not exist
521
559
  *
@@ -525,12 +563,33 @@ export class World {
525
563
  * console.log(position.value.x);
526
564
  * }
527
565
  */
528
- getOptional<T>(entityId: EntityId<T>): { value: T } | undefined;
566
+ getOptional<T>(entityId: EntityId, componentType: EntityId<T>): { value: T } | undefined;
567
+ /**
568
+ * Safely retrieves all matching relation values for a wildcard relation type.
569
+ * Returns `undefined` if there are no relations.
570
+ *
571
+ * @template T - The component data type
572
+ * @param entityId - The entity identifier
573
+ * @param componentType - The wildcard relation type
574
+ * @returns Array of [target, value] pairs wrapped in `{ value }`, or `undefined` if none
575
+ *
576
+ * @throws {Error} If the entity does not exist
577
+ */
529
578
  getOptional<T>(
530
579
  entityId: EntityId,
531
580
  componentType: WildcardRelationId<T>,
532
581
  ): { value: [EntityId<unknown>, T][] } | undefined;
533
- getOptional<T>(entityId: EntityId, componentType: EntityId<T>): { value: T } | undefined;
582
+ /**
583
+ * Safely retrieves the entity's primary component without throwing an error.
584
+ * Returns `undefined` if the component does not exist.
585
+ *
586
+ * @template T - The component data type
587
+ * @param entityId - The entity identifier
588
+ * @returns The component value wrapped in `{ value }`, or `undefined` if absent
589
+ *
590
+ * @throws {Error} If the entity does not exist
591
+ */
592
+ getOptional<T>(entityId: EntityId<T>): { value: T } | undefined;
534
593
  getOptional<T>(
535
594
  entityId: EntityId,
536
595
  componentType: EntityId<T> | WildcardRelationId<T> = entityId as EntityId<T>,
@@ -770,21 +829,15 @@ export class World {
770
829
 
771
830
  /**
772
831
  * Registers a lifecycle hook that responds to component changes.
773
- * The hook callback is invoked when components matching the specified types are added, updated, or removed.
774
- * @overload hook<const T extends readonly ComponentType<any>[]>(
775
- * componentTypes: T,
776
- * hook: LifecycleHook<T> | LifecycleCallback<T>,
777
- * filter?: QueryFilter,
778
- * ): () => void
779
- * Registers a hook for multiple component types.
780
- * The hook is triggered when entities enter/exit the matching set.
832
+ * The hook callback is invoked when components matching the specified types
833
+ * are added, updated, or removed.
781
834
  *
782
835
  * @param componentTypes - Component types that define the matching entity set
783
836
  * @param hook - Either a hook object with on_init/on_set/on_remove handlers, or a callback function
784
837
  * @param filter - Optional query-style filter applied to the hook match set
785
838
  * @returns A function that unsubscribes the hook when called
786
839
  *
787
- * @throws {Error} If no required components are specified in array overload
840
+ * @throws {Error} If no required components are specified
788
841
  *
789
842
  * @example
790
843
  * const unsubscribe = world.hook([Position, Velocity], {
@@ -1069,32 +1122,30 @@ export class World {
1069
1122
 
1070
1123
  /**
1071
1124
  * Queries entities with specific components.
1072
- * For simpler use cases, prefer using `createQuery()` with `forEach()` which is cached and more efficient.
1073
- *
1074
- * @overload query(componentTypes: EntityId<any>[]): EntityId[]
1075
1125
  * Returns an array of entity IDs that have all specified components.
1076
- *
1077
- * @overload query<const T extends readonly EntityId<any>[]>(
1078
- * componentTypes: T,
1079
- * includeComponents: true,
1080
- * ): Array<{ entity: EntityId; components: ComponentTuple<T> }>
1081
- * Returns entities along with their component data.
1126
+ * For simpler use cases, prefer using `createQuery()` with `forEach()` which is cached and more efficient.
1082
1127
  *
1083
1128
  * @param componentTypes - Array of component types to query
1084
- * @param includeComponents - If true, includes component data in results
1085
- * @returns Array of entity IDs or objects with entities and components
1129
+ * @returns Array of entity IDs matching the query
1086
1130
  *
1087
1131
  * @example
1088
- * // Just entity IDs
1089
1132
  * const entities = world.query([Position, Velocity]);
1133
+ */
1134
+ query(componentTypes: EntityId<any>[]): EntityId[];
1135
+ /**
1136
+ * Queries entities with specific components and returns their component data.
1137
+ *
1138
+ * @template T - The tuple of component types
1139
+ * @param componentTypes - Array of component types to query
1140
+ * @param includeComponents - Must be `true` to include component data
1141
+ * @returns Array of objects with entity and component data
1090
1142
  *
1091
- * // With components
1143
+ * @example
1092
1144
  * const results = world.query([Position, Velocity], true);
1093
1145
  * results.forEach(({ entity, components: [pos, vel] }) => {
1094
1146
  * pos.x += vel.x;
1095
1147
  * });
1096
1148
  */
1097
- query(componentTypes: EntityId<any>[]): EntityId[];
1098
1149
  query<const T extends readonly EntityId<any>[]>(
1099
1150
  componentTypes: T,
1100
1151
  includeComponents: true,