摄像机区域的架构改动

This commit is contained in:
2026-05-15 14:47:24 +08:00
parent 1b37297585
commit f264329751
3591 changed files with 1687228 additions and 446503 deletions

View File

@@ -0,0 +1,24 @@
{
"excludePlatforms": [],
"allowUnsafeCode": false,
"precompiledReferences": [],
"name": "BaseGames.UI",
"defineConstraints": [],
"noEngineReferences": false,
"versionDefines": [],
"rootNamespace": "BaseGames.UI",
"references": [
"BaseGames.Core",
"BaseGames.Core.Events",
"BaseGames.Core.Save",
"BaseGames.Input",
"BaseGames.Combat",
"BaseGames.Localization",
"Unity.TextMeshPro",
"Unity.InputSystem",
"BaseGames.Equipment"
],
"autoReferenced": true,
"overrideReferences": false,
"includePlatforms": []
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 227d8c9f56b569340aed5e35153e22a6
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,163 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;
using BaseGames.Core.Events;
using BaseGames.Combat;
namespace BaseGames.UI
{
/// <summary>
/// 伤害飘字(架构 10_UIModule §10
/// 从对象池取出后由 FloatingDamageSpawner 调用 Show();协程完成后归还。
/// 挂在预制体根 GameObject 上Canvas_HUD 子节点World Space Canvas 或 Screen Space
/// </summary>
public class FloatingDamageText : MonoBehaviour
{
[SerializeField] private TMP_Text _text;
[SerializeField] private float _floatDistance = 1.5f;
[SerializeField] private float _duration = 0.8f;
/// <summary>
/// 父级 Canvas用于 RectTransformUtility 坐标转换)。
/// 适配所有 Canvas 渲染模式Overlay / Camera / World Space
/// Screen Space - Overlay 时可传 null会自动 fallback 到 null camera
/// </summary>
[SerializeField] private Canvas _parentCanvas;
private RectTransform _rectTransform;
private Coroutine _animCoroutine;
private void Awake()
{
_rectTransform = (RectTransform)transform;
// 不在 Awake 缓存 Camera.main避免 Boss 过场切换主摄像机后引用过期
}
/// <summary>
/// 在世界坐标位置显示伤害数字并开始飘动动画。
/// 动画结束后由对象池回收Deactivate
/// </summary>
public void Show(Vector2 worldPosition, int damage, DamageType type)
{
if (_animCoroutine != null) StopCoroutine(_animCoroutine);
_text.text = damage.ToString();
_text.color = GetColorForType(type);
SetAnchoredPosition(worldPosition);
_animCoroutine = StartCoroutine(FloatAndFade(worldPosition));
}
private void SetAnchoredPosition(Vector2 worldPosition)
{
var cam = (_parentCanvas != null && _parentCanvas.renderMode == RenderMode.ScreenSpaceCamera)
? _parentCanvas.worldCamera
: UnityEngine.Camera.main;
var screenPoint = cam != null
? (Vector2)cam.WorldToScreenPoint(worldPosition)
: Vector2.zero;
var canvasRect = _parentCanvas != null
? (RectTransform)_parentCanvas.transform
: null;
if (canvasRect != null)
{
RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvasRect, screenPoint, cam, out var localPoint);
_rectTransform.anchoredPosition = localPoint;
}
else
{
_rectTransform.anchoredPosition = screenPoint;
}
}
private IEnumerator FloatAndFade(Vector2 startWorld)
{
float elapsed = 0f;
var color = _text.color;
var startAlpha = color.a;
while (elapsed < _duration)
{
float t = elapsed / _duration;
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)));
elapsed += Time.deltaTime;
yield return null;
}
gameObject.SetActive(false); // 归还对象池
}
private static Color GetColorForType(DamageType type) => type switch
{
DamageType.Fire => new Color(1f, 0.5f, 0f), // 橙
DamageType.Poison => new Color(0.3f,0.9f, 0.3f), // 绿
DamageType.True => new Color(1f, 0.95f,0.4f), // 黄
DamageType.Ice => new Color(0.5f,0.85f,1f), // 冰蓝
DamageType.Lightning=>new Color(0.9f,0.9f, 0.2f), // 闪黄
_ => Color.white
};
}
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// 伤害飘字生成器(挂在 Canvas_HUD 上)。
/// 订阅 EVT_DamageDealt 事件频道,从 Addressables 池取出 FloatingDamageText 显示。
/// </summary>
public class FloatingDamageSpawner : MonoBehaviour
{
[Header("事件频道")]
[SerializeField] private DamageInfoEventChannelSO _onDamageDealt;
[Header("预制体(对象池 key = AddressKeys.PrefabUIFloatingDmgText")]
[SerializeField] private GameObject _floatingDmgPrefab; // FallbackInspector 直接拖入
private readonly Queue<FloatingDamageText> _pool = new();
private readonly CompositeDisposable _subs = new();
private void OnEnable() => _onDamageDealt?.Subscribe(OnDamageDealt).AddTo(_subs);
private void OnDisable() => _subs.Clear();
private void OnDamageDealt(DamageInfo info)
{
if (info.FinalDamage <= 0) return;
var text = GetOrCreate();
if (text != null)
text.Show(info.SourcePosition, info.FinalDamage, info.Type);
}
private FloatingDamageText GetOrCreate()
{
// 从池中找到已停用的实例
while (_pool.Count > 0)
{
var pooled = _pool.Dequeue();
if (pooled == null) continue;
if (!pooled.gameObject.activeSelf)
{
pooled.gameObject.SetActive(true);
return pooled;
}
_pool.Enqueue(pooled); // 仍在使用,放回
break;
}
// 没有可用实例则实例化
if (_floatingDmgPrefab == null) return null;
var go = Instantiate(_floatingDmgPrefab, transform);
var comp = go.GetComponent<FloatingDamageText>();
if (comp != null) _pool.Enqueue(comp);
return comp;
}
}
}

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c9ae9298ea861bc47b2e8d8d746943d5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,110 @@
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Core.Events;
namespace BaseGames.UI
{
/// <summary>
/// Boss 血条 UI架构 10_UIModule §4
/// 默认隐藏Boss 战开始时从屏幕底部滑入。
/// 阶段标记点由 BossHPMax 与 PhaseThreshold 共同决定(此处由外部广播 EVT_BossHPMaxSet 设置)。
/// </summary>
public class BossHPBar : MonoBehaviour
{
[SerializeField] private TMP_Text _bossNameText;
[SerializeField] private Image _hpFill;
[SerializeField] private Transform _phaseMarkersRoot;
[SerializeField] private GameObject _phaseMarkerPrefab;
[SerializeField] private float _slideDistance = 120f; // 滑入距离(像素)
[SerializeField] private float _slideDuration = 0.3f;
[Header("Event Channels")]
[SerializeField] private BoolEventChannelSO _onBossFightToggled; // true=开始, false=结束
[SerializeField] private IntEventChannelSO _onBossHPChanged;
[SerializeField] private StringEventChannelSO _onBossNameSet;
[SerializeField] private IntEventChannelSO _onBossHPMaxSet;
[SerializeField] private FloatEventChannelSO _onBossPhaseThreshold; // 每个阶段切换阈值0-1
private int _maxHP;
private RectTransform _rect;
private Vector2 _shownPos;
private Vector2 _hiddenPos;
private Coroutine _slideCoroutine;
private readonly CompositeDisposable _subs = new();
private void Awake()
{
_rect = (RectTransform)transform;
_shownPos = _rect.anchoredPosition;
_hiddenPos = _shownPos - new Vector2(0, _slideDistance);
_rect.anchoredPosition = _hiddenPos;
gameObject.SetActive(false);
}
private void OnEnable()
{
_onBossFightToggled?.Subscribe(OnBossFightToggled).AddTo(_subs);
_onBossHPChanged?.Subscribe(OnHPChanged).AddTo(_subs);
_onBossNameSet?.Subscribe(OnNameSet).AddTo(_subs);
_onBossHPMaxSet?.Subscribe(OnMaxSet).AddTo(_subs);
}
private void OnDisable() => _subs.Clear();
// ── 事件回调 ──────────────────────────────────────────────────────────
private void OnBossFightToggled(bool started)
{
if (_slideCoroutine != null) StopCoroutine(_slideCoroutine);
if (started)
{
gameObject.SetActive(true);
_slideCoroutine = StartCoroutine(SlideTo(_shownPos));
}
else
{
_slideCoroutine = StartCoroutine(SlideOut());
}
}
private void OnHPChanged(int hp)
{
if (_maxHP > 0) _hpFill.fillAmount = (float)hp / _maxHP;
}
private void OnNameSet(string bossName)
{
if (_bossNameText != null) _bossNameText.text = bossName;
}
private void OnMaxSet(int max)
{
_maxHP = max;
// 重建阶段标记(每次 BossHPMax 改变时清空并按已存阈值重建,此处简化为清空)
if (_phaseMarkersRoot != null)
foreach (Transform t in _phaseMarkersRoot) Destroy(t.gameObject);
}
// ── 动画协程 ──────────────────────────────────────────────────────────
private IEnumerator SlideTo(Vector2 target)
{
Vector2 start = _rect.anchoredPosition;
float t = 0;
while (t < _slideDuration)
{
_rect.anchoredPosition = Vector2.Lerp(start, target, t / _slideDuration);
t += Time.unscaledDeltaTime;
yield return null;
}
_rect.anchoredPosition = target;
}
private IEnumerator SlideOut()
{
yield return SlideTo(_hiddenPos);
gameObject.SetActive(false);
}
}
}

