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
This commit is contained in:
2026-05-25 14:25:19 +08:00
parent a1f9122153
commit 5cb6c2a19d
64 changed files with 2358 additions and 32937 deletions

View File

@@ -0,0 +1,148 @@
#if UNITY_EDITOR
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using BaseGames.World.Map;
namespace BaseGames.Editor.Map
{
/// <summary>
/// MapDatabaseSO 的自定义 Inspector
/// <list type="bullet">
/// <item>显示房间总数、已配置出口数</item>
/// <item>一键 "重新验证",将错误以 HelpBox 列出</item>
/// <item>一键 "打开布局编辑器" 跳转到 MapLayoutEditorWindow</item>
/// <item>可展开的房间列表,每行点击可 Ping 对应 SO</item>
/// </list>
/// </summary>
[CustomEditor(typeof(MapDatabaseSO))]
public class MapDatabaseEditor : UnityEditor.Editor
{
private MapDatabaseSO _database;
private List<string> _lastErrors;
private bool _roomListExpanded;
private Vector2 _roomListScroll;
/// <summary>
/// 缓存的错误房间 ID 集合,仅在"重新验证"按钮点击时重建。
/// 避免 OnInspectorGUI高频重绘每次都重建 O(N²) 扫描。
/// </summary>
private readonly HashSet<string> _cachedErrorRoomIds = new();
private static readonly GUIContent LabelValidate = new GUIContent("重新验证", "检查重复 RoomId、出口目标缺失、房间网格重叠等问题");
private static readonly GUIContent LabelOpenEditor = new GUIContent("打开布局编辑器", "在独立窗口中预览全局地图布局");
private void OnEnable() => _database = (MapDatabaseSO)target;
public override void OnInspectorGUI()
{
serializedObject.Update();
// ── 统计摘要 ──────────────────────────────────────────────────────
int roomCount = _database.AllRooms?.Length ?? 0;
int exitCount = 0;
if (_database.AllRooms != null)
foreach (var r in _database.AllRooms)
if (r?.Exits != null) exitCount += r.Exits.Length;
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
EditorGUILayout.LabelField("📊 数据库统计", EditorStyles.boldLabel);
EditorGUI.indentLevel++;
EditorGUILayout.LabelField("房间总数", roomCount.ToString());
EditorGUILayout.LabelField("出口总数", exitCount.ToString());
EditorGUI.indentLevel--;
EditorGUILayout.EndVertical();
EditorGUILayout.Space(4);
// ── 操作按钮 ──────────────────────────────────────────────────────
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button(LabelValidate, GUILayout.Height(28)))
{
_lastErrors = _database.ValidateAll();
// 构建错误 RoomId 集合(使用引号匹配,防止 "Room_A1" 误匹配 "Room_A10"
_cachedErrorRoomIds.Clear();
if (_lastErrors.Count > 0 && _database.AllRooms != null)
foreach (var err in _lastErrors)
foreach (var r in _database.AllRooms)
if (r != null && err.Contains($"'{r.RoomId}'"))
_cachedErrorRoomIds.Add(r.RoomId);
if (_lastErrors.Count == 0)
Debug.Log("[MapDatabase] 验证通过,未发现问题 ✓");
else
Debug.LogWarning($"[MapDatabase] 发现 {_lastErrors.Count} 项问题,详见 Inspector。");
}
if (GUILayout.Button(LabelOpenEditor, GUILayout.Height(28)))
{
var win = EditorWindow.GetWindow<MapLayoutEditorWindow>("地图布局编辑器");
win.SetDatabase(_database);
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(4);
// ── 验证结果 ──────────────────────────────────────────────────────
if (_lastErrors != null)
{
if (_lastErrors.Count == 0)
{
EditorGUILayout.HelpBox("验证通过,无问题 ✓", MessageType.Info);
}
else
{
string errorText = string.Join("\n", _lastErrors);
EditorGUILayout.HelpBox(errorText, MessageType.Warning);
}
}
EditorGUILayout.Space(4);
// ── 默认字段AllRooms 数组等) ───────────────────────────────────
DrawDefaultInspector();
EditorGUILayout.Space(4);
// ── 可折叠房间列表(快速总览) ────────────────────────────────────
_roomListExpanded = EditorGUILayout.Foldout(_roomListExpanded, $"房间列表({roomCount}", true);
if (_roomListExpanded && _database.AllRooms != null)
{
_roomListScroll = EditorGUILayout.BeginScrollView(_roomListScroll, GUILayout.MaxHeight(200));
foreach (var room in _database.AllRooms)
{
if (room == null)
{
EditorGUILayout.HelpBox("空引用AllRooms 中有 null 元素)", MessageType.Error);
continue;
}
bool hasError = _cachedErrorRoomIds.Contains(room.RoomId);
var rowStyle = hasError
? new GUIStyle(EditorStyles.label) { normal = { textColor = new Color(1f, 0.35f, 0.35f) } }
: EditorStyles.label;
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField(
$"{(hasError ? " " : "")}{room.RoomId} [{room.GridPosition.x},{room.GridPosition.y}] {room.GridSize.x}×{room.GridSize.y}",
rowStyle);
if (GUILayout.Button("Ping", EditorStyles.miniButton, GUILayout.Width(40)))
{
Selection.activeObject = room;
EditorGUIUtility.PingObject(room);
}
EditorGUILayout.EndHorizontal();
}
EditorGUILayout.EndScrollView();
}
serializedObject.ApplyModifiedProperties();
}
}
}
#endif

View File

