ecspresso 0.4.3 → 0.5.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,6 +11,9 @@ 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
15
18
 
16
19
  ## Installation
@@ -277,6 +280,19 @@ interface Events {
277
280
 
278
281
  const world = new ECSpresso<Components, Events>();
279
282
 
283
+ // Subscribe to events with on() - returns unsubscribe function
284
+ const unsubscribe = world.on('playerDied', (data) => {
285
+ console.log(`Player ${data.playerId} died`);
286
+ });
287
+
288
+ // Unsubscribe when done
289
+ unsubscribe();
290
+
291
+ // Or unsubscribe by callback reference with off()
292
+ const handler = (data) => console.log(`Level complete! Score: ${data.score}`);
293
+ world.on('levelComplete', handler);
294
+ world.off('levelComplete', handler);
295
+
280
296
  // Handle events in systems
281
297
  world.addSystem('gameLogic')
282
298
  .setEventHandlers({
@@ -341,6 +357,369 @@ world.addSystem('gameSystem')
341
357
  await world.initialize();
342
358
  ```
343
359
 
360
+ ### Post-Update Hooks
361
+
362
+ Register callbacks that run after all systems have processed during `update()`:
363
+
364
+ ```typescript
365
+ // Register a post-update hook - returns unsubscribe function
366
+ const unsubscribe = world.onPostUpdate((ecs, deltaTime) => {
367
+ // Runs after all systems in update()
368
+ // Useful for cleanup, state sync, or debug logging
369
+ console.log(`Frame completed in ${deltaTime}s`);
370
+ });
371
+
372
+ // Multiple hooks run in registration order
373
+ world.onPostUpdate((ecs) => {
374
+ // First hook
375
+ });
376
+ world.onPostUpdate((ecs) => {
377
+ // Second hook
378
+ });
379
+
380
+ // Unsubscribe when no longer needed
381
+ unsubscribe();
382
+ ```
383
+
384
+ ### Entity Hierarchy
385
+
386
+ Create parent-child relationships between entities for scene graphs, UI trees, or skeletal hierarchies:
387
+
388
+ ```typescript
389
+ const world = new ECSpresso<Components>();
390
+
391
+ // Create a parent entity
392
+ const player = world.spawn({
393
+ position: { x: 0, y: 0 }
394
+ });
395
+
396
+ // Create a child entity using spawnChild
397
+ const weapon = world.spawnChild(player.id, {
398
+ position: { x: 10, y: 0 } // Relative to parent
399
+ });
400
+
401
+ // Or set parent on existing entity
402
+ const shield = world.spawn({ position: { x: -10, y: 0 } });
403
+ world.setParent(shield.id, player.id);
404
+
405
+ // Query relationships
406
+ world.getParent(weapon.id); // player.id
407
+ world.getChildren(player.id); // [weapon.id, shield.id]
408
+
409
+ // Orphan an entity (remove from parent)
410
+ world.removeParent(shield.id);
411
+ world.getParent(shield.id); // null
412
+ ```
413
+
414
+ #### Traversal Methods
415
+
416
+ Navigate the hierarchy tree with traversal utilities:
417
+
418
+ ```typescript
419
+ // Build a hierarchy: root -> child -> grandchild
420
+ const root = world.spawn({ position: { x: 0, y: 0 } });
421
+ const child = world.spawnChild(root.id, { position: { x: 10, y: 0 } });
422
+ const grandchild = world.spawnChild(child.id, { position: { x: 20, y: 0 } });
423
+
424
+ // Ancestors (from entity up to root)
425
+ world.getAncestors(grandchild.id); // [child.id, root.id]
426
+
427
+ // Descendants (depth-first order)
428
+ world.getDescendants(root.id); // [child.id, grandchild.id]
429
+
430
+ // Get root of any entity
431
+ world.getRoot(grandchild.id); // root.id
432
+
433
+ // Siblings (other children of same parent)
434
+ const child2 = world.spawnChild(root.id, { position: { x: -10, y: 0 } });
435
+ world.getSiblings(child.id); // [child2.id]
436
+
437
+ // Relationship checks
438
+ world.isDescendantOf(grandchild.id, root.id); // true
439
+ world.isAncestorOf(root.id, grandchild.id); // true
440
+
441
+ // All root entities (entities with children but no parent)
442
+ world.getRootEntities(); // [root.id]
443
+
444
+ // Child ordering
445
+ world.getChildAt(root.id, 0); // child.id
446
+ world.getChildIndex(root.id, child2.id); // 1
447
+ ```
448
+
449
+ #### Cascade Deletion
450
+
451
+ When removing entities, descendants are automatically removed by default:
452
+
453
+ ```typescript
454
+ const parent = world.spawn({ position: { x: 0, y: 0 } });
455
+ const child = world.spawnChild(parent.id, { position: { x: 10, y: 0 } });
456
+ const grandchild = world.spawnChild(child.id, { position: { x: 20, y: 0 } });
457
+
458
+ // Remove parent - cascades to all descendants
459
+ world.removeEntity(parent.id);
460
+ world.entityManager.getEntity(child.id); // undefined
461
+ world.entityManager.getEntity(grandchild.id); // undefined
462
+
463
+ // To orphan children instead of deleting them:
464
+ world.removeEntity(parent.id, { cascade: false });
465
+ // Children still exist but have no parent
466
+ ```
467
+
468
+ #### Hierarchy Events
469
+
470
+ React to hierarchy changes with the `hierarchyChanged` event:
471
+
472
+ ```typescript
473
+ interface Events {
474
+ hierarchyChanged: {
475
+ entityId: number;
476
+ oldParent: number | null;
477
+ newParent: number | null;
478
+ };
479
+ }
480
+
481
+ const world = new ECSpresso<Components, Events>();
482
+
483
+ world.on('hierarchyChanged', (data) => {
484
+ if (data.newParent !== null) {
485
+ console.log(`Entity ${data.entityId} attached to ${data.newParent}`);
486
+ } else {
487
+ console.log(`Entity ${data.entityId} detached from ${data.oldParent}`);
488
+ }
489
+ });
490
+
491
+ // Events fire on setParent, removeParent, and spawnChild
492
+ world.setParent(child.id, parent.id); // Emits hierarchyChanged
493
+ ```
494
+
495
+ ### Asset Management
496
+
497
+ Manage game assets with eager/lazy loading, groups, and progress tracking:
498
+
499
+ ```typescript
500
+ // Define asset types
501
+ type Assets = {
502
+ playerTexture: { data: ImageBitmap };
503
+ enemyTexture: { data: ImageBitmap };
504
+ level1Music: { buffer: AudioBuffer };
505
+ level1Background: { data: ImageBitmap };
506
+ };
507
+
508
+ // Create world with assets using the builder pattern
509
+ const game = ECSpresso.create<Components, Events, Resources, Assets>()
510
+ .withAssets(assets => assets
511
+ // Eager assets - loaded automatically during initialize()
512
+ .add('playerTexture', async () => {
513
+ const img = await loadImage('player.png');
514
+ return { data: img };
515
+ })
516
+ .add('enemyTexture', async () => {
517
+ const img = await loadImage('enemy.png');
518
+ return { data: img };
519
+ })
520
+ // Lazy asset group - loaded on demand
521
+ .addGroup('level1', {
522
+ level1Music: async () => {
523
+ const buffer = await loadAudio('level1.mp3');
524
+ return { buffer };
525
+ },
526
+ level1Background: async () => {
527
+ const img = await loadImage('level1-bg.png');
528
+ return { data: img };
529
+ },
530
+ })
531
+ )
532
+ .build();
533
+
534
+ // Initialize loads eager assets automatically
535
+ await game.initialize();
536
+
537
+ // Access loaded assets
538
+ const player = game.getAsset('playerTexture');
539
+
540
+ // Check if asset is loaded
541
+ if (game.isAssetLoaded('enemyTexture')) {
542
+ const enemy = game.getAsset('enemyTexture');
543
+ }
544
+
545
+ // Load asset groups on demand (e.g., when entering a level)
546
+ await game.loadAssetGroup('level1');
547
+
548
+ // Track loading progress
549
+ const progress = game.getAssetGroupProgress('level1'); // 0-1
550
+
551
+ // Check if group is fully loaded
552
+ if (game.isAssetGroupLoaded('level1')) {
553
+ const music = game.getAsset('level1Music');
554
+ }
555
+ ```
556
+
557
+ #### Asset Events
558
+
559
+ React to asset loading with built-in events:
560
+
561
+ ```typescript
562
+ game.addSystem('loadingUI')
563
+ .setEventHandlers({
564
+ assetLoaded: {
565
+ handler: (data) => console.log(`Loaded: ${data.key}`)
566
+ },
567
+ assetFailed: {
568
+ handler: (data) => console.error(`Failed: ${data.key}`, data.error)
569
+ },
570
+ assetGroupProgress: {
571
+ handler: (data) => {
572
+ console.log(`${data.group}: ${data.loaded}/${data.total}`);
573
+ }
574
+ },
575
+ assetGroupLoaded: {
576
+ handler: (data) => console.log(`Group ready: ${data.group}`)
577
+ }
578
+ })
579
+ .build();
580
+ ```
581
+
582
+ #### Systems with Asset Requirements
583
+
584
+ Systems can declare required assets and will only run when those assets are loaded:
585
+
586
+ ```typescript
587
+ game.addSystem('gameplay')
588
+ .requiresAssets(['playerTexture', 'enemyTexture'])
589
+ .setProcess((queries, dt, ecs) => {
590
+ // This only runs when both assets are loaded
591
+ const player = ecs.getAsset('playerTexture');
592
+ })
593
+ .build();
594
+ ```
595
+
596
+ ### Screen Management
597
+
598
+ Manage game states/screens with transitions and overlay support:
599
+
600
+ ```typescript
601
+ import type { ScreenDefinition } from 'ecspresso';
602
+
603
+ // Define screen types with config and state
604
+ type Screens = {
605
+ menu: ScreenDefinition<
606
+ Record<string, never>, // Config (passed when entering)
607
+ { selectedOption: number } // State (mutable during screen)
608
+ >;
609
+ gameplay: ScreenDefinition<
610
+ { difficulty: string; level: number }, // Config
611
+ { score: number; isPaused: boolean } // State
612
+ >;
613
+ pause: ScreenDefinition<
614
+ Record<string, never>,
615
+ Record<string, never>
616
+ >;
617
+ };
618
+
619
+ // Create world with screens
620
+ const game = ECSpresso.create<Components, Events, Resources, {}, Screens>()
621
+ .withScreens(screens => screens
622
+ .add('menu', {
623
+ initialState: () => ({ selectedOption: 0 }),
624
+ onEnter: () => console.log('Entered menu'),
625
+ onExit: () => console.log('Left menu'),
626
+ })
627
+ .add('gameplay', {
628
+ initialState: () => ({ score: 0, isPaused: false }),
629
+ onEnter: (config) => console.log(`Starting level ${config.level}`),
630
+ onExit: () => console.log('Gameplay ended'),
631
+ // Require assets before screen can be entered
632
+ requiredAssetGroups: ['level1'],
633
+ })
634
+ .add('pause', {
635
+ initialState: () => ({}),
636
+ onEnter: () => console.log('Paused'),
637
+ onExit: () => console.log('Resumed'),
638
+ })
639
+ )
640
+ .build();
641
+
642
+ await game.initialize();
643
+
644
+ // Set initial screen
645
+ await game.setScreen('menu', {});
646
+
647
+ // Transition to gameplay (clears screen stack)
648
+ await game.setScreen('gameplay', { difficulty: 'hard', level: 1 });
649
+
650
+ // Push overlay screen (adds to stack, previous screen stays active)
651
+ await game.pushScreen('pause', {});
652
+
653
+ // Pop overlay (returns to previous screen)
654
+ await game.popScreen();
655
+
656
+ // Access current screen info
657
+ const current = game.getCurrentScreen(); // 'gameplay'
658
+ const config = game.getScreenConfig(); // { difficulty: 'hard', level: 1 }
659
+ const state = game.getScreenState(); // { score: 0, isPaused: false }
660
+
661
+ // Update screen state
662
+ game.updateScreenState({ score: 100 });
663
+ ```
664
+
665
+ #### Screen-Scoped Systems
666
+
667
+ Systems can be restricted to run only in specific screens:
668
+
669
+ ```typescript
670
+ // Only runs when 'menu' is the current screen
671
+ game.addSystem('menuUI')
672
+ .inScreens(['menu'])
673
+ .setProcess((queries, dt, ecs) => {
674
+ const state = ecs.getScreenState();
675
+ renderMenu(state.selectedOption);
676
+ })
677
+ .build();
678
+
679
+ // Only runs in 'gameplay' screen
680
+ game.addSystem('scoring')
681
+ .inScreens(['gameplay'])
682
+ .setProcess((queries, dt, ecs) => {
683
+ const state = ecs.getScreenState();
684
+ ecs.updateScreenState({ score: state.score + 1 });
685
+ })
686
+ .build();
687
+
688
+ // Runs in all screens EXCEPT 'pause'
689
+ game.addSystem('animations')
690
+ .excludeScreens(['pause'])
691
+ .setProcess(() => {
692
+ // Animations continue except when paused
693
+ })
694
+ .build();
695
+ ```
696
+
697
+ #### Screen Resource
698
+
699
+ Access screen state through the `$screen` resource:
700
+
701
+ ```typescript
702
+ game.addSystem('ui')
703
+ .setProcess((queries, dt, ecs) => {
704
+ const screen = ecs.getResource('$screen');
705
+
706
+ console.log(screen.current); // Current screen name
707
+ console.log(screen.config); // Current screen config
708
+ console.log(screen.state); // Current screen state (mutable)
709
+ console.log(screen.isOverlay); // true if screen was pushed
710
+ console.log(screen.stackDepth); // Number of screens in stack
711
+
712
+ // Check screen status
713
+ if (screen.isCurrent('gameplay')) {
714
+ // ...
715
+ }
716
+ if (screen.isActive('menu')) {
717
+ // true if menu is current OR in the stack
718
+ }
719
+ })
720
+ .build();
721
+ ```
722
+
344
723
  ## Type Safety
345
724
 
346
725
  ECSpresso provides comprehensive TypeScript support:
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Asset management for ECSpresso ECS framework
3
+ */
4
+ import type EventBus from './event-bus';
5
+ import type { AssetStatus, AssetDefinition, AssetHandle, AssetsResource, AssetEvents, AssetConfigurator } from './asset-types';
6
+ /**
7
+ * Manages asset loading and access for ECSpresso
8
+ */
9
+ export default class AssetManager<AssetTypes extends Record<string, unknown> = Record<string, never>> {
10
+ private readonly assets;
11
+ private readonly groups;
12
+ private eventBus;
13
+ /**
14
+ * Set the event bus for asset events
15
+ * @internal
16
+ */
17
+ setEventBus(eventBus: EventBus<AssetEvents>): void;
18
+ /**
19
+ * Register an asset definition
20
+ */
21
+ register<K extends string, T>(key: K, definition: AssetDefinition<T>): void;
22
+ /**
23
+ * Load all assets marked as eager
24
+ */
25
+ loadEagerAssets(): Promise<void>;
26
+ /**
27
+ * Load a single asset by key
28
+ */
29
+ loadAsset<K extends keyof AssetTypes>(key: K): Promise<AssetTypes[K]>;
30
+ /**
31
+ * Load all assets in a group
32
+ */
33
+ loadAssetGroup(groupName: string): Promise<void>;
34
+ /**
35
+ * Get a loaded asset. Throws if not loaded.
36
+ */
37
+ get<K extends keyof AssetTypes>(key: K): AssetTypes[K];
38
+ /**
39
+ * Get a loaded asset or undefined
40
+ */
41
+ getOrUndefined<K extends keyof AssetTypes>(key: K): AssetTypes[K] | undefined;
42
+ /**
43
+ * Get a handle to an asset with status information
44
+ */
45
+ getHandle<K extends keyof AssetTypes>(key: K): AssetHandle<AssetTypes[K]>;
46
+ /**
47
+ * Get the status of an asset
48
+ */
49
+ getStatus<K extends keyof AssetTypes>(key: K): AssetStatus;
50
+ /**
51
+ * Check if an asset is loaded
52
+ */
53
+ isLoaded<K extends keyof AssetTypes>(key: K): boolean;
54
+ /**
55
+ * Check if all assets in a group are loaded
56
+ */
57
+ isGroupLoaded(groupName: string): boolean;
58
+ /**
59
+ * Get the loading progress of a group (0-1)
60
+ */
61
+ getGroupProgress(groupName: string): number;
62
+ /**
63
+ * Get detailed group progress
64
+ */
65
+ getGroupProgressDetails(groupName: string): {
66
+ loaded: number;
67
+ total: number;
68
+ progress: number;
69
+ };
70
+ /**
71
+ * Check group progress and emit events
72
+ */
73
+ private checkGroupProgress;
74
+ /**
75
+ * Create the $assets resource object
76
+ */
77
+ createResource(): AssetsResource<AssetTypes>;
78
+ /**
79
+ * Get all registered asset keys
80
+ */
81
+ getKeys(): Array<keyof AssetTypes>;
82
+ /**
83
+ * Get all group names
84
+ */
85
+ getGroupNames(): string[];
86
+ /**
87
+ * Get all asset keys in a group
88
+ */
89
+ getGroupKeys(groupName: string): Array<keyof AssetTypes>;
90
+ }
91
+ /**
92
+ * Implementation of AssetConfigurator for builder pattern
93
+ */
94
+ export declare class AssetConfiguratorImpl<A extends Record<string, unknown>> implements AssetConfigurator<A> {
95
+ private readonly manager;
96
+ constructor(manager: AssetManager<A>);
97
+ add<K extends string, T>(key: K, loader: () => Promise<T>): AssetConfigurator<A & Record<K, T>>;
98
+ addWithConfig<K extends string, T>(key: K, definition: AssetDefinition<T>): AssetConfigurator<A & Record<K, T>>;
99
+ addGroup<G extends string, T extends Record<string, () => Promise<unknown>>>(groupName: G, assets: T): AssetConfigurator<A & {
100
+ [K in keyof T]: Awaited<ReturnType<T[K]>>;
101
+ }>;
102
+ /**
103
+ * Get the underlying manager
104
+ * @internal
105
+ */
106
+ getManager(): AssetManager<A>;
107
+ }
108
+ /**
109
+ * Create a new AssetConfigurator for builder pattern usage
110
+ */
111
+ export declare function createAssetConfigurator<A extends Record<string, unknown> = Record<string, never>>(manager?: AssetManager<A>): AssetConfiguratorImpl<A>;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Asset management types for ECSpresso ECS framework
3
+ */
4
+ /**
5
+ * Status of an asset in the loading lifecycle
6
+ */
7
+ export type AssetStatus = 'pending' | 'loading' | 'loaded' | 'failed';
8
+ /**
9
+ * Definition for an asset including its loader and configuration
10
+ */
11
+ export interface AssetDefinition<T> {
12
+ readonly loader: () => Promise<T>;
13
+ readonly eager?: boolean;
14
+ readonly group?: string;
15
+ }
16
+ /**
17
+ * Handle to an asset that provides status information and access methods
18
+ */
19
+ export interface AssetHandle<T> {
20
+ readonly status: AssetStatus;
21
+ readonly isLoaded: boolean;
22
+ /**
23
+ * Get the asset value. Throws if asset is not loaded.
24
+ */
25
+ get(): T;
26
+ /**
27
+ * Get the asset value if loaded, undefined otherwise.
28
+ */
29
+ getOrUndefined(): T | undefined;
30
+ }
31
+ /**
32
+ * Resource interface for accessing assets in systems
33
+ * Exposed as $assets resource
34
+ */
35
+ export interface AssetsResource<A extends Record<string, unknown>> {
36
+ /**
37
+ * Get the loading status of an asset
38
+ */
39
+ getStatus<K extends keyof A>(key: K): AssetStatus;
40
+ /**
41
+ * Check if an asset is loaded
42
+ */
43
+ isLoaded<K extends keyof A>(key: K): boolean;
44
+ /**
45
+ * Check if all assets in a group are loaded
46
+ */
47
+ isGroupLoaded(groupName: string): boolean;
48
+ /**
49
+ * Get the loading progress of a group (0-1)
50
+ */
51
+ getGroupProgress(groupName: string): number;
52
+ /**
53
+ * Get a loaded asset. Throws if not loaded.
54
+ */
55
+ get<K extends keyof A>(key: K): A[K];
56
+ /**
57
+ * Get a loaded asset or undefined if not loaded
58
+ */
59
+ getOrUndefined<K extends keyof A>(key: K): A[K] | undefined;
60
+ /**
61
+ * Get a handle to an asset with status information
62
+ */
63
+ getHandle<K extends keyof A>(key: K): AssetHandle<A[K]>;
64
+ }
65
+ /**
66
+ * Events emitted by the asset system
67
+ */
68
+ export interface AssetEvents {
69
+ assetLoaded: {
70
+ key: string;
71
+ };
72
+ assetFailed: {
73
+ key: string;
74
+ error: Error;
75
+ };
76
+ assetGroupLoaded: {
77
+ group: string;
78
+ };
79
+ assetGroupProgress: {
80
+ group: string;
81
+ progress: number;
82
+ loaded: number;
83
+ total: number;
84
+ };
85
+ }
86
+ /**
87
+ * Configuration for asset definitions during builder setup
88
+ */
89
+ export interface AssetConfigurator<A extends Record<string, unknown>> {
90
+ /**
91
+ * Add a single eager asset
92
+ */
93
+ add<K extends string, T>(key: K, loader: () => Promise<T>): AssetConfigurator<A & Record<K, T>>;
94
+ /**
95
+ * Add a single asset with full configuration
96
+ */
97
+ addWithConfig<K extends string, T>(key: K, definition: AssetDefinition<T>): AssetConfigurator<A & Record<K, T>>;
98
+ /**
99
+ * Add a group of assets that can be loaded together
100
+ */
101
+ addGroup<G extends string, T extends Record<string, () => Promise<unknown>>>(groupName: G, assets: T): AssetConfigurator<A & {
102
+ [K in keyof T]: Awaited<ReturnType<T[K]>>;
103
+ }>;
104
+ }