ROGUEFORGE / 개발 일지

화면을 둘로 나누자,
세계의 전제가 드러났다

· 네트워크와 렌더링 · Windows 1.5.52

왼쪽 플레이어는 야외에, 오른쪽 플레이어는 조명이 켜진 건물 안에 있는 실제 분할 화면
같은 시뮬레이션에서 한 명은 야외, 다른 한 명은 실내에 있다. 각 카메라가 볼 지형과 빛을 따로 결정해야 한다.

지난 글에서는 협동의 기반을 만들기 시작했다고 적었습니다. 이번에는 그 기반을 실제 게임 루프에 연결했습니다. 온라인 게스트의 이동을 예측하고, 호스트가 보낸 결과로 보정하며, 한 컴퓨터에서는 두 사람이 서로 다른 곳을 바라보게 만드는 작업입니다.

가장 먼저 잡힌 문제는 테스트와 실제 실행 경로의 차이였습니다. 이동 검사는 통과했는데, 실제 게스트의 게임 루프에서는 그 이동 코드가 실행되지 않았습니다.

통과한 테스트가 놓친 한 줄

게임 루프는 호스트일 때만 시뮬레이션을 갱신하고 있었습니다. 월드의 최종 상태를 호스트가 결정하는 구조에서는 필요한 제한입니다. 그런데 기존 이동 테스트는 게스트의 전체 시뮬레이션 갱신 함수를 직접 호출했습니다. 실제 게임에서 닫혀 있던 문을 테스트가 열고 들어간 셈입니다.

게스트에서도 전체 갱신을 켜면 당장은 걸을 수 있습니다. 대신 몬스터 AI, 채집, 전투, 드롭까지 양쪽에서 실행됩니다. 아이템이 생성되는 시점과 피해 판정이 서로 달라지고, 다음 호스트 스냅샷이 도착하면 로컬 결과가 덮입니다.

그래서 게스트 전용 이동 틱을 따로 연결했습니다. 자기 캐릭터의 이동만 예측하고, 월드와 피해 판정은 호스트에 남겼습니다. 이동 속도 계산과 지형 충돌은 기존 이동 경로를 사용합니다. 네트워크용으로 별도의 걷기 규칙을 만들면 단차나 과적 같은 조건에서 두 경로가 어긋나기 때문입니다.

실제 입력 경로도 다시 따라갔습니다. 테스트용 이동 프레임과 키보드·패드가 부르는 함수가 달랐고, 점프·조준·일반 공격 일부는 송신에 연결되지 않았습니다. 검사는 이제 실제 입력 API를 거쳐 고정 틱에서 메시지가 나가는지 확인합니다.

과거 위치를 받았을 때, 지금까지의 입력을 다시 계산한다

호스트의 위치는 도착하는 순간 이미 과거입니다. 수신한 좌표를 그대로 대입하면 캐릭터가 주기적으로 뒤로 끌려갑니다. 이번 구현에서는 입력마다 증가하는 번호를 붙이고, 호스트가 마지막으로 처리한 번호를 AckSeq로 돌려줍니다.

  1. 게스트는 30Hz 틱에서 입력을 보내고 자기 이동을 예측한다.
  2. 입력과 시간 간격을 최대 128개까지 보관한다.
  3. 호스트 위치와 확인 번호가 도착하면, 확인된 입력을 버린다.
  4. 호스트 위치에서 아직 확인되지 않은 입력만 다시 적용한다.

예를 들어 입력 105번까지 움직인 상태에서 102번까지 처리한 결과가 오면, 102번의 확정 위치 위에 103~105번을 다시 계산합니다. 예측과 확정 상태의 차이는 이렇게 정리합니다.

작은 위치 보정은 기존 렌더 보간에 맡기고, 실내·지하 전환이나 큰 위치 변화는 즉시 반영합니다. 서로 다른 층을 가로질러 부드럽게 보간하면 벽을 통과하는 것처럼 보이기 때문입니다. 점프 높이와 수직 속도, 도약 준비 시간, 수레 진행 방향도 스냅샷에 넣었습니다. XY만 맞춰서는 다음 이동을 같은 조건에서 계산할 수 없습니다.

재실행 비용도 제한했습니다. 입력 기록은 고정 크기 버퍼에 두고, 이동에 필요한 탐색 보조 데이터는 입력 하나마다 다시 만들지 않고 재실행 묶음에서 한 번 준비합니다. 이것은 전체 월드의 시간을 되감는 방식이 아닙니다. 호스트가 확정한 월드 상태를 바탕으로 로컬 이동만 다시 계산합니다.

같은 시드가 같은 현재 상태를 뜻하지는 않는다

같은 월드 시드로 나무와 돌을 만들 수 있어도, 누가 나무를 베었는지까지 재현되지는 않습니다. 기존 스냅샷을 점검하니 채집물·동물·바닥 드롭의 상태 전달이 빠져 있었습니다. 호스트에서 없어진 물건이 게스트 화면에는 남을 수 있는 구조였습니다.

전송 범위는 수신 플레이어 주변 64타일로 제한하고, 상태의 성격에 따라 갱신 주기를 나눴습니다.

현재 주변 월드 상태의 전송 기준
대상갱신 기준
플레이어·투사체스냅샷 15Hz
동물·바닥 드롭5Hz
채집물추가·제거·피격, 시점 셀 변경, 120틱 주기 재전송

채집물은 매번 모든 것을 보내지 않습니다. 버전과 피격 변경을 확인하고, 플레이어가 8타일 단위의 시점 셀을 이동할 때 주변 목록을 갱신합니다. 다만 변경이 생기면 주변 목록 전체를 보내는 방식이므로, 대규모 채집 상황의 전송량은 더 측정해야 합니다.

