팀 프로젝트를 완성시키며 발표, 종료를 하며 KPT 회고 진행시간이 있었습니다.

언리얼 엔진을 사용한 첫 프로젝트에서 진행된 만족스러운점, 아쉬운점, 그리고 해결방법들을 회고하는 시간을 가지게 되었습니다.

회고록을 진행하며 다음 프로젝트는 어떻게 기획, 설계해야하는지, 어떤것들을 기록해야하는 것인지를 스스로 학습 및 리마인드 하게 되었으며 해당 내용들을 포스팅을 통해 남기고자 합니다.

이번 포스팅에서는 팀 프로젝트를 진행하며 KPT 회고록 및 프로젝트 진행동안의 개인 피드백을 작성하겠습니다. 

 

KPT 회고

Keep : 현재 만족하고 있는 부분

Problem : 불편하게 느끼는 부분

Try : Problem에 대한 해결책, 당장 실행 가능한 것

 

프로젝트 개인 피드백

UI Part

  • 모작 게임에 대한 레퍼런스를 가지고 있음에도 추가 기능에 대한 미구현.
    이론적인 학습을 위주로 함으로써 개발속도가 느리다고 느껴졌다.
    • 기획단계에서 각각의 기능에 대한 제작 기간을 설계함으로써 기능 구현에 대한 타임라인 만들기
    • 개인프로젝트를 진행하며 개발속도 높이기
  • MVVM 설계 기반이지만, WidgetComponent를 활용한 HUD의 경우에는 MVC패턴의 구조다.
    초기 설계시 구체적인 설계를 하지 않음으로써 발생하는 문제들이 많다.
    • 기획단계에서 어떻게 구현할 것인지에 대한 UML, 시퀀스 다이어그램 작성하기
    • 개발자로써의 설계 및 기획하기 (장르, 담당파트만 정하지 않기)
  • 메모리 관리를 효율적으로 하지 않았다.
    • GC가 관리하지 않는 포인터 참조 변수들에 대한 소멸자 메모리 관리 습관화 하기
  • 최적화에 대한 근거가 없다.
    • 기능 구현 후 단위테스트 진행, 프로파일을 통한 성능 비교 후 우선순위 기록하기
    • 최종 단계에서 기대한 성능이 나오지 않는것들에 대한 최적화 리팩토링 작업
  • 일부 공통기능에 대한 유지보수성 낮은 로직
    • 객체지향 언어의 특징 및 원칙 기반의 설계 진행하기
  • 트러블슈팅을 진행하였지만, 기록하지 않았던 점
    • 발생하는 문제에 대한 원인, 해결 과정, 해결 결과에 대한 트러블슈팅 기록 습관화 하기

 

팀 프로젝트 회고

Keep

  1. 팀원들과 협업하며 프로젝트를 완성했다.
    • 초기 프로젝트 설계시에 필수/도전기능에 대한 구현을 성공적으로 구현. 책임감을 성취했다.
  2. 프로젝트를 진행,기획 하며 학습하지 않은 부분들을 주도적으로 찾아보며 프로젝트에 적용한 점
    • 프로젝트를 진행하며 담당 파트에 대한 추가 학습, 언리얼 기술 스택 향상
    • 주어진 동일한 기능 구현에 대한 심도있는 개발 능력
  3. 지속적인 팀 회의, 좋은 분위기
    • 왜 협업을 하는지에 대한 이유를 알 수 있었던점
    • 긍정적인 분위기 및 원활한 팀회의를 통한 빠른 문제 해결 능력 제공 및 추가 학습
  4. 확장성을 고려한 객체지향적인 개발
    • 당장의 게임 완성을 위한 하드코딩이 아닌, 확장성을 고려한 객체지향적인 코드 작성
    • 게임 내부가 완성도가 높았다.
  5. 담당파트의 구현 로직이 의도한대로 설계된 점
    • Stat FPS를 사용한 프로파일링 및 기획시 의도한 레벨 전환 구현으로 성취감 달성
  6. 아침/저녁 시간마다 회고를 진행한 부분
    • 회고를 통해 자기 자신이 얼마나 학습했는지에 대한 인지
    • 팀원들의 학습을 공유받으며 담당 파트 외에도 추가적인 학습기회 제공
  7. 협업시 Git을 활용한 스킬 향상
    • Git을 활용한 컨벤션에 대한 종류 및 장단점 학습
    • Branch Merge시 발생하는 Conflict원인 및 해결과정 등 병합하는 과정에 대한 이해도 향상
  8. 상황에 맞는 방어코드를 작성함으로써 빌드 오류가 발생하지 않은점.
    • 게임 실행시 크래쉬가 발생하지 않는 방어코드 작성. 패키징 고려한 코드 작성
    • 예외 발생시에 대한 방어코드 및 메모리관리를 철저하게 한 프로그램 내적 완성도 향상
    • 오류가 발생할 수 있는 부분에 대한 참조메모리, 콜스택들을 확인하며 트러블 슈팅을 할 수 있는 방법 학습
    • 어떠한 상황에 Equals, Valid, Check, Ensure를 사용하는지에 대한 방어코드 차이점 학습

Problem 

  1. 추가기능을 대부분 구현하지 않은 점
    • 구현할 수 있는 추가기능들이 다양했지만, 프로젝트 완성까지 적용/시도 하지않았다.
  2. 완성된 기능에 대한 추가적인 개발을 하지 않은점
    • 코드에는 정답이 없다. 다른 방법으로 개발을 할 수 있었는데 하지 않았던 점
    • 코드 리팩토링을 추가적으로 하지 않았음
  3. 언리얼 엔진 내부에 대한 이해도 부족
    • 메모리 관리에 대한 이해도 부족으로 인한 메모리 해제를 제대로 적용하지 않은 점
    • 게임 인스턴스 클래스를 활용한 Object Pooling 적용 실패
    • 액터 라이프사이클에 대한 이해도 부족으로 인한 의도치 않은 결과 발생
  4. 느슨한 초기 기획 설계
    • LFS에 대한 이해도 부족으로 인한 Config 세팅을 개별로 적용하던 점
    • UML 클래스 다이어그램, 초기 GameMode 파일 등을 기획하지 않아 병합시 문제가 발생한점
    • 중복되는 파일,DA,DT들이 존재, 유지보수성이 낮아진 부분
    • Content,Source 폴더 내 디렉토리 구조가 준수되지 않아 팀원들의 파일을 찾기 힘든점
  5. 내부적으로 존재하는 중복되는 코드들에 대한 유지보수성
    • 프로젝트 완성을 목적으로 중복되는 코드들을 리팩토링 하지 않은점
  6. 트러블 슈팅에 대한 기록을 하지 않은점
    • 프로젝트 발표시 트러블 슈팅에 대한 정보 누락
    • 팀 회의를 통해 트러블 슈팅 원인, 해결과정을 인지하지만, 기록하지 않아 리마인드가 힘듬
  7. 최적화에 대한 근거 부족
    • 각자 최적화를 하기위한 설계를 하였지만, 이론상으로만 최적화가 좋아짐.
    • Profile CPU,GPU / Stat Fps를 활용한 성능 비교를 실행하지 않음

Try

  1. 추가기능을 대부분 구현하지 않은 것
    • Render Text와 같이 구현하지 않은것들, 피격 이펙트, 사운드와 같은 추가기능들을 초기 설계시에 디테일하게 기획한다.
    • 프로젝트 제작 기간에 맞는 계획 기간에 맞춘 설계 구현
  2. 완성된 기능에 대한 추가적인 개발을 하지 않은 것
    • 팀원간 회의를 통한 상시 피드백
    • 코드 리뷰를 통한 추가적인 기획 피드백
  3. 언리얼 엔진 내부구조에 대한 이해도 부족으로 인한 문제점
    • 부족한 내용들에 개념을 공식문서, 강의를 통한 추가 학습
    • 팀원들과 공유를 함으로써 개인적인 성장이 아닌 팀 위주의 학습 성장 태도
  4. 느슨한 초기 기획 설계
    • 게임 장르에 대한 설계만이 아닌 개발자로써의 기획 설계 추가
    • 각자의 아이디어를 제공, 적극적인 태도
  5. 내부적으로 존재하는 중복되는 코드 (유지보수성 관련)
    • 객체지향언어의 특징 및 원칙을 추가학습하며 적용하도록 습관화 하기
  6. 트러블 슈팅에 대한 기록을 하지 않은것
    • 기록 습관화
    • 발생 원인, 해결 과정, 해결 결과 세가지를 기록해두기(TIL 등)
  7. 최적화에 대한 근거
    • FPS, CPU, GPU 사용량을 개발하며 체크, 어떤것에서 많이 문제가 발생했는지 기록하기
    • 프로젝트 완성 후 최적화 작업 진행하기
    • 메모리 관리
  1.  

