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()、获得初始焦点,并得到自己的输入上下文。panelnull 时抛出 ArgumentNullException;面板已在栈上或栈已达最大深度时,记录一条警告且不做任何事。

bool Pop()

移除栈顶面板。它会收到 OnPopped(),其输入上下文被释放。显露出来的面板依次收到 OnResumed()RestoreFocus()。栈为空时返回 false,成功时返回 true。若在一次压入或弹出仍在进行中时再次调用,则抛出异常。

void PopToRoot()

弹出除最底部面板以外的所有面板。中间的面板只收到 OnPopped()(不会收到 OnSuspended())。最底部面板依次收到 OnResumed()RestoreFocus()。深度小于等于 1 时不做任何事。

分发输入

你的输入管线(通常是 InputAction 上的一个处理器)把 UI 输入转发给栈:

void DispatchAction(PanelAction action)

ConfirmBackAltPrimaryAltSecondary 路由到当前活动面板。默认情况下 Back 会自动弹出面板。设置了 InterceptsBackAction = true 的面板会改为收到 HandleBack(),并可以阻止弹出(例如未保存更改的确认提示)。ConfirmAltPrimaryAltSecondary 总是调用面板的 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()仅当 InterceptsBackActiontrue 时调用。返回 true 阻止弹出,返回 false 允许弹出。

以下属性用于描述面板:

属性含义
IsModaltrue 时(且开启了 autoPauseOnModal 选项),压入此面板会暂停 Gameplay 时钟。游戏时间冻结;UI 与 Background 时钟继续流动。
InterceptsBackActionfalse(默认值)时,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 框架。PushPop 变成静默的空操作,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>
    

相关页面

  • 输入 — 输入上下文、动作映射与按键重绑定
  • 时间 — 模态暂停背后的时钟
  • 事件总线 — 发布/订阅事件
  • 常见问题 — Active Input Handling 修复的分步说明