Files
obsidian-notes/InBox/战斗/写代码的诗人/战斗系统:状态配置管理(技能配置管理).md
2025-06-11 18:37:23 +08:00

12 KiB
Raw Blame History

阅读提醒:

  1. 本篇虽然总是以技能为例,但在一切皆状态的架构下,所有的状态效果配置都是相同的。

  2. 关于表格设计,可阅读《配置:表格设计


Excel之殇

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

我在《配置:吐槽时间》中提到:因为程序不配表,体会不到策划配表的痛苦,因此缺少反思。程序很少去思考自己设计的正确性,或是想着能用就行,不考虑团队其它成员的工作效率,这对整个项目来说是非常有害的。

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

状态总表

我在前篇《战斗系统:什么是数据驱动?》中提到对于技能这种复杂的数据应该舍弃Excel使用Json或Lua等来配置。那我们是否还需要一张状态总表

我的建议是需要,它有几个作用:

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

PS关于状态表的字段设计可阅读《战斗系统:状态管理》。

技能总表

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

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

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

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

技能类型

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

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

技能Tips和状态Tips

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

技能的Tips属于预览数据的Tips而状态的Tips属于运行时数据Tips真实数据的Tips

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

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

PSDNF的武神玩家都是多动症玩家...

施展条件

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

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

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

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

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


数值成长问题

在我将行为树引入到战斗系统时,技能的所有参数都直接配置在了行为树的节点上,这面临着几个问题:

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

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

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引用关联的字段。就像这样

数据结构定义如下:

// 状态变量和上面的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; // 查询缓存
}

简单文本示例:

// 技能配置 - 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等。如果变量名不为空字符串则表示变量引用否则表示常量值。

定制等级属性

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

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

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

数值传递问题

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

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

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

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并非总是由主动技能添加也可能由装备的被动技能触发我们需要保持在无修改器的情况下等级相同时属性一致。如果担心冗余配置出错可以通过数据检查工具来校验 —— 数据检查工具是不可或缺的。


舍弃子弹表

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

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

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


选择一种好的文本格式

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

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

作者在处理序列化问题的时候,设计了自己的数据文本格式《Dson文本格式》,有兴趣的读者可以看一看。


结语

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