UI系统优化

This commit is contained in:
2026-05-25 11:54:37 +08:00
parent c7057db27d
commit 3c812cfb41
130 changed files with 4738 additions and 477 deletions

View File

@@ -16,7 +16,11 @@
"BaseGames.Localization",
"Unity.TextMeshPro",
"Unity.InputSystem",
"BaseGames.Equipment"
"BaseGames.Equipment",
"BaseGames.Combat.StatusEffects",
"BaseGames.Spells",
"BaseGames.Quest",
"BaseGames.Skills"
],
"autoReferenced": true,
"overrideReferences": false,

View File

@@ -0,0 +1,233 @@
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using UnityEngine.EventSystems;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Equipment;
using BaseGames.Localization;
namespace BaseGames.UI
{
/// <summary>
/// 护符装备面板。
///
/// 布局:
/// 顶部 — 标题 + 凹槽容量进度条(已用/总数)
/// 左侧 — 已装备护符区域(动态格子列表)
/// 右侧 — 已收集护符目录(可滚动,点击即装备/卸下)
///
/// 数据来源ServiceLocator 获取 EquipmentManager读取 Collected / Equipped 列表。
/// 反应式更新:订阅 _onEquipmentChanged VoidEventChannelSO变更时重建两侧列表。
///
/// Inspector 必填:
/// _notchText — "X / Y 格子" 文本
/// _equippedContainer — 已装备列表父节点
/// _catalogContainer — 收藏目录列表父节点
/// _charmCardTemplate — 护符卡片预制kept inactive
/// _btnClose — 关闭按钮
/// _onEquipmentChanged — EquipmentManager 广播的装备变更事件频道
/// </summary>
public class CharmEquipPanel : MonoBehaviour, IFocusable
{
// ── Inspector 字段 ────────────────────────────────────────────────────
[Header("UI 根节点")]
[SerializeField] private TMP_Text _notchText;
[SerializeField] private Image _notchBarFill;
[SerializeField] private Transform _equippedContainer;
[SerializeField] private Transform _catalogContainer;
[SerializeField] private GameObject _charmCardTemplate; // kept inactive
[SerializeField] private Button _btnClose;
[Header("Event Channels")]
[SerializeField] private VoidEventChannelSO _onEquipmentChanged;
// ── 私有状态 ──────────────────────────────────────────────────────────
private IEquipmentService _manager;
private readonly List<GameObject> _equippedCards = new();
private readonly List<GameObject> _catalogCards = new();
private readonly Queue<GameObject> _cardPool = new(); // O(1) 取用归还
private readonly CompositeDisposable _subs = new();
// ── 生命周期 ──────────────────────────────────────────────────────────
private void OnEnable()
{
_manager = ServiceLocator.GetOrDefault<IEquipmentService>();
if (_btnClose != null)
{
_btnClose.onClick.RemoveAllListeners();
_btnClose.onClick.AddListener(OnCloseBtnClicked);
}
_onEquipmentChanged?.Subscribe(Rebuild).AddTo(_subs);
Rebuild();
// 手柄导航:面板打开时将焦点置于关闭按钮
EventSystem.current?.SetSelectedGameObject(_btnClose?.gameObject);
}
private void OnDisable()
{
_subs.Clear();
RecycleCards(_equippedCards);
RecycleCards(_catalogCards);
}
// ── 重建 UI ───────────────────────────────────────────────────────────
private void Rebuild()
{
if (_manager == null) return;
RefreshNotchBar();
RebuildEquippedList();
RebuildCatalogList();
}
private void RefreshNotchBar()
{
int used = _manager.UsedNotches;
int total = _manager.TotalNotches;
if (_notchText != null)
_notchText.text = $"{used} / {total}";
if (_notchBarFill != null)
_notchBarFill.fillAmount = total > 0 ? (float)used / total : 0f;
}
private void RebuildEquippedList()
{
RecycleCards(_equippedCards);
if (_equippedContainer == null) return;
foreach (var charm in _manager.Equipped)
{
var card = SpawnCard(_equippedContainer, charm, isEquipped: true);
if (card != null) _equippedCards.Add(card);
}
}
private void RebuildCatalogList()
{
RecycleCards(_catalogCards);
if (_catalogContainer == null) return;
foreach (var charm in _manager.Collected)
{
bool isEquipped = _manager.Equipped.Contains(charm);
var card = SpawnCard(_catalogContainer, charm, isEquipped);
if (card != null) _catalogCards.Add(card);
}
}
// ── 卡片生成 ──────────────────────────────────────────────────────────
private GameObject SpawnCard(Transform parent, CharmSO charm, bool isEquipped)
{
if (_charmCardTemplate == null || parent == null) return null;
if (!TryDequeueCard(out GameObject go))
go = Instantiate(_charmCardTemplate);
go.transform.SetParent(parent, worldPositionStays: false);
go.SetActive(true);
// Icon第一个 Image
var images = go.GetComponentsInChildren<Image>(includeInactive: true);
if (images.Length > 0 && charm.icon != null)
images[0].sprite = charm.icon;
// 文本约定TMP_Text[0] = 名称, TMP_Text[1] = 凹槽消耗, TMP_Text[2] = 描述)
var texts = go.GetComponentsInChildren<TMP_Text>(includeInactive: true);
if (texts.Length > 0)
{
string name = LocalizationManager.Get(charm.displayNameKey, LocalizationTable.Items);
texts[0].text = string.IsNullOrEmpty(name) || name == charm.displayNameKey
? charm.charmId : name;
}
if (texts.Length > 1)
texts[1].text = charm.notchCost.ToString();
if (texts.Length > 2)
{
string desc = LocalizationManager.Get(charm.descriptionKey, LocalizationTable.Items);
texts[2].text = string.IsNullOrEmpty(desc) || desc == charm.descriptionKey
? string.Empty : desc;
}
// 按钮(约定:第一个 Button = 装备/卸下切换)
var btn = go.GetComponentInChildren<Button>(includeInactive: true);
if (btn != null)
{
btn.onClick.RemoveAllListeners();
CharmSO captured = charm;
bool equip = !isEquipped;
if (equip)
{
btn.onClick.AddListener(() =>
{
string err = _manager?.TryEquipCharm(captured);
if (err != null)
Debug.LogWarning($"[CharmEquipPanel] 装备失败: {err}");
});
}
else
{
btn.onClick.AddListener(() => _manager?.UnequipCharm(captured));
}
// 已装备时使用不同视觉(可选:标记文本)
if (texts.Length > 3)
texts[3].text = isEquipped ? "✓" : string.Empty;
}
return go;
}
// ── 回收 ─────────────────────────────────────────────────────────────
/// <summary>取队列头第一个非空实例;队列为空时返回 false。</summary>
private bool TryDequeueCard(out GameObject go)
{
while (_cardPool.Count > 0)
{
go = _cardPool.Dequeue();
if (go != null) return true;
}
go = null;
return false;
}
private void RecycleCards(List<GameObject> cards)
{
foreach (var card in cards)
{
if (card == null) continue;
card.SetActive(false);
_cardPool.Enqueue(card);
}
cards.Clear();
}
private void OnCloseBtnClicked()
{
var uiMgr = ServiceLocator.GetOrDefault<IUIManager>();
if (uiMgr != null) uiMgr.CloseTopPanel();
else gameObject.SetActive(false);
}
// ── IFocusable ────────────────────────────────────────────────────────
/// <summary>面板恢复为栈顶时将焦点移回关闭按鈕。</summary>
public void OnFocusRestored()
=> EventSystem.current?.SetSelectedGameObject(_btnClose?.gameObject);
}
}

View File

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

View File

@@ -92,9 +92,9 @@ namespace BaseGames.UI
SetAnchoredPosition(startWorld + new Vector2(0, _floatDistance * t));
// alpha 淡出(后半段开始)
_text.color = new Color(color.r, color.g, color.b,
Mathf.Lerp(startAlpha, 0f, Mathf.Clamp01((t - 0.5f) / 0.5f)));
// alpha 淡出(后半段开始)—— 修改 struct 的 a 分量并回写,避免每帧 new Color 堆分配
color.a = Mathf.Lerp(startAlpha, 0f, Mathf.Clamp01((t - 0.5f) / 0.5f));
_text.color = color;
elapsed += Time.deltaTime;
yield return null;

View File