@@ -0,0 +1,391 @@
#if UNITY_EDITOR
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using BaseGames.World.Map;
namespace BaseGames.Editor.Map
{
/// <summary>
/// 全局地图布局预览编辑器窗口(架构 15_MapShopModule §5.2)。
/// 菜单BaseGames/Map/Map Layout Editor
/// <list type="bullet">
/// <item>以格子坐标显示 MapDatabaseSO 中所有房间</item>
/// <item>鼠标滚轮缩放,中键(或 Alt+左键)拖拽平移</item>
/// <item>按区域自动着色,红色高亮配置错误(重叠/重复)</item>
/// <item>左键点击房间Selection + Ping 对应 SO并在 Inspector 中打开</item>
/// </list>
/// </summary>
public class MapLayoutEditorWindow : EditorWindow
{
[MenuItem("BaseGames/Map/Map Layout Editor", priority = 100)]
public static void ShowWindow() => GetWindow<MapLayoutEditorWindow>("地图布局编辑器");
// ── 状态 ──────────────────────────────────────────────────────────────
private MapDatabaseSO _database;
private Vector2 _panOffset;
private float _zoom = 24f; // px / 格
private bool _isDragging;
private Vector2 _dragStart;
private MapRoomDataSO _selectedRoom;
private List<string> _validationErrors;
private HashSet<string> _errorRoomIds;
/// <summary>缓存的错误房间集合,由验证按钮点击时重建(防止 OnInspectorGUI 高频重建导致 GC。</summary>
private readonly HashSet<string> _cachedErrorRoomIds = new();
private readonly Dictionary<string, Color> _regionColors = new();
private int _paletteIndex;
// 缓存 GUIStyle避免每帧每格重新分配约 100 房间 × 60fps = 大量 GC 压力)
private GUIStyle _roomLabelStyle;
private GUIStyle _badgeBossStyle;
private GUIStyle _badgeNormalStyle;
private float _cachedZoomForStyle = -1f; // 用于检测缩放变化时重建 Style
// 区域配色方案(与 MapExploration 标注颜色视觉呼应)
private static readonly Color[] Palette =
{
new Color(0.30f, 0.60f, 1.00f),
new Color(0.30f, 1.00f, 0.55f),
new Color(1.00f, 0.60f, 0.25f),
new Color(0.85f, 0.30f, 0.90f),
new Color(1.00f, 0.88f, 0.20f),
new Color(0.25f, 0.90f, 1.00f),
new Color(1.00f, 0.45f, 0.45f),
new Color(0.50f, 1.00f, 0.80f),
};
// ── 主 GUI ────────────────────────────────────────────────────────────
private void OnGUI()
{
DrawToolbar();
if (_database == null)
{
EditorGUILayout.HelpBox("请在上方选择 MapDatabaseSO 资产以预览地图布局。", MessageType.Info);
return;
}
// 验证错误摘要
if (_validationErrors != null && _validationErrors.Count > 0)
{
int shown = Mathf.Min(3, _validationErrors.Count);
string msg = string.Join("\n", _validationErrors.GetRange(0, shown));
if (_validationErrors.Count > shown) msg += $"\n…共 {_validationErrors.Count} 项)";
EditorGUILayout.HelpBox(msg, MessageType.Warning);
}
// 地图绘制区域
Rect mapRect = GUILayoutUtility.GetRect(
GUIContent.none, GUIStyle.none,
GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true));
HandleInput(mapRect);
DrawMapArea(mapRect);
}
// ── 工具栏 ────────────────────────────────────────────────────────────
private void DrawToolbar()
{
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
var newDb = (MapDatabaseSO)EditorGUILayout.ObjectField(
_database, typeof(MapDatabaseSO), false, GUILayout.Width(240));
if (newDb != _database)
{
_database = newDb;
_validationErrors = null;
_errorRoomIds = null;
_regionColors.Clear();
_paletteIndex = 0;
_selectedRoom = null;
Repaint();
}
GUILayout.FlexibleSpace();
if (GUILayout.Button("验 证", EditorStyles.toolbarButton, GUILayout.Width(60)))
RunValidation();
if (GUILayout.Button("重置视图", EditorStyles.toolbarButton, GUILayout.Width(72)))
{
_zoom = 24f;
_panOffset = Vector2.zero;
Repaint();
}
EditorGUILayout.LabelField($"缩放 {_zoom:F0}px/格", GUILayout.Width(80));
EditorGUILayout.EndHorizontal();
}
// ── 输入处理 ──────────────────────────────────────────────────────────
private void HandleInput(Rect mapRect)
{
var e = Event.current;
if (!mapRect.Contains(e.mousePosition)) return;
switch (e.type)
{
case EventType.ScrollWheel:
_zoom = Mathf.Clamp(_zoom - e.delta.y * 1.8f, 6f, 96f);
e.Use();
Repaint();
break;
// 中键拖拽 或 Alt+左键拖拽 平移
case EventType.MouseDown when e.button == 2 || (e.button == 0 && e.alt):
_isDragging = true;
_dragStart = e.mousePosition;
e.Use();
break;
case EventType.MouseDrag when _isDragging:
_panOffset += e.mousePosition - _dragStart;
_dragStart = e.mousePosition;
e.Use();
Repaint();
break;
case EventType.MouseUp when _isDragging:
_isDragging = false;
e.Use();
break;
// 左键(非 Alt点击选中房间
case EventType.MouseUp when e.button == 0 && !e.alt:
TrySelectRoom(e.mousePosition - mapRect.position, mapRect);
break;
}
}
// ── 地图绘制 ──────────────────────────────────────────────────────────
private void DrawMapArea(Rect mapRect)
{
if (_database?.AllRooms == null) return;
EnsureLabelStyles();
GUI.BeginClip(mapRect);
Vector2 origin = mapRect.size * 0.5f + _panOffset;
foreach (var room in _database.AllRooms)
{
if (room == null) continue;
Rect cell = RoomToClipRect(room, origin);
// 填充
bool hasError = _errorRoomIds != null && _errorRoomIds.Contains(room.RoomId);
Color regionColor = GetRegionColor(room.RegionId);
Color fillColor = hasError
? new Color(1f, 0.15f, 0.15f, 0.55f)
: new Color(regionColor.r, regionColor.g, regionColor.b, 0.28f);
EditorGUI.DrawRect(cell, fillColor);
// 边框
Color borderColor = room == _selectedRoom
? new Color(1f, 0.9f, 0.1f, 1f)
: new Color(regionColor.r, regionColor.g, regionColor.b, 0.85f);
float border = room == _selectedRoom ? 2f : 1f;
DrawBorder(cell, borderColor, border);
// 标签(缩放足够大时才显示,使用缓存 Style
if (_zoom >= 18f)
GUI.Label(cell, string.IsNullOrEmpty(room.RoomId) ? "?" : room.RoomId, _roomLabelStyle);
// 特殊图标标记Boss / 存档点 / 商店)
DrawRoomBadge(cell, room);
}
// 出口连线(缩放足够大时)
if (_zoom >= 12f)
DrawExitLines(origin);
GUI.EndClip();
}
/// <summary>
/// 按需重建 GUIStyle 缓存——仅当 zoom 发生变化(或首次调用)时重建,
/// 避免每帧每格 <c>new GUIStyle(...)</c> 造成大量 GC 分配。
/// </summary>
private void EnsureLabelStyles()
{
if (Mathf.Approximately(_cachedZoomForStyle, _zoom) && _roomLabelStyle != null) return;
_cachedZoomForStyle = _zoom;
int fontSize = Mathf.Clamp(Mathf.RoundToInt(_zoom * 0.4f), 8, 14);
_roomLabelStyle = new GUIStyle(EditorStyles.miniLabel)
{
alignment = TextAnchor.MiddleCenter,
wordWrap = false,
normal = { textColor = Color.white },
fontSize = fontSize,
};
int badgeSize = Mathf.Max(8, Mathf.RoundToInt(_zoom * 0.35f));
_badgeBossStyle = new GUIStyle(EditorStyles.miniLabel)
{
alignment = TextAnchor.UpperRight,
normal = { textColor = new Color(1f, 0.4f, 0.4f) },
fontSize = badgeSize,
};
_badgeNormalStyle = new GUIStyle(EditorStyles.miniLabel)
{
alignment = TextAnchor.UpperRight,
normal = { textColor = Color.cyan },
fontSize = badgeSize,
};
}
private void DrawRoomBadge(Rect cell, MapRoomDataSO room)
{
if (!room.IsBossRoom && !room.IsSavePoint && !room.IsShop) return;
if (cell.width < 8f) return;
string badge = room.IsBossRoom ? "★" :
room.IsSavePoint ? "♦" : "¥";
GUI.Label(cell, badge, room.IsBossRoom ? _badgeBossStyle : _badgeNormalStyle);
}
private void DrawExitLines(Vector2 origin)
{
if (_database?.AllRooms == null) return;
var lineColor = new Color(1f, 1f, 0.5f, 0.35f);
foreach (var room in _database.AllRooms)
{
if (room?.Exits == null) continue;
foreach (var exit in room.Exits)
{
var target = _database.GetRoom(exit.TargetRoomId);
if (target == null) continue;
Vector2 from = GridCenterToClip(room.GridPosition + room.GridSize / 2, origin);
Vector2 to = GridCenterToClip(target.GridPosition + target.GridSize / 2, origin);
DrawLine(from, to, lineColor, 1.5f);
}
}
}
// ── 公共 API ──────────────────────────────────────────────────────────
/// <summary>由外部(如 MapDatabaseEditor注入 Database避免反射访问私有字段。</summary>
public void SetDatabase(MapDatabaseSO db)
{
_database = db;
_validationErrors = null;
_errorRoomIds = null;
_regionColors.Clear();
_paletteIndex = 0;
_selectedRoom = null;
Repaint();
}
// ── 房间选择 ──────────────────────────────────────────────────────────
private void TrySelectRoom(Vector2 clipPos, Rect mapRect)
{
if (_database?.AllRooms == null) return;
// 使用与 DrawMapArea 完全相同的 origin 公式,确保命中测试一致
Vector2 origin = mapRect.size * 0.5f + _panOffset;
foreach (var room in _database.AllRooms)
{
if (room == null) continue;
Rect cell = RoomToClipRect(room, origin);
if (cell.Contains(clipPos))
{
_selectedRoom = room;
Selection.activeObject = room;
EditorGUIUtility.PingObject(room);
Repaint();
return;
}
}
_selectedRoom = null;
Repaint();
}
// ── 工具方法 ──────────────────────────────────────────────────────────
private Rect RoomToClipRect(MapRoomDataSO room, Vector2 origin)
{
float x = origin.x + room.GridPosition.x * _zoom;
// Y 轴:格子坐标 Y 向上,屏幕 Y 向下,需翻转
float y = origin.y - (room.GridPosition.y + room.GridSize.y) * _zoom;
return new Rect(x, y, room.GridSize.x * _zoom, room.GridSize.y * _zoom);
}
private Vector2 GridCenterToClip(Vector2Int gridPos, Vector2 origin)
=> new Vector2(origin.x + gridPos.x * _zoom, origin.y - gridPos.y * _zoom);
private Color GetRegionColor(string regionId)
{
if (string.IsNullOrEmpty(regionId)) return new Color(0.55f, 0.55f, 0.55f);
if (!_regionColors.TryGetValue(regionId, out var c))
{
c = Palette[_paletteIndex % Palette.Length];
_paletteIndex++;
_regionColors[regionId] = c;
}
return c;
}
private void RunValidation()
{
_validationErrors = _database?.ValidateAll() ?? new List<string>();
_errorRoomIds = new HashSet<string>();
if (_validationErrors != null && _database?.AllRooms != null)
foreach (var err in _validationErrors)
foreach (var r in _database.AllRooms)
if (r != null && err.Contains($"'{r.RoomId}'"))
_errorRoomIds.Add(r.RoomId);
Repaint();
}
// ── 绘图辅助 ──────────────────────────────────────────────────────────
private static void DrawBorder(Rect r, Color color, float t)
{
var prev = GUI.color;
GUI.color = color;
GUI.DrawTexture(new Rect(r.x, r.y, r.width, t), EditorGUIUtility.whiteTexture);
GUI.DrawTexture(new Rect(r.x, r.yMax - t, r.width, t), EditorGUIUtility.whiteTexture);
GUI.DrawTexture(new Rect(r.x, r.y, t, r.height), EditorGUIUtility.whiteTexture);
GUI.DrawTexture(new Rect(r.xMax - t, r.y, t, r.height), EditorGUIUtility.whiteTexture);
GUI.color = prev;
}
// 用细矩形近似绘制连线(无需 GL/Handles
private static void DrawLine(Vector2 a, Vector2 b, Color color, float thickness)
{
Vector2 dir = b - a;
float len = dir.magnitude;
if (len < 1f) return;
float angle = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg;
Vector2 mid = (a + b) * 0.5f;
var prevMatrix = GUI.matrix;
var prevColor = GUI.color;
GUI.color = color;
GUIUtility.RotateAroundPivot(angle, mid);
GUI.DrawTexture(new Rect(mid.x - len * 0.5f, mid.y - thickness * 0.5f, len, thickness),
EditorGUIUtility.whiteTexture);
GUI.matrix = prevMatrix;
GUI.color = prevColor;
}
}
}
#endif

