用 GAS 搭一套动作游戏战斗系统:一个实战项目的设计记录
用 GAS 搭一套动作游戏战斗系统:一个实战项目的设计记录
这篇记的是一个第三人称动作游戏(下文称"本项目")用 GAS 搭战斗系统的实战设计——不讲 GAS 基础概念(那部分见 GAS 入门 与本目录其他篇),只讲落到具体战斗需求时怎么组织 ASC/AttributeSet/GE/Cue/Tag,以及那些框架文档不会告诉你的取舍。代码里的类名统一用 Combat 前缀指代项目自定义子类。
为什么选 GAS
选它的理由很实在:Fortnite、Paragon、Palia 等一批上线 UE 游戏都在用,经过了验证;以插件模块化集成、易扩展复用;自带网络复制(属性复制、客户端预测),对以后加联网友好;能吃 UE 的 Gameplay Tags 和 visual/audio cue;社区文档相对多。
代价也很实在:完善带来复杂,学习和调试成本都不低,遇到特定需求有深改 GAS 的可能。对一个战斗系统需求密集的项目,这套框架的复杂度是值得的。
ASC 与属性表设计
ASC(CombatAbilitySystemComponent)是整套的基础。所有要用 GAS 的对象都得有 ASC,它挂在 OwnerActor 上,实际作用的对象叫 AvatarActor——ASC 既可以给角色本体,也可以给 PlayerState(后者能在角色死亡后保住一部分数据)。角色间的技能交互,本质就是一个 Actor 的 ASC 作用到另一个 Actor 的 ASC 上:互相增删 Effect、触发 Cue、贴 Tag。
属性表分了层:一个基础属性表 UBasicAttributeSet 收敛所有角色的共有属性(生命值、最大生命值、伤害值、治疗值、移动速度乘数、跳跃初速度乘数),再派生出各角色特有的表。比如玩家表继承基础表、加上蓝量/最大蓝量。构造函数里用 CreateDefaultSubobject<UCombatPlayerAttributeSet>() 实例化,ASC 初始化时会去 OwnerActor 身上找 AttributeSet 注册进来。
用 AbilitySet 批量授予技能
技能不是一个个手动 give 的,而是走一张 AbilitySet 配置表批量授予。遍历表、拿每个技能的 CDO、填公共参数、再 GiveAbility:
// 必须是 authoritative 才能增删 ability set
if (!CombatASC->IsOwnerActorAuthoritative()) { return; }
for (const FCombatAbilitySet_GameplayAbility& AbilityToGrant : GrantedGameplayAbilities)
{
UCombatGameplayAbility* AbilityCDO = AbilityToGrant.Ability->GetDefaultObject<UCombatGameplayAbility>();
AbilityCDO->SetCombatAbilitySystemComponent(CombatASC);
AbilityCDO->MontageToPlay = AbilityToGrant.MontageToPlay;
AbilityCDO->SetCooldownClass(AbilityToGrant.CoolDown);
AbilityCDO->InitCombatAbility(CombatASC);
FGameplayAbilitySpec AbilitySpec(AbilityCDO, AbilityToGrant.AbilityLevel);
AbilitySpec.SourceObject = SourceObject;
AbilitySpec.DynamicAbilityTags.AddTag(AbilityToGrant.InputTag);
const FGameplayAbilitySpecHandle Handle = CombatASC->GiveAbility(AbilitySpec);
if (OutGrantedHandles) { OutGrantedHandles->AddAbilitySpecHandle(Handle); }
}
注意最后把 InputTag 塞进了 DynamicAbilityTags——这是后面「输入 → 技能」映射的关键。为了让 AI 和 NPC 的技能配置表跟具体 Ability 解耦,配置结构做过一次重构,把技能参数(蒙太奇、冷却、伤害数据)从代码搬到配置表里。
攻击碰撞:远程实体化 + 把近战也做成 AOE
攻击分近战(AI 的匕首)和远程(AI 的弩箭、玩家的音波刃)。近战直接用武器的碰撞盒;远程生成的实体继承 Actor 做了个飞行物基类 ACombatProjectileBase 去扩展。
一个不太直觉的决定:玩家的轻攻击、重攻击这类近战,也当成远程技能来做 AOE 伤害——即它们同样会生成一个带自己伤害范围数据的飞行物去做检测。这样近战的范围伤害和远程走同一套判定路径,省掉一套单独的近战判定逻辑。
GameplayEffect:伤害与自施 Debuff
伤害都走 GameplayEffect 改属性。比较实用的一个模式是用动画蒙太奇里的 AnimNotifyState 施加 GE:玩家格挡、重攻击蓄力、播放跌落动画期间,在蒙太奇里加一个 UAnimNotifyState_ApplyEffectToSelf,Begin 时给自己套减速/限跳的 GE,End 时移除。
void UAnimNotifyState_ApplyEffectToSelf::ApplyScaleToOwner()
{
for (UCombatGameplayEffect* GameplayEffect : GameplayEffects)
{
CacheCurrentActiveEffectHandles.AddUnique(
CombatASC->ApplyGameplayEffectToSelf(GameplayEffect, 1.0f, CombatASC->MakeEffectContext()));
}
}
void UAnimNotifyState_ApplyEffectToSelf::RemoveScaleToOwner()
{
for (const FActiveGameplayEffectHandle& Handle : CacheCurrentActiveEffectHandles)
{
if (Handle.IsValid()) { CombatASC->RemoveActiveGameplayEffect(Handle); }
}
CacheCurrentActiveEffectHandles.Empty();
}
好处是「这个动作期间角色移动受限」这件事和动画帧精确绑定,策划在蒙太奇上就能调,不用改代码。
GameplayCue:受击反馈
Cue 用来做非玩法性的东西——音效、粒子、震屏。一般在 GE 里直接配好 cue 标签,也可以在 GA 里 Execute/Add 触发。
受击反馈是一个专门的 cue GameplayCueNotify_DamageTaken:技能造成伤害后触发它,让角色播对应的受击动画。它从 EffectContext 里取施法者方向和命中信息,算出受击方向和受击类型(Flinch 等),再驱动受击动画;如果施法者实现了特定接口,还会顺带通知非战斗 AI 进入「被吓到」的 goal。此外还有 GC_HealthRecoverVFX/GC_ManaRecoverVFX 这类粒子 cue 和恢复音效 cue,都在放技能后触发。
用 GameplayTag 控制技能关系
这套战斗系统里最吃 Tag 的是技能之间的关系控制。GA 上封装了一组标签容器,用来约束技能互相之间能不能放:
- Ability Tags:该 GA 自己的标签(建议以
Ability.开头)。 - Cancel Abilities with Tag:激活时打断拥有指定标签的其他 GA。
- Block Abilities with Tag:激活时阻止激活拥有指定标签的 GA(已激活的不打断)。
- Activation Owned Tags:激活时赋予 Owner 的标签。
- Activation Required / Blocked Tags:激活时 Owner 必须有 / 不能有的标签。
- Source / Target Required / Blocked Tags:对 Source、Target 的标签要求。
设计要点是 Tag 的父子层级:把「受击时不能释放的一批技能」挂在同一个父层级下,一句「受击时 block 这个父标签」就全挡住了,比如受击时不能翻滚。ASC 侧有个 GameplayTagCountContainer 存所有赋予 Owner 的标签,上面这些容器都是拿它的内容来判断的。
格挡、克制与弹反
技能 A 克制技能 B:A 给对手施加 GE 时,同时取消对手身上某个标签集合里的技能。项目给 UCombatGameplayAbility 扩了一个 CancelTargetAbilitiesWithTagOnEffectApply 集合来做这件事。格挡则是另一个集合 AbilityTagsToBlock:施加 GE 时如果发现当前施法技能的标签落在这个集合里,就直接放弃这次 GE。
弹反(BounceBack)走的是「格挡前几帧有机会反击」——用蒙太奇里的 UAnimNotifyState_BounceBack,Begin 时按标签激活弹反技能,End 时取消:
FGameplayTagContainer Tag(FGameplayTag::RequestGameplayTag("Ability.Action.BounceBack", false));
ASC->TryActivateAbilitiesByTag(Tag); // NotifyBegin
// ...
ASC->CancelAbilities(&Tag); // NotifyEnd
输入到技能的映射
不写死「按键→技能」,而是建一张 UInputAction ↔ FGameplayTag 的映射表(一个数组管基础移动输入,一个数组管技能输入),技能表里的技能引用对应的输入标签。上面 AbilitySet 授予时把 InputTag 塞进 DynamicAbilityTags,运行时按标签触发即可。好处是换键位、加技能都只动配置。
一个按键触发多个技能(连招)
策划要一个左键(或手柄 RT)对应一整套连招:一段轻攻击、二段轻攻击、重攻击、暗杀。全靠 Tag 分流:
- 一段轻攻击:第一次 Tap 触发。播放蒙太奇,动画帧里用
ANS_GameAbilityTag在第 8~13 帧加上Event.Combo2.Ready标签——这段窗口内玩家再 Tap 才能接二段。 - 二段轻攻击:在一段之后的窗口内再次 Tap 触发。
- 暗杀:满足暗杀条件时 Owner 挂上
Ability.Assassinate.CanAssassinate,此时轻/重攻击都把这个标签放进各自的 Activation Blocked Tags——于是同一按键的 Tap/长按不再响应轻重攻击,而是响应暗杀。 - 重攻击:最绕。当前没有轻攻击状态标签时,按下即开始蓄力,超过阈值放重攻击、没到阈值退回一段轻攻击;已经在轻攻击连招里时,重攻击技能记一个
bWantToHeavyAttack,靠 Tick 判断触发蓄力直至释放。
这套的本质是:用「谁在 Owner 身上、谁 block 谁」的标签状态机,把一个物理按键路由到不同技能,而不是在输入层写一堆 if。
手柄辅助瞄准:GA_AutoAim
支持多平台,手柄比键鼠难瞄,于是给手柄单独做了个锁定技能 GA_AutoAim:它通过前面那张「按键↔技能映射表」配置成只对手柄输入生效。它只服务于有锁定需求的其他技能,并开放接口给策划配「哪些技能能用自动瞄准、哪些物体可被锁定」。实现上,GA_AutoAim 激活后异步等待 Event.AutoAim 标签事件加到 Owner 身上,然后做旋转插值把朝向对准目标。
小结
这套系统的核心做法,是把战斗的绝大多数规则收敛到 Gameplay Tag 上:技能互斥、格挡克制、连招窗口、暗杀优先级、辅助瞄准触发,全是标签状态的组合。用不用 GAS 反而是次要的——真正省事的是这套标签状态机。配合「AbilitySet 批量授予 + 输入标签映射 + 蒙太奇里用 AnimNotifyState 施加 GE/激活技能」,策划能在配置和动画层调掉大量战斗手感,代码改动最小。