This commit is contained in:
zzz
2025-08-04 17:40:09 +08:00
parent 08ecc85597
commit 46c0e35419
24 changed files with 21 additions and 15 deletions

View File

@@ -0,0 +1,9 @@
#### 一些观念
- 面向接口编程
- IOC.Register**注册的是工厂**而不是实例
- 只有在获取的时候才是注册实例的时机
- 区别IOC获取实例的不是根据不同数据而获取不同的实例而是根据不同的区域获取不同的类型工厂
- 例子在某个业务中IOC注入的不是某个对象池的实例而是应该注入对象池是哪个
- 例子2:背包页面中需要某个Item的数据的时候应该通过**IOC先注入背包管理类**,然后用背包管理类获取这个数据

View File

@@ -0,0 +1,21 @@
- 错误处理
- 生命周期
- 层级
- 各层级动画
- SortingLayer和PanelInLayer
- 渲染问题
- 粒子特效
- 3D模型
- RT相机
- 自动回收
- 半透明混合
- 事件注册自动回收
- RT相机自动护手
- 优化
- 内存优化
- 常驻图片优化
- 图集优化
- 帧率优化
- Active优化
- 多语言
- 热更包

View File

@@ -0,0 +1 @@
- 当上层节点需要调用统一的构造函数的时候用抽象类,可以自定义构造函数的用接口

View File

@@ -0,0 +1,9 @@
{
"nodes":[
{"id":"ed3d4299af3c74e1","type":"text","text":"# BUG\n\n拖拽BoxSpan在滚动之后异常","x":-220,"y":-740,"width":250,"height":480},
{"id":"0d8282c969461d99","type":"text","text":"# 目前\n\n默认状态 切换状态 是否回到默认状态\n\n支持输入监听配置\n\n\t支持预输入配置\n\n\n\n需要支持删除操作\n支持粘贴板操作\n\n实际的敌人\n","x":-1060,"y":-740,"width":250,"height":480,"color":"1"},
{"id":"9ac5e140a1fddd90","type":"text","text":"# 重要\n\n需要一个Inspector面板\n\n滚轮缩放控制帧间隔宽度\n帧位置跟随宽度变化\n\n相机状态切换\n屏幕震动\n卡帧时间\n\n拖拽Box\n\n特效轨道\n{\n\t跟随节点\n\t\n}","x":-780,"y":-740,"width":250,"height":480,"color":"2"},
{"id":"d0bcb2652c1d4cbf","type":"text","text":"# 低优先","x":-500,"y":-740,"width":250,"height":480,"color":"4"}
],
"edges":[]
}

View File

@@ -0,0 +1,9 @@
---
原文发布链接: https://zhuanlan.zhihu.com/p/691516531
tags:
- 3C
- 战斗系统
- 技能编辑器
- AI
- BUFF
---

View File

@@ -0,0 +1 @@
source:https://github.com/m969/EGamePlay?tab=readme-ov-file

View File

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

View File

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

View File

