no image
[ UE ] BehaviorTree Task 관리
저번 프로젝트에서 적 AI 구현을 담당했었다.그때는 AI를 처음 접하기도 하고, 어려웠어서 Task를 블루프린트로만 구현해서 관리했었다.이번에 C++로도 Task 관리를 할 수 있는 것을 배우게 되어 두 구현 기법의 장단점을 정리해보려고 한다. 블루프린트로 구현했을 때- 노드로 구성되어있어 실행 비용이 큼- Task 호출이 빈번하거나 반복 호출이 많으면 프레임 드랍 가능성 많음- 시각적이라 트리 흐름을 빠르게 파악 가능- Task 로직이 복잡하고 입력인자가 많으면 노드 내부 파악하기 쉽지않음- 디버깅하기 힘들고, 대형 프로젝트가 되면 담당자를 찾기 힘들고, 수정에 버거움- Git 머지 충돌 위험이 높음(BP 파일 바이너리 저장 때문)- 프로토타입으로 설계하기 매우 빠름 C++로 구현했을 때..
2025.04.28
no image
[ C++ ] 프로그래머스 비밀지도
문제 링크https://school.programmers.co.kr/learn/courses/30/lessons/17681 프로그래머스SW개발자를 위한 평가, 교육, 채용까지 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프programmers.co.kr 문제 나의 풀이1. 각 배열이 들어가있는 10진수를 2진수로 변환2 . 2진수로 변환된 배열의 값들을 순회하면서 비교, 두 배열 중 하나라도 1이 있다면 temp에 1로 저장3. temp 순회하면서 1이면 #, 0이면 공백을 answer에 추가+ 처음엔 배열에 들어가있는 수를 하나하나 이진수로 바꾸는 작업을 했었는데 하다보니 코드가 길어지기도 하고, 가독성이 너무 떨어졌다. 바꾸는 과정에서 함수를 만들고 이 함수는 vector를 리턴하..
2025.04.25
C++
[ UE ] Navigation Systems
(금요일까지 꼭 쓰겠습니다.. )
2025.04.24
no image
[ UE, C++ ] 네트워크 환경에서 클라이언트 카메라 전환시키기
데디케이트 서버 기반의 프로젝트를 진행하면서 인게임 로직을 담당하게 되었다.캐릭터가 사망하고 리스폰되었을 때를 처리하는 부분을 설계하다가 단순히 다른 컨트롤러에 빙의되는 것이 아니라 격투게임이니만큼 격투를 관전할 수 있는 관전뷰를 만들어 거기에 플레이어들을 빙의시켰다. 트러블슈팅하다가 서버에서 클라이언트의 플레이어컨트롤러를 관전 카메라에 배치하는건 잘 작동되었다.void ACharacterController::ClientSpectateCamera_Implementation(ACameraActor* SpectatorCam){ if (!SpectatorCam) { UE_LOG(LogTemp, Warning, TEXT("ClientSpectateCamera: SpectatorCam is null")); r..
2025.04.21
no image
[ UE ] TrainingMode 및 UI 구현
훈련장 모드에서 타이머를 구현하다가 발생한 트러블상황과 구조 대공사를 통해 알게된 내용을 정리해보려고 한다. 트러블 슈팅 트러블 상황RoundTime을 0으로 초기화해줬는데 갑자기 이상한 값이 뜸.. 해결방법기존 코드에서 Time이 0일때도 1초씩 감소하는 로직을 타서 발생했던 현상.Time이 아직 초기화되지않았을 때에는 1초씩 감소하지 않도록 방어 코드를 짜주어서 해결. 프로젝트 진행 중 다시 알게된 내용GameMode, GameState, Widget 간의 데이터 처리게임 프레임워크를 설계하고 구현하다보니 구조적인 설계에 대해 많이 고민하고 성장하게 되었다.GameMode, GameState, Widget 간의 데이터 처리에 부분에서 흐름이 어떻게 흘러가는지 이해하게되었다.나는 Game..
2025.04.08
no image
[ UE ] 주말 작업동안의 트러블 슈팅 정리
첫번째 트러블 상황BaseInGameHUD에서 정의한 GetTogglePauseWidget을 PlayerController에서 가져오기 위해 IsValid로 값체크를 하는 과정에서 발생.   이유IsValid()는 UObject* 타입을 요구하는데 GetTogglePauseWidget()은 const 포인터를 반환하고 있음. IsValid()는 주로 non-const 버전을 검사하게 구현되어있어서 생기는 문제.   해결방법 UFUNCTION(BlueprintCallable, Category = "CCFF|UI") FORCEINLINE UTogglePauseWidget* GetTogglePauseWidget() const { return TogglePauseWidget; }IsValid()를 쓰지않고 ≠n..
2025.04.07
no image
[ UE ] 위젯과 타이머 연동하기
위젯에 텍스트로 타이머 기능을 넣는 부분을 진행했다. BaseHUD, BaseWidget 설계위젯을 쓰려고하니 모드마다 공통으로 들어가는 위젯들이 있어서 Base 위젯을 만들면 좋겠다고 생각했다.베이스 HUD와 Widget의 구조를 잡는데 오늘 대부분의 시간을 썼었던 것 같다.HUD와 Widget의 역할을 잘 몰랐었는데 HUD는 UI를 담는 컨테이너 같은 역할이고 그 안에 실질적으로 유저가 보는 것들이 UI라고 이해했다. 그래서 일단 BaseHUD를 설계했고, 공통된 부분만 묶어서 정의해주었다.새로운 HUD가 추가될 때마다 이 BaseHUD를 상속받아서 적용할 수 있게 설계했고, 위젯도 비슷한 방식을 적용했다.근데 위젯은 어떤걸 묶을지 아직 감이 안잡히는 것 같다.. Base가 굳이 필요한가 싶기도하고...
2025.04.04
no image
[ UE ] 훈련장모드 베이스 설계
훈련장 모드를 만들기 위해 베이스 구조를 설계해보았따.가장 필요한 GameMode와 GameState를 설계했다. BaseInGameMode#pragma once#include "CoreMinimal.h"#include "GameFramework/GameMode.h"#include "BaseInGameMode.generated.h"UCLASS()class CCFF_API ABaseInGameMode : public AGameMode{ GENERATED_BODY()public: ABaseInGameMode(const FObjectInitializer& ObjectInitializer); virtual void BeginPlay() override; UFUNCTION(BlueprintCallable, Cat..
2025.04.03
no image
[ UE ] 데디케이트 서버 패키징을 해보자
오늘을 데디케이트 서버로 패키징을 하는 방법을 공부해보았다. 패키징 하는 과정먼저 똑같은 프로젝트를 2개를 만들어줘야한다. 하나는 작업용, 하나는 패키징용으로 관리할 것이다.(참고로 두 프로젝트의 경로는 다르게 설정해주는 것이 좋다. 패키징 작업 때 경로 때문에 꼬일 일이 생길 수도 있다고 한다.)그리고 작업용은 어떤 버전으로 해도 상관없으나 패키징용 프로젝트는 언리얼 소스 빌드로 해줘야 문제없이 패키징할 수 있는 환경을 만들 수 있다. 패키징용 프로젝트가 있는 폴더로 들어가서 Source 폴더로 들어간다. 프로젝트이름Editor.Target.cs 파일을 2번 복붙하고 Editor를 각각 Client, Server로 변경해주고 파일 안에서도 이름을 변경해준다. 언리얼 에디터에서 Tools에서 Projec..
2025.03.31

[ UE ] BehaviorTree Task 관리

닿메_dahme
|2025. 4. 28. 21:07

저번 프로젝트에서 적 AI 구현을 담당했었다.

그때는 AI를 처음 접하기도 하고, 어려웠어서 Task를 블루프린트로만 구현해서 관리했었다.

이번에 C++로도 Task 관리를 할 수 있는 것을 배우게 되어 두 구현 기법의 장단점을 정리해보려고 한다.

 

 

 

블루프린트로 구현했을 때

- 노드로 구성되어있어 실행 비용이 큼

- Task 호출이 빈번하거나 반복 호출이 많으면 프레임 드랍 가능성 많음

- 시각적이라 트리 흐름을 빠르게 파악 가능

- Task 로직이 복잡하고 입력인자가 많으면 노드 내부 파악하기 쉽지않음

- 디버깅하기 힘들고, 대형 프로젝트가 되면 담당자를 찾기 힘들고, 수정에 버거움

- Git 머지 충돌 위험이 높음(BP 파일 바이너리 저장 때문)

- 프로토타입으로 설계하기 매우 빠름

 

 

 

 

 

 

 

C++로 구현했을 때

- 블루프린트 대비 호출 오버헤드가 적음

- 대규모 프로젝트에서 관리하기 비교적 쉬움

- 로직이 명시적으로 관리되므로 디버깅이나 추적에 용이

- 코드를 분할하면 기능별로 Task를 관리할 수 있어 확장, 수정에 용이

- 초기 설계가 어렵고 복잡하지만 프로젝트 규모가 커질 수록 편하게 관리가능

- Task 실패 시 블랙보드 상태까지 추척 가능

 

 

 

 

 

 

주의사항

- Task는 비동기 작업하면 안 된다.
(Ex. 인터넷 호출, 긴 연산) → Task는 반드시 "짧고 확정적"으로 끝내야 한다.

- 성공/실패 조건을 명확히 분기하라.
ambiguous(모호)한 성공/실패 처리하면 BT 플로우가 꼬여서 AI가 멍때린다.

- Performance Critical한 부분만 먼저 C++화한다.
거리 계산, 콜리전 체크, Line Trace 같은 것부터 최적화 대상.

 

 

 

 

 

 

 

마무리 말

Task는 C++로 구현하는게 좋을 것 같다. 일단 확장성과 디버깅하기 너무 편리하다.

그리고 Decorator나 Service같은 부분은 블루프린트로 작성하면 빠르게 수정하는데 장점을 가져갈 수 있을 것 같다.

[ C++ ] 프로그래머스 비밀지도

닿메_dahme
|2025. 4. 25. 16:20

 

문제 링크

https://school.programmers.co.kr/learn/courses/30/lessons/17681

 

프로그래머스

SW개발자를 위한 평가, 교육, 채용까지 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프

programmers.co.kr

 

문제

 

나의 풀이

1. 각 배열이 들어가있는 10진수를 2진수로 변환

2 . 2진수로 변환된 배열의 값들을 순회하면서 비교, 두 배열 중 하나라도 1이 있다면 temp에 1로 저장

3. temp 순회하면서 1이면 #, 0이면 공백을 answer에 추가

+ 처음엔 배열에 들어가있는 수를 하나하나 이진수로 바꾸는 작업을 했었는데 하다보니 코드가 길어지기도 하고, 가독성이 너무 떨어졌다. 바꾸는 과정에서 함수를 만들고 이 함수는 vector<int>를 리턴하는데 이러면 2차원 벡터에 이진수들이 하나하나 들어가있어서 arr1과 arr2를 비교하기에 굉장히 비용이 많이들고, 뎁스도 깊어지는 설계를 하고있었다.

그러던 와중에 까맣게 잊고 있었던 비트 연산을 보게되었고, 기존 설계보다 훨씬 간결하고 가독성 좋게 문제를 풀 수 있었다.

 

최종 코드

#include <string>
#include <vector>
#include <iostream>
#include <algorithm>

using namespace std;

vector<string> solution(int n, vector<int> arr1, vector<int> arr2) {
    vector<string> answer;
    
    for (int i = 0; i < n; i++)
    {
        string temp = "";
        arr1[i] = arr1[i] | arr2[i];
        
        while (temp.size() < n)
        {
            if (arr1[i] % 2 == 1)
                temp.push_back('#');
            else if (arr1[i] % 2 == 0)
                temp.push_back(' ');
            
            arr1[i] /= 2;
        }
        
        reverse(temp.begin(), temp.end());
        answer.push_back(temp);
    }
    return answer;
}

 

 

다른 코드

#include <string>
#include <vector>
#include <iostream>
#include <bitset>

using namespace std;

vector<string> solution(int n, vector<int> arr1, vector<int> arr2) {
    vector<string> answer;

    for (int i = 0; i < n; i++) {
        bitset<16> bits(arr1[i] | arr2[i]);

        string row = bits.to_string().substr(16 - n);
        for (char& c : row)
            c = (c == '1') ? '#' : ' ';

        answer.push_back(row);
    }

    return answer;
}

여기서는 따로 이진수 변환작업을 해주지않고 바로 bitset을 활용하여 넣어준 예시이다.

substr은 16개의 비트 00000000011111 이런식으로 저장되는데 결국은 마지막 n 번째의 비트만 필요하기 때문에 잘라주었다.

 

 

마지막 말

처음엔 10진수에서 2진수로 변환해야한다는 생각과 OR을 써야겠다는 생각까지는 접근했는데 비트연산을 생각못하고 2진수로 변환하는데 하나하나 하느라 이 과정에서 막혀버린 것 같다.

비트 연산을 공부했지만 막상 코드를 짤 때는 사용해봤던 적이 적은데 이렇게 진법 계산할 때나, 부분집합 구할 때 많이 사용한다고 한다.

검색하다가 비트 마스트라는 용어도 보았는데 어떻게 구현하는지 공부해봐야겠다.

[ UE ] Navigation Systems

닿메_dahme
|2025. 4. 24. 21:07

(금요일까지 꼭 쓰겠습니다.. )

 

데디케이트 서버 기반의 프로젝트를 진행하면서 인게임 로직을 담당하게 되었다.

캐릭터가 사망하고 리스폰되었을 때를 처리하는 부분을 설계하다가 단순히 다른 컨트롤러에 빙의되는 것이 아니라 격투게임이니만큼 격투를 관전할 수 있는 관전뷰를 만들어 거기에 플레이어들을 빙의시켰다.

 

트러블슈팅

하다가 서버에서 클라이언트의 플레이어컨트롤러를 관전 카메라에 배치하는건 잘 작동되었다.

void ACharacterController::ClientSpectateCamera_Implementation(ACameraActor* SpectatorCam)
{
	if (!SpectatorCam)
	{
		UE_LOG(LogTemp, Warning, TEXT("ClientSpectateCamera: SpectatorCam is null"));
		return;
	}

	UnPossess();
	ChangeState(NAME_Spectating);
	SetViewTargetWithBlend(SpectatorCam, 0.f);

	UE_LOG(LogTemp, Log, TEXT("ClientSpectateCamera: switched to SpectatorCamera"));
}
void ACharacterController::NotifyPawnDeath()
{
	AArenaPlayerState* ArenaPlayerState = GetPlayerState<AArenaPlayerState>();
	if (!ArenaPlayerState)
	{
		UE_LOG(LogTemp, Warning, TEXT("NotifyPawnDeath: PlayerState not invaild"));
		return;
	}

	if (ArenaPlayerState->MaxLives > 0)
	{
		ArenaPlayerState->MaxLives--;

		FTimerHandle RespawnTimerHandle;
		GetWorld()->GetTimerManager().SetTimer(RespawnTimerHandle, [this]()
			{
				if (AArenaGameMode* ArenaGameMode = Cast<AArenaGameMode>(GetWorld()->GetAuthGameMode()))
				{
					ArenaGameMode->RespawnPlayer(this);
				}
			}, 3.0f, false);
	}
	else
	{
		if (AArenaGameState* ArenaGameState = Cast<AArenaGameState>(GetWorld()->GetGameState()))
		{
			float CurrentRoundTime = ArenaGameState->GetRemainingTime();
			float TotalRountTime = ArenaGameState->GetRoundStartTime();
			ArenaPlayerState->SetSurvivalTime(TotalRountTime - CurrentRoundTime);
			UE_LOG(LogTemp, Log, TEXT("NotifyPawnDeath: Survivla time is %.2f"), CurrentRoundTime);
		}

		if (AArenaGameMode* ArenaGameMode = Cast<AArenaGameMode>(GetWorld()->GetAuthGameMode()))
		{
			if (ACameraActor* SpectatorCam = ArenaGameMode->GetSpectatorCamera())
			{
				ClientSpectateCamera(SpectatorCam);
			}
			else
			{
				UE_LOG(LogTemp, Warning, TEXT("NotifyPawnDeath: SpectatorCamera not invaild"));
			}
		}
	}
}

이건 처음에 작성했던 코드인데, 클라이언트 RPC로 구현했다.

그런데 저 카메라 전환에 대해 0.2초정도는 원하는 카메라로 이동되었다가 갑자기 이상한 뷰로 전환되는 것이 아닌가!

솔직히 로직상으로는 뭐가 문제인지 잘 찾을 수 없었던 것 같다.

 

하지만 찾아내야했다..

 

카메라는 블루프린트로 만들어진 카메라 액터였고, 컨트롤러에 Tick 함수에 어떤 카메라가 빙의되는지 찾아봤다.

근데 내 관전카메라로 떴다가 컨트롤러로 뜨는 것이 아닌가..

아마 캐릭터쪽에서 Pawn Destroy()를 타이머로 관리하고, 바로 컨트롤러의 죽음 처리 함수를 호출한다.

컨트롤러 죽음 함수에서 카메라 전환을 위해 컨트롤러를 UnPossess하고, SetViewTarget을 해주게되는데 카메라 빙의 함수 호출 순서가 꼬여서 그런 것 같다.

(디버깅에만 4시간 쓴 것 같다...)

 

void ACharacterController::NotifyPawnDeath()
{
	if (!HasAuthority()) { return; }

	AArenaPlayerState* ArenaPlayerState = GetPlayerState<AArenaPlayerState>();
	if (!IsValid(ArenaPlayerState)) { return; }

	AArenaGameMode* ArenaGameMode = Cast<AArenaGameMode>(GetWorld()->GetAuthGameMode());
	if (!IsValid(ArenaGameMode)) { return; }

	UnPossess();
	DisableInput(this);
	FlushPressedKeys();
	SetIgnoreMoveInput(true);
	SetIgnoreLookInput(true);

	ArenaPlayerState->MaxLives--;

	// Posses Spectator Camera
	if (AArenaGameState* ArenaGameState = Cast<AArenaGameState>(GetWorld()->GetGameState()))
	{
		FTimerHandle SpectateHandle;
		ACameraActor* SpectatorCam = ArenaGameState->GetSpectatorCamera();
		if (IsValid(SpectatorCam))
		{
			GetWorld()->GetTimerManager().SetTimer(SpectateHandle, [this, SpectatorCam]()
				{
					FViewTargetTransitionParams Params;
					Params.BlendTime = 0.f;
					Cast<APlayerController>(this)
						->ClientSetViewTarget(SpectatorCam, Params);
				}, 0.2f, false);
		}

		if (IsLocalController())
		{
			GetWorld()->GetTimerManager().SetTimer(SpectateHandle, [this, SpectatorCam]()
				{
					FViewTargetTransitionParams Params;
					Params.BlendTime = 0.f;
					Cast<APlayerController>(this)
						->ClientSetViewTarget(SpectatorCam, Params);
				}, 0.2f, false);
		}
	}

	// Respawn
	AArenaPlayerState* PS = GetPlayerState<AArenaPlayerState>();
	const bool bHasLives = PS && PS->MaxLives > 0;

	if (ArenaGameMode->SelectedArenaSubMode == EArenaSubMode::DeathMatch)
	{
		ClientStartRespawnCountdown();
		FTimerHandle RespawnTimerHandle;
		GetWorld()->GetTimerManager().SetTimer(
			RespawnTimerHandle,
			[this, ArenaGameMode]()
			{
				ArenaGameMode->SpawnPlayer(this);
			},
			5.0f,
			false
		);
	}
	else if (ArenaGameMode->SelectedArenaSubMode == EArenaSubMode::Elimination)
	{
		if (bHasLives)
		{
			ClientStartRespawnCountdown();
			FTimerHandle RespawnTimerHandle;
			GetWorld()->GetTimerManager().SetTimer(
				RespawnTimerHandle,
				[this, ArenaGameMode]()
				{
					ArenaGameMode->SpawnPlayer(this);
				},
				5.0f,
				false
			);
		}
		else
		{
			if (AArenaGameState* ArenaGameState = Cast<AArenaGameState>(GetWorld()->GetGameState()))
			{
				float SurvivalTime = ArenaGameState->GetRoundStartTime() - ArenaGameState->GetRemainingTime();
				ArenaPlayerState->SetSurvivalTime(SurvivalTime);
				ClientShowDieMessage();
			}
		}
	}
}

여기 함수를 보면 ClientSetViewTarget을 호출하는데 이건 언리얼 내장 RPC 함수이고 호출될 때 지연시간이 있다고 한다.

그래서 충분히 UnPossess되고, 빙의되고 이런 과정을들 기다렸다가 호출하기 때문에 순서가 꼬이지 않아 정상적으로 카메라 전환이 되었던 것 같다.

또는 컨트롤러에서 빙의해제가 되기 전에  RPC 함수가 호출되면서 컨트롤러의 빙의 처리와 SetViewTarget의 호출시점이 꼬여서 발생했을 수도 있을 것 같다,

 

 

 

마무리말

아직 언리얼 내장 함수들이 어떻게 동작하는지 명확하게 이해하지 못한 것 같다.

결론도 혼자 함수와 코드를 따라가보면서 유추한것뿐이라, 나중에 시간을 따로 내어서 내장 함수를 깊에 들여다보는 시간을 가져야겠다.

 

훈련장 모드에서 타이머를 구현하다가 발생한 트러블상황과 구조 대공사를 통해 알게된 내용을 정리해보려고 한다.

 

 

 

트러블 슈팅

 

트러블 상황

RoundTime을 0으로 초기화해줬는데 갑자기 이상한 값이 뜸..

 

해결방법

기존 코드에서 Time이 0일때도 1초씩 감소하는 로직을 타서 발생했던 현상.
Time이 아직 초기화되지않았을 때에는 1초씩 감소하지 않도록 방어 코드를 짜주어서 해결.

 

 

 

 

 

프로젝트 진행 중 다시 알게된 내용

GameMode, GameState, Widget 간의 데이터 처리

게임 프레임워크를 설계하고 구현하다보니 구조적인 설계에 대해 많이 고민하고 성장하게 되었다.

GameMode, GameState, Widget 간의 데이터 처리에 부분에서 흐름이 어떻게 흘러가는지 이해하게되었다.

나는 GameState가 실시간으로 데이터를 저장하는 구조라서 데이터 업데이트하는 로직을 여기에서 처리하고 있었다.

근데 다시 공부해보니 로직이나 계산 같은 부분은 GameMode에서 처리하고 GameState는 데이터를 저장하는 역할하고 GameMode에서 State의 데이터를 업데이트해주도록 가져가는게 좋을 것 같았다.

Widget은 GameMode에서 State의 값을 가져오거나 바로 State에서 가져가는 구조로 설계했는데 지금 프로젝트 규모는 크지 않기때문에 바로 State에서 값을 가져가도록 설계했다.

 

 

 

 

 

결과

 

타이머 연동 ? OK !

Start, Reset 버튼 온클릭 함수 ? OK !

TotalDamage, DPS 연동 ? OK !

 

잘 작동한다. 구조도 싸그리 대공사를 했기때문에 나중에 유지보수하기 더 쉬울 것 같다.

 

 

 

 

 

 

첫번째 트러블 상황

BaseInGameHUD에서 정의한 GetTogglePauseWidget을 PlayerController에서 가져오기 위해 IsValid로 값체크를 하는 과정에서 발생.

 

 

 

이유

IsValid()는 UObject* 타입을 요구하는데 GetTogglePauseWidget()은 const 포인터를 반환하고 있음. IsValid()는 주로 non-const 버전을 검사하게 구현되어있어서 생기는 문제.

 

 

 

해결방법
	UFUNCTION(BlueprintCallable, Category = "CCFF|UI")
	FORCEINLINE UTogglePauseWidget* GetTogglePauseWidget() const { return TogglePauseWidget; }


IsValid()를 쓰지않고 ≠nullptr로 바꿔줘서 해결

 

 

 

두번째 트러블 상황

void UTrainingWidget::UpdateTimer()
{

	CurrentTime -= 1.0f;

	if (CurrentTime <= 0.0f)
	{
		CurrentTime = 0.0f;
		GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
	}

	int32 Minutes = FMath::FloorToInt(CurrentTime / 60.0f);
	int32 Seconds = FMath::FloorToInt(CurrentTime) % 60;
	FString FormattedTime = FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds);

	UpdateTimerText(FormattedTime);
}

이런 구조로 가는데 Base의 TimerText가 업데이트되지 않았다.

 

 

 

이유 및 해결 방법
void UTrainingWidget::UpdateTimer()
{

	CurrentTime -= 1.0f;

	if (CurrentTime <= 0.0f)
	{
		CurrentTime = 0.0f;
		GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
	}

	int32 Minutes = FMath::FloorToInt(CurrentTime / 60.0f);
	int32 Seconds = FMath::FloorToInt(CurrentTime) % 60;
	FString FormattedTime = FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds);

	if (APlayerController* PC = GetOwningPlayer())
	{
		if (ABaseInGameHUD* HUD = Cast<ABaseInGameHUD>(PC->GetHUD()))
		{
			if (UBaseInGameWidget* BaseWidget = HUD->GetBaseInGameWidget())
			{
				BaseWidget->UpdateTimerText(FormattedTime);
			}
		}
	}
}


