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 +571 -9
- package/dist/asset-manager.d.ts +111 -0
- package/dist/asset-types.d.ts +104 -0
- package/dist/bundle.d.ts +65 -6
- package/dist/bundles/renderers/pixi.d.ts +248 -0
- package/dist/bundles/renderers/pixi.js +4 -0
- package/dist/bundles/renderers/pixi.js.map +12 -0
- package/dist/bundles/utils/timers.d.ts +113 -0
- package/dist/bundles/utils/timers.js +4 -0
- package/dist/bundles/utils/timers.js.map +12 -0
- package/dist/ecspresso.d.ts +402 -15
- package/dist/entity-manager.d.ts +118 -4
- package/dist/event-bus.d.ts +5 -0
- package/dist/hierarchy-manager.d.ts +122 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +2 -2
- package/dist/index.js.map +15 -11
- package/dist/reactive-query-manager.d.ts +59 -0
- package/dist/resource-manager.d.ts +37 -5
- package/dist/screen-manager.d.ts +116 -0
- package/dist/screen-types.d.ts +119 -0
- package/dist/system-builder.d.ts +37 -2
- package/dist/types.d.ts +62 -5
- package/package.json +23 -3
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
|
-
|
|
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('
|
|
316
|
-
|
|
317
|
-
return { textures };
|
|
364
|
+
world.addResource('database', async () => {
|
|
365
|
+
return await connectToDatabase();
|
|
318
366
|
});
|
|
319
367
|
|
|
320
|
-
//
|
|
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
|
|
399
|
-
world.
|
|
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
|
-
|
|
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:
|