摄像机区域的架构改动
This commit is contained in:
24
Assets/_Game/Scripts/UI/BaseGames.UI.asmdef
Normal file
24
Assets/_Game/Scripts/UI/BaseGames.UI.asmdef
Normal 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": []
|
||||
}
|
||||
7
Assets/_Game/Scripts/UI/BaseGames.UI.asmdef.meta
Normal file
7
Assets/_Game/Scripts/UI/BaseGames.UI.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 227d8c9f56b569340aed5e35153e22a6
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
163
Assets/_Game/Scripts/UI/FloatingDamageText.cs
Normal file
163
Assets/_Game/Scripts/UI/FloatingDamageText.cs
Normal 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; // Fallback:Inspector 直接拖入
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/FloatingDamageText.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/FloatingDamageText.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2188d49285e757b48ae643b9fca018fe
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Scripts/UI/HUD.meta
Normal file
8
Assets/_Game/Scripts/UI/HUD.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c9ae9298ea861bc47b2e8d8d746943d5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
110
Assets/_Game/Scripts/UI/HUD/BossHPBar.cs
Normal file
110
Assets/_Game/Scripts/UI/HUD/BossHPBar.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/HUD/BossHPBar.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/HUD/BossHPBar.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7d4dda82b1ba56049b864b6ac58765b2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
130
Assets/_Game/Scripts/UI/HUD/HUDController.cs
Normal file
130
Assets/_Game/Scripts/UI/HUD/HUDController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/HUD/HUDController.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/HUD/HUDController.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 533b885673d509d419e441a7264261a7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
31
Assets/_Game/Scripts/UI/InputDeviceIconSetSO.cs
Normal file
31
Assets/_Game/Scripts/UI/InputDeviceIconSetSO.cs
Normal 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 path,e.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/InputDeviceIconSetSO.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/InputDeviceIconSetSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bf96d35cbe629854794062790e40afb7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
68
Assets/_Game/Scripts/UI/InputDeviceIconSwitcher.cs
Normal file
68
Assets/_Game/Scripts/UI/InputDeviceIconSwitcher.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 输入设备图标切换器(架构 10_UIModule §12)。
|
||||
/// 订阅 EVT_InputDeviceChanged(BoolEventChannelSO,true = 手柄,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/InputDeviceIconSwitcher.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/InputDeviceIconSwitcher.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c9be26c81afd98e428814f874de8e424
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
60
Assets/_Game/Scripts/UI/LoadingOverlay.cs
Normal file
60
Assets/_Game/Scripts/UI/LoadingOverlay.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/LoadingOverlay.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/LoadingOverlay.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 015962d1f5b4f75478f3c521a2fbb287
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
79
Assets/_Game/Scripts/UI/LoadingScreenManager.cs
Normal file
79
Assets/_Game/Scripts/UI/LoadingScreenManager.cs
Normal 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();
|
||||
}
|
||||
|
||||
// ── 公开 API(SceneLoader 可直接调用)────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/LoadingScreenManager.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/LoadingScreenManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 57313fea7dbe62f42826481b78a5e989
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Scripts/UI/Menus.meta
Normal file
8
Assets/_Game/Scripts/UI/Menus.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cce9781f030e49648ba1939ca3cfdaa5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
51
Assets/_Game/Scripts/UI/Menus/DeathScreenController.cs
Normal file
51
Assets/_Game/Scripts/UI/Menus/DeathScreenController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Menus/DeathScreenController.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Menus/DeathScreenController.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b6773e585eac299448529521e4b090c7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
59
Assets/_Game/Scripts/UI/Menus/PauseMenuController.cs
Normal file
59
Assets/_Game/Scripts/UI/Menus/PauseMenuController.cs
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Menus/PauseMenuController.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Menus/PauseMenuController.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8adf13ec10899df439ee33bc9dcbcdeb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
147
Assets/_Game/Scripts/UI/Menus/SaveSlotController.cs
Normal file
147
Assets/_Game/Scripts/UI/Menus/SaveSlotController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Menus/SaveSlotController.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Menus/SaveSlotController.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 76a400ef5becc074ca745eb099289143
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
66
Assets/_Game/Scripts/UI/Menus/SettingsPanelController.cs
Normal file
66
Assets/_Game/Scripts/UI/Menus/SettingsPanelController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 93f9600681435a74187c249850a0f71c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
59
Assets/_Game/Scripts/UI/SaveIndicator.cs
Normal file
59
Assets/_Game/Scripts/UI/SaveIndicator.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 右下角存档进行中提示(架构 10_UIModule §7.6)。
|
||||
/// 订阅 EVT_SaveIndicatorVisible(BoolEventChannelSO):true = 淡入,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/SaveIndicator.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/SaveIndicator.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e617049d0a5b7254ea2c23c766de3bb3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Scripts/UI/Settings.meta
Normal file
8
Assets/_Game/Scripts/UI/Settings.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 464ff8538aeccd2428ab6b7a2107ef46
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
113
Assets/_Game/Scripts/UI/Settings/RebindActionRow.cs
Normal file
113
Assets/_Game/Scripts/UI/Settings/RebindActionRow.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Settings/RebindActionRow.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Settings/RebindActionRow.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 788a8dd508aa2124f8247c9f04fdeaec
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
67
Assets/_Game/Scripts/UI/Settings/RebindPanel.cs
Normal file
67
Assets/_Game/Scripts/UI/Settings/RebindPanel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/Settings/RebindPanel.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/Settings/RebindPanel.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d2a1ab65135b4be43a738101f0a7eb84
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
126
Assets/_Game/Scripts/UI/ToastManager.cs
Normal file
126
Assets/_Game/Scripts/UI/ToastManager.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/ToastManager.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/ToastManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b950cba1adc08c41b3dac76ab597545
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
74
Assets/_Game/Scripts/UI/ToolHUD.cs
Normal file
74
Assets/_Game/Scripts/UI/ToolHUD.cs
Normal 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:图标 + 剩余次数文本 + 冷却遮罩 Image(FillAmount)。
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/ToolHUD.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/ToolHUD.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d94cc95f6dd3f0a48a2f35fd4c1d710b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
88
Assets/_Game/Scripts/UI/UIManager.cs
Normal file
88
Assets/_Game/Scripts/UI/UIManager.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/UIManager.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/UIManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9b9dc4d5fa79326428150ef020e32fe4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user