View File

@@ -13,5 +13,14 @@ namespace BaseGames.World.Map
/// <summary>玩家当前所在区域 ID最近一次 EVT_RegionChanged 对应的值)。</summary>
string CurrentRegionId { get; }
/// <summary>已踏入的房间总数。</summary>
int ExploredRoomCount { get; }
/// <summary>探索进度 0~1已探索房间数 / 数据库总房间数)。</summary>
float GetExplorationProgress();
/// <summary>返回属于指定区域的所有房间数据regionId 为空时返回空数组。</summary>
MapRoomDataSO[] GetRoomsByRegion(string regionId);
}
}

View File

@@ -0,0 +1,31 @@
using System.Collections.Generic;
using BaseGames.Core.Save;
namespace BaseGames.World.Map
{
/// <summary>
/// 玩家地图标记服务接口。
/// <para>
/// MapPanel 依赖此接口而非具体组件,
/// 允许替换实现(云存档、多存档槽等扩展场景)。
/// </para>
/// 通过 <see cref="BaseGames.Core.ServiceLocator"/> 注册与获取。
/// </summary>
public interface IPinService
{
/// <summary>当前所有地图标记(只读视图)。</summary>
IReadOnlyList<MapPin> Pins { get; }
/// <summary>
/// 每次增删标记时自增的版本号,供消费方进行脏检查。
/// </summary>
int PinsVersion { get; }
/// <summary>创建并添加一个地图标记。</summary>
MapPin CreatePin(string roomId, float normX, float normY,
PinType type = PinType.Marker, string note = "");
/// <summary>移除指定地图标记。</summary>
void RemovePin(MapPin pin);
}
}

View File

@@ -0,0 +1,28 @@
using System;
using UnityEngine;
namespace BaseGames.World.Map
{
/// <summary>
/// 玩家地图位置信息提供者接口。
/// <para>
/// MapPanel / MinimapHUD 依赖此接口而非具体组件,
/// 支持替换实现(单人、多人、观察者模式、重播系统等场景)。
/// </para>
/// 通过 <see cref="BaseGames.Core.ServiceLocator"/> 注册与获取。
/// </summary>
public interface IPlayerPositionProvider
{
/// <summary>玩家当前所在房间 ID未在任何已知房间内时为 null 或空字符串。</summary>
string CurrentRoomId { get; }
/// <summary>
/// 玩家在当前房间内的归一化坐标0~1基于世界坐标精确插值
/// 每帧更新,可用于平滑移动图标。
/// </summary>
Vector2 NormalizedPositionInRoom { get; }
/// <summary>玩家进入新房间时触发(参数为新房间 ID。</summary>
event Action<string> OnRoomChanged;
}
}