개발블로그에 작성하지 않았던 설계에 맞춘 구현을 했던 위젯들에 대해 작성하겠습니다.

개발 시 작성한 코드들에 대해서는 하단에 깃허브를 통해 첨부를 하겠습니다.

 

프로젝트 기획 시 UI 구현 목록

 

설계시 공통 작업

  • WBP 위젯들은 UUserWidget을 상속받은 각각의 클래스들을 통해 생성한다.
    • 성능 최적화 및 MVVM 설계의 Model 계층과의 유연한 데이터 수신을 위해
  • GameMode/GameInstance/PlayerController 등 런타임 환경에 잠시 생성되는 위젯들은 인스턴스 형식으로 생성한다.
    • Slate UI에 대한 개념을 이해하며, 생성 후 해당 함수 외에서 호출하지 않는 경우는 인스턴스 형식으로 생성.
  • 최적화작업을 위해 이벤트 기반의 로직을 구현한다.
    • HP가 변경되는 등의 단순한 UI들은 HP가 변경되는 순간 Broadcast를 통한 데이터 전송을 통한 최적화 작업
  • 객체지향 특징과 MVVM 아키텍처 기반의 설계
    • 느슨한 결합성을 유지하여 특정 View 추가/삭제시에 해당 UI외 다른 클래스들은 영향을 받지 않는다.
    • 유지보수성이 향상되며 위젯들은 독립적으로 실행 할 수 있다.
  • Path 경로 변경 시 문제를 방지하기위한 DataAsset/DataTable를 사용한 데이터 관리
    • 각각의 필요한 DA/DT를 생성, 관리하며 필요없는 데이터들을 포함하는것을 방지
    • 필요한 데이터들은 PK담당을 하는 값들을 Contains,IsA,Equals등의 비교를 통해 로직 내 연동처리
  • Widget들은 UUserWidget를 상속한 C++클래스에서 제작하며, 디자인을 위젯 블루프린트를 통해 작업한다.
    • GameMode,Instance등 연동시 유연한 작업을 위해, 디자인의 경우는 효율적으로 작업할 수 있다. 

 

1. Lobby

  • NativeConstruct
    • 위젯 생성시 FInputModeDataBase를 UIOnly로 변경, UI에서만 키 입력을 받을 수 있게 변경한다.
    • 생성된 위젯의 SetGamePause(GetWorld(),true)를 사용한 게임 정지
    • SetFocusable를 통한 키입력 추가 설정.
  • NativeOnKeyDown
    • 위젯이 생성 된 후 어떠한 Key를 입력받아도 해당 위젯을 닫을 수 있게 설계
    • 사용한 PlayerController의 FInputMode를 GameOnly 변경
    • 게임 속행을 위한 SetGamePause(GetWorld(),false) 변경
    • RemoveFromParent() 호출을 통한 Slate UI 내 제거, GC가 관리할 수 있게 변경하여 메모리 최적화
      • RemoveFromViewport()는 더이상 사용하지 않는 함수. 권장하지 않으며(Deprecate), 호출 시 해당 함수 내부에서 RemoveFromParent()를 호출한다.
    • SetFocusable 해제.
    • GameMode 내 설계한 레벨 변경 함수를 통한 다음 Level로 이동

 

 

2. InGame Player HUD

 

  • InGameMainWidget Class가 VM역할을 담당하며 각각의 위젯들을 관리한다.
    • 각각의 Minimap, PlayerStatus, Weapon, Crosshair등 위젯들에 대한 이벤트 바인딩 작업
    • Player,Monster과 같은 Model계층에서 이벤트 방식으로 전달한 데이터들에 대한 데코레이터 작업 후 전달.
      • Delegate는 옵저퍼 패턴을 기반으로 구현 할 수 있으며, AddDynamic을 통한 바인딩 된 함수들을 통해 추가작업을 하여 데코레이터 패턴 적용이 가능하다.
    • 불필요한 유효성 검사를 제거하기위한 리플렉션 및 meta = (BindWidget)을 통해 WBP내에서 바인딩 체크
    • IsBound를 통한 이벤트 및 위젯 바인딩 체크. 중복 바인딩 방지, 최적화 작업
    • 각각의 클래스 분리를 통한 객체지향 원칙 준수 및 유지보수성 향상

 

3. SelectWidget

 

  • 특정 조건에 따라 (상점 방문, 레벨업 등) 생성되는 VM담당 위젯
  • ItemWidgetBase를 기반의 Card/Item... Widgets 들을 상황에 맞게 생성한다.
    • 동일하게 가지는 ObjectName, Texture, Description, Button 등을 쉽게 관리 할 수 있다.
  • 해당 위젯에서는 Background Image만을 가지고 있으며, HorizontalBox/Overlay를 사용한 하위클래스 위젯 배치
  • 각각의 Button을 입력받을시 해당 위젯을 RemoveFromParent를 호출한 삭제, Player에게 해당 정보 전달

 

4. Monster HUD

  • WidgetComponent를 사용한 부착된 BaseEnemy에 대한 정보를 보여준다.
  • MVVM 기반이 아닌, MVC 기반의 연동
    • WidgetComponent는 View를 담당하지 않는다. Controller의 역할에 더 적합하다.
  • Raycast / 체력 변화 관련 Event 발생시에만 활성화 한 최적화 작업
    • TimerHandle를 사용한 Visibility 세팅

 

5. PlayAnimWidget

  • Key 입력 또는 특정 상황에 맞는 애니메이션 연출
  • Ex) Dash 2D Effect, Hit Maker, Ending Credits...
  • 게임의 시각적 재미를 위한 추가 및 PlayAnimation, IsAnimationPlaying을 통한 애니메이션 중복 실행 제어

 

6. View (Minimap, WeaponStatus, Card ... )

  • VM 계층에서 전달받은 데이터들을 통해 Widget의 데이터들을 변경한다.
  • Model <-> ViewModel <-> View 설계로 인해 낮은 결합성을 유지하며, 클라이언트에게 보여주는 계층
  • 각 View들은 객체지향적으로 설계하여 독립적이며 각각의 책임만을 가질 수 있는 설계
    • PlayerStatus는 플레이어 체력/텍스처/실드 등을 관리, Weapon은 탄환, WeaponTexutre만 관리 등. 

 

Git Code Link

 

 

 

팀프로젝트를 진행하며 레벨간 전환시 월드에 액터들을 로드하는 시간동안 게임이 멈춘것처럼 진행하지 않았습니다.

이를 해결하기위해 로딩 스크린을 만들게되었으며 로딩 스크린이 출력하는 동안 언리얼 엔진 내부에서는 월드에 필요한 액터들을 로드하는 작업을 하기위해 비동기 방식으로 로딩 스크린을 구현하게되었습니다.

기존에는 언리얼 C++ 내부에서 비동기 방식의 로딩 스크린을 구현하고자 하였으나, 이를 위해 학습하던 중 

해당 플러그인을 사용하여 간단하게 로딩 스크린을 출력할 수 있게 되어 포스팅하게 되었습니다.

 

ASync Loading Screen Plugin - 언리얼 마켓플레이스

  • 비동기 레벨 로딩 중 커스텀 로딩 화면을 제공하는 플러그인
  • 2023 Epic MegaGrants  수상 및 여러 쇼케이스에서 우수 플러그인 선정
  • 비동기 로딩 (Load Stream Level())을 활용한 비동기 로딩 중 자동으로 로딩 화면 표시 기능
  • 배경이미지, 로딩 애니메이션 등 커스텀 UI 지원
  • 게임 시작 시 또는, 레벨 전환시마다 설정에 따른 로딩 화면 출력 가능
  • 프로젝트 세팅 내에서 간편하게 로딩 스크린 구현 가능

 

연동 과정

 

  • 마켓 플레이스 내 해당 플러그인 라이브러리 추가
  • 에픽 게임즈 내 사용할 엔진 버전에 플러그인 설치
  • Edit - Plugin 내 Async Loading Screen 추가 후 재시작
  • 연동 완료

 

적용 예시

 

