TIL

2025.02.13(목)

iiblueblue 2025. 2. 13. 23:44

Quest

  • [8번 과제] 게임 루프 및 UI 재설계하기 ◀

HUD&메뉴 UI 리뉴얼

  • 인게임 - 점수, 시간, 체력을 전부 한 화면에서 볼 수 있도록 배치
  • 점수, 체력을 잘 배치하고 시간을 Process Bar로 다시 구현하여 배치

플레이어에게 시간을 보여주지 않고 Progress Bar로 표현해보기로 하였다.

우선 최종 결과물은 이렇다. 레벨을 왼쪽에 배치하고 해당 레벨의 남은 시간이 이 정도라는 것을 보여주기 위해 바로 옆에 타이머 바를 배치하였다. 각각 1/3 지점마다 이미지와 텍스트로 wave를 표시해준다. 점수는 레벨 아래에 배치하였다.

 

생각보다 Progress Bar를 구현하는 건 어렵지는 않았는데 이상한 짓을 하면서 헤매서 조금 오래 걸렸다.

// Time
if (UProgressBar* TimeSlider = Cast<UProgressBar>(HUDWidget->GetWidgetFromName(TEXT("TimeBar"))))
{
    float RemainingTime = GetWorldTimerManager().GetTimerRemaining(LevelTimerHandle);
    TimeSlider->SetPercent(RemainingTime/LevelDuration);
}

UProgressBar와 Slider를 착각하여 Slider를 불러오고 불러와지지 않아 한참을 원인을 찾았다. Slider를 불러오는 부분을 if문의 조건문에서 빼서 위에다 선언하고 그 아래 null이라면 로그가 찍히도록 설정하여 TimeSlider가 null임을 발견하였다.

처음 사용해보는 클래스라 좀 헤맸지만 덕분에 절대 잊지 않게 되었다.

 

아이템 상호작용 로직 고도화

  • SlowingItem: 플레이어가 일정 시간 동안 이동 속도 50% 감소
  • ReverseControlItem : 일정 시간 동안 W키가 뒤로, A키가 오른쪽 등 컨트롤 반전
  • 아이템 효과 중첩 가능
  • 효과의 상황을 UI를 통해 표시하여 플레이어가 언제 디버프가 풀리는지 인지할 수 있도록 함.

디버프 기능을 디버프 아이템에 구현해도 되는가?

디버프 기능을 아이템에 구경했을 때 문제가 있다. 아이템이 사라지면 같이 사라져버린다. 그래서 디버프 기능을 모두 ASpartaCharacter에 구현하였다. 결국 캐릭터 클래스에 정착한 이유는 명확했다. 플레이어 속도나 이동 방향 모두 캐릭터가 관리하고 있기 때문이다. 캐릭터를 느리게 한다거나 이동 방향을 반대로 하는 것은 모두 캐릭터에서 할 수 있는 것들이다.

void ASpartaCharacter::StartSlowDebuff(float DebuffDuration)
{
	// 이미 Slow 상태라면
	if (bIsSlowState)
	{
		// 타이머 초기화 후 진행
		GetWorld()->GetTimerManager().ClearTimer(SlowDebuffTimerHandle);

	}

	bIsSlowState = true;
	GetCharacterMovement()->MaxWalkSpeed = SlowSpeed;

	// 타이머 실행
	GetWorld()->GetTimerManager().SetTimer(
		SlowDebuffTimerHandle,
		this,
		&ASpartaCharacter::EndSlowDebuff,
		DebuffDuration,
		false
	);

	// UI 표시

}

void ASpartaCharacter::StartReverseDebuff(float DebuffDuration)
{
	if (bIsReverseState)
	{
		GetWorld()->GetTimerManager().ClearTimer(ReverseDebuffTimerHandle);
	}

	bIsReverseState = true;
	ReverseDirection = -1.0f;

	GetWorld()->GetTimerManager().SetTimer(
		ReverseDebuffTimerHandle,
		this,
		&ASpartaCharacter::EndReverseDebuff,
		DebuffDuration,
		false
	);
}