View File

@@ -0,0 +1,12 @@
namespace BaseGames.World.Map
{
/// <summary>
/// 地图格子渲染常量。
/// 统一管理像素/格比例,避免魔法数字分散在多个文件中。
/// </summary>
public static class MapGridConstants
{
/// <summary>全屏地图每格像素数MapPanel / MapRoomCellUI 使用)。</summary>
public const float FullMapCellPixels = 32f;
}
}

View File

@@ -0,0 +1,78 @@
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace BaseGames.World.Map
{
/// <summary>
/// 全屏地图输入处理器(架构 15_MapShopModule §1.3.1)。
/// 挂在与 MapPanel 相同的 GameObject 上MapPanel OnEnable/OnDisable 联动启停)。
/// <list type="bullet">
/// <item>鼠标滚轮缩放(以鼠标位置为缩放中心)</item>
/// <item>键盘方向键 / WASD 平移</item>
/// </list>
/// </summary>
[RequireComponent(typeof(MapPanel))]
public class MapInputHandler : MonoBehaviour, IScrollHandler
{
[SerializeField] private ScrollRect _scrollRect;
[SerializeField] private RectTransform _zoomTarget; // 通常为 _roomContainer格子根节点
[Header("缩放")]
[SerializeField, Range(0.2f, 1f)] private float _zoomMin = 0.4f;
[SerializeField, Range(1f, 5f)] private float _zoomMax = 3.0f;
[SerializeField, Range(0.05f, 0.5f)] private float _zoomStep = 0.12f;
[Header("键盘平移")]
[SerializeField] private float _keyPanSpeed = 600f; // px / 秒
private float _zoom = 1f;
private void OnEnable()
{
// 重新激活时还原缩放,避免上次关闭时的残留状态
if (_zoomTarget != null)
_zoom = _zoomTarget.localScale.x;
}
private void Update()
{
if (_scrollRect == null) return;
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
if (h == 0 && v == 0) return;
var delta = new Vector2(h, v) * (_keyPanSpeed * Time.unscaledDeltaTime);
_scrollRect.content.anchoredPosition += delta;
}
// ── 鼠标滚轮缩放 ─────────────────────────────────────────────────────
public void OnScroll(PointerEventData eventData)
{
if (_zoomTarget == null) return;
float newZoom = Mathf.Clamp(
_zoom + eventData.scrollDelta.y * _zoomStep,
_zoomMin, _zoomMax);
if (Mathf.Approximately(newZoom, _zoom)) return;
// 将鼠标屏幕坐标转为 zoomTarget 本地坐标(缩放前)
RectTransformUtility.ScreenPointToLocalPointInRectangle(
_zoomTarget, eventData.position, eventData.pressEventCamera, out Vector2 pivotBefore);
_zoom = newZoom;
_zoomTarget.localScale = new Vector3(_zoom, _zoom, 1f);
// 将同一屏幕点再次映射(缩放后),计算偏移量保持鼠标下方内容不动
RectTransformUtility.ScreenPointToLocalPointInRectangle(
_zoomTarget, eventData.position, eventData.pressEventCamera, out Vector2 pivotAfter);
// pivotAfter - pivotBefore 是 zoomTarget 本地空间的偏差,需转为父空间偏差
Vector2 offset = pivotAfter - pivotBefore;
_zoomTarget.anchoredPosition += offset * _zoom;
}
}
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
@@ -28,6 +29,7 @@ namespace BaseGames.World.Map
private HashSet<string> _exploredRooms = new();
private HashSet<string> _mappedRooms = new();
private string _currentRegionId;
private int _totalRoomCount = -1; // -1 = 未缓存OnLoad 后重置
private readonly CompositeDisposable _subs = new();
private void Awake()
@@ -58,8 +60,9 @@ namespace BaseGames.World.Map
public void OnLoad(SaveData data)
{
_exploredRooms = data.Map.ExploredRooms != null ? new HashSet<string>(data.Map.ExploredRooms) : new HashSet<string>();
_mappedRooms = data.Map.MappedRooms != null ? new HashSet<string>(data.Map.MappedRooms) : new HashSet<string>();
_exploredRooms = data.Map.ExploredRooms != null ? new HashSet<string>(data.Map.ExploredRooms) : new HashSet<string>();
_mappedRooms = data.Map.MappedRooms != null ? new HashSet<string>(data.Map.MappedRooms) : new HashSet<string>();
_totalRoomCount = -1; // 强制下次调用 GetExplorationProgress 时重新计数
}
// ── 事件驱动房间发现 ──────────────────────────────────────────────────
@@ -94,8 +97,23 @@ namespace BaseGames.World.Map
public bool IsExplored(string roomId) => _exploredRooms.Contains(roomId);
public bool IsMapped(string roomId) => _mappedRooms.Contains(roomId);
public string CurrentRegionId => _currentRegionId;
public MapDatabaseSO Database => _database;
public int ExploredRoomCount => _exploredRooms.Count;
public MapDatabaseSO Database => _database;
public float GetExplorationProgress()
{
if (_database?.AllRooms == null || _database.AllRooms.Length == 0) return 0f;
if (_totalRoomCount < 0)
_totalRoomCount = _database.AllRooms.Count(r => r != null);
return _totalRoomCount > 0 ? Mathf.Clamp01((float)_exploredRooms.Count / _totalRoomCount) : 0f;
}
public MapRoomDataSO[] GetRoomsByRegion(string regionId)
{
if (string.IsNullOrEmpty(regionId) || _database?.AllRooms == null)
return System.Array.Empty<MapRoomDataSO>();
return _database.AllRooms.Where(r => r != null && r.RegionId == regionId).ToArray();
}
private void OnDestroy()
{

View File

@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
@@ -10,16 +9,17 @@ using BaseGames.Core.Save;
namespace BaseGames.World.Map
{
/// <summary>房间可见性三级状态:未知 / 已踏入 / 已标注(购买地图碎片)。</summary>
public enum RoomVisibility { Unknown, Explored, Mapped }
/// <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 MapDatabaseSO _database;
[SerializeField] private RectTransform _roomContainer; // 格子图放置根节点
[SerializeField] private MapRoomCellUI _cellPrefab; // 地图格子预制
[SerializeField] private Image _exitConnectorPrefab; // 出口连接线预制(小矩形 Image
@@ -37,13 +37,11 @@ namespace BaseGames.World.Map
[SerializeField] private Color _colorUnknown = Color.black;
[Header("玩家位置")]
[SerializeField] private MapPlayerTracker _playerTracker; // 挂在 Player 上的追踪器
[SerializeField] private Image _playerIconImg; // _roomContainer 内的玩家图标
[SerializeField] private Image _playerIconImg; // _roomContainer 内的玩家图标
[Header("地图标记")]
[SerializeField] private Image _pinPrefab;
[SerializeField] private PinSpriteEntry[] _pinSprites; // PinType → 对应 Sprite
[SerializeField] private MapPinManager _pinManager;
[SerializeField] private PinSpriteEntry[] _pinSprites; // PinType → 对应 Sprite(在 Inspector 中配置)
[Header("Tooltip")]
[SerializeField] private GameObject _tooltipPanel;
@@ -52,16 +50,34 @@ namespace BaseGames.World.Map
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onMapUpdated; // 房间发现/标注时刷新
private Dictionary<string, MapRoomCellUI> _cells = new();
private List<Image> _pinImages = new();
private List<Image> _exitImages = new();
private Dictionary<string, MapRoomCellUI> _cells = new();
private List<Image> _pinImages = new();
private List<Image> _exitImages= new();
private string _highlightedRoomId;
private IMapService _mapSvc; // 面板活跃期间缓存,避免高频 ServiceLocator 查询
private readonly CompositeDisposable _subs = new();
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>();
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
_playerProvider = ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
_pinService = ServiceLocator.GetOrDefault<IPinService>();
// 首次打开时建立格子;后续打开只刷新探索状态,跳过重复 Instantiate
if (_cells.Count == 0)
@@ -78,7 +94,11 @@ namespace BaseGames.World.Map
private void OnDisable()
{
_subs.Clear();
_mapSvc = null;
_mapSvc = null;
_playerProvider = null;
_pinService = null;
_lastIconRoomId = null;
_lastIconNormPos = Vector2.zero;
HideTooltip();
}
@@ -93,6 +113,12 @@ namespace BaseGames.World.Map
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();
}
@@ -102,7 +128,7 @@ namespace BaseGames.World.Map
foreach (var (roomId, cell) in _cells)
{
if (cell == null) continue;
cell.SetVisibility(GetVisibility(_mapSvc, roomId));
cell.SetVisibility(_mapSvc.GetVisibility(roomId));
}
}
@@ -110,13 +136,15 @@ namespace BaseGames.World.Map
private void BuildGrid()
{
if (_database?.AllRooms == null) return;
foreach (var room in _database.AllRooms)
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, GetVisibility(_mapSvc, room.RoomId), ChooseIcon(room),
cell.Setup(room, _mapSvc.GetVisibility(room.RoomId), ChooseIcon(room),
ShowTooltip, HideTooltip);
cell.SetColors(_colorExplored, _colorMapped, _colorUnknown);
_cells[room.RoomId] = cell;
}
DrawExits();
@@ -125,16 +153,18 @@ namespace BaseGames.World.Map
/// <summary>为每条出口在格子坐标处实例化一个小矩形连接线图像。</summary>
private void DrawExits()
{
if (_exitConnectorPrefab == null || _database?.AllRooms == null) return;
var db = _mapSvc?.Database;
if (_exitConnectorPrefab == null || db?.AllRooms == null) return;
ClearExits();
const float px = 32f;
foreach (var room in _database.AllRooms)
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 * px, exit.ExitGridPos.y * px);
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);
@@ -152,30 +182,30 @@ namespace BaseGames.World.Map
private void OnMapUpdated(string roomId)
{
if (_cells.TryGetValue(roomId, out var cell))
cell.SetVisibility(GetVisibility(_mapSvc, roomId));
cell.SetVisibility(_mapSvc.GetVisibility(roomId));
}
// ── 玩家位置图标 ──────────────────────────────────────────────────────
private void UpdatePlayerIcon()
{
if (_playerIconImg == null || _playerTracker == null) return;
var roomId = _playerTracker.CurrentRoomId;
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;
}
var cellRT = cell.GetComponent<RectTransform>();
_playerIconImg.sprite = _iconPlayerPos;
_playerIconImg.enabled = true;
_playerIconImg.rectTransform.anchoredPosition =
cellRT.anchoredPosition
+ Vector2.Scale(_playerTracker.NormalizedPositionInRoom, cellRT.sizeDelta);
cell.RT.anchoredPosition
+ Vector2.Scale(_playerProvider.NormalizedPositionInRoom, cell.RT.sizeDelta);
UpdateCellHighlight(roomId);
}
// ── 当前房间高亮 & ScrollRect 居中 ──────────────────────────────────────
// ── 当前房间高亮 & ScrollRect 居中 ─────────────────────────────────
/// <summary>切换高亮描边:取消旧房间高亮,激活新房间高亮。</summary>
private void UpdateCellHighlight(string roomId)
@@ -191,20 +221,20 @@ namespace BaseGames.World.Map
/// <summary>面板打开时将 ScrollRect 视口居中到玩家当前所在房间。</summary>
private void CenterOnCurrentRoom()
{
if (_scrollRect == null || _playerTracker == null) return;
var roomId = _playerTracker.CurrentRoomId;
if (_scrollRect == null || _playerProvider == null) return;
var roomId = _playerProvider.CurrentRoomId;
if (string.IsNullOrEmpty(roomId) || !_cells.TryGetValue(roomId, out var cell)) return;
Canvas.ForceUpdateCanvases();
// 仅重建 ScrollRect.content 的布局,避免全 Canvas 树强制刷新
LayoutRebuilder.ForceRebuildLayoutImmediate(_scrollRect.content);
var content = _scrollRect.content;
var viewport = _scrollRect.viewport != null
? _scrollRect.viewport
: (RectTransform)_scrollRect.transform;
var cellRT = cell.GetComponent<RectTransform>();
// 将 cell 中心转换到 content 本地坐标系
Vector2 cellWorldCenter = cellRT.TransformPoint(cellRT.rect.center);
Vector2 cellWorldCenter = cell.RT.TransformPoint(cell.RT.rect.center);
Vector2 cellLocal = content.InverseTransformPoint(cellWorldCenter);
// 距 content 左下角的距离pivot 无关)
@@ -221,22 +251,28 @@ namespace BaseGames.World.Map
_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 || _pinManager == null) return;
foreach (var pin in _pinManager.Pins)
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);
var cellRT = cell.GetComponent<RectTransform>();
img.rectTransform.anchoredPosition =
cellRT.anchoredPosition + new Vector2(
pin.NormalizedPosX * cellRT.sizeDelta.x,
pin.NormalizedPosY * cellRT.sizeDelta.y);
cell.RT.anchoredPosition + new Vector2(
pin.NormalizedPosX * cell.RT.sizeDelta.x,
pin.NormalizedPosY * cell.RT.sizeDelta.y);
_pinImages.Add(img);
}
}
@@ -261,15 +297,6 @@ namespace BaseGames.World.Map
// ── 辅助方法 ──────────────────────────────────────────────────────────
/// <summary>按优先级推导可见性Explored > Mapped > Unknown。</summary>
private static RoomVisibility GetVisibility(IMapService svc, string roomId)
{
if (svc == null) return RoomVisibility.Unknown;
if (svc.IsExplored(roomId)) return RoomVisibility.Explored;
if (svc.IsMapped(roomId)) return RoomVisibility.Mapped;
return RoomVisibility.Unknown;
}
private Sprite ChooseIcon(MapRoomDataSO room)
{
if (room.MapIconOverride != null) return room.MapIconOverride;
@@ -280,95 +307,6 @@ namespace BaseGames.World.Map
}
private Sprite GetPinSprite(PinType type)
{
if (_pinSprites != null)
foreach (var e in _pinSprites)
if (e.PinType == type) return e.Sprite;
return null;
}
}
[Serializable]
public struct PinSpriteEntry
{
public PinType PinType;
public Sprite Sprite;
}
// ─── 单个地图格子 UI ─────────────────────────────────────────────────────────
/// <summary>地图面板中每个房间对应的格子 UI 组件。</summary>
public class MapRoomCellUI : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
{
[SerializeField] private Image _bg;
[SerializeField] private Image _icon;
[SerializeField] private RawImage _outlineImage; // 可选:房间非矩形轮廓纹理
[SerializeField] private Image _highlight; // 可选:当前房间高亮描边(玩家所在时激活)
private static readonly Color ColExplored = Color.white;
private static readonly Color ColMapped = new Color(0.45f, 0.45f, 0.45f, 1f);
private static readonly Color ColUnknown = Color.black;
private string _displayName;
private Action<string> _onHover;
private Action _onHoverExit;
/// <summary>初始化格子位置、可见性、图标、Tooltip 回调)。</summary>
public void Setup(MapRoomDataSO room, RoomVisibility visibility, Sprite icon,
Action<string> onHover = null, Action onHoverExit = null)
{
_displayName = room.DisplayName;
_onHover = onHover;
_onHoverExit = onHoverExit;
if (TryGetComponent<RectTransform>(out var rt))
{
rt.anchoredPosition = new Vector2(room.GridPosition.x * 32f, room.GridPosition.y * 32f);
rt.sizeDelta = new Vector2(room.GridSize.x * 32f, room.GridSize.y * 32f);
}
// 房间轮廓纹理(非矩形形状,覆盖在矩形背景上方)
if (_outlineImage != null)
{
_outlineImage.texture = room.RoomOutlineTex;
_outlineImage.enabled = room.RoomOutlineTex != null;
}
SetVisibility(visibility);
if (_icon != null)
{
_icon.sprite = icon;
_icon.enabled = icon != null;
}
}
public void SetVisibility(RoomVisibility v)
{
if (_bg == null) return;
_bg.color = v switch
{
RoomVisibility.Explored => ColExplored,
RoomVisibility.Mapped => ColMapped,
_ => ColUnknown,
};
}
/// <summary>向后兼容:直接传 bool 时等同于 Explored / Unknown。</summary>
public void SetDiscovered(bool v)
=> SetVisibility(v ? RoomVisibility.Explored : RoomVisibility.Unknown);
/// <summary>激活/取消当前房间高亮描边。</summary>
public void SetHighlight(bool v)
{
if (_highlight != null) _highlight.enabled = v;
}
public void OnPointerEnter(PointerEventData _)
{
if (!string.IsNullOrEmpty(_displayName)) _onHover?.Invoke(_displayName);
}
public void OnPointerExit(PointerEventData _) => _onHoverExit?.Invoke();
=> _pinSpriteDict.TryGetValue(type, out var s) ? s : null;
}
}

