Game Development/Devlog

[UE5] 클래스 재설계, 리팩토링 (ProjectVT 개발기)

Dlaiml 2024. 7. 31. 17:34

이전 포스팅에 이어 ProjectVT 개발 진행 사항에 대해 간단하게 포스팅하려 합니다.

 

 

[UE5] 메신저 핵심 기능 개발 완료 (ProjectVT 개발기)

이전 포스팅에서 메신저 기능을 위해 WBP와 클래스 구조를 설계하고 기본적인 레이아웃을 개발한 것까지 소개하였다.   [UE5] 메신저 기능 데이터 구조 설계, Widget Reflector (ProjectVT 개발기)출시

dlaiml.tistory.com

 

처음 개발할 때, 언리얼 엔진을 새로 배우자마자 프로젝트 개발을 진행했던 탓에 확장성을 고려하지 못한 설계가 많았고 데모 출시를 위해 개발된 부분을 모아 통합하던 중에 문제가 하나 둘 나타났다.

 

우선 UI와 게임로직의 분리가 되어 있지 않아 UserWidget의 함수 안에서 게임진행과 관련된 주요 로직을 처리하느라 재사용하려면 사용하지도 않는 Widget을 참조하거나 코드를 복사해야 되는 문제가 있었다.

 

다음으로는 OOP 설계의 문제가 있었다. 대화를 중심으로 게임이 진행되기 때문에 여러 레벨에서 동일한 속성을 지닌 [대화 데이터 테이블 / 대화 진행 로직 / UI] 등이 있는데 이들이 모두 각각 레벨에서 연관성 없는 클래스로 관리되고 있었고 대사 진행 로직을 수정하거나 옵션을 하나 변경하는 데에도 모든 레벨을 돌며 수정이 필요했다.

 

이 두 문제가 맞물린 상태로 기능이 계속 추가되다 보니 특정 UserWidget은 거의 게임에 필요한 모든 UI와 로직을 담은 하나의 거대한 시스템이 되어버렸고 비슷한 기능을 추가할 때는 매번 내부 함수나 컴포넌트를 참조하는 기형적인 형태가 되었다.

 

이 문제들을 해결하는데 대부분 시간을 사용하였다.

 

UserWidget 객체지향 설계

초기에 개발하면서 간단한 확인 취소 위젯, 안내 모달 위젯까지 문구마다 모두 다른 Widget으로 만들어졌음. UI와 로직이 혼재하다 보니 다른 로직을 처리하려면 다른 위젯을 임시로 만들었기 때문에 생긴 문제.

 

잘못된 입력을 하였을 때, 저장이 완료되었을 때 등 안내를 위해 1초간 나타났다가 사라지는 애니메이션이 Construct시점에 재생되는 버튼이 없는 위젯들을 하나의 클래스로 묶고, 멤버인 텍스트를 수정하는 방식으로 교체.

 

확인 취소 버튼이 있는 위젯의 경우 부모 클래스를 만들고 확인버튼 OnClick 이벤트를 핸들링하는 함수를 순수 가상 함수로 취소버튼 OnClick 이벤트를 애니메이션 재생과 자신 객체를 소멸시키는 방식으로 설정하였다.

 

 

대화 진행의 경우도 여러 레벨에서 모두 활용할 수 있도록 하기 위해 대화 박스를 UserWidget으로 따로 분리한 뒤, 각각 텍스를 모두 변수로 설정하였다. 우상단의 메뉴, 로그, 스킵, 오토 버튼 또한 별도의 UserWidget으로 분리하여 다양한 형태의 대화창에 사용할 수 있도록 하였다.

 

GameMode, GameInstance, Blueprint Library 활용

현재 개발하고 있는 게임에는 대화가 주로 진행되는 씬 레벨 / 미니게임을 진행하는 미니게임 레벨 / 씬 사이에 여러 콘텐츠를 진행하는 대기화면 레벨 / 게임 시작 화면인 오프닝 레벨이 있다.

 

