ecspresso 0.4.3 → 0.6.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/README.md CHANGED
@@ -11,7 +11,13 @@ A type-safe, modular, and extensible Entity Component System (ECS) framework for
11
11
  - **Developer-Friendly**: Clean, fluent API with method chaining
12
12
  - **Event-Driven**: Integrated event system for decoupled communication
13
13
  - **Resource Management**: Global state management with lazy loading
14
+ - **Asset Management**: Eager/lazy asset loading with groups and progress tracking
15
+ - **Screen Management**: Game state/screen transitions with overlay support
16
+ - **Entity Hierarchy**: Parent-child relationships with traversal and cascade deletion
14
17
  - **Query System**: Powerful entity filtering with helper type utilities
18
+ - **Reactive Queries**: Enter/exit callbacks when entities match or unmatch queries
19
+ - **System Groups**: Enable/disable groups of systems at runtime
20
+ - **Component Lifecycle**: Callbacks for component add/remove with unsubscribe support
15
21
 
16
22
  ## Installation
17
23
 
@@ -209,6 +215,35 @@ world.addSystem('physics')
209
215
  .build();
210
216
  ```
211
217
 
218
+ ### System Groups
219
+
220
+ Organize systems into groups that can be enabled/disabled at runtime:
221
+
222
+ ```typescript
223
+ // Assign systems to groups
224
+ world.addSystem('renderSprites')
225
+ .inGroup('rendering')
226
+ .addQuery('sprites', { with: ['position', 'sprite'] })
227
+ .setProcess((queries) => { /* ... */ })
228
+ .and()
229
+ .addSystem('renderParticles')
230
+ .inGroup('rendering')
231
+ .inGroup('effects') // Systems can belong to multiple groups
232
+ .setProcess(() => { /* ... */ })
233
+ .build();
234
+
235
+ // Disable/enable groups at runtime
236
+ world.disableSystemGroup('rendering'); // All rendering systems skip
237
+ world.enableSystemGroup('rendering'); // Resume rendering
238
+
239
+ // Query group state
240
+ world.isSystemGroupEnabled('rendering'); // true/false
241
+ world.getSystemsInGroup('rendering'); // ['renderSprites', 'renderParticles']
242
+
243
+ // If a system belongs to multiple groups, disabling ANY group skips the system
244
+ world.disableSystemGroup('effects'); // renderParticles won't run
245
+ ```
246
+
212
247
  ## Advanced Features
213
248
 
214
249
  ### Bundles
@@ -277,6 +312,19 @@ interface Events {
277
312
 
278
313
  const world = new ECSpresso<Components, Events>();
279
314
 
315
+ // Subscribe to events with on() - returns unsubscribe function
316
+ const unsubscribe = world.on('playerDied', (data) => {
317
+ console.log(`Player ${data.playerId} died`);
318
+ });
319
+
320
+ // Unsubscribe when done
321
+ unsubscribe();
322
+
323
+ // Or unsubscribe by callback reference with off()
324
+ const handler = (data) => console.log(`Level complete! Score: ${data.score}`);
325
+ world.on('levelComplete', handler);
326
+ world.off('levelComplete', handler);
327
+
280
328
  // Handle events in systems
281
329
  world.addSystem('gameLogic')
282
330
  .setEventHandlers({
@@ -300,7 +348,8 @@ Create resources lazily with factory functions:
300
348
  ```typescript
301
349
  interface Resources {
302
350
  config: { difficulty: string; soundEnabled: boolean };
303
- assets: { textures: any[] };
351
+ database: Database;
352
+ cache: { db: Database };
304
353
  }
305
354
 
306
355
  const world = new ECSpresso<Components, {}, Resources>();
@@ -312,15 +361,87 @@ world.addResource('config', () => ({
312
361
  }));
313
362
 
314
363
  // Async factory
