UI系统组件
This commit is contained in:
8
Assets/_Game/Scripts/UI/Base.meta
Normal file
8
Assets/_Game/Scripts/UI/Base.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e8cff642fe44f954694ab825135c02f5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
91
Assets/_Game/Scripts/UI/Base/UIPanelBase.cs
Normal file
91
Assets/_Game/Scripts/UI/Base/UIPanelBase.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Base/UIPanelBase.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Base/UIPanelBase.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b0b3d5f5b7a324d47930ee0ccd5c6ae3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
11
Assets/_Game/Scripts/UI/Base/UISimplePanel.cs
Normal file
11
Assets/_Game/Scripts/UI/Base/UISimplePanel.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 通用面板:<see cref="UIPanelBase"/> 的最简具体实现,无额外逻辑。
|
||||
/// 用作脚手架生成的 themed 面板根(带 CanvasGroup 淡入 + 默认焦点 + 订阅清理),
|
||||
/// 适合不需要自定义控制器的简单弹窗 / 容器。需要业务逻辑时改挂自定义 <see cref="UIPanelBase"/> 子类。
|
||||
/// </summary>
|
||||
public sealed class UISimplePanel : UIPanelBase
|
||||
{
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Base/UISimplePanel.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Base/UISimplePanel.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c6fa4cf81d2fd2e46b0d922f7b55097c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Scripts/UI/Controls.meta
Normal file
8
Assets/_Game/Scripts/UI/Controls.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 986c5f0b71c98d74c9082fb0c5236b1c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
96
Assets/_Game/Scripts/UI/Controls/PooledListView.cs
Normal file
96
Assets/_Game/Scripts/UI/Controls/PooledListView.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Controls/PooledListView.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Controls/PooledListView.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9c1f8c866c54a9440ba21d3a146de17d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
142
Assets/_Game/Scripts/UI/Controls/UIButton.cs
Normal file
142
Assets/_Game/Scripts/UI/Controls/UIButton.cs
Normal 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
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Controls/UIButton.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Controls/UIButton.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 270b0a121b06dfc4587c060335f18432
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
44
Assets/_Game/Scripts/UI/Controls/UIDropdown.cs
Normal file
44
Assets/_Game/Scripts/UI/Controls/UIDropdown.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Controls/UIDropdown.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Controls/UIDropdown.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d6a9d49903ac46e4194b22cc1632a790
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
74
Assets/_Game/Scripts/UI/Controls/UISelectableRow.cs
Normal file
74
Assets/_Game/Scripts/UI/Controls/UISelectableRow.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Controls/UISelectableRow.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Controls/UISelectableRow.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 64037ec2ca4c93d40908480eb137ccf3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
52
Assets/_Game/Scripts/UI/Controls/UISlider.cs
Normal file
52
Assets/_Game/Scripts/UI/Controls/UISlider.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Controls/UISlider.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Controls/UISlider.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a1743e750e4f79c43b64e07a1c1a7270
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
157
Assets/_Game/Scripts/UI/Controls/UITabGroup.cs
Normal file
157
Assets/_Game/Scripts/UI/Controls/UITabGroup.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Controls/UITabGroup.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Controls/UITabGroup.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8cc0a2d3090a75f4c82697a82dd9ec85
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
259
Assets/_Game/Scripts/UI/MainMenu/DataDrivenMainMenuController.cs
Normal file
259
Assets/_Game/Scripts/UI/MainMenu/DataDrivenMainMenuController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a38dc36f833bef3438073a0740ece716
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
44
Assets/_Game/Scripts/UI/MainMenu/MainMenuButtonView.cs
Normal file
44
Assets/_Game/Scripts/UI/MainMenu/MainMenuButtonView.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/MainMenu/MainMenuButtonView.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/MainMenu/MainMenuButtonView.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aa83d8ead83f78b428cdd2b0d1a89aa7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
56
Assets/_Game/Scripts/UI/MainMenu/MainMenuConfigSO.cs
Normal file
56
Assets/_Game/Scripts/UI/MainMenu/MainMenuConfigSO.cs
Normal 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("按钮标签本地化 Key(UI 表)。")]
|
||||
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;
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/MainMenu/MainMenuConfigSO.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/MainMenu/MainMenuConfigSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d78ee6af5c2b0344ea105452f496e85c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
|
||||
// ── 存档槽确认 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
214
Assets/_Game/Scripts/UI/Settings/DataDrivenSettingsPanel.cs
Normal file
214
Assets/_Game/Scripts/UI/Settings/DataDrivenSettingsPanel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a7cf26a2f7cc5bb49b573bbe9f5614b5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
24
Assets/_Game/Scripts/UI/Settings/SettingKey.cs
Normal file
24
Assets/_Game/Scripts/UI/Settings/SettingKey.cs
Normal 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,
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Settings/SettingKey.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Settings/SettingKey.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5daec1dcdf7f7884ca90b234d3ac0f75
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
31
Assets/_Game/Scripts/UI/Settings/SettingsSchemaSO.cs
Normal file
31
Assets/_Game/Scripts/UI/Settings/SettingsSchemaSO.cs
Normal 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("标签本地化 Key(UI 表)。")]
|
||||
public string labelKey;
|
||||
|
||||
[Tooltip("绑定的设置项(isHeader 为 true 时忽略)。")]
|
||||
public SettingKey key;
|
||||
}
|
||||
|
||||
[SerializeField] private Item[] _items;
|
||||
|
||||
public Item[] Items => _items;
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Settings/SettingsSchemaSO.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Settings/SettingsSchemaSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0edaa79306d53d74da12f1f0cb03ea48
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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();
|
||||
|
||||
54
Assets/_Game/Scripts/UI/UISelectionRestorer.cs
Normal file
54
Assets/_Game/Scripts/UI/UISelectionRestorer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/UISelectionRestorer.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/UISelectionRestorer.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a95115526c2b68e469f425df962742e4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user