12.08 - TIL

김혁·2025년 12월 8일

TIL

목록 보기
72/84

오늘의 코드카타

  • 퍼즐 조각 채우기
    • 빈칸과 퍼즐이 있을 때, 정확하게 빈칸에 맞게 퍼즐을 넣을 수 있는지 확인하기
    • 정규화를 통해 빈칸 또는 퍼즐의 좌표를 (0,0)을 기준으로 하기
      -> 최소값을 찾아서 좌표별로 감소시키기
    • 퍼즐을 검사할 때, 90도씩 돌려가면서 검사하기
      -> (x, y) -> (y, -x)
    • https://school.programmers.co.kr/learn/courses/30/lessons/84021

팀 프로젝트 진행 내용

1. 캐릭터 사망 구현

사망 로직 설계

  1. Attribute Set에서 체력이 0이 되면 사망을 알려주는 델리게이트를 Broadcast해준다.
  2. 캐릭터에서 해당 델리게이트를 구독하고 사망을 하게 되면 사망 처리 함수를 실행시켜준다.
  3. PlayerState에서 생존 여부를 관리하고 있는데, 이를 수정해준다.
  4. PlayerController에 ClientRPC를 통해 사망 UI를 보여주게 한다.
  5. 사망 Ability를 구현하여 사망 몽타주 재생 및 캐릭터의 사망에 필요한 처리를 해준다.

(1) Attribute Set에서 사망 델리게이트 선언

  • 먼저 헤더에서 델리게이트를 선언했다.
  • 이를 PostGameplayEffectExecute(...)에서 체력을 변경했을 때, 0보다 작거나 같은 경우 서버에서만 사망 델리게이트를 Broadcast하게 했다.
// AO_PlayerCharacter_AttributeSet.h

DECLARE_MULTICAST_DELEGATE(FOnPlayerDeath);

UCLASS()
class AO_API UAO_PlayerCharacter_AttributeSet : public UAttributeSet
{
	...
    
public:
	FOnPlayerDeath OnPlayerDeath;
};


// AO_PlayerCharacter_AttributeSet.cpp

void UAO_PlayerCharacter_AttributeSet::PostGameplayEffectExecute(const struct FGameplayEffectModCallbackData& Data)
{
	Super::PostGameplayEffectExecute(Data);

	if (Data.EvaluatedData.Attribute == GetHealthAttribute())
	{
		const float NewHealth = GetHealth();
		
		if (NewHealth <= 0.f)
		{
			if (AActor* Owner = GetOwningActor())
			{
				if (Owner->HasAuthority())
				{
					OnPlayerDeath.Broadcast();
				}
			}
		}
		
		SetHealth(FMath::Clamp(NewHealth, 0.f, GetMaxHealth()));
	}
    ...
}

(2) 플레이어 캐릭터에서 사망 처리 함수 구현

  • BeginPlay() 과정에서 BindAttributeDelegates() 함수를 통해 위에서 선언한 델리게이트와 바인딩을 해주었다.
  • 사망 처리 함수는 PlayerState를 받아와서 생존 상태 변수를 변경하고, 태그를 통해 사망 Ability를 실행시켜주고, PlayerController에게 사망 UI를 표시해달라고 하는 기능을 가지고 있다.
  • 추가적으로 클라이언트에서 사망 시에 화면이 줌아웃되게끔 하고 싶어서 SpringArm의 TargetArmLength도 증가시켰다.
// AO_PlayerCharacter.cpp

void AAO_PlayerCharacter::BindAttributeDelegates()
{
	if (!AttributeSet)
	{
		return;
	}

	AttributeSet->OnPlayerDeath.AddUObject(this, &AAO_PlayerCharacter::HandlePlayerDeath);
}

