개요

11편까지 해서 하루치 이야기를 다섯 편에 나눠 썼다. 이번 편이 마지막이고, 앞에서 다 못 담은 것들을 모았다.

모아놓고 보니 공통점이 하나 있었다. 넷 다 처음 잡은 방향이 틀렸던 것이고, 그걸 알려준 게 매번 달랐다. 어떤 건 이름이 알려줬고, 어떤 건 화면이 알려줬고, 어떤 건 아무도 안 알려줘서 한참 헤맸다.

이번 글이 답할 질문. 방향이 틀렸다는 것은 무엇을 통해 드러나는가?


1. 게임이 끝나게 만들기

이날 아침까지 이 게임은 끝나지 않는 상태였다. 좀비가 나오고 때리면 죽는데, 이기는 것도 지는 것도 없다.

원래 계획에는 게임 오버만 있고 이기는 조건이 없었다. 보스를 이번 범위에서 뺐기 때문인데, 그러면 루프가 안 닫힌다. 그래서 5분을 버티면 이긴 것으로 잠정 처리했다.

LOSE와 재시작 버튼이 뜬 화면

붙이는 동안 걸린 것들이 있었다.

남은 시간이 0 아래로 계속 내려갔다. 타이머가 다 됐는데도 매 프레임 빼는 걸 멈추지 않아서다. 다 되면 그 컴포넌트를 꺼서 더 안 돌게 했다.

재시작하면 화면이 얼어붙었다. 이게 제일 오래 걸렸다.

게임이 끝날 때 Time.timeScale = 0으로 세상을 멈춰둔다. 그리고 재시작은 씬을 통째로 다시 부르는 방식이다. 그러면 다 초기화될 것 같은데, 시간 배속만은 안 돌아온다. 그건 씬이 아니라 게임 전체에 걸린 설정이라서다.

void Awake()
{
    _outcome = GameOutcome.Pending;
    Time.timeScale = 1f;   // 씬을 다시 불러도 이건 안 돌아온다
}

끄는 쪽이 아니라 켜지는 쪽에 뒀다. 재시작 방법이 나중에 늘어나도 시작할 때 항상 1로 맞춰지니 자동으로 커버된다.

이기지도 지지도 않았는데 결과가 승리로 잡혔다. 결과를 담는 목록에서 승리를 맨 앞에 뒀는데, C#은 그런 목록의 기본값이 맨 앞 항목이다. 아무것도 대입 안 하면 승리 상태로 시작한다.

미정 / 승리 / 패배 순서로 바꾸고, 시작할 때 미정을 직접 넣어줬다. 순서로 한 번, 대입으로 한 번 두 겹으로 막았다.

끝났다는 신호가 영원히 안 왔다. 신호를 만들어두고 부르는 줄을 안 썼다. 선언만 하고 발화를 빼먹은 것인데, 아무 에러가 안 나니 화면 쪽만 계속 들여다봤다..

신호를 안 보내는 코드는 조용하다. 아무 일도 안 일어나는 버그가 뭔가 터지는 버그보다 찾기 어렵다.


2. 이름을 두 번 바꿨다

승패를 담는 목록을 처음엔 GameState라고 불렀다. 게임의 상태니까 자연스러워 보였는데, 쓰다 보니 이름이 자꾸 걸렸다.

기획 문서에는 화면 단계가 따로 있다. 일반 / 보스전 / 결과 이렇게 셋이다. 여기서 “결과”는 화면 단계를 뜻하고, 내가 만든 목록의 승리·패배는 판의 승패를 뜻한다.

같은 단어가 두 층에서 다른 뜻으로 쓰이고 있었다

둘은 같은 게 아니라 포함 관계다. 결과 화면에 들어간 시점엔 승패가 이미 정해져 있다.

문제는 나중이다. 보스를 붙이면 화면 단계를 담는 두 번째 목록이 필요해지는데, 그때 GameState(승패)와 GamePhase(화면)가 나란히 있으면 이름만 보고 구분이 안 된다. GameState는 사실 둘 다에 어울리기 때문이다.

그래서 GameOutcome으로 바꿨다. GameState라는 이름은 안 쓰고 비워뒀다.

같이 바꾼 것들이다.

처음 바꾼 뒤 이유
GameState GameOutcome 상태가 아니라 승패다
Running Pending “돌고 있다”는 상태의 말, “아직 안 정해졌다”가 결과의 말
GameResult (클래스) GameEnder 다른 클래스들이 전부 [대상] + [하는 일] 형태다

