本文非常长,并且涉及部分专业知识。建议先马后看。如果还没关注我们,记得关注一下哦。使用微信扫右边的二维码就可以了哦。
点击github链接,下载demo
00 前言
NO.1 什么是动作游戏的动作系统
动作游戏中的动作系统,负责的是游戏中的每一个角色的动作管理,包括当前动作和动作的切换,是动作游戏的核心系统之一。
所谓的当前动作,顾名思义就是角色正在做的动作,但这是一个标准的玩家理解,而对于游戏开发者和游戏而言,一个动作只能是一个“包”,重要的是角色正处于的当前动作帧。一个角色的一个动作,是由若干帧组成的,因此当时间静止的时候,也就是游戏运行到某一帧的时候,角色总是处于一帧的状态。
在这里,就是动作游戏和即时回合制游戏的根本区别——对于动作游戏而言,每一帧都是一个重要的状态,这一帧的攻击盒、受击盒、包括位移、推力、击飞能力等都与上一帧在逻辑上是完全不同的;而对于回合制(包括即时回合制),一帧的意义是“过度”,是一个动作的某一片段,所有属性都是依附于一个动作的。
本文的demo中,因为使用了unity,用一种大家熟悉的方式,也就是依赖于animator做动画的方式,所以看起来也跟即时回合制游戏一样——采用了“动作”为单位而非“动作帧”为单位,但实际上实现的时候,却是以“Merge一批帧”的概念展开的,因此从出发点上是不同的,自然实现的手法会与“状态机”大不相同。
NO.2 为什么在本文和代码中会多次提及Montage
Montage是UE的一个功能,Unity并没有这个功能。Montage的作用简单地说,是在一段时时间内掰动一个角色的若干根骨骼,按照剧本(也就是这个montage的数据)来进行运动。一个Montage并不是一个完整的动画,尽管一个动画可以只有一个Montage组成。一个角色同一时间可以由多个Montage控制,因此我们理解的“一个动作”,从Montage(或者说UE4)的视角来说,就是一段时间内多段montage执行完毕了。

比如我们做一个射击游戏,角色上半身在开枪是一个Montage,上半身装弹是一个Montage,下半身站立是一个Montage,下半身跑动是一个Montage。当我们让角色开枪的时候,只要给他按上上半身开枪的Montage即可,此时下半身站立或者跑步的Montage依然自顾自的在工作。
我们借用Montage的这个思路,包括Montage中AnimNotify和AnimNotifyState所带来的思路——我们将动作游戏中一些“连续几帧都有效”的事情,做成一个Montage(或者更确切的说是一个AnimNotifyState),就可以凑出动作游戏应该有的功能来了。简单地说——就比如在连续的10个动作帧之内,我都开起了某个CancelTag,正确的动作游戏做法是这10帧的BeCancelledTag中都包含了这个值,我们保留这个概念,只是改变了“配置方法”,让用户去配置,比如从第9帧到18帧的10帧,他都开启了这个BeCancelledTag,就有了在Animator下实现了动作游戏一样的逻辑。
NO.3 Update带来的遗憾
但是无论怎么做,我们不得不面对的就是Update的遗憾,因为我们要丢掉“动作帧”的概念,才能用好Animator,依赖于Animator去做这件事情(当然可以自己重新开发一个类似animator的东西,但不在这个demo展示,也不推荐现代的游戏公司去做这件事情,毕竟这是一件需要游戏开发技术的事情),所以我们不得不使用Update来实现动作帧管理,而“使用Update”的本意就是,我们把Update的2个tick当做“帧”,但是为了保证和动画的同步,我们不得不启用两帧之间的deltaTime来运算,这个做法是标准的动画补间处理,为了让动画平滑,但是用来做游戏逻辑,是大错特错的。因为假如卡一下,就会“跳过关键逻辑帧”,而假如在这个关键逻辑帧中间,有非常重要的事情,比如可以cancel到某个大招,就会被错过。
在本文和本demo中,我们采用了一个追溯的方法,以确保一些逻辑一定会至少发生一次,但是依然还是很难做到和动作游戏100%相似的手法,幸运的是,玩家在正常设备下,不联网玩的话,基本感觉不出问题的存在。
01 动作与动作切换
NO.1 动作(Action)与动画(Animation)之间的关系
在真正开始说技术内容之前,我们一定要先搞清楚一个概念——动作和动画,是完全不同的两个东西,在正常的动作游戏开发中(比如喀普康的RE引擎),动画是依赖于动作的,因为当动画达到关键帧的时候,会观察动作逻辑是否已经到了对应帧,根据到了、没到、超过了3种情况,做出保持、加速、减速播放的处理,直到下一个关键帧,虽然这样的加减速玩家几乎感觉不到,但是为了保持动作画面和逻辑的一致性,还是得做。而动画和动作的一致性,是因为碰撞盒都是依赖于动作逻辑的,画面只是告诉玩家本帧碰撞盒大约应该在这里,所以得同步,不让玩家就无法准确的判断。
值得十分注意的是——我们从一些工具或者网络视频看,乍一想是碰撞盒依赖于动画绘制,但实际上这是不对的,动画和碰撞盒恰好对得上,是因为制作精良,而不是“因为动画这样,所以碰撞盒这样”,这也是为什么魂系(非动作游戏)在动作融合的时候碰撞盒会出现意想不到的Transform,而怪猎、街霸这些真动作游戏就不会的关键原因。
所以,在开始之前,我们必须明白一件事情:动画和动作是不相关的两个东西,动画更像是一种UI,而动作才是游戏运行的关键。
NO.2 真正的动作游戏动作系统的需求
当我们错误的用回合制游戏的“前摇”“攻击”“后摇”来划分一个动作游戏的动作之后,就会进一步错误的理解——动作游戏无非就是玩家可以快点做一个“正确的”动作,来取消掉“后摇”。但事实上动作游戏的动作系统真的是这么简单的功能吗?远远不是,一个动作游戏的动作系统,具体的需求除了“可以在动作没有结束前更换动作”,但实际上玩的足够仔细,我们还会发现,动作游戏还有这些情况:
命中时才会有派生动作