void AAO_PlayerCharacter::HandlePlayerDeath()
{
	if (!HasAuthority())
	{
		return;
	}

	// PlayerState에서 생존 상태 변경
	AAO_PlayerState* PS = GetPlayerState<AAO_PlayerState>();
	checkf(PS, TEXT("PlayerState is null"));

	if (!PS->GetIsAlive())
	{
		return;
	}

	PS->SetIsAlive(false);

	// Death Ability 실행
	FGameplayTagContainer DeathTag(FGameplayTag::RequestGameplayTag(FName("Ability.State.Death")));
	AbilitySystemComponent->TryActivateAbilitiesByTag(DeathTag);

	// Death UI 출력
	if (Cast<APlayerController>(GetController()))
	{
		ClientRPC_HandleDeathView();
	}
}

void AAO_PlayerCharacter::ClientRPC_HandleDeathView_Implementation()
{
	if (SpringArm)
	{
		SpringArm->TargetArmLength += DeathCameraArmOffset;
	}

	if (TObjectPtr<AAO_PlayerController_Stage> PC = Cast<AAO_PlayerController_Stage>(GetController()))
	{
		PC->ShowDeathUI();
	}
}

(3) PlayerState 생존 여부 수정

  • 기존에 추가되어있던 bIsAlive 변수를 수정하고, 이를 받아오는 Getter, Setter 함수를 추가했다.
  • 변수는 protected로 해서 캡슐화를 신경쓰고자 했으나, 다른 분이 이미 사용하고 있는 코드여서 접근 지정자를 변경하지는 않았다.
// AO_PlayerState.h

UCLASS()
class AO_API AAO_PlayerState : public APlayerState
{
	...

public:
	UPROPERTY(ReplicatedUsing=OnRep_IsAlive)
	bool bIsAlive = true;

	FORCEINLINE bool GetIsAlive() const { return bIsAlive; };
	void SetIsAlive(bool bInIsAlive);
};

(4) PlayerController 사망 UI 출력

  • 사망 Widget 클래스를 받아서 화면에 출력해주는 함수를 PlayerController에 추가했다.
  • 사망 Widget의 경우에는 간단하게 화면에 관전하기 버튼만 나오게끔 구현했다.
// AO_PlayerController_Stage.h

UCLASS()
class AO_API AAO_PlayerController_Stage : public AAO_PlayerController_InGameBase
{
	...
    
protected:
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "AO|UI")
	TSubclassOf<UUserWidget> DeathWidgetClass;

	UPROPERTY()
	TObjectPtr<UUserWidget> DeathWidget;

public:
	UFUNCTION(BlueprintCallable, Category = "AO|UI")
	void ShowDeathUI();
}

// AO_PlayerController_Stage.cpp

void AAO_PlayerController_Stage::ShowDeathUI()
{
	if (!IsLocalController())
	{
		return;
	}

	if (!DeathWidget && DeathWidgetClass)
	{
		DeathWidget = CreateWidget<UUserWidget>(this, DeathWidgetClass);
	}
	
	if (DeathWidget)
	{
		DeathWidget->AddToViewport();
	}

	FInputModeUIOnly InputMode;
	InputMode.SetWidgetToFocus(DeathWidget->TakeWidget());
	SetInputMode(InputMode);
	bShowMouseCursor = true;
}

(5) 사망 Ability 구현

  • 사망 몽타주를 재생하고, 캐릭터의 이동 제한, 콜리전 제거 등의 작업을 해주는 Ability를 구현했다.
  • 사망 Ability를 하는 도중에 다른 Ability가 동작하면 되지 않기 때문에, 피격 리액션에서 사용한 GE_BlockAbilities를 재활용하고자 했다.
  • Montage 종료와 취소되는 로직은 사용되지 않지만, 일단 바인딩해두었고, 추후에 필요하지 않으면 삭제할 예정이다.
// AO_GameplayAbility_Death.h

