URL
status
type
date
slug
summary
tag
category
password
icon
目的
正确地使用它,你的代码会变得更加优雅。
模式
“命令就是一个对象化(实例化)的方法调用(A command is a reified method call)。”
“命令就是面向对象化的回调(Commands are an object-oriented replacement for callbacks)。”
一些语言的反射系统(Reflection system)可以让你在运行时命令式地处理系统中的类型。你可以获取到一个对象,它代表着某些其他对象的类,你可以通过它试试看这个类型能做些什么。换句话说,反射是一个对象化的类型系统。
使用情境
配置输入
- 通过模块化的框架处理玩家输入
- 自定义输入命令的控制对象
- AI代理游戏对象
撤销和重做
- 策略游戏中的撤销功能;
- 游戏内容的回放;
- 游戏状态的回溯;
实例部分
配置输入
每个游戏都有一处代码块用来读取用户原始输入:按钮点击、键盘事件、鼠标点击,或者其他输入等。它记录每次的输入,并将之转换为游戏中一个有意义的动作(action),如下图所示。
简单的实现
这个函数通常会在每一帧中通过游戏循环(第9章)被调用,我想你能理解这段代码的作用。如果我们将用户的输入硬编码到游戏的行为(game actions)中去,上面的代码是有效的,但是许多游戏允许用户配置他们的按钮与游戏行为之间的映射关系。
命令模式实现
为了支持自定义配置,我们需要把那些对jump()和fireGun()方法的直接调用转换为我们可以更换(swap out)的东西。“可更换的(swapping out)”听起来会让人联想到分配变量,所以我们需要个对象来代表一个游戏动作。这就用到了命令模式。
我们定义了一个基类用来代表一个可触发的游戏命令:
然后,为每种游戏动作创建一个命令的子类:
在我们的输入处理中,我们为每个按钮存储一个指向它的指针。
现在输入处理便通过这些指针进行代理:
注意,我们这里没有检查命令是否为NULL。因为这里假设了每个按钮都有某个命令对象与之对应关联。如果你想要支持不处理任何事情的按钮,而不用明确检查按钮对象是否为NULL,我们可以定义一个命令类,这个命令类中的execute()方法不做任何事情。然后,我们将按钮处理器(button handler)指向一个空值对象(null object),就好像它指向了NULL一样。这便是应用了空值对象模式。
以前每个输入都会直接调用一个函数,现在则增加了一个间接调用层,如下图所示。
简而言之,这就是命令模式
自定义控制对象
我们刚才定义的命令类在上个例子中是有效的,但它们却有局限性。问题在于它们做了这样的假定:存在jump()、fireGun()等这样的顶级函数,这些函数能够隐式地获知玩家游戏实体并对其进行木偶般的操控。
这种对耦合性的假设限制了这些命令的使用范围。JumpCommand类的跳跃命令只能作用于玩家对象。让我们放宽限制,传进去一个我们想要控制的对象而不是让命令自身来确定所控制的对象:
GameActor是我们用来表示游戏世界中的角色的“游戏对象”类。我们将它传入execute()中,以便命令的子类可以针对我们选择的角色进行调用,如下所示:
现在,我们可以使用这个类让游戏中的任何角色来回跳动。但是,在输入处理(Input Handler)和接受命令并针对指定对象进行调用的命令之间,我们还缺少了一些东西。
首先,我们需要修改一下
handleInput()
方法,像下面这样返回一个命令(commands):它不能立即执行命令,因为它并不知道该传入哪个角色对象。这里我们所利用的是命令即具体化(reified)的函数调用这一点——我们可将命令的调用延迟到handleInput被调用之时。
然后,我们需要一些代码来接收命令并让象征着玩家的角色执行命令。代码如下所示:
假设actor是对玩家角色的一个引用,那么上面的代码将会基于用户的输入来驱动角色,于是我们赋予了角色与前例一致的行为。而在命令和角色之间加入的间接层使得我们可以让玩家控制游戏中的任何角色,只需通过改变命令执行时传入的角色对象即可。
人工智能
在实际情况中,上述问题的特征并不具有普遍性,而另一种相似的状况却很常见。迄今为止,我们只考虑了玩家驱动角色(player-driven character),但是对于游戏世界中的其他角色呢?它们由游戏的AI来驱动。我们可以照搬上面的命令模式来作为AI引擎和角色之间的接口;AI代码简单地提供命令(Command)对象以供执行。
选择命令的AI和表现玩家的代码之间的解耦为我们提供了很大的灵活性。我们可以对不同的角色使用不同的模块。或者我们可以针对不同种类型的行为将AI进行混搭。你想要一个更加具有侵略性的敌人?只需要插入一段更具侵略性的AI代码来为它生成命令。事实上,我们甚至可以将AI使用到玩家的角色身上,这对于实现自动演算的游戏演示模式(demo mode)是很有用的。
关于队列的更多信息,见事件队列(第15章)。 为什么我感觉有必要通过图片来解释“流”呢?为什么它看起来就像一个管道?”
将控制角色的命令作为头等对象,我们便解除了函数直接调用这样的紧耦合。把它想象成一个队列(queue)或者一个命令流(stream of commands)如下图所示。
一些代码(输入处理或者AI)生成命令并将它们放置于命令流中,一些代码(发送者或者角色自身)执行命令并且调用它们。通过中间的队列,我们将生产者端和消费者端解耦。
如果我们把这些命令序列化,我们便可以通过网络发送数据流。我们可以把玩家的输入,通过网络发送到另外一台机器上,然后进行回放。这是多人网络游戏很重要的一部分。
撤销和重做
“最后这个例子(撤销和重做)是命令模式的成名应用了。如果一个命令对象可以做(do)一些事情,那么就应该可以很轻松地撤销(undo)它们。撤销这个行为经常在一些策略游戏中见到,在游戏中可以回滚一些你不满意的步骤。在创建游戏时这是一个很常见的工具。如果你想让你的游戏设计师们讨厌你,最可靠的办法就是不在关卡编辑器中提供撤销命令,让他们对自己无意犯的错误束手无策。
如果没有命令模式,那么实现撤销是很困难的。有了它,这简直是小菜一碟啊。假定一个情景,我们在制作一款单人回合制的游戏,我们想让玩家能够撤销一些行动以便他们能够更多地专注于策略而不是猜测。
我们已经对使用命令模式来抽象输入处理很上手了,所以角色的每个行动都要封装起来。例如,像下面这样来移动一个单位:
注意这和我们前面的命令都不太相同。在上个例子中,我们想要从被操控的角色中抽象出命令,以便将角色和命令解耦。在这个例子中,我们特别希望将命令绑定到被移动的单位上。这个命令的实例不是一般性质的“移动某些物体”这样适用于很多情境下的的操作,在游戏的回合次序中,它是一个特定具体的移动。
这凸显了命令模式在实现时的一个变化。在某些情况下,像我们第一对的例子,一个命令代表了一个可重用的对象,表示一件可完成的事情(a thing that can be done)。我们前面的输入处理程序仅维护单一的命令对象,并在对应按钮被按下的时候调用其
execute()
方法。当然了,在没有垃圾回收机制的语言(如C++)中,这意味着执行命令的代码也要负责释放它们申请的内存。
这里,这些命令更加具体。它们表示一些可在特定时间点完成的事情。这意味着每次玩家选择一个动作,输入处理程序代码都会创建一个命令实例。如下所示:
一次性命令的特质很快能为我们所用。为了使命令变得可撤销,我们定义了一个操作,每个命令类都需要来实现它:
undo()方法会反转由对应的execute()方法改变的游戏状态。下面我们针对上一个移动命令加入了撤销支持:
注意到我们在类中添加了一些状态。当单位移动时,它会忘记它刚才在哪。如果我们要撤销移动,就必须记录单位的上一次位置,这正是xBefore_和yBefore_变量的作用。
这看起来挺像备忘录模式1的,但是我发现备忘录模式用在这里并不能有效的工作。因为命令试图去修改一个对象状态的一小部分,而为对象的其他数据创建快照是浪费内存。只手动存储被修改的部分相对来说就节省很多内存了。持久化数据结构2是另一个选择。通过它们,每次对一个对象进行修改都会返回一个新的对象,保留原对象不变。通过这样明智的实现,这些新对象与原对象共享数据,所以比拷贝整个对象的代价要小得多。 使用持久化数据结构,每个命令存储着命令执行前对象的一个引用,所以撤销意味着切换到原来先前的对象。”
为了让玩家能够撤销一次移动,我们保留了他们执行的上一个命令。当他们敲击Control-Z时,我们便会调用该命令的undo()方法。(如果他们已经撤销了,那么会变为“重做”,我们会再次执行原命令。)
支持多次撤销并不难。这次我们不再保存最后一个命令,取而代之的是,我们维护一个命令列表和一个对“当前”(current)命令的一个引用。当玩家执行了一个命令,我们将这个命令添加到列表中,并将“current”指向它(如下图所示)。
当玩家选择“撤销”时,我们撤销当前的命令并且将当前的指针移回去。当他们选择“重做”时,我们将指针前移然后执行它所指向的命令。如果他们在撤销之后选择了一个新的命令,那么列表中位于当前命令之后的所有命令都被舍弃掉。
我第一次在一个关卡编辑器中实现了这一点,顿时自我感觉良好。我很惊讶它是如此的简单而且高效。我们需要制定规则来确保每个数据的更改都经由一个命令实现,但只要定了规则,剩下的就容易得多。
重做在游戏中并不常见,但回放却很常见。一个简单的实现方法是记录每一帧的游戏状态以便回放,但这样会使用大量的内存。 实际上,许多游戏会记录每一帧每个实体执行的一系列命令。为了回放游戏,引擎只需模拟正常游戏的运行,执行预先录制的命令即可。 比如赛博朋克2077中的超梦功能,它就是将每一帧的游戏状态预先录制并存储起来,然后进行回放。 抑或是galgame中的倒档,也是通过存储前面的每一帧画面,再进行回放实现的。 但这种方式只适用于回放内容确定的情况。如果回放内容是随机且不可预知的,那么可能仍然无法避免实时记录所消耗的大量内存。 也是这个原因,时空幻境的回溯功能才做了上限,命令列表的长度固定,最前面的操作将会过期而无法回溯,否则这种小游戏都会玩得越来越卡。
设计决策
类风格化还是函数风格化
此前,我说命令和头等函数或者闭包相似,但是这里我举的每个例子都用了类定义。如果你熟悉函数式编程,你可能想知道如何用函数式风格实现命令模式。
我用这种方式写例子是因为C++对于头等函数的支持非常有限。函数指针是无状态的,仿函数看起来比较怪异,它需要定义一个类,C++11中的闭包因为要手动管理内存,所以使用起来比较棘手。
这并不是说在其他语言中你不应该使用函数来实现命令模式。如果你使用的语言中有闭包的实现,毫无疑问,使用它们!在某些方面,命令模式对于没有闭包的语言来说是模拟闭包的一种方式。
我说在某些方面,是因为即使在有闭包的语言中为命令构建实际的类或结构仍然是有用的。如果你的命令有多个操作(如可撤销命令),那么映射到一个单一函数是比较尴尬的。定义一个实际的附带字段的实体类也有助于读者分辨该命令中包含哪些数据。闭包自动包装一些状态的方式是比较简洁,但它们太过于自动化了以至于很难分辨出它们实际上持有的状态。
举个例子,如果我们在用JavaScript编写游戏,那么我们可以像下面这样创建一个单位移动命令:
参考部分
1.你可能最终会有很多不同的命令类。为了更容易地实现这些类,可以定义一个具体的基类,里面有着一些实用的高层次的方法,这样便可以通过对派生出来的命令组合来定义其行为,这么做通常是有帮助的。它会将命令的主要方法execute()变成子类沙盒(第12章)。
2.在我们的例子中,我们明确地选择了那些会执行命令的角色。在某些情况下,尤其是在对象模型分层的情况下,它可能没这么直观。一个对象可以响应一个命令,而它也可以决定将命令下放给其从属对象。如果你这样做,你需要了解下责任链(Chain of Responsibility)。
3.一些命令如第一个例子中的JumpCommand是无状态的纯行为的代码块。在类似这样的情况下,拥有不止一个这样命令类的实例会浪费内存,因为所有的实例是等价的。享元模式(第3章)就是解决这个问题的。
- 作者:Cloud
- 链接:https://cloud09.xyz/article/88d6149f-cfcc-4a46-b7eb-2ee1939760f2
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。