Files
zeling_v2/Assets/_Game/Scripts/World/Map/MapPanel.cs
Joywayer 5cb6c2a19d Add final evaluation report for Minimap system after all fixes and improvements
- Summarized the evolution of scores across five review rounds
- Detailed the status of each evaluation dimension post-fixes
- Highlighted remaining issues and recommended future work for further enhancements
- Compared current system against industry benchmarks
2026-05-25 14:25:19 +08:00

313 lines
14 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;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
namespace BaseGames.World.Map
{
/// <summary>
/// 全屏地图 UI 面板(架构 15_MapShopModule §1.3)。
/// 由 UIManager PanelStack 管理开关OnEnable 时重建格子并订阅更新事件。
/// <para>
/// 依赖项均通过 <see cref="ServiceLocator"/> 获取(<see cref="IMapService"/>、
/// <see cref="IPlayerPositionProvider"/>、<see cref="IPinService"/>
/// 不持有任何具体 MonoBehaviour 的 SerializeField 引用,实现架构解耦。
/// </para>
/// </summary>
public class MapPanel : MonoBehaviour
{
[SerializeField] private RectTransform _roomContainer; // 格子图放置根节点
[SerializeField] private MapRoomCellUI _cellPrefab; // 地图格子预制
[SerializeField] private Image _exitConnectorPrefab; // 出口连接线预制(小矩形 Image
[SerializeField] private ScrollRect _scrollRect; // 包裹 _roomContainer 的滚动矩形(可空,无滚动时留空)
[Header("图标 Sprites")]
[SerializeField] private Sprite _iconSavePoint;
[SerializeField] private Sprite _iconBossRoom;
[SerializeField] private Sprite _iconShop;
[SerializeField] private Sprite _iconPlayerPos;
[Header("颜色")]
[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("玩家位置")]
[SerializeField] private Image _playerIconImg; // _roomContainer 内的玩家图标
[Header("地图标记")]
[SerializeField] private Image _pinPrefab;
[SerializeField] private PinSpriteEntry[] _pinSprites; // PinType → 对应 Sprite在 Inspector 中配置)
[Header("Tooltip")]
[SerializeField] private GameObject _tooltipPanel;
[SerializeField] private TMP_Text _tooltipText;
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onMapUpdated; // 房间发现/标注时刷新
private Dictionary<string, MapRoomCellUI> _cells = new();
private List<Image> _pinImages = new();
private List<Image> _exitImages= new();
private string _highlightedRoomId;
private string _lastIconRoomId; // LateUpdate 脏标记
private Vector2 _lastIconNormPos; // LateUpdate 脏标记
private int _lastPinVersion = -1;
private IMapService _mapSvc;
private IPlayerPositionProvider _playerProvider;
private IPinService _pinService;
private Dictionary<PinType, Sprite> _pinSpriteDict;
private readonly CompositeDisposable _subs = new();
private void Awake()
{
// 预构建 PinType → Sprite 字典,将 GetPinSprite 从 O(N) 降至 O(1)
_pinSpriteDict = new Dictionary<PinType, Sprite>();
if (_pinSprites != null)
foreach (var e in _pinSprites)
_pinSpriteDict[e.PinType] = e.Sprite;
}
private void OnEnable()
{
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
_playerProvider = ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
_pinService = ServiceLocator.GetOrDefault<IPinService>();
// 首次打开时建立格子;后续打开只刷新探索状态,跳过重复 Instantiate
if (_cells.Count == 0)
BuildGrid();
else
RefreshAllCells();
RenderPins();
UpdatePlayerIcon();
CenterOnCurrentRoom();
_onMapUpdated?.Subscribe(OnMapUpdated).AddTo(_subs);
}
private void OnDisable()
{
_subs.Clear();
_mapSvc = null;
_playerProvider = null;
_pinService = null;
_lastIconRoomId = null;
_lastIconNormPos = Vector2.zero;
HideTooltip();
}
private void OnDestroy()
{
foreach (var cell in _cells.Values)
if (cell != null) Destroy(cell.gameObject);
_cells.Clear();
ClearPins();
ClearExits();
}
private void LateUpdate()
{
if (_playerProvider == null || _playerIconImg == null) return;
// 脏标记:位置/房间未变化时跳过 RectTransform 读写,消除无效每帧开销
if (_playerProvider.CurrentRoomId == _lastIconRoomId &&
_playerProvider.NormalizedPositionInRoom == _lastIconNormPos) return;
_lastIconRoomId = _playerProvider.CurrentRoomId;
_lastIconNormPos = _playerProvider.NormalizedPositionInRoom;
UpdatePlayerIcon();
}
// 面板重新打开时同步关闭期间积累的探索进度
private void RefreshAllCells()
{
foreach (var (roomId, cell) in _cells)
{
if (cell == null) continue;
cell.SetVisibility(_mapSvc.GetVisibility(roomId));
}
}
// ── 格子 & 出口连接 ──────────────────────────────────────────────────
private void BuildGrid()
{
var db = _mapSvc?.Database;
if (db?.AllRooms == null) return;
foreach (var room in db.AllRooms)
{
if (room == null) continue;
var cell = Instantiate(_cellPrefab, _roomContainer);
cell.Setup(room, _mapSvc.GetVisibility(room.RoomId), ChooseIcon(room),
ShowTooltip, HideTooltip);
cell.SetColors(_colorExplored, _colorMapped, _colorUnknown);
_cells[room.RoomId] = cell;
}
DrawExits();
}
/// <summary>为每条出口在格子坐标处实例化一个小矩形连接线图像。</summary>
private void DrawExits()
{
var db = _mapSvc?.Database;
if (_exitConnectorPrefab == null || db?.AllRooms == null) return;
ClearExits();
foreach (var room in db.AllRooms)
{
if (room?.Exits == null) continue;
foreach (var exit in room.Exits)
{
var conn = Instantiate(_exitConnectorPrefab, _roomContainer);
conn.rectTransform.anchoredPosition = new Vector2(
exit.ExitGridPos.x * MapGridConstants.FullMapCellPixels,
exit.ExitGridPos.y * MapGridConstants.FullMapCellPixels);
bool vertical = exit.Direction == ExitDirection.Up || exit.Direction == ExitDirection.Down;
conn.rectTransform.sizeDelta = vertical ? new Vector2(16f, 8f) : new Vector2(8f, 16f);
_exitImages.Add(conn);
}
}
}
private void ClearExits()
{
foreach (var img in _exitImages)
if (img != null) Destroy(img.gameObject);
_exitImages.Clear();
}
private void OnMapUpdated(string roomId)
{
if (_cells.TryGetValue(roomId, out var cell))
cell.SetVisibility(_mapSvc.GetVisibility(roomId));
}
// ── 玩家位置图标 ──────────────────────────────────────────────────────
private void UpdatePlayerIcon()
{
if (_playerIconImg == null || _playerProvider == null) return;
var roomId = _playerProvider.CurrentRoomId;
if (string.IsNullOrEmpty(roomId) || !_cells.TryGetValue(roomId, out var cell))
{
_playerIconImg.enabled = false;
UpdateCellHighlight(null);
return;
}
_playerIconImg.sprite = _iconPlayerPos;
_playerIconImg.enabled = true;
_playerIconImg.rectTransform.anchoredPosition =
cell.RT.anchoredPosition
+ Vector2.Scale(_playerProvider.NormalizedPositionInRoom, cell.RT.sizeDelta);
UpdateCellHighlight(roomId);
}
// ── 当前房间高亮 & ScrollRect 居中 ─────────────────────────────────
/// <summary>切换高亮描边:取消旧房间高亮,激活新房间高亮。</summary>
private void UpdateCellHighlight(string roomId)
{
if (roomId == _highlightedRoomId) return;
if (_highlightedRoomId != null && _cells.TryGetValue(_highlightedRoomId, out var prev))
prev.SetHighlight(false);
_highlightedRoomId = roomId;
if (roomId != null && _cells.TryGetValue(roomId, out var next))
next.SetHighlight(true);
}
/// <summary>面板打开时将 ScrollRect 视口居中到玩家当前所在房间。</summary>
private void CenterOnCurrentRoom()
{
if (_scrollRect == null || _playerProvider == null) return;
var roomId = _playerProvider.CurrentRoomId;
if (string.IsNullOrEmpty(roomId) || !_cells.TryGetValue(roomId, out var cell)) return;
// 仅重建 ScrollRect.content 的布局,避免全 Canvas 树强制刷新
LayoutRebuilder.ForceRebuildLayoutImmediate(_scrollRect.content);
var content = _scrollRect.content;
var viewport = _scrollRect.viewport != null
? _scrollRect.viewport
: (RectTransform)_scrollRect.transform;
// 将 cell 中心转换到 content 本地坐标系
Vector2 cellWorldCenter = cell.RT.TransformPoint(cell.RT.rect.center);
Vector2 cellLocal = content.InverseTransformPoint(cellWorldCenter);
// 距 content 左下角的距离pivot 无关)
float cellX = cellLocal.x - content.rect.xMin;
float cellY = cellLocal.y - content.rect.yMin;
Vector2 viewSize = viewport.rect.size;
Vector2 contentSize = content.rect.size;
float rangeX = contentSize.x - viewSize.x;
float rangeY = contentSize.y - viewSize.y;
float normX = rangeX > 0 ? Mathf.Clamp01((cellX - viewSize.x * 0.5f) / rangeX) : 0.5f;
float normY = rangeY > 0 ? Mathf.Clamp01((cellY - viewSize.y * 0.5f) / rangeY) : 0.5f;
_scrollRect.normalizedPosition = new Vector2(normX, normY);
}
// ── 地图标记渲染 ──────────────────────────────────────────────────────
private void RenderPins()
{
if (_pinService == null) return;
// 版本号脏检查Pin 集合未变化时跳过重绘,避免无效 Instantiate
if (_pinService.PinsVersion == _lastPinVersion && _pinImages.Count > 0) return;
_lastPinVersion = _pinService.PinsVersion;
ClearPins();
if (_pinPrefab == null) return;
foreach (var pin in _pinService.Pins)
{
if (!_cells.TryGetValue(pin.RoomId, out var cell)) continue;
var img = Instantiate(_pinPrefab, _roomContainer);
img.sprite = GetPinSprite((PinType)pin.PinTypeInt);
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 ClearPins()
{
foreach (var img in _pinImages)
if (img != null) Destroy(img.gameObject);
_pinImages.Clear();
}
// ── Tooltip ───────────────────────────────────────────────────────────
private void ShowTooltip(string text)
{
if (_tooltipPanel == null || string.IsNullOrEmpty(text)) return;
if (_tooltipText != null) _tooltipText.text = text;
_tooltipPanel.SetActive(true);
}
private void HideTooltip() => _tooltipPanel?.SetActive(false);
// ── 辅助方法 ──────────────────────────────────────────────────────────
private Sprite ChooseIcon(MapRoomDataSO room)
{
if (room.MapIconOverride != null) return room.MapIconOverride;
if (room.IsSavePoint) return _iconSavePoint;
if (room.IsBossRoom) return _iconBossRoom;
if (room.IsShop) return _iconShop;
return null;
}
private Sprite GetPinSprite(PinType type)
=> _pinSpriteDict.TryGetValue(type, out var s) ? s : null;
}
}