Project Settings 내 Async Loading Screen을 사용한 설정이 가능하다.

 

  • Preload Background Images
    • 게임이 시작 시 Background Images로 추가한 이미지들을 미리 불러온다.
    • 하지만, 시작 시 로딩 스크린 외 스타트 스크린이 존재하지 않을 경우 처음 로딩스크린이 로드하는 시간동안 해당 미지가 출력되지 않는 현상이 있다.
  • StartUp Loading Screen
    • 게임 시작 시 한번만 보여줄 화면에 대한 설정
  • Default Loading Screen
    • 레벨 전환 간 로드시 설정한 값들을 보여주는 설정
  • LayOut
    • 화면 전환 간 Text, SideBar 등 Tip과같은 설명에 대한 추가 기능

 

 

  • BeginPlay에 ASync Loading Screen 관련 블루프린트 설정 추가
  • 독립형 게임 / 디바이스 내 실행
    • 뷰포트 실행의 경우 지원하지 않음

 

연동결과

  • 레벨 전환간 해당 로딩 스크린이 출력된다.
  • 설정에 따른 텍스트, 최소 스크린 출력시간, 랜덤 이미지 출력 등 다양한 지원 가능

이렇게 간단하게 해당 플러그인에 대해 작성해보았습니다.

장점은 간편하게 플러그인을 통한 비동기 로딩 스크린을 출력할 수 있었다는 좋은점이 있었지만,

로딩 시 얼마나 진행되었는지를 출력하는 로딩 바 같은 기능은 지원하지 않았습니다.

또한 뷰포트 내 실행시 사용이 불가능 한 점, C++클래스를 사용한 호출이 불가능 한 점 등 아쉬운 부분도 존재하였지만

사용할 로딩 이미지 외 동영상같은 출력 기능등을 지원함 및 블루프린트를 통한 빠른 테스트같은 장점이있었습니다.

이번 포스팅에서는 로딩스크린이 왜 비동기로 출력해야하는지, 그리고 C++를 사용한 유연한 비동기 스크린 제작 같은 과정을 추가하지않았지만, 다음에 로딩 스크린을 구현하게되면 C++내 Load Stream Level()와 같은 기능을 사용하여 상세하게 다뤄보도록 하겠습니다.

 


출처 및 참고내역

https://github.com/truong-bui/AsyncLoadingScreen  

https://www.youtube.com/watch?v=TRpFxT9zGu8

실행 결과

 

 

MVVM패턴 / C++  과정

  • 델리게이트를 사용하여 액터 클래스 / 거리를 건내주는 ActorComponent 기반의 클래스
  • ActorComponent를 부착할 플레이어/몬스터 클래스
  • ViewModel 역할의 MainWidget 클래스 (UUserWidget)
  • View 역할의 MininapWidget 클래스 (UUserWidget)
  • Minimap 내 배치할 MinimapIcon 클래스 (UUserWidget)

※ 미니맵에 현재 LandScape를 보여주기 위해서는 Player에게 카메라를 부착, RenderTarget을 사용한 Texture를 사용.

 

MinimapTracker (ActorComponent)

DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnActorLocation, ACharacter* ,Target, float, Distance);

UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class GUNFIREPARAGON_API UMinimapTracker : public UActorComponent
{
	GENERATED_BODY()
protected:
	virtual void BeginPlay() override;
	virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

private:
	FVector LastLocation;
	void CheckLocationChange(float DeltaSeconds, FVector OldLocation, FVector OldVelocity);

	UFUNCTION()
	void OnOwnerMoved(float DeltaSeconds, FVector OldLocation, FVector OldVelocity);
		
public:
	static FOnActorLocation OnActorLocation;
};

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

	ACharacter* Character = Cast<ACharacter>(GetOwner());
	if (Character)
	{
		LastLocation = Character->GetActorLocation();

		Character->OnCharacterMovementUpdated.AddDynamic(this, &UMinimapTracker::OnOwnerMoved);
	}
}

void UMinimapTracker::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
	Super::EndPlay(EndPlayReason);

	ACharacter* Character = Cast<ACharacter>(GetOwner());
	if (Character)
	{
		Character->OnCharacterMovementUpdated.RemoveDynamic(this, &UMinimapTracker::OnOwnerMoved);
	}
}

void UMinimapTracker::CheckLocationChange(float DeltaSeconds, FVector OldLocation, FVector OldVelocity)
{
	ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
	ACharacter* PlayerCharacter = Cast<ACharacter>(UGameplayStatics::GetPlayerCharacter(GetWorld(), 0));

	if (!OwnerCharacter || !PlayerCharacter) return;

	FVector CurrentLocation = OwnerCharacter->GetActorLocation();
	float Distance = FVector::Dist(CurrentLocation, PlayerCharacter->GetActorLocation());

	if (!CurrentLocation.Equals(LastLocation, 5.0f))
	{
		OnActorLocation.Broadcast(OwnerCharacter, Distance);
		LastLocation = CurrentLocation;
	}
}

void UMinimapTracker::OnOwnerMoved(float DeltaSeconds, FVector OldLocation, FVector OldVelocity)
{
	CheckLocationChange(DeltaSeconds, OldLocation, OldVelocity);
}

 

FOnActorLocation Delegate

  • Target : 미니맵 아이콘을 적용할 대상(Actor Component)를 가지고 있는 Owner를 전송
  • Dist : 게임 프로젝트 기획 시 싱글프로젝트 기반. PlayerController Index 0 빙의 캐릭터와의 거리를 계산

OnCharacterMovementUpdated

  • Character 내 구현되어있는 델리게이트 시그니처. DeltaSecond, OldLocation, OldVelocity 반환
  • 매 Tick마다 전송을 하는것이 아닌, 캐릭터가 이동했을 경우 데이터를 전송함으로써 최적화 하기 위해 사용

OnOwnerMoved / CheckLocationChange

  • 이벤트 바인딩 및 필터링 / OnActorLocation 내 BroadCast하기 전 계산 로직
  • OnOwnerMoved는 이벤트 바인딩 역할하여 전송만 하지만, SRP 및 확장성을 고려하여 분리

현재 로직의 문제점

  • 캐릭터가 움직임을 감지했을 경우 (OnCharacterMovementUpdated)가 호출된 경우만 값이 변경된다.
  • 초기 생성 위치를 전송하지 않는다 / Character가 소멸될 경우에만 제거된다.
    •  BeginPlay시 BroadCast 추가 / Character isHidden() == true 시 BroadCast.

 

MainWidget

UFUNCTION()
void OnMinimapUpdated(ACharacter* Target, float Distance);

// Minimap Rendering Character Distance
float MaxRenderDistance = 1600.f;

void UUserWidget::OnMinimapUpdated(ACharacter* Target, float Distance)
{
	if (Distance <= MaxRenderDistance)
	{
		if (!MinimapWidget->ActiveIcons.Contains(Target))
		{
			MinimapWidget->AddMinimapIcon(Target);
		}

		MinimapWidget->UpdateActorIcon(Target, Target->GetActorLocation());
	}
	else
	{
		MinimapWidget->RemoveMinimapIcon(Target);
	}
}
  • ActorComponent에서 BroadCast한 데이터들을 Minimap에 전송하는 단계.
  • 델리게이트 시그니처를 바인딩하여 해당 함수를 통해 Model 데이터들을 View계층에 전달한다.

 

MinimapIcon

UCLASS()
class GUNFIREPARAGON_API UIngameMinimapIcon : public UUserWidget
{
	GENERATED_BODY()
	
public:	
	UFUNCTION()
	void SetIconTexture(UTexture2D* Texture);
	
	UFUNCTION()
	void SetVisibilityBasedOnDistance(float Distance, float MaxDistance);

protected:
	UPROPERTY(meta = (BindWidget))
	class UImage* IconImage;
};

void UIngameMinimapIcon::SetIconTexture(UTexture2D* Texture)
{
	if (IconImage)
	{
		IconImage->SetBrushFromTexture(Texture, true);
	}
}

void UIngameMinimapIcon::SetVisibilityBasedOnDistance(float Distance, float MaxDistance)
{
	SetVisibility(Distance > MaxDistance ? ESlateVisibility::Hidden : ESlateVisibility::Visible);
}

 

미니맵에 배치할 아이콘에 대한 위젯.

  • SetIconTexture - 동적으로 액터에 적합한 텍스처를 변경하기 위함
  • SetVisibilityBasedOnDistance - 플레이어와의 거리에 따른 아이콘 상태 변경

 

Minimap.cpp