View File

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

View File

@@ -0,0 +1,130 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Core.Events;
namespace BaseGames.UI.HUD
{
public class HUDController : MonoBehaviour
{
[Header("HP")]
[SerializeField] private Transform _hpContainer;
[SerializeField] private GameObject _hpCellPrefab;
[Header("Gauges")]
[SerializeField] private Image _soulGaugeFill;
[SerializeField] private Image _spiritGaugeFill;
[SerializeField] private TMP_Text _lingZhuText;
[Header("Spring Charges")]
[SerializeField] private Transform _springContainer;
[SerializeField] private GameObject _springIconPrefab;
[Header("Form")]
[SerializeField] private Image[] _formIcons;
[Header("Interact Prompt")]
[SerializeField] private TMP_Text _interactText;
[SerializeField] private GameObject _interactPromptRoot;
[Header("Event Channels - Subscribe")]
[SerializeField] private IntEventChannelSO _onHPChanged;
[SerializeField] private IntEventChannelSO _onMaxHPChanged;
[SerializeField] private IntEventChannelSO _onSoulPowerChanged;
[SerializeField] private IntEventChannelSO _onSpiritPowerChanged;
[SerializeField] private IntEventChannelSO _onLingZhuChanged;
[SerializeField] private IntEventChannelSO _onSpringChargesChanged;
[SerializeField] private IntEventChannelSO _onFormChanged;
[SerializeField] private StringEventChannelSO _onShowInteractPrompt;
[SerializeField] private VoidEventChannelSO _onHideInteractPrompt;
private readonly List<GameObject> _hpCells = new();
private readonly List<GameObject> _springIcons = new();
private readonly CompositeDisposable _subs = new();
private void OnEnable()
{
_onHPChanged?.Subscribe(UpdateHP).AddTo(_subs);
_onMaxHPChanged?.Subscribe(RebuildHPCells).AddTo(_subs);
_onSoulPowerChanged?.Subscribe(UpdateSoul).AddTo(_subs);
_onSpiritPowerChanged?.Subscribe(UpdateSpirit).AddTo(_subs);
_onLingZhuChanged?.Subscribe(UpdateLingZhu).AddTo(_subs);
_onSpringChargesChanged?.Subscribe(RebuildSpringIcons).AddTo(_subs);
_onFormChanged?.Subscribe(UpdateFormIcon).AddTo(_subs);
_onShowInteractPrompt?.Subscribe(ShowInteractPrompt).AddTo(_subs);
_onHideInteractPrompt?.Subscribe(HideInteractPrompt).AddTo(_subs);
}
private void OnDisable() => _subs.Clear();
private void UpdateHP(int current)
{
for (int i = 0; i < _hpCells.Count; i++)
if (_hpCells[i] != null) _hpCells[i].SetActive(i < current);
}
private void RebuildHPCells(int max)
{
if (_hpContainer == null || _hpCellPrefab == null) return;
// 复用现有 Cell仅在数量不足时 Instantiate 补充,超出时 SetActive(false) 而非 Destroy
for (int i = 0; i < max; i++)
{
if (i < _hpCells.Count)
_hpCells[i].SetActive(true);
else
_hpCells.Add(Instantiate(_hpCellPrefab, _hpContainer));
}
for (int i = max; i < _hpCells.Count; i++)
if (_hpCells[i] != null) _hpCells[i].SetActive(false);
}
private void UpdateSoul(int val)
{
if (_soulGaugeFill != null) _soulGaugeFill.fillAmount = val / 100f;
}
private void UpdateSpirit(int val)
{
if (_spiritGaugeFill != null) _spiritGaugeFill.fillAmount = val / 100f;
}
private void UpdateLingZhu(int val)
{
if (_lingZhuText != null) _lingZhuText.text = val.ToString();
}
private void RebuildSpringIcons(int charges)
{
if (_springContainer == null || _springIconPrefab == null) return;
// 复用已有图标,超出数量时 SetActive(false)
for (int i = 0; i < charges; i++)
{
if (i < _springIcons.Count)
_springIcons[i].SetActive(true);
else
_springIcons.Add(Instantiate(_springIconPrefab, _springContainer));
}
for (int i = charges; i < _springIcons.Count; i++)
if (_springIcons[i] != null) _springIcons[i].SetActive(false);
}
private void UpdateFormIcon(int formIndex)
{
if (_formIcons == null) return;
for (int i = 0; i < _formIcons.Length; i++)
if (_formIcons[i] != null) _formIcons[i].enabled = (i == formIndex);
}
private void ShowInteractPrompt(string text)
{
if (_interactText != null) _interactText.text = text;
if (_interactPromptRoot != null) _interactPromptRoot.SetActive(true);
}
private void HideInteractPrompt()
{
if (_interactPromptRoot != null) _interactPromptRoot.SetActive(false);
}
}
}

