ecspresso 0.10.2 → 0.12.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 +256 -148
- package/dist/asset-manager.d.ts +16 -16
- package/dist/asset-types.d.ts +18 -16
- package/dist/command-buffer.d.ts +30 -20
- package/dist/ecspresso-builder.d.ts +193 -0
- package/dist/ecspresso.d.ts +323 -209
- package/dist/entity-manager.d.ts +76 -30
- package/dist/event-bus.d.ts +6 -1
- package/dist/index.d.ts +6 -13
- package/dist/plugin.d.ts +61 -0
- package/dist/plugins/audio.d.ts +273 -0
- package/dist/{bundles/utils → plugins}/bounds.d.ts +20 -26
- package/dist/plugins/camera.d.ts +88 -0
- package/dist/plugins/collision.d.ts +285 -0
- package/dist/plugins/coroutine.d.ts +126 -0
- package/dist/plugins/diagnostics.d.ts +49 -0
- package/dist/{bundles/utils → plugins}/input.d.ts +22 -29
- package/dist/plugins/particles.d.ts +225 -0
- package/dist/plugins/physics2D.d.ts +163 -0
- package/dist/plugins/renderers/renderer2D.d.ts +262 -0
- package/dist/plugins/spatial-index.d.ts +58 -0
- package/dist/plugins/sprite-animation.d.ts +150 -0
- package/dist/plugins/state-machine.d.ts +244 -0
- package/dist/plugins/timers.d.ts +151 -0
- package/dist/{bundles/utils → plugins}/transform.d.ts +21 -22
- package/dist/plugins/tween.d.ts +162 -0
- package/dist/reactive-query-manager.d.ts +14 -3
- package/dist/resource-manager.d.ts +64 -23
- package/dist/screen-manager.d.ts +21 -15
- package/dist/screen-types.d.ts +15 -11
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +25 -0
- package/dist/src/plugins/audio.js +4 -0
- package/dist/src/plugins/audio.js.map +10 -0
- package/dist/src/plugins/bounds.js +4 -0
- package/dist/src/plugins/bounds.js.map +10 -0
- package/dist/src/plugins/camera.js +4 -0
- package/dist/src/plugins/camera.js.map +10 -0
- package/dist/src/plugins/collision.js +4 -0
- package/dist/src/plugins/collision.js.map +11 -0
- package/dist/src/plugins/coroutine.js +4 -0
- package/dist/src/plugins/coroutine.js.map +10 -0
- package/dist/src/plugins/diagnostics.js +5 -0
- package/dist/src/plugins/diagnostics.js.map +10 -0
- package/dist/src/plugins/input.js +4 -0
- package/dist/src/plugins/input.js.map +10 -0
- package/dist/src/plugins/particles.js +4 -0
- package/dist/src/plugins/particles.js.map +10 -0
- package/dist/src/plugins/physics2D.js +4 -0
- package/dist/src/plugins/physics2D.js.map +11 -0
- package/dist/src/plugins/renderers/renderer2D.js +4 -0
- package/dist/src/plugins/renderers/renderer2D.js.map +10 -0
- package/dist/src/plugins/spatial-index.js +4 -0
- package/dist/src/plugins/spatial-index.js.map +11 -0
- package/dist/src/plugins/sprite-animation.js +4 -0
- package/dist/src/plugins/sprite-animation.js.map +10 -0
- package/dist/src/plugins/state-machine.js +4 -0
- package/dist/src/plugins/state-machine.js.map +10 -0
- package/dist/src/plugins/timers.js +4 -0
- package/dist/src/plugins/timers.js.map +10 -0
- package/dist/src/plugins/transform.js +4 -0
- package/dist/src/plugins/transform.js.map +10 -0
- package/dist/src/plugins/tween.js +4 -0
- package/dist/src/plugins/tween.js.map +11 -0
- package/dist/system-builder.d.ts +75 -112
- package/dist/type-utils.d.ts +247 -7
- package/dist/types.d.ts +58 -39
- package/dist/utils/check-required-cycle.d.ts +12 -0
- package/dist/utils/easing.d.ts +71 -0
- package/dist/utils/math.d.ts +67 -0
- package/dist/utils/narrowphase.d.ts +63 -0
- package/dist/utils/spatial-hash.d.ts +53 -0
- package/package.json +65 -27
- package/dist/bundle.d.ts +0 -123
- package/dist/bundles/renderers/renderer2D.d.ts +0 -220
- package/dist/bundles/renderers/renderer2D.js +0 -4
- package/dist/bundles/renderers/renderer2D.js.map +0 -10
- package/dist/bundles/utils/bounds.js +0 -4
- package/dist/bundles/utils/bounds.js.map +0 -10
- package/dist/bundles/utils/collision.d.ts +0 -204
- package/dist/bundles/utils/collision.js +0 -4
- package/dist/bundles/utils/collision.js.map +0 -10
- package/dist/bundles/utils/input.js +0 -4
- package/dist/bundles/utils/input.js.map +0 -10
- package/dist/bundles/utils/movement.d.ts +0 -86
- package/dist/bundles/utils/movement.js +0 -4
- package/dist/bundles/utils/movement.js.map +0 -10
- package/dist/bundles/utils/timers.d.ts +0 -172
- package/dist/bundles/utils/timers.js +0 -4
- package/dist/bundles/utils/timers.js.map +0 -10
- package/dist/bundles/utils/transform.js +0 -4
- package/dist/bundles/utils/transform.js.map +0 -10
- package/dist/index.js +0 -4
- package/dist/index.js.map +0 -22
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ A type-safe, modular, and extensible Entity Component System (ECS) framework for
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
9
|
- **Type-Safe**: Full TypeScript support with component, event, and resource type inference
|
|
10
|
-
- **Modular**:
|
|
10
|
+
- **Modular**: Plugin-based architecture for organizing features
|
|
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
|
|
@@ -20,8 +20,9 @@ A type-safe, modular, and extensible Entity Component System (ECS) framework for
|
|
|
20
20
|
- **Reactive Queries**: Enter/exit callbacks when entities match or unmatch queries
|
|
21
21
|
- **System Groups**: Enable/disable groups of systems at runtime
|
|
22
22
|
- **Component Lifecycle**: Callbacks for component add/remove with unsubscribe support
|
|
23
|
+
- **Required Components**: Auto-add dependent components on spawn/addComponent (e.g. `localTransform` implies `worldTransform`)
|
|
23
24
|
- **Command Buffer**: Deferred structural changes for safe entity/component operations during systems
|
|
24
|
-
- **Timer
|
|
25
|
+
- **Timer Plugin**: ECS-native timers with event-based completion notifications
|
|
25
26
|
|
|
26
27
|
## Installation
|
|
27
28
|
|
|
@@ -47,7 +48,7 @@ npm install ecspresso
|
|
|
47
48
|
- [Entity Hierarchy](#entity-hierarchy) -- [Traversal](#traversal), [Parent-First Traversal](#parent-first-traversal), [Cascade Deletion](#cascade-deletion)
|
|
48
49
|
- [Change Detection](#change-detection) -- [Marking Changes](#marking-changes), [Changed Query Filter](#changed-query-filter), [Sequence Timing](#sequence-timing)
|
|
49
50
|
- [Command Buffer](#command-buffer) -- [Available Commands](#available-commands)
|
|
50
|
-
- [
|
|
51
|
+
- [Plugins](#plugins) -- [Plugin Factory](#plugin-factory), [Required Components](#required-components), [Built-in Plugins](#built-in-plugins), [Timer Plugin](#timer-plugin)
|
|
51
52
|
- [Asset Management](#asset-management)
|
|
52
53
|
- [Screen Management](#screen-management) -- [Screen-Scoped Systems](#screen-scoped-systems), [Screen Resource](#screen-resource)
|
|
53
54
|
- [Type Safety](#type-safety)
|
|
@@ -66,8 +67,10 @@ interface Components {
|
|
|
66
67
|
health: { value: number };
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
// 2. Create a world
|
|
70
|
-
const world =
|
|
70
|
+
// 2. Create a world using the builder — types are inferred automatically
|
|
71
|
+
const world = ECSpresso.create()
|
|
72
|
+
.withComponentTypes<Components>()
|
|
73
|
+
.build();
|
|
71
74
|
|
|
72
75
|
// 3. Add a movement system
|
|
73
76
|
world.addSystem('movement')
|
|
@@ -77,8 +80,7 @@ world.addSystem('movement')
|
|
|
77
80
|
entity.components.position.x += entity.components.velocity.x * deltaTime;
|
|
78
81
|
entity.components.position.y += entity.components.velocity.y * deltaTime;
|
|
79
82
|
}
|
|
80
|
-
})
|
|
81
|
-
.build();
|
|
83
|
+
});
|
|
82
84
|
|
|
83
85
|
// 4. Create entities
|
|
84
86
|
const player = world.spawn({
|
|
@@ -107,7 +109,7 @@ const entity = world.spawn({
|
|
|
107
109
|
// Add components later
|
|
108
110
|
world.entityManager.addComponent(entity.id, 'velocity', { x: 5, y: 0 });
|
|
109
111
|
|
|
110
|
-
// Get component data (returns
|
|
112
|
+
// Get component data (returns undefined if not found)
|
|
111
113
|
const position = world.entityManager.getComponent(entity.id, 'position');
|
|
112
114
|
|
|
113
115
|
// Remove components or entities
|
|
@@ -186,8 +188,7 @@ world.addSystem('combat')
|
|
|
186
188
|
// Combat logic here
|
|
187
189
|
}
|
|
188
190
|
}
|
|
189
|
-
})
|
|
190
|
-
.build();
|
|
191
|
+
});
|
|
191
192
|
```
|
|
192
193
|
|
|
193
194
|
### Resources
|
|
@@ -200,10 +201,11 @@ interface Resources {
|
|
|
200
201
|
settings: { difficulty: 'easy' | 'hard' };
|
|
201
202
|
}
|
|
202
203
|
|
|
203
|
-
const world =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
204
|
+
const world = ECSpresso.create()
|
|
205
|
+
.withComponentTypes<Components>()
|
|
206
|
+
.withResourceTypes<Resources>()
|
|
207
|
+
.withResource('score', { value: 0 })
|
|
208
|
+
.build();
|
|
207
209
|
|
|
208
210
|
// Sync or async factories (lazy initialization)
|
|
209
211
|
world.addResource('config', () => ({ difficulty: 'normal', soundEnabled: true }));
|
|
@@ -223,16 +225,14 @@ world.addSystem('scoring')
|
|
|
223
225
|
.setProcess((queries, deltaTime, ecs) => {
|
|
224
226
|
const score = ecs.getResource('score');
|
|
225
227
|
score.value += 10;
|
|
226
|
-
})
|
|
227
|
-
.build();
|
|
228
|
+
});
|
|
228
229
|
```
|
|
229
230
|
|
|
230
|
-
|
|
231
|
+
Resources also chain naturally with plugins in the builder:
|
|
231
232
|
|
|
232
233
|
```typescript
|
|
233
|
-
const world = ECSpresso
|
|
234
|
-
.
|
|
235
|
-
.withBundle(physicsBundle)
|
|
234
|
+
const world = ECSpresso.create()
|
|
235
|
+
.withPlugin(physicsPlugin)
|
|
236
236
|
.withResource('config', { debug: true, maxEntities: 1000 })
|
|
237
237
|
.withResource('score', () => ({ value: 0 }))
|
|
238
238
|
.withResource('cache', {
|
|
@@ -266,21 +266,20 @@ await world.disposeResources(); // All, in reverse dependency order
|
|
|
266
266
|
|
|
267
267
|
### Method Chaining
|
|
268
268
|
|
|
269
|
-
|
|
269
|
+
Systems use a fluent builder API: `world.addSystem().addQuery().setProcess()` -- systems are automatically registered via deferred finalization. No explicit termination call is needed.
|
|
270
270
|
|
|
271
271
|
```typescript
|
|
272
272
|
world.addSystem('physics')
|
|
273
273
|
.addQuery('moving', { with: ['position', 'velocity'] })
|
|
274
274
|
.setProcess((queries, deltaTime) => {
|
|
275
275
|
// Physics logic
|
|
276
|
-
})
|
|
277
|
-
|
|
278
|
-
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
world.addSystem('rendering')
|
|
279
279
|
.addQuery('visible', { with: ['position', 'sprite'] })
|
|
280
280
|
.setProcess((queries) => {
|
|
281
281
|
// Rendering logic
|
|
282
|
-
})
|
|
283
|
-
.build();
|
|
282
|
+
});
|
|
284
283
|
```
|
|
285
284
|
|
|
286
285
|
### Query Type Utilities
|
|
@@ -311,8 +310,7 @@ world.addSystem('movement')
|
|
|
311
310
|
for (const entity of queries.entities) {
|
|
312
311
|
updatePosition(entity, deltaTime);
|
|
313
312
|
}
|
|
314
|
-
})
|
|
315
|
-
.build();
|
|
313
|
+
});
|
|
316
314
|
```
|
|
317
315
|
|
|
318
316
|
### System Phases
|
|
@@ -328,33 +326,35 @@ Each phase's command buffer is played back before the next phase begins, so enti
|
|
|
328
326
|
```typescript
|
|
329
327
|
world.addSystem('input')
|
|
330
328
|
.inPhase('preUpdate')
|
|
331
|
-
.setProcess((queries, dt, ecs) => { /* Read input, update timers */ })
|
|
332
|
-
|
|
333
|
-
|
|
329
|
+
.setProcess((queries, dt, ecs) => { /* Read input, update timers */ });
|
|
330
|
+
|
|
331
|
+
world.addSystem('physics')
|
|
334
332
|
.inPhase('fixedUpdate')
|
|
335
333
|
.setProcess((queries, dt, ecs) => {
|
|
336
334
|
// dt is always fixedDt here (e.g. 1/60)
|
|
337
335
|
// Runs 0..N times per frame based on accumulated time
|
|
338
|
-
})
|
|
339
|
-
|
|
340
|
-
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
world.addSystem('gameplay')
|
|
341
339
|
.inPhase('update') // default phase
|
|
342
|
-
.setProcess((queries, dt, ecs) => { /* Game logic, AI */ })
|
|
343
|
-
|
|
344
|
-
|
|
340
|
+
.setProcess((queries, dt, ecs) => { /* Game logic, AI */ });
|
|
341
|
+
|
|
342
|
+
world.addSystem('transform-sync')
|
|
345
343
|
.inPhase('postUpdate')
|
|
346
|
-
.setProcess((queries, dt, ecs) => { /* Transform propagation */ })
|
|
347
|
-
|
|
348
|
-
|
|
344
|
+
.setProcess((queries, dt, ecs) => { /* Transform propagation */ });
|
|
345
|
+
|
|
346
|
+
world.addSystem('renderer')
|
|
349
347
|
.inPhase('render')
|
|
350
|
-
.setProcess((queries, dt, ecs) => { /* Visual output */ })
|
|
351
|
-
.build();
|
|
348
|
+
.setProcess((queries, dt, ecs) => { /* Visual output */ });
|
|
352
349
|
```
|
|
353
350
|
|
|
354
351
|
**Fixed Timestep** -- The `fixedUpdate` phase uses a time accumulator for deterministic simulation. A spiral-of-death cap (8 steps) prevents runaway accumulation.
|
|
355
352
|
|
|
356
353
|
```typescript
|
|
357
|
-
const world = ECSpresso.create
|
|
354
|
+
const world = ECSpresso.create()
|
|
355
|
+
.withComponentTypes<Components>()
|
|
356
|
+
.withEventTypes<Events>()
|
|
357
|
+
.withResourceTypes<Resources>()
|
|
358
358
|
.withFixedTimestep(1 / 60) // 60Hz physics (default)
|
|
359
359
|
.build();
|
|
360
360
|
```
|
|
@@ -371,13 +371,12 @@ Within each phase, systems execute in priority order (higher numbers first). Sys
|
|
|
371
371
|
world.addSystem('physics')
|
|
372
372
|
.inPhase('fixedUpdate')
|
|
373
373
|
.setPriority(100) // Runs first within fixedUpdate
|
|
374
|
-
.setProcess(() => { /* physics */ })
|
|
375
|
-
|
|
376
|
-
|
|
374
|
+
.setProcess(() => { /* physics */ });
|
|
375
|
+
|
|
376
|
+
world.addSystem('constraints')
|
|
377
377
|
.inPhase('fixedUpdate')
|
|
378
378
|
.setPriority(50) // Runs second within fixedUpdate
|
|
379
|
-
.setProcess(() => { /* constraints */ })
|
|
380
|
-
.build();
|
|
379
|
+
.setProcess(() => { /* constraints */ });
|
|
381
380
|
```
|
|
382
381
|
|
|
383
382
|
### System Groups
|
|
@@ -388,13 +387,12 @@ Organize systems into groups that can be enabled/disabled at runtime:
|
|
|
388
387
|
world.addSystem('renderSprites')
|
|
389
388
|
.inGroup('rendering')
|
|
390
389
|
.addQuery('sprites', { with: ['position', 'sprite'] })
|
|
391
|
-
.setProcess((queries) => { /* ... */ })
|
|
392
|
-
|
|
393
|
-
|
|
390
|
+
.setProcess((queries) => { /* ... */ });
|
|
391
|
+
|
|
392
|
+
world.addSystem('renderParticles')
|
|
394
393
|
.inGroup('rendering')
|
|
395
394
|
.inGroup('effects') // Systems can belong to multiple groups
|
|
396
|
-
.setProcess(() => { /* ... */ })
|
|
397
|
-
.build();
|
|
395
|
+
.setProcess(() => { /* ... */ });
|
|
398
396
|
|
|
399
397
|
world.disableSystemGroup('rendering'); // All rendering systems skip
|
|
400
398
|
world.enableSystemGroup('rendering'); // Resume rendering
|
|
@@ -415,8 +413,7 @@ world.addSystem('gameSystem')
|
|
|
415
413
|
})
|
|
416
414
|
.setOnDetach((ecs) => {
|
|
417
415
|
console.log('System shutting down...');
|
|
418
|
-
})
|
|
419
|
-
.build();
|
|
416
|
+
});
|
|
420
417
|
|
|
421
418
|
await world.initialize();
|
|
422
419
|
```
|
|
@@ -448,7 +445,10 @@ interface Events {
|
|
|
448
445
|
};
|
|
449
446
|
}
|
|
450
447
|
|
|
451
|
-
const world =
|
|
448
|
+
const world = ECSpresso.create()
|
|
449
|
+
.withComponentTypes<Components>()
|
|
450
|
+
.withEventTypes<Events>()
|
|
451
|
+
.build();
|
|
452
452
|
|
|
453
453
|
// Subscribe - returns unsubscribe function
|
|
454
454
|
const unsubscribe = world.on('playerDied', (data) => {
|
|
@@ -464,19 +464,16 @@ world.off('levelComplete', handler);
|
|
|
464
464
|
// Handle events in systems
|
|
465
465
|
world.addSystem('gameLogic')
|
|
466
466
|
.setEventHandlers({
|
|
467
|
-
playerDied: {
|
|
468
|
-
|
|
469
|
-
// Respawn logic
|
|
470
|
-
}
|
|
467
|
+
playerDied: (data, ecs) => {
|
|
468
|
+
// Respawn logic
|
|
471
469
|
}
|
|
472
|
-
})
|
|
473
|
-
.build();
|
|
470
|
+
});
|
|
474
471
|
|
|
475
472
|
// Publish events from anywhere
|
|
476
473
|
world.eventBus.publish('playerDied', { playerId: 1 });
|
|
477
474
|
```
|
|
478
475
|
|
|
479
|
-
**Built-in events**: `hierarchyChanged` (entity parent changes), `assetLoaded` / `assetFailed` / `assetGroupProgress` / `assetGroupLoaded` (asset loading), and timer `onComplete` events (see [
|
|
476
|
+
**Built-in events**: `hierarchyChanged` (entity parent changes), `assetLoaded` / `assetFailed` / `assetGroupProgress` / `assetGroupLoaded` (asset loading), and timer `onComplete` events (see [Plugins](#plugins)).
|
|
480
477
|
|
|
481
478
|
## Entity Hierarchy
|
|
482
479
|
|
|
@@ -545,7 +542,7 @@ world.removeEntity(parent.id, { cascade: false });
|
|
|
545
542
|
|
|
546
543
|
Hierarchy changes emit the `hierarchyChanged` event (see [Events](#events)).
|
|
547
544
|
|
|
548
|
-
**World position pattern**: `worldPos = localPos + parent.worldPos`. A parent's world position already includes all grandparents, so each entity only needs to combine its local position with its immediate parent's world position. The Transform
|
|
545
|
+
**World position pattern**: `worldPos = localPos + parent.worldPos`. A parent's world position already includes all grandparents, so each entity only needs to combine its local position with its immediate parent's world position. The Transform plugin implements this automatically.
|
|
549
546
|
|
|
550
547
|
## Change Detection
|
|
551
548
|
|
|
@@ -577,8 +574,7 @@ world.addSystem('render-sync')
|
|
|
577
574
|
for (const entity of queries.moved) {
|
|
578
575
|
syncSpritePosition(entity);
|
|
579
576
|
}
|
|
580
|
-
})
|
|
581
|
-
.build();
|
|
577
|
+
});
|
|
582
578
|
```
|
|
583
579
|
|
|
584
580
|
When multiple components are listed in `changed`, entities matching **any** of them are included (OR semantics).
|
|
@@ -602,7 +598,7 @@ if (em.getChangeSeq(entity.id, 'localTransform') > ecs.changeThreshold) {
|
|
|
602
598
|
|
|
603
599
|
**Deferred marking**: `ecs.commands.markChanged(entity.id, 'position')` queues a mark for command buffer playback.
|
|
604
600
|
|
|
605
|
-
**Built-in
|
|
601
|
+
**Built-in plugin usage**: Movement marks `localTransform` (fixedUpdate) → Transform propagation reads `localTransform` changed, writes+marks `worldTransform` (postUpdate) → Renderer reads `worldTransform` changed (render).
|
|
606
602
|
|
|
607
603
|
## Command Buffer
|
|
608
604
|
|
|
@@ -621,8 +617,7 @@ world.addSystem('combat')
|
|
|
621
617
|
});
|
|
622
618
|
}
|
|
623
619
|
}
|
|
624
|
-
})
|
|
625
|
-
.build();
|
|
620
|
+
});
|
|
626
621
|
```
|
|
627
622
|
|
|
628
623
|
### Available Commands
|
|
@@ -653,68 +648,170 @@ ecs.commands.clear(); // Discard all queued commands
|
|
|
653
648
|
|
|
654
649
|
Commands execute in FIFO order. If a command fails (e.g., entity doesn't exist), it logs a warning and continues with remaining commands.
|
|
655
650
|
|
|
656
|
-
##
|
|
651
|
+
## Plugins
|
|
657
652
|
|
|
658
|
-
Organize related systems and resources into reusable
|
|
653
|
+
Organize related systems and resources into reusable plugins:
|
|
659
654
|
|
|
660
655
|
```typescript
|
|
661
|
-
import {
|
|
656
|
+
import ECSpresso, { definePlugin, type WorldConfigFrom } from 'ecspresso';
|
|
662
657
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
})
|
|
672
|
-
.and()
|
|
673
|
-
.addSystem('applyGravity')
|
|
674
|
-
.addQuery('falling', { with: ['velocity'] })
|
|
675
|
-
.setProcess((queries, deltaTime, ecs) => {
|
|
676
|
-
const gravity = ecs.getResource('gravity');
|
|
677
|
-
for (const entity of queries.falling) {
|
|
678
|
-
entity.components.velocity.y += gravity.value * deltaTime;
|
|
679
|
-
}
|
|
680
|
-
})
|
|
681
|
-
.and()
|
|
682
|
-
.addResource('gravity', { value: 9.8 });
|
|
658
|
+
interface PhysicsComponents {
|
|
659
|
+
position: { x: number; y: number };
|
|
660
|
+
velocity: { x: number; y: number };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
interface PhysicsResources {
|
|
664
|
+
gravity: { value: number };
|
|
665
|
+
}
|
|
683
666
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
667
|
+
const physicsPlugin = definePlugin<WorldConfigFrom<PhysicsComponents, {}, PhysicsResources>>({
|
|
668
|
+
id: 'physics',
|
|
669
|
+
install(world) {
|
|
670
|
+
world.addSystem('applyVelocity')
|
|
671
|
+
.addQuery('moving', { with: ['position', 'velocity'] })
|
|
672
|
+
.setProcess((queries, deltaTime) => {
|
|
673
|
+
for (const entity of queries.moving) {
|
|
674
|
+
entity.components.position.x += entity.components.velocity.x * deltaTime;
|
|
675
|
+
entity.components.position.y += entity.components.velocity.y * deltaTime;
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
world.addSystem('applyGravity')
|
|
680
|
+
.addQuery('falling', { with: ['velocity'] })
|
|
681
|
+
.setProcess((queries, deltaTime, ecs) => {
|
|
682
|
+
const gravity = ecs.getResource('gravity');
|
|
683
|
+
for (const entity of queries.falling) {
|
|
684
|
+
entity.components.velocity.y += gravity.value * deltaTime;
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
world.addResource('gravity', { value: 9.8 });
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
// Register plugins with the world — types merge automatically
|
|
693
|
+
const game = ECSpresso.create()
|
|
694
|
+
.withPlugin(physicsPlugin)
|
|
687
695
|
.build();
|
|
688
696
|
```
|
|
689
697
|
|
|
690
|
-
###
|
|
698
|
+
### Plugin Factory
|
|
699
|
+
|
|
700
|
+
When multiple plugins share the same types (common in application code), use `pluginFactory()` on the builder or built world to capture types automatically:
|
|
701
|
+
|
|
702
|
+
```typescript
|
|
703
|
+
// types.ts — builder accumulates all types
|
|
704
|
+
export const builder = ECSpresso.create()
|
|
705
|
+
.withPlugin(createPhysicsPlugin())
|
|
706
|
+
.withComponentTypes<{ player: boolean; enemy: EnemyData }>()
|
|
707
|
+
.withResourceTypes<{ score: number }>();
|
|
708
|
+
|
|
709
|
+
// Types flow from the builder — no manual imports or extends chains
|
|
710
|
+
export const definePlugin = builder.pluginFactory();
|
|
711
|
+
|
|
712
|
+
// movement-plugin.ts — no type params needed
|
|
713
|
+
import { definePlugin } from './types';
|
|
714
|
+
|
|
715
|
+
export const movementPlugin = definePlugin({
|
|
716
|
+
id: 'movement',
|
|
717
|
+
install(world) {
|
|
718
|
+
world.addSystem('movement')
|
|
719
|
+
.addQuery('moving', { with: ['position', 'velocity'] })
|
|
720
|
+
.setProcess((queries, dt) => { /* ... */ });
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
You can also pass a world type directly to `definePlugin` as a one-off alternative:
|
|
726
|
+
|
|
727
|
+
```typescript
|
|
728
|
+
type MyWorld = typeof ecs; // derive from a built world
|
|
729
|
+
const plugin = definePlugin<MyWorld>({
|
|
730
|
+
id: 'my-plugin',
|
|
731
|
+
install(world) { /* world is fully typed */ },
|
|
732
|
+
});
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
### Required Components
|
|
736
|
+
|
|
737
|
+
Plugins can declare that certain components depend on others. When an entity gains a trigger component, any required components that aren't already present are auto-added with default values:
|
|
738
|
+
|
|
739
|
+
```typescript
|
|
740
|
+
const transformPlugin = definePlugin<WorldConfigFrom<TransformComponents>>({
|
|
741
|
+
id: 'transform',
|
|
742
|
+
install(world) {
|
|
743
|
+
world.registerRequired('localTransform', 'worldTransform', () => ({
|
|
744
|
+
x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1,
|
|
745
|
+
}));
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
const world = ECSpresso.create()
|
|
750
|
+
.withPlugin(transformPlugin)
|
|
751
|
+
.build();
|
|
752
|
+
|
|
753
|
+
// worldTransform is auto-added with defaults
|
|
754
|
+
const entity = world.spawn({
|
|
755
|
+
localTransform: { x: 100, y: 200, rotation: 0, scaleX: 1, scaleY: 1 },
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// Explicit values always win — no auto-add if already provided
|
|
759
|
+
const entity2 = world.spawn({
|
|
760
|
+
localTransform: { x: 100, y: 200, rotation: 0, scaleX: 1, scaleY: 1 },
|
|
761
|
+
worldTransform: { x: 50, y: 50, rotation: 0, scaleX: 2, scaleY: 2 }, // used as-is
|
|
762
|
+
});
|
|
763
|
+
```
|
|
691
764
|
|
|
692
|
-
|
|
765
|
+
Requirements can also be registered via the builder or at runtime:
|
|
766
|
+
|
|
767
|
+
```typescript
|
|
768
|
+
// Builder
|
|
769
|
+
const world = ECSpresso.create()
|
|
770
|
+
.withComponentTypes<Components>()
|
|
771
|
+
.withRequired('rigidBody', 'velocity', () => ({ x: 0, y: 0 }))
|
|
772
|
+
.withRequired('rigidBody', 'force', () => ({ x: 0, y: 0 }))
|
|
773
|
+
.build();
|
|
774
|
+
|
|
775
|
+
// Runtime
|
|
776
|
+
world.registerRequired('position', 'velocity', () => ({ x: 0, y: 0 }));
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
**Behavior:**
|
|
780
|
+
- Enforced at insertion time (`spawn`, `addComponent`, `addComponents`, `spawnChild`, command buffer)
|
|
781
|
+
- Removal is unrestricted — removing a required component does not cascade
|
|
782
|
+
- Transitive requirements resolve automatically (A requires B, B requires C → all three added)
|
|
783
|
+
- Circular dependencies are detected and rejected at registration time
|
|
784
|
+
- Auto-added components are marked as changed and trigger reactive queries
|
|
785
|
+
- Component names and factory return types are fully type-checked
|
|
786
|
+
|
|
787
|
+
**Built-in requirements:** The Transform plugin registers `localTransform` → `worldTransform`. The Physics 2D plugin registers `rigidBody` → `velocity` and `rigidBody` → `force`.
|
|
788
|
+
|
|
789
|
+
### Built-in Plugins
|
|
790
|
+
|
|
791
|
+
| Plugin | Import | Default Phase | Description |
|
|
693
792
|
|--------|--------|---------------|-------------|
|
|
694
|
-
| **Input** | `ecspresso/
|
|
695
|
-
| **Timers** | `ecspresso/
|
|
696
|
-
| **Movement** | `ecspresso/
|
|
697
|
-
| **Transform** | `ecspresso/
|
|
698
|
-
| **Bounds** | `ecspresso/
|
|
699
|
-
| **Collision** | `ecspresso/
|
|
700
|
-
| **2D Renderer** | `ecspresso/
|
|
793
|
+
| **Input** | `ecspresso/plugins/input` | `preUpdate` | Frame-accurate keyboard/pointer input with action mapping |
|
|
794
|
+
| **Timers** | `ecspresso/plugins/timers` | `preUpdate` | ECS-native timers with event-based completion |
|
|
795
|
+
| **Movement** | `ecspresso/plugins/movement` | `fixedUpdate` | Velocity-based movement integration |
|
|
796
|
+
| **Transform** | `ecspresso/plugins/transform` | `postUpdate` | Hierarchical transform propagation (local/world transforms) |
|
|
797
|
+
| **Bounds** | `ecspresso/plugins/bounds` | `postUpdate` | Screen bounds enforcement (destroy, clamp, wrap) |
|
|
798
|
+
| **Collision** | `ecspresso/plugins/collision` | `postUpdate` | Layer-based AABB/circle collision detection with events |
|
|
799
|
+
| **2D Renderer** | `ecspresso/plugins/renderers/renderer2D` | `render` | Automated PixiJS scene graph wiring |
|
|
701
800
|
|
|
702
|
-
Each
|
|
801
|
+
Each plugin accepts a `phase` option to override its default.
|
|
703
802
|
|
|
704
|
-
### Input
|
|
803
|
+
### Input Plugin
|
|
705
804
|
|
|
706
|
-
The input
|
|
805
|
+
The input plugin provides frame-accurate keyboard, pointer (mouse + touch via PointerEvent), and named action mapping. It's a resource-only plugin — input is polled via the `inputState` resource. DOM events are accumulated between frames and snapshotted once per frame, so all systems see consistent state.
|
|
707
806
|
|
|
708
807
|
```typescript
|
|
709
808
|
import {
|
|
710
|
-
|
|
809
|
+
createInputPlugin,
|
|
711
810
|
type InputResourceTypes, type KeyCode
|
|
712
|
-
} from 'ecspresso/
|
|
713
|
-
|
|
714
|
-
interface Resources extends InputResourceTypes {}
|
|
811
|
+
} from 'ecspresso/plugins/input';
|
|
715
812
|
|
|
716
|
-
const world = ECSpresso.create
|
|
717
|
-
.
|
|
813
|
+
const world = ECSpresso.create()
|
|
814
|
+
.withPlugin(createInputPlugin({
|
|
718
815
|
actions: {
|
|
719
816
|
jump: { keys: [' ', 'ArrowUp'] },
|
|
720
817
|
shoot: { keys: ['z'], buttons: [0] },
|
|
@@ -730,21 +827,28 @@ if (input.actions.justActivated('jump')) { /* ... */ }
|
|
|
730
827
|
if (input.keyboard.isDown('ArrowRight')) { /* ... */ }
|
|
731
828
|
if (input.pointer.justPressed(0)) { /* ... */ }
|
|
732
829
|
|
|
733
|
-
// Runtime remapping
|
|
734
|
-
input.setActionMap({
|
|
830
|
+
// Runtime remapping — must include all configured actions
|
|
831
|
+
input.setActionMap({
|
|
832
|
+
jump: { keys: ['w'] },
|
|
833
|
+
shoot: { keys: ['z'], buttons: [0] },
|
|
834
|
+
moveLeft: { keys: ['a'] },
|
|
835
|
+
moveRight: { keys: ['d'] },
|
|
836
|
+
});
|
|
735
837
|
```
|
|
736
838
|
|
|
839
|
+
Action names are type-safe — `isActive`, `justActivated`, `justDeactivated`, `setActionMap`, and `getActionMap` only accept action names from the config. The type parameter `A` is inferred from the `actions` object keys passed to `createInputPlugin`. Defaults to `string` when no actions are configured.
|
|
840
|
+
|
|
737
841
|
Key values use the `KeyCode` type — a union of all standard `KeyboardEvent.key` values — providing autocomplete and compile-time validation. Note that the space bar key is `' '` (a space character), not `'Space'`.
|
|
738
842
|
|
|
739
|
-
### Timer
|
|
843
|
+
### Timer Plugin
|
|
740
844
|
|
|
741
|
-
The timer
|
|
845
|
+
The timer plugin provides ECS-native timers that follow the "data, not callbacks" philosophy. Timers are components processed each frame, with optional event-based completion notifications.
|
|
742
846
|
|
|
743
847
|
```typescript
|
|
744
848
|
import {
|
|
745
|
-
|
|
849
|
+
createTimerPlugin, createTimer, createRepeatingTimer,
|
|
746
850
|
type TimerComponentTypes, type TimerEventData
|
|
747
|
-
} from 'ecspresso/
|
|
851
|
+
} from 'ecspresso/plugins/timers';
|
|
748
852
|
|
|
749
853
|
// Events used with onComplete must have TimerEventData payload
|
|
750
854
|
interface Events {
|
|
@@ -752,21 +856,19 @@ interface Events {
|
|
|
752
856
|
spawnWave: TimerEventData;
|
|
753
857
|
}
|
|
754
858
|
|
|
755
|
-
interface Components extends TimerComponentTypes<Events> {
|
|
756
|
-
position: { x: number; y: number };
|
|
757
|
-
}
|
|
758
|
-
|
|
759
859
|
const world = ECSpresso
|
|
760
|
-
.create
|
|
761
|
-
.
|
|
860
|
+
.create()
|
|
861
|
+
.withPlugin(createTimerPlugin())
|
|
862
|
+
.withComponentTypes<{ position: { x: number; y: number } }>()
|
|
863
|
+
.withEventTypes<Events>()
|
|
762
864
|
.build();
|
|
763
865
|
|
|
764
866
|
// One-shot timer (poll justFinished or use onComplete event)
|
|
765
|
-
world.spawn({ ...createTimer
|
|
766
|
-
world.spawn({ ...createTimer
|
|
867
|
+
world.spawn({ ...createTimer(2.0), position: { x: 0, y: 0 } });
|
|
868
|
+
world.spawn({ ...createTimer(1.5, { onComplete: 'hideMessage' }) });
|
|
767
869
|
|
|
768
870
|
// Repeating timer
|
|
769
|
-
world.spawn({ ...createRepeatingTimer
|
|
871
|
+
world.spawn({ ...createRepeatingTimer(5.0, { onComplete: 'spawnWave' }) });
|
|
770
872
|
```
|
|
771
873
|
|
|
772
874
|
Timer components expose `elapsed`, `duration`, `repeat`, `active`, `justFinished`, and optional `onComplete` for runtime control.
|
|
@@ -782,7 +884,10 @@ type Assets = {
|
|
|
782
884
|
level1Background: { data: ImageBitmap };
|
|
783
885
|
};
|
|
784
886
|
|
|
785
|
-
const game = ECSpresso.create
|
|
887
|
+
const game = ECSpresso.create()
|
|
888
|
+
.withComponentTypes<Components>()
|
|
889
|
+
.withEventTypes<Events>()
|
|
890
|
+
.withResourceTypes<Resources>()
|
|
786
891
|
.withAssets(assets => assets
|
|
787
892
|
// Eager assets - loaded automatically during initialize()
|
|
788
893
|
.add('playerTexture', async () => {
|
|
@@ -813,8 +918,7 @@ game.addSystem('gameplay')
|
|
|
813
918
|
.requiresAssets(['playerTexture'])
|
|
814
919
|
.setProcess((queries, dt, ecs) => {
|
|
815
920
|
const player = ecs.getAsset('playerTexture');
|
|
816
|
-
})
|
|
817
|
-
.build();
|
|
921
|
+
});
|
|
818
922
|
```
|
|
819
923
|
|
|
820
924
|
Asset events (`assetLoaded`, `assetFailed`, `assetGroupProgress`, `assetGroupLoaded`) are available through the event system -- see [Events](#events).
|
|
@@ -838,7 +942,10 @@ type Screens = {
|
|
|
838
942
|
pause: ScreenDefinition<Record<string, never>, Record<string, never>>;
|
|
839
943
|
};
|
|
840
944
|
|
|
841
|
-
const game = ECSpresso.create
|
|
945
|
+
const game = ECSpresso.create()
|
|
946
|
+
.withComponentTypes<Components>()
|
|
947
|
+
.withEventTypes<Events>()
|
|
948
|
+
.withResourceTypes<Resources>()
|
|
842
949
|
.withScreens(screens => screens
|
|
843
950
|
.add('menu', {
|
|
844
951
|
initialState: () => ({ selectedOption: 0 }),
|
|
@@ -876,13 +983,11 @@ game.addSystem('menuUI')
|
|
|
876
983
|
.inScreens(['menu']) // Only runs in 'menu'
|
|
877
984
|
.setProcess((queries, dt, ecs) => {
|
|
878
985
|
renderMenu(ecs.getScreenState().selectedOption);
|
|
879
|
-
})
|
|
880
|
-
.build();
|
|
986
|
+
});
|
|
881
987
|
|
|
882
988
|
game.addSystem('animations')
|
|
883
989
|
.excludeScreens(['pause']) // Runs in all screens except 'pause'
|
|
884
|
-
.setProcess(() => { /* ... */ })
|
|
885
|
-
.build();
|
|
990
|
+
.setProcess(() => { /* ... */ });
|
|
886
991
|
```
|
|
887
992
|
|
|
888
993
|
### Screen Resource
|
|
@@ -900,8 +1005,7 @@ game.addSystem('ui')
|
|
|
900
1005
|
screen.stackDepth; // Number of screens in stack
|
|
901
1006
|
screen.isCurrent('gameplay'); // Check current screen
|
|
902
1007
|
screen.isActive('menu'); // true if in current or stack
|
|
903
|
-
})
|
|
904
|
-
.build();
|
|
1008
|
+
});
|
|
905
1009
|
```
|
|
906
1010
|
|
|
907
1011
|
## Type Safety
|
|
@@ -926,15 +1030,19 @@ world.addSystem('example')
|
|
|
926
1030
|
entity.components.position.x; // ✅ guaranteed
|
|
927
1031
|
entity.components.health.value; // ❌ not in query
|
|
928
1032
|
}
|
|
929
|
-
})
|
|
930
|
-
.build();
|
|
1033
|
+
});
|
|
931
1034
|
|
|
932
|
-
//
|
|
933
|
-
const
|
|
934
|
-
|
|
1035
|
+
// Plugin type compatibility - conflicting types error at compile time
|
|
1036
|
+
const plugin1 = definePlugin<WorldConfigFrom<{ position: { x: number; y: number } }>>({
|
|
1037
|
+
id: 'p1', install() {},
|
|
1038
|
+
});
|
|
1039
|
+
const plugin2 = definePlugin<WorldConfigFrom<{ velocity: { x: number; y: number } }>>({
|
|
1040
|
+
id: 'p2', install() {},
|
|
1041
|
+
});
|
|
1042
|
+
// Builder merges plugin types automatically — no manual type params needed
|
|
935
1043
|
const world = ECSpresso.create()
|
|
936
|
-
.
|
|
937
|
-
.
|
|
1044
|
+
.withPlugin(plugin1)
|
|
1045
|
+
.withPlugin(plugin2)
|
|
938
1046
|
.build();
|
|
939
1047
|
```
|
|
940
1048
|
|
|
@@ -949,8 +1057,8 @@ world.getResource('nonexistent');
|
|
|
949
1057
|
world.entityManager.addComponent(999, 'position', { x: 0, y: 0 });
|
|
950
1058
|
// → "Cannot add component 'position': Entity with ID 999 does not exist"
|
|
951
1059
|
|
|
952
|
-
// Component not found returns
|
|
953
|
-
world.entityManager.getComponent(123, 'position'); //
|
|
1060
|
+
// Component not found returns undefined (no throw)
|
|
1061
|
+
world.entityManager.getComponent(123, 'position'); // undefined
|
|
954
1062
|
```
|
|
955
1063
|
|
|
956
1064
|
## Performance Tips
|
|
@@ -958,7 +1066,7 @@ world.entityManager.getComponent(123, 'position'); // null
|
|
|
958
1066
|
- Use `changed` query filters to skip unchanged entities in render sync, transform propagation, and similar systems
|
|
959
1067
|
- Call `markChanged` after in-place mutations so downstream systems can detect the change
|
|
960
1068
|
- Extract business logic into testable helper functions using query type utilities
|
|
961
|
-
-
|
|
1069
|
+
- Group related systems into plugins for better organization and reusability
|
|
962
1070
|
- Use system phases to separate concerns (physics in `fixedUpdate`, rendering in `render`) and priorities for ordering within a phase
|
|
963
1071
|
- Use resource factories for expensive initialization (textures, audio, etc.)
|
|
964
1072
|
- Consider component callbacks for immediate reactions to state changes
|