전투가 끝난 후, 얻은 아이템들을 개인 인벤토리에서 팀원들과 상의하여 팀 인벤토리로 옮길 수 있다. 팀 인벤토리로 옮긴 아이템들은 HUB 공간으로 이동하여 다음 전투에서 사용되고, 개인 인벤토리에 남아 있는 아이템들은 파기가 된다.
팀원들과 채팅을 통하여 소통을 하게 되는데, 이때 필요한 채팅 시스템을 구현하였다.
ChatListView
입력 필드 (ChatInputWidget)
보내기 버튼 (ChatSendButton )
DSChatComponent와 DSChatPanel은 각각 네트워크 처리와 UI 처리를 담당하는 핵심 클래스이다.
DSChatComponent는 UActorComponent를 상속받아 ServerRPC/ClientRPC를 통한 메시지 전송 기능을 담당하며,
DSChatPanel은 채팅 입력 및 출력을 처리하는 UMG 기반 UI 클래스이다.
이 두 클래스는 Delegate를 통해 연결된다:
Submit하여 서버로 전송초기에는 위젯이 언제 생성되고, 어떤 타이밍에 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를 바인딩하면
중복 연결 위험이 있어 사용하지 않도록 주의해야 한다.
채팅 입력은 서버로 전송되고, 서버는 특정 조건을 만족하는 클라이언트들에게만 메시지를 보낸다.
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이 이를 받아서 출력한다.

채팅 메시지는 UI 위젯(DSChatPanel)에서 입력되어 DSChatComponent를 통해 서버로 전송되며,
서버는 현재 접속 중인 모든 PlayerController를 순회하면서 조건에 맞는 클라이언트에게만 메시지를 전달한다.
이 구조는 모든 플레이어에게 브로드캐스트하지 않고, 자기 자신이 아닌, 그리고 캐릭터 타입이 다른 플레이어만을 대상으로 선별 전송하기 때문에
불필요한 네트워크 비용을 줄이고 구조적 명확성을 확보할 수 있다. 메시지를 수신한 클라이언트는 자신의 DSChatComponent를 통해 Delegate를 통해 UI로 메시지를 전달하고, 이를 채팅창에 출력한다.
언씬 프로그램을 할 때, 채팅을 구현하시는 분들을 보면서 한번 구현해보고 싶었던 기능이다. RPC 함수 호출에 대한 이해도, UI에 대한 이해도가 있어야 구현할 수 있는 기능이기 때문에...
실제 팀 프로젝트에서 구현하면서 느낀점은 이미 틀이 짜여진 UI 시스템과 멀티플레이 환경에서 이벤트 바인딩 시점, 초기화 시점이 중요하다는 걸 느꼈다..!
+) 벨로그는 영상 올리는 게 너무 불편하다 ㅠㅠ