前言

基于行为树的Moba技能系统系列文章总目录:https://www.lfzxb.top/nkgmoba-totaltabs/

在战斗系统中数值系统也是一个核心的系统,当今主流做法是将一个属性分为两个相关联的属性,比如最大生命值就会被分为基础最大生命值 + 额外最大生命值两者之和

基础最大生命值一般而言是初始恒定的

额外最大生命值一般而言是受英雄自身属性,等级,装备,Buff影响的,比如对于力量英雄而言+1力量会为英雄提供20最大生命值,提升一级会为英雄提升80最大生命值,一件装备会提升20%额外最大生命值

其他的例如攻击力,移速,魔法值,法强,护甲,魔抗等都是如此。

此外,还有常见的伤害处理,减速处理等间接影响属性的类型。

分类

战斗数据处理主要分为两大类

  • 直接作用于属性上,例如最大生命值,魔法恢复速度,移速等
  • 间接作用于属性上,例如伤害,减速,魔法消耗等

并且直接作用复杂度 < 间接作用复杂度

由于间接作用类型的存在,我们就不能用诸如

final = ((base + add) * (100 + pct) / 100);

的形式来处理属性变更了,我们需要找到一种更加灵活的方式来处理这些内容,为什么这样说呢?因为间接作用类型它的变化更加多样,流程也更加复杂,

比如一次A向B发起一次普攻伤害,会先获取A总攻击力,然后计算A身上的相关Buff,比如虚弱(伤害减少40%),石像鬼板甲(开启后会降低50%的输出),振奋光环(提升5%伤害)等,

随后这个伤害值来到B,B接收伤害要先计算暴击,然后格挡伤害,护甲减伤,buff减伤,buff增伤等,

这里我采用的是抽象每一个会更改属性的行为为一个DataModifier,这个DataModifier又可以分为两大类,一类是常量类型的改变(ConstantModifier)(比如增加400最大额外生命值),另一类是百分比类型的改变(PercentageModifier)(比如提升35%移动速度),其中常量类型的需要先于百分比类型的执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/// <summary>
/// 洗礼一个数值
/// </summary>
/// <param name="targetModifierName">目标修改器集合名称</param>
/// <param name="targetData">将要修改的值</param>
public float BaptismData(string targetModifierName, float targetData)
{
if (AllModifiers.TryGetValue(targetModifierName, out var modifiers))
{
float constantValue = 0;
float percentageValue = 0;
foreach (var modify in modifiers)
{
if (modify.ModifierType == ModifierType.Constant)
{
constantValue += modify.GetModifierValue();
}
else
{
percentageValue += modify.GetModifierValue();
}
}
targetData = (targetData + constantValue) * (1 + percentageValue);
}
return targetData;
}

对于DataModifier的管理,使用

1
private Dictionary<string, List<ADataModifier>> AllModifiers = new Dictionary<string, List<ADataModifier>>();

其中Key表示数值修改器的一个类别,例如额外生命值,减速时长,眩晕时长,接收伤害,释放伤害等,Value就是具体的修改器

(PS:这里为了方便讲解就直接以攻击方->受击方的过程直接来描述伤害计算流程了,实际情况是一个伤害数据可能会在攻击方和受击方之间跳来跳去,比如有个Buff是提升实际造成伤害的30%,就需要在受击方计算完成后在把数据发回攻击方计算Buffd的伤害,看起来很复杂,并且似乎打乱了我们的既有架构。其实细想一下,这也只不过是一个特殊的DataModifier罢了,首先我们的伤害数据是一个数据集合,里面包含很多信息,伤害/暴击率/伤害类型等,可以在ADataModifier提供一个虚方法,然后在特殊的DataModifier中重写这个虚方法,在BaptismData(这个BaptismData方法也要修改,第二个参数修改为接受指定Class类型,比如我们的伤害数据,耗魔数据等)的时候执行这个方法即可)

直接作用

对于直接作用类型来说,我们需要保存各类属性的初始值,因为大多数Buff会有持续时间,持续时间结束后需要移除Buff,同样的就得移除对应DataModifier。

1
2
3
4
5
6
7
8
public Dictionary<int, float> OriNumericDic;
/// <summary>
/// 初始化初始值字典,用于值回退,比如一个buff加50ad buff移除后要减去这个50ad,就需要用到OriNumericDic里的值
/// </summary>
public void InitOriNumerDic()
{
OriNumericDic = new Dictionary<int, float>(NumericDic);
}

这里以DOTA2中的恐鳌之心为例,它的效果为

+400 生命值
+1% 生命恢复

