@vworlds/vecs 1.0.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/.claude/settings.json +12 -0
- package/.devcontainer/devcontainer.json +22 -0
- package/.github/workflows/publish.yml +32 -0
- package/README.md +464 -0
- package/dist/component.d.ts +135 -0
- package/dist/component.js +101 -0
- package/dist/component.js.map +1 -0
- package/dist/entity.d.ts +157 -0
- package/dist/entity.js +199 -0
- package/dist/entity.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/package.json +25 -0
- package/dist/phase.d.ts +47 -0
- package/dist/phase.js +23 -0
- package/dist/phase.js.map +1 -0
- package/dist/system.d.ts +361 -0
- package/dist/system.js +396 -0
- package/dist/system.js.map +1 -0
- package/dist/util/array_map.d.ts +58 -0
- package/dist/util/array_map.js +84 -0
- package/dist/util/array_map.js.map +1 -0
- package/dist/util/bitset.d.ts +117 -0
- package/dist/util/bitset.js +177 -0
- package/dist/util/bitset.js.map +1 -0
- package/dist/util/events.d.ts +27 -0
- package/dist/util/events.js +43 -0
- package/dist/util/events.js.map +1 -0
- package/dist/util/ordered_set.d.ts +17 -0
- package/dist/util/ordered_set.js +69 -0
- package/dist/util/ordered_set.js.map +1 -0
- package/dist/world.d.ts +279 -0
- package/dist/world.js +453 -0
- package/dist/world.js.map +1 -0
- package/package.json +25 -0
- package/src/component.ts +180 -0
- package/src/entity.ts +276 -0
- package/src/index.ts +6 -0
- package/src/phase.ts +49 -0
- package/src/system.ts +693 -0
- package/src/util/array_map.ts +93 -0
- package/src/util/bitset.ts +199 -0
- package/src/util/events.ts +95 -0
- package/src/util/ordered_set.ts +82 -0
- package/src/world.ts +534 -0
- package/tests/_helpers.ts +30 -0
- package/tests/array_map.test.ts +68 -0
- package/tests/bitset.test.ts +127 -0
- package/tests/component.test.ts +104 -0
- package/tests/entity.test.ts +179 -0
- package/tests/events.test.ts +48 -0
- package/tests/ordered_set.test.ts +153 -0
- package/tests/setup.ts +6 -0
- package/tests/system.test.ts +800 -0
- package/tests/world.test.ts +174 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vecs",
|
|
3
|
+
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20",
|
|
4
|
+
"remoteUser": "node",
|
|
5
|
+
"postCreateCommand": "yarn install",
|
|
6
|
+
"customizations": {
|
|
7
|
+
"vscode": {
|
|
8
|
+
"extensions": [
|
|
9
|
+
"dbaeumer.vscode-eslint",
|
|
10
|
+
"esbenp.prettier-vscode",
|
|
11
|
+
"anthropic.claude-code"
|
|
12
|
+
],
|
|
13
|
+
"settings": {
|
|
14
|
+
"claudeCode.allowDangerouslySkipPermissions": true
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"mounts": [
|
|
19
|
+
// Claude Code auth — mounts ~/.claude from the host so login carries over seamlessly
|
|
20
|
+
"source=${localEnv:HOME}/.claude,target=/home/node/.claude,type=bind,consistency=cached"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- master
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
contents: read
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version: '20'
|
|
20
|
+
registry-url: 'https://registry.npmjs.org'
|
|
21
|
+
|
|
22
|
+
- name: Install dependencies
|
|
23
|
+
run: yarn install --frozen-lockfile
|
|
24
|
+
|
|
25
|
+
- name: Run tests
|
|
26
|
+
run: yarn test
|
|
27
|
+
|
|
28
|
+
- name: Build
|
|
29
|
+
run: yarn build
|
|
30
|
+
|
|
31
|
+
- name: Publish
|
|
32
|
+
run: yarn publish --access public
|
package/README.md
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
# vecs
|
|
2
|
+
|
|
3
|
+
A TypeScript Entity Component System (ECS) for real-time games and simulations.
|
|
4
|
+
|
|
5
|
+
`vecs` lets you model game state as **entities** (integer IDs) with **components** (typed data bags) attached to them. **Systems** declare which component combinations they care about and receive automatic callbacks when entities enter or leave their query, when component data changes, and on every tick. A **World** ties it all together and drives the update loop.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
yarn add @vworlds/vecs
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Concepts
|
|
14
|
+
|
|
15
|
+
| Concept | What it is |
|
|
16
|
+
|---|---|
|
|
17
|
+
| **World** | Central container. Owns all entities, runs all systems. |
|
|
18
|
+
| **Component** | A plain data class. Extend `Component` and attach instances to entities. |
|
|
19
|
+
| **Entity** | An integer id with a set of components. Create via the world. |
|
|
20
|
+
| **System** | Reactive logic. Declare which components you need; get called when things change. |
|
|
21
|
+
|
|
22
|
+
### Lifecycle in brief
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
registerComponent() × N → system() × N → start() → progress() every frame
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
After `start()`, no new components or systems can be registered.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Example
|
|
33
|
+
|
|
34
|
+
The example below defines three components, two systems, a phase, and a hook, then runs a simple "move and despawn" loop.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { World, Component, IPhase } from "@vworlds/vecs";
|
|
38
|
+
|
|
39
|
+
// ─── Components ────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
class Position extends Component {
|
|
42
|
+
x = 0;
|
|
43
|
+
y = 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class Velocity extends Component {
|
|
47
|
+
vx = 0;
|
|
48
|
+
vy = 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class Health extends Component {
|
|
52
|
+
hp = 100;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── World setup ───────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
const world = new World();
|
|
58
|
+
|
|
59
|
+
world.registerComponent(Position);
|
|
60
|
+
world.registerComponent(Velocity);
|
|
61
|
+
world.registerComponent(Health);
|
|
62
|
+
|
|
63
|
+
// ─── Phases ────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const update: IPhase = world.addPhase("update");
|
|
66
|
+
const cleanup: IPhase = world.addPhase("cleanup");
|
|
67
|
+
|
|
68
|
+
// ─── Systems ───────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
// MoveSystem: runs every tick for entities that have both Position and Velocity.
|
|
71
|
+
world
|
|
72
|
+
.system("Move")
|
|
73
|
+
.phase(update)
|
|
74
|
+
.requires(Position, Velocity)
|
|
75
|
+
.enter([Position, Velocity], (e, [pos, vel]) => {
|
|
76
|
+
console.log(`entity ${e.eid} entered Move with pos=(${pos.x},${pos.y})`);
|
|
77
|
+
})
|
|
78
|
+
.update(Velocity, [Position], (vel, [pos]) => {
|
|
79
|
+
// Called whenever vel.modified() is queued.
|
|
80
|
+
pos.x += vel.vx;
|
|
81
|
+
pos.y += vel.vy;
|
|
82
|
+
pos.modified(); // propagate position change to other systems
|
|
83
|
+
})
|
|
84
|
+
.exit((e) => {
|
|
85
|
+
console.log(`entity ${e.eid} left Move`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// HealthSystem: despawns entities whose HP drops to zero.
|
|
89
|
+
world
|
|
90
|
+
.system("Health")
|
|
91
|
+
.phase(cleanup)
|
|
92
|
+
.requires(Health)
|
|
93
|
+
.update(Health, (health) => {
|
|
94
|
+
if (health.hp <= 0) {
|
|
95
|
+
health.entity.destroy();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ─── Hooks ─────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
// Hooks are a lightweight alternative to systems for side effects on a single
|
|
102
|
+
// component type — no per-entity query, just callbacks on add/remove/set.
|
|
103
|
+
world
|
|
104
|
+
.hook(Health)
|
|
105
|
+
.onAdd((h) => console.log(`entity ${h.entity.eid} spawned with hp=${h.hp}`))
|
|
106
|
+
.onRemove((h) => console.log(`entity ${h.entity.eid} died`));
|
|
107
|
+
|
|
108
|
+
// ─── Start ─────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
world.start(); // freeze registration, sort systems into phases
|
|
111
|
+
|
|
112
|
+
// ─── Create entities ───────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const bullet = world.createEntity();
|
|
115
|
+
const pos = bullet.add(Position);
|
|
116
|
+
pos.x = 0;
|
|
117
|
+
pos.y = 0;
|
|
118
|
+
|
|
119
|
+
const vel = bullet.add(Velocity);
|
|
120
|
+
vel.vx = 5;
|
|
121
|
+
vel.vy = 0;
|
|
122
|
+
vel.modified(); // first update: notify Move system
|
|
123
|
+
|
|
124
|
+
const hp = bullet.add(Health);
|
|
125
|
+
hp.hp = 3;
|
|
126
|
+
hp.modified();
|
|
127
|
+
|
|
128
|
+
// ─── Game loop ─────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
let now = 0;
|
|
131
|
+
for (let tick = 0; tick < 5; tick++) {
|
|
132
|
+
now += 16;
|
|
133
|
+
world.progress(now, 16);
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## API Reference
|
|
140
|
+
|
|
141
|
+
### World
|
|
142
|
+
|
|
143
|
+
The world owns everything. Create one per game session.
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
const world = new World();
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
#### Component registration
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
// Auto-assigned type id (starts at 256 for "local" components):
|
|
153
|
+
world.registerComponent(Position);
|
|
154
|
+
|
|
155
|
+
// Explicit numeric type id (required when the id comes from a server):
|
|
156
|
+
world.registerComponent(Position, 1);
|
|
157
|
+
|
|
158
|
+
// With a display name different from the class name:
|
|
159
|
+
world.registerComponent(Position, "pos");
|
|
160
|
+
|
|
161
|
+
// Pre-register a name → id mapping before the class is available:
|
|
162
|
+
world.registerComponentType("Position", 1);
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
After `world.start()` any further call to `registerComponent` throws.
|
|
166
|
+
|
|
167
|
+
#### Entity management
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// Locally-owned entity with an auto-incrementing id:
|
|
171
|
+
const e = world.createEntity();
|
|
172
|
+
|
|
173
|
+
// Server-assigned id; creates the entity if it doesn't exist yet:
|
|
174
|
+
const e = world.getOrCreateEntity(serverId, (newEntity) => {
|
|
175
|
+
tracked.add(newEntity);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Look up by id (returns undefined if not found):
|
|
179
|
+
const e = world.entity(42);
|
|
180
|
+
|
|
181
|
+
// Destroy everything (e.g. on level reset):
|
|
182
|
+
world.clearAllEntities();
|
|
183
|
+
|
|
184
|
+
// Reserve a high id range for locally-created entities so they don't
|
|
185
|
+
// collide with server-assigned ids (call before world.start()):
|
|
186
|
+
world.setEntityIdRange(0x10000);
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
#### Systems
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// Create, configure, and register a system in one chain:
|
|
193
|
+
world.system("MySystem")
|
|
194
|
+
.phase("update")
|
|
195
|
+
.requires(A, B)
|
|
196
|
+
.enter(...)
|
|
197
|
+
.update(...)
|
|
198
|
+
.exit(...);
|
|
199
|
+
|
|
200
|
+
world.start(); // must be called once, after all systems are set up
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
#### Phases
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
// Declare phases in the order they should run each frame:
|
|
207
|
+
const preUpdate = world.addPhase("preupdate");
|
|
208
|
+
const update = world.addPhase("update");
|
|
209
|
+
const send = world.addPhase("send");
|
|
210
|
+
|
|
211
|
+
// Each frame, run all phases in registration order:
|
|
212
|
+
world.progress(Date.now(), deltaMs);
|
|
213
|
+
|
|
214
|
+
// Or drive individual phases manually:
|
|
215
|
+
world.runPhase(preUpdate, Date.now(), deltaMs);
|
|
216
|
+
world.runPhase(update, Date.now(), deltaMs);
|
|
217
|
+
world.runPhase(send, Date.now(), deltaMs);
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Systems with no explicit phase go into a built-in `"update"` phase.
|
|
221
|
+
|
|
222
|
+
#### Hooks
|
|
223
|
+
|
|
224
|
+
A hook is a shorthand for reacting to a single component's lifecycle without writing a full system:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
world.hook(Sprite)
|
|
228
|
+
.onAdd((sprite) => sprite.initialize(scene))
|
|
229
|
+
.onRemove((sprite) => sprite.destroy())
|
|
230
|
+
.onSet((sprite) => sprite.syncToScene());
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`onSet` fires whenever `component.modified()` is called.
|
|
234
|
+
`onAdd` fires when the component is first attached to an entity.
|
|
235
|
+
`onRemove` fires when it is removed or the entity is destroyed.
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
### Component
|
|
240
|
+
|
|
241
|
+
Extend `Component` to define your data:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
class Position extends Component {
|
|
245
|
+
x = 0;
|
|
246
|
+
y = 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
world.registerComponent(Position);
|
|
250
|
+
|
|
251
|
+
const pos = entity.add(Position);
|
|
252
|
+
pos.x = 100;
|
|
253
|
+
pos.modified(); // tell the world this component changed
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Every component instance exposes:
|
|
257
|
+
|
|
258
|
+
| Property / Method | Description |
|
|
259
|
+
|---|---|
|
|
260
|
+
| `entity` | The `Entity` this component belongs to. |
|
|
261
|
+
| `meta` | `ComponentMeta` — holds the type id, name, and bitset pointer. |
|
|
262
|
+
| `type` | Numeric type id (shorthand for `meta.type`). |
|
|
263
|
+
| `modified()` | Queue an `onSet` / `update` notification. Call after mutating fields. |
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
### Entity
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
const e = world.createEntity();
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
| Property / Method | Description |
|
|
274
|
+
|---|---|
|
|
275
|
+
| `eid` | Unique numeric entity id. |
|
|
276
|
+
| `world` | The `World` that owns this entity. |
|
|
277
|
+
| `add(Class)` | Attach a component; returns the typed instance. Idempotent. |
|
|
278
|
+
| `get(Class)` | Return the component instance, or `undefined` if not present. |
|
|
279
|
+
| `remove(Class)` | Detach a component (triggers `onRemove` hooks and `exit` callbacks). |
|
|
280
|
+
| `destroy()` | Remove all components and unregister the entity. Recurses to children. |
|
|
281
|
+
| `empty` | `true` when no components are attached. |
|
|
282
|
+
| `forEachComponent(cb)` | Iterate over all attached components. |
|
|
283
|
+
| `parent` | Parent entity in the scene hierarchy, or `undefined`. |
|
|
284
|
+
| `children` | `Set<Entity>` of direct children (lazy, created on first access). |
|
|
285
|
+
| `events` | Typed event emitter. Currently emits `"destroy"` before teardown. |
|
|
286
|
+
| `properties` | `Map<string, any>` free-form bag for module-level bookkeeping. |
|
|
287
|
+
|
|
288
|
+
#### Parent–child hierarchy
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
child.parent = parent;
|
|
292
|
+
parent.children.add(child);
|
|
293
|
+
|
|
294
|
+
// Destroying a parent recursively destroys all children:
|
|
295
|
+
parent.destroy();
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Archetype queries that use `{ PARENT: ... }` are automatically re-evaluated when a parent's component set changes.
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
### System
|
|
303
|
+
|
|
304
|
+
Systems are created via `world.system(name)` and configured through a fluent builder API. All methods return `this` for chaining.
|
|
305
|
+
|
|
306
|
+
#### `.requires(...components)` and `.query(q)`
|
|
307
|
+
|
|
308
|
+
Declare which entities the system should track:
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
// Entities that have both Position and Velocity:
|
|
312
|
+
.requires(Position, Velocity)
|
|
313
|
+
|
|
314
|
+
// Equivalent explicit query:
|
|
315
|
+
.query({ HAS: [Position, Velocity] })
|
|
316
|
+
|
|
317
|
+
// Entities that have a parent with Player AND Container:
|
|
318
|
+
.query({ PARENT: { AND: [Player, Container] } })
|
|
319
|
+
|
|
320
|
+
// Compound queries:
|
|
321
|
+
.query({ AND: [Position, { OR: [Sprite, Container] }] })
|
|
322
|
+
.query({ NOT: Invisible })
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
**Query operators:**
|
|
326
|
+
|
|
327
|
+
| Operator | Meaning |
|
|
328
|
+
|---|---|
|
|
329
|
+
| `{ HAS: [A, B] }` | Entity has all of A and B |
|
|
330
|
+
| `{ HAS_ONLY: [A, B] }` | Entity has exactly A and B, nothing else |
|
|
331
|
+
| `{ AND: [q1, q2] }` | Both sub-queries must match |
|
|
332
|
+
| `{ OR: [q1, q2] }` | Either sub-query matches |
|
|
333
|
+
| `{ NOT: q }` | Sub-query must not match |
|
|
334
|
+
| `{ PARENT: q }` | Entity's parent matches q |
|
|
335
|
+
| An array `[A, B]` | Shorthand for `HAS: [A, B]` |
|
|
336
|
+
|
|
337
|
+
**Type inference:** `requires()` records the listed classes as a type parameter on the system. Callbacks in `.sort()`, `.each()`, and `.update()` inject then treat those components as non-nullable — no `!` needed. For complex `query()` expressions the type system cannot introspect, pass a second argument as an explicit hint:
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
.query({ AND: [{ HAS: Position }, { HAS: Velocity }] }, [Position, Velocity])
|
|
341
|
+
.each([Position, Velocity], (e, [pos, vel]) => {
|
|
342
|
+
pos.x += vel.vx; // pos and vel are non-null
|
|
343
|
+
})
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
#### `.phase(p)`
|
|
347
|
+
|
|
348
|
+
Assign the system to a named phase or an `IPhase` reference. Systems without a phase run in `"update"`.
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
.phase("preupdate") // by name
|
|
352
|
+
.phase(myPhase) // by IPhase reference
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
#### `.enter(callback)` / `.enter(inject, callback)`
|
|
356
|
+
|
|
357
|
+
Called once when an entity first matches the system's query.
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
// No injection:
|
|
361
|
+
.enter((e) => { console.log("entity joined", e.eid); })
|
|
362
|
+
|
|
363
|
+
// With injection — component instances resolved from the entity:
|
|
364
|
+
.enter([Position, Sprite], (e, [pos, sprite]) => {
|
|
365
|
+
sprite.setPosition(pos.x, pos.y);
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
// Resolve from parent:
|
|
369
|
+
.enter([{ parent: Container }], (e, [container]) => {
|
|
370
|
+
container.add(e.get(Sprite)!.gameObject);
|
|
371
|
+
})
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
#### `.exit(callback)` / `.exit(inject, callback)`
|
|
375
|
+
|
|
376
|
+
Called when an entity leaves the system (component removed or entity destroyed). Components removed in the same frame are still accessible in exit callbacks.
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
.exit([Sprite], (e, [sprite]) => {
|
|
380
|
+
sprite.destroy();
|
|
381
|
+
})
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
#### `.update(ComponentClass, callback)` / `.update(ComponentClass, inject, callback)`
|
|
385
|
+
|
|
386
|
+
Called when `component.modified()` is queued on a watched component of a tracked entity.
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
// Simple — receives the modified component:
|
|
390
|
+
.update(Position, (pos) => {
|
|
391
|
+
renderer.setPosition(pos.x, pos.y);
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
// With injection — receives the modified component and extra components:
|
|
395
|
+
.update(Position, [Sprite], (pos, [sprite]) => {
|
|
396
|
+
sprite.sprite.setPosition(pos.x, pos.y);
|
|
397
|
+
})
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Injected components listed in `requires()` are non-nullable in the callback; any others are `Type | undefined`.
|
|
401
|
+
|
|
402
|
+
Calling `update` also adds that component type to the system's implicit `HAS` query (unless you called `query()` first).
|
|
403
|
+
|
|
404
|
+
#### `.each(components, callback)`
|
|
405
|
+
|
|
406
|
+
Called every tick for **every tracked entity**, unconditionally. Unlike `update` (which only fires when `component.modified()` is called), `each` fires regardless of whether the component was modified — use it for per-entity logic that must run on every frame.
|
|
407
|
+
|
|
408
|
+
The callback receives the entity and a tuple of resolved component instances. Components declared via `requires()` are guaranteed non-null; any others are `undefined` if the entity lacks them.
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
.requires(Position, Velocity)
|
|
412
|
+
.each([Position, Velocity], (e, [pos, vel]) => {
|
|
413
|
+
pos.x += vel.vx; // non-null — both are in requires()
|
|
414
|
+
pos.y += vel.vy;
|
|
415
|
+
})
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
`each` does not modify the system's query — define membership with `requires(...)` or `query(...)` as usual. Only one `each` may be registered per system; a second call throws.
|
|
419
|
+
|
|
420
|
+
#### `.sort(components, compare)`
|
|
421
|
+
|
|
422
|
+
Enable sorted entity tracking. Matched entities are stored in an ordered set whose insertion position is determined by `compare`, which receives a tuple of resolved component instances for each pair being ordered. Implies `.track()`.
|
|
423
|
+
|
|
424
|
+
Components declared via `requires()` are non-null in the compare callback.
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
world.system("Render")
|
|
428
|
+
.requires(Position, Sprite)
|
|
429
|
+
.sort([Position], ([posA], [posB]) => posA.z - posB.z)
|
|
430
|
+
.each([Position, Sprite], (e, [pos, sprite]) => {
|
|
431
|
+
sprite.draw(pos.x, pos.y);
|
|
432
|
+
});
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Iterating `system.entities` after a phase run yields entities in the sorted order.
|
|
436
|
+
|
|
437
|
+
#### `.track()`
|
|
438
|
+
|
|
439
|
+
Enable entity tracking without an `each` callback — matched entities are exposed via `system.entities` as they enter and leave. `each` and `sort` imply `track` automatically; call this directly only when you need the set without a per-tick callback.
|
|
440
|
+
|
|
441
|
+
#### `.run(callback)`
|
|
442
|
+
|
|
443
|
+
Called every tick when the system's phase runs, regardless of entity state. Use this for polling, network I/O, timers, etc.
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
.run((now, delta) => {
|
|
447
|
+
sendNetworkPacket(now);
|
|
448
|
+
})
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
---
|
|
452
|
+
|
|
453
|
+
## Build & Test
|
|
454
|
+
|
|
455
|
+
```
|
|
456
|
+
yarn build
|
|
457
|
+
yarn test
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
## License
|
|
463
|
+
|
|
464
|
+
UNLICENSED
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { BitPtr, Bitset } from "./util/bitset.js";
|
|
2
|
+
import type { Entity } from "./entity.js";
|
|
3
|
+
import { type World } from "./world.js";
|
|
4
|
+
/**
|
|
5
|
+
* Lifecycle hook for a component type. Obtained via {@link World.hook}.
|
|
6
|
+
*
|
|
7
|
+
* Hooks let you react to component lifecycle events without building a full
|
|
8
|
+
* {@link System}. Each call returns the same `Hook` so the methods can be
|
|
9
|
+
* chained:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* world.hook(Sprite)
|
|
13
|
+
* .onAdd(c => initSprite(c))
|
|
14
|
+
* .onRemove(c => destroySprite(c))
|
|
15
|
+
* .onSet(c => syncSprite(c));
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Callbacks are invoked synchronously during {@link World.runPhase} when
|
|
19
|
+
* archetype changes are flushed.
|
|
20
|
+
*
|
|
21
|
+
* @typeParam C - The `Component` subclass this hook is bound to.
|
|
22
|
+
*/
|
|
23
|
+
export interface Hook<C extends Component = Component> {
|
|
24
|
+
/**
|
|
25
|
+
* Register a callback that fires when a component of this type is added to
|
|
26
|
+
* an entity.
|
|
27
|
+
*
|
|
28
|
+
* @param handler - Receives the newly created component instance.
|
|
29
|
+
* @returns `this` for chaining.
|
|
30
|
+
*/
|
|
31
|
+
onAdd(handler: (c: C) => void): Hook<C>;
|
|
32
|
+
/**
|
|
33
|
+
* Register a callback that fires when a component of this type is removed
|
|
34
|
+
* from an entity (including when the entity is destroyed).
|
|
35
|
+
*
|
|
36
|
+
* @param handler - Receives the component instance being removed.
|
|
37
|
+
* @returns `this` for chaining.
|
|
38
|
+
*/
|
|
39
|
+
onRemove(handler: (c: C) => void): Hook<C>;
|
|
40
|
+
/**
|
|
41
|
+
* Register a callback that fires when {@link Component.modified} is called
|
|
42
|
+
* on a component of this type.
|
|
43
|
+
*
|
|
44
|
+
* @param handler - Receives the component instance that changed.
|
|
45
|
+
* @returns `this` for chaining.
|
|
46
|
+
*/
|
|
47
|
+
onSet(handler: (c: C) => void): Hook<C>;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Internal bookkeeping record for a registered component class.
|
|
51
|
+
*
|
|
52
|
+
* Every component class that is passed to {@link World.registerComponent} gets
|
|
53
|
+
* a `ComponentMeta` that maps it to a numeric type id, a string name, and a
|
|
54
|
+
* pre-computed {@link BitPtr} used for fast archetype checks.
|
|
55
|
+
*
|
|
56
|
+
* `ComponentMeta` also implements {@link Hook}, so you can attach lifecycle
|
|
57
|
+
* callbacks directly on the meta object (as `World.hook()` returns it).
|
|
58
|
+
*/
|
|
59
|
+
export declare class ComponentMeta implements Hook<Component> {
|
|
60
|
+
/** The component class constructor. */
|
|
61
|
+
readonly Class: typeof Component;
|
|
62
|
+
/** Numeric type id assigned at registration time. */
|
|
63
|
+
readonly type: number;
|
|
64
|
+
/** Human-readable name used in logs and serialization lookups. */
|
|
65
|
+
readonly componentName: string;
|
|
66
|
+
/** Pre-computed bit-pointer into the entity archetype {@link Bitset}. */
|
|
67
|
+
readonly bitPtr: BitPtr;
|
|
68
|
+
private onAddHandler;
|
|
69
|
+
private onRemoveHandler;
|
|
70
|
+
private onSetHandler;
|
|
71
|
+
constructor(Class: typeof Component, type: number, componentName: string);
|
|
72
|
+
/** @inheritdoc */
|
|
73
|
+
onAdd(handler: (c: Component) => void): ComponentMeta;
|
|
74
|
+
/** @inheritdoc */
|
|
75
|
+
onRemove(handler: (c: Component) => void): ComponentMeta;
|
|
76
|
+
/** @inheritdoc */
|
|
77
|
+
onSet(handler: (c: Component) => void): ComponentMeta;
|
|
78
|
+
}
|
|
79
|
+
/** A component class constructor or its numeric type id. */
|
|
80
|
+
export type ComponentClassOrType = number | typeof Component;
|
|
81
|
+
/** An array of component class constructors or type ids. */
|
|
82
|
+
export type ComponentClassArray = ComponentClassOrType[];
|
|
83
|
+
/**
|
|
84
|
+
* Base class for all ECS components.
|
|
85
|
+
*
|
|
86
|
+
* Extend this class to define data that can be attached to an {@link Entity}:
|
|
87
|
+
*
|
|
88
|
+
* ```ts
|
|
89
|
+
* class Position extends Component {
|
|
90
|
+
* x = 0;
|
|
91
|
+
* y = 0;
|
|
92
|
+
* }
|
|
93
|
+
*
|
|
94
|
+
* world.registerComponent(Position);
|
|
95
|
+
* const pos = entity.add(Position);
|
|
96
|
+
* pos.x = 100;
|
|
97
|
+
* pos.modified(); // notify watching systems
|
|
98
|
+
* ```
|
|
99
|
+
*
|
|
100
|
+
* A component instance is always bound to a single entity and is created by
|
|
101
|
+
* the world when {@link Entity.add} is called.
|
|
102
|
+
*/
|
|
103
|
+
export declare class Component {
|
|
104
|
+
/** The entity this component belongs to. */
|
|
105
|
+
readonly entity: Entity;
|
|
106
|
+
/** Registration metadata (type id, name, bit-pointer). */
|
|
107
|
+
readonly meta: ComponentMeta;
|
|
108
|
+
private dirty;
|
|
109
|
+
constructor(
|
|
110
|
+
/** The entity this component belongs to. */
|
|
111
|
+
entity: Entity,
|
|
112
|
+
/** Registration metadata (type id, name, bit-pointer). */
|
|
113
|
+
meta: ComponentMeta);
|
|
114
|
+
/** Numeric type id — shorthand for `this.meta.type`. */
|
|
115
|
+
get type(): number;
|
|
116
|
+
/** Pre-computed bit-pointer — shorthand for `this.meta.bitPtr`. */
|
|
117
|
+
get bitPtr(): BitPtr;
|
|
118
|
+
/**
|
|
119
|
+
* Notify the world that this component's data has changed.
|
|
120
|
+
*
|
|
121
|
+
* Queues the component for delivery to all {@link System.update} callbacks
|
|
122
|
+
* that watch this component type. Call this after mutating the component's
|
|
123
|
+
* fields to ensure systems react to the new values.
|
|
124
|
+
*/
|
|
125
|
+
modified(): void;
|
|
126
|
+
/** Returns the component's registered name, e.g. `"Position"`. */
|
|
127
|
+
toString(): string;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Compute a {@link Bitset} that has a bit set for every component class or
|
|
131
|
+
* type id in `classes`.
|
|
132
|
+
*
|
|
133
|
+
* @internal Used internally to build archetype masks for system queries.
|
|
134
|
+
*/
|
|
135
|
+
export declare function calculateComponentBitmask(classes: ComponentClassArray, world: World): Bitset;
|