버튼을 한번 맨들어보자

민트초코케밥·2025년 12월 28일

유니티 게임 개발

목록 보기
1/3

버튼 UI는 간단해 보이지만 또, 어려운 영역입니다.
다양한 상태가 존재하고, 그에 따른 경우의 수도 많으며,
보여줘야 할 비주얼과 예외 상황도 생각보다 많습니다.

게임에서 필수적인 요소인 만큼,
아키텍처를 잘 잡아두지 않으면 최종적으로 개발 과정에서 꽤 많은 로스를 발생시킵니다.

UI가 켜졌을 때의 초기화
Hover Index 관리
Animator 관리
애매하게 어려운 디버깅

원래 이전에 시도했던 방식은
유니티 애니메이터를 이용한 유사 FSM 구조였습니다.

여러 병렬 레이어와 트리거, 조건을 조합해
버튼의 비주얼 상태를 제어하는 방식이었는데,
중간에 애니메이터가 끊기거나 비활성화될 때의 리스크가 꽤 컸습니다.

무엇보다 만족스럽지 않았던 점은,
모든 버튼이 특별한 연출을 필요로 하지는 않는데도
단순한 버튼까지 애니메이터 기반으로 관리해야 한다는 점이었습니다.

제작과 유지보수 모두 번거롭다는 인상이 강했습니다.

그래서 이번에는 조금 다른 접근을 시도했습니다.

단일 SSOT

저는 개인적으로 SSOT Context로 시스템을 제어하는 것을 선호합니다.
버튼도 마찬가지로, 하나의 소스만 보고 판단할 수 있어야 디버깅이 쉬워질 것이라 판단했습니다.

그래서 버튼의 상태를 정의할 무언가가 필요했고... 처음에는 이런 식으로 만들었습니다:

public enum ButtonState
{
None,
Default,
Hover,
Focused,
Pressed,
Selected,
Deactivated,
...
}

음... 이렇게 상태를 정의해두고 시작해봤을 때,
디폴트면서 호버, 선택됨이면서 호버... 뭐 암튼 여러 기상천외한 조합의 규칙과 전이를 만드려니까 이건 아니다 싶었습니다.

대신 배타적 상태와, 중첩 상태를 나누어서 정의했습니다.

  • ExclusiveButtonState: 서로 배타적인 상태
  • CompositeButtonState: 동시에 존재할 수 있는 입력 상태
public enum ExclusiveButtonState
{
    Default,
    Selected,
    Activated,
    Deactivated,
}

[Flags]
public enum CompositeButtonState
{
    None    = 0,
    Hover   = 1 << 0,
    Pressed = 1 << 1,
    Focused = 1 << 2,
}

public class ButtonContext
{
    public ExclusiveButtonState Exclusive { get; private set; }
    public CompositeButtonState Composite { get; private set; }
}

물론 정책이 바뀔 수 있겠지만, 디폴트와 Deactivated는 양립이 불가한 상태라고 '정의'했습니다. 일단 제 프로젝트에 한해서는요.
그 위에 Hover, Focused, Pressed 같은 입력 상태를 Composite로 허용했습니다.

그리고 허용 규칙에 대해서는...

굳이 막을 필요는 없잖아?

음... 항상 이런 방법이 해결책이 될 때가 많았습니다:

내부가 어떻든, 안 보여주기만 하면 됩니다.

Deactivated같은 상황에서 Hover를 막기보단 그냥 안 보여주기만 해도 된다고 판단했습니다. (Focused는 상위 레벨에서 관리하면 되는 거고, 어차피 버튼은 할 수가 없는 영역입니다.)

그래서 VisualController를 따로 만들어서 Context를 기반으로 재계산해서 표현 계산에만 사용하는 visual context를 따로 만든 다음, "어떤 레이어를 보여줄지"만 판단하게 했습니다.

VisualSnapshot ComputeSnapshot(
    ExclusiveButtonState exclusive,
    CompositeButtonState composite)
{
    return new VisualSnapshot
    {
        HoverVisible = composite.HasFlag(CompositeButtonState.Hover),
        SelectedVisible = exclusive == ExclusiveButtonState.Selected,
        DeactivatedVisible = exclusive == ExclusiveButtonState.Deactivated,
        PressedTrigger = composite.HasFlag(CompositeButtonState.Pressed)
    };
}

