19 KiB
战斗系统,在大型游戏中,基本就是最复杂的系统;在项目中,通常由专门的战斗策划和战斗程序负责。相信读者中也有维护过战斗系统的,我想问几个问题:
-
有站在技能和Buff的更高层审视过你们的战斗系统吗?
-
项目的战斗系统有明确的架构吗?
-
项目的战斗系统有缺陷吗?知道问题的成因吗?
-
能实现你玩过的游戏中的所有技能和Buff效果吗?
我不知道大家的回答如何,但如果让刚离开第一个项目时候的我来回答这些问题,我几乎需要全部答NO...
我后来发现,其实不仅我是这样,有大量的开发者都对战斗系统缺少系统性的认知和思考,即使当前项目的战斗框架是他实现的。为什么呢?因为他不是最初的设计者,而只是搬运工,他并不真正了解他手头的工具。
其实,对战斗系统有系统性的认知非常重要,有许多设计问题,在我们只盯着部分模块的时候是发现不了的 —— 你只会觉得很难受,但不知道为什么。
本篇就带着大家看一下常见的战斗框架设计,以及我的心血之作,相信你定有所收获。
1.独立抽象(反面教材)
我的第一和第二个项目,都将主动技能、被动技能、Buff进行了独立的抽象。
第一个项目的战斗系统是由我Leader搭建的,一开始仅包含主动技能和Buff两个模块;后来,在我接手战斗系统的维护工作后,策划提出了被动技能的需求,我便增加了被动技能模块。
第二个项目的战斗系统是谁搭建的,我并不知道,但由我的同事(旸仔)维护;我记得一开始项目里也只有技能和Buff模块,后来,旸仔在处理被动技能需求时,也增加了被动技能模块 —— 果然是同龄人。。。
项目共性
如果看细节的话,这两个项目的代码差异是非常大的;但从整体(框架)上看,两个项目的内核其实是相同的。
首先,从整体上看,角色都可以分为5大块:
-
属性:属性即角色身上的数据,包括:攻防血、坐标朝向、模型、等级、名字...
-
主状态机:用于管理角色的的主要状态切换,通常与角色的模型控制相关
-
主动技能:通常由玩家操作施展,且通常会控制角色的模型动作(Action)
-
被动技能:通过养成系统或装备获得的效果,获得后立即生效,无时间限制
-
Buff:角色身上的增益或减益状态,通常是临时性的
其次,主动技能都是基于FSM + Action的框架实现的,FSM管理技能的主流程,Action实现具体的效果;至于Buff和被动技能,也都是采用类似Action的方式实现的,只是接口抽象上稍有不同。
// 技能执行上下文
Q:为什么坐标朝向和模型被标粗体?
A:坐标、朝向、模型(Model)及其动作(Action)与受击包围盒和攻击包围盒的计算相关,非常重要。
主状态机
其实,这两个项目的服务端都没有显式定义角色主状态机,而是根据角色的属性(移动状态、死亡标识、Buff等)来处理的互斥,我觉得是一个失误。
为什么应该定义主状态机?
从逻辑层的角度讲,将行走、施法、死亡、冰冻等纳入同一个状态机,可以让角色的主要状态及其互斥逻辑更为清晰。
从表现层的角度讲,角色模型一次只能播放一个动作,因此也需要一套状态机来管理;而要想表现层的状态机更容易编写,逻辑层涉及到模型控制相关的逻辑最好也在同一套状态机中。
主要缺陷(重复感)
在我实现需求和维护的过程中,我感受到的最大缺陷是:重复感。
被动技能和Buff效果存在交集,被动和Buff的Action代码存在一定的重复。这种重复感一直困扰着我,我分不清这是真实的重复,还是表面的重复 —— 既想合并它们,又觉得它们不该合并。
此外,我明确的感知到,项目的战斗框架,无法支持DNF那般强大的战斗系统 —— 距离还很远。
PS:其实主动技能的效果和Buff、被动也会有一些重复,但重复率不高;而且由于主动技能流程的特殊性,我们总是认为主动技能就应该是独立的。
核心问题是什么?
开发者站在了用户的角度来设计架构。
我们在玩游戏的过程中,建立了主动技能、被动技能和buff概念;在实现需求时,便不假思索地设计了对应的抽象。可我们所谓的主动技能、被动技能、Buff之间的差异,真的存在吗?它是表面的还是真实的?
这样去做战斗系统的人 —— 比如我,大概率是没有思考过这个问题的。
2.被动是特殊的Buff
虽然被动和Buff具有较大的差异,但仔细思考可以发现:被动技能可以是Buff的子集,被动技能的那些特性,Buff也是可以拥有的;而且用Buff支持被动技能,并不需要太大的改动量 —— 主要处理对站街面板的加成等。
将被动技能实现为Buff后,不仅消除了被动技能和Buff之间的重复感,也扩展了Buff系统。
该架构比较符合大家认知,采用率较高。不过,采用该架构的项目并不全是从前一种架构演进来的,也有一开始就用Buff实现被动技能的 —— 这是明智的;如果是从前一种架构演进来的,想必已有一点体会:过于明确的抽象,对战斗系统是有害的。
PS:关于抽象带来的扩展性障碍,阅读《角色框架:GameObject类型实现》可能会有所帮助。
3.被动技能后台施展
被动技能和Buff合并的最大障碍是数据依赖不同,被动技能效果依赖的是SkillData,而Buff效果依赖的是BuffData。
既然被动技能也依赖SkillData,那么是不是可以让被动技能也走主动技能的流程呢?即被动技能挂载后,按主动技能流程自动施展,但不占用角色主状态机。
public class SkillComponent {
PS:当年在第一个项目的时候,我就没想到这么做 —— 还是能力不足。
将被动和主动一视同仁,意味着支持多技能同时施展,有以下优点:
-
支持被动技能转主动技能施展,被动技能在满足条件的情况下可以手动施展
-
支持主动技能间断性施法,eg:技能A第一击,技能B第一击,技能A第二击...
后台技能 VS Buff
将被动技能看做主动技能,更符合代码层抽象 —— 程序员们会更容易理解。
但将被动技能看做Buff,更符合业务层的抽象 —— 策划们会更容易理解;此外,用Buff实现被动,还避免了效果交叠带来的重复代码。
思考题
被动技能和主动技能有共同点,又和Buff有共同点,那是不是说:主动技能和Buff有共同点?
4.一切皆Buff
' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
在市面上,还存在一种架构:一切皆Buff。
从字面上看,我的理解是:主动技能在施展的时候,也是给自己添加一个Buff,然后通过Buff来实现所有的功能。
我相信,肯定有读者在看见这句话的时候心生抵触!被动技能可以说是Buff,但主动技能怎么能是Buff呢?
是啊!不仅仅是读者觉得主动技能和Buff不一样,俺也一样,甚至那些说着“一切皆Buff”的团队也认为不一样!
所以,他们最终的实现并不是我理解的那样。根据职责转移的粒度,分两种情况:
-
技能流程由Buff构成
-
技能效果皆是Buff
4.1 技能流程由Buff构成
在技能设计中,技能被分为多个阶段,每个阶段由一组SkillAction构成;SkillAction中包含了打击点、触发间隔、攻击包围盒等信息;而在Buff中,同样存在这些配置。
因此可以将技能的SkillAction看做Buff,这样技能便可以看做是Buff的集合,在特定的阶段挂载特定的Buff即可。
// 技能执行上下文
主要缺陷(交互)
并非所有的技能都是简单的时间线施法,因此技能框架仍然需要是FSM式的;而由于SkillAction的消失,Skill成为空壳,因此Buff需要负责技能的阶段切换;所以,Buff系统和技能系统存在复杂的交互逻辑。
此外,将SkillAction委托给Buff,概念上接受起来还是有难度。
4.2 技能效果皆是Buff
技能加Buff是很常见的效果,那么造成伤害、治疗这些瞬间的行为为什么不能通过Buff(绕一下)实现呢?
所以,这里又有另一种实现:技能管理流程,Buff执行效果;即技能执行到打击点时,总是通过给目标挂载Buff来实现效果。
public class SkillAction {
不过,由于从技能转移过来的效果大多是瞬时Buff,为了避免不必要的同步,BuffMgr需要屏蔽这些Buff的同步。
主要缺陷(Buff量)
用Buff来执行技能效果,其实是将Buff作为了适配器,适配是有代价的。
-
造成伤害、治疗等效果的体量非常大,全部配置在Buff表会导致非常大的Buff量。
-
技能效果的上下文变得更复杂,会增加效果的实现难度
-
Buff的增删频率极大,Buff需要池化
可惜,将主动技能真正实现为的Buff,是更正确的方案 —— 终究是我们先入为主的观念阻碍了我们。
5.返璞归真,一切皆状态(State)
' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
在第三个项目,在经过较长时间的探索后,我成功将第一个项目的战斗架构演进为这套“一切皆状态”的架构。
它何以称得上返璞归真?继续阅读,你便知晓。
状态组件
public class StateComponent {
状态组件可以和传统架构的Buff组件类比,它存储着角色身上的所有状态和状态槽。
状态
public class State {
什么是状态?
状态在业务层面的含义,读者可以自行理解。我想告诉大家的是:状态即脚本!只有将状态看做脚本,你才能理解这套设计的强大。
现在,不论是主动技能、被动技能,还是Buff,都变得很简单,就是给GameObject添加一个状态,也就是给GameObject挂一个脚本,视野是不是一下就打开了?
这像不像我们在Unity下通过MonoBehavior扩展GameObject的行为?所以,在业务层面,State就是GameObject的行为组件。
状态脚本的实现
状态的最终逻辑由行为树(任务树)承担,可阅读《行为树:事件驱动版实现》《行为树:返璞归真TaskTree》。
行为树并不是唯一选择,但是是很好的选择;如果不选择行为树,只需要提供一个类似的脚本抽象即可,核心抽象方法就两个:
public abstract class Task<Blackboard> {
为什么是“状态”,而不是“Buff”或其它?
回答几个问题即可:
-
跳跃是不是Buff?死亡是不是Buff?施展技能是不是Buff?
-
跳跃是不是状态?死亡是不是状态?施展技能是不是状态?流血是不是状态?冰冻是不是状态?一切持续性的效果是不是都可以定义为状态?
-
Buff是不是状态的子集?(Buff是角色获得的增益或减益状态)
状态带来的改变
前面的框架有一个公共问题:主状态机的状态和技能、Buff的互斥不容易处理。
在前面的框架下,处理死亡等逻辑时通常都较为麻烦,通常需要编写特殊的代码来处理。这其实是因为主状态、技能和Buff不属于同一概念,不同概念下的事物无法进行比较,也就无法简单处理互斥;而将一切都纳入状态后,我们便可以通过统一的互斥规则代替这些特殊的互斥代码。
主动技能真的也可以吗?
被动技能和Buff的处理,想必大家不会有太多疑问,但当前施展的主动技能需要支持外部查询,该怎么处理呢?
需要“状态发布”,即状态在启动时将自己发布到关联的组件。至于状态发布的方式,我们在后面的篇章详细讨论。
public class SkillComponent {
状态槽
public class StateSlot {
静态槽和动态槽
静态槽表示预设的状态槽,在创建GameObject时即分配,动态槽则在运行的时候根据需要创建。
什么是状态槽?
我们可以通过电脑的进程和网络端口来辅助理解,先看图:
状态必须先绑定到状态槽才可以执行,好比网络应用需要先绑定网络端口;当多个状态需要绑定在同一个状态槽时,将发生状态槽冲突,将挤掉既有的状态。
一些状态只能挂载在指定槽上,如:跳跃、死亡、冰冻等只能挂载在1号槽(静态槽),也就是说,我们的1号槽充当了前面架构中的主状态机。因此,我们也可以通过1号槽查询角色的主状态。
所以状态槽就是个资源句柄,有两个作用:
-
实现状态的互斥
-
提供额外的查询
演进过程
虽然我在上面举了Unity的MonoBehavior例子,但该框架并不是直接通过组件模式演进来的,而是慢慢演进为组件模式的 -- 过程曲折。我在重新实现战斗框架的时候,着重关注三件事:
-
将行为树引入技能和Buff系统,见《行为树:序》
-
如何消除主动技能、Buff、被动技能效果交叠带来的重复感
-
如何实现技能修改器
将行为树引入技能和Buff系统,由于行为树的实现错误,这件事花了很长时间才完成;在这之前,我为了消除重复感,尝试过一个方案:为主动技能、被动技能、Buff提取一个公共的效果(Effect)抽象。
为了尽可能保证接口的通用性,接口参数仅一个黑板,如下:
public interface IEffect {
但在实现具体效果的过程中发现,由于技能执行上下文(SkillContext)和Buff是不同的抽象类型,导致Effect还是需要独立实现 -- 需要从不同的类型上读取数据。
这个问题困扰了我好久,由于我坚持认为技能和Buff是不同的东西,因而无法合并它们的抽象;但随着行为树和黑板在项目中的应用越来越多,范围越来越广,我增加了些体悟,如:
-
要想提升系统的灵活性,应尽量使用黑板代替明确的数据结构定义 —— 关注业务,而不是Class和Interface
-
在使用黑板的时候,应尽可能直接将数据set到黑板 —— 即依赖注入,避免反向依赖
在这一系列思想的影响下,我发现技能和Buff也不是不能合并了;在偶然的灵感之下,我决定使用状态(State)来表达合并后的抽象。
在确定使用状态这个概念后,我便联想到了跳跃和死亡等主状态机上的状态,我发现它们也可以纳入到新的状态管理中。为方便管理主状态的互斥以及查询,于是增加了状态槽(StateSlot)。
在获得最终的结构后,我发现了两点不同:
-
我能证明设计的正确性!不论是拿计算机下的进程来举例,还是拿组件模式来举例,都能证明它的正确性;而在以前的项目中,我都没有这个感觉,只是觉着应该是这样的。
-
我看待GameObject的视角被拉高了。在之前,我设计技能和Buff时,就只是盯着技能和Buff模块看,而现在是盯着整个GameObject在设计 —— 所以结果必然更正确。
“证明设计的正确性”是我在该项目获得的重要习惯,这为后面的技能修改器设计起到了极大的作用。
结语
“一切皆状态”这套架构有极强的扩展性和灵活性,在我看来,基本不会有更好的抽象了。
读者要想掌握它,关键在于打破通过玩游戏获得的先入为主的观念 —— 不要从玩家的角度去设计战斗系统。