技能系统

流程图

Skill

数据类 SkillCfg:

​ 拥有伤害值、动画切片名等等基础信息。

​ 拥有技能类型、碰撞关系的技能逻辑体需要用到的枚举信息。

​ 拥有BuffList、BulletCfg用于动态创建Buff和Bullet。

逻辑类 Skill:

Buff

  • 数据类 BuffCfg。给策划配表用,只有数值(碰撞关系、伤害数值)。
  • 逻辑类 Buff。本身用状态机实现。

复杂的Buff自己派生BuffCfg、Buff来实现数据、逻辑。

Bullet

  • 目标追踪型:如果Skill是指向型的、且存在BulletCfg,就会被当作指向型弹道技能。
  • 位置确定型:如果Skill是非指向型的、且存在BulletCfg,就会被当作非指向型弹道技能。

Bullet结构自身并不附带任何Buff。

子弹伤害、效果在Bullet的命中回调中通过读取Skill伤害、Skill的BuffList实现。

技能实现

// TODO 梳理一整个战斗网络包(位移、技能)Send-Rsp-Tick过程:所有的本地设备(包括操纵者自己)其实就是同步演算+播放器

技能前摇、后摇

指向技能实现

查找目标

指向 = 有目标的、追踪的。需要额外的查找目标代码。

非弹道型

前摇结束时目标仍在技能范围内,就判定释放成功。比如亚瑟这类近战的普攻。

弹道型

追踪子弹。比如后裔这类射手的普攻。

非指向技能实现

非弹道型

通过buff动态确定目标,其实大部分属于纯buff型技能。比如亚瑟2技能。

弹道型

自判定子弹,通过SweepVolume算法确定是否命中。比如后裔的大招。

纯buff型技能

很多,比如替换普攻的技能、亚瑟的2技能、亚瑟的被动都属于这一类。

不需要通过技能逻辑代码来执行,直接通过buff逻辑(比如Buff.Tick、Buff对Skill添加回调等)。

金克斯技能拆解

被动

击杀防御塔、英雄就进行加速175%,持续3秒。

技能一

枪炮交响曲·Q:金克丝切换武器   

鱼骨头——火箭发射器:普通攻击会对目标和目标身边的所有敌人造成110%的伤害,攻击距离提升75/100/125/150/175,并且耗费法力值。   

砰砰枪——轻机枪:普通攻击会获得攻击速度加成,持续2.5秒。攻速加成可叠加3次,总攻速加成为30/55/80/105/130%。叠加效果在同一时间只会消耗一层,并且在金克丝切换至火箭发射器后,只有第一次攻击会享受加速效果。

金克斯最复杂的一个技能。

普攻、技能一自身替换,通过技能替换buff实现。

持续2.5秒的可叠加被动,是作为英雄被动技能实现的:buff初期化时给英雄的技能释放成功添加回调来刷新计时、叠加层数,在buff状态机内不断迭代时间是否满足2.5秒。但是普攻回调中会检测目前的普攻ID,如果是替换了的火箭发射器ID,就会把攻速加成提前取消。这样同时实现了“只有第一次攻击会享受加速效果”。

火箭发射器形态普攻,当作指向性弹道技能实现。爆炸是通过给技能添加一个 静态范围伤害buff 来实现的爆炸效果。在子弹命中回调中创建buff,传入子弹及时Pos,形成一个检测碰撞区来获取被命中的敌人。

技能二

振荡电磁波·W:金克丝0.6-0.4秒后发射一束震荡波,对命中的第一个目标造成10/60/110/160/210物理伤害,让非潜行的目标暴露在视野内,并让目标减速30/40/50/60/70%,减速效果持续2秒。

由于demo还没有实现战争迷雾系统,暴露视野暂不实现。

非指向弹道技能 + 减速buff。

技能三

金克丝扔出3颗陷阱手雷。手雷一旦布置完毕,就会在接触到敌方英雄时爆炸,将敌方英雄束缚1.5秒并阻止在该位置上的进行中的移动技能,立刻对周围的敌人造成共80/135/190/245/300魔法伤害。手雷持续5秒。

可以通过非指向非弹道技能实现。通过轮盘确定投放位置后,对位置生成3个静态范围寻找目标buff实现。给buff添加buffEffect实现夹子特效。

技能四