TrainingWidget에서는 직접 TimerText를 갖지 않고, 대신 현재 HUD에 있는 BaseInGameWidget의 UpdateTimerText() 함수를 호출해서 타이머 텍스트를 갱신하는 게 내 예상 결과값이었다.

즉, TrainingWidget 내부에서 "this->UpdateTimerText(...)"를 호출하면 TrainingWidget 자체의 (없거나 null인) TimerText에 업데이트가 적용된다고 한다. 그래서 대신, HUD에 있는 BaseInGameWidget 인스턴스를 찾아서 그 인스턴스의 UpdateTimerText()를 호출해주는 방식으로 수정해주었다.

 

 

 

마지막 말

오늘 타이머 작업을 하다가 아무래도 위젯클래스에서 로직을 타는게 좀 이상하다고 생각이 들었다..

그리고 GameMode와 GameState의 역할? 분리를 어떻게 해야할지 감을 못잡고 작업하는 상태여서 계속 하면서 이상하다는 생각을 했다..

아니나다를까.. 이상한 구조로 작업하고 있어서 아예 구조를 전면변경하려고 한다..

눈에서 무슨 액체가 흐른다..

위젯에 텍스트로 타이머 기능을 넣는 부분을 진행했다.

 

BaseHUD, BaseWidget 설계

