公司
This commit is contained in:
@@ -1,40 +0,0 @@
|
||||
|
||||
##### 流程组件
|
||||
- [x] **构建[[流程组件]]**
|
||||
|
||||
##### 数据结构更换
|
||||
- [x] 更换为新的数据结构
|
||||
- 路线数据变更
|
||||
- 旧版本: 由文件夹下Roomline记录
|
||||
- 新版本: 用链表实现,在 **关卡表-房间表** 中指定首个房间,随后用房间指向下个房间组成链表实现路线
|
||||
- [x] 接入Luban的表格
|
||||
- 旧的数据结构包括
|
||||
- 文件夹下
|
||||
- SO文件
|
||||
- 地板图片
|
||||
- 预览图
|
||||
- 地板数据,长宽高,
|
||||
- 地板承载的摆件怪物标志信息
|
||||
缺点:
|
||||
- SO文件会实时改动到实际的物体的配置数据
|
||||
但编辑器通常的模式是,需要dirty,不想保存的修改,不能直接修改到原有的数据
|
||||
- 对于无法序列化的东西无法保存,比如字典,参考路线配置,需要存一份文本配置来做序列化的事情
|
||||
|
||||
- Json文件
|
||||
- 一份额外信息,key为mapIndex,value为对应的额外信息
|
||||
- StreamingAssetPath中
|
||||
- RoomData.json 记录房间的怪物波次
|
||||
- MonsterWaveData.csv 怪物波次信息
|
||||
|
||||
- 新的数据结构
|
||||
- 文件夹下
|
||||
- 所有地板以及地板摆放的物体.json
|
||||
- 预览图
|
||||
|
||||
##### 单块地板编辑
|
||||
- 只专注于一个地块的编辑
|
||||
- 数据,地图大小(瓦片),环境物体
|
||||
- 更换地块图片
|
||||
- [ ] importer时更改数据width,height
|
||||
|
||||
##### 瓦片地图
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"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":[]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
原文发布链接: https://zhuanlan.zhihu.com/p/691516531
|
||||
tags:
|
||||
- 3C
|
||||
- 战斗系统
|
||||
- 技能编辑器
|
||||
- AI
|
||||
- BUFF
|
||||
---
|
||||
@@ -1 +0,0 @@
|
||||
source:https://github.com/m969/EGamePlay?tab=readme-ov-file
|
||||
@@ -1,100 +0,0 @@
|
||||
## **前言**
|
||||
|
||||
作者首次接触到这个词,大概是20年,当时我正在探索技能实现的正确方案。在这期间,我了解到[Dota2](https://zhida.zhihu.com/search?content_id=255482146&content_type=Article&match_order=1&q=Dota2&zhida_source=entity)编辑器的存在,而Dota2的技能扩展就提到了“[数据驱动](https://zhida.zhihu.com/search?content_id=255482146&content_type=Article&match_order=1&q=%E6%95%B0%E6%8D%AE%E9%A9%B1%E5%8A%A8&zhida_source=entity)”这个词。
|
||||
|
||||
不过,Dota2并没有对数据驱动这个词下定义,也没有解释数据驱动的优缺点 —— 即为什么要这么做,再加之他们的技能设计和我的想法差异过大,我便没有深入研究他们的设计。
|
||||
|
||||
我真正理解数据驱动,是在将[行为树](https://zhida.zhihu.com/search?content_id=255482146&content_type=Article&match_order=1&q=%E8%A1%8C%E4%B8%BA%E6%A0%91&zhida_source=entity)成功引入战斗系统后。在将行为树引入战斗系统后,为了支持编辑技能和Buff,我将既有的行为树编辑器修改为了通用的数据结构编辑器 —— 不再限制节点的类型,就像这样:
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
这样以后,对于简单的技能,策划就可以在编辑器中直接实现;而特殊的技能,也只需要程序提供特殊的节点即可。也就是做完这件事,我便理解了Dota2的数据驱动是什么意思。
|
||||
|
||||
---
|
||||
|
||||
## **那什么是数据驱动?**
|
||||
|
||||
我们编辑器导出的是[Json](https://zhida.zhihu.com/search?content_id=255482146&content_type=Article&match_order=1&q=Json&zhida_source=entity)格式(以后会调整),其内容虽然和Dota2有区别,但内核是一样的,那就是:**用数据描述行为**。
|
||||
|
||||
你可以将策划的配置(数据)看做是更高层的代码,而我们的程序则相当于解释器,解释配置实现对应的行为。
|
||||
|
||||
不过,数据驱动还有另一个解释:**通过修改数据,改变对象的行为**。
|
||||
|
||||
这个解释侧重最终目的,而非过程(怎么做);因为数据描述行为的情况下,数据改变,行为自然改变。
|
||||
|
||||
这两个解释都应该知晓,前者可以指导我们该怎么做,后者可以检验我们设计的正确性,也可以告知他人我们的设计目的。
|
||||
|
||||
---
|
||||
|
||||
## **它主要干了什么事情?**
|
||||
|
||||
它其实就干了一件事:**分离接口和实现**。
|
||||
|
||||
注意,这里是分离接口和实现,而不是分离数据和行为(面向过程)。面向过程下,数据只是数据,它不关心它如何被处理;而技能的配置是完整约定了技能行为的,它不仅仅约定了函数名,还约定了函数参数。
|
||||
|
||||
所以,**策划的配置起的是接口的作用**,而我们的代码则是对接口的实现,所以是接口和实现的分离。
|
||||
|
||||
---
|
||||
|
||||
### **数据驱动的优点:**
|
||||
|
||||
1. 更好的扩展性和灵活性 —— 显而易见
|
||||
2. 更好的可读性和可维护性
|
||||
3. 解放策划的生产力,减少程序的工作量
|
||||
4. 跨语言:配置通常采用语言无关的文本格式
|
||||
|
||||
|
||||
|
||||
### **数据驱动的缺点:**
|
||||
|
||||
1. 不确定性(安全性)
|
||||
2. 解析开销(脚本实例化开销)
|
||||
|
||||
不确定性,是我在向策划提供技能编辑器以后最苦恼的事。在编辑器里组合节点是很容易的——就连个线,但行为之间可能存在数据(上下文)依赖,由于策划并非专业的程序员,对程序运行时的上下文也并不完全了解,因此可能拼出错误的逻辑,而这些错误难以在开发期检测出。
|
||||
|
||||
所以,即使在有编辑器的情况下,我还是会经常被策划喊:“磊哥,你看看我这个技能怎么不好使呢?”
|
||||
|
||||
所以,即使是有编辑器,对战斗策划要求仍然是比较高的 —— 但已然降低不少。
|
||||
|
||||
---
|
||||
|
||||
## **如何实现数据驱动?**
|
||||
|
||||
有几个关键点:
|
||||
|
||||
1. **控制反转**:将(部分)控制权交给策划
|
||||
2. 组合模式:将大块的逻辑,拆分为一个个独立的单元,允许组合使用
|
||||
3. 函数无状态:函数的输入完全来源于黑板
|
||||
4. 保持数据结构简单:只使用简单的数据类型集合
|
||||
5. 上下文使用黑板类型
|
||||
6. 好的文本格式和序列化工具
|
||||
|
||||
---
|
||||
|
||||
### **数据驱动随处可见**
|
||||
|
||||
在游戏开发中,数据驱动有着大规模的应用,只是以前我们没有关注到这个词而已。我们的场景编辑器,任务编辑器,剧情编辑器,都是数据驱动的案例。甚至[Unity](https://zhida.zhihu.com/search?content_id=255482146&content_type=Article&match_order=1&q=Unity&zhida_source=entity),都可以说是数据驱动的,它的资产文件就是数据,我们通过数据告知Unity动画该怎么播,UI该怎么展示。
|
||||
|
||||
所以,虽然你可能不了解数据驱动这个词,但工作中其实一直在使用。
|
||||
|
||||
**数据驱动与编辑器有关吗?**
|
||||
|
||||
无关。配置(数据)是可以手写的,编辑器的作用只是简化了配置方式 —— 算是简单的图形化编程。
|
||||
|
||||
**那以前策划通过Excel组合不同的技能效果算数据驱动吗?**
|
||||
|
||||
严格意义上来说,是算的,但我更想说不算。
|
||||
|
||||
因为通过Excel配置技能的方式,程序的工作并不轻松,策划的工作就更是艰难。此外,使用Excel配置技能的结果是:**数据,数据不清楚;行为,行为不清楚,那谈什么数据驱动呢**?
|
||||
|
||||
对于技能这种复杂的数据,应该舍弃Excel,使用Json或[Lua](https://zhida.zhihu.com/search?content_id=255482146&content_type=Article&match_order=1&q=Lua&zhida_source=entity)等来配置,只有数据和行为都清楚了,才算得上数据驱动。
|
||||
|
||||
---
|
||||
|
||||
## **结语**
|
||||
|
||||
游戏开发中有许多的词汇,这些词汇通常代表着一些模式, 了解这些词,可以让我们对项目的设计认知更为清楚。
|
||||
@@ -1,319 +0,0 @@
|
||||
说完技能组件,本篇讲讲战斗系统的另一个重要组件:属性组件。
|
||||
|
||||
阅读提醒:
|
||||
|
||||
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,避免负数。
|
||||
|
||||
另外,哪些属性需要同步给客户端,可以在代码中配置,也可以通过表格配置,通过表格会更灵活一点。
|
||||
|
||||
最后,不仅仅是战斗属性,属性组件里的所有属性都需要配置在该表格,包括前面提到的角色等级这些。
|
||||
@@ -1,399 +0,0 @@
|
||||
说完技能的配置管理,本篇聊一聊技能的逻辑管理。
|
||||
|
||||
在“一切皆状态”的架构下,[技能系统](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=%E6%8A%80%E8%83%BD%E7%B3%BB%E7%BB%9F&zhida_source=entity)并不负责技能的Update管理,所以技能系统的职责是很少的,因此本篇主要讲几个技能实现层面的架构设计。
|
||||
|
||||
阅读提醒:
|
||||
|
||||
1. 关于战斗系统的框架设计,可阅读《[战斗系统:框架设计](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247484186%26idx%3D1%26sn%3Dfde958e5c26b7e7ba572eee0b64211c8%26scene%3D21%23wechat_redirect)》
|
||||
2. 本篇尚不涉及技能修改器的管理
|
||||
|
||||
---
|
||||
|
||||
## **前后摇问题**
|
||||
|
||||
在说其它问题之前,我决定先说前后摇的问题,我想再次提醒各位程序和策划:**不要站在用户的角度来设计架构**。
|
||||
|
||||
### **前摇、施法、后摇是根据什么划分的?**
|
||||
|
||||
**时间**。按格斗游戏的标准来说:
|
||||
|
||||
1. 技能开始到第一个打击(Hit)帧之前,称之为前摇
|
||||
2. 第一个打击帧到最后一个打击帧,称之为施法
|
||||
3. 最后一个打击帧之后到技能结束,称之为后摇
|
||||
|
||||

|
||||
|
||||
街霸·隆 动作拆解
|
||||
|
||||
PS:图片来源于网络。
|
||||
|
||||
### **代码里需要定义前摇、施法、后摇这些阶段吗?**
|
||||
|
||||
这个问题,对于常年玩游戏的程序员来说,可能有点反直觉。在我的前两个项目中,技能都是FSM式的,都在代码里明确划分了前摇、施法、后摇等阶段。如下:
|
||||
|
||||
```csharp
|
||||
public enum SkillPhase {
|
||||
// 开始帧
|
||||
Begin = 0,
|
||||
// 启动(前摇),也有命名sing/pre-cast的
|
||||
StartUp = 1,
|
||||
// 执行(施法),也有命名casting的
|
||||
Active = 2,
|
||||
// 恢复(后摇),也有命名post-cast的
|
||||
Recovery = 3,
|
||||
// 结束帧
|
||||
End = 4
|
||||
}
|
||||
```
|
||||
|
||||
所以,我们以前的项目定义了这些阶段,我们就应该定义这些阶段吗?
|
||||
|
||||
_避免经验主义_!要想正确回答这个问题,我们还需要了解一些问题。
|
||||
|
||||
**非时间轴施法问题**
|
||||
|
||||
虽然游戏中的大多数技能都是时间轴施法的,因此前摇后摇的划分是比较精准的;但对于非时间轴施法技能而言,前后摇就不那么好划分。
|
||||
|
||||
|
||||
|
||||
**多段式技能问题**
|
||||
|
||||
游戏中有很多技能是多段式的,且每一段都有启动过程和恢复过程,这个能算前后摇吗?
|
||||
|
||||
有很多人肯定想着说不算。那么问题来了:为什么要将第一段的启动和最后一段的恢复特殊处理呢?为什么不能把每一段都看做是等同的呢?中间段就不能有特殊逻辑吗?
|
||||
|
||||
|
||||
|
||||
**充能(蓄力)阶段问题**
|
||||
|
||||
我记得在第二个项目中,还为技能设计了充能(Charge)阶段,在前摇后,施法前。
|
||||
|
||||
在部分多段式的技能中,每一段都是可以等待玩家输入进行蓄力的,只将第一个蓄力阶段拉出来特殊处理合理吗?
|
||||
|
||||
|
||||
|
||||
**[Dota2](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=Dota2&zhida_source=entity)里不就有前摇、施法和后摇这些阶段吗?**
|
||||
|
||||
是有的。在第三个项目,我提议干掉这些阶段时,就被客户端主程拒绝了,举的例子就是Dota2。
|
||||
|
||||
怎么说呢?我只能说Dota2的战斗体系还不够复杂,它和[DNF](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=DNF&zhida_source=entity)这类格斗游戏还是很有差距。
|
||||
|
||||
在第三个项目中,客户端的技能表现是基于前摇、施法、后摇这些事件进行的;说实话,我觉得一点都不好,远不如用编辑器画行为树([TaskTree](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=TaskTree&zhida_source=entity))来得清晰和可扩展。
|
||||
|
||||
|
||||
|
||||
**前后摇阶段的问题到底是什么?**
|
||||
|
||||
**流程划分得越细,就越固化,系统的灵活性和扩展性越差**。
|
||||
|
||||
上面的充能阶段就是典型,要想保持技能系统的灵活性,需要尽可能的减少对技能流程的约束。
|
||||
|
||||
|
||||
|
||||
**所以,代码里需要定义前摇、施法、后摇这些阶段吗?**
|
||||
|
||||
不需要,不建议!如果项目中已经定义了前后摇这些阶段,可以保留,但应尽可能减少逻辑层对这些阶段的依赖。
|
||||
|
||||
如果是新项目,我建议尽可能避免定义这些施法阶段;如果真的要定义,也要尽可能保持粗糙,有前摇、施法、后摇三个阶段就可以了,不要再去细分。
|
||||
|
||||
|
||||
|
||||
### 避免感知其它技能的执行阶段
|
||||
|
||||
部分项目有这样的需求:目标技能进入后摇时触发一个行为。
|
||||
|
||||
这其实是非常不好的需求,策划提出这样的需求,证明策划对战斗系统的认知还不到位,他不知道他这样的设计会导致什么样的后果。**前后摇的需求,只应该出现在表现层,而不应该出现在逻辑层,否则会严重影响系统的扩展性**。
|
||||
|
||||
|
||||
|
||||
为什么会导致扩展性问题?
|
||||
|
||||
每个技能都是一个脚本,只有尽可能减少对其它技能(脚本)执行过程的假设 —— 除非技能之间是深度绑定的,系统才有最佳的灵活性和扩展性。
|
||||
|
||||
|
||||
|
||||
那深度绑定的技能之间如何监听流程呢?
|
||||
|
||||
被监听的技能在执行特定的阶段时,抛出一个特定的事件即可。
|
||||
|
||||
---
|
||||
|
||||
### **关注真实需求**
|
||||
|
||||
程序通常会直接按照他听到的需求来实现功能,但这在战斗这种复杂系统的实现中是不行的。**程序需要关注策划需求中的真实需求,而不是表面需求**。
|
||||
|
||||
以前摇后摇这个问题为例,我们应当关注策划想设计这些阶段的目的是什么,比如:前摇时被打断不记CD、后摇时可施展其它技能等等,而不是关注策划的这些阶段。
|
||||
|
||||
|
||||
|
||||
以前摇时被打断不记CD这个需求为例,大家会怎么实现?
|
||||
|
||||
我想,很多程序员第一想法就是给技能划阶段,然后在技能退出的时候判断是否是前摇阶段,然后决定是否执行技能CD。
|
||||
|
||||
那假设策划现在出了一个新需求,只要抓取技能没有抓到敌人,就不触发CD —— DNF女柔道的空绞锤就是这样,那你又应该怎么办?
|
||||
|
||||
要想避免每次出现新需求时都去打补丁,正确的方式是让策划在编辑器(或脚本)中去配置触发CD的时机,想在什么时候触发就在什么时候触发 —— **控制反转。**
|
||||
|
||||

|
||||
|
||||
空绞锤未抓取敌人时,不触发CD
|
||||
|
||||
如果你说这样配置的话,策划工作量很大,不好。
|
||||
|
||||
那确实是,我们可以提供一个通用的前摇节点,当技能无特殊需求时,策划将这个节点挂上去就行 —— 而我们的代码里并不需要前摇后摇这些概念。
|
||||
|
||||
---
|
||||
|
||||
## **脚本化(流程抽象)**
|
||||
|
||||
**技能只有脚本化才有无限可能**。
|
||||
|
||||
我工作近5年的时候才意识到这一点,作者的前两个项目,都是比较重度的MMORPG项目,但技能和Buff的实现都不是脚本化的。
|
||||
|
||||
|
||||
|
||||
什么意思呢?
|
||||
|
||||
这两个项目,**都没有为整个流程建立一个抽象**。最典型的就是技能流程,技能的状态切换,都是在[SkillMgr](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=SkillMgr&zhida_source=entity)里实现的,而不是由技能自身管理的。即:技能的流程是高度固化的,SkillMgr对技能的流程约定太多。
|
||||
|
||||
|
||||
|
||||
脚本化是什么意思呢?是要引入脚本语言吗?
|
||||
|
||||
NO!脚本化是指我们应当尽可能将技能和Buff的流程看做一个黑盒,Manager只是简单的去驱动它们,就像这样:
|
||||
|
||||
```csharp
|
||||
// 作者框架下,由TaskTree实现
|
||||
public interface ISkillScript {
|
||||
void Update();
|
||||
void OnEvent(object evt);
|
||||
}
|
||||
// 技能模块就是简单地调用技能脚本的Update函数即可
|
||||
public void Update(GameObject gobj) {
|
||||
foreach (SkillContext ctx in gobj.skillComponent.castingSkills) {
|
||||
ctx.script.Update();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果是写过Unity的,可以将技能比作MonoBehavior,就明白什么意思了。
|
||||
|
||||
---
|
||||
|
||||
## **[FSM架构](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=FSM%E6%9E%B6%E6%9E%84&zhida_source=entity)**
|
||||
|
||||
技能需要实现为FSM架构,这可以从多个方面说明。
|
||||
|
||||
### **玩法需求角度**
|
||||
|
||||
虽然游戏中的多数技能都是时间轴施法,但如果所有的技能都是时间轴施法,那么游戏就会变得无趣。所以技能需要支持两件事:
|
||||
|
||||
1. 响应玩家的输入,根据玩家输入的不同产生不同的行为
|
||||
2. 根据环境的变化,主要指目标变化,产生不同的行为
|
||||
|
||||
环境数据也可以看做输入,而要支持根据输入的不同,跳转到不同的行为,就依赖于FSM架构。
|
||||
|
||||

|
||||
|
||||
PS:DNF的武神一觉技能,在风场阶段,按Z可立即踢出终结一击。
|
||||
|
||||
### **技能恢复(回滚)**
|
||||
|
||||
在网络游戏中,客户端和服务器需要同时演算,为了表现尽可能的好,客户端会做较多的预演,包括命中判定。
|
||||
|
||||
对于简单的伤害技能来说,客户端判定命中后,即可播放响应的特效和音效;但对于抓取和控制技能来说,客户端就不能使用本地的命中结果,必须等待服务器通知抓取结果,然后才能进入下一阶段。
|
||||
|
||||
这里就有一个问题:由于网络延迟的问题,客户端可能在动作结束后都没有收到服务器的响应,那客户端该怎么办呢?是保持这个抓取动作,还是恢复到Idle动作?
|
||||
|
||||
需要恢复到Idle动作,这样客户端的表现才足够流畅。但这要求,**客户端在收到服务器的协议的时候,能立即从抓取成功开始执行,即技能框架需要支持从任意阶段开始执行**。
|
||||
|
||||
也就是说,我们的技能脚本在启动时,需要根据技能的输入立即切换到指定阶段执行。从这个角度讲,也不建议技能在逻辑层划分前摇、施法、后摇这些阶段。
|
||||
|
||||
```csharp
|
||||
public class SkillScript1001 : Task<Blackboard> {
|
||||
public override void Enter() {
|
||||
SkillInput input = blackboard.Get(EffectKey.Input);
|
||||
if (input.stateId > 0) { // 服务器告知切换到哪个状态
|
||||
ChangeState(state2)
|
||||
} else {
|
||||
ChangeState(state1)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
如果网络延迟,可能在超过怪物身位后将怪物抓住
|
||||
|
||||
PS:DNF的女柔道,就巨受网络延迟的影响 —— 非常影响手感。
|
||||
|
||||
### **逻辑表现同步切换**
|
||||
|
||||
我在《[战斗系统:框架设计](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247484186%26idx%3D1%26sn%3Dfde958e5c26b7e7ba572eee0b64211c8%26scene%3D21%23wechat_redirect)》中提到:由于角色模型一次只能播放一个动作,要想表现层的状态机更容易编写,逻辑层涉及到模型控制相关的逻辑最好也在同一套状态机中。
|
||||
|
||||
当时是针对主状态机而言的,但对于单个技能来说同样适用:当一个技能由多个动作构成时,**动作切换最好伴随着逻辑切换**。而这些动作之间是平级的,那么对应的逻辑节点之间也最好是平级的,即FSM式的。
|
||||
|
||||

|
||||
|
||||
PS:逻辑切换时,动作不一定切换。
|
||||
|
||||
### **子技能问题**
|
||||
|
||||
我发现有些项目喜欢用子技能来解决问题,比如常见的普攻三连击,有些项目会将其实现为三个子技能。
|
||||
|
||||
看起来扩展性很好呢,是不是很美妙?
|
||||
|
||||
其实是个不好的设计。首先,事件可能需要测试当前施展的技能,引入子技能,这个问题就会变得麻烦;此外,如果项目想要设计技能修改器,子技能也会导致诸多的问题。
|
||||
|
||||
正确的方式就是实现FSM架构,**一个技能不论多复杂,最好都保持为一个脚本**(对象)。
|
||||
|
||||
---
|
||||
|
||||
## **逻辑驱动方式**
|
||||
|
||||
在逻辑和动画的驱动方式上,有两种:[动画驱动逻辑](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=%E5%8A%A8%E7%94%BB%E9%A9%B1%E5%8A%A8%E9%80%BB%E8%BE%91&zhida_source=entity)和[逻辑驱动动画](https://zhida.zhihu.com/search?content_id=255897514&content_type=Article&match_order=1&q=%E9%80%BB%E8%BE%91%E9%A9%B1%E5%8A%A8%E5%8A%A8%E7%94%BB&zhida_source=entity)。
|
||||
|
||||
### **动画驱动逻辑**
|
||||
|
||||
由逻辑启动动画的播放,但逻辑层不执行更新,而是由动画播放到打击点和结束的时候,通知逻辑层执行相关行为 —— 需要在动画上记录数据。
|
||||
|
||||
简单说:逻辑层启动动画后,**依靠动画的回调执行逻辑**。
|
||||
|
||||
```csharp
|
||||
public class Script : Task<Blackboard> {
|
||||
private int triggerCount; // 已触发次数
|
||||
|
||||
public override void Enter() {
|
||||
// 播放技能01动画,并将自身作为回调传入,这期间不进行Update
|
||||
gobj.animator.PlayAction(EAnimation.Skill_01, this)
|
||||
}
|
||||
public void OnEvent(AnimationEvent evt) {
|
||||
if (evt.type == EType.AniCompleted) {
|
||||
SetSuccess(); // 流程结束
|
||||
return;
|
||||
}
|
||||
// 动画事件触发行为
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **逻辑驱动动画**
|
||||
|
||||
同样由逻辑启动动画的播放,但逻辑层不关心动画的播放进度,按照逻辑层的时间点触发行为。
|
||||
|
||||
简单说:角色就是个动画播放器。
|
||||
|
||||
```csharp
|
||||
public class Script : Task<Blackboard> {
|
||||
private ScheduleCfg cfg; // // 效果调度配置
|
||||
private int triggerCount; // 已触发次数
|
||||
|
||||
public override void Enter() {
|
||||
// 播放技能01动画,单纯调起
|
||||
gobj.animator.PlayAction(EAnimation.Skill_01)
|
||||
}
|
||||
public override void Execute() {
|
||||
// 逻辑层管理触发时机
|
||||
State state = blackboard.Get(EffectKey.state);
|
||||
if (state.timeEscaped < cfg.enterTime) return;
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **两者有什么区别?**
|
||||
|
||||
如果说游戏运行很流畅,不卡顿也没有延迟,那么两者可能看不出区别;但如果渲染性能跟不上,就会有明显的差别。
|
||||
|
||||
假设渲染层本该是60帧 / 秒,但现在只有10帧 / 秒;那么在动画驱动逻辑的方案下,逻辑层的施法施法时间就会被拉长,但总是在动画播放到特定帧时执行相关逻辑;而如果是在逻辑驱动动画的方案下,表现层的时间就会被缩短,表现为技能动画可能才播了一点伤害都打完了。
|
||||
|
||||
也就是说,**在动画驱动逻辑的情况下,动画和逻辑总是同步的 —— 视觉体验好**;而在逻辑驱动动画的情况下,动画和逻辑的匹配是随缘的,但网络同步会更容易,更容易保证多客户端之间的一致性。
|
||||
|
||||
|
||||
|
||||
### **如何选择?**
|
||||
|
||||
如果是单机游戏,首选动画驱动逻辑;如果说经验不足,也可以选择逻辑驱动动画,在渲染压力不大的情况下,两者差异不大。
|
||||
|
||||
如果是网络游戏,但很重视打击感,如ACT和ARPG,也建议选择动画驱动逻辑,而且客户端需要预演,否则体验会很差;如果对打击感的要求不那么高,比如MMORPG,可以选择逻辑驱动动画。
|
||||
|
||||
---
|
||||
|
||||
## **特效的定位**
|
||||
|
||||
本篇不讲特效的实现和管理,作者想强调的是:**特效永远不应该参与到逻辑**。
|
||||
|
||||
为了追求打击感的精确性,我们可以让角色的动作来驱动逻辑,但万不可让特效来触发逻辑。特效、音效、着色器这些都应该作为纯粹的表现层,使得我们随时可以禁用和关闭它们。
|
||||
|
||||
|
||||
|
||||
如果想用“特效”触发逻辑怎么办?
|
||||
|
||||
以DNF的狂战士为例,击杀敌人时概率出现血球,血球会飞到角色身上,然后触发回血。这个血球看起来可能是个特效,但应该实现为GameObject(子弹),子弹击中角色时触发子弹效果。
|
||||
|
||||

|
||||
|
||||
## **技能组件的作用**
|
||||
|
||||
不论是新旧框架,技能组件的主要作用是一样的:技能数据中心。
|
||||
|
||||
只要是角色技能,都应该添加(注入)到技能组件;如果需要识别来源,可以在配置上标记。
|
||||
|
||||
```csharp
|
||||
public class SkillComponent {
|
||||
// 角色身上的所有技能
|
||||
public Dictionary<int, SkillData> skillDataDic;
|
||||
// 技能冷却信息
|
||||
public Dictionary<int, double> cdDic;
|
||||
}
|
||||
public class SkillData {
|
||||
public SkillCfg cfg;
|
||||
public SkillTaskCfg taskCfg; // 脚本配置,见前篇
|
||||
public List<double> values; // 技能属性,见前篇
|
||||
public int lv;
|
||||
public int Id => cfg.Id;
|
||||
public double Cd => values[0];
|
||||
}
|
||||
```
|
||||
|
||||
作者曾经的项目中,出现过这类需求:开启某个系统后,获得一个技能槽,可以装备该系统激活的技能。
|
||||
|
||||
作者在第二个项目的时候,似乎没有把这类技能加入到技能模块;但在第三个项目的时候,就是加入到了技能组件的。
|
||||
|
||||
|
||||
|
||||
**为什么要加入到技能组件?**
|
||||
|
||||
有几个点:
|
||||
|
||||
1. 解耦:**战斗系统尽可能关注更少的模块**
|
||||
2. 技能筛选:技能修改器等需要筛选技能,技能数据集中更容易实现
|
||||
3. 技能屏蔽和替换:可阅读《[角色框架:变身玩法实现](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247484162%26idx%3D1%26sn%3D2dc7289b8675e9b71748a47997d5e844%26scene%3D21%23wechat_redirect)》
|
||||
4. 统一技能施展流程:都通过技能模块发起,施展测试也通过技能模块进行
|
||||
5. 统一同步管理
|
||||
|
||||
### **施展技能**
|
||||
|
||||
在新的“一切皆状态”框架下,施展技能的流程是比较简单的,就是根据SkillData创建对应的State,然后挂载到GameObject上即可。
|
||||
|
||||
```csharp
|
||||
// 施展技能
|
||||
public void CastSkill(GameObject gobj, SkillData skillData, SkillInput input) {
|
||||
// 派发施展技能事件
|
||||
BeforeCastSkill(GameObject gobj, SkillData skillData);
|
||||
// 创建State,然后覆盖默认由等级算出的数值
|
||||
State state = StateUtil.CreateState(skillData.Id, skillData.lv);
|
||||
state.values.Clear();
|
||||
state.values.AddRange(skillData.values);
|
||||
|
||||
state.lv = skillData.lv;
|
||||
state.input = input;
|
||||
// 添加状态 -- 启动技能
|
||||
stateMgr.AddState(gobj, state);
|
||||
}
|
||||
```
|
||||
|
||||
## **结语**
|
||||
|
||||
本篇主要讲的是一些高层设计,关于细节的实现,以后讲一部分。不过,作者最近有很多代码要写,文章的更新频率可能会放缓。
|
||||
@@ -1,355 +0,0 @@
|
||||
战斗系统,在大型游戏中,基本就是最复杂的系统;在项目中,通常由专门的战斗策划和战斗程序负责。相信读者中也有维护过战斗系统的,我想问几个问题:
|
||||
|
||||
1. 有站在技能和Buff的更高层审视过你们的战斗系统吗?
|
||||
|
||||
2. 项目的战斗系统有明确的架构吗?
|
||||
|
||||
3. 项目的战斗系统有缺陷吗?知道问题的成因吗?
|
||||
|
||||
4. 能实现你玩过的游戏中的所有技能和Buff效果吗?
|
||||
|
||||
|
||||
我不知道大家的回答如何,但如果让刚离开第一个项目时候的我来回答这些问题,我几乎需要全部答NO...
|
||||
|
||||
我后来发现,其实不仅我是这样,有大量的开发者都对战斗系统缺少系统性的认知和思考,即使当前项目的战斗框架是他实现的。为什么呢?因为他不是最初的设计者,而只是搬运工,他并不真正了解他手头的工具。
|
||||
|
||||
其实,对战斗系统有系统性的认知非常重要,有许多设计问题,在我们只盯着部分模块的时候是发现不了的 —— 你只会觉得很难受,但不知道为什么。
|
||||
|
||||
本篇就带着大家看一下常见的战斗框架设计,以及我的心血之作,相信你定有所收获。
|
||||
|
||||
---
|
||||
|
||||
1.独立抽象(反面教材)
|
||||
|
||||

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

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

|
||||
|
||||
虽然被动和Buff具有较大的差异,但仔细思考可以发现:被动技能可以是Buff的子集,被动技能的那些特性,Buff也是可以拥有的;而且用Buff支持被动技能,并不需要太大的改动量 —— 主要处理对站街面板的加成等。
|
||||
|
||||
将被动技能实现为Buff后,不仅消除了被动技能和Buff之间的重复感,也扩展了Buff系统。
|
||||
|
||||
该架构比较符合大家认知,采用率较高。不过,采用该架构的项目并不全是从前一种架构演进来的,也有一开始就用Buff实现被动技能的 —— 这是明智的;如果是从前一种架构演进来的,想必已有一点体会:过于明确的抽象,对战斗系统是有害的。
|
||||
|
||||
PS:关于抽象带来的扩展性障碍,阅读《[角色框架:GameObject类型实现](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247484167&idx=1&sn=08876da72c7042b8031d96b815ad4e20&scene=21#wechat_redirect)》可能会有所帮助。
|
||||
|
||||
---
|
||||
|
||||
3.被动技能后台施展
|
||||
|
||||

|
||||
|
||||
被动技能和Buff合并的最大障碍是数据依赖不同,被动技能效果依赖的是SkillData,而Buff效果依赖的是BuffData。
|
||||
|
||||
既然被动技能也依赖SkillData,那么是不是可以让被动技能也走主动技能的流程呢?即被动技能挂载后,按主动技能流程自动施展,但不占用角色主状态机。
|
||||
|
||||
```
|
||||
public class SkillComponent {
|
||||
```
|
||||
|
||||
PS:当年在第一个项目的时候,我就没想到这么做 —— 还是能力不足。
|
||||
|
||||
将被动和主动一视同仁,意味着支持多技能同时施展,有以下优点:
|
||||
|
||||
1. 支持被动技能转主动技能施展,被动技能在满足条件的情况下可以手动施展
|
||||
|
||||
2. 支持主动技能间断性施法,eg:技能A第一击,技能B第一击,技能A第二击...
|
||||
|
||||
|
||||
后台技能 VS Buff
|
||||
|
||||
将被动技能看做主动技能,更符合代码层抽象 —— 程序员们会更容易理解。
|
||||
|
||||
但将被动技能看做Buff,更符合业务层的抽象 —— 策划们会更容易理解;此外,用Buff实现被动,还避免了效果交叠带来的重复代码。
|
||||
|
||||
思考题
|
||||
|
||||
被动技能和主动技能有共同点,又和Buff有共同点,那是不是说:主动技能和Buff有共同点?
|
||||
|
||||
---
|
||||
|
||||
4.一切皆Buff
|
||||
|
||||
' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
|
||||
|
||||
在市面上,还存在一种架构:一切皆Buff。
|
||||
|
||||
从字面上看,我的理解是:主动技能在施展的时候,也是给自己添加一个Buff,然后通过Buff来实现所有的功能。
|
||||
|
||||
我相信,肯定有读者在看见这句话的时候心生抵触!被动技能可以说是Buff,但主动技能怎么能是Buff呢?
|
||||
|
||||
是啊!不仅仅是读者觉得主动技能和Buff不一样,俺也一样,甚至那些说着“一切皆Buff”的团队也认为不一样!
|
||||
|
||||
所以,他们最终的实现并不是我理解的那样。根据职责转移的粒度,分两种情况:
|
||||
|
||||
1. 技能流程由Buff构成
|
||||
|
||||
2. 技能效果皆是Buff
|
||||
|
||||
|
||||
---
|
||||
|
||||
4.1 技能流程由Buff构成
|
||||
|
||||

|
||||
|
||||
在技能设计中,技能被分为多个阶段,每个阶段由一组SkillAction构成;SkillAction中包含了打击点、触发间隔、攻击包围盒等信息;而在Buff中,同样存在这些配置。
|
||||
|
||||
因此可以将技能的SkillAction看做Buff,这样技能便可以看做是Buff的集合,在特定的阶段挂载特定的Buff即可。
|
||||
|
||||
```
|
||||
// 技能执行上下文
|
||||
```
|
||||
|
||||
主要缺陷(交互)
|
||||
|
||||
并非所有的技能都是简单的时间线施法,因此技能框架仍然需要是FSM式的;而由于SkillAction的消失,Skill成为空壳,因此Buff需要负责技能的阶段切换;所以,Buff系统和技能系统存在复杂的交互逻辑。
|
||||
|
||||
此外,将SkillAction委托给Buff,概念上接受起来还是有难度。
|
||||
|
||||
4.2 技能效果皆是Buff
|
||||
|
||||
技能加Buff是很常见的效果,那么造成伤害、治疗这些瞬间的行为为什么不能通过Buff(绕一下)实现呢?
|
||||
|
||||
所以,这里又有另一种实现:技能管理流程,Buff执行效果;即技能执行到打击点时,总是通过给目标挂载Buff来实现效果。
|
||||
|
||||
```
|
||||
public class SkillAction {
|
||||
```
|
||||
|
||||
不过,由于从技能转移过来的效果大多是瞬时Buff,为了避免不必要的同步,BuffMgr需要屏蔽这些Buff的同步。
|
||||
|
||||
主要缺陷(Buff量)
|
||||
|
||||
用Buff来执行技能效果,其实是将Buff作为了适配器,适配是有代价的。
|
||||
|
||||
1. 造成伤害、治疗等效果的体量非常大,全部配置在Buff表会导致非常大的Buff量。
|
||||
|
||||
2. 技能效果的上下文变得更复杂,会增加效果的实现难度
|
||||
|
||||
3. Buff的增删频率极大,Buff需要池化
|
||||
|
||||
|
||||
可惜,将主动技能真正实现为的Buff,是更正确的方案 —— 终究是我们先入为主的观念阻碍了我们。
|
||||
|
||||
---
|
||||
|
||||
5.返璞归真,一切皆状态(State)
|
||||
|
||||
' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
|
||||
|
||||
在第三个项目,在经过较长时间的探索后,我成功将第一个项目的战斗架构演进为这套“一切皆状态”的架构。
|
||||
|
||||
它何以称得上返璞归真?继续阅读,你便知晓。
|
||||
|
||||
状态组件
|
||||
|
||||
```
|
||||
public class StateComponent {
|
||||
```
|
||||
|
||||
状态组件可以和传统架构的Buff组件类比,它存储着角色身上的所有状态和状态槽。
|
||||
|
||||
状态
|
||||
|
||||
```
|
||||
public class State {
|
||||
```
|
||||
|
||||
什么是状态?
|
||||
|
||||
状态在业务层面的含义,读者可以自行理解。我想告诉大家的是:状态即脚本!只有将状态看做脚本,你才能理解这套设计的强大。
|
||||
|
||||
现在,不论是主动技能、被动技能,还是Buff,都变得很简单,就是给GameObject添加一个状态,也就是给GameObject挂一个脚本,视野是不是一下就打开了?
|
||||
|
||||
这像不像我们在Unity下通过MonoBehavior扩展GameObject的行为?所以,在业务层面,State就是GameObject的行为组件。
|
||||
|
||||
状态脚本的实现
|
||||
|
||||
状态的最终逻辑由行为树(任务树)承担,可阅读《[行为树:事件驱动版实现](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247483909&idx=1&sn=c5e746e8bd1e01da5e4d12ec4dae3b4d&scene=21#wechat_redirect)》《[行为树:返璞归真TaskTree](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247483940&idx=1&sn=91cafb326ee1c6c4b528e1fc9898d9d6&scene=21#wechat_redirect)》。
|
||||
|
||||
行为树并不是唯一选择,但是是很好的选择;如果不选择行为树,只需要提供一个类似的脚本抽象即可,核心抽象方法就两个:
|
||||
|
||||
```
|
||||
public abstract class Task<Blackboard> {
|
||||
```
|
||||
|
||||
为什么是“状态”,而不是“Buff”或其它?
|
||||
|
||||
回答几个问题即可:
|
||||
|
||||
1. 跳跃是不是Buff?死亡是不是Buff?施展技能是不是Buff?
|
||||
|
||||
2. 跳跃是不是状态?死亡是不是状态?施展技能是不是状态?流血是不是状态?冰冻是不是状态?一切持续性的效果是不是都可以定义为状态?
|
||||
|
||||
3. Buff是不是状态的子集?(Buff是角色获得的增益或减益状态)
|
||||
|
||||
|
||||
状态带来的改变
|
||||
|
||||
前面的框架有一个公共问题:主状态机的状态和技能、Buff的互斥不容易处理。
|
||||
|
||||
在前面的框架下,处理死亡等逻辑时通常都较为麻烦,通常需要编写特殊的代码来处理。这其实是因为主状态、技能和Buff不属于同一概念,不同概念下的事物无法进行比较,也就无法简单处理互斥;而将一切都纳入状态后,我们便可以通过统一的互斥规则代替这些特殊的互斥代码。
|
||||
|
||||
主动技能真的也可以吗?
|
||||
|
||||
被动技能和Buff的处理,想必大家不会有太多疑问,但当前施展的主动技能需要支持外部查询,该怎么处理呢?
|
||||
|
||||
需要“状态发布”,即状态在启动时将自己发布到关联的组件。至于状态发布的方式,我们在后面的篇章详细讨论。
|
||||
|
||||
```
|
||||
public class SkillComponent {
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
状态槽
|
||||
|
||||
```
|
||||
public class StateSlot {
|
||||
```
|
||||
|
||||
静态槽和动态槽
|
||||
|
||||
静态槽表示预设的状态槽,在创建GameObject时即分配,动态槽则在运行的时候根据需要创建。
|
||||
|
||||
什么是状态槽?
|
||||
|
||||
我们可以通过电脑的进程和网络端口来辅助理解,先看图:
|
||||
|
||||

|
||||
|
||||
状态必须先绑定到状态槽才可以执行,好比网络应用需要先绑定网络端口;当多个状态需要绑定在同一个状态槽时,将发生状态槽冲突,将挤掉既有的状态。
|
||||
|
||||
一些状态只能挂载在指定槽上,如:跳跃、死亡、冰冻等只能挂载在1号槽(静态槽),也就是说,我们的1号槽充当了前面架构中的主状态机。因此,我们也可以通过1号槽查询角色的主状态。
|
||||
|
||||
所以状态槽就是个资源句柄,有两个作用:
|
||||
|
||||
1. 实现状态的互斥
|
||||
|
||||
2. 提供额外的查询
|
||||
|
||||
|
||||
---
|
||||
|
||||
演进过程
|
||||
|
||||
虽然我在上面举了Unity的MonoBehavior例子,但该框架并不是直接通过组件模式演进来的,而是慢慢演进为组件模式的 -- 过程曲折。我在重新实现战斗框架的时候,着重关注三件事:
|
||||
|
||||
1. 将行为树引入技能和Buff系统,见《[行为树:序](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247483881&idx=1&sn=aa7a90d8cccc72e071855de64432f35d&scene=21#wechat_redirect)》
|
||||
|
||||
2. 如何消除主动技能、Buff、被动技能效果交叠带来的重复感
|
||||
|
||||
3. 如何实现技能修改器
|
||||
|
||||
|
||||
将行为树引入技能和Buff系统,由于行为树的实现错误,这件事花了很长时间才完成;在这之前,我为了消除重复感,尝试过一个方案:为主动技能、被动技能、Buff提取一个公共的效果(Effect)抽象。
|
||||
|
||||

|
||||
|
||||
为了尽可能保证接口的通用性,接口参数仅一个黑板,如下:
|
||||
|
||||
```
|
||||
public interface IEffect {
|
||||
```
|
||||
|
||||
但在实现具体效果的过程中发现,由于技能执行上下文(SkillContext)和Buff是不同的抽象类型,导致Effect还是需要独立实现 -- 需要从不同的类型上读取数据。
|
||||
|
||||
这个问题困扰了我好久,由于我坚持认为技能和Buff是不同的东西,因而无法合并它们的抽象;但随着行为树和黑板在项目中的应用越来越多,范围越来越广,我增加了些体悟,如:
|
||||
|
||||
1. 要想提升系统的灵活性,应尽量使用黑板代替明确的数据结构定义 —— 关注业务,而不是Class和Interface
|
||||
|
||||
2. 在使用黑板的时候,应尽可能直接将数据set到黑板 —— 即依赖注入,避免反向依赖
|
||||
|
||||
|
||||
在这一系列思想的影响下,我发现技能和Buff也不是不能合并了;在偶然的灵感之下,我决定使用状态(State)来表达合并后的抽象。
|
||||
|
||||
在确定使用状态这个概念后,我便联想到了跳跃和死亡等主状态机上的状态,我发现它们也可以纳入到新的状态管理中。为方便管理主状态的互斥以及查询,于是增加了状态槽(StateSlot)。
|
||||
|
||||
在获得最终的结构后,我发现了两点不同:
|
||||
|
||||
1. 我能证明设计的正确性!不论是拿计算机下的进程来举例,还是拿组件模式来举例,都能证明它的正确性;而在以前的项目中,我都没有这个感觉,只是觉着应该是这样的。
|
||||
|
||||
2. 我看待GameObject的视角被拉高了。在之前,我设计技能和Buff时,就只是盯着技能和Buff模块看,而现在是盯着整个GameObject在设计 —— 所以结果必然更正确。
|
||||
|
||||
|
||||
“证明设计的正确性”是我在该项目获得的重要习惯,这为后面的技能修改器设计起到了极大的作用。
|
||||
|
||||
---
|
||||
|
||||
结语
|
||||
|
||||
“一切皆状态”这套架构有极强的扩展性和灵活性,在我看来,基本不会有更好的抽象了。
|
||||
|
||||
读者要想掌握它,关键在于打破通过玩游戏获得的先入为主的观念 —— 不要从玩家的角度去设计战斗系统。
|
||||
@@ -1,260 +0,0 @@
|
||||
|
||||
阅读提醒:
|
||||
|
||||
1. 本篇虽然总是以技能为例,但在一切皆状态的架构下,所有的状态效果配置都是相同的。
|
||||
|
||||
2. 关于表格设计,可阅读《[配置:表格设计](https://mp.weixin.qq.com/s?__biz=Mzk0MjcwNTY4MA==&mid=2247483826&idx=1&sn=2d3cc9ac988cb20e60fb8fca1405bf52&scene=21#wechat_redirect)》
|
||||
|
||||
|
||||
---
|
||||
|
||||
## **Excel之殇**
|
||||
|
||||
作者的第一个项目和第二个项目,都是使用Excel来配置技能的,那复杂度简直爆炸 —— 好几张表,每张表又是几十个字段。回过头看,我们的战斗策划的工作真是太艰难了,策划里面加班最多的,就是数值策划和战斗策划,可是真的需要那么加班吗?
|
||||
|
||||
我在《[配置:吐槽时间](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247483826%26idx%3D2%26sn%3Dd4aacd587f734bdd25d1d82a220f7a60%26scene%3D21%23wechat_redirect)》中提到:**因为程序不配表,体会不到策划配表的痛苦,因此缺少反思**。程序很少去思考自己设计的正确性,或是想着能用就行,不考虑团队其它成员的工作效率,这对整个项目来说是非常有害的。
|
||||
|
||||
程序,就是用来解放生产力的。如果你的程序不仅没有减少他人的工作量,反而增加他人工作量,那这个程序就是不合格的。
|
||||
|
||||
## **[状态总表](https://zhida.zhihu.com/search?content_id=255785060&content_type=Article&match_order=1&q=%E7%8A%B6%E6%80%81%E6%80%BB%E8%A1%A8&zhida_source=entity)**
|
||||
|
||||
我在前篇《[战斗系统:什么是数据驱动?](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247484217%26idx%3D1%26sn%3D8fe2c017ab84501915d99ec79c3371c3%26scene%3D21%23wechat_redirect)》中提到,对于技能这种复杂的数据,应该舍弃Excel,使用Json或Lua等来配置。那我们是否还需要一张状态总表?
|
||||
|
||||
我的建议是需要,它有几个作用:
|
||||
|
||||
1. 状态总览:我们可以知晓一共有哪些状态,以及状态的一些基础数据
|
||||
2. 有效性控制:只有在表格中存在的状态才是有效的,而不是所有扫描到的配置都有效
|
||||
3. 简化配置:虽然行为相关的参数不适合配置在Excel,但其它参数配置在表格更加方便
|
||||
|
||||

|
||||
|
||||
PS:关于状态表的字段设计,可阅读《[战斗系统:状态管理](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247484197%26idx%3D1%26sn%3D6e069ad2972f1222cf96eaa118116c37%26scene%3D21%23wechat_redirect)》。
|
||||
|
||||
## **[技能总表](https://zhida.zhihu.com/search?content_id=255785060&content_type=Article&match_order=1&q=%E6%8A%80%E8%83%BD%E6%80%BB%E8%A1%A8&zhida_source=entity)**
|
||||
|
||||
同理,技能我们也需要一张总表,用于控制技能的有效性。不过,**技能表中的ID必须在状态表中存在**,这有几个作用:
|
||||
|
||||
1. 可通过技能ID查询关联的State配置 —— 约定大于配置
|
||||
2. _脚本中不需要区分状态ID和技能ID_,可以避免诸多麻烦
|
||||
|
||||

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

|
||||
|
||||
最终加了多少力量,在技能界面是看不见的,是动态计算的。
|
||||
|
||||
PS:DNF的武神玩家都是多动症玩家...
|
||||
|
||||
|
||||
|
||||
**施展条件**
|
||||
|
||||
有些项目是将技能的施展条件放在了技能表中的,比如我以前的项目;施展条件确实不属于运行时数据,但条件是存在组合和分层的,用Excel很难表达(很不直观)。有几种方式:
|
||||
|
||||
1. 手写Json(或其它格式)
|
||||
2. 为Excel制作编辑器,可视化编辑Json
|
||||
3. 转移到数据脚本,通过编辑器编辑
|
||||
|
||||
为Excel做编辑器需要巨大的工作量才能做得好,但做好了收益巨大;不过,对于中小团队而言,教会策划写Json更具性价比。
|
||||
|
||||
在有通用数据编辑器的情况下,将这部分配置转移编辑器也是个方案;但数据一部分在表格,一部分在编辑器导出的Json文本,可能会带来一些不便。
|
||||
|
||||
不过,由于技能的执行数据是在编辑器中配置的,所以将施展条件等转移到编辑器不会有额外的副作用,所以很合适。
|
||||
|
||||
---
|
||||
|
||||
## **数值成长问题**
|
||||
|
||||
在我将[行为树](https://zhida.zhihu.com/search?content_id=255785060&content_type=Article&match_order=1&q=%E8%A1%8C%E4%B8%BA%E6%A0%91&zhida_source=entity)引入到战斗系统时,技能的所有参数都直接配置在了行为树的节点上,这面临着几个问题:
|
||||
|
||||
1. 角色技能是支持升级的,升级以后数值会变化,总不能让策划每一级画一棵树吧?
|
||||
2. 参数散落在行为树的节点上,客户的技能Tips该怎么做?
|
||||
3. 表现层和逻辑层的逻辑可能不同,客户端和服务器可能使用两棵树,但部分参数是相同的。
|
||||
|
||||
其实第一个问题还好,因为技能升级,其数值通常都是按公式计算的,所以我们可以直接在编辑器中支持公式,就像这样:
|
||||
|
||||
```csharp
|
||||
public class Value {
|
||||
public double value;
|
||||
public int deltaLv; // 每多少级执行一次成长
|
||||
public double k;
|
||||
public double b;
|
||||
public double min;
|
||||
public double max;
|
||||
}
|
||||
```
|
||||
|
||||
PS:这里仅以线性成长函数为例。
|
||||
|
||||
至于客户端技能Tips,一种常见的方式是:策划将格式文本(Fromat)和数值分离,并冗余定义一套变量,由程序根据公式计算每一级的数值,然后再根据Format计算最终的展示文本 —— 使用name占位符。
|
||||
|
||||
这种方案虽然能解决问题,但我向来对**增加策划工作量的设计**表示否定,因为手工维护的东西(冗余数据)越多,工作效率越低,出错的概率也越高;而程序处理问题,通常是一次性的,对就是对,错就是错;如果担心运行时的性能,可以通过开发期的工具解决。
|
||||
|
||||
第三个问题,自然也可以通过冗余配置来解决,但这也是会增加策划工作量的,所以不好。
|
||||
|
||||
正确的方案是:**将变量定义在行为树外部**(集中定义),行为树中的节点通过name引用关联的字段。就像这样:
|
||||
|
||||

|
||||
|
||||
数据结构定义如下:
|
||||
|
||||
```csharp
|
||||
// 状态变量:和上面的Value结构类似,只是多了name
|
||||
public class StateVar {
|
||||
public string name; // 变量名
|
||||
public double value; // value = k * lv +b
|
||||
// ...
|
||||
}
|
||||
// 状态脚本配置--运行时会缓存在State对象上
|
||||
public class StateTaskCfg {
|
||||
// 状态变量
|
||||
public List<StateVar> stateVars;
|
||||
// 技能施展条件
|
||||
public ICondition castCond;
|
||||
// 服务器脚本
|
||||
public Task<Blackboard> stask;
|
||||
// 客户端脚本
|
||||
public Task<Blackboard> ctask;
|
||||
}
|
||||
// 变量引用
|
||||
public class VarRef {
|
||||
public string name; // 变量名,非空表引用
|
||||
public double value; // 常量值
|
||||
public int index = -1; // 查询缓存
|
||||
}
|
||||
```
|
||||
|
||||
简单文本示例:
|
||||
|
||||
```as3
|
||||
// 技能配置 - Dson文本格式
|
||||
{@{StateTaskCfg}
|
||||
skillId: 1001,
|
||||
skillVars: [
|
||||
{name: attack_01, value: 1.5, min: 1.5, max: 3},
|
||||
{name: attack_02, value: 1.2, min: 1.2, max: 2},
|
||||
],
|
||||
castCond: {},
|
||||
stask: {},
|
||||
ctask: {}
|
||||
}
|
||||
// 伤害节点示例:攻击力系数为变量,元素类型为固定值
|
||||
{@{DamageAction}
|
||||
attack: {name: attack_01},
|
||||
element: {value: 1}
|
||||
}
|
||||
```
|
||||
|
||||
我们将技能、Buff中的**所有数值都定义为VarRef类型** —— 包括触发间隔、触发次数、技能CD等。如果变量名不为空字符串,则表示变量引用,否则表示常量值。
|
||||
|
||||
### **定制等级属性**
|
||||
|
||||
首先,数值的成长最好是可预测的,这对玩家来说更容易理解,所以并不建议如此做。
|
||||
|
||||
如果真的有这种需求,是可以支持的,我们只需要将成长公式分等级段即可。
|
||||
|
||||
```csharp
|
||||
public class Value {
|
||||
// 成长公式配置
|
||||
List<ValueUpgradeCfg> upgradeCfgs;
|
||||
}
|
||||
public class ValueUpgradeCfg {
|
||||
public int minLv; // 公式适用等级
|
||||
public int maxLv;
|
||||
|
||||
public double value; // 该段的初始值
|
||||
public int deltaLv; // 每多少级执行一次成长
|
||||
public double k;
|
||||
public double b;
|
||||
public double min;
|
||||
public double max;
|
||||
}
|
||||
```
|
||||
|
||||
## 数值传递问题
|
||||
|
||||

|
||||
|
||||
技能加Buff是非常常见的需求,如果需要在技能的Tips界面,看见最终的Buff数值,如持续时间,属性加成等,应该怎么做?
|
||||
|
||||
最直接的办法,就是**保持两个脚本具有相同的变量** —— name和成长公式相同。这样,只要技能等级和Buff等级一样,那么算出的数值也就一样,也就不需要传递了。
|
||||
|
||||
另一种方案是运行时解决,**允许指定状态的变量值**,即覆盖变量,代码如下:
|
||||
|
||||
```csharp
|
||||
public sealed class State {
|
||||
public StateCfg cfg;
|
||||
public int lv; // 状态等级
|
||||
public StateTaskCfg taskCfg; // 变量配置在这里
|
||||
public List<double> values; // 最终值
|
||||
// ...
|
||||
// 查询变量值
|
||||
public double GetValue(VarRef varRef) {
|
||||
if (string.IsEmptyOrWhitespace(varRef.name)) {
|
||||
return varRef.value; // 常量
|
||||
}
|
||||
if (varRef.index < 0) { // 索引缓存
|
||||
varRef.index = taskCfg.GetIndex(varRef.name);
|
||||
}
|
||||
return values[varRef.index];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
将状态由等级计算的数值全部缓存下来,然后允许外部修改。
|
||||
|
||||
**那怎么选呢?**
|
||||
|
||||
自然是全都要。**技能的数值并不完全由等级计算,还会受到技能修改器影响**,因此保持公式一致并不能完全保证数值的一致性,所有后者是必须的。
|
||||
|
||||
而前者也是必须的,因为Buff并非总是由主动技能添加,也可能由装备的被动技能触发,我们需要保持在无修改器的情况下,等级相同时属性一致。如果担心冗余配置出错,可以通过[数据检查工具](https://zhida.zhihu.com/search?content_id=255785060&content_type=Article&match_order=1&q=%E6%95%B0%E6%8D%AE%E6%A3%80%E6%9F%A5%E5%B7%A5%E5%85%B7&zhida_source=entity)来校验 —— 数据检查工具是不可或缺的。
|
||||
|
||||
---
|
||||
|
||||
## **舍弃子弹表**
|
||||
|
||||
我们通常有一个GameObject的总表,配置着逻辑层用到的所有GameObject;然后,为了减少总表的列数,部分项目会将子类型才拥有的字段会拆分到子表中,子弹表就是比较常见的。
|
||||
|
||||
游戏中的子弹有许多特殊的职责 —— 这个我们后面的文章讲,因此子弹表通常有非常多的字段,而且主要是跟逻辑相关的。
|
||||
|
||||
在一切皆状态的框架下,子弹表是可以完全省略的,并不需要额外的配置;但如果大家使用的自己的框架,我建议将子弹的配置改为Json或Lua,也采用数据驱动的方式配置,这可以大幅提供系统的可维护性和可扩展性。
|
||||
|
||||
---
|
||||
|
||||
## **选择一种好的文本格式**
|
||||
|
||||
最后,我们应当选择一种好的文本格式来记录数据,它需要满足以下基本条件:
|
||||
|
||||
1. 能精确表达数据
|
||||
2. 易于阅读和手工编写
|
||||
3. 易于维护(修改)
|
||||
4. 支持注释
|
||||
5. 良好的格式
|
||||
|
||||
作者在处理序列化问题的时候,设计了自己的数据文本格式《[Dson文本格式](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s%3F__biz%3DMzk0MjcwNTY4MA%3D%3D%26mid%3D2247483975%26idx%3D1%26sn%3D9f20abc69e456cbea798be54ca227135%26scene%3D21%23wechat_redirect)》,有兴趣的读者可以看一看。
|
||||
|
||||
---
|
||||
|
||||
## **结语**
|
||||
|
||||
除了技能和Buff外,其实我们的副本流程这些也需要脚本化,其表格和数据脚本的设计,规则是相似的。
|
||||
@@ -1,81 +0,0 @@
|
||||
|
||||
|
||||
#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
|
||||
@@ -1 +0,0 @@
|
||||
https://www.bilibili.com/list/watchlater?oid=114522652147889&bvid=BV1HKJAzyEZ5&spm_id_from=333.1007.top_right_bar_window_view_later.content.click
|
||||
@@ -1,378 +0,0 @@
|
||||
更新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 猎天使魔女
|
||||
|
||||
当然这么分很不严谨, 这是我的认知
|
||||
|
||||
整体的导图如下:
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
我在最后会把思维导图的下载链接发出来,方便大家浏览.如果你对战斗系统很熟悉,可以直接跳过文章直接看思维导图会更方便和直观.更多的描述用于新接触战斗的萌新方便上手
|
||||
|
||||
|
||||
|
||||
我们分为几个大类描述:
|
||||
|
||||
- 可配置性 - 可配置性决定了这东西做出来是否能用, 如何配置一个怪物,配置一个角色, 配置一个boss
|
||||
|
||||
- 动力学和动画模块分开 - 这样处理能避免大部分的奇怪问题.并且在结构上更为方便维护
|
||||
|
||||
- 状态管理 - 状态管理对玩家的输入如何处理, 前摇中段后摇 如何处理, 这里我开始漏掉了,我朋友告诉我应该谢谢状态管理, 所以文章完成后扭头来写这个的时候其实我已经没劲了,最后写的实在是写的很崩溃,因为内容太多,每个地方都要想半天当时怎么做的,什么思路,还要对照代码, 如果这里有不明确的地方希望有兴趣的童靴给我留言, 这里我补充一点,unity的状态机不是很好用,如果有工程能力的同学,建议自己手动管理,仅仅用animation controller的混合树就好了,我就是这么做的
|
||||
|
||||
- 动作的细节处理 - 这是一些经验之谈
|
||||
|
||||
- IK的简单描述- 其实IK的应用就那么几个点,具体的功能点就自己摸索摸索吧
|
||||
|
||||
- 物理和动画的互相作用 - 这里是进一步提升的方式, 但是我并没有做物理相关的东西
|
||||
|
||||
- 黑科技 - 如果你想把3C往更深了做 这些方向可以考虑
|
||||
|
||||
|
||||
## 可配置性1 - 角色模板
|
||||
|
||||
可配置性是一个框架是否可用的标准
|
||||
|
||||
所以我们放在第一个讨论, 这里关系到你的工程能力, 一般第一次做重构个3-4次太正常了
|
||||
|
||||
所以大家放心大胆的开发,后面多重构就好了,不用考虑一次性开发一个完美的系统.
|
||||
|
||||
如何考虑可配置性呢? 我们需要从业务出发,抽象出我们需要的类型
|
||||
|
||||
1 角色 - 怪物和玩家都属于角色的一种
|
||||
|
||||
2 角色需要动作 - 动作让角色动起来, 每个不同类型的角色可能需要不同的动画, 但是角色之间有没有共性?
|
||||
|
||||
3 如何播放? 事件和角色的关系, 这里还要考虑AI , 其他玩家操作输入,网络等
|
||||
|
||||
让我们进一步抽象
|
||||
|
||||
有没有可能有几种角色,长相差距很大,但是动作是类似的? 例如拿刀的骷髅兵 和 拿剑的人类士兵, 同时需要攻击动作,和 idle , 和 走跑
|
||||
|
||||
可能骷髅兵的攻击只有两下, 人类士兵会多一个跳劈
|
||||
|
||||
并且我们支持非空判断,如果特殊攻击时空,就无法触发
|
||||
|
||||
那么从动作组上我们这样分类这三个单位(骷髅兵 - 弓箭兵 - 人类士兵)
|
||||
|
||||
- 角色A - 关联动作(攻击, idle - 移动 - 特殊攻击)
|
||||
|
||||
- 角色B - 关联动作(远程攻击 , 移动)
|
||||
|
||||
|
||||
再此之外,还要考虑什么事件可以触发,例如攻击就是攻击事件触发
|
||||
|
||||
可能特殊攻击需要一个 扳机状态 + 攻击触发,
|
||||
|
||||
那么基本上大思路就出来了,
|
||||
|
||||
我们需要一个模板A 角色模板,支持配置模型 动作组, 以及一些参数
|
||||
|
||||
还有每个动作相关联的事件, 这样的东西有较高的配置性.
|
||||
|
||||
我做了如下模板:
|
||||
|
||||

|
||||
|
||||
当然,你可以根据你的业务需求调整,
|
||||
|
||||
下面的bing action setting 是用来绑定对应事件的
|
||||
|
||||
红色的代表该bing action 有问题
|
||||
|
||||
左边是状态, 右边是对应事件, none代表没有状态
|
||||
|
||||
这样,我们的角色模板就创建好了
|
||||
|
||||
**可配置性2 - 动画组**
|
||||
|
||||
然而关联对应的动画组, 我们也需要考虑角色的动画有那些类型
|
||||
|
||||
例如最简单的 idle - 走 - 跑 - 攻击 - 特殊攻击 这些动画背后有关联着那些内容呢?
|
||||
|
||||
idle - 走 - 跑 这三个被称为**locomotion** 是一个角色的基础动作组
|
||||
|
||||
我这里采用的是一个动画组支持多个locomotion, 放置一个角色有多重基础动作
|
||||
|
||||
例如一个角色有锤子, 和 举起锤子两个状态 , 举起锤子的时候可以移动, 那么locomotion里的移动动作必须全部换新的, 并且这个角色还可以切换武器 - 太刀, 那么所有的动画片又被替换了
|
||||
|
||||
所以最后动画组是这样的:
|
||||
|
||||
上面是一个基础的locomotion, 下面是对应的 其他locomotion
|
||||
|
||||
再往下是具体的动画业务, 这里最好的状态是下面的动画根据上面locomotion的切换也有一定的可编辑性,但是我当时并没有做.
|
||||
|
||||
动画片的意思是例如攻击, [idle - 走 - 跑] , 翻滚 , 这些动画的类型如何处理
|
||||
|
||||
我们当然可以直接播放动画片,但是一个动画片包含了很多可能性, 这些可能性我们应该做成可配置性的
|
||||
|
||||
locomotion 的做法我采用了是否的选择, 例如考虑到巨型怪物可能只有走,
|
||||
|
||||
或者某些小型怪物只有跑, 走和跑还有idle都是用于的可选项
|
||||
|
||||
locomotion动画片的选项如下:
|
||||
|
||||
你可以考虑是否有疾跑, 跑, 和走
|
||||
|
||||
很关键一点在于生成locomotion以及对应的混合树,我把最复杂的混合树截图出来,其他的大家自己研究吧,有问题可以留言.
|
||||
|
||||
这里的关键点在于 走和跑的分离, 这样在左右横移的时候 不会出现这样的状态切换 [左横跑 - 左横走- idle - 右横走 - 右横跑], 双层混合树的好处在于直接变成:[左横跑 - 右横跑]
|
||||
|
||||
当然, 直接八向混合也可以,但是**如果追求细节不要这么做.**
|
||||
|
||||

|
||||
|
||||
最基础形态就是全部取消,只有idle:
|
||||
|
||||
因为动画状态特别多,如下:
|
||||
|
||||
```
|
||||
public enum AnimationType
|
||||
```
|
||||
|
||||
我在用一个典型的举例,就不多讲了
|
||||
|
||||
如果讨论动画组,不讨论combo也太不厚道了
|
||||
|
||||
combo我大致是这么做的, 可以支持多个动画片, 在播放时根据长度连续播放这样就支持连击了, 而且可以选择遮罩,例如只有上半身, 只有下半身,每个动画片绑定自己的前摇和后摇.
|
||||
|
||||
还有是否支持root motion等
|
||||
|
||||
再举个例子就是被击:
|
||||
|
||||
被击可以选择有效图层和具体播放动画片的朝向, 这个根据你的业务具体安排
|
||||
|
||||
其他例如loop动画, 普通animation, 就是可以配置前摇后摇时间即可, loop要支持三个动画的循环, 在动画播放结构中也需要支持.
|
||||
|
||||
**动力学和动画模块分离**
|
||||
|
||||
这里的核心思想是:
|
||||
|
||||
PlayController需要将输入交给动力学组件,同时将动力学组件的内容,交给Animation组件.
|
||||
|
||||
需要根据真实移动速度来决定动画状态,而不是输入向量,基于这个大的思想,可以保证一些奇怪的问题不要出现.
|
||||
|
||||
而动力学的核心在于处理特殊情况.
|
||||
|
||||
这里的类结构大概是这样:
|
||||
|
||||
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. 网络
|
||||
|
||||
|
||||
不管哪个来源,我们可以用同样的抽象层让输入统一,
|
||||
|
||||
输入分为几个类型
|
||||
|
||||
- 普通事件 - 不要让玩家的输入直接操作动作,一定要做一个转换,尤其是移动,移动的朝向交给动力学,动力学作用于动画系统,而按键输入一定要转化为一个类似行为的抽象.
|
||||
|
||||
- 状态转换型输入 - 某些按键实际上是一个状态, 例如,玩家按下收刀实际上角色要进入到一个收刀的状态,此时攻击键变成了一个特殊攻击,相对应这个状态下的输入都出现了变化,所以一个行为应该是基于状态的, 关于这个状态和事件的区别大致是这样的:
|
||||
|
||||
|
||||

|
||||
|
||||
这个状态手柄上一般会用扳机键
|
||||
|
||||
- 输入的时效 - 一般一个动作分为三个阶段 - 前摇, 执行中, 后摇,你可以指定三个阶段那个是有效输入阶段,例如我一般会屏蔽前摇的输入, 执行中是有效输入阶段,后摇就处理玩家输入
|
||||
|
||||
- 输入指令的重排序 - 这里很重要的是当一个动作执行中,玩家的输入要如何处理,后摇就开始执行之前输入的动作进入预混合阶段,而且后摇这个时间点我推荐对动作进行排序,例如当前是攻击,那么要做一个输入的优先级排序, 优先级最高的可能是下一个攻击, 而翻滚之后 优先级最高的可能是喝血, 不要根据玩家的最后一次按键去执行,这里对感受的提升是巨大的
|
||||
|
||||
|
||||
## 动作的细节处理
|
||||
|
||||
**攻击和翻滚相关:**
|
||||
|
||||
> *攻击,翻滚的前摇最好不要锁方向,这样玩家容易攻击之后怪物因为移动躲开了, 最好的量我感觉最好根据不同动作,每个单独设置
|
||||
|
||||
> * 攻击 - 攻击的核心在于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_
|
||||
@@ -1,3 +0,0 @@
|
||||
|
||||
[游戏知识学习——【战斗系统】-CSDN博客](https://blog.csdn.net/qq_53045580/article/details/130804401)
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
[独立游戏-万字解析游戏战斗系统开发 - 知乎](https://zhuanlan.zhihu.com/p/694146117)
|
||||
## 背景
|
||||
|
||||
写这篇文章的背景来自于去年做的一款**[星穹铁道](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=%E6%98%9F%E7%A9%B9%E9%93%81%E9%81%93&zhida_source=entity)**的**[战斗模拟器](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=%E6%88%98%E6%96%97%E6%A8%A1%E6%8B%9F%E5%99%A8&zhida_source=entity)**: 【耗时3个月,自制星铁战斗系统!就为作出最强攻略!】 [耗时3个月,自制星铁战斗系统!就为作出最强攻略!_手机游戏热门视频](https://link.zhihu.com/?target=https%3A//www.bilibili.com/video/BV1Er4y1R7y4/%3Fshare_source%3Dcopy_web%26vd_source%3Da00e3a4a1a7552a159a18dddb0a14fb0)。
|
||||
|
||||
后来结合自身经验又将此战斗系统**扩展复用**到了**多种类型**的游戏Demo中。在此来简单总结分享下其中组成,也不期待能帮助到大家什么,仅当在如今游戏行业的氛围中聊以慰藉吧。
|
||||
|
||||
做这个模拟器的背景在视频里其实有交代的蛮清楚的,感兴趣的会看不感兴趣的也不用多介绍了~。来说一下实现了什么样的功能:
|
||||
|
||||
1. 完全模拟了**星穹铁道**([卡牌向](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=%E5%8D%A1%E7%89%8C%E5%90%91&zhida_source=entity))的所有核心战斗机制,技能体系、数值体系、能量、装备系统、行动出手逻辑等。
|
||||
2. 截止xx前**所有角色**的**战斗逻辑**(和官方技能描述一致)。
|
||||
3. 实现了随机/伪随机的**可重入**操作(也就是视频里经常提到的**万次模拟**)。
|
||||
4. **伤害公式,数值公式**,甚至**buff被动**等时序以及打出来的伤害和官方角色实际体验**保持一致**。
|
||||
5. 截止xx前完全拆解并实现了当前版本的所有特殊角色的特殊逻辑。
|
||||
6. 加了点前端界面,有**战斗过程,出手演示,行动演示,数据LOG演示,调试**等界面。
|
||||
|
||||
**为什么要从卡牌讲起?**
|
||||
|
||||
- 因为这是我个人经历中比较少见的给"**自己**""**商用**"的战斗体系搭建,是从**0开始搭建**并**成功落地**验证了**扩展性**的结构。因为平时就热爱游戏开发,这套战斗体系我同样移植给了自己做的**"音游""肉鸽""休闲游戏""割草游戏"**等多个Demo中。所以其实可以看到这并**不局限于游戏类型**,只要维护核心观念的扩展性即可(**配置驱动**)。
|
||||
|
||||
**概览下战斗系统里包含什么?**
|
||||
|
||||
- **战斗核心机制**是 "**技能**" "**Buff**" "**子弹**" 体系的三者联动, 辅以"被动""装备""法球"等机制用来配合实现特定逻辑。
|
||||
- **逻辑与表现分离**: 这是避免不同步的基础,也是后续万次重入结论一致的基础,在设计之初就应该考虑的。
|
||||
- **[C#配置表工具](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=C%23%E9%85%8D%E7%BD%AE%E8%A1%A8%E5%B7%A5%E5%85%B7&zhida_source=entity):** 在工作中有做了一个把**excel**表导出成**c#数据表**的工具,生成c#之后可以让代码里直接调用对应的excel类的属性,所见即所得可以说非常好用了。
|
||||
- **出手逻辑:** 也就是程序如何循环的。这里不同类型的游戏会定制化一些。
|
||||
- **伤害机制:** 伤害公式确认好之后,重点就是用buff和属性、被动等共同组织起各种加成与时序算法。
|
||||
- **人物属性:** 单独拿出来说是因为人物属性需要做到动静分离,实时变更。影响因素较多(装备、被动、buff等),做到高效组织是需要点功力在的。
|
||||
- **触发机制:** 其实大部分就是**被动**带来的, **注册+触发**执行。
|
||||
- **日志系统:** 包含及其丰富细致的日志系统,让所有数据有迹可循。
|
||||
- **战斗表现:** demo做的粗糙,真正商业化游戏配合着**技能编辑器**来,根据时间轴做好战斗前摇、出手、后摇阶段的排版。
|
||||
- **操作手感:** 呦,抽象了喔。这是我最擅长的部分,单独拿出来作为一个文章不为过,解决过王者上千个战斗bug,走A,寻敌,位移,指示器等等非常多,也感慨优化了大部分的英雄。后面得空在好好讲讲这里吧。
|
||||
- **打击感:** 抛砖引玉: 技能缓存、飘字、震动、血条表现、顿帧、硬直等等。
|
||||
- **音频:** 不是我关注的重点.但是玩家体验的关键一环。
|
||||
- **网络:** 帧同步|状态同步。
|
||||
- **Debug:** 快捷的调试工具也是节约开发时间的"利器"。
|
||||
- **渲染表现:** 风火雷电冰,雨雪阴晴,草水等等. 不作为本文重点。
|
||||
- **AI:** 本身AI&怪物AI
|
||||
- **UI:** 战斗UI,可能讲一下有趣的地方~ 比如3DUI, 资源后处理Event生成等
|
||||
- **资源加载:** 和战斗关联比较大,顺便提一下
|
||||
|
||||
## 战斗系统雏形构建思路(星铁为例)
|
||||
|
||||
> 这是写给我自己做的独立游戏的,并不适用于所有项目/大厂,正式的战斗系统会更为复杂严谨,各位资深道友看见了觉得写的草率了就图一乐吧~。
|
||||
|
||||
**下面的内容组织思路?**
|
||||
|
||||
从星铁战斗系统的**整体构建**来总览回顾一步步还原**从0制作**一款卡牌**战斗雏形**的过程。
|
||||
|
||||
1. 首先**构建初始时序**
|
||||
2. 讲一下**个人风格**的**战斗体系的组成**,重点是如何实现**高扩展性**。
|
||||
3. 讲一下**战斗相关**的**系统扩展**,比如**战斗表现|日志系统|资源加载**等。
|
||||
4. 将战斗系统**扩展**应用到**多类型**游戏中,验证扩展性和鲁棒性。
|
||||
|
||||
## 构建初始时序
|
||||
|
||||
> 根据**游戏类型**(卡牌),做了下**出手执行顺序**的草图,这是游戏运转前的基础验证,后续的所有战斗逻辑皆以此为基础扩展。
|
||||
|
||||

|
||||
|
||||
出手执行顺序草图
|
||||
|
||||
可以看到星铁的核心出手机制定义为**行动值**,其实行动值就是速度,每个角色在程序中的运行速度的差异决定了**普攻出手**的先后。然后辅以每个角色特殊的**战技出手**条件&**能量出手**条件,形成了一个角色**全部的行动规则。**
|
||||
|
||||
下面提供**角色更新**伪代码(为**常规模拟模式**稍微做了些2D表现,所以使用了**协程wait.**):
|
||||
|
||||
```csharp
|
||||
// 通过协程分帧处理表现
|
||||
public IEnumerator ExcuteInner()
|
||||
{
|
||||
// 前置需要添加Boss | 角色 | 日志系统 | 开局事件触发等
|
||||
var maxRunCount = 全局配置表["行动值上限"];
|
||||
|
||||
while (curRunCount++ < maxRunCount)
|
||||
{
|
||||
// 更新人物
|
||||
foreach (var actor in actorList)
|
||||
{
|
||||
actor.Update();
|
||||
yield return new WaitForSeconds(0.5f);
|
||||
}
|
||||
|
||||
// 更新怪物 or 对手等..
|
||||
yield return new WaitForSeconds(0.01f);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**角色出手逻辑伪代码:**
|
||||
|
||||
```text
|
||||
public void Update()
|
||||
{
|
||||
// 判断死亡,死亡不会执行后续
|
||||
if (IsDie()) return;
|
||||
|
||||
// 更新角色携带的宠物
|
||||
m_pet?.UpdatePet();
|
||||
|
||||
// 更新人物路程
|
||||
ChangeWay(speed);
|
||||
|
||||
// 检查大招释放规则(能量更新在角色出手外,可以做到差帧更新: 即大招能量条满了不会同帧抢技能执行)
|
||||
CheckPlayEnergySkill();
|
||||
|
||||
// 判断是否可以出手(当前行动路程大于预设)
|
||||
if (m_curWay >= GlobalConfig.S_SumWay)
|
||||
{
|
||||
// 回合开始事件等派发
|
||||
EventCenter.Instance().Notify(EVENT_NAME.ROUND_BEGIN, this, arg);
|
||||
|
||||
// 路程归零(这里可以加速度余量,没加大概是强度使然)
|
||||
SetWay(0);
|
||||
|
||||
// 更新持续伤害
|
||||
UpdateLastDamageBuffs();
|
||||
|
||||
// 判断是否存在冻结等行动停滞效果
|
||||
if (IsFrozen())
|
||||
{
|
||||
// 冻结之后路程变成5000后面且不会出手,但是可以触发buff
|
||||
}
|
||||
else
|
||||
{
|
||||
ActorFight();
|
||||
}
|
||||
|
||||
// 检查大招释放规则
|
||||
CheckPlayEnergySkill();
|
||||
|
||||
// 更新Buff(挂在角色身上的正面负面都算)
|
||||
UpdateBuffs();
|
||||
|
||||
// 更新被动技能
|
||||
UpdatePassiveSkills();
|
||||
|
||||
// 清理当前轮次的攻击对象等回合相关信息
|
||||
// ClearRoundData();
|
||||
}
|
||||
|
||||
// 移除延迟buff列表(有些buff不会在当前更新即刻移除,要放在DelayRemoveBuff列表中延迟移除)
|
||||
// 移除被动(同理)
|
||||
// 角色更新完成
|
||||
}
|
||||
```
|
||||
|
||||
补充**ActorFight**判断是否是战技出手即完成完整**角色出手**流程:
|
||||
|
||||
```text
|
||||
public void ActorFight()
|
||||
{
|
||||
// 敌人出手前更新韧性
|
||||
// 出手前判断buff列表是否有回合开始时需要执行的buff
|
||||
for (int i = 0; i < m_buffList.Count; i++)
|
||||
{
|
||||
buffExcuteDic[m_buffList[i].m_buffType].RoundBeginExcute(m_buffList[i]);
|
||||
}
|
||||
// 判断技能出手条件是否满足 决定此刻是普攻/战技出手
|
||||
if (!CanSkill())
|
||||
{
|
||||
PlayAttack();
|
||||
}
|
||||
else
|
||||
{
|
||||
PlaySkill();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 构建角色属性系统
|
||||
|
||||
> 角色属性是伤害公式调用的基础,伤害公式就是根据配置动态修改各种属性的业务算法,而各种属性又决定了玩家的游戏策略,所以这个时候可以优先选择构建属性系统。
|
||||
|
||||
以星铁早期的人物属性举例,可以拆解为:
|
||||
|
||||
**速度、阵营、角色类型、攻击力、增伤、伤害加成、暴击率、无视防御、防御、暴击加成、血量、等级、穿透、抗性、韧性、嘲讽值、最大能量值、攻击属性、弱点、抗性弱点**。
|
||||
|
||||
下面这个配置表也可以参考一二,为什么用的是**拼音**啊?**Up是我室友他学俄语的啊**!!!要蚌埠住了,实在没有勇气用俄语来做表头和代码注释,索性在部分属性上折个中用拼音了。
|
||||
|
||||

|
||||
|
||||
后来才悔恨的加了一行中文注释.. 晚了
|
||||
|
||||
这里有3个点:
|
||||
|
||||
- Q: 为什么是会出现5000这种数字?
|
||||
|
||||
- A: 为什么是会出现5000这种数字?A: 为了保证精度,用万分比保留2位小数部分
|
||||
|
||||
- Q: 为什么是会出现大面积的0?
|
||||
|
||||
- A: 0代表了角色身上的初始属性确实没有,但是可以支持后续带有特定属性的角色更新
|
||||
|
||||
- Q: 为什么逻辑上又出现中文?
|
||||
|
||||
- A: 不用怕,针对有强迫症的程序来讲,是准备了一张**中文转换表**的,可以将中文**适配成对应的枚举**,但是给"策划"展示依然是中文方便阅读配置(不然你填个2谁知道是啥啊喂)。
|
||||
|
||||
至此,关于属性的基础创建就完成了,选择了对应的角色就可以直接使用属性系统了。
|
||||
|
||||
## 构建装备系统
|
||||
|
||||
> 本来不想扩散的,不过上一章节配置表中有很多的0,看不习惯,那就增加一个章节讲一下属性系统的实际数值是如何填充的(其中一种方式)-**"装备系统的构建".** 这一节很短,快的离谱
|
||||
|
||||
Q: 装备很多,属性很杂,还有装备各种特殊效果,套装加成等,关于加成的配置和装备的代码一定很麻烦吧?
|
||||
|
||||
A: 核心代码几十行代码就搞定.只需要 "**通用的属性加成**" 与 "**被动添加移除机制**",下面提供伪代码
|
||||
|
||||
```text
|
||||
// 获取装备属性
|
||||
public int GetEquipAdd(PropertyType _property)
|
||||
{
|
||||
// 自行判空 异常处理
|
||||
|
||||
return m_configData[propertyType];
|
||||
}
|
||||
|
||||
// 添加被动
|
||||
public void AddPassiveSkill()
|
||||
{
|
||||
// 自行判空 异常处理
|
||||
var passiveID = m_configData["PassiveID"];
|
||||
|
||||
foreach (var p in passives)
|
||||
{
|
||||
if (p > 0)
|
||||
{
|
||||
// 参数分别代表: 1.给谁上被动 2.谁上的被动 3.被动的id
|
||||
UTils.AddPassive(m_srcActor, m_srcActor, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移除被动
|
||||
public void Remove()
|
||||
{
|
||||
// 自行判空 异常处理
|
||||
var passiveID = m_configData["PassiveID"];
|
||||
|
||||
|
||||
foreach (var p in passives)
|
||||
{
|
||||
if (p <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (m_srcActor.ContainsPassive(p))
|
||||
{
|
||||
m_srcActor.RemovePassive(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||

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

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

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

|
||||
|
||||
技能系统简易示意图
|
||||
|
||||
### 如何实现**高扩展性?**
|
||||
|
||||
> 这套体系的优势就在高扩展性,将逻辑原子化提供节点给"策划"调用,扩展参数以达到强大的复用效果。
|
||||
|
||||
在B站的评论区经常有人会问: **米哈游再出一个英雄,**你是不是要**重新全部实现**一次英雄的技能呢? 如果是这样,累也怕是累死了,星铁战斗团队十数人一个版本的内容我**完全手敲代码复刻要死**的。
|
||||
|
||||
如何做到的? 两点:
|
||||
|
||||
1. [技能配置文件](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=%E6%8A%80%E8%83%BD%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6&zhida_source=entity)
|
||||
2. 优秀的抽象逻辑节点
|
||||
|
||||
### 技能配置文件
|
||||
|
||||
这部分既是指技能编辑器产出的技能组成,也同样指我们技能表中的配置。那为什么会存在两份结构不一样的配置表呢? 一个比较直观的解释: **技能配置表**更像是数据库.你可以随时**读取数据**. 技能配置文件则是组织技能出手后的复杂执行逻辑&效果的配置文件。他们的核心都为**"[数据驱动](https://zhida.zhihu.com/search?content_id=242384834&content_type=Article&match_order=1&q=%E6%95%B0%E6%8D%AE%E9%A9%B1%E5%8A%A8&zhida_source=entity)",**所以也可以说: 只要做到了足够丰富抽象的节点设计,使用**"数据驱动"**就可以让策划自己编排所有后续的战斗需求了。
|
||||
|
||||

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

|
||||
|
||||
技能配置表相关数据
|
||||
|
||||
```text
|
||||
public partial class NCONFIG_CSHAP
|
||||
{
|
||||
public static Dictionary<int, CfgBuffData> CfgBuff = new Dictionary<int, CfgBuffData>
|
||||
{
|
||||
[100101] = new CfgBuffData {
|
||||
ID = 100101,
|
||||
Name = "通用伤害",
|
||||
Desc = "测试",
|
||||
SkillSrc = "XX技能",
|
||||
LastTime = 1f,
|
||||
BuffType = "通用伤害",
|
||||
Param1 = 0,
|
||||
Param2 = 0,
|
||||
Param3 = 0,
|
||||
PerCentParam = 10000,
|
||||
target = "对方单体",
|
||||
BuffTypeAddBuff = 0,
|
||||
LastBuff = false,
|
||||
DieJia = "",
|
||||
MaxCount = 1,
|
||||
DelayBuff = false,
|
||||
TriggerCD = 0,
|
||||
EndAddBuffs = new int[] { },
|
||||
},
|
||||
[100102] = new CfgBuffData {
|
||||
ID = 100102,
|
||||
Name = "普通攻击伤害",
|
||||
Desc = "测试",
|
||||
SkillSrc = "XX技能",
|
||||
LastTime = 1f,
|
||||
BuffType = "通用伤害",
|
||||
Param1 = 0,
|
||||
Param2 = 0,
|
||||
Param3 = 0,
|
||||
PerCentParam = 30000,
|
||||
target = "对方单体",
|
||||
BuffTypeAddBuff = 0,
|
||||
LastBuff = false,
|
||||
DieJia = "",
|
||||
MaxCount = 1,
|
||||
DelayBuff = false,
|
||||
TriggerCD = 0,
|
||||
EndAddBuffs = new int[] { },
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 抽象逻辑节点
|
||||
|
||||
> 当建立了一定体量的逻辑功能节点后,"策划"就可以通过简单改变配置来组合达到不同的技能效果了。
|
||||
|
||||
在讲之前,可以先分析以下两个技能的区别:
|
||||
|
||||
1.释放技能,对随机敌人造成x点伤害,并叠加一层火焰伤害,每回合每层火焰伤害造成x点伤害,最高上限5层。
|
||||
|
||||
2.释放技能,对选中敌人造成五段冰霜伤害,最后一段伤害必定造成暴击。
|
||||
|
||||
这两个技能看起来风马牛不相及,实际上同属【**伤害**】这个概念中.我们可以从技能描述中抽离出**"随机/选中敌人""火焰/冰霜""单回合/多回合""1段/5段伤害""暴击"**这些差异点。
|
||||
|
||||
那我们**只需要构建一个**通用的**伤害Buff**,在执行的过程中根据以上**五种差异元素**做配置判断即可,根据配置:
|
||||
|
||||
- 将**选敌逻辑**抽象出来: 可以选择单个、多个、随机的**敌人.**可以选择血量最低、伤害最高、距离最近的**友军**。
|
||||
- 将**元素逻辑**抽象出来: 可以配置冰、火、暗、光属性等,并单独结算属性伤害&韧性计算(关于破韧等逻辑使用条件触发即可)。
|
||||
- 将**回合逻辑**抽象出来: 控制生命周期,根据配置单回合生效即销毁还是执行多回合。
|
||||
- 将**叠加逻辑**抽象出来: 分段伤害可以分为几种:
|
||||
|
||||
- 如果是纯伤害叠加可以使用**buff叠层**
|
||||
- 如果要分开结算(比如暴击率独立判定),则配置多个伤害buff,或者buff执行衔接buff等方式都可以
|
||||
|
||||
- 将暴击**逻辑**抽象出来: 配置可以无极调整的暴击率参数就Ok.
|
||||
|
||||
用来简化的表达如下, 技能产生buff, Buff通过配置表参数传递特殊数据,而具体的执行逻辑节点是注册机制,使用Map索引即可。
|
||||
|
||||

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

|
||||
|
||||
通用Buff逻辑执行节点注册示意
|
||||
|
||||
具体逻辑节点要做的事情千变万化了,也是高扩展性的核心竞争力 。这里就不展开讲了,注意配置的扩展就好,接下来来聊一下战斗相关的系统扩展。
|
||||
|
||||
## **战斗相关**的**系统扩展**
|
||||
|
||||
> **战斗相关**的**系统扩展**,涉及到星铁的比如**战斗表现|日志系统**等, 涉及到常规游戏的,如音频管理,资源加载等,不作为重点,**后续更新.**
|
||||
|
||||
### 战斗表现
|
||||
|
||||
> 战斗表现因为是卡牌又是Demo,实际上没什么好讲的,这个章节会比较短,后面会补上图或者视频.(其实分享链接里面也有,就是个演示界面。
|
||||
|
||||
从设计开始, 使用xx做原型设计
|
||||
|
||||
### 日志系统
|
||||
|
||||
> 重点是日志的全面打点&显示统计和输出,后续更新
|
||||
@@ -1,182 +0,0 @@
|
||||
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. **输入事件**:改用事件驱动减少每帧状态查询
|
||||
|
||||
|
||||
> 该设计通过分层抽象解决了多键位绑定问题,核心价值在于将**物理输入**到**游戏行为**的映射关系标准化,为复杂输入需求提供了可扩展框架。
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"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":[]
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
|
||||
|
||||
|
||||
### Animator
|
||||
- 优势
|
||||
- Unity原生支持
|
||||
- 劣势
|
||||
- 源码黑盒
|
||||
- 不可编辑帧事件
|
||||
- 意味着不可编辑打击感,不可编辑运动方向位移
|
||||
- 帧事件可能被跳过的bug
|
||||
- 混合也在黑盒里
|
||||
|
||||
|
||||
### Timeline
|
||||
- 优势
|
||||
- 自定义Track Clip Behaviour
|
||||
- Clip可以支持Inspector
|
||||
- 自定义混合
|
||||
- 劣势
|
||||
- Inspector不可编辑某一帧的事件
|
||||
- 没有Animator的状态可视化切换,需要所有切换动作都在代码中写
|
||||
|
||||
|
||||
### AnimacerComponent
|
||||
- 优势
|
||||
- 劣势
|
||||
|
||||
|
||||
### 自定义Playable动作系统
|
||||
@@ -7,17 +7,17 @@
|
||||
- 优化V和VM的绑定
|
||||
---
|
||||
- [x] M和VM的事件通知
|
||||
- 在用的时候可以用别的事件系统,接入Event
|
||||
- ~~在用的时候可以用别的事件系统,接入Event~~
|
||||
---
|
||||
- [ ] 设置Active标签
|
||||
---
|
||||
- [ ] bool值回调除了toggle另外的组件怎么处理
|
||||
- 通过特性ValueChanged解决
|
||||
- 需要将现有组件重新封装一次
|
||||
- bool值回调就可以根据IValueChanged接口来实现了
|
||||
- IValueChanged\<string\>
|
||||
- Text : IValueChanged\<string\>
|
||||
- IValueChanged\<bool\>
|
||||
- [x] bool值回调除了toggle另外的组件怎么处理
|
||||
- ~~通过特性ValueChanged解决~~
|
||||
- ~~需要将现有组件重新封装一次~~
|
||||
- ~~bool值回调就可以根据IValueChanged接口来实现了~~
|
||||
- ~~~~~IValueChanged\<string\>~~~~~
|
||||
- ~~Text : IValueChanged\<string\>~~
|
||||
- ~~IValueChanged\<bool\>~~
|
||||
---
|
||||
- [ ] Inject添加ScopeId的参数
|
||||
---
|
||||
@@ -27,4 +27,4 @@
|
||||
- OnValueChanged太过麻烦
|
||||
- ToggleGroup选择改变的时候View没有对应的监听,想要监听必须new一个组件出来,或者直接绑定ViewModel的PropertyChanged的方法
|
||||
---
|
||||
- toggle监听的方式为interface = bool,选中之后不可取消
|
||||
- ~~toggle监听的方式为interface = bool,选中之后不可取消~~
|
||||
Reference in New Issue
Block a user