超究极死神飞弹·R:金克丝发射一枚飞弹。在发射后的一秒里,飞弹的伤害会根据飞行的距离而持续增加。飞弹会在首次命中敌方英雄后爆炸,对目标造成25/35/45到250/350/450物理伤害,并附带相当于目标已损失生命值的25/30/35%的额外伤害。附近的敌人也会受到80%伤害。

非指向弹道技能 + 静态计算碰撞范围+记录距离计算伤害+损失生命计算百分比伤害buff。因为比较特殊3个buff的数据是存在依赖(后2个buff的伤害计算 ->.前1个buff的伤害基准值)的,所以直接放到一个新buff里实现。

技能机制

技能分析

目标 = 指向;弹道 = 借助bullet;无前摇 = 瞬发型。

英雄 亚瑟 后羿
普攻 目标技能 + 非弹道、有前摇 目标技能 + 弹道、延迟
技能1 非目标技能 buff替换普攻Cfg + 非弹道、无前摇
技能2 非目标技能 + 非弹道、无前摇
技能3 目标技能 + 非弹道、有前摇

非目标技能(非指向)

1.buff替换普攻Cfg技能实现(亚瑟一技能)

亚瑟技能1有许多buff附加到下一个普攻上,直接修改普攻得buffArr使其变更数据会让代码复杂,所以直接采用技能替换普攻的方法,将其作为一个技能。

之后每1次普攻,其实就相当于在释放1次技能了,所以技能的buff 也会随着每一次普攻而重新Create。

直到时间结束或者攻击次数用完,技能被替换回原先的普攻。

2.后裔三技能实现

目标技能(指向)

通用的寻找目标实现

  1. 根据配置,查找到所有活着的可作为目标的单位(比如红队的英雄)
  2. 根据技能配置规则,确定最终目标单位(比如最近的单个英雄)
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
27
28
29
30
31
32
33
34
35
36
37
38
// ----------------1.中的配置规则----------------

/// 施法目标阵营
public enum TargetTeamEnum
{
/// 用于动态选择目标,通常是方向指向或位置指向技能,在施法成功后通过buff选择目标
Dynamic,
Friend,
Enemy
}
/// 施法目标类型
public enum UnitTypeEnum
{
Hero,
Soldier,
Tower,
}

// ----------------2.中的技能配置规则----------------

public enum SelectRuleEnum
{
None,

// 单个目标选择规则

MinHpValue,// 血量最小
MinHpPercent,// 血量百分比最小
TargetClosestSingle,// 最近的单个
PositionClosestSingle,// 靠近某个位置的单个选择

// 多个目标选择规则

TargetClosestMulti,// 最近的多个(范围选择)
PositionClosestMulti,// 靠近某个位置的多个选择(范围选择)

Hero,// 所有英雄单位
}

然后让我们看一下2中实现目标查找的关键部分代码,找最近单体目标的核心代码。其他的实现比如查找符合目标的群体算法,核心部分逻辑也是一样的:

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
// ----------------核心,查找规则----------------
static MainLogicUnit FindMinDisTarget(MainLogicUnit self,List<MainLogicUnit> targetList, PEInt range)
{
if (targetList == null || targetList.Count < 1)
{
return null;
}

MainLogicUnit target = null;
CodingKVector3 selfPos = self.LogicPos;
PEInt len = 0;
for (int i = 0; i < targetList.Count; i++)
{
PEInt sumRaius = targetList[i].ud.unitCfg.colliCfg.mRadius + self.ud.unitCfg.colliCfg.mRadius;
// 要剔除掉半径,因为某单位半径可能因为体型变大而变得非常大,结果就看上去更近了,应该打这个看上去更近的
// 可以优化一下 => 模magnitude 用 平方根sqrMagnitude替代,比较的时候与(range + sumRaius)*(range + sumRaius)比较即可,不等式是一样的,但可以省去一次开根号。
PEInt tmpLen = (targetList[i].LogicPos - selfPos).magnitude - sumRaius;
if (target == null || tmpLen < len)
{
len = tmpLen;
target = targetList[i];
}
}
// 将上面找到的最近目标与range比较,如果还是超过range就返回null
return len < range ? target : null;
}

1.亚瑟普攻实现

  1. 寻找目标
  2. 切换技能状态到SKillState.SpellStart
  3. 播放音效
  4. 修改朝向:修改为lockTarget.LogicPos - owner.LogicPos的CodingKVector3向量。
  5. 播放动画:先取消移动,再播放攻击动画。