View File

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

View File

@@ -0,0 +1,31 @@
using UnityEngine;
namespace BaseGames.UI
{
/// <summary>
/// 输入设备图标集 SO架构 10_UIModule §12
/// 存储一套设备(键鼠 or 手柄)所有按键对应的 Sprite。
/// 由 InputDeviceIconSwitcher 根据当前设备选择正确的图标集。
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/UI/Input Device Icon Set", fileName = "InputDeviceIconSetSO")]
public class InputDeviceIconSetSO : ScriptableObject
{
[System.Serializable]
public struct IconEntry
{
public string BindingPath; // InputSystem binding pathe.g. "<Keyboard>/space"
public Sprite Icon;
}
[SerializeField] private IconEntry[] _entries;
/// <summary>根据 binding path 查找对应图标;未找到返回 null。</summary>
public Sprite GetIcon(string bindingPath)
{
if (_entries == null) return null;
foreach (var entry in _entries)
if (entry.BindingPath == bindingPath) return entry.Icon;
return null;
}
}
}

View File

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

View File

@@ -0,0 +1,68 @@
using UnityEngine;
using UnityEngine.UI;
using BaseGames.Core.Events;
namespace BaseGames.UI
{
/// <summary>
/// 输入设备图标切换器(架构 10_UIModule §12
/// 订阅 EVT_InputDeviceChangedBoolEventChannelSOtrue = 手柄false = 键鼠),
/// 切换后广播给场景内所有 InputIconImage 组件。
/// 通常挂在 UIRoot 或 UIManager 同一 GameObject 上。
/// </summary>
public class InputDeviceIconSwitcher : MonoBehaviour
{
[SerializeField] private InputDeviceIconSetSO _kbIconSet;
[SerializeField] private InputDeviceIconSetSO _padIconSet;
[Header("Event Channel")]
[SerializeField] private BoolEventChannelSO _onDeviceChanged; // EVT_InputDeviceChanged
public static InputDeviceIconSetSO Current { get; private set; }
private readonly CompositeDisposable _subs = new();
private void Awake() { Current = _kbIconSet; }
private void OnEnable() => _onDeviceChanged?.Subscribe(SwitchIconSet).AddTo(_subs);
private void OnDisable() => _subs.Clear();
private void SwitchIconSet(bool isGamepad)
{
Current = isGamepad ? _padIconSet : _kbIconSet;
// 通知场景内所有图标 Image 刷新(包括非本对象子节点的其他 Canvas 区域)
foreach (var img in FindObjectsByType<InputIconImage>(FindObjectsInactive.Include, FindObjectsSortMode.None))
img.Refresh();
}
}
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// 单个按键图标 Image 组件。
/// 记录 bindingPath由 InputDeviceIconSwitcher 切换时自动刷新。
/// </summary>
[RequireComponent(typeof(Image))]
public class InputIconImage : MonoBehaviour
{
[Tooltip("InputSystem 绑定路径,如 <Keyboard>/space 或 <Gamepad>/buttonSouth")]
[SerializeField] private string _bindingPath;
private Image _image;
private void Awake() => _image = GetComponent<Image>();
private void Start() => Refresh();
public void Refresh()
{
if (_image == null || string.IsNullOrEmpty(_bindingPath)) return;
var set = InputDeviceIconSwitcher.Current;
if (set == null) return;
var sprite = set.GetIcon(_bindingPath);
if (sprite != null)
{
_image.sprite = sprite;
_image.enabled = true;
}
}
}
}

View File

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

View File