在经典的动作游戏中,并不是猛按攻击键就会一口气打完一连串所有的动作,有很多更有利的动作只有当玩家打中了敌人之后才会允许做出来。事实上就是今天的动作游戏中,“普通攻击”虽然可以全自动一口气打完,还是有一些“技能”的“派生技能”会要求技能首先能打中。所以这里有一个需求——命中后可以做一些未命中时不能做的动作。
全自动的下一个动作

这是一个十分容易被忽略掉的需求——就是动作游戏中很多时候我们只能先做出一个动作,而它的“派生动作”是当条件满足的时候自动发动的,很典型的一个就是格斗游戏中的“当身”,我们能操作角色做出第一段动作,只有当第一段动作中某一段被敌人攻击命中的时候,才会全自动的做出下一段、也就是派生动作。
受到攻击时的下一个动作

当我们被命中的时候,下一个动作该做什么呢?这看起来是一个没问题的问题——是不是说我们进入了受伤动作,然后根据“情况”来选择下一个动作是啥呢?可是“情况”又是什么?这会是一个非常不好分析的东西,我们通常会简单的理解为是一个枚举值,比如受伤、完全格挡、格挡,可真的是这样分状态吗?那如果还有方向、还有空中地上、还有上下段、还有格挡左侧、格挡右侧……等等等呢?甚至还有类似“落木术”的忍术呢?所以这个情况随着策划脑洞越大,越是不胜枚举。
02 相关的数据结构
NO.1 动作ActionInfo
当我们进一步思考了需求之后,第一步还是根据信息来抽象出数据结构,一个动作的数据包含且不限于以下内容:
● id:string,每一个动作都会有一个id,尽管可以有多个actionInfo有同样的id,但是在一个角色身上的时候,每个动作的id都是唯一的,同id的动作出现在同一个角色身上、并且同时都被激活是不合理的(因为挑选下一动作的时候会出现问题)。我们可以利用这个性质,来做出动作替换(类似怪物猎人Rise中的替换动作玩法),也就是当玩家可以在多个动作中做出选择,我们只需要把选中的动作的id改成设置的就行了——比如我们在连招配置中,动作顺序是(id):A→B→C,其中B是可以换的,原本有个动作id=B,2个备选动作的id是M和Q,当我们选择M的时候,就把现在的id==B的动作的id变成M,原本id==M的动作的id=B即可轻而易举的做到“动作替换”玩法。
● animKey(动画名):string,指向Animator中的State的name,在这里,我们仅把Animator作为一个播放器,使用他的CrossFade吃动作融合,顺便把它作为一个动作容器,便于查看。当然另一方面也是因为我们要知道当前动画状态的一些信息,才不得已使用了Animator,如果有时间,还是推荐自己实现一个Animator功能的东西。
● catalog(类型标签):string,这个动作的标签,比如他是“受伤动作”,这个更类似于即时回合制游戏中的动作状态机的状态,但是他又有不同,可以有很多个动作(而非动画)有同一个catalog,就像即时回合制游戏中一个状态也可以有很多种动画一样。之所以要这样做,是因为比如我们要让一个角色做受伤动作,可是角色处于不同状态的时候能做的受伤动作可能就不一样,而与回合制游戏不同的是,动作游戏是一帧一个单位,而不是一个动作一个单位,因此可能2个受伤动作的区别不仅仅只是动画不同这么简单。比如在即时回合制游戏艾尔登法环中,角色正面受到攻击和背后受到同样的攻击,区别只是动画不同,其他都是一样的,至多至多有个背后伤害提高了。但是动作游戏中可能为了惩罚背后受击,会有背后受击动作时间本身较长、不能受身(也就是cancel到一个相对安全的动作)、甚至背后受击之后回复动作(背后受击动作的自然下一个动作下一个动作)与正面受击都不同。我们把这些受击动作都用同一个Catalog,然后在一些环境通过catalog先索引出一批动作,再进一步筛选。
● cancelTag(取消信息):CancelTag[],这个动作可以取消其他动作的依据。取消是一个十分重要的概念,也是动作切换功能的关键,正如即时回合制游戏使用Animator的Transition来切换动作一样,在下文中会详细说明。

