Who: 谁能放技能?AbilitySystemComponent
How: 技能的逻辑?GameplayAbility
Change: 技能的效果?GameplayEffect
What: 技能改变的属性?GamePlayAttribute
If: 技能涉及的条件?GameplayTag
Visual: 技能的视效?GameplayCue
Async: 技能的长时行动?GameplayTask
Send: 技能消息事件?GamePlayEvent
概括
GAS是虚幻的技能系统
拥有AbilitySystemComponent的对象拥有技能的能力。GameplayAbility描述技能行为,通过 GameplayEffect 来 修改属性 GamePlayAttribute
GameplayCue 来描述技能中纯表现性的东西,通常是音频和例子特效。GE可以配置,也可以在GA中用接口直接加出来。
GameplayTask 来描述技能行为中的长时任务。
===
ASC是一个容器维护了
- 拥有维护技能对象的容器ActivatableAbilities
- 维护ActiveGameplayEffects的容器
- 维护独立Cue的容器(区别于GE附带的) ActiveGameplayCues
- 技能预测效果的Tag ReplicatedPredictionKeyMap
- 手动添加的Tag 的容器 ReplicatedLooseTags
ASC是一个UActorComponent。通常挂在角色或者PlayerState上。但是也不绝对。
例如lyra在gamestate挂了一个。game state是全局唯一、所有客户端都可访问且自动复制的 Actor,所以它非常适合承载与任何单个玩家无关的、游戏全局级别的 GAS 逻辑。lyra在 ULyraGamePhaseSubsystem 维护阶段逻辑
GameplayCue 来描述技能中纯表现性的东西,通常是音频和例子特效。GE可以配置,也可以在GA中用Execute GameplayCueWithParamsOnOwner来调用
GameplayTask 用来描述技能行为中的长时行为。例如:
- 等待输入 WaitInputPress/Release
- 播放蒙太奇并等待完成 PlayMontageAndWait
技能集配置和获取失去
技能集的设计 基于DataAsset配置 动态获得和失去 GA GE AS
class UEqZeroAbilitySet : public UPrimaryDataAsset
{
// GA + AbilityLevel + InputTag
UPROPERTY(EditDefaultsOnly, Category = "Gameplay Abilities", meta=(TitleProperty=Ability))
TArray<FEqZeroAbilitySet_GameplayAbility> GrantedGameplayAbilities;
// GE + EffectLevel
UPROPERTY(EditDefaultsOnly, Category = "Gameplay Effects", meta=(TitleProperty=GameplayEffect))
TArray<FEqZeroAbilitySet_GameplayEffect> GrantedGameplayEffects;
// AS
UPROPERTY(EditDefaultsOnly, Category = "Attribute Sets", meta=(TitleProperty=AttributeSet))
TArray<FEqZeroAbilitySet_AttributeSet> GrantedAttributes;
};
这个配置的获得可以关联武器的装备和脱下,或者GameFeatureAction_AddAbilities
技能的触发
技能激活策略
我们自己定义了技能的触发方式
UENUM(BlueprintType)
enum class EEqZeroAbilityActivationPolicy : uint8
{
OnInputTriggered,
WhileInputActive,
OnSpawn
};
按下触发,按住一直触发,角色OnSpawn时触发
增强输入到技能激活
在增强输入中,我们绑定了InputAction触发到某个 参数为GameplayTag的回调函数的触发。然后转发给ASC
具体细节:
输入数据结构
UEqZeroInputConfig (DataAsset)
├─ NativeInputActions[] → Move, Look, Crouch 等固定操作
└─ AbilityInputActions[] → 每条是 { UInputAction, FGameplayTag }
例如: { IA_Fire, InputTag.Ability.Fire }
例如: { IA_Jump, InputTag.Ability.Jump }
绑定层:HeroComponent 在 InitState 数据就绪后绑定输入
从而 输入到 GameplayTag 的流程打通,技能能够得到这个输入
HandleChangeInitState (DataAvailable → DataInitialized)
└─ InitializePlayerInput(InputComponent)
├─ AddMappingContext (IMC → EnhancedInput 子系统)
├─ BindNativeAction (Move/Look/Crouch → 固定函数)
└─ BindAbilityActions (所有 AbilityInputActions)
→ 对每个 { InputAction, InputTag }:
Triggered → Input_AbilityInputTagPressed(InputTag)
Completed → Input_AbilityInputTagReleased(InputTag)
路由层:HeroComponent → ASC 的 Tag 缓冲
// HeroComponent 收到输入回调
void Input_AbilityInputTagPressed(FGameplayTag InputTag) {
EqZeroASC->AbilityInputTagPressed(InputTag); // 转发给 ASC
}
ASC 收到 InputTag 后,【不立即激活技能】,而是写入三个缓冲数组:
void AbilityInputTagPressed(const FGameplayTag& InputTag) {
// 遍历所有已授予的 GA,找 DynamicSpecSourceTags 匹配此 InputTag 的
for (AbilitySpec : ActivatableAbilities.Items) {
if (AbilitySpec.GetDynamicSpecSourceTags().HasTagExact(InputTag)) {
InputPressedSpecHandles.AddUnique(AbilitySpec.Handle); // 按下队列
InputHeldSpecHandles.AddUnique(AbilitySpec.Handle); // 持续队列
}
}
}
void AbilityInputTagReleased(const FGameplayTag& InputTag) {
for (AbilitySpec : ActivatableAbilities.Items) {
if (AbilitySpec.GetDynamicSpecSourceTags().HasTagExact(InputTag)) {
InputReleasedSpecHandles.AddUnique(AbilitySpec.Handle); // 释放队列
InputHeldSpecHandles.Remove(AbilitySpec.Handle); // 移出持续队列
}
}
}
InputTag → GA 的匹配机制:AbilitySet 在 GiveAbility 时,将 InputTag 写入 AbilitySpec.DynamicAbilityTags。ASC 通过 GetDynamicSpecSourceTags().HasTagExact(InputTag) 查找匹配的 GA。
FGameplayAbilitySpec AbilitySpec(AbilityCDO, AbilityToGrant.AbilityLevel);
AbilitySpec.SourceObject = SourceObject;
AbilitySpec.GetDynamicSpecSourceTags().AddTag(AbilityToGrant.InputTag);
const FGameplayAbilitySpecHandle AbilitySpecHandle = EqZeroASC->GiveAbility(AbilitySpec);
处理层:PostProcessInput 统一消费
PlayerController::PostProcessInput (每帧 Tick)
└─ ASC::ProcessAbilityInput(DeltaTime, bGamePaused)
ProcessAbilityInput 是整个输入→技能的核心调度,一帧内按严格顺序处理:
① 检查全局阻塞 — HasMatchingGameplayTag(TAG_Gameplay_AbilityInputBlocked)
→ 如果阻塞,ClearAbilityInput() 丢弃所有输入,直接返回
② 处理 WhileInputActive(持续按住的技能)
→ 遍历 InputHeldSpecHandles
→ 技能未激活 && ActivationPolicy == WhileInputActive → 加入待激活列表
③ 处理 OnInputTriggered(按下触发的技能)
→ 遍历 InputPressedSpecHandles
→ 技能已激活 → AbilitySpecInputPressed (传递输入事件给运行中的技能)
→ 技能未激活 && ActivationPolicy == OnInputTriggered → 加入待激活列表
④ 统一激活
→ 遍历待激活列表,TryActivateAbility(Handle)
→ 之所以统一激活,是避免 WhileInputActive 激活后又被 OnInputTriggered 发送事件
⑤ 处理 InputReleased(松开的技能)
→ 遍历 InputReleasedSpecHandles
→ 技能已激活 → AbilitySpecInputReleased (传递释放事件给运行中的技能)
→ InputPressed = false
⑥ 清空 Pressed/Released 缓冲(Held 保留到下一帧)
这么设计的意义是
维护 FGameplayAbilitySpec->InputPressed 的值,能对 WaitInputPress/Release 产生作用。通过ASC接口 AbilitySpecInputPressed/Release
对于【按下】触发的技能,按下激活成功,继续按住,被判断掉了,没有实际作用
对于【按住】触发的技能,按下激活成功,继续按住会一直 InputPressed=true并且传递事件,但是技能激活中不能再次激活,这个自动步枪技能的结束是基于蒙太奇时间的
OnSpawn 自动激活
OnSpawn 策略的 GA 在两个时机尝试激活:
① OnGiveAbility — GA 被授予时
UEqZeroGameplayAbility::OnGiveAbility
→ TryActivateAbilityOnSpawn(ActorInfo, Spec)
② InitAbilityActorInfo — Avatar 切换时(重生)
UEqZeroAbilitySystemComponent::InitAbilityActorInfo
→ bHasNewPawnAvatar == true
→ TryActivateAbilitiesOnSpawn() // 遍历所有已授予 GA
→ 每个 GA 的 TryActivateAbilityOnSpawn
为什么需要两个时机:
- 时机①:正常流程——先有 Avatar,后授予技能(Equipment 装备时、GameFeature 注入时)
- 时机②:ASC 在 PlayerState 上 + 重生流程——技能早就授予了(上一条命),但 Avatar 是新的 Pawn
激活条件
void TryActivateAbilityOnSpawn(ActorInfo, Spec) const {
if (ActivationPolicy != OnSpawn) return;
if (Spec.IsActive()) return; // 已经在运行了
if (AvatarActor->GetTearOff()) return; // 正在销毁
if (AvatarActor->GetLifeSpan() > 0) return; // 即将销毁
// 根据 NetExecutionPolicy 决定谁来激活
bool bLocalShouldActivate = IsLocallyControlled() && (LocalPredicted || LocalOnly);
bool bServerShouldActivate = IsNetAuthority() && (ServerOnly || ServerInitiated);
if (bLocalShouldActivate || bServerShouldActivate)
ASC->TryActivateAbility(Spec.Handle);
}
要点:
Spec.IsActive()防止重复激活(重生时已有 GA 可能还在运行)GetTearOff()/GetLifeSpan()过滤正在销毁的 Avatar- 网络角色检查确保只在正确端激活
典型用途,需要在角色生成时激活的监听类技能。
例如交互技能,周期性扫描周围的可交互物。
激活失败
TryActivateAbility(Handle)
→ InternalTryActivateAbility (引擎)
→ CanActivateAbility 失败
→ NotifyAbilityFailed(Handle, Ability, FailureReason) ← 引擎调用
ASC 重写的 NotifyAbilityFailed:
NotifyAbilityFailed(Handle, Ability, FailureReason)
├─ Super::NotifyAbilityFailed
│ → 触发 ASC 的 AbilityFailedCallbacks 委托(通用监听)
│
├─ 判断是否需要转发给客户端
│ if (!Avatar->IsLocallyControlled() && Ability->IsSupportedForNetworking())
│ → ClientNotifyAbilityFailed (Client RPC, Unreliable)
│ → HandleAbilityFailed (客户端执行)
│ else
│ → HandleAbilityFailed (本地直接执行)
│
└─ HandleAbilityFailed
→ EqZeroAbility->OnAbilityFailedToActivate(FailureReason)
├─ NativeOnAbilityFailedToActivate (C++)
│ ├─ 查 FailureTagToUserFacingMessages 表 → 广播 UI 消息
│ └─ 查 FailureTagToAnimMontage 表 → 广播失败动画
└─ ScriptOnAbilityFailedToActivate (蓝图可重写)
简单来说就是技能失败了,有个回调,我们自己广播一下。
技能的互斥管理
enum class EEqZeroAbilityActivationGroup : uint8 {
Independent, // 独立,不受任何约束(默认,绝大多数技能)
Exclusive_Replaceable, // 排他可替换,会被新的排他技能取消
Exclusive_Blocking // 排他阻塞,阻止其他排他技能激活
};
这是ASC上的一个 int32 ActivationGroupCounts[(uint8)EEqZeroAbilityActivationGroup::MAX];
每个技能属于一个组,数值相当于一个计数器
技能激活 → NotifyAbilityActivated
→ AddAbilityToActivationGroup(Group, Ability)
├─ Independent: 无操作
└─ Exclusive_Replaceable / Exclusive_Blocking:
→ CancelActivationGroupAbilities(Exclusive_Replaceable, 排除自身)
→ 也就是:新的排他技能进来,取消所有"可替换"的排他技能
技能结束 → NotifyAbilityEnded
→ RemoveAbilityFromActivationGroup(Group, Ability)
→ ActivationGroupCounts[Group]--
ActivationGroupCounts 是一个计数器数组,IsActivationGroupBlocked 通过检查 Exclusive_Blocking 计数 > 0 来阻塞:
bool IsActivationGroupBlocked(Group) const {
if (Group == Independent) return false; // 独立永远不被阻塞
// Replaceable 和 Blocking 都在有 Blocking 技能时被阻塞
return ActivationGroupCounts[Exclusive_Blocking] > 0;
}
虽然死亡确实是占用了 Exclusive_Blocking,但是却不会阻塞 Independent 因为
bool UEqZeroGameplayAbility::CanActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayTagContainer* SourceTags, const FGameplayTagContainer* TargetTags, FGameplayTagContainer* OptionalRelevantTags) const
{
if (!ActorInfo || !ActorInfo->AbilitySystemComponent.IsValid())
{
return false;
}
if (!Super::CanActivateAbility(Handle, ActorInfo, SourceTags, TargetTags, OptionalRelevantTags))
{
return false;
}
//@TODO Possibly remove after setting up tag relationships
UEqZeroAbilitySystemComponent* EqZeroASC = CastChecked<UEqZeroAbilitySystemComponent>(ActorInfo->AbilitySystemComponent.Get());
if (EqZeroASC->IsActivationGroupBlocked(ActivationGroup))
{
if (OptionalRelevantTags)
{
OptionalRelevantTags->AddTag(EqZeroGameplayTags::Ability_ActivateFail_ActivationGroup);
}
return false;
}
return true;
}
官方说这部分可能被删除。。。
本质上是技能激活 CanActivateAbility 流程的自定义
GameplayAbility::CanActivateAbility 里面有三个虚函数 CD, Cost,tag检查,我们可以重写。
例如ASC的配置 TObjectPtr
就重写了一张表,配置了技能的阻塞和取消关系。
这个表会在ability的接口 DoesAbilitySatisfyTagRequirements 中重写。就是上面所说的 CanActivateAbility 中三个重写中tag检查那部分。
===
为什么这么设计,相比GameplayAbility直接配置有什么好处?
原生 GA Tag 配置
GA_RangedWeapon:
BlockAbilitiesWithTag: [Ability.Type.Action.Reload]
CancelAbilitiesWithTag: []
ActivationBlockedTags: [Status.Death]
ActivationRequiredTags: []
问题:每个 GA 各管各的。项目有 20 个 GA,新增 GA_Grenade (手雷) 需要被冲刺阻塞:
- 改 GA_Dash 加 BlockAbilitiesWithTag → 要改已有 GA
- 或者改 GA_Grenade 加 ActivationBlockedTags → 要知道所有可能阻塞它的技能的 Tag
- 再加一个 GA_Slide 也阻塞手雷 → 又要改
N 个技能有互斥关系 = 改 N 个 GA 的配置。关系分散在每个 GA 身上,新增/修改一条规则要翻遍所有相关 GA。
TagRelationshipMapping — 集中式规则表
TagRelationshipMapping DataAsset (一张表管所有):
Ability.Type.Action.Dash:
Block: [Ability.Type.Action.Melee, Ability.Type.Action.Grenade]
Cancel: [Ability.Type.Action.Reload]
Ability.Type.Action.Death:
Block: [Ability.Type.Action] // 一条规则阻塞所有 Action 类技能
ActivationGroupCounts — 解决一类完全不同的问题
如果用 Tag 做:
- GA_Death 的 BlockAbilitiesWithTag 要列出 所有 非独立技能的 Tag
- 每新增一个技能都要记得加到这个列表
实际的效果,死亡技能激活的时候,会在ActivationGroupCounts里面写一个阻塞
阻塞技能在进组的时候会 取消所有Exclusive_Replaceable的技能
Cost和CD
CD 在lyra中只是一个简单的GE,配置了一个数字。
Cost 本质上是自己重写Ability的 CheckCost
引擎的流程好像是,基于Attr的修改,看能否成功。
Lyra自定义的流程是 定义一个Cost数组,UEqZeroAbilityCost父类是Uobject
UEqZeroAbilityCost 的子类实现CheckCost,ApplyCost
这里面通过拿到Actor,Control 去拿到 库存组件等做一些逻辑
GameplayTag的管理
后面再重构吧
[根命名空间] [职责] [定义位置]
─────────────────────────────────────────────────────────────────────
Ability.* 技能系统元数据(类型/行为/失败原因) C++ Native
State.* 实体状态标签(替代旧 Status) C++ Native
Input.* 输入映射标签 C++ Native
Event.* GAS GameplayEvent 事件 C++ Native + INI
Effect.* GE 分类(伤害类型/治疗类型/Trait) INI
Cue.* GameplayCue 视觉/音效 INI
Message.* GameplayMessageSubsystem 通道 INI
Movement.* 移动模式 C++ Native
Phase.* 游戏流程/阶段 INI(GameFeature)
UI.* UI 层级/HUD 插槽 INI
Platform.* 平台能力检测 INI
Cosmetic.* 外观/表现 INI
SetByCaller.* GE 数值传递 C++ Native
Cheat.* 调试作弊 C++ Native
技能实现总结
基于当前阶段,可能不完全
GA_Jump
bool CanActivateAbility(...) {
return EqZeroCharacter->CanJump()
}
void CharacterJumpStart() {
if (Character->IsLocallyControlled() && !Character->bPressedJump) {
Character->UnCrouch()
Character->Jump();
}
}
void CharacterJumpStop() {
if (Character->IsLocallyControlled() && !Character->bPressedJump) {
Character->StopJumping();
}
}
void ActivateAbility(...)
CharacterJumpStart()
AbilityTask_StartAbilityState("Jumping")
OnStateEnd -> CharacterJumpStop()
OnStateInterruped-> CharacterJumpStop()
AbilityTask_WaitInputRelease
OnRelease->EndAbility
简单的伪代码,包括蓝图逻辑。所以,跳跃键没有松开的时候,角色虽然落地了,但是技能并没有结束。
GA_Dash
主要流程
- 方向计算 — 玩家 vs AI 双路径
玩家 GetLastMovementInputVector(输入方向)+ 摄像机前向(含 Z 轴 1.2x 偏移)
AI NavigationSystem::ProjectPointToNavigation 投影到导航网格获取导航方向,退化时使用 Actor 朝向
- 方向蒙太奇选择 — 角度区间映射
根据前向量和 输入向量的Yaw插值,判断用哪个蒙太奇做动作
void ActivateAbility(...)
{
if (IsLocallyControlled())
{
// 计算移动意图,获取移动方向和该方向的蒙太奇
Direction = ...;
Montage = ...;
// 提交技能
CommitAbility(...);
if (HasAuthority(&ActivationInfo))
{
// 单机或 Listen Server:直接执行
ExecuteDash(Direction, Montage);
}
else
{
// DS 客户端:本地预测执行 + RPC 通知服务器
ExecuteDash(Direction, Montage);
ServerSendInfo(Direction, Montage);
}
}
}
我们讨论DS的情况
ExecuteDash + ServerSendInfo(server rpc)
ExecuteDash:
AbilityTask_PlayMontageAndWait:播放方向蒙太奇动画(不含 Root Motion,纯姿态动画)AbilityTask_ApplyRootMotionConstantForce:施加恒定力的 Root Motion,参数包括:- Strength = 1850(推力大小)
- RootMotionDuration = 0.25s(Root Motion 持续时间)
- ClampVelocity 模式,最大速度
1000 bIsAdditive = true(叠加到现有移动)
ServerSendInfo_Implementation:
把Direction,Montage 通过RPC带到服务器了
- CommitAbility
- ExecuteDash 这里和客户端跑同样的流程
CD的配置
cooldowns里面配置一个普通的GE的子类蓝图
这是一个周期性的GE,1.5s 并且会给Owner 加一个TAG
GA_Melee
项目的GA默认是LocalPredicated
void UEqZeroGameplayAbility_Melee::ActivateAbility(...)
{
Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);
CommitAbility(Handle, ActorInfo, ActivationInfo);
PlayMeleeMontage();
if (HasAuthority(&ActivationInfo))
{
MeleeCapsuleTrace();
}
}
双端的逻辑
Commit 技能
PlayMontageAndWait
服务端执行碰撞检测
如果检测到了:
ApplyRootMotion, Apply GE 和 Cue
这里GE是一个伤害的GE,Cue还没写。
项目的伤害GE配置
/Game/GameplayEffects/Damage/GameplayEffectParent_Damage_Basic.GameplayEffectParent_Damage_Basic
/Game/GameplayEffects/Damage/GE_Damage_Basic_Instant.GE_Damage_Basic_Instant
/Game/GameplayEffects/Damage/GE_Damage_Basic_Periodic.GE_Damage_Basic_Periodic
GameplayEffectParent_Damage_Basic
配置了一个 GE.Damage.Basic 的 TAG 和 Execution伤害计算器,还有一点伤害
子类两个:
- GE_Damage_Basic_Instant
- 追加了一个TAG GE.Damage.Instant 描述瞬时伤害
- GE_Damage_Basic_Periodic
- 追加了一个TAG GE.Damage.Periodic 描述周期伤害,但是周期配置是。无限的1s间隔触发
瞬时伤害GE_Damage_Basic_Instant的子类包括
- Melee
- 追加TAG GE.Damage.Melee
- 修改了伤害值
- RifleAuto
- 追加TAG GE.Damage.RifleAuto
- 修改了伤害值
- Pistol
- 追加TAG GE.Damage.Pistol
- 修改了伤害值
周期伤害GE_Damage_Basic_Periodic的子类
/Game/Environments/Gameplay/GE_GameplayEffectPad_Damage.GE_GameplayEffectPad_Damage
追加了TAG,伤害值改了一下。
地面上哪个周期性扣血的圈。
GA_Death
生命值组件检测到 HP 小于0的时候,通过 event 触发的。
他配置了CameraMode
取消和阻塞所有 Ability.Type.Action的技能。这个和 TagRelationshipMapping 好像有点重复了
ServerInitiated 这是一个服务器技能
void ActivateAbility(...)
- 取消所有技能除了 Ability_Behavior_SurvivesDeath(重生技能) 和自己的技能
- 触发 StartDeath,在 ASC的 LooseGameplayTag 标记一个 Status_Death_Dying = 1 【广播事件】
- 这个事件做了很多事,例如B_Hero_Default里面,播放蒙太奇,布娃娃
- 设置摄像机模式
- 触发GameplayCue
- 播放例子特效,设置材质消融
- 添加WaitDelay任务,等8s后 死亡结束
GA_AutoRespawn
注意这是一个OnSpawn技能
所以他监听的是 生命值组件的 OnDeathStarted 事件
他会在 OnDeathStarted 触发的时候注册一个timer 5s 去激活 OnPlayerReset
OnPlayerReset 的时候
- 去结束死亡技能
- 调用 GameMode->RequestPlayerRestartNextFrame,这会触发 APlayerController::ServerRestartPlayer_Implementation 的流程,从而走到UE的重生里面。
ServerRestartPlayer_Implementation
这里面会有一些GameMode重写的函数,例如
GameMode->PlayerCanRestart
GameMode->RestartPlayer(this);
===
开火技能
/Game/Weapons/GA_Weapon_Fire.GA_Weapon_Fire 核心逻辑是这个,
子类手枪和自动步枪的开火,主要是配置上的修改
这个前两篇很详细了略
交互技能
/Game/Input/Abilities/GA_Interact.GA_Interact 父类 UEqZeroGameplayAbility_Interact
这是一个OnSpawn技能,
激活触发了 UAbilityTask_GrantNearbyInteraction 会角色周围扫描范围内的交互物(实现了对应接口的物体)
同时在蓝图的激活时,他又会 寻找交互物,这次是一条射线,找到会显示一个交互UI,按G可以调用上面获得的交互技能。
例如他从某个石头上读取到了交互技能 /Game/Interact/GA_Interaction_Collect.GA_Interaction_Collect
开镜技能
LocalPredicted
比较简单,但是IMC里面还缺点东西。
void UEqZeroGameplayAbility_ADS::ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData)
{
Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);
// 切换 ADS 摄像机
SetCameraMode(DefaultCameraMode);
// 缓存默认移速,并应用 ADS 移速倍率
if (AEqZeroCharacter* Character = GetEqZeroCharacterFromActorInfo())
{
if (UCharacterMovementComponent* Movement = Character->GetCharacterMovement())
{
MaxWalkSpeedDefault = Movement->MaxWalkSpeed;
Movement->MaxWalkSpeed = MaxWalkSpeedDefault * ADSMultiplier;
}
}
// 添加 ADS 输入映射上下文(更高优先级使移速修改器生效)
if (AEqZeroPlayerController* PC = GetEqZeroPlayerControllerFromActorInfo())
{
if (UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PC->GetLocalPlayer()))
{
if (ADSInputMappingContext)
{
FModifyContextOptions Options;
Options.bForceImmediately = true;
Subsystem->AddMappingContext(ADSInputMappingContext, ADSMappingContextPriority, Options);
}
}
}
// 本地端:通知 UI、播放音效
if (IsLocallyControlled())
{
BroadcastToUI(true);
if (ZoomInSound)
{
UGameplayStatics::PlaySound2D(GetWorld(), ZoomInSound);
}
}
// 等待输入松开后结束技能
UAbilityTask_WaitInputRelease* WaitInputReleaseTask = UAbilityTask_WaitInputRelease::WaitInputRelease(this, true);
WaitInputReleaseTask->OnRelease.AddDynamic(this, &UEqZeroGameplayAbility_ADS::OnInputReleased);
WaitInputReleaseTask->ReadyForActivation();
}