@@ -0,0 +1,60 @@
using System.Collections;
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.UI
{
/// <summary>
/// 全屏黑幕渐入渐出遮罩(架构 10_UIModule §8
/// 由 SceneLoader 通过 EVT_LoadingOverlay BoolEventChannelSO 触发。
/// true = 淡入遮挡场景false = 淡出(显示场景)。
/// </summary>
[RequireComponent(typeof(CanvasGroup))]
public class LoadingOverlay : MonoBehaviour
{
[SerializeField] private CanvasGroup _canvasGroup;
[SerializeField] private float _fadeDuration = 0.3f;
[Header("Event Channel")]
[SerializeField] private BoolEventChannelSO _onLoadingOverlayRequested; // EVT_LoadingOverlay
private Coroutine _fadeCoroutine;
private readonly CompositeDisposable _subs = new();
private void Awake()
{
if (_canvasGroup == null)
_canvasGroup = GetComponent<CanvasGroup>();
// 初始状态:完全透明,不阻挡射线
_canvasGroup.alpha = 0f;
_canvasGroup.blocksRaycasts = false;
}
private void OnEnable() => _onLoadingOverlayRequested?.Subscribe(SetVisible).AddTo(_subs);
private void OnDisable() => _subs.Clear();
public void SetVisible(bool visible)
{
if (_fadeCoroutine != null) StopCoroutine(_fadeCoroutine);
_fadeCoroutine = StartCoroutine(FadeCoroutine(visible ? 1f : 0f));
}
private IEnumerator FadeCoroutine(float target)
{
float start = _canvasGroup.alpha;
float elapsed = 0f;
// 遮挡时立刻阻挡射线,让开时完成后再放开
if (target > 0.5f) _canvasGroup.blocksRaycasts = true;
while (elapsed < _fadeDuration)
{
_canvasGroup.alpha = Mathf.Lerp(start, target, elapsed / _fadeDuration);
elapsed += Time.unscaledDeltaTime;
yield return null;
}
_canvasGroup.alpha = target;
_canvasGroup.blocksRaycasts = target > 0.5f;
}
}
}

View File

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

View File

@@ -0,0 +1,79 @@
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Core.Events;
using BaseGames.Localization;
namespace BaseGames.UI
{
/// <summary>
/// 全屏加载界面:进度条 + 提示文字 + 随机背景图(架构 10_UIModule §7.7)。
/// </summary>
public class LoadingScreenManager : MonoBehaviour
{
[SerializeField] private GameObject _loadingRoot;
[SerializeField] private Image _progressFill;
[SerializeField] private TMP_Text _tipText;
[SerializeField] private Image[] _backgroundArts;
[SerializeField] private string[] _tipMessages; // 本地化 key对应 "UI" 表中的条目,如 "tip_explore"
[SerializeField] private float _minDisplayTime = 0.5f;
[Header("Event Channels")]
[SerializeField] private VoidEventChannelSO _onLoadingStarted;
[SerializeField] private VoidEventChannelSO _onLoadingComplete;
[SerializeField] private FloatEventChannelSO _onLoadingProgressUpdated;
private float _shownAt;
private readonly CompositeDisposable _subs = new();
private void OnEnable()
{
_onLoadingStarted?.Subscribe(Show).AddTo(_subs);
_onLoadingComplete?.Subscribe(Hide).AddTo(_subs);
_onLoadingProgressUpdated?.Subscribe(SetProgress).AddTo(_subs);
}
private void OnDisable()
{
_subs.Clear();
}
// ── 公开 APISceneLoader 可直接调用)────────────────────────────────
public void Show()
{
_shownAt = Time.unscaledTime;
if (_loadingRoot != null) _loadingRoot.SetActive(true);
if (_progressFill != null) _progressFill.fillAmount = 0f;
// 随机背景
if (_backgroundArts != null && _backgroundArts.Length > 0)
{
foreach (var bg in _backgroundArts) if (bg != null) bg.enabled = false;
_backgroundArts[Random.Range(0, _backgroundArts.Length)].enabled = true;
}
// 随机提示(通过 LocalizationManager 解析 key
if (_tipText != null && _tipMessages != null && _tipMessages.Length > 0)
_tipText.text = LocalizationManager.Get(_tipMessages[Random.Range(0, _tipMessages.Length)], "UI");
}
public void Hide() => StartCoroutine(HideAfterMinTime());
public void SetProgress(float value)
{
if (_progressFill != null)
_progressFill.fillAmount = Mathf.Clamp01(value);
}
// ── 内部 ─────────────────────────────────────────────────────────────
private IEnumerator HideAfterMinTime()
{
float elapsed = Time.unscaledTime - _shownAt;
if (elapsed < _minDisplayTime)
yield return new WaitForSecondsRealtime(_minDisplayTime - elapsed);
if (_loadingRoot != null) _loadingRoot.SetActive(false);
}
}
}

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: cce9781f030e49648ba1939ca3cfdaa5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,51 @@
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Core.Events;
namespace BaseGames.UI.Menus
{
public class DeathScreenController : MonoBehaviour
{
[SerializeField] private TMP_Text _deathMessage;
[SerializeField] private Button _btnRespawn;
[Header("Event Channels")]
[SerializeField] private VoidEventChannelSO _onDeathScreenConfirmed;
private void OnEnable()
{
// 死亡界面由 UIManager 在游戏状态变为 Dead 时通过 SetActive(true) 激活。
// _onPlayerDied 事件此时已经触发完毕,订阅它不会收到回调。
// 直接在 OnEnable 启动延迟显示协程即可保证 1.5s 缓冲。
StartCoroutine(ShowAfterDelay(1.5f));
}
private void OnDisable()
{
StopAllCoroutines();
}
private IEnumerator ShowAfterDelay(float delay)
{
yield return new WaitForSeconds(delay);
Show();
}
private void Show()
{
if (_btnRespawn != null)
{
_btnRespawn.onClick.RemoveAllListeners();
_btnRespawn.onClick.AddListener(Confirm);
}
}
private void Confirm()
{
gameObject.SetActive(false);
_onDeathScreenConfirmed?.Raise();
}
}
}

View File

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

View File

