언리얼 프로젝트를 진행하며, 느슨한 결합을 유지하기위해 델리게이트를 사용하게 되었습니다.

델리게이트를 사용 하던 중, 델리게이트는 기존에 학습하였던 옵저버 패턴(Observer Pattern)을 사용한 것 같다고 느껴졌습니다.

이를 기반으로 옵저버패턴의 정의와 특징 그리고 게임 클라이언트 환경에서 옵저버 패턴을 적용한 경우들에 대해 알아보겠습니다.

※ 델리게이트 (Delegate) - https://mynameiskgws.tistory.com/16

 

https://ko.wikipedia.org/wiki/%EC%98%B5%EC%84%9C%EB%B2%84_%ED%8C%A8%ED%84%B4

옵저버/감시자 패턴 (Observer Pattern) 정의 / 특징

  • 관찰자(Observer)들이 관찰하고 있는 대상자(Subject)의 상태가 변경시에 대상자는 각 관찰자들에게 알림(Eventg)을 전달, 관찰자는 알림을 수신받아 변화에 대한 조치를 취하는 행동
    • 통보를 받은 관찰자가 값을 변경, 삭제, 알림 등 대응을 한다.
  • 1:N의 의존성을 가지며, 분산 이벤트 핸들링 시스템을 구현하는데 사용한다.
    • 한개의 대상자 (Subject) :  관찰자 (Observer A,B,C...)
  • 관찰 대상자(Subject) 인터페이스와 관찰자(Observer) 클래스로 구분되어 사용한다.
  • Observer들은 Subject의 그룹에서 추가/삭제가 동적으로 이루어진다.
  • 한 객체의 상태가 변경되면 다른 객체도 변경해야 할 때, 변경되는 다른 객체들이 변경되어야 하는지 몰라도 될 때

 

장점

  • 대상자의 상태 변경을 주기적으로 조회하지 않고 자동으로 감지할 수 있다.
  • Observer클래스를 추가/삭제 시 Subject의 코드를 변경하지 않기 때문에 OCP원칙을 준수하며, 느슨한 결합성을 유지한다.
  • 런타임 시점에 관계를 맺는다.
    • 프로그램 코드가 상호작용되어야하는지 확인할 때 정적으로 알 수 없으며, 명령 실행과정을 동적으로 추론해야하기 때문에 단점 일 수 있다.

단점

  • 동기적(Syncronized)이다.
    • Observer중 하나라도 느릴 경우, 대상이 블록 될 수 있다.
  • Observer를 멀티스레드, 락과 함께 사용시에는 교착상태에 빠질 수 있다.
    • 이벤트 큐를 사용하여 비동기적으로 상호작용하여 안정적으로 처리할 수 있다.
  • 알림 순서를 제어할 수 없다. 즉, 무작위 순서로 알림을 받는다.
  • 옵저버 객체를 등록 후 해지하지 않는다면, 메모리 누수가 발생할 수 있다.

 

사용 예시

  • 델리게이트(Delegate)를 사용하여 캐릭터의 체력이 변경될 경우 UI에 자동으로 반영
    • Subject : PlayerCharacter
    • Observer : UI
    • Event(Delegate) : DelegateSingature
  • 게임의 업적 기능
  • Java의 라이브러리 (java.util.Observer), C#의 Event
  • 유튜브 (채널 : Subject / 구독자 : Observer)
  • MVC패턴 (Model : Subject / View: Observer)

출처 및 참고내역

https://inpa.tistory.com/entry/GOF-%F0%9F%92%A0-%EC%98%B5%EC%A0%80%EB%B2%84Observer-%ED%8C%A8%ED%84%B4-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EB%B0%B0%EC%9B%8C%EB%B3%B4%EC%9E%90 

 

도서 - 게임 프로그래밍 패턴

 

'디자인패턴' 카테고리의 다른 글

[디자인패턴] - MVC, MVVM 패턴  (1) 2025.02.18
[디자인패턴] - 프록시 패턴 (Proxy Pattern)  (0) 2025.02.07
Factory Method Pattern  (0) 2024.12.24
Command Pattern  (2) 2024.12.23
디자인 패턴의 정의 및 종류  (0) 2024.12.05

팀 프로젝트 미니맵 UI 제작 관련 중 발생한 문제입니다.

※ 미니맵 관련 포스팅 - https://mynameiskgws.tistory.com/50

미니맵을 초기에 설계했을 때, 각 액터 컴포넌트에 타임 핸들러를 사용하여 일정 시간마다 위치값을 전송하려고 했습니다.

이 때 각 액터 컴포넌트들이 타임핸들러를 가지고 있을 경우, 미니맵에 100개의 몬스터가 렌더링 된다 하면 타이머도 100개를 소지하게 되며 메모리 낭비 및 오버헤드 발생 우려가 있었습니다.

이를 해결하기 위해 액터의 시간을 전송하는 싱글톤패턴 기반의 Minimap Manager를 설계하게 되었습니다.

 

UObject 기반의 Manager 클래스

DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnTargetUpdated, const TArray<FMinimapTargetLocation>&, UpdateLocations);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnTargetRemoved, AActor*, RemovedTarget);

UCLASS()
class GUNFIREPARAGON_API UMinimapManager : public UObject
{
	GENERATED_BODY()
	
public:
	void Initialize();
	void RegisterTarget(AActor* Target);
	void UnRegisterTarget(AActor* Target);
	void RecvLocationToBroadCast();

public:
	UPROPERTY(BlueprintAssignable, Category = "Minimap")
	FOnTargetUpdated OnAllLocationsUpdated;

