普通视图

发现新文章,点击刷新页面。
昨天 — 2026年5月23日首页

Cocos Creator 3.x 装饰器实战:让你的代码优雅 10 倍

作者 烛阴
2026年5月23日 21:08

一、为什么必须懂装饰器

Cocos Creator 3.x 全面拥抱 TypeScript,最直接的红利就是装饰器(Decorator)。从 2.x 的 cc.Class({ ... }) 到 3.x 的 @ccclass,看似只是语法糖,实际上是组件开发模式的彻底重构。

先看一组对比,你立刻就能感受到差距。

Cocos Creator 2.x 写法:

cc.Class({
    extends: cc.Component,
    properties: {
        speed: {
            default: 100,
            type: cc.Float,
            tooltip: '移动速度',
            range: [0, 1000]
        },
        target: {
            default: null,
            type: cc.Node
        }
    },
    onLoad() { /* ... */ },
    start() { /* ... */ }
});

Cocos Creator 3.x 写法:

@ccclass('PlayerController')
export class PlayerController extends Component {
    @property({ tooltip: '移动速度', range: [0, 1000] })
    speed: number = 100;

    @property(Node)
    target: Node = null!;

    onLoad() { /* ... */ }
    start() { /* ... */ }
}

差异点很明显:

  • 类型推断:3.x 直接用 TypeScript 类型,IDE 智能提示拉满
  • 代码结构:属性声明和方法声明位置一致,可读性远超 2.x
  • 关注点分离:装饰器是元数据,逻辑是逻辑,互不干扰
  • 可组合:多个装饰器可以叠加,灵活程度跃升一个量级

二、装饰器基础速通

2.1 什么是装饰器

装饰器(Decorator)是 TypeScript 提供的元编程能力,本质是一个函数,它能在运行时附加在类、属性、方法、参数上,修改其行为或添加元数据。

// 装饰器就是一个函数
function MyDecorator(target: any) {
    console.log('我被附加到了', target.name);
}

@MyDecorator
class MyClass { }
// 输出:我被附加到了 MyClass

2.2 装饰器的四种形态

类型 附加位置 Cocos 典型代表
类装饰器 @ccclass
属性装饰器 属性 @property
方法装饰器 方法 自定义节流、日志等
参数装饰器 参数 依赖注入框架常用

2.3 在 Cocos Creator 3.x 中导入装饰器

所有内置装饰器都从 cc 模块导入:

import { _decorator, Component, Node } from 'cc';
const { ccclass, property, executeInEditMode, menu, requireComponent } = _decorator;

这是 3.x 项目的标配头部。_decorator 是命名空间,里面装了所有官方装饰器。

小贴士:用 Cocos Creator 编辑器创建脚本时,会自动生成 _decorator 导入语句,无需手动写。


三、@ccclass 类装饰器深度解析

@ccclass 是 Cocos Creator 3.x 最基础也最重要的装饰器,任何继承 Component 的脚本必须使用它,否则引擎无法识别该类。

3.1 基础用法

@ccclass('PlayerController')
export class PlayerController extends Component {
    // ...
}

参数是一个字符串,作为类在引擎中的注册名称。这个名称用于序列化,一旦使用就不要随意修改,否则旧的预制体或场景会丢失引用。

3.2 命名空间策略

对于大型项目,建议加上命名空间避免冲突:

@ccclass('Game.UI.MainMenu')
export class MainMenu extends Component { }

@ccclass('Game.Battle.PlayerController')
export class PlayerController extends Component { }

这样在编辑器组件菜单和资源面板中,会有清晰的层级显示。

3.3 不传参数的简写

@ccclass
export class MyComponent extends Component { }

不传参数时,类名会自动作为注册名。生产环境不推荐这种写法,因为 minify 之后类名会变,导致序列化失效。


四、@property 属性装饰器全攻略

@property 是 Cocos Creator 3.x 中使用频率最高的装饰器,决定属性是否序列化、是否显示在编辑器、显示样式如何。

4.1 七种核心用法

用法 1:纯类型推断

@property
hp: number = 100;

TypeScript 自动推断为 number,序列化也按 number 处理。简单字段推荐这种写法