@@ -0,0 +1,59 @@
using UnityEngine;
using UnityEngine.UI;
using BaseGames.Core.Events;
namespace BaseGames.UI
{
/// <summary>
/// 暂停菜单控制器(架构 10_UIModule §5
/// 挂载在 Canvas_Menu → PauseMenuPanel GameObject 上。
/// 按钮绑定在 Awake 中完成;由 UIManager 负责面板开关。
/// </summary>
public class PauseMenuController : MonoBehaviour
{
[SerializeField] private UIManager _uiManager;
[SerializeField] private GameObject _settingsRoot; // SettingsPanel 根 GameObject
[Header("按钮引用")]
[SerializeField] private Button _btnResume;
[SerializeField] private Button _btnSettings;
[SerializeField] private Button _btnMainMenu;
[SerializeField] private Button _btnQuit;
[Header("Event Channels")]
[SerializeField] private VoidEventChannelSO _onResumeRequested;
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
private void Awake()
{
_btnResume.onClick.AddListener(Resume);
_btnSettings.onClick.AddListener(OpenSettings);
_btnMainMenu.onClick.AddListener(GoToMainMenu);
_btnQuit.onClick.AddListener(Application.Quit);
}
// ── 按钮回调 ──────────────────────────────────────────────────────────
private void Resume()
{
_onResumeRequested?.Raise();
_uiManager.CloseTopPanel();
}
private void OpenSettings()
{
if (_settingsRoot != null)
_uiManager.OpenPanel(_settingsRoot);
}
private void GoToMainMenu()
{
_uiManager.CloseTopPanel();
_onSceneLoadRequest?.Raise(new SceneLoadRequest
{
SceneName = "MainMenu",
ShowLoadingScreen = true
});
}
}
}

View File

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

View File

@@ -0,0 +1,147 @@
using System;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
namespace BaseGames.UI.Menus
{
/// <summary>
/// 驱动主菜单存档槽选择面板(新游戏 / 继续 / 删除)。
/// </summary>
public class SaveSlotController : MonoBehaviour
{
[SerializeField] private SaveSlotUI[] _slotUIs; // 存档槽 UI数量由 Inspector 决定)
[SerializeField] private GameSaveManager _saveManager;
[Header("Event Channels")]
[SerializeField] private IntEventChannelSO _onSlotConfirmed; // 携带槽索引,供 GameManager 监听
private void OnEnable()
{
var task = RefreshAsync();
// 捕获 async Task 异常,避免 async void 吞掉未处理异常
task.ContinueWith(t =>
{
if (t.IsFaulted)
Debug.LogException(t.Exception?.InnerException ?? t.Exception, this);
}, TaskScheduler.FromCurrentSynchronizationContext());
}
private async Task RefreshAsync()
{
if (_saveManager == null) return;
for (int i = 0; i < _slotUIs.Length; i++)
{
if (_slotUIs[i] == null) continue;
var summary = await _saveManager.GetSlotSummaryAsync(i);
_slotUIs[i].Refresh(summary);
}
}
/// <summary>选中指定槽位(新局或继续)。由 SaveSlotUI 内部按钮调用。</summary>
public void OnSlotSelected(int slotIndex)
{
if (slotIndex < 0 || slotIndex >= _slotUIs.Length || _saveManager == null) return;
_ = SelectSlotAsync(slotIndex);
}
private async Task SelectSlotAsync(int slotIndex)
{
if (_saveManager.SlotExists(slotIndex))
await _saveManager.LoadAsync(slotIndex);
else
_saveManager.CreateSlot(slotIndex);
_onSlotConfirmed?.Raise(slotIndex);
}
/// <summary>删除指定槽位存档并刷新 UI。由 SaveSlotUI 内部按钮调用。</summary>
public void OnSlotDeleteRequested(int slotIndex)
{
if (slotIndex < 0 || slotIndex >= _slotUIs.Length || _saveManager == null) return;
_ = DeleteAndRefreshAsync(slotIndex);
}
private async Task DeleteAndRefreshAsync(int slotIndex)
{
await _saveManager.DeleteSlotAsync(slotIndex);
await RefreshAsync();
}
}
/// <summary>
/// 单个存档槽卡片组件,负责显示存档摘要或空槽提示。
/// </summary>
public class SaveSlotUI : MonoBehaviour
{
[SerializeField] private TMP_Text _playtimeText;
[SerializeField] private TMP_Text _regionText;
[SerializeField] private TMP_Text _lastSavedText;
[SerializeField] private Image _formIcon;
[SerializeField] private GameObject _emptyIndicator; // 空槽时显示的"新游戏"提示
[SerializeField] private GameObject _dataIndicator; // 有数据时显示的内容根
[SerializeField] private Button _selectButton;
[SerializeField] private Button _deleteButton;
private int _slotIndex;
private SaveSlotController _controller;
/// <summary>由 SaveSlotController 在 Awake 或初始化时调用以完成按钮绑定。</summary>
public void Init(int slotIndex, SaveSlotController controller)
{
_slotIndex = slotIndex;
_controller = controller;
if (_selectButton != null)
{
_selectButton.onClick.RemoveAllListeners();
_selectButton.onClick.AddListener(() => _controller.OnSlotSelected(_slotIndex));
}
if (_deleteButton != null)
{
_deleteButton.onClick.RemoveAllListeners();
_deleteButton.onClick.AddListener(() => _controller.OnSlotDeleteRequested(_slotIndex));
}
}
/// <summary>用摘要数据刷新显示summary 为 null 表示空槽。</summary>
public void Refresh(SlotSummary summary)
{
bool hasData = summary != null;
if (_emptyIndicator != null) _emptyIndicator.SetActive(!hasData);
if (_dataIndicator != null) _dataIndicator.SetActive(hasData);
if (_deleteButton != null) _deleteButton.gameObject.SetActive(hasData);
if (!hasData) return;
if (_playtimeText != null) _playtimeText.text = FormatPlaytime(summary.Playtime);
if (_regionText != null) _regionText.text = summary.SceneName ?? string.Empty;
if (_lastSavedText != null) _lastSavedText.text = FormatDateTime(summary.LastSaved);
}
private static string FormatPlaytime(float seconds)
{
int h = (int)(seconds / 3600);
int m = (int)((seconds % 3600) / 60);
int s = (int)(seconds % 60);
return $"{h:D2}:{m:D2}:{s:D2}";
}
private static string FormatDateTime(string iso8601)
{
if (string.IsNullOrEmpty(iso8601)) return string.Empty;
if (DateTime.TryParse(iso8601,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind,
out DateTime dt))
{
return dt.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
}
return iso8601;
}
}
}