void ASpartaCharacter::EndSlowDebuff()
{
	bIsSlowState = false;
	GetCharacterMovement()->MaxWalkSpeed = NormalSpeed;
	GetWorld()->GetTimerManager().ClearTimer(SlowDebuffTimerHandle);
}

void ASpartaCharacter::EndReverseDebuff()
{
	bIsReverseState = false;
	ReverseDirection = 1.0f;
	GetWorld()->GetTimerManager().ClearTimer(ReverseDebuffTimerHandle);
}

의미적으로 괜찮은지 계속 생각해보았다. 아이템 자체에서 기능을 처리하지 않는것이 좀 마음에 걸렸지만 그래도 캐릭터에 구현하는 것이 맞다고 생각한다.

 

디버프 아이템을 따로 만들었는데 그럴 필요가 있는가?

DebuffItem을 부모 클래스로 만들고 그 아래로 두 개의 디버프 아이템 클래스를 만들었다. 디버프 아이템 만의 공통점들이 많을 것 같아서였다. 아래는 이 구조를 만들면서 디버프 아이템들 사이에 비슷하다고 생각했던 것들이다.

  • UI에 상태이상을 띄우는 것
  • 일정 시간 이후 효과가 해제 되는 것(근데 이건 플레이어가 하고 있음)
  • 디버프 유지 시간을 갖는 것(DebuffDuration)

이걸 생각해서 SlowDebuffItem, ReverseDebuffItem을 설계할 때 이런 모양으로 하였었다. 아래는 SlowDebuffItem의 헤더 파일이다. 원래는 이 코드에 UI를 부르는 함수도 추가되어 있었다.

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "DebuffItem.h"
#include "SlowDebuffItem.generated.h"

/**
 * 
 */
UCLASS()
class CH3_LEARNINGPROJECT_API ASlowDebuffItem : public ADebuffItem
{
	GENERATED_BODY()
public:
	ASlowDebuffItem();

	FTimerHandle SlowDebuffTimerHandle;

	virtual void ActivateItem(AActor* Activator) override;
	void StartDebuff(AActor* Activator) override;
	void OnDebuffTimeUp() override;
};

 

하지만 실제로 구현하다보니 예상과는 완전히 다른 방향으로 구현하게 되었다. 결국 디버프 아이템 클래스 자체에서 별로 득을 본 것은 없었다. 위 코드에서 타이머도 사용하지 않았으며 OnDebuffTimeUp도 사용하지 않았다. 모든 타이머와 관련된 기능들이 캐릭터 클래스에서 이루어지기 때문애다.

 

시간이 없어서 수정하지 못했지만 DebuffItem을 따로 만들 필요는 없었던 것 같기도 하다.

 

디버프를 표시할 UI는 어떻게 띄울 것인가?

디버프의 실질적인 기능은 캐릭터 클래스에서 하고 있지만 UI는 어디서 관리할지 또 다른 문제가 생겼다.

  • 디버프의 남은 시간을 알 수 있는 타이머 핸들을 가지고 있는 곳 = 캐릭터
  • UI를 변경하는 것은 플레이어 컨트롤러에서
  • 현재 UI instance가지고 있는 곳 = 플레이어 컨트롤러
  • 심지어 디버프의 지속 시간을 표시할 타이머는 디버프 종류마다 따로 써야함

결론적으로 이렇게 하기로 하였다.

UI를 편집하는 것은 PlayerController가 하고 그 UI를 편집하는 함수를 불러오는 것은 Character이다. 그리고 Character은 함수를 호출할 때 타이머로 남은 시간을 구해 매개변수로 넘겨 주어야 한다.