// 전송받은 Target에게 어떤 아이콘을 입힐지, 디자이너 패널 내 어디에 배치할지 결정하는 로직
void UInGameMinimap::AddMinimapIcon(ACharacter* Target)
{
	if (!Target || ActiveIcons.Contains(Target) || !MinimapIconClass) return;

	UIngameMinimapIcon* NewIcon = CreateWidget<UIngameMinimapIcon>(this, MinimapIconClass);
	if (NewIcon)
	{
		RenderCanvas->AddChild(NewIcon);
		ActiveIcons.Add(Target, NewIcon);

		if (Target == PlayerCharacter)
		{
			NewIcon->SetIconTexture(PlayerIconTexture);
		}
		else
		{
			NewIcon->SetIconTexture(EnemyIconTexture);
		}
		int32 ChildCount = RenderCanvas->GetChildrenCount();

		UpdateActorIcon(Target, Target->GetActorLocation());
	}
}

// ICon 삭제에 관련된 로직
void UInGameMinimap::RemoveMinimapIcon(ACharacter* Target)
{
	if (UIngameMinimapIcon** IconPtr = ActiveIcons.Find(Target))
	{
		if (UIngameMinimapIcon* Icon = *IconPtr)
		{
			Icon->RemoveFromParent();
		}
		ActiveIcons.Remove(Target);
	}
}

// 특정 타겟의 거리계산 및 아이콘 배치 위치, 렌더링 여부
void UInGameMinimap::UpdateActorIcon(ACharacter* RenderTarget, FVector WorldLocation)
{
	if (!RenderTarget || !RenderCanvas) return;

	FVector2D MinimapPosition = IconRenderPosition(WorldLocation);

	if (ActiveIcons.Contains(RenderTarget))
	{
		UIngameMinimapIcon* Icon = ActiveIcons[RenderTarget];

		Icon->SetRenderTranslation(MinimapPosition);
		float Distance = FVector::Dist(PlayerCharacter->GetActorLocation(), WorldLocation);
		Icon->SetVisibilityBasedOnDistance(Distance, MaxRenderDistance);
	}
}

// 플레이어의 전방벡터를 받아와, 미니맵에 렌더링되는 방향을 계산한다.
FVector2D UInGameMinimap::IconRenderPosition(FVector WorldLocation)
{
	if (!PlayerCharacter || !RenderCanvas) return FVector2D::ZeroVector;

	FVector Offset = WorldLocation - PlayerCharacter->GetActorLocation();
	Offset *= MinimapScale;

	FVector Forward = PlayerCharacter->GetActorForwardVector(); 
	FVector Right = PlayerCharacter->GetActorRightVector(); 

	float RotatedX = FVector::DotProduct(Offset, Forward);
	float RotatedY = FVector::DotProduct(Offset, Right);

	FVector2D MinimapCenter = FVector2D(RenderCanvas->GetCachedGeometry().GetLocalSize()) * 0.5f;
	FVector2D MinimapPosition = MinimapCenter + FVector2D(RotatedY, -RotatedX);

	UE_LOG(LogTemp, Warning, TEXT("WorldLocation: (%.1f, %.1f) -> MinimapPosition: (%.1f, %.1f)"),
		WorldLocation.X, WorldLocation.Y, MinimapPosition.X, MinimapPosition.Y);

	return MinimapPosition;
}

 

 

※ 현재 로직은 하드코딩된 상태입니다. (확장성을 고려하지 않은상태)

 

UpdateActorIcon

  • 파라미터 타겟에 대한 미니맵과 뷰포트간 실시간 동기화를 위한 로직
  • 해당 로직을 ActiveIcons에 삽입되어있는 Target들을 매 Tick마다 이동값을 계산하여 정합성을 만족한다.

Add/RemoveIcon

  • ActorComponent에 대한 값을 전달받은 후 어떤 텍스처를 입혀줄지 결정하는 단계 / 제거하는 단계
  • 플레이어 / 그외 플레이어(몬스터) 에 대한 렌더링만을 담당한다.
  • 그 외 액터를 추가하여 텍스처를 결정하고 싶은 경우 DataAsset/Table를 활용한 Contains / IsA 검증 후 텍스처 결정.

IconRenderPosition

  • 플레이어를 미니맵 내 정중앙 배치. (MinimapCenter)
  • 플레이어의 전방벡터와 거리를 계산하여 렌더링 할 다른 캐릭터의 위치값 계산, 변환
    • WorldLocation (FVector 3D) -> WidgetLocation (FVector 2D)

 

https://www.youtube.com/watch?v=vBKFwI_JZpE

 

 

출처 및 학습자료


https://www.youtube.com/watch?v=xWbJiCSwxSE&list=PLNTm9yU0zou6xdEqL2QSSfanaZ3KA_vU7&index=4

지난번 포스팅에서 팀 프로젝트에 대한 기획 중 어떤것들을 구현할 것인지에 대해 간단한 설계를 하였습니다.

이번 포스팅에서는 각각의 요소에서 어떤 기능들을 구현할 것인지, 그리고 해당 클래스들을 어떻게 관리할 것인지에 대해 기획한 프로젝트 내 UI들에 대해서는 지난번 포스팅한 MVVM 디자인 패턴을 적용할 것이며, 어떤 방식으로 적용할것인지에대해서는 UML 클래스 다이어그램을 사용하여 작성하겠습니다.

프로젝트를 진행하며 주요 인게임 내 주요 기능에대한 설계만을 포스팅하였습니다.

※ - FPS 팀프로젝트 초기 설계

※ - MVVM이란?  

 

UML 클래스 다이어그램 - InGame

 

 

인게임 - HUD

Gunfire Reborn - 인게임 주요 HUD

 

InGame내 항상 표현하게되는 HUD입니다.

각각 View 계층 역할을 담당하는 UUserWidget 기반의 C++클래스내 UTextBlock, UProgressBar, UImage 등을 

BindWidget을 사용하여 위젯 블루프린트 내 바인딩이 되어있지 않은 경우를 컴파일을 제한하는 형식의 유효성 검사를 언리얼에서 진행합니다.

따라서 해당 HUD 내 동적으로 변경되는 위젯들에 대해서 개발자가 유효성 검사를 하지 않아도 되어 유지보수성 및 코드가독성이 향상되었습니다.

 

MainWidget (ViewModel)

  • Model에서 동적으로 변화되는 값들을 View계층으로 전달하기 위한 위젯 클래스
  • 인게임 내 주요 HUD 및 동적으로 Create,Remove되는 위젯들을 관리
  • 생성보다 Visibility설정을 통한 성능이 좋은 경우 : BindWidget
  • 동적인 환경에서 Create,Remove하는게 성능이 좋은 경우 : TSubclassOf<UUserWidget>, Cast
  • Player, Monster에서 선언한 델리게이트들에 대한 이벤트 바인딩 진행, View계층에 전달.

 

  • Minimap
    • 현재 진입한 레벨의 이름 출력
      • GetWorld()->GetMapName()을 사용한 현재 레벨이름 가져오기.
      • TextBlock->SetText()를 사용한 현재 레벨 출력
    • 현재 진입한 레벨의 진행 시간
      • UGamePlayStatics::GetRealTimeSecond()를 사용한 진행 시간.
      • Text 형식 ("%02d / %02d") , Minutes, Seconds
    • 현재 플레이어 위치 기반의 미니맵 구현
      • DataAsset 또는 DataTable을 사용한 UTexture2D저장
      • IsA 함수 또는 설정한 EnumClass 를 활용한 클래스 타입 비교.
      • Monster, Portal, Player 각각 저장되어있는 Texture2D를 사용한 표시
  • CurrentGold
    • InGame 진입 후 현재 획득한 재화에 관련된 UI
  • PlayerStatus
    • 선택한 Player의 Texture 출력
    • HP,Shield 관련 UProgressBar, UTextBlock를 사용한 체력/실드 표시
      • Model계층이 되는 Player의 Current HP,Shield, Max HP,Shield DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams를 사용한 이벤트 바인딩
      • Tick 호출이 아닌 이벤트 호출시마다 UI변경을 통한 최적화
      • Model에서 BroadCast한 값을 바로 받아오는것이 아닌, VM역할의 MainWidget에서 이벤트 바인딩 후 전달
  • Skill
    • 선택한 Playable Character 기반의 고유 스킬에 따른 Texture
    • Animation을 사용한 쿨타임 시 시각적 표현
  • Weapon/PlayerDodge
    • 공용 로직 Dodge 관련 쿨타임 애니메이션을 사용한 시각적 표시
    • 투척물 관련 UI
    • 현재 사용중인 Weapon의 Texture, CurrentAmmo,MaxAmmo 관련 TextBlock
      • PlayerCharacter 내 Ammo관련 델리게이트 이벤트 바인딩을 사용한 이벤트 처리
  • CrossHairs
    • 공격 입력 IA 호출시 CrossHairs RenderTranslation을 사용한 Spread
    • Projectile 적중 대상이 Monster 클래스인 경우, Animation 관련 Crosshair Visible
      • Target이 치명적인 공격을 받은 경우 Color 변경

 