View File

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

View File

@@ -0,0 +1,66 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using BaseGames.Core;
namespace BaseGames.UI
{
/// <summary>
/// 设置面板控制器(架构 10_UIModule §7
/// 驱动 SettingsManager 的音量与画面设置,并从当前配置初始化控件值。
/// </summary>
public class SettingsPanelController : MonoBehaviour
{
[SerializeField] private SettingsManager _settings;
[Header("音量滑条")]
[SerializeField] private Slider _masterVolume;
[SerializeField] private Slider _bgmVolume;
[SerializeField] private Slider _sfxVolume;
[SerializeField] private Slider _ambientVolume;
[Header("画面")]
[SerializeField] private Toggle _vSyncToggle;
[SerializeField] private TMP_Dropdown _fpsDropdown; // 30 / 60 / 120 / 无限
[Header("按键重绑定")]
[SerializeField] private GameObject _rebindPanelRoot; // RebindPanel GameObject
private static readonly int[] FpsOptions = { 30, 60, 120, -1 };
private void Start()
{
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));
InitSlider(_ambientVolume,data.AmbientVolume, v => _settings.SetAmbientVolume(v));
if (_vSyncToggle != null)
{
_vSyncToggle.isOn = data.VSync;
_vSyncToggle.onValueChanged.AddListener(v => _settings.SetVSync(v));
}
if (_fpsDropdown != null)
{
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)]));
}
}
// ── 辅助 ──────────────────────────────────────────────────────────────
private static void InitSlider(Slider slider, float value, UnityEngine.Events.UnityAction<float> onChange)
{
if (slider == null) return;
slider.value = value;
slider.onValueChanged.AddListener(onChange);
}
}
}

View File

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

View File

@@ -0,0 +1,59 @@
using System.Collections;
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.UI
{
/// <summary>
/// 右下角存档进行中提示(架构 10_UIModule §7.6)。
/// 订阅 EVT_SaveIndicatorVisibleBoolEventChannelSOtrue = 淡入false = 淡出。
/// </summary>
[RequireComponent(typeof(CanvasGroup))]
public class SaveIndicator : MonoBehaviour
{
[SerializeField] private CanvasGroup _cg;
[SerializeField] private float _fadeDuration = 0.2f;
[Header("Event Channel")]
[SerializeField] private BoolEventChannelSO _onSaveIndicatorVisible; // EVT_SaveIndicatorVisible
private Coroutine _fadeCoroutine;
private readonly CompositeDisposable _subs = new();
private void Awake()
{
if (_cg == null) _cg = GetComponent<CanvasGroup>();
_cg.alpha = 0f;
}
private void OnEnable()
{
_onSaveIndicatorVisible?.Subscribe(OnVisibilityChanged).AddTo(_subs);
}
private void OnDisable()
{
_subs.Clear();
}
private void OnVisibilityChanged(bool visible) => FadeTo(visible ? 1f : 0f);
private void FadeTo(float target)
{
if (_fadeCoroutine != null) StopCoroutine(_fadeCoroutine);
_fadeCoroutine = StartCoroutine(FadeCoroutine(target));
}
private IEnumerator FadeCoroutine(float target)
{
float start = _cg.alpha;
float elapsed = 0f;
while (elapsed < _fadeDuration)
{
_cg.alpha = Mathf.Lerp(start, target, elapsed / _fadeDuration);
elapsed += Time.unscaledDeltaTime;
yield return null;
}
_cg.alpha = target;
}
}
}

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 464ff8538aeccd2428ab6b7a2107ef46
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,113 @@
using System;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
using BaseGames.Input;
namespace BaseGames.UI.Settings
{
/// <summary>
/// 单行按键重绑定 UI架构 04_InputModule §6
/// 显示 Action 名称 + 当前绑定路径;点击按钮触发交互式重绑定。
/// 由 RebindPanel.Initialize() 批量配置;不直接加载任何资产。
/// </summary>
public class RebindActionRow : MonoBehaviour
{
[Tooltip("对应 Input Action 的名称(与 InputActions 资产中 Gameplay Map 内的名称一致)")]
[SerializeField] private string _actionName;
[Tooltip("绑定索引0 = 主绑定1 = 副绑定(视 Action 定义而定)")]
[SerializeField] private int _bindingIndex;
[Header("UI 引用")]
[SerializeField] private TMP_Text _actionLabel; // 显示 Action 可读名(固定)
[SerializeField] private TMP_Text _currentBindingText; // 显示当前绑定路径
[SerializeField] private Button _bindButton; // 点击启动重绑定
private InputReaderSO _inputReader;
private ConflictDetector _conflictDetector;
private Action<RebindActionRow> _onRebindRequested;
// ── 初始化(由 RebindPanel 调用)──────────────────────────────────
public void Initialize(
InputReaderSO reader,
ConflictDetector detector,
Action<RebindActionRow> onRequest)
{
_inputReader = reader;
_conflictDetector = detector;
_onRebindRequested = onRequest;
if (_actionLabel != null)
_actionLabel.text = _actionName;
_bindButton.onClick.AddListener(() => _onRebindRequested?.Invoke(this));
RefreshDisplay();
}
// ── 重绑定流程 ────────────────────────────────────────────────────
/// <summary>启动交互式重绑定;完成或取消后调用 onFinished。</summary>
public void StartRebind(Action onFinished)
{
_currentBindingText.text = "按下新按键…";
_inputReader.StartRebinding(
_actionName,
_bindingIndex,
onComplete: () =>
{
RefreshDisplay();
RefreshConflicts();
onFinished?.Invoke();
},
onCancel: () =>
{
RefreshDisplay();
onFinished?.Invoke();
});
}
// ── 显示刷新 ──────────────────────────────────────────────────────
/// <summary>从 InputReaderSO 读取当前绑定路径并更新文字。</summary>
public void RefreshDisplay()
{
if (_inputReader == null) return;
var action = _inputReader.FindAction(_actionName);
if (action == null || _bindingIndex >= action.bindings.Count)
{
_currentBindingText.text = "—";
return;
}
var path = action.bindings[_bindingIndex].effectivePath;
_currentBindingText.text = string.IsNullOrEmpty(path)
? "—"
: InputControlPath.ToHumanReadableString(
path,
InputControlPath.HumanReadableStringOptions.OmitDevice);
}
/// <summary>冲突状态着色:红色 = 与其他 Action 路径冲突,白色 = 正常。</summary>
public void SetConflictHighlight(bool conflict)
=> _currentBindingText.color = conflict ? Color.red : Color.white;
/// <summary>是否允许用户点击此行的绑定按钮。</summary>
public void SetInteractable(bool interactable)
=> _bindButton.interactable = interactable;
// ── 内部 ──────────────────────────────────────────────────────────
/// <summary>重绑定完成后扫描 Panel 内所有行,更新冲突高亮。</summary>
private void RefreshConflicts()
{
if (_conflictDetector == null || _inputReader == null) return;
var conflicts = _conflictDetector.FindConflicts(_inputReader.GetAllActionMap());
foreach (var row in GetComponentsInParent<RebindPanel>(includeInactive: true)
[0]?.GetRows() ?? Array.Empty<RebindActionRow>())
{
row.SetConflictHighlight(conflicts.Contains(row._actionName));
}
}
}
}

