阅读提醒: 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 stateVars; // 技能施展条件 public ICondition castCond; // 服务器脚本 public Task stask; // 客户端脚本 public Task 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 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 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外,其实我们的副本流程这些也需要脚本化,其表格和数据脚本的设计,规则是相似的。