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,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: