《Cocos 游戏开发实战》3.3 组件通信与事件

组件之间硬引用会织出蜘蛛网。本节把 Cocos Creator 3.8 的三种通信方式摆开对比:直接引用、getComponent、事件。讲清节点事件与全局 EventTarget 总线、target 参数绑定 this 的坑、触摸坐标从屏幕到节点空间的两次换算、全局 input 与 KeyCode 接键盘跳跃,并给出编辑器里给 Button 绑定 Click Events 的步骤与场景文件结构。

3.3 组件通信与事件

3.1 和 3.2 解决了「组件是什么」「组件什么时候跑」。但《方块冲刺》里组件要协作:玩家吃到金币,HUD 要加分,音效要响,成就系统要计数。如果让 PlayerController 直接持有 Hud 和 AudioManager 的引用,依赖会织成一张蜘蛛网——改一个类要连带改一圈。这一节把 Cocos Creator 3.8 提供的通信手段摆开,讲清各自该用在哪。

卷一 6.2 我们给 mini2d 写过一个 EventBus,核心是「发布者只管发,订阅者只管收」。Cocos 内置了同样的设施 EventTarget,还额外提供了节点级的冒泡事件和全局输入事件。卷二直接站在这些内置能力上。

3.3.1 三种通信方式的取舍

方式写法适用代价
直接引用@property(Node) hud层级里稳定存在的父子/兄弟编辑器里要拖,耦合强
getComponentthis.node.getComponent(Hud)同一节点或已知路径运行时查找,路径写死
事件bus.on / emit跨模块、一对多、可解耦调用链隐式,调试要跟

卷二的经验是:同一节点内用 getComponent,父子之间用直接引用,跨模块一律走事件。玩家吃金币属于典型的跨模块,走事件;玩家读自己的 RigidBody2D 属于同节点,getComponent。

事件不是万能药。它最大的代价是调用链变成隐式的:一个 emit 出去,谁在监听、按什么顺序执行、有没有人漏退订,都得靠全局搜索。所以卷二只在真正需要解耦的地方用事件——玩法系统之间、UI 与玩法之间。像「父节点读子节点的血量」这种层级明确的关系,直接引用比事件更清晰。一条判断标准:如果两个组件的关系在场景层级里是稳定的,就用引用;如果关系是「不知道谁在听」,才用事件。

3.3.2 节点事件:on / off / emit

Node 本身就是一个 EventTarget,可以直接在节点上收发自定义事件。这套机制最常见的用途是「让子节点通知父节点」:

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

@ccclass('Emitter')
export class Emitter extends Component {
  onEnable(): void {
    this.node.on('coin:collected', this.onCoin, this);
  }

  onDisable(): void {
    this.node.off('coin:collected', this.onCoin, this);
  }

  private onCoin(): void { /* 收到通知 */ }

  collect(): void {
    this.node.emit('coin:collected');
  }
}

三个要点:

  • 第三个参数 this 是必须的。 on(type, callback, target) 里的 target 用来绑定回调里的 this。漏了它,回调执行时 this 是 undefined(严格模式下),读任何成员都报错。
  • emit 是同步的。 事件发出后订阅者立即执行,别在 emit 里期待「下一帧才处理」。
  • 节点事件不会自动冒泡到父节点,除非事件对象 bubbles 为真。自定义字符串事件默认不冒泡,想跨层级得自己在父节点上监听或用全局总线。

顺带记一个细节:emit(type, arg1, arg2) 的变参会原样透传给回调,不做包装。所以订阅 gameBus.on('coin:collected', this.onCoin, this) 时,回调签名可以写成 onCoin(value: number) 直接收参数。只有引擎内置事件(TOUCH_END 等)才会传一个 EventTouch 对象,自定义事件没有这层包装。

3.3.3 全局事件总线

跨模块通信用一个模块级单例 EventTarget。它和卷一那个 EventBus 语义一致,但 API 由引擎提供:

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

export const gameBus = new EventTarget();