Hover는 입력 사실이고, 보여줄지는 정책입니다.
그래서 Hover는 상태로 남기고, 표현만 필터링합니다.

if (isDeactivated)
{
    // Hover 상태는 유지
    // 하지만 비주얼에서는 숨김
    snapshot.HoverVisible = false;
}

혹시 몰라서 정책을 Gate로 분리했고,
인스펙터에서 버튼마다 Boolean으로 조절할 수 있게 했습니다.

// 버튼마다 "어떤 상태에서 Hover를 보여줄지"를 결정하는 정책
[Serializable]
public class ButtonVisualStateGate
{
    [SerializeField] bool showHoverWhenDeactivated = false;
    [SerializeField] bool showHoverWhenActivated   = true;
    [SerializeField] bool showHoverWhenSelected    = true;

    public ButtonContext Apply(
        ButtonContext src,
        ExclusiveButtonState exclusive,
        bool inSelectedDuration,
        bool inDeactivatedDuration,
        bool inActivatedDuration)
    {
        var visual = src.Clone();

        if (IsDeactivated(exclusive, inDeactivatedDuration) && !showHoverWhenDeactivated)
            visual.Unset(CompositeButtonState.Hover);

        if (IsActivated(exclusive, inActivatedDuration) && !showHoverWhenActivated)
            visual.Unset(CompositeButtonState.Hover);

        if (IsSelected(exclusive, inSelectedDuration) && !showHoverWhenSelected)
            visual.Unset(CompositeButtonState.Hover);

        return visual;
    }

    bool IsDeactivated(...) => ...;
    bool IsActivated(...)   => ...;
    bool IsSelected(...)    => ...;
}

원본 Context는 그대로 두고,
표현 계산에만 쓰는 복사본을 필터링하는 구조입니다.

전이는 레이어마다 관리

버튼을 만들다 보면 결국 보간을 가장 많이 쓰게 됩니다.
그리고 보간할 레이어는 자유롭게 추가하고 싶었습니다.

그래서 상태별로 Receiver 리스트를 두고,
해당 레이어를 한꺼번에 보간하도록 구성했습니다.

 private readonly List<ButtonReceiverInstance> _defaultReceivers = new List<ButtonReceiverInstance>(16);
 private readonly List<ButtonReceiverInstance> _hoverReceivers = new List<ButtonReceiverInstance>(16);
 private readonly List<ButtonReceiverInstance> _pressedReceivers = new List<ButtonReceiverInstance>(16);
 private readonly List<ButtonReceiverInstance> _selectedReceivers = new List<ButtonReceiverInstance>(16);
 private readonly List<ButtonReceiverInstance> _deactivatedReceivers = new List<ButtonReceiverInstance>(16);
 private readonly List<ButtonReceiverInstance> _activatedReceivers = new List<ButtonReceiverInstance>(16);

AttackDuration과 ReleaseDuration으로 Set, Unset 시간을 조절하고,
Receiver 쪽에는 Override를 두어 필요하면 개별적으로 덮어쓸 수 있게 했습니다.

그 다음 레이어마다 콜백을 모아,
모든 Receiver의 콜백이 돌아왔을 때 duration 플래그를 정리합니다.

[ShowInInspector, ReadOnly] bool IsInHoverDuration;
[ShowInInspector, ReadOnly] bool IsInSelectedDuration;
[ShowInInspector, ReadOnly] bool IsInDeactivatedDuration;
[ShowInInspector, ReadOnly] bool IsInActivatedDuration;

// 같은 레이어 요청이 겹치면, 최신 요청만 유효
int _hoverSeq, _selectedSeq, _deactivatedSeq, _activatedSeq;

void ApplyDurationLayer(LayerState layer, bool active, float duration,
                        List<ButtonReceiverInstance> receivers, Action onComplete)
{
    int seq = ++GetSeqRef(layer);          // 최신 요청 토큰
    SetDurationFlag(layer, true);
    StartTimeout(layer, seq, active);

    int pending = 0;

    foreach (var r in receivers)
    {
        if (r == null) continue;
        pending++;

        r.SetStateVisual(active, duration, () =>
        {
            if (--pending > 0) return;

            // 마지막 콜백 + 최신 요청이면만 duration 종료
            if (seq == GetSeqRef(layer))
            {
                SetDurationFlag(layer, false);
                StopTimeout(layer);
            }

            onComplete?.Invoke();
        });
    }

    // receiver가 없으면 즉시 종료
    if (pending == 0)
        SetDurationFlag(layer, false);
}