UI와 게임로직을 분리하니 해당 레벨에서 정의되어야 하는 여러 로직들이 있었고 (마우스 좌클릭, ESC, 마우스 커서, InputMode) 레벨에 상관없이 공통적으로 실행되어야 할 부분을 4개의 게임레벨의 부모 클래스를 만들어 내부에 코드를 작성하였다.

 

이후 4개의 레벨의 게임모드를 따로 만들고 레벨이 시작하고 끝날 때까지 변하지 않는 기능의 코드를 작성하였다.

대화를 진행하는 씬 레벨에서는 BeginPlay 시점에 마우스 커서, 메인 대화 유저위젯 Viewport에 추가, 대화 진행에 따른 콜백 바인딩 등이 이루어진다.

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

    APlayerController* PlayerController = UGameplayStatics::GetPlayerController(this, 0);
    if (PlayerController)
    {
        PlayerController->bShowMouseCursor = true;

        UUserWidget* DialogueWidget = CreateWidget<UUserWidget>(PlayerController, DialogueWidgetClass);
        if (DialogueWidget)
        {
            DialogueWidget->AddToViewport();
            DialogueWidget->OnDialogProgress.AddDynamic(this, &AVTGameMode::OnDialogProgress);
        }
    }
}

 

이렇게 레벨의 초기화, 주요 로직들을 GameMode에 따로 작성한 뒤 각 레벨에서 중추가 되던 메인 위젯은 UI의 역할과 관련된 로직들만 담게 되었다. Delegate를 활용하여 위젯에서는 대화 진행이 이루어지면 호출을, GameMode는 이와 관련된 대사 Index, Save Data 업데이트 등 로직을 처리하게 되었다.

 

GameInstance는 레벨이 바뀌더라도 유지되는 속성을 가져 이를 활용할 수 있는 객체, 변수를 가지도록 하였다. 사용자 설정과 관련된 부분은 GameInstance에서 다루고 추가로 현재 전체 게임 진행도 / Save Load에 필요한 데이터(진행 레벨, 진행 중인 대사 Index, 선택하였던 대화, 플레이어 이름) 등을 멤버 변수로 가지고 있다.

 

Save, Load, Setting 등 기능을 담고 있는 클래스도 어느 레벨에서나 동일하게 동작하고 GameInstance에서 가지고 있는 변수들을 요구로 하기 때문에 GameInstance내에 함수로 구현하였다.

 

 

 

다음 레벨, 현재 레벨의 데이터(어떤 대화 데이터를 사용하여야 하는지)는 모두 DataTable로 관리하여 비개발자도 엑셀로 export 하여 쉽게 수정할 수 있도록 하였다.

 

대화 진행 기능 통합

대화를 통해 게임이 진행되기 때문에 거의 모든 레벨에서 대화창 UI와 대화 기능을 사용한다. 하지만 이전에는 각 레벨의 대화 Widget, 그 안의 함수로 대화 진행 코드가 작성되어 있어 이를 관리하기가 까다로웠다.

 

현재 진행 중인 대화의 데이터테이블, 대사 Index, 대화박스를 모두 GameInstance의 멤버 변수로 두었고 Save, Load를 위한 SaveGame 객체에는 해당 필드들이 포함되어 있어 현재 진행중인 레벨의, 진행중인 대화로 바로 Save, Load 할 수 있도록 하였다. (이전에는 레벨이 달라지면 대화 State가 유지되지 않아 메인 대화 Widget을 다른 레벨에서도 참조하도록 하였음)

 

GameInstance의 값들에 접근하는 경우가 많아지면서 이를 블루프린트에서도 보기 편하게 사용하기 위해 Blueprint Library에 변화가 잦은 대사 Index 등의 Getter, Setter를 설정하였고 간단한 유틸함수들도 Pure 함수로 만들어 실행핀 없이 간단하게 활용할 수 있도록 하였다.

 

BlueprintLibrary 내부 함수

 

이렇게 각 레벨에서 알맞은 위젯이 로드되고 Construction, BeginPlay 등 생애주기에 맞춰 각 레벨에 알맞은 초기화가 이루어지고 레벨을 건너뛰어도 Save, Load를 위한 값들이 유지되도록 하였다.

 

스크린샷을 저장 슬롯 썸네일로 만들기

