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