《Cocos 游戏开发入门》6.3 序列化与数据驱动配置

对象能组装、能跑了,但关卡数值还写死在代码里。本节为 mini2d 引入组件字段描述符(类型与默认值),实现 serializeComponent / deserializeGameObject 双向管线,用 JSON 配置驱动《方块冲刺》的敌人实例化;末节给出 Cocos Creator 3.8 的 _decorator、Prefab 与 JsonAsset 完整对照。

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自动补依赖组件
disallowMultipleClassDecorator & ((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 碰撞检测 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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