ref int GetSeqRef(LayerState layer) => ref ...;
void SetDurationFlag(LayerState layer, bool on) => ...;
void StartTimeout(LayerState layer, int seq, bool active) => ...;
void StopTimeout(LayerState layer) => ...;

이 플래그들은 상위 컨트롤러가 읽어서
Hover나 Pressed를 제어하는 신호로 사용합니다.
물론 필요 없다면 인스펙터에서 끌 수도 있습니다.

Receiver 계약은 State, Duration, onComplete만

비주얼 컨트롤러는 전이가 끝났다는 것만 알면 되기 때문에 리시버인 버튼 인스턴스가 뭔 짓을 하든 알 바가 아니게 됩니다.
일단 기본은 보간을 염두해두고 짰지만 Complete 콜백만 제대로 오게 되면
Tween을 하든, 애니메이터를 쓰든, 그냥 뻥카를 치든 아무 상관이 없게 됩니다.
이렇게 하면 Context의 순수성을 유지한 채, 표현 방식만 자유롭게 교체할 수 있습니다.

r.SetStateVisual(isActive, duration, () =>
{
    // VisualController는
    // "이 레이어의 애니메이션이 끝났다"는 사실만 알면 된다
    OnLayerComplete();
});

Receiver 쪽 구현 (어떤 방식이든 상관 없음)

public void SetStateVisual(bool isActive, float duration, Action onComplete)
{
    // Tween을 쓰든
    // Animator를 쓰든
    // 즉시 끝내든

    PlayVisual(isActive, duration)
        .OnComplete(() => onComplete?.Invoke());
}

이제 상위 컨트롤러는 컨텍스트만 관리합니다.

이런 식으로 SSOT와 플래그 규칙을 잡으면 상위 컨트롤러는 "지금 버튼이 무엇인지"만 ButtonContext로 소유하고, 그걸 비주얼 쪽에 던지기만 하면 됩니다.
현재 애니메이션이 어디까지 진행됐는지, 어떤 방식으로 보간하는지, 전이가 어떤 순서로 이어졌는지는 컨트롤러가 알 필요가 없습니다.

컨트롤러가 비주얼 쪽과 공유하는 건 딱 두 가지입니다.

  • 현재 상태(ButtonContext)
  • duration 플래그 4개 (IsInHoverDuration / IsInSelectedDuration / IsInDeactivatedDuration / IsInActivatedDuration)

그 외의 것들은 전부 VisualController가 알아서 처리하고 관리가 필요한 Pressed 제한은 배타적 상태와 저 4개 플래그를 참고해서 제어하면 됩니다.

정리

이 구조는 버튼처럼 상태 조합은 많지만 전이 자체는 단순한 UI에 잘 맞습니다.
상태를 하나의 Context로 모아두고,
표현은 항상 "현재 상태를 다시 계산"하는 방식이기 때문에
중간에 무엇이 끊기거나 순서가 꼬여도 크게 흔들리지 않습니다.

마음에 들었던 점은 다음과 같습니다.

  • 버튼이 어떤 상태인지 확인할 때 Context 하나만 보면 됩니다
  • 애니메이션이 중간에 끊겨도 다음 프레임에서 다시 계산되며 자연스럽게 수렴합니다
  • Hover, Pressed 같은 입력 상태를 "막을지 말지" 고민하지 않고 보여줄지만 결정하면 됩니다
  • 표현 방식이 Tween이든 Animator든 onComplete만 보장되면 교체 비용이 거의 없습니다
  • 덕분에 디버깅할 때도 "지금 이 버튼이 뭐 상태지?"라는 질문부터 시작하지 않아도 됩니다.
    Context와 duration 플래그만 보면 대략적인 상황이 바로 보입니다.

그래서 버튼처럼:

반복이 많고 상태 전이가 자주 끊기고 중간 Disable 같은 예외가 잦은 요소라면
FSM이나 애니메이터보다 합리적일 수 있겠다는 생각이 듭니다.

반대로 드래그/스크럽처럼 연속 입력이 핵심인 UI는 별도 모델이 필요할 듯합니다...

profile
민트초코시렁

0개의 댓글