《Cocos 游戏开发入门》6.1 组件模型:从继承到组合

前五章的渲染、时间、输入能力还散在几个类里,要让《方块冲刺》的玩家、敌人、金币各自动起来,还缺一层组织方式。本节从深继承的三个硬伤讲起,亲手为 mini2d 写出 GameObject 持有 Component 的实体-组件结构,实现 addComponent/getComponent 与组件注册表,最后给出 Cocos Creator 3.8 的 Component 对照。

6.1 组件模型:从继承到组合

前五章里,mini2d 已经能画精灵、管场景图、跑固定步长循环、处理输入。但这些能力现在散落在几个类中:渲染在 Renderer,时间在 Clock,输入在 InputSystem。要让《方块冲刺》里的玩家、敌人、金币各自动起来,我们还缺一层组织方式——把「一个游戏对象」和「它能做的事」拼装到一起。这一层就是组件模型。

与站内 game/ 顶层 31 篇「引擎概念综述」的分工:那批文章解释「引擎一般怎么做组件」,本套书则亲手把 mini2d 的组件系统写出来并真跑测试。Cocos Creator 3.8 的对照放在本章末节。

6.1.1 深继承的困境

新手最自然的想法是继承。玩家能移动、能跳跃、能受伤,那就写一个 Player 类;敌人也能移动、能受伤,那就让 Enemy 也继承一个 Actor 基类:

class Actor {
  x = 0;
  y = 0;
  vx = 0;
  vy = 0;
  hp = 100;
  move(dt: number) {
    this.x += this.vx * dt;
    this.y += this.vy * dt;
  }
  hurt(n: number) {
    this.hp -= n;
  }
}

class Player extends Actor {
  jump() {
    this.vy = -520;
  }
}

class Enemy extends Actor {
  patrol() {
    /* ... */
  }
}

开始时很清爽。问题出在第一个「交叉需求」:金币不移动、不受伤,但能被拾取;子弹会移动,但不受伤;平台不动也不受伤,却参与碰撞。这时继承树开始分叉:

class StaticBody {
  x = 0;
  y = 0;
}
class MovableBody extends StaticBody {
  vx = 0;
  vy = 0;
}
class Damageable extends MovableBody {
  hp = 100;
}
class Collectible extends StaticBody {
  picked = false;
}

再往后,需求两两组合(「会移动 + 可拾取」「会移动 + 可受伤 + 可拾取」)会逼你为每种组合建一个类,或者被迫接受「金币也继承一个它根本用不到的 hp 字段」。这就是深继承的三个硬伤:

问题表现在《方块冲刺》里的例子
组合爆炸N 种能力要 M 个类覆盖两两组合金币、道具、子弹都要「可拾取」
行为污染子类继承了用不上的字段与方法平台被迫持有 hp、vx
脆弱基类改基类一个方法,所有子类都要回归改 Actor.move 影响全部敌人

继承表达的是「是一个」的关系,可游戏对象之间大多是「有某种能力」。用「有」来建模,就是组合。

6.1.2 实体-组件结构

我们把「一个游戏对象」拆成两半:GameObject(实体)只负责身份与位置,具体能力交给挂在它身上的 Component(组件)。实体不继承任何能力,它只是组件的容器。

// mini2d/core/game-object.ts
export class Component {
  /** 反向引用:组件总是知道自己挂在哪个实体上 */
  gameObject!: GameObject;

  onAttach(): void {}
  onDetach(): void {}
}

export class GameObject {
  readonly name: string;
  readonly components: Component[] = [];

  constructor(name = 'GameObject') {
    this.name = name;
  }

  addComponent<T extends Component>(ctor: new () => T): T {
    const comp = new ctor();
    comp.gameObject = this;
    this.components.push(comp);
    comp.onAttach();
    return comp;
  }

  getComponent<T extends Component>(ctor: new () => T): T | null {
    for (const c of this.components) {
      if (c instanceof ctor) return c;
    }
    return null;
  }
}

Component 基类只提供两样东西:gameObject 反向引用,以及 onAttach / onDetach 两个挂载钩子。真正的能力由子类实现:

export class VelocityComponent extends Component {
  vx = 0;
  vy = 0;
}

export class PlayerControlComponent extends Component {
  update(input: InputState, dt: number): void {
    const v = this.gameObject.getComponent(VelocityComponent);
    if (!v) return;
    v.vy = input.jumpPressed ? -520 : v.vy;
  }
}

