游戏开发笔记(九)——技能系统

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/mooke/article/details/9771545
      技能系统是一个对于游戏来说,非常重要,实现起来又有些复杂的模块了。

       在这个模块的设计上,很多程序都有不同见解,以及可以想到的是,在这个模块的制作上早期的许多团队都或多或少的走过一段弯路。
这里也不是说下面写的这些就是康庄大道了,只算是这个行业发展到某个阶段可能是比较经典的思路罢了,相信它还是可以继续不断演变进化的 :)

       技能系统虽然在游戏所有的子系统中算是一个比较庞大复杂的模块,但私下考虑的时候,我发现对于许多不同类型的游戏也并非一定要作出不同的技能系统设计。把握住其核心部分后,即使更换游戏类型,也能够有较好的适用性。

在构建之前,和开发任何东西一样,首先考虑有什么样的需求(弄清需求让你不会在面对复杂情况时惊慌失措XD):

      因为现实环境下,动手开发之前一般已经有项目计划书——或者至少也已经有一个大致的对产品原型的考虑的——这个原型通常是过去的某款游戏或者某几款游戏功能的排列组合...
      所以通常来说,我们的最终的目标是要使技能系统能够覆盖到这个”产品原型“中的任意技能表现并留出一定扩展空间才行。因为现在没有现成的游戏规划,所以只好先举例说明。对于像魔兽世界、或者大菠萝这样的ARPG类型的游戏原型,我们首先要分析一下其中技能部分会涉及到哪些要素,并进行简单归纳。

具体的说,在这种类型游戏里,我们常常可以看到这几种比较有代表性的技能效果:
位置相关的如:瞬移、冲撞、击退、跳跃等
持续时间相关的如:眩晕、定身、临时提高xx属性等、魔法盾等
永久效果相关的如:永久加属性上限、攻击时永久附带某种效果等
障碍效果的如:骨墙、还有像DOTA某牛的耕田技能
导弹系列如:追踪箭、魔兽的远程攻击等
直线打击类的如:菠萝2的闪电、魔兽争霸熊猫的喷射技能等
连锁效果的如:治疗链、闪电链等
地点持续类的:火墙一类的技能
还有吟唱类的:一下子想不起技能名...概括起来就是先念x秒咒语,然后出效果
还有立即生效类的:比如传奇的闪电术、战士的强力打击等
还有一些多种类型复合的技能:比如菠萝2的九头蛇(既有地点持续,又放出火球攻击)
以及其它一时没想到的类型等等。

      实际开发过程中,为每一个技能写全套代码可行性不大,所以必须对技能进行分类处理,然后我们把一个个效果排列组合来完成丰富的技能设计。观察角度不同我们会得到不同的分类结果,这里的分类依据是对技能效果的主要功能特点的理解,结果并不是绝对化的。

      好,既然已知本系统的设计要求能够完成上述功能,那么就可以尝试性的开始构思程序了。


结构初步设计:

      对于技能系统这个比较大的主题,通常要先拆小了来分析。也许不太恰当,但姑且先分为动作表现、技能逻辑和伤害判定这三块吧。

从流程上看,从开始释放一个技能到效果完全结束大致经历这么几个步骤:
1、发出施放请求
2、验证是否满足使用技能条件
3、返回失败结果或者开始执行技能同时开始动作、特效播放
4、执行该技能需要表现的各项效果
5、如需伤害判定则进行判断并反馈结果

      所以我们需要一种 单技能 => 多种表现的总分结构,解释下为什么是这样的结构:
      首先从玩家角度来说,他们所学习、使用的一定是一个个具体的技能,而我们要实现一些复杂效果的技能,又要努力避免重写技能代码的情况,就需要组合多种效果,所以通常一个技能下面需要挂载多种特定的表现效果。多个效果之间可能还会有前后序影响,于是用序列结构加以管理(这样设计的好处下面会看到)。

      这样我们就形成三个基本概念,其一是作为表象的技能(相当于包装盒),其二是作为实际表现效果的技能效果(各种形状的饼干),其三是效果中的一系列操作(饼干的组成元素)。
      如此一来,对于一些表现比较简单的功能,我们可以在一个技能下面挂一个效果,效果下面挂多个操作来实现。而对于复合效果的技能,我们也只需要增加操作或者想办法挂多个效果来做到。只是顺着这个思路下去我们会遇到一个不同效果衔接的问题,具体比如说某技能要求发出一个导弹,导弹命中目标后产生一个爆炸效果,同时产生一圈弹片飞向爆点周围目标并造成伤害。因为我们除了配置该技能使它发出导弹之外,还需要配置导弹命中后的表现。
      这样的情况有两个特征:1、导弹的命中目标所需的飞行时间是不固定的,即我们没办法通过设定效果延时时间来配置命中后效果  2、到达导弹命中目标后所需要的表现效果是具有多样性的(不是固定的产生某种类型的效果)。

      所以,为了解决这种问题,我们除了对技能配置,还应该对特定的导弹进行配置,在它上面挂载另外的效果。所以上面提到的好处来了,把技能逻辑拆分成一个个效果有效降低了各个效果之间的耦合,同时提高了配置的灵活性。

      我们把技能可能产生的所有具体效果(比如改变属性值、转换仇恨目标等各种特殊操作)都归入到”操作“,一个效果就相当于一个特定的程序功能接口。这样,我们可以按照 ”技能 => 效果 => 操作列表“ 的方式组合出各种各样的表现。一些操作根据表现需要可能需要设置不同的参数,多的话可以考虑作单独配置(如设置眩晕的持续时间、导弹的追踪距离、跳跃的距离等)。这些具体的表现效果,和作为入口的技能一样,最终也会折回到效果流程来完成自己特立独行的操作。

      顺带一提,播放动作时可能会遇到实际效果和动作播放进度的匹配问题,可以参考的解决办法是由动作方面提供额外信息描述需要在什么时间点产生效果,这样程序发现需要播放某个动作时可以做适当延时。