View File

@@ -1,3 +1,5 @@
// NOTE: 此文件包含 MapPinManager 类,但文件名为 MapPin.cs历史遗留Unity .meta 绑定限制不可安全重命名)。
// 如需搜索,请搜索 "MapPinManager" 类名,而非文件名。
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
@@ -5,28 +7,55 @@ using BaseGames.Core.Save;
namespace BaseGames.World.Map
{
/// <summary>标记类型与显示精灵的映射表项(从 MapPanel 移入,与数据同文件管理)。</summary>
[System.Serializable]
public class PinSpriteEntry
{
public PinType PinType;
public Sprite Sprite;
}
/// <summary>
/// 地图自定义标记管理器(架构 15_MapShopModule §1.5)。
/// 实现 ISaveable通过 SaveManager 持久化玩家地图标记
/// 实现 <see cref="ISaveable"/> 和 <see cref="IPinService"/>,通过 ServiceLocator 对外暴露
/// <para>
/// <see cref="PinsVersion"/> 每次 Pin 集合变化时自增外部消费方MapPanel
/// 可通过版本号判断是否需要重绘,避免无效 Instantiate。
/// </para>
/// MapPin/PinType 数据类定义在 SaveData.csBaseGames.Core.Save避免循环依赖。
/// </summary>
public class MapPinManager : MonoBehaviour, ISaveable
public class MapPinManager : MonoBehaviour, ISaveable, IPinService
{
private List<MapPin> _pins = new();
public IReadOnlyList<MapPin> Pins => _pins;
public IReadOnlyList<MapPin> Pins => _pins;
private void OnEnable() => ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
private void OnDisable() => ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
/// <summary>每次 Pin 集合发生变化时自增;外部消费方通过此版本号实现脏检查。</summary>
public int PinsVersion { get; private set; }
private void OnEnable()
{
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
ServiceLocator.Register<IPinService>(this);
}
private void OnDisable()
{
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
ServiceLocator.Unregister<IPinService>(this);
}
// ── 公共 API ──────────────────────────────────────────────────────────
public void AddPin(MapPin pin)
{
if (pin != null) _pins.Add(pin);
if (pin != null) { _pins.Add(pin); PinsVersion++; }
}
public void RemovePin(MapPin pin) => _pins.Remove(pin);
public void RemovePin(MapPin pin)
{
if (_pins.Remove(pin)) PinsVersion++;
}
/// <summary>便捷方法:用枚举类型创建并添加标记。</summary>
public MapPin CreatePin(string roomId, float normX, float normY,
@@ -47,6 +76,11 @@ namespace BaseGames.World.Map
// ── ISaveable ─────────────────────────────────────────────────────────
public void OnSave(SaveData data) => data.Map.Pins = _pins;
public void OnLoad(SaveData data) => _pins = data.Map.Pins ?? new List<MapPin>();
public void OnLoad(SaveData data)
{
_pins = data.Map.Pins ?? new List<MapPin>();
PinsVersion++; // 加载存档后通知消费方重绘
}
}
}

View File

@@ -1,49 +1,126 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
namespace BaseGames.World.Map
{
/// <summary>
/// 将玩家世界坐标转换为地图格子坐标,供 MapPanel 显示玩家位置图标(架构 15_MapShopModule §1.4
/// 将玩家世界坐标转换为地图格子坐标,供 MapPanel / MinimapHUD 显示玩家位置图标。
/// 挂在 Player GameObject 上LateUpdate 每帧计算)。
/// <para>
/// 性能:首次启动时建立 Dictionary&lt;Vector2Int, string&gt; 空间索引,
/// LateUpdate 房间判定为 O(1) 哈希查找。归一化位置每帧从世界坐标精确插值,
/// 确保图标跟随玩家平滑移动,而非以格子步长离散跳动。
/// </para>
/// 通过 <see cref="ServiceLocator"/> 注册为 <see cref="IPlayerPositionProvider"/>。
/// </summary>
public class MapPlayerTracker : MonoBehaviour
public class MapPlayerTracker : MonoBehaviour, IPlayerPositionProvider
{
[SerializeField] private Transform _playerTransform;
[SerializeField] private MapDatabaseSO _database;
[SerializeField] private MapDatabaseSO _databaseOverride; // 可选:直接指定;留空则从 IMapService 获取
[Header("世界坐标 → 格子坐标换算参数")]
[SerializeField] private float _worldUnitsPerCell = 18f; // 1 格 = N 世界单位
[Tooltip("1 格对应的世界单位数。请在关卡编辑器中测量房间实际尺寸后填入,确保与关卡设计对齐。")]
[SerializeField] private float _worldUnitsPerCell = 18f;
/// <summary>玩家当前所在房间 ID(用于地图高亮当前房间)。</summary>
/// <summary>玩家当前所在房间 ID;未在任何已知房间内时为 null。</summary>
public string CurrentRoomId { get; private set; }
/// <summary>玩家在当前格子房间内的归一化坐标0~1。</summary>
/// <summary>
/// 玩家在当前房间内的归一化坐标0~1
/// 基于世界坐标精确插值,每帧更新,可用于平滑移动图标。
/// </summary>
public Vector2 NormalizedPositionInRoom { get; private set; }
// 缓存上一帧的格子坐标;格子不变则跳过 O(N) 搜索
private Vector2Int _lastCellPos = new Vector2Int(int.MinValue, int.MinValue);
/// <summary>玩家进入新房间时触发(参数为新房间 ID。</summary>
public event Action<string> OnRoomChanged;
private void LateUpdate()
private MapDatabaseSO _database;
private Dictionary<Vector2Int, string> _cellToRoomId;
private MapRoomDataSO _currentRoom; // 当前房间数据缓存,避免 LateUpdate 每帧 GetRoom 查找
private Vector2Int _lastCellPos = new Vector2Int(int.MinValue, int.MinValue);
private void Awake()
{
if (_playerTransform == null || _database?.AllRooms == null) return;
// 单例保护:同一时刻只允许一个 IPlayerPositionProvider 存在
if (ServiceLocator.GetOrDefault<IPlayerPositionProvider>() != null) return;
ServiceLocator.Register<IPlayerPositionProvider>(this);
}
Vector2Int cellPos = WorldToCell(_playerTransform.position);
if (cellPos == _lastCellPos) return; // 格子未变,无需重新搜索
_lastCellPos = cellPos;
private void Start()
{
_database = _databaseOverride
?? ServiceLocator.GetOrDefault<IMapService>()?.Database;
BuildSpatialIndex();
}
private void OnDestroy()
{
ServiceLocator.Unregister<IPlayerPositionProvider>(this);
}
/// <summary>
/// 构建格子坐标 → 房间 ID 的哈希映射,将 LateUpdate 的查询从 O(N) 降至 O(1)。
/// 房间数据变化时(运行时热更)可再次调用重建索引。
/// </summary>
public void BuildSpatialIndex()
{
_cellToRoomId = new Dictionary<Vector2Int, string>();
if (_database?.AllRooms == null) return;
foreach (var room in _database.AllRooms)
{
if (room == null) continue;
var rect = new RectInt(room.GridPosition, room.GridSize);
if (rect.Contains(cellPos))
for (int x = 0; x < room.GridSize.x; x++)
for (int y = 0; y < room.GridSize.y; y++)
{
CurrentRoomId = room.RoomId;
Vector2 inRoom = (Vector2)(cellPos - room.GridPosition);
NormalizedPositionInRoom = new Vector2(
inRoom.x / Mathf.Max(1, room.GridSize.x),
inRoom.y / Mathf.Max(1, room.GridSize.y));
var cell = new Vector2Int(room.GridPosition.x + x, room.GridPosition.y + y);
_cellToRoomId[cell] = room.RoomId;
}
}
}
private void LateUpdate()
{
if (_playerTransform == null || _cellToRoomId == null) return;
Vector2Int cellPos = WorldToCell(_playerTransform.position);
bool cellChanged = cellPos != _lastCellPos;
if (cellChanged)
{
_lastCellPos = cellPos;
if (!_cellToRoomId.TryGetValue(cellPos, out var newRoomId))
{
// 玩家离开所有已知房间
CurrentRoomId = null;
_currentRoom = null;
NormalizedPositionInRoom = Vector2.zero;
return;
}
var prevRoomId = CurrentRoomId;
CurrentRoomId = newRoomId;
_currentRoom = _database.GetRoom(newRoomId);
if (newRoomId != prevRoomId)
OnRoomChanged?.Invoke(newRoomId);
}
// 每帧从世界坐标精确计算归一化位置,实现平滑图标跟随
if (_currentRoom != null)
{
var worldMin = new Vector2(
_currentRoom.GridPosition.x * _worldUnitsPerCell,
_currentRoom.GridPosition.y * _worldUnitsPerCell);
var worldSize = new Vector2(
_currentRoom.GridSize.x * _worldUnitsPerCell,
_currentRoom.GridSize.y * _worldUnitsPerCell);
var localPos = (Vector2)_playerTransform.position - worldMin;
NormalizedPositionInRoom = new Vector2(
Mathf.Clamp01(localPos.x / Mathf.Max(1f, worldSize.x)),
Mathf.Clamp01(localPos.y / Mathf.Max(1f, worldSize.y)));
}
}
@@ -53,3 +130,4 @@ namespace BaseGames.World.Map
Mathf.FloorToInt(worldPos.y / _worldUnitsPerCell));
}
}

