개요

지난 편까지 체력바와 쿨타임 바를 붙였다. 이제 게임이 시작하고, 끝나고, 화면에 상태가 보인다.

그런데 5분 내내 좀비가 같은 간격으로 같은 수만큼 나온다. 1분째나 4분째나 똑같다. 난이도 곡선이 없으니 5분을 버티는 게 아니라 그냥 5분이 지나가는 것에 가깝다.

원래 일요일에 하려던 웨이브 테이블을 당겨왔다. 만드는 것 자체는 표 하나에 시간·간격·마릿수를 적어두고 때 되면 갈아끼우는 것뿐인데, 그 “때 되면”을 누가 재느냐에서 한참 걸렸다.

이번 글이 답할 질문. 게임에서 시간을 세는 곳은 몇 군데여야 하는가?


1. 코루틴을 안 썼다 — 시계가 둘이면 어긋난다

주기적으로 뭔가 하는 코드는 지금까지 전부 코루틴으로 짰다. 공격도, 좀비 스폰도, 피격 연출도 그렇다. 그러니 웨이브도 자연스럽게 코루틴이 먼저 떠올랐다.

60초 기다린다 → 2번 웨이브 적용
60초 더 기다린다 → 3번 웨이브 적용
...

안 쓰기로 한 이유가 셋인데, 결정타는 하나였다. 시간을 세는 곳이 둘로 갈라진다.

화면에 뜨는 남은 시간은 GameTimer가 센다. 웨이브를 코루틴으로 하면 웨이브는 자기 시계로 따로 센다. 처음엔 같이 가겠지만, 정지가 끼거나 프레임이 밀리면 조금씩 벌어진다. 그러다 화면엔 3분 남았다고 떠 있는데 웨이브는 다른 구간을 돌고 있는 상황이 나온다.

시계가 둘이면 둘은 반드시 어긋난다

나머지 둘도 같이 걸렸다.

이유 내용
부채가 또 청구된다 간격이 데이터에서 오니 7편에서 세 번 고친 그 자리를 네 번째로 고쳐야 한다
정지와 얽힌다 카드 창이 떠서 게임이 멈춘 동안 웨이브가 어떻게 되는지 보장이 안 된다

셋 다 Update에서 비교 한 번 하는 것으로 사라졌다.

void Update()
{
    int nextWaveIndex = _currentWaveIndex + 1;
    if (nextWaveIndex >= _table.Waves.Count)
    {
        enabled = false;   // 마지막 웨이브 이후 스스로 멈춘다
        return;
    }

    var nextWave = _table.Waves[nextWaveIndex];
    if (_timer.Elapsed < nextWave.StartTime)
    {
        return;
    }

    ApplyNextWave();
}

시간을 세는 곳은 GameTimer 하나만 남았다. 게임이 멈추면 그 시계가 멈추니 웨이브도 같이 멈춘다. 따로 처리한 게 아니라 저절로 그렇게 됐다.

매 프레임 비교 한 번이 아깝게 느껴졌는데, 한 판에 웨이브 전환은 네 번뿐이다. 아낄 것과 아끼면 안 될 것을 헷갈린 셈이다.


2. 뺄셈은 값을 가진 쪽에서

웨이브는 “게임 시작 후 몇 초”를 기준으로 하니 경과 시간이 필요하다. 그런데 GameTimer남은 시간만 갖고 있었다.

총 시간을 밖에 열어서 총 시간 - 남은 시간을 계산하면 될 것 같았는데, 그렇게 안 했다.

public float Elapsed => _duration - Remaining;

뺄셈을 타이머 안에 뒀다. 이유는 하나다.

값을 소유한 쪽이 그 값에서 나오는 계산도 소유한다.

밖에서 계산하게 두면 그 뺄셈을 쓰는 곳이 늘어날 때마다 같은 식이 복사된다. 나중에 보스 페이즈가 붙어서 시간 계산이 복잡해지면, 복사된 자리를 전부 찾아 고쳐야 한다.

체력에서 이미 같은 판단을 했었다. 최대 체력을 올릴 때 현재 체력 비율을 유지하는 계산이 Health 안에 있는 것과 같은 이유다.

웨이브 쪽은 총 시간을 알 이유가 없다. 필요한 건 “지금 몇 초째냐” 하나뿐이니 그것만 열어줬다.


3. 표 하나로 곡선을 만든다

웨이브 데이터는 ScriptableObject로 뒀다. 코드가 아니라 에셋 파일 하나에 값을 적어두는 방식이다. 값을 바꿔도 다시 빌드할 필요가 없다.

[Serializable]
public struct Wave
{
    public float StartTime;      // 게임 시작 후 몇 초부터
    public float Interval;       // 스폰 간격
    public int CountPerSpawn;    // 한 번에 몇 마리
}