@@ -1,8 +1,10 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Core.Events;
using BaseGames.Localization;
namespace BaseGames.UI
{
@@ -32,6 +34,7 @@ namespace BaseGames.UI
private Vector2 _shownPos;
private Vector2 _hiddenPos;
private Coroutine _slideCoroutine;
private readonly List<float> _pendingThresholds = new();
private readonly CompositeDisposable _subs = new();
private void Awake()
@@ -49,6 +52,7 @@ namespace BaseGames.UI
_onBossHPChanged?.Subscribe(OnHPChanged).AddTo(_subs);
_onBossNameSet?.Subscribe(OnNameSet).AddTo(_subs);
_onBossHPMaxSet?.Subscribe(OnMaxSet).AddTo(_subs);
_onBossPhaseThreshold?.Subscribe(OnPhaseThreshold).AddTo(_subs);
}
private void OnDisable() => _subs.Clear();
@@ -64,6 +68,7 @@ namespace BaseGames.UI
}
else
{
_pendingThresholds.Clear();
_slideCoroutine = StartCoroutine(SlideOut());
}
}
@@ -75,19 +80,52 @@ namespace BaseGames.UI
private void OnNameSet(string bossName)
{
if (_bossNameText != null) _bossNameText.text = bossName;
if (_bossNameText == null) return;
string loc = !string.IsNullOrEmpty(bossName)
? LocalizationManager.Get(bossName, LocalizationTable.Character)
: null;
_bossNameText.text = !string.IsNullOrEmpty(loc) && loc != bossName ? loc : bossName;
}
private void OnMaxSet(int max)
{
_maxHP = max;
// 重建阶段标记(每次 BossHPMax 改变时清空并按已存阈值重建,此处简化为清空)
// 清除旧标记
if (_phaseMarkersRoot != null)
{
// 逆序删除:避免正序枚举 Transform 子节点同时销毁时的迭代器失效
for (int i = _phaseMarkersRoot.childCount - 1; i >= 0; i--)
Destroy(_phaseMarkersRoot.GetChild(i).gameObject);
}
// 延迟一帧:等 Canvas LayoutRebuilder 完成布局后再读取 rect.width避免得到 0
StartCoroutine(RebuildMarkersAfterLayout());
}
private System.Collections.IEnumerator RebuildMarkersAfterLayout()
{
yield return null; // 等待 Canvas 完成当前帧的 Layout 传递
foreach (var t in _pendingThresholds)
PlacePhaseMarker(t);
}
private void OnPhaseThreshold(float threshold)
{
if (threshold <= 0f || threshold >= 1f) return;
_pendingThresholds.Add(threshold);
if (_maxHP > 0) PlacePhaseMarker(threshold);
}
private void PlacePhaseMarker(float threshold)
{
if (_phaseMarkersRoot == null || _phaseMarkerPrefab == null || _hpFill == null) return;
var marker = Instantiate(_phaseMarkerPrefab, _phaseMarkersRoot);
var markerRect = marker.GetComponent<RectTransform>();
if (markerRect != null)
{
float barWidth = _hpFill.rectTransform.rect.width;
markerRect.anchoredPosition = new Vector2(barWidth * threshold, 0f);
}
marker.SetActive(true);
}
// ── 动画协程 ──────────────────────────────────────────────────────────

View File

