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 一个真实的迁移顺序
把上面的步骤串成一次真实迁移的时序,你会发现它其实很短:
- 在 Cocos 里新建工程,
assets/下建scenes/prefabs/scripts/logic四个目录。 - 把
mini2d的纯逻辑文件(logic/)整个拷过来,先跑一遍单测确认没退化。 - 在编辑器里搭主场景,把玩家、金币、地面做成预制体。
- 逐个写组件脚本:每个脚本只做「读引擎数据 → 调
logic/→ 写回引擎数据」。 - 接输入、接音效、接 UI,全部走引擎组件。
- 存档格式原样搬,介质换
sys.localStorage。 - 逐条过 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 回顾与路线:进入实战卷 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。