프로젝트 개요
턴제로 진행되는 물리 액션 게임을 개발했다.
플레이어가 유닛을 조작해서 탄환을 발사하고, 벽이나 지형에 탄환을 튕겨 상대 유닛과 코어 블록을 공격하는 방식이다. 탄환의 이동과 충돌 결과가 게임 플레이에 직접 영향을 주기 때문에 물리 처리가 상당히 중요한 프로젝트였다.
여기에 실시간 네트워크 대전 구조를 붙이면서 클라이언트마다 물리 결과가 달라지는 문제도 같이 해결해야 했다.
클라이언트에서는 게임플레이와 상태 머신 외에도 물리 Tick, 네트워크 동기화, RPC, 탄도 예측, AI 탐색, 런타임 맵 에디터 등을 구현했다.

물리 Tick을 직접 관리하게 된 과정
처음에는 Unity의 FixedUpdate에 물리 처리를 맡겼다.
싱글 플레이에서 테스트할 때는 크게 문제가 없었다. 그런데 두 클라이언트에서 같은 상황을 재현해보니 탄환의 최종 위치가 조금씩 달라졌다.
한두 번 충돌하는 정도에서는 눈에 띄지 않았지만, 탄환이 여러 벽을 연속으로 튕기는 상황에서는 차이가 커졌다. 첫 번째 충돌에서 발생한 작은 위치 차이가 다음 반사 방향에 영향을 주고, 이 차이가 다시 다음 충돌 위치에 영향을 주는 식이었다.
그래서 기본 물리 루프에 의존하지 않고 물리 업데이트를 직접 관리했다.
Physics.autoSimulation = false;
Physics.Simulate(0.025f);
0.025초를 하나의 Tick으로 잡아서 40Hz로 물리를 진행했다.
게임의 물리 처리를 다음과 같은 흐름으로 분리했다.
Physics.Simulate() -> Rigidbody State Collection -> Tick Packet -> Remote Client
이렇게 하면서 물리 업데이트를 프레임 단위가 아니라 게임에서 관리하는 고정 Tick 단위로 맞출 수 있었다.
같은 Tick을 실행해도 결과가 달랐던 문제
물리 Tick을 고정한 뒤에도 문제가 완전히 해결되지는 않았다.
양쪽 클라이언트가 같은 Tick을 같은 순서로 실행하면 결과도 같을 것이라고 생각했는데, 실제 기기에서 테스트해보면 조금씩 차이가 발생했다.
특히 모바일 기기마다 물리 연산 과정에서 발생하는 부동소수점 오차나 충돌 해결 과정의 차이가 누적됐다.
10~20회 정도 충돌하는 탄환을 계속 테스트하면서 이 문제가 확실하게 드러났다.
처음에는 양쪽 클라이언트에서 동일한 물리 시뮬레이션을 재생하는 방식으로 해결하려고 했다. 그런데 이 방식만으로는 최종 위치까지 정확하게 맞추기 어려웠다.
그래서 물리 시뮬레이션 자체를 완전히 결정론적으로 만드는 대신, 현재 턴을 진행하는 클라이언트의 상태를 기준으로 삼았다.
Stream 클라이언트가 물리를 진행하면서 매 Tick마다 Rigidbody의 위치, 회전, 속도 등을 수집해서 보낸다.
Remote 클라이언트에서는 다음 Physics.Simulate() 전에 전달받은 값을 Rigidbody에 적용한다.
Authoritative State -> State Packet -> Remote State Injection -> Physics.Simulate()
결과적으로 양쪽 클라이언트가 물리를 각각 계산하되, 매 Tick마다 기준 클라이언트의 상태를 적용하는 방식이 됐다.
완전한 Lockstep 방식은 아니고, 물리 시뮬레이션과 상태 동기화를 같이 사용하는 구조다.
RPC 시스템
게임 기능이 늘어나면서 네트워크에서 호출해야 하는 함수도 계속 늘어났다.
각 메시지를 직접 분기해서 처리하는 방식은 코드가 계속 길어졌다. 그래서 [NewtroRPC] 속성을 이용해서 RPC 메서드를 등록하는 구조를 만들었다.
초기화할 때 Reflection으로 RPC 속성이 붙은 메서드를 찾고 MethodInfo와 대상 인스턴스를 Dictionary<string, RPCData>에 저장했다.
실제 패킷을 받았을 때는 다시 Reflection을 수행하지 않고 캐싱된 정보를 이용했다.
기본 타입은 직접 파싱하고 CustomVector3 같은 복합 데이터는 JsonMapper를 통해 역직렬화했다.
패킷 구조는 다음과 같이 만들었다.
FunctionName(Type>Value|Type>Value...)
덕분에 새로운 RPC를 추가할 때 네트워크 처리 코드를 별도로 크게 수정하지 않고 메서드를 등록해서 사용할 수 있었다.
문자열 기반으로 만든 만큼 패킷 크기가 커지는 문제는 있었다. 이 부분은 이후 압축 처리를 추가했다.
패킷 압축
Tick마다 Rigidbody 상태를 보내다 보니 패킷 크기가 계속 커졌다.
당시 RPC 데이터가 문자열 기반이라 바이너리 데이터보다 크기가 큰 편이었다.
전송 직전에 문자열을 UTF-8 Byte Array로 변환하고 GZip으로 압축했다.
RPC String -> UTF-8 Byte[] -> GZip -> Encryption -> Socket
압축 데이터 앞에는 원본 데이터 크기를 4바이트로 넣었다.
BitConverter.GetBytes(sourceArray.Length)
압축을 풀 때 필요한 크기를 미리 알고 처리하기 위해서였다.
틱 데이터를 일정량 모은 다음 압축하는 방식으로 처리했고, 문자열 기반 패킷의 크기를 기존보다 상당히 줄일 수 있었다.
세션 키 교환과 패킷 암호화
서버에서 모든 통신을 중계하는 구조가 아니었기 때문에 클라이언트 간 세션 초기화 과정에서 사용할 키도 직접 구성했다.
세션이 만들어질 때 Diffie-Hellman 방식으로 키를 교환하고 이후 통신에는 AES-256-CBC를 사용했다.
DH Key Exchange -> Shared Session Key -> AES-256-CBC -> Packet
Socket 통신은 별도의 작업 스레드에서 처리했다.
Blocking I/O와 패킷 프레이밍을 담당하는 Socket Worker를 두고, 게임 로직과 실제 Socket 처리를 분리했다.
이 부분은 네트워크 코드를 게임플레이 코드에서 직접 처리하지 않기 위한 목적도 있었다.
반사 탄도 예측
이 게임에서는 플레이어가 발사하기 전에 탄환의 예상 경로를 보여줘야 했다.
직선 Raycast 하나만으로는 부족했다. 벽에 부딪히면 반사된 방향으로 다시 Raycast를 해야 했다.
충돌면의 Normal을 이용해서 다음 방향을 계산했다.
r = d - 2(d · n)n
Unity에서는 Vector3.Reflect()를 사용했다.
ray = new Ray(
hit.point,
Vector3.Reflect(ray.direction, hit.normal)
);
처음에는 hit.point를 그대로 LineRenderer의 위치로 사용했다.
그런데 실제 탄환은 반지름을 가진 구체라서 Ray의 충돌 지점과 탄환의 실제 위치가 맞지 않았다.
벽에 가까운 곳에서는 탄환이 벽 안으로 조금 들어가 보이거나 다음 반사 위치가 어긋나는 문제가 있었다.
그래서 탄환의 반지름을 이용해서 충돌 위치를 보정했다.
Visual Position = Hit Point + Normal * Radius - Direction * Epsilon
이렇게 실제 탄환 크기를 계산에 포함시켰다.
발사 파워 계산
발사 파워도 단순하게 속도에 선형으로 곱하지 않았다.
처음에는 파워가 증가한 만큼 속도가 증가하도록 만들었는데, 높은 파워 구간에서 조작감이 너무 일정했다.
그래서 Power Curve를 적용했다.
v0 = Forward * Power^Regulate
Regulate 값은 테스트하면서 1.0 ~ 1.2 정도에서 조정했다.
파워가 높아질수록 속도 증가량도 조금씩 커지게 해서 강하게 쏠수록 조준 오차의 영향도 커지도록 했다.
AI 반사 탄도 탐색
AI도 플레이어와 같은 반사 탄도를 사용할 수 있도록 만들었다.
AI 턴이 시작되면 먼저 적을 향한 직선 경로를 확인한다.
직격이 가능한 경우에는 바로 해당 경로를 사용하고, 직격이 안 되는 경우 반사 경로를 탐색한다.
반사 탐색에서는 여러 방향으로 Ray를 발사하고 충돌할 때마다 반사 방향을 계산했다.
360° Scan -> Direct LOS -> Reflection Search -> Target Filtering -> Fire
각 방향에서 최대 50회까지 반사하도록 했고, 적 유닛이나 CoreBlock이 발견되면 탐색 결과를 DetectDataForm에 저장했다.
여기에는 타깃, 조준 방향, 반사 횟수, 거리 등의 정보를 넣었다.
탐색 결과는 다시 조건을 걸어서 정리했다.
- 아군 유닛을 맞히는 경로 제외
- CoreBlock 우선
- 반사 횟수가 적은 경로 우선
- 가까운 적 우선
최종적으로 선택된 방향과 파워를 발사 시스템에 넘겼다.
AI가 즉시 발사하면 너무 기계적으로 보이기 때문에 실제 발사 전에는 조준 시간과 딜레이를 넣었다.
AI Raycast 성능 문제
초기 AI 탐색은 1도 단위로 360개의 방향을 검사하고 각 방향마다 최대 50회까지 반사시키는 방식이었다.
AI가 턴을 시작하는 순간 많은 Raycast가 발생했고 모바일에서 프레임이 끊겼다.
처음 측정했을 때 약 80~120ms 정도의 CPU 사용 시간이 발생했고, RaycastHit[]를 계속 생성하면서 GC도 같이 발생했다.
탐색 방식을 먼저 바꿨다.
적 방향으로 직선 Raycast를 먼저 검사하고, 직격이 가능한 경우 반사 탐색을 하지 않도록 했다.
직격이 안 되는 경우에만 반사 탐색을 수행했다.
각도도 처음부터 1도 단위로 모두 검사하지 않고 넓은 간격으로 먼저 확인한 다음 후보가 나온 주변만 다시 세밀하게 검사했다.
Coarse Search -> Candidate Area -> Fine Search
Raycast 배열은 RaycastNonAlloc으로 바꾸고, 레이어 마스크도 캐싱했다.
기존보다 탐색 횟수를 크게 줄일 수 있었고 AI 턴 시작 시 발생하던 프레임 드랍도 15ms 이하 수준으로 낮췄다.
AI 탐색 실패 처리
반사 경로를 아무리 찾아도 공격 가능한 경로가 나오지 않는 경우가 있었다.
지형에 적이 완전히 막혀 있는 경우였다.
이 상황에서 AI가 아무 행동도 하지 않으면 턴이 끝나지 않는 문제가 생길 수 있었다.
그래서 탐색 결과를 DETECT_METHOD.FAIL로 따로 처리했다.
AI_DetectFailActionHandler에서 장애물을 공격하거나, 맵 반대편을 향해 블라인드 샷을 하거나, 낮은 파워로 견제하는 등의 행동을 선택하도록 했다.
AI가 최적의 공격 경로를 찾지 못하는 것도 정상적인 상황으로 보고 처리한 것이다.
런타임 블록 에디터
맵을 수정할 때마다 Unity Editor에서 씬을 수정하고 다시 테스트하는 과정이 번거로웠다.
그래서 게임을 실행한 상태에서 블록을 배치하고 바로 확인할 수 있는 런타임 블록 에디터를 만들었다.
마우스 위치에서 Raycast를 실행하고 맞은 블록의 표면 Normal을 확인한다.
이 값을 이용해서 새 블록의 위치와 회전을 계산했다.
Mouse Raycast -> Hit Block -> Surface Normal -> Snap Transform -> Create Block
블록 GameObject 자체를 저장하지 않고 별도의 데이터 구조를 만들었다.
[System.Serializable]
public class MapBlockData : IComparable<MapBlockData>
{
public Vector3 Position;
public Vector3 Rotation;
public Vector3 Scale;
public int BlockCode;
public int CompareTo(MapBlockData other)
{
return BlockCode.CompareTo(other.BlockCode);
}
}
이 데이터만 가지고 맵을 저장하고 다시 생성할 수 있도록 했다.
JSON과 ScriptableObject를 이용해 저장과 로드를 지원했고, 맵의 중심점이 바뀌는 경우에는 ZeroCorrection을 이용해 블록들의 상대 좌표를 다시 계산했다.
턴 전환 Race Condition
턴 전환에서도 문제가 있었다.
탄환이 움직이고 있는 동안 턴 타이머가 끝날 수 있고, 동시에 유닛 사망 처리나 블록 파괴 이벤트가 발생할 수도 있었다.
초기에는 각각의 이벤트가 독립적으로 상태를 변경했다.
그러다 보니 탄환이 아직 움직이고 있는데 다음 턴으로 넘어가거나, 블록 파괴 연출이 끝나기 전에 상태가 변경되는 문제가 간헐적으로 발생했다.
StatusMaster에서 다음 상태로 넘어가는 조건을 한 번에 확인하도록 수정했다.
Rigidbody의 속도를 확인해서 이동이 끝났는지 확인하고,
velocity.sqrMagnitude < 0.001f
필요한 애니메이션과 카메라 연출까지 끝난 뒤 다음 상태로 넘어가도록 했다.
Movement End -> Physics Stable -> Animation End -> Camera/UI End -> Turn Change
코어 블록 파괴처럼 별도의 연출이 필요한 경우에는 해당 이벤트가 끝날 때까지 턴 전환을 막았다.
마치며
이 프로젝트에서 가장 많이 수정한 부분은 물리 동기화와 AI 탐색이었다.
처음에는 Unity의 FixedUpdate를 사용했고, 이후 물리 Tick을 직접 관리하도록 바꿨다. 그래도 기기별 물리 오차가 남아서 최종적으로는 기준 클라이언트의 Rigidbody 상태를 매 Tick 주입하는 방식까지 추가했다.
AI도 처음에는 360도를 전부 세밀하게 탐색했다. 실제 모바일 환경에서 성능 문제가 발생한 뒤 직격 탐색을 먼저 수행하고, 반사 탐색에서는 Coarse-to-Fine 방식으로 후보를 줄였다.
이외에도 RPC, 패킷 압축과 암호화, 런타임 맵 에디터, 턴 상태 관리 등을 직접 구현했다.
프로젝트를 진행하면서 기능 하나를 구현하는 것보다 서로 다른 시스템이 같이 동작할 때 생기는 문제가 더 많았다. 물리 결과는 네트워크에 영향을 주고, 지형은 AI 탐색과 물리 충돌에 같이 영향을 준다. 턴 전환 역시 물리와 애니메이션 상태를 같이 확인해야 했다.
그래서 최종적으로는 각 시스템을 별도로 만드는 것보다 어떤 데이터가 어디에서 만들어지고, 어느 시점에 다른 시스템으로 넘어가는지를 명확하게 잡는 데 신경을 많이 썼다.