注意 PlayerControlComponent 并不自己存速度,而是通过 this.gameObject.getComponent(VelocityComponent) 拿到同一实体上的兄弟组件。组件之间不互相持有引用,只通过实体中转——这样任意组件都能被单独替换、删除、复用。

6.1.3 组件注册表与按名查找

getComponent(VelocityComponent) 用构造器查找,类型安全但要求调用方 import 到具体类。配置驱动(6.3)和编辑器面板都需要按字符串名字创建组件,所以再补一张注册表:

// mini2d/core/component-registry.ts
type Ctor<T> = new () => T;

export class ComponentRegistry {
  private static readonly map = new Map<string, Ctor<Component>>();

  static register(name: string, ctor: Ctor<Component>): void {
    this.map.set(name, ctor);
  }

  static create(name: string): Component | null {
    const ctor = this.map.get(name);
    return ctor ? new ctor() : null;
  }

  static names(): string[] {
    return [...this.map.keys()];
  }
}

启动时集中注册一次:

ComponentRegistry.register('Velocity', VelocityComponent);
ComponentRegistry.register('PlayerControl', PlayerControlComponent);
ComponentRegistry.register('Sprite', SpriteComponent);
ComponentRegistry.register('Collectible', CollectibleComponent);

再给 GameObject 加两个按名入口,查找与创建就齐了:

getComponentByName(name: string): Component | null {
  for (const c of this.components) {
    if (c.constructor.name === name) return c;
  }
  return null;
}

addComponentByName(name: string): Component | null {
  const comp = ComponentRegistry.create(name);
  if (!comp) return null;
  comp.gameObject = this;
  this.components.push(comp);
  comp.onAttach();
  return comp;
}

用 c.constructor.name 查找有个坑:生产构建压缩后类名会被改写成 t、e,字符串匹配立刻失效。所以正式项目里要么给组件显式登记名字,要么一律用 instanceof。注册表正好能承担「显式名字」这一职责。

6.1.4 在《方块冲刺》里组装玩家

现在玩家不再是 class Player extends Actor,而是一次组装:

export function spawnPlayer(scene: GameObject): GameObject {
  const player = new GameObject('Player');
  player.addComponent(TransformComponent);
  const sprite = player.addComponent(SpriteComponent);
  sprite.texture = 'player_idle';
  player.addComponent(VelocityComponent);
  player.addComponent(PlayerControlComponent);
  const box = player.addComponent(BoxColliderComponent);
  box.w = 24;
  box.h = 32;
  scene.addChild(player);
  return player;
}

要造一个「会飘的金币」,只是换一组组件,不必新写一个类:

const coin = new GameObject('Coin');
coin.addComponent(TransformComponent);
coin.addComponent(SpriteComponent).texture = 'coin';
coin.addComponent(CollectibleComponent).value = 10;

组合模型的好处到这里就很直观了:新对象是既有组件的重新拼装,不是新类。要加「金币也会飘」的效果,挂一个 FloatComponent 即可,玩家、敌人完全不受影响。

6.1.5 Cocos 对照:Component 与 addComponent/getComponent

Cocos Creator 3.8 用的正是同一套实体-组件模型:Node 是实体,Component 是组件,Node 上挂载组件的 API 与我们的 GameObject 几乎一一对应。以下为 L2 片段(import { ... } from 'cc'):

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

@ccclass('VelocityComponent')
export class VelocityComponent extends Component {
  @property
  vx = 0;

  @property
  vy = 0;
}

@ccclass('PlayerControl')
export class PlayerControl extends Component {
  update(dt: number): void {
    const v = this.node.getComponent(VelocityComponent);
    if (!v) return;
    v.vy += 9.8 * dt;
  }
}

与 mini2d 的差异只有三处:

  • 反向引用叫 this.node(Cocos 的实体叫 Node),我们叫 this.gameObject。
  • 挂载用 node.addComponent(VelocityComponent),查找用 node.getComponent(VelocityComponent);cc.d.ts 里的签名是 addComponent<T extends Component>(classConstructor): T 与 getComponent<T extends Component>(classConstructor): T | null,另有按字符串的重载 getComponent(className: string): Component | null。
  • 能力声明靠装饰器 @property,我们靠普通字段 + 序列化描述符(见 6.3)。

getComponent 不只在 Node 上有:任何 Component 实例都能调用 this.getComponent(...) 拿到同一节点上的兄弟组件,这正是我们 this.gameObject.getComponent(...) 的写法来源。下一节我们把组件真正「跑起来」——挂载、启用、每帧更新、销毁,这四类时机各自对应哪些回调。

阅读导航:上一节:目录 · 下一节:6.2 生命周期与消息分发 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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