游戏设计模式--架构,性能和游戏

思考并回答以下问题:

本章涵盖:

什么是软件架构?

与其说这本书是关于如何写代码,不如说是关于如何架构代码的。 每个程序都有一定架构,哪怕这架构是“将所有东西都塞到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%。

我们很少遇到优雅表达的问题,一般反而是一堆用况。 你想要X在Z情况下做Y,在A情况下做W,诸如此类。换言之,一长列不同行为。

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

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

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

就快完了
几乎每个人都会跳过介绍章节,所以祝贺你看到这里。 我没有太多东西回报你的耐心,但还有些建议给你,希望对你有用:

抽象和解耦让扩展代码更快更容易,但除非确信需要灵活性,否则不要在这上面浪费时间。

在整个开发周期中为性能考虑并做好设计,但是尽可能推迟那些底层的,基于假设的优化,那会锁死代码。

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

快速地探索游戏的设计空间,但不要跑得太快,在身后留下烂摊子。毕竟你总得回来打扫。

如果打算抛弃这段代码,就不要尝试将其写完美。摇滚明星将旅店房间弄得一团糟,因为他们知道明天就走人了。

但最重要的是,如果你想要做出让人享受的东西,那就享受做它的过程。

0%