void ASpartaPlayerController::AddDebuffWidget(FName DebuffType, float DebuffDuration)
{
	if (DebuffType == "ReverseDebuff")
	{
		ReverseDebuffWidgetInstance = CreateWidget<UUserWidget>(this, DebuffWidgetClass);
		if (ReverseDebuffWidgetInstance)
		{
			UpdateDebuffTime("ReverseDebuff", DebuffDuration);
			if (UImage* DebuffWidgetImage = Cast<UImage>(ReverseDebuffWidgetInstance->GetWidgetFromName(TEXT("DebuffImage"))))
			{
				DebuffWidgetImage->SetColorAndOpacity(FLinearColor(1.0f, 0.0f, 0.0f, 1.0f));
			}

			if (UHorizontalBox* HorizontalBox = Cast<UHorizontalBox>(HUDWidgetInstance->GetWidgetFromName(TEXT("DebuffBox"))))
			{
				HorizontalBox->AddChild(ReverseDebuffWidgetInstance);
			}
		}
			
	}
	else if (DebuffType == "SlowDebuff")
	{
		SlowDebuffWidgetInstance = CreateWidget<UUserWidget>(this, DebuffWidgetClass);
		if (SlowDebuffWidgetInstance)
		{
			UpdateDebuffTime("SlowDebuff", DebuffDuration);
			if (UImage* DebuffWidgetImage = Cast<UImage>(SlowDebuffWidgetInstance->GetWidgetFromName(TEXT("DebuffImage"))))
			{
				DebuffWidgetImage->SetColorAndOpacity(FLinearColor(0.0f, 0.0f, 1.0f, 1.0f));
			}

			if (UHorizontalBox* HorizontalBox = Cast<UHorizontalBox>(HUDWidgetInstance->GetWidgetFromName(TEXT("DebuffBox"))))
			{
				HorizontalBox->AddChild(SlowDebuffWidgetInstance);
			}
		}
	}
}

void ASpartaPlayerController::RemoveDebuffWidget(FName DebuffType)
{
	if (DebuffType == "SlowDebuff")
	{
		if (UHorizontalBox* HorizontalBox = Cast<UHorizontalBox>(HUDWidgetInstance->GetWidgetFromName(TEXT("DebuffBox"))))
		{
			HorizontalBox->RemoveChild(SlowDebuffWidgetInstance);
		}
	}
	else if (DebuffType == "ReverseDebuff")
	{
		if (UHorizontalBox* HorizontalBox = Cast<UHorizontalBox>(HUDWidgetInstance->GetWidgetFromName(TEXT("DebuffBox"))))
		{
			HorizontalBox->RemoveChild(ReverseDebuffWidgetInstance);
		}
	}
}

void ASpartaPlayerController::UpdateDebuffTime(FName DebuffName, float RemainingTime)
{
	if (DebuffName == "SlowDebuff")
	{
		if (SlowDebuffWidgetInstance)
		{
			if (UTextBlock* DebuffWidgetTimer = Cast<UTextBlock>(SlowDebuffWidgetInstance->GetWidgetFromName(TEXT("DebuffTimer"))))
			{
				DebuffWidgetTimer->SetText(FText::FromString(
					FString::Printf(TEXT("%d"), FMath::RoundToInt(RemainingTime))));
			}
		}
	}
	else if (DebuffName == "ReverseDebuff")
	{
		if (ReverseDebuffWidgetInstance)
		{
			if (UTextBlock* DebuffWidgetTimer = Cast<UTextBlock>(ReverseDebuffWidgetInstance->GetWidgetFromName(TEXT("DebuffTimer"))))
			{
				DebuffWidgetTimer->SetText(FText::FromString(
					FString::Printf(TEXT("%d"), FMath::RoundToInt(RemainingTime))));
			}
		}
	}
}
void ASpartaCharacter::UpdateSlowDebuffTime()
{
	float RemainingTime = GetWorld()->GetTimerManager().GetTimerRemaining(SlowDebuffTimerHandle);
	
	if (ASpartaPlayerController* SpartaPlayerController = Cast<ASpartaPlayerController>(GetController()))
	{
		SpartaPlayerController->UpdateDebuffTime("SlowDebuff", RemainingTime);
	}
}

void ASpartaCharacter::UpdateReverseDebuffTime()
{
	float RemainingTime = GetWorld()->GetTimerManager().GetTimerRemaining(ReverseDebuffTimerHandle);

	if (ASpartaPlayerController* SpartaPlayerController = Cast<ASpartaPlayerController>(GetController()))
	{
		SpartaPlayerController->UpdateDebuffTime("ReverseDebuff", RemainingTime);
	}
}

