Skip to content

引言

BattleSystem-ECS 塔防项目里实现了一个精简版的 GAS(Gameplay Ability System)模块。最初是参照 Unreal Engine 的 GAS 设计思想做的——技能定义(Ability)+ 效果(Effect)+ 属性集(Attribute),但砍掉了 UE 里那些重度的标签系统和预测机制,只保留塔防场景下真正有用的部分。

这篇文章记录 GAS 三个核心模块的设计、实战中遇到的工程问题、以及从技能硬编码到配置驱动的演进过程。

一、为什么需要 GAS

塔防游戏里的技能系统看起来简单——火球打一片、冰冻减速、治疗回血。但如果每个技能都写一个独立的方法,很快就失控了:

CastFireball()   → 圆形 AOE + 伤害
CastFrostNova()  → 圆形 AOE + 冰冻
CastHeal()       → 友军回血
CastShield()     → 护盾
CastPoison()     → 圆形 AOE + 持续伤害(DoT)
...

每个技能的参数(范围半径、伤害值、冷却时间、持续时间)散落在各自的实现里,改一个数值要翻代码,加一个新技能要复制粘贴一百行。

GAS 的思路是把"技能能做什么"抽象成配置驱动的能力定义,把"技能怎么影响世界"抽象成效果链。加一个新技能只需要配 JSON,不改代码。

二、三层架构

GameplayAbilityDef    — 技能定义(冷却、消耗、范围形状、效果参数)

GameplayEffectDef     — 效果定义(类型、属性修改、持续时间、叠加方式)

AttributeSetDefinitions — 被修改的属性集合(血量、攻击、暴击…)

1. GameplayAbilityDef(技能定义)

每个技能是一个纯数据结构,不包含运行时状态:

csharp
public struct GameplayAbilityDef
{
    string Name;            // 技能名称
    float Cooldown;         // 冷却时间(秒)
    float Cost;             // 资源消耗
    int AreaShape;          // 范围形状:0=单体 1=十字 2=方形 3=圆形 4=链式 5=治疗 6=护盾 ...
    int AreaRadius;         // 范围半径(格)
    float HealPercent;      // 治疗比例
    float ShieldAmount;     // 护盾值
    float DotDuration;      // DoT 持续时间
    float DotTickInterval;  // DoT 每跳间隔
    float DotDamagePerTick; // DoT 每跳伤害
    // ...
}

AreaShape 是核心驱动力——它决定了 SkillSystem 怎么选择目标:

AreaShape用途目标选择逻辑
Single(0)单体攻击选最近/血量最低的敌人
Cross(1)十字范围以目标为中心的十字形(上下左右)
Box(2)方形范围以目标为中心的矩形区域
Circle(3)圆形 AOE半径内的所有敌人
Chain(4)链式闪电主目标 + 依次跳到最近的 3 个目标,每次衰减 30%
Heal(5)治疗射程内受伤最重的友军
Shield(6)护盾给友军套盾吸收伤害
Freeze(8)冰冻 Nova圆形 AOE + 按概率冻结
Cone(9)扇形(龙息类)前方一定角度和半径的扇形区域

2. GameplayEffectDef(效果定义)

效果分四种类型:

  • Instant:立即生效(比如直接扣血),不进入 Buff 列表
  • Duration:持续一段时间,到期自动移除(比如攻击力 +50%,持续 5 秒)
  • Periodic:类似 DoT,每个 tick interval 执行一次(比如"每秒造成 8 点伤害,持续 5 秒")
  • Heal:治疗后即移除

叠加行为的定义:

csharp
public enum StackingBehavior
{
    None = 0,             // 不叠加,新效果覆盖旧效果
    DurationRefresh = 1,  // 仅刷新持续时间
    MaxStacks = 2,        // 叠加到最大层数,不刷时间
    MaxStacksRefresh = 3  // 叠加到最大层数,且每次叠加刷新持续时间
}

这个枚举看起来简单,但涵盖了大部分游戏中的 Buff 行为。比如一个"每层增加 5% 攻速"的被动——用 MaxStacksRefresh,每次触发叠一层、刷新计时器。如果策划要改"只叠层不刷新",改成 MaxStacks 就行了,不用动代码。

3. AttributeSetDefinitions(属性集)

属性集是被效果修改的目标。BattleSystem-ECS 里属性很精简:

玩家属性:ATTACK_DAMAGE / ATTACK_RANGE / MAX_HEALTH / GOLD / CRIT_RATE / BUFF_STRENGTH
敌人属性:ENEMY_HEALTH / ENEMY_DAMAGE / ENEMY_GOLD_REWARD

属性和效果的组合通过 AttributeModifierOp 控制:

  • Add:加法修改(基础伤害 +20)
  • Multiply:乘法修改(暴击率 ×1.5)
  • Override:覆盖型修改(直接设为某个值)

