UI系统组件

This commit is contained in:
2026-06-06 09:00:11 +08:00
parent fe4fd60083
commit d794b83ebe
107 changed files with 25690 additions and 476 deletions

View File

@@ -0,0 +1,259 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using BaseGames.Core;
using BaseGames.Core.Assets;
using BaseGames.Core.Events;
namespace BaseGames.UI.MainMenu
{
/// <summary>
/// 数据驱动主菜单控制器(<see cref="MainMenuController"/> 的表驱动版,非破坏性并存)。
///
/// 按钮列表据 <see cref="MainMenuConfigSO"/> 生成(标签/图标/顺序/动作纯数据,策划改表零代码);
/// 子面板开关、存档槽流程、入场动画、状态锁定等编排仍在本控制器(场景耦合,不下放配置表)。
/// 动作派发:内置 NewGame/Continue/OpenSettings/OpenCredits/LoadScene/Quit + 事件频道 RaiseEvent。
/// </summary>
public class DataDrivenMainMenuController : MonoBehaviour
{
[Header("数据表 / 按钮列表")]
[SerializeField] private MainMenuConfigSO _config;
[Tooltip("按钮的父节点(通常挂 VerticalLayoutGroup即主按钮组。")]
[SerializeField] private Transform _container;
[SerializeField] private MainMenuButtonView _buttonPrefab;
[Header("主按钮组(入场动画)")]
[SerializeField] private CanvasGroup _mainButtonsGroup;
[SerializeField] private RectTransform _mainButtonsRect;
[Header("子面板")]
[SerializeField] private GameObject _saveSlotPanel;
[SerializeField] private BaseGames.UI.Menus.SaveSlotController _saveSlotController;
[SerializeField] private GameObject _settingsPanel;
[SerializeField] private GameObject _creditsPanel;
[Header("子面板关闭按钮(可选)")]
[SerializeField] private Button _btnCloseSaveSlot;
[SerializeField] private Button _btnCloseSettings;
[SerializeField] private Button _btnCloseCredits;
[Header("入场动画")]
[SerializeField] private float _entrySlideOffset = 80f;
[SerializeField] private float _entryDuration = 0.55f;
[Header("场景")]
[SerializeField] private string _firstGameSceneKey = AddressKeys.SceneGameChapter1;
[Header("Event Channels - Listen")]
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
[SerializeField] private IntEventChannelSO _onSlotConfirmed;
[Header("Event Channels - Raise")]
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
private readonly CompositeDisposable _subs = new();
private readonly List<(MainMenuConfigSO.Item item, MainMenuButtonView view)> _buttons = new();
private Vector2 _buttonsPanelOriginalPos;
private MainMenuButtonView _firstButton;
// ── 生命周期 ──────────────────────────────────────────────────────────
private void Awake()
{
if (_buttonPrefab != null) _buttonPrefab.gameObject.SetActive(false);
_btnCloseSaveSlot?.onClick.AddListener(() => CloseSubPanel(_saveSlotPanel));
_btnCloseSettings?.onClick.AddListener(() => CloseSubPanel(_settingsPanel));
_btnCloseCredits? .onClick.AddListener(() => CloseSubPanel(_creditsPanel));
if (_mainButtonsRect != null)
_buttonsPanelOriginalPos = _mainButtonsRect.anchoredPosition;
SetPanel(_saveSlotPanel, false);
SetPanel(_settingsPanel, false);
SetPanel(_creditsPanel, false);
SetButtonsGroupVisible(false);
BuildMenu();
}
private void OnEnable()
{
_onGameStateChanged?.Subscribe(HandleGameStateChanged).AddTo(_subs);
_onSlotConfirmed? .Subscribe(HandleSlotConfirmed).AddTo(_subs);
}
private void OnDisable() => _subs.Clear();
private void Start() => StartCoroutine(PlayEntryAnimation());
// ── 据表建菜单 ────────────────────────────────────────────────────────
/// <summary>据配置重建主菜单按钮列表public 以便编辑器/测试验证)。</summary>
public void BuildMenu()
{
foreach (var (_, view) in _buttons) if (view != null) Destroy(view.gameObject);
_buttons.Clear();
_firstButton = null;
if (_config == null || _container == null || _buttonPrefab == null) return;
foreach (var item in _config.Items)
{
var view = Instantiate(_buttonPrefab, _container);
view.gameObject.SetActive(true);
var captured = item;
view.Bind(item.labelKey, item.icon, () => Dispatch(captured));
_buttons.Add((item, view));
if (_firstButton == null) _firstButton = view;
}
RefreshConditional();
}
/// <summary>根据存档存在性刷新 requiresSave 按钮的可用性(如"继续")。</summary>
public void RefreshConditional()
{
bool hasSave = HasAnySave();
foreach (var (item, view) in _buttons)
if (item.requiresSave && view != null)
view.SetInteractable(hasSave);
}
// ── 动作派发 ──────────────────────────────────────────────────────────
private void Dispatch(MainMenuConfigSO.Item item)
{
switch (item.action)
{
case MainMenuAction.NewGame:
_saveSlotController?.SetMode(BaseGames.UI.Menus.SaveSlotPanelMode.NewGame);
OpenSubPanel(_saveSlotPanel);
break;
case MainMenuAction.Continue:
_saveSlotController?.SetMode(BaseGames.UI.Menus.SaveSlotPanelMode.Continue);
OpenSubPanel(_saveSlotPanel);
break;
case MainMenuAction.OpenSettings:
OpenSubPanel(_settingsPanel);
break;
case MainMenuAction.OpenCredits:
OpenSubPanel(_creditsPanel);
if (_btnCloseCredits != null)
EventSystem.current?.SetSelectedGameObject(_btnCloseCredits.gameObject);
break;
case MainMenuAction.LoadScene:
_onSceneLoadRequest?.Raise(new SceneLoadRequest
{
SceneName = string.IsNullOrEmpty(item.sceneKey) ? _firstGameSceneKey : item.sceneKey,
TransitionType = TransitionType.Scene,
ShowLoadingScreen = true,
});
break;
case MainMenuAction.Quit:
Application.Quit();
break;
case MainMenuAction.RaiseEvent:
item.eventChannel?.Raise();
break;
}
}
// ── 子面板编排 ────────────────────────────────────────────────────────
private void OpenSubPanel(GameObject panel)
{
SetMainButtonsInteractable(false);
SetPanel(panel, true);
}
private void CloseSubPanel(GameObject panel)
{
SetPanel(panel, false);
SetMainButtonsInteractable(true);
if (_firstButton != null)
EventSystem.current?.SetSelectedGameObject(_firstButton.Button.gameObject);
}
private void SetMainButtonsInteractable(bool on)
{
if (_mainButtonsGroup == null) return;
_mainButtonsGroup.interactable = on;
_mainButtonsGroup.blocksRaycasts = on;
}
// ── 存档槽确认(与 MainMenuController 一致)────────────────────────────
private void HandleSlotConfirmed(int _)
{
SetPanel(_saveSlotPanel, false);
var svc = ServiceLocator.GetOrDefault<ISaveService>();
string checkpointScene = svc?.LastCheckpointScene;
bool hasCheckpoint = !string.IsNullOrEmpty(checkpointScene);
_onSceneLoadRequest?.Raise(new SceneLoadRequest
{
SceneName = hasCheckpoint ? checkpointScene : _firstGameSceneKey,
EntryTransitionId = hasCheckpoint ? svc.LastCheckpointSpawnId : null,
TransitionType = TransitionType.Scene,
ShowLoadingScreen = true,
});
}
private void HandleGameStateChanged(GameStateId state)
{
bool isMainMenu = state == GameStates.MainMenu;
if (_mainButtonsGroup != null)
{
_mainButtonsGroup.interactable = isMainMenu;
_mainButtonsGroup.blocksRaycasts = isMainMenu;
}
}
// ── 入场动画 ──────────────────────────────────────────────────────────
private IEnumerator PlayEntryAnimation()
{
if (_mainButtonsGroup == null) yield break;
Vector2 startPos = _buttonsPanelOriginalPos - new Vector2(0f, _entrySlideOffset);
if (_mainButtonsRect != null) _mainButtonsRect.anchoredPosition = startPos;
float elapsed = 0f;
while (elapsed < _entryDuration)
{
float t = Mathf.SmoothStep(0f, 1f, elapsed / _entryDuration);
_mainButtonsGroup.alpha = t;
if (_mainButtonsRect != null)
_mainButtonsRect.anchoredPosition = Vector2.Lerp(startPos, _buttonsPanelOriginalPos, t);
elapsed += Time.unscaledDeltaTime;
yield return null;
}
_mainButtonsGroup.alpha = 1f;
if (_mainButtonsRect != null) _mainButtonsRect.anchoredPosition = _buttonsPanelOriginalPos;
_mainButtonsGroup.interactable = true;
_mainButtonsGroup.blocksRaycasts = true;
if (_firstButton != null)
EventSystem.current?.SetSelectedGameObject(_firstButton.Button.gameObject);
}
// ── 工具 ──────────────────────────────────────────────────────────────
private static bool HasAnySave()
{
var s = ServiceLocator.GetOrDefault<ISaveService>();
return s != null && (s.HasSave(0) || s.HasSave(1) || s.HasSave(2));
}
private static void SetPanel(GameObject panel, bool active)
{
if (panel != null) panel.SetActive(active);
}
private void SetButtonsGroupVisible(bool visible)
{
if (_mainButtonsGroup == null) return;
_mainButtonsGroup.alpha = visible ? 1f : 0f;
_mainButtonsGroup.interactable = visible;
_mainButtonsGroup.blocksRaycasts = visible;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a38dc36f833bef3438073a0740ece716
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,44 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using BaseGames.Localization;
namespace BaseGames.UI.MainMenu
{
/// <summary>
/// 主菜单按钮视图(显式序列化绑定,对照 <see cref="BaseGames.UI.Inventory.ItemSlotView"/> 风格)。
/// 由 <see cref="DataDrivenMainMenuController"/> 据配置实例化并 <see cref="Bind"/>。
/// 标签走 <see cref="LocalizedText"/>,随语言切换自动刷新。
/// </summary>
[DisallowMultipleComponent]
public class MainMenuButtonView : MonoBehaviour
{
[SerializeField] private Button _button;
[SerializeField] private LocalizedText _label;
[Tooltip("按钮图标(可空)。")]
[SerializeField] private Image _icon;
public Button Button => _button;
/// <summary>绑定标签 Key、图标与点击回调。</summary>
public void Bind(string labelKey, Sprite icon, Action onClick)
{
if (_label != null) _label.SetKey(labelKey);
if (_icon != null)
{
_icon.sprite = icon;
_icon.enabled = icon != null;
}
if (_button != null)
{
_button.onClick.RemoveAllListeners();
if (onClick != null) _button.onClick.AddListener(() => onClick());
}
}
public void SetInteractable(bool value)
{
if (_button != null) _button.interactable = value;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: aa83d8ead83f78b428cdd2b0d1a89aa7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,56 @@
using System;
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.UI.MainMenu
{
/// <summary>主菜单项动作类型。常用动作内置;任意自定义走事件频道。</summary>
public enum MainMenuAction
{
NewGame, // 打开存档槽(新游戏语境)
Continue, // 打开存档槽(继续语境)
OpenSettings, // 打开设置子面板
OpenCredits, // 打开制作团队子面板
LoadScene, // 直接发起场景加载(用 sceneKey
Quit, // 退出游戏
RaiseEvent, // 触发 eventChannel万能扩展
}
/// <summary>
/// 主菜单数据驱动表(策划编辑)。按顺序列出主菜单项;
/// <see cref="DataDrivenMainMenuController"/> 据此生成按钮并派发动作。
/// 策划可增删 / 重排 / 改标签图标 / 改动作,无需改代码。
///
/// 派发边界:动作类型固定(含 RaiseEvent 万能扩展);
/// 子面板 / 场景 / 存档流的编排在控制器,配置表不引用场景对象。
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/UI/Main Menu Config", fileName = "UI_MainMenuConfig")]
public class MainMenuConfigSO : ScriptableObject
{
[Serializable]
public struct Item
{
[Tooltip("按钮标签本地化 KeyUI 表)。")]
public string labelKey;
[Tooltip("按钮图标(可空)。")]
public Sprite icon;
[Tooltip("点击动作。")]
public MainMenuAction action;
[Tooltip("勾选则需要存在有效存档才可用(如\"继续\";无存档时按钮置灰)。")]
public bool requiresSave;
[Tooltip("LoadScene 动作的目标场景 Addressable Key。")]
public string sceneKey;
[Tooltip("RaiseEvent 动作触发的事件频道。")]
public VoidEventChannelSO eventChannel;
}
[SerializeField] private Item[] _items;
public Item[] Items => _items;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d78ee6af5c2b0344ea105452f496e85c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -93,9 +93,9 @@ namespace BaseGames.UI.MainMenu
_btnCredits? .onClick.AddListener(OnCreditsClicked);
_btnQuit? .onClick.AddListener(Application.Quit);
_btnCloseSaveSlot?.onClick.AddListener(() => SetPanel(_saveSlotPanel, false));
_btnCloseSettings?.onClick.AddListener(() => SetPanel(_settingsPanel, false));
_btnCloseCredits? .onClick.AddListener(() => SetPanel(_creditsPanel, false));
_btnCloseSaveSlot?.onClick.AddListener(() => CloseSubPanel(_saveSlotPanel, _btnNewGame));
_btnCloseSettings?.onClick.AddListener(() => CloseSubPanel(_settingsPanel, _btnSettings));
_btnCloseCredits? .onClick.AddListener(() => CloseSubPanel(_creditsPanel, _btnCredits));
// 记录按钮组原始位置(供动画使用)
if (_mainButtonsRect != null)
@@ -162,15 +162,44 @@ namespace BaseGames.UI.MainMenu
private void OnNewGameClicked()
{
_saveSlotController?.SetMode(BaseGames.UI.Menus.SaveSlotPanelMode.NewGame);
SetPanel(_saveSlotPanel, true);
OpenSubPanel(_saveSlotPanel);
}
private void OnContinueClicked()
{
_saveSlotController?.SetMode(BaseGames.UI.Menus.SaveSlotPanelMode.Continue);
SetPanel(_saveSlotPanel, true);
OpenSubPanel(_saveSlotPanel);
}
private void OnSettingsClicked() => OpenSubPanel(_settingsPanel); // SettingsPanelController 自行设焦点
private void OnCreditsClicked()
{
OpenSubPanel(_creditsPanel);
// Credits 面板无独立控制器,打开时把焦点交给返回按钮(键盘 / 手柄可直接退出)
if (_btnCloseCredits != null)
EventSystem.current?.SetSelectedGameObject(_btnCloseCredits.gameObject);
}
/// <summary>打开子面板:禁用主按钮组交互,避免键盘/手柄导航"穿透"到背后的主菜单按钮。</summary>
private void OpenSubPanel(GameObject panel)
{
SetMainButtonsInteractable(false);
SetPanel(panel, true);
}
/// <summary>关闭子面板:恢复主按钮组交互,并把焦点恢复到对应主菜单按钮(导航连续性)。</summary>
private void CloseSubPanel(GameObject panel, Button focusAfter)
{
SetPanel(panel, false);
SetMainButtonsInteractable(true);
if (focusAfter != null)
EventSystem.current?.SetSelectedGameObject(focusAfter.gameObject);
}
private void SetMainButtonsInteractable(bool on)
{
if (_mainButtonsGroup == null) return;
_mainButtonsGroup.interactable = on;
_mainButtonsGroup.blocksRaycasts = on;
}
private void OnSettingsClicked() => SetPanel(_settingsPanel, true);
private void OnCreditsClicked() => SetPanel(_creditsPanel, true);
// ── 存档槽确认 ───────────────────────────────────────────────────────