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

在“一切皆状态”的架构下，[技能系统](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=%E6%8A%80%E8%83%BD%E7%B3%BB%E7%BB%9F&zhida_source=entity)并不负责技能的Update管理，所以技能系统的职责是很少的，因此本篇主要讲几个技能实现层面的架构设计。

阅读提醒：

1. 关于战斗系统的框架设计，可阅读《[战斗系统：框架设计](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247484186%26idx%3D1%26sn%3Dfde958e5c26b7e7ba572eee0b64211c8%26scene%3D21%23wechat_redirect)》
2. 本篇尚不涉及技能修改器的管理

---

## **前后摇问题**

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

### **前摇、施法、后摇是根据什么划分的？**

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

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

![](https://pic4.zhimg.com/v2-02318c3a81f428d2e1bfba09a7bd1cc7_1440w.jpg)

街霸·隆 动作拆解

PS：图片来源于网络。

### **代码里需要定义前摇、施法、后摇这些阶段吗？**

这个问题，对于常年玩游戏的程序员来说，可能有点反直觉。在我的前两个项目中，技能都是FSM式的，都在代码里明确划分了前摇、施法、后摇等阶段。如下：

```csharp
public enum SkillPhase { 
    // 开始帧
    Begin = 0, 
    // 启动（前摇），也有命名sing/pre-cast的
    StartUp = 1,
    // 执行（施法），也有命名casting的
    Active = 2,
    // 恢复（后摇），也有命名post-cast的
    Recovery = 3, 
    // 结束帧
    End = 4
} 
```

所以，我们以前的项目定义了这些阶段，我们就应该定义这些阶段吗？

_避免经验主义_！要想正确回答这个问题，我们还需要了解一些问题。

**非时间轴施法问题**

虽然游戏中的大多数技能都是时间轴施法的，因此前摇后摇的划分是比较精准的；但对于非时间轴施法技能而言，前后摇就不那么好划分。

  

**多段式技能问题**

游戏中有很多技能是多段式的，且每一段都有启动过程和恢复过程，这个能算前后摇吗？

有很多人肯定想着说不算。那么问题来了：为什么要将第一段的启动和最后一段的恢复特殊处理呢？为什么不能把每一段都看做是等同的呢？中间段就不能有特殊逻辑吗？

  

**充能（蓄力）阶段问题**

我记得在第二个项目中，还为技能设计了充能（Charge）阶段，在前摇后，施法前。

在部分多段式的技能中，每一段都是可以等待玩家输入进行蓄力的，只将第一个蓄力阶段拉出来特殊处理合理吗？

  

**[Dota2](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=Dota2&zhida_source=entity)里不就有前摇、施法和后摇这些阶段吗？**

是有的。在第三个项目，我提议干掉这些阶段时，就被客户端主程拒绝了，举的例子就是Dota2。

怎么说呢？我只能说Dota2的战斗体系还不够复杂，它和[DNF](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=DNF&zhida_source=entity)这类格斗游戏还是很有差距。

在第三个项目中，客户端的技能表现是基于前摇、施法、后摇这些事件进行的；说实话，我觉得一点都不好，远不如用编辑器画行为树（[TaskTree](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=TaskTree&zhida_source=entity)）来得清晰和可扩展。

  

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

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

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

  

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

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

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

  

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

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

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

  

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

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

  

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

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

---

### **关注真实需求**

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

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

  

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

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

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

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

![](https://picx.zhimg.com/v2-bde9696749680e6b6f56b3e8e37d065b_1440w.jpg)

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

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

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

---

## **脚本化（流程抽象）**

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

我工作近5年的时候才意识到这一点，作者的前两个项目，都是比较重度的MMORPG项目，但技能和Buff的实现都不是脚本化的。

  

什么意思呢？

这两个项目，**都没有为整个流程建立一个抽象**。最典型的就是技能流程，技能的状态切换，都是在[SkillMgr](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=SkillMgr&zhida_source=entity)里实现的，而不是由技能自身管理的。即：技能的流程是高度固化的，SkillMgr对技能的流程约定太多。

  

脚本化是什么意思呢？是要引入脚本语言吗？

NO！脚本化是指我们应当尽可能将技能和Buff的流程看做一个黑盒，Manager只是简单的去驱动它们，就像这样：

```csharp
// 作者框架下，由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架构](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=FSM%E6%9E%B6%E6%9E%84&zhida_source=entity)**

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

### **玩法需求角度**

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

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

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

![](https://picx.zhimg.com/v2-4a5b2eacf4bbb72cbaf73980cbd6ab29_1440w.jpg)

PS：DNF的武神一觉技能，在风场阶段，按Z可立即踢出终结一击。

### **技能恢复（回滚）**

在网络游戏中，客户端和服务器需要同时演算，为了表现尽可能的好，客户端会做较多的预演，包括命中判定。

对于简单的伤害技能来说，客户端判定命中后，即可播放响应的特效和音效；但对于抓取和控制技能来说，客户端就不能使用本地的命中结果，必须等待服务器通知抓取结果，然后才能进入下一阶段。

这里就有一个问题：由于网络延迟的问题，客户端可能在动作结束后都没有收到服务器的响应，那客户端该怎么办呢？是保持这个抓取动作，还是恢复到Idle动作？

需要恢复到Idle动作，这样客户端的表现才足够流畅。但这要求，**客户端在收到服务器的协议的时候，能立即从抓取成功开始执行，即技能框架需要支持从任意阶段开始执行**。

也就是说，我们的技能脚本在启动时，需要根据技能的输入立即切换到指定阶段执行。从这个角度讲，也不建议技能在逻辑层划分前摇、施法、后摇这些阶段。

```csharp
public class SkillScript1001 : Task<Blackboard> {
    public override void Enter() {
        SkillInput input = blackboard.Get(EffectKey.Input);
        if (input.stateId > 0) { // 服务器告知切换到哪个状态
            ChangeState(state2)        
        } else {
            ChangeState(state1)    
        }
    }
}
```

![](https://pic4.zhimg.com/v2-9d41102512b35c7bf5db3fa888fc47df_1440w.jpg)

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

PS：DNF的女柔道，就巨受网络延迟的影响 —— 非常影响手感。

### **逻辑表现同步切换**

我在《[战斗系统：框架设计](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247484186%26idx%3D1%26sn%3Dfde958e5c26b7e7ba572eee0b64211c8%26scene%3D21%23wechat_redirect)》中提到：由于角色模型一次只能播放一个动作，要想表现层的状态机更容易编写，逻辑层涉及到模型控制相关的逻辑最好也在同一套状态机中。

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

![](https://picx.zhimg.com/v2-4a1273ef18dbbe84c999d22fd4e62d47_1440w.jpg)

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

### **子技能问题**

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

看起来扩展性很好呢，是不是很美妙？

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

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

---

## **逻辑驱动方式**

在逻辑和动画的驱动方式上，有两种：[动画驱动逻辑](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=%E5%8A%A8%E7%94%BB%E9%A9%B1%E5%8A%A8%E9%80%BB%E8%BE%91&zhida_source=entity)和[逻辑驱动动画](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=%E9%80%BB%E8%BE%91%E9%A9%B1%E5%8A%A8%E5%8A%A8%E7%94%BB&zhida_source=entity)。

### **动画驱动逻辑**

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

简单说：逻辑层启动动画后，**依靠动画的回调执行逻辑**。

```csharp
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;        
        }
        // 动画事件触发行为
    }
}
```

### **逻辑驱动动画**

同样由逻辑启动动画的播放，但逻辑层不关心动画的播放进度，按照逻辑层的时间点触发行为。

简单说：角色就是个动画播放器。

```csharp
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（子弹），子弹击中角色时触发子弹效果。

![](https://pic1.zhimg.com/v2-3db57705e9fd2441dd1fe3b5e1f59e92_1440w.jpg)

## **技能组件的作用**

不论是新旧框架，技能组件的主要作用是一样的：技能数据中心。

只要是角色技能，都应该添加（注入）到技能组件；如果需要识别来源，可以在配置上标记。

```csharp
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. 技能屏蔽和替换：可阅读《[角色框架：变身玩法实现](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247484162%26idx%3D1%26sn%3D2dc7289b8675e9b71748a47997d5e844%26scene%3D21%23wechat_redirect)》
4. 统一技能施展流程：都通过技能模块发起，施展测试也通过技能模块进行
5. 统一同步管理

### **施展技能**

在新的“一切皆状态”框架下，施展技能的流程是比较简单的，就是根据SkillData创建对应的State，然后挂载到GameObject上即可。

```csharp
// 施展技能
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);
}
```

## **结语**

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