void ASpartaCharacter::StartSlowDebuff(float DebuffDuration)
{
	// 이미 Slow 상태라면
	if (bIsSlowState)
	{
		// 타이머 초기화 후 진행
		GetWorld()->GetTimerManager().ClearTimer(SlowDebuffTimerHandle);
		GetWorld()->GetTimerManager().ClearTimer(SlowDebuffUpdateTimerHandle);
		if (ASpartaPlayerController* SpartaPlayerController = Cast<ASpartaPlayerController>(GetController()))
		{
			SpartaPlayerController->UpdateDebuffTime("SlowDebuff", DebuffDuration);
		}
	}
	else
	{
		// UI 표시
		if (ASpartaPlayerController* SpartaPlayerController = Cast<ASpartaPlayerController>(GetController()))
		{
			SpartaPlayerController->AddDebuffWidget("SlowDebuff", DebuffDuration);
		}
	}

	// Slow 효과
	bIsSlowState = true;
	GetCharacterMovement()->MaxWalkSpeed = SlowSpeed;

	// 타이머 실행
	GetWorld()->GetTimerManager().SetTimer(
		SlowDebuffTimerHandle,
		this,
		&ASpartaCharacter::EndSlowDebuff,
		DebuffDuration,
		false
	);

	GetWorld()->GetTimerManager().SetTimer(
		SlowDebuffUpdateTimerHandle,
		this,
		&ASpartaCharacter::UpdateSlowDebuffTime,
		1.0f,
		true
	);
}

void ASpartaCharacter::StartReverseDebuff(float DebuffDuration)
{
	if (bIsReverseState)
	{
		GetWorld()->GetTimerManager().ClearTimer(ReverseDebuffTimerHandle);
		GetWorld()->GetTimerManager().ClearTimer(ReverseDebuffUpdateTimerHandle);
		if (ASpartaPlayerController* SpartaPlayerController = Cast<ASpartaPlayerController>(GetController()))
		{
			SpartaPlayerController->UpdateDebuffTime("ReverseDebuff", DebuffDuration);
		}
	}
	else
	{
		if (ASpartaPlayerController* SpartaPlayerController = Cast<ASpartaPlayerController>(GetController()))
		{
			SpartaPlayerController->AddDebuffWidget("ReverseDebuff", DebuffDuration);
		}
	}

	bIsReverseState = true;
	ReverseDirection = -1.0f;

	GetWorld()->GetTimerManager().SetTimer(
		ReverseDebuffTimerHandle,
		this,
		&ASpartaCharacter::EndReverseDebuff,
		DebuffDuration,
		false
	);

	GetWorld()->GetTimerManager().SetTimer(
		ReverseDebuffUpdateTimerHandle,
		this,
		&ASpartaCharacter::UpdateReverseDebuffTime,
		1.0f,
		true
	);
}

void ASpartaCharacter::EndSlowDebuff()
{
	bIsSlowState = false;
	GetCharacterMovement()->MaxWalkSpeed = NormalSpeed;
	if (ASpartaPlayerController* SpartaPlayerController = Cast<ASpartaPlayerController>(GetController()))
	{
		SpartaPlayerController->RemoveDebuffWidget("SlowDebuff");
	}
	GetWorld()->GetTimerManager().ClearTimer(SlowDebuffTimerHandle);
	GetWorld()->GetTimerManager().ClearTimer(SlowDebuffUpdateTimerHandle);
}

void ASpartaCharacter::EndReverseDebuff()
{
	bIsReverseState = false;
	ReverseDirection = 1.0f;
	if (ASpartaPlayerController* SpartaPlayerController = Cast<ASpartaPlayerController>(GetController()))
	{
		SpartaPlayerController->RemoveDebuffWidget("ReverseDebuff");
	}
	GetWorld()->GetTimerManager().ClearTimer(ReverseDebuffTimerHandle);
	GetWorld()->GetTimerManager().ClearTimer(ReverseDebuffUpdateTimerHandle);
}

 

디버프 UI를 어떻게 배치할 것인가?

디버프 UI는 디버프 모양 이미지 위에 지속시간을 적어서 표현하고 싶었다. 그 와중에 문제가 여러개 발생하였다.