View File

@@ -0,0 +1,113 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
namespace BaseGames.World.Map
{
/// <summary>房间可见性三级状态:未知 / 已踏入 / 已标注(购买地图碎片)。</summary>
public enum RoomVisibility { Unknown, Explored, Mapped }
/// <summary>
/// 地图面板中每个房间对应的格子 UI 组件(架构 15_MapShopModule §1.3)。
/// <para>同时被 MapPanel全屏地图和 MinimapHUD角落小地图复用。</para>
/// 颜色通过 <see cref="SetColors"/> 从外部注入,不在此处硬编码。
/// <para><see cref="RT"/> 属性在 Awake 中缓存,避免调用方反复 GetComponent。</para>
/// </summary>
public class MapRoomCellUI : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
{
[SerializeField] private Image _bg;
[SerializeField] private Image _icon;
[SerializeField] private RawImage _outlineImage; // 可选:房间非矩形轮廓纹理
[SerializeField] private Image _highlight; // 可选:当前房间高亮描边(玩家所在时激活)
// 实例颜色(默认值与原硬编码保持一致);可通过 SetColors 统一覆盖
private Color _colExplored = Color.white;
private Color _colMapped = new Color(0.45f, 0.45f, 0.45f, 1f);
private Color _colUnknown = Color.black;
private RoomVisibility _currentVisibility;
private string _displayName;
private Action<string> _onHover;
private Action _onHoverExit;
/// <summary>格子的 RectTransformAwake 中缓存,外部直接访问无需 GetComponent。</summary>
public RectTransform RT { get; private set; }
private void Awake() => RT = GetComponent<RectTransform>();
/// <summary>
/// 初始化格子位置、可见性、图标、Tooltip 回调)。
/// </summary>
/// <param name="pixelsPerCell">
/// 每格像素数,默认 <see cref="MapGridConstants.FullMapCellPixels"/>32f
/// MinimapHUD 调用后会立即通过 <c>PlaceCell</c> 按自身比例覆盖位置/尺寸。
/// </param>
public void Setup(MapRoomDataSO room, RoomVisibility visibility, Sprite icon,
Action<string> onHover = null, Action onHoverExit = null,
float pixelsPerCell = MapGridConstants.FullMapCellPixels)
{
_displayName = room.DisplayName;
_onHover = onHover;
_onHoverExit = onHoverExit;
RT.anchoredPosition = new Vector2(room.GridPosition.x * pixelsPerCell,
room.GridPosition.y * pixelsPerCell);
RT.sizeDelta = new Vector2(room.GridSize.x * pixelsPerCell,
room.GridSize.y * pixelsPerCell);
// 房间轮廓纹理(非矩形形状,覆盖在矩形背景上方)
if (_outlineImage != null)
{
_outlineImage.texture = room.RoomOutlineTex;
_outlineImage.enabled = room.RoomOutlineTex != null;
}
SetVisibility(visibility);
if (_icon != null)
{
_icon.sprite = icon;
_icon.enabled = icon != null;
}
}
/// <summary>覆盖此格子的三级可见性颜色(通常由 MapPanel / MinimapHUD 在创建后统一调用)。</summary>
public void SetColors(Color explored, Color mapped, Color unknown)
{
_colExplored = explored;
_colMapped = mapped;
_colUnknown = unknown;
SetVisibility(_currentVisibility); // 用新颜色重新渲染当前状态
}
public void SetVisibility(RoomVisibility v)
{
_currentVisibility = v;
if (_bg == null) return;
_bg.color = v switch
{
RoomVisibility.Explored => _colExplored,
RoomVisibility.Mapped => _colMapped,
_ => _colUnknown,
};
}
/// <summary>向后兼容:直接传 bool 时等同于 Explored / Unknown。</summary>
public void SetDiscovered(bool v)
=> SetVisibility(v ? RoomVisibility.Explored : RoomVisibility.Unknown);
/// <summary>激活/取消当前房间高亮描边。</summary>
public void SetHighlight(bool v)
{
if (_highlight != null) _highlight.enabled = v;
}
public void OnPointerEnter(PointerEventData _)
{
if (!string.IsNullOrEmpty(_displayName)) _onHover?.Invoke(_displayName);
}
public void OnPointerExit(PointerEventData _) => _onHoverExit?.Invoke();
}
}