数据的组织:

      结构清晰了安排数据的时候是需要对应一下把需要额外配置的数据汇总到一个个文件就OK,通常是一些经常需要调整的数值会抽出来放到文件中,对于不太变化的(比如重力加速度)就直接程序里定义一个常量。

我们大致需要这么几份文件:
1、技能信息:描述技能名、技能介绍、有效距离、播放动作、技能效果等信息
2、效果信息:描述一个具名效果的执行操作列表、操作的作用目标等
3、各个具体类型的效果信息:对导弹、连锁、状态、吟唱、地点之类的效果针对性进行描述其特征以及产生的效果(返回到第2点)


扩展功能设计:

      上面所做设计貌似已经能够完成多数技能的制作,对于日后发现新类型技能效果,我们也只需要像原来那样增加表现这个效果所需要的代码而已,配置方法上完全一样。
但对于一些复杂的技能(通常也是别具特色的技能),这种配置能力仍是会很快暴露出它的问题的。比如某技能希望在技能目标处于A情况的时候进行一种效果,不处于A情况的时候进行另一种效果解决起来就比较麻烦。

      执行一个技能的过程类似跑一个函数,技能名相当于函数名,技能效果相当于函数体,论灵活肯定是程序最灵活,一旦存在配死的东西,就产生无法实现的功能。而程序的核心在于(在我看来的)控制流,即顺序、分支、循环,为了亲爱的灵活性,我们最好把它暴露给外部文件。

      这样我们在外部文件进行配置的时候,就同时拥有了功能接口(效果)和控制流,就变成了用伪代码来写程序了!

      这是程序员最擅长的事,不过做起来却不爽,”因为技能是策划设计的!“。

      如果我们想要从没完没了的和策划沟通然后来调整技能各个细节的劳役中解脱出来的话,最好把这份差事丢给策划来做(这样他们也要相应的负责一部分的BUG)。但问题是几乎所有的策划都没有程序基础,所以让他们来写伪代码可能不是件容易的事。我的看法是,首先作为策划,假设他们的逻辑能力假设是OK的,同时假定部分策划对编写代码具有一定心理负担,作为程序对此可以做的是:
1、尽量让编写伪代码看起来不像是编写伪代码(提供友好的、容易理解、对应具体游戏逻辑的接口名)  
2、可以考虑用表格的形式组织数据,这样在策划看来只是在填表  
3、简单的功能只需要简单的配置,甚至感觉不到控制流的存在

那么我们如何暴露控制流给外部文件呢?

