更新日志
This commit is contained in:
56
.obsidian/workspace.json
vendored
56
.obsidian/workspace.json
vendored
@@ -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
9
Timeline TODO.canvas
Normal 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":[]
|
||||
}
|
||||
100
战斗/写代码的诗人/战斗系统:什么是数据驱动?.md
Normal file
100
战斗/写代码的诗人/战斗系统:什么是数据驱动?.md
Normal 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,我将既有的行为树编辑器修改为了通用的数据结构编辑器 —— 不再限制节点的类型,就像这样:
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
这样以后,对于简单的技能,策划就可以在编辑器中直接实现;而特殊的技能,也只需要程序提供特殊的节点即可。也就是做完这件事,我便理解了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)等来配置,只有数据和行为都清楚了,才算得上数据驱动。
|
||||
|
||||
---
|
||||
|
||||
## **结语**
|
||||
|
||||
游戏开发中有许多的词汇,这些词汇通常代表着一些模式, 了解这些词,可以让我们对项目的设计认知更为清楚。
|
||||
319
战斗/写代码的诗人/战斗系统:属性管理.md
Normal file
319
战斗/写代码的诗人/战斗系统:属性管理.md
Normal 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,避免负数。
|
||||
|
||||
另外,哪些属性需要同步给客户端,可以在代码中配置,也可以通过表格配置,通过表格会更灵活一点。
|
||||
|
||||
最后,不仅仅是战斗属性,属性组件里的所有属性都需要配置在该表格,包括前面提到的角色等级这些。
|
||||
399
战斗/写代码的诗人/战斗系统:技能实现架构.md
Normal file
399
战斗/写代码的诗人/战斗系统:技能实现架构.md
Normal 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. 最后一个打击帧之后到技能结束,称之为后摇
|
||||
|
||||

|
||||
|
||||
街霸·隆 动作拆解
|
||||
|
||||
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的时机,想在什么时候触发就在什么时候触发 —— **控制反转。**
|
||||
|
||||

|
||||
|
||||
空绞锤未抓取敌人时,不触发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架构。
|
||||
|
||||

