本文很长,约37000字···如手机上遇卡顿,请关注微信公众号“千猴马的游戏设计之道”后在PC上观看。

代码地址

https://github.com/kierstone/Buff-In-TopDownShooter

Unity版本:2020.3.15f2c1。项目中没有使用第三方插件,美术资源均来自asset store中免费的资源。

什么是顶视角射击游戏

顶视角射击游戏,顾名思义,就是在平面俯视或者顶视角下,操作角色发射子弹与敌人对抗,同时躲避敌人子弹来避免伤害的游戏类型。在Steam等游戏平台上有这么一个标签叫Top Down Shooter,即这个顶视角射击游戏的分类。在雅达利开始的30年多年的电子游戏历史上,顶视角射击一直是一个不冷不热的类型,但却也佳作层出不穷,从早年FC的《前线》、《坦克大战》《古巴英雄》等,到最近几年的《元气骑士》《弓箭传说》,以及今年新推出的《上行战场》等,都是典型的顶视角射击游戏。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图1

(弓箭传说是标准的顶视角射击游戏)

从游戏开发和设计的角度出发,顶视角射击游戏属于ARPG的一种,也就是“即时回合制游戏”范围内的,因此开发一款顶视角射击游戏,他的开发思维(而非具体内容的设计思维)与《Diablo》(Diablolike ARPG)、《英雄联盟》(Moba类)、《魔兽世界》(标准MMORPG类)是有异曲同工之妙的。

顶视角射击游戏的扩展需求

当我们去做一个顶视角射击游戏的时候,具体会有哪些内容是充满可扩展性的呢?首先我们定义一下可扩展性——即游戏的元素可以延伸出无穷多的玩法内容、或者说是组合、或者说是创意等,也就是在某一细节上会不断展开的。比如《英雄联盟》中的英雄、英雄的技能,都是“极其需要可扩展性的元素”,从设计的角度来说,我们会花很大的精力去设计他们,把它们变得尽量多元化;从实现的角度来说,这里往往是“定时炸弹”,策划很可能随时会提过来一个需求,造成代码的重构等工作——因此,我们才需要去构建一个极可扩展的游戏系统框架,在策划“肆无忌惮”的产生出好的想法的时候,能尽量不伤筋动骨的去改动游戏来实现他们。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图2

(ARPG的游戏技能可以设计的非常“复杂”,我们所熟知的《英雄联盟》的技能就非常“复杂”,且数量繁多,如果没有一套靠谱的框架,就一个伤害流程几万行代码都不夸张)

本文中将会介绍一个12年前开始设计,并且实战之今已经11年,投入过大小近百个游戏使用的一套机制(我为它命名为“Buff机制”,因为他的诞生最早是为了解决Buff的设计)。这套机制完美的解决了游戏中的可扩展性问题,并且重新抽象、重新理解了游戏开发中的很多元素,典型的如技能、buff等元素。这套机制做的游戏系统具有极高可扩展性的同时,还为游戏设计者(也就是策划),提供了准确的游戏设计方式——我们曾一度将“暴雪更新文档式文字”当做游戏设计,比如“火球术:发出一个火球,对目标造成30点伤害,并使目标进入灼热状态,持续30秒,每3秒受到3点火焰伤害。”这看起来是一个“完整的设计”,但实际上他什么都没有设计。接着我们先展开说一下这套机制,然后再回过头来探讨这个问题。

这个顶视角游戏的demo是使用unity开发的,因此我们不得不先探讨一下unity以及游戏开发,以此作为基石,来谈谈这套机制的运用思路。

Unity设计模式的思路

Unity的模式,也就是GameObject下绑定Component(Monobehavior)的这套方法,从表面来看与ECS是有异曲同工之妙的——如果你把GameObject类比为一个Entity,Component类比为ECS的Component(Data)的话,它们看起来似乎是一样的。但这相似,仅仅是因为ECS和Unity都是基于数据驱动的设计模式而来的,实际上他们有着本质的区别——ECS的概念里,一个Entity什么都不是,只是他恰好拥有一些什么;而Unity的概念里,一个GameObject代表了他是什么,而他具体是什么,取决于他有什么Component。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图3

(Unity已经是世界上最流行的游戏引擎之一了)

Unity的这套思路,其实更接近于我们现实中的理解,这个理解是基于我们从小受到的教育以及所产生出来的思维的。从一定程度上来说它是更舒适的——因为不同的GameObject他是什么,这个确定了以后也比较容易捋思路,而面向对象的设计模式,本身核心之一也是要分清楚一个对象是什么——“是什么”是一个核心问题——比如我要一个坦克GameObject,那他就应该有炮、有履带、有驾驶舱等等,炮负责开火、履带负责移动、驾驶舱负责驾驶员传递命令给坦克GameObject。

这样的设计思路,在游戏开发中是存在一定弊病的——假如你只从一个GameObject的角度出发来看,他的抽象是完美的,但是因为游戏中,不同的GameObject之间是有“交互”的,最简单的就比如移动需要依赖于地图数据,角色下肯定得有个移动Component来管理角色的移动,那么地图数据应不应该是在这个Component中,或者直白的说是角色下的一个元素呢?显然不是的,这就是别扭之处,当然unity至今也多了很多补丁,来解决这个问题,比如GameManager的设计。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图4

(MonoBehavior确实有不少不好的地方,但是我们还是基于这个Unity自己推荐的脚本类展开吧)

当然,关于unity的一些设计思想对于开发的利弊,并不是本文讨论的核心。既然这个demo是使用unity开发的,当然我们要尽可能地遵守unity的“规矩”,从思路上出发,就得去接受unity的规则。

Update()与FixedUpdate()

有很多新手或者经验不足的人并不能理解这两者的区别,但这两个MonoBehavior下的回调点是截然不同的概念。首先我们必须理解渲染世界和逻辑世界的不同,渲染世界和我们人类生活的世界世界是并行的,他的单位时间也是秒(或者毫秒等等),与现实时间是完全一致的;逻辑世界他的时间单位是帧,1帧是逻辑世界最小的时间单位,而逻辑世界与我们人类生活的现实世界的时间之间有个换算单位,就像$和¥之间也有个汇率一样道理,这个换算就是1帧多少秒,在Unity中,即Time.fixedDeltaTime,默认为0.02秒。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图5

(游戏的逻辑世界,才是游戏真正的世界,它是没有画面的,有点类似文字Mud的感觉,在这个世界里,时间单位是“帧”)

Update()是渲染世界的推进器,也就是当渲染世界的“工人”完成一次工作之后就会Update一下,去做下一次工作了。因此跟渲染相关的东西,我们应该写在Update里面,比如要让一个球旋转,这个球只是看起来在转,对游戏来说就是一个动画,没什么实际意义,那么就应该写在Update下,并且依赖于Time.deltaTime来做运算。

FixedUpdate()则是逻辑帧,Unity会尽可能保证现实生活中平均时间(默认0.02秒)执行一次,当然,这根设备也有很大关系的,破机器肯定跑不了这么快。每一次执行FixedUpdate,就是逻辑世界的时间推进一个单位。所以如果一些公式和现实有关的时候,比如你的移动速度概念里是米/秒(我的demo里故意这么做)而不是米/帧的话,那你应该坚信,不管现实中1帧花了多少秒,Time.fixedDeltaTime就是现实中经过的时间(这是你预设的,不会变化)。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图6

(给予逻辑世界的运转,我们可以用不同的方式将他们渲染出来,表现给玩家看,这便是渲染的世界,渲染做的事情本质是每一帧运算屏幕上每个像素点的颜色,因此是一件“非常耗非常不稳定”的事情,所以他的“帧率”是不定的,同时他是现实世界与游戏世界的接口)

这就是逻辑与渲染2个世界的不同,为什么需要分2个Update,其实道理很简单,如果渲染的update也走逻辑的,因为逻辑的并不能保证每一次运行间隔,所以比如动画、比如位移(纯动画的位移)之类的都会出现速度不正确的情况,使观众不适应。因此,我们要严格遵守一个规则——逻辑层的用FixedUpdate()和Time.fixedDeltaTime,而渲染层的用Update()和Time.deltaTime。

顶视角射击游戏或者说ARPG的战斗系统元素

接着就进入正题了,一个顶视角射击游戏的战斗系统,它是由哪些元素组合成的?这些元素又是如何工作的?

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图7

一个最基础的顶视角射击游戏所有的元素以及它们之间的关系就如上图所示——主要元素为地图信息、角色、子弹、AoE,在角色下有技能、buff等重要的构成元素。在进行具体的是深入分析之前,我们先得明确一个概念:

Model、Obj与Info

