参考Game Programming Patterns完成的设计模式总结笔记。
命令模式
命令模式本质上希望把一个硬编码的函数解耦成可配置的函数命令,尽可能将功能触发和具体功能分离。
如下有一个玩家对象的输入控制功能,其中按下X键对应jump动作。如果我们想要将X键绑定到fireGun功能,那还得去代码里修改对应的硬编码if语句,然后重新编译,重新链接。
1 | void InputHandler::handleInput() |
动态绑定功能
那么我们来将功能触发和具体功能分离吧。既然想要让X键对应动态的功能,那么可以用一个
多态/函数指针/委托
来实现。命令模式即使用多态方式,如下将jump,fireGun之类的功能抽取成一个Command
基类的四个派生类,这样就可以通过Command
对象的多态来实现动态绑定:
1 | //基类 |
接下来我们就可以为按键绑定功能,如buttonX_=FireCommand,然后在按键触发时动态执行虚函数就行了:
1 | //处理类 |
这样我们在游戏流程中随时可以根据需要来替换按键对应的功能。当然,动态绑定还可以带来其他的便利性,如我们可以把Command.execute()
改成
Command.execute(GameObject actor)
,这样甚至能改变操作对象!
撤销和重做
如上所述,功能的动态绑定可以通过 多态/函数指针/委托 三种方式来实现,但为什么命令模式选择了Command类与多态呢?实现成类,一方面可以便于添加扩展功能,另一方面类拥有状态信息,可以针对命令做一些维护操作,比如撤销和重做。
例如我们有一个Move
Command,在execute()
中我们会移动角色,那么我们自然可以在Move中保留下角色移动之前的位置,然后构造一个Undo()
操作,来将原来的位置赋值为角色,完成撤销。
1 | class MoveUnitCommand : public Command |
享元模式
享元模式思路比较简单,就是把大量实例共享的那一部分数据单独抽取出来,成为一个共享数据ShareData
类,并让其他实例类引用它即可。这样可以节省大量的内存,并且在GPU渲染大量同类实例时也能节省性能。
观察者模式
观察者模式算是一种十分常用的模式了,即把数据的改动变化做成一个 事件,然后由 发布者发布该事件,订阅者则可以提前订阅好相关事件处理程序。每当数据改动时事件会被发布者Invoke,订阅者的事件处理程序即可响应执行。这在数据逻辑和UI分离的常规MVC开发模式下很常见。
事件的订阅执行并没有想象中的那么慢:实际上在C#中事件就是由委托类型(类似函数指针)完成的。订阅者用事件处理程序去订阅事件通知,即相当于在事件的数据结构里保存这个事件处理程序的函数指针,每次事件的Invoke无非是遍历事件保存的函数指针数组依次执行而已。不过需要小心的一点是事件发布者可能会被订阅者阻塞,例如某个前端事件处理程序去访问网络...因此小心UI线程相关!
对象销毁和垃圾处理:如上订阅者订阅事件时,发布者的内部其实是在引用订阅者对象。假如我们此时主动销毁了订阅者呢?那么事件Invoke时就会访问一个错误的野指针。所以订阅者需要注意在自己被释放时主动取消订阅。这个问题对垃圾回收语言有没有影响呢?C#中不需要主动销毁对象,因此倒不至于会得到一个野指针。但是由于发布者手握着一个引用,垃圾回收器只能认为订阅者对象是有用的,因此订阅者会一直存在于内存中,最终形成一种内存泄漏。更为严重的情况是,我们可能以为订阅者已经被回收了,之后又尝试加载一个新的订阅者示例,进行新的订阅,最终你的内存里可能有一万个订阅者!
例如玩家在离开战斗场景时,我们可能会想要卸载战斗UI,但实际上由于事件订阅的存在,UI被隐藏后并没有被回收。而下一次玩家进入战斗场景时会创建新的战斗UI,现在,同样的UI就有两个了!之后还会有3个,4个....
单例模式
单例模式:一方面限定一个类只有一个示例,另一方面为这个实例提供了全局访问方式,示例如下:
1 | class Singleton |
可以看出单例模式实现十分简单,并且带来的好处显而易见:
- 全局直接访问:所有调用对象都无需存储相关引用和指针。99.9%的单例模式都是为了创建全局访问的Manager类。
- 实例数量限定:避免不必要的重复。
- 惰性初始化实例:如果没有发生访问,则Singleton类永远不会创建实例。就算程序后期发生访问,那也是在运行时才实例化,不会给前期带来不必要的垃圾。
- 可动态实例化:这种全局访问的Manager类另一种类似的实现方法是C#的静态类,但是静态类很大的一个缺点就是初始化数据也只能是静态的。例如我不能写一个字段
static int a=ReadFromFile();
,而单例模式就没有这种动态依赖的困扰,想怎么初始化怎么初始化。 - 拥有类的完整特性,继承和多态:这样我们可以把一个单例基类扩展成多种泛化单例,分别添加不同的功能,之后再按需创建对应的一种。
每个了解单例模式的人都能很快的上手使用,并且觉得这个模式真是又快又好。但是难道把所有类都往单例上怼就行了吗?就像从我们学编程开始就被教导不要乱用全局变量一样,比起学会使用单例,我们更需要关注有没有滥用全局单例:
- 全局共享 = 类型安全降低:任何一个对单例的调用都可能对全局的数据进行更改。如果知道是哪个调用发生了错误的修改还好办,假如不知道是哪个调用呢?你开开心心的在单例的数据段打上断点,却发现这个单例会有几百次的相关调用,就算是007也很难从中找出错误的那一个。
- 全局访问 = 失去访问权限控制:增加耦合性。随便一个类,只要它想,就能在里面加上一笔单例的调用,导致代码的引用链简直乱七八糟,例如:逻辑功能 ref 音频单例 ref 物理单例 ref 渲染单例 ref 逻辑单例。有可能程序员只想做一个走路带声音的效果,但这段代码却把整个引擎系统都逛了一遍。之后维护的时候动了物理单例,可能音频单例就出错了,动了渲染单例,可能逻辑就跑出BUG了,失去了访问权限的控制,程序员不可能自觉的降低代码耦合。
- 实例数量限定 = 等等,我们真的需要单例的"单"吗?:正如上面所述,99.9%的单例模式都是为了全局访问,但我们往往就顺手给这个全局访问点加了个"限定唯一"的特性。我们真的需要唯一的访问点吗?好好想想。另一方面,当我们需要限定实例数量的时候,我们需要为它提供全局访问吗?换句话说,"全局"和"单例"有必要同时出现吗?
- 惰性初始化 = 惰性加载还是预加载:在游戏开发中,预加载十分常见,有时候我们并不希望需要的时候再即时加载一个东西,而是希望提前在某个宽裕的点加载好。
总而言之,比起把不方便访问的类一股脑的塞给单例模式,更优先想想是否需要全局变量,是否需要数量控制,如果是小范围的访问能不能用成员变量或者传递参数的方式解决?
状态模式
让我们先从状态机的应用灵感讲起。在游戏中我们肯定会有一个角色控制器,其要控制玩家的 跳跃、卧倒、站立操作。当然,我们希望不能在空中无限跳跃,并且不能在空中卧倒等等...因此我们要在控制器中加入许多的判断条件检查是否允许操作:
1 | void Player::handleInput(Input input) |
有限状态机的使用
可以看到光是 跳跃、卧倒、站立
这三个操作之间的互相耦合就已经非常难看了,如果还要加入攻击操作、道具操作、闪避操作等...可想而知代码中的if
会爆炸式的增长,并且也根本理不清其中的避让关系。这时候就需要我们好好把所有的限定状态理清楚,迎接有限状态机的救援
(这里不细谈有限状态机的基本定义):
这个时候我们需要顾虑的事情就变了:
- 之前的担忧:进入下一个操作前,需要检查不能从什么操作转移过来 (禁止父操作),比如 跳跃 之前不能在 攻击、下蹲、喝药...。
- 现在的考量:在当前操作状态中,需要检查能去往什么操作状态 (可行子操作)。比如 跳跃 能去往 跳斩。
通常情况下对于一个操作状态来说,它的 可行子操作 肯定比 禁止父操作 要多得多。如图中 跳斩 没有去往的操作状态,但是不能前往 跳斩 的操作有 蹲下、站立,而且随着状态的增加,禁止父操作会O(N)级别增长,而可行子操作往往变化不大。因此,在通过状态机转换思考方式之后,我们需要考虑的条件明显简化了很多。那么我们怎么来实现这个状态机呢?首先需要定义所有的状态:
1 | enum State |
然后根据我们的状态图,可以定义出每个状态的状态转移操作,朴素点的话可以用if
来实现条件判断:
1 | void Player::handleInput(Input input) |
这已经完成了状态机的模型,但还差了点意思。这里每个状态只是用一个枚举以及switch的分支来决定,但其实所有的状态还是在共享着Player的所有数据段。因此假如我们要为每个运动设置一个持续时间,那么我们就要往Player里塞4个time数据。并且对于其中一个状态来说,有3个time数据是和它没任何关系的。为了优化这一点,我们可以很自然的想到用派生类来表示状态:
首先定义一个状态基类,其定义了每个状态的基本操作,例如进入状态时操作、退出状态时操作、帧更新、接受输入:
1 | class PlayerState |
然后定义具体的状态类:
1 | class DuckingState : public PlayerState |
这时候每个状态会管理好自己的转移操作,并且我们还可以为每个状态单独添加其专属的数据字段,如chargeTime_
。并且在Player
中对状态的处理也可以利用多态来简化:
1 | class Player |
有限状态机已经很棒了,它帮我们把复杂的判断条件全部梳理了出来,整个系统变得从所未有的干净整洁!但是如果你用过Unity的动画机就知道,仅仅是一个的有限状态机只能表示一张连通的状态图,但实际中我们还会有很多不相关的状态,并不是所有图都是连通图不是吗?
多个并行的状态机
例如我们现在已经掌握了Player的所有角色动作,我们还想要增加一个 持枪 的状态,持枪和之前的 跳跃、蹲下、站立都没关系,无论在什么姿势都能在Player的手上加上这把枪。那我们该怎么判断呢?如果只用一个状态机,那我们难道要直接增加一倍的状态:持枪跳跃、持枪蹲下、持枪站立?这样显然及其低效且繁琐。因此我们可以为持枪创建一个独立的状态机,即让Player拥有两个并行状态判断:
1 | class Player |
这时候在action_state里开个小的if
分支判断equip_state的状态接口。例如equip_state处于不持枪时则保持jump_image,
持枪则改为jump_and_gun_image。当然这样又回到了使用状态机之前的if
地狱,只不过规模小了一点,可以称为if
地牢。
分层状态机
再来考虑复用/继承状态机的事情。例如在Player站立、跳跃时,我们要允许其进行开火操作:生成子弹、生成粒子效果、生成音效...我们显然不能在Player中实现Fire()
操作,因为这样那些禁止开火的状态也能调用这个功能了,因此我们得在允许开火的状态内实现。在最粗暴的时候,我们会为站立、跳跃状态都粘贴Fire()
操作,但这样显然不利于维护,我们希望Fire()
的代码能够在多个状态机之间复用。因此按照我们平常面向对象的思路,我们可以抽取出一个
允许开火
的基类状态,把Fire()
实现在里面,再让站立、跳跃的状态继承它。这就是分层状态机的结构。
下推状态机
下推状态机即为状态机加上历史记录功能。例如我们允许Player进行喝药补血,它可以蹲着喝也可以站着喝(喝药是一个持续性的动作,因此我们把它当做状态而不是操作),但是我们不希望它蹲着喝完就自动站起来了,或者站着喝完就自动蹲下了。我们需要记录其在进入喝药状态之前的状态,显然我们需要维护一个状态栈来表示状态的历史记录。这就是下推状态机。