턴제 온라인 게임 개발 회고 #Project 2201

이번 프로젝트에서는 턴제 방식의 물리 액션 게임 클라이언트를 개발했다.

코어 메카닉 자체를 처음부터 기획한 프로젝트는 아니다. 기존 프로젝트에서 정의되어 있던 게임 규칙과 플레이 방식을 기준으로 클라이언트 구현을 진행했다.

기본적인 게임 방식은 유닛을 당겨 발사하고, 지형이나 다른 유닛과 충돌하면서 결과가 만들어지는 구조였다. 한 유닛의 행동이 끝나면 턴이 넘어간다.

처음 보면 구조가 단순해 보이지만 실제로 구현하다 보니 물리, 네트워크, AI, UI가 서로 꽤 많이 얽혀 있었다.

물리 기반 조작

모바일 게임이라 조작부터 손을 봤다.

화면을 누른 위치를 기준으로 조작할 수 있는 플로팅 조이스틱을 만들었고, 당긴 방향과 거리를 이용해서 발사 방향과 Power를 계산했다.

너무 조금 당긴 상태에서 실수로 발사되는 것도 막아야 해서 데드존을 뒀다. Power가 0.3 이하라면 발사하지 않고 취소하도록 했고, 그 이상부터 실제 발사에 사용할 값을 다시 계산했다.

실제 발사는 Rigidbody2D.AddForce()를 이용한 Impulse 방식으로 처리했다.

F = Normalize(Direction) * (Power * ShootPower) * TotalUnitSpeed

유닛의 질량도 별도로 계산해서 같은 힘을 받더라도 유닛마다 움직임이 조금씩 다르게 나오도록 했다.

발사 중에는 카메라가 같이 뒤로 빠지도록 만들었다. 힘을 많이 줄수록 더 넓은 범위를 볼 수 있어야 했기 때문이다.

예상 궤적

물리 게임이다 보니 발사 전에 어디로 날아갈지 보여주는 기능도 필요했다.

처음에는 Raycast를 사용하려고 했는데 실제 유닛의 크기를 제대로 반영하기 어려웠다. Ray는 선으로 검사하기 때문에 벽 모서리나 좁은 공간에서 실제 충돌 결과와 차이가 생겼다.

그래서 CircleCastAll을 사용했다.

유닛 반지름의 0.9배 정도를 Cast 반경으로 사용하고, 충돌하면 충돌점과 법선을 구해서 반사 벡터를 계산했다.

충돌 -> 법선 계산 -> 반사 벡터 계산 -> 다음 충돌 검사

이걸 여러 번 반복해서 예상 궤적을 만들었다.

궤적에 사용하는 화살표와 점도 매번 생성하지 않고 풀링해서 재사용했다. 이런 부분은 처음에는 별것 아닌 것 같지만 계속 발사하다 보면 생성과 삭제가 꽤 많이 발생한다.

물리와 네트워크

네트워크 대전을 붙이면서 물리를 직접 제어할 필요가 생겼다.

이 게임에서는 물리 결과 자체가 게임 결과라서 단순히 각 클라이언트에서 Unity 물리를 돌리는 것만으로는 부족했다.

Physics2D.SimulationMode2D.Script를 사용하고 Physics2D.Simulate()를 직접 호출했다.

FixedUpdate -> Physics2D.Simulate() -> Physics Update Event -> 물리 상태 수집 -> 네트워크 데이터 생성

이렇게 물리 업데이트 시점을 직접 잡아두니 네트워크로 보낼 상태를 어느 시점에 수집해야 하는지도 명확해졌다.

Freeze Frame이나 슬로우 모션도 있었기 때문에 일반 게임 시간과 물리 시간을 분리해서 관리했다.

다만 Unity Physics2D가 완전한 결정론적 물리를 보장하는 것은 아니기 때문에 모든 환경에서 100% 동일한 결과를 만드는 방식으로 접근하지는 않았다. 실제 게임에서 필요한 수준의 동기화를 맞추는 쪽으로 잡았다.

RPC는 [RPC] 어트리뷰트를 붙인 메서드를 초기화할 때 찾아 MethodInfo를 캐싱하는 방식으로 만들었다.

매번 리플렉션을 수행하지 않고 Dictionary에 등록된 정보를 사용하도록 했다. Vector2, ContactPoint2D[] 같은 데이터도 직렬화할 수 있도록 별도로 처리했다.

네트워크 지터 처리

패킷을 받는 즉시 실행하면 네트워크 상태에 따라 화면이 튀는 문제가 있었다.

그래서 수신 데이터를 바로 적용하지 않고 Queue에 넣어두고 어느 정도 쌓인 다음 순서대로 재생했다.