@@ -6,6 +6,11 @@ using BaseGames.Core.Events;
namespace BaseGames.UI.HUD
{
/// <summary>
/// 游戏内 HUD 控制器(架构 10_UIModule §2
/// 订阅事件频道更新 HP 格子、魂魂力/灵力/灵珠/弹簧充能和形态图标。
/// HP/弹簧格子采用 SetActive 复用,避免运行期频繁 Instantiate/Destroy。
/// </summary>
public class HUDController : MonoBehaviour
{
[Header("HP")]

View File

@@ -0,0 +1,223 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Quest;
using BaseGames.Localization;
namespace BaseGames.UI.HUD
{
/// <summary>
/// 任务追踪 HUD 控件(架构 10_UIModule §HUD
/// 自动追踪最近开始的活跃任务,显示任务名称与各目标的完成进度。
/// 订阅 QuestEventChannelRegistry 中的广播频道,实现响应式更新。
///
/// Inspector 必填:
/// _questDatabase — 注册到游戏中的所有 QuestSO 列表(与 QuestManager._allQuests 保持一致)
/// _questTitleText — 显示任务标题的 TMP_Text
/// _objectiveRowTemplate — 目标行模板(保持非激活状态,用于实例化)
/// _objectiveContainer — 目标行父节点
/// 事件频道字段 — 从 QuestEventChannelRegistry 对应字段拖入
/// </summary>
public class QuestTrackerWidget : MonoBehaviour
{
// ── Inspector 字段 ────────────────────────────────────────────────────
[Header("UI 根节点")]
[SerializeField] private TMP_Text _questTitleText;
[SerializeField] private GameObject _objectiveRowTemplate; // kept inactive
[SerializeField] private Transform _objectiveContainer;
[Header("Event Channels")]
[Tooltip("EVT_QuestStartedpayload = questId")]
[SerializeField] private StringEventChannelSO _onQuestStarted;
[Tooltip("EVT_QuestCompletedpayload = questId")]
[SerializeField] private StringEventChannelSO _onQuestCompleted;
[Tooltip("EVT_QuestFailedpayload = questId")]
[SerializeField] private StringEventChannelSO _onQuestFailed;
[Tooltip("EVT_QuestAbandonedpayload = questId")]
[SerializeField] private StringEventChannelSO _onQuestAbandoned;
[Tooltip("EVT_QuestReadyToCompletepayload = questId")]
[SerializeField] private StringEventChannelSO _onQuestReadyToComplete;
[Tooltip("EVT_QuestObjectiveBatchUpdated同帧内多目标聚合更新")]
[SerializeField] private QuestObjectiveBatchEventChannelSO _onObjectiveBatchUpdated;
// ── 私有状态 ──────────────────────────────────────────────────────────
private string _trackedQuestId;
private QuestSO _trackedQuest;
private readonly List<GameObject> _rowPool = new();
private readonly List<(TMP_Text label, TMP_Text progress)> _activeRows = new();
private readonly CompositeDisposable _subs = new();
// IQuestManager 在 OnEnable 时从 ServiceLocator 解析,消除对 _questDatabase 数组的重复注入需求
private IQuestManager _questManager;
// ── 进度字典objectiveId → (Progress, Required) ─────────────────────
private readonly Dictionary<string, (int Progress, int Required)> _progressCache =
new(System.StringComparer.Ordinal);
// ── 生命周期 ──────────────────────────────────────────────────────────
private void Awake()
{
gameObject.SetActive(false);
}
private void OnEnable()
{
// 延迟解析而非缓存到字段:支持 ServiceLocator 中途替换实现(测试/多场景)
_questManager = ServiceLocator.GetOrDefault<IQuestManager>();
_onQuestStarted?.Subscribe(OnQuestStarted).AddTo(_subs);
_onQuestCompleted?.Subscribe(OnQuestEnded).AddTo(_subs);
_onQuestFailed?.Subscribe(OnQuestEnded).AddTo(_subs);
_onQuestAbandoned?.Subscribe(OnQuestEnded).AddTo(_subs);
_onQuestReadyToComplete?.Subscribe(OnQuestReadyToComplete).AddTo(_subs);
_onObjectiveBatchUpdated?.Subscribe(OnObjectiveBatchUpdated).AddTo(_subs);
}
private void OnDisable()
{
_subs.Clear();
}
// ── 事件处理 ──────────────────────────────────────────────────────────
private void OnQuestStarted(string questId)
{
// 自动追踪最新开始的任务
_trackedQuestId = questId;
// 通过 IQuestManager 接口查找 QuestSO无需在 HUD 中重复注入数据库
_questManager?.TryGetQuest(questId, out _trackedQuest);
_progressCache.Clear();
Rebuild();
}
private void OnQuestEnded(string questId)
{
if (questId != _trackedQuestId) return;
_trackedQuestId = null;
_trackedQuest = null;
_progressCache.Clear();
gameObject.SetActive(false);
}
private void OnQuestReadyToComplete(string questId)
{
if (questId == _trackedQuestId)
{
// 可选:更改标题颜色以提示玩家交任务
if (_questTitleText != null)
_questTitleText.color = Color.yellow;
}
}
private void OnObjectiveBatchUpdated(QuestObjectiveBatchEvent batch)
{
if (batch.QuestId != _trackedQuestId) return;
if (batch.Updates == null) return;
foreach (var update in batch.Updates)
_progressCache[update.ObjectiveId] = (update.Progress, update.Required);
RefreshObjectiveRows();
}
// ── UI 重建 ───────────────────────────────────────────────────────────
private void Rebuild()
{
if (string.IsNullOrEmpty(_trackedQuestId))
{
gameObject.SetActive(false);
return;
}
gameObject.SetActive(true);
// 标题
if (_questTitleText != null)
{
_questTitleText.color = Color.white;
if (_trackedQuest != null && !string.IsNullOrEmpty(_trackedQuest.displayNameKey))
{
string title = LocalizationManager.Get(_trackedQuest.displayNameKey, LocalizationTable.Quest);
_questTitleText.text = string.IsNullOrEmpty(title) || title == _trackedQuest.displayNameKey
? _trackedQuestId
: title;
}
else
{
_questTitleText.text = _trackedQuestId;
}
}
// 返还所有行到对象池
foreach (var row in _activeRows)
if (row.label != null) row.label.transform.parent.gameObject.SetActive(false);
_activeRows.Clear();
// 生成目标行
if (_trackedQuest?.objectives == null) return;
foreach (var obj in _trackedQuest.objectives)
{
if (obj == null) continue;
var rowGo = GetOrCreateRow();
var texts = rowGo.GetComponentsInChildren<TMP_Text>(includeInactive: true);
TMP_Text labelText = texts.Length > 0 ? texts[0] : null;
TMP_Text progressText = texts.Length > 1 ? texts[1] : null;
if (labelText != null)
{
string objText = LocalizationManager.Get(obj.displayTextKey, LocalizationTable.Quest);
labelText.text = string.IsNullOrEmpty(objText) || objText == obj.displayTextKey
? obj.objectiveId
: objText;
}
_progressCache.TryGetValue(obj.objectiveId, out var cached);
int prog = cached.Progress;
int req = cached.Required > 0 ? cached.Required : obj.GetRequiredCount();
if (progressText != null)
progressText.text = req > 1 ? $"{prog}/{req}" : string.Empty;
rowGo.SetActive(true);
_activeRows.Add((labelText, progressText));
}
}
private void RefreshObjectiveRows()
{
if (_trackedQuest?.objectives == null) return;
for (int i = 0; i < _activeRows.Count && i < _trackedQuest.objectives.Length; i++)
{
var obj = _trackedQuest.objectives[i];
if (obj == null) continue;
_progressCache.TryGetValue(obj.objectiveId, out var cached);
int prog = cached.Progress;
int req = cached.Required > 0 ? cached.Required : obj.GetRequiredCount();
var (_, progressText) = _activeRows[i];
if (progressText != null)
progressText.text = req > 1 ? $"{prog}/{req}" : string.Empty;
}
}
// ── 对象池 ────────────────────────────────────────────────────────────
private GameObject GetOrCreateRow()
{
foreach (var r in _rowPool)
if (r != null && !r.activeSelf) return r;
var go = Instantiate(_objectiveRowTemplate, _objectiveContainer);
_rowPool.Add(go);
return go;
}
}
}

View File

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

View File

@@ -0,0 +1,126 @@
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using BaseGames.Core;
using BaseGames.Spells;
namespace BaseGames.UI.HUD
{
/// <summary>
/// 法术槽 HUD 小组件。
///
/// 通过 ServiceLocator 获取 ISpellService显示
/// · 已装备法术图标(无法术时显示空槽占位)
/// · 冷却进度环_cooldownFill.fillAmount = CooldownFraction0 = 冷却完毕1 = 刚施放)
///
/// 图标刺新:订阅 ISpellService.OnSpellChanged 事件,装备切换实时响应(零延迟)。
/// 冷却进度:协程专职追踪,冷却中每帧更新,待机时每 0.1 秒轮询。
///
/// Inspector 必填:
/// _iconImage — 法术图标 Image
/// _cooldownFill — 覆盖在图标上的冷却遮罩 ImagefillMethod = Radial360 推荐)
/// _emptySlot — 无法术时显示的占位对象
/// </summary>
public class SpellSlotWidget : MonoBehaviour
{
// ── Inspector 字段 ────────────────────────────────────────────────────
[SerializeField] private Image _iconImage;
[Tooltip("冷却进度覆盖图(填充方式建议设为 Radial360 + 顺时针)。")]
[SerializeField] private Image _cooldownFill;
[Tooltip("无法术装备时显示的空槽占位对象。")]
[SerializeField] private GameObject _emptySlot;
// ── 私有状态 ──────────────────────────────────────────────────────────
private ISpellService _spellService;
private SpellSO _cachedSpell; // 延迟绑定期间班本记录,避免重复 SetSprite
// ── 生命周期 ──────────────────────────────────────────────────────────
private void OnEnable()
{
_cachedSpell = null;
StartCoroutine(TrackSpell());
}
private void OnDisable()
{
StopAllCoroutines();
if (_spellService != null)
{
_spellService.OnSpellChanged -= RefreshIcon;
_spellService = null;
}
_cachedSpell = null;
}
// ── 协程 ─────────────────────────────────────────────────────────────
/// <summary>
/// 阶段一:延迟绑定——等到 ISpellService 就绪,订阅图标事件并同步初始状态。
/// 阶段二:冷却追踪——事件驱动图标(零延迟),协程专职更新进度环。
/// </summary>
private IEnumerator TrackSpell()
{
var pollWait = new WaitForSeconds(0.1f);
// 阶段一延迟绑定SpellManager 可能晚于此组件初始化)
while (_spellService == null)
{
_spellService = ServiceLocator.GetOrDefault<ISpellService>();
if (_spellService == null)
{
ShowEmpty();
yield return pollWait;
continue;
}
// 服务就绪:订阅装备变更事件(零延迟图标刷新)+ 同步当前状态
_spellService.OnSpellChanged += RefreshIcon;
RefreshIcon(_spellService.EquippedSpell);
}
// 阶段二:冷却进度追踪(图标已由事件驱动,协程仅负责冷却环)
while (true)
{
float f = _spellService.CooldownFraction;
if (_cooldownFill != null) _cooldownFill.fillAmount = f;
yield return f > 0f ? null : (object)pollWait;
}
}
// ── 视觉刷新 ──────────────────────────────────────────────────────────
private void RefreshIcon(SpellSO spell)
{
_cachedSpell = spell;
bool hasSpell = spell != null;
if (_emptySlot != null)
_emptySlot.SetActive(!hasSpell);
if (_iconImage != null)
{
_iconImage.gameObject.SetActive(hasSpell);
if (hasSpell) _iconImage.sprite = spell.icon;
}
if (_cooldownFill != null)
{
_cooldownFill.fillAmount = 0f;
_cooldownFill.gameObject.SetActive(hasSpell);
}
}
private void ShowEmpty()
{
if (_emptySlot != null) _emptySlot.SetActive(true);
if (_iconImage != null) _iconImage.gameObject.SetActive(false);
if (_cooldownFill != null)
{
_cooldownFill.fillAmount = 0f;
_cooldownFill.gameObject.SetActive(false);
}
}
}
}

View File

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

View File

@@ -0,0 +1,185 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Core.Events;
using BaseGames.Combat.StatusEffects;
using StatusEffectType = BaseGames.Combat.StatusEffects.StatusEffectType;
namespace BaseGames.UI.HUD
{
/// <summary>
/// 状态效果 HUD —— 在 HUD 上为玩家展示当前激活的状态效果图标、叠层数和倒计时。
///
/// 布局约定:挂在 HUD Canvas 内,持有一个 _slotTemplate默认 inactive和一个 _container。
/// 每种 StatusEffectType 最多对应一个槽位实例,通过字典管理。
///
/// 订阅的事件频道:
/// _onStatusEffectApplied → OnStatusEffectApplied
/// _onStatusEffectExpired → OnStatusEffectExpired
/// </summary>
public class StatusEffectHUD : MonoBehaviour
{
// ── Inspector 字段 ────────────────────────────────────────────────────
[Header("Slot")]
[Tooltip("槽位模板预制(设为 inactive每种效果类型会从此 Instantiate 一个实例。")]
[SerializeField] private GameObject _slotTemplate;
[Tooltip("槽位实例的父节点(水平/垂直 LayoutGroup 即可)。")]
[SerializeField] private Transform _container;
[Header("效果图标配置")]
[SerializeField] private SlotConfig[] _slotConfigs;
[Header("Event Channels")]
[SerializeField] private StatusEffectEventChannelSO _onStatusEffectApplied;
[SerializeField] private StatusEffectEventChannelSO _onStatusEffectExpired;
// ── 内部数据 ──────────────────────────────────────────────────────────
/// <summary>每种 StatusEffectType 对应的 Inspector 配置(图标 Sprite。</summary>
[System.Serializable]
public struct SlotConfig
{
public StatusEffectType effectType;
[Tooltip("该状态效果在 HUD 上显示的图标。")]
public Sprite icon;
}
private class SlotInstance
{
public GameObject root;
public Image iconImage;
public TMP_Text durationText; // 倒计时文本(可为 null
public TMP_Text stackText; // 叠层数文本(可为 null
public Coroutine countdown;
}
private readonly Dictionary<StatusEffectType, SlotInstance> _activeSlots = new();
private readonly CompositeDisposable _subs = new();
// 倒计时节流:缓存 WaitForSecondsRealtime 以避免每帧堆分配
private readonly WaitForSecondsRealtime _countdownTick = new(0.1f);
// 图标快速查找表: Awake 时一次性从 _slotConfigs 构建,避免 OnStatusEffectApplied 高频线性扫描
private Dictionary<StatusEffectType, Sprite> _iconLookup;
// ── 生命周期 ───────────────────────────────────────────────────────
private void Awake()
{
_iconLookup = new Dictionary<StatusEffectType, Sprite>(_slotConfigs?.Length ?? 0);
if (_slotConfigs != null)
foreach (var cfg in _slotConfigs)
_iconLookup[cfg.effectType] = cfg.icon;
}
private void OnEnable()
{
_onStatusEffectApplied?.Subscribe(OnStatusEffectApplied).AddTo(_subs);
_onStatusEffectExpired?.Subscribe(OnStatusEffectExpired).AddTo(_subs);
}
private void OnDisable()
{
_subs.Clear();
StopAllCoroutines();
foreach (var slot in _activeSlots.Values)
if (slot.root != null) slot.root.SetActive(false);
_activeSlots.Clear();
}
// ── 事件处理 ──────────────────────────────────────────────────────────
private void OnStatusEffectApplied(StatusEffectEvent e)
{
if (!_activeSlots.TryGetValue(e.EffectType, out var slot))
{
slot = CreateSlot(e.EffectType);
if (slot == null) return;
_activeSlots[e.EffectType] = slot;
}
slot.root.SetActive(true);
RefreshSlotDisplay(slot, e.StackCount, e.RemainingDuration);
// 重启倒计时协程OnStack 刷新持续时间时也会走到这里)
if (slot.countdown != null)
StopCoroutine(slot.countdown);
slot.countdown = StartCoroutine(CountdownRoutine(slot, e.RemainingDuration));
}
private void OnStatusEffectExpired(StatusEffectEvent e)
{
if (!_activeSlots.TryGetValue(e.EffectType, out var slot)) return;
if (slot.countdown != null)
{
StopCoroutine(slot.countdown);
slot.countdown = null;
}
slot.root.SetActive(false);
}
// ── 协程 ─────────────────────────────────────────────────────────────
private IEnumerator CountdownRoutine(SlotInstance slot, float totalDuration)
{
float endTime = Time.realtimeSinceStartup + totalDuration;
while (Time.realtimeSinceStartup < endTime)
{
float remaining = endTime - Time.realtimeSinceStartup;
if (slot.durationText != null)
slot.durationText.text = remaining.ToString("F1");
yield return _countdownTick; // 0.1s 节忍,替代每帧 yield return null
}
slot.countdown = null;
slot.root.SetActive(false);
}
// ── 内部辅助 ──────────────────────────────────────────────────────────
private void RefreshSlotDisplay(SlotInstance slot, int stackCount, float duration)
{
if (slot.durationText != null)
slot.durationText.text = duration.ToString("F1");
bool multiStack = stackCount > 1;
if (slot.stackText != null)
{
slot.stackText.gameObject.SetActive(multiStack);
if (multiStack) slot.stackText.text = stackCount.ToString();
}
}
private SlotInstance CreateSlot(StatusEffectType type)
{
if (_slotTemplate == null || _container == null) return null;
_iconLookup.TryGetValue(type, out Sprite icon); // O(1) 查找
var go = Instantiate(_slotTemplate, _container);
go.SetActive(false);
var slot = new SlotInstance { root = go };
// 约定:第一个 Image 子节点作为图标;第一个 TMP_Text 作为倒计时;第二个作为叠层数
var images = go.GetComponentsInChildren<Image>(includeInactive: true);
if (images.Length > 0)
{
slot.iconImage = images[0];
if (icon != null) slot.iconImage.sprite = icon;
}
var texts = go.GetComponentsInChildren<TMP_Text>(includeInactive: true);
if (texts.Length > 0) slot.durationText = texts[0];
if (texts.Length > 1) slot.stackText = texts[1];
return slot;
}
}
}

View File

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

View File

@@ -0,0 +1,19 @@
namespace BaseGames.UI
{
/// <summary>
/// 可恢复焦点的 UI 面板接口。
///
/// 挂载在面板根 GameObject 上的 MonoBehaviour 实现此接口后,
/// 当 <see cref="IUIManager.CloseTopPanel"/> 将该面板恢复为栈顶时,
/// 会自动调用 <see cref="OnFocusRestored"/>
/// 将 EventSystem 焦点重置到合适控件,保证手柄/键盘导航不丢失。
/// </summary>
public interface IFocusable
{
/// <summary>
/// 面板成为当前栈顶时自动调用。
/// 实现时请调用 <c>EventSystem.current?.SetSelectedGameObject(...)</c>。
/// </summary>
void OnFocusRestored();
}
}

View File

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

View File

@@ -25,6 +25,12 @@ namespace BaseGames.UI
/// </summary>
string GetActionEffectivePath(string actionName);
/// <summary>
/// 根据固定绑定路径(如 "&lt;Keyboard&gt;/space")在当前图标集中查找图标。
/// 不受改键影响,适合装饰性按键说明。找不到时返回 null。
/// </summary>
Sprite GetPathIcon(string bindingPath);
/// <summary>
/// 当设备切换或玩家改键后触发。
/// 订阅此事件的 UI 组件应在回调中刷新图标显示。

View File

@@ -0,0 +1,23 @@
using UnityEngine;
namespace BaseGames.UI
{
/// <summary>
/// 面板栈管理接口。所有需要操作 UI 面板的组件应依赖此接口而非直接引用
/// <see cref="UIManager"/>,从而保持可测试性和解耦合。
/// </summary>
public interface IUIManager
{
/// <summary>通过枚举 ID 打开已注册面板。</summary>
void OpenPanel(PanelId id);
/// <summary>打开指定 GameObject 面板并压栈已在栈中则忽略O(1) 判断)。</summary>
void OpenPanel(GameObject panel);
/// <summary>关闭栈顶面板并恢复上一层;上一层若实现 <see cref="IFocusable"/> 则自动恢复焦点。</summary>
void CloseTopPanel();
/// <summary>运行时注册或覆盖面板绑定(如场景加载后动态添加的面板)。</summary>
void RegisterPanel(PanelId id, GameObject root);
}
}

View File

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

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using BaseGames.Core;
@@ -25,9 +26,8 @@ namespace BaseGames.UI
private void OnDeviceChanged(InputDeviceType _)
{
// 通知场景内所有 InputIconImage 刷新(含非本对象子节点的其他 Canvas 区域)
foreach (var img in FindObjectsByType<InputIconImage>(FindObjectsInactive.Include, FindObjectsSortMode.None))
img.Refresh();
// 通过静态注册表刷新O(n) 遍历已启用实例,无需全场景搜索
InputIconImage.RefreshAll();
}
}
@@ -57,6 +57,19 @@ namespace BaseGames.UI
private Image _image;
private IInputIconService _iconService;
// ── 静态注册表:替换 FindObjectsByTypeO(1) 注册/注销O(n) 广播 ────────
private static readonly List<InputIconImage> _registry = new();
/// <summary>通知注册表内所有已启用实例刷新图标(设备切换时调用)。</summary>
internal static void RefreshAll()
{
for (int i = _registry.Count - 1; i >= 0; i--)
{
if (_registry[i] != null) _registry[i].Refresh();
else _registry.RemoveAt(i); // 清理已销毁的残留引用
}
}
private void Awake() => _image = GetComponent<Image>();
private void OnEnable()
@@ -64,6 +77,7 @@ namespace BaseGames.UI
_iconService = ServiceLocator.GetOrDefault<IInputIconService>();
if (_iconService != null)
_iconService.OnIconSetChanged += Refresh;
_registry.Add(this);
Refresh();
}
@@ -71,6 +85,7 @@ namespace BaseGames.UI
{
if (_iconService != null)
_iconService.OnIconSetChanged -= Refresh;
_registry.Remove(this);
}
/// <summary>刷新图标显示。设备切换或改键后由 InputDeviceIconSwitcher / InputIconService 调用。</summary>
@@ -86,9 +101,8 @@ namespace BaseGames.UI
}
else if (_mode == LookupMode.ByBindingPath && !string.IsNullOrEmpty(_bindingPath))
{
// 使用固定路径直接在当前图标集查找(不考虑改键)
// 此分支通常用于装饰性按键说明,不依赖服务
sprite = null; // 图标集访问须通过 InputIconServiceByBindingPath 模式已列入低优先级
// 使用固定路径在当前图标集查找(不随改键变化),适合装饰性按键说明
sprite = _iconService?.GetPathIcon(_bindingPath);
}
if (sprite != null)

