菜单系统
菜单系统建立在 MenuSelector 组件之上 —— 一个通用、可复用的控制器,为任意按钮列表或网格处理光标导航、输入、音频和选择箭头。每个游戏内菜单(暂停菜单、战斗菜单、商店、图鉴、选项等)都继承自它。
MenuSelector
位置: Assets/OpenMon/Core/Runtime/UI/MenuSelector.cs
命名空间: OpenMon.MCE.UI
MenuSelector 继承自 HidableUiElement<MenuSelector> 并实现 IInputReceiver。它管理一个 MenuItem 子对象列表,并提供:
- 垂直或水平导航
- 环绕式光标移动
- 动画选择箭头(MCE 内置的补间 API —— 无需任何第三方资源)
- 长按快速滚动
- 移动和选择时的音频反馈
- 用于选择、悬停和返回的事件
关键序列化字段
[SerializeField] protected int DefaultSelected; // Starting cursor position
[SerializeField] private bool RequestInputOnShow = true; // Auto-request input focus
[SerializeField] protected Navigation MenuNavigation; // Vertical or Horizontal
[SerializeField] protected bool InvertedControls = true; // Invert nav direction
[SerializeField] private bool HideWhenChosen = true; // Auto-hide on select
[SerializeField] private bool NoChoosing; // Browse-only mode
[SerializeField] public bool NoGoingBack; // Disable back button
[SerializeField] public bool ShowSelectorArrow = true; // Show animated arrow
[SerializeField] public bool CanHoldToScrollFaster; // Hold-to-repeat
[SerializeField] public float HoldNavigationInterval; // Repeat rate (seconds)
public List<MenuItem> MenuOptions = new(); // The button list
事件
| 事件 | 类型 | 触发时机 |
|---|---|---|
OnButtonSelected | Action<int> | 玩家确认一个选择(所选项的索引) |
OnHovered | Action<int> | 光标移动到新的一项 |
OnBackSelected | Action | 玩家按下返回/取消按钮 |
生命周期
Show(true)
└─ RequestInput() ──► IInputManager pushes this onto the input stack
└─ OnStateEnter()
├─ Instantiates selector arrow prefab
├─ Starts HoldNavigation coroutine
└─ Selects DefaultSelected after one frame
Show(false)
└─ ReleaseInput() ──► IInputManager pops this from the input stack
└─ OnStateExit()
├─ Stops HoldNavigation coroutine
├─ Deselects current item
└─ Destroys selector arrow
导航流程
当玩家按下一个方向时:
OnNavigation()读取输入向量并提取相关轴(垂直取 Y,水平取 X)。Navigate()递增或递减选择索引,在边界处环绕。- 不可交互的按钮会被自动跳过。
- 在新索引上调用
Select(),它会:- 取消选择上一项
- 播放导航音频
- 更新
CurrentSelection - 用
OpenMon.Foundation.TweeningAPI 移动选择箭头 - 触发
OnHovered - 在新按钮上调用
MenuItem.OnSelect()
// Simplified navigation logic
protected virtual void Navigate(float input)
{
if (InvertedControls) input *= -1;
int newSelection = CurrentSelection;
// Find next interactable item, wrapping around
switch (input)
{
case > 0: newSelection = (newSelection + 1) % MenuOptions.Count; break;
case < 0: newSelection = (newSelection - 1 + MenuOptions.Count) % MenuOptions.Count; break;
}
Select(newSelection);
}
长按滚动
当 CanHoldToScrollFaster 为 true 时,按住导航输入会以 HoldNavigationInterval 的速率触发重复的 Navigate() 调用。这使用一个持久协程:
protected IEnumerator HoldNavigation()
{
while (true)
{
if (Holding) Navigate(LastInput);
yield return new WaitForSeconds(HoldNavigationInterval);
}
}
MenuItem
位置: Assets/OpenMon/Core/Runtime/UI/MenuItem.cs
MenuSelector 中的每个按钮都是一个 MenuItem —— 一个 HidableAndSubscribableButton,带有:
ArrowSelectorPosition—— 一个Transform子对象,定义选择箭头应落在何处。ToggleIgnoreLayout—— 为 true 时,隐藏该项也会通过LayoutElement.ignoreLayout将其从布局计算中移除。OnSelect()/OnDeselect()—— 由父级MenuSelector调用,以触发 UnityEventSystem的选择状态。
public class MenuItem : HidableAndSubscribableButton
{
public Transform ArrowSelectorPosition;
public virtual void OnSelect()
{
EventSystem.current.SetSelectedGameObject(null);
Button.Select();
}
public virtual void OnDeselect() => Button.OnDeselect(null);
}
ScrollableMenuSelector
位置: Assets/OpenMon/Core/Runtime/UI/ScrollableMenuSelector.cs
对于高度超过视口的菜单(例如一长串招式或道具列表),ScrollableMenuSelector 覆盖 Select() 以自动滚动父级 ScrollRect:
public override void Select(int index, bool playAudio = true, bool updateArrow = true, bool force = false)
{
base.Select(index, playAudio, false, force);
UpdateScroll(); // Scroll to keep selection visible
UpdateSelectorArrowPosition(); // Then reposition the arrow
}
private void UpdateScroll()
{
if (MenuOptions.Count < 2) return;
Scroll.verticalNormalizedPosition = 1 - (float)CurrentSelection / (MenuOptions.Count - 1);
}
VirtualizedMenuSelector
位置: Assets/OpenMon/Core/Runtime/UI/VirtualizedMenuSelector.cs
对于非常大的列表(数百项、图鉴、PC 存储),VirtualizedMenuSelector<TData, TButtonController, TButtonFactory> 使用对象池,在玩家滚动时回收一小组按钮实例:
- 创建固定数量的
TButtonController实例(足以填满视口外加一个小缓冲)。 - 随着
ScrollRect滚动,屏幕外的项会被重新定位并用新数据重新填充。 - 抽象方法
PopulateChildData(TButtonController child, TData data)必须由子类实现,以将数据绑定到每个被回收的按钮。
// Setting data on a virtualized menu
public void SetButtons(List<TData> newData, bool clearPrevious = true)
{
Data = newData;
RowCount = Data.Count;
Refresh(clearPrevious);
}
按钮实例通过 Zenject GameObjectFactory<TButtonController> 创建:
[Inject] private TButtonFactory buttonFactory;
// Inside CheckChildItems():
childItems[i] = buttonFactory.CreateUiGameObject(Content);
示例:SavegameButton
public class SavegameButton : VirtualizedMenuItem
{
[SerializeField] private TMP_Text Text;
public void SetSaveName(string nameToSet) => Text.SetText(nameToSet);
public class Factory : GameObjectFactory<SavegameButton> { }
}
游戏界面
主菜单(标题界面)
位置: Assets/OpenMon/Core/Runtime/UI/MainMenu/
标题界面提供新游戏、继续(读档)、制作人员。读档界面使用带 SavegameButton 项的 VirtualizedMenuSelector 来显示已保存的游戏。
游戏内暂停菜单
位置: Assets/OpenMon/Core/Runtime/UI/GameMenu/GameMenuScreen.cs
GameMenuScreen 继承自 MenuSelector,并用补间动画滑入/滑出。它通过一个 ButtonSelected(int index) switch 路由选择,共有 11 个条目 —— 原有的 8 个外加三个可建造内容条目(case 9–11;索引 8 未使用,其余条目保留各自的索引):
| 索引 | 条目 | 行动 |
|---|---|---|
| 0 | Dex | DialogManager.ShowDexScreen(...) |
| 1 | Monsters | DialogManager.ShowPlayerRosterMenu(...) |
| 2 | Bag | DialogManager.ShowBag(...) |
| 3 | Map | MapSceneLauncher.ShowMap(...) |
| 4 | Quests | DialogManager.ShowQuestsScreen(...) |
| 5 | Profile | DialogManager.ShowProfileScreen(...) |
| 6 | Options | DialogManager.ShowOptionsMenu() |
| 7 | Exit | 提示存档,然后退出或返回标题界面 |
| 9 | Battle Tower | EphemeralTowerLauncher.Launch(diContainer, playerCharacter) —— 无尽的、短暂性的挑战 |
| 10 | My Base | EnterOrDecorateSecretBase() —— 进入室内,或在室内时打开装饰调色板 |
| 11 | My Gym | EnterOrManageGym() —— 进入玩家的道馆,或在室内时装饰/管理 |
这三个新条目已本地化(源码中的西班牙语标签 Torre de Batalla / Mi Base / Mi Gimnasio),并按可用性门控 —— 在其编写槽位于菜单预制体中接好之前,每个都是一个安全的空操作:
| 条目 | 可用性门(在 GameMenuScreen 上序列化) |
|---|---|
| 对战塔(case 9) | 玩家拥有一个非空的 PlayerRoster |
| My Base(case 10) | 已分配 secretBaseInteriorScene(一个 SceneInfoAsset);否则跳过进入 |
| My Gym(case 11) | 已分配 gymInteriorScene(一个 SceneInfoAsset);否则跳过进入 |
Case 9–11 是依赖注入的:diContainer 构造注入短暂性的对战塔,teleporter(PlayerTeleporter)将玩家传送进基地/道馆室内。在任何会接管界面的条目之前,CloseMenuForOverworldAction() 会镜像 OnBack 的拆解流程,使大地图不会被冻结在战斗/场景之后。
菜单支持通过 OnExtra2 快速存档,并可通过返回键和专用的菜单键(OnExtra1)切换。
// Opening the game menu
DialogManager.ShowGameMenu(onButtons, playerCharacter, onBack);
按钮可见性通过 UpdateLayout(List<bool>) 控制 —— 传入一个布尔列表以显示/隐藏特定条目(例如在玩家获得图鉴前隐藏 Dex)。
关于这三个新条目背后的玩法,参见秘密基地与玩家道馆指南。
选项界面
位置: Assets/OpenMon/Core/Runtime/UI/Options/
将游戏设置暴露为基于 MenuSelector 的控件:
- 通过
VolumeSlider/TweenableSlider提供音量滑块(全局、音乐、音效) - 分辨率、全屏模式、显示器选择
- 语言选择
- 玩法:战斗速度、战斗风格、难度、捕捉难度、跑步模式
- 自动存档开关、存档格式、昵称显示
战斗 UI
位置: Assets/OpenMon/Core/Runtime/UI/Battle/
战斗 HUD 使用独立的 MenuSelector 子类(不经由 DialogManager 路由):
MainBattleMenu—— Fight / Bag / Monster / Run,带快速投掷上一个精灵球(OnExtra2)。BattleMoveMenuSelector—— 4 招式网格。TargetMonstersMenuSelector—— 多打战斗的目标选择器。BattleInfoPanel—— 详细的能力值/属性信息叠加层。
添加一个自定义菜单
- 创建预制体,根部放一个
MenuSelector(或子类),每个按钮放一个MenuItem子对象。 - 在每个
MenuItem上设置ArrowSelectorPosition,指向箭头应出现的子 Transform。 - 配置
MenuNavigation(垂直/水平)、音频引用和检视器中的箭头预制体。 - 在你的控制器中订阅事件:
public class MyCustomMenu : MonoBehaviour
{
[SerializeField] private MenuSelector menu;
private void OnEnable()
{
menu.OnButtonSelected += OnItemChosen;
menu.OnBackSelected += OnBack;
}
private void OnItemChosen(int index)
{
// Handle selection
}
private void OnBack()
{
// Close menu
}
}
- 对于大型列表,继承
VirtualizedMenuSelector<TData, TButton, TFactory>,实现PopulateChildData(),并在UIElementsInstaller中注册你的按钮工厂。