class가 아니라 struct로 둔 이유가 둘이다. [Serializable]을 붙여야 인스펙터에 배열로 펼쳐지고, struct는 꺼낼 때 복사본이 나와서 원본을 실수로 건드릴 경로가 하나 준다.

밖으로 내보낼 때도 배열을 그대로 주지 않았다.

public IReadOnlyList<Wave> Waves => _waves;

배열을 그대로 열면 밖에서 원소를 갈아끼울 수 있다. 그러면 그 수정이 에셋 파일에 그대로 저장된다. 읽기 전용 형태로 바꾸면 인덱싱과 개수 세기만 되고 대입은 컴파일이 안 된다.

이건 앞에서 계속 쓰던 것과 같은 발상이다. 이벤트가 붙였다 떼기만 열어주는 것, 프로퍼티가 읽기만 열어주는 것과 층만 다르고 원칙은 하나다.

밖에 열어주는 만큼이 밖에서 망가뜨릴 수 있는 범위다. 파일에 저장되는 값이면 그 범위가 다음 실행까지 남는다.

밸런스 초안

시작 시각 스폰 간격 한 번에
0초 2.0초 1마리
60초 1.2초 2마리
120초 0.8초 3마리
180초 0.5초 4마리
240초 0.3초 6마리

마지막 구간이 초당 20마리다.

마지막 구간에서 화면이 좀비로 덮인다

이 수치는 아직 감으로 적은 것이다. 5분을 처음부터 끝까지 돌려본 게 아니라, 타이머를 60초로 줄이고 시작 시각을 0/12/24/36/48로 압축해서 곡선 모양만 확인했다. 5분짜리를 매번 돌려보며 밸런스를 잡는 건 시간이 안 나온다..


4. 게임을 안 켜고 잡는 오류

표를 손으로 채우다 보니 실수할 자리가 눈에 보였다. 간격에 0을 넣거나, 시작 시각을 앞 웨이브보다 작게 넣거나 하는 것들이다.

특히 시작 시각을 전부 0으로 두면 첫 프레임에 마지막 웨이브까지 한 번에 적용된다. 게임을 켜자마자 최고 밀도가 되니 5분 곡선이 통째로 사라진다.

그런데 이건 게임을 돌려야만 알 수 있는 문제가 아니다. 표를 채우는 순간 이미 틀려 있다.

유니티에는 인스펙터에서 값을 고칠 때마다 불리는 함수가 있어서, 거기에 검사를 넣었다.

인스펙터에서 값을 치자마자 콘솔에 경고가 뜬다

if (_waves[i].StartTime <= _waves[i-1].StartTime)
{
    Debug.LogWarning($"{i+1}번째 Wave의 시작 시간이 이전 Wave보다 빠름", this);
}

<가 아니라 <=인 게 포인트다. 같은 값이어도 무의미하다. 앞 웨이브와 같은 시각이면 둘이 같은 프레임에 적용되니 앞의 것은 있으나 마나다.

여기서 값을 고치면 안 된다

처음엔 잘못된 값을 코드가 알아서 고쳐줄까 했는데, 그러면 안 되는 이유가 있었다.

“60”을 치려면 먼저 “6”이 입력된다. 그 순간 코드가 값을 보정해버리면 편집 자체가 불가능해진다.

그래서 경고만 남긴다. 로그를 에러가 아니라 경고로 둔 것도 같은 이유다. 편집하는 동안 콘솔이 빨개지면 진짜 에러를 놓치게 된다.

경고에 몇 번째인지와 실제 값을 같이 넣어두면 인스펙터에서 어느 칸인지 바로 찾을 수 있다. 그리고 로그를 남길 때 두 번째 인자로 자기를 넘기면, 콘솔에서 그 줄을 눌렀을 때 해당 파일로 바로 점프한다.

검사를 표가 갖는 게 맞나

이 검사를 웨이브 관리자 쪽에 둘 수도 있었는데, 표가 갖게 했다. 자기 데이터의 규칙은 자기가 아는 게 맞고, 무엇보다 관리자 쪽에 두면 게임을 켜야만 검사가 돌아간다.


5. 좀비가 벽 밖에 생겼다

스폰 위치는 플레이어를 중심으로 한 원 위에서 뽑는다. 그래서 플레이어가 맵 가장자리에 서 있으면 원의 절반이 맵 밖이 된다.

후보가 셋이었다.

벽 밖에 생기는 좀비를 어떻게 막을까

벽 충돌을 끄는 건 바로 기각했다. 벽이 벽 역할을 못 하게 되면 벽 뒤에 숨는 플레이가 통째로 사라진다. 버그 하나 막자고 게임의 선택지를 없애는 셈이다.

밖이면 다시 뽑는 방식은 그럴듯했다. 위치 분포가 제일 자연스럽다. 그런데 이걸 안 골랐다.

spawnPoint = Vector2.Min(Vector2.Max(spawnPoint, _mapMin), _mapMax);

