오늘 한 것
언리얼 엔진 5 팀 프로젝트의 로비 씬을 단순 대기 공간에서 플레이어블 공간 + 외형 커스터마이징이 가능한 씬으로 발전시키는 설계
목표 기능 정의
기능 설명
| 플레이어블 로비 | L_Lobby에서 Top-Down 이동 + 마우스 클릭 공격 가능 |
| Customize 버튼 | 클릭 시 이동 잠금 + 카메라 전환 + 커스터마이즈 UI 오픈 |
| 무기 선택 | 3종 고정, 개별 버튼 클릭으로 즉시 미리보기 |
| 외형 선택 | 머티리얼 4종 이상, 좌/우 화살표 버튼으로 순환 |
| Apply | 서버 RPC 전송 → PlayerState 갱신 → 전체 클라이언트 복제 |
| Cancel | 이전 값 복원 (서버 RPC 없음), UI 닫기 |
아키텍처 설계
데이터 흐름
[로컬 클라이언트]
버튼 클릭
→ ApplyCustomizationLocal() (즉시 로컬 미리보기)
[Apply 클릭 시]
PlayerController
→ ServerRPC_SetCustomization(WeaponIdx, MatIdx)
→ Server: PlayerState.SelectedWeaponIndex / SelectedMaterialIndex 갱신
→ OnRep_ 자동 발동
→ 모든 클라이언트: ApplyCustomization() 호출
에셋 관리 원칙
- PlayerState : int32 인덱스만 저장 (가볍게 유지)
- PlayerCharacter Blueprint : 무기 메시 배열, 머티리얼 배열 EditDefaultsOnly로 보관
- 에셋 자체를 네트워크로 전달하지 않고 인덱스만 복제
핵심 설계 결정 3가지
1. ReplicatedUsing=OnRep_ 방식
PlayerState에 SelectedWeaponIndex, SelectedMaterialIndex 두 개의 int32를 복제 변수로 선언한다. 서버에서 값이 바뀌는 순간 언리얼 엔진이 자동으로 모든 클라이언트에 OnRep_ 함수를 호출해 준다.
- 별도 Multicast RPC 없이도 전원에게 동기화
- 인덱스 값 하나만 복제하므로 네트워크 부하 최소
2. Blueprint 부착 컴포넌트를 C++에서 접근
무기 StaticMeshComponent는 Blueprint에서 WeaponSocket에 붙어있고 C++에 변수 선언이 없다. C++에서 접근하려면 GetComponents<UStaticMeshComponent>()로 전체를 순회한 뒤 소켓 이름으로 필터링한다.
GetComponents → 소켓 이름 "WeaponSocket" 비교 → SetStaticMesh
이 방법 덕분에 Blueprint 구조를 변경하지 않아도 된다.
3. SeamlessTravel 대응 - OnRep_ 타이밍 안전망 2중 구조
SeamlessTravel로 맵 이동 시 PlayerState가 먼저 복제되고, 이후에 Pawn(PlayerCharacter)이 생성된다. 따라서 OnRep_ 호출 시점에 Pawn이 없을 수 있다.
안전망 호출 시점 역할
| OnRep_SelectedWeaponIndex / OnRep_SelectedMaterialIndex | PS 복제 직후 | Pawn이 이미 있으면 즉시 적용 |
| OnRep_PlayerState() (PlayerCharacter) | Pawn이 PS와 연결된 직후 | 뒤늦게 도착한 경우 적용 |
둘 중 하나라도 성공하면 외형이 적용된다.
인터랙션 플로우
로비 입장 시
입장 → Top-Down 카메라 + 이동 가능 (FInputModeGameAndUI)
→ LobbyWidget 표시 (Ready 버튼, Customize 버튼)
Customize 버튼 클릭
PlayerController.OpenCustomizeWidget()
→ LobbyWidget 숨김 (Collapsed)
→ CustomizeWidget 생성 + 뷰포트 추가
→ FInputModeUIOnly 전환
→ Character.SetCustomizeMode(true)
→ 이동 잠금 (로컬 DisableMovement, 서버 RPC 불필요)
→ SpringArm: 절대회전→상대회전, Pitch=-15, Yaw=180, Length=450
무기 선택 (버튼 3개)
WeaponButton 클릭 → CurrentWeaponIdx 갱신 → ApplyCustomizationLocal() → 즉시 미리보기
머티리얼 선택 (좌/우 순환)
← 버튼: (idx - 1 + total) % total
→ 버튼: (idx + 1) % total
→ ApplyCustomizationLocal() → 즉시 미리보기
Apply 클릭
ServerRPC_SetCustomization(WeaponIdx, MatIdx) 전송
Character.SetCustomizeMode(false) ← 이동 복구, 카메라 복구
PlayerController.CloseCustomizeWidget()
→ CustomizeWidget 제거
→ LobbyWidget 다시 표시
→ FInputModeGameAndUI 복구
Cancel 클릭
Character.ApplyCustomizationLocal(PreviousWeaponIdx, PreviousMatIdx) ← 이전 외형 복원
Character.SetCustomizeMode(false)
PlayerController.CloseCustomizeWidget()
※ 서버 RPC 없음, PlayerState 변경 없음
수정/신규 파일 목록
파일 구분 주요 변경
| SMPlayerState.h/cpp | 수정 | SelectedWeaponIndex, SelectedMaterialIndex 복제 변수 + OnRep_ 추가, CopyProperties에 인덱스 복사 추가 |
| SMPlayerCharacter.h/cpp | 수정 | SetCustomizeMode(), ApplyCustomizationLocal(), ApplyCustomization() 추가, OnRep_PlayerState()에서 외형 적용 |
| SMPlayerController.h/cpp | 수정 | ServerRPCSetCustomization(), OpenCustomizeWidget(), CloseCustomizeWidget() 추가, ShowLobbyWidget 입력모드 수정 |
| SMCustomizeWidget.h/cpp | 신규 | 커스터마이즈 UI 전체 로직 |
| SMLobbyWidget.h/cpp | 수정 | Customize 버튼 바인딩 추가 |
설계 중 고민했던 것들
Q. 이동 잠금을 서버에 알려야 하나?
A. No. 클라이언트에서 DisableMovement()를 호출하면 입력 자체가 차단되어 서버로 이동 패킷이 가지 않는다. 서버 캐릭터는 자연히 멈춘다. ServerRPC 불필요.
Q. CustomizeWidget을 LobbyWidget에서 바로 열면 안 되나?
A. 안 된다. LobbyWidget이 깔린 채로 CustomizeWidget을 열면 UI가 겹친다. PlayerController가 LobbyWidget을 숨기고, InputMode를 UIOnly로 전환한 뒤 CustomizeWidget을 여는 구조가 맞다.
Q. 에셋 배열을 PlayerState에 두면 안 되나?
A. PlayerState는 모든 클라이언트에 복제된다. 메시/머티리얼 에셋 자체를 복제하면 네트워크 낭비가 크다. 인덱스만 복제하고 에셋은 각 클라이언트의 PlayerCharacter Blueprint에서 로컬로 참조한다.
Q. PossessedBy()에서 ApplyCustomization()을 호출하면?
A. PossessedBy()는 서버에서 호출된다. 서버는 비주얼 처리가 필요 없으므로 호출하지 않는다. 클라이언트 전용인OnRep_PlayerState()에서 호출하는 것이 맞다.
배운 점
- OnRep_ 단독으로는 타이밍 문제가 생길 수 있다. SeamlessTravel 이후 Pawn 생성 순서가 보장되지 않으므로, OnRep_PlayerState()로 두 번째 안전망을 두는 패턴이 필요하다.
- 인덱스 복제 + 로컬 에셋 참조 패턴은 멀티플레이에서 외형 커스터마이징을 구현할 때 가장 효율적인 방법이다.
- UI 라이프사이클 관리는 PlayerController에서 하는 것이 깔끔하다. 위젯끼리 서로를 알 필요가 없어지고, InputMode 전환도 한 곳에서 처리된다.
'언리얼 게임개발' 카테고리의 다른 글
| [팀 프로젝트 회고] 2026-04-24 | 팀 프로젝트 회고 (0) | 2026.04.27 |
|---|---|
| [TIL] 2026-04-16 | GameplayCueNotify에서 무기 소켓 위치 올바르게 가져오기 (0) | 2026.04.16 |
| [TIL] 2026-04-06 | UnrealEngine에서의 Null체크: IsValid(Thing), Thing.IsValid, !Thing의 차이점 (0) | 2026.04.06 |
| [TIL] 2026-04-02 | UnrealEngine에서의 TObjectPtr과 Raw포인터의 사용 (0) | 2026.04.02 |
| [팀 프로젝트] 2026-04-01 | Steam Session Subsystem 기획 (0) | 2026.04.01 |