状态机?大错特错!动作游戏的动作系统是这样做的
01 什么是动作游戏的动作系统
动作游戏的动作系统指的就是管理游戏中每个角色动作的系统。我们通常理解的“这个动作做完了切换到下一个动作”,属于动作系统的功能之一,毕竟“动作切换”这件事情,不仅是动作游戏才有的,即便是回合制游戏,只要需要角色做不同的动作,就会面临动作切换问题。而相比于非动作游戏,动作游戏的动作系统,还有更多核心的细节,也正是这些细节导致他不能用传统的回合制游戏的状态机的方式来开发。那么,动作游戏有哪些独特的细节呢?
NO.1帧是时间的单位
我们把游戏中的一个Tick认为是一帧,而对于每个角色来说,每个tick当前的“状态”就是一个Frame,也称之为“一帧”,只不过是“角色的一帧”,而非Tick(“游戏的一帧”)。在一个游戏里面,确切地说是任何游戏的逻辑世界里面(而非渲染),帧都应该是最小时间单位,因此游戏世界里的时间概念是“运行了多少帧”,而非“几秒”,在这里外行和内行之间有一个分水岭——外行会认为帧和秒之间的汇率是一个十分重要的数字,因为如果每2帧之间的现实时间差过大,会引起不流畅的问题。但是根本的问题就在于,逻辑世界中的时间单位和显示时间单位未必要有个汇率,也就是说2帧之间间隔了多少现实时间的单位,对于游戏逻辑而言是没有意义的——假如我们把现实时间以秒作为最小时间单位(以int而非float计算),我们吃一口苹果需要2秒,那么2秒到底是不是真的“均匀”的2秒,秒与秒之间时间差真的相等吗这些问题我们下意识的都不会去思考,我们确信他们是相等的,因此在做游戏的时候,我们也要确信2个逻辑帧之间时间就是相等的,因为逻辑帧就是最小时间单位。
帧作为最小时间单位,会导致一些错误的依赖于动画的做法在动作游戏中出现问题。逻辑依赖于渲染的典型表现就是我们把逻辑写在Update里面,并且用deltaTime(这甚至是一个float)去运算间隔了多久,然后算个插值——这样的做法完全搞混或者含糊掉了逻辑帧的概念,因为他一定要把逻辑帧和显示做个运算,以至于一下tick运行了原本应该很多tick做的事情,即便是unity的FixedUpdate这样的凑效果(FixedUpdate实际上是在Update的时候for循环的运行了好几次Tick),也比直接Tick依赖于Update(每个Update是1个Tick)来的更适合游戏。
(Cancel让动作切换变的更连贯了)
那么这个Tick问题导致的错误会是什么呢?我们说“动作游戏中每一帧都是一个不一样的世界”,我可能在游戏的第328帧进入一个仅有2帧的Cancel点,此时我按下对应的按键可以打出Combo,但是如果是Update的模式,很可能这个2帧因为算法而跳过了,比如策划需要FPS是60的游戏,那么错误的实现方式中,我们会把1/60秒当做一个单位,然后如果一个Update走了0.1秒,那么就直接把6帧当做一个Tick处理了,而不是for循环走10次,这样一来我们就完全错过了这个Combo的机会了;同样的在《艾尔登法环》等一些伪动作游戏中,也会发生因为卡顿导致一个Update时间过长,而错过了攻击判定框生效的时间而无法命中敌人。而在类似喀普康最新的格斗游戏《街霸6》等经典的动作、格斗游戏中,这样的QTE型的连招又是很核心的玩法,因此“帧是最小时间单位”是一个要做这样的游戏必须贯彻的基础思想,也是一个游戏开发者对于游戏最基本的理解。
NO.2 “下一帧是什么”而非“下一个动作是什么”
正如上文所说,动作游戏中“每一帧都是不一样的世界”,因此对于每一个角色来说,每一帧的逻辑数据都是和上一帧没有直接的因果关系的,他们之间在逻辑上是并非连续的——上一帧我还在重拳的第12帧,这一帧可能就是升龙的第8帧,因为有一个Cancel关系,导致动作游戏动作切换会有“加速”的效果,并且让玩家十分爽快。而在非动作游戏中,“一个动作(美术上的动作)”才是一个单位,这个动作在什么条件下进入哪一个动作——是非动作游戏对于动作切换的理解。
尽管从抬杠的角度来看,你可以把两者都说成是“符合条件就切换到对应动作,只是能切换到的动作数量多点少点”,但是这两者的根本逻辑是截然相反的——动作游戏是每一帧都在运算我的下一帧是什么;而非动作游戏,是等待一个通知告诉我要不要试试看换某个动作,如果等不到这个通知,就按照预设的“下一个动作”来确定转换到什么动作。
假如说游戏中“切换动作”这个需求简单到真的只是“符合了某个条件就切”,那么这两个做法都是没有问题的,可是动作游戏中,藏着更多玩家无法直观看清,但确确实实可以提会到的“隐藏需求”,比如《街霸6》中的Combo等。
这些乍一看“通知系统”也可以做到,但实际上存在着非常多的隐患,在下文中,我们通过实现的思路就能看清楚这里“说不清道不明”(所以才有这么多人搞不清楚)的细节。
这里要顺带提一下一个名词解释——Cancel:在动作游戏中,假如我(角色)当前处于A动作的第x帧,这并不是A动作的最后一帧,此时我发动了另外一个动作B,结束了动作A,并且从B动作的y帧开始继续动作,这样一来A动作切换到B动作,但是动作长度却低于A动作长度+B动作长度,以达到了一个“快速切换”或者“加速”的效果,这在动作游戏和格斗游戏领域中被称为“Cancel”。而Cancel本身也是动作游戏动作切换“手感好”的核心要素,这是用状态机几乎无法实现的(不是说硬凑不出来,就看平白多了几百倍的工作量能不能接受吧)。
NO.3 同一个“状态”有不同的动作
通常当我们没有经验的开发人员乍一想的时候,都会认为一系列动作可以对应为一个状态,而一个状态也可以对应多个动作,只要达到“一个状态有丰富的动作可选”这个标准,状态机就万无一失了。这个想法在即时回合制游戏,比如diablo系列、魂系、无双系列、FF7Remake等游戏中是说的通的,因为他们本质上都是一个时间自动推进的回合制游戏(ATB),他们都可以以“一个动作”为一个逻辑单位,不涉及非常细节的动作切换,因此你完全可以定义出一些状态来描述动作,比如“普通攻击”,他可以是平砍3连、4连、5连什么的,几连都是平砍,并且顺序一定是这样的——1后面是2后面是3后面是4,顶多有个buff可能让你1235之类的顺序,而从游戏设计的角度出发,策划本身想的也是“普通攻击”就是“普通”的和技能不一样。
但是动作游戏他不太一样,动作游戏设计思想上本身就是角色每个动作是等价的,这个动作是不是防御动作不知道,有防御框或者完全没有受击框的都可以是防御动作,有攻击框的都可以是攻击动作,同样的一个动作也可以同时有攻击框和防御框;而必杀技的设计,也是从“基础的拳脚功夫之外的一些特殊动作”而非回合制游戏的“技能就是比较强大的攻击方式”的角度出发设计的。因此在这个设计思路下,动作之间的切换就有更灵活的需求,比如:
你很难定义其中那些动作是普通攻击。而在传统的动作游戏中,还有这样一个细节设定——即当我们挥空的时候,角色会保持“3连普通攻击”的第一段,只有命中了,才会进入第二段、第三段,这也是即时回合制游戏不好做的细节(但是动作游戏要做到即时回合制这样打一下就加一段是十分简单的),下文会详细说到。
(只有打中人了,才会进入第二段连招)
而除了攻击之外,还有下文会专门提到的受伤动作更是如此——是一个很典型的不同受伤动作本当是“不同状态”的存在,那假如所有的动作都是不同“状态”,还如何用有限状态机(FSM)?那思路逆转过来,是不是用状态机来思考本身就是个错误?
02 开发和设计时的具体问题
说完了理论部分,接下来我们就要实实在在的来看一下,动作游戏的动作系统是如何开发的了,在这里你会真切的感受到他与即时回合制游戏的根本不同。老规矩,我们(设计游戏,就要)从数据结构开始。
NO.1必要的数据结构
角色身上必要的相关数据
在一个角色身上,我们至少要有3个相关数据:
●当前动作帧(Frame):因为“帧是最小逻辑单位”也因为“动作游戏每一帧世界都在变化”,因此这个角色当前所处的“状态”就是一个动作帧。除了逻辑运算安全依赖于这个数据,渲染也会有相关的逻辑保证动作不超过这一帧。
●掌握的动作(List<Action>):一个角色会做的所有动作,从基本的站立、走路到所有的必杀技,只要是这个角色能做的动作,就应该被记录在角色身上。由于一个角色的动作是可以动态增删的,比如在某些条件下可以学会一个新的动作或者遗忘一个老的动作,所以还是使用List比较好,不然应该是一个array(本文中相关的内容都是同理,使用List的地方都是因为数据的个数本当是动态的,而非固定的)。
●碰撞记录(List<HitRecord>):在当前动作中,我们打中过谁需要有个记录,因为每一帧都可能发生我们碰撞到(打击了)同一个角色,但是我们并不能每一帧都有效的攻击,因此我们要剔除掉已经打击过的对象,所以要记录一个打击信息,来记住我们最近打过了谁,还有多久就能再次打中。通常来说这个列表在角色更换动作的时候会被清除。
一个动作的数据(Action)
由于是一个动作游戏,所以动作数据并不会非常复杂,他更像是一个“包裹”,主要的作用是把一些帧打包在一起,从而解决一些策划配置和理解的问题,毕竟游戏最后是需要靠人去做的,我们不能太过程序化了,因此这个结构的存在,更确切的说法,就是一个“语法糖”。一个动作数据的意义很好理解,就是一个角色可以做的每个动作需要都是一个动作的数据,比如轻拳、比如起跳等。一个动作至少需要这些数据:
●动作名(string):每个动作都可以有一个名字,之所以是动作名而不是动作id,是因为不同的动作的动作名可以完全相等,这是允许的用法,甚至应当巧妙地去运用好。
●动作帧(List<Frame>):一个动作是由若干个动作帧组成的,这些动作帧的“自然下一帧”都会是列表的下一个动作帧,除了最后一个动作帧之外。动作帧是一个动作的灵魂所在,因为原本就不需要“动作”这个概念,只需要动作帧即可,但是我们这里还是做了一个“语法糖”,事实证明,有“动作”概念可以让工作复杂度级数次方的下降。
●自然的下一个动作(Action):即这个动作的最后一帧之后的下一个动作的第一帧。比如打拳打完了会回到握拳站立动作,这非常好理解,之所以单独列一条,也是因为本身这就是“语法糖”,所以要保持“甜味”。
●动画信息(string等):动画(animation)和动作(action)是不一样的东西,动画是美术制作的,而动作则是一个逻辑数据。在一个正常的动作游戏中,同一个动画可以是完全不同的动作,但是同一个动作不能有不同的动画,因为动作逻辑数据是严肃的。用“状态机”的思路来理解,就是“一个状态有多个动作(此处动作实则为动画)是不允许的”。
●输入指令(Command[]):尽管在传统的动作游戏中,我们都是每一帧设置指令的,但是实际需求来看,每个动作有一个指令列表才是对的。这里指令使用数组的原因是一个动作的指令未必是单一的,甚至可以开放给玩家配置,比如玩家设置下前拳或者下前脚这样的指令都可以发动某个动作,这是允许的,但是要注意,这里的指令并非“键盘Q键”“手柄三角键”之类的具体按键,他只是一个命令集。
●CancelTags(List<CancelTag>):这个动作可以Cancel别的动作的信息,这通常逻辑上来说,都是跟动作比较好的,毕竟我们说Cancel本身就是“一个动作做到一半切换另外一个动作”,所以这“另外一个动作”能不能是这个动作,就用Action.CancelTags来决定,是一个很舒服的设计。
●基础优先级(int):动作的基础优先级,因为我们不管做什么样的游戏,只要涉及到动作切换,一定会有当前动作同时可以切换到若干动作的局面,但实际上我们真正能切换到的只能是一个动作,毕竟一个角色一个时刻只可能做一个动作,这是基础的性质无法改变,所以就有了“从若干动作中挑选出最合适的”这个问题,那么筛选动作的最好方式是根据优先级冒泡,当然这里只是一个基础的优先级,动作游戏里可并没有那么简单。基础优先级也是用来描述动作之间在没有任何条件的情况下的优先级,比如死亡动作的基础优先级会高于其他大多动作。
●打击信息(HitInfo):原本这个信息应该属于Frame,但是由于动作这个语法糖,使得这个信息更简化的出现在动作数据里面了,这是在打中目标之后做记录的一个依据,也就是说我要记录我打中了目标还能打多少次等内容的。
●其他:可以根据扩展玩法来设计一些必要的属性,比如我们有替换技系统,那么技能就该有个名字好让玩家有个“官方称呼”方式。当然除了这些“新增”项目之外,如果我们在同一个动作中的每一个动作帧都会去填写一样的值的数据,也可以放在动作里面,比如我们认为整个动作的“攻击力倍率”属性都是0.7(即怪物猎人中的动作值的概念),不用精细到跟帧走,那么就在动作层设置“攻击力倍率”属性就好了。
打击信息(HitInfo)与碰撞记录(HitRecord)
为了避免同一个动作连续好几帧都命中同一个目标,造成连续伤害(很可能一秒就发生60次碰撞),我们需要一个碰撞记录来避免连续被命中,碰撞记录必要的数据有:
●目标(Character):这条记录证明我当前的动作打中了谁,也就是继续保持这个动作,我要再碰到这个目标的时候,就要看看这条数据允许不允许我再次打中他了。
●还剩多少帧可以再次打中(int):这是一个剩余帧数,因此每一帧自然-1,当这个值<=0的时候,如果“还能打中几次”大于0,就能再次打中目标,并且降低1次“还能打中几次”。
●还能打中几次(int):一般来说一个动作都只能打中目标1次,当然也有例外,为了让玩家爽快,一个动作可以允许连续命中好几段,配合“还剩多少帧可以再次打中”就有了这个效果,经典的街机游戏《吞食天地2》中玩家使用草薙剑砍人,就会有一刀3段伤害的设定。
为了对应产生碰撞记录,我们在动作里面会有一个打击信息,这个打击信息是用来“改写”碰撞记录的规则,它包含的必要信息有:
●同一目标命中次数(int):当创建(而非修改)一个目标(character)所在的HitRecord的时候,“还能打中几次”就会等于这个值。
●命中间隔帧数(int):这是为HitRecord的“还剩多少帧可以再次打中”赋值的。
动作帧的数据(Frame)
动作帧是动作游戏的核心数据,尽管我们有了“动作”这层“语法糖”,但是逻辑上“动作帧”依然还是动作游戏中角色的“动作单位”,因此在角色属性中“当前动作帧”依然是这样一个结构的数据。动作帧需要的关键数据有:
●动画关键帧(float):拿Unity来举例,动画是在Update上走的,用的是一个deltaTime的方式(即上面说到的每一个Update的Tick去算插值),由于动画是给玩家看到的最基本也是唯一的信息,所以动画表现出来的当前状态是十分重要的,而动画因为是在update,与在FixedUpdate上运行的逻辑可以看做是2个不同线程上(尽管unity实现并不是2个不同线程这样的),所以存在一个“同步”问题,也就是说,逻辑需要告诉动画,你最多可以播放到哪儿,如果我逻辑还没有去下一帧,那么你动画播放到这里就应该“卡住不动”了。
●循环帧数(int):一个动作帧的下一帧直接是自己这一帧的次数,这个循环帧数通常都是1,但是格斗游戏中的受伤动作,是动态算出来的。这正是《街霸》这些游戏中玩家可以看到一些+6、-30之类的数据的来源之一,正因为受伤动作中某些Frame的循环次数增多了,所以动作“变得更慢了”或者“硬直更久了”,以至于这个(玩家理解的)“加减帧”数据发生了变化。
●Cancel信息(List<CancelData>):当角色处于这帧的时候,允许被那些动作所Cancel,这是与动作中的CancelTag对应的数据,在后面会详细说到。
●身体碰撞框(Rect[]\Box[]):2D游戏中通常是Rect(便于aabb计算提高性能),3D游戏中是Box,这是用于和地形做碰撞的。
●攻击框(AttackHitBox[]):角色的攻击框,在每一帧,角色的攻击框数量、大小等等都是可以完全不同的,但是这一帧一定有若干个(或者0个)。
●受击框(HitBox[]):角色的受击框,每一帧也会有0或者若干个,任何受击框遭遇到任何攻击框都可以看做是“碰到了”,这个概念会在后面详细说到。
●下一帧(Frame):当完成了循环次数之后,这个Frame的下一个Frame是哪个,毕竟我们需要动作继续播放下去的,只是这个Frame仅仅是一个“候选人”,他还要跟Cancel的内容去竞争的,也就是有Cancel的动作的时候,这个“下一帧”就没必要加入到“候选人”列表中去了。“候选人”列表是个什么概念,会在后面的“动作切换的逻辑和配置”一章中详细讲解。
●游戏玩法相关数据:玩法相关的数据,包括前面举例说的“攻击力倍率”等,都是看情况放在Frame或者Action中的,甚至精细一点还会放在攻击框里面,我们这里就按照放在攻击框里面来做,所以会在下一章的“攻击框与受击框”中详细说。
攻击框与受击框(HitBox)
在动作游戏的实际开发中,只有攻击框和受击框2种框,他们有共同点,也可以都派生于“框”(如果你很喜欢oop的话),也有各自独有的东西。当然这里要强调一个关键,所谓格斗游戏中的“抓取框”和“被抓框”、“不意打”“锁骨割”对下段、动作游戏常见的“防御框”“格挡框”“JustDodge框”等,都只是一个人类的逻辑概念,事实上他们在开发中,只是配置了不同的参数所致的。那么我们接下来就看看“框”共有的参数:
●框体(Rect\Box):这个框体相对于角色坐标的偏移量,2D中通常都是Rect,3D通常都是Box,当然你也可以选择自己喜欢的,比如用圆形也没什么不行,具体还是看游戏设计如何。
●自身动作变化信息(ActionChangeInfo):当这个动作的优先级高于对手框(攻击框的对手框是受击框,反之,受击框的对手框也一定是攻击框)的时候,那么我自己会如何改变动作,就取决于这个信息了;如果对手动作的优先级更高,那么我就会变成对手动作的“对手动作变化信息”指向的动作。这看起来虽然有点绕,但事实就是这样工作的才正确。
●对手动作变化信息(ActionChangeInfo):对应“自身动作变化信息”,如果我这个框的优先级更高的时候,对手的动作变化依赖于这个数据。
●优先级(int):A角色与B角色的一次碰撞中,很可能会产生若干个A的攻击框碰到B的受击框的信息,当然也可能同时有很多B的攻击框碰到A的受击框的信息,我们将一个角色的攻击框碰到其他人受击框(比如A的攻击框碰到B的受击框)的信息列表冒泡,先根据攻击框的优先级冒泡,攻击框优先级最高的那个可能同时还碰到了很多受击框,此时冒泡出受击框优先级最高的,就是这次碰撞的“有效碰撞信息(双方的框)”。通过这两个框(攻击方优先级最高的框vs它所碰到的受击方受击框中优先级最高的)的优先级对比,得出如果攻击框的优先级>(而非>=)受击框的优先级,则采用攻击方的“动作变化信息”(自身和对手那两个),否则采用受击方的。
●对口(组)标志(string[]):并不是每个攻击框对每个受击框都有效的,比如攻击框对“被抓取框”,只有双方标志有共同的值的时候,才可能碰上。当然我们也因此可以定义出一些标志碰到另一些标志可以忽略优先级来让攻击框一定主导“动作变化”(锁骨割等)。
而攻击框和受击框各自还特有一些属性,他们都是根据游戏内容设定来的,攻击框特有的参数常见的有:
●攻击力倍率(int):这个攻击框碰到受击框的时候,攻击力怎么算。
●卡帧(int/int):攻击有效时,双方进行卡帧的时间,攻击方和受击方卡帧时间不同,具体会在下文描述。
●硬直(int):受击方除了卡帧,还会硬直,也会在下文中进一步详细描述。
●吹飞力(int):这个攻击框有效(优先极高)的时候会把目标直接击飞多少,对抗对方的“沉重度”,而对方优先极高的时候,则对抗稳固度。
●击退力(int):与吹飞力相似,只是作用在水平坐标而非垂直坐标变化上。
●硬直帧(int):同样是对抗稳固力和沉重度,是对方动作硬直的加帧(这个规则得策划定义,通常来说,是一个动作中第一个“循环帧数”>1的帧来设定更大的“循环帧数”值)。
受击框特有的参数常见的有:
●防御力(int):这个框受击时候的攻击减伤能力。
●硬直抵抗(int):对抗攻击框的硬直属性用的,具体算法各家游戏不同,可以根据自己的游戏需要来自行设计公式。
●稳固力(int):当这个框优先极高的时候,对抗硬直帧、吹飞力、击退力的参数。
●沉重度(int):当攻击框优先级高的时候,对抗硬直帧、吹飞力、击退力的参数。
到这里,我们不难看出——所谓的“格挡框”,不过是一个优先级高、稳固力高的受击框而已,并不需要特殊处理或者说特别定义一种叫做“格挡框”的东西。
动作变化信息(ActionChangeInfo)
动作变化信息是用来在碰撞发生后,决定双方动作变化的信息,这个动作变化是被加入到“候选人”中的,也就是说他未必最终一定会变成这个动作——你可以想像一下,我一刀砍出出去同时命中了3个目标,2个目标光头被砍,一个带着超级钛合金头盔,因此我弹刀——前两个都是我的优先极高,所以我保持动作了,最后一个是因为他的优先极高,所以我得听他的切换到弹刀动作。这就是这个动作变化信息要用的地方,它的核心数据有:
●变化类型(枚举):保持现在的动作,或者变化到某个指定名称的动作。
●变化为动作(string):如果类型是变化为指定名称的动作,那么如果角色存在名字符合这个的动作(可能会有好几个同名的动作,每一个都要进行一次判断),就会判断是否被允许Cancel(具体会在Cancel信息一章说到),如果允许就会被加入候选列表(这里也是动作名称秒用的关键点之一)。
●起始帧(Frame):如果变化为那个动作起始帧是第几帧(指向某个Frame)。
●临时开启CancelTag信息(List<(CancelTag, int)>):变化动作后,临时开启若干帧(int)的CancelTag,这里是做到“3连普通攻击第一下命中了才会出第二段,否则保持第一段”的关键,而《街霸6》中很多的连招,也都是通过这个来实现的。值得注意的是,这是在动作切换之后才临时开启的,这个时序不能反了(尽管看起来反了也没事儿)。
到这里我们不难看出,一个动作如果前几帧的受击框优先级很高,且“自身动作变化”为一个可以快速反击的动作、之后几帧变成重重的格挡住的动作,就有了“Just Block”的设定了,也是完全不用特殊处理什么“防御动作开始后0.1秒受到攻击”,不需要特殊写个if else。
Cancel信息(CancelTag\CancelData)
CancelTag和CancelData是决定2个动作切换的一个关键信息,他们之间的关系就像是锁跟钥匙的关系,有一种对应关系,并且因为这种对应关系产生联动。在一个动作的动作帧本身有一些CancelTag,他们就像是锁孔一样,本身没有直接的作用,但是因为其他动作有CancelData,这些有CancelData的动作就仿佛钥匙能开锁一般的,和这个动作帧产生关联,最后可以形成“动作的切换”。所以动作游戏的“动作切换”的关键所在,就是这个CancelTag和CancelData的配置。CancelTag在每一帧的信息上,他必须要有这样的数据:
●Tag(string):这是一个和CancelData的Tag对应的东西,只有当CancelData的Tag中包含这个CancelTag的Tag,他们之间才会有Cancel关系,也就是那个动作可以Cancel掉当前帧。
●优先级修正(int):如果动作可以在此帧进行Cancel,那么那个动作的优先级会被加上这个值进行修正。并不是每个动作的优先级总是一样的,比如在重拳之后,完全允许重脚的优先级是最高的,以形成一个独特的连招,因此我们会需要临时调整一些动作的优先级,以确保他能在这个环境下优先被释放。
●现在激活(bool):我们当然允许策划事先配置好所有的cancel情况,但是一些cancel情况可以暂时不激活,只有当发生碰撞(ActionChangeInfo中)激活,或者走一些其他的玩法,比如通过角色喝了在游戏中啤酒之后激活他们,这可以看游戏具体设计以及实现方式需要不需要。
相对于CancelTag,动作的CancelData需要的信息主要有:
●Tag(string[]):和CancelTag中的Tag对应,之所以是数组,是因为一个动作可以Cancel的动作帧可能有很多种,这具体看策划设计,但是至少得有1种,不然这个动作要这个信息就没用了。
●优先级修正(int):当这个动作走这个CancelData去Cancel别的动作的时候,动作的优先级除了加上CancelTag的优先级,还要加上CancelData的优先级,进行进一步精确地调整,既然是int,自然是可加可减的,让策划有更灵活的用法。
●指令变化(Command[]\Command[]):一些动作在某些条件下Cancel其他动作,可以有更多或者更少的指令,以便更方便玩家使出来或者要求玩家更精确的操作才能使出来,这其实是一个手感调整的数据,但是既然我们做动作游戏,跟大菠萝最大的区别就是手感十分重要对吧。
●起始帧(int):当这个动作Cancel掉之前的动作之后,是从第几帧开始的。
●临时开启的CancelTag等信息:实际上这里想说的是还可以定制很多数据,主要看游戏设计的细节需要了。这里之所以提这个,是因为这是一个常见的需求,但是虽然常见,要驾驭好又很难,所以只是提一嘴——如果是走这个CancelData去Cancel别的动作的话,可以临时开启或者关闭一些这个动作下的一些帧的CancelTag,来调整连招派生关系和手感,这是一个细腻活,但是做好确实让玩家体验会好很多,只是现代商业化游戏花这个时间和心思从性价比来说可能并不值得,毕竟你至少需要有很好的游戏策划才行。
到这里,不得不在提醒一句——每个动作帧的CancelTag都是一个数组、每一个动作的CancelData也是一个数组,所以他们的关系可以并不是简单的一一对应的,可以多对多,善加利用,就可以做出灵便性极好的连招来,根据自己喜好和感知打出行云流水的连招来,对于玩家来说是一件十分爽快的事情。
输入指令(Command)
这是一个非常“搞”的数据,实际开发交流中,我发现和不少团队的人交流这个的时候是存在一定理解障碍的,值得单独写一下。首先我们还是说他的数据有哪些核心的:
●指令(string):这里的指令包括且不限于“前进”“蹲下”等指令,他们虽然(从玩家角度)看起来和按钮是一一对应的,但实际上对应关系也并非这么的绝对,所以是需要把来自设备的输入(比如unity中的Input.GetKeyDown()等)进行“翻译”,转化为这个,而翻译的过程依赖于Config中(一些游戏会开放给玩家的输入设置)的配置。这里会有一些弱耦合,但关系不大, 比如在街霸中,我们的角色在左侧,按下方向右,得到的是“前进”,角色在左侧,按下右就是“后退”了。
●按下(bool):是按下还是抬起,这是标准的输入都有的2个状态,这很好理解,一般设备里只有down和up,而click和双击、holding这些的都是“糖”,通常需要自己实现,通过时间戳判定。
●时间戳(int/float等取决于设备和环境):按下的时间戳,根据系统(引擎)给你的数据记录就行了,只要统一单位就没问题,也可以用逻辑帧,第几帧得到的这个输入也可以,他的作用是查询的时候用。在一些十分讲究输入的游戏(大多为格斗游戏,比如《街霸》)中,每帧的CancelTag里甚至有“检查指令时间范围”,即只有符合这个时间范围内的输入才是有效的,所以怎么利用这个记录做手感,是一件细腻活,他也会影响到连续出招的手感。
这里最“搞”的地方,就是在游戏中,并不是和软件一样的“按键触发”方式,当然如果是回合制游戏,比如即时回合制的魂系游戏,“按键触发”是没问题的。但是在动作游戏中,是个每个Player(player不一定是玩家操作的,这是一个游戏专有名词,特指“几号玩家”,操作“几号玩家”的未必是真人,也可以是ai)的输入都有一个List<Command>的,每次输入会添加入这个List,定时也会清理。而每一帧实际都是检测这个列表来决定是否发生动作的,这个细节具体在下一章“动作切换的逻辑”中会详细提及。但是无论如何请记住一个要点——他不是“按键触发”,而是“每一帧检查”的用法,要不别说做不了搓招,就是手感都会有些许僵硬,当然现代玩家可能感觉得到说不清楚,也就只能“不在乎”了。
NO.2 动作切换的逻辑和配置
说完数据结构之后,就是我们的核心逻辑,也就是这个“动作游戏的动作系统”最关键的内容——动作切换的逻辑了。在这里,我们还是要提一句“动作游戏每一帧世界都是变化的”,因此,我们首先要把视角定格在“每一帧”来看这个问题,那么在每一帧,每个角色发生了什么呢?他的流程是这样的:
在这个流程里面有几个细节:
●我能否切换到一个动作的某一帧可以完全依赖于Cancel关系:我们可以看到实际上我们可以进入下一个动作候选列表的动作,大多是通过Cancel关系筛选出来的,因为我会的动作的CancelData和我当前帧的CancelTag能对上,这个动作才有可能被加入到Cancel列表中,当然一般“主动触发”的,但是受伤动作,依然也可以走这个流程,即外部系统告诉我要切换到受伤动作,相当于增加了一个Command到CommandList,这时候我的Cancel信息里面当前帧有3种受伤动作,但是因为command对应上和优先级筛选,最后选出了一个“最合适当前情况”的受伤动作。
●Command是一个List缓存在那里,所以才有了“提前搓招”和“模糊输入”的可能性:就如流程里面所示,因为这个动作在我的Cancel列表中存在,所以这个动作可以Cancel我的当前帧,所以我才会去检查他有没有可能成为我的下一个动作,而这里的“有没有可能”主要看的就是Command是否发生,而Command是否发生,则是去看CommandList,比如当前帧,可以被轻拳升龙拳Cancel,于是我就回去查询CommandList内,最近有没有前进按下、蹲下按下、前进按下、轻拳抬起这个队列,即使这个队列中有许多穿插,比如在列表里按照输入时间顺序是:【前进按下】、轻脚按下、轻脚抬起、【蹲下按下】、前进抬起、【前进按下】、中拳按下、【轻拳按下】——只要在这个顺序中,我们能匹配到对应的指令,就可以认为这个动作是可以加入到“候选列表”的,当然我们可以根据这个顺序中的位置设计一个“亲和度”来修改这个动作Cancel时候的优先级,进行增加或者减少,以做到更合适的手感,比如这次输入的“亲和度”就远不如【前进按下】、前进抬起、【蹲下按下】、【前进按下】、前进抬起、【轻拳按下】来的高对吧。
●所以《怪物猎人》中斩斧的斧形态和剑形态需要2个“状态”吗?如果按照魂系玩家的理解,应该有个状态来证明我现在是用剑还是用斧头,但实际上根本不需要,因为一个“斧头转剑斩”的动作,自然地下一个动作就是“剑形态站立”动作,而这个动作可以Cancel“斧形态站立”动作,就这样一个动作的“前后关系”就自然地出现了玩家肉眼看见的“状态”了,但实际实现的时候,压根就没有去做“追加一个状态”的特殊处理。
NO.3如何用UE的Montage或者Unity的Animator做动作游戏
这是一个十分现实的问题——毕竟我们绝大多数团队必须依赖于Unity或者UE才能开发游戏,那么如果我们要用Unity和UE开发游戏。我们知道Unity里主流的对于动画处理的是Animator,他是一个动画状态机;而UE里面使用的则是角色的Animation Blueprint去调用BlendSpace和Montage来做到类似Unity的Animator对于Blendtree和State的管理。这些做法似乎和刚才说的动作游戏是背道而驰的?那如何使用这些引擎来开发动作游戏呢?
首先我们要明确一点,就是我们反复强调的——动画(Animation)和动作(Action)是两回事儿,动画是动作的一个属性,一个动作肯定有一个动画,但是同一个动画,可以是多个动作的动画属性的的值。简单地说actionA.animation = AnimationA,acitonB.animation也可以等于AnimationA。
Unity的Animator,只是一个把Animation串联起来的东西,我们使用它的最重要的价值就是动作融合的编辑器;UE的AnimBP(Animation Blueprint的简称,下同)也是干这个的,而UE的Blendspace和Unity的Blendtree也都是动画融合工具,UE独有的Montage则是把一个动画的Timeline(逻辑的Timeline)拿出来,允许定义一些事件(AnimNotify和AnimNotifyState),他也是一个编辑器作用。
就拿unity举例,我们可以定义一个角色的GameObject下用的Animator用的是哪个具体的Animator,这就是定义了角色所有的动画而已,而这个Animator中所有的Param,也就是那些Trigger、bool等东西,都只是“操控动画”播放的参数——这也是Animator存在的本意,我们使用它的时候,调用这些SetTrigger函数的目的是制作动画之间的跳转,而这些跳转无非是吃到了动作融合而已。但是谁去调用这个Animator.SetTrigger之类的函数?就是我们说的“动作游戏的动作切换系统”,在动作切换发生的时候,我们根据Frame信息中的动画信息(可以是set那些trigger等的组合),来对Animator进行操作就可以了。
而UE提供的Montage的编辑,我们可以把AnimNotify和AnimNotifyState,看做是“关键帧”的编辑,也就是我们可以拉一段AnimNotifyState来作为CancelTag的编辑,这样策划可以更可视化的去编辑,而这个AnimNotifyState是派生自AnimNotifyState的一个类,比如就叫CancelTag。
因此,我们在使用Unity和UE做动作游戏的时候,最重要的是一点——就是我们要调整心态,并不是说我们直接用Animator或者AnimBP去做角色的动作状态机,他们只是动画管理器,这里所谓的状态,也不是FSM的“状态”。我们使用这些只是当做工具,和“动画驱动器”,只要调整好这个心态就能很好的使用UE和Unity开发动作游戏了(当然,如果用UE开发动作游戏,你还得克服一下UE的那些底层玩法逻辑代码带来的诸多麻烦就是了)。
NO.4 挨打了该做哪种受伤动作?
挨打动作这个问题,其实是一个很经典的游戏开发问题,动作游戏的挨打动作问题,远比非动作游戏来得重要,毕竟动作游戏对于玩家来说,每个动作是什么都是很重要的事情。
在绝大多数动态的(即时制)游戏中,受伤动作是一个权重非常低的动作,因为受伤了不能打断角色的行动,比如在《魔兽世界》里面,我一个法师在读条,虽然受到来自别人的普通攻击,会有进度条被延缓之类的逻辑效果,但是这并不会因为说我做了一个受伤动作,动作变了,就得重新读条了。所以一般在这样的即时回合制游戏中,由于要做别的动作,就会放弃播放受伤动作,受伤动作只是一个表演,只有在“空闲”的时候才会播放,比如diablo4中野蛮人开始使用先祖之锤了,无论周围多少敌人在打他,他都一定会做完锤下去的动作,而不会播放受伤动作。
(只要我在做动作,那么敌人的“普通攻击”就不会让我做受伤动作来打断当前动作)
而当我们要把一个即时回合制游戏的受伤动作重要级抬上来的时候,问题就暴露了——由于即时回合游戏的逻辑上一个动作是一个回合,因此当受伤这个动作成为一个回合,并且在即时环境下,他的回合时长属性就成了一个尴尬的事情——因为受伤动作是一个回合,他会打断之前正在做的动作(回合),比如我正在攻击动作,由于是即时回合制,世界不是每一帧都在变化,而是我这个动作到了“攻击有效帧”的时候才会产生伤害等效果,而攻击有效帧是由box碰撞得来的。因此,很有可能发生我正在攻击动作,但是碰撞还未发生(也就是玩家俗称的“前摇”),即有效伤害帧还没到,我就变成了受伤(开始了没有攻击判定的新回合),而当受伤动作结束我再次攻击,由于受伤本身有时长,导致对方的进攻依然比我快,所以我又挨打又受伤了,那么这样一来,我永远也没法比对方先出手,几乎是没法还手的——这是一个即时回合制游戏经典问题,所以魂系等游戏里面采取了玩家一侧用能量条限制连续进攻、AI一侧通过AI脚本和动作大多直接击飞击倒来避免这样的情况发生。
因此,当我们真正动手开始做的时候——因为游戏要落地,就不是做一个简单的对空气或者木桩子张牙舞爪的demo了,所以这些细节都会被提上日程,这时候就会发现,其实受击动作是一个十分棘手的问题,尤其是我们用了即时回合制游戏概念的“状态机(FSM)”来开发的时候,魂系游戏中那些让人并不舒服的设定就成了不得不考虑的“补丁”了,至少他没让问题严重化。
那么动作游戏的受击动作咋做?我们要从这些细节问题出发来做:
问题一:下一个动作是哪个?
这并不是一个简单到“我有一个角色8方向受击的动作,只要做一个blendtree,把受击的方向作为参数就好了”的问题。如果我们用非动作游戏的概念来思考这个问题,可能会想到的是——受伤之后要有个受伤状态,想的再仔细一定,这些就是不同情况下受伤动作不同,给个if else就解决问题了。
但是动作游戏的受击动作,未必是受伤动作,或者说精细到哪个受伤动作。我们说“动作游戏世界每一帧都是在变化的”,因此“每一个动作都是等价的,不存在这是什么动作的人为概念”——也就是说,我们理解的受伤动作和攻击动作,在动作游戏里都是一个动作,由此,动作游戏中一个角色受到攻击,未必是我们理解的“受伤动作”,比如在《街霸6》中,也有气势架招等动作,受击之后会做个架招成功的动作——注意这个细节,是气势架招动作,受到攻击变成了气势架招成功动作,气势架招成功动作自然的下一个动作又是气势架招动作,这是两个动作,不是一个动作(状态机思维下,这会被理解为一个状态,比如防御状态),而在《街霸6》中,气势架招存在Just Block的情况,Just block跳转的会是另外一个动作,所以一共是3个气势架招相关的动作,尽管他们可能用的只是2个动作,但是背后的逻辑数据的值完全不同。
(气势架招动作至少有3种:架招、架招被打中、just block)
我们对此做出抽象,就可以得出一个说法——在动作游戏中,受到攻击,确切地说是受击框被攻击框碰到了之后,双方都可能发生动作的变化(不变也是一种变化)。那么进一步来看在,这个问题就是这样的:
●什么攻击框碰到了哪个受击框:这在上文已经有提过,我们将一个角色这一帧所有的攻击框先按照优先级冒泡,第一个有碰到另一个角色受击框的攻击框拿出来,将里面碰到的受击框按照优先级冒泡,由此得出“最高优先级的攻击框,碰到的最高优先级的受击框”就是这次“攻击”的攻击框和受击框。
●下一个动作怎么变:根据攻击框的优先级对抗受击框的优先级,得出谁的动作“占上风”,我们认为优先级高的那个框(所代表的动作)是“占上风”的。“占上风”的框中的自身、对手两个动作变化(ActionChangeInfo)就会告诉我们双方应该怎么变化动作。
●但是ActionChangeInfo中只有一个目标Action.Name:没错,正是如此,比如只有"hurt",但如我们所说,一个动作(Action)的Name并非Id,也就是一个角色身上可以有几个甚至几十个名字为“hurt”的动作对吧。那如果我被ActionChangeInfo要求做“hurt”动作的时候,我就会去看当前帧(Character.Frame)中的CancelTag,然后遍历我所有名字为“hurt”的动作,一旦这个动作可以用来Cancel当前帧,就会加入候选列表。最后这帧逻辑末尾的时候,会根据候选列表冒泡优先级最高的那个作为要做的动作,所以“下一个动作”是自然地,谁都不知道他具体会是哪个,一切都是运行时根据算法得来的。
这样一来,是不是我当前所处的帧,也会决定我到底做什么样的受击动作了?没错,正是如此。
问题二:动作决定了时长了吗?
当“下一个动作”选出来了以后,我们是不是就只管做下一个动作就完事儿了?这是一个做非动作游戏的概念,在动作游戏中,碰撞了还有一个细节,分别是卡帧(HitLag)和硬直(HitStun)。
●卡帧的意思是:当双方发生碰撞(攻击生效)的时候,双方都会进入卡帧阶段,保持当前帧(切换到下一帧之后的当前帧)一定帧数,通常攻击方和挨揍方的卡帧时长不同,攻击方卡帧会久一点,这不仅是为了打击感,也是为了一种平衡性。
●硬直的意思是:受击方要做的下一个动作,并不是下一个动作本身约定的时长,而是会通过下一个动作中的可循环的帧延长帧数来做到,因此一个动作游戏中。
由此可见,动作游戏中发生命中的时候,每个角色下一个动作的时长是会变化的,是动态算出来的,而根据算法的不同,效果也会有所不同,在一些做的不太好的动作游戏中,遇到大量敌人在同一帧被我同一个动作打中的时候,我反而会硬直很久(源于卡帧)以至于漏出破绽挨打,当然你也可以说“就是这么设定的,习惯了就好”。
当然具体的卡帧和硬直,还是根据攻击框和受击框提供数据、策划设定的算法得出来的,因此这也是一个调整游戏手感的重要环境,同时一些动作也会因为算法在挨打的时候完全没有卡帧,这是允许的,比如just block动作。
问题三:反击和追击怎么弄?
看到这里,相信大家已经理解了——“动作游戏中,每个动作都是等价的,所以没有受伤、格挡一说”,是这样的,因为下一个动作是什么就是什么,花头全在ActionChangeInfo的配置,包括当身也是——当身指的是受到攻击的时候立即做出一个反击动作,那么就是受击框的优先级很高,然后“自身动作变化”变成一个攻击动作。不难理解,毕竟ActionChangeInfo,是“另一个渠道的Cancel”。
当身可以用ActionChangeInfo,但是追击(Combo)和反击要怎么做?他们并不是“全自动”跳转的对吧,这里就要用到临时开启Cancel点的概念了,还记得我们说过“Cancel点有个激活状态”吗?就是通过命中的时候,临时开启一些CancelTag来做到这个效果,至于开启CancelTag的信息放在哪儿,可以是攻击框决定双方、也可以是受击框决定双方、可以是优先级高的那个框决定双方、也可以是各自决定各自的,每个游戏做法都不一样,这具体还是看策划游戏的时候,我们想要咋样做,然后进一步具体分析。上文我们提过的经典动作游戏中那些第一段打空只会反复第一段,打中了才会进行第二段的做法,也是这样来实现的。利用好这个性质,可以把动作游戏的手感和连击爽快感做到极致。
03 基于动作系统可能产生的玩法创新
在了解了动作游戏的动作切换系统的做法之后,我们基于这个做法可以创作出一些什么玩法呢?毕竟在这个时代,做游戏并不是一件很稀罕的事情,大家都在做游戏,那我的游戏要能被人看到,至少得有点特色也就是创新对吧。那我们就来看看一些基于动作游戏的动作系统可以做的简单创新案例。
NO.1 怪物猎人的替换技玩法
这是在《怪物猎人崛起》中开始被大家了解到的一个新系统,事实上之前《怪物猎人XX》中的狩技也已经是这样了。这个玩法就是让玩家可以在策划设计好的2个动作之间做一个选择,比如玩家可以选择长枪冲刺或者架盾冲刺,但是2个动作只能选择一个。
我们抛开美术和策划设计不说,即有哪些动作可以替换哪些动作我们这里不深究,只是探讨一下,从程序开发角度,这个替换技玩法怎么做到?实际上非常非常的简单,就是接着刚才的例子说,因为是替换技,所以Command都是一样的对吧,比如我默认给玩家的是长枪冲刺,他的Command是“强攻击+弱攻击”(它能够Cancel的是架盾动作,但是架盾动作本身要按住RT,所以RT并不在他的Command中,这是一个错觉),那我们配置的时候,长枪冲锋的Command里面是Command:[[强攻击,弱攻击]],而架盾冲锋因为是替换技,所以默认配置的Command是Command:[],也就是没有任何操作可以放出架盾冲锋,但是当玩家选择用架盾冲锋替换长枪冲锋的时候我们要做的仅仅是 (长枪冲刺Command, 架盾冲锋Command)=(架盾冲锋Command,长枪冲刺Command)这一句C#代码就行了——交换了他们的指令之后,长枪冲刺的Command就变成了空数组,就放不出来了,而架盾冲锋的Command里面有了一条就是强攻击+弱攻击的,由于他们的CancelData等都一样,就产生了“替换”的效果了。
NO.2 连招自定义build玩法
自定义连招的意思是指把策划预设的一些CancelTag和CancelData交给玩家去在游戏(玩法系统)中进行设置,比如花费训练点数可以开启“力劈华山Cancel白虹贯日”的功能,即我们把这些Cancel关系做个文章,让玩家通过调整Cancel关系,来进一步打出自己喜欢的连招来,组合出自己的流派是一件非常好玩的事情。
NO.3 基于性质尽情地去设计吧
看,基于动作游戏的动作系统的基本性质,我们随手就能很轻易的实现出两个非常好玩、非常有扩展性的玩法。当然时间和篇幅有限,这里就不提更多的“创意”了,还是由各位自己在自己的项目中发挥吧。
但是核心是——动作游戏和即时回合制游戏,根本的不同在于他们的实现方式不同,实现方式的不同必然会带来细节上的不同,细节上的不同就会带来完全不一样的玩法和体验。