2.后裔普攻实现

技能构造

技能Config

1

血条和伤害UI

血条 数据结构

个体状态枚举类

1
2
3
4
5
6
7
8
9
10
11
12
13
/// <summary>
/// 血条上显示的状态
/// </summary>
public enum StateEnum
{
None,
Silenced,//沉默
Knockup,//击飞
Stunned,//眩晕

Invincible,//无敌
Restricted,//禁锢
}

抽象血条类结构

继承关系:公用抽象血条类 => 塔 ; 公用抽象血条类 => 小兵 => 英雄

1
2
3
4
5
6
public RectTransform rect;
public Image imgPrg;// 进度

protected bool isFriend;//是否是友军,用来显示不同颜色的血条UI
protected Transform rootTrans;//目标血条,即需要映射的物体的位置,比如英雄头顶上的位置
protected int hpVal;

UI映射

血条

血条映射

1
2
3
4
5
6
7
8
9
10
private void Update()
{
if (rootTrans)
{
// 标准高度:自适应高度,取出比例
float scaleRate = 1.0f * ClientConfig.ScreenStandardHeight / Screen.height;
var screenPos = Camera.main.WorldToScreenPoint(rootTrans.position);
rect.anchoredPosition = screenPos * scaleRate;
}
}

伤害飘字 数据结构

缓存池

需要用到伤害飘字的单位比较多,频繁销毁创建会消耗性能,所以首先建立一个缓存池,限制个数为50,用到的时候从池中取出播放动画。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
```



**跳字类型枚举类**

```csharp
public enum JumpTypeEnum
{
None,
SkillDamage,
BuffDamage,
Cure,// 治疗
SlowSpeed,// 减速
}

跳字动画枚举类

1
2
3
4
5
6
7
public enum JumpAniEnum
{
None,
LeftCurve,// 左曲线飘出
RightCurve,// 右曲线飘出
CenterUp,
}

飘字预制体脚本 结构

全部公开,方便在unity面板内调参

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class JumpNum : MonoBehaviour
{
public RectTransform rect;
public Animator ani;
public Text txt;

public int MaxFont;// 最大字体,比如60
public int MinFont;// 最小字体,比如40
public int MaxFontValue;// 将伤害转化为字体大小的计算值x,举例:伤害刚好=x,字体大小就取50

public Color skillDamageColor;
public Color buffDamageColor;
public Color cureDamageColor;
public Color slowDamageColor;
}

通用技能定时器

总体流程

用于CD计时器。一共分3个部分:延迟、回调函数(循环)执行、结束回调(1次or不)执行。

定时器代码

只展示重要成员和使用的接口,内部直接看代码(在github开源了)。

回调函数和3个部分的回调签名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1.回调函数,用于显示次数进度。参数:loopCounter
private Action<int> cbAction;
// 循环累计次数
private int loopCounter;

// 2.回调函数,用于显示百分比进度。参数:是不是延迟的部分,prgLoopRate,prgAllRate
private Action<bool, float, float> prgAction;
// 当前阶段(延迟or回调执行)进度
float prgLoopRate = 0;
// 计时器总体进度
float prgAllRate = 0;

// 3.结束后回调函数
private Action endAction;

对外接口(构造器):

1
2
3
4
5
6
7
8
9
10
11
12
public MonoTimer(
Action<int> cbAction,
float intervalTime,
int loopCount = 1,
Action<bool, float, float> prgAction = null,
Action endAction = null,
float delayTime = 0)
{
...
this.IsActive = true;
this.prgAllTime = delayTime + intervalTime * loopCount;
}

定时器测试示例

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
27
28
29
public void ClickTestBtn() {
SetText(txtTime, 5);
testTimer?.DisableTimer();
testTimer = CreateMonoTimer(
(loopCount) => {
this.ColorLog(LogColor.Green, "Loop:" + loopCount);
SetText(txtTime, 5 - loopCount);
},
1000,
5,
(isDelay, loopPrg, allPrg) => {
SetActive(imgPrgLoop);
if(isDelay) {
SetActive(txtTime, false);
}
else {
SetActive(txtTime);
}
imgPrgLoop.fillAmount = 1 - loopPrg;
imgPrgAll.fillAmount = allPrg;
},
() => {
SetActive(imgPrgLoop, false);
imgPrgAll.fillAmount = 0;
this.ColorLog(LogColor.Green, "Loop End");
},
3000
);
}

延迟圈转完后进行倒计时轮转,下面的进度条prg= 已过时长 / (延迟时长+回调时长)。

逻辑定时器

逻辑定时器(基于定点数),可以跑在服务器上,不依赖于Mono。

在本项目中,由 NetSvc.GetServiceMessageAndHandle(GameMsg msg) > BattleSys.NotifyOpKey(GameMsg msg) > FightMgr.Tick() > MainLogicUnit.LogicTick() > MainLogicUnit.TickSkill() > LogicTimer.Tick() 驱动。

下面贴重要成员和使用的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 对外接口(构造器):
public LogicTimer(Action cb, PEInt delayTime, int loopTime = 0)
{
...
delta = Configs.ServerLogicFrameIntervelMs;
IsActive = true;
}

// 重要成员:
// 服务端每一帧的时间(66ms)
PEInt delta;
// 延迟时长
PEInt delayTime;
// 循环周期时长
PEInt loopTime;
// 回调函数
Action cb;

Buff

贴一下核心属性和对应的Enum类。

SubLogicUnit类:辅助类逻辑单元基类

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/// <summary>
/// 状态阶段
/// </summary>
public enum SubUnitState
{
None,
Delay,
Start,
Tick,
End
}

/// 辅助类逻辑单元基类
/// Buff and Bullet
/// </summary>
public abstract class SubLogicUnit : LogicUnit
{
//---------------1.核心属性:---------------
// 来源角色
public MainLogicUnit source;
// 所属技能
protected Skill skill;
// 延迟生效时间
protected int delayTime;
// 辅助单元状态
public SubUnitState unitState;

//---------------2.核心事件:---------------
public override void LogicInit()
{
// if delayTime == 0 切换状态为Start
// else 切换状态为Delay
}

public override void LogicTick()
{
switch (unitState)
{
case SubUnitState.Delay:
// Delay完切换状态为Start
break;
case SubUnitState.End:
End();
unitState = SubUnitState.None;
break;
case SubUnitState.None:
default:
break;
}
}

public override void LogicUnInit() { }

protected abstract void Start();
protected abstract void Tick();
protected abstract void End();
}

BuffCfg类

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// buff类型,用来创建不同类型的buff
public enum BuffTypeEnum {
None,
ModifySkill,
MoveSpeed_Single,//单体加速buff
Silense,//沉默
ArthurMark,//Arthur1技能的标记伤害Buff
HPCure,//治疗
MoveSpeed_DynamicGroup,//Arthur1技能的动态群体移速Buff
MoveAttack,//移动攻击
}

// buff位置确定方式
public enum StaticPosTypeEnum {
None,
SkillCasterPos,//Buff所属技能施放者的位置
SkillLockTargetPos,//Buff所属技能锁定目标的位置
BulletHitTargetPos,//子弹命中目标的位置
UIInputPos,//UI输入位置信息,比如后裔2技能
}

// buff附着目标
public enum AttachTypeEnum {
None,
Caster,//由施术者自己确定:给自己,Arthur的1技能加速buff
Indie,//由施术者自己确定:区域,Arthur大招(位置固定)产生的持续范围伤害

Target,//由受击者确定:给目标,Arthur的1技能沉默buff
Bullet,//由受击者确定:Houyi大招命中(位置动态)目标时产生的范围伤害
}

public class BuffCfg
{
// ------------属性------------

public int buffId;
public string buffName;

// ⭐buff类型,用来创建不同类型的buff
public BuffTypeEnum buffType;

// ⭐buff附着目标
public AttachTypeEnum attacher;
public StaticPosTypeEnum staticPosType;

// buff作用目标,如果为null默认影响附着对象
public TargetCfg impacter;

// ------------效果相关------------

public int buffDelay;
// ⭐buff效果触发频率(比如持续1秒1次)
public int buffInterval;
// ⭐buff持续时间(不包含delay)0:生效1次,-1:永久生效
public int buffDuration;

// ------------配置------------

public string buffAudio;
public string buffEffect;
public string hitTickAudio;
}

Buff类

使用关系:

正常一种类得buff有 xxBuff类 and xxBuffCfg类,xxBuffCfg拥有该buff特有的属性,xxBuff会包含xxBuffCfg在初期化通过ResSvc加载获取它。

继承关系:

  • xxBuff < Buff < SubLogicUnit < LogicUnit
  • xxBuffCfg < BuffCfg
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class Buff : SubLogicUnit
{
/// buff附着单位
public MainLogicUnit owner;
public BuffCfg cfg;
protected int buffId;
protected object[] args;

// 群体buff作用目标列表
protected List<MainLogicUnit> targetList;

public override void LogicInit() {
cfg = ResSvc.Instance().GetBuffConfigById(buffId);
// ...
base.LogicInit();
}

public override void LogicTick() {
base.LogicTick();
switch(unitState) {
case SubUnitState.Start:
Start();
// buffDuration: buff持续时间(不包含delay)0:生效1次,-1:永久生效
// 根据buffDuration来切换状态,永久or循环的切换到Tick,生效1次得直接进入End状态
break;
case SubUnitState.Tick:
// 频率触发型buff需要按照频率来Tick,比如点燃
if(cfg.buffInterval > 0) {
tickCount += Configs.ServerLogicFrameIntervelMs;
if(tickCount >= cfg.buffInterval) {
tickCount -= cfg.buffInterval;
Tick();
}
}
// 根据服务器所规定得逻辑帧得时长,来进行每一次循环得时长计算
durationCount += Configs.ServerLogicFrameIntervelMs;
if(durationCount >= buffDuration && buffDuration != -1) {
unitState = SubUnitState.End;
}
break;
}
}

protected override void Start() { }
protected override void Tick() { }
protected override void End() { }
}

用于替换技能的特别buff

前面提到的亚瑟技能1实现得具体buff,进行技能之间的替换

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class CommonModifySkillBuffCfg : BuffCfg {
public int originalID;
public int replaceID;
}

public class CommonModifySkillBuff : Buff {
public int originalID;
public int replaceID;
private Skill modifySkill;

public override void LogicInit() {
base.LogicInit();
// Cfg给originalID、replaceID赋值,owner.SkillArr找到对应技能给modifySkill赋值
}

protected override void Start() {
base.Start();
// 开始时替换
modifySkill.ReplaceSkillCfg(replaceID);
modifySkill.SpellSuccCallback += ReplaceSkillReleaseDone;
}

void ReplaceSkillReleaseDone(Skill skillReleased) {
if(skillReleased.cfg.isNormalAttack) {
unitState = SubUnitState.End;
}
}

// 情况1.如果上次成功释放普攻就会被Skill.SpellSuccCallback()调用到这里
// 情况2.如果buff时间过了,会在Buff.LogicTick()中得计时结束进入End状态被调用到这里
protected override void End() {
base.End();
// 结束时替换回来
modifySkill.ReplaceSkillCfg(originalID);
modifySkill.SpellSuccCallback -= ReplaceSkillReleaseDone;
}
}

// 技能替换得接口:
public class Skill{
/// 技能替换,其实就是把Skill构造时做的事重做一遍
public void ReplaceSkillCfg(int replaceId) {
skillCfg = ResSvc.Instance().GetSkillConfigById(replaceId);
spellTime = skillCfg.spellTime;
skillTime = skillCfg.skillTime;
if(skillCfg.isNormalAttack) {
owner.InitAttackSpeedRate(1000 / skillTime);
}
}
}

人物状态:沉默、眩晕、击飞

比如亚瑟1技能的沉默,技能是附加了沉默buff,buff那块只要对 英雄/小兵 脚本的对应字段进行改变就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/// <summary>
/// 单体沉默buff:亚瑟技能1附带的buff
/// </summary>
public class SilenseBuff_Single: Buff {

protected override void Start() {
base.Start();
owner.SilenceCount += 1;//1秒沉默
}

protected override void End() {
base.End();
owner.SilenceCount -= 1;//buff.duration字段设置了为1秒,所以1秒后会执行这句,沉默期结束
}
}

对应的 英雄/小兵 的接口:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/// <summary>
/// MainLogicAttributes
/// </summary>
public partial class MainLogicUnit
{
// -------------沉默:沉默时无法施放技能-------------
int silenceCount;

public int SilenceCount {
get {
return silenceCount;
}
set {
silenceCount = value;
if(IsSilenced()) {
// 1.进入沉默状态并触发OnStateChange
OnStateChange?.Invoke(StateEnum.Silenced, true);
}
else {
OnStateChange?.Invoke(StateEnum.Silenced, false);
}
}
}

bool IsSilenced() {
return silenceCount != 0;
}
}

// 上面的接口是 OnStateChange += UpdateState,看下UpdateState代码
public class MainViewUnit{
public void UpdateState(StateEnum state, bool show) {
if(state == StateEnum.Knockup
|| state == StateEnum.Silenced
|| state == StateEnum.Silenced) {
if(mainLogicUnit.IsPlayerSelf() && show) {
playWindow.SetAllSkillForbidState();// 禁止所有技能的点击
}
}

hpWindow.SetStateInfo(mainLogicUnit, state, show);// 在ui血条上显示状态
}
}

为目标标记上受击标记

其实就是MarkBuff,类似于亚瑟技能1的标记buff:标记目标,持续5秒,技能和普攻会对标记目标可额外造成目标最大生命1%的伤害。

使用对Onhurt事件委托链添加委托实现。

调用回溯:from serive > InputKey() > MainLogicSkill.InputSkillKey() >Skill.ReleaseSkill() > Skill.CalcSkillAttack() > Skill.HitTarget() >target.CreateSkillBuff() > ArthurMarkBuff new()

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
27
28
29
30
31
32
public class ArthurMarkBuffCfg : BuffCfg
{
public int damagePct;
}

public class ArthurMarkBuff : Buff
{
PEInt damagePct;
MainLogicUnit target;

public override void LogicInit() {
base.LogicInit();

ArthurMarkBuffCfg ambc = cfg as ArthurMarkBuffCfg;
damagePct = ambc.damagePct;
target = skill.lockTarget; // 为target赋值
}

protected override void Start() {
base.Start();
target.OnHurt += GetHurt;
}

void GetHurt() {
target.GetDamageByBuff(damagePct / 100 * target.ud.unitCfg.hp, this, false);
}

protected override void End() {
base.End();
target.OnHurt -= GetHurt;
}
}

为目标标记一个立场标记,友军群体加速

亚瑟技能1标记,会让附近 range<5f 的友军会增加10%的移速。这像是一个立场,需要按照服务端的逻辑帧逐帧计算敌人是否在范围内从而为其加速。

核心是维护一个targetList:使用类似于前面写的通用的寻找目标实现查找算法,逐帧计算附近 range<5f 的对象,将其加入到targetList队列中。

下面对Buff整体生命流程中,仅对于核心targetList的处理与使用进行展示:

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
27
28
29
30
31
32
33
public class MoveSpeedBuff_DynamicGroup: Buff {
// 设定的buff加速量,亚瑟技能1标记buff为10%
private PEInt speedOffset;
public override void LogicInit() {
...
targetList = new List<MainLogicUnit>();
targetList.AddRange(CalcRule.FindMulipleTargetByRule(owner, cfg.impacter, skill.skillArgs)); // 开始时计算目标队列
}

protected override void Start() {
...
ModifyTargetsMoveSpeed(speedOffset, true);
}

protected override void Tick() {
...
ModifyTargetsMoveSpeed(-speedOffset);

targetList.Clear();
targetList.AddRange(CalcRule.FindMulipleTargetByRule(owner, cfg.impacter, CodingKVector3.zero)); // 逐帧计算目标队列
ModifyTargetsMoveSpeed(speedOffset);
}

protected override void End() {
...
ModifyTargetsMoveSpeed(-speedOffset);
targetList.Clear();
}

void ModifyTargetsMoveSpeed(PEInt value, bool showJump = false) {
// targetList => Move speed up
}
}

自动寻找目标攻击的通用buff

实现功能示意图:

image-20220104233959056

