ecspresso 0.8.0 → 0.10.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 +502 -729
- package/dist/bundles/renderers/renderer2D.d.ts +4 -4
- package/dist/bundles/renderers/renderer2D.js +2 -2
- package/dist/bundles/renderers/renderer2D.js.map +4 -8
- package/dist/bundles/utils/bounds.d.ts +4 -1
- package/dist/bundles/utils/bounds.js +4 -0
- package/dist/bundles/utils/bounds.js.map +10 -0
- package/dist/bundles/utils/collision.d.ts +4 -1
- package/dist/bundles/utils/collision.js +4 -0
- package/dist/bundles/utils/collision.js.map +10 -0
- package/dist/bundles/utils/input.d.ts +133 -0
- package/dist/bundles/utils/input.js +4 -0
- package/dist/bundles/utils/input.js.map +10 -0
- package/dist/bundles/utils/movement.d.ts +4 -1
- package/dist/bundles/utils/movement.js +4 -0
- package/dist/bundles/utils/movement.js.map +10 -0
- package/dist/bundles/utils/timers.d.ts +4 -1
- package/dist/bundles/utils/timers.js +2 -2
- package/dist/bundles/utils/timers.js.map +4 -6
- package/dist/bundles/utils/transform.d.ts +4 -1
- package/dist/bundles/utils/transform.js +4 -0
- package/dist/bundles/utils/transform.js.map +10 -0
- package/dist/command-buffer.d.ts +6 -0
- package/dist/ecspresso.d.ts +102 -13
- package/dist/entity-manager.d.ts +38 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +8 -8
- package/dist/reactive-query-manager.d.ts +22 -3
- package/dist/system-builder.d.ts +19 -4
- package/dist/types.d.ts +31 -5
- package/package.json +22 -2
package/README.md
CHANGED
|
@@ -15,6 +15,8 @@ A type-safe, modular, and extensible Entity Component System (ECS) framework for
|
|
|
15
15
|
- **Screen Management**: Game state/screen transitions with overlay support
|
|
16
16
|
- **Entity Hierarchy**: Parent-child relationships with traversal and cascade deletion
|
|
17
17
|
- **Query System**: Powerful entity filtering with helper type utilities
|
|
18
|
+
- **System Phases**: Named execution phases (preUpdate → fixedUpdate → update → postUpdate → render) with fixed-timestep simulation
|
|
19
|
+
- **Change Detection**: Per-system monotonic sequence change tracking with `changed` query filters
|
|
18
20
|
- **Reactive Queries**: Enter/exit callbacks when entities match or unmatch queries
|
|
19
21
|
- **System Groups**: Enable/disable groups of systems at runtime
|
|
20
22
|
- **Component Lifecycle**: Callbacks for component add/remove with unsubscribe support
|
|
@@ -27,6 +29,31 @@ A type-safe, modular, and extensible Entity Component System (ECS) framework for
|
|
|
27
29
|
npm install ecspresso
|
|
28
30
|
```
|
|
29
31
|
|
|
32
|
+
## Table of Contents
|
|
33
|
+
|
|
34
|
+
- [Quick Start](#quick-start)
|
|
35
|
+
- [Core Concepts](#core-concepts)
|
|
36
|
+
- [Entities and Components](#entities-and-components) -- [Component Callbacks](#component-callbacks), [Reactive Queries](#reactive-queries)
|
|
37
|
+
- [Systems and Queries](#systems-and-queries)
|
|
38
|
+
- [Resources](#resources)
|
|
39
|
+
- [Systems in Depth](#systems-in-depth)
|
|
40
|
+
- [Method Chaining](#method-chaining)
|
|
41
|
+
- [Query Type Utilities](#query-type-utilities)
|
|
42
|
+
- [System Phases](#system-phases)
|
|
43
|
+
- [System Priority](#system-priority)
|
|
44
|
+
- [System Groups](#system-groups)
|
|
45
|
+
- [System Lifecycle](#system-lifecycle)
|
|
46
|
+
- [Events](#events)
|
|
47
|
+
- [Entity Hierarchy](#entity-hierarchy) -- [Traversal](#traversal), [Parent-First Traversal](#parent-first-traversal), [Cascade Deletion](#cascade-deletion)
|
|
48
|
+
- [Change Detection](#change-detection) -- [Marking Changes](#marking-changes), [Changed Query Filter](#changed-query-filter), [Sequence Timing](#sequence-timing)
|
|
49
|
+
- [Command Buffer](#command-buffer) -- [Available Commands](#available-commands)
|
|
50
|
+
- [Bundles](#bundles) -- [Built-in Bundles](#built-in-bundles), [Timer Bundle](#timer-bundle)
|
|
51
|
+
- [Asset Management](#asset-management)
|
|
52
|
+
- [Screen Management](#screen-management) -- [Screen-Scoped Systems](#screen-scoped-systems), [Screen Resource](#screen-resource)
|
|
53
|
+
- [Type Safety](#type-safety)
|
|
54
|
+
- [Error Handling](#error-handling)
|
|
55
|
+
- [Performance Tips](#performance-tips)
|
|
56
|
+
|
|
30
57
|
## Quick Start
|
|
31
58
|
|
|
32
59
|
```typescript
|
|
@@ -88,21 +115,72 @@ world.entityManager.removeComponent(entity.id, 'velocity');
|
|
|
88
115
|
world.entityManager.removeEntity(entity.id);
|
|
89
116
|
```
|
|
90
117
|
|
|
118
|
+
#### Component Callbacks
|
|
119
|
+
|
|
120
|
+
React to component additions and removals. Both methods return an unsubscribe function:
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
const unsubAdd = world.onComponentAdded('health', (value, entity) => {
|
|
124
|
+
console.log(`Health added to entity ${entity.id}:`, value);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const unsubRemove = world.onComponentRemoved('health', (oldValue, entity) => {
|
|
128
|
+
console.log(`Health removed from entity ${entity.id}:`, oldValue);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Unsubscribe when done
|
|
132
|
+
unsubAdd();
|
|
133
|
+
unsubRemove();
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Also available on `world.entityManager.onComponentAdded()` / `onComponentRemoved()`.
|
|
137
|
+
|
|
138
|
+
#### Reactive Queries
|
|
139
|
+
|
|
140
|
+
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:
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
world.addReactiveQuery('enemies', {
|
|
144
|
+
with: ['position', 'enemy'],
|
|
145
|
+
without: ['dead'],
|
|
146
|
+
onEnter: (entity) => {
|
|
147
|
+
console.log(`Enemy ${entity.id} appeared at`, entity.components.position);
|
|
148
|
+
spawnHealthBar(entity.id);
|
|
149
|
+
},
|
|
150
|
+
onExit: (entityId) => {
|
|
151
|
+
// Receives ID since entity may already be removed
|
|
152
|
+
console.log(`Enemy ${entityId} gone`);
|
|
153
|
+
removeHealthBar(entityId);
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Triggers onEnter: spawning matching entity, adding required component, removing excluded component
|
|
158
|
+
const enemy = world.spawn({ position: { x: 0, y: 0 }, enemy: true }); // onEnter fires
|
|
159
|
+
|
|
160
|
+
// Triggers onExit: removing required component, adding excluded component, removing entity
|
|
161
|
+
world.entityManager.addComponent(enemy.id, 'dead', true); // onExit fires
|
|
162
|
+
|
|
163
|
+
// Existing matching entities trigger onEnter when query is added
|
|
164
|
+
// Component replacement does NOT trigger enter/exit (match status unchanged)
|
|
165
|
+
|
|
166
|
+
// Remove reactive query when no longer needed
|
|
167
|
+
world.removeReactiveQuery('enemies'); // returns true if existed
|
|
168
|
+
```
|
|
169
|
+
|
|
91
170
|
### Systems and Queries
|
|
92
171
|
|
|
93
172
|
Systems process entities that match specific component patterns:
|
|
94
173
|
|
|
95
174
|
```typescript
|
|
96
175
|
world.addSystem('combat')
|
|
97
|
-
.addQuery('fighters', {
|
|
98
|
-
with: ['position', 'health'],
|
|
99
|
-
without: ['dead']
|
|
176
|
+
.addQuery('fighters', {
|
|
177
|
+
with: ['position', 'health'],
|
|
178
|
+
without: ['dead']
|
|
100
179
|
})
|
|
101
|
-
.addQuery('projectiles', {
|
|
102
|
-
with: ['position', 'damage']
|
|
180
|
+
.addQuery('projectiles', {
|
|
181
|
+
with: ['position', 'damage']
|
|
103
182
|
})
|
|
104
183
|
.setProcess((queries, deltaTime) => {
|
|
105
|
-
// Process fighters and projectiles
|
|
106
184
|
for (const fighter of queries.fighters) {
|
|
107
185
|
for (const projectile of queries.projectiles) {
|
|
108
186
|
// Combat logic here
|
|
@@ -114,7 +192,7 @@ world.addSystem('combat')
|
|
|
114
192
|
|
|
115
193
|
### Resources
|
|
116
194
|
|
|
117
|
-
Resources provide global state accessible to all systems
|
|
195
|
+
Resources provide global state accessible to all systems.
|
|
118
196
|
|
|
119
197
|
```typescript
|
|
120
198
|
interface Resources {
|
|
@@ -124,9 +202,21 @@ interface Resources {
|
|
|
124
202
|
|
|
125
203
|
const world = new ECSpresso<Components, {}, Resources>();
|
|
126
204
|
|
|
127
|
-
//
|
|
205
|
+
// Direct values
|
|
128
206
|
world.addResource('score', { value: 0 });
|
|
129
|
-
|
|
207
|
+
|
|
208
|
+
// Sync or async factories (lazy initialization)
|
|
209
|
+
world.addResource('config', () => ({ difficulty: 'normal', soundEnabled: true }));
|
|
210
|
+
world.addResource('database', async () => await connectToDatabase());
|
|
211
|
+
|
|
212
|
+
// Factory with dependencies (initialized after dependencies are ready)
|
|
213
|
+
world.addResource('cache', {
|
|
214
|
+
dependsOn: ['database'],
|
|
215
|
+
factory: (ecs) => ({ db: ecs.getResource('database') })
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Initialize all resources (respects dependency order, detects circular deps)
|
|
219
|
+
await world.initializeResources();
|
|
130
220
|
|
|
131
221
|
// Use in systems
|
|
132
222
|
world.addSystem('scoring')
|
|
@@ -137,14 +227,48 @@ world.addSystem('scoring')
|
|
|
137
227
|
.build();
|
|
138
228
|
```
|
|
139
229
|
|
|
140
|
-
|
|
230
|
+
**Builder pattern** -- resources chain naturally with other builder methods:
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
const world = ECSpresso
|
|
234
|
+
.create<Components, Events, Resources>()
|
|
235
|
+
.withBundle(physicsBundle)
|
|
236
|
+
.withResource('config', { debug: true, maxEntities: 1000 })
|
|
237
|
+
.withResource('score', () => ({ value: 0 }))
|
|
238
|
+
.withResource('cache', {
|
|
239
|
+
dependsOn: ['database'],
|
|
240
|
+
factory: (ecs) => createCache(ecs.getResource('database'))
|
|
241
|
+
})
|
|
242
|
+
.build();
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
**Disposal** -- resources can define cleanup logic with `onDispose` callbacks:
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
world.addResource('keyboard', {
|
|
249
|
+
factory: () => {
|
|
250
|
+
const handler = (e: KeyboardEvent) => { /* ... */ };
|
|
251
|
+
window.addEventListener('keydown', handler);
|
|
252
|
+
return { handler };
|
|
253
|
+
},
|
|
254
|
+
onDispose: (resource) => {
|
|
255
|
+
window.removeEventListener('keydown', resource.handler);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await world.disposeResource('keyboard'); // Dispose a single resource
|
|
260
|
+
await world.disposeResources(); // All, in reverse dependency order
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`onDispose` receives the resource value and the ECSpresso instance. Supports sync and async callbacks. Only initialized resources have their `onDispose` called. `removeResource()` still exists for removal without disposal.
|
|
264
|
+
|
|
265
|
+
## Systems in Depth
|
|
141
266
|
|
|
142
267
|
### Method Chaining
|
|
143
268
|
|
|
144
|
-
Chain multiple systems using `.and()
|
|
269
|
+
Chain multiple systems using `.and()`. The `.and()` method returns the parent container (ECSpresso or Bundle), enabling fluent chaining:
|
|
145
270
|
|
|
146
271
|
```typescript
|
|
147
|
-
// Chaining systems on ECSpresso
|
|
148
272
|
world.addSystem('physics')
|
|
149
273
|
.addQuery('moving', { with: ['position', 'velocity'] })
|
|
150
274
|
.setProcess((queries, deltaTime) => {
|
|
@@ -157,16 +281,6 @@ world.addSystem('physics')
|
|
|
157
281
|
// Rendering logic
|
|
158
282
|
})
|
|
159
283
|
.build();
|
|
160
|
-
|
|
161
|
-
// Chaining systems in a Bundle
|
|
162
|
-
const bundle = new Bundle<Components>()
|
|
163
|
-
.addSystem('movement')
|
|
164
|
-
.setProcess(() => { /* ... */ })
|
|
165
|
-
.and() // Returns Bundle for continued chaining
|
|
166
|
-
.addSystem('collision')
|
|
167
|
-
.setProcess(() => { /* ... */ })
|
|
168
|
-
.and()
|
|
169
|
-
.addResource('config', { speed: 10 });
|
|
170
284
|
```
|
|
171
285
|
|
|
172
286
|
### Query Type Utilities
|
|
@@ -178,14 +292,13 @@ import { createQueryDefinition, QueryResultEntity } from 'ecspresso';
|
|
|
178
292
|
|
|
179
293
|
// Create reusable query definitions
|
|
180
294
|
const movingQuery = createQueryDefinition<Components>({
|
|
181
|
-
with: ['position', 'velocity']
|
|
182
|
-
without: ['frozen']
|
|
295
|
+
with: ['position', 'velocity'],
|
|
296
|
+
without: ['frozen']
|
|
183
297
|
});
|
|
184
298
|
|
|
185
299
|
// Extract entity type for helper functions
|
|
186
300
|
type MovingEntity = QueryResultEntity<Components, typeof movingQuery>;
|
|
187
301
|
|
|
188
|
-
// Create type-safe helper functions
|
|
189
302
|
function updatePosition(entity: MovingEntity, deltaTime: number) {
|
|
190
303
|
entity.components.position.x += entity.components.velocity.x * deltaTime;
|
|
191
304
|
entity.components.position.y += entity.components.velocity.y * deltaTime;
|
|
@@ -202,18 +315,68 @@ world.addSystem('movement')
|
|
|
202
315
|
.build();
|
|
203
316
|
```
|
|
204
317
|
|
|
318
|
+
### System Phases
|
|
319
|
+
|
|
320
|
+
Systems are organized into named execution phases that run in a fixed order:
|
|
321
|
+
|
|
322
|
+
```
|
|
323
|
+
preUpdate → fixedUpdate → update → postUpdate → render
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Each phase's command buffer is played back before the next phase begins, so entities spawned in `preUpdate` are visible to `fixedUpdate`, and so on. Systems without `.inPhase()` default to `update`.
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
world.addSystem('input')
|
|
330
|
+
.inPhase('preUpdate')
|
|
331
|
+
.setProcess((queries, dt, ecs) => { /* Read input, update timers */ })
|
|
332
|
+
.and()
|
|
333
|
+
.addSystem('physics')
|
|
334
|
+
.inPhase('fixedUpdate')
|
|
335
|
+
.setProcess((queries, dt, ecs) => {
|
|
336
|
+
// dt is always fixedDt here (e.g. 1/60)
|
|
337
|
+
// Runs 0..N times per frame based on accumulated time
|
|
338
|
+
})
|
|
339
|
+
.and()
|
|
340
|
+
.addSystem('gameplay')
|
|
341
|
+
.inPhase('update') // default phase
|
|
342
|
+
.setProcess((queries, dt, ecs) => { /* Game logic, AI */ })
|
|
343
|
+
.and()
|
|
344
|
+
.addSystem('transform-sync')
|
|
345
|
+
.inPhase('postUpdate')
|
|
346
|
+
.setProcess((queries, dt, ecs) => { /* Transform propagation */ })
|
|
347
|
+
.and()
|
|
348
|
+
.addSystem('renderer')
|
|
349
|
+
.inPhase('render')
|
|
350
|
+
.setProcess((queries, dt, ecs) => { /* Visual output */ })
|
|
351
|
+
.build();
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
**Fixed Timestep** -- The `fixedUpdate` phase uses a time accumulator for deterministic simulation. A spiral-of-death cap (8 steps) prevents runaway accumulation.
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
const world = ECSpresso.create<Components, Events, Resources>()
|
|
358
|
+
.withFixedTimestep(1 / 60) // 60Hz physics (default)
|
|
359
|
+
.build();
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
**Interpolation** -- Use `ecs.interpolationAlpha` (0..1) in the render phase to smooth between fixed steps.
|
|
363
|
+
|
|
364
|
+
**Runtime Phase Changes** -- Move systems between phases at runtime with `world.updateSystemPhase('debug-overlay', 'render')`.
|
|
365
|
+
|
|
205
366
|
### System Priority
|
|
206
367
|
|
|
207
|
-
|
|
368
|
+
Within each phase, systems execute in priority order (higher numbers first). Systems with the same priority execute in registration order:
|
|
208
369
|
|
|
209
370
|
```typescript
|
|
210
371
|
world.addSystem('physics')
|
|
211
|
-
.
|
|
372
|
+
.inPhase('fixedUpdate')
|
|
373
|
+
.setPriority(100) // Runs first within fixedUpdate
|
|
212
374
|
.setProcess(() => { /* physics */ })
|
|
213
375
|
.and()
|
|
214
|
-
.addSystem('
|
|
215
|
-
.
|
|
216
|
-
.
|
|
376
|
+
.addSystem('constraints')
|
|
377
|
+
.inPhase('fixedUpdate')
|
|
378
|
+
.setPriority(50) // Runs second within fixedUpdate
|
|
379
|
+
.setProcess(() => { /* constraints */ })
|
|
217
380
|
.build();
|
|
218
381
|
```
|
|
219
382
|
|
|
@@ -222,7 +385,6 @@ world.addSystem('physics')
|
|
|
222
385
|
Organize systems into groups that can be enabled/disabled at runtime:
|
|
223
386
|
|
|
224
387
|
```typescript
|
|
225
|
-
// Assign systems to groups
|
|
226
388
|
world.addSystem('renderSprites')
|
|
227
389
|
.inGroup('rendering')
|
|
228
390
|
.addQuery('sprites', { with: ['position', 'sprite'] })
|
|
@@ -234,96 +396,68 @@ world.addSystem('renderSprites')
|
|
|
234
396
|
.setProcess(() => { /* ... */ })
|
|
235
397
|
.build();
|
|
236
398
|
|
|
237
|
-
//
|
|
238
|
-
world.
|
|
239
|
-
world.
|
|
240
|
-
|
|
241
|
-
// Query group state
|
|
242
|
-
world.isSystemGroupEnabled('rendering'); // true/false
|
|
243
|
-
world.getSystemsInGroup('rendering'); // ['renderSprites', 'renderParticles']
|
|
399
|
+
world.disableSystemGroup('rendering'); // All rendering systems skip
|
|
400
|
+
world.enableSystemGroup('rendering'); // Resume rendering
|
|
401
|
+
world.isSystemGroupEnabled('rendering'); // true/false
|
|
402
|
+
world.getSystemsInGroup('rendering'); // ['renderSprites', 'renderParticles']
|
|
244
403
|
|
|
245
404
|
// If a system belongs to multiple groups, disabling ANY group skips the system
|
|
246
|
-
world.disableSystemGroup('effects'); // renderParticles won't run
|
|
247
405
|
```
|
|
248
406
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
### Bundles
|
|
407
|
+
### System Lifecycle
|
|
252
408
|
|
|
253
|
-
|
|
409
|
+
Systems can have initialization, cleanup, and post-update hooks:
|
|
254
410
|
|
|
255
411
|
```typescript
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
position: { x: number; y: number };
|
|
260
|
-
velocity: { x: number; y: number };
|
|
261
|
-
sprite: { texture: string };
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
interface GameResources {
|
|
265
|
-
gravity: { value: number };
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Create a bundle with multiple systems using .and() for chaining
|
|
269
|
-
const physicsBundle = new Bundle<GameComponents, {}, GameResources>('physics')
|
|
270
|
-
.addSystem('applyVelocity')
|
|
271
|
-
.addQuery('moving', { with: ['position', 'velocity'] })
|
|
272
|
-
.setProcess((queries, deltaTime) => {
|
|
273
|
-
for (const entity of queries.moving) {
|
|
274
|
-
entity.components.position.x += entity.components.velocity.x * deltaTime;
|
|
275
|
-
entity.components.position.y += entity.components.velocity.y * deltaTime;
|
|
276
|
-
}
|
|
412
|
+
world.addSystem('gameSystem')
|
|
413
|
+
.setOnInitialize(async (ecs) => {
|
|
414
|
+
console.log('System starting...');
|
|
277
415
|
})
|
|
278
|
-
.
|
|
279
|
-
|
|
280
|
-
.addQuery('falling', { with: ['velocity'] })
|
|
281
|
-
.setProcess((queries, deltaTime, ecs) => {
|
|
282
|
-
const gravity = ecs.getResource('gravity');
|
|
283
|
-
for (const entity of queries.falling) {
|
|
284
|
-
entity.components.velocity.y += gravity.value * deltaTime;
|
|
285
|
-
}
|
|
416
|
+
.setOnDetach((ecs) => {
|
|
417
|
+
console.log('System shutting down...');
|
|
286
418
|
})
|
|
287
|
-
.
|
|
288
|
-
.addResource('gravity', { value: 9.8 });
|
|
419
|
+
.build();
|
|
289
420
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
.addQuery('sprites', { with: ['position', 'sprite'] })
|
|
293
|
-
.setProcess((queries) => {
|
|
294
|
-
// Render sprites
|
|
295
|
-
})
|
|
296
|
-
.and();
|
|
421
|
+
await world.initialize();
|
|
422
|
+
```
|
|
297
423
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
424
|
+
**Post-Update Hooks** -- Register callbacks that run between the `postUpdate` and `render` phases:
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
// Returns unsubscribe function; multiple hooks run in registration order
|
|
428
|
+
const unsubscribe = world.onPostUpdate((ecs, deltaTime) => {
|
|
429
|
+
console.log(`Frame completed in ${deltaTime}s`);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
unsubscribe();
|
|
303
433
|
```
|
|
304
434
|
|
|
305
|
-
|
|
435
|
+
## Events
|
|
306
436
|
|
|
307
|
-
Use events for decoupled system communication
|
|
437
|
+
Use events for decoupled system communication. Events work across all features -- hierarchy changes, asset loading, timer completion, and custom game events all use the same system.
|
|
308
438
|
|
|
309
439
|
```typescript
|
|
310
440
|
interface Events {
|
|
311
441
|
playerDied: { playerId: number };
|
|
312
442
|
levelComplete: { score: number };
|
|
443
|
+
// Hierarchy events (if using entity hierarchy)
|
|
444
|
+
hierarchyChanged: {
|
|
445
|
+
entityId: number;
|
|
446
|
+
oldParent: number | null;
|
|
447
|
+
newParent: number | null;
|
|
448
|
+
};
|
|
313
449
|
}
|
|
314
450
|
|
|
315
451
|
const world = new ECSpresso<Components, Events>();
|
|
316
452
|
|
|
317
|
-
// Subscribe
|
|
453
|
+
// Subscribe - returns unsubscribe function
|
|
318
454
|
const unsubscribe = world.on('playerDied', (data) => {
|
|
319
455
|
console.log(`Player ${data.playerId} died`);
|
|
320
456
|
});
|
|
321
|
-
|
|
322
|
-
// Unsubscribe when done
|
|
323
457
|
unsubscribe();
|
|
324
458
|
|
|
325
|
-
// Or unsubscribe by callback reference
|
|
326
|
-
const handler = (data) => console.log(`
|
|
459
|
+
// Or unsubscribe by callback reference
|
|
460
|
+
const handler = (data) => console.log(`Score: ${data.score}`);
|
|
327
461
|
world.on('levelComplete', handler);
|
|
328
462
|
world.off('levelComplete', handler);
|
|
329
463
|
|
|
@@ -332,8 +466,7 @@ world.addSystem('gameLogic')
|
|
|
332
466
|
.setEventHandlers({
|
|
333
467
|
playerDied: {
|
|
334
468
|
handler: (data, ecs) => {
|
|
335
|
-
|
|
336
|
-
// Respawn logic here
|
|
469
|
+
// Respawn logic
|
|
337
470
|
}
|
|
338
471
|
}
|
|
339
472
|
})
|
|
@@ -343,302 +476,312 @@ world.addSystem('gameLogic')
|
|
|
343
476
|
world.eventBus.publish('playerDied', { playerId: 1 });
|
|
344
477
|
```
|
|
345
478
|
|
|
346
|
-
|
|
479
|
+
**Built-in events**: `hierarchyChanged` (entity parent changes), `assetLoaded` / `assetFailed` / `assetGroupProgress` / `assetGroupLoaded` (asset loading), and timer `onComplete` events (see [Bundles](#bundles)).
|
|
347
480
|
|
|
348
|
-
|
|
481
|
+
## Entity Hierarchy
|
|
482
|
+
|
|
483
|
+
Create parent-child relationships between entities for scene graphs, UI trees, or skeletal hierarchies:
|
|
349
484
|
|
|
350
485
|
```typescript
|
|
351
|
-
|
|
352
|
-
config: { difficulty: string; soundEnabled: boolean };
|
|
353
|
-
database: Database;
|
|
354
|
-
cache: { db: Database };
|
|
355
|
-
}
|
|
486
|
+
const player = world.spawn({ position: { x: 0, y: 0 } });
|
|
356
487
|
|
|
357
|
-
|
|
488
|
+
// Create child entity
|
|
489
|
+
const weapon = world.spawnChild(player.id, { position: { x: 10, y: 0 } });
|
|
490
|
+
|
|
491
|
+
// Or set parent on existing entity
|
|
492
|
+
const shield = world.spawn({ position: { x: -10, y: 0 } });
|
|
493
|
+
world.setParent(shield.id, player.id);
|
|
358
494
|
|
|
359
|
-
//
|
|
360
|
-
world.
|
|
361
|
-
|
|
362
|
-
soundEnabled: true
|
|
363
|
-
}));
|
|
495
|
+
// Orphan an entity
|
|
496
|
+
world.removeParent(shield.id);
|
|
497
|
+
```
|
|
364
498
|
|
|
365
|
-
|
|
366
|
-
world.addResource('database', async () => {
|
|
367
|
-
return await connectToDatabase();
|
|
368
|
-
});
|
|
499
|
+
### Traversal
|
|
369
500
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
501
|
+
| Method | Returns | Description |
|
|
502
|
+
|--------|---------|-------------|
|
|
503
|
+
| `getParent(id)` | `number \| null` | Parent entity ID |
|
|
504
|
+
| `getChildren(id)` | `number[]` | Direct children |
|
|
505
|
+
| `getAncestors(id)` | `number[]` | Entity up to root |
|
|
506
|
+
| `getDescendants(id)` | `number[]` | Depth-first order |
|
|
507
|
+
| `getRoot(id)` | `number` | Root of the hierarchy |
|
|
508
|
+
| `getSiblings(id)` | `number[]` | Other children of same parent |
|
|
509
|
+
| `getRootEntities()` | `number[]` | All root entities |
|
|
510
|
+
| `getChildAt(id, index)` | `number` | Child at index |
|
|
511
|
+
| `getChildIndex(parentId, childId)` | `number` | Index of child |
|
|
512
|
+
| `isDescendantOf(id, ancestorId)` | `boolean` | Relationship check |
|
|
513
|
+
| `isAncestorOf(id, descendantId)` | `boolean` | Relationship check |
|
|
514
|
+
|
|
515
|
+
### Parent-First Traversal
|
|
516
|
+
|
|
517
|
+
Iterate the hierarchy with guaranteed parent-first order (useful for transform propagation):
|
|
518
|
+
|
|
519
|
+
```typescript
|
|
520
|
+
// Callback-based traversal
|
|
521
|
+
world.forEachInHierarchy((entityId, parentId, depth) => {
|
|
522
|
+
// Parents are always visited before their children
|
|
376
523
|
});
|
|
377
524
|
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
```
|
|
525
|
+
// Filter to specific subtrees
|
|
526
|
+
world.forEachInHierarchy(callback, { roots: [root.id] });
|
|
381
527
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
528
|
+
// Generator-based (supports early termination)
|
|
529
|
+
for (const { entityId, parentId, depth } of world.hierarchyIterator()) {
|
|
530
|
+
if (depth > 2) break;
|
|
531
|
+
}
|
|
532
|
+
```
|
|
386
533
|
|
|
387
|
-
###
|
|
534
|
+
### Cascade Deletion
|
|
388
535
|
|
|
389
|
-
|
|
536
|
+
When removing entities, descendants are automatically removed by default:
|
|
390
537
|
|
|
391
538
|
```typescript
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
.withResource('cache', {
|
|
398
|
-
dependsOn: ['database'],
|
|
399
|
-
factory: (ecs) => createCache(ecs.getResource('database'))
|
|
400
|
-
})
|
|
401
|
-
.build();
|
|
539
|
+
world.removeEntity(parent.id);
|
|
540
|
+
// All descendants are removed
|
|
541
|
+
|
|
542
|
+
// To orphan children instead:
|
|
543
|
+
world.removeEntity(parent.id, { cascade: false });
|
|
402
544
|
```
|
|
403
545
|
|
|
404
|
-
|
|
546
|
+
Hierarchy changes emit the `hierarchyChanged` event (see [Events](#events)).
|
|
405
547
|
|
|
406
|
-
|
|
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 bundle implements this automatically.
|
|
407
549
|
|
|
408
|
-
|
|
550
|
+
## Change Detection
|
|
409
551
|
|
|
410
|
-
|
|
411
|
-
// Factory with disposal callback
|
|
412
|
-
world.addResource('keyboard', {
|
|
413
|
-
factory: () => {
|
|
414
|
-
const handler = (e: KeyboardEvent) => { /* ... */ };
|
|
415
|
-
window.addEventListener('keydown', handler);
|
|
416
|
-
return { handler };
|
|
417
|
-
},
|
|
418
|
-
onDispose: (resource) => {
|
|
419
|
-
window.removeEventListener('keydown', resource.handler);
|
|
420
|
-
}
|
|
421
|
-
});
|
|
552
|
+
ECSpresso tracks component changes using a per-system monotonic sequence. Each `markChanged` call increments a global counter and stamps the component with a unique sequence number. Each system tracks the highest sequence it has seen; on its next execution, it only processes marks with a sequence greater than its last-seen value. This means each mark is processed exactly once per system, and marks expire after a single update cycle.
|
|
422
553
|
|
|
423
|
-
|
|
424
|
-
const world = ECSpresso
|
|
425
|
-
.create<Components, Events, Resources>()
|
|
426
|
-
.withResource('database', {
|
|
427
|
-
factory: async () => await connectToDatabase(),
|
|
428
|
-
onDispose: async (db) => await db.close()
|
|
429
|
-
})
|
|
430
|
-
.build();
|
|
554
|
+
### Marking Changes
|
|
431
555
|
|
|
432
|
-
|
|
433
|
-
await world.disposeResource('keyboard');
|
|
556
|
+
Components are automatically marked as changed when added via `spawn()`, `addComponent()`, or `addComponents()`. For in-place mutations, call `markChanged` explicitly:
|
|
434
557
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
558
|
+
```typescript
|
|
559
|
+
const position = world.entityManager.getComponent(entity.id, 'position');
|
|
560
|
+
if (position) {
|
|
561
|
+
position.x += 10;
|
|
562
|
+
world.markChanged(entity.id, 'position');
|
|
563
|
+
}
|
|
438
564
|
```
|
|
439
565
|
|
|
440
|
-
|
|
441
|
-
- `onDispose` receives the resource value and the ECSpresso instance as context
|
|
442
|
-
- `disposeResources()` disposes in reverse topological order (dependents first)
|
|
443
|
-
- Only initialized resources have their `onDispose` called
|
|
444
|
-
- Supports both sync and async disposal callbacks
|
|
445
|
-
- `removeResource()` still exists for removal without disposal
|
|
446
|
-
|
|
447
|
-
### System Lifecycle
|
|
566
|
+
### Changed Query Filter
|
|
448
567
|
|
|
449
|
-
|
|
568
|
+
Add `changed` to a query definition to filter entities to only those whose specified components changed since the system last ran:
|
|
450
569
|
|
|
451
570
|
```typescript
|
|
452
|
-
world.addSystem('
|
|
453
|
-
.
|
|
454
|
-
|
|
455
|
-
|
|
571
|
+
world.addSystem('render-sync')
|
|
572
|
+
.addQuery('moved', {
|
|
573
|
+
with: ['position', 'sprite'],
|
|
574
|
+
changed: ['position'], // Only entities whose position changed this tick
|
|
456
575
|
})
|
|
457
|
-
.
|
|
458
|
-
|
|
459
|
-
|
|
576
|
+
.setProcess((queries) => {
|
|
577
|
+
for (const entity of queries.moved) {
|
|
578
|
+
syncSpritePosition(entity);
|
|
579
|
+
}
|
|
460
580
|
})
|
|
461
581
|
.build();
|
|
462
|
-
|
|
463
|
-
// Initialize all systems
|
|
464
|
-
await world.initialize();
|
|
465
582
|
```
|
|
466
583
|
|
|
467
|
-
|
|
584
|
+
When multiple components are listed in `changed`, entities matching **any** of them are included (OR semantics).
|
|
468
585
|
|
|
469
|
-
|
|
586
|
+
### Sequence Timing
|
|
470
587
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
console.log(`Frame completed in ${deltaTime}s`);
|
|
477
|
-
});
|
|
588
|
+
- Marks made between updates are visible to all systems on the next update
|
|
589
|
+
- Spawn auto-marks are visible on the first update
|
|
590
|
+
- Marks from earlier phases are visible to later phases within the same frame
|
|
591
|
+
- Within a phase, a higher-priority system's marks are visible to lower-priority systems
|
|
592
|
+
- Each mark is processed exactly once per system (single-update expiry)
|
|
478
593
|
|
|
479
|
-
|
|
480
|
-
world.onPostUpdate((ecs) => {
|
|
481
|
-
// First hook
|
|
482
|
-
});
|
|
483
|
-
world.onPostUpdate((ecs) => {
|
|
484
|
-
// Second hook
|
|
485
|
-
});
|
|
594
|
+
For manual change detection outside of system queries:
|
|
486
595
|
|
|
487
|
-
|
|
488
|
-
|
|
596
|
+
```typescript
|
|
597
|
+
const em = ecs.entityManager;
|
|
598
|
+
if (em.getChangeSeq(entity.id, 'localTransform') > ecs.changeThreshold) {
|
|
599
|
+
// Component changed since last system execution (or since last update if between updates)
|
|
600
|
+
}
|
|
489
601
|
```
|
|
490
602
|
|
|
491
|
-
|
|
603
|
+
**Deferred marking**: `ecs.commands.markChanged(entity.id, 'position')` queues a mark for command buffer playback.
|
|
492
604
|
|
|
493
|
-
|
|
605
|
+
**Built-in bundle usage**: Movement marks `localTransform` (fixedUpdate) → Transform propagation reads `localTransform` changed, writes+marks `worldTransform` (postUpdate) → Renderer reads `worldTransform` changed (render).
|
|
494
606
|
|
|
495
|
-
|
|
496
|
-
const world = new ECSpresso<Components>();
|
|
607
|
+
## Command Buffer
|
|
497
608
|
|
|
498
|
-
|
|
499
|
-
const player = world.spawn({
|
|
500
|
-
position: { x: 0, y: 0 }
|
|
501
|
-
});
|
|
609
|
+
Queue structural changes during system execution that execute between phases. This prevents issues when modifying entities during iteration.
|
|
502
610
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
world.getParent(shield.id); // null
|
|
611
|
+
```typescript
|
|
612
|
+
world.addSystem('combat')
|
|
613
|
+
.addQuery('enemies', { with: ['enemy', 'health'] })
|
|
614
|
+
.setProcess((queries, dt, ecs) => {
|
|
615
|
+
for (const entity of queries.enemies) {
|
|
616
|
+
if (entity.components.health.value <= 0) {
|
|
617
|
+
ecs.commands.removeEntity(entity.id);
|
|
618
|
+
ecs.commands.spawn({
|
|
619
|
+
position: entity.components.position,
|
|
620
|
+
explosion: true,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
})
|
|
625
|
+
.build();
|
|
519
626
|
```
|
|
520
627
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
Navigate the hierarchy tree with traversal utilities:
|
|
628
|
+
### Available Commands
|
|
524
629
|
|
|
525
630
|
```typescript
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
// Ancestors (from entity up to root)
|
|
532
|
-
world.getAncestors(grandchild.id); // [child.id, root.id]
|
|
533
|
-
|
|
534
|
-
// Descendants (depth-first order)
|
|
535
|
-
world.getDescendants(root.id); // [child.id, grandchild.id]
|
|
536
|
-
|
|
537
|
-
// Get root of any entity
|
|
538
|
-
world.getRoot(grandchild.id); // root.id
|
|
631
|
+
// Entity operations
|
|
632
|
+
ecs.commands.spawn({ position: { x: 0, y: 0 } });
|
|
633
|
+
ecs.commands.spawnChild(parentId, { position: { x: 10, y: 0 } });
|
|
634
|
+
ecs.commands.removeEntity(entityId);
|
|
635
|
+
ecs.commands.removeEntity(entityId, { cascade: false });
|
|
539
636
|
|
|
540
|
-
//
|
|
541
|
-
|
|
542
|
-
|
|
637
|
+
// Component operations
|
|
638
|
+
ecs.commands.addComponent(entityId, 'velocity', { x: 5, y: 0 });
|
|
639
|
+
ecs.commands.addComponents(entityId, { velocity: { x: 5, y: 0 }, health: { value: 100 } });
|
|
640
|
+
ecs.commands.removeComponent(entityId, 'velocity');
|
|
543
641
|
|
|
544
|
-
//
|
|
545
|
-
|
|
546
|
-
|
|
642
|
+
// Hierarchy operations
|
|
643
|
+
ecs.commands.setParent(childId, parentId);
|
|
644
|
+
ecs.commands.removeParent(childId);
|
|
547
645
|
|
|
548
|
-
//
|
|
549
|
-
|
|
646
|
+
// Change detection
|
|
647
|
+
ecs.commands.markChanged(entityId, 'position');
|
|
550
648
|
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
|
|
649
|
+
// Utility
|
|
650
|
+
ecs.commands.length; // Number of queued commands
|
|
651
|
+
ecs.commands.clear(); // Discard all queued commands
|
|
554
652
|
```
|
|
555
653
|
|
|
556
|
-
|
|
654
|
+
Commands execute in FIFO order. If a command fails (e.g., entity doesn't exist), it logs a warning and continues with remaining commands.
|
|
557
655
|
|
|
558
|
-
|
|
656
|
+
## Bundles
|
|
657
|
+
|
|
658
|
+
Organize related systems and resources into reusable bundles:
|
|
559
659
|
|
|
560
660
|
```typescript
|
|
561
|
-
|
|
562
|
-
world.forEachInHierarchy((entityId, parentId, depth) => {
|
|
563
|
-
// Parents are always visited before their children
|
|
564
|
-
console.log(`Entity ${entityId} at depth ${depth}, parent: ${parentId}`);
|
|
565
|
-
});
|
|
661
|
+
import { Bundle } from 'ecspresso';
|
|
566
662
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
(
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
663
|
+
const physicsBundle = new Bundle<GameComponents, {}, GameResources>('physics')
|
|
664
|
+
.addSystem('applyVelocity')
|
|
665
|
+
.addQuery('moving', { with: ['position', 'velocity'] })
|
|
666
|
+
.setProcess((queries, deltaTime) => {
|
|
667
|
+
for (const entity of queries.moving) {
|
|
668
|
+
entity.components.position.x += entity.components.velocity.x * deltaTime;
|
|
669
|
+
entity.components.position.y += entity.components.velocity.y * deltaTime;
|
|
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 });
|
|
574
683
|
|
|
575
|
-
//
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
}
|
|
684
|
+
// Register bundles with the world
|
|
685
|
+
const game = ECSpresso.create<GameComponents, {}, GameResources>()
|
|
686
|
+
.withBundle(physicsBundle)
|
|
687
|
+
.build();
|
|
580
688
|
```
|
|
581
689
|
|
|
582
|
-
|
|
690
|
+
### Built-in Bundles
|
|
583
691
|
|
|
584
|
-
|
|
692
|
+
| Bundle | Import | Default Phase | Description |
|
|
693
|
+
|--------|--------|---------------|-------------|
|
|
694
|
+
| **Input** | `ecspresso/bundles/utils/input` | `preUpdate` | Frame-accurate keyboard/pointer input with action mapping |
|
|
695
|
+
| **Timers** | `ecspresso/bundles/utils/timers` | `preUpdate` | ECS-native timers with event-based completion |
|
|
696
|
+
| **Movement** | `ecspresso/bundles/utils/movement` | `fixedUpdate` | Velocity-based movement integration |
|
|
697
|
+
| **Transform** | `ecspresso/bundles/utils/transform` | `postUpdate` | Hierarchical transform propagation (local/world transforms) |
|
|
698
|
+
| **Bounds** | `ecspresso/bundles/utils/bounds` | `postUpdate` | Screen bounds enforcement (destroy, clamp, wrap) |
|
|
699
|
+
| **Collision** | `ecspresso/bundles/utils/collision` | `postUpdate` | Layer-based AABB/circle collision detection with events |
|
|
700
|
+
| **2D Renderer** | `ecspresso/bundles/renderers/renderer2D` | `render` | Automated PixiJS scene graph wiring |
|
|
701
|
+
|
|
702
|
+
Each bundle accepts a `phase` option to override its default.
|
|
703
|
+
|
|
704
|
+
### Input Bundle
|
|
705
|
+
|
|
706
|
+
The input bundle provides frame-accurate keyboard, pointer (mouse + touch via PointerEvent), and named action mapping. It's a resource-only bundle — input is polled via the `inputState` resource. DOM events are accumulated between frames and snapshotted once per frame, so all systems see consistent state.
|
|
585
707
|
|
|
586
708
|
```typescript
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
709
|
+
import {
|
|
710
|
+
createInputBundle, defineActionMap,
|
|
711
|
+
type InputResourceTypes, type KeyCode
|
|
712
|
+
} from 'ecspresso/bundles/utils/input';
|
|
713
|
+
|
|
714
|
+
interface Resources extends InputResourceTypes {}
|
|
715
|
+
|
|
716
|
+
const world = ECSpresso.create<Components, Events, Resources>()
|
|
717
|
+
.withBundle(createInputBundle({
|
|
718
|
+
actions: defineActionMap({
|
|
719
|
+
jump: { keys: [' ', 'ArrowUp'] },
|
|
720
|
+
shoot: { keys: ['z'], buttons: [0] },
|
|
721
|
+
moveLeft: { keys: ['a', 'ArrowLeft'] },
|
|
722
|
+
moveRight: { keys: ['d', 'ArrowRight'] },
|
|
723
|
+
}),
|
|
724
|
+
}))
|
|
725
|
+
.build();
|
|
590
726
|
|
|
591
|
-
//
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
727
|
+
// In a system:
|
|
728
|
+
const input = ecs.getResource('inputState');
|
|
729
|
+
if (input.actions.justActivated('jump')) { /* ... */ }
|
|
730
|
+
if (input.keyboard.isDown('ArrowRight')) { /* ... */ }
|
|
731
|
+
if (input.pointer.justPressed(0)) { /* ... */ }
|
|
595
732
|
|
|
596
|
-
//
|
|
597
|
-
|
|
598
|
-
// Children still exist but have no parent
|
|
733
|
+
// Runtime remapping
|
|
734
|
+
input.setActionMap({ jump: { keys: ['w'] } });
|
|
599
735
|
```
|
|
600
736
|
|
|
601
|
-
|
|
737
|
+
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'`.
|
|
602
738
|
|
|
603
|
-
|
|
739
|
+
### Timer Bundle
|
|
740
|
+
|
|
741
|
+
The timer bundle provides ECS-native timers that follow the "data, not callbacks" philosophy. Timers are components processed each frame, with optional event-based completion notifications.
|
|
604
742
|
|
|
605
743
|
```typescript
|
|
744
|
+
import {
|
|
745
|
+
createTimerBundle, createTimer, createRepeatingTimer,
|
|
746
|
+
type TimerComponentTypes, type TimerEventData
|
|
747
|
+
} from 'ecspresso/bundles/utils/timers';
|
|
748
|
+
|
|
749
|
+
// Events used with onComplete must have TimerEventData payload
|
|
606
750
|
interface Events {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
oldParent: number | null;
|
|
610
|
-
newParent: number | null;
|
|
611
|
-
};
|
|
751
|
+
hideMessage: TimerEventData; // { entityId, duration, elapsed }
|
|
752
|
+
spawnWave: TimerEventData;
|
|
612
753
|
}
|
|
613
754
|
|
|
614
|
-
|
|
755
|
+
interface Components extends TimerComponentTypes<Events> {
|
|
756
|
+
position: { x: number; y: number };
|
|
757
|
+
}
|
|
615
758
|
|
|
616
|
-
world
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
console.log(`Entity ${data.entityId} detached from ${data.oldParent}`);
|
|
621
|
-
}
|
|
622
|
-
});
|
|
759
|
+
const world = ECSpresso
|
|
760
|
+
.create<Components, Events>()
|
|
761
|
+
.withBundle(createTimerBundle<Events>())
|
|
762
|
+
.build();
|
|
623
763
|
|
|
624
|
-
//
|
|
625
|
-
world.
|
|
764
|
+
// One-shot timer (poll justFinished or use onComplete event)
|
|
765
|
+
world.spawn({ ...createTimer<Events>(2.0), position: { x: 0, y: 0 } });
|
|
766
|
+
world.spawn({ ...createTimer<Events>(1.5, { onComplete: 'hideMessage' }) });
|
|
767
|
+
|
|
768
|
+
// Repeating timer
|
|
769
|
+
world.spawn({ ...createRepeatingTimer<Events>(5.0, { onComplete: 'spawnWave' }) });
|
|
626
770
|
```
|
|
627
771
|
|
|
628
|
-
|
|
772
|
+
Timer components expose `elapsed`, `duration`, `repeat`, `active`, `justFinished`, and optional `onComplete` for runtime control.
|
|
773
|
+
|
|
774
|
+
## Asset Management
|
|
629
775
|
|
|
630
776
|
Manage game assets with eager/lazy loading, groups, and progress tracking:
|
|
631
777
|
|
|
632
778
|
```typescript
|
|
633
|
-
// Define asset types
|
|
634
779
|
type Assets = {
|
|
635
780
|
playerTexture: { data: ImageBitmap };
|
|
636
|
-
enemyTexture: { data: ImageBitmap };
|
|
637
781
|
level1Music: { buffer: AudioBuffer };
|
|
638
782
|
level1Background: { data: ImageBitmap };
|
|
639
783
|
};
|
|
640
784
|
|
|
641
|
-
// Create world with assets using the builder pattern
|
|
642
785
|
const game = ECSpresso.create<Components, Events, Resources, Assets>()
|
|
643
786
|
.withAssets(assets => assets
|
|
644
787
|
// Eager assets - loaded automatically during initialize()
|
|
@@ -646,110 +789,55 @@ const game = ECSpresso.create<Components, Events, Resources, Assets>()
|
|
|
646
789
|
const img = await loadImage('player.png');
|
|
647
790
|
return { data: img };
|
|
648
791
|
})
|
|
649
|
-
.add('enemyTexture', async () => {
|
|
650
|
-
const img = await loadImage('enemy.png');
|
|
651
|
-
return { data: img };
|
|
652
|
-
})
|
|
653
792
|
// Lazy asset group - loaded on demand
|
|
654
793
|
.addGroup('level1', {
|
|
655
|
-
level1Music: async () => {
|
|
656
|
-
|
|
657
|
-
return { buffer };
|
|
658
|
-
},
|
|
659
|
-
level1Background: async () => {
|
|
660
|
-
const img = await loadImage('level1-bg.png');
|
|
661
|
-
return { data: img };
|
|
662
|
-
},
|
|
794
|
+
level1Music: async () => ({ buffer: await loadAudio('level1.mp3') }),
|
|
795
|
+
level1Background: async () => ({ data: await loadImage('level1-bg.png') }),
|
|
663
796
|
})
|
|
664
797
|
)
|
|
665
798
|
.build();
|
|
666
799
|
|
|
667
|
-
//
|
|
668
|
-
|
|
800
|
+
await game.initialize(); // Loads eager assets
|
|
801
|
+
const player = game.getAsset('playerTexture'); // Access loaded asset
|
|
802
|
+
game.isAssetLoaded('playerTexture'); // Check if loaded
|
|
669
803
|
|
|
670
|
-
//
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
// Check if asset is loaded
|
|
674
|
-
if (game.isAssetLoaded('enemyTexture')) {
|
|
675
|
-
const enemy = game.getAsset('enemyTexture');
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
// Load asset groups on demand (e.g., when entering a level)
|
|
679
|
-
await game.loadAssetGroup('level1');
|
|
680
|
-
|
|
681
|
-
// Track loading progress
|
|
682
|
-
const progress = game.getAssetGroupProgress('level1'); // 0-1
|
|
683
|
-
|
|
684
|
-
// Check if group is fully loaded
|
|
685
|
-
if (game.isAssetGroupLoaded('level1')) {
|
|
686
|
-
const music = game.getAsset('level1Music');
|
|
687
|
-
}
|
|
804
|
+
await game.loadAssetGroup('level1'); // Load group on demand
|
|
805
|
+
game.getAssetGroupProgress('level1'); // 0-1 progress
|
|
806
|
+
game.isAssetGroupLoaded('level1'); // Check if group is ready
|
|
688
807
|
```
|
|
689
808
|
|
|
690
|
-
#### Asset Events
|
|
691
|
-
|
|
692
|
-
React to asset loading with built-in events:
|
|
693
|
-
|
|
694
|
-
```typescript
|
|
695
|
-
game.addSystem('loadingUI')
|
|
696
|
-
.setEventHandlers({
|
|
697
|
-
assetLoaded: {
|
|
698
|
-
handler: (data) => console.log(`Loaded: ${data.key}`)
|
|
699
|
-
},
|
|
700
|
-
assetFailed: {
|
|
701
|
-
handler: (data) => console.error(`Failed: ${data.key}`, data.error)
|
|
702
|
-
},
|
|
703
|
-
assetGroupProgress: {
|
|
704
|
-
handler: (data) => {
|
|
705
|
-
console.log(`${data.group}: ${data.loaded}/${data.total}`);
|
|
706
|
-
}
|
|
707
|
-
},
|
|
708
|
-
assetGroupLoaded: {
|
|
709
|
-
handler: (data) => console.log(`Group ready: ${data.group}`)
|
|
710
|
-
}
|
|
711
|
-
})
|
|
712
|
-
.build();
|
|
713
|
-
```
|
|
714
|
-
|
|
715
|
-
#### Systems with Asset Requirements
|
|
716
|
-
|
|
717
809
|
Systems can declare required assets and will only run when those assets are loaded:
|
|
718
810
|
|
|
719
811
|
```typescript
|
|
720
812
|
game.addSystem('gameplay')
|
|
721
|
-
.requiresAssets(['playerTexture'
|
|
813
|
+
.requiresAssets(['playerTexture'])
|
|
722
814
|
.setProcess((queries, dt, ecs) => {
|
|
723
|
-
// This only runs when both assets are loaded
|
|
724
815
|
const player = ecs.getAsset('playerTexture');
|
|
725
816
|
})
|
|
726
817
|
.build();
|
|
727
818
|
```
|
|
728
819
|
|
|
729
|
-
|
|
820
|
+
Asset events (`assetLoaded`, `assetFailed`, `assetGroupProgress`, `assetGroupLoaded`) are available through the event system -- see [Events](#events).
|
|
821
|
+
|
|
822
|
+
## Screen Management
|
|
730
823
|
|
|
731
824
|
Manage game states/screens with transitions and overlay support:
|
|
732
825
|
|
|
733
826
|
```typescript
|
|
734
827
|
import type { ScreenDefinition } from 'ecspresso';
|
|
735
828
|
|
|
736
|
-
// Define screen types with config and state
|
|
737
829
|
type Screens = {
|
|
738
830
|
menu: ScreenDefinition<
|
|
739
831
|
Record<string, never>, // Config (passed when entering)
|
|
740
832
|
{ selectedOption: number } // State (mutable during screen)
|
|
741
833
|
>;
|
|
742
834
|
gameplay: ScreenDefinition<
|
|
743
|
-
{ difficulty: string; level: number },
|
|
744
|
-
{ score: number; isPaused: boolean }
|
|
745
|
-
>;
|
|
746
|
-
pause: ScreenDefinition<
|
|
747
|
-
Record<string, never>,
|
|
748
|
-
Record<string, never>
|
|
835
|
+
{ difficulty: string; level: number },
|
|
836
|
+
{ score: number; isPaused: boolean }
|
|
749
837
|
>;
|
|
838
|
+
pause: ScreenDefinition<Record<string, never>, Record<string, never>>;
|
|
750
839
|
};
|
|
751
840
|
|
|
752
|
-
// Create world with screens
|
|
753
841
|
const game = ECSpresso.create<Components, Events, Resources, {}, Screens>()
|
|
754
842
|
.withScreens(screens => screens
|
|
755
843
|
.add('menu', {
|
|
@@ -761,73 +849,43 @@ const game = ECSpresso.create<Components, Events, Resources, {}, Screens>()
|
|
|
761
849
|
initialState: () => ({ score: 0, isPaused: false }),
|
|
762
850
|
onEnter: (config) => console.log(`Starting level ${config.level}`),
|
|
763
851
|
onExit: () => console.log('Gameplay ended'),
|
|
764
|
-
// Require assets before screen can be entered
|
|
765
852
|
requiredAssetGroups: ['level1'],
|
|
766
853
|
})
|
|
767
854
|
.add('pause', {
|
|
768
855
|
initialState: () => ({}),
|
|
769
|
-
onEnter: () => console.log('Paused'),
|
|
770
|
-
onExit: () => console.log('Resumed'),
|
|
771
856
|
})
|
|
772
857
|
)
|
|
773
858
|
.build();
|
|
774
859
|
|
|
775
860
|
await game.initialize();
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
await game.
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
await game.pushScreen('pause', {});
|
|
785
|
-
|
|
786
|
-
// Pop overlay (returns to previous screen)
|
|
787
|
-
await game.popScreen();
|
|
788
|
-
|
|
789
|
-
// Access current screen info
|
|
790
|
-
const current = game.getCurrentScreen(); // 'gameplay'
|
|
791
|
-
const config = game.getScreenConfig(); // { difficulty: 'hard', level: 1 }
|
|
792
|
-
const state = game.getScreenState(); // { score: 0, isPaused: false }
|
|
793
|
-
|
|
794
|
-
// Update screen state
|
|
861
|
+
await game.setScreen('menu', {}); // Set initial screen
|
|
862
|
+
await game.setScreen('gameplay', { difficulty: 'hard', level: 1 }); // Transition
|
|
863
|
+
await game.pushScreen('pause', {}); // Push overlay
|
|
864
|
+
await game.popScreen(); // Pop overlay
|
|
865
|
+
|
|
866
|
+
const current = game.getCurrentScreen(); // 'gameplay'
|
|
867
|
+
const config = game.getScreenConfig(); // { difficulty: 'hard', level: 1 }
|
|
868
|
+
const state = game.getScreenState(); // { score: 0, isPaused: false }
|
|
795
869
|
game.updateScreenState({ score: 100 });
|
|
796
870
|
```
|
|
797
871
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
Systems can be restricted to run only in specific screens:
|
|
872
|
+
### Screen-Scoped Systems
|
|
801
873
|
|
|
802
874
|
```typescript
|
|
803
|
-
// Only runs when 'menu' is the current screen
|
|
804
875
|
game.addSystem('menuUI')
|
|
805
|
-
.inScreens(['menu'])
|
|
806
|
-
.setProcess((queries, dt, ecs) => {
|
|
807
|
-
const state = ecs.getScreenState();
|
|
808
|
-
renderMenu(state.selectedOption);
|
|
809
|
-
})
|
|
810
|
-
.build();
|
|
811
|
-
|
|
812
|
-
// Only runs in 'gameplay' screen
|
|
813
|
-
game.addSystem('scoring')
|
|
814
|
-
.inScreens(['gameplay'])
|
|
876
|
+
.inScreens(['menu']) // Only runs in 'menu'
|
|
815
877
|
.setProcess((queries, dt, ecs) => {
|
|
816
|
-
|
|
817
|
-
ecs.updateScreenState({ score: state.score + 1 });
|
|
878
|
+
renderMenu(ecs.getScreenState().selectedOption);
|
|
818
879
|
})
|
|
819
880
|
.build();
|
|
820
881
|
|
|
821
|
-
// Runs in all screens EXCEPT 'pause'
|
|
822
882
|
game.addSystem('animations')
|
|
823
|
-
.excludeScreens(['pause'])
|
|
824
|
-
.setProcess(() => {
|
|
825
|
-
// Animations continue except when paused
|
|
826
|
-
})
|
|
883
|
+
.excludeScreens(['pause']) // Runs in all screens except 'pause'
|
|
884
|
+
.setProcess(() => { /* ... */ })
|
|
827
885
|
.build();
|
|
828
886
|
```
|
|
829
887
|
|
|
830
|
-
|
|
888
|
+
### Screen Resource
|
|
831
889
|
|
|
832
890
|
Access screen state through the `$screen` resource:
|
|
833
891
|
|
|
@@ -835,20 +893,13 @@ Access screen state through the `$screen` resource:
|
|
|
835
893
|
game.addSystem('ui')
|
|
836
894
|
.setProcess((queries, dt, ecs) => {
|
|
837
895
|
const screen = ecs.getResource('$screen');
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
// Check screen status
|
|
846
|
-
if (screen.isCurrent('gameplay')) {
|
|
847
|
-
// ...
|
|
848
|
-
}
|
|
849
|
-
if (screen.isActive('menu')) {
|
|
850
|
-
// true if menu is current OR in the stack
|
|
851
|
-
}
|
|
896
|
+
screen.current; // Current screen name
|
|
897
|
+
screen.config; // Current screen config
|
|
898
|
+
screen.state; // Current screen state (mutable)
|
|
899
|
+
screen.isOverlay; // true if screen was pushed
|
|
900
|
+
screen.stackDepth; // Number of screens in stack
|
|
901
|
+
screen.isCurrent('gameplay'); // Check current screen
|
|
902
|
+
screen.isActive('menu'); // true if in current or stack
|
|
852
903
|
})
|
|
853
904
|
.build();
|
|
854
905
|
```
|
|
@@ -857,336 +908,58 @@ game.addSystem('ui')
|
|
|
857
908
|
|
|
858
909
|
ECSpresso provides comprehensive TypeScript support:
|
|
859
910
|
|
|
860
|
-
### Component Type Safety
|
|
861
911
|
```typescript
|
|
862
912
|
// ✅ Valid
|
|
863
913
|
world.entityManager.addComponent(entity.id, 'position', { x: 0, y: 0 });
|
|
864
914
|
|
|
865
|
-
// ❌ TypeScript error - invalid component
|
|
915
|
+
// ❌ TypeScript error - invalid component name
|
|
866
916
|
world.entityManager.addComponent(entity.id, 'invalid', { data: 'bad' });
|
|
867
917
|
|
|
868
918
|
// ❌ TypeScript error - wrong component shape
|
|
869
919
|
world.entityManager.addComponent(entity.id, 'position', { x: 0 }); // missing y
|
|
870
|
-
```
|
|
871
920
|
|
|
872
|
-
|
|
873
|
-
```typescript
|
|
921
|
+
// Query type safety - TypeScript knows which components exist
|
|
874
922
|
world.addSystem('example')
|
|
875
923
|
.addQuery('moving', { with: ['position', 'velocity'] })
|
|
876
924
|
.setProcess((queries) => {
|
|
877
925
|
for (const entity of queries.moving) {
|
|
878
|
-
// ✅
|
|
879
|
-
entity.components.
|
|
880
|
-
entity.components.velocity.y;
|
|
881
|
-
|
|
882
|
-
// ❌ TypeScript error - not guaranteed to exist
|
|
883
|
-
entity.components.health.value;
|
|
926
|
+
entity.components.position.x; // ✅ guaranteed
|
|
927
|
+
entity.components.health.value; // ❌ not in query
|
|
884
928
|
}
|
|
885
929
|
})
|
|
886
930
|
.build();
|
|
887
|
-
```
|
|
888
|
-
|
|
889
|
-
### Bundle Type Compatibility
|
|
890
|
-
```typescript
|
|
891
|
-
// ✅ Compatible bundles merge cleanly
|
|
892
|
-
const bundle1 = new Bundle<{position: {x: number, y: number}}>('bundle1');
|
|
893
|
-
const bundle2 = new Bundle<{velocity: {x: number, y: number}}>('bundle2');
|
|
894
931
|
|
|
932
|
+
// Bundle type compatibility - conflicting types error at compile time
|
|
933
|
+
const bundle1 = new Bundle<{position: {x: number, y: number}}>('b1');
|
|
934
|
+
const bundle2 = new Bundle<{velocity: {x: number, y: number}}>('b2');
|
|
895
935
|
const world = ECSpresso.create()
|
|
896
936
|
.withBundle(bundle1)
|
|
897
|
-
.withBundle(bundle2)
|
|
898
|
-
.build();
|
|
899
|
-
|
|
900
|
-
// ❌ Conflicting types error at compile time
|
|
901
|
-
const conflictingBundle = new Bundle<{position: string}>('conflict');
|
|
902
|
-
world.withBundle(conflictingBundle); // TypeScript prevents this
|
|
903
|
-
```
|
|
904
|
-
|
|
905
|
-
## Component Callbacks
|
|
906
|
-
|
|
907
|
-
React to component changes with callbacks. Both methods return an unsubscribe function:
|
|
908
|
-
|
|
909
|
-
```typescript
|
|
910
|
-
// Listen for component additions - returns unsubscribe function
|
|
911
|
-
const unsubAdd = world.onComponentAdded('health', (value, entity) => {
|
|
912
|
-
console.log(`Health added to entity ${entity.id}:`, value);
|
|
913
|
-
});
|
|
914
|
-
|
|
915
|
-
// Listen for component removals
|
|
916
|
-
const unsubRemove = world.onComponentRemoved('health', (oldValue, entity) => {
|
|
917
|
-
console.log(`Health removed from entity ${entity.id}:`, oldValue);
|
|
918
|
-
});
|
|
919
|
-
|
|
920
|
-
// Unsubscribe when done
|
|
921
|
-
unsubAdd();
|
|
922
|
-
unsubRemove();
|
|
923
|
-
|
|
924
|
-
// Also available on entityManager directly
|
|
925
|
-
world.entityManager.onComponentAdded('position', (value, entity) => {
|
|
926
|
-
// ...
|
|
927
|
-
});
|
|
928
|
-
```
|
|
929
|
-
|
|
930
|
-
## Reactive Queries
|
|
931
|
-
|
|
932
|
-
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:
|
|
933
|
-
|
|
934
|
-
```typescript
|
|
935
|
-
// Add a reactive query with enter/exit callbacks
|
|
936
|
-
world.addReactiveQuery('enemies', {
|
|
937
|
-
with: ['position', 'enemy'],
|
|
938
|
-
without: ['dead'],
|
|
939
|
-
onEnter: (entity) => {
|
|
940
|
-
// Called when entity starts matching the query
|
|
941
|
-
console.log(`Enemy ${entity.id} appeared at`, entity.components.position);
|
|
942
|
-
spawnHealthBar(entity.id);
|
|
943
|
-
},
|
|
944
|
-
onExit: (entityId) => {
|
|
945
|
-
// Called when entity stops matching (receives ID since entity may be removed)
|
|
946
|
-
console.log(`Enemy ${entityId} gone`);
|
|
947
|
-
removeHealthBar(entityId);
|
|
948
|
-
},
|
|
949
|
-
});
|
|
950
|
-
|
|
951
|
-
// Triggers: spawning matching entity, adding required component,
|
|
952
|
-
// removing excluded component
|
|
953
|
-
const enemy = world.spawn({ position: { x: 0, y: 0 }, enemy: true }); // onEnter fires
|
|
954
|
-
|
|
955
|
-
// Triggers: removing required component, adding excluded component,
|
|
956
|
-
// removing entity
|
|
957
|
-
world.entityManager.addComponent(enemy.id, 'dead', true); // onExit fires
|
|
958
|
-
|
|
959
|
-
// Existing matching entities trigger onEnter when query is added
|
|
960
|
-
world.spawn({ position: { x: 10, y: 10 }, enemy: true });
|
|
961
|
-
world.addReactiveQuery('positioned', {
|
|
962
|
-
with: ['position'],
|
|
963
|
-
onEnter: (entity) => { /* Called for all existing entities with position */ },
|
|
964
|
-
});
|
|
965
|
-
|
|
966
|
-
// Remove reactive query when no longer needed
|
|
967
|
-
const removed = world.removeReactiveQuery('enemies'); // returns true if existed
|
|
968
|
-
```
|
|
969
|
-
|
|
970
|
-
**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.
|
|
971
|
-
|
|
972
|
-
## Command Buffer
|
|
973
|
-
|
|
974
|
-
The command buffer allows you to queue structural changes (entity creation, removal, component changes) that execute at the end of the update cycle. This prevents issues when modifying entities during system iteration.
|
|
975
|
-
|
|
976
|
-
```typescript
|
|
977
|
-
// Queue commands during system execution
|
|
978
|
-
world.addSystem('combat')
|
|
979
|
-
.addQuery('enemies', { with: ['enemy', 'health'] })
|
|
980
|
-
.setProcess((queries, dt, ecs) => {
|
|
981
|
-
for (const entity of queries.enemies) {
|
|
982
|
-
if (entity.components.health.value <= 0) {
|
|
983
|
-
// Queue removal - doesn't execute immediately
|
|
984
|
-
ecs.commands.removeEntity(entity.id);
|
|
985
|
-
|
|
986
|
-
// Queue spawning an explosion
|
|
987
|
-
ecs.commands.spawn({
|
|
988
|
-
position: entity.components.position,
|
|
989
|
-
explosion: true,
|
|
990
|
-
});
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
// Commands execute automatically at end of update()
|
|
994
|
-
})
|
|
995
|
-
.build();
|
|
996
|
-
```
|
|
997
|
-
|
|
998
|
-
### Available Commands
|
|
999
|
-
|
|
1000
|
-
```typescript
|
|
1001
|
-
// Entity operations
|
|
1002
|
-
ecs.commands.spawn({ position: { x: 0, y: 0 } });
|
|
1003
|
-
ecs.commands.spawnChild(parentId, { position: { x: 10, y: 0 } });
|
|
1004
|
-
ecs.commands.removeEntity(entityId);
|
|
1005
|
-
ecs.commands.removeEntity(entityId, { cascade: false }); // Orphan children
|
|
1006
|
-
|
|
1007
|
-
// Component operations
|
|
1008
|
-
ecs.commands.addComponent(entityId, 'velocity', { x: 5, y: 0 });
|
|
1009
|
-
ecs.commands.addComponents(entityId, { velocity: { x: 5, y: 0 }, health: { value: 100 } });
|
|
1010
|
-
ecs.commands.removeComponent(entityId, 'velocity');
|
|
1011
|
-
|
|
1012
|
-
// Hierarchy operations
|
|
1013
|
-
ecs.commands.setParent(childId, parentId);
|
|
1014
|
-
ecs.commands.removeParent(childId);
|
|
1015
|
-
|
|
1016
|
-
// Utility
|
|
1017
|
-
ecs.commands.length; // Number of queued commands
|
|
1018
|
-
ecs.commands.clear(); // Discard all queued commands
|
|
1019
|
-
```
|
|
1020
|
-
|
|
1021
|
-
Commands execute in FIFO order. If a command fails (e.g., entity doesn't exist), it logs a warning and continues with remaining commands.
|
|
1022
|
-
|
|
1023
|
-
## Built-in Bundles
|
|
1024
|
-
|
|
1025
|
-
ECSpresso provides optional utility bundles for common game development needs:
|
|
1026
|
-
|
|
1027
|
-
| Bundle | Import | Description |
|
|
1028
|
-
|--------|--------|-------------|
|
|
1029
|
-
| **Transform** | `ecspresso/bundles/utils/transform` | Hierarchical transform propagation (local/world transforms) |
|
|
1030
|
-
| **Movement** | `ecspresso/bundles/utils/movement` | Velocity-based movement integration |
|
|
1031
|
-
| **Bounds** | `ecspresso/bundles/utils/bounds` | Screen bounds enforcement (destroy, clamp, wrap) |
|
|
1032
|
-
| **Collision** | `ecspresso/bundles/utils/collision` | Layer-based AABB/circle collision detection with events |
|
|
1033
|
-
| **Timers** | `ecspresso/bundles/utils/timers` | ECS-native timers with event-based completion |
|
|
1034
|
-
| **2D Renderer** | `ecspresso/bundles/renderers/renderer2D` | Automated PixiJS scene graph wiring |
|
|
1035
|
-
|
|
1036
|
-
## Timer Bundle
|
|
1037
|
-
|
|
1038
|
-
The timer bundle provides ECS-native timers that follow the "data, not callbacks" philosophy. Timers are components processed each frame, with optional event-based completion notifications.
|
|
1039
|
-
|
|
1040
|
-
```typescript
|
|
1041
|
-
import {
|
|
1042
|
-
createTimerBundle,
|
|
1043
|
-
createTimer,
|
|
1044
|
-
createRepeatingTimer,
|
|
1045
|
-
type TimerComponentTypes,
|
|
1046
|
-
type TimerEventData
|
|
1047
|
-
} from 'ecspresso/bundles/utils/timers';
|
|
1048
|
-
|
|
1049
|
-
// Define events that use TimerEventData payload
|
|
1050
|
-
interface Events {
|
|
1051
|
-
hideMessage: TimerEventData;
|
|
1052
|
-
spawnWave: TimerEventData;
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
// Extend components with timer support
|
|
1056
|
-
interface Components extends TimerComponentTypes<Events> {
|
|
1057
|
-
position: { x: number; y: number };
|
|
1058
|
-
messageDisplay: true;
|
|
1059
|
-
spawner: true;
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
// Create world with timer bundle
|
|
1063
|
-
const world = ECSpresso
|
|
1064
|
-
.create<Components, Events, Resources>()
|
|
1065
|
-
.withBundle(createTimerBundle<Events>())
|
|
1066
|
-
.build();
|
|
1067
|
-
|
|
1068
|
-
// One-shot timer without event (poll justFinished)
|
|
1069
|
-
world.spawn({
|
|
1070
|
-
...createTimer<Events>(2.0),
|
|
1071
|
-
messageDisplay: true,
|
|
1072
|
-
});
|
|
1073
|
-
|
|
1074
|
-
// One-shot timer with completion event
|
|
1075
|
-
world.spawn({
|
|
1076
|
-
...createTimer<Events>(1.5, { onComplete: 'hideMessage' }),
|
|
1077
|
-
messageDisplay: true,
|
|
1078
|
-
});
|
|
1079
|
-
|
|
1080
|
-
// Repeating timer with event
|
|
1081
|
-
world.spawn({
|
|
1082
|
-
...createRepeatingTimer<Events>(5.0, { onComplete: 'spawnWave' }),
|
|
1083
|
-
spawner: true,
|
|
1084
|
-
});
|
|
1085
|
-
|
|
1086
|
-
// Subscribe to timer events
|
|
1087
|
-
world.on('hideMessage', (data) => {
|
|
1088
|
-
console.log(`Timer on entity ${data.entityId} completed after ${data.elapsed}s`);
|
|
1089
|
-
});
|
|
1090
|
-
```
|
|
1091
|
-
|
|
1092
|
-
### Timer Event Data
|
|
1093
|
-
|
|
1094
|
-
Events used with timer `onComplete` must have `TimerEventData` as their payload type:
|
|
1095
|
-
|
|
1096
|
-
```typescript
|
|
1097
|
-
interface TimerEventData {
|
|
1098
|
-
entityId: number; // Entity the timer belongs to
|
|
1099
|
-
duration: number; // Timer's configured duration
|
|
1100
|
-
elapsed: number; // Actual elapsed time (may exceed duration slightly)
|
|
1101
|
-
}
|
|
1102
|
-
```
|
|
1103
|
-
|
|
1104
|
-
### Polling vs Events
|
|
1105
|
-
|
|
1106
|
-
You can use timers in two ways:
|
|
1107
|
-
|
|
1108
|
-
```typescript
|
|
1109
|
-
// 1. Polling with justFinished flag
|
|
1110
|
-
world.addSystem('timerPolling')
|
|
1111
|
-
.addQuery('timers', { with: ['timer', 'messageDisplay'] })
|
|
1112
|
-
.setProcess((queries) => {
|
|
1113
|
-
for (const entity of queries.timers) {
|
|
1114
|
-
if (entity.components.timer.justFinished) {
|
|
1115
|
-
// Timer just completed this frame
|
|
1116
|
-
hideMessage(entity.id);
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
})
|
|
1120
|
-
.build();
|
|
1121
|
-
|
|
1122
|
-
// 2. Event-based (decoupled)
|
|
1123
|
-
world.addSystem('timerEvents')
|
|
1124
|
-
.setEventHandlers({
|
|
1125
|
-
hideMessage: {
|
|
1126
|
-
handler: (data, ecs) => {
|
|
1127
|
-
// React to timer completion
|
|
1128
|
-
ecs.commands.removeEntity(data.entityId);
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
})
|
|
937
|
+
.withBundle(bundle2) // Types merge successfully
|
|
1132
938
|
.build();
|
|
1133
939
|
```
|
|
1134
940
|
|
|
1135
|
-
### Timer Properties
|
|
1136
|
-
|
|
1137
|
-
```typescript
|
|
1138
|
-
interface Timer {
|
|
1139
|
-
elapsed: number; // Time accumulated (seconds)
|
|
1140
|
-
duration: number; // Target duration (seconds)
|
|
1141
|
-
repeat: boolean; // Whether timer repeats
|
|
1142
|
-
active: boolean; // Whether timer is running
|
|
1143
|
-
justFinished: boolean; // True for one frame after completion
|
|
1144
|
-
onComplete?: string; // Event name to publish (optional)
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
// Control timer at runtime
|
|
1148
|
-
const entity = world.spawn({ ...createTimer<Events>(5.0), myTimer: true });
|
|
1149
|
-
const timer = entity.components.timer;
|
|
1150
|
-
|
|
1151
|
-
timer.active = false; // Pause
|
|
1152
|
-
timer.active = true; // Resume
|
|
1153
|
-
timer.elapsed = 0; // Reset
|
|
1154
|
-
timer.duration = 10; // Change duration
|
|
1155
|
-
```
|
|
1156
|
-
|
|
1157
941
|
## Error Handling
|
|
1158
942
|
|
|
1159
|
-
ECSpresso provides clear, contextual error messages
|
|
943
|
+
ECSpresso provides clear, contextual error messages:
|
|
1160
944
|
|
|
1161
945
|
```typescript
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
const missing = world.getResource('nonexistent');
|
|
1165
|
-
} catch (error) {
|
|
1166
|
-
console.error(error.message);
|
|
1167
|
-
// "Resource 'nonexistent' not found. Available resources: [config, score, settings]"
|
|
1168
|
-
}
|
|
946
|
+
world.getResource('nonexistent');
|
|
947
|
+
// → "Resource 'nonexistent' not found. Available resources: [config, score, settings]"
|
|
1169
948
|
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
world.entityManager.addComponent(999, 'position', { x: 0, y: 0 });
|
|
1173
|
-
} catch (error) {
|
|
1174
|
-
console.error(error.message);
|
|
1175
|
-
// "Cannot add component 'position': Entity with ID 999 does not exist"
|
|
1176
|
-
}
|
|
949
|
+
world.entityManager.addComponent(999, 'position', { x: 0, y: 0 });
|
|
950
|
+
// → "Cannot add component 'position': Entity with ID 999 does not exist"
|
|
1177
951
|
|
|
1178
|
-
// Component not found returns null
|
|
1179
|
-
|
|
1180
|
-
if (component === null) {
|
|
1181
|
-
console.log('Component not found');
|
|
1182
|
-
}
|
|
952
|
+
// Component not found returns null (no throw)
|
|
953
|
+
world.entityManager.getComponent(123, 'position'); // null
|
|
1183
954
|
```
|
|
1184
955
|
|
|
1185
956
|
## Performance Tips
|
|
1186
957
|
|
|
958
|
+
- Use `changed` query filters to skip unchanged entities in render sync, transform propagation, and similar systems
|
|
959
|
+
- Call `markChanged` after in-place mutations so downstream systems can detect the change
|
|
1187
960
|
- Extract business logic into testable helper functions using query type utilities
|
|
1188
961
|
- Bundle related systems for better organization and reusability
|
|
1189
|
-
- Use system
|
|
962
|
+
- Use system phases to separate concerns (physics in `fixedUpdate`, rendering in `render`) and priorities for ordering within a phase
|
|
1190
963
|
- Use resource factories for expensive initialization (textures, audio, etc.)
|
|
1191
964
|
- Consider component callbacks for immediate reactions to state changes
|
|
1192
965
|
- Minimize the number of components in queries when possible to leverage indexing
|