GitHub

 

프로젝트 소개

  • 프로젝트 명 : GunFire : Paragon
  • 제작 기간 : [25.02.17 ~ 25.03.07]
  • 프로젝트 소개
    • UE5 엔진의 주요 기능을 활용한 로그라이크 기반의 FPS 싱글플레이 슈터게임
    • 건파이어리본의 시스템을 계승받아 만든 게임
  • 담당파트 : UI/UX
    • UMG / UUserWidget 기반의 C++ 클래스를 사용한 로비, 인게임, 결과창 관련 UI 제작
    • 플레이어 정보 및 AI와의 상호작용 관련 UI 제작

 

기술 스택 및 개발 도구

  • C++ 17 (v14.38) / MSVC v143
  • Unreal5.5
  • VisualStudio 2022
  • Git Hub, Git LFS Plugin
  • Notion

 

5W1H

  •  

 

 

담당 파트 UI / UX 구현 로직

인게임 입장 전 메인 로비

건파이어 리본 - 인게임 UI

 

  • 버튼을 통한 관리
    • 도전 시작 - 설정된 Level 오픈
    • 게임 설정 - 게임 설정 (조작키, 사운드, 화면크기 관련 설정)
    • 게임 종료 - 플레이 게임 종료

 

인게임 UI/UX

건파이어 리본 - 인게임 UI

  • Player
    • Texture : 선택된 플레이어 캐릭터 Texture
    • HP, Shield : 현재 플레이어의 실드, 체력
    • Shift, Skill, Item CoolTime : 스킬 사용 가능 및 불가능시의 관련 애니메이션 작동
    • Current Gold : 플레이어가 인게임에서 획득한 골드. 상인과 상호작용 할 수 있다.
  • Weapon
    • 사용중인 무기의 Texture
    • 현재 무기의 남은 탄약 / 장전시 최대 탄약
    • 선택한 탄약의 타입 관련 UI

 

건파이어 리본 - 인게임 UI

  • UObject
    • 플레이어의 버프, 디버프 관련 획득한 패시브 UI
    • 획득한 아이템을 우측 상단을 통해 표시
  • Minimap
    • 플레이어 중심의 존재하는 적 관련 UI를 나타낸다.

 

건파이어 리본 - 인게임 UI

  • 크로스헤어
    • 무기에 따른 조준점 변경 (필수 기능 구현 후 추가 기능)
  • 데미지 히트 관련 위젯
    • 플레이어가 가한 공격력에 따른 Text 출력, 피격지점을 기반으로 Animation 처리
  • 레이캐스트 충돌시 관련 AI HUD
    • 공격받은 또는, 조준중인 대상에 대한 HUD 출력
    • HP, AI Name관련 HUD 출력

 

건파이어 리본 - 카드 선택 UI

  • UObject 기반의 패시브 오브젝트 선택 UI
  • 선택한 UI를 통해 플레이어 강화
  • 3중 택1, 선택한 아이템만 적용
  • 패시브 오브젝트 Name, Texture, Description 출력

 

게임 종료 UI/UX

건파이어 리본 - 결과창 UI

 

  • 플레이한 캐릭터 Texture
  • 플레이 타임
  • 총 가한 데미지
  • 획득한 아이템
  • 사용한 총기

 

UI와 관련된 작업을 하던 중, 일부 매치메이킹 시스템의 구조에 대해 생각하게 되었습니다.

  • 초기 게임모드에서는 빙의되어있는 폰이 필요없다.
  • UI를 통한 매치메이킹 시스템 후 선택된 캐릭터를 빙의한다.
  • 그렇다면, 게임 시작 로딩을 고려하여 UI만 로딩하는것이 어떨까?
  • 프로젝트 설계 시 목적이였던 멀티플레이 환경에서 UGamePlayStatics::OpenLevel는 작동할까?

그 외 다양한 구조를 생각하며 찾아보던 중 플레이어 컨트롤러 내 존재하는 ClientTravel 함수에 대해 알게되었습니다.

 

이번 포스팅에서는 ClientTravel을 사용한 UI 버튼 클릭 시 레벨 이동하기, 그리고 그와 관련된 다양한 함수들에 대해서 알아보겠습니다.

 

Level 생성 / 이동 관련 함수

UGameplayStatics::OpenLevel()

  • 다양한 기능을 가지고 있는 UGameplayStatics를 사용한 레벨 전환
  • 모든 객체를 삭제시킨 후 새로 시작한다.
  • 쉬운 작동법 (GetWorld(), "Open Level FString")
  • 로컬에서 새 레벨을 로드한다.
  • 네트워크 처리를 하지 못해 싱글플레이어 게임 환경에서만 가능하다.

 

APlayerController::ClientTravel()

  • 네트워크 또는 레벨을 이동하는 형식
  • 현재 상태를 유지한다. (플레이어 상태, HUD 등)
  • 클라이언트에서 호출하는 형식.
  • 서버에서 호출되는 경우 특정 클라이언트에게 새 맵으로 이동하라 명령(현재 서버 접속은 유지)
  • 멀티플레이 게임의 경우 ClientTravel, ServerTravel을 사용하여 레벨을 이동한다.
  • ETravelType를 사용한 이동방식 조절 가능
    • ETravelType::TRAVEL_Absolute : 절대경로
    • ETravelType::TRAVEL_Relative : 상대경로
    • ETravelType::Particle : 현재 맵을 유지하며 새로운 서버 연결 시도
  • 서버 연결 시 FString ServerAddress(TEXT("IPv4")를 사용한 이동 가능
    • 해당 주소의 서버에서 어떤 레벨을 연결할 지 결정.
    • 기본값 127.0.0.1 (로컬 PC 주소)

APlayerController::ServerTravel()

  • 서버 전용 레벨 이동
  • 서버를 새 월드/레벨로 점프시킨다.
  • 서버에 접속되어있는 모든 클라이언트들도 함께 이동한다.
    • 모든 클라이언트 플레이어에게 ClientTravel() 호출
  • 멀티플레이 환경에서 서버 IPv4주소에 따른 클라이언트 이동시 LevelURL은 서버에서 처리
    • Ex)GetWorld()->ServerTravel(LevelURL);

 

UEngine::Brose()

  • 새 맵 로드시 하드리셋같은 개념
  • 원활하지 않은 이동 (Non - Seamless)
    • 클라이언트는 서버에서 접속을 끊은 다음 같은 서버에 다시 접속하여 새로 로드할 맵을 준비한다.
  • 데디케이티드 서버의 경우 다른 서버로 이동할 수 없으므로, 맵은 반드시 로컬이여야한다.

 

원활한 이동 (Seamless)

  • 트랜지션 맵을 구성한 상태에서 사용한다.
    • UGameMapsSettings::TransitionMap 프로퍼티를 통해 이루어진다
    • AGameModeBase::bUseSeamlessTravel true 설정 추가 필요
    • 현재 맵에서 트랜지션 맵으로 이동 후, 최종 맵으로 이동하는 형식
    • 새 레벨로 액터를 가지고 가는것이 가능하다.
      • 인벤토리 아이템, 플레이어와 같은 특정 액터

원활하지 않은 이동 (Non-Seamless)

  • Blocking 형태
  • 클라이언트는 접속을 끊은 뒤 서버에 재연결해 맵을 새로 로드한다.

 

 

UGameInstance::LoadMap(MapName, Options)

  • 현재 게임 상태를 유지하며, 새로운 맵을 로드할 경우 사용
  • 멀티플레이 불가
  • OpenLevel()보다 유연하게 사용가능, 직관적이지는 않다.
  • FString Options를 설정한 특정 설정 전달 가능

ULevelStreaming::LoadLevelInstance()

  • 기존 레벨을 유지하며 새로운 레벨을 특정 좌표에 로드하는 기능
  • 오픈월드 게임 혹은 레벨 스트리밍이 필요한 상황에서 사용
  • 동적으로 레벨을 로드하고 배치 할 수 있다.
  • 멀티플레이 불가
  • 동적으로 로드되는 객체들을 관리해야한다.

 

