金桔
金币
威望
贡献
回帖 0
精华
在线时间 小时
说完技能的配置管理,本篇聊一聊技能的逻辑管理。
在“一切皆状态”的架构下,技能系统并不负责技能的Update管理,所以技能系统的职责是很少的,因此本篇主要讲几个技能实现层面的架构设计。
阅读提醒:
<hr/>前后摇问题
在说其它问题之前,我决定先说前后摇的问题,我想再次提醒各位程序和策划:不要站在用户的角度来设计架构 。
前摇、施法、后摇是根据什么划分的?
时间 。按格斗游戏的标准来说:
技能开始到第一个打击(Hit)帧之前,称之为前摇
第一个打击帧到最后一个打击帧,称之为施法
最后一个打击帧之后到技能结束,称之为后摇
街霸·隆 动作拆解
PS:图片来源于网络。
代码里需要定义前摇、施法、后摇这些阶段吗?
这个问题,对于常年玩游戏的程序员来说,可能有点反直觉。在我的前两个项目中,技能都是FSM式的,都在代码里明确划分了前摇、施法、后摇等阶段。如下:
public enum SkillPhase {
// 开始帧
Begin = 0,
// 启动(前摇),也有命名sing/pre-cast的
StartUp = 1,
// 执行(施法),也有命名casting的
Active = 2,
// 恢复(后摇),也有命名post-cast的
Recovery = 3,
// 结束帧
End = 4
}
所以,我们以前的项目定义了这些阶段,我们就应该定义这些阶段吗?
避免经验主义 !要想正确回答这个问题,我们还需要了解一些问题。
非时间轴施法问题
虽然游戏中的大多数技能都是时间轴施法 的,因此前摇后摇的划分是比较精准的;但对于非时间轴施法技能而言,前后摇就不那么好划分。
多段式技能问题
游戏中有很多技能是多段式的,且每一段都有启动过程和恢复过程,这个能算前后摇吗?
有很多人肯定想着说不算。那么问题来了:为什么要将第一段的启动和最后一段的恢复特殊处理呢?为什么不能把每一段都看做是等同的呢 ?中间段就不能有特殊逻辑吗?
充能(蓄力)阶段问题
我记得在第二个项目中,还为技能设计了充能(Charge)阶段,在前摇后,施法前。
在部分多段式的技能中,每一段都是可以等待玩家输入进行蓄力的 ,只将第一个蓄力阶段拉出来特殊处理合理吗?
Dota2里不就有前摇、施法和后摇这些阶段吗?
是有的。在第三个项目,我提议干掉这些阶段时,就被客户端主程拒绝了,举的例子就是Dota2。
怎么说呢?我只能说Dota2的战斗体系还不够复杂,它和DNF这类格斗游戏还是很有差距。
在第三个项目中,客户端的技能表现是基于前摇、施法、后摇这些事件进行的;说实话,我觉得一点都不好,远不如用编辑器画行为树(TaskTree)来得清晰和可扩展。
前后摇阶段的问题到底是什么?
流程划分得越细,就越固化,系统的灵活性和扩展性越差 。
上面的充能阶段就是典型,要想保持技能系统的灵活性,需要尽可能的减少对技能流程的约束。
所以,代码里需要定义前摇、施法、后摇这些阶段吗?
不需要,不建议!如果项目中已经定义了前后摇这些阶段,可以保留,但应尽可能减少逻辑层对这些阶段的依赖。
如果是新项目,我建议尽可能避免定义这些施法阶段;如果真的要定义,也要尽可能保持粗糙,有前摇、施法、后摇三个阶段就可以了,不要再去细分。
避免感知其它技能的执行阶段
部分项目有这样的需求:目标技能进入后摇时触发一个行为。
这其实是非常不好的需求,策划提出这样的需求,证明策划对战斗系统的认知还不到位,他不知道他这样的设计会导致什么样的后果。前后摇的需求,只应该出现在表现层,而不应该出现在逻辑层,否则会严重影响系统的扩展性 。
为什么会导致扩展性问题?
每个技能都是一个脚本,只有尽可能减少对其它技能(脚本)执行过程的假设 —— 除非技能之间是深度绑定的,系统才有最佳的灵活性和扩展性。
那深度绑定的技能之间如何监听流程呢?
被监听的技能在执行特定的阶段时,抛出一个特定的事件即可。
<hr/>关注真实需求
程序通常会直接按照他听到的需求来实现功能,但这在战斗这种复杂系统的实现中是不行的。程序需要关注策划需求中的真实需求,而不是表面需求 。
以前摇后摇这个问题为例,我们应当关注策划想设计这些阶段的目的是什么 ,比如:前摇时被打断不记CD、后摇时可施展其它技能等等,而不是关注策划的这些阶段。
以前摇时被打断不记CD这个需求为例,大家会怎么实现?
我想,很多程序员第一想法就是给技能划阶段,然后在技能退出的时候判断是否是前摇阶段,然后决定是否执行技能CD。
那假设策划现在出了一个新需求,只要抓取技能没有抓到敌人,就不触发CD —— DNF女柔道的空绞锤就是这样,那你又应该怎么办?
要想避免每次出现新需求时都去打补丁,正确的方式是让策划在编辑器(或脚本)中去配置触发CD的时机 ,想在什么时候触发就在什么时候触发 —— 控制反转。
空绞锤未抓取敌人时,不触发CD
如果你说这样配置的话,策划工作量很大,不好。
那确实是,我们可以提供一个通用的前摇节点,当技能无特殊需求时,策划将这个节点挂上去就行 —— 而我们的代码里并不需要前摇后摇这些概念。
<hr/>脚本化(流程抽象)
技能只有脚本化才有无限可能 。
我工作近5年的时候才意识到这一点,作者的前两个项目,都是比较重度的MMORPG项目,但技能和Buff的实现都不是脚本化的。
什么意思呢?
这两个项目,都没有为整个流程建立一个抽象 。最典型的就是技能流程,技能的状态切换,都是在SkillMgr里实现的,而不是由技能自身管理的。即:技能的流程是高度固化的,SkillMgr对技能的流程约定太多。
脚本化是什么意思呢?是要引入脚本语言吗?
NO!脚本化是指我们应当尽可能将技能和Buff的流程看做一个黑盒 ,Manager只是简单的去驱动它们,就像这样:
// 作者框架下,由TaskTree实现
public interface ISkillScript {
void Update();
void OnEvent(object evt);
}
// 技能模块就是简单地调用技能脚本的Update函数即可
public void Update(GameObject gobj) {
foreach (SkillContext ctx in gobj.skillComponent.castingSkills) {
ctx.script.Update();
}
}
如果是写过Unity的,可以将技能比作MonoBehavior,就明白什么意思了。
<hr/>FSM架构
技能需要实现为FSM架构,这可以从多个方面说明。
玩法需求角度
虽然游戏中的多数技能都是时间轴施法,但如果所有的技能都是时间轴施法,那么游戏就会变得无趣。所以技能需要支持两件事:
响应玩家的输入,根据玩家输入的不同产生不同的行为
根据环境的变化,主要指目标变化,产生不同的行为
环境数据也可以看做输入,而要支持根据输入的不同,跳转到不同的行为,就依赖于FSM架构。
PS:DNF的武神一觉技能,在风场阶段,按Z可立即踢出终结一击。
技能恢复(回滚)
在网络游戏中,客户端和服务器需要同时演算,为了表现尽可能的好,客户端会做较多的预演,包括命中判定。
对于简单的伤害技能来说,客户端判定命中后,即可播放响应的特效和音效;但对于抓取和控制技能来说,客户端就不能使用本地的命中结果,必须等待服务器通知抓取结果,然后才能进入下一阶段。
这里就有一个问题:由于网络延迟的问题,客户端可能在动作结束后都没有收到服务器的响应 ,那客户端该怎么办呢?是保持这个抓取动作,还是恢复到Idle动作?
需要恢复到Idle动作,这样客户端的表现才足够流畅。但这要求,客户端在收到服务器的协议的时候,能立即从抓取成功开始执行,即技能框架需要支持从任意阶段开始执行 。
也就是说,我们的技能脚本在启动时,需要根据技能的输入立即切换到指定阶段执行。从这个角度讲,也不建议技能在逻辑层划分前摇、施法、后摇这些阶段。
public class SkillScript1001 : Task<Blackboard> {
public override void Enter() {
SkillInput input = blackboard.Get(EffectKey.Input);
if (input.stateId > 0) { // 服务器告知切换到哪个状态
ChangeState(state2)
} else {
ChangeState(state1)
}
}
}
如果网络延迟,可能在超过怪物身位后将怪物抓住
PS:DNF的女柔道,就巨受网络延迟的影响 —— 非常影响手感。
逻辑表现同步切换
我在《战斗系统:框架设计 》中提到:由于角色模型一次只能播放一个动作,要想表现层的状态机更容易编写,逻辑层涉及到模型控制相关的逻辑最好也在同一套状态机中。
当时是针对主状态机而言的,但对于单个技能来说同样适用:当一个技能由多个动作构成时,动作切换最好伴随着逻辑切换 。而这些动作之间是平级的,那么对应的逻辑节点之间也最好是平级的,即FSM式的。
PS:逻辑切换时,动作不一定切换。
子技能问题
我发现有些项目喜欢用子技能来解决问题,比如常见的普攻三连击,有些项目会将其实现为三个子技能。
看起来扩展性很好呢,是不是很美妙?
其实是个不好的设计。首先,事件可能需要测试当前施展的技能,引入子技能,这个问题就会变得麻烦;此外,如果项目想要设计技能修改器,子技能也会导致诸多的问题。
正确的方式就是实现FSM架构,一个技能不论多复杂,最好都保持为一个脚本 (对象)。
<hr/>逻辑驱动方式
在逻辑和动画的驱动方式上,有两种:动画驱动逻辑和逻辑驱动动画。
动画驱动逻辑
由逻辑启动动画的播放,但逻辑层不执行更新,而是由动画播放到打击点和结束的时候,通知逻辑层执行相关行为 —— 需要在动画上记录数据。
简单说:逻辑层启动动画后,依靠动画的回调执行逻辑 。
public class Script : Task<Blackboard> {
private int triggerCount; // 已触发次数
public override void Enter() {
// 播放技能01动画,并将自身作为回调传入,这期间不进行Update
gobj.animator.PlayAction(EAnimation.Skill_01, this)
}
public void OnEvent(AnimationEvent evt) {
if (evt.type == EType.AniCompleted) {
SetSuccess(); // 流程结束
return;
}
// 动画事件触发行为
}
}
逻辑驱动动画
同样由逻辑启动动画的播放,但逻辑层不关心动画的播放进度,按照逻辑层的时间点触发行为。
简单说:角色就是个动画播放器。
public class Script : Task<Blackboard> {
private ScheduleCfg cfg; // // 效果调度配置
private int triggerCount; // 已触发次数
public override void Enter() {
// 播放技能01动画,单纯调起
gobj.animator.PlayAction(EAnimation.Skill_01)
}
public override void Execute() {
// 逻辑层管理触发时机
State state = blackboard.Get(EffectKey.state);
if (state.timeEscaped < cfg.enterTime) return;
// ...
}
}
两者有什么区别?
如果说游戏运行很流畅,不卡顿也没有延迟,那么两者可能看不出区别;但如果渲染性能跟不上,就会有明显的差别。
假设渲染层本该是60帧 / 秒,但现在只有10帧 / 秒;那么在动画驱动逻辑的方案下,逻辑层的施法施法时间就会被拉长,但总是在动画播放到特定帧时执行相关逻辑;而如果是在逻辑驱动动画的方案下,表现层的时间就会被缩短,表现为技能动画可能才播了一点伤害都打完了。
也就是说,在动画驱动逻辑的情况下,动画和逻辑总是同步的 —— 视觉体验好 ;而在逻辑驱动动画的情况下,动画和逻辑的匹配是随缘的,但网络同步会更容易,更容易保证多客户端之间的一致性。
如何选择?
如果是单机游戏,首选动画驱动逻辑;如果说经验不足,也可以选择逻辑驱动动画,在渲染压力不大的情况下,两者差异不大。
如果是网络游戏,但很重视打击感,如ACT和ARPG,也建议选择动画驱动逻辑,而且客户端需要预演,否则体验会很差;如果对打击感的要求不那么高,比如MMORPG,可以选择逻辑驱动动画。
<hr/>特效的定位
本篇不讲特效的实现和管理,作者想强调的是:特效永远不应该参与到逻辑 。
为了追求打击感的精确性,我们可以让角色的动作来驱动逻辑,但万不可让特效来触发逻辑。特效、音效、着色器这些都应该作为纯粹的表现层,使得我们随时可以禁用和关闭它们。
如果想用“特效”触发逻辑怎么办?
以DNF的狂战士为例,击杀敌人时概率出现血球,血球会飞到角色身上,然后触发回血。这个血球看起来可能是个特效,但应该实现为GameObject(子弹),子弹击中角色时触发子弹效果。
技能组件的作用
不论是新旧框架,技能组件的主要作用是一样的:技能数据中心。
只要是角色技能,都应该添加(注入)到技能组件;如果需要识别来源,可以在配置上标记。
public class SkillComponent {
// 角色身上的所有技能
public Dictionary<int, SkillData> skillDataDic;
// 技能冷却信息
public Dictionary<int, double> cdDic;
}
public class SkillData {
public SkillCfg cfg;
public SkillTaskCfg taskCfg; // 脚本配置,见前篇
public List<double> values; // 技能属性,见前篇
public int lv;
public int Id => cfg.Id;
public double Cd => values[0];
}
作者曾经的项目中,出现过这类需求:开启某个系统后,获得一个技能槽,可以装备该系统激活的技能。
作者在第二个项目的时候,似乎没有把这类技能加入到技能模块;但在第三个项目的时候,就是加入到了技能组件的。
为什么要加入到技能组件?
有几个点:
解耦:战斗系统尽可能关注更少的模块
技能筛选:技能修改器等需要筛选技能,技能数据集中更容易实现
技能屏蔽和替换:可阅读《角色框架:变身玩法实现 》
统一技能施展流程:都通过技能模块发起,施展测试也通过技能模块进行
统一同步管理
施展技能
在新的“一切皆状态”框架下,施展技能的流程是比较简单的,就是根据SkillData创建对应的State,然后挂载到GameObject上即可。
// 施展技能
public void CastSkill(GameObject gobj, SkillData skillData, SkillInput input) {
// 派发施展技能事件
BeforeCastSkill(GameObject gobj, SkillData skillData);
// 创建State,然后覆盖默认由等级算出的数值
State state = StateUtil.CreateState(skillData.Id, skillData.lv);
state.values.Clear();
state.values.AddRange(skillData.values);
state.lv = skillData.lv;
state.input = input;
// 添加状态 -- 启动技能
stateMgr.AddState(gobj, state);
}
结语
本篇主要讲的是一些高层设计,关于细节的实现,以后讲一部分。不过,作者最近有很多代码要写,文章的更新频率可能会放缓。
PS:这么硬核的文章哪里还有呢?稀罕作者 、点赞 和推荐 都可以增加作者的创作动力哦。
原文地址:https://zhuanlan.zhihu.com/p/1890729361855976910
楼主热帖