슬링샷 로그라이크 게임 개발 회고 #Project 2412

이번 프로젝트는 슬링샷 방식으로 유닛을 발사해서 전투하는 턴제 로그라이크 게임이었다.

유닛을 당겨서 발사하고, 벽에 튕기거나 다른 유닛과 부딪히면서 전투가 진행된다. 적의 공격이 끝나면 다시 플레이어가 조작한다. 구조만 보면 그렇게 복잡한 게임은 아니다.

그런데 직접 구현해보니 생각보다 신경 쓸 부분이 많았다.

특히 물리와 턴 시스템이 붙으면서 문제가 많이 생겼다. 유닛이 언제 멈췄다고 판단할지부터 시작해서, 충돌 연출 중 물리만 느리게 만드는 방법, 발사 전에 보여주는 예상 궤적과 실제 물리 결과를 최대한 비슷하게 맞추는 작업까지 손볼 부분이 계속 나왔다.

처음부터 모든 문제를 예상하고 설계한 것은 아니었다. 실제로 만들어보고 문제가 생길 때마다 구조를 조금씩 바꿨다.

프로젝트 구조

코드가 커지면서 가장 먼저 신경 쓴 부분은 모듈 간 의존성이었다.

게임 데이터, 전투 로직, 씬 오브젝트, UI가 한곳에 섞이기 시작하면 수정할 때 같이 건드려야 하는 코드가 계속 늘어난다.

그래서 프로젝트를 크게 세 영역으로 나눴다.

GlobalModule -> 공통 기능, 입력, 데이터 저장, 유틸리티
ContentsModule -> 게임 규칙, 유닛 데이터, 버프와 상태 계산
GroupModule -> 씬 제어, 전투 진행, 맵 생성, UI

완전히 독립된 구조는 아니었지만 적어도 게임 규칙과 씬 제어를 어느 정도 분리해두려고 했다.

예를 들어 유닛의 HP 계산이 UI의 존재를 직접 알 필요는 없게 만들었다. 데이터가 변경되면 모델에서 변경 사실을 전달하고, 이를 받은 UI 쪽에서 화면을 갱신하는 방식이다.

데이터 변경을 옵저버로 전달

HP, 버프, 장착 파츠처럼 게임 도중 계속 변하는 값들이 있었다.

처음에는 값이 변경되는 곳에서 필요한 UI 함수를 직접 호출하는 방식도 생각했지만, 기능이 추가될수록 호출 관계가 복잡해질 것 같았다.

그래서 ModelBase에 옵저버를 붙였다.

public void InformDataChanged()
{
    SyncDataModel();

    for (int i = _observerList.Count - 1; i >= 0; i--)
    {
        var observer = _observerList[i];

        if (observer != null)
            observer.OnDataChanged();
        else
            _observerList.RemoveAt(i);
    }
}

모델의 값이 바뀌면 InformDataChanged()를 호출한다.

UI는 모델을 관찰하고 있다가 변경 알림을 받으면 필요한 부분만 갱신한다.

이 구조는 처음에는 꽤 깔끔하게 동작했다.

문제는 유닛이 삭제된 뒤였다.

유니티에서 GameObject가 없어졌는데 모델에 등록된 옵저버가 그대로 남아 있는 경우가 있었다. 이후 데이터가 다시 변경되면 이미 사라진 컴포넌트를 호출하게 된다.

MissingReferenceException이 발생했고, 처음에는 전투 코드 쪽을 의심했지만 실제 원인은 옵저버의 생명주기였다.

그래서 옵저버 목록을 역순으로 검사하면서 잘못된 참조를 제거하고, 유닛이 삭제될 때도 모델에서 옵저버를 정리하도록 수정했다.

데이터 모델과 Unity 오브젝트는 같은 생명주기를 가진다고 생각하면 안 되는 부분이었다.

물리 시간을 따로 떼어냈다

게임에서 가장 중요한 부분은 물리였다.

