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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。