《Cocos 游戏开发实战》3.1 @ccclass / @property 与类型系统

装饰器是 Cocos 组件脚本的地基:@ccclass 把类注册进序列化器,@property 把字段暴露到属性面板。本节讲清四个基础类型标注为什么必须显式写、引用类型与数组的默认值约定、erasable syntax 下用 Enum() 替代 TS enum,以及 tooltip/range/visible/formerlySerializedAs 等选项的面板效果,并给出挂组件后应显示的值。

3.1 @ccclass / @property 与类型系统

卷一 6.3 我们让 mini2d 从 JSON 里读组件参数,走的是「手写序列化器 + 字段名约定」这条路,读者得自己维护字段表和默认值。Cocos Creator 把这一步做成了语言级设施:用装饰器把「类的身份」和「字段的类型」声明给引擎,编辑器据此生成属性面板,运行时据此反序列化场景与预制体。这一节把这两个装饰器的边界讲清楚,它们是卷二全部组件脚本的地基。

与站内 game/client/ 那批 Godot/Phaser 工程文的区别在于:那边讲的是「组件怎么写」,这里讲的是**「编辑器凭什么认识你写的组件」**——.scene 里存的不是你的字段名,而是 __type__ 加一串序列化后的值,中间那层翻译全靠本节的两个装饰器。

3.1.1 @ccclass 到底注册了什么

@ccclass 把普通 TS 类登记进 Cocos 的序列化器注册表。注册之后引擎才知道:这个类的名字是什么、它有哪些可序列化字段、字段类型是什么。没有这一步,.scene 里 "__type__": "BlockDashPlayer" 这一项就无法还原成对象。

import { _decorator, Component } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('BlockDashPlayer')
export class BlockDashPlayer extends Component {
  @property
  speed = 320;
}

@ccclass('BlockDashPlayer') 里的字符串就是注册名。它有两个必须记住的约束:

  • 注册名全局唯一。两个不同目录下的脚本都叫 Player,后加载的会覆盖前者,症状是「属性面板上挂的是 A,运行时跑的是 B」。
  • 不传名字时用类名,但类名会被压缩混淆(构建时的 --minify 或引擎的压缩选项会改函数名),所以正式项目一律显式传名字。

还有一个容易踩的点:只有继承自 Component 的类才能被挂到节点上。继承自普通类(比如一个 LevelConfig)也可以用 @ccclass 注册,但那只是为了让它能被序列化成资源字段,不能 addComponent。

3.1.2 为什么必须显式标注 CCInteger / CCFloat

TypeScript 的类型是编译期概念,经过类型剥离后运行时完全看不到 number、boolean、string 的区分——它们在 JS 里就是 number、boolean、string 三个值,而 number 分不出整数和浮点。属性面板要渲染成「整数输入框」还是「滑动条」,必须由开发者显式告诉引擎,这就是四个 CC* 标记存在的理由:

import { _decorator, Component, CCInteger, CCFloat, CCBoolean, CCString } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('TypeDemo')
export class TypeDemo extends Component {
  @property(CCInteger) hp = 3;
  @property(CCFloat) speed = 320.5;
  @property(CCBoolean) invincible = false;
  @property(CCString) label = 'player';
}
标注面板控件不标的后果
CCInteger整数输入框面板显示为普通数字框,可输入 3.7
CCFloat数字输入框与整数框无法区分,滑动条步长失控
CCBoolean勾选框被当成数字,面板显示 0 / 1
CCString单行文本框被当成未知类型,字段直接不显示

一个反直觉的结论:不标类型不等于报错,而是「静默按默认推断」。推断规则是看默认值——默认值是 3 就当整数,是 'player' 就当字符串。所以「能跑」不代表「面板正确」,团队里最好把「所有 @property 必须带类型」写进代码评审清单。

3.1.3 引用类型、数组与内置值类型

引用类型(Node / Prefab / SpriteFrame / AudioClip)直接把类构造函数传给 @property,面板会渲染成一个拖拽槽;数组写成 { type: [X] },槽位可增删。Color、Vec3 这类内置值类型同样可以直接标注:

import { _decorator, Component, Node, Prefab, SpriteFrame, AudioClip, Color, Vec3 } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('RefDemo')
export class RefDemo extends Component {
  @property(Node) groundCheck: Node | null = null;
  @property(Prefab) coinPrefab: Prefab | null = null;
  @property({ type: [SpriteFrame] }) frames: SpriteFrame[] = [];
  @property({ type: [AudioClip] }) sfx: AudioClip[] = [];
  @property(Color) tint = new Color(255, 255, 255, 255);
  @property(Vec3) spawn = new Vec3(0, 0, 0);
}

三条实践约定,卷二全程遵守:

  • 引用类型默认值一律写 null,别写 new Node()。new Node() 会真的造一个游离节点并序列化进场景,运行时多一个看不见的节点,还占用内存。
  • 数组默认值写 [],不要写 null。面板上的「加号」按钮只在数组类型上出现,null 会让面板退化成单槽。
  • 拖拽槽里的资源会被场景自动引用(引用计数 +1)。「首包只含启动场景与主 UI」这条约束(见 4.1 资源目录与 Bundle )的实现前提就是:关卡贴图不要拖进主场景的组件槽。

3.1.4 枚举:在 erasable syntax 下不要用 TS enum

卷二全书的 TS 限 erasable syntax(Node 26 只做类型剥离,不做代码转换),这意味着 enum、namespace、构造函数参数属性都不能用。Cocos 提供了替代品 Enum(),它在运行时生成一个带反向映射的普通对象,属性面板据此渲染成下拉框:

import { _decorator, Component, Enum } from 'cc';
const { ccclass, property } = _decorator;

export const Direction = Enum({
  Left: 0,
  Right: 1,
});

@ccclass('DirDemo')
export class DirDemo extends Component {
  @property({ type: Enum(Direction) }) dir = Direction.Right;
}

如果不想引入 Enum,用 as const 对象加字面量联合类型也能通过类型检查,只是属性面板会退化成数字框:

export const Facing = { Left: 0, Right: 1 } as const;
export type FacingValue = typeof Facing[keyof typeof Facing];

选哪个取决于团队要不要「面板可读性」。卷一里我们用纯数字常量,卷二因为有策划要看面板,统一用 Enum()。

3.1.5 属性选项与属性面板

@property 接受一个选项对象,常用字段如下(全部来自 cc.d.ts 的 IExposedAttributes):

选项作用面板表现
type显式类型(含数组)决定控件种类
displayName面板显示名替代字段名,可写中文
tooltip悬停提示鼠标停留时弹出
range: [min, max, step]数值区间出现滑动条
slide是否用滑动条与 range 配合
min / max / step数值边界输入越界被夹紧
visible是否显示传 false 隐藏,传函数可动态控制
serializable是否参与序列化false 时构建后不存盘
formerlySerializedAs字段曾用名迁移老场景数据用
editorOnly仅编辑器生效构建后字段被剥离
import { _decorator, Component, CCInteger } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('OptionDemo')
export class OptionDemo extends Component {
  @property({
    type: CCInteger,
    displayName: '空中跳跃次数',
    tooltip: '落地前允许的二段跳次数',
    range: [0, 3, 1],
    slide: true,
    min: 0,
    max: 3,
    step: 1,
    formerlySerializedAs: 'jumpTimes',
  })
  airJumps = 1;

  @property({ type: CCInteger, visible: false, serializable: true })
  internalSeed = 0;
}

formerlySerializedAs 是卷二升级存档和场景时的救火工具:把 jumpTimes 改名成 airJumps 后,老 .scene 里存的仍是 jumpTimes,加上这一行引擎就会按旧名读取,避免「改个字段名,全部关卡预制体参数清零」。

3.1.6 在编辑器里挂组件(需编辑器,不可命令行复现)