|
||||
|
||||
PS:DNF的武神一觉技能,在风场阶段,按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)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
如果网络延迟,可能在超过怪物身位后将怪物抓住
|
||||
|
||||
PS:DNF的女柔道,就巨受网络延迟的影响 —— 非常影响手感。
|
||||
|
||||
### **逻辑表现同步切换**
|
||||
|
||||
我在《[战斗系统:框架设计](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式的。
|
||||
|
||||

|
||||
|
||||
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(子弹),子弹击中角色时触发子弹效果。
|
||||
|
||||

|
||||
|
||||
## **技能组件的作用**
|
||||
|
||||
不论是新旧框架,技能组件的主要作用是一样的:技能数据中心。
|
||||
|
||||
只要是角色技能,都应该添加(注入)到技能组件;如果需要识别来源,可以在配置上标记。
|
||||
|
||||
```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);
|
||||
}
|
||||
```
|
||||
|
||||
## **结语**
|
||||
|
||||
本篇主要讲的是一些高层设计,关于细节的实现,以后讲一部分。不过,作者最近有很多代码要写,文章的更新频率可能会放缓。
|
||||
355
战斗/写代码的诗人/战斗系统:框架设计—战斗视角的GameObject.md
Normal file
355
战斗/写代码的诗人/战斗系统:框架设计—战斗视角的GameObject.md
Normal file
@@ -0,0 +1,355 @@
|
||||
战斗系统,在大型游戏中,基本就是最复杂的系统;在项目中,通常由专门的战斗策划和战斗程序负责。相信读者中也有维护过战斗系统的,我想问几个问题:
|
||||
|
||||
1. 有站在技能和Buff的更高层审视过你们的战斗系统吗?
|
||||
|
||||
2. 项目的战斗系统有明确的架构吗?
|
||||
|
||||
3. 项目的战斗系统有缺陷吗?知道问题的成因吗?
|
||||
|
||||
4. 能实现你玩过的游戏中的所有技能和Buff效果吗?
|
||||
|
||||
|
||||
我不知道大家的回答如何,但如果让刚离开第一个项目时候的我来回答这些问题,我几乎需要全部答NO...
|
||||
|
||||
我后来发现,其实不仅我是这样,有大量的开发者都对战斗系统缺少系统性的认知和思考,即使当前项目的战斗框架是他实现的。为什么呢?因为他不是最初的设计者,而只是搬运工,他并不真正了解他手头的工具。
|
||||
|
||||
其实,对战斗系统有系统性的认知非常重要,有许多设计问题,在我们只盯着部分模块的时候是发现不了的 —— 你只会觉得很难受,但不知道为什么。
|
||||
|
||||
本篇就带着大家看一下常见的战斗框架设计,以及我的心血之作,相信你定有所收获。
|
||||
|
||||
---
|
||||
|
||||
1.独立抽象(反面教材)
|
||||
|
||||

|
||||
|
||||
我的第一和第二个项目,都将主动技能、被动技能、Buff进行了独立的抽象。
|
||||
|
||||
第一个项目的战斗系统是由我Leader搭建的,一开始仅包含主动技能和Buff两个模块;后来,在我接手战斗系统的维护工作后,策划提出了被动技能的需求,我便增加了被动技能模块。
|
||||
|
||||
第二个项目的战斗系统是谁搭建的,我并不知道,但由我的同事(旸仔)维护;我记得一开始项目里也只有技能和Buff模块,后来,旸仔在处理被动技能需求时,也增加了被动技能模块 —— 果然是同龄人。。。
|
||||
|
||||
项目共性
|
||||
|
||||
如果看细节的话,这两个项目的代码差异是非常大的;但从整体(框架)上看,两个项目的内核其实是相同的。
|
||||
|
||||
首先,从整体上看,角色都可以分为5大块:
|
||||
|
||||
1. 属性:属性即角色身上的数据,包括:攻防血、坐标朝向、模型、等级、名字...
|
||||
|
||||
2. 主状态机:用于管理角色的的主要状态切换,通常与角色的模型控制相关
|
||||
|
||||
3. 主动技能:通常由玩家操作施展,且通常会控制角色的模型动作(Action)
|
||||
|
||||
4. 被动技能:通过养成系统或装备获得的效果,获得后立即生效,无时间限制
|
||||
|
||||
5. Buff:角色身上的增益或减益状态,通常是临时性的
|
||||
|
||||
|
||||
其次,主动技能都是基于FSM + Action的框架实现的,FSM管理技能的主流程,Action实现具体的效果;至于Buff和被动技能,也都是采用类似Action的方式实现的,只是接口抽象上稍有不同。
|
||||
|
||||
```
|
||||
// 技能执行上下文
|
||||
```
|
||||
|
||||
Q:为什么坐标朝向和模型被标粗体?
|
||||
|
||||
A:坐标、朝向、模型(Model)及其动作(Action)与受击包围盒和攻击包围盒的计算相关,非常重要。
|
||||
|
||||
主状态机
|
||||
|
||||
其实,这两个项目的服务端都没有显式定义角色主状态机,而是根据角色的属性(移动状态、死亡标识、Buff等)来处理的互斥,我觉得是一个失误。
|
||||
|
||||
为什么应该定义主状态机?
|
||||
|
||||
从逻辑层的角度讲,将行走、施法、死亡、冰冻等纳入同一个状态机,可以让角色的主要状态及其互斥逻辑更为清晰。
|
||||
|
||||
从表现层的角度讲,角色模型一次只能播放一个动作,因此也需要一套状态机来管理;而要想表现层的状态机更容易编写,逻辑层涉及到模型控制相关的逻辑最好也在同一套状态机中。
|
||||
|
||||

|
||||
|
||||
主要缺陷(重复感)
|
||||
|
||||
在我实现需求和维护的过程中,我感受到的最大缺陷是:重复感。
|
||||
|
||||
被动技能和Buff效果存在交集,被动和Buff的Action代码存在一定的重复。这种重复感一直困扰着我,我分不清这是真实的重复,还是表面的重复 —— 既想合并它们,又觉得它们不该合并。
|
||||
|
||||
此外,我明确的感知到,项目的战斗框架,无法支持DNF那般强大的战斗系统 —— 距离还很远。
|
||||
|
||||
PS:其实主动技能的效果和Buff、被动也会有一些重复,但重复率不高;而且由于主动技能流程的特殊性,我们总是认为主动技能就应该是独立的。
|
||||
|
||||
|
||||
|
||||
核心问题是什么?
|
||||
|
||||
开发者站在了用户的角度来设计架构。
|
||||
|
||||
我们在玩游戏的过程中,建立了主动技能、被动技能和buff概念;在实现需求时,便不假思索地设计了对应的抽象。可我们所谓的主动技能、被动技能、Buff之间的差异,真的存在吗?它是表面的还是真实的?
|
||||
|
||||
这样去做战斗系统的人 —— 比如我,大概率是没有思考过这个问题的。
|
||||
|
||||
---
|
||||
|
||||
2.被动是特殊的Buff
|
||||
|
||||

|
||||
|
||||
虽然被动和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.被动技能后台施展
|
||||
|
||||

|
||||
|
||||
被动技能和Buff合并的最大障碍是数据依赖不同,被动技能效果依赖的是SkillData,而Buff效果依赖的是BuffData。
|
||||
|
||||
既然被动技能也依赖SkillData,那么是不是可以让被动技能也走主动技能的流程呢?即被动技能挂载后,按主动技能流程自动施展,但不占用角色主状态机。
|
||||
|
||||
```
|
||||
public class SkillComponent {
|
||||
```
|
||||
|
||||
PS:当年在第一个项目的时候,我就没想到这么做 —— 还是能力不足。
|
||||
|
||||
将被动和主动一视同仁,意味着支持多技能同时施展,有以下优点:
|
||||
|
||||
1. 支持被动技能转主动技能施展,被动技能在满足条件的情况下可以手动施展
|
||||
|
||||
2. 支持主动技能间断性施法,eg:技能A第一击,技能B第一击,技能A第二击...
|
||||
|
||||
|
||||
后台技能 VS Buff
|
||||
|
||||
将被动技能看做主动技能,更符合代码层抽象 —— 程序员们会更容易理解。
|
||||
|
||||
但将被动技能看做Buff,更符合业务层的抽象 —— 策划们会更容易理解;此外,用Buff实现被动,还避免了效果交叠带来的重复代码。
|
||||
|
||||
思考题
|
||||
|
||||
被动技能和主动技能有共同点,又和Buff有共同点,那是不是说:主动技能和Buff有共同点?
|
||||
|
||||
---
|
||||
|
||||
4.一切皆Buff
|
||||
|
||||
' 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构成
|
||||
|
||||

|
||||
|
||||
在技能设计中,技能被分为多个阶段,每个阶段由一组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)
|
||||
|
||||
' 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时即分配,动态槽则在运行的时候根据需要创建。
|
||||
|
||||
什么是状态槽?
|
||||
|
||||
我们可以通过电脑的进程和网络端口来辅助理解,先看图:
|
||||
|
||||