메시지의 빈 값도 구분했습니다. null은 “이번에는 변경 내용을 보내지 않음”, 빈 목록은 “현재 아무것도 없음”입니다. 둘을 같게 처리하면 마지막 레일이나 마지막 드롭을 제거했을 때 게스트에 잔상이 남습니다.

투사체는 화면에 필요한 궤적 상태만 전달합니다. 소유자와 피해 대상 객체를 그대로 직렬화하면 캐릭터의 다른 상태까지 줄줄이 따라 들어갈 수 있습니다. 게스트는 날아가는 모습을 표시하고, 명중과 피해는 호스트가 계산합니다.

입력의 주인은 메시지 안에 적힌 이름으로 정하지 않는다

호스트는 입력 본문에 적힌 플레이어 번호를 그대로 믿지 않습니다. 등록된 실제 송신자를 기준으로 실행 대상을 결정합니다. 오래된 이동 시퀀스는 무시하고, 비정상 이동 값은 거릅니다. 입력이 0.5초 동안 끊기면 마지막 방향으로 계속 걷지 않도록 정지시킵니다.

재접속에서는 반대 문제가 생깁니다. 새 클라이언트가 낮은 번호부터 입력을 보내는데 호스트가 이전 확인 번호를 기억하면, 새 입력을 전부 오래된 것으로 판단합니다. 따라서 재참가 시 입력 상태를 초기화하고, 인벤토리·건물·레일의 전체 상태를 즉시 다시 보내도록 했습니다.

연결 종료로 숨겨 둔 캐릭터를 복구할 때는 체력과 사망 상태도 보존합니다. 무조건 살아 있는 상태로 돌려놓으면 재접속이 무료 부활 수단이 됩니다. 연결 상태와 게임 안의 생존 상태를 별도로 다뤄야 했습니다.

카메라 두 개를 만들자, 한 명만 따라가던 월드가 드러났다

서로 멀리 떨어진 두 플레이어를 각각 추적하는 좌우 분할 화면
같은 야외 평면을 공유할 때 지형 루트를 복제하지 않는다. 대신 두 시점 주변의 청크를 모두 준비한다.

분할 화면은 카메라의 사각형만 절반으로 줄여서는 끝나지 않았습니다. 지형 스트리밍이 1P만 따라가면 멀리 이동한 2P의 화면 가장자리가 비었습니다. 같은 평면에서는 하나의 지형 루트를 공유하되, 두 시점이 요구하는 청크의 합집합을 준비하고 가까운 시점부터 생성하도록 연결했습니다.

한 명이 실내로 들어가면 조건이 달라집니다. 실내와 야외는 같은 XY를 사용할 수 있으므로 카메라마다 볼 평면과 레이어를 분리해야 합니다. 2P의 실내 소품도 전역의 “현재 실내”를 읽는 대신 자기 플레이어의 평면 정보를 사용합니다.

조명은 한 번 더 분리해야 했습니다. 사용 중인 URP 2D 조명은 카메라의 일반 오브젝트 마스크와 같은 방식으로 걸러지지 않습니다. 지형 레이어만 나눠도 실내 횃불이 같은 좌표의 야외를 밝힐 수 있었습니다. 카메라 렌더 직전에 해당 평면의 전역광과 점광 세기를 적용하고, 렌더 뒤에 복원하는 경로를 사용합니다.

이처럼 전역에 하나 있던 값들이 두 시점에서는 서로 다른 의미를 갖습니다. 현재 플레이어, 현재 실내, 현재 광원 상태가 대표적입니다. 화면이 늘어날 때마다 월드를 통째로 복제하기보다, 공유할 데이터와 시점마다 달라질 데이터를 구분하는 데 작업의 대부분을 썼습니다.

위아래 화면에서 각 플레이어를 독립적으로 추적하는 상하 분할 화면
상하 분할. 카메라와 UI가 같은 정규화 뷰포트 정의를 사용한다. 위는1P, 아래는2P다.

좌우와 상하는 공통 뷰포트 정의 하나를 참조합니다. 카메라만 나누고 HUD나 가방 창이 전체 화면 기준으로 배치되는 어긋남을 막기 위해서입니다. 상하 전환에서는 직교 카메라의 세로 시야도 함께 조절하고, 분할을 끝내면 원래 값으로 복구합니다.

검사한 경로와 아직 남아 있는 경로

이번 작업은 고유한 편집 모드 검사 66개와 실제 게임 통합 검사 1개를 통과했습니다. 지연을 넣은 직렬화 메시지 전달, 입력 재실행, 월드 틱 중복 방지, 레일·드롭 제거, 재접속 상태 보존을 확인했습니다. 실제 장면에서는 좌우·상하 전환, 슬롯별 UI 영역, 야외와 실내의 지형·소품·조명 분리를 확인했습니다.

본문 사진은 실제 로컬 분할 장면을 두 카메라로 캡처한 것입니다. UI Toolkit HUD는 이 렌더 텍스처 캡처에 포함되지 않아 별도의 실제 레이아웃 검사로 확인했습니다. 인터넷으로 연결한 두 기기의 화면을 촬영한 것은 아닙니다.

코드는 Windows 1.5.52에 반영했습니다. 온라인 최대4인과 로컬2인 분할은 별도 모드이며, 두 모드의 혼합 접속은 아직 지원하지 않습니다. 실제 두 기기의 Steam·릴레이 연결, 장시간 패킷 유실, 대규모 전송량 측정도 남아 있습니다. 테스트가 통과했다는 사실과 실회선에서 검증했다는 사실은 구분해 기록하려고 합니다.