ULevelStreamingDynamic::LoadLevelInstance()

  • LoadLevelInstance와 유사하지만, 여러개의 레벨을 로드 할 수 있다.
  • 특정 오브젝트 로드 가능
  • 객체와 AI관련 추가 설정 필요
함수 멀티플레이 현재 상태 유지 언제
OpenLevel 불가능 불가능 싱글플레이, 새로시작
ClientTravel 가능 가능 멀티플레이 환경 보편적 사용
Browse 가능 가능 새 맵에서의 하드리셋
LoadMap 불가능 가능 현재 게임상태 유지, 새로운 맵 로드시
LoadLevelInstance 불가능 가능 오픈월드
DynamicLoadLevelInstance 불가능 가능 오픈월드, 특정오브젝트 로드, 여러개의 레벨 로드시

 

 

사용 예시

UUserWidget (상속받은 하위 클래스)

 

- GameMode, GameState, GameInstance를 사용하여 위젯 AddToViewPort 설정 완료했을 경우

- 플레이어 컨트롤러 내 설정 FInputModeUIOnly || FInputModeGameAndUI설정 완료했을 경우 

void UUserWidget::NativeOnInitialized()
{
	Super::NativeOnInitialized();
	// 입력 허용
	bIsFocusable = true;
	// 버튼에 대한 OnClicked 이벤트 함수와 바인딩 
	if (StartButton && !StartButton->OnClicked.IsBound())
	{
		StartButton->OnClicked.AddDynamic(this, &UMainLobbyWidget::OpenInGameLevel);
	}
}

void UUserWidget::OpenInGameLevel()
{
	// 이동하려는 레벨 설정
	FString LevelName = TEXT("OpenLevelPath");
  
	// 플레이어 컨트롤러가 존재할 경우
	if (APlayerController* PC = Cast<APlayerController>(GetOwningPlayer()))
	{
		if (PC)
		{
			// 해당 UI는 삭제한다.
			RemoveFromParent();
			// 이동하려는 레벨, 
			PC->ClientTravel(LevelName, ETravelType::TRAVEL_Absolute);
			// UI는 사용하지 않으니 마우스의 입력을 받을 수 있게 모드 변경
			PC->SetInputMode(FInputModeGameOnly());
			// 마우스커서 삭제
			PC->bShowMouseCursor = false;
		}
	}
	else
	{
		UE_LOG(LogTemp, Error, TEXT("Player Controll Nullptr"));
	}

	/* TODO:
	현재는 Replication 적용하지 않은 상태
	Replicated 추가시 매치매이킹 시스템 구현
	1. 서버에 큐 참가 요청
	2. 해당 큐에 일정 인원이 모이면 매치 생성
	3. 서버에서 해당 매칭 완료 결과를 클라이언트에 전달
	--Case : 클라이언트 (로비 UI 내 ) --
	4. 클라이언트는 메시지를 응답받고 게임으로 이동. (ClientTravel)
	--Case : 서버 (게임 세션 생성, 전환)
	4. 새 게임 서버 생성 (ServerTravel)
	5. 클라이언트들에게 새로운 게임 서버로 이동 명령(ClientTravel)

	현재는 로직 구현을 우선순위로 한 싱글플레이 전용으로 구현
	*/
}

 

 


아직은 UProperty 내 Replicated를 추가하지 않은 상태의 개발중이며

Replicated, RPC를 사용한 연동 시 TODO 형식으로 사용 예정입니다.

기본적인 로직을 완료 후 싱글플레이 -> 멀티플레이 전환시 Queue를 사용한 매치메이킹 시스템 추가,

ServerTravel을 사용한 Queue 삽입된 플레이어 이동 관련 구현 예정이며, 추후 관련 포스팅 예정입니다.

 

출처 및 참고자료


https://dev.epicgames.com/documentation/ko-kr/unreal-engine/travelling-in-multiplayer-in-unreal-engine

https://www.reddit.com/r/unrealengine/comments/1fa5e0h/how_to_let_players_enter_different_levels_in/

https://upbo.tistory.com/90

https://dawnarc.com/2017/06/ue4networking-in-basic-travelling-in-multiplayer/

기존의 DataAsset을 사용한 IMC 맵핑 후 UI통한 키리맵핑에 대해서 추가 작성하도록 하겠습니다.

IMC와 매핑한 데이터에셋 UI 연동과정

 

키리맵핑 API 구현시 사용된 함수 및 관련 함수

UI 입력 구조 - Unreal Engine

 

UI 관련

 

NativeOnInitialized

  • 위젯 생성 시 호출되는 UUserWidget내 함수
  • Initialized()와 같이 위젯 생성시 초기화, AddDynamic과같은 바인딩 작업 할 시 사용
  • Initialized는 리턴타입 bool, 내부적으로 초기화시 많은 내부적인 작업을 처리
  • NativeOnInitialized 리턴타입 void, 언리얼 블루프린트와 관련된 OnInitialized 호출
  • 기존의 Native가 아닌 함수들은 BlueprintImplementableEvent로 인한 C++클래스 내 재정의 불가
    • Ex) PreConstruct, OnInitialized 등

bIsFocusable

  • UI위젯이 키보드 입력을 받을 수 있도록 설정하는 변수
  • 기본값 false
  • Set Keyboard, OnKeyDown, OnKeyUp등의 키보드 관련 이벤트 함수 처리 가능

FReply NativeOnKeyDown(FGeometry,FKeyEvent)

  • UI 위젯 내 키보드 키가 눌렸을 때 실행되는 함수
  • ETriggerEvent::Started와 같은 개념
  • bIsFocusable가 활성화 된 경우 UI를 통한 키 입력 처리 가능
  • FGeometry
    • UI 위젯의 위치, 크기, 화면 변환 정보를 포함하는 구조체
    • 배치 정보 및 좌표 변환 시 사용
    • UI 내부에서 마우스나 키보드 입력의 좌표 변환 가능
  • FKeyEvent
    • 키보드 이벤트 정보를 담고있는 구조체
    • 어떤 키가 눌렸는지, 조합키(Shift,Ctrl,Alt)가 눌렸는지 확인 가능
    • GetKey() : 눌린 키 (FKey 타입) 반환
    • Is[Shitft/Control/Alt/Reapeat]()
      • 각각 [Shitft/Ctrl/Alt] 키가 눌렸는지에 대한 여부
    • IsRepeat
      • 키가 반복 입력 중인지 확인
      • 길게 누를시 True
      • ETriggerEvent::Triggered같은 개념
  • FReply 
    • 입력을 어떻게 처리할지에 대한 여부를 결정하는 구조체
    • Handled() : UI에서 입력을 처리했음을 엔진에 알린다. (게임에 전달되지 않는다.)
    • UnHandled() : UI에서 입력을 무시, (게임 혹은 다른위젯에 전달)
      • 해당 위젯이 활성화 되어있음에도, 게임 내 캐릭터가 특정 FKey의 입력을 받을 때 등
    • CaptureMouse(Widget) : 특정 위젯에서 마우스 입력 캡처
    • ReleaseMouseCapure() : 마우스 입력 캡처 해제
    • SetUserFocus(Widget) : 특정 위젯에 UI 포커스 설정
    • SetKeyboardFocus(Widget) : 특정 위젯에서 키보드 입력 유지
    • SetMousePos(Position) : 마우스 커서 위치 설정

 

OnClicked, Pressed,Hovered...

  • UMG UButton버튼 관련 이벤트 처리 함수
  • AddDynamic을 통해 C++에서 이벤트 바인딩
  • OnClicked : 버튼이 클릭될 때 실행
  • OnPressed : 버튼을 눌렀을 때 실행
  • OnReleased :  버튼을 땟을 때 실행
  • OnHovered : 버튼에 마우스가 올라갔을 때 실행
  • OnUnhovered : 버튼에서 마우스가 벗어났을 때 실행

키리맵핑 관련

UInputMappintContext (IMC)

  • 여러개의 키 바인딩을 저장하는 컨테이너
  • 한번의 컨텍스트에 여러 입력 액션을 포함 가능
  • 해당 컨텍스트를 UEnhancedInputLocalPlayerSubsystem*을 통한 입력 제어 변경 가능

 

FEnhancedActionKeyMapping

  • UInputAction과 키를 연결하는 구조체
  • IMC에 포함할 키들을 개별적으로 매핑을 담당
  • 여러개의 키를 하나의 액션에 바인딩 가능
  • FKey, Modifiers,Triggers등 추가 가능

