팀 인벤토리 : 채팅

KWONYEONGMIN·2025년 5월 8일

Project DreamScape

목록 보기
5/5

개요

전투가 끝난 후, 얻은 아이템들을 개인 인벤토리에서 팀원들과 상의하여 팀 인벤토리로 옮길 수 있다. 팀 인벤토리로 옮긴 아이템들은 HUB 공간으로 이동하여 다음 전투에서 사용되고, 개인 인벤토리에 남아 있는 아이템들은 파기가 된다.
팀원들과 채팅을 통하여 소통을 하게 되는데, 이때 필요한 채팅 시스템을 구현하였다.



UI 구성 요소

  • ChatListView

    • UDSChatEntry
      • 프로필 이미지 아이콘
      • 대화 텍스트
  • 입력 필드 (ChatInputWidget)

  • 보내기 버튼 (ChatSendButton )




문제 상황

🔷 채팅 시스템 구조와 델리게이트 바인딩 시점 관리

DSChatComponentDSChatPanel은 각각 네트워크 처리UI 처리를 담당하는 핵심 클래스이다.

DSChatComponentUActorComponent를 상속받아 ServerRPC/ClientRPC를 통한 메시지 전송 기능을 담당하며,

DSChatPanel은 채팅 입력 및 출력을 처리하는 UMG 기반 UI 클래스이다.

이 두 클래스는 Delegate를 통해 연결된다:

  • UI → ChatComponent: 채팅 입력을 Submit하여 서버로 전송
  • ChatComponent → UI: 수신한 메시지를 UI로 전달하여 출력

🔷 델리게이트 바인딩 시점 문제

초기에는 위젯이 언제 생성되고, 어떤 타이밍에 PlayerController와 연결되어 있는지 확실하지 않아

Delegate 바인딩 시점이 불안정한 문제가 발생하였다.

이를 해결하기 위해,

모든 위젯이 DSUserWidget을 상속받도록 하고,

BindEvents() / InitializeWidget() 두 가지 단계로 생명주기를 명확히 나누었다:

함수명설명
BindEvents()Delegate를 연결하는 함수. PlayerController가 존재하는 시점 이후에 호출되어야 함
InitializeWidget()캐릭터 타입 등의 초기 정보를 셋업. Pawn이 스폰된 이후에 호출됨

🔷 바인딩 시점 보장 방법

  • BindEvents()HUD → PrimaryLayout → RegisterLayers() 흐름을 통해 UI가 생성된 이후에 명시적으로 호출
void UDSPrimaryLayout::RegisterLayers()
{
...
    for (const auto& Layer : LayersMap)
    {
        Layer.Value->BindEvents(); // 여기서 모든 Layer에 Delegate 연결
    }
}
  • InitializeWidget()은 캐릭터가 스폰되고 나서 호출되므로 PlayerState에 접근하여 CharacterType 등의 초기화 가능
void UDSWidgetLayer::NativeConstruct()
{
    Super::NativeConstruct();
    ...
    DSEVENT_DELEGATE_BIND(GameEvent.OnCharacterSpawned, this, &UDSWidgetLayer::InitializeWidgets);
}

단, OnCharacterSpawned캐릭터 수만큼 여러 번 호출되므로, 이 시점에 Delegate를 바인딩하면

중복 연결 위험이 있어 사용하지 않도록 주의해야 한다.


구현

🔷 서버 → 클라이언트 메시지 전송 로직 (RPC)

채팅 입력은 서버로 전송되고, 서버는 특정 조건을 만족하는 클라이언트들에게만 메시지를 보낸다.

void UDSChatComponent::ServerRPC_SendChatMessage_Implementation(const FText& Message)
{
	...
	// 메시지 보낸 사람의 타입
	const ECharacterType SenderType = PlayerState->GetCharacterType();

	// 서버 월드의 모든 PlayerController 순회
	for (FConstPlayerControllerIterator It = GetWorld()->GetPlayerControllerIterator(); It; ++It)
	{
		ADSPlayerController* OtherPC = Cast<ADSPlayerController>(It->Get());
	 ...
		
		// 자기 자신 제외
		if (GetOwner() == OtherPC)
		{
			continue;
		}

		ADSPlayerState* OtherPS = Cast<ADSPlayerState>(OtherPC->PlayerState);
		if (false == IsValid(OtherPS)) 
		{
			continue;
		}

		const ECharacterType ReceiverType = OtherPS->GetCharacterType();

		// 타입이 같은 사람 제외
		if (ReceiverType == SenderType)
		{
			continue;
		}
		// 해당 클라이언트의 ChatComponent에 메시지 전송
		if(IsValid(OtherPC->GetChatComponent()))
		{
			OtherPC->GetChatComponent()->ClientRPC_ReceiveChatMessage(SenderType, Message);
		}
	}
}

✅ 핵심 조건

서버는 다음 조건을 모두 만족하는 클라이언트에게만 메시지를 보낸다

  • 메시지를 보낸 자기 자신은 제외
  • 보낸 사람과 CharacterType이 같은 클라이언트는 제외
  • → 결국 다른 타입의 캐릭터를 조종 중인, 나를 제외한 모든 클라이언트 에게만 메시지 전송

🔷 클라이언트 처리

void UDSChatComponent::ClientRPC_ReceiveChatMessage_Implementation(const ECharacterType SenderType, const FText& Message)
{
    OnChatReceived.Broadcast(SenderType, Message); // UI에 전달
}

UI에서는 OnChatReceived Delegate를 통해 메시지를 수신하고, ChatPanel이 이를 받아서 출력한다.

  • MulticastRPC를 사용하지 않은 이유
    • 캐릭터 타입 기반 필터링이 필요한 경우에는 Client RPC로 선별 전송하는 구조가 더 적절하기 때문

채팅 전송 과정에서 발생하는 서버 ↔ 클라이언트 간의 흐름

채팅 메시지는 UI 위젯(DSChatPanel)에서 입력되어 DSChatComponent를 통해 서버로 전송되며,

서버는 현재 접속 중인 모든 PlayerController를 순회하면서 조건에 맞는 클라이언트에게만 메시지를 전달한다.

이 구조는 모든 플레이어에게 브로드캐스트하지 않고, 자기 자신이 아닌, 그리고 캐릭터 타입이 다른 플레이어만을 대상으로 선별 전송하기 때문에

불필요한 네트워크 비용을 줄이고 구조적 명확성을 확보할 수 있다. 메시지를 수신한 클라이언트는 자신의 DSChatComponent를 통해 Delegate를 통해 UI로 메시지를 전달하고, 이를 채팅창에 출력한다.




테스트 영상


https://youtu.be/9-5pEjCyocg




회고

언씬 프로그램을 할 때, 채팅을 구현하시는 분들을 보면서 한번 구현해보고 싶었던 기능이다. RPC 함수 호출에 대한 이해도, UI에 대한 이해도가 있어야 구현할 수 있는 기능이기 때문에...
실제 팀 프로젝트에서 구현하면서 느낀점은 이미 틀이 짜여진 UI 시스템과 멀티플레이 환경에서 이벤트 바인딩 시점, 초기화 시점이 중요하다는 걸 느꼈다..!
+) 벨로그는 영상 올리는 게 너무 불편하다 ㅠㅠ

profile
Hello World

0개의 댓글