using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using BaseGames.Core; using BaseGames.Core.Events; using BaseGames.Core.Save; namespace BaseGames.World.Map { /// /// 角落小地图 HUD(架构 15_MapShopModule §1.6)。 /// 以玩家当前房间为中心,仅渲染 ±ViewRadiusCells 格范围内的房间。 /// 玩家跨格进入新房间时(OnRoomChanged 事件)触发增量重建,无需每帧扫描全局。 /// /// 挂载位置:HUD Canvas 下 Minimap 根节点(需配有 RectMask2D 用于裁剪)。 /// /// 依赖 , /// 均通过 获取,不持有具体类的 SerializeField 引用。 /// 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 _cells = new(); private readonly List _pinImages = new(); private readonly Stack _pinPool = new(); // R10-N3 Pin 对象池 private readonly Stack _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 每次分配临时 List(GC 友好) private readonly List _toRemove = new List(8); // 复用 HashSet 避免 RefreshView 每次分配(GC 友好) private readonly HashSet _roomsInViewBuffer = new HashSet(32); private readonly HashSet _newlyAddedBuffer = new HashSet(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(); if (_playerProvider != null) _playerProvider.OnRoomChanged += OnRoomChanged; } if (_mapSvc == null) { _mapSvc = ServiceLocator.GetOrDefault(); if (_mapSvc != null) { _mapSvc.OnDatabaseChanged += OnDatabaseChanged; _mapSvc.OnExplorationChanged += OnExplorationChanged; } } _pinService ??= ServiceLocator.GetOrDefault(); // 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 禁用时累积 dirty;OnEnable 后再 RefreshView if (!isActiveAndEnabled) { _viewDirty = true; return; } RefreshView(); } /// R10-N12 探索进度变化:仅刷新已实例化的格子可见性。 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)); } /// 数据库结构变更时完整重建:清空所有格子,下次 RefreshView 重新实例化。 private void OnDatabaseChanged() { if (!isActiveAndEnabled) { _databaseDirty = true; return; } ClearAllCells(); ClearPins(); _lastPinVersion = -1; RefreshView(); } // ── Pin 渲染(可视范围内)───────────────────────────────────────────── /// /// 仅渲染当前小地图视野内(已实例化格子的房间)的 Pin。 /// 基于 PinsVersion + RefreshView 时机做脏检查:版本未变化且格子未变化时跳过。 /// 单地图 N(视野)级别开销,远小于 MapPanel 的全图 Pin 渲染。 /// 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); } } // ── 视图重建 ────────────────────────────────────────────────────────── /// /// 以玩家当前房间中心为基准,增量更新可视格内的格子: /// 回收超出范围的旧格子,实例化刚进入范围的新格子,重定位全部格子到新中心。 /// 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); } /// /// R20-N2 委托到 MapRoomDataSO.ChooseDisplayIcon,消除与 MapPanel 的重复实现。 /// 优先级:MapIconOverride > SavePoint > BossRoom > Shop > TeleportStation。 /// 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); } // ── 缩放档位切换 ───────────────────────────────────────────────────── /// /// R12-FA 循环切换视野半径档位(可绑定到按键/按钮)。 /// 档位在 Inspector 中通过 _zoomLevels 数组配置(默认:2/3/5 格)。 /// 安全检查:数组为空时不切换,避免除零或越界。 /// 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; } } } }