|
||||
|
||||
状态必须先绑定到状态槽才可以执行,好比网络应用需要先绑定网络端口;当多个状态需要绑定在同一个状态槽时,将发生状态槽冲突,将挤掉既有的状态。
|
||||
|
||||
一些状态只能挂载在指定槽上,如:跳跃、死亡、冰冻等只能挂载在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)抽象。
|
||||
|
||||

|
||||
|
||||
为了尽可能保证接口的通用性,接口参数仅一个黑板,如下:
|
||||
|
||||
```
|
||||
public interface IEffect {
|
||||
```
|
||||
|
||||
但在实现具体效果的过程中发现,由于技能执行上下文(SkillContext)和Buff是不同的抽象类型,导致Effect还是需要独立实现 -- 需要从不同的类型上读取数据。
|
||||
|
||||
这个问题困扰了我好久,由于我坚持认为技能和Buff是不同的东西,因而无法合并它们的抽象;但随着行为树和黑板在项目中的应用越来越多,范围越来越广,我增加了些体悟,如:
|
||||
|
||||
1. 要想提升系统的灵活性,应尽量使用黑板代替明确的数据结构定义 —— 关注业务,而不是Class和Interface
|
||||
|
||||
2. 在使用黑板的时候,应尽可能直接将数据set到黑板 —— 即依赖注入,避免反向依赖
|
||||
|
||||
|
||||
在这一系列思想的影响下,我发现技能和Buff也不是不能合并了;在偶然的灵感之下,我决定使用状态(State)来表达合并后的抽象。
|
||||
|
||||
在确定使用状态这个概念后,我便联想到了跳跃和死亡等主状态机上的状态,我发现它们也可以纳入到新的状态管理中。为方便管理主状态的互斥以及查询,于是增加了状态槽(StateSlot)。
|
||||
|
||||
在获得最终的结构后,我发现了两点不同:
|
||||
|
||||
1. 我能证明设计的正确性!不论是拿计算机下的进程来举例,还是拿组件模式来举例,都能证明它的正确性;而在以前的项目中,我都没有这个感觉,只是觉着应该是这样的。
|
||||
|
||||
2. 我看待GameObject的视角被拉高了。在之前,我设计技能和Buff时,就只是盯着技能和Buff模块看,而现在是盯着整个GameObject在设计 —— 所以结果必然更正确。
|
||||
|
||||
|
||||
“证明设计的正确性”是我在该项目获得的重要习惯,这为后面的技能修改器设计起到了极大的作用。
|
||||
|
||||
---
|
||||
|
||||
结语
|
||||
|
||||
“一切皆状态”这套架构有极强的扩展性和灵活性,在我看来,基本不会有更好的抽象了。
|
||||
|
||||
读者要想掌握它,关键在于打破通过玩游戏获得的先入为主的观念 —— 不要从玩家的角度去设计战斗系统。
|
||||
260
战斗/写代码的诗人/战斗系统:状态配置管理(技能配置管理).md
Normal file
260
战斗/写代码的诗人/战斗系统:状态配置管理(技能配置管理).md
Normal 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,但其它参数配置在表格更加方便
|
||||
|
||||

