概念页
命令模式
Command Pattern
命令模式 (Command Pattern) 把一次方法调用“物化”成对象,从而可以存储、传递、延迟执行、排队或撤销。游戏里最典型的三种用法是:可配置的按键映射、让玩家输入和 AI 共用同一套“驱动角色”的接口、以及撤销/重做。
意图
GoF 的原始定义是“把请求封装为对象,从而可用不同请求参数化客户端、对请求排队或记录日志,并支持可撤销操作”。Nystrom 认为这句话太绕,给出的一句话版本是:命令就是被物化的方法调用 (reified method call)——把一次调用包进对象,使它能放进变量、传给函数。GoF 自己后来也说“命令是回调的面向对象替代品”。(Game Programming Patterns — command)
机制:三个递进的例子
- 配置输入:最朴素的输入处理在每帧里直接
if (按下 X) jump();。要支持改键,就把jump()、fireGun()换成可替换的对象:定义带execute()的Command基类,每个动作一个子类,输入处理器为每个按键保存一个命令指针,按下时只调用execute()。(Game Programming Patterns — command) - 给角色下指令:上面的命令隐含“去找玩家角色”,耦合太死。改成
execute(GameActor&),输入处理器只返回命令而不立即执行,由别处决定作用到哪个角色上。于是同一套命令可驱动任意角色;AI 也只需产出Command对象,可以给不同敌人换不同 AI 模块,甚至给玩家角色挂 AI 做演示模式。生产者(输入或 AI)与消费者(角色)之间相当于隔了一条命令流。(Game Programming Patterns — command) - 撤销与重做:这时的命令绑定到具体对象和具体时刻(例如“把这个单位移到 (x, y)”),每次操作新建一个实例;给命令加
undo(),并在命令里记下执行前的状态(如xBefore_、yBefore_)。多级撤销用一个命令列表加“当前”指针:撤销时撤销当前命令并后退,重做时前进并重新执行;撤销后又执行新命令,就丢弃当前位置之后的所有命令。前提是所有数据修改都必须经过命令。(Game Programming Patterns — command)
原文旁注里还有几个值得记住的细节(Game Programming Patterns — command):
- 输入处理器不检查空指针,假定每个按键都绑了命令;想让某键“什么都不做”,就绑一个
execute()为空的命令,即空对象 (Null Object) 模式。 - 一次性命令由
new创建,在 C++ 这类没有垃圾回收的语言里,执行命令的代码要负责释放它们。 - 把命令做成可序列化的,就能把玩家输入流发到另一台机器上重放,这是联网多人游戏的重要一环。
- 重做在游戏里不常见,但回放 (replay) 很常见:与其每帧记录整个游戏状态,不如记录每个实体每帧执行的命令,回放时照常运行模拟、执行预录的命令。
- 撤销看似适合备忘录 (Memento) 模式,但作者认为不划算:命令通常只改对象的一小部分状态,给其余数据拍快照浪费内存,手动保存被改动的那几项更便宜。另一种方案是持久化数据结构 (persistent data structure),命令保存执行前的对象引用,撤销即切回旧对象。
下图对应第 2、3 个例子:输入处理器和 AI 都只产出命令;分发者把命令作用到某个角色上,同时把可撤销的命令压入历史列表。
flowchart LR IN["输入处理器"] -->|"产出"| C["Command 对象"] AI["AI 模块"] -->|"产出"| C C -->|execute actor| A[GameActor] C -->|"可撤销命令入栈"| H["命令历史列表 + 当前指针"] H -->|undo / redo| A
从图中可以看到,角色代码不知道命令来自玩家还是 AI;撤销能力只来自命令对象自己记住的“执行前状态”。
何时使用
- 需要把“触发动作的人”和“执行动作的对象”分开:改键、AI 与玩家共用控制接口、回放、演示模式。
- 需要撤销/重做:关卡编辑器里几乎是必需品,回合制策略游戏也常用。Nystrom 说,让关卡设计师恨你的最快办法就是给他一个不能撤销的编辑器。(Game Programming Patterns — command)
- 需要排队、延迟或记录请求——这时它和 事件队列 很接近。
取舍与坑
- 类可能爆炸:命令子类会很多。可以给命令定义一个提供高层便捷方法的基类,让
execute()成为 子类沙盒 的沙盒方法。(Game Programming Patterns — command) - 无状态命令浪费内存:像
JumpCommand这样纯行为、没有字段的命令,多个实例完全等价,可用 享元模式 共享一个实例。(Game Programming Patterns — command) - 可复用命令与一次性命令是两种变体:前者表示“一件能做的事”,被反复
execute();后者表示“某时刻做的某件具体事”,撤销系统依赖后者。 - 有闭包的语言可直接用函数:C++ 的函数指针无状态、仿函数要写类、C++11 lambda 有手动内存管理的麻烦,所以书中用类;在有真正闭包的语言里,命令模式就是在模拟闭包,一对闭包即可实现 execute/undo。(Game Programming Patterns — command)
- 若对象层级让“谁处理命令”不明确,命令可能被层层转交,那就是职责链 (Chain of Responsibility) 模式。(Game Programming Patterns — command)
代码草图(C++)
下面按书中示例整理:先是例子 2 中“返回命令、由调用方决定作用对象”的输入处理,再是例子 3 中带撤销的移动命令。
// 例子 2:命令作用于传入的角色
class Command {
public:
virtual ~Command() {}
virtual void execute(GameActor& actor) = 0;
};
class JumpCommand : public Command {
public:
virtual void execute(GameActor& actor) { actor.jump(); }
};
Command* InputHandler::handleInput() {
if (isPressed(BUTTON_X)) return buttonX_;
if (isPressed(BUTTON_Y)) return buttonY_;
if (isPressed(BUTTON_A)) return buttonA_;
if (isPressed(BUTTON_B)) return buttonB_;
return NULL; // 没按键,什么都不做
}
Command* command = inputHandler.handleInput();
if (command) command->execute(actor);
// 例子 3:绑定到具体单位、可撤销的一次性命令
class MoveUnitCommand : public Command { // 此处 Command 为 execute()/undo() 版本
public:
MoveUnitCommand(Unit* unit, int x, int y)
: unit_(unit), xBefore_(0), yBefore_(0), x_(x), y_(y) {}
virtual void execute() {
xBefore_ = unit_->x(); // 记住移动前的位置以便恢复
yBefore_ = unit_->y();
unit_->moveTo(x_, y_);
}
virtual void undo() { unit_->moveTo(xBefore_, yBefore_); }
private:
Unit* unit_;
int xBefore_, yBefore_;
int x_, y_;
};
多级撤销书中只给了文字描述(命令列表 + “当前”指针),没有给代码,上文“机制”第 3 条已概括。
UE 中的对应
- 改键与输入解耦 → 增强输入:增强输入把“玩家想做什么”(输入动作 Input Action)和“哪些按键在什么情境下触发它”(输入映射上下文 Input Mapping Context)分开,上下文可在运行时按优先级增删,用数据资产而不是代码处理情境切换与改键。这正是本模式第一个例子要解决的问题,只是 UE 用数据而不是命令子类来表达映射。
- 把输入与决策抽象开 → Gameplay 能力系统:该页提到能力可以由 Gameplay 事件触发,官方认为这“最适合把输入与决策抽象开”,思路与例子 2 一致。
- 撤销/重做:本知识库现有 UE 页面没有讲编辑器的撤销(事务)系统,这里不做对应。
出处
- Game Programming Patterns — command
- Game Programming Patterns — command(全文版)
- Robert Nystrom《Game Programming Patterns》(2014) command 章,gameprogrammingpatterns.com/command.html
相关页面
游戏开发事件队列Event Queue事件队列 (Event Queue) 用一个先进先出队列,把“发送消息或事件”和“处理它”在时间上解耦:发送方入队后立即返回,接收方在自己方便的时候再取出处理。它比观察者和命令模式多做的一件事是时间上的解耦,适合“推”与“拉”两端节奏不同的场合(例如音频请求),代价是全局可见、世界状态可能已经改变,以及可能出现隐蔽的反馈回环。游戏开发子类沙盒Subclass Sandbox子类沙盒 (Subclass Sandbox) 让基类提供一组受保护的“提供的操作”和一个抽象的沙盒方法,派生类只用这些操作来实现沙盒方法。这样大量派生类(例如几十种超能力)只耦合到基类,不直接接触引擎的各个系统;代价是基类会越来越臃肿,变成脆弱基类。游戏开发享元模式Flyweight Pattern享元模式 (Flyweight Pattern) 把对象的数据拆成可共享的“内在状态”和每个实例独有的“外在状态”,让大量对象共用一份内在状态。森林里成千上万棵树共用一份网格和纹理,以及瓦片地图中每格只存一个指向共享地形对象的指针,都是它的典型应用。游戏开发 · UE5增强输入Enhanced Input增强输入 (Enhanced Input) 是 UE5 默认启用的输入插件,把输入拆成四层:输入动作 (Input Action) 表示“玩家想做什么”,输入映射上下文 (Input Mapping Context) 规定“哪些按键在什么情境下触发它”,输入修饰器 (Input Modifier) 预处理原始值,输入触发器 (Input Trigger) 判定是否触发。上下文可在运行时按优先级增删,从而用数据资产而非代码处理情境切换、按键冲突与改键。综合游戏编程模式选型:解耦与性能Choosing Game Programming Patterns本页把《Game Programming Patterns》里的 19 个模式放到同一张取舍图上:大多数模式要么为了解耦(降低改代码时需要装进脑子里的知识量),要么为了性能(利用对数据和硬件的具体假设),而这两个目标经常互相拉扯。下面先讲全书的定位和 Nystrom 的架构观,再给出按问题选模式的路线和与 UE 的对照。游戏开发引擎输入系统Game Engine HID System多数引擎不直接使用「原始」HID 输入,而是先加工(死区、滤波)使游戏内行为平滑直观,并在 HID 与游戏之间加至少一层间接(如按键映射表把原始按钮翻译成逻辑游戏动作)。GEA 列出引擎输入系统的典型需求并逐一讲解实现:死区、模拟信号滤波、按下/松开事件、组合键与序列手势、多设备多玩家、跨平台抽象、输入重映射、上下文相关控制与输入禁用。(卷 I 9.5–9.6 节)游戏开发事件与消息传递Events & Message Passing游戏天然是事件驱动的:爆炸、玩家被敌人发现、血包被捡起都是事件,系统要通知感兴趣的对象并让它们以各自的方式处理。用虚函数 OnExplosion() 通知属于静态类型的后期绑定,迫使基类为所有可能的事件声明虚函数;GEA 主张把事件封装成对象(类型 + 参数),实现动态类型的后期绑定,从而获得单一处理函数、持久性和盲转发。GEA 依次讨论事件类型(枚举 vs 字符串/字符串 id)、参数(派生类、Variant 数组、键值对)、处理器、职责链、兴趣注册、排队与否的利弊,最后是数据驱动的事件系统和以数据通路连接端口的图形化系统(如 UE 蓝图)(GEA4 卷 II 17.8)。游戏开发 · UE5Gameplay 能力系统Gameplay Ability SystemGameplay 能力系统 (Gameplay Ability System, GAS) 是 UE 用来构建「Actor 可拥有并反复触发的能力」的框架:能力系统组件 (Ability System Component, ASC) 是持有者,Gameplay 能力 (Gameplay Ability) 编排执行过程,属性集 (Attribute Set) 存数值,Gameplay 效果 (Gameplay Effect, GE) 改数值和标签,Gameplay 标签 (Gameplay Tags) 做状态与互斥,Gameplay Cue 负责表现。它内建复制与客户端预测,代价是概念多、上手重,