View File

@@ -40,6 +40,12 @@ namespace BaseGames.World.Map
"建议在关卡内容基本定型后更新此值。0 = 未填写,将跳过内存预算检查。")]
[Min(0)]
public int EstimatedMemoryKB;
private void OnValidate()
{
// 保证 GridSize 每轴最小为 1防止零尺寸房间导致碰撞和渲染异常
GridSize = new Vector2Int(Mathf.Max(1, GridSize.x), Mathf.Max(1, GridSize.y));
}
}
[Serializable]
@@ -84,5 +90,67 @@ namespace BaseGames.World.Map
}
private void OnDisable() => _index = null; // SO 卸载时清理缓存
private void OnValidate() => _index = null; // 编辑器中修改 AllRooms 后强制重建索引
// ── 配置验证 ──────────────────────────────────────────────────────────
/// <summary>
/// 检查数据库中的常见配置错误RoomId 重复、格子重叠、出口悬空)。
/// 编辑器侧调用;运行时不应调用(有 O(N²) 开销)。
/// 返回错误描述列表;空列表表示无错误。
/// </summary>
public List<string> ValidateAll()
{
var errors = new List<string>();
if (AllRooms == null) return errors;
// ① null / 空 RoomId
for (int i = 0; i < AllRooms.Length; i++)
{
if (AllRooms[i] == null) { errors.Add($"AllRooms[{i}] 为 null"); continue; }
if (string.IsNullOrEmpty(AllRooms[i].RoomId))
errors.Add($"AllRooms[{i}]{AllRooms[i].name}RoomId 为空");
}
// ② RoomId 重复
var seenIds = new Dictionary<string, string>();
foreach (var room in AllRooms)
{
if (room == null || string.IsNullOrEmpty(room.RoomId)) continue;
if (seenIds.TryGetValue(room.RoomId, out var first))
errors.Add($"RoomId '{room.RoomId}' 重复({first} 与 {room.name}");
else
seenIds[room.RoomId] = room.name;
}
// ③ 格子重叠
var cellOwner = new Dictionary<Vector2Int, string>();
foreach (var room in AllRooms)
{
if (room == null) continue;
for (int x = 0; x < room.GridSize.x; x++)
for (int y = 0; y < room.GridSize.y; y++)
{
var cell = new Vector2Int(room.GridPosition.x + x, room.GridPosition.y + y);
if (cellOwner.TryGetValue(cell, out var other))
errors.Add($"'{room.RoomId}' 与 '{other}' 在格子 {cell} 重叠");
else
cellOwner[cell] = room.RoomId;
}
}
// ④ 出口目标不存在(单向验证)
var validIds = new HashSet<string>(seenIds.Keys);
foreach (var room in AllRooms)
{
if (room?.Exits == null) continue;
foreach (var exit in room.Exits)
if (!string.IsNullOrEmpty(exit.TargetRoomId) && !validIds.Contains(exit.TargetRoomId))
errors.Add($"'{room.RoomId}' 出口指向不存在的房间 '{exit.TargetRoomId}'");
}
return errors;
}
}
}

View File

@@ -0,0 +1,20 @@
namespace BaseGames.World.Map
{
/// <summary>
/// IMapService 无状态扩展方法,集中可复用的查询逻辑。
/// MapPanel、MinimapHUD 等所有消费方均调用此处,避免分散的重复实现。
/// </summary>
public static class MapServiceExtensions
{
/// <summary>
/// 根据探索状态推导房间三级可见性Explored > Mapped > Unknown
/// </summary>
public static RoomVisibility GetVisibility(this IMapService svc, string roomId)
{
if (svc == null) return RoomVisibility.Unknown;
if (svc.IsExplored(roomId)) return RoomVisibility.Explored;
if (svc.IsMapped(roomId)) return RoomVisibility.Mapped;
return RoomVisibility.Unknown;
}
}
}

View File

@@ -0,0 +1,201 @@
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);
private Vector2Int _currentCenter;
private string _lastDotRoomId;
private Vector2 _lastDotNormPos;
// ── 生命周期 ──────────────────────────────────────────────────────────
private void OnEnable()
{
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
_playerProvider = ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
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;
}
private void ClearAllCells()
{
foreach (var cell in _cells.Values)
if (cell != null) Destroy(cell.gameObject);
_cells.Clear();
}
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);
// ② 实例化新进入范围的格子
foreach (var room in db.AllRooms)
{
if (room == null || _cells.ContainsKey(room.RoomId)) continue;
if (!RoomInView(room, minX, maxX, minY, maxY)) continue;
var cell = Instantiate(_cellPrefab, _cellContainer);
cell.Setup(room, _mapSvc.GetVisibility(room.RoomId), null);
cell.SetColors(_colorExplored, _colorMapped, _colorUnknown);
_cells[room.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);
}
}
}