diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json index be1e4df..585b56b 100644 --- a/.obsidian/workspace.json +++ b/.obsidian/workspace.json @@ -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", diff --git a/Timeline TODO.canvas b/Timeline TODO.canvas new file mode 100644 index 0000000..5b2bbc7 --- /dev/null +++ b/Timeline TODO.canvas @@ -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":[] +} \ No newline at end of file diff --git a/战斗/写代码的诗人/战斗系统:什么是数据驱动?.md b/战斗/写代码的诗人/战斗系统:什么是数据驱动?.md new file mode 100644 index 0000000..16d4d12 --- /dev/null +++ b/战斗/写代码的诗人/战斗系统:什么是数据驱动?.md @@ -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)等来配置,只有数据和行为都清楚了,才算得上数据驱动。 + +--- + +## **结语** + +游戏开发中有许多的词汇,这些词汇通常代表着一些模式, 了解这些词,可以让我们对项目的设计认知更为清楚。 \ No newline at end of file diff --git a/战斗/写代码的诗人/战斗系统:属性管理.md b/战斗/写代码的诗人/战斗系统:属性管理.md new file mode 100644 index 0000000..a47cf11 --- /dev/null +++ b/战斗/写代码的诗人/战斗系统:属性管理.md @@ -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,避免负数。 + +    另外,哪些属性需要同步给客户端,可以在代码中配置,也可以通过表格配置,通过表格会更灵活一点。 + +    最后,不仅仅是战斗属性,属性组件里的所有属性都需要配置在该表格,包括前面提到的角色等级这些。 \ No newline at end of file diff --git a/战斗/写代码的诗人/战斗系统:技能实现架构.md b/战斗/写代码的诗人/战斗系统:技能实现架构.md new file mode 100644 index 0000000..7e462a4 --- /dev/null +++ b/战斗/写代码的诗人/战斗系统:技能实现架构.md @@ -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) + +PS:DNF的武神一觉技能,在风场阶段,按Z可立即踢出终结一击。 + +### **技能恢复(回滚)** + +在网络游戏中,客户端和服务器需要同时演算,为了表现尽可能的好,客户端会做较多的预演,包括命中判定。 + +对于简单的伤害技能来说,客户端判定命中后,即可播放响应的特效和音效;但对于抓取和控制技能来说,客户端就不能使用本地的命中结果,必须等待服务器通知抓取结果,然后才能进入下一阶段。 + +这里就有一个问题:由于网络延迟的问题,客户端可能在动作结束后都没有收到服务器的响应,那客户端该怎么办呢?是保持这个抓取动作,还是恢复到Idle动作? + +需要恢复到Idle动作,这样客户端的表现才足够流畅。但这要求,**客户端在收到服务器的协议的时候,能立即从抓取成功开始执行,即技能框架需要支持从任意阶段开始执行**。 + +也就是说,我们的技能脚本在启动时,需要根据技能的输入立即切换到指定阶段执行。从这个角度讲,也不建议技能在逻辑层划分前摇、施法、后摇这些阶段。 + +```csharp +public class SkillScript1001 : Task { + 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) + +如果网络延迟,可能在超过怪物身位后将怪物抓住 + +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式的。 + +![](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 { + 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 { + 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 skillDataDic; + // 技能冷却信息 + public Dictionary cdDic; +} +public class SkillData { + public SkillCfg cfg; + public SkillTaskCfg taskCfg; // 脚本配置,见前篇 + public List 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); +} +``` + +## **结语** + +本篇主要讲的是一些高层设计,关于细节的实现,以后讲一部分。不过,作者最近有很多代码要写,文章的更新频率可能会放缓。 \ No newline at end of file diff --git a/战斗/写代码的诗人/战斗系统:框架设计—战斗视角的GameObject.md b/战斗/写代码的诗人/战斗系统:框架设计—战斗视角的GameObject.md new file mode 100644 index 0000000..3b7372c --- /dev/null +++ b/战斗/写代码的诗人/战斗系统:框架设计—战斗视角的GameObject.md @@ -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 { +``` + +为什么是“状态”,而不是“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在设计 —— 所以结果必然更正确。 + + +    “证明设计的正确性”是我在该项目获得的重要习惯,这为后面的技能修改器设计起到了极大的作用。 + +--- + +结语 + +    “一切皆状态”这套架构有极强的扩展性和灵活性,在我看来,基本不会有更好的抽象了。 + +    读者要想掌握它,关键在于打破通过玩游戏获得的先入为主的观念 —— 不要从玩家的角度去设计战斗系统。 \ No newline at end of file diff --git a/战斗/写代码的诗人/战斗系统:状态配置管理(技能配置管理).md b/战斗/写代码的诗人/战斗系统:状态配置管理(技能配置管理).md new file mode 100644 index 0000000..373b13c --- /dev/null +++ b/战斗/写代码的诗人/战斗系统:状态配置管理(技能配置管理).md @@ -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) + +最终加了多少力量,在技能界面是看不见的,是动态计算的。 + +PS:DNF的武神玩家都是多动症玩家... + + + +**施展条件** + +有些项目是将技能的施展条件放在了技能表中的,比如我以前的项目;施展条件确实不属于运行时数据,但条件是存在组合和分层的,用Excel很难表达(很不直观)。有几种方式: + +1. 手写Json(或其它格式) +2. 为Excel制作编辑器,可视化编辑Json +3. 转移到数据脚本,通过编辑器编辑 + +为Excel做编辑器需要巨大的工作量才能做得好,但做好了收益巨大;不过,对于中小团队而言,教会策划写Json更具性价比。 + +在有通用数据编辑器的情况下,将这部分配置转移编辑器也是个方案;但数据一部分在表格,一部分在编辑器导出的Json文本,可能会带来一些不便。 + +不过,由于技能的执行数据是在编辑器中配置的,所以将施展条件等转移到编辑器不会有额外的副作用,所以很合适。 + +--- + +## **数值成长问题** + +在我将[行为树](https://zhida.zhihu.com/search?content_id=255785060&content_type=Article&match_order=1&q=%E8%A1%8C%E4%B8%BA%E6%A0%91&zhida_source=entity)引入到战斗系统时,技能的所有参数都直接配置在了行为树的节点上,这面临着几个问题: + +1. 角色技能是支持升级的,升级以后数值会变化,总不能让策划每一级画一棵树吧? +2. 参数散落在行为树的节点上,客户的技能Tips该怎么做? +3. 表现层和逻辑层的逻辑可能不同,客户端和服务器可能使用两棵树,但部分参数是相同的。 + +其实第一个问题还好,因为技能升级,其数值通常都是按公式计算的,所以我们可以直接在编辑器中支持公式,就像这样: + +```csharp +public class Value { + public double value; + public int deltaLv; // 每多少级执行一次成长 + public double k; + public double b; + public double min; + public double max; +} +``` + +PS:这里仅以线性成长函数为例。 + +至于客户端技能Tips,一种常见的方式是:策划将格式文本(Fromat)和数值分离,并冗余定义一套变量,由程序根据公式计算每一级的数值,然后再根据Format计算最终的展示文本 —— 使用name占位符。 + +这种方案虽然能解决问题,但我向来对**增加策划工作量的设计**表示否定,因为手工维护的东西(冗余数据)越多,工作效率越低,出错的概率也越高;而程序处理问题,通常是一次性的,对就是对,错就是错;如果担心运行时的性能,可以通过开发期的工具解决。 + +第三个问题,自然也可以通过冗余配置来解决,但这也是会增加策划工作量的,所以不好。 + +正确的方案是:**将变量定义在行为树外部**(集中定义),行为树中的节点通过name引用关联的字段。就像这样: + +![](https://pica.zhimg.com/v2-a7fca1a1a43ac54206d996421bf9b78e_1440w.jpg) + +数据结构定义如下: + +```csharp +// 状态变量:和上面的Value结构类似,只是多了name +public class StateVar { + public string name; // 变量名 + public double value; // value = k * lv +b + // ... +} +// 状态脚本配置--运行时会缓存在State对象上 +public class StateTaskCfg { + // 状态变量 + public List stateVars; + // 技能施展条件 + public ICondition castCond; + // 服务器脚本 + public Task stask; + // 客户端脚本 + public Task ctask; +} +// 变量引用 +public class VarRef { + public string name; // 变量名,非空表引用 + public double value; // 常量值 + public int index = -1; // 查询缓存 +} +``` + +简单文本示例: + +```as3 +// 技能配置 - Dson文本格式 +{@{StateTaskCfg} + skillId: 1001, + skillVars: [ + {name: attack_01, value: 1.5, min: 1.5, max: 3}, + {name: attack_02, value: 1.2, min: 1.2, max: 2}, + ], + castCond: {}, + stask: {}, + ctask: {} +} +// 伤害节点示例:攻击力系数为变量,元素类型为固定值 +{@{DamageAction} + attack: {name: attack_01}, + element: {value: 1} +} +``` + +我们将技能、Buff中的**所有数值都定义为VarRef类型** —— 包括触发间隔、触发次数、技能CD等。如果变量名不为空字符串,则表示变量引用,否则表示常量值。 + +### **定制等级属性** + +首先,数值的成长最好是可预测的,这对玩家来说更容易理解,所以并不建议如此做。 + +如果真的有这种需求,是可以支持的,我们只需要将成长公式分等级段即可。 + +```csharp +public class Value { + // 成长公式配置 + List upgradeCfgs; +} +public class ValueUpgradeCfg { + public int minLv; // 公式适用等级 + public int maxLv; + + public double value; // 该段的初始值 + public int deltaLv; // 每多少级执行一次成长 + public double k; + public double b; + public double min; + public double max; +} +``` + +## 数值传递问题 + +![](https://pic3.zhimg.com/v2-3f5e9d520ce0ab86d66c211898a649d6_1440w.jpg) + +技能加Buff是非常常见的需求,如果需要在技能的Tips界面,看见最终的Buff数值,如持续时间,属性加成等,应该怎么做? + +最直接的办法,就是**保持两个脚本具有相同的变量** —— name和成长公式相同。这样,只要技能等级和Buff等级一样,那么算出的数值也就一样,也就不需要传递了。 + +另一种方案是运行时解决,**允许指定状态的变量值**,即覆盖变量,代码如下: + +```csharp +public sealed class State { + public StateCfg cfg; + public int lv; // 状态等级 + public StateTaskCfg taskCfg; // 变量配置在这里 + public List values; // 最终值 + // ... + // 查询变量值 + public double GetValue(VarRef varRef) { + if (string.IsEmptyOrWhitespace(varRef.name)) { + return varRef.value; // 常量 + } + if (varRef.index < 0) { // 索引缓存 + varRef.index = taskCfg.GetIndex(varRef.name); + } + return values[varRef.index]; + } +} +``` + +将状态由等级计算的数值全部缓存下来,然后允许外部修改。 + +**那怎么选呢?** + +自然是全都要。**技能的数值并不完全由等级计算,还会受到技能修改器影响**,因此保持公式一致并不能完全保证数值的一致性,所有后者是必须的。 + +而前者也是必须的,因为Buff并非总是由主动技能添加,也可能由装备的被动技能触发,我们需要保持在无修改器的情况下,等级相同时属性一致。如果担心冗余配置出错,可以通过[数据检查工具](https://zhida.zhihu.com/search?content_id=255785060&content_type=Article&match_order=1&q=%E6%95%B0%E6%8D%AE%E6%A3%80%E6%9F%A5%E5%B7%A5%E5%85%B7&zhida_source=entity)来校验 —— 数据检查工具是不可或缺的。 + +--- + +## **舍弃子弹表** + +我们通常有一个GameObject的总表,配置着逻辑层用到的所有GameObject;然后,为了减少总表的列数,部分项目会将子类型才拥有的字段会拆分到子表中,子弹表就是比较常见的。 + +游戏中的子弹有许多特殊的职责 —— 这个我们后面的文章讲,因此子弹表通常有非常多的字段,而且主要是跟逻辑相关的。 + +在一切皆状态的框架下,子弹表是可以完全省略的,并不需要额外的配置;但如果大家使用的自己的框架,我建议将子弹的配置改为Json或Lua,也采用数据驱动的方式配置,这可以大幅提供系统的可维护性和可扩展性。 + +--- + +## **选择一种好的文本格式** + +最后,我们应当选择一种好的文本格式来记录数据,它需要满足以下基本条件: + +1. 能精确表达数据 +2. 易于阅读和手工编写 +3. 易于维护(修改) +4. 支持注释 +5. 良好的格式 + +作者在处理序列化问题的时候,设计了自己的数据文本格式《[Dson文本格式](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247483975%26idx%3D1%26sn%3D9f20abc69e456cbea798be54ca227135%26scene%3D21%23wechat_redirect)》,有兴趣的读者可以看一看。 + +--- + +## **结语** + +除了技能和Buff外,其实我们的副本流程这些也需要脚本化,其表格和数据脚本的设计,规则是相似的。 \ No newline at end of file diff --git a/战斗/独立游戏-万字解析游戏战斗系统开发.md b/战斗/独立游戏-万字解析游戏战斗系统开发.md new file mode 100644 index 0000000..aeda4e9 --- /dev/null +++ b/战斗/独立游戏-万字解析游戏战斗系统开发.md @@ -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 CfgBuff = new Dictionary + { + [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做原型设计 + +### 日志系统 + +> 重点是日志的全面打点&显示统计和输出,后续更新 \ No newline at end of file