위젯을 쓰려고하니 모드마다 공통으로 들어가는 위젯들이 있어서 Base 위젯을 만들면 좋겠다고 생각했다.

베이스 HUD와 Widget의 구조를 잡는데 오늘 대부분의 시간을 썼었던 것 같다.

HUD와 Widget의 역할을 잘 몰랐었는데 HUD는 UI를 담는 컨테이너 같은 역할이고 그 안에 실질적으로 유저가 보는 것들이 UI라고 이해했다.

 

그래서 일단 BaseHUD를 설계했고, 공통된 부분만 묶어서 정의해주었다.

새로운 HUD가 추가될 때마다 이 BaseHUD를 상속받아서 적용할 수 있게 설계했고, 위젯도 비슷한 방식을 적용했다.

근데 위젯은 어떤걸 묶을지 아직 감이 안잡히는 것 같다.. Base가 굳이 필요한가 싶기도하고.. 주말에 좀 더 정리를 해봐야겠다.

 

 

Text 위젯과 타이머 연동

타이머 연동은

00:00 이런 식으로 하려고 설계했고, 하나의 Text를 사용해서 만들어주었다.

 

이렇게 중간에 시간을 넣어주었고 위젯을 상속받은 C++을 기반으로 만들어진 위젯블루프린트이기때문에, 특정 위젯을 가져야만 오류가 나지 않도록 처리해주었다.

