[翻译][编程游戏]架构、性能和游戏

214 阅读13分钟

在我们开始之前,我会先讨论下我对软件架构以及如何将架构思想应用到游戏的看法,这应该会对你有所帮助吧 -- 在理解这本书剩余内容方面,否则,那么至少,如果你被卷入一场关于什么是好的设计模式和软件架构的争论中,可以给你提供一些素材来使用。

什么是软件架构?

如果你从头到尾读完本书,你不会获得任何游戏物理引擎背后的线形代数知识,这本书不会介绍任何优化AI决策树或者在播放器中模拟房间回声的技术。

相反,这本书的关注点在于代码和代码之间的组织关系。每个开发者都会以某种方式组织他的代码,即使只是将所有的代码放入main函数中,因此我认为更确切的说,我们面临的问题,如何更好的组织代码,怎么区分好的架构和坏的架构?

在写这本书的时候,我已经思考这个问题差不多五年了。当然啦,和你一样,我从对好的设计有一种直觉。我们都被糟糕的代码仓库折磨过,对待它们最好的方式就是就是将它们消灭掉。

少部分幸运的人拥有相反的体验 ---- 工作在设计优良的代码中。那种代码就像装修豪华的奢侈酒店,你的每一个奇思妙想都能够被早早等待着的管家及时响应和满足。

什么是好的软件架构?

对我来说,架构的优秀之处往往会在修改代码的时候体现出来,当我对一坨代码进行修改的时候,会发现整个系统好像就是为了这个修改而设计得一样 -- 我可以只调用几个函数就完美地完成任务,对整个程序造成的影响微乎其微。

这听起来很棒,但事实上这是不可能的。“只写你自己的代码,这样就不会干扰其他部分” 往往是很多场景下的答案。

事实上,第一个关键的部分就是,软件架构与修改和变化密切相关。总有人要修改代码,如果某些代码不需要人改,不管是因为太完美还是因为没人改得动,对他的设计就是无关紧要的。对一个设计的评价方式之一就是它能否灵活的响应修改。如果不进行修改,那就和一个永远不离开起点的跑步运动员一样。

你怎样进行修改?

当你要为代码添加新的功能,或者修复一个bug,或者任何其他原因你要启动你的ide之前,你需要先理解目前的代码是如何工作的。即使你不用知道全部,但是至少得对所有相关的部分有基本的认识和理解。

我们常常会忽略这一步,但是这往往是编程中最消耗时间的部分。如果你觉得将数据从硬盘读取到内存中很慢,那么试试通过双眼将数据存入大脑吧。

你可能会在你的游戏中加入更多的代码,但是你不想让下一个想使用你代码的人因为你的代码而感到困扰。除非改动很小,不然你一般都会在提交代码之前整理一下你的新代码,当然,下一个接手这段代码的人不会看到你的每一行代码分别是什么时候写下的。

简而言之,修改代码的流程就像下面这样:

Get problem → Learn code → Code solution → Clean up → and back around to the beginning.

解耦有什么用?

虽然这不是特别明显,但我认为软件架构的很多内容都是为了降低维护代码的心智负担。正如前文所述,将程序的每个运行细节装入大脑同时进行思考是非常困难且容易出错的,本书中使用了一整个部分来讨论解耦模式,并且许多的设计模式也正是为了解决同样的问题。

你可以用很多种方式来定义解耦,但我认为,如果两部分代码耦合了,这就说明你不能脱离其他耦合的部分来完全理解某一部分代码。如果将他们解耦,那就可以独立的解释他们。这样的好处在于,如果对于你的问题来说,只有一小部分代码是相关的,那你就只需要思考这部分代码就行嘞。

对我来说,这是软件架构的关键目标:最小化心智负担。

代价是什么?

解耦听起来是个很棒的想法,因为工作在设计优良的代码中,会让人感觉效率很高,我们只需要修改一小部分代码,就可以实现功能,并且对全局的影响非常小。