这三个操作符可以组合出绝大多数游戏中的数值效果。比如一个"攻击力提升 20%"的 Buff = AttackDamage * 1.2 (Multiply);一个"固定增加 10 点攻击"的 Buff = AttackDamage + 10 (Add)

4. 运行时技能实例

技能定义是静态的,运行时有一个 AbilityInstance 包裹它:

csharp
public struct AbilityInstance
{
    GameplayAbilityDef Definition;
    float CurrentCooldown;     // 当前冷却剩余

    bool CanActivate() => CurrentCooldown <= EPSILON;
    void Activate() { CurrentCooldown = Definition.Cooldown; }
}

每个技能槽位存一个 AbilityInstance。每帧 SkillSystem 递减冷却时间,CanActivate() 返回 true 时玩家可以按快捷键施放。

三、从硬编码到配置驱动的演进

最初版本的 SkillSystem 是硬编码的:

csharp
if (skillName == "Fireball")      CastFireball();
if (skillName == "FrostNova")     CastFrostNova();
if (skillName == "Heal")          CastHeal();

每加一个新技能需要:加一个方法、加一个 if 分支、加一个 case、改 CoolDown 表、改伤害表...这个模式不可持续。

重构后,核心变成两件事:

  1. JSON → GameplayAbilityDefskills.json 定义每个技能的参数,GameConfigLoader 在启动时解析为 AbilityDef 数组。

  2. SkillSystem.CastSkill() → 按 AreaShape 分发。施放时不需要知道技能叫什么名字,只需要知道它的 AreaShape 类型和参数。Switch 从 N 个技能分支缩减为 10 个 AreaShape 分支(无论有多少个技能,都是这 10 种形状之一)。

一个实际的 JSON 技能定义例子:

json
{
  "name": "Chain Lightning",
  "cooldown": 3.0,
  "cost": 25,
  "area_shape": "chain",
  "area_radius": 2,
  "fixed_base_damage": 15,
  "dot_duration": 0,
  "description": "闪电链:打击目标后弹射至多 3 个邻近敌人"
}

加技能变成纯数据工作。这对塔防这种 150+ 技能的项目来说,是唯一的可维护方式。

四、几个工程上的决策

1. 护盾系统精简化

完整 GAS 里护盾是一个独立的 GameplayEffect,有叠加层数、不同类型护盾的优先级吸收顺序等。BattleSystem-ECS 里做了大幅简化:

  • 护盾是一个 float 值叠在 PlayerShieldAmount[playerId]
  • 受到伤害时优先扣护盾(DecreasePlayerHealth → 先扣 Shield,剩余穿透到 HP)
  • 护盾有持续时间,到期自动清零

这个简化牺牲了"不同类型护盾叠加"的灵活性,但塔防场景下根本不需要这个。不存在的需求,不需要预留接口。

2. 冰冻共享 Stun 的底层

冰冻(Freeze)和眩晕(Stun)在底层完全共享同一套基础设施——同样的 EnemyStunDurationLeft 数组、同样的 IsEnemyStunned() 判定、同样的 EnemyAISystem 跳过逻辑。

csharp
public void ApplyEnemyFreeze(int enemyId, float duration)
{
    ApplyEnemyStun(enemyId, duration);  // 复用 Stun 底层
}

public bool IsEnemyFrozen(int enemyId)
{
    return IsEnemyStunned(enemyId);     // 别名
}

从代码角度这是个"偷懒"的简化,但从设计角度它的合理性在于:冰冻和眩晕在塔防中的行为表现完全一致(敌人不能移动、不能攻击),只是在表现层有差异(蓝色冰块 vs 金色星星)。如果以后需要差异化(比如冰冻的敌人被打碎 = 即死、眩晕的敌人可以被打醒),这个决策会需要返工 —— 但那是未来的问题。当前的简化是合理的,别过度设计。

3. DoT 伤害的两阶段安全

SkillSystem 的 DoT 伤害也是通过两阶段模式处理的(和攻击伤害一样):

并行段:skillSystem.Update() → 遍历所有 active DoT → tick → 收集 (enemyId, dotDamage) → ConcurrentBag
串行段:帧末 resolve → enemyHealth -= dotDamage → 检查死亡

不因为"伤害量小"就放松并行安全约束——DoT 叠加、多技能同时 tick 都可能产生竞争。

五、对做自己游戏的人的实用建议

  • 如果你是一个人在做游戏,不要一上来就实现完整的 GAS。先用几个硬编码技能把战斗循环跑通,等你发现维护成本超过重构成本的时候(通常在第 5~10 个技能时),再抽象。
  • GAS 的核心价值不是"完整",而是"配置驱动"。只要能做到加技能不用改代码(只改 JSON),就已经是成功的抽象了。
  • 属性集别做太多。7 个玩家属性 + 3 个敌人属性足够塔防跑起来了。加一倍的属性意味着效果系统复杂度和 bug 数量的指数增长。

参考文献