Files
zeling_v2/Assets/_Game/Scripts/World/Map/MapPanel.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

505 lines
24 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;
[Tooltip("传送站图标;房间含 TeleportStation 标志时显示。")]
[SerializeField] private Sprite _iconTeleport;
[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 MapPinConfigSO _pinConfig; // R12-N3 集中 PinType→Sprite 映射,替代旧的 PinSpriteEntry[]
[Header("房间解锁动画")]
[SerializeField] private Color _revealFlashColor = Color.white; // R12-FC 新房间发现时的闪光颜色
[SerializeField] private float _revealDuration = 0.4f; // R12-FC 淡出动画持续时间(秒)
[Header("Tooltip")]
[SerializeField] private GameObject _tooltipPanel;
[SerializeField] private TMP_Text _tooltipText;
[HideInInspector, SerializeField] private StringEventChannelSO _onMapUpdated; // 已废弃仅保留序列化兼容性R12-N8
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(); // R12-N1 Cell 对象池BuildGrid/OnDestroy 共用
private readonly Stack<Image> _exitPool = new(); // R12-N1 Exit connector 对象池
private readonly List<Image> _exitImages= new();
private readonly Dictionary<string, Coroutine> _revealCoroutines = new(); // R19-N2 跟踪进行中的发现动画协程
private string _highlightedRoomId;
private string _lastIconRoomId; // LateUpdate 脏标记
private Vector2 _lastIconNormPos; // LateUpdate 脏标记
private int _lastPinVersion = -1;
private bool _databaseDirty; // R10-N1 关闭期间收到 OnDatabaseChanged → 下次 OnEnable 触发重建
private bool _explorationDirty; // R10-N12 关闭期间收到 OnExplorationChanged → 下次 OnEnable RefreshAllCells
private bool _servicesReady; // R12-N7 三个服务全部就绪后置 true短路 LateUpdate 的每帧查询
private IMapService _mapSvc;
private IPlayerPositionProvider _playerProvider;
private IPinService _pinService;
private void Awake()
{
// R10-N1 服务订阅在 Awake/OnDestroy 长期持有:即便面板关闭也能感知数据库变更,
// 设置 dirty 标志后由 OnEnable 触发重建,避免错过事件导致下次打开仍展示陈旧布局。
SubscribeServices();
}
private void OnEnable()
{
// 若服务在 Awake 时还未注册(启动顺序),此处补订阅
SubscribeServices();
// 首次打开时建立格子;后续打开只刷新探索状态,跳过重复 Instantiate
if (_cells.Count == 0)
BuildGrid();
else if (_databaseDirty)
RebuildAll();
else if (_explorationDirty)
RefreshAllCells();
_databaseDirty = _explorationDirty = false;
RenderPins();
UpdatePlayerIcon();
CenterOnCurrentRoom();
// R12-N8移除 _onMapUpdated 订阅,避免与 OnExplorationChanged 双重刷新;
// _onMapUpdated 字段保留但标记 HideInInspector防止旧 Prefab 数据丢失。
}
private void OnDisable()
{
_lastIconRoomId = null;
_lastIconNormPos = Vector2.zero;
HideTooltip();
// R10-N1 保持 _mapSvc 等订阅引用,监听器在 Awake 已挂;不再置空
}
private void OnDestroy()
{
UnsubscribeServices();
foreach (var cell in _cells.Values)
if (cell != null) Destroy(cell.gameObject);
_cells.Clear();
ClearPins();
foreach (var img in _pinPool)
if (img != null) Destroy(img.gameObject);
_pinPool.Clear();
ClearExits();
// R12-N1 销毁对象池中的格子和出口连接线
foreach (var cell in _cellPool)
if (cell != null) Destroy(cell.gameObject);
_cellPool.Clear();
foreach (var img in _exitPool)
if (img != null) Destroy(img.gameObject);
_exitPool.Clear();
}
/// <summary>统一订阅服务的 OnDatabaseChanged / OnExplorationChanged / OnRoomMapped 事件。</summary>
private void SubscribeServices()
{
if (_servicesReady) return; // R12-N7 三服务全部就绪后短路
if (_mapSvc == null)
{
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
if (_mapSvc != null)
{
_mapSvc.OnDatabaseChanged += OnDatabaseChanged;
_mapSvc.OnExplorationChanged += OnExplorationChanged;
_mapSvc.OnRoomMapped += OnRoomMappedAnim;
}
}
_playerProvider ??= ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
_pinService ??= ServiceLocator.GetOrDefault<IPinService>();
if (_mapSvc != null && _playerProvider != null && _pinService != null)
_servicesReady = true;
}
private void UnsubscribeServices()
{
// 仅在 OnDestroy 调用,生命周期末尾服务引用不需要清空(与 MinimapHUD.UnsubscribeServices 的有意差异:
// MinimapHUD 是持久 HUD需支持跨场景销毁/重建后重连MapPanel 由 UIManager 管理,
// OnDestroy 后不再重用,服务引用随对象销毁自然回收)。
if (_mapSvc != null)
{
_mapSvc.OnDatabaseChanged -= OnDatabaseChanged;
_mapSvc.OnExplorationChanged -= OnExplorationChanged;
_mapSvc.OnRoomMapped -= OnRoomMappedAnim;
}
}
private void LateUpdate()
{
// R12-N7 服务懒加载_servicesReady 置 true 后短路,消除每帧 ServiceLocator 查询
if (!_servicesReady)
SubscribeServices();
// Pin 增删响应:基于 PinsVersion 脏检查,版本未变化时 RenderPins 立即 return无开销
RenderPins();
if (_playerProvider == null || _playerIconImg == null) return;
// 脏标记:位置/房间未变化时跳过 RectTransform 读写,消除无效每帧开销
if (_playerProvider.CurrentRoomId == _lastIconRoomId &&
_playerProvider.NormalizedPositionInRoom == _lastIconNormPos) return;
_lastIconRoomId = _playerProvider.CurrentRoomId;
_lastIconNormPos = _playerProvider.NormalizedPositionInRoom;
UpdatePlayerIcon();
}
/// <summary>数据库结构变更:禁用状态置 dirty启用状态立即重建。</summary>
private void OnDatabaseChanged()
{
if (!isActiveAndEnabled) { _databaseDirty = true; return; }
RebuildAll();
}
/// <summary>R10-N12 探索进度变化:仅刷新格子可见性,不重建结构(轻量级)。</summary>
private void OnExplorationChanged()
{
if (!isActiveAndEnabled) { _explorationDirty = true; return; }
RefreshAllCells();
}
/// <summary>
/// R12-FC 房间被标 Mapped 时播放发现动画(格子存在才播放)。
/// R19-N2 先停止该房间的旧协程,防止 RebuildAll 把格子回收后协程继续写颜色。
/// R20-N1 通过 RunRevealAnim 包装协程,动画完成后自动从 _revealCoroutines 移除,
/// 消除已完成协程引用在字典中积累至下次 RebuildAll 的问题。
/// </summary>
protected virtual void OnRoomMappedAnim(string roomId)
{
if (!_cells.TryGetValue(roomId, out var cell) || cell == null) return;
if (_revealCoroutines.TryGetValue(roomId, out var old) && old != null)
StopCoroutine(old);
_revealCoroutines[roomId] = StartCoroutine(RunRevealAnim(roomId, cell));
}
private IEnumerator RunRevealAnim(string roomId, MapRoomCellUI cell)
{
yield return cell.PlayRevealAnim(_revealFlashColor, _revealDuration);
_revealCoroutines.Remove(roomId); // R20-N1 完成后自清理,避免过期引用积累
}
private void RebuildAll()
{
// R19-N2 在格子回收前停止所有进行中的发现动画协程,防止协程写入已入池的格子
foreach (var c in _revealCoroutines.Values)
if (c != null) StopCoroutine(c);
_revealCoroutines.Clear();
foreach (var cell in _cells.Values)
{
if (cell == null) continue;
// R12-N1 入池而非销毁
cell.gameObject.SetActive(false);
_cellPool.Push(cell);
}
_cells.Clear();
ClearExits();
ClearPins();
_lastPinVersion = -1;
_highlightedRoomId = null;
BuildGrid();
RenderPins();
UpdatePlayerIcon();
CenterOnCurrentRoom();
}
// 面板重新打开时同步关闭期间积累的探索进度
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;
// R12-N1 优先从对象池取格子,避免高频 Instantiate/Destroy
MapRoomCellUI cell;
if (_cellPool.Count > 0)
{
cell = _cellPool.Pop();
cell.gameObject.SetActive(true);
}
else
{
cell = Instantiate(_cellPrefab, _roomContainer);
}
cell.Setup(room, _mapSvc.GetVisibility(room.RoomId), ChooseIcon(room),
ShowTooltip, HideTooltip);
// R11-N8 布局单独调用 SetGridLayout与 MinimapHUD.PlaceCell 职责对称
cell.SetGridLayout(room, MapGridConstants.FullMapCellPixels);
cell.SetColors(_colorExplored, _colorMapped, _colorUnknown);
_cells[room.RoomId] = cell;
}
DrawExits();
// R11-N4 格子布局改变后统一重建一次CenterOnCurrentRoom 不再重复调用
if (_scrollRect != null)
LayoutRebuilder.ForceRebuildLayoutImmediate(_scrollRect.content);
}
/// <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)
{
// R12-N1 优先从对象池取连接线
Image conn;
if (_exitPool.Count > 0)
{
conn = _exitPool.Pop();
conn.gameObject.SetActive(true);
}
else
{
conn = Instantiate(_exitConnectorPrefab, _roomContainer);
}
// R13-N1 检查 HasCustomExitPos未配置时按出口方向计算房间边缘中点避免落在 (0,0)
Vector2Int gridPos = exit.HasCustomExitPos
? exit.ExitGridPos
: GetExitFallbackGridPos(room, exit);
conn.rectTransform.anchoredPosition = new Vector2(
gridPos.x * MapGridConstants.FullMapCellPixels,
gridPos.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()
{
// R12-N1 禁用入池而非销毁
foreach (var img in _exitImages)
{
if (img == null) continue;
img.gameObject.SetActive(false);
_exitPool.Push(img);
}
_exitImages.Clear();
}
[Obsolete("R12-N8: 由 OnExplorationChanged 统一处理,此方法仅保留序列化兼容性,请勿新增调用。")]
private void OnMapUpdated(string roomId) { /* R12-N8 已废弃:由 OnExplorationChanged 统一处理,此方法保留避免序列化引用问题 */ }
// ── 玩家位置图标 ──────────────────────────────────────────────────────
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);
// 强制玩家图标渲染在所有格子/出口连线/Pin 之上,避免 Prefab 中层级配置错误导致被遮挡
_playerIconImg.transform.SetAsLastSibling();
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>
/// 当前地图缩放系数(从 _roomContainer.localScale.x 读取)。
/// 供 MapInputHandler 使用以消除双份状态MapInputHandler._zoom 写入 _roomContainer
/// CenterOnCurrentRoom 与 Update 均从此属性读取,保证两处始终一致。
/// </summary>
public float CurrentZoom => _roomContainer != null ? _roomContainer.localScale.x : 1f;
/// <summary>将 ScrollRect 视口居中到玩家当前所在房间。可由外部(如 MapInputHandler调用。</summary>
public void CenterOnCurrentRoom()
{
if (_scrollRect == null || _playerProvider == null) return;
var roomId = _playerProvider.CurrentRoomId;
if (string.IsNullOrEmpty(roomId) || !_cells.TryGetValue(roomId, out var cell)) return;
// R11-N4 不再在此调用 ForceRebuildLayoutImmediate已移至 BuildGrid 末尾);
// 直接从 cell.RT 读取位置——格子由 Setup 手动定位,无需 LayoutGroup 重建。
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;
// R18-N1 / R19-N1 使用 CurrentZoom 属性(读取 _roomContainer.localScale.x
// 缩放后实际可滚动范围 = contentSize * zoom - viewSize同步修正 cellX/cellY 的像素偏移。
float zoom = CurrentZoom;
float rangeX = contentSize.x * zoom - viewSize.x;
float rangeY = contentSize.y * zoom - viewSize.y;
float normX = rangeX > 0 ? Mathf.Clamp01((cellX * zoom - viewSize.x * 0.5f) / rangeX) : 0.5f;
float normY = rangeY > 0 ? Mathf.Clamp01((cellY * zoom - viewSize.y * 0.5f) / rangeY) : 0.5f;
_scrollRect.normalizedPosition = new Vector2(normX, normY);
}
// ── 地图标记渲染 ──────────────────────────────────────────────────────
private void RenderPins()
{
if (_pinService == null) return;
// 版本号脏检查Pin 集合未变化时跳过重绘,避免无效 Instantiate
// 初始值 -1 保证首次 RenderPins 必然执行
if (_pinService.PinsVersion == _lastPinVersion) return;
_lastPinVersion = _pinService.PinsVersion;
ClearPins();
if (_pinPrefab == null) return;
foreach (var pin in _pinService.Pins)
{
if (!_cells.TryGetValue(pin.RoomId, out var cell)) continue;
// R10-N3 优先复用对象池中的 Pin Image避免高频 Instantiate
Image img;
if (_pinPool.Count > 0)
{
img = _pinPool.Pop();
img.gameObject.SetActive(true);
}
else
{
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()
{
// R10-N3 禁用入池而非销毁,减少 GC 与下次创建开销
foreach (var img in _pinImages)
{
if (img == null) continue;
img.gameObject.SetActive(false);
_pinPool.Push(img);
}
_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)
// R20-N2 委托到 MapRoomDataSO.ChooseDisplayIcon消除与 MinimapHUD 的重复实现
=> room.ChooseDisplayIcon(_iconSavePoint, _iconBossRoom, _iconShop, _iconTeleport);
private Sprite GetPinSprite(PinType type)
=> _pinConfig != null ? _pinConfig.GetSprite(type) : null;
/// <summary>
/// R13-N1 当出口未配置自定义坐标时,按 ExitDirection 推算房间边缘中点。
/// 避免 ExitGridPos 默认 (0,0) 导致所有连接线渲染到容器原点。
/// </summary>
private static Vector2Int GetExitFallbackGridPos(MapRoomDataSO room, RoomExitData exit)
=> exit.Direction switch
{
ExitDirection.Up => new Vector2Int(room.GridPosition.x + room.GridSize.x / 2,
room.GridPosition.y + room.GridSize.y),
ExitDirection.Down => new Vector2Int(room.GridPosition.x + room.GridSize.x / 2,
room.GridPosition.y),
ExitDirection.Right => new Vector2Int(room.GridPosition.x + room.GridSize.x,
room.GridPosition.y + room.GridSize.y / 2),
ExitDirection.Left => new Vector2Int(room.GridPosition.x,
room.GridPosition.y + room.GridSize.y / 2),
_ => room.GridPosition + room.GridSize / 2,
};
}
}