WebSocket -> 압축 해제 -> Deserialize -> Queue -> 버퍼 확보 -> 순차 재생

프로젝트에서는 대략 50개 정도의 패킷을 버퍼링했다.

버퍼가 너무 크면 화면은 안정적이지만 실제 플레이와 차이가 커지고, 너무 작으면 네트워크 지터가 그대로 보인다. 결국 둘 사이에서 적당한 값을 잡는 작업이 필요했다.

JSON 데이터는 GZip으로 압축했고 환경에 따라 대략 65~75% 정도 크기를 줄일 수 있었다.

AI를 Ray에서 물리 시뮬레이션으로 바꾸다

AI는 개발하면서 방식이 가장 많이 바뀐 부분 중 하나였다.

처음에는 Ray를 여러 방향으로 쏴서 적과 벽을 확인하고 행동을 결정하는 방식으로 만들었다.

그런데 Ray만으로는 실제 물리 결과를 제대로 알기 어려웠다.

벽에 부딪힌 다음 어디로 튕기는지, 다른 유닛과 다시 충돌하는지, 최종적으로 어느 위치에 도착하는지까지 전부 판단하기가 어려웠다.

문제는 여기서 끝나지 않았다.

AI가 처리하지 못하는 지형은 레벨 디자인에서도 사용하기 어려워졌다. 사람이 플레이하기에는 재미있는 구조인데 AI가 판단하지 못해서 맵을 단순하게 만들어야 하는 상황도 생겼다.

그래서 AI가 직접 물리를 실행해보도록 바꿨다.

여러 각도와 힘으로 행동 후보를 만들고 각각을 실제 물리 시뮬레이션에 넣었다.

행동 후보 생성 -> 물리 시뮬레이션 -> 결과 확인 -> 점수 계산 -> 후보 정렬 -> 행동 선택

각 후보마다 예상 피해량, 적과의 거리, 자신의 위치, 위험도 등을 기준으로 점수를 계산했다.

그리고 점수순으로 정렬한 다음 AI 난이도에 따라 선택 범위를 다르게 했다.

높은 난이도에서는 좋은 행동을 선택하고, 낮은 난이도에서는 일부러 조금 떨어지는 후보도 선택할 수 있게 했다.

단순히 AI의 공격력이나 반응속도를 낮추는 방식보다 실제 판단 자체를 다르게 만들 수 있었다.

후보를 테스트할 때는 실제 게임 상태를 건드리지 않도록 별도의 물리 상태를 사용했다.

현재 상태 복사 -> 후보 시뮬레이션 -> 결과 확인 -> 상태 복구 -> 다음 후보

후보가 많아질수록 계산량이 커지기 때문에 각도와 힘의 범위, 후보 수를 제한했다.

이 작업을 하면서 물리 기반 게임에서는 AI도 결국 물리를 어느 정도 직접 사용해야 한다는 생각이 들었다.

UI 구조

UI가 많아지면서 데이터를 UI에서 직접 가져오는 방식도 조금씩 문제가 생겼다.

그래서 데이터 모델과 UI를 분리하고 Observer 방식으로 변경 사항을 전달했다.

Model -> 데이터 변경 -> Observer -> UI 업데이트

플레이어 목록이나 게임 데이터가 변경될 때 UI가 계속 확인하는 게 아니라 모델에서 변경됐다는 사실을 알려주는 방식이다.

팝업도 여러 개가 동시에 호출되는 경우가 있어서 Queue로 순서를 관리했다.

팝업 요청 -> Queue -> 팝업 표시 -> 입력 대기 -> 종료 -> 다음 팝업

씬이 바뀌면 대기 중인 팝업을 정리하고 실행 중인 코루틴도 종료하도록 했다.

모바일 입력에서는 일반 클릭과 길게 누르는 입력을 구분하기 위해 DeepPushButton도 만들었다.

스크롤바는 기본 ScrollRect를 그대로 사용했을 때 핸들이 너무 작아지는 문제가 있어서 CustomScrollRect로 최소 크기를 지정했다.

UI 리스트 풀링

아이템이나 유저 목록처럼 데이터가 많은 UI는 전부 만들어두지 않았다.

예를 들어 100개의 아이템이 있어도 화면에 실제로 보이는 것은 10개 정도다. 나머지 90개까지 전부 UI 오브젝트를 만들어놓을 필요는 없었다.

그래서 UI 리스트에 풀링과 가상화를 적용했다.

전체 데이터 -> Visible Range 계산 -> 필요한 UI 생성 -> 화면 밖 UI 반환 -> Pool 재사용 -> 데이터 바인딩

