푸시다운 스택

쌓아 올려 재개 가능한 게임 상태 스코프 — 인터럽트를 위에 푸시하면 실행 중이던 것이 팝될 때까지 그대로 멈춥니다.

게임 위에 인터럽트를 푸시하세요. 실행 중이던 것은 팝될 때까지 그 자리에서 그대로 멈춥니다.

인터페이스IPushdownStackServiceIPushdownStack 인스턴스를 만드는 팩토리
끄기 스위치NullPushdownStackService
어셈블리CommonGameSystem.Core
시작부팅 시 자동 등록 (23개 서비스 중 하나) — 별도 설정 불필요

하는 일

푸시다운 스택은 스코프라 부르는 게임 상태들의 헤드리스 후입선출(LIFO) 스택입니다. 실행 중인 게임플레이 시뮬레이션(또는 AI, 메뉴)을 잠시 중단했다가 멈춘 그 지점에서 재개할 수 있게 해 줍니다. 스택에 스코프를 푸시하면 — 대화 인터럽트, AI 결정, 논리적 메뉴 — 그 아래에서 살아 움직이던 것이 정확히 그 자리에서 얼어붙습니다. 팝하면 이전 스코프가 아무것도 다시 만들 필요 없이 깨어납니다(세션 내에서).

최상단 스코프만 틱합니다. 일시 중단된 스코프는 재개될 때까지 상태를 얼린 채 유지합니다. 스택은 순수 C#이고 결정론적이며, 그 구조는 저장 / 불러오기 서비스를 통해 저장하고 복원할 수 있습니다.

빠른 시작

서비스 로케이터에서 팩토리를 resolve한 뒤 스택을 만드세요:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)] // Run after the framework has started.
public class MyStackUser : MonoBehaviour
{
    private IPushdownStack _stack;

    private void Awake()
    {
        _stack = ServiceLocator.Resolve<IPushdownStackService>().Create();

        // Or construct one directly — works standalone, with no service registration:
        // _stack = new PushdownStack();
    }
}

API 레퍼런스

IPushdownStack — 주 인터페이스

멤버설명
void Push(string key, IStackScope scope)스코프를 새 최상단으로 푸시합니다. 이전 최상단이 일시 중단된 뒤 새 스코프에 진입합니다. key는 저장에 쓰이는 라벨입니다.
void Pop()최상단 스코프를 팝합니다: 그 스코프가 종료되고 아래 스코프가 재개됩니다. 빈 스택에서는 no-op입니다.
void Replace(string key, IStackScope scope)최상단 스코프를 제자리에서 교체합니다(이전 것 종료, 새것 진입). 아래 스코프는 계속 일시 중단 상태이며 suspend/resume 호출을 받지 않습니다.
void Clear()위에서 아래로 모든 스코프를 종료하고(아무것도 재개되지 않음) 스택을 비웁니다.
void Tick(float deltaTime)최상단 스코프만 틱합니다. 일시 중단된 스코프는 얼어붙은 채입니다. deltaTime을 여러분이 전달하므로 클록도 여러분이 고릅니다.
int Depth { get; }현재 스택에 있는 스코프의 수.
IStackScope Top { get; }최상단 스코프. 스택이 비어 있으면 null.
string TopKey { get; }최상단 스코프의 키. 스택이 비어 있으면 null.
string[] GetFrameKeys()아래에서 위 순서의 스코프 키. 새 배열을 할당하므로 저장용으로 쓰고, 매 프레임 호출하지 마세요.
PushdownStackSnapshot TakeSnapshot()저장과 복원을 위해 스택 구조를 캡처합니다.
void Restore(PushdownStackSnapshot snapshot, IScopeFactory factory)여러분의 팩토리를 사용해 스냅샷에서 스택을 재구성합니다. 재구성 중에는 enter/suspend/resume 훅이 전혀 발생하지 않습니다 — 조용한 하드 리셋입니다.
event Action<IStackScope, IStackScope> ScopeChanged완료된 각 연산 후 (pushed, popped)와 함께 발생합니다. 전역 이벤트 버스의 메시지가 아니라 평범한 인스턴스별 C# 이벤트입니다.

IStackScope — 각 스코프마다 여러분이 구현하는 것

멤버설명
void OnEnter()스코프가 푸시될 때 한 번 호출됩니다.
void OnSuspend()다른 스코프가 위에 푸시될 때 호출됩니다(이 스코프는 얼어붙습니다).
void OnResume()위 스코프가 팝될 때 호출됩니다(이 스코프가 다시 활성화됩니다).
void OnExit()스코프가 팝·교체·클리어될 때 한 번 호출됩니다.
void Tick(float deltaTime)이 스코프가 최상단인 동안 매 틱 호출됩니다. 일시 중단된 스코프는 절대 틱하지 않습니다.

IPushdownStackService — Bootstrap이 등록하는 팩토리

멤버설명
IPushdownStack Create(PushdownStackOptions options = default)주어진 옵션으로 새 스택을 만듭니다.
int ActiveStackCount { get; }아직 살아 있는 팩토리 생성 스택의 수.

PushdownStackOptions — readonly 설정 구조체