베이스 UI를 이렇게 만들어주었고, 이제 이 베이스 UI를 상속받은 훈련장 UI를 만들어주었다.

 

이런 느낌으로 초를 입력하면 화면 중앙 상단의 타이머까지 연동할 수 있게 하고 싶었다.

시작 버튼을 누르면 설정한 초만큼 시작이되고, 리셋을 누르면 타이머가 0초로 돌아가게 설정해주려고 한다.

 

#pragma once

#include "CoreMinimal.h"
#include "Framework/UI/BaseInGameWidget.h"
#include "TrainingWidget.generated.h"

UCLASS()
class CCFF_API UTrainingWidget : public UBaseInGameWidget
{
	GENERATED_BODY()
	
	UFUNCTION()
	void OnStartButtonClicked();

	UFUNCTION()
	void OnResetButtonClicked();

	UFUNCTION()
	void UpdateTimer();

protected:
	virtual void NativeConstruct() override;

	float CurrentTime = 0.0f;

	FTimerHandle TimerHandle;

	UPROPERTY(meta = (BindWidget))
	class UTextBlock* TimerText;
	
	UPROPERTY(meta = (BindWidget))
	class UEditableTextBox* TimeInputBox;

	UPROPERTY(meta = (BindWidget))
	class UButton* StartButton;