최근 개발중인 기능이다. 플레이어가 메뉴에서 저장 버튼을 누르면 현재 화면을 캡쳐하여 저장 슬롯의 이미지로 설정하는 기능이다.  메뉴 버튼을 누르기 전 화면을 캡쳐해야되는 문제라 쉽게 해결하지 못하고 있다.

가장 간단할 거라 생각한 기능에 거의 단일 기능으로는 가장 많은 시간을 할애해도 아직 해결하지 못하였다.

 

시도해 본 방법은 아래와 같다.

 

Scene Capture 2D Actor 활용

 

[UE5] Scene Capture 2D - 실시간 카메라 구현

탱크 슈팅게임에서 전체적인 전투 현황을 생생하게 볼 수 있도록 경기장의 전광판처럼 실시간으로 씬을 보여주는 기능을 만들어보려 한다.이번 포스팅의 주제는 Scene Capture 2D라는 Actor 클래스를

dlaiml.tistory.com

이전 포스팅에서 사용했던 방법(위 포스팅)을 사용하려 했으나 레벨에 배치된 액터의 Widget Component가 아닌 User Widget이 Viewport에 추가되어 진행되는 게임이라 Camera를 활용할 수 없었다. 

모든 레벨에서 구조를 UserWidget을 사용하는 대신 Construction 시점에 Actor를 Spawn하고 Widget Component로 메인 위젯을 지정해서 게임을 이어가도록 바꾸는 방법이 있으나 Cost가 조금 있어 보류하였다.

 

High res screenshot

엔진의 스크린샷 함수이다. UI를 캡쳐하지 못해 마찬가지로 위 방법처럼 Actor의 활용이 강제된다. 

 

bugscreenshot / shot -showui

화면 자체를 캡쳐하는 콘솔 커맨드. 장점은 UserWidget도 모두 캡쳐할 수 있다는 것이고 단점은 스크린샷이 완료된 시점을 알 수가 없어 File Exists와 같은 함수로 직접 틱마다 체크를 해야만 한다는 것이다. 속도도 빠르지 않다 (0.5~1초 정도)

가장 큰 문제는 특정 Widget에 Focus를 두고 캡쳐하는 기능이 없어서 메뉴가 열리기전 스크린샷을 얻으려면 메뉴 버튼을 누르기 전 0.5 ~ 1.5초 전 시점에 알아야한다는 것이다(?)

 

일단 메뉴위젯과 그 Child의 Visibility를 조절하고 스크린샷 후 File Exists로 생성되는 순간 다시 메뉴 Widget의 Visibility를 조정하는 방식이 있다.

 

0.5~1초 정도 사용자는 갑자기 저장버튼을 눌렀는데 메뉴가 사라지고 게임 화면이 나오는 경험을 하게 된다. 직접 테스트해보니 꽤 긴 시간일 뿐더러 안내를 위한 UI가 추가되면 이 또한 같이 캡쳐되는 문제가 있다.

 

보이지않는 액터 추가

기존 UserWidget 중심의 게임플레이를 그대로 유지하면서 Actor의 Widget Component를 활용하기 위해 생각해낸 방식이다. Widget Component가 UserWidget을 참조하도록하고 Actor를 Hidden하여 UI는 미러링하나 게임에는 영향을 끼치지 않도록 하는 것이다. 캡쳐하기 직전에 잠시 미러링을 멈추고 스크린샷을 한 뒤 다시 객체를 받아오는 방식이다.

간단한 게임이라 크지는 않지만 퍼포먼스에 악영향이 있을 것으로 예상되어 우선 보류하였다.

 


 

 

최근 포스팅 중 가장 많은 시간을 써서 개발하였지만 주로 효율성을 위한 리팩토링과 클래스 설계가 대부분이라 포스팅에 적을 내용이 많지 않아 여기까지 작성하려 한다.

 

Blueprint를 주로 Mesh, Animation처럼 시각적인 것을 에디터에서 처리할 때 사용하였는데 UserWidget을 중심으로 진행되는 2D 스크립트 게임을 개발하다 보니 Blueprint에서의 활용을 고려하며 개발하는 것이 새롭다.