View File

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

View File

@@ -0,0 +1,67 @@
using UnityEngine;
using UnityEngine.UI;
using BaseGames.Input;
namespace BaseGames.UI.Settings
{
/// <summary>
/// 按键重绑定面板(架构 04_InputModule §6
/// 统一管理所有 RebindActionRow协调"同时只有一行处于重绑定状态"的排他锁;
/// 提供"重置全部"按钮;绑定完成后自动持久化。
/// 挂载路径Canvas_Overlay → SettingsPanel → KeybindingTab → RebindPanel
/// </summary>
public class RebindPanel : MonoBehaviour
{
[SerializeField] private InputReaderSO _inputReader;
[SerializeField] private RebindActionRow[] _rows; // Inspector 配置,顺序对应 UI 布局
[SerializeField] private Button _resetAllButton;
[SerializeField] private ConflictDetector _conflictDetector;
// ── 生命周期 ──────────────────────────────────────────────────────
private void Awake()
{
_resetAllButton?.onClick.AddListener(OnResetAll);
if (_rows == null) return;
foreach (var row in _rows)
row.Initialize(_inputReader, _conflictDetector, OnRebindRequested);
}
// ── 供 RebindActionRow 反查行列表 ─────────────────────────────────
/// <summary>返回此面板管理的所有行(供 RebindActionRow 刷新冲突高亮时遍历)。</summary>
public RebindActionRow[] GetRows() => _rows;
// ── 内部逻辑 ──────────────────────────────────────────────────────
/// <summary>由 RebindActionRow 点击时回调;实现排他锁(同时只允许一行重绑定)。</summary>
private void OnRebindRequested(RebindActionRow requestingRow)
{
// 禁用其他所有行,防止并发重绑定操作
if (_rows != null)
foreach (var row in _rows)
row.SetInteractable(row == requestingRow);
requestingRow.StartRebind(onFinished: () =>
{
// 重绑定完成(或取消)→ 恢复所有行可交互 → 持久化
if (_rows != null)
foreach (var row in _rows)
row.SetInteractable(true);
_inputReader?.SaveBindingOverrides();
});
}
/// <summary>"重置全部"按钮回调:恢复默认绑定并刷新所有行显示。</summary>
private void OnResetAll()
{
_inputReader?.ResetBindings();
if (_rows != null)
foreach (var row in _rows)
row.RefreshDisplay();
}
}
}

View File

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

View File

@@ -0,0 +1,126 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Core.Events;
namespace BaseGames.UI
{
/// <summary>
/// 单条通知弹窗(架构 10_UIModule §11
/// 由 ToastManager 控制显示和自动隐藏。
/// </summary>
[RequireComponent(typeof(CanvasGroup))]
public class ToastNotification : MonoBehaviour
{
[SerializeField] private TMP_Text _titleText;
[SerializeField] private TMP_Text _bodyText;
[SerializeField] private Image _icon;
[SerializeField] private float _displayDuration = 3f;
[SerializeField] private float _fadeDuration = 0.25f;
private CanvasGroup _cg;
private Coroutine _hideCoroutine;
/// <summary>淡入 + 保持 + 淡出的总时长(供 ToastManager 队列计时用)。</summary>
public float TotalTime => _displayDuration + _fadeDuration * 2f;
private void Awake()
{
_cg = GetComponent<CanvasGroup>();
_cg.alpha = 0f;
}
public void Show(string title, string body, Sprite icon = null)
{
if (_titleText != null) _titleText.text = title;
if (_bodyText != null) _bodyText.text = body;
if (_icon != null)
{
_icon.sprite = icon;
_icon.enabled = icon != null;
}
gameObject.SetActive(true);
if (_hideCoroutine != null) StopCoroutine(_hideCoroutine);
_hideCoroutine = StartCoroutine(AutoHide());
}
private IEnumerator AutoHide()
{
// 淡入
yield return StartCoroutine(FadeTo(1f));
// 保持
yield return new WaitForSecondsRealtime(_displayDuration);
// 淡出
yield return StartCoroutine(FadeTo(0f));
gameObject.SetActive(false);
}
private IEnumerator FadeTo(float target)
{
float start = _cg.alpha;
float elapsed = 0f;
while (elapsed < _fadeDuration)
{
_cg.alpha = Mathf.Lerp(start, target, elapsed / _fadeDuration);
elapsed += Time.unscaledDeltaTime;
yield return null;
}
_cg.alpha = target;
}
}
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// 通知队列管理器(架构 10_UIModule §11 ToastManager
/// 同时只显示一条 Toast队列不为空时前一条结束后立即显示下一条。
/// 订阅 EVT_AchievementUnlocked / EVT_AbilityUnlocked 事件频道。
/// </summary>
public class ToastManager : MonoBehaviour
{
[SerializeField] private ToastNotification _toast;
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onAchievementUnlocked; // EVT_AchievementUnlocked
[SerializeField] private StringEventChannelSO _onAbilityUnlocked; // EVT_AbilityUnlocked
private readonly Queue<(string title, string body, Sprite icon)> _queue = new();
private readonly CompositeDisposable _subs = new();
private bool _showing;
private void OnEnable()
{
_onAchievementUnlocked?.Subscribe(OnAchievement).AddTo(_subs);
_onAbilityUnlocked?.Subscribe(OnAbility).AddTo(_subs);
}
private void OnDisable()
{
_subs.Clear();
}
private void OnAchievement(string id) => Enqueue("成就解锁", id, null);
private void OnAbility(string abilityId) => Enqueue("能力获得", abilityId, null);
public void Enqueue(string title, string body, Sprite icon = null)
{
_queue.Enqueue((title, body, icon));
if (!_showing) StartCoroutine(ProcessQueue());
}
private IEnumerator ProcessQueue()
{
_showing = true;
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);
}
_showing = false;
}
}
}

