8. Gameplay Abilities/Ability Tasks

groot616·2024년 8월 13일

8. Gameplay Abilities

목차

  1. Gameplay Ability 정의
  2. Gameplay Ability 생성
  3. Gameplay Ability 실행
  4. 클릭으로 캐릭터 이동(GA에 추가한 RMB)
  5. Projectile 생성
  6. Ability Tasks
  7. GameplayEffect를 통한 Attribute값 변경
  8. 적 Attribute 변경점 확인을 위한 Widget 추가

8.1 Gameplay Ability 정의

GameplayAbilityUGameplayAbility 로부터 파생된 클래스를 통해 생성한다.
해당 클래스를 통해 스킬 및 사용 조건 등을 정의한다.
간단한 함수를 실행시키는 것이 아닌, 동기화를 통해 실행되는 인스턴스 객체이다.

어느 시점이나 지점에서 발동되면 multi-stage tasks가 진행된다.
GameplayEffect 를 적용시키거나, GameplayCue 를 재생시키거나, 등이 동기화를 통해 실행된다.
또한 코스트(마나)나 쿨다운과 같은 것들을 적용시킬 수 있다.

GameplayAbilityUAbilityTask 클래스로부터 파생된 AbilityTask 를 사용한다.
AbilityTaskGameplayAbility 가 실행될 때 동기화 작업을 진행하는데, 이러한 작업은 Broadcast Delegate를 통해 진행될 수 있다.

GameplayAbility 를 사용하기 위해서는 ASC 에서 반드시 스킬에 대한 승인을 받아야 한다.
승인시 GameplayAbilitySpec 이 만들어지고, Spec 이 해당 스킬의 세부 정보를 내용을 정의하는데, 여기에는 GameAbility 클래스, 스킬 레벨, 런타임에 변경될 수 있는 동적 정보들이 포함된다.

원문
When this happends, a GameplayAbilitySpec is created and the Spec defines the details pertaining to that ability, including the GameplayAbility class itself, the abilities level and any dynamic information that can be changed at runtime.

스킬은 일반적으로 서버에 의해 승인되고, 이러한 동작이 발생시 AbilitySpec 은 해당 스킬 소유 클라이언트에게 레플리케이트를 진행하여 스킬을 발동가능하도록 한다.

한번 발동된 스킬은 끝나거나 캔슬될 때 까지 발동된다.

