《Cocos 游戏开发实战》2.2 属性面板与序列化

属性面板显示哪些字段、值怎么存进 .prefab:讲清 @property 的几种写法(类型、数组、选项对象),为什么改字段名会让编辑器里绑好的引用变空、formerlySerializedAs 怎么救,serializable:false 与 editorOnly 用在哪,并打开预制体看自定义组件的序列化结果。

2.2 属性面板与序列化

上一节我们把节点搭好、预制体做出来了,但有个问题一直悬着:为什么有些字段会出现在属性面板里,有些不会?你在面板里填的值,最后跑到哪去了? 答案是一对概念——@property 决定「显示什么」,序列化决定「存到哪」。

这一节把这条链路彻底讲透。它是卷二里最实用的一节:绝大多数「编辑器里明明绑了引用,运行却报 null」的问题,根源都在这里。

本节写一个真实会用的调参组件 LevelTuning,逐种 @property 写法过一遍,然后打开 .prefab 看它的序列化结果,重点解释两个坑:改字段名导致引用丢失,以及把运行时状态误当配置序列化。读完你会知道什么时候该用 formerlySerializedAs、什么时候该关掉序列化。

2.2.1 Inspector 显示什么,由 @property 决定

规则很干脆:只有带 @property 的字段,才会出现在属性面板里,也才会被序列化。 普通字段(没加装饰器)只是普通的类字段,编辑器看不见它,存盘时也不会写进文件。

字段写法出现在 Inspector被序列化典型用途
@property speed = 240;✅✅可调参数
speed = 240;(无装饰器)❌❌纯运行时临时值
@property({ serializable: false }) t = 0;✅❌想在面板看、但不想存盘
@property({ editorOnly: true }) n = 0;✅仅编辑器编辑器辅助字段

第一行和第三行的区别很关键:serializable: false 的字段能在面板里调、但存盘时被丢掉,下次打开又变回默认值。它适合「调试开关」这类东西。

2.2.2 @property 的几种写法

@property 支持三种调用形态,覆盖不同场景:

@property(CCInteger)                 // 显式类型:整数
@property(CCFloat)                   // 显式类型:浮点
@property(Node)                      // 引用类型:节点
@property([Node])                    // 引用类型数组
@property({ type: CCFloat, min: -3000, max: -500, step: 50 })  // 选项对象
@property                            // 裸装饰器:按 TS 类型推断

裸 @property 最省事:runSpeed = 240 会被推断成浮点、label = 'x' 推断成字符串。但引用类型(Node、Prefab、SpriteFrame)不能靠推断——因为字段初值通常是 null,推断不出类型,必须显式写 @property(Node) 之类。这一点搞混,就会出现「面板里那个拖拽槽不见了」。

2.2.3 一个完整的调参组件

把上面这些拼起来,就是《方块冲刺》里真正会用的关卡调参组件。它本身也是 L2 片段,对过 cc.d.ts:

import {
  _decorator,
  Component,
  Node,
  Prefab,
  SpriteFrame,
  CCInteger,
  CCFloat,
  Color,
  Vec3,
} from 'cc';

const { ccclass, property } = _decorator;

@ccclass('LevelTuning')
export class LevelTuning extends Component {
  @property({ type: CCInteger, tooltip: '最大跳跃次数', range: [1, 5, 1] })
  maxJumps = 2;

  @property({ type: CCFloat, min: -3000, max: -500, step: 50 })
  gravity = -1800;

  @property
  runSpeed = 240;

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

  @property(Prefab)
  obstaclePrefab: Prefab | null = null;

  @property(SpriteFrame)
  coinIcon: SpriteFrame | null = null;

  @property([Node])
  checkpoints: Node[] = [];

  @property(Color)
  tint = new Color(255, 255, 255, 255);

  @property(Vec3)
  offset = new Vec3(0, 0, 0);

  @property({ serializable: false })
  elapsed = 0;

  update(dt: number): void {
    this.elapsed += dt;
  }
}

在编辑器里选中挂了 LevelTuning 的节点,属性面板会按字段顺序显示:Max Jumps 是一个带范围滑条的整数框(15,步长 1)、Gravity 是一个滑块(-3000-500)、Run Speed 是普通数字框、Spawn Point / Obstacle Prefab / Coin Icon 是三个拖拽槽、Checkpoints 是可增删的节点数组、Tint 是颜色选择器、Offset 是三个数字框、Elapsed 是一个只读般的数字框(可调但不存)。

2.2.4 值是怎么被序列化的

你在面板里改一个值,编辑器不会立刻写文件——它标记「脏」,在你保存(Ctrl/Cmd + S)时把整棵节点树序列化。序列化的规则是:带 @property 且可序列化的字段,按名字写进组件对象。

一个预制体里的 LevelTuning 组件,序列化后大致是这样:

以下片段需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现:

{
  "__type__": "LevelTuning",
  "node": { "__id__": 2 },
  "_enabled": true,
  "maxJumps": 2,
  "gravity": -1800,
  "runSpeed": 240,
  "spawnPoint": { "__id__": 5 },
  "obstaclePrefab": { "__uuid__": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d" },
  "checkpoints": [],
  "tint": { "__type__": "cc.Color", "r": 255, "g": 255, "b": 255, "a": 255 },
  "offset": { "__type__": "cc.Vec3", "x": 0, "y": 0, "z": 0 }
}

三种值的存法各不一样:

  • 数字、字符串、布尔直接内联,如 "maxJumps": 2。
  • 场景内节点存成 { "__id__": 5 },指向同一文件里的另一个对象——所以引用必须在同一棵树里。
  • 资源(预制体、贴图)存成 { "__uuid__": "..." },指向资源自己的 uuid,跨文件引用靠它。

注意 elapsed 不在这个 JSON 里——因为它 serializable: false。这就是「面板可见、存盘丢弃」的实证。

2.2.5 改字段名,引用为什么会变空

这是本节最重要的一个坑。假设你把 coinPrefab 改名成 pickupPrefab:

// 改名前
@property(Prefab)
coinPrefab: Prefab | null = null;

// 改名后
@property(Prefab)
pickupPrefab: Prefab | null = null;

保存后,编辑器里的拖拽槽变空了。原因藏在 2.2.4 的 JSON 里:序列化是按字段名写的,文件里存的是 "coinPrefab": { "__uuid__": ... }。你把字段名改成 pickupPrefab,引擎去找 pickupPrefab 这个键,找不到,于是取默认值 null——原来绑的那个预制体引用就丢了。

正确做法是用 formerlySerializedAs 声明「我以前叫这个名字」:

@property({ formerlySerializedAs: 'coinPrefab' })
pickupPrefab: Prefab | null = null;

引擎读到这行,会先去文件里找 pickupPrefab,找不到再回头找 coinPrefab,把旧值迁移过来。改名时永远带上 formerlySerializedAs,否则团队里每个人的编辑器都会静默丢引用,而且这种 bug 不报错、只在运行时表现为「预制体没生成」。

2.2.6 哪些字段不该序列化

序列化不是越多越好。下面三类字段应该主动排除:

字段类型处理理由
运行时状态(计时器、缓存)serializable: false存盘没意义,还会污染 diff
派生值(能算出来的)serializable: false存了反而可能不一致
编辑器辅助(预览开关)editorOnly: true只在编辑器里用,不进包

一个反例:把「当前帧的碰撞盒」这类运行时对象做成 @property。后果是每次运行后保存场景,main.scene 都会变——因为碰撞盒的引用变了。这种「每次保存都有一堆无意义 diff」的现象,九成是把运行时状态序列化了。

2.2.7 数组与资源引用的边界

两个容易搞错的地方:

第一,数组元素类型要写清。 @property([Node]) checkpoints 里那个方括号不能省。写成 @property checkpoints: Node[] = [] 会因为初值空数组推断不出元素类型,面板里显示不出可拖拽的数组槽。

第二,跨场景引用会断。 2.2.4 说过,节点引用存成 __id__,只在同一个文件内有意义。所以你不能在 main.scene 里把一个 @property(Node) 指向 level.scene 里的节点——场景是分别序列化的。跨场景共享的东西,要么做成预制体,要么用 5.3 讲的常驻节点。

2.2.8 让面板更好用:分组、提示与条件显示

@property 的选项对象不只是用来标类型的,它还能改造属性面板本身。几个最实用的:

选项效果
tooltip鼠标悬停显示说明文字
displayName面板里显示的名字(默认是字段名)
group把字段折叠进一个分组块
range[min, max, step],显示成滑条
visible传函数,按条件显示/隐藏该字段

visible 传一个函数时,可以做到「勾了某个开关,才显示相关字段」——比如 debugDraw 关着的时候,隐藏 debugColor。这在参数很多的调参组件里很省地方。

@property({ type: CCBoolean })
debugDraw = false;

@property({ type: Color, visible() { return this.debugDraw; } })
debugColor = new Color(255, 0, 0, 255);

注意 visible 里 this 指向组件实例,所以能读到 debugDraw 的当前值。这类「面板联动」只影响显示,不影响序列化——隐藏的字段照样存盘。

2.2.9 getter / setter 也能是 @property

有些字段需要在赋值时做钳制(比如血量不能为负、不能超过上限)。这时把 @property 加在访问器上:

@property({ type: CCInteger, min: 0, max: 100 })
get hp(): number {
  return this._hp;
}
set hp(value: number) {
  this._hp = Math.max(0, Math.min(100, value));
}

private _hp = 100;

面板里 hp 显示为一个 0~100 的整数框,但真正被序列化的是 _hp——访问器只是个入口。这么写的好处是:无论是编辑器面板赋值、还是代码里 tuning.hp = 200,都会被同一段钳制逻辑收口,不会出现「面板限制了、代码绕过了」的不一致。

一个代价:访问器写法下,面板里那个字段名会带下划线(因为它存的是 _hp)。追求面板美观的话,用 displayName 覆盖显示名即可。

2.2.10 本节自检清单

  • 每个要在面板里调的字段都加了 @property
  • 引用类型(Node/Prefab/SpriteFrame)都显式写了类型
  • 数组用 @property([Node]) 写法,不是靠推断
  • 改过名的字段都补了 formerlySerializedAs
  • 运行时状态用 serializable: false,不进 .scene/.prefab
  • 保存后 git diff 里没有莫名其妙的引用变化
  • 跨场景引用没有被误用(同文件内才能用 __id__)

2.2.11 工程视角的收口

这一节之后,工程里多了 assets/scripts/game/level-tuning.ts 这个调参组件,以及它序列化到预制体里的那一串字段。你第一次亲眼看到了「面板里的值 → 文件里的键」这条链路,也知道了它有多脆——一个字段名就能让引用静默丢失。

下一节我们把视角放到更底层:资源本身。一张 PNG 拖进 assets/ 之后,编辑器在旁边生成了一个同名的 .meta 文件。它是什么、为什么必须提交、为什么绝不能手工改、uuid 冲突怎么办——这些是资源管线(第 4 章)的地基。这就是 2.3 的内容。

阅读导航:上一节:2.1 场景、节点与预制体 · 下一节:2.3 资源导入与 .meta 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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