개요

지난 편에서 카드 선택 창을 붙였다. 카드를 고르면 체력이 오르고 공격력이 오른다.

그런데 화면에는 그게 안 보인다. 체력이 100인지 120인지, 대시를 지금 쓸 수 있는지 없는지 전부 감으로 해야 한다. 그래서 체력바와 대시 쿨타임 바를 붙이는 게 이번 작업이었다.

붙이는 것 자체는 어렵지 않았다. 오래 걸린 건 “이미 있는 걸 그대로 쓰면 안 되나” 를 확인하는 쪽이었다.

이번 글이 답할 질문. 이미 있는 신호를 재활용해도 되는지 어떻게 판단하는가?


1. 피격 이벤트를 체력바에 재활용해도 되나

체력이 깎이는 순간을 알려주는 신호는 이미 있다. 맞았을 때 발화하는 OnDamaged다. 체력바도 결국 체력이 변할 때 갱신하면 되니 그대로 쓰면 될 것 같았다.

한 가지 걸리는 게 있었는데, 카드로 최대 체력을 올릴 때였다. 이건 이렇게 처리하고 있다.

public void AddMaxHealth(float amount)
{
    // 기존 최대체력 대비 현재체력 비율을 저장
    float ratio = _currentHealth / _maxHealth;
    _maxHealth += amount;
    // 증가 비율에 맞춰 현재체력 계산
    _currentHealth = _maxHealth * ratio;
}

비율을 유지하니까 바의 길이는 안 변한다. 절반이었으면 늘어난 뒤에도 절반이다. 그럼 갱신을 안 해도 티가 안 나겠다 싶었다.

그런데 두 가지가 걸렸다

첫째, 숫자를 같이 띄우면 들킨다. 바 옆에 50 / 100 같은 표시를 붙이면, 카드를 먹어도 그대로 50 / 100에 머문다. 그러다 다음에 한 대 맞는 순간 50 / 120으로 값이 점프한다. 늘어난 시점과 표시가 바뀌는 시점이 어긋난다.

둘째가 결정적이었다. OnDamaged를 누가 구독하고 있는지 확인하다가 걸렸다.

// DamageFlash
void OnEnable()  { _health.OnDamaged += Flash; }

피격 시 스프라이트를 빨갛게 번쩍이는 연출이 같은 신호를 듣고 있다. 여기에 체력바까지 태우면, 카드를 먹어서 체력이 늘어날 때도 플레이어가 맞은 것처럼 빨갛게 번쩍인다.

실제로 그렇게 만들어서 본 건 아니다. 구독자 목록을 훑다가 “아 이거 그대로 나가겠는데” 하고 멈춘 쪽에 가깝다. 그래도 결론은 같았다.

좋아지는 일에 아파 보이는 연출이 나가면 게임이 플레이어에게 거짓말을 하는 셈이다.

사건과 상태는 다른 질문에 답한다

문제를 정리하니 신호 하나가 두 가지 뜻을 겸하고 있었다.

신호 답하는 질문
OnDamaged 사건 “방금 맞았다”
OnHealthChanged 상태 “체력 값이 달라졌다”

맞으면 둘 다 참이다. 그런데 카드를 먹었을 때는 상태만 참이고 사건은 거짓이다. 하나로 겸하게 두면 그 차이를 표현할 방법이 없다.

신호 하나에 두 가지 뜻을 담으면 엉뚱한 곳이 반응한다

그래서 신호를 둘로 나눴다. TakeDamage는 둘 다 발화하고, AddMaxHealth는 상태 쪽만 발화한다.

_currentHealth = Mathf.Max(_currentHealth - amount, 0f);
OnDamaged?.Invoke();         // 사건
OnHealthChanged?.Invoke();   // 상태

이름도 그 차이를 담게 지었다. Damaged는 일어난 일이고 Changed는 달라진 값이다.


2. 이벤트로 할까, 계속 물어볼까

바를 두 개 붙이면서 갱신 방식이 갈렸다. 체력바는 이벤트로 하고, 대시 쿨타임 바는 매 프레임 값을 물어보는 방식으로 했다.

여기서 매 프레임 물어보는 걸 폴링이라고 부른다. “지금 얼마야?”를 계속 묻는 것이다.