部分代码演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Buff配置
var MoveAttackBuff = new BuffCfg() {
{
//通用buff属性
buffId = 999999,
buffName = "移动攻击",
buffType = BuffTypeEnum.MoveAttack,

attacher = AttachTypeEnum.Caster,// buff附着给自己
impacter = null,

buffDelay = 0,
buffInterval = 66,// 1帧检测1次
buffDuration = 5000,// 单次持续时间
};
...

// Buff逻辑
public class MoveAttackBuff: Buff {

}

查找算法示意图:

后裔的被动技能

普攻成功后增加攻速

后裔被动,每次平砍增加5%攻速,最高3次,持续3秒。如果释放2、3技能(1技能强化普攻可以)就直接失效。

1
2
3
4
5
public class HouyiPasvAttackSpeedBuffCfg : BuffCfg {
public int overCount;// 叠加层数
public int speedAddtion;// 加成百分比
public int resetTime;// 重置周期
}

buff代码就不贴了,具体逻辑是 buff类 给技能释放成功的回调注册一个事件,事件实现技能效果。

普攻3次后变成3连击

由于技能会跳转(如图),所以不能使用前面制作的替换技能buff,那个太简单了。

Bullet

SweepVolume 体积扫描检测

用于逻辑帧计算子弹是否命中用的。记录上一帧的位置,与这一帧的位置连线,它的轨迹是一个矩形(粉色),查看矩形是否穿过目标碰撞体(蓝色),也就是矩形与圆形求相交的问题。

  1. 计算 AB中点位置 = (LogicPos + lastPos) / 2
  2. 计算 向量A->B = LogicPos - lastPos
  3. 由1与2模拟出 矩形碰撞体CodingKBoxCollider
  4. 由3创建出的矩形,与目标的 圆形碰撞体CodingKCylinderCollider 进行

具体怎么创建碰撞体,是自制的定点数物理碰撞计算库,在其他文章中详解。

Debug 弹道显示方案

想要debug看到弹道规矩,可以在每一个Tick里创建一个Cube模拟矩形,也就是子弹轨迹。

子弹Config

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class BulletCfg {
public BulletTypeEnum bulletType;
public string bulletName;
public string resPath;
public float bulletSpeed;//运算时转换为PEInt
public float bulletSize;
public float bulletHeight;
public float bulletOffset;//画面表现用的偏移值
public int bulletDelay;//释放延迟 ms
public bool canBlock;//技能是否不能穿透

public TargetCfg impacter;//目标类型
public int bulletDuration;//持续时间(不含bulletDelay)
}

public enum BulletTypeEnum {
UIDirection,//ui指定方向
UIPosition,//ui指定位置
SkillTarget,//当前技能目标(锁定目标技能)
BuffSearch,//靠近物体自动寻找
}

实现子弹曲线

图像分解

![image-20220115105710617](C:\Users\YAN JUNJIE\AppData\Roaming\Typora\typora-user-images\image-20220115105710617.png)

理解

我 -> 目标 连线的向量,是最初的弹道向量,也就是一开始的红色向量。

对 最初的弹道向量 + 我的中心点,可以取出一个唯一的垂直平面,对这个垂直平面上,以我的中心点为起始点,任取一个模(偏移量)数值随机的向量,作为偏移向量,也就是绿箭头。

绿箭头的模确定后就不再改变,持续施加,称作固定值影响向量

而红色向量始终指向目标,所以会不停改变方向,称作方向矫正向量

最终,使用 固定值影响向量、方向矫正向量 的向量之和,也就是某个时间点下子弹的方向向量了。

实现

实际就并不取用这么复杂过程了。

① 固定值影响向量

取 我->目标 的向量 + 我的up方向向量 的叉乘Cross向量,然后规格化,就得到了准确的方向向量。

想要往上,只要对结果添加up方向上的向量就可以。当然,这需要一个随机数。

随机数

因为同步问题,不能只使用随机,所以通过传播随机种子来确定性地随机,让多个端末都能正确计算出同样的效果。

然后再偷个懒,种子也不传播了,固定666。

Bug & QA

移动攻击Bugs

1.攻击动画没播放是因为移动攻击时方向变更,导致动画状态立即恢复“free”从而导致攻击动画被跳过了。

解决方案是方向变更时,将是否在技能前摇flag一起加入判定条件,来决定是否变化动画。

2.人物朝向没变化也是因为方向被UI输入朝向重制了,关键是看ViewUnit.viewTargetDir属性。

解决方案一样,将是否在技能前摇flag作为判定条件来决定是否根据UI改变朝向。

3.移动攻击时会滑动是因为UI输入方向被服务端传来的最新UI移动请求给覆盖了,关键看MainLogic.InputDir属性。

解决方案是在接收到服务端传来的最新UI移动请求时,进行是否在技能前摇、是否被控制flag作为判定条件来决定是否改变InputDir。

4.移动攻击完会僵直平移一段距离是因为技能前摇时有一个定时器,根据技能总时长来将角色设回“free”动画。

解决方案是在收到服务器逻辑方向改变请求改变LogicUnit.LogicDir属性时,将过去的定时器动画Callback都清除掉。