슬링샷 게임이라 발사 이후의 움직임이 게임 결과 자체가 된다. 그래서 물리를 그냥 Unity의 기본 FixedUpdate에 맡기기보다 직접 제어할 필요가 있었다.

특히 충돌 연출을 넣으면서 문제가 생겼다.

강하게 부딪히는 순간 물리를 거의 멈추고 카메라를 확대하고 싶었다.

Time.timeScale을 낮추면 쉽게 구현할 수 있지만 이 경우 게임 전체가 느려진다. UI나 다른 코루틴까지 영향을 받는다.

원하는 건 물리만 느려지는 것이었다.

그래서 2D 물리의 자동 시뮬레이션을 끄고 직접 Physics2D.Simulate()를 호출하는 방식으로 바꿨다.

Physics2D.simulationMode = SimulationMode2D.Script;

float simDt = Time.fixedDeltaTime * _physicsTimeScale;
Physics2D.Simulate(simDt);

이제 물리 시간만 별도로 조절할 수 있었다.

충돌 직후에는 물리 시간 배율을 거의 0에 가깝게 낮추고 카메라를 따라가게 만들었다. 일정 시간이 지나면 다시 1로 복구했다.

전체 게임의 시간을 멈추는 것보다 이쪽이 연출을 제어하기 편했다.

충돌 연출에서 생긴 문제

특정 버프가 다른 유닛으로 넘어가는 상황에서는 일반 충돌보다 강한 연출을 넣었다.

버프 수치를 먼저 계산해서 대상 유닛에게 넘기고 기존 버프를 제거한 다음 카메라를 대상 유닛으로 이동시켰다.

collisionUnit.Model.AddStatus(new RogueStateModel()
{
    stateCode = RogueGameEnv.DamageIncreasePercentBuffCode,
    stateValue = transferredPercent
});

unit.Model.RemoveStatus(RogueGameEnv.DamageIncreaseCountBuffCode);
unit.Model.RemoveStatus(RogueGameEnv.DamageIncreasePercentBuffCode);

그 뒤 물리 속도를 낮췄다.

여기서 순서를 잘못 잡으면 화면에서는 버프가 넘어간 것처럼 보이는데 실제 데이터는 아직 변경되지 않은 식의 문제가 생길 수 있다.

그래서 게임 상태를 먼저 변경하고 그 결과를 연출에서 보여주는 방향으로 처리했다.

턴 시스템

턴은 코루틴으로 관리했다.

기본 흐름은 단순하다.

전투 시작 -> 플레이어 턴 -> 발사 -> 물리 종료 -> 턴 종료 -> 적 턴 -> 적 발사 -> 물리 종료 -> 턴 종료 -> 반복

문제는 중간중간 기다려야 하는 조건이 많다는 것이다.

플레이어가 발사를 끝내야 하고, 발사된 유닛들이 움직이지 않아야 한다. 충돌 연출이 들어가면 그것도 끝나야 한다.

적 턴에서는 AI가 공격할 때까지 기다려야 한다.

그래서 턴 자체는 TurnType으로 구분하고 각 상태에서 필요한 코루틴을 실행했다.

if (currentTurn == TurnType.MyTurn)
{
    SetStageEmissionColor(RogueUnitType.Friendly);
    RogueFriendlyBattleUnitManager.Instance.SetAllStagingUnitCanShoot();
    yield return MyTurnRoutine();
}
else if (currentTurn == TurnType.EnemyTurn)
{
    SetStageEmissionColor(RogueUnitType.Enemy);
    RogueEnemyBattleUnitManager.Instance.SetAllStagingUnitCanShoot();
    yield return EnemyTurnRoutine();
}

코드만 보면 별거 아닌데 실제로는 MyTurnRoutine()이 언제 끝났다고 판단할지가 핵심이었다.

유닛이 멈추지 않던 문제

테스트하면서 턴이 넘어가지 않는 문제가 있었다.

