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,8 @@
fileFormatVersion: 2
guid: e8cff642fe44f954694ab825135c02f5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,91 @@
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.UI
{
/// <summary>
/// UI 面板基类:统一面板生命周期、焦点恢复、进场淡入与事件订阅清理。
///
/// 消除各面板控制器PauseMenu / Settings / InventoryHub 等)重复的样板:
/// - OnEnable取服务 / 订阅 / 设默认选中 / 播淡入
/// - OnDisable清理订阅<see cref="_subs"/>/ 停动画
/// - <see cref="IFocusable.OnFocusRestored"/>:关闭子面板回到栈顶时聚焦首项
///
/// 与 <see cref="UIManager"/> 面板栈完全兼容UIManager 仍以 SetActive 驱动开关,
/// 本基类只在 OnEnable/OnDisable 加钩子,不改变栈语义。
///
/// 子类用法:重写 <see cref="OnPanelOpen"/>(订阅/刷新)与可选 <see cref="OnPanelClose"/>
/// 在 Inspector 指定 <see cref="_firstSelected"/>(手柄/键盘默认焦点);可选挂 CanvasGroup 做淡入。
/// </summary>
[DisallowMultipleComponent]
public abstract class UIPanelBase : MonoBehaviour, IFocusable
{
[Header("Panel Base")]
[Tooltip("打开 / 焦点恢复时默认选中的控件(手柄/键盘导航起点)。可空。")]
[SerializeField] protected Selectable _firstSelected;
[Tooltip("进场淡入用的 CanvasGroup可空为空则不淡入。")]
[SerializeField] protected CanvasGroup _canvasGroup;
[Tooltip("进场淡入时长。0 表示无淡入。")]
[SerializeField] protected float _fadeInDuration = 0.15f;
[Tooltip("打开时是否自动把焦点设到首项。")]
[SerializeField] protected bool _selectFirstOnEnable = true;
/// <summary>事件订阅容器OnDisable 自动清理。子类订阅用 <c>channel.Subscribe(..).AddTo(_subs)</c>。</summary>
protected readonly CompositeDisposable _subs = new();
private Coroutine _fadeRoutine;
// ── 生命周期 ──────────────────────────────────────────────────────────
protected virtual void OnEnable()
{
OnPanelOpen();
if (_canvasGroup != null && _fadeInDuration > 0f) PlayFadeIn();
if (_selectFirstOnEnable) FocusFirst();
}
protected virtual void OnDisable()
{
_subs.Clear();
if (_fadeRoutine != null) { StopCoroutine(_fadeRoutine); _fadeRoutine = null; }
OnPanelClose();
}
/// <summary>面板打开时调用OnEnable。子类在此取服务、订阅事件、刷新内容。</summary>
protected virtual void OnPanelOpen() { }
/// <summary>面板关闭时调用OnDisable_subs 已清理之后)。子类在此释放非 _subs 资源。</summary>
protected virtual void OnPanelClose() { }
// ── 焦点 ──────────────────────────────────────────────────────────────
/// <summary>关闭子面板、本面板恢复为栈顶时调用(<see cref="IUIManager.CloseTopPanel"/> 触发)。</summary>
public virtual void OnFocusRestored() => FocusFirst();
/// <summary>将 EventSystem 焦点设到首项(优先 <see cref="_firstSelected"/>,否则 <see cref="ResolveFirstSelected"/>)。</summary>
protected void FocusFirst()
{
var go = _firstSelected != null ? _firstSelected.gameObject : ResolveFirstSelected();
if (go != null && EventSystem.current != null)
EventSystem.current.SetSelectedGameObject(go);
}
/// <summary>子类可重写:动态决定首个选中项(如列表第一行),当 <see cref="_firstSelected"/> 未指定时使用。</summary>
protected virtual GameObject ResolveFirstSelected() => null;
// ── 工具 ──────────────────────────────────────────────────────────────
/// <summary>从 ServiceLocator 取服务(未注册返回 null。</summary>
protected static T GetService<T>() where T : class => ServiceLocator.GetOrDefault<T>();
private void PlayFadeIn()
{
if (_fadeRoutine != null) StopCoroutine(_fadeRoutine);
_canvasGroup.alpha = 0f;
_fadeRoutine = StartCoroutine(UITween.FadeCanvasGroup(_canvasGroup, 1f, _fadeInDuration));
}
}
}

View File

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

View File

