跳到主要内容

菜单系统

菜单系统建立在 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

事件​

事件类型触发时机
OnButtonSelectedAction<int>玩家确认一个选择(所选项的索引)
OnHoveredAction<int>光标移动到新的一项
OnBackSelectedAction玩家按下返回/取消按钮

生命周期​

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

导航流程​

当玩家按下一个方向时:

  1. OnNavigation() 读取输入向量并提取相关轴(垂直取 Y,水平取 X)。
  2. Navigate() 递增或递减选择索引,在边界处环绕。
  3. 不可交互的按钮会被自动跳过。
  4. 在新索引上调用 Select(),它会:
    • 取消选择上一项
    • 播放导航音频
    • 更新 CurrentSelection
    • 用 OpenMon.Foundation.Tweening API 移动选择箭头
    • 触发 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);
}
}

位置: Assets/OpenMon/Core/Runtime/UI/MenuItem.cs

MenuSelector 中的每个按钮都是一个 MenuItem —— 一个 HidableAndSubscribableButton,带有:

  • ArrowSelectorPosition —— 一个 Transform 子对象,定义选择箭头应落在何处。
  • ToggleIgnoreLayout —— 为 true 时,隐藏该项也会通过 LayoutElement.ignoreLayout 将其从布局计算中移除。
  • OnSelect() / OnDeselect() —— 由父级 MenuSelector 调用,以触发 Unity EventSystem 的选择状态。
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 未使用,其余条目保留各自的索引):

索引条目行动
0DexDialogManager.ShowDexScreen(...)
1MonstersDialogManager.ShowPlayerRosterMenu(...)
2BagDialogManager.ShowBag(...)
3MapMapSceneLauncher.ShowMap(...)
4QuestsDialogManager.ShowQuestsScreen(...)
5ProfileDialogManager.ShowProfileScreen(...)
6OptionsDialogManager.ShowOptionsMenu()
7Exit提示存档,然后退出或返回标题界面
9Battle TowerEphemeralTowerLauncher.Launch(diContainer, playerCharacter) —— 无尽的、短暂性的挑战
10My BaseEnterOrDecorateSecretBase() —— 进入室内,或在室内时打开装饰调色板
11My GymEnterOrManageGym() —— 进入玩家的道馆,或在室内时装饰/管理

这三个新条目已本地化(源码中的西班牙语标签 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 —— 详细的能力值/属性信息叠加层。

添加一个自定义菜单​

  1. 创建预制体,根部放一个 MenuSelector(或子类),每个按钮放一个 MenuItem 子对象。
  2. 在每个 MenuItem 上设置 ArrowSelectorPosition,指向箭头应出现的子 Transform。
  3. 配置 MenuNavigation(垂直/水平)、音频引用和检视器中的箭头预制体。
  4. 在你的控制器中订阅事件:
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
}
}
  1. 对于大型列表,继承 VirtualizedMenuSelector<TData, TButton, TFactory>,实现 PopulateChildData(),并在 UIElementsInstaller 中注册你的按钮工厂。