读《游戏编程模式》(上)

大家好,我是光源。

偶然在微信读书里瞥到这个书名不禁有一些好奇,尝试看了几页发现还蛮有趣的。

作为非游戏从业人员,对于游戏开发本身就有一些好奇,又是一本讲编程模式的书 —— 不会太钻技术细节,一些思想还可以触类旁通沿用到非游戏程序设计中。

书籍开篇是介绍了作者自身对于游戏开发的经历和思考,非常赞同作者的一个观点是讲设计模式的书有很多,讲游戏开发引擎设计、渲染等技术方向的书也不少,唯独讲怎么好好做(游戏的)程序设计的书特别少。

我的共鸣在于之前看过的设计模式相关书籍都执着于设计模式本身,致力于将设计模式的细节讲得形象具体,而相关的使用场景、例子只是一点附赠品 —— 这样的叙述方式没错,但写出来的书很容易变成《代码大全》之类的工具书,缺乏了一些联系实际的感染力。

本书的可贵之处在于不仅联系实际、用实际问题引入对应的设计模式将设计模式本身说得明明白白,同时作者也是一个非常资深的专家级别开发者,介绍了设计模式的概念后,会去探索和扩展使用了设计模式之后的程序设计问题。

比如我们熟悉的观察者模式,作者先从「解耦」的角度阐述我们为什么需要观察者模式,然后又从实际应用的角度提出「维护观察者列表时如何处理线程安全问题」和「大量的内容动态分配要如何规避」。

又比如单例模式,对于单例模式作者从章节开头就表明,我们学习单例模式最需要学习的是「如何避免滥用单例模式」—— 稍有经验的开发者应该理解这个才是正确的态度。

还有状态模式。虽然以前也有在实际开发中用有限自动机去处理复杂业务,但在本书中,作者会一步一步拆解有限自动机的构建和思考过程,同时提出用于解决「多状态场景」的并发状态机、解决「状态复用」的层次状态机和解决「状态回溯」的下推状态机 —— 对于未接触过游戏编程的程序员来说,这些经验还是非常宝贵的。

从实际问题出发引出设计模式的,又基于实际问题提出专业从业者如何优化和解决,这些经验可以直接复用到日常开发中,对于初级到资深开发都非常有启发和参考价值。下面是读后感和读书笔记。

架构、性能和游戏

什么是好的软件架构?作者认为好的软件架构首先要具备面对变更的灵活性,更具体点就是

只管写你的代码,架构会为你收拾一切。

这个观点倒是跟我最近要做的事不谋而合,我最近在设计一个 xx 管理框架,核心目标是任一使用到它的业务开发可以完全专注自己的业务,不需要管底层、或同层其他业务的任何干扰。

如何做到这种程度?有一个很重要的点就是「解耦」。当我们阅读代码时,我们会有一个将代码「载入」大脑的过程,模块间越解耦,代码阅读时就越专注于当前模块,你的负担也就越小。如果两个模块耦合,你就必须要同时了解这两块代码。

这听起来很棒?将一切解耦,每次变更就只涉及某一个方法或一个类,你就可以迅速编写代码 —— 这就是人们为模块化、抽象、设计模式兴奋的原因。但天下没有免费的午餐,解耦往往需要抽象,需要引入一定的复杂度,当过度解耦时你就会发现接口和抽象无处不在,你将花费大量的时间去找有实际功能的代码。理论上解耦意味着在你进行扩展时仅需理解少量代码,然而抽象却增加了理解代码的难度。因此,好的架构一直是在动态平衡中诞生的

再谈谈性能,性能优化一定是在某个假设下进行的。比如空间富余但时间不够就用「空间换时间」,比如设定敌人不会超过 256 个那就可以把 ID 打包成单字节,比如某个模块内只会在一个具体类型上调用方法那就可以用静态调度或内联,等等。这不意味着灵活性很差,我们需要的不是完美的代码,而是满足当下和未来近远期需求的代码,它可以让我们快速开发

下一个点是,编码风格讲求天时地利。在充满大量实验和探索的早期,编写一些你知道迟早要扔掉的代码是很稀松平常的。如果只是想验证一些想法是否能否工作,那么对其精心设计架构意味着在想法真正显示到屏幕并得到反馈前需要花费更多时间。原型是一个完全正确的编程实践 —— 要确保这种一次性代码能够被顺利丢掉。

开发中有三个因素需要考虑:

  1. 我们想获得一个良好的架构

  2. 我们希望获得快速运行时性能

  3. 我们希望快速完成今天的功能

这些目标至少部分是冲突的。好的架构从长远来看改进了生产力,但维护一个良好的架构就意味着每一个变化都需要更多的努力来保持代码的干净;最快编写的代码实现却很少是运行最快的,相反,优化需要消耗工程实践。

这里没有终极的答案,只有权衡。如果有一种方法来缓解限制,那就是简单性。尝试编写最干净、最直接的函数来解决问题。

再探设计模式

命令模式

将一个请求封装成一个对象,从而允许你使用不同的请求、队列或日志将客户端参数化,同时支持请求操作的撤销与恢复。

命令模式就是一个对象化的方法调用,命令就是面向对象化的回调。通过命令模式,我们可以将「直接执行方法」变成「处理命令」,相当于增加了间接调用层。

通过这种方式,我们就解除了函数直接调用的紧耦合,可以支持很多动态处理。比如游戏开发中可以支持用户自定义键位映射,原本 x 是跳跃的可以改成攻击;我们也可以动态调整命令的生成逻辑,支持各种 AI。

听起来有点像 callback?相对于 callback, 命令模式的优点在于支持请求排队、命令的撤销和恢复。

比如游戏中想支持动作的撤销,只要让命令支持 undo,即可实现部分或整体的回溯。想要支持多次撤销也不难,维护一个命令列表和一个对当前命令的引用即可。

一个典型的 command 类形如:

1
2
3
4
5
6
7
8
9
10
11
12
13
public MoveCommand extends Command {
public MoveCommand(int x, int y) {
……
}

public void execute() {
……
}
// 假如要支持撤销
public void unDo() {
……
}
}

享元模式

使用共享以高效地支持大量的细粒度对象。

开发过程中可能会碰到一种场景,需要使用大量的对象。比如渲染满屏幕的森林,这数以千计的树木每棵树木都包含着成千上万的多边形,想要将包含整片森林的对象数据在一帧内传给 GPU 是几乎不可能的。

通过分析我们发现,这些实例除了位置等少量参数外,大部分字段都是相同的,那我们是否可以复用这些相同的字段呢?我们将相同的大多数相同的字段指向同一个实例可以减少冗余的字段内存。(渲染过程中,OpenGL 也可以支持将共享数据只发送一次到 GPU。)

享元模式通过将对象数据分割成两种类型来解决问题,第一种是那些不属于单一实例对象并且能够被所有对象共享的数据,称为内部状态;其他数据就是外部状态。

享元模式不仅具有面向对象的优点,而且不会因数量巨大而产生开销。如果你发现自己正在创建一个枚举,并且做了大量的 switch,那么可以考虑用这个模式来代替。 举个例子,现在有一百万个地图块、一万个地形,那这一百万个地图块显然是复用这一万个地形 —— 这个享元模式和枚举都可以做到,但枚举每个实例都是单例生命周期跟进程一致,而享元模式则可以在需要的时候创建、在不需要的时候释放。

观察者模式

在对象间定义一种一对多的依赖关系,以便当某些对象的状态改变时,与它存在依赖关系的所有对象都能收到通知并自动进行更新。

现在我们要做一个成就系统,当玩家击杀多少怪物、得到多少金币…… 就会有相应的成就解锁。这部分代码该写在哪里呢?写在击杀逻辑里?写在金币统计系统里?还是写在碰撞检测逻辑里? 显然都不合适。此时就轮到观察者大显身手,它使得代码能够发出一个个消息,并通知对消息感兴趣的对象,而不用关心具体是谁接受了通知。

那么就成就系统而言,在击杀怪物、得到金币等发生时,相关的模块可以对外抛出事件,成就系统模块接收到这些后可以做相应的处理。

在观察者模式里,有「观察者」和「被观察者」两个角色,被观察者往往维护着一个观察者的集合,可以这样防止它们隐式得耦合在一起。

看起来很简单?对的,这个模式的结构非常简单,但想要在实际开发环境中使用,我们还得解决几个问题:

1)同步执行的问题

观察者模式是同步的,被观察者对象可以直接调用观察者们,这意味着任何一个观察者都有可能阻塞被观察者对象。同时当观察者列表达到一定规模时,循环遍历一次也变成一个耗时的操作。(PS: 在一个高度线程化的引擎中,最好使用事件队列来处理异步通信问题)

2)太多的内存分配

在具体项目中,观察者列表总是一个动态分配的集合,当添加或删除观察者的时候会动态扩展或收缩,这种内存的分配在对性能敏感的环境里会令人头疼。

我们可以使用链式结构来替代集合,在被观察者维护一个指向观察者头部的指针,每个观察者是链表的一个节点,内部指向下一个观察者。当要添加观察者时将新的观察者作为表头或表尾即可。

再进一步,我们可以建立链表节点池,即预先分配一个内存对象池来避免动态内存分配。

3)删除观察者的问题

尽管在 Java 等语言里存在 GC,不可显示 delete 对象,但正确处理观察者的引用仍然是重要的事。由于被观察者持有观察者的引用,会导致一些失效观察者仍留在内存中导致内存泄漏,我们需要及时删除观察者。

另外,需要注意「删除」操作和「遍历」操作的冲突,列表无法一边遍历一边被编辑。

原型模式

使用特定原型实例来创建特定种类的对象,并且通过拷贝原型来创建新的对象。

设想我们在开发一款游戏,我们可以通过怪物生成器来生成怪物,且每种怪物都对应不同的怪物生成器。

那么当我们生成 n 种怪物时就需要 n 个怪物类及 n 个怪物生成器类。这显然不是一种好的方案。

而原型模式提供了一个解决方案,核心思想是一个对象可以生成与自身相似的其他对象。如果你有一个幽灵,则可以制造其他幽灵。

父类声明一个 clone 方法,子类都会提供特定的实现,这样我们将子类实例传入作为模板,仅需一个父类生成器类即可:

1
2
Monster ghostProtoType = new Ghost(15, 3);
Spawner ghostSpawner = new Spawner(ghostProtoType); //传入具体的幽灵实例做为目标

通过这种方式生成的另一个好处是可以拷贝模板的具体状态,比如「行动迟缓的幽灵」、「敏捷的幽灵」可以拷贝过来。

单例模式

确保一个类只有一个实例,并为其提供一个全局访问入口。

单例本身已经不需要赘述,这篇主要讲的是如何避免滥用它。

在短期内单例是有益的,但一旦我们将一些不必要的单例进行了硬编码,便会带来一些麻烦。

1) 它是一个全局变量

全局变量会令代码晦涩难懂。假设我们正在跟踪其他人写的函数中的 bug,如果函数没有涉及到全局变量(称为纯函数)则我们只需要理解函数体本身就好,否则就得跟踪全局变量的整个流程。同时,全局变量也促进了耦合。比如,当一个新人想要播放一段音效的时候,恰巧有一个全局可见的 AudioPlayer,那么设计的架构很可能被破坏。然后,全局变量对于并发也不友好,一段全局共享的内存在多线程环境下可能会导致死锁、条件竞争等。

2)它容易成为一个画蛇添足的解决方案

比如,当我们把 Log 工具类设置成单例后,我们就没办法创建多个日志器了。而且当一个日志器单例被多个模块依赖后,你要修改里面的实现的话不得不通知所有人。

3)延迟初始化剥离了控制

当我们使用单例时可能正好是应用程序负荷最高的时候,此时初始化可能会导致卡顿和掉帧。

那要怎么避免滥用呢?

1)重新思考具体场景是否需要单例

项目中总是很容易出现 xxManager 类,但有时候这些单例管理类并不必要。比如子弹管理类会提供 isOnScreenmove 等方法,但熟悉 OOP 的同学应该了解,这些是可以被纳入子弹类里面的。

2)将类限制为单一实例

单例模式意味着全局访问,我们可以将普通的类的构造函数做一定限制(比如实例化第二次时就报错),保证它只能被实例化一次,这样它既是「单例」也无法被全局访问。

3)为实例提供便捷的访问方式

我们使用单例的主要原因是因为它足够便利,它让我随时随地可以获得所需的对象。我们可以考虑一下访问对象的其他途径。

  1. 传递进去。将这个对象传递给需要它的函数,有人称之为「依赖注入」,比如各种 context。
  2. 在基类获取它。比如在基类中提供一个 log() 函数。
  3. 通过其他全局对象访问它。既然我们做不到将所有全局变量都移除,那么可以尝试将全局对象类包装到现有的类里面来减少它们的数量。
  4. 另外也可以用服务器定位模式来访问。

状态模式

允许一个对象在其内部状态改变时改变自身的行为。对象看起来好像是在修改自身类。

记得小时候玩魂斗罗 4 代的时候有一个小 trick:当你跳跃起来的时候按一下暂停,然后再按下继续,紧接着按下第二次跳跃,这时候玩家就可以在第一跳的基础上再往上跳 —— 利用游戏漏洞实现了梯云纵。可见每种状态下能执行的操作是不一样的,假如这块逻辑错乱就容易有 bug 产生。

在游戏开发中玩家会有不同的状态,行走、跳跃、站立、蹲下,而每个状态又都有一些特殊的限制,比如跳跃的时候不能前后移动,那么常规的做法是用一些类似 isJumping 的变量来判断。这种处理方式随着复杂度上升布尔标志位会越来越多……

怎么优化呢?我们做一定的抽象后发现这里其实是有限的状态 + 一些行为,有经验的开发同学这时候应该想到计算机科学里的自动机理论了,这里用到的是 FSM 有限状态机。

有限自动机可以分为状态、输入和转换:

  1. 有一组状态,并且可以在状态之间进行切换
  2. 状态机同一时刻只能处于一种状态
  3. 状态机会接收一组输入或者事件
  4. 每一个状态有一组转换,每个转换都关联着一个输入并指向另一个状态

利用 FSM 我们将上面的场景比较好得实现出来,简单的处理方式是用枚举。一般的场景用枚举就够了,但碰到一些需要辅助变量的场景就需要将枚举变成面向对象的实现,也就是状态模式。

一个典型的状态形如:

1
2
3
4
5
abstract class State {
public abstract State handleInput(Owner onwer, Input input); //处理输入
public abstract void enter(Owner onwer);
public abstract void exit(Owner onwer);
}

为每个状态定一个类,主对象将状态委托给这些具体状态。

1
2
3
4
5
6
7
class Heroine {
private State mState;

public void handleInput(Input input) {
mState = mState.handleInput(this, input);
}
}

那如何给 mState 赋值呢?假如状态本身没有任何成员变量,那可以考虑复用状态实例,用静态变量或 enum 来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Heroine {
private static StandingState standing;
private static MovingState moving;
……
}

class StandingState extends State {
public State handleInput(Owner onwer, Input input) {
if (input == PRESS_B) {
onwer.state = owner.moving;
}
}
}

假如 state 里有成员变量,则需要动态实例化状态。

并发状态机、层次状态机与下推状态机

现在我们有了对人物状态的控制,再思考一下,假如把「武器」也考虑进去呢?武器有 m 种状态的话,假如塞进上面的 FSM 中则会扩展成 n m 个状态,这无疑极大增加了复杂度,也失去了状态模式的灵活性。这种多状态的问题,可以用*并发状态机 来解决:

1
2
3
4
5
6
7
8
class Heroine {
private State mHeroState;
private State mEquipmentState;
public void handleInput(Input input) {
mState = mHeroState.handleInput(this, input);
mEquipment = mEquipmentState.handleInput(this, input);
}
}

那么假如这两种状态相互影响呢?这种分离的并发模式显然无法解决问题,这时可以用层次状态机。一个状态有一个父状态,当有事件进来时如果子状态不处理它则沿着继承链传给它的父状态来处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class OnGroundState extends State { 
public State handleInput(Owner owner, Input input) {
// do something
}
} //先声明一个父状态
class DuckingState extends OnGroundState {
public State handleInput(Owner owner, Input input) {
if (input == PRESS_DOWN) {
// do something
} else {
return super.handleInput(owner, input);
}
}
} //每个子状态继承它

状态处理看起来比较完备了,但有一个问题,我们无法实现状态保存。比如当玩家开枪结束后要回到哪个状态呢?跳跃开枪是回到跳跃状态、行走中开枪是回到行走状态 —— 也就是需要回到上一个状态。这里我们使用一个状态栈来保存状态记录,也就是下推状态机的数据结构。

就日常开发来说,状态模式是一个比较有用的模式,比如播放器的状态、多个状态扭转的 view 等都可以用它来实现。