@ccclass('CoinHud')
export class CoinHud extends Component {
  private coins = 0;

  onEnable(): void {
    gameBus.on('coin:collected', this.onCoin, this);
  }

  onDisable(): void {
    gameBus.off('coin:collected', this.onCoin, this);
  }

  private onCoin(value: number): void {
    this.coins += value;
  }
}

约定一条事件命名规范:模块:动作,如 coin:collected、level:finished、item:activated。冒号前缀让事件在日志里一眼可辨,也避免和节点内置的 touch-start 这类事件名撞车。gameBus 定义在 assets/scripts/core/ 下的独立文件里,谁都能 import,但谁都不持有对方的实例——这就是解耦的落点。

高频坑:Cocos 的 EventTarget.on 不返回退订函数(卷一我们自己写的 EventBus.on 会返回)。所以退订必须显式 off(type, callback, target),三个参数一个都不能少,callback 和 target 必须与订阅时同一个引用。用匿名箭头函数订阅,就永远退不掉。

3.3.4 触摸事件与坐标换算

UI 上的点击用节点触摸事件。Node.EventType 提供 TOUCH_START / TOUCH_MOVE / TOUCH_END / TOUCH_CANCEL 等常量:

import { _decorator, Component, Node, EventTouch, Vec2, Vec3, UITransform } from 'cc';
const { ccclass } = _decorator;

@ccclass('TapArea')
export class TapArea extends Component {
  onEnable(): void {
    this.node.on(Node.EventType.TOUCH_END, this.onTap, this);
  }

  onDisable(): void {
    this.node.off(Node.EventType.TOUCH_END, this.onTap, this);
  }

  private onTap(e: EventTouch): void {
    const ui: Vec2 = e.getUILocation();
    const world = new Vec3(ui.x, ui.y, 0);
    const ut = this.node.getComponent(UITransform);
    const local = new Vec3();
    ut?.convertToNodeSpaceAR(world, local);
    console.log(local.x, local.y);
  }
}

坐标有两处容易错:

  • getUILocation() 返回的是 Vec2(UI 空间),不是 Vec3;要喂给 convertToNodeSpaceAR 必须自己补一个 z。
  • getLocation() 返回的是屏幕像素坐标,getUILocation() 返回的是设计分辨率下的 UI 坐标。960×540 的工程在手机上跑,两者数值完全不同,换算节点局部坐标一律用 getUILocation()。

3.3.5 全局输入:input 与 KeyCode

键盘、全局触摸、手柄走的是 input 单例,事件类型常量在 Input.EventType 上:

import { _decorator, Component, Input, input, EventKeyboard, KeyCode } from 'cc';
const { ccclass } = _decorator;

@ccclass('KeyboardJump')
export class KeyboardJump extends Component {
  onEnable(): void {
    input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
    input.on(Input.EventType.KEY_UP, this.onKeyUp, this);
  }

  onDisable(): void {
    input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
    input.off(Input.EventType.KEY_UP, this.onKeyUp, this);
  }

  private onKeyDown(e: EventKeyboard): void {
    if (e.keyCode === KeyCode.SPACE || e.keyCode === KeyCode.ARROW_UP) {
      this.requestJump();
    }
  }

  private onKeyUp(e: EventKeyboard): void {
    void e;
  }

  private requestJump(): void { /* 通知玩家起跳 */ }
}

KeyCode 是 cc.d.ts 里真实导出的枚举,SPACE、ARROW_UP、KEY_A 等成员齐全。和节点触摸事件不同,input 是全局的——不管点在哪,只要在窗口内就触发。所以跳跃这类「随时可按」的操作放 input,而「点按钮」放节点事件。注意 input 是全局单例,订阅同样要在 onDisable 里退订,否则场景切换后旧组件仍响应按键。

