aiecsjs 0.1.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/CHANGELOG.md +102 -0
- package/LICENSE +21 -0
- package/README.md +881 -0
- package/README_ZHTW.md +891 -0
- package/STABILITY.md +145 -0
- package/STABILITY_ZHTW.md +145 -0
- package/api.json +1087 -0
- package/dist/commands.cjs +2 -0
- package/dist/commands.cjs.map +1 -0
- package/dist/commands.d.cts +7 -0
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +2 -0
- package/dist/commands.js.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +43 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/loop.cjs +2 -0
- package/dist/loop.cjs.map +1 -0
- package/dist/loop.d.cts +13 -0
- package/dist/loop.d.ts +13 -0
- package/dist/loop.js +2 -0
- package/dist/loop.js.map +1 -0
- package/dist/observers.cjs +2 -0
- package/dist/observers.cjs.map +1 -0
- package/dist/observers.d.cts +8 -0
- package/dist/observers.d.ts +8 -0
- package/dist/observers.js +2 -0
- package/dist/observers.js.map +1 -0
- package/dist/relations.cjs +2 -0
- package/dist/relations.cjs.map +1 -0
- package/dist/relations.d.cts +11 -0
- package/dist/relations.d.ts +11 -0
- package/dist/relations.js +2 -0
- package/dist/relations.js.map +1 -0
- package/dist/serialize.cjs +2 -0
- package/dist/serialize.cjs.map +1 -0
- package/dist/serialize.d.cts +9 -0
- package/dist/serialize.d.ts +9 -0
- package/dist/serialize.js +2 -0
- package/dist/serialize.js.map +1 -0
- package/dist/types-Bbv2u6kb.d.cts +227 -0
- package/dist/types-Bbv2u6kb.d.ts +227 -0
- package/dist/worker.cjs +2 -0
- package/dist/worker.cjs.map +1 -0
- package/dist/worker.d.cts +10 -0
- package/dist/worker.d.ts +10 -0
- package/dist/worker.js +2 -0
- package/dist/worker.js.map +1 -0
- package/docs/MIGRATION.md +252 -0
- package/docs/MIGRATION_ZHTW.md +252 -0
- package/llms-full.txt +579 -0
- package/llms.txt +31 -0
- package/package.json +93 -0
package/README_ZHTW.md
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
# aiecsjs
|
|
2
|
+
|
|
3
|
+
[English](README.md) | [繁體中文](README_ZHTW.md)
|
|
4
|
+
|
|
5
|
+
[](LICENSE)
|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
> 為 TypeScript 而設計的原型式 ECS,支援瀏覽器與 Node,內建 SAB 快照傳輸(snapshot transport)與 AI 可讀文件。
|
|
12
|
+
|
|
13
|
+
aiecsjs 採用 **原型表格搭配 TypedArray 欄位** 與 **位元遮罩查詢**,這正是 piecs 與 wolf-ecs 在公開效能評測中名列前茅所採用的架構。API 為 **函式式且可 tree-shake**,以 `pipe()` 組合。元件(Component)同時支援 SoA(結構陣列)與 AoS(結構物件)兩種佈局。0.1 的實體 ID 為純索引值;世代計數在內部追蹤槽位重用,但不編入 ID。具 ABA 安全的 `EntityRef` 預計 0.2 推出。
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createWorld, createEntity, defineComponent, defineQuery, pipe, forEachEntity, Types } from 'aiecsjs'
|
|
17
|
+
|
|
18
|
+
const Position = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
19
|
+
const Velocity = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
20
|
+
|
|
21
|
+
const world = createWorld()
|
|
22
|
+
const eid = createEntity(world)
|
|
23
|
+
addComponent(world, eid, Position, { x: 0, y: 0 })
|
|
24
|
+
addComponent(world, eid, Velocity, { x: 1, y: 2 })
|
|
25
|
+
|
|
26
|
+
const movers = defineQuery([Position, Velocity])
|
|
27
|
+
const movement = (w, dt) => { forEachEntity(w, movers, (e, pos, vel) => { pos.x[e] += vel.x[e] * dt; pos.y[e] += vel.y[e] * dt }); return w }
|
|
28
|
+
|
|
29
|
+
pipe(movement)(world, 1/60)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
> **狀態:實驗版(v0.1.x)。** `STABILITY.md` 中載明的 API 表面在 0.x 系列內承諾穩定,但仍可能微調。1.0 穩定凍結會在收集社群回饋後執行。
|
|
33
|
+
|
|
34
|
+
## 目錄
|
|
35
|
+
|
|
36
|
+
- [為什麼選 aiecsjs?](#為什麼選-aiecsjs)
|
|
37
|
+
- [安裝](#安裝)
|
|
38
|
+
- [快速上手](#快速上手)
|
|
39
|
+
- [核心概念](#核心概念)
|
|
40
|
+
- [使用指引](#使用指引)
|
|
41
|
+
- [API 參考](#api-參考)
|
|
42
|
+
- [效能](#效能)
|
|
43
|
+
- [多執行緒指引](#多執行緒指引)
|
|
44
|
+
- [WebGPU 互通](#webgpu-互通)
|
|
45
|
+
- [序列化指引](#序列化指引)
|
|
46
|
+
- [移轉指引](#移轉指引)
|
|
47
|
+
- [給 AI 助手](#給-ai-助手)
|
|
48
|
+
- [常見問答](#常見問答)
|
|
49
|
+
- [注意事項與已知限制](#注意事項與已知限制)
|
|
50
|
+
- [貢獻](#貢獻)
|
|
51
|
+
- [變更紀錄](#變更紀錄)
|
|
52
|
+
- [授權](#授權)
|
|
53
|
+
|
|
54
|
+
## 為什麼選 aiecsjs?
|
|
55
|
+
|
|
56
|
+
- **原型優先儲存** — 擁有相同元件集合的實體共用一張連續表格;查詢時直接以 `for` 迴圈走訪平行的 TypedArray。從架構上就保證快取友善。
|
|
57
|
+
- **零設定的 TypeScript 推導** — `defineQuery([Position, Velocity])` 回傳的迭代器會丟出 `(eid, posCols, velCols)`,欄位型別自動從 `defineComponent` 推導。不必手寫泛型。
|
|
58
|
+
- **AI 優先的文件契約** — 每個公開匯出都有穩定度標籤與 `since` 版本。內建 `llms.txt`、`llms-full.txt`、`api.json`,讓 LLM 工具直接讀懂 API 表面。
|
|
59
|
+
|
|
60
|
+
### 與其他函式庫比較
|
|
61
|
+
|
|
62
|
+
| 項目 | aiecsjs 0.1 | bitECS 0.4 | miniplex 2.0 | becsy 0.15 |
|
|
63
|
+
|---|---|---|---|---|
|
|
64
|
+
| 儲存模型 | 原型 + SoA 欄位 | SparseSet + bitmask + SoA/AoS | 原型 + JS 物件 | 可選(packed/sparse/compact)+ ArrayBuffer |
|
|
65
|
+
| API 風格 | 函式式 + `pipe` | 函式式 + `pipe` | 鏈式 OO | 裝飾器 class |
|
|
66
|
+
| 查詢 TS 推導 | 欄位元組支援 | 手動 | 述詞推導 | class-based |
|
|
67
|
+
| 多執行緒 | SAB 快照傳輸(0.1);真共享欄位預計 0.2 | SAB-ready,排程自理 | 單執行緒 | Roadmap(未實作) |
|
|
68
|
+
| AI 文件 | `llms.txt` + `llms-full.txt` + `api.json` | 無 | 無 | 無 |
|
|
69
|
+
| 維護狀態 | 活躍(新) | 活躍 | 趨緩(npm 已 ~3 年) | 活躍 |
|
|
70
|
+
|
|
71
|
+
### 適合的場景
|
|
72
|
+
|
|
73
|
+
- 渲染導向、模擬導向、實體 ≥ 1 萬的應用
|
|
74
|
+
- 需要 SharedArrayBuffer 跨 Worker 共享 world
|
|
75
|
+
- 想要 TypeScript 自動推導查詢結果型別
|
|
76
|
+
- 希望讓 AI 助手能精確生成程式碼
|
|
77
|
+
|
|
78
|
+
### 不適合的場景
|
|
79
|
+
|
|
80
|
+
- **追求極致小包體(≤ 3 kB)。** 改用 [bitECS 0.4](https://github.com/NateTheGreatt/bitECS),其 SparseSet 模型較精簡且 tree-shake 效果更好。
|
|
81
|
+
- **想用任意 JS 物件當實體、追求最大 DX 彈性。** 改用 [miniplex](https://github.com/hmans/miniplex)。它是 DX 冠軍,代價是 2-4 倍的迭代開銷。
|
|
82
|
+
- **需要自動排程系統並宣告 read/write 權限。** 改用 [@lastolivegames/becsy](https://github.com/LastOliveGames/becsy)。aiecsjs 的系統只是 `pipe()` 順序的函式。
|
|
83
|
+
- **工作負載以實體變動為主(> 50% 實體每幀變動)。** Sparse-set 風格的 ECS 在此情境會勝過原型式 ECS。改用 bitECS 或 goodluck。
|
|
84
|
+
|
|
85
|
+
### aiecsjs 明確不做的事
|
|
86
|
+
|
|
87
|
+
核心刻意保持窄範圍。以下為明確的非目標;請改用專屬工具或在應用層自行處理:
|
|
88
|
+
|
|
89
|
+
- **自動排程系統並宣告 read/write 權限。** `pipe()` 依宣告順序執行系統。需要並行排程請改用 `@lastolivegames/becsy`。
|
|
90
|
+
- **渲染元件 / 場景圖同步。** ECS 只持有資料。請搭配 PixiJS、Three.js 或其他渲染器。
|
|
91
|
+
- **物理 / 空間分割。** 無 broad-phase、無碰撞偵測。請使用 Rapier、Matter 或專用 quadtree。
|
|
92
|
+
- **網路複製。** `aiecsjs/serialize` 產生快照 byte 流;如何送上線路由應用層決定。
|
|
93
|
+
- **反應式 value-predicate query。** `enterQuery` / `exitQuery` 只在元件集合 membership 變動時觸發。元件值變動不追蹤。
|
|
94
|
+
- **Prefab / 實體繼承 / 階層。** `aiecsjs/relations` 提供純粹的實體對實體關聯,不是繼承。
|
|
95
|
+
|
|
96
|
+
## 安裝
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npm install aiecsjs
|
|
100
|
+
pnpm add aiecsjs
|
|
101
|
+
yarn add aiecsjs
|
|
102
|
+
bun add aiecsjs
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
CDN (ESM):
|
|
106
|
+
|
|
107
|
+
```html
|
|
108
|
+
<script type="module">
|
|
109
|
+
import { createWorld } from 'https://unpkg.com/aiecsjs?module'
|
|
110
|
+
</script>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
執行環境需求:**Node 18+**(為了 ESM 與 WebStreams),**TypeScript 5.0+**(選用但建議,可享受推導紅利)。
|
|
114
|
+
|
|
115
|
+
## 快速上手
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import {
|
|
119
|
+
createWorld, createEntity, destroyEntity,
|
|
120
|
+
defineComponent, addComponent, removeComponent,
|
|
121
|
+
defineQuery, forEachEntity, pipe, Types,
|
|
122
|
+
} from 'aiecsjs'
|
|
123
|
+
import { createLoop } from 'aiecsjs/loop'
|
|
124
|
+
|
|
125
|
+
const Position = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
126
|
+
const Velocity = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
127
|
+
const Lifetime = defineComponent({ remaining: Types.f32 })
|
|
128
|
+
|
|
129
|
+
const world = createWorld({ initialCapacity: 1024 })
|
|
130
|
+
|
|
131
|
+
// 生成 100 個粒子
|
|
132
|
+
for (let i = 0; i < 100; i++) {
|
|
133
|
+
const e = createEntity(world)
|
|
134
|
+
addComponent(world, e, Position, { x: Math.random() * 100, y: Math.random() * 100 })
|
|
135
|
+
addComponent(world, e, Velocity, { x: Math.random() * 2 - 1, y: Math.random() * 2 - 1 })
|
|
136
|
+
addComponent(world, e, Lifetime, { remaining: 5 })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const movers = defineQuery([Position, Velocity])
|
|
140
|
+
const decaying = defineQuery([Lifetime])
|
|
141
|
+
|
|
142
|
+
const movementSystem = (w, dt) => {
|
|
143
|
+
forEachEntity(w, movers, (e, pos, vel) => {
|
|
144
|
+
pos.x[e] += vel.x[e] * dt
|
|
145
|
+
pos.y[e] += vel.y[e] * dt
|
|
146
|
+
})
|
|
147
|
+
return w
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const lifetimeSystem = (w, dt) => {
|
|
151
|
+
forEachEntity(w, decaying, (e, life) => {
|
|
152
|
+
life.remaining[e] -= dt
|
|
153
|
+
if (life.remaining[e] <= 0) destroyEntity(w, e)
|
|
154
|
+
})
|
|
155
|
+
return w
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const tick = pipe(movementSystem, lifetimeSystem)
|
|
159
|
+
const loop = createLoop({ fixed: 1 / 60, onUpdate: (dt) => tick(world, dt) })
|
|
160
|
+
loop.start()
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
完整模擬:100 個粒子飄移直到各自的壽命結束。
|
|
164
|
+
|
|
165
|
+
## 核心概念
|
|
166
|
+
|
|
167
|
+
**實體(Entity)** — 一個含世代計數的 32 位元整數 ID。低位為實體索引;高位為當 ID 被回收時遞增的世代計數器。這能防止「我快取了 entity 42,可是 entity 42 已經不是同一個東西了」這類錯誤。預設切分為 24 索引位元 + 8 世代位元(約 16M 實體 × 各 256 次回收)。
|
|
168
|
+
|
|
169
|
+
**元件(Component)** — 附加在實體上的資料型態,有兩種風味:
|
|
170
|
+
- **SoA(結構陣列)** — 用 `defineComponent({ x: Types.f32, y: Types.f32 })` 宣告。每個欄位變成一個 TypedArray,以實體 ID 索引。適合熱資料、數值資料。
|
|
171
|
+
- **AoS(結構物件)** — 用 `defineObjectComponent(() => ({ ref: null }))` 宣告。每個實體配發一個獨立的 JS 物件。適合異質資料或外部參考(例如 `three.js` 的 Mesh)。
|
|
172
|
+
|
|
173
|
+
**系統(System)** — 就是一個函式:`(world, ctx) => world`。沒有 base class,也沒有裝飾器。多個系統用 `pipe()` 組合。回傳的 world 就是同一個 world 參考 — `pipe` 具結合律,world 是原地變動。
|
|
174
|
+
|
|
175
|
+
**查詢(Query)** — 對元件集合的持久描述:`defineQuery({ all: [Position], any: [Active, Visible], none: [Hidden] })`。查詢會被預編譯成位元遮罩對並快取於 world;迭代成本為 O(符合的原型),非 O(全部實體)。
|
|
176
|
+
|
|
177
|
+
**世界(World)** — 擁有所有實體、元件、原型、查詢索引。支援多個 world;除非主動透過 `SharedArrayBuffer` 共享,否則它們不共用實體 ID。
|
|
178
|
+
|
|
179
|
+
**原型(Archetype)** — 內部表格,每組獨特的元件組合對應一張表。實體增/減元件時會在原型之間遷移。遷移成本與該實體的元件數成正比;迭代成本則無關。
|
|
180
|
+
|
|
181
|
+
## 使用指引
|
|
182
|
+
|
|
183
|
+
### 定義元件
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
// SoA:TypedArray 儲存,效能最大,可放入 SAB
|
|
187
|
+
const Position = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
188
|
+
|
|
189
|
+
// SoA 含固定長度向量欄位
|
|
190
|
+
const Transform = defineComponent({
|
|
191
|
+
position: [Types.f32, 3], // 每實體一個長度 3 的 Float32Array
|
|
192
|
+
scale: Types.f32,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Tag:零位元的標記,無資料
|
|
196
|
+
const Player = defineTag()
|
|
197
|
+
const Dead = defineTag()
|
|
198
|
+
|
|
199
|
+
// AoS:任意 JS 物件,僅主執行緒可用
|
|
200
|
+
const MeshRef = defineObjectComponent<{ mesh: THREE.Mesh | null }>(() => ({ mesh: null }))
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### 生成與銷毀實體
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
const eid = createEntity(world)
|
|
207
|
+
addComponent(world, eid, Position, { x: 10, y: 20 })
|
|
208
|
+
addComponent(world, eid, Player)
|
|
209
|
+
|
|
210
|
+
if (entityExists(world, eid)) {
|
|
211
|
+
destroyEntity(world, eid)
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`destroyEntity` 會立即遞增該實體的世代,因此任何快取下來的 `EntityId` 在下次 `entityExists` 檢查時就會失效。
|
|
216
|
+
|
|
217
|
+
### 撰寫系統
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
const moveSystem = (world: World, dt: number) => {
|
|
221
|
+
forEachEntity(world, defineQuery([Position, Velocity]), (e, pos, vel) => {
|
|
222
|
+
pos.x[e] += vel.x[e] * dt
|
|
223
|
+
pos.y[e] += vel.y[e] * dt
|
|
224
|
+
})
|
|
225
|
+
return world
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
務必把 `defineQuery(...)` 拉出熱迴圈 — 同樣的元件集合會回傳同一個 query 物件,但查找仍要算一次 hash。
|
|
230
|
+
|
|
231
|
+
### 用 pipe 與 createLoop 組合
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
import { createLoop } from 'aiecsjs/loop'
|
|
235
|
+
|
|
236
|
+
const tick = pipe(inputSystem, physicsSystem, movementSystem, renderSystem)
|
|
237
|
+
|
|
238
|
+
const loop = createLoop({
|
|
239
|
+
fixed: 1 / 60,
|
|
240
|
+
maxSubSteps: 5,
|
|
241
|
+
onUpdate: (dt) => tick(world, dt),
|
|
242
|
+
onRender: (alpha) => renderInterpolated(world, alpha),
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
loop.start()
|
|
246
|
+
// 之後可呼叫 loop.stop()
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`createLoop` 採用 `gafferongames.com` 介紹的標準累加器固定時步模型 — 物理層具確定性,獨立於可變化的畫面更新率。
|
|
250
|
+
|
|
251
|
+
### 反應式查詢(enter/exit)
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
const newlyDead = enterQuery(defineQuery([Dead]))
|
|
255
|
+
const noLongerDead = exitQuery(defineQuery([Dead]))
|
|
256
|
+
|
|
257
|
+
const reapSystem = (world) => {
|
|
258
|
+
forEachEntity(world, newlyDead, (e) => playDeathAnimation(e))
|
|
259
|
+
forEachEntity(world, noLongerDead, (e) => stopDeathAnimation(e))
|
|
260
|
+
return world
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
`enterQuery` 只丟出本幀新匹配的實體;`exitQuery` 只丟出本幀剛離開匹配的實體。兩者皆在結構變動時增量計算,沒有每幀掃描。
|
|
265
|
+
|
|
266
|
+
### Observer
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
import { onAdd, onRemove, onSet } from 'aiecsjs/observers'
|
|
270
|
+
|
|
271
|
+
const stopAdd = onAdd(world, Position, (e) => console.log('positioned', e))
|
|
272
|
+
const stopRemove = onRemove(world, Player, (e) => console.log('un-playered', e))
|
|
273
|
+
const stopSet = onSet(world, Health, (e, val) => console.log('health set', e, val))
|
|
274
|
+
|
|
275
|
+
// 之後解除
|
|
276
|
+
stopAdd()
|
|
277
|
+
stopRemove()
|
|
278
|
+
stopSet()
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Observer 會在變動呼叫內同步觸發。用於需要在變動發生瞬間執行的副作用(除錯、複製)。對於要批次的 UI 更新,請改用反應式查詢。
|
|
282
|
+
|
|
283
|
+
### Command buffer:何時與為何
|
|
284
|
+
|
|
285
|
+
黃金法則:**不要在正在迭代的實體上新增或移除元件。** 這樣做可能讓某些實體被跳過或被處理兩次,因為原型歸屬中途改變了。請用 command buffer 延後執行:
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
import { withCommandBuffer } from 'aiecsjs/commands'
|
|
289
|
+
|
|
290
|
+
const damageSystem = (world) => {
|
|
291
|
+
const dying = defineQuery([Health])
|
|
292
|
+
withCommandBuffer(world, (cb) => {
|
|
293
|
+
forEachEntity(world, dying, (e, health) => {
|
|
294
|
+
if (health.hp[e] <= 0) cb.destroy(e)
|
|
295
|
+
})
|
|
296
|
+
}) // 區塊結束時自動 flush
|
|
297
|
+
return world
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
或手動:
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
import { createCommandBuffer, flush } from 'aiecsjs/commands'
|
|
305
|
+
|
|
306
|
+
const cb = createCommandBuffer(world)
|
|
307
|
+
forEachEntity(world, q, (e) => { cb.remove(e, SomeTag) })
|
|
308
|
+
flush(cb)
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Relations 與階層(實驗中)
|
|
312
|
+
|
|
313
|
+
> ⚠️ Relations API 已在 0.1 實作,但標記為 `experimental`;signature 可能在 0.3 穩定化之前微調。
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
import { defineRelation, addRelation, ChildOf, getRelationTargets } from 'aiecsjs/relations'
|
|
317
|
+
|
|
318
|
+
const Likes = defineRelation()
|
|
319
|
+
addRelation(world, alice, Likes, bob)
|
|
320
|
+
addRelation(world, alice, ChildOf, parent)
|
|
321
|
+
|
|
322
|
+
const parentOfAlice = getRelationTargets(world, alice, ChildOf)
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
0.2 版會加入獨佔關係(只允許一個目標)、wildcard 查詢、以及關係圖的序列化。
|
|
326
|
+
|
|
327
|
+
## API 參考
|
|
328
|
+
|
|
329
|
+
完整機器可讀表面在 [`api.json`](./api.json)。各 export 穩定度在 [`STABILITY.md`](./STABILITY.md)(中文:[`STABILITY_ZHTW.md`](./STABILITY_ZHTW.md))。
|
|
330
|
+
|
|
331
|
+
### World — `aiecsjs`
|
|
332
|
+
|
|
333
|
+
| 函式 | Signature | 穩定度 |
|
|
334
|
+
|---|---|---|
|
|
335
|
+
| `createWorld` | `(options?: WorldOptions) => World` | stable |
|
|
336
|
+
| `destroyWorld` | `(world: World) => void` | stable |
|
|
337
|
+
| `resetWorld` | `(world: World) => void` | stable |
|
|
338
|
+
| `getWorldSize` | `(world: World) => number`(存活實體數) | stable |
|
|
339
|
+
| `getWorldCapacity` | `(world: World) => number` | stable |
|
|
340
|
+
|
|
341
|
+
`WorldOptions`:
|
|
342
|
+
```ts
|
|
343
|
+
type WorldOptions = {
|
|
344
|
+
initialCapacity?: number // 預設 1024
|
|
345
|
+
maxEntities?: number // 預設 1_000_000
|
|
346
|
+
indexBits?: 20 | 24 // 預設 24 → 16M 實體
|
|
347
|
+
generationBits?: 8 | 12 | 16 // 預設 8 → 256 次回收
|
|
348
|
+
buffer?: SharedArrayBuffer // 啟用 SAB 儲存
|
|
349
|
+
bufferByteOffset?: number // 一個 SAB 給多個 world 用時的位移
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### Entity — `aiecsjs`
|
|
354
|
+
|
|
355
|
+
| 函式 | Signature | 穩定度 |
|
|
356
|
+
|---|---|---|
|
|
357
|
+
| `createEntity` | `(world: World) => EntityId` | stable |
|
|
358
|
+
| `destroyEntity` | `(world: World, eid: EntityId) => void` | stable |
|
|
359
|
+
| `entityExists` | `(world: World, eid: EntityId) => boolean` | stable |
|
|
360
|
+
| `getEntityIndex` | `(eid: EntityId) => number` | stable |
|
|
361
|
+
| `getEntityGeneration` | `(eid: EntityId) => number` | stable |
|
|
362
|
+
| `packEntity` | `(index: number, generation: number) => EntityId` | stable |
|
|
363
|
+
|
|
364
|
+
### Component — `aiecsjs`
|
|
365
|
+
|
|
366
|
+
| 函式 | Signature | 穩定度 |
|
|
367
|
+
|---|---|---|
|
|
368
|
+
| `defineComponent` | `<S extends SoASchema>(schema: S) => SoAComponent<S>` | stable |
|
|
369
|
+
| `defineTag` | `() => TagComponent` | stable |
|
|
370
|
+
| `defineObjectComponent` | `<T>(factory?: () => T) => AoSComponent<T>` | stable |
|
|
371
|
+
| `addComponent` | `<C>(world, eid, c: C, init?) => void` | stable |
|
|
372
|
+
| `removeComponent` | `<C>(world, eid, c: C) => void` | stable |
|
|
373
|
+
| `hasComponent` | `<C>(world, eid, c: C) => boolean` | stable |
|
|
374
|
+
| `getComponent` | `<C>(world, eid, c: C) => ComponentView<C>` | stable |
|
|
375
|
+
| `setComponent` | `<C, V>(world, eid, c: C, v: V) => void` | stable |
|
|
376
|
+
|
|
377
|
+
`Types`:
|
|
378
|
+
```ts
|
|
379
|
+
const Types = { i8, u8, i16, u16, i32, u32, f32, f64, eid, bool } as const
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### Query — `aiecsjs`
|
|
383
|
+
|
|
384
|
+
| 函式 | Signature | 穩定度 |
|
|
385
|
+
|---|---|---|
|
|
386
|
+
| `defineQuery` | `(components: ComponentLike[] \| QueryDescriptor) => Query` | stable |
|
|
387
|
+
| `runQuery` | `(world: World, q: Query) => readonly EntityId[]` | stable |
|
|
388
|
+
| `forEachEntity` | `<Q>(world, q: Q, fn: (eid, ...cols) => void) => void` | stable |
|
|
389
|
+
| `iterQuery` | `(world, q) => IterableIterator<EntityId>` | stable |
|
|
390
|
+
| `enterQuery` | `(q: Query) => Query` | stable |
|
|
391
|
+
| `exitQuery` | `(q: Query) => Query` | stable |
|
|
392
|
+
| `queryArchetypes` | `(world, q) => readonly Archetype[]` | experimental |
|
|
393
|
+
|
|
394
|
+
### System — `aiecsjs`
|
|
395
|
+
|
|
396
|
+
| 函式 | Signature | 穩定度 |
|
|
397
|
+
|---|---|---|
|
|
398
|
+
| `pipe` | `<W, Ctx>(...systems) => System<W, Ctx>` | stable |
|
|
399
|
+
| `System`(型別) | `(world, ctx) => world` | stable |
|
|
400
|
+
|
|
401
|
+
### Loop — `aiecsjs/loop`
|
|
402
|
+
|
|
403
|
+
| 函式 | Signature | 穩定度 |
|
|
404
|
+
|---|---|---|
|
|
405
|
+
| `createLoop` | `(opts) => { start(), stop() }` | stable |
|
|
406
|
+
|
|
407
|
+
### Command buffer — `aiecsjs/commands`
|
|
408
|
+
|
|
409
|
+
| 函式 | Signature | 穩定度 |
|
|
410
|
+
|---|---|---|
|
|
411
|
+
| `createCommandBuffer` | `(world) => CommandBuffer` | stable |
|
|
412
|
+
| `flush` | `(cb: CommandBuffer) => void` | stable |
|
|
413
|
+
| `withCommandBuffer` | `<R>(world, fn: (cb) => R) => R` | stable |
|
|
414
|
+
|
|
415
|
+
### Observer — `aiecsjs/observers`
|
|
416
|
+
|
|
417
|
+
| 函式 | Signature | 穩定度 |
|
|
418
|
+
|---|---|---|
|
|
419
|
+
| `observe` | `(world, q, event, handler) => () => void` | stable |
|
|
420
|
+
| `onAdd` | `(world, comp, handler) => () => void` | stable |
|
|
421
|
+
| `onRemove` | `(world, comp, handler) => () => void` | stable |
|
|
422
|
+
| `onSet` | `(world, comp, handler) => () => void` | stable |
|
|
423
|
+
|
|
424
|
+
### 序列化 — `aiecsjs/serialize`
|
|
425
|
+
|
|
426
|
+
| 函式 | Signature | 穩定度 |
|
|
427
|
+
|---|---|---|
|
|
428
|
+
| `serializeWorld` | `(world, opts?) => Uint8Array` | stable |
|
|
429
|
+
| `deserializeWorld` | `(bytes, opts?) => World` | stable |
|
|
430
|
+
| `toJSON` | `(world) => WorldSnapshot` | stable |
|
|
431
|
+
| `fromJSON` | `(snap) => World` | stable |
|
|
432
|
+
| `createDeltaSerializer` | `(world, opts?) => DeltaSerializer` | experimental |
|
|
433
|
+
|
|
434
|
+
### Worker / SAB — `aiecsjs/worker`
|
|
435
|
+
|
|
436
|
+
| 函式 | Signature | 穩定度 |
|
|
437
|
+
|---|---|---|
|
|
438
|
+
| `transferableSnapshot` | `(world) => { buffer, meta }` | experimental |
|
|
439
|
+
| `adoptSnapshot` | `(snap) => World` | experimental |
|
|
440
|
+
| `attachWorld` | `(buffer, opts?) => World` | experimental |
|
|
441
|
+
| `detachWorld` | `(world) => void` | experimental |
|
|
442
|
+
|
|
443
|
+
### Relations — `aiecsjs/relations`(實驗中)
|
|
444
|
+
|
|
445
|
+
| 函式 | Signature | 穩定度 |
|
|
446
|
+
|---|---|---|
|
|
447
|
+
| `defineRelation` | `<T>(opts?) => Relation<T>` | experimental |
|
|
448
|
+
| `addRelation` | `(world, src, rel, tgt, data?) => void` | experimental |
|
|
449
|
+
| `removeRelation` | `(world, src, rel, tgt) => void` | experimental |
|
|
450
|
+
| `getRelationTargets` | `(world, src, rel) => readonly EntityId[]` | experimental |
|
|
451
|
+
| `ChildOf`(常數) | `Relation` | experimental |
|
|
452
|
+
|
|
453
|
+
### 工具 — `aiecsjs`
|
|
454
|
+
|
|
455
|
+
| 匯出 | 型別 | 穩定度 |
|
|
456
|
+
|---|---|---|
|
|
457
|
+
| `VERSION` | `string` | stable |
|
|
458
|
+
| `IS_SAB_SUPPORTED` | `boolean` | stable |
|
|
459
|
+
| `isWorld` | `(x: unknown) => x is World` | stable |
|
|
460
|
+
| `isEntity` | `(world, x) => x is EntityId` | stable |
|
|
461
|
+
|
|
462
|
+
## 效能
|
|
463
|
+
|
|
464
|
+
### 儲存模型
|
|
465
|
+
|
|
466
|
+
```
|
|
467
|
+
World
|
|
468
|
+
├── Archetype 0: [] (空實體)
|
|
469
|
+
├── Archetype 1: [Position]
|
|
470
|
+
│ ├── entities: Uint32Array [e1, e2, e3, ...]
|
|
471
|
+
│ └── columns: Position.x: Float32Array, Position.y: Float32Array
|
|
472
|
+
├── Archetype 2: [Position, Velocity]
|
|
473
|
+
│ ├── entities: Uint32Array [e4, e5, ...]
|
|
474
|
+
│ ├── columns: Position.x, Position.y, Velocity.x, Velocity.y
|
|
475
|
+
└── Archetype 3: [Position, Velocity, Health]
|
|
476
|
+
└── ...
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
對 `(Position, Velocity)` 的查詢只匹配到 Archetype 2 與 3,分別線性走訪它們。每個 archetype 的欄位都是連續的 `Float32Array` — JIT 可向量化內迴圈,L1 快取命中率接近 100%。
|
|
480
|
+
|
|
481
|
+
### 成本模型
|
|
482
|
+
|
|
483
|
+
- **迭代**:`O(符合的 archetype × 每個 archetype 中的實體數)`,archetype 清單解析完之後就幾乎沒有 per-entity 額外開銷。解析會被 query cache 攤平。
|
|
484
|
+
- **新增 / 移除元件**:`O(該實體上的元件數)`。實體的整列資料會從來源 archetype 的欄位被複製到目標的欄位。若每幀對 N 個實體閃爍切換 tag,那就是每幀 N × (欄位數)次記憶體搬移。
|
|
485
|
+
- **建立查詢**:`O(元件數量)`(在 `defineQuery` 時)。重複用同樣的元件集合會直接回傳快取的 query。
|
|
486
|
+
|
|
487
|
+
### 提示
|
|
488
|
+
|
|
489
|
+
- 把 `defineQuery` 拉出熱迴圈。同樣的元件集合會回傳同一個 query 物件,但查找仍要算一次 hash。
|
|
490
|
+
- 偏好 **批次操作**:用 tight loop 一次 `createEntity` + `addComponent` 生成 1000 個實體;原型遷移每個 shape 只跑一次。
|
|
491
|
+
- 把 **頻繁切換的 tag** 整合成一個穩定元件的布林欄位,而不是反覆 add/remove 一個 tag — 後者會觸發原型遷移。
|
|
492
|
+
- 對極熱的內迴圈,在系統最上方先取一次欄位:`const px = Position.x; const vx = Velocity.x;` 之後直接索引。
|
|
493
|
+
|
|
494
|
+
### 可重現的微基準
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
import { createWorld, createEntity, addComponent, defineComponent, defineQuery, forEachEntity, Types } from 'aiecsjs'
|
|
498
|
+
|
|
499
|
+
const Position = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
500
|
+
const Velocity = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
501
|
+
|
|
502
|
+
const world = createWorld({ initialCapacity: 100_000 })
|
|
503
|
+
for (let i = 0; i < 100_000; i++) {
|
|
504
|
+
const e = createEntity(world)
|
|
505
|
+
addComponent(world, e, Position, { x: 0, y: 0 })
|
|
506
|
+
addComponent(world, e, Velocity, { x: 1, y: 1 })
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const movers = defineQuery([Position, Velocity])
|
|
510
|
+
const start = performance.now()
|
|
511
|
+
for (let frame = 0; frame < 1000; frame++) {
|
|
512
|
+
forEachEntity(world, movers, (e, pos, vel) => {
|
|
513
|
+
pos.x[e] += vel.x[e]; pos.y[e] += vel.y[e]
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
console.log('每幀毫秒:', (performance.now() - start) / 1000)
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### 免責聲明
|
|
520
|
+
|
|
521
|
+
以上提示衍生自公開 ECS 評測(noctjs/ecs-benchmark、ddmills/js-ecs-benchmarks)以及 Cox、Williams、Vickers、Ward、Headleand 在 CGVC 2025 的同儕審查 C++ 比較論文([DOI 10.2312/cgvc.20251224](https://doi.org/10.2312/cgvc.20251224))。在以渲染為主的應用中,ECS 開銷通常只佔每幀時間 1-2%(如 Felix Z 在 Meta Project Flowerbed 的觀察)— 因此挑 aiecsjs 取代較慢 ECS 的實際收益其實不大,除非模擬本身就是瓶頸。請依工作負載挑選 DX 最合適的函式庫。
|
|
522
|
+
|
|
523
|
+
## 多執行緒指引
|
|
524
|
+
|
|
525
|
+
aiecsjs **支援 SharedArrayBuffer**:world 的 archetype 欄位可放在共享記憶體中,並由 Worker 平行迭代。
|
|
526
|
+
|
|
527
|
+
### 能力偵測
|
|
528
|
+
|
|
529
|
+
```ts
|
|
530
|
+
import { IS_SAB_SUPPORTED } from 'aiecsjs'
|
|
531
|
+
if (!IS_SAB_SUPPORTED) {
|
|
532
|
+
console.warn('SAB 無法使用;請檢查 COOP/COEP 標頭')
|
|
533
|
+
}
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
在瀏覽器中,`SharedArrayBuffer` 需要頁面達成 **跨來源隔離**:伺服器需回傳 `Cross-Origin-Opener-Policy: same-origin` 與 `Cross-Origin-Embedder-Policy: require-corp` 標頭。
|
|
537
|
+
|
|
538
|
+
### 主執行緒
|
|
539
|
+
|
|
540
|
+
```ts
|
|
541
|
+
const buffer = new SharedArrayBuffer(64 * 1024 * 1024) // 64 MB
|
|
542
|
+
const world = createWorld({ buffer })
|
|
543
|
+
|
|
544
|
+
// 填入實體與元件 ...
|
|
545
|
+
|
|
546
|
+
const worker = new Worker(new URL('./sim-worker.ts', import.meta.url), { type: 'module' })
|
|
547
|
+
worker.postMessage({ buffer, meta: transferableSnapshot(world).meta })
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
### Worker 端
|
|
551
|
+
|
|
552
|
+
```ts
|
|
553
|
+
// sim-worker.ts
|
|
554
|
+
import { adoptSnapshot, defineComponent, defineQuery, forEachEntity, Types } from 'aiecsjs'
|
|
555
|
+
|
|
556
|
+
const Position = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
557
|
+
const Velocity = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
558
|
+
|
|
559
|
+
self.onmessage = (e) => {
|
|
560
|
+
const world = adoptSnapshot(e.data)
|
|
561
|
+
const movers = defineQuery([Position, Velocity])
|
|
562
|
+
setInterval(() => {
|
|
563
|
+
forEachEntity(world, movers, (e, pos, vel) => {
|
|
564
|
+
pos.x[e] += vel.x[e]
|
|
565
|
+
pos.y[e] += vel.y[e]
|
|
566
|
+
})
|
|
567
|
+
}, 16)
|
|
568
|
+
}
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
### Atomics 與同步
|
|
572
|
+
|
|
573
|
+
對 SAB 內 TypedArray 欄位的讀寫 **預設不是 atomic**。對大多數遊戲迴圈工作,慣例是:每個欄位只有一個寫者執行緒(例如物理 worker 擁有座標),讀者看到最終一致的資料。如果需要嚴格順序,請改用 `Atomics.load` / `Atomics.store`;代價是失去向量化機會。
|
|
574
|
+
|
|
575
|
+
### 陷阱
|
|
576
|
+
|
|
577
|
+
- **AoS 元件不可在 SAB 中共享。** Worker 只看得到 SoA 欄位。請把 AoS 資料留在主執行緒,或改用對應的 SoA 形式。
|
|
578
|
+
- **在 Worker 內 `createEntity` / `destroyEntity` 需要該 worker 擁有 entity index。** 目前 Worker 端建議用 `{ readOnly: true }` 附掛,只變動欄位。
|
|
579
|
+
- **aiecsjs 並未內建同步原語。** 若需要 barrier,自行使用 `Atomics.wait` / `Atomics.notify`。
|
|
580
|
+
|
|
581
|
+
## WebGPU 互通
|
|
582
|
+
|
|
583
|
+
SoA 元件的欄位是 TypedArray,正好可直接餵給 `GPUQueue.writeBuffer`。aiecsjs 並沒有「ECS 跑在 GPU 上」的模式;整合是單向的(CPU 寫、GPU 讀)。
|
|
584
|
+
|
|
585
|
+
```ts
|
|
586
|
+
const Position = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
587
|
+
// 填入 world 後 ...
|
|
588
|
+
|
|
589
|
+
const gpuBuffer = device.createBuffer({
|
|
590
|
+
size: Position.x.byteLength,
|
|
591
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
// 每幀上傳,或只在 archetype 改變時上傳
|
|
595
|
+
device.queue.writeBuffer(gpuBuffer, 0, Position.x)
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### 注意事項
|
|
599
|
+
|
|
600
|
+
- **原型遷移會讓欄位參考失效。** 若某實體遷移到新的 archetype,`Position.x` 中該實體位置的對應 `Float32Array` 已不同。要讓 GPU buffer 穩定,請替欲上傳的實體分配一個固定的 archetype(例如打上絕不移除的 `Renderable` tag),或改用 per-archetype 上傳。
|
|
601
|
+
- **不支援 GPU 寫回 ECS。** GPU 端為唯讀。若需要 GPU 計算結果寫回 CPU 欄位,請手動 map buffer 後寫入。
|
|
602
|
+
- **明確非目標:在 GPU 上跑系統。** aiecsjs 不會把系統編譯為 compute shader。如有此需求請使用專門的 GPU compute 框架。
|
|
603
|
+
|
|
604
|
+
## 序列化指引
|
|
605
|
+
|
|
606
|
+
### 二進位存檔/讀檔
|
|
607
|
+
|
|
608
|
+
```ts
|
|
609
|
+
import { serializeWorld, deserializeWorld } from 'aiecsjs/serialize'
|
|
610
|
+
|
|
611
|
+
const bytes = serializeWorld(world)
|
|
612
|
+
localStorage.setItem('save', btoa(String.fromCharCode(...bytes)))
|
|
613
|
+
|
|
614
|
+
const restored = deserializeWorld(Uint8Array.from(atob(localStorage.getItem('save')!), c => c.charCodeAt(0)))
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
二進位格式 **內含版本戳記**。從較舊 `aiecsjs` 版本來的位元組若能成功遷移就回傳 world,否則拋出。AoS 元件以 JSON 內嵌於二進位 blob 中。
|
|
618
|
+
|
|
619
|
+
### JSON 存檔/讀檔
|
|
620
|
+
|
|
621
|
+
```ts
|
|
622
|
+
import { toJSON, fromJSON } from 'aiecsjs/serialize'
|
|
623
|
+
|
|
624
|
+
const snap = toJSON(world) // 人類可讀
|
|
625
|
+
const restored = fromJSON(snap)
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
比二進位慢、檔案大,但可在 DevTools 中檢視。
|
|
629
|
+
|
|
630
|
+
### 網路差量
|
|
631
|
+
|
|
632
|
+
多人連線時,只想傳上次 tick 之後的變動:
|
|
633
|
+
|
|
634
|
+
```ts
|
|
635
|
+
import { createDeltaSerializer } from 'aiecsjs/serialize'
|
|
636
|
+
|
|
637
|
+
const delta = createDeltaSerializer(world, { components: [Position, Velocity, Health] })
|
|
638
|
+
setInterval(() => {
|
|
639
|
+
const bytes = delta.capture()
|
|
640
|
+
ws.send(bytes)
|
|
641
|
+
}, 50)
|
|
642
|
+
|
|
643
|
+
// 另一端:
|
|
644
|
+
const remoteDelta = createDeltaSerializer(remoteWorld)
|
|
645
|
+
ws.onmessage = (e) => remoteDelta.apply(remoteWorld, new Uint8Array(e.data))
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
> ⚠️ `createDeltaSerializer` 在 0.1 為 `experimental`;wire format 在 1.0 之前可能改變。
|
|
649
|
+
|
|
650
|
+
## 移轉指引
|
|
651
|
+
|
|
652
|
+
完整表格請見 [`docs/MIGRATION.md`](./docs/MIGRATION.md) 與 [`docs/MIGRATION_ZHTW.md`](./docs/MIGRATION_ZHTW.md)。
|
|
653
|
+
|
|
654
|
+
### 從 bitECS 0.4 移轉
|
|
655
|
+
|
|
656
|
+
| bitECS | aiecsjs |
|
|
657
|
+
|---|---|
|
|
658
|
+
| `createWorld()` | `createWorld()` |
|
|
659
|
+
| `defineComponent({ x: Types.f32 })` | `defineComponent({ x: Types.f32 })` |
|
|
660
|
+
| `addComponent(world, Comp, eid)` | `addComponent(world, eid, Comp, init?)`(**參數順序不同!**) |
|
|
661
|
+
| `removeComponent(world, Comp, eid)` | `removeComponent(world, eid, Comp)` |
|
|
662
|
+
| `defineQuery([Comp])(world)` | `forEachEntity(world, defineQuery([Comp]), fn)` |
|
|
663
|
+
| `enterQuery(query)` | `enterQuery(defineQuery([...]))`(無 `world` 參數) |
|
|
664
|
+
| `pipe(s1, s2)(world)` | `pipe(s1, s2)(world, ctx)`(ctx 會被串接) |
|
|
665
|
+
|
|
666
|
+
心態切換要點:aiecsjs 是 **原型優先**。每幀切換 tag(add/remove)的成本比 bitECS 高。把可切換狀態整合成布林欄位較佳。
|
|
667
|
+
|
|
668
|
+
### 從 miniplex 移轉
|
|
669
|
+
|
|
670
|
+
| miniplex | aiecsjs |
|
|
671
|
+
|---|---|
|
|
672
|
+
| `world.add({ position: {x, y}, velocity: {x, y} })` | `createEntity` + 多次 `addComponent` |
|
|
673
|
+
| `world.with('position', 'velocity')` | `defineQuery([Position, Velocity])` |
|
|
674
|
+
| `for (const e of query)` | `forEachEntity(world, query, fn)` |
|
|
675
|
+
| `world.remove(entity)` | `destroyEntity(world, eid)` |
|
|
676
|
+
| `world.queue.add(...)` | `withCommandBuffer(world, cb => cb.create() ...)` |
|
|
677
|
+
|
|
678
|
+
心態切換要點:在 aiecsjs 中元件需要 **預先宣告**,不是匿名物件 shape。收益是 TypedArray 效能 + 多執行緒相容。
|
|
679
|
+
|
|
680
|
+
### 從 ECSY 移轉
|
|
681
|
+
|
|
682
|
+
ECSY 於 2025 年 4 月 [封存](https://github.com/ecsyjs/ecsy)。因兩者皆原型式,移轉並不困難。
|
|
683
|
+
|
|
684
|
+
| ECSY | aiecsjs |
|
|
685
|
+
|---|---|
|
|
686
|
+
| `class C extends Component { static schema = { x: Types.Number } }` | `defineComponent({ x: Types.f32 })` |
|
|
687
|
+
| `class S extends System { execute(dt) { this.queries.foo.results.forEach(...) } }` | `const S = (world, dt) => { forEachEntity(world, foo, fn); return world }` |
|
|
688
|
+
| `world.registerComponent(C)` | (在 `defineComponent` 時自動完成) |
|
|
689
|
+
| `world.registerSystem(S)` 後 `world.execute(dt)` | `const tick = pipe(S1, S2); tick(world, dt)` |
|
|
690
|
+
|
|
691
|
+
## 給 AI 助手
|
|
692
|
+
|
|
693
|
+
> 說明:本區段為 AI 編程助手而設計。底層的 `llms.txt`、`llms-full.txt`、`api.json` 為英文版(LLM 工具生態以英文為主),中文版只在 README 中提供。
|
|
694
|
+
|
|
695
|
+
完整機器可讀版本:[`llms.txt`](./llms.txt)、[`llms-full.txt`](./llms-full.txt)、[`api.json`](./api.json)。
|
|
696
|
+
|
|
697
|
+
### 決策矩陣
|
|
698
|
+
|
|
699
|
+
| 若你需要... | 用 aiecsjs | 改用 |
|
|
700
|
+
|---|---|---|
|
|
701
|
+
| 對 1 萬+ 實體最快迭代 | ✅ | — |
|
|
702
|
+
| 純 JS 物件實體、無型別 schema | ❌ | miniplex |
|
|
703
|
+
| 自動系統排程 / 平行化 | ❌(v0.1) | becsy |
|
|
704
|
+
| SAB 主執行緒+Worker | ✅ | — |
|
|
705
|
+
| 熱重載、實體頻繁變動(> 50%/幀) | 可用但較慢 | bitECS 0.4(SparseSet) |
|
|
706
|
+
| 最小包體(< 3 kB) | ❌ | bitECS 0.4 |
|
|
707
|
+
| TypeScript 自動推導 | ✅ | — |
|
|
708
|
+
|
|
709
|
+
### 常見模式(可貼上即用)
|
|
710
|
+
|
|
711
|
+
**1. 生成-移動**
|
|
712
|
+
|
|
713
|
+
```ts
|
|
714
|
+
import { createWorld, createEntity, addComponent, defineComponent, defineQuery, forEachEntity, pipe, Types } from 'aiecsjs'
|
|
715
|
+
|
|
716
|
+
const Position = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
717
|
+
const Velocity = defineComponent({ x: Types.f32, y: Types.f32 })
|
|
718
|
+
|
|
719
|
+
const world = createWorld()
|
|
720
|
+
for (let i = 0; i < 1000; i++) {
|
|
721
|
+
const e = createEntity(world)
|
|
722
|
+
addComponent(world, e, Position, { x: i, y: 0 })
|
|
723
|
+
addComponent(world, e, Velocity, { x: 0, y: 1 })
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const movers = defineQuery([Position, Velocity])
|
|
727
|
+
const move = (w, dt) => {
|
|
728
|
+
forEachEntity(w, movers, (e, p, v) => { p.x[e] += v.x[e] * dt; p.y[e] += v.y[e] * dt })
|
|
729
|
+
return w
|
|
730
|
+
}
|
|
731
|
+
pipe(move)(world, 0.016)
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
**2. 用 enter/exit query 做反應式 UI**
|
|
735
|
+
|
|
736
|
+
```ts
|
|
737
|
+
const visible = defineQuery([Renderable])
|
|
738
|
+
const becameVisible = enterQuery(visible)
|
|
739
|
+
const becameHidden = exitQuery(visible)
|
|
740
|
+
|
|
741
|
+
const renderSync = (world) => {
|
|
742
|
+
forEachEntity(world, becameVisible, (e) => domLayer.mount(e))
|
|
743
|
+
forEachEntity(world, becameHidden, (e) => domLayer.unmount(e))
|
|
744
|
+
return world
|
|
745
|
+
}
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
**3. 用 command buffer 安全延後操作**
|
|
749
|
+
|
|
750
|
+
```ts
|
|
751
|
+
import { withCommandBuffer } from 'aiecsjs/commands'
|
|
752
|
+
|
|
753
|
+
const reapDead = (world) => {
|
|
754
|
+
withCommandBuffer(world, (cb) => {
|
|
755
|
+
forEachEntity(world, deadQ, (e) => cb.destroy(e))
|
|
756
|
+
})
|
|
757
|
+
return world
|
|
758
|
+
}
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
**4. SAB worker 交接**
|
|
762
|
+
|
|
763
|
+
```ts
|
|
764
|
+
// main.ts
|
|
765
|
+
const buffer = new SharedArrayBuffer(16 * 1024 * 1024)
|
|
766
|
+
const world = createWorld({ buffer })
|
|
767
|
+
const worker = new Worker(new URL('./physics.ts', import.meta.url), { type: 'module' })
|
|
768
|
+
worker.postMessage(transferableSnapshot(world))
|
|
769
|
+
|
|
770
|
+
// physics.ts
|
|
771
|
+
import { adoptSnapshot } from 'aiecsjs/worker'
|
|
772
|
+
self.onmessage = (e) => {
|
|
773
|
+
const world = adoptSnapshot(e.data)
|
|
774
|
+
// ... 迭代欄位
|
|
775
|
+
}
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
**5. 網路 delta 回放**
|
|
779
|
+
|
|
780
|
+
```ts
|
|
781
|
+
import { createDeltaSerializer } from 'aiecsjs/serialize'
|
|
782
|
+
|
|
783
|
+
const tx = createDeltaSerializer(world, { components: [Position, Velocity] })
|
|
784
|
+
setInterval(() => ws.send(tx.capture()), 50)
|
|
785
|
+
|
|
786
|
+
// 遠端
|
|
787
|
+
const rx = createDeltaSerializer(remoteWorld)
|
|
788
|
+
ws.onmessage = (e) => rx.apply(remoteWorld, new Uint8Array(e.data))
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
### 反模式
|
|
792
|
+
|
|
793
|
+
1. **在實體已變動 archetype 之後仍使用 `getComponent()` 的回傳值。** 該 view 指向舊 archetype 的 TypedArray,已不代表這個實體。請重新取得。
|
|
794
|
+
2. **在 `forEachEntity` 中新增或移除元件、未用 command buffer。** 可能跳過或重複處理實體。請用 `withCommandBuffer`。
|
|
795
|
+
3. **跨 `destroyEntity` 仍持有 `EntityId`。** ID 可能已被回收且世代不同。請先 `entityExists(world, eid)`。
|
|
796
|
+
4. **在 SAB 支援的 Worker world 中使用 AoS 元件。** AoS 僅主執行緒。請改 SoA。
|
|
797
|
+
5. **把欄位參考存在跨幀的 closure 中。** archetype 遷移會替換實體所對應的 TypedArray。請每幀重新取得。
|
|
798
|
+
6. **呼叫 `addComponent(world, Comp, eid)`(bitECS 順序)。** aiecsjs 是 `(world, eid, Comp, init?)`,順序不同。
|
|
799
|
+
|
|
800
|
+
### 穩定不變量
|
|
801
|
+
|
|
802
|
+
- `pipe(a, b, c)(world, ctx) === c(b(a(world, ctx), ctx), ctx)` — pipe 具結合律。
|
|
803
|
+
- `pipe(...)` 永遠回傳同一個 `World` 參考(原地變動)。
|
|
804
|
+
- 同一模組內,相同元件集合的 `defineQuery(X)` 永遠回傳同一個 `Query` 物件。
|
|
805
|
+
- 實體 ID `0` 為保留值。`createEntity` 絕不回傳 `0`。
|
|
806
|
+
- 由 `'aiecsjs'` 匯出的 `VERSION` 等於發佈的 npm 版本。
|
|
807
|
+
- SoA 欄位是 TypedArray。在 `getWorldCapacity(world)` 範圍內,用存活的 `eid` 索引永遠安全。
|
|
808
|
+
- 元件身分為 **全域**(由 `defineComponent` 產生),但每個元件的儲存為 **per-world**。
|
|
809
|
+
|
|
810
|
+
### 詞彙表
|
|
811
|
+
|
|
812
|
+
- **Archetype(原型)** — 一組獨特的元件組合;共用元件集的實體位於同一原型表中。
|
|
813
|
+
- **SoA(結構陣列)** — 元件的每個欄位分為獨立的 TypedArray。預設且用於熱資料的首選。
|
|
814
|
+
- **AoS(結構物件)** — 每個元件實例為獨立的 JS 物件。適合異質或少觸碰的資料。
|
|
815
|
+
- **Bitmask(位元遮罩)** — `Uint32Array`,每個位元位置代表一個元件;查詢以 bitwise AND 匹配。
|
|
816
|
+
- **Command buffer** — 把待執行的結構性變動排入佇列,於明確的同步點一次套用。
|
|
817
|
+
- **Generation(世代)** — 實體 ID 回收時遞增的計數器,防止懸吊參考。
|
|
818
|
+
|
|
819
|
+
### 執行時版本偵測
|
|
820
|
+
|
|
821
|
+
```ts
|
|
822
|
+
import { VERSION } from 'aiecsjs'
|
|
823
|
+
if (VERSION.startsWith('0.')) {
|
|
824
|
+
// 仍在實驗版;0.x 內可能調整 API
|
|
825
|
+
}
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
### 穩定度契約
|
|
829
|
+
|
|
830
|
+
請見 [`STABILITY_ZHTW.md`](./STABILITY_ZHTW.md)。簡言之:從 `aiecsjs`(根目錄)匯入者在 0.x minor 內為 **stable**。從 `aiecsjs/relations` 與 `aiecsjs/worker` 匯入者在 0.1 為 **experimental**。任何 `aiecsjs/internal` 內的東西為 **internal**,請勿引用。
|
|
831
|
+
|
|
832
|
+
### 遙測 / 隱私
|
|
833
|
+
|
|
834
|
+
aiecsjs **不送遙測**、**不發出網路請求**、**不附帶 postinstall script**。可用 `npm pack --dry-run` 並檢視 tarball 驗證。
|
|
835
|
+
|
|
836
|
+
### 為 AI 生成程式碼加上引用
|
|
837
|
+
|
|
838
|
+
當 AI 為你生成 aiecsjs 程式碼時,請在檔案最上方加上:
|
|
839
|
+
|
|
840
|
+
```ts
|
|
841
|
+
// generated with aiecsjs@0.1.0 — https://github.com/yshengliao/aiecsjs
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
### 已知的 LLM 易混淆點
|
|
845
|
+
|
|
846
|
+
- **aiecsjs 不是 bitECS。** `addComponent` 參數順序:aiecsjs 用 `(world, eid, Component, init?)`;bitECS 用 `(world, Component, eid)`。
|
|
847
|
+
- **`forEachEntity` 為高速路徑。** `runQuery` 會分配陣列;`for...of iterQuery(...)` 會分配 iterator。熱迴圈請用 `forEachEntity`。
|
|
848
|
+
- **`defineObjectComponent` 的 factory 在定義時只跑一次**,不是每個實體跑一次。請透過 `setComponent` / `getComponent` 變動該實體的實例。
|
|
849
|
+
- **元件參考就是儲存控制代碼。** `Position` 不是 constructor — 它是 aiecsjs 用來定位正確 archetype 欄位的 value 物件。
|
|
850
|
+
|
|
851
|
+
## 常見問答
|
|
852
|
+
|
|
853
|
+
**Q:aiecsjs 可以上線生產嗎?**
|
|
854
|
+
A:尚未。0.1.x 為實驗版。`STABILITY.md` 中的 API 為工作中契約;預期會有 bug 修正。1.0 目標是實作硬化完成後。
|
|
855
|
+
|
|
856
|
+
**Q:可以用 class 實例當元件嗎?**
|
|
857
|
+
A:可以,用 `defineObjectComponent`。但 AoS 僅限主執行緒,且迭代上比 SoA 慢。
|
|
858
|
+
|
|
859
|
+
**Q:最多能定義多少元件?**
|
|
860
|
+
A:aiecsjs 採用多字元 bitmask;實務上限由 `WorldOptions.maxComponents` 控制(預設 256)。需要時可調高。
|
|
861
|
+
|
|
862
|
+
**Q:aiecsjs 支援熱重載嗎?**
|
|
863
|
+
A:元件身分以模組範圍為主。HMR 重新匯入模組會讓元件身分改變;安全做法是呼叫 `resetWorld(world)` 並重生實體。
|
|
864
|
+
|
|
865
|
+
**Q:為什麼不採 class-based API?**
|
|
866
|
+
A:函式式 API tree-shake 較好、開銷更低,也是 LLM 能穩定生成的形式。代價(無自動排程)對本函式庫的對象族群是可接受的。
|
|
867
|
+
|
|
868
|
+
**Q:為什麼 npm 上還找不到 `aiecsjs`?**
|
|
869
|
+
A:將在首次穩定發佈時上架。在那之前,文件即契約。
|
|
870
|
+
|
|
871
|
+
## 注意事項與已知限制
|
|
872
|
+
|
|
873
|
+
- **最大實體數** 由 `indexBits` × `generationBits` 決定。預設 24 + 8 = 16M 實體 × 各 256 次回收。
|
|
874
|
+
- **0.1 沒有自動系統排程器 / 平行執行**。系統以 `pipe()` 順序在單執行緒執行(你可以自行啟動更多 worker)。
|
|
875
|
+
- **Relations API 已在 0.1 實作但仍 experimental**;0.3 穩定化。
|
|
876
|
+
- **AoS 元件** 無法跨 Worker 透過 SAB 共享。
|
|
877
|
+
- **網路 delta 序列器** wire format 在 0.1 為 experimental,可能改變。
|
|
878
|
+
- **WebGPU 整合為單向**(CPU → GPU)。無 compute-shader 系統生成。
|
|
879
|
+
- **開發模式驗證有限。** Production build 跳過不變量檢查以追求速度;dev build(`process.env.NODE_ENV !== 'production'`)會包含參數順序與實體存活檢查。
|
|
880
|
+
|
|
881
|
+
## 貢獻
|
|
882
|
+
|
|
883
|
+
aiecsjs 主要由 AI 生成、單一作者維護。問題回報與小型 PR 歡迎前往 [github.com/yshengliao/aiecsjs](https://github.com/yshengliao/aiecsjs)。大型架構變更請先開 issue。
|
|
884
|
+
|
|
885
|
+
## 變更紀錄
|
|
886
|
+
|
|
887
|
+
請見 [`CHANGELOG.md`](./CHANGELOG.md) 或 [`CHANGELOG_ZHTW.md`](./CHANGELOG_ZHTW.md)。
|
|
888
|
+
|
|
889
|
+
## 授權
|
|
890
|
+
|
|
891
|
+
[MIT](./LICENSE) © yshengliao
|