	UPROPERTY(meta = (BindWidget))
	class UButton* ResetButton;

};
#include "Framework/UI/TrainingWidget.h"
#include "Components/Button.h"
#include "Components/EditableTextBox.h"
#include "Components/TextBlock.h"
#include "TimeManagementClasses.h"


void UTrainingWidget::NativeConstruct()
{
	Super::NativeConstruct();

	if (IsValid(StartButton))
	{
		StartButton->OnClicked.AddDynamic(this, &UTrainingWidget::OnStartButtonClicked);
	}

	if (IsValid(ResetButton))
	{
		ResetButton->OnClicked.AddDynamic(this, &UTrainingWidget::OnResetButtonClicked);
	}

	if (IsValid(TimerText))
	{
		TimerText->SetText(FText::FromString(TEXT("00:00")));
	}
}


void UTrainingWidget::OnStartButtonClicked()
{
	if (IsValid(TimeInputBox))
	{
		const FString InputStr = TimeInputBox->GetText().ToString();
		float EnterTime = FCString::Atof(*InputStr);

		if (EnterTime < 0.f)
			EnterTime = 0.f;

		CurrentTime = EnterTime;

		int32 Minutes = FMath::FloorToInt(CurrentTime / 60.0f);
		int32 Seconds = FMath::FloorToInt(CurrentTime) % 60;

		FString FormattedTime = FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds);

		if (IsValid(TimerText))
		{
			TimerText->SetText(FText::FromString(FormattedTime));
		}

		// initialize timer
		GetWorld()->GetTimerManager().ClearTimer(TimerHandle);

		GetWorld()->GetTimerManager().SetTimer(TimerHandle, this, &UTrainingWidget::UpdateTimer, 1.0f, true);
	}
}

