Files
obsidian-notes/2Areas/游戏开发/战斗编辑器/参考文章/写代码的诗人/战斗系统:技能实现架构.md
2025-08-04 17:40:09 +08:00

18 KiB
Raw Blame History

说完技能的配置管理,本篇聊一聊技能的逻辑管理。

在“一切皆状态”的架构下,技能系统并不负责技能的Update管理所以技能系统的职责是很少的因此本篇主要讲几个技能实现层面的架构设计。

阅读提醒:

  1. 关于战斗系统的框架设计,可阅读《战斗系统:框架设计
  2. 本篇尚不涉及技能修改器的管理

前后摇问题

在说其它问题之前,我决定先说前后摇的问题,我想再次提醒各位程序和策划:不要站在用户的角度来设计架构

前摇、施法、后摇是根据什么划分的?

时间。按格斗游戏的标准来说:

  1. 技能开始到第一个打击Hit帧之前称之为前摇
  2. 第一个打击帧到最后一个打击帧,称之为施法
  3. 最后一个打击帧之后到技能结束,称之为后摇

街霸·隆 动作拆解

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)来得清晰和可扩展。

前后摇阶段的问题到底是什么?

流程划分得越细,就越固化,系统的灵活性和扩展性越差

上面的充能阶段就是典型,要想保持技能系统的灵活性,需要尽可能的减少对技能流程的约束。

所以,代码里需要定义前摇、施法、后摇这些阶段吗?

不需要,不建议!如果项目中已经定义了前后摇这些阶段,可以保留,但应尽可能减少逻辑层对这些阶段的依赖。

如果是新项目,我建议尽可能避免定义这些施法阶段;如果真的要定义,也要尽可能保持粗糙,有前摇、施法、后摇三个阶段就可以了,不要再去细分。

避免感知其它技能的执行阶段

部分项目有这样的需求:目标技能进入后摇时触发一个行为。

这其实是非常不好的需求,策划提出这样的需求,证明策划对战斗系统的认知还不到位,他不知道他这样的设计会导致什么样的后果。前后摇的需求,只应该出现在表现层,而不应该出现在逻辑层,否则会严重影响系统的扩展性

为什么会导致扩展性问题?

每个技能都是一个脚本,只有尽可能减少对其它技能(脚本)执行过程的假设 —— 除非技能之间是深度绑定的,系统才有最佳的灵活性和扩展性。

那深度绑定的技能之间如何监听流程呢?

被监听的技能在执行特定的阶段时,抛出一个特定的事件即可。


关注真实需求

程序通常会直接按照他听到的需求来实现功能,但这在战斗这种复杂系统的实现中是不行的。程序需要关注策划需求中的真实需求,而不是表面需求

以前摇后摇这个问题为例我们应当关注策划想设计这些阶段的目的是什么比如前摇时被打断不记CD、后摇时可施展其它技能等等而不是关注策划的这些阶段。

以前摇时被打断不记CD这个需求为例大家会怎么实现

我想很多程序员第一想法就是给技能划阶段然后在技能退出的时候判断是否是前摇阶段然后决定是否执行技能CD。

那假设策划现在出了一个新需求只要抓取技能没有抓到敌人就不触发CD —— DNF女柔道的空绞锤就是这样那你又应该怎么办

要想避免每次出现新需求时都去打补丁正确的方式是让策划在编辑器或脚本中去配置触发CD的时机想在什么时候触发就在什么时候触发 —— 控制反转。

空绞锤未抓取敌人时不触发CD

如果你说这样配置的话,策划工作量很大,不好。

那确实是,我们可以提供一个通用的前摇节点,当技能无特殊需求时,策划将这个节点挂上去就行 —— 而我们的代码里并不需要前摇后摇这些概念。


脚本化(流程抽象)

技能只有脚本化才有无限可能

我工作近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就明白什么意思了。


FSM架构

技能需要实现为FSM架构这可以从多个方面说明。

玩法需求角度

虽然游戏中的多数技能都是时间轴施法,但如果所有的技能都是时间轴施法,那么游戏就会变得无趣。所以技能需要支持两件事:

  1. 响应玩家的输入,根据玩家输入的不同产生不同的行为
  2. 根据环境的变化,主要指目标变化,产生不同的行为

环境数据也可以看做输入而要支持根据输入的不同跳转到不同的行为就依赖于FSM架构。

PSDNF的武神一觉技能在风场阶段按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)    
        }
    }
}

如果网络延迟,可能在超过怪物身位后将怪物抓住

PSDNF的女柔道就巨受网络延迟的影响 —— 非常影响手感。

逻辑表现同步切换

我在《战斗系统:框架设计》中提到:由于角色模型一次只能播放一个动作,要想表现层的状态机更容易编写,逻辑层涉及到模型控制相关的逻辑最好也在同一套状态机中。

当时是针对主状态机而言的,但对于单个技能来说同样适用:当一个技能由多个动作构成时,动作切换最好伴随着逻辑切换。而这些动作之间是平级的那么对应的逻辑节点之间也最好是平级的即FSM式的。

PS逻辑切换时动作不一定切换。

子技能问题

我发现有些项目喜欢用子技能来解决问题,比如常见的普攻三连击,有些项目会将其实现为三个子技能。

看起来扩展性很好呢,是不是很美妙?

其实是个不好的设计。首先,事件可能需要测试当前施展的技能,引入子技能,这个问题就会变得麻烦;此外,如果项目想要设计技能修改器,子技能也会导致诸多的问题。

正确的方式就是实现FSM架构一个技能不论多复杂,最好都保持为一个脚本(对象)。


逻辑驱动方式

在逻辑和动画的驱动方式上,有两种:动画驱动逻辑逻辑驱动动画

动画驱动逻辑

由逻辑启动动画的播放,但逻辑层不执行更新,而是由动画播放到打击点和结束的时候,通知逻辑层执行相关行为 —— 需要在动画上记录数据。

简单说:逻辑层启动动画后,依靠动画的回调执行逻辑

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可以选择逻辑驱动动画。


特效的定位

本篇不讲特效的实现和管理,作者想强调的是:特效永远不应该参与到逻辑

为了追求打击感的精确性,我们可以让角色的动作来驱动逻辑,但万不可让特效来触发逻辑。特效、音效、着色器这些都应该作为纯粹的表现层,使得我们随时可以禁用和关闭它们。

如果想用“特效”触发逻辑怎么办?

以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];
}

作者曾经的项目中,出现过这类需求:开启某个系统后,获得一个技能槽,可以装备该系统激活的技能。

作者在第二个项目的时候,似乎没有把这类技能加入到技能模块;但在第三个项目的时候,就是加入到了技能组件的。

为什么要加入到技能组件?

有几个点:

  1. 解耦:战斗系统尽可能关注更少的模块
  2. 技能筛选:技能修改器等需要筛选技能,技能数据集中更容易实现
  3. 技能屏蔽和替换:可阅读《角色框架:变身玩法实现
  4. 统一技能施展流程:都通过技能模块发起,施展测试也通过技能模块进行
  5. 统一同步管理

施展技能

在新的“一切皆状态”框架下施展技能的流程是比较简单的就是根据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);
}

结语

本篇主要讲的是一些高层设计,关于细节的实现,以后讲一部分。不过,作者最近有很多代码要写,文章的更新频率可能会放缓。