跳到內容

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 WebView
npm 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 與 aiecsjs root 的核心路徑不碰 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 只能匯入 .plist atlas),aiecsjs/loop 則與 update(dt) 重複。

原生與小遊戲建置要使用 aifsmjs 時,需要加入下面這個最小實作。它只涵蓋 ai*js 用到的部分(abort()、signal.aborted、signal.reason,以及 abort 事件的 addEventListener/removeEventListener),環境已內建 AbortController 時不會覆寫。其他程式碼若需要 AbortSignal.timeout()、AbortSignal.any() 或 dispatchEvent,請改用完整的 polyfill。

assets/scripts/abort-controller-lite.ts
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 決定目前的 state 與 frame key,元件再從 SpriteAtlas 取出同名的 SpriteFrame 顯示。

assets/scripts/HeroSprite.ts
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 格式的 .plist atlas(TexturePacker 4.x),frame key 請以匯入後 atlas 的實際 key 為準;官方手冊範例的 key 不含副檔名,例如 sheep_down_0。
  • 0.6.0 起,識別名稱不是字串(例如 initial、from、to)或 priority 不是整數時,createSpriteAnimator() 會丟出 InvalidGraphError,從 JSON 載入 graph 時要特別注意。在 onStateChange 或 onComplete listener 裡呼叫的 update()/reset() 會排入佇列,等目前的呼叫結束後才執行。

規則寫在 machine 裡。元件只回報目標是否在視野內,並在 state 為 chase 時移動節點。

assets/scripts/EnemyBrain.ts
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 context
const 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 也會跟著被凍結。

觸控與 UI 事件繼續使用 Cocos 的 node.on(),元件之間的遊戲事件則交給 aieventjs:payload 有型別,每個 on() 都回傳自己的取消訂閱函式。

assets/scripts/ScoreHud.ts
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()。

物件池存放由 prefab 建立的子彈節點,子彈的移動交給 prefab 上的元件。

assets/scripts/BulletSpawner.ts
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 染成紅色。

assets/scripts/BroadphaseArena.ts
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() 當下場地範圍之外的物件不會被取回。

SoA component 儲存位置與速度,AoS component(defineObjectComponent)儲存對應的 Node。第一輪更新資料,第二輪寫回節點。namespace import 可以避免把 ecs.addComponent 和 Cocos 的 node.addComponent 搞混。

assets/scripts/SwarmSimulation.ts
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。

assets/scripts/HostBridge.ts
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。