立即注册找回密码

QQ登录

只需一步,快速开始

微信登录

微信扫一扫,快速登录

手机动态码快速登录

手机号快速注册登录

搜索

图文播报

查看: 226|回复: 0

[讨论] 战斗系统:技能实现架构

[复制链接]
发表于 2025-4-6 13:42 | 显示全部楼层 |阅读模式

登陆有奖并可浏览互动!

您需要 登录 才可以下载或查看,没有账号?立即注册 微信登录 手机动态码快速登录

×
说完技能的配置管理,本篇聊一聊技能的逻辑管理。
    在“一切皆状态”的架构下,技能系统并不负责技能的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
楼主热帖
回复

使用道具 举报

发表回复

您需要登录后才可以回帖 登录 | 立即注册 微信登录 手机动态码快速登录

本版积分规则

关闭

官方推荐 上一条 /3 下一条

快速回复 返回列表 客服中心 搜索 官方QQ群 洽谈合作
快速回复返回顶部 返回列表