补充一点:老教程里常见的 systemEvent 自 3.4.0 起已标记废弃,cc.d.ts 的注释明确写着「please use input instead」。卷二一律用 input,不要在键盘事件上走 systemEvent。另外键盘输入在小游戏平台上基本不可用(没有物理键盘),所以《方块冲刺》的跳跃同时接 input 的键盘事件与屏幕触摸事件,两端各用各的,互不干扰。

3.3.6 一次完整的跨模块通知:吃金币

把上面几种手段串成《方块冲刺》里最典型的一条链路——金币被收集。发布方(金币)只管发事件然后销毁自己,完全不认识 HUD 和音效:

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

export const gameBus = new EventTarget();

@ccclass('CoinPickup')
export class CoinPickup extends Component {
  private collected = false;

  onTriggerEnter(): void {
    if (this.collected) { return; }
    this.collected = true;
    gameBus.emit('coin:collected', 1);
    this.node.destroy();
  }
}

订阅方各自在 onEnable 里挂上、onDisable 里摘掉,互不知道对方存在。加一个 HUD、加一个音效、加一个成就计数,都不需要改动 CoinPickup 一个字。

纪律做法反例
命名带模块前缀coin:collectedcollected(谁发的?)
订阅与退订配对onEnable / onDisable只在 start 订阅,组件复用后重复注册
回调用命名方法this.onCoin匿名箭头函数(退不掉)
事件只传数据传 value: number传整个 Node 引用(订阅方反过来耦合发布方)

最后一条尤其重要:事件参数应当是值而不是对象引用。传 Node 出去,订阅方就能反过来改发布方的状态,解耦白做了;而且发布方 destroy() 后那个 Node 已失效,订阅方拿到的是悬空引用。

3.3.7 编辑器里给 Button 绑定 Click Events(需编辑器,不可命令行复现)

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

  1. 打开 assets/scenes/main.scene,选中 Canvas/StartPanel/StartButton 节点。
  2. 属性检查器「添加组件」→ Button。
  3. 在 Button 的 Click Events 列表点「+」,得到一行。
  4. cc.Node 槽拖入 Canvas(承载脚本的节点)。
  5. 组件下拉选 MenuController,Handler 下拉选 onStartGame。

保存后 StartButton 节点的 _components 里会出现带 _clickEvents 的 cc.Button:

{
  "__type__": "cc.Button",
  "_clickEvents": [
    {
      "__type__": "cc.ClickEvent",
      "target": { "__id__": 3 },
      "component": "MenuController",
      "handler": "onStartGame",
      "customEventData": ""
    }
  ]
}

打开 main.scene 会看到:StartButton 节点的 Button 组件下 Click Events 列表里有一行,cc.Node 槽指向 Canvas,组件下拉为 MenuController,Handler 下拉为 onStartGame。

component 存的是脚本的注册名(MenuController),handler 是方法名,customEventData 是可选字符串参数。这条绑定关系等价于在 MenuController 里写 button.node.on('click', this.onStartGame, this),但绑定在场景里、不在代码里——好处是策划能改,坏处是重命名方法后编辑器不会报错,运行时才静默失效。卷二的做法是:交互频繁的按钮用代码绑定,只有菜单这类静态 UI 用面板绑定。

顺带记一个坑:cc.ClickEvent 的 target 存的是节点在场景里的 __id__(节点索引),不是 UUID。所以在编辑器里剪切粘贴节点后,绑定关系可能错位到别的节点上——这是卷二只把面板绑定留给静态菜单 UI 的另一个理由。真要在代码里绑,写法是 buttonNode.on('click', this.onStartGame, this),和 3.3.2 的节点事件完全一致。

把这一节的三种手段合起来看,其实是一套分层:节点内部用 getComponent 拿同节点的组件,节点之间用节点事件做父子通信,模块之间用全局 gameBus 解耦,外部输入用 input 接键盘与触摸。四层各司其职,组件之间就不会织出蜘蛛网。

下一节进入资源管线,讲清 assets/ 的目录分层与 Bundle 分包,那是「首包只含启动场景与主 UI」这条约束的实现方式。

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

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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