这是一个非常容易被混淆和忽略的概念——即游戏中的元素存在Model与Obj的区别,具体带入到Unity(C#)中去看。所谓Model,即一些struct,他们是静态数据,通常来自策划设计的数据;而Obj,在Unity的框架下,可以直白的理解为GameObject,比如BulletObj,就是子弹的GameObject(的Prefab)。这两者的关系是——Model用来初始化GameObject的一些数据,一个GameObject允许从n种不同的的Model来初始数据,也可能完全不需要Model来初始化,举一个具体的例子,比如游戏中的角色——这是最常见的“多类型Model创建的Obj”,通常游戏中都会有敌人、npc、战斗宠物、玩家等角色,他们的数据源肯定是不一样的,比如玩家的数据源多来自于存盘数据,尤其是网络游戏,只有初始化的时候会根据一些玩家的选择比如性别、名称、职业、初始兵器、门派等等等等(各游戏元素不同)产生出来(no model);而npc因为没有什么特别的战斗力,甚至不能参加战斗,所以会从NpcModel初始化而来;怪物核心在于战斗数据,所以他会是一个和npc不一样的model比如是MobModel;战斗宠物可能也有PetModel,但是最终,不管他们来自于什么Model,都会变成一个CharacterObj(一个GameObject)运行在有的世界里,如果因此把“玩家角色”“npc”“怪物”和“宠物”分成4个不同的类(哪怕派生自角色类)这个抽象都是错误的,因为角色的Object运作在游戏逻辑世界里,是等价的。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图8

所以Model和Obj是根本不同的东西,区分好哪些数据是Model的,哪些数据是Obj的,是一个极其重要的游戏设计工作,因为这会定义整个游戏的元素。比如技能等级,通常来说是一个SkillObj的数据,而非SkillModel的数据,如果每一级技能是一个SkillModel,那说明这个游戏中不存在“技能升级系统”只有“技能替换系统”,这两者从玩法体验上来说是完全不同的,技能升级是对技能的强化,玩家在使用新等级的技能的时候对于技能的效果是完全熟知的,只是数值等细节有了进步;而替换一个技能,应该做到的效果是玩家对于技能新的等级是完全无法得知的,它与之前的等级应该的技能应该是截然不同的效果,这才需要一个等级是一个Model,不然就是一个抽象错误。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图9

(角色的属性,就是最典型的Obj数据,而非Model数据,因为这是玩家自己玩出来的结果,而不是策划预设好的)

不同于model和obj,游戏业务中往往还会有一层Info,Info更像是一种逻辑数据,在Unity中它以class形式存在。Info是一些逻辑业务所依赖的数据,比如我们创建一个buff,就需要一个AddBuffInfo,来告诉系统我要创建的是哪个buff,多少层等等,哪个buff就是BuffModel中的数据,也就是一个Buff的模板;而“多少层”,其实是一个从BuffModel变成BuffObj才有的数据,但是初始化的时候应该给多少层呢?这是添加buff的系统最关心的问题,因此我们才有了AddBuffInfo作为“中间件”来完成这个过程。

当了解完Model、Obj和Info各自的特点之后,我们接下来就来进一步看,一个顶视角射击游戏,要如何去设计这些元素了。

伤害信息(DamageInfo)

之所以把这个放在第一位说,是因为这通常是一个完全会被忽略的信息,并且当你忽略了这个信息的时候,你的游戏整个伤害流程都是别扭的。“伤害不就是一个数字吗?”从表面现象来看,似乎是这样,那为什么我们需要加入这么一个多余的Info呢?因为它在实际的游戏逻辑运作中,起到了非常重要的承上启下的作用:

首先,DamageInfo是贯穿整个伤害处理流程的,一个伤害处理流程通常是这样的:

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图10

从流程可以看到,在这个流程的过程中,我们通过buff的不同触发点的事件执行各种效果的同时,不断改写这个DamageInfo,最终产生出一个最后的伤害信息用于做最后的处理,这里需要一提的是——不是说跳数字就是伤害处理的流程,伤害处理流程可不管跳数字,跳数字是UI的事情,UI需要怎么跳数字、跳什么数字,是UI规则,buff可以调用比如PopNumber之类的接口来跳数字,但是伤害流程是不干这个的,除非游戏设定了处理伤害的流程里,一定会按照一个规则跳数字,这也是允许的,但这明显是伤害流程的side-effect。

除了正常逻辑之外,任何伤害都应该走DamageInfo,这也是有原因的,正如上面流程所示,我们现在来假象一个情况——攻击者A有一个被动技能,是攻击的时候额外附带3下伤害,因此一共4个伤害,比如分别是5、10、15、20点伤害;而受击者D,身上有一个被动技能:受到>0点的伤害时,下一次受到伤害为0。这样一个设计之下,就会出现一个有意思的事情,假如我们不创建DamageInfo并且丢进一个管理器里面,那么在走到攻击者A所有onHit的buff的时候,就会直接产生下一次伤害,这时候流程还没走到D的Buff.beHurt,也就是说,D的被动效果没有发生,下一个伤害已经出来了,最终,在D受到最后一下20点伤害之后,第三下的15点化为0了,然后受到了第二下的10点,之后第一下的5点被划为0了,这肯定不是应有的效果,但是程序执行的循环就是这么走的。于是我们需要让“额外的3次伤害”产生3个DamageInfo,丢在一个管理器里,管理器while do来挨个按顺序执行所有的DamageInfo,由此效果就正确了。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图11

(游戏中每一次“跳数字”都不是看起来这么简单的,每一个伤害数字都需要经历一次完整的伤害流程,我们往往临时记录一些数字来特殊处理,核心原因是我们缺少了DamageInfo这个结构)

从设计角度上来说,DamageInfo还解决了一个经典的争议——暴击了必定命中,还是命中了才会暴击——因为在DamageInfo中记录了是否会命中,和是否会暴击,这两个是等价并行的,没有先后问题,直到最后策划再来做决策,是命中了才暴击,还是暴击必定命中,我们可以看一下DamageInfo的具体属性:

  • attacker:攻击者的GameObject,也就是发起这次伤害信息的攻击者,这个攻击者当然可以是空的,因为并不是所有的伤害都是人为造成的,比如说我们走到关卡中某个地刺上的时候,攻击者并不是一个角色,这时候Attacker应该是空。
  • defender:受到伤害的角色的GameObject,这个角色必须是存在的,如果是空,那么这条伤害信息就没有任何意义了。同样的,根据游戏的设定,大多游戏中Defender如果已经挂了,也一样会把这条数据销毁了(因为通常游戏都不具有鞭尸的功能,但这并不代表你想做的时候不能做)。
  • tags:字符串数组,伤害类型的tag,这在buff的脚本逻辑中会是一个非常重要的元素。这里是策划的游戏设计中必须定义清楚的东西,它是用来描述一个伤害的类型的,这个类型并不是说比如“冰冻伤害”、“火焰伤害”之类的不同的属性伤害的,而是用来描述“伤害源”类似的东西的,比如是“直接伤害”、“间歇伤害”、“反弹伤害”等等,这些定义都是因游戏而已的,并不是一个固定的范式。比如当我们要做一个buff,这个buff的效果是“反弹受到的伤害给攻击者”,如果攻防双方(设为A和B两个角色)身上都有这个buff,就会发生:A对B产生伤害,之后B的buff反弹伤害给A形成B对A造成伤害,这时候A的Buff又生效了,反弹给B,这样会发生一个“短路”现象,直到A或者B有一方作为defender的时候被击败了,循环才会终止。所以此时,我们需要根据DamageInfo的tag来判断,这个伤害对于这个buff来说该不该发生反弹,由此避免类似的死循环问题。而除此之外,我们还会遇到很多类似“受到直接伤害的时候伤害降低最多50点”之类的效果,都是需要通过tag来判断这次伤害的类型的。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图12

(一个游戏中有多种元素的伤害,并且一次伤害有多种元素伤害同时产生,这也是比较常见的设计)

  • damage:一个伤害数据结构,这是根据游戏不同来设计的,比如游戏中有金木水火土外加物理攻击,那么他就应该是有6个伤害数字组成的。我们通常忽略的一种情况是——策划会设计一些针对游戏中“元素属性”类似的东西有效的效果,比如“受到的火焰伤害减半”,如果此时受到的攻击是一个“暗影烈焰”,即暗属性伤害200点+火属性伤害200点,那么就得从这里面去找到暗影属性伤害减去一半;再比如“短时间内抑制所有受到的子弹伤害”,那么这个伤害的数据里面有一条肯定是子弹伤害,将子弹伤害设定为0,就抑制了。
  • damageDegree:伤害的角度,也就是伤害打向defender的入射角度,通常这个角度来源是取子子弹的飞行方向或者aoe的中心点指向角色的位置的。这个角度配合角色当前的面向角度,就可以算出角色什么方向受到了伤害,假如需要做类似“背刺”的效果,那就得用上这个了。
  • criticalRate:本次攻击的最终暴击率,大多游戏还是有暴击设计的,如果没有,可以砍掉这个数据。当一次伤害信息经历了所有流程之后,将最后的数值传递给策划编写的公式脚本,由策划来处理是否暴击了,以及暴击造成多少伤害。
  • hitRate:和暴击率类似的概念,但是他俩在逻辑流程中实际上并无直接关系,即当有一个效果是“下一次攻击必定暴击”,这时候的DamageInfo哪怕hitRate是<=0的数字,也不妨碍criticalRate被设置为1或者更高的数字(假如策划认为1代表100%,这完全是由设计数值公式的策划来定义的),这两者并无依赖关系,不是说不命中就一定不能暴击,最后不命中能不能暴击,暴击了是不是一定命中,还是看策划写的公式脚本如何认为。
  • addBuffs:这是一个“隐藏属性”,所以在上述结构中并没有标明,但是非常有必要说明一下。因为在整个伤害的流程中,我们可能因为一些角色身上的buff效果,他会需要添加新的buff效果,而这个新的buff效果并不想马上添加给角色(通常都是如此),比如说攻击者有一个buffA,他的效果是“攻击后目标受到割裂影响”,也就是在目标身上上一个buffB;还有一个优先级更低(更晚执行)的buffC,是“对割裂的目标造成的伤害提高200%”,策划设计的时候的想法是,这次攻击造成割裂,下次才是3倍伤害,但是因为执行顺序,产生了本次就直接上了割裂并且3倍伤害,我们不能让策划因为这个去调整buff的优先级,因为这会导致逻辑混乱,优先级本身是期望某些必然又先后关系的buff之间用的,这种其实对于策划设计的思路来说,是“非必然的先后顺序”,因此理论上来说,策划完全可以用buff的priority来解决这个问题,但这属于设计burden,因此我们通过在DamageInfo中添加这么一个List,把在流程中的这些AddBuffInfo储存在这里,在完成流程后执行,而策划要做的,则是在BuffOnHit、BuffBeHurt、BuffOnKill、BuffBeKilled中产生的buff(这些概念都会在下文中详细说明),通过
  1. public void AddBuffToCha(AddBuffInfo buffInfo)

来添加给这个damageInfo

值得一提的是,这是一个目前demo中用到的damageInfo的结构,这并不代表每一个游戏的damageInfo都是长这样的,具体还是得由设计游戏内容的策划,来定义一次攻击到底是个什么样的事情,具体的事情——我们通常会认为“一次攻击”还不好理解吗?但实际上确实不好理解,因为“一次攻击”只是一个概括,什么都是也什么都不是,在我们实际开发工作中,定义好DamageInfo和DamageManager(也就是伤害流程),才是完整的游戏伤害系统设计。

角色(Character)

遵守Unity的调性,我把CharacterObj这个GameObject做成Prefab,然后在需要创建角色的时候去Instantiate(…)把它拿出来。CharacterObj是这样一个东西:

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图13

我们可以看到一个CharacterObj是一个“空”的GameObject,他下面有2个子GameObject,分别是ViewContainer和PieChart:

  • ViewContainer也是一个空的GameObject,它的作用是美术做的图形的父级节点,也就是美术做好的Prefab,我把他们Instantiate之后丢在这个ViewContainer下面,就作为角色的模型来用了。不同的美术做法不同,但是只要他们最后保证给我做一个GameObject(目前assetstore免费的资源都是做成GameObject的)就行了。ViewContainer则负责依据逻辑需要进行缩放,旋转(但不是逻辑层旋转,只是渲染层旋转)一个美术做好的模型。
  • PieChart则是角色脚下的血条HUD。尽管从逻辑抽象的角度来说,在这里放这么一个东西是不合适的,因为既然是UI,就应该额外单独创建,而我只是想吃到坐标等一系列优势才放在这里了;但是Unity的规则又是建议这么做的,因为UI是整个GameObject的一部分。

整个CharacterObj看起来是“空”的,因为没有图像。通常我们可能会对Unity的设计有一个误会,认为GameObject就是存在于游戏中的一个模型、或者一个被渲染出来的东西、或者说是一个元素,反正是因为有了这么一个元素,然后给他绑了一些关于这个东西的属性——比如一个角色,他应该是一个小人,然后给这个小人绑上属性、buff等等的东西。但实际上不是这么回事儿,因为GameObject,在Unity的逻辑下,他是Scene下的一个数据,Unity的IDE,把这个数据渲染到了他的编辑器上,这并不代表这个数据就是“有形的”,所以空的GameObject丢在Scene,如果有Render控件,他才会被渲染,这个逻辑其实是对的、是符合数据驱动的、也是符合视觉依赖于逻辑——这条游戏开发铁律的。

CharacterObj之所以是一个角色,是因为这个GameObject下的Component,它包含了:

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图14

值得注意的是,这里我给他添加了一个Tag,而他的组件主要包括:

UnitBindManager

这是一个“绑点”管理器,“绑点”其实就是我们在角色的模型上设置了一些点,这个跟模型和动画制作方式有关,总之我们在“类似某个骨骼”的位置,加上一个UnitBindPoint的组件,他就会被UnitBindManager管理到。UnitBindManager要做的是,当我们需要角色在身上某个绑点播放特效的时候找到绑点去添加这个特效。

UnitMove

单位移动控件,这个控件仅仅负责角色的坐标变化,也就是最终根据移动力来处理transform.poisition的赋值,其他一律不管。但是在这里,有一个“特殊处理”,因为他访问了地图数据,我把“寻路”也放在这个里面去做了,当然从实现上来说,这里也只是访问了地图数据,连地址都没保存,所以只能算是弱依赖。

在这个demo的UnitMove里面,我做的是根据传递过来的移动力(Vector3)来进行直接移动,如果是Diablo类的ARPG或者《英雄联盟》这样的Moba,无非就是传过来一个坐标和移动速度,然后决定能移动到那儿,这也就是顶视角射击与他们的为数不多的“差异”了。

UnitAnim

是一个动画管理器,负责角色动画切换工作的,依赖于子GameObject下的Animator。因为这是一个“即时回合制游戏”,所以角色会有一个“当前使用什么动作”的问题,它是需要一个优先级管理的,因为在这种游戏中,角色攻击的同时是允许挨打的,那究竟应该做攻击动作还是受伤动作呢?就是由UnitAnim来管理的。Animator尽管也有状态的概念,但是存在一个问题——在回合制游戏里,同一个状态的动作可能是几种动作根据算法抽取的,比如受伤动作,可能就是受伤数字大于5%hp上限就用后仰,否则用低头;当然“随机选一个”也是一种规则……但是他们都是受伤状态,因此我们需要一个管理器去管理当前应该做什么动作,他实际上是和Animator同级的东西,但是因为Animator是一个美术们已经完全接受了的东西,所以作为和美术合作的接口,我们还是得依赖于Animator,尤其是我这样没什么收入全靠免费美术资源做点东西的独立游戏制作人,就更只能“被美术的做法牵着鼻子走”了。所以在策划+程序与美术的“交流”过程中,我们需要一个“中间件”,这就是UnitAnim了。

UnitRotate

他只负责接受GameObject.transform的Rotate请求,并且根据条件来执行。CharacterObj的Rotate和角色动画的Rotate不是一回事儿,这个Rotate不仅影响渲染(其实本不该如此,但Unity提供了“便利”),最重要的是——他是一个逻辑数据,角色是否会被背刺,应当取决于这个。

ChaState

这是整个CharacterObj的核心Component,如果一个GameObject是角色,就必须有这个,或者反过来用ECS(entity-component-system)的逻辑来说——因为有这个,所以才可以被认为是一个CharacterObj。

在ChaState里面记录了所有的角色属性相关的数据,并且做处理,其中包括角色的数值属性(比如攻击力等)、控制状态属性(比如是否可以移动等)、以及buff等。因此ChaState还有一个“side-effect”就是大半个buff管理器,负责角色身上的buff的增删改查。

ChaState还是一个命令中枢,也就是对一个角色的所有命令都是发布给ChaState,经由ChaState消化之后发布给“下家”(如UnitMove等)进行具体执行的。这个“命令”并不是直接的Input.KeyDown之类的东西,而是比如“向哪儿移动”、“旋转多少度”之类的,Input是由专门负责角色控制的Component来转换成命令发布给ChaState的,而buff、timeline等,也会给ChaState发布一些命令,比如“做某个动作”。

在ChaState中值得强调的是一点:诸如角色移动速度(属性转化为实际的移动速度),添加buff等,都应该是暴露给策划的一个脚本(我的demo里就偷个懒了,反正C#对unity本身也就是脚本)。比如AddBuff,本当是抛给脚本一个接口

  1. --根据角色状态给角色添加指定buff的--在这里chaState就是C#的ref ChaState chaState
  2. --addBuffInfo就是C$AddBuffInfo addBuffInfo
  3. --策划写逻辑来确定最后chaState.buffs的值,所以chaState得是ref
  4. --而比如叠加规则、互斥规则等等,都在这个接口里由策划搞定了
  5. --C#端根据新老值得变化,得出哪些buff应该执行onOccur、那些执行onRemoved
  6. function AddBuff(chaState, addBuffInfo)

ChaPie

这仅仅只是一个让角色生命值和UI(脚下血条)同步的东西,依赖于ChaState。

PlayerController/SimpleAI

这些是动态添加的“控制器”,前者添加给玩家角色,由此玩家的输入转化为命令,传递给角色。并不是只有一个角色能添加PlayerController——假想在星际争霸等游戏中,选中的一个编队的角色都会被添加PlayerController。同样的如果有一个技能是分身,然后玩家可以同时控制主题分身同步行动,就是在2个角色身上加PlayerController。

而SimpleAI则是一个AI,通过AI每一帧给ChaState发布命令,来操作角色。之所以是SimpleAI是因为这个demo没打算好好做怪物的AI,不然这里应该是一个严肃的AIComponent。

UnitRemover

顾名思义,是在指定时间之后移除掉所在的GameObject(Destroy(this.gameObject))。因为游戏设定了敌人被击败,5秒后消失;玩家被击败则不会消失,所以得根据实际情概况去动态添加给CharacterObj。

时间轴(Timeline)

此Timeline并非Unity的Timeline,但是概念是相似的,因为Timeline这个概念,早在2008年Unity诞生之前就活跃于游戏开发领域了。Timeline是一个时间轴,在这个时间轴上由若干个节点,每个节点都有一个时间点,即从timeline开始运作后多久;然后是其执行的事件——时间点和事件形成的数据组成了数组就是Timeline的基本逻辑。通过Timeline,我们可以“预约”控制一些事件的发生,这和Unity的Timeline控制动画的概念是一样的,只是我们控制的不是动画,而是“事件”——即类似“让角色做个动作”、“产生一个AoE”等等等等事件。

因为一个顶视角射击游戏,或者说绝大多数的ARPG游戏,其本质都是即时回合制游戏,所以游戏中的角色单位的运行,是以回合为单位的,而一个回合在游戏开发逻辑里,就是一个Timeline。

Timeline的数据结构

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图15

TimelineNode

这是Timeline的每一个节点的信息,它主要包含3个内容:

  • timeElapsed:发生在timeline运行之后多久。
  • event:即一个事件脚本函数。
  • eventParam:是这个脚本需要传递的参数,这个脚本是:
  1. public delegate void TimelineEvent(TimelineObj timeline, params object[] args);

这里的参数TimelineObj即执行这个事件的Timeline,而args是动态的参数,即调用这个脚本时候传递给这个脚本的参数,也就是eventParam。

TimelineModel

这是策划填表的Timeline数据,当然它的来源可以不仅仅是填表(包括使用编辑器之类的生成,其本质也是填表),基于TimelineModel创建游戏运行中的TimelineObj。他主要包含了:

  • nodes:这个timeline所有的节点事件。
  • duration:整个timeline的生命周期,timeline总需要一个结束释放掉的时间,他未必是最后一个节点所在的位置,或者最后一个节点的事情做完,比如最后一个节点是播放一个动画,可能动画开始播放0.3秒就可以结束掉timeline了,这完全取决于策划和美术的想法,因此结构上就得给这么一个float去定义。
  • chargePoint:这是顶视角射击游戏的特色——他就会有一些需要蓄力的技能,蓄力的技能无非就是在Timeline上有一个循环“播放”的区域——在某一帧A判断,如果需要“蓄力”,就跳转到某一帧B(通常来说比A靠前,不然“蓄力”就变成“快速释放”了,当然并不是不能这么用,只要游戏设计用得着)。

TimelineObj

即游戏世界运行的实实在在存在的Timeline,一个Timeline是一个独立的元素,他并不隶属于谁。通常我们可能错误的理解为他隶属于一个角色,实际上你可以这样理解——他是一段剧本,这段剧本可能属于某个演员,但也可能不属于任何演员,只是道具组场景组要工作。TimelineObj除了有个Model证明他是什么,还需要一些元素:

  • caster:这个timeline所有的节点事件。
  • timeScale:这是因为游戏中会有“狂暴”之类的设计,他的效果是“使角色动作加速”,这里的动作加速,不光是美术做的动画要加速,逻辑内容也要加速,所以得有个timeScale。这个timeScale*Time.fixedDeltaTime就是每个fixedUpdate中timeElapsed增量。
  • param:这是一个动态传递的参数,通常是timeline的“创建源”,比如是一个技能创建的,他就是一个SkillObj,记录用的,也作为参数传递给timeline的事件脚本。
  • timeElapsed:就是经过了多少时间了,这决定了每个node是否该运作了。

技能(Skill)

技能是绝大多数游戏中角色的核心元素之一,尤其是对于ARPG下任何分类的游戏都是如此。我们通常见过的绝大多数的技能框架,都会错误地把技能的效果抽象为技能的属性,所以最后技能看起来是一个非常庞大的系统。但实际上,真正的技能系统,无非就是一个简单的“发射器”,他做的一切就是:

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图16

没了,就这么简单——因为在这里,我们把一切“技能效果”,都抽象为了Timeline。

比如在游戏中有一个火球魔法,我们释放技能,首先就是检查我们的资源是否足够释放,或者说是否学会了这个火球魔法,总之一系列检查是否能放;通过之后,就扣除对应的资源,并且创建了一个Timeline,这个Timeline做了几件事情:

  • 0秒时角色开始播放吟唱动作(低头念咒)
  • 1.5秒时角色开始播放施法动作(手一伸,指向目标)
  • 1.5秒时创建一个子弹(火球)飞向目标

通过这个Timeline,和这个创建出来的火球(子弹),实现了整个火球法术的效果。在可以说任何一个ARPG游戏中,无论再复杂的技能,他都是产生一个Timeline,因为一个Timeline就是一个回合要做的事情。由这个Timeline,来创造buff、子弹和aoe最终实现技能的细节效果。

技能的数据结构

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图17

SkillModel

技能的模板,这是一个策划填表的数据,它主要包含了:

  • condition:在这个demo里,技能的释放条件仅仅只有角色的资源(ChaResource),角色资源也就是Hp、Ammo之类的数值,指的是角色现有的生命值、弹药值等等,检查如果数量足够就算condition为true了。而根据游戏设计,相对复杂一点的,他也不是很需要直接做成一个function,因为只要判断角色持有的buff外加资源就可以满足绝大多数游戏技能需要。但是buff机制本身是强调开放性的,所以这里应该随时都可以被改为一个function,让策划写脚本决定一个技能是否能被释放。
  • cost:通常都是ChaResource,因为使用技能以后会扣除这些资源,最常见的就是减少mana。我们通常看到的技能都是cost==condition的,但实际上并不是,他们是2个属性,因为有些技能是这样的——当怒气大于20的时候可以释放,消耗掉所有的怒气,造成100+怒气值点伤害,这时候cost和condition就是不等的。
  • effect:也就是技能释放成功的时候创建的一个Timeline,这个Timeline的caster就是技能释放者,而param就是这个技能的skillObj。
  • buff:是当玩家角色学会这个技能的时候(由SkillModel创建了一个SkillObj的时候)会给玩家角色添加的永久buff,有点像技能附送的被动技能效果的概念。在这个结构也未必是AddBuffInfo[],他可以是一个:
  1. public delegate AddBuffInfo[] LearnBuff(SkillModel model, int level);

即根据技能和学到的技能等级,来添加不同的buff的函数。

值得注意的是——技能等级并不在Model里,因为正确的游戏设计中,技能升级并不是更换一个Model,而仅仅只是SkillObj.level发生变化,而level作为一个参数传递给效果的脚本,也会导致效果发生变化(或者不发生什么变化,取决于策划设计)。

SkillObj

即角色学会的技能实体,当玩家学会了一个技能之后,又学会了一些类似“技能插件”、“技能强化”、“被动效果”、“装备特效”、“天赋效果”等等等等各种来源的技能变化因子之后,修改的是SkillObj.model(clone出来的,C#里struct赋值本身也就是clone的了)。SkillObj的属性主要包括:

  • model:也就是SkillModel数据。在游戏中,如果我们经过历练之后,比如一个火球技能,现在能发出2个火球了,那我们需要做的就是把skill.model.timeline给“hack”了,而不需要设计“另外一个技能”。
  • level:技能的等级,如果有技能升级系统的话……
  • 其他:这是根据游戏设计需要的,比如游戏需要技能还能装插件,不同的插件对技能效果有不同的影响,这时候我们至少得有这些插件槽的数据在这里。

AI与技能

游戏中玩家角色使用技能,是县要学会一个技能的,但是这个对于AI角色来说有必要吗?其实是没有必要的,因此我们的AI可以通过“直接创建一个TimelineObj”的方式来实现释放技能——事实上AI都是这样释放技能的,只是玩家(和后来成为开发人员的玩家们)有一部分都认为AI也是在释放技能,因为那看起来是一样的。

因此游戏中的AI,并不需要走技能流程去释放技能,而是直接产生一个TimelineObj,如果有必要,就在产生一个“假的”SkillObj传递给TimelineObj作为param就好了。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图18

(当我们看到一个怪物或者boss“释放了一个及技能”的时候,他真的是“释放了一个技能”吗?)

Buff

Buff是整个技能体系的核心存在元素。在任何游戏的任何系统中,buff都是一个非常核心的元素,它更像是一个标记。如果把Buff单纯的理解为一个角色属性的变化器,或者一个简单的Modifer,这样的抽象都是错误的。

Buff的设计被发扬光大是在《魔兽世界》里,但是实现一个魔兽世界的buff系统要如何做?很多人初次尝试肯定会遇到这样的麻烦——比如我要做个“攻击时吸血”的效果,我就会写类似这样的代码:

  1. ///<summary>
  2. ///刻意模仿一下新手写代码的方式,表达一下这种感觉。
  3. ///这里的写法完全不符合“有可扩展性”
  4. ///<param name="attacker">攻击者</param>
  5. ///<param name="defender">受击者</param>
  6. ///<param name="damageValue">伤害值</param>
  7. ///</summary>
  8. public void doDamage(character attacker, character defender, int damageValue){
  9. //... if (attacker.drainHP){} //...
  10. }

通过attacker身上的某些标记,来判断是否需应该吸血之类的,但是吸血可多了,有按照伤害百分比、有按照自身百分比、有固定量、固定量算法还有很多种,最后发现标记根本不够添加的,而最后伤害流程也是动则几万行的if else代码(当时看到的代码也的确如此,毕竟是一个有几十个英雄的moba游戏)。这时候,我们把思维逆转过来——为什么我们要在流程里访问角色的标记呢?我们应该让角色的标记在流程里告诉我们该做什么!于是,Buff机制的第一版诞生了,它的初步思维是,在角色身上有一个Array<标记>,然后在需要写if (attacker.drainHP)的地方,for循环遍历标记是不是要干什么——要写的地方,被称为“回调点”;而写的东西,被称为“回调事件”——2010年的某一天,buff机制的第一版诞生了,并且落实到了起凡某项目的代码中去了。

而随着时间的推移,随着经历项目的增加,挑战的增加,buff机制也迎来了很多次变化,最终变成了今天的结构,他已经在任何的游戏中都可以成为设计和实现的中心思想——比如我们一直做错的任务系统,我们都认为任务的进度是一个枚举,比如击败多少敌人、获得什么道具。但实际上,正确的任务抽象应该是:在接受任务的时候获得一些buff,在任务过程中这些buff的逻辑产生了一些任务关注的buff,当任务关注的buff“全到齐了”,任务就完成了,然后从角色身上移除掉一些指定的buff——这才是一个对的任务逻辑:我们为角色添加了1个buff,onKill中判断击败的是哥布林,添加2层“击败怪物”的buff,击败的是豺狼人则添加5层,当达到50层的时候任务完成,只是ui上显示的是“给区域内的怪物上一课,进度:35%”而已。

回归到这个Unity做的顶视角射击游戏里面,一个buff并不是一个GameObject而是一个class,因为它只是角色的“属性数据”而已。在角色的ChaState中有一个Array就是这个角色所有的buff了。

Buff的数据结构

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图19

BuffModel

是一个Buff的填表数据,也就是一个Buff具体是什么,它主要包括的属性:

  • tags:是一个string数组,他是策划定义的内容,因为这跟游戏玩法逻辑息息相关。比如我们游戏中有“中毒效果”,那么策划就要定义一个”poison”,所有认为是中毒效果的buff的tag里面都得有这个。这样当出现一个技能,它的效果是“清除所有中毒效果”的时候,计算机才能“大概明白”什么是“中毒效果”——这相当于是对于buff的一个属性的描述。
  • priority:优先级,这通常是一个int,也是需要策划预设好的。在我们添加buff的时候需要根据这个来进行一次排序(buff列表的排序),而执行buff逻辑的时候是[0, 列表长度)的顺序执行的,这是非常有必要的。比如我们有2个buff,A的效果是伤害无效化,即DamageInfo的damage全部清0;B的效果是将受到的伤害反弹给攻击者,也就是创建一个新的DamageInfo,攻击者防御者换位,damage=现在的damage。这时候问题就来了,如果有人对“我”造成50暗影伤害,我应该反弹50暗影伤害,还是0伤害?也就是A和B的执行顺序问题决定了最终结果,因此到底反弹50还是0,这是由策划说了算的——策划填写的buff的priority和程序实现的排序规则,决定了最终谁先运行。
  • tickTime:这和onTick的回调点是配套的,如果这个是<=0的数字,或者回调点是null,那么就不会有OnTick的事件发生了。
  • maxStack:最大层数,这通常和游戏的buff堆叠规则有关。比如在《激战2》里,所有buff的maxStack都是1,所以不需要这个属性。《激战2》中,虽然我们肉眼看到比如“流血”的buff有很多层,但实际上他只是UI统计了角色身上有多少个model.id==“流血”的BuffObj,作为一个数字显示出来而已,所以只是个UI的设计,而非buff本身需要maxStack。

BuffObj

是角色身上运行着的Buff实体,Buff所有的逻辑都和这个BuffObj是相关的,BuffObj包含的主要属性有:

  • model:这是BuffModel的一个克隆体,因为在实际运作中,BuffObj的Model是可能发生改变的,所以他不能是一个id指向一个静态数据。举个例子,比如我们游戏中有一个“灼伤”效果,他的作用是每5秒对携带者造成伤害;然后有一个技能叫“焚烧”,这个技能的效果是:如果角色身上没有“灼烧”则添加灼烧,如果有灼烧,则会让灼烧的工作间隔缩短0.5秒,最短为1秒。实现这个效果的手法就是,把灼烧的model.tickTime给改写了。
  • time:也就是buffObj的时间,permanent代表了是否是一个永久的,如果是永久的,duration就不会变化,但是timeElapsed依然会增加,ticked也会随着触发OnTick的次数增加;duration是生命周期,当他小于等于0的时候BuffObj就将被删除了;timeElapsed是记录了这个BuffObj存在了多久了,因为duration这个数据也是可以在运行时被改动的,比如我有个“加热”技能,他的效果是“灼热”的生命周期+20秒,并且随着灼热的运行的时间越久,每次工作造成的伤害也越大。这里不仅要改写model的tickTime,还要连OnTick函数都改写,依赖的参数就是这个timeElapsed(越久伤害越高)。
  • castercarrier:是buff的释放者和携带者两个GameObject,释放者可能是null的,但是携带者一定是有的,之所以记录携带者(实际上BuffObj就是被添加到了携带者的ChaState控件里了)做出耦合效果,是因为BuffObj是要传递给脚本的一个参数,作为数据,他应该带有“我在谁身上”的信息。
  • stack:当前层数,也是因为游戏设计需要才存在的,如果都是1层,就不需要了。
  • param:一些动态的参数,比如《魔兽世界》中牧师的盾,还能吸收多少伤害的具体数值,就记录在这里,每次受到攻击都会减少多少。原本最早版本的buff机制中,这个盾的效果被记录在stack里,但这样显然是不对的——stack的意义不是这个,如果强行这么用就有了二义性,盾吸收值也许是一个Int128甚至Int256,而stack通常只需要Byte就够了。

AddBuffInfo

这是创建BuffObj的中间件,确切地说,我们每次要给角色的Buff做增删改查,都应该创建一条AddBuffInfo,而非直接修改ChaState中的buff。因为我们可能有些技能的效果会导致角色的添加buff,比如说有一个buff的效果是“受到伤害的时候获得厚皮(另一个buff)”,“厚皮”的效果是受到伤害时降低50%,并且下一次伤害免疫(又是一个新的buff),这时候我们如果同一轮里面执行就会执行到后面2个新增加的buff效果,但实际上这是不应该发生的,只是恰好C#的list管理方式碰巧有这个效果在那里,但这非常的不安全。所以我们正确的做法,就是当要给角色添加buff的时候,一定是产生一个AddBuffInfo,由BuffManager来管理buff的添加。AddBuffInfo这个“中间件”的属性有:

  • model:也就是要创建的BuffObj的model,这里也用model而不是一个id然后去buffModel表里查找,是因为model是可以通过脚本代码动态生成的,他也是个数据,所以他的源不应该是唯一的(仅仅来自于策划填表,确切地说,所有的Model数据都不应该只能是读表读来的)。
  • castercarrier:谁给谁添加。
  • time:要添加的时间,如果是永久的,那么duration就不在重要了,否则就是根据durationSetTo来确定是给buff添加一个时间还是设置为这个时间。因为这很可能是在改动一个已经存在的BuffObj,而并不一定总是新增一个BuffObj。
  • addStack:要添加的层数,如果是负数就是要减少的层数,0则表示层数不变,尽管如此,添加了之后只要BuffObj不符合被删除的标准,就还是会导致BuffOnTick被执行。
  • param:要设定给BuffObj.param的值,这个设定规则可以由策划定,当然对于程序来说最好的就是新的覆盖老的,但通常来说,这样做是无法满足需求的。

Buff的属性和状态影响

这是buff最基础的功能,也就是改变角色的属性和控制状态,以至于出现了一种错误的理解——buff就是改变角色属性的(尽管实际上在大型游戏的实际开发运用中,改变角色属性的buff占比甚至不到5%)。

角色的控制状态和属性是2个不同的struct:

  1. ///<summary>
  2. ///buff会给角色添加的属性
  3. ///</summary>
  4. public ChaProperty[] propMod;
  5. ///<summary>
  6. ///buff对于角色的ChaControlState的影响
  7. ///</summary>
  8. public ChaControlState stateMod;

其中ChaProperty是游戏中角色的数值属性,比如Hp上限、攻击力、防御力之类的,都是属于ChaProperty的,但是当前HP、当前MP之类的属性,属于ChaResource而非ChaProperty。在Buff中,ChaProperty是一个数组,是因为在这里策划可以定义游戏中的buff对于角色的属性影响,比如在这个demo中,buff只有2维:

  1. ///<summary>
  2. ///角色来自buff的属性
  3. ///这个数组并不是说每个buff可以占用一条数据,而是分类总和
  4. ///在这个游戏里buff带来的属性总共有2类,plus和times,用策划设计的公式就是plus的属性加完之后乘以times的属性
  5. ///所以数组长度其实只有2:[0]buffPlus, [1]buffTimes
  6. ///</summary>
  7. public ChaProperty[] buffProp = new ChaProperty[2]{ChaProperty.zero, ChaProperty.zero};

在运行重新计算角色属性(因为添加或者删除了buff)的时候,抛给策划脚本,让他们来返回一个ChaProperty作为计算用的,比如:

  1. --计算角色的最终属性值
  2. --baseProp是角色“裸体属性”,对应C#是ChaProperty baseProp
  3. --equipment是角色装备属性值之和,对应C#是ChaProperty equipmentProp
  4. --buffedProp是角色buff的属性之和,对应C#是ChaProperty[] buffedProp
  5. --返回计算结果,也就是说策划在这里写公式
  6. function AttrRecheck(baseProp, equipmentProp, buffedProp)
  7. return (baseProp + equipmentProp + buffedProp[0]) * (buffedProp[1]);
  8. end;

当然,加法和乘法,也是根据策划设计ChaProperty时候定义的乘法加法,比如这样:

  1. public static ChaProperty operator *(ChaProperty a, ChaProperty b){
  2. return new ChaProperty(
  3. Mathf.RoundToInt(a.moveSpeed * (1.0000f + Mathf.Max(b.moveSpeed, -0.9999f))),
  4. Mathf.RoundToInt(a.hp * (1.0000f + Mathf.Max(b.hp, -0.9999f))),
  5. Mathf.RoundToInt(a.ammo * (1.0000f + Mathf.Max(b.ammo, -0.9999f))),
  6. Mathf.RoundToInt(a.attack * (1.0000f + Mathf.Max(b.attack, -0.9999f))),
  7. Mathf.RoundToInt(a.actionSpeed * (1.0000f + Mathf.Max(b.actionSpeed, -0.9999f))),
  8. a.bodyRadius * (1.0000f + Mathf.Max(b.bodyRadius, -0.9999f)),
  9. a.hitRadius * (1.0000f + Mathf.Max(b.hitRadius, -0.9999f)),
  10. a.moveType == MoveType.fly || b.moveType == MoveType.fly ? MoveType.fly : MoveType.ground
  11. );
  12. }
  13. public static ChaProperty operator *(ChaProperty a, float b){
  14. return new ChaProperty(
  15. Mathf.RoundToInt(a.moveSpeed * b),
  16. Mathf.RoundToInt(a.hp * b),
  17. Mathf.RoundToInt(a.ammo * b),
  18. Mathf.RoundToInt(a.attack * b),
  19. Mathf.RoundToInt(a.actionSpeed * b),
  20. a.bodyRadius * b,
  21. a.hitRadius * b,
  22. a.moveType
  23. );
  24. }

而控制状态(ChaControlState)则是游戏中角色的一些权限集合,比如在这个demo中有这样几条:

  1. ///<summary>
  2. ///是否可以移动坐标
  3. ///</summary>
  4. public bool canMove;
  5. ///<summary>
  6. ///是否可以转身
  7. ///</summary>
  8. public bool canRotate;
  9. ///<summary>
  10. ///是否可以使用技能,这里的是“使用技能”特指整个技能流程是否可以开启
  11. ///如果是类似中了沉默,则应该走buff的onCast,尤其是类似《魔兽世界》里面沉默了不能施法但是还能放致死打击(部分技能被分类为法术,会被沉默,而不是法术的不会)
  12. ///</summary>
  13. public bool canUseSkill;

控制状态应该都是bool的,并且他们的加法关系通常是:默认值是true的呈与关系,默认值为false的呈或关系。当然,这也是看游戏具体设计的,只是“通常”如此而已。

Buff的“回调点”

Buff的“回调点”的本质,就是在一些固定的逻辑代码中安插脚本片段,来改变逻辑执行所依赖的数据,从而使得整个流程走完会得到“不同寻常的效果”。Buff回调点是整个游戏流程设计的灵魂,因为Buff的回调点有哪些,是完全取决于游戏需要的,我们可以把游戏的任何一段代码里都安插上,但越多的回调点会导致游戏运行效率越低,所以归纳好回调点是非常重要的事情,而归纳回调点的核心思路是——在设计一个玩法的同时,也去设计一些数据,来试着填充玩法,而不是凭空去想一个“创意”,比如我们要设计一个卡牌游戏,规则设计完了之后,我们应该马上设计一些具体的卡牌,然后看看这些卡牌需要哪些回调点来支持逻辑。通常来说,一个ARPG(当然也包含了顶视角射击游戏),他所需要埋设buff回调点的流程,不外乎就是伤害流程和角色相关的一些流程(比如buff的增删改和释放技能的时候)。在我这个demo里面,提供出来的回调点有:

OnOccur

在BuffObj被创建、或者已经存在的BuffObj层数发生变化(但结果并不小于等于0)的时候,会出触发的脚本:

  1. public delegate void BuffOnOccur(BuffObj buff, int modStack);

这个脚本提供给策划2个数据,一个是当前运行的OnOccur的BuffObj,一个是本次Occur的时候发生的层数变化(比如添加了多少层)。

这个回调点在大多游戏中,核心的作用和视觉管理更接近,比如我们设计一个“猎人印记”的技能,它的效果是给目标添加一个标记,使之受到的伤害提高。在OnOccur的时候,我们执行一个脚本,为角色的指定绑点添加一个视觉特效,这表示角色被标记了。当然为什么播放视觉特效是在OnOccur而非一个Buff的标准属性呢?因为绝大多数的buff完全是不存在视觉特效,甚至因为UI上连Icon都没有,以至于玩家根本不知道他们的存在,但实战中,尤其是大型项目比如MMORPG、Moba中,玩家真正能够感知到的buff,占游戏所有设计出来的buff的10%都不到,如果可见buff占比超过50%,那说明这个游戏的“花头”实在太少了,做内容设计(比如技能设计)的策划要好好加油了。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图20

(《魔兽世界》的猎人标记在OnOccur时给目标添加一个视觉特效)

而除了视觉管理,也有一些很有意思的效果会用到这个。比如我们设计一个角色受到伤害的时候会狂暴,狂暴最多100层,每层狂暴提高1%伤害,当狂暴大于30层的时候,角色进入“忘我状态”,受到伤害提高100%,但是攻击必定暴击。在这个效果里面,涉及到了3个buff,第一个buff是受到伤害的时候叠加这个狂暴buff,狂暴buff的属性变化中有攻击力提升,他的OnOccur里面就会计数——如果参数buff.stack>=30,那么就自己给自己添加一层“忘我状态”这个buff,这个buff最大层数只有1,持续时间是永久的,因此Occur的时候不论出发多少次,都是1层;而当buff.stack<30的时候,就会删除这层“忘我状态”,从而做到这个效果。

OnRemoved

在一个buff因为生命周期结束,或者层数<=0的时候,他要被移除掉之前,会执行的一个脚本:

  1. public delegate void BuffOnRemoved(BuffObj buff)

传递的参数是这个要被移除的BuffObj,当然这个回调点在我的这个demo里只需要这样,但是在其他游戏里面,可能他会需要别的参数,比如在《魔兽世界》里面,我们需要把它变成:

  1. public delegate bool BuffOnRemoved(BuffObj buff, GameObject dispeller);

我们注意到这里返回值和参数发生了变化,首先是参数多了一个GameObject dispeller,这是buff的驱散者。在《魔兽世界》中,有驱散buff的机制,即通过法术等方式,提前终止了某个角色身上某个BuffObj,此时这个dispeller是驱散者,而非null;而返回bool,则是有这个脚本决定,buff最终是否会被删除,尽管我们可以通过buff移除流程再走一次判断buffObj的生命周期和层数来确定他是否要删除,但是也许我们的某些逻辑的确在此时提高了buffObj的层数,却依然想要删除掉它。

这个回调点并不会在角色被击败的时候触发。他的使用范围除了移除掉OnOccur添加的视觉特效之外,还有很多逻辑作用,比如我们设计“在角色身上绑一个定时炸弹,10秒后爆炸,对角色和身边所有的友军造成伤害”,这就是一个持续10秒的buff,在开始的时候为角色身上某个绑点添加一个炸弹的视觉特效,然后在OnRemoved中,将这个特效移除掉,同时产生一个AoE,就是爆炸的AoE,对AoE范围内的所有符合条件(buff.carrier同阵营的)造成伤害。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图21

(痛苦无常是最典型的OnRemoved的效果,也是玩家最常见的OnRemoved效果)

而《魔兽世界》中更是有“生命绽放”和“痛苦无常”这两个技能——“生命绽放”是间歇性恢复效果(HoT),当这个效果(buff)结束时,造成一次较大数额的直接治疗,即便“生命绽放”被提前驱散,他也可能发生这个效果;而痛苦无常,则是判断OnRemoved参数dispeller,和这个buff.caster(buff的释放者)不同阵营,则会被沉默(另一个buff)+受到伤害。

OnTick

这是最常见的buff效果“每一跳”的回调点,我们通常所说的“间歇性效果”,比如“灼烧:每3秒对目标造成50点火焰伤害”等,其执行伤害的点都在这个OnTick:

  1. public delegate void BuffOnTick(BuffObj buff)

在我们写OnTick的回调脚本的时候,我们也会用到buff中一些记录用的参数,比如ticked(执行过多少次OnTick了),为什么需要这么一个参数。因为比如在《魔兽世界》中,有一个叫“痛苦诅咒”的技能,它产生一个间歇性伤害(DoT),这个间歇性伤害每2秒对角色造成一次暗影伤害,造成的伤害数值会逐渐变大,比如第一次是100,第二次就是120,第三次150……类似这样的提高,直到一个最高峰值,在这里,我们就要依赖ticked来算出当前的伤害值。

OnCast

这是在角色释放技能的时候发生的回调:

  1. public delegate TimelineObj BuffOnCast(BuffObj buff, SkillObj skill, TimelineObj skillEffect);

这个脚本回调接口中有3个参数,第一个依然是执行的BuffObj,第二个SkillObj则是角色当前企图释放的那个技能,第三个参数TimelineObj,则是当前由这个技能产生的效果(也就是这个timeline)是怎样的。在这里我们写一些逻辑最后返回一个要释放出去的“技能效果”,从而达成一个Hack了技能效果的过程。这个回调点的具体使用情景非常多,我们这里就随意举2个例子,首先我们确定我们有一个技能是翻滚——这在顶视角射击游戏很常见,就是角色往一个方向翻滚,可以躲避子弹,不仅有位移,还有位移过程中也会有无敌时间,它的Timeline大致是这样的:

  1. 0秒时,角色开始做翻滚动画,同时停止角色的移动、转身、使用技能权限(这并不等于角色不能动了,只是不接受移动、旋转、放技能指令了)。
  2. 0.1秒时,角色开始向指定方向发生位移,翻滚肯定得往一个方向滚出去才行。
  3. 0.4秒时,角色获得一个0.2秒的无敌时间(不会被子弹命中),这里是一个短暂无敌,更有助于玩家躲避子弹。
  4. 0.6秒时,整个技能结束,归还玩家对应权限。

基于这样一个效果的技能,策划设计了一些“被动技能”、“装备效果”、“天赋点数”等等的元素,去让这个技能要发生一点变化,比如我们说“随便举2个例子”:

  1. 角色翻滚的无敌时间变长:实际上是角色现在从0.3秒开始获得一个持续0.4秒的无敌时间,因此,我们只需要把传递进来的skillEffect的第三个节点的内容修改一下,返回出去就完事儿了。
  2. 翻滚时,会在地上留下一个地雷:这其实是个非常酷的感觉,所谓“离别礼物”嘛,那他如何实现?其实就是在2和3之间插入一个——0.1秒时,创建一个aoe(如果地雷决定用aoe实现的话,用什么实现是看具体想要什么效果定的),就这样,这个效果也就轻松实现了。

所以OnCast这个回调点本身非常的神奇,他完全可以把一个技能变成另外一个,只要策划愿意这么设计的话,假如我们游戏中角色有个“化境状态”,这个状态下所有的技能效果都会有较大的区别咋做呢?答案就在BuffOnCast。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图22

(《英雄联盟》中皮城女警的被动技能效果,就可以通过BuffOnCast来改变timeline从而改变发射的子弹来实现)

OnHit

这是技能在“命中”时候执行的一个回调点,此处的“命中”并不是最终结果的命中的意思,他更像是“碰撞到了”的意思,也就是当有一个DamageInfo的时候就有一次“命中”了:

  1. public delegate void BuffOnHit(BuffObj buff, ref DamageInfo damageInfo, GameObject target);

在这里第二个参数DamageInfo就是我们在整个伤害流程中不断传递的那个信息,因此需要ref有进有出,而target则是被攻击的目标的GameObject,基于target和damageInfo以及buff本身,我们可以做很多逻辑来实现很多效果,比如:攻击时有30%的几率触发额外一次攻击:这里就是OnHit中投个[0,100)的随机数,如果结果<30,就创建一个DamageInfo来作为“额外一次攻击”。再比如:如果攻击的目标是异性,则伤害提高30%,就是判断buff.carrier(buff的携带者)与target是否为异性(有类似gender的属性决定的),如果是就把DamageInfo.damage全都乘以1.3。

OnBeHurt

与OnHit相呼应的是,这是受到攻击时候触发的逻辑,如果说OnHit是主动攻击的效果,那么BeHurt就是被动挨打时候的效果:

  1. public delegate void BuffOnBeHurt(BuffObj buff, ref DamageInfo damageInfo, GameObject attacker);

在我们实际的游戏设计中,经常有类似“受到伤害降低20%”、“受到的火焰伤害转化为治疗”、“反弹受到的直接伤害的20%给攻击者”、“如果受到暴击就会恢复生命值”之类的设定,这些往往都是在BeHurt中实现的。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图23

(护盾类技能,减伤类技能都是典型的BeHurt中实现的效果)

OnKill

在确定会击败对手的时候执行的回调:

  1. public delegate void BuffOnKill(BuffObj buff, DamageInfo damageInfo, GameObject target);

尽管看起来在这里DamageInfo已经没有意义了,因为无力回天了,但我们依然可以访问到他,是为了实现一些类似“如果最后一击伤害大于目标生命的30%,会获得额外50%的经验”之类的效果。在这个回调点实现的主要效果比如“击败任何敌人后都会获得1个金币”、“击败敌人时任务进度提升”、“击败敌人时会发生爆炸对周围敌人产生伤害”等等效果。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图24

(《英雄联盟》中,船长Q技能火枪谈判击败小兵会有额外奖励,这个效果可以在OnKill中实现,因为是船长→小兵,而非小兵→船长)

OnBeKilled

在确定会被击败之后执行的回调:

  1. public delegate void BuffOnBeKilled(BuffObj buff, DamageInfo damageInfo, GameObject attacker);

这里通常会去实现的效果类似“角色被击败时会发生爆炸”、“角色被诅咒了,如果角色被击败,这个诅咒将扩散到附近的友方身上”、“击败这个目标可以获得一枚勋章”之类的效果。

子弹(Bullet)

子弹是射击游戏中非常核心的元素,尤其是在顶视角射击游戏里,子弹是“可见”的,在场景中以各自的速度飞行。对于玩家来说,选择好的方向发射合适的子弹,躲避来自敌人发射的子弹,是玩顶视角射击游戏的核心技巧以及乐趣所在。

和角色一样,遵守Unity的规则,我们创建一个空的GameObject作为子弹:

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图25

子弹下面一样有一个ViewContainer,作为装特效、或者说子弹外观的一个容器,当有必要改变子弹的外观大小等的时候,应该改变的是ViewContainer的大小,而非整个BulletObj。BulletObj下的控件主要有:

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图26

UnitMove和UnitRotate

UnitMove和UnitRotate,就是CharacterObj下也会用到的2个控件,他们在这里的作用是控制子弹的移动和旋转。

子弹的移动非常好理解,因为子弹“肯定”是会移动的,这里“肯定”的意思是,在通常的子弹设计中,子弹都是一个会移动的东西,但并不是说子弹不能不移动,当他的移动力是Vector3.zero的时候他就不会移动了,实际游戏设计中,策划也完全可以依靠这个设计出“静止不动”的子弹来。

子弹的旋转默认是根据子弹这一帧的飞行方向算出来的,子弹需要一个角度,这个角度就是BulletObj.transform.rotation,因为是顶视角设计,所以跟角色一样,只要欧拉y就是他的“面向角度”,子弹和角色的面向角度,以及位置之间的关系,就能形成“子弹射入方向”,并传递给造成的DamageInfo。

BulletState

BulletState对于BulletObj,就如ChaState对于CharacterObj,是一个核心的存在——有了BulletState的GameObject才是真正的BulletObj。

BulletState在游戏设计逻辑中就是BulletObj,只是因为Unity的实现方式问题,所以我们在BulletObj这个GameObject下绑了这么一个BulletState,所以,在下文中所提到的BulletObj,在非指代GameObject的情况下,都是指代的也是这个BulletState。

子弹的数据结构

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图27

BulletModel

是策划填表的子弹模板数据,用来创建子弹的时候进行数据初始化的,初始化后克隆给GameObj,在子弹运行过程中,其中的属性会被用到:

  • prefab:这是子弹需要用的外观文件的prefab,同样他是美术做好的一个GameObject,然后我把它创建到ViewContainer下。
  • radius:子弹的半径。在这个demo中,子弹的逻辑碰撞范围都是圆形的,所以我们只要一个半径就行了(由此,激光无法实现,因为只是没有去花时间实现它,激光是碰撞范围不同的子弹而已)。虽然游戏的画面是3d的,但是逻辑其实还是2d的,所以逻辑世界的碰撞是在平面直角坐标系内的。如果子弹用Unity的胶囊体来做,那么很可能因为子弹的高度等问题导致子弹无法命中目标(比如目标教矮的,或者正跳起来的等),渲染依赖于逻辑是游戏开发的根本,反制逻辑依赖于渲染是错误的,如果是否碰撞依靠的是“模型碰撞”,那么本质上就是逻辑依赖了渲染,是错误的——正确的游戏中,即使子弹没有美术图形也可以命中目标,只不过没法传递给玩家“子弹在哪儿、有多大、移动速度如何”的信息了。这个radius同时也会被当做子弹移动时候的bodyRaidus,参与和地形的碰撞,因为在这个游戏中,子弹的碰撞半径在面对地形和角色和aoe等任何元素的时候是等价的,因此当然是可以做成不一样的,只是这里没这么做。
  • hitTimes:一个子弹可以命中多少次之后才会被销毁,可以理解为子弹的“贯穿力”。一个子弹未必只能命中一次就没了,比如发出了一个“超大元气球”,他可以穿过很多个角色也不会消失掉。
  • sameTargetDelay:因为可以命中多次,同时因为碰撞判定每一帧都在发生,由此我们需要一个延迟——当第一次命中一个角色后,得经过多久才能第二次命中,这中间的时候碰撞不到这个角色。我们可以想象有一个体型非常大的敌人,我们有一发子弹贯穿力很好,可以命中100次才消失,那么他在路线上可以命中这个敌人多少次,也会取决于这个延迟的值,如果我们希望“伤害段数”非常高非常爽,就把这个值变小就可以。
  • moveType:子弹也有异动类型,地面或者飞行,这决定了他和地形的碰撞关系。
  • removeOnObstacled:子弹是否碰到地形阻挡就会消失,并且触发子弹的OnRemoved回调。如果设置为不会,就会和玩家移动一样,沿着阻挡继续前进。
  • hitFoehitAlly:子弹碰撞是否会发生在友军或者敌军身上,所谓友军,是双方的ChaState.side值相等,敌方则是不相等的。子弹的发射者(caster)的side(如果没有caster就会被当做-1阵营,可以命中任何角色)与命中目标的side形成了敌我判断逻辑中的“双方”。

BulletObj(BulletState)

这是子弹的实体数据信息了,也就是玩家能看到的子弹,它具备的属性主要有:

  • model:子弹模板的克隆体,和buff一样,子弹在运作过程中,依然有被hack的可能性,因此我们还是需要对他的model进行改动的,因此这里应该是一个克隆出来的model。
  • caster:子弹的施法者,也就是负责创建了这个子弹的角色,这个角色可以是null的,因为比如从地形机关脚本创建出来的子弹,他就应该是null的。
  • propWhileCast:子弹的施法者在创建子弹时候的角色属性,因为在《魔兽世界》中有个类似设定就是命中时候造成的伤害和释放的时候角色属性有关,技能放出去(利用子弹发射到命中的时间差)以后换装备,并不会影响技能的最后效果,于是我们也记录一个这个属性在子弹中,作为创建时候的施法者属性。但是依然,这解决不了子弹命中时候,角色身上的某些buff已经被移除了,以至于一些效果无法生效,因此是否需要记录一个List buffsWhileCast,就看游戏具体需要了,但是通常来说,就连propWhileCast,都是不需要记录的,因为在设计层,这个几乎没有必要。当然既然这里说的是实现层,实现层不可以否定设计,不能提出“设计不合理”(这是原则),因此我们这里只说要去做的时候的实现方式。
  • hp:子弹还能命中几个目标,每次命中(碰撞到)一个目标-1。
  • Time:子弹也有很多的时间参数,duration是生命周期,和buff的duration一样概念;timeElapsed也是记录子弹存在了多久了,因为duration可能被改动,但是这个只能持续增加,如果我们要做一个技能效果是“子弹随着飞行距离越远伤害越大”,则完全可以直接用这个时间作为参数(毕竟时间乘以速度=距离);canHitAfterCreated是一个特殊的值,如果这个值>0,子弹就不会碰撞任何单位(但不包括地形),因为我们有些时候比如设计了一个“子母弹:打中目标后分裂出3个小子弹”,这时候分裂出的子弹立即就会碰撞到母弹碰撞(命中)的目标,因此需要加一个时间,确保短时间子弹不会碰到目标,可以“让子弹飞一会”了。当子弹的duration为0的时候,子弹会被移除,此时会进入OnRemoved回调点,如果子弹的model.removeOnObstacled是false的,那么就只有duration<=0才会触发OnRemoved了。
  • Movement:子弹的移动信息,这里的核心是tween;fireDegree和useFireDegreeForever是创建时候的参数,用于传递做tween的参数;velocity是当前一帧的移动速度和方向信息,也是给tween函数(或者其他回调点)使用的只读参数;moveType和smoothMove其实是创建时候model里面的moveType和removeOnObstacled赋值而来,因为这两项变化频率会非常高,还有“还原”的需求,所以尽量不动model的值,而直接用这个——比如一个手雷,他在飞起来的时候moveType应该是fly的,落地的时候是ground的;followingTarget则是子弹跟踪的一个目标,比如“跟踪导弹”、“回力标”的tween就会用到这个作为参数。在这里,Tween是一个策划编写的脚本函数:
  1. public delegate Vector3 BulletTween(float t, GameObject bullet, GameObject target);

这就像写动画的Tween的缓动函数是差不多的,只是缓动函数(比如我们经常用的Ease.linear)是传入时间(0.00f-1.00f)返回一个0.00f到1.00f的数字,这里则是传入子弹运行到多久的时间作为t,并且把子弹的GameObject和跟踪的目标GameObject传递给这个脚本,让策划根据这个写出当前子弹的移动力函数,根据得到的移动力值,来逐帧推动子弹前进。所以只要Tween写的“够妖”什么轨迹的子弹都能做的出来。而上面提到的useFireDegreeForever,就是这个tween传出来的移动力,他的角度是加上fireDegree的还是加上子弹当前的transform.eulerAngle.y的。

  • param:子弹的参数,和buff的参数一样,是一些特殊记录的参数,尽管不是马上就想得到用途,但是留着还是有意义的。
  • hitRecords:子弹的命中纪录,正如我们上面说的,一个子弹击中一个目标后,一定时间内是不能再击中这个角色的,要做到这个效果,我们得有一条数据,纪录击中了谁,多久之后才能再次击中,然后每帧减少这个时间,当这个时间<=0之后清理数据,就可以再次击中目标了。

BulletLauncher

子弹的发射器,也就是游戏中,所有创建子弹的入口所使用的参数(也就是创建子弹的函数依赖的数据),他的主要内容有:

  • model:要创建什么子弹。
  • caster:创建子弹的负责人是谁,可以是null。创建的子弹的caster就是这个GameObject,而propWhileCast则是读取此时caster下ChaState.property而来的,如果caster是null,propWhileCast则默认是一个全为0的属性,当然可以通过脚本来修改它。
  • firePosition:子弹发射时候的坐标,对应的是世界坐标系,即逻辑的二维世界坐标系。
  • fireDegree:子弹发射时候的角度,对应的也是逻辑世界的二维坐标系。
  • speed:子弹的飞行速度,这是子弹最基础的飞行速度,他乘以tween返回的移动力,就是最终子弹的移动速度了。
  • duration:这个子弹的生命周期,不同发射器发出来的同样的子弹,他的生命周期很可能是不同的,所以生命周期并不属于子弹本身,而是发射器决定的。
  • targettingFunction:创建子弹的时候,他的followingTarget是通过策划写的这个脚本获得的,绝大多数子弹这个脚本都应该是null,毕竟是射击游戏,跟踪子弹不是好的设计。
  • canHitAfterCreated:多久之后可以第一次碰撞目标,这也不是子弹的属性,而是发射器决定了子弹的这个“性能”。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图28

(其实BulletLauncher很好理解——UMP9和Uzi都是发射9mm子弹的,但是效果肯定是不一样的)

子弹的回调点

和buff一样的概念(因为这套机制的名字就叫”buff机制“),子弹也是通过触发回调点的脚本的模式来工作的,子弹的回调点在顶视角射击游戏中通常(而不是绝对只有)有这些:

OnCreate

子弹创建是的回调函数,即子弹杯创建完放到场景中之后做的绝对的第一件事情:

  1. public delegate void BulletOnCreate(GameObject bullet);

这个回调点的使用率其实是非常低的,但并不是没有,比如我们需要让“子弹为角色添加buff”,这时候就需要走这个回调点。“子弹为角色添加buff”这个工作,通常是希望角色同步子弹信息才有的,比如我们发射出一个飞弹(子弹),这时候可以再次使用飞弹技能来引爆这个飞弹,那角色就得知道飞弹是否存在,因此就得知道具体飞弹是哪个GameObject,此时,我们就在飞弹OnCreate时,给角色添加一个buff,buff的param中存飞弹的信息;判断的时候只要判断这个buff的这个参数指向的GameObject是否存在以及他的状态就行了。

OnHit

子弹命中目标的回调,通常玩家理解的“子弹效果”就是写在这里面的,确切地说,是子弹效果的大约70%写在这里:

  1. public delegate void BulletOnHit(GameObject bullet, GameObject target);

这里的第二个参数target就是被碰撞到的角色的GameObject,策划写子弹效果的时候,比如最基础的要对目标产生伤害,就要用到这个target。

OnRemoved

之所以说OnHit只是玩家理解的子弹效果的70%,是因为30%在这里,就是子弹被移除的时候,我们通常会忽略这个时间点——它发生在子弹“自然消失”的时候,比如撞到了墙壁或者生命周期终止,此时我们需要:

  1. public delegate void BulletOnRemoved(GameObject bullet);

比如我们的子弹效果是“命中目标之后分裂出4个小的子弹向子弹命中的反方向飞去”,那么这个效果在OnHit的时候,需要写的脚本里就要包括造成伤害和产生4个BulletLauncher发射小子弹。而这个效果本身,策划心目中的理解也是“如果碰到墙壁,肯定也得分出来4个小子弹”,因此我们就得在OnRemoved里面也写这个效果(只是伤害的部分没有了),但是因为OnRemoved会被碰撞和生命周期走完触发,所以我们还要判断bullet.GetComponent().duration是否还>0,如果是,说明是碰撞到墙壁的,就该执行,否则是自然的消失了,就不会创建这4个BulletLauncher了。

Area of Effect(AoE)

AoE通常的理解是游戏中的范围性技能,这个理解出现于《魔兽世界》,玩家把类似暴风雪之类能对一个范围内所有敌人造成伤害的技能称为AoE。在实际的游戏开发中,AoE更像是一个范围捕捉器,他的作用是捕捉一个范围内所有符合条件的内容,并执行对应的回调函数,起到一个“批管理作用”。AoE的本质有点像服务器概念里的AoI(Area of Interest),通常我们通过AoI来管理一个范围内的事物之间的坐标,然后广播数据的时候会起到一个优化作用。两者的区别在于通常来说,AoI是静态的,在整个游戏中是不会发生变化的,AoE是动态的,会不断的被增删改查;AoI的范围是一个形状,一般来说矩形是最优的,作用是将“位置”这个属性符合这个AoI的事物注册进来进行小范围管理;但是AoE的范围未必是一个形状(比如“全场女性角色”也是一个AoE的范围,这就不是一个形状能概括的),且不同AoE之间的“范围”会有“重合”。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图29

(AoE技能是在ARPG、Moba、顶视角射击游戏都很常见的效果)

当我们认清AoE的本质之后,不难发现,AoE并不仅仅是做技能的效果用的,除了做《魔兽世界》中一些暴风雪等范围伤害技能的效果;还能用于做类似《星际争霸》中事件触发器其里面的范围(Location);也可以在游戏的机制层用来对于不同类型的事物进行管理。他的用途远远不是只有技能这么渺小,而是整个技能体系,会依赖到AoE。

比如在《魔兽世界:巫妖王之怒》中,有一个叫卡鲁亚克的阵营,在他那里的日常任务有时候会随机到一个拯救海豹的任务,任务讲述的是雄海豹和雌海豹被海水隔开了,所以不再交配了,海豹面临绝迹的危险,要求玩家使用鱼把雄海豹吸引到雌海豹的地盘。这个任务的实现中,玩家丢出的鱼,就是一个aoe,他的图形是地上的一条鱼,但是实际范围远大于这个图形,当有单位进入这个范围的时候会判断,如果是一个没有被“这条鱼”吸引的雄海豹,就会吸引这个海豹(给这个海豹添加被吸引的buff,如果原来有别的就会覆盖掉),并且关闭掉AoE的“角色进入”的回调点,带有这被吸引buff的海豹AI会变成向这个aoe的坐标行走。当玩家通过“丢鱼”实现多个AoE把雄海豹骗到“雌海豹岸边”这个AoE范围,就会获得一个buff,也就是这个任务目标所关注的buff(还记得上面我们说buff的时候那个buff用于任务的手法吗?)——这便是AoE和Buff联合在一起实现了一个有趣的小任务(任务系统的玩法)的实际例子。

和CharacterObj、BulletObj一样,AoeObj也是一个“空”的GameObject下放了一个“空”的ViewContainer:

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图30

AoeObj的主要控件包括:

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图31

值得注意的是,因为这个demo是一个顶视角射击游戏,因此AoE会移动的可能性较高,尽管需要旋转的可能性依然不是很高,但我还是把UnitMove和UnitRotate都放进去了,通常来说,我们根据AoeState中的数据来看,如果需要移动、旋转才添加这两个控件才是上策。

AoE的数据结构

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图32

AoeModel

是策划填表的AoE模板数据,一样的,并非所有的AoE模板数据都来自于填表,也可以同脚本代码临时生成数据。Aoe的主要数据包括:

  • id、tags、prefab:是基础的3信息,aoe一样需要tags用于一些设计逻辑,prefab即aoe的美术资源从哪儿来,他是一个string的,因此我们可以在他是空字符串,或者不存在对应美术资源的时候,认为他是不需要美术表现的。绝大多数的AoE其实是没有视觉特效的,但是为什么我们依然用了GameObject,就如一开始所说,GameObject只是数据,它本身在没有任何Renderer的情况下是不渲染的,纯粹的逻辑数据,只是我们可能会因为透过编辑器可以看到“空”的GameObject(比如运行的时候他会出现在Hierarchy列表里),于是产生了一些错误的认知。
  • tickTime:和buff的tickTime同样的概念,他决定了多久执行一次AoE的OnTick回调点,如果是0,依然会导致OnTick不执行。
  • removedOnObstacle:和bullet的removedOnObstacle同理,一个会移动的AoE,同样可能会被地形阻挡而导致被摧毁,但是绝大多数AoE这项属性应该是false的。

我们发现AoEModel的属性非常之少,当然,大头在回调点上,但实际上AoeObj中许多的重要信息,都不是来自AoeModel的,AoeModel只是决定了这个aoe是什么,比如说是火雨,那么火雨范围多大呢?这些信息应该是由AoeLauncher来传递的,比如一个高阶术士释放的火雨半径可能是20米,但是低阶术士只有5米。

AoeObj(AoeState)

就是aoe的核心数据,也就是运行中的AoE所有需要用到的数据,它主要包括了:

  • model:和bullet、buff是一样的,是一个model的克隆体,因为他也会在运行中被改变,就如上面我们说的小海豹的任务,它在吸引到海豹之后,model.onCharacterEnter被设置为空了。
  • raidus:在这个demo里面,所有的aoe都是圆形的(也因此确实没有激光了),所以我们只需要一个半径来作为范围就好了, 但实际上根据游戏的复杂程度,这个radius是应该被扩展为一个range的。这个属性是让AoeManager在每一帧判定捕捉角色和子弹的时候用的,在这个游戏里,AoE只跟角色和子弹有捕捉关系, 根据游戏不同、需求不同,AoE还可以捕获其他的AoE等,只要策划需要这么设计的话。
  • Time:duration和timeElapsed我们已经熟悉了,一个是剩余的时间,一个是经历了的时间;tweenRunnedTime和经过的时间其实并不是一个时间,只是他们恰好可能相等,但是通过逻辑脚本,完全可以重设这个tweenRunnedTime,而通常来说严肃一点的话,timeElapsed都应该是只读的(只是demo里我没这么做而已),tweenRunnedTime是每一帧调用tween函数时候作为参数传递给tween函数的。
  • casterpropWhileCast:caster是aoe的创建人,propWhileCast就是创建aoe的时候这个创建人的属性,这和bullet是相似的,同样的aoe也可以没有创建人。在上面小海豹的例子里,鱼的aoe创始人一定是丢鱼的人,由此才能在添加buff给小海豹的时候确认buff的caster是丢鱼的人,而当小海豹到地方之后,也才能从这个buff的caster得出代表任务进度的buff该授予谁。
  • charactersInRangebulletsInRange:是指当前已经在这个aoe捕获范围中的角色和子弹,正如我们前面说的,这个demo的aoe只捕捉角色和子弹,因此只记录这些,至于为什么不是每一帧去捕捉,是因为在我们的回调点里面onXXXEnter和onXXXLeave,我们必须管理这一帧谁进来了谁走了。
  • tween:AoE的tween,和子弹的tween类似,是用于驱动aoe移动和旋转的Tween,但是aoe的移动旋转规则又与子弹不太一样:
  1. public delegate AoeMoveInfo AoeTween(GameObject aoe, float t);
  2. public class AoeMoveInfo{
  3. public MoveType moveType;
  4. public Vector3 velocity;
  5. public float rotateToDegree;
  6. }

aoe的tween也是每一帧(每一个fixedUpdate)的时候传递过去是哪一个aoe和当前运行的时间,也就是是tweenRunnedTime,然后由脚本返回一个结构AoeMoveInfo(这里我用了个class,实际上是偷懒逃避了一些麻烦而已,正确的做法应该是个struct)。在这个AoeMoveInfo里面有这一帧aoe的移动方式:允许每一帧aoe的运动方式都是不同的,有时候在天上,有时候在地上;aoe的移动力,即一个Vector3向量,但实际上作用只有x,z轴,因为逻辑上是个2d的游戏;rotateToDegree即aoe的transform.eulerAngle.y的值。

AoeLauncher

aoe和子弹一样,是通过launcher来制造的,即如果我们有一个函数是createAoE,那么他的参数也应该是一个AoeLauncher,或者能构成一个AoeLauncher的数据。AoeLauncher的核心内容有:

  • model:即要创建的是一个什么AoE。
  • positiondegree:AoE创建在什么位置,也就是AoE的初始位置,以及他的初始角度。
  • caster:AoE的负责人是谁,也就是谁创建了这个AoE,和buff、bullet的caster一样的概念,并且也一样可以是空的。
  • raidus:AoE的半径,也就是在这个demo里面的范围。
  • tween:AoE的tween函数,为空的时候代表不移动。
  • param:即创建后的AoeObj的param值,这个参数是给策划设计aoe时候用于记录一些自定义的信息。

AoE的回调点

AoE在捕捉角色和子弹的同时,也会通过运行的时候出发一些“回调点”来让执行逻辑,这些“回调点”主要有:

OnCreate

在AoE创建的时候被执行:

  1. public delegate void AoeOnCreate(GameObject aoe);

在执行这个函数的时候,AoeState已经完成了一次捕获,也就是已经获取了范围内的角色和子弹了,因此是可以利用aoe.GetComponent().characterInRange等数据来进行对范围内角色造成伤害等处理的。

这个回调点的用法之一是“创建一个法阵,3秒后对法阵内的所有人造成伤害,如果目标在法阵创建时就已经在法阵之中,那就会受到双倍伤害”,类似这样的效果,就是在OnCreate的时候给角色添加一个buff,OnRemoved的时候造成伤害,并判断是否有这个buff,有就是双倍伤害。

OnRemoved

在AoE被移除的时候执行,AoE被移除的渠道通常是duration<=0,如果AoE会碰撞,那么碰撞后也会发生被移除的事件:

  1. public delegate void AoeOnRemoved(GameObject aoe);

在这个demo里,aoe被移除前会执行这个,并且无力回天(就是无法为这个aoe续命),如果需要可以能在OnRemoved里面“再给aoe一次机会”,那就是移除的流程(在AoeManager中)稍作调整即可做到。

这是最常用于伤害性法术的时间点,因为我们通常需要伤害性法术,比如爆炸效果有一个延迟时间,这个延迟时间也就是aoe的duration了。比如我们要做个“4秒后会自动爆炸,玩家也可以手动引爆,对范围内的敌人造成伤害”,那么就是OnRemoved里面写“对范围内的敌人造成伤害”的效果,而至于AoE是如何被移除的,比如用了一个技能duration直接变0了,那根AoE本身没有任何关系。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图33

(《暗黑破坏神3》中的陨石术,就是在OnCreate的时候创建一个地面视觉效果,在OnRemoved时候造成伤害,所以要“加速”陨石术生效,只要把AoE的duration调短即可)

OnTick

是每一个Tick执行的事件,同样需要AoeModel.tickTime > 0才可能被执行:

  1. public delegate void AoeOnTick(GameObject aoe);

这也是一个极为经典的回调点,他的经典在于类似《魔兽世界》中法师的暴风雪等,知道今天都还是用这个回调点来实现的,而早年的光环技能,也是通过这个回调点不断给角色上一个buff。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图34

(《魔兽世界》中德鲁伊的宁静,就是在AoeOnTick时对范围内caster的友军进行治疗的技能)

OnCharacterEnter

是当有新角色进入到这个区域内的时候执行的:

  1. public delegate void AoeOnCharacterEnter(GameObject aoe, List<GameObject> cha);

其中参数List cha就是这一帧进入这个范围的角色们(的GameObject),而在此时,aoe的AoeState.characterInRange中还没有这些角色。当每次范围内的角色离开范围后再次进入范围,就会再次执行这个。所以假如我们需要做一个效果是“对进入范围的目标造成50点伤害,但是短时间内不会再次伤害”,遇到有角色在这个AoE范围里“我出来了,我又进来了,我出来了,我又进来了”,就需要通过buff来辅助,其执行的函数是——没有某个buff的时候会造成伤害并添加buff,持续时间为这句说明中的“短时间”,否则return。当然我们也可以利用AoeState.param来做类似bullet.hitRecord的事情,比如param[“hitRecord”]=new List(),然后通过OnTick事件来维护这个List也是一个做法,只是相比之下,上一个buff的性能会好不少,但是如果太多这样的类似效果的话,我们就不得不审视这个项目是否需要一个aoeHitRecord了(做法是活得,而不是因为框架如此就动不得了)。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图35

(《西游记》中孙悟空为唐僧画的保护圈,就是当怪物进入时会被传送出去并受到伤害,这里就需要OnCharacterEnter,判断如果是怪物……)

OnCharacterLeave

当角色离开aoe范围的时候会执行的事情:

  1. public delegate void AoeOnCharacterLeave(GameObject aoe, List<GameObject> cha);

这里的cha是这一帧刚刚离开离开AoeState.characterInRange的角色,他们已经不在characterInRange里了。

这个回调点的用法通常是光环类效果的buff移除等,但是这里有一个经典的用法——就是《魔兽世界》中卡拉赞(70级时代)艾兰的烈焰花环——“随机挑选3个玩家,在他们脚下生成烈焰花环,如果有人进入或者离开了烈焰花环,就会对全团造成伤害”。我们看到这里有几个彻底发挥AoE性质的点:

  • 首先是OnCharacterEnter和OnCharacterLeave,只要cha的长度变化,就会对全团产生伤害。
  • 其次是如何产生这个伤害,其问题不是调用伤害脚本,而是“谁”该收到伤害,这里有一个全团,尽管“艾兰的房间”,也就是战斗的场地是一个足够小的场景,所以可以直接丢一个aoe覆盖范围,但是我们更应该去做的是——AoE的范围内,包括了“正在副本中的所有玩家”这个range。

而这个“老狗也有几颗牙”的技能,也正是aoe绝妙之处所在——你也许发现了,在aoe和buff中,都没有角色OnMove的回调,事实上这个回调点触发频率过于频繁,是不建议存在的,它与OnTick最大的不同是,他要判断移动过了,这是一个并不如想象的那么简单的判断。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图36

(在端游《激战2》中,有一个个人史诗副本中有一个机关,需要达到一定的重量才会开门,玩家需要搬运石头或者让队友站上去,然后门会开启,一旦重量不足门就会关闭,类似机关,就是OnCharacterEnter和OnCharacterLeave的时候判断是否要开门)

OnBulletEnter

这是一个与角色对应的,子弹进入aoe范围的回调点:

  1. public delegate void AoeOnBulletEnter(GameObject aoe, List<GameObject> bullet);

同样的bullet是正在进入,但还没有处于AoeState.bulletInRange的子弹们。在这个回调点我们可以做什么?其实很多射击游戏中的“护盾类技能”,比如《B计划》(FC)里面的“黑煤球”,就是2个球环绕玩家,可以摧毁子弹;比如“面前生成一个防御气场,子弹无法穿越”;再比如《暗黑破坏神3》中奥法的减速力场,子弹进入力场之后移动速度会降低90%,都是通过这个BulletOnEnter来对于Bullet进行处理的,摧毁他们,或者改写它们的某些属性,比如移动速度。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图37

(《暗黑破坏神3》中法师的罩子,可以减速进入的子弹)

OnBulletLeave

与角色离开对应的是子弹离开的回调点:

  1. public delegate void AoeOnBulletLeave(GameObject aoe, List<GameObject> bullet);

除了离开减速立场的子弹需要把移动速度设置回去之外,还可以做很多有意思的设计,比如角色有一个被动效果是“角色发射出子弹,在近距离(5米内)是带火焰的,离开后火焰效果消失”,那么就是有一个由角色创建的aoe,他的tween是跟随这个caster的,这个aoe的OnBulletLeave中,会改写bullet的外观和OnHit等,起到改变子弹性能和视觉效果的作用。

AoE与子弹为何不可互相取代?

说到这里,不免会产生一个疑惑,aoe和bullet如此相似,为什么他们不能混合到一起?或者说他们能不能派生自同一个类?回答是否定的,原因非常简单——因为设计子弹和设计AoE的动机是完全不同的。

  • 当我们设计子弹的时候,出发点是角色发射出这么一系列子弹,如果碰到了什么应该有什么效果。
  • 当我们设计AoE的时候,出发点是捕捉到了一个范围内的角色或者子弹,对他们干点什么;然后就是如果有角色或者子弹进来、离开或者呆在里面,该对他们干点什么。

这两者的思路是大相径庭的,也是设计师最自然的设计思路——因此我们必须把他们严肃的分开来,让设计师不去用“凑效果”的方式来思考设计,不应该给予设计这样一层burden。

如何去设计以及实现一些技能

到此,这套“Buff机制”在顶视角射击游戏中的框架,就初步搭完了。之所以说是初步,是因为这个游戏并没开始设计,任何一套框架,其价值并不是帮设计者想好了会怎么做的范围,然后设计者被约束在框架里去设计,而是在于当设计者有了更有趣的想法(或者老板有了“更无理”的需求)的时候可以几乎不动核心代码,思路清晰简洁的进行改善。

当然,demo的框架搭完之后,我们也需要做一些实际的技能效果来,于是我从千猴马的QQ群里征集了一些群友的设计并且实现了他们,接下来就说说这些技能的实现思路:

手雷

来自群友mystery的设计:

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图38

需求分析

乍一看需求,第一感觉是《英雄联盟》中炸弹人的Q技能,丢出一个炸弹,撞到目标直接爆炸,不同的是,这里会在没有命中任何目标的时候,最终落在地上,停留一会以后再爆炸。而停留的期间,炸弹不会被其他人引爆:

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图39

于是最终我们选择了策略——不会被引爆。

这个设计用在顶视角射击游戏完全没有问题,毕竟《英雄联盟》作为Moba和顶视角射击游戏是同宗的,区别在于一些UI(包括操作输入方式和界面)。我们在讨论这个技能的效果的时候,可能会忽略一些细节的因素,这些细节可能会对技能的实现难度产生一些影响:

第1个细节是这个炸弹撞到阻挡物之后应该如何处理?我们选择直接爆炸的效果,因为他符合“命中”这个概念,但即使是反弹,其实也只是一个简单的初中数学问题。

第2个细节是这个炸弹是抛物线的轨迹,尽管这种游戏的逻辑世界是2D的,也就是说尽管我们肉眼看到的是3D,但实际上他就是在一个平面上几个方块圆圈在运动的游戏,包了一层好看的美术而已。所以严格的说,和普通的子弹相比,只需要控制炸弹的美术资源y坐标变化变化就行了。但是在我这个demo的逻辑世界里分了2个“层”——地面层和飞行层,地面层是大多角色走路的层,而飞行层可以理解为类似子弹之类会飞的东西。针对于每一种地形,他都有地面层是否可过和天空层是否可过,而一个单位(不论是角色还是子弹),他都有移动模式(地面还是飞行),在每一帧内,这个值是唯一的,但是这个值可以在不同帧内不同,最直接的效果是,比如demo里的河流地形,是一个“走不过去但是可以飞过去”的阻挡,也就是说,如果我的手雷丢在半空中,他应该是飞行的,可以越过河流,而落地的那几下,应该是会碰撞到河流爆炸的(是不是很奇怪?其实并不,如果我能找到美术资源替换掉河流,比如岩浆他还奇怪吗?不奇怪了,所以只是眼睛骗了人)。

当确认过这些细节之后,就可以进一步以“buff机制”来思考实现的详细细节了。

需要制作的元素和细节

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图40

可见,在这个技能的制作中,我们需要做的内容,包括1个bullet(手雷)和2个AoE(手雷和爆炸),其中:

  • Bullet手雷:就是发射出去的子弹,因为给动画绑了个BouncingBallY的控件,由这个控件来同步动画和逻辑的“落地时间”。
    • 在OnRemoved的时候,我们需要判断他是否是自然结束的,也就是duration走到0了。如果是,就产生一个AoE手雷,因为我们还需要3-4秒的时间去引爆;如果不是或者遭遇了碰撞,那么直接创建一个AoE爆炸,开始对范围内的目标造成伤害。
    • 在OnHit中,根据需求,直接产生AoE爆炸,对范围目标造成伤害。
    • 我们需要只做一个Tween,就是Bullet的运动轨迹,实际上手雷是直线向前飞的,当然要再精细一些也可以做成越来越慢,用个缓动函数就好了,而我这里每一帧返回的都是Vector3.forward,让手雷在x,z轴匀速前进。而在这个Tween里,我们还需要做一件事情,就是根据约定的落地时间,来改变Bullet的移动属性,使他在逻辑上真的落地,从而享受地面层的碰撞。
  • AoE手雷:这实际上只是一个假象,即这个AoE所采用的图形和我们丢出去的手雷是一样的,这个AoE的作用实际上是一个计时器,也就是当AoE的生命周期结束后,就会创建一个AoE爆炸。在这里,如果我们需求手雷落地后要么等3-4秒,要么由角色也可以触发的话,就在这个AoE的OnCharacterEnter里面做做文章就好。
  • AoE爆炸:这是最常见,也是很多游戏开发初心者们理解的AoE的作用——对范围内的角色造成伤害。因为这个AoE是刚创建很快就要删除的,最多有一个爆炸伤害的延迟这么久的时间(aoeObj.duration=爆炸后延迟多久产生伤害,因为逻辑写在OnRemoved里面)。因此我们还需要在开始的时候执行播放一个视觉特效,也就是那个爆炸特效的工作。由此我们在运行中的Hierarchy里面会看到有一个SightEffect的GameObject被创建,我们把这个爆炸要放多久,放完以后回收的事情交给了SightEffect,而非AoE,因为我们没必要也不应该等动画播放完毕才去结束AoE,AoE是逻辑,不依赖于动画数据(特效的播放时间),且AoE的duration已经有了明确的意义了。

到此这个手雷的逻辑也就捋清了,就剩下动手写脚本了。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图41

回力标

来自群友Cloak的设计:

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图42

需求分析

回力标这个东西在顶视角设计游戏里面,最应该有的一个也是最有意思的设计,就是玩家向前对一个回力标出去,回力标飞一段距离之后,折返回来应该总是能跟着玩家角色去的。由此,玩家可以通过走位,来调整回力标的路线,来对敌人进行更有力的打击。回力标应该符合的特点主要有:

  • 回力标的耐久度应该非常高,可以命中很多很多次,这样才能让路线走完。
  • 因为耐久高,因此得设置一个命中同一个角色的间隔时间,不然会一下秒了血少的敌人,毕竟每一帧都是伤害,按照我们设置的帧率,1秒就是50次伤害。
  • 回力标应该也是有一个duration的,因为发射回力标的角色会消失,角色消失之后,回力标的移动轨迹应该回归Vector3.forward才对。

接着,就该总结我们对回力标要做的主要工作了:

需要制作的元素和细节

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图43

可见回力标的工作量真的不大,复杂的内容全在这个Tween里面了,但是思路是十分清晰的。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图44

瞬移子弹

来自群友魔理沙的猫的设计:

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图45

需求分析

看到这个技能设计的时候,我第一个想到的就是《英雄联盟》种冰霜女巫丽桑卓的E技能。实际上《英雄联盟》的技能放在TopDownShooter里是完全可以的。但是这里引起了两个具体思考:

因为我们想设计的是发射一个球,或者说是一个飞弹,他首先的特征是“飞”,和丽桑卓的冰川之径(E技能)不同之处在于后者是在地面上放一个爪子。如果我强行将它理解为“看起来不同,实际上一回事儿”那就太不符合需求了,因为我们的是飞弹,所以他应该是飞的,不应该被“地面层”的阻挡中断掉。因为我们要做的是一个“飞弹”,所以他就会面临一个问题——飞弹可以飞过走路不能走过的地方,那如果飞弹正处于不能走的地方,该如何处理?由此我们进一步思考对策,想到的办法是:如果不能传送,就冒出提示。一开始的版本由于子弹时间非常长(duration为10秒),因此当经过的区域大量是不能走路的地形或者飞出屏幕(总是不能传送的)的时候就非常难受,于是我想不能传送的时候也把子弹销毁,但是因为这非常突然,体验又不是很好。于是有了现在的版本——子弹飞出去,duration只设置3秒,不能传送时提示。

第二个思考就是这个技能看起来是有“状态”的,在发射子弹之前是一个状态,子弹发射之后是一个状态,不同状态下同一个按键指向不同的技能。但事实上这个抽象并不准确——因为改变的只是技能的效果,按照“buff机制”的规则,就是用一个buff在OnCast返回另一个Timeline就可以了。而这个OnCast的时候如何得知子弹是否还在?我们大可以遍历全地图所有的子弹,但是这是低效的,于是我还是追加了一个Bullet.OnCreate的点,在这里我们“由子弹给他的发射这添加一个buff”,这个buff里面记录了这个子弹是谁,而OnCast的时候,如果这个buff存在,就判断这个buff中储存的子弹是否还在,如果没有了,就丢掉这个buff并且发射子弹,如果都有,就尝试传送——通过buff来完美解决问题,而为什么会确定在Bullet中加入OnCreate,是因为这样的“记录子弹”的需求可能并不是只有这样一个技能,也可能在这个点还能干更多有意思的事情。从一个主策划的角度来看,因为可能添加的玩法范围变大了不少,并且确实已经有纯策划想到了用他的实际用途,那么这就是值得开给内容设计策划们这个“口子”的。

思考清楚这些问题之后,我们依然需要捋清楚这个技能需要干些什么具体内容。

需要制作的元素和细节

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图46

在这个技能里面,我们需要做3个buff和1个子弹,其中:

  • Bullet子弹:其实就是非常普通的一个子弹,除了他的Tween是由慢到快的,几乎没有独特之处。而他有一项特别的工作,就是在创建的时候添加一个buff(标记)给发射者。
  • Buff标记:他什么都不用做,只是一个单纯的数据,里面记录了发射的子弹。他的存在证明了“子弹被发射”这个“状态”,并且可以从中直接查询子弹。
  • Buff传送:工作核心buff,在技能被切换到“子弹被发射状态”之后,timeline其实核心是给施法者添加一个这个buff,并且持续0秒,将角色传送到目的地。为什么是0秒,是因为能不能用技能的判断在于OnCast的时候判断子弹的位置,所以一旦有时间延迟,子弹又飞了一段,可能又到了不能落脚的地方就会出现bug。要解决这个bug,就要将传送的判断放进这个buff的OnRemoved,但是这样一来,技能已经被释放,因此逻辑上要做一些调整(而不是技能流程)。
  • Buff施法检测:在施法时判断标记和子弹,来决定使用的是哪一个Timeline,由此把技能分为了“2个不同状态”,实际上只是区区戏法而已。这里会延伸出一个有意思的问题——如果我们把传送的逻辑放在Buff传送里,那么技能就已经成功释放了,技能的消耗已经产生了,但是没有传送过去,该咋办呢?就留给各位自己想想了(办法不是只有一种的)。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图47

炸药桶帧

来自群友太空猴的设计:

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图48

需求分析

这个需求确实是非常清晰了,因为他就是《英雄联盟》里面的船长的技能。在这个技能里面有几个思考的点:

  • 首先是桶子用aoe好还是用召唤一个角色好,我们乍一看需求,实际上是用aoe更好一些,我们只要在OnBulletEnter中判断Bullet的caster和aoe的caster,就能得出是否能打中木桶了。但是实际上在这种顶视角射击游戏中引诱怪物向桶子开火也是一个有意思的设定,于是我用角色作为桶子。
  • “连环爆破”的效果,无非是说桶子被击败了的时候,不能马上爆炸,而是要稍微晚一些,才会有“多米诺骨牌”的效果。而桶子肯定得上一个buff,由这个buff的beKilled触发一个aoe爆炸,只要爆炸的效果在aoe的onRemoved,并且aoe的duration大于0,就能有多米诺骨牌效果了。
  • 木桶应该所有人打他都掉一样的血量,因此我们假设都是1滴血,而桶子因为引线(他是爆弹)在燃烧,所以持久时间也在下降,跟《英雄联盟》中一样,我们也采用OnTick的时候桶子对自己造成伤害来实现。
  • 因为桶子有超过1滴血,而其中一个桶子爆炸的时候要引爆另外一个,也就是一个桶子爆炸的aoe要对范围内其他桶子造成伤害,并且是足以秒杀桶子的伤害。但是我们上面的设计也有桶子只会受到1点伤害,这意味着有一个buff总在把DamageInfo的伤害变成1,这时候我们需要根据伤害来源判断,如果是来自另一个桶子的伤害,则直接变成9999秒杀自己就能实现。

有了思路之后,我们继续来整理要做的内容:

需要制作的元素和细节

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图49

这里我们需要一个角色、一个buff和一个aoe:

  • 角色桶子:一个没有ai也不是玩家操作的角色,他附带了一个buff,这个buff也让他真的成为了“桶子”。
  • Buff桶子被动:这个buff的核心作用是让桶子受到的伤害变化,其次是在桶子挂了以后创造一个aoe来产生爆炸的效果。
  • AoE爆炸:这个爆炸和普通的爆炸有一个区别,就是他会根据范围内的目标是不是一个桶子,来决定这次伤害的攻击者是谁,如果是普通目标攻击者就是aoe的caster,由于创建aoe的是桶子被动buff,这个buff的caster是释放桶子的人,所以相当于释放桶子的人对普通目标们进行伤害;但如果目标是一个桶子,就会把放在param中的桶子(爆炸的那个)的GameObject拿出来作为attacker,这样就会触发桶子被动的beHurt判断中“如果攻击者也是个桶子”的条件,造成9999伤害,从而起到引爆效果。这里有一个极为特殊的处理(坏实现),就是因为角色死亡后会被挂一个UnitRemover,持续5秒后GameObject就被摧毁了,所以桶子造成的AoE爆炸延迟应该是在5秒之内的,不然因为没有伤害者就无法引爆其他桶子了,只是恰好5秒太长了,所以才看起来一切正常。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图50

助力飞弹

来自群友太空猴的设计:

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图51

需求分析

这是个比较有意思的设计,尽管无法验证他在顶视角射击游戏中到底好不好,只能说找不出毛病。因此还是值得做出来看看的,毕竟如果不做出来,光靠文字又如何验证他好不好呢?所以我们进一步开始分析这个需求:

  • 首先关于主手,其实不是这个技能设计的核心,因为主手可以放任何这个“大子弹”之外的子弹。
  • 然后是这个大子弹,他刚开始应该飞得很慢,然后随着被子弹击中,并且只能是自己的子弹击中会变快。但是这里有个问题,“变快”是指我站在子弹飞行的方向背后,对着子弹的尾部开火,让他加速,那如果我站在侧面开火会怎样?因此,这个需求应该是:会根据发射过来的其他子弹,来改变这个“大子弹”的移动规则。
  • 大子弹应该不是打一下敌人就没的,因此他应该有一种碾压过敌人的感觉,所以他不应该在碰触到第一个敌人之后消失。同时我们也不应该使用Bullet来实现这个大子弹,并且为bullet增加一个BeHitByBullet的回调,而是直接利用AoE的OnBulletEnter来实现轨迹变化;用OnCharacterEnter来实现伤害。

设计差不多捋清了,开始接下来的元素整理环节:

需要制作的元素和细节

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图52

可以看到,这个技能实际需要制作的元素,并不如“暴雪更新公告”式文字里说的这么复杂,我们需要做的只是一个AoE,顶多附带一个buff(demo里没有):

  • AoE大子弹:用AoE来实现这个子弹的效果,所以AoE的Tween就是子弹的移动方式了,这个Tween会根据param的forces以及aoe创建时候的初始角度和速度来进行叠加,获得最终的移动力,推动球前进。由OnCharacterEnter来造成对敌人的伤害,如果担心重复伤害,可以添加一个Buff作为标记,判断目标身上有这个buff就不能造成伤害,否则造成伤害并且添加这个标记buff,持续时间就是“多久之后可以再次攻击”,这个手法其实是很常用的。由OnBulletEnter来处理销毁“攻击自己”的子弹,并且给param下的forces添加一个新的Vector3元素。
  • Buff标记:如果需要这个buff,他就仅仅只是一个“空”的buff,除了用来判断,和作为一个计时器存在,他什么都不是,这当然也是buff的主要工作之一,还记得我们开始说的吗——Buff便是原来写死在流程里的if else被合理逻辑抽象化的产物。

[收藏]用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎 - 图53

总结

至此,这套“Buff机制”如何运用与顶视角射击游戏的介绍就完结了,但这对于开发一个顶视角射击游戏来说,只是刚刚开始,因为任何游戏的设计核心都是设计游戏的内容,而不是一个框架或者一套玩法系统就完事儿了。

值得注意的是,如果到这里你已经思真的认真考过了每一段文字的细节之后,不难发现——这套机制其实可以用于任何类型的游戏开发之中,不限于横版动作游戏、赛车游戏、足球游戏等等,题材类型都不限——因为这套“buff机制”的妙处,在于策划设计整个游戏系统的时候,完整的设计了所有的逻辑流程,并且找到了逻辑流程中用来“玩花头”的点(也就是“回调点”),因此这套“Buff机制”并不是一套代码,包括demo中的代码,只是基于这个机制,也就是“心法”而施展的“招式”。

除了设计游戏的系统变得更明确了,包括策划在设计游戏内元素的时候,其思维也将更缺明确抽象,比如不会做游戏的时候的我们都认为“火球术”这个“技能”,他的效果是让角色举起法杖,发出一个火球,当火球打中人的时候造成伤害并且灼烧。现在会做了,思维过程就会变成:

进一步分析需求:根据基础概念,开始逐渐思考实现中的一些细节,由此推论出一些没有被“概括”,或者说我们觉得“大家都懂”的问题。比如这个火球术,他打中敌人后会马上消失吗?还是会穿透敌人?又或者在地上留下火海?这些细节也都是跟具体游戏规则呼应的。

思考实现方式:在完成需求分析之后,就可以根据安排需要做的元素和细节,思考实现方式。为什么策划需要思考这个实现方式?不仅仅是因为这个技能是策划自己设计的,其中妙处可能只有策划知道。更因为在实现的过程中,可能会产生新的想法,比如火球命中(OnHit)之后会造成一个伤害,他会经过一个Damage流程,这个流程中OnHit我们可给目标添加一个“灼烧”的buff,那能不能还能修改角色已经有的Buff,比如角色本身身上有个“滚烫”的Buff(《魔兽世界》中称之为debuff,《激战2》中称之为condition,总之都是buff),其效果是每30秒对携带者造成50伤害,而火球法术,每次可以使间隔缩短一秒,变成29、28……以此类推,最短可以到1秒——类似这样的设计,都是在实际实现的时候“涌现出来”的,哪怕这个项目暂时用不上,记录在自己的“创意手册”里也是好主意。

从设计到实现到再设计到进一步实现……这是一个循序渐进的过程,这个过程中不仅有让游戏的元素变得更丰富的结果,也同时在增加一个策划的脑等、思维严密性。可以说这套“Buff机制”乍一看,只是一套开发游戏程序的独孤九剑,好用、实在、并且可维护性顶天,但它的最大受益者,其实是使用这套机制去设计、去思考、去不断创新的积极进取的游戏设计师们。

“Buff机制”的原则

最后是这个Buff机制的几大原则,也就是这个机制的中心思想:

框架因便于修改存在

Buff机制的核心精神之一,它便于修改便于扩展,当你需要修改、扩展的时候可以不伤筋动骨地就实现。“游戏中的效果就这些”“游戏中回调点就这么多了”——这些都是违背“buff机制”精髓的,我们做一个游戏的机制,设计一套框架,不是为了把设计师的思维约束在我们能想到的范围之内,而是为了让更有天赋的设计师有个更容易发挥才能的舞台。

实现层永不否定设计层

当我们用“buff机制”来设计和实现游戏的时候,就已经位于“实现层”了,在此之前是“设计层”,例如我们设计一个技能,首先这个技能适不适合游戏的调性,他在游戏中倒地强不强,这些都是“设计层”的问题,在“设计层”我们敲定了一定要做这个技能了,那么到了“实现层”,我们绝对不say no,只想办法实现,而不以“这个设计不合理”之类的托词“逆袭”——“只有想不到,没有做不到”是根本精神。

Seeing is NOT believeing

眼睛只是信号接收器,大脑才是处理器,如果眼睛看到什么就是什么(“所见即所得”),便是只有眼睛没有脑子的表现。不论是现实中,还是逻辑世界中,还是游戏开发中,有很多东西我们肉眼看到的现象,未必就是如看到的那么一回事儿,把问题反复思考到本质,可能跟看到的会有极大的不同——“这么简单的火球术,居然要想这么多才能做出来?”好辛苦吗?是的,游戏设计和开发从来就是这么辛苦但这么快乐的。

来自: 用Unity制作一个极具扩展性的顶视角射击游戏战斗系统 - 知乎