《Cocos 游戏开发入门》18.2 迁移清单:mini2d → Cocos Creator

把自研运行时 mini2d 的每个模块映射到 Cocos Creator 3.8 的对应物,给出「先搬纯逻辑、再换外壳」的三步迁移流程,并逐条拆解四类高频陷阱:坐标系差异、生命周期顺序、资源引用方式与序列化,附一份可勾选的迁移检查清单,帮自研运行时平滑过渡到引擎工程。

18.2 迁移清单:mini2d → Cocos Creator

上一节把两套实现并排看完了。这一节解决一个很实际的问题:如果你手里已经有一个像 mini2d 这样的自研运行时,怎么把它搬到 Cocos Creator 上?这不是「推倒重写」,而是一次有章法的搬家——把能带走的带走,把该换的换掉,把容易踩的坑提前标出来。

本节的可迁移性对两类人都有用:一类是像你一样写完了 mini2d 的读者,一类是手上有别的小型自研 2D 运行时、想迁到 Cocos 的开发者。迁移的核心不是「抄 API」,而是先分清哪些代码与引擎无关。

18.2.1 迁移的总体思路:先搬纯逻辑,再换外壳

mini2d 的代码可以一刀切成两半:

  • 纯逻辑:位移积分、碰撞判定、状态机、结算、存档编解码、掉落表。这些只吃数字、只吐数字,跟渲染与引擎毫无关系。
  • 外壳:帧循环、渲染遍历、输入绑定、资源加载、UI 构建。这些每一行都在跟引擎打交道。

迁移的正确顺序是先搬纯逻辑(几乎零改动),再换外壳(重写)。反过来做——先把渲染搬过去再回头改逻辑——会让你在引擎工程里到处埋「暂时先这样」的临时补丁。

18.2.2 模块映射表

先把 mini2d 的每个模块对应到 Cocos 的对应物,这张表是整节的地图:

mini2d 模块职责Cocos Creator 3.8 对应物
core/loop.ts帧循环director + Component.update(dt)
core/node.ts场景图与变换Node
core/component.ts组件与生命周期Component + _decorator.ccclass
render/render-tree.ts遍历绘制Sprite / Label 等渲染组件
render/atlas.ts图集与切帧SpriteFrame / SpriteAtlas
input/pointer.ts输入与坐标input / Node.on(NodeEventType...)
physics/aabb.ts碰撞检测RigidBody2D / Collider2D / PhysicsSystem2D
anim/tween.ts补间与缓动tween
anim/flipbook.ts帧动画Animation + AnimationClip
audio/pool.ts音效池AudioSource.playOneShot
assets/loader.ts资源加载resources / assetManager
ui/layout.ts布局Widget / Layout
save/codec.ts存档格式与迁移仍是你的,介质换 sys.localStorage

注意最后一行:存档格式不换。它是你的数据契约,引擎没有理由替它做主——这是迁移里最容易被忽略、也最容易出错的一块。

18.2.3 第一步:抽出与引擎无关的纯逻辑

迁移前先做一件事:把 mini2d 里的纯逻辑抠出来,放进一个不 import 任何引擎符号的目录。以《方块冲刺》的跑动为例:

// logic/run.ts —— 纯逻辑,mini2d 与 Cocos 共用同一份
export interface RunState {
  x: number;
  vx: number;
  onGround: boolean;
}

export const MAX_SPEED = 260;
export const ACCEL = 1200;

export function stepRun(s: RunState, input: number, dt: number): RunState {
  let vx = s.vx + input * ACCEL * dt;
  vx = Math.max(-MAX_SPEED, Math.min(MAX_SPEED, vx));
  return { x: s.x + vx * dt, vx, onGround: s.onGround };
}

这段代码在 mini2d 里是这么用的(update 里直接调),在 Cocos 里也一样。只要你没在这段代码里写 ctx、node、worldMatrix 这类词,它就能一字不改地跨过去。 判断标准很简单:一个文件能不能在 Node 里跑单测(第 16 章)——能跑,就是纯逻辑。

18.2.4 第二步:把节点树换成场景与预制体

mini2d 的节点树是运行时用 addChild 拼出来的;Cocos 里对应的结构在编辑器里搭好,存成 .scene 与 .prefab。这一步属于 L3——需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。你在编辑器里会看到:

  • 层级管理器(Hierarchy)是一棵树,和 mini2d 的 Node 树形状一致;
  • 把玩家拖进 prefabs/player.prefab,它就成了可复用的预制体;
  • 场景文件 main.scene 保存的是这棵树的序列化结果,不是代码。

一个预制体的序列化结果大致长这样(.prefab 片段,需编辑器打开,不可命令行复现):

{
  "__type__": "cc.Prefab",
  "data": {
    "__type__": "cc.Node",
    "name": "coin",
    "_children": [
      { "__id__": 2 }
    ],
    "_components": [
      { "__id__": 3 }
    ]
  }
}

