aiecsjs 0.5.6 → 0.5.8

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