プッシュダウンスタック

スタック可能で再開可能なゲームステートのスコープ — 割り込みを上にプッシュすると、実行中だったものはポップされるまで凍結する。

ゲームの上に割り込みをプッシュ。実行中だったものは、ポップされるまでその場で凍結します。

インターフェースIPushdownStackServiceIPushdownStack インスタンスを作るファクトリ
オフスイッチNullPushdownStackService
アセンブリCommonGameSystem.Core
起動起動時に自動登録(23 サービスの 1 つ)— セットアップ不要

概要

プッシュダウンスタックは、スコープと呼ばれるゲームステートのヘッドレスな後入れ先出しスタックです。実行中のゲームプレイシミュレーション(や AI、メニュー)をサスペンドし、途中から再開できます。スコープをスタックにプッシュすると — ダイアログ割り込み、AI の意思決定、論理メニュー — その下で生きていたものは、まさにその場で凍結します。ポップすれば、前のスコープは(セッション内では)何も作り直すことなく目を覚まします。

ティックするのは最上位のスコープだけです。サスペンドされたスコープは、再開されるまで状態を凍結したまま保持します。スタックは純粋な C# で決定論的であり、その構造はセーブ/ロードサービスを通じて保存・復元できます。

クイックスタート

サービスロケーターからファクトリを解決し、スタックを作ります:

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)スコープを新しい最上位としてプッシュします。旧最上位はサスペンドされ、その後に新しいスコープへ enter します。key はセーブに使われるラベルです。
void Pop()最上位のスコープをポップします:そのスコープが exit し、下のスコープが再開します。空のスタックでは no-op です。
void Replace(string key, IStackScope scope)最上位のスコープをその場で入れ替えます(旧を exit、新を enter)。下のスコープはサスペンドされたままで、suspend/resume の呼び出しは受けません。
void Clear()すべてのスコープを上から下へ exit し(何も再開しません)、スタックを空にします。
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 — 読み取り専用の設定構造体

メンバー説明
int EffectiveMaxStackDepth { get; }Push の安全上限。デフォルト 32、範囲 1–256。
int EffectiveMaxOpsPerDispatch { get; }ディスパッチごとに処理される、キュー済みフォローアップ操作の上限(下の再入の落とし穴を参照)。デフォルト 8、範囲 1–64。
int EffectiveInitialCapacity { get; }内部スコープリストの事前アロケーションのヒント。デフォルト 8、範囲 1–256。
bool LetScopeExceptionsPropagate { get; }true のとき、スコープフックが投げた例外はあなたのコードへ伝播します(フェイルファストな開発モード)。false(デフォルト)のときは捕捉されてログされ、操作は続行します。
bool SuppressEmptyPopWarning { get; }true のとき、空のスタックでの Pop が出す警告を抑止します。投機的にポップするゲーム向けです。デフォルト false

PushdownStackSnapshot — セーブとリストア用の [Serializable] 構造体

メンバー説明
int schemaVersionスナップショット形式のバージョン(現在は 1)。
string[] frameKeys下から上へのスコープキー。決して null にはならず、空のスタックは空の配列として保存されます。

IScopeFactory — ゲーム側が実装し、Restore にだけ渡すもの

メンバー説明
IStackScope Create(string key)保存されたキーに対応するスコープを、すでに enter 済みの状態で再構築します。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 は発火しません。ヘッドレスビルドやサーバービルド、あるいはスタックを使わない場面で使ってください。構築時に 1 回警告をログするので、有効になっていることが分かります。

よくある落とし穴

  • メインスレッド専用です。 PushPopTick をはじめとするすべての操作はメインスレッドで実行しなければなりません。ワーカースレッドからの呼び出しは InvalidOperationException を投げます。
  • スコープ内部の状態はあなたの責任です。 スナップショットがキャプチャするのはスタックの構造だけです:どのスコープキーが、どの順番か。各スコープの内部状態 — ダイアログの現在の行、ステートマシンの現在のステート — は別途保存し(セーブ/ロードを参照)、あなたの IScopeFactory.Create(key) で再構築する必要があります。
  • フックの内側から発行された操作は遅延されます。 フック(OnEnterOnSuspendOnResumeOnExitTick)が PushPopReplaceClear を呼ぶと、その操作はキューに入り、現在のフックの終了後に要求順で実行されます。1 回のディスパッチで実行されるキュー済み操作は最大 EffectiveMaxOpsPerDispatch 件(デフォルト 8)で、それを超えると警告がログされ残りは破棄されます。
  • ScopeChanged の購読者は例外を投げてはいけません。 このイベントは try/catch なしで操作の途中に発火します。PopClear では、新しい最上位の OnResume より前に発火します — 例外を投げる購読者は、操作を中途半端に適用されたまま残します。
  • IL2CPP:自分のスコープクラスは自分で保存してください。 フレームワークが保存するのは自分自身の型だけです。インターフェース経由でしか使われない、IStackScopeIScopeFactory を実装するゲームクラスは、IL2CPP のコードストリッピングで除去されることがあります。[Preserve] またはプロジェクトの link.xml のエントリでクラスを保護してください。
  • AI スコープは再開後に再検証してください。 ダイアログやメニューは元のまま再開し、それで正しく動きます。しかし AI スコープがキャッシュした世界のビュー(最後に見たターゲット位置、実時間タイマー)は、サスペンドされていた時間のぶんだけ古くなっています — 盲目的に再開すると、キャラクターが敵のかつていた場所へ歩いていきかねません。OnResume で世界に関する前提を確認し直してください。Tick 駆動のタイマーは自然にポーズするのでリセット不要です。古くなるのは実時間(壁時計)タイマーだけです。

関連ページ