用法 2:显式类型声明

@property(Node)
target: Node = null!;

@property(Prefab)
bulletPrefab: Prefab = null!;

当属性是引用类型(Node、Prefab、SpriteFrame 等),必须显式声明类型,编辑器才能在面板显示正确的拖拽控件。

用法 3:完整 options 配置

@property({
    type: Node,
    tooltip: '主摄像机节点',
    displayName: '相机',
    visible: true,
    serializable: true,
})
mainCamera: Node = null!;

options 对象支持非常多配置项,下面逐一展开。

用法 4:数组类型

@property({ type: [Node] })
enemies: Node[] = [];

@property({ type: [Prefab] })
weaponPrefabs: Prefab[] = [];

数组类型必须用方括号包裹元素类型,否则编辑器无法识别。

用法 5:枚举类型

enum WeaponType {
    Sword = 0,
    Bow = 1,
    Staff = 2,
}

@ccclass('Weapon')
export class Weapon extends Component {
    @property({ type: Enum(WeaponType) })
    weaponType: WeaponType = WeaponType.Sword;
}

枚举必须用 Enum() 包装,编辑器会展示为下拉框。

用法 6:自定义类(非 Component)

@ccclass('SkillData')
export class SkillData {
    @property
    name: string = '';

    @property
    damage: number = 0;
}

@ccclass('Player')
export class Player extends Component {
    @property({ type: SkillData })
    skill: SkillData = new SkillData();

    @property({ type: [SkillData] })
    skills: SkillData[] = [];
}

数据类不继承 Component,但只要加上 @ccclass + @property,就能在编辑器面板里编辑。

用法 7:getter/setter

@ccclass('HealthBar')
export class HealthBar extends Component {
    private _hp: number = 100;

    @property
    get hp(): number {
        return this._hp;
    }

    set hp(value: number) {
        this._hp = clamp(value, 0, 100);
        this.updateUI();
    }

    private updateUI() { /* ... */ }
}

通过 getter/setter 可以在属性变更时自动触发逻辑,比如更新 UI、播放动画等,远比手动调用 setHP() 优雅。

4.2 options 完整字段速查

字段 类型 作用
type Constructor 显式声明类型
default any 默认值(推荐直接初始化字段,不用此项)
serializable boolean 是否序列化保存,默认 true
visible boolean / Function 编辑器是否可见,可以传函数动态决定
displayName string 编辑器显示的名字(覆盖字段名)
displayOrder number 编辑器面板中的显示顺序
tooltip string 鼠标悬停时的提示文字
readonly boolean 是否只读
range [min, max, step] 数值范围(带步长)
slide boolean 是否显示为滑动条
group string / object 属性分组
min / max number 数值上下限(不带步长)
step number 数值步长
unit string 单位(显示在值后面)
multiline boolean 字符串多行编辑

4.3 高级技巧:visible 函数

@ccclass('Weapon')
export class Weapon extends Component {
    @property({ type: Enum(WeaponType) })
    weaponType: WeaponType = WeaponType.Sword;

    // 仅当武器类型是 Bow 时显示这个字段
    @property({
        visible: function (this: Weapon) {
            return this.weaponType === WeaponType.Bow;
        }
    })
    arrowSpeed: number = 500;
}

这种条件显示特性可以让编辑器面板更整洁,根据当前配置动态展示相关字段。

4.4 高级技巧:分组管理

@ccclass('Player')
export class Player extends Component {
    @property({ group: { name: '基础属性', id: '1' } })
    hp: number = 100;

    @property({ group: { name: '基础属性', id: '1' } })
    mp: number = 50;

    @property({ group: { name: '战斗属性', id: '2' } })
    attack: number = 10;

    @property({ group: { name: '战斗属性', id: '2' } })
    defense: number = 5;
}

通过 group 字段,可以把相关属性聚合在一起,编辑器面板呈现折叠分组,对于大型组件至关重要


五、编辑器行为装饰器

这一类装饰器决定组件在编辑器中的行为,是提升团队协作效率的利器。

5.1 @executeInEditMode:编辑器实时预览

@ccclass('PreviewBox')
@executeInEditMode
export class PreviewBox extends Component {
    @property
    size: number = 100;

    update() {
        // 编辑器中也会运行
        this.node.setScale(this.size, this.size, 1);
    }
}

加了这个装饰器,updateonLoad 等生命周期在编辑器环境也会触发。适合做实时预览效果,比如调整参数立即看到结果。

注意:不要在 executeInEditMode 的组件里访问运行时才存在的资源(比如远端加载的图片)。

5.2 @menu:自定义菜单分类

@ccclass('SmokeEffect')
@menu('特效/烟雾效果')
export class SmokeEffect extends Component { }

@ccclass('ExplosionEffect')
@menu('特效/爆炸效果')
export class ExplosionEffect extends Component { }

在编辑器 添加组件 菜单里,组件会按 菜单路径 分类显示。大项目必备,避免菜单一堆杂乱的组件。

5.3 @requireComponent:依赖声明

@ccclass('Mover')
@requireComponent(RigidBody2D)
export class Mover extends Component {
    private rb: RigidBody2D = null!;

    onLoad() {
        // 一定能拿到,不需要判空
        this.rb = this.getComponent(RigidBody2D)!;
    }
}

Mover 被添加到节点时,如果节点上没有 RigidBody2D,引擎会自动添加。同时移除时如果有其他组件依赖它,会阻止移除。

5.4 @disallowMultiple:禁止重复添加

@ccclass('UniqueController')
@disallowMultiple
export class UniqueController extends Component { }

防止一个节点上添加多个相同组件。所有单例职责的组件都应该加这个,防止策划误操作。

5.5 @executionOrder:执行顺序

@ccclass('GameManager')
@executionOrder(-1000)
export class GameManager extends Component {
    onLoad() {
        // 在所有默认组件之前执行
    }
}

@ccclass('UIManager')
@executionOrder(1000)
export class UIManager extends Component {
    onLoad() {
        // 在所有默认组件之后执行
    }
}

数字越小,执行越靠前。默认是 0,负数提前,正数延后。常用于全局管理类组件,确保它们先于业务组件初始化。

5.6 @help:帮助文档链接

@ccclass('ComplexComponent')
@help('https://docs.your-game.com/components/complex')
export class ComplexComponent extends Component { }

编辑器面板右上角会出现帮助按钮,点击跳转到指定 URL。


六、自定义装饰器:通用能力封装

6.1 单例模式装饰器

function singleton<T extends new (...args: any[]) => any>(constructor: T) {
    let instance: InstanceType<T> | null = null;

    return class extends constructor {
        constructor(...args: any[]) {
            if (instance) {
                return instance;
            }
            super(...args);
            instance = this as InstanceType<T>;
        }
    };
}

@singleton
class GameManager {
    private _data: any = {};

    setData(key: string, value: any) {
        this._data[key] = value;
    }

    getData(key: string) {
        return this._data[key];
    }
}

const a = new GameManager();
const b = new GameManager();
console.log(a === b); // true

注意:单例装饰器不适合 Component 类,Component 必须挂在节点上。这种模式更适合纯数据管理类。

6.2 节流装饰器

防止某些方法被频繁调用(比如按钮点击、网络请求):

function throttle(delay: number = 1000) {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const original = descriptor.value;
        const lastCallMap = new WeakMap<object, number>();

        descriptor.value = function (...args: any[]) {
            const now = Date.now();
            const last = lastCallMap.get(this) || 0;

            if (now - last >= delay) {
                lastCallMap.set(this, now);
                return original.apply(this, args);
            } else {
                console.log(`[throttle] ${propertyKey} 节流中`);
            }
        };

        return descriptor;
    };
}

@ccclass('SkillController')
export class SkillController extends Component {
    @throttle(500)
    castFireball() {
        console.log('火球术发射!');
        // 真实业务逻辑
    }

    @throttle(2000)
    castUltimate() {
        console.log('大招发动!');
    }
}

亮点@throttle(500) 一行注解,整个方法自动具备节流能力,业务代码零侵入。

6.3 性能监控装饰器

