@phalanx-engine/ecs 0.1.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 +666 -0
- package/dist/Component.d.ts +7 -0
- package/dist/Component.d.ts.map +1 -0
- package/dist/Component.js +7 -0
- package/dist/Entity.d.ts +25 -0
- package/dist/Entity.d.ts.map +1 -0
- package/dist/Entity.js +58 -0
- package/dist/EntityManager.d.ts +36 -0
- package/dist/EntityManager.d.ts.map +1 -0
- package/dist/EntityManager.js +218 -0
- package/dist/EventBus.d.ts +15 -0
- package/dist/EventBus.d.ts.map +1 -0
- package/dist/EventBus.js +69 -0
- package/dist/GameSystem.d.ts +23 -0
- package/dist/GameSystem.d.ts.map +1 -0
- package/dist/GameSystem.js +43 -0
- package/dist/GameWorld.d.ts +69 -0
- package/dist/GameWorld.d.ts.map +1 -0
- package/dist/GameWorld.js +237 -0
- package/dist/IAbilitySystem.d.ts +18 -0
- package/dist/IAbilitySystem.d.ts.map +1 -0
- package/dist/IAbilitySystem.js +1 -0
- package/dist/IPhysicsWorld.d.ts +21 -0
- package/dist/IPhysicsWorld.d.ts.map +1 -0
- package/dist/IPhysicsWorld.js +1 -0
- package/dist/ISystemLifecycleHooks.d.ts +18 -0
- package/dist/ISystemLifecycleHooks.d.ts.map +1 -0
- package/dist/ISystemLifecycleHooks.js +12 -0
- package/dist/ITickFrameProvider.d.ts +24 -0
- package/dist/ITickFrameProvider.d.ts.map +1 -0
- package/dist/ITickFrameProvider.js +1 -0
- package/dist/SoAComponent.d.ts +22 -0
- package/dist/SoAComponent.d.ts.map +1 -0
- package/dist/SoAComponent.js +59 -0
- package/dist/SoAComponentStore.d.ts +41 -0
- package/dist/SoAComponentStore.d.ts.map +1 -0
- package/dist/SoAComponentStore.js +253 -0
- package/dist/SoASchema.d.ts +22 -0
- package/dist/SoASchema.d.ts.map +1 -0
- package/dist/SoASchema.js +33 -0
- package/dist/SystemContext.d.ts +18 -0
- package/dist/SystemContext.d.ts.map +1 -0
- package/dist/SystemContext.js +18 -0
- package/dist/SystemRegistry.d.ts +20 -0
- package/dist/SystemRegistry.d.ts.map +1 -0
- package/dist/SystemRegistry.js +73 -0
- package/dist/TickFrameManager.d.ts +35 -0
- package/dist/TickFrameManager.d.ts.map +1 -0
- package/dist/TickFrameManager.js +130 -0
- package/dist/debug/DebugDataProvider.d.ts +26 -0
- package/dist/debug/DebugDataProvider.d.ts.map +1 -0
- package/dist/debug/DebugDataProvider.js +128 -0
- package/dist/debug/DebugPanel.d.ts +57 -0
- package/dist/debug/DebugPanel.d.ts.map +1 -0
- package/dist/debug/DebugPanel.js +482 -0
- package/dist/debug/index.d.ts +4 -0
- package/dist/debug/index.d.ts.map +1 -0
- package/dist/debug/index.js +2 -0
- package/dist/debug/types.d.ts +47 -0
- package/dist/debug/types.d.ts.map +1 -0
- package/dist/debug/types.js +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/pool/EntityPool.d.ts +21 -0
- package/dist/pool/EntityPool.d.ts.map +1 -0
- package/dist/pool/EntityPool.js +86 -0
- package/dist/pool/IPoolable.d.ts +4 -0
- package/dist/pool/IPoolable.d.ts.map +1 -0
- package/dist/pool/IPoolable.js +1 -0
- package/dist/pool/IPoolableComponent.d.ts +7 -0
- package/dist/pool/IPoolableComponent.d.ts.map +1 -0
- package/dist/pool/IPoolableComponent.js +4 -0
- package/dist/pool/IPoolableEntity.d.ts +6 -0
- package/dist/pool/IPoolableEntity.d.ts.map +1 -0
- package/dist/pool/IPoolableEntity.js +1 -0
- package/dist/pool/ObjectPool.d.ts +20 -0
- package/dist/pool/ObjectPool.d.ts.map +1 -0
- package/dist/pool/ObjectPool.js +76 -0
- package/dist/pool/PoolManager.d.ts +20 -0
- package/dist/pool/PoolManager.d.ts.map +1 -0
- package/dist/pool/PoolManager.js +92 -0
- package/dist/pool/index.d.ts +10 -0
- package/dist/pool/index.d.ts.map +1 -0
- package/dist/pool/index.js +5 -0
- package/dist/pool/types.d.ts +31 -0
- package/dist/pool/types.d.ts.map +1 -0
- package/dist/pool/types.js +8 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
# Phalanx ECS
|
|
2
|
+
|
|
3
|
+
A lightweight, renderer-agnostic Entity-Component-System (ECS) library with optional multiplayer support via Phalanx Engine.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **GameWorld Facade**: One-liner setup — construct, register systems, start
|
|
8
|
+
- **Pure ECS Architecture**: EntityManager, GameSystem, EventBus
|
|
9
|
+
- **Renderer Agnostic**: No rendering dependencies — bring your own renderer (Babylon.js, Three.js, etc.)
|
|
10
|
+
- **Flexible Integration**: Use standalone or with Phalanx Client for multiplayer
|
|
11
|
+
- **TypeScript First**: Full type safety and excellent IDE support
|
|
12
|
+
- **Deterministic Tick/Frame**: Separate tick-based simulation from frame-based rendering
|
|
13
|
+
- **SoA Storage**: Optional Structure-of-Arrays component storage for cache-friendly hot-path iteration
|
|
14
|
+
- **Object Pooling**: Built-in entity and object pools to minimize GC pressure in hot loops
|
|
15
|
+
|
|
16
|
+
## Core Components
|
|
17
|
+
|
|
18
|
+
### GameWorld (Recommended Entry Point)
|
|
19
|
+
- **GameWorld**: High-level facade — creates all core dependencies, wires tick/frame loops, provides convenience accessors
|
|
20
|
+
|
|
21
|
+
### Entity Management
|
|
22
|
+
- **Entity**: Base class for all game objects (id + components only, no rendering)
|
|
23
|
+
- **EntityManager**: Central registry with efficient component-based queries
|
|
24
|
+
- **IComponent**: Interface for all components
|
|
25
|
+
|
|
26
|
+
### System Architecture
|
|
27
|
+
- **GameSystem**: Base class for all game systems (convenience accessors for `eventBus`, `entityManager`, `abilities`, `physics`, `pools`)
|
|
28
|
+
- **SystemRegistry**: Low-level system lifecycle and execution order (used internally by GameWorld)
|
|
29
|
+
- **SystemContext**: Dependency injection container (`eventBus`, `entityManager`, optional `abilities` / `physics`, `pools`)
|
|
30
|
+
- **ISystemLifecycleHooks**: Optional `IBeforeTick`, `IAfterTick`, `IBeforeFrame`, `IAfterFrame` interfaces — GameWorld invokes them automatically
|
|
31
|
+
|
|
32
|
+
### Event System
|
|
33
|
+
- **EventBus**: Decoupled communication between systems
|
|
34
|
+
|
|
35
|
+
### Tick/Frame Management
|
|
36
|
+
- **TickFrameManager**: Built-in no-op client for single-player games
|
|
37
|
+
- Compatible with PhalanxClient for multiplayer
|
|
38
|
+
|
|
39
|
+
### SoA (Structure-of-Arrays) Storage
|
|
40
|
+
- **SoAComponent**: Base class for components backed by contiguous typed arrays
|
|
41
|
+
- **SoAComponentStore**: Dense, cache-friendly storage with O(1) entity lookup
|
|
42
|
+
- **defineSoASchema**: Type-safe schema definition for SoA field layout
|
|
43
|
+
|
|
44
|
+
### Object Pooling
|
|
45
|
+
- **ObjectPool**: Generic LIFO pool for any `IPoolable` object
|
|
46
|
+
- **EntityPool**: Low-level entity storage with stable IDs and growth stats
|
|
47
|
+
- **PoolManager**: Orchestrates spawn/despawn lifecycle and EntityManager registration
|
|
48
|
+
- **IPoolableEntity**: Per-entity `onSpawn(args)` / `onDespawn()` hooks for typed value assignment
|
|
49
|
+
- **IPoolableComponent**: Engine-driven `onSpawn()` / `onDespawn()` hooks for backing storage (SoA rows, visibility)
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm install @phalanx-engine/ecs
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
### Single-player Mode
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
import { GameWorld } from '@phalanx-engine/ecs';
|
|
63
|
+
|
|
64
|
+
// Create GameWorld (no rendering dependencies)
|
|
65
|
+
const world = new GameWorld({
|
|
66
|
+
tickRate: 60, // optional, default 60
|
|
67
|
+
maxFrameTime: 0.25, // optional, default 0.25
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Create and register your systems
|
|
71
|
+
const movementSystem = new MovementSystem();
|
|
72
|
+
const renderSystem = new RenderSystem();
|
|
73
|
+
|
|
74
|
+
world.registerSystems(
|
|
75
|
+
[movementSystem], // Tick systems (deterministic)
|
|
76
|
+
[renderSystem] // Frame systems (visual)
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Start the game loop
|
|
80
|
+
// Automatically runs: processAllTicks(tick), updateAll(dt)
|
|
81
|
+
// Note: scene.render() is NOT called automatically — call it in afterFrame hook if needed
|
|
82
|
+
world.start();
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Multiplayer Mode (with Phalanx Client)
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
import { PhalanxClient } from '@phalanx-engine/client';
|
|
89
|
+
import { GameWorld } from '@phalanx-engine/ecs';
|
|
90
|
+
|
|
91
|
+
// Initialize Phalanx Client
|
|
92
|
+
const client = new PhalanxClient({
|
|
93
|
+
serverUrl: 'wss://your-server.com',
|
|
94
|
+
// ... other config
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Create GameWorld with external tick/frame provider
|
|
98
|
+
const world = new GameWorld({
|
|
99
|
+
tickFrameProvider: client,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
world.registerSystems(tickSystems, frameSystems);
|
|
103
|
+
|
|
104
|
+
// Start with lifecycle hooks (tick systems and frame systems run automatically)
|
|
105
|
+
world.start({
|
|
106
|
+
beforeTick(tick, commandsBatch) {
|
|
107
|
+
// Execute network commands before tick systems run
|
|
108
|
+
lockstepManager.processTick(tick, commandsBatch);
|
|
109
|
+
},
|
|
110
|
+
afterTick(tick) {
|
|
111
|
+
// Cleanup after tick systems have run
|
|
112
|
+
cleanupDestroyedEntities();
|
|
113
|
+
},
|
|
114
|
+
beforeFrame(alpha, dt) {
|
|
115
|
+
// Update camera before frame systems
|
|
116
|
+
cameraController.update(dt);
|
|
117
|
+
},
|
|
118
|
+
afterFrame(alpha, dt) {
|
|
119
|
+
// Render the scene (must be called manually)
|
|
120
|
+
// Interpolation is handled by phalanx-physics InterpolationSystem when registered
|
|
121
|
+
scene.render();
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Connect to match
|
|
126
|
+
await client.connect();
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Low-level API (SystemRegistry + ITickFrameProvider)
|
|
130
|
+
|
|
131
|
+
For advanced use-cases you can still use `SystemRegistry` and `ITickFrameProvider` directly:
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
import { SystemRegistry, TickFrameManager } from '@phalanx-engine/ecs';
|
|
135
|
+
|
|
136
|
+
const registry = new SystemRegistry(componentTypes);
|
|
137
|
+
registry.registerSystems(tickSystems, frameSystems);
|
|
138
|
+
|
|
139
|
+
const tickManager = new TickFrameManager({ tickRate: 60 });
|
|
140
|
+
tickManager.onTick((tick) => registry.processAllTicks(tick));
|
|
141
|
+
tickManager.onFrame((alpha, dt) => { registry.updateAll(dt); });
|
|
142
|
+
tickManager.start();
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## API Reference
|
|
146
|
+
|
|
147
|
+
### GameWorld
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
class GameWorld {
|
|
151
|
+
constructor(config: GameWorldConfig)
|
|
152
|
+
|
|
153
|
+
// Convenience accessors
|
|
154
|
+
get eventBus(): EventBus
|
|
155
|
+
get entityManager(): EntityManager
|
|
156
|
+
get context(): SystemContext
|
|
157
|
+
get pools(): PoolManager | null
|
|
158
|
+
get paused(): boolean
|
|
159
|
+
getSystem<T extends GameSystem>(systemClass: new (...args: any[]) => T): T | undefined
|
|
160
|
+
|
|
161
|
+
// System registration
|
|
162
|
+
registerSystems(tickSystems: GameSystem[], frameSystems: GameSystem[]): void
|
|
163
|
+
addFrameSystem(system: GameSystem): void
|
|
164
|
+
|
|
165
|
+
// Tick / Frame delegation
|
|
166
|
+
processAllTicks(tick: number): void
|
|
167
|
+
updateAll(dt: number): void
|
|
168
|
+
|
|
169
|
+
// Pause / Resume (delegates to tick/frame provider if available)
|
|
170
|
+
pause(): void
|
|
171
|
+
resume(): void
|
|
172
|
+
|
|
173
|
+
// Lifecycle
|
|
174
|
+
start(hooks?: GameWorldHooks): void
|
|
175
|
+
stop(): void
|
|
176
|
+
dispose(): void
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface GameWorldConfig {
|
|
180
|
+
componentTypes?: symbol[]
|
|
181
|
+
tickRate?: number // default 60
|
|
182
|
+
maxFrameTime?: number // default 0.25
|
|
183
|
+
tickFrameProvider?: ITickFrameProvider // e.g. PhalanxClient
|
|
184
|
+
pooling?: PoolingConfig // Object pooling configuration
|
|
185
|
+
debug?: boolean // Enable debug features (default: false)
|
|
186
|
+
debugConfig?: DebugDataProviderConfig
|
|
187
|
+
debugPanelConfig?: DebugPanelConfig
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface GameWorldHooks {
|
|
191
|
+
beforeTick?(tick: number, commands: CommandsBatch): void
|
|
192
|
+
afterTick?(tick: number): void
|
|
193
|
+
beforeFrame?(alpha: number, dt: number): void
|
|
194
|
+
afterFrame?(alpha: number, dt: number): void
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### EntityManager
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
class EntityManager {
|
|
202
|
+
addEntity(entity: Entity): void
|
|
203
|
+
removeEntity(entity: Entity): void
|
|
204
|
+
getEntity(id: number): Entity | undefined
|
|
205
|
+
getAllEntities(): Entity[]
|
|
206
|
+
queryEntities(...componentTypes: symbol[]): Entity[]
|
|
207
|
+
queryEntitiesAny(...componentTypes: symbol[]): Entity[]
|
|
208
|
+
cleanupDestroyed(): Entity[]
|
|
209
|
+
count: number
|
|
210
|
+
|
|
211
|
+
// SoA store management
|
|
212
|
+
getSoAStore<S>(schema: SoASchema<S>): SoAComponentStore<S> | undefined
|
|
213
|
+
getOrCreateSoAStore<S>(schema: SoASchema<S>, capacity?: number): SoAComponentStore<S>
|
|
214
|
+
hasSoAStore(schema: SoASchema): boolean
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### SystemContext
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
class SystemContext {
|
|
222
|
+
readonly eventBus: EventBus
|
|
223
|
+
readonly entityManager: EntityManager
|
|
224
|
+
abilities: IAbilitySystem | undefined // set by createAbilitySystem() before registerSystems()
|
|
225
|
+
physics: IPhysicsWorld | undefined // set by game bootstrap before registerSystems()
|
|
226
|
+
pools: PoolManager | null // wired automatically when pooling is configured
|
|
227
|
+
|
|
228
|
+
getSystem<T extends GameSystem>(systemClass: new (...args: any[]) => T): T | undefined
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Set optional services on `world.context` before calling `registerSystems()`:
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
import { PhysicsWorld } from '@phalanx-engine/physics';
|
|
236
|
+
|
|
237
|
+
const physicsWorld = new PhysicsWorld({ tickRate: 20 });
|
|
238
|
+
world.context.physics = physicsWorld;
|
|
239
|
+
|
|
240
|
+
// Systems access it via the protected getter:
|
|
241
|
+
class RenderSystem extends GameSystem {
|
|
242
|
+
update(_dt: number): void {
|
|
243
|
+
const sample = this.physics?.getInterpolatedTransform(entityId);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### IPhysicsWorld (optional contract)
|
|
249
|
+
|
|
250
|
+
Implemented by `PhysicsWorld` from phalanx-physics. Keeps phalanx-ecs dependency-free via `unknown` for fixed-point types.
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
interface IPhysicsWorld {
|
|
254
|
+
getInterpolatedTransform(entityId: number): InterpolatedTransformSample | undefined
|
|
255
|
+
getEntityPosition(entityId: number): { x: unknown; z: unknown } | undefined
|
|
256
|
+
applyImpulse(entityId: number, vx: unknown, vz: unknown): void
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
interface InterpolatedTransformSample {
|
|
260
|
+
position: { x: number; y: number; z: number }
|
|
261
|
+
rotation: { x: number; y: number; z: number }
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### System Lifecycle Hooks
|
|
266
|
+
|
|
267
|
+
Systems can implement optional hook interfaces. GameWorld calls them automatically at the correct pipeline phase (before user-supplied `GameWorldHooks` for "before" variants, after tick/frame systems for "after" variants):
|
|
268
|
+
|
|
269
|
+
```
|
|
270
|
+
Tick: IBeforeTick systems → beforeTick hook → tick systems → IAfterTick systems → afterTick hook
|
|
271
|
+
Frame: IBeforeFrame systems → beforeFrame hook → frame systems → IAfterFrame systems → afterFrame hook
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
import { GameSystem, type IBeforeTick, type IAfterTick, type IBeforeFrame } from '@phalanx-engine/ecs';
|
|
276
|
+
|
|
277
|
+
class MySystem extends GameSystem implements IBeforeTick, IAfterTick {
|
|
278
|
+
beforeTick(tick: number, commands: CommandsBatch): void { /* snapshot state */ }
|
|
279
|
+
afterTick(tick: number): void { /* capture state */ }
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Type guards are also exported: `isBeforeTick`, `isAfterTick`, `isBeforeFrame`, `isAfterFrame`.
|
|
284
|
+
|
|
285
|
+
### SystemRegistry (Low-level)
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
class SystemRegistry {
|
|
289
|
+
constructor(componentTypes?: symbol[])
|
|
290
|
+
registerSystems(tickSystems: GameSystem[], frameSystems: GameSystem[]): void
|
|
291
|
+
processAllTicks(tick: number): void
|
|
292
|
+
updateAll(deltaTime: number): void
|
|
293
|
+
getContext(): SystemContext
|
|
294
|
+
dispose(): void
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### EventBus
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
class EventBus {
|
|
302
|
+
on<T>(eventType: string, callback: (data: T) => void): UnsubscribeFunction
|
|
303
|
+
once<T>(eventType: string, callback: (data: T) => void): UnsubscribeFunction
|
|
304
|
+
off<T>(eventType: string, callback: (data: T) => void): void
|
|
305
|
+
emit<T>(eventType: string, data: T): void
|
|
306
|
+
clear(eventType: string): void
|
|
307
|
+
clearAll(): void
|
|
308
|
+
listenerCount(eventType: string): number
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### TickFrameManager
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
class TickFrameManager implements ITickFrameProvider {
|
|
316
|
+
constructor(config?: { tickRate?: number; maxFrameTime?: number })
|
|
317
|
+
onTick(callback: (tick: number, commands: CommandsBatch) => void): Unsubscribe
|
|
318
|
+
onFrame(callback: (alpha: number, deltaTime: number) => void): Unsubscribe
|
|
319
|
+
onPause(handler: PauseHandler): Unsubscribe
|
|
320
|
+
onResume(handler: PauseHandler): Unsubscribe
|
|
321
|
+
start(): void
|
|
322
|
+
stop(): void
|
|
323
|
+
requestPause(): void
|
|
324
|
+
requestResume(): void
|
|
325
|
+
dispose(): void
|
|
326
|
+
getCurrentTick(): number
|
|
327
|
+
getTickRate(): number
|
|
328
|
+
isActive(): boolean
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### ITickFrameProvider
|
|
333
|
+
|
|
334
|
+
The shared interface that both `TickFrameManager` and `PhalanxClient` satisfy.
|
|
335
|
+
Game code should depend on this interface to allow easy switching between
|
|
336
|
+
single-player and multiplayer modes.
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
interface ITickFrameProvider {
|
|
340
|
+
onTick(handler: TickHandler): Unsubscribe;
|
|
341
|
+
onFrame(handler: FrameHandler): Unsubscribe;
|
|
342
|
+
|
|
343
|
+
// Optional pause/resume support
|
|
344
|
+
requestPause?(): void;
|
|
345
|
+
requestResume?(): void;
|
|
346
|
+
onPause?(handler: PauseHandler): Unsubscribe;
|
|
347
|
+
onResume?(handler: PauseHandler): Unsubscribe;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
type TickHandler = (tick: number, commands: CommandsBatch) => void;
|
|
351
|
+
type FrameHandler = (alpha: number, dt: number) => void;
|
|
352
|
+
type PauseHandler = () => void;
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
## Component Types: IComponent vs SoAComponent
|
|
356
|
+
|
|
357
|
+
Phalanx ECS offers two component types. Choose based on your performance and data layout needs.
|
|
358
|
+
|
|
359
|
+
### IComponent (Standard Components)
|
|
360
|
+
|
|
361
|
+
Simple class-based components that store data in regular object properties.
|
|
362
|
+
|
|
363
|
+
**Use when:**
|
|
364
|
+
- The component is accessed infrequently (e.g., flags, config, UI state)
|
|
365
|
+
- There are few instances (e.g., a single `ResourceComponent` per player)
|
|
366
|
+
- The data is complex or polymorphic (nested objects, arrays, callbacks)
|
|
367
|
+
- You want maximum simplicity
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
import type { IComponent } from '@phalanx-engine/ecs';
|
|
371
|
+
|
|
372
|
+
class ArmorComponent implements IComponent {
|
|
373
|
+
public readonly type = ComponentType.Armor;
|
|
374
|
+
constructor(public armor: number = 10) {}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Usage: attach to entity as usual
|
|
378
|
+
entity.addComponent(new ArmorComponent(15));
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### SoAComponent (Structure-of-Arrays Components)
|
|
382
|
+
|
|
383
|
+
Components backed by contiguous typed arrays (`Float64Array`, `BigInt64Array`, etc.) for cache-friendly memory layout.
|
|
384
|
+
|
|
385
|
+
**Use when:**
|
|
386
|
+
- The component is iterated every tick in a hot loop (physics, transforms, velocities)
|
|
387
|
+
- There are many instances (hundreds/thousands of entities)
|
|
388
|
+
- The data is flat numeric fields (positions, velocities, radii)
|
|
389
|
+
- You need deterministic fixed-point storage via `BigInt64Array` (`'i64'` fields)
|
|
390
|
+
|
|
391
|
+
**Avoid when:**
|
|
392
|
+
- The data is complex (nested objects, strings, variable-length arrays)
|
|
393
|
+
- There are very few instances — the typed-array overhead isn't worth it
|
|
394
|
+
- The component is rarely queried
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
import { SoAComponent, defineSoASchema } from '@phalanx-engine/ecs';
|
|
398
|
+
|
|
399
|
+
// 1. Define a schema — maps field names to typed-array element types
|
|
400
|
+
const PhysicsSoASchema = defineSoASchema({
|
|
401
|
+
velocityX: 'i64', // BigInt64Array — deterministic fixed-point
|
|
402
|
+
velocityY: 'i64',
|
|
403
|
+
velocityZ: 'i64',
|
|
404
|
+
radius: 'i64',
|
|
405
|
+
isStatic: 'u8', // Uint8Array — boolean flag
|
|
406
|
+
}, 'PhysicsBody');
|
|
407
|
+
|
|
408
|
+
// 2. Extend SoAComponent
|
|
409
|
+
class PhysicsBodyComponent extends SoAComponent<typeof PhysicsSoASchema.definition> {
|
|
410
|
+
public readonly type = ComponentType.PhysicsBody;
|
|
411
|
+
static readonly soaSchema = PhysicsSoASchema;
|
|
412
|
+
|
|
413
|
+
constructor(entityId: number, radius: bigint) {
|
|
414
|
+
super(PhysicsSoASchema, entityId, {
|
|
415
|
+
velocityX: 0n,
|
|
416
|
+
velocityY: 0n,
|
|
417
|
+
velocityZ: 0n,
|
|
418
|
+
radius: radius,
|
|
419
|
+
isStatic: 0,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Getters/setters provide a clean API over raw array access
|
|
424
|
+
get radiusRaw(): bigint {
|
|
425
|
+
return this.store.arrays.radius[this.getIndex()];
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 3. Hot-path systems should bypass the component facade and access arrays directly
|
|
430
|
+
// Cache store references in init(), iterate entityIds() in tick methods
|
|
431
|
+
const store = entityManager.getSoAStore(PhysicsSoASchema)!;
|
|
432
|
+
|
|
433
|
+
// Single-store loop — ideal case, zero cross-store overhead
|
|
434
|
+
for (const entityId of store.entityIds()) {
|
|
435
|
+
const idx = store.indexOf(entityId);
|
|
436
|
+
store.arrays.velocityX[idx] += accelerationX;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Cross-store loop — needed when correlating two SoA stores
|
|
440
|
+
const txStore = entityManager.getSoAStore(TransformSoASchema)!;
|
|
441
|
+
const velocityX = store.arrays.velocityX;
|
|
442
|
+
const fpPositionX = txStore.arrays.fpPositionX;
|
|
443
|
+
for (const entityId of store.entityIds()) {
|
|
444
|
+
const physIdx = store.indexOf(entityId);
|
|
445
|
+
const txIdx = txStore.indexOf(entityId); // one Map.get() per entity
|
|
446
|
+
if (txIdx === -1) continue;
|
|
447
|
+
fpPositionX[txIdx] += velocityX[physIdx];
|
|
448
|
+
}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
**Important:** The facade (getters/setters on `SoAComponent`) is convenient for infrequent
|
|
452
|
+
access (spawning, event handlers) but adds overhead in hot loops — each field access calls
|
|
453
|
+
`getIndex()` (a `Map.get()` + stale check). Direct store access removes this overhead.
|
|
454
|
+
|
|
455
|
+
### SoA Field Types
|
|
456
|
+
|
|
457
|
+
| Type | TypedArray | JS Value | Use Case |
|
|
458
|
+
| ------ | ---------------- | --------- | --------------------------------------- |
|
|
459
|
+
| `f64` | `Float64Array` | `number` | Floating-point values, visual positions |
|
|
460
|
+
| `f32` | `Float32Array` | `number` | Lower-precision floats |
|
|
461
|
+
| `i32` | `Int32Array` | `number` | Signed integers |
|
|
462
|
+
| `u32` | `Uint32Array` | `number` | Unsigned integers |
|
|
463
|
+
| `u8` | `Uint8Array` | `number` | Flags, booleans (0/1) |
|
|
464
|
+
| `i64` | `BigInt64Array` | `bigint` | Fixed-point raw values (deterministic) |
|
|
465
|
+
|
|
466
|
+
### SoA Store Lifecycle
|
|
467
|
+
|
|
468
|
+
Stores are **lazily created** when the first `SoAComponent` of a given schema is instantiated. `GameWorld` sets the `EntityManager` context automatically — no manual store registration needed.
|
|
469
|
+
|
|
470
|
+
```
|
|
471
|
+
GameWorld created → SoAComponent.useEntityManager(em)
|
|
472
|
+
First PhysicsBodyComponent constructed → store created in EntityManager
|
|
473
|
+
Subsequent PhysicsBodyComponents → share the same store
|
|
474
|
+
GameWorld disposed → SoAComponent.resetContext()
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
## Object Pooling
|
|
478
|
+
|
|
479
|
+
Phalanx ECS includes a built-in pooling system to avoid garbage collection spikes in hot loops. This is critical for deterministic lockstep games where GC pauses can cause missed ticks.
|
|
480
|
+
|
|
481
|
+
### ObjectPool
|
|
482
|
+
|
|
483
|
+
Generic pool for any object implementing `IPoolable`:
|
|
484
|
+
|
|
485
|
+
```typescript
|
|
486
|
+
import { ObjectPool } from '@phalanx-engine/ecs';
|
|
487
|
+
import type { IPoolable } from '@phalanx-engine/ecs';
|
|
488
|
+
|
|
489
|
+
class Particle implements IPoolable {
|
|
490
|
+
x = 0; y = 0; life = 0;
|
|
491
|
+
reset(): void { this.x = 0; this.y = 0; this.life = 0; }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const pool = new ObjectPool(() => new Particle(), { initialSize: 100 });
|
|
495
|
+
pool.prewarm(100);
|
|
496
|
+
|
|
497
|
+
const p = pool.acquire(); // reuses an existing object or creates new
|
|
498
|
+
p.x = 10; p.life = 60;
|
|
499
|
+
pool.release(p); // returns to pool, calls reset()
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
### Entity pooling with GameWorld
|
|
503
|
+
|
|
504
|
+
Attach all components once in the entity constructor. Use `IPoolableEntity` for per-spawn values and let the engine handle SoA row lifecycle automatically:
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
import { GameWorld, Entity, type IPoolableEntity } from '@phalanx-engine/ecs';
|
|
508
|
+
|
|
509
|
+
export interface ProjectileSpawnArgs {
|
|
510
|
+
fpPosition: FPVector3;
|
|
511
|
+
fpDirection2: FPVector2;
|
|
512
|
+
teamId: number;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export class ProjectileEntity extends Entity implements IPoolableEntity<ProjectileSpawnArgs> {
|
|
516
|
+
private readonly transform: TransformComponent;
|
|
517
|
+
private readonly projectile: ProjectileComponent;
|
|
518
|
+
|
|
519
|
+
constructor() {
|
|
520
|
+
super();
|
|
521
|
+
this.addComponent(MeshComponent.createProjectile(radius));
|
|
522
|
+
this.projectile = this.addComponent(new ProjectileComponent());
|
|
523
|
+
// SoA rows are auto-managed — attach wrappers once, never call reattach/detach
|
|
524
|
+
this.transform = this.addComponent(new TransformComponent(this.id));
|
|
525
|
+
this.addComponent(new PhysicsBodyComponent(this.id, { radius }));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
onSpawn(args: ProjectileSpawnArgs): void {
|
|
529
|
+
this.transform.fpPosition = args.fpPosition;
|
|
530
|
+
this.projectile.fpDirection2 = args.fpDirection2;
|
|
531
|
+
this.teamId = args.teamId;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
onDespawn(): void {
|
|
535
|
+
this.active = false;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const world = new GameWorld({
|
|
540
|
+
componentTypes: Object.values(ComponentType),
|
|
541
|
+
pooling: {
|
|
542
|
+
autoPrewarm: true,
|
|
543
|
+
entityTypes: {
|
|
544
|
+
projectile: {
|
|
545
|
+
factory: () => new ProjectileEntity(),
|
|
546
|
+
pool: { initialSize: 50, maxSize: 200 },
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// Spawn: component onSpawn() → entity.onSpawn(args) → EntityManager.addEntity()
|
|
553
|
+
const projectile = world.pools!.spawn<ProjectileEntity>('projectile', {
|
|
554
|
+
fpPosition: spawnPosition,
|
|
555
|
+
fpDirection2: direction2,
|
|
556
|
+
teamId: caster.teamId,
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// Despawn: EntityManager.removeEntity() → entity.onDespawn() → component onDespawn() → pool
|
|
560
|
+
world.pools!.despawn(projectile);
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### PoolManager
|
|
564
|
+
|
|
565
|
+
`PoolManager` is constructed with an `EntityManager` and wired automatically when `pooling` is configured on `GameWorld`. Game code uses `spawn()` / `despawn()` — not low-level `acquire()` / `release()`:
|
|
566
|
+
|
|
567
|
+
```typescript
|
|
568
|
+
// Diagnostics
|
|
569
|
+
const stats = world.pools!.getStats(); // Map<string, PoolStats>
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
### IPoolableComponent
|
|
573
|
+
|
|
574
|
+
Components that manage backing storage (SoA rows, mesh visibility) implement `IPoolableComponent`. The engine calls these hooks automatically — game code never does:
|
|
575
|
+
|
|
576
|
+
```typescript
|
|
577
|
+
import type { IPoolableComponent } from '@phalanx-engine/ecs';
|
|
578
|
+
|
|
579
|
+
// SoAComponent implements IPoolableComponent generically — subclasses need no changes.
|
|
580
|
+
// Custom render components can toggle visibility in onSpawn/onDespawn:
|
|
581
|
+
|
|
582
|
+
class MeshComponent implements IPoolableComponent {
|
|
583
|
+
onSpawn(): void { this.mesh.isVisible = true; }
|
|
584
|
+
onDespawn(): void { this.mesh.isVisible = false; }
|
|
585
|
+
}
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
`EntityPool` remains available as a low-level storage primitive; prefer `PoolManager.spawn()` / `despawn()` for gameplay entities.
|
|
589
|
+
|
|
590
|
+
## Creating Custom Systems
|
|
591
|
+
|
|
592
|
+
```typescript
|
|
593
|
+
import { GameSystem, SystemContext } from '@phalanx-engine/ecs';
|
|
594
|
+
|
|
595
|
+
class MySystem extends GameSystem {
|
|
596
|
+
init(context: SystemContext): void {
|
|
597
|
+
super.init(context);
|
|
598
|
+
|
|
599
|
+
// Optional: resolve other systems or optional services
|
|
600
|
+
const movement = context.getSystem(MovementSystem);
|
|
601
|
+
const physics = this.physics; // IPhysicsWorld | undefined
|
|
602
|
+
const abilities = this.abilities; // IAbilitySystem | undefined
|
|
603
|
+
|
|
604
|
+
// Subscribe to events
|
|
605
|
+
this.subscribe('MY_EVENT', (data) => {
|
|
606
|
+
console.log('Event received:', data);
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
processTick(tick: number): void {
|
|
611
|
+
// Deterministic simulation logic
|
|
612
|
+
const entities = this.entityManager.queryEntities(ComponentType.Movement);
|
|
613
|
+
for (const entity of entities) {
|
|
614
|
+
// Update entity
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
update(deltaTime: number): void {
|
|
619
|
+
// Visual updates, animations, interpolation
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
dispose(): void {
|
|
623
|
+
super.dispose();
|
|
624
|
+
// Clean up resources
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
## EventBus vs Direct System Calls
|
|
630
|
+
|
|
631
|
+
In a deterministic lockstep game, not every action needs to go through the network. Use this guideline to decide:
|
|
632
|
+
|
|
633
|
+
| Scenario | Mechanism | Why |
|
|
634
|
+
| --- | --- | --- |
|
|
635
|
+
| **Player commands** (move, attack, place unit) | Network → EventBus | Only player intent crosses the wire; all clients execute the same commands |
|
|
636
|
+
| **Simulation-internal decisions** (combat chase, resume movement after target dies, AI pathing) | Direct system call via `context.getSystem()` | These are deterministic outcomes computed identically on every client during tick processing — sending them through the network would be redundant and add latency |
|
|
637
|
+
| **Cross-system notifications** (damage dealt, entity spawned, animation trigger) | EventBus (local) | Keeps systems decoupled without network overhead |
|
|
638
|
+
|
|
639
|
+
### Example: Direct System Call for Combat Movement
|
|
640
|
+
|
|
641
|
+
When CombatSystem decides a unit should chase a target or resume its original waypoint after killing an enemy, it calls `MovementSystem.moveEntityTo()` directly instead of emitting a network event:
|
|
642
|
+
|
|
643
|
+
```typescript
|
|
644
|
+
export class CombatSystem extends GameSystem {
|
|
645
|
+
private movementSystem!: MovementSystem;
|
|
646
|
+
|
|
647
|
+
init(context: SystemContext): void {
|
|
648
|
+
super.init(context);
|
|
649
|
+
// Resolve at init — guaranteed to be available during tick processing
|
|
650
|
+
const ms = context.getSystem(MovementSystem);
|
|
651
|
+
if (!ms) throw new Error('CombatSystem requires MovementSystem');
|
|
652
|
+
this.movementSystem = ms;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private requestMove(entityId: number, target: Vector3): void {
|
|
656
|
+
// Direct call — deterministic, no network round-trip
|
|
657
|
+
this.movementSystem.moveEntityTo(entityId, target);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
**Rule of thumb:** if every client will compute the same result from the same game state, call the system directly. If the action originates from a specific player's input, send it through the network.
|
|
663
|
+
|
|
664
|
+
## License
|
|
665
|
+
|
|
666
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Component.d.ts","sourceRoot":"","sources":["../src/Component.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAeD,wBAAgB,2BAA2B,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC1E,KAAK,EAAE,CAAC,GACP;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM;CAAE,CAM5B"}
|