@@ -0,0 +1,11 @@
namespace BaseGames.UI
{
/// <summary>
/// 通用面板:<see cref="UIPanelBase"/> 的最简具体实现,无额外逻辑。
/// 用作脚手架生成的 themed 面板根(带 CanvasGroup 淡入 + 默认焦点 + 订阅清理),
/// 适合不需要自定义控制器的简单弹窗 / 容器。需要业务逻辑时改挂自定义 <see cref="UIPanelBase"/> 子类。
/// </summary>
public sealed class UISimplePanel : UIPanelBase
{
}
}

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 986c5f0b71c98d74c9082fb0c5236b1c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,96 @@
using System.Collections.Generic;
using UnityEngine;
namespace BaseGames.UI
{
/// <summary>
/// 简单数据绑定列表项接口。视图实现后,<see cref="PooledListView{TData,TView}"/>
/// 的默认 <c>BindItem</c> 可直接调用,无需子类重写绑定逻辑。
/// </summary>
public interface IPooledItemView<in TData>
{
void Bind(TData data);
}
/// <summary>
/// 对象池数据绑定列表基类(统一 ItemInventory / CharmEquip / QuestLog 等处的手写重建模板)。
///
/// 用法:
/// 1. 子类指定 TData数据与 TView继承 <see cref="Component"/> 的列表项视图)。
/// 2. 在 Inspector 设置 <see cref="_container"/>(布局父节点)与 <see cref="_template"/>(保持 inactive 的原型)。
/// 3. 调用 <see cref="SetItems"/> 以数据集重建;视图通过 <see cref="BindItem"/> 绑定。
///
/// 默认绑定TView 实现 <see cref="IPooledItemView{TData}"/> 时无需重写 <see cref="BindItem"/>
/// 需要传回调(如 onSelect时重写 <see cref="BindItem"/>。
///
/// 非虚拟化:所有项同时存在(适合数十到上百项);超长列表后续可加虚拟化。
/// </summary>
public abstract class PooledListView<TData, TView> : MonoBehaviour
where TView : Component
{
[Header("Pooled List")]
[Tooltip("列表项的布局父节点(通常挂 Horizontal/VerticalLayoutGroup 或 GridLayoutGroup。")]
[SerializeField] protected Transform _container;
[Tooltip("列表项原型(保持 inactive作为对象池模板。")]
[SerializeField] protected TView _template;
private readonly List<TView> _active = new();
private readonly Queue<TView> _pool = new();
/// <summary>当前激活的列表项视图(只读)。</summary>
public IReadOnlyList<TView> Active => _active;
protected virtual void Awake()
{
if (_template != null) _template.gameObject.SetActive(false);
}
/// <summary>以数据集重建列表(回收旧项 → 逐项 Spawn + 绑定 → <see cref="OnRebuilt"/>)。</summary>
public void SetItems(IEnumerable<TData> items)
{
RecycleAll();
if (_container == null || _template == null || items == null) { OnRebuilt(); return; }
foreach (var data in items)
{
var view = Spawn();
BindItem(view, data);
_active.Add(view);
}
OnRebuilt();
}
/// <summary>回收所有激活项到池中(隐藏)。</summary>
public void RecycleAll()
{
foreach (var view in _active)
{
if (view == null) continue;
view.gameObject.SetActive(false);
_pool.Enqueue(view);
}
_active.Clear();
}
/// <summary>
/// 绑定单个视图。默认TView 实现 <see cref="IPooledItemView{TData}"/> 时直接调用其 Bind。
/// 需要额外参数(如选中回调)时子类重写。
/// </summary>
protected virtual void BindItem(TView view, TData data)
{
if (view is IPooledItemView<TData> bindable) bindable.Bind(data);
}
/// <summary>重建完成回调(子类可重写:默认选中首项、空态提示等)。</summary>
protected virtual void OnRebuilt() { }
private TView Spawn()
{
TView view = _pool.Count > 0 ? _pool.Dequeue() : Instantiate(_template, _container);
view.transform.SetParent(_container, false);
view.gameObject.SetActive(true);
return view;
}
}
}

View File

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

View File

