ecspresso 0.8.0 → 0.9.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 +470 -733
- package/dist/bundles/renderers/renderer2D.js +2 -2
- package/dist/bundles/renderers/renderer2D.js.map +6 -6
- package/dist/bundles/utils/bounds.d.ts +3 -0
- package/dist/bundles/utils/collision.d.ts +3 -0
- package/dist/bundles/utils/movement.d.ts +3 -0
- package/dist/bundles/utils/timers.d.ts +3 -0
- package/dist/bundles/utils/timers.js +2 -2
- package/dist/bundles/utils/timers.js.map +4 -4
- package/dist/bundles/utils/transform.d.ts +3 -0
- package/dist/command-buffer.d.ts +6 -0
- package/dist/ecspresso.d.ts +84 -12
- package/dist/entity-manager.d.ts +34 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +7 -7
- package/dist/system-builder.d.ts +12 -1
- package/dist/types.d.ts +16 -0
- package/package.json +1 -1
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,276 @@ 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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
world.removeEntity(parent.id);
|
|
593
|
-
world.entityManager.getEntity(child.id); // undefined
|
|
594
|
-
world.entityManager.getEntity(grandchild.id); // undefined
|
|
692
|
+
| Bundle | Import | Default Phase | Description |
|
|
693
|
+
|--------|--------|---------------|-------------|
|
|
694
|
+
| **Timers** | `ecspresso/bundles/utils/timers` | `preUpdate` | ECS-native timers with event-based completion |
|
|
695
|
+
| **Movement** | `ecspresso/bundles/utils/movement` | `fixedUpdate` | Velocity-based movement integration |
|
|
696
|
+
| **Transform** | `ecspresso/bundles/utils/transform` | `postUpdate` | Hierarchical transform propagation (local/world transforms) |
|
|
697
|
+
| **Bounds** | `ecspresso/bundles/utils/bounds` | `postUpdate` | Screen bounds enforcement (destroy, clamp, wrap) |
|
|
698
|
+
| **Collision** | `ecspresso/bundles/utils/collision` | `postUpdate` | Layer-based AABB/circle collision detection with events |
|
|
699
|
+
| **2D Renderer** | `ecspresso/bundles/renderers/renderer2D` | `render` | Automated PixiJS scene graph wiring |
|
|
595
700
|
|
|
596
|
-
|
|
597
|
-
world.removeEntity(parent.id, { cascade: false });
|
|
598
|
-
// Children still exist but have no parent
|
|
599
|
-
```
|
|
701
|
+
Each bundle accepts a `phase` option to override its default.
|
|
600
702
|
|
|
601
|
-
|
|
703
|
+
### Timer Bundle
|
|
602
704
|
|
|
603
|
-
|
|
705
|
+
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
706
|
|
|
605
707
|
```typescript
|
|
708
|
+
import {
|
|
709
|
+
createTimerBundle, createTimer, createRepeatingTimer,
|
|
710
|
+
type TimerComponentTypes, type TimerEventData
|
|
711
|
+
} from 'ecspresso/bundles/utils/timers';
|
|
712
|
+
|
|
713
|
+
// Events used with onComplete must have TimerEventData payload
|
|
606
714
|
interface Events {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
oldParent: number | null;
|
|
610
|
-
newParent: number | null;
|
|
611
|
-
};
|
|
715
|
+
hideMessage: TimerEventData; // { entityId, duration, elapsed }
|
|
716
|
+
spawnWave: TimerEventData;
|
|
612
717
|
}
|
|
613
718
|
|
|
614
|
-
|
|
719
|
+
interface Components extends TimerComponentTypes<Events> {
|
|
720
|
+
position: { x: number; y: number };
|
|
721
|
+
}
|
|
615
722
|
|
|
616
|
-
world
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
});
|
|
723
|
+
const world = ECSpresso
|
|
724
|
+
.create<Components, Events>()
|
|
725
|
+
.withBundle(createTimerBundle<Events>())
|
|
726
|
+
.build();
|
|
727
|
+
|
|
728
|
+
// One-shot timer (poll justFinished or use onComplete event)
|
|
729
|
+
world.spawn({ ...createTimer<Events>(2.0), position: { x: 0, y: 0 } });
|
|
730
|
+
world.spawn({ ...createTimer<Events>(1.5, { onComplete: 'hideMessage' }) });
|
|
623
731
|
|
|
624
|
-
//
|
|
625
|
-
world.
|
|
732
|
+
// Repeating timer
|
|
733
|
+
world.spawn({ ...createRepeatingTimer<Events>(5.0, { onComplete: 'spawnWave' }) });
|
|
626
734
|
```
|
|
627
735
|
|
|
628
|
-
|
|
736
|
+
Timer components expose `elapsed`, `duration`, `repeat`, `active`, `justFinished`, and optional `onComplete` for runtime control.
|
|
737
|
+
|
|
738
|
+
## Asset Management
|
|
629
739
|
|
|
630
740
|
Manage game assets with eager/lazy loading, groups, and progress tracking:
|
|
631
741
|
|
|
632
742
|
```typescript
|
|
633
|
-
// Define asset types
|
|
634
743
|
type Assets = {
|
|
635
744
|
playerTexture: { data: ImageBitmap };
|
|
636
|
-
enemyTexture: { data: ImageBitmap };
|
|
637
745
|
level1Music: { buffer: AudioBuffer };
|
|
638
746
|
level1Background: { data: ImageBitmap };
|
|
639
747
|
};
|
|
640
748
|
|
|
641
|
-
// Create world with assets using the builder pattern
|
|
642
749
|
const game = ECSpresso.create<Components, Events, Resources, Assets>()
|
|
643
750
|
.withAssets(assets => assets
|
|
644
751
|
// Eager assets - loaded automatically during initialize()
|
|
@@ -646,110 +753,55 @@ const game = ECSpresso.create<Components, Events, Resources, Assets>()
|
|
|
646
753
|
const img = await loadImage('player.png');
|
|
647
754
|
return { data: img };
|
|
648
755
|
})
|
|
649
|
-
.add('enemyTexture', async () => {
|
|
650
|
-
const img = await loadImage('enemy.png');
|
|
651
|
-
return { data: img };
|
|
652
|
-
})
|
|
653
756
|
// Lazy asset group - loaded on demand
|
|
654
757
|
.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
|
-
},
|
|
758
|
+
level1Music: async () => ({ buffer: await loadAudio('level1.mp3') }),
|
|
759
|
+
level1Background: async () => ({ data: await loadImage('level1-bg.png') }),
|
|
663
760
|
})
|
|
664
761
|
)
|
|
665
762
|
.build();
|
|
666
763
|
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
// Access loaded assets
|
|
671
|
-
const player = game.getAsset('playerTexture');
|
|
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
|
-
}
|
|
688
|
-
```
|
|
689
|
-
|
|
690
|
-
#### Asset Events
|
|
691
|
-
|
|
692
|
-
React to asset loading with built-in events:
|
|
764
|
+
await game.initialize(); // Loads eager assets
|
|
765
|
+
const player = game.getAsset('playerTexture'); // Access loaded asset
|
|
766
|
+
game.isAssetLoaded('playerTexture'); // Check if loaded
|
|
693
767
|
|
|
694
|
-
|
|
695
|
-
game.
|
|
696
|
-
|
|
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();
|
|
768
|
+
await game.loadAssetGroup('level1'); // Load group on demand
|
|
769
|
+
game.getAssetGroupProgress('level1'); // 0-1 progress
|
|
770
|
+
game.isAssetGroupLoaded('level1'); // Check if group is ready
|
|
713
771
|
```
|
|
714
772
|
|
|
715
|
-
#### Systems with Asset Requirements
|
|
716
|
-
|
|
717
773
|
Systems can declare required assets and will only run when those assets are loaded:
|
|
718
774
|
|
|
719
775
|
```typescript
|
|
720
776
|
game.addSystem('gameplay')
|
|
721
|
-
.requiresAssets(['playerTexture'
|
|
777
|
+
.requiresAssets(['playerTexture'])
|
|
722
778
|
.setProcess((queries, dt, ecs) => {
|
|
723
|
-
// This only runs when both assets are loaded
|
|
724
779
|
const player = ecs.getAsset('playerTexture');
|
|
725
780
|
})
|
|
726
781
|
.build();
|
|
727
782
|
```
|
|
728
783
|
|
|
729
|
-
|
|
784
|
+
Asset events (`assetLoaded`, `assetFailed`, `assetGroupProgress`, `assetGroupLoaded`) are available through the event system -- see [Events](#events).
|
|
785
|
+
|
|
786
|
+
## Screen Management
|
|
730
787
|
|
|
731
788
|
Manage game states/screens with transitions and overlay support:
|
|
732
789
|
|
|
733
790
|
```typescript
|
|
734
791
|
import type { ScreenDefinition } from 'ecspresso';
|
|
735
792
|
|
|
736
|
-
// Define screen types with config and state
|
|
737
793
|
type Screens = {
|
|
738
794
|
menu: ScreenDefinition<
|
|
739
795
|
Record<string, never>, // Config (passed when entering)
|
|
740
796
|
{ selectedOption: number } // State (mutable during screen)
|
|
741
797
|
>;
|
|
742
798
|
gameplay: ScreenDefinition<
|
|
743
|
-
{ difficulty: string; level: number },
|
|
744
|
-
{ score: number; isPaused: boolean }
|
|
745
|
-
>;
|
|
746
|
-
pause: ScreenDefinition<
|
|
747
|
-
Record<string, never>,
|
|
748
|
-
Record<string, never>
|
|
799
|
+
{ difficulty: string; level: number },
|
|
800
|
+
{ score: number; isPaused: boolean }
|
|
749
801
|
>;
|
|
802
|
+
pause: ScreenDefinition<Record<string, never>, Record<string, never>>;
|
|
750
803
|
};
|
|
751
804
|
|
|
752
|
-
// Create world with screens
|
|
753
805
|
const game = ECSpresso.create<Components, Events, Resources, {}, Screens>()
|
|
754
806
|
.withScreens(screens => screens
|
|
755
807
|
.add('menu', {
|
|
@@ -761,73 +813,43 @@ const game = ECSpresso.create<Components, Events, Resources, {}, Screens>()
|
|
|
761
813
|
initialState: () => ({ score: 0, isPaused: false }),
|
|
762
814
|
onEnter: (config) => console.log(`Starting level ${config.level}`),
|
|
763
815
|
onExit: () => console.log('Gameplay ended'),
|
|
764
|
-
// Require assets before screen can be entered
|
|
765
816
|
requiredAssetGroups: ['level1'],
|
|
766
817
|
})
|
|
767
818
|
.add('pause', {
|
|
768
819
|
initialState: () => ({}),
|
|
769
|
-
onEnter: () => console.log('Paused'),
|
|
770
|
-
onExit: () => console.log('Resumed'),
|
|
771
820
|
})
|
|
772
821
|
)
|
|
773
822
|
.build();
|
|
774
823
|
|
|
775
824
|
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
|
|
825
|
+
await game.setScreen('menu', {}); // Set initial screen
|
|
826
|
+
await game.setScreen('gameplay', { difficulty: 'hard', level: 1 }); // Transition
|
|
827
|
+
await game.pushScreen('pause', {}); // Push overlay
|
|
828
|
+
await game.popScreen(); // Pop overlay
|
|
829
|
+
|
|
830
|
+
const current = game.getCurrentScreen(); // 'gameplay'
|
|
831
|
+
const config = game.getScreenConfig(); // { difficulty: 'hard', level: 1 }
|
|
832
|
+
const state = game.getScreenState(); // { score: 0, isPaused: false }
|
|
795
833
|
game.updateScreenState({ score: 100 });
|
|
796
834
|
```
|
|
797
835
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
Systems can be restricted to run only in specific screens:
|
|
836
|
+
### Screen-Scoped Systems
|
|
801
837
|
|
|
802
838
|
```typescript
|
|
803
|
-
// Only runs when 'menu' is the current screen
|
|
804
839
|
game.addSystem('menuUI')
|
|
805
|
-
.inScreens(['menu'])
|
|
840
|
+
.inScreens(['menu']) // Only runs in 'menu'
|
|
806
841
|
.setProcess((queries, dt, ecs) => {
|
|
807
|
-
|
|
808
|
-
renderMenu(state.selectedOption);
|
|
842
|
+
renderMenu(ecs.getScreenState().selectedOption);
|
|
809
843
|
})
|
|
810
844
|
.build();
|
|
811
845
|
|
|
812
|
-
// Only runs in 'gameplay' screen
|
|
813
|
-
game.addSystem('scoring')
|
|
814
|
-
.inScreens(['gameplay'])
|
|
815
|
-
.setProcess((queries, dt, ecs) => {
|
|
816
|
-
const state = ecs.getScreenState();
|
|
817
|
-
ecs.updateScreenState({ score: state.score + 1 });
|
|
818
|
-
})
|
|
819
|
-
.build();
|
|
820
|
-
|
|
821
|
-
// Runs in all screens EXCEPT 'pause'
|
|
822
846
|
game.addSystem('animations')
|
|
823
|
-
.excludeScreens(['pause'])
|
|
824
|
-
.setProcess(() => {
|
|
825
|
-
// Animations continue except when paused
|
|
826
|
-
})
|
|
847
|
+
.excludeScreens(['pause']) // Runs in all screens except 'pause'
|
|
848
|
+
.setProcess(() => { /* ... */ })
|
|
827
849
|
.build();
|
|
828
850
|
```
|
|
829
851
|
|
|
830
|
-
|
|
852
|
+
### Screen Resource
|
|
831
853
|
|
|
832
854
|
Access screen state through the `$screen` resource:
|
|
833
855
|
|
|
@@ -835,20 +857,13 @@ Access screen state through the `$screen` resource:
|
|
|
835
857
|
game.addSystem('ui')
|
|
836
858
|
.setProcess((queries, dt, ecs) => {
|
|
837
859
|
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
|
-
}
|
|
860
|
+
screen.current; // Current screen name
|
|
861
|
+
screen.config; // Current screen config
|
|
862
|
+
screen.state; // Current screen state (mutable)
|
|
863
|
+
screen.isOverlay; // true if screen was pushed
|
|
864
|
+
screen.stackDepth; // Number of screens in stack
|
|
865
|
+
screen.isCurrent('gameplay'); // Check current screen
|
|
866
|
+
screen.isActive('menu'); // true if in current or stack
|
|
852
867
|
})
|
|
853
868
|
.build();
|
|
854
869
|
```
|
|
@@ -857,336 +872,58 @@ game.addSystem('ui')
|
|
|
857
872
|
|
|
858
873
|
ECSpresso provides comprehensive TypeScript support:
|
|
859
874
|
|
|
860
|
-
### Component Type Safety
|
|
861
875
|
```typescript
|
|
862
876
|
// ✅ Valid
|
|
863
877
|
world.entityManager.addComponent(entity.id, 'position', { x: 0, y: 0 });
|
|
864
878
|
|
|
865
|
-
// ❌ TypeScript error - invalid component
|
|
879
|
+
// ❌ TypeScript error - invalid component name
|
|
866
880
|
world.entityManager.addComponent(entity.id, 'invalid', { data: 'bad' });
|
|
867
881
|
|
|
868
882
|
// ❌ TypeScript error - wrong component shape
|
|
869
883
|
world.entityManager.addComponent(entity.id, 'position', { x: 0 }); // missing y
|
|
870
|
-
```
|
|
871
884
|
|
|
872
|
-
|
|
873
|
-
```typescript
|
|
885
|
+
// Query type safety - TypeScript knows which components exist
|
|
874
886
|
world.addSystem('example')
|
|
875
887
|
.addQuery('moving', { with: ['position', 'velocity'] })
|
|
876
888
|
.setProcess((queries) => {
|
|
877
889
|
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;
|
|
890
|
+
entity.components.position.x; // ✅ guaranteed
|
|
891
|
+
entity.components.health.value; // ❌ not in query
|
|
884
892
|
}
|
|
885
893
|
})
|
|
886
894
|
.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
895
|
|
|
896
|
+
// Bundle type compatibility - conflicting types error at compile time
|
|
897
|
+
const bundle1 = new Bundle<{position: {x: number, y: number}}>('b1');
|
|
898
|
+
const bundle2 = new Bundle<{velocity: {x: number, y: number}}>('b2');
|
|
895
899
|
const world = ECSpresso.create()
|
|
896
900
|
.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
|
-
})
|
|
901
|
+
.withBundle(bundle2) // Types merge successfully
|
|
995
902
|
.build();
|
|
996
903
|
```
|
|
997
904
|
|
|
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
|
-
})
|
|
1132
|
-
.build();
|
|
1133
|
-
```
|
|
1134
|
-
|
|
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
905
|
## Error Handling
|
|
1158
906
|
|
|
1159
|
-
ECSpresso provides clear, contextual error messages
|
|
907
|
+
ECSpresso provides clear, contextual error messages:
|
|
1160
908
|
|
|
1161
909
|
```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
|
-
}
|
|
910
|
+
world.getResource('nonexistent');
|
|
911
|
+
// → "Resource 'nonexistent' not found. Available resources: [config, score, settings]"
|
|
1169
912
|
|
|
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
|
-
}
|
|
913
|
+
world.entityManager.addComponent(999, 'position', { x: 0, y: 0 });
|
|
914
|
+
// → "Cannot add component 'position': Entity with ID 999 does not exist"
|
|
1177
915
|
|
|
1178
|
-
// Component not found returns null
|
|
1179
|
-
|
|
1180
|
-
if (component === null) {
|
|
1181
|
-
console.log('Component not found');
|
|
1182
|
-
}
|
|
916
|
+
// Component not found returns null (no throw)
|
|
917
|
+
world.entityManager.getComponent(123, 'position'); // null
|
|
1183
918
|
```
|
|
1184
919
|
|
|
1185
920
|
## Performance Tips
|
|
1186
921
|
|
|
922
|
+
- Use `changed` query filters to skip unchanged entities in render sync, transform propagation, and similar systems
|
|
923
|
+
- Call `markChanged` after in-place mutations so downstream systems can detect the change
|
|
1187
924
|
- Extract business logic into testable helper functions using query type utilities
|
|
1188
925
|
- Bundle related systems for better organization and reusability
|
|
1189
|
-
- Use system
|
|
926
|
+
- Use system phases to separate concerns (physics in `fixedUpdate`, rendering in `render`) and priorities for ordering within a phase
|
|
1190
927
|
- Use resource factories for expensive initialization (textures, audio, etc.)
|
|
1191
928
|
- Consider component callbacks for immediate reactions to state changes
|
|
1192
929
|
- Minimize the number of components in queries when possible to leverage indexing
|