팀내 담당 : C++ 게임플레이 / 네트워크 로직 전반
1. 프로젝트 개요
FunnyorDie는 최대 8인이 하나의 세션에 모여 술래(Tagger) 하이더(Hider)들을 제한 시간 안에 찾아내는 멀티플레이 파티 게임
레벨 & 페이즈 흐름
[레벨] StartMaps -> LobbyMaps -> DemoMaps
[페이즈] Warmup → AssignRole → Scouting(60s) → InGame(300s) → GameOver
- Start: EOS 로그인, 방 만들기/찾기
- Lobby: 방장 지정, 인원 대기, 방장의 Start로 매치 개시
- DemoMaps: 페이즈 상태 머신이 돌아가는 본게임 맵
2. 데이터 소유권
| 데이터 | 위치 | 이유 |
| EOS 세션, ExpectedPlayerCount | GameInstance | 레벨 전환(Travel)에도 파괴되지 않는 유일한 객체 |
| 매치 페이즈, 생존자 수, 승자, 순위표 | GameState | 모든 클라이언트가 봐야 하는 매치의 현재 상태 |
| 역할(RoleTag), 생존 여부, 사망 시각 | PlayerState | 캐릭터는 재스폰되어도 플레이어 정보는 매치 내내 유지되어야 함 |
| 페이즈 전환 타이머, 역할 배정, 포획 판정 | GameMode | 서버에만 존재 -> 치팅 불가능한 결정 권한 |
| 위장/무적/스턴 같은 순간 상태 | Character | 그 캐릭터가 살아있는 동안만 의미 있는 값 |
3. 핵심 시스템
3-1. EOS 세션 시스템 : 데디케이티드에서 리슨 서버로
프로젝트 초기 설계는 데디케이티드 서버였다
그러나 별도 서버 빌드 운영 비용과 배포 복잡도를 고려해 리슨 서버 + EOS P2P로 전환했다
이 전환은 단순 설정 변경이 아니라 코드베이스 전반의 버그 수정을 일으켰다
리슨 서버에서는 호스트가 곧 서버이자 클라이언트라는 이중 신분이 생기기 때문이다
GameInstance::Init()이 게임에서 가장 이른 진입점이라는 점을 이용해 EOS AutoLogin을 시작한다
Init → AutoLogin → OnLoginComplete(Ready)
호스트: HostSession → CreateSession → OnCreateSessionComplete → ServerTravel("LobbyMaps?listen")
클라: FindAndJoinSession → FindSessions → JoinSession → GetResolvedConnectString → ClientTravel
3-2. 로비 시스템 : 방장 권한과 인원 관리
로비의 책임 : 방장 지정, 방장 이탈 대비, 매치 시작 검증
- PostLogin에서 첫 입장자를 방장으로 지정(bIsHost = true, PlayerState에 복제)
- Logout에서 나간 사람이 방장이면 PromoteNewHost()로 PlayerArray[0]에게 위임
여기서 순서가 중요한데, Super::Logout()이 PlayerArray에서 이탈자를 제거해주므로 재지정은 반드시 그 이후에 해야 나간 사람이 다시 방장이 되는 사고를 막음 - TryStartMatch(Requester)는 클라이언트 UI 버튼 -> ServerRPC_RequestStartMatch -> GameMode로 전달되는데, 검증은 전부 서버에서 한다(요청자가 진짜 방장인가 + MinPlayersToStart(3명) 이상인가)
- 시작이 확정되는 순간 GameInstance->ExpectedPlayerCount = 현재 인원을 기록하고 ServerTravel
+ 로비에서는 캐릭터가 주는 가치 대비 유지 비용이 크다고 생각해서 DefaultPawnClass = nullptr + LobbyCam 태그 카메라에 SetViewTarget하는 고정 샷 방식으로 결정
3-3. 페이즈 상태 머신
매치 진행은 GameMode의 타이머 체인
StartPlay → WaitForPlayers(0.5s 폴링) → AssignRoles → StartScouting → StartInGame → EndMatch
각 단계는GameState->SetPhase()로 현재 페이즈를 기록하고, 이 값이 ReplicatedUsing = OnRep_CurrentPhase로 전 클라이언트에 퍼진다
SetPhase = 값 대입 + 수동 OnRep
언리얼의 OnRep은 클라이언트에서만 자동 호출되기 때문에 서버(리슨 호스트 포함)는 자기 화면을 위해 OnRep을 직접 불러줘야 하는데, 이걸 매번 기억하는 대신 SetPhase() 안에 값 대입과 수동 OnRep 호출을 묶어 만들었다
페이즈 UI 스위치보드
각 위젯이 스스로 페이즈를 감시하게 하는 대신 OnRep_CurrentPhase -> UpdatePhaseUI() 한 곳에서 "정찰 배너는 Scouting일 때만, 카운트다운은 Scouting/InGame일 때만, 순위표는 GameOver일 때만" 식으로 생성/제거를 선언적으로 관리한다
3-4. 역할 배정과 역할별 폰 스폰
술래와 하이더는 아예 다른 캐릭터 클래스라서 역할이 정해지기 전에는 폰이 스폰되면 안 된다
생성자에서 DefaultPawnClass = nullptr로 기본 스폰을 차단하고 엔진 훅 GetDefaultPawnClassForController_Implementation을 오버라이드한다
(이 함수는 엔진이 폰을 스폰하려 할 때마다 "이 컨트롤러에겐 어떤 클래스를 줄까?"를 묻는 지점인데,
여기서 PlayerState의 RoleTag를 읽어 TaggerClass/HiderClass를 분기 반환한다)
역할이 아직 None이면 부모 구현으로 흘려보내고, 부모는 DefaultPawnClass(nullptr)를 돌려주므로 스폰이 스킵된다
AssignRoles()의 흐름: 랜덤 인덱스 1명을 술래로 -> 전원의 RoleTag를 PlayerState에 기록(복제)
-> 그 다음 전원 RestartPlayer()로 스폰 재요청
3-5. 타이머/카운트다운
처음엔 서버가 매초 복제하도록 설계했다가, 네트워크 최적화를 시도해보라는 말씀이 생각나서 이 부분에 한 번 적용해봤다
서버는 페이즈 시작 시 PhaseEndServerTime = 서버시각 + 지속시간을 딱 한 번 복제
-> 클라이언트는 GetServerWorldTimeSeconds()(엔진이 제공하는 클라에서도 조회 가능한 서버 동기화 시계)에서 이 값을 빼 남은 시간을 스스로 매 프레임 계산한다
복제가 1회로 끝나고 표시도 프레임 단위로 매끄러워졌다
3-6. 포획 판정
[TaggerCharacter] 좌클릭 → Server_TryCapture → 콜리전 0.2초만 활성화 → Overlap 감지
[GameMode] RequestCaptureJudgement: 게임 규칙 검증 -> 시퀀스 시작 지시
[TaggerCharacter] StartCaptureSequence: 양쪽 입력 잠금 + 술래에게 판정 팝업(Client RPC) + 15초 타이머
[GameMode] ResolveCapture: 사망 기록, 생존자 감소, 관전 재배치, 종료 판정
- 콜리전을 상시 켜두지 않는다
공격 입력이 들어온 순간에만 QueryOnly로 0.2초 활성화 후 다시 끈다
스치기만 해도 잡히는 게 아니라 공격 액션에 반응하는 판정이 된다 - 캐스팅으로 대상 좁히기
Overlap 콜백에서 Cast<AFDHiderCharacter>로 좁혀 술래끼리 부딪히거나 소품에 닿는 경우를 걸러낸다 - 아웃 / 봐주기(Spare) 분기
판정 팝업에서 술래는 즉시 아웃시키거나 봐줄 수 있다
봐주면 하이더는 일정 시간 무적 + 속도 버프를 받고 무적 중인 하이더는 Overlap 단계에서 판정이 스킵된다 - 검증(Validate) RPC
Server_RequestOut/Spare에는 _Validate를 붙여 "청자가 실제로 술래 폰을 조종 중인가를 서버에서 확인한다
3-7. 사망 처리와 관전 시스템
잡힌 하이더는 Destroy하지 않고 SetActorHiddenInGame(true) + 콜리전 오프 + 이동 잠금으로 숨김 처리만 한다
파괴하면 PlayerState-Pawn 연결, 관전 시점 전환, 순위 계산에 필요한 참조가 전부 끊어지기 때문이다
관전은 UpdateSpectators()가 담당한다
FindLivingViewTarget()이 살아있는 하이더를 우선 탐색(없으면 술래 폴백)하고, 죽은 플레이어들의 컨트롤러에 Client_SetSpectateTarget RPC를 보낸다
-> 클라 쪽에서 SetViewTargetWithBlend(0.5f)로 부드럽게 전환한다
3-8. 아이템 시스템
UFDItemInventoryComponent가 하이더 캐릭터에 붙어 획득/보관/사용/UI 방송을 전담한다
TMap이 아니라 슬롯 변수로 만들었다 (TMap 복제하는 법을 몰라서...)
InvisibilityCount, ThrowItemCount를 변수로 분리하고 각각 DOREPLIFETIME 등록했다
- 획득: 서버에서 개수 증가 -> OnRep_Inventory() 수동 호출 -> Client_NotifyItemAcquired로 주운 본인에게만 알림
한도 초과면 증가 없이 Client_NotifyInventoryFull 호출 - 사용: Server_UseItem에서 소모를 효과 실행보다 먼저 처리한다
- UI: 개수 변화는 OnInventoryChanged 델리게이트 방송 -> WBP가 구독해 갱신
아이템 3종
- 투명화: bIsItemInvisible을 복제 변수로 두고 OnRep에서 시각 처리 지속시간은 DataTable
- 투사체(술래 스턴): 조준 진입 시 전용 1인칭 AimCamera로 전환한다
눈높이에 카메라를 직결하면 화면 중앙 = 발사 방향이 보장되기 때문에
궤적 예측선과 실제 투사체(AFDThrowItem)의 속도/중력 값이 어긋나면 보이는 궤적과 실제 경로가 달라지므로 두 곳의 수치를 반드시 일치시켜야 한다는 제약이 있었다
그리고 포물선을 그리며 떨어지는 구조라서 에임 크로스헤어 뿐만이 아니라 데칼을 덧붙여서 더 정확한 조준감을 주려고 했다 - 소리: Multicast_PlayNoise로 전 클라이언트에서 하이더 위치에 사운드 재생 (아이템 획득시 위험요소 추가 - 밸런스 조절용)
스턴 처리
-> 술래 피격 시 bIsStunned 복제 + Client_LockMovement로 술래 이동 잠금
이미 스턴 중이면 연장하지 않는다 -> 하이더 여럿이 투사체를 연달아 던져 술래를 영구히 묶어두는 밸런스 붕괴를 차단한 것
3-9. 순위 시스템
잡힌 순간 PlayerState에 DeathServerTime(서버 시각, -1이면 생존)만 기록해두고 EndMatch 시점에 FinalizeHiderRanking()이 이를 정렬해 TArray<FHiderRankEntry>(이름/순위/생존시간)를 GameState에 채운다
-> 순위 배열을 먼저 채우고 나서 SetPhase(GameOver)를 부른다
+ 배열 복제 완료 시 OnRep_HiderRankings -> OnRankingsUpdated.Broadcast()로 위젯이 다시 그리게 했다
3-10. 밸런스 DataTable
정찰 시간, 본게임 시간, 포획 판정 대기(15초), 무적 시간, 정찰 속도, 기본 속도 등 밸런스 수치를 FMatchBalanceSettings DataTable 한 곳에 모았다
코드에는 항상 폴백 기본값을 두고, 테이블이 있으면 덮어쓰는 방식이다
'프로젝트 > Funny Or Die (멀티플레이 술래잡기 게임)' 카테고리의 다른 글
| 포획된 하이더를 사라지게 하고 살아있는 사람 시점으로 넘기기 (0) | 2026.07.18 |
|---|---|
| [트러블슈팅] 하드 트래블 재접속 레이스 잡기 (0) | 2026.07.18 |
| 게임 시작 UI 디자인 (0) | 2026.07.18 |
| 순위창 UI 제작 (0) | 2026.07.18 |
| 순위표 WBP 생성 (0) | 2026.07.18 |
