游戏的数值系统的实现和演化
在游戏的战斗系统中,数值系统是很重要的模块之一。对策划来说,数值策划是一个非常重要的分类,关于数值从策划的角度介绍的比较多。但是对于程序来说,可能是这一块和需求比较密切,实现起来也没有特别复杂,关于数值模块实现的介绍,网上的资料比较少。
最近我在对我们游戏的数值系统进行重构,一方面希望提高性能,另一方面支持策划配置更新和实时测试,减少沟通成本(主要是不想把自己变成实现策划想法的工具)。
0 数值系统包括什么?
我们可以把市面上绝大部分游戏的战斗抽象成这种模式:攻击者A使用技能I击中了受击者B,造成了伤害x。
那么战斗其实就可以分为两个模块:技能流程模块/数值模块。
技能流程[1]描述攻击者以什么样的表现攻击了受击者;
数值模块负责根据攻击者A的属性/受击者B的属性以及技能I的信息,计算出来伤害值x;
由上可知,数值系统我们可以分成三块:属性、技能信息和伤害计算。
属性模块记录单位的属性值,包括血量上限、攻击力等。
技能信息模块记录每个技能和伤害/治疗计算相关的信息,比如一个技能的伤害为:基础伤害+伤害比例攻击者攻击力,基础伤害和伤害比例*就是这个技能的技能信息。
伤害计算模块是由计算规则构成。一个伤害值由攻击者属性/受击者属性和技能信息决定,那么使用这些信息,如何计算出来最终的伤害值,这个规则就是伤害计算模块。
1. 数值系统的实现过程中会遇到什么困难?
若游戏数值需求比较简单,比如游戏只有攻击力和血量,所有的技能都是对目标造成百分之几攻击力的伤害值,其实这一块没有什么好说的,直接代码硬编码就行。
但对于有的游戏,比如暗黑,玩家具有很多的属性种类,而这些属性对最终的伤害都可能产生影响。对于这种游戏,属性数量多,伤害计算流程复杂,往往会造成以下问题:
1.性能问题
属性之间往往是有依赖关系的,比如在dota中,(力量型英雄)等级决定了力量,而力量又决定了攻击力/血量等。当等级上升时,这些属性都需要重新计算。
其次,每次伤害也需要实时计算伤害值,对于数值复杂的游戏,每次计算要走一大坨流程逻辑,这可能是一个比较大的性能热点。
在属性数量比较多、伤害计算流程复杂的情况下,数值计算就会成为性能热点。还有一个重要原因是我们使用的脚本语言python,计算成本相对C++更高。
2.维护更新困难
在游戏的开发过程中,数值系统往往会快速迭代。策划今天加了一个攻击属性,明天又要加一个防御属性,后天可能觉得伤害计算流程不够牛逼,再改改伤害计算流程。
若策划每次修改都需要程序参与,一方面沟通成本会很高,另一方面,策划在他们文档里改了一些细节但没有告诉程序或者程序漏了,会导致代码和策划预期不同,且问题很难被发现。
3.测试困难
数值系统往往难以测试。比如QA在玩游戏的过程中,发现某一次伤害值不合理,但是qa也不知道哪里出了问题。只能去找策划让策划看一下相关的配表有没有问题,若策划没找到问题,只能再让程序去debug。一方面,有些情况又往往难以重现,也就无法debug;另一方面,这个debug过程设计程序策划qa三方,成本太高。
4.无法做到离线分析
从程序和qa的角度,保证游戏的数值系统正确性是必要的事情,也是相对简单的事情。但是,从策划的角度如何确保策划填的数值的合理性其实是一个很困难的事情。
换句话说,策划如何知道他们填的数值、成长、伤害计算公式是符合他们预期的呢?这件事我们项目qa和我都考虑过,但是没有想到比较好的离线分析方案。
我咨询了一些其他项目实现,比较常见的是在线分析方案,就是策划去配置几个玩家、怪物去互相攻击,看他们的属性、伤害、胜负关系和胜利时间是否符合预期。
我们系统由于数值模块已经和游戏解藕,其实是能做到支持离线分析的。但策划对此的需求并不强烈也就没有推动,我们也没有做这块内容。
2 整体解决方案
这里,我先对我们游戏遇到的问题,总体介绍下我们的解决方案。
2.1性能优化方案
对于性能问题, 我们以下图暗黑3的情况为例分析。
暗黑3截图在游戏里面,副本中往往是多只相同类型的怪物聚集在一起,并且具有相同的等级。因此,这么多怪物其实属性都是相同的。因此,我们可以让所有的属性相同的怪物,只计算一次属性值并且所有怪物共用。当玩家使用一个AOE同时打中多个相同属性的怪物时,其实我们可以只计算一次伤害,然后把伤害缓存下来,其他所有的伤害都不用再重新计算了。
注意,由于游戏中的伤害一般都会有随机性,这里缓存的是不随机的部分,然后每次伤害结算根据缓存信息计算最终伤害。比如,我们缓存暴击率,然后实时计算时确定是否暴击
此外,在我们游戏里策划设计了大概100个属性,并且绝大部分都会影响到最终的伤害计算。但是,这么多属性,大部分都是给玩家/BOSS设计的,而对于小怪,他们的属性少很多,伤害计算公式也就简单很多。因此,我们可以对小怪的属性进行简化,涉及小怪的伤害计算也就可以对应进行简化。
对于性能问题,我们的方案总体来说用了两种方案:
- 就是把能缓存的数值缓存起来,能共用的进行共用,以此减少计算量。
- 对游戏中单位分类,分为小怪/非小怪。对于小怪,可以简化他们的属性和伤害计算。
使用缓存方面,我们做了以下工作:
- 引入属性树描述一类单位的属性值间的依赖/更新关系和计算公式。游戏中所有相同单位可以共用一棵属性树。
- 具有相同属性的不同单位,可以使用共同的属性值。
- 多个单位,若他们的技能信息相同,则他们共用相同的技能信息。
- 引入伤害图描述伤害计算流程,游戏中只需要管理固定数量的伤害图。
- 根据攻击者属性值/受击者属性值和技能信息,计算并缓存伤害值。以后,相同攻击者属性、受击者属性、技能信息的伤害不需要计算,直接拿cache。
- 即使属性值变化,也不一定需要从新计算所有的伤害流程。只需要重新计算属性改变所影响到的伤害流程中的部分结果。
对单位分类方面,我们区分了小怪和非小怪,然后做了以下工作:
- 小怪和非小怪的属性数量不一样。
- 将伤害计算区分,分为非小怪攻击非小怪、非小怪攻击小怪、小怪攻击非小怪和小怪攻击小怪。对于治疗数值的计算同样处理。因此我们游戏一共只管理了8个伤害计算图。
此外,我们把数值系统和其他模块解藕后,用C++重新实现了一遍,也获得了较大的性能提升。
性能优化结果:
- 伤害计算所消耗的时间受cache命中率影响,python版本比较性能提高了6-10倍,C++版本比python版本又提升了3-5倍(由于数值公式计算还使用了callable python对象,C++实现的提升比预期差了点)。
- 相同单位共用属性值的提升效果和策划配置场景怪物的方式有关,在大量相同小怪聚集的地方,效率提升明显。
目前,数值计算所造成的性能消耗不再是战斗逻辑的瓶颈,虽然还有一些优化空间,但是也不打算继续了。
2.2 数值系统的解藕和可配置化
刚开始,我们的数值系统是和游戏中的其他模块耦合的。而且伤害值的计算是程序写死的,不利于程序的维护和策划对数值模块进行修改。
为此,我对游戏中的数值系统首先进行解藕。将游戏的数值相关的内容都统一放到一个模块中封装起来,只留了有限的几个接口供其他模块使用。此外,为了性能优化,做的相关内容比如缓存等,其他模块也是不知道的,只有数值模块才了解内部逻辑。其他模块只能通过有限的几个接口,获得或改变某单位的属性值,或者根据攻击者受击者和技能,查询伤害值信息。
另外,为了让策划可以自己修改属性和伤害计算流程,我们将这些信息都做成了可配置的。策划可以在单位表里填写单位的属性树,描述单位的属性值更新依赖和计算公式。对与伤害值计算流程,我们引入了伤害值计算流程图,策划可以定义流程图中的各个节点,以及各个节点所使用的计算公式,从而描述伤害值的计算流程。
伤害流程图配置文件2.3 数值系统的测试方案
刚才已经介绍我们把数值模块从游戏中抽离出来,那么带来的另一个附加的好处就是这个系统可以一定程度脱离游戏独自运行。
之前,数值系统无法测试就是因为无法获得每次伤害计算流程的中间结果。若我们可以从游戏中获得单位的属性值,然后在游戏外面计算伤害值并且把伤害计算的流程和中间都展示出来,策划和QA就能明确的了解数值系统的内部运行逻辑,哪里有问题也就一目了然。
因此,我们的实习生做了一个数值系统的模拟工具。最核心的功能就是从游戏中抽取单位的属性值,然后把伤害计算的中间结果和最终结果都展示出来。
3 属性树和属性值
下面介绍我们游戏中关于属性树和属性值的实现。在我们游戏中,每类怪物/玩家控制单位都有一个自己的属性树(我们游戏也支持不同类型单位使用同一棵属性树,这里不扩展介绍)。
这个属性树可能如下所示:
一棵属性树从结构上来看,有的不同类型单位可能属性树的结构相同,但是不同的是,下层节点被上层节点影响的公式不同。比如,有的单位攻击力 = 4 * 力量,而有的单位攻击力 = 10 * 力量。我们把公式也保存在属性树中。
每一个单位都持有自己的属性值(当然我们之前介绍过,通过一些方式若能确定两个单位的属性值相同,可以共享一个属性值。)
属性值保存的就是当前单位的属性信息,可以把它通过简单的dict实现,key是一个属性名,value就是值。当一个属性发生了修改,比如等级升级或者装备增加了力量,那么就根据它的属性树结构,使用公式重新计算被它影响到的所有属性。
4 伤害计算重构方案
之前,我们伤害计算是夹杂在游戏战斗逻辑中的,和其他模块藕合在一起,程序维护苦难,策划也没法直接修改。后来,我们引入伤害计算流程图,策划配置伤害流程图,程序只需要读取就可以了。同时,伤害流程图的引入,也可以支持中间结果的缓存,对性能提升也有贡献。
4.1 伤害计算流程图
一次伤害结算,是由攻击者属性、受击者属性以及技能参数确定的。
我们引入伤害计算流程图来描述伤害计算的流程,以下图为例,下图是一个简单的伤害计算流程:伤害=技能伤害加成*攻击者攻击力-受击者防御力。
伤害计算流程图举例该图中,技能的伤害加成属于技能信息,攻击者的攻击力属于攻击者属性,受击者防御力属于受击者属性。
伤害计算流程图中存在两个节点:
1.中间节点:技能加成*攻击力
2.最终结点:最终伤害
基于伤害计算流程图,我们可以定义每个节点(中间节点和最终结点)的描述规则,那么伤害计算流程就可以由各个节点的描述构成,伤害计算流程描述文件如下:
伤害流程图配置文件4.2 伤害计算性能优化
我们在进行伤害计算时,若每次都计算一次,计算量往往是比较大的。因此,在上文我们说过,当同一个单位,使用相同的技能,多次中相同的另一个单位时,我们可以只计算一次,然后把结果缓存起来下次使用。
可以再拓展一下,相同属性值的单位(可能是不同单位),使用相同的技能,击中相同属性值的单位(也可以是不同单位),只计算一次,把结果缓存下来。这时,若场景中成堆的小怪,往往能大幅度提高cache命中率。
这时就出现了一个问题,若单位的属性、技能的参数变化特别频繁(而这是游戏的常态),缓存的伤害信息就无效了,又大幅度降低了命中率。(我们游戏大概能命中70%~80%,这个数字并不高)
因此,我们基于伤害计算流程图结构,对中间结果进行cache。以之前的最终伤害 = 技能加成*攻击者攻击力-受击者防御力
为例。我们第一次计算的时候,中间节点和最终结点都需要计算。当受击者防御力改变时,我们的中间节点“技能加成*攻击力”并不需要重新加算,只需要计算最终结果。
对于真实游戏中的伤害计算流程,会存在比较多的这种中间节点,而缓存这种中间结果的方式也可以减少计算量。
测试发现,过多的引入中间节点引入(尤其在python中)会由于引入较多的函数调用和管理成本导致效率降低。因此中间结果需要以一定的经验设置,引入不常常变化的中间节点是比较好的方式,但是无节制的引入中间节点有可能造成最终结果计算效率的变低。
5 技能的数值信息
对于小怪使用的技能,一般不存在技能等级等动态信息,其实全游戏所有的相同类型小怪都共用同一个技能信息,也是完全可以的。
而对于玩家,根据需求可以适当cache。比如我们游戏的技能信息被技能等级影响,这种情况如果技能等级最高5~10级的话,使用全局cache也是可以的。不幸的是,我们游戏技能等级最高100级,而且玩家技能信息可能会被各种符文影响修改,所以没法全局cache,只能每个玩家保存自己的技能信息,只要保证每次释放技能不要都去重新生成技能信息就好。
6 伤害模拟分析工具
前面介绍了我们把数值系统和游戏逻辑进行了解耦,并且把伤害计算流程改为可配置文件,因此我们的伤害计算可以做到离线计算。
基于此,我们项目的实习生进一步开发了伤害模拟分析工具。此工具最重要的功能是从游戏中实时的抽取单位的属性信息和技能信息,通过工具可以选择计算攻击者单位使用某一个技能攻击收集者单位的伤害计算流程信息,包括计算过程的中间结果和最终结果。
此外,我们还支持离线计算某类单位和另一类单位互相攻击的伤害结果分析等。可以供策划分析数值成长和数值平衡。
伤害模拟分析工具界面总结
总的来说,我们的数值系统的迭代主要是两个方向,第一个是使用cache,解决性能问题。第二个是解耦和可配置,从而进一步支持策划直接配置和实时测试。
预告:
下一篇我想写一篇形而上的战斗系统开发总结,希望梳理一下实现方案背后的设计思想和思路。敬请期待。