View File

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

View File

@@ -0,0 +1,74 @@
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Core.Events;
namespace BaseGames.UI
{
/// <summary>
/// 工具栏 HUD架构 09_ProgressionModule §7.5)。
/// 显示 2 个工具槽的图标 + 剩余次数 + 冷却遮罩。
/// </summary>
public class ToolHUD : MonoBehaviour
{
[SerializeField] private ToolSlotUI[] _slots; // 2 个 ToolSlotUI 组件
[SerializeField] private BaseGames.Equipment.ToolSlotManager _slotManager;
[SerializeField] private ToolUsedEventChannelSO _onToolUsed;
private readonly CompositeDisposable _subs = new();
private void OnEnable()
{
_onToolUsed?.Subscribe(RefreshSlot).AddTo(_subs);
}
private void OnDisable()
{
_subs.Clear();
}
private void RefreshSlot(ToolUsedPayload payload)
{
int i = payload.SlotIndex;
if (_slots == null || i < 0 || i >= _slots.Length) return;
_slots[i].Refresh(
_slotManager.GetTool(i),
_slotManager.GetRemainingUses(i),
_slotManager.GetCooldownRatio(i));
}
private void Update()
{
if (_slots == null || _slotManager == null) return;
for (int i = 0; i < _slots.Length; i++)
_slots[i].SetCooldownFill(_slotManager.GetCooldownRatio(i));
}
}
/// <summary>
/// 单个工具槽 UI图标 + 剩余次数文本 + 冷却遮罩 ImageFillAmount
/// </summary>
public class ToolSlotUI : MonoBehaviour
{
[SerializeField] private Image _icon;
[SerializeField] private TMP_Text _usesText;
[SerializeField] private Image _cooldownMask; // type = Filled, Image Type = Filled
public void Refresh(BaseGames.Equipment.ToolSO tool, int remainingUses, float cooldownRatio)
{
if (_icon != null)
_icon.sprite = tool != null ? tool.icon : null;
if (_usesText != null)
_usesText.text = remainingUses < 0 ? "∞" : remainingUses.ToString();
SetCooldownFill(cooldownRatio);
}
public void SetCooldownFill(float ratio)
{
if (_cooldownMask != null)
_cooldownMask.fillAmount = Mathf.Clamp01(ratio);
}
}
}

View File

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

View File

@@ -0,0 +1,88 @@
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.UI
{
[DefaultExecutionOrder(+50)]
public class UIManager : MonoBehaviour
{
[Header("Canvas Roots")]
[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("Event Channels")]
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
[SerializeField] private VoidEventChannelSO _onPauseRequested;
[SerializeField] private VoidEventChannelSO _onFastTravelOpen;
[SerializeField] private StringEventChannelSO _onShopOpen;
[SerializeField] private VoidEventChannelSO _onMapOpen;
private readonly Stack<GameObject> _panelStack = new();
private readonly CompositeDisposable _subs = new();
private void OnEnable()
{
_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);
}
private void OnDisable()
{
_subs.Clear();
}
private void HandleGameStateChanged(GameStateId state)
{
// GameStateId 是 struct用 if/else 而非 switch
bool showHud = state == GameStates.Gameplay || state == GameStates.BossFight;
if (_hudRoot != null) _hudRoot.SetActive(showHud);
if (state == GameStates.Dead)
{
if (_deathScreenRoot != null) _deathScreenRoot.SetActive(true);
}
else
{
// 离开 Dead 状态时(复活/重生)隐藏死亡界面
if (_deathScreenRoot != null) _deathScreenRoot.SetActive(false);
if (state == GameStates.Cutscene)
if (_hudRoot != null) _hudRoot.SetActive(false);
}
}
public void OpenPanel(GameObject panel)
{
if (panel == null) return;
if (_panelStack.Count > 0) _panelStack.Peek().SetActive(false);
panel.SetActive(true);
_panelStack.Push(panel);
}
public void CloseTopPanel()
{
if (_panelStack.Count == 0) return;
_panelStack.Pop().SetActive(false);
if (_panelStack.Count > 0) _panelStack.Peek().SetActive(true);
}
private void TogglePause()
{
if (_pauseMenuRoot != null && _pauseMenuRoot.activeSelf)
CloseTopPanel();
else
OpenPanel(_pauseMenuRoot);
}
private void OpenShop(string _) => OpenPanel(_shopRoot);
private void OpenMap() => OpenPanel(_mapRoot);
}
}

View File

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