尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

ARPG战斗框架为什么选GAS?从状态机到AbilitySystem的实战拆解

ARPG战斗框架为什么选GAS?从状态机到AbilitySystem的实战拆解 1. 为什么ARPG战斗要押注GAS而不是自己写状态机1.1 先说说我自己踩过的状态机深坑今年初接了一个ARPG项目战斗玩法不算特别复杂轻击、重击、闪避、受击硬直、霸体、处决外加敌人AI的一套攻击逻辑。最初版本是团队前同事手写的状态机角色身上挂了个ECombatState枚举从Idle到Attacking、Rolling、HitStun、Dying每个状态在Tick里一堆switch判断遇到攻击中能否闪避受击硬直中能不能被处决触发这类组合条件就得在状态转换函数里叠好几个if (bIsAttacking !bIsHitStun ...)。功能倒是都能跑但加需求的时候特别痛苦加一个重攻击蓄力就要动枚举、动转换表、动动画蓝图稍不留神就漏掉某个边界条件。后来我下决心把这套东西推翻用GAS重写。选择GAS不是因为它时髦是因为看中了它的核心思路把技能从角色代码里彻底拆出来变成一个独立的对象把状态从布尔变量换成GameplayTag把数值变化全部收敛到GameplayEffect里走统一通道。这套思路天然适合ARPG——因为ARPG战斗本质上就是大量技能攻击动作、闪避、Buff在各种状态条件能否释放、能否打断下被触发并在命中后产生一系列数值变化和表现反馈的过程。1.2 ARPG战斗框架到底需要什么做ARPG战斗框架落到实现层面需求其实很明确一是连招系统轻击重击能派生、能取消输入窗口要接得住二是闪避和受击这类瞬时状态需要在正确时机给无敌帧或硬直三是伤害数值要一套统一的结算路径不能让每个技能自己写一套扣血逻辑四是Buff和Debuff系统比如燃烧、减速、霸体、破防五是表现层反馈受击特效、震屏、击打停顿这些不能和逻辑强耦合。GAS恰好覆盖了大部分Ability管技能行为、Task管异步流程、Tag管状态条件、Effect管数值和Buff、Cue管表现反馈。但它也明确不覆盖手感调校和动画融合这部分还是得用输入系统、动画蓝图、移动组件自己处理。所以我的建议是GAS负责战斗的逻辑骨架手感相关的东西仍然在外部配合——它不会替你做好ARPG但能让你不再被状态机的复杂度拖死。2. 顶层骨架怎么搭ASC挂载、AttributeSet与Tag体系2.1 AbilitySystemComponent该挂在谁身上GAS最核心的组件是UAbilitySystemComponent后面统一叫ASC所有Ability、Effect、Attribute都通过它来运转。我的做法是玩家角色、敌人角色、甚至场景里的可破坏物只要参与战斗或者会被战斗影响都在Character上挂一个ASC。挂Character而不挂Controller是因为敌人AI也经常切换Controller比如Boss战切AI、处决时切玩家控制挂在Character上生命周期稳定。初始化时机是个大坑。ASC必须在PossessedBy之后调用InitAbilityActorInfo不能在BeginPlay里就初始化——因为需要AvatarActor和OwnerActor都正确设置。玩家这边我在APlayerController::OnPossess里调敌人那边在AIController的OnPossess里调。初始化之后第二步是GiveAbility把角色的基础技能轻击、重击、闪避、处决通过FGameplayAbilitySpec给进ASC这一步可以在初始化同一个函数里做也可以用UGameplayAbilityDataAsset做配置化批量赋予。伪代码示意 void AMyCharacter::InitGAS() { AbilitySystemComponent-InitAbilityActorInfo(this, this); FGameplayAbilitySpec Spec(MyComboAbilityClass, 1, INDEX_NONE, this); AbilitySystemComponent-GiveAbility(Spec); }有个细节值得注意GiveAbility返回FGameplayAbilitySpecHandle如果技能后续要移除或者做冷却查询这个Handle要存下来。但大部分情况技能是常驻的不用特意存用Tag查询FindAbilitySpecFromInputTags或者TryActivateAbilitiesByTag会更方便。2.2 AttributeSet设计血量、精力、霸体UAttributeSet承担所有战斗数值的存储。我项目里自定义了一个UMyAttributeSet主要属性包括Health、MaxHealth、Stamina、MaxStamina、Poise霸体值、MaxPoise外加一个Meta属性Damage。按GAS惯例Attribute只存当前值变化逻辑不写在AttributeSet的业务代码里——玩家回血、敌人扣血都通过GameplayEffect去改数值AttributeSet只负责在属性被改之后做收尾工作。Damage这个Meta属性是个经典用法攻击方的Effect不直接扣Health而是往Damage上加一个数值然后在目标AttributeSet的PostGameplayEffectExecute里统一做防御减免计算再真正扣Health。这样所有伤害都走同一条结算路径物理攻击、魔法攻击、真实伤害的区别只是改了不同系数。void UMyAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData Data) { Super::PostGameplayEffectExecute(Data); if (Data.EvaluatedData.Attribute GetDamageAttribute()) { const float Damage GetDamage(); SetDamage(0.f); const float FinalDamage FMath::Max(0.f, Damage - GetArmor()); SetHealth(FMath::Clamp(GetHealth() - FinalDamage, 0.f, GetMaxHealth())); // 这里广播血条更新事件并且当 Health 0 时添加死亡Tag if (GetHealth() 0.f !Data.Target.HasMatchingTag(StateTag::Dying)) { Data.Target.AddLooseGameplayTag(StateTag::Dying); } } }另外所有需要网络同步的Attribute都必须标记FOnRep并做属性复制否则联机时客户端看到的是客户端本地预测值回包一校准就跳变。单机项目可以偷懒但一旦要加联机这块一个都不能漏。2.3 GameplayTag用标签替代布尔状态我把角色所有战斗状态全部Tag化替代原本的布尔变量。比如State.Attacking.Light、State.Attacking.Heavy、State.Rolling、State.HitStun、State.Invincible、State.Dying、State.CanCombo。用Tag最大的好处是技能激活条件可以直接声明不用在代码里写一串if (bAttacking !bHitStun)。具体做法是在UGameplayAbility的子类上配置ActivationBlockedTags被哪些Tag挡住就不能激活和ActivationRequiredTags必须有哪些Tag才能激活。以闪避为例ActivationBlockedTags里放State.Dying、State.HitStunActivationRequiredTags放State.CanMove这样角色倒地时技能系统自己会拒绝激活闪避完全不用我在输入回调里手动判状态。Tag还有个隐藏价值它天然是多标签并存的。一个角色可以同时处于State.Attacking.Light和State.CanCombo布尔变量这种组合状态管理起来极其痛苦Tag集合却毫无压力。Debug的时候开GAS自带调试器或者打印AbilitySystemComponent-GetLooseGameplayTags()一眼能看全角色当前状态。3. 技能层拆解Ability、Task、Effect、Cue各司其职3.1 GameplayAbility的实例化策略怎么选UGameplayAbility有三种实例化策略选错会在奇怪的地方出Bug。我项目里90%的技能用InstancedPerActor也就是技能实例在角色身上存一份跨多次激活复用。连招技能必须用它因为连招计数、当前命中列表、蓄力状态这些中间变量要保存在实例里技能结束不销毁下次激活再接着读。InstancedPerExecution用于一次性技能比如投掷物、召唤物每次激活生成新实例激活结束就销毁。好处是并发安全——如果同一个技能可以在短时间内触发多次连发子弹每次触发互不干扰。代价是每次都付出构造开销以及你不能在实例里留跨触发状态。NonInstanced最省性能但限制极大整个技能不能有任何成员变量所有数据都得从外部传入。我基本不用ARPG技能普遍有状态强行NonInstanced会让代码丑到没法维护。非要说什么时候用大概是那些纯函数式的辅助技能比如一个只负责施加某个Buff的触发器但这种场景我也更愿意用Effect本身来实现不绕一层Ability。3.2 AbilityTask串联异步流程技能里大量的等待逻辑等动画播到某个Notify、等玩家按下输入、等延迟结束。GAS把这套封装成了AbilityTask最常用的几个是PlayMontageAndWait播放技能蒙太奇并等待结束/被打断WaitInputPressWithTiming在时间窗口内等待输入按下WaitGameplayEvent等待任意处广播的GameplayEventWaitDelay单纯等时间连招的实现就是靠Task串联起来的。一个轻击技能激活后先播放一段蒙太奇蒙太奇分好几个SectionAttack_1、Attack_2、Attack_3。在第一段攻击的收尾阶段技能里挂一个WaitInputPressWithTiming如果玩家在窗口期内按下轻击就往Attack_2Section跳没按就等蒙太奇播完自然结束。void UGA_ComboLightAttack::ActivateAbility(...) { Super::ActivateAbility(...); MontageTask UAbilityTask_PlayMontageAndWait::PlayMontageAndWait( this, NAME_None, ComboMontage, FGameplayTagContainer(), 1.0f, NAME_None, true); MontageTask-OnCompleted.AddDynamic(this, ThisClass::OnMontageCompleted); MontageTask-OnInterrupted.AddDynamic(this, ThisClass::OnMontageInterrupted); MontageTask-ReadyForActivation(); WaitInputTask UAbilityTask_WaitInputPressWithTiming::WaitInputPressWithTiming(this); WaitInputTask-OnPress.AddDynamic(this, ThisClass::OnComboInputPressed); WaitInputTask-ReadyForActivation(); }Task是所有异步控制的骨架但它也有个麻烦的点生命周期。Task是Ability的附属对象Ability结束时Task通常会跟着清理但如果你在Task回调里调用EndAbility而Ability其实已经被外部End了就会Assert崩溃。后面第五章我会把这类坑集中讲。3.3 GameplayEffect伤害结算与Buff统一入口GAS里所有数值变化都走UGameplayEffect。按DurationPolicy分三类Instant瞬时生效伤害、回血、Infinite持续生效直到被移除增益Buff、霸体状态、HasDuration定时结束中毒、燃烧、减速。伤害Effect我配了一个基础模板Modifier部分往目标的Damage属性加一个数值这个数值来自技能数据资产传入的攻击力。Buff类Effect就五花八门了燃烧是HasDuration的Effect每秒通过PeriodicExecution执行一次伤害霸体是InfiniteEffectModifier修改目标Poise属性上限或每秒回满目标有霸体Effect时收到的攻击硬直Tag就被技能系统的Tag条件过滤掉减速是InfiniteEffectModifier修改移动速度。字段里有个容易被忽略的TargetTags配置它控制这个Effect能不能作用在目标身上。我这个项目里处决处决技能就是一个特殊的伤害Effect要求目标带有State.ExecutionableTag才能应用否则直接不生效。这种比在代码里写if (Target-HasTag(...))要规整得多因为判定直接写在配置里策划改起来不用碰代码。3.4 GameplayCue把表现层从逻辑层剥离开UGameplayCue专门处理表现反馈命中火花、受击音效、击打停顿、震屏、飘字。它和GameplayEffect最大的区别是——Cue不改变任何数值只负责看起来发生了什么。我的做法是在命中确认后攻击方Execute一个Cue.HitImpact带上命中点、命中方向、是否暴击等上下文数据。Cue本身是个蓝图类里面根据上下文决定播放哪个粒子、是否触发HitStop、是否推动相机震动。这样伤害逻辑和表现完全解耦换一套特效风格不用动任何C代码只替换Cue资产。Cue的类型要注意选对纯特效音效用Static需要跟随目标Actor的贴花/持续特效用Actor联机里需要本地预测表现比如自己受击时的屏幕红框用Local。表现事务不要过度设计Cue只做表现别在Cue里写伤害计算否则调试会成为噩梦。4. 一条轻击连招的完整链路从按键到伤害结算4.1 输入处理与Ability激活UE5推荐用Enhanced Input。我在PlayerController里监听轻击的IA_LightAttack动作触发后直接调用AbilitySystemComponent-TryActivateAbilitiesByTag传入Input.Attack.Light这个Tag。为什么用Tag而不用Handle因为轻击技能可能多段每一段都是一个独立Ability我用的是多Ability方案而不是单Ability跳Section方案下面细说用Tag激活能自动匹配到当前连击阶段对应的那个Ability。ASC内部会先检查这个Ability的ActivationBlockedTags、ActivationRequiredTags、是否在冷却中等条件全部通过才真正激活。输入回调里不需要判断角色当前能不能攻击这个判断全部下沉到Ability身上代码会干净非常多。实际暴力实现void AMyPlayerController::OnLightAttack(const FInputActionValue Value) { if (UAbilitySystemComponent* ASC GetPawn()-FindComponentByClassUAbilitySystemComponent()) { FGameplayTagContainer TagContainer; TagContainer.AddTag(InputTag::Attack_Light); ASC-TryActivateAbilitiesByTag(TagContainer); } }多段实现我最后选了每个攻击段一个Ability子类GA_Attack01、GA_Attack02、GA_Attack03。它们共用一套父类逻辑只是蒙太奇Section、伤害倍率、攻击范围各有DataAsset配置。连击计数存在角色ASC上的一个自定义FGameplayTag上Combo.Index.2表示当前第二段。GA_Attack02的ActivationRequiredTags要求有Combo.Index.1而GA_Attack01要求无连击Tag或连击已过期。这套方案比单个Ability内部跳Section更清晰因为每一段都是独立资源冷却、消耗、预测策略各配各的后面扩展重击分支或者特定段可蓄力都不用动其他段。4.2 连段派生与输入窗口管理实际手感上连招最难调的是输入窗口玩家必须在第一段攻击的后半段或落地瞬间按下一次轻击第二段才会衔接上否则回到待机。窗口太短像便秘太长像无脑连点。我在每段攻击的Montage里在收招动作的合适时间点放一个Notify这个Notify触发时Send一个Event.ComboWindow.Open给ASC。Ability用WaitGameplayEvent接收这个事件事件到来后才真正激活下一段的WaitInput。用事件方式而不是固定WaitDelay是为了让窗口时机和动画帧完全对齐——不同武器的攻击动画可能有快有慢但窗口逻辑代码完全不用改。窗口窄一些反而手感更实。我调参的经验是窗口开放时间设0.2秒左右但输入输入本身要Enable Move Cancel否则玩家连招中想移动会被卡在攻击动作里等完整个蒙太奇特别难受。移动取消的逻辑也是Tag方案攻击段Ability的Montage被打断时会清理State.AttackingTag并EndAbility移动能力有ActivationRequiredTags包含State.CanMove所以攻击中它不会被激活一旦攻击Tag移除移动立刻恢复这个过渡是实时的。4.3 伤害判定武器拖尾还是球形扫描伤害判定这块我试过两种方案。第一种是武器碰撞体物理检测武器挂个Collision Box动画里开启碰撞、关闭碰撞。这套的优点是判定贴合武器模型砍中哪就是哪缺点是物理引擎介入性能开销大而且容易误触发静态环境碰撞。最终我选了球形扫描在Montage的命中帧Notify里从武器Socket位置打一条SphereTraceMulti半径根据武器长度配置短剑60、大剑100起点是武器Socket终点是Socket往前加一个武器长度的向量。命中后拿Hit的Actor检查对方有没有ASC有就用ApplyGameplayEffectSpecToTarget把预制的伤害EffectSpec打过去。// 在攻击Notify里触发 void UMyAbilityTask_TraceHit::DoTrace() { TArrayFHitResult Hits; const FVector Start WeaponComp-GetSocketLocation(Socket_Blade); const FVector End Start GetOwnerActor()-GetActorForwardVector() * WeaponLength; FCollisionQueryParams Params; Params.AddIgnoredActor(GetAvatarActor()); GetWorld()-SweepMultiByChannel(Hits, Start, End, FQuat::Identity, ECC_GameTraceChannel1, FCollisionShape::MakeSphere(TraceRadius), Params); for (const FHitResult Hit : Hits) { AActor* HitActor Hit.GetActor(); if (HitActors.Contains(HitActor)) continue; HitActors.Add(HitActor); if (UAbilitySystemComponent* TargetASC UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(HitActor)) { ApplyDamageEffect(TargetASC); ExecuteHitCue(Hit); } } }仔细看上面这段HitActors数组就是为了防止同一次攻击的多个Substep如果一刀有多个判定帧重复命中同一个怪。连段结束后清空数组下一段攻击重新收集。这个细节如果你不处理就会出现一刀跳三次伤害的经典Bug。4.4 命中反馈的完整链路每次命中从攻击方角度到受击方角度链路是这样的攻击方的TraceTask检测到目标跑一遍HitActors去重攻击方生成伤害GE Spec调用ApplyGameplayEffectSpecToTarget把Spec发给目标ASC目标ASC收到按GE里配置的Modifier往目标的DamageMeta属性上加值目标AttributeSet触发PostGameplayEffectExecute算出减免后的真实伤害扣Health目标ASC检测到Health变化广播OnHealthChanged委托如果血归零给自身添加State.DyingTag攻击方同时Execute一个GameplayCue播命中特效、震屏、HitStop击打停顿目标如果处于可受击状态没有霸体Tag给它加一个State.HitStunTag目标的动画蓝图监听这个Tag播受击动画我非常推荐把受击硬直的处理放在受击方自己的ASC和动画蓝图里而不是让攻击方直接调目标播放受击动画。原因很简单以后加一个BossBoss可能对特殊攻击免疫受击或者受击动画有自己的一套规则如果攻击方直接控制目标表现这些差异就得写在一堆if (EnemyType Boss)里完全没有扩展性。Tag 各方自行响应才是合理的解耦方式。5. 避坑清单帧率、Montage与网络同步5.1 AbilityTask的取消与崩溃这套框架跑起来后我遇到的第一个顽固Bug是技能已经因为受击被打断但之前挂的Task还在回调回调里又去访问已经释放的资源直接Assert。根因是Ability End时Task不一定自动回收或者说Task的回调委托还挂在事件源上。我的统一处理是写了一个基类UMyGameplayAbility在EndAbility里统一做这几件事void UMyGameplayAbility::EndAbility(...) { // 先取消所有子Task防止异步回调 UAbilitySystemComponent* ASC GetAbilitySystemComponentFromActorInfo(); if (ASC) { ASC-CancelAbilities(GetCurrentAbilitySpec()-Ability-AbilityTags); } Super::EndAbility(...); }同时任何Task回调尤其是WaitGameplayEvent里如果要结束技能都先判IsEndAbilityValid()if (IsEndAbilityValid()) { EndAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo, true, false); }另外一个常见崩溃是Montage被打断PlayMontageAndWait的OnInterrupted和OnBlendOut都会触发但这时Ability可能已经End了或者Montage所在的AnimInstance已经被切换。我的做法是这三个回调都走同一个收口函数收口函数里统一检查IsEndAbilityValid()再做对应处理不要各写各的逻辑。5.2 Montage与AbilityTask的节奏同步Montage的Rate Scale播放速度是个隐蔽的大坑。技能里我做了个子弹时间功能处决动画时全局时间放慢用UGameplayStatics::SetGlobalTimeDilation实现。但问题来了——Montage的播放是受全局时间膨胀影响的而AbilityTask里的WaitDelay用的是真实时间两者会错开。同样Montage的Notify触发也会受Rate Scale影响。所以我的判定帧Notify是放在Montage里的它有动画绑定Rate Scale变化时依然和动画画面对齐。但依靠等几秒后执行的Task逻辑比如连段输入窗口关闭我用的是GameplayEvent来自Montage Notify而不是纯WaitDelay确保所有逻辑节奏都绑定动画而不是绑定时钟。顺手提一句RootMotion攻击技能里如果带位移比如前冲斩我建议蒙太奇启用RootMotion from Montage能力但移动组件要配合设置否则经常出现动画往左砍角色原地不动的诡异手感。这块当初调了整整两天其实核心就是检查UCharacterMovementComponent的bAllowPhysicsRotationDuringAnimRootMotion和Montage里RootMotion Track是否设置正确。5.3 网络同步与预测策略如果是纯单机ARPG这节可以跳过。但凡你有任何联机计划包括以后可能加合作模式框架设计就得提前考虑网络。GAS的基本网络模型是ASC是Server权威的GameplayEffect对属性的修改必须在Server上执行客户端变更只是预测。预测最常见的是客户端按下技能ASC立刻激活Ability并播放MontageServer几帧后才确认如果Server判定不能激活比如技能还在冷却客户端本地会回滚。这个回滚机制GAS自带但你要付出的代价是任何客户端预测执行期间修改的数据都必须在预测Key的管理下否则回滚时会发现状态没法还原。实战里我遇到最多的是伤害重复客户端预测了一次伤害Server又处理了一次目标被扣了两遍血。解决方案很简单——伤害的GE应用只在Server端做判断HasAuthority()后在Server执行客户端只跑Cue表现和Montage。蒙太奇播放可以是本地的性能也更好但最终血量变化以Server为准血条靠Attribute复制回到客户端。还有GameplayCue的网络分类Static和Actor类Cue默认只在Server执行再多播Local类Cue可以客户端自己预测执行。像自己角色受击屏幕震动这种用Local类型不然联机时受击反馈会有你无法忍受的延迟。5.4 调试工具与DataAsset组织最后给两个效率建议。第一个是强烈建议开启GAS调试器控制台执行AbilitySystem.Debug等命令可以实时查看每个角色身上的Ability列表、Tag集合、Attribute数值、冷却进度。当年我在看一个技能明明给了但激活不了的Bug时就是靠调试器看到ActivationBlockedTags里混进了一个不该有的Tag一眼定位。第二个是技能数据全部DataAsset化。我为每个技能建了一个UMyGameplayAbilityData里面存Montage引用、伤害倍率、攻击范围、消耗精力值、技能Tag集合。Ability从DataAsset读取参数C里没有硬编码的数值。策划调平衡时只改资产不动代码。这套Battle Monster、Lyra都在用确实是GAS项目里组织数据的主流方案。6. 若干实战经验以及这套框架后续还能怎么扩展6.1 实测下来最值得坚持的三个决定这套框架跑完整个Demo后我回头总结有三个决定是最值得坚持的。第一是把受击硬直、霸体这类被动反馈全部收敛到Tag和Effect主力代码里几乎看不到if (bHit)这种分支第二是伤害路径强制走GE Meta Attribute哪怕是一个只有5点伤害的弹幕子弹也走同一套结算原因很简单——以后要加暴击、免伤、护盾、吸血全部能在结算通道里统一处理逐个技能去改的苦我受过不打算再受第三是每一段攻击都做成独立Ability配合DataAsset配置虽然资产数量多了但扩展新的连段分支几乎零成本。6.2 踩过几次坑之后的几个小提醒这里几条是我掏心窝子的话写给正准备上GAS的同行ASC的InitAbilityActorInfo时机千万别放在BeginPlay之前AttributeSet的所有属性记得标记复制游戏跑起来发现联机血条乱跳再回头补是最痛苦的Enemy和Player别共享同一个AttributeSet类实例各给各的否则Boss战你改敌人血量时不小心把玩家血量也改了。调试期建议一上来就把GameplayTag命名规范定好State.、Event.、Input.、Cue.前缀区分清楚。Tag命名乱了的项目后期搜索都搜不明白比代码乱还可怕。6.3 这套框架后续可以怎么扩展框架稳定后往几个方向扩展都挺顺畅。想做武器系统给每种武器一套独立的Montage和伤害范围配置换武器就是换DataAsset和Tag集合想做元素反应给伤害GE加GameplayTagContainer属性用Tag匹配来做元素附着再挂定期Effect想做敌人AI技能把Boss的每个招式配成一个Ability由BehaviorTree在合适时机TryActivateAbilitiesByTagAI和玩家都走同一套底层省掉两套逻辑维护的成本。GAS这套框架学起来确实有门槛它的概念密度比大部分UE子系统都高但一旦骨架搭对ARPG战斗的复杂度会从状态机地狱变成配置资产拆分技能的良性结构。如果你正在纠结要不要用我的建议是别犹豫上但把精力集中在Tag设计和Ability拆分上那才是这套框架真正的命门。
返回列表