战斗系统

12k words

Desc:

记录战斗系统的实现

AOI

主要解决游戏中多人同屏的问题

目标离你的距离远超你九宫格的距离,你依然要将他显示出来。那这个时候该怎么办呢?
所以,我们顺其自然地有了一个新的概念——“关注者“,AOI搜寻到的列表并不能就完全等同于关注者列表。而客户端显示的就是关注者列表里面的内容。AOI搜寻到的列表只是用来获得关注者的手段

AOI并不需要双向连接

以游戏场景为例的话,假如周边有很多玩家的话,你不一定需要严格按照位置远近来排序,而是需要把跟你关联度最高的玩家加到你的关注者列表中

AOI的信息需要立即更新吗?不一定需要,我们未必需要时时刻刻关注离我最近的目标,附近的人这个功能里面的用户列表更新频率慢一点也没关系

技能系统

一个完整的技能系统逻辑层主要包括三个模块:技能模块,buff模块,projectile(子弹)模块

技能系统中,所有的技能里面不包含任何具体技能效果及逻辑,每个技能根据其类型提供若干抽象接口,具体执行效果由策划去配置

技能类型:被动技能\主动施法技能\开关类技能\激活类技能

被动技能

一般会在技能初始化时生效,技能初始化时会有抽象行为接口Ablilty::OnAlilityInit()
被动技能可以在这个接口中执行一些行为,具体执行的功能由策划配置,一般情况下策划会配置给角色添加Buff来监听各种事件以触发各种效果

主动技能

可以手动释放 释放前需要目标信息

情况:
不需要目标就可以释放(群体治疗)
需要选中目标(单体指向性技能)
需要以指定地点为目标(常用于AOE)

阶段:
技能起手、前摇阶段、施法阶段、后摇阶段、结束

表中配置,来控制技能参数
Bimmediately : 技能是否可强制立即释放
CastPoint : 前摇配置时长

引导阶段:
提供三个接口供策划配置逻辑:
ChannelStart (引导开始):
ChannelThink (引导持续触发):
ChannelFinish (引导结束):
提供两个时间参数以供配置:
ThinkInterval (引导触发间隔)
ChannelTIme (引导阶段总时长)

使用:
如策划配置CastPoint为0.1秒,ThinkInterval为0.3秒,ChannelTime为1.4秒时。则技能将会在AbilityStart0.1秒后调用ChannelStart,将在第0.4秒->0.7秒->1.0秒->1.3秒调用ChannelThink,将在第1.5秒调用ChannelFinish。这样的接口设计在保证简洁的情况下基本能覆盖几乎所有的需求

开关类技能:

OnAbilityToggleOn
OnAbilityToggleOff
通过调用添加、移除Buff实现具体效果

激活类技能:

OnAbilityActivate
OnAbilityDeactivate
通过调用添加、移除Buff实现具体效果

Buff

设计

Image text
Buff:所有Buff的基类,包含各类成员函数和基本接口

Modifier:继承于Buff,代表这个Buff是一个修改器,它可以用来修改当前目标的各种属性,状态等等。抽象Modifier这个类的目的是出于性能优化的考虑。因为当Buff修改角色的属性或者状态时,会导致重新计算角色的动态属性, 而在游戏中我们很多的Buff并不需要修改角色的属性状态,仅仅用来提供一段逻辑。那么如果它是一个Buff不是Modifier,就不需要重新计算角色的动态属性

MotionModifier:继承于Modifier,代表此类Buff提供修改玩家运动效果的功能。因为牵涉到与运动组件的交互,所以抽象出一个新的类

类的设计

Caster代表Buff的施加者,它有可能为空,也有可能不为空,视具体构造时是否传Caster参数而定。但是Buff有一个配置项bNoCaster(是否强制设置Caster为空)。如果bNoCaster = true。则Buff的Caster一定为空。
Caster不仅仅是一个成员项,它还关系到Buff合并问题。如果存在两个TypeId类型相同的Buff时候,当他们的Caster相同才可以走合并流程(Buff层数增加),如果Caster不同,则不能合并。
当策划有一些玩法需求可以多人给BOSS叠Buff时就可以配置Buff的bNoCaster=true,这样就不需要开发者在写代码添加Buff的时候小心翼翼的设置Caster参数为空了。另外还有几种情况也需要设置bNoCaster=true,比如存在一个熔岩地图,或者冰雪地图,玩家每秒掉多少血量,这个时候也可以配置bNoCaster=true。
再比如说一些活动buff,如双倍经验buff,红名惩罚buff,都可以由策划配置bNoCaster=true。类似于双倍经验,还有红名Buff这种所有需要存盘的Buff,我们都需要设置bNoCaster=true