결국 맵 경계 안으로 밀어 넣는 쪽으로 갔다. 고른 이유가 성능이 아니라는 게 중요하다.

다시 뽑는 방식은 한 번에 끝날 때도 있고 여러 번 걸릴 때도 있다. 평소엔 상관없는데, 초당 20마리를 뽑는 구간에서는 걸리는 시간이 들쭉날쭉해진다.

다음 작업이 성능 측정이다. 오브젝트 풀링을 넣기 전후를 비교해야 하는데, 여기에 “때에 따라 다른 코드”를 심어두면 나중에 느려진 원인이 풀링 때문인지 이것 때문인지 못 가린다.

측정을 앞둔 코드에는 편차를 심지 않는다. 재려는 것이 흐려진다.

경계 값은 코드에 박지 않고 인스펙터에서 조절할 수 있게 뒀다. 플레이하면서 벽 위치에 맞춰볼 수 있기 때문이다. 코드에 초기값만 적어두면 하드코딩의 편함도 그대로 가져간다.


6. 잔 실수들

배열 끝을 한 칸 잘못 봤다. 다음 웨이브 번호가 표 길이보다 큰지 검사하는데 >로 썼다. 길이가 5면 쓸 수 있는 번호는 0~4까지인데, 5 > 5가 거짓이라 그냥 통과한다. 그래서 마지막 웨이브 다음 프레임부터 매 프레임 예외가 났다. >=여야 했다.

실행 순서를 못 믿는 자리가 생겼다. 웨이브 관리자와 스포너 둘 다 시작할 때 할 일이 있는데, 유니티가 둘 중 뭘 먼저 부를지는 정해져 있지 않다. 스포너가 먼저 돌면 첫 스폰이 인스펙터에 남아 있던 초기값을 쓴다. 지금은 그 초기값을 0번 웨이브와 같게 맞춰서 피했다.

웨이브를 인자로 넘기려다 말았다. 처음엔 적용 함수가 웨이브를 인자로 받게 짰는데, 그러면 번호는 함수 안에서 따로 올라간다. 넘긴 웨이브와 올라간 번호가 서로 다른 걸 가리킬 수 있다. 번호 하나만 진실로 두고 거기서 웨이브를 꺼내는 편이 안전하다.

문서에서 잡아낸 것

기능이 아니라 문서 쪽에서 하나 걸렸다.

결정 기록을 다시 읽는데 앞뒤가 안 맞았다. 번호 초기값을 -1로 둔 근거를 “이러면 초기화와 전환을 같은 코드로 처리할 수 있다”고 적어뒀는데, 그 뒤에 시작할 때 0번을 적용하기로 정하면서 그 근거가 반쯤 무의미해졌다. 결정이 바뀌었는데 앞에 쓴 이유는 그대로 남아 있었던 것이다.

지우는 대신 기각된 대안으로 옮겨 적었다. 나중에 같은 생각을 다시 했을 때 “그건 이래서 안 했다”가 문서에 이미 있게 된다.

기록은 결정된 상태를 담아야지, 생각한 순서대로 쌓으면 안 된다. 쌓기만 하면 폐기된 이유가 살아 있는 이유인 척한다.


정리

  • 시간을 세는 곳은 하나여야 한다. 둘이면 처음엔 같이 가다가 정지·지연에서 반드시 벌어진다.
  • 출처가 하나면 정지도 저절로 따라온다. 웨이브를 따로 멈추는 코드를 안 써도 됐다.
  • 값을 가진 쪽이 그 값에서 나오는 계산도 갖는다. 밖에서 계산하게 두면 같은 식이 복사된다.
  • 필요한 최소한만 연다. 웨이브는 총 시간을 알 이유가 없다.
  • 데이터를 파일로 빼면 값을 고칠 때 다시 빌드하지 않아도 된다. 밸런스는 값을 자주 고치는 일이다.
  • 밖으로 열 때는 읽기 전용으로. 배열을 그대로 열면 밖에서 갈아끼운 게 파일에 저장된다.
  • 게임을 안 켜고 잡을 수 있는 오류는 그때 잡는다. 표를 채우는 순간 이미 틀린 값이다.
  • 검사가 값을 고치면 안 된다. “60”을 치려면 먼저 “6”이 입력된다.
  • 선택의 이유가 항상 빠르기는 아니다. 곧 성능을 잴 참이면 “때에 따라 다른 코드”를 안 심는 게 더 중요하다.
  • 버그 하나 막자고 게임의 선택지를 없애지 않는다. 벽 충돌을 끄면 벽이 벽이 아니게 된다.
  • 기록은 결정된 상태를 담는다. 시간순으로 쌓으면 폐기된 이유가 살아 있는 척한다.

참고 자료

한줄 평

  • 웨이브를 만든 시간보다 “시간을 어디서 셀 것인가”를 정하는 데 더 오래 걸렸는데, 정하고 나니 정지 처리가 공짜로 따라와서 결과적으로 이득이었다!