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:
148
Assets/_Game/Scripts/Editor/World/Map/MapDatabaseEditor.cs
Normal file
148
Assets/_Game/Scripts/Editor/World/Map/MapDatabaseEditor.cs
Normal 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
|
||||
391
Assets/_Game/Scripts/Editor/World/Map/MapLayoutEditorWindow.cs
Normal file
391
Assets/_Game/Scripts/Editor/World/Map/MapLayoutEditorWindow.cs
Normal 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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
31
Assets/_Game/Scripts/World/Map/IPinService.cs
Normal file
31
Assets/_Game/Scripts/World/Map/IPinService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
28
Assets/_Game/Scripts/World/Map/IPlayerPositionProvider.cs
Normal file
28
Assets/_Game/Scripts/World/Map/IPlayerPositionProvider.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
12
Assets/_Game/Scripts/World/Map/MapGridConstants.cs
Normal file
12
Assets/_Game/Scripts/World/Map/MapGridConstants.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// 地图格子渲染常量。
|
||||
/// 统一管理像素/格比例,避免魔法数字分散在多个文件中。
|
||||
/// </summary>
|
||||
public static class MapGridConstants
|
||||
{
|
||||
/// <summary>全屏地图每格像素数(MapPanel / MapRoomCellUI 使用)。</summary>
|
||||
public const float FullMapCellPixels = 32f;
|
||||
}
|
||||
}
|
||||
78
Assets/_Game/Scripts/World/Map/MapInputHandler.cs
Normal file
78
Assets/_Game/Scripts/World/Map/MapInputHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.cs(BaseGames.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++; // 加载存档后通知消费方重绘
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vector2Int, string> 空间索引,
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
113
Assets/_Game/Scripts/World/Map/MapRoomCellUI.cs
Normal file
113
Assets/_Game/Scripts/World/Map/MapRoomCellUI.cs
Normal 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>格子的 RectTransform(Awake 中缓存,外部直接访问无需 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
20
Assets/_Game/Scripts/World/Map/MapServiceExtensions.cs
Normal file
20
Assets/_Game/Scripts/World/Map/MapServiceExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
201
Assets/_Game/Scripts/World/Map/MinimapHUD.cs
Normal file
201
Assets/_Game/Scripts/World/Map/MinimapHUD.cs
Normal 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 每次分配临时 List(GC 友好)
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user