本节涉及 .scene 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 下面是可照做的步骤:

  1. 在层级管理器选中 assets/scenes/main.scene 里的 Player 节点。
  2. 属性检查器底部点「添加组件」→ 搜索 BlockDashPlayer → 点击。
  3. 面板上应出现:Speed(数字框,值 320)、Air Jumps(滑动条,值 1)、Ground Check(拖拽槽,空)。
  4. 把 Ground 子节点拖进 Ground Check 槽。

保存后 main.scene 里该节点会出现对应的 _components 项。结构大致如下:

{
  "__type__": "cc.Node",
  "_name": "Player",
  "_components": [
    { "__type__": "cc.UITransform" },
    { "__type__": "BlockDashPlayer", "speed": 320, "airJumps": 1 }
  ]
}

打开 main.scene 会看到:层级管理器里 Player 节点旁多了一个脚本图标,属性检查器里它的 BlockDashPlayer 分组下是 Speed、Air Jumps、Ground Check 三个字段。

注意 __type__ 用的是 @ccclass 注册名(编辑器里会转成脚本的 UUID 形式),不是文件名,也不是 TS 类名。这也是 3.1.1 强调「注册名唯一」的原因:改注册名等于改存盘格式,老场景会全部丢失组件引用。

3.1.7 一个完整组件:从字段到面板

把前面几节的规则合起来,就是《方块冲刺》里最基础的玩家组件。它同时用到基础类型、引用类型、数组、枚举和选项对象:

import { _decorator, Component, Node, Prefab, AudioClip, CCInteger, CCFloat, Enum, SpriteFrame } from 'cc';
const { ccclass, property, menu, disallowMultiple } = _decorator;

export const MoveState = Enum({ Idle: 0, Run: 1, Jump: 2 });

@ccclass('PlayerController')
@menu('BlockDash/PlayerController')
@disallowMultiple
export class PlayerController extends Component {
  @property({ type: CCFloat, range: [200, 600, 10], slide: true })
  runSpeed = 320;

  @property({ type: CCInteger, displayName: '二段跳次数', min: 0, max: 2 })
  airJumps = 1;

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

  @property({ type: [SpriteFrame], tooltip: '跑动序列帧' })
  runFrames: SpriteFrame[] = [];

  @property({ type: AudioClip }) jumpSfx: AudioClip | null = null;

  @property({ type: Prefab }) dustPrefab: Prefab | null = null;

  @property({ type: Enum(MoveState) }) state = MoveState.Idle;
}

在编辑器里挂上之后,属性面板从上到下应显示:Run Speed(滑动条,值 320,范围 200–600)、二段跳次数(整数框,值 1)、Ground Check(空拖拽槽)、Run Frames(空数组,带加号)、Jump Sfx(空拖拽槽)、Dust Prefab(空拖拽槽)、State(下拉框,选中 Idle)。

检查清单(写完组件后逐条过):

检查项通过标准
每个 @property 都有类型面板控件正确,不是「按默认值猜」
引用类型默认值是 null不会造出游离节点
数组默认值是 []面板有加号,可增删槽位
@ccclass 传了显式名字构建压缩后 __type__ 不丢
没有 enum / namespace满足 erasable syntax

3.1.8 类型系统即门禁

本卷所有 import ... from 'cc' 的片段都会被抽出来过一遍 tsc --noEmit,类型声明来自真实的 @cocos/creator-types@3.8.8。这意味着引擎里不存在的符号会当场编译失败——cc.d.ts 是唯一真相,任何凭记忆写出来的 API 名都要先查证。这也解释了为什么本节每个例子都写全 import:片段是可独立编译的,读者可以直接粘进工程。

另一个副作用是:@property({ type: X }) 里的 X 必须是 cc.d.ts 里真实导出的构造器。写 @property({ type: LevelConfig }) 而 LevelConfig 是自定义类时,只要那个类被 @ccclass 注册过就没问题;写 @property({ type: 'SpriteFrame' }) 这种字符串形式虽然也支持,但拼错时不会有类型报错,属于要避免的写法。

下一节我们让这些组件真正「活」起来,讲清 onLoad / start / update 的调用顺序,以及引擎一帧里到底按什么次序调度它们。

阅读导航:上一节:目录 · 下一节:3.2 生命周期与执行顺序 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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