● beCancelledTag(被取消信息):BeCancelledTag[],这动作通常可以被其他动作取消的依据。
● tempBeCancelledTag(临时的被取消信息):TempBeCancelledTag[],动作过程中,因为攻击会临时开启的Cancel信息。那么为什么受击不会开启临时的Cancel信息?比如受身动作,不应该是一个临时开启的Cancel信息吗?实际上当一个角色进入可以受身的动作的时候,哪个动作的beCancelledTag中必然包含了受身的Cancel了。
● commands(动作的命令):ActionCommand[],这个动作的输入信息,比如是打拳还是踢脚,甚至是236拳等。当然一个动作的动作输入未必只有一个,所以得用数组,同样的动作的输入未必都是玩家输入,也可以是直接的AI输入,AI输入的Command与玩家通过手柄输入的Command是一样的,最终都会进入到InputToCommand的记录中。
● inputAcceptance(允许的移动倍率):MoveInputAcceptance[],当角色在做一个动作的时候,他原本的主动移动还能按照多少的速率进行。比如一些动作在做的过程中依然可以保持正常的移动速度,这项属性就是1.00f,想一想如果一个角色可以边跑边换弹夹,那么换弹夹动作的inputAcceptance中的倍率就应该是1.00f。同样的有些动作只能保持缓慢地移动,甚至越来越慢,这些都是允许的设计,这个属性配合动作本身的RootMotion,也会产生出一些“按住前冲更远,按住后充不出去”的效果(速度相加或抵消了)。


因为inputAcceptance不同,所以移动速度有了不同,但都是基于角色的“移动速度”属性的,只是倍率不同。
● autoNextActionId(自然的下一个动作是什么):string,当一个动作做完之后,他的自然下一个动作是什么,比如站立动作做完之后依然是站立;而“普通攻击”的动作完成之后回到的也是站立动作。到这里,我们不难发现,站立本身不是一个状态,他只是大多动作结束后都会回到这个动作(包括站立自己),所以只是看起来像是一个状态,实际上他跟“普通攻击”是一样的动作。而另一个被误认为要用状态区分的动作是拔刀和纳刀、以及类似怪物猎人斩击斧拔刀还有剑形态和斧形态的区别,很多人可能觉得这应该是个枚举,可仔细想想,是不是当你拔刀变成纳刀的时候有个收刀动作?这个收刀动作可以Cancel拔刀站立(或跑步)动作,而收刀动作的“自然下一个动作”就是纳刀站立动作,是不是就动作切换了?这时候我们再仔细回顾一下,斩击斧用RT拔刀和用X拔刀动作是一样的吗?不是的,奥妙就在这里!