마지막 게 재밌었다. 클래스 이름이 GameResult인데 그 안에 결과 목록도 있으니 GameResult.Result.Win이 된다. “결과의 결과의 승리” 로 읽힌다.

기존 클래스들을 보니 전부 CameraFollow(카메라를 따라가게), EnemyChase(적이 쫓게), DamageFlash(피격 시 번쩍이게) 형태였다. 그 규칙에 맞추면 이건 게임을 끝내는 것이니 GameEnder다.

오늘만 세 번 있었다

같은 일이 하루에 세 번 있었다.

이름 문제
Result vs Outcome 화면 단계와 승패가 같은 단어를 썼다
AttackSpeed 이름은 속도인데 실제로는 간격을 반환하고 있었다
OnDamaged vs OnHealthChanged 사건과 상태가 한 신호에 얹혀 있었다

셋 다 “단어 하나가 두 층에서 다른 뜻으로 쓰인” 같은 문제였다.

이름을 고치려고 들여다보는 순간 설계 결함이 드러났다. 이름이 안 지어지면 대체로 구조가 안 정해진 것이다.


3. 심장은 이벤트가 아니었다

경험치 심장을 먹는 처리를 처음엔 이렇게 짰다. 줍는 쪽이 “주웠다”는 신호를 쏘고, 심장들이 각자 그 신호를 듣는다.

써놓고 보니 안 되는 구조였다. 신호에 “어느 심장인지”가 안 실려 있다. 바닥에 심장이 300개 깔려 있으면, 하나를 밟는 순간 300개가 전부 자기가 먹혔다고 판단하고 사라진다.

방향이 반대였다.

신호는 누가 관심 있는지 발신자가 모를 때 쓰는 도구다. 여기서는 정확히 하나를 지목해야 했다.

그리고 그 하나는 이미 손에 있었다. 트리거가 겹칠 때 유니티가 어느 것과 닿았는지를 인자로 넘겨준다.

void OnTriggerEnter2D(Collider2D other)
{
    if (!other.TryGetComponent<Heart>(out Heart heart))
    {
        return;
    }

    int value = heart.Collect();
    _experience.Add(value);
}

신호를 걷어내니 심장 쪽에서 관련 필드와 구독·해제 코드가 전부 사라졌다.

값을 주면서 자기를 치운다

직접 부르는 구조로 바꾸면서 심장 쪽 함수를 이렇게 뒀다.

public int Collect()
{
    _release(this.gameObject);   // 자기를 회수시키고
    return _value;               // 경험치량을 넘긴다
}

한 번 부르면 경험치를 받는 것과 심장이 사라지는 것이 같이 일어난다. 나눠두면 값만 받고 안 치우거나, 치우고 값을 안 받는 실수가 가능해진다.

같이 일어나야 하는 두 가지는 한 번에 일어나게 묶는다. 그러면 한쪽만 하는 실수가 아예 불가능해진다.

치우는 방법은 심장이 직접 정하지 않는다. 만들어준 쪽이 “다 쓰면 이걸 불러라”를 미리 꽂아준다. 좀비를 치울 때 쓰던 방식과 같다.

죽음은 왜 신호로 두는가

좀비가 죽는 처리는 여전히 신호를 구독한다. 심장과 뭐가 다른가 싶어 정리해봤다.

  죽음 심장 획득
어디서 결정되나 좀비 자기 안에서 (체력이 0이 됨) 에서 (플레이어가 밟음)
방식 구독 직접 호출

소멸이 자기 안에서 결정되면 구독, 밖에서 결정되면 직접 호출. 그 차이 하나로 갈렸다.

몸에 안 붙인 것들

심장은 300개가 깔린다. 그래서 뭘 안 붙일지도 같이 봤다.

심장 프리팹에는 컴포넌트가 셋뿐이다

물리 컴포넌트를 안 붙였다. 트리거 신호는 닿는 둘 중 한쪽에만 있으면 발생한다. 플레이어에 이미 있으니 심장 300개에 각각 붙일 이유가 없다.

충돌 검사 대상도 줄였다. 유니티에는 어떤 종류끼리 충돌을 검사할지 정하는 표가 있는데, 거기서 심장끼리, 그리고 심장과 좀비를 꺼뒀다. 안 그러면 좀비가 심장을 밀고 다니고, 심장 300개끼리 서로 검사하느라 헛일을 한다.

