引言
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(技能定义)
每个技能是一个纯数据结构,不包含运行时状态:
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:治疗后即移除
叠加行为的定义:
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 包裹它:
public struct AbilityInstance
{
GameplayAbilityDef Definition;
float CurrentCooldown; // 当前冷却剩余
bool CanActivate() => CurrentCooldown <= EPSILON;
void Activate() { CurrentCooldown = Definition.Cooldown; }
}每个技能槽位存一个 AbilityInstance。每帧 SkillSystem 递减冷却时间,CanActivate() 返回 true 时玩家可以按快捷键施放。
三、从硬编码到配置驱动的演进
最初版本的 SkillSystem 是硬编码的:
if (skillName == "Fireball") CastFireball();
if (skillName == "FrostNova") CastFrostNova();
if (skillName == "Heal") CastHeal();每加一个新技能需要:加一个方法、加一个 if 分支、加一个 case、改 CoolDown 表、改伤害表...这个模式不可持续。
重构后,核心变成两件事:
JSON → GameplayAbilityDef。
skills.json定义每个技能的参数,GameConfigLoader 在启动时解析为 AbilityDef 数组。SkillSystem.CastSkill() → 按 AreaShape 分发。施放时不需要知道技能叫什么名字,只需要知道它的 AreaShape 类型和参数。Switch 从 N 个技能分支缩减为 10 个 AreaShape 分支(无论有多少个技能,都是这 10 种形状之一)。
一个实际的 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 跳过逻辑。
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 数量的指数增长。
参考文献
- Unreal Engine Gameplay Ability System
- GitHub — BattleSystem-ECS(个人项目,本文相关内容来自此仓库)
- 本项目文档 [docs/architecture.md]