有哪些在游戏开发中常用到游戏中常用的设计模式式


  认错时刻:在这一章里,我有的包装和修饰有点过分了。看起来是在讲状态模式,但是我发现如果不讲有限状态机的基本概念,我几乎没法讨论状态模式和游戏。但是一旦我讲了,感觉就像是在介绍分层状态机和下推自动机。

  这样牵扯的太多了,所以为了尽量保持简洁,实例代码留下了一些细节需要你自己去填写。我希望他们仍然能够表达清楚大体意思。

  如果你没听说过状态机也不要慌。虽然在AI和编译器黑客们那里很常见,但是其他领域的程序员可能不熟悉。我想他们应该被广泛认知,所以我将在不同的问题中抛出他们。

  我们在做一个横向卷轴的小游戏。我们的工作是实现一个女英雄(heroine),就是玩家在游戏世界的阿凡达。也就是说她要相应玩家的输入。按B键她就会跳。很简单:

  没有东西能够组织“跳跃”动作——当她在空中的时候持续按B键,她就永远悬在空中了。简单地解决办法是为Heroine类添加一个isJumping_布尔值,当她起跳的时候设为true,然后:

  然后,当她站立时,一旦玩家按向下键,我们想让heroine下水。放开按键后回到站立状态。

  这些代码,玩家可以:

  1、按向下键入水。


  2、按B键从潜水的位置上起跳。
  3、在空中释放向下键。

  heroine就会在跳跃中间变成站立样子。应该加入另一个标志了:

  下一步,如果玩家在跳跃的中间按向下键,进行一个俯冲攻击,那一定很酷:
  又到了找bug时间了。

  我们发现你再跳跃时不能再次跳跃,但是在俯冲和其他状态下可以…

  通过我们的方法,能找到一些明显的错误。每次我们动一动这些棘手的代码,都会引起一些错误。我们需要加入更多地运动——我们甚至还没有加入“走路”——但是照这样下去,在我们处理它之前,就会引起一系列的bug。

  面对这种挫折,你要清理掉桌面上的所有东西,只剩下一支笔和一张纸,开始画流程图。你画一个矩形代表heroine可以做的每一个动作:站立、跳跃、下潜和俯冲。当她能从一种状态响应一个按键消息,你画一个从这个矩形出发的箭头,并且用这个按钮来标记箭头,用它来指向下一个状态。

  恭喜,你已经创建了一个有限状态机(FSM)。这出自计算机科学的一个叫做自动机理论的分支。这个家族里里还有大名鼎鼎的图灵机。FSM是这个家族最简单地一个成员。要点如下:

  1、状态机的状态集合是确定的。例如在我们的例子里,有站立、跳跃、下蹲和俯冲。

  2、状态机在某一时刻只能处于一种状态。我们的heroine不能同时跳跃和站立。其实,避免出现这种状况正是我们使用FSM的原因。

  3、一连串的输入或者事件发送到这个状态机。我们的例子里,就是一串按键和释放消息。

  4、每一种状态都由一些转化路径,每一个路径都通过一个输入关联到另外一种状态。当一个输入进来后,如果正好跟一个当前的转化路径匹配,状态机就会转到它所指向的另一个状态。

  例如,站立的时候按向下键,转换到下蹲状态,跳跃的时候按向下键,转换到俯冲状态。如果当前状态下没有定义某种操作相应的转化,那这种操作将会被忽略。

  理想状态下,状态机里就只有:状态,输入,转化这些东西了。你可以画一个很小的图表示出来。但是编译器并不能识别我们的草图,我们要怎么实现他们呢?GOF的State模式就是一种方法,下面将会讲到,让我从简单的入手。

  我们的Heroine类有一个问题就是无法使用这几个布尔值得组合,例如isJumping_和isDucking_永远不可能都为true。当你确定这些标志不可能同时为true时,这就表示你真正应该使用的是枚举。

  在这个例子中,枚举就是我们FSM的状态集合,所以我们可以这样定义:

  Heroine类不再需要一堆标志了,取而代之的是一个state_属性。并且需要颠倒代码的顺序。在前面的代码里,我们先判断输入,然后是状态。这把一个键的相关代码放在了一起,但是关于状态的代码被分散了。我们希望让它们集中起来,所以先判断状态,代码如下:
  这看起来很散乱,但是其实已经比前面的代码有很大进步了。我们仍然有很多判断分支,但是我们把分散的状态归纳到了一个地方。处理同一个状态的代码被很好的集中在一起。这是实现状态机的最简单的方式,而且在某些应用场景工作的很好。

  但是你遇到的问题会超出这个解决方案的范围。比如说,我们想添加一个改动,让heroine能够下蹲蓄力然后发出一个特殊的大招。当她下蹲的时候,我们要记录时间。

  我们在Heroine中添加一个 chargeTime_属性,用来记录她下蹲了多长时间。假如我们有一个update()函数,每帧调用一次。我们在其中加入代码:

  我们需要在她开始下蹲前重置这个计时器,所以修改handleInput函数:

  总之,为了添加大招,我们必须修改两个方法,并且在Heroine类中加入chargeTime_属性。尽管它只在下蹲状态下有意义。我们最理想的情况是把这些代码和数据封装在一起,GOF该出场了。

  面向对象思想已经深入人心,每一种判断分支都提供了一个动态分配的机会(C++中用的是另外一个说法叫虚函数调用)。我想这是个坑,优势其实你所需要的只是一个if语句。

  但是在我们的实例中,恰好更适合面向对象。它让我们能够使用State模式,GOF的描述是:

  允许一个对象在内部状态改变时,改变其行为。这个对象会表现为改变它的类型。

  这并没有告诉我们太多信息。搞笑的是swtch却做到了。这个模式的描述如果应用到我们的heroine上,会是这一个样子:

  首先,我们定义一个state的接口。每一个状态依赖的方法——之前我们放switch语句的地方——变成了一个虚方法。在这里就是handleInput() 和 update()

  每一个状态对应的类

  对每种状态我们定义一个类去实现这个接口。它的方法定义了heroine在这个状态下的行为。也就是说,我们把原来swtich语句中每个case下的代码,移到了他们各自状态对应的类中。例如:




  注意我们把chargeTime_从Heroine类中移到了DuckingState类中。这是极好的——这条数据只在这个状态下有意义,现在我们的对象模型很明显得反应了这一点。

  下一步,我们在Heroine中添加一个指向当前状态的指针,去掉那些大switch语句,把他们代理给state:



  如果要切换状态,我们只需要将state_指针指向另外一个HeroineState对象即可。这就是State模式的

  State对象在哪里

  我在这里隐藏了一点细节。为了改变状态,我们需要让state_指向一个新的State对象,但是这些对象从哪里来呢?在我们的枚举实现中,他们是一些简单的数。但是现在状态是类,这就意味着我们需要指向一个实在的对象实例。这里有两种答案:

  如果状态对象没有其他的属性,那么他只会存储一个指向内部虚函数表的指针。这样,没有理由创建多个实例。每一个状态的实例都应该是唯一的。

  这样,你可以用一个静态实例。即使你有一堆FSM 都用到了同一个状态,他们也可以指向同一个状态对象,因为它没有对某个状态机进行特化。

  这些静态实例放在什么地方,取决于你。找一个合适的地方。如果没有特殊原因,我们可以放在基类里:


  每一个静态域都是游戏中用到的状态对象。为了让heroine跳跃,站立状态可以像这样:

  实例化状态   有时这种方法并不可行。静态状态不适合下蹲状态,它有一个chargeTime_属性,这个属性绑定在下蹲的heroine。不过这个恰巧在我们的游戏中也能用,因为我们只有一个heroine,但如果我们要加入两个玩家的玩法,在同一个名目中有两个heroine,就会出问题了。

  这种情况下,我们必须在切换到一个状态的时候,创建它。这样才能做到每一个FSM都有专属自己的状态实例。当然,每当我们构建一个新状态,就要释放掉当前的状态对象。这里需要小心,因为触发切换的代码是在当前的状态对象中,我们不希望在这些对象中用delete this的方式销毁自己。

  取而代之的是我们让HeroineState中的handleInput()方法能够返回一个新状态。如果返回了,Heroine就销毁老状态,切换到新状态上,就像这样:

  这样,我们直到返回一个新状态后,才销毁掉前一个状态。现在站立状态可以用创建新对象的方式切换到下蹲状态。

  如果可能,我会尽量使用静态状态,因为他们不消耗更多的内存和用来申请内存的CPU时钟。对状态来说,这些消耗太大了,尽管这也是一个方法。

  State模式的目的在于把一种状态的代码和数据归到一个单独的类中。我们基本实现了,但是还有一些收尾工作。

  当heroine改变状态,我们还要切换她的图片。现在这些代码被放在了前一个状态中。从下蹲到站立的过程,在下蹲的状态中设置站立的图片:


  我们想要的是每一个状态都控制它自己的图片,我们可以给状态添加一个entry动作,来做这个事情:

  回到Heroine,我们修改代码,把状态改变后的变化放在新状态的调用中:

  现在我们就可以简化下蹲代码了:

  它只需要切换到站立状态,而让站立状态自己去关心图像。现在我们的状态才算真正的归类了。进入事件只需要关注进入状态,而不用关心是从哪个状态转化而来。

  现实中的状态图中,会有多个转化路径到同一个状态。例如,heroine跳跃和俯冲后都会进入站立状态。这就意味着我们会把一些相同的代码散布在这些地方。进入事件让我们可以把它们归拢到一个地方。

  当然,我们也可以支持退出事件。其实就是一个离开一个状态时调用的方法。

  我用了大量的时间向大家兜售FSM,现在我要收一收了。到目前为止我说的都对,FSM也很好得解决了一些问题。但是他们最大的优点也是最大的缺点。

  状态机帮助你整理一些杂乱的代码,用的方法把他们强制套用到一个结构中。你得到的就是一些状态集合,一个当前状态,和一些写死的转化。

  如果你试着用状态机去做一些复杂的工作,如游戏AI,你会被它带来的很多限制甩一脸。幸运的是,我们的前辈们已经找到了很多方法去避开这些坑。在最后,我带大家来看看这些办法。

  我们决定让heroine能带枪。当她带枪的时候,她仍然可以做他之前能做的所有动作:跑、跳、下蹲等等。但是她需要在做这些动作的同时开枪。

  如果我们严格按照FSM来做,我们必须有原来两倍数量的状态机。对每个已有的状态,我们都要有一个对应的持枪动作:站立,持枪站立,跳跃,持枪跳跃。。。你懂得。

  添加更多的武器,会组合出更多的状态。不止会产生大量的状态,还会产生大量的冗余。有武器和没有武器的状态几乎是相同的,除了少数的开火代码。

  问题是我们把两种状态——她做了什么和她拿着什么——混淆进了一个状态机。为了产生所有组合,我们需要为每一对组合产生一个状态。解决办法也很明显:用两个分离的状态机。

  我们保留原有的状态机,支持原来的功能。然后为她携带的武器单独定义一个状态机。Heroine将会两个状态引用,就像这样:


  当heroine响应状态输入的时候,她要处理两者:
  每个状态机都可以响应输入,产生行为,并且变换状态时相互没有依赖。如果两个撞他集合几乎没有联系,它们会工作的很好。

  在实际应用中,你会发现有的情况下这些状态会有交集。例如,可能她在跳跃的时候不能开火,或者可能她有装备的时候不能俯冲。要处理这个问题,在一种状态的代码里,你可能加入一些if语句去判断另一个状态机的状态。这不是一个优雅的解决方案,但是有效。

  在给我们的heroine加强了一些行为以后,她拥有了一些相似的状态。例如,她会有站立,行走,跑,滑行等状态。它们都是按B跳跃,按向下就下蹲。

  用简单的状态机实现方法,我们必须把这些代码在每一个状态中写一遍。如果我们能实现一遍然后在所有的状态中复用,那就更好了。

  如果这只是一些面向对象的代码而不是状态机,一个复用代码的方法就是用继承。我们需要定义一个底层的状态,处理跳跃和下蹲。站立,行走,跑和滑行要继承它,并且加入他们附加的行为。

  原来,这是一个常见的结构叫做分层状态机。一个状态可以有一个超状态(这样它就成了子状态)。当一个事件进来后,如果子状态不去处理它,事件将会沿着链条传递给超状态。另一种说法,它就像覆盖继承方法。

  事实上,如果我们用到State模式去实现FSM,我们可以使用类继承去实现层次化。定义一个超状态基类:

  然后每个子状态继承它:
  这当然不是实现层次化的唯一方法。如果你不用GOF的State模式,这就不会奏效。然而,你可以用显式得使用状态栈来表示当前的超状态链,在宿主类(Heroine)中用它来替换那个单一的状态(state_)。

  当前的状态就是栈顶的那个状态,它下面的就是它的直接超状态,然后是超状态的超状态,以此类推。当你遇到这个状态对应的行为时,从栈顶开始往下传递,直到有一个状态处理了它。(如果没有,就忽略。)

  有限状态机另外有一个扩展就是使用栈式状态。不过容易使人迷惑的是,栈代表了完全不同的东西,经常被用来解决其他问题。

  有限状态机的一个问题在于它不保留历史记录。你知道你当前的状态是什么,但是不记得之前的状态。并且没有返回到之前状态的简单办法。

  这里有一个例子:前面,我们把我们无畏的heroine武装到了牙齿。当她开火的时候,我们需要一个新状态来播放开火动画,发射子弹和一些视觉特效。所以我们把他们放在FiringState中,在所有能够开火的状态中,添加一个到开火状态的转化。

  问题是,当她开火完了之后,进入什么状态呢?她在站立、跑动、跳跃或者下蹲的时候,都能突然开火。当开火的一系列动作完成后,她应该返回到原来的的状态下。

  如果我们必须要使用传统FSM,就已经忘掉她之前的状态了。为了解决这个问题,我们必须定义一些特定的状态——站着射击,跑着射击,跳着射击等等——这仅仅为了能写死一些代码,使得能转化回原来的状态。

  我们真正希望的是一种能够记住射击前状态的方法,供后面使用。自动机就是一个有用的策略。相应的数据结构叫做下推自动机。

  有限状态机有一个指向状态的指针,而下推自动机却有一个由这些指针构成的栈。在FSM中,转换状态用的是用一个新状态替换原来的。一个下推自动机也允许你这么做,不过它还提供另外一种操作:

  1、你可以在栈中压入一个新状态,当前状态始终位于栈顶,所以这也相当于转换了新状态。但这把原状态留在了它下面,而不是直接丢弃掉。

  2、你可以弹出栈顶的状态,丢掉,它下面的状态就变成了当前状态。

  这正是我们开火所需要的。我们只需要创建一个开火状态。当在其他状态下开火键被按下时,我们把开火状态压入到栈中。等到开火动作结束后,我们弹出这个状态,然后下推自动机会自动得帮我们把状态转换到原有状态上。

  即使那些扩展过的状态机,也有很多限定条件。当今游戏AI发展趋势是那些更吸引人的东西,如行为树,计划系统。如果你对更复杂的AI感兴趣,本章的内容旨在唤起你的兴趣。你需要阅读其他书籍来满足你的需求。

  这不说明有限状态机,下推自动机,和其他简单系统没有用。他们是对一些特定的问题是一个很好的模型工具。有限状态机在下面的情况下比较有用。

  1、你有一个东西,它的行为是基于一些内部状态的。

  2、这些状态能够很容易地分离出少量明确的选项。

  3、随着时间变化,这个东西要相应一系列输入和事件。

  在游戏中,最有名的用法是在AI中。而在其他用法也很常见,如处理用户输入,菜单导航,解析文本,网络协议,以及一些其他异步行为。

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 才可以下载或查看,没有帐号?