@@ -0,0 +1,142 @@
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using BaseGames.Core.Events;
using BaseGames.UI.Theme;
namespace BaseGames.UI
{
/// <summary>按钮视觉变体(驱动从主题派生的 <see cref="ColorBlock"/>)。</summary>
public enum UIButtonVariant { Primary, Secondary, Accent, Danger, Ghost }
/// <summary>
/// 主题化按钮变体 + 交互反馈。薄封装在 uGUI <see cref="Button"/> 之上:
/// - 按变体从 <see cref="UIThemeSO"/> 取基色,派生 normal/highlighted/pressed/disabled 并写入 Button.colors。
/// - 选中/悬停时缩放反馈(走 <see cref="UITween.Scale"/>)。
/// - 可选点击/悬停音效(事件频道驱动,不直接耦合 Audio 程序集)。
///
/// 主题来源:优先 Inspector 直接赋的 <see cref="_theme"/>,否则就近向上找 <see cref="UIThemeApplier"/>。
/// </summary>
[RequireComponent(typeof(Button))]
[DisallowMultipleComponent]
public class UIButton : MonoBehaviour,
ISelectHandler, IDeselectHandler, IPointerEnterHandler, IPointerExitHandler
{
[Header("变体")]
[SerializeField] private UIButtonVariant _variant = UIButtonVariant.Primary;
[Tooltip("主题资产;为空则就近向上查找 UIThemeApplier。")]
[SerializeField] private UIThemeSO _theme;
[Header("反馈")]
[Tooltip("选中/悬停时的缩放反馈。")]
[SerializeField] private bool _scaleFeedback = true;
[SerializeField] private float _selectedScale = 1.06f;
[SerializeField] private float _scaleDuration = 0.08f;
[Header("音效(可选,事件频道驱动)")]
[SerializeField] private VoidEventChannelSO _onClickSfx;
[SerializeField] private VoidEventChannelSO _onHoverSfx;
private Button _button;
private Coroutine _scaleRoutine;
private Vector3 _baseScale = Vector3.one;
private void Awake()
{
_button = GetComponent<Button>();
_baseScale = transform.localScale;
_button.onClick.AddListener(RaiseClickSfx);
}
private void OnEnable()
{
ApplyTheme();
transform.localScale = _baseScale;
}
private void OnDisable()
{
if (_scaleRoutine != null) { StopCoroutine(_scaleRoutine); _scaleRoutine = null; }
transform.localScale = _baseScale;
}
// ── 主题应用 ──────────────────────────────────────────────────────────
/// <summary>按当前变体与主题刷新 Button 配色。</summary>
public void ApplyTheme()
{
var theme = ResolveTheme();
if (theme == null) return;
if (_button == null) _button = GetComponent<Button>();
if (_button == null) return;
Color baseColor = VariantBaseColor(theme, _variant);
var cb = _button.colors;
cb.colorMultiplier = 1f;
cb.fadeDuration = 0.1f;
cb.disabledColor = theme.ButtonDisabled;
if (_variant == UIButtonVariant.Ghost)
{
cb.normalColor = new Color(baseColor.r, baseColor.g, baseColor.b, 0f);
cb.highlightedColor = new Color(baseColor.r, baseColor.g, baseColor.b, 0.25f);
cb.pressedColor = new Color(baseColor.r, baseColor.g, baseColor.b, 0.40f);
cb.selectedColor = cb.highlightedColor;
}
else
{
cb.normalColor = baseColor;
cb.highlightedColor = Color.Lerp(baseColor, Color.white, 0.12f);
cb.pressedColor = Color.Lerp(baseColor, Color.black, 0.15f);
cb.selectedColor = cb.highlightedColor;
}
_button.colors = cb;
}
private static Color VariantBaseColor(UIThemeSO t, UIButtonVariant v) => v switch
{
UIButtonVariant.Primary => t.Primary,
UIButtonVariant.Secondary => t.Secondary,
UIButtonVariant.Accent => t.Accent,
UIButtonVariant.Danger => t.Danger,
UIButtonVariant.Ghost => t.TextPrimary,
_ => t.Primary,
};
private UIThemeSO ResolveTheme()
{
if (_theme != null) return _theme;
var applier = GetComponentInParent<UIThemeApplier>(includeInactive: true);
return applier != null ? applier.Theme : null;
}
// ── 交互反馈 ──────────────────────────────────────────────────────────
public void OnSelect(BaseEventData e) { PlayScale(_selectedScale); RaiseHoverSfx(); }
public void OnDeselect(BaseEventData e) { PlayScale(1f); }
public void OnPointerEnter(PointerEventData e) { PlayScale(_selectedScale); RaiseHoverSfx(); }
public void OnPointerExit(PointerEventData e)
{
// 仍为当前选中项则保持放大
if (EventSystem.current != null && EventSystem.current.currentSelectedGameObject == gameObject) return;
PlayScale(1f);
}
private void PlayScale(float factor)
{
if (!_scaleFeedback || !isActiveAndEnabled) return;
if (_scaleRoutine != null) StopCoroutine(_scaleRoutine);
_scaleRoutine = StartCoroutine(UITween.Scale(transform, _baseScale * factor, _scaleDuration));
}
private void RaiseClickSfx() => _onClickSfx?.Raise();
private void RaiseHoverSfx() => _onHoverSfx?.Raise();
#if UNITY_EDITOR
private void OnValidate()
{
if (Application.isPlaying) return;
_button = GetComponent<Button>();
ApplyTheme();
}
#endif
}
}

View File

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

View File

@@ -0,0 +1,44 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using TMPro;
namespace BaseGames.UI
{
/// <summary>
/// 下拉控件封装TMP_Dropdown + 一行式 <see cref="Bind"/>(填充选项 / 设当前项 / 接回调),
/// 消除设置面板里重复的 ClearOptions/AddOptions/RefreshShownValue 样板。
/// 选项文本可由调用方先经本地化(<c>LocalizationManager.Get</c>)解析后传入。
/// </summary>
[DisallowMultipleComponent]
public class UIDropdown : MonoBehaviour
{
[SerializeField] private TMP_Dropdown _dropdown;
public TMP_Dropdown Dropdown => _dropdown;
public int Value => _dropdown != null ? _dropdown.value : 0;
/// <summary>填充选项、设当前选中项并接变更回调。</summary>
public void Bind(IEnumerable<string> options, int index, UnityAction<int> onChanged)
{
if (_dropdown == null) return;
_dropdown.onValueChanged.RemoveAllListeners();
_dropdown.ClearOptions();
var list = options as List<string> ?? new List<string>(options);
_dropdown.AddOptions(list);
_dropdown.value = Mathf.Clamp(index, 0, Mathf.Max(0, list.Count - 1));
_dropdown.RefreshShownValue();
_dropdown.onValueChanged.AddListener(i => onChanged?.Invoke(i));
}
/// <summary>不触发回调地设置当前选中项(用于外部状态同步)。</summary>
public void SetIndexSilent(int index)
{
if (_dropdown == null) return;
_dropdown.SetValueWithoutNotify(Mathf.Clamp(index, 0, Mathf.Max(0, _dropdown.options.Count - 1)));
_dropdown.RefreshShownValue();
}
}
}

View File

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

View File

