
阅读提醒：

1. 本篇虽然总是以技能为例，但在一切皆状态的架构下，所有的状态效果配置都是相同的。
    
2. 关于表格设计，可阅读《[配置：表格设计](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247483826&idx=1&sn=2d3cc9ac988cb20e60fb8fca1405bf52&scene=21#wechat_redirect)》
    

---

## **Excel之殇**

作者的第一个项目和第二个项目，都是使用Excel来配置技能的，那复杂度简直爆炸 —— 好几张表，每张表又是几十个字段。回过头看，我们的战斗策划的工作真是太艰难了，策划里面加班最多的，就是数值策划和战斗策划，可是真的需要那么加班吗？

我在《[配置：吐槽时间](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247483826%26idx%3D2%26sn%3Dd4aacd587f734bdd25d1d82a220f7a60%26scene%3D21%23wechat_redirect)》中提到：**因为程序不配表，体会不到策划配表的痛苦，因此缺少反思**。程序很少去思考自己设计的正确性，或是想着能用就行，不考虑团队其它成员的工作效率，这对整个项目来说是非常有害的。

程序，就是用来解放生产力的。如果你的程序不仅没有减少他人的工作量，反而增加他人工作量，那这个程序就是不合格的。

## **[状态总表](https://zhida.zhihu.com/search?content_id=255785060&content_type=Article&match_order=1&q=%E7%8A%B6%E6%80%81%E6%80%BB%E8%A1%A8&zhida_source=entity)**

我在前篇《[战斗系统：什么是数据驱动？](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247484217%26idx%3D1%26sn%3D8fe2c017ab84501915d99ec79c3371c3%26scene%3D21%23wechat_redirect)》中提到，对于技能这种复杂的数据，应该舍弃Excel，使用Json或Lua等来配置。那我们是否还需要一张状态总表？

我的建议是需要，它有几个作用：

1. 状态总览：我们可以知晓一共有哪些状态，以及状态的一些基础数据
2. 有效性控制：只有在表格中存在的状态才是有效的，而不是所有扫描到的配置都有效
3. 简化配置：虽然行为相关的参数不适合配置在Excel，但其它参数配置在表格更加方便

![](https://pic3.zhimg.com/v2-f8d27bf17d3bad3752005ecb02478d0a_1440w.jpg)

PS：关于状态表的字段设计，可阅读《[战斗系统：状态管理](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247484197%26idx%3D1%26sn%3D6e069ad2972f1222cf96eaa118116c37%26scene%3D21%23wechat_redirect)》。

## **[技能总表](https://zhida.zhihu.com/search?content_id=255785060&content_type=Article&match_order=1&q=%E6%8A%80%E8%83%BD%E6%80%BB%E8%A1%A8&zhida_source=entity)**

同理，技能我们也需要一张总表，用于控制技能的有效性。不过，**技能表中的ID必须在状态表中存在**，这有几个作用：

1. 可通过技能ID查询关联的State配置 —— 约定大于配置
2. _脚本中不需要区分状态ID和技能ID_，可以避免诸多麻烦

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

Q：哪些需要配置在数据脚本，哪些需要配置Excel表？

A：与执行相关的数据都配置在数据脚本（通过编辑器配置），其它则配置在Excel中。换句话说，Excel配置的主要是与Player养成功能相关的数据。

**技能类型**

技能的分类也是多维度的，不过没有状态那般复杂，所以可以展开为多列 —— 但建议仍采用Tag分类法。

被动技能也采用标签表达。

  

**技能Tips和状态Tips**

技能的Tips仍然配置在技能表，状态的Tips配置在状态表，虽然技能ID一定在状态表存在，但技能的Tips不能在状态表。

**技能的Tips属于预览数据的Tips，而状态的Tips属于运行时数据Tips**（真实数据的Tips）。

直接说，可能大家不太明白。我举个例子：DNF的武神步，其技能描述如下：

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

最终加了多少力量，在技能界面是看不见的，是动态计算的。

PS：DNF的武神玩家都是多动症玩家...

  

**施展条件**

有些项目是将技能的施展条件放在了技能表中的，比如我以前的项目；施展条件确实不属于运行时数据，但条件是存在组合和分层的，用Excel很难表达（很不直观）。有几种方式：

1. 手写Json（或其它格式）
2. 为Excel制作编辑器，可视化编辑Json
3. 转移到数据脚本，通过编辑器编辑

为Excel做编辑器需要巨大的工作量才能做得好，但做好了收益巨大；不过，对于中小团队而言，教会策划写Json更具性价比。

在有通用数据编辑器的情况下，将这部分配置转移编辑器也是个方案；但数据一部分在表格，一部分在编辑器导出的Json文本，可能会带来一些不便。  

不过，由于技能的执行数据是在编辑器中配置的，所以将施展条件等转移到编辑器不会有额外的副作用，所以很合适。

---

## **数值成长问题**

在我将[行为树](https://zhida.zhihu.com/search?content_id=255785060&content_type=Article&match_order=1&q=%E8%A1%8C%E4%B8%BA%E6%A0%91&zhida_source=entity)引入到战斗系统时，技能的所有参数都直接配置在了行为树的节点上，这面临着几个问题：

1. 角色技能是支持升级的，升级以后数值会变化，总不能让策划每一级画一棵树吧？
2. 参数散落在行为树的节点上，客户的技能Tips该怎么做？
3. 表现层和逻辑层的逻辑可能不同，客户端和服务器可能使用两棵树，但部分参数是相同的。

其实第一个问题还好，因为技能升级，其数值通常都是按公式计算的，所以我们可以直接在编辑器中支持公式，就像这样：

```csharp
public class Value {
    public double value;
    public int deltaLv; // 每多少级执行一次成长
    public double k;
    public double b;
    public double min;
    public double max;
}
```

PS：这里仅以线性成长函数为例。

至于客户端技能Tips，一种常见的方式是：策划将格式文本（Fromat）和数值分离，并冗余定义一套变量，由程序根据公式计算每一级的数值，然后再根据Format计算最终的展示文本 —— 使用name占位符。

这种方案虽然能解决问题，但我向来对**增加策划工作量的设计**表示否定，因为手工维护的东西（冗余数据）越多，工作效率越低，出错的概率也越高；而程序处理问题，通常是一次性的，对就是对，错就是错；如果担心运行时的性能，可以通过开发期的工具解决。

第三个问题，自然也可以通过冗余配置来解决，但这也是会增加策划工作量的，所以不好。

正确的方案是：**将变量定义在行为树外部**（集中定义），行为树中的节点通过name引用关联的字段。就像这样：

![](https://pica.zhimg.com/v2-a7fca1a1a43ac54206d996421bf9b78e_1440w.jpg)

数据结构定义如下：

```csharp
// 状态变量：和上面的Value结构类似，只是多了name
public class StateVar {
    public string name; // 变量名
    public double value; // value = k * lv +b
    // ...
}
// 状态脚本配置--运行时会缓存在State对象上
public class StateTaskCfg {
    // 状态变量
    public List<StateVar> stateVars;
    // 技能施展条件
    public ICondition  castCond;
    // 服务器脚本
    public Task<Blackboard> stask;
    // 客户端脚本
    public Task<Blackboard> ctask;
}
// 变量引用
public class VarRef {
    public string name; // 变量名，非空表引用
    public double value; // 常量值
    public int index = -1; // 查询缓存
}
```

简单文本示例：

```as3
// 技能配置 - Dson文本格式
{@{StateTaskCfg}
    skillId: 1001,
    skillVars: [
        {name: attack_01, value: 1.5, min: 1.5, max: 3}, 
        {name: attack_02, value: 1.2, min: 1.2, max: 2}, 
    ],
    castCond: {},    
    stask: {},
    ctask: {} 
}
// 伤害节点示例：攻击力系数为变量，元素类型为固定值
{@{DamageAction}
    attack: {name: attack_01},
    element: {value: 1}
}
```

我们将技能、Buff中的**所有数值都定义为VarRef类型** —— 包括触发间隔、触发次数、技能CD等。如果变量名不为空字符串，则表示变量引用，否则表示常量值。

### **定制等级属性**

首先，数值的成长最好是可预测的，这对玩家来说更容易理解，所以并不建议如此做。

如果真的有这种需求，是可以支持的，我们只需要将成长公式分等级段即可。

```csharp
public class Value {
    // 成长公式配置
    List<ValueUpgradeCfg> upgradeCfgs;
}
public class ValueUpgradeCfg {
    public int minLv; // 公式适用等级
    public int maxLv;
    
    public double value; // 该段的初始值
    public int deltaLv; // 每多少级执行一次成长
    public double k;
    public double b;
    public double min;
    public double max;
}
```

## 数值传递问题

![](https://pic3.zhimg.com/v2-3f5e9d520ce0ab86d66c211898a649d6_1440w.jpg)

技能加Buff是非常常见的需求，如果需要在技能的Tips界面，看见最终的Buff数值，如持续时间，属性加成等，应该怎么做？

最直接的办法，就是**保持两个脚本具有相同的变量** —— name和成长公式相同。这样，只要技能等级和Buff等级一样，那么算出的数值也就一样，也就不需要传递了。

另一种方案是运行时解决，**允许指定状态的变量值**，即覆盖变量，代码如下：

```csharp
public sealed class State {
    public StateCfg cfg;
    public int lv; // 状态等级
    public StateTaskCfg taskCfg; // 变量配置在这里
    public List<double> values; // 最终值
    // ...
    // 查询变量值
    public double GetValue(VarRef varRef) {
       if (string.IsEmptyOrWhitespace(varRef.name)) {
           return varRef.value; // 常量
       }
       if (varRef.index < 0) { // 索引缓存
           varRef.index = taskCfg.GetIndex(varRef.name);
       }
       return values[varRef.index];
    }
}
```

将状态由等级计算的数值全部缓存下来，然后允许外部修改。

**那怎么选呢？**

自然是全都要。**技能的数值并不完全由等级计算，还会受到技能修改器影响**，因此保持公式一致并不能完全保证数值的一致性，所有后者是必须的。

而前者也是必须的，因为Buff并非总是由主动技能添加，也可能由装备的被动技能触发，我们需要保持在无修改器的情况下，等级相同时属性一致。如果担心冗余配置出错，可以通过[数据检查工具](https://zhida.zhihu.com/search?content_id=255785060&content_type=Article&match_order=1&q=%E6%95%B0%E6%8D%AE%E6%A3%80%E6%9F%A5%E5%B7%A5%E5%85%B7&zhida_source=entity)来校验 —— 数据检查工具是不可或缺的。

---

## **舍弃子弹表**

我们通常有一个GameObject的总表，配置着逻辑层用到的所有GameObject；然后，为了减少总表的列数，部分项目会将子类型才拥有的字段会拆分到子表中，子弹表就是比较常见的。

游戏中的子弹有许多特殊的职责 —— 这个我们后面的文章讲，因此子弹表通常有非常多的字段，而且主要是跟逻辑相关的。

在一切皆状态的框架下，子弹表是可以完全省略的，并不需要额外的配置；但如果大家使用的自己的框架，我建议将子弹的配置改为Json或Lua，也采用数据驱动的方式配置，这可以大幅提供系统的可维护性和可扩展性。

---

## **选择一种好的文本格式**

最后，我们应当选择一种好的文本格式来记录数据，它需要满足以下基本条件：

1. 能精确表达数据
2. 易于阅读和手工编写
3. 易于维护（修改）
4. 支持注释
5. 良好的格式

作者在处理序列化问题的时候，设计了自己的数据文本格式《[Dson文本格式](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247483975%26idx%3D1%26sn%3D9f20abc69e456cbea798be54ca227135%26scene%3D21%23wechat_redirect)》，有兴趣的读者可以看一看。

---

## **结语**

除了技能和Buff外，其实我们的副本流程这些也需要脚本化，其表格和数据脚本的设计，规则是相似的。