我们先来把一个个操作视为我们预期的要调用的子函数,然后:
1、首先对于”顺序“逻辑,由于我们是用一个序列来保存技能的各个效果,所以填写文件的时候各个操作的上下关系已经可以表达顺序关系了。
2、对于分支,我们可以通过在配置操作列表的文件中增加”执行条件“,其中内容为各个操作是否执行的需要满足的各项条件。如”条件A,成立 | 条件B,不成立 | 条件C, 成立“,为此我们需要定义一系列条件(如”魔法值大于,参数“也是一种操作),而且各个操作需要反馈执行结果。程序在顺序执行一条条操作的时候,我们同时给出一个序列结构报错每个操作执行的结果。这样我们在判断一个操作条件是否满足的时候就可以依次比较条件列表和结果列表,判断是否所有条件都满足。
3、而对于循环来说,表格结构描述起来不是很方便,递归(直接或者间接)实现起来会更加直观方便一点,而且有了分支做基础,我们也能方便的定义出递归的终止条件。实现递归的方法,比如说,我们可以定义一个操作叫做”执行效果“,操作参数给出和当前效果相同的效果名,然后递归就开始了...

      控制流的加入一定会使得技能的多变性大大增强,足以应付大多数情况。但是,或许,偶尔可能还是有点不够的,设计系统的时候我们要极力防止变态需求的出现,以至于后来疲于应付,最好的办法就是一开始就把我们难以想到的特殊性考虑在内...

      也许这时候已经很难想到这种流程还有什么典型技能是不好做的,但是作为程序员会知道,只有程序流而没有变量的编程是怎样一回事(啊,多么痛的领悟...=。=)。但是安全起见,为了防止策划滥用或者过度依赖,提供给策划用的变量最好是程序给出的,有限的几个。而程序坚守阵地时只要牢牢盯着这几个变量的生命周期就可以。
      这一点上,我们可以参考寄存器的配置...给出几个类型的变量,如设置用来保存数值的P1-P3,保存角色对象的T1-T3,和保存其它稀奇古怪东西的一些变量(如果有这需要的话),如果是弱类型语言来实现,可以直接做几个通用变量了事。另外还要定义一组用来保存变量的操作(如mov指令一样)。
      一个技能相当于产生一个调用栈(本来执行技能也就相当于执行一个函数,很自然的想法),变量只在该层栈以上活动,一旦该层栈销毁了变量亦随之销毁。有个流程在就可以了,不需要一开始就为策划做太多的预留变量,可以后期根据需求逐渐加入。如果有全局变量的需求,也可以考虑加入进来,不过要提醒策划慎用吧(别说策划了,程序也要慎用)。

      到这里,配技能已经彻底沦为写伪代码了(策划友好的版本)...

      技能的配置中还有两个要点。

      其一是许多游戏都有技能等级的概念,不同技能等级会带来不同的数值影响。按照上面的设计,我们在操作名称后面留出了一项参数,通过这个参数来实现相同操作不同结果的效果。加入技能等级时,我们需要描述技能等级和具体参数值之间的关系,这种关系通常是用表达式来做到的。为此我们还要在程序中实现一个解析表达式的模块,程序执行时通过向该模块传递技能等级获得其具体值(这个功能静态语言做起来略蛋疼,但在一些解释型语言中做起来会十分容易,这大概是许多游戏会把大量逻辑丢给脚本去写的原因之一)。

      第二个要点是,技能的作用目标的问题这个分成几种情况来说:
      一种情况是地点、方向性的技能,这类技能通常是根据鼠标提供的位置施放的,施放这种技能时并不能从施法请求中得到目标对象。这种情况应该从请求中获取位置信息并沿着调用栈传递上去。
      另一种情况是现在比较新的所谓”战斗2.0(非锁定目标攻击)“的目标判断方式,即不管是否选择了目标都可以进行攻击,击中了哪些目标是根据实时位置关系计算得到的。这种情况多见于一些物理引擎应用比较深入的游戏,逻辑上根据打击部位和位置关系等信息确定作用对象和结果。但这种情况另说吧,展开来又是比较复杂的内容,而且自己接触的也十分有限。
      还有一种则是比较传统的锁定目标攻击,即许多技能需要先选中一个对象才能使用。这种情况下可以把作用对象和第一种位置的情况作类似的处理。

      但通常像1、3这种简单的目标机制并不能很好满足实际需求,比如技能希望攻击选中目标及其周围一定范围的对象等。依然需要一个获取游戏中目标的机制来完成。
这个可以借鉴Pipe的思想,顾名思义在一组Pipe执行过程中,上一个Pipe产生的结果给到下一个Pipe,最后一个Pipe输出的结果即为最终结果。我们先实现许多个不同规则的Pipe(如”周围,范围参数“,”队员“,”处于某状态“等),然后通过组合这些Pipe来实现较为复杂的目标筛选。


具体实现:

      至此已经基本把我们会用到的数据结构弄清了,围绕这个结构来编程,我们只需要把数据和逻辑对应一下,实现起来是非常容易的事。我们可以把技能的入口逻辑封装到一个管理器上,取名如SkillMgr,然后把这个管理器挂到需要用到的角色对象上,通过角色对象身上的一个转调函数来进入技能流程。

然后封装数据结构如下:
技能表[技能名] => 技能参数信息
效果表[效果名] => 操作(条件作用目标、操作名、参数)列表
操作表[操作名] => 操作的具体实现代码

主要逻辑部分在于实现各个操作的逻辑,以后扩展主要也是扩展这个部分。


