6.3 序列化与数据驱动配置
前两节我们把「一个对象由哪些组件拼成」「这些组件什么时候被调用」定了下来。但《方块冲刺》有关卡、有几十种敌人配置,如果每个敌人都得在代码里写一遍 new Enemy(...),调数值就要改代码、重新构建。这一节把组件的参数序列化成 JSON,让对象从配置文件里长出来——这就是数据驱动。末节给出 Cocos Creator 3.8 的对照。
6.3.1 序列化在这里解决什么问题
序列化在本套书里有两个用途,别混为一谈:
- 存档:把运行时的对象状态写进
localStorage,下次读回来(第 12 章细讲)。 - 配置:把设计好的对象「模板」写进 JSON,运行时按模板实例化。
本节只做第二种。目标是:写一份 level-1.json,描述这一关有几个敌人、各自在哪、速度多快,游戏启动时读它、造出对象。要做到这一点,组件必须先能声明自己有哪些可序列化字段。
6.3.2 字段描述符:类型与默认值
给 Component 加一张字段描述表,每个字段写明类型与默认值:
export type FieldType = 'number' | 'string' | 'boolean';
export interface FieldDef {
type: FieldType;
default: number | string | boolean;
}
export class Component {
gameObject!: GameObject;
/** 子类覆写:字段名 -> 描述符 */
static fields(): Record<string, FieldDef> {
return {};
}
/** 按描述符把普通对象写进自身 */
apply(data: Record<string, unknown>): void {
const defs = (this.constructor as typeof Component).fields();
for (const key of Object.keys(defs)) {
const raw = data[key];
if (raw === undefined) continue; // 缺省则保留类字段初始值
(this as Record<string, unknown>)[key] = coerce(raw, defs[key].type);
}
}
}
function coerce(v: unknown, t: FieldType): number | string | boolean {
if (t === 'number') return Number(v);
if (t === 'boolean') return Boolean(v);
return String(v);
}
每个组件声明自己的字段:
export class EnemyComponent extends Component {
speed = 60;
patrolRange = 120;
hp = 30;
kind = 'slime';
static override fields(): Record<string, FieldDef> {
return {
speed: { type: 'number', default: 60 },
patrolRange: { type: 'number', default: 120 },
hp: { type: 'number', default: 30 },
kind: { type: 'string', default: 'slime' },
};
}
}
coerce 是必要的:JSON 里数字可能被写成字符串 "60",编辑器导出的配置也常带类型漂移。显式声明 type 让加载时做一次收敛,比在业务代码里到处 Number(x) 更集中、更好排查。
6.3.3 序列化成 JSON
反方向也要有:把组件实例的字段导出成普通对象。注意只导出描述表里声明的字段,运行时临时状态(缓存引用、内部计时器)不该进配置:
export function serializeComponent(c: Component): Record<string, unknown> {
const defs = (c.constructor as typeof Component).fields();
const out: Record<string, unknown> = {};
for (const key of Object.keys(defs)) {
out[key] = (c as Record<string, unknown>)[key];
}
return out;
}
export function serializeGameObject(go: GameObject): Record<string, unknown> {
return {
name: go.name,
active: go.active,
components: go.components.map((c) => ({
type: c.constructor.name,
data: serializeComponent(c),
})),
};
}
导出结果就是一段可直接 JSON.stringify 的普通对象:
{
"name": "Enemy_A",
"active": true,
"components": [
{ "type": "TransformComponent", "data": { "x": 320, "y": 128 } },
{ "type": "EnemyComponent", "data": { "speed": 60, "patrolRange": 120, "hp": 30, "kind": "slime" } }
]
}
6.3.4 从配置实例化对象
加载端把上面的结构反向展开:按 type 从注册表取构造器,new 出来,apply 数据。注意顺序——先给组件灌数据,再挂到实体上,否则 onAttach 里读到的是默认值:
export interface GameObjectData {
name: string;
active?: boolean;
components: Array<{ type: string; data: Record<string, unknown> }>;
}
export function deserializeGameObject(data: GameObjectData): GameObject {
const go = new GameObject(data.name);
go.active = data.active ?? true;
for (const item of data.components) {
const comp = ComponentRegistry.create(item.type);
if (!comp) {
console.warn(`[mini2d] 未知组件类型:${item.type}`);
continue;
}
comp.apply(item.data);
go.addComponentInstance(comp);
}
return go;
}
addComponentInstance 是给「已经 new 好的实例」用的挂载入口,与 addComponent(ctor) 并存:
addComponentInstance(comp: Component): Component {
comp.gameObject = this;
this.components.push(comp);
comp.onAttach();
return comp;
}
整关的配置就是一组对象数组,遍历即可铺场景:
export function loadLevel(scene: GameObject, cfg: { objects: GameObjectData[] }): void {
for (const raw of cfg.objects) {
scene.addChild(deserializeGameObject(raw));
}
}
6.3.5 在《方块冲刺》里:配置驱动敌人
有了这条管线,调关卡不用碰代码。levels/level-1.json 里放:
{
"objects": [
{ "name": "Enemy_A", "components": [
{ "type": "TransformComponent", "data": { "x": 320, "y": 128 } },
{ "type": "EnemyComponent", "data": { "speed": 60, "hp": 30, "kind": "slime" } }
]},
{ "name": "Enemy_B", "components": [
{ "type": "TransformComponent", "data": { "x": 640, "y": 128 } },
{ "type": "EnemyComponent", "data": { "speed": 90, "hp": 50, "kind": "bat" } }
]}
]
}
策划改一个 speed、加一只敌人,只是编辑 JSON,不必重新构建逻辑代码。默认值兜底也很重要:配置里漏写 hp 时,apply 会跳过该字段,实例保留类字段初始化的 30——所以类字段的初始化值必须与 fields() 里的 default 保持一致,这是这套设计唯一的纪律。为方便人工核对,可以加一条启动自检:
export function validateFields(ctors: Array<typeof Component>): string[] {
const problems: string[] = [];
for (const ctor of ctors) {
const sample = new ctor() as Record<string, unknown>;
for (const [key, def] of Object.entries(ctor.fields())) {
if (sample[key] !== def.default) {
problems.push(`${ctor.name}.${key}: 初始值 ${String(sample[key])} 与默认值 ${String(def.default)} 不一致`);
}
}
}
return problems;
}
6.3.6 Cocos 对照:_decorator、Prefab 与 JsonAsset
Cocos Creator 3.8 把「字段描述符」交给了 _decorator 命名空间的 @property,把「配置对象」交给了 Prefab。以下 L2 片段(import { ... } from 'cc'):
import { Component, Prefab, _decorator } from 'cc';
const { ccclass, property, requireComponent, disallowMultiple, executionOrder, menu } = _decorator;
@ccclass('VelocityComponent')
export class VelocityComponent extends Component {
@property
vx = 0;
}
@ccclass('Enemy')
@requireComponent(VelocityComponent)
@disallowMultiple
@executionOrder(-10)
@menu('方块冲刺/敌人')
export class Enemy extends Component {
@property
speed = 60;
@property
patrolRange = 120;
@property
kind = 'slime';
}
这些装饰器都能在 cc.d.ts 里查到确切签名(均在 export namespace _decorator 内):
| 装饰器 | cc.d.ts 签名 | 作用 |
|---|---|---|
ccclass | ((name?: string) => ClassDecorator) & ClassDecorator | 注册组件类、定名字 |
property | 三个重载:(options?) / (type) / (...args) | 声明可序列化字段 |
requireComponent | (requiredComponent: Function | Function[]) => ClassDecorator | 自动补依赖组件 |
disallowMultiple | ClassDecorator & ((yes?: boolean) => ClassDecorator) | 同节点禁止多份 |
executionOrder | (priority: number) => ClassDecorator | 调整组件执行顺序 |
menu | (path: string) => ClassDecorator | 编辑器添加菜单路径 |
@property 与我们 static fields() 的差别是:Cocos 用它同时驱动编辑器面板渲染和序列化,类型从 TS 字段类型推断,也能显式指定(如 @property(Prefab))。带 @property 的字段会被自动写进 .scene / .prefab,无需手写 serializeComponent。
从配置实例化对象在 Cocos 里对应 instantiate。cc.d.ts 里有两个重载:instantiate(prefab: Prefab): Node 与泛型 instantiate<T>(original: T): T,即 Prefab 克隆出一个 Node,普通对象则深拷贝:
import { instantiate, Component, Node, Prefab, _decorator } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('EnemySpawner')
export class EnemySpawner extends Component {
@property(Prefab)
enemyPrefab: Prefab | null = null;
spawn(): void {
if (!this.enemyPrefab) return;
const node: Node = instantiate(this.enemyPrefab);
this.node.addChild(node);
}
}
而「读一份 JSON 配置」在 Cocos 里用 JsonAsset(cc.d.ts 中 export class JsonAsset extends Asset),通过资源系统加载后取 json 字段即可,等价于我们的 fetch + JSON.parse:
import { JsonAsset, resources } from 'cc';
resources.load('configs/level-1', JsonAsset, (err, asset) => {
if (err) return;
const cfg = asset.json as { objects: unknown[] };
// 用 cfg 驱动关卡生成
});
至此,mini2d 的组件系统三件套齐了:能组装(6.1)、能跑(6.2)、能从数据长出来(6.3)。下一节我们进入碰撞检测,让这些会动的对象真正「撞上」彼此。
阅读导航:上一节:6.2 生命周期与消息分发 · 下一节:7.1 AABB、圆形与 SAT 碰撞检测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。