工厂模式专门负责将大量有共同接口的类实例化。工厂模式可以动态决定实例化哪一个类,而不必实现知道要实例化的是哪一个类。
在这个工厂模式家族中有3种形态:
简单工厂模式,这是他的中文名,英文名叫做Simple Factory。
工厂方法模式,这是他的中文名,英文名叫做Factory Method。
抽象工厂模式,这是他的中文名,英文名叫做Abstract Factory。

现在我们知道了工厂家族在GoF23里面的几个队员叫什么名字,但是还不熟悉怎么使用他。不要着急,现在我们就开始学习如何使用他们。在工厂家族中简单工厂模式是这3种形态里面最简单最直接的一种。我们就先从他下手,然后在逐步的了解工厂方法模式、抽象工厂模式的使用。

相对于OOP来说,每种设计模式都是一套武功心法,而每套心法对应的武功套路就是模式的具体使用的方式。

修炼简单工厂模式的心法如下: 1)工厂类角色Creator:工厂类在客户端的直接控制下(Create方法)创建产品对象。


2)抽象产品角色Product:定义简单工厂创建的对象的父类或它们共同拥有的接口。可以是一个类、抽象类或接口。
3)具体产品角色ConcreteProduct:定义工厂具体加工出的对象。

修炼简单工厂模式的武功套路如下: (备注:上面是简单工厂模式demo里的定向关系图文档的截图!)


