前言 这是个人动作游戏DEMO的搓招系统实现一些实现笔记,主要在于AnimNotifyState、GAS、EnhencedInput的适配改造。
该DEMO主要灵感来源为流星蝴蝶剑.net。具备搓招操作和连招设计。这篇文章主要讨论如何实现的搓招连招系统。
基础知识 AnimNotifyState AnimNofityState 是UE中,为动画或蒙太奇播放过程中触发特定逻辑的一种机制。
相较于AnimNotify 只会在固定时间轴上触发的离散机制,AnimNotifyState 提供了完整的开始、结束、Tick功能,因此在游戏开发中,更容易用AnimNotifyState 来作为动画相关逻辑机制的载体。
AnimNotifyState实例绑定于动画 在开发中可以发现,在一个动画中,添加多个相同的ANS 时,会产生不同的实例。
但是对于一个动画,由不同的Actor进行播放时,会发现实例的地址都是相同的,即是多个不同的Actor会触发同样的实例。
这就导致了使用ANS 时,不应该在内部存储状态。
这里个人采取的做法时将相关数据直接绑定到通过MeshComponent 获得的Actor 上。ANS 只进行逻辑调整,不存储信息。
听着很像ECS的System。
AnimNotifyState的触发逻辑 UAnimInstance::TriggerAnimNotifies 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 USkeletalMeshComponent* SkelMeshComp = GetSkelMeshComponent (); TArray<FAnimNotifyEvent> NewActiveAnimNotifyState; NewActiveAnimNotifyState.Reserve (NotifyQueue.AnimNotifies.Num ()); TArray<FAnimNotifyEventReference> NewActiveAnimNotifyEventReference; NewActiveAnimNotifyEventReference.Reserve (NotifyQueue.AnimNotifies.Num ()); TArray<const FAnimNotifyEvent *> NotifyStateBeginEvent; TArray<const FAnimNotifyEventReference *> NotifyStateBeginEventReference; for (int32 Index=0 ; Index<NotifyQueue.AnimNotifies.Num (); Index++) { if (const FAnimNotifyEvent* AnimNotifyEvent = NotifyQueue.AnimNotifies[Index].GetNotify ()) { if (AnimNotifyEvent->NotifyStateClass) { int32 ExistingItemIndex = INDEX_NONE; if (ActiveAnimNotifyState.Find (*AnimNotifyEvent, ExistingItemIndex)) { check (ActiveAnimNotifyState.Num () == ActiveAnimNotifyEventReference.Num ()); ActiveAnimNotifyState.RemoveAtSwap (ExistingItemIndex, 1 , false ); ActiveAnimNotifyEventReference.RemoveAtSwap (ExistingItemIndex, 1 , false ); } else { NotifyStateBeginEvent.Add (AnimNotifyEvent); NotifyStateBeginEventReference.Add (&NotifyQueue.AnimNotifies[Index]); } NewActiveAnimNotifyState.Add (*AnimNotifyEvent); FAnimNotifyEventReference& EventRef = NewActiveAnimNotifyEventReference.Add_GetRef (NotifyQueue.AnimNotifies[Index]); EventRef.SetNotify (&NewActiveAnimNotifyState.Top ()); continue ; } TriggerSingleAnimNotify (NotifyQueue.AnimNotifies[Index]); } }
遍历目前的所有ANE ,对于AN 类型直接触发事件
对于ANS 类型,根据现有的已激活ANS ,分成新激活和已激活的两类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 for (int32 Index = 0 ; Index < ActiveAnimNotifyState.Num (); ++Index) { const FAnimNotifyEvent& AnimNotifyEvent = ActiveAnimNotifyState[Index]; const FAnimNotifyEventReference& EventReference = ActiveAnimNotifyEventReference[Index]; if (AnimNotifyEvent.NotifyStateClass && ShouldTriggerAnimNotifyState (AnimNotifyEvent.NotifyStateClass)) { { AnimNotifyEvent.NotifyStateClass->NotifyEnd (SkelMeshComp, Cast <UAnimSequenceBase>(AnimNotifyEvent.NotifyStateClass->GetOuter ()), EventReference); } } if (ActiveAnimNotifyState.IsValidIndex (Index) == false ) { ensureMsgf (false , TEXT ("UAnimInstance::ActiveAnimNotifyState has been invalidated by NotifyEnd. AnimInstance: %s, Owning Component: %s, Owning Actor: %s " ), *GetNameSafe (this ), *GetNameSafe (GetOwningComponent ()), *GetNameSafe (GetOwningActor ())); return ; } }
对于所有已经不在的ANS 调用结束事件
1 2 3 4 5 6 7 8 9 10 11 12 for (int32 Index = 0 ; Index < NotifyStateBeginEvent.Num (); Index++) { const FAnimNotifyEvent* AnimNotifyEvent = NotifyStateBeginEvent[Index]; const FAnimNotifyEventReference * AnimNotifyEventReference = NotifyStateBeginEventReference[Index]; if (ShouldTriggerAnimNotifyState (AnimNotifyEvent->NotifyStateClass)) { { AnimNotifyEvent->NotifyStateClass->NotifyBegin (SkelMeshComp, Cast <UAnimSequenceBase>(AnimNotifyEvent->NotifyStateClass->GetOuter ()), AnimNotifyEvent->GetDuration (), *AnimNotifyEventReference); } } }
将新出现的的ANS 调用开始事件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ActiveAnimNotifyState = MoveTemp (NewActiveAnimNotifyState); ActiveAnimNotifyEventReference = MoveTemp (NewActiveAnimNotifyEventReference); for (int32 Index = 0 ; Index < ActiveAnimNotifyState.Num (); Index++){ const FAnimNotifyEvent& AnimNotifyEvent = ActiveAnimNotifyState[Index]; const FAnimNotifyEventReference& EventReference = ActiveAnimNotifyEventReference[Index]; if (ShouldTriggerAnimNotifyState (AnimNotifyEvent.NotifyStateClass)) { { AnimNotifyEvent.NotifyStateClass->NotifyTick (SkelMeshComp, Cast <UAnimSequenceBase>(AnimNotifyEvent.NotifyStateClass->GetOuter ()), DeltaSeconds, EventReference); } } }
对现有的所有ANS 触发TICK事件,并且将激活情况保存
总结
AN触发
ANS结束事件
ANS开始事件
ANS帧事件
根据部分资料上判断,ANS和AN、同类事件的触发顺序不保证一致。在后续涉及到网络同步时需要谨慎处理
AnimNotify 相关触发流程在AnimInstance 上,AnimIntance 是一个对应于Actor 的动画控制系统。因此不必太关心动画提前结束、打断等是否会导致结束事件不能正确触发的相关问题
参考资料 [UE4/UE5 动画通知AnimNotify AnimNotifyState源码解析](UE4/UE5 动画通知AnimNotify AnimNotifyState源码解析)
格斗游戏基本概念 这是一些格斗游戏系统中存在的基本概念,在本DEMO中也存在对应的类似概念
招式 一段连续的动作,在这个动作中,会存在对敌人造成伤害等效果。往往由自己输入而开始一段招式,但是也有可能由其他情况进入一段招式。例如受击可以视作为一种由别人而开始特殊招式。
硬直 在招式中。大部分输入都不可用,这些输入不能激活其他招式。
取消 & 连招 在招式中的某些特定时间点,可以用某些特定输入直接激活其他招式
连招系统实现 由于EnhancedInput已经提供了combo系统,可以满足初步的搓招需求。因此这部分暂且不考虑连招输入的相关设计,直接使用EnhancedInput的相关功能实现连招的输入。
这里主要在介绍对于输入的技能激活逻辑。
基本思路
每个搓招输入会对应一个InputAction
IA 对应的要激活GA 不固定
动作游戏,因此基本上每个个GA 都包括一个或多个Montage 的动作表现。这里将这种GA 称之为ActionGA ,即格斗游戏的招式
使用ActionGA 相当于进入一段动作状态,在这个情况下,不可以激活常规的ActionGA ,即格斗游戏的硬直
但是可以在ActionGA 中可以通过某些特定的IA 打断当前的ActionGA 触发一个新的ActionGA ,即格斗游戏的取消和连招
取消时,IA 所激活的ActionGA 不一定和通常状态下同一IA 激活的ActionGA 一致
因此在的Montage 的时间轴用AnimNotifyState 进行标记,用于IA 和对应ActionGA 之间的关系
输入和激活ActionGA的关系主要分成三种:
连招:在一个ActionGA 中只能激活造成特定的连招ActionGA
基本:不在ActionGA 中,可以激活当前的基本ActionGA
始终:始终会尝试激活的ActionGA
IA激活ActionGA 输入方面,对于所有连招的InputAction 绑定到一个通用的函数上。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 void ADemoPlayerGASCharacterBase::BindActionInputAction (TObjectPtr<UInputAction> IA) { if (!BindedInputActions.Contains (IA) && BindEnhancedInputComponent) { BindEnhancedInputComponent->BindAction (IA, ETriggerEvent::Triggered, this , &ADemoPlayerGASCharacterBase::UseActionGameplayAbility); BindedInputActions.Add (IA); } } void ADemoPlayerGASCharacterBase::UseActionGameplayAbility (const FInputActionInstance& InputInstance) { const UInputAction* Action = InputInstance.GetSourceAction (); if (AbilitySystemComponent.IsValid ()) { if (AbilitySystemComponent->HasMatchingGameplayTag (OnActionTag)) { FGameplayAbilitySpecHandle * comboSpec = InputActionToComboAbilityMap.Find (Action); if (comboSpec) { AbilitySystemComponent->TryActivateAbility (*comboSpec, true ); } } else { FGameplayAbilitySpecHandle * basicSpec = InputActionToBasicAbilityMap.Find (Action); if (basicSpec) { AbilitySystemComponent->TryActivateAbility (*basicSpec, true ); } } if (InputActionToAlwaysAbilityMap.Contains (Action)) { FGameplayAbilitySpecHandle * alwaysSpec = InputActionToAlwaysAbilityMap.Find (Action); if (alwaysSpec) { AbilitySystemComponent->TryActivateAbility (*alwaysSpec, true ); } } } }
在ActionGA中,增添对应IA的配置项来配置三种输入。
存储三种情况下IA 和ActionGA 的关系
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 bool ADemoPlayerGASCharacterBase::onAddActionGameplayAbility (TSubclassOf<UActionGameplayAbility> Ability, FGameplayAbilitySpecHandle AbilitySpecHandle) { if (Ability && GetLocalRole () == ROLE_Authority && AbilitySystemComponent.IsValid ()) { ActionAbilityToSpec.Add (Ability, AbilitySpecHandle); TArray<TObjectPtr<UInputAction>> AbilityInputActions; for (UInputAction * IA: Ability->GetDefaultObject <UActionGameplayAbility>()->BasicInputActions) { BindActionInputAction (IA); InputActionToBasicAbilityMap.Add (IA, AbilitySpecHandle); AbilityInputActions.Add (IA); } for (UInputAction * IA: Ability->GetDefaultObject <UActionGameplayAbility>()->ComboInputActions) { BindActionInputAction (IA); AbilityInputActions.Add (IA); } for (UInputAction * IA: Ability->GetDefaultObject <UActionGameplayAbility>()->AlwaysInputActions) { BindActionInputAction (IA); InputActionToAlwaysAbilityMap.Add (IA, AbilitySpecHandle); AbilityInputActions.Add (IA); } ActionAbilityToInputActionMap.Add (Ability, AbilityInputActions); return true ; } return false ; }
改动ASC,让其赋予技能时主动调起绑定关系
1 2 3 4 5 6 7 8 9 10 11 12 13 void UCharacterAbilitySystemComponent::OnGiveAbility (FGameplayAbilitySpec& AbilitySpec) { Super::OnGiveAbility (AbilitySpec); if (UActionGameplayAbility* Ability = Cast <UActionGameplayAbility>(AbilitySpec.Ability)) { if (ADemoPlayerGASCharacterBase* character = Cast <ADemoPlayerGASCharacterBase>(GetAvatarActor ())) { character->onAddActionGameplayAbility (Ability->GetClass (), AbilitySpec.Handle); } } }
ANS激活Combo输入 在AnimNotifyState 中存储ActionGA 和可以用于激活的IA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 void UComboAnimNotifyState::NotifyBegin (USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, float TotalDuration, const FAnimNotifyEventReference& EventReference) { Super::NotifyBegin (MeshComp, Animation, TotalDuration, EventReference); AActor* OwnerActor = MeshComp->GetOwner (); if (ADemoPlayerGASCharacterBase * Character = Cast <ADemoPlayerGASCharacterBase>(OwnerActor)) { Character->ActiveActionGameplayAbilityComboInput (ActionGameplayAbility, ComboInputActions); } } void UComboAnimNotifyState::NotifyEnd (USkeletalMeshComponent * MeshComp, UAnimSequenceBase * Animation, const FAnimNotifyEventReference& EventReference) { Super::NotifyEnd (MeshComp, Animation, EventReference); AActor* OwnerActor = MeshComp->GetOwner (); if (ADemoPlayerGASCharacterBase * Character = Cast <ADemoPlayerGASCharacterBase>(OwnerActor)) { Character->DeActiveActionGameplayAbilityComboInput (ActionGameplayAbility, ComboInputActions); } }
激活IA 和连招ActionGA 的关系
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 void ADemoPlayerGASCharacterBase::DeActiveActionGameplayAbilityComboInput (TSubclassOf<UActionGameplayAbility> Ability, TArray<UInputAction*> InputActions) { FGameplayAbilitySpecHandle AbilitySpecHandle = ActionAbilityToSpec[Ability]; for (UInputAction * InputAction: InputActions) { if (InputActionToComboAbilityMap.Find (InputAction)) { InputActionToComboAbilityMap.Remove (InputAction); } } } void ADemoPlayerGASCharacterBase::ActiveActionGameplayAbilityComboInput (TSubclassOf<UActionGameplayAbility>Ability, TArray<UInputAction*> InputActions) { FGameplayAbilitySpecHandle AbilitySpecHandle = ActionAbilityToSpec[Ability]; for (UInputAction * InputAction: InputActions) { InputActionToComboAbilityMap.Add (InputAction, AbilitySpecHandle); } }
总结 Q & A Q:为什么不通过GameplayTag确定激活能力,通过TryActivateAbilitiesByTag批量尝试触发,GA再通过Tag禁止触发? A:因为这是一个搓招的动作游戏,技能触发会和输入具备密切相关性。例如一个动作希望在不同动作阶段中用不同的输入操作触发,还要避免被不合适的输入触发,这样的功能GA 并没有相关机制提供。
GA的Tag机制只能一层条件判定,对于复杂情况并不适宜。无论是增添Tag判定机制还是维护一套能表达状态的Tag都会提高复杂性。
而且本身GA 和EnhancedInput 也没有什么联动机制,不如根据需求,直接实现一整套。Tag则更关注角色状态和GA触发的关系,而非输入。