就可以拆分成两个DataModifier

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ConstantModifier: ADataModifier
{
/// <summary>
/// 修改的值,这里直接增加400绝对值
/// </summary>
public float ChangeValue = 400;
public override ModifierType ModifierType
{
get
{
return ModifierType.Constant;
}
}

public override float GetModifierValue()
{
return ChangeValue;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class PercentageModifier: ADataModifier
{
/// <summary>
/// 百分比,这里意思为1%加成
/// </summary>
public float Percentage = 0.01;
public float Percentage;

public override ModifierType ModifierType
{
get
{
return ModifierType.Percentage;
}
}
public override float GetModifierValue()
{
return Percentage;
}
}

然后调用

1
2
3
//添加DataModifier到字典中
AddDataModifier("HPAdd", constantModifier);
AddDataModifier("HPAdd", percentageModifier);

这种类型的数值处理往往需要经过两次处理,即发起方-接收方。具体例子在分类那一块已经举过了。

有了直接作用的前置,我们就可以进行形如

1
2
3
4
//发起方进行伤害处理
this.TheUnitFrom.GetComponent<CastDamageComponent>().BaptismDamageData(damageData);
//接收方进行伤害计算得到最终伤害值
float finalDamage = this.TheUnitBelongto.GetComponent<ReceiveDamageComponent>().BaptismDamageData(damageData);

技能数值计算

技能数值也是非常重要的一部分,有些技能需要根据法强,攻击力,生命值,命中目标数量,护甲等各种各样的加成方式进行数据修正,这一部分的处理,其实是要提前于我们前面说到的两大类处理的。因为那时候的初始伤害是技能已经打出去的伤害,根据角色身上已有的Buff数据进行伤害修正,而技能数据的计算,是与他们无关的,纯粹是技能自身伤害的计算,可能有点抽象,下图比较形象地描述了技能数值计算在整个数值系统中的位置(第二和第三块)

之所以会把技能数值的计算分为两块,是因为其本身的特殊性

第二块偏向于“技能本身无关”属性的计算,例如根据对方最大生命值造成伤害,根据自身护甲值加成伤害等

第三块偏向于“技能本身效果强相关”的计算,例如碰撞到了多个敌人会有伤害衰减,技能已施放时间越长伤害越高

有些类似于一静一动的模式,所以分开去处理

对于第二块的加成处理,目前主流方式是Code硬编码 + Excel配置公式,使用可视化编辑器的话可以更加方便直观的去处理配置这个流程

image-20210907125821127

当然也是少不了手写大量switch硬编码操作的

事件通知

我们的数值往往会显示在UI上,并且是可以实时更新的,所以需要构建一个数值改变时自动分发事件的系统

首先不同的数值类型需要分开

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public enum NumericType
{
//最小值,小于此值的都被认为是原始属性
Min = 10000,
//生命值
Hp = 1001,
//最大生命值
MaxHp = 1002,
MaxHpBase = MaxHp * 10 + 1,
MaxHpAdd = MaxHp * 10 + 2,
//魔法值
Mp = 1003,
//最大魔法值
MaxMp = 1004,
MaxMpBase = MaxMp * 10 + 1,
MaxMpAdd = MaxMp * 10 + 2,
...
}

用一个Dic在数值类型和数值真正的值之间做好映射

1
public Dictionary<int, float> NumericDic = new Dictionary<int, float>();

最后在我们更新数值的时候进行事件分发

1
2
3
4
5
6
7
8
public void Update(NumericType numericType)
{
int final = (int) numericType;
float result = this.NumericDic[final];
...
//将改变的值以事件的形式发送出去
Game.EventSystem.Run(EventIdType.NumbericChange, this.Entity.Id, numericType, result);
}

事件通知会通过AddDataModifier和RemoveDataModifier与前面提到的数据修改器相结合,因为数据修改器集合的修改势必会影响数值,以AddDataModifier为例

1
2
3
4
5
6
7
8
9
10
11
12
/// <summary>
/// 新增一个数据修改器
/// </summary>
/// <param name="modifierName">所归属修改器集合名称</param>
/// <param name="dataModifier">要添加的修改器</param>
/// <param name="numericType">如果不为Min说明需要直接去更新属性</param>
public void AddDataModifier(string modifierName, ADataModifier dataModifier, NumericType numericType = NumericType.Min)
{
...
this.Entity.GetComponent<NumericComponent>().Update(numericType) =
this.BaptismData(modifierName, this.Entity.GetComponent<NumericComponent>()[numericType]);
}

这样,一个健壮的数值系统就构建完成了

优化

其实在一些情况下我们是不需要让数据经过一大串数据修改器的,比如我们的基础攻击力是40,各种Buff(修改器)加成下会达到50,如果我们的基础攻击力没变,数据修改器也没变,是不是我们就可以直接再使用50这个最终数字了呢?实现方式也很简单,计算Hash即可,典型的空间换时间做法。