전체 요약

  • GameplayAbility 는 스킬이나 능력을 정의
  • 능력은 사용하기 위해서 ASC에 의해 반드시 승인받아야 하고, 이는 서버에서 진행된다. 서버에서 승인받은 AbilitySpec 은 클라이언트에게 레플리케이트를 진행한다.
  • 캔슬되거나 끝날때까지 사용하기 위해 반드시 발동되어야 한다.
  • 코스트나 쿨다운과 같은 개념을 가질 수 있다.
  • Ability 는 동기화 동작을 할 수 있고, 다양한 Abilitty` 들이 동기화를 통해 한번에 발동될 수 있다.
  • Ability각각의 작업에 대해 동작하는 개별적인 클래스들을 캡슐화한 동기화 액션인 AbilitiyTasks 들을 작동시킬 수 있다.
    원문 : An Abilities can run AbilityTasks which are asynchronous actions that encapsulate behaviors into individual classes, each of which can perfom their own particular job.

8.2 Gameplay Ability 생성

먼저 GameplayAbility 기반 클래스인 AuraGameplayAbility 를 생성한다.

캐릭터가 Ability 를 사용할 수 있도록 관련 코드를 작성한다.
먼저 서버에서 Ability 에 대한 승인을 받기 위해 AuraCharacterBase.h 에서 코드를 작성한다.

  • AuraCharacterBase.h
// AuraCharacterBase.h

...
class UGameplayAbility;
...

protected:
	...
    
    // 캐릭터가 Ability 사용 가능하도록 하는 함수
    void AddCharacterAbilities();
    
private:
	// Ability 클래스 배열
	UPROPERTY(EditAnywhere, Category = "Abilities")
	TArray<TSubclassOf<UGameplayAbility>> StartupAbilities;
  • AuraCharacterBase.cpp
// AuraCharacterBase.cpp

...

void AAuraCharacterBase::AddCharacterAbilities()
{
	// 승인을 통한 권한 획득 확인
	if(!HasAuthority()) return;
    
    // 권한 확인 후 ASC에서 다룰 수 있도록 코드 작성
}

이어서 AuraAbilitySystemComponent 에서 Ability 를 추가할 수 있도록 함수를 하나 추가한다.

  • AuraAbilitySystemComponent.h
// AuraAbilitySystemComponent.h

...

public:
	...
    
    // ASC에 Ability를 추가하기 위한 함수
    void AddCharacterAbilities(const TArray<TSubclassOf<UGameplayAbility>>& StartupAbilities);
  • AuraAbilitySystemComponent.cpp
// AuraAbilitySystemComponent.cpp

...

void AuraAbilitySystemComponent::AddCharacterAbilities(const TArray<TSubclassOf<UGameplayAbility>>& StartupAbilities)
{
	// 반복문을 통해 Ability들에 대한 Spec을 만들고 Ability 사용을 가능하도록 함
	for(TSubclassOf<UGameplayAbility> AbilityClass : StartupAbilities)
    {
    	FGameplayAbilitySpec AbilitySpec = FGameplayAbilitySpec(AbilityClass, 1);
        //GiveAbility(AbilitySpec);
        GiveAbilityAndActivateOnce(AbilitySpec);
    }
}

...

다시 AuraCharacterBase 로 돌아와 캐릭터가 자신의 ASC에 Ability 를 추가할 수 있도록 코드를 추가한다.

  • AuraCharacterBase.cpp
// AuraCharacter.cpp

...
#include "AbilitySystem/AuraAbilitySystemComponent.h"
...

...

void AAuraCharacterBase::AddCharacterAbilities()
{
	UAuraAbilitySystemComponent* AuraASC = CastChecked<UAuraAbilitySystemComponent>(AbilitySystemComponent);
	if(!HasAuthority()) return;
    
    AuraASC->AddCharacterAbilities(StartupAbilities); 
}

...

그리고 AuraCharacterBase 기반 클래스인 AuraCharscter 클래스에서 컨트롤러를 소유할 때 Ability 를 사용할 수 있도록 부모 클래스의 AddCharacterAbilities() 함수를 호출한다.

  • AuraCharacter.cpp
// AuraCharacter.cpp

...

void AAuraCharacter::PossessedBy(AController* NewController)
{
	Super::PossessedBy(AController* NewController);
	...
    AddCharacterAbilities();
}
...

요약
1. AuraCharacterBase 에서 Ability 클래스를 추가가능하도록 함
2. 해당 함수에서 ASC에 접근한 다음 ASC에 있는 AddCharacterAbilities() 함수에 Ability 클래스인 StartupAilibites을 전달, 권한이 없으면 return
3. ASC에서는 받은 StartupAbilities 클래스들을 활성화(스킬실행)시킴
4. AuraCharacterBase 기반 클래스 AuraCharacter 가 컨트롤러를 소유하게 되면 자동으로 Ability를 가지도록 AddCharacterAbilities 함수 실행.
AuraCharacterBase 에서는 권환 확인 및 ASC에게 Ability 전달, ASC에서는 Ability 활성화, 캐릭터는 컨트롤러 소유(게임시작)하자마자 부모 클래스의 함수를 통해 Ability 획득

컴파일 후 에디터로 돌아와서 AuraGameplayAbility 클래스 기반 GA_TestGameplayAbility 클래스를 생성한다.

GA_TestGameplayAbility 에서 Print String 노드로 Test Ability Activated 가 출력되도록 구성한다.
동일하게 Ability 가 끝났을 때에도 디버그 메세지를 표시하기 위해 Print String 노드를 통하여 메세지를 출력하도록 한다.

마지막으로 BP_AuraCharacter 에서 Startup Abilities 에 생성해둔 GA_TestGameplayAbility 를 추가하고 컴파일 후 실행한다.

실행시 Test Ability Activated 가 출력되는 것을 확인할 수 있다.

Ability 가 끝나는 조건을 추가하지 않았으므로Event OnEndAbility 가 실행되지 않는다. 그러므로 Delay 를 통한 End Ability 가 발생되도록 한다.

컴파일 후 실행시 5초뒤에 Test Ability Ended 가 출력되는 것을 확인할 수 있다.

GA_TestGameplayAbility 에서 Class Defaults 를 선택하고 우측 Details 탭을 확인하면 다양한 옵션이 존재하는 것을 확인할 수 있다.

  • Tags
GameplayTagDescription
Ability Tags해당 Ability의 태그
Cancel Abilities with Tag해당 Ability 실행시, 이 태그가 있는 Ability들은 취소됨
Block Abilities with Tag해당 Ability 작동 도중, 이 태그가 있는 Ability들은 Block당함
Activation Owned Tags해당 Ability가 활성화되어 있는 동안 소유자에게 적용할 태그
ReplicateActivationOwnedTags가 AbilitySystemGlobals에서 활성화된 경우, 이 태그들은 복제됨
Activaiton Required Tags해당 Ability는 활성화하는 액터/컴포넌트가 이 모든 태그(설정으로 추가한 태그)를 가지고 있는 경우에만 활성화됨
(예를 들면 공중상태 + 화살같은 특정 코스트가 3개 이상 보유시 사용가능한 스킬일 때, 공중상태태그 + 스택태그 만족시 활성화)
Activation Blocked Tags해당 Ability는 활성화하는 액터/컴포넌트가 이 태그들(설정으로 추가한 태그) 중 하나라도 가지고 있으면 Block당함
(예를 들면 수중상태 태그, 공중상태 태그 보유시 지상에서 사용할 스킬들을 Block)
Source Required Tags해당 Ability는 활성화하는 Source의 액터/컴포넌트가 이 태그들(설정으로 추가한 태그)를 가지고 있는 경우에만 활성화됨
Source Blocked Tags해당 Ability는 활성화하는 Source의 액터/컴포넌트가 이 태그들(설정으로 추가한 태그) 중 하나라도 가지고 있으면 Block당함
Target Required Tags해당 Ability는 활성화하는 Target의 액터/컴포넌트가 이 태그들(썰정으로 추가한 태그)를 가지고 있는 경우에만 활성화됨
Target Blocked Tags해당 Ability는 활성화하는 Target의 액터/컴포넌트가 이 태그들(설정으로 추가한 태그) 중 하나라도 가지고 있으면 Block당함
  • Input : Replicate Input Directly
    활성화시 해당 Ability는 press/release 이벤트를 항상 서버로 복제함.
    즉, 입력을 누르거나, 매 프레임마다 입력을 받고 있다면, 서버로 전송됨.

    원문
    That means if you press input or say you are receiving input every frame, well that's going to be sent to the server. Not really the best idea to use this.

  • Advanced

    • Replication Policy
      리플리케이션 정책, 이미 서버에서 자동으로 리플리케이트하므로 건드릴 필요 없음

    • Instancing Policy

      Instance PolicyDescriptionDetails
      Instanced Per Actor하나의 인스턴스가 생성된 후 각 활성화 시에 재사용됨영구 데이터를 저장할 수 있음
      변수를 매번 수동으로 초기화해야함
      Instanced Per Execution각 활성화 시마다 새로운 인스턴스가 생성됨활성화 사이에 영구 데이터를 저장하지 않음
      액터별 인스턴스화보다 성능이 낮음
      Non- Instanced클래스 기본 객체만 사영되며, 인스턴스는 생성되지 않음상태를 저장할 수 없으며, AbilityTasks의 델리게이트에 바인딩할 수 없음
      세가지 옵션 중성능이 가장 뛰어남
    • Server Respects Remote Ability Cancellation
      클라이언트에서 Ability를 취소하려는 요청이 있을 때, 서버가 이를 존중하고 해당 능력을 취소하는지 여부 결정.
      활성화시 클라이언트가 Ability 취소를 요청하면 서버에서 요청을 받아들임.
      빠른 반응이 필요한 게임 프레이에서 클라이언트의 Ability 취소 요청이 중요한 경우 키는 것이 유리하고, 서버의 일관성을 유지하고자 할 때는 설정을 끌 수 있음.

    • Retrigger Instanced Ability
      이미 활성화된 Ability가 다시 트리거(재활성화)될 수 있는지 여부를 결정.

    • Net Execution Policy
      Ability가 네트워크 상에서 어떻게 실행되는지를 정의하는 설정

      Net Execution PolicyDescription
      Local OnlyAbility가 오직 로컬(클라이언트)에서만 실행됨
      서버는 Ability를 실행하지 않음
      Local Predicted로컬 클라이언트에서 활성화한 후 서버에서 활성화됨
      예측 기능을 사용함
      서버는 유효하지 않은 변경 사항을 롤백할 수 있음
      Server Only서버에서만 실행됨
      Server Initiated서버에서 먼저 실행되고, 그다음 소요하는 로컬 클라이언트에서 실행됨
    • Net Security Policy
      클라이언트와 서버 간의 데이터 전송 및 실행에 대한 보안을 설정하는 방침

  • Costs
    코스트 관련

    • Cost Gameplay Effect Class
  • Triggers
    Ability 발동 조건

    • Ability Triggers
  • Cooldonws
    쿨다운 관련

    • Cooldown Gameplay Effect Class

사용하지 않을 옵션

  • Replication Policy : 이미 서버에서 클라이언트로 자동으로 리플리케이트하므로 건드릴 필요 없음.
  • Server Respects Remote Ability Cancellation
  • Replicate Input Directly

8.3 Gameplay Ability 실행

8.3.1 Input Action과 태그 생성 및 매핑

Input Action 과 태그를 링크하여 Gameplay Ability 를 사용 가능하도록 만들 수 있다.
먼저 DataAsset 클래스 기반의 AuraInputConfig 클래스를 생성한다.

  • AuraInputConfig.h
// AuraInputConfig.h

...
#include "GameplayTagContainer.h"
...

// InputAction과 InputTag를 묶어놓은 구조체
USTRUCT(BlueprintType)
struct FAuraInputAction
{
	GENERATED_BODY()
    
    UPROPERTY(EditDefaultsOnly)
    const class UInputAction* InputAction = nullptr;
	
    UPROPERTY(EditDefaultsOnly)
    FGameplayTag InputTag = FGameplayTag();
};

...

public:
	// InputTag에 맞는 InputAction을 찾는 함수
	const UInputAction* FindAbilityInputActionForTag(const FGameplayTag& InputTag, bool bLogNotFound = false) const;

	// InputAction, InputTag 구조체배열
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
	TArray<FAuraInputAction> AbilityInputActions;
  • AuraInputConfig.cpp
// AuraInputConfig.cpp

...
#include "InputAction.h"
...

const UInputAction* UAuraInputConfig::FindAbilityInputActdionFortag(const FGameplayTag& InputTag, bool bLogNotFound = false) const
{
	// 루프를 통한 AbilityInputAction 배열 순회
	for(const FAuraInputAction& Action : AbilityInputActions)
    {
    	// Action.InputAction 유효성 검사 && Action.InputTag가 함수의 파라미터와 동일한지 확인
    	if(Action.InputAction && Action.InputTag == InputTag)
        {
        	return Action.InputAction;
        }
    }
    
    // 오류 발생시 로그 출력
    if(bLogNotFound)
    {
    	UE_LOG(LogTemp, Error, TEXT("Can't find AbilityInputAction for InputTag [%s], on InputConfig [%s]"), *InputTag.ToString(), *GetNameSafe(this));
    }
    
    return nullptr;
}

이제 입력에 사용될 태그를 추가해야 한다.

  • AuraGameplayTags.h
// AuraGameplayTags.h

...
public:
	...
    
    /*
     *Primary Attributes
     */
    ...
    
    /* 
     *Secondary Attributes
     */
    ...
    
    /*
     *InputTags
     */
    FGameplayTag InputTag_LMB;
    FGameplayTag InputTag_RMB;
    FGameplayTag InputTag_1;
    FGameplayTag InputTag_2;
    FGameplayTag InputTag_3;
    FGameplayTag InputTag_4;
    
    ...
  • AuraGameplayTags.cpp
// AuraGameplayTags.cpp

...

void FAuraGameplayTags::InitializeNativeGameplayTags()
{
	/*
     * Primary Attributes
     */
    ...
     
    /*
     * Secondary Attributes
     */
    ...
    
    /*
     * InputTags
     */
    GameplayTags.InputTag_LMB = UGameplayTagsManager::Get().AddNativeGameplayTag(
    	FName("InputTag.LMB"), FString("Input Tag for Left Mouse Button"));
        
    GameplayTags.InputTag_RMB = UGameplayTagsManager::Get().AddNativeGameplayTag(
    	FName("InputTag.RMB"), FString("Input Tag for Right Mouse Button"));
        
    GameplayTags.InputTag_1 = UGameplayTagsManager::Get().AddNativeGameplayTag(
    	FName("InputTag.1"), FString("Input Tag for 1 key"));
        
    GameplayTags.InputTag_2 = UGameplayTagsManager::Get().AddNativeGameplayTag(
    	FName("InputTag.2"), FString("Input Tag for 2 key"));
        
    GameplayTags.InputTag_3 = UGameplayTagsManager::Get().AddNativeGameplayTag(
    	FName("InputTag.3"), FString("Input Tag for 3 key"));
        
    GameplayTags.InputTag_4 = UGameplayTagsManager::Get().AddNativeGameplayTag(
    	FName("InputTag.4"), FString("Input Tag for 4 key"));
};

컴파일 후 에디터로 돌아와서 Project Settings -> GameplayTags 에 태그가 정상적으로 추가되었는지 확인한다.

Data Asset 을 만들기 위해 AuraInputConfig 기반 블루프린트 클래스인 DA_AuraInputConfig 를 생성한다.

Ability Input Actions 을 지정해주기 위해 Input Action 블루프린트 IA_LMB 를 생성하고, Value Type : Axis 1D (float) 로 설정해준다.

나머지 IA_RMB , IA_1 , IA_2 , IA_3 , IA_4 를 생성하고 동일하게 Value Type : Axis 1D (float) 로 설정해준다.

이어서 InputMappingContext 블루프린트인 IMC_AuraContext 에서 mapping을 진행한다.

마지막으로 DA_AuraInputConfig 로 돌아와서 Ability Input Actions 를 추가해준 다음 모든 IA 에 대해 태그를 추가시켜 준다.

8.3.2 Input에 콜백함수 바인딩하기

AuraPlayerController 클래스에 관련 코드가 있다.

참조

  • AuraPlayerController.cpp
// AuraPlayerController.cpp
void AAuraPlayerController::SetupInputComponent()
{
	Super::SetupInputComponent();
    UEnhancedInputComponent* EnhancedInputComponent = CastChecked<UEnhancedInputComponent>(InputComponent);
    // 파라미터 : (const UInputAction* Action, ETriggerEvent TriggerEvent, UObject* Object, FName FunctionName)
    EnhancedInputComponent->BindAciton(MoveAction, ETriggerEvent::Triggered, this, &AAuraPlayerContoller::Move);
};

해당 방식은 입력시 선언한 함수를 바인딩하여 실행시키는 방식인데, 이 방식은 함수를 선언하고 정의하는 과정을 거쳐야 하므로 불편함이 있다.
Custom Input Component Class 를 통해 좀 더 편리하게 진행한다.
먼저 EnhancedInputComponent 기반 클래스 AuraInputComponent 를 생성한다.

이어서 코드를 작성한다.

  • AuraInputComponent.h
// AuraInputComponent.h

...
// 템플릿 함수로 만들기 위해 전방선언이 아닌 헤더파일 include
#include "Input/AuraInputConfig.h"
...

...
public:
	// 템플릿함수
	// UserClass : 액션 바인딩시 필요
	// PressedFuncType : 키 입력시 실행시킬 함수
	// ReleasedFuncType : 키 입력 해제시 실행시킬 함수
	// HeldFuncType : 키 입력 지속시 실행시킬 함수
	template<class UserClass, typename PressedFuncType, typename ReleasedFuncType, typename HeldFuncType>
	void BindAbilityActions(const UAuraInputConfig* InputConfig, UserClass* Object, PressedFuncType PressedFunc, ReleasedFuncType ReleasedFunc, HeldFuncType HeldFunc);
};

template <class UserClass, typename PressedFuncType, typename ReleasedFuncType, typename HeldFuncType>
void UAuraInputComponent::BindAbilityActions(const UAuraInputConfig* InputConfig, UserClass* Object, PressedFuncType PressedFunc, ReleasedFuncType ReleasedFunc, HeldFuncType HeldFunc)
{
	check(InputConfig);
    
    // 루프를 통한 구조체 순회
    for(const FAuraInputAction& Action : InputConfig->AbilityInputActions)
    {
    	// 유효성 검사
    	if(Action.InputAction && Action.InputTag.IsValid())
        {
        	if(PressedFunc)
            {
            	// InputAction: IA_LMB와 같은 InputAction
				// ETriggerEvent: 입력 이벤트가 발생하는 시점 설정
				// Object: 적용 대상
				// PressedFunc: 콜백함수
				// Acitdon.InputTag: 적용 태그
                BindAction(Action.InputAction, ETriggerEvent::Started, Object, PressedFunc, Action.InputTag);
            }
            
            if(ReleasedFunc)
            {
            	BindAction(Action.InputAction, ETriggerEvent::Completed, Object, ReleasedFunc, Action.InputTag);
            }
            
        	if(HeldFunc)
            {
            	BindAction(Action.InputAction, ETriggerEvent::Triggered, Object, HeldFunc, Action.InputTag);
            }
        }
    }
}

이제 바인딩할 콜백 함수에 대해서 코드를 추가한다.

  • AuraPlyerController.h
// AuraPlayerContoller.h

...
class UAuraInputConfig;
...
struct FGameplayTag;

private:
	...
    
    void AbilityInputTagPressed(FGameplayTag InputTag);
    void AbilityInputTagReleased(FGameplayTag InputTag);
    void AbilityInputTagHeld(FGameplayTag InputTag);
    
    // FAuraInputAction 구조체 사용을 위한 변수
    UPROPERTY(EditDefaultsOnly, Category = "Input")
    TObjectPtr<UAuraInputConfig> InputConfig;
  • AuraPlyerController.cpp
// AuraPlayerController.cpp

...
#include "Input/AuraInputComponent.h"
#include "GameplayTagContainer.h"
...

...

void AAuraPlayerController::SetupInputComponent()
{
	Super::SetupInputComponent();
    
    /** 코드 수정 */
    // CustomInputComponent를 사용할 것이므로 UEnhancedInputComonent가 아닌 UAuraInputComponent를 캐스팅
    // UEnhancedInputComponent -> UAuraInputComponent
    UAuraInputComponent* AuraInputComponent = CastChecked<UAuraInputComponent>(InputComponent);
    // CustomInputComponent인 AuraInputComponent 사용
    AuraInputComponent->BindAction(MoveAction, ETriggerEvent::Triggered, this, &AAuraPlayerController::Move);
	/** 코드 수정 */
    // 세 함수를 바인딩하기 위한 코드
    AuraInputComponent->BindAbilityActions(InputConfig, this, &ThisClass::AbilityInputTagPressed, &ThisClass::AbilityInputTagReleased, &ThisClass::AbilityInputTagHeld);
}

...

void AAuraPlayerController::AbilityInputTagPressed(FGmaeplayTag InputTag)
{
	GEngine->AddOnScreenDebugMessage(1, 3.f, FColor::Red, *InputTag.ToString());
}

void AAuraPlayerController::AbilityInputTagReleased(FGmaeplayTag InputTag)
{
	GEngine->AddOnScreenDebugMessage(2, 3.f, FColor::Green, *InputTag.ToString());
}

void AAuraPlayerController::AbilityInputTagHeld(FGmaeplayTag InputTag)
{
	GEngine->AddOnScreenDebugMessage(3, 3.f, FColor::Blue, *InputTag.ToString());
}

컴파일 후 에디터로 돌아와서 Projectd Settings -> Input -> DefaultInputComponentClass : AuraInputComponent 로 변경한다.

그리고 BP_AuraPlayerController 에서 Input Config : DA_AuraInputConfig 로 설정해준다.

컴파일 후 실행하면 입력한 키에 따라 Tag가 출력되는 것을 확인할 수 있다.

이제 게임 시작시 캐릭터에게 부여할 Ability(LMB, RMB, 1, 2, 3, 4)의 태그를 추가해야 한다.
먼저 해당 Tag를 설정가능하도록 변수를 하나 생성한다.

  • AuraGameplayAbility.h
// AuraGameplayAbility.h

public:
	// 게임 시작시 캐릭터에게 부여할 Ability의 Tag(LMB, RMB, 1, 2, 3, 4 등등)
	UPROPERTY(EditDefaultsOnly, Category = "Input")
	FGameplayTag StartupInputTag;

이어서 AuraAbilitySystemComponent 클래스에서 StartupInputTag 의 유효성 검사 및 태그와 Ability를 추가하는 코드를 작성한다.

  • AuraAbilitySystemComponent.cpp
// AuraAbilitySystemComponent.cpp

...
#include "AbilitySystem/Abilities/AuraGameplayAbility.h"
...

void UAuraAbilitySystemComponent::AddCharacterAbilities(const TArray<TsubclassOf<UGameplayAbility>>& StartupAbilities)
{
	// for문 조건 수정 : const 추가
	for(const TSubclassOf<UGameplayAbility> AbilityClass : StartupAbilities)
    {
    	FGameplayAbilitySpec AbilitySpec = FGameplayAbilitySpec(AbilityClass, 1);
        /** 코드 추가 */
        if(const UAuraGameplayAbility* AuraAbility = Cast<UAuraGameplayAbility>(AbilitySpec.Ability))
        {
        	// GameplayAbilitySpec에는 게임 진행 도중에 동적으로 추가되거나 제거될 수 있는 태그 전용 GameplayTagContainer가 있음
        	// 그리고 AbilitySystemComponent가 게임 시작시 처음으로 Ability를 추가할 때
            // 이러한 능력들은 StartupAbilities이므로 StartupInputTag를 통해 확인할 수 있고
            // 해당 Ability에 대해 이 태그들을 AbilitySpec에 추가 가능함
            /*
             * AbilitySpec: 특정 Ability에 대한 사양을 담고 있는 객체
             * DynamicAbilityTags: 동적으로 추가되는 태그
             * AddTag: 새로운 태그 추가
             * AuraAbiltiy->StartupInputTag: 추가할 태그(LMB, RMB, 1, 2, 3, 4)
             */
            AbilitySpec.DynamicAbilityTags.AddTag(AuraAbility->StartupInputTag);
            GiveAbility(AbilitySpec);
        }
        /** 코드 추가 */
        
        /** 코드 삭제 */
        // Ability를 한번 활성화하기 위해 추가해둔 코드
        // Ability가 활성화되는 것을 확인하였으므로 삭제
        /** 코드 학제 */
    }        
}

Released , Held 상태의 InputTag 발생시 ASC에서 Ability를 확인하고 일치하는 태그인 경우 입력 발생 및 종료를 알리고, 입력 발생시 Ability 를 활성화시킬 함수를 선언한다.
(원문 해석 : 첫 입력은 Ability 활성화 여부로 확인가능하지만 나머지는 확인이 불가능하므로 확인하는 함수가 필요함.)

  • AuraAbilitysystemComponent.h
// AuraAbilitySystemComponent.h

public:
	...
	
    // 태그를 통해 키 입력 유지시 입력됨을 알리고, 활성화되지 않은 경우 활성화
	void AbilityInputTagHeld(const FGameplayTag& InputTag);
    // 태그를 통해 키 입력 종료시 종료됨을 알림
    void AbilityInputTagReleased(const FGameplayTag& InputTag);
  • AuraAbilitySystemComponent.cpp
// AuraAbilitySystemComponent.cpp

void UAuraAbilitySystemComponent::AddCharacterAbilities(...) {...}

void UAuraAbilitySystemComponent::AbilityInputTagHeld(const FGameplayTag& InputTag)
{
	
}

void UAuraAbilitySystemComponent::AbilityInputTagReleased(const FGameplayTag& IputTag)
{

}

...

매 입력이 발생할 때마다 ASC 에서 AuraPlayerController 클래스의 캐스팅을 통한 함수 접근을 하기에는 매 프레임마다 캐스팅을 진행해야 하므로 소스 낭비가 발생한다.
소스 절약을 위해 AuraPlayerController 클래스에 ASC 로 접근가능하도록 변수를 추가한 다음 캐스팅을 진행한다.

  • AuraPlayerController.h
// AuraPlayerController.h

...
class UAuraAbilitySystemComponent;
...

private:
	...
    
 	UPROPERTY()
    TObjectPtr<UAuraAbilitySystemComponent> AuraAbilitySystemComponent;
    
    UAuraAbilitySystemComponent* GetASC();
  • AuraPlayerController.cpp
// AuraPlayerController.cpp

...
#include "AbilitySystem/AuraAbilitySystemComponent.h"
#include "AbilitySystemBlueprintLibrary.h"
...

// 키 입력 발생시 실행
void AAuraPlayerController::AbilityInputTagPressed(FGameplayTag InputTag)
{
	// 코멘트화
	// GEngine->AddOnScreenDebugMessage(1, 3.f, FColor::Red, *InputTag.ToString());
}

// 키 입력 유지시 실행
void AAuraPlayerController::AbilityInputTagReleased(FGameplayTag InputTag)
{
	if(GetASC() == nullptr) return;
	GetASC()->AbilityInputTagReleased(InputTag);
}

// 키 입력 종료시 실행
void AAuraPlayerController::AbilityInputTagHeld(FGameplayTag InputTag)
{
	if(GetASC() == nullptr) return;
    GetASC()->AbilityInputTagHeld(InputTag);
}

UAuraAbilitySystemComponent* AAuraPlayerController::GetASC()
{
	if(AuraAbilitySystemComponent == nullptr)
    {
    	AuraAbilitySystemComponent = Cast<UAuraAbilitySystemComponent>(UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(GetPawn<APawn>()));
    }
    return AuraAbilitySystemComponent;
}

이제 게임 시작시 캐릭터에게 부여할 Ability 의 Tag를 통해 ReleasedHeld 상태의 InputTag 발생시
1. ASC에서 해당 태그와 일치하는 태그인지 확인하고
2. 일치하는 경우에 입력 시작/종료 발생을 알리고
3. 활성화가능하다면 활성화시키는 함수(AbilityInputTagHeld)
5. 키입력 종료를 알리는 함수(AbilityInputTagReleased)
가 되도록 코드를 작성한다.

  • AuraAbilitySystemComponent.cpp
// AuraAbilitySystemComponent.cpp

...

void UAuraAbilitySystemComponent::AbilityInputTagHeld(const FGameplayTag& InputTag)
{
	if(!InputTag.IsValid()) return;
    
    // GetActivatableAbilities(): 활성화 가능한 모든 Ability들을 체크하는 함수, TArray 리턴
    // 루프를 통한 활성화 가능한 Ability를 순회
    for(FGameplayAbilitySpec& AbilitySpec : GetActivatableAbilities())
    {
    	
 		// 일치하는 태그가 있을 경우, Ability가 활성화되지 않은 경우에만 Ability를 활성화하도록 함     
        if(AbilitySpec.DynamicAbilityTags.HasTagExact(InputTag))
        {
        	// 입력이 시작되었다는 것을 알림
            AbilitySpecInputPressed(AbilitySpec);
        	if(!AbilitySpec.IsActive())
            {
            	TryActivateAbility(AbilitySpec.Handle);
            }
        }
    }
}

void UAuraAbilitySystemComponent::AbilityInputTagReleased(const FGameplayTag& Input Tag)
{
	if(!InputTag.IsValid()) return;
    
    for(FGameplayAbilitySpec& AbilitySpec : GetActivatableAbilities())
    {
    	if(AbilitySpec.DynamicAbilityTags.HasTagExact(InputTag))
        {
        	// 키 입력이 종료되었다는 것을 알림
            AbilitySpecInputReleased(AbilitySpec);
        }
    }
}

컴파일후 에디터의 GA_TestGameplayAbilities 로 돌아와서 Details 탭의 Startup Input Tag : LMB 로 설정한다.

컴파일 후 실행하여 마우스를 클릭하였을 때, Event Graph 에 있는 노드가 실행될 것이다.

8.4 클릭으로 캐릭터 이동(GA에 추가한 LMB)

RMB 클릭 시 캐릭터가 해당 지점으로 움직이도록 한다.
일단 최소 기본적인 조건은 다음과 같다
1. 최소 클릭 지속시간
2. 클릭 지속시간
3. 최소 클릭 지속시간 만족 여부
4. 마우스커서가 적 위를 클릭하고 있는지 확인
5. 오차 범위 조정을 위한 변수
6. 스무스한 이동을 위한 USplineComponent

먼저 이동과 관련된 변수들을 선언해주고, 곡선 형태의 이동에 필요한 SplineComponent 도 같이 선언해준다.

  • AuraPlayerController.h
// AuraPlatyerController.h

...
class USplineComponent;

private:
	...
    
    // RMB 클릭시 클릭지점을 저장할 벡터
    FVector CachedDestination = FVector::ZeroVector;
    // 클릭 유지시간
    float FollowTime = 0.f;
    // 클릭 최소 요구지속 시간, 0.5초 이내의 클릭 지속은 무시하기 위함
    float ShortPressThreshold = 0.5f;
    // 클릭 최소 요구시간을 만족하지 않으면 자동이동이 발생하지 않도록 하기 위함
	bool bAutoRunning = false;
    // 적을 타게팅하고 있는지 확인하기 위함
    bool bTargeting = false;
    
    // 오차범위 조정용, 해당 반경 내로 들어오면 도달한 것으로 판단
    UPROPERTY(EditDefaultsOnly)
    float AutoRunAcceptanceRadius = 50.f;
    
    // 캐릭터 이동시 자연스러운 곡선 이동을 하기 위한 Component
    UPROPERTY(VisibleAnywhere)
    TObjectPtr<USplineComponent> Spline;
  • AuraPlayerController.cpp
// AuraPlayerController.cpp

...
#include "Components/SplineComponent.h"
#include "AuraGameplayTags.h"
...

AAuraPlayerController::AuraPlayerController();
{
	...
    
    Spline = CreateDefaultSubobject<USplineComponent>("Spline");
}

...

void AAuraPlayerController::AbilityInputTagPressed(FGameplayTag InputTag)
{
	// LMB 태그와 InputTag를 비교하여 LMB와 일치할 경우 bAutoRunning = false;
	// 추가로 타게팅 대상이 존재하는지 확인후 bool값 할당
    if(InputTag.MatchesTagExact(FAuraGameplayTags::Get().InputTag_LMB))
    {
		bTargeting = ThisActor ? true : false;
    	bAutoRunning = false;
    }
}

void AAuraPlayerController::AbilityInputTagReleased(FGameplayTag InputTag)
{
	...
}

void AAuraPlayerController::AbilityInputTagHeld(FGameplayTag InputTag)
{
	/** 코드 삭제 */
    // if (GetASC() == nullptr) return;
	// GetASC()->AbilityInputTagHeld(InputTag);
    /** 코드 삭제 */
    
	// LMB가 아닌 다른 키 입력 유지시 해당 태그에 맞는 Ability가 발동되도록 AbilityInputTagHeld(InputTag) 실행
	if(!InputTag.MatchesTagExact(FAuraGameplayTags::Get().InputTag_LMB))
    {
    	if(GetASC())
        {
        	GetASC()->AbilityInputTagHeld(InputTag);
        }
        return;
    }
    
    // 타게팅 중인 경우 해당 태그에 맞는 Ability가 발동되도록 AbilityInputTagHeld(InputTag) 실행
    // ex) LMB로 공격 Abiliity 사용시AbilityInputTagHeld(InputTag) 실행
    if(bTargeting)
    {
    	if(GetASC())
        {
        	GetASC()->AbilityInputTagHeld(InputTag);
        }
        // RMB 클릭일 경우 이동기능까지 하고 종료되어야 하므로 return 사용x
    }
    else
    {
    	// 키 입력 유지시간
    	FollowTime += GetWorld()->GetDeltaSeconds();
        
        // 마우스 커서 지점 이동을 위한 코드
        FHitResult Hit;
        if(GetHitResultUnderCursor(ECC_Visibility, false, Hit))
        {
        	CachedDestination = Hit.ImpactPoint;
        }
        
        if(APawn* ControlledPawn = GetPawn())
        {
        	const FVector WorldDirection = (CachedDestination - ControlledPawn->GetActorLocation()).GetSafeNormal();
            ControlledPawn->AddMovementInput(WorldDirection);
        }
    }
}

...

컴파일 후 실행시 우클릭을 꾹 누르면 해당 방향으로 캐릭터가 이동한다.

AbilityInputTagReleased() 함수도 코드 추가를 해준다.

  • AuraPlayerController.cpp
// AuraPlayerController.cpp

...
#include "NavigationSystem.h"
#include "NavMesh/NavMeshPath.h"
...

...

void AAuraPlyerController::AbilityInputTAgReleased(FGameplayTag InputTag)
{
	
    // LMB 클릭이 아닐 경우 ASC의 AbilityInputTagReleased() 함수 실행
    if(!InputTag.MatchesTagExact(FAuraGameplayTags::Get().InputTag_LMB))
    {
    	if(GetASC())
        {
        	GetASC()->AbilityInputTagReleased(InputTag);
        }
        return;
    }
    
    // 만약 적을 타게팅중이라면
    if(bTargeting)
    {
    	// ASC의 AbilityInputTagReleased() 함수 실행
    	if(GetASC())
        {
        	GetASC()->AbilityInputTagReleased(InputTag);
        }
    }
    // 타게팅하지 않을 경우 해당 지점으로 이동
    else
    {
    	APawn* ControlledPawn = GetPawn();
        // 클릭시 AbilityInputTagHeld()가 무조건 먼저 실행되므로 FollowTime 값에 변동 발생
        // 키 입력 유지시간이 클릭 최소 요구시간 이하일 경우 Spline을 통한 이동 보정
    	if(FollowTime <= ShortPressThreshold && ControlledPawn)
        {
        	if(UNavigationPath* NavPath = UNavigationSystemV1::FindPathToLocationSynchronously(this, ControlledPawn->GetActorLocation(), CachedDestination))
            {
            	Spline->ClearSplinePoints();
                // Build.cs 파일에 NavigationSystem 모듈 추가 필요
            	for(const FVector& PointLoc : NavPath->PathPoints)
                {
                	Spline->AddSplinePoint(PointLoc, ESplineCoordinateSpace::World);
                    DrawDebugSphere(GetWorld(), PointLoc, 8.f, 8, FColor::Green, false, 5.f);
                }
                bAutoRunning = true;
            }          
        }
        FollowTime = 0.f;
        bTargeting = false;
    }
}

...

UNavigationSystemV1 을 사용하기 위해서는 모듈을 추가해야 한다.

  • Aura.Build.cs
// Aura.Build.cs

...

PrivateDependencyModuleName.AddRange(new string[] { "...", "NavigationSystem" });

컴파일 에디터로 돌아와서 Navigation Volume 을 맵 전체에 적용되도록 한다.
(P 키를 누르면 적용되는 범위 확인이 가능)

지형 지물에 막힐 경우 Spline을 통해 스무스하게 돌아가는지 확인하기 위해 아무 지형 지물을 설치한다.
(경로: Contents->assets->Dungeon->Beacon 배치)

실행하고 배치된 액터 뒤로 이동을 시도하면 주변에 DebugSphere 가 생성되는 것을 확인할 수 있다.

이제 마지막으로 목표지점으로 자동으로 이동하도록만 구현하면 된다.

  • AuraPlayerController.h
// AuraPlayerController.h

...
private:
	...
    
    // 클릭 지점 자동 이동
    void AutoRun();
  • AuraPlayerController.cpp
// AuraPlayerController.cpp

...

// 코드상에 함수명이 Tick으로 되어있다면 PlayerTick으로 변경
void AAuraPlayerController::PlyaerTick(float DeltaTime)
{
	Super::PlayerTick(DeltaTime);
    CursorTrace();
    AutoRun();
}

...

void AAuraPlayerController::AutoRun()
{
	// bAutorunning이 false인 경우 AutoRun하지 않도록 하기 위함
	if(!bAutoRunning) return;
	if(APawn* ControlledPawn = GetPawn())
    {
    	/*
 		* Tick함수를 통해 지속적으로 Spline을 통해 위치와 방향벡터를 구해서 캐릭터를 이동함
 		*/
		// Spline 위에서 캐릭터의 위치와 가장 가까운 지점의 위치를 벡터 형태로 반환
    	const FVector LocationOnSpline = Spline->FindLocationClosestToWorldLocation(ControlledPawn->GetActorLocation(), ESplineCoordinateSpace::World);
        // Spline 위에서 LocationOnSpline에 대한 방향 벡터(Spline의 곡선 방향)를 반환
        const FVector Direction = Spline->FindDirectionClosestToWorldLocation(LocationOnSpline, ESplineCoordinateSpace::World);
        // 캐릭터를 Spline 방향으로 이동시키기 위한 방향 벡터 사용
        ControlledPawn->AddMovementInput(Direction);
        
        // 오차범위 내 도달시 자동 이동 취소
        const float DistanceToDestination = (LocationOnSpline - CachedDestination).Length();
        if(DistanceToDestination <= AutoRunAcceptanceRadius)
        {
        	bAutoRunning = false;
        }
    }
}

컴파일 후 실행하면 정상적으로 동작하는 것을 확인할 수 있다.
(클라이언트측에서는 마우스 클릭후 release해도 아직 이동안되는게 정상)
Project Settings -> Navigation system -> Allow Client Side Navigation 활성화하면 클라이언트 측에서의 경로 DebugSphere 를 확인해 볼 수 있다.

(액터 좌측 하단에서 우측 상단으로 돌아가는 클라이언트측 경로)

실행해보면 알겠지만 문제점이 두가지 발생한다.
1. 레벨에 존재하는 액터에 의해 클릭 지점이 가려지는 경우
단 클릭 지점은 NavMesh에 의해 이동 불가능한 지점이 아닌 이동 가능한 지점

2. 레벨에 존재하는 액터에 의해 클릭 지점이 가려지는 경우
단 클릭 지점은 NavMesh에 의해 이동 불가능한 지점

첫번째 문제는 배치된 액터를 클릭하고, 뷰포트에 있는 Details -> Collision Preset : Custom -> Visibility Channel : Ignore 로 설정을 변경해주면 된다.

두번째 문제는 액터와 마주치기 가장 이전의 PathPoint 를 목적지로 설정해주면 된다.

  • AuraPlayerController.cpp
// AuraPlayerController.cpp

...

void AAuraPlayerController::AbilityInputTagReleased(FGameplayTag InputTag)
{
	if(!InputTag.MatchedTagExact(FAuraGameplayTags::Get().InputTag_LMB))
    {
    	...
    }
    
    if(bTargeting)
    {
    	...
    }
    else
    {
    	...
        if(FollowTime <= ShortPressThreshold && ControlledPawn)
        {
            if(UNavigationPath* NavPath = UNavigationSystemV1::FindPathToLocationSynchronously(this, ControlledPawn->GetActorLocation(), CachedDestination))
            {
            	...
                for(const FVector& PointLoc : NavPath->PathPoints)
                {
                	...
                }
                /** 코드 추가 */
                // 목표 지점 직전 위치에 생성되는 포인트로 도달 지점 지정
				// 액터 중심으로 이동을 시도할 경우 액터 앞에서 멈추게 됨
                CachedDestination = NavPath->PathPoints[NavPath->PathPoints.Num() - 1];
                /** 코드 추가 */
            	...
            }
        }
    	...
    }
}

컴파일 후 실행시 캐릭터가 더이상 이상한 곳으로 계속 이동하는것이 아닌 마지막 PathPoint 로 이동하는것을 확인할수 있다.

코드가 복잡해졌으므로 리팩토링 진행

  • AuraPlayerController.h
// AuraPlayerController.h

...
private:
    ...
    void CursorTrace();

    IEnemyInterface* LastActor;

    IEnemyInterface* ThisActor;

    /** 코드 추가 */
    // cpp 파일에 있는 코드 잘라넣기
    FHitResult CursorHit;
    /** 코드 추가 */
  • AuraPlayerController.cpp
// AuraPlayerController.cpp\

...

void AAuraPlayerController::CursorTrace()
{
	/** 코드 삭제 */
    // 헤더파일에 선언했으므로 삭제
    FHitResult CursorHit;
    /** 코드 삭제 */
    GetHitresultUnderCursor(ECC_Visibility, false, CursorHit);
    if(!CursorHit.bBlockingHit) return;

    LastActor = ThisActor;
	ThisActor = CursorHit.GetActor();

    /** 코드 삭제 */
    // Hovering 부분 코드 전부 삭제 후 아래 코드 추가
    /** 코드 삭제 */
    /** 코드 추가 */
    if(LastActor != ThisActor)
    {
    	if(LastActor) LastActor->UnHighlightActor();
        if(ThisActor) ThisActor->HighlightActor();
    }
    /** 코드 추가 */
}

...

void AAuraPlayerController::AbilityInputTagReleased(FGameplayTag InputTag)
{
	if(!InputTag.MatchesTagExact(FAuraGameplayTags::Get().InputTag_LMB))
    {
    	/** 코드 수정 */
        // 싱글코드이므로 {} 없애고 그냥 if문 옆에 코드 작성
        // 언리얼 공식 가이드라인에서는 {} 사용을 권장하지만 개발자들은 한줄 코드인 경우 if문 옆에 작성하는 것을 선호한다고 하는것 같음
        if(GetASC()) GetASC()->AbilityInputTagReleased(InputTag);
    	/** 코드 수정 */
        return;
    }
    if(bTargeting)
    {
        /** 코드 수정 */
        if(GetASC()) GetASC()->AbilityInputTagReleased(InputTag);
    	/** 코드 수정 */
    }
    else
    {
    	/** 코드 수정 : const */
        const APawn* ControlledPawn = GetPawn();
        /** 코드 수정 : const */
    	if(FollowTime <= ShortPressThreshold && ControlledPawn)
        {
        	if(UNavigationPath* NavPath = UNavgigationSystemV1::FindPathToLocdationSynchronously(...))
        	{
            	for(const FVector& PointLoc : NavPath->PathPoints)
            	{
            		...
                	/** 코드 삭제 */
                	DrawDebugSphere(GetWorld(), PointLoc, 8.f, 8, FColor::Greene, false, 5.f);
                	/** 코드 삭제 */
                }
                ...
            }
        }
        ...
    }
}

void AAuraPlayerController::AbilityInputTagHeld(FGameplayTag InputTag)
{
	if(!INputTag.MatchesTagExact(FAuraGameplayTags::Get().InputTag_LMB))
    {
    	/** 코드 수정 */
    	if(GetASC()) GetASC()->AbilityInputTagHeld(InputTag);
        /** 코드 수정 */
        return ;
    }
    if(bTargeting)
    {
    	/** 코드 수정 */
    	if(GetASC()) GetASC()->AbilityInputHeld(InputTag);
        /** 코드 수정 */
    }
    else
    {
    	FollowTime += GetWorld()->GetDeltaSecponds();
        /** 코드 삭제 */
        FHitReuslt Hit;
        /** 코드 삭제 */
        /** 코드 수정 */
        if(CursorHit.bBlockingHit) CachedDestination = CursorHit.ImpactPoint;
        /** 코드 수정 */
        if(APawn* ControlledPawn = GetPawn())
        {
        	...
        }
    }
}

컴파일 후 멀티플레이로 실행하여 캐릭터가 오브젝트 클릭 또는 오브젝트 뒤를 클릭할 시 정상적으로 이동하는지, 호버링시 정상적으로 작동하는지 확인한다.

추가
Client쪽에서 아이템 획득시 획득 위젯이 표시되지 않는 문제 수정

  • AuraAbilitySystemComponent.cpp
// AuraAbilitySystemComponent.cpp

#include "GameplayTagContainer.h" // ClientEffectApplied_Implementation()에서 EffectAssetTags 호출하려면 필요
...

void UAuraAbilitySystemComponent::AbilityActorInfoSet()
{
	/** 코드 수정 : 델리게이트에 바인딩 함수명 EffectApplied -> ClientEffectApplied로 변경 */
	// 정의 코멘트에 따르면 서버에서만 작동됨
	/** Called on server whenever a GE is applied to self. This includes instant and duration based GEs */
	OnGameplayEffectAppliedDelegateToSelf(AddUObject(this, &UAuraAbilitySytemComponent::ClientEffectApplied));
    /** 코드 수정 : 델리게이트에 바인딩 함수명 ClientEffectApplied로 변경 */
}
...
/** 코드 수정 : 함수명 EffectApplied -> ClientEffectApplied_Implementation로 수정 */
void UAuraAbilitySystemComponent::ClientEffectApplied_Implementation(...)
{
	...
}
/** 코드 수정 : 함수명 ClientEffectApplied로 수정 */
  • AuraAbilitySystemComponent.h
// AuraAbilitySystemComponent.h

...

protected:
	/** 코드 수정 : UFUNCTION(Client, Reliable) 추가, EffectApplied ->ClientEffectApplied 변경 */
    /* 
     * Cliennt RPC : 서버가 클라이언트에게 호출하는 원격 프로시저 호출.
     * 이를 통해 서버는 특정 클라이언트에서 실행되어야 하는 함수를 호출할 수 있음.
     * 즉 OnGameplayEffectAppliedDelegateToself를 클라이언트에서 호출 가능함.
     */
    // Client : ClientRPC 함수로 선언하기 위함
    // Reliable : Guarantee(보증)로써 RPC가 반드시 클라이언트에게 전달되어야 하는 것을 의미
    // 번외로 Unreliable은 패킷 손실이 발생할 수 있음을 의미
    UFUNCTION(Client, Reliable)
	void ClientEffectApplied(...);
    /** 코드 수정 : UFUNCTION(Client, Reliable) 추가*/

컴파일 후 멀티플레이로 실행시 클라이언트측에서도 아이템을 획득하면 위젯이 뷰포트에 나타나는 것을 확인할 수 있다.
TODO: 캡쳐후추가

8.5 Projectile 생성

8.5.1 AuraProjectile 클래스 생성

chap.109
먼저 Projectile 생성을 위해 Actor 기반 클래스 AuraProjectile 를 생성한다.

  • AuraProjectile.h
// AuraProjectile.h

...
class USphereComponent;
class UProjectileMovementComponent;
...

...
public:
	AAuraProjectile();
    
	// Tick함수 사용하지 않으므로 제거
	/** 코드 삭제 */
    virtual void Tick(float DeltaTime) override;
	/** 코드 삭제 */
    
    UPROPERTY(VisibleAnywhere)
    TObjectPtr<UProjectileMovementComponent> ProjectileMovement;
    
protected:
	virtual void BeginPlay() override;
    
    UFUNCTION()
    void OnSphereOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
    
    
private:
	UPROPERTY(VisibleAnywhere)
	TObjectPtr<USphereComponent> Sphere;
  • AuraProjectile.cpp
// AuraProjectile.cpp

...
#include "Components/SphereComponent.h"
#include "GameFramework/ProjectileMovementComponent.h"
...

AAuraProjectile::AAuraProjectile()

	/** 코드 수정 : false로 변경 */
	// Tick함수 사용안하므로 false
	PrimaryActorTick.bCanEverTick = false;
    /** 코드 수정 : false로 변경 */
    
    Sphere = CreateDefaultSubobject<USphereComponent>("Sphere");
    SetRootComponent(Sphere);
    Sphere->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
    Sphere->SetCollisionResponseToAllChannels(ECR_Ignore);
    Sphere->SetCollisionResponseToChannel(ECC_WorldDynamic, ECR_Overlap);
    Sphere->SetCollisionResponseToChannel(ECC_WorldStatic, ECR_Overlap);
    Sphere->SetCollisionResponseToChannel(ECC_Pawn, ECR_Overlap);
    
    ProjectileMovement = CreateDefaultSubobject<UProjectileMovementComponent>("ProjectileMovement");
    ProjectileMovement->InitialSpeed = 550.f;
    ProjectileMovement->MaxSpeed = 550.f;
    ProjectileMovement->ProjectileGravityScale = 0.f;
}

void AAuraProjectile::BeginPlay()
{
	Super::BeginPlay();
    
    Sphere->OnComponentBeginOverlap.AddDynamic(this, &AAuraProjectile::OnSphereOverlap);
}

/** 코드 삭제 */
void AAuraProjectile::Tick(float DeltatTime)
{
	Super::Tick(DeltaTime);
}
/** 코드 삭제 */

void AAuraProjectile::OnSphereOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	
}

컴파일 후 에디터에서 AuraProjectile 클래스 기반 블루프린트 BP_FireBolt 를 생성한다.

파일을 연 후 Components 탭에서 Sphere 를 클릭한 상태에서 Niagara Particle System Component 를 추가하고 FireEffect 로 rename한다.

Details 탭에서 Niagara System Asset : NS_Fire_3 로 설정해준다.

컴파일 후 월드에 BP_FireBolt 를 배치하고 실행하면 파이어볼이 날아가는 것을 확인할 수 있다.

추가1
Assets -> Dungeon -> SM_Tile_3x3_ASM_Tile_3x3_B 로 바닥 변경하기

추가2 BP_AuraCharacter 의 SpringArm 길이 800.f로 변경

8.5.2 Ability 스폰 클래스 생성

chap.110
AuraGamelayAbility 클래스 기반의 AuraProjectileSpell 클래스를 생성한다.

추가
GameplayAbility 헤더파일에 있는 K2Kismet Too 라는 의미, 즉 K2 버전은 블루프린트에 expose할 수 있음
정의를 살펴보면 K2 버전은 UFUNCTION() 매크로에 BlueprintImplementableEvent 가 있음
Ex)

UFUNCTION(BlueprintImplementableEvent, Category = Ability, DisplayName = "ActivateAbility", meta=(ScripName = "ActivateAbility"))
void K2_ActivateAbiliy();
  • AuraProjectileSpell.h
// AuraProjectileSpell.h

...
protected:
	virtual void ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData) override;
  • AuraProjectileSpell.cpp
// AuraProjectileSpell.cpp

...
#include "Kismet/KismetSystemLibrary.h"
...

...
void UAuraProjectileSpell::ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData)
{
	Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);
    
    UKismetSystemLibrary::PrintString(this, FString("ActivateAbility (c++)"), true, true, FLinearColor::Yellow, 3);
}

컴파일 후 에디터로 돌아와서 AuraProjetileSpell 클래스 기반 블루프린트 클래스인 GA_FireBolt 를 생성한다.

GA_FireBoltEvent Graph 탭에서 Print String 노드를 추가해 디버그메세지를 출력하도록 한다.

Class Defaults 를 선택한 상태에서 Details 패널의 InputStartup Input Tag : InputTag.LMB 로 변경한다.

BP_AuraCharacter 파일을 열고 Details 패널의 Abilities 탭에 기존에 있던 Startup Abilities 를 제거하고 GA_FireBolt 를 추가해준다.
컴파일 후 실행하여 적을 LMB으로 클릭시 뷰포트에 코드와 블루프린트로 추가한 디버그메세지 둘다 출력되는 것을 확인할 수 있다.

이제 스폰을 위한 코드를 추가해야 한다. 서버에 스폰하기 위해서 AuraProjectile 클래스에서 리플리케이트 관련 코드를 추가한다.

  • AuraProjectile.cpp
// AuraProjectile.cpp

...

AAuraProjectile::AAuraProjectile()
{
	PrimaryActorTick.bCanEverTick = false;
    bReplicates = true;
    
    ...
}

...
  • AuraProjectileSpell.h
// AuraProjectileSpell.h

...
class AAuraProjectile;
...

...
protected:
	...
    
    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    TSubclassOf<AAuraProjectile> ProjectileClass;
  • AuraProjectileSpell.cpp
// AuraProjectileSpell.cpp

...
// #include "kismet/KismetSystemLibrary.h" 디버그메세지 사용안하므로 삭제
#include "Actor/AuraProjectile.h"
...

void UAuraProjectileSpell::ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData)
{
	Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);
    
    // 서버에 스폰 가능한지 확인
	// HasAuthority() : 객체가 서버에서 실행중이거나 싱글 플레이어 게임에서 실행중일 경우 true, 객체가 클라이언트에서 실행 중일 경우 false
	// 능력의 활성화 과정에 관한 정보를 가지고 있는지 확인하여 서버에서 동작하는지 체크하는 bool타입 변수
    const bool bIsServer = HasAuthority(&ActivationInfo);
    if(!bIsServer) return;
    
    // 스폰 Transform을 저장할 구조체
    FTransform SpawnTransform;
    // SpawnActorDeferred<AActorClass>(ActorClass, Transform, Owner, Instigator, SpawnParameters) : 액터를 생성할 때 사용되는 함수. 즉시 액터를 활성화하지 않고 필요한 설정을 완료한 후에 액터를 활성화할 수 있도록 함
	// ProjectileClass : 생성할 ActorClass
	// SpawnTransform : 생성 Transform 정보
	// GetOwningActorFromActorInfo() : 생성된 액터의 소유자
	// Cast<APawn>(GetOwningActorFromActorInfo()) : Instigator(가해자)
	// ESpawnActorCollisionHandleMethod : 충돌 처리 방식 설정
	/*
	 * AlwaysSpawn : 충돌 여부에 관계없이 항상 액터를 생성
	 * AdjustIfPossibleButAlwaysSpawn : 가능하면 충돌을 피하지만, 충돌하더라도 항상 생성
	 * AdjustIfPossibleButSpawnIfColliding : 가능하면 충돌을 피하지만, 충돌할 경우 생성하지 않음
	 * DonSpawnIfColliding : 충돌할 경우 액터를 생성하지 않음
	 */
    GetWorld()->SpawnActorDeferred<AAuraProjectile>(
    	ProjectileClass, 
    	SpawnTransform, 
    	GetOwningActorFromActorInfo(), 
    	Cast<APawn>(GetOwningActorFromActorInfo()),  
    	ESpawnActorCollisionHandlingMethod::AlwaysSpawn);
}

파이어볼트를 스폰할 위치는 스태프이다. 스태프에 추가할 Socket의 위치 정보를 리턴하는 함수를 CombatInterface.h 에 생성한다.

  • CombatInterface.h
// CombatInterface.h

...
public:
	...
    // 파이어볼트 스폰 소켓 위치 리턴
    virtual FVector GetCombatSocketLocation();
  • CombatInterface.cpp
// CombatInterface.cpp

...

FVector ICombatInterface::GetCombatSocketLocation()
{
	return FVector();
}

이제 AuraCharacterBase 클래스에 해당 함수를 override하여 사용하기 위한 코드를 추가한다.

  • AuraCharacterBase.h
// AuraCharacetBase.h

...
protected:
	...
    
    UPROPERTY(EditAnywhere, Category = "Combat")
    TObjectPtr<USkeletalMeshComponent> Weapon;
    
    /** 코드 추가 */
    // 무기에서 스킬 스폰할 소켓명
    UPROPERTY(EditAnywhere, Category = "Combat")
    FName WeaponTipSocketName;
    
    // CombatInterface에 있는 소켓 위치 리턴 함수 오버라이드
    virtual FVector GetCombatSocketLocation() override;
    /** 코드 추가 */
    
    ...
  • AuraCharacterBase.cpp
// AuraCharacterBase.cpp

...

FVecctor AAuraCharacterBase::GetCombatSocketLocation()
{
	check(Weapon);
	return Weapon->GetSocketLocation(WeaponTipSocketName);
}

...

다시 AuraProjectileSpell 클래스로 돌아와서 파이어볼트를 스폰할 수 있도록 코드를 이어서 작성한다.

  • AuraProjectileSpell.cpp
// AuraProjectileSpell.cpp

...
#include "Interaction/CombatInterface.h"
...

void UAuraProjectileSpell::ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData)
{
	Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);
    
    const bool bIsServer = HasAuthority(&ActivationInfo);
    if(!bIsServer) return;
    
    /** 코드 추가 */
    // GetAvatarAcorFromActorInfo() 함수를 통해 Ability를 사용하는 주체(아바타를 가져옴)
	// 주어진 액터가 ICombatInterface를 구현하고 있는지 확인하고, 인터페이스 호출 가능하도록 함
    ICombatInterface* CombatInterface = Cast<ICombatInterface>(GetAvatarActorFromActorInfo());
    if(CombatInterface)
    {
    	const FVector SocketLocation = CombatInterface->GetCombatSocketLocation();
        
        /** 코드 이동 */
        FTransform SpawnTransform;
        /** 코드 추가 */
        SpawnTransform.SetLocation(SocketLocation);
        /** 코드 추가 */
        // TODO: 추후에 GameplayEffect를 통해 데미지를 주는 과정 진행시 rotation 관련 값 추가 예정
        
        /** 코드 수정 */
    AAuraProjectile* Projectile = GetWorld()->SpawnActorDeferred<AAuraProjectile>(
    	ProjectileClass, 
    	SpawnTransform, 
    	GetOwningActorFromActorInfo(), 
    	Cast<APawn>(GetOwningActorFromActorInfo()),  
    	ESpawnActorCollisionHandlingMethod::AlwaysSpawn);
        /** 코드 수정 */
        /** 코드 이동 */
       
    // 스폰 시작과 스폰 끝을 나누는 이유
    // 그 사이에 GameplayEffect를 통하여 데미지를 발생시키는 코드를 추가할 수 있음.
    // TODO: 추후에 추가예정
       
    Projectile->FinishSpawning(SpawnTransform);
    }
    /** 코드 추가 */
}

컴파일후 에디터로 돌아와서 BP_AuraCharacter 파일을 연 다음 Components 탭의 Weapon 을 클릭하면 Details 탭의 Mesh 에서 현재 장착중인 무기의 Mesh 의 경로를 콘텐츠 브라우저에 띄울 수 있다.

SKM_Staff 를 열면 TipSocket 이 있는 것을 확인할 수 있다.

다시 BP_Auracharacter 로 돌아와서 Details 패널에서 코드에 추가해둔 Weapon Tip Socket Name : TipSocket 을 입력해준다.

그리고 GA_FireBolt 에서 Projectile Class : BP_FireBolt 로 설정해준다.

컴파일 후 실행하여 적 위에 마우스를 두고 LMB를 클릭하였을 때 파이어볼트가 스폰되는 것을 확인할 수 있다.(속도가 빨라서 캡쳐가 제대로 안됨)

추가
GA_FireBolt 에서 Event ActivateAbility 노드에 연결된 Print String 노드 삭제

8.6 Ability Tasks

8.6.1 Montage 추가

먼저 FireBolt 를 시전할 때 사용할 Montage 가 필요하다.
사용할 Cast_FireBolt 애니메이션을 우클릭한 후 Create -> Create AnimMontage 를 통해 AM_Cast_FireBolt 를 생성한다.

경로
Content -> Assets -> Character -> Aura -> Animations -> Abilities


AM_Cast_FireBolt 에서 Blend In -> Blend Time : 0.05 으로, Blend Out -> Blend Time : 0.1 로 설정한다.

몽타주 생성을 완료하였다면 GA_FireBoltEvent Graph 에서 우클릭 후 PlayMontageAndWait 노드를 생성하고 Montage to Play : AM_Cast_FireBolt 로 설정한 후, 문자열을 출력하도록 Print String : Casting FireBolt 노드를 추가한다.

컴파일 후 실행하면 FireBolt 캐스팅 애니메이션이 재생되고, 뷰포트에 디버그메세지가 출력되는 것을 확인할 수 있다.

이외에도 다양한 조건의 핀들이 있다.

상황에 맞추어 사용 가능하다.

  • On Completed
    몽타주 재생 완료시 핀 트리거
  • On Blend Out
    몽타주 블렌드 아웃 시작시 핀 트리거
  • On Interrupted
    몽타주가 다른 애니메이션에 의해 중단된 경우 핀 트리거
  • On Cancelled
    몽타주가 사용자의 입력 또는 코드에 의해 취소된 경우 핀 트리거

일반적으로 파이어볼트 사출시, 스태프가 앞으로 향한 다음 파이어볼트가 스폰되어 발사되어야 하는데, 지금은 몽타주 재생과 동시에 스폰되는 부자연스러움이 발생한다.
이를 위해서 PlayMontageAndWait 가 실행되자마자 Wait Gameplay Event 노드를 이용해 GameplayEvent 가 발생할 때 까지 대기한 이후 로직을 실행하도록 수정해야 한다.

해당 노드를 살펴보면 태그를 통해 이벤트 발생을 확인한다.
따라서 특정 이벤트 발생시 사용할 태그를 생성해야 한다.
(이러한 이벤트에 대한 GameplayTag 의 경우 굳이 c++ 클래스를 통해 태그를 생성하지 않아도 됨.)
Project Settings -> GameplayTags 에서 Event.Montage.FireBolt 태그를 추가한다.

다시 GA_FireBolt 로 돌아와 생성한 태그를 Event Tag 에 추가해준다.

이제 Custom Anim Notify 가 발생하면 Gameplay Event 를 발생시키기 위해 AnimNotify 기반 블루프린트 클래스 AN_MontageEvent 를 생성한다.

Functions -> Received Notify 를 통해 Notify 발생시 실행시킬 것들을 추가할 수 있다.

Send Gameplay Event to Actdor 노드를 통해 캐릭터에게 Gameplay Event 를 전달하도록 노드를 구성한다.
캐릭터에게 전달되도록 Get Onwer 노드를 통해 Actor 를 전달하고

EventTag 라는 GameplayTag 타입의 변수를 하나 생성한 뒤 눈모양 아이콘을 클릭하여 다른 블루프린트 클래스에서 접근가능하도록 public화 하고, Event Tag 핀과 연결시킨 다음()

컴파일 후 AM_Cast_FireBolt 로 돌아와 방금 생성한 Custom Anim Notify 를 적절한 곳에 추가해준 다음

Details 패널에서 Event.Montage.FireBolt 태그를 추가한다.
(public화 한 이유)

이제 해당 노티파이를 지나면 Event.Montage.FireBolt 라는 GameplayTag 가 적용된다.
확인해보기 위해 GA_FireBolt 로 돌아와 Event Received -> Print String : FireBolt Event Received 를 추가하여 제대로 동작하는지 확인할 수 있도록 한다.

AM_Cast_FireBolt 에서 애니메이션 재생 속도를 줄여 노티파이 발생 지점에서 디버그메세지가 출력되는지 확인 가능하도록 한다.
(애니메이션 재생속도가 빠르므로 AM_Cast_FireBoltRate Scale 을 0.1로 변경)

컴파일 후 실행하면 정상적으로 노티파이 발생 이후 디버그메세지가 출력되는 것을 확인할 수 있다.

이제 투사체를 이벤트 발생시에 생성하도록 수정해야한다.
TODO:여기서부터수정진행chap114

  • AuraProjectileSpell.h
// AuraPRojectileSpell.h

...
protected:
	virtual void ActivateAbility(...);
    
    UFUNCTION(BlueprintCallable, Category = "Projectile")
    void SpawnProjectile();
    
    ...
  • AuraProjectileSpell.cpp
// AuraProjectileSpell.cpp

void UAuraProjectileSpell::ActivateAbility(...)
{
	Super::ActivateAbility(...);
    
    /** 코드 SpawnProjectile() 함수로 잘라넣기 */
    ...
    /** 코드 SpawnProjectile() 함수로 잘라넣기 */
}

void UAuraProjectileSpell::SpawnProjectile()
{
	/** ActivateAbility() 에서 코드 잘라넣기 */
    /** 코드 수정 HasAuthority(&ActivationInfo); -> GetAvatarActorFromActorInfo()->HasAuthority() */
    const bool bIsServer = GetAvatarActorFromActorInfo()->HasAuthority();
    /** 코드 수정 HasAuthority(&ActivationInfo); -> GetAvatarActorFromActorInfo()->HasAuthority() */
    if(!bIsServer) return;
     
    ...
    if(CombatInterface)
    {
    	...
    }     
    /** ActivateAbility() 에서 코드 잘라넣기 */
}

컴파일 후 GA_FireBolt 로 돌아와서 Print String 노드를 삭제한 다음 생성한 SpawnProjectile() 함수를 호출하도록 노드를 생성한다.

컴파일 후 실행시 노티파이 발생 시점에 파이어볼트가 발사되는 것을 확인할 수 있다.
마지막으로 End Ability 노드까지 연결해주면 파이어볼트를 다시 사용할 수 있다.

(방향이 다르긴 하지만 스태프를 앞으로 내밀 때 스폰 되는것을 확인할 수 있음.)

8.6.2 Spawn된 Projectile 방향 설정

스폰된 파이어볼트는 방향을 지정해주지 않아 스폰된 위치 기준 투사체의 한 축으로만 이동하는 이슈가 있으므로 수정이 필요하다.
먼저 AbilityTask 기반 c++ 클래스인 TargetDataUnderMouse 를 생성한다.

  • TargetDataUnderMouse.h
// TargetDataUnderMouse.h

...
public:
	// TargetDataUnderMouse 노드 생성 함수
	// HidePin: OwningAbility 핀을 노드에서 숨김
    // DefaultToSelf: OwningAbility 파라미터가 제공되지 않았을 때 기본적으로 self 적용, 여기서는 OwningAbility로 적용
    // BlueprintInternalUseOnly: 참인 경우, 외부 블루프린트에서 이 함수를 호출할 수 없음
	UFUNCTION(BlueprintCallable, Category = "Ability|Tasks", meta = (DisplayName = "TargetDataUnderMouse", HidePin = "OwningAbility", DefaultToSelf = "OwningAbility", BlueprintInternalUseOnly = "true"))
	static UTargetDataUnderMouse* CreateTargetDataUnderMouse(UGameplayAbility* OwningAbility);
  • TargetDataUnderMouse.cpp
// TargetDataUnderMouse.cpp

UTargetDataUnderMouse* UTargetDataUnderMouse::CreateTargetDataUnderMouse(UGameplayAbility* OwningAbility)
{
	UTargetDataUnderMouse* MyObj = NewAbilityTask<UTargetDataUnderMouse>(OwningAbility);
    
    return MyObj;
}

컴파일 후 GA_FireBolt 로 돌아와서 정상적으로 동작하는지 확인하기 위해 TaretDataUnderMouse 노드를 생성하고, Event ActivateAbility 노드와 연결하여 문자열을 출력하도록 노드를 구성한다.

타이머 표시

타이머 표시는 Latent 노드를 의미.
블루프린트 및 C++ 코딩에서 비동기 작업을 처리하기 위한 중요한 개념.
이러한 노드는 즉시 실행되지 않고, 일정 조건이 만족되거나 특정 이벤트가 발생할 때까지 대기하는 노드.
이를 통해 게임 내에서 복잡한 비동기 작업을 간단하게 관리 가능

컴파일 후 실행하면 클릭시 뷰포트에 디버그메세지가 출력되는 것을 확인할 수 있다.

이제 해당 함수가 호출되면 실행될 델리게이트를 생성한다.(정상적으로 실행되는 것 확인했으먼 노드 복구)

  • TargetDataUnderMouse.h
// TargetDataUnderMouse.h

...
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FMouseTargetDataSignature, const FVector&, Data);
...

public:
	...
    
    // TargetDataUnderMouse 노드에 Output Execution Pin 생성
    UPROPERTY(BlueprintAssignable)
    FMouseTargetDataSignature ValidData;

컴파일 후 실행하면 Valid Data 라는 Output Pin 이 추가된 것을 확인할 수 있다.

델리게이트를 통해 마우스 커서 지점을 Broadcast하도록 코드를 작성한다.

  • TargetDataUnderMouse.h
// TargetDataUnderMouse.h

...
private:
	// 마우스 지점의 데이터를 얻고, Broadcast하기 위함
    // 델리게이트를 통해 Data를 Broadcast함
	virtual void Activate() override;
  • TargetDataUnderMouse.cpp
// TargetDataUnderMouse.cpp

...

void UTargetDataUnderMouse::Activate()
{
	// Super 미사용 이유 : 부모 클래스의 함수에서 log출력밖에 안함
    
    APlayerController* PC = Ability->GetCurrentActorInfo()->PlayerController.Get();
    FHitResult CursorHit;
    PC->GetHitResultUnderCursor(ECC_Visibility, false, CursorHit);
    // Broadcast될 Data는 커서의 위치
    ValidData.Broadcast(CursorHit.Location);
}

컴파일 후 GA_FireBolt 로 돌아와서 기존의 Output Pin 이 아닌 Valid Data 핀에서 DrawDebugSphere 노드와 End Ability 노드를 생성하여 연결한다.

컴파일 후 실행하여 적을 클릭시 적에 DebugSphere 가 생성되는 것을 확인할 수 있다.

하지만 클라이언트측에서 실행할 경우 문제가 생긴다.
아래와 같이 DebugSphere 가 서버측에서는 다른 곳에 생성된다.

해당 지점은 월드에서 제로지점에 속하는데, 이는 서버가 클라이언트측에서 어디를 가리키는지 알지 못해 데이터가 0값을 가지기 때문이다.

문제 해결 전 간단한 개념 정리

TargetData

대충 요약하자면 Client 측에서 Ability Task 활성화시 서버로 전송되고 이시간을 엡실론이라고 한다.
그리고 Data (위 코드에서는 로케이션 정보) 대한 것을 ServerRPC를 통해 서버로 전송되고 이 시간을 델타라고 한다.

쟁점은 어떤 것이 먼저 서버에 도착하냐인데 이 부분은 명확하게 정해져 있지 않다.
ε < δ 일 경우 서버는 Ability Task 가 활성화 되었다는 정보는 있지만, 유효한 데이터를 가지고 있지 않게 되고,
ε > δ 일 경우 유효한 데이터를 가지고는 있지만, Ability Task 가 활성화 되었다는 정보가 없으므로 의미가 없어진다.


그래서 GAS 에는 TargetDataSystem 을 사용한다.
여러 데이터를 포함한 FGameplayAbilityTargetData 라는 구조체가 있고 ServerSetReplicatedTargetData() 라는 함수를 이용해 TargetData 를 서버로 전송한다.
서버는 어느 시점에서( ε 의 시간이 지난 후) TargetData 를 받게 되고, TargetSet
( FAbilityTargetDataSetDelegateTargetData 를 설정할 때 사용되는 데이터의 집합)
을 통해 FAbilityTargetDataSetDelegate 델리게이트를 Broadcast한다.
따라서 서버에 TargetSet 에 의해 BroadCast된 델리게이트에 바인딩된 콜백 함수가 있으면, 호출되어 TargetData 를 받을 수 있다.


또한 서버에서는 AbilityTargetDataMap 이라는 맵이 유지되며 이 맵은 AbilitySpecsTargetData 를 매핑한다.
( AbilityTargetDataMapAbilitySystem 에서 특정 Ability 에 대한 TargetData 를 저장하고 관리하는데 사용된다. Ability 가 실행될 때 관련된 TargetData 를 저장하고 AbilityTargetDataMap 에 저장하거나, 이 Ability 또는 다른 시스템이 TargetData 에 접근할 필요가 있을 때 AbilityTargetDataMap 에서 데이터를 가져온다. )


결론 요약을 하자면 Ability Task 에서 해야하는 것은, Ability Task 를 활성화시킬 때 ServerSetRepliccatedTargetData() 함수를 호출하여 TargetData 를 서버로 보내는 것이다.
서버측에서는 Activate 가 먼저 호출된 경우 이후에 ServerSetReplicatedTargetData() 함수를 통해 TargetData 를 받게 되므로 델리게이트를 통해 콜백함수를 호출하게 된다.
반면에 TargetData 가 먼저 서버에 도착하게 되면 콜백함수를 바인드하기 전에 Broadcast되어 버린다.
해당 경우에는 서버가 CallReplicatedTargetDataDelegateIfSet() 함수를 호출하여 델리게이트의 Broadcast를 다시 수행하고 TargetData 를 검색하게 한다.
이는 복제된 TargetData가 먼저 도착하고, 델리게이트가 서버가 TargetSetDelegate에 바인딩하기 전에 Broadcast되는 경우에 대한 안전장치를 제공한다.

위 설명을 토대로 코드를 수정한다.

  • TargetDataUnderMouse.h
// TargetDataUnderMouse.h

...
/** 코드 수정 */
/* 
 * FGameplayAbilityTargetDataHandle은 TargetData를 관리하고 전달하는데 사용되는 구조체.
 * 다양한 유형의 TargetData를 래핑하여 Ability나 Effect가 필요로 하는 정보를 제공하는데 중요한 역할
 */
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FMouseTargetDataSignature, const FGameplayAbilityTargetDataHandle&, DataHandle);
/** 코드 수정 */
...

...
private:
	...
    void SendMouseCursorData();
    
  • TargetDataUnderMouse.cpp
// TargetDataUnderMouse.cpp

...
#include "AbilitySystemComponent.h"
...

void UTargetDataUnderMouse::Activate()
{
	/** 코드 추가 */
    // 컨트롤러가 로컬 플레이어에 의해 제어되고 있는지 확인
    const bool bIsLocallyControlled = Ability->GetCurrentActorInfo()->IsLocallyControlled();
    
    if(bIsLocallyControlled)
    {
    	// 클라이언트 측에서 서버로 데이터 전송
    	SendMouseCursorData();
    }
    else
    {
    	// TODO: 컨트롤러가 서버측에 의해 제어되고 있으므로 TargetData를 listen 해야함
    }
    /** 코드 추가 */
    /** 나머지 기존 코드 SendMouseCursorData() 함수로 이동 */
    // APlayerController* PC = Ability->GetCurrentActorInfo()->PlayerController.Get();
    // FHitResult CursorHit;;
    // PC->GetHitResultUnderCursor(ECC_Visibility, false, CursorHit);
    // ValidData.Broadcast(CursorHit.Location);
    /** 나머지 기존 코드 SendMouseCursorData() 함수로 이동 */
}

void UTargetDataUnderMouse::SendMouseCursorData()
{
	/*
     * 예측적 스코프와 관련된 데이터 기능을 관리하는 구조체
   	 * 클라이언트 측에서 Ability가 실행될 때, 서버와의 지연을 고려하여 예측적으로 능력의 효과를 시뮬레이션하는데 사용
     * 예측 데이터와 관련된 정보를 저장(클라이언트는 타겟이 이동하거나 변동할 것으로 예측하며, 이를 기반으로 능력의 효과를 미리 시뮬레이션)
     */
	FScopedPredictionWindow ScopedPrediction(AbilitySystemComponent.Get());

    /** 이동된 코드 */
    APlayerController* PC = Ability->GetCurrentActorInfo()->PlayerController.Get();
    FHitResult CursorHit;;
    PC->GetHitResultUnderCursor(ECC_Visibility, false, CursorHit);
    /** 코드 삭제 */
    ValidData.Broadcast(CursorHit.Location);
    /** 코드 삭제 */
    /** 이동된 코드 */
    
    // TargetData를 관리하고 전달하는데 사용되는 구조체
    FGameplayAbilityTargetDataHandle DataHandle;
    // TargetData를 동적으로 생성함
    // FGameplayAbilityTargetData_SingleTargetHit는 FGamelayAbilityTargetData 파생 클래스
    FGameplayAbilityTargetData_SingleTargetHit* Data = new FGameplayAbilityTargetData_SingleTargetHit();
    // 커서의 로케이션을 Data의 HitResult에 저장하고 이를 DataHandle에 추가함
    Data->HitResult = CursorHit;
    DataHandle.Add(Data);
    
    /*
     * SetServerReplicatedTargetData 서버에 TargetData를 리플리케이트하여 전송하는 함수
     * GetActivationPredictionKey() : Ability가 예측적으로 활성화될 때 클라이언트 측에서 사용하는 PredictionKey를 반환
     * PredictionKey : 아래에 자세한 설명 있음. 간략하게 서버와의 지연을 줄이기 위해 사용되는 식별자로써, 
     * 				   클라이언트는 이를 사용하여 능력의 효과를 시뮬레이션하고, 
     * 				   서버의 응답을 기다리면서 사용자에게 즉각적인 피드백을 제공함
     * ScopedPredictionKey : 예측적 데이터의 범위(Scope)와 유효성을 관리
     						 클라이언트에서 서버로 전송되는 데이터의 예측 범위를 정의하고 동기화
     * 간단 요약 : (ScopedPredictionKey)데이터의 예측 범위를 정의하고 동기화 vs (GetActivationPredictionKey())예측적으로 처리된 데이터를 서버에 전송할 때 사용
     */
    AbilitySystemComponent->ServerSetReplicatedTargetData(
    	GetAbilitySpecHandle(), 
        GetActivationPredictionKey(), 
        DataHandle, 
        FGameplayTag(), 
        AbilitySystemComponent->ScopedPredictionKey);

    // Ability가 활성화되지 않은 상태에서 Broadcast하는 것을 방지하기 위함
    if(ShouldBroadcastAbilityTaskDelegates())
    {
    	ValidData.Broadcast(DataHandle);
    }   
}

TODO: 여기서부터수정식, chap118부터 진행
서버에서 TargetData를 받도록 코드를 이어서 작성한다.

  • TargetDataUnderMouse.h
// TargetDataUnderMouse.h

...
private:
	...
    
    // 콜백함수, Activate와 TargetData 둘다 서버에서 만족할 경우 실행될 콜백 함수(DataHandle Broadcast하기 위한 함수)
    void OnTargetDataReplicatedCallback(const FGameplayAbilityTargetDataHandle& DataHandle, FGameplayTag ActivationTag);
  • TargetDataUnderMouse.cpp
// TargetDataUnderMouse.cpp

...

void UTargetDataUnderMouse::Activate()
{
	const bool bIsLocallyControlled = Ability->GetCurrentActorInfo()->IsLocallyControlled();
    // 컨트롤러를 클라이언트가 제어하는 경우
    if(bISLocallyControlled)
    {
    	SnedMouseCursorData();
    }
    // 컨트롤러를 서버가 제어하는 경우
    else
    {
    	/** 코드 추가 */
        // 컨트롤러가 서버측에 의해 제어되고 있으므로 TargetData를 listen 해야함
		// Activate와 TargetData가 있다면 델리게이트를 진행
        const FGameplayAbilitySpecHandle SpecHandle = GetAbilitySpecHandle();
        const FPredictionKey ActivationPredictionKey = GetActivationPredictionKey();
        AbilitySystemComponent.Get()->AbilityTargetDataSetDelegate(SpecHandle, ActivationPredictionKey).AddUObject(this, &UTargetDataUnderMouse::OnTargetDataReplicatedCallback);
        // 델리게이트를 요청하지 않았다면 아직 Activate 또는 TargetData가 서버에 도달하지 않았다는 의미이므로 대기
        const bool bCalledDelegate = AbilitySystemComponent.Get()->CallReplicatedTargetDataDelegatesIfSet(SpecHandle, ActivationPredictionKey);
        if(!bCalledDelegate)
        {
        	SetWaitingOnRemotePlayerData();
        }
        /** 코드 추가 */
    }
}

...

void UTargetDataUnderMouse::OnTargetDataReplicatedCallback(const FGameplayAbilityTargetDataHandle& DataHandle, FGameplayTag ActivaitonTag)
{
	// ConsumeClientReplicatedTargetData : 클라이언트가 서버로부터 수신한 타겟 데이터를 처리하여 해당 능력을 실행하거나 업데이트함
	// 클라이언트 측에서 서버로부터 받은 데이터를 바탕으로 적절한 게임플레이 로직을 실행함
	AbilitySystemComponent->ConsumeClientReplicatedTargetData(GetAbilitySpecHandle(), GetActivationPredictionKey());
    if(ShouldBroadcastAbilityTaskDelegates())
    {
    	ValidData.Broadcast(DataHandle);
    }
}

컴파일 후 GA_FireBolt 에서 에러가 발생한 노드를 Refresh 해준다.

Data Handle 핀에서 Get Hit Result from Target Data 노드를 생성하여 연결해주고, Return Value 핀에서 Split Struct Pin 를 통해 LocationDraw Debug SphereCenter 와 연결한다.

추가
언리얼 5.3버전으로는 아래의 오류가 발생하지 않았지만 혹시 몰라 기록

컴파일 후 실행(2인클라리언트로)하면 클라이언트가 서버로부터 연결이 끊어진다.
디버그를 살펴보면 InitTargetDataSriptStructCache 관련된 에러가 발생함을 알 수 있다.
TODO: 캡쳐후추가
TargetData 를 사용하기 위한 코드가 없기 때문이므로 이를 추가해준다.

  • AuraAssetManager.cpp
// AuraAssetManager.cpp
...
#include "AbilitySystemGlobals.h"
...
void UAuraAssetManager::StartInitialLoading()
{
	...
    // InitTargetDataSriptStructCache 에러 해결
    // TargetData를 사용하기 위해 필요함
    UAbilitySystemGlobals::Get().InitGlobalData();
}

컴파일 후 실행하면 더이상 클라이언트측에서 연결이 끊기지 않고 적에게 DebugSphere 가 생성되는 것을 양 측에서 확인할 수 있다.

추가
아래 내용의 대략적인 요약
클라이언트 측에서 무언가 행동을 하면 이를 서버에 보내고, 서버에서 연산 이후 결과를 다시 클라리언트로 전송하는 과정을 진행한다면 실시간으로 진행되는 멀티플레이 게임에서 큰 딜레이가 발생하게 된다. 그러므로 예측을 통해 처리하고 결과를 서버에 보내 값이 유효하지 않을 경우 롤백을 시도하도록 한다.
이때 예측은 에측이 발생할 수 있는 창이 존재하는데, 이 창이 열려있는 동안 클라이언트는 서버의 허가 없이도 게임 상태를 변경할 수 있는 중요한 액션을 수행가능함.
클라이언트 측에서 Server RPC를 호출하면 서버가 능력 활성화의 유효를 검사하고 결정을 내림. 서버는 능력 활성화 실패시 ClientActivateAbilityFaild 를, 성공시 CleintActivateAbilitySucceeded 를 호출하여 클라이언트에게 전달하고, 클라이언트는 이를 통해 롤백여부를 결정함.
ASC에는 ReplicatedPredictionKey 라는 멤버변수가 있고, 설정되자마자 복제됨. 클라이언트가 이 값을 받으면 롤백을 하는 등의 반응이 가능함.
GameplayEffect 는 클라이언트측에서만 적용되므로 해당 부분에 대해 SideEffect(부작용)이 발생할 수 있다.
동일하게 PredictionKey 를 가지고 있고 Attribute Modificaitons , Gameplay Tag Modificaiton , Gameplay Cues 와 같은 것들이 예측된다.
FActiveGameplayEffect 가 생성될 때 PredictionKey 를 저장하게 되는데, 이때 서버도 같은 키를 가지게 된다.
FActiveGameplayEffect 는 리플리케이트되는데, 클라이언트측에서 이를 받게 되면 키를 통해 이미 동일한 키의 GameplayEffect 가 활성화되었는지 확인 가능하다.


GAS 는 자동으로 아래의 항목들을 예측하여 처리하지만

  • Gameplay Ability Activaiton
  • Treggered Event
  • Gameplay Effect Application
    • Attribute Modifiers ( not Execution Calculations)
    • GameplayTag Modificaiton
  • Gameplay Cue Events
    • From within a predicted GAmeplayAbility
    • Their own Events
  • Montages
  • Movement (UCharacerMovement)
    아래의 항목들은 예측하지 않는다.
  • Gameplay Effect Removal
  • Gameplay Effect Periodic Effects

GAS Prediction

GAS 에서의 예측은 Prediction Key 라는 개념을 중심으로 이루어진다.
Prediction KeyFPredictionKey 를 기반으로 하는데, 간단하게 설명하자면 고유 ID라고 볼 수 있으며, 클라이언트의 중앙 위치에 저장한다.


클라이언트가 예측가능한 액션을 취하면 Prediction Key 를 서버에 전달하고, 클라이언트 측의 예측 동작 및 부작용을 해당 키와 연결한다.
서버는 해당 키를 받거나, 거절한다.
그러면 그 키와 관련된 모든 서버 생성 부작용을 연결하고, 클라이언트에게 그 키가 받아들여졌는지 또는 거부되었는지 알려주게 된다.
클라이언트에서 서버로 전송되는 FPredictionKey 는 항상 서버에 도달하게 된다.


이전에는 코드에서 UPROPERTY 매크로를 통해 ReplicatedReplicatedUsing 를 사용하여 서버에서 클라이언트로만 리플리케이션 되도록 작성하였다.
이러한 경우에는 복제가 서버에서 클라이언트로만 발생하게 된다.
키도 동일하게 클라이언트에서 서버로 복제되어 보내지게 된다.
여기서 Prediction Key 를 서버로 보내는 과정도 편의상 리플리케이션이라는 단어를 사용하게 된다.


서버가 Prediction Key 를 다시 보낼 때, 키가 처음에 서버로 보낸 클라이언트에게만 돌아가는데, 클라이언트로의 리플리케이션은 FPredictionKey::NetSerialize() 를 통해 이루어지며, 다른 모든 클라이언트는 그 키가 복제될 때 유효하지 않은 키를 받거나, ID가 0인 키를 받게 되므로 혼동이 발생하지 않는다.

Ability Activation

GAS 에서 GameplayAbility 는 예측적인 액션의 첫번째 클래스로 간주된다. 이는 클라이언트가 예측적으로 능력을 활성화할 때마다 명시적으로 서버에 알리고, 서버가 명시적으로 응답하는 것을 의미한다.
클라이언트가 예측적으로 능력을 활성화할 떄, 예측적 액션이 발생할 수 있는 창이 존재하는데, 이 창을 예측 창으로 지칭하며, 창이 열려있는 동안 클라이언트는 서버의 허가 없이도 게임 상태를 변경할 수 있는 중요한 액션을 수행할 수 있다.


ASC 에는 클라이언트와 서버 간의 능력 활성화 통신을 위한 함수인 TryActivateAbility() 함수가 있고 이미 사용한 적이 있다.
클라이언트가 TryActivateAbility() 함수를 호출하면 새로운 FPredictionKey 타입의 ActivationPredictionKey 생성하게 되고, GetActivationPredictionKey() 함수를 호출해서 나온 결과를 ActivaitonPredictionKey 에 할당하게 된다.
TryActivateAbility() 가 호출된 후에는 서버 RPC(Server TryActivateAbility()) 가 호출되고, 클라이언트는 서버의 확인 신호를 기다리지 않고 계속해서 능력 활성화와 활성화 정보에 부여된 PredictionKey 와 함게 ActivateAbility() 를 호출한다.
클라이언트가 작업을 수행하고 부작용을 생성하는 동안, 모든 부작용은 생성된 PredictionKey 와 관련이 있다.


클라이언트가 Server RPC 를 호출하면 서버는 능력 활성화가 유효한지 여부를 결정하고, 이를 통해 클라이언트가 활성화한 능력의 성공여부를 알 수 있다. 서버는 두가지 두가지 클라이언트 RPC중 하나를 호출하는데 실패한 경우 ClientActivateAbilityFailed 를, 성공한 경우 ClientActivateAbilitySucceeded 를 호출한다.
그러면 클라이언트는 서버의 응답을 받고 실패일 경우 능력을 종료한 다음 생성된 PredictionKey 와 관련된 모든 부작용을 되돌린다.
ASC 에는 ReplicatedPredictionKey 라는 멤버변수가 있는데, 생성된 PredictionKey 로 설정되고, 설정되자마자 복제된다. 클라이언트가 이 값을 받으면 롤백을 하는 등의 적절한 반응을 할 수 있다.

클라이언트측에서 DebgSphere 가 생성되는 것을 확인하였으므로 이제 원하는 방향으로 파이어볼트가 날아가도록 해야 한다.

  • AuraProjectileSpell.h
// AuraProjectileSpell.h

...
protected:
	...
    
    /** 코드 수정 */
    void SpawnProjectile(const FVector& ProjectileTargetLocation);
    /** 코드 수정 */
    
    ...
  • AuraProjectileSpell.cpp
// AuraProjectileSpell.cpp

...
void UAuraProjectileSpell::SpawnProjectile(const FVector& ProejctileTargetLocation)
{
	const bool bIsServer = GetAvatarActorFromActorInfo()->HasAuthority();
    if(!bIsServer) return;
    
    ICombatInterface* CombetInterface = Cast<ICombatInterface>(GetAvatarActorFromActorInfo());
    if(CombatInterface)
    {
    	const FVector SocketLocation = CombatInterface->GetCombatSocketLocation();
        /** 코드 추가 */
        FRotator Rotation = (ProjectileTargetLocation - SocketLocaiton).Rotation();
        // 지면으로부터 평행하게 이동하기 위함
        Rotation.Pitch = 0.f;
        /** 코드 추가 */
        
        FTransform SpawnTransform;
        SpawnTransform.SetLocaiton(SocketLocation);
        /** 코드 추가 */
        SpawnTransform.SetRotation(Rotation.Quaternion());
        /** 코드 추가 */
    }
}

컴파일 후 GA_FireBolt 에서 Draw Debug Sphere 관련 노드를 전부 삭제하고, Break Hit Point : Location 을 변수로 승격시킨 후 노드를 연결한다.

Spawn Projectile 노드에 ProjectileTargetLocation 을 연결시키고


(활성화하자마자 끝내면 캔슬되어버리기 때문에 더이상 발사가 안되므로 마지막의 End Ability 노드 삭제 필요)
컴파일 후 실행하면 서버와 클라이언트 양쪽다 타겟을 향해 파이어볼트가 발사되는 것을 확인할 수 있다.

추가
카메라가 다른 액터와 충돌시 줌인되는 이슈 해결

원인은 CapsuleComponent 와 캐릭터의 메시가 카메라를 블럭처리하기 때문
TODO: 블루프린트에서 콜리전 설정에 블럭처리된거 표시

  • AuraCharacterBase.cpp
// AuraCharacterBase.cpp
...
#include "Components/CapsuleComponent.h"
...
AAuraCharaterBase::AAuraCharacterBase()
{
	PrimaryAcdtorTick.bCanEverTick = false;
    /** 코드 추가 */
    GetCapsuleComponent()->SetCollisionResponseToChannel(ECC_Camera, ECR_Ignore);
    GetMesh()->SetCollisionResponseToChannel(ECC_Camera, ECR_Ignore);
    /** 코드 추가 */
    ...
}

컴파일 후 실행시 더이상 카메라가 줌인되지 않음.

캐릭터가 적이 아닌 지점을 클릭해도 공격이 가능하도록 수정이 필요하다.
Shift 키를 누를 경우 호버링이 아닌 경우에도 파이어볼트가 발사되도록 구현한다.
먼저 Shift 키에 대한 InputAction 이 필요하다.
IA_Shift 라는 InputAction 블루프린트를 생성하고 Value Type : Axis 1D (float) 로 설정한다.

생성한 IA_ShiftIMC_AuraInputContext 에 매핑한다.

이제 Shift 가 눌렸을 경우 실행될 기능에 대해 AuraPlayerController 클래스에서 코드를 추가해준다.

  • AuraPlayerController.h
// AuraPlayerController.h

...
private:
	...
    
    UPROPERTY(EditAnywhere, Category = "Input")
    TObjectPtr<UInputAction> MoveAction;
    
    // 레퍼런스를 매개변수로 받기 때문에, 값변경을 방지하기 위한 const 선언
	// FInputActionValue 사용을 위한 전방선언 필요
	void Move(const FInputActionValue& InputActionValue);
    
    /** 코드 추가 */
    UPROPERTY(EditAnywhere, Category = "Input")
    TObjectPtr<UInputAction> ShiftAction;
    
    bool bShiftKeyDown = false;
    void ShiftPressed() { bShiftKeyDown = true; };
    void ShiftReleased() { bShiftKeyDown = false; };
    /** 코드 추가 */
    
    ...
  • AuraPlayerController.cpp
// AuraPlayerController.cpp

...

void AAuraPlayerController::SetupInputComponent()
{
	...
    AuraInputComponent->BindAction(MoveAction, ETriggerEvent::Triggered, this, &AAuraPlayerController::Move);
    /** 코드 추가 */
    AuraInputComponent->BindAction(ShiftAction, ETriggerEvent::Started, this, &AAuraPlayerController::ShiftPressed);
    AuraInputComponent->BindAction(ShiftAction, ETriggerEvent::Completed, this, &AAuraPlayerController::ShiftReleased);
    /** 코드 추가 */
	
    ...
}

...

void AAuraPlayerController::AbilityInputTagReleased(FGameplayTag InputTag)
{
	//  LMB가 아닐 경우 공격이 발생되지 않았으므로 해당 조건문은 관련x
    if(!InputTag.MatchedTagExact(FAuraGameplayTag::Get().Input_LMb))
    {
    	...
    }
    
    // 이동인 경우 bTargeting일 필요가 없음
    // shift키 추가로 적을 타게팅하던 하지 않던 해당 방향으로 공격을 시도하므로 if(bTargeting) 조건이 필요 없음
    if(GetASC()) GetASC()->AbilityInputTagReleased(InputTag);
    
    if(bTargeting)
    {
    	/** 코드 이동 : if문 밖으로 이동후 if문 삭제 */
        if(GetASC()) GetASC()->AbilityTagInputReleased(InputTag);
        /** 코드 이동 : if문 밖으로 이동후 if문 삭제 */
    }
    /** 코드 수정 : else -> if(!bTargeting && !bShiftKeyDown) */
    if(!bTargeting && !bShiftKeyDown)
    /** 코드 수정 : else -> if(!bTargeting && !bShiftKeyDown) */
    {
    	...
    }
}

void AAuraPlayerController::AbilityInputTagHeld(FGameplayTag InputTag)
{
	
 //  LMB 입력이 아닐 경우 공격 자체가 발생되지 않으므로 해당 조건문에는 코드추가 없음
 if(!InputTag.MatchedTagExact(FAuraGameplayTag::GEt().Input_LMb))
    {
    	...
    }
    
    // LMB 입력시 타게팅하지 않더라도 공격이 발생되게 하기 위해 조건문 수정
    /** 코드 수정 */
    if(bTargeting || bShiftKeyDown)
    {
    	...
    }
    /** 코드 수정 */
}

...

컴파일 후 BP_AuraPlayerController 에서 코드에 추가한 ShiftAction : IA_Shift 로 설정해주고

컴파일 후 실행하면 Shift 키를 누를 경우 호버링 도중이 아니더라도 해당 방향으로 파이어볼트가 사출되는 것을 확인할 수 있다.

8.6.3 캐릭터 방향 회전

Motion Warping 을 위해서는 사용하려는 캐릭터 애니메이션의 Root Motion -> EnableRootMotion 이 활성화되어 있어야 한다.

활성화가 되어있는지 확인되었다면 Motion Warping 을 사용하기 위해 플러그인을 추가해주어야 한다.
플러그인 추가 창에서 Motion Warping 을 검색한 후 추가해준다.

BP_AuraCharacter 로 돌아와서 Component 탭에 Motion Waring 을 추가해준다.

Motion Warping 을 사용하기 위해서 사용할 에니메이션 몽타주에 노티파이를 추가해야 한다.
AM_Cast_FireBolt 에서 기존의 커스텀 노티파이의 트랙명을 Events 로 변경하고 Motion Warping 을 위한 새로운 트랙을 추가한다.

애니메이션이 시작되고 살짝 뒤의 지점에 Motion Warping 노티파이를 추가해준 다음 커스텀 노티파이 발생 전까지 노티파이를 끌어 범위를 지정해준다.

Details 탭에서 Root Motion Modifier -> Warp Target Name : FacingTarget 으로 설정해주고, Warp Translation 을 비활성화킨 다음 Rotation Type : Facing 으로 변경한다.

다시 BP_AuraCharacter 로 돌아와서 Event Graph 탭에서 Add Custom Event 를 통해 SetFacingTarget 이벤트를 생성한다.

생성해둔 MotionWarping 컴포넌트를 통해 Add or Update Target from Location 노드를 생성하여 SetFacingTarget 이벤트 노드와 연결한다.

SetFacingTarget 이벤트의 Input 을 추가해주고

TargetLocation 핀과 연결시켜준 다음, Warp Target Name : FacingTarget 으로 설정해준다.

FacingTarget 을 지정해주기 위해 GA_FireBoltEvent Graph 탭에서 Get Avatar Actor from Actor Info 노드를 생성한 다음 Return Value -> Cast to BP_AuraCharacter 를 통해 캐스팅을 진행하고, 해당 과정을 Projectile Location 변수 승격 이후에 진행하도록 노드를 구성한다.

BP_AuraCharacter 에 추가해둔 커스텀 이벤트인 SetFacingTarget을 호출하고 Input 으로 변수로 만들어둔 Projectile Location 을 파라미터로 전달하도록 한 다음 PlayMontageAndWait 노드와 연결한다.

컴파일 후 실행하여 파이어볼트를 발사하려고 시도할 경우 캐릭터가 해당 방향으로 회전하는 것을 확인할 수 있다.

지금까지 만든 기능은 캐릭터뿐만이 아니라 적에게도 동일한 기능이 적용되어야 하므로 코드를 수정한다.

  • CombatInterface.h
// CombatInterface.h

...
UINTERFACE(MinimalAPI, BlueprintType)
...

public:
	...
    
    UFUNCTION(BlueprintImplementableEvent, BlueprintCallable)
    void UpdateFacingTarget(const FVector& Target);

해당 함수의 구현부를 구현하기 위해 다시 BP_AuraCharacter 로 돌아와서 Event Update Facing Target 노드를 생성하여 기존의 커스텀 이벤트인 SetFacingTarget 과 교체한다.

GA_FireBolt 에서는 더이상 BP_AuraCharacter 를 캐스팅하는 것이 아닌 CombatInterface 를 캐스팅하여 해당 함수를 호출하도록 수정해야 한다.
Set Facing Target 사용할 수 없으므로 삭제하고, Get Avatar Actor from Actor 노드의 Return Value 를 통해 Cast To CombatInterface 노드를 추가하여 Set Projectile Target 노드와 연결시킨다.

이후 CombatInterface 클래스에 추가한 UpdateFacingTarget 함수를 호출하도록 하고 나머지 노드와 연결한다.

8.6.4 Projectile Impact 생성

Projectile 충돌시 해당 지점에 충돌 이펙트를 나타내기 위해서 코드를 수정한다.
먼저 NiagaraSystem 을 코드상에서 사용하기 위해서 모듈을 추가해야 한다.

  • Aura.Build.cs
// Aura.Build.cs

...
	PrivateDependencyModuleNames.AddRAnge([] { "...", "Niagara" });
  • AuraProjectile.h
// AuraProjectile.h

...
class UNiagaraSystem;
...

protected:
	...
    virtual void Destroyed() override;
private:
	bool bHit = false;
    
	...
    
	UPROPERTY(EditAnywhere)
	TObjectPtr<UNiagaraSystem> ImpactEffect;

	UPROPERTY(EditAnywhere)
	TObjectPtr<USoundBase> ImpactSound;
  • AuraProjectile.cpp
// AuraProjectile.cpp

...
#include "kismet/GameplayStatics.h"
#include "NiagaraFunctionLibrary.h"
// #include "NiagaraFunctionLibrary.h" 안되면 아래 코드 사용
// #include "E:/Unreal Engine/UE_5.3/Engine/Plugins/FX/Niagara/Source/Niagara/Public/NiagaraFunctionLibrary.h"
...

...

void AAuraProjectile::Destroyed()
{
	if(!bHit && !HasAuthority())
    {
    	UGameplayStatics::PlaySoundAtLocation(this, ImpactSound, GetActorLocation(), FRotator::ZeroRotator);
    	UNiagaraFunctionLibrary::SpawnSystemAtLocation(this, ImpactEffect, GetActorLocation());
    }
    
	Super::Destroyed();
}

void AAuraProjectile::OnSphereOverlap(UPrimitiveComponenet* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherCom, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	UGameplayStatics::PlaySoundAtLocation(this, ImpactSound, GetActorLocation(), FRotator::ZeroRotator);
    UNiagaraFunctionLibrary::SpawnSystemAtLocation(this, ImpactEffect, GetActorLocation());
    
    // 서버에서 실행중인 경우
    if(HasAuthority())
    {
    	Destroy();
    }
    // 클라이언트에서 실행중인 경우
    else
    {
    	bHit = true;
    }
}

컴파일 후 사운드와 이펙트를 추가하기 위해 BP_FireBolt 로 돌아와 ImpactEffect : NS_FireExplosion1 , ImpactSound : sfx_FireBolt_Impact 로 설정해준다.

컴파일 후 실행시 사운드와 이펙트가 적에게 생성되는 것을 확인할 수 있다.

추가
마우스 좌클릭시 BP_FireBolt 생성안되는 오류 해결방법
AuraProjectile.cppBeginPlay()

Sphere->IgnoreActortWhenMoving(GetInstigator(), true);

추가
AuraProjectileSpell.cppSpawnProjectile() 함수에서 해당 코드 수정

AAuraProjectile* Projectile = GetWorld()->SpawnActorDeferred<AAuraProjectile>(
    ProjectileClass,
    SpawnTransform,
    GetOwningActorFromActorInfo(),
    Cast<APawn>(CurrentActorInfo->AvatarActor)
    /*Cast<APawn>(GetOwningActorFromActorInfo())*/,
    ESpawnActorCollisionHandlingMethod::AlwaysSpawn);

파이어볼트 캐스팅시 사운드도 추가해야 한다.
AM_Cast_FireBolt 에서 새로운 노티파이 트랙 Sound 를 추가한 다음 PlaySound 노티파이를 추가하고 Details 패널에서 sfx_FireBolt 로 설정한다.

컴파일 후 실행하면 캐스팅시 사운드가 출력되는 것을 확인할 수 있다.
이어서 파이어볼트가 타겟을 향해 날아가는 동안 재생될 사운드를 추가한다.

  • AuraProjectile.h
// AuraProjectile.h

...
private:
	...
    
    UPROPERTY(EditAnywhere)
    TObjectPtr<USoundBase> LoopingSound;
    
    UPROPERTY()
    TObjectPtr<UAudioComponent> LoopingSoundComponent;
  • AuraProjectile.cpp
#include "..."
#include "Components/AudioComponent.h"

...

void AAuraProjectile::BeginPlay()
{
	...
    
    LoopingSoundComponent = UGameplayStatics::SpawnSoundAttached(LoopingSound, GetRootComponent());
}

...

void AAuraProjectile::OnSphereOverlap(UPrimitiveComponent* OverlappedComponent, AACtor* OtherActor, UPrimitiveComponent* OtherComp, bool bFromSweep, const FHitResult& HitResult)
{
	UGameplayStatics::PlaySoundAtLocation(...);
    UNigaraFunctionLibrary::SpawnSystemAtLocaiton(...);
    LoopingSoundComponent->Stop();
    
    ...
}

컴파일후 BP_FireBolt 에서 Looping Sound : sfx_FireBoltHiss 로 설정해준다.

컴파일 후 실행하면 파이어볼트가 오브젝트에 충돌해 사라지기 전까지 사운드가 재생된다.
마지막으로 파이어볼트의 LifeSpan 을 추가해 계속 날아가지 않도록 한다.

  • AuraProjectile.h
// AuraProjectile.h

...
private:
	UPROPERTY(EditDefaultsOnly)
	float LifeSpan = 15.f;
    
    ...
  • AuraProjectile.cpp
// AuraProjectile.cpp

...

void AAuraProjectile::BeginPlay()
{
	Super::BeginPlay();
 
 	SetLifeSpan(LifeSpan);
    ...
}

8.6.5 Projectile Collision Channel 생성

추가
레벨에 배치한 액터를 파이어볼트가 통과하는 이슈 수정
액터 선택후 뷰포트의 Details 패널 -> Generate Overlap Events 활성화

Projectile 에 대한 채널을 따로 생성해서 충돌 관련 설정을 관리하는 것이 추후에 충돌 관련해서 관리하기에 편리하다.
Project Settings -> Object 검색 -> Engine - Collision -> New Object Channel... -> Name : Projectile , Default Response : Ignore 로 설정하고 추가한다.

해당 채널을 사용하기 위해 Aura.h 에 채널을 적용시키기 위한 코드를 추가한다.

  • Aura.h
// Aura.h

...
#define ECC_Projectile ECollisionChannel::ECC_GameTraceChannel1

컴파일 후 BP_ManaPotion 의 콜리젼 설정을 살펴보면 채널을 설정한대로 ProjectileIgnore 하도록 설정되어 있다.

레벨에 배치된 액터는 ProjectileBlock 해야하므로 뷰포트의 Details 패널의 콜리젼 설정에서 Projectile : Block 으로 변경한다.

BP_FireBoltObject Type : Projectile 로 변경하고

코드 추가를 통해 c++ 클래스에도 적용시킨다.

  • AuraProjectile.cpp
// AuraProjectile.cpp

...
#include "Aura/Aura.h"
...

AAuraProjectile::AAuraProjectile()
{
	PrimaryActortick.bCanEverTick = false;
    bReplicates = true;
    
    Sphere = CreateDefaultSubobject<USphereComponent>("Sphere");
    SetRootComponent(Sphere);
    /** 코드 추가 */
    Sphere->SetCollisionObjectType(ECC_Projectile);
    /** 코드 추가 */
    ...
}

컴파일 후 실행하면 더이상 파이어볼트가 포션을 파괴하지 않는다.

적의 경우 Overlap이 발생해야 하므로 BP_EnemyBase 에서 Mesh 를 선택하고 콜리젼 설정으로 가서 Projectile : Overlap 으로 변경한다.

BP_EnemyBase 에서 Generate Overlap Events 를 활성화시킨다.

코드 추가를 통해 c++ 클래스에도 적용시킨다.

  • AuraCharacterBase.cpp
// AuraCharacterBase.cpp

...
#include "Aura/Aura.h"
...

AuraCharacterBase::AuraCharacterBase()
{
	GetCapsuleComponent()->SetCollisionResponseToChannel(ECC_Camera, ECR_Ignore);
    GetMesh()->SetCollisionResponseToChannel(ECC_Camera, ECR_Ignore);
    /** 코드 추가 */
    GetMesh()->SetCollisionResponseToChannel(ECC_Projectile, ECR_Overlap);
    GetMesh()->SetGenerateOverlapEvents(true);
    /** 코드 추가 */
    
    ...

컴파일 후 실행시 적에게 오버랩이 발생해 ImpactEffect 가 발생하는 것을 확인할 수 있다.

추가
8.6.4에서 추가한 에러 해결방법과 동일
Overlap된 OtherActorInstigator 일 경우 리턴하도록 하면 FireBoltAuraCharacter 와 충돌이 발생하지 않음.

...
void AAuraProjectile::OnSphereOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{   
    // Sphere->
    if (OtherActor == GetInstigator()) return;

    ...
}

8.7 GameplayEffect를 통한 Attribute값 변경

GameplayEffect 를 통해 적에게 데미지를 가하여 Attribute 를 변경시켜야 한다.
먼저 GameplayEffect 기반의 블루프린트 GE_Damage 를 생성한다.

GameplayEffect -> Modifiers -> +Modifiers 를 추가해주고
Attribute : AuraAttributeSet.Health , Modifier Op : Add , Modifier Magnitude -> Scalable Float Magnitude : -10.0 으로 설정해준다.

GameplayEffect 를 추가하고 설정할 수 있도록 코드를 추가한다.

  • AuraProjectile.h
// AuraProjectile.h

...
#include "GameplayEffectTypes.h"
...

...
public:
	...
    
    // GameplayEffect를 다루기 위함
    UPROPERTY(BlueprintReadWrite, meta = (ExposeOnSpawn = true))
    FGameplayEffectSpecHandle DamageEffectSpecHandle;

AuraProjectileSpell 클래스에서 데미지를 가하도록 하는 GE클래스를 블루프린트에서 설정할 수 있도록 코드를 작성한다.

  • AuraProjectileSpell.h
// AuraProjectileSpell.h

...
class UGameplayEffect;
...

...
protected:
	...
    
    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
    TSubclassOf<UGameplayEffect> DamageEffectClass;
  • AuraProjectileSpell.cpp
// AuraProjectileSpell.cpp

...
#include "AbilitySystemBlueprintLibrary.h"
#include "AbilitySystemComponent.h"
...

void UAuraProjectileSpell::SpawnProjectile(const FVector& ProjectileTargetLocation)
{
    ...
    if(CombatInterface)
    {
    	...
        
        const UAbilitySystemComponent* SourceASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(GetAvatarActorFromActorInfo());
        const FGameplayEffectSpecHandle SpecHandle = SourceASC->MakeOutgoingSpec(DamageEffectClass, GetAbilityLevel(), SourceASC->MakeEffectContext());
        Projectile->DamageEffectSpecHandle = SpecHandle;
        
        Projectile->FinishSpawning(SpawnTransform);
    }
}

AuraProjectile 의 오버랩 발생시 GameplayEffect 를 적용하도록 코드를 추가한다.

  • AuraProjectile.cpp
// AuraProjectile.cpp

...
#include "AbilitySystemBlueprintLibrary.h"
#include "AbilitySystemComponent.h"
...

void AAuraProjectile::OnSphereOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	...
    
    if(HasAuthority())
    {
    	/** 코드 추가 */
    	// 타겟의 ASC에 GameplayEffect 적용
        if(UAbilitySystemComponent* TargetASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(OtherActor))
        {
        	TargetASC->ApplyGameplayEffectSpecToSelf(*DamageEffectSpecHandle.Data.Get());
        }
        /** 코드 추가 */
        Destroy();
    }
}

...

GameplayEffect 적용 여부를 확인하기 위해 로그를 추가하고

  • AuraAttributeSet.cpp
// AuraAttributeSet.cpp

void UAuraAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
	...
    
    if(Data.EvaluatedData.Attribute == GetHealthAttribute())
    {
    	...
        UE_LOG(LogTemp, Warning, TEXT("Changed Health on %s, Health: %f"), *Props.TargetAvatarActor->GetName(), GetHealth());
	}
}

...

마지막으로 적이 Attribute 를 가질 수 있도록 코드를 추가한다.

  • AuraEnemy.cpp
// AuraEnemy.cpp

void AAuraEnemy::InitAbilityActorInfo()
{
	...
    
    // Attribute를 소유할 수 있게 함
    InitializeDefaultAttributes();
}

컴파일후 BP_EnemyBase 에서 Default Primary Attributes : GE_AuraPrimaryAttributes , Default Secondary Attributes : GE_AuraSecondaryAttributes , Default Vital Attributes : GE_AuraVitalAttributes 로 설정해준다.

BP_Goblin_Spear 에 가서 확인해보면 설정이 위와 같이 변경된 것을 확인할 수 있다.

GA_FireBolt 에서 Damage Effect ClassGE_Damage 로 설정해주고 컴파일 후 실행하면 로그에 GameEffect 에 입력된 수치에 따라 체력이 10 줄어드는 것을 확인할 수 있다.

공격을 여러번 시도했을 때에도 적용이 잘 되는지 확인해보기 위해 약간의 딜레이와 End Ability 노드를 연결시켜준다.

컴파일 후 실행하면 정상적으로 동작하는 것을 확인할 수 있다.

추가
오버랩이 두번 되어 데미지가 두배로 들어오는 문제 해결

메쉬와 캡슐 컴포넌트가 둘다 오버랩되어 데미지가 두번 들오는 문제이다.

  • 캐릭터의 캡슐 컴포넌트 콜리전
  • 캐릭터의 메쉬 콜리젼
  • BP_FireAreaBox Collision 의 오브젝트 타입



    일반적으로 캡슐 컴포넌트의 경우 벽과 같은 지형지물을 뚫지 않도록 하는 역할을 하므로 캡슐 컴포넌트의 Generate Overlap Events 를 비활성화 시켜주면 해결된다.

    AuraCahracterBase 클래스의 코드를 수정해준다.
  • AuraCharacterBase.cpp
// AuraCharacterBase.cpp
...
AAuraCharacterBase::AAuraCharacterBase()
{
	PrimaryActorTick.bCanEverTick = false;
    GetCapsuleComponent()->SetCollisionResponseToChannel(ECC_Camera, ECR_Ignore);
    /** 코드 추가 */
    GetCapsuleComponent()->SetGenerateOverlapEvents(false);
    /** 코드 추가 */
    ...
}

컴파일 후 실행시 정상적으로 5씩 감소되는 것을 확인할 수 있다.

8.8 적 Attribute 변경점 확인을 위한 Widget 추가

8.8.1 ProgressBar 생성

AuraEnemy 클래스에 WidgetComponent 를 추가하는 코드를 작성한다.

  • AuraEnemy.h
// AuraEnemy.h

...
class UWidgetComponent;
...

...
protected:
	...
    
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
    TObjectPtr<UWidgetComponent> HealthBar;
  • AuraEnemy.cpp
// AuraEnemy.cpp

...
#include "Components/WidgetComponent.h"
...

AAuraEnemy::AAuraEnemy()
{
	...
    
	HealthBar = CreateDefaultSubobject<UWidgetComponent>("HealthBar");
    HealthBar->SetupAttachment(GetRootComponent());
}

...

컴파일 후 에디터로 돌아와 새로운 Widget 블루프린트 WBP_ProgressBar 를 생성한다.
(기본 ProgressBar 역할, 추후 HP관련 위젯은 해당 블루프린트 기반으로 생성예정)

먼저 Size Box 를 추가해주고

크기를 원하는 사이즈로 조정할 수 있도록 Desired 로 설정을 변경한다.

적당히 가로 세로 길이를 조절하여 크기를 확인해보고

SizeBox_RootIsVariable 활성화를 통해 Graph 에서 노드로 사용할 수 있게 한다.
Graph 탭에서 가로 세로 길이를 조절할 수 있도록 노드를 구성한 뒤

float 타입 변수 BoxWidthBoxHeight 를 생성하고, Progress Bar Properties 카테고리에 소속시킨다.

BoxWidth 값을 60으로, BoxHeight 값을 5로 설정한다.

마지막으로 전체 노드를 UpdateBoxSize 함수로 리팩토링한다.

SizeBox 관련 구현이 끝났으면 OverlayProgress Bar 를 추가해야 한다.

가로 세로 전체 가득 채우도록 설정을 변경하고

Progress BarApperance -> Fill Color and Opacity 에서 Saturation 을 최대로 낮추고

Background ImageTint 알파값을 0으로 변경하고

중간에 Percent 조절로 잘 작동되는지도 확인해본다.

Graph 에서 노드로 사용하기 위해 ProgressBar_FrontIsVariable 을 활성화시키고

그래프탭에서 노드를 구성한다.
먼저 Progress Bar Front 노드를 통해 Set Style 노드를 생성한다.

Set Style 노드의 Style 핀에서 드래그하여 Make ProgressBarStyle 노드를 생성하고, BackGround Image 핀에서 드래그하여 Make SlateBrush 노드를 생성하고, Tint 핀에서 드래그하여 Make SlateColor 노드를 생성한다.

Specified Color 에서 Saturation 을 최대로 낮추고 알파값을 0으로 변경한다.

Make ProgressBarStyle 노드에서 Fill Image 핀을 변수로 승격시켜 FrontBarFillBrush 로 수정한 후, 카테고리도 변경한다.

FrontBarFillBrushTint 에서 색상을 적절히 적용시키고

마지막으로 전체 노드를 UpdateFrontFillBrush 함수로 리팩토링한다.

잘 적용되는지 확인하기 위해 BP_AuraEnemy 에서 Space : Screen 으로 변경하고 생성한 WBP_ProgressBar 를 적용시킨 다음 Draw at Desired Size 를 활성화시킨다.

컴파일 후 실행하면 체력바가 정상적으로 생성된 것을 확인할 수 있다.

높이를 70.f로 변경하여 적당한 높이에 위젯이 위치하도록 수정한다.

추가
가로 세로 길이 수정
BoxWidth : 80 , BoxHeight : 6

이제 AuraEnemy 클래스에서 델리게이트를 통해 Attribute 값에 대해 Broadcast 하도록 코드를 수정한다.

  • AuraEnemy.h
// AuraEnemy.h

...
#include "UI/WidgetController/OverlayWidgetController.h"
...

...
public:
	...
    
    UPROPERTY(BlueprintAssignable)
    FOnAttributeChangedSignature OnHealthChanged;

	UPROPERTY(BlueprintAssignable)
    FOnAttributeChangedSignature OnMaxHealthChanged;
  • AuraEnemy.cpp
// AuraEnemy.cpp

...

void AAuraEnemy::BeginPlay()
{
	Super::BeginPlay();

	InitAbilityActorInfo();
    
    // ASC가 있는 상태에서 Broadcast해야 함(InitAbilityActorInfo()에서 ASC 소유)
    // 게임 시작시 델리게이트를 보유하도록 설정
    // Health 또는 MaxHealth 변경 발생시 델리게이트를 통한 Broadcast로 값 변경
    if(const UAuraAttributeSet* AuraAS = Cast<UAuraAttributeSet>(AttributeSet))
    {
    	AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AuraAS->GetHealthAttribute()).AddLambda(
        	[this](const FOnAttributeChangeData& Data)
            {
            	OnHealthChanged.Broadcast(Data.NewValue);
            }
        );
        AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AuraAS->GetMaxHealthAttribute()).AddLambda(
        	[this](const FOnAttributeChangeData& Data)
            {
            	OnMaxHealthChanged.Broadcast(Data.NewValue);
            }
        );
    }
}

컴파일 후 에디터로 돌아와서 WBP_ProgressBar 기반의 WBP_EnemyHealthBar 를 생성한다.

BP_EnemyBase 에서 HealthBarWidget Class 를 방금 생성한 WPB_EnemyHealthBar 로 변경한다.

새로 만든 위젯에 Widget Controller 를 설정해주기 위해 코드를 추가한다.

  • AuraEnemy.cpp
// AuraEnemy.cpp

...
#include "UI/Widget/AuraUserWidget.h"
...

void AAuraEnemy::BeginPlay()
{
	Super::BeginPlay();
    
    InitAbilityActorInfo();
    
    /** 코드 추가 */
    if(UAuraUserWidget* AuraUserWidget = Cast<UAuraUserWidget>(HealthBar->GetUserWidgetObject()))
    {
    	AuraUserWidget->SetWidgetController(this);
    }
    /** 코드 추가 */
    
    if(const UAuraAttributeSet* AuraAS = Cast<UAuraAttributeSet>(AttributeSet))
    {
    	...
        // 현재 Health와 MaxHealth 값을 Broadcast를 통해 알림
        OnHealthChanged.Broadcast(AuraAS->GetHealth());
        OnMaxHealthChanged.Broadcast(AuraAS->GetMaxHealth());
    }
}

...

에디터로 돌아와서 WBP_EnemyHealthBarGraph 탭에서 Event Widget Controller Set 노드를 통해 BP_EnemyBase 로의 캐스팅을 시도하여 접근가능하도록 변수로 승격시켜준다.

Bp_EnemyBase 로부터 Assign On Health Changed 델리게이트 노드를 통해 체력 변경시 ProgressBar 가 업데이트 되도록 할 수 있다.

MaxHealth 도 동일하게 Sequence2 : Then 2 에 연결하여 노드를 구성한다.

델리게이트를 통한 리턴값들을 변수로 승격시겨 HealthMaxHealth 로 설정하고

ProgressBar 의 퍼센트를 변경하기 위한 노드를 구성한 다음

HealthMaxHealthSafe Divide 노드를 통해 퍼센트를 구하고, Set Percent L In Percent 핀과 연결시킨다.

동일한 과정을 MaxHealth 쪽에도 진행한다.

컴파일후 실행시 정상적으로 체력이 감소되는 것을 확인할 수 있다.

8.8.1 GhostBar 생성

먼저 WBP_ProgressBar 에서 ProgressBar 를 하나 추가한다. 기존의 체력 표시를 위한 ProgressBar 보다 뒤에 위치하도록 한다.

전체를 채우도록 설정을 변경하고

Fill Color And Opacity 에서 Saturation 을 최대로 낮춘다.

Graph 탭에서 노드로 사용할 수 있도록 IsVariable 을 활성화시켜주고

동일하게 ProgressBar_Ghost -> Set Style 노드 생성후 Style -> Make ProgressBarStyle -> Make SlateBrush -> TintSaturation 과 알파값을 수정한다.

Fill ImageGhost Fill Brush 라는 변수로 승격시키고 카테고리도 변경한다.

GhostBarFillBrushTint 를 변경시키고

마지막으로 전체 노드를 UpdateGhostFillBrush 함수로 리팩토링한다.

컴파일 후 실행하면 정상적으로 GhostBar 가 나타나는 것을 확인할 수 있다.

추가1
ProgressBar 퍼센트를 설정하는 함수 추가로 노드 정리
우선 WBP_EnemyHealthBar 에서 SetBarPercent 함수를 생성하고

파라미터로 두 값을 받도록 설정한다.

두 값을 받아 나누어 Set Percent 노드에 전달해 퍼센트 값을 얻도록 노드를 구성한다.

WBP_EnemyHealthBar 에서 생성한 함수를 호출하고 값을 전달하도록 노드를 수정한다.

컴파일 후 실행하여 정상적으로 동작하는지 확인한다.

추가2
GhostBar 퍼센트 최대로 설정

이제 GhostBar 가 체력 감소보다 한타임 늦게 간섭하도록 구현해야 한다.
먼저 GhostPercentTarget 변수를 생성하여 해당 값을 목표로 감소할 수 있도록 한다.

FInterp To 노드를 생성하고 GhostBar 의 현재 퍼센트가 TargetGhostPercentTarget 을 따라 감소하도록 연결한 다음 Event Tick 노드의 DeltaTime 을 연결한다.

ProgressBarGhost 의 퍼센트를 변경하기 위한 Set Percent 노드를 생성하고 Event Tick 노드와 FIntert To 노드의 Return Value 를 연결시켜준다.

마지막으로 전체 노드를 InterpGhostBar 함수로 리팩토링한다.

WBP_EnemyHealthBarEvent Tick 을 우클릭하고 Add Call to Parent Function 노드를 생성해 연결시켜준다.

WBP_ProgressBar 로 돌아와 UpdateGhostInterpTarget 커스텀 이벤트를 생성하고

Input 으로 float 타입의 Target 을 받도록 추가한다.

SetBarPercent 함수에서 ProgressBar_FrontSet Percent 이후 ProgressBarGhostSet Percent 를 호출하도록 한다.

UpdateGhostTarget 이벤트를 통해 GhostPercentTarget 을 1초의 딜레이를 주고 설정하도록 노드를 구성한다.

실행하면 1초의 간격을 두고 ProgressBarGhost 가 1.0의 속도로 체력바를 따라 줄어드는 것을 확인할 수 있다.

추가
InterpGhostBar 함수의 InterpSpeed : 5.0 으로 변경

추가
ProgressBar_Front의 Width : 90Height : 8 로 수치 변경

8.8.3 피격 전까지 체력바 숨기기

해당 기능은 값이 변경되기전까지 체력바를 숨기도록 하고, 값 변경시 시간이나 딜레이를 주어 다시 체력바를 숨기도록 하면 구현할 수 있다.
먼저 UpdateGhostInterpTarget 노드에서 Set Timer by Event 노드를 생성하여 연결시키고, HideProgressBar 커스텀 이벤트를 생성하여 연결한다.

Return ValueHide Timer 라는 변수로 승격시켜 노드를 연결하고

타이머가 끝나기 전에 다시 공격받는 경우를 대비해 Clear and Invalidate Timer by Handle 노드를 추가하여 연결한다.

타이머의 시간을 6초로 지정해주고

6초마다 HideProgressBar 이벤트가 발생하여 Set Visibility 노드를 통해 In Visibility : Hidden 로 설정하여 숨기도록 한다.
( 빠른 테스트를 위해서 Set Timer by Event 노드의 Time : 2 로 설정, 확인후 다시 6초로 수정)
(게임 플레이시 n초뒤에 체력바가 사라지는게 노드 구성상 정상, 시작하자자 숨기는건 마지막에 있음)

그리고 수치 변경에 의한 퍼센트 변경으로 인해 ProgressBar를 수정하는 SetBarPercent 함수에서 다시 위젯을 노출시킬 수 있도록 Set Visiblity 노드를 추가하여 In Visibility : Visible 로 설정한다.

컴파일 후 실행하면 적이 피격시 체력바를 노출하고, 계속 때리면 타이머의 초기화에 의해 계속 노출되며, 6초 이후 다시 체력바를 숨기는 것을 확인할 수 있다.
노드 정리를 위해 SetBarVisibility 함수를 생성하고, bool 타입 VisibleInput 으로 받도록 한다.

bool 타입 ProgressBarVisible 을 생성하고

Input 으로 받은 값을 ProgressBarVisible 에 할당한 다음, 해당 값에 따라 Visible 또는 Hidden 으로 변경하도록 노드를 구성한다.

SetBarPercent 함수에서 Set Visibility 노드를 SetBarVisibility 함수로 교체하고, true 를 전달하도록 한다.

동일하게 Event Graph 에서도 Set Visibility 노드를 SetBarvisibility 함수로 교체하고, false 를 전달하도록 한다.

그리고 Event Tick 노드에서 ProgressBar 의 상태가 Visible 인 경우에만 Interp 를 진행하도록 노드를 수정한다.

마지막으로 시작시에 체력바가 보이는 것을 수정하기 위해 Event Construct 노드를 생성하고 SetBarVisibility 노드를 생성하여 false 상태로 연결한다.

컴파일 후 실행하면 정상적으로 동작하는 것을 확인할 수 있다.

0개의 댓글