화면상으로는 유닛이 멈췄는데 게임은 계속 다음 턴을 기다렸다.

확인해보니 Rigidbody2D.linearVelocity에 아주 작은 값이 계속 남아 있었다.

물리 계산 결과가 완전히 0이 되지 않는 경우가 있었고, IsAllUnitStopped()에서는 그 값을 움직이는 상태로 보고 있었다.

그래서 일정 값 아래의 속도는 그냥 정지 상태로 처리했다.

if (unit.Rigidbody2D.linearVelocity.sqrMagnitude <= 0.01f)
{
    unit.Rigidbody2D.linearVelocity = Vector2.zero;
    unit.Rigidbody2D.angularVelocity = 0f;
}

여기에 최대 턴 시간도 걸었다.

while (elapsed < maxTurnDuration)
{
    // 유닛 정지 여부 확인
}

10초 동안 끝나지 않으면 강제로 물리를 종료한다.

게임에서는 이런 안전장치가 필요했다. 물리 계산이 예상과 다르게 흘러가는 상황을 완벽하게 막는 것보다, 문제가 발생했을 때 게임 전체가 멈추지 않게 하는 편이 현실적이었다.

발사 전에 궤적을 계산했다

슬링샷 게임에서는 발사하기 전에 어디로 날아갈지 보여줘야 한다.

처음에는 Raycast를 생각했는데 실제 유닛의 크기를 제대로 반영하기 어려웠다.

유닛이 원형이라 단순한 선 하나로 충돌을 검사하면 벽 모서리 같은 곳에서 실제 결과와 예상 결과가 달라진다.

그래서 CircleCastAll을 사용했다.

RaycastHit2D[] hits = Physics2D.CircleCastAll(
    targetPos,
    RogueGameEnv.UnitRadius,
    direction,
    float.MaxValue,
    LayerMask.GetMask("Block")
);

충돌한 지점의 법선 벡터를 이용해 반사 방향을 계산했다.

Vector2 reflectDir =
    inDir - 2f * Vector2.Dot(inDir, normal) * normal;

이걸 여러 번 반복하면 벽에 몇 번 튕길지까지 미리 계산할 수 있다.

현재 위치 -> 충돌 검사 -> 충돌 지점 -> 반사 방향 -> 다시 충돌 검사 -> 반복

그런데 반사 직후 같은 벽을 다시 잡는 문제가 있었다.

그래서 직전에 충돌했던 콜라이더를 저장하고 동일한 콜라이더가 연속으로 잡히는 경우를 제외했다.

이렇게 하니까 실제 게임에서 보이는 궤적과 상당히 비슷하게 맞출 수 있었다.

궤적 표시 때문에 GC가 발생했다

궤적 자체보다 표시 오브젝트가 문제였다.

조준하는 동안 화살표와 포인트를 계속 생성하고 삭제하고 있었다.

기능은 잘 되지만 플레이어가 조준할 때마다 InstantiateDestroy가 발생한다.

Profiler를 확인해보니 이 부분에서 GC Alloc이 발생하고 있었다.

그래서 화살표와 포인트를 미리 생성해놓고 필요할 때만 활성화했다.

Pool 생성 -> 필요한 개수 활성화 -> 위치 갱신 -> 조준 종료 -> 비활성화 -> 재사용

결과적으로 궤적을 계속 갱신해도 표시용 오브젝트를 새로 만들지 않게 됐다.

이건 특별한 최적화 기법이라기보다는 반복적으로 생성되고 삭제되는 오브젝트를 찾아서 풀링한 것이다. 그래도 실제 플레이에서 자주 실행되는 부분이라 효과는 확실했다.

로그라이크 맵 생성

전투만 있는 구조는 아니었다.

스테이지는 여러 층으로 나뉘고, 각 층에서 여러 노드 중 하나를 선택해 다음 전투로 이동하는 형태였다.

