设计模式
参考《游戏编程模式》
1. 命令模式 Command Pattern
介绍
https://gpp.tkchu.me/command.html
命令模式是一种回调的面向对象实现。
命令模式将“请求”封装成对象,以便使用不同的请求、队列或者日志来参数化其他对象,同时支持可撤消的操作。
命令模式的本质是对命令进行封装,将发出命令的责任和执行命令的责任分割开。
命令模式的优点有:对类间解耦、可扩展性强、易于命令的组合维护、易于与其他模式结合,而缺点是会导致类的膨胀。
使用场合
命撤消,重做,回放,时间倒流之类的功能。
基于命令模式实现录像与回放等功能,也就是执行并解析一系列经过预录制的序列化后的各玩家操作的有序命令集合。
玩家输入控制 & 菜单项
在控制玩家输入的时候,使用命令模式可以使得按键和按键对应的命令解绑,从而支持用户自定义,还可以把指令的触发和执行时间解绑,实现延时执行。对于多角色游戏,还能为玩家和AI之间提供一套通用的命令接口
宏记录 & GM 指令
所谓宏,就是将一些命令组织在一起,作为一个单独命令完成一个特定任务。而宏记录是指将用户的一系列命令记录下来,将记录下来的一组命令作为一个宏。通过宏记录能够实现对玩家所有操作的录像。
GM 指令一般来说能够模拟玩家操作来快速获得游戏的一些资源或者推进游戏进度。如果为玩家操作的命令类型实现一个 toScript() 方法,将玩家的一个操作直接转换成一个可执行的 GM 指令,那么就可以直接通过宏记录执行 GM 指令回放玩家的操作。关于这个 toScript() 方法稍微再解释一下,一般游戏里面会嵌入脚本引擎,例如 lua ,因此可以方便地将一个操作转化成可执行的 lua 脚本。
网络应用
命令对象可以方便地在网络中进行传输,这样一个一个指令就可以同时在多个机器上运行。例如在移动游戏的PVE战斗中,客户端会先将玩家在战斗中的所有操作记录下来,在战斗结束后将所有操作上传给服务器再重新计算一遍,确保客户端没有作弊。
进度条
将每个需要加载的操作作为命令对象,并给命令对象实现一个加载时间预估的方法,可能使得进度条能够较为精准得反映加载进度。
新手引导
使用命令模式可以实现某些类型新手引导的无缝切入,例如某个新手引导只需要玩家一路点击 next 直到点击确认进入实际的游戏环境。那么可以把这个新手引导作为一个命令对象,在需要展示该引导的时候新建命令对象,让该命令对象处理 next 的交互逻辑与每个引导页的展示逻辑,等到完成的时候,命令对象直接调用传入接收者的执行方法完成转换。
命令模式的用途举例:队列请求
运用命令模式,即使命令对象被创建许久之后,运算依然课被调用。事实上,它甚至可以在不同的线程中被调用。我们可以利用这样的特性衍生出一些应用,如:日程安排(Scheduler),线程池,工作队列等。
工作队列
在一端添加命令
线程在另一端取出命令,调用它的execute()方法,等待这个调用完成,然后将此命令对象丢弃,再取出下一个命令......
工作队列类和进行计算的对象之间完全是解耦的。此刻线程可能在进行财务计算,下一刻却在读取网络数据。工作队列对象不在乎到底做些什么,它们只知道取出命令对象,就可以放入队列里,当线程可用时,就调用此对象的execute()方法。
命令模式的用途举例:日志请求
将所有的动作记录在日志中,并能在系统死机后,重新调用这些动作恢复到之前的状态。
通过新增两个方法(store(),load()),命令模式能够支持这一点。
我们可以将上次检查点(checkpoint)之后的所有操作记录下来,如果系统出状况,从检查点开始应用这些操作
享元模式 Flyweight Pattern
介绍
https://gpp.tkchu.me/flyweight.html
享元模式,以共享的方式高效地支持大量的细粒度的对象。通过复用内存中已存在的对象,降低系统创建对象实例的性能消耗。
享元模式中有两种状态。内蕴状态(Internal State)和外蕴状态(External State)。
内蕴状态,是不会随环境改变而改变的,是存储在享元对象内部的状态信息,因此内蕴状态是可以共享的。对任何一个享元对象而言,内蕴状态的值是完全相同的。
外蕴状态,是会随着环境的改变而改变的。因此是不可共享的状态,对于不同的享元对象而言,它的值可能是不同的。
享元模式通过共享内蕴状态,区分外蕴状态,有效隔离系统中的变化部分和不变部分。
使用场合
当系统中某个对象类型的实例较多的时候。
由于使用了大量的对象,造成了很大的存储开销。
对象的大多数状态都可变为外蕴状态。
在系统设计中,对象实例进行分类后,发现真正有区别的分类很少的时候。
观察者模式 Observer Pattern
介绍
https://gpp.tkchu.me/observer.html
定义了对象之间的一对多依赖,这样一来,当一个对象改变状态时,它的所有依赖者都会收到通知并自动更新。
观察者推的方式,被观察者改变就主动告诉所有观察者,还有拉的方式,被观察者提供get接口,观察者主动获取数据。
当一个系统中一个对象的改变需要同时改变其他对象内容,但是又不知道待改变的对象到底有多少个的时候。
当一个对象的改变必须通知其他对象作出相应的变化,但是不能确定通知的对象是谁的时候。
Java将其放到了核心库之中(java.util.Observer),C#直接将其嵌入了语法(event关键字)。
链式观察者。减少动态分配的方法:使用链表。每个被观察者有一链表的观察者。
适用场景
事件分发管理器。当用户进行触摸、按键等输入后,输入管理类将事件分发给添加了事件监听的对象。
游戏业务逻辑与游戏控制器。当游戏业务逻辑改变了自身状态后,将该变化通知给添加了监听的控制器。
具体案例
成就解锁系统
原型模式 Prototype Pattern
介绍
https://gpp.tkchu.me/prototype.html
用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。
原型模式是一种比较简单的模式,也非常容易理解,实现一个接口,重写一个方法即完成了原型模式。在实际应用中,原型模式很少单独出现。经常与其他模式混用,他的原型类Prototype也常用抽象类来替代。
使用原型模式拷贝对象时,需注意浅拷贝与深拷贝的区别。
原型模式可以结合JSON等数据交换格式,为数据模型构建原型。
使用场合
产生对象过程比较复杂,初始化需要许多资源时。
当直接创建对象的代价比较大时,则采用这种模式。例如,一个对象需要在一个高代价的数据库操作之后被创建。我们可以缓存该对象,在下一个请求时返回它的克隆,在需要的时候更新数据库,以此来减少数据库调用。
单例模式
介绍
https://gpp.tkchu.me/singleton.html
保证一个类只有一个实例,并且提供了访问该实例的全局访问点。
尽量少用单例模式。单例模式作为一个全局的变量,有很多全局的变量的弊病。它会使代码更难理解,更加耦合,并且对并行不太友好。
防止多线程竞争,构造函数中可以先判空再加锁。如果用C++11,可以用本地静态变量,如下,C++11标准也保证了本地静态变量只会初始化一次,所以下面的代码是安全的。
使用场合
大多数游戏都不使用惰性初始化
如果初始化音频系统消耗了几百个毫秒,我们需要控制它何时发生。 如果在第一次声音播放时惰性初始化它自己,这可能发生在游戏的高潮部分,导致可见的掉帧和断续的游戏体验。
当在系统中某个特定的类对象实例只需要有唯一一个的时候,如文件系统、日志系统。
为了保证实例是单一的,可以简单的使用静态类。 还可以使用静态标识位,在运行时检测是不是只有一个实例被创建了。
状态模式
介绍
https://gpp.tkchu.me/state.html
允许对象在当内部状态改变时改变其行为,就好像此对象改变了自己的类一样。
状态模式用来解决当控制一个对象状态转换的条件表达式过于复杂的情况,它把状态的判断逻辑转移到表示不同的一系列类当中,可以把复杂的逻辑判断简单化。
状态模式的实现分为三个要点:
为状态定义一个接口。
为每个状态定义一个类。
恰当地进行状态委托。
通常来说,状态模式中状态对象的存放有两种实现存放的思路:
静态状态。初始化时把所有可能的状态都new好,状态切换时通过赋值改变当前的状态。
实例化状态。每次切换状态时动态new出新的状态。
使用场合
双缓冲模式 Double Buffer
介绍
https://gpp.tkchu.me/double-buffer.html
双缓冲模式,使用序列操作来模拟瞬间或者同时发生的事情。
一个双缓冲类封装了一个缓冲:一段可改变的状态。这个缓冲被增量的修改,但我们想要外部的代码将其视为单一的元素修改。 为了实现这点,双缓冲类需保存两个缓冲的实例:下一缓存和当前缓存。
当信息从缓冲区中读取,我们总是去读取当前的缓冲区。当信息需要写到缓存,我们总是在下一缓冲区上操作。 当改变完成后,一个交换操作会立刻将当前缓冲区和下一缓冲区交换, 这样新缓冲区就是公共可见的了。旧的缓冲区则成为了下一个重用的缓冲区。
注意,在状态被修改后,双缓冲需要一个swap步骤。 这个操作必须是原子的——在交换时,没有代码可以接触到任何一个状态。 通常,这就是修改一个指针那么快,但是如果交换消耗的时间长于修改状态的时间,那就毫无助益。
使用场合
我们需要维护一些被增量修改的状态
在修改过程中,状态可能会被外部请求。
我们想要读取状态,而且不想在修改的时候等待。
双缓冲模式常用来做帧缓冲区交换。
游戏循环模式 Game Loop
介绍
https://gpp.tkchu.me/game-loop.html
游戏循环在游戏过程中持续运转。每循环一次,它非阻塞地处理用户的输入,更新游戏状态,并渲染游戏。它跟踪流逝的时间并控制游戏的速率。
游戏循环将游戏的处理过程和玩家输入解耦,和处理器速度解耦,实现用户输入和处理器速度在游戏行进时间上的分离。
游戏循环也许需要与平台的事件循环相协调。如果在操作系统的高层或有图形UI和内建事件循环的平台上构建游戏,那就有了两个应用循环在同时运作,需要对他们进行相应的协调。
使用场合
任何游戏或游戏引擎都拥有自己的游戏循环,因为游戏循环是游戏运行的主心骨。
更新方法 Update Method
介绍
https://gpp.tkchu.me/update-method.html
更新方法,通过每次处理一帧的行为来模拟一系列独立对象。
更新方法模式:在游戏中保持游戏对象的集合。每个对象实现一个更新方法,以处理对象在一帧内的行为。每一帧中,游戏循环对集合中的每一个对象进行更新。
当离开每帧时,我们也许需要存储下状态,以备不时之需。
使用场合
更新方法和游戏循环模式一般一起使用。更新方法适应以下情况:
游戏中有很多对象或系统需要同时运行。
每个对象的行为都与其他的大部分独立。
游戏中的对象需要随时间模拟。
字节码模式 Bytecode
介绍
https://gpp.tkchu.me/bytecode.html
字节码模式,将行为编码为虚拟机器上的指令,来赋予其数据的灵活性。从而让数据易于修改,易于加载,并与其他可执行部分相隔离。
字节码模式:指令集定义了可执行的底层操作。一系列的指令被编码为字节序列。 虚拟机使用中间值堆栈 依次执行这些指令。 通过组合指令,可以定义复杂的高层行为。
可以理解为项目中的转表工具,将excel中的数据转为二进制数据,并读取到工程中,如在项目中使用googleprotobuf或json。
字节码类似GOF的解释器模式,这两种方式都能让我们将数据与行为相组合。其实很多时候都是两者一起使用。用来构造字节码的工具会有内部的对象树,而为了编译到字节码,我们需要递归回溯整棵树,就像用解释器模式去解释它一样。唯一的不同在于,并不是立即执行一段行为,而是生成整个字节码再执行。
使用场合
这是GPP一书中最复杂的模式,不能轻易的加入到游戏中。 当我们需要定义很多行为,而游戏实现语言因为以下原因不能很好地完成任务时,就可以使用字节码模式:
这些行为过于底层,繁琐易错。
这些行为遍历起来很缓慢,导致编译时间长。
这些行为太受依赖。如果想保证行为不会破坏游戏,你需要将其与代码的其他部分隔开。
但需要注意,字节码比本地代码慢,所以最好不要用于引擎对性能敏感的部分。
子类沙箱模式 Subclass Sandbox
介绍
https://gpp.tkchu.me/subclass-sandbox.html
用一系列由基类提供的操作定义子类中的行为。
子类沙箱模式:基类定义抽象的沙箱方法和几个提供操作的实现方法,将他们设为protected,表明它们只为子类所使用。每个推导出的沙箱子类用提供的操作实现了沙箱方法。
使用场合
有一个能推导很多子类的基类。
基类可以提供子类需要的所有操作。
在子类中有行为重复,你想要更容易的在它们间分享代码。
想要最小化子类和程序的其他部分的耦合。
类型对象模式 Type Object
介绍
https://gpp.tkchu.me/type-object.html
创造一个类A来允许灵活的创造新的类,而类A的每个实例都代表了不同类型的对象。
类型对象模式:定义类型对象类与有类型的对象类。每个类型对象实例代表一种不同的逻辑类型。每种有类型的对象保存描述它类型的对类型对象的引用。
类型对象的基本思想就是给基类一个品种类(breed类),而不是用一些子类继承自这个基类。所以我们在做种类区分的时候就可以只有两个类,怪物类monster和品种类breed,而不是monster,dragon,troll等一堆类。所以在此种情况下,游戏中的每个怪物都是怪物类的一个实例,而实例中的 breed 类包含了所有同种类型怪物共享的信息。
使用场合
不知道后续还需什么新类型。(举个例子,如果你的游戏需要支持增量更新,让用户下载后续新包含进来的怪物品种)
想要不改变代码或不重新编译就能修改或添加新类型。
组件模式 Component
介绍
https://gpp.tkchu.me/component.html
允许单一的实体跨越多个领域,无需这些领域彼此耦合。
组件模式:在单一实体跨越了多个领域时,为了保持领域之间相互解耦,可以将每部分代码放入各自的组件类中,将实体简化为组件的容器。
使用场合
有一个涉及了多个领域的类,而你想保持这些领域互相隔离。
一个类正在变大而且越来越难以使用。
想要能定义一系列分享不同能力的类,但是使用接口不足以得到足够的重用部分。
事件队列模式 Event Queue
介绍
https://gpp.tkchu.me/event-queue.html
事件队列模式,对消息或事件的发送与处理进行时间上的解耦。
事件队列:在先入先出的队列中存储一系列通知或请求。发送通知时,将请求放入队列并返回。处理请求的系统在稍晚些的时候从队列中获取请求并进行处理。 这样就解耦了发送者和接收者,既静态又及时。
事件队列很复杂,会对游戏架构引起广泛影响。中心事件队列是一个全局变量。这个模式的通常方法是一个大的交换站,游戏中的每个部分都能将消息送过这里。
事件队列是基础架构中很强大的存在,但有些时候强大并不代表好。事件队列模式将状态包裹在协议中,但是它还是全局的,仍然存在全局变量引发的一系列危险。
使用场合
如果你只是想解耦接收者和发送者,像观察者模式和命令模式都可以用较小的复杂度来进行处理。在需要解耦某些实时的内容时才建议使用事件队列。
不妨用推和拉来的情形来考虑。有一块代码A需要另一块代码B去做些事情。对A自然的处理方式是将请求推给B。同时,对B自然的处理方式是在B方便时将请求拉入。当一端有推模型另一端有拉模型时,你就需要在它们间放一个缓冲的区域。 这就是队列比简单的解耦模式多出来的那一部分。队列给了代码对拉取的控制权——接收者可以延迟处理,合并或者忽视请求。发送者能做的就是向队列发送请求然后就完事了,并不能决定什么时候发送的请求会受到处理。
而当发送者需要一些回复反馈时,队列模式就不是一个好的选择。
服务定位模式 Service Locator
介绍
https://gpp.tkchu.me/service-locator.html
提供服务的全局接入点,而不必让用户和实现它的具体类耦合。
服务定位模式:服务类定义了一堆操作的抽象接口。具体的服务提供者实现这个接口。 分离的服务定位器提供了通过查询合适的提供者, 获取服务的方法,同时隐藏了提供者的具体细节和需要定位它的进程。
一般通过使用单例或者静态类来实现服务定位模式,提供服务的全局接入点。
服务定位模式可以看做是更加灵活,更加可配置的单例模式。如果用得好,它能以很小的运行时开销,换取很大的灵活性。相反,如果用得不好,它会带来单例模式的所有缺点以及更多的运行时开销。
使用服务定位器的核心难点是它将依赖,也就是两块代码之间的一点耦合,推迟到运行时再连接。这有了更大的灵活度,但是代价是更难在阅读代码时理解其依赖的是什么。
使用场合
服务定位模式在很多方面是单例模式的亲兄弟,在应用前应该考虑看看哪个更适合你的需求。
让大量内容在程序的各处都能被访问时,就是在制造混乱。对何时使用服务定位模式的最简单的建议就是:尽量少用。
与其使用全局机制让某些代码直接接触到它,不妨先考虑将对象传过来。因为这样可以明显地保持解耦,而且可以满足我们大部分的需求。当然,有时候不方便手动传入对象,也可以使用单例的方式。
数据局部性模式 Data Locality
介绍
https://gpp.tkchu.me/data-locality.html
合理组织数据,充分使用CPU的缓存来加速内存读取。
现代的CPU有缓存来加速内存读取,其可以更快地读取最近访问过的内存毗邻的内存。基于这一点,我们通过保证处理的数据排列在连续内存上,以提高内存局部性,从而提高性能。
为了保证数据局部性,就要避免的缓存不命中。也许你需要牺牲一些宝贵的抽象。你越围绕数据局部性设计程序,就越放弃继承、接口和它们带来的好处。没有银弹,只有权衡。
使用场合
使用数据局部性的第一准则是在遇到性能问题时使用。不要将其应用在代码库不经常使用的角落上。 优化代码后其结果往往更加复杂,更加缺乏灵活性。
就本模式而言,还得确认你的性能问题确实由缓存不命中而引发的。如果代码是因为其他原因而缓慢,这个模式自然就不会有帮助。
简单的性能评估方法是手动添加指令,用计时器检查代码中两点间消耗的时间。而为了找到糟糕的缓存使用情况,知道缓存不命中有多少发生,又是在哪里发生的,则需要使用更加复杂的工具—— profilers。
组件模式是为缓存优化的最常见例子。而任何需要接触很多数据的关键代码,考虑数据局部性都是很重要的。
脏标识模式 Dirty Flag
介绍
https://gpp.tkchu.me/dirty-flag.html
将工作延期至需要其结果时才去执行,以避免不必要的工作。
脏标记,就是用来表示被标记的内容是否有被修改过的一个标志位。
脏标识模式:考虑情况,当前有一组原始数据随着时间变化而改变。由这些原始数据计算出目标数据需要耗费一定的计算量。这个时候,可以用一个脏标识,来追踪目前的原始数据是否与之前的原始数据保持一致,而此脏标识会在被标记的原始数据改变时改变。那么,若这个标记没被改变,就可以使用之前缓存的目标数据,不用再重复计算。反之,若此标记已经改变,则需用新的原始数据计算目标数据。
使用场合
就像其他优化模式一样,此模式会增加代码复杂度。只在有足够大的性能问题时,再考虑使用这一模式。
脏标记在这两种情况下适用:
当前任务有昂贵的计算开销
当前任务有昂贵的同步开销。
若原始数据的变化速度远高于目标数据的使用速度,此时数据会因为随后的修改而失效,此时就不适合使用脏标记模式。
对象池模式 Object Pool
介绍
https://gpp.tkchu.me/object-pool.html
放弃单独地分配和释放对象,从固定的池中重用对象,以提高性能和内存使用率。
对象池模式:定义一个包含了一组可重用对象的对象池。其中每个可重用对象都支持查询“使用中”状态,说明它是不是“正在使用”。 对象池被初始化时,就创建了整个对象集合(通常使用一次连续的分配),然后初始化所有对象到“不在使用中”状态。
当我们需要新对象时,就从对象池中获取。从对象池取到一个可用对象,初始化为“使用中”然后返回给我们。当不再需要某对象时,将其设置回“不在使用中”状态。 通过这种方式,便可以轻易地创建和销毁对象,而不必每次都分配内存或其他资源。
使用场合
这个模式广泛使用在可见事物上,比如游戏物体和特效。但是它也可在不那么视觉化的数据结构上使用,比如正在播放的声音。
需要频繁创建和销毁对象。
对象大小相仿。
在堆上分配对象缓慢或者会导致内存碎片。
每个对象都封装了像数据库或者网络连接这样很昂贵又可以重用的资源。
空间分区模式 Spatial Partition
介绍
https://gpp.tkchu.me/spatial-partition.html
将对象存储在基于位置组织的数据结构中,来有效的定位对象。
对于一系列对象,每个对象都有空间上的位置。将它们存储在根据位置组织对象的空间数据结构中,让我们有效查询在某处或者附近的对象。 当对象的位置改变时,更新空间数据结构,这样它可以继续找到对象。
最简单的空间分区:固定网格。想象某即时战略类游戏,一改在单独的数组中存储我们的游戏对象的常规思维,我们将它们存到网格的格子中。每个格子存储一组单位,它们的位置在格子的边界内部。当我们处理战斗时,一般只需考虑在同一格子或相邻格子中的单位,而不是将每个游戏中的单位与其他所有单位比较,这样就大大节约了计算量。
使用场合
空间分区模式在需要大量存储活跃、移动的游戏物体,和静态的美术模型的游戏中比较常用。因为复杂的游戏中不同的内容有不同的空间划分。
这个模式的基本适用场景是你有一系列有位置的对象,当做了大量通过位置寻找对象的查询而导致性能下降的时候。
空间分区的存在是为了将O(n)或者O(n²) 的操作降到更加可控的数量级。 你拥有的对象越多,此模式就越好用。相反的,如果n足够小,也许就不需要使用此模式。
了解了空间分区模式,下一步应该是学习一下常见的结构。常见的有:
每种空间划分数据结构基本上都是将一维数据结构扩展成更高维度的数据结构。而了解它的直系子孙,有助于分辨其对当前问题的解答是不是有帮助:
网格其实是持续的桶排序。
BSP,k-d tree,和层次包围盒是线性搜索树。
四叉树和八叉树是多叉树。
最后更新于