	UPROPERTY(BlueprintAssignable, Category = "Minimap")
	FOnTargetRemoved OnTargetRemoved;

private:
	TMap<AActor*,FVector> TargetLocations;
	FTimerHandle UpdateTimer;

	void UpdateTargetsLocation();
};

void UMinimapManager::Initialize()
{
	TargetLocations.Empty();
}

void UMinimapManager::RegisterTarget(AActor* Target)
{
	if(!GetWorld())
	{
		UE_LOG(LogTemp, Display, TEXT("World Is Null"));
		return;
	}
	if (IsValid(Target) && !TargetLocations.Contains(Target))
	{
		TargetLocations.Add(Target, Target->GetActorLocation());

		if (!UpdateTimer.IsValid())
		{
			UpdateTargetsLocation();
		}
		UE_LOG(LogTemp, Display, TEXT("Register Test : %s"), *Target->GetName());
	}
}

void UMinimapManager::UnRegisterTarget(AActor* Target)
{
	if (TargetLocations.Contains(Target))
	{
		TargetLocations.Remove(Target);
		OnTargetRemoved.Broadcast(Target);
	}

	if (TargetLocations.Num() == 0)
	{
		if (GetWorld())
		{
			GetWorld()->GetTimerManager().ClearTimer(UpdateTimer);
		}
	}
}

void UMinimapManager::RecvLocationToBroadCast()
{
	TArray<FMinimapTargetLocation> TargetData;

	for (const auto& Target : TargetLocations)
	{
		if (Target.Key)
		{
			FMinimapTargetLocation Data;
			Data.TargetActor = Target.Key;
			Data.Location = Target.Key->GetActorLocation();
			TargetData.Add(Data);
		}
		else
		{
			UnRegisterTarget(Target.Key);
		}
	}

	OnAllLocationsUpdated.Broadcast(TargetData);
}

void UMinimapManager::UpdateTargetsLocation()
{
	float UpdateCycleTime = 0.1f;
	if (GetWorld())
	{
		GetWorld()->GetTimerManager().SetTimer(UpdateTimer, this, &UMinimapManager::RecvLocationToBroadCast, UpdateCycleTime, true);
	}
}

 

해당 클래스를 기존에는 static Get을 통한 싱글톤 패턴의 기반으로 하였으나,

미니맵에 해당 액터클래스를 등록하는 과정에서 GetWorld()가 Null이 되는 로그가 출력되었습니다.

이를 해결하기 위해, GameInstance에서 등록을 하였지만, 마찬가지로 GetWorld()가 Null이 출력되며, 크래시되는 현상이 발생하였습니다.

해당 과정은 UObject 기반의 클래스를 정적 으로 사용시에 발생한 문제입니다. 

원인 분석

  • 게임 컨셉이 레벨과 레벨을 이동하는 과정이 발생한다.
  • 해당 과정에서 레벨이 다시 실행 될 경우, 액터 라이프사이클과 같이 새 월드를 가져온다.
  • 해당 과정에서 전역 클래스인 MinimapManager를 재 호출하게되며, 순서를 보장하지 않은 상태.
  • 이로인해 MinimapManager를 다시 불러오며 동시에 새 월드가 생성되기 전 호출되어 Null반환을 하게된다.
    • 순서가 보장이 되지않는다 -> 매 실행마다 출력되는 로그가 다름. Null일경우, 아닐경우 존재를 통해 검증.
  • --- 다른 원인 ---
  • AActor에서 파생된 모든 클래스는 배치 할 수 있는 클래스. 즉, 자신이 어느 World인지 인지 할 수 있다.
  • UObject는 GetWorld()를 호출 할 수 있으나, 배치 되지 않은 경우 의미가 없음.

 

해결 과정

  • 게임 시작부터 유지되는 GEngine()->GetWorld... 를 사용한 호출
    • 수명동안 다른 세계를 로드할 수 있으므로 특정 세계를 반환할 수 없음. 실패.
  • 게임 시작부터 끝까지 데이터를 가지고 있는 Instance에서 생성, 초기화에서 호출
    • 동일 현상, 원인 파악을 못했음. 실패.
    • 기본 자료형 또는 함수는 사용 할 수 있으나, 월드간 변화시 동일 월드가 아니라서 발생한것같다.
  • Actor기반의 클래스 내 호출하여 사용하기
    • 해당 액터의 GetWorld()를 가져오기 때문에 정상적으로 작동.
    • 하지만, BeginPlay()후 GetWorld()를 호출하여 사용하면 초기 설계시 고려한 문제가 발생한다. 실패.
  • Timer를 사용하지 않고, Delegate를 사용한 이벤트 기반으로 리팩토링
    • 특정 조건에 바인딩하여 이벤트 기반 데이터 전송. 구현 성공

 

정리

  • UObject에는 GetWorld() 함수가 존재하기는 하나, 실제로는 null을 반환 할 수 있다.
  • GEngine()->GetWorld()... 를 사용한 호출은 서로 다른 세계를 가져올 수 있어 적합하지 않다.
  • Instance 내 GetWorld()를 사용할 순 있지만, 정적 클래스에는 부적합 (추정)
  • Timer 사용을 하지 않은 Delegate 이벤트 바인딩, 전송으로 인한 우회, 구현 성공

 


참고내역

https://forums.unrealengine.com/t/gengine-getworld-return-null/377571

https://husk321.tistory.com/426

https://www.inflearn.com/community/questions/989392/getworld-%EA%B0%80-nullptr?srsltid=AfmBOookfoyJK0gL44HvTFW_aOKR6EPWRV0Fc8a7JPtwvrUCHOoRy5xI

 

'Unreal Engine > Debugging' 카테고리의 다른 글

[UE5] - 유효성 검사  (0) 2025.01.27

실행 결과

 

 

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

+ Recent posts