Files
zeling_v2/Assets/_Game/Scripts/World/Map/MinimapHUD.cs
Joywayer f74d7f1877 Add independent review reports for Minimap system (Rounds 8, 9, and 26)
- Round 8 report highlights improvements in architecture, editor usability, and data robustness, with a total score of 80/100.
- Round 9 report focuses on editor extension capabilities, identifying issues with room data indexing and layout editing, resulting in a score of 76/100.
- Round 26 report evaluates the system against commercial standards, noting new issues and confirming previous fixes, with a score of 95.8/100.
2026-05-25 23:15:12 +08:00

434 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
namespace BaseGames.World.Map
{
/// <summary>
/// 角落小地图 HUD架构 15_MapShopModule §1.6)。
/// 以玩家当前房间为中心,仅渲染 ±ViewRadiusCells 格范围内的房间。
/// 玩家跨格进入新房间时OnRoomChanged 事件)触发增量重建,无需每帧扫描全局。
/// <para>
/// 挂载位置HUD Canvas 下 Minimap 根节点(需配有 RectMask2D 用于裁剪)。
/// </para>
/// 依赖 <see cref="IPlayerPositionProvider"/> 和 <see cref="IMapService"/>
/// 均通过 <see cref="ServiceLocator"/> 获取,不持有具体类的 SerializeField 引用。
/// </summary>
public class MinimapHUD : MonoBehaviour
{
[SerializeField] private MapRoomCellUI _cellPrefab;
[SerializeField] private RectTransform _cellContainer; // 带 RectMask2D 的容器,内容在此节点内平移
[SerializeField] private Image _playerDot; // 玩家位置圆点(在 _cellContainer 内)
[Header("显示范围")]
[SerializeField, Min(1)] private int _viewRadiusCells = 3; // 以玩家房间中心为圆心的可视半径(格)
[SerializeField] private float _cellPixels = 16f; // 每格显示像素数
[Header("颜色Inspector 覆盖)")]
[SerializeField] private Color _colorExplored = Color.white;
[SerializeField] private Color _colorMapped = new Color(0.45f, 0.45f, 0.45f, 1f);
[SerializeField] private Color _colorUnknown = Color.black;
[Header("Event Channels")]
// R18-N3 _onMapUpdated 订阅已废弃(对齐 MapPanel R12-N8
// OnExplorationChanged 全量刷新完全覆盖 OnMapUpdated 的单格更新,订阅形成冗余双重刷新。
// 保留 [HideInInspector, SerializeField] 维持 Prefab 序列化兼容,不再订阅事件。
[HideInInspector, SerializeField] private StringEventChannelSO _onMapUpdated;
[Header("地图标记(可选)")]
[SerializeField] private Image _pinPrefab; // 留空则不渲染 Pin
[SerializeField] private MapPinConfigSO _pinConfig; // R12-N3 集中 PinType→Sprite 映射
[Header("房间类型图标(可选,与全屏地图保持视觉一致)")]
[SerializeField] private Sprite _iconSavePoint;
[SerializeField] private Sprite _iconBossRoom;
[SerializeField] private Sprite _iconShop;
[Tooltip("传送站图标;房间含 TeleportStation 标志时显示。")]
[SerializeField] private Sprite _iconTeleport;
[Header("缩放档位(可选)")]
[SerializeField] private int[] _zoomLevels = { 2, 3, 5 }; // R12-FA 可用视野半径档位(格)
private int _zoomLevelIndex;
private IMapService _mapSvc;
private IPlayerPositionProvider _playerProvider;
private IPinService _pinService;
private readonly Dictionary<string, MapRoomCellUI> _cells = new();
private readonly List<Image> _pinImages = new();
private readonly Stack<Image> _pinPool = new(); // R10-N3 Pin 对象池
private readonly Stack<MapRoomCellUI> _cellPool = new(); // R11-N5 Cell 对象池
private int _lastPinVersion = -1;
private bool _viewDirty; // R10-N2 关闭期间收到 OnRoomChanged → 下次 OnEnable RefreshView
private bool _databaseDirty; // R10-N1 关闭期间收到 OnDatabaseChanged → 下次 OnEnable 完整重建
private bool _servicesReady; // R21-N1 三服务全部就绪后置 true短路 LateUpdate 的每帧 ServiceLocator 查询(对齐 MapPanel
// 复用 List 避免 RefreshView 每次分配临时 ListGC 友好)
private readonly List<string> _toRemove = new List<string>(8);
// 复用 HashSet 避免 RefreshView 每次分配GC 友好)
private readonly HashSet<string> _roomsInViewBuffer = new HashSet<string>(32);
private readonly HashSet<string> _newlyAddedBuffer = new HashSet<string>(16);
private Vector2Int _currentCenter;
private string _lastDotRoomId;
private Vector2 _lastDotNormPos;
// ── 生命周期 ──────────────────────────────────────────────────────────
private void Awake()
{
// R10-N2/N1 服务订阅在 Awake/OnDestroy 长期持有OnDisable 不解绑
// 即便 HUD 隐藏期间发生 OnRoomChanged / OnDatabaseChanged也能记录 dirty 标志
SubscribeServices();
}
private void OnEnable()
{
// 启动顺序兜底
SubscribeServices();
// R10-N1/N2 应用关闭期间累积的状态变化
if (_databaseDirty)
{
ClearAllCells();
ClearPins();
_lastPinVersion = -1;
_databaseDirty = false;
_viewDirty = true; // 重建后需 RefreshView
}
if (_viewDirty || _cells.Count == 0)
{
_viewDirty = false;
RefreshView();
}
}
private void OnDisable()
{
_lastDotRoomId = null;
// R10-N2 保留 cells/pins/服务订阅HUD 频繁开关时避免 GC 抖动
}
private void OnDestroy()
{
UnsubscribeServices();
ClearAllCells();
ClearPins();
foreach (var img in _pinPool)
if (img != null) Destroy(img.gameObject);
_pinPool.Clear();
foreach (var cell in _cellPool)
if (cell != null) Destroy(cell.gameObject);
_cellPool.Clear();
}
private void SubscribeServices()
{
// 各服务独立守门任意服务迟到就绪时后续调用仍能补订阅R11-N1 修复)
if (_playerProvider == null)
{
_playerProvider = ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
if (_playerProvider != null)
_playerProvider.OnRoomChanged += OnRoomChanged;
}
if (_mapSvc == null)
{
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
if (_mapSvc != null)
{
_mapSvc.OnDatabaseChanged += OnDatabaseChanged;
_mapSvc.OnExplorationChanged += OnExplorationChanged;
}
}
_pinService ??= ServiceLocator.GetOrDefault<IPinService>();
// R21-N1 三服务全部就绪后置 true短路 LateUpdate 的每帧查询(对齐 MapPanel._servicesReady
if (_playerProvider != null && _mapSvc != null && _pinService != null)
_servicesReady = true;
}
private void UnsubscribeServices()
{
_servicesReady = false; // R21-N3 重置,确保重建场景后的实例能重新订阅
if (_playerProvider != null)
{
_playerProvider.OnRoomChanged -= OnRoomChanged;
_playerProvider = null;
}
if (_mapSvc != null)
{
_mapSvc.OnDatabaseChanged -= OnDatabaseChanged;
_mapSvc.OnExplorationChanged -= OnExplorationChanged;
_mapSvc = null;
}
_pinService = null;
}
private void ClearAllCells()
{
// R11-N5 禁用入池而非销毁
foreach (var cell in _cells.Values)
{
if (cell == null) continue;
cell.gameObject.SetActive(false);
_cellPool.Push(cell);
}
_cells.Clear();
}
private void ClearPins()
{
// R10-N3 禁用入池而非销毁
foreach (var img in _pinImages)
{
if (img == null) continue;
img.gameObject.SetActive(false);
_pinPool.Push(img);
}
_pinImages.Clear();
}
private void LateUpdate()
{
// R21-N1 服务懒加载重试_servicesReady 置 true 后短路,消除每帧 ServiceLocator 查询(对齐 MapPanel
if (!_servicesReady)
SubscribeServices();
UpdatePlayerDot();
RenderPinsIfDirty();
}
// ── 事件响应 ──────────────────────────────────────────────────────────
private void OnRoomChanged(string _)
{
// R10-N2 禁用时累积 dirtyOnEnable 后再 RefreshView
if (!isActiveAndEnabled) { _viewDirty = true; return; }
RefreshView();
}
/// <summary>R10-N12 探索进度变化:仅刷新已实例化的格子可见性。</summary>
private void OnExplorationChanged()
{
if (!isActiveAndEnabled) { _viewDirty = true; return; }
if (_mapSvc == null) return;
foreach (var (id, cell) in _cells)
if (cell != null) cell.SetVisibility(_mapSvc.GetVisibility(id));
}
/// <summary>数据库结构变更时完整重建:清空所有格子,下次 RefreshView 重新实例化。</summary>
private void OnDatabaseChanged()
{
if (!isActiveAndEnabled) { _databaseDirty = true; return; }
ClearAllCells();
ClearPins();
_lastPinVersion = -1;
RefreshView();
}
// ── Pin 渲染(可视范围内)─────────────────────────────────────────────
/// <summary>
/// 仅渲染当前小地图视野内(已实例化格子的房间)的 Pin。
/// 基于 PinsVersion + RefreshView 时机做脏检查:版本未变化且格子未变化时跳过。
/// 单地图 N视野级别开销远小于 MapPanel 的全图 Pin 渲染。
/// </summary>
private void RenderPinsIfDirty()
{
if (_pinService == null || _pinPrefab == null) return;
if (_pinService.PinsVersion == _lastPinVersion) return;
_lastPinVersion = _pinService.PinsVersion;
RebuildPins();
}
private void RebuildPins()
{
ClearPins();
foreach (var pin in _pinService.Pins)
{
if (!_cells.TryGetValue(pin.RoomId, out var cell)) continue;
// R10-N3 优先从对象池取
Image img;
if (_pinPool.Count > 0)
{
img = _pinPool.Pop();
img.gameObject.SetActive(true);
}
else
{
img = Instantiate(_pinPrefab, _cellContainer);
}
img.sprite = _pinConfig != null ? _pinConfig.GetSprite((PinType)pin.PinTypeInt) : null;
img.rectTransform.anchoredPosition = cell.RT.anchoredPosition
+ new Vector2(pin.NormalizedPosX * cell.RT.sizeDelta.x,
pin.NormalizedPosY * cell.RT.sizeDelta.y);
_pinImages.Add(img);
}
}
// ── 视图重建 ──────────────────────────────────────────────────────────
/// <summary>
/// 以玩家当前房间中心为基准,增量更新可视格内的格子:
/// 回收超出范围的旧格子,实例化刚进入范围的新格子,重定位全部格子到新中心。
/// </summary>
private void RefreshView()
{
var db = _mapSvc?.Database;
if (db?.AllRooms == null) return;
var currentRoomId = _playerProvider?.CurrentRoomId;
if (string.IsNullOrEmpty(currentRoomId)) return;
var currentRoom = db.GetRoom(currentRoomId);
if (currentRoom == null) return;
_currentCenter = currentRoom.GridPosition + currentRoom.GridSize / 2;
int minX = _currentCenter.x - _viewRadiusCells;
int maxX = _currentCenter.x + _viewRadiusCells;
int minY = _currentCenter.y - _viewRadiusCells;
int maxY = _currentCenter.y + _viewRadiusCells;
// ① 回收不在可视范围内的格子(复用 _toRemove 避免每帧 new
_toRemove.Clear();
foreach (var (id, cell) in _cells)
{
var r = db.GetRoom(id);
if (r == null || !RoomInView(r, minX, maxX, minY, maxY))
{
// R11-N5 禁用入池而非 Destroy下次复用避免 GC 抖动
if (cell != null)
{
cell.gameObject.SetActive(false);
_cellPool.Push(cell);
}
_toRemove.Add(id);
}
}
foreach (var id in _toRemove) _cells.Remove(id);
// ② 通过 MapDatabaseSO 共享空间索引,在可视范围格点上查询所属房间
// 复杂度O(viewRadius²) 替代 O(allRooms),大地图下效果显著
_roomsInViewBuffer.Clear();
for (int x = minX; x <= maxX; x++)
for (int y = minY; y <= maxY; y++)
{
var rId = db.GetRoomIdAtCell(new Vector2Int(x, y));
if (!string.IsNullOrEmpty(rId)) _roomsInViewBuffer.Add(rId);
}
_newlyAddedBuffer.Clear();
foreach (var roomId in _roomsInViewBuffer)
{
if (_cells.ContainsKey(roomId)) continue;
var room = db.GetRoom(roomId);
if (room == null) continue;
// R11-N5 优先从对象池取,避免高频 Instantiate
MapRoomCellUI cell;
if (_cellPool.Count > 0)
{
cell = _cellPool.Pop();
cell.gameObject.SetActive(true);
}
else
{
cell = Instantiate(_cellPrefab, _cellContainer);
}
cell.Setup(room, _mapSvc.GetVisibility(room.RoomId), ChooseIcon(room));
cell.SetColors(_colorExplored, _colorMapped, _colorUnknown);
PlaceCell(cell, room); // 立即设置正确的中心相对坐标
_cells[roomId] = cell;
_newlyAddedBuffer.Add(roomId);
}
// ③ 重定位存量格子(新增格子在 step② 已 PlaceCell跳过避免重复写入
foreach (var (id, cell) in _cells)
{
if (cell == null || _newlyAddedBuffer.Contains(id)) continue;
var r = db.GetRoom(id);
if (r != null) PlaceCell(cell, r);
}
UpdatePlayerDot();
}
// ── 格子位置 ──────────────────────────────────────────────────────────
private void PlaceCell(MapRoomCellUI cell, MapRoomDataSO room)
{
cell.RT.sizeDelta = new Vector2(room.GridSize.x * _cellPixels, room.GridSize.y * _cellPixels);
cell.RT.anchoredPosition = new Vector2(
(room.GridPosition.x - _currentCenter.x) * _cellPixels,
(room.GridPosition.y - _currentCenter.y) * _cellPixels);
}
/// <summary>
/// R20-N2 委托到 MapRoomDataSO.ChooseDisplayIcon消除与 MapPanel 的重复实现。
/// 优先级MapIconOverride > SavePoint > BossRoom > Shop > TeleportStation。
/// </summary>
private Sprite ChooseIcon(MapRoomDataSO room)
=> room.ChooseDisplayIcon(_iconSavePoint, _iconBossRoom, _iconShop, _iconTeleport);
private static bool RoomInView(MapRoomDataSO room, int minX, int maxX, int minY, int maxY)
=> room.GridPosition.x + room.GridSize.x > minX &&
room.GridPosition.x < maxX &&
room.GridPosition.y + room.GridSize.y > minY &&
room.GridPosition.y < maxY;
// ── 玩家圆点 ──────────────────────────────────────────────────────────
private void UpdatePlayerDot()
{
if (_playerDot == null || _playerProvider == null) return;
var roomId = _playerProvider.CurrentRoomId;
var normPos = _playerProvider.NormalizedPositionInRoom;
// Dirty check: 避免房间和位置均未变化时写 RectTransform
if (roomId == _lastDotRoomId && normPos == _lastDotNormPos) return;
_lastDotRoomId = roomId;
_lastDotNormPos = normPos;
if (string.IsNullOrEmpty(roomId) || !_cells.TryGetValue(roomId, out var cell))
{
_playerDot.enabled = false;
return;
}
_playerDot.enabled = true;
_playerDot.rectTransform.anchoredPosition =
cell.RT.anchoredPosition
+ Vector2.Scale(normPos, cell.RT.sizeDelta);
}
// ── 缩放档位切换 ─────────────────────────────────────────────────────
/// <summary>
/// R12-FA 循环切换视野半径档位(可绑定到按键/按钮)。
/// 档位在 Inspector 中通过 _zoomLevels 数组配置默认2/3/5 格)。
/// 安全检查:数组为空时不切换,避免除零或越界。
/// </summary>
public void CycleZoom()
{
if (_zoomLevels == null || _zoomLevels.Length == 0) return;
_zoomLevelIndex = (_zoomLevelIndex + 1) % _zoomLevels.Length;
_viewRadiusCells = _zoomLevels[_zoomLevelIndex];
if (isActiveAndEnabled)
{
// R29-N1 先清除标志再刷新,防止 HUD 关闭后重新打开触发冗余 RefreshView
_viewDirty = false;
RefreshView();
}
else
{
_viewDirty = true;
}
}
}
}