UInputModifier

  • 입력 값을 수정하는 역할
  • IMC의 Modifiers
  • 외 입력값 Value 반대로 변경하는 UInputModifierNegate와 같은 다양한 입력 값 관련 조절 가능
  • IMC에 매핑시 배열형식의 TArray<UInputModifier*> modifiers를 사용한 모디파이어 추가 가능

 

키리맵핑 구현

 

발로란트 - UI통한 키리맵핑 시 참조한 UI

 

  1. ActionName / Binding Action FKey 형식의 구조
  2. 버튼 클릭(OnClicked) 시 SetText()내 입력된 FKey값 " - " 변경
  3. 적용 버튼 클릭 시 변경된 값 적용, 되돌리기를 통해 기본값으로 복원

OptionMenuWidget.cpp

// 초기화
void UUserWidget::NativeOnInitialized()
{
	Super::NativeOnInitialized();
	// ... 유효성 검사
 
	// UButton 이벤트 바인딩 과정
	// IsBound : 이미 바인딩이 되어있는가?
	if (ApplyButton && !ApplyButton->OnClicked.IsBound())
	{
		ApplyButton->OnClicked.AddDynamic(this, &UUserWidget::OnApplyButtonClicked);
	}
	if (ResetButton && !ResetButton->OnClicked.IsBound())
	{
		ResetButton->OnClicked.AddDynamic(this, &UUserWidget::OnResetButtonClicked);
	}
}

// Apply Button OnClicked 
void UUserWidget::OnApplyButtonClicked()
{
	for (auto& KeyMapping : CurrentKeyDataAsset->KeyMappings)
	{
		// Enum to String
		FString ActionName = StaticEnum<EPlayableInputAction>()->GetNameStringByValue(static_cast<int64>(KeyMapping.InputActionEnum));
		// 적용을 누르기 전까지 DataAssets에 반영되지 않는다. TMap<FString,FKey> PendingKeyChanges를 통한 변경 값 임시 저장
		// Apply시 임시 저장한 PendingKeyChanges를 연동한 DataAssets에 반영한다.
		if (PendingKeyChanges.Contains(ActionName))
		{
			KeyMapping.CurrentKey = PendingKeyChanges[ActionName];
		}
	}
	// 적용 후 초기화
	PendingKeyChanges.Empty();

	// PlayController에게 변경된 DataAsset을 통한 키 리맵핑 요청
	if (APlayerController* PC = Cast<APlayerController>(GetOwningPlayer()))
	{
		PC->UpdateCurrentIMC(CurrentKeyDataAsset);
	}
}

// Reset 로직... (생략)
// Apply와 같은 로직, CurrentKey를 DefaultKey로 변경 후 요청
// 임시저장 PendingKeyChanges 초기화

// 델리게이트 Dynamic_Multicast
void UUserWidget::HandleKeyBindingUpdated(UOptionKeyBindWidget* Widget, FKey NewKey)
{
	if (!Widget) return;

	PendingKeyChanges.Add(Widget->CurrentData.ActionName, NewKey);
}

PlayerController

void PlayerController::UpdateCurrentIMC(DataAsset* CurrentDataAsset)
{
    // 동적 환경에서 빙의된 폰이 있는지에 대한 검사.
    // 빙의가 된 경우, Subsystem 최신화, 아닌경우 변경된 DataAsset과 매핑한 IMC만 변경하기위해
    bool bHasPawn = (GetPawn() != nullptr);

    if (ULocalPlayer* LocalPlayer = GetLocalPlayer())
    {
        if (UEnhancedInputLocalPlayerSubsystem* SubSystem = LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>())
        {
            // TMap ControlModeMap : 3인칭,1인칭,그외(자동차, 드론 등)을 구분하는 Key, IMC와DataAssets을 매핑한 Value
            for (auto& ControlMap : ControlModeMap)
            {
                // UI에서 받아온 DataAsset과 일치한 경우를 모두 변경하기위한 조건문 추가
                if (ControlMap.Value.DataAsset == CurrentDataAsset)
                {
                    // IMC 인스턴스 생성
                    UInputMappingContext* NewIMC = NewObject<UInputMappingContext>(this);
                    // IMC 매핑할 Key들을 각각 하나씩 설정한다.
                    for (const FPlayerDefaultInputKeyMapping& Mapping : CurrentDataAsset->KeyMappings)
                    {
                        if (Mapping.InputAction && Mapping.CurrentKey.IsValid())
                        {
                            // 개별 키 FEnhancedActionKeyMapping를 통한 DataAsset내 IA, FKey 설정
                            FEnhancedActionKeyMapping& NewMapping = NewIMC->MapKey(Mapping.InputAction, Mapping.CurrentKey);
                            // 개발자가 미리 설정한 Modifier을 다시 적용한다.
                            // 해당 과정 누락 시 모디파이어를 통한 Ex) FKey A Value -1(Native) -> 1로 변경, 원하는 입력이 안된다.
                            for (UInputModifier* Modifier : Mapping.Modifiers)
                            {
                                if (Modifier)
                                {
                                    NewMapping.Modifiers.Add(Modifier);
                                }
                            }
                        }
                    }
                    
                    // DataAsset과 매핑한 IMC내 IA들을 변경된 NewIMC를 통해 변경
                    ControlMap.Value.IMC = NewIMC;
                    // 빙의된 Pawn/Character이 있을 경우, 실시간 반영을 위한 Subsystem을 통한 IMC 변경
                    // Ex) 인게임 내 조작키 변경 시 실시간 반영
                    if (bHasPawn && ControlMap.Key == CurrentMode)
                    {
                        SubSystem->ClearAllMappings();
                        SubSystem->AddMappingContext(NewIMC, 0);
                    }
                }
            }
        }
    }
}

 

 

구현 결과

https://www.youtube.com/watch?v=atH2eThw2NI

 

 

 

 

 


 

출처 및 참고내역

 

https://community.gamedev.tv/t/nativeoninitialized-may-replace-initialize-and-setup/152157

https://dev.epicgames.com/documentation/ko-kr/unreal-engine/input-fundamentals-for-commonui-in-unreal-engine

https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Runtime/UMG/Components/UButton

 

 

언리얼 부트캠프를 참여하며 UI를 학습하게 되었고, 그에 관련된 프로젝트가 주어졌습니다.

프로젝트는 UI를 통해 다양한 디자인을 하는것이였으며, 제출할 프로젝트는 조작키 변경에 대해 다뤄보고자합니다.

이번 프로젝트에 대한 제출 시 저의 목표는 이렇습니다.

  1. 빙의된 폰/캐릭터 기반의 클래스의 조작키를 런타임 환경에서도 변경, 적용, 기본값 복원
  2. 캐릭터 클래스의 IMC에대한 매핑에 대한 정보를 보관하는 데이터 에셋과 연동, 자동으로 관리
  3. UMG 형식의 UUserWidget기반의 다양한 팔레트/애니메이션 적용하기

 

이번 포스팅에서는 프로젝트 내 UI와 DataAsset의 연동과정에 대해 알아보겠습니다.

 

UMG (Unreal Motion Graphics)

  • 언리얼 엔진의 Widget Blueprint를 사용한 HUD 디자인 방식
  • 직관적이며, Text,Button,Image,Scroll,ListView등 다양한 위젯들이 존재한다.
  • 기본적으로 Animation 기능들을 가지고 있다.
  • 클릭시 색상 변경, 텍스트 무브 등 UI 내 이벤트 연출에 대한 자유도가 높다.

※ HUD(HeadUpDisplay) : 플레이어에게정보를 제공하기 위한 2D 형식의 화면. 

 

위젯 블루프린트 (Widget Blueprint)

  • UI(User Interface)를 시각적으로 설계할 수 있도록 제공되는 에디터용 블루프린트
  • 드래그앤 드롭으로 다양한 위젯들을 배치할 수 있다.
  • Designer : UI 배치 공간
  • Graph : 블루프린트를 사용한 이벤트 그래프를 작성하는 공간

 

연동과정 - 기존 위젯 블루프린트 UI

기존의 강의를 통해 학습받은 UI 적용 과정은 이렇습니다.

  1. UUserWidget를 상속받은 블루프린트 클래스를 생성한다.
  2. 위젯 블루프린트 클래스 내 디자이너에서 다양한 위젯들을 드래그앤 드롭을 통해 배치한다.
  3. HUD를 보여줄 로컬 플레이어의 컨트롤러에서 해당 위젯들을 리플렉션을 통해 바인딩, 로직을 구현한다.
  4. 로직 구현 중 버튼, 텍스트와 같은 기능들은 GetWidgetFromName 함수를 통해 캐스팅한다.

