Game Programming Patterns: #2 Part.1

date
Aug 18, 2023
slug
gpp2.1
status
Published
tags
Program
summary
Design Patterns Revisited: Command
type
Post

引言

本篇文章源于《Game Programming Patterns》第二章第一节(游戏设计模式:命令模式
浅薄地说,命令模式就是将各种命令操作设计成类进行保存,使用时调用类的对象来实现操作。这个模式在游戏或是其他大型的项目中都很常用,操作设计成类后,相比于将操作封装成函数,在引起执行操作的条件不变时想要改变操作内容就不需要更改函数源代码,只需要获取新操作的对象。用它很容易实现撤销(UNDO)和重做(REDO)操作,是个十分经典的设计模式。

命令模式的定义

命令模式可以应用于大多数游戏或是别的什么类似结构的大型程序中,当正确的使用命令模式时,可以极大的提高效率,它可以将复杂的代码清理干净,对于如此强大的模式,原书中提到:设计模式界的扛鼎之作《Design Patterns: Elements ofReusable Object-Oriented Softwar》(中译版《设计模式:可复用面向对象软件的基础》) 一书的作者GoF对此有个深奥的定义:
命令模式将“请求”封装成对象,以便使用不同的请求、队列或者日志来参数化其他对象,同时支持可撤消的操作。Encapsulate a request as an object, therebyletting you parameterizeclients with different requests, queue or log requests,and support undoable operations.
而在游戏设计模式这个章节中作者的命令模式精简定义为:
命令是具现化的方法调用。
“具现化”意思是“实例化,对象化”。 具现化的另外一种解释方式是将某事物作为“第一公民”对待。
两种术语都意味着将概念变成数据 ——一个对象——可以存储在变量中,传给函数。 所以称命令模式为“具现化方法调用”,意思是方法调用被存储在对象中。
这听起来有些像“回调”,“第一公民函数”,“函数指针”,“闭包”,“偏函数”, 取决于你在学哪种语言,事实上大致上是同一个东西。GoF随后说:
命令模式是一种回调机制的面向对象实现。Commands are an object-oriented replacementfor callbacks.
它是回调的面向对象版本,这些对命令模式的解释都有些抽象又模糊。
而命令模式在游戏编程中的经典应用则为撤消,重做,回放,时间倒流之类的功能。基于命令模式实现录像与回放等功能,也就是执行并解析一系列经过预录制的序列化后的各玩家操作的有序命令集合。
故而我对命令模式的理解为:
命令模式将“请求”封装成对象,以便使用不同的请求、队列或者日志来参数化其他对象,同时支持可撤消的操作。
单看以上的这些概念也许太过于抽象,还是让我结合实例进行理解吧

引例(配置输入)

在每个游戏中都有类似于输入的操作“按钮按下,键盘敲击,鼠标点击,其余外设信号的输入等等”,一些代码块就是用来读取用户的输入操作的,这些代码记录每次的输入,并将之转换为游戏中一个有意义的动作(action),如下图:
notion image
最简单的实现大概是这样
这个函数通常在游戏循环中每帧调用一次。在我们想将用户的输入和程序行为硬编码在一起时,这段代码可以正常工作。但如果想实现用户自定义配置他们的按钮与动作的映射,就需要进行修改了。
为了支持玩家的自定义配置,我们需要将这些对jump()fireGun()的直接调用转化为可以变换(swap out)的东西。 “变换”(swapping out)听起来很像分配变量,因此我们需要个对象来表示游戏行为的对象。这就用到了命令模式
我们定义了一个基类代表可触发的游戏行为:
然后我们为每个不同的游戏动作创建一个子类,public继承自我们的Command类:
在负责输入处理的InputHandler中,我们为每个键存储一个指向Command的指针。
现在InputHandler部分这样处理:
以前每个输入都会直接调用一个函数,现在则会有一个间接寻址层:
notion image
这就是命令模式的最基础的实现,按照其思路画了一个大概的形状出来。
简而言之,命令模式的关键在于引入了抽象命令接口(execute( )方法),且发送者针对抽象命令接口编程,只有实现了抽象命令接口的具体命令才能与接收者相关联。而且命令模式的本质是对命令进行封装,将发出命令的责任和执行命令的责任分割开。
如果你能够看出它的好处,那我们可以继续。

进一步地使用命令模式(角色说明)

我们前面的定义的命令类貌似可以顺利的工作,但有很大的局限,问题在于假设了顶层的jump(), fireGun()之类的函数可以与玩家关联并控制玩家。这种假设耦合限制了这些命令的的效用。JumpCommand只能 让玩家的角色跳跃。不让函数去找它们控制的角色,我们将想要用函数控制的角色对象传进去,而不是用命令对象自身来调用函数:
这里GameActor是代表游戏世界中角色的“游戏对象”类。 我们将其传给execute(),这样可以在它的子类中添加函数,来与我们选择的角色关联,就像这样:
现在,我们可以使用这个类控制游戏中的任何角色。 还少了一块在输入控制和在正确的对象上起作用之间的代码。 首先,我们修改handleInput()这样它可以返回命令:
这段代码不能直接执行命令,因为还不知道哪个角色对象会传进来。 命令是一个对象化的调用,是回调的面向对象版本,这里我们可以利用命令是具体调用的好处——我们可以延迟调用
然后,我们需要一些代码来保存命令并且执行对玩家角色的调用。像下面这样:
actor视为玩家角色的一个引用,它会正确地按着玩家的输入驱动角色, 所以我们赋予了角色和前面例子中相同的行为。 在命令和角色之间加入的间接层使得我们可以让玩家控制游戏中的任何角色,这样,我们就获得了一个灵巧的功能:我们可以让玩家控制游戏中的任何角色,只需向命令传入不同的角色对象。
在实践中,这个特性并不经常使用,但是经常会有类似的用例跳出来。 到目前为止,我们只考虑了玩家控制的角色,但是游戏中的其他角色呢? 它们由游戏AI驱动。我们可以使用相同的命令模式来作为AI引擎和角色的接口;AI代码部分提供命令(Command)对象用来执行,
AI选择命令,角色执行命令,它们之间的解耦给了我们很大的灵活性。 我们可以对不同的角色使用不同的AI模块,或者为了不同的行为而混合AI。 你想要一个更加具有侵略性的敌人?插入一个更具侵略性的AI为其生成命令。 事实上,我们甚至可以为玩家角色加上AI, 在展示阶段,游戏需要自动演示时,这是很有用的。比如说自动运行的demo模式和最近似乎在流行的自动战斗系统。
通过将控制角色的命令作为对象,我们便去掉了直接调用指定函数这样的紧耦合。我们不妨将这样的方式理解成一个队列或者一个命令流(queue or stream of commands):
notion image
 
如图,一些代码(输入控制器或者AI)产生一系列指令后将其放入流中。 另一些指令(调度器或者角色自身)调用并消耗指令。 通过在中间加入队列,我们解耦了行为请求者和行为实现者。
而且,如果将这些命令序列化,便可以通过互联网来发送数据流。可以把玩家的输入通过网络放送到另外一台机器上,然后重现之,这样就可以实现游戏中的回放功能。这是网络多人游戏的基础。

小结

本篇介绍了命令模式定义和两个简单的实例,下一篇将对实现撤消与重做功能录像与回放系统的实现思路 进行简单的介绍,并且对命令模式进行提炼总结
 

后续内容:
 

© Yuay 2022 - 2024 Next.js & Notion