Ability代表Buff是由哪个技能创建,它有可能为空,也有可能不为空,视具体构造时是否传Ability参数而定。通过Ability这个成员类型,我们就将Buff与技能联系起来了,我们能在Buff中取得技能的各种数据,通过获取技能的数据,然后由Buff来实现各种各样的技能效果

Context代表Buff创建时候的一些上下文数据,它是一个不确定的项,通过外部传入各种自定义的数据,然后在Buff逻辑中使用这些自定义数据

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69

local Buff=BaseClass("Buff")

local function __init(self)
-- 施加者
self.caster=nil
-- buff当前挂载的目标
self.parent=nil
-- 由哪个技能创建
self.ability=nil
-- 层数
self.buffLayer=nil
-- 等级
self.buffLevel=nil
-- 时长
self.buffDuration=nil
-- 标签
self.buffTag=nil
-- 免疫标签(当身上添加了其他带有免疫标签的buff,会直接免疫,并不会添加到对象身上)
self.buffImmuneTag=nil
-- 表信息
self.buffInfo=nil
-- buff产生的数值
self.buffValue=nil
end

local function Init(self,info)
assert(info,"info nil!")
self.buffInfo=info
-- 根据info初始化
end

local function __delete(self)
self.caster=nil
self.parent=nil
self.ability=nil
self.buffLayer=nil
self.buffDuration=nil
self.buffTag=nil
self.buffImmuneTag=nil
self.buffInfo=nil
end

-- 实例化之后,生效之前,未加入到buff容器中
local function OnBuffAwake(self)
-- TODO 派发事件,buff在生效前可能会被销毁
end

-- Buff生效时,加入到Buff容器后
local function OnBuffStart(self)
-- 根据配置表处理效果
-- TODO 派发事件
end

-- 添加时,存在相同类型且caster相等的时候,buff执行刷新流程(更新buff层数,等级,持续时间)
local function OnBuffRefresh(self)
-- TODO 派发事件
end

-- 还未从buff容器中移除
local function OnBuffRemove(self)
-- TODO 派发事件
end

-- 已从buff容器中移除
local function OnBuffDestroy(self)
-- TODO 派发事件
end

表转换成lua文件后的结果
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
local BuffItem = {
[2002] = {
buffId = 2002,
name = 'subAttack',
type = 'Attack',
buffTag = 2002,
BuffImmuneTag = {},
mount = false,
},
[11001] = {
buffId = 11001,
name = 'banHp',
type = 'HP',
buffTag = 11001,
BuffImmuneTag = {1001},
mount = true,
},
[2001] = {
buffId = 2001,
name = 'addAttack',
type = 'Attack',
buffTag = 2001,
BuffImmuneTag = {},
mount = false,
},
[1002] = {
buffId = 1002,
name = 'subHp',
type = 'HP',
buffTag = 1002,
BuffImmuneTag = {},
mount = false,
},
[1001] = {
buffId = 1001,
name = 'addHp',
type = 'HP',
buffTag = 1001,
BuffImmuneTag = {},
mount = false,
},
}
return BuffItem

执行流程

阶段:
1:创建前检查当前Buff是否可以创建
检测目标身上是否存在免疫该Buff的相关Buff,
如果被免疫则不会创建该Buff
2:Buff在实例化之后,生效之前 (还未加入到Buff容器中)时会抛出一个OnBuffAwake事件。如果存在某种Buff的效果是:受到负面效果时,驱散当前所有负面效果,并给自己加一个护盾,并把所有负面Buff驱散。这意味着一个Buff可能还未生效前就销毁了(小心Buff的生命周期)
3:当Buff生效时(加入到Buff容器后),我们提供一个抽象接口 OnBuffStart,由策划配置具体效果
4:当Buff添加时存在相同类型且Caster相等的时候,Buff执行刷新流程(更新Buff层数,等级,持续时间等数据)。我们提供一个抽象接口 OnBuffRefresh 由策划配置具体效果
5:当Buff销毁前(还未从Buff容器中移除),我们提供一个抽象接口 OnBuffRemove
6:当Buff销毁后(已从Buff容器中移除),提供一个抽象接口 OnBuffDestroy
7:Buff可以创建定时器,以触发间隔持续效果。通过配置表调用 StartIntervalThink 操作,提供 OnIntervalThink 抽象接口供配置具体效果
8:Buff可以通过请求改变运动来触发相应效果。通过策划配置时调用 ApplyMotion操作,提供 OnMotionUpdate 和 OnMotionInterrupt 接口供策划配置具体效果

