第 20 课
开发系统菜单的显示逻辑
本课从菜单开关的状态变化出发,先读懂左手主按钮的输入路径,再把输入与界面职责分别交给 GameManager 和 UIManager。你将完整编写两份脚本,掌握输入动作的创建、订阅、启用、停用与释放,并让隐藏的菜单持续准备在体验者前方,打开后固定在按键瞬间的位置。最后把脚本连接到场景,使用 PICO 控制器或 XR 交互模拟器完成连续切换。
用左手 X 键显示和隐藏系统菜单
把上一课完成的菜单框架连接到 Unity 输入系统,让菜单默认隐藏,并在按下左手 X 键时显示在体验者前方,再次按键时关闭。
学完你将能够
学习目标
- 说明系统菜单为什么应该默认隐藏,并解释 Toggle(切换)的含义。
- 画出左手 X 键到 MenuUI 状态改变的完整调用顺序。
- 区分物理按钮、输入绑定和输入动作的职责。
- 读懂 <XRController>{LeftHand}/primaryButton 控件路径。
- 比较输入动作资源与代码创建动作两种使用方式。
- 说明 GameManager 与 UIManager 的职责边界。
- 使用 Player 属性保存主摄像机的位置与前方方向。
- 完成 InputAction 的创建、事件订阅、启用、停用和释放。
- 说明 InputAction 在 Awake、OnEnable、OnDisable 与 OnDestroy 中经历的完整过程。
- 使用 activeSelf 与逻辑取反计算菜单下一状态。
- 使用体验者位置、前方方向和距离计算菜单位置。
- 按照进入运行、隐藏时预先跟随、打开后固定和关闭后恢复跟随的顺序说明代码运行逻辑。
- 说明为什么隐藏状态使用 LateUpdate 更新位置,以及菜单打开后为什么停止跟随。
- 完成脚本组件、MenuUI 引用和 MainCamera 标签的场景连接。
- 根据无响应、双重触发、位置错误和空引用等现象定位原因。
开始操作前
准备工作
- 打开虚拟博物馆主场景,确认上一课的 MenuUI、PageContainer 和 NavigatorBar 已经完成。
- 确认场景中存在 XR Origin 与实际用于头显视角的主摄像机。
- 确认主摄像机的标签为 MainCamera。
- 确认项目已经启用 Unity 新输入系统,并安装 XR Interaction Toolkit。
- 确认 Assets/_Scripts 中已经有第 15 课完成的 Singleton.cs。
- 准备一个始终保持启用的 Scripts 场景对象,或准备在本课中创建它。
- 打开 Hierarchy(层级面板)、Inspector(属性面板)、Console(控制台)和代码编辑器。
- 进入运行前保存场景,并等待 Unity 完成现有脚本编译。
本课学习路线
按照学习模块逐步完成
每个模块都包含理解、跟随操作和独立实践。把物理按键转换为程序能够理解的动作
读懂左手主按钮的输入路径
代码不直接依赖控制器表面印刷的字母,而是监听左手 XR 控制器的 primaryButton(主按钮)。下面两张图用可视化方式展示等价的动作与绑定关系;本课的实际运行代码会直接创建 ToggleMenu,不要求你修改输入动作资源。输入系统的三层关系
物理按钮负责产生操作,绑定负责指定控件,动作负责表达程序意图。
左右滑动表格,可查看完整内容
| 层级 | 本课示例 | 中文理解 |
|---|---|---|
| 物理控件 | 左手控制器 X 键 | 体验者真正按下的按钮 |
| 输入绑定 | primaryButton | 动作连接到左手主按钮 |
| 输入动作 | ToggleMenu | 程序收到切换菜单意图 |
理解按钮动作在编辑器中的表达方式
- 01
观察图中左侧选中的 XRI Left Interaction。
- 02
观察中间名为 Menu 的示例动作。
- 03
确认右侧 Action Type 为 Button,表示它接收一次按钮动作。
- 04
如果自己的动作资源中没有 Menu,不需要新建或保存它。
- 05
本课只借助图片理解映射关系,实际监听由代码创建的 ToggleMenu 完成。