上面描述了简单工厂模式的修炼心法和武功套路。下面就给出具体的套路细节。
建议:看下面代码之前,自己先动手练习下为好。
/// 简单资源工厂类,负责创建UI,Audio等管理器的实例。
/// 资源管理器基类,抽象产品
/// Audio资源管理器,抽象产品的具体产品
/// UI资源管理器,抽象产品的具体产品
/// 定义资源枚举类型
/// 使用资源管理器的客户端
搞懂了上面的武功心法和武功套路,你也就领悟了简单工厂模式。
每种武功都有他的优缺点,例如七伤拳,你懂得。那么简单工厂模式的优缺点是什么呢?接着往下看,你就明白了。

优点: 工厂类含有必要的判断逻辑,可以决定在什么时候创建哪一个产品类的实例,客户端可以免除直接创建产品对象的责任,而仅仅"消费"产品。简单工厂模式通过这种做法实现了对责任的分割。

缺点: 1) 当产品有复杂的多层等级结构时,工厂类只有自己,以不变应万变,就是模式的缺点。因为工厂类集中了所有产品创建逻辑,一旦不能正常工作,整个系统都要受到影响。


2) 系统扩展困难,一旦添加新产品就不得不修改工厂逻辑,有可能造成工厂逻辑过于复杂。
3) 简单工厂模式通常使用静态工厂方法,这使得无法由子类继承,造成工厂角色无法形成基于继承的等级结构。