멤버설명
int EffectiveMaxStackDepth { get; }Push에 대한 안전 한도. 기본 32, 범위 1–256.
int EffectiveMaxOpsPerDispatch { get; }디스패치당 처리되는 큐잉된 후속 연산의 한도(아래 재진입 함정 참고). 기본 8, 범위 1–64.
int EffectiveInitialCapacity { get; }내부 스코프 리스트의 사전 할당 힌트. 기본 8, 범위 1–256.
bool LetScopeExceptionsPropagate { get; }true이면 스코프 훅이 던진 예외가 여러분의 코드로 전파됩니다(fail-fast 개발 모드). false(기본)이면 잡아서 로그하고, 연산은 계속됩니다.
bool SuppressEmptyPopWarning { get; }true이면 빈 스택에서의 Pop이 남기는 경고를 끕니다. 추측성으로 팝하는 게임용입니다. 기본 false.

PushdownStackSnapshot — 저장과 복원용 [Serializable] 구조체

멤버설명
int schemaVersion스냅샷 형식 버전(현재 1).
string[] frameKeys아래에서 위 순서의 스코프 키. 절대 null이 아니며, 빈 스택은 빈 배열로 저장됩니다.

IScopeFactory — 게임이 구현하며, Restore에만 전달

멤버설명
IStackScope Create(string key)저장된 키의 스코프를 이미 진입된 상태로 재구성합니다. null을 반환하면 그 스코프를 건너뛰며, 건너뜀은 경고로 로그됩니다.

예제

게임플레이를 일시 중단하는 대화 인터럽트:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)] // Run after the framework has started.
public class GameplayController : MonoBehaviour
{
    private IPushdownStack _stack;

    void Awake()
    {
        _stack = ServiceLocator.Resolve<IPushdownStackService>().Create();
    }

    void Update()
    {
        RunGameplay();
        _stack.Tick(Time.deltaTime);
    }

    public void StartDialogue(DialogueScope dialogue)
    {
        _stack.Push("dialogue", dialogue);
        // Gameplay is now frozen; only the dialogue ticks.
    }

    // Called by the dialogue when it closes:
    public void EndDialogue()
    {
        _stack.Pop();
        // Gameplay resumes mid-stride.
    }

    void OnDestroy() => _stack?.Clear();  // Exits every scope top-to-bottom (nothing resumes).

    private void RunGameplay()
    {
        // Your per-frame gameplay update.
    }
}

// A simple dialogue scope:
public class DialogueScope : IStackScope
{
    public void OnEnter() => Debug.Log("Dialogue opened.");
    public void OnSuspend() { }  // Freezes naturally: suspended scopes never tick.
    public void OnResume() { }   // Nothing to do for a self-contained scope.
    public void OnExit() => Debug.Log("Dialogue closed.");
    public void Tick(float deltaTime)
    {
        // Advance the dialogue text here.
    }
}

끄는 방법

ServiceLocator.Replace<IPushdownStackService>(new NullPushdownStackService());

NullPushdownStack은 호출 코드를 바꾸지 않고 모든 연산을 무음 처리합니다: Depth는 항상 0, Top은 항상 null이고, 모든 변경 호출은 아무것도 하지 않으며, ScopeChanged는 절대 발생하지 않습니다. 헤드리스나 서버 빌드에서, 또는 스택을 쓰지 않을 때 사용하세요. 생성 시 한 번 경고를 로그하므로 활성화 여부를 알 수 있습니다.

흔한 함정

  • 메인 스레드 전용입니다. Push, Pop, Tick을 비롯한 모든 연산은 메인 스레드에서 실행해야 합니다. 워커 스레드 호출은 InvalidOperationException을 throw합니다.
  • 스코프 내부 상태는 여러분의 책임입니다. 스냅샷은 스택 구조만 캡처합니다: 어떤 스코프 키가 어떤 순서인지. 각 스코프의 내부 상태 — 대화의 현재 줄, 상태 머신의 현재 상태 — 는 따로 저장하고(저장 / 불러오기 참고) 여러분의 IScopeFactory.Create(key)에서 재구성해야 합니다.
  • 훅 안에서 발행한 연산은 지연됩니다. 훅(OnEnter, OnSuspend, OnResume, OnExit, Tick)이 Push, Pop, Replace, Clear를 호출하면, 그 연산은 큐에 들어가 현재 훅이 끝난 뒤 요청된 순서대로 실행됩니다. 디스패치당 최대 EffectiveMaxOpsPerDispatch개(기본 8)의 큐잉된 연산이 실행되며, 그 이상은 경고가 로그되고 나머지는 버려집니다.
  • ScopeChanged 구독자는 throw하면 안 됩니다. 이 이벤트는 try/catch 없이 연산 중간에 발생합니다. PopClear에서는 새 최상단의 OnResume 전에 발생합니다 — 구독자가 throw하면 연산이 반쯤 적용된 채 남습니다.
  • IL2CPP: 자신의 스코프 클래스를 보존하세요. 프레임워크는 자기 타입만 보존합니다. 인터페이스로만 사용되는, IStackScopeIScopeFactory를 구현한 게임 클래스는 IL2CPP 코드 스트리핑에 제거될 수 있습니다. [Preserve]나 프로젝트 link.xml의 항목으로 클래스를 보호하세요.
  • 재개 후 AI 스코프는 재검증하세요. 대화나 메뉴는 있던 그대로 재개되며, 그것이 맞습니다. 하지만 AI 스코프가 캐시해 둔 세계에 대한 시각 — 마지막으로 본 목표 위치, 실시간 타이머 — 은 중단되어 있던 시간만큼 낡아 있습니다. 그대로 재개하면 캐릭터가 적이 있던 자리로 걸어갈 수 있습니다. OnResume에서 세계에 대한 가정을 다시 확인하세요. Tick으로 구동되는 타이머는 자연스럽게 멈추므로 리셋이 필요 없습니다. 실시간(벽시계) 타이머만 낡습니다.

관련 페이지