Files
zeling_v2/Assets/_Game/Scripts/World/Map/MinimapHUD.cs
Joywayer e2bc324905 Add independent review report for Minimap system Round 7
- Validate fixes from Round 6 and identify new issues
- Document findings including UX defects, editor integration flaws, and code quality concerns
- Propose solutions and prioritize issues based on severity
- Evaluate against standards of mature 2D Metroidvania games
2026-05-25 14:44:31 +08:00

242 lines
10 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;
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")]
[SerializeField] private StringEventChannelSO _onMapUpdated; // 房间发现/标注时局部刷新
private IMapService _mapSvc;
private IPlayerPositionProvider _playerProvider;
private readonly Dictionary<string, MapRoomCellUI> _cells = new();
private readonly CompositeDisposable _subs = new();
// 复用 List 避免 RefreshView 每次分配临时 ListGC 友好)
private readonly List<string> _toRemove = new List<string>(8);
// 空间索引:格子坐标 → 房间 ID将 RefreshView step② 的 O(N) 遍历降至 O(viewRadius²)
private Dictionary<Vector2Int, string> _spatialIndex;
// 复用 HashSet 避免 RefreshView 每次分配GC 友好)
private readonly HashSet<string> _roomsInViewBuffer = new HashSet<string>(32);
private Vector2Int _currentCenter;
private string _lastDotRoomId;
private Vector2 _lastDotNormPos;
// ── 生命周期 ──────────────────────────────────────────────────────────
private void OnEnable()
{
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
_playerProvider = ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
BuildSpatialIndex(_mapSvc?.Database);
if (_playerProvider != null)
_playerProvider.OnRoomChanged += OnRoomChanged;
_onMapUpdated?.Subscribe(OnMapUpdated).AddTo(_subs);
// 首次显示时立即刷新
RefreshView();
}
private void OnDisable()
{
if (_playerProvider != null)
_playerProvider.OnRoomChanged -= OnRoomChanged;
_subs.Clear();
ClearAllCells();
_lastDotRoomId = null;
_mapSvc = null;
_playerProvider = null;
_spatialIndex = null;
}
private void ClearAllCells()
{
foreach (var cell in _cells.Values)
if (cell != null) Destroy(cell.gameObject);
_cells.Clear();
}
/// <summary>
/// 构建格子坐标 → 房间 ID 的哈希映射。
/// 将 RefreshView step② 从 O(allRooms) 全量遍历降至 O(viewRadius²) 范围格点查询。
/// 数据库变更时(如热更)应再次调用。
/// </summary>
private void BuildSpatialIndex(MapDatabaseSO db)
{
_spatialIndex = new Dictionary<Vector2Int, string>();
if (db?.AllRooms == null) return;
foreach (var room in db.AllRooms)
{
if (room == null) continue;
for (int x = 0; x < room.GridSize.x; x++)
for (int y = 0; y < room.GridSize.y; y++)
_spatialIndex[new Vector2Int(room.GridPosition.x + x, room.GridPosition.y + y)] = room.RoomId;
}
}
private void LateUpdate()
{
UpdatePlayerDot();
}
// ── 事件响应 ──────────────────────────────────────────────────────────
private void OnRoomChanged(string _) => RefreshView();
private void OnMapUpdated(string roomId)
{
if (_cells.TryGetValue(roomId, out var cell))
cell.SetVisibility(_mapSvc.GetVisibility(roomId));
}
// ── 视图重建 ──────────────────────────────────────────────────────────
/// <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))
{
if (cell != null) Destroy(cell.gameObject);
_toRemove.Add(id);
}
}
foreach (var id in _toRemove) _cells.Remove(id);
// ② 用空间索引替代 O(N) 全量遍历,在可视范围格点上查询所属房间
// 复杂度O(viewRadius²) 替代 O(allRooms),大地图下效果显著
_roomsInViewBuffer.Clear();
if (_spatialIndex != null)
{
for (int x = minX; x <= maxX; x++)
for (int y = minY; y <= maxY; y++)
{
if (_spatialIndex.TryGetValue(new Vector2Int(x, y), out var rId))
_roomsInViewBuffer.Add(rId);
}
}
foreach (var roomId in _roomsInViewBuffer)
{
if (_cells.ContainsKey(roomId)) continue;
var room = db.GetRoom(roomId);
if (room == null) continue;
var cell = Instantiate(_cellPrefab, _cellContainer);
cell.Setup(room, _mapSvc.GetVisibility(room.RoomId), null);
cell.SetColors(_colorExplored, _colorMapped, _colorUnknown);
PlaceCell(cell, room); // 立即设置正确的中心相对坐标,避免 Setup 默认偏移被 step③ 覆盖
_cells[roomId] = cell;
}
// ③ 重定位所有格子(中心发生变化时)
foreach (var (id, cell) in _cells)
{
if (cell == null) 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);
}
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);
}
}
}