在这里野猪先给大家提一个醒,GoF23设计模式里面的每种模式都是针对某类特定的解决策略。So,没有什么设计模式是完美的! 好了,简单工厂模式就讲到这里。敬请期待如何修炼工厂方法模式。


Tips:告诉大家一个小秘密,简单工厂模式实际上不是GoF23设计模式的一员哦!
原创翻译,转载请注明出处

在我们一头扎进模式的大坑之前,我想给你们介绍一些我所理解的有关软件架构以及其如何应用在游戏中的知识,这对你们应该有所帮助。这些知识可以帮你们更好地理解本书接下来的部分。别的不说,当你被拖进一场关于设计模式和软件架构是多糟糕(或多棒)的争论中时,这些知识至少会给你提供一些可用的弹药。

注意,我并不管你们在这场战斗中站在了哪一边。和每一个武器商一样,我的武器对任何一方的战士都有出售。

如果你读完了这本书,你并不会得到任何在3D图形学中使用到的线性代数知识或者在游戏物理中使用到的微积分知识。本书也同样不会告诉你如何在你的AI搜索树中使用alpha-beta剪枝算法或者如何在你的音频播放中模拟房间混响效果。

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

不过,本书中的代码在上述所有的领域中都会出现。相对于如何写代码,本书更关注的是如何去组织代码。每一个程序都对代码有一定的组织,即使只是“把所有的东西都堆到main()中然后看看到底会发生什么”,所以我想,聊一聊如何对代码做出好的组织一定会更有趣。那么如何区分出架构的好坏呢?