void UTrainingWidget::OnResetButtonClicked()
{
	if (IsValid(TimerText))
	{
		TimerText->SetText(FText::FromString(TEXT("00:00")));
	}

	GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
}

void UTrainingWidget::UpdateTimer()
{
	CurrentTime -= 1.0f;

	if (CurrentTime <= 0.0f)
	{
		CurrentTime = 0.0f;
		GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
	}

	int32 Minutes = FMath::FloorToInt(CurrentTime / 60.0f);
	int32 Seconds = FMath::FloorToInt(CurrentTime) % 60;
	FString FormattedTime = FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds);

	if (IsValid(TimerText))
	{
		TimerText->SetText(FText::FromString(FormattedTime));
	}
}

 

Editable Text Box에서 입력된 텍스트를 가져와서 형변환을 해주고, 분과 초를 나눠서 타이머에 적용시켜주었다.

 

블루프린트로도 버튼에 대한 온클릭 함수를 구현할 수 있었지만 이번에 진행하는 프로젝트는 UI가 많아지고 Base 위젯을 상속받아 사용하기 때문에 혼동될 수도 있어서 안전하게 코드로 온클릭 함수를 연결해주었다.

 

 

결과

이렇게 입력한 값은 항상 초로 인식하고 라운드 시계에서는 저렇게 분과 초로 나눠서 타이머를 구현해보았다.

 

 

