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