● keepPlayingAnim(切换到这个动作自己时,是否保持播放):bool,一些动作通过自己Cancel自己保持着,所以当自己Cancel到自己的时候动画得保持,比如典型的就是移动和下蹲动作,就必须要通过这个标志来,让动作自己Cancel自己时动画保持继续(循环)播放下去。之所以要这个bool,因为我们也允许一些攻击动作Cancel攻击动作自己,这时候我们期望他只是快速打出第二次,所以可以用这样一个属性来描述,虽然看起来用Cancel可以做到,但是着手配置的时候就会发现是存在逻辑矛盾的,非得一个属性来判断。
● autoTerminate(是否自动终止动作):bool,一个动作是否会自动终止,这个概念说的是,动作的一些动作帧(在unity框架下的这个demo只能算是一段时间,而我采用了“整个动画过程”这么长时间)会检查是否依然有这个动作的ActionCommand存在,如果不存在了就会进入“自动下一个动作”,或者进入另外一个动作阶段(这里要夸赞一下UE的Montage中的Section,做这个还是不错的,如果能动作切换section的时候也加上融合就更好了)。典型的需要这个属性的动作就是移动、蓄力斩等。
● attacks(动作期间的攻击信息):AttackInfo[],一个动作可以有若干个攻击信息,每一个攻击可以算是“一段攻击”,当然一个动作完全可以一段攻击也没有,比如受伤动作。并不是一个攻击能命中同一个目标2次,就有2个攻击信息的,这会在之后的“攻击信息”这一段进一步讲解。
● attackPhase(动作期间的攻击阶段):AttackBoxTurnOnInfo[],攻击开启阶段,无论是Unity的collider的isTrigger还是UE的Collider的Overlap,虽然都提供了类似OnTriggerStay、OnOverlap,但是我们不能让攻击框自身承载命中逻辑,毕竟动作游戏一帧会有若干的攻击框,不同的攻击框可能指向的是不同的攻击信息,所以我们在攻击框命中受击框(OnTriggerEnter/OnOverlapBegin)的时候记录下来,在攻击框离开受击框(OnTriggerExit/OnOverlapEnd)的时候移除碰撞记录,然后有角色本身去判断受击框命中了,然后他是否是激活的受击框,激活了才有资格算命中,那么是否激活了,就取决于这个attackPhase了,在后面的“攻击盒活跃信息”中,我们将更进一步说明他的具体功能。
● defensePhase(动作期间的受击阶段):BeHitBoxTurnOnInfo[],受击盒开启信息,由于一个受击盒就可以代表一个部位(并且多个受击盒都可以代表同一个部位),所以受击相关的信息,完全可以丢在部位信息或者受击盒中,这具体会看项目的设计,本demo中,我把他们放在了受击盒内。
● rootMotionTween(动作位移信息):ScriptMethodInfo,动作的RootMotion信息,因为我们不能让美术的RootMotion来主导角色移动,所以必须把RootMotion信息拿出来。并不是说美术按照策划要求做了RootMotion不行,而是因为动画的RootMotion会强行移动角色,而角色移动这个功能并不是角色本身的功能,是游戏逻辑系统的功能,毕竟角色不依赖于阻挡等影响角色移动的东西,所以角色本身只能提供出“我这帧打算怎么移动”然后能不能动,得有GameManager根据地图阻挡信息以及其他规则来判断得出一个结论在执行是否发生移动,所以RootMotion只能是提出一个信息来,而不能粗暴地做在动画中(无论是Unity还是UE,做在动画里面,动画就自顾自动了,完全失控,如果你用他的阻挡,那就会掉进另外的坑里,一切都会失控)。
● priority(基础优先级):int,动作的基础优先级,如果在同一帧,有若干个动作告诉我说要切换到他们,但是我只能切换到一个动作,这时候就得用priority来冒个泡。动作本身有个priority,是因为动作本身是有“贵贱之分”的,比如走路、站立这样的动作priority总是最低的,而受伤、被击败总是最高的。
● flip(是否会导致角色转向):bool,由于这个demo是一个横版的游戏,还有搓招,所以有一个转身问题,角色是朝着正手还是反手的,搓招输入是有正反手一说的。因此一个动作本身会提供出他是否会导致角色逻辑上转身的信息,严格的来说,转身会发生在一个动作的好几帧,每一帧都有可能转身一次。但是在这里,因为不是这个demo的重要功能,就偷个懒了,只在切换动作的瞬间,判断新的动作如果转身,就立即转身了。
● 其它扩展:除了以上的数据之外,根据游戏的实际需要还可以有很多的属性加给动作,重要的一条是——思考一个数据的时候,千万不能像上面的flip那样,把整个动作当做一个单位思考,而是要考虑他是否应该每一帧都有可能发生?是否是连续发生的,要像attackPhase这样的去思考。UE的Montage的AnimNotify和AnimNotifyState的用法思路是对的。除非这个属性本身就像id这个属性一样,他就是属于整个动作的。
NO.2 Cancel信息CancelTag
如果说是“前一个动作被后一个动作替换了”,那么Cancel信息就是“后一个动作的”,而BeCancelled则都是“前一个动作”的。CancelInfo的数据包括:
● tag:string,一个相当于id的东西,如果BeCancelledTag的tag中包含了这个tag,说明这个CancelInfo就可以导致这个动作在“上一个动作”的这个BeCancelledTag生效的阶段内替换了它。
● startFrom(起始帧):int/float,“后一个动作”取消“前一个动作”的时候,“后一个动作”未必是从第一帧开始播放的,允许从一半开始,这样才能把Cancel看做是连招效果,不然就只能算“接着做”了。在这个demo中,因为使用unity,可以获得的最精准的动作进度是一个normalized,所以这类时间戳的参数,大多采用百分比(0.000f-1.000f)来做。
● blendIn(融合):float,动作融合时间,在这个cancel点,融合进“后一个动作”的融合长度是多少。
● priority(优先级变化):int,优先级变化,在不同的Cancel点上,同一个动作的价值也会发生变化,甚至原本更高级的动作,会因为Cancel点暂时变得不如比自己低级的动作,这在格斗游戏中会比较常见。
NO.3 被Cancel信息BeCancelledTag
和CancelTag是一对,属于“前一个动作”中可被切换到“后一个动作”的阶段的信息:
● tag:string[],和CancelTag的tag对应,一个BeCancelledTag是可以被多个CancelTag所cancel的,同样的多个BeCancelledTag也可能被同一个CancelTag所Cancel。
● range(生效范围):min\max,动作游戏中,每一帧的Cancel信息可能都截然不同,我们这里把一些帧合并,来作为Cancel阶段,但是这个做法也是有问题的,可能需要再打一些补丁来做到和动作游戏一样。因为比如在格斗游戏中,并非每一帧都有Cancel点,可能是若干帧有一个(但是玩家几乎感觉不出),这样是为了确保操作的手感的。
● blendOut(融合):float,动作融合信息,当前动作融合出去的长度。
● priority(优先级变化):int,优先级变化,毕竟一个CancelTag可以对应多个BeCancelledTag,所以每一个精确地Cancel点,都需要BeCancelledTag的priority加上CancelTag的priority得出priority结论。
NO.4 临时的被Cancel信息TempBeCancelledTag
在攻击发生后,临时开启的Cancel点,这些Cancel点并不是一早就开启的,我们只是将他们记录在动作里面,只有当他们被激活的时候,才会产生出一个临时的(所以短暂的)Cancel点,相比BeCancelledTag,他有2初不同:
●【增】id:string,因为需要被开启,所以我们要知道开启哪个,所以得有一个id作为索引。
●【改】range=>time:int/float,因为是临时开启的,所以没法定义启动时间(不在这个信息里提供),但是要提供一个多久,以获得range中的结束时间。
其他都跟BeCancelledTag的一样。
NO.5 操作指令信息ActionCommand
操作指令是驱动某个动作做出来的重要信息,对于玩家来说,无论是“普通攻击”还是搓招,都得输入至少一个命令,让角色知道我要干什么动作,然后才会尝试去做出。一个动作可以有若干操作指令,他们之间是或关系,也就是任何一条满足,都可以驱动出这个动作。操作信息在动作中,有这样几条信息:
● keySequence:KeyMap[],按键序列,这个顺序是大多情况下要保证顺序的,比如2、3、6、拳,这4个输入的顺序是有先后关系的。
● valid:int/float,有效帧(本demo顺应unity用了秒),每一帧动作检查输入的时候,只检查多少时间以内的输入,超过时间的虽然在缓存也不考虑。
NO.6 移动接受程度MoveInputAcceptance
移动速度接受的百分比信息:
● range(时间范围):min/max,因为我们模拟的是UE里面的AnimNotifyState,用这个方式去实现“一段时间内”,所以得有个时间范围。一个动作可以根据移动接受程度分为多个段,比如起跳动作,起跳前的下蹲(哪怕很短)是一个阶段,这个阶段是不接受输入的,而跳起来以后,很多游戏为了手感还是允许输入方向的。
● rate(百分比):float,接受的百分比,毕竟不是每个动作都能100%动作移动的,比如举着盾走路的时候。
NO.7 一段攻击AttackInfo
“一段攻击”可以理解为回合制游戏中的“一个攻击技能的效果”,在一个动作中,可能发动超过1个“攻击技能”这个在动作游戏中虽然也不那么常见,但是少数存在依然还是存在的。之所以需要“一段攻击”,是因为我们要根据攻击段来决定命中效果的次数和间隔,由于是动作游戏,每一帧都要判定命中,那攻击盒命中了受击盒,是不是就一定算打中了?肯定不是,不然会连续若干帧都发生命中效果。那么是不是只要第一次命中就行了?也不是的,因为在动作游戏中,我一拳下去造成3段命中效果太正常了,所以我们需要这样一个信息:
● phase(阶段):int,可以理解为一个id,每个动作中如果有若干攻击阶段,每个阶段的phase值都应该是唯一的。
● attack(攻击力倍数):float,可以理解为《怪物猎人》系列的动作值,也就是这次伤害的攻击力倍率,最后用来乘以碰撞框的倍率和攻击力,得出本次攻击力。当然这只是一个例子,并不是说一定要有这样一个攻击力,要根据自己的游戏设定,来设计出相关的数值。
● forceDir(力的方向):enum,这段攻击的发力方向,比如在横版游戏中有:向前、向后,这个属性不仅决定最后受击者往哪个方向受力(以至于移动和动作选择结果都不同),还会在判定中起到作用,比如格斗游戏中的Overhead(街霸隆的锁骨割、侍魂霸王丸的不意打这类站着破蹲防的动作)。
● pushPower(推力):MoveInfo,推力信息,这是一个目标被击飞、击退的信息,依赖于这个信息将目标击退,当然这依然是一个例子,在本demo中,无论对方如何都会被击退击飞这么多,我们当然可以在动作中设计一些其他值来改变受击飞的力,这也是常见的;同样的也可以在流程里面要求一定要导致对方动作变化了才会位移(那么这个属性就要属于ActionChangeInfo了),这具体的还是看游戏的设计。
● hitStun(硬直时间):int/float,如果攻击命中,那么对手一侧会硬直多久,硬直往往是对方更换动作之后的卡帧。
● freeze(卡帧时间):int/float,自身卡帧多久,值得一提的是,卡帧一定是暂停动作的,但是动画是否暂停是不一定的,因为逻辑动作暂停了,动画可以缓慢地播放,看起来可能会流畅一些,但是手感肯定是不如完全静止的,这个可以酌情设计。但是值得肯定的是,暂停就是动作的信息保持在当前的帧,在动作游戏中,因为帧是最基础的时间单位,所以就是当前帧持续多少帧,在unity和ue中,因为用了deltaTime,所以只能说一段时间内保持动作的信息不变就好了,但多少会有点漏洞,只是一般来说不容易暴露。这里值得一提的是,既然是卡帧,那么Cancel信息依然是生效的,这意味着卡帧一方可以在下一帧就用另一个动作Cancel掉现在这个动作,形成了流畅的连招,当然前提是下一帧可以被其他动作Cancel——这里就有个细节手感问题,在标准动作游戏中,卡帧开始后,可以有短暂的几帧不接受输入,以确保打击感,但是在这个demo,没有去做这个,因为unity中我得到当前在第几帧依赖于animator,所以得做很多一些“魔法处理”来做到这个, 比如我得另起一个float来记录多久之内不接受command,不是不能这么做,并且是Unity和UE只能这么做,只是我这里就懒得做了而已。
● canHitSameTarget(可以命中同一个角色次数):int,这段攻击能命中同一个目标多少次,值得注意的是,这只关联到这段攻击,也就是说如果你的动作中攻击有3段,那你无论如何控制,都有可能发生3次命中。所以一段攻击通常是一段攻击框开启,只是为了配置不那么死板,所以用phase来索引,允许多段攻击框开启时间指向同一个attackInfo,这也是非常常见的需求。
● hitSameTargetDelay(命中同一个角色间隔时间):int/float,这一段攻击如果可以命中目标2次或更多,那么2次之间的“冷却时间”是多少,至少得有个这个,不然一次攻击框碰撞可以有好几帧黏在一起的,每帧一下就出问题了。
● selfActionChange(自身动作变化信息):ActionChangeInfo,攻击发生时,攻击者自身动作变化的信息,具体会在“动作变化”一章中详细说明
● targetActionChange(被命中目标的动作变化信息):ActionChangeInfo,攻击发生时,目标的动作变化信息。
● tempBeCancelledTagTurnOn(临时开启的BeCancelledTag的id):string[],我们之前说过,攻击命中后会临时开启一些BeCancelledTag,这样至少可以做到,只有命中了才能派生后续动作。
NO.8 攻击盒激活信息AttackBoxTurnOnInfo
在标准的动作游戏中,不同帧开启的攻击盒无论是位置、数量还是尺寸都是不同的,他们与前一帧后一帧的攻击盒之间没有必然的关系。要做到这样,首先得支持以帧为单位的正确游戏开发方式,其次是得有一个编辑一帧动作的攻击盒受击盒的环境,这在Unity和UE,都得费很大劲去实现,Unity相对于UE没有那么多包袱,只需要自己写一个animator就完事儿了,但是编辑器还是要花时间做的。所以我在这个demo就采用了一个老头环之类的ARPG(即时回合制游戏)的做法,就是把攻击盒受击盒都跟角色骨骼绑定,然后逻辑上默认关闭他们,也就是他们只提供了“我命中了谁”(OnTriggerEnter)和“我不再命中谁”(OnTriggerExit)两条信息。至于是否有效,则会在流程里通过判断attackInfo和一个HitRecord进一步来做。攻击盒激活信息主要包含的内容:
● range(时间范围):min/max,模拟UE的AnimNotifyState,在一段时间内开启一批攻击盒,这里的“开启”并不是说原本的攻击盒不工作,而是指“让攻击盒的工作有意义”。
● tag:string[],与攻击盒的tag对应,只要tag中包含了攻击盒tag中的任意一个,就算这个攻击盒是要开启的。
● attackPhase(攻击阶段):int,与attackInfo的phase关联,是指这段“AnimNotifyState”产生的攻击,是哪一个攻击信息。
● priority(攻击盒优先级调整):int,由于一次攻击中同一个phase的攻击只有一次命中效果,但是一帧里面可能会有很多个攻击盒同时命中了很多个受击盒,一个攻击盒自己就能命中多个受击盒,一个受击盒还能被多个攻击盒命中,但是逻辑上我们只需要一个命中信息,这就需要冒泡出最有价值的攻击盒跟最有价值的受击盒了(这个算法不进一步说明了,每个游戏需求不同,做法也会有差异。这个demo里面没有去实现这个),在攻击盒激活信息中,会临时增加某些攻击盒的优先级,使之在这次判定中超越原本价值高于自己的攻击盒。
NO.9 受击阶段BeHitBoxTurnOnInfo
正如我们上面所说的,动作游戏的每一帧攻击盒跟受击盒都会发生变化,所以除了攻击盒之外,我们的受击盒也需要这样一系列信息来驱动他们被激活。
●range(时间范围):min/max,模拟UE的AnimNotifyState,所以是一段时间。
●tag(开启的受击盒tag):string[],与受击盒的tags有交集时,该受击盒将视作激活。
●priority(优先级):int,激活受击盒的时候给受击盒增加的临时优先级,当多个受击阶段信息有重叠指向同一个受击盒的时候,就看程序如何运行来算优先级了。
●tempBeCancelledTagTurnOn(临时开启的BeCancelledTag的id):string[],受击盒受到攻击导致临时开启的BeCancelledTag。概念里,受击阶段是受击动作的某些不存在重叠的阶段(即使时间有重叠,但是影响的受击盒应该是不同的),所以可以理解为动作做到某个时刻了,命中这批受击盒就会___,这里是“就会开启临时的BeCancelledTag”,对于下面两项actionChange属性来说,就是“会导致双方动作变化”了。
●attackerActionChange(使攻击者动画变化信息):ActionChangeInfo,攻击这个阶段下这批受击盒中的任何一个,会导致攻击者的动画变化。因为攻击者的attackInfo中也有一个selfActionChange,但是一次攻击,只能导致一个角色产生一条ActionChangeInfo,所以,此时会对比两条actionChangeInfo,取其priority高的来用,当然这个demo是这样,你同样可以把它做成 (ActionChangeInfo, ActionChangeInfo)=>ActionChangeInfo的代理,也就是通过传递给策划2个ActionChangeInfo,由策划编写一个规则,返回一个给我们用(返回的甚至不是传过去的2个中的任何一个都是可以的,想想比如盾牌格档的时候因为“攻击力度”不同导致“格挡成功”动作不同)
●selfActionChange(自身动画变化信息):ActionChangeInfo,和attackerActionChange的意义相同,只是作用对象是受击者一方。
NO.10 位移动作信息ScriptMethodInfo
其实这是RootMotion,只是RootMotion不能直接拿来用,正如我们上面所说的,如果用了RootMotion的功能,那就变成位移依赖于角色动画了,这个本身是不对的,毕竟角色动画是无法判断场景里面的地形和规则,无脑直接移动了。移动这个功能本身应该是游戏逻辑层,至少是游戏世界(或者场景)一层的,毕竟移动涉及到游戏的规则,不光是阻挡,还有比如在某些规则下角色的移动距离翻倍、减速等变化等,所以一个角色最终的位移(transform.position发生变化)必须由游戏逻辑层来左右,而非角色更不可能是角色动画。因此我们在这里需要提取出RootMotion的信息来,提供给游戏逻辑层作为移动参考信息。
一个ScriptMethodInfo可以用在很多需要脚本接口的地方:
● method(函数名):string,要调用的脚本函数名,至于怎么调用,方法有很多,包括使用反射,或者直接走Dictionary的key拿到对应的函数都可以,具体看如何定义和使用。
● param(参数):string[],函数需要的参数,具体意义应该是事先都能约定的,因为这个结构是填表数据,策划配表的时候也只能配置string,无法配置复杂的object,所以用的是string[]。在这个RootMotion信息中,我是这样使用的:
● method:指向RootMotionMethod类下唯一一个静态dictionary Methods的key,由此可以拿到value就是delegate了。
● param:是为了函数需要一些简单的参数变得更加通用。
03 动作切换
当说完了数据结构之后,我们就可以进一步开始看,切换动作的流程了。
NO.1 如何切换动作
值得注意的是,动作游戏和即时回合制游戏表面(从玩家视角)看起来,动作切换方法是没有区别的,但实际上是天壤之别的。即时回合制游戏中,通过触发事件告知角色我要替换动画了,换而言之,如果没有触发事件,就会保持播放下去。而动作游戏中,则是每一帧都会通过运算获得我下一帧是什么。因此对于动作游戏来说“挑选下一帧”就是对于整个动作系统的概括了。经过改变,demo中的框架已经是基于这个概念和unity所产生的限制而做的了,所以他的逻辑流程几乎没有变化:

NO.2 流程说明
1. 播放动画帧:第一步是播放当前的动画帧的动画。在unity和ue中,我们对这块是几乎失控的,能做的操作仅仅是改变它的播放倍率,但是由于我们的逻辑被迫依赖于动画资源,所以当卡帧的时候,只能被迫100%暂停动画——这是一个依赖关系错误所酿成的典型悲剧,但也只有这样,才能勉强维持系统工作起来。
2. 产生信息:产生这一阵的碰撞框信息(AttackBoxTurnOnInfo, BeHitBoxTurnOnInfo),输入信息(一个float,代表倍率)和RootMotion信息(一个Vector3代表偏移距离)。让其他Component或者系统访问来获得需要的数据。
3. 检查所有动作:更换动作的重头在这里,就是要检查所有的动作,不是数据表里所有的动作,而是角色的所有动作,也就是被注册到这个Component中的所有动作。遍历所有的动作,对每一个动作都检查他的CancelTag是否符合当前动作当前帧的BeCancelledTag,如果符合,就看这个动作是否至少有1个Command在InputToCommand中储存了(操作成立了),如果有,就把他加入到“候选列表”中,而不是直接更换。由此,我们一帧虽然可以同时存在多个动作的操作,但是最后能执行的只有优先级最高的那个。
4. 从候选列表得出结论:之所以要从候选列表得出结论,不仅仅是因为一帧内同时满足Cancel条件的动作可能存在多个,还因为有类似“受击”之类的动作会从别的方法加入到候选列表,这时候候选列表实际上是比检查动作得出来的数量要多的。所以我们最后把这个列表的元素按照其优先级进行冒泡,得出优先级最高的,就作为要更换的动作了。
NO.3 切换动作的渠道
在动作游戏中,切换动作的地方可能有很多,除了输入指令、发动和受到攻击之外,还能通过buff、通过地图事件、通过各种各样的游戏元素来导致角色改变动作,但是无论何种改变动作的渠道,最终都得通过ReorderActionInfo来做到预约这个切换动作的请求,就像我们上面说的,最终都会把这些请求的动作放进一个候选列表,通过冒泡得出最合适的那个。这个PreorderActionInfo包含的内容:
● actionId(目标动作的id):string,要切换到哪个动作。
● priority(优先级):int,最终优先级,因为在候选列表中进行选择的,就是这个PreorderActionInfo,所以其实冒泡的依据是这个priority。
● transitionNormalized(融合):float,动作融合的时间长度。
● from(从哪帧开始):int/float,切换动作后从哪一帧开始播放,在Unity则用从百分之多少开始播放比较好。
● freezingAfterChange(切换后的硬直时间):int/float,切换动作后的硬直时间,如果硬直是受到攻击的,那么应该在切换了受击动作之后开始硬直。
04 打击感、美术与游戏策划
打击感一直是动作游戏设计中一个比较重要但又特别“抽象”的事情,也有很多说法和文章讲述了“如何做好打击感”,但真到自己动手做的时候,该如何下手呢?我们就从打击感相关的一些细节实现开始说起(打击感相关的我没有做在这个demo里)。
NO.1 硬直与卡帧
首先是卡帧和硬直,在上文中,我们提到过“卡帧的逻辑是停止的,动画是缓慢播放的”这究竟是怎么回事呢?首先我们看动作游戏正常的动画播放和动作推进是这样的:

我们假设每帧和现实时间的汇率是0.03秒,实际上并不会这么精准,我们知道update每帧的间隔时间(Time.deltaTime)只能努力接近,总是不那么容易一样的,所以上图中只是表达一个理想状态。如图所示,动作逻辑和动画的时间长度在两个“时空”其实是等比的,所以正常播放动画的时候,动画走Update自顾自的播放,而逻辑走FixedUpdate不断地推进,虽然每一帧总是存在一些误差,但是这些误差基本是可以忽略不计的。
而我们做卡帧的时候,逻辑端就会发生变化,比如我们第3帧卡帧了:

但是兑换的现实时间并不会发生变化:

而动画走的是Update,也就是现实时间,这时候我们如何让他“卡住”,以确保和逻辑同步呢?最简单的做法就是把动画也暂停了:

这样做的效果没有问题,在以前我们都用同一个Tick做游戏的时候是这样做的,毕竟当时是序列帧,1帧就是1帧,卡帧了只能卡帧。在今天动画制作比当年精致了很多,也有了很多补间的技术,设备的渲染帧率水平也高了很多,更流畅的动画成了一个最基础的需求了,此时如果我们还采用彻底停住动画的方式已经不太合适了,所以我们通常采用的是缓速播放:

由于卡帧开始时间是绝对确定的,卡帧结束时间也可以通过卡帧长度算出,于是我们有了起点和终点的时间,有了卡帧结束时间,根据汇率就能大约算出动画的卡帧结束时间,于是我们只要通过动画的卡帧起始、结束时间和卡帧时间,算出动画播放速度(相对更加缓慢地速度),然后缓缓地让动画播放,就形成了玩家肉眼看到的“并不是彻底卡住了的卡帧”效果。
这实际上和网络游戏的“追帧”是一个原理,所以我们这里“追帧”的效果,也就是“卡帧结束时间”未必真的就是卡帧结束时间,而卡帧过程中的动画速度变化也可以是一个缓动函数(接近 1-power(x,3),x是0-1的百分比),至于这个缓动函数具体如何,以及其他卡帧的表现策略,就看游戏策划的功力。
NO.2 受击与抖动
打击感的关键之一也是受击表现,卡帧是一个维度,是动画表现维度。我们还有一个可以利用的维度,就是角色的坐标,这里的坐标并不是说要真的改变角色的世界坐标,而是轻微的改动角色的锚点坐标。让角色在受到攻击卡帧的时候,坐标会快速地、“小范围的”发生变动,起到抖动的效果,使得攻击的力量感增强。这个抖动的函数实际上可以抽象为float=>Vector3,参数是百分比时间,也就是(卡帧经过了多久/(卡帧结束-卡帧起始)),根据这个百分比得出一个坐标的Offset,通过这个offset来改变角色的anchor。

