UI 프레임워크
포커스 기억, 게임패드 내비게이션, 모달 일시정지를 갖춘 메뉴·HUD·다이얼로그용 패널 스택.
메뉴·HUD·다이얼로그를 패널 스택으로 관리 · 포커스 기억, 게임패드 내비게이션, 모달 일시정지 · No-op 대체 구현:
NullPanelStack
CGS는 게임이 표시하는 모든 화면 — 메뉴, HUD, 오버레이, 다이얼로그 — 을 IPanelStack, 즉 패널 스택으로 관리합니다. _ui.Push(panel)로 패널을 푸시하면 그 패널이 활성 입력 대상이 되고, _ui.Pop()으로 팝하면 포커스가 아래 패널로 돌아갑니다. 스택은 메뉴 코드를 스파게티로 만들기 일쑤인 배관 작업을 대신 처리합니다:
- 포커스 기억. 가려진 패널은 자신의 포커스 상태(어떤 버튼이 하이라이트되어 있었는지, 스크롤 위치)를 저장했다가 다시 드러날 때 복원합니다.
- 입력 라우팅. 푸시할 때마다 입력 컨텍스트가 전환되므로, 열려 있는 메뉴가 게임플레이 버튼을 가로채는 일이 없습니다 — 그 반대도 마찬가지입니다.
- 모달 일시정지. 모달(뒤의 게임을 막는 패널)로 표시된 패널은 UI 시간은 계속 흐르게 두면서 게임 시간을 멈출 수 있습니다. 다이얼로그는 애니메이션되고, 월드는 정지합니다.
- 게임패드·키보드 내비게이션. 방향키와 D-패드로 포커스를 이동하며, 누르고 있으면 키 반복이 적용됩니다.
모든 패널이 하나의 입력 언어를 공유합니다: Esc 또는 게임패드 B 버튼은 뒤로 가기, 방향키 또는 D-패드는 포커스 이동, Enter 또는 A 버튼은 확인입니다.
구현은 선택적 CommonGameSystem.UI 어셈블리로 제공됩니다. Unity의 Input System 패키지와 내장 uGUI 패키지가 필요합니다. 둘 중 하나라도 제거되면 어셈블리가 스스로를 컴파일에서 제외하고 패널 스택은 안전한 no-op이 됩니다 — 나머지 CGS는 그대로 부팅합니다.
메뉴가 어떤 키나 버튼에도 반응하지 않나요? 패널 스택은 Unity Input System을 통해 내비게이션을 라우팅하므로 Input 서비스와 같은 프로젝트 설정 요구사항을 물려받습니다: Active Input Handling이 Input System Package (New) 또는 Both여야 합니다. 새로 만든 프로젝트에서는 여전히 *Input Manager (Old)*로 되어 있어 모든 패널이 입력을 무시합니다. Welcome 창(Tools > Common Game System > Welcome)에서 클릭 한 번으로 고칠 수 있습니다 — FAQ를 참고하세요.
서비스 가져오기
[DefaultExecutionOrder(100)]가 붙은 MonoBehaviour의 Awake()나 Start()에서 리졸브하세요:
using CommonGameSystem.Core;
using UnityEngine;
[DefaultExecutionOrder(100)]
public class UiController : MonoBehaviour
{
private IPanelStack _ui;
private void Awake()
{
_ui = ServiceLocator.Resolve<IPanelStack>();
}
}
참조를 캐시해 두고 그 위에서 메서드를 호출하세요. Update() 안에서는 절대 리졸브하지 마세요.
API 레퍼런스
스택 조회
int Depth { get; } // Current stack depth (0 when empty)
IPanel ActivePanel { get; } // Top panel, or null if the stack is empty
푸시, 팝, 루트까지 팝 (메인 스레드 전용)
void Push(IPanel panel)
패널을 맨 위에 올립니다. 가려지는 패널은 SaveFocus()를 받은 뒤 OnSuspended()를 받습니다. 새 패널은 OnPushed()를 받고, 초기 포커스를 얻고, 자신만의 입력 컨텍스트를 갖습니다. panel이 null이면 ArgumentNullException을 던지고, 패널이 이미 스택에 있거나 스택이 최대 깊이에 도달했으면 경고를 로그하고 아무것도 하지 않습니다.
bool Pop()
맨 위 패널을 제거합니다. 그 패널은 OnPopped()를 받고 입력 컨텍스트가 해제됩니다. 드러난 패널은 OnResumed()를 받은 뒤 RestoreFocus()를 받습니다. 빈 스택에서는 false, 성공 시 true를 반환합니다. 푸시나 팝이 아직 진행 중일 때 다시 호출하면 예외를 던집니다.
void PopToRoot()
맨 아래 패널만 남기고 전부 팝합니다. 중간 패널은 OnPopped()만 받습니다(OnSuspended()는 받지 않음). 맨 아래 패널은 OnResumed()를 받은 뒤 RestoreFocus()를 받습니다. 깊이가 1 이하이면 아무것도 하지 않습니다.
입력 디스패치
입력 파이프라인(보통 InputAction의 핸들러)이 UI 입력을 스택으로 전달합니다:
void DispatchAction(PanelAction action)
Confirm, Back, AltPrimary, AltSecondary를 활성 패널로 라우팅합니다. 기본적으로 Back은 패널을 자동으로 팝합니다. InterceptsBackAction = true인 패널은 대신 HandleBack()을 받아 팝을 막을 수 있습니다(예: 저장하지 않은 변경 사항 확인 프롬프트). Confirm, AltPrimary, AltSecondary는 항상 패널의 OnAction()을 호출합니다.
void DispatchFocus(Vector2 dirVec)
D-패드, 왼쪽 스틱, 방향키 입력을 활성 패널의 OnFocusRequested()로 라우팅합니다. 대각선 입력은 우세한 축으로 스냅되고(왼쪽 위는 위가 됨), 0에 가까운 벡터는 무시됩니다.
패널이 구현하는 것 (IPanel)
여러분의 화면은 IPanel을 구현합니다. 아래 멤버들은 스택이 호출합니다 — 직접 호출하지 마세요:
| 멤버 | 호출 시점 |
|---|---|
OnPushed() | Push 직후; 패널이 이제 활성 상태입니다. 자신을 표시하고 리스너를 등록하세요. |
OnSuspended() | 다른 패널이 위에 푸시되었습니다. 일시적 상태를 저장하고 필요하면 숨기세요. |
OnResumed() | 위의 패널이 팝되어 이 패널이 다시 활성 상태입니다. |
OnPopped() | 이 패널이 스택에서 팝되었습니다. 리스너를 해제하고 자신을 숨기세요. |
GetInitialFocus() | Push 중 OnPushed() 이후. 처음 포커스할 요소(보통 첫 번째 버튼)를 반환하거나 null을 반환하세요. |
OnFocusRequested(FocusDirection dir) | 방향키 또는 D-패드 입력 시(Up/Down/Left/Right). 순환(wrap-around)은 직접 구현해야 합니다. |
SaveFocus() | 가려지는 패널에서 OnSuspended() 직전. 포커스 상태를 담은 FocusSnapshot 구조체를 반환하세요. |
RestoreFocus(FocusSnapshot snapshot) | 드러난 패널에서 OnResumed() 직후. |
OnAction(PanelAction action) | Confirm/AltPrimary/AltSecondary 입력 시. Back은 대신 HandleBack()을 거칩니다. |
HandleBack() | InterceptsBackAction이 true일 때만. true를 반환하면 팝을 막고, false를 반환하면 허용합니다. |
그리고 다음 프로퍼티들이 패널을 설명합니다:
| 프로퍼티 | 의미 |
|---|---|
IsModal | true이면(그리고 autoPauseOnModal 옵션이 켜져 있으면) 이 패널을 푸시할 때 Gameplay 클럭이 일시정지됩니다. 게임 시간은 멈추고, UI·Background 클럭은 계속 흐릅니다. |
InterceptsBackAction | false(기본값)이면 Back이 패널을 자동으로 팝합니다. true이면 HandleBack()이 먼저 실행됩니다. |
AccessibleLabel | 향후 스크린 리더 지원을 위해 예약됨; null일 수 있습니다. |
TransitionDuration | 이 패널의 페이드 시간(초); null이면 스택의 기본값을 사용합니다. |
포커스 대상 (IFocusable)
void Focus() // Move focus to this element (e.g. Selectable.Select())
bool IsFocused { get; } // Whether this element currently holds focus
전체 예제
uGUI 버튼으로 구현한 설정 패널과 이를 여는 컨트롤러:
using CommonGameSystem.Core;
using UnityEngine;
using UnityEngine.UI;
// A small adapter that lets the panel stack focus a uGUI Selectable.
public sealed class ButtonFocusable : IFocusable
{
private readonly Selectable _target;
public ButtonFocusable(Selectable target)
{
_target = target;
}
public void Focus() => _target.Select();
public bool IsFocused =>
UnityEngine.EventSystems.EventSystem.current != null &&
UnityEngine.EventSystems.EventSystem.current.currentSelectedGameObject == _target.gameObject;
}
[DefaultExecutionOrder(100)]
public class SettingsPanel : MonoBehaviour, IPanel
{
[SerializeField] private Button _audioButton;
[SerializeField] private Button _graphicsButton;
[SerializeField] private Button _backButton;
private IPanelStack _ui;
private UnityEngine.Events.UnityAction _goBack;
public bool IsModal => false;
public bool InterceptsBackAction => false;
public string AccessibleLabel => "Settings";
public float? TransitionDuration => null;
public void OnPushed()
{
_ui ??= ServiceLocator.Resolve<IPanelStack>();
_goBack ??= () => _ui.Pop();
_backButton.onClick.AddListener(_goBack); // register here...
gameObject.SetActive(true);
}
public void OnPopped()
{
_backButton.onClick.RemoveListener(_goBack); // ...remove the SAME delegate here
gameObject.SetActive(false);
}
public void OnSuspended() => gameObject.SetActive(false);
public void OnResumed() => gameObject.SetActive(true);
public IFocusable GetInitialFocus() => new ButtonFocusable(_audioButton);
public void OnFocusRequested(FocusDirection dir)
{
// Move focus between buttons. Wrap-around is up to you.
if (dir == FocusDirection.Down) _graphicsButton.Select();
if (dir == FocusDirection.Up) _audioButton.Select();
}
public FocusSnapshot SaveFocus()
{
// FocusSnapshot is a plain struct — fill its fields directly.
// Here we remember the focused Selectable by its instance id.
var current = UnityEngine.EventSystems.EventSystem.current?.currentSelectedGameObject;
return new FocusSnapshot
{
FocusedElementId = current != null ? current.GetInstanceID() : 0
};
}
public void RestoreFocus(FocusSnapshot snapshot)
{
if (snapshot.FocusedElementId == _audioButton.gameObject.GetInstanceID()) _audioButton.Select();
else _graphicsButton.Select();
}
public void OnAction(PanelAction action)
{
if (action == PanelAction.Confirm)
{
// Confirm pressed — the focused button receives the click.
}
}
public bool HandleBack() => false; // Never called while InterceptsBackAction is false.
}
게임 어디에서든 패널 열기:
using CommonGameSystem.Core;
using UnityEngine;
[DefaultExecutionOrder(100)]
public class PauseMenuController : MonoBehaviour
{
[SerializeField] private SettingsPanel _settingsPanel;
private IPanelStack _ui;
private void Awake() => _ui = ServiceLocator.Resolve<IPanelStack>();
public void OpenSettings() => _ui.Push(_settingsPanel);
}
끄기
ServiceLocator.Replace<IPanelStack>(new NullPanelStack());
UI 프레임워크 전체가 비활성화됩니다. Push와 Pop은 조용한 no-op이 되고, Depth는 항상 0을, ActivePanel은 항상 null을 반환합니다. 입력 컨텍스트도 푸시되지 않고, 시간도 일시정지되지 않고, 이벤트도 발행되지 않고, 아무것도 로그되지 않습니다. 자체 UI 시스템을 얹어 출시할 때 사용하세요.
흔한 함정
-
메뉴가 모든 입력을 무시합니다. 열에 아홉은 Active Input Handling 프로젝트 설정 문제입니다 — 이 페이지 상단의 콜아웃과 FAQ를 보세요.
-
메인 스레드 전용. 모든 public 메서드와 프로퍼티는 워커 스레드에서 호출하면
InvalidOperationException을 던집니다. 서비스를 리졸브하는 MonoBehaviour에는[DefaultExecutionOrder(100)]를 붙여, 자동 부트스트래퍼의 등록이 여러분의Awake()보다 먼저 끝나게 하세요. -
OnPushed()에서 등록하고OnPopped()에서 해제하세요. 패널이 푸시될 때 버튼 리스너를 추가하고, 팝될 때 같은 델리게이트 인스턴스를 제거하세요. 제거를 잊으면 다음 푸시에서 핸들러가 하나 더 붙어 클릭마다 두 번 실행됩니다. Domain Reload가 꺼진 에디터에서 가장 아프게 물립니다. -
모달 일시정지는 카운트됩니다. 모달 푸시는 Gameplay 클럭을 일시정지하고, 짝이 되는 팝이 재개합니다. Time 서비스는 일시정지를 카운트합니다 — 두 번 일시정지했으면 두 번 재개해야 합니다. 직접
Pause()/Resume()도 호출한다면 여러분의 호출 짝을 맞추세요. 그러지 않으면 게임플레이가 계속 얼어 있습니다. -
입력 컨텍스트는 스택과 일대일로 움직입니다. 푸시마다 입력 컨텍스트가 하나 추가되고 팝마다 하나 제거됩니다.
autoPushInputContext옵션을 끄면 입력 컨텍스트를 직접 관리해야 하며, 그러지 않으면 메뉴 입력이 라우팅되지 않습니다. -
자체 저장 상태 타입은 IL2CPP에서
link.xml이 필요합니다.FocusSnapshot자체는 프레임워크가 보존하므로 위 예제에는 추가 작업이 필요 없습니다. 패널이 저장 상태용 커스텀 값 타입을 도입한다면 프로젝트의link.xml에 추가하세요:<assembly fullname="YourGame"> <type fullname="YourGame.MyCustomFocusState" preserve="all"/> </assembly>