맵은 미리 고정된 데이터를 사용하는 대신 실행 시 랜덤하게 생성했다.

문제는 그냥 랜덤으로 연결하면 특정 노드가 아무 경로에도 연결되지 않을 수 있다는 것이다.

그래서 노드 연결을 두 번 나눴다.

첫 번째에는 랜덤하게 연결한다.

두 번째에는 연결되지 않은 노드를 찾아 강제로 연결한다.

노드 생성 -> 랜덤 연결 -> 연결되지 않은 노드 확인 -> 부족한 연결 추가 -> 다음 층

이 정도만 해도 랜덤성을 유지하면서 고립된 노드를 방지할 수 있었다.

로그라이크의 랜덤 생성은 단순히 Random을 많이 사용하는 문제가 아니었다. 랜덤하게 만들어도 플레이어가 실제로 이동할 수 있는 결과를 보장해야 했다.

배경 렌더링을 텍스처로 바꿨다

전투 스테이지의 배경에는 3D 장식물이 꽤 많았다.

처음에는 전부 그대로 렌더링했는데 층이 늘어나면서 배경 때문에 드로우콜이 증가했다.

전투 중에는 배경이 거의 정적인데 매 프레임 계속 렌더링하고 있었다.

그래서 층에 들어갈 때 배경을 한 번 렌더링하고 그 결과를 텍스처로 저장했다.

배경 생성 -> 1회 렌더링 -> RenderTexture -> Texture2D -> RawImage

이후에는 실제 3D 배경 대신 캡처한 이미지를 사용했다.

완전히 2D처럼 보이는 것은 피하고 싶어서 카메라 이동에 맞춰 배경을 조금씩 움직이는 패럴랙스를 추가했다.

결과적으로 배경의 디테일은 유지하면서 매 프레임 3D 배경을 렌더링하는 비용을 줄일 수 있었다.

실제 측정에서는 배경 관련 드로우콜이 약 180회에서 45회 정도까지 줄었다.

캡처가 검게 나오는 문제

배경 캡처를 처음 적용했을 때는 간헐적으로 검은 화면이 나왔다.

맵을 생성하고 바로 Camera.Render()를 호출했는데, 오브젝트가 생성된 것과 실제 렌더링 가능한 상태가 되는 시점이 완전히 같지 않았다.

그래서 한 프레임 기다린 뒤 캡처하도록 수정했다.

yield return new WaitForEndOfFrame();

Texture2D background =
    RogueBgCaptureHandler.Instance.CaptureBg(floorPosition);

이후에는 생성 직후 검은 텍스처가 만들어지는 문제가 사라졌다.

이런 문제는 코드 한 줄만 보면 별것 아닌데 디버깅할 때는 꽤 오래 걸린다. 특히 Unity에서는 오브젝트 생성, 활성화, 렌더링이 한 번의 함수 호출로 끝나는 것처럼 보여도 실제 실행 순서는 다르기 때문에 프레임 순서를 같이 봐야 했다.

최적화 결과

프로파일러를 기준으로 확인한 주요 변경점은 다음과 같다.

항목변경 전변경 후
배경 렌더링3D 배경 상시 렌더링진입 시 1회 캡처
배경 드로우콜약 180회약 45회
물리 시간 제어Time.timeScale물리 시뮬레이션 별도 제어
궤적 오브젝트생성/삭제Object Pool
궤적 GC반복적인 Alloc 발생표시 과정 GC Alloc 0 Bytes
턴 종료속도 조건만 검사임계값 + 최대 시간 제한

특히 물리 연출에서 전체 Time.timeScale을 건드리지 않게 된 것과 궤적 표시에서 불필요한 할당을 제거한 부분은 체감이 컸다.

개발하면서 느낀 점

이 프로젝트에서 가장 많이 건드린 것은 결국 상태와 실행 순서였다.

유닛이 움직이고 있는지, 턴이 끝났는지, 물리 연출이 끝났는지, 데이터가 변경됐는지 같은 것들이 서로 연결되어 있었다.

하나의 시스템만 놓고 보면 간단하다.

문제는 서로 연결되는 순간부터 생긴다.

물리 시스템에서는 유닛이 멈췄는데 턴 시스템에서는 아직 움직이는 것으로 판단할 수 있고, 데이터 모델에서는 옵저버가 살아있다고 생각하는데 Unity에서는 이미 GameObject가 삭제되어 있을 수도 있다.

그래서 개발하면서 단순히 기능을 구현하는 것보다 각 시스템이 어떤 상태를 책임지고 있는지 정하는 데 시간을 많이 썼다.

특히 물리 기반 게임에서는 “정지” 같은 단순한 상태도 명확한 기준이 필요했다.

속도가 0인가, 거의 0인가. 각속도는 남아 있는가. 충돌 처리가 끝났는가. 너무 오래 실행되고 있지는 않은가.

이런 조건을 하나씩 정리하면서 시스템이 조금씩 안정됐다.

아쉬웠던 부분

지금 다시 만든다면 싱글톤 구조부터 줄일 것 같다.

개발 당시에는 MonoSingleton이 편했다. 어디서든 시스템에 접근할 수 있고 빠르게 기능을 붙일 수 있었다.

하지만 프로젝트가 커지니까 호출 관계를 추적하기 어려워졌다.

특히 전투 시스템에서 카메라, 물리, 유닛 매니저, 코루틴 핸들러 등을 직접 참조하는 코드가 늘어나면서 결합도가 올라갔다.

다음에는 핵심 전투 로직은 최대한 순수 C#으로 분리하고 Unity의 MonoBehaviour는 입력과 화면, 물리 엔진 연결 정도만 담당하게 만드는 쪽을 생각하고 있다.

궤적 계산도 개선할 수 있다.

현재는 CircleCast와 반사 벡터를 이용해서 예상 경로를 계산한다. 일반적인 벽에서는 충분하지만 콜라이더가 복잡해지면 실제 물리 결과와 차이가 생길 가능성이 있다.

이 부분은 별도의 PhysicsScene2D를 만들어 실제 물리 시뮬레이션을 미리 돌리는 방식으로 개선할 수 있다.

예상 궤적을 수학적으로 근사하는 대신 실제 물리 엔진에 미리 물어보는 방식이다.

마무리

이번 프로젝트에서 가장 많이 배운 부분은 물리 시스템 자체보다 물리 시스템을 다른 게임 시스템과 연결하는 방법이었다.

슬링샷 게임의 핵심은 발사와 충돌이지만, 실제 게임을 완성하려면 그 뒤에 턴 관리, 데이터 동기화, 카메라 연출, 맵 생성, UI, 렌더링 최적화까지 같이 움직여야 했다.

처음에는 각각의 기능을 정상적으로 만드는 데 집중했다.

개발이 진행될수록 기능 하나가 정상적으로 동작하는 것보다 다른 시스템과 연결됐을 때도 상태가 꼬이지 않는 구조를 만드는 것이 더 중요하다는 쪽으로 생각이 바뀌었다.

결과적으로 수동 물리 시뮬레이션을 이용한 물리 시간 제어, 다중 반사 궤적 계산, 턴 상태 관리, 옵저버 기반 데이터 동기화, 로그라이크 맵 생성, 배경 캡처와 오브젝트 풀링까지 직접 구현하면서 전투 시스템을 완성했다.

완벽하게 처음부터 설계된 구조는 아니었다.

실제로 개발하면서 문제가 생겼고, 프로파일러와 로그를 확인하고, 실행 순서를 다시 뜯어보고, 필요한 부분의 구조를 바꿨다.

그 과정을 거치면서 단순히 “기능이 동작하는 코드”와 “실제 게임에서 계속 돌릴 수 있는 코드”의 차이를 꽤 명확하게 경험할 수 있었다.