@@ -0,0 +1,399 @@
说完技能的配置管理,本篇聊一聊技能的逻辑管理。
在“一切皆状态”的架构下,[技能系统](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=%E6%8A%80%E8%83%BD%E7%B3%BB%E7%BB%9F&zhida_source=entity)并不负责技能的Update管理所以技能系统的职责是很少的因此本篇主要讲几个技能实现层面的架构设计。
阅读提醒:
1. 关于战斗系统的框架设计,可阅读《[战斗系统:框架设计](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247484186%26idx%3D1%26sn%3Dfde958e5c26b7e7ba572eee0b64211c8%26scene%3D21%23wechat_redirect)》
2. 本篇尚不涉及技能修改器的管理
---
## **前后摇问题**
在说其它问题之前,我决定先说前后摇的问题,我想再次提醒各位程序和策划:**不要站在用户的角度来设计架构**。
### **前摇、施法、后摇是根据什么划分的?**
**时间**。按格斗游戏的标准来说:
1. 技能开始到第一个打击Hit帧之前称之为前摇
2. 第一个打击帧到最后一个打击帧,称之为施法
3. 最后一个打击帧之后到技能结束,称之为后摇
![](https://pic4.zhimg.com/v2-02318c3a81f428d2e1bfba09a7bd1cc7_1440w.jpg)
街霸·隆 动作拆解
PS图片来源于网络。
### **代码里需要定义前摇、施法、后摇这些阶段吗?**
这个问题对于常年玩游戏的程序员来说可能有点反直觉。在我的前两个项目中技能都是FSM式的都在代码里明确划分了前摇、施法、后摇等阶段。如下
```csharp
public enum SkillPhase {
// 开始帧
Begin = 0,
// 启动前摇也有命名sing/pre-cast的
StartUp = 1,
// 执行施法也有命名casting的
Active = 2,
// 恢复后摇也有命名post-cast的
Recovery = 3,
// 结束帧
End = 4
}
```
所以,我们以前的项目定义了这些阶段,我们就应该定义这些阶段吗?
_避免经验主义_!要想正确回答这个问题,我们还需要了解一些问题。
**非时间轴施法问题**
虽然游戏中的大多数技能都是时间轴施法的,因此前摇后摇的划分是比较精准的;但对于非时间轴施法技能而言,前后摇就不那么好划分。
**多段式技能问题**
游戏中有很多技能是多段式的,且每一段都有启动过程和恢复过程,这个能算前后摇吗?
有很多人肯定想着说不算。那么问题来了:为什么要将第一段的启动和最后一段的恢复特殊处理呢?为什么不能把每一段都看做是等同的呢?中间段就不能有特殊逻辑吗?
**充能(蓄力)阶段问题**
我记得在第二个项目中还为技能设计了充能Charge阶段在前摇后施法前。
在部分多段式的技能中,每一段都是可以等待玩家输入进行蓄力的,只将第一个蓄力阶段拉出来特殊处理合理吗?
**[Dota2](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=Dota2&zhida_source=entity)里不就有前摇、施法和后摇这些阶段吗?**
是有的。在第三个项目我提议干掉这些阶段时就被客户端主程拒绝了举的例子就是Dota2。
怎么说呢我只能说Dota2的战斗体系还不够复杂它和[DNF](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=DNF&zhida_source=entity)这类格斗游戏还是很有差距。
在第三个项目中,客户端的技能表现是基于前摇、施法、后摇这些事件进行的;说实话,我觉得一点都不好,远不如用编辑器画行为树([TaskTree](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=TaskTree&zhida_source=entity))来得清晰和可扩展。
**前后摇阶段的问题到底是什么?**
**流程划分得越细,就越固化,系统的灵活性和扩展性越差**
上面的充能阶段就是典型,要想保持技能系统的灵活性,需要尽可能的减少对技能流程的约束。
**所以,代码里需要定义前摇、施法、后摇这些阶段吗?**
不需要,不建议!如果项目中已经定义了前后摇这些阶段,可以保留,但应尽可能减少逻辑层对这些阶段的依赖。
如果是新项目,我建议尽可能避免定义这些施法阶段;如果真的要定义,也要尽可能保持粗糙,有前摇、施法、后摇三个阶段就可以了,不要再去细分。
### 避免感知其它技能的执行阶段
部分项目有这样的需求:目标技能进入后摇时触发一个行为。
这其实是非常不好的需求,策划提出这样的需求,证明策划对战斗系统的认知还不到位,他不知道他这样的设计会导致什么样的后果。**前后摇的需求,只应该出现在表现层,而不应该出现在逻辑层,否则会严重影响系统的扩展性**。
为什么会导致扩展性问题?
每个技能都是一个脚本,只有尽可能减少对其它技能(脚本)执行过程的假设 —— 除非技能之间是深度绑定的,系统才有最佳的灵活性和扩展性。
那深度绑定的技能之间如何监听流程呢?
被监听的技能在执行特定的阶段时,抛出一个特定的事件即可。
---
### **关注真实需求**
程序通常会直接按照他听到的需求来实现功能,但这在战斗这种复杂系统的实现中是不行的。**程序需要关注策划需求中的真实需求,而不是表面需求**。
以前摇后摇这个问题为例我们应当关注策划想设计这些阶段的目的是什么比如前摇时被打断不记CD、后摇时可施展其它技能等等而不是关注策划的这些阶段。
以前摇时被打断不记CD这个需求为例大家会怎么实现
我想很多程序员第一想法就是给技能划阶段然后在技能退出的时候判断是否是前摇阶段然后决定是否执行技能CD。
那假设策划现在出了一个新需求只要抓取技能没有抓到敌人就不触发CD —— DNF女柔道的空绞锤就是这样那你又应该怎么办
要想避免每次出现新需求时都去打补丁正确的方式是让策划在编辑器或脚本中去配置触发CD的时机想在什么时候触发就在什么时候触发 —— **控制反转。**
![](https://picx.zhimg.com/v2-bde9696749680e6b6f56b3e8e37d065b_1440w.jpg)
空绞锤未抓取敌人时不触发CD
如果你说这样配置的话,策划工作量很大,不好。
那确实是,我们可以提供一个通用的前摇节点,当技能无特殊需求时,策划将这个节点挂上去就行 —— 而我们的代码里并不需要前摇后摇这些概念。
---
## **脚本化(流程抽象)**
**技能只有脚本化才有无限可能**
我工作近5年的时候才意识到这一点作者的前两个项目都是比较重度的MMORPG项目但技能和Buff的实现都不是脚本化的。
什么意思呢?
这两个项目,**都没有为整个流程建立一个抽象**。最典型的就是技能流程,技能的状态切换,都是在[SkillMgr](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=SkillMgr&zhida_source=entity)里实现的而不是由技能自身管理的。即技能的流程是高度固化的SkillMgr对技能的流程约定太多。
脚本化是什么意思呢?是要引入脚本语言吗?
NO脚本化是指我们应当尽可能将技能和Buff的流程看做一个黑盒Manager只是简单的去驱动它们就像这样
```csharp
// 作者框架下由TaskTree实现
public interface ISkillScript {
void Update();
void OnEvent(object evt);
}
// 技能模块就是简单地调用技能脚本的Update函数即可
public void Update(GameObject gobj) {
foreach (SkillContext ctx in gobj.skillComponent.castingSkills) {
ctx.script.Update();
}
}
```
如果是写过Unity的可以将技能比作MonoBehavior就明白什么意思了。
---
## **[FSM架构](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=FSM%E6%9E%B6%E6%9E%84&zhida_source=entity)**
技能需要实现为FSM架构这可以从多个方面说明。
### **玩法需求角度**
虽然游戏中的多数技能都是时间轴施法,但如果所有的技能都是时间轴施法,那么游戏就会变得无趣。所以技能需要支持两件事:
1. 响应玩家的输入,根据玩家输入的不同产生不同的行为
2. 根据环境的变化,主要指目标变化,产生不同的行为
环境数据也可以看做输入而要支持根据输入的不同跳转到不同的行为就依赖于FSM架构。
![](https://picx.zhimg.com/v2-4a5b2eacf4bbb72cbaf73980cbd6ab29_1440w.jpg)
PSDNF的武神一觉技能在风场阶段按Z可立即踢出终结一击。
### **技能恢复(回滚)**
在网络游戏中,客户端和服务器需要同时演算,为了表现尽可能的好,客户端会做较多的预演,包括命中判定。
对于简单的伤害技能来说,客户端判定命中后,即可播放响应的特效和音效;但对于抓取和控制技能来说,客户端就不能使用本地的命中结果,必须等待服务器通知抓取结果,然后才能进入下一阶段。
这里就有一个问题由于网络延迟的问题客户端可能在动作结束后都没有收到服务器的响应那客户端该怎么办呢是保持这个抓取动作还是恢复到Idle动作
需要恢复到Idle动作这样客户端的表现才足够流畅。但这要求**客户端在收到服务器的协议的时候,能立即从抓取成功开始执行,即技能框架需要支持从任意阶段开始执行**。
也就是说,我们的技能脚本在启动时,需要根据技能的输入立即切换到指定阶段执行。从这个角度讲,也不建议技能在逻辑层划分前摇、施法、后摇这些阶段。
```csharp
public class SkillScript1001 : Task<Blackboard> {
public override void Enter() {
SkillInput input = blackboard.Get(EffectKey.Input);
if (input.stateId > 0) { // 服务器告知切换到哪个状态
ChangeState(state2)
} else {
ChangeState(state1)
}
}
}
```
![](https://pic4.zhimg.com/v2-9d41102512b35c7bf5db3fa888fc47df_1440w.jpg)
如果网络延迟,可能在超过怪物身位后将怪物抓住
PSDNF的女柔道就巨受网络延迟的影响 —— 非常影响手感。
### **逻辑表现同步切换**
我在《[战斗系统:框架设计](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247484186%26idx%3D1%26sn%3Dfde958e5c26b7e7ba572eee0b64211c8%26scene%3D21%23wechat_redirect)》中提到:由于角色模型一次只能播放一个动作,要想表现层的状态机更容易编写,逻辑层涉及到模型控制相关的逻辑最好也在同一套状态机中。
当时是针对主状态机而言的,但对于单个技能来说同样适用:当一个技能由多个动作构成时,**动作切换最好伴随着逻辑切换**。而这些动作之间是平级的那么对应的逻辑节点之间也最好是平级的即FSM式的。
![](https://picx.zhimg.com/v2-4a1273ef18dbbe84c999d22fd4e62d47_1440w.jpg)
PS逻辑切换时动作不一定切换。
### **子技能问题**
我发现有些项目喜欢用子技能来解决问题,比如常见的普攻三连击,有些项目会将其实现为三个子技能。
看起来扩展性很好呢,是不是很美妙?
其实是个不好的设计。首先,事件可能需要测试当前施展的技能,引入子技能,这个问题就会变得麻烦;此外,如果项目想要设计技能修改器,子技能也会导致诸多的问题。
正确的方式就是实现FSM架构**一个技能不论多复杂,最好都保持为一个脚本**(对象)。
---
## **逻辑驱动方式**
在逻辑和动画的驱动方式上,有两种:[动画驱动逻辑](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=%E5%8A%A8%E7%94%BB%E9%A9%B1%E5%8A%A8%E9%80%BB%E8%BE%91&zhida_source=entity)和[逻辑驱动动画](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=%E9%80%BB%E8%BE%91%E9%A9%B1%E5%8A%A8%E5%8A%A8%E7%94%BB&zhida_source=entity)。
### **动画驱动逻辑**
由逻辑启动动画的播放,但逻辑层不执行更新,而是由动画播放到打击点和结束的时候,通知逻辑层执行相关行为 —— 需要在动画上记录数据。
简单说:逻辑层启动动画后,**依靠动画的回调执行逻辑**。
```csharp
public class Script : Task<Blackboard> {
private int triggerCount; // 已触发次数
public override void Enter() {
// 播放技能01动画并将自身作为回调传入这期间不进行Update
gobj.animator.PlayAction(EAnimation.Skill_01, this)
}
public void OnEvent(AnimationEvent evt) {
if (evt.type == EType.AniCompleted) {
SetSuccess(); // 流程结束
return;
}
// 动画事件触发行为
}
}
```
### **逻辑驱动动画**
同样由逻辑启动动画的播放,但逻辑层不关心动画的播放进度,按照逻辑层的时间点触发行为。
简单说:角色就是个动画播放器。
```csharp
public class Script : Task<Blackboard> {
private ScheduleCfg cfg; // // 效果调度配置
private int triggerCount; // 已触发次数
public override void Enter() {
// 播放技能01动画单纯调起
gobj.animator.PlayAction(EAnimation.Skill_01)
}
public override void Execute() {
// 逻辑层管理触发时机
State state = blackboard.Get(EffectKey.state);
if (state.timeEscaped < cfg.enterTime) return;
// ...
}
}
```
### **两者有什么区别?**
如果说游戏运行很流畅,不卡顿也没有延迟,那么两者可能看不出区别;但如果渲染性能跟不上,就会有明显的差别。
假设渲染层本该是60帧 / 秒但现在只有10帧 / 秒;那么在动画驱动逻辑的方案下,逻辑层的施法施法时间就会被拉长,但总是在动画播放到特定帧时执行相关逻辑;而如果是在逻辑驱动动画的方案下,表现层的时间就会被缩短,表现为技能动画可能才播了一点伤害都打完了。
也就是说,**在动画驱动逻辑的情况下,动画和逻辑总是同步的 —— 视觉体验好**;而在逻辑驱动动画的情况下,动画和逻辑的匹配是随缘的,但网络同步会更容易,更容易保证多客户端之间的一致性。
### **如何选择?**
如果是单机游戏,首选动画驱动逻辑;如果说经验不足,也可以选择逻辑驱动动画,在渲染压力不大的情况下,两者差异不大。
如果是网络游戏但很重视打击感如ACT和ARPG也建议选择动画驱动逻辑而且客户端需要预演否则体验会很差如果对打击感的要求不那么高比如MMORPG可以选择逻辑驱动动画。
---
## **特效的定位**
本篇不讲特效的实现和管理,作者想强调的是:**特效永远不应该参与到逻辑**。
为了追求打击感的精确性,我们可以让角色的动作来驱动逻辑,但万不可让特效来触发逻辑。特效、音效、着色器这些都应该作为纯粹的表现层,使得我们随时可以禁用和关闭它们。
如果想用“特效”触发逻辑怎么办?
以DNF的狂战士为例击杀敌人时概率出现血球血球会飞到角色身上然后触发回血。这个血球看起来可能是个特效但应该实现为GameObject子弹子弹击中角色时触发子弹效果。
![](https://pic1.zhimg.com/v2-3db57705e9fd2441dd1fe3b5e1f59e92_1440w.jpg)
## **技能组件的作用**
不论是新旧框架,技能组件的主要作用是一样的:技能数据中心。
只要是角色技能,都应该添加(注入)到技能组件;如果需要识别来源,可以在配置上标记。
```csharp
public class SkillComponent {
// 角色身上的所有技能
public Dictionary<int, SkillData> skillDataDic;
// 技能冷却信息
public Dictionary<int, double> cdDic;
}
public class SkillData {
public SkillCfg cfg;
public SkillTaskCfg taskCfg; // 脚本配置,见前篇
public List<double> values; // 技能属性,见前篇
public int lv;
public int Id => cfg.Id;
public double Cd => values[0];
}
```
作者曾经的项目中,出现过这类需求:开启某个系统后,获得一个技能槽,可以装备该系统激活的技能。
作者在第二个项目的时候,似乎没有把这类技能加入到技能模块;但在第三个项目的时候,就是加入到了技能组件的。
**为什么要加入到技能组件?**
有几个点:
1. 解耦:**战斗系统尽可能关注更少的模块**
2. 技能筛选:技能修改器等需要筛选技能,技能数据集中更容易实现
3. 技能屏蔽和替换:可阅读《[角色框架:变身玩法实现](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247484162%26idx%3D1%26sn%3D2dc7289b8675e9b71748a47997d5e844%26scene%3D21%23wechat_redirect)》
4. 统一技能施展流程:都通过技能模块发起,施展测试也通过技能模块进行
5. 统一同步管理
### **施展技能**
在新的“一切皆状态”框架下施展技能的流程是比较简单的就是根据SkillData创建对应的State然后挂载到GameObject上即可。
```csharp
// 施展技能
public void CastSkill(GameObject gobj, SkillData skillData, SkillInput input) {
// 派发施展技能事件
BeforeCastSkill(GameObject gobj, SkillData skillData);
// 创建State然后覆盖默认由等级算出的数值
State state = StateUtil.CreateState(skillData.Id, skillData.lv);
state.values.Clear();
state.values.AddRange(skillData.values);
state.lv = skillData.lv;
state.input = input;
// 添加状态 -- 启动技能
stateMgr.AddState(gobj, state);
}
```
## **结语**
本篇主要讲的是一些高层设计,关于细节的实现,以后讲一部分。不过,作者最近有很多代码要写,文章的更新频率可能会放缓。

View File

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

View File

@@ -0,0 +1,260 @@
阅读提醒:
1. 本篇虽然总是以技能为例,但在一切皆状态的架构下,所有的状态效果配置都是相同的。
2. 关于表格设计,可阅读《[配置:表格设计](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247483826&idx=1&sn=2d3cc9ac988cb20e60fb8fca1405bf52&scene=21#wechat_redirect)》
---
## **Excel之殇**
作者的第一个项目和第二个项目都是使用Excel来配置技能的那复杂度简直爆炸 —— 好几张表,每张表又是几十个字段。回过头看,我们的战斗策划的工作真是太艰难了,策划里面加班最多的,就是数值策划和战斗策划,可是真的需要那么加班吗?
我在《[配置:吐槽时间](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247483826%26idx%3D2%26sn%3Dd4aacd587f734bdd25d1d82a220f7a60%26scene%3D21%23wechat_redirect)》中提到:**因为程序不配表,体会不到策划配表的痛苦,因此缺少反思**。程序很少去思考自己设计的正确性,或是想着能用就行,不考虑团队其它成员的工作效率,这对整个项目来说是非常有害的。
程序,就是用来解放生产力的。如果你的程序不仅没有减少他人的工作量,反而增加他人工作量,那这个程序就是不合格的。
## **[状态总表](https://zhida.zhihu.com/search?content_id=255785060&content_type=Article&match_order=1&q=%E7%8A%B6%E6%80%81%E6%80%BB%E8%A1%A8&zhida_source=entity)**
我在前篇《[战斗系统:什么是数据驱动?](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247484217%26idx%3D1%26sn%3D8fe2c017ab84501915d99ec79c3371c3%26scene%3D21%23wechat_redirect)》中提到对于技能这种复杂的数据应该舍弃Excel使用Json或Lua等来配置。那我们是否还需要一张状态总表
我的建议是需要,它有几个作用:
1. 状态总览:我们可以知晓一共有哪些状态,以及状态的一些基础数据
2. 有效性控制:只有在表格中存在的状态才是有效的,而不是所有扫描到的配置都有效
3. 简化配置虽然行为相关的参数不适合配置在Excel但其它参数配置在表格更加方便
![](https://pic3.zhimg.com/v2-f8d27bf17d3bad3752005ecb02478d0a_1440w.jpg)
PS关于状态表的字段设计可阅读《[战斗系统:状态管理](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247484197%26idx%3D1%26sn%3D6e069ad2972f1222cf96eaa118116c37%26scene%3D21%23wechat_redirect)》。
## **[技能总表](https://zhida.zhihu.com/search?content_id=255785060&content_type=Article&match_order=1&q=%E6%8A%80%E8%83%BD%E6%80%BB%E8%A1%A8&zhida_source=entity)**
同理,技能我们也需要一张总表,用于控制技能的有效性。不过,**技能表中的ID必须在状态表中存在**,这有几个作用:
1. 可通过技能ID查询关联的State配置 —— 约定大于配置
2. _脚本中不需要区分状态ID和技能ID_,可以避免诸多麻烦
![](https://picx.zhimg.com/v2-84d6db916fce33b47902598762aff167_1440w.jpg)
Q哪些需要配置在数据脚本哪些需要配置Excel表
A与执行相关的数据都配置在数据脚本通过编辑器配置其它则配置在Excel中。换句话说Excel配置的主要是与Player养成功能相关的数据。
**技能类型**
技能的分类也是多维度的,不过没有状态那般复杂,所以可以展开为多列 —— 但建议仍采用Tag分类法。
被动技能也采用标签表达。
**技能Tips和状态Tips**
技能的Tips仍然配置在技能表状态的Tips配置在状态表虽然技能ID一定在状态表存在但技能的Tips不能在状态表。
**技能的Tips属于预览数据的Tips而状态的Tips属于运行时数据Tips**真实数据的Tips
直接说可能大家不太明白。我举个例子DNF的武神步其技能描述如下
![](https://picx.zhimg.com/v2-b2cf963ab726ec6d94b097e43e3d267d_1440w.jpg)
最终加了多少力量,在技能界面是看不见的,是动态计算的。
PSDNF的武神玩家都是多动症玩家...
**施展条件**
有些项目是将技能的施展条件放在了技能表中的比如我以前的项目施展条件确实不属于运行时数据但条件是存在组合和分层的用Excel很难表达很不直观。有几种方式
1. 手写Json或其它格式
2. 为Excel制作编辑器可视化编辑Json
3. 转移到数据脚本,通过编辑器编辑
为Excel做编辑器需要巨大的工作量才能做得好但做好了收益巨大不过对于中小团队而言教会策划写Json更具性价比。
在有通用数据编辑器的情况下将这部分配置转移编辑器也是个方案但数据一部分在表格一部分在编辑器导出的Json文本可能会带来一些不便。
不过,由于技能的执行数据是在编辑器中配置的,所以将施展条件等转移到编辑器不会有额外的副作用,所以很合适。
---
## **数值成长问题**
在我将[行为树](https://zhida.zhihu.com/search?content_id=255785060&content_type=Article&match_order=1&q=%E8%A1%8C%E4%B8%BA%E6%A0%91&zhida_source=entity)引入到战斗系统时,技能的所有参数都直接配置在了行为树的节点上,这面临着几个问题:
1. 角色技能是支持升级的,升级以后数值会变化,总不能让策划每一级画一棵树吧?
2. 参数散落在行为树的节点上客户的技能Tips该怎么做
3. 表现层和逻辑层的逻辑可能不同,客户端和服务器可能使用两棵树,但部分参数是相同的。
其实第一个问题还好,因为技能升级,其数值通常都是按公式计算的,所以我们可以直接在编辑器中支持公式,就像这样:
```csharp
public class Value {
public double value;
public int deltaLv; // 每多少级执行一次成长
public double k;
public double b;
public double min;
public double max;
}
```
PS这里仅以线性成长函数为例。
至于客户端技能Tips一种常见的方式是策划将格式文本Fromat和数值分离并冗余定义一套变量由程序根据公式计算每一级的数值然后再根据Format计算最终的展示文本 —— 使用name占位符。
这种方案虽然能解决问题,但我向来对**增加策划工作量的设计**表示否定,因为手工维护的东西(冗余数据)越多,工作效率越低,出错的概率也越高;而程序处理问题,通常是一次性的,对就是对,错就是错;如果担心运行时的性能,可以通过开发期的工具解决。
第三个问题,自然也可以通过冗余配置来解决,但这也是会增加策划工作量的,所以不好。
正确的方案是:**将变量定义在行为树外部**集中定义行为树中的节点通过name引用关联的字段。就像这样
![](https://pica.zhimg.com/v2-a7fca1a1a43ac54206d996421bf9b78e_1440w.jpg)
数据结构定义如下:
```csharp
// 状态变量和上面的Value结构类似只是多了name
public class StateVar {
public string name; // 变量名
public double value; // value = k * lv +b
// ...
}
// 状态脚本配置--运行时会缓存在State对象上
public class StateTaskCfg {
// 状态变量
public List<StateVar> stateVars;
// 技能施展条件
public ICondition castCond;
// 服务器脚本
public Task<Blackboard> stask;
// 客户端脚本
public Task<Blackboard> ctask;
}
// 变量引用
public class VarRef {
public string name; // 变量名,非空表引用
public double value; // 常量值
public int index = -1; // 查询缓存
}
```
简单文本示例:
```as3
// 技能配置 - Dson文本格式
{@{StateTaskCfg}
skillId: 1001,
skillVars: [
{name: attack_01, value: 1.5, min: 1.5, max: 3},
{name: attack_02, value: 1.2, min: 1.2, max: 2},
],
castCond: {},
stask: {},
ctask: {}
}
// 伤害节点示例:攻击力系数为变量,元素类型为固定值
{@{DamageAction}
attack: {name: attack_01},
element: {value: 1}
}
```
我们将技能、Buff中的**所有数值都定义为VarRef类型** —— 包括触发间隔、触发次数、技能CD等。如果变量名不为空字符串则表示变量引用否则表示常量值。
### **定制等级属性**
首先,数值的成长最好是可预测的,这对玩家来说更容易理解,所以并不建议如此做。
如果真的有这种需求,是可以支持的,我们只需要将成长公式分等级段即可。
```csharp
public class Value {
// 成长公式配置
List<ValueUpgradeCfg> upgradeCfgs;
}
public class ValueUpgradeCfg {
public int minLv; // 公式适用等级
public int maxLv;
public double value; // 该段的初始值
public int deltaLv; // 每多少级执行一次成长
public double k;
public double b;
public double min;
public double max;
}
```
## 数值传递问题
![](https://pic3.zhimg.com/v2-3f5e9d520ce0ab86d66c211898a649d6_1440w.jpg)
技能加Buff是非常常见的需求如果需要在技能的Tips界面看见最终的Buff数值如持续时间属性加成等应该怎么做
最直接的办法,就是**保持两个脚本具有相同的变量** —— name和成长公式相同。这样只要技能等级和Buff等级一样那么算出的数值也就一样也就不需要传递了。
另一种方案是运行时解决,**允许指定状态的变量值**,即覆盖变量,代码如下:
```csharp
public sealed class State {
public StateCfg cfg;
public int lv; // 状态等级
public StateTaskCfg taskCfg; // 变量配置在这里
public List<double> values; // 最终值
// ...
// 查询变量值
public double GetValue(VarRef varRef) {
if (string.IsEmptyOrWhitespace(varRef.name)) {
return varRef.value; // 常量
}
if (varRef.index < 0) { // 索引缓存
varRef.index = taskCfg.GetIndex(varRef.name);
}
return values[varRef.index];
}
}
```
将状态由等级计算的数值全部缓存下来,然后允许外部修改。
**那怎么选呢?**
自然是全都要。**技能的数值并不完全由等级计算,还会受到技能修改器影响**,因此保持公式一致并不能完全保证数值的一致性,所有后者是必须的。
而前者也是必须的因为Buff并非总是由主动技能添加也可能由装备的被动技能触发我们需要保持在无修改器的情况下等级相同时属性一致。如果担心冗余配置出错可以通过[数据检查工具](https://zhida.zhihu.com/search?content_id=255785060&content_type=Article&match_order=1&q=%E6%95%B0%E6%8D%AE%E6%A3%80%E6%9F%A5%E5%B7%A5%E5%85%B7&zhida_source=entity)来校验 —— 数据检查工具是不可或缺的。
---
## **舍弃子弹表**
我们通常有一个GameObject的总表配置着逻辑层用到的所有GameObject然后为了减少总表的列数部分项目会将子类型才拥有的字段会拆分到子表中子弹表就是比较常见的。
游戏中的子弹有许多特殊的职责 —— 这个我们后面的文章讲,因此子弹表通常有非常多的字段,而且主要是跟逻辑相关的。
在一切皆状态的框架下子弹表是可以完全省略的并不需要额外的配置但如果大家使用的自己的框架我建议将子弹的配置改为Json或Lua也采用数据驱动的方式配置这可以大幅提供系统的可维护性和可扩展性。
---
## **选择一种好的文本格式**
最后,我们应当选择一种好的文本格式来记录数据,它需要满足以下基本条件:
1. 能精确表达数据
2. 易于阅读和手工编写
3. 易于维护(修改)
4. 支持注释
5. 良好的格式
作者在处理序列化问题的时候,设计了自己的数据文本格式《[Dson文本格式](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247483975%26idx%3D1%26sn%3D9f20abc69e456cbea798be54ca227135%26scene%3D21%23wechat_redirect)》,有兴趣的读者可以看一看。
---
## **结语**
除了技能和Buff外其实我们的副本流程这些也需要脚本化其表格和数据脚本的设计规则是相似的。

View File

@@ -0,0 +1,81 @@
#ArcSystem #BBScript #动作游戏
---
#### Source:
[Unity动作游戏【一】GamePlay拆解 - 知乎](https://zhuanlan.zhihu.com/p/25292088386)
1. 前言
2. 动作切换
2.1 简单定义
2.2 层级化管理动作
2.3 取消与衔接
2.4 动作的过渡与附加
2.5 总结
3. 帧是动作游戏的逻辑单位
3.1 以Tick模式更新逻辑帧
3.2 动作帧的组成
3.3 把动作当成一个协程
3.4 总结
----
#### Source:[(2 封私信 / 4 条消息) Unity动作游戏【二】如何设计一套逻辑帧驱动框架? - 知乎](https://zhuanlan.zhihu.com/p/27612124414)
1. 前言
2. 需求分析
2.1 时间轴驱动万物
2.2 时间轴并行
2.3 定时任务
2.4 生命周期
2.5 异步操作
3. 时间轴实现
3.1 时间轴组件(BBTimerComponent)
3.2 时间轴管理器(BBTimerManager)
4. 定时任务实现
4.1 定时任务(BBTimerAction)
4.2 BBTimerComponent拓展
4.3 定时任务声明
4.4 如何使用定时任务
5. 生命周期实现
5.1 生命周期拓展
5.2 EventSystem拓展
5.3 完善BBTimerManager
6. 应用
6.1 攻击检测演示
6.2 动作协程中的循环控制流
6.3 单独修改Unit的TimeScale

View File

@@ -0,0 +1 @@
https://www.bilibili.com/list/watchlater?oid=114522652147889&bvid=BV1HKJAzyEZ5&spm_id_from=333.1007.top_right_bar_window_view_later.content.click

View File

@@ -0,0 +1,378 @@
更新1:
*有一个很重要的点我忘记说了,就是游戏策略 -实际上在制作动作系统的时候要考虑兼容各种策略,**但是在调手感前, 要先想清楚自己的战斗策略**,所为战斗策略是指博弈方式,以及对玩家的操作进行约束,约束具体是指你需要的是一个黏糊糊的手感,还是一个干净利索的手感,而整个战斗博弈是什么样的, 如何保持有效博弈的同时,让玩家特别明确,例如剪刀石头布的方式?,或者是打地鼠? 亦或者是节奏游戏?或者搓招?
*关于战斗反馈 - 这一点是关于给玩家的信息的, 所有给玩家的信息绝对不能是单一的,例如被击,被击相关联的信息我之前看过一篇文章他们做了11种不同的反馈方向,保证玩家至少能接收到3-4种, 例如声音, 攻击特效,手柄震动, 屏幕效果, 动作反馈,环境反馈等等,这种信息量越多,玩家就越明确,这里还涉及到上一条战略策略,玩家游戏失败的原因归错到游戏的可能性也会更低
*关于业务感知能力 - 我想说其实大部分问题都是看做这个的game play有没有业务感知能力, 感知到一些细节还有问题, 这一块基本上靠策划是不行的,策划毕竟不能实现细节,而且很多时候还会提出错误的方向,这全靠gameplay去探索和补完, 所以gameplay是一个极其吃经验和爱的职位.如果你不是一个深爱你做的这个类型的game play 仅靠工程能力和一个靠谱的策划, 这个手感和细节一定达不到顶级.
---
首先3C(camera,control,character)是一个巨大的话题.
我对战斗系统本身非常有爱.
但是架不住不赚钱啊, 这年头手游当道,基本上仅仅是3C做的出色很难找到很好的工作.
运气好的是在我们那个独立游戏项目结束后有一段时间
而且加上我之前开发独立游戏很多时候被渲染相关的业务卡住,
于是就转型做技术向的TA, houdini大地形生成这个方向, 并且一边学习图形学,一边学习美术资产相关的东西.
图形学相关的东西也相当的迷人,我天生就喜欢复杂而且结构化的东西,感觉可以沉浸在图形学里好多年,如此说来人生也许一晃就过去了.
3C作为任何游戏的基础, 为了以后还有机会做动作类的游戏,也对之前经验的总结
我把所有的信息整合在这篇文章里,方便以后查阅,也分享给大家,希望得到更多的指点.
## 关于3C
关于3C我认为最重要的一件事是有爱
那么根据重要程度排序是这样的:
1.
2. 细节
3. 架构
爱决定了细节的数量和高度, 细节决定了质量, 架构决定你能不能开发一个能用的东西出来.
我想说其实大部分问题都是看你有没有业务感知能力, 感知到一些细节还有问题, 这一块基本上靠策划是不靠谱的,策划毕竟不能实现,所以gameplay是一个极其吃经验和爱的职位.如果你不是一个深爱你做的这个类型的game play 仅靠工程能力, 这个手感一定达不到顶级.
并且, 3C的camera是另外一个大坑,我用的Cinemachine,但是负责任的说,如果要追求好的3C相机必须自己做, 但是control和 character应该在一起做,我只做了后者也是对工程量的妥协,整个系统我从18年8月开始做, 期间得到很多大佬的指点和帮助例如 @我乱写的[1] 还有号称从不玩知乎的小白, 最后做到11月底,后来转向去研究渲染和houdini.
动作系统实际上就是一个ACT的核心,当然你拿他做RPG也没问题
大致分为美式和日式,
日式更多的是走的打击感,操作性
美式走的一般是真实感,而真实感需要大量的资源和黑科技加成,例如 motion matching
所以我的整个基础是基于日式的风格做的.
我认为整个日式动作游戏的3C水平 大致可以这么划分: 塞尔达 - 黑魂 - 只狼 - 鬼泣 or 猎天使魔女
当然这么分很不严谨, 这是我的认知
整体的导图如下:
![图片](https://mmbiz.qpic.cn/sz_mmbiz_png/l2Uzl9GfAIcGrR8WkWics8ia8cmyJbqFEpTZscsKy6SUvL92eMW5zWz5gQdRbkEjWmElLg8ZFU3au246apSoWWSQ/640?wx_fmt=png&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)
我在最后会把思维导图的下载链接发出来,方便大家浏览.如果你对战斗系统很熟悉,可以直接跳过文章直接看思维导图会更方便和直观.更多的描述用于新接触战斗的萌新方便上手
我们分为几个大类描述:
- 可配置性 - 可配置性决定了这东西做出来是否能用, 如何配置一个怪物,配置一个角色, 配置一个boss
- 动力学和动画模块分开 - 这样处理能避免大部分的奇怪问题.并且在结构上更为方便维护
- 状态管理 - 状态管理对玩家的输入如何处理, 前摇中段后摇 如何处理, 这里我开始漏掉了,我朋友告诉我应该谢谢状态管理, 所以文章完成后扭头来写这个的时候其实我已经没劲了,最后写的实在是写的很崩溃,因为内容太多,每个地方都要想半天当时怎么做的,什么思路,还要对照代码, 如果这里有不明确的地方希望有兴趣的童靴给我留言, 这里我补充一点,unity的状态机不是很好用,如果有工程能力的同学,建议自己手动管理,仅仅用animation controller的混合树就好了,我就是这么做的
- 动作的细节处理 - 这是一些经验之谈
- IK的简单描述- 其实IK的应用就那么几个点,具体的功能点就自己摸索摸索吧
- 物理和动画的互相作用 - 这里是进一步提升的方式, 但是我并没有做物理相关的东西
- 黑科技 - 如果你想把3C往更深了做 这些方向可以考虑
## 可配置性1 - 角色模板
可配置性是一个框架是否可用的标准
所以我们放在第一个讨论, 这里关系到你的工程能力, 一般第一次做重构个3-4次太正常了
所以大家放心大胆的开发,后面多重构就好了,不用考虑一次性开发一个完美的系统.
如何考虑可配置性呢? 我们需要从业务出发,抽象出我们需要的类型
1 角色 - 怪物和玩家都属于角色的一种
2 角色需要动作 - 动作让角色动起来, 每个不同类型的角色可能需要不同的动画, 但是角色之间有没有共性?
3 如何播放? 事件和角色的关系, 这里还要考虑AI , 其他玩家操作输入,网络等
让我们进一步抽象
有没有可能有几种角色,长相差距很大,但是动作是类似的? 例如拿刀的骷髅兵 和 拿剑的人类士兵, 同时需要攻击动作,和 idle , 和 走跑
可能骷髅兵的攻击只有两下, 人类士兵会多一个跳劈
并且我们支持非空判断,如果特殊攻击时空,就无法触发
那么从动作组上我们这样分类这三个单位(骷髅兵 - 弓箭兵 - 人类士兵)
- 角色A - 关联动作(攻击, idle - 移动 - 特殊攻击)
- 角色B - 关联动作(远程攻击 , 移动)
再此之外,还要考虑什么事件可以触发,例如攻击就是攻击事件触发
可能特殊攻击需要一个 扳机状态 + 攻击触发,
那么基本上大思路就出来了,
我们需要一个模板A 角色模板,支持配置模型 动作组, 以及一些参数
还有每个动作相关联的事件, 这样的东西有较高的配置性.
我做了如下模板:
![图片](https://mmbiz.qpic.cn/sz_mmbiz_jpg/l2Uzl9GfAIcGrR8WkWics8ia8cmyJbqFEpWbmOvJX3o3D015nUEW9zAWrXzh4wgF5hSlyEX0jT2SfMkSjDYMGV7Q/640?wx_fmt=jpeg&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)
当然,你可以根据你的业务需求调整,
下面的bing action setting 是用来绑定对应事件的
红色的代表该bing action 有问题
左边是状态, 右边是对应事件, none代表没有状态
![图片](https://mmbiz.qpic.cn/sz_mmbiz_jpg/l2Uzl9GfAIcGrR8WkWics8ia8cmyJbqFEp3yPbonRVyCc1wvttRjZfaVel4KwEMXmt08pcGZenFW6vEh26a3ymrA/640?wx_fmt=jpeg&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)这样,我们的角色模板就创建好了
**可配置性2 - 动画组**
然而关联对应的动画组, 我们也需要考虑角色的动画有那些类型
例如最简单的 idle - 走 - 跑 - 攻击 - 特殊攻击 这些动画背后有关联着那些内容呢?
idle - 走 - 跑 这三个被称为**locomotion** 是一个角色的基础动作组
我这里采用的是一个动画组支持多个locomotion, 放置一个角色有多重基础动作
例如一个角色有锤子, 和 举起锤子两个状态 , 举起锤子的时候可以移动, 那么locomotion里的移动动作必须全部换新的, 并且这个角色还可以切换武器 - 太刀, 那么所有的动画片又被替换了
所以最后动画组是这样的:
![图片](https://mmbiz.qpic.cn/sz_mmbiz_jpg/l2Uzl9GfAIcGrR8WkWics8ia8cmyJbqFEphAkwRYGcfTZEGREJNcAn2bdbcicqsdic8E7JCmYqvOgdwM9mGRxwT9vA/640?wx_fmt=jpeg&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)上面是一个基础的locomotion, 下面是对应的 其他locomotion
再往下是具体的动画业务, 这里最好的状态是下面的动画根据上面locomotion的切换也有一定的可编辑性,但是我当时并没有做.
动画片的意思是例如攻击, [idle - 走 - 跑] , 翻滚 , 这些动画的类型如何处理
我们当然可以直接播放动画片,但是一个动画片包含了很多可能性, 这些可能性我们应该做成可配置性的
locomotion 的做法我采用了是否的选择, 例如考虑到巨型怪物可能只有走,
或者某些小型怪物只有跑, 走和跑还有idle都是用于的可选项
locomotion动画片的选项如下:
![图片](https://mmbiz.qpic.cn/sz_mmbiz_jpg/l2Uzl9GfAIcGrR8WkWics8ia8cmyJbqFEpEH9p2eWOFEbxOnicjfP5gperEibtibOr0YycJsLQyrvOTxE3dpwxhxGQw/640?wx_fmt=jpeg&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)你可以考虑是否有疾跑, 跑, 和走
很关键一点在于生成locomotion以及对应的混合树,我把最复杂的混合树截图出来,其他的大家自己研究吧,有问题可以留言.
这里的关键点在于 走和跑的分离, 这样在左右横移的时候 不会出现这样的状态切换 [左横跑 - 左横走- idle - 右横走 - 右横跑], 双层混合树的好处在于直接变成:[左横跑 - 右横跑]
![图片](https://mmbiz.qpic.cn/sz_mmbiz_jpg/l2Uzl9GfAIcGrR8WkWics8ia8cmyJbqFEp2ubib1PB30d0pYANhickWtVyb5iaDpMkJlMyE0m4CbQxiaOSGpVI5qFUOA/640?wx_fmt=jpeg&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)当然, 直接八向混合也可以,但是**如果追求细节不要这么做.**
![图片](https://mmbiz.qpic.cn/sz_mmbiz_jpg/l2Uzl9GfAIcGrR8WkWics8ia8cmyJbqFEpbPkYTbLa6OberQqBT3CSWicE7uIDHlficYBkEMLIM5dx9oXB8pYhSupA/640?wx_fmt=jpeg&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)
最基础形态就是全部取消,只有idle:
![图片](https://mmbiz.qpic.cn/sz_mmbiz_jpg/l2Uzl9GfAIcGrR8WkWics8ia8cmyJbqFEpOLz53icjH0O8GjOC8gvpeKp9JRdYfwEludKZ8rXcCj0jwsXI2Mq0nWw/640?wx_fmt=jpeg&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)因为动画状态特别多,如下:
```
public enum AnimationType
```
我在用一个典型的举例,就不多讲了
如果讨论动画组,不讨论combo也太不厚道了
![图片](https://mmbiz.qpic.cn/sz_mmbiz_jpg/l2Uzl9GfAIcGrR8WkWics8ia8cmyJbqFEpYSsoaC4HgdCibcpzAwI4qt03xJ7taGe1BicAvAjMRSkusr56qQEzmtJw/640?wx_fmt=jpeg&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)combo我大致是这么做的, 可以支持多个动画片, 在播放时根据长度连续播放这样就支持连击了, 而且可以选择遮罩,例如只有上半身, 只有下半身,每个动画片绑定自己的前摇和后摇.
还有是否支持root motion等
再举个例子就是被击:
![图片](https://mmbiz.qpic.cn/sz_mmbiz_jpg/l2Uzl9GfAIcGrR8WkWics8ia8cmyJbqFEpdl2ibnb5MOpXAANI3fv2ENqG3cnB7VoR5GdHoBvC7f3AQibkhjNYypzQ/640?wx_fmt=jpeg&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)被击可以选择有效图层和具体播放动画片的朝向, 这个根据你的业务具体安排
其他例如loop动画, 普通animation, 就是可以配置前摇后摇时间即可, loop要支持三个动画的循环, 在动画播放结构中也需要支持.
**动力学和动画模块分离**
这里的核心思想是:
PlayController需要将输入交给动力学组件,同时将动力学组件的内容,交给Animation组件.
需要根据真实移动速度来决定动画状态,而不是输入向量,基于这个大的思想,可以保证一些奇怪的问题不要出现.
而动力学的核心在于处理特殊情况.
这里的类结构大概是这样:
![图片](https://mmbiz.qpic.cn/sz_mmbiz_jpg/l2Uzl9GfAIcGrR8WkWics8ia8cmyJbqFEpmm6Ft2b9UBIHricdBguYibvJXX4q0x984Myb9oPMayxibFNGF2TR7u7zA/640?wx_fmt=jpeg&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)Kinematic大概有几个大的业务要考虑清楚:
- **撞墙** - 撞墙的处理关键在于不要穿的太厉害,当动力学组件遇见墙体时候,Rootmotion依旧会执行,这时需要同步角色位置到当前动力学组件的位置,这个是基础
- **边缘检测** - 边缘检测在于下滑,一般人物会用一个胶囊体,这个胶囊体会卡在边缘,这里要处理的平滑,下落既是下落,而不下落时人物要站扎实不要抖
- **跳跃1** - 原地起跳 - 原地起跳最好用一个独立动画,问题的关键是当前跳的落点是高低不平还是平面,因为资源和时间的问题,我直接用跳跃处理的这里,当动画播完还没落地,我就认为地面是不平的直接过渡到跳跃循环
- **跳跃2** - 冲刺起跳 - 当玩家加速跑时,这个起跳必须和普通跳跃不同,否则没有那种速度感,同时向前跃起,这里最大的坑在于可能你刚起跳就撞到物体了,好一点的或者美式动作游戏会处理一下,给个撞击动画,但是有些游戏也不处理,尤其是日式游戏,硬播完对玩家来说并不是不可接受的事情,冲刺起跳后落地最好也接一个落地动画,例如翻个跟头,而撞墙就不适合播放,这里具体如何处理就要看GamePlay的爱了...
- **跳跃3** - 向前跳跌入悬崖或者落地 - 跳跃其实分为是哪个部分,起跳,loop, 跳跃落地,落地又分为下落时间过长的大硬直和下落距离不大的小硬直
- **爬梯子1** - 分为三个状态,上梯子,下梯子,和爬,这里最好给一个固定动画,由程序控制正播或者倒播,比较麻烦是对上点,并且要动力学组件配合,这里还有个做法就是用ik让手和脚找梯子的挂点具体就看GP的爱吧...
- **爬梯子2** - 还有一个坑就是爬梯子的时候,被击怎么处理,黑魂里是给人物一个抖动,死了就直接跌落
- **爬峭壁** - 这算是比较恶心的一个点了,爬峭壁的核心在于配合IK以及关卡设计,以及庞大的动画状态,这个状态属于一个全新的locomotion,我当时只是试了一下挂在峭壁上,然后按跳跃就播放跳跃,然后扒到下一个挂点
允许我偷个懒,多余的就不写了,有问题请评论追加...
## 状态管理
首先unity的animation controller提供的混合树非常好,但是状态却又一些不疼不痒的bug,例如A状态到B状态的过程中, 切换C状态这时会出现闪一下切过去, 上下半身的动作会又互相影响, 修正起来既蛋疼,又麻烦, 所以我干脆就自己管理状态, 让unity直接执行就好了, 手动管理状态,把ac内部的混合树当一个状态调用
而和状态在一起的,还有一个关联项目就是对输入的处理
输入可能有三个来源
1. 玩家手柄或者键盘
2. AI行为树
3. 网络
不管哪个来源,我们可以用同样的抽象层让输入统一,
输入分为几个类型
- 普通事件 - 不要让玩家的输入直接操作动作,一定要做一个转换,尤其是移动,移动的朝向交给动力学,动力学作用于动画系统,而按键输入一定要转化为一个类似行为的抽象.
- 状态转换型输入 - 某些按键实际上是一个状态, 例如,玩家按下收刀实际上角色要进入到一个收刀的状态,此时攻击键变成了一个特殊攻击,相对应这个状态下的输入都出现了变化,所以一个行为应该是基于状态的, 关于这个状态和事件的区别大致是这样的:
![图片](https://mmbiz.qpic.cn/sz_mmbiz_jpg/l2Uzl9GfAIcGrR8WkWics8ia8cmyJbqFEptytMVY5lM1uUEOR2qSeMhSEcdDEibuHicxTQIFiaNTpNbTRe3Uic87ZIHw/640?wx_fmt=jpeg&from=appmsg&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)
这个状态手柄上一般会用扳机键
- 输入的时效 - 一般一个动作分为三个阶段 - 前摇, 执行中, 后摇,你可以指定三个阶段那个是有效输入阶段,例如我一般会屏蔽前摇的输入, 执行中是有效输入阶段,后摇就处理玩家输入
- 输入指令的重排序 - 这里很重要的是当一个动作执行中,玩家的输入要如何处理,后摇就开始执行之前输入的动作进入预混合阶段,而且后摇这个时间点我推荐对动作进行排序,例如当前是攻击,那么要做一个输入的优先级排序, 优先级最高的可能是下一个攻击, 而翻滚之后 优先级最高的可能是喝血, 不要根据玩家的最后一次按键去执行,这里对感受的提升是巨大的
## 动作的细节处理
**攻击和翻滚相关:**
> *攻击,翻滚的前摇最好不要锁方向,这样玩家容易攻击之后怪物因为移动躲开了, 最好的量我感觉最好根据不同动作,每个单独设置
> * 攻击 - 攻击的核心在于Root Motion, 但是细节又有好多讲究,例如如何定帧, 是真的停顿还是减速, 攻击的碰撞又有几种方式, 有直接绑定碰撞盒的, 有攻击最后一个伤害帧算扇形面积的,攻击中还可能有被击, 被击怎么处理
> *攻击还有一个特性就是3D场景下的攻击,容易让玩家产生错误空间感受, 所以要增强玩家在3D场景下的攻击范围,或者敌人伤害的触发范围,这个要结合是否有PVP单独调整.
> *翻滚 翻滚我因为动作资源的限制用一个翻滚,然后角色转向的方式处理, 其实最好的方式应该是8个方向分别用独立的动作, 翻滚很重要一点是启动的时候,需要给一个旋转的时间,也就是前摇不要锁方向这一点是通用的
**locomotion相关:**
> *locomotion其实决定了一个手感的基础,大部分的动作都是由locomotion过渡的,这里双层混合树是指第一个混合树用来做当前速度,朝向的判断,第二层有三个对应的混合树
> idle,当没有速度的时候,就默认idle
> 走,当速度不够跑的时候,就默认用走
> 跑,速度够的时候就切换到跑,之所以要把跑单独出来,是因为有一个特殊情况,就是玩家在向左平移时,突然向右,有一个瞬间会速度到0,例如从左到右 速度会这样变化: [1, 0,-1], 此时如果走和跑用一个混合树,动作会过渡到走,然后过渡到跑,这里会有一个微小的卡顿,很不舒服
> 直接根据速度和方向切换到走跑,这样在一个平移中,例如向左横移,突然向右,会有一个微小的过渡卡顿, 跑-走-跑, 而实际上这是不符合人的移动习惯的,但是在一个情况下例如怪物没有走路的动画只有idle和跑,或者没有跑步,只有idle和走,那么可以用单层混合树搞定全部
**特殊动作处理:**
> 在某个武器的状态中,要支持一个或者多个特殊状态,例如太刀的收刀,大锤子的蓄力,有些特殊动作会禁止移动,有些特殊动作释放中可以移动,那这里我感觉最好是切换locomotion
> 切换武器实际上相对应的很多动画都要切换,我这里用的是直接切换全套动画组来处理,例如双刀切换到单手中剑,这连idle都切换了
**被击**
> 被击实际上是打击感的核心,一个动作的受力做的极其好,但是没有攻击物体时,你是感受不到力量的, 但是一个动作做的一般,但是被击做的非常到位,此时你可以感受到攻击后的力量,这个力量感就是被击表现,被击的核心有几块: 停顿配合, 击退距离和攻击移动距离要配合, 连续攻击后击飞, ,音效,特效等等
> 被击方式1 : 攻击原地不动,被击者微小后退,几下后击飞或者退出攻击距离(塞尔达),这是一种对怪物的保护,连击几下后怪物就被击飞
> 被击方式2 : 攻击者前移,被击者后退(黑魂)
> 被击方式3 4向被击 - 四向被击对被击的体验提示是很大的,而且被击本身最好支持Rootmotion,攻击对象被击后后退对被击对象也是一种保护,拉开与攻击者的距离,还要配合击倒,并且可以支持4向-同时支持每个方向根据攻击角度不同采用2种不同被击
> 击倒 - 这其实是一个保护性动作,击倒后角色不可以被二次攻击,当然也有可以被攻击的鬼泣里的就有这里就看具体策略了
**硬直中的被击处理**
> 硬直被打断 - 直接转入被击,这里的处理没有特别好的办法,被击的关键在于快速反馈,直接决定手感是否刀刀入肉,所以如果连续被攻击,就会出现抖动,如果例如被4个人前后间隔不到一秒攻击4下,这里可能要特殊处理是否要从第一个被击直接过渡到第四个 中间2个干掉一个 甚至直接干掉2个
> 硬直没有被打断 - 硬直动画例如蓄力攻击中被攻击有可能不会破坏硬直效果,那此时最好混合一部分被击,让人物有一定抖动,否则也会很假
**处决**
> 处决的核心在于把两个角色的相对位置弄的差不多, 比较糙你就直接滑过去, 然后两个动画的同步播放, 例如黑魂的背刺,你到达指定位置可以触发了,咱们就直接滑动调整位置,一般处决玩家就无敌了,只狼也是这么做的
> 另外一种就是类似QTE的,这一种又分好几种,例如莎木的就比较弱鸡, 但是战神的就比较猛,这个我没做过
**Gameplay 到过场动画的无缝衔接**
> 摄像机平滑过渡的切换和角色动作的切换,这个没做过,难点在于摄像机和角色如何抵达预定位置,中间还是平滑的,看看战神...即便不做成战神那样,这个细节量也很大
## 关于IK
我是用的final IK 解决我的ik问题,所以这里我不展开讲了, 主要处理这几个问题:
- 楼梯上脚悬空的处理
- 开门是用ik还是固定动画
- 拿桌上,抽屉内物体
- 死亡后的纸娃娃效果
- 头部朝向锁定目标或者场景内重要物品
## 物理和动画的相互作用
> 物理这里完全没做过,不过听某些大佬讲,这里处理好可以增加真实感,例如攻击的时候被击,被击没有打断攻击状态可以加一个10-30%的物理状态的抖动到攻击身上
> 例如你被前面的敌人攻击然后背后也有人攻击你,此时如果不处理就是抖动到背后的被击了,因为被击必须快速切换,响应慢了就假了,被击直接影响手感,听某些大佬说可以考虑混一点物理,这样抖动的效果会更好,但是我并没有试过
## 这些黑科技
> motion matching是育碧搞的一个动作匹配技术,基于目标的位置和方向速度通过算法从数据库中取用匹配动画,需要大量的动画片支持,同时还要预判断未来地形变化,总之很黑科技,没有足够的动画资源基本没办法做,美式动作游戏或者冒险游戏里大量的使用该技术
> 例如当一个角色的动画是变速的时候,如何保持动作的受力感受同时又可以变速,例如某个技能可以根据玩家输入加快速度,或者减慢速度,某些游戏会考虑尝试多套动作,但是高级动作游戏例如鬼泣,贝姐,显然不是这么做的,至少我感觉不是这么做也可能是我感觉错了
> 动作中断后如何和下一个动作保持良好的衔接-好吧,讲的直接点,我实在不知道鬼泣和猎天使魔女怎么那么灵活还那么流畅的,不管我怎么处理混合总感觉混合时间不够,或者动作幅度特别大就变的很奇怪,这里我感觉暴力一点就是做大量的中间动作片加强过渡, 不过只能是猜测了
---
嗯 能看到这里的都是真爱
希望有一天我也能开发出像黑魂, 只狼这样游戏设计和技术完美结合的游戏
而且有很多细节介于主题太大了,我也没劲写了
欢迎大家跟我讨论
我有一个网盘,里面放着我做的一些东西
思维导图我也放里面了.
也欢迎下载
链接: https://pan.baidu.com/s/1lLxvffJuN7NEds4mbNYPLg[2]
提取码: 9vb1
### 参考资料
[1]
@我乱写的: _https://www.zhihu.com/people/c6adcb1f69bff45a74f57f4e2150dc3c_
[2]
https://pan.baidu.com/s/1lLxvffJuN7NEds4mbNYPLg: _https://pan.baidu.com/s/1lLxvffJuN7NEds4mbNYPLg_

View File

@@ -0,0 +1,3 @@
[游戏知识学习——【战斗系统】-CSDN博客](https://blog.csdn.net/qq_53045580/article/details/130804401)

View File

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

View File

@@ -0,0 +1,182 @@
https://zhuanlan.zhihu.com/p/424070778
### 游戏输入系统设计总结
#### **核心设计思路**
1. **输入抽象分层**
- **基础输入层**:将输入抽象为按钮(`IButtonInput`)和轴值(`IAxisInput`)两类
- **复合输入层**:支持多个按键绑定到同一行为(`CompoundButtonInput`/`CompoundAxisInput`
- **行为映射层**:将输入状态转化为游戏行为(`InputButtonProcessor`
2. **关键接口设计**
csharp
复制
下载
// 按钮输入
public interface IButtonInput {
bool GetButton(); // 获取按钮状态
}
// 轴值输入
public interface IAxisInput {
float AxisValue(); // 获取轴值(-1~1)
}
#### **核心组件实现**
1. **基础输入实现**
- `KeyCodeButtonInput`封装Unity键值检测
- `ButtonAxisInput`:将按钮转为轴值(正/负方向)
2. **复合输入容器**
csharp
复制
下载
// 复合按钮OR逻辑
public class CompoundButtonInput : IButtonInput {
public bool GetButton() {
foreach(var btn in Buttons)
if(btn.GetButton()) return true;
return false;
}
}
// 复合轴值(取极值叠加)
public class CompoundAxisInput : IAxisInput {
public float AxisValue() {
float max=0, min=0;
foreach(var axis in Axis) {
float val = axis.AxisValue();
if(val<0) min = Mathf.Min(min, val);
else max = Mathf.Max(max, val);
}
return max + min;
}
}
3. **行为状态机**
csharp
复制
下载
public class InputButtonProcessor {
// 状态记录
public bool WasPressed; // 上一帧状态
public bool IsPressed; // 当前帧状态
// 状态查询属性
public bool OnPressed => IsPressed && !WasPressed; // 刚按下
public bool OnReleased => !IsPressed && WasPressed; // 刚释放
// 状态更新
public void Update(bool isPressed) {
WasPressed = IsPressed;
IsPressed = isPressed;
}
}
#### **系统工作流程**
1. **初始化绑定**PlayerInput
csharp
复制
下载
public void AddKeyboardControls() {
// 水平轴A/D/左右箭头
HorizontalDigiPad.Add(new ButtonAxisInput(
new KeyCodeButtonInput(KeyCode.A),
ButtonAxisInput.Mode.Negative
));
// 跳跃空格/Z/Y
Jump.Add(new KeyCodeButtonInput(KeyCode.Space));
}
2. **状态更新**每帧
csharp
复制
下载
void RefreshControls() {
// 1. 更新轴值输入
Input.HorizontalValue = HorizontalDigiPad.AxisValue();
// 2. 更新按钮状态机
for(int i=0; i<buttonInputs.Count; i++) {
bool pressed = buttonInputs[i].GetButton();
buttonProcessors[i].Update(pressed);
}
}
3. **游戏逻辑使用**
csharp
复制
下载
void Update() {
if(Input.Jump.OnPressed) {
// 处理跳跃触发
}
if(Input.Right.Pressed) {
// 处理持续右移
}
}
#### **设计优势**
1. **多键位绑定**一个行为支持多个触发键如跳跃可用空格/Z/Y
2. **输入抽象**统一处理键盘/鼠标/手柄等输入设备
3. **状态封装**提供精细的输入状态检测按下/持续/释放
4. **动态配置**运行时切换控制方案`RefreshControlScheme()`
5. **行为解耦**游戏逻辑只依赖抽象行为而非具体按键
#### **扩展建议**
1. **输入重映射**添加键位配置保存/加载功能
2. **输入缓冲**实现指令缓冲窗口提升操作手感
3. **设备扩展**集成手柄输入`GamepadButtonInput`
4. **组合键**支持Ctrl+Jump等组合键检测
5. **输入事件**改用事件驱动减少每帧状态查询
> 该设计通过分层抽象解决了多键位绑定问题,核心价值在于将**物理输入**到**游戏行为**的映射关系标准化,为复杂输入需求提供了可扩展框架。

View File

@@ -0,0 +1,9 @@
{
"nodes":[
{"id":"61edc211325da480","type":"text","text":"动作时间轴","x":-1206,"y":2853,"width":906,"height":60,"color":"4"},
{"id":"a7f14817e1497be2","type":"text","text":"不可打断","x":-1206,"y":2940,"width":586,"height":60,"color":"6"},
{"id":"e8d1678f431e2ee0","type":"text","text":"缓存输入","x":-870,"y":3020,"width":250,"height":60,"color":"6"},
{"id":"8fc0985df93fdde7","type":"text","text":"可切换的后摇","x":-620,"y":3020,"width":320,"height":60,"color":"6"}
],
"edges":[]
}

View File

@@ -0,0 +1,30 @@
### Animator
- 优势
- Unity原生支持
- 劣势
- 源码黑盒
- 不可编辑帧事件
- 意味着不可编辑打击感,不可编辑运动方向位移
- 帧事件可能被跳过的bug
- 混合也在黑盒里
### Timeline
- 优势
- 自定义Track Clip Behaviour
- Clip可以支持Inspector
- 自定义混合
- 劣势
- Inspector不可编辑某一帧的事件
- 没有Animator的状态可视化切换需要所有切换动作都在代码中写
### AnimacerComponent
- 优势
- 劣势
### 自定义Playable动作系统

View File

@@ -0,0 +1,20 @@
# 初级能力范围
- 大部分只有数据和UI的业务
- 动效
- 寻路
- 角色
- 战斗
- 地图
- 资源框架
- 热更
- [[性能优化-目录]]
- 资源管理系统
- 战斗系统
- GAS
- 创建一个示例项目
- 寻路
- 角色
- 模型 网格 渲染 骨骼 蒙皮