315
- world.addResource('assets', async () => {
316
- const textures = await loadTextures();
317
- return { textures };
364
+ world.addResource('database', async () => {
365
+ return await connectToDatabase();
318
366
  });
319
367
 
320
- // Initialize all resources
368
+ // Factory with dependencies - initialized after dependencies are ready
369
+ world.addResource('cache', {
370
+ dependsOn: ['database'],
371
+ factory: (ecs) => ({
372
+ db: ecs.getResource('database')
373
+ })
374
+ });
375
+
376
+ // Initialize all resources (respects dependency order)
321
377
  await world.initializeResources();
322
378
  ```
323
379
 
380
+ **Dependency Features:**
381
+ - Resources are initialized in topological order (dependencies first)
382
+ - Circular dependencies throw a descriptive error at initialization time
383
+ - Existing patterns (direct values, simple factories) work unchanged
384
+
385
+ ### Resource Builder
386
+
387
+ Add resources fluently during ECSpresso construction using `withResource()`:
388
+
389
+ ```typescript
390
+ const world = ECSpresso
391
+ .create<Components, Events, Resources>()
392
+ .withBundle(physicsBundle)
393
+ .withResource('config', { debug: true, maxEntities: 1000 })
394
+ .withResource('score', () => ({ value: 0 }))
395
+ .withResource('cache', {
396
+ dependsOn: ['database'],
397
+ factory: (ecs) => createCache(ecs.getResource('database'))
398
+ })
399
+ .build();
400
+ ```
401
+
402
+ This chains naturally with `withBundle()`, `withAssets()`, and `withScreens()`.
403
+
404
+ ### Resource Disposal
405
+
406
+ Resources can define cleanup logic with `onDispose` callbacks, useful for removing event listeners, closing connections, or releasing resources:
407
+
408
+ ```typescript
409
+ // Factory with disposal callback
410
+ world.addResource('keyboard', {
411
+ factory: () => {
412
+ const handler = (e: KeyboardEvent) => { /* ... */ };
413
+ window.addEventListener('keydown', handler);
414
+ return { handler };
415
+ },
416
+ onDispose: (resource) => {
417
+ window.removeEventListener('keydown', resource.handler);
418
+ }
419
+ });
420
+
421
+ // Or with the builder pattern
422
+ const world = ECSpresso
423
+ .create<Components, Events, Resources>()
424
+ .withResource('database', {
425
+ factory: async () => await connectToDatabase(),
426
+ onDispose: async (db) => await db.close()
427
+ })
428
+ .build();
429
+
430
+ // Dispose a single resource
431
+ await world.disposeResource('keyboard');
432
+
433
+ // Dispose all resources in reverse dependency order
434
+ // (dependents are disposed before their dependencies)
435
+ await world.disposeResources();
436
+ ```
437
+
438
+ **Disposal Features:**
439
+ - `onDispose` receives the resource value and the ECSpresso instance as context
440
+ - `disposeResources()` disposes in reverse topological order (dependents first)
441
+ - Only initialized resources have their `onDispose` called
442
+ - Supports both sync and async disposal callbacks
443
+ - `removeResource()` still exists for removal without disposal
444
+
324
445
  ### System Lifecycle
325
446
 
326
447
  Systems can have initialization and cleanup hooks:
@@ -341,6 +462,395 @@ world.addSystem('gameSystem')
341
462
  await world.initialize();
342
463
  ```
343
464
 
465
+ ### Post-Update Hooks
466
+
467
+ Register callbacks that run after all systems have processed during `update()`:
468
+
469
+ ```typescript
470
+ // Register a post-update hook - returns unsubscribe function
471
+ const unsubscribe = world.onPostUpdate((ecs, deltaTime) => {
472
+ // Runs after all systems in update()
473
+ // Useful for cleanup, state sync, or debug logging
474
+ console.log(`Frame completed in ${deltaTime}s`);
475
+ });
476
+
477
+ // Multiple hooks run in registration order
478
+ world.onPostUpdate((ecs) => {
479
+ // First hook
480
+ });
481
+ world.onPostUpdate((ecs) => {
482
+ // Second hook
483
+ });
484
+
485
+ // Unsubscribe when no longer needed
486
+ unsubscribe();
487
+ ```
488
+
489
+ ### Entity Hierarchy
490
+
491
+ Create parent-child relationships between entities for scene graphs, UI trees, or skeletal hierarchies:
492
+
493
+ ```typescript
494
+ const world = new ECSpresso<Components>();
495
+
496
+ // Create a parent entity
497
+ const player = world.spawn({
498
+ position: { x: 0, y: 0 }
499
+ });
500
+
501
+ // Create a child entity using spawnChild
502
+ const weapon = world.spawnChild(player.id, {
503
+ position: { x: 10, y: 0 } // Relative to parent
504
+ });
505
+
506
+ // Or set parent on existing entity
507
+ const shield = world.spawn({ position: { x: -10, y: 0 } });
508
+ world.setParent(shield.id, player.id);
509
+
510
+ // Query relationships
511
+ world.getParent(weapon.id); // player.id
512
+ world.getChildren(player.id); // [weapon.id, shield.id]
513
+
514
+ // Orphan an entity (remove from parent)
515
+ world.removeParent(shield.id);
516
+ world.getParent(shield.id); // null
517
+ ```
518
+
519
+ #### Traversal Methods
520
+
521
+ Navigate the hierarchy tree with traversal utilities:
522
+
523
+ ```typescript
524
+ // Build a hierarchy: root -> child -> grandchild
525
+ const root = world.spawn({ position: { x: 0, y: 0 } });
526
+ const child = world.spawnChild(root.id, { position: { x: 10, y: 0 } });
527
+ const grandchild = world.spawnChild(child.id, { position: { x: 20, y: 0 } });
528
+
529
+ // Ancestors (from entity up to root)
530
+ world.getAncestors(grandchild.id); // [child.id, root.id]
531
+
532
+ // Descendants (depth-first order)
533
+ world.getDescendants(root.id); // [child.id, grandchild.id]
534
+
535
+ // Get root of any entity
536
+ world.getRoot(grandchild.id); // root.id
537
+
538
+ // Siblings (other children of same parent)
539
+ const child2 = world.spawnChild(root.id, { position: { x: -10, y: 0 } });
540
+ world.getSiblings(child.id); // [child2.id]
541
+
542
+ // Relationship checks
543
+ world.isDescendantOf(grandchild.id, root.id); // true
544
+ world.isAncestorOf(root.id, grandchild.id); // true
545
+
546
+ // All root entities (entities with children but no parent)
547
+ world.getRootEntities(); // [root.id]
548
+
549
+ // Child ordering
550
+ world.getChildAt(root.id, 0); // child.id
551
+ world.getChildIndex(root.id, child2.id); // 1
552
+ ```
553
+
554
+ #### Parent-First Traversal
555
+
556
+ Iterate the hierarchy with guaranteed parent-first order (useful for transform propagation):
557
+
558
+ ```typescript
559
+ // Callback-based traversal
560
+ world.forEachInHierarchy((entityId, parentId, depth) => {
561
+ // Parents are always visited before their children
562
+ console.log(`Entity ${entityId} at depth ${depth}, parent: ${parentId}`);
563
+ });
564
+
565
+ // Filter to specific subtrees
566
+ world.forEachInHierarchy(
567
+ (entityId, parentId, depth) => {
568
+ // Only visits entities under root.id
569
+ },
570
+ { roots: [root.id] }
571
+ );
572
+
573
+ // Generator-based traversal (supports early termination)
574
+ for (const { entityId, parentId, depth } of world.hierarchyIterator()) {
575
+ if (depth > 2) break; // Stop at depth 2
576
+ console.log(entityId);
577
+ }
578
+ ```
579
+
580
+ #### Cascade Deletion
581
+
582
+ When removing entities, descendants are automatically removed by default:
583
+
584
+ ```typescript
585
+ const parent = world.spawn({ position: { x: 0, y: 0 } });
586
+ const child = world.spawnChild(parent.id, { position: { x: 10, y: 0 } });
587
+ const grandchild = world.spawnChild(child.id, { position: { x: 20, y: 0 } });
588
+
589
+ // Remove parent - cascades to all descendants
590
+ world.removeEntity(parent.id);
591
+ world.entityManager.getEntity(child.id); // undefined
592
+ world.entityManager.getEntity(grandchild.id); // undefined
593
+
594
+ // To orphan children instead of deleting them:
595
+ world.removeEntity(parent.id, { cascade: false });
596
+ // Children still exist but have no parent
597
+ ```
598
+
599
+ #### Hierarchy Events
600
+
601
+ React to hierarchy changes with the `hierarchyChanged` event:
602
+
603
+ ```typescript
604
+ interface Events {
605
+ hierarchyChanged: {
606
+ entityId: number;
607
+ oldParent: number | null;
608
+ newParent: number | null;
609
+ };
610
+ }
611
+
612
+ const world = new ECSpresso<Components, Events>();
613
+
614
+ world.on('hierarchyChanged', (data) => {
615
+ if (data.newParent !== null) {
616
+ console.log(`Entity ${data.entityId} attached to ${data.newParent}`);
617
+ } else {
618
+ console.log(`Entity ${data.entityId} detached from ${data.oldParent}`);
619
+ }
620
+ });
621
+
622
+ // Events fire on setParent, removeParent, and spawnChild
623
+ world.setParent(child.id, parent.id); // Emits hierarchyChanged
624
+ ```
625
+
626
+ ### Asset Management
627
+
628
+ Manage game assets with eager/lazy loading, groups, and progress tracking:
629
+
630
+ ```typescript
631
+ // Define asset types
632
+ type Assets = {
633
+ playerTexture: { data: ImageBitmap };
634
+ enemyTexture: { data: ImageBitmap };
635
+ level1Music: { buffer: AudioBuffer };
636
+ level1Background: { data: ImageBitmap };
637
+ };
638
+
639
+ // Create world with assets using the builder pattern
640
+ const game = ECSpresso.create<Components, Events, Resources, Assets>()
641
+ .withAssets(assets => assets
642
+ // Eager assets - loaded automatically during initialize()
643
+ .add('playerTexture', async () => {
644
+ const img = await loadImage('player.png');
645
+ return { data: img };
646
+ })
647
+ .add('enemyTexture', async () => {
648
+ const img = await loadImage('enemy.png');
649
+ return { data: img };
650
+ })
651
+ // Lazy asset group - loaded on demand
652
+ .addGroup('level1', {
653
+ level1Music: async () => {
654
+ const buffer = await loadAudio('level1.mp3');
655
+ return { buffer };
656
+ },
657
+ level1Background: async () => {
658
+ const img = await loadImage('level1-bg.png');
659
+ return { data: img };
660
+ },
661
+ })
662
+ )
663
+ .build();
664
+
665
+ // Initialize loads eager assets automatically
666
+ await game.initialize();
667
+
668
+ // Access loaded assets
669
+ const player = game.getAsset('playerTexture');
670
+
671
+ // Check if asset is loaded
672
+ if (game.isAssetLoaded('enemyTexture')) {
673
+ const enemy = game.getAsset('enemyTexture');
674
+ }
675
+
676
+ // Load asset groups on demand (e.g., when entering a level)
677
+ await game.loadAssetGroup('level1');
678
+
679
+ // Track loading progress
680
+ const progress = game.getAssetGroupProgress('level1'); // 0-1
681
+
682
+ // Check if group is fully loaded
683
+ if (game.isAssetGroupLoaded('level1')) {
684
+ const music = game.getAsset('level1Music');
685
+ }
686
+ ```
687
+
688
+ #### Asset Events
689
+
690
+ React to asset loading with built-in events:
691
+
692
+ ```typescript
693
+ game.addSystem('loadingUI')
694
+ .setEventHandlers({
695
+ assetLoaded: {
696
+ handler: (data) => console.log(`Loaded: ${data.key}`)
697
+ },
698
+ assetFailed: {
699
+ handler: (data) => console.error(`Failed: ${data.key}`, data.error)
700
+ },
701
+ assetGroupProgress: {
702
+ handler: (data) => {
703
+ console.log(`${data.group}: ${data.loaded}/${data.total}`);
704
+ }
705
+ },
706
+ assetGroupLoaded: {
707
+ handler: (data) => console.log(`Group ready: ${data.group}`)
708
+ }
709
+ })
710
+ .build();
711
+ ```
712
+
713
+ #### Systems with Asset Requirements
714
+
715
+ Systems can declare required assets and will only run when those assets are loaded:
716
+
717
+ ```typescript
718
+ game.addSystem('gameplay')
719
+ .requiresAssets(['playerTexture', 'enemyTexture'])
720
+ .setProcess((queries, dt, ecs) => {
721
+ // This only runs when both assets are loaded
722
+ const player = ecs.getAsset('playerTexture');
723
+ })
724
+ .build();
725
+ ```
726
+
727
+ ### Screen Management
728
+
729
+ Manage game states/screens with transitions and overlay support:
730
+
731
+ ```typescript
732
+ import type { ScreenDefinition } from 'ecspresso';
733
+
734
+ // Define screen types with config and state
735
+ type Screens = {
736
+ menu: ScreenDefinition<
737
+ Record<string, never>, // Config (passed when entering)
738
+ { selectedOption: number } // State (mutable during screen)
739
+ >;
740
+ gameplay: ScreenDefinition<
741
+ { difficulty: string; level: number }, // Config
742
+ { score: number; isPaused: boolean } // State
743
+ >;
744
+ pause: ScreenDefinition<
745
+ Record<string, never>,
746
+ Record<string, never>
747
+ >;
748
+ };
749
+
750
+ // Create world with screens
751
+ const game = ECSpresso.create<Components, Events, Resources, {}, Screens>()
752
+ .withScreens(screens => screens
753
+ .add('menu', {
754
+ initialState: () => ({ selectedOption: 0 }),
755
+ onEnter: () => console.log('Entered menu'),
756
+ onExit: () => console.log('Left menu'),
757
+ })
758
+ .add('gameplay', {
759
+ initialState: () => ({ score: 0, isPaused: false }),
760
+ onEnter: (config) => console.log(`Starting level ${config.level}`),
761
+ onExit: () => console.log('Gameplay ended'),
762
+ // Require assets before screen can be entered
763
+ requiredAssetGroups: ['level1'],
764
+ })
765
+ .add('pause', {
766
+ initialState: () => ({}),
767
+ onEnter: () => console.log('Paused'),
768
+ onExit: () => console.log('Resumed'),
769
+ })
770
+ )
771
+ .build();
772
+
773
+ await game.initialize();
774
+
775
+ // Set initial screen
776
+ await game.setScreen('menu', {});
777
+
778
+ // Transition to gameplay (clears screen stack)
779
+ await game.setScreen('gameplay', { difficulty: 'hard', level: 1 });
780
+
781
+ // Push overlay screen (adds to stack, previous screen stays active)
782
+ await game.pushScreen('pause', {});
783
+
784
+ // Pop overlay (returns to previous screen)
785
+ await game.popScreen();
786
+
787
+ // Access current screen info
788
+ const current = game.getCurrentScreen(); // 'gameplay'
789
+ const config = game.getScreenConfig(); // { difficulty: 'hard', level: 1 }
790
+ const state = game.getScreenState(); // { score: 0, isPaused: false }
791
+
792
+ // Update screen state
793
+ game.updateScreenState({ score: 100 });
794
+ ```
795
+
796
+ #### Screen-Scoped Systems
797
+
798
+ Systems can be restricted to run only in specific screens:
799
+
800
+ ```typescript
801
+ // Only runs when 'menu' is the current screen
802
+ game.addSystem('menuUI')
803
+ .inScreens(['menu'])
804
+ .setProcess((queries, dt, ecs) => {
805
+ const state = ecs.getScreenState();
806
+ renderMenu(state.selectedOption);
807
+ })
808
+ .build();
809
+
810
+ // Only runs in 'gameplay' screen
811
+ game.addSystem('scoring')
812
+ .inScreens(['gameplay'])
813
+ .setProcess((queries, dt, ecs) => {
814
+ const state = ecs.getScreenState();
815
+ ecs.updateScreenState({ score: state.score + 1 });
816
+ })
817
+ .build();
818
+
819
+ // Runs in all screens EXCEPT 'pause'
820
+ game.addSystem('animations')
821
+ .excludeScreens(['pause'])
822
+ .setProcess(() => {
823
+ // Animations continue except when paused
824
+ })
825
+ .build();
826
+ ```
827
+
828
+ #### Screen Resource
829
+
830
+ Access screen state through the `$screen` resource:
831
+
832
+ ```typescript
833
+ game.addSystem('ui')
834
+ .setProcess((queries, dt, ecs) => {
835
+ const screen = ecs.getResource('$screen');
836
+
837
+ console.log(screen.current); // Current screen name
838
+ console.log(screen.config); // Current screen config
839
+ console.log(screen.state); // Current screen state (mutable)
840
+ console.log(screen.isOverlay); // true if screen was pushed
841
+ console.log(screen.stackDepth); // Number of screens in stack
842
+
843
+ // Check screen status
844
+ if (screen.isCurrent('gameplay')) {
845
+ // ...
846
+ }
847
+ if (screen.isActive('menu')) {
848
+ // true if menu is current OR in the stack
849
+ }
850
+ })
851
+ .build();
852
+ ```
853
+
344
854
  ## Type Safety
345
855
 
346
856
  ECSpresso provides comprehensive TypeScript support:
@@ -392,19 +902,71 @@ world.withBundle(conflictingBundle); // TypeScript prevents this
392
902
 
393
903
  ## Component Callbacks
394
904
 
395
- React to component changes with callbacks:
905
+ React to component changes with callbacks. Both methods return an unsubscribe function:
396
906
 
397
907
  ```typescript
398
- // Listen for component additions/removals
399
- world.entityManager.onComponentAdded('health', (value, entity) => {
908
+ // Listen for component additions - returns unsubscribe function
909
+ const unsubAdd = world.onComponentAdded('health', (value, entity) => {
400
910
  console.log(`Health added to entity ${entity.id}:`, value);
401
911
  });
402
912
 
403
- world.entityManager.onComponentRemoved('health', (oldValue, entity) => {
913
+ // Listen for component removals
914
+ const unsubRemove = world.onComponentRemoved('health', (oldValue, entity) => {
404
915
  console.log(`Health removed from entity ${entity.id}:`, oldValue);
405
916
  });
917
+
918
+ // Unsubscribe when done
919
+ unsubAdd();
920
+ unsubRemove();
921
+
922
+ // Also available on entityManager directly
923
+ world.entityManager.onComponentAdded('position', (value, entity) => {
924
+ // ...
925
+ });
926
+ ```
927
+
928
+ ## Reactive Queries
929
+
930
+ Get callbacks when entities enter or exit a query match. Unlike regular queries that you poll during `update()`, reactive queries push notifications when the entity's components change:
931
+
932
+ ```typescript
933
+ // Add a reactive query with enter/exit callbacks
934
+ world.addReactiveQuery('enemies', {
935
+ with: ['position', 'enemy'],
936
+ without: ['dead'],
937
+ onEnter: (entity) => {
938
+ // Called when entity starts matching the query
939
+ console.log(`Enemy ${entity.id} appeared at`, entity.components.position);
940
+ spawnHealthBar(entity.id);
941
+ },
942
+ onExit: (entityId) => {
943
+ // Called when entity stops matching (receives ID since entity may be removed)
944
+ console.log(`Enemy ${entityId} gone`);
945
+ removeHealthBar(entityId);
946
+ },
947
+ });
948
+
949
+ // Triggers: spawning matching entity, adding required component,
950
+ // removing excluded component
951
+ const enemy = world.spawn({ position: { x: 0, y: 0 }, enemy: true }); // onEnter fires
952
+
953
+ // Triggers: removing required component, adding excluded component,
954
+ // removing entity
955
+ world.entityManager.addComponent(enemy.id, 'dead', true); // onExit fires
956
+
957
+ // Existing matching entities trigger onEnter when query is added
958
+ world.spawn({ position: { x: 10, y: 10 }, enemy: true });
959
+ world.addReactiveQuery('positioned', {
960
+ with: ['position'],
961
+ onEnter: (entity) => { /* Called for all existing entities with position */ },
962
+ });
963
+
964
+ // Remove reactive query when no longer needed
965
+ const removed = world.removeReactiveQuery('enemies'); // returns true if existed
406
966
  ```
407
967
 
968
+ **Note:** Component replacement (calling `addComponent` with a component that already exists) does NOT trigger enter/exit callbacks since the entity's query match status doesn't change.
969
+
408
970
  ## Error Handling
409
971
 
410
972
  ECSpresso provides clear, contextual error messages for common issues: