UI 框架
面向菜单、HUD 与对话框的面板栈,支持焦点记忆、手柄导航与模态暂停。
以面板栈的形式管理菜单、HUD 与对话框 · 焦点记忆、手柄导航、模态暂停 · 空操作替代:
NullPanelStack
CGS 通过 IPanelStack——一个由面板组成的栈——管理游戏展示的每一个界面:菜单、HUD、叠加层、对话框。用 _ui.Push(panel) 压入一个面板,它就成为当前的输入目标;用 _ui.Pop() 弹出后,焦点回到下方的面板。通常把菜单代码搅成一团乱麻的那些底层管线,都由这个栈来处理:
- 焦点记忆。 被覆盖的面板会保存自己的焦点状态(哪个按钮处于高亮、滚动位置),并在重新显露时恢复。
- 输入路由。 每次压入都会切换输入上下文,因此打开的菜单永远不会截获游戏按键——反之亦然。
- 模态暂停。 标记为模态的面板(会挡住其背后游戏的面板)可以冻结游戏时间,而 UI 时间继续流动。对话框照常播放动画;世界则静止。
- 手柄与键盘导航。 方向键和 D-pad 移动焦点,按住时支持按键重复。
所有面板共享同一套输入语言:Esc 或手柄 B 键返回,方向键或 D-pad 移动焦点,Enter 或 A 键确认。
该实现随可选的 CommonGameSystem.UI 程序集发布。它需要 Unity 的 Input System 包和内置的 uGUI 包。移除其中任何一个包,程序集就会把自己从编译中排除,面板栈变成安全的空操作——CGS 的其余部分仍能正常启动。
菜单对任何按键都没有反应? 面板栈通过 Unity 的 Input System 路由导航,因此它继承了与输入服务相同的项目设置要求:Active Input Handling 必须是 Input System Package (New) 或 Both。新建项目里它仍是 Input Manager (Old),此时所有面板都会忽略输入。Welcome 窗口(Tools > Common Game System > Welcome)一键即可修复——参见常见问题。
获取服务
在标注了 [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-pad、左摇杆或方向键输入路由到当前活动面板的 OnFocusRequested()。斜向输入会吸附到主导轴(左上变成上);接近零的向量会被忽略。
面板需要实现什么(IPanel)
你的各个界面实现 IPanel。以下成员由栈来调用——你自己永远不要调用它们:
| 成员 | 何时被调用 |
|---|---|
OnPushed() | 紧随 Push 之后;面板此刻处于活动状态。在这里显示自己、注册监听器。 |
OnSuspended() | 有另一个面板被压到了上面。保存临时状态,视情况隐藏。 |
OnResumed() | 上方面板已弹出;此面板重新处于活动状态。 |
OnPopped() | 此面板已被弹出栈。注销监听器、隐藏自己。 |
GetInitialFocus() | 在 Push 期间、OnPushed() 之后调用。返回应当首先获得焦点的元素(通常是第一个按钮),或 null。 |
OnFocusRequested(FocusDirection dir) | 在方向键或 D-pad 输入时调用(Up/Down/Left/Right)。循环绕回由你负责。 |
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 变成静默的空操作,Depth 始终返回 0,ActivePanel 始终返回 null。不会压入输入上下文,不会暂停时间,不会发布事件,也不会记录任何日志。当你使用自己的 UI 系统时用它。
常见陷阱
-
菜单忽略所有输入。 十有八九是 Active Input Handling 项目设置的问题——参见本页顶部的提示框和常见问题。
-
仅限主线程。 从工作线程调用任何公开方法和属性都会抛出
InvalidOperationException。给解析该服务的 MonoBehaviour 加上[DefaultExecutionOrder(100)],让自动引导器在你的Awake()运行之前完成注册。 -
在
OnPushed()中注册,在OnPopped()中注销。 面板压入时添加按钮监听器,弹出时移除同一个委托实例。忘记移除意味着下一次压入会加上第二个处理器,每次点击都触发两遍。在禁用了 Domain Reload 的编辑器中,这个问题咬得最狠。 -
模态暂停是计数式的。 压入模态面板会暂停 Gameplay 时钟;对应的弹出将其恢复。时间服务对暂停进行计数——两次暂停需要两次恢复。如果你自己也调用
Pause()/Resume(),请保持你的调用成对平衡,否则游戏会一直冻结。 -
输入上下文与栈一一对应。 每次压入添加一个输入上下文,每次弹出移除一个。如果你关闭了
autoPushInputContext选项,就必须自己管理输入上下文,否则菜单输入不会被路由。 -
你自己的保存状态类型在 IL2CPP 下需要
link.xml。FocusSnapshot本身已由框架保留,因此上面的示例不需要任何额外配置。如果你的面板为保存状态引入了自定义值类型,请把它们加入项目的link.xml:<assembly fullname="YourGame"> <type fullname="YourGame.MyCustomFocusState" preserve="all"/> </assembly>