12 KiB
阅读提醒:
-
本篇虽然总是以技能为例,但在一切皆状态的架构下,所有的状态效果配置都是相同的。
-
关于表格设计,可阅读《配置:表格设计》
Excel之殇
作者的第一个项目和第二个项目,都是使用Excel来配置技能的,那复杂度简直爆炸 —— 好几张表,每张表又是几十个字段。回过头看,我们的战斗策划的工作真是太艰难了,策划里面加班最多的,就是数值策划和战斗策划,可是真的需要那么加班吗?
我在《配置:吐槽时间》中提到:因为程序不配表,体会不到策划配表的痛苦,因此缺少反思。程序很少去思考自己设计的正确性,或是想着能用就行,不考虑团队其它成员的工作效率,这对整个项目来说是非常有害的。
程序,就是用来解放生产力的。如果你的程序不仅没有减少他人的工作量,反而增加他人工作量,那这个程序就是不合格的。
状态总表
我在前篇《战斗系统:什么是数据驱动?》中提到,对于技能这种复杂的数据,应该舍弃Excel,使用Json或Lua等来配置。那我们是否还需要一张状态总表?
我的建议是需要,它有几个作用:
- 状态总览:我们可以知晓一共有哪些状态,以及状态的一些基础数据
- 有效性控制:只有在表格中存在的状态才是有效的,而不是所有扫描到的配置都有效
- 简化配置:虽然行为相关的参数不适合配置在Excel,但其它参数配置在表格更加方便
PS:关于状态表的字段设计,可阅读《战斗系统:状态管理》。
技能总表
同理,技能我们也需要一张总表,用于控制技能的有效性。不过,技能表中的ID必须在状态表中存在,这有几个作用:
- 可通过技能ID查询关联的State配置 —— 约定大于配置
- 脚本中不需要区分状态ID和技能ID,可以避免诸多麻烦
Q:哪些需要配置在数据脚本,哪些需要配置Excel表?
A:与执行相关的数据都配置在数据脚本(通过编辑器配置),其它则配置在Excel中。换句话说,Excel配置的主要是与Player养成功能相关的数据。
技能类型
技能的分类也是多维度的,不过没有状态那般复杂,所以可以展开为多列 —— 但建议仍采用Tag分类法。
被动技能也采用标签表达。
技能Tips和状态Tips
技能的Tips仍然配置在技能表,状态的Tips配置在状态表,虽然技能ID一定在状态表存在,但技能的Tips不能在状态表。
技能的Tips属于预览数据的Tips,而状态的Tips属于运行时数据Tips(真实数据的Tips)。
直接说,可能大家不太明白。我举个例子:DNF的武神步,其技能描述如下:
最终加了多少力量,在技能界面是看不见的,是动态计算的。
PS:DNF的武神玩家都是多动症玩家...
施展条件
有些项目是将技能的施展条件放在了技能表中的,比如我以前的项目;施展条件确实不属于运行时数据,但条件是存在组合和分层的,用Excel很难表达(很不直观)。有几种方式:
- 手写Json(或其它格式)
- 为Excel制作编辑器,可视化编辑Json
- 转移到数据脚本,通过编辑器编辑
为Excel做编辑器需要巨大的工作量才能做得好,但做好了收益巨大;不过,对于中小团队而言,教会策划写Json更具性价比。
在有通用数据编辑器的情况下,将这部分配置转移编辑器也是个方案;但数据一部分在表格,一部分在编辑器导出的Json文本,可能会带来一些不便。
不过,由于技能的执行数据是在编辑器中配置的,所以将施展条件等转移到编辑器不会有额外的副作用,所以很合适。
数值成长问题
在我将行为树引入到战斗系统时,技能的所有参数都直接配置在了行为树的节点上,这面临着几个问题:
- 角色技能是支持升级的,升级以后数值会变化,总不能让策划每一级画一棵树吧?
- 参数散落在行为树的节点上,客户的技能Tips该怎么做?
- 表现层和逻辑层的逻辑可能不同,客户端和服务器可能使用两棵树,但部分参数是相同的。
其实第一个问题还好,因为技能升级,其数值通常都是按公式计算的,所以我们可以直接在编辑器中支持公式,就像这样:
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,也采用数据驱动的方式配置,这可以大幅提供系统的可维护性和可扩展性。
选择一种好的文本格式
最后,我们应当选择一种好的文本格式来记录数据,它需要满足以下基本条件:
- 能精确表达数据
- 易于阅读和手工编写
- 易于维护(修改)
- 支持注释
- 良好的格式
作者在处理序列化问题的时候,设计了自己的数据文本格式《Dson文本格式》,有兴趣的读者可以看一看。
结语
除了技能和Buff外,其实我们的副本流程这些也需要脚本化,其表格和数据脚本的设计,规则是相似的。