View File

@@ -42,28 +42,24 @@ namespace BaseGames.UI
// ── Lifecycle ─────────────────────────────────────────────────────────
private void Awake()
{
ServiceLocator.RegisterIfAbsent<IInputIconService>(this);
_activeSet = _kbMouseSet;
}
private void OnEnable()
{
// OnEnable/OnDisable 对称注册:支持多场景加载时 GameObject 的启用/禁用周期
ServiceLocator.RegisterIfAbsent<IInputIconService>(this);
_onDeviceChanged?.Subscribe(HandleDeviceChanged).AddTo(_subs);
// 改键后 InputSystem 会广播 BoundControlsChanged
InputSystem.onActionChange += HandleActionChange;
}
private void OnDisable()
{
ServiceLocator.Unregister<IInputIconService>(this); // 仅注销自身ReferenceEquals 保护)
_subs.Clear();
InputSystem.onActionChange -= HandleActionChange;
}
private void OnDestroy()
{
ServiceLocator.Unregister<IInputIconService>(this);
}
// ── Event Handlers ────────────────────────────────────────────────────
private void HandleDeviceChanged(InputDeviceType deviceType)
@@ -94,6 +90,12 @@ namespace BaseGames.UI
return _activeSet.GetIcon(path);
}
public Sprite GetPathIcon(string bindingPath)
{
if (_activeSet == null || string.IsNullOrEmpty(bindingPath)) return null;
return _activeSet.GetIcon(bindingPath);
}
public string GetActionEffectivePath(string actionName)
{
if (_inputReader == null) return null;

View File

@@ -55,7 +55,7 @@ namespace BaseGames.UI
// 随机提示(通过 LocalizationManager 解析 key
if (_tipText != null && _tipMessages != null && _tipMessages.Length > 0)
_tipText.text = LocalizationManager.Get(_tipMessages[Random.Range(0, _tipMessages.Length)], "UI");
_tipText.text = LocalizationManager.Get(_tipMessages[Random.Range(0, _tipMessages.Length)], LocalizationTable.UI);
}
public void Hide() => StartCoroutine(HideAfterMinTime());

View File

@@ -1,7 +1,9 @@
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using BaseGames.Core;
using BaseGames.Core.Assets;
using BaseGames.Core.Events;
namespace BaseGames.UI.MainMenu
@@ -61,7 +63,7 @@ namespace BaseGames.UI.MainMenu
[Header("场景")]
[Tooltip("新游戏 / 继续后进入的第一个游戏场景Addressable Key")]
[SerializeField] private string _firstGameSceneKey = "Scene_Game_Chapter1";
[SerializeField] private string _firstGameSceneKey = AddressKeys.SceneGameChapter1;
// ── Event Channels ────────────────────────────────────────────────────
@@ -148,6 +150,9 @@ namespace BaseGames.UI.MainMenu
_mainButtonsGroup.interactable = true;
_mainButtonsGroup.blocksRaycasts = true;
// 手柄导航:入场动画完成后将焦点置于第一个按钮
EventSystem.current?.SetSelectedGameObject(_btnNewGame?.gameObject);
}
// ── 按钮回调 ─────────────────────────────────────────────────────────