function measureTime(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const original = descriptor.value;

    descriptor.value = function (...args: any[]) {
        const start = performance.now();
        const result = original.apply(this, args);
        const cost = performance.now() - start;

        if (cost > 16) {
            console.warn(`[Performance] ${propertyKey} 耗时 ${cost.toFixed(2)}ms(超过一帧)`);
        }

        return result;
    };

    return descriptor;
}

@ccclass('PathFinder')
export class PathFinder extends Component {
    @measureTime
    findPath(start: Vec3, end: Vec3) {
        // 复杂的 A* 寻路算法
    }

    @measureTime
    updateMap() {
        // 更新地图数据
    }
}

亮点:开发期发现性能瓶颈,无需手动加计时代码。


七、多装饰器叠加顺序

7.1 多装饰器叠加顺序

@ccclass('GameCore')
@executeInEditMode
@disallowMultiple
@menu('系统/游戏核心')
@executionOrder(-9999)
export class GameCore extends Component { }

多个装饰器从下往上执行(最靠近类的先生效)。建议顺序:

  1. @ccclass(必须最上面)
  2. 行为类装饰器(@executeInEditMode@disallowMultiple
  3. 菜单和元数据(@menu@help
  4. 执行控制(@executionOrder
昨天以前首页

TEngine 入门系列(一):TEngine 是什么 & 为什么选它

作者 烛阴
2026年5月8日 19:43

一、做游戏为什么需要框架

1.1 没有框架的开发是什么样的

想象你在盖一栋房子。如果没有图纸、没有脚手架,你能盖吗?能。但你会遇到这些问题:

  • 墙歪了才发现地基没打好
  • 水管和电线混在一起
  • 改个窗户位置,整面墙得拆了重来

游戏开发不用框架,几乎一模一样:

开发阶段 没有框架的典型问题
初期 感觉很快,随便写写就能跑
中期 脚本之间互相引用,牵一发而动全身
后期 加新功能要改 10 个文件,改完原来的功能又坏了
上线后 想热更一个 Bug,发现根本没法热更
多人协作 每个人写法不一样,合并代码就是灾难

1.2 框架解决什么问题

一个好的游戏框架,本质上是帮你制定了一套规则和工具

  • 资源管理:资源怎么加载、怎么卸载、怎么打包——有标准流程
  • UI 管理:界面怎么打开、怎么关闭、怎么分层——有统一入口
  • 事件系统:模块之间怎么通信——不需要互相引用
  • 流程控制:游戏启动、登录、主界面、战斗——有清晰的状态切换
  • 热更新:代码和资源都能在不重新发版的情况下更新

一句话总结:框架让你把精力花在游戏玩法上,而不是重复造轮子。


二、TEngine 是什么

2.1 一句话定位

TEngine 是一个基于 Unity 的开箱即用游戏开发框架,集成了资源管理、UI 系统、事件系统、网络模块、热更新等完整的游戏开发基础设施。

它的目标是:让独立开发者和小团队不需要从零搭建底层架构,直接开始写游戏逻辑。

2.2 核心特性

  • 开箱即用:导入就能跑,不需要复杂配置
  • 模块化设计:每个功能是独立模块,用什么导什么
  • YooAsset 资源管理:业界成熟的资源打包和加载方案
  • HybridCLR 热更新:支持代码和资源双热更
  • 完整的 UI 框架:分层管理、生命周期、堆栈式导航
  • 事件驱动:模块间松耦合通信
  • 持续维护:GitHub 活跃更新,社区支持

2.3 TEngine 的模块全景

┌─────────────────────────────────────────────────────┐
│                    TEngine 框架                      │
├──────────┬──────────┬──────────┬──────────┬─────────┤
│ 资源管理  │ UI 框架   │ 事件系统  │ 流程控制  │ 网络模块 │
│(YooAsset)│(UIModule)│(EventMgr)│(Procedure│(Network)│
│          │          │          │ + FSM)   │         │
├──────────┼──────────┼──────────┼──────────┼─────────┤
│ 对象池   │ 音频管理  │ 计时器   │ 配置表   │ 调试器   │
│(ObjPool) │(AudioMgr)│(TimerMgr)│(DataTable│(Debugger│
│          │          │          │)         │)        │
├──────────┴──────────┴──────────┴──────────┴─────────┤
│              热更新(HybridCLR + YooAsset)            │
└─────────────────────────────────────────────────────┘

每个模块一句话说明:

模块 做什么
资源管理 加载/卸载/打包游戏资源
UI 框架 管理所有界面的打开、关闭、层级
事件系统 模块间发消息,不需要互相认识
流程控制 管理游戏整体阶段切换
网络模块 与服务器通信
对象池 复用频繁创建/销毁的物体
音频管理 播放背景音乐、音效、语音
计时器 延时执行、倒计时、循环计时
配置表 从 Excel 读取游戏数值
调试器 运行时查看日志、性能、内存
热更新 不重新发版就能更新游戏内容

三、为什么选 TEngine

3.1 选几个主流框架对比

对比项 TEngine GameFramework QFramework 不用框架(裸写)
上手难度 中等 较高 最低(初期)
开箱即用 否(需大量配置) 部分
资源管理 YooAsset(成熟) 自带(较老) 需自己接 Resources.Load
热更新 HybridCLR + YooAsset 需自己接 需自己接 不支持
UI 框架 完整(分层+堆栈) 完整但复杂 简洁 自己写
文档质量 中文文档 + 示例 中文文档丰富 中文教程多
适合项目规模 中小型商业项目 中大型项目 小型/原型 极小型 Demo
学习曲线 前期稍陡,后期省力 前期很陡 平缓 前期平缓,后期灾难
社区活跃度 活跃 活跃 活跃 -
  • 除这些之外还有很多有名的框架,比如,ET,MyFramework等等,感兴趣可以自己查看

3.2 什么情况选 TEngine

TEngine 最适合以下场景:

  • 独立开发者或 2~5 人小团队:不想花几周搭底层架构
  • 需要热更新的手游项目:TEngine 原生集成 HybridCLR + YooAsset
  • 有一定 Unity 基础,想进阶到工程化开发:TEngine 的模块设计是很好的学习样本
  • 希望用中文文档和社区获得支持:国内开发者维护,交流无障碍

3.3 TEngine vs 裸写:一个真实场景对比

假设你要实现一个常见功能:玩家完成关卡后,弹出结算面板,显示得分和奖励。

裸写方式:

// GameManager.cs 里
public class GameManager : MonoBehaviour
{
    public GameObject resultPanel; // 在 Inspector 里拖引用
    public Text scoreText;
    public Text rewardText;

    public void OnLevelComplete(int score, int reward)
    {
        resultPanel.SetActive(true);
        scoreText.text = "得分: " + score;
        rewardText.text = "奖励: " + reward;
        // 问题:如果 resultPanel 被销毁了呢?
        // 问题:如果要加动画呢?
        // 问题:如果有多个面板要管理呢?
        // 问题:如果其他脚本也要知道关卡完成了呢?
    }
}

TEngine 方式:

// 1. 定义事件
public static class GameEvents
{
    public static readonly int LevelComplete = "LevelComplete".GetHashCode();
}

// 2. 关卡逻辑完成时,广播事件(不需要知道谁在听)
GameEvent.Send(GameEvents.LevelComplete, new LevelResult { Score = 100, Reward = 50 });

// 3. 结算面板自己监听事件(不需要知道谁发的)
public class UIResultPanel : UIWindow
{
    protected override void OnCreate()
    {
        GameEvent.AddEventListener<LevelResult>(GameEvents.LevelComplete, OnLevelComplete);
    }

    private void OnLevelComplete(LevelResult result)
    {
        // UI 框架自动管理面板的打开/关闭/层级/动画
        FindChildComponent<Text>("ScoreText").text = $"得分: {result.Score}";
        FindChildComponent<Text>("RewardText").text = $"奖励: {result.Reward}";
    }

    protected override void OnDestroy()
    {
        GameEvent.RemoveEventListener<LevelResult>(GameEvents.LevelComplete, OnLevelComplete);
    }
}

关键区别:

  • 关卡逻辑和 UI 面板完全解耦——改一个不影响另一个
  • UI 的生命周期由框架管理——不会出现空引用
  • 想加新面板监听同一个事件?加就行,不用改关卡逻辑的一行代码
❌
❌