lua代码
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
-- 检测unit身上是否有冲突的标签
local function AddCheckHandle(self,buff,unit)
assert(buff,"buff nil!")
assert(unit,"unit nil!")

local state=true;
table.walk(unit.buff_collection,function (k,v)
if v.BuffImmuneTag then
for id,tag in ipairs(v.BuffImmuneTag) do
if tag==buff.buffTag then
state= false
return
end
end
end
end)
return state
end

local function AddBuffToUnit(self,buff,unit)
assert(buff,"buff nil!")
assert(unit,"unit nil!")
local result=self:AddCheckHandle(buff,unit)
if result then
-- 更新unit的属性
unit.unit_attr:UpdateAttrExternal(buff.type,)

if not buff.mount then
return
end
if unit.buff_collection[buff.buffId]~=nil then
-- 增加层数
else
unit.buff_collection[buff.buffId]=buff
end
end
end

local function RemoveBuffToUnit(self,buff,inst)

end

Buff修改状态(ModifyState)

Buff可以通过修改状态去影响角色行为逻辑。以下列举一些最常见的状态:

  1. Stun(眩晕状态——目标不再响应任何操控)
  2. Root(缠绕,又称定身——目标不响应移动请求,但是可以执行某些操作,如施放某些技能)
  3. Silence (沉默——目标禁止施放技能)
  4. Invincible (无敌——几乎不受到所有的伤害和效果影响)
  5. Invisible (隐身——不可被其他人看见)

这些状态是高度凝练的精华,抽象到极致的代表。非常多的游戏效果实际上都是这几种状态+运动+动画的组合。这里很多开发者都会有一个设计误区就是把Buff的状态跟运动和动画耦合在一块,比如:眩晕状态一定就是播个眩晕动画,然后击退状态就是击退位移+击退动画。这样最后导致的问题就是状态膨胀,而且各种逻辑耦合,Bug频出,最后维护成本大大提高。
以Stun为例,很多人第一眼看过去就觉得它是个Debuff,是个敌人给我方加的控制Buff。实际上并非如此,Stun可以用到的地方非常多。例如有个技能是野蛮冲撞,释放后2秒内向前移动10米并将敌人推开。那这个Buff的实现就是技能Spell的时候给角色加个Buff,这个Buff会有个Stun状态同时带位移突进效果。挂上这个Buff后,技能施放后角色2秒内就不会响应角色按键移动和释放其他技能的请求了,同时往前突进的效果由Buff控制,将来处理各种位移打断效果也很方便。 再比如说有个技能叫寒冰屏障:你被一道寒冰屏障所笼罩,在十秒内不会受到任何物理和法术伤害,但这期间无法移动、攻击或施法。那这个技能的实现也很简单,就是一个十秒的Buff同时添加了眩晕和无敌这两个状态,如果还需要每秒回血,则StartIntervalThink(interval),然后OnIntervalThink的时候Heal当前角色即可。
除了各类战斗效果之外,我们的Buff甚至可以扩展到一些其他场景。比如说打BOSS前有个播过场动画的需求,此时策划希望隐藏Boss和玩家的血条和姓名。那么此时我们完全可以做个Buff,这个Buff扩展个状态HideHpBar,当有这个状态时即隐藏血条和名字就行了。而且我们还可以让这个Buff加上无敌状态,毕竟播过场动画的时候我们不希望玩家或者BOSS真的受到什么伤害。
总而言之,Buff状态除了上面提到几种高度凝练抽象的状态外,我们还可以根据具体游戏的需求去扩展各种特殊状态,以满足策划的需求,同时方便开发者管理逻辑

Buff修改属性

在游戏中Buff的添加与移除是一个频繁的过程。而玩家的属性来源有很多,如等级,装备,成就,任务,时装等等各种各样的来源。相比于Buff,这些模块修改属性的频率要远低于Buff,所以我们一般将玩家的属性划分为两层,第一层时Core(核心层),第二层是External(外部层)。Core层是玩家各个其他模块的属性总和,而External层则是Buff修改属性的总和。两者相加既为玩家的实时属性