|
||||
|
||||
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_,可以避免诸多麻烦
|
||||
|
||||

|
||||
|
||||
Q:哪些需要配置在数据脚本,哪些需要配置Excel表?
|
||||
|
||||
A:与执行相关的数据都配置在数据脚本(通过编辑器配置),其它则配置在Excel中。换句话说,Excel配置的主要是与Player养成功能相关的数据。
|
||||
|
||||
**技能类型**
|
||||
|
||||
技能的分类也是多维度的,不过没有状态那般复杂,所以可以展开为多列 —— 但建议仍采用Tag分类法。
|
||||
|
||||
被动技能也采用标签表达。
|
||||
|
||||
|
||||
|
||||
**技能Tips和状态Tips**
|
||||
|
||||
技能的Tips仍然配置在技能表,状态的Tips配置在状态表,虽然技能ID一定在状态表存在,但技能的Tips不能在状态表。
|
||||
|
||||
**技能的Tips属于预览数据的Tips,而状态的Tips属于运行时数据Tips**(真实数据的Tips)。
|
||||
|
||||
直接说,可能大家不太明白。我举个例子:DNF的武神步,其技能描述如下:
|
||||
|
||||

|
||||
|
||||
最终加了多少力量,在技能界面是看不见的,是动态计算的。
|
||||
|
||||
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引用关联的字段。就像这样:
|
||||
|
||||

|
||||
|
||||
数据结构定义如下:
|
||||
|
||||
```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;
|
||||
}
|
||||
```
|
||||
|
||||
## 数值传递问题
|
||||
|
||||

|
||||
|
||||
技能加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外,其实我们的副本流程这些也需要脚本化,其表格和数据脚本的设计,规则是相似的。
|
||||
418
战斗/独立游戏-万字解析游戏战斗系统开发.md
Normal file
418
战斗/独立游戏-万字解析游戏战斗系统开发.md
Normal 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. 将战斗系统**扩展**应用到**多类型**游戏中,验证扩展性和鲁棒性。
|
||||
|
||||
## 构建初始时序
|
||||
|
||||
> 根据**游戏类型**(卡牌),做了下**出手执行顺序**的草图,这是游戏运转前的基础验证,后续的所有战斗逻辑皆以此为基础扩展。
|
||||
|
||||

|
||||
|
||||
出手执行顺序草图
|
||||
|
||||
可以看到星铁的核心出手机制定义为**行动值**,其实行动值就是速度,每个角色在程序中的运行速度的差异决定了**普攻出手**的先后。然后辅以每个角色特殊的**战技出手**条件&**能量出手**条件,形成了一个角色**全部的行动规则。**
|
||||
|
||||
下面提供**角色更新**伪代码(为**常规模拟模式**稍微做了些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是我室友他学俄语的啊**!!!要蚌埠住了,实在没有勇气用俄语来做表头和代码注释,索性在部分属性上折个中用拼音了。
|
||||
|
||||

|
||||
|
||||
后来才悔恨的加了一行中文注释.. 晚了
|
||||
|
||||
这里有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);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
装备的配置加成属性其实可玩项【非常丰富】
|
||||
|
||||
ok,至此已经讲完属性系统与装备填充的部分,接下来来关注比较核心的伤害公式部分(每个游戏此部分应不尽相同,特别是成长类型游戏,是数值策划保证游戏生命周期的必修课)
|
||||
|
||||
## 根据伤害公式构建伤害系统
|
||||
|
||||
> 来拆解一下伤害公式(早期的部分伤害公式中文描述,后续我们是有微调迭代的~,每一种伤害公式我们后期都和原版游戏运行进行了大量对比验证):
|
||||
|
||||
关于**伤害公式**的选择有非常多种,这里因为复刻的星铁就以**星铁的公式为引子**介绍,其余公式可以根据情况自行推导.
|
||||
|
||||
星铁的伤害公式是**乘法公式** DMG=a*ATK*F(targetDEF) , 这种公式可以形容为“**折损伤害**”,比较适合ATK与DEF的成长空间无限大(无限成长扩展那种),通过构建F(DEF),可以得到边界递减的收益效果:
|
||||
|
||||