같은 UI인데 방식을 다르게 한 기준은 하나였다.

갱신 방식은 값이 얼마나 자주 변하는지로 정한다

대상 얼마나 자주 변하나 방식
체력 맞았을 때, 카드 먹었을 때만 이벤트
대시 쿨타임 매 프레임 줄어든다 폴링
남은 시간 매 프레임 줄어든다 폴링

체력을 폴링하면 값이 그대로인 대부분의 프레임에서 읽고 계산하고 대입한다. 반대로 쿨타임을 이벤트로 하면 초당 50번씩 발화하게 된다. 물리 갱신이 그 주기라서 그렇다.

각각 반대쪽을 고르면 양쪽 다 낭비가 된다. 값이 얼마나 자주 변하는지가 방식을 정한다.

더 쉬운 비유로

택배가 오면 알려달라고 부탁해두는 것과
5초마다 현관에 나가보는 것

하루에 한 번 오는 택배라면 부탁해두는 게 맞다. 그런데 계속 물이 끓고 있는 냄비라면 알림을 걸 게 아니라 그냥 보고 있는 게 낫다. 알림이 1초에 50번 울리면 그게 알림인지 소음인지 모른다.


3. 이벤트가 알려주지 않는 것 — 처음 값

이벤트로 바꾸고 나서 바로 걸린 게 있다. 게임을 켜자마자 체력바가 비어 있었다.

당연했다. 이벤트는 “바뀌었다”만 알려준다. 체력 초기화는 이렇게 되어 있는데,

void Awake()
{
    _currentHealth = _maxHealth;   // 여기서는 아무 신호도 안 나간다
}

여기서 발화가 없으니 UI는 첫 피격을 맞을 때까지 아무것도 못 듣는다.

그래서 UI 쪽에서 한 번 직접 읽어줘야 한다.

void Start()
{
    _health.OnHealthChanged += HandleHealthChanged;
    // 초기 1회 동기화
    HandleHealthChanged();
}

이벤트 기반 UI에서 제일 흔하게 빠뜨리는 자리다. 구독만 걸어두고 초기값을 안 읽으면, 첫 변화가 오기 전까지 화면이 거짓말을 한다.

대입 순서도 걸렸다

바의 최대치와 현재값을 넣는 순서에도 함정이 있었다.

_slider.maxValue = maxHealth;    // 최대치를 먼저
_slider.value = currentHealth;   // 그다음 현재값

슬라이더는 현재값을 최대치 범위 안으로 잘라낸다. 그러니 순서를 뒤집으면, 최대 체력이 100에서 120으로 오를 때 새 값 120이 아직 100인 옛 상한에 잘린다.

한 번 잘리고 나면 그다음에 최대치를 올려도 값은 이미 100으로 줄어든 뒤다.

쿨타임 바는 차오르게

방향도 정해야 했다. 남은 시간을 그리면 줄어들고, 준비된 정도를 그리면 차오른다.

_slider.value = _movement.DashCooldown - _movement.DashCooldownRemaining;

“준비됨”이 가득 찬 상태여야 직관적이라 차오르는 쪽으로 했다. 게임에서 빈 바는 대체로 “못 쓴다”는 뜻이니까.


4. 만피인데 바가 끝까지 안 찼다

붙이고 나서 이게 걸렸다.

체력이 가득인데 바 오른쪽 끝이 살짝 비어 있다

체력은 만피인데 바 오른쪽 끝이 조금 남아 있다. 값 문제인가 싶어 찍어봤는데 숫자는 정확히 최대치였다.

원인은 코드가 아니라 UI 쪽이었다. 유니티 슬라이더는 원래 손잡이(Handle)를 끼울 여백을 갖고 태어난다. 손잡이를 지워도 그 여백은 남는다.

여백 설정을 0으로 맞춰도 해결이 안 돼서 한참 헤맸는데, 진짜 원인은 다른 데 있었다.

Fill의 Rect Transform에 Some values driven by Slider 경고가 떠 있다

채워지는 부분의 가로 폭에 기본값이 남아 있었다.

여기가 헷갈리는 지점이다. 이 칸에는 Some values driven by Slider. 라는 경고가 떠 있다. “이 값들은 슬라이더가 알아서 관리한다”는 뜻이라, 나는 폭도 당연히 거기 포함되는 줄 알았다.