这是一张动作关系示例。重点观察中间的 Menu 和右侧 Action Type = Button;自己的动作资源中没有 Menu 也不影响本课代码运行。
点击图片可以查看完整截图把 PrimaryButton 对应到代码控件路径
- 01
观察图中 Menu 下展开的 PrimaryButton 绑定。
- 02
观察中间的 PrimaryButton [LeftHand XR Controller]。
- 03
观察右侧 Binding Path 指向左手控制器主按钮。
- 04
把图中的 LeftHand 与代码路径中的 {LeftHand} 对应起来。
- 05
把图中的 PrimaryButton 与代码路径中的 /primaryButton 对应起来。

这是一张绑定关系示例:中间列表与右侧 Path 都指向左手 XR 控制器的 PrimaryButton;在当前 PICO 控制器上,它对应左手 X 键。
点击图片可以查看完整截图拆解控件路径
尖括号指定设备布局,花括号指定设备用途,斜杠后指定具体控件。
左右滑动表格,可查看完整内容
| 路径片段 | 含义 | 改错后的影响 |
|---|---|---|
| <XRController> | 设备必须是 XR 控制器 | 设备类型不匹配时无法响应 |
| {LeftHand} | 只接收左手控制器 | 写成 RightHand 会改为右手 |
| /primaryButton | 监听该设备的主按钮 | 控件名错误时无法触发 |
两种输入动作使用方式
本课只有一个固定菜单键,因此直接在代码中创建动作。
左右滑动表格,可查看完整内容
| 方式 | 怎样配置 | 适合情况 |
|---|---|---|
| 输入动作资源 | 在编辑器创建动作并把引用交给脚本 | 动作较多、需要集中管理或允许改键 |
| 代码创建动作 | 使用 new InputAction 指定名称、类型和路径 | 动作较少、绑定固定且职责明确 |
把控件路径翻译成中文
不看表格,用一句完整中文解释 <XRController>{LeftHand}/primaryButton。
- 指出设备类型。
- 指出设备属于左手还是右手。
- 指出监听的具体控件。
- 说明 ToggleMenu 是动作名称,不是控制器按键名称。
- 说明为什么 GameManager 属性窗口中不会出现 Menu Action 引用栏。
让输入命令和界面实现各自保持清楚
划分 GameManager 与 UIManager 的职责
GameManager 保存体验者的头部变换并接收全局输入,UIManager 专门管理菜单状态与空间位置。输入发生时,GameManager 只把“切换菜单”命令交给 UIManager,不直接修改界面对象。沿橙色路径阅读菜单命令,沿紫色路径阅读空间数据:GameManager 转交命令,UIManager 需要定位时读取 GameManager.Player。
手机上可左右滑动;点击图片可以查看完整图两个管理器的职责边界
一个脚本负责命令来源,另一个脚本负责菜单如何响应。
左右滑动表格,可查看完整内容
| 脚本 | 负责 | 不负责 |
|---|---|---|
| GameManager | 保存 Player、创建并监听菜单动作、转交切换命令 | 不直接调用 MenuUI.SetActive |
| UIManager | 保存 MenuUI、计算显示状态、更新位置和方向 | 不直接读取手柄按钮 |
Singleton 提供唯一的访问入口
两个脚本都继承 Singleton<T>(单例基类),其他脚本可以通过 GameManager.Instance 与 UIManager.Instance 找到当前管理器。
单例会保留第一个正式实例,并移除后来出现的重复实例。场景中仍应主动只放置一个 GameManager 和一个 UIManager,避免承载重复实例的对象被一并移除。
正确的场景承载关系
GameManager 与 UIManager 是 Scripts 对象上的组件,不是 MenuUI 的子对象。
左右滑动表格,可查看完整内容
| 场景对象 | 承载内容 | 启用规则 |
|---|---|---|
| Scripts | GameManager 与 UIManager 组件 | 始终保持启用 |
| MenuUI | PageContainer 与 NavigatorBar | 允许显示或隐藏 |
| Main Camera | 体验者头部位置和观察方向 | 标签为 MainCamera |
为什么管理器不能挂在 MenuUI 上
menuUI.SetActive(false) 会同时停用 MenuUI 及其全部组件和子对象。如果监听按键的 GameManager 也挂在菜单上,菜单关闭后就没有脚本继续接收再次打开的命令。
把两个管理器放在独立的 Scripts 对象上,菜单即使隐藏,输入监听与状态控制仍然继续工作。
完成职责归类
把下列任务分别交给 GameManager 或 UIManager,并说明依据。
- 读取主摄像机 Transform。
- 监听左手主按钮。
- 读取 MenuUI 当前状态。
- 把 MenuUI 放到体验者前方。
- 调用 SetActive 显示或隐藏。
- 确认两个管理器都不位于 MenuUI 下面。
保存体验者位置并创建左手菜单动作
编写 GameManager 的初始化逻辑
GameManager 先取得主摄像机的 Transform,再创建只监听左手主按钮的 InputAction。动作在 Awake 中完成创建,但此时还没有开始监听,启用动作会在下一模块完成。创建 GameManager.cs
- 01
在 Assets/_Scripts 中新建 C# 脚本。
- 02
把文件名和类名都保持为 GameManager。
- 03
导入 UnityEngine.InputSystem,以便使用 InputAction。
- 04
让类继承 Singleton<GameManager>。
- 05
按顺序输入下面三段 GameManager 代码,不跳过中间段。
using UnityEngine;
using UnityEngine.InputSystem;
public class GameManager : Singleton<GameManager>
{
public Transform Player { get; private set; }
private InputAction menuAction;
第一段打开 GameManager 类,公开 Player 的读取权限,同时把赋值权限保留在当前类内。
Player 属性的访问规则
主摄像机代表头显视角,它的 Transform 提供位置和前方方向。
左右滑动表格,可查看完整内容
| 代码部分 | 含义 | 使用结果 |
|---|---|---|
| public | 其他脚本可以访问 | UIManager 能读取 Player |
| Transform | 保存位置、旋转与缩放 | 可以读取 position 与 forward |
| get | 提供读取入口 | 外部脚本能够取得当前值 |
| private set | 只有 GameManager 可以赋值 | 防止外部脚本替换体验者对象 |
protected override void Awake()
{
base.Awake();
if (Instance != this)
{
return;
}
if (Camera.main != null)
{
Player = Camera.main.transform;
}
else
{
Debug.LogError("GameManager:没有找到主摄像机。", this);
}
menuAction = new InputAction(
"ToggleMenu",
InputActionType.Button,
"<XRController>{LeftHand}/primaryButton");
}
base.Awake 先完成单例登记;随后保存 MainCamera 的 Transform,并创建 ToggleMenu 按钮动作。
按实际执行顺序阅读 GameManager.Awake
Awake 是组件初始化方法。Unity 调用它以后,程序从方法开头向下执行,并根据条件决定是否跳过某些分支。
左右滑动表格,可查看完整内容
| 顺序 | 关键代码 | 此时发生什么 |
|---|---|---|
| 1 | base.Awake() | 先让 Singleton 登记正式实例,并处理重复对象 |
| 2 | if (Instance != this) | 判断当前组件是不是被保留的正式实例 |
| 3 | return | 若是重复实例,只结束这一次 Awake,不再创建输入动作 |
| 4 | if (Camera.main != null) | 找到带 MainCamera 标签的摄像机后,把它的 Transform 保存到 Player |
| 5 | else | 没有找到主摄像机时输出提示,便于确认标签 |
| 6 | new InputAction(...) | 创建 ToggleMenu 动作;此时只是完成定义,还没有开始监听 |
InputAction 构造参数
创建动作与启用动作是两个步骤,这里只完成定义。
左右滑动表格,可查看完整内容
| 参数 | 本课值 | 含义 |
|---|---|---|
| 动作名称 | ToggleMenu | 便于识别切换菜单命令 |
| 动作类型 | InputActionType.Button | 这是一次按钮动作 |
| 绑定路径 | <XRController>{LeftHand}/primaryButton | 只监听左手 XR 控制器主按钮 |
先组合 GameManager 的前两段
这两段还没有闭合 GameManager 类,只用于跟随讲解和继续组合。暂时不要保存后返回 Unity 编译,完成第三段和 UIManager 后再统一确认。
- 确认文件名与类名都是 GameManager。
- 确认已导入 UnityEngine.InputSystem。
- 确认 base.Awake() 位于单例判断之前。
- 确认主摄像机未找到时会输出明确提示。
- 确认控件路径包含 LeftHand 与 primaryButton。
- 确认当前只完成 2/3,继续进入下一模块补齐类的结束大括号。
组件启用时监听,停用时成对清理
完成输入动作的完整生命周期
InputAction 创建后不会自动监听。GameManager 启用时需要订阅 performed 事件并调用 Enable,停用时取消同一订阅并调用 Disable,最终销毁时再释放动作。沿循环阅读:组件再次启用时会重新订阅和监听;只有销毁阶段才最终 Dispose,因此启用与停用代码必须成对出现。
手机上可左右滑动;点击图片可以查看完整图 private void OnEnable()
{
if (menuAction == null)
{
return;
}
menuAction.performed += OnMenuButtonPressed;
menuAction.Enable();
}
private void OnDisable()
{
if (menuAction == null)
{
return;
}
menuAction.performed -= OnMenuButtonPressed;
menuAction.Disable();
}
private void OnDestroy()
{
menuAction?.Dispose();
}
private void OnMenuButtonPressed(InputAction.CallbackContext context)
{
UIManager.Instance.ToggleMenu();
}
}第三段关闭 GameManager 类。performed 发生时,回调只调用 UIManager.ToggleMenu,不直接处理界面。
成对出现的输入操作
把每一组配对记牢,可以避免组件反复启用后一次按键执行多次。
左右滑动表格,可查看完整内容
| 开始操作 | 对应清理 | 缺少清理可能出现的现象 |
|---|---|---|
| performed += | performed -= | 同一回调被重复登记 |
| Enable() | Disable() | 动作仍保持启用并监听控件,继续维护内部状态 |
| new InputAction | Dispose() | 不再使用的动作资源没有释放 |
事件通知与每帧轮询不是一回事
performed 是输入动作达到“已执行”阶段时发出的事件通知。学生按下左手 X 键后,Input System 发现 primaryButton 满足按钮条件,才会通知已经登记的 OnMenuButtonPressed;程序不需要在每一帧反复询问“按钮按下了吗”。
+= 表示把 OnMenuButtonPressed 登记到事件中,之后发生 performed 时就调用它;-= 表示移除同一个登记。Enable 与 Disable 决定动作是否接收输入,订阅与启用两组操作都要成对管理。
CallbackContext 是这次输入的上下文
InputAction.CallbackContext 可以提供动作阶段、来源控件和值等信息。对于本课的按钮动作,当主按钮越过按下阈值时,动作进入 performed 阶段,表示系统确认了一次有效按下,不表示按钮已经松开。
本课暂时不需要读取这些详细信息,但参数仍然要保留,因为 performed 事件要求回调方法使用这个参数形式。CallbackContext 只在这次回调执行期间使用,不把它保存到其他帧。
从进入运行模式到第一次按下 X 键
下面是理解代码的主线。两个管理器各自的 Awake 先完成自己的初始化,不要让功能依赖两个不同对象的 Awake 谁先谁后。
左右滑动表格,可查看完整内容
| 阶段 | 被调用的代码 | 结果 |
|---|---|---|
| 场景开始初始化 | GameManager.Awake | 登记实例、保存 Player,并创建 ToggleMenu |
| 场景开始初始化 | UIManager.Awake | 取得 MenuUI 引用,并把菜单设为隐藏 |
| GameManager 进入启用状态 | GameManager.OnEnable | 登记回调并启用 ToggleMenu |
| 学生按下左手 X 键 | Input System | 检测 primaryButton,并触发 performed |
| 事件发出通知 | OnMenuButtonPressed | 把切换菜单的命令交给 UIManager |
| 界面处理命令 | UIManager.ToggleMenu | 读取当前状态,决定显示还是隐藏 |
下载完整 GameManager.cs
完整文件包含 Player、左手菜单动作、全部生命周期方法和菜单命令回调。下载后放入 Assets/_Scripts,并保持文件名与类名一致。
- 与上面三段代码按顺序组合后的内容完全一致。
- 依赖 UnityEngine.InputSystem。
- 依赖项目中已有的 Singleton.cs 和稍后完成的 UIManager.cs。
完成并讲解 GameManager
输入第三段代码后,GameManager 文件已经闭合;但它会调用尚未完成的 UIManager,因此先不要单独返回 Unity 编译。按执行顺序讲清整份脚本后,继续完成 UIManager。
- 从 Awake 开始指出动作在哪里创建。
- 指出 performed 在哪里订阅与取消订阅。
- 指出动作在哪里启用、停用和释放。
- 停用再启用 GameManager,说明哪些方法会依次执行。
- 确认回调只把命令交给 UIManager。
- 确认三段代码已经闭合 GameManager 类,再继续编写 UIManager。
让菜单引用明确,并在运行开始时统一隐藏
建立 UIManager 的引用与初始状态
UIManager 需要保存 MenuUI 根对象和菜单距离。字段保持 private,避免其他脚本随意替换;使用 SerializeField 后,仍然可以在 Inspector 中完成明确绑定。创建 UIManager.cs
- 01
在 Assets/_Scripts 中新建 C# 脚本。
- 02
把文件名和类名都保持为 UIManager。
- 03
让类继承 Singleton<UIManager>。
- 04
先完成菜单引用、距离和 Awake。
- 05
下一模块再继续输入切换、隐藏预跟随和打开后固定的代码。
UIManager 的两个可配置字段
SerializeField 让私有字段显示在 Inspector 中,但不会把公共写入权限交给其他脚本。
左右滑动表格,可查看完整内容
| 字段 | 类型与初始值 | 用途 |
|---|---|---|
| menuUI | GameObject | 保存整个 MenuUI 根对象 |
| distanceFromPlayer | float,1.5 | 设置菜单与体验者的距离 |
using UnityEngine;
public class UIManager : Singleton<UIManager>
{
[SerializeField] private GameObject menuUI;
[SerializeField] private float distanceFromPlayer = 1.5f;
protected override void Awake()
{
base.Awake();
if (Instance != this)
{
return;
}
if (menuUI == null)
{
menuUI = GameObject.Find("MenuUI");
}
if (menuUI != null)
{
menuUI.SetActive(false);
}
else
{
Debug.LogError("UIManager:没有找到 MenuUI。", this);
}
}
第一段优先使用 Inspector 中分配的 MenuUI;引用为空时才尝试按名称寻找,最后统一设置为隐藏。
按实际执行顺序阅读 UIManager.Awake
这段代码先确认当前实例是否有效,再取得菜单引用,最后统一设置菜单的初始状态。
左右滑动表格,可查看完整内容
| 顺序 | 代码判断 | 学生可以怎样理解 |
|---|---|---|
| 1 | base.Awake() | 先完成 UIManager 单例登记,重复对象会被移除 |
| 2 | if (Instance != this) | 如果当前对象不是正式实例,就用 return 结束这一次 Awake |
| 3 | if (menuUI == null) | Inspector 没有分配对象时,才按 MenuUI 这个名称寻找 |
| 4 | if (menuUI != null) | 找到菜单后执行 SetActive(false),让它从隐藏状态开始 |
| 5 | else | 仍然找不到菜单时输出提示,不继续假装引用有效 |
获取 MenuUI 的两层方式
明确拖入引用更加稳定,按名称寻找只作为补充。
左右滑动表格,可查看完整内容
| 顺序 | 方式 | 特点 |
|---|---|---|
| 第一选择 | 在 Menu UI 字段拖入 MenuUI | 对象明确,即使之后被隐藏也仍有引用 |
| 第二选择 | GameObject.Find("MenuUI") | 只找到当前启用且名称完全匹配的对象 |
先组合 UIManager 的第一段
第一段还没有闭合 UIManager 类,只用于跟随讲解和继续组合。暂时不要返回 Unity 编译,也不要提前绑定场景对象;下一模块补齐第二段后再统一确认。
- 确认 menuUI 使用 SerializeField,同时保持 private。
- 确认 Distance From Player 的代码初始值是 1.5。
- 进入运行前让 MenuUI 保持启用。
- 说明 Awake 为什么要负责统一初始状态。
- 确认当前只完成 1/2,继续进入下一模块补齐类的结束大括号。
把知识变成作品
本课练习
请按顺序完成以下任务,并保存自己的学习成果。- 01
不查看代码,画出左手 X 键到 MenuUI.SetActive 的调用链。
- 02
把 <XRController>{LeftHand}/primaryButton 的三个片段分别翻译成中文。
- 03
按顺序说出 InputAction 在 Awake、OnEnable、OnDisable 和 OnDestroy 中的操作。
- 04
故意停用 GameManager,观察菜单按键停止响应后再恢复组件。
- 05
把 Distance From Player 改为不同正数,比较菜单显示距离后恢复为 1.5。
- 06
用自己的话解释“隐藏时预跟随、打开时固定、关闭后恢复跟随”的状态变化。
把本课进度保存下来
登录后可以保存浏览内容、有效学习时间、完成状态和答题参与记录。