游戏设计模式(二) 论撤消重做、回放系统的优雅实现:命令模式

作者:浅墨
2016-10-31
7 25 5

引言

这篇文章起源于《Game Programming Patterns》第二章第一节,将与大家一起探索游戏开发中命令模式的用法。

命令模式的成名应用是实现诸如撤消,重做,回放,时间倒流之类的功能。如果你想知道《Dota2》中的观战系统、《魔兽争霸3》中的录像系统、《守望先锋》的全场最佳回放系统可能的一些实现思路,这篇文章或许也能给你一些启示。

v2-0e091efa19b6aa2d5717cf8010c32e88_r

本文涉及知识点思维导图

还是国际惯例,先放出这篇文章所涉及内容知识点的一张思维导图,就开始正文。大家若是疲于阅读文章正文,直接看这张图,也是可以Get到本文的主要知识点的大概(推荐放大后查看,其实,单看总结的这些概念还是太抽象,关键还是在于文中三、四、五节中的代码与图示)。

v2-7b52e7b6de9aabd1f31fb1566de70ab0_r

命令模式的定义

在许多大型游戏中,都可以见到命令模式(Command Pattern)的身影。设计模式界的扛鼎之作《Design Patterns: Elements ofReusable Object-Oriented Softwar》(中译版《设计模式:可复用面向对象软件的基础》) 一书的作者四人帮Gang of Four对命令模式这样概括:

命令模式将“请求”封装成对象,以便使用不同的请求、队列或者日志来参数化其他对象,同时支持可撤消的操作。Encapsulate a request as an object, therebyletting you parameterizeclients with different requests, queue or log requests,and support undoable operations.

这句话解读版本应该是这样:将一组行为抽象为对象,这个对象和其他对象一样可以被存储和传递,从而实现行为请求者与行为实现者之间的松耦合,这就是命令模式。

接着看看Gang of Four随后提出的另一个阐述:

命令模式是回调机制的面向对象版本。Commands are an object-oriented replacementfor callbacks.

这句话从另一个方面道出了命令模式的思想,它是回调的面向对象版本。

OK,定义都给出了,不妨我们举一些栗子,在实际例子中看看命令模式到底能带给我们哪些惊喜。

引例

每个游戏都有一些代码块用来读取用户的输入操作,按钮点击,键盘事件,鼠标点击,或者其他输入。这些代码记录每次的输入,并将之转换为游戏中一个有意义的动作(action),如下图:

v2-c9080851a0348dd9322370837b9aa365_r

一种最简单粗暴的实现大概是这样:

void InputHandler::handleInput()
{
if(isPressed(BUTTON_X)) jump();
else if (isPressed(BUTTON_Y)) fireGun();
else if (isPressed(BUTTON_A)) swapWeapon();
else if (isPressed(BUTTON_B)) lurchIneffectively();
}

我们知道,这个函数通常会通过游戏循环被每帧调用。这段代码在我们想将用户的输入和程序行为硬编码在一起时,是完全可以胜任自身的工作的。但如果想实现用户自定义配置他们的按钮与动作的映射,就需要进行修改了。

为了支持自定义配置,我们需要把那些对 jump() 和 fireGun() 的直接调用转换为我们可以换出(swap out)的东西。”换出“(swapping out)听起来很像分配变量,所以我们需要个对象来代表一个游戏动作。这就用到了命令模式。

于是,我们定义了一个基类用来代表一个可激活的游戏命令:

class Command
{
public:
virtual ~Command() {}
virtual void execute() = 0;
};

然后我们为每个不同的游戏动作创建一个子类,public继承自我们的Command类:

class JumpCommand : public Command
{
public:
virtual void execute() { jump(); }
};

class FireCommand : public Command
{
public:
virtual void execute() { fireGun(); }
};

在负责输入处理的InputHandler中,我们为为每个键存储一个指向Command的指针。

class InputHandler
{
public:
void handleInput();

//Methods to bind commands...

private:
Command* buttonX_;
Command* buttonY_;
Command* buttonA_;
Command* buttonB_;
};