NO.3 盾牌格挡的档次设计
当我们说到这里的时候,还有一个没能彻底解开的问题——就是我们之前说的,攻击受击双方,都由defenseInfo和attackInfo来决定发生攻击后的效果,取的是priority高的那个,但是这样双方配置的只能有2个动作,有这样一个需求存在——我举盾格挡,如果对方“攻击力”很低,我就能“完整格挡”,但如果地方“攻击力”较高,我就会“格挡后失衡”,如果对方攻击力特别大,我会做出“盾牌被弹开,破绽超大的动作”,这样一来至少有3个动作,怎么才能做到?
首先我们得把这个需求的话进行“蜕皮”,去掉里面一些概括性词汇,比如“攻击力”,在这里“攻击力”指的真的是伤害值吗?其实不是的,在这里“攻击力”更像是这个动作的一种特性,比如说“看起来压倒性很高”。因为在设计这个动作的时候,我们所表达的“攻击力”并非数值层面的那个攻击力。换而言之,我们需要有一个东西来描述这次攻击的“压倒性”。至此,我们并不能直接诶就一股脑地把“压倒性”作为一个属性放进动作里,因为可能有“压倒性”,那会不会还有别的什么性?比如“柔韧性”之类的,所以我们应该反过来想——这个问题就是,在attackInfo中我们需要添加一个参数,配合attackInfo的其他信息,或者配合这个attackInfo所在的动作,策划就足以得出一个结论说,应该选择哪个“格挡动作”。
所以不难看出,当我们的ActionChangeInfo是根据Catalog切换动作的时候,本身就需要做一个具体动作的筛选,当做筛选的时候,我们把ActionInfo和AttackInfo都作为参数传递给脚本函数,这样策划就能根据信息恰当的筛选出动作了,当然如果还有其他的信息需求,依然需要进一步思考需求的本质,再做打算。
05 总结
动作游戏的动作切换系统,是不存在状态一说的,也可以理解为每一帧每个角色都因为动作而处于一个独有的状态,所以他是没法用状态机这种即时回合制游戏的思路来实现的。并且也因为Cancel系统的存在,动作的扩展性才是非常灵活的。在今天的游戏里,我们随着版本推进,不断给角色增加动作(技能)也是一个十分常见的需求了。正确的开发动作游戏的框架,才是做到好玩的动作游戏的坚实基础。