UI系统优化

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

View File

@@ -12,6 +12,7 @@
"BaseGames.Core",
"BaseGames.Core.Save",
"BaseGames.Core.Events",
"BaseGames.Localization",
"Unity.TextMeshPro"
],
"autoReferenced": true,

View File

@@ -56,10 +56,13 @@ namespace BaseGames.World.Map
private List<Image> _pinImages = new();
private List<Image> _exitImages = new();
private string _highlightedRoomId;
private IMapService _mapSvc; // 面板活跃期间缓存,避免高频 ServiceLocator 查询
private readonly CompositeDisposable _subs = new();
private void OnEnable()
{
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
// 首次打开时建立格子;后续打开只刷新探索状态,跳过重复 Instantiate
if (_cells.Count == 0)
BuildGrid();
@@ -75,6 +78,7 @@ namespace BaseGames.World.Map
private void OnDisable()
{
_subs.Clear();
_mapSvc = null;
HideTooltip();
}
@@ -95,11 +99,10 @@ namespace BaseGames.World.Map
// 面板重新打开时同步关闭期间积累的探索进度
private void RefreshAllCells()
{
var svc = ServiceLocator.GetOrDefault<IMapService>();
foreach (var (roomId, cell) in _cells)
{
if (cell == null) continue;
cell.SetVisibility(GetVisibility(svc, roomId));
cell.SetVisibility(GetVisibility(_mapSvc, roomId));
}
}
@@ -108,12 +111,11 @@ namespace BaseGames.World.Map
private void BuildGrid()
{
if (_database?.AllRooms == null) return;
var svc = ServiceLocator.GetOrDefault<IMapService>();
foreach (var room in _database.AllRooms)
{
if (room == null) continue;
var cell = Instantiate(_cellPrefab, _roomContainer);
cell.Setup(room, GetVisibility(svc, room.RoomId), ChooseIcon(room),
cell.Setup(room, GetVisibility(_mapSvc, room.RoomId), ChooseIcon(room),
ShowTooltip, HideTooltip);
_cells[room.RoomId] = cell;
}
@@ -149,9 +151,8 @@ namespace BaseGames.World.Map
private void OnMapUpdated(string roomId)
{
var svc = ServiceLocator.GetOrDefault<IMapService>();
if (_cells.TryGetValue(roomId, out var cell))
cell.SetVisibility(GetVisibility(svc, roomId));
cell.SetVisibility(GetVisibility(_mapSvc, roomId));
}
// ── 玩家位置图标 ──────────────────────────────────────────────────────

View File

@@ -4,6 +4,7 @@ using UnityEngine;
using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Localization;
namespace BaseGames.World.Map
{
@@ -90,7 +91,8 @@ namespace BaseGames.World.Map
{
if (_regionNames != null)
foreach (var e in _regionNames)
if (e.RegionId == regionId) return e.DisplayName;
if (e.RegionId == regionId)
return e.GetDisplayName();
return regionId;
}
}
@@ -98,7 +100,26 @@ namespace BaseGames.World.Map
[Serializable]
public struct RegionNameEntry
{
[Tooltip("场景/区域 ID与 EVT_RegionChanged 事件传递的字符串匹配。")]
public string RegionId;
[Tooltip("本地化 Key如 REGION_CITY_NAME。设置后运行时通过 LocalizationManager 自动解析。")]
public string LocKey;
[Tooltip("直接显示名LocKey 为空时使用)。建议优先配置 LocKey仅在开发或单语言项目中直接善用。")]
public string DisplayName;
/// <summary>返回最终显示名:优先读 LocKey其次 DisplayName最后回退到 RegionId。</summary>
public string GetDisplayName()
{
if (!string.IsNullOrEmpty(LocKey))
{
string localized = LocalizationManager.Get(LocKey, LocalizationTable.UI);
// LocalizationManager 在未找到 Key 时返回 Key 本身,判断是否是真正翻译结果
if (!string.IsNullOrEmpty(localized) && localized != LocKey)
return localized;
}
return !string.IsNullOrEmpty(DisplayName) ? DisplayName : RegionId;
}
}
}

View File

@@ -14,7 +14,10 @@
"BaseGames.Core.Save",
"BaseGames.Equipment",
"BaseGames.Dialogue",
"BaseGames.World.Map"
"BaseGames.World.Map",
"BaseGames.Localization",
"BaseGames.UI",
"Unity.TextMeshPro"
],
"autoReferenced": true,
"overrideReferences": false,

View File

@@ -68,6 +68,12 @@ namespace BaseGames.World.Shop
public string Description;
public Sprite Icon;
[Header("本地化(可选,优先于直接文本)")]
[Tooltip("显示名本地化 Key如 SHOP_ITEM_POTION_NAME设置后优先于 DisplayName 字段。")]
public string DisplayNameKey;
[Tooltip("描述本地化 Key如 SHOP_ITEM_POTION_DESC设置后优先于 Description 字段。")]
public string DescriptionKey;
[Header("价格")]
public int BasePrice;
public bool IsUnique; // 购买一次后永久从库存移除

View File

@@ -0,0 +1,240 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Localization;
using BaseGames.UI;
namespace BaseGames.World.Shop
{
/// <summary>
/// 商店 UI 面板具体实现(架构 15_MapShopModule §2.3)。
/// 挂在 ShopRoot 预制体上,继承 ShopPanel 基类,由 ShopController.Open() 驱动。
///
/// 布局:
/// 标题栏(商店名 + 灵珠余额 + 关闭按钮)
/// 商品列表(每项:图标 + 名称 + 描述 + 价格 + 购买按钮)
/// </summary>
public sealed class ShopPanelUI : ShopPanel, IFocusable
{
[Header("面板根节点")]
[SerializeField] private TMP_Text _titleText;
[SerializeField] private TMP_Text _lingZhuText;
[SerializeField] private Button _btnClose;
[Header("商品列表")]
[SerializeField] private Transform _itemListRoot;
[SerializeField] private ShopItemSlotUI _slotTemplate; // 用作对象池模板,初始设为 inactive
[Header("Event Channels")]
[SerializeField] private IntEventChannelSO _onLingZhuChanged; // EVT_LingZhuChanged
// ── 运行时状态 ────────────────────────────────────────────────────────
private ShopController _controller;
private int _currentLingZhu;
private readonly List<ShopItemSlotUI> _activeSlots = new();
private readonly Queue<ShopItemSlotUI> _slotPool = new(); // O(1) 取用归还
private readonly CompositeDisposable _subs = new();
// ── 生命周期 ─────────────────────────────────────────────────────────
private void Awake()
{
// 模板槽在运行时始终隐藏,仅作克隆原型
if (_slotTemplate != null) _slotTemplate.gameObject.SetActive(false);
gameObject.SetActive(false);
}
private void OnEnable()
{
_onLingZhuChanged?.Subscribe(OnLingZhuChanged).AddTo(_subs);
if (_btnClose != null) _btnClose.onClick.AddListener(OnCloseClicked);
// 手柄导航:商店面板打开时将焦点置于关闭按钮
EventSystem.current?.SetSelectedGameObject(_btnClose?.gameObject);
}
private void OnDisable()
{
_subs.Clear();
if (_btnClose != null) _btnClose.onClick.RemoveListener(OnCloseClicked);
}
// ── ShopPanel API ─────────────────────────────────────────────────────
public override void Show(List<ShopItemSO> items, ShopController controller)
{
_controller = controller;
gameObject.SetActive(true);
BuildSlots(items);
RefreshLingZhuText();
}
public override void Hide()
{
gameObject.SetActive(false);
RecycleSlots();
_controller = null;
}
// ── 事件处理 ─────────────────────────────────────────────────────────
private void OnLingZhuChanged(int newValue)
{
_currentLingZhu = newValue;
RefreshLingZhuText();
// 购买后余额变化,同步刷新所有槽的购买按钮可用状态
foreach (var slot in _activeSlots)
slot.UpdateAffordability(_currentLingZhu);
}
private void OnCloseClicked()
{
_controller?.Close();
}
// ── 列表构建 ─────────────────────────────────────────────────────────
private void BuildSlots(List<ShopItemSO> items)
{
RecycleSlots();
if (items == null || _slotTemplate == null) return;
foreach (var item in items)
{
if (item == null) continue;
var slot = GetOrCreateSlot();
if (slot == null) continue;
slot.transform.SetParent(_itemListRoot, false);
slot.gameObject.SetActive(true);
int price = _controller != null
? _controller.GetEffectivePrice(item)
: item.BasePrice;
slot.Init(item, price, OnBuyClicked);
slot.UpdateAffordability(_currentLingZhu);
_activeSlots.Add(slot);
}
}
private void RecycleSlots()
{
foreach (var slot in _activeSlots)
{
if (slot == null) continue;
slot.gameObject.SetActive(false);
_slotPool.Enqueue(slot);
}
_activeSlots.Clear();
}
/// <summary>从对象池取出一个槽实例;池为空时克隆模板。</summary>
private ShopItemSlotUI GetOrCreateSlot()
{
while (_slotPool.Count > 0)
{
var s = _slotPool.Dequeue();
if (s != null) return s;
}
var go = Instantiate(_slotTemplate.gameObject, _itemListRoot);
return go.GetComponent<ShopItemSlotUI>();
}
private void OnBuyClicked(ShopItemSO item)
{
if (_controller == null) return;
bool success = _controller.TryPurchase(item, _currentLingZhu);
if (success)
{
// 购买成功:从列表中移除该槽(商品已消耗或已达上限)
var toRemove = _activeSlots.Find(s => s.Item == item);
if (toRemove != null)
{
_activeSlots.Remove(toRemove);
toRemove.gameObject.SetActive(false);
_slotPool.Enqueue(toRemove);
}
}
// 失败时资金不足等UI 保持不动ShakeFeedback 可在此扩展
}
private void RefreshLingZhuText()
{
if (_lingZhuText != null)
_lingZhuText.text = _currentLingZhu.ToString();
}
// ── IFocusable ────────────────────────────────────────────────────────
/// <summary>面板恢复为栈顶时将焦点移回关闭按钮。</summary>
public void OnFocusRestored()
=> EventSystem.current?.SetSelectedGameObject(_btnClose?.gameObject);
}
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// 商品槽 UI图标 + 名称 + 描述 + 价格标签 + 购买按钮。
/// 由 ShopPanelUI.BuildSlots 动态创建。
/// </summary>
public sealed class ShopItemSlotUI : MonoBehaviour
{
[SerializeField] private Image _icon;
[SerializeField] private TMP_Text _nameText;
[SerializeField] private TMP_Text _descText;
[SerializeField] private TMP_Text _priceText;
[SerializeField] private Button _btnBuy;
private System.Action<ShopItemSO> _onBuy;
/// <summary>绑定到此槽的商品数据ShopPanelUI 用于识别槽)。</summary>
public ShopItemSO Item { get; private set; }
public void Init(ShopItemSO item, int effectivePrice, System.Action<ShopItemSO> onBuy)
{
Item = item;
_onBuy = onBuy;
if (_icon != null) { _icon.sprite = item.Icon; _icon.enabled = item.Icon != null; }
if (_nameText != null)
{
string loc = !string.IsNullOrEmpty(item.DisplayNameKey)
? LocalizationManager.Get(item.DisplayNameKey, LocalizationTable.Items)
: null;
_nameText.text = !string.IsNullOrEmpty(loc) && loc != item.DisplayNameKey
? loc : item.DisplayName;
}
if (_descText != null)
{
string loc = !string.IsNullOrEmpty(item.DescriptionKey)
? LocalizationManager.Get(item.DescriptionKey, LocalizationTable.Items)
: null;
_descText.text = !string.IsNullOrEmpty(loc) && loc != item.DescriptionKey
? loc : item.Description;
}
if (_priceText != null) _priceText.text = effectivePrice.ToString();
if (_btnBuy != null)
{
_btnBuy.onClick.RemoveAllListeners();
_btnBuy.onClick.AddListener(() => _onBuy?.Invoke(Item));
}
}
/// <summary>根据玩家当前灵珠余额更新购买按钮的可交互状态。</summary>
public void UpdateAffordability(int lingZhu)
{
if (_btnBuy == null || Item == null) return;
// 从价格文本反推有效价格(避免重新请求 Controller
bool canAfford = int.TryParse(_priceText != null ? _priceText.text : "0", out int p)
&& lingZhu >= p;
_btnBuy.interactable = canAfford;
}
}
}

View File

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