마지막 말

초반에 역할 분리가 애매하게 된 상황에서 클래스를 구현하다보니 내 담당이 아닌 부분까지 함수를 만들어뒀었다.

다른 팀원의 혼동을 막기 위해 주말에 쓸데없는 함수는 다 과감하게 없애버리고, 중복되는 코드들을 줄여볼 생각이다.

훈련장 모드를 완성하고, 멀티 서버 공부를 더 해봐야겠다.

프로젝트에서 내가 담당한 인게임의 구조를 다시 한 번 틀을 잡아가는 단계를 거쳐야할 것 같다.

코드를 짜면서 구조 설계가 미흡해서 자꾸 딴 길로 새면서 기능 하나를 구현하는데 쓸데없이 많은 시간을 쓰고 있는 것 같다.

훈련장 모드를 만들기 위해 베이스 구조를 설계해보았따.

가장 필요한 GameMode와 GameState를 설계했다.

 

BaseInGameMode

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/GameMode.h"
#include "BaseInGameMode.generated.h"

UCLASS()
class CCFF_API ABaseInGameMode : public AGameMode
{
	GENERATED_BODY()

public:
	ABaseInGameMode(const FObjectInitializer& ObjectInitializer);

	virtual void BeginPlay() override;

	UFUNCTION(BlueprintCallable, Category = "CCFF|GameMode|Round")
	virtual void StartRound();

	UFUNCTION(BlueprintCallable, Category = "CCFF|GameMode|Round")
	virtual void EndRound();

	UFUNCTION(BlueprintCallable, Category = "CCFF|GameMode|Round")
	virtual void CheckGameConditions();

protected:
	UPROPERTY(EditAnywhere, Category = "CCFF|GameMode|Round")
	float RoundDuration;

	UPROPERTY(EditAnywhere, Category = "CCFF|GameMode|Round")
	int32 MaxHP;

	FTimerHandle GameTimerHandle;

};
#include "Framework/GameMode/BaseInGameMode.h"
#include "Framework/GameState/BaseInGameState.h"

ABaseInGameMode::ABaseInGameMode(const FObjectInitializer & ObjectInitializer) : Super(ObjectInitializer)
{
	GameStateClass = ABaseInGameState::StaticClass();

	RoundDuration = 60.0f;
	MaxHP = 3;
}

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

	StartRound();
}

void ABaseInGameMode::StartRound()
{
	UE_LOG(LogTemp, Log, TEXT("Start Round!"));

	GetWorldTimerManager().SetTimer(
		GameTimerHandle, this, &ABaseInGameMode::CheckGameConditions, 1.0f, true
		);
}

void ABaseInGameMode::EndRound()
{
	UE_LOG(LogTemp, Log, TEXT("End Round!"));

	GetWorldTimerManager().ClearTimer(GameTimerHandle);
}

void ABaseInGameMode::CheckGameConditions()
{
}

 

이렇게 게임 모드가 확장될 때마다 사용할 수 있게 필요한 부분들만 정의해주었다.

그리고 인게임에서는 항상 타이머가 작동되기때문에 타이머를 넣어주었다.

 

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/GameState.h"
#include "BaseInGameState.generated.h"


UENUM(BlueprintType)
enum class ERoundProgress : uint8
{
	NotStarted UMETA(DisplayName = "Not Started"),
	InProgress UMETA(DisplayName = "In Progress"),
	Ended UMETA(DisplayName = "Ended")
};


UCLASS()
class CCFF_API ABaseInGameState : public AGameState
{
	GENERATED_BODY()

public:
	ABaseInGameState();

	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

