我们的逻辑服务器(Game Server,以下简称GS)主要逻辑大概是从去年夏天开始写的。因为很多基础模块,包括整体结构沿用了上个项目的代码,所以算不上从头开始做。转眼又快一年,我觉得回头总结下对于经验的积累太有必要。
整体架构
GS的架构很大程度取决于游戏的功能需求,当然更受限于上个项目的基础架构。基础架构包括场景、对象的关系管理,消息广播等。
需求
这一回,程序员其实已经不需要太过关心需求。因为我们决定大量使用脚本。到目前为止整个项目主要还是集中在技能开发上。而这个使用脚本的度,就是技能全部由策划使用脚本制作,程序员不会去编写某个具体技能,也不会提供某种配置方式去让策划通过配置来开发技能。这真是一个好消息,不管对于程序员而言,还是对于策划而言。但后来,我觉得对于这一点还是带来了很多问题。
实现
基于以上需求,程序员所做的就是开发框架,制定功能实现方案。脚本为了与整个游戏框架交互,我们制定了“触发器“这个概念,大概就是一种事件系统。
这个触发器系统,简单来说,就是提供一种“关心“、”通知“的交互方式,也就是一般意义上的事件机制。例如,脚本中告诉程序它关心某个对象的移动,那么当程序中该对象产生移动时,就通知脚本。脚本中可以关心很多东西,包括对象属性,其关心的方式包括属性值改变、变大、变小,各种变化形式;对象开始移动,移动停止;对象碰撞,这个会单独谈谈;定时器等。
除了触发器系统外,还有个较大的系统是游戏对象的属性系统。对象的属性必然是游戏逻辑中策划最关心最容易改动的模块。既然我们程序的大方向是尽可能地不关心策划需求,所以对象属性在设计上就不可能去编写某个具体属性,更不会编写这个属性相关的逻辑功能。简单来说,程序为每个对象维护一个key-value表,也就是属性名、属性值表。该表的内容由脚本填入,脚本享有存取权限。然后脚本中就可以围绕某个属性来编写功能,而程序仅起存储作用。
第三,怪物AI模块。AI模块的设计在开发周期上靠后。同样,程序不会去编写某类AI的实现。程序提供了另一种简单的事件系统,这个系统其实就是一个调用脚本的方案。当关于某个怪物发生了某个事件时,程序调用脚本,传入事件类型和事件参数。这个事件分为两类:程序类和脚本类。脚本类程序不需关心,仅提供事件触发API。程序类事件非常有限:怪物创建、出生、删除。
除了以上三块之外,还有很多零散的脚本交互。例如游戏对象属性初始化,角色进入游戏,角色进入场景等。这些都无关痛痒。
接下来谈一些关键模块的实现。
定时器
整个GS的很多逻辑模块都基于这个定时器来实现。这个定时器接收逻辑模块的注册,在主循环中传入系统时间,定时器模块检查哪些定时器实例超时,然后触发调用之。这个主循环以每帧5ms的速率运行,也即帧率1000/5。
这个定时器是基于操作系统的时间。随着帧率的不同,它在触发逻辑功能时,就必然不精确。游戏客户端(包括单机游戏)在帧率这块的实现上,一般逻辑功能的计算都会考虑到一个dt(也就是一帧的时间差),例如移动更新,一般都是x = last_x + speed * dt。但,我们这里并没有这样做。我们的几乎所有逻辑功能,都没有考虑这个时间差。
例如,我们的移动模块注册了一个固定时间值的定时器,假设是200ms。理想情况下,定时器模块每200ms回调移动模块更新坐标。但现实情况肯定是大于200ms的更新频率,悲剧的是,移动模块每次更新坐标都更新一个固定偏移。这显然是不够精确的。
更悲剧的是,定时器的实现中,还可能出现跳过一些更新帧。例如,理论情况下,定时器会在系统时间点t1/t2/t3/t4分别回调某个逻辑模块。某一帧里,定时器大概在t1回调了某逻辑模块,而当该帧耗时严重时,下一帧定时器模块在计算时,其时间值为t,而t大于t4,此时定时器模块跳过t2/t3。相当于该逻辑模块少了2次更新。这对于移动模块而言,相当于某个对象本来在1秒的时间里该走5格,但实际情况却走了1格。
当然,当游戏帧率无法保证时,逻辑模块运行不理想也是情有可原的。但,不理想并不包含BUG。而我觉得,这里面是可能出现BUG的。如何改善这块,目前为止我也没什么方案。
移动
有很多更上层的模块依赖移动。我们的移动采用了一种分别模拟的实现。客户端将复杂的移动路径拆分为一条一条的线段,然后每个线段请求服务器移动。然后服务器上使用定时器来模拟在该线段上的移动。因为服务器上的阻挡是二维格子,这样服务器的模拟也很简单。当然,这个模块在具体实现上复杂很多,这里不细谈。
碰撞检测
我们的技能要求有碰撞检测,这主要包括对象与对象之间的碰撞。在最早的实现中,当脚本关心某个对象的碰撞情况时,程序就为该对象注册定时器,然后周期触发检测与周围对象的距离关系,这个周期低于100ms。这个实现很简单,维护起来也就很简单。但它是有问题的。因为它基于了一个不精确的定时器,和一个不精确的移动模块。
首先,这个检测是基于对象的当前坐标。前面分析过在帧率掉到移动更新帧都掉帧的情况下,服务器的对象坐标和理论情况差距会很大,而客户端基本上是接近正确情况的,这个时候做的距离检测,就不可能正确。另一方面,就算移动精确了,这个碰撞检测还是会带来BUG。例如现在检测到了碰撞,触发了脚本,脚本中注册了关心离开的事件。但不幸的是,在这个定时器开始检测前,这两个对象已经经历了碰撞、离开、再碰撞的过程,而定时器开始检测的时候,因为它基于了当前的对象坐标,它依然看到的是两个对象处于碰撞状态。
最开始,我们直觉这样的实现是费时的,是不精确的。然后有了第二种实现。这个实现基于了移动的实现。因为对象的移动是基于直线的(服务器上)。我们就在对象开始移动时,根据移动方向、速度预测两个对象会在未来的某个时间点发生碰撞。当然,对于频繁的小距离移动而言,这个预测从直觉上来说也是费时的。然后实现代码写了出来,一看,挺复杂,维护难度不小。如果效果好这个维护成本也就算了,但是,它依然是不精确的。因为,它也依赖了这个定时器。
例如,在某个对象开始移动时,我们预测到在200ms会与对象B发生碰撞。然后注册了一个200ms的定时器。但定时器不会精确地在未来200ms触发,随着帧率的下降,400ms触发都有可能。即便不考虑帧率下降的情况,它还是有问题。前面说过,我们游戏帧保证每帧至少5ms,本来这是一个限帧手段,目的当然是避免busy-loop。这导致定时器最多出现5ms的延迟。如果策划使用这个碰撞检测去做飞行道具的实现,例如一个快速飞出去的火球,当这个飞行速度很快的时候,这5ms相对于这个预测碰撞时间就不再是个小数目。真悲剧。
技能
虽然具体的技能不是程序写的,但正如把几乎所有具体逻辑交给策划写带来的悲剧一样:这事不是你干的,但你得负责它的性能。所以有必要谈谈技能的实现。
技能的实现里,只有一个技能使用入口,程序只需要在客户端发出使用技能的消息时,调用这个入口脚本函数。然后脚本中会通过注册一些触发器来驱动整个技能运作。程序员一直希望策划能把技能用一个统一的、具体的框架统一起来,所谓的变动都是基于这个框架来变的。但策划也一直坚持,他们心目中的技能是无法统一的。
我们的技能确实很复杂。一个技能的整个过程中,服务器可能会和客户端发生多次消息交互。在最初的实现中,服务器甚至会控制客户端的技能特效、释放动作等各种细节;甚至于服务器会在这个过程中依赖客户端的若干次输入。
下一篇我将谈谈一些遇到的问题。