更新日志

This commit is contained in:
zzz
2025-04-30 18:17:35 +08:00
parent bd2948942d
commit 91897dc18f
8 changed files with 1897 additions and 19 deletions

View File

@@ -13,15 +13,33 @@
"state": {
"type": "markdown",
"state": {
"file": "性能优化/关于Unity加载优化你可能遇到这些问题 - 知乎.md",
"file": "战斗/写代码的诗人/战斗系统:状态配置管理(技能配置管理).md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "关于Unity加载优化你可能遇到这些问题 - 知乎"
"title": "战斗系统:状态配置管理(技能配置管理)"
}
},
{
"id": "a641fc3e9f5fe08c",
"type": "leaf",
"state": {
"type": "canvas",
"state": {
"file": "Timeline TODO.canvas",
"viewState": {
"x": -515,
"y": -500,
"zoom": 0
}
},
"icon": "lucide-layout-dashboard",
"title": "Timeline TODO"
}
}
]
],
"currentTab": 1
}
],
"direction": "vertical"
@@ -186,7 +204,7 @@
}
],
"direction": "horizontal",
"width": 351.5
"width": 476.5
},
"left-ribbon": {
"hiddenItems": {
@@ -199,15 +217,28 @@
"copilot:Open Copilot Chat": false
}
},
"active": "d08a5ba04eeae6cf",
"active": "a641fc3e9f5fe08c",
"lastOpenFiles": [
"TODO.canvas",
"Timeline TODO.canvas",
"战斗/写代码的诗人/战斗系统:技能实现架构.md",
"用户卡及游戏信息处理过程设计文档.md",
"战斗/写代码的诗人/战斗系统:状态配置管理(技能配置管理).md",
"战斗/写代码的诗人/战斗系统:属性管理.md",
"战斗/写代码的诗人/战斗系统:什么是数据驱动?.md",
"战斗/写代码的诗人/战斗系统框架设计—战斗视角的GameObject.md",
"战斗/写代码的诗人/角色框架GameObject类型实现.md",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第一个用法|查漏补缺/思路.md",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第一个用法|查漏补缺/如何搞钱/小白金融知识/01_初识通货膨胀钱怎么就不值钱了.md",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第二个用法|私人智囊团/我的侧写档案.md",
"战斗/写代码的诗人",
"TODO.md",
"战斗/独立游戏-万字解析游戏战斗系统开发.md",
"性能优化/关于Unity加载优化你可能遇到这些问题 - 知乎.md",
"性能优化/批量渲染 数字UI.md",
"AI for Obsidian/插件相关说明/Copilot 插件使用说明.md",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第二个用法|私人智囊团/注意事项.md",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第二个用法|私人智囊团/心理侧写 Prompt.md",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第二个用法|私人智囊团/我的侧写档案.md",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第二个用法|私人智囊团/我的长文参考/2022 年 - 我的 B 站运营指南.md",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第二个用法|私人智囊团/我的长文参考/2016 年终总结.md",
"AI for Obsidian/第二个用法|私人智囊团/我的长文参考/2024 年 - 聊聊近况 二一的笔记 001.md",
@@ -218,29 +249,16 @@
"AI for Obsidian/第二个用法|私人智囊团/我的侧写档案.md",
"AI for Obsidian/第二个用法|私人智囊团/心理侧写 Prompt.md",
"AI for Obsidian/第二个用法|私人智囊团",
"AI for Obsidian/插件相关说明/Spaced Repetition 使用说明.md",
"AI for Obsidian/插件相关说明/Smart Connections 使用说明.md",
"AI for Obsidian/插件相关说明/Media Extended 视频笔记插件.md",
"AI for Obsidian/插件相关说明/Inline AI 设置教程.md",
"AI for Obsidian/插件相关说明",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第五个用法|成为插件助手/Dataview 插件/测试用文档/项目复盘会议.md",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第五个用法|成为插件助手/Dataview 插件/测试用文档/项目A计划.md",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第五个用法|成为插件助手/Dataview 插件/测试用文档/财务季度报告.md",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第五个用法|成为插件助手/Dataview 插件/测试用文档/简易待办事项.md",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第五个用法|成为插件助手/Dataview 插件/测试用文档/用户反馈分析.md",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第五个用法|成为插件助手/Dataview 插件/测试用文档/每周例会记录.md",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第五个用法|成为插件助手/Dataview 插件/测试用文档/技术架构设计.md",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第四个用法|成为对弈的棋手/正方观点/观点9.md.edtz",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第四个用法|成为对弈的棋手/正方观点/观点8.md.edtz",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第四个用法|成为对弈的棋手/正方观点/观点7.md.edtz",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第四个用法|成为对弈的棋手/正方观点/观点6.md.edtz",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第四个用法|成为对弈的棋手/正方观点/观点5.md.edtz",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第四个用法|成为对弈的棋手/正方观点/观点4.md.edtz",
"AI for Obsidian/20250315 - 测试 Obsidian+ AI 的能力边界/第四个用法|成为对弈的棋手/正方观点/观点3.md.edtz",
"渲染/GPU/Pasted image 20230413163234.png",
"Mesh/图片/Pasted image 20221021171104.png",
"渲染/软渲染/图片/Pasted image 20230613102109.png",
"未命名.canvas",
"未命名 1.canvas",
"并行计算/GPU.canvas",
"并行计算/CPU.canvas",

9
Timeline TODO.canvas Normal file
View File

@@ -0,0 +1,9 @@
{
"nodes":[
{"id":"ed3d4299af3c74e1","type":"text","text":"# BUG\n\n拖拽BoxSpan在滚动之后异常","x":-220,"y":-740,"width":250,"height":480},
{"id":"0d8282c969461d99","type":"text","text":"# 目前\n\n支持输入监听配置\n支持技能编辑控制AOE范围\n\n人物动作可视化帧打击范围\n\n需要支持删除操作\n支持粘贴板操作","x":-1060,"y":-740,"width":250,"height":480,"color":"1"},
{"id":"9ac5e140a1fddd90","type":"text","text":"# 重要\n\n滚轮缩放控制帧间隔宽度\n帧位置跟随宽度变化\n\n相机状态切换\n屏幕震动\n卡帧时间","x":-780,"y":-740,"width":250,"height":480,"color":"2"},
{"id":"d0bcb2652c1d4cbf","type":"text","text":"# 低优先","x":-500,"y":-740,"width":250,"height":480,"color":"4"}
],
"edges":[]
}

View File

@@ -0,0 +1,100 @@
## **前言**
作者首次接触到这个词大概是20年当时我正在探索技能实现的正确方案。在这期间我了解到[Dota2](https://zhida.zhihu.com/search?content_id=255482146&content_type=Article&match_order=1&q=Dota2&zhida_source=entity)编辑器的存在而Dota2的技能扩展就提到了“[数据驱动](https://zhida.zhihu.com/search?content_id=255482146&content_type=Article&match_order=1&q=%E6%95%B0%E6%8D%AE%E9%A9%B1%E5%8A%A8&zhida_source=entity)”这个词。
不过Dota2并没有对数据驱动这个词下定义也没有解释数据驱动的优缺点 —— 即为什么要这么做,再加之他们的技能设计和我的想法差异过大,我便没有深入研究他们的设计。
我真正理解数据驱动,是在将[行为树](https://zhida.zhihu.com/search?content_id=255482146&content_type=Article&match_order=1&q=%E8%A1%8C%E4%B8%BA%E6%A0%91&zhida_source=entity)成功引入战斗系统后。在将行为树引入战斗系统后为了支持编辑技能和Buff我将既有的行为树编辑器修改为了通用的数据结构编辑器 —— 不再限制节点的类型,就像这样:
![](https://pic3.zhimg.com/v2-71ba79a9cbbdbc9de6476bd6a180c6aa_1440w.jpg)
这样以后对于简单的技能策划就可以在编辑器中直接实现而特殊的技能也只需要程序提供特殊的节点即可。也就是做完这件事我便理解了Dota2的数据驱动是什么意思。
---
## **那什么是数据驱动?**
我们编辑器导出的是[Json](https://zhida.zhihu.com/search?content_id=255482146&content_type=Article&match_order=1&q=Json&zhida_source=entity)格式以后会调整其内容虽然和Dota2有区别但内核是一样的那就是**用数据描述行为**。
你可以将策划的配置(数据)看做是更高层的代码,而我们的程序则相当于解释器,解释配置实现对应的行为。
不过,数据驱动还有另一个解释:**通过修改数据,改变对象的行为**。
这个解释侧重最终目的,而非过程(怎么做);因为数据描述行为的情况下,数据改变,行为自然改变。
这两个解释都应该知晓,前者可以指导我们该怎么做,后者可以检验我们设计的正确性,也可以告知他人我们的设计目的。
---
## **它主要干了什么事情?**
它其实就干了一件事:**分离接口和实现**。
注意,这里是分离接口和实现,而不是分离数据和行为(面向过程)。面向过程下,数据只是数据,它不关心它如何被处理;而技能的配置是完整约定了技能行为的,它不仅仅约定了函数名,还约定了函数参数。
所以,**策划的配置起的是接口的作用**,而我们的代码则是对接口的实现,所以是接口和实现的分离。
---
### **数据驱动的优点:**
1. 更好的扩展性和灵活性 —— 显而易见
2. 更好的可读性和可维护性
3. 解放策划的生产力,减少程序的工作量
4. 跨语言:配置通常采用语言无关的文本格式
### **数据驱动的缺点:**
1. 不确定性(安全性)
2. 解析开销(脚本实例化开销)
不确定性,是我在向策划提供技能编辑器以后最苦恼的事。在编辑器里组合节点是很容易的——就连个线,但行为之间可能存在数据(上下文)依赖,由于策划并非专业的程序员,对程序运行时的上下文也并不完全了解,因此可能拼出错误的逻辑,而这些错误难以在开发期检测出。
所以,即使在有编辑器的情况下,我还是会经常被策划喊:“磊哥,你看看我这个技能怎么不好使呢?”
所以,即使是有编辑器,对战斗策划要求仍然是比较高的 —— 但已然降低不少。
---
## **如何实现数据驱动?**
有几个关键点:
1. **控制反转**:将(部分)控制权交给策划
2. 组合模式:将大块的逻辑,拆分为一个个独立的单元,允许组合使用
3. 函数无状态:函数的输入完全来源于黑板
4. 保持数据结构简单:只使用简单的数据类型集合
5. 上下文使用黑板类型
6. 好的文本格式和序列化工具
---
### **数据驱动随处可见**
在游戏开发中,数据驱动有着大规模的应用,只是以前我们没有关注到这个词而已。我们的场景编辑器,任务编辑器,剧情编辑器,都是数据驱动的案例。甚至[Unity](https://zhida.zhihu.com/search?content_id=255482146&content_type=Article&match_order=1&q=Unity&zhida_source=entity)都可以说是数据驱动的它的资产文件就是数据我们通过数据告知Unity动画该怎么播UI该怎么展示。
所以,虽然你可能不了解数据驱动这个词,但工作中其实一直在使用。
**数据驱动与编辑器有关吗?**
无关。配置(数据)是可以手写的,编辑器的作用只是简化了配置方式 —— 算是简单的图形化编程。
**那以前策划通过Excel组合不同的技能效果算数据驱动吗**
严格意义上来说,是算的,但我更想说不算。
因为通过Excel配置技能的方式程序的工作并不轻松策划的工作就更是艰难。此外使用Excel配置技能的结果是**数据,数据不清楚;行为,行为不清楚,那谈什么数据驱动呢**
对于技能这种复杂的数据应该舍弃Excel使用Json或[Lua](https://zhida.zhihu.com/search?content_id=255482146&content_type=Article&match_order=1&q=Lua&zhida_source=entity)等来配置,只有数据和行为都清楚了,才算得上数据驱动。
---
## **结语**
游戏开发中有许多的词汇,这些词汇通常代表着一些模式, 了解这些词,可以让我们对项目的设计认知更为清楚。

View File

@@ -0,0 +1,319 @@
    说完技能组件,本篇讲讲战斗系统的另一个重要组件:属性组件。
阅读提醒:
1. 本篇尚不涉及属性修改器的管理
2. 关于战斗系统的框架设计,可阅读《[战斗系统:框架设计](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247484186&idx=1&sn=fde958e5c26b7e7ba572eee0b64211c8&scene=21#wechat_redirect)》
---
属性管理的范围
    按照我们在《战斗系统:框架设计》中的设计,属性即角色身上的数据,包括:攻防血、坐标朝向、模型、等级、名字...
    那么,哪些属性需要存储在属性组件上呢?
    所有可表达为数值number的属性如果放在独立的模块没有变得更优都可以存储在属性组件。
```
public class AttrComponent {
```
    这样做有什么好处?
    我们可以将属性组件看做角色的数值中心,这有以下好处:
1. 减少战斗系统关注的组件
2. 方便属性测试更支持Mask快速测试角色属性
3. 方便属性修改器实现
4. 方便数据同步
5. 方便属性变化监听
---
等级
    角色的等级可以存储在属性组件吗?
    可以!是不是有点反直觉?但确实可以。我在《[角色框架:场景内外玩法数据分离](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247484162&idx=1&sn=2dc7289b8675e9b71748a47997d5e844&scene=21#wechat_redirect)》一篇中提到:角色在场景中的等级和名字等需要和养成系统分离,这样才可以支持复杂的变身玩法。
    在数据分离之后,场景中角色的等级便不再有特殊业务,就是简单的数值而已;因此存储在属性组件中并无坏处,反而可以享受集中存储带来的各种好处。
坐标
    我见过将角色的坐标拆分为 XYZ 三个值独立存储在角色身上的,这么做也许有一些好处:比如通过属性组件自动同步,但我认为这并不是个好的设计。
    从存储的角度讲角色的坐标和朝向确实可以拆分为XYZ存储在属性组件但这有一些问题
1. 表达力不足坐标拆散存储没有Vector3的可读性好。
2. 性能问题坐标和朝向的读写频率极高使用值类型的Vector3会更高效。
3. 职责问题:存储在属性组件的属性,应避免为属性组件增加特殊的职责,而坐标放入属性组件会增加特殊逻辑。
4. 扩展性问题:如果项目需要实现子空间,将坐标和朝向封装到独立的模块是更好的选择。
为角色坐标和朝向建立属性视图
    如果项目已经将角色坐标和朝向存储在了属性组件,那么建议为坐标和朝向封装属性视图,屏蔽底层的存储方式。
```
public class AttrComponent {
```
死亡
    在传统框架下死亡通常是角色的一个属性有些项目是直接定义在了GameObject上另一些项目则可能存储在属性组件上。
    而在作者的“一切皆状态”架构下死亡不是角色身上的属性判断角色是否死亡是查询角色身上是否包含State实现的。
```
// 传统架构
```
运行时 ≠ 配置时
    虽然在运行时我们会将角色的等级怪物的强度等属性注入到属性组件但策划在配置Npc时这些特殊的属性通常还是分开配置的即特殊的属性会定义为单独列或编辑器项进行配置而不会和纯粹的战斗属性攻防血这些混合在一起。
---
属性的分类
    属性按照每一个数值是否具有特殊的含义可以划分为数值属性number、功能属性bool和枚举属性enum
数值属性number
    数值属性是指属性的不同数值的含义相同,比如大家熟悉的攻防血。
    数值属性还需要根据是否可能为小数,分为整数属性和浮点数属性,像攻防血就是整数属性,而移动速度就是浮点数属性。
只要变化就同步和派发事件
    简单数值属性,只要数值发生变化,就进行网络同步和派发事件。
```
public void Set(int type, double value) {
```
PS哪些属性需要同步和派发事件是需要规划的这里后面讨论。
---
功能属性bool
    功能属性是指仅区分0和非0类型的属性比如禁止移动、禁止施展技能、AI不可见。
    功能属性可以看做角色支持的功能开关,因为它们负责的是功能的开启和禁用。
    功能属性通常由状态State添加StateCfg中就包含了获得状态时需要添加给角色的功能属性。
```
public class StateCfg {
```
PS功能属性bool属性是枚举属性的特殊情况。
避免负数
    功能属性应当尽可能避免产生负数,这会带来奇怪的语义。
0和非0突变时同步和派发事件
    功能属性只有在值由0变为非0通常指大于0和非0变为0时才需要同步和派发事件。
```
public void Set(int type, double value) {
```
MASK缓存
    属性组件上的mask缓存是为功能属性设计的。业务经常需要查询角色身上的功能禁用信息通过数组测试相交固然可行但不够高效。
```
// 通过迭代数组测试相交
```
BitSet仅128位
    128个功能属性对于绝大多数的项目来说都是足够的而且128位的BitSet实现简单且效率极好因此默认情况下我们使用的都是128位的BitSet。必要时扩展到256位也不麻烦
    那是将属性类型中的低128种还是高128种规划为功能属性呢
    高128种。功能属性只有在0和非0突变时才需要同步因此功能属性进行同步的频次是较低的因此可以将低位让给同步频率更高的数值属性可以减少网络同步开销。
```
public void Set(int type, double value) {
```
UE的GamePlayTag
    我看见部分UE的开发者在使用GAS时通过GamePlayTag来实现不可移动、禁止施法等效果我认为不是一个好选择。
    TagTree表达力和扩展性都很好但低效。用Tags表达对象的类型信息是OK的但用来实现功能开关功能属性则是低效的。
    UE它提供的是通用型框架因此不能对属性组件做特殊的约定因此它只能通过TagTree来实现一些功能但我们在具体的项目中没有这些约束因此应当选择更高效的实现方式。
---
枚举属性
    枚举属性是指属性的每一个值都有特殊的意义比如怪物的强度无效普通精英Boss
```
public enum MonsterStrength {
```
    同数值属性一样,枚举属性的值只要发生变化,就需要同步和派发事件。
类型枚举
    我们在定义最终的枚举时,可以将数值属性下的浮点数属性和整数属性提到最上层来,这可以简化逻辑。
```
public enum AttrValueType {
```
---
long和double的选择
    在前面的示例代码中我们采用的都是double数组那么是否还有别的选择呢
    还可以存储为long类型。作者的第二个项目底层便是long类型浮点数类型属性统一存储为万分比。
```
// 浮点数采用万分比存储
```
    那么long和double哪种方式更好呢
    各有缺陷但更推荐double。
long类型的优缺点
    long类型的主要优点是利于网络同步整数值是容易压缩的。long的另一个优点是高效角色属性中大多数属性都是整数类型使用long可以避免大量的额外运算。
    long类型最直接的缺点就是不直接不直观。策划在配置表和技能编辑器中也需要将属性配置为万分比我认为这不是个好现象策划的配置数据最好是简单且直接的。
    long类型的另一个缺点就是设值函有副作用 —— double转为long的时候会丢失精度。就是说取出的值可能和写入的值不同看下面的代码
```
public void Func1() {
```
    这个问题可大可小但如果你想让自己编程更轻松Bug更少那么不要去分析有没有问题直接选择double。
double类型的优缺点
    double类型的主要优点就是直接直观。
    double类型的主要缺点是网络同步的开销大浮点数通常不能被压缩因此多数通信协议并未对float和double进行传输优化。这个问题我们可以在业务层来优化当我们进行同步时可将整数型属性写为int64类型浮点数属性才写为double类型。
```
message AttrItem {
```
PS整数型的浮点数其实是可以被压缩的可阅读《[序列化:数字的各种压缩方式](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247484027&idx=1&sn=c22fbc5f250a2ae40fb2b755dd7943f9&scene=21#wechat_redirect)》
    double的另一个问题是整数型属性通常远多于浮点数类型属性使用double会带来较多的额外运算此外要保证非浮点数含Bool和Enum属性被正确存储为整数也要额外的开销。
什么情况下使用long胜过double
    配置概率的时候。10个0.1的和不是1使用小数配置概率得出的总概率不一定等于期望的总概率会有误差。
---
属性变化事件
    角色的属性变化事件,有以下特殊性:
1. 无论一次修改多少个属性,都只产生一次事件 —— 原子性
2. 监听器可能监听多个属性类型,不论一次其中几个属性发生变化,只应该接收到一次事件
3. 属性变化事件派发期间如果有监听器又修改了角色属性其它的监听器就可能产生Bug
4. 属性变化事件,需要告知监听器旧属性值
    第一个点要求我们不能在修改属性时自动触发事件,甚至自动同步给客户端也是不行的,必须在修改数据完成之后才派发事件。
    第二个点要为管理监听器时,不能简单的分散注册,而是需要在派发事件时测试属性类型集合的相交性。
```
public struct ListenerInfo {
```
    第三个问题,在项目中也是比较容易出现的。如果有监听器在处理属性变化事件的时候,又修改了本次事件中包含的属性;那么,不论新的属性变化事件是立即广播,还是延迟广播,都会影响其它监听器。
    如果新事件立即广播,那么其它监听器就会先看见新值,再看见旧值,这明显是不对的;如果新事件延迟广播,那么其它监听器在处理旧事件的时候,从角色身上取出来的当前属性是新的属性值,这也是有问题的。
    这个问题不好解决,所以我们只能要求属性监听器不能立即产生属性修改,必须延迟修改 -- 通常是延迟到下一帧。
    与这个问题类似的,是背包系统的新获得物品事件。
    第四点则要求我们在修改属性时,需要自动记录属性的旧值;在修改完成后,清空变化的属性信息并同步和派发事件。
```
public void FireEvent(GameObject gobj) {
```
公共事件系统 OR 定制事件系统
    现在有个小问题,那就是我们的属性变化事件,是通过公共的事件系统派发,还是在属性组件派发?
    通过属性组件派发,即我们在属性组件中维护属性变化事件的监听器,派发时直接迭代监听器列表即可,这相当于提前进行了分流,因此性能上会更好一些。
```
public class AttrComponent {
```
    但提前分流也会带来一些问题,那就是我们的战斗系统需要关注多个事件系统。如果没有明显的性能问题,我建议还是使用公共的事件系统。
---
属性同步
    属性同步和事件管理是类似的都依赖属性组件记录的ChangedValues外部在完成数据修改后将变化的属性同步给客户端。
哪些属性需要服务器同步给客户端?
    所有涉及到客户端表现和预演的,比如:当前血量和最大血量,都需要同步给客户端。
高频同步的数据放前方
    通信协议通常对int32、int64类型进行了压缩最出名的便是Protobuf中的VarInt算法在VarInt算法中数值越小的Int使用的字节数越少因此越频繁同步的属性类型其类型Int应当越小。
```
public enum AttrType {
```
属性配置
```
public class AttrTypeCfg {
```
    首先我们需要在表格中配置属性的极大极小值对于Bool类型和Enum类型我们应该限制极小值为0避免负数。
    另外,哪些属性需要同步给客户端,可以在代码中配置,也可以通过表格配置,通过表格会更灵活一点。
    最后,不仅仅是战斗属性,属性组件里的所有属性都需要配置在该表格,包括前面提到的角色等级这些。

View File

@@ -0,0 +1,399 @@
说完技能的配置管理,本篇聊一聊技能的逻辑管理。
在“一切皆状态”的架构下,[技能系统](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)
PSDNF的武神一觉技能在风场阶段按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)
如果网络延迟,可能在超过怪物身位后将怪物抓住
PSDNF的女柔道就巨受网络延迟的影响 —— 非常影响手感。
### **逻辑表现同步切换**
我在《[战斗系统:框架设计](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);
}
```
## **结语**
本篇主要讲的是一些高层设计,关于细节的实现,以后讲一部分。不过,作者最近有很多代码要写,文章的更新频率可能会放缓。

View File

@@ -0,0 +1,355 @@
 战斗系统,在大型游戏中,基本就是最复杂的系统;在项目中,通常由专门的战斗策划和战斗程序负责。相信读者中也有维护过战斗系统的,我想问几个问题:
1. 有站在技能和Buff的更高层审视过你们的战斗系统吗
2. 项目的战斗系统有明确的架构吗?
3. 项目的战斗系统有缺陷吗?知道问题的成因吗?
4. 能实现你玩过的游戏中的所有技能和Buff效果吗
    我不知道大家的回答如何但如果让刚离开第一个项目时候的我来回答这些问题我几乎需要全部答NO...
    我后来发现,其实不仅我是这样,有大量的开发者都对战斗系统缺少系统性的认知和思考,即使当前项目的战斗框架是他实现的。为什么呢?因为他不是最初的设计者,而只是搬运工,他并不真正了解他手头的工具。
    其实,对战斗系统有系统性的认知非常重要,有许多设计问题,在我们只盯着部分模块的时候是发现不了的 —— 你只会觉得很难受,但不知道为什么。
    本篇就带着大家看一下常见的战斗框架设计,以及我的心血之作,相信你定有所收获。
---
1.独立抽象(反面教材)
![图片](https://mmbiz.qpic.cn/mmbiz_png/c2vcRRONwG3h0wDrWt6I5vxd8o4k17icEXNiceUGBCAAj5PldYlMMdMricbH4dVwsecl62LUzP17YmMib4fhd68IgA/640?wx_fmt=png&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)
    我的第一和第二个项目都将主动技能、被动技能、Buff进行了独立的抽象。
    第一个项目的战斗系统是由我Leader搭建的一开始仅包含主动技能和Buff两个模块后来在我接手战斗系统的维护工作后策划提出了被动技能的需求我便增加了被动技能模块。
    第二个项目的战斗系统是谁搭建的我并不知道但由我的同事旸仔维护我记得一开始项目里也只有技能和Buff模块后来旸仔在处理被动技能需求时也增加了被动技能模块 —— 果然是同龄人。。。
项目共性
    如果看细节的话,这两个项目的代码差异是非常大的;但从整体(框架)上看,两个项目的内核其实是相同的。
    首先从整体上看角色都可以分为5大块
1. 属性:属性即角色身上的数据,包括:攻防血、坐标朝向、模型、等级、名字...
2. 主状态机:用于管理角色的的主要状态切换,通常与角色的模型控制相关
3. 主动技能通常由玩家操作施展且通常会控制角色的模型动作Action
4. 被动技能:通过养成系统或装备获得的效果,获得后立即生效,无时间限制
5. Buff角色身上的增益或减益状态通常是临时性的
    其次主动技能都是基于FSM + Action的框架实现的FSM管理技能的主流程Action实现具体的效果至于Buff和被动技能也都是采用类似Action的方式实现的只是接口抽象上稍有不同。
```
// 技能执行上下文
```
Q为什么坐标朝向和模型被标粗体
A坐标、朝向、模型Model及其动作Action与受击包围盒和攻击包围盒的计算相关非常重要。
主状态机
    其实这两个项目的服务端都没有显式定义角色主状态机而是根据角色的属性移动状态、死亡标识、Buff等来处理的互斥我觉得是一个失误。
    为什么应该定义主状态机?
    从逻辑层的角度讲,将行走、施法、死亡、冰冻等纳入同一个状态机,可以让角色的主要状态及其互斥逻辑更为清晰。
    从表现层的角度讲,角色模型一次只能播放一个动作,因此也需要一套状态机来管理;而要想表现层的状态机更容易编写,逻辑层涉及到模型控制相关的逻辑最好也在同一套状态机中。
![图片](https://mmbiz.qpic.cn/mmbiz_png/c2vcRRONwG3h0wDrWt6I5vxd8o4k17icEsbBCl6g9wZF449ico4Ob48w2Lx4jIAupnGibic28CWZSabvicyWnhjZIng/640?wx_fmt=png&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)
主要缺陷(重复感)
    在我实现需求和维护的过程中,我感受到的最大缺陷是:重复感。
    被动技能和Buff效果存在交集被动和Buff的Action代码存在一定的重复。这种重复感一直困扰着我我分不清这是真实的重复还是表面的重复 —— 既想合并它们,又觉得它们不该合并。
    此外我明确的感知到项目的战斗框架无法支持DNF那般强大的战斗系统 —— 距离还很远。
PS其实主动技能的效果和Buff、被动也会有一些重复但重复率不高而且由于主动技能流程的特殊性我们总是认为主动技能就应该是独立的。
核心问题是什么?
    开发者站在了用户的角度来设计架构。
    我们在玩游戏的过程中建立了主动技能、被动技能和buff概念在实现需求时便不假思索地设计了对应的抽象。可我们所谓的主动技能、被动技能、Buff之间的差异真的存在吗它是表面的还是真实的
    这样去做战斗系统的人 —— 比如我,大概率是没有思考过这个问题的。
---
2.被动是特殊的Buff
![图片](https://mmbiz.qpic.cn/mmbiz_png/c2vcRRONwG3h0wDrWt6I5vxd8o4k17icEKKVExdk4icyL4azCd4QiaPPVYPCiaibP2ia78jxxLpk9Krib86Ppia3zBBFIg/640?wx_fmt=png&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)
    虽然被动和Buff具有较大的差异但仔细思考可以发现被动技能可以是Buff的子集被动技能的那些特性Buff也是可以拥有的而且用Buff支持被动技能并不需要太大的改动量 —— 主要处理对站街面板的加成等。
    将被动技能实现为Buff后不仅消除了被动技能和Buff之间的重复感也扩展了Buff系统。
    该架构比较符合大家认知采用率较高。不过采用该架构的项目并不全是从前一种架构演进来的也有一开始就用Buff实现被动技能的 —— 这是明智的;如果是从前一种架构演进来的,想必已有一点体会:过于明确的抽象,对战斗系统是有害的。
    PS关于抽象带来的扩展性障碍阅读《[角色框架GameObject类型实现](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247484167&idx=1&sn=08876da72c7042b8031d96b815ad4e20&scene=21#wechat_redirect)》可能会有所帮助。
---
3.被动技能后台施展
![图片](https://mmbiz.qpic.cn/mmbiz_png/c2vcRRONwG3h0wDrWt6I5vxd8o4k17icEprofn1P2L43BLWpkyDI0LegPZQaaXBeetIAtCOpRSmYjU5NsyAmuhQ/640?wx_fmt=png&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)
    被动技能和Buff合并的最大障碍是数据依赖不同被动技能效果依赖的是SkillData而Buff效果依赖的是BuffData。
    既然被动技能也依赖SkillData那么是不是可以让被动技能也走主动技能的流程呢即被动技能挂载后按主动技能流程自动施展但不占用角色主状态机。
```
public class SkillComponent {
```
PS当年在第一个项目的时候我就没想到这么做 —— 还是能力不足。
    将被动和主动一视同仁,意味着支持多技能同时施展,有以下优点:
1. 支持被动技能转主动技能施展,被动技能在满足条件的情况下可以手动施展
2. 支持主动技能间断性施法eg技能A第一击技能B第一击技能A第二击...
后台技能 VS Buff
    将被动技能看做主动技能,更符合代码层抽象 —— 程序员们会更容易理解。
    但将被动技能看做Buff更符合业务层的抽象 —— 策划们会更容易理解此外用Buff实现被动还避免了效果交叠带来的重复代码。
思考题
    被动技能和主动技能有共同点又和Buff有共同点那是不是说主动技能和Buff有共同点
---
4.一切皆Buff
![图片](data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='1px' height='1px' viewBox='0 0 1 1' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3E%3C/title%3E%3Cg stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' fill-opacity='0'%3E%3Cg transform='translate(-249.000000, -126.000000)' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
    在市面上还存在一种架构一切皆Buff。
    从字面上看我的理解是主动技能在施展的时候也是给自己添加一个Buff然后通过Buff来实现所有的功能。
    我相信肯定有读者在看见这句话的时候心生抵触被动技能可以说是Buff但主动技能怎么能是Buff呢
    是啊不仅仅是读者觉得主动技能和Buff不一样俺也一样甚至那些说着“一切皆Buff”的团队也认为不一样
    所以,他们最终的实现并不是我理解的那样。根据职责转移的粒度,分两种情况:
1. 技能流程由Buff构成
2. 技能效果皆是Buff
---
4.1 技能流程由Buff构成
![图片](https://mmbiz.qpic.cn/mmbiz_png/c2vcRRONwG3h0wDrWt6I5vxd8o4k17icEsvyk3f185a81MZNISwwj8w9ZlzIawlNHIfic5pGSnAiaZbTQGFBrjeQg/640?wx_fmt=png&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)
    在技能设计中技能被分为多个阶段每个阶段由一组SkillAction构成SkillAction中包含了打击点、触发间隔、攻击包围盒等信息而在Buff中同样存在这些配置。
    因此可以将技能的SkillAction看做Buff这样技能便可以看做是Buff的集合在特定的阶段挂载特定的Buff即可。
```
// 技能执行上下文
```
主要缺陷(交互)
    并非所有的技能都是简单的时间线施法因此技能框架仍然需要是FSM式的而由于SkillAction的消失Skill成为空壳因此Buff需要负责技能的阶段切换所以Buff系统和技能系统存在复杂的交互逻辑。
    此外将SkillAction委托给Buff概念上接受起来还是有难度。
4.2 技能效果皆是Buff
    技能加Buff是很常见的效果那么造成伤害、治疗这些瞬间的行为为什么不能通过Buff绕一下实现呢
    所以这里又有另一种实现技能管理流程Buff执行效果即技能执行到打击点时总是通过给目标挂载Buff来实现效果。
```
public class SkillAction {
```
    不过由于从技能转移过来的效果大多是瞬时Buff为了避免不必要的同步BuffMgr需要屏蔽这些Buff的同步。
主要缺陷Buff量
    用Buff来执行技能效果其实是将Buff作为了适配器适配是有代价的。
1. 造成伤害、治疗等效果的体量非常大全部配置在Buff表会导致非常大的Buff量。
2. 技能效果的上下文变得更复杂,会增加效果的实现难度
3. Buff的增删频率极大Buff需要池化
    可惜将主动技能真正实现为的Buff是更正确的方案 —— 终究是我们先入为主的观念阻碍了我们。
---
5.返璞归真一切皆状态State
![图片](data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='1px' height='1px' viewBox='0 0 1 1' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3E%3C/title%3E%3Cg stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' fill-opacity='0'%3E%3Cg transform='translate(-249.000000, -126.000000)' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
    在第三个项目,在经过较长时间的探索后,我成功将第一个项目的战斗架构演进为这套“一切皆状态”的架构。
    它何以称得上返璞归真?继续阅读,你便知晓。
状态组件
```
public class StateComponent {
```
    状态组件可以和传统架构的Buff组件类比它存储着角色身上的所有状态和状态槽。
状态
```
public class State {
```
什么是状态?
    状态在业务层面的含义,读者可以自行理解。我想告诉大家的是:状态即脚本!只有将状态看做脚本,你才能理解这套设计的强大。
    现在不论是主动技能、被动技能还是Buff都变得很简单就是给GameObject添加一个状态也就是给GameObject挂一个脚本视野是不是一下就打开了
    这像不像我们在Unity下通过MonoBehavior扩展GameObject的行为所以在业务层面State就是GameObject的行为组件。
状态脚本的实现
    状态的最终逻辑由行为树(任务树)承担,可阅读《[行为树:事件驱动版实现](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247483909&idx=1&sn=c5e746e8bd1e01da5e4d12ec4dae3b4d&scene=21#wechat_redirect)》《[行为树返璞归真TaskTree](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247483940&idx=1&sn=91cafb326ee1c6c4b528e1fc9898d9d6&scene=21#wechat_redirect)》。
    行为树并不是唯一选择,但是是很好的选择;如果不选择行为树,只需要提供一个类似的脚本抽象即可,核心抽象方法就两个:
```
public abstract class Task<Blackboard> {
```
为什么是“状态”而不是“Buff”或其它
    回答几个问题即可:
1. 跳跃是不是Buff死亡是不是Buff施展技能是不是Buff
2. 跳跃是不是状态?死亡是不是状态?施展技能是不是状态?流血是不是状态?冰冻是不是状态?一切持续性的效果是不是都可以定义为状态?
3. Buff是不是状态的子集Buff是角色获得的增益或减益状态
状态带来的改变
    前面的框架有一个公共问题主状态机的状态和技能、Buff的互斥不容易处理。
    在前面的框架下处理死亡等逻辑时通常都较为麻烦通常需要编写特殊的代码来处理。这其实是因为主状态、技能和Buff不属于同一概念不同概念下的事物无法进行比较也就无法简单处理互斥而将一切都纳入状态后我们便可以通过统一的互斥规则代替这些特殊的互斥代码。
主动技能真的也可以吗?
    被动技能和Buff的处理想必大家不会有太多疑问但当前施展的主动技能需要支持外部查询该怎么处理呢
    需要“状态发布”,即状态在启动时将自己发布到关联的组件。至于状态发布的方式,我们在后面的篇章详细讨论。
```
public class SkillComponent {
```
---
状态槽
```
public class StateSlot {
```
静态槽和动态槽
    静态槽表示预设的状态槽在创建GameObject时即分配动态槽则在运行的时候根据需要创建。
什么是状态槽?
    我们可以通过电脑的进程和网络端口来辅助理解,先看图:
![图片](https://mmbiz.qpic.cn/mmbiz_png/c2vcRRONwG3h0wDrWt6I5vxd8o4k17icEDufXonxVDYdicY3aGf423l4ykBG9mNXnz193oF1tDz2H88r0TaIdSoQ/640?wx_fmt=png&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)
    状态必须先绑定到状态槽才可以执行,好比网络应用需要先绑定网络端口;当多个状态需要绑定在同一个状态槽时,将发生状态槽冲突,将挤掉既有的状态。
    一些状态只能挂载在指定槽上跳跃、死亡、冰冻等只能挂载在1号槽静态槽也就是说我们的1号槽充当了前面架构中的主状态机。因此我们也可以通过1号槽查询角色的主状态。
    所以状态槽就是个资源句柄,有两个作用:
1. 实现状态的互斥
2. 提供额外的查询
---
演进过程
    虽然我在上面举了Unity的MonoBehavior例子但该框架并不是直接通过组件模式演进来的而是慢慢演进为组件模式的 -- 过程曲折。我在重新实现战斗框架的时候,着重关注三件事:
1. 将行为树引入技能和Buff系统见《[行为树:序](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247483881&idx=1&sn=aa7a90d8cccc72e071855de64432f35d&scene=21#wechat_redirect)》
2. 如何消除主动技能、Buff、被动技能效果交叠带来的重复感
3. 如何实现技能修改器
    将行为树引入技能和Buff系统由于行为树的实现错误这件事花了很长时间才完成在这之前我为了消除重复感尝试过一个方案为主动技能、被动技能、Buff提取一个公共的效果Effect抽象。
![图片](https://mmbiz.qpic.cn/mmbiz_png/c2vcRRONwG3h0wDrWt6I5vxd8o4k17icEcI27ic0jkbvWHJV2XeFDMskAjpJYH9GU0u2yIelPicKgniceibbaWE0nPA/640?wx_fmt=png&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)
    为了尽可能保证接口的通用性,接口参数仅一个黑板,如下:
```
public interface IEffect {
```
    但在实现具体效果的过程中发现由于技能执行上下文SkillContext和Buff是不同的抽象类型导致Effect还是需要独立实现 -- 需要从不同的类型上读取数据。
    这个问题困扰了我好久由于我坚持认为技能和Buff是不同的东西因而无法合并它们的抽象但随着行为树和黑板在项目中的应用越来越多范围越来越广我增加了些体悟
1. 要想提升系统的灵活性,应尽量使用黑板代替明确的数据结构定义 —— 关注业务而不是Class和Interface
2. 在使用黑板的时候应尽可能直接将数据set到黑板 —— 即依赖注入,避免反向依赖
    在这一系列思想的影响下我发现技能和Buff也不是不能合并了在偶然的灵感之下我决定使用状态State来表达合并后的抽象。
    在确定使用状态这个概念后我便联想到了跳跃和死亡等主状态机上的状态我发现它们也可以纳入到新的状态管理中。为方便管理主状态的互斥以及查询于是增加了状态槽StateSlot
    在获得最终的结构后,我发现了两点不同:
1. 我能证明设计的正确性!不论是拿计算机下的进程来举例,还是拿组件模式来举例,都能证明它的正确性;而在以前的项目中,我都没有这个感觉,只是觉着应该是这样的。
2. 我看待GameObject的视角被拉高了。在之前我设计技能和Buff时就只是盯着技能和Buff模块看而现在是盯着整个GameObject在设计 —— 所以结果必然更正确。
    “证明设计的正确性”是我在该项目获得的重要习惯,这为后面的技能修改器设计起到了极大的作用。
---
结语
    “一切皆状态”这套架构有极强的扩展性和灵活性,在我看来,基本不会有更好的抽象了。
    读者要想掌握它,关键在于打破通过玩游戏获得的先入为主的观念 —— 不要从玩家的角度去设计战斗系统。

View File

@@ -0,0 +1,260 @@
阅读提醒:
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)
最终加了多少力量,在技能界面是看不见的,是动态计算的。
PSDNF的武神玩家都是多动症玩家...
**施展条件**
有些项目是将技能的施展条件放在了技能表中的比如我以前的项目施展条件确实不属于运行时数据但条件是存在组合和分层的用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外其实我们的副本流程这些也需要脚本化其表格和数据脚本的设计规则是相似的。

View File

@@ -0,0 +1,418 @@
[独立游戏-万字解析游戏战斗系统开发 - 知乎](https://zhuanlan.zhihu.com/p/694146117)
## 背景
写这篇文章的背景来自于去年做的一款**[星穹铁道](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=%E6%98%9F%E7%A9%B9%E9%93%81%E9%81%93&zhida_source=entity)**的**[战斗模拟器](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=%E6%88%98%E6%96%97%E6%A8%A1%E6%8B%9F%E5%99%A8&zhida_source=entity)**: 【耗时3个月自制星铁战斗系统就为作出最强攻略 [耗时3个月自制星铁战斗系统就为作出最强攻略_手机游戏热门视频](https://link.zhihu.com/?target=https%3A//www.bilibili.com/video/BV1Er4y1R7y4/%3Fshare_source%3Dcopy_web%26vd_source%3Da00e3a4a1a7552a159a18dddb0a14fb0)。
后来结合自身经验又将此战斗系统**扩展复用**到了**多种类型**的游戏Demo中。在此来简单总结分享下其中组成也不期待能帮助到大家什么仅当在如今游戏行业的氛围中聊以慰藉吧。
做这个模拟器的背景在视频里其实有交代的蛮清楚的,感兴趣的会看不感兴趣的也不用多介绍了~。来说一下实现了什么样的功能:
1. 完全模拟了**星穹铁道**([卡牌向](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=%E5%8D%A1%E7%89%8C%E5%90%91&zhida_source=entity))的所有核心战斗机制,技能体系、数值体系、能量、装备系统、行动出手逻辑等。
2. 截止xx前**所有角色**的**战斗逻辑**(和官方技能描述一致)。
3. 实现了随机/伪随机的**可重入**操作(也就是视频里经常提到的**万次模拟**)。
4. **伤害公式,数值公式**,甚至**buff被动**等时序以及打出来的伤害和官方角色实际体验**保持一致**。
5. 截止xx前完全拆解并实现了当前版本的所有特殊角色的特殊逻辑。
6. 加了点前端界面,有**战斗过程出手演示行动演示数据LOG演示调试**等界面。
**为什么要从卡牌讲起?**
- 因为这是我个人经历中比较少见的给"**自己**""**商用**"的战斗体系搭建,是从**0开始搭建**并**成功落地**验证了**扩展性**的结构。因为平时就热爱游戏开发,这套战斗体系我同样移植给了自己做的**"音游""肉鸽""休闲游戏""割草游戏"**等多个Demo中。所以其实可以看到这并**不局限于游戏类型**,只要维护核心观念的扩展性即可(**配置驱动**)。
**概览下战斗系统里包含什么?**
- **战斗核心机制**是 "**技能**" "**Buff**" "**子弹**" 体系的三者联动, 辅以"被动""装备""法球"等机制用来配合实现特定逻辑。
- **逻辑与表现分离**: 这是避免不同步的基础,也是后续万次重入结论一致的基础,在设计之初就应该考虑的。
- **[C#配置表工具](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=C%23%E9%85%8D%E7%BD%AE%E8%A1%A8%E5%B7%A5%E5%85%B7&zhida_source=entity):** 在工作中有做了一个把**excel**表导出成**c#数据表**的工具,生成c#之后可以让代码里直接调用对应的excel类的属性,所见即所得可以说非常好用了。
- **出手逻辑:** 也就是程序如何循环的。这里不同类型的游戏会定制化一些。
- **伤害机制:** 伤害公式确认好之后重点就是用buff和属性、被动等共同组织起各种加成与时序算法。
- **人物属性:** 单独拿出来说是因为人物属性需要做到动静分离,实时变更。影响因素较多(装备、被动、buff等),做到高效组织是需要点功力在的。
- **触发机制:** 其实大部分就是**被动**带来的, **注册+触发**执行。
- **日志系统:** 包含及其丰富细致的日志系统,让所有数据有迹可循。
- **战斗表现:** demo做的粗糙,真正商业化游戏配合着**技能编辑器**来,根据时间轴做好战斗前摇、出手、后摇阶段的排版。
- **操作手感:** 呦,抽象了喔。这是我最擅长的部分单独拿出来作为一个文章不为过解决过王者上千个战斗bug走A寻敌位移指示器等等非常多也感慨优化了大部分的英雄。后面得空在好好讲讲这里吧。
- **打击感:** 抛砖引玉: 技能缓存、飘字、震动、血条表现、顿帧、硬直等等。
- **音频:** 不是我关注的重点.但是玩家体验的关键一环。
- **网络:** 帧同步|状态同步。
- **Debug:** 快捷的调试工具也是节约开发时间的"利器"。
- **渲染表现:** 风火雷电冰,雨雪阴晴,草水等等. 不作为本文重点。
- **AI:** 本身AI&怪物AI
- **UI:** 战斗UI,可能讲一下有趣的地方~ 比如3DUI, 资源后处理Event生成等
- **资源加载:** 和战斗关联比较大,顺便提一下
## 战斗系统雏形构建思路(星铁为例)
> 这是写给我自己做的独立游戏的,并不适用于所有项目/大厂,正式的战斗系统会更为复杂严谨,各位资深道友看见了觉得写的草率了就图一乐吧~。
**下面的内容组织思路?**
从星铁战斗系统的**整体构建**来总览回顾一步步还原**从0制作**一款卡牌**战斗雏形**的过程。
1. 首先**构建初始时序**
2. 讲一下**个人风格**的**战斗体系的组成**,重点是如何实现**高扩展性**。
3. 讲一下**战斗相关**的**系统扩展**,比如**战斗表现|日志系统|资源加载**等。
4. 将战斗系统**扩展**应用到**多类型**游戏中,验证扩展性和鲁棒性。
## 构建初始时序
> 根据**游戏类型**(卡牌),做了下**出手执行顺序**的草图,这是游戏运转前的基础验证,后续的所有战斗逻辑皆以此为基础扩展。
![](https://pica.zhimg.com/v2-d3ea5b40decde25f454c6bc76456898c_1440w.jpg)
出手执行顺序草图
可以看到星铁的核心出手机制定义为**行动值**,其实行动值就是速度,每个角色在程序中的运行速度的差异决定了**普攻出手**的先后。然后辅以每个角色特殊的**战技出手**条件&**能量出手**条件,形成了一个角色**全部的行动规则。**
下面提供**角色更新**伪代码(为**常规模拟模式**稍微做了些2D表现,所以使用了**协程wait.**):
```csharp
// 通过协程分帧处理表现
public IEnumerator ExcuteInner()
{
// 前置需要添加Boss | 角色 | 日志系统 | 开局事件触发等
var maxRunCount = 全局配置表["行动值上限"];
while (curRunCount++ < maxRunCount)
{
// 更新人物
foreach (var actor in actorList)
{
actor.Update();
yield return new WaitForSeconds(0.5f);
}
// 更新怪物 or 对手等..
yield return new WaitForSeconds(0.01f);
}
}
```
**角色出手逻辑伪代码:**
```text
public void Update()
{
// 判断死亡,死亡不会执行后续
if (IsDie()) return;
// 更新角色携带的宠物
m_pet?.UpdatePet();
// 更新人物路程
ChangeWay(speed);
// 检查大招释放规则(能量更新在角色出手外,可以做到差帧更新: 即大招能量条满了不会同帧抢技能执行)
CheckPlayEnergySkill();
// 判断是否可以出手(当前行动路程大于预设)
if (m_curWay >= GlobalConfig.S_SumWay)
{
// 回合开始事件等派发
EventCenter.Instance().Notify(EVENT_NAME.ROUND_BEGIN, this, arg);
// 路程归零(这里可以加速度余量,没加大概是强度使然)
SetWay(0);
// 更新持续伤害
UpdateLastDamageBuffs();
// 判断是否存在冻结等行动停滞效果
if (IsFrozen())
{
// 冻结之后路程变成5000后面且不会出手,但是可以触发buff
}
else
{
ActorFight();
}
// 检查大招释放规则
CheckPlayEnergySkill();
// 更新Buff(挂在角色身上的正面负面都算)
UpdateBuffs();
// 更新被动技能
UpdatePassiveSkills();
// 清理当前轮次的攻击对象等回合相关信息
// ClearRoundData();
}
// 移除延迟buff列表(有些buff不会在当前更新即刻移除,要放在DelayRemoveBuff列表中延迟移除)
// 移除被动(同理)
// 角色更新完成
}
```
补充**ActorFight**判断是否是战技出手即完成完整**角色出手**流程:
```text
public void ActorFight()
{
// 敌人出手前更新韧性
// 出手前判断buff列表是否有回合开始时需要执行的buff
for (int i = 0; i < m_buffList.Count; i++)
{
buffExcuteDic[m_buffList[i].m_buffType].RoundBeginExcute(m_buffList[i]);
}
// 判断技能出手条件是否满足 决定此刻是普攻/战技出手
if (!CanSkill())
{
PlayAttack();
}
else
{
PlaySkill();
}
}
```
## 构建角色属性系统
> 角色属性是伤害公式调用的基础,伤害公式就是根据配置动态修改各种属性的业务算法,而各种属性又决定了玩家的游戏策略,所以这个时候可以优先选择构建属性系统。
以星铁早期的人物属性举例,可以拆解为:
**速度、阵营、角色类型、攻击力、增伤、伤害加成、暴击率、无视防御、防御、暴击加成、血量、等级、穿透、抗性、韧性、嘲讽值、最大能量值、攻击属性、弱点、抗性弱点**
下面这个配置表也可以参考一二,为什么用的是**拼音**啊?**Up是我室友他学俄语的啊**!!!要蚌埠住了,实在没有勇气用俄语来做表头和代码注释,索性在部分属性上折个中用拼音了。
![](https://pic2.zhimg.com/v2-47ba38d3d0e577981e9e18a18c833bd1_1440w.jpg)
后来才悔恨的加了一行中文注释.. 晚了
这里有3个点:
- Q: 为什么是会出现5000这种数字?
- A: 为什么是会出现5000这种数字?A: 为了保证精度,用万分比保留2位小数部分
- Q: 为什么是会出现大面积的0?
- A: 0代表了角色身上的初始属性确实没有,但是可以支持后续带有特定属性的角色更新
- Q: 为什么逻辑上又出现中文?
- A: 不用怕,针对有强迫症的程序来讲,是准备了一张**中文转换表**的,可以将中文**适配成对应的枚举**,但是给"策划"展示依然是中文方便阅读配置(不然你填个2谁知道是啥啊喂)。
至此,关于属性的基础创建就完成了,选择了对应的角色就可以直接使用属性系统了。
## 构建装备系统
> 本来不想扩散的,不过上一章节配置表中有很多的0,看不习惯,那就增加一个章节讲一下属性系统的实际数值是如何填充的(其中一种方式)-**"装备系统的构建".** 这一节很短,快的离谱
Q: 装备很多,属性很杂,还有装备各种特殊效果,套装加成等,关于加成的配置和装备的代码一定很麻烦吧?
A: 核心代码几十行代码就搞定.只需要 "**通用的属性加成**" 与 "**被动添加移除机制**",下面提供伪代码
```text
// 获取装备属性
public int GetEquipAdd(PropertyType _property)
{
// 自行判空 异常处理
return m_configData[propertyType];
}
// 添加被动
public void AddPassiveSkill()
{
// 自行判空 异常处理
var passiveID = m_configData["PassiveID"];
foreach (var p in passives)
{
if (p > 0)
{
// 参数分别代表: 1.给谁上被动 2.谁上的被动 3.被动的id
UTils.AddPassive(m_srcActor, m_srcActor, p);
}
}
}
// 移除被动
public void Remove()
{
// 自行判空 异常处理
var passiveID = m_configData["PassiveID"];
foreach (var p in passives)
{
if (p <= 0)
{
continue;
}
if (m_srcActor.ContainsPassive(p))
{
m_srcActor.RemovePassive(p);
}
}
}
```
![](https://pic2.zhimg.com/v2-3a1a7f91afaaa7f8a83f009bec94f2af_1440w.jpg)
装备的配置加成属性其实可玩项【非常丰富】
ok,至此已经讲完属性系统与装备填充的部分,接下来来关注比较核心的伤害公式部分(每个游戏此部分应不尽相同,特别是成长类型游戏,是数值策划保证游戏生命周期的必修课)
## 根据伤害公式构建伤害系统
> 来拆解一下伤害公式(早期的部分伤害公式中文描述,后续我们是有微调迭代的~,每一种伤害公式我们后期都和原版游戏运行进行了大量对比验证):
关于**伤害公式**的选择有非常多种,这里因为复刻的星铁就以**星铁的公式为引子**介绍,其余公式可以根据情况自行推导.
星铁的伤害公式是**乘法公式** DMG=a*ATK*F(targetDEF) , 这种公式可以形容为“**折损伤害**”,比较适合ATK与DEF的成长空间无限大(无限成长扩展那种),通过构建F(DEF),可以得到边界递减的收益效果:
![](https://pic1.zhimg.com/v2-99a8569c4a5d65b42827186083fe1b76_1440w.jpg)
示意
简单知道了公式原理后,我们来拆解一下星铁存在哪些伤害公式(十多种,不截全了**,早期Demo期间推导**的战斗公式需求表,内容在后续实战测试验证后有调整)
![](https://pic3.zhimg.com/v2-e52b95ecdb85e81f42f597b649a590a2_1440w.jpg)
星铁伤害公式在早期模拟系统Demo期间的拆解示意图
这里大家可以看到,正式游戏中的伤害公式不止一种,为了增加游戏的复杂多变性,往往就需要增加各种打破常规的计算方式来增加伤害的数值乐趣。这点如果做独立游戏的话根据平衡性自己设计吧,或者就完全拆解已经被验证过数值曲线的数值公式。
接下来来看战斗机制部分.
## 战斗体系的核心组成
> 核心为 技能 buff 子弹的组织循环, 再加入"被动""印记""法球"等效果, 基本可以实现"所有"想象的到的战斗技能效果.
![](https://picx.zhimg.com/v2-9f039a6b2456fd3739245c9572243901_1440w.jpg)
技能系统简易示意图
### 如何实现**高扩展性?**
> 这套体系的优势就在高扩展性,将逻辑原子化提供节点给"策划"调用,扩展参数以达到强大的复用效果。
在B站的评论区经常有人会问: **米哈游再出一个英雄,**你是不是要**重新全部实现**一次英雄的技能呢? 如果是这样,累也怕是累死了,星铁战斗团队十数人一个版本的内容我**完全手敲代码复刻要死**的。
如何做到的? 两点:
1. [技能配置文件](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=%E6%8A%80%E8%83%BD%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6&zhida_source=entity)
2. 优秀的抽象逻辑节点
### 技能配置文件
这部分既是指技能编辑器产出的技能组成,也同样指我们技能表中的配置。那为什么会存在两份结构不一样的配置表呢? 一个比较直观的解释: **技能配置表**更像是数据库.你可以随时**读取数据**. 技能配置文件则是组织技能出手后的复杂执行逻辑&效果的配置文件。他们的核心都为**"[数据驱动](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=%E6%95%B0%E6%8D%AE%E9%A9%B1%E5%8A%A8&zhida_source=entity)",**所以也可以说: 只要做到了足够丰富抽象的节点设计,使用**"数据驱动"**就可以让策划自己编排所有后续的战斗需求了。
![](https://pic1.zhimg.com/v2-39e442532f60a87e0455ae8e9113cb2e_1440w.jpg)
### 技能配置表
> 技能配置表单拿出来是因为大家基本都会使用的到,一般也会转成二进制数据而存在.这里我想为小团队/独立游戏制作者推荐一个个人制作的c#转表工具。
> 实现的原理很简单就不讲了有需要的我可以把工具贴到Git上。
使用结果上可以将配置表的数据转成**c#类**中清晰可见的**数据结构**并且有相应**数据填充**实现了c#类的配置表信息存储。适用于小团队是因为其**足够的方便**,导出后可以在业务侧直接使用对应配置表类的数据。并且点击类名可以**跳转**到对应类中**查看所有有效数据****减少查看原始Excel**的繁琐操作,更改**临时数据**更是可以做到**1s搞定**。
下面是c#配置表的示例(原始数据为excel表,为了避免不必要的麻烦使用的是自己的Demo游戏配置数据):
![](https://pic4.zhimg.com/v2-121ae2f4004eee63292ffe8f77cf486f_1440w.jpg)
技能配置表相关数据
```text
public partial class NCONFIG_CSHAP
{
public static Dictionary<int, CfgBuffData> CfgBuff = new Dictionary<int, CfgBuffData>
{
[100101] = new CfgBuffData {
ID = 100101,
Name = "通用伤害",
Desc = "测试",
SkillSrc = "XX技能",
LastTime = 1f,
BuffType = "通用伤害",
Param1 = 0,
Param2 = 0,
Param3 = 0,
PerCentParam = 10000,
target = "对方单体",
BuffTypeAddBuff = 0,
LastBuff = false,
DieJia = "",
MaxCount = 1,
DelayBuff = false,
TriggerCD = 0,
EndAddBuffs = new int[] { },
},
[100102] = new CfgBuffData {
ID = 100102,
Name = "普通攻击伤害",
Desc = "测试",
SkillSrc = "XX技能",
LastTime = 1f,
BuffType = "通用伤害",
Param1 = 0,
Param2 = 0,
Param3 = 0,
PerCentParam = 30000,
target = "对方单体",
BuffTypeAddBuff = 0,
LastBuff = false,
DieJia = "",
MaxCount = 1,
DelayBuff = false,
TriggerCD = 0,
EndAddBuffs = new int[] { },
},
}
}
```
### 抽象逻辑节点
> 当建立了一定体量的逻辑功能节点后,"策划"就可以通过简单改变配置来组合达到不同的技能效果了。
在讲之前,可以先分析以下两个技能的区别:
1.释放技能,对随机敌人造成x点伤害,并叠加一层火焰伤害,每回合每层火焰伤害造成x点伤害,最高上限5层。
2.释放技能,对选中敌人造成五段冰霜伤害,最后一段伤害必定造成暴击。
这两个技能看起来风马牛不相及,实际上同属【**伤害**】这个概念中.我们可以从技能描述中抽离出**"随机/选中敌人""火焰/冰霜""单回合/多回合""1段/5段伤害""暴击"**这些差异点。
那我们**只需要构建一个**通用的**伤害Buff**,在执行的过程中根据以上**五种差异元素**做配置判断即可,根据配置:
- 将**选敌逻辑**抽象出来: 可以选择单个、多个、随机的**敌人.**可以选择血量最低、伤害最高、距离最近的**友军**。
- 将**元素逻辑**抽象出来: 可以配置冰、火、暗、光属性等,并单独结算属性伤害&韧性计算(关于破韧等逻辑使用条件触发即可)。
- 将**回合逻辑**抽象出来: 控制生命周期,根据配置单回合生效即销毁还是执行多回合。
- 将**叠加逻辑**抽象出来: 分段伤害可以分为几种:
- 如果是纯伤害叠加可以使用**buff叠层**
- 如果要分开结算(比如暴击率独立判定),则配置多个伤害buff,或者buff执行衔接buff等方式都可以
- 将暴击**逻辑**抽象出来: 配置可以无极调整的暴击率参数就Ok.
用来简化的表达如下, 技能产生buff, Buff通过配置表参数传递特殊数据,而具体的执行逻辑节点是注册机制,使用Map索引即可。
![](https://picx.zhimg.com/v2-8613fcbae63468b0a769a93132238771_1440w.jpg)
Buff执行节点示意
![](https://pic4.zhimg.com/v2-1e3626998d48a4a702a0e549c4c3d7a3_1440w.jpg)
通用Buff逻辑执行节点注册示意
具体逻辑节点要做的事情千变万化了,也是高扩展性的核心竞争力 。这里就不展开讲了,注意配置的扩展就好,接下来来聊一下战斗相关的系统扩展。
## **战斗相关**的**系统扩展**
> **战斗相关**的**系统扩展**,涉及到星铁的比如**战斗表现|日志系统**等, 涉及到常规游戏的,如音频管理,资源加载等,不作为重点,**后续更新.**
### 战斗表现
> 战斗表现因为是卡牌又是Demo,实际上没什么好讲的,这个章节会比较短,后面会补上图或者视频.(其实分享链接里面也有,就是个演示界面。
从设计开始, 使用xx做原型设计
### 日志系统
> 重点是日志的全面打点&显示统计和输出,后续更新