위젯 블루프린트 클래스 - 현재 점수, 레벨, 남은 시간을 나타낸다.

if (UUserWidget* HUDWidget = PlayerController->GetHUDWidget())
{
  if (UTextBlock* TimeText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("Time"))))
  {
  //... Time 관련 로직
  }
  if (UTextBlock* ScoreText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("Score"))))
  {
  //... Score 관련 로직
  }
  if (UTextBlock* LevelText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("Level"))))
  {
  // ... Level 관련 로직
  }
 }

 

위젯 블루프린트 내 사용한 TextBlock들을 캐스팅하는 과정

 

강의를 통해 배운 과정에서는 불편한 점을 가지고 있었습니다.

GetWidgetFromName를 통해 가져올 경우 만약 Score라는 위젯의 이름이 변경시, 블루프린트와 C++클래스가 동시에 변경되어야합니다.

또한, 빌드시 문제가 발견되지않아 문제가 발생시 해당 원인을 명확하게 파악하기 어렵다.

현재 화면에서는 3개의 위젯만을 다루고 있지만, 100개의 위젯을 다루는 경우 동일 로직이 100개 있으며 문제점을 쉽게 파악하기 어렵고, 동일한 코드가 길어집니다. 즉, 유지보수성 및 확장성이 낮습니다.

해당 과정의 문제점을 해결하기 위한 리팩토링 과정에 대해 알아보겠습니다.

 

연동과정 - 리팩토링 후 UI

개선된 UI는 기존 위젯 블루프린트 클래스가 아닌 다른 클래스에서 작성되었습니다.

 

UI 개요

  • 사용자 설정시 키값을 변경하기 위한 UI
  • 현재 로컬 플레이어에 매핑되어있는 입력에 대한 정보들과 동기화.
  • 변경할 조작키의 이름 / [현재 설정된 조작키 / 변경 할 조작키]
  • 적용 버튼을 누를 시 변경된 내용 저장, 그 외는 변경된 내용은 적용하지 않는다.

UI 구조 형식을 개선한 이유

  • 블루프린트 클래스 내 디자인 후 바인딩하면 유지보수성이 낮다.
  • 또한 같은 로직을 가지고있는 UI의 경우에도 GetWidgetFromName을 통해 바인딩 해야하여 유연성 및 확장성이 낮다.

연동 결과 - IMC에 매핑되어있는 값들을 보여주는 UI

0. 기존의 입력관련된 정보를 가지고 있는 DataAsset

IMC에 매핑시 필요한 정보들 FKey, ETrigger, InputAction 및 메타정보들을 구조체 형식으로 보관중인 DataAsset.

 

1. C++ 클래스를 통한 UUserWidget 하위클래스 생성

가장 먼저 UI에 어떠한 데이터들을 노출 해야 할 지 정하는 과정입니다.

Text1 : 현재 InputAction에 대한 정보

Text2 : IMC내 IA와 매핑되어있는 FKey값

 

2. SRP원칙을 준수하여 하위클래스 분리

UI내 보여줄 Text1,Text2에 대한 값들을 저장할 클래스

해당 클래스들의 인스턴스들을 가지고있으며, 화면 출력 시 백그라운드 이미지 및 다른 UI와 연동예정인 클래스

 

FKeyBindingData

USTRUCT(BlueprintType)
struct FKeyBindingData
{
  // ...
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "KeyBinding")
	FString ActionName;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "KeyBinding")
	UInputAction* InputAction;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "KeyBinding")
	FKey DefaultKey;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "KeyBinding")
	FKey CurrentKey;
  // ...
};

위젯 내 필요한 정보들을 분리한 구조체 생성

 

UKeyBindWidget

	UPROPERTY(meta = (BindWidget))
	UTextBlock* ActionText;
	
	UPROPERTY(meta = (BindWidget))
	UButton* KeyButton;

	UPROPERTY(meta = (BindWidget))
	UTextBlock* KeyText;

	UPROPERTY(meta = (BindWidget))
	UBorder* ActionBorder;
	
	
	if (ActionText)
	{
		ActionText->SetText(FText::FromString(Data.ActionName));
	}
	if (KeyText)
	{
		KeyText->SetText(FText::FromString(Data.CurrentKey.ToString()));
	}

 

meta = (BindWidget) 

  • 블루프린트에 동일한 클래스와 동일한 이름을 가진 위젯이 있다면 해당 위젯 포인터를 통해 C++에서 런타임에 엑세스 할 수 있다.
  • 클래스와 동일한 위젯타입,위젯명이 없을 경우 컴파일 오류가 발생하여 문제를 쉽게 파악, 해결할 수 있다.

ActionText->SetText

  • ActionText는 어떠한 String명이 오는지, 변경되는지 확인하지 않는다.
  • 해당 클래스에 사용하려는 위젯들이 많아져도 유효성검사, SetText를 통해 쉽게 바인딩 할 수 있다.

 

UKeyBindWidget을 상속받은 위젯 블루프린트 클래스

BindWidget 리플렉션과 동일한 위젯이 없다면 컴파일 오류가 발생한다.

 

 

 

UMenuWidget

	UPROPERTY(meta = (BindWidget))
	UScrollBox* KeyBindingList;

	UPROPERTY(EditAnywhere, Category = "KeyBindings")
	UInputConfigPrimaryDataAsset* KeyDataAsset;

	UPROPERTY(EditAnywhere, Category = "KeyBindings")
	TSubclassOf<UOptionKeyBindWidget> KeyBindWidgetClass;
  
	void UOptionMenuWidget::NativeConstruct()
	{
		Super::NativeConstruct();
	  
		KeyBindingList->ClearChildren();
  
		// 리플렉션을 통해 등록한 데이터 에셋들을 전부 불러온다.
		for (const auto& KeyMapping : KeyDataAsset->KeyMappings)
		{
		// KeyWidget이 존재하는지
			UOptionKeyBindWidget* KeyWidget = CreateWidget<UOptionKeyBindWidget>(this, KeyBindWidgetClass);
			if (!KeyWidget) continue;
			
			// Enum To String
			FString ActionName = StaticEnum<EPlayableInputAction>()->GetNameStringByValue(static_cast<int64>(KeyMapping.InputActionEnum));
			
			// 구조체 생성, 기존의 KeyBindWidget클래스를통해 생성.
			FKeyBindingData NewData(ActionName, KeyMapping.InputAction, KeyMapping.DefaultKey, KeyMapping.CurrentKey);
			KeyWidget->SetKeyBindWidget(NewData);
			
			// ScrollBar에 자식으로 추가
			KeyBindingList->AddChild(KeyWidget);
		}
	}

 

PreConstruct()

  • UI생성 직전 호출된다.
  • 여러번 호출 할 수 있으며, UI 요소를 미리 설정할 시 사용한다.

 

NativeConstruct()

  • UUserWidget이 생성된 직후 (UI가 화면에 추가되기 전) 호출된다.
  • UI 위젯이 생성될 때 초기화 작업(버튼 바인딩, 데이터 로드, 변수 설정 등)시 사용한다.
  • 여러번 호출화 할 수 있으며, 화면에 추가될 때마다 실행된다.

 

OnInitialized()

  • 위젯이 처음 초기화 될 때 한번만 호출된다.

 

NaticeDestruct()

  • 런타임 내 위젯이 제거될 때 실행된다. 메모리 정리, 리소스 해제시 사용된다.

RemoveFromParent

  • UI가 제거될 때 호출된다.
  • 즉시 삭제되며 위젯을 강제로 제거하는 경우 사용된다.
  • EndPlay()가 Destroy()를 호출하듯이, NativeDestruct()를 호출한다.

 

 

 

해당 과정을 통해 기존의 IMC에 매핑 시 사용한 DataAsset들의 정보 (Key값, 바인딩된 IA, IA와 매핑된 액션의 이름 등)을 가져올 수 있었습니다.

이로인해 런타임 환경에서도 플레이어 컨트롤러 내 사용되는 IMC에 대한 정보에 대한 동기화를 할 수 있었으며, DataAsset에 IA들을 추가하더라도, 위젯에서도 자동으로 연동하여 보여줄 수 있게 되었습니다.

즉, 유지보수성 및 확장성을 향상시킬 수 있었습니다.

이번포스팅에서는 데이터에셋을 통해 UI를 연동한 과정에 대해 간략하게 알아보았으며,

다음 포스팅에서는 조작키를 동적환경에서 어떻게 변경할것인지에 대한 과정을 알아보겠습니다.

 

 

 

+ Recent posts