어떤 디버프 아이템을 먼저 먹는지는 상관없이 먼저 먹은 디버프 아이템의 효과을 왼쪽 정렬하여 먼저 보이게 하고 싶었다. 그러기 위해서는 단순히 이미지를 넣고 껐다켰다 하는 것은 의미가 없었다. 그래서 Horizontal Box라는 것을 사용해보았다.

 

이제 또 새로운 문제를 만나게 되었다. 그래서 이 Horizontal Box 안에 들어가는 위젯은 어떻게 만드는가였다. Hud안에 미리 이미지와 텍스트를 만들어 놓고 껐다켰다 할는 것은 내가 원하는 연출이 아니었다. 내가 해야하는 건 게임 중에 동적으로 위젯을 생성하고 삭제하는 것이었다. 그러기 위해 위젯을 유니티의 프리팹처럼 만들어서 생성해야 했다.

방법에 대해서 오래 찾아본 것치고 생각보다 별거 아닌 방법이었다.

단 HUD와 다른 완전히 다른 UI 블루프린트를 만든다. Horizontal Box에 들어갈 UI를 이미지와 텍스트를 조합하여 만들었다. 이 과정에서 처음 알게된 것인데 Image UI의 자식으로 Text UI가 들어갈 수는 없다. Overlay를 이용하면 된다고 하여 해보았지만 그러면 크기가 조절되지 않는 문제가 있어 그냥 Canvas Panal 위에 제작하였다.

디버프 아이콘은 포토샵에서 직접 만들어서 가져왔다. 하얀색으로 만들어서 가져오면 여기서 원하는 색으로 만들 수 있었다.

 

그러고 테스트로 Horizontal Box에 만들어 놓은 디버프 UI를 드래그하여 넣어보았다. 혹시나 될까 싶어서 해봤는데 Content 폴더에서 그냥 끌어다 놓아도 쏙 들어가는 것을 확인했다. 이제 코드로 UI를 집어 넣기만 하면 된다.

if (DebuffType == "ReverseDebuff")
{
    ReverseDebuffWidgetInstance = CreateWidget<UUserWidget>(this, DebuffWidgetClass);
    if (ReverseDebuffWidgetInstance)
    {
        UpdateDebuffTime("ReverseDebuff", DebuffDuration);
        if (UImage* DebuffWidgetImage = Cast<UImage>(ReverseDebuffWidgetInstance->GetWidgetFromName(TEXT("DebuffImage"))))
        {
            DebuffWidgetImage->SetColorAndOpacity(FLinearColor(1.0f, 0.0f, 0.0f, 1.0f));
        }

        if (UHorizontalBox* HorizontalBox = Cast<UHorizontalBox>(HUDWidgetInstance->GetWidgetFromName(TEXT("DebuffBox"))))
        {
            HorizontalBox->AddChild(ReverseDebuffWidgetInstance);
        }
    }

}

UI를 가져오는 것만 실습에서 해봤었는데 만드는 것은 이름부터 아주 직관적인 CreateWidget 함수를 사용한다. 그리고 Horizontal Box의 자손으로 넣는 것은 AddChild를 사용한다. 추가적으로 위젯의 색을 바꾸기 위해서는 SetColorAndOpacity 함수를 사용한다. FColor가 아닌 FLinearColor을 사용함에 주의해야 한다.

 

이렇게 원하는 색으로 디버프 아이콘을 만들어서 적용되는 순서대로 왼쪽 정렬하여 UI가 생성되도록 구현하였다.

 

 

Project

9시 이후 팀원들과 모여서 저번에 얘기했던 내용들을 바탕으로 구현해야할 기능들을 쭉 정리하고 그 우선순위를 결정하였다. 또한 기능 구현을 위한 글이나 에셋들을 검색하였다.

 

어떤 기능 구현을 맡아서 할지 고민을 해봐야할 것 같다.

'TIL' 카테고리의 다른 글

2025.02.20(목)  (0) 2025.02.20
2025.02.19(수)  (0) 2025.02.19
2025.02.11(화)  (0) 2025.02.11
2025.02.10(월)  (0) 2025.02.10
2025.02.07(금)  (0) 2025.02.07