View File

@@ -1,6 +1,7 @@
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
using BaseGames.Core.Events;
@@ -11,15 +12,28 @@ namespace BaseGames.UI.Menus
[SerializeField] private TMP_Text _deathMessage;
[SerializeField] private Button _btnRespawn;
[Header("时序")]
[Tooltip("死亡画面出现前的缓冲延迟(秒)。调整此值可匹配死亡动画时长。")]
[SerializeField] private float _showDelay = 1.5f;
[Tooltip("死亡文字默认显示内容,会被本地化系统覆盖(如果已配置)。")]
[SerializeField] private string _defaultDeathText = "決死";
[Header("Event Channels")]
[SerializeField] private VoidEventChannelSO _onDeathScreenConfirmed;
private WaitForSecondsRealtime _showDelayWait;
private void Awake()
{
_showDelayWait = new WaitForSecondsRealtime(_showDelay);
}
private void OnEnable()
{
// 死亡界面由 UIManager 在游戏状态变为 Dead 时通过 SetActive(true) 激活。
// _onPlayerDied 事件此时已经触发完毕,订阅它不会收到回调。
// 直接在 OnEnable 启动延迟显示协程即可保证 1.5s 缓冲。
StartCoroutine(ShowAfterDelay(1.5f));
StartCoroutine(ShowAfterDelay(_showDelay));
}
private void OnDisable()
@@ -29,17 +43,22 @@ namespace BaseGames.UI.Menus
private IEnumerator ShowAfterDelay(float delay)
{
yield return new WaitForSeconds(delay);
yield return _showDelayWait;
Show();
}
private void Show()
{
if (_deathMessage != null) _deathMessage.text = _defaultDeathText;
if (_btnRespawn != null)
{
_btnRespawn.onClick.RemoveAllListeners();
_btnRespawn.onClick.AddListener(Confirm);
}
// 手柄导航:死亡界面显示后将焦点置于复活按钮
EventSystem.current?.SetSelectedGameObject(_btnRespawn?.gameObject);
}
private void Confirm()

View File

@@ -1,5 +1,6 @@
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using BaseGames.Core;
using BaseGames.Core.Assets;
using BaseGames.Core.Events;
@@ -11,10 +12,10 @@ namespace BaseGames.UI
/// 挂载在 Canvas_Menu → PauseMenuPanel GameObject 上。
/// 按钮绑定在 Awake 中完成;由 UIManager 负责面板开关。
/// </summary>
public class PauseMenuController : MonoBehaviour
public class PauseMenuController : MonoBehaviour, IFocusable
{
[SerializeField] private UIManager _uiManager;
[SerializeField] private GameObject _settingsRoot; // SettingsPanel 根 GameObject
// UIManager 通过 ServiceLocator 解析,开启时自动获取,无需 Inspector 直接绑定具体类型
private IUIManager _uiManager;
[Header("按钮引用")]
[SerializeField] private Button _btnResume;
@@ -28,10 +29,23 @@ namespace BaseGames.UI
private void Awake()
{
_btnResume.onClick.AddListener(Resume);
_btnSettings.onClick.AddListener(OpenSettings);
_btnMainMenu.onClick.AddListener(GoToMainMenu);
_btnQuit.onClick.AddListener(Application.Quit);
_btnResume?.onClick.AddListener(Resume);
_btnSettings?.onClick.AddListener(OpenSettings);
_btnMainMenu?.onClick.AddListener(GoToMainMenu);
_btnQuit?.onClick.AddListener(Application.Quit);
}
private void OnEnable()
{
// 暂停面板由 UIManager 开启,此时 ServiceLocator 已就绪
_uiManager = ServiceLocator.GetOrDefault<IUIManager>();
// 手柄导航:打开时将焦点置于第一个按钮
EventSystem.current?.SetSelectedGameObject(_btnResume?.gameObject);
}
private void OnDisable()
{
_uiManager = null;
}
// ── 按钮回调 ──────────────────────────────────────────────────────────
@@ -39,18 +53,17 @@ namespace BaseGames.UI
private void Resume()
{
_onResumeRequested?.Raise();
_uiManager.CloseTopPanel();
_uiManager?.CloseTopPanel();
}
private void OpenSettings()
{
if (_settingsRoot != null)
_uiManager.OpenPanel(_settingsRoot);
_uiManager?.OpenPanel(PanelId.Settings);
}
private void GoToMainMenu()
{
_uiManager.CloseTopPanel();
_uiManager?.CloseTopPanel();
_onSceneLoadRequest?.Raise(new SceneLoadRequest
{
SceneName = AddressKeys.SceneMainMenu,
@@ -58,5 +71,11 @@ namespace BaseGames.UI
ShowLoadingScreen = false,
});
}
// ── IFocusable ────────────────────────────────────────────────────────
/// <summary>面板恢复为栈顶时(关闭子面板后)自动移回第一个按鈕。</summary>
public void OnFocusRestored()
=> EventSystem.current?.SetSelectedGameObject(_btnResume?.gameObject);
}
}

View File

