游戏程序常规设计模式

2020年02月09日 阅读数:169
这篇文章主要向大家介绍游戏程序常规设计模式,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

 

 

 

 

 

 

游戏程序常规设计模式javascript

 

 

 

https://gpp.tkchu.me/spatial-partition.htmlhtml

 

 

                   

 

 

 

 

 

 

 

 

 

 

 

O一 十二 月于上海浦东新区前端

 

第一章 序html5

 

游戏设计模式java

在五年级时,我和个人朋友被准许使用一间存放有几台很是破旧的TRS-80s的房间。 为了鼓舞咱们,一位老师给咱们找了一些简单的BASIC程序打印文档。git

电脑的磁带驱动器已经坏掉了,因此每当咱们想要运行代码,就得当心地从头开始输入它们。 所以,咱们更喜欢那些只有几行长的程序:程序员

10 PRINT "BOBBY IS RADICAL!!!"angularjs

20 GOTO 10github

若是电脑打印的次数足够多,也许这句话就会魔法成真。golang

哪怕这样,过程也充满了困难。咱们不知道如何编程,因此小小的语法错误对咱们来讲也是天险。 若是程序没有工做,咱们就得从头再来一遍——这常常发生。

文档的最后几页是个真正的怪物:一个占据了几页篇幅的程序。 咱们得花些时间才能鼓起勇气去试一试,但它实在太诱人——它的标题是地道与巨魔 咱们不知道它能作什么,但听起来像是个游戏,还有什么比本身编个电脑游戏更酷的吗?

咱们历来没能让它运行起来,一年之后,咱们离开了那间教室。 (好久之后,当我真的学会了点BASIC,我意识到那只是个桌面游戏角色生成器,而不是游戏。) 可是命运的车轮已经开始转动——自那时起,我就想要成为一名游戏程序员。

青少年时,我家有了一台能运行QuickBASICMacintosh,以后THINK C也能在其上运行。 几乎整个暑假我都在用它编游戏。 自学缓慢而痛苦。 我能轻松地编写并运行某些部分——地图或者小谜题——但随着程序代码量的增加,这愈来愈难。

暑假中的很多时间我都花在在路易斯安那州南部的沼泽里逮蛇和乌龟上了。 若是外面不是那么酷热,颇有可能这就会是一本讲爬虫而不是编程的书了。

起初,挑战之处仅仅在于让程序成功运行。而后,是搞明白怎样写出内容超出我大脑容量的代码。 我再也不只阅读关于如何用C++编程的书籍,而开始尝试找那些讲如何组织程序的书。

几年事后,一位朋友给我一本书:《设计模式:可复用面向对象软件的基础》。 终于!这正是我从青年时期就在寻找的书。 我一口气从头读到尾。虽然我仍然挣扎于本身的程序中,但看到别人也在挣扎并提出了解决方案是一种解脱。 我意识到手无寸铁的我终于有件像样的工具了。

那是我首次见到这位朋友,相互介绍五分钟后,我坐在他的沙发上,在接下来的几个小时中无视他并全神贯注地阅读。 我想自那之后个人社交技能仍是有所提升的。

2001年,我得到了梦想中的工做:EA的软件工程师。 我等不及要看看真正的游戏,还有专业人士是如何组织一切的。 像实况足球这样的大型游戏使用了什么样的架构?不一样的系统是如何交互的?一套代码库是如何在多个平台上运行的?

分析理解源代码是种震颤的体验。图形,AI,动画,视觉效果皆有杰出代码。 有专家知道如何榨干CPU的最后一个循环并好好使用。 那些我都不知道是否可行的事情,这些人在午餐前就能完成。

可是这些杰出代码依赖的架构一般是过后设计。 他们太注重功能而忽视了架构。耦合充斥在模块间。 新功能被塞到任何能塞进去的地方。 在梦想幻灭的我看来,这和其余程序员没什么不一样, 若是他们阅读过《设计模式》,最多也就用用单例

固然,没那么糟。我曾幻想游戏程序员坐在白板包围的象牙塔里,为架构冷静地讨论上几周。 而实际状况是,我看到的代码是努力应对紧张截止期限的人赶工完成的。 他们已经不遗余力,并且就像我慢慢意识到的那样,他们尽心尽力的结果一般很好。 我花在游戏代码上的时间越多,我越能发现藏在表面下的天才之处。

不幸的是,是广泛现象。 宝石埋在代码中,但人们从未意识到它们的存在。 我看到同事重复寻找解决方案,而须要的示例代码就埋在他们所用的代码库中。

这个问题正是这本书要解决的。 我挖出了游戏代码库中能找到的设计模式,打磨而后在这里展现它们,这样能够节约时间用在发明新事物上,而非从新发明它们。

书店里已有的书籍

书店里已经有不少游戏编程书籍了。为何要再写一本呢?

我看到的不少编程书籍能够归为这两类:

  • 特定领域的书籍。 这些关于细分领域的书籍带你深刻理解游戏开发的某一特定层面。 它们会教授你3D图形,实时渲染,物理模拟,人工智能,或者音频播放。 那些不少程序员穷其一辈子研究的细分领域。
  • 完整引擎的书籍。 另外一个方向,还有书籍试图包含游戏引擎的各个部分。 它们倾向于构建特定种类游戏的完整引擎,一般是3D FPS游戏。

这两种书我都喜欢,但我认为它们并未覆盖所有空间。 特定领域的书籍不多告诉你这些代码如何与游戏的其余部分打交道。 你擅长物理或者渲染,可是你知道怎么将二者优雅地组合吗?

第二类书包含这些,可是我发现完整引擎的书籍一般过于总体,过于专一某类游戏了。 特别是,随着手游和休闲游戏的兴起,咱们正处于众多游戏类型欣欣向荣的时刻。 咱们再也不只是复制Quake了。若是你的游戏与该类游戏不一样,那些介绍单一引擎的书就不那么有用了。

相反,我在这里作的更à la carte  每一章都是独立的、可应用到代码上的思路。 这样,你能够用认为最好的方式组合这些思路,用到你的游戏上去。

另外一个普遍使用这种à la carte风格的例子是Game Programming Gems系列。

和设计模式的关联

任何名字中有模式的编程书 都与Erich GammaRichard HelmRalph Johnson,和John Vlissides(一般被称为GoF)合著的经典书籍: 《设计模式:可复用面向对象软件要素》相关。

《设计模式》也受到以前的书籍的启发。 建立一种模式语言来描述问题的开放式解法, 这思路来自 A Pattern Language, 做者是Christopher Alexander (还有Sarah Ishikawa和Murray Silverstein).

他们的书是关于架构的(建筑和墙那样的真正的框架结构), 但他们但愿其余人能使用相同的方法描述其余领域的解决方案。 《设计模式》正是是GoF用这一方法在软件业作出的努力。

称这本书为游戏编程模式,我不是暗示GoF的模式不适用于游戏编程。 相反:本书的重返设计模式一节包含了《设计模式》中的不少模式, 但强调了这些模式在游戏编程中的特定使用。

一样地,我认为本书也适用于非游戏软件。 我能够依样画瓢称本书为《更多设计模式》,可是我认为举游戏编程为例子更为契合。 你真的想要另外一本介绍员工记录和银行帐户的书吗?

也就是说,虽然这里介绍的模式在其余软件上也颇有用,但它们更合适于处理游戏中常见的工程挑战:

  • 时间和顺序一般是游戏架构的核心部分。事物必须在正确的时间按正确的顺序发生。
  • 高度压缩的开发周期,大量程序员须要能快速构建和迭代一系列不一样的行为,同时保证不烦扰他人,也不污染代码库。
  • 在定义全部的行为后,游戏开始互动。怪物攻击英雄,药物相互混合,炸弹炸飞敌人或者友军。 实现这些互动不能把代码库搞成一团乱麻。
  • 最后,游戏中性能很重要。 游戏开发者处于一场榨干平台性能的竞赛中。 节约CPU循环的技巧区分了A级百万销量游戏和掉帧差评游戏。

如何阅读这本书

《游戏设计模式》分为三大块。 第一部分介绍并划分本书的框架。包含你如今阅读的这章和下一章

第二部分,重访设计模式,复习了GoF书籍里的不少模式。 在每一章中,我给出我对这个模式的见解,以及我认为它和游戏编程有什么关系。

最后一部分是这本书最肥美的部分。 它展现了十三种我发现有用的模式。它们被分为四类: 序列模式行为模式解耦模式,优化模式

每种模式都使用固定的格式表述,这样你能够将这本书当成引用,快速找到你须要的:

  • 意图 部分提供这个模式想要解决什么问题的简短介绍。 将它放在首位,这样你能够快速翻阅,找到你如今须要的模式。
  • 动机 部分描述了模式处理的问题示例。 不一样于具体的算法,模式一般不针对某个特定问题。 不用示例教授模式,就像不用面团教授烘烤。动机部分提供了面团,而下部分会教你烘烤。
  • 模式 部分将模式从示例中剥离出来。 若是你想要一段对模式的教科书式简短介绍,那就是这部分了。 若是你已经熟悉了这种模式,想要确保你没有拉下什么,这部分也是很好的提示。
  • 到目前为止,模式只是用一两个示例解释。可是如何知道模式对你的问题有没有用呢?什么时候使用 部分提供了这个模式在什么时候使用什么时候不用的指导。 记住 部分指出了使用模式的结果和风险。
  • 若是你像我同样须要具体的例子来真正地理解某物,那么示例代码部分能让你趁心如意。 它描述模式的一步步具体实现,来展示模式是如何工做的。
  • 模式与算法不一样的是它们是开放的。 每次你使用模式,能够用不一样的方式实现。 下一部分设计决策,讨论这些方式,告诉你应用模式时可供考虑的不一样选项。
  • 做为结尾,这里有参见部分展现了这一模式与其余模式的关联,以及那些使用它的真实代码。

关于示例代码

这本书的示例代码使用C++写就,但这并不意味着这些模式只在C++中有用,或C++比其余语言更适合使用这些模式。 这些模式适用于几乎每种编程语言,虽然有的模式假设编程语言有对象和类。

我选择C++有几个缘由。首先,这是在游戏制做中最流行的语言,是业界的通用语 一般,C++基于的C语法也是JavaC#JavaScript和其余不少语言的基础。 哪怕你不懂C++,你也只需一点点努力就能理解这里的示例代码。

这本书的目标不是教会你C++ 示例代码尽量地简单,不必定符合好的C++风格或规范。 示例代码展现的是意图,而不是代码。

特别地,代码没用现代的”——C++11或者更新的——标准。 没有使用标准库,不多使用模板。 它们是糟糕”C++代码,但我但愿保持这样,这样那些使用CObjective-CJava和其余语言的人更容易理解它们。

为了不花费时间在你已经看过或者是与模式无关的代码上,示例中省略了部分代码。 若是是那样,示例代码中的省略号代表这里隐藏了一些代码。

假设有个函数,作了些工做而后返回值。 而用它做示例的模式只关心返回的值,而不是完成了什么工做。那样的话,示例代码长得像这样:

bool update()

{

  // 作点工做……

  return isDone();

}

接下来呢

设计模式在软件开发过程当中不断地改变和扩展。 这本书继续了GoF记录分享设计模式的旅程,而这旅程也不会终于本书。

你是这段旅程的关键部分。改良(或者否决)了这本书中的模式,你就是为软件开发社区作贡献。 若是你有任何建议,更正,或者任何反馈,保持联络!

1.1架构,性能和游戏

游戏设计模式Introduction

在一头扎进一堆设计模式以前,我想先讲一些我对软件架构及如何将其应用到游戏之中的理解, 这也许能帮你更好地理解这本书的其他部分。 至少,在你被卷入一场关于设计模式和软件架构有多么糟糕(或多么优秀)的辩论时, 这能够给你一些火力支援。

注意我没有建议你在战斗中选哪一边。就像任何军火贩子同样,我愿意向做战双方出售武器。

什么是软件架构?

若是把本书从头至尾读一遍, 你不会学会3D图形背后的线性代数或者游戏物理背后的微积分。 本书不会告诉你如何用α-β修剪你的AI树,也不会告诉你如何在音频播放中模拟房间中的混响。

Wow,这段给这本书打了个糟糕的广告啊。

相反,这本书告诉你在这些之间的代码的事情。 与其说这本书是关于如何写代码,不如说是关于如何架构代码的。 每一个程序都有必定架构,哪怕这架构是将全部东西都塞到main()中看看如何 因此我认为讲讲什么形成了架构是颇有意思的。咱们如何区分好架构和坏架构呢?

我思考这个问题五年了。固然,像你同样,我有对好的设计有一种直觉。 咱们都被糟糕的代码折磨得不轻,你惟一能作的好事就是删掉它们,结束它们的痛苦。

不得不认可,咱们中大多数人都该对一些糟糕代码负责

少数幸运儿有相反的经验,有机会在好好设计的代码库上工做。 那种代码库看上去是间豪华酒店,里面的门房随时准备知足你心血来潮的需求。 这二者之间的区别是什么呢?

什么是好的软件架构?

对我而言,好的设计意味着当我做出改动,整个程序就好像正等着这种改动。 我能够仅调用几个函数就完成任务,而代码库自己无需改动。

这听起来很棒,但实际上不可行。把代码写成改动不会影响其表面上的和谐。就好。

让咱们通俗些。第一个关键点是架构是关于改动的 总会有人改动代码。若是没人碰代码,那么它的架构设计就可有可无——不管是由于代码至善至美,仍是由于代码糟糕透顶以致于没人会为了修改它而玷污本身的文本编辑器。 评价架构设计的好坏就是评价它应对改动有多么轻松。 没有了改动,架构好似永远不会离开起跑线的运动员。

你如何处理改动?

在你改动代码去添加新特性,去修复漏洞,或者随便用文本编辑器干点什么的时候, 你须要理解代码正在作什么。固然,你不须要理解整个程序, 但你须要将全部相关的东西装进你的大脑。

有点诡异,这字面上是一个OCR过程。

咱们一般无视了这步,但这每每是编程中最耗时的部分。 若是你认为将数据从磁盘上分页到RAM上很慢, 那么经过一对神经纤维将数据分页到大脑中无疑更慢。

一旦把全部正确的上下文都记到了你的大脑里, 想一会,你就能找到解决方案。 可能有时也须要反复斟酌,但一般比较简单。 一旦理解了问题和须要改动的代码,实际的编码工做有时是微不足道的。

用手指在键盘上敲打一阵,直到屏幕上闪着正确的光芒, 搞定了,对吧?还没呢! 在你为之写测试并发送到代码评审以前,一般有些清理工做要作。

我是否是说了“测试”?噢,是的。为有些游戏代码写单元测试很难,但代码库的大部分是彻底能够测试的。

我不会在这里发表演说,可是我建议你,若是尚未作自动测试,请考虑一下。 除了手动验证之外你就没更重要的事要作了吗?

你将一些代码加入了游戏,但确定不想下一我的被留下来的小问题绊倒。 除非改动很小,不然就还须要一些微调新代码的工做,使之无缝对接到程序的其余部分。 若是你作对了,那么下个编写代码的人没法察觉到哪些代码是新加入的。

简而言之,编程的流程图看起来是这样的:

使人震惊的死循环,我看到了。

解耦帮了什么忙?

虽然并不明显,但我认为不少软件架构都是关于研究代码的阶段。 将代码载入到神经元太过缓慢,找些策略减小载入的总量是件很值得作的事。 这本书有整整一章是关于解耦模式 还有不少设计模式是关于一样的主题。

能够用多种方式定义解耦,但我认为若是有两块代码是耦合的, 那就意味着没法只理解其中一个。 若是耦了它们俩,就能够单独地理解某一块。 这固然很好,由于只有一块与问题相关, 只需将这一块加载到你的大脑中而不须要加载另一块。

对我来讲,这是软件架构的关键目标: 最小化在编写代码前须要了解的信息

固然,也能够从后期阶段来看。 解耦的另外一种定义是:当一块代码有改动时,不须要修改另外一块代码。 确定也得修改一些东西,但耦合程度越小,改动会波及的范围就越小。

代价呢?

听起来很棒,对吧?解耦任何东西,而后就能够像风同样编码。 每一个改动都只需修改一两个特定方法,你能够在代码库上行云流水地编写代码。

这就是抽象、模块化、设计模式和软件架构令人们激动不已的缘由。 在架构优良的程序上工做是极佳的体验,每一个人都但愿能更有效率地工做。 好架构能形成生产力上巨大的不一样。它的影响大得无以复加。

可是,天下没有免费的午饭。好的设计须要汗水和纪律。 每次作出改动或是实现特性,你都须要将它优雅的集成到程序的其余部分。 须要花费大量的努力去管理代码, 使得程序在开发过程当中面对千百次变化仍能保持它的结构。

第二部分——管理代码——须要特别关注。 我看到无数程序有优雅的开始,而后死于程序员一遍又一遍添加的“微小黑魔法”。

就像园艺,仅仅种植是不够的,还须要除草和修剪。

你得考虑程序的哪部分须要解耦,而后再引入抽象。 一样,你须要决定哪部分能支持扩展来应对将来的改动。

人们对这点变得狂热。 他们设想,将来的开发者(或者他们本身)进入代码库, 发现它极为开放,功能强大,只需扩展。 他们想要有至尊代码应众求。(译著:这里是至尊魔戒御众戒的梗,很遗憾翻译不出来)

可是,事情从这里开始变得棘手。 每当你添加了抽象或者扩展支持,你就是在之后这里须要灵活性。 你向游戏中添加的代码和复杂性是须要时间来开发、调试和维护的。

若是你赌对了,后来使用了这些代码,那么功夫不负有心人。 但预测将来很难,模块化若是最终无益,那就有害。 毕竟,你得处理更多的代码。

有些人喜欢使用术语“YAGNI”——You aren’t gonna need it(你不须要那个)——来对抗这种预测未来需求的强烈冲动。

当你过度关注这点时,代码库就失控了。 接口和抽象无处不在。插件系统,抽象基类,虚方法,还有各类各样的扩展点,它们遍地都是。

你要消耗无尽的时间回溯全部的脚手架,去找真正作事的代码。 当须要做出改动时,固然,有可能某个接口能帮上忙,但能不能找到就只能听天由命了。 理论上,解耦意味着在修改代码以前须要了解更少的代码, 但抽象层自己也会填满大脑。

像这样的代码库会使得人们反对软件架构,特别是设计模式。 人们很容易沉浸在代码中,忽略了目标是要发布游戏 对可扩展性的过度强调使得无数的开发者花费多年时间制做引擎 却没有搞清楚作引擎是为了什么

性能和速度

软件架构和抽象有时因损伤性能而被批评,而游戏开发尤甚。 让代码更灵活的许多模式依靠虚拟调度、 接口、 指针、 消息和其余机制, 它们都会加大运行时开销。

一个有趣的反面例子是C++中的模板。模板编程有时能够带来没有运行时开销的抽象接口。

这是灵活性的两极。 当写代码调用类中的具体方法时,你就是在的时候指定类——硬编码了调用的是哪一个类。 当使用虚方法或接口时,直到运行时才知道调用的类。这更加灵活但增长了运行时开销。

模板编程是在两极之间。在编译时初始化模板,决定调用哪些类。

还有一个缘由。不少软件架构的目的是使程序更加灵活,做出改动须要更少的付出,编码时对程序有更少的假设。 使用接口能够让代码可与任何实现了接口的类交互,而不只仅是如今写的类。 今天,你能够使用观察者消息让游戏的两部分相互交流, 之后能够很容易地扩展为三个或四个部分相互交流。

但性能与假设相关。实现优化须要基于肯定的限制。 敌人永远不会超过256个?好,能够将敌人ID编码为一个字节。 只在这种类型上调用方法吗?好,能够作静态调度或内联。 全部实体都是同一类?太好了,能够使用 连续数组存储它们。

但这并不意味着灵活性很差!它能够让咱们快速改进游戏, 开发速度对创造更好的游戏体验来讲是很重要的。 没有人能在纸面上构建一个平衡的游戏,哪怕是Will Wright。这须要迭代和实验。

尝试想法并查看效果的速度越快,能尝试的东西就越多,也就越可能找到有价值的东西。 就算找到正确的机制,你也须要足够的时间调试。 一个微小的不平衡就有可能破坏整个游戏的乐趣。

这里没有普适的答案。 要么在损失一点点性能的前提下,让你的程序更加灵活以便更快地作出原型; 要么就优化性能,损失一些灵活性。

就我我的经验而言,让有趣的游戏变得高效比让高效的游戏变有趣简单得多。 一种折中的办法是保持代码灵活直到肯定设计,再去除抽象层来提升性能。

糟糕代码的优点

下一观点:不一样的代码风格各有千秋。 这本书的大部分是关于保持干净可控的代码,因此我坚持应该用正确方式写代码,但糟糕的代码也有必定的优点。

编写架构良好的代码须要仔细地思考,这会消耗时间。 在项目的整个周期中保持良好的架构须要花费大量的努力。 你须要像露营者处理营地同样当心处理代码库:老是让它比以前更好些。

当你要在项目上花费好久时间的时这是很好的。 但就像早先提到的,游戏设计须要不少实验和探索。 特别是在早期,写一些你知道将会扔掉的代码是很广泛的事情。

若是只想试试游戏的某些点子是否可行, 良好的架构就意味着在屏幕上看到和获取反馈以前要消耗很长时间。 若是最后证实这点子不对,那么删除代码时,那些让代码更优雅的工夫就付诸东流了。

原型——一坨勉强拼凑在一块儿,只能完成某个点子的简单代码——是个彻底合理的编程实践。 虽然当你写一次性代码时,必须 保证未来能够扔掉它。 我见过不少次糟糕的经理人在玩这种把戏:

老板:嗨,我有些想试试的点子。只要原型,不须要作得很好。你能多快搞定?

开发者:额,若是删掉这些部分,不测试,不写文档,容许不少的漏洞,那么几天能给你临时的代码文件。

老板:太好了。

几天后

老板:嘿,原型很棒,你能花上几个小时清理一下而后变为成品吗?

你得让人们清楚,可抛弃的代码即便看上去能工做,也不能被维护必须 重写。 若是有可能要维护这段代码,就得防护性地好好编写它。

一个小技巧能保证原型代码不会变成真正用的代码:使用和游戏实现不一样的编程语言。 这样,在将其实际应用于游戏中以前必须重写。

保持平衡

有些因素在相互角力:

1. 为了在项目的整个生命周期保持其可读性,须要好的架构。 2. 须要更好的运行时性能。 3. 须要让如今想要的特性更快地实现。

有趣的是,这些都是速度:长期开发的速度,游戏运行的速度,和短时间开发的速度。

这些目标至少是部分对立的。 好的架构长期来看提升了生产力, 也意味着每一个改动都须要消耗更多努力保持代码整洁。

草就的代码不多是运行时最快的。 相反,提高性能须要不少的开发时间。 一旦完成,它就会污染代码库:高度优化的代码不灵活,很难改动。

总有今日事今日毕的压力。可是若是尽量快地实现特性, 代码库就会充满黑魔法,漏洞和混乱,阻碍将来的产出。

没有简单的答案,只有权衡。 从我收到的邮件看,这伤了不少人的心,特别是那些只是想作个游戏的人。 这彷佛是在恐吓,没有正确的答案,只有不一样的错误。

但对我而言,这让人兴奋!看看任何人们从事的领域, 你总能发现某些相互抵触的限制。不管如何,若是有简单的答案,每一个人都会那么作。 一周就能掌握的领域是很无聊的。你历来没有据说过有人讨论挖坑。

也许你会讨论挖坑;我没有深究这个类比。 可能有挖坑热爱者,挖坑规范,以及一整套亚文化。 我算什么人,能在此大放厥词?

对我来讲,这和游戏有不少类似之处。 国际象棋之类的游戏永远不能被掌握,由于每一个棋子都很完美地与其余棋子相平衡。 这意味你能够花费一辈子探索广阔的可选策略。糟糕的游戏就像井字棋,玩上几遍就会厌倦地退出。

简单

最近,我感受若是有什么能简化这些限制,那就是简单 在我如今的代码中,我努力去写最简单,最直接的解决方案。 你读过这种代码后,彻底理解了它在作什么,想不到其余完成的方法。

个人目标是正确得到数据结构和算法(大体是这样的前后),而后再从那里开始。 我发现若是能让事物变得简单,最终的代码就更少, 就意味着改动时有更少的代码载入脑海。

它一般跑的很快,由于没什么开销,也没什么代码须要执行。 (虽然大部分时候事实并不是如此。你能够在一小段代码里加入大量的循环和递归。)

可是,注意我并无说简单的代码须要更少的时间编写 你会这么以为是由于最终获得了更少的代码,可是好的解决方案不是往代码中注水,而是蒸干代码。

Blaise Pascal有句著名的信件结尾,“我没时间写得更短。”

另外一句名言来自Antoine de Saint-Exupery:“臻于完美之时,不是加无可加,而是减无可减。”

言归正传,我发现每次重写本书,它就变得更短。有些章节比刚完成时短了20%。

咱们不多遇到优雅表达的问题,通常反而是一堆用况。 你想要XZ状况下作Y,在A状况下作W,诸如此类。换言之,一长列不一样行为。

最节约心血的方法是为每段用况编写一段代码。 看看新手程序员,他们常常这么干:为每种状况编写条件逻辑。

但这一点也不优雅,那种风格的代码遇到一点点没想到的输入就会崩溃。 当咱们想象优雅的代码时,想的是通用的那一个: 只须要不多的逻辑就能够覆盖整个用况。

找到这样的方法有点像模式识别或者解决谜题。 须要努力去识别散乱的用例下隐藏的规律。 完成时你会感受好得不能再好。

就快完了

几乎每一个人都会跳过介绍章节,因此祝贺你看到这里。 我没有太多东西回报你的耐心,但还有些建议给你,但愿对你有用:

  • 抽象和解耦让扩展代码更快更容易,但除非确信须要灵活性,不然不要在这上面浪费时间。
  • 在整个开发周期中为性能考虑并作好设计,可是尽量推迟那些底层的,基于假设的优化,那会锁死代码。

相信我,发布前两个月不是开始思考“游戏运行只有1FPS”这种问题的时候。

  • 快速地探索游戏的设计空间,但不要跑得太快,在身后留下烂摊子。毕竟你总得回来打扫。
  • 若是打算抛弃这段代码,就不要尝试将其写完美。摇滚明星将旅店房间弄得一团糟,由于他们知道明天就走人了。
  • 但最重要的是,若是你想要作出让人享受的东西,那就享受作它的过程。

 

第二章 重访设计模式

游戏设计模式

《设计模式:可复用面向对象软件的基础》出版已经二十年了。 除非你比我从业还久,不然《设计模式》已经酝酿成一坛足以饮用的老酒了。 对于像软件行业这样快速发展的行业,它已是老古董了。 这本书的持久流行证实了设计方法比框架和方法论更经久不衰。

虽然我认为设计模式仍然有意义,但在过去几十年咱们学到了不少。 在这一部分,咱们会遇到GoF记载的一些模式。 对于每一个模式,我但愿能讲些有用有趣的东西。

我认为有些模式被过分使用了(单例模式), 而另外一些被冷落了(命令模式)。 有些模式在这里是由于我想探索其在游戏上的特殊应用(享元模式观察者模式)。 最后,我认为看看有些模式在更广的编程领域是如何运用的是颇有趣的(原型模式状态模式)。

模式

2.1命令模式

游戏设计模式Design Patterns Revisited

命令模式是我最喜欢的模式之一。 大多数我写的游戏或者别的什么之类的大型程序,都会在某处用到它。 当在正确的地方使用时,它能够将复杂的代码清理干净。 对于这样一个了不得的模式,不出所料地,GoF有个深奥的定义:

将一个请求封装为一个对象,从而使你可用不一样的请求对客户进行参数化; 对请求排队或记录请求日志,以及支持可撤销的操做。

我想你也会以为这个句子晦涩难懂。 第一,它的比喻难以理解。 在词语能够指代任何事物的狂野软件世界以外,客户是一个——那些和你作生意的人。 据我查证,人类不能被参数化

而后,句子余下的部分介绍了可能会使用这个模式的场景。 若是你的场景不在这个列表中,那么这对你就没什么用处。 个人命令模式精简定义为:

命令是具现化的方法调用

“Reify(具现化)”来自于拉丁语“res”,意为“thing”(事物),加上英语后缀“–fy”。 因此它意为“thingify”,没准用“thingify”更合适。

固然,精简每每意味着着缺乏必要信息,因此这可能没有太大的改善。 让我扩展一下。若是你没有据说过具现化的话,它的意思是实例化,对象化 具现化的另一种解释方式是将某事物做为第一公民对待。

在某些语言中的反射容许你在程序运行时命令式地和类型交互。 你能够得到类的类型对象,能够与其交互看看这个类型能作什么。换言之,反射是具现化类型的系统

两种术语都意味着将概念变成数据 ——一个对象——能够存储在变量中,传给函数。 因此称命令模式为具现化方法调用,意思是方法调用被存储在对象中。

这听起来有些像回调第一公民函数函数指针闭包偏函数 取决于你在学哪一种语言,事实上大体上是同一个东西。GoF随后说:

命令模式是一种回调的面向对象实现。

这是一种对命令模式更好的解释。

但这些都既抽象又模糊。我喜欢用实际的东西做为章节的开始,很差意思,搞砸了。 做为弥补,从这里开始都是命令模式能出色应用的例子。

配置输入

在每一个游戏中都有一块代码读取用户的输入——按钮按下,键盘敲击,鼠标点击,诸如此类。 这块代码会获取用户的输入,而后将其变为游戏中有意义的行为:

下面是一种简单的实现:

void InputHandler::handleInput()
{
  if (isPressed(BUTTON_X)) jump();
  else if (isPressed(BUTTON_Y)) fireGun();
  else if (isPressed(BUTTON_A)) swapWeapon();
  else if (isPressed(BUTTON_B)) lurchIneffectively();
}

专家建议:不要太常常地按B。

这个函数一般在游戏循环中每帧调用一次,我确信你能够理解它作了什么。 在咱们想将用户的输入和程序行为硬编码在一块儿时,这段代码能够正常工做,可是许多游戏容许玩家配置按键的功能。

为了支持这点,须要将这些对jump()fireGun()的直接调用转化为能够变换的东西。变换听起来有点像变量干的事,所以咱们须要表示游戏行为的对象。进入:命令模式。

咱们定义了一个基类表明可触发的游戏行为:

class Command
{
public:
  virtual ~Command() {}
  virtual void execute() = 0;
};

当你有接口只包含一个没有返回值的方法时,极可能你能够使用命令模式。

而后咱们为不一样的游戏行为定义相应的子类:

class JumpCommand : public Command
{
public:
  virtual void execute() { jump(); }
};
 
class FireCommand : public Command
{
public:
  virtual void execute() { fireGun(); }
};
 
// 你知道思路了吧

在代码的输入处理部分,为每一个按键存储一个指向命令的指针。

class InputHandler
{
public:
  void handleInput();
 
  // 绑定命令的方法……
 
private:
  Command* buttonX_;
  Command* buttonY_;
  Command* buttonA_;
  Command* buttonB_;
};

如今输入处理部分这样处理:

void InputHandler::handleInput()
{
  if (isPressed(BUTTON_X)) buttonX_->execute();
  else if (isPressed(BUTTON_Y)) buttonY_->execute();
  else if (isPressed(BUTTON_A)) buttonA_->execute();
  else if (isPressed(BUTTON_B)) buttonB_->execute();
}

注意在这里没有检测NULL了吗?这假设每一个按键都与某些命令相连。

若是想支持不作任何事情的按键又不想显式检测NULL,咱们能够定义一个命令类,它的execute()什么也不作。 这样,某些按键处理器没必要设为NULL,只需指向这个类。这种模式被称为空对象

之前每一个输入直接调用函数,如今会有一层间接寻址:

这是命令模式的简短介绍。若是你可以看出它的好处,就把这章剩下的部分做为奖励吧。

角色说明

咱们刚才定义的类能够在以前的例子上正常工做,但有很大的局限。 问题在于假设了顶层的jump()fireGun()之类的函数能够找到玩家角色,而后像木偶同样操纵它。

这些假定的耦合限制了这些命令的用处。JumpCommand只能 让玩家的角色跳跃。让咱们放松这个限制。 不让函数去找它们控制的角色,咱们将函数控制的角色对象传进去

class Command
{
public:
  virtual ~Command() {}
  virtual void execute(GameActor& actor) = 0;
};

这里的GameActor是表明游戏世界中角色的游戏对象类。 咱们将其传给execute(),这样命令类的子类就能够调用所选游戏对象上的方法,就像这样:

class JumpCommand : public Command
{
public:
  virtual void execute(GameActor& actor)
  {
    actor.jump();
  }
};

如今,咱们能够使用这个类让游戏中的任何角色跳来跳去了。 在输入控制部分和在对象上调用命令部分之间,咱们还缺了一块代码。 第一,咱们修改handleInput(),让它能够返回命令:

Command* InputHandler::handleInput()
{
  if (isPressed(BUTTON_X)) return buttonX_;
  if (isPressed(BUTTON_Y)) return buttonY_;
  if (isPressed(BUTTON_A)) return buttonA_;
  if (isPressed(BUTTON_B)) return buttonB_;
 
  // 没有按下任何按键,就什么也不作
  return NULL;
}

这里不能当即执行,由于还不知道哪一个角色会传进来。 这里咱们享受了命令是具体调用的好处——延迟到调用执行时再知道。

而后,须要一些接受命令的代码,做用在玩家角色上。像这样:

Command* command = inputHandler.handleInput();
if (command)
{
  command->execute(actor);
}

actor视为玩家角色的引用,它会正确地按着玩家的输入移动, 因此咱们赋予了角色和前面例子中相同的行为。 经过在命令和角色间增长了一层重定向, 咱们得到了一个灵巧的功能:咱们能够让玩家控制游戏中的任何角色,只需向命令传入不一样的角色。

在实践中,这个特性并不常用,可是常常会有相似的用例跳出来。 到目前为止,咱们只考虑了玩家控制的角色,可是游戏中的其余角色呢? 它们被游戏AI控制。咱们能够在AI和角色之间使用相同的命令模式;AI代码只需生成Command对象。

在选择命令的AI和展示命令的游戏角色间解耦给了咱们很大的灵活度。 咱们能够对不一样的角色使用不一样的AI,或者为了避免同的行为而混合AI 想要一个更加有攻击性的对手?插入一个更加有攻击性的AI为其生成命令。 事实上,咱们甚至能够为玩家角色加上AI 在展现阶段,游戏须要自动演示时,这是颇有用的。

把控制角色的命令变为第一公民对象,去除直接方法调用中严厉的束缚。 将其视为命令队列,或者是命令流:

队列能为你作的更多事情,请看事件队列

为何我以为须要为你画一幅“流”的图像?又是为何它看上去像是管道?

一些代码(输入控制器或者AI)产生一系列命令放入流中。 另外一些代码(调度器或者角色自身)调用并消耗命令。 经过在中间加入队列,咱们解耦了消费者和生产者。

若是将这些指令序列化,咱们能够经过网络流传输它们。 咱们能够接受玩家的输入,将其经过网络发送到另一台机器上,而后重现之。这是网络多人游戏的基础。

撤销和重作

最后的这个例子是这种模式最广为人知的使用状况。 若是一个命令对象能够一件事,那么它亦能够撤销这件事。 在一些策略游戏中使用撤销,这样你就能够回滚那些你不喜欢的操做。 它是创造游戏时必不可少的工具。 一个不能撤销误操做致使的错误的编辑器,确定会让游戏设计师恨你。

这是经验之谈。

没有了命令模式,实现撤销很是困难,有了它,就是小菜一碟。 假设咱们在制做单人回合制游戏,想让玩家能撤销移动,这样他们就能够集中注意力在策略上而不是猜想上。

咱们已经使用了命令来抽象输入控制,因此每一个玩家的举动都已经被封装其中。 举个例子,移动一个单位的代码可能以下:

class MoveUnitCommand : public Command
{
public:
  MoveUnitCommand(Unit* unit, int x, int y)
  : unit_(unit),
    x_(x),
    y_(y)
  {}
 
  virtual void execute()
  {
    unit_->moveTo(x_, y_);
  }
 
private:
  Unit* unit_;
  int x_, y_;
};

注意这和前面的命令有些许不一样。 在前面的例子中,咱们须要从修改的角色那里抽象命令。 在这个例子中,咱们将命令绑定到要移动的单位上。 这条命令的实例不是通用的移动某物命令;而是游戏回合中特殊的一次移动。

这展示了命令模式应用时的一种情形。 就像以前的例子,指令在某些情形中是可重用的对象,表明了可执行的事件 咱们早期的输入控制器将其实现为一个命令对象,而后在按键按下时调用其execute()方法。

这里的命令更加特殊。它们表明了特定时间点能作的特定事件。 这意味着输入控制代码能够在玩家下决定时创造一个实例。就像这样:

Command* handleInput()
{
  Unit* unit = getSelectedUnit();
 
  if (isPressed(BUTTON_UP)) {
    // 向上移动单位
    int destY = unit->y() - 1;
    return new MoveUnitCommand(unit, unit->x(), destY);
  }
 
  if (isPressed(BUTTON_DOWN)) {
    // 向下移动单位
    int destY = unit->y() + 1;
    return new MoveUnitCommand(unit, unit->x(), destY);
  }
 
  // 其余的移动……
 
  return NULL;
}

固然,在像C++这样没有垃圾回收的语言中,这意味着执行命令的代码也要负责释放内存。

命令的一次性为咱们很快地赢得了一个优势。 为了让指令可被取消,咱们为每一个类定义另外一个须要实现的方法:

class Command
{
public:
  virtual ~Command() {}
  virtual void execute() = 0;
  virtual void undo() = 0;
};

undo()方法回滚了execute()方法形成的游戏状态改变。 这里是添加了撤销功能后的移动命令:

class MoveUnitCommand : public Command
{
public:
  MoveUnitCommand(Unit* unit, int x, int y)
  : unit_(unit),
    xBefore_(0),
    yBefore_(0),
    x_(x),
    y_(y)
  {}
 
  virtual void execute()
  {
    // 保存移动以前的位置
    // 这样以后能够复原。
 
    xBefore_ = unit_->x();
    yBefore_ = unit_->y();
 
    unit_->moveTo(x_, y_);
  }
 
  virtual void undo()
  {
    unit_->moveTo(xBefore_, yBefore_);
  }
 
private:
  Unit* unit_;
  int xBefore_, yBefore_;
  int x_, y_;
};

注意咱们为类添加了更多的状态。 当单位移动时,它忘记了它以前是什么样的。 若是咱们想要撤销这个移动,咱们须要记得单位以前的状态,也就是xBefore_yBefore_的做用。

这看上去是备忘录模式使用的地方,它历来没有有效地工做过。 因为命令趋向于修改对象状态的一小部分,对数据其余部分的快照就是浪费内存。手动内存管理的消耗更小。

持久化数据结构是另外一个选项。 使用它,每次修改对象都返回一个新对象,保持原来的对象不变。巧妙的实现下,这些新对象与以前的对象共享数据,因此比克隆整个对象开销更小。

使用持久化数据结构,每条命令都存储了命令执行以前对象的引用,而撤销只是切换回以前的对象。

为了让玩家撤销移动,咱们记录了执行的最后命令。当他们按下control+z时,咱们调用命令的undo()方法。 (若是他们已经撤销了,那么就变成了重作,咱们会再一次执行命令。)

支持多重的撤销也不太难。 咱们不仅仅记录最后一条指令,还要记录指令列表,而后用一个引用指向当前的那个。 当玩家执行一条命令,咱们将其添加到列表,而后将表明当前的指针指向它。

当玩家选择撤销,咱们撤销如今的命令,将表明当前的指针日后退。 当他们选择重作,咱们将表明当前的指针往前进,执行该指令。 若是在撤销后选择了新命令,那么清除命令列表中当前的指针所指命令以后的所有命令。

第一次在关卡编辑器中实现这点时,我以为本身简直就是个天才。 我惊讶于它如此的简明有效。 你须要约束本身,保证每一个数据修改都经过命令完成,一旦你作到了,余下的都很简单。

重作在游戏中并不常见,但重常见。 一种简单的重放实现是记录游戏每帧的状态,这样它能够回放,但那会消耗太多的内存。

相反,不少游戏记录每一个实体每帧运行的命令。 为了重放游戏,引擎只须要正常运行游戏,执行以前存储的命令。

用类仍是用函数?

早些时候,我说过命令与第一公民函数或者闭包相似, 可是在这里展示的每一个例子都是经过类完成的。 若是你更熟悉函数式编程,你也许会疑惑函数都在哪里。

我用这种方式写例子是由于C++对第一公民函数支持很是有限。 函数指针没有状态,函子很奇怪并且仍然须要定义类, C++11中的lambda演算须要大量的人工记忆辅助才能使用。

这并不是说你在其余语言中不能够用函数来完成命令模式。 若是你使用的语言支持闭包,无论怎样,快去用它! 在某种程度上说,命令模式是为一些没有闭包的语言模拟闭包。

(我说某种程度上是由于,即便是那些支持闭包的语言, 为命令创建真正的类或者结构也是颇有用的。 若是你的命令拥有多重操做(好比可撤销的命令), 将其所有映射到同一函数中并不优雅。)

定义一个有字段的真实类能帮助读者理解命令包含了什么数据。 闭包是自动包装状态的完美解决方案,但它们过于自动化而很难看清包装的真正状态有哪些。

举个例子,若是咱们使用javascript来写游戏,那么咱们能够用这种方式来写让单位移动的命令:

function makeMoveUnitCommand(unit, x, y) {
  // 这个函数就是命令对象:
  return function() {
    unit.moveTo(x, y);
  }
}

咱们能够经过一对闭包来为撤销提供支持:

function makeMoveUnitCommand(unit, x, y) {
  var xBefore, yBefore;
  return {
    execute: function() {
      xBefore = unit.x();
      yBefore = unit.y();
      unit.moveTo(x, y);
    },
    undo: function() {
      unit.moveTo(xBefore, yBefore);
    }
  };
}

若是你习惯了函数式编程风格,这种作法是很天然的。 若是你没有,我但愿这章能够帮你了解一些。 对于我而言,命令模式展示了函数式范式在不少问题上的高效性。

参见

  • 你最终可能会获得不少不一样的命令类。 为了更容易实现这些类,定义一个具体的基类,包含一些能定义行为的高层方法,每每会有帮助。 这将命令的主体execute()转到子类沙箱中。
  • 在上面的例子中,咱们明确地指定哪一个角色会处理命令。 在某些状况下,特别是当对象模型分层时,也能够不这么简单粗暴。 对象能够响应命令,或者将命令交给它的从属对象。 若是你这样作,你就完成了一个职责链模式
  • 有些命令是无状态的纯粹行为,好比第一个例子中的JumpCommand 在这种状况下,有多个实例是在浪费内存,由于全部的实例是等价的。 能够用享元模式解决。

2.2享元模式

游戏设计模式Design Patterns Revisited

迷雾散尽,露出了古朴庄严的森林。古老的铁杉,在头顶编成绿色穹顶。 阳光在树叶间破碎成金色顶棚。从树干间远眺,远处的森林渐渐隐去。

这是咱们游戏开发者梦想的超凡场景,这样的场景一般由一个模式支撑着,它的名字低调至极:享元模式。

森林

用几句话就能描述一片巨大的森林,可是在实时游戏中作这件事就彻底是另一件事了。 当屏幕上须要显示一整个森林时,图形程序员看到的是每秒须要送到GPU六十次的百万多边形。

咱们讨论的是成千上万的树,每棵都由上千的多边形组成。 就算有足够的内存描述森林,渲染的过程当中,CPUGPU的部分也太过繁忙了。

每棵树都有一系列与之相关的位:

  • 定义树干,树枝和树叶形状的多边形网格。
  • 树皮和树叶的纹理。
  • 在森林中树的位置和朝向。
  • 大小和色彩之类的调节参数,让每棵树都看起来不同凡响。

若是用代码表示,那么会获得这样的东西:

class Tree
{
private:
  Mesh mesh_;
  Texture bark_;
  Texture leaves_;
  Vector position_;
  double height_;
  double thickness_;
  Color barkTint_;
  Color leafTint_;
};

这是一大堆数据,多边形网格和纹理体积很是大。 描述整个森林的对象在一帧的时间就交给GPU实在是太过了。 幸运的是,有一种老办法来处理它。

关键点在于,哪怕森林里有千千万万的树,它们大多数长得如出一辙。 它们使用了相同的网格和纹理。 这意味着这些树的实例的大部分字段是同样的

你要么是疯了,要么是亿万富翁,才能让美术给森林里每棵树创建独立模型。

注意每一棵树的小盒子中的东西都是同样的。

咱们能够经过显式地将对象切为两部分来更加明确地模拟。 第一,将树共有的数据拿出来分离到另外一个类中:

class TreeModel
{
private:
  Mesh mesh_;
  Texture bark_;
  Texture leaves_;
};

游戏只须要一个这种类, 由于没有必要在内存中把相同的网格和纹理重复一千遍。 游戏世界中每一个树的实例只需有一个对这个共享TreeModel引用 留在Tree中的是那些实例相关的数据:

class Tree
{
private:
  TreeModel* model_;
 
  Vector position_;
  double height_;
  double thickness_;
  Color barkTint_;
  Color leafTint_;
};

你能够将其想象成这样:

这有点像类型对象模式。 二者都涉及将一个类中的状态委托给另外的类,来达到在不一样实例间分享状态的目的。 可是,这两种模式背后的意图不一样。

使用类型对象,目标是经过将类型引入对象模型,减小须要定义的类。 伴随而来的内容分享是额外的好处。享元模式则是纯粹的为了效率。

把全部的东西都存在主存里没什么问题,可是这对渲染也毫无帮助。 在森林到屏幕上以前,它得先到GPU。咱们须要用显卡能够识别的方式共享数据。

一千个实例

为了减小须要推送到GPU的数据量,咱们想把共享的数据——TreeModel——只发送一次 而后,咱们分别发送每一个树独特的数据——位置,颜色,大小。 最后,咱们告诉GPU使用同一模型渲染每一个实例

幸运的是,今日的图形接口和显卡正好支持这一点。 这些细节很繁琐且超出了这部书的范围,可是Direct3DOpenGL均可以作实例渲染

在这些API中,你须要提供两部分数据流。 第一部分是一块须要渲染屡次的共同数据——在例子中是树的网格和纹理。 第二部分是实例的列表以及绘制第一部分时须要使用的参数。 而后调用一次渲染,绘制整个森林。

这个API是由显卡直接实现的,意味着享元模式也许是惟一的有硬件支持的GoF设计模式。

享元模式

好了,咱们已经看了一个具体的例子,下面我介绍模式的通用部分。 享元,就像它的名字暗示的那样, 当你须要共享类时使用,一般是由于你有太多这种类了。

实例渲染时,每棵树经过总线送到GPU消耗的更可能是时间而非内存,可是基本要点是同样的。

这个模式经过将对象的数据分为两种来解决这个问题。 第一种数据没有特定指明是哪一个对象的实例,所以能够在它们间分享。 Gof称之为固有状态,可是我更喜欢将其视为上下文无关部分。 在这里的例子中,是树的网格和纹理。

数据的剩余部分是变化状态,那些每一个实例独一无二的东西。 在这个例子中,是每棵树的位置,拉伸和颜色。 就像这里的示例代码块同样,这种模式经过在每一个对象出现时共享一份固有状态来节约内存。

就目前而言,这看上去像是基础的资源共享,很难被称为一种模式。 部分缘由是在这个例子中,咱们能够为共享状态划出一个清晰的身份TreeModel

我发现,当共享对象没有有效定义的实体时,使用这种模式就不那么明显(使用它也就愈加显得精明)。 在那些状况下,这看上去是一个对象被魔术般地同时分配到了多个地方。 让我展现给你另一个例子。

扎根之所

这些树长出来的地方也须要在游戏中表示。 这里可能有草,泥土,丘陵,湖泊,河流,以及其它任何你能够想到的地形。 咱们基于区块创建地表:世界的表面被划分为由微小区块组成的巨大网格。 每一个区块都由一种地形覆盖。

每种地形类型都有一系列特性会影响游戏玩法:

  • 决定了玩家可以多快地穿过它的移动开销。
  • 代表可否用船穿过的水域标识。
  • 用来渲染它的纹理。

由于咱们游戏程序员偏执于效率,咱们不会在每一个区块中保存这些状态。 相反,一个通用的方式是为每种地形使用一个枚举。

再怎么样,咱们也已经从树的例子吸收教训了。

enum Terrain
{
  TERRAIN_GRASS,
  TERRAIN_HILL,
  TERRAIN_RIVER
  // 其余地形
};

而后,世界管理巨大的网格:

class World
{
private:
  Terrain tiles_[WIDTH][HEIGHT];
};

这里我使用嵌套数组存储2D网格。 在C/C++中这样是颇有效率的,由于它会将全部元素打包在一块儿。 在Java或者其余内存管理语言中,那样作会实际给你一个数组,其中每一个元素都是对数组的列的引用,那就不像你想要的那样内存友好了。

反正,隐藏2D网格数据结构背后的实现细节,能使代码更好地工做。 我这里这样作只是为了让其保持简单。

为了得到区块的实际有用的数据,咱们作了一些这样的事情:

int World::getMovementCost(int x, int y)
{
  switch (tiles_[x][y])
  {
    case TERRAIN_GRASS: return 1;
    case TERRAIN_HILL:  return 3;
    case TERRAIN_RIVER: return 2;
      // 其余地形……
  }
}
 
bool World::isWater(int x, int y)
{
  switch (tiles_[x][y])
  {
    case TERRAIN_GRASS: return false;
    case TERRAIN_HILL:  return false;
    case TERRAIN_RIVER: return true;
      // 其余地形……
  }
}

你知道个人意思了。这可行,可是我以为很丑。 移动开销和水域标识是区块的数据,但在这里它们散布在代码中。 更糟的是,简单地形的数据被众多方法拆开了。 若是可以将这些包裹起来就行了。毕竟,那是咱们设计对象的目的。

若是咱们有实际的地形就行了,像这样:

class Terrain
{
public:
  Terrain(int movementCost,
          bool isWater,
          Texture texture)
  : movementCost_(movementCost),
    isWater_(isWater),
    texture_(texture)
  {}
 
  int getMovementCost() const { return movementCost_; }
  bool isWater() const { return isWater_; }
  const Texture& getTexture() const { return texture_; }
 
private:
  int movementCost_;
  bool isWater_;
  Texture texture_;
};

你会注意这里全部的方法都是const。这不是巧合。 因为同一对象在多处引用,若是你修改了它, 改变会同时在多个地方出现。

这也许不是你想要的。 经过分享对象来节约内存的这种优化,不该该影响到应用的显性行为。 所以,享元对象几乎老是不可变的。

可是咱们不想为每一个区块都保存一个实例。 若是你看看这个类内部,你会发现里面实际上什么也没有 惟一特别的是区块在哪里 用享元的术语讲,区块的全部状态都是固有的或者说上下文无关的

鉴于此,咱们没有必要保存多个同种地形类型。 地面上的草区块两两无异。 咱们不用地形区块对象枚举构成世界网格,而是用Terrain对象指针组成网格:

class World
{
private:
  Terrain* tiles_[WIDTH][HEIGHT];
 
  // 其余代码……
};

每一个相同地形的区块会指向相同的地形实例。

因为地形实例在不少地方使用,若是你想要动态分配,它们的生命周期会有点复杂。 所以,咱们直接在游戏世界中存储它们。

class World
{
public:
  World()
  : grassTerrain_(1, false, GRASS_TEXTURE),
    hillTerrain_(3, false, HILL_TEXTURE),
    riverTerrain_(2, true, RIVER_TEXTURE)
  {}
 
private:
  Terrain grassTerrain_;
  Terrain hillTerrain_;
  Terrain riverTerrain_;
 
  // 其余代码……
};

而后咱们能够像这样来描绘地面:

void World::generateTerrain()
{
  // 将地面填满草皮.
  for (int x = 0; x < WIDTH; x++)
  {
    for (int y = 0; y < HEIGHT; y++)
    {
      // 加入一些丘陵
      if (random(10) == 0)
      {
        tiles_[x][y] = &hillTerrain_;
      }
      else
      {
        tiles_[x][y] = &grassTerrain_;
      }
    }
  }
 
  // 放置河流
  int x = random(WIDTH);
  for (int y = 0; y < HEIGHT; y++) {
    tiles_[x][y] = &riverTerrain_;
  }
}

我认可这不是世界上最好的地形生成算法。

如今不须要World中的方法来接触地形属性,咱们能够直接暴露出Terrain对象。

const Terrain& World::getTile(int x, int y) const
{
  return *tiles_[x][y];
}

用这种方式,World再也不与各类地形的细节耦合。 若是你想要某一区块的属性,可直接从那个对象得到:

int cost = world.getTile(2, 3).getMovementCost();

咱们回到了操做实体对象的API,几乎没有额外开销——指针一般不比枚举大。

性能如何?

我在这里说几乎,是由于性能偏执狂确定会想要知道它和枚举比起来如何。 经过解引用指针获取地形须要一次间接跳转。 为了得到移动开销这样的地形数据,你首先须要跟着网格中的指针找到地形对象, 而后再找到移动开销。跟踪这样的指针会致使缓存不命中,下降运行速度。

须要更多指针追逐和缓存不命中的相关信息,看看数据局部性这章。

就像往常同样,优化的金科玉律是需求优先 现代计算机硬件过于复杂,性能只是游戏的一个考虑方面。 在我这章作的测试中,享元较枚举没有什么性能上的损失。 享元实际上明显更快。可是这彻底取决于内存中的事物是如何排列的。

能够自信地说使用享元对象不会搞到不可收拾。 它给了你面向对象的优点,并且没有产生一堆对象。 若是你建立了一个枚举,又在它上面作了不少分支跳转,考虑一下这个模式吧。 若是你担忧性能,那么至少在把代码编程为难以维护的风格以前先作些性能分析。

参见

  • 在区块的例子中,咱们只是为每种地形建立一个实例而后存储在World中。 这也许能更好找到和重用这些实例。 可是在多数状况下,你不会在一开始就建立全部享元。

若是你不能预料哪些是实际上须要的,最好在须要时才建立。 为了保持共享的优点,当你须要一个时,首先看看是否已经建立了一个相同的实例。 若是确实如此,那么只需返回那个实例。

这一般意味须要将构造函数封装在查询对象是否存在的接口以后。 像这样隐藏构造指令是工厂方法的一个例子。

  • 为了返回一个早先建立的享元,须要追踪那些已经实例化的对象池。 正如其名,这意味着对象池是存储它们的好地方。
  • 当使用状态模式时, 常常会出现一些没有任何特定字段的状态对象 这个状态的标识和方法都颇有用。 在这种状况下,你能够使用这个模式,而后在不一样的状态机上使用相同的对象实例。

2.3观察者模式

游戏设计模式Design Patterns Revisited

随便打开电脑中的一个应用,颇有可能它就使用了MVC架构 而究其根本,是由于观察者模式。 观察者模式应用普遍,Java甚至将其放到了核心库之中(java.util.Observer),而C#直接将其嵌入了语法event关键字)。

就像软件中的不少东西,MVC是Smalltalkers在七十年代创造的。 Lisp程序员也许会说实际上是他们在六十年代发明的,可是他们懒得记下来。

观察者模式是应用最普遍和最广为人知的GoF模式,可是游戏开发世界与世隔绝, 因此对你来讲,它也许是全新的。 假设你与世隔绝,让我给你举个形象的例子。

成就解锁

假设咱们向游戏中添加了成就系统。 它存储了玩家能够完成的各类各样的成就,好比杀死1000只猴子恶魔从桥上掉下去,或者一命通关

我发誓画的这个没有第二个意思,笑。

要实现这样一个包含各类行为来解锁成就的系统是颇有技巧的。 若是咱们不够当心,成就系统会缠绕在代码库的每一个黑暗角落。 固然,从桥上掉落和物理引擎相关, 但咱们并不想看到在处理撞击代码的线性代数时, 有个对unlockFallOffBridge()的调用是不?

这只是随口一说。 有自尊的物理程序员毫不会容许像游戏玩法这样的平凡之物玷污他们优美的算式。

咱们喜欢的是,照旧,让关注游戏一部分的全部代码集成到一块。 挑战在于,成就在游戏的不一样层面被触发。怎么解耦成就系统和其余部分呢?

这就是观察者模式出现的缘由。 这让代码宣称有趣的事情发生了,而没必要关心究竟是谁接受了通知。

举个例子,有物理代码处理重力,追踪哪些物体待在地表,哪些坠入深渊。 为了实现桥上掉落的徽章,咱们能够直接把成就代码放在那里,但那就会一团糟。 相反,能够这样作:

void Physics::updateEntity(Entity& entity)
{
  bool wasOnSurface = entity.isOnSurface();
  entity.accelerate(GRAVITY);
  entity.update();
  if (wasOnSurface && !entity.isOnSurface())
  {
    notify(entity, EVENT_START_FALL);
  }
}

它作的就是声称,额,我不知道有谁感兴趣,可是这个东西刚刚掉下去了。作你想作的事吧。

物理引擎确实决定了要发送什么通知,因此这并无彻底解耦。但在架构这个领域,一般只能让系统变得更好,而不是完美

成就系统注册它本身为观察者,这样不管什么时候物理代码发送通知,成就系统都能收到。 它能够检查掉落的物体是否是咱们的失足英雄, 他以前有没有作过这种不愉快的与桥的经典力学遭遇。 若是知足条件,就伴着礼花和炫光解锁合适的成就,而这些都无需牵扯到物理代码。

事实上,咱们能够改变成就的集合或者删除整个成就系统,而没必要修改物理引擎。 它仍然会发送它的通知,哪怕实际没有东西接收。

固然,若是咱们永久移除成就,没有任何东西须要物理引擎的通知, 咱们也一样能够移除通知代码。可是在游戏的演进中,最好保持这里的灵活性。

它如何运做

若是你还不知道如何实现这个模式,你可能能够从以前的描述中猜到,可是为了减轻你的负担,我仍是过一遍代码吧。

观察者

咱们从那个须要知作别的对象作了什么事的类开始。 这些好打听的对象用以下接口定义:

class Observer
{
public:
  virtual ~Observer() {}
  virtual void onNotify(const Entity& entity, Event event) = 0;
};

onNotify()的参数取决于你。这就是为何是观察者模式, 而不是“能够粘贴到游戏中的真实代码”。 典型的参数是发送通知的对象和一个装入其余细节的“数据”参数。

若是你用泛型或者模板编程,你可能会在这里使用它们,可是根据你的特殊用况裁剪它们也很好。 这里,我将其硬编码为接受一个游戏实体和一个描述发生了什么的枚举。

任何实现了这个的具体类就成为了观察者。 在咱们的例子中,是成就系统,因此咱们能够像这样实现:

class Achievements : public Observer
{
public:
  virtual void onNotify(const Entity& entity, Event event)
  {
    switch (event)
    {
    case EVENT_ENTITY_FELL:
      if (entity.isHero() && heroIsOnBridge_)
      {
        unlock(ACHIEVEMENT_FELL_OFF_BRIDGE);
      }
      break;
 
      // 处理其余事件,更新heroIsOnBridge_变量……
    }
  }
 
private:
  void unlock(Achievement achievement)
  {
    // 若是尚未解锁,那就解锁成就……
  }
 
  bool heroIsOnBridge_;
};

被观察者

被观察的对象拥有通知的方法函数,用GoF的说法,那些对象被称为主题 它有两个任务。首先,它有一个列表,保存默默等它通知的观察者:

class Subject
{
private:
  Observer* observers_[MAX_OBSERVERS];
  int numObservers_;
};

在真实代码中,你会使用动态大小的集合而不是一个定长数组。 在这里,我使用这种最基础的形式是为了那些不了解C++标准库的人们。

重点是被观察者暴露了公开的API来修改这个列表:

class Subject
{
public:
  void addObserver(Observer* observer)
  {
    // 添加到数组中……
  }
 
  void removeObserver(Observer* observer)
  {
    // 从数组中移除……
  }
 
  // 其余代码……
};

这就容许了外界代码控制谁接收通知。 被观察者与观察者交流,可是不与它们耦合 在咱们的例子中,没有一行物理代码会说起成就。 但它仍然能够与成就系统交流。这就是这个模式的聪慧之处。

被观察者有一列表观察者而不是单个观察者也是很重要的。 这保证了观察者不会相互干扰。 举个例子,假设音频引擎也须要观察坠落事件来播放合适的音乐。 若是客体只支持单个观察者,当音频引擎注册时,就会取消成就系统的注册。

这意味着这两个系统须要相互交互——并且是用一种极其糟糕的方式, 第二个注册时会使第一个的注册失效。 支持一列表的观察者保证了每一个观察者都是被独立处理的。 就它们各自的视角来看,本身是这世界上惟一看着被观察者的。

被观察者的剩余任务就是发送通知:

class Subject
{
protected:
  void notify(const Entity& entity, Event event)
  {
    for (int i = 0; i < numObservers_; i++)
    {
      observers_[i]->onNotify(entity, event);
    }
  }
 
  // 其余代码…………
};

注意,代码假设了观察者不会在它们的onNotify()方法中修改观察者列表。 更加可靠的实现方法会阻止或优雅地处理这样的并发修改。

可被观察的物理系统

如今,咱们只须要给物理引擎和这些挂钩,这样它能够发送消息, 成就系统能够和引擎连线来接受消息。 咱们按照传统的设计模式方法实现,继承Subject

class Physics : public Subject
{
public:
  void updateEntity(Entity& entity);
};

这让咱们将notify()实现为了Subject内的保护方法。 这样派生的物理引擎类能够调用并发送通知,可是外部的代码不行。 同时,addObserver()removeObserver()是公开的, 因此任何能够接触物理引擎的东西均可以观察它。

在真实代码中,我会避免使用这里的继承。 相反,我会让Physics  一个Subject的实例。 再也不是观察物理引擎自己,被观察的会是独立的“下落事件”对象。 观察者能够用像这样注册它们本身:

physics.entityFell()
  .addObserver(this);

对我而言,这是“观察者”系统与“事件”系统的不一样之处。 使用前者,你观察作了有趣事情的事物。 使用后者,你观察的对象表明了发生的有趣事情

如今,当物理引擎作了些值得关注的事情,它调用notify(),就像以前的例子。 它遍历了观察者列表,通知全部观察者。

很简单,对吧?只要一个类管理一列表指向接口实例的指针。 难以置信的是,如此直观的东西是无数程序和应用框架交流的主心骨。

观察者模式不是天衣无缝的。当我问其余程序员怎么看,他们提出了一些抱怨。 让咱们看看能够作些什么来处理这些抱怨。

太慢了

我常常听到这点,一般是从那些不知道模式具体细节的程序员那里。 他们有一种假设,任何东西只要沾到了设计模式,那么必定包含了一堆类,跳转和浪费CPU循环其余行为。

观察者模式的名声特别坏,一些坏名声的事物与它如影随形, 好比事件消息,甚至数据绑定 其中的一些系统确实会慢。(一般是故意的,出于好的意图)。 他们使用队列,或者为每一个通知动态分配内存。

这就是为何我认为设计模式文档化很重要。 当咱们没有统一的术语,咱们就失去了简洁明确表达的能力。 你说“观察者”,我觉得是“事件”,他觉得是“消息”, 由于没人花时间记下差别,也没人阅读。

而那就是在这本书中我要作的。 本书中也有一章关于事件和消息:事件队列.

如今你看到了模式是如何真正被实现的, 你知道事实并不如他们所想的这样。 发送通知只需简单地遍历列表,调用一些虚方法。 是的,这比静态调用慢一点,除非是性能攸关的代码,不然这点消耗都是微不足道的。

我发现这个模式在代码性能瓶颈之外的地方能有很好的应用, 那些你能够承担动态分配消耗的地方。 除那之外,使用它几乎毫无限制。 咱们没必要为消息分配对象,也无需使用队列。这里只多了一个用在同步方法调用上的额外跳转。

事实上,你得当心,观察者模式同步的。 被观察者直接调用了观察者,这意味着直到全部观察者的通知方法返回后, 被观察者才会继续本身的工做。观察者会阻塞被观察者的运行。

这听起来很疯狂,但在实践中,这可不是世界末日。 这只是值得注意的事情。 UI程序员——那些使用基于事件的编程的程序员已经这么干了不少年了——有句经典名言:远离UI线程

若是要对事件同步响应,你须要完成响应,尽量快地返回,这样UI就不会锁死。 当你有耗时的操做要执行时,将这些操做推到另外一个线程或工做队列中去。

你须要当心地在观察者中混合线程和锁。 若是观察者试图得到被观察者拥有的锁,游戏就进入死锁了。 在多线程引擎中,你最好使用事件队列来作异步通讯。

它作了太多动态分配

整个程序员社区——包括不少游戏开发者——转向了拥有垃圾回收机制的语言, 动态分配今昔非比。 但在像游戏这样性能攸关的软件中,哪怕是在有垃圾回收机制的语言,内存分配也依然重要。 动态分配须要时间,回收内存也须要时间,哪怕是自动运行的。

不少游戏开发者不怎么担忧分配,但很担忧分页。 当游戏须要不崩溃地连续运行多日来得到发售资格,不断增长的分页堆会影响游戏的发售。

对象池模式一章介绍了避免这点的经常使用技术,以及更多其余细节。

在上面的示例代码中,我使用的是定长数组,由于我想尽量保证简单。 在真实的项目中中,观察者列表随着观察者的添加和删除而动态地增加和缩短。 这种内存的分配吓坏了一些人。

固然,第一件须要注意的事情是只在观察者加入时分配内存。 发送通知无需内存分配——只需一个方法调用。 若是你在游戏一开始就加入观察者而不乱动它们,分配的总量是很小的。

若是这仍然困扰你,我会介绍一种无需任何动态分配的方式来增长和删除观察者。

链式观察者

咱们如今看到的全部代码中,Subject拥有一列指针指向观察它的Observer Observer类自己没有对这个列表的引用。 它是纯粹的虚接口。优先使用接口,而不是有状态的具体类,这大致上是一件好事。

可是若是咱们确实愿意在Observer中放一些状态, 咱们能够将观察者的列表分布到观察者本身中来解决动态分配问题。 不是被观察者保留一列表分散的指针,观察者对象自己成为了链表中的一部分:

为了实现这一点,咱们首先要摆脱Subject中的数组,而后用链表头部的指针取而代之:

class Subject
{
  Subject()
  : head_(NULL)
  {}
 
  // 方法……
private:
  Observer* head_;
};

而后,咱们在Observer中添加指向链表中下一观察者的指针。

class Observer
{
  friend class Subject;
 
public:
  Observer()
  : next_(NULL)
  {}
 
  // 其余代码……
private:
  Observer* next_;
};

这里咱们也让Subject成为了友类。 被观察者拥有增删观察者的API,可是如今链表在Observer内部管理。 最简单的实现办法就是让被观察者类成为友类。

注册一个新观察者就是将其连到链表中。咱们用更简单的实现方法,将其插到开头:

void Subject::addObserver(Observer* observer)
{
  observer->next_ = head_;
  head_ = observer;
}

另外一个选项是将其添加到链表的末尾。这么作增长了必定的复杂性。 Subject要么遍历整个链表来找到尾部,要么保留一个单独tail_指针指向最后一个节点。

加在在列表的头部很简单,但也有另外一反作用。 当咱们遍历列表给每一个观察者发送一个通知, 注册的观察者最接到通知。 因此若是以ABC的顺序来注册观察者,它们会以CBA的顺序接到通知。

理论上,这种仍是那种方式没什么差异。 在好的观察者设计中,观察同一被观察者的两个观察者互相之间不应有任何顺序相关。 若是顺序确实有影响,这意味着这两个观察者有一些微妙的耦合,最终会害了你。

让咱们完成删除操做:

void Subject::removeObserver(Observer* observer)
{
  if (head_ == observer)
  {
    head_ = observer->next_;
    observer->next_ = NULL;
    return;
  }
 
  Observer* current = head_;
  while (current != NULL)
  {
    if (current->next_ == observer)
    {
      current->next_ = observer->next_;
      observer->next_ = NULL;
      return;
    }
 
    current = current->next_;
  }
}

如你所见,从链表移除一个节点一般须要处理一些丑陋的特殊状况,应对头节点。 还能够使用指针的指针,实现一个更优雅的方案。

我在这里没有那么作,是由于半数看到这个方案的人都迷糊了。 但这是一个很值得作的练习:它能帮助你深刻思考指针。

由于使用的是链表,因此咱们得遍历它才能找到要删除的观察者。 若是咱们使用普通的数组,也得作相同的事。 若是咱们使用双向链表,每一个观察者都有指向前面和后面的指针, 就能够用常量时间移除观察者。在实际项目中,我会这样作。

剩下的事情只有发送通知了,这和遍历列表一样简单;

void Subject::notify(const Entity& entity, Event event)
{
  Observer* observer = head_;
  while (observer != NULL)
  {
    observer->onNotify(entity, event);
    observer = observer->next_;
  }
}

这里,咱们遍历了整个链表,通知了其中每个观察者。 这保证了全部的观察者相互独立并有一样的优先级。

咱们能够这样实现,当观察者接到通知,它返回了一个标识,代表被观察者是否应该继续遍历列表。 若是这样作,你就接近了职责链模式。

不差嘛,对吧?被观察者如今想有多少观察者就有多少观察者,无需动态内存。 注册和取消注册就像使用简单数组同样快。 可是,咱们牺牲了一些小小的功能特性。

因为咱们使用观察者对象做为链表节点,这暗示它只能存在于一个观察者链表中。 换言之,一个观察者一次只能观察一个被观察者。 在传统的实现中,每一个被观察者有独立的列表,一个观察者同时能够存在于多个列表中。

你也许能够接受这一限制。 一般是一个被观察者有多个观察者,反过来就不多见了。 若是这真是一个问题,这里还有一种没必要使用动态分配的解决方案。 详细介绍的话,这章就太长了,但我会大体描述一下,其他的你能够自行填补……

链表节点池

就像以前,每一个被观察者有一链表的观察者。 可是,这些链表节点不是观察者自己。 相反,它们是分散的小链表节点对象, 包含了指向观察者的指针和指向链表下一节点的指针。

因为多个节点能够指向同一观察者,这就意味着观察者能够同时在超过多个被观察者的列表中。 咱们能够同时观察多个对象了。

链表有两种风格。学校教授的那种,节点对象包含数据。 在咱们以前的观察者链表的例子中,是另外一种: 数据(这个例子中是观察者)包含了节点next_指针)。

后者的风格被称为“侵入式”链表,由于在对象内部使用链表侵入了对象自己的定义。 侵入式链表灵活性更小,但如咱们所见,也更有效率。 在Linux核心这样的地方这种风格很流行。

避免动态分配的方法很简单:因为这些节点都是一样大小和类型, 能够预先在对象池中分配它们。 这样你只需处理固定大小的列表节点,能够随你所需使用和重用, 而无需牵扯到真正的内存分配器。

剩余的问题

我认为该模式将人们吓阻的三个主要问题已经被搞定了。 它简单,快速,对内存管理友好。 可是这意味着你总该使用观察者吗?

如今,这是另外一个的问题。 就像全部的设计模式,观察者模式不是万能药。 哪怕能够正确高效地的实现,它也不必定是好的解决方案。 设计模式声名狼藉的缘由之一就是人们将好模式用在错误的问题上,获得了糟糕的结果。

还有两个挑战,一个是关于技术,另外一个更偏向于可维护性。 咱们先处理关于技术的挑战,由于关于技术的问题老是更容易处理。

销毁被观察者和观察者

咱们看到的样例代码健壮可用,但有一个严重的反作用: 当删除一个被观察者或观察者时会发生什么? 若是你不当心在某些观察者上面调用了delete,被观察者也许仍然持有指向它的指针。 那是一个指向一片已释放区域的悬空指针。 当被观察者试图发送一个通知,额……就说发生的事情会出乎你的意料以外吧。

不是谴责,但我注意到设计模式彻底没提这个问题。

删除被观察者更容易些,由于在大多数实现中,观察者没有对它的引用。 可是即便这样,将被观察者所占的字节直接回收可能仍是会形成一些问题。 这些观察者也许仍然期待在之后收到通知,而这是不可能的了。 它们无法继续观察了,真的,它们只是认为它们能够。

你能够用好几种方式处理这点。 最简单的就是像我作的那样,之后一脚踩空。 在被删除时取消注册是观察者的职责。 多数状况下,观察者确实知道它在观察哪一个被观察者, 因此一般须要作的只是给它的析构器添加一个removeObserver()

一般在这种状况下,难点不在如何作,而在记得作。

若是在删除被观察者时,你不想让观察者处理问题,这也很好解决。 只须要让被观察者在它被删除前发送一个最终的死亡通知 这样,任何观察者均可以接收到,而后作些合适的行为。

默哀,献花,挽歌……

——哪怕是那些花费在大量时间在机器前,拥有让咱们黯然失色的才能的人——也是绝对不可靠的。 这就是为何咱们发明了电脑:它们不像咱们那样常常犯错误。

更安全的方案是在每一个被观察者销毁时,让观察者自动取消注册。 若是你在观察者基类中实现了这个逻辑,每一个人没必要记住就能够使用它。 这确实增长了必定的复杂度。 这意味着每一个观察者都须要有它在观察的被观察者的列表。 最终维护一个双向指针。

别担忧,我有垃圾回收器

大家那些装备有垃圾回收系统的孩子如今必定很洋洋自得。 以为你没必要担忧这个,由于你历来没必要显式删除任何东西?再仔细想一想!

想象一下:你有UI显示玩家角色状况的状态,好比健康和道具。 当玩家在屏幕上时,你为其初始化了一个对象。 UI退出时,你直接忘掉这个对象,交给GC清理。

每当角色脸上(或者其余什么地方)挨了一拳,就发送一个通知。 UI观察到了,而后更新健康槽。很好。 当玩家离开场景,但你没有取消观察者的注册,会发生什么?

UI界面再也不可见,但也不会进入垃圾回收系统,由于角色的观察者列表还保存着对它的引用。 每一次场景加载后,咱们给那个不断增加的观察者列表添加一个新实例。

玩家玩游戏时,来回跑动,打架,角色的通知发送给全部的界面。 它们不在屏幕上,但它们接受通知,这样就浪费CPU循环在不可见的UI元素上了。 若是它们会播放声音之类的,这样的错误就会被人察觉。

这在通知系统中很是常见,甚至专门有个名字:失效监听者问题 因为被观察者保留了对观察者的引用,最终有UI界面对象僵死在内存中。 这里的教训是要及时删除观察者。

它甚至有专门的维基条目

而后呢?

观察者的另外一个深层次问题是它的意图直接致使的。 咱们使用它是由于它帮助咱们放松了两块代码之间的耦合。 它让被观察者与没有静态绑定的观察者间接交流。

当你要理解被观察者的行为时,这颇有价值,任何不相关的事情都是在分散注意力。 若是你在处理物理引擎,你根本不想要编辑器——或者你的大脑——被一堆成就系统的东西而搞糊涂。

另外一方面,若是你的程序没能运行,漏洞散布在多个观察者之间,理清信息流变得更加困难。 显式耦合中更易于查看哪个方法被调用了。 这是由于耦合是静态的,IDE分析它垂手可得。

可是若是耦合发生在观察者列表中,想要知道哪一个观察者被通知到了,惟一的办法是看看哪一个观察者在列表中,并且处于运行中 你得理清它的命令式,动态行为而非理清程序的静态交流结构。

处理这个的指导原则很简单。 若是为了理解程序的一部分,两个交流的模块须要考虑, 那就不要使用观察者模式,使用其余更加显式的东西。

当你在某些大型程序上用黑魔法时,你会感受这样处理很笨拙。 咱们有不少术语用来描述,好比关注点分离一致性和内聚性模块化 总归就是这些东西待在一块儿,而不是与那些东西待在一块儿。

观察者模式是一个让这些不相关的代码块互相交流,而没必要打包成更大的块的好方法。 这在专一于一个特性或层面的单一代码块不会太有用。

这就是为何它能很好地适应咱们的例子: 成就和物理是几乎彻底不相干的领域,一般被不一样的人实现。 咱们想要它们之间的交流最小化, 这样不管在哪个上工做都不须要另外一个的太多信息。

今日观察者

设计模式源于1994 那时候,面向对象语言正是热门的编程范式。每一个程序员都想要“30天学会面向对象编程,中层管理员根据程序员建立类的数量为他们支付工资。工程师经过继承层次的深度评价代码质量。

同一年,Ace of Base的畅销单曲发行了三首而不是一首,这也许能让你了解一些咱们那时的品味和洞察力。

观察者模式在那个时代中很流行,因此构建它须要不少类就不奇怪了。 可是现代的主流程序员更加适应函数式语言。 实现一整套接口只是为了接受一个通知再也不符合今日的美学了。

它看上去是又沉重又死板。它确实又沉重又死板。 举个例子,在观察者类中,你不能为不一样的被观察者调用不一样的通知方法。

这就是为何被观察者常常将自身传给观察者。 观察者只有单一的onNotify()方法, 若是它观察多个被观察者,它须要知道哪一个被观察者在调用它的方法。

现代的解决办法是让观察者只是对方法或者函数的引用。 在函数做为第一公民的语言中,特别是那些有闭包的, 这种实现观察者的方式更为广泛。

今日,几乎每种语言都有闭包。C++克服了在没有垃圾回收的语言中构建闭包的挑战, 甚至Java都在JDK8中引入了闭包。

举个例子,C#事件嵌在语言中。 经过这样,观察者是一个委托 委托是方法的引用在C#中的术语)。在JavaScript事件系统中,观察者能够是支持了特定EventListener协议的类, 可是它们也能够是函数。 后者是人们经常使用的方式。

若是设计今日的观察者模式,我会让它基于函数而不是基于类。 哪怕是在C++中,我倾向于让你注册一个成员函数指针做为观察者,而不是Observer接口的实例。

这里的一篇有趣博文以某种方式在C++上实现了这一点。

明日观察者

事件系统和其余相似观察者的模式现在遍地都是。 它们都是成熟的方案。 可是若是你用它们写一个稍微大一些的应用,你会发现一件事情。 在观察者中不少代码最后都长得同样。一般是这样:

1. 获知有状态改变了。
2. 下命令改变一些UI来反映新的状态。

就是这样,哦,英雄的健康如今是7了?让咱们把血条的宽度设为70像素。 过上一段时间,这会变得很沉闷。 计算机科学学术界和软件工程师已经用了很长时间尝试结束这种情况了。 这些方式被赋予了不一样的名字:数据流编程函数反射编程等等。

即便有所突破,通常也局限在特定的领域中,好比音频处理或芯片设计,咱们尚未找到万能钥匙。与此同时,一个更脚踏实地的方式开始得到成效。那就是如今的不少应用框架使用的数据绑定

不像激进的方式,数据绑定再也不期望彻底终结命令式代码,也不尝试基于巨大的声明式数据图表架构整个应用。它作的只是自动改变UI元素或计算某些数值来反映一些值的变化。

就像其余声明式系统,数据绑定也许太慢,嵌入游戏引擎的核心也太复杂。 可是若是说它不会侵入游戏不那么性能攸关的部分,好比UI,那我会很惊讶。

与此同时,经典观察者模式仍然在那里等着咱们。是的,它不像其余的新热门技术同样在名字中填满了函数”“反射,可是它超简单并且能正常工做。对我而言,这一般是解决方案最重要的条件。

2.4原型模式

游戏设计模式Design Patterns Revisited

我第一次听到原型这个词是在设计模式中。 现在,彷佛每一个人都在用这个词,但他们讨论的实际上不是设计模式 咱们会讨论他们所说的原型,也会讨论术语原型的有趣之处,和其背后的理念。 但首先,让咱们重访传统的设计模式。

“传统的”一词可不是随便用的。 设计模式引自1963 Ivan Sutherland的Sketchpad传奇项目,那是这个模式首次出现。 当其余人在听迪伦和甲壳虫乐队时,Sutherland正忙于,你知道的,发明CAD,交互图形和面向对象编程的基本概念。

看看这个demo,跪服吧。

原型设计模式

假设咱们要用《圣铠传说》的风格作款游戏。 野兽和恶魔围绕着英雄,争着要吃他的血肉。 这些可怖的同行者经过生产者进入这片区域,每种敌人有不一样的生产者。

在这个例子中,假设咱们游戏中每种怪物都有不一样的类——GhostDemonSorcerer等等,像这样:

class Monster
{
  // 代码……
};
 
class Ghost : public Monster {};
class Demon : public Monster {};
class Sorcerer : public Monster {};

生产者构造特定种类怪物的实例。 为了在游戏中支持每种怪物,咱们能够用一种暴力的实现方法, 让每一个怪物类都有生产者类,获得平行的类结构:

我得翻出落满灰尘的UML书来画这个图表。表明“继承”。

实现后看起来像是这样:

class Spawner
{
public:
  virtual ~Spawner() {}
  virtual Monster* spawnMonster() = 0;
};
 
class GhostSpawner : public Spawner
{
public:
  virtual Monster* spawnMonster()
  {
    return new Ghost();
  }
};
 
class DemonSpawner : public Spawner
{
public:
  virtual Monster* spawnMonster()
  {
    return new Demon();
  }
};
 
// 你知道思路了……

除非你会根据代码量来得到工资, 不然将这些焊在一块儿很明显不是好方法。 众多类,众多引用,众多冗余,众多副本,众多重复自我……

原型模式提供了一个解决方案。 关键思路是一个对象能够产出与它本身相近的对象。 若是你有一个恶灵,你能够制造更多恶灵。 若是你有一个恶魔,你能够制造其余恶魔。 任何怪物均可以被视为原型怪物,产出其余版本的本身。

为了实现这个功能,咱们给基类Monster添加一个抽象方法clone()

class Monster
{
public:
  virtual ~Monster() {}
  virtual Monster* clone() = 0;
 
  // 其余代码……
};

每一个怪兽子类提供一个特定实现,返回与它本身的类和状态都彻底同样的新对象。举个例子:

class Ghost : public Monster {
public:
  Ghost(int health, int speed)
  : health_(health),
    speed_(speed)
  {}
 
  virtual Monster* clone()
  {
    return new Ghost(health_, speed_);
  }
 
private:
  int health_;
  int speed_;
};

一旦咱们全部的怪物都支持这个, 咱们再也不须要为每一个怪物类建立生产者类。咱们只需定义一个类:

class Spawner
{
public:
  Spawner(Monster* prototype)
  : prototype_(prototype)
  {}
 
  Monster* spawnMonster()
  {
    return prototype_->clone();
  }
 
private:
  Monster* prototype_;
};

它内部存有一个怪物,一个隐藏的怪物, 它惟一的任务就是被生产者当作模板,去产生更多同样的怪物, 有点像一个历来不离开巢穴的蜂后。

为了获得恶灵生产者,咱们建立一个恶灵的原型实例,而后建立拥有这个实例的生产者:

Monster* ghostPrototype = new Ghost(15, 3);
Spawner* ghostSpawner = new Spawner(ghostPrototype);

这个模式的灵巧之处在于它不但拷贝原型的,也拷贝它的状态 这就意味着咱们能够建立一个生产者,生产快速鬼魂,虚弱鬼魂,慢速鬼魂,而只需建立一个合适的原型鬼魂。

我在这个模式中找到了一些既优雅又使人惊叹的东西。 我没法想象本身是如何创造出它们的,但我更没法想象不知道这些东西的本身该如何是好。

效果如何?

好吧,咱们不须要为每一个怪物建立单独的生产者类,那很好。 但咱们确实须要在每一个怪物类中实现clone() 这和使用生产者方法比起来也没节约多少代码量。

当你坐下来试着写一个正确的clone(),会碰见使人不快的语义漏洞。 作深层拷贝仍是浅层拷贝呢?换言之,若是恶魔拿着叉子,克隆恶魔也要克隆叉子吗?

同时,这看上去没减小已存问题上的代码, 事实上还增添了些人为的问题 咱们须要将每一个怪物有独立的类做为前提条件。 这绝对不是当今大多数游戏引擎运做的方法。

咱们中大部分痛苦地学到,这样庞杂的类层次管理起来很痛苦, 那就是咱们为何用组件模式类型对象为不一样的实体建模,这样无需一一建构本身的类。

生产函数

哪怕咱们确实须要为每一个怪物构建不一样的类,这里还有其余的实现方法。 不是使用为每一个怪物创建分离的生产者,咱们能够建立生产函数,就像这样:

Monster* spawnGhost()
{
  return new Ghost();
}

这比构建怪兽生产者类更简洁。生产者类只需简单地存储一个函数指针:

typedef Monster* (*SpawnCallback)();
 
class Spawner
{
public:
  Spawner(SpawnCallback spawn)
  : spawn_(spawn)
  {}
 
  Monster* spawnMonster()
  {
    return spawn_();
  }
 
private:
  SpawnCallback spawn_;
};

为了给恶灵构建生产者,你须要作:

Spawner* ghostSpawner = new Spawner(spawnGhost);

模板

现在,大多数C++开发者已然熟悉模板了。 生产者类须要为某类怪物构建实例,可是咱们不想硬编码是哪类怪物。 天然的解决方案是将它做为模板中的类型参数

我不太肯定程序员是学着喜欢C++模板仍是彻底畏惧并远离了C++。 无论怎样,今日我见到的程序员中,使用C++的也都会使用模板。

这里的Spawner类没必要考虑将生产什么样的怪物, 它总与指向Monster的指针打交道。

若是咱们只有SpawnerFor<T>类,模板类型没有办法共享父模板, 这样的话,若是一段代码须要与产生多种怪物类型的生产者打交道,就都得接受模板参数。

class Spawner
{
public:
  virtual ~Spawner() {}
  virtual Monster* spawnMonster() = 0;
};
 
template <class T>
class SpawnerFor : public Spawner
{
public:
  virtual Monster* spawnMonster() { return new T(); }
};

像这样使用它:

Spawner* ghostSpawner = new SpawnerFor<Ghost>();

第一公民类型

前面的两个解决方案使用类完成了需求,Spawner使用类型进行参数化。 C++中,类型不是第一公民,因此须要一些改动。 若是你使用JavaScriptPython,或者Ruby这样的动态类型语言, 它们的类能够传递的对象,你能够用更直接的办法解决这个问题。

某种程度上, 类型对象也是为了弥补第一公民类型的缺失。 但那个模式在拥有第一公民类型的语言中也有用,由于它让决定什么是“类型”。 你也许想要与语言内建的类不一样的语义。

当你完成一个生产者,直接向它传递要构建的怪物类——那个表明了怪物类的运行时对象。超容易的,对吧。

综上所述,老实说,我不能说找到了一种情景,而在这个情景下,原型设计模式是最好的方案。 也许你的体验有所不一样,但如今把它搁到一边,咱们讨论点别的:将原型做为一种语言范式

原型语言范式

不少人认为面向对象编程是同义词。 OOP的定义却让人感受正好相反, 毫无疑问,OOP让你定义对象,将数据和代码绑定在一块儿。 C这样的结构化语言相比,与Scheme这样的函数语言相比, OOP的特性是它将状态和行为牢牢地绑在一块儿。

你也许认为类是完成这个的惟一方式方法, 可是包括Dave UngarRandall Smith的一大堆家伙一直在拼命区分OOP和类。 他们在80年代建立了一种叫作Self的语言。它不用类实现了OOP

Self语言

就单纯意义而言,Self比基于类的语言更加面向对象。 咱们认为OOP将状态和行为绑在一块儿,可是基于类的语言实际将状态和行为割裂开来。

拿你最喜欢的基于类的语言的语法来讲。 为了接触对象中的一些状态,你须要在实例的内存中查询。状态包含在实例中。

可是,为了调用方法,你须要找到实例的类, 而后在那里调用方法。行为包含在中。 得到方法总须要经过中间层,这意味着字段和方法是不一样的。

举个例子,为了调用C++中的虚方法,你须要在实例中找指向虚方法表的指针,而后再在那里找方法。

Self结束了这种分歧。不管你要找啥,都只需在对象中找。 实例同时包含状态和行为。你能够构建拥有彻底独特方法的对象。

没有人能与世隔绝,但这个对象是。

若是这就是Self语言的所有,那它将很难使用。 基于类的语言中的继承,无论有多少缺陷,总归提供了有用的机制来重用代码,避免重复。 为了避免使用类而实现一些相似的功能,Self语言加入了委托

若是要在对象中寻找字段或者调用方法,首先在对象内部查找。 若是能找到,那就成了。若是找不到,在对象的父对象中寻找。 这里的父类仅仅是一个对其余对象的引用。 当咱们没能在第一个对象中找到属性,咱们尝试它的父对象,而后父类的父对象,继续下去直到找到或者没有父对象为止。 换言之,失败的查找被委托给对象的父对象。

我在这里简化了。Self实际上支持多个父对象。 父对象只是特别标明的字段,意味着你能够继承它们或者在运行时改变他们, 你最终获得了“动态继承”。

父对象让咱们在不一样对象间重用行为(还有状态!),这样就完成了类的公用功能。 类作的另外一个关键事情就是给出了建立实例的方法。 当你须要新的某物,你能够直接new Thingamabob(),或者随便什么你喜欢的表达法。 类是实例的生产工厂。

不用类,咱们怎样建立新的实例? 特别地,咱们如何建立一堆有共同点的新东西? 就像这个设计模式,在Self中,达到这点的方式是使用克隆

Self语言中,就好像每一个对象都自动支持原型设计模式。 任何对象都能被克隆。为了得到一堆类似的对象,你:

  1. 将对象塑形成你想要的状态。你能够直接克隆系统内建的基本Object,而后向其中添加字段和方法。
  2. 克隆它来产出…………随你想要多少就克隆多少个对象。

无需烦扰本身实现clone();咱们就实现了优雅的原型模式,原型被内建在系统中。

这个系统美妙,灵巧,并且小巧, 一据说它,我就开始建立一个基于原型的语言来进一步学习。

我知道从头开始构建一种编程语言语言不是学习它最有效率的办法,但我能说什么呢?我可算是个怪人。 若是你很好奇,我构建的语言叫Finch.

它的实际效果如何?

能使用纯粹基于原型的语言让我很兴奋,可是当我真正上手时, 我发现了一个使人不快的事实:用它编程没那么有趣。

从小道消息中,我据说不少Self程序员得出了相同的结论。 但这项目并非一无可取。 Self很是的灵活,为此创造了不少虚拟机的机制来保持高速运行。

他们发明了JIT编译,垃圾回收,以及优化方法分配——这都是由同一批人实现的—— 这些新玩意让动态类型语言能快速运行,构建了不少大受欢迎的应用。

是的,语言自己很容易实现,那是由于它把复杂度甩给了用户。 一旦开始试着使用这语言,我发现我想念基于类语言中的层次结构。 最终,在构建语言缺失的库概念时,我放弃了。

鉴于我以前的经验都来自基于类的语言,所以个人头脑可能已经固定在它的范式上了。 可是直觉上,我认为大部分人仍是喜欢有清晰定义的事物

除去基于类的语言自身的成功之外,看看有多少游戏用类建模描述玩家角色,以及不一样的敌人、物品、技能。 不是游戏中的每一个怪物都不同凡响,你不会看到洞穴人和哥布林还有雪混合在一块儿这样的怪物。

原型是很是酷的范式,我但愿有更多人了解它, 但我很庆幸没必要每天用它编程。 彻底皈依原型的代码是一团浆糊,难以阅读和使用。

这同时证实,不多 有人使用原型风格的代码。我查过了。

JavaScript又怎么样呢?

好吧,若是基于原型的语言不那么友好,怎么解释JavaScript呢? 这是一个有原型的语言,天天被数百万人使用。运行JavaScript的机器数量超过了地球上其余全部的语言。

Brendan EichJavaScript的缔造者, Self语言中直接汲取灵感,不少JavaScript的语义都是基于原型的。 每一个对象都有属性的集合,包含字段和方法(事实上只是存储为字段的函数)。 A对象能够拥有B对象,B对象被称为A对象的原型 若是A对象的字段获取失败就会委托给B对象。

做为语言设计者,原型的诱人之处是它们比类更易于实现。 Eich充分利用了这一点,他在十天内建立了JavaScript的第一个版本。

但除那之外,我相信在实践中,JavaScript更像是基于类的而不是基于原型的语言。 JavaScriptSelf有所偏离,其中一个要点是除去了基于原型语言的核心操做克隆

JavaScript中没有方法来克隆一个对象。 最接近的方法是Object.create(),容许你建立新对象做为现有对象的委托。 这个方法在ECMAScript5中才添加,而那已经是JavaScript出现后的第十四年了。 相对于克隆,让我带你参观一下JavaScript中定义类和建立对象的经典方法。 咱们从构造器函数开始:

function Weapon(range, damage) {
  this.range = range;
  this.damage = damage;
}

这建立了一个新对象,初始化了它的字段。你像这样引入它:

var sword = new Weapon(10, 16);

这里的new调用Weapon()函数,而this绑定在新的空对象上。 函数为新对象添加了一系列字段,而后返回填满的对象。

new也为你作了另一件事。 当它建立那个新的空对象时,它将空对象的委托和一个原型对象链接起来。 你能够用Weapon.prototype来得到原型对象。

属性是添加到构造器中的,而定义行为一般是经过向原型对象添加方法。就像这样:

Weapon.prototype.attack = function(target) {
  if (distanceTo(target) > this.range) {
    console.log("Out of range!");
  } else {
    target.health -= this.damage;
  }
}

这给武器原型添加了attack属性,其值是一个函数。 因为new Weapon()返回的每个对象都有给Weapon.prototype的委托, 你如今能够经过调用sword.attack() 来调用那个函数。 看上去像是这样:

让咱们复习一下:

  • 经过“new”操做建立对象,该操做引入表明类型的对象——构造器函数。
  • 状态存储在实例中。
  • 行为经过间接层——原型的委托——被存储在独立的对象中,表明了一系列特定类型对象的共享方法。

说我疯了吧,但这听起来很像是我以前描述的类。 能够JavaScript中写原型风格的代码(不用 克隆), 可是语言的语法和惯用法更鼓励基于类的实现。

我的而言,我认为这是好事。 就像我说的,我发现若是一切都使用原型,就很难编写代码, 因此我喜欢JavaScript,它将整个核心语义包上了一层糖衣。

为数据模型构建原型

好吧,我以前不断地讨论我不喜欢原型的缘由,这让这一章读起来使人沮丧。 我认为这本书应该更欢乐些,因此在最后,让咱们讨论讨论原型确实有用,或者更加精确,委托 有用的地方。

随着编程的进行,若是你比较程序与数据的字节数, 那么你会发现数据的占比稳定地增加。 早期的游戏在程序中生成几乎全部东西,这样程序能够塞进磁盘和老式游戏卡带。 在今日的游戏中,代码只是驱动游戏的引擎,游戏是彻底由数据定义的。

这很好,可是将内容推到数据文件中并不能魔术般地解决组织大项目的挑战。 它只能把这挑战变得更难。 咱们使用编程语言就由于它们有办法管理复杂性。

再也不是将一堆代码拷来拷去,咱们将其移入函数中,经过名字调用。 再也不是在一堆类之间复制方法,咱们将其放入单独的类中,让其余类能够继承或者组合。

当游戏数据达到必定规模时,你真的须要考虑一些类似的方案。 我不期望在这里能说清数据模式这个问题, 但我确实但愿提出个思路,让你在游戏中考虑考虑:使用原型和委托来重用数据。

假设咱们为早先提到的山寨版《圣铠传说》定义数据模型。 游戏设计者须要在不少文件中设定怪物和物品的属性。

这标题是我原创的,没有受到任何已存的多人地下城游戏的影响。 请不要起诉我。

一个经常使用的方法是使用JSON 数据实体通常是字典,或者属性集合,或者其余什么术语, 由于程序员就喜欢为旧事物发明新名字。

咱们从新发明了太屡次,Steve Yegge称之为通用设计模式.

因此游戏中的哥布林也许被定义为像这样的东西:

{
  "name": "goblin grunt",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"]
}

这看上去很易懂,哪怕是最讨厌文本的设计者也能使用它。 因此,你能够给哥布林你们族添加几个兄弟分支:

{
  "name": "goblin wizard",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"],
  "spells": ["fire ball", "lightning bolt"]
}
 
{
  "name": "goblin archer",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"],
  "attacks": ["short bow"]
}

如今,若是这是代码,咱们会闻到了臭味。 在实体间有不少的重复,训练优良的程序员讨厌重复。 它浪费了空间,消耗了做者更多时间。 你须要仔细阅读代码才知道这些数据是否是相同的。 这难以维护。 若是咱们决定让全部哥布林变强,须要记得将三个哥布林都更新一遍。糟糕糟糕糟糕。

若是这是代码,咱们会为哥布林构建抽象,并在三个哥布林类型中重用。 可是无能的JSON无法这么作。因此让咱们把它作得更加巧妙些。

咱们能够为对象添加"prototype"字段,记录委托对象的名字。 若是在此对象内没找到一个字段,那就去委托对象中查找。

这让"prototype"再也不是数据,而成为了数据。 哥布林有绿色疣皮和黄色牙齿。 它们没有原型。 原型是表示哥布林的数据模型的属性,而不是哥布林自己的属性。

这样,咱们能够简化咱们的哥布林JSON内容:

{
  "name": "goblin grunt",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"]
}
 
{
  "name": "goblin wizard",
  "prototype": "goblin grunt",
  "spells": ["fire ball", "lightning bolt"]
}
 
{
  "name": "goblin archer",
  "prototype": "goblin grunt",
  "attacks": ["short bow"]
}

因为弓箭手和术士都将grunt做为原型,咱们就不须要在它们中重复血量,防护和弱点。 咱们为数据模型增长的逻辑超级简单——基本的单一委托——但已经成功摆脱了一堆冗余。

有趣的事情是,咱们没有更进一步,把哥布林委托的抽象原型设置成基本哥布林 相反,咱们选择了最简单的哥布林,而后委托给它。

在基于原型的系统中,对象能够克隆产生新对象是很天然的, 我认为在这里也同样天然。这特别适合记录那些只有一处不一样的实体的数据。

想一想Boss和其余独特的事物,它们一般是更加常见事物的从新定义, 原型委托是定义它们的好方法。 断头魔剑,就是一把拥有加成的长剑,能够像下面这样表示:

{
  "name": "Sword of Head-Detaching",
  "prototype": "longsword",
  "damageBonus": "20"
}

只需在游戏引擎上多花点时间,你就能让设计者更加方便地添加不一样的武器和怪物,而增长的这些丰富度可以取悦玩家。

2.5单例模式

游戏设计模式Design Patterns Revisited

这个章节不一样寻常。 其余章节展现如何使用某个设计模式。 这个章节展现如何避免使用某个设计模式。

尽管它的意图是好的,GoF描述的单例模式一般弊大于利。 他们强调应该谨慎使用这个模式,但在游戏业界的口口相传中,这一提示常常被无视了。

就像其余模式同样,在不合适的地方使用单例模式就好像用夹板处理子弹伤口。 因为它被滥用得太严重了,这章的大部分都在讲如何回避单例模式, 但首先,让咱们看看模式自己。

当业界从C语言迁移到面向对象的语言,他们遇到的首个问题是“如何访问实例?” 他们知道有要调用的方法,可是找不到实例提供这个方法。 单例(换言之,全局化)是一条简单的解决方案。

单例模式

设计模式 像这样描述单例模式:

保证一个类只有一个实例,而且提供了访问该实例的全局访问点。

咱们从而且那里将句子分为两部分,分别进行考虑。

保证一个类只有一个实例

有时候,若是类存在多个实例就不能正确的运行。 一般发生在类与保存全局状态的外部系统互动时。

考虑封装文件系统的API类。 由于文件操做须要一段时间完成,因此类使用异步操做。 这就意味着能够同时运行多个操做,必须让它们相互协调。 若是一个操做建立文件,另外一个操做删除同一文件,封装器类须要同时考虑,保证它们没有相互妨碍。

为了实现这点,对咱们封装器类的调用必须接触以前的每一个操做。 若是用户能够自由地建立类的实例,这个实例就没法知道另外一实例以前的操做。 而单例模式提供的构建类的方式,在编译时保证类只有单一实例。

提供了访问该实例的全局访问点

游戏中的不一样系统都会使用文件系统封装类:日志,内容加载,游戏状态保存,等等。 若是这些系统不能建立文件系统封装类的实例,它们如何访问该实例呢?

单例为这点也提供了解决方案。 除了建立单一实例之外,它也提供了一种得到它的全局方法。 使用这种范式,不管何处何人均可以访问实例。 综合起来,经典的实现方案以下:

class FileSystem
{
public:
  static FileSystem& instance()
  {
    // 惰性初始化
    if (instance_ == NULL) instance_ = new FileSystem();
    return *instance_;
  }
 
private:
  FileSystem() {}
 
  static FileSystem* instance_;
};

静态的instance_成员保存了一个类的实例, 私有的构造器保证了它是惟一的。 公开的静态方法instance()让任何地方的代码都能访问实例。 在首次被请求时,它一样负责惰性实例化该单例。

现代的实现方案看起来是这样的:

class FileSystem
{
public:
  static FileSystem& instance()
  {
    static FileSystem *instance = new FileSystem();
    return *instance;
  }
 
private:
  FileSystem() {}
};

哪怕是在多线程状况下,C++11标准也保证了本地静态变量只会初始化一次, 所以,假设你有一个现代C++编译器,这段代码是线程安全的,而前面的那个例子不是。

固然,单例类自己的线程安全是个不一样的问题!这里只保证了它的初始化没问题。

为何咱们使用它

看起来已有成效。 文件系统封装类在任何须要的地方均可用,而无需笨重地处处传递。 类自己巧妙地保证了咱们不会实例化多个实例而搞砸。它还具备不少其余的优良性质:

  • 若是没人用,就没必要建立实例。 节约内存和CPU循环老是好的。 因为单例只在第一次被请求时实例化,若是游戏永远不请求,那么它不会被实例化。
  • 它在运行时实例化。 一般的替代方案是使用含有静态成员变量的类。 我喜欢简单的解决方案,所以我尽量使用静态类而不是单例,可是静态成员有个限制:自动初始化。 编译器在main()运行前初始化静态变量。 这就意味着不能使用在程序加载时才获取的信息(举个例子,从文件加载的配置)。 这也意味着它们的相互依赖是不可靠的——编译器可不保证以什么样的顺序初始化静态变量。

惰性初始化解决了以上两个问题。 单例会尽量晚地初始化,因此那时它须要的全部信息都应该可用了。 只要没有环状依赖,一个单例在初始化它本身的时甚至能够引用另外一个单例。

  • 可继承单例。 这是个颇有用但一般被忽视的能力。 假设咱们须要跨平台的文件系统封装类。 为了达到这一点,咱们须要它变成文件系统抽象出来的接口,而子类为每一个平台实现接口。 这是基类:
  • class FileSystem
  • {
  • public:
  •   virtual ~FileSystem() {}
  •   virtual char* readFile(char* path) = 0;
  •   virtual void  writeFile(char* path, char* contents) = 0;
  • };

而后为一堆平台定义子类:

class PS3FileSystem : public FileSystem
{
public:
  virtual char* readFile(char* path)
  {
    // 使用索尼的文件读写API……
  }
 
  virtual void writeFile(char* path, char* contents)
  {
    // 使用索尼的文件读写API……
  }
};
 
class WiiFileSystem : public FileSystem
{
public:
  virtual char* readFile(char* path)
  {
    // 使用任天堂的文件读写API……
  }
 
  virtual void writeFile(char* path, char* contents)
  {
    // 使用任天堂的文件读写API……
  }
};

下一步,咱们把FileSystem变成单例:

class FileSystem
{
public:
  static FileSystem& instance();
 
  virtual ~FileSystem() {}
  virtual char* readFile(char* path) = 0;
  virtual void  writeFile(char* path, char* contents) = 0;
 
protected:
  FileSystem() {}
};

灵巧之处在于如何建立实例:

FileSystem& FileSystem::instance()
{
  #if PLATFORM == PLAYSTATION3
    static FileSystem *instance = new PS3FileSystem();
  #elif PLATFORM == WII
    static FileSystem *instance = new WiiFileSystem();
  #endif
 
  return *instance;
}

经过一个简单的编译器转换,咱们把文件系统包装类绑定到合适的具体类型上。 整个代码库均可以使用FileSystem::instance()接触到文件系统,而无需和任何平台相关的代码耦合。耦合发生在为特定平台写的FileSystem类实现文件中。

大多数人解决问题到这个程度就已经够了。 咱们获得了一个文件系统封装类。 它工做可靠,它全局有效,只要请求就能获取。 是时候提交代码,开怀畅饮了。

为何咱们后悔使用它

短时间来看,单例模式是相对良性的。 就像其余设计决策同样,咱们须要从长期考虑。 这里是一旦咱们将一些没必要要的单例写进代码,会给本身带来的麻烦:

它是一个全局变量

当游戏仍是由几个家伙在车库中完成时,榨干硬件性能比象牙塔里的软件工程原则更重要。 C语言和汇编程序员前辈能毫无问题地使用全局变量和静态变量,发布好游戏。 但随着游戏变得愈来愈大,愈来愈复杂,架构和管理开始变成瓶颈, 阻碍咱们发布游戏的,除了硬件限制,还有生产力限制。

因此咱们迁移到了像C++这样的语言, 开始将一些从软件工程师前辈那里学到的智慧应用于实际。 其中一课是全局变量有害的诸多缘由:

  • 理解代码更加困难。 假设咱们在查找其余人所写函数中的漏洞。 若是函数没有碰到任何全局状态,脑子只需围着函数转, 只需搞懂函数和传给函数的变量。

计算机科学家称不接触不修改全局状态的函数为函数。 纯函数易于理解,易于编译器优化, 易于完成优雅的任务,好比记住缓存的状况并继续上次调用。

彻底使用纯函数是有难度的,但其好处足以引诱科学家创造像Haskell这样使用纯函数的语言。

如今考虑函数中间是个对SomeClass::getSomeGlobalData()的调用。为了查明发生了什么,得追踪整个代码库来看看什么修改了全局变量。你真的不须要讨厌全局变量,直到你在凌晨三点使用grep搜索数百万行代码,搞清楚哪个错误的调用将一个静态变量设为了错误的值。

  • 促进了耦合的发生。 新加入团队的程序员也许不熟悉大家完美、可维护、松散耦合的游戏架构, 但仍是刚刚得到了第一个任务:在岩石撞击地面时播放声音。 你我都知道这不须要将物理和音频代码耦合,可是他只想着把任务完成。 不幸的是,咱们的AudioPlayer是全局可见的。 因此以后一个小小的#include,新队员就打乱了整个精心设计的架构。

若是不用全局实例实现音频播放器,那么哪怕他确实#include包含了头文件,他仍是啥也作不了。 这种阻碍给他发送了一个明确的信号,这两个模块不应接触,他须要另辟蹊径。经过控制对实例的访问,你控制了耦合。

  • 对并行不友好。 那些在单核CPU上运行游戏的日子已经远去。 哪怕彻底不须要并行的优点,现代的代码至少也应考虑在多线程环境下工做 当咱们将某些东西转为全局变量时,咱们建立了一块每一个线程都能看到并访问的内存, 殊不知道其余线程是否正在使用那块内存。 这种方式带来了死锁,竞争状态,以及其余很难解决的线程同步问题。

像这样的问题足够吓阻咱们声明全局变量了, 同理单例模式也是同样,可是那尚未告诉咱们应该如何设计游戏。 怎样不使用全局变量构建游戏?

有几个对这个问题的答案(这本书的大部分都由答案构成), 可是它们并不是显而易见。 与此同时,咱们得发布游戏。 单例模式看起来是万能药。 它被写进了一本关于面向对象设计模式的书中,所以它确定是个好的设计模式,对吧? 何况咱们已经借助它作了不少年软件设计了。

不幸的是,它不是解药,它是安慰剂。 若是浏览全局变量形成的问题列表,你会注意到单例模式解决不了其中任何一个。 由于单例确实是全局状态——它只是被封装在一个类中。

它能在你只有一个问题的时候解决两个

GoF对单例模式的描述中,而且这个词有点奇怪。 这个模式解决了一个问题仍是两个问题呢?若是咱们只有其中一个问题呢? 保证明例是惟一存在的是颇有用的,可是谁告诉咱们要让每一个人都能接触到它? 一样,全局接触很方便,可是必须禁止存在多个实例吗?

这两个问题中的后者,便利的访问,几乎是使用单例模式的所有缘由。 想一想日志类。大部分模块都能从记录诊断日志中获益。 可是,若是将Log类的实例传给每一个须要这个方法的函数,那就混杂了产生的数据,模糊了代码的意图。

明显的解决方案是让Log类成为单例。 每一个函数都能从类那里得到一个实例。 但当咱们这样作时,咱们无心地制造了一个奇怪的小约束。 忽然之间,咱们再也不能建立多个日志记录者了。

起初,这不是一个问题。 咱们记录单独的日志文件,因此只须要一个实例。 而后,随着开发周期的逐次循环,咱们遇到了麻烦。 每一个团队的成员都使用日志记录各自的诊断信息,大量的日志倾泻在文件里。 程序员须要翻过不少页代码来找到他关心的记录。

咱们想将日志分散到多个文件中来解决这点。 为了达到这点,咱们得为游戏的不一样领域创造单独的日志记录者: 网络,UI,声音,游戏,玩法。 可是咱们作不到。 Log类再也不容许咱们建立多个实例,并且调用的方式也保证了这一点:

Log::instance().write("Some event.");

为了让Log类支持多个实例(就像它原来的那样), 咱们须要修改类和说起它的每一行代码。 以前便利的访问就再也不那么便利了。

这可能更糟。想象一下你的Log类是在多个游戏间共享的库中。 如今,为了改变设计,须要在多组人之间协调改变, 他们中的大多数既没有时间,也没有动机修复它。

惰性初始化从你那里剥夺了控制权

在拥有虚拟内存和软性性能需求的PC里,惰性初始化是一个小技巧。 游戏则是另外一种情况。初始化系统须要消耗时间:分配内存,加载资源,等等。 若是初始化音频系统消耗了几百个毫秒,咱们须要控制它什么时候发生。 若是在第一次声音播放时惰性初始化它本身,这可能发生在游戏的高潮部分,致使可见的掉帧和断续的游戏体验。

一样,游戏一般须要严格管理在堆上分配的内存来避免碎片。 若是音频系统在初始化时分配到了堆上,咱们须要知道初始化在什么时候发生, 这样咱们能够控制内存待在堆的哪里

对象池模式一节中有内存碎片的其余细节。

由于这两个缘由,我见到的大多数游戏都不使用惰性初始化。 相反,它们像这样实现单例模式:

class FileSystem
{
public:
  static FileSystem& instance() { return instance_; }
 
private:
  FileSystem() {}
 
  static FileSystem instance_;
};

这解决了惰性初始化问题,可是损失了几个单例确实比原生的全局变量优良的特性。 静态实例中,咱们不能使用多态,在静态初始化时,类也必须是可构建的。 咱们也不能在不须要这个实例的时候,释放实例所占的内存。

与建立一个单例不一样,这里其实是一个简单的静态类。 这并不是坏事,可是若是你须要的是静态类,为何不彻底摆脱instance()方法, 直接使用静态函数呢?调用Foo::bar()Foo::instance().bar()更简单, 也更明确地代表你在处理静态内存。

一般使用单例而不是静态类的理由是, 若是你后来决定将静态类改成非静态的,你须要修改每个调用点。 理论上,用单例就没必要那么作,由于你能够将实例传来传去,像普通的实例方法同样使用。

实践中,我从未见过这种状况。 每一个人都在使用Foo::instance().bar()。 若是咱们将Foo改为非单例,咱们仍是得修改每个调用点。 鉴于此,我更喜欢简单的类和简单的调用语法。

那该如何是好

若是我如今达到了目标,你在下次遇到问题使用单例模式以前就会三思然后行。 可是你仍是有问题须要解决。你应该使用什么工具呢? 这取决于你试图作什么,我有一些你能够考虑的选项,可是首先……

看看你是否是真正地须要类

我在游戏中看到的不少单例类都是管理器”——那些类存在的意义就是照顾其余对象。 我曾看到一些代码库中,几乎全部类都有管理器: 怪物,怪物管理器,粒子,粒子管理器,声音,声音管理器,管理管理器的管理器。 有时候,它们被叫作系统引擎,可是思路仍是同样的。

管理器类有时是有用的,但一般它们只是反映出做者对OOP的不熟悉。思考这两个特制的类:

class Bullet
{
public:
  int getX() const { return x_; }
  int getY() const { return y_; }
 
  void setX(int x) { x_ = x; }
  void setY(int y) { y_ = y; }
 
private:
  int x_, y_;
};
 
class BulletManager
{
public:
  Bullet* create(int x, int y)
  {
    Bullet* bullet = new Bullet();
    bullet->setX(x);
    bullet->setY(y);
 
    return bullet;
  }
 
  bool isOnScreen(Bullet& bullet)
  {
    return bullet.getX() >= 0 &&
           bullet.getX() < SCREEN_WIDTH &&
           bullet.getY() >= 0 &&
           bullet.getY() < SCREEN_HEIGHT;
  }
 
  void move(Bullet& bullet)
  {
    bullet.setX(bullet.getX() + 5);
  }
};

也许这个例子有些蠢,可是我见过不少代码,在剥离了外部的细节后是同样的设计。 若是你看看这个代码,BulletManager很天然应是一个单例。 不管如何,任何有Bullet的对象都须要管理,而你又须要多少个BulletManager实例呢?

事实上,这里的答案是 这里是咱们如何为管理类解决单例问题:

class Bullet
{
public:
  Bullet(int x, int y) : x_(x), y_(y) {}
 
  bool isOnScreen()
  {
    return x_ >= 0 && x_ < SCREEN_WIDTH &&
           y_ >= 0 && y_ < SCREEN_HEIGHT;
  }
 
  void move() { x_ += 5; }
 
private:
  int x_, y_;
};

好了。没有管理器,也没有问题。 糟糕设计的单例一般会帮助另外一个类增长代码。 若是能够,把全部的行为都移到单例帮助的类中。 毕竟,OOP就是让对象管理好本身。

可是在管理器以外,还有其余问题咱们须要寻求单例模式帮助。 对于每种问题,都有一些后续方案可供参考。

将类限制为单一的实例

这是单例模式帮你解决的一个问题。 就像在文件系统的例子中那样,保证类只有一个实例是很重要的。 可是,这不意味着咱们须要提供对实例的公众全局访问。 咱们想要减小某部分代码的公众部分,甚至让它在类中是私有的。 在这些状况下,提供一个全局接触点消弱了总体架构。

举个例子,咱们也许想把文件系统包在另外一层抽象中。

咱们但愿有种方式能保证同事只有一个实例而无需提供全局接触点。 有好几种方法能作到。这是其中之一:

class FileSystem
{
public:
  FileSystem()
  {
    assert(!instantiated_);
    instantiated_ = true;
  }
 
  ~FileSystem() { instantiated_ = false; }
 
private:
  static bool instantiated_;
};
 
bool FileSystem::instantiated_ = false;

这个类容许任何人构建它,若是你试图构建超过一个实例,它会断言并失败。 只要正确的代码首先建立了实例,那么就保证了没有其余代码能够接触实例或者建立本身的实例。 这个类保证知足了它关注的单一实例,可是它没有指定类该如何被使用。

断言 函数是一种向你的代码中添加限制的方法。 当assert()被调用时,它计算传入的表达式。 若是结果为true,那么什么都不作,游戏继续。 若是结果为false,它马上中止游戏。 在debug build时,这一般会启动调试器,或至少打印失败断言所在的文件和行号。

assert()表示, “我断言这个总该是真的。若是不是,那就是漏洞,我想马上中止并处理它。” 这使得你能够在代码区域之间定义约束。 若是函数断言它的某个参数不能为NULL,那就是说,“我和调用者定下了协议:传入的参数不会NULL。”

断言帮助咱们在游戏发生预期之外的事时马上追踪漏洞, 而不是等到错误最终显如今用户可见的某些事物上。 它们是代码中的栅栏,围住漏洞,这样漏洞就不能从制造它的代码边逃开。

这个实现的缺点是只在运行时检查并阻止多重实例化。 单例模式正相反,经过类的天然结构,在编译时就能肯定实例是单一的。

为了给实例提供方便的访问方法

便利的访问是咱们使用单例的一个主要缘由。 这让咱们在不一样地方获取须要的对象更加容易。 这种便利是须要付出代价的——在咱们不想要对象的地方,也能轻易地使用。

通用原则是在能完成工做的同时,将变量写得尽量局部。 对象影响的范围越小,在处理它时,咱们须要放在脑子里的东西就越少。 在咱们拿起有全局范围影响的单例对象前,先考虑考虑代码中其余获取对象的方式:

  • 传进来。 最简单的解决办法,一般也是最好的,把你须要的对象简单地做为参数传给须要它的函数。 在用其余更加繁杂的方法前,考虑一下这个解决方案。

有些人使用术语依赖注入来指代它。不是代码出来调用某些全局量来确认依赖, 而是依赖经过参数被传进到须要它的代码中去。 其余人将依赖注入保留为对代码提供更复杂依赖的方法。

考虑渲染对象的函数。为了渲染,它须要接触一个表明图形设备的对象,管理渲染状态。 将其传给全部渲染函数是很天然的,一般是用一个名字像context之类的参数。

另外一方面,有些对象不应在方法的参数列表中出现。 举个例子,处理AI的函数可能也须要写日志文件,可是日志不是它的核心关注点。 看到Log出如今它的参数列表中是很奇怪的事情,像这样的状况,咱们须要考虑其余的选项。

像日志这样散布在代码库各处的是横切关注点”(cross-cutting concern) 当心地处理横切关注点是架构中的持久挑战,特别是在静态类型语言中。

面向切面编程被设计出来应对它们。

  • 从基类中得到。 不少游戏架构有浅层可是宽泛的继承层次,一般只有一层深。 举个例子,你也许有GameObject基类,每一个游戏中的敌人或者对象都继承它。 使用这样的架构,很大一部分游戏代码会存在于这些推导类中。 这就意味着这些类已经有了对一样事物的相同获取方法:它们的GameObject基类。 咱们能够利用这点:
  • class GameObject
  • {
  • protected:
  •   Log& getLog() { return log_; }
  •  
  • private:
  •   static Log& log_;
  • };
  •  
  • class Enemy : public GameObject
  • {
  •   void doSomething()
  •   {
  •     getLog().write("I can log!");
  •   }
  • };

这保证任何GameObject以外的代码都不能接触Log对象,可是每一个派生的实体都确实能使用getLog() 这种使用protected函数,让派生对象使用的模式, 被涵盖在子类沙箱这章中。

这也引出了一个新问题,GameObject是怎样得到Log实例的?一个简单的方案是,让基类建立并拥有静态实例。

若是你不想要基类承担这些,你能够提供一个初始化函数传入Log实例, 或使用服务定位器模式找到它。

  • 从已是全局的东西中获取。 移除全部全局状态的目标使人钦佩,但并不实际。 大多数代码库仍有一些全局可用对象,好比一个表明了整个游戏状态的GameWorld对象。

咱们能够让现有的全局对象捎带须要的东西,来减小全局变量类的数目。 不让LogFileSystemAudioPlayer都变成单例,而是这样作:

class Game
{
public:
  static Game& instance() { return instance_; }
 
  // 设置log_, et. al. ……
 
  Log&         getLog()         { return *log_; }
  FileSystem&  getFileSystem()  { return *fileSystem_; }
  AudioPlayer& getAudioPlayer() { return *audioPlayer_; }
 
private:
  static Game instance_;
 
  Log         *log_;
  FileSystem  *fileSystem_;
  AudioPlayer *audioPlayer_;
};

这样,只有Game是全局可见的。 函数能够经过它访问其余系统。

Game::instance().getAudioPlayer().play(VERY_LOUD_BANG);

纯粹主义者会声称这违反了Demeter法则。我则声称这比一大坨单例要好。

若是,稍后,架构被改成支持多个Game实例(多是为了流处理或者测试),LogFileSystem,和AudioPlayer都不会被影响到——它们甚至不知道有什么区别。 缺陷是,固然,更多的代码耦合到了Game中。 若是一个类简单地须要播放声音,为了访问音频播放器,上例中仍然须要它知道游戏世界。

咱们经过混合方案解决这点。 知道Game的代码能够直接从它那里访问AudioPlayer 而不知道的代码,咱们用上面描述的其余选项来提供AudioPlayer

  • 从服务定位器中得到。 目前为止,咱们假设全局类是具体的类,好比Game 另外一种选项是定义一个类,存在的惟一目标就是为对象提供全局访问。 这种常见的模式被称为服务定位器模式,有单独讲它的章节。

单例中还剩下什么

剩下的问题,何处咱们应该使用真实的单例模式? 说实话,我历来没有在游戏中使用所有的GoF模式。 为了保证明例是单一的,我一般简单地使用静态类。 若是这无效,我使用静态标识位,在运行时检测是否是只有一个实例被建立了。

书中还有一些其余章节也许能有所帮助。 子类沙箱模式经过分享状态, 给实例以类的访问权限而无需让其全局可用。 服务定位器模式确实让一个对象全局可用, 但它给了你如何设置对象的灵活性。

2.6状态模式

游戏设计模式Design Patterns Revisited

忏悔时间:我有些越界,将太多的东西打包到了这章中。 它表面上关于状态模式 但我没法只讨论它和游戏,而不涉及更加基础的有限状态机FSMs)。 可是一旦讲了那个,我发现也想要介绍层次状态机下推自动机

有不少要讲,我会尽量简短,这里的示例代码留下了一些你须要本身填补的细节。 我但愿它们仍然足够清晰,能让你获取一份全景图。

若是你历来没有据说过状态机,不要难过。 虽然在AI和编译器程序方面很出名,但它在其余编程圈就没那么知名了。 我认为应该有更多人知道它,因此在这里我将其运用在不一样的问题上。

这些状态机术语来自人工智能的早期时代。 在五十年代到六十年代,不少AI研究关注于语言处理。 不少如今用于分析程序语言的技术在当时是发明出来分析人类语言的。

感同身受

假设咱们在完成一个卷轴平台游戏。 如今的工做是实现玩家在游戏世界中操做的女英雄。 这就意味着她须要对玩家的输入作出响应。按B键她应该跳跃。简单实现以下:

void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    yVelocity_ = JUMP_VELOCITY;
    setGraphics(IMAGE_JUMP);
  }
}

看到漏洞了吗?

没有东西阻止空中跳跃”——当角色在空中时狂按B,她就会浮空。 简单的修复方法是给Heroine增长isJumping_布尔字段,追踪它跳跃的状态。而后这样作:

void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    if (!isJumping_)
    {
      isJumping_ = true;
      // 跳跃……
    }
  }
}

这里也应该有在英雄接触到地面时将isJumping_设回false的代码。 我在这里为了简明没有写。

接下来,当玩家按下下方向键时,若是角色在地上,咱们想要她卧倒,而松开按键时站起来:

void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    // 若是没在跳跃,就跳起来……
  }
  else if (input == PRESS_DOWN)
  {
    if (!isJumping_)
    {
      setGraphics(IMAGE_DUCK);
    }
  }
  else if (input == RELEASE_DOWN)
  {
    setGraphics(IMAGE_STAND);
  }
}

此次看到漏洞了吗?

经过这个代码,玩家能够:

  1. 按下键卧倒。
  2. B从卧倒状态跳起。
  3. 在空中放开下键。

英雄跳一半贴图变成了站立时的贴图。是时候增长另外一个标识了……

void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    if (!isJumping_ && !isDucking_)
    {
      // 跳跃……
    }
  }
  else if (input == PRESS_DOWN)
  {
    if (!isJumping_)
    {
      isDucking_ = true;
      setGraphics(IMAGE_DUCK);
    }
  }
  else if (input == RELEASE_DOWN)
  {
    if (isDucking_)
    {
      isDucking_ = false;
      setGraphics(IMAGE_STAND);
    }
  }
}

下面,若是玩家在跳跃途中按下下方向键,英雄可以作跳斩攻击就太酷了:

void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    if (!isJumping_ && !isDucking_)
    {
      // 跳跃……
    }
  }
  else if (input == PRESS_DOWN)
  {
    if (!isJumping_)
    {
      isDucking_ = true;
      setGraphics(IMAGE_DUCK);
    }
    else
    {
      isJumping_ = false;
      setGraphics(IMAGE_DIVE);
    }
  }
  else if (input == RELEASE_DOWN)
  {
    if (isDucking_)
    {
      // 站立……
    }
  }
}

又是检查漏洞的时间了。找到了吗?

跳跃时咱们检查了字段,防止了空气跳,可是速降时没有。又是另外一个字段……

咱们的实现方法很明显有错。 每次咱们改动代码时,就破坏些东西。 咱们须要增长更多动做——行走 都尚未加入呢——但以这种作法,完成以前就会形成一堆漏洞。

那些你崇拜的、看上去永远能写出完美代码的程序员并非超人。 相反,他们有哪一种代码易于出错的直觉,而后避开。

复杂分支和可变状态——随时间改变的字段——是两种易错代码,上面的例子覆盖了二者。

有限状态机前来救援

在经历了上面的挫败以后,把桌子扫空,只留下纸笔,咱们开始画流程图。 你给英雄每件能作的事情都画了一个盒子:站立,跳跃,俯卧,跳斩。 当角色在能响应按键的状态时,你从那个盒子画出一个箭头,标记上按键,而后链接到她变到的状态。

祝贺,你刚刚建好了一个有限状态机 它来自计算机科学的分支自动理论,那里有不少著名的数据结构,包括著名的图灵机。 FSMs是其中最简单的成员。

要点是:

  • 你拥有状态机全部可能状态的集合。 在咱们的例子中,是站立,跳跃,俯卧和速降。
  • 状态机同时只能在一个状态。 英雄不可能同时处于跳跃和站立状态。事实上,防止这点是使用FSM的理由之一。
  • 一连串的输入事件被发送给状态机。 在咱们的例子中,就是按键按下和松开。
  • 每一个状态都有一系列的转移,每一个转移与输入和另外一状态相关。 当输入进来,若是它与当前状态的某个转移相匹配,机器转换为所指的状态。

举个例子,在站立状态时,按下下方向键转换为俯卧状态。 在跳跃时按下下方向键转换为速降。 若是输入在当前状态没有定义转移,输入就被忽视。

这就是核心部分的所有了:状态,输入,和转移。 你能够用一张流程图把它画出来。不幸的是,编译器不认识流程图, 因此咱们如何实现一个? GoF的状态模式是一个方法——咱们会谈到的——但先从简单的开始。

对FSMs我最喜欢的类比是那种老式文字冒险游戏,好比Zork。 你有个由屋子组成的世界,屋子彼此经过出口相连。你输入像“去北方”的导航指令探索屋子。

这其实就是状态机:每一个屋子都是一个状态。 你如今在的屋子是当前状态。每一个屋子的出口是它的转移。 导航指令是输入。

枚举和分支

Heroine类的问题在于它不合法地捆绑了一堆布尔量: isJumping_isDucking_不会同时为真。 但有些标识同时只能有一个是true,这提示你真正须要的实际上是enum(枚举)。

在这个例子中的enum就是FSM的状态的集合,因此让咱们这样定义它:

enum State
{
  STATE_STANDING,
  STATE_JUMPING,
  STATE_DUCKING,
  STATE_DIVING
};

不须要一堆标识,Heroine只有一个state_状态。 这里咱们同时改变了分支顺序。在前面的代码中,咱们先判断输入,而后 判断状态。 这让处理某个按键的代码集中到了一处,但处理某个状态的代码分散到了各处。 咱们想让处理状态的代码聚在一块儿,因此先对状态作分支。这样的话:

void Heroine::handleInput(Input input)
{
  switch (state_)
  {
    case STATE_STANDING:
      if (input == PRESS_B)
      {
        state_ = STATE_JUMPING;
        yVelocity_ = JUMP_VELOCITY;
        setGraphics(IMAGE_JUMP);
      }
      else if (input == PRESS_DOWN)
      {
        state_ = STATE_DUCKING;
        setGraphics(IMAGE_DUCK);
      }
      break;
 
    case STATE_JUMPING:
      if (input == PRESS_DOWN)
      {
        state_ = STATE_DIVING;
        setGraphics(IMAGE_DIVE);
      }
      break;
 
    case STATE_DUCKING:
      if (input == RELEASE_DOWN)
      {
        state_ = STATE_STANDING;
        setGraphics(IMAGE_STAND);
      }
      break;
  }
}

这看起来很普通,可是比起前面的代码是个很大的进步。 咱们仍有条件分支,但简化了状态变化,将它变成了字段。 处理同一状态的全部代码都聚到了一块儿。 这是实现状态机最简单的方法,在某些状况下,这也不错。

重要的是,英雄再也不会处于不合法状态。 使用布尔标识,不少可能存在的值的组合是不合法的。 经过enum,每一个值都是合法的。

可是,你的问题也许超过了这个解法的能力范围。 假设咱们想增长一个动做动做,英雄能够俯卧一段时间充能,以后释放一次特殊攻击。 当她俯卧时,咱们须要追踪充能的持续时间。

咱们为Heroine添加了chargeTime_字段,记录充能的时间长度。 假设咱们已经有一个每帧都会调用的update()方法。在那里,咱们添加:

void Heroine::update()
{
  if (state_ == STATE_DUCKING)
  {
    chargeTime_++;
    if (chargeTime_ > MAX_CHARGE)
    {
      superBomb();
    }
  }
}

若是你猜这就是更新方法模式,恭喜你答对了!

咱们须要在她开始俯卧的时候重置计时器,因此咱们修改handleInput()

void Heroine::handleInput(Input input)
{
  switch (state_)
  {
    case STATE_STANDING:
      if (input == PRESS_DOWN)
      {
        state_ = STATE_DUCKING;
        chargeTime_ = 0;
        setGraphics(IMAGE_DUCK);
      }
      // 处理其余输入……
      break;
 
      // 其余状态……
  }
}

总而言之,为了增长这个充能攻击,咱们须要修改两个方法, 添加一个chargeTime_字段到Heroine,哪怕它只在俯卧时有意义。 咱们更喜欢的是让全部相关的代码和数据都待在同一个地方。GoF完成了这个。

状态模式

对于那些思惟模式深深沉浸在面向对象的人,每一个条件分支都是使用动态分配的机会(在C++中叫作虚方法调用)。 我以为那就太过于复杂化了。有时候一个if就能知足你的须要了。

这里有个历史遗留问题。 原先的面向对象传教徒,好比写《设计模式》的GoF和写《重构》的Martin Fowler都使用Smalltalk。 那里,ifThen:只是个由你在必定状况下使用的方法,该方法在truefalse对象中以不一样的方式实现。

可是在咱们的例子中,面向对象确实是一个更好的方案。 这带领咱们走向状态模式。GoF这样描述状态模式:

容许一个对象在其内部状态发生变化时改变本身的行为,该对象看起来好像修改了它的类型

这可没太多帮助。咱们的switch也完成了这一点。 它们描述的东西应用在英雄的身上实际是:

一个状态接口

首先,咱们为状态定义接口。 状态相关的行为——以前用switch的每一处——都成为了接口中的虚方法。 在咱们的例子中,那是handleInput()update()

class HeroineState
{
public:
  virtual ~HeroineState() {}
  virtual void handleInput(Heroine& heroine, Input input) {}
  virtual void update(Heroine& heroine) {}
};

为每一个状态写个类

对于每一个状态,咱们定义一个类实现接口。它的方法定义了英雄在状态的行为。 换言之,从以前的switch中取出每一个case,将它们移动到状态类中。举个例子:

class DuckingState : public HeroineState
{
public:
  DuckingState()
  : chargeTime_(0)
  {}
 
  virtual void handleInput(Heroine& heroine, Input input) {
    if (input == RELEASE_DOWN)
    {
      // 改回站立状态……
      heroine.setGraphics(IMAGE_STAND);
    }
  }
 
  virtual void update(Heroine& heroine) {
    chargeTime_++;
    if (chargeTime_ > MAX_CHARGE)
    {
      heroine.superBomb();
    }
  }
 
private:
  int chargeTime_;
};

注意咱们也将chargeTime_移出了Heroine,放到了DuckingState类中。 这很好——那部分数据只在这个状态有用,如今咱们的对象模型显式反映了这一点。

状态委托

接下来,向Heroine添加指向当前状态的指针,放弃庞大的switch,转向状态委托:

class Heroine
{
public:
  virtual void handleInput(Input input)
  {
    state_->handleInput(*this, input);
  }
 
  virtual void update()
  {
    state_->update(*this);
  }
 
  // 其余方法……
private:
  HeroineState* state_;
};

为了改变状态,咱们只须要将state_声明指向不一样的HeroineState对象。 这就是状态模式的所有了。

这看上去有些像策略模式和类型对象模式。 在三者中,你都有一个主对象委托给下属。区别在于意图

  • 在策略模式中,目标是解耦主类和它的部分行为。
  • 在类型对象中,目标是经过共享一个对相同类型对象的引用,让一系列对象行为相近。
  • 在状态模式中,目标是让主对象经过改变委托的对象,来改变它的行为。

状态对象在哪里?

我这里掩掩藏了一些细节。为了改变状态,咱们须要声明state_指向新的状态, 但那个新状态又是从哪里来呢? enum实现中,这都不用过脑子——enum实际上就像数字同样。 可是如今状态是类了,意味着咱们须要指向实例。一般这有两种方案:

静态状态

若是状态对象没有其余数据字段, 那么它存储的惟一数据就是指向虚方法表的指针,用来调用它的方法。 在这种状况下,没理由产生多个实例。毕竟每一个实例都彻底同样。

若是你的状态没有字段,只有一个虚方法,你能够再简化这个模式。 将每一个状态替换成状态函数——只是一个普通的顶层函数。 而后,主类中的state_字段变成一个简单的函数指针。

在那种状况下,你能够用一个静态实例。 哪怕你有一堆FSM同时在同一状态上运行,它们也能指向同一实例,由于状态没有与状态机相关的部分。

这是享元模式。

哪里放置静态实例取决于你。找一个合理的地方。 没什么特殊的理由,在这里我将它放在状态基类中。

class HeroineState
{
public:
  static StandingState standing;
  static DuckingState ducking;
  static JumpingState jumping;
  static DivingState diving;
 
  // 其余代码……
};

每一个静态字段都是游戏状态类的一个实例。为了让英雄跳跃,站立状态会这样作:

if (input == PRESS_B)
{
  heroine.state_ = &HeroineState::jumping;
  heroine.setGraphics(IMAGE_JUMP);
}

实例化状态

有时没那么容易。静态状态对俯卧状态不起做用。 它有一个chargeTime_字段,与正在俯卧的英雄特定相关。 在游戏中,若是只有一个英雄,那也行,可是若是要添加双人合做,同时在屏幕上有两个英雄,就有麻烦了。

在那种状况下,转换时须要建立状态对象。 这须要每一个FSM拥有本身的状态实例。若是咱们分配状态, 那意味着咱们须要释放当前的状态。 在这里要当心,因为触发变化的代码是当前状态中的方法,须要删除this,所以须要当心从事。

相反,咱们容许HeroineState中的handleInput()返回一个新状态。 若是它那么作了,Heroine会删除旧的,而后换成新的,就像这样:

void Heroine::handleInput(Input input)
{
  HeroineState* state = state_->handleInput(*this, input);
  if (state != NULL)
  {
    delete state_;
    state_ = state;
  }
}

这样,直到从以前的状态返回,咱们才须要删除它。 如今,站立状态能够经过建立新实例转换为俯卧状态:

HeroineState* StandingState::handleInput(Heroine& heroine,
                                         Input input)
{
  if (input == PRESS_DOWN)
  {
    // 其余代码……
    return new DuckingState();
  }
 
  // 保持这个状态
  return NULL;
}

若是能够,我倾向于使用静态状态,由于它们不会在状态转换时消耗太多的内存和CPU 可是,对于更多状态的事物,须要耗费一些精力来实现。

当你为状态动态分配内存时,你也许会担忧碎片。 对象池模式能够帮上忙。

入口行为和出口行为

状态模式的目标是将状态的行为和数据封装到单一类中。 咱们完成了一部分,可是还有一些未了之事。

当英雄改变状态时,咱们也改变她的贴图。 如今,那部分代码在她转换的状态中。 当她从俯卧转为站立,俯卧状态修改了她的贴图:

HeroineState* DuckingState::handleInput(Heroine& heroine,
                                        Input input)
{
  if (input == RELEASE_DOWN)
  {
    heroine.setGraphics(IMAGE_STAND);
    return new StandingState();
  }
 
  // 其余代码……
}

咱们想作的是,每一个状态控制本身的贴图。这能够经过给状态一个入口行为来实现:

class StandingState : public HeroineState
{
public:
  virtual void enter(Heroine& heroine)
  {
    heroine.setGraphics(IMAGE_STAND);
  }
 
  // 其余代码……
};

Heroine中,咱们将处理状态改变的代码移动到新状态上调用:

void Heroine::handleInput(Input input)
{
  HeroineState* state = state_->handleInput(*this, input);
  if (state != NULL)
  {
    delete state_;
    state_ = state;
 
    // 调用新状态的入口行为
    state_->enter(*this);
  }
}

这让咱们将俯卧代码简化为:

HeroineState* DuckingState::handleInput(Heroine& heroine,
                                        Input input)
{
  if (input == RELEASE_DOWN)
  {
    return new StandingState();
  }
 
  // 其余代码……
}

它作的全部事情就是转换到站立状态,站立状态控制贴图。 如今咱们的状态真正地封装了。 关于入口行为的好事就是,当你进入状态时,没必要关心你是从哪一个状态转换来的

大多数真正的状态图都有转为同一状态的多个转移。 举个例子,英雄在跳跃或跳斩后进入站立状态。 这意味着咱们在转换发生的最后重复相同的代码。 入口行为很好地解决了这一点。

咱们能,固然,扩展并支持出口行为 这是在咱们离开现有状态,转换到新状态以前调用的方法。

有什么收获?

我花了这么长时间向您推销FSMs,如今咱们来捋一捋。 我到如今讲的都是真的,FSM能很好地解决一些问题。但它们最大的优势也是它们最大的缺点。

状态机经过使用有约束的结构来理清杂乱的代码。 你只需一个固定状态的集合,单一的当前状态,和一些硬编码的转换。

一个有限状态机甚至不是图灵彻底的。 自动理论用一系列抽象模型描述计算,每种都比以前的复杂。 图灵机 是其中最具备表现力的模型之一。

“图灵彻底”意味着一个系统(一般是编程语言)足以在内部实现一个图灵机, 也就意味着,在某种程度上,全部的图灵彻底具备一样的表现力。 FSMs不够灵活,并不在其中。

若是你须要为更复杂的东西使用状态机,好比游戏AI,你会撞到这个模型的限制上。 感谢上天,咱们的前辈找到了一些方法来避免这些限制。我会在这一章的最后简单地浏览一下它们。

并发状态机

咱们决定赋予英雄拿枪的能力。 当她拿着枪的时候,她仍是能作她以前的任何事情:跑动,跳跃,跳斩,等等。 可是她在作这些的同时也要能开火。

若是咱们执着于FSM,咱们须要翻倍现有状态。 对于每一个现有状态,咱们须要另外一个她持枪状态:站立,持枪站立,跳跃,持枪跳跃, 你知道个人意思了吧。

多加几种武器,状态就会指数爆炸。 不但增长了大量的状态,也增长了大量的冗余: 持枪和不持枪的状态是彻底同样的,只是多了一点负责射击的代码。

问题在于咱们将两种状态绑定到了一个状态机上——作的和她携带的 为了处理全部可能的组合,咱们须要为每一组合写一个状态。 修复方法很明显:使用两个单独的状态机。

若是她在作什么有n个状态,而她携带了什么有m个状态,要塞到一个状态机中, 咱们须要n × m个状态。使用两个状态机,就只有n + m个。

咱们保留以前记录她在作什么的状态机,不用管它。 而后定义她携带了什么的单独状态机。 Heroine将会有两个状态引用,每一个对应一个状态机,就像这样:

class Heroine
{
  // 其余代码……
 
private:
  HeroineState* state_;
  HeroineState* equipment_;
};

为了便于说明,她的装备也使用了状态模式。 在实践中,因为装备只有两个状态,一个布尔标识就够了。

当英雄把输入委托给了状态,两个状态都须要委托:

void Heroine::handleInput(Input input)
{
  state_->handleInput(*this, input);
  equipment_->handleInput(*this, input);
}

功能更完备的系统也许能让状态机销毁输入,这样其余状态机就不会收到了。 这能阻止两个状态机响应同一输入。

每一个状态机以后都能响应输入,发生行为,独立于其它机器改变状态。 当两个状态集合几乎没有联系的时候,它工做得不错。

在实践中,你会发现状态有时须要交互。 举个例子,也许她在跳跃时不能开火,或者她在持枪时不能跳斩攻击。 为了完成这个,你也许会在状态的代码中作一些粗糙的if测试其余状态来协同, 这不是最优雅的解决方案,但这能够搞定工做。

分层状态机

再充实一下英雄的行为,她可能会有更多类似的状态。 举个例子,她也许有站立、行走、奔跑和滑铲状态。在这些状态中,按B跳,按下蹲。

若是使用简单的状态机实现,咱们在每一个状态中的都重复了代码。 若是咱们可以实现一次,在多个状态间重用就行了。

若是这是面向对象的代码而不是状态机的,在状态间分享代码的方式是经过继承。 咱们能够为在地面上定义一个类处理跳跃和速降。 站立、行走、奔跑和滑铲都从它继承,而后增长各自的附加行为。

它的影响有好有坏。 继承是一种有力的代码重用工具,但也在两块代码间创建了很是强的耦合。 这是重锤,因此请当心使用。

你会发现,这是个被称为分层状态机的通用结构。 状态能够有父状态(这让它变为子状态)。 当一个事件进来,若是子状态没有处理,它就会交给链上的父状态。 换言之,它像重载的继承方法那样运做。

事实上,若是咱们使用状态模式实现FSM,咱们能够使用继承来实现层次。 定义一个基类做为父状态:

class OnGroundState : public HeroineState
{
public:
  virtual void handleInput(Heroine& heroine, Input input)
  {
    if (input == PRESS_B)
    {
      // 跳跃……
    }
    else if (input == PRESS_DOWN)
    {
      // 俯卧……
    }
  }
};

每一个子状态继承它:

class DuckingState : public OnGroundState
{
public:
  virtual void handleInput(Heroine& heroine, Input input)
  {
    if (input == RELEASE_DOWN)
    {
      // 站起……
    }
    else
    {
      // 没有处理输入,返回上一层
      OnGroundState::handleInput(heroine, input);
    }
  }
};

这固然不是惟一的实现层次的方法。 若是你没有使用GoF的状态模式,这可能不会有用。 相反,你能够显式的使用状态而不是单一状态来表示当前状态的父状态链。

栈顶的状态是当前状态,在他下面是它的直接父状态, 而后是那个父状态的父状态,以此类推。 当你须要状态的特定行为,你从栈的顶端开始, 而后向下寻找,直到某一个状态处理了它。(若是到底也没找到,就无视它。)

下推自动机

还有一种有限状态机的扩展也用了状态栈。 容易混淆的是,这里的栈表示的是彻底不一样的事物,被用于解决不一样的问题。

要解决的问题是有限状态机没有任何历史的概念。 你记得正在什么状态中,可是不记得曾在什么状态。 没有简单的办法重回上一状态。

举个例子:早先,咱们让无畏英雄武装到了牙齿。 当她开火时,咱们须要新状态播放开火动画,发射子弹,产生视觉效果。 因此咱们拼凑了一个FiringState,无论如今是什么状态,都能在按下开火按钮时跳转为这个状态。

这个行为在多个状态间重复,也许是用层次状态机重用代码的好地方。

问题在于她射击转换到的状态。 她能够在站立、奔跑、跳跃、跳斩时射击。 当射击结束,应该转换为她以前的状态。

若是咱们执拗于纯粹的FSM,咱们就已经忘了她以前所处的状态。 为了追踪以前的状态,咱们定义了不少几乎彻底同样的类——站立开火,跑步开火,跳跃开火,诸如此类—— 每一个都有硬编码的转换,用来回到以前的状态。

咱们真正想要的是,它会存储开火前所处的状态,以后能回想起来。 自动理论又一次能帮上忙了,相关的数据结构被称为下推自动机

有限状态机有一个指向状态的指针,下推自动机有一栈指针。 FSM中,新状态代替了以前的那个状态。 下推自动机不只能完成那个,还能给你两个额外操做:

  1. 你能够将新状态压入栈中。当前的状态老是在栈顶,因此你能转到新状态。 但它让以前的状态待在栈中而不是销毁它。
  2. 你能够弹出最上面的状态。这个状态会被销毁,它下面的状态成为新状态。

这正是咱们开火时须要的。咱们建立单一的开火状态。 当开火按钮在其余状态按下时,咱们压入开火状态。 当开火动画结束,咱们弹出开火状态,而后下推自动机自动转回以前的状态。

因此它们有多有用呢?

即便状态机有这些常见的扩展,它们仍是很受限制。 这让今日游戏AI移向了更加激动人心的领域,好比行为树规划系统  若是你关注复杂AI,这一整章只是为了勾起你的食欲。 你须要阅读其余书来知足你的欲望。

这不意味着有限状态机,下推自动机,和其余简单的系统没有用。 它们是特定问题的好工具。有限状态机在如下状况有用:

  • 你有个实体,它的行为基于一些内在状态。
  • 状态能够被严格地分割为相对较少的不相干项目。
  • 实体响应一系列输入或事件。

在游戏中,状态机因在AI中使用而闻名,可是它也经常使用于其余领域, 好比处理玩家输入,导航菜单界面,分析文字,网络协议以及其余异步行为。

第三章 序列模式

游戏设计模式

电子游戏之因此有趣,很大程度上归功于它们会将咱们带到别的地方。 几分钟后(或者,诚实点,可能会更长),咱们活在一个虚拟的世界。 创造那样的世界是游戏程序员至上的欢愉。

大多数游戏世界都有的特性是时间——虚构世界以其特定的节奏运行。 做为世界的架构师,咱们必须发明时间,制造推进游戏时间运做的齿轮。

本篇的模式是建构这些的工具。 游戏循环是时钟的中心轴。 对象经过更新方法来聆听时钟的滴答声。 咱们能够用双缓冲模式存储快照来隐藏计算机的顺序执行,这样看起来世界能够进行同步更新。

模式

3.1双缓冲模式

游戏设计模式Sequencing Patterns

意图

用序列的操做模拟瞬间或者同时发生的事情。

动机

电脑具备强大的序列化处理能力。 它的力量来自于将大的任务分解为小的步骤,这样能够一步接一步的完成。 可是,一般用户须要看到事情发生在瞬间或者让多个任务同时进行。

使用线程和多核架构让这种说法不那么正确了,但哪怕使用多核,也只有一些操做能够同步运行。

一个典型的例子,也是每一个游戏引擎都得掌控的问题,渲染。 当游戏渲染玩家所见的世界时,它同时须要处理一堆东西——远处的山,起伏的丘陵,树木,每一个都在各自的循环中处理。 若是在用户观察时增量作这些,连续世界的幻觉就会被打破。 场景必须快速流畅地更新,显示一系列完整的帧,每帧都是当即出现的。

双缓冲解决了这个问题,可是为了理解其原理,让咱们首先的复习下计算机是如何显示图形的。

计算机图形系统是如何工做的(概述)

在电脑屏幕上显示图像是一次绘制一个像素点。 它从左到右扫描每行像素点,而后移动至下一行。 当抵达了右下角,它退回左上角从新开始。 它作得飞快——每秒六十次——所以咱们的眼睛没法察觉。 对咱们来讲,这是一整张静态的彩色像素——一张图像。

这个解释是“简化过的”。 若是你是底层软件开发人员,跳过下一节吧。 你对这章的其他部分已经了解得够多了。 若是你不是,这部分的目标是给你足够的背景知识,理解等下要讨论的设计模式。

你能够将整个过程想象为软管向屏幕喷洒像素。 独特的像素从软管的后面流入,而后在屏幕上喷洒,每次对一个像素涂一点颜色。 因此软管怎么知道哪一种颜色要喷到哪里?

在大多数电脑上,答案是从帧缓冲中获知这些信息。 帧缓冲是内存中的色素数组,RAM中每两个字节表明表示一个像素点的颜色。 当软管向屏幕喷洒时,它从这个数组中读取颜色值,每次一个字节。

在字节值和颜色之间的映射一般由系统的像素格式色深来指定。 在今日多数游戏主机上,每一个像素都有32位,红绿蓝三个各占八位,剩下的八位保留做其余用途。

最终,为了让游戏显示在屏幕中,咱们须要作的就是写入这个数组。 咱们疯狂摆弄的图形算法最终都到了这里:设置帧缓冲中的字节值。 但这里有个小问题。

早先,我说过计算机是顺序处理的。 若是机器在运行一块渲染代码,咱们不期望它同时还能作些别的什么事。 这一般是没啥问题,可是有些事确实在程序运行时发生。 其中一件是,当游戏运行时,视频输出正在不断从帧缓冲中读取数据。 这可能会为咱们带来问题。

假设咱们要在屏幕上显示一张笑脸。 程序在帧缓冲上开始循环,为像素点涂色。 咱们没有意识到的是,在写入的同时,视频驱动正在读取它。 当它扫描过已写的像素时,笑脸开始浮现,可是以后它进入了未写的部分,就将没有写的像素绘制到了屏幕上。结果就是撕裂,你在屏幕上看到了绘制到一半的图像,这是可怕的视觉漏洞。

显卡设备读取的缓冲帧正是咱们绘制像素的那块(Fig. 1)。 显卡最终追上了渲染器,而后越过它,读取了尚未写入的像素(Fig. 2)。 咱们完成了绘制,但驱动没有收到那些新像素。

结果(Fig. 4)是用户只看到了一半的绘制结果。 我称它为“哭脸”,笑脸看上去下半部是撕裂的。

这就是咱们须要这个设计模式的缘由。 程序一次渲染一个像素,可是显示须要一次所有看到——在这帧中啥也没有,下一帧笑脸所有出现。 双缓冲解决了这个问题。我会用类比来解释。

表演1,场景1

想象玩家正在观看咱们的表演。 在场景一结束而场景二开始时,咱们须要改变舞台设置。 若是让场务在场景结束后进去拖动东西,场景的连贯性就被打破了。 咱们能够减弱灯光(这是剧院实际上的作法),可是观众仍是知道有什么在进行,而咱们想在场景间毫无跳跃地转换。

经过消耗一些地皮,咱们想到了一个聪明的解决方案:建两个舞台,观众两个都能看到。 每一个有它本身的一组灯光。咱们称这些舞台为舞台A和舞台B 场景一在舞台A上。同时场务在处于黑暗之中的舞台B布置场景二。 当场景一完成后,将切断场景A的灯光,打开场景B的灯光。观众看向新舞台,场景二当即开始。

同时,场务到了黑咕隆咚的舞台A,收拾了场景一而后布置场景 一旦场景二结束,将灯光转回舞台A 咱们在整场表演中进行这样的活动,使用黑暗的舞台做为布置下一场景的工做区域。 每一次场景转换,只是在两个舞台间切换灯光。 观众得到了连续的体验,场景转换时没有感到任何中断。他们历来没有见到场务。

使用单面镜以及其余的巧妙布置,你能够真正地在同一位置布置两个舞台。 随着灯光切换,观众看到了不一样的舞台,无需看向不一样的地方。 如何这样布置舞台就留给读者作练习吧。

从新回到图形

这就是双缓冲的工做原理, 这就是你看到的几乎每一个游戏背后的渲染系统。 不仅用一个帧缓冲,咱们用两个。其中一个表明如今的帧,即类比中的舞台A,也就是说是显卡读取的那一个。 GPU能够想何时扫就何时扫。

但不是全部的游戏主机都是这么作的。 更老的简单主机中,内存有限,须要当心地同步绘制和渲染。那很须要技巧。

同时,咱们的渲染代码正在写入另外一个帧缓冲。 即黑暗中的舞台B。当渲染代码完成了场景的绘制,它将经过交换缓存来切换灯光。 这告诉图形硬件开始从第二块缓存中读取而不是第一块。 只要在刷新以前交换,就不会有任何撕裂出现,整个场景都会一会儿出现。

这时能够使用之前的帧缓冲了。咱们能够将下一帧渲染在它上面了。超棒!

模式

定义缓冲类封装了缓冲:一段可改变的状态。 这个缓冲被增量地修改,但咱们想要外部的代码将修改视为单一的原子操做。 为了实现这点,类保存了两个缓冲的实例:下一缓冲当前缓冲

当信息缓冲区中读取,它老是读取当前的缓冲区。 当信息须要写缓存,它老是在下一缓冲区上操做。 当改变完成后,一个交换操做会马上将当前缓冲区和下一缓冲区交换, 这样新缓冲区就是公共可见的了。旧的缓冲区成为下一个重用的缓冲区。

什么时候使用

这是那种你须要它时天然会想起的模式。 若是你有一个系统须要双缓冲,它可能有可见的错误(撕裂之类的)或者行为不正确。 可是,当你须要时天然会想起没提提供太多有效信息。 更加特殊地,如下状况都知足时,使用这个模式就很恰当:

  • 咱们须要维护一些被增量修改的状态。
  • 在修改到一半的时候,状态可能会被外部请求。
  • 咱们想要防止请求状态的外部代码知道内部的工做方式。
  • 咱们想要读取状态,并且不想等着修改完成。

记住

不像其余较大的架构模式,双缓冲模式位于底层。 正因如此,它对代码库的其余部分影响较小——大多数游戏甚至不会感到有区别。 尽管这里仍是有几个警告。

交换自己须要时间

在状态被修改后,双缓冲须要一个swap步骤。 这个操做必须是原子的——在交换时,没有代码能够接触到任何一个状态。 一般,这就是修改一个指针那么快,可是若是交换消耗的时间长于修改状态的时间,那但是毫无助益。

咱们得保存两个缓冲区

这个模式的另外一个结果是增长了内存的使用。 正如其名,这个模式须要你在内存中一直保留两个状态的拷贝。 在内存受限的设备上,你可能要付出惨痛的代价。 若是你不能接受使用两分内存,你须要使用别的方法保证状态在修改时不会被请求。

示例代码

咱们知道了理论,如今看看它在实践中如何应用。 咱们编写了一个很是基础的图形系统,容许咱们在缓冲帧上描绘像素。 在大多数主机和电脑上,显卡驱动提供了这种底层的图形系统, 可是在这里手动实现有助于理解发生了什么。首先是缓冲区自己:

class Framebuffer
{
public:
  Framebuffer() { clear(); }
 
  void clear()
  {
    for (int i = 0; i < WIDTH * HEIGHT; i++)
    {
      pixels_[i] = WHITE;
    }
  }
 
  void draw(int x, int y)
  {
    pixels_[(WIDTH * y) + x] = BLACK;
  }
 
  const char* getPixels()
  {
    return pixels_;
  }
 
private:
  static const int WIDTH = 160;
  static const int HEIGHT = 120;
 
  char pixels_[WIDTH * HEIGHT];
};

它有将整个缓存设置成默认的颜色的操做,也将其中一个像素设置为特定颜色的操做。 它也有函数getPixels(),读取保存像素数据的数组。 虽然在这个例子中没有出现,但在实际中,显卡驱动会频繁调用这个函数,将缓存中的数据输送到屏幕上。

咱们将整个缓冲区封装在Scene类中。渲染某物须要作的是在这块缓冲区上调用一系列draw()

class Scene
{
public:
  void draw()
  {
    buffer_.clear();
 
    buffer_.draw(1, 1);
    buffer_.draw(4, 1);
    buffer_.draw(1, 3);
    buffer_.draw(2, 4);
    buffer_.draw(3, 4);
    buffer_.draw(4, 3);
  }
 
  Framebuffer& getBuffer() { return buffer_; }
 
private:
  Framebuffer buffer_;
};

特别地,它画出来这幅旷世杰做:

每一帧,游戏告诉场景去绘制。场景清空缓冲区而后一个接一个绘制一大堆像素。 它也提供了getBuffer()得到缓冲区,这样显卡能够接触到它。

这看起来直截了当,可是若是就这样作,咱们会遇到麻烦。 显卡驱动能够在任何时间调用getBuffer(),甚至在这个时候:

buffer_.draw(1, 1);
buffer_.draw(4, 1);
// <- 图形驱动从这里读取像素!
buffer_.draw(1, 3);
buffer_.draw(2, 4);
buffer_.draw(3, 4);
buffer_.draw(4, 3);

当上面的状况发生时,用户就会看到脸的眼睛,可是这一帧中嘴却消失了。 下一帧,又可能在某些别的地方发生冲突。最终结果是糟糕的闪烁图形。咱们会用双缓冲修复这点:

class Scene
{
public:
  Scene()
  : current_(&buffers_[0]),
    next_(&buffers_[1])
  {}
 
  void draw()
  {
    next_->clear();
 
    next_->draw(1, 1);
    // ...
    next_->draw(4, 3);
 
    swap();
  }
 
  Framebuffer& getBuffer() { return *current_; }
 
private:
  void swap()
  {
    // 只需交换指针
    Framebuffer* temp = current_;
    current_ = next_;
    next_ = temp;
  }
 
  Framebuffer  buffers_[2];
  Framebuffer* current_;
  Framebuffer* next_;
};

如今Scene有存储在buffers_数组中的两个缓冲区,。 咱们并不从数组中直接引用它们。而是经过两个成员,next_current_,指向这个数组。 当绘制时,咱们绘制在next_指向的缓冲区上。 当显卡驱动须要得到像素信息时,它老是经过current_获取另外一个缓冲区。

经过这种方式,显卡驱动永远看不到咱们正在施工的缓冲区。 解决方案的的最后一部分就是在场景完成绘制一帧的时候调用swap() 它经过交换next_current_的引用完成这一点。 下一次显卡驱动调用getBuffer(),它会得到咱们刚刚完成渲染的新缓冲区, 而后将刚刚描绘好的缓冲区放在屏幕上。没有撕裂,也没有不美观的问题。

不只是图形

双缓冲解决的核心问题是状态有可能在被修改的同时被请求。 这一般有两种缘由。图形的例子覆盖了第一种缘由——另外一线程的代码或者另外一个中断的代码直接访问了状态。

可是,还有一个一样常见的缘由:负责修改的 代码试图访问一样正在修改状态。 这可能发生在不少地方,特别是实体的物理部分和AI部分,实体在相互交互。 双缓冲在那里也十分有用。

人工不智能

假设咱们正在构建一个关于趣味喜剧的游戏的行为系统。 这个游戏包括一堆跑来跑去寻欢做乐的角色。这里是咱们的基础角色:

class Actor
{
public:
  Actor() : slapped_(false) {}
 
  virtual ~Actor() {}
  virtual void update() = 0;
 
  void reset()      { slapped_ = false; }
  void slap()       { slapped_ = true; }
  bool wasSlapped() { return slapped_; }
 
private:
  bool slapped_;
};

每一帧,游戏要在角色身上调用update(),让角色作些事情。 特别地,从玩家的角度,全部的角色都应该看上去同时更新

这是更新方法模式的例子。

角色也能够相互交互,这里的交互,我指能够互相扇对方巴掌 当更新时,角色能够在另外一个角色身上调用slap()来扇它一巴掌,而后调用wasSlapped()看看本身是否是被扇了。

角色须要一个能够交互的舞台,让咱们来布置一下:

class Stage
{
public:
  void add(Actor* actor, int index)
  {
    actors_[index] = actor;
  }
 
  void update()
  {
    for (int i = 0; i < NUM_ACTORS; i++)
    {
      actors_[i]->update();
      actors_[i]->reset();
    }
  }
 
private:
  static const int NUM_ACTORS = 3;
 
  Actor* actors_[NUM_ACTORS];
};

Stage容许咱们向其中增长角色, 而后使用简单的update()调用来更新每一个角色。 在用户看来,角色是同时移动的,可是实际上,它们是依次更新的。

这里须要注意的另外一点是,每一个角色的被扇状态在更新后就马上被清除。 这样才能保证一个角色对一巴掌只反应一次。

做为一切的开始,让咱们定义一个具体的角色子类。 这里的喜剧演员很简单。 他只面向一个角色。当他被扇时——不管是谁扇的他——他的反应是扇他面前的人一巴掌。

class Comedian : public Actor
{
public:
  void face(Actor* actor) { facing_ = actor; }
 
  virtual void update()
  {
    if (wasSlapped()) facing_->slap();
  }
 
private:
  Actor* facing_;
};

如今咱们把一些喜剧演员丢到舞台上看看发生了什么。 咱们设置三个演员,第一个面朝第二个,第二个面朝第三个,第三个面对第一个,造成一个环:

Stage stage;
 
Comedian* harry = new Comedian();
Comedian* baldy = new Comedian();
Comedian* chump = new Comedian();
 
harry->face(baldy);
baldy->face(chump);
chump->face(harry);
 
stage.add(harry, 0);
stage.add(baldy, 1);
stage.add(chump, 2);

最终舞台布置以下图。箭头表明角色的朝向,而后数字表明角色在舞台数组中的索引。

咱们扇哈利一巴掌,为表演拉开序幕,看看以后会发生什么:

harry->slap();
 
stage.update();

记住Stage中的update()函数轮流更新每一个角色, 所以若是检视整个代码,咱们会发现事件这样发生:

Stage updates actor 0 (Harry)
  Harry was slapped, so he slaps Baldy
Stage updates actor 1 (Baldy)
  Baldy was slapped, so he slaps Chump
Stage updates actor 2 (Chump)
  Chump was slapped, so he slaps Harry
Stage update ends

在单独的一帧中,初始给哈利的一巴掌传给了全部的喜剧演员。 如今,让事物复杂起来,让咱们从新排列舞台数组中角色的排序, 可是继续保持面向对方的方式。

咱们不动舞台的其他部分,只是将添加角色到舞台的代码块改成以下:

stage.add(harry, 2);
stage.add(baldy, 1);
stage.add(chump, 0);

让咱们看看再次运行时会发生什么:

Stage updates actor 0 (Chump)
  Chump was not slapped, so he does nothing
Stage updates actor 1 (Baldy)
  Baldy was not slapped, so he does nothing
Stage updates actor 2 (Harry)
  Harry was slapped, so he slaps Baldy
Stage update ends

哦不。彻底不同了。问题很明显。 更新角色时,咱们修改了他们的被扇状态,这也是咱们在更新时读取的状态。 所以,在更新中早先的状态修改会影响以后同一状态的修改的步骤。

若是你继续更新舞台,你会看到巴掌在角色间逐渐传递,每帧传递一个。 在第一帧 Harry扇了Baldy。下一帧,Baldy扇了Chump,如此类推。

而最终的结果是,一个角色对被扇做出反应多是在被扇的同一帧或者下一帧, 这彻底取决于两个角色在舞台上是如何排序的。 这没能知足我让角色同时反应的需求——它们在同一帧中更新的顺序不应对结果有影响。

缓存巴掌

幸运的是,双缓冲模式能够帮忙。 此次,不是保存两大块缓冲,咱们缓冲更小粒度的事物:每一个角色的被扇状态。

class Actor
{
public:
  Actor() : currentSlapped_(false) {}
 
  virtual ~Actor() {}
  virtual void update() = 0;
 
  void swap()
  {
    // 交换缓冲区
    currentSlapped_ = nextSlapped_;
 
    // 清空新的下一个缓冲区。.
    nextSlapped_ = false;
  }
 
  void slap()       { nextSlapped_ = true; }
  bool wasSlapped() { return currentSlapped_; }
 
private:
  bool currentSlapped_;
  bool nextSlapped_;
};

再也不使用一个slapped_状态,每一个演员如今使用两个。 就像咱们以前图形的例子同样,当前状态为读准备,下一状态为写准备。

reset()函数被替换为swap() 如今,就在清除交换状态前,它将下一状态拷贝到当前状态上, 使其成为新的当前状态,这还须要在Stage中进行小小的改变:

void Stage::update()
{
  for (int i = 0; i < NUM_ACTORS; i++)
  {
    actors_[i]->update();
  }
 
  for (int i = 0; i < NUM_ACTORS; i++)
  {
    actors_[i]->swap();
  }
}

update()函数如今更新全部的角色,而后 交换它们的状态。 最终结果是,角色在实际被扇以后的那帧才能看到巴掌。 这样一来,角色不管在舞台数组中如何排列,都会保持相同的行为。 不管外部的代码如何调用,全部的角色在一帧内同时更新。

设计决策

双缓冲很直观,咱们上面看到的例子也覆盖了大多数你须要的场景。 使用这个模式以前,还须要作两个主要的设计决策。

缓冲区是如何被交换的?

交换操做是整个过程的最重要的一步, 由于在其发生时,咱们必须锁住两个缓冲区上的读取和修改。 为了让性能最优,咱们须要它进行得越快越好。

  • 交换缓冲区的指针或者引用: 这是咱们图形例子中的作法,这也是大多数双缓冲图形通用的解决方法。
    • 速度快。 无论缓冲区有多大,交换都只需赋值一对指针。很难在速度和简易性上超越它。
    • 外部代码不能存储对缓存的永久指针。 这是主要限制。 因为咱们没有真正地移动数据,本质上作的是周期性地通知代码库的其余部分到别处去寻找缓存, 就像前面的舞台类比同样。这就意味着代码库的其余部分不能存储指向缓冲区中数据的指针—— 它一段时间后可能就指向了错误的部分。

这会严重误导那些期待缓冲帧永远在内存中的固定地址的显卡驱动。在这种状况下,咱们不能这么作。

    • 缓冲区中的数据是两帧以前的数据,而不是上一帧的数据。 接下来的那帧绘制在帧缓冲区上,而不是在它们之间拷贝数据,就像这样:
    • Frame 1 drawn n buffer A
    • Frame 2 drawn n buffer B
    • Frame 3 drawn n buffer A
    • ...

你会注意到,当咱们绘制第三帧时,缓冲区上的数据是第一帧的,而不是第二帧的。大多数状况下,这不是什么问题——咱们一般在绘制以前清空整个帧。但若是想沿用某些缓存中已有的数据,就须要考虑数据其实比指望的更旧。

旧帧中缓存数据的经典用法是模拟动态模糊。 当前的帧混合一点以前的帧,看起来更像真实的相机捕获的图景。

  • 在缓冲区之间拷贝数据: 若是咱们不能重定向到其余缓存,惟一的选项就是将下帧的数据实实在在的拷贝到如今这帧上。 这是咱们的扇巴掌喜剧的工做方法。 这种状况下,使用这种方法是由于拷贝状态——一个简单的布尔标识——不比修改指向缓存的指针开销大。
    • 下一帧的数据和以前的数据相差一帧。 拷贝数据与在两块缓冲区间跳来跳去正相反。 若是咱们须要前一帧的数据,这样咱们能够处理更新的数据。
    • 交换也许更花时间。 这个固然是最大的缺点。交换操做如今意味着在内存中拷贝整个缓冲区。 若是缓冲区很大,好比一整个缓冲帧,这须要花费可观的时间。 因为交换时没有东西能够读取或者写入任何一个缓冲区,这是一个巨大的限制。

缓冲的粒度如何?

这里的另外一个问题是缓冲区自己是如何组织的——是单个数据块仍是散布在对象集合中? 图形例子是前一种,而角色例子是后一种。

大多数状况下,你缓存的方式天然而然会引导你找到答案,可是这里也有些灵活度。 好比,角色总能将消息存在独立的消息块中,使用索引来引用。

  • 若是缓存是一整块:
    • 交换操做更简单。 因为只有一对缓存,一个简单的交换就完成了。 若是能够改变指针来交换,那么没必要在乎缓冲区大小,只需几部操做就能够交换整个缓冲区。
  • 若是不少对象都持有一块数据:
    • 交换操做更慢。 为了交换,须要遍历整个对象集合,通知每一个对象交换。

在喜剧的例子中,这没问题,由于反正须要清除被扇状态 ——每块缓存的数据每帧都须要接触。 若是不须要接触较旧的帧,能够用经过在多个对象间分散状态来优化,得到使用整块缓存同样的性能。

思路是将当前下一指针概念,将它们改成对象相关的偏移量。就像这样:

class Actor
{
public:
  static void init() { current_ = 0; }
  static void swap() { current_ = next(); }
 
  void slap()        { slapped_[next()] = true; }
  bool wasSlapped()  { return slapped_[current_]; }
 
private:
  static int current_;
  static int next()  { return 1 - current_; }
 
  bool slapped_[2];
};

角色使用current_在状态数组中查询,得到当前的被扇状态, 下一状态老是数组中的另外一索引,这样能够用next()来计算。 交换状态只需改动current_索引。 聪明之处在于swap()如今是静态函数,它只需被调用一次,每一个 角色的状态都会被交换。

参见

  • 你能够在几乎每一个图形API中找到双缓冲模式。举个例子,OpenGLswapBuffers()Direct3D”swap chains”, MicrosoftXNA框架有endDraw()方法。

3.2游戏循环

游戏设计模式Sequencing Patterns

意图

将游戏的进行和玩家的输入解耦,和处理器速度解耦。

动机

若是本书中有一个模式不可或缺,那非这个模式莫属了。 游戏循环是游戏编程模式的精髓。 几乎每一个游戏都有,两两不一样,而在非游戏的程序几乎没有使用。

为了看看它多有用,让咱们快速缅怀一遍往事。 在每一个编写计算机程序的人都留着胡子的时代,程序像洗碗机同样工做。 你输入一堆代码,按个按钮,等待,而后得到结果,完成。 程序全都是批处理模式——一旦工做完成,程序就中止了。

Ada Lovelace和Rear Admiral Grace Hopper是女程序员,并无胡子。

你在今日仍然能看到这些程序,虽然感谢上天,咱们没必要在打孔纸上面编写它们了。 终端脚本,命令行程序,甚至将Markdown翻译成这本书的Python脚本都是批处理程序。

采访CPU

最终,程序员意识到将批处理代码留在计算办公室,等几个小时后拿到结果才能开始找程序漏洞的方式实在低效。 他们想要当即的反馈。交互式 程序诞生了。 第一批交互式程序中就有游戏:

YOU ARE STANDING AT THE END OF A ROAD BEFORE A SMALL BRICK
BUILDING . AROUND YOU IS A FOREST. A SMALL
STREAM FLOWS OUT OF THE BUILDING AND DOWN A GULLY.
 
> GO IN
YOU ARE INSIDE A BUILDING, A WELL HOUSE FOR A LARGE SPRING.

这是Colossal Cave Adventure,史上首个冒险游戏。

你能够和这个程序进行实时交互。 它等待你的输入,而后进行响应。 你再输入,这样一唱一和,就像相声同样。 当轮到你时,它停在那里啥也不作。像这样:

while (true)
{
  char* command = readCommand();
  handleCommand(command);
}

这程序会永久循环,因此无法退出游戏。 真实的游戏会作些while (!done)进行检查,而后经过设置done为真来退出游戏。 我省去了那些内容,保持简明。

事件循环

若是你剥开现代的图形UI的外皮,会惊讶地发现它们与老旧的冒险游戏差很少。 文本处理器一般呆在那里什么也不作,直到你按了个键或者点了什么东西:

while (true)
{
  Event* event = waitForEvent();
  dispatchEvent(event);
}

这与冒险游戏主要的不一样是,程序不是等待文本指令,而是等待用户输入事件——鼠标点击、按键按下之类的。 其余部分仍是和之前的老式文本冒险游戏同样,程序阻塞等待用户的输入,这是个问题。

不像其余大多数软件,游戏即便在没有玩家输入时也继续运行。 若是你站在那里看着屏幕,游戏不会冻结。动画继续动着。视觉效果继续闪烁。 若是运气很差的话,怪物会继续吞噬英雄。

事件循环有“空转”事件,这样你能够无需用户输入间歇地作些事情。 这对于闪烁的光标或者进度条已经足够了,但对于游戏就太原始了。

这是真实游戏循环的第一个关键部分:它处理用户输入,可是不等待它。循环老是继续旋转:

while (true)
{
  processInput();
  update();
  render();
}

咱们以后会改善它,可是基本的部分都在这里了。 processInput()处理上次调用到如今的任何输入。 而后update()让游戏模拟一步。 运行AI和物理(一般是这种顺序)。 最终,render()绘制游戏,这样玩家能够看到发生了什么。

就像你能够从名字中猜到的,update()是使用更新方法模式的好地方。

时间以外的世界

若是这个循环没有由于输入而阻塞,这就带来了明显的问题,要运转多快呢? 每次进行游戏循环都会推进必定的游戏状态的发展。 在游戏世界的居民看来,他们手上的表就会滴答一下。

运行游戏循环一次的经常使用术语就是“滴答”(tick)和“帧”(frame)。

同时,玩家的真实手表也在滴答着。 若是咱们用实际时间来测算游戏循环运行的速度,就获得了游戏的帧率”(FPS) 若是游戏循环的更快,FPS就更高,游戏运行得更流畅、更快。 若是循环得过慢,游戏看上去就像是慢动做电影。

咱们如今写的这个循环是能转多快转多快,两个因素决定了帧率。 一个是每帧要作多少工做。复杂的物理,众多游戏对象,图形细节都让CPUGPU繁忙,这决定了须要多久能完成一帧。

另外一个是底层平台的速度。 更快的芯片能够在一样的时间里执行更多的代码。 多核,GPU组,独立声卡,以及系统的调度都影响了在一次滴答中可以作多少东西。

每秒的帧数

在早期的视频游戏中,第二个因素是固定的。 若是你为NES或者Apple IIe写游戏,你明确知道游戏运行在什么CPU上。 你能够(也必须)为它特制代码。 你只需担心第一个因素:每次滴答要作多少工做。

早期的游戏被仔细地编码,一帧只作必定的工做,开发者能够让游戏以想要的速率运行。 可是若是你想要在快些或者慢些的机器上运行同一游戏,游戏自己就会加速或减速。

这就是为何老式计算机一般有“turbo”按钮。 新的计算机运行得太快了,没法玩老游戏,由于游戏也会运行得过快。 关闭 turbo按钮,会减慢计算机的运行速度,就能够运行老游戏了。

如今,不多有开发者能够奢侈地知道游戏运行的硬件条件。游戏必须自动适应多种设备。

这就是游戏循环的另外一个关键任务:无论潜在的硬件条件,以固定速度运行游戏。

模式

一个游戏循环在游玩中不断运行。 每一次循环,它无阻塞地处理玩家输入更新游戏状态渲染游戏 它追踪时间的消耗并控制游戏的速度。

什么时候使用

使用错误的模式比不使用模式更糟,因此这节一般告诫你不要过于热衷设计模式。 设计模式的目标不是往代码库里尽量的塞东西。

可是这个模式有所不一样。我能够很自信的说你使用这个模式。 若是你使用游戏引擎,你不须要本身编写,可是它还在那里。

对于我而言,这是“引擎”与“库”的不一样之处。 使用库时,你拥有游戏循环,调用库代码。 使用引擎时,引擎拥有游戏循环,调用你的代码。

你可能认为在作回合制游戏时不须要它。 可是哪怕是那里,就算游戏状态到玩家回合才改变,视觉听觉 状态仍会改变。 哪怕游戏在等待你进行你的回合,动画和音乐也会继续运行。

记住

咱们这里谈到的循环是游戏代码中最重要的部分。 有人说程序会花费90%的时间在10%的代码上。 游戏循环代码确定在这10%中。 你必须当心谨慎,时时注意效率。

“真正的”工程师,好比机械或电子工程师,不把咱们当回事,大概就是由于咱们像这样使用统计学。

你也许须要与平台的事件循环相协调

若是你在操做系统的顶层或者有图形UI和内建事件循环的平台上构建游戏, 那你就有了两个应用循环在同时运做。 它们须要很好地协调。

有时候,你能够进行控制,只运行你的游戏循环。 举个例子,若是舍弃了Windows的珍贵APImain()能够只用游戏循环。 其中你能够调用PeekMessage()来处理和分发系统的事件。 不像GetMessage()PeekMessage()不会阻塞等待用户输入, 所以你的游戏循环会保持运做。

其余的平台不会让你这么轻松地摆脱事件循环。 若是你使用网页浏览器做为平台,事件循环已被内建在浏览器的执行模型深处。 这样,你得用事件循环做为游戏循环。 你会调用requestAnimationFrame()之类的函数,它会回调你的代码,保持游戏继续运行。

示例代码

在如此长的介绍以后,游戏循环的代码实际上很直观。 咱们会浏览一堆变种,比较它们的好处和坏处。

游戏循环驱动了AI,渲染和其余游戏系统,但这些不是模式的要点, 因此咱们会调用虚构的方法。在实现了render()update()以后, 剩下的做为给读者的练习(挑战!)。

跑,能跑多快跑多快

咱们已经见过了多是最简单的游戏循环:

while (true)
{
  processInput();
  update();
  render();
}

它的问题是你不能控制游戏运行得有多快。 在快速机器上,循环会运行得太快,玩家看不清发生了什么。 在慢速机器上,游戏慢的跟在爬同样。 若是游戏的一部分有大量内容或者作了不少AI或物理运算,游戏就会慢一些。

休息一下

咱们看看增长一个简单的小修正如何。 假设你想要你的游戏以60FPS运行。这样每帧大约16毫秒。 只要你用少于这个的时长进行游戏全部的处理和渲染,就能够以稳定的帧率运行。 你须要作的就是处理这一帧而后等待,直处处理下一帧的时候,就像这样:

代码看上去像这样:

1000 毫秒 / 帧率 = 毫秒每帧.

while (true)
{
  double start = getCurrentTime();
  processInput();
  update();
  render();
 
  sleep(start + MS_PER_FRAME - getCurrentTime());
}

若是它很快地处理完一帧,这里的sleep()保证了游戏不会运行太 若是你的游戏运行太,这无济于事。 若是须要超过16ms来更新并渲染一帧,休眠的时间就变成了负的 若是计算机能回退时间,不少事情就很容易了,可是它不能。

相反,游戏变慢了。 能够经过每帧少作些工做来解决这个问题——减小物理效果和绚丽光影,或者把AI变笨。 可是这影响了那些有快速机器的玩家的游玩体验。

一小步,一大步

让咱们尝试一些更加复杂的东西。咱们拥有的问题基本上是:

  1. 每次更新将游戏时间推进一个固定量。
  2. 这消耗必定量的真实时间来处理它。

若是第二步消耗的时间超过第一步,游戏就变慢了。 若是它须要超过16ms来推进游戏时间16ms,那它永远也跟不上。 可是若是一步中推进游戏时间超过16ms,那咱们能够减小更新频率,就能够跟得上了。

接着的思路是基于上帧到如今有多少真实时间流逝来选择前进的时间。 这一帧花费的时间越长,游戏的间隔越大。 它总能跟上真实时间,由于它走的步子愈来愈大。 有人称之为变化的或者流动的时间间隔。它看上去像是:

double lastTime = getCurrentTime();
while (true)
{
  double current = getCurrentTime();
  double elapsed = current - lastTime;
  processInput();
  update(elapsed);
  render();
  lastTime = current;
}

每一帧,咱们计算上次游戏更新到如今有多少真实时间过去了(即变量elapsed)。 当咱们更新游戏状态时将其传入。 而后游戏引擎让游戏世界推动必定的时间量。

假设有一颗子弹跨过屏幕。 使用固定的时间间隔,在每一帧中,你根据它的速度移动它。 使用变化的时间间隔,你根据过去的时间拉伸速度 随着时间间隔增长,子弹在每帧间移动得更远。 不管是二十个快的小间隔仍是四个慢的大间隔,子弹在真实时间里移动一样多的距离。 这看上去成功了:

  • 游戏在不一样的硬件上以固定的速度运行。
  • 使用高端机器的玩家得到了更流畅的游戏体验。

但悲剧的是,这里有一个严重的问题: 游戏再也不是肯定的了,也再也不稳定。 这是咱们给本身挖的一个坑:

“肯定的”表明每次你运行程序,若是给了它一样的输入,就得到一样的输出。 能够想获得,在肯定的程序中追踪漏洞更容易——一旦找到形成漏洞的输入,每次你都能重现之。

计算机自己是肯定的;它们机械地执行程序。 在纷乱的真实世界搀合进来,非肯定性就出现了。 例如,网络,系统时钟,线程调度都依赖于超出程序控制的外部世界。

假设咱们有个双人联网游戏,Fred的游戏机是台性能猛兽,而George正在使用他祖母的老爷机。 前面提到的子弹在他们的屏幕上飞行。 Fred的机器上,游戏跑得超级快,每一个时间间隔都很小。 好比,咱们塞了50帧在子弹穿过屏幕的那一秒。 可怜的George的机器只能塞进大约5帧。

这就意味着在Fred的机器上,物理引擎每秒更新50次位置,可是George的只更新5次。 大多数游戏使用浮点数,它们有舍入偏差 每次你将两个浮点数加在一块儿,得到的结果就会有点误差。 Fred的机器作了10倍的操做,因此他的偏差要比George的更大。 一样 的子弹最终在他们的机器上到了不一样的位置

这是使用变化时间可引发的问题之一,还有更多问题呢。 为了实时运行,游戏物理引擎作的是实际机制法则的近似。 为了不飞天遁地,物理引擎添加了阻尼。 这个阻尼运算被当心地安排成以固定的时间间隔运行。 改变了它,物理就再也不稳定。

“飞天遁地”在这里使用的是它的字面意思。当物理引擎卡住,对象得到了彻底错误的速度,就会飞到天上或者掉入地底。

这种不稳定性太糟了,这个例子在这里的惟一缘由是做为警示寓言,引领咱们到更好的东西……

追逐时间

游戏中渲染一般不会被动态时间间隔影响到。 因为渲染引擎表现的是时间上的一瞬间,它不会计算上次到如今过了多久。 它只是将当前事物渲染在所在的地方。

这或多或少是成立的。像动态模糊的东西会被时间间隔影响,但若是有一点延迟,玩家一般也不会注意到。

咱们能够利用这点。 以固定的时间间隔更新游戏,由于这让全部事情变得简单,物理和AI也更加稳定。 可是咱们容许灵活调整渲染的时刻,释放一些处理器时间。

它像这样运做:自上一次游戏循环过去了必定量的真实时间。 须要为游戏的当前时间模拟推动相同长度的时间,以追上玩家的时间。 咱们使用一系列固定时间步长。 代码大体以下:

double previous = getCurrentTime();
double lag = 0.0;
while (true)
{
  double current = getCurrentTime();
  double elapsed = current - previous;
  previous = current;
  lag += elapsed;
 
  processInput();
 
  while (lag >= MS_PER_UPDATE)
  {
    update();
    lag -= MS_PER_UPDATE;
  }
 
  render();
}

这里有几个部分。 在每帧的开始,根据过去了多少真实的时间,更新lag 这个变量代表了游戏世界时钟比真实世界落后了多少,而后咱们使用一个固定时间步长的内部循环进行追赶。 一旦咱们追上真实时间,咱们就渲染而后开始新一轮循环。 你能够将其画成这样:

注意这里的时间步长不是视觉上的帧率了。 MS_PER_UPDATE只是咱们更新游戏的间隔 这个间隔越短,就须要越多的处理次数来追上真实时间。 它越长,游戏抖动得越厉害。 理想上,你想要它足够短,一般快过60FPS,这样游戏在高速机器上会有高效的表现。

可是当心不要把它整得短了。 你须要保证即便在最慢的机器上,这个时间步长也超过处理一次update()的时间。 不然,你的游戏就跟不上现实时间了。

我不会详谈这个,但你能够经过限定内层循环的最大次数来保证这一点。 游戏会变慢,可是比彻底卡死要好。

幸运的是,咱们给本身了一些喘息的空间。 技巧在于咱们将渲染拉出了更新循环 这释放了一大块CPU时间。 最终结果是游戏以固定时间步长模拟,该时间步长与硬件不相关。 只是使用低端硬件的玩家看到的内容会有抖动。

卡在中间

咱们还剩一个问题,就是剩下的延迟。 以固定的时间步长更新游戏,在任意时刻渲染。 这就意味着从玩家的角度看,游戏常常在两次更新之间时显示。

这是时间线:

就像你看到的那样,咱们以紧凑固定的时间步长进行更新。 同时,咱们在任何可能的时候渲染。 它比更新发生得要少,并且也不稳定。 二者都没问题。糟糕的是,咱们不总能在正确的时间点渲染。 看看第三次渲染时间。它发生在两次更新之间。

想象一颗子弹飞过屏幕。第一次更新时,它在左边。 第二次更新将它移到了右边。 这个游戏在两次更新之间的时间点渲染,因此玩家指望看到子弹在屏幕的中间。 而如今的实现中,它还在左边。这意味着看上去移动发生了卡顿。

方便的是,咱们实际知道渲染时距离两次更新的时间:它被存储在lag中。 咱们在lag比更新时间间隔小时,而不是lag时,跳出循环进行渲染。 lag的剩余量?那就是到下一帧的时间。

当咱们要渲染时,咱们将它传入:

render(lag / MS_PER_UPDATE);

咱们在这里除以MS_PER_UPDATE归一化值。 无论更新的时间步长是多少,传给render()的值总在0(恰巧在前一帧)到1.0(恰巧在下一帧)之间。 这样,渲染引擎没必要担忧帧率。它只需处理0到1的值。

渲染器知道每一个游戏对象以及它当前的速度 假设子弹在屏幕左边20像素的地方,正在以400像素每帧的速度向右移动。 若是在两帧正中渲染,咱们会给render()0.5 它绘制了半帧以前的图形,在220像素,啊哈,平滑的移动。

固然,也许这种推断是错误的。 在咱们计算下一帧时,也许会发现子弹碰撞到另外一障碍,或者减速,又或者别的什么。 咱们只是在上一帧位置和咱们认为的下一帧位置之间插值。 但只有在完成物理和AI更新后,咱们才能知道真正的位置。

因此推断有猜想的成分,有时候结果是错误的。 可是,幸运地,这种修正一般不可感知。 最起码,比你不使用推断致使的卡顿更不明显。

设计决策

虽然这章我讲了不少,可是有更多的东西我没讲。 一旦你考虑显示刷新频率的同步,多线程,多GPU,真正的游戏循环会变得更加复杂。 即便在高层,这里还有一些问题须要你回答:

拥有游戏循环的是你,仍是平台?

这个选择一般是已经由平台决定的。 若是你在作浏览器中的游戏,极可能你不能编写本身的经典游戏循环。 浏览器自己的事件驱动机制阻碍了这一点。 相似地,若是你使用现存的游戏引擎,你极可能依赖于它的游戏循环而不是本身写一个。

  • 使用平台的事件循环:
    • 简单。你没必要担忧编写和优化本身的游戏核心循环。
    • 平台友好。 你没必要明确地给平台一段时间让它处理它本身的事件,没必要缓存事件,没必要管理任何平台输入模型和你的不匹配之处。
    • 你失去了对时间的控制。 平台会在它方便时调用代码。 若是这不如你想要的那样平滑或者频繁,太糟了。 更糟的是,大多数应用的事件循环并未为游戏设计,一般又慢又卡顿。
  • 使用游戏引擎的循环:
    • 没必要本身编写。 编写游戏循环很是须要技巧。 因为是每帧都要执行的核心代码,小小的漏洞或者性能问题就对游戏有巨大的影响。 稳固的游戏循环是使用现有引擎的缘由之一。
    • 没必要本身编写。 固然,硬币的另外一面是,若是引擎没法知足你真正的需求,你也无法得到控制权。
  • 本身写:
    • 彻底的控制。 你能够作任何想作的事情。你能够为游戏的需求订制开发。
    • 你须要与平台交互。 应用框架和操做系统一般须要时间片去处理本身的事件和其余工做。 若是你拥有应用的核心循环,平台就没有这些时间片了。 你得显式按期检查,保证框架没有挂起或者混乱。

如何管理能量消耗?

在五年前这还不是问题。 游戏运行在插到插座上的机器上或者专用的手持设备上。 可是随着智能手机,笔记本以及移动游戏的发展,如今须要关注这个问题了。 画面绚丽,但会耗干三十分钟前充的电,并将手机变成空间加热器的游戏,可不能让人开心。

如今,你须要考虑的不只仅是让游戏看上去很棒,同时也要尽量少地使用CPU 你须要设置一个性能的上限:完成一帧以内所需的工做后,让CPU休眠。

  • 尽量快地运行:

这是PC游戏的常态(即便愈来愈多的人在笔记本上运行游戏)。 游戏循环永远不会显式告诉系统休眠。相反,空闲的循环被划在提高FPS或者图像显示效果上了。

这会给你最好的游戏体验。 可是,也会尽量多地使用电量。若是玩家在笔记本电脑上游玩,他们就获得了一个很好的加热器。

  • 固定帧率

移动游戏更加注意游戏的体验质量,而不是最大化图像画质。 不少这种游戏都会设置最大帧率(一般是3060FPS)。 若是游戏循环在分配的时间片消耗完以前完成,剩余的时间它会休眠。

这给了玩家足够好的游戏体验,也让电池轻松了一点。

你如何控制游戏速度?

游戏循环有两个关键部分:不阻塞用户输入和自适应的帧时间步长。 输入部分很直观。关键在于你如何处理时间。 这里有数不尽的游戏可运行的平台, 每一个游戏都须要在其中一些平台上运行。 如何适应平台的变化就是关键。

创做游戏看来是人类的天性,由于每当咱们建构能够计算的机器,首先作的就是在上面编游戏。 PDP-1是一个仅有4096字内存的2kHz机器,可是Steve Russell和他的朋友仍是在上面建立了Spacewar!。

  • 固定时间步长,没有同步:

见咱们第一个样例中的代码。你只需尽量快地运行游戏。

    • 简单。这是主要的(好吧,惟一的)好处。
    • 游戏速度直接受到硬件和游戏复杂度影响。 主要的缺点是,若是有所变化,会直接影响游戏速度。游戏速度与游戏循环紧密相关。
  • 固定时间步长,有同步:

对复杂度控制的下一步是使用固定的时间间隔,但在循环的末尾增长同步点,保证游戏不会运行得过快。

    • 仍是很简单。 这比过于简单以致于不可行的例子只多了一行代码。 在多数游戏循环中,你可能须要作一些同步。 你可能须要双缓冲图形并将缓冲块与更新显示的频率同步。
    • 电量友好。 这对移动游戏相当重要。你不想消耗没必要要的电量。 经过简单地休眠几个毫秒而不是试图每帧塞入更多的处理,你就节约了电量。
    • 游戏不会运行得太快。 这解决了固定循环速度的一半问题。
    • 游戏可能运行的太慢。 若是花了太多时间更新和渲染一帧,播放也会减缓。 由于这种方案没有分离更新和渲染,它比更高级的方案更容易遇到这点。 无法扔掉渲染帧来追上真实时间,游戏自己会变慢。
  • 动态时间步长:

我把这个方案放在这里做为问题的解决办法之一,附加警告:大多数我认识的游戏开发者反对它。 不过记住为何反对它是颇有价值的。

    • 能适应并调整,避免运行得太快或者太慢。 若是游戏不能追上真实时间,它用愈来愈长的时间步长更新,直到追上。
    • 让游戏不肯定并且不稳定。 这是真正的问题,固然。在物理和网络部分使用动态时间步长会碰见更多的困难。
  • 固定更新时间步长,动态渲染:

在示例代码中提到的最后一个选项是最复杂的,可是也是最有适应性的。 它以固定时间步长更新,可是若是须要遇上玩家的时间,能够扔掉一些渲染帧。

    • 能适应并调整,避免运行得太快或者太慢。 只要能实时更新,游戏状态就不会落后于真实时间。若是玩家用高端的机器,它会回以更平滑的游戏体验。
    • 更复杂。 主要负面问题是须要在实现中写更多东西。 你须要将更新的时间步长调整得尽量小来适应高端机,同时不至于在低端机上太慢。

参见

  • 关于游戏循环的经典文章是Glenn FiedlerFix Your Timestep。若是没有这篇文章,这章就不会是这个样子。
  • Witters关于game loops的文章也值得阅读。
  • Unity框架有一个复杂的游戏循环,细节在这里有详尽的解释。

3.3更新方法

游戏设计模式Sequencing Patterns

意图

经过每次处理一帧的行为模拟一系列独立对象。

动机

玩家操做强大的女武神完成考验:从死亡巫王的栖骨之处偷走华丽的珠宝。 她尝试接近巫王华丽的地宫门口,而后遇到了……啥也没遇到 没有诅咒雕像向她发射闪电,没有不死战士巡逻入口。 她直捣黄龙,拿走了珠宝。游戏结束。你赢了。

好吧,这可不行。

地宫须要守卫——一些英雄能够杀死的敌人。 首先,咱们须要一个骷髅战士在门口先后移动巡逻。 若是无视任何关于游戏编程的知识, 让骷髅蹒跚着来回移动的最简单的代码大概是这样的:

若是巫王想表现得更加智慧,它应创造一些仍有脑子的东西。

while (true)
{
  // 向右巡逻
  for (double x = 0; x < 100; x++)
  {
    skeleton.setX(x);
  }
 
  // 向左巡逻
  for (double x = 100; x > 0; x--)
  {
    skeleton.setX(x);
  }
}

这里的问题,固然,是骷髅来回打转,可玩家永远看不到。 程序锁死在一个无限循环,那可不是有趣的游戏体验。 咱们事实上想要的是骷髅每帧移动一步。

咱们得移除这些循环,依赖外层游戏循环来迭代。 这保证了在卫士来回巡逻时,游戏能响应玩家的输入并进行渲染。以下:

固然,游戏循环是本书的另外一个章节。

Entity skeleton;
bool patrollingLeft = false;
double x = 0;
 
// 游戏主循环
while (true)
{
  if (patrollingLeft)
  {
    x--;
    if (x == 0) patrollingLeft = false;
  }
  else
  {
    x++;
    if (x == 100) patrollingLeft = true;
  }
 
  skeleton.setX(x);
 
  // 处理用户输入并渲染游戏……
}

在这里先后两个版本展现了代码是如何变得复杂的。 左右巡逻须要两个简单的for循环。 经过指定哪一个循环在执行,咱们追踪了骷髅在移向哪一个方向。 如今咱们每帧跳出到外层的游戏循环,而后再跳回继续咱们以前所作的,咱们使用patrollingLeft显式地追踪了方向。

但或多或少这能行,因此咱们继续。 一堆无脑的骨头不会对你的女武神提出太多挑战, 咱们下一个添加的是魔法雕像。它们一直会向她发射闪电球,这样可以让她保持移动。

继续咱们的用最简单的方式编码的风格,咱们获得了:

// 骷髅的变量……
Entity leftStatue;
Entity rightStatue;
int leftStatueFrames = 0;
int rightStatueFrames = 0;
 
// 游戏主循环:
while (true)
{
  // 骷髅的代码……
 
  if (++leftStatueFrames == 90)
  {
    leftStatueFrames = 0;
    leftStatue.shootLightning();
  }
 
  if (++rightStatueFrames == 80)
  {
    rightStatueFrames = 0;
    rightStatue.shootLightning();
  }
 
  // 处理用户输入,渲染游戏
}

你会发现这代码渐渐滑向失控。 变量数目不断增加,代码都在游戏循环中,每段代码处理一个特殊的游戏实体。 为了同时访问并运行它们,咱们将它们的代码混杂在了一块儿。

一旦能用“混杂”一词描述你的架构,你就有麻烦了。

你也许已经猜到了修复这个所用的简单模式了: 每一个游戏实体应该封装它本身的行为。这保持了游戏循环的整洁,便于添加和移除实体。

为了作到这点须要抽象层,咱们经过定义抽象的update()方法来完成。 游戏循环管理对象的集合,可是不知道对象的具体类型。 它只知道这些对象能够被更新。 这样,每一个对象的行为与游戏循环分离,与其余对象分离。

每一帧,游戏循环遍历集合,在每一个对象上调用update() 这给了咱们在每帧上更新一次行为的机会。 在全部对象上每帧调用它,对象就能同时行动。

死抠细节的人会在这点上揪着我不放,是的,它们没有真的同步。 当一个对象更新时,其余的都不在更新中。 咱们等会儿再说这点。

游戏循环维护动态的对象集合,因此从关卡添加和移除对象是很容易的——只须要将它们从集合中添加和移除。 没必要再用硬编码,咱们甚至能够用数据文件构成这个关卡,那正是咱们的关卡设计者须要的。

模式

游戏世界管理对象集合 每一个对象实现一个更新方法模拟对象在一帧内的行为。每一帧,游戏循环更新集合中的每个对象。

什么时候使用

若是游戏循环模式是切片面包, 那么更新方法模式就是它的奶油。 不少玩家交互的游戏实体都以这样或那样的方式实现了这个模式。 若是游戏有太空陆战队,火龙,火星人,鬼魂或者运动员,颇有可能它使用了这个模式。

可是若是游戏更加抽象,移动部分不太像活动的角色而更加像棋盘上的棋子, 这个模式一般就不适用了。 在棋类游戏中,你不须要同时模拟全部的部分, 你可能也不须要告诉棋子每帧都更新它们本身。

你也许不须要每帧更新它们的行为,但即便是棋类游戏, 你可能也须要每帧更新动画。 这个设计模式也能够帮到你。

更新方法适应如下状况:

  • 你的游戏有不少对象或系统须要同时运行。
  • 每一个对象的行为都与其余的大部分独立。
  • 对象须要跟着时间进行模拟。

记住

这个模式很简单,因此没有太多值得发现的惊喜。固然,每行代码仍是有利有弊。

将代码划分到一帧帧中会让它更复杂

当你比较前面两块代码时,第二块看上去更加复杂。 二者都只是让骷髅守卫来回移动,但与此同时,第二块代码将控制权交给了游戏循环的一帧帧中。

几乎 这个改变是游戏循环处理用户输入,渲染等几乎必需要注意的事项,因此第一个例子不大实用。 可是颇有必要记住,将你的行为切片会增长很高的复杂性。

我在这里说几乎是由于有时候鱼和熊掌能够兼得。 你能够直接为对象编码而不进行返回, 保持不少对象同时运行并与游戏循环保持协调。

你须要的是容许你同时拥有多个“线程”执行的系统。 若是对象的代码能够在执行中暂停和继续,而不是总得返回, 你能够用更加命令式的方式编码。

真实的线程太太重量级而不能这么作, 但若是你的语言支持轻量协同架构好比generators,coroutines或者fibers,那你也许能够使用它们。

字节码模式是另外一个在应用层建立多个线程执行的方法。

当离开每帧时,你须要存储状态,以备未来继续。

在第一个示例代码中,咱们不须要用任何变量代表守卫在向左仍是向右移动。 这显式的依赖于哪块代码正在运行。

当咱们将其变为一次一帧的形式,咱们须要建立patrollingLeft变量来追踪行走的方向。 当从代码中返回时,就丢失了行走的方向,因此为了下帧继续,咱们须要显式存储足够的信息。

状态模式一般能够在这里帮忙。 状态机在游戏中频繁出现的部分缘由是(就像名字暗示的),它能在你离开时为你存储各类你须要的状态。

对象逐帧模拟,但并不是真的同步

在这个模式中,游戏遍历对象集合,更新每个对象。 update()调用中,大多数对象都可以接触到游戏世界的其余部分, 包括如今正在更新的其余对象。这就意味着你更新对象的顺序相当重要。

若是对象更新列表中,AB以前,当A更新时,它会看到B以前的状态。 可是当B更新时,因为A已经在这帧更新了,它会看见A状态。 哪怕按照玩家的视角,全部对象都是同时运转的,游戏的核心仍是回合制的。 只是完整的回合只有一帧那么长。

若是,因为某些缘由,你决定让游戏按这样的顺序更新,你须要双缓冲模式。 那么AB更新的顺序就没有关系了,由于双方都会看对方以前那帧的状态。

当关注游戏逻辑时,这一般是件好事。 同时更新全部对象将把你带到一些不愉快的语义角落。 想象若是国际象棋中,黑白双方同时移动会发生什么。 双方都试图同时往同一个空格子中放置棋子。这怎么解决?

序列更新解决了这点——每次更新都让游戏世界从一个合法状态增量更新到下一个,不会出现引起歧义而须要协调的部分。

这对在线游戏也有用,由于你有了能够在网上发送的行动指令序列。

在更新时修改对象列表需当心

当你使用这个模式时,不少游戏行为在更新方法中纠缠在一块儿。 这些行为一般包括增长和删除可更新对象。

举个例子,假设骷髅守卫被杀死时掉落物品。 使用新对象,你一般能够将其增长到列表尾部,而不引发任何问题。 你会继续遍历这张链表,最终找到新的那个,而后也更新了它。

但这确实代表新对象在它产生的那帧就有机会活动,甚至有可能在玩家看到它以前。 若是你不想发生那种状况,简单的修复方法就是在游戏循环中缓存列表对象的数目,而后只更新那么多数目的对象就中止:

int numObjectsThisTurn = numObjects_;
for (int i = 0; i < numObjectsThisTurn; i++)
{
  objects_[i]->update();
}

这里,objects_是可更新游戏对象的数组,而numObjects_是数组的长度。 当添加新对象时,这个数组长度变量就增长。 在循环的一开始,咱们在numObjectsThisTurn中存储数组的长度, 这样这帧的遍历循环会停在新添加的对象以前。

一个更麻烦的问题是在遍历时移除对象。 你击败了邪恶的野兽,如今它须要被移出对象列表。 若是它正好位于你当前更新对象以前,你会意外地跳过一个对象:

for (int i = 0; i < numObjects_; i++)
{
  objects_[i]->update();
}

这个简单的循环经过增长索引值来遍历每一个对象。 下图的左侧展现了在咱们更新英雄时,数组看上去是什么样的:

咱们在更新她时,索引值i1 邪恶野兽被她杀了,所以须要从数组移除。 英雄移到了位置0,倒霉的乡下人移到了位置1 在更新英雄以后,i增长到了2 就像你在右图看到的,倒霉的乡下人被跳过了,没有更新。

一种简单的解决方案是在更新时从后往前遍历列表。 这种方式只会移动已经被更新的对象。

一种解决方案是当心地移除对象,任何对象被移除时,更新索引。 另外一种是在遍历完列表后再移除对象。 将对象标为死亡,可是把它放在那里。 在更新时跳过任何死亡的对象。而后,在完成遍历后,遍历列表并删除尸体。

若是在更新循环中有多个线程处理对象, 那么你可能更喜欢推迟任何修改,避免更新时同步线程的开销。

示例代码

这个模式太直观了,代码几乎只是在重复说明要点。 这不意味着这个模式没有用。它由于简单而有用:这是一个无需装饰的干净解决方案。

可是为了让事情更具体些,让咱们看看一个基础的实现。 咱们会从表明骷髅和雕像的Entity类开始:

class Entity
{
public:
  Entity()
  : x_(0), y_(0)
  {}
 
  virtual ~Entity() {}
  virtual void update() = 0;
 
  double x() const { return x_; }
  double y() const { return y_; }
 
  void setX(double x) { x_ = x; }
  void setY(double y) { y_ = y; }
 
private:
  double x_;
  double y_;
};

我在这里只呈现了咱们后面所需东西的最小集合。 能够推断在真实代码中,会有不少图形和物理这样的其余东西。 上面这部分代码最重要的部分是它有抽象的update()方法。

游戏管理实体的集合。在咱们的示例中,我会把它放在一个表明游戏世界的类中。

class World
{
public:
  World()
  : numEntities_(0)
  {}
 
  void gameLoop();
 
private:
  Entity* entities_[MAX_ENTITIES];
  int numEntities_;
};

在真实的世界程序中,你可能真的要使用集合类,我在这里使用数组来保持简单

如今,万事俱备,游戏经过每帧更新每一个实体来实现模式:

void World::gameLoop()
{
  while (true)
  {
    // 处理用户输入……
 
    // 更新每一个实体
    for (int i = 0; i < numEntities_; i++)
    {
      entities_[i]->update();
    }
 
    // 物理和渲染……
  }
}

正如其名,这是游戏循环模式的一个例子。

子类化实体?!

有不少读者刚刚起了鸡皮疙瘩,由于我在Entity主类中使用继承来定义不一样的行为。 若是你在这里尚未看出问题,我会提供一些线索。

当游戏业界从6502汇编代码和VBLANKs转向面向对象的语言时, 开发者陷入了对软件架构的狂热之中。 其中之一就是使用继承。他们创建了遮天蔽日的高耸的拜占庭式对象层次。

最终证实这是个糟点子,没人能够不拆解它们来管理庞杂的对象层次。 哪怕在1994年的GoF都知道这点,并写道:

多用对象组合,而非类继承

只在你我间聊聊,我认为这已是一朝被蛇咬十年怕井绳了。 我一般避免使用它,但教条地不用和教条地使用同样糟。 你能够适度使用,没必要彻底禁用。

当游戏业界都明白了这一点,解决方案是使用组件模式。 使用它,update()是实体的组件而不是在Entity中。 这让你避开了为了定义和重用行为而建立实体所需的复杂类继承层次。相反,你只需混合和组装组件。

若是我真正在作游戏,我也许也会那么作。 可是这章不是关于组件的, 而是关于update()方法,最简单,最少牵连其余部分的介绍方法, 就是把更新方法放在Entity中而后建立一些子类。

组件模式在这里

定义实体

好了,回到任务中。 咱们原先的动机是定义巡逻的骷髅守卫和释放闪电的魔法雕像。 让咱们从咱们的骷髅朋友开始吧。 为了定义它的巡逻行为,咱们定义恰当地实现了update()的新实体:

class Skeleton : public Entity
{
public:
  Skeleton()
  : patrollingLeft_(false)
  {}
 
  virtual void update()
  {
    if (patrollingLeft_)
    {
      setX(x() - 1);
      if (x() == 0) patrollingLeft_ = false;
    }
    else
    {
      setX(x() + 1);
      if (x() == 100) patrollingLeft_ = true;
    }
  }
 
private:
  bool patrollingLeft_;
};

如你所见,几乎就是从早先的游戏循环中剪切代码,而后粘贴到Skeletonupdate()方法中。 惟一的小小不一样是patrollingLeft_被定义为字段而不是本地变量。 经过这种方式,它的值在update()两次调用间保持不变。

让咱们对雕像如法炮制:

class Statue : public Entity
{
public:
  Statue(int delay)
  : frames_(0),
    delay_(delay)
  {}
 
  virtual void update()
  {
    if (++frames_ == delay_)
    {
      shootLightning();
 
      // 重置计时器
      frames_ = 0;
    }
  }
 
private:
  int frames_;
  int delay_;
 
  void shootLightning()
  {
    // 火光效果……
  }
};

又一次,大部分改动是将代码从游戏循环中移动到类中,而后重命名一些东西。 可是,在这个例子中,咱们真的让代码库变简单了。 先前讨厌的命令式代码中,存在存储每一个雕像的帧计数器和开火的速率的分散的本地变量。

如今那些都被移动到了Statue类中,你能够想建立多少就建立多少实例了, 每一个实例都有它本身的小计时器。 这是这章背后的真实动机——如今为游戏世界增长新实体会更加简单, 由于每一个实体都带来了它须要的所有东西。

这个模式让咱们分离了游戏世界的构建实现 这一样能让咱们灵活地使用分散的数据文件或关卡编辑器来构建游戏世界。

还有人关心UML吗?若是还有,那就是咱们刚刚建的。

传递时间

这是模式的关键,可是我只对经常使用的部分进行了细化。 到目前为止,咱们假设每次对update()的调用都推进游戏世界前进一个固定的时间。

我更喜欢那样,可是不少游戏使用可变时间步长 在那种状况下,每次游戏循环推动的时间长度或长或短, 具体取决于它须要多长时间处理和渲染前一帧。

游戏循环一章讨论了更多关于固定和可变时间步长的优劣。

这意味着每次update()调用都须要知道虚拟的时钟转动了多少, 因此你常常能够看到传入消逝的时间。 举个例子,咱们能够让骷髅卫士像这样处理变化的时间步长:

void Skeleton::update(double elapsed)
{
  if (patrollingLeft_)
  {
    x -= elapsed;
    if (x <= 0)
    {
      patrollingLeft_ = false;
      x = -x;
    }
  }
  else
  {
    x += elapsed;
    if (x >= 100)
    {
      patrollingLeft_ = true;
      x = 100 - (x - 100);
    }
  }
}

如今,骷髅卫士移动的距离随着消逝时间的增加而增加。 也能够看出,处理变化时间步长须要的额外复杂度。 若是一次须要更新的时间步长过长,骷髅卫士也许就超过了其巡逻的范围,所以须要当心的处理。

设计决策

在这样简单的模式中,没有太多的调控之处,可是这里仍有两个你须要决策的地方:

更新方法在哪一个类中?

最明显和最重要的决策就是决定将update()放在哪一个类中。

  • 实体类中:

若是你已经有实体类了,这是最简单的选项, 由于这不会带来额外的类。若是你须要的实体种类很少,这也许可行,可是业界已经逐渐远离这种作法了。

当类的种类不少时,一有新行为就建Entity子类来实现是痛苦的。 当你最终发现你想要用单一继承的方法重用代码时,你就卡住了。

  • 组件类:

若是你已经使用了组件模式,你知道这个该怎么作。 这让每一个组件独立更新它本身。 更新方法用了一样的方法解耦游戏中的实体,组件让你进一步解耦了单一实体中的各部分 渲染,物理,AI均可以自顾自了。

  • 委托类:

还可将类的部分行为委托给其余的对象。 状态模式能够这样作,你能够经过改变它委托的对象来改变它的行为。 类型对象模式也这样作了,这样你能够在同实体间分享行为。

若是你使用了这些模式,将update()放在委托类中是很天然的。 在那种状况下,也许主类中仍有update()方法,可是它不是虚方法,能够简单地委托给委托对象。就像这样:

void Entity::update()
{
  // 转发给状态对象
  state_->update();
}

这样作容许你改变委托对象来定义新行为。就像使用组件,这给了你无须定义全新的子类就能改变行为的灵活性。

如何处理隐藏对象?

游戏中的对象,无论什么缘由,可能暂时无需更新。 它们多是停用了,或者超出了屏幕,或者尚未解锁。 若是状态中的这种对象不少,每帧遍历它们却什么都不作是在浪费CPU循环。

一种方法是管理单独的活动对象集合,它存储真正须要更新的对象。 当一个对象停用时,从那个集合中移除它。当它启用时,再把它添加回来。 用这种方式,你只须要迭代那些真正须要更新的东西:

  • 若是你使用单个包括了全部不活跃对象的集合:
    • 浪费时间。对于不活跃对象,你要么检查一些是否启用的标识,要么调用一些啥都不作的方法。

检查对象启用与否而后跳过它,不但消耗了CPU循环,还报销了你的数据缓存。 CPU经过从RAM上读取数据到缓存上来优化读取。 这样作是基于刚刚读取内存以后的内存部分极可能等会儿也会被读取到这个假设。

当你跳过对象,你可能越过了缓存的尾部,强迫它从缓慢的主存中再取一块。

  • 若是你使用单独的集合保存活动对象:
    • 使用了额外的内存管理第二个集合。 当你须要全部实体时,一般又须要一个巨大的集合。在那种状况下,这集合是多余的。 在速度比内存要求更高的时候(一般如此),这取舍还是值得的。

另外一个权衡后的选择是使用两个集合,除了活动对象集合的另外一个集合只包含不活跃实体而不是所有实体。

    • 得保持集合同步。 当对象建立或彻底销毁时(不是暂时停用),你得修改所有对象集合和活跃对象集合。

方法选择的度量标准是不活跃对象的可能数量。 数量越多,用分离的集合避免在核心游戏循环中用到它们就更有用。

参见

  • 这个模式,以及游戏循环模式和组件模式,是构建游戏引擎核心的三位一体。
  • 当你关注在每帧中更新实体或组件的缓存性能时,数据局部性模式能够让它跑到更快。
  • Unity框架在多个类中使用了这个模式,包括 MonoBehaviour
  • 微软的XNA平台在 Game  GameComponent 类中使用了这个模式。
  • Quintus,一个JavaScript游戏引擎在它的主Sprite类中使用了这个模式。

 

第四章 行为模式

游戏设计模式

一旦作好游戏设定,在里面装满了角色和道具,剩下的就是启动场景。 为了完成这点,你须要行为——告诉游戏中每一个实体作什么的剧本。

固然,全部代码都是行为,而且全部软件都是定义行为的, 但在游戏中有所不一样的是,行为一般很 文字处理器也许有很长的特性清单, 但特性的数量与角色扮演游戏中的居民,物品和任务数量相比,那就相形见绌了。

本章的模式有助于快速定义和完善大量的行为。 类型对象定义行为的类别而无需完成真正的类。 子类沙盒定义各类行为的安全原语。 最早进的是字节码,将行为从代码中分离,放入数据文件中。

模式

4.1字节码

游戏设计模式Behavioral Patterns

意图

将行为编码为虚拟机器上的指令,赋予其数据的灵活性。

动机

制做游戏也许颇有趣,但毫不容易。 现代游戏的代码库非常庞杂。 主机厂商和应用市场有严格的质量要求, 小小的崩溃漏洞就能阻止游戏发售。

我曾参与制做有六百万行C++代码的游戏。做为对比,控制好奇号火星探测器的软件尚未其一半大小。

与此同时,咱们但愿榨干平台的每一点性能。 游戏对硬件发展的推进数一数二,只有坚持不懈地优化才能跟上竞争。

为了保证稳定和性能的需求,咱们使用如C++这样的重量级的编程语言,它既有能兼容多数硬件的底层表达能力,又拥有防止漏洞的强类型系统。

咱们对本身的专业技能充满自信,但其亦有代价。 专业程序员须要多年的训练,以后又要对抗代码规模的增加。 构建大型游戏的时间长度能够在喝杯咖啡烤咖啡豆,手磨咖啡豆,弄杯espresso,打奶泡,在拿铁咖啡里拉花。之间变更。

除开这些挑战,游戏还多了个苛刻的限制:乐趣 玩家须要仔细权衡过的新奇体验。 这须要不断的迭代,可是若是每一个调整都须要让工程师修改底层代码,而后等待漫长的编译结束,那就毁掉了创做流程。

法术战斗!

假设咱们在完成一个基于法术的格斗游戏。 两个敌对的巫师互相丢法术,直到分出胜负。 咱们能够将这些法术都定义在代码中,但这就意味着每次修改法术都会牵扯到工程师。 当设计者想修改几个数字感受一下效果,就要从新编译整个工程,重启,而后进入战斗。

像如今的许多游戏同样,咱们也须要在发售以后更新游戏,修复漏洞或是添加新内容。 若是全部法术都是硬编码的,那么每次修改都意味着要给游戏的可执行文件打补丁。

再扯远一点,假设咱们还想支持模组。咱们想让玩家创造本身的法术。 若是这些法术都是硬编码的,那就意味着每一个模组制造者都得拥有编译游戏的整套工具链, 咱们也就不得不开放源代码,若是他们的自创法术上有个漏洞,那么就会把其余人的游戏也搞崩溃。

数据 > 代码

很明显实现引擎的编程语言不是个好选择。 咱们须要将法术放在与游戏核心隔绝的沙箱中。 咱们想要它们易于修改,易于加载,并与其余可执行部分相隔离。

我不知道你怎么想,但这听上去让我以为有点像是数据 若是能在分离的数据文件中定义行为,游戏引擎还能加载并执行它们,就能够实现全部目标。

这里须要指出执行对于数据的意思。如何让文件中的数据表示为行为呢?这里有几种方式。 解释器模式对比着看会好理解些。

解释器模式

关于这个模式我就能写整整一章,可是有四个家伙的工做早涵盖了这一切, 因此,这里给一些简短的介绍。

它源于一种你想要执行的语言——想一想编程语言。

好比,它支持这样的算术表达式

(1 + 2) * (3 - 4)

而后,把每块表达式,每条语言规则,都装到对象中去。数字字面量都变成对象:

简单地说,它们在原始值上作了个小封装。 运算符也是对象,它们拥有操做数的引用。 若是你考虑了括号和优先级,那么表达式就魔术般变成这样的小树:

这里的“魔术”是什么?很简单——语法分析。 语法分析器接受一串字符做为输入,将其转为抽象语法树,即一个包含了表示文本语法结构的对象集合。

完成这个你就获得了半个编译器。

解释器模式与建立这棵树无关,它只关于执行这棵树。 它工做的方式很是巧妙。树中的每一个对象都是表达式或子表达式。 用真正面向对象的方式描述,咱们会让表达式本身对本身求值。

首先,咱们定义全部表达式都实现的基本接口:

class Expression
{
public:
  virtual ~Expression() {}
  virtual double evaluate() = 0;
};

而后,为咱们语言中的每种语法定义一个实现这个接口的类。最简单的是数字:

class NumberExpression : public Expression
{
public:
  NumberExpression(double value)
  : value_(value)
  {}
 
  virtual double evaluate()
  {
    return value_;
  }
 
private:
  double value_;
};

一个数字表达式就等于它的值。加法和乘法有点复杂,由于它们包含子表达式。在一个表达式计算本身的值以前,必须先递归地计算其子表达式的值。像这样:

class AdditionExpression : public Expression
{
public:
  AdditionExpression(Expression* left, Expression* right)
  : left_(left),
    right_(right)
  {}
 
  virtual double evaluate()
  {
    // 计算操做数
    double left = left_->evaluate();
    double right = right_->evaluate();
 
    // 把它们加起来
    return left + right;
  }
 
private:
  Expression* left_;
  Expression* right_;
};

你确定能想明白乘法的实现是什么样的。

很优雅对吧?只需几个简单的类,如今咱们能够表示和计算任意复杂的算术表达式。 只须要建立正确的对象,并正确地连起来。

Ruby用了这种实现方法差很少15年。在1.9版本,他们转换到了本章所介绍的字节码。看看我给你节省了多少时间!

这是个优美、简单的模式,但它有一些问题。 看看插图,看到了什么?大量的小盒子,以及它们之间大量的箭头。 代码被表示为小物体组成的巨大分形树。这会带来些使人不快的后果:

  • 从磁盘上加载它须要实例化并链接成吨的小对象。
  • 这些对象和它们之间的指针会占据大量的内存。在32位机上,那个小的算术表达式至少要占据68字节,这还没考虑内存对其呢。

若是你想本身算算,别忘了算上虚函数表指针。

  • 顺着那些指针遍历子表达式是对数据缓存的谋杀。同时,虚函数调用是对指令缓存的屠戮。

参见数据局部性一章以了解什么是缓存以及它是如何影响游戏性能的。

将这些拼到一块儿,怎么念?S-L-O-W 这就是为何大多数普遍应用的编程语言不基于解释器模式: 太慢了,也太消耗内存了。

虚拟的机器码

想一想咱们的游戏。玩家电脑在运行游戏时并不会遍历一堆C++语法结构树。 咱们提早将其编译成了机器码,CPU基于机器码运行。机器码有什么好处呢?

  • 密集。 它是一块坚实连续的二进制数据块,没有一位被浪费。
  • 线性。 指令被打成包,一条接一条地执行。不会在内存里处处乱跳(除非你的控制流代码真真这么干了)。
  • 底层。 每条指令都作一件小事,有趣的行为从组合中诞生。
  • 速度快。 综合全部这些条件(固然,也包括它直接由硬件实现这一事实),机器码跑得跟风同样快。

这听起来很好,但咱们不但愿真的用机器代码来写咒语。 让玩家提供游戏运行时的机器码简直是在自找麻烦。咱们须要的是机器代码的性能和解释器模式的安全的折中。

若是不是加载机器码并直接执行,而是定义本身的虚拟机器码呢? 而后,在游戏中写个小模拟器。 这与机器码相似——密集,线性,相对底层——但也由游戏直接掌控,因此能够放心地将其放入沙箱。

这就是为何不少游戏主机和iOS不容许程序在运行时生成并加载机器码。 这是一种拖累,由于最快的编程语言实现就是那么作的。 它们包含了“即时(just-in-time)”编译器,或者JIT,在运行时将语言翻译成优化的机器码。

咱们将小模拟器称为虚拟机(或简称“VM”),它运行的二进制机器码叫作字节码 它有数据的灵活性和易用性,但比解释器模式性能更好。

在程序语言编程圈,“虚拟机”和“解释器”是同义词,我在这里交替使用。 当指代GoF的解释器模式,我会加上“模式”来代表区别。

这听起来有点吓人。 这章其他部分的目标是为了展现一下,若是把功能列表缩减下来,它实际上至关通俗易懂。 即便最终没有使用这个模式,你也至少能够对Lua和其余使用了这一模式的语言有个更好的理解。

模式

指令集 定义了可执行的底层操做。 一系列的指令被编码为字节序列 虚拟机 使用 中间值栈 依次执行这些指令。 经过组合指令,能够定义复杂的高层行为。

什么时候使用

这是本书中最复杂的模式,没法轻易地加入游戏中。这个模式应当用在你有许多行为须要定义,而游戏实现语言由于以下缘由不适用时:

  • 过于底层,繁琐易错。
  • 编译慢或者其余工具因素致使迭代缓慢。
  • 安全性依赖编程者。若是想保证行为不会破坏游戏,你须要将其与代码的其余部分隔开。

固然,该列表描述了一堆特性。谁不但愿有更快的迭代循环和更多的安全性? 然而,世上没有免费的午饭。字节码比本地代码慢,因此不适合引擎的性能攸关的部分。

记住

建立本身的语言或者创建系统中的系统是颇有趣的。 我在这里作的是小演示,但在现实项目中,这些东西会像藤蔓同样蔓延。

对我来讲,游戏开发也正所以而有趣。 无论哪一种状况,我都建立了虚拟空间让他人游玩。

每当我看到有人定义小语言或脚本系统,他们都说,别担忧,它很小。因而,不可避免地,他们增长更多小功能,直到完成了一个完整的语言。 除了,和其它语言不一样,它是定制的并拥有棚户区的建筑风格。

例如每一种模板语言。

固然,完成完整的语言并无什么。只是要肯定你作得慎重。 不然,你就要当心地控制你的字节码所能表达的范围。在野马脱缰以前把它拴住。

你须要一个前端

底层的字节码指令性能优越,可是二进制的字节码格式不是用户能写的。 咱们将行为移出代码的一个缘由是想要以更高层的形式表示它。 若是说写C++太过底层,那么让用户写汇编可不是一个改进方案——就算是你设计的!

一个反例的是使人尊敬的游戏RoboWar。 在游戏中,玩家 编写相似汇编的语言控制机器人,咱们这里也会讨论这种指令集。

这是我介绍相似汇编的语言的首选。

就像GoF的解释器模式,它假设你有某些方法来生成字节码。 一般状况下,用户在更高层编写行为,再用工具将其翻译为虚拟机能理解的字节码。 这里的工具就是编译器。

我知道,这听起来很吓人。丑话说在前头, 若是没有资源制做编辑器,那么字节码不适合你。 可是,接下来你会看到,也可能没你想的那么糟。

你会想念调试器

编程很难。咱们知道想要机器作什么,但并不总能正确地传达——因此咱们会写出漏洞。 为了查找和修复漏洞,咱们已经积累了一堆工具来了解代码作错了什么,以及如何修正。 咱们有调试器,静态分析器,反编译工具等。 全部这些工具都是为现有的语言设计的:不管是机器码仍是某些更高层次的东西。

当你定义本身的字节码虚拟机时,你就得把这些工具抛在脑后了。 固然,能够经过调试器调试虚拟机,但它告诉你虚拟机自己在作什么,而不是正在被翻译的字节码是干什么的。

它固然也不会把字节码映射回编译前的高层次的形式。

若是你定义的行为很简单,可能无需太多工具帮忙调试就能勉强坚持下来。 但随着内容规模增加,仍是应该花些时间完成些功能,让用户看到字节码在作什么。 这些功能也许不随游戏发布,但它们相当重要,它们能确保你的游戏被发布。

固然,若是你想要让游戏支持模组,那你发布这些特性,它们就更加剧要了。

示例代码

经历了前面几个章节后,你也许会惊讶于它的实现是多么直接。 首先须要为虚拟机设定一套指令集。 在开始考虑字节码之类的东西前,先像思考API同样思考它。

法术的API

若是直接使用C++代码定义法术,代码须要调用何种API呢? 在游戏引擎中,构成法术的基本操做是什么样的?

大多数法术最终改变一个巫师的状态,所以先从这样的代码开始。

void setHealth(int wizard, int amount);
void setWisdom(int wizard, int amount);
void setAgility(int wizard, int amount);

第一个参数指定哪一个巫师被影响,0表明玩家而1表明对手。 以这种方式,治愈法术能够治疗玩家的巫师,而伤害法术伤害他的敌人。 这三个小方法能覆盖的法术出人意料地多。

若是法术只是默默地调整数据,游戏逻辑就已经完成了, 但玩这样的游戏会让玩家无聊得要哭。让咱们修复这点:

void playSound(int soundId);
void spawnParticles(int particleType);

这并不影响游戏玩法,但它们加强了游戏的体验 咱们能够增长一些镜头晃动,动画之类的,但这足够咱们开始了。

法术指令集

如今让咱们把这种程序化API转化为可被数据控制的东西。 从小处开始,而后慢慢拓展到总体。 如今,要去除方法的全部参数。 假设set__()方法总影响玩家的巫师,总直接将状态设为最大值。 一样,FX操做老是播放一个硬编码的声音和粒子效果。

这样,一个法术就只是一系列指令了。 每条指令都表明了想要呈现的操做。咱们能够枚举以下:

enum Instruction
{
  INST_SET_HEALTH      = 0x00,
  INST_SET_WISDOM      = 0x01,
  INST_SET_AGILITY     = 0x02,
  INST_PLAY_SOUND      = 0x03,
  INST_SPAWN_PARTICLES = 0x04
};

为了将法术编码进数据,咱们存储了一数组enum值。 只有几个不一样的基本操做原语,所以enum值的范围能够存储到一个字节中。 这就意味着法术的代码就是一系列字节——也就是字节码

有些字节码虚拟机为每条指令使用多个字节,解码规则也更复杂。 事实上,在x86这样的常见芯片上的机器码更加复杂。

但单字节对于Java虚拟机和支撑了.NET平台的Common Language Runtime已经足够了,对咱们来讲也同样。

为了执行一条指令,咱们看看它的基本操做原语是什么,而后调用正确的API方法。

switch (instruction)
{
  case INST_SET_HEALTH:
    setHealth(0, 100);
    break;
 
  case INST_SET_WISDOM:
    setWisdom(0, 100);
    break;
 
  case INST_SET_AGILITY:
    setAgility(0, 100);
    break;
 
  case INST_PLAY_SOUND:
    playSound(SOUND_BANG);
    break;
 
  case INST_SPAWN_PARTICLES:
    spawnParticles(PARTICLE_FLAME);
    break;
}

用这种方式,解释器创建了沟通代码世界和数据世界的桥梁。咱们能够像这样将其放进执行法术的虚拟机:

class VM
{
public:
  void interpret(char bytecode[], int size)
  {
    for (int i = 0; i < size; i++)
    {
      char instruction = bytecode[i];
      switch (instruction)
      {
        // 每条指令的跳转分支……
      }
    }
  }
};

输入这些,你就完成了你的首个虚拟机。 不幸的是,它并不灵活。 咱们不能设定攻击对手的法术,也不能减小状态上限。咱们只能播放声音!

为了得到像一个真正的语言那样的表达能力,咱们须要在这里引入参数。

栈式机器

要执行复杂的嵌套表达式,得先从最里面的子表达式开始。 计算完里面的,将结果做为参数向外流向包含它们的表达式, 直到得出最终结果,整个表达式就算完了。

解释器模式将其明确地表现为嵌套对象组成的树,但咱们须要指令速度达到列表的速度。咱们仍然须要确保子表达式的结果正确地向外传递给包括它的表达式。

但因为数据是扁平的,咱们得使用指令的顺序来控制这一点。咱们的作法和CPU同样——使用栈。

这种架构不出所料地被称为栈式计算机。像ForthPostScript,和Factor这些语言直接将这点暴露给用户。

class VM
{
public:
  VM()
  : stackSize_(0)
  {}
 
  // 其余代码……
 
private:
  static const int MAX_STACK = 128;
  int stackSize_;
  int stack_[MAX_STACK];
};

虚拟机用内部栈保存值。在例子中,指令交互的值只有一种,那就是数字, 因此能够使用简单的int数组。 每当数据须要从一条指令传到另外一条,它就得经过栈。

顾名思义,值能够压入栈或者从栈弹出,因此让咱们添加一对方法。

class VM
{
private:
  void push(int value)
  {
    // 检查栈溢出
    assert(stackSize_ < MAX_STACK);
    stack_[stackSize_++] = value;
  }
 
  int pop()
  {
    // 保证栈不是空的
    assert(stackSize_ > 0);
    return stack_[--stackSize_];
  }
 
  // 其他的代码
};

当一条指令须要接受参数,就将参数从栈弹出,以下所示:

switch (instruction)
{
  case INST_SET_HEALTH:
  {
    int amount = pop();
    int wizard = pop();
    setHealth(wizard, amount);
    break;
  }
 
  case INST_SET_WISDOM:
  case INST_SET_AGILITY:
    // 像上面同样……
 
  case INST_PLAY_SOUND:
    playSound(pop());
    break;
 
  case INST_SPAWN_PARTICLES:
    spawnParticles(pop());
    break;
}

为了将一些值存入栈中,须要另外一条指令:字面量。 它表明了原始的整数值。可是的值又是从哪里来的呢? 咱们怎么样避免这样追根溯源到无穷无尽呢?

技巧是利用指令是字节序列这一事实——咱们能够直接将数值存储在字节数组中。 以下,咱们为数值字面量定义了另外一条指令类型:

case INST_LITERAL:
{
  // 从字节码中读取下一个字节
  int value = bytecode[++i];
  push(value);
  break;
}

这里,从单个字节中读取值,从而避免了解码多字节整数须要的代码, 但在真实实现中,你会须要支持整个数域的字面量。

它读取字节码流中的字节做为数值并将其压入栈。

让咱们把一些这样的指令串起来看看解释器的执行,感觉下栈是如何工做的。 从空栈开始,解释器指向第一个指令:

首先,它执行第一条INST_LITERAL,读取字节码流的下一个字节(0)并压入栈中。

而后,它执行第二条INST_LITERAL,读取10而后压入。

最后,执行INST_SET_HEALTH。这会弹出10存进amount,弹出0存进wizard。而后用这两个参数调用setHealth()

完成!咱们得到了将玩家巫师血量设为10点的法术。 如今咱们拥有了足够的灵活度,来定义修改任一巫师的状态到任意值的法术。 咱们还能够放出不一样的声音和粒子效果。

可是……这感受仍是像数据格式。好比,不能将巫师的血量提高为他智力的一半。 设计师但愿法术能表达规则,而不只仅是数值

行为 = 组合

若是咱们视小虚拟机为编程语言,如今它能支持的只有一些内置函数,以及常量参数。 为了让字节码感受像行为,咱们缺乏的是组合

设计师须要能以有趣的方式组合不一样的值,来建立表达式。 举个简单的例子,他们想让法术变化一个数值而不是变到一个数值。

这须要考虑到状态的当前值。 咱们有指令来修改状态,如今须要添加方法读取状态:

case INST_GET_HEALTH:
{
  int wizard = pop();
  push(getHealth(wizard));
  break;
}
 
case INST_GET_WISDOM:
case INST_GET_AGILITY:
  // 你知道思路了吧……

正如你所看到的,这要与栈双向交互。 弹出一个参数来肯定获取哪一个巫师的状态,而后查找状态的值并压入栈中。

这容许咱们创造复制状态的法术。 咱们能够建立一个法术,根据巫师的智慧设定敏捷度,或者让巫师的血量等于对方的血量。

有所改善,但仍很受限制。接下来,咱们须要算术。 是时候让小虚拟机学习如何计算1 + 1了,咱们将添加更多的指令。 如今,你可能已经知道如何去作,猜到了大概的模样。我只展现加法:

case INST_ADD:
{
  int b = pop();
  int a = pop();
  push(a + b);
  break;
}

像其余指令同样,它弹出数值,作点工做,而后压入结果。 直到如今,每一个新指令彷佛都只是有所改善而已,但其实咱们已完成大飞跃。 这并不显而易见,但如今咱们能够处理各类复杂的,深层嵌套的算术表达式了。

来看个稍微复杂点的例子。 假设咱们但愿有个法术,能让巫师的血量增长敏捷和智慧的平均值。 用代码表示以下:

setHealth(0, getHealth(0) +
    (getAgility(0) + getWisdom(0)) / 2);

你可能会认为咱们须要指令来处理括号形成的分组,但栈隐式支持了这一点。能够手算以下:

  1. 获取巫师当前的血量并记录。
  2. 获取巫师敏捷并记录。
  3. 对智慧执行一样的操做。
  4. 获取最后两个值,加起来并记录。
  5. 除以二并记录。
  6. 回想巫师的血量,将它和这结果相加并记录。
  7. 取出结果,设置巫师的血量为这一结果。

你看到这些记录回想了吗?每一个记录对应一个压入,回想对应弹出。 这意味着能够很容易将其转化为字节码。例如,第一行得到巫师的当前血量:

LITERAL 0
GET_HEALTH

这些字节码将巫师的血量压入堆栈。 若是咱们机械地将每行都这样转化,最终获得一大块等价于原来表达式的字节码。 为了让你感受这些指令是如何组合的,我在下面给你作个示范。

为了展现堆栈如何随着时间推移而变化,咱们举个代码执行的例子。 巫师目前有45点血量,7点敏捷,和11点智慧。 每条指令的右边是栈在执行指令以后的模样,再右边是解释指令意图的注释:

LITERAL 0    [0]            # 巫师索引
LITERAL 0    [0, 0]         # 巫师索引
GET_HEALTH   [0, 45]        # 获取血量()
LITERAL 0    [0, 45, 0]     # 巫师索引
GET_AGILITY  [0, 45, 7]     # 获取敏捷()
LITERAL 0    [0, 45, 7, 0]  # 巫师索引
GET_WISDOM   [0, 45, 7, 11] # 获取智慧()
ADD          [0, 45, 18]    # 将敏捷和智慧加起来
LITERAL 2    [0, 45, 18, 2] # 被除数:2
DIVIDE       [0, 45, 9]     # 计算敏捷和智慧的平均值
ADD          [0, 54]        # 将平均值加到现有血量上。
SET_HEALTH   []             # 将结果设为血量

若是你注意每步的栈,你能够看到数据如何魔法通常地在其中流动。 咱们最开始压入0来查找巫师,而后它一直挂在栈的底部,直到最终的SET_HEALTH才用到它。

也许“魔法”在这里的门槛过低了。

一台虚拟机

我能够继续下去,添加愈来愈多的指令,可是时候适可而止了。 如上所述,咱们已经有了一个可爱的小虚拟机,能够使用简单,紧凑的数据格式,定义开放的行为。 虽然字节码虚拟机的听起来很吓人,但你能够看到它们每每简单到只需栈,循环,和switch语句。

还记得咱们最初的让行为呆在沙盒中的目标吗? 如今,你已经看到虚拟机是如何实现的,很明显,那个目标已经完成。 字节码不能把恶意触角伸到游戏引擎的其余部分,由于咱们只定义了几个与其余部分接触的指令。

咱们经过控制栈的大小来控制内存使用量,并很当心地确保它不会溢出。 咱们甚至能够控制它使用多少时间 在指令循环里,能够追踪已经执行了多少指令,若是遇到了问题也能够摆脱困境。

控制运行时间在例子中没有必要,由于没有任何循环的指令。 能够限制字节码的整体大小来限制运行时间。 这也意味着咱们的字节码不是图灵完备的。

如今就剩一个问题了:建立字节码。 到目前为止,咱们使用伪代码,再手工编写为字节码。 除非你有不少的空闲时间,不然这种方式并不实用。

语法转换工具

咱们最初的目标是创造更高层的方式来控制行为,可是,咱们却创造了比C++底层的东西。 它具备咱们想要的运行性能和安全性,但绝对没有对设计师友好的可用性。

为了填补这一空白,咱们须要一些工具。 咱们须要一个程序,让用户定义法术的高层次行为,而后生成对应的低层栈式机字节码。

这可能听起来比虚拟机更难。 许多程序员都在大学参加编译器课程,除了被龙书或者lexyacc引起了PTSD外,什么也没真正学到。

我指的,固然,是经典教材Compilers: Principles, Techniques, and Tools

事实上,编译一个基于文本的语言并不那么糟糕,尽管把这个话题放进这里来要牵扯的东西有多。可是,你不是非得那么作。 我说,咱们须要的是工具——它并不必定是个输入格式是文本文件编译器

相反,我建议你考虑构建图形界面让用户定义本身的行为, 尤为是在使用它的人没有很高的技术水平时。 没有花几年时间习惯编译器怒吼的人很难写出没有语法错误的文本。

你能够创建一个应用程序,用户经过单击拖动小盒子,下拉菜单项,或任何有意义的行为建立脚本,从而建立行为。

我为Henry Hatsworth in the Puzzling Adventure编写的脚本系统就是这么工做的。

这样作的好处是,你的UI能够保证用户没法建立无效的程序。 与其向他们吐一大堆错误警告,不如主动禁用按钮或提供默认值, 以确保他们创造的东西在任什么时候间点上都有效。

我想要强调错误处理是多么重要。做为程序员,咱们趋向于将人为错误视为应当极力避免的的我的耻辱。

为了制做用户喜欢的系统,你须要接受人性,包括他们的失败。是人都会犯错误,但错误同时也是创做的固有基础。 用撤销这样的特性优雅地处理它们,这能让用户更有创意,创做出更好的成果。

这免去了设计语法和编写解析器的工做。 可是我知道,你可能会发现UI设计一样使人不快。 好吧,若是这样,我就没啥办法啦。

毕竟,这种模式是关于使用对用户友好的高层方式表达行为。 你必须精心设计用户体验。 要有效地执行行为,又须要将其转换成底层形式。这是必作的,但若是你准备好迎接挑战,这终会有所回报。

设计决策

我想尽量让本章简短,但咱们所作的事情实际上但是创造语言啊。 那但是个宽泛的设计领域,你能够从中得到不少乐趣,因此别沉迷于此反而忘了完成你的游戏。

这是本书中最长的章节,看来我失败了。

指令如何访问堆栈?

字节码虚拟机主要有两种:基于栈的和基于寄存器的。 栈式虚拟机中,指令老是操做栈顶,如同咱们的示例代码所示。 例如,INST_ADD弹出两个值,将它们相加,将结果压入。

基于寄存器的虚拟机也有栈。惟一不一样的是指令能够从栈的深处读取值。 不像INST_ADD始终弹出其操做数, 它在字节码中存储两个索引,指示了从栈的何处读取操做数。

  • 基于栈的虚拟机:
    • 指令短小。 因为每一个指令隐式认定在栈顶寻找参数,不须要为任何数据编码。 这意味着每条指令可能会很是短,通常只需一个字节。
    • 易于生成代码。 当你须要为生成字节码编写编译器或工具时,你会发现基于栈的字节码更容易生成。 因为每一个指令隐式地在栈顶工做,你只须要以正确的顺序输出指令就能够在它们之间传递参数。
    • 会生成更多的指令。 每条指令只能看到栈顶。这意味着,产生像a = b + c这样的代码, 你须要单独的指令将bc压入栈顶,执行操做,再将结果压入a
  • 基于寄存器的虚拟机:
    • 指令较长。 因为指令须要参数记录栈偏移量,单个指令须要更多的位。 例如,一个Lua指令占用完整的32——它多是最著名的基于寄存器的虚拟机了。 它采用6位作指令类型,其他的是参数。

Lua做者没有指定Lua的字节码格式,它每一个版本都会改变。如今描述的是Lua 5.1 要深究Lua的内部构造, 读读这个

    • 指令较少。 因为每一个指令能够作更多的工做,你不须要那么多的指令。 有人说,性能会得以提高,由于不须要将值在栈中移来移去了。

因此,应该选一种?个人建议是坚持使用基于栈的虚拟机。 它们更容易实现,也更容易生成代码。 Lua转换为基于寄存器的虚拟机从而变得更快,这为寄存器虚拟机博得了声誉, 可是这强烈依赖于实际的指令和虚拟机的其余大量细节。

你有什么指令?

指令集定义了在字节码中能够干什么,不能干什么,对虚拟机性能也有很大的影响。 这里有个清单,记录了你可能须要的不一样种类的指令:

  • 外部基本操做原语。 这是虚拟机与引擎其余部分交互,影响玩家所见的部分。 它们控制了字节码能够表达的真实行为。 若是没有这些,你的虚拟机除了消耗CPU循环之外一无所获。
  • 内部基本操做原语 这些语句在虚拟机内操做数值——文字,算术,比较操做,以及操纵栈的指令。
  • 控制流。 咱们的例子没有包含这些,但当你须要有条件执行或循环执行,你就会须要控制流。 在字节码这样底层的语言中,它们出奇地简单:跳转。

在咱们的指令循环中,须要索引来跟踪执行到了字节码的哪里。 跳转指令作的是修改这个索引并改变将要执行的指令。 换言之,这就是goto。你能够基于它制定各类更高级别的控制流。

  • 抽象。 若是用户开始在数据中定义不少的东西,最终要重用字节码的部分位,而不是复制和粘贴。 你也许会须要可调用过程这样的东西。

最简单的形式中,过程并不比跳转复杂。 惟一不一样的是,虚拟机须要管理另外一个返回栈。 当执行“call”指令时,将当前指令索引压入栈中,而后跳转到被调用的字节码。 当它到了“return”,虚拟机从堆栈弹出索引,而后跳回索引指示的位置。

数值是如何表示的?

咱们的虚拟机示例只与一种数值打交道:整数。 回答这个问题很简单——栈只是一栈的int 更加完整的虚拟机支持不一样的数据类型:字符串,对象,列表等。 你必须决定在内部如何存储这些值。

  • 单一数据类型:
    • 简单易用 你没必要担忧标记,转换,或类型检查。
    • 没法使用不一样的数据类型。 这是明显的缺点。将不一样类型成塞进单一的表示方式——好比将数字存储为字符串——这是自找麻烦。
  • 带标记的类型:

这是动态类型语言中常见的表示法。 全部的值有两部分。 第一部分是类型标识——一个存储了数据的类型的enum。其他部分会被解释为这种类型:

enum ValueType
{
  TYPE_INT,
  TYPE_DOUBLE,
  TYPE_STRING
};
 
struct Value
{
  ValueType type;
  union
  {
    int    intValue;
    double doubleValue;
    char*  stringValue;
  };
};
    • 数值知道其类型。 这个表示法的好处是可在运行时检查值的类型。 这对动态调用很重要,能够确保没有在类型上面执行其不支持的操做。
    • 消耗更多内存。 每一个值都要带一些额外的位来标识类型。在像虚拟机这样的底层,这里几位,那里几位,总量就会快速增长。
  • 无标识的union

像前面同样使用union,可是没有类型标识。 你能够将这些位表示为不一样的类型,由你确保没有搞错值的类型。

这是静态类型语言在内存中表示事物的方式。 因为类型系统在编译时保证没弄错值的类型,不须要在运行时对其进行验证。

这也是无类型语言,像汇编和Forth存储值的方式。 这些语言让用户保证不会写出误认值的类型的代码。毫无服务态度!

    • 结构紧凑。 找不到比只存储须要的值更加有效率的存储方式。
    • 速度快。 没有类型标识意味着在运行时无需消耗周期检查它们的类型。这是静态类型语言每每比动态类型语言快的缘由之一。
    • 不安全。 这是真正的代价。一块错误的字节码,会让你误解一个值,把数字误解为指针,会破坏游戏安全性从而致使崩溃。

若是你的字节码是由静态类型语言编译而来,你也许认为它是安全的,由于编译不会生成不安全的字节码。 那也许是真的,但记住恶意用户也许会手写恶意代码而不通过你的编译器。

举个例子,这就是为何Java虚拟机在加载程序时要作字节码验证

  • 接口:

多种类型值的面向对象解决方案是经过多态。接口为不一样的类型的测试和转换提供虚方法,以下:

class Value
{
public:
  virtual ~Value() {}
 
  virtual ValueType type() = 0;
 
  virtual int asInt() {
    // 只能在int上调用
    assert(false);
    return 0;
  }
 
  // 其余转换方法……
};

而后你为每一个特定的数据类型设计特定的类,如:

class IntValue : public Value
{
public:
  IntValue(int value)
  : value_(value)
  {}
 
  virtual ValueType type() { return TYPE_INT; }
  virtual int asInt() { return value_; }
 
private:
  int value_;
};
    • 开放。 可在虚拟机的核心以外定义新的值类型,只要它们实现了基本接口就行。
    • 面向对象。 若是你坚持OOP原则,这是正确的作法,为特定类型使用多态分配行为,而不是在标签上作switch之类的。
    • 冗长。 必须定义单独的类,包含了每一个数据类型的相关行为。 注意在前面的例子中,这样的类定义了全部的类型。在这里,只包含了一个!
    • 低效。 为了使用多态,必须使用指针,这意味着即便是短小的值,如布尔和数字,也得裹在堆中分配的对象里。 每使用一个值,你就得作一次虚方法调用。

在虚拟机核心之类的地方,像这样的性能影响会迅速叠加。 事实上,这引发了许多咱们试图在解释器模式中避免的问题。 只是如今的问题不在代码中,而是在中。

个人建议是:若是能够,只用单一数据类型。 除此之外,使用带标识的union。这是世界上几乎每一个语言解释器的选择。

如何生成字节码?

我将最重要的问题留到最后。咱们已经完成了消耗解释字节码的部分, 但需你要写制造字节码的工具。 典型的解决方案是写个编译器,但它不是惟一的选择。

  • 若是你定义了基于文本的语言:
    • 必须定义语法。 业余和专业的语言设计师小看这件事情的难度。让解析器高兴很简单,让用户快乐很

语法设计是用户界面设计,当你将用户界面限制到字符构成的字符串,这可没把事情变简单。

    • 必须实现解析器。 无论名声如何,这部分其实很是简单。不管使用ANTLRBison,仍是——像我同样——手写递归降低,均可以完成。
    • 必须处理语法错误。 这是最重要和最困难的部分。 当用户制造了语法和语义错误——他们总会这么干——引导他们返回到正确的道路是你的任务。 解析器只知道接到了意外的符号,给予有用的的反馈并不容易。
    • 可能会对非技术用户关上大门。 咱们程序员喜欢文本文件。结合强大的命令行工具,咱们把它们看成计算机的乐高积木——简单,有百万种方式组合。

大部分非程序员不这样想。 对他们来讲,输入文本文件就像为愤怒机器人审核员填写税表,若是忘记了一个分号就会遭到痛斥。

  • 若是你定义了一个图形化创做工具:
    • 必须实现用户界面。 按钮,点击,拖动,诸如此类。 有些人畏惧它,但我喜欢它。 若是沿着这条路走下去,设计用户界面和工做核心部分同等重要——而不是硬着头皮完成的乱七八糟工做。

每点额外工做都会让工具更容易更温馨地使用,并直接致使了游戏中更好的内容。 若是你看看不少游戏制做过程的内部解密,常常会发现制做有趣的创造工具是秘诀之一。

    • 有较少的错误状况。 因为用户经过交互式一步一步地设计行为,应用程序能够尽快引导他们走出错误。

而使用基于文本的语言时,直到用户输完整个文件才能看到用户的内容,预防和处理错误更加困难。

    • 更难移植。 文本编译器的好处是,文本文件是通用的。编译器简单地读入文件并写出。跨平台移植的工做实在微不足道。

除了换行符。还有编码。

当你构建用户界面,你必须选择要使用的架构,其中不少是基于某个操做系统。 也有跨平台的用户界面工具包,但他们每每要为对全部平台一样适用付出代价——它们在不一样的平台上一样差别很大。

参见

  • 这一章节的近亲是GoF解释器模式。两种方式都能让你用数据组合行为。

事实上,最终你两种模式会使用。你用来构造字节码的工具会有内部的对象树。这也是解释器模式所能作的。

为了编译到字节码,你须要递归回溯整棵树,就像用解释器模式去解释它同样。 惟一的 不一样在于,不是当即执行一段行为,而是生成整个字节码再执行。

  • Lua是游戏中最普遍应用的脚本语言。 它的内部被实现为一个很是紧凑的,基于寄存器的字节码虚拟机。
  • Kismet是个可视化脚本编辑工具,应用于Unreal引擎的编辑器UnrealEd
  • 个人脚本语言Wren,是一个简单的,基于栈的字节码解释器。

4.2子类沙箱

游戏设计模式Behavioral Patterns

意图

用一系列由基类提供的操做定义子类中的行为。

动机

每一个孩子都梦想过变成超级英雄,可是不幸的是,高能射线在地球上很短缺。 游戏是让你扮演超级英雄最简单的方法。 由于咱们的游戏设计者历来没有学会说咱们的超级英雄游戏中有成百上千种不一样的超级能力可供选择。

咱们的计划是建立一个Superpower基类。而后由它派生出各类超级能力的实现类。 咱们在程序员队伍中分发设计文档,而后开始编程。 当咱们完成时,咱们就会有上百种超级能力类。

当你发现像这个例子同样有不少子类时,那一般意味着数据驱动的方式更好。 再也不用代码定义不一样的能力,用数据吧。

类型对象字节码,和解释器模式都能帮忙。

咱们想让玩家处于拥有无限可能的世界中。不管他们在孩童时想象过什么能力,咱们都要在游戏中展示。 这就意味着这些超能力子类须要作任何事情: 播放声音,产生视觉刺激,与AI交互,建立和销毁其余游戏实体,与物理打交道。没有哪处代码是它们不会接触的。

假设咱们让团队信马由缰地写超能力类。会发生什么?

  • 会有不少冗余代码。 当超能力种类繁多,咱们能够预期有不少重叠。 不少超能力都会用相同的方式产生视觉效果并播放声音。 当你坐下来看看,冷冻光线,热能光线,芥末酱光线都很类似。 若是人们实现这些的时候没有协同,那就会有不少冗余的代码和重复劳动。
  • 游戏引擎中的每一部分都会与这些类耦合。 没有深刻了解的话,任何人都能写出直接调用子系统的代码,但子系统历来没打算直接与超能力类绑定。 就算渲染系统被好好组织成多个层次,只有一个能被外部的图形引擎使用, 咱们能够打赌,最终超能力代码会与每个接触。
  • 当外部代码须要改变时,一些随机超能力代码有很大概率会损坏。 一旦咱们有了不一样的超能力类绑定到游戏引擎的多个部分,改变那些部分必然影响超能力类。 这可不合理,由于图形,音频,UI程序员极可能不想成为玩法程序员。
  • 很难定义全部超能力遵照的不变量。 假设咱们想保证超能力播放的全部音频都有正确的顺序和优先级。 若是咱们的几百个类都直接调用音频引擎,就没什么好办法来完成这点。

咱们要的是给每一个实现超能力的玩法程序员一系列可以使用的基本单元。 你想要播放声音?这是你的playSound()函数。 你想要粒子效果?这是你的spawnParticles()函数。 咱们保证了这些操做覆盖了你要作的事情,因此你不须要#include随机的头文件,干扰到代码库的其余部分。

咱们实现的方法是经过定义这些操做为Superpower基类protected方法 将它们放在基类给了每一个子类直接便捷的途径获取方法。 让它们成为protected(极可能不是虚方法)方法暗示了它们存在就是为了被子类调用

一旦有了这些东西来使用,咱们须要一个地方使用他们。 为了作到这点,咱们定义沙箱方法,这是子类必须实现的抽象的protected方法。 有了这些,要实现一种新的能力,你须要:

  1. 建立从Superpower继承的新类。
  2. 重载沙箱方法activate()
  3. 经过调用Superpower提供的protected方法实现主体。

咱们如今能够使用这些高层次的操做来解决冗余代码问题了。 当咱们看到代码在多个子类间重复,咱们总能够将其打包到Superpower中,做为它们均可以使用的新操做。

咱们经过将耦合约束到一个地方解决了耦合问题。 Superpower最终与不一样的系统耦合,可是继承它的几百个类不会。 相反,它们耦合基类。 当游戏系统的某部分改变时,修改Superpower也许是必须的,可是众多的子类不须要修改。

这个模式带来浅层可是普遍的类层次。 你的继承链不,可是有不少类与Superpower挂钩。 经过使用有不少直接子类的基类,咱们在代码库中创造了一个支撑点。 咱们投入到Superpower的时间和爱能够让游戏中众多类获益。

最近,你会发现不少人批评面向对象语言中的继承。 继承有问题——在代码库中没有比父类子类之间的耦合更深的了——但我发现扁平的继承树比起深的继承树更好处理。

模式

基类定义抽象的沙箱方法和几个提供的操做 将操做标为protected,代表它们只为子类所使用。 每一个推导出的沙箱子类用提供的操做实现了沙箱函数。

什么时候使用

子类沙箱模式是潜伏在代码库中简单经常使用的模式,哪怕是在游戏以外的地方亦有应用。 若是你有一个非虚的protected方法,你可能已经在用相似的东西了。 沙箱方法在如下状况适用:

  • 你有一个能推导不少子类的基类。
  • 基类能够提供子类须要的全部操做。
  • 在子类中有行为重复,你想要更容易地在它们间分享代码。
  • 你想要最小化子类和程序的其余部分的耦合。

记住

继承近来在不少编程圈子为人诟病,缘由之一是基类趋向于增长愈来愈多的代码 这个模式特别容易染上这个毛病。

因为子类经过基类接触游戏的剩余部分,基类最后和子类须要的每一个系统耦合。 固然,子类也紧密地与基类相绑定。这种蛛网耦合让你很难在不破坏什么的状况下改变基类——你获得了(脆弱的基类问题)brittle base class problem

硬币的另外一面是因为你耦合的大部分都被推到了基类,子类如今与世界的其余部分分离。 理想的状况下,你大多数的行为都在子类中。这意味着你的代码库大部分是孤立的,很容易管理。

若是你发现这个模式正把你的基类变成一锅代码糊糊, 考虑将它提供的一些操做放入分离的类中, 这样基类能够分散它的责任。组件模式能够在这里帮上忙。

示例代码

由于这个模式太简单了,示例代码中没有太多东西。 这不是说它没用——这个模式关键在于意图,而不是它实现的复杂度。

咱们从Superpower基类开始:

class Superpower
{
public:
  virtual ~Superpower() {}
 
protected:
  virtual void activate() = 0;
 
  void move(double x, double y, double z)
  {
    // 实现代码……
  }
 
  void playSound(SoundId sound, double volume)
  {
    // 实现代码……
  }
 
  void spawnParticles(ParticleType type, int count)
  {
    // 实现代码……
  }
};

activate()方法是沙箱方法。因为它是抽象虚函数,子类必须重载它。 这让那些须要建立子类的人知道要作哪些工做。

其余的protected函数move()playSound(),和spawnParticles()都是提供的操做。 它们是子类在实现activate()时要调用的。

在这个例子中,咱们没有实现提供的操做,但真正的游戏在那里有真正的代码。 那些代码中,Superpower与游戏中其余部分的耦合——move()也许调用物理代码,playSound()会与音频引擎交互,等等。 因为这都在基类的实现中,保证了耦合封闭在Superpower中。

好了,拿出咱们的放射蜘蛛,建立个能力。像这样:

class SkyLaunch : public Superpower
{
protected:
  virtual void activate()
  {
    // 空中滑行
    playSound(SOUND_SPROING, 1.0f);
    spawnParticles(PARTICLE_DUST, 10);
    move(0, 0, 20);
  }
};

好吧,也许跳跃不是超级能力,但我在这里讲的是基础知识。

这种能力将超级英雄射向天空,播放合适的声音,扬起尘土。 若是全部的超能力都这样简单——只是声音,粒子效果,动做的组合——那么就根本不须要这个模式了。 相反,Superpower有内置的activate()能获取声音ID,粒子类型和运动的字段。 可是这只在全部能力运行方式相同,只在数据上不一样时才可行。让咱们精细一些:

class Superpower
{
protected:
  double getHeroX()
  {
    // 实现代码……
  }
 
  double getHeroY()
  {
    // 实现代码……
  }
 
  double getHeroZ()
  {
    // 实现代码……
  }
 
  // 退出之类的……
};

这里咱们增长了些方法获取英雄的位置。咱们的SkyLaunch如今能够使用它们了:

class SkyLaunch : public Superpower
{
protected:
  virtual void activate()
  {
    if (getHeroZ() == 0)
    {
      // 在地面上,冲向空中
      playSound(SOUND_SPROING, 1.0f);
      spawnParticles(PARTICLE_DUST, 10);
      move(0, 0, 20);
    }
    else if (getHeroZ() < 10.0f)
    {
      // 接近地面,再跳一次
      playSound(SOUND_SWOOP, 1.0f);
      move(0, 0, getHeroZ() + 20);
    }
    else
    {
      // 正在空中,跳劈攻击
      playSound(SOUND_DIVE, 0.7f);
      spawnParticles(PARTICLE_SPARKLES, 1);
      move(0, 0, -getHeroZ());
    }
  }
};

因为咱们如今能够访问状态,沙箱方法能够作有用有趣的控制流了。 这还须要几个简单的if声明, 但你能够作任何你想作的东西。 使用包含任意代码的成熟沙箱方法,天高任鸟飞了。

早先,我建议以数据驱动的方式创建超能力。 这里是你可能想那么作的缘由之一。 若是你的行为复杂而使用命令式风格,它更难在数据中定义。

设计决策

如你所见,子类沙箱是一个模式。它表述了一个基本思路,可是没有不少细节机制。 这意味着每次使用都面临着一些有趣的选择。这里是一些须要思考的问题。

应该提供什么操做?

这是最大的问题。这深深影响了模式感受上和实际上有多好。 在一个极端,基类几乎不提供任何操做。只有一个沙箱方法。 为了实现功能,老是须要调用基类外部的系统。若是你这样作,很难说你在使用这个模式。

另外一个极端,基类提供了全部子类也许须要的操做。 子类与基类耦合,不调用任何外部系统的东西。

具体来讲,这意味着每一个子类的源文件只须要#include它的基类头文件。

在这两个极端之间,操做由基类提供仍是向外部直接调用有很大的操做余地。 你提供的操做越多,外部系统与子类耦合越少,可是与基类耦合越多 从子类中移除了耦合是经过将耦合推给基类完成的。

若是你有一堆与外部系统耦合的子类的话,这很好。 经过将耦合移到提供的操做中,你将其移动到了一个地方:基类。可是你越这么作,基类就越大越难管理。

因此分界线在哪里?这里是一些首要原则:

  • 若是提供的操做只被一个或几个子类使用,将操做加入基类获益不会太多。 你向基类添加了会影响全部事物的复杂性,可是只有少数几个类受益。

让该操做与其余提供的操做保持一致或许有价值,但让使用操做的子类直接调用外部系统也许更简单明了。

  • 当你调用游戏中其余地方的方法,若是方法没有修改状态就有更少的干扰。 它仍然制造耦合,可是这是安全的耦合,由于它没有破坏游戏中的任何东西。

安全的在这里打了引号是由于严格来讲,接触数据也能形成问题。 若是你的游戏是多线程的,读取的数据可能正在被修改。若是你不当心,就会读入错误的数据。

另外一个不愉快的状况是,若是你的游戏状态是严格肯定性的(不少在线游戏为了保持玩家同步都是这样的)。 接触了游戏同步状态以外的东西会形成极糟的不肯定性漏洞。

另外一方面,修改状态的调用会和代码库的其余方面紧密绑定,你须要三思。打包他们成基类提供的操做是个好的候选项。

  • 若是操做只是增长了向外部系统的转发调用,那它就没增长太多价值。那种状况下,也许直接调用外部系统的方法更简单。

可是,简单的转发也是有用的——那些方法接触了基类不想直接暴露给子类的状态。 举个例子,假设Superpower提供这个:

void playSound(SoundId sound, double volume)
{
  soundEngine_.play(sound, volume);
}

它只是转发调用给SuperpowersoundEngine_字段。 可是,好处是将字段封装在Superpower中,避免子类接触。

方法应该直接提供,仍是包在对象中提供?

这个模式的挑战是基类中最终加入了不少方法。 你能够将一些方法移到其余类中来缓和。基类经过返回对象提供方法。

举个例子,为了让超能力播放声音,咱们能够直接将它们加到Superpower中:

class Superpower
{
protected:
  void playSound(SoundId sound, double volume)
  {
    // 实现代码……
  }
 
  void stopSound(SoundId sound)
  {
    // 实现代码……
  }
 
  void setVolume(SoundId sound)
  {
    // 实现代码……
  }
 
  // 沙盒方法和其余操做……
};

可是若是Superpower已经很庞杂了,咱们也许想要避免这样。 取而代之的是建立SoundPlayer类暴露该函数:

class SoundPlayer
{
  void playSound(SoundId sound, double volume)
  {
    // 实现代码……
  }
 
  void stopSound(SoundId sound)
  {
    // 实现代码……
  }
 
  void setVolume(SoundId sound)
  {
    // 实现代码……
  }
};

Superpower提供了对其的接触:

class Superpower
{
protected:
  SoundPlayer& getSoundPlayer()
  {
    return soundPlayer_;
  }
 
  // 沙箱方法和其余操做……
 
private:
  SoundPlayer soundPlayer_;
};

将提供的操做分流到辅助类能够为你作一些事情:

  • 减小了基类中的方法。 在这里的例子中,将三个方法变成了一个简单的获取函数。
  • 在辅助类中的代码一般更好管理。 Superpower的核心基类,无论意图如何好,它被太多的类依赖而很难改变。 经过将函数移到耦合较少的次要类,代码变得更容易被使用而不破坏任何东西。
  • 减小了基类和其余系统的耦合度。 playSound()方法直接在Superpower时,基类与SoundId以及其余涉及的音频代码直接绑定。 将它移动到SoundPlayer中,减小了SuperpowerSoundPlayer类的耦合,这就封装了它其余的依赖。

基类如何得到它须要的状态?

你的基类常常须要将对子类隐藏的数据封装起来。 在第一个例子中,Superpower类提供了spawnParticles()方法。 若是方法的实现须要一些粒子系统对象,怎么得到呢?

  • 将它传给基类构造器:

最简单的解决方案是让基类将其做为构造器变量:

class Superpower
{
public:
  Superpower(ParticleSystem* particles)
  : particles_(particles)
  {}
 
  // 沙箱方法和其余操做……
 
private:
  ParticleSystem* particles_;
};

这安全地保证了每一个超能力在构造时能获得粒子系统。但让咱们看看子类:

class SkyLaunch : public Superpower
{
public:
  SkyLaunch(ParticleSystem* particles)
  : Superpower(particles)
  {}
};

咱们在这儿看到了问题。每一个子类都须要构造器调用基类构造器并传递变量。这让子类接触了咱们不想要它知道的状态。

这也形成了维护的负担。若是咱们后续向基类添加了状态,每一个子类都须要修改并传递这个状态。

  • 使用两阶初始化:

为了不经过构造器传递全部东西,咱们能够将初始化划分为两个部分。 构造器不接受任何参数,只是建立对象。而后,咱们调用定义在基类的分离方法传入必要的数据:

Superpower* power = new SkyLaunch();
power->init(particles);

注意咱们没有为SkyLaunch的构造器传入任何东西,它与Superpower中想要保持私有的任何东西都不耦合。 这种方法的问题在于,你要保证永远记得调用init(),若是忘了,你会得到处于半完成的,没法运行的超能力。

你能够将整个过程封装到一个函数中来修复这一点,就像这样:

Superpower* createSkyLaunch(ParticleSystem* particles)
{
  Superpower* power = new SkyLaunch();
  power->init(particles);
  return power;
}

使用一点像私有构造器和友类的技巧,你能够保证createSkylaunch()函数是惟一可以建立能力的函数。 这样,你不会忘记任何初始化步骤。

  • 让状态静态化:

在先前的例子中,咱们用粒子系统初始化每个Superpower实例 在每一个能力都须要本身独特的状态时这是有意义的。可是若是粒子系统是单例,那么每一个能力都会分享相同的状态。

若是是这样,咱们能够让状态是基类私有而静态的。 游戏仍然要保证初始化状态,可是它只须要为整个游戏初始化Superpower一遍,而不是为每一个实例初始化一遍。

记住单例仍然有不少问题。你在不少对象中分享了状态(全部的Superpower实例)。 粒子系统被封装了,所以它不是全局可见的,这很好,但它们都访问同一对象,这让分析更加困难了。

class Superpower
{
public:
  static void init(ParticleSystem* particles)
  {
    particles_ = particles;
  }
 
  // 沙箱方法和其余操做……
 
private:
  static ParticleSystem* particles_;
};

注意这里的init()particles_都是静态的。 只要游戏早先调用过一次Superpower::init(),每种能力都能接触粒子系统。 同时,能够调用正确的推导类构造器来自由建立Superpower实例。

更棒的是,如今particles_静态变量, 咱们不须要在每一个Superpower中存储它,这样咱们的类占据的内存更少了。

  • 使用服务定位器:

前一选项中,外部代码要在基类请求前压入基类须要的所有状态。 初始化的责任交给了周围的代码。另外一选项是让基类拉取它须要的状态。 而作到这点的一种实现方法是使用服务定位器模式:

class Superpower
{
protected:
  void spawnParticles(ParticleType type, int count)
  {
    ParticleSystem& particles = Locator::getParticles();
    particles.spawn(type, count);
  }
 
  // 沙箱方法和其余操做……
};

这儿,spawnParticles()须要粒子系统,不是外部系统它,而是它本身从服务定位器中拿了一个。

参见

  • 当你使用更新模式时,你的更新函数一般也是沙箱方法。
  • 这个模式与模板方法正相反。 两种模式中,都使用一系列受限操做实现方法。 使用子类沙箱时,方法在推导类中,受限操做在基类中。 使用模板方法时,基类 有方法,而受限操做在推导类中。
  • 你也能够认为这个模式是外观模式的变形。 外观模式将一系列不一样系统藏在简化的API后。使用子类沙箱,基类起到了在子类前隐藏整个游戏引擎的做用。

4.3类型对象

游戏设计模式Behavioral Patterns

意图

创造一个类A来容许灵活地创造新类型,类A的每一个实例都表明了不一样的对象类型。

动机

想象咱们在制做一个奇幻RPG游戏。 咱们的任务是为一群想要杀死英雄的恶毒怪物编写代码。 怪物有多个的属性:生命值,攻击力,图形效果,声音表现,等等。 可是为了说明介绍的目的咱们先只考虑前面两个。

游戏中的每一个怪物都有当前血值。 开始时是满的,每次怪物受伤,它就降低。 怪物也有一个攻击字符串。 当怪物攻击咱们的英雄,那个文本就会以某种方式展现给用户。 (咱们不在意这里怎样实现。)

设计者告诉咱们怪物有不一样品种,像或者巨魔 每一个品种都描述了一存在于游戏中的怪物,同时可能有多个同种怪物在地牢里游荡。

品种决定了怪物的初始健康——龙开始的血量比巨魔多,它们更难被杀死。 这也决定了攻击字符——同种的全部怪物都以相同的方式进行攻击。

传统的面向对象方案

想着这样的设计方案,咱们启动了文本编辑器开始编程。 根据设计,龙是一种怪物,巨魔是另外一种,其余品种的也同样。 用面向对象的方式思考,这引导咱们建立Monster基类。

这是一种“是某物”的关系。 在传统OOP思路中,因为龙“是”怪物,咱们用DragonMonster的子类来描述这点。 如咱们将看到的,继承是一种将这种关系表示为代码的方法。

class Monster
{
public:
  virtual ~Monster() {}
  virtual const char* getAttack() = 0;
 
protected:
  Monster(int startingHealth)
  : health_(startingHealth)
  {}
 
private:
  int health_; // 当前血值
};

在怪物攻击英雄时,公开的getAttack()函数让战斗代码能得到须要显示的文字。 每一个子类都须要重载它来提供不一样的消息。

构造器是protected的,须要传入怪物的初始血量。 每一个品种的子类的公共构造器调用这个构造器,传入对于该品种适合的起始血量。

如今让咱们看看两个品种子类:

class Dragon : public Monster
{
public:
  Dragon() : Monster(230) {}
 
  virtual const char* getAttack()
  {
    return "The dragon breathes fire!";
  }
};
 
class Troll : public Monster
{
public:
  Troll() : Monster(48) {}
 
  virtual const char* getAttack()
  {
    return "The troll clubs you!";
  }
};

感叹号让全部事情都更刺激!

每一个从Monster派生出来的类都传入起始血量,重载getAttack()返回那个品种的攻击字符串。 全部事情都一如所料地运行,不久之后,咱们的英雄就能够跑来跑去杀死各类野兽了。 咱们继续编程,在乎识到以前,咱们就有了从酸泥怪到僵尸羊的众多怪物子类。

而后,很奇怪,事情陷入了困境。 设计者最终想要几百个品种,可是咱们发现全部的时间都花费在写这些只有七行长的子类和从新编译上。 这会继续变糟——设计者想要协调已经编码的品种。咱们以前富有产出的工做日退化成了:

  1. 收到设计者将巨魔的血量从48改到52的邮件。
  2. 签出并修改Troll.h
  3. 从新编译游戏。
  4. 签入修改。
  5. 回复邮件。
  6. 重复。

咱们度过了失意的一天,由于咱们变成了填数据的猴子。 设计者也感到挫败,由于修改一个数据就要老久。 咱们须要的是一种无需每次从新编译游戏就能修改品种的状态。 若是设计者建立和修改品种时无需任何程序员的介入那就更好了。

为类型建类

从较高的层次看来,咱们试图解决的问题很是简单。 游戏中有不少不一样的怪物,咱们想要在它们之间分享属性。 一大群怪物在攻击英雄,咱们想要它们中的一些使用相同的攻击文本。 咱们声明这些怪物是相同的品种,而品种决定了攻击字符串。

这种状况下咱们很容易想到类,那就试试吧。 龙是怪物,每条龙都是龙的实例。 定义每一个品种为抽象基类Monster 的子类,让游戏中每一个怪物都是子类的实例反映了那点。最终的类层次是这样的:

这里的意为“从……继承”。

每一个怪物的实例属于某个继承怪物类的类型。 咱们有的品种越多,类层次越高。 这固然是问题:添加新品种就须要添加新代码,而每一个品种都须要被编译为它本身的类型。

这可行,但不是惟一的选项。 咱们也能够重构代码让每一个怪物品种。 不是让每一个品种继承Monster,咱们如今有单一的Monster类和Breed类。

这里意为“被……引用”。

这就成了,就两个类。注意这里彻底没有继承。 经过这个系统,游戏中的每一个怪物都是Monster的实例。 Breed类包含了在不一样品种怪物间分享的信息:开始血量和攻击字符串。

为了将怪物与品种相关联,咱们给了每一个Monster实例对包含品种信息的Breed对象的引用。 为了得到攻击字符串,一个怪兽能够调用它品种的方法。 Breed类本质上定义了一个怪物的类型,这就是为啥这个模式叫作类型对象。

这个模式特别有用的一点是,咱们如今能够定义全新的类型而无需搅乱代码库。 咱们本质上将部分的类型系统从硬编码的继承结构中拉出,放到能够在运行时定义的数据中去。

咱们能够经过用不一样值实例化Monster来建立成百上千的新品种。 若是从配置文件读取不一样的数据初始化品种,咱们就有能力彻底靠数据定义新怪物品种。 这么容易,设计者也能够作到!

模式

定义类型对象类和有类型的对象类。每一个类型对象实例表明一种不一样的逻辑类型。 每种有类型的对象保存对描述它类型的类型对象的引用

实例相关的数据被存储在有类型对象的实例中,被同种类分享的数据或者行为存储在类型对象中。 引用同一类型对象的对象将会像同一类型同样运做。 这让咱们在一组相同的对象间分享行为和数据,就像子类让咱们作的那样,但没有固定的硬编码子类集合。

什么时候使用

在任何你须要定义不一样事物,可是语言自身的类型系统过于僵硬的时候使用该模式。尤为是下面二者之一成立时:

  • 你不知道你后面还须要什么类型。(举个例子,若是你的游戏须要支持资料包,而资料包有新的怪物品种呢?)
  • 想不改变代码或者从新编译就能修改或添加新类型。

记住

这个模型是关于将类型的定义从命令式僵硬的语言世界移到灵活可是缺乏行为的对象内存世界。 灵活性很好,可是将类型提到数据丧失了一些东西。

须要手动追踪类型对象

使用像C++类型系统这种东西的好处之一就是编译器自动记录类的注册。 定义类的数据自动编译到可执行的静态内存段而后就运做起来了。

使用类型对象模式,咱们如今不但要负责管理内存中的怪物,同时要管理它们的类型 ——咱们要保证,只要个人怪物须要,全部的品种对象都能实例化并保存在内存中。 不管什么时候建立新的怪物,由咱们来保证能初始化为含有品种的引用。

咱们从编译器的限制中解放了本身,可是代价是须要从新实现一些它之前为咱们作的事情。

C++内部使用了“虚函数表”(“vtable”)实现虚方法。 虚函数表是个简单的struct,包含了一集合函数指针,每一个对应一个类中的虚方法。 在内存中每一个类有一个虚函数表。每一个类的实例有一个指针指向它的类的虚函数表。

当你调用一个虚函数,代码首先在虚函数表中查找对象,而后调用表中函数指针指向的函数。

听起来很熟悉?虚函数表就是个品种对象,而指向虚函数表的指针是怪物保留的、指向品种的引用。 C++的类是C中的类型对象,由编译器自动处理。

更难为每种类型定义行为

使用子类派生,你能够重载方法,而后作你想作的事——用程序计算值,调用其余代码,等等。 天高任鸟飞。若是咱们想的话,能够定义一个怪物子类,根据月亮的阶段改变它的攻击字符串。(我以为就像狼人。)

当咱们使用类型对象模式时,咱们将重载的方法替换成了成员变量。 再也不让怪物的子类重载方法,用不一样的代码计算攻击字符串,而是让咱们的品种对象在不一样的变量存储攻击字符串。

这让使用类型对象定义类型相关的数据变得容易,可是定义类型相关的行为变得困难。 若是,举个例子,不一样品种的怪物须要使用不一样的AI算法,使用这个模式就面临着挑战。

有不少方式能够让咱们跨越这个限制。 一个简单的方式是使用预先定义的固定行为, 而后类型对象中的数据简单地选择它们中的一个。 举例,假设咱们的怪物AI老是处于站着不动追逐英雄或者恐惧地呜咽颤抖(嘿,他们不可能都是强势的龙)状态。 咱们能够定义函数来实现每种行为。 而后,咱们在方法中存储合适函数的引用,将AI算法与品种相关联。

听起来很熟悉?这是在咱们的类型对象中实现虚函数表。

另外一个更加完全的解决方案是真正地在数据中支持定义行为。 解释器模式和字节码模式让咱们定义有行为的对象。 若是咱们读取数据文件并用上面两种模式之一构建数据结构,咱们就将行为彻底从代码中移出,放入了数据之中。

时过境迁,游戏愈来愈多地由数据驱动。 硬件变得更为强大,咱们发现比起能榨干多少硬件的性能,瓶颈更多于在能完成多少内容。 使用64K软盘的时代,挑战是将游戏塞入其中。 而在使用双面DVD的时代,挑战是用游戏填满它。

脚本语言和其余定义游戏行为的高层方式能给咱们提供必要的生产力,同时只消耗可预期的运行时性能。 因为硬件愈来愈好,而大脑并不是如此,这种交换愈来愈有意义。

示例代码

在第一遍实现中,让咱们从简单的开始,只构建动机那节提到的基础系统。 咱们从Breed类开始:

class Breed
{
public:
  Breed(int health, const char* attack)
  : health_(health),
    attack_(attack)
  {}
 
  int getHealth() { return health_; }
  const char* getAttack() { return attack_; }
 
private:
  int health_; // 初始血值
  const char* attack_;
};

很简单。它基本上只是两个数据字段的容器:起始血量和攻击字符串。 让咱们看看怪物怎么使用它:

class Monster
{
public:
  Monster(Breed& breed)
  : health_(breed.getHealth()),
    breed_(breed)
  {}
 
  const char* getAttack()
  {
    return breed_.getAttack();
  }
 
private:
  int    health_; // 当前血值
  Breed& breed_;
};

当咱们建构怪物时,咱们给它一个品种对象的引用。 它定义了怪物的品种,取代了以前的子类。 在构造函数中,Monster使用的品种决定了起始血量。 为了得到攻击字符串,怪物简单地将调用转发给它的品种。

这段很是简单的代码是这章的核心思路。剩下的任何东西都是红利。

让类型对象更像类型:构造器

如今,咱们能够直接构造怪物并负责传入它的品种。 和经常使用的OOP语言实现的对象相比这有些退步——咱们一般不会分配一块空白内存,而后赋予它类型。 相反,咱们根据类调用构造器,它负责建立一个新实例。

咱们能够在类型对象上应用一样的模式。

class Breed
{
public:
  Monster* newMonster() { return new Monster(*this); }
 
  // Previous Breed code...
};

“模式”一词用在这里正合适。咱们讨论的是设计模式中经典的模式:工厂方法

在一些语言中,这个模式被用来构造全部的对象。 在Ruby,Smalltalk,Objective-C以及其余类是对象的语言中,你经过在类对象自己上调用方法来构建实例。

以及那个使用它们的类:

class Monster
{
  friend class Breed;
 
public:
  const char* getAttack() { return breed_.getAttack(); }
 
private:
  Monster(Breed& breed)
  : health_(breed.getHealth()),
    breed_(breed)
  {}
 
  int health_; // 当前血值
  Breed& breed_;
};

不一样的关键点在于Breed中的newMonster() 这是咱们的构造器工厂方法。使用咱们原先的实现,就像这样建立怪物:

这里还有一个小小的不一样。 由于样例代码由C++写就,咱们能够使用一个小小的特性:友类

咱们让Monster的构造器成为私有,防止了任何人直接调用它。 友类放松了这个限制,Breed仍可接触它。 这意味着构造怪物的惟一方法是经过newMonster()

Monster* monster = new Monster(someBreed);

在咱们改动后,它看上去是这样:

Monster* monster = someBreed.newMonster();

因此,为何这么作?建立一个对象分为两步:内存分配和初始化。 Monster的构造器让咱们作完了全部须要的初始化。 在例子中,那只存储了类型;可是在完整的游戏中,那须要加载图形,初始化怪物AI以及作其余的设置工做。

可是,那都发生在内存分配以后 在构造器调用前,咱们已经找到了内存放置怪物。 在游戏中,咱们一般也想控制对象创造这一环节: 咱们一般使用自定义的分配器或者对象池模式来控制对象最终在内存中的位置。

Breed中定义构造器函数给了咱们地方实现这些逻辑。 不是简单地调用new,newMonster()函数能够在将控制权传递给Monster初始化以前,从池中或堆中获取内存。 经过在惟一有能力建立怪物的Breed函数中放置这些逻辑, 咱们保证了全部怪物变量遵照了内存管理规范。

经过继承分享数据

咱们如今已经实现了能完美服务的类型对象系统,可是它很是基础。 咱们的游戏最终有上百种不一样品种,每种都有成打的特性。 若是设计者想要协调30种不一样的巨魔,让它们变得强壮一点,他会得处理不少数据。

能帮上忙的是在不一样品种间分享属性的能力,一如品种在不一样的怪物间分享属性的能力。 就像咱们在以前OOP方案中作的那样,咱们能够使用派生完成这点。 只是,此次,不使用语言的继承机制,咱们用类型对象实现它。

简单起见,咱们只支持单继承。 就像类能够有一个父类,咱们容许品种有一个父品种:

class Breed
{
public:
  Breed(Breed* parent, int health, const char* attack)
  : parent_(parent),
    health_(health),
    attack_(attack)
  {}
 
  int         getHealth();
  const char* getAttack();
 
private:
  Breed*      parent_;
  int         health_; // 初始血值
  const char* attack_;
};

当咱们构建一个品种,咱们先传入它继承的父品种。 咱们能够为基础品种传入NULL代表它没有祖先。

为了让这有用,子品种须要控制它从父品种继承了哪些属性,以及哪些属性须要重载并由本身指定。 在咱们的示例系统中,咱们能够说品种用非零值重载了怪物的健康,用非空字符串重载了攻击字符串。 不然,这些属性要从它的父品种里继承。

实现方式有两种。 一种是每次属性被请求时动态处理委托,就像这样:

int Breed::getHealth()
{
  // 重载
  if (health_ != 0 || parent_ == NULL) return health_;
 
  // 继承
  return parent_->getHealth();
}
 
const char* Breed::getAttack()
{
  // 重载
  if (attack_ != NULL || parent_ == NULL) return attack_;
 
  // 继承
  return parent_->getAttack();
}

若是品种在运行时修改种类,再也不重载,或者再也不继承某些属性时,这能保证作正确的事。 另外一方面,这要更多的内存(它须要保存指向它的父品种的指针)并且更慢。 每次你查找属性都须要回溯继承链。

若是咱们能够保证品种的属性不变,一个更快的解决方案是在构造时使用继承。 这被称为复制委托,由于在建立对象时,咱们复制继承的属性推导的类型。它看上去是这样的:

Breed(Breed* parent, int health, const char* attack)
: health_(health),
  attack_(attack)
{
  // 继承没有重载的属性
  if (parent != NULL)
  {
    if (health == 0) health_ = parent->getHealth();
    if (attack == NULL) attack_ = parent->getAttack();
  }
}

注意如今咱们再也不须要给父品种的字段了。 一旦构造器完成,咱们能够忘了父品种,由于咱们已经拷贝了它的全部属性。 为了得到品种的属性,咱们如今直接返回字段:

int         getHealth() { return health_; }
const char* getAttack() { return attack_; }

又好又快!

假设游戏引擎从品种的JSON文件加载设置而后建立类型。它看上去是这样的:

{
  "Troll": {
    "health": 25,
    "attack": "The troll hits you!"
  },
  "Troll Archer": {
    "parent": "Troll",
    "health": 0,
    "attack": "The troll archer fires an arrow!"
  },
  "Troll Wizard": {
    "parent": "Troll",
    "health": 0,
    "attack": "The troll wizard casts a spell on you!"
  }
}
 
:::json
{
  "Troll": {
    "health": 25,
    "attack": "The troll hits you!"
  },
  "Troll Archer": {
    "parent": "Troll",
    "health": 0,
    "attack": "The troll archer fires an arrow!"
  },
  "Troll Wizard": {
    "parent": "Troll",
    "health": 0,
    "attack": "The troll wizard casts a spell on you!"
  }
}

咱们有一段代码读取每一个品种,用新数据实例化品种实例。 就像你从"parent": "Troll"字段看到的, Troll ArcherTroll Wizard品种都由基础Troll品种继承而来。

因为派生类的初始血量都是0,因此该值从基础Troll品种继承。 这意味着不管怎么调整Troll的血量,三个品种的血量都会被更新。 随着品种的数量和属性的数量增长,这节约了不少时间。 如今,经过一小块代码,系统给了设计者控制权,让他们能好好利用时间。 与此同时,咱们能够回去编码其余特性了。

设计决策

类型对象模式让咱们创建类型系统,就好像在设计本身的编程语言。 设计空间是开放的,咱们能够作不少有趣的事情。

在实践中,有些东西打破了咱们的幻想。 时间和可维护性阻止咱们建立特别复杂的东西。 更重要的是,不管如何设计类型系统,用户(一般不是程序员)要能轻松地理解它。 咱们将其作得越简单,它就越有用。 因此咱们在这里谈到的是已经反复探索的领域,开辟新路就留给学者和探索者吧。

类型对象是封装的仍是暴露的?

在咱们的简单实现中,Monster有一个对品种的引用,可是它没有显式暴露这个引用。 外部代码不能直接获取怪物的品种。 从代码库的角度看来,怪物事实上是没有类型的,事实上它们拥有品种只是个实现细节。

咱们能够很容易地改变这点,让Monster返回它的Breed

class Monster
{
public:
  Breed& getBreed() { return breed_; }
 
  // 当前的代码……
};

在本书的另外一个例子中,咱们遵照了惯例,返回对象的引用而不是对象的指针,保证了永远不会返回NULL

这样作改变了Monster的设计。 事实是全部怪物都拥有品种是API的可见部分了,下面是这二者各自的好处:

  • 若是类型对象是封装的:
    • 类型对象模式的复杂性对代码库的其余部分是隐藏的。 它成为了只有有类型的对象才须要考虑的实现细节。
    • 有类型的对象能够选择性地修改类型对象的重载行为 假设咱们想要怪物在它接近死亡时改变它的攻击字符串。 因为攻击字符串老是经过Monster获取的,咱们有一个方便的地方放置代码:
    • cnst char* Mnster::getAttack()
    • {
    •   if (health_ < LOW_HEALTH)
    •   {
    •     return "The mnster flails weakly.";
    •   }
    •  
    •   return breed_.getAttack();
    • }

若是外部代码直接调用品种的getAttack(),咱们就没有机会能插入逻辑。

    • 咱们得为每一个类型对象暴露的方法写转发。 这是这个设计的冗长之处。若是类型对象有不少方法,对象类也得为每个方法创建属于本身的公共可见方法。
  • 若是类型对象是暴露的:
    • 外部代码能够与类型对象直接交互,无需拥有类型对象的实例。 若是类型对象是封装的,那么没有一个拥有它的对象就无法使用它。 这阻止咱们使用构造器模式这样的方法,在品种上调用方法来建立新怪物。 若是用户不能直接得到品种,他们就没办法调用它。
    • 类型对象如今是对象公共API的一部分了。 大致上,窄接口比宽接口更容易掌控——你暴露给代码库其余部分的越少,你须要处理的复杂度和维护工做就越少。 经过暴露类型对象,咱们扩宽了对象的API,包含了全部类型对象提供的东西。

有类型的对象是如何建立的?

使用这个模式,每一个对象如今都是一对对象:主对象和它的类型对象。 因此咱们怎样建立并绑定二者呢?

  • 构造对象而后传入类型对象:
    • 外部代码能够控制分配。 因为调用代码也是构建对象的代码,它能够控制其内存位置。 若是咱们想要UI在多种内存场景中使用(不一样的分配器,在栈中,等等),这给了完成它的灵活性。
  • 在类型对象上调用构造器函数:
    • 类型对象控制了内存分配。 这是硬币的另外一面。若是咱们不想让用户选择在内存中何处建立对象, 在类型对象上调用工厂方法能够达到这一点。 若是咱们想保证全部的对象都来自具体的对象池或者其余的内存分配器时也有用。

能改变类型吗?

到目前为止,咱们假设一旦对象建立并绑定到类型对象上,这永远不会改变。 对象建立时的类型就是它销毁时的类型。这其实没有必要。 咱们能够容许对象随着时间改变它的类型。

让咱们回想下咱们的例子。 当怪物死去时,设计者告诉咱们,有时它的尸体会复活成僵尸。 咱们能够经过在怪物死亡时产生僵尸类型的新怪兽,但另外一个选项是拿到现有的怪物,而后将它的品种改成僵尸。

  • 若是类型不改变:
    • 编码和理解都更容易。 在概念上,大多数人不指望类型会改变。这符合大多数人的理解。
    • 更容易查找漏洞。 若是咱们试图追踪怪物进入奇怪状态时的漏洞,如今看到的品种就是怪物始终保持的品种能够大大简化工做。
  • 若是类型能够改变:
    • 须要建立的对象更少。 在咱们的例子中,若是类型不能改变,咱们须要消耗CPU循环建立新的僵尸怪物对象, 把原先对象中须要保留的属性都拷贝过来,而后删除它。 若是咱们能够改变类型,全部的工做都被一个简单的声明取代。
    • 咱们须要当心地作约束。 在对象和它的类型间有强耦合是很天然的事情。 举个例子,一个品种也许假设怪物当前的血量永远高于品种中的初始血量。

若是咱们容许品种改变,咱们须要确保已存对象知足新品种的需求。 当咱们改变类型时,咱们也许须要执行一些验证代码保证对象如今的状态对新类型是有意义的。

它支持何种继承?

  • 没有继承:
    • 简单。 最简单的一般是最好的。若是你在类型对象间没有大量数据共享,为何要为难本身呢?
    • 这会带来重复的工做。 我从未见过哪一个编码系统中设计者想要继承的。 当你有十五种不一样的精灵时,协调血量就要修改十五处一样的数字真是糟透了。
  • 单继承:
    • 仍是相对简单。 它易于实现,可是,更重要的是,也易于理解。若是非技术用户正在使用这个系统,要操做的部分越少越好。 这就是不少编程语言只支持单继承的缘由。这看起来是能力和简洁之间的平衡点。
    • 查询属性更慢。 为了在类型对象中获取一块数据,咱们也许须要回溯继承链寻找是哪个类型最终决定了值。 在性能攸关的代码上,咱们也许不想花时间在这上面。
  • 多重继承:
    • 能够避免绝大多数代码重复。 使用优良的多继承系统,用户能够为类型对象创建几乎没有冗余的层次。 改变数值时,咱们能够避免不少复制和粘贴。
    • 复杂。 不幸的是,它的好处更多地是理论上的而非实际上的。多重继承很难理解。

若是僵尸龙继承僵尸和龙,哪些属性来自僵尸,哪些来自于龙? 为了使用系统,用户须要理解如何遍历继承图,还须要有设计优秀层次的远见。

我看到的大多数C++编码标准趋向于禁止多重继承,JavaC#彻底移除了它。 这认可了一个悲伤的事实:它太难掌握了,最好根本不要用。 尽管值得考虑,但你不多想要在类型对象上实现多重继承。就像往常同样,简单的老是最好的。

参见

  • 这个模式处理的高层问题是在多个对象间分享数据和行为。 另外一个用另外一种方式解决了相同问题的模式是原型模式。
  • 类型对象是享元模式的近亲。 二者都让你在实例间分享代码。使用享元,意图是节约内存,而分享的数据也许不表明任何概念上对象的类型 使用类型对象模式,焦点在组织性和灵活性。
  • 这个模式和状态模式有不少类似之处。 二者都委托对象的部分定义给另一个对象。 经过类型对象,咱们一般委托了对象什么:不变的数据归纳描述对象。 经过状态,咱们委托了对象如今是什么:暂时描述对象当前状态的数据。

当咱们讨论对象改变它的类型时,你能够认为类型对象起到了和状态类似的职责。

第五章 解耦模式

游戏设计模式

一旦你掌握了编程语言,编写想要写的东西就会变得至关容易。 困难的是编写适应需求变化的代码,在咱们用文本编辑器开火以前,一般没有完美的特性表供咱们使用。

能让咱们更好地适应变化的工具是解耦 当咱们说两块代码解耦时,是指修改一块代码通常不会须要修改另外一块代码。 当咱们修改游戏中的特性时,须要修改的代码越少,就越容易。

组件模式将一个实体拆成多个,解耦不一样的领域。 事件序列解耦了两个互相通讯的事物,稳定并且及时 服务定位器让代码使用服务而无需绑定到提供服务的代码。

模式

5.1组件模式

游戏设计模式Decoupling Patterns

意图

容许单一的实体跨越多个领域而不会致使这些领域彼此耦合。

动机

让咱们假设咱们正在制做平台跳跃游戏。 意大利水管工已经有人作了,所以咱们将出动丹麦面包师,Bjorn 照理说,会有一个类来表示友好的糕点厨师,包含他在游戏中作的一切。

像这样的游戏创意致使了我是程序员而不是设计师。

因为玩家控制着他,这意味着须要读取控制器的输入而后转化为动做。 并且他固然须要与关卡进行互动,因此要引入物理和碰撞。 一旦这样作了,他就必须在屏幕上出现,因此要引入动画和渲染。 他可能还会播放一些声音。

等一下,这一切正在失控。软件体系结构101课程告诉咱们,程序的不一样领域应保持分离。 若是咱们作一个文字处理器,处理打印的代码不该该受加载和保存文件的代码影响。 游戏和企业应用程序的领域不尽相同,但该规则仍然适用。

咱们但愿AI,物理,渲染,声音和其余领域域尽量相互不了解, 但如今咱们将全部这一切挤在一个类中。 咱们已经看到了这条路通往何处:5000行的巨大代码文件,哪怕是大家团队中最勇敢的程序员也不敢打开。

这工做对能驯服他的少数人来讲是有趣的,但对其余人而言是地狱。 这么大的类意味着,即便是看似微不足道的变化亦可有深远的影响。 很快,为类添加错误的速度会明显快于添加功能的速度。

一团乱麻

比起单纯的规模问题,更糟糕的是耦合。 在游戏中,全部不一样的系统被绑成了一个巨大的代码球:

if (collidingWithFloor() && (getRenderState() != INVISIBLE))
{
  playSound(HIT_FLOOR);
}

任何试图改变上面代码的程序员,都须要物理,图形和声音的相关知识,以确保没破坏什么。

这样的耦合在任何游戏中出现都是个问题,可是在使用并发的现代游戏中尤为糟糕。 在多核硬件上,让代码同时在多个线程上运行是相当重要的。 将游戏分割为多线程的一种通用方法是经过领域划分——在一个核上运行AI代码,在另外一个上播放声音,在第三个上渲染,等等。

一旦你这么作了,在领域间保持解耦就是相当重要的,这是为了不死锁或者其余噩梦般的并发问题。 若是某个函数从一个线程上调用UpdateSounds()方法,从另外一个线程上调用RenderGraphics()方法,那它是在自找麻烦。

这两个问题互相混合;这个类涉及太多的域,每一个程序员都得接触它, 但它又太过巨大,这就变成了一场噩梦。 若是变得够糟糕,程序员会黑入代码库的其余部分,仅仅为了躲开这个像毛球同样的Bjorn类。

快刀斩乱麻

咱们能够像亚历山大大帝同样解决这个问题——快刀斩乱麻。 按领域将Bjorn类割成相互独立的部分。 例如,抽出全部处理用户输入的代码,将其移动到一个单独的InputComponent类。 Bjorn拥有这个部件的一个实例。咱们将对Bjorn接触的每一个领域重复这一过程。

当完成后,咱们就将Bjorn大多数的东西都抽走了。 剩下的是一个薄壳包着全部的组件。 经过将类划分为多个小类,咱们已经解决了这个问题。但咱们所完成的远不止如此。

宽松的结果

咱们的组件类如今解耦了。 尽管BjornPhysicsComponentGraphicsComponent 但这两部分都不知道对方的存在。 这意味着处理物理的人能够修改组件而不须要了解图形,反之亦然。

在实践中,这些部件之间须要有一些相互做用。 例如,AI组件可能须要告诉物理组件Bjorn试图去哪里。 然而,咱们能够将这种交互限制在确实须要交互的组件之间, 而不是把它们围在同一个围栏里。

绑到一块儿

这种设计的另外一特性是,组件如今是可复用的包。 到目前为止,咱们专一于面包师,可是让咱们考虑几个游戏世界中其余类型的对象。 装饰 是玩家看到但不能交互的事物:灌木,杂物等视觉细节。 道具 装饰,但能够交互:箱,巨石,树木。 区域 与装饰相反——无形但可互动。 它们是很好的触发器,好比在Bjorn进入区域时触发过场动画。

当面向对象语言第一次接触这个场景时,继承是它箱子里最闪耀的工具。 它被认为是代码无限重用之锤,编程者经常挥舞着它。 然而咱们痛苦地学到,事实上它是一把重锤。 继承有它的用处,但对简单的代码重用来讲太过复杂。

相反,在今日软件设计的趋势是尽量使用组件代替继承。 不是让两个类继承同一类来分享代码,而是让它们拥有同一个类的实例

如今,考虑若是不用组件,咱们将如何创建这些类的继承层次。第一遍多是这样的:

咱们有GameObject基类,包含位置和方向之类的通用部分。 Zone继承它,增长了碰撞检测。 一样,Decoration继承GameObject,并增长了渲染。 Prop继承Zone,所以它能够重用碰撞代码。 然而,Prop不能同时继承Decoration来重用渲染 不然就会形成致命菱形结构。

“致命菱形”发生在类继承了多个类,而这多个类中有两个继承同一基类时。 介绍它形成的痛苦超过了本书的范围,但它被说成“致命”是有缘由的。

咱们能够反过来让Prop继承Decoration,但随后不得不重复碰撞检测代码。 不管哪一种方式,没有干净的办法重用碰撞和渲染代码而不诉诸多重继承。 惟一的其余选择是一切都继承GameObject 但随后Zone会浪费内存在并不须要的渲染数据上, Decoration在物理效果上有一样的浪费。

如今,让咱们尝试用组件。子类将完全消失。 取而代之的是一个GameObject类和两个组件类:PhysicsComponentGraphicsComponent 装饰是个简单的GameObject,包含GraphicsComponent但没有PhysicsComponent 区域与其刚好相反,而道具包含两种组件。 没有代码重复,没有多重继承,只有三个类,而不是四个。

能够拿饭店菜单打比方。若是每一个实体是一个类,那就只能订套餐。 咱们须要为每种可能的组合定义各自的类。 为了知足每位用户,咱们须要十几种套餐。

组件是照单点菜——每位顾客均可以选他们想要的,菜单记录可选的菜式。

对对象而言,组件是即插即用的。 将不一样的可重用部件插入对象,咱们就能构建复杂且具备丰富行为的实体。 就像软件中的战神金刚。

模式

单一实体跨越了多个领域。为了保持领域之间相互分离,将每部分代码放入各自的组件类中。 实体被简化为组件的容器

“组件”,就像“对象”,在编程中意味任何东西也不意味任何东西。 正因如此,它被用来描述一些概念。 在商业软件中,“组件”设计模式描述经过网络解耦的服务。

我试图从游戏中找到无关这个设计模式的另外一个名字,但“组件”看来是最经常使用的术语。 因为设计模式是记录已存的实践,我没有建立新术语的余地。 因此,跟着XNA,Delta3D和其余人的脚步,我称之为“组件”。

什么时候使用

组件一般在定义游戏实体的核心部分中使用,但它们在其余地方也有用。 这个模式应用在在以下状况中:

  • 有一个涉及了多个领域的类,而你想保持这些领域互相隔离。
  • 一个类正在变大并且愈来愈难以使用。
  • 想要能定义一系列分享不一样能力的类,可是使用继承没法让你精确选取要重用的部分。

记住

组件模式比简单地向类中添加代码增长了一点点复杂性。 每一个概念上的对象要组成真正的对象须要实例化,初始化,而后正确地链接。 不一样组件间沟通会有些困难,而控制它们如何使用内存就更加复杂。

对于大型代码库,为了解耦和重用而付出这样的复杂度是值得的。 可是在使用这种模式以前,保证你没有为了避免存在的问题而过分设计

使用组件的另外一后果是,须要多一层跳转才能作要作的事。 拿到容器对象,得到相应的组件,而后 你才能作想作的事情。 在性能攸关的内部循环中,这种跳转也许会致使糟糕的性能。

这是硬币的两面。组件模式一般能够增进性能和缓存一致性。 组件让使用数据局部性模式的CPU更容易组织数据。

示例代码

我写这本书的最大挑战之一就是搞明白如何隔离各个模式。 许多设计模式包含了不属于这种模式的代码。 为了将提取模式的本质,我尽量地消减代码, 可是在某种程度上,这就像是没有衣服还要说明如何整理衣柜。

说明组件模式尤为困难。 若是看不到它解耦的各个领域的代码,你就不能得到正确的体会, 所以我会多写一些有关于Bjorn的代码。 这个模式事实上只关于将组件变为,但类中的代码能够帮助代表类是作什么用的。 它是伪代码——它调用了其余不存在的类——但这应该能够让你理解咱们正在作什么。

单块类

为了清晰的看到这个模式是如何应用的, 咱们先展现一个Bjorn类, 它包含了全部咱们须要的事物,可是没有使用这个模式:

我应指出在代码中使用角色的名字老是个坏主意。市场部有在发售以前更名字的坏习惯。 “焦点测试代表,在11岁到15岁之间的男性不喜欢‘Bjorn’,请改成‘Sven‘”。

这就是为何不少软件项目使用内部代码名。 并且比起告诉人们你在完成“Photoshop的下一版本”,告诉他们你在完成“大电猫”更有趣。

class Bjorn
{
public:
  Bjorn()
  : velocity_(0),
    x_(0), y_(0)
  {}
 
  void update(World& world, Graphics& graphics);
 
private:
  static const int WALK_ACCELERATION = 1;
 
  int velocity_;
  int x_, y_;
 
  Volume volume_;
 
  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
};

Bjorn有个每帧调用的update()方法。

void Bjorn::update(World& world, Graphics& graphics)
{
  // 根据用户输入修改英雄的速度
  switch (Controller::getJoystickDirection())
  {
    case DIR_LEFT:
      velocity_ -= WALK_ACCELERATION;
      break;
 
    case DIR_RIGHT:
      velocity_ += WALK_ACCELERATION;
      break;
  }
 
  // 根据速度修改位置
  x_ += velocity_;
  world.resolveCollision(volume_, x_, y_, velocity_);
 
  // 绘制合适的图形
  Sprite* sprite = &spriteStand_;
  if (velocity_ < 0)
  {
    sprite = &spriteWalkLeft_;
  }
  else if (velocity_ > 0)
  {
    sprite = &spriteWalkRight_;
  }
 
  graphics.draw(*sprite, x_, y_);
}

它读取操纵杆以肯定如何加速面包师。 而后,用物理引擎解析新位置。 最后,将Bjorn渲染至屏幕。

这里的示例实现平凡而简单。 没有重力,动画,或任何让人物有趣的其余细节。 即使如此,咱们能够看到,已经出现了同时消耗多个程序员时间的函数,而它开始变得有点混乱。 想象增长到一千行,你就知道这会有多难受了。

分离领域

从一个领域开始,将Bjorn的代码去除一部分,纳入分离的组件类。 咱们从首个执行的领域开始:输入。 Bjorn作的头件事就是读取玩家的输入,而后基于此调整它的速度。 让咱们将这部分逻辑移入一个分离的类:

class InputComponent
{
public:
  void update(Bjorn& bjorn)
  {
    switch (Controller::getJoystickDirection())
    {
      case DIR_LEFT:
        bjorn.velocity -= WALK_ACCELERATION;
        break;
 
      case DIR_RIGHT:
        bjorn.velocity += WALK_ACCELERATION;
        break;
    }
  }
 
private:
  static const int WALK_ACCELERATION = 1;
};

很简单吧。咱们将Bjornupdate()的第一部分取出,放入这个类中。 Bjorn的改变也很直接:

class Bjorn
{
public:
  int velocity;
  int x, y;
 
  void update(World& world, Graphics& graphics)
  {
    input_.update(*this);
 
    // 根据速度修改位置
    x += velocity;
    world.resolveCollision(volume_, x, y, velocity);
 
    // 绘制合适的图形
    Sprite* sprite = &spriteStand_;
    if (velocity < 0)
    {
      sprite = &spriteWalkLeft_;
    }
    else if (velocity > 0)
    {
      sprite = &spriteWalkRight_;
    }
 
    graphics.draw(*sprite, x, y);
  }
 
private:
  InputComponent input_;
 
  Volume volume_;
 
  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
};

Bjorn如今拥有了一个InputComponent对象。 以前它在update()方法中直接处理用户输入,如今委托给组件:

input_.update(*this);

咱们才刚开始,但已经摆脱了一些耦合——Bjorn主体如今已经与Controller无关了。这会派上用场的。

将剩下的分割出来

如今让咱们对物理和图像代码继续这种剪切粘贴的工做。 这是咱们新的 PhysicsComponent

class PhysicsComponent
{
public:
  void update(Bjorn& bjorn, World& world)
  {
    bjorn.x += bjorn.velocity;
    world.resolveCollision(volume_,
        bjorn.x, bjorn.y, bjorn.velocity);
  }
 
private:
  Volume volume_;
};

为了将物理行为移出Bjorn类,你能够看到咱们也移出了数据Volume对象已是组件的一部分了。

最后,这是如今的渲染代码:

class GraphicsComponent
{
public:
  void update(Bjorn& bjorn, Graphics& graphics)
  {
    Sprite* sprite = &spriteStand_;
    if (bjorn.velocity < 0)
    {
      sprite = &spriteWalkLeft_;
    }
    else if (bjorn.velocity > 0)
    {
      sprite = &spriteWalkRight_;
    }
 
    graphics.draw(*sprite, bjorn.x, bjorn.y);
  }
 
private:
  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
};

咱们几乎将全部的东西都移出来了,因此面包师还剩下什么?没什么了:

class Bjorn
{
public:
  int velocity;
  int x, y;
 
  void update(World& world, Graphics& graphics)
  {
    input_.update(*this);
    physics_.update(*this, world);
    graphics_.update(*this, graphics);
  }
 
private:
  InputComponent input_;
  PhysicsComponent physics_;
  GraphicsComponent graphics_;
};

Bjorn类如今基本上就作两件事:拥有定义它的组件,以及在不一样域间分享的数据。 有两个缘由致使位置和速度仍然在Bjorn的核心类中: 首先,它们是泛领域状态——几乎每一个组件都须要使用它们, 因此咱们想要提取它出来时,哪一个组件应该拥有它们并不明确。

第二,也是更重要的一点,它给了咱们无需让组件耦合就能沟通的简易方法。 让咱们看看能不能利用这一点。

机器人Bjorn

到目前为止,咱们将行为纳入了不一样的组件类,但还没将行为抽象出来。 Bjorn仍知道每一个类的具体定义的行为。让咱们改变这一点。

取出处理输入的部件,将其藏在接口以后,将InputComponent变为抽象基类。

class InputComponent
{
public:
  virtual ~InputComponent() {}
  virtual void update(Bjorn& bjorn) = 0;
};

而后,将现有的处理输入的代码取出,放进一个实现接口的类中。

class PlayerInputComponent : public InputComponent
{
public:
  virtual void update(Bjorn& bjorn)
  {
    switch (Controller::getJoystickDirection())
    {
      case DIR_LEFT:
        bjorn.velocity -= WALK_ACCELERATION;
        break;
 
      case DIR_RIGHT:
        bjorn.velocity += WALK_ACCELERATION;
        break;
    }
  }
 
private:
  static const int WALK_ACCELERATION = 1;
};

咱们将Bjorn改成只拥有一个指向输入组件的指针,而不是拥有一个内联的实例。

class Bjorn
{
public:
  int velocity;
  int x, y;
 
  Bjorn(InputComponent* input)
  : input_(input)
  {}
 
  void update(World& world, Graphics& graphics)
  {
    input_->update(*this);
    physics_.update(*this, world);
    graphics_.update(*this, graphics);
  }
 
private:
  InputComponent* input_;
  PhysicsComponent physics_;
  GraphicsComponent graphics_;
};

如今当咱们实例化Bjorn,咱们能够传入输入组件使用,就像下面这样:

Bjorn* bjorn = new Bjorn(new PlayerInputComponent());

这个实例能够是任何实现了抽象InputComponent接口的类型。 咱们为此付出了代价——update()如今是虚方法调用了,这会慢一些。这一代价的回报是什么?

大多数的主机须要游戏支持演示模式 若是玩家停在主菜单没有作任何事情,游戏就会自动开始运行,直到接入一个玩家。 这让屏幕上的主菜单看上去更有生机,同时也是销售商店里很好的展现。

隐藏在输入组件后的类帮咱们实现了这点, 咱们已经有了具体的PlayerInputComponent供玩游戏时使用。 如今让咱们完成另外一个:

class DemoInputComponent : public InputComponent
{
public:
  virtual void update(Bjorn& bjorn)
  {
    // 自动控制BjornAI……
  }
};

当游戏进入演示模式,咱们将Bjorn和一个新组件链接起来,而不像以前演示的那样构造它:

Bjorn* bjorn = new Bjorn(new DemoInputComponent());

如今,只须要更改组件,咱们就有了为演示模式而设计的电脑控制的玩家。 咱们能够重用全部Bjorn的代码——物理和图像都不知道这里有了变化。 也许我有些奇怪,但这就是天天能让我起床的事物。

那个,还有咖啡。热气腾腾的咖啡。

删掉Bjorn

若是你看看如今的Bjorn类,你会意识到那里彻底没有“Bjorn”——那只是个组件包。 事实上,它是个好候选人,可以做为每一个游戏中的对象都能继承的游戏对象基类。 咱们能够像弗兰肯斯坦同样,经过挑选拼装部件构建任何对象。

让咱们将剩下的两个具体组件——物理和图像——像输入那样藏到接口以后。

class PhysicsComponent
{
public:
  virtual ~PhysicsComponent() {}
  virtual void update(GameObject& obj, World& world) = 0;
};
 
class GraphicsComponent
{
public:
  virtual ~GraphicsComponent() {}
  virtual void update(GameObject& obj, Graphics& graphics) = 0;
};

而后将Bjorn改成使用这些接口的通用GameObject类。

class GameObject
{
public:
  int velocity;
  int x, y;
 
  GameObject(InputComponent* input,
             PhysicsComponent* physics,
             GraphicsComponent* graphics)
  : input_(input),
    physics_(physics),
    graphics_(graphics)
  {}
 
  void update(World& world, Graphics& graphics)
  {
    input_->update(*this);
    physics_->update(*this, world);
    graphics_->update(*this, graphics);
  }
 
private:
  InputComponent* input_;
  PhysicsComponent* physics_;
  GraphicsComponent* graphics_;
};

有些人走的更远。 不使用包含组件的GameObject,游戏实体只是一个ID,一个数字。 每一个组件都知道它们链接的实体ID,而后管理分离的组件。

这些实体组件系统将组件发挥到了极致,让你向实体添加组件而无需通知实体。 数据局部性一章有更多细节。

咱们现有的具体类被重命名并实现这些接口:

class BjornPhysicsComponent : public PhysicsComponent
{
public:
  virtual void update(GameObject& obj, World& world)
  {
    // 物理代码……
  }
};
 
class BjornGraphicsComponent : public GraphicsComponent
{
public:
  virtual void update(GameObject& obj, Graphics& graphics)
  {
    // 图形代码……
  }
};

如今咱们无需为Bjorn创建具体类,就能构建拥有全部Bjorn行为的对象。

GameObject* createBjorn()
{
  return new GameObject(new PlayerInputComponent(),
                        new BjornPhysicsComponent(),
                        new BjornGraphicsComponent());
}

这个createBjorn()函数固然就是经典的GoF工厂模式的例子。

经过用不一样组件实例化GameObject,咱们能够构建游戏须要的任何对象。

设计决策

这章中你最须要回答的设计问题是我须要什么样的组件?回答取决于你游戏的需求和风格。 引擎越大越复杂,你就越想将组件划分得更细。

除此以外,还有几个更具体的选项要回答:

对象如何获取组件?

一旦将单块对象分割为多个分离的组件,就须要决定谁将它们拼到一块儿。

  • 若是对象建立组件:
    • 这保证了对象老是能拿到须要的组件。 你永远没必要担忧某人忘记链接正确的组件而后破坏了整个游戏。容器类本身会处理这个问题。
    • 从新设置对象比较困难。 这个模式的强力特性之一就是只需从新组合组件就能够建立新的对象。 若是对象老是用硬编码的组件组装本身,咱们就没法利用这个特性。
  • 若是外部代码提供组件:
    • 对象更加灵活。 咱们能够提供不一样的组件,这样就能改变对象的行为。 经过共用组件,对象变成了组件容器,咱们能够为不一样目的一遍又一遍地重用它。
    • 对象能够与具体的组件类型解耦。

若是咱们容许外部代码提供组件,好处是也能够传递派生的组件类型。 这样,对象只知道组件接口而不知道组件的具体类型。这是一个很好的封装结构。

组件之间如何通讯?

完美解耦的组件不须要考虑这个问题,但在真正的实践中行不通。 事实上组件属于同一对象暗示它们属于须要相互协同的更大总体的一部分。 这就意味着通讯。

因此组件如何相互通讯呢? 这里有不少选项,但不像这本书中其余的选项,它们相互并不冲突——你能够在一个设计中支持多种方案。

  • 经过修改容器对象的状态:
    • 保持了组件解耦。 当咱们的InputComponent设置了Bjorn的速度,然后PhysicsComponent使用它, 这两个组件都不知道对方的存在。在它们的理解中,Bjorn的速度是被黑魔法改变的。
    • 须要将组件分享的任何数据存储在容器类中。 一般状态只在几个组件间共享。好比,动画组件和渲染组件须要共享图形专用的信息。 将信息存入容器类会让全部组件都得到这样的信息。

更糟的是,若是咱们为不一样组件配置使用相同的容器类,最终会浪费内存存储不被任何对象组件须要的状态。 若是咱们将渲染专用的数据放入容器对象中,任何隐形对象都会无益地消耗内存。

    • 这让组件的通讯基于组件运行的顺序。 在一样的代码中,原先一整块的update()代码当心地排列这些操做。 玩家的输入修改了速度,速度被物理代码使用并修改位置,位置被渲染代码使用将Bjorn绘制到所在之处。 当咱们将这些代码划入组件时,仍是得当心翼翼地保持这种操做顺序。

若是咱们不那么作,就引入了微妙而难以追踪的漏洞。 好比,咱们更新图形组件,就错误地将Bjorn渲染在他上一帧而不是这一帧所处的位置上。 若是你考虑更多的组件和更多的代码,那你能够想象要避免这样的错误有多么困难了。

这样被大量代码读写相同数据的共享状态很难保持正确。 这就是为何学术界花时间研究彻底函数式语言,好比Haskell,那里根本没有可变状态。

  • 经过它们之间相互引用:

这里的思路是组件有要交流的组件的引用,这样它们直接交流,无需经过容器类。

假设咱们想让Bjorn跳跃。图形代码想知道它须要用跳跃图像仍是不用。 这能够经过询问物理引擎它当前是否在地上来肯定。一种简单的方式是图形组件直接知道物理组件的存在:

class BjornGraphicsComponent
{
public:
  BjornGraphicsComponent(BjornPhysicsComponent* physics)
  : physics_(physics)
  {}
 
  void Update(GameObject& obj, Graphics& graphics)
  {
    Sprite* sprite;
    if (!physics_->isOnGround())
    {
      sprite = &spriteJump_;
    }
    else
    {
      // 现存的图形代码……
    }
 
    graphics.draw(*sprite, obj.x, obj.y);
  }
 
private:
  BjornPhysicsComponent* physics_;
 
  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
  Sprite spriteJump_;
};

当构建BjornGraphicsComponent时,咱们给它相应的PhysicsComponent引用。

    • 简单快捷。 通讯是一个对象到另外一个的直接方法调用。组件能够调用任一引用对象的方法。作什么均可以。
    • 两个组件紧绑在一块儿。 这是作什么均可以带来的坏处。咱们向使用整块类又退回了一步。 这比只用单一类好一点,至少咱们如今只是把须要通讯的类绑在一块儿。
  • 经过发送消息:
    • 这是最复杂的选项。咱们能够在容器类中建小小的消息系统,容许组件相互发送消息。

下面是一种可能的实现。咱们从每一个组件都会实现的Component接口开始:

class Component
{
public:
  virtual ~Component() {}
  virtual void receive(int message) = 0;
};

它有一个简单的receive()方法,每一个须要接受消息的组件类都要实现它。 这里,咱们使用一个int来定义消息,但更完整的消息实现应该能够附加数据。

而后,向容器类添加发送消息的方法。

class ContainerObject
{
public:
  void send(int message)
  {
    for (int i = 0; i < MAX_COMPONENTS; i++)
    {
      if (components_[i] != NULL)
      {
        components_[i]->receive(message);
      }
    }
  }
 
private:
  static const int MAX_COMPONENTS = 10;
  Component* components_[MAX_COMPONENTS];
};

如今,若是组件可以接触容器,它就能向容器发送消息,直接向全部的组件广播。 (包括了原先发送消息的组件,当心别陷入无限的消息循环中!)这会形成一些结果:

若是你真的乐意,甚至能够将消息存储在队列中,晚些发送。 要知道更多,看看事件队列

    • 同级组件解耦。 经过父级容器对象,就像共享状态的方案同样,咱们保证了组件之间仍然是解耦的。 使用了这套系统,组件之间惟一的耦合是它们发送的消息。

GoF称之为中介模式——两个或更多的对象经过中介对象通讯。 如今这种状况下,容器对象自己就是中介。

    • 容器类很简单。 不像使用共享状态那样,容器类无需知道组件使用了什么数据,它只是将消息发送出去。 这能够让组件发送领域特有的数据而无需打扰容器对象。

不出意料的,这里没有最好的答案。这些方法你最终可能都会使用一些。 共享状态对于每一个对象都有的数据是很好用的——好比位置和大小。

有些不一样领域仍然紧密相关。想一想动画和渲染,输入和AI,或物理和粒子。 若是你有这样一对分离的组件,你会发现直接相互引用也许更加容易。

消息对于不那么重要的通讯颇有用。对物理组件发现事物碰撞后发送消息让音乐组件播放声音这种事情来讲,发送后无论的特性是颇有效的。

就像之前同样,我建议你从简单的开始,而后若是须要的话,加入其余的通讯路径。

参见

  • Unity核心架构中GameObject类彻底根据这样的原则设计components
  • 开源的Delta3D引擎有GameActor基类经过ActorComponent实现了这种模式。
  • 微软的XNA游戏框架有一个核心的Game类。它拥有一系列GameComponent对象。咱们在游戏实体层使用组件,XNA在游戏主对象上实现了这种模式,但意图是同样的。
  • 这种模式与GoF策略模式相似。 两种模式都是将对象的行为取出,划入单独的重述对象。 与对象模式不一样的是,分离的策略模式一般是无状态的——它封装了算法,而没有数据。 它定义了对象如何行动,但没有定义对象什么。

组件更加剧要。它们常常保存了对象的状态,这有助于肯定其真正的身份。 可是,这条界限很模糊。有一些组件也许根本没有任何状态。 在这种状况下,你能够在不一样的容器对象中使用相同的组件实例。这样看来,它的行为确实更像一种策略。

5.2事件队列

游戏设计模式Decoupling Patterns

意图

解耦发出消息或事件的时间和处理它的时间。

动机

除非还呆在一两个没有互联网接入的犄角旮旯,不然你极可能已经据说过事件序列了。 若是没有,也许消息队列事件循环消息泵能够让你想起些什么。 为了唤醒你的记忆,让咱们了解几个此模式的常见应用吧。

这章的大部分里,我交替使用“事件”和“消息”。 在二者的意义有区别时,我会代表的。

GUI事件循环

若是你曾作过任何用户界面编程,你就会很熟悉事件 每当用户与你的程序交互——点击按钮,拉出菜单,或者按个键——操做系统就会生成一个事件。 它会将这个对象扔给你的应用程序,你的工做就是获取它而后将其与有趣的行为相挂钩。

这个程序风格很是广泛,被认为是一种编程范式:事件驱动编程

为了获取这些事件,代码底层是事件循环。它大致上是这样的:

while (running)
{
  Event event = getNextEvent();
  // 处理事件……
}

调用getNextEvent()将一堆未处理的用户输入传到应用程序中。 你将它导向事件处理器,以后应用魔术般得到了生命。 有趣的部分是应用在想要的时候获取事件。 操做系统在用户操做时不是直接跳转到你应用的某处代码。

相反,操做系统的中断确实是直接跳转的。 当中断发生时,操做系统中断应用在作的事,强制它跳到中断处理。 这种唐突的作法是中断很难使用的缘由。

这就意味着当用户输入进来时,它须要到某处去, 这样操做系统在设备驱动报告输入和应用去调用getNextEvent()之间不会漏掉它。 这个某处是一个队列

当用户输入抵达时,操做系统将其添加到未处理事件的队列中。 当你调用getNextEvent()时,它从队列中获取最旧的事件而后交给应用程序。

中心事件总线

大多数游戏不是像这样事件驱动的,可是在游戏中使用事件循环来支撑中枢系统是很常见的。 你一般听到用中心”“全局”“主体描述它。 它一般被用于想要相互保持解耦的高层模块间通讯。

若是你想知道为何它们不是事件驱动的,看看游戏循环一章。

假设游戏有新手教程系统,在某些特定游戏事件后显示帮助框。 举个例子,当玩家第一次击败了邪恶野兽,你想要一个显示着X拿起战利品!的小气泡。

新手教程系统很难优雅地实现,大多数玩家不多使用游戏内的帮助,因此这感受上吃力不讨好。 但对那些使用教程的玩家,这是无价之宝。

游戏玩法和战斗代码也许像上面同样复杂。 你最不想作的就是检查一堆教程的触发器。 相反,你能够使用中心事件队列。 任何游戏系统均可以发事件给队列,这样战斗代码能够在砍倒敌人时发出敌人死亡事件。

相似地,任何游戏系统都能从队列接受事件。 教程引擎在队列中注册本身,而后代表它想要收到敌人死亡事件。 用这种方式,敌人死了的消息从战斗系统传到了教程引擎,而不须要这两个系统直接知道对方的存在。

实体能够发送和收到消息的模型很像AI界的blackboard systems

我本想将这个做为这章其余部分的例子,可是我真的不喜欢这样巨大的全局系统。 事件队列不须要在整个游戏引擎中沟通。在一个类或者领域中沟通就足够有用了。

你说什么?

因此说点别的,让咱们给游戏添加一些声音。 人类是视觉动物,可是听觉强烈影响到情感系统和空间感受。 正确模拟的回声能够让漆黑的屏幕感受上是巨大的洞穴,而适时的小提琴慢板能够让心弦拉响一样的旋律。

为了得到优秀的音效表现,咱们从最简单的解决方法开始,看看结果如何。 添加一个声音引擎,其中有使用标识符和音量就能够播放音乐的API

我老是离单例模式远远的。 这是少数它能够使用的领域,由于机器一般只有一个声源系统。 我使用更简单的方法,直接将方法定为静态。

class Audio
{
public:
  static void playSound(SoundId id, int volume);
};

它负责加载合适的声音资源,找到可靠的播放频道,而后启动它。 这章不是关于某个平台真实的音频API,因此我会假设在其余某处魔术般实现了一个。 使用它,咱们像这样写方法:

void Audio::playSound(SoundId id, int volume)
{
  ResourceId resource = loadSound(id);
  int channel = findOpenChannel();
  if (channel == -1) return;
  startSound(resource, channel, volume);
}

咱们签入以上代码,建立一些声音文件,而后在代码中加入一些对playSound()的调用。 举个例子,在UI代码中,咱们在选择菜单项变化时播放一点小音效:

class Menu
{
public:
  void onSelect(int index)
  {
    Audio::playSound(SOUND_BLOOP, VOL_MAX);
    // 其余代码……
  }
};

这样作了以后,咱们注意到有时候你改变菜单项目,整个屏幕就会冻住几帧。 咱们遇到了第一个问题:

  • 问题一:API在音频引擎完成对请求的处理前阻塞了调用者。

咱们的playSound()方法是同步——它在从播放器放出声音前不会返回调用者。 若是声音文件要从光盘上加载,那就得花费必定时间。 与此同时,游戏的其余部分被卡住了。

如今忽视这一点,咱们继续。 AI代码中,咱们增长了一个调用,在敌人承受玩家伤害时发出痛苦的低号。 没有什么比在虚拟的生物身上施加痛苦更能温暖玩家心灵的了。

这能行,可是有时玩家打出暴击,他在同一帧能够打到两个敌人。 这让游戏同时要播放两遍哀嚎。 若是你了解一些音频的知识,那么就知道要把两个不一样的声音混合在一块儿,就要加和它们的波形。 当这两个是同一波形时,它与一个声音播放两倍响是同样的。那会很刺耳。

我在完成Henry Hatsworth in the Puzzling Adventure时遇到了一样的问题。解决方法和这里的很类似。

Boss战中有个相关的问题,当有一堆小怪跑动并制造伤害时。 硬件只能同时播放必定数量的音频。当数量超过限度时,声音就被忽视或者切断了。

为了处理这些问题,咱们须要得到音频调用的整个集合,用来整合和排序。 不幸的是,音频API独立处理每个playSound()调用。 看起来这些请求像是从针眼穿过同样,一次只能有一个。

  • 问题二:请求没法合并处理。

这个问题与下面的问题相比只是小烦恼。 如今,咱们在不少不一样的游戏系统中散布了playSound()调用。 可是游戏引擎是在现代多核机器上运行的。 为了使用多核带来的优点,咱们将系统分散在不一样线程上——渲染在一个,AI在另外一个,诸如此类。

因为咱们的API是同步的,它在调用者的线程上运行。 当从不一样的游戏系统调用时,咱们从多个线程同时使用API 看看示例代码,看到任何线程同步性吗?我也没看到。

当咱们想要分配一个单独的线程给音频,这个问题就更加严重。 当其余线程都忙于互相跟随和制造事物,它只是傻傻待在那里。

  • 问题三:请求在错误的线程上执行。

音频引擎调用playSound()意味着,放下任何东西,如今就播放声音!当即就是问题。 游戏系统在它们方便时调用playSound(),可是音频引擎不必定能方便去处理这个请求。 为了解决这点,咱们须要将接受请求和处理请求解耦。

模式

事件队列在队列中按先入先出的顺序存储一系列通知或请求 发送通知时,将请求放入队列并返回 处理请求的系统以后稍晚从队列中获取请求并处理。 解耦了发送者和接收者,既静态及时

什么时候使用

若是你只是想解耦接收者和发送者,像观察者模式 命令模式均可以用较小的复杂度进行处理。 在解耦某些须要及时处理的东西时使用队列。

我在以前的几乎每章都提到了,但这值得反复提。 复杂度会拖慢你,因此要将简单视为珍贵的财宝。

用推和拉来考虑。 有一块代码A须要另外一块代码B去作些事情。 A天然的处理方式是将请求B

同时,对B天然的处理方式是在B方便时将请求入。 当一端有推模型另外一端有拉模型,你须要在它们之间设置缓存。 这就是队列比简单的解耦模式多提供的部分。

队列给了代码对拉取的控制权——接收者能够延迟处理,合并或者忽视请求。 但队列作这些事是经过将控制权从发送者那里拿走完成的。 发送者能作的就是向队列发送请求而后祈祷。 当发送者须要回复时,队列不是好的选择。

记住

不像本书中的其余模式,事件队列很复杂,会对游戏架构产生普遍影响。 这就意味着你得仔细考虑如何——或者要不要——使用它。

中心事件队列是一个全局变量

这个模式的经常使用方法是一个大的交换站,游戏中的每一个部分都能将消息送到这里。 这是颇有用的基础架构,可是有用并不表明好用

可能要走一些弯路,可是咱们中的大多数最终学到了全局变量是很差的。 当有一小片状态,程序的每部分都能接触到,会产生各类微妙的相关性。 这个模式将状态封装在协议中,可是它仍是全局的,仍然有全局变量引起的所有危险。

世界的状态能够因你改变

假设在虚拟的小怪结束它一辈子时,一些AI代码将实体死亡事件发送到队列中。 这个事件在队列中等待了谁知有多少帧后才排到了前面,得以处理。

同时,经验系统想要追踪英雄的杀敌数,并对他的效率加以奖励。 它接受每一个实体死亡事件,而后决定英雄击杀了何种怪物,以及击杀的难易程度,最终计算出合适的奖励。

这须要游戏世界的多种不一样状态。 咱们须要死亡的实体以获取击杀它的难度。 咱们也许要看看英雄的周围有什么其余的障碍物或者怪物。 可是若是事件没有及时处理,这些东西都会消失。 实体可能被清除,周围的东西也有可能移开。

当你接到事件时,得当心,不能假设如今的状态反映了事件发生时的世界。 这就意味着队列中的事件比同步系统中的事件须要存储更多数据。 在后者中,通知只需说某事发生了而后接收者能够找到细节。 使用队列时,这些短暂的细节必须在事件发送时就被捕获,以方便以后使用。

会陷于反馈系统环路中

任何事件系统和消息系统都得担忧环路:

  1. A发送了一个事件
  2. B接收而后发送事件做为回应。
  3. 这个事件刚好是A关注的,因此它收到了。为了回应,它发送了一个事件。
  4. 回到2.

当消息系统是同步的,你很快就能找到环路——它们形成了栈溢出并让游戏崩溃。 使用队列,它会异步地使用栈,即便虚假事件晃来晃去,游戏仍然能够继续运行。 避免这个的通用方法就是避免在处理事件的代码中发送事件。

在你的事件系统中加一个小小的漏洞日志也是一个好主意。

示例代码

咱们已经看到一些代码了。它不完美,可是有基本的正确功能——公用的API和正确的底层音频调用。 剩下须要作的就是修复它的问题。

第一个问题是咱们的API阻塞的 当代码播放声音时,它不能作任何其余事情,直到playSound()加载完音频而后真正地开始播放。

咱们想要推迟这项工做,这样 playSound() 能够很快地返回。 为了达到这一点,咱们须要具体化播放声音的请求。 咱们须要一个小结构存储发送请求时的细节,这样咱们晚些时候能够使用:

struct PlayMessage
{
  SoundId id;
  int volume;
};

下面咱们须要给Audio一些存储空间来追踪正在播放的声音。 如今,你的算法专家也许会告诉你使用激动人心的数据结构, 好比Fibonacci heap或者skip list或者最起码链表 可是在实践中,存储一堆同类事物最好的办法是使用一个平凡无奇的经典数组:

算法研究者经过发表对新奇数据结构的研究得到收入。 他们不鼓励使用基本的结构。

  • 没有动态分配。
  • 没有为记录信息形成的额外的开销或者多余的指针。
  • 对缓存友好的连续存储空间。

更多“缓存友好”的内容,见数据局部性一章。

因此让咱们开干吧:

class Audio
{
public: