@codehz/ecs 0.6.11 → 0.7.1

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.en.md ADDED
@@ -0,0 +1,446 @@
1
+ # @codehz/ecs
2
+
3
+ > **中文版本:** [README.md](./README.md)
4
+
5
+ A high-performance Entity Component System (ECS) library built with TypeScript and the Bun runtime.
6
+
7
+ ## Features
8
+
9
+ - 🚀 High performance: Archetype-based component storage and efficient query system
10
+ - 🔧 Type-safe: Full TypeScript support
11
+ - 🏗️ Modular: Clean architecture with custom component support
12
+ - 📦 Lightweight: Zero dependencies, easy to integrate
13
+ - ⚡ Memory efficient: Contiguous memory layout, optimized iteration performance
14
+ - 🎣 Lifecycle hooks: Multi-component and wildcard relation event listening
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ bun install
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ### Basic Example
25
+
26
+ ```typescript
27
+ import { World, component } from "@codehz/ecs";
28
+
29
+ // Define component types
30
+ type Position = { x: number; y: number };
31
+ type Velocity = { x: number; y: number };
32
+
33
+ // Define component IDs (auto-assigned)
34
+ const PositionId = component<Position>();
35
+ const VelocityId = component<Velocity>();
36
+
37
+ // Create world
38
+ const world = new World();
39
+
40
+ // Create entity and set components (all changes buffered until sync())
41
+ const entity = world.new();
42
+ world.set(entity, PositionId, { x: 0, y: 0 });
43
+ world.set(entity, VelocityId, { x: 1, y: 0.5 });
44
+ world.sync();
45
+
46
+ // Create reusable query
47
+ const query = world.createQuery([PositionId, VelocityId]);
48
+
49
+ // Update loop
50
+ const deltaTime = 1.0 / 60.0;
51
+ query.forEach([PositionId, VelocityId], (entity, position, velocity) => {
52
+ position.x += velocity.x * deltaTime;
53
+ position.y += velocity.y * deltaTime;
54
+ });
55
+ ```
56
+
57
+ ### Defining Components (Auto-assigned IDs)
58
+
59
+ `component()` automatically assigns a unique ID from a global allocator. You can also specify a name or options:
60
+
61
+ ```typescript
62
+ import { component } from "@codehz/ecs";
63
+
64
+ // Auto-assign ID with no arguments
65
+ const Position = component<Position>();
66
+
67
+ // Specify a name (readable in serialization)
68
+ const Velocity = component<Velocity>("Velocity");
69
+
70
+ // With options (for relation components)
71
+ const ChildOf = component({ exclusive: true, name: "ChildOf" });
72
+ ```
73
+
74
+ **`ComponentOptions` options:**
75
+
76
+ | Option | Type | Description |
77
+ | --------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
78
+ | `name` | `string` | Component name, used for serialization/debugging |
79
+ | `exclusive` | `boolean` | Relation components only: an entity can have at most one relation of the same base component |
80
+ | `cascadeDelete` | `boolean` | Entity relations only: when the target entity is deleted, the **entire referencing entity** is deleted. Differs from default behavior (default only cleans up the relation component, the entity survives). Supports transitive cascading. |
81
+ | `dontFragment` | `boolean` | Relation components only: relations with different target entities are stored in the same Archetype, preventing excessive fragmentation |
82
+ | `merge` | `(prev, next) => T` | Merge strategy when `set()` is called multiple times on the same component within a single sync batch |
83
+
84
+ ### Lifecycle Hooks
85
+
86
+ `world.hook()` registers multi-component lifecycle hooks using a component array:
87
+
88
+ ```typescript
89
+ // Returns an unlisten function
90
+ const unhook = world.hook([PositionId, VelocityId], {
91
+ on_init: (entityId, position, velocity) => {
92
+ // Called for every entity that already matches when the hook is registered
93
+ },
94
+ on_set: (entityId, position, velocity) => {
95
+ // Called when an entity "enters" the matching set (after adding/updating components)
96
+ },
97
+ on_remove: (entityId, position, velocity) => {
98
+ // Called when an entity "exits" the matching set (after removing components or deleting entity)
99
+ },
100
+ });
101
+ // Unlisten the hook
102
+ unhook();
103
+ ```
104
+
105
+ A shorthand callback form is also supported:
106
+
107
+ ```typescript
108
+ const unhook = world.hook([PositionId, VelocityId], (type, entityId, position, velocity) => {
109
+ if (type === "init") console.log("init");
110
+ if (type === "set") console.log("set");
111
+ if (type === "remove") console.log("remove");
112
+ });
113
+ ```
114
+
115
+ Optional components and filters:
116
+
117
+ ```typescript
118
+ // Optional component: the hook fires even if Velocity is absent
119
+ world.hook([PositionId, { optional: VelocityId }], {
120
+ on_set: (entityId, position, velocity) => {
121
+ if (velocity !== undefined) {
122
+ console.log("has velocity and position");
123
+ } else {
124
+ console.log("has position only");
125
+ }
126
+ },
127
+ });
128
+
129
+ // Filter: exclude entities with specified negative components
130
+ const DisabledId = component<void>();
131
+ world.hook(
132
+ [PositionId, VelocityId],
133
+ {
134
+ on_set: (entityId, position, velocity) => console.log("entered matching set"),
135
+ on_remove: (entityId, position, velocity) => console.log("exited matching set"),
136
+ },
137
+ { negativeComponentTypes: [DisabledId] },
138
+ );
139
+ ```
140
+
141
+ ### Relation Components
142
+
143
+ ```typescript
144
+ import { World, component, relation } from "@codehz/ecs";
145
+
146
+ const ChildOf = component<void>({ exclusive: true });
147
+ const world = new World();
148
+ const child = world.new();
149
+ const parent1 = world.new();
150
+ const parent2 = world.new();
151
+
152
+ // Add relation
153
+ world.set(child, relation(ChildOf, parent1));
154
+ world.sync();
155
+
156
+ // Exclusive relations: adding a new relation automatically removes the old one
157
+ world.set(child, relation(ChildOf, parent2));
158
+ world.sync();
159
+ console.log(world.has(child, relation(ChildOf, parent1))); // false
160
+ console.log(world.has(child, relation(ChildOf, parent2))); // true
161
+ ```
162
+
163
+ ### Wildcard Relation Hooks
164
+
165
+ ```typescript
166
+ import { World, component, relation } from "@codehz/ecs";
167
+ const PositionId = component<Position>();
168
+
169
+ const world = new World();
170
+ const wildcardPos = relation(PositionId, "*");
171
+
172
+ // Listen for changes to all relations of this type
173
+ world.hook([wildcardPos], {
174
+ on_set: (entityId, relations) => {
175
+ for (const [targetId, position] of relations) {
176
+ console.log(`entity ${entityId} -> target ${targetId}:`, position);
177
+ }
178
+ },
179
+ on_remove: (entityId, relations) => {
180
+ console.log(`entity ${entityId} removed all Position relations`);
181
+ },
182
+ });
183
+ ```
184
+
185
+ ### EntityBuilder Fluent Creation
186
+
187
+ ```typescript
188
+ const entity = world
189
+ .spawn()
190
+ .with(Position, { x: 0, y: 0 })
191
+ .with(Marker) // void components don't need a value
192
+ .withRelation(ChildOf, parentEntity)
193
+ .build();
194
+ world.sync(); // apply all at once
195
+ ```
196
+
197
+ ### Batch Creation
198
+
199
+ ```typescript
200
+ const entities = world.spawnMany(100, (builder, index) => builder.with(Position, { x: index * 10, y: 0 }));
201
+ world.sync();
202
+ ```
203
+
204
+ ### Running Examples
205
+
206
+ ```bash
207
+ bun run examples/simple.ts
208
+ bun run examples/advanced-scheduling.ts
209
+ bun run examples/parent-child-hierarchy.ts
210
+ bun run examples/inventory-system-relations.ts
211
+ ```
212
+
213
+ ## API Overview
214
+
215
+ ### World
216
+
217
+ | Method | Description |
218
+ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
219
+ | `new<T>()` | Create a new entity, returns `EntityId<T>` |
220
+ | `create<T>()` | Semantic alias for `new()` |
221
+ | `spawn()` | Returns an `EntityBuilder` for fluent creation |
222
+ | `spawnMany(count, configure)` | Batch create multiple entities |
223
+ | `exists(entity)` | Check if an entity exists |
224
+ | `set(entity, componentId, data?)` | Add/update a component (buffered, takes effect after `sync()`). For `void` components, `data` can be omitted |
225
+ | `set(componentId, data)` | Singleton component shorthand: `world.set(GlobalConfig, { ... })` |
226
+ | `get(entity, componentId?)` | Get component data. **Throws if the component does not exist**; use `has()` first or use `getOptional()` |
227
+ | `getOptional(entity, componentId?)` | Safely get a component, returns `{ value: T } \| undefined` |
228
+ | `has(entity, componentId?)` | Check if a component exists |
229
+ | `remove(entity, componentId?)` | Remove a component (buffered), also has a singleton shorthand |
230
+ | `delete(entity)` | Destroy an entity and all its components (buffered) |
231
+ | `query(componentIds)` | Fast ad-hoc query (not cached) |
232
+ | `query(componentIds, true)` | Fast ad-hoc query returning entities and component data |
233
+ | `createQuery(componentIds, filter?)` | Create a reusable, cached query |
234
+ | `releaseQuery(query)` | Release a query (optional cleanup) |
235
+ | `hook(componentTypes, hook, filter?)` | Register a lifecycle hook, returns an unlisten function |
236
+ | `serialize()` | Serialize world state as a snapshot object |
237
+ | `sync()` | Execute all deferred commands |
238
+
239
+ ### Query
240
+
241
+ Queries are created via `world.createQuery()` and should be **reused across frames** for best performance.
242
+
243
+ | Method | Description |
244
+ | ----------------------------------- | ---------------------------------------------------------------------- |
245
+ | `forEach(componentTypes, callback)` | Iterate over matching entities |
246
+ | `getEntities()` | Get the list of all matching entity IDs |
247
+ | `getEntitiesWithComponents(types)` | Get an array of entities with component data objects |
248
+ | `iterate(types)` | Return a generator for `for...of` iteration |
249
+ | `getComponentData(type)` | Get a single component's data array for all matching entities |
250
+ | `dispose()` | Release the query (decrements reference count; fully released at zero) |
251
+ | `get disposed()` | Check if the query has been released |
252
+
253
+ ### QueryFilter
254
+
255
+ ```typescript
256
+ interface QueryFilter {
257
+ negativeComponentTypes?: EntityId<any>[]; // Components to exclude
258
+ }
259
+ ```
260
+
261
+ ### EntityBuilder
262
+
263
+ | Method | Description |
264
+ | -------------------------------------------- | -------------------------------------------------------------- |
265
+ | `with(componentId, ...args)` | Add a regular component. No value for `void` types |
266
+ | `withRelation(componentId, target, ...args)` | Add a relation component. No value for `void` types |
267
+ | `build()` | Create the entity and return `EntityId` (still needs `sync()`) |
268
+
269
+ ### component()
270
+
271
+ ```typescript
272
+ // Auto-assigned ID
273
+ component<T>();
274
+ // With a name
275
+ component<T>("Name");
276
+ // With options
277
+ component<T>({ name?: string, exclusive?: boolean, cascadeDelete?: boolean, dontFragment?: boolean, merge?: (prev, next) => T });
278
+ ```
279
+
280
+ ### relation()
281
+
282
+ ```typescript
283
+ // Create a relation ID
284
+ relation(componentId, targetEntity);
285
+ // Wildcard (query all targets)
286
+ relation(componentId, "*");
287
+ // Singleton target (associate with another component)
288
+ relation(componentId, otherComponentId);
289
+ ```
290
+
291
+ ### Component / Entity ID Rules
292
+
293
+ - Component ID: `1` – `1023`
294
+ - Entity ID: `1024+`
295
+ - Relation ID: negative encoded as `-(componentId * 2^42 + targetId)`
296
+
297
+ ## Serialization (Snapshot)
298
+
299
+ The library provides an "in-memory snapshot" serialization interface for saving/restoring entity and component data.
300
+
301
+ ```typescript
302
+ // Create a snapshot (in-memory object)
303
+ const snapshot = world.serialize();
304
+
305
+ // Restore directly within the same process
306
+ const restored = new World(snapshot);
307
+ ```
308
+
309
+ **Design notes:**
310
+
311
+ - `world.serialize()` returns an in-memory snapshot object. It does **not** call `JSON.stringify` on component values, nor does it attempt to convert component values to a serializable format.
312
+ - `new World(snapshot)` is the sole entry point for deserialization (there is no `World.deserialize()` static method).
313
+ - The snapshot includes entities, components, and the `EntityIdManager` allocator state (preserving the next ID to assign). It does **not** automatically restore query caches or lifecycle hooks.
314
+
315
+ **Persistence example (when component values are JSON-friendly):**
316
+
317
+ ```typescript
318
+ const snapshot = world.serialize();
319
+ const json = JSON.stringify(snapshot);
320
+ // Write to file or send over network ...
321
+
322
+ const parsed = JSON.parse(json);
323
+ const restored = new World(parsed);
324
+ ```
325
+
326
+ **Custom encoding example:**
327
+
328
+ ```typescript
329
+ const snapshot = world.serialize();
330
+ const encoded = {
331
+ ...snapshot,
332
+ entities: snapshot.entities.map((e) => ({
333
+ id: e.id,
334
+ components: e.components.map((c) => ({ type: c.type, value: myEncode(c.value) })),
335
+ })),
336
+ };
337
+ // Persist encoded ...
338
+
339
+ // Decode in reverse when restoring
340
+ const decodedSnapshot = {
341
+ ...decoded,
342
+ entities: decoded.entities.map((e) => ({
343
+ id: e.id,
344
+ components: e.components.map((c) => ({ type: c.type, value: myDecode(c.value) })),
345
+ })),
346
+ };
347
+ const restored = new World(decodedSnapshot);
348
+ ```
349
+
350
+ **Important:** `get()` throws an error when the component does not exist. Since `undefined` is a valid component value, you cannot use `get()`'s return value being `undefined` to determine whether a component exists. Use `has()` or `getOptional()` instead.
351
+
352
+ ## System / Pipeline Integration
353
+
354
+ Starting from v0.4.0, the library removed the built-in `System` and `SystemScheduler`. It is recommended to use `@codehz/pipeline` to organize the game loop, and **always call `world.sync()` in the last pass**.
355
+
356
+ ```bash
357
+ bun add @codehz/pipeline
358
+ ```
359
+
360
+ ```typescript
361
+ import { pipeline } from "@codehz/pipeline";
362
+ import { World, component } from "@codehz/ecs";
363
+
364
+ const world = new World();
365
+ const movementQuery = world.createQuery([PositionId, VelocityId]);
366
+
367
+ const gameLoop = pipeline<{ deltaTime: number }>()
368
+ .addPass((env) => {
369
+ movementQuery.forEach([PositionId, VelocityId], (entity, position, velocity) => {
370
+ position.x += velocity.x * env.deltaTime;
371
+ position.y += velocity.y * env.deltaTime;
372
+ });
373
+ })
374
+ .addPass(() => {
375
+ world.sync(); // must be the last pass
376
+ })
377
+ .build();
378
+
379
+ gameLoop({ deltaTime: 0.016 });
380
+ ```
381
+
382
+ ## Project Structure
383
+
384
+ ```
385
+ src/
386
+ ├── index.ts # Entry point (unified exports)
387
+ ├── core/ # Core implementation
388
+ │ ├── world.ts # World management
389
+ │ ├── archetype.ts # Archetype system (efficient component storage)
390
+ │ ├── builder.ts # EntityBuilder fluent creation
391
+ │ ├── component-registry.ts # Component registry
392
+ │ ├── component-entity-store.ts # Singleton component storage
393
+ │ ├── component-type-utils.ts # Component type utilities
394
+ │ ├── dont-fragment-store.ts # DontFragment storage
395
+ │ ├── entity.ts # Entity/component/relation type exports (aggregate)
396
+ │ ├── entity-types.ts # Entity ID type definitions & constants
397
+ │ ├── entity-relation.ts # Relation ID encoding/decoding
398
+ │ ├── entity-manager.ts # ID allocator
399
+ │ ├── query-registry.ts # Query registry
400
+ │ ├── serialization.ts # Serialization ID encoding/decoding
401
+ │ ├── world-serialization.ts # World serialization/deserialization
402
+ │ ├── world-commands.ts # World commands
403
+ │ ├── world-hooks.ts # Hook execution logic
404
+ │ ├── world-references.ts # Entity reference tracking
405
+ │ └── types.ts # Type definitions
406
+ ├── query/ # Query system
407
+ │ ├── query.ts # Query class
408
+ │ └── filter.ts # Query filter
409
+ ├── commands/ # Command buffer
410
+ ├── utils/ # Utility functions
411
+ ├── testing/ # Test utilities
412
+ └── __tests__/ # Unit tests & performance tests
413
+
414
+ examples/
415
+ ├── advanced-scheduling.ts # Pipeline scheduling example
416
+ ├── collision-detection.ts # Collision detection example
417
+ ├── parent-child-hierarchy.ts # Parent-child hierarchy and transform propagation example
418
+ ├── serialization.ts # Serialization example
419
+ ├── simple.ts # Basic example
420
+ ├── spatial-grid.ts # Spatial grid example
421
+ ├── state-machine.ts # State machine example
422
+ └── tag-filtering.ts # Tag filtering example
423
+
424
+ scripts/
425
+ ├── build.ts # Build script
426
+ └── release.ts # Release script
427
+ ```
428
+
429
+ ## Development
430
+
431
+ ```bash
432
+ bun install
433
+ bun test # Run tests
434
+ bunx tsc --noEmit # Type check
435
+ bun run examples/simple.ts # Run example
436
+ bun run examples/parent-child-hierarchy.ts
437
+ bun run scripts/build.ts # Build
438
+ ```
439
+
440
+ ## License
441
+
442
+ MIT
443
+
444
+ ## Contributing
445
+
446
+ Issues and Pull Requests are welcome!