획득 판정을 플레이어 본체가 아니라 자식 오브젝트에 뒀다. 본체 충돌 범위는 이미 피격 처리가 쓰고 있어서, 같은 곳에 판정을 하나 더 얹으면 어느 신호가 어느 범위에서 온 건지 구분이 안 된다.

경험치가 두 곳으로 갈라질 뻔했다

여기서 낸 실수 하나가 제일 아슬아슬했다.

획득 판정은 자식 오브젝트에 있는데, 거기에 “경험치 저장소가 반드시 있어야 한다”는 표시를 붙였다. 이 표시는 같은 오브젝트에 그 컴포넌트를 강제하는 것이라, 유니티가 자식에 저장소를 하나 더 만들어버렸다.

그러면 판정은 자식 것에 경험치를 넣고, 레벨업은 본체 것을 보게 된다. 경험치가 두 곳으로 갈라진다. 그런데 화면에는 아무 에러도 안 났다.

고친 방법은 표시를 떼고 부모 쪽에서 찾아오게 한 것이다.

_experience = GetComponentInParent<PlayerExperience>();
if (_experience == null)
{
    Debug.LogError("값을 저장할 PlayerExperience 가 매핑되지 않았습니다.", this);
    enabled = false;
}

없으면 만들어버리는 대신 없다고 알리고 자기를 끈다. 그리고 부모와 자식이 하나의 프리팹으로 묶여 있으니, 씬에서 연결을 빼먹는 실수는 애초에 생길 수 없다.

이건 앞에서 쓰던 것과 같은 방식이다. 무기가 같은 오브젝트의 조준 정보를 직접 찾아 쓰던 것을, 프리팹 안쪽 계층까지 넓힌 것이다.

대입과 비교를 헷갈린 것도 있었다. ==를 써야 하는데 =를 하나만 썼는데, 이건 컴파일러가 막아줬다. 담긴 게 참·거짓이 아니라서다.

같은 실수인데 하나는 컴파일러가 잡아주고 하나는 조용히 지나간다. 위험한 쪽은 언제나 조용한 쪽이다.


4. 부채꼴은 타원으로 흉내낼 수 없다

공격이 나갈 때 뭔가 번쩍이게 하고 싶었다. 공격 판정은 앞쪽 90도 부채꼴 모양인데, 그 모양의 그림이 없었다.

그래서 동그란 그림을 눌러 타원으로 만들어 흉내내려 했다. 실패했다.

타원이 화면 상당 부분을 덮어 좀비가 가려졌다

부채꼴은 앞으로 갈수록 벌어지고, 타원은 가운데가 제일 넓다. 모양이 근본적으로 달라서 어떻게 늘리고 줄여도 안 맞는다.

크기를 판정 범위에 맞추자 타원이 화면을 상당히 덮어 좀비가 안 보였다. 그래서 투명하게 만들어봤는데 이것도 아니었다.

0.1초 번쩍이는 연출을 옅게 만들면 아예 안 보인다. 문제는 투명도가 아니라 크기였다.

여기서 얻은 규칙 하나.

짧게 번쩍이는 연출은 작고 진하게. 크고 옅게가 아니다.

결국 판정 범위를 그대로 보여주는 건 포기했다. “공격이 나갔다”는 신호만 전달하기로 하고, 타원 대신 사각형을 조준 방향 앞쪽에 붙였다.

공격 방향 앞쪽에 사각형이 잠깐 나타난다

크기와 위치는 사거리에서 뽑는다.

float range = _weapon.Range;
_flashSprite.transform.localScale = new Vector3(range, range * 1.2f, 1f);
_flashSprite.transform.localPosition = new Vector3(range / 2, 0f);

한 변을 사거리로 잡고, 절반만큼 앞으로 밀었다. 그러면 사각형의 안쪽 끝이 플레이어에 붙고 바깥 끝이 사거리에 닿는다. 사거리 카드를 먹어서 범위가 늘어나면 연출도 같이 커진다.

부채꼴은 아니지만 어느 쪽을 쳤고 어디까지 닿는지는 읽힌다. 그리고 나중에 부채꼴 그림을 구하면 그림만 갈아끼우면 되고 코드는 안 바뀐다.