화면 밖으로 나간 UI는 풀에 돌려놓고 새로 화면에 들어온 데이터에 다시 사용했다.

이렇게 하면 데이터가 많아져도 실제 UI 오브젝트 수는 화면에 필요한 만큼만 유지할 수 있다.

일반적인 Object Pooling이 생성과 삭제 비용을 줄이는 것이라면 UI 리스트에서는 애초에 필요 없는 오브젝트를 만들지 않는 것도 꽤 중요했다.

Live2D와 카메라

로비에서는 Live2D Cubism을 사용했다.

캐릭터를 터치하면 시선이 움직이고 표정이나 모션이 변경되도록 구성했다. 캐릭터 데이터가 변경되면 Observer를 통해 해당 캐릭터의 Live2D 프리팹을 교체하고 파라미터도 초기화했다.

게임에서는 Cinemachine을 사용해 카메라 연출을 만들었다.

발사 Power에 따라 카메라를 뒤로 빼고, 충돌 데미지에 따라 Perlin Noise를 이용한 카메라 셰이크 강도를 변경했다.

특정 상황에서는 유닛 전용 카메라로 전환했고 Freeze Frame이나 슬로우 모션도 물리 이벤트와 연결했다.

런타임 맵 에디터

맵 제작을 편하게 하기 위해 Unity Editor가 아닌 런타임에서 사용할 수 있는 3D 블록 에디터도 만들었다.

Raycast로 블록의 어느 면을 클릭했는지 확인하고 그 방향으로 새로운 블록을 배치했다.

Raycast -> 충돌 면 확인 -> 위치 계산 -> Grid Snap -> 블록 생성

블록 위치를 계산하다 보니 부동소수점 오차도 생겼다. 위치가 아주 조금씩 어긋나는 문제가 있어서 Math.Round()와 별도의 비교 처리를 넣었다.

Undo / Redo는 Stack<BlockHistoryData>로 구현했다.

여러 블록을 한 번에 생성한 경우에는 각각을 따로 기록하지 않고 하나의 작업으로 묶어서 한 번에 되돌릴 수 있게 했다.

맵 데이터는 저장할 때 X -> Y -> Z 순서로 정렬했다. 같은 맵인데 저장 순서가 달라져서 불필요한 변경이 생기는 것을 줄이기 위해서였다.

Addressables로 리소스 관리

씬이 많아지고 프리팹이나 공용 리소스가 늘어나면서 리소스 관리도 문제가 됐다.

처음에는 씬에서 필요한 프리팹을 직접 Reference로 물고 있는 방식이었는데, 씬마다 같은 리소스를 참조하다 보니 관리하기가 불편했다.

그래서 Addressable Assets를 적용했다.

기존 Scene에서 직접 참조하고 있던 리소스를 확인해서 Addressable Assets 시스템으로 옮기고, 필요한 프리팹과 리소스들을 Addressable로 관리하도록 변경했다.

특히 같은 리소스가 여러 곳에서 참조되는 경우 중복해서 포함되지 않도록 리소스 의존 관계를 확인하면서 구성했다.

게임 시작 시에는 Start Scene에서 필요한 Addressable 리소스를 먼저 로드하고 이후 각 씬이나 시스템에서 필요한 리소스를 가져오는 구조로 만들었다.

Start Scene -> Addressables 초기 로드 -> 필요한 리소스 로드 -> Scene 진입

이렇게 바꾸면서 씬에 리소스를 직접 물고 있는 Reference를 줄일 수 있었고, 리소스가 어디에서 사용되는지도 관리하기 편해졌다.

씬을 하나씩 수정하면서 리소스가 중복으로 들어가는 문제도 같이 확인했다.

인앱 결제와 광고

게임 내 결제와 광고 시스템도 직접 연동했다.

IAP는 상품 정보를 기준으로 결제 요청을 보내고 결제 결과를 받아 게임 내 보상으로 연결하는 구조로 구현했다.

단순히 결제 성공 콜백에서 바로 아이템을 지급하는 방식으로 끝내지 않고, 결제 상태에 따라 처리할 수 있도록 흐름을 나눴다.

상품 조회 -> 구매 요청 -> 결제 처리 -> 구매 결과 확인 -> 보상 지급

모바일 게임에서는 결제 과정에서 앱이 종료되거나 네트워크 상태가 바뀌는 경우도 있기 때문에 결제 요청과 실제 보상 지급을 완전히 같은 시점의 처리로 생각하지 않았다.

광고는 AdMob을 연동했다.

보상형 광고의 경우 광고 시청이 정상적으로 완료된 경우에만 보상을 지급하도록 처리했고, 광고 로드 상태에 따라 버튼이나 보상 처리를 다르게 했다.

