背景

我曾经为两款游戏开发战斗技能系统,使用的是传统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个动作:

  1. PlayBasicAttackAnim: 播放普攻动画,并在Sequence中多个动画中随机选择一个,每个动画名字后面的数字是权重,为了实现近战普攻有多种攻击动画按权重随机播放。
  2. WaitCastPoint:等待技能前摇点,WaitCastPoint就是单纯的等待技能前摇时间结束,$CAST_POINT是个变量,每个英雄的前摇时间不同,而且会跟随攻击速度而动态改变,所以这里是个变量引用,实际值定义在另外一张配置表中,战斗中会动态计算(受攻速影响)。
  3. PlaySound:播放声音,$CAST_SOUND也是个变量,引用另外配置表中的配置的攻击声效。
  4. PlayVfx:播放特效,$TARGET变量表示特效在敌方身上,如果是$SELF特效挂自己身上。
  5. AbilityApplyDamage:对目标造成伤害,会引用DamageTable.Ability.Levels里的公式。
  6. PlaySound:在目标身上播放受击声音,在RandomRange里随机选择一个音效。
  7. 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的解释。

  1. FindSkillTarget:随机查找目标,最大数量1,这个技能是随机选择目标发射火球,所以每次发火球前都要先找一个目标,找到的目标会存在$SKILL_TARGET变量中。
  2. RotateTo:让单位面朝目标,这里会访问变量$SKILL_TARGET
  3. LoopStart: 循环开始,相当于编程语言的for(int i = 0; i < FireballCount[$skillLevel]; i++),这里有个变量访问的新方式${L:FireballCount},L表示访问的变量是数组,索引是当前技能等级。
  4. CreateProjectile:创建投射物,投射物的定义名字是FireballBullet,在第五段Projectiles找定义,发射目标是$SKILL_TARGET
  5. PlaySound:播放发射火球音效
  6. LoopEnd:循环结束

第五段Projectiles用来定义该技能的所有抛射物定义,可以有多个,每个名字各不同。这里FireballBullet的定义:

  1. ProjectileType:TRACKING(追踪型子弹),DIRECTIONAL(直线型子弹)
  2. EffectName:特效prefab的路径
  3. MoveSpeed:子弹飞行速度
  4. 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的定义:

  1. Duration:该Buff持续时间。
  2. Flags:Buff可以定义一些Flag在身上,例如有个净化技能:移除英雄身上所有负面效果,就是通过搜索含有NEGATIVE flag的Modifier来移除。
  3. Icon:中了该Buff会在单位头上显示一个Icon图标,这里填图标ID
  4. AddAttributes:用来增加属性值,我们游戏中定义了几十个属性这里填属性ID后面的修改的数值。

除了AddAttributes是增加属性值,还有BonusAttributes是用百分比增加属性值,还有OverrideAttributes用来重写属性值。

Modifer系统远比上面描述的复杂,这里就不展开多写了,有空单独再写一篇。

代码实现

这里只简单谈谈代码实现:整套战斗系统使用ECS架构,使用逻辑与表现分离设计,数值计算使用定点数,目的是战斗系统同时跑在客户端和服务端并且计算结果完全一致。使用ECS的原因也是方便实现客户端和服务端同步,表现层的Component只存在于客户端,逻辑层Component两端同时用。yaml中表现层技能Action只在客户端有效,例如播放动画,特效,声音,而逻辑层技能Action两端同时跑。

等有时间再单独写一遍代码实现,里面有很多精彩的东西!