@@ -1,11 +1,14 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
using BaseGames.Localization;
namespace BaseGames.UI.Menus
{
@@ -14,32 +17,49 @@ namespace BaseGames.UI.Menus
/// </summary>
public class SaveSlotController : MonoBehaviour
{
[SerializeField] private SaveSlotUI[] _slotUIs; // 存档槽 UI数量由 Inspector 决定)
[SerializeField] private SaveSlotUI[] _slotUIs;
[Header("Event Channels")]
[SerializeField] private IntEventChannelSO _onSlotConfirmed; // 携带槽索引,供 GameManager 监听
[SerializeField] private IntEventChannelSO _onSlotConfirmed;
private CancellationTokenSource _cts;
private void Awake()
{
for (int i = 0; i < _slotUIs.Length; i++)
if (_slotUIs[i] != null) _slotUIs[i].Init(i, this);
}
private void OnEnable()
{
var task = RefreshAsync();
_cts = new CancellationTokenSource();
var ct = _cts.Token;
var task = RefreshAsync(ct);
task.ContinueWith(t =>
{
if (t.IsFaulted)
if (t.IsFaulted && !(t.Exception?.InnerException is OperationCanceledException))
Debug.LogException(t.Exception?.InnerException ?? t.Exception, this);
}, TaskScheduler.FromCurrentSynchronizationContext());
}
private async Task RefreshAsync()
private void OnDisable()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
}
private async Task RefreshAsync(CancellationToken ct = default)
{
var svc = ServiceLocator.GetOrDefault<ISaveService>();
if (svc == null) return;
// 并行加载所有槽位摘要,减少主菜单等待时间
var tasks = new Task<SlotSummary>[_slotUIs.Length];
for (int i = 0; i < _slotUIs.Length; i++)
tasks[i] = svc.GetSlotSummaryAsync(i);
var summaries = await Task.WhenAll(tasks);
ct.ThrowIfCancellationRequested(); // 组件已失活时不写 UI
for (int i = 0; i < _slotUIs.Length; i++)
{
@@ -80,7 +100,7 @@ namespace BaseGames.UI.Menus
var svc = ServiceLocator.GetOrDefault<ISaveService>();
if (svc == null) return;
await svc.DeleteSlotAsync(slotIndex);
await RefreshAsync();
await RefreshAsync(_cts?.Token ?? CancellationToken.None);
}
}
@@ -98,6 +118,14 @@ namespace BaseGames.UI.Menus
[SerializeField] private Button _selectButton;
[SerializeField] private Button _deleteButton;
[Header("删除确认(可选)")]
[Tooltip("删除确认对话框根节点,为 null 时删除按钮直接执行。")]
[SerializeField] private GameObject _deleteConfirmRoot;
[Tooltip("确认删除按钮。")]
[SerializeField] private Button _btnConfirmDelete;
[Tooltip("取消删除按钮。")]
[SerializeField] private Button _btnCancelDelete;
private int _slotIndex;
private SaveSlotController _controller;
@@ -112,11 +140,31 @@ namespace BaseGames.UI.Menus
_selectButton.onClick.RemoveAllListeners();
_selectButton.onClick.AddListener(() => _controller.OnSlotSelected(_slotIndex));
}
if (_deleteButton != null)
{
_deleteButton.onClick.RemoveAllListeners();
_deleteButton.onClick.AddListener(() => _controller.OnSlotDeleteRequested(_slotIndex));
// 配置了确认对话框时先弹出确认,未配置时直接删除
_deleteButton.onClick.AddListener(ShowDeleteConfirm);
}
if (_btnConfirmDelete != null)
{
_btnConfirmDelete.onClick.RemoveAllListeners();
_btnConfirmDelete.onClick.AddListener(() =>
{
HideDeleteConfirm();
_controller.OnSlotDeleteRequested(_slotIndex);
});
}
if (_btnCancelDelete != null)
{
_btnCancelDelete.onClick.RemoveAllListeners();
_btnCancelDelete.onClick.AddListener(HideDeleteConfirm);
}
HideDeleteConfirm();
}
/// <summary>用摘要数据刷新显示summary 为 null 表示空槽。</summary>
@@ -128,13 +176,51 @@ namespace BaseGames.UI.Menus
if (_dataIndicator != null) _dataIndicator.SetActive(hasData);
if (_deleteButton != null) _deleteButton.gameObject.SetActive(hasData);
// 刷新时重置确认对话框状态
HideDeleteConfirm();
if (!hasData) return;
if (_playtimeText != null) _playtimeText.text = FormatPlaytime(summary.Playtime);
if (_regionText != null) _regionText.text = summary.SceneName ?? string.Empty;
if (_regionText != null)
{
string key = summary.SceneName ?? string.Empty;
string loc = !string.IsNullOrEmpty(key)
? LocalizationManager.Get(key, LocalizationTable.UI)
: null;
_regionText.text = !string.IsNullOrEmpty(loc) && loc != key ? loc : key;
}
if (_lastSavedText != null) _lastSavedText.text = FormatDateTime(summary.LastSaved);
}
// ── 删除确认 ──────────────────────────────────────────────────────────
/// <summary>
/// 显示删除确认对话框。
/// 未配置 _deleteConfirmRoot 时直接执行删除(向下兼容旧 Prefab
/// </summary>
private void ShowDeleteConfirm()
{
if (_deleteConfirmRoot == null)
{
// 旧 Prefab 未添加确认根节点,直接删除
_controller.OnSlotDeleteRequested(_slotIndex);
return;
}
_deleteConfirmRoot.SetActive(true);
// 手柄导航:将焦点移至"确认"按钮,防止误触选择按钮
EventSystem.current?.SetSelectedGameObject(_btnConfirmDelete?.gameObject);
}
private void HideDeleteConfirm()
{
if (_deleteConfirmRoot != null) _deleteConfirmRoot.SetActive(false);
}
// ── 格式化工具 ────────────────────────────────────────────────────────
private static string FormatPlaytime(float seconds)
{
int h = (int)(seconds / 3600);

View File

@@ -1,6 +1,7 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using BaseGames.Core;
namespace BaseGames.UI
@@ -9,9 +10,11 @@ namespace BaseGames.UI
/// 设置面板控制器(架构 10_UIModule §7
/// 驱动 SettingsManager 的音量与画面设置,并从当前配置初始化控件值。
/// </summary>
public class SettingsPanelController : MonoBehaviour
public class SettingsPanelController : MonoBehaviour, IFocusable
{
[SerializeField] private SettingsManager _settings;
// ISettingsService 通过 ServiceLocator 获取,无需 Inspector 直接注入具体类,
// 支持测试场景替换 Mock 实现。
private ISettingsService _settings;
[Header("音量滑条")]
[SerializeField] private Slider _masterVolume;
@@ -28,12 +31,13 @@ namespace BaseGames.UI
private static readonly int[] FpsOptions = { 30, 60, 120, -1 };
private void Start()
private void OnEnable()
{
_settings = ServiceLocator.GetOrDefault<ISettingsService>();
if (_settings == null) return;
var data = _settings.Current;
// 初始化控件值(不触发 onChange先移除监听再设置值再添加)
// 初始化控件值(先移除监听再设置值再添加,防止面板重开时重复注册
InitSlider(_masterVolume, data.MasterVolume, v => _settings.SetMasterVolume(v));
InitSlider(_bgmVolume, data.BGMVolume, v => _settings.SetBGMVolume(v));
InitSlider(_sfxVolume, data.SFXVolume, v => _settings.SetSFXVolume(v));
@@ -41,17 +45,22 @@ namespace BaseGames.UI
if (_vSyncToggle != null)
{
_vSyncToggle.onValueChanged.RemoveAllListeners();
_vSyncToggle.isOn = data.VSync;
_vSyncToggle.onValueChanged.AddListener(v => _settings.SetVSync(v));
}
if (_fpsDropdown != null)
{
_fpsDropdown.onValueChanged.RemoveAllListeners();
int idx = System.Array.IndexOf(FpsOptions, data.TargetFPS);
_fpsDropdown.value = idx >= 0 ? idx : 1; // default 60
_fpsDropdown.onValueChanged.AddListener(i =>
_settings.SetTargetFrameRate(FpsOptions[Mathf.Clamp(i, 0, FpsOptions.Length - 1)]));
}
// 手柄导航:打开设置面板时将焦点置于主音量滑条
EventSystem.current?.SetSelectedGameObject(_masterVolume?.gameObject);
}
// ── 辅助 ──────────────────────────────────────────────────────────────
@@ -59,8 +68,15 @@ namespace BaseGames.UI
private static void InitSlider(Slider slider, float value, UnityEngine.Events.UnityAction<float> onChange)
{
if (slider == null) return;
slider.onValueChanged.RemoveAllListeners();
slider.value = value;
slider.onValueChanged.AddListener(onChange);
}
// ── IFocusable ────────────────────────────────────────────────────────
/// <summary>面板恢复为栈顶时将焦点移回主音量滑条。</summary>
public void OnFocusRestored()
=> EventSystem.current?.SetSelectedGameObject(_masterVolume?.gameObject);
}
}

View File