@@ -0,0 +1,74 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
namespace BaseGames.UI
{
/// <summary>
/// 通用可选择行:图标 + 标签 + 选中高亮 + 按钮,适用于菜单项、设置行、存档槽、列表项。
///
/// 配合 <see cref="PooledListView{TData,TView}"/> 作 TView或在面板里直接布局使用。
/// 选中(手柄/键盘导航或鼠标悬停)时自动切换高亮节点。
/// </summary>
[DisallowMultipleComponent]
public class UISelectableRow : MonoBehaviour, ISelectHandler, IDeselectHandler, IPointerEnterHandler
{
[Header("引用")]
[SerializeField] private Button _button;
[SerializeField] private TMP_Text _label;
[Tooltip("行图标(可空)。")]
[SerializeField] private Image _icon;
[Tooltip("选中高亮节点(可空,选中时 SetActive(true))。")]
[SerializeField] private GameObject _selectedHighlight;
/// <summary>行被点击(或 Submit时触发。</summary>
public event Action Clicked;
public Button Button => _button;
public Selectable Selectable => _button;
private void Awake()
{
if (_selectedHighlight != null) _selectedHighlight.SetActive(false);
if (_button != null)
_button.onClick.AddListener(() => Clicked?.Invoke());
}
// ── 数据填充 ──────────────────────────────────────────────────────────
public void SetLabel(string text)
{
if (_label != null) _label.text = text;
}
public void SetIcon(Sprite sprite)
{
if (_icon == null) return;
_icon.sprite = sprite;
_icon.enabled = sprite != null;
}
public void SetSelected(bool selected)
{
if (_selectedHighlight != null) _selectedHighlight.SetActive(selected);
}
/// <summary>把 EventSystem 焦点设到本行。</summary>
public void Focus()
{
if (_button != null && EventSystem.current != null)
EventSystem.current.SetSelectedGameObject(_button.gameObject);
}
// ── 选中反馈 ──────────────────────────────────────────────────────────
public void OnSelect(BaseEventData e) => SetSelected(true);
public void OnDeselect(BaseEventData e) => SetSelected(false);
public void OnPointerEnter(PointerEventData e)
{
if (_button != null && _button.interactable && EventSystem.current != null)
EventSystem.current.SetSelectedGameObject(_button.gameObject);
}
}
}

View File

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

View File

@@ -0,0 +1,52 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Events;
using TMPro;
namespace BaseGames.UI
{
/// <summary>
/// 滑条控件封装Slider + 实时数值标签 + 一行式 <see cref="Bind"/>
/// 消除 <see cref="SettingsPanelController"/> 里"移除监听→设值→加监听→更新标签"的重复样板。
/// </summary>
[DisallowMultipleComponent]
public class UISlider : MonoBehaviour
{
[SerializeField] private Slider _slider;
[Tooltip("实时数值标签(可空)。")]
[SerializeField] private TMP_Text _valueLabel;
[Tooltip("数值标签格式(当未传 fmt 委托时使用)。例:\"{0:0}\"、\"{0:0}%\"。")]
[SerializeField] private string _format = "{0:0}";
private Func<float, string> _fmt;
public Slider Slider => _slider;
public float Value => _slider != null ? _slider.value : 0f;
/// <summary>
/// 绑定取值范围、初值与变更回调。<paramref name="fmt"/> 可自定义数值标签文本(优先于 <see cref="_format"/>)。
/// </summary>
public void Bind(float min, float max, float value, UnityAction<float> onChanged, Func<float, string> fmt = null)
{
if (_slider == null) return;
_slider.onValueChanged.RemoveAllListeners();
_slider.minValue = min;
_slider.maxValue = max;
_slider.value = Mathf.Clamp(value, min, max);
_fmt = fmt;
UpdateLabel(_slider.value);
_slider.onValueChanged.AddListener(v =>
{
onChanged?.Invoke(v);
UpdateLabel(v);
});
}
private void UpdateLabel(float v)
{
if (_valueLabel == null) return;
_valueLabel.text = _fmt != null ? _fmt(v) : string.Format(_format, v);
}
}
}

View File

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

View File

@@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using BaseGames.Core.Events;
namespace BaseGames.UI
{
/// <summary>
/// 可复用标签组(从 <see cref="BaseGames.UI.Inventory.InventoryHubPanel"/> 的 Tab 逻辑提取)。
///
/// 职责:管理一组 Tab内容根 + 头部按钮 + 高亮),处理 Next/Prev/Select 切换、
/// 头部高亮、焦点委托(内容若实现 <see cref="IFocusable"/> 则接管,否则聚焦头部按钮),
/// 可选跨重建记忆当前 Tab并通过 C# 事件 + 可选 SO 频道广播索引。
///
/// 解耦:不引用任何具体 Tab 类型,只持有 Tab 根 <see cref="GameObject"/>。
/// 由宿主面板在 OnEnable 中调用 <see cref="Activate"/>,并把肩键频道接到 <see cref="Next"/>/<see cref="Prev"/>。
/// </summary>
public class UITabGroup : MonoBehaviour
{
[Serializable]
public struct Tab
{
[Tooltip("Tab 内容根节点(激活时 SetActive(true))。")]
public GameObject content;
[Tooltip("Tab 头部按钮(点击跳转;高亮显示当前选中)。可空。")]
public Button headerButton;
[Tooltip("Tab 头部高亮节点(选中时 SetActive(true))。可空。")]
public GameObject headerHighlight;
}
[Header("Tabs按显示顺序")]
[SerializeField] private Tab[] _tabs;
[Tooltip("默认打开的 Tab 索引。")]
[SerializeField] private int _defaultIndex = 0;
[Tooltip("是否记住上次停留的 Tab跨面板重开。")]
[SerializeField] private bool _rememberLast = true;
[Tooltip("跨重建记忆用的键;为空则只在本实例存活期间记忆。")]
[SerializeField] private string _persistKey = "";
[Header("Event Channels可选")]
[Tooltip("当前 Tab 变化时广播索引。可空。")]
[SerializeField] private IntEventChannelSO _onTabChanged;
/// <summary>当前 Tab 变化时触发(参数为新索引)。</summary>
public event Action<int> TabChanged;
public int CurrentIndex { get; private set; } = -1;
public int TabCount => _tabs?.Length ?? 0;
// 跨重建记忆(静态:面板/组件被销毁重建后仍保留)
private static readonly Dictionary<string, int> s_persist = new();
private bool _wired;
private void Awake() => EnsureWired();
private void EnsureWired()
{
if (_wired || _tabs == null) return;
for (int i = 0; i < _tabs.Length; i++)
{
int captured = i;
if (_tabs[i].headerButton != null)
{
_tabs[i].headerButton.onClick.RemoveAllListeners();
_tabs[i].headerButton.onClick.AddListener(() => Select(captured));
}
if (_tabs[i].content != null) _tabs[i].content.SetActive(false);
if (_tabs[i].headerHighlight != null) _tabs[i].headerHighlight.SetActive(false);
}
_wired = true;
}
/// <summary>激活标签组并打开起始 Tab默认或记忆。宿主面板在 OnEnable 中调用。</summary>
public void Activate()
{
EnsureWired();
if (TabCount == 0) return;
int start = _rememberLast && TryGetPersisted(out var p) ? p : _defaultIndex;
CurrentIndex = -1; // 强制 Select 执行切换
Select(Mathf.Clamp(start, 0, TabCount - 1));
}
public void Next() => Step(+1);
public void Prev() => Step(-1);
private void Step(int dir)
{
if (TabCount == 0) return;
int next = CurrentIndex;
for (int i = 0; i < _tabs.Length; i++) // 跳过未配置 content 的空槽
{
next = (next + dir + _tabs.Length) % _tabs.Length;
if (_tabs[next].content != null) break;
}
Select(next);
}
/// <summary>切换到指定 Tab无效或与当前相同则忽略。</summary>
public void Select(int index)
{
EnsureWired();
if (TabCount == 0) return;
index = Mathf.Clamp(index, 0, _tabs.Length - 1);
if (index == CurrentIndex) return;
// 关闭旧 Tab
if (CurrentIndex >= 0 && CurrentIndex < _tabs.Length)
{
if (_tabs[CurrentIndex].content != null) _tabs[CurrentIndex].content.SetActive(false);
if (_tabs[CurrentIndex].headerHighlight != null) _tabs[CurrentIndex].headerHighlight.SetActive(false);
}
CurrentIndex = index;
SetPersisted(index);
// 打开新 Tab
var tab = _tabs[index];
if (tab.content != null) tab.content.SetActive(true);
if (tab.headerHighlight != null) tab.headerHighlight.SetActive(true);
FocusCurrent();
_onTabChanged?.Raise(index);
TabChanged?.Invoke(index);
}
/// <summary>把焦点交给当前 Tab内容 IFocusable 优先,否则其头部按钮)。供宿主 OnFocusRestored 调用。</summary>
public void FocusCurrent()
{
if (CurrentIndex < 0 || CurrentIndex >= TabCount) return;
var tab = _tabs[CurrentIndex];
var focusable = tab.content != null ? tab.content.GetComponent<IFocusable>() : null;
if (focusable != null) focusable.OnFocusRestored();
else if (tab.headerButton != null && EventSystem.current != null)
EventSystem.current.SetSelectedGameObject(tab.headerButton.gameObject);
}
// ── 记忆 ──────────────────────────────────────────────────────────────
private bool TryGetPersisted(out int index)
{
if (!string.IsNullOrEmpty(_persistKey) && s_persist.TryGetValue(_persistKey, out index)) return true;
if (CurrentIndex >= 0) { index = CurrentIndex; return true; }
index = -1;
return false;
}
private void SetPersisted(int index)
{
if (!string.IsNullOrEmpty(_persistKey)) s_persist[_persistKey] = index;
}
}
}

View File

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

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);
// ── 存档槽确认 ───────────────────────────────────────────────────────

View File

@@ -1,7 +1,5 @@
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using BaseGames.Core;
using BaseGames.Core.Assets;
using BaseGames.Core.Events;
@@ -10,9 +8,9 @@ namespace BaseGames.UI
/// <summary>
/// 暂停菜单控制器(架构 10_UIModule §5
/// 挂载在 Canvas_Menu → PauseMenuPanel GameObject 上。
/// 按钮绑定在 Awake 中完成;UIManager 负责面板开关
/// 按钮绑定在 Awake 中完成;生命周期 / 焦点由 <see cref="UIPanelBase"/> 统一处理
/// </summary>
public class PauseMenuController : MonoBehaviour, IFocusable
public class PauseMenuController : UIPanelBase
{
// UIManager 通过 ServiceLocator 解析,开启时自动获取,无需 Inspector 直接绑定具体类型
private IUIManager _uiManager;
@@ -35,18 +33,13 @@ namespace BaseGames.UI
_btnQuit?.onClick.AddListener(Application.Quit);
}
private void OnEnable()
{
// 暂停面板由 UIManager 开启,此时 ServiceLocator 已就绪
_uiManager = ServiceLocator.GetOrDefault<IUIManager>();
// 手柄导航:打开时将焦点置于第一个按钮
EventSystem.current?.SetSelectedGameObject(_btnResume?.gameObject);
}
// 暂停面板由 UIManager 开启,此时 ServiceLocator 已就绪
protected override void OnPanelOpen() => _uiManager = GetService<IUIManager>();
protected override void OnPanelClose() => _uiManager = null;
private void OnDisable()
{
_uiManager = null;
}
/// <summary>默认焦点 / 焦点恢复回到"继续"按钮(基类 FocusFirst 调用)。</summary>
protected override GameObject ResolveFirstSelected()
=> _btnResume != null ? _btnResume.gameObject : null;
// ── 按钮回调 ──────────────────────────────────────────────────────────
@@ -71,11 +64,5 @@ namespace BaseGames.UI
ShowLoadingScreen = false,
});
}
// ── IFocusable ────────────────────────────────────────────────────────
/// <summary>面板恢复为栈顶时(关闭子面板后)自动移回第一个按鈕。</summary>
public void OnFocusRestored()
=> EventSystem.current?.SetSelectedGameObject(_btnResume?.gameObject);
}
}

View File

@@ -83,6 +83,9 @@ namespace BaseGames.UI.Menus
if (t.IsFaulted && !(t.Exception?.InnerException is OperationCanceledException))
Debug.LogException(t.Exception?.InnerException ?? t.Exception, this);
}, TaskScheduler.FromCurrentSynchronizationContext());
// 面板打开时设置初始焦点(键盘 / 手柄导航入口)
StartCoroutine(RestoreFocusNextFrame());
}
private void OnDisable()

View File

@@ -1,16 +1,17 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using BaseGames.Core;
using BaseGames.Localization;
namespace BaseGames.UI
{
/// <summary>
/// 设置面板控制器(架构 10_UIModule §7
/// 驱动 SettingsManager 的音量与画面设置,并从当前配置初始化控件值。
/// 生命周期 / 焦点由 <see cref="UIPanelBase"/> 统一处理。
/// </summary>
public class SettingsPanelController : MonoBehaviour, IFocusable
public class SettingsPanelController : UIPanelBase
{
// ISettingsService 通过 ServiceLocator 获取,无需 Inspector 直接注入具体类,
// 支持测试场景替换 Mock 实现。
@@ -32,12 +33,19 @@ namespace BaseGames.UI
[SerializeField] private TMP_Dropdown _colorblindDropdown; // None / Prot / Deut / Trit
[SerializeField] private Toggle _screenShakeToggle;
[Header("语言")]
[SerializeField] private TMP_Dropdown _languageDropdown; // 中文 / English / 日本語 / 한국어
[Header("按键重绑定")]
[SerializeField] private GameObject _rebindPanelRoot; // RebindPanel GameObject
private static readonly int[] FpsOptions = { 30, 60, 120, -1 };
private void OnEnable()
// 语言下拉项顺序(与脚手架填充的显示项一一对应)
private static readonly Language[] LanguageOptions =
{ Language.ChineseSimplified, Language.English, Language.Japanese, Language.Korean };
protected override void OnPanelOpen()
{
_settings = ServiceLocator.GetOrDefault<ISettingsService>();
if (_settings == null) return;
@@ -95,8 +103,18 @@ namespace BaseGames.UI
_screenShakeToggle.onValueChanged.AddListener(v => _settings.SetScreenShakeEnabled(v));
}
// 手柄导航:打开设置面板时将焦点置于主音量滑条
EventSystem.current?.SetSelectedGameObject(_masterVolume?.gameObject);
// ── 语言 ──────────────────────────────────────────────────────────
if (_languageDropdown != null)
{
_languageDropdown.onValueChanged.RemoveAllListeners();
var loc = ServiceLocator.GetOrDefault<ILocalizationService>();
int idx = loc != null ? System.Array.IndexOf(LanguageOptions, loc.CurrentLanguage) : 0;
_languageDropdown.value = idx >= 0 ? idx : 0;
_languageDropdown.RefreshShownValue();
_languageDropdown.onValueChanged.AddListener(i =>
ServiceLocator.GetOrDefault<ILocalizationService>()?
.SetLanguage(LanguageOptions[Mathf.Clamp(i, 0, LanguageOptions.Length - 1)]));
}
}
private void UpdateUIScaleLabel(float v)
@@ -115,10 +133,10 @@ namespace BaseGames.UI
slider.onValueChanged.AddListener(onChange);
}
// ── IFocusable ────────────────────────────────────────────────────────
// ── 焦点 ──────────────────────────────────────────────────────────────
/// <summary>面板恢复为栈顶时将焦点移回主音量滑条。</summary>
public void OnFocusRestored()
=> EventSystem.current?.SetSelectedGameObject(_masterVolume?.gameObject);
/// <summary>默认焦点 / 焦点恢复回到主音量滑条(基类 FocusFirst 调用)。</summary>
protected override GameObject ResolveFirstSelected()
=> _masterVolume != null ? _masterVolume.gameObject : null;
}
}

View File