那么现在,InputHandler就可以该写成这样:

void InputHandler::handleInput()
{
if(isPressed(BUTTON_X)) buttonX_->execute();
else if (isPressed(BUTTON_Y)) buttonY_>execute();
else if (isPressed(BUTTON_A)) buttonA_->execute();
else if (isPressed(BUTTON_B)) buttonB_->execute();
}

不难理解,以前每个输入都会直接调用一个函数,现在则会有一个间接调用层。那么图示看起来就是这样:

v2-8c7799f712c1b243133dff5e84a71d12_r

这就是命令模式的最基础的实现,按照其思路画了一个大概的型出来。

简而言之,命令模式的关键在于引入了抽象命令接口(execute( )方法),且发送者针对抽象命令接口编程,只有实现了抽象命令接口的具体命令才能与接收者相关联。而且命令模式的本质是对命令进行封装,将发出命令的责任和执行命令的责任分割开。

进一步地使用命令模式

我们刚才定义的命令类在上个例子中可以跑得起来,但很受限。问题在于,他们假设存在jump() , fireGun() 等这样的函数能与玩家关联并控制玩家。这种假设耦合限制了这些命令的的效用。JumpCommand类唯一能做的事情就是控制玩家的跳跃。让我们放宽限制,传进去一个我们想要控制的对象进去,而不是用命令对象自身来调用函数:

class Command
{
public:
virtual ~Command() {}
virtual void execute(GameActor& actor) = 0;
};

这里GameActor是代表游戏世界中角色的“游戏对象”类。 我们将其传给execute(),这样可以在它的子类中添加函数,来与我们选择的角色关联,就像这样:

class JumpCommand : public Command
{
public:
virtual void execute(GameActor& actor)
{
actor.jump();
}
};

现在,我们可以使用这个类控制游戏中的任何角色。 还少了一块在输入控制和在正确的对象上起作用之间的代码。 首先,我们修改handleInput()这样它可以返回命令:

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_;

//Nothing pressed, so do nothing.
return NULL;
}

这段代码不能直接执行命令,因为它并不知道该传入那个角色对象。命令是一个对象化的调用,是回调的面向对象版本,这里正是我们可以利用的地方——我们可以延迟调用。我们需要一些代码来保存命令并且执行对玩家角色的调用。像下面这样:

Command* command =inputHandler.handleInput();
if (command)
{
command->execute(actor);
}

假设 actor 是玩家角色的一个引用,这将会基于用户的输入来驱动角色,所以我们可以赋予角色与前例一致的行为。在命令和角色之间加入的间接层使得我们可以让玩家控制游戏中的任何角色,只需通过改变命令执行时传入的角色对象即可。

目前我们只考虑了玩家驱动角色(player-driven character),但是对于游戏世界中的其他角色呢?他们由游戏的AI来驱动。我们可以使用相同的命令模式来作为AI引擎和角色的接口;AI代码部分提供命令(Command)对象用来执行,代码也就是:

command->execute(AI对象);

AI选择命令,角色执行命令,它们之间的解耦给了我们很大的灵活性。我们可以为不同的角色使用不同的AI模块,或者可以为不同种类的行为混合AI。你想要一个更加具有侵略性的敌人?只需要插入一段更具侵略性的AI代码来为它生成命令即可。事实上,我们甚至可以将AI使用到玩家的角色身上,这对于像游戏需要自动运行的demo模式是很有用的。

通过将控制角色的命令作为对象,我们便去掉了直接调用指定函数这样的紧耦合。我们不妨将这样的方式理解成一个队列或者一个命令流(queue or stream of commands):

v2-f99a44ae3bb6347d2a697d75b506fcec_r

如图,一些代码(输入控制器或者AI)产生一系列指令然后将其放入流中。 另一些指令(调度器或者角色自身)消耗指令并调用他们。这样,通过在中间加入了一个队列,我们解耦了行为请求者和行为实现者。