我已经思索了这个问题将近5年的时间。当然,和你们一样,我也有能够辨认出好的设计的直觉。我们都经历过糟糕的代码库,那个时候最想做的应该就是把这些代码全部干掉,好让它们从痛苦中解脱出来。

不得不承认,我们中的大多数要多少为上面这种情况负一些责任。

而有一些幸运的人们却有着完全不同的经历,他们有机会去使用一些有着优秀设计的代码。这种优秀的代码库感觉起来就像是一间奢华的酒店,里面的服务员们都殷勤地等待着为你的每一个心血来潮的念头服务。那么这两种代码之间的区别究竟是什么呢?

对于我来说,好的设计意味着每当我做出修改的时候,整个程序就像在设计时就已经预料到了我的这次修改一样。在解决一个问题时,我只需要用到几个可以完美嵌入代码库中的函数,不会让代码库平静的水面溅起一点涟漪。

“你只是在写自己那部分的代码,所以这种修改不会打扰到代码库平静的水面。”好吧,这听起来很棒,但其实并不是那么可行。

来让我稍微分析一下这是为什么吧。最关键的一点就是,架构所要考虑的正是改动。总是会有人需要修改代码库的。如果没有人会去修改代码库--不管是因为它太完善和完美了,还是因为它糟糕到没有人愿意打开以免玷污了自己的文本编辑器--那么它的设计都是无关痛痒的。衡量一个架构设计好坏的标准其实很简单,那就是它对改动的适应性。一个没有改动的架构,就像一个从来没有离开过起跑线的赛跑选手。

在你开始修改代码之前(不管是添加新功能、修改bug或者任何导致你打开编辑器的原因),你必须要搞清楚现有的代码在做些什么。当然,你不需要搞清楚整个项目,但是你需要把你所要修改的地方所有相关的部分装进你灵长类的脑子里。

我们常常掩饰这个步骤,但其实它通常是编程工作中最为耗时的那个部分。 如果你认为把数组从硬盘分页到RAM中很慢的话,可以试试把它通过视神经传进一个类人猿的脑子里。

当你将所有正确的内容装入你的脑中之后,你就可以开始思考并找出你的解决方案了。这个过程可能是曲折的,但是相对来说总是往好的方向前进的。当你搞清楚了你的问题和它所要使用到代码之后,接下来的编程工作可能就非常容易了。

你用你肉肉的手指在键盘上敲打一阵,等到颜色正确的灯在屏幕上闪起的时候你就完成了,对吗?仅仅这样还不够!在你的写入测试完成和把它发送给代码评审之前,你通常还有一些善后工作要完成。

我是说了“测试”吗?哦,是的,我是说了。为游戏代码编写单元测试是很困难的,但是对于代码库来说,大部分代码都是可测试的。
我不会在这里开始一段关于测试的长篇大论,但是如果你还没有使用一些自动化测试方法的话,我建议你要开始考虑使用它们了。难道你没有比一遍又一遍手动地测试代码更有意义的事情做了么?

简而言之,敲代码的流程图看起来大概就和下面这张图差不多:

这个循环是没有出口的,我想起来它的时候不禁有些担忧。

虽然不是很明显,但是我认为,很多的软件架构都是关于解耦的。把代码装进脑袋里是一个痛苦且缓慢的过程,因为它需要花费时间去寻找削减代码体量的策略。本书有一整个章节都是描述用来解耦的模式的,《设计模式》中也有很大一部分在描述解耦的相关思想。

你可以给“解耦”下很多种定义,而如果两块代码一旦耦合了,那就意味着你不能只了解其中一块代码而不去了解另一块。如果你把它们解耦了,那你就可以分开来考虑其中的每一块代码。这是很棒的,因为如果这些代码中的其中一块和你的问题相关,你只需要把这一块装进你猴子一样的大脑里而不需要考虑另外一块。