__id__ 是文件内的引用编号——节点引用子节点、组件引用所属节点,全靠这些编号串起来。你不需要手写它,但理解它有助于排查「迁移后组件丢了」这类问题:多半是某个引用编号对应的对象没被正确序列化。

运行时要用预制体时,用 instantiate:

import { _decorator, Component, Prefab, instantiate, Node } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('Spawner')
export class Spawner extends Component {
  @property(Prefab)
  coinPrefab: Prefab | null = null;

  @property(Node)
  layer: Node | null = null;

  spawnCoin(): void {
    if (!this.coinPrefab || !this.layer) return;
    const coin = instantiate(this.coinPrefab);   // 复制一份预制体
    this.layer.addChild(coin);                   // 挂到指定父节点下
  }
}

对照 mini2d:instantiate(prefab) 相当于「按模板 new 一个节点并递归复制子节点」,addChild 和我们第 3 章写的一模一样。区别在于模板不在代码里,而在编辑器的资源文件里。

18.2.5 第三步:把组件脚本挂上节点

mini2d 的组件是 new 出来、addComponent 上去的;Cocos 里组件脚本用 @ccclass 注册,在编辑器里拖到节点上。逻辑代码基本不变,只是「谁来 new 组件」从你的代码变成了编辑器:

import { _decorator, Component, Vec3 } from 'cc';
import { stepRun, RunState } from '../logic/run';
const { ccclass } = _decorator;

@ccclass('Player')
export class Player extends Component {
  private state: RunState = { x: 0, vx: 0, onGround: true };

  update(dt: number): void {
    this.state = stepRun(this.state, this.readInput(), dt);
    // 纯逻辑算出的 x,写回节点的位置
    this.node.setPosition(this.state.x, 0, 0);
  }

  private readInput(): number { return 0; }
}

看到没——stepRun 一字未改。这就是先搬纯逻辑的收益:组件脚本退化成一层薄薄的「读输入 → 调逻辑 → 写节点」的适配层。

18.2.6 陷阱一:坐标系差异

这是迁移里最容易静默错位的一条。两套坐标系的差异:

维度mini2d(Canvas2D)Cocos Creator 3.8
y 轴方向向下为正(屏幕坐标)向上为正
原点画布左上角节点父空间的锚点
锚点精灵左上角对齐UITransform 默认居中 (0.5, 0.5)
位置分量(x, y)(x, y, z),2D 只用到 x/y

后果很直接:mini2d 里「向上跳」是 y -= speed,搬到 Cocos 后要写成 y += speed;mini2d 里精灵的 (x, y) 是左上角,Cocos 里是中心点——如果你直接照搬坐标,所有物体都会偏移半个尺寸。迁移时第一件事就是统一一套「设计坐标」(建议直接采用 Cocos 的 y-up + 中心锚点),让纯逻辑只认这套坐标,适配层负责与外部的屏幕坐标互转。

18.2.7 陷阱二:生命周期顺序

mini2d 的生命周期是我们第 6 章定义的:onLoad → onEnable → start → update。Cocos 的 Component 用同样的命名,但有两个坑:

  • onLoad 的先后顺序不保证:同一场景里多个组件的 onLoad 按引擎内部顺序调用,别在 onLoad 里假设「另一个组件的 onLoad 已经跑完」。要依赖别人,用 start(所有 onLoad 都完成后才轮到 start)。
  • update 里改节点父子关系要小心:在遍历中 addChild / destroy 可能影响同帧的其他组件,稳妥做法是压队列、帧末统一处理——这正是我们第 6 章 markDestroy 的思路。

18.2.8 陷阱三:资源引用方式

mini2d 里资源用字符串路径加载(loader.load('coin'));Cocos 里有两种引用方式,用错了会拿到 null:

  • 编辑器引用:在 @property(Prefab) / @property(SpriteFrame) 上,从资源管理器把资源拖进来。它在场景序列化时被存成引用,加载即有效。
  • 运行时按路径加载:resources.load('textures/coin/spriteFrame', SpriteFrame, cb)。路径是相对 assets/resources/ 的、且要带上资源类型后缀(图集里的一帧路径是 .../spriteFrame)。

常见错误是把编辑器资源放在 assets/resources/ 之外,却试图用 resources.load 去取——取不到,回调里 err 非空。规则记牢:只有 assets/resources/ 下的资源才能被 resources.load 按路径找到,其余都要靠编辑器引用。

import { _decorator, Component, Sprite, SpriteFrame, resources } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('Coin')
export class Coin extends Component {
  // 方式一:编辑器引用——在检查器里从资源管理器拖进来
  @property(SpriteFrame)
  frame: SpriteFrame | null = null;

  start(): void {
    const sprite = this.node.getComponent(Sprite);
    if (sprite && this.frame) sprite.spriteFrame = this.frame;
  }

  // 方式二:运行时按路径加载——资源必须在 assets/resources/ 下
  loadRuntime(): void {
    resources.load('textures/coin/spriteFrame', SpriteFrame, (err, sf) => {
      if (err) return;   // 路径不对时 err 非空,这里是迁移期最高频的报错点
      const sprite = this.node.getComponent(Sprite);
      if (sprite) sprite.spriteFrame = sf;
    });
  }
}

18.2.9 陷阱四:序列化

mini2d 的配置是 JSON 文件,读进来 JSON.parse 就是普通对象;Cocos 的序列化是两套并存的:

  • 编辑器序列化:场景与预制体(.scene / .prefab)里的每个 @property 字段,由编辑器写进文件、运行时反序列化回来。你不需要手写这些文件。
  • 业务序列化:存档、关卡数据这些仍然是你自己的 JSON,和第 12 章一模一样。

坑在边界:别把运行时才有的东西(比如临时 Node 引用、回调函数)声明成 @property——编辑器序列化不了函数,运行时的对象引用也不该进存档。@property 只放「需要在编辑器里配置、且能被序列化」的字段:数字、字符串、资源引用、子节点引用。

// 反面示例:把运行时对象塞进 @property
@ccclass('BadConfig')
export class BadConfig extends Component {
  @property
  onCollected: (score: number) => void = () => {};   // 函数序列化不了,迁移后会丢
}

上例在编辑器里保存后再打开,onCollected 会退回空函数——因为 .scene 里存不下一个闭包。正确做法是:@property 只声明配置数据,回调在代码里通过 EventTarget 或直接方法调用接线(第 6.2 节那套事件总线思路在 Cocos 里同样适用)。

18.2.10 迁移检查清单

把上面的内容收成一份可勾选的清单,按顺序过一遍:

  • 纯逻辑已抽出到 logic/,不 import 任何 cc 符号,能独立跑单测。
  • 坐标系已统一(y 方向、原点、锚点三处都对过)。
  • 场景与预制体在编辑器里搭好,结构对应原来的节点树。
  • 每个组件脚本用 @ccclass 注册,编辑器里已挂到对应节点。
  • onLoad 里没有跨组件的顺序假设,依赖初始化挪到 start。
  • 帧末统一处理销毁与增删,不在 update 遍历中直接改树。
  • 资源引用方式一致:编辑器引用 vs resources.load 分清,路径正确。
  • @property 字段只放可序列化的数据,不放函数与运行时对象。
  • 存档格式与版本号原样保留,介质换成 sys.localStorage。
  • 迁移后逐项回归:跑通、跳、收集、碰撞、存档五条主路径。

18.2.11 一个真实的迁移顺序

把上面的步骤串成一次真实迁移的时序,你会发现它其实很短:

  1. 在 Cocos 里新建工程,assets/ 下建 scenes / prefabs / scripts / logic 四个目录。
  2. 把 mini2d 的纯逻辑文件(logic/)整个拷过来,先跑一遍单测确认没退化。
  3. 在编辑器里搭主场景,把玩家、金币、地面做成预制体。
  4. 逐个写组件脚本:每个脚本只做「读引擎数据 → 调 logic/ → 写回引擎数据」。
  5. 接输入、接音效、接 UI,全部走引擎组件。
  6. 存档格式原样搬,介质换 sys.localStorage。
  7. 逐条过 18.2.10 的检查清单。

整个迁移的「重活」在编辑器搭场景与写适配层,而不是改逻辑。 这也是为什么卷一花了 17 章让你先亲手实现底层——你现在知道每个模块「在 Cocos 里对应什么」,迁移就不是摸索,而是照单执行。

18.2.12 迁移后不该保留的东西

迁移不是「把 mini2d 整个塞进 Cocos」,有几样东西搬过去只会拖后腿,应当果断丢掉:

  • 自研的渲染遍历:render-tree.ts 这类代码在 Cocos 里完全多余,渲染交给引擎组件。
  • 自研的帧循环:loop.ts 的 rAF 驱动与 Cocos 的 director 会互相打架,只保留累加器那一小段。
  • 自研的输入绑定:DOM 事件绑定在原生平台根本不存在,一律换成 input 与节点事件。
  • 自研的资源缓存:loader.ts 的引用计数与引擎的 assetManager 重复,删掉以免两套计数打架。

保留的是纯逻辑与数据契约(存档格式、掉落表、关卡数据),丢掉的是与引擎职责重叠的外壳。 判断标准就一句:这段代码是不是在替引擎干它已经干了的活?是,就删。

下一节是全书最后一节:回顾 mini2d 从第 1 章到第 18 章的完整演进,总结这 18 章的核心收获,并给出继续深入的方向。

阅读导航:上一节:18.1 同一款游戏的两套实现对照 · 下一节:18.3 回顾与路线:进入实战卷 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画