下推栈

可堆叠、可恢复的游戏状态作用域——把中断压到栈顶,正在运行的一切原地冻结,直到它被弹出。

把一个中断压到游戏之上;正在运行的东西在半途中冻结,直到它被弹出。

接口IPushdownStackService — 用于创建 IPushdownStack 实例的工厂
关闭开关NullPushdownStackService
程序集CommonGameSystem.Core
启动启动时自动注册(23 个服务之一)——无需任何设置

它做什么

下推栈是一个 headless 的、后进先出的游戏状态栈,栈中的元素称为作用域(scope)。它让你可以挂起一段正在运行的玩法模拟(或 AI、或菜单),之后从中断处原样恢复。把一个作用域压入栈中——一段对话中断、一次 AI 决策、一个逻辑菜单——下面正在运行的东西就在原地冻结。弹出它,之前的作用域就地醒来,(在同一会话内)无需重建任何东西。

只有栈顶作用域会被 tick;被挂起的作用域保持冻结,持有各自的状态,直到恢复。这个栈是纯 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)把一个作用域压为新栈顶。旧栈顶先被挂起,然后进入新作用域。key 是用于保存的标签。
void Pop()弹出栈顶作用域:它退出,其下方的作用域恢复。空栈上是空操作。
void Replace(string key, IStackScope scope)原地替换栈顶作用域(旧的退出,新的进入)。下方作用域保持挂起,不会收到挂起/恢复调用。
void Clear()从顶到底逐个退出所有作用域(谁也不会恢复),并清空栈。
void Tick(float deltaTime)只 tick 栈顶作用域;挂起的作用域保持冻结。deltaTime 由你提供,所以时钟由你选。
int Depth { get; }当前栈中作用域的数量。
IStackScope Top { get; }栈顶作用域;栈为空时为 null
string TopKey { get; }栈顶作用域的 key;栈为空时为 null
string[] GetFrameKeys()自底向上的作用域 key。这会分配一个新数组——用于保存,不要每帧调用。
PushdownStackSnapshot TakeSnapshot()捕获栈结构,用于保存与恢复。
void Restore(PushdownStackSnapshot snapshot, IScopeFactory factory)用你的工厂按快照重建栈。重建期间不会触发任何进入/挂起/恢复钩子——这是一次静默的硬重置。
event Action<IStackScope, IStackScope> ScopeChanged每个操作完成后以 (pushed, popped) 触发。这是普通的逐实例 C# 事件,不是全局事件总线上的消息。

IStackScope — 为每个作用域实现的接口

成员说明
void OnEnter()作用域被压入时调用一次。
void OnSuspend()有别的作用域压到它上面时调用(本作用域冻结)。
void OnResume()上方的作用域弹出时调用(本作用域重新变为活动)。
void OnExit()作用域被弹出、替换或清空时调用一次。
void Tick(float deltaTime)该作用域处于栈顶期间每次 tick 调用。挂起的作用域绝不会被 tick。

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自底向上的作用域 key。绝不为 null;空栈保存为空数组。

IScopeFactory — 由你的游戏实现,只传给 Restore

成员说明
IStackScope Create(string key)为保存的 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 永不触发。可用于 headless 或服务器构建,或任何用不到栈的场合。它在构造时记录一条警告,让你知道它处于启用状态。

常见陷阱

  • 仅限主线程。 所有操作——PushPopTick 及其余——都必须在主线程上运行。工作线程的调用抛出 InvalidOperationException
  • 作用域的内部状态归你负责。 快照只捕获栈的结构:有哪些作用域 key、顺序如何。每个作用域的内部状态——对话的当前行、状态机的当前状态——必须单独保存(见保存 / 加载),并由你的 IScopeFactory.Create(key) 重建。
  • 在钩子内部发起的操作会被延后。 如果某个钩子(OnEnterOnSuspendOnResumeOnExitTick)调用了 PushPopReplaceClear,该操作会进入队列,在当前钩子结束后按请求顺序执行。每次派发最多执行 EffectiveMaxOpsPerDispatch 个排队操作(默认 8);超出的部分记录一条警告并被丢弃。
  • ScopeChanged 的订阅者不得抛异常。 该事件在操作进行到一半时触发,且没有 try/catch 保护。在 PopClear 时,它在新栈顶的 OnResume 之前触发——抛异常的订阅者会让操作停在半途。
  • IL2CPP:保留你自己的作用域类。 框架只保留它自己的类型。仅通过接口使用的、实现了 IStackScopeIScopeFactory 的游戏类可能被 IL2CPP 代码裁剪移除。用 [Preserve] 或项目 link.xml 中的条目保护你的类。
  • 恢复后要重新校验 AI 作用域。 对话或菜单恢复时保持原样,这是正确的。但 AI 作用域缓存的世界视图(最后一次看到的目标位置、真实时间计时器)已经过时了整个挂起时长——盲目恢复它可能让角色走向敌人曾经所在的位置。用 OnResume 重新检查对世界的假设。由 Tick 驱动的计时器会自然暂停、无需重置;只有真实(墙钟)时间的计时器会过时。

相关页面