UI系统优化

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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