而且,如果我们把这些命令序列化,便可以通过互联网来发送数据流。可以把玩家的输入通过网络发送到另外一台机器上,然后进行回放,这就是多人网络游戏里面非常重要的一块。

实现撤消与重做功能

撤消和重做是命令模的成名应用了。如果一个命令对象可以做(do) 一些事情,那么应该可以很轻松的撤消(undo)它们。撤销这个行为经常在一些策略游戏中见到,在游戏中如果你不喜欢的话可以回滚一些步骤。在创建游戏时这是必不可少的的工具之一。

如果你想让游戏策划同事们喷你,最可靠的办法就是在关卡编辑器中不提供撤消功能,让他们不能撤消不小心犯的错误,我保证他们会打你。

利用命令模式,撤消和重做功能实现起来非常容易。假设我们在制作单人回合制游戏,想让玩家能撤消移动,这样他们就可以集中注意力在策略上而不是猜测上,而之前我们已经使用了命令来抽象输入控制,所以每个玩家的举动都已经被封装其中。举个例子,移动一个单位的代码可能如下:

class MoveUnitCommand : public Command
{
public:
MoveUnitCommand(Unit* unit, int x, int y)
: unit_(unit),
x_(x),
y_(y)
{}

virtual void execute()
{
unit_->moveTo(x_, y_);
}

private:
Unit* unit_;
int x_, y_;
};

注意这和前面的命令模式有些许不同。 在前面的例子中,我们需要从修改的角色那里抽象命令。而在这个例子中,我们将命令绑定到要移动的单位上。这条命令的实例不是通用的“移动某物”指令,而是游戏回合中特殊的一次移动。

这边就可以展示出命令模式的几种形态。 在某些情况下,指令是可重用的对象,代表了可执行的事件。我们在文章开头展示的输入控制将其实现为一个命令对象,然后在按键按下时调用其execute()方法。而这里的命令代表了特定时间点能做的特定事件。这意味着输入控制代码可以在玩家下决定时创造一个实例。就像这样:

Command* handleInput()
{
Unit* unit = getSelectedUnit();

if (isPressed(BUTTON_UP)) {
// Move the unit up one.
int destY = unit->y() - 1;
return new MoveUnitCommand(unit, unit->x(), destY);
}

if (isPressed(BUTTON_DOWN)) {
// Move the unit down one.
int destY = unit->y() + 1;
return new MoveUnitCommand(unit, unit->x(), destY);
}

// Other moves...

return NULL;
}

而为了撤消命令,我们定义了一个undo的操作,每个命令类都需要来实现它:

class Command
{
public:
virtual ~Command() {}
virtual void execute() = 0;
virtual void undo() = 0;
};

当然,在像C++这样没有垃圾回收的语言,这意味着执行命令的代码也要负责释放内存。

undo()方法用于回滚execute()方法造成的游戏状态改变。下面我们针对上一个移动命令加入撤消支持:

class MoveUnitCommand : public Command
{
public:
MoveUnitCommand(Unit* unit, int x, int y)
: unit_(unit),
xBefore_(0),
yBefore_(0),
x_(x),
y_(y)
{}

virtual void execute()
{
// Remember the unit's position before the move
// so we can restore it.
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_;
};

需要注意的是,我们为类添加了更多状态。 当单位移动时,它忘记了它之前是什么样的。 如果我们想要撤销这个移动,我们需要记得单位之前的状态,也就是xBefore_和yBefore_做的事。

其实,这样的实现看起来挺像备忘录模式(Memento pattern)的,但是你会发现备忘录模式用在这里并不能愉快地工作。因为命令试图去修改一个对象状态的一小部分,而为对象的其他数据创建快照是浪费内存。只手动存储被修改的部分相对来说就节省很多内存了。

持久化数据结构是另一个选择。通过它们,每次对一个对象进行修改都会返回一个新的对象,保留原对象不变。通过这样的实现,新对象会与原对象共享数据,所以比拷贝整个对象的代价要小得多。使用持久化数据结构,每个命令存储着命令执行前对象的一个引用,所以撤销意味着切换到原来老的对象。

为了让玩家能够撤销一次移动,我们保留了他们执行的上一个命令。当他们敲击Control+Z 时,我们便会调用 undo() 方法。(如果已经撤消了,那么会变为”重做“,我们会再次执行那个命令。)

支持多次撤消也很容易实现。也就是我们不再保存最后一个命令,取而代之保存了一个命令列表和”current“(当前)命令的一个引用。当玩家执行了某个命令时,我们将此命令添加到列表中,并将”current“指向它即可。思路如下图:

v2-f02693ef96689d68cbb47d0acea6e4b8_r

当玩家选择”撤消“时,我们撤消掉当前的命令并且将当前的指针移回去。当他们选择”重做“,我们将指针前移然后执行命令。如果他们在撤消之后选择了一个新的命令,就把列表中位于当前命令之后的所有命令进行舍弃。

若你是第一次在游戏关卡编辑器中用命令模式实现撤消重做的功能,或许你会惊叹它是如此的简单、高效而且优雅。

录像与回放系统的实现思路

上文刚刚讲到了如何用命令模式实现撤消与重做。重做在游戏中并不常见,但回放(replay)、录像、观战系统却很常见。一个简单粗暴的实现方法就是记录每一帧的游戏状态以便能够回放,但是这样会占用大量的内存。

所以,许多游戏会记录每一帧每个实体所执行的一系列命令,就可以轻松的实现回放功能。而为了回放游戏,引擎只需要运行正常游戏的模拟,执行预先录制的命令即可。

那我们便可以这样理解,录像与回放等功能,可以基于命令模式实现,也就是执行并解析一系列经过预录制的序列化后的各玩家操作的有序命令集合。以下只是提供一些分析的思路,并不代表这三款游戏当时就是这样实现的:

  • 《魔兽争霸3》中的replay录像,大概就是通过将所有玩家的操作命令,序列化到一个.rep后缀的文件中,然后在游戏中进行解析后回放来实现。
  • 《Dota2》中的录像功能也大致如此,而观战功能也就是通过在线不断获取该局比赛中各个玩家经过序列化后的有序命令流,然后在自己的客户端中解析并重放。
  • 《守望先锋》的回放系统,大概也就是将各个玩家的一系列操作命令通过网络发送到其他玩家的机器上(其实对战过程中就已经在实时发送),然后进行解析后进行模拟回放。

v2-a46897d313becf7ea4994c31b879c654_b
这大致就是各种游戏中录像、回放、观战系统所用的一些设计思路。

命令模式的要点总结

OK,例子讲完了,下面对命令模式进行一些要点的总结。

命令模式的要点总结

首先,给出命令模式的UML图:

v2-a454f14dd88294d6cb5b887eb97f9a96_r

然后,让我们再次看看文章开头给出的GOF对于命令模式的定义:

"命令模式将“请求”封装成对象,以便使用不同的请求、队列或者日志来参数化其他对象,同时支持可撤消的操作。"

接着是对命令模式的一些解读与思考:

  • 将一组行为抽象为对象,这个对象和其他对象一样可以被存储和传递,从而实现行为请求者与行为实现者之间的松耦合,这就是命令模式。
  • 命令模式的本质是对命令进行封装,将发出命令的责任和执行命令的责任分割开。
  • 命令模式是回调机制的面向对象版本。
  • 每一个命令都是一个操作:请求的一方发出请求,要求执行一个操作;接收的一方收到请求,并执行操作。
  • 命令模式允许请求的一方和接收的一方独立开来,使得请求的一方不必知道接收请求的一方的接口,更不必知道请求是怎么被接收,以及操作是否被执行、何时被执行,以及是怎么被执行的。
  • 命令模式的关键在于引入了抽象命令接口,且发送者针对抽象命令接口编程,只有实现了抽象命令接口的具体命令才能与接收者相关联。
  • 命令模式很适合实现诸如撤消,重做,回放,回退一步,时间倒流之类的功能。
  • 命令模式有不少的细分种类,实际使用时应根据当前所需来找到合适的设计方式。
命令模式的优点
  1. 对类间解耦。调用者角色与接受者角色之间没有任何依赖关系,调用者实现功能时只需调用Command抽象类的execute方法即可,不需要了解到底是哪个接收者在执行。
  2. 可扩展性强。Command的子类可以非常容易地扩展,而调用者Invoker和高层次的模块Client之间不会产生严重的代码耦合。
  3. 易于命令的组合维护。可以比较容易地设计一个组合命令,维护所有命令的集合,并允许调用同一方法实现不同的功能。
  4. 易于与其他模式结合。命令模式可以结合责任链模式,实现命令族的解析;而命令模式结合模板方法模式,则可以有效减少Command子类的膨胀问题。
命令模式的缺点

会导致类的膨胀。使用命令模式可能会导致某些系统有过多的具体命令类。因为针对每一个命令都需要设计一个具体命令类,这将导致类的膨胀。上文讲解优点时已经提到了应对之策,我们可以将命令模式结合模板方法模式,来有效减少Command子类的膨胀问题。也可以定义一个具体基类,包括一些能定义自己行为的高层方法,将命令的主体execute()转到子类沙箱中,往往会有一些帮助。

本文涉及知识点提炼整理

本文涉及知识点提炼整理如下:

  • GOF对命令模式的定义是,命令模式将“请求”封装成对象,以便使用不同的请求、队列或者日志来参数化其他对象,同时支持可撤消的操作。命令模式是回调机制的面向对象版本。
  • 将一组行为抽象为对象,这个对象和其他对象一样可以被存储和传递,从而实现行为请求者与行为实现者之间的松耦合,这就是命令模式。
  • 命令模式的本质是对命令进行封装,将发出命令的责任和执行命令的责任分割开。
  • 命令模式很适合实现诸如撤消,重做,回放,时间倒流之类的功能。而基于命令模式实现录像与回放等功能,也就是执行并解析一系列经过预录制的序列化后的各玩家操作的有序命令集合。

而录像与回放等功能,就是在执行并解析一系列经过预录制的序列化后的各玩家操作的有序命令集合。
命令模式的优点有:对类间解耦、可扩展性强、易于命令的组合维护、易于与其他模式结合,而缺点是会导致类的膨胀。
命令模式有不少的细分种类,实际使用时应根据当前所需来找到合适的设计方式。

当然,单看以上的这些概念也许太过于抽象,关键还是在于理解文中三、四、五节的代码与图示。

参考文献

  • [1] Command · Design Patterns Revisited · Game Programming Patterns
  • [2] Gamma E. Design patterns: elements of reusable object-oriented software[M]. Pearson Education India, 1995.
  • [3] Freeman E, Robson E, Bates B, et al. Head first design patterns[M]. " O'Reilly Media, Inc.", 2004.
  • [4] Command pattern
  • [5] Memento pattern

本文就此结束,系列文章未完待续。

With Best Wishes.

近期点赞的会员

 分享这篇文章

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. eastecho 2016-11-03

    有价值!

  2. coffeecat 2017-05-02

    我从2017.3开始读《游戏设计模式》,对于一名游戏从业者也是收益良多。
    录像与回放的设计思路给了我很多启发,在2015年的一个线上游戏项目中,为了实现录像功能,走了很多弯路,最后虽然实现了功能,还是有诸多不满,今天看到你的设想,豁然开朗!

  3. LittleFC 2017-06-18

    很棒,你的解读非常棒,学习效率很高

  4. 张燊 2017-11-03

    非常感谢

  5. cwenn 2018-04-27

    感谢分享!我们游戏的回放系统结构好像是命令模式,但是你提到的备忘录模式,也可以用来做回放的。但不是很理解命令模式与备忘录模式在这种用途上有什么不同和优劣?

您需要登录或者注册后才能发表评论

登录/注册