연출이 판정을 그대로 그릴 필요는 없었다. 방향과 대략의 거리만 맞으면 플레이어는 알아본다.

연출을 무기가 소유하지 않는다

구조는 앞에서 쓰던 것과 같게 뒀다. 무기가 “공격했다”는 신호를 쏘고, 연출 담당이 그걸 듣는다. 무기가 그림을 직접 들고 있으면 공격 로직이 연출까지 소유하게 된다.

신호를 쏘는 위치는 맞은 대상을 훑는 반복문 밖이다. 맞았든 안 맞았든 휘두른 건 사실이기 때문이다.

연출은 도는 중이면 끊고 다시 시작하는 방식으로 했다. “도는 중이면 무시”로 두면, 공격속도가 연출 길이보다 빨라졌을 때 켜진 채로 유지되고 이후 공격이 씹힌다.

다만 끊는 방식에는 함정이 하나 있다. 중간에 끊으면 뒷정리 코드가 실행되지 않는다. 지금은 끊자마자 다음 줄에서 다시 켜니 문제가 없지만, 흔히 놓치는 자리라 적어뒀다.

회전은 부모를 돌린다. 그림을 직접 돌리면 자기 중심에서 제자리 회전한다. 플레이어를 중심으로 돌게 하려면 한 단계 위를 돌려야 한다.


5. 에디터가 조용히 삼킨 것

꺼져 있는 오브젝트에서는 시작 함수가 아예 안 불린다.

결과 화면 패널은 평소에 꺼둔다. 그런데 거기에 스크립트를 붙이면, 꺼져 있으니 시작 함수가 안 불리고 신호 구독이 영원히 안 일어난다. 게임이 끝나도 화면이 안 뜬다.

그래서 규칙을 하나 정했다. 화면 담당 스크립트는 항상 켜져 있는 곳에 붙이고, 꺼두는 패널은 참조로만 들고 있는다.

화면 뷰에서 UI가 터무니없이 커 보였다. 화면에 딱 붙는 UI는 픽셀 단위로 산다. 기준 해상도가 1920이면 가로 폭이 1920 “칸”인데, 게임 속 플레이어는 1~2칸짜리다. 나란히 놓으면 UI가 산더미처럼 보인다.

그런데 실제로 그릴 때는 카메라를 안 거치고 다 그려진 화면 위에 덧그려진다. 그래서 결과는 정상이다. 작업 화면에서 크기를 비교하는 것 자체가 의미가 없었다.


정리

  • 시간 배속은 씬을 다시 불러도 안 돌아온다. 끄는 쪽이 아니라 켜지는 쪽에서 되돌려야 경로가 늘어도 커버된다.
  • 목록형 값의 기본값은 맨 앞 항목이다. 순서와 대입 두 겹으로 막는다.
  • 신호를 선언만 하고 안 보내면 아무 에러도 안 난다. 조용한 버그가 더 오래 걸린다.
  • 이름이 안 지어지면 구조가 안 정해진 것이다. 이름을 고치려고 보는 순간 설계 결함이 드러난다.
  • 한 단어가 두 층에서 다른 뜻으로 쓰이면 나중에 반드시 충돌한다. 오늘만 세 번 나왔다.
  • 소멸이 자기 안에서 결정되면 구독, 밖에서 결정되면 직접 호출.
  • 신호에 대상이 안 실려 있으면 전부가 반응한다. 하나를 지목해야 하면 신호가 아니다.
  • 같이 일어나야 하는 두 가지는 한 번에 일어나게 묶는다. 한쪽만 하는 실수가 불가능해진다.
  • 없으면 만들어주는 것보다 없다고 알리는 편이 낫다. 조용히 만들어진 두 번째 저장소가 경험치를 갈라놨다.
  • 300개 깔리는 것에는 무엇을 안 붙일지도 설계다.
  • 짧게 번쩍이는 연출은 작고 진하게. 옅게 만들면 그냥 안 보인다.
  • 연출이 판정을 그대로 그릴 필요는 없다. 방향과 대략의 거리만 맞으면 읽힌다.
  • 꺼져 있는 오브젝트는 시작 함수도 안 불린다. 구독은 항상 켜진 곳에서 건다.

참고 자료

한줄 평

  • 하루치를 여섯 편으로 나눠 쓰고 나니 그날 제일 오래 붙잡은 게 코드가 아니라 이름이었다는 게 보인다..