UI系统优化
This commit is contained in:
@@ -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:
|
||||
Reference in New Issue
Block a user