UCLASS()
class AO_API UAO_GameplayAbility_Death : public UGameplayAbility
{
	GENERATED_BODY()

public:
	UAO_GameplayAbility_Death();

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

protected:
	UPROPERTY(EditDefaultsOnly, Category = "Death")
	TObjectPtr<UAnimMontage> DeathMontage;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "HitReact")
	TSubclassOf<UGameplayEffect> BlockAbilitiesEffectClass;

	UFUNCTION()
	void OnMontageCompleted();

	UFUNCTION()
	void OnMontageCancelled();
};
  • 사망 로직만 작성하면 NetExecutionPolicyServerOnly로 해서 트래픽을 줄일 수 있지만, 몽타주를 재생하게 되면 클라에서 리플리케이션 안 되기 때문에 ServerInitiated로 지정을 했다.
  • 해당 Ability의 태그를 "Ability.State.Death"로 지정했고, 해당 Ability가 실행되면 액터가 죽은 상태이기 때문에 "Status.Death" 태그를 붙여서 사망 상태를 확인하고자 했다.
UAO_GameplayAbility_Death::UAO_GameplayAbility_Death()
{
	InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
	NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::ServerInitiated;

	AbilityTags.AddTag(FGameplayTag::RequestGameplayTag(FName("Ability.State.Death")));

	ActivationOwnedTags.AddTag(FGameplayTag::RequestGameplayTag(FName("Status.Death")));
}
  • 공격을 당하면 피격 리액션 어빌리티가 실행되는데, 해당 어빌리티가 우선되서 사망 Ability가 정상적으로 동작을 안 해서 해당 어빌리티를 취소해줬다.
  • 이후에 캐릭터의 이동을 못하게끔 하고, 콜리전을 못 하게 변경했다. 추가적으로 다른 Ability를 실행시키지 못하게끔 GE_BlockAbilities도 실행시켰다.
  • 마지막으로 사망 몽타주를 재생시켰다. 사망 몽타주는 공격받아서 눕는 모션과 누워있는 모션을 루프시켜서 계속 누워있게끔 하고자 했다.
void UAO_GameplayAbility_Death::ActivateAbility(const FGameplayAbilitySpecHandle Handle,
	const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo,
	const FGameplayEventData* TriggerEventData)
{
	if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
	{
		EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
		return;
	}

	if (HasAuthority(&ActivationInfo))
	{
		// 피격 리액션 Ability 취소
		if (TObjectPtr<UAbilitySystemComponent> ASC = ActorInfo->AbilitySystemComponent.Get())
		{
			FGameplayTagContainer HitReactTags;
			HitReactTags.AddTag(FGameplayTag::RequestGameplayTag(FName("Ability.State.HitReact")));

			ASC->CancelAbilities(&HitReactTags);
		}

		TObjectPtr<ACharacter> Character = Cast<ACharacter>(ActorInfo->AvatarActor.Get());
		checkf(Character, TEXT("Failed to cast AvatarActor to ACharacter"));

		Character->GetCharacterMovement()->DisableMovement();
		Character->GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
		
		if (BlockAbilitiesEffectClass)
		{
			FGameplayEffectSpecHandle SpecHandle = MakeOutgoingGameplayEffectSpec(Handle, ActorInfo, ActivationInfo, BlockAbilitiesEffectClass, 1.f);
			FActiveGameplayEffectHandle BlockAbilitiesEffectHandle
				= ApplyGameplayEffectSpecToOwner(Handle, ActorInfo, ActivationInfo, SpecHandle);
		}
	}
	
	checkf(DeathMontage, TEXT("DeathMontage is null"));

	TObjectPtr<UAbilityTask_PlayMontageAndWait> MontageTask
		= UAbilityTask_PlayMontageAndWait::CreatePlayMontageAndWaitProxy(this, NAME_None, DeathMontage);

	checkf(MontageTask, TEXT("Failed to create MontageTask"));

	MontageTask->OnCompleted.AddDynamic(this, &UAO_GameplayAbility_Death::OnMontageCompleted);
	MontageTask->OnBlendOut.AddDynamic(this, &UAO_GameplayAbility_Death::OnMontageCompleted);
	MontageTask->OnCancelled.AddDynamic(this, &UAO_GameplayAbility_Death::OnMontageCancelled);
	MontageTask->OnInterrupted.AddDynamic(this, &UAO_GameplayAbility_Death::OnMontageCancelled);

	MontageTask->ReadyForActivation();
}
  • 사망 몽타주는 몽타주 섹션 기능을 활용해서 누워있는 섹션을 루프시키는 구조로 작성을 했다.


구현 결과



2. 캐릭터 사망 후 관전 기능 초기 구현

  • 플레이어가 사망한 이후에 다른 플레이어를 관전할 수 있도록 기능을 구현하고자 한다.
  • SetViewTargetWithBlend()를 활용해서 다른 플레이어를 보여주는 카메라로 이동하는 기능을 구현하고자 한다.
  • 해당 기능을 구현하기 위해서 PlayerController에서 아래와 같이 필요한 함수, 변수를 선언했다.
  • 관전하기 버튼을 누르면 RequestSpectate()가 실행되고, ServerRPC_RequestSpectate()를 통해 관전할 대상을 지정하고, ClientRPC_SetSpectateTarget()를 통해 클라가 지정한 Pawn을 관전하게 하고자 한다.
// AO_PlayerController_Stage.h

UCLASS()
class AO_API AAO_PlayerController_Stage : public AAO_PlayerController_InGameBase
{
	...
    
public:
	UFUNCTION(BlueprintCallable, Category = "AO|UI")
	void RequestSpectate();
    
protected:
    UFUNCTION(Server, Reliable)
	void ServerRPC_RequestSpectate();

	UFUNCTION(Client, Reliable)
	void ClientRPC_SetSpectateTarget(APawn* NewTarget);

	UPROPERTY()
	TObjectPtr<APawn> CurrentSpectateTarget;
}
  • 해당 함수들의 구현부는 아래와 같다.
  • RequestSpectate()를 Death UI의 관전하기 버튼과 연결하여 정상적으로 동작하는 것을 알 수 있었다.
// AO_PlayerController_Stage.cpp

void AAO_PlayerController_Stage::RequestSpectate()
{
	if (IsLocalController())
	{
		ServerRPC_RequestSpectate();
	}
}

void AAO_PlayerController_Stage::ServerRPC_RequestSpectate_Implementation()
{
	TObjectPtr<APawn> NewTarget = nullptr;

	TObjectPtr<UWorld> World = GetWorld();
	checkf(World, TEXT("World is null"));
	
	TObjectPtr<AGameStateBase> GS = GetWorld()->GetGameState<AGameStateBase>();
	checkf(GS, TEXT("GameState is null"));

	TObjectPtr<AAO_PlayerState> MyPS = GetPlayerState<AAO_PlayerState>();
	checkf(MyPS, TEXT("PlayerState is null"));

	// 현재 게임의 플레이어들을 전부 가져와서 검사하기
	const TArray<TObjectPtr<APlayerState>>& Players = GS->PlayerArray;
	const int32 NumPlayers = Players.Num();
	for (int32 i = 0; i < NumPlayers; ++i)
	{
		TObjectPtr<AAO_PlayerState> PS = Cast<AAO_PlayerState>(Players[i]);
        if (!PS || PS == MyPS) continue;	// 자기 자신은 제외
        if (!PS->GetIsAlive()) continue;    // 플레이어가 죽은 경우 제외

		TObjectPtr<APawn> OtherPawn = PS->GetPawn();
		if (!OtherPawn) continue;

		NewTarget = OtherPawn;
		break;
	}

	if (NewTarget)
	{
		ClientRPC_SetSpectateTarget(NewTarget);
	}
}

void AAO_PlayerController_Stage::ClientRPC_SetSpectateTarget_Implementation(APawn* NewTarget)
{
	if (!IsLocalController() || !NewTarget)
	{
		return;
	}

	CurrentSpectateTarget = NewTarget;

	const float BlendTime = 0.4f;
	SetViewTargetWithBlend(NewTarget, BlendTime);
}
  • 현재는 관전하는 대상을 변경하는 기능은 존재하지 않는다. 이는 내일 관전하기 UI를 제작하여 다른 플레이어를 관전할 수 있게 수정할 예정이다.

profile
게임 개발자를 향해..

0개의 댓글