@codehz/ecs 0.6.10 → 0.7.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.en.md +440 -0
- package/README.md +248 -269
- package/builder.d.mts +667 -196
- package/index.d.mts +2 -2
- package/package.json +1 -1
- package/testing.d.mts +2 -2
- package/testing.mjs.map +1 -1
- package/world.mjs +1457 -1137
- package/world.mjs.map +1 -1
package/README.en.md
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
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/demo.ts
|
|
208
|
+
bun run examples/advanced-scheduling/demo.ts
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## API Overview
|
|
212
|
+
|
|
213
|
+
### World
|
|
214
|
+
|
|
215
|
+
| Method | Description |
|
|
216
|
+
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
217
|
+
| `new<T>()` | Create a new entity, returns `EntityId<T>` |
|
|
218
|
+
| `create<T>()` | Semantic alias for `new()` |
|
|
219
|
+
| `spawn()` | Returns an `EntityBuilder` for fluent creation |
|
|
220
|
+
| `spawnMany(count, configure)` | Batch create multiple entities |
|
|
221
|
+
| `exists(entity)` | Check if an entity exists |
|
|
222
|
+
| `set(entity, componentId, data?)` | Add/update a component (buffered, takes effect after `sync()`). For `void` components, `data` can be omitted |
|
|
223
|
+
| `set(componentId, data)` | Singleton component shorthand: `world.set(GlobalConfig, { ... })` |
|
|
224
|
+
| `get(entity, componentId?)` | Get component data. **Throws if the component does not exist**; use `has()` first or use `getOptional()` |
|
|
225
|
+
| `getOptional(entity, componentId?)` | Safely get a component, returns `{ value: T } \| undefined` |
|
|
226
|
+
| `has(entity, componentId?)` | Check if a component exists |
|
|
227
|
+
| `remove(entity, componentId?)` | Remove a component (buffered), also has a singleton shorthand |
|
|
228
|
+
| `delete(entity)` | Destroy an entity and all its components (buffered) |
|
|
229
|
+
| `query(componentIds)` | Fast ad-hoc query (not cached) |
|
|
230
|
+
| `query(componentIds, true)` | Fast ad-hoc query returning entities and component data |
|
|
231
|
+
| `createQuery(componentIds, filter?)` | Create a reusable, cached query |
|
|
232
|
+
| `releaseQuery(query)` | Release a query (optional cleanup) |
|
|
233
|
+
| `hook(componentTypes, hook, filter?)` | Register a lifecycle hook, returns an unlisten function |
|
|
234
|
+
| `serialize()` | Serialize world state as a snapshot object |
|
|
235
|
+
| `sync()` | Execute all deferred commands |
|
|
236
|
+
|
|
237
|
+
### Query
|
|
238
|
+
|
|
239
|
+
Queries are created via `world.createQuery()` and should be **reused across frames** for best performance.
|
|
240
|
+
|
|
241
|
+
| Method | Description |
|
|
242
|
+
| ----------------------------------- | ---------------------------------------------------------------------- |
|
|
243
|
+
| `forEach(componentTypes, callback)` | Iterate over matching entities |
|
|
244
|
+
| `getEntities()` | Get the list of all matching entity IDs |
|
|
245
|
+
| `getEntitiesWithComponents(types)` | Get an array of entities with component data objects |
|
|
246
|
+
| `iterate(types)` | Return a generator for `for...of` iteration |
|
|
247
|
+
| `getComponentData(type)` | Get a single component's data array for all matching entities |
|
|
248
|
+
| `dispose()` | Release the query (decrements reference count; fully released at zero) |
|
|
249
|
+
| `get disposed()` | Check if the query has been released |
|
|
250
|
+
|
|
251
|
+
### QueryFilter
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
interface QueryFilter {
|
|
255
|
+
negativeComponentTypes?: EntityId<any>[]; // Components to exclude
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### EntityBuilder
|
|
260
|
+
|
|
261
|
+
| Method | Description |
|
|
262
|
+
| -------------------------------------------- | -------------------------------------------------------------- |
|
|
263
|
+
| `with(componentId, ...args)` | Add a regular component. No value for `void` types |
|
|
264
|
+
| `withRelation(componentId, target, ...args)` | Add a relation component. No value for `void` types |
|
|
265
|
+
| `build()` | Create the entity and return `EntityId` (still needs `sync()`) |
|
|
266
|
+
|
|
267
|
+
### component()
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
// Auto-assigned ID
|
|
271
|
+
component<T>();
|
|
272
|
+
// With a name
|
|
273
|
+
component<T>("Name");
|
|
274
|
+
// With options
|
|
275
|
+
component<T>({ name?: string, exclusive?: boolean, cascadeDelete?: boolean, dontFragment?: boolean, merge?: (prev, next) => T });
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### relation()
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
// Create a relation ID
|
|
282
|
+
relation(componentId, targetEntity);
|
|
283
|
+
// Wildcard (query all targets)
|
|
284
|
+
relation(componentId, "*");
|
|
285
|
+
// Singleton target (associate with another component)
|
|
286
|
+
relation(componentId, otherComponentId);
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Component / Entity ID Rules
|
|
290
|
+
|
|
291
|
+
- Component ID: `1` – `1023`
|
|
292
|
+
- Entity ID: `1024+`
|
|
293
|
+
- Relation ID: negative encoded as `-(componentId * 2^42 + targetId)`
|
|
294
|
+
|
|
295
|
+
## Serialization (Snapshot)
|
|
296
|
+
|
|
297
|
+
The library provides an "in-memory snapshot" serialization interface for saving/restoring entity and component data.
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
// Create a snapshot (in-memory object)
|
|
301
|
+
const snapshot = world.serialize();
|
|
302
|
+
|
|
303
|
+
// Restore directly within the same process
|
|
304
|
+
const restored = new World(snapshot);
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**Design notes:**
|
|
308
|
+
|
|
309
|
+
- `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.
|
|
310
|
+
- `new World(snapshot)` is the sole entry point for deserialization (there is no `World.deserialize()` static method).
|
|
311
|
+
- 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.
|
|
312
|
+
|
|
313
|
+
**Persistence example (when component values are JSON-friendly):**
|
|
314
|
+
|
|
315
|
+
```typescript
|
|
316
|
+
const snapshot = world.serialize();
|
|
317
|
+
const json = JSON.stringify(snapshot);
|
|
318
|
+
// Write to file or send over network ...
|
|
319
|
+
|
|
320
|
+
const parsed = JSON.parse(json);
|
|
321
|
+
const restored = new World(parsed);
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
**Custom encoding example:**
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
const snapshot = world.serialize();
|
|
328
|
+
const encoded = {
|
|
329
|
+
...snapshot,
|
|
330
|
+
entities: snapshot.entities.map((e) => ({
|
|
331
|
+
id: e.id,
|
|
332
|
+
components: e.components.map((c) => ({ type: c.type, value: myEncode(c.value) })),
|
|
333
|
+
})),
|
|
334
|
+
};
|
|
335
|
+
// Persist encoded ...
|
|
336
|
+
|
|
337
|
+
// Decode in reverse when restoring
|
|
338
|
+
const decodedSnapshot = {
|
|
339
|
+
...decoded,
|
|
340
|
+
entities: decoded.entities.map((e) => ({
|
|
341
|
+
id: e.id,
|
|
342
|
+
components: e.components.map((c) => ({ type: c.type, value: myDecode(c.value) })),
|
|
343
|
+
})),
|
|
344
|
+
};
|
|
345
|
+
const restored = new World(decodedSnapshot);
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
**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.
|
|
349
|
+
|
|
350
|
+
## System / Pipeline Integration
|
|
351
|
+
|
|
352
|
+
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**.
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
bun add @codehz/pipeline
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
import { pipeline } from "@codehz/pipeline";
|
|
360
|
+
import { World, component } from "@codehz/ecs";
|
|
361
|
+
|
|
362
|
+
const world = new World();
|
|
363
|
+
const movementQuery = world.createQuery([PositionId, VelocityId]);
|
|
364
|
+
|
|
365
|
+
const gameLoop = pipeline<{ deltaTime: number }>()
|
|
366
|
+
.addPass((env) => {
|
|
367
|
+
movementQuery.forEach([PositionId, VelocityId], (entity, position, velocity) => {
|
|
368
|
+
position.x += velocity.x * env.deltaTime;
|
|
369
|
+
position.y += velocity.y * env.deltaTime;
|
|
370
|
+
});
|
|
371
|
+
})
|
|
372
|
+
.addPass(() => {
|
|
373
|
+
world.sync(); // must be the last pass
|
|
374
|
+
})
|
|
375
|
+
.build();
|
|
376
|
+
|
|
377
|
+
gameLoop({ deltaTime: 0.016 });
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
## Project Structure
|
|
381
|
+
|
|
382
|
+
```
|
|
383
|
+
src/
|
|
384
|
+
├── index.ts # Entry point (unified exports)
|
|
385
|
+
├── core/ # Core implementation
|
|
386
|
+
│ ├── world.ts # World management
|
|
387
|
+
│ ├── archetype.ts # Archetype system (efficient component storage)
|
|
388
|
+
│ ├── builder.ts # EntityBuilder fluent creation
|
|
389
|
+
│ ├── component-registry.ts # Component registry
|
|
390
|
+
│ ├── component-entity-store.ts # Singleton component storage
|
|
391
|
+
│ ├── component-type-utils.ts # Component type utilities
|
|
392
|
+
│ ├── dont-fragment-store.ts # DontFragment storage
|
|
393
|
+
│ ├── entity.ts # Entity/component/relation type exports (aggregate)
|
|
394
|
+
│ ├── entity-types.ts # Entity ID type definitions & constants
|
|
395
|
+
│ ├── entity-relation.ts # Relation ID encoding/decoding
|
|
396
|
+
│ ├── entity-manager.ts # ID allocator
|
|
397
|
+
│ ├── query-registry.ts # Query registry
|
|
398
|
+
│ ├── serialization.ts # Serialization ID encoding/decoding
|
|
399
|
+
│ ├── world-serialization.ts # World serialization/deserialization
|
|
400
|
+
│ ├── world-commands.ts # World commands
|
|
401
|
+
│ ├── world-hooks.ts # Hook execution logic
|
|
402
|
+
│ ├── world-references.ts # Entity reference tracking
|
|
403
|
+
│ └── types.ts # Type definitions
|
|
404
|
+
├── query/ # Query system
|
|
405
|
+
│ ├── query.ts # Query class
|
|
406
|
+
│ └── filter.ts # Query filter
|
|
407
|
+
├── commands/ # Command buffer
|
|
408
|
+
├── utils/ # Utility functions
|
|
409
|
+
├── testing/ # Test utilities
|
|
410
|
+
└── __tests__/ # Unit tests & performance tests
|
|
411
|
+
|
|
412
|
+
examples/
|
|
413
|
+
├── simple/
|
|
414
|
+
│ ├── demo.ts # Basic example
|
|
415
|
+
│ └── README.md # Example documentation
|
|
416
|
+
└── advanced-scheduling/
|
|
417
|
+
└── demo.ts # Pipeline scheduling example
|
|
418
|
+
|
|
419
|
+
scripts/
|
|
420
|
+
├── build.ts # Build script
|
|
421
|
+
└── release.ts # Release script
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
## Development
|
|
425
|
+
|
|
426
|
+
```bash
|
|
427
|
+
bun install
|
|
428
|
+
bun test # Run tests
|
|
429
|
+
bunx tsc --noEmit # Type check
|
|
430
|
+
bun run examples/simple/demo.ts # Run example
|
|
431
|
+
bun run scripts/build.ts # Build
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
## License
|
|
435
|
+
|
|
436
|
+
MIT
|
|
437
|
+
|
|
438
|
+
## Contributing
|
|
439
|
+
|
|
440
|
+
Issues and Pull Requests are welcome!
|