对于我来说,这就是软件架构要实现的关键目标:在你开始你的工作进度之前,最小化你所需要装进脑袋里的知识量。

这在后期的工作中同样也会起到作用。解耦的另一个定义是对一块代码的修改并不一定需要修改另外一块。很明显我们需要去修改一些都系,但是越少的耦合,意味着之后工作中越少的修改。

这听起来很棒,对吗?把一切解耦,你就可以像风一样飘逸地敲代码。每次修改意味着只要接触仅有的一到两个方法,你可以神出鬼没般从代码库上飘过。

这就是人们为什么这么热衷于抽象、模块化、设计模式和软件架构的原因。在一个架构良好的项目里工作真的是一个非常令人愉悦的体验,每个人都喜欢工作更有效率。好的架构可以给生产力带来巨大的改变,一点也不夸张地说,它可以带来非常深远的影响。

但是,就像我们生命中的其他事情一样,这不是免费的。好的架构需要花费很多的努力和磨练。每一次你做出修改或者实现新功能的时候,你都需要花费很大的功夫去把它优雅地集成到项目的其他部分中去。你需要非常注意组织你的新代码,而且需要在你之后的开发周期中可能出现的成千上万的改动中维护它们的组织化。

这里的第二部分--维护你的设计--需要格外地注意。我见过很多开始时很不错的项目最终死于程序员们一次又一次的“只是一点小修改”上。
就像园艺一样,只是种新的植物是不够的。你还必须要除草和修剪。

你需要考虑项目的哪些部分是应该解耦的,你要在那些点上引入抽象。同样的,你也需要哪里的扩展性是需要被设计的,以使得未来的修改更加容易。

人们对这件事感到非常的兴奋。他们想象未来的开发者们(可能只是未来的他们自己)进入代码库之后,发现它时这样的开放、功能强大,就在那等着被扩展。他们想象使用一个游戏引擎去管理所有的游戏。

但是事情从这里开始变得微妙起来。每当你想要添加一个抽象层或者给一处代码增加扩展性支持的时候,你是在推测你以后会用到这样的灵活性。但这也是在给你的游戏增加额外的代码和复杂度,而这些都是需要花费时间去开发、debug和维护的。

如果你的猜测是正确的,而且之后也不会去修改这块代码的话,那么你的努力就是值得的。但是预测未来是很难的,当那个模块不再有帮助的时候,它很快就会变得非常有危害。最终,它会导致你需要去应付更多的代码。

一些人杜撰了“YAGNI”这个词--You aren't gonna need it(你不会需要它的)--作为一个咒语去遏制想要预测自己未来的渴望。

当大家在这件事上热衷过头的时候,你会得到一个架构被扭曲到超出控制的代码库。这种代码库里到处都是接口和抽象。同样也存在着大量的插件系统、抽象基类、虚方法,以及各种各样的扩展点。

这样你将一直把时间花费在从框架代码中寻找真正的功能性代码。当你需要作出改动的时候,当然,这里可能会有一个接口给你提供帮助,但前提是你能足够幸运地找到它。理论上来说,解耦意味着你可以在扩展代码之前了解较少的代码,但实际上抽象层里的代码最终将填满你大脑里的硬盘。

这种类型的代码库使得人们反感软件架构,尤其是设计模式。它让你很容易陷入到代码之中,而忘记了你其实只是想发售一款游戏。这首有关于扩展性的塞壬之歌卷入了无数花费多年时间在一个引擎上工作却从来不知道这个引擎是干什么的开发者。

还有另外一种对软件架构和抽象持批评观点的说法,在游戏开发中经常会听到:会降低游戏的性能。许多让你的代码更灵活的模式一般都依赖于虚拟派发、接口、指针、消息以及其他会在运行时有性能消耗的机制。

一个有趣的相反的例子是C++中的模板。模板元编程有些情况下能让你使用抽象接口而在运行时没有任何额外的消耗。
这是一个有关于灵活性的范畴。当你在一个类中调用一个具体方法时,那么你就是在编写阶段就把那个类定死了--也就是说你是在硬编码这个类。而当你使用虚方法或者接口时,这个被调用的类在运行之前是不确定的。这当然是更灵活的,但是带来了一些运行时的消耗。
模板元编程在介于这两者之间。你将在编译阶段模板被实例化时决定哪个类将被调用。

不过这样做是有原因的。大部分的软件架构都是在让你的项目更加灵活。它们让你的项目在修改时付出的代价更少。这意味着在项目中敲代码时会有比较少的假设。使用接口可以让你的代码在任何实现它的类里正常工作,而不是为了功能临时新创建一个类。使用观察者模式和消息模式让游戏中的两部分可以互通消息,以后也可以很容易地扩展到三个或四个部分互通。