这种感觉正是人们对抽象、模块化、设计模式和软件架构感到兴奋的原因。一个架构良好的程序也的确会带来愉悦的工作体验,每个人都喜欢更高效。良好的架构对生产力有着巨大的影响。这种影响是绝对无法被忽视的。

但是,一切馈赠都已标好了价格,好的架构需要非常严格的纪律来维护,每一次对代码进行修改或者实现新的特性,开发者都必须谨慎的设计编码方案以维持整个程序代码的健康。

你得时刻留意哪些部分需要被解耦,然后引入相关的抽象。同样的,你得确定哪些部分在将来会进行频繁地修改,因此将其实现为可扩展的。

人们往往雄心勃勃地创建这样的架构,想象后来加入的开发者看到这些代码后发现一切井然有序,开闭得当,以他们此时定义的法则就可以满足后来的任何需求。

然而,这正是让事情开始变味的原因之一。每当你添加一个抽象层,或者设置一个可扩展的方式,你都有可能会高估这个抽象和扩展性的重要程度,而盲目地让代码的复杂性变得更高。

如果你猜对了,后来还需要碰这块代码,那么这种努力就会得到回报。但是预测未来是很困难的,而当模块化最终没有带来帮助时,它就会变得有害。毕竟,你必须处理的代码更多了。

当人们对这个过于狂热时,你会得到一个架构失控的代码库。你会发现到处都是的接口和抽象。插件系统、抽象基类、虚拟方法、各种扩展点。

你需要花费很长时间才能搞明白整个架构,找到一些真正做事情的代码。当你需要修改代码时,当然,可能有一个接口可以帮助你,但祝你好运能找到它。从理论上讲,所有的解耦都意味着在扩展之前你需要理解的代码更少,但是抽象层本身最终会让你背负严重的心智负担。

这样的代码库正是让人们对软件架构,尤其是对设计模式产生反感的原因。人们很容易陷入到代码本身中去,以至于忽视了他们正试图发布一款游戏的事实。可扩展性的诱惑吸引了许多开发者,他们花费数年的时间开发一款“引擎”,却从未弄清楚它到底是为了什么而开发。

性能和开发速度

对于架构和设计模式,一个常见的问题就是,它们往往会带来性能损耗。许多设计模式使用了额外的对象和数据传递来实现其灵活性,这当然是会存在性能损耗的。

很多软件架构的目的就是让程序更加灵活,也就是非常容易进行修改。这意味着在编程过程中很少对程序运行的实际情况进行假设 -- 使用接口来让你的程序能够和满足条件的任意对象进行交互,使用observers和messaging来让程序的多个部分相互通信,这些设计使得程序具备很好的扩展性。

但是性能差不多就是在程序中加入预设条件,也就是与架构的思路是相反的。比如,我们是否能够保证游戏中不会同时出现256个敌人?如果是的话,那就太好了,因为这样我们就可以将玩家的id存储在单字节中了。我们是否只会对某种特定类型的数据调用某个方法?如果是的话,那我们就可以静态调用或者将其改为内联方法。所有的物料都是同样的类吗?如果是,那就可以将他们放在定型数组中了。

但是!这不是说,灵活性不好。灵活性让我们能够快速地开发游戏,开发速度也是很重要的。没人能在纸面上就设计出一个平衡了开发速度和性能的游戏程序,这需要迭代和验证。

你越快地尝试想法并体验感受,就能尝试越多,找到优秀东西的可能性就越大。即使在找到合适的机制后,你也需要大量时间进行微调。微小的不平衡可能会毁掉游戏的乐趣。

平衡性能和程序的灵活性不是一件容易的事,就我个人的经验来说,让一个有趣的游戏运行更快远比让一个性能优良的游戏变得好玩要简单。一种折衷方案是在设计确定之前保持代码的灵活性,然后移除一些抽象层来提高性能。

坏代码的用武之地