Buff监听事件

Buff可以通过监听各类事件,执行特定逻辑或者修改事件数据来实现各种效果

最常见的事件监听一般有:
• OnAbilityExecuted,监听某个主动技能执行成功。常用于被动技能Buff,比如说角色施法时有10%概率获得30%的攻速提升。那么我们通常是Buff-A监听OnAbilityExcuted事件,然后10%概率添加Buff-B。Buff-B的作用是修改玩家属性,增加30%攻速。
• OnBeforeGiveDamage,OnAfterGiveDamage监听我方给目标造成伤害时触发。比如说对目标造成的伤害有10%概率无法被闪避,那么这个效果我们就可以通过监听OnBeforeGiveDamage的流程来实现。当执行伤害流程时,在计算伤害前我们抛出一个事件event。event里面有当前伤害数据。Buff在调用OnBeforeGiveDamage(event)时,修改event.Damage.DamageFlag |= DamageFlag_NotMiss,标注该伤害无法被闪避就行了。又或者如果有一个需求是给目标造成伤害后有10%几率触发DOT伤害效果,那么我们在OnAfterGiveDamage的时候取出event.Target并给这个目标加个DOT类Buff即可。
• OnBeforeTakeDamage,OnAfterTakeDamage监听我方受到伤害时触发。如护盾类Buff通常在OnBeforeTakeDamage的时候修改伤害数据。又或者有某些Buff在受到伤害后可以触发各类效果就可以通过监听OnAfterTakeDamage事件来触发指定逻辑。
• OnBeforeDead,OnAfterDead监听我方死亡时触发。如免疫致死效果可以通过监听OnBeforeDead事件修改角色当前的Hp>0,从而让角色提前退出死亡流程以避免死亡。死亡后触发额外效果,如爆炸或者召唤其他生物都可以通过监听OnAfterDead事件来执行。
• OnKill事件,监听我方击杀目标时触发。如当击杀目标后获得治疗效果回复即可通过监听到Kill事件时给自己加一个HOT的Buff来实现。
开发者可以通过扩展各类事件列表,让Buff通过监听对应事件就能执行任意逻辑。不需要与任何模块耦合,只需要抛出事件,监听事件,执行逻辑即可获得Buff功能上的扩展。

技能编辑器

Track赋值

轨道的名字对应Bindings的key值
脚本可以获取PlayableDirector类,通过SetGenericBinding函数赋值物体
var mDirector = new PlayableDirector;
mDirector.SetGenericBinding(“轨道名字”,”轨道绑定的物体”);
如果做攻击和被击效果的话可以直接把动作放在角色控制器里面,没必要用TimeLine实现。
的确是这样,但是如果游戏中模型动作资源太多,比如战神这款游戏,每一个精英怪都会有一个动作特写,如果一开始全部放在动画控制器中,会造成动画十分混乱。
而使用TimeLine,相当于我们动态给模型加载动画,当使用到这个动画的时候再加载它,这样就会让动画控制器非常的干净简洁

TimeLineClip赋值

动态给轨道内的Clip赋值,比如说Cinemachine的摄像机,和摄像机观察的物体、跟随的物体

待续

图形相交检测

圆和圆

圆和圆的相交
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
判断半径和距离的大小

/// <summary>
/// 圆形区间
/// </summary>
public struct CircleArea
{
public Vector2 o;
public float r;
/// <summary>
/// 判断圆形与圆形相交
/// </summary>
/// <param name="circleArea"></param>
/// <param name="target"></param>
/// <returns></returns>
public static bool Circle(CircleArea circleArea, CircleArea target)
{
return (circleArea.o - target.o).sqrMagnitude < (circleArea.r + target.r) * (circleArea.r + target.r);
}
}

Image text

圆和胶囊体

圆和胶囊体的相交
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/// <summary>
/// 胶囊体
/// </summary>
public struct CapsuleArea
{
public Vector2 X0;
public Vector2 U;
public float d;
}

/// <summary>
/// 判断胶囊体与圆形相交
/// </summary>
/// <param name="capsuleArea"></param>
/// <param name="circleArea"></param>
/// <returns></returns>
public static bool Capsule(CapsuleArea capsuleArea, CircleArea circleArea)
{
float sqrD = SegmentPointSqrDistance(capsuleArea.X0, capsuleArea.U, circleArea.o);
return sqrD < (circleArea.r + capsuleArea.d) * (circleArea.r + capsuleArea.d);
}

