UI系统优化
This commit is contained in:
@@ -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,
|
||||
|
||||
233
Assets/_Game/Scripts/UI/CharmEquipPanel.cs
Normal file
233
Assets/_Game/Scripts/UI/CharmEquipPanel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/CharmEquipPanel.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/CharmEquipPanel.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 26f73e5e3a219384fa612e0cb5cd3646
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ── 动画协程 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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")]
|
||||
|
||||
223
Assets/_Game/Scripts/UI/HUD/QuestTrackerWidget.cs
Normal file
223
Assets/_Game/Scripts/UI/HUD/QuestTrackerWidget.cs
Normal 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_QuestStarted:payload = questId")]
|
||||
[SerializeField] private StringEventChannelSO _onQuestStarted;
|
||||
[Tooltip("EVT_QuestCompleted:payload = questId")]
|
||||
[SerializeField] private StringEventChannelSO _onQuestCompleted;
|
||||
[Tooltip("EVT_QuestFailed:payload = questId")]
|
||||
[SerializeField] private StringEventChannelSO _onQuestFailed;
|
||||
[Tooltip("EVT_QuestAbandoned:payload = questId")]
|
||||
[SerializeField] private StringEventChannelSO _onQuestAbandoned;
|
||||
[Tooltip("EVT_QuestReadyToComplete:payload = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/HUD/QuestTrackerWidget.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/HUD/QuestTrackerWidget.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ece7e5ce8a9d10145977db8d593d9523
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
126
Assets/_Game/Scripts/UI/HUD/SpellSlotWidget.cs
Normal file
126
Assets/_Game/Scripts/UI/HUD/SpellSlotWidget.cs
Normal 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 = CooldownFraction,0 = 冷却完毕,1 = 刚施放)
|
||||
///
|
||||
/// 图标刺新:订阅 ISpellService.OnSpellChanged 事件,装备切换实时响应(零延迟)。
|
||||
/// 冷却进度:协程专职追踪,冷却中每帧更新,待机时每 0.1 秒轮询。
|
||||
///
|
||||
/// Inspector 必填:
|
||||
/// _iconImage — 法术图标 Image
|
||||
/// _cooldownFill — 覆盖在图标上的冷却遮罩 Image(fillMethod = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/HUD/SpellSlotWidget.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/HUD/SpellSlotWidget.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7cc3bde860ef57e4e9a86d2fcdaab753
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
185
Assets/_Game/Scripts/UI/HUD/StatusEffectHUD.cs
Normal file
185
Assets/_Game/Scripts/UI/HUD/StatusEffectHUD.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/HUD/StatusEffectHUD.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/HUD/StatusEffectHUD.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2236c2626495bc0428fcf39ed77446df
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
19
Assets/_Game/Scripts/UI/IFocusable.cs
Normal file
19
Assets/_Game/Scripts/UI/IFocusable.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/IFocusable.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/IFocusable.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f712e5032b3d73746be69ad383ee0a2e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -25,6 +25,12 @@ namespace BaseGames.UI
|
||||
/// </summary>
|
||||
string GetActionEffectivePath(string actionName);
|
||||
|
||||
/// <summary>
|
||||
/// 根据固定绑定路径(如 "<Keyboard>/space")在当前图标集中查找图标。
|
||||
/// 不受改键影响,适合装饰性按键说明。找不到时返回 null。
|
||||
/// </summary>
|
||||
Sprite GetPathIcon(string bindingPath);
|
||||
|
||||
/// <summary>
|
||||
/// 当设备切换或玩家改键后触发。
|
||||
/// 订阅此事件的 UI 组件应在回调中刷新图标显示。
|
||||
|
||||
23
Assets/_Game/Scripts/UI/IUIManager.cs
Normal file
23
Assets/_Game/Scripts/UI/IUIManager.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/IUIManager.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/IUIManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f5bd955e97878e34f8eb966ee57257fd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
|
||||
// ── 静态注册表:替换 FindObjectsByType,O(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; // 图标集访问须通过 InputIconService,ByBindingPath 模式已列入低优先级
|
||||
// 使用固定路径在当前图标集查找(不随改键变化),适合装饰性按键说明
|
||||
sprite = _iconService?.GetPathIcon(_bindingPath);
|
||||
}
|
||||
|
||||
if (sprite != null)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ── 按钮回调 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
220
Assets/_Game/Scripts/UI/SkillTreePanel.cs
Normal file
220
Assets/_Game/Scripts/UI/SkillTreePanel.cs
Normal 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 — IntEventChannelSO,FormController 广播当前形态 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("灵技 1(Y / Triangle / 手柄按钮)")]
|
||||
public FormSkillSO spiritSkill1;
|
||||
[Tooltip("灵技 2(Shift+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/SkillTreePanel.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/SkillTreePanel.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e02c693dae6d9841881391d58327b4d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user