	UFUNCTION(BlueprintCallable, Category = "CCFF|GameState|HUD")
	virtual void UpdateHUDData();
	UFUNCTION(BlueprintCallable, Category = "CCFF|GameState|HUD")
	virtual void SetRemainingTime(float NewTime);
	UFUNCTION(BlueprintCallable, Category = "CCFF|GameState|HUD")
	virtual void SetRoundProgress(ERoundProgress NewProgress);

	UPROPERTY(BlueprintReadOnly, Replicated, Category = "CCFF|GameState|HUD")
	float RemainingTime;

	UPROPERTY(BlueprintReadOnly, Replicated, Category = "CCFF|GameState|HUD")
	ERoundProgress RoundProgress;

};
#include "Framework/GameState/BaseInGameState.h"
#include "Net/UnrealNetwork.h"

ABaseInGameState::ABaseInGameState()
{
	RemainingTime = 0.0f;
	RoundProgress = ERoundProgress::NotStarted;
}

void ABaseInGameState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME(ThisClass, RemainingTime);
	DOREPLIFETIME(ThisClass, RoundProgress);
}

void ABaseInGameState::UpdateHUDData()
{
	UE_LOG(LogTemp, Log, TEXT("HUD Updated: RemainingTime: %f, RoundProgress: %d"), RemainingTime, (uint8)RoundProgress);
}

void ABaseInGameState::SetRemainingTime(float NewTime)
{
	RemainingTime = NewTime;
	UpdateHUDData();
}

void ABaseInGameState::SetRoundProgress(ERoundProgress NewProgress)
{
	RoundProgress = NewProgress;
	UpdateHUDData();
}

 

그리고 GameState에서는 HUD 정보들과 필요한 정보들을 업데이트해주고, 복제할 수 있게 설정해주었다.

 

훈련장 배치는 이렇게 허수아비를 하나 두고, 시작하려고 설계했다.

 

UI  구조는 다음과 같이 설정했따.

네모박스는 BaseWidget으로 설정할려고 한다. 모든 모드에서 사용할 부분이라..

그리고 오른쪽 하단 부분은 훈련장에서만 필요한 부분들을 모아서 작업하려고 한다.

 

 

내일은 UI 위젯을 본격적으로 만들고 기능을 붙일 생각이다.

아자아자

오늘을 데디케이트 서버로 패키징을 하는 방법을 공부해보았다.

 

패키징 하는 과정

먼저 똑같은 프로젝트를 2개를 만들어줘야한다. 하나는 작업용, 하나는 패키징용으로 관리할 것이다.
(참고로 두 프로젝트의 경로는 다르게 설정해주는 것이 좋다. 패키징 작업 때 경로 때문에 꼬일 일이 생길 수도 있다고 한다.)

그리고 작업용은 어떤 버전으로 해도 상관없으나 패키징용 프로젝트는 언리얼 소스 빌드로 해줘야 문제없이 패키징할 수 있는 환경을 만들 수 있다.

 

패키징용 프로젝트가 있는 폴더로 들어가서 Source 폴더로 들어간다.

 

프로젝트이름Editor.Target.cs 파일을 2번 복붙하고 Editor를 각각 Client, Server로 변경해주고 파일 안에서도 이름을 변경해준다.

 

언리얼 에디터에서 Tools에서 Project Launcher를 켜준다.

 

 

만약 이렇게 네모부분이 비어있다면 언리얼 5.5버전으로 빌드했기때문!!!!!

***앞에서 언급했듯이 제대로 패키징을 하기 위해서는 언리얼 엔진을 git으로 받고 빌드가 성공적으로 된 것을 확인한 후 Source로 빌드해주는 과정이 필요하다!!!!

 

제대로 환경을 구축했다면 이렇게 Build Target 옵션이 떠야한다.

 

데디케이트 서버 패키징을 하기 위해서는 클라이언트와 서버 둘 다 패키징 작업을 해줘야한다.

 

먼저 클라이언트를 작업해주려고 한다.

 

Build Target - 프로젝트이름Client
Varient - WindowClient
Data Build - By the book

으로 옵션을 설정해주고 오른쪽의 게임기 버튼을 클릭해서 패키징을 시작해준다. (만약 이 옵션들이 보이지않고 Variant만 보인다면 Show Advanced를 클릭해주면 옵션이 뜬다)

 

서버도 동일한 방법으로 Server 옵션으로 바꿔주고 패키징해준다.

 

내 컴퓨터 환경으로는 클라이언트는 1시간 30분, 서버는 30분의 시간이 소요되었다.

 

패키징을 모두 완료하면 Saved/StageBuilds/WindowClient or WindowServer 폴더가 생성된 것을 볼 수 있다.

이 폴더들 안에 들어가보면 각각 exe파일이 생겼을 것이다.

실행해본 후 정상적으로 동작한다면 패키징 끝!!

 

아 참고로 에디터에서 패키징하면서 오류가 있다면 오류 로그가 뜨는데 어디에서 오류가 떴는지 확인해볼 수 있다.

그래서 패키징을 한다면 매일 하는 것을 추천한다.(오류가 쌓이게되면 추적하기 힘들기때문..)