Cocos Creator 整合指南
aijs 套件的核心是不依賴 renderer 的 TypeScript,可以在 Cocos Creator 專案中負責遊戲邏輯、資料與通訊,畫面、音效與資源載入則繼續交給 Cocos。aijs 決定「要做什麼」,再由元件把結果套用到節點上。
本頁以 Cocos Creator 3.8.8(2025 年 12 月釋出,3.8 LTS 系列的最新穩定版)與 ai*js 0.6.0 為準。COCOS 4 仍是 4.0.0-alpha,本頁不涵蓋。
在專案根目錄(assets/ 與 package.json 所在的那一層)執行:
npm install aispritejs aifsmjs aieventjs aipooljs aiquadtreejs aiecsjs# only for web builds embedded in an iframe or a Flutter WebViewnpm install aibridgejs- Cocos Creator 以 Node.js 的演算法解析 bare specifier,並依
import與default條件支援package.json的exports(預設另加browser,不支援require與node)。ai*js 套件都是 ES module,Cocos 會載入 ESM 輸出,aibridgejs/detect這類 subpath 也能直接匯入。官方手冊以 npm 示範,pnpm 以 symlink 組成的node_modules未經驗證。 - Cocos 編譯指令碼時不讀
tsconfig.json、忽略型別錯誤,並以 TypeScript 4.1 語法為準,所以範例使用獨立的import type,不使用satisfies、override或import { type X }。 tsconfig.json僅影響 IDE。若 IDE 找不到 subpath 的型別,可以在compilerOptions加上"moduleResolution": "bundler"(IDE 需使用 TypeScript 5.0 以上);官方手冊只要求不要修改target與module。
Web 指 Web Desktop 與 Web Mobile,包含嵌在 iframe 或 Flutter WebView 裡的建置。原生指 Android、iOS、macOS 與 Windows,Cocos 3.8.8 在這些平臺都以 V8 11.6 執行指令碼(iOS 使用 --jitless)。小遊戲欄以微信小遊戲為準(iOS 使用 JavaScriptCore,Android 使用 V8,沒有 DOM),其他小遊戲平臺未逐一驗證。
| 套件 | Web | 原生 | 小遊戲 |
|---|---|---|---|
aispritejs(核心) |
可用 | 可用 | 可用,注意 Object.hasOwn |
aifsmjs |
可用 | 需 AbortController polyfill |
需 polyfill,注意 Object.hasOwn |
aieventjs、aipooljs、aiquadtreejs |
可用 | 可用 | 可用 |
aiecsjs(root) |
可用 | 可用 | 可用 |
aiecsjs/serialize |
可用 | 含 AoS component 時不可用 | 未驗證 |
aiecsjs/worker |
僅限 cross-origin isolated 頁面 | 不可用 | 不可用 |
aibridgejs |
可用 | 不可用 | 不可用 |
aiaudiojs |
不建議 | 不可用 | 不可用 |
aispritejs、aieventjs、aipooljs、aiquadtreejs與aiecsjsroot 的核心路徑不碰 DOM 或 Web 專屬的全域 API。只有傳入signal選項時才會用到AbortSignal,aieventjs也只在設定throttleMs時呼叫performance.now()。aifsmjs每次createRuntime()都會建立AbortController(aibridgejs則在第一次ready()、call()或emit()時建立),而 Cocos 3.8.8 的原生與微信小遊戲 adapter 都沒有提供。aispritejs與aifsmjs的核心路徑會呼叫Object.hasOwn(ES2022)。iOS 15.4 以前的 JavaScriptCore 沒有這個 API,會影響舊版 iOS 上的 Web 建置與 iOS 微信小遊戲;需要支援這些裝置時,請在啟動時自行補上。aiecsjs/serialize以structuredClone深層複製 AoS component,原生執行環境沒有這個 API。aiecsjs/worker需要SharedArrayBuffer、Web Worker 與 cross-origin isolation,aibridgejs需要window.postMessage或 Flutter WebView。- 離開 Web 之後計時較粗略:原生 adapter 每個 frame 才檢查一次
setTimeout,微信 adapter 以Date.now實作performance.now。這會影響aifsmjs/timer的延遲與aieventjs的throttleMs區間。 aiaudiojs包裝 Howler.js 與 Web Audio,並在document上掛解鎖事件。原生建置兩者都沒有,小遊戲 adapter 不提供AudioContext,Web 建置則會和 Cocos 自己的AudioContext並存,也繞過AudioClip資源管線;請改用AudioSource。同理,aispritejs/pixi針對 PixiJS v8,aispritejs/atlas解析 PixiJS 的 atlas JSON(Cocos 只能匯入.plistatlas),aiecsjs/loop則與update(dt)重複。
AbortController polyfill
Section titled “AbortController polyfill”原生與小遊戲建置要使用 aifsmjs 時,需要加入下面這個最小實作。它只涵蓋 ai*js 用到的部分(abort()、signal.aborted、signal.reason,以及 abort 事件的 addEventListener/removeEventListener),環境已內建 AbortController 時不會覆寫。其他程式碼若需要 AbortSignal.timeout()、AbortSignal.any() 或 dispatchEvent,請改用完整的 polyfill。
class AbortSignalLite { aborted = false; reason: unknown = undefined; private readonly listeners = new Set<() => void>();
addEventListener(type: string, listener: () => void): void { if (type === "abort" && !this.aborted) this.listeners.add(listener); }
removeEventListener(type: string, listener: () => void): void { if (type === "abort") this.listeners.delete(listener); }
fire(reason: unknown): void { if (this.aborted) return; this.aborted = true; this.reason = reason; this.listeners.forEach((listener) => { try { listener.call(this); } catch (err) { console.error(err); } }); this.listeners.clear(); }}
class AbortControllerLite { readonly signal = new AbortSignalLite();
abort(reason?: unknown): void { const error = new Error("This operation was aborted"); error.name = "AbortError"; this.signal.fire(reason === undefined ? error : reason); }}
const g = globalThis as unknown as { AbortController?: unknown };if (typeof g.AbortController === "undefined") g.AbortController = AbortControllerLite;
export {};在每個會呼叫 createRuntime() 的指令碼加上 import "./abort-controller-lite";。若不想使用 polyfill,可以改用純函式 step(def, snapshot, event, impl) 與 initialSnapshot(def) 驅動 machine,自行儲存 snapshot。
以下每個範例都是放在 assets/scripts/ 的元件,可以直接掛到節點上。Cocos 傳給 update(dt) 的 dt 以秒為單位。
aispritejs:用 SpriteAtlas 顯示 frame
Section titled “aispritejs:用 SpriteAtlas 顯示 frame”aispritejs 決定目前的 state 與 frame key,元件再從 SpriteAtlas 取出同名的 SpriteFrame 顯示。
import { _decorator, Component, Sprite, SpriteAtlas } from "cc";import { createSpriteAnimator } from "aispritejs";import type { SpriteGraph } from "aispritejs";
const { ccclass, property, requireComponent } = _decorator;
const HERO_GRAPH: SpriteGraph = { inputs: { speed: { type: "number" }, jump: { type: "trigger" } }, initial: "idle", states: { idle: { animation: "idle", loop: true }, run: { animation: "run", loop: true }, jump: { animation: "jump", onEnd: "idle" }, }, transitions: [ { from: "idle", to: "run", when: [{ input: "speed", op: "GreaterThan", value: 0 }] }, { from: "run", to: "idle", when: [{ input: "speed", op: "Equals", value: 0 }] }, { from: "*", to: "jump", when: [{ input: "jump", op: "Trigger" }] }, ], animations: { idle: ["hero_idle_0", "hero_idle_1"], run: ["hero_run_0", "hero_run_1", "hero_run_2", "hero_run_3"], jump: ["hero_jump_0", "hero_jump_1", "hero_jump_2"], },};
@ccclass("HeroSprite")@requireComponent(Sprite)export class HeroSprite extends Component { @property(SpriteAtlas) atlas: SpriteAtlas | null = null;
readonly animator = createSpriteAnimator(HERO_GRAPH); private shownKey = "";
update(dt: number) { this.animator.update(dt * 1000); // aispritejs counts milliseconds const key = this.animator.activeFrameKey; if (key === this.shownKey || !this.atlas) return; this.shownKey = key; this.getComponent(Sprite)!.spriteFrame = this.atlas.getSpriteFrame(key); }
onDestroy() { this.animator.dispose(); }}- 其他元件以
hero.animator.setInput("speed", 1)與hero.animator.fireTrigger("jump")驅動動畫。loop的預設值是false;除非defaultFrameDuration或frames另有設定,每個 frame 顯示 100 ms。 getSpriteFrame()遇到不存在的 key 會回傳null,sprite 就會變成空白。Cocos 只能匯入 Cocos2d-x 格式的.plistatlas(TexturePacker 4.x),frame key 請以匯入後 atlas 的實際 key 為準;官方手冊範例的 key 不含副檔名,例如sheep_down_0。- 0.6.0 起,識別名稱不是字串(例如
initial、from、to)或priority不是整數時,createSpriteAnimator()會丟出InvalidGraphError,從 JSON 載入 graph 時要特別注意。在onStateChange或onCompletelistener 裡呼叫的update()/reset()會排入佇列,等目前的呼叫結束後才執行。
aifsmjs:敵人狀態
Section titled “aifsmjs:敵人狀態”規則寫在 machine 裡。元件只回報目標是否在視野內,並在 state 為 chase 時移動節點。
import "./abort-controller-lite";import { _decorator, Component, Node, Vec3 } from "cc";import { assign, createRuntime, setup } from "aifsmjs";
const { ccclass, property } = _decorator;
type EnemyEvent = { type: "SEE" } | { type: "LOSE" } | { type: "HIT"; damage: number };
// the first passing guard wins; the entry without a target only updates the contextconst onHit = [ { target: "dead", guard: "lethal", actions: ["hurt"] }, { actions: ["hurt"] },] as const;
const enemyMachine = setup<{ hp: number }, EnemyEvent>().defineMachine({ id: "enemy", initial: "patrol", context: { hp: 3 }, states: { patrol: { on: { SEE: "chase", HIT: onHit } }, chase: { on: { LOSE: "patrol", HIT: onHit } }, dead: { final: true }, },});
@ccclass("EnemyBrain")export class EnemyBrain extends Component { @property(Node) target: Node | null = null;
private readonly runtime = createRuntime(enemyMachine, { guards: { lethal: ({ context, event }) => event.type === "HIT" && context.hp <= event.damage }, actions: { hurt: assign(({ context, event }) => ({ hp: event.type === "HIT" ? context.hp - event.damage : context.hp, })), }, }); private readonly next = new Vec3();
takeHit(damage: number) { this.runtime.send({ type: "HIT", damage }); if (this.runtime.getSnapshot().value === "dead") this.node.active = false; }
update(dt: number) { if (!this.target) return; const from = this.node.worldPosition; const to = this.target.worldPosition; // an event the current state does not handle is ignored this.runtime.send({ type: Vec3.distance(from, to) <= 400 ? "SEE" : "LOSE" }); if (this.runtime.getSnapshot().value !== "chase") return; this.node.setWorldPosition(Vec3.moveTowards(this.next, from, to, 120 * dt)); }
onDestroy() { this.runtime.dispose(); }}- 第一行匯入 polyfill,原生與小遊戲建置才能執行
createRuntime()。dead是final,之後的事件一律被忽略。 - 0.6.0 起
send()採 run-to-completion:在 listener、middleware 或 effect 裡呼叫的send()會排入佇列,等目前的事件處理完才執行,並回傳當下已提交的 snapshot。請在外層呼叫返回後再讀getSnapshot(),如同takeHit()的寫法。 - context 只放純資料。若讀得到
process.env.NODE_ENV且不是production,snapshot 會被 deep freeze,放進 context 的Node也會跟著被凍結。
aieventjs:型別化的遊戲事件
Section titled “aieventjs:型別化的遊戲事件”觸控與 UI 事件繼續使用 Cocos 的 node.on(),元件之間的遊戲事件則交給 aieventjs:payload 有型別,每個 on() 都回傳自己的取消訂閱函式。
import { _decorator, Component, Label } from "cc";import { createEmitter } from "aieventjs";
const { ccclass, property } = _decorator;
export type HudEvents = { "score/add": { amount: number }; "score/reset": void };
@ccclass("ScoreHud")export class ScoreHud extends Component { @property(Label) label: Label | null = null;
readonly events = createEmitter<HudEvents>(); private score = 0; private offs: Array<() => void> = [];
onEnable() { this.offs = [ this.events.on("score/add", ({ amount }) => this.show(this.score + amount)), this.events.on("score/reset", () => this.show(0)), ]; }
onDisable() { for (const off of this.offs) off(); }
onDestroy() { this.events.dispose(); }
private show(score: number) { this.score = score; if (this.label) this.label.string = String(score); }}- 其他元件呼叫
hud.events.emit("score/add", { amount: 10 })。payload 型別為void的事件仍要傳入undefined,例如emit("score/reset", undefined)。事件表請用type宣告,不要用interface。 - 0.6.0 起,在分派途中被移除的 handler(例如某個元件的
onDisable()在另一個 handler 裡執行)不會再收到這次emit()。
aipooljs:包裝 instantiate(prefab)
Section titled “aipooljs:包裝 instantiate(prefab)”物件池存放由 prefab 建立的子彈節點,子彈的移動交給 prefab 上的元件。
import { _decorator, Component, instantiate, Node, Prefab } from "cc";import { createPool } from "aipooljs";import type { NullPool } from "aipooljs";
const { ccclass, property } = _decorator;
@ccclass("BulletSpawner")export class BulletSpawner extends Component { @property(Prefab) bulletPrefab: Prefab | null = null;
private pool: NullPool<Node> | null = null;
onLoad() { const prefab = this.bulletPrefab; if (!prefab) throw new Error("BulletSpawner: assign a bullet Prefab"); this.pool = createPool<Node>({ size: 64, onOverflow: "null", create: () => { const node = instantiate(prefab); node.active = false; this.node.addChild(node); return node; }, reset: (node) => { node.active = false; }, }); }
start() { this.schedule(this.fire, 0.1); }
fire() { const bullet = this.pool?.acquire(); if (!bullet) return; bullet.setPosition(0, 0, 0); bullet.active = true; // a component on the prefab moves it this.scheduleOnce(() => this.pool?.release(bullet), 1.2); }
onDestroy() { this.pool?.dispose(); this.pool = null; }}- Cocos 的
NodePool沒有容量上限:池子空了就得在遊戲迴圈中instantiate(),重複put()也不會被偵測。createPool()會先呼叫size次create,池子用完時依onOverflow處理("throw"、"null"或"grow"),重複 release 同一個物件會丟出PoolError。 - 0.6.0 起,
size不是非負整數、create或reset不是函式,或create回傳null/undefined時,createPool()也會丟出PoolError。 dispose()只釋放池子的內部參照。子彈都是 spawner 節點的子節點,會在 spawner 被 destroy 時一併清除。
aiquadtreejs:以 UITransform 範圍做 broadphase
Section titled “aiquadtreejs:以 UITransform 範圍做 broadphase”UITransform.getBoundingBoxToWorld() 回傳新的 Rect,帶有 x、y、width、height,本身就是 AABB。把元件掛在場地節點上,它每個 frame 以場地的子節點重建 quadtree,並把重疊的 sprite 染成紅色。
import { _decorator, Color, Component, Rect, Sprite, UITransform } from "cc";import { createQuadtree } from "aiquadtreejs";import type { AABB, Quadtree } from "aiquadtreejs";
const { ccclass, requireComponent } = _decorator;
type Box = Rect & { sprite: Sprite | null };
const overlaps = (a: AABB, b: AABB) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
@ccclass("BroadphaseArena")@requireComponent(UITransform)export class BroadphaseArena extends Component { private tree: Quadtree<Box> | null = null; private readonly boxes: Box[] = []; private readonly candidates: Box[] = [];
start() { const bounds = this.getComponent(UITransform)!.getBoundingBoxToWorld(); this.tree = createQuadtree<Box>({ bounds, maxObjects: 8, maxLevels: 5 }); }
update() { const tree = this.tree; if (!tree) return; tree.clear(); this.boxes.length = 0; for (const child of this.node.children) { const ui = child.getComponent(UITransform); if (!ui) continue; const box = Object.assign(ui.getBoundingBoxToWorld(), { sprite: child.getComponent(Sprite) }); this.boxes.push(box); tree.insert(box); } for (const a of this.boxes) { let hit = false; for (const b of tree.retrieveInto(a, this.candidates)) { if (b !== a && overlaps(a, b)) hit = true; } if (a.sprite) a.sprite.color = hit ? Color.RED : Color.WHITE; } }
onDestroy() { this.tree?.dispose(); this.tree = null; }}createQuadtree()只讀取bounds的每個欄位一次;缺少 bounds 或寬高不是正數時會丟出QuadtreeError,所以場地節點需要設定 content size。Cocos 的Rect以左下角為原點、y 軸向上,quadtree 只要求座標系一致。getBoundingBoxToWorld()每次呼叫都會配置新物件,也會併入啟用中的子節點範圍。物件很多時,請自行維護尺寸並讀取worldPosition。完全落在start()當下場地範圍之外的物件不會被取回。
aiecsjs:模擬資料同步到節點
Section titled “aiecsjs:模擬資料同步到節點”SoA component 儲存位置與速度,AoS component(defineObjectComponent)儲存對應的 Node。第一輪更新資料,第二輪寫回節點。namespace import 可以避免把 ecs.addComponent 和 Cocos 的 node.addComponent 搞混。
import { _decorator, CCInteger, Component, instantiate, Node, Prefab, randomRange } from "cc";import * as ecs from "aiecsjs";
const { ccclass, property } = _decorator;
const Position = ecs.defineComponent({ x: ecs.Types.f32, y: ecs.Types.f32 });const Velocity = ecs.defineComponent({ x: ecs.Types.f32, y: ecs.Types.f32 });const View = ecs.defineObjectComponent<{ node: Node | null }>(() => ({ node: null }));const movers = ecs.defineQuery([Position, Velocity]);const views = ecs.defineQuery([Position, View]);
@ccclass("SwarmSimulation")export class SwarmSimulation extends Component { @property(Prefab) agentPrefab: Prefab | null = null;
@property({ type: CCInteger }) count = 200;
private world: ecs.World | null = null;
onLoad() { const prefab = this.agentPrefab; if (!prefab) throw new Error("SwarmSimulation: assign an agent Prefab"); const world = ecs.createWorld({ initialCapacity: this.count }); for (let n = 0; n < this.count; n++) { const e = ecs.createEntity(world); ecs.addComponent(world, e, Position, { x: randomRange(-400, 400), y: randomRange(-300, 300) }); ecs.addComponent(world, e, Velocity, { x: randomRange(-80, 80), y: randomRange(-80, 80) }); const node = instantiate(prefab); this.node.addChild(node); ecs.addComponent(world, e, View, { node }); } this.world = world; }
update(dt: number) { const world = this.world; if (!world) return; ecs.forEachEntityIndexed(world, movers, (_e, i, pos, vel) => { pos.x[i] += vel.x[i] * dt; pos.y[i] += vel.y[i] * dt; if (Math.abs(pos.x[i]) > 400) vel.x[i] = -vel.x[i]; if (Math.abs(pos.y[i]) > 300) vel.y[i] = -vel.y[i]; }); ecs.forEachEntityIndexed(world, views, (_e, i, pos, view) => { view[i].node?.setPosition(pos.x[i], pos.y[i], 0); }); }
onDestroy() { if (this.world) ecs.disposeWorld(this.world); this.world = null; }}forEachEntityIndexed的 callback 是(e, i, ...columns):SoA 欄位以pos.x[i]存取,AoS 欄位以view[i]存取,不要把e當成索引。要在迴圈中新增或移除 entity 時,請改用aiecsjs/commands。- 0.6.0 起,
initialCapacity不是整數時createWorld()會丟出EcsError。一般的number屬性在屬性檢查器中是浮點數,所以count使用CCInteger。 - 要用
aiecsjs/serialize存檔時,請給每個 component 一個穩定的 key,例如defineComponent(schema, { key: "swarm.position" });snapshot 格式 2 依 key 比對 component。以相同 key 與欄位配置再次執行定義(例如重新載入指令碼之後)會回傳既有的 component,配置不同則丟出EcsError。
aibridgejs:嵌在 iframe 或 Flutter WebView 裡的 Web 建置
Section titled “aibridgejs:嵌在 iframe 或 Flutter WebView 裡的 Web 建置”只用於 Web 建置。sys.isBrowser 排除原生與小遊戲建置;沒有宿主時 detectBridgeAdapter() 回傳 mock adapter,範例遇到 mock 就直接關閉 bridge。
import { _decorator, Component, director, sys } from "cc";import { createBridge } from "aibridgejs";import type { Bridge } from "aibridgejs";import { detectBridgeAdapter } from "aibridgejs/detect";
const { ccclass, property } = _decorator;
@ccclass("HostBridge")export class HostBridge extends Component { @property hostOrigin = "https://host.example";
private bridge: Bridge | null = null;
onLoad() { if (!sys.isBrowser) return; const bridge = createBridge({ adapter: detectBridgeAdapter(window, { iframe: { targetOrigin: this.hostOrigin, expectedSource: window.parent }, }), timeoutMs: 5000, }); if (bridge.platform() === "mock") { bridge.dispose(); return; } bridge.on<{ paused?: unknown }>("game/pause", (payload) => { if (payload?.paused === true) director.pause(); if (payload?.paused === false) director.resume(); }); this.bridge = bridge; bridge .call<{ name?: unknown }>("user/current") .then((user) => console.log("[HostBridge] player", user?.name)) .catch((err: unknown) => console.warn("[HostBridge] host did not answer", err)); }
onDestroy() { this.bridge?.dispose(); this.bridge = null; }}targetOrigin必須是宿主頁面的精確 origin("*"會被拒絕),expectedSource限定只接受window.parent送來的訊息。宿主送來的 payload 一律視為不可信的輸入。- 0.6.0 起,
call()的timeoutMs(這裡是createBridge()設定的 5 秒)也涵蓋等待宿主就緒的時間,沒有回應的宿主會讓呼叫以BridgeTimeoutError失敗。emit()除非傳入timeoutMs,否則沒有期限;dispose()會以BridgeDisposedError拒絕所有進行中的呼叫。 - iOS 與 macOS 上的 Flutter InAppWebView 沒有可偵測的 ready 旗標,Cocos bundle 若在
flutterInAppWebViewPlatformReady觸發之後才載入完成,就會一直等到呼叫逾時;這時請改傳detectBridgeAdapter(window, { flutter: { waitForReadyEvent: false }, iframe: { ... } })。原生建置請改用 Cocos 的JsbBridge或JsbBridgeWrapper。
- 套件指南:aispritejs、aifsmjs、aieventjs、aipooljs、aiquadtreejs、aiecsjs、aibridgejs
- Cocos Creator 3.8 手冊:模組規範、取得 npm 套件、語言支援