背景
我曾经为两款游戏开发战斗技能系统,使用的是传统OOP的方式,大概设计是有个基类BaseAbility,里面有一些基础的通用逻辑,然后不同的技能实现在不同的子类中。2020年我们立了一个新项目,策划告诉我这个项目以英雄战斗技能为主,可能多达500个技能。
过去两个项目中基于OOP的战斗系统已经让我陷入纠结和痛苦之中,因为一直在为类继承体系而纠结,虽然继承可以提高复用性,但也大大增加耦合性和复杂性,到了后期子类爆炸,逻辑在不同类中来回穿梭,属性计算也在庞大的类继承体系中迷失。
为了让战斗技能系统变的简单清晰,这个项目我准备用一种全新的基于DSL的数据驱动式的战斗技能系统,战斗框架采用ECS架构。数据驱动具体来讲是基于DSL来表述一个技能的全部逻辑,DSL在维基百科中的定义:
领域特定语言(英语:Domain-specific language,缩写:DSL),也称为特定域语言,是专门针对特定应用领域的计算机语言,和可以用在多种领域的通用语言(GPL)恰好相反。像HTML专门用在网页设计上,就属于领域特定语言。
战斗系统代码负责解析执行DSL中的逻辑,ECS是为了更好的实现逻辑和表现层分离,因为我们战斗系统是同一套代码同时运行在客户端和服务端,至于ECS带来的性能上的收益则是额外收获。
技能DSL设计
在参考了Dota2的技能描述语言后,我决定设计一套更简洁,功能更丰富的DSL来表达技能的配置系统。这套DSL必须足够简单,策划容易阅读和编写,不需要太多专业编程知识。经过研究,我发现yaml格式就可以很好的作为一种DSL的载体来定义技能。最终用这套系统实现了700多个技能,开发技能效率很高,上线后效果也不错。 后期基本是策划在做技能,程序偶尔辅助下。
先看个近战普攻技能示例:
# MeleeBasicAttack.yaml
# 近战普通攻击技能模板
# 技能每个等级的伤害数值
DamageTable:
Ability:
Type: PHYSICAL
Levels:
- 140% * $ATK + 600
- 280% * $ATK + 6000
- 320% * $ATK + 21000
- 400% * $ATK + 30000
# 技能主动作序列
MainActions:
- PlayBasicAttackAnim:
Sequence:
- Attack1: 3
- Attack2: 1
- WaitCastPoint:
Duration: $CAST_POINT
- PlaySound:
Name: $CAST_SOUND
- PlayVfx:
Target: $TARGET
Prefab: Battle/Battlefield/VFX_General_AttackHit
- AbilityApplyDamage:
Target: $TARGET
- PlaySound:
Target: $TARGET
RandomRange:
- SFX_HIT_TARGET_1
- SFX_HIT_TARGET_2
- WaitRecoverTime:
Duration: $RECOVER_TIME
第一段DamageTable用来定义技能的伤害,因为我们游戏技能可以升级所以写了4个等级的伤害,每一行都是一个伤害计算公式,公式里有常量和变量引用,上面技能DSL里$ATK用来引用英雄当前的物理攻击力,技能执行Runtime会动态替换里面的变量,上面技能1级伤害是140%的英雄物理攻击 + 600点伤害,后几级以此类推。DamageTable是一个Map结构,技能伤害的Key是Ability,技能可以有多种伤害,在DamageTable定义多个伤害配置。
第二段MainActions定义了技能的主动作序列,为什么这里叫主动作序列呢?因为还可以定义平行的子动作序列,整个技能动作序列就像Unity的Timeline一样有多个轨道,主动作序列可以认为是第一个轨道。
MainActions里面定义多个技能Action,每个Action都执行一个特定的行为,每个Action都一个Duration属性,代表这个Action执行多长时间,同一个Action序列中Action串型执行,上面近战普攻技能执行了7个动作:
PlayBasicAttackAnim: 播放普攻动画,并在Sequence中多个动画中随机选择一个,每个动画名字后面的数字是权重,为了实现近战普攻有多种攻击动画按权重随机播放。WaitCastPoint:等待技能前摇点,WaitCastPoint就是单纯的等待技能前摇时间结束,$CAST_POINT是个变量,每个英雄的前摇时间不同,而且会跟随攻击速度而动态改变,所以这里是个变量引用,实际值定义在另外一张配置表中,战斗中会动态计算(受攻速影响)。PlaySound:播放声音,$CAST_SOUND也是个变量,引用另外配置表中的配置的攻击声效。PlayVfx:播放特效,$TARGET变量表示特效在敌方身上,如果是$SELF特效挂自己身上。AbilityApplyDamage:对目标造成伤害,会引用DamageTable.Ability.Levels里的公式。PlaySound:在目标身上播放受击声音,在RandomRange里随机选择一个音效。WaitRecoverTime:等待后摇结束,$RECOVER_TIME后摇实际也是变量,运行时动态计算。
技能动作
每个技能yaml配置文件的核心是技能动作序列(Actions),每个技能可以有多个动作序列,默认会运行名字为MainActions的动作序列。每个Action执行特定的行为,并等待一段时间(Duration)再执行下个动作。
技能动作序列有点类似Unity的Timeline中的一个轨道,但又有本质的区别:技能动作序列有基础的编程逻辑行为,例如分支跳转,循环,变量读取和写入
MainActions相当于程序的main函数,其中可以调用其他子函数(SubActions)。
接下来我们看一个技能的实现:
熔岩巨龙对随机敌方单位连续发射(2/3/4/5)颗火球,每颗火球在命中敌人时对其周身范围内(半径8+)的单位造成(20%/30%/40%/50%)魔法攻击力+(60/200/600/2000)点魔法伤害
# 熔岩巨龙 - 火球攻击技能
DamageTable:
Ability:
Type: MAGICAL
Levels:
- 20% * $MATK + 60
- 30% * $MATK + 200
- 40% * $MATK + 600
- 50% * $MATK + 1000
DataTable:
FireballCount: [2,3,4,5] # 每个技能等级发射火球数量,随等级提升数量
FireballRadius: [8,9,10,11] # 每个技能等级火球伤害半径,随等级提升范围
MainActions:
- PlayAnim:
Name: $SKILL
- WaitCastPoint:
Duration: $CAST_POINT
- StartSubActions:
BranchName: FireballProjectiles
- WaitRecoverTime:
Duration: $RECOVER_TIME
SubActions:
FireballProjectiles:
- FindSkillTarget:
Team: $ENEMY
MaxCount: 1
- RotateTo:
Target: $SKILL_TARGET
- LoopStart:
Count: ${L:FireballCount}
- CreateProjectile:
Target: $SKILL_TARGET
ProjectileName: FireballBullet
- PlaySound:
SfxId: SKILL_SFX_1001_FIREBALL
- LoopEnd:
Duration: 1
Projectiles:
FireballBullet:
ProjectileType: TRACKING
EffectName: Battle/Hero/FireDragon/FX_Bullet1
MoveSpeed: 80
OnProjectileHit:
- PlayEffect:
Target: $TARGET
Prefab: Battle/Hero/FireDragon/FX_Hit1
- PlaySound:
SfxId: GOLDEN_BUG_SKILL_SFX_PASSIVE1_BULLET_HIT
- FindSkillTargetInRange:
Team: $ENEMY
Circle:
Radius: ${L:FireballRadius}
Center:
Target: $TARGET
- ApplyAbilityDamage:
Target: $SKILL_TARGET
第一段DamageTable是技能伤害数值表,上面已经解释过了,这个技能伤害类型是魔法伤害(Type: MAGICAL)。
第二段DataTable用来定义Action里引用到的数值,这个技能里随等级提升的数值是火球数量(FireballCount)和火球伤害范围(FireballRadius)。
第三段就是核心主技能序列了,其中有一个新Action:StartSubActions,启动一个子动作序列,相当于main函数调用了其他函数。
第四段是SubActions的定义,一个技能可以有多个子动作序列,这里的子动作序列名是FireballProjectiles,用来发射火球,下面是每个Action的解释。
- FindSkillTarget:随机查找目标,最大数量1,这个技能是随机选择目标发射火球,所以每次发火球前都要先找一个目标,找到的目标会存在
$SKILL_TARGET变量中。 - RotateTo:让单位面朝目标,这里会访问变量
$SKILL_TARGET。 - LoopStart: 循环开始,相当于编程语言的
for(int i = 0; i < FireballCount[$skillLevel]; i++),这里有个变量访问的新方式${L:FireballCount},L表示访问的变量是数组,索引是当前技能等级。 - CreateProjectile:创建投射物,投射物的定义名字是FireballBullet,在第五段Projectiles找定义,发射目标是
$SKILL_TARGET - PlaySound:播放发射火球音效
- LoopEnd:循环结束
第五段Projectiles用来定义该技能的所有抛射物定义,可以有多个,每个名字各不同。这里FireballBullet的定义:
- ProjectileType:TRACKING(追踪型子弹),DIRECTIONAL(直线型子弹)
- EffectName:特效prefab的路径
- MoveSpeed:子弹飞行速度
- OnProjectileHit:当子弹到达目标后的行为:
- PlayEffect:在目标身上播放击中特效,prefab路径在EffectName里配置
- PlaySound: 在目标身上播放击中音效
- FindSkillTargetInRange:以击中目标为中心半径
FireballRadius[$skillLevel]范围内搜索敌方单位存储在变量$SKILL_TARGET里 - ApplyAbilityDamage:对上一步找到的目标应用伤害,伤害数值读取DamageTable
从这个技能实现可以看出,这套基于yaml的技能DSL描述语言已经初步具备了编程的基础功能:变量,函数,跳转,循环。他的设计目标是让策划自己就可以实现技能,我们程序大概开发了300+技能Action的实现,基本涵盖MOBA游戏中大部分技能逻辑。
如果还是不能满足一些特定技能的复杂需求,我们有一个Action叫RunScript可以运行一段Lua脚本,实现一些高阶复杂逻辑技能也完全没有问题。
Buff/Debuff
Buff/Debuff系统在战斗中非常庞大复杂,我也花了大量时间来实现,限于篇幅这里只简单谈谈。来看某个技能的实现片段:该技能的子弹击中目标后会给目标带上一个Debuff:移动速度降低50%,持续3秒。
Projectiles:
PotionBullet:
ProjectileType: TRACING
EffectName: Battle/Hero/XXX/FX_Passive1_Bullet
MoveSpeed: 40
OnProjectileHit:
- PlayEffect:
Target: $TARGET
EffectName: Battle/Hero/XXX/FX_Passive1_Hit
- ApplyModifier:
UnlockLevel: 3
Target: $TARGET
ModifierName: PotionDebuff
Modifiers:
PotionDebuff:
Duration: ${L:PotionDebuffDuration}
Flags:
- DEBUFF
Icon:
IconId: EFFECT_ICON_MOVESPEED_DOWN
AddAttributes:
MOVE_SPEED_BONUS: ${L:PotionDebuffMoveSpeedDown}
第一段Projectiles技能子弹定义前面已经介绍过了,这里OnProjectileHit后有个新的Action:ApplyModifier,给目标加一个Modifier(修改器)。
Modifier顾名思义是对单位某些属性的修改,例如:加速/减速,加攻/减攻。没有直接叫Buff是因为Modifier系统远比Buff更复杂更强大,例如有个技能可以将敌方单位击退5码就是用Modifier来实现的。
上面ApplyModifier有个属性UnlockLevel,它的含义是这个Modifier只有在技能升级到3级才解锁。ModifierName属性用来从Modifiers里查找Modifier定义。
第二段Modifiers用来定义该技能产生的所有Modifier,跟Projectiles类似可以定义多个,用名字做区分。来看PotionDebuff的定义:
- Duration:该Buff持续时间。
- Flags:Buff可以定义一些Flag在身上,例如有个净化技能:移除英雄身上所有负面效果,就是通过搜索含有NEGATIVE flag的Modifier来移除。
- Icon:中了该Buff会在单位头上显示一个Icon图标,这里填图标ID
- AddAttributes:用来增加属性值,我们游戏中定义了几十个属性这里填属性ID后面的修改的数值。
除了AddAttributes是增加属性值,还有BonusAttributes是用百分比增加属性值,还有OverrideAttributes用来重写属性值。
Modifer系统远比上面描述的复杂,这里就不展开多写了,有空单独再写一篇。
代码实现
这里只简单谈谈代码实现:整套战斗系统使用ECS架构,使用逻辑与表现分离设计,数值计算使用定点数,目的是战斗系统同时跑在客户端和服务端并且计算结果完全一致。使用ECS的原因也是方便实现客户端和服务端同步,表现层的Component只存在于客户端,逻辑层Component两端同时用。yaml中表现层技能Action只在客户端有效,例如播放动画,特效,声音,而逻辑层技能Action两端同时跑。
等有时间再单独写一遍代码实现,里面有很多精彩的东西!