Image text

圆形和扇形

当扇形角度大于180度时,就不再是凸多边形,不能适用于分离轴理论。可以找出相交时圆心的所有可能区域,并把区域划分成可以简单验证的几个区域,逐个试验

划分了两个区间:
1:半径为两者半径和的扇形区间,角度方向同扇形,验证方法是 验证距离和夹角
2:扇形边为轴,圆形半径为大小组成的胶囊体空间,由于扇形的对称性,我们可以通过把圆心映射到一侧,从而只需要计算一条边

Image text

圆和扇的相交
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

/// <summary>
/// 扇形区间。
/// </summary>
public struct SectorArea
{
public Vector2 o;
public float r;
public Vector2 direction;
public float angle;
}

/// <summary>
/// 判断圆形与扇形相交。
/// </summary>
/// <param name="sectorArea"></param>
/// <param name="target"></param>
/// <returns></returns>
public static bool Sector(SectorArea sectorArea, CircleArea target)
{
Vector2 tempDistance = target.o - sectorArea.o;
float halfAngle = Mathf.Deg2Rad * sectorArea.angle / 2;
if (tempDistance.sqrMagnitude < (sectorArea.r + target.r) * (sectorArea.r + target.r))
{
if (Vector3.Angle(tempDistance, sectorArea.direction) < sectorArea.angle / 2)
{
return true;
}
else
{
Vector2 targetInSectorAxis = new Vector2(Vector2.Dot(tempDistance,
sectorArea.direction), Mathf.Abs(Vector2.Dot(tempDistance, new Vector2(-sectorArea.direction.y, sectorArea.direction.x))));
Vector2 directionInSectorAxis = sectorArea.r * new Vector2(Mathf.Cos(halfAngle), Mathf.Sin(halfAngle));
return SegmentPointSqrDistance(Vector2.zero, directionInSectorAxis, targetInSectorAxis) <= target.r * target.r;
}
}
return false;
}

Vector2.Dot(tempDistance,sectorArea.direction)
X:轴下的投影

Mathf.Abs(Vector2.Dot(tempDistance, new Vector2(-sectorArea.direction.y, sectorArea.direction.x)
Y轴下的投影

圆形和凸多边形

圆和凸边形的相交
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
/// <summary>
/// 多边形区域。
/// </summary>
public struct PolygonArea
{
public Vector2[] vertexes;
}


/// <summary>
/// 判断多边形与圆形相交
/// </summary>
/// <param name="polygonArea"></param>
/// <param name="target"></param>
/// <returns></returns>
public static bool PolygonS(PolygonArea polygonArea, CircleArea target)
{
if (polygonArea.vertexes.Length < 3)
{
Debug.Log("多边形边数小于3.");
return false;
}
#region 定义临时变量
//圆心
Vector2 circleCenter = target.o;
//半径的平方
float sqrR = target.r * target.r;
//多边形顶点
Vector2[] polygonVertexes = polygonArea.vertexes;
//圆心指向顶点的向量数组
Vector2[] directionBetweenCenterAndVertexes = new Vector2[polygonArea.vertexes.Length];
//多边形的边
Vector2[] polygonEdges = new Vector2[polygonArea.vertexes.Length];
for (int i = 0; i < polygonArea.vertexes.Length; i++)
{
directionBetweenCenterAndVertexes[i] = polygonVertexes[i] - circleCenter;
polygonEdges[i] = polygonVertexes[i] - polygonVertexes[(i + 1)% polygonArea.vertexes.Length];
}
#endregion

#region 以下为圆心处于多边形内的判断。
//总夹角
float totalAngle = Vector2.SignedAngle(directionBetweenCenterAndVertexes[polygonVertexes.Length - 1], directionBetweenCenterAndVertexes[0]);
for (int i = 0; i < polygonVertexes.Length - 1; i++)
totalAngle += Vector2.SignedAngle(directionBetweenCenterAndVertexes[i], directionBetweenCenterAndVertexes[i + 1]);
if (Mathf.Abs(Mathf.Abs(totalAngle) - 360f) < 0.1f)
return true;
#endregion
#region 以下为多边形的边与圆形相交的判断。
for (int i = 0; i < polygonEdges.Length; i++)
if (SegmentPointSqrDistance(polygonVertexes[i], polygonEdges[i], circleCenter) < sqrR)
return true;
#endregion
return false;
}



参考

AOI
技能系统
技能编辑器
技能编辑器Github
相交检测