但是性能表现其实基本上都是假设。优化的习惯来自于具体的限制。我们可以假设我们永远不会拥有超过256个敌人吗?这样的话,我们可以把一个ID封装到一个单字节里。我们只会在一个具体的类里调用某种方法么?好的,这样我们就可以把这个方法写死或者内嵌在这个类里。所有的实例都是同一个类?非常好,这样我们就能使用连续数组了。

但这并不意味着灵活性是不好的!它让我们可以快速地修改游戏,而开发速度对于获得有趣的游戏体验来说绝对是非常重要的。没有任何一个人,即使是Will Wright(模拟城市、模拟人生等游戏的创造者),可以在纸上就把一个非常平衡的游戏设计出来。这是一个需要迭代和试验的过程。

如果你越快地尝试新的点子并体验它们,那么相同的时间里你就可以更多地尝试,这样你就越可能发现一些很棒的东西。即使你已经找到正确的游戏机制,你仍然需要大量的时间去优化。一处小小的不平衡也会毁掉整个游戏的乐趣。

这里没有一个简单的答案。让你的项目具有更多的灵活性可以让你在游戏创作时更快速,但是需要消耗一定的性能。同样的,优化你的代码会使得它缺少一定的灵活性。

而我的经验是,更快地制作出一款有趣的游戏要比让一款快速制作出的游戏变的有趣要更容易。一种折衷的方案是,在设计完成之前让代码保持灵活性,之后剔除一些抽象概念去优化游戏的性能。

这就引入了下一个问题,无论何时何地,总会有不同类型的代码存在。本书的大部分都是在描述如何写出可维护、纯净的代码,所以我所拥护的很清楚,就是用“正确”的方法去做事情,不过那些草草写出的代码中有些东西也是有参考价值的。

编写拥有良好架构的代码需要考虑得很仔细,而这就需要消耗时间。而且,在项目的生命周期内维护一个好的架构是需要付出很多努力的。你需要像露营者对待他们的营地一样对待你的代码库:总是尝试在离开时把它变得比你找到它时更好。

如果这些代码是你需要长期使用的话,那么这么做是很好的。但是,就像我之前提到的那样,游戏设计需要大量的试验和探索。尤其是在开发的前期阶段,经常会写一些你“明知道”之后会扔掉的代码。

如果你只是想找出一个游戏点子玩起来是怎么样的,对它进行好的架构意味着在它可以在屏幕上运行以及你可以获得一些反馈之前需要花费大量的时间。如果这个点子最终不可行的话,那么用来把代码变得优雅地时间就随着你把它删除而浪费了。

原型设计--把一些仅仅完成了设计问题中的功能的方法拼凑在一起--是一种非常合理的编程实践模式。但是这种方式存在很大的问题。如果你写了一些以后准备抛弃的代码,那你必须确认你以后能够把它们抛弃掉。我曾经见过一些不好的管理者一遍又一遍地玩这样一个游戏:

老板:“嘿,我们有了一个想法想要尝试一下。只要做一个原型就可以,不用考虑需要把它做的多完善。你多快可以把这东西弄出来?”

*开发者:“好的,如果一些细微的功能不管,不需要测试,不需要写文档,而且可能会有一堆bug的情况下,几天内我就能给出一些完成功能的临时代码。”

老板:“嘿,那个原型很棒。你能花几个小时把它处理一下,让它成为正式的东西么?”

有一个保证你的原型代码不会成为最终使用的代码的小技巧,就是用和你制作游戏不同的语言去编写它。这样,你就可以重写它,因而避免它最终存在于你的游戏中。

你需要确认使用这种临时代码的人明白一件事,即使临时代码看起来好像是可以正常工作的,但是它是不可维护的而且必须要被重写。如果你有任何可能会继续使用它,那么你最好还是在写的时候就严谨一些比较好。

我们有以下几个方面需要考虑:
我们想要一个好的架构,这样代码在项目的整个生命周期都会很容易地去理解。
我们想要运行时的高性能。
我们想要当前的功能能尽快完成。

我认为这是非常有趣的,因为这些方面都关系到了某一种速度:我们的长期开发速度、游戏的运行速度、我们的短期开发速度。

这几个方面至少在它们的某些部分是相互对立的。好的架构提高了长期的开发效率,但是这意味着每次修改都需要多付出一些努力去维护架构。

最快写出的功能实现往往不是可以最快运行的。相反的是,优化它需要花费可观的开发时间。当优化完成后,整个代码库就会变得僵硬:高度优化的代码往往是不灵活的,而且修改起来会很困难。

现实中总会有今日事今日毕和担心明天所要完成的工作的压力。但是如果我们填鸭式地尽我们所能地快速完成功能,我们的代码库就会变得充满漏洞、bug和不一致性,而这些都会影响我们未来的开发效率。

这里没有一个简单的答案,需要的是取舍。从我收到的email来看,这让很多人感到沮丧。特别是那些想做游戏的新手们,他们最怕听到的就是,“这个没有正确答案,只有不同形式的错误的解决方法。”