虽然说良好的架构有很多优点,但确实需要大量的时间和精力去维护,这在项目的初期,或者对一个初窥门径的开发者来说是成本高昂的,我们常常需要快速按照设计给出能够实际运行的原型程序,而不考虑其可维护性,这时候,我们可以仅仅将各种demo代码糅合到一起,忽略架构方面的考量。但是,一定要让管理者知道,目前的程序只是一个demo,仅仅可用于演示功能,如果要实现真正的产品,一切都必须得重头来过。

权衡的困境

我们往往面临着这样的压力:可读性、性能、快速开发 这些目标某种程度上是矛盾的。好的架构在长期看来确实提高了生产效率,但是这意味着需要花费大量的时间来维护,即使是实现很小的特性也需要做出更多努力以保持架构的健康。

同时,很少有代码能够同时兼顾架构优雅和性能,相反,优化性能需要投入大量的时间。而一旦完成优化后,代码就会变成僵化的状态:高度优化的代码灵活性非常差,且极难改变。

总是有压力要求我们今天完成今天的工作,明天再担心其他的事情。但是如果我们尽可能快地加入功能,我们的代码库将变成一堆hack、漏洞和各种不一致的代码,这些都会降低我们未来的生产力。

这里没有简单的答案,只能做出取舍。从我收到的邮件看,这一点让很多人感到沮丧。特别是对于那些只想制作游戏的初学者来说,听到“没有正确的答案,只有不同形式的错误”这样的说法会让他们望而却步。

但是,对我来说,这是令人兴奋的!看看人们为了谋生而不断努力学习的任何领域,你会发现,在所有知识的中心,总有一组相互交织的约束条件。毕竟,如果有一个简单的答案,每个人都会这样做。一个可以在一周内掌握的领域最终是无趣的。你不会听说某个人在挖沟渠方面取得了杰出的成就。

对我而言,这和游戏本身有很多共同点。像国际象棋这样的游戏永远无法被完全掌握,因为所有棋子之间都是如此完美地相互制衡。这意味着你可以用一生的时间来探索可行策略的广阔空间。一个设计不佳的游戏会不断使用一种获胜策略,直到你厌倦退出为止。

简洁

简化是一种很好的方法来缓解这些权衡的压力。在我今天的代码中,我非常努力地编写最简洁、最直接的解决方案。这种代码在阅读后,能够让人准确地理解它的作用,并且想象不到其他可能的解决方案。

我的目标是选择正确的数据结构和算法(按照这个顺序),然后从那里开始。我发现如果能让代码保持简洁,那么总代码量就会更少。这意味着需要加载到头脑中的代码也会减少,从而进行更改。

由于没有太多的开销和要执行的代码,因此代码通常运行速度很快。(尽管这并不总是成立。你可以在很小的代码量中包含大量的循环和递归。)

然而,需要注意的是,我并不是说简单的代码需要更少的时间来编写。你可能会认为,由于总代码量减少,因此需要更少的时间来编写代码,但一个好的解决方案并不是代码的累积,而是对代码的精简。

确实,我们面临的问题很少是优雅的,相反,它们通常是一堆杂乱的用例。在处理这些问题时,我们需要在不同的情境下采用不同的策略。换句话说,就是需要一个很长的不同示例行为清单。

最省脑力的方法就是逐个编码这些用例。如果你看一下初学者,他们经常这样做:他们逐个为脑海中出现的每个用例编写大量的条件逻辑。

但这种方法并不优雅,而且这种风格的代码在遇到与程序员考虑的示例稍有不同的输入时往往会崩溃。当我们想到优雅的解决方案时,我们通常想到的是通用的解决方案:一小段逻辑就能正确地涵盖广泛的用例空间。

找到这种解决方案有点类似于模式匹配或解谜。它需要我们花费精力去观察这些用例背后的隐藏规律,找到解决这些问题的最佳方法。当我们成功地找到这个隐藏规律时,会感到非常棒。