执行过程:

      技能请求中得到技能名,查找得到技能信息 => 判断技能是否可执行 => 播放动作,从效果表查找 技能.效果名,得到效果 => 依次执行操作列表中操作,判断是否满足操作给出条件 => 根据操作名查找操作表得到具体执行操作的代码 => 执行相应操作

技能的另一种实现思路:

      抛弃任何格式的束缚,直接拥抱代码,最大限度的争取配置技能的灵活性。
      总体上还是延续上面提到的一部分思路——我们已经把技能的具体表现抽象成一个效果(操作列表),并且可知技能的逻辑重心也在这个地方。于是我们可以把代表效果的部分的配置直接用代码文件去实现,一个效果对应一个文件。如果是静态语言来实现的话,这通常要通过提供接口编译伪代码的形式来完成,可以在启动游戏程序的时候把所有代码文本解析一遍以执行效率更高的内部指令来保存,根据文件名来调用。但如果用像Lua这样的脚本语言,做起来就异常简单,可以直接把游戏功能接口封装成Lua函数,并让通过适当培训策划直接参与技能逻辑编写。

      我们假定每个技能都有自己独特的逻辑,所以一个单独的控制流是必不可少的。但是如果策划偷懒实在是有很多技能相似,也可以很容易做到封装一些效果模板函数来快速实现效果。

      这样一来,流程上的固定操作还是由程序来开发,但着重点仅在于保证效率和稳定性上。要执行效果的地方采用Call(效果ID)来调用指定文本对应的指令。脚本语言本来就比较容易上手,而如果只是使用其中最为简单的逻辑控制,那学习成本就更低了。所以可以预见的是,策划不需要花很长时间来提高代码能力,但已经可以独立完成十分复杂的技能编写(个别较难实现的大概得由程序来辅助完成,但对于实现一些很NB的技能效果来说,程序这点付出算得了什么呢~)。

      最后我接触下来觉得许多策划都有很好的逻辑能力,这方面未必不如程序,而快速掌握简单的脚本编程也不是一个多么难的事情。另一方面来看,策划掌握一些程序技巧后一定程度上也能拓宽自己的设计思路,时间长了会用的越来越得心应手(已经许多国外公司都要招会点脚本的策划啦!)。


关于调试:

      我的想法是,既然我们已经把开发技能逻辑的任务丢给了策划(程序只在缺少必要组件的时候添加一下新组件),那么不妨(嘿嘿...)好人做到底,把调试的工作也交给策划来完成(可怜的策划...)。因为否则的话,当策划配好技能来向反馈一个异常状况的时候,程序不得不先理解一遍策划配置的技能的逻辑(平时不需要关心策划进度),然后才能开始诊断问题。反复沟通的过程中经常会有相互打断/阻碍相互工作的情况发生,久而久之其实策划未必不愿意自己解决。但毕竟程序过程对于策划来说是一个黑箱子,不能了解各个步骤的运行状况仅通过观察配置有时候难以发现问题(何况程序提供的组件也不能完全确信是无BUG的)。

      为了让策划参与调试,有必要在一些关键的地方加上调试信息输出,并不断完善直到策划可以自行诊断问题为止(如果是用脚本的方法就更简单啦)。为了不妨碍他人开发,我们还需要做一个输出控制开关,把它交给策划,非必要时不打开。


开发中较常用的机制:

      各种设计模式中比较适用于技能系统的是其中的观察者模式。

      技能的表现可以简单的看作是对角色对象的一系列操作,和执行纯粹的逻辑不同,这些操作有的需要计时,所以不总是连续执行的。
      在这执行的过程中角色可能发生种种意外(主要是ARPG比较容易出现),常见的事件比如角色死亡、切换地图、打断技能等,如果此时还在执行某些效果,我们需要将其正确的处理掉。

      一种直观的办法是将处理的操作封装到一个角色对象上的一个函数中,通过调用这个函数来完成清理,各个模块需要清理的就把清理相关代码加到这个函数中来。但是这种做法并不理想,首先一堆需要清理的功能放到一个函数中肯定不好看,另外个人觉得写起来别扭也容易漏掉,对于多个事件我们还得封装到不同的函数里面去,坏处一堆堆谁试谁知道。

      而通过观察者模式来进行管理,把角色对象定义成一个Subject,而具体的效果流程定义为Observer,这样一些持续性效果在开始之前,把自己加入到Subject上的某个事件的观察者列表中,角色对象由于状态改变而发出事件通知的时候,只需执行Notity(event),即可广播给所有关心这个事件的Observer对象(调用该对象上的OnEvent),这样每个效果流程内部只需要实现OnEvent并且添加处理代码就完成了整个过程,要比前者简洁漂亮不少。

没有更多推荐了,返回首页