但是,对我来说,这是令人兴奋的!看看其他那些人们奉献了自己的整个职业生涯去掌握的领域,你会发现那里总是充满了各种错综复杂的问题。毕竟,如果问题有一个简单的答案的话,所有人都会去照着这样去做。一个你一周就能掌握的领域是最最令人感到无趣的。你应该从来都没听说过某人因为挖沟的职业生涯而出名吧。

好吧,可能你听过;我对这个方面没什么研究。就我所知,这个世界上可能有狂热的挖沟业余爱好者,挖沟交流大会以及有关于挖沟的整个亚文化产业。我该选哪一个?

在我看来,这和游戏本身有着很多相同之处。一个像国际象棋这样的游戏永远不会被完全掌握,因为它的每一部分都和另一个部分有着完美的平衡。这意味着你能用尽一生的时间去探索所有可行的策略。而一个设计糟糕的游戏通常毁于唯一一种胜利战术被一遍又一遍地使用,直到你感到厌烦而最终弃坑。

后来,我在想,如果有一种方法可以解决这些问题的话,那它一定就是简单性了。在如今我的代码中,我会非常努力地去尝试写出最干净,最直接解决问题的代码。这种类型的代码,在你读过它之后,你就会很清楚它是用来干嘛的,而且再也想不出还有其它可能的解决方案。

我的目标是保证数据结构和算法正确(大概是这个顺序)然后以此为起点。我发现如果我可以保持事情简单的话,代码总体上也会变少。这意味着我想做出改变的时候可以少装一些代码到我的脑子里。

这种代码一般运行起来都很快,因为一切都保持简单性,没有太多的消耗,也没有很多需要运行的代码。(当然,并不总是这样的情况。你可能在一小段代码里封装了一堆循环和递归。)

不过,这里请注意,我并没有说简单的代码可以花更少的时间去写。你可能这么想过,因为你用更少的代码完成了项目,但是一个好的解决方案并不是由于代码的添加,而是对因为对代码的精炼。

Blaise Pascal一封很著名的信的结尾是,“我本想写一封更短的信,但是我没时间了。”
另一个被引用的句子来自于Antoine de Saint-Exupery:“完美的达成,不是因为没有更多的东西可以被添加了,而是因为没有东西可以被移除了。”
言归正传,我注意到每一次我修订本书中的一个章节,它都会变得更短。一些章节在它们完成之时被精简了大概20%。

我们很少会面对一个优雅的问题。取而代之的是一大堆的用例。就是你想要X在Z的情况下去做Y,但是在A的情况下要做W之类的东西。换句话说就是,一个长长的不同例子行为的列表。

最省心的解决方案就是为每一个用例编写代码。如果你看那些新手程序员工作的话,你会发现这就是他们经常在做的:为每一个他们想到的用例大量编写一堆的条件逻辑。

但是这样做是很不优雅地,而且这种类型的代码在被提供的输入数据和设计时有一点点不同的时候都有可能发生问题。当我们在思考优雅的解决方案时,我们往往能想到的就是最常见的那一种:一小段逻辑,在很多用例中使用时都是正确的。

寻找这种解决方案的过程有点像模式匹配和解谜。这需要从分散的用例中努力地看穿隐藏在它们表面下的规律。当你找到它的时候,感觉会很棒。

几乎所有人都跳过了这些引导章节,所以我要恭喜你一直来到了这里。对于你的耐心我没有更多的可以回报,我所能给你的是一些我觉得可能会对你有帮助的建议:

  • 抽象和解耦会让推进你的项目更快更简单,但是除非你确定某个问题中的代码需要这样的灵活性,否则不要在这上面浪费时间。

  • 有关性能的设计需要在你的整个开发周期中都有所考虑,但是那些底层和细节上的优化,因为可能会把一些特定的假设写入代码中,所以还是越晚进行越好。

相信我,在你的游戏发售时间的两个月之前,你都不需要担心“游戏只能跑到1帧”这个小问题。

  • 尽量快地去探索你的游戏的设计空间,但是要在保证速度的同时确定没有留下一堆坑。毕竟你以后还是要靠它吃饭的。

  • 如果你准备丢弃某段代码的话,那就不要浪费时间去把它写的很漂亮。摇滚明星们通常会把酒店房间弄的很乱,因为他们知道第二天就要退房了。

  • 不过,最重要的就是,如果你想做出一些有趣的东西的话,那就享受做出它的过程吧。


因为水平有限,翻译的文字会有不妥之处,欢迎大家指正

“本译文仅供个人研习、欣赏语言之用,谢绝任何转载及用于任何商业用途。本译文所涉法律后果均由本人承担。本人同意简书平台在接获有关著作权人的通知后,删除文章。”

我要回帖

更多关于 游戏中常用的设计模式 的文章

 

随机推荐