슬라이더가 관리하는 값과 안 하는 값이 섞여 있다. 경고 문구는 그중 무엇이 관리 대상인지까지 말해주지 않는다.

폭을 0으로 맞추니 바가 끝까지 찼다. 원인이 값 계산 쪽인 줄 알고 코드만 들여다본 시간이 아까웠다..


5. 미뤄둔 것 — 대시를 떼어내면 생기는 경합

대시 관련 값을 UI에 노출하면서 “이참에 대시를 별도 컴포넌트로 빼는 게 낫지 않나” 싶었다. 무적 시간 같은 것도 결국 거기 붙을 테니까.

그런데 이번엔 안 하기로 했다. 미룬 게 맞다고 판단했다.

이유가 둘이다.

첫째, 리팩토링은 따로 커밋해야 한다. UI를 붙이는 커밋에 구조 변경이 섞이면 나중에 뭘 왜 바꿨는지 안 보인다.

둘째, 아직 안 드러난 결합이 있다. 지금 이동은 매 물리 갱신마다 이렇게 돌고 있다.

void FixedUpdate()
{
    _rigidbody.linearVelocity = _movementInput * _moveSpeed;
    // ...
}

속도를 매번 통째로 덮어쓴다. 대시를 다른 컴포넌트로 빼면 그 컴포넌트도 같은 물체를 움직이려 들 텐데, 한쪽이 매 프레임 속도를 덮어쓰고 있으면 다른 쪽이 뭘 해도 지워진다.

지금은 이게 안 드러난다. 대시가 속도를 주는 게 아니라 위치를 직접 옮기는 순간이동이라서다. 가속 방식으로 바꾸는 순간 바로 문제가 된다.

분리의 진짜 설계 지점은 컴포넌트를 나누는 게 아니라, 둘이 같은 물체를 어떻게 나눠 쓸지 정하는 쪽이다.

그래서 지금 노출해둔 쿨타임 값은 나중에 새 컴포넌트로 따라가면 되고, UI는 참조 대상만 바꾸면 된다. 지금 한 줄이 나중 작업을 막지는 않는다.

잘라낸 것

스탯 5종을 화면에 실시간으로 띄우는 것도 하려다 못 했다. 시간이 없었다..

다음에 하더라도 공짜는 아니다. 지금 스탯이 세 컴포넌트에 흩어져 있어서 전부 참조해야 하고, 레벨업마다 갱신을 걸어야 한다. 스탯을 각 컴포넌트가 소유하게 한 대가가 여기서 청구되는 셈이다.


정리

  • 사건과 상태는 다른 질문에 답한다. “방금 일어났다”와 “값이 이렇다”를 한 신호로 겸하면 한쪽이 거짓말을 한다.
  • 재활용 전에 구독자를 먼저 본다. 신호를 늘리는 건 안전한데, 있는 신호에 올라타면 이미 듣고 있던 쪽까지 같이 반응한다.
  • 갱신 방식은 변화 빈도로 정한다. 가끔 변하면 이벤트, 계속 변하면 폴링. 반대로 고르면 양쪽 다 낭비다.
  • 이벤트는 초기값을 알려주지 않는다. 구독을 걸었으면 그 자리에서 한 번 직접 읽어야 한다.
  • 최대치를 먼저, 현재값을 나중에. 순서가 반대면 새 값이 옛 상한에 잘린다.
  • 차오르는 바가 “쓸 수 있다”에 맞다. 빈 바는 대체로 못 쓴다는 뜻으로 읽힌다.
  • 엔진이 관리해주는 값과 아닌 값이 섞여 있다. 경고 문구가 어디까지인지는 말해주지 않는다.
  • 구조를 나누는 것보다 나눈 뒤 누가 무엇을 쓸지가 어렵다. 매 프레임 속도를 덮어쓰는 코드가 그 자리다.
  • 리팩토링은 따로 커밋한다. 기능 커밋에 섞이면 나중에 이유가 안 보인다.

참고 자료

한줄 평

  • 붙이는 건 30분이었고 “이거 그냥 재활용하면 안 되나”를 확인하는 데 나머지를 다 썼는데, 그 확인이 없었으면 게임이 조용히 거짓말을 할 뻔했다!