|
||||
|
||||
示意
|
||||
|
||||
简单知道了公式原理后,我们来拆解一下星铁存在哪些伤害公式(十多种,不截全了**,早期Demo期间推导**的战斗公式需求表,内容在后续实战测试验证后有调整)
|
||||
|
||||

|
||||
|
||||
星铁伤害公式在早期模拟系统Demo期间的拆解示意图
|
||||
|
||||
这里大家可以看到,正式游戏中的伤害公式不止一种,为了增加游戏的复杂多变性,往往就需要增加各种打破常规的计算方式来增加伤害的数值乐趣。这点如果做独立游戏的话根据平衡性自己设计吧,或者就完全拆解已经被验证过数值曲线的数值公式。
|
||||
|
||||
接下来来看战斗机制部分.
|
||||
|
||||
## 战斗体系的核心组成
|
||||
|
||||
> 核心为 技能 buff 子弹的组织循环, 再加入"被动""印记""法球"等效果, 基本可以实现"所有"想象的到的战斗技能效果.
|
||||
|
||||

|
||||
|
||||
技能系统简易示意图
|
||||
|
||||
### 如何实现**高扩展性?**
|
||||
|
||||
> 这套体系的优势就在高扩展性,将逻辑原子化提供节点给"策划"调用,扩展参数以达到强大的复用效果。
|
||||
|
||||
在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)",**所以也可以说: 只要做到了足够丰富抽象的节点设计,使用**"数据驱动"**就可以让策划自己编排所有后续的战斗需求了。
|
||||
|
||||

|
||||
|
||||
### 技能配置表
|
||||
|
||||
> 技能配置表单拿出来是因为大家基本都会使用的到,一般也会转成二进制数据而存在.这里我想为小团队/独立游戏制作者推荐一个个人制作的c#转表工具。
|
||||
> 实现的原理很简单就不讲了,有需要的我可以把工具贴到Git上。
|
||||
|
||||
使用结果上可以将配置表的数据转成**c#类**中清晰可见的**数据结构**并且有相应**数据填充**,实现了c#类的配置表信息存储。适用于小团队是因为其**足够的方便**,导出后可以在业务侧直接使用对应配置表类的数据。并且点击类名可以**跳转**到对应类中**查看所有有效数据**,**减少查看原始Excel**的繁琐操作,更改**临时数据**更是可以做到**1s搞定**。
|
||||
|
||||
下面是c#配置表的示例(原始数据为excel表,为了避免不必要的麻烦使用的是自己的Demo游戏配置数据):
|
||||
|
||||

|
||||
|
||||
技能配置表相关数据
|
||||
|
||||
```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索引即可。
|
||||
|
||||

|
||||
|
||||
Buff执行节点示意
|
||||
|
||||

|
||||
|
||||
通用Buff逻辑执行节点注册示意
|
||||
|
||||
具体逻辑节点要做的事情千变万化了,也是高扩展性的核心竞争力 。这里就不展开讲了,注意配置的扩展就好,接下来来聊一下战斗相关的系统扩展。
|
||||
|
||||
## **战斗相关**的**系统扩展**
|
||||
|
||||
> **战斗相关**的**系统扩展**,涉及到星铁的比如**战斗表现|日志系统**等, 涉及到常规游戏的,如音频管理,资源加载等,不作为重点,**后续更新.**
|
||||
|
||||
### 战斗表现
|
||||
|
||||
> 战斗表现因为是卡牌又是Demo,实际上没什么好讲的,这个章节会比较短,后面会补上图或者视频.(其实分享链接里面也有,就是个演示界面。
|
||||
|
||||
从设计开始, 使用xx做原型设计
|
||||
|
||||
### 日志系统
|
||||
|
||||
> 重点是日志的全面打点&显示统计和输出,后续更新
|
||||
Reference in New Issue
Block a user