@@ -0,0 +1,214 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using BaseGames.Core;
using BaseGames.Localization;
namespace BaseGames.UI.Settings
{
/// <summary>
/// 数据驱动设置面板:据 <see cref="SettingsSchemaSO"/> 生成控件行并绑定 <see cref="ISettingsService"/>。
///
/// 取代硬编码的 <see cref="BaseGames.UI.SettingsPanelController"/>:策划改表即可增删 / 重排 / 改标签 / 分节,
/// 无需改代码。每行据 <see cref="SettingKey"/> 自动选用 Slider / Toggle / Dropdown 行预制件,
/// 并复用通用控件 <see cref="UISlider"/> / <see cref="UIDropdown"/>。
///
/// 控件来源:行预制件由 <c>SettingsPanelScaffold</c> 生成(标签为 <see cref="LocalizedText"/>)。
/// </summary>
public class DataDrivenSettingsPanel : UIPanelBase
{
public enum ControlKind { Slider, Toggle, Dropdown }
[Header("数据表")]
[SerializeField] private SettingsSchemaSO _schema;
[Header("行布局")]
[Tooltip("生成的行的父节点(通常挂 VerticalLayoutGroup。")]
[SerializeField] private Transform _container;
[SerializeField] private GameObject _headerPrefab;
[SerializeField] private GameObject _sliderRowPrefab;
[SerializeField] private GameObject _toggleRowPrefab;
[SerializeField] private GameObject _dropdownRowPrefab;
// FPS 下拉的取值集合(与 SettingsPanelController 一致)
private static readonly int[] FpsOptions = { 30, 60, 120, -1 };
private static readonly Language[] LanguageOptions =
{ Language.ChineseSimplified, Language.English, Language.Japanese, Language.Korean };
private static readonly string[] LanguageNativeNames =
{ "简体中文", "English", "日本語", "한국어" };
private ISettingsService _settings;
private ILocalizationService _loc;
private readonly List<GameObject> _spawned = new();
// ── 生命周期 ──────────────────────────────────────────────────────────
protected override void OnPanelOpen()
{
_settings = GetService<ISettingsService>();
_loc = GetService<ILocalizationService>();
Build();
}
protected override void OnPanelClose() => Clear();
// ── 构建 ──────────────────────────────────────────────────────────────
/// <summary>据 schema 重建所有行。可在无 ISettingsService 时调用(仅生成行 + 标签,不绑定)。</summary>
public void Build()
{
Clear();
if (_schema == null || _container == null) return;
foreach (var item in _schema.Items)
{
GameObject prefab = item.isHeader ? _headerPrefab : PrefabFor(KindOf(item.key));
if (prefab == null) continue;
var go = Instantiate(prefab, _container);
_spawned.Add(go);
SetLabel(go, item.labelKey);
if (!item.isHeader && _settings != null)
BindControl(go, item.key);
}
}
private void Clear()
{
foreach (var go in _spawned) if (go != null) Destroy(go);
_spawned.Clear();
}
private GameObject PrefabFor(ControlKind kind) => kind switch
{
ControlKind.Slider => _sliderRowPrefab,
ControlKind.Toggle => _toggleRowPrefab,
ControlKind.Dropdown => _dropdownRowPrefab,
_ => null,
};
private static void SetLabel(GameObject row, string labelKey)
{
var loc = row.GetComponentInChildren<LocalizedText>(true);
if (loc != null) loc.SetKey(labelKey);
}
// ── 控件类型映射 ──────────────────────────────────────────────────────
public static ControlKind KindOf(SettingKey key) => key switch
{
SettingKey.MasterVolume or SettingKey.BGMVolume or SettingKey.SFXVolume
or SettingKey.AmbientVolume or SettingKey.UIScale => ControlKind.Slider,
SettingKey.VSync or SettingKey.ScreenShake => ControlKind.Toggle,
SettingKey.TargetFPS or SettingKey.ColorblindMode or SettingKey.Language => ControlKind.Dropdown,
_ => ControlKind.Slider,
};
// ── 绑定派发 ──────────────────────────────────────────────────────────
private void BindControl(GameObject row, SettingKey key)
{
switch (KindOf(key))
{
case ControlKind.Slider: BindSlider(row, key); break;
case ControlKind.Toggle: BindToggle(row, key); break;
case ControlKind.Dropdown: BindDropdown(row, key); break;
}
}
private void BindSlider(GameObject row, SettingKey key)
{
var ui = row.GetComponentInChildren<UISlider>(true);
if (ui == null) return;
var data = _settings.Current;
switch (key)
{
case SettingKey.MasterVolume:
ui.Bind(0f, 1f, data.MasterVolume, v => _settings.SetMasterVolume(v), Percent); break;
case SettingKey.BGMVolume:
ui.Bind(0f, 1f, data.BGMVolume, v => _settings.SetBGMVolume(v), Percent); break;
case SettingKey.SFXVolume:
ui.Bind(0f, 1f, data.SFXVolume, v => _settings.SetSFXVolume(v), Percent); break;
case SettingKey.AmbientVolume:
ui.Bind(0f, 1f, data.AmbientVolume, v => _settings.SetAmbientVolume(v), Percent); break;
case SettingKey.UIScale:
ui.Bind(0.8f, 1.5f, data.UIScale, v => _settings.SetUIScale(v), Percent); break;
}
}
private void BindToggle(GameObject row, SettingKey key)
{
var toggle = row.GetComponentInChildren<Toggle>(true);
if (toggle == null) return;
var data = _settings.Current;
toggle.onValueChanged.RemoveAllListeners();
switch (key)
{
case SettingKey.VSync:
toggle.isOn = data.VSync;
toggle.onValueChanged.AddListener(v => _settings.SetVSync(v));
break;
case SettingKey.ScreenShake:
toggle.isOn = data.ScreenShakeEnabled;
toggle.onValueChanged.AddListener(v => _settings.SetScreenShakeEnabled(v));
break;
}
}
private void BindDropdown(GameObject row, SettingKey key)
{
var ui = row.GetComponentInChildren<UIDropdown>(true);
if (ui == null) return;
var data = _settings.Current;
switch (key)
{
case SettingKey.TargetFPS:
{
var opts = new List<string> { "30", "60", "120", Loc("SET_FPS_UNLIMITED", "无限") };
int idx = Array.IndexOf(FpsOptions, data.TargetFPS); if (idx < 0) idx = 1;
ui.Bind(opts, idx, i => _settings.SetTargetFrameRate(FpsOptions[Mathf.Clamp(i, 0, FpsOptions.Length - 1)]));
break;
}
case SettingKey.ColorblindMode:
{
int count = Enum.GetValues(typeof(ColorblindMode)).Length;
var opts = new List<string>(count);
for (int i = 0; i < count; i++)
opts.Add(Loc($"SET_COLORBLIND_{i}", ((ColorblindMode)i).ToString()));
ui.Bind(opts, (int)data.ColorblindMode,
i => _settings.SetColorblindMode((ColorblindMode)Mathf.Clamp(i, 0, count - 1)));
break;
}
case SettingKey.Language:
{
var opts = new List<string>(LanguageNativeNames);
int idx = _loc != null ? Array.IndexOf(LanguageOptions, _loc.CurrentLanguage) : 0;
if (idx < 0) idx = 0;
ui.Bind(opts, idx, i =>
GetService<ILocalizationService>()?.SetLanguage(LanguageOptions[Mathf.Clamp(i, 0, LanguageOptions.Length - 1)]));
break;
}
}
}
// ── 工具 ──────────────────────────────────────────────────────────────
private static string Percent(float v) => Mathf.RoundToInt(v * 100f) + "%";
private string Loc(string key, string fallback)
{
if (_loc != null && _loc.TryGet(key, out var v, LocalizationTable.UI)) return v;
return fallback;
}
protected override GameObject ResolveFirstSelected()
{
foreach (var go in _spawned)
{
if (go == null) continue;
var sel = go.GetComponentInChildren<Selectable>(true);
if (sel != null) return sel.gameObject;
}
return null;
}
}
}

