首先先来定义一下什么是我这里说的核心游戏系统,一般来说,游戏可以大致分为两个部分,一个部分是我这里指的核心游戏部分,比如FPS里的射击战斗部分,或者如LOL里的战斗对抗部分,又或者是体育类游戏里的比赛部分等等。
这些是游戏里的主要玩的点,核心游戏部分可以很重,占到玩家80%以上的游戏时间,也可以很轻,甚至没有,像现在很火的列王的纷争(COK),几乎就是没有什么核心游戏部分。另一部分就是外围的辅助系统,比如装备,任务,社交等等,这部分也有玩点和设计用意,这两个部分相辅相成组成了大部分游戏的主体框架。而今天要聊的就是第一部分的核心游戏系统。 从开始做游戏到现在,我大部分的工作是专注在引擎以及核心游戏系统部分,所以今天就想来聊聊如何来设计核心游戏系统。当然这个设计不一定适用于所有的游戏,仅仅是我个人的经验之谈,希望能给大家一些参考的价值。 大多数情况下,核心游戏系统都比较复杂,牵涉到很多系统之间的协作,也和策划的需求有相当紧密的联系,国外公司一般称这类程序员为Gameplay programmer,国内公司这种职位相对较少,一般就以泛指的客户端程序员代替了,但和做外围系统的程序员不同,真正的Gameplay programmer需要对于AI系统,动画系统,物理系统都有一定的了解,因为这是核心游戏部分都会涉及的领域。正因为核心游戏系统的复杂性,所以必须要有一个适合的,灵活的架构来支持,抛开一些基本的优点,诸如可扩展性,低耦合等等要求不说,我最直观的感受,就是以下两点好处:
- 多人合作:一个好的架构可以将系统进行合理的拆分,这样的话就便于多人协作,对于核心游戏系统来说,一般是不可能一个人单枪匹马的去完成的,所以如何去拆分任务让更多的人参与,对于项目而言是相当有利的。对于现在的AAA的游戏来说,单单一个主角,可能就会有将近10个程序员在一起制作,包括AI,行为,动画等等,所以好的架构可以保证工作效率随着人数的增加而得到提升;
- 留有“挥霍”的空间:架构是很难完美的,因为在开始设计的时候,所有的需求并不明确,特别是核心游戏系统,可能会推倒重来,重构很多次。而当游戏方向定下来了之后,一些策划的改动或者扩展,也会使得以前的架构在某些情况下变得不是很适用,这个时候就会需要一些对于特殊情况的处理,也就是所谓的“hack”,好的架构会让我们在开发后期,在不重构的情况下,有余地进行适当的“hack”,而不是在一开始就“hack”到底,导致bug满天飞。
好,接下来开始说说设计思路。 解构一个复杂的系统,有一个很好(不是唯一)的办法,就是“分层结构”(Layered structure),也就是把一个复杂系统,分成一层一层的结构,每一层都做每一层自己的事情,并且每一层都是单向依赖。这样可以把一个网状的,如同乱线团一样的复杂系统,梳理的非常的清楚。这样的例子其实很多,比如学计算机的人都很熟悉的OSI网络七层架构,这就是一个非常好的,把复杂问题层次化的典型例子,它使得每一层都可以独立设计,而且可以有明确的设计目标,层与层之间的接口也变得非常清晰。
还有一个游戏的例子,就是游戏的架构,游戏其实也是遵照这层次化的设计思路来设计的,虽然不像OSI那样有一个标准化的结构,但是大部分游戏可以分为核心层(Core),引擎层(Engine),游戏类型层(Game Genre),游戏层(Game),像现在一般的商用的游戏引擎,基本就做到核心层和引擎层,再往上就是使用引擎的人自己设计和实现了,像一些大公司可能会有一些积累,就会根据不同的游戏类型在引擎层的之上抽象出游戏类型层,比如体育类游戏,射击类游戏等等,然后再开始开发实际的游戏产品。这种分层的架构设计就可以帮助我们把复杂的系统进行解构,从而实现每个子系统或者模块的功能单一化。
核心游戏系统架构也可以用这样的思路来设计,这样每一层都可以由不同的人来负责,如果实现的话,在一层当中也可以进行任务分工,那下面我就根据执行顺序,自上而下一层一层来描述。 总共的架构分为五层
第一层:更新/收集世界信息 这部分主要是要设计两个部分,一个是知识池(Knowledge Pool),另一个就是感知器(Sensor)。听上去很高大上,其实概念上很简单,知识池可以理解为就是世界信息的数据存储,比如某一个智能体需要一个这样的数据,“谁是离我最近的人”,这个数据就可以存下来,方便获取,写成代码的话,就类似于这样:
- KnowledgePool().Get(WHO_IS_NEAREST_TO_ME, meEntity)
这种数据存储的数据结构,可以根据不同的情况去选择,用key-value的黑板格式,或者自定义的数据类型都可以,也可以分成多个知识池来管理不同类型的数据,设计的关键就是要有清晰的世界信息获取方式。 感知器的话,就可以理解为具体的获取数据的方法,可以定义一个感知器的接口类:
- interface ISensor {
- Update()
- }
这样就可以把这个感知器注册到一个感知器的管理器中,当收集所有世界信息的时候,只要遍历一遍这些感知器,就可以完成对于世界信息的收集工作:
- foreach(s in sensers){
- s.Update()
- }
感知器也可以分为两种,一种是全局的感知器,这种可以看成是对于游戏整个世界,或者关卡的抽象,比如势力图
还有一种是个体感知器,比如听力,视野等等
当然,这只是一种思路,也可以不定义接口,而是写成不同的数据更新方法。这就是第一层,主要的功能就是为下层预备数据。 第二层往下,都是针对单个智能体的更新,也就是说需要对每一个智能体执行更新操作。关于智能体的行为,很多时候容易写的一团糟,又要决策,又要运动,又有动画,还要处理物理,有些系统呢,需要每帧更新,比如位置,有些呢,又不需要更新的这么勤快,比如决策,所以在设计上我把它分成几个层次,决策层,请求层,行为层,运动层。 第二层:更新决策层(做什么)- What to do 决策层就是负责来决策此时该智能体应该要做什么,比如我要走到某个位置,我要攻击,放技能等等,可以说,这就是传统所说的人工智能AI部分,这部分只根据当前所有的世界信息,产生“做什么”的决策,决策的内容会封装在一个“请求”(Request)的结构中,继续向下传递: 这部分的结构可以有多种选择,状态机,行为树,甚至神经网络都可以,但是有两个要点决策的时机:也就是什么时候进行决策,这里就可以用来控制决策的频率,比如离玩家很远的人可以降低决策频度,离玩家近的人,可以提高决策频度等等,类似与这种的控制都应该在这一层中得到支持和实现。决策请求的类型:这一层的输出可以看成是所有该智能体可以做的决策的总和,所以千万不要把一些下层的行为放在这里,比如寻路,接下来会说到,寻路并不是决策层的决策行为,它只是来处理“移动”这个决策的一个方法。还有比如选择动画,也不应该是决策层所要关心的内容。 还有一种特殊的模块是属于这一层,那就是玩家输入,玩家的输入说起来,其实也是一种决策,只是这个决策是通过玩家来做出的。行为树就很容易处理这个情况,将玩家输入和AI决策可以融合成一体让所有的智能体共用:
第三层:更新请求层 上面说到,决策层产生的输出是“请求”,请求是一种自定义的数据结构,包含所以该决策所需要传递的决策信息。那为什么要更新请求层呢?直接把当前请求传递给下一层不就好了吗?这一层的功能抽象,也是我在实践中的经验,总结一句话,“请求层”就类似于一个“防火墙”,由它来“过滤”,那些请求会被继续往下传递到行为层。 我们可以先来看一下请求层的设计,请求层的设计,借鉴了渲染中的“双缓冲”结构,把请求分为,前端请求(Foreground Request),后端请求(Background Request),前端请求就是当前智能体正在执行的请求,因为一个决策请求可能需要多帧才能完成(想象一下,移动到某一个点这个请求,就需要一段时间才能完成),后端请求就是准备执行决策请求,当一定条件满足后,就可以做了一个“Flip”的操作(前后互换),把后端请求变成前端请求,这样的话,后一层就会执行这个请求,从而改变行为了。
细心的同学会发现,在上面的描述中,有一个地方值得回味,就是Flip操作是,“当一定条件满足后”,而这个条件的监测,就是这里更新请求层的时候需要做的事情了,其实每一个决策是存在潜在的优先级的,这个优先级和策划的设计有关,比如我当前正在执行一个攻击的请求,这个时候,新的请求是一个释放技能,此时策划要求,技能释放能够打断当前的攻击行为,那这个判断逻辑就可以写在这一层中,使得当这个条件满足时,可以立即切换请求。这里的设计一般采用配优先级表,和基于规则的(Rule-based)的实现方式。 有了这一层的逻辑抽象,就可以保证它的上层和下层都不需要关心决策能否被执行,而只要关心自身的决策/行为逻辑就可以了,大大降低了上下层的实现复杂度。由于这部分逻辑相对比较繁琐,所以把这些繁琐的逻辑集中在一起,也是一种理想的设计思路。 第四层:更新行为层(怎么做)- How to do 就像补充里所描述的,行为层的职责就是怎么做,也就是如何去完成上层经过决策,经过规则的逻辑判定,最终“胜出”的那个前端请求。像前面提到的寻路,或者选择需要播放的动画,都是在这一层所完成的工作,这层的实现同样可以用行为树,或者状态机,不过我还是推荐用行为树,因为行为树可以扩展和处理更复杂的行为逻辑,比如随机,序列,并行等等。某些请求可能不是用单个行为可以完成的,需要多个行为的配合,比如完成一个技能释放,需要先集气,然后再释放,类似这样的行为,就可以用行为树来实现了,更棒的是,集气这个行为还能被共用。
在这一层中,会产生一系列的输出,有特效,有动画,可能还有声音等等,有一个很重要,也是必不可少的,那就是运动信息(Kinematic Information),这也是智能体最终呈现的样子,这部分内容包括空间信息(位移,旋转,缩放)和动画信息,某些情况下,智能体的空间信息可以通过物理计算在这一层直接更新,动画信息直接调用引擎的播放接口即可,但有时候这种处理还不够,那就需要第五层,运动层的参与。 第五层:更新运动层 游戏中物体的移动有两种方式,一种是动画跟随物理,比如为了解决移动中的滑步问题,我们可以做多个移动的动画,然后根据速度做融合,这样可以调出一个滑步不明显的表现,还有一种就是root motion,也就是物理跟随动画,就是物体的移动和旋转完全跟随动画中的效果,这样可以解决一些物理没有办法模拟的复杂运动。 有些时候,我们需要混合使用这两种方式,并且在位移过程中需要加上一些修正(比如为了解决同步问题),这个时候,就需要用运动层来实现。这一层的输入,就是行为层产生的运动信息,输出自然就是智能体最终的空间信息和动画了。 在实现上,建议对这两种方式进行封装,这样可以对于上层来说,接口就相对统一了。 有了这五层的设计,整个核心游戏系统的更新循环就完成了,并且每一层的功能职责和输入输出都有了明确的定义,如下图:
每一层具体的设计可以仁者见仁,智者见智,并且也和具体的游戏有关,但是整体的架构基本就可以参考这样的思路,至少以我的实践来看,不会导致结构混乱,也可以更好的进行分工和合作。 最后再聊一个关于核心游戏部分网络同步和回放系统的问题(同步和回放是差不多的东西,回放只是把同步的东西存下来而已)。 其实如果有了上面的架构设计,理解网络同步就很简单了。 如果同步放在第二层,那就是采用的“同步输入”(Input synchronization)的方式
如果在第三层,那就是“同步命令”(Command synchronization)的方式
如果放在第四层/第五层,那就是“同步状态”(State synchronization)的方式
这三种方式是越往下传输的数据越多,但是“失同步”(Out of synchronization)的风险就越小。当然不同的方式在具体实现上,还是有很多值得讨论的地方,这里就不多说了。如果客户端和游戏服务器采用相同的语言,那就可以很方便的在单机游戏和网络游戏间切换,在单机模式下,只是本地和本地通信罢了,FPS游戏很多都是这样去实现的,其实在单机模式下,内部也是一个CS的架构,而如果需要一个服务器的版本,只是加一个宏去编译而已。