팀내 담당 : 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 한 곳에 모았다

코드에는 항상 폴백 기본값을 두고, 테이블이 있으면 덮어쓰는 방식이다

+ Recent posts