광고 역시 게임 로직과 직접 섞이지 않도록 별도의 광고 관리 클래스를 두고 필요한 곳에서 호출하는 형태로 사용했다.

Google과 Facebook 로그인

소셜 로그인도 추가했다.

Google과 Facebook OAuth 로그인을 연결해서 계정을 생성하거나 기존 계정으로 다시 로그인할 수 있도록 했다.

로그인 과정에서는 각 플랫폼에서 인증을 진행한 뒤 전달받은 인증 정보를 서버와 연결해서 게임 계정을 확인하는 방식으로 처리했다.

Google/Facebook 인증 -> 인증 정보 수신 -> 서버 인증 -> 게임 계정 확인 -> 로그인 완료

특히 로그인 SDK에서 제공하는 콜백과 실제 게임 로그인 상태를 직접 섞지 않으려고 중간에서 로그인 상태를 관리하도록 했다.

그래야 Google 로그인과 Facebook 로그인이 들어오더라도 이후 게임에서 사용하는 로그인 처리 방식은 동일하게 가져갈 수 있었다.

로그인 실패나 사용자가 인증을 취소한 경우도 별도로 처리했다.

모바일에서는 외부 인증 화면으로 이동했다가 다시 게임으로 돌아오는 경우가 있기 때문에 앱 포커스가 바뀌는 상황까지 같이 확인하면서 구현했다.

보안

통신 데이터는 Diffie-Hellman으로 세션 키를 교환하고 AES로 암호화했다.

연결할 때마다 새로운 세션 키를 사용하고 패킷마다 다른 IV를 적용했다.

클라이언트 메모리에서 변경될 수 있는 값에는 Anti-Cheat Toolkit의 ObscuredInt, ObscuredFloat, ObscuredString도 사용했다.

다만 이걸 적용했다고 클라이언트를 신뢰할 수 있는 것은 아니다.

클라이언트가 직접 계산한 결과는 결국 변조할 수 있기 때문에 최종 위치, HP, Random Seed 등의 값을 이용해 SHA-256 결과를 만들고 서로 비교하는 정도로 처리했다.

서버에서 물리를 완전히 다시 계산하는 구조까지 만들지는 않았다.

모바일 최적화

렌더링 쪽에서는 Built-in에서 URP로 전환하고 SRP Batcher를 적용했다.

같은 씬을 기준으로 Batches가 대략 140~180에서 60~80 정도까지 내려갔다.

Profiler를 보면서 Draw Call, 오브젝트 수, 셰이더, 파티클 등을 확인했고 실제 병목이 있는 부분 위주로 수정했다.

전투 중 자주 생성되는 파티클이나 데미지 텍스트는 Queue<T> 기반의 PrefabObjectPool<T>을 만들어 재사용했다.

이후 전투 중 GC Alloc을 0B 수준으로 유지할 수 있었고, 전투가 몰리는 상황에서 GC 때문에 프레임이 흔들리는 문제도 줄일 수 있었다.

마무리

이번 프로젝트에서 코어 메카닉을 새로 기획한 것은 아니다.

기존에 만들어져 있던 게임의 핵심 규칙을 기준으로 클라이언트를 구현했고, 실제로 개발하면서 문제가 생기는 부분을 하나씩 수정했다.

처음부터 모든 구조를 정해놓고 개발한 것도 아니었다.

Ray로 AI를 만들었다가 물리 결과를 제대로 처리하지 못해서 시뮬레이션 방식으로 바꿨고, 네트워크에서는 물리 업데이트를 직접 제어할 필요가 생겼다.

UI도 처음에는 단순하게 만들었지만 화면이 늘어나면서 데이터 모델과 View를 분리했고, 아이템 수가 많아지면서 UI 가상화와 풀링까지 적용하게 됐다.

맵 에디터도 처음에는 블록을 놓는 기능만 생각했지만 실제로 사용하려고 보니 위치 오차나 Undo / Redo 같은 부분이 필요했다.

개발하면서 계속 구조가 바뀌었다.

이번 프로젝트에서 가장 많이 배운 것도 이 부분이었다. 기능 하나를 구현하는 것보다 실제로 사용했을 때 어떤 문제가 생기는지 보고 그에 맞춰 구조를 바꾸는 일이 더 중요했다.

특히 물리 기반 게임에서는 하나의 문제가 한 시스템에서 끝나지 않았다. 물리 문제가 네트워크로 이어지고, AI 문제는 레벨 디자인까지 영향을 줬다.

그래서 이후에는 기능을 만들 때 해당 코드만 보는 것보다 이 기능이 다른 시스템에 어떤 영향을 주는지 먼저 생각하게 됐다.