Game Programming Patterns: #2 Part.2
date
Aug 21, 2023
slug
gpp2.2
status
Published
tags
Program
summary
Design Patterns Revisited: Command
type
Post
本文为游戏编程模式 #2:命令模式一文的下半部分,阅读前请先阅读文章的上半部分:
实现撤消与重做功能
撤销与重做功能是命令模式的一个非常典型的应用。如果一个命令对象可以做(do)一些事情,那么一般应该也可以很轻松的撤销(undo)它们。撤销这个行为经常在一些策略游戏中见到,在游戏中如果你不喜欢的话可以回滚一些步骤。而在制作游戏的过程中,撤销功能也是必不可少的。
利用命令模式,撤消和重做功能实现起来非常容易。假设我们在制作单人回合制游戏,想让玩家能撤销移动,这样他们就可以集中注意力在策略上而不是猜测上。
在前面的部分我们已经使用了命令来抽象输入控制,所以每个玩家的举动都已经被封装其中。
举个例子,移动一个单位的代码可能是这样的:
注意这和前面的命令有些许不同。 在前面的例子中,我们需要从修改的角色那里抽象命令。 在这个例子中,我们将命令绑定到要移动的单位上。 这条命令的实例不是通用的“移动某物”命令;而是游戏回合中特殊的一次移动。
这展现了命令模式应用时的一种情形。 就像之前的例子,指令在某些情形中是可重用的对象,代表了可执行的事件。 我们早期的输入控制器将其实现为一个命令对象,然后在按键按下时调用其
execute()
方法。这里的命令更加特殊。它们代表了特定时间点能做的特定事件。 这意味着输入控制代码可以在玩家下决定时创造一个实例。就像这样:
命令的一次性可以为我们所利用。为了撤消命令,我们定义了一个undo的操作,每个命令类都需要来实现它:
当然,在像C++这样没有垃圾回收的语言中,这意味着执行命令的代码也要负责释放内存。
undo()
方法回滚了execute()
方法造成的游戏状态改变。 这里是添加了撤销功能后的移动命令:需要注意的是,我们为类添加了更多状态。 当单位移动时,它忘记了它之前是什么样的。 如果我们想要撤销这个移动,我们需要记得单位之前的状态,也就是
xBefore_
和yBefore_
做的事。这看上去是备忘录模式使用的地方,但是你会发现备忘录模式用在这里并不能有效地工作。 因为命令趋向于去修改一个对象状态的一小部分,而为对象的其他数据创建快照是浪费内存。只用手动内存管理被修改的部分相对来说消耗的内存更小。
持久化数据结构是另一个选择。通过它们,每次对一个对象进行修改都会返回一个新的对象,保留原对象不变。巧妙的实现下,这些新对象与之前的原对象共享数据,所以比克隆整个对象开销更小。用持久化数据结构,每个命令存储着命令执行前对象的一个引用,所以撤销意味着切换到原来老的对象。
为了让玩家撤销移动,我们记录了执行的最后命令。当他们按下
control+z
时,我们调用命令的undo()
方法。 (如果他们已经撤销了,那么就变成了“重做”,我们会再一次执行命令。)多次撤销的功能也不难做。我们记录一个命令列表和”current“(当前)命令的一个引用。 当玩家执行一条命令,我们将其添加到列表,然后将代表“current”(当前)的指针指向它。如图:
当玩家选择“撤销”,我们撤销现在的命令,将代表当前的指针往后退。 当他们选择“重做”,我们将代表当前的指针往前进,执行该指令。 如果在撤销后选择了新命令,那么清除命令列表中当前的指针所指命令之后的全部命令。
类还是函数?
在上一篇文章里有提到命令与第一函数或者闭包类似,但是前面举的实例都是通过类来完成的。如果你熟悉函数式编程,你可能会疑惑函数在哪里。
使用这样的例子是因为C++对第一公民函数支持非常有限。 函数指针没有状态,函子很奇怪而且仍然需要定义类, 在C++11中的lambda演算需要大量的人工记忆辅助才能使用。
这并不是说你在其他语言中不可以用函数来完成命令模式。 如果你使用的语言支持闭包,可以任意使用它!在某种程度上说,命令模式是为一些没有闭包的语言模拟闭包。
举个例子,如果我们使用javascript来写游戏,那么我们可以用这种方式来写让单位移动的命令:
我们可以通过一对闭包来为撤销功能提供支持:
如果你习惯了函数式编程的风格,这种做法会很顺畅。其实命令模式展现了函数式范式在很多问题上的高效性。
录像与回放系统的实现思路
前面通过命令模式实现了撤销与重做功能。重做在游玩过程中可能不太常用,但在回放、录像、观战系统中却很常见。一种简单的重放实现是记录游戏每帧的状态,这样它可以回放,但那会消耗太多的内存。所以很多游戏只是记录每个实体每帧运行命令。重放游戏的时候,引擎只需要正常运行游戏,执行之前记录的命令即可。
所以可以我们这样理解:录像与回放等功能,可以基于命令模式实现,也就是执行并解析一系列经过预录制的序列化后的各玩家操作的有序命令集合。以下我提供三个此类系统实现的思路:
- replay 录像,可以通过将所有玩家的操作命令,序列化到一个.rep后缀的文件中,然后在游戏中进行解析后回放来实现。
- 观战功能则是通过网络在线不断获取该局比赛中各个玩家经过序列化后的有序命令流,然后在自己的客户端中解析并重放。
- 而一些游戏的回放系统则是在对战过程中就已经在实时地通过网络,将各个玩家的一系列操作命令发送到其他玩家的机器上,然后进行解析后进行模拟回放
这大致就是各种游戏中录像、回放、观战系统所用的一些设计思路。
命令模式的总结
命令模式将“请求”封装成对象,以便使用不同的请求、队列或者日志来参数化其他对象,同时支持可撤消的操作。
- 要点
- 将一组行为抽象为对象,这个对象和其他对象一样可以被存储和传递,从而实现行为请求者与行为实现者之间的松耦合,这就是命令模式。
- 命令模式是回调机制的面向对象版本。
- 命令模式的本质是对命令进行封装,将发出命令的责任和执行命令的责任分割开。
- 命令模式有不少的细分种类,实际使用时应根据当前所需来找到合适的设计方式。
- 使用场合
- 命令模式很适合实现诸如撤消,重做,回放,时间倒流之类的功能。
- 基于命令模式实现录像与回放等功能,也就是执行并解析一系列经过预录制的序列化后的各玩家操作的有序命令集合。
- 优点
- 对类间解耦。调用者角色与接受者角色之间没有任何依赖关系,调用者实现功能时只需调用
Command
抽象类的execute
方法即可,不需要了解到底是哪个接收者在执行。 - 可扩展性强。
Command
的子类可以非常容易地扩展,而调用者Invoker
和高层次的模块Client
之间不会产生严重的代码耦合。 - 易于命令的组合维护。可以比较容易地设计一个组合命令,维护所有命令的集合,并允许调用同一方法实现不同的功能。
- 易于与其他模式结合。命令模式可以结合责任链模式,实现命令族的解析;而命令模式结合模板方法模式,则可以有效减少
Command
子类的膨胀问题。
- 缺点
- 会导致类的膨胀。使用命令模式可能会导致某些系统有过多的具体命令类。因为针对每一个命令都需要设计一个具体命令类,这将导致类的膨胀。前面讲解优点时已经提到了应对之策,我们可以将命令模式结合模板方法模式,来有效减少
Command
子类的膨胀问题。也可以定义一个具体基类,包括一些能定义自己行为的高层方法,将命令的主体execute()
转到子类沙箱中,往往会有一些帮助。
- 引申与参考
- 你最终可能会得到很多不同的命令类。 为了更容易实现这些类,定义一个具体的基类,包含一些能定义行为的高层方法,往往会有帮助。 这将命令的主体
execute()
转到子类沙箱中。 - 在前面的例子中,我们明确地指定哪个角色会处理命令。 在某些情况下,特别是当对象模型分层时,也可以不这么简单粗暴。对象可以响应命令,或者将命令交给它的从属对象。如果我们这样实现了,就完成了一个职责链模式。
- 有些命令是无状态的纯粹行为,比如第一个例子中的
JumpCommand
。 在这种情况下,有多个实例是在浪费内存,因为所有的实例是等价的。对于等价的实例,可以用享元模式提高内存利用率。
有关命令模式的内容就此结束,系列文章未完待续。
With Best Wishes.