View File

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

View File

@@ -0,0 +1,24 @@
namespace BaseGames.UI.Settings
{
/// <summary>
/// 可在设置面板中数据驱动绑定的设置项标识。
/// 与 <see cref="BaseGames.Core.ISettingsService"/> 的 typed get/set 一一对应;
/// 控件类型与绑定逻辑由 <see cref="DataDrivenSettingsPanel"/> 的派发器据此决定。
///
/// 新增"全新"设置项需:在此加枚举 + ISettingsService 加方法 + 派发器加分支(代码);
/// 但增删 / 重排 / 改标签 / 分节既有设置项是纯数据(改 <see cref="SettingsSchemaSO"/>)。
/// </summary>
public enum SettingKey
{
MasterVolume,
BGMVolume,
SFXVolume,
AmbientVolume,
VSync,
TargetFPS,
UIScale,
ColorblindMode,
ScreenShake,
Language,
}
}

View File

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

View File

@@ -0,0 +1,31 @@
using System;
using UnityEngine;
namespace BaseGames.UI.Settings
{
/// <summary>
/// 设置面板的数据驱动表(策划编辑)。
/// 按顺序列出设置项与分节标题;<see cref="DataDrivenSettingsPanel"/> 据此生成对应控件行。
/// 策划可在 Inspector 增删、重排、改标签、加分节,无需改代码或重编译。
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/UI/Settings Schema", fileName = "UI_SettingsSchema")]
public class SettingsSchemaSO : ScriptableObject
{
[Serializable]
public struct Item
{
[Tooltip("勾选则为分节标题行(仅显示 labelKey不绑定控件。")]
public bool isHeader;
[Tooltip("标签本地化 KeyUI 表)。")]
public string labelKey;
[Tooltip("绑定的设置项isHeader 为 true 时忽略)。")]
public SettingKey key;
}
[SerializeField] private Item[] _items;
public Item[] Items => _items;
}
}

View File

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

View File

@@ -23,6 +23,9 @@ namespace BaseGames.UI.Theme
[Tooltip("启用时自动应用一次。运行时切换主题可手动调用 Apply()。")]
[SerializeField] private bool _applyOnEnable = true;
/// <summary>当前主题(供子控件如 <see cref="UIButton"/> 就近读取)。</summary>
public UIThemeSO Theme => _theme;
private void OnEnable()
{
if (_applyOnEnable) Apply();

View File

@@ -0,0 +1,54 @@
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem.UI;
namespace BaseGames.UI
{
/// <summary>
/// 多设备 UI 焦点守护:挂在持有 EventSystem 的对象上。
///
/// 解决"鼠标点击空白处 → 当前选中被清空 → 切换到键盘/手柄时无选中项可导航"的问题:
/// 记录最近一个有效选中项;当选中丢失且玩家按下导航/确认键(键盘方向键 / 手柄摇杆·十字键 / Submit
/// 自动把焦点恢复到上一个仍可交互的选中项。仅在"导航意图"出现时恢复,不与鼠标悬停/点击冲突。
///
/// 设备无关:依赖 <see cref="InputSystemUIInputModule"/> 的 move/submit Action
/// 因此键盘、Xbox、Switch、PlayStation 手柄(均归一为 Gamepad 布局)统一生效。
/// </summary>
[DefaultExecutionOrder(100)]
public class UISelectionRestorer : MonoBehaviour
{
private GameObject _lastSelected;
private void Update()
{
var es = EventSystem.current;
if (es == null) return;
var current = es.currentSelectedGameObject;
if (current != null && current.activeInHierarchy)
{
_lastSelected = current; // 记录最近的有效选中
return;
}
// 选中已丢失:仅当出现导航/确认意图时才恢复,避免抢占鼠标操作
if (!NavigationIntentThisFrame(es)) return;
if (_lastSelected == null || !_lastSelected.activeInHierarchy) return;
var sel = _lastSelected.GetComponent<Selectable>();
if (sel == null || !sel.IsInteractable()) return;
es.SetSelectedGameObject(_lastSelected);
}
private static bool NavigationIntentThisFrame(EventSystem es)
{
if (es.currentInputModule is not InputSystemUIInputModule m) return false;
if (m.move != null && m.move.action != null && m.move.action.WasPerformedThisFrame()) return true;
if (m.submit != null && m.submit.action != null && m.submit.action.WasPerformedThisFrame()) return true;
return false;
}
}
}

View File

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