@@ -4,6 +4,7 @@ using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
using BaseGames.Input;
using BaseGames.Localization;
namespace BaseGames.UI.Settings
{
@@ -51,7 +52,7 @@ namespace BaseGames.UI.Settings
/// <summary>启动交互式重绑定;完成或取消后调用 onFinished。</summary>
public void StartRebind(Action onFinished)
{
_currentBindingText.text = "按下新按键…";
_currentBindingText.text = LocalizationManager.Get("REBIND_WAITING_PROMPT", LocalizationTable.UI);
_inputReader.StartRebinding(
_actionName,
_bindingIndex,

View File

@@ -0,0 +1,220 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Skills;
using BaseGames.Localization;
namespace BaseGames.UI
{
/// <summary>
/// 技能树面板(架构 10_UIModule §Panel
/// 全屏面板,按形态展示各技能槽的技能资料(名称、描述、消耗、冷却)。
/// 支持左右翻页浏览各形态,并高亮当前实际使用的形态。
///
/// Inspector 必填:
/// _formSkillSets — 每个形态对应一条配置soulSkill + spirit1 + spirit2
/// _formTitleText — 显示当前形态标签名的文本
/// _prevFormBtn / _nextFormBtn — 翻页按钮
/// _activeFormIndicator — 高亮"当前形态"的标志(可选,如小圆点)
/// _skillSlots — 3 个 SkillSlotView 引用Soul / Spirit1 / Spirit2
/// _onFormChanged — IntEventChannelSOFormController 广播当前形态 index
/// </summary>
public class SkillTreePanel : MonoBehaviour
{
// ── 子数据结构 ────────────────────────────────────────────────────────
[System.Serializable]
public struct FormSkillConfig
{
[Tooltip("形态标题的本地化 Key格式如 \"Form_SkyWarden_Label\",由 LocalizationManager.Get(…, LocalizationTable.UI) 查找。")]
public string formLabelKey;
[Tooltip("天魂/地魂/命魂的「魂技」")]
public FormSkillSO soulSkill;
[Tooltip("灵技 1Y / Triangle / 手柄按钮)")]
public FormSkillSO spiritSkill1;
[Tooltip("灵技 2Shift+Y / L2+Triangle 等)")]
public FormSkillSO spiritSkill2;
}
[System.Serializable]
public struct SkillSlotView
{
[Tooltip("技能图标")]
public Image icon;
[Tooltip("技能名称文本")]
public TMP_Text nameText;
[Tooltip("技能描述文本")]
public TMP_Text descText;
[Tooltip("消耗数值文本(如 \"魂力 25\"")]
public TMP_Text costText;
[Tooltip("冷却数值文本(如 \"CD 3.5s\"")]
public TMP_Text cooldownText;
[Tooltip("无技能时隐藏的根 GameObject")]
public GameObject root;
}
// ── Inspector 字段 ────────────────────────────────────────────────────
[Header("形态技能配置")]
[Tooltip("每个元素对应一种形态的三项技能,顺序应与 FormType 枚举序号一致。")]
[SerializeField] private FormSkillConfig[] _formSkillSets;
[Header("UI 导航")]
[SerializeField] private TMP_Text _formTitleText;
[SerializeField] private Button _prevFormBtn;
[SerializeField] private Button _nextFormBtn;
[Tooltip("高亮当前实际装备形态的指示器(可选)")]
[SerializeField] private GameObject _activeFormIndicator;
[Header("技能卡槽Soul / Spirit1 / Spirit2")]
[SerializeField] private SkillSlotView _soulSlot;
[SerializeField] private SkillSlotView _spiritSlot1;
[SerializeField] private SkillSlotView _spiritSlot2;
[Header("关闭按钮")]
[SerializeField] private Button _btnClose;
[Header("Event Channels")]
[Tooltip("FormController 广播当前形态切换payload = FormType 的整数序号0=天魂,1=地魂,2=命魂)")]
[SerializeField] private IntEventChannelSO _onFormChanged;
// ── 私有状态 ──────────────────────────────────────────────────────────
private int _viewIndex = 0; // 当前正在查看的形态
private int _activeIndex = 0; // 玩家当前实际使用的形态
private readonly CompositeDisposable _subs = new();
// ── 生命周期 ──────────────────────────────────────────────────────────
private void Awake()
{
_prevFormBtn?.onClick.AddListener(PrevForm);
_nextFormBtn?.onClick.AddListener(NextForm);
_btnClose?.onClick.AddListener(OnCloseClicked);
}
private void OnEnable()
{
_onFormChanged?.Subscribe(OnFormChanged).AddTo(_subs);
// 打开时自动跳到当前形态
_viewIndex = _activeIndex;
Refresh();
}
private void OnDisable()
{
_subs.Clear();
}
// ── 事件处理 ──────────────────────────────────────────────────────────
private void OnFormChanged(int formIndex)
{
_activeIndex = formIndex;
if (_activeFormIndicator != null)
_activeFormIndicator.SetActive(_viewIndex == _activeIndex);
}
// ── 导航 ──────────────────────────────────────────────────────────────
private void PrevForm()
{
if (_formSkillSets == null || _formSkillSets.Length == 0) return;
_viewIndex = (_viewIndex - 1 + _formSkillSets.Length) % _formSkillSets.Length;
Refresh();
}
private void NextForm()
{
if (_formSkillSets == null || _formSkillSets.Length == 0) return;
_viewIndex = (_viewIndex + 1) % _formSkillSets.Length;
Refresh();
}
// ── 显示刷新 ──────────────────────────────────────────────────────────
private void Refresh()
{
if (_formSkillSets == null || _formSkillSets.Length == 0) return;
_viewIndex = Mathf.Clamp(_viewIndex, 0, _formSkillSets.Length - 1);
var cfg = _formSkillSets[_viewIndex];
// 标题走本地化管道fallback 为 key 本身)
if (_formTitleText != null)
{
string label = !string.IsNullOrEmpty(cfg.formLabelKey)
? LocalizationManager.Get(cfg.formLabelKey, LocalizationTable.UI)
: string.Empty;
_formTitleText.text = string.IsNullOrEmpty(label) || label == cfg.formLabelKey
? cfg.formLabelKey // fallback直接显示 Key
: label;
}
// 当前形态高亮指示器
if (_activeFormIndicator != null)
_activeFormIndicator.SetActive(_viewIndex == _activeIndex);
// 翻页按钮可见性(只有一种形态时隐藏)
bool multiForm = _formSkillSets.Length > 1;
if (_prevFormBtn != null) _prevFormBtn.gameObject.SetActive(multiForm);
if (_nextFormBtn != null) _nextFormBtn.gameObject.SetActive(multiForm);
// 技能卡槽
FillSlot(ref _soulSlot, cfg.soulSkill);
FillSlot(ref _spiritSlot1, cfg.spiritSkill1);
FillSlot(ref _spiritSlot2, cfg.spiritSkill2);
}
private static void FillSlot(ref SkillSlotView slot, FormSkillSO skill)
{
bool hasSkill = skill != null;
if (slot.root != null) slot.root.SetActive(hasSkill);
if (!hasSkill) return;
if (slot.icon != null)
{
slot.icon.sprite = skill.icon;
slot.icon.enabled = skill.icon != null;
}
if (slot.nameText != null)
{
string name = LocalizationManager.Get(skill.displayNameKey, LocalizationTable.UI);
slot.nameText.text = string.IsNullOrEmpty(name) || name == skill.displayNameKey
? skill.skillId
: name;
}
if (slot.descText != null)
{
string desc = LocalizationManager.Get(skill.descriptionKey, LocalizationTable.UI);
slot.descText.text = string.IsNullOrEmpty(desc) || desc == skill.descriptionKey
? string.Empty
: desc;
}
if (slot.costText != null)
slot.costText.text = skill.baseCost > 0
? $"{skill.resourceType} {skill.baseCost}"
: string.Empty;
if (slot.cooldownText != null)
slot.cooldownText.text = skill.cooldown > 0f
? $"CD {skill.cooldown:0.#}s"
: string.Empty;
}
// ── 关闭 ──────────────────────────────────────────────────────────────
private void OnCloseClicked()
{
var uiMgr = ServiceLocator.GetOrDefault<UIManager>();
if (uiMgr != null) uiMgr.CloseTopPanel();
else gameObject.SetActive(false);
}
}
}

View File

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

View File

@@ -4,6 +4,7 @@ using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Core.Events;
using BaseGames.Localization;
namespace BaseGames.UI
{
@@ -22,6 +23,7 @@ namespace BaseGames.UI
private CanvasGroup _cg;
private Coroutine _hideCoroutine;
private WaitForSecondsRealtime _waitDisplay; // 缓存:避免每次 Show 堆分配
/// <summary>淡入 + 保持 + 淡出的总时长(供 ToastManager 队列计时用)。</summary>
public float TotalTime => _displayDuration + _fadeDuration * 2f;
@@ -30,6 +32,7 @@ namespace BaseGames.UI
{
_cg = GetComponent<CanvasGroup>();
_cg.alpha = 0f;
_waitDisplay = new WaitForSecondsRealtime(_displayDuration);
}
public void Show(string title, string body, Sprite icon = null)
@@ -51,8 +54,8 @@ namespace BaseGames.UI
{
// 淡入
yield return StartCoroutine(FadeTo(1f));
// 保持
yield return new WaitForSecondsRealtime(_displayDuration);
// 保持(复用缓存的 WaitForSecondsRealtime
yield return _waitDisplay;
// 淡出
yield return StartCoroutine(FadeTo(0f));
gameObject.SetActive(false);
@@ -89,6 +92,14 @@ namespace BaseGames.UI
private readonly Queue<(string title, string body, Sprite icon)> _queue = new();
private readonly CompositeDisposable _subs = new();
private bool _showing;
private WaitForSecondsRealtime _queueWait; // 缓存:避免 ProcessQueue 每条堆分配
private void Awake()
{
// _toast.TotalTime 基于 Inspector 常量,只需 Awake 时初始化一次
if (_toast != null)
_queueWait = new WaitForSecondsRealtime(_toast.TotalTime + 0.1f);
}
private void OnEnable()
{
@@ -100,8 +111,11 @@ namespace BaseGames.UI
_subs.Clear();
}
private void OnAchievement(string id) => Enqueue("成就解锁", id, null);
private void OnAbility(string abilityId) => Enqueue("能力获得", abilityId, null);
private void OnAchievement(string id)
=> Enqueue(LocalizationManager.Get("TOAST_ACHIEVEMENT_TITLE", LocalizationTable.UI), id, null);
private void OnAbility(string abilityId)
=> Enqueue(LocalizationManager.Get("TOAST_ABILITY_TITLE", LocalizationTable.UI), abilityId, null);
public void Enqueue(string title, string body, Sprite icon = null)
{
@@ -115,10 +129,13 @@ namespace BaseGames.UI
while (_queue.Count > 0)
{
var (title, body, icon) = _queue.Dequeue();
if (_toast != null) _toast.Show(title, body, icon);
// 等待 Toast 完成后再显示下一条(与 ToastNotification._displayDuration/_fadeDuration 保持同步)
float wait = _toast != null ? _toast.TotalTime + 0.1f : 3.6f;
yield return new WaitForSecondsRealtime(wait);
// _toast 为 null 时直接终止队列(不能显示,也无需等待)
if (_toast == null) break;
_toast.Show(title, body, icon);
// 使用缓存的等待对象_toast 为 null 时已在上方 break
if (_queueWait == null)
_queueWait = new WaitForSecondsRealtime(_toast.TotalTime + 0.1f);
yield return _queueWait;
}
_showing = false;
}

View File

@@ -1,7 +1,9 @@
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Equipment;
namespace BaseGames.UI
{
@@ -11,37 +13,67 @@ namespace BaseGames.UI
/// </summary>
public class ToolHUD : MonoBehaviour
{
[SerializeField] private ToolSlotUI[] _slots; // 2 个 ToolSlotUI 组件
[SerializeField] private BaseGames.Equipment.ToolSlotManager _slotManager;
[SerializeField] private ToolUsedEventChannelSO _onToolUsed;
[SerializeField] private ToolSlotUI[] _slots; // 2 个 ToolSlotUI 组件
[SerializeField] private ToolUsedEventChannelSO _onToolUsed;
// 通过 ServiceLocator 解析:避免 UI 直接持有 ToolSlotManager 具体类型引用
private IToolSlotService _slotManager;
private readonly CompositeDisposable _subs = new();
// 每个槽独立的冷却追踪协程;仅在冷却期间运行,避免每帧全槽轮询
private Coroutine[] _cooldownCoroutines;
private void Awake()
{
_cooldownCoroutines = _slots != null
? new Coroutine[_slots.Length]
: System.Array.Empty<Coroutine>();
}
private void OnEnable()
{
_slotManager = ServiceLocator.GetOrDefault<IToolSlotService>();
_onToolUsed?.Subscribe(RefreshSlot).AddTo(_subs);
}
private void OnDisable()
{
_subs.Clear();
StopAllCoroutines();
if (_cooldownCoroutines != null)
for (int i = 0; i < _cooldownCoroutines.Length; i++)
_cooldownCoroutines[i] = null;
}
private void RefreshSlot(ToolUsedPayload payload)
{
int i = payload.SlotIndex;
if (_slots == null || i < 0 || i >= _slots.Length) return;
if (_slotManager == null) return; // ToolSlotManager 尚未注册
_slots[i].Refresh(
_slotManager.GetTool(i),
_slotManager.GetRemainingUses(i),
_slotManager.GetCooldownRatio(i));
// 启动(或重启)该槽的冷却追踪协程
if (_cooldownCoroutines[i] != null) StopCoroutine(_cooldownCoroutines[i]);
_cooldownCoroutines[i] = StartCoroutine(TrackCooldown(i));
}
private void Update()
/// <summary>
/// 仅在该槽冷却未结束时每帧更新填充值;冷却归零后自动停止,不消耗额外 CPU。
/// </summary>
private System.Collections.IEnumerator TrackCooldown(int slotIndex)
{
if (_slots == null || _slotManager == null) return;
for (int i = 0; i < _slots.Length; i++)
_slots[i].SetCooldownFill(_slotManager.GetCooldownRatio(i));
while (_slotManager != null && _slotManager.GetCooldownRatio(slotIndex) > 0f)
{
_slots[slotIndex].SetCooldownFill(_slotManager.GetCooldownRatio(slotIndex));
yield return null;
}
// 冷却结束,确保填充归零
if (slotIndex < _slots.Length)
_slots[slotIndex].SetCooldownFill(0f);
_cooldownCoroutines[slotIndex] = null;
}
}

View File

@@ -5,16 +5,33 @@ using BaseGames.Core.Events;
namespace BaseGames.UI
{
[DefaultExecutionOrder(+50)]
public class UIManager : MonoBehaviour
/// <summary>
/// 面板 ID 枚举。新增面板时只需在此添加值并在 Inspector 的 _panels 数组中注册,
/// 无需修改 UIManager 的其他代码,满足开闭原则。
/// </summary>
public enum PanelId
{
[Header("Canvas Roots")]
Pause,
Settings,
Map,
Shop,
CharmPanel,
SpellSelect,
}
[DefaultExecutionOrder(+50)]
public class UIManager : MonoBehaviour, IUIManager
{
// ── 状态驱动根节点(不进入面板栈,仅根据 GameState 显示/隐藏)────────
[Header("状态驱动根节点(非面板栈)")]
[SerializeField] private GameObject _hudRoot;
[SerializeField] private GameObject _pauseMenuRoot;
[SerializeField] private GameObject _deathScreenRoot;
[SerializeField] private GameObject _settingsRoot;
[SerializeField] private GameObject _mapRoot;
[SerializeField] private GameObject _shopRoot;
// ── 面板栈注册表 ──────────────────────────────────────────────────────
[Header("面板栈注册表Inspector 配置,可运行时扩展)")]
[Tooltip("将 PanelId 与对应的根 GameObject 绑定。" +
"新增面板只需在此添加一行,无需修改 UIManager 代码。")]
[SerializeField] private PanelRegistration[] _panels;
[Header("Event Channels")]
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
@@ -22,27 +39,68 @@ namespace BaseGames.UI
[SerializeField] private VoidEventChannelSO _onFastTravelOpen;
[SerializeField] private StringEventChannelSO _onShopOpen;
[SerializeField] private VoidEventChannelSO _onMapOpen;
[SerializeField] private VoidEventChannelSO _onCharmPanelOpen;
[SerializeField] private VoidEventChannelSO _onSpellSelectOpen;
private readonly Stack<GameObject> _panelStack = new();
private readonly CompositeDisposable _subs = new();
// ── 面板栈结构 ────────────────────────────────────────────────────────
private readonly Stack<GameObject> _panelStack = new();
/// <summary>O(1) 成员判断,与 _panelStack 保持同步,替代 Stack.Contains O(n)。</summary>
private readonly HashSet<GameObject> _openPanelSet = new();
private readonly Dictionary<PanelId, GameObject> _panelRegistry = new();
private readonly CompositeDisposable _subs = new();
// ── 序列化辅助结构 ────────────────────────────────────────────────────
[System.Serializable]
private struct PanelRegistration
{
[Tooltip("面板标识符(与代码中的 PanelId 枚举对应)。")]
public PanelId id;
[Tooltip("该面板的根 GameObject通常是 Canvas 的直接子节点)。")]
public GameObject root;
}
// ── 生命周期 ──────────────────────────────────────────────────────────
private void Awake()
{
if (_panels != null)
foreach (var p in _panels)
if (p.root != null) _panelRegistry[p.id] = p.root;
}
private void OnEnable()
{
ServiceLocator.Register<IUIManager>(this);
_onGameStateChanged?.Subscribe(HandleGameStateChanged).AddTo(_subs);
_onPauseRequested?.Subscribe(TogglePause).AddTo(_subs);
_onFastTravelOpen?.Subscribe(OpenMap).AddTo(_subs);
_onShopOpen?.Subscribe(OpenShop).AddTo(_subs);
_onMapOpen?.Subscribe(OpenMap).AddTo(_subs);
_onCharmPanelOpen?.Subscribe(OpenCharmPanel).AddTo(_subs);
_onSpellSelectOpen?.Subscribe(OpenSpellSelect).AddTo(_subs);
}
private void OnDisable()
{
ServiceLocator.Unregister<IUIManager>(this);
_subs.Clear();
}
// ── 面板注册(运行时动态扩展入口)────────────────────────────────────
/// <summary>
/// 运行时注册或覆盖面板绑定(如场景加载后动态添加的面板)。
/// Inspector 中已配置的面板无需调用此方法。
/// </summary>
public void RegisterPanel(PanelId id, GameObject root)
{
if (root != null) _panelRegistry[id] = root;
}
// ── 状态响应 ──────────────────────────────────────────────────────────
private void HandleGameStateChanged(GameStateId state)
{
// GameStateId 是 struct用 if/else 而非 switch
bool showHud = state == GameStates.Gameplay || state == GameStates.BossFight;
if (_hudRoot != null) _hudRoot.SetActive(showHud);
@@ -52,7 +110,6 @@ namespace BaseGames.UI
}
else
{
// 离开 Dead 状态时(复活/重生)隐藏死亡界面
if (_deathScreenRoot != null) _deathScreenRoot.SetActive(false);
if (state == GameStates.Cutscene)
@@ -60,29 +117,101 @@ namespace BaseGames.UI
}
}
// ── 面板栈 API ────────────────────────────────────────────────────────
/// <summary>通过 ID 打开已注册的面板。</summary>
public void OpenPanel(PanelId id)
{
if (_panelRegistry.TryGetValue(id, out var panel))
OpenPanel(panel);
}
/// <summary>打开指定 GameObject 面板并压栈已在栈中则忽略O(1) 判断)。</summary>
public void OpenPanel(GameObject panel)
{
if (panel == null) return;
if (!_openPanelSet.Add(panel)) return; // HashSet.Add 返回 false = 已存在
if (_panelStack.Count > 0) _panelStack.Peek().SetActive(false);
panel.SetActive(true);
_panelStack.Push(panel);
}
/// <summary>关闭栈顶面板并恢复上一层(如有);上一层若实现 IFocusable 则自动恢复焦点。</summary>
public void CloseTopPanel()
{
if (_panelStack.Count == 0) return;
_panelStack.Pop().SetActive(false);
if (_panelStack.Count > 0) _panelStack.Peek().SetActive(true);
var top = _panelStack.Pop();
_openPanelSet.Remove(top);
top.SetActive(false);
if (_panelStack.Count > 0)
{
var restored = _panelStack.Peek();
restored.SetActive(true);
restored.GetComponent<IFocusable>()?.OnFocusRestored();
}
}
// ── 快捷事件回调 ──────────────────────────────────────────────────────
private void TogglePause()
{
if (_pauseMenuRoot != null && _pauseMenuRoot.activeSelf)
if (_panelRegistry.TryGetValue(PanelId.Pause, out var pausePanel)
&& _panelStack.Count > 0 && _panelStack.Peek() == pausePanel)
CloseTopPanel();
else
OpenPanel(_pauseMenuRoot);
OpenPanel(PanelId.Pause);
}
private void OpenShop(string _) => OpenPanel(_shopRoot);
private void OpenMap() => OpenPanel(_mapRoot);
private void OpenShop(string _) => OpenPanel(PanelId.Shop);
private void OpenMap() => OpenPanel(PanelId.Map);
private void OpenCharmPanel() => OpenPanel(PanelId.CharmPanel);
private void OpenSpellSelect() => OpenPanel(PanelId.SpellSelect);
// ── 编辑器工具 (不入构建) ──────────────────────────────────────────────────
/// <summary>验证面板注册表是否完整、无重复、无空引用。</summary>
[ContextMenu("验证面板注册表")]
private void EditorValidateRegistry()
{
if (_panels == null || _panels.Length == 0)
{
Debug.LogWarning("[UIManager] 面板注册表为空!", this);
return;
}
var seen = new System.Collections.Generic.HashSet<PanelId>();
bool ok = true;
foreach (var p in _panels)
{
if (p.root == null)
{
Debug.LogWarning($"[UIManager] PanelId.{p.id} 的 GameObject 引用为 null", this);
ok = false;
}
if (!seen.Add(p.id))
{
Debug.LogError($"[UIManager] PanelId.{p.id} 重复注册!", this);
ok = false;
}
}
if (ok)
Debug.Log($"[UIManager] 验证通过 ✔ 已注册 {_panels.Length} 个面板。", this);
}
#if UNITY_EDITOR
/// <summary>仅供 UIManagerEditor 实时可视化面板栈(由栈顶到栈底顺序)。</summary>
public GameObject[] EditorGetPanelSnapshot() => _panelStack.ToArray();
#endif
[ContextMenu("测试:打开 Pause 面板")]
private void EditorOpenPause() => OpenPanel(PanelId.Pause);
[ContextMenu("测试:打开 Map 面板")]
private void EditorOpenMap() => OpenPanel(PanelId.Map);
[ContextMenu("测试:打开 Shop 面板")]
private void EditorOpenShop() => OpenPanel(PanelId.Shop);
[ContextMenu("测试:关闭栈顶面板")]
private void EditorCloseTop() => CloseTopPanel();
}
}