Add independent review reports for Minimap system (Rounds 8, 9, and 26)

- Round 8 report highlights improvements in architecture, editor usability, and data robustness, with a total score of 80/100.
- Round 9 report focuses on editor extension capabilities, identifying issues with room data indexing and layout editing, resulting in a score of 76/100.
- Round 26 report evaluates the system against commercial standards, noting new issues and confirming previous fixes, with a score of 95.8/100.
This commit is contained in:
2026-05-25 23:15:12 +08:00
parent e2bc324905
commit f74d7f1877
53 changed files with 6825 additions and 270 deletions

View File

@@ -99,9 +99,11 @@ namespace BaseGames.Core.Save
[Serializable]
public class MapSaveData
{
public HashSet<string> ExploredRooms = new(); // 踏入过的房间 ID
public HashSet<string> MappedRooms = new(); // 完整地图信息(购买/存档点揭示)
public List<MapPin> Pins = new(); // 玩家自定义地图标记
public HashSet<string> ExploredRooms = new(); // 踏入过的房间 ID
public HashSet<string> MappedRooms = new(); // 完整地图信息(购买/存档点揭示)
public List<MapPin> Pins = new(); // 玩家自定义地图标记
public string LastRegionId; // 上次进入的区域 ID避免读档后首次进房误触发 EVT_RegionChanged
public HashSet<string> UnlockedTeleportRoomIds = new(); // 已解锁传送站的房间 ID由 TeleportService 维护)
}
/// <summary>玩家在地图上放置的自定义标记(架构 15_MapShopModule §1.5)。</summary>

View File

@@ -39,6 +39,13 @@ namespace BaseGames.Editor.Map
{
_database = (MapDatabaseSO)target;
_errorRowStyle = null; // 编辑器皮肤切换时(亮/暗模式)需重建
// 自动验证Inspector 打开时即填充错误集,无需手动点击"重新验证"按钮
// 防御性 null 检查CustomEditor.OnEnable 在反序列化失败的资产上仍会调用
if (_database != null)
{
_lastErrors = _database.ValidateAll();
RebuildErrorRoomCache();
}
}
/// <summary>错误行 GUIStyle 惰性初始化,基于当前编辑器皮肤构建,避免 OnInspectorGUI 每帧分配。</summary>
@@ -50,6 +57,21 @@ namespace BaseGames.Editor.Map
return _errorRowStyle;
}
/// <summary>
/// 基于 _lastErrors 重建 _cachedErrorRoomIds 集合。
/// 使用引号匹配防止 "Room_A1" 误匹配 "Room_A10"。
/// 由 OnEnable 自动验证与"重新验证"按钮共享调用。
/// </summary>
private void RebuildErrorRoomCache()
{
_cachedErrorRoomIds.Clear();
if (_lastErrors == null || _lastErrors.Count == 0 || _database.AllRooms == null) return;
foreach (var err in _lastErrors)
foreach (var r in _database.AllRooms)
if (r != null && err.Contains($"'{r.RoomId}'"))
_cachedErrorRoomIds.Add(r.RoomId);
}
public override void OnInspectorGUI()
{
serializedObject.Update();
@@ -77,14 +99,7 @@ namespace BaseGames.Editor.Map
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);
RebuildErrorRoomCache();
if (_lastErrors.Count == 0)
Debug.Log("[MapDatabase] 验证通过,未发现问题 ✓");

View File

@@ -32,8 +32,20 @@ namespace BaseGames.Editor.Map
private List<string> _validationErrors;
private HashSet<string> _errorRoomIds;
/// <summary>缓存的错误房间集合,由验证按钮点击时重建(防止 OnInspectorGUI 高频重建导致 GC。</summary>
private readonly HashSet<string> _cachedErrorRoomIds = new();
// R9-N2 房间拖拽编辑状态
private MapRoomDataSO _draggedRoom;
private Vector2Int _dragOriginGridPos;
private Vector2 _dragMouseStart;
private bool _dragHasConflict; // R10-N5 当前拖拽位置与其它房间重叠时为 true
// R9-N4 搜索/图例
private string _searchText = string.Empty;
private bool _showLegend = true;
/// <summary>N4: DrawExitLines 去重集,缓存为字段避免每次 OnGUI 分配。</summary>
private readonly HashSet<(string, string)> _drawnExitPairs = new();
/// <summary>N4: DrawExitLines 连接线颜色,缓存为字段避免每次 OnGUI 分配。</summary>
private static readonly Color ExitLineColor = new Color(1f, 1f, 0.5f, 0.35f);
private readonly Dictionary<string, Color> _regionColors = new();
private int _paletteIndex;
@@ -42,6 +54,7 @@ namespace BaseGames.Editor.Map
private GUIStyle _roomLabelStyle;
private GUIStyle _badgeBossStyle;
private GUIStyle _badgeNormalStyle;
private GUIStyle _noResultStyle; // R18-N2 缓存"搜索无结果"提示样式zoom 无关,初始化一次)
private float _cachedZoomForStyle = -1f; // 用于检测缩放变化时重建 Style
// 区域配色方案(与 MapExploration 标注颜色视觉呼应)
@@ -78,6 +91,14 @@ namespace BaseGames.Editor.Map
Repaint();
}
/// <summary>R11-N7 项目资产变更(导入/删除/重命名)后清除验证缓存并触发重绘。</summary>
private void OnProjectChange()
{
_validationErrors = null;
_errorRoomIds = null;
Repaint();
}
private void OnGUI()
{
DrawToolbar();
@@ -97,6 +118,14 @@ namespace BaseGames.Editor.Map
EditorGUILayout.HelpBox(msg, MessageType.Warning);
}
// R10-N5 拖拽冲突提示
if (_draggedRoom != null && _dragHasConflict)
{
EditorGUILayout.HelpBox(
$"⚠ 房间 '{_draggedRoom.RoomId}' 当前位置与其它房间格子重叠,请调整。",
MessageType.Error);
}
// 地图绘制区域
Rect mapRect = GUILayoutUtility.GetRect(
GUIContent.none, GUIStyle.none,
@@ -137,6 +166,25 @@ namespace BaseGames.Editor.Map
Repaint();
}
// R9-N4 / R24-N3 搜索框:输入 RoomId / RegionId 子串或 RoomType 枚举名高亮匹配房间;✕ 按钮一键清空
GUILayout.Label("搜索", GUILayout.Width(32));
var newSearch = EditorGUILayout.TextField(_searchText, EditorStyles.toolbarTextField, GUILayout.Width(120));
if (newSearch != _searchText)
{
_searchText = newSearch;
Repaint();
}
GUI.enabled = !string.IsNullOrEmpty(_searchText);
if (GUILayout.Button("✕", EditorStyles.toolbarButton, GUILayout.Width(22)))
{
_searchText = string.Empty;
GUI.FocusControl(null);
Repaint();
}
GUI.enabled = true;
_showLegend = GUILayout.Toggle(_showLegend, "图例", EditorStyles.toolbarButton, GUILayout.Width(48));
EditorGUILayout.LabelField($"缩放 {_zoom:F0}px/格", GUILayout.Width(80));
EditorGUILayout.EndHorizontal();
}
@@ -163,6 +211,23 @@ namespace BaseGames.Editor.Map
e.Use();
break;
// R9-N2 左键按下(不带 Alt若命中房间则进入"房间拖拽"模式
case EventType.MouseDown when e.button == 0 && !e.alt:
{
var hit = HitTestRoom(e.mousePosition - mapRect.position, mapRect);
if (hit != null)
{
_draggedRoom = hit;
_dragOriginGridPos = hit.GridPosition;
_dragMouseStart = e.mousePosition;
_selectedRoom = hit;
Selection.activeObject = hit;
EditorGUIUtility.PingObject(hit);
e.Use();
}
break;
}
case EventType.MouseDrag when _isDragging:
_panOffset += e.mousePosition - _dragStart;
_dragStart = e.mousePosition;
@@ -170,18 +235,75 @@ namespace BaseGames.Editor.Map
Repaint();
break;
// R9-N2 房间拖拽:将像素偏移换算为格子偏移,实时更新 GridPosition
case EventType.MouseDrag when _draggedRoom != null:
{
Vector2 delta = e.mousePosition - _dragMouseStart;
int gx = Mathf.RoundToInt(delta.x / _zoom);
int gy = Mathf.RoundToInt(-delta.y / _zoom); // 屏幕 Y 向下→格子 Y 向上
var newPos = new Vector2Int(_dragOriginGridPos.x + gx, _dragOriginGridPos.y + gy);
if (newPos != _draggedRoom.GridPosition)
{
Undo.RecordObject(_draggedRoom, "Move Room");
_draggedRoom.GridPosition = newPos;
EditorUtility.SetDirty(_draggedRoom);
// R10-N5 重叠检测:候选位置覆盖的任意格子被其它房间占用即标冲突
_dragHasConflict = HasOverlapAt(_draggedRoom, newPos);
Repaint();
}
e.Use();
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);
// R9-N2 房间拖拽结束
case EventType.MouseUp when _draggedRoom != null && e.button == 0:
_draggedRoom = null;
_dragHasConflict = false;
e.Use();
Repaint();
break;
}
}
/// <summary>命中测试:返回鼠标位置覆盖的房间(用于选中/拖拽起始判定)。</summary>
private MapRoomDataSO HitTestRoom(Vector2 clipPos, Rect mapRect)
{
if (_database?.AllRooms == null) return null;
Vector2 origin = mapRect.size * 0.5f + _panOffset;
foreach (var room in _database.AllRooms)
{
if (room == null) continue;
if (RoomToClipRect(room, origin).Contains(clipPos))
return room;
}
return null;
}
/// <summary>
/// R10-N5 检测候选房间放置在 <paramref name="newPos"/> 时是否与其它房间格子重叠。
/// 排除自身;用于拖拽时实时给策划红色反馈。
/// </summary>
private bool HasOverlapAt(MapRoomDataSO room, Vector2Int newPos)
{
if (_database?.AllRooms == null || room == null) return false;
int minX = newPos.x, maxX = newPos.x + room.GridSize.x;
int minY = newPos.y, maxY = newPos.y + room.GridSize.y;
foreach (var other in _database.AllRooms)
{
if (other == null || other == room) continue;
int oMinX = other.GridPosition.x, oMaxX = oMinX + other.GridSize.x;
int oMinY = other.GridPosition.y, oMaxY = oMinY + other.GridSize.y;
bool overlap = minX < oMaxX && maxX > oMinX && minY < oMaxY && maxY > oMinY;
if (overlap) return true;
}
return false;
}
// ── 地图绘制 ──────────────────────────────────────────────────────────
private void DrawMapArea(Rect mapRect)
@@ -193,6 +315,9 @@ namespace BaseGames.Editor.Map
Vector2 origin = mapRect.size * 0.5f + _panOffset;
// N3 搜索有内容时追踪是否有任何匹配项,无结果时显示提示
bool anySearchMatch = false;
foreach (var room in _database.AllRooms)
{
if (room == null) continue;
@@ -201,10 +326,25 @@ namespace BaseGames.Editor.Map
// 填充
bool hasError = _errorRoomIds != null && _errorRoomIds.Contains(room.RoomId);
// R23-N2 搜索支持 RoomId / RegionId 子串 以及 RoomType 枚举名(如 "SavePoint"、"BossRoom"
bool matchesSearch = !string.IsNullOrEmpty(_searchText)
&& (room.RoomId?.IndexOf(_searchText, System.StringComparison.OrdinalIgnoreCase) >= 0
|| room.RegionId?.IndexOf(_searchText, System.StringComparison.OrdinalIgnoreCase) >= 0
|| MatchesRoomType(room.RoomFlags, _searchText));
if (matchesSearch) anySearchMatch = true;
// R10-N5 正在被拖拽且与其它房间重叠时优先显示红色警示
bool draggingConflict = room == _draggedRoom && _dragHasConflict;
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);
Color fillColor = draggingConflict
? new Color(1f, 0.1f, 0.1f, 0.75f)
: hasError
? new Color(1f, 0.15f, 0.15f, 0.55f)
: matchesSearch
? new Color(1f, 0.95f, 0.2f, 0.55f)
// N2 搜索活跃时非匹配房间降低 alpha使匹配项更突出
: !string.IsNullOrEmpty(_searchText)
? new Color(regionColor.r, regionColor.g, regionColor.b, 0.08f)
: new Color(regionColor.r, regionColor.g, regionColor.b, 0.28f);
EditorGUI.DrawRect(cell, fillColor);
// 边框
@@ -226,7 +366,75 @@ namespace BaseGames.Editor.Map
if (_zoom >= 12f)
DrawExitLines(origin);
// R9-N11 Play Mode 玩家位置叠加:在当前房间上绘制醒目红点
if (Application.isPlaying)
DrawPlayModePlayerDot(origin);
// N3 搜索无结果时居中显示提示文本
if (!string.IsNullOrEmpty(_searchText) && !anySearchMatch)
{
GUI.Label(new Rect(0f, 0f, mapRect.width, mapRect.height),
$"未找到与 \"{_searchText}\" 匹配的房间", _noResultStyle);
}
GUI.EndClip();
// R9-N4 图例面板(独立绘制,不参与裁剪)
if (_showLegend)
DrawLegendPanel(mapRect);
}
private void DrawPlayModePlayerDot(Vector2 origin)
{
var provider = BaseGames.Core.ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
if (provider == null) return;
var roomId = provider.CurrentRoomId;
if (string.IsNullOrEmpty(roomId)) return;
var room = _database.GetRoom(roomId);
if (room == null) return;
Rect cell = RoomToClipRect(room, origin);
Vector2 norm = provider.NormalizedPositionInRoom;
// norm.y=0 是房间底部;屏幕 Y 向下需翻转
float px = cell.x + norm.x * cell.width;
float py = cell.y + (1f - norm.y) * cell.height;
float r = Mathf.Max(4f, _zoom * 0.18f);
var prev = GUI.color;
GUI.color = new Color(1f, 0.2f, 0.2f, 0.95f);
GUI.DrawTexture(new Rect(px - r, py - r, r * 2f, r * 2f), EditorGUIUtility.whiteTexture);
GUI.color = Color.white;
DrawBorder(new Rect(px - r - 1f, py - r - 1f, r * 2f + 2f, r * 2f + 2f), Color.white, 1f);
GUI.color = prev;
// 持续刷新使 Play Mode 玩家位置可视化跟随移动
Repaint();
}
private void DrawLegendPanel(Rect mapRect)
{
const float W = 200f;
float h = 26f + _regionColors.Count * 18f + (Application.isPlaying ? 18f : 0f);
var panel = new Rect(mapRect.xMax - W - 8f, mapRect.y + 8f, W, h);
EditorGUI.DrawRect(panel, new Color(0.12f, 0.12f, 0.12f, 0.85f));
DrawBorder(panel, new Color(0.4f, 0.4f, 0.4f, 1f), 1f);
var labelStyle = EditorStyles.miniBoldLabel;
GUI.Label(new Rect(panel.x + 6f, panel.y + 4f, W - 12f, 16f), "Region 图例", labelStyle);
float y = panel.y + 22f;
foreach (var kv in _regionColors)
{
EditorGUI.DrawRect(new Rect(panel.x + 8f, y + 3f, 12f, 12f), kv.Value);
GUI.Label(new Rect(panel.x + 24f, y, W - 30f, 16f),
string.IsNullOrEmpty(kv.Key) ? "(无 Region)" : kv.Key, EditorStyles.miniLabel);
y += 18f;
}
if (Application.isPlaying)
{
EditorGUI.DrawRect(new Rect(panel.x + 8f, y + 4f, 10f, 10f), new Color(1f, 0.2f, 0.2f));
GUI.Label(new Rect(panel.x + 24f, y, W - 30f, 16f), "玩家位置Play Mode", EditorStyles.miniLabel);
}
}
/// <summary>
@@ -235,6 +443,17 @@ namespace BaseGames.Editor.Map
/// </summary>
private void EnsureLabelStyles()
{
// _noResultStyle 不依赖 zoom初始化一次即可
if (_noResultStyle == null)
{
_noResultStyle = new GUIStyle(EditorStyles.boldLabel)
{
alignment = TextAnchor.MiddleCenter,
normal = { textColor = new Color(1f, 0.8f, 0.2f, 0.8f) },
fontSize = 13,
};
}
if (Mathf.Approximately(_cachedZoomForStyle, _zoom) && _roomLabelStyle != null) return;
_cachedZoomForStyle = _zoom;
@@ -267,18 +486,27 @@ namespace BaseGames.Editor.Map
private void DrawRoomBadge(Rect cell, MapRoomDataSO room)
{
if (!room.IsBossRoom && !room.IsSavePoint && !room.IsShop) return;
// R12-N9 优先检查 RoomFlags新 [Flags] 枚举),回退兼容旧 bool 字段
// R15-N1 补充 TeleportStation与运行时 MapRoomDataSO.ChooseDisplayIcon 保持一致
bool isBoss = room.RoomFlags.HasFlag(RoomType.BossRoom) || room.IsBossRoom;
bool isSave = room.RoomFlags.HasFlag(RoomType.SavePoint) || room.IsSavePoint;
bool isShop = room.RoomFlags.HasFlag(RoomType.Shop) || room.IsShop;
bool isTeleport = room.RoomFlags.HasFlag(RoomType.TeleportStation);
if (!isBoss && !isSave && !isShop && !isTeleport) return;
if (cell.width < 8f) return;
string badge = room.IsBossRoom ? "★" :
room.IsSavePoint ? "" : "¥";
GUI.Label(cell, badge, room.IsBossRoom ? _badgeBossStyle : _badgeNormalStyle);
// 优先级与运行时 MapRoomDataSO.ChooseDisplayIcon 对齐Save > Boss > Shop > Teleport
string badge = isSave ? "♦" : isBoss ? "★" : isShop ? "¥" : "";
// 徽章样式与实际显示的徽章类型对齐(不随 isBoss 变化,而是随 badge 内容)
GUI.Label(cell, badge, badge == "★" ? _badgeBossStyle : _badgeNormalStyle);
}
private void DrawExitLines(Vector2 origin)
{
if (_database?.AllRooms == null) return;
var lineColor = new Color(1f, 1f, 0.5f, 0.35f);
// R12-N2 去重A→B 和 B→A 各画一次,用规范化有序键防止重复绘制
// N4: 复用字段级 HashSet避免每次 OnGUI 分配新对象
_drawnExitPairs.Clear();
foreach (var room in _database.AllRooms)
{
@@ -288,10 +516,34 @@ namespace BaseGames.Editor.Map
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);
// R12-N2 规范化键:按 Ordinal 比较,较小 ID 在前,避免 A-B / B-A 重复画线
var keyA = string.CompareOrdinal(room.RoomId, exit.TargetRoomId) <= 0
? (room.RoomId, exit.TargetRoomId)
: (exit.TargetRoomId, room.RoomId);
if (!_drawnExitPairs.Add(keyA)) continue;
DrawLine(from, to, lineColor, 1.5f);
// R12-N5 使用 HasCustomExitPos 替代 != Vector2Int.zero 哨兵,避免合法原点坐标歧义
Vector2Int fromGrid = exit.HasCustomExitPos
? exit.ExitGridPos
: room.GridPosition + room.GridSize / 2;
// 找目标房间中与本出口对应的反向出口(方向相反且 TargetRoomId == room.RoomId
Vector2Int toGrid = target.GridPosition + target.GridSize / 2; // fallback
if (target.Exits != null)
{
foreach (var rev in target.Exits)
{
if (rev.TargetRoomId == room.RoomId && rev.HasCustomExitPos)
{
toGrid = rev.ExitGridPos;
break;
}
}
}
Vector2 from = GridCenterToClip(fromGrid, origin);
Vector2 to = GridCenterToClip(toGrid, origin);
DrawLine(from, to, ExitLineColor, 1.5f);
}
}
}
@@ -311,30 +563,8 @@ namespace BaseGames.Editor.Map
}
// ── 房间选择 ──────────────────────────────────────────────────────────
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();
}
// R9-N2 后由 HandleInput 中的 MouseDown 直接调用 HitTestRoom 完成选中+起拖,
// 旧的 TrySelectRoom仅在 MouseUp 时选中)已合并到 HandleInput。
// ── 工具方法 ──────────────────────────────────────────────────────────
@@ -361,6 +591,23 @@ namespace BaseGames.Editor.Map
return c;
}
/// <summary>
/// R23-N2 检查房间的 RoomFlags 是否包含与 searchText 大小写不敏感匹配的 RoomType 枚举成员。
/// 允许策划通过搜索 "SavePoint"、"BossRoom" 等关键字快速过滤特定类型的房间。
/// </summary>
private static bool MatchesRoomType(RoomType flags, string searchText)
{
if (flags == RoomType.None) return false;
foreach (RoomType value in System.Enum.GetValues(typeof(RoomType)))
{
if (value == RoomType.None) continue;
if (!flags.HasFlag(value)) continue;
if (value.ToString().IndexOf(searchText, System.StringComparison.OrdinalIgnoreCase) >= 0)
return true;
}
return false;
}
private void RunValidation()
{
_validationErrors = _database?.ValidateAll() ?? new List<string>();
@@ -399,11 +646,18 @@ namespace BaseGames.Editor.Map
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;
// R11-N12 try/finally 保证 GUI.matrix/color 在 DrawTexture 抛出异常时仍能恢复
try
{
GUIUtility.RotateAroundPivot(angle, mid);
GUI.DrawTexture(new Rect(mid.x - len * 0.5f, mid.y - thickness * 0.5f, len, thickness),
EditorGUIUtility.whiteTexture);
}
finally
{
GUI.matrix = prevMatrix;
GUI.color = prevColor;
}
}
}
}

View File

@@ -0,0 +1,105 @@
#if UNITY_EDITOR
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
using BaseGames.World.Map;
namespace BaseGames.Editor.Map
{
/// <summary>
/// 新建 MapRoomDataSO 时自动追加到默认 MapDatabaseSO 的 AllRooms 列表R9-N3
/// 解决问题:开发/策划新建房间后忘记手动拖入 Database导致运行时小地图缺失房间。
/// <para>
/// 触发条件:检测到导入的 MapRoomDataSO 资产路径中包含项目内任意 Database 引用即跳过;
/// 若不被任何 Database 引用,则追加到"默认"Database首个被找到的 MapDatabaseSO
/// 按 AssetDatabase.FindAssets GUID 排序保证可复现)。
/// </para>
/// 不会修改资产文件层级结构;仅修改 Database 的 AllRooms 数组并 SetDirty。
/// 可在 EditorPrefs 中通过 Key <see cref="DISABLE_KEY"/> 设为 true 来禁用。
/// </summary>
public class MapRoomAutoRegister : AssetPostprocessor
{
private const string DISABLE_KEY = "BaseGames.Map.AutoRegister.Disabled";
private static void OnPostprocessAllAssets(
string[] importedAssets,
string[] deletedAssets,
string[] movedAssets,
string[] movedFromAssetPaths)
{
if (EditorPrefs.GetBool(DISABLE_KEY, false)) return;
if (importedAssets == null || importedAssets.Length == 0) return;
// 仅处理新导入的 MapRoomDataSO含 .asset 后缀过滤,减少 LoadAssetAtPath 调用)
List<MapRoomDataSO> newRooms = null;
foreach (var path in importedAssets)
{
if (!path.EndsWith(".asset", System.StringComparison.OrdinalIgnoreCase)) continue;
var room = AssetDatabase.LoadAssetAtPath<MapRoomDataSO>(path);
if (room == null) continue;
(newRooms ??= new List<MapRoomDataSO>()).Add(room);
}
if (newRooms == null) return;
// 查找项目内所有 MapDatabaseSO按 GUID 排序确保"默认 Database"在多人协作下一致)
var dbGuids = AssetDatabase.FindAssets($"t:{nameof(MapDatabaseSO)}");
if (dbGuids.Length == 0) return;
var databases = dbGuids
.OrderBy(g => g, System.StringComparer.Ordinal)
.Select(g => AssetDatabase.LoadAssetAtPath<MapDatabaseSO>(AssetDatabase.GUIDToAssetPath(g)))
.Where(db => db != null)
.ToList();
if (databases.Count == 0) return;
// R10-N4 默认 Database 选择优先级:
// 1) 显式勾选 IsDefault 的首个GUID 排序);
// 2) 否则回退 GUID 排序首个。
var defaultDb = databases.FirstOrDefault(db => db.IsDefault) ?? databases[0];
bool dirty = false;
foreach (var room in newRooms)
{
// 已被任意 Database 引用则跳过(避免重复追加 + 跨 Database 误抢占)
if (databases.Any(db => db.AllRooms != null && System.Array.IndexOf(db.AllRooms, room) >= 0))
continue;
var arr = defaultDb.AllRooms ?? System.Array.Empty<MapRoomDataSO>();
var newArr = new MapRoomDataSO[arr.Length + 1];
System.Array.Copy(arr, newArr, arr.Length);
newArr[arr.Length] = room;
defaultDb.EditorSetRooms(newArr);
dirty = true;
Debug.Log($"[MapRoomAutoRegister] 已将新房间 '{room.RoomId}' 自动注册到 Database '{defaultDb.name}'。", defaultDb);
}
if (dirty)
{
EditorUtility.SetDirty(defaultDb);
// 不主动 SaveAssets避免在批量导入流程中拖慢 IDE下次工程保存自动落盘
}
// R12-N4 清理所有 Database 中的 null 条目(资产被删除后留下的空引用)
foreach (var db in databases)
{
if (db.AllRooms == null) continue;
if (!System.Array.Exists(db.AllRooms, r => r == null)) continue;
var cleaned = System.Array.FindAll(db.AllRooms, r => r != null);
int nullCount = db.AllRooms.Length - cleaned.Length;
db.EditorSetRooms(cleaned);
EditorUtility.SetDirty(db);
Debug.Log($"[MapRoomAutoRegister] 已清理 Database '{db.name}' 中的 {nullCount} 个 null 条目。", db);
}
}
[MenuItem("Tools/Map/Toggle Auto-Register New Rooms")]
private static void ToggleAutoRegister()
{
bool disabled = EditorPrefs.GetBool(DISABLE_KEY, false);
EditorPrefs.SetBool(DISABLE_KEY, !disabled);
Debug.Log($"[MapRoomAutoRegister] 自动注册已 {(!disabled ? "" : "")}。");
}
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 770823afe767eef48a373ddbbb7eeaa0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -11,6 +11,7 @@ namespace BaseGames.Editor.Map
/// 拖动自动吸附到整格精度;左下/右上角可独立拖动(含反转保护);支持 Undo。
/// </summary>
[CustomEditor(typeof(MapRoomDataSO))]
[CanEditMultipleObjects]
public class MapRoomDataEditor : UnityEditor.Editor
{
private const float CELL_SIZE = 1f; // 每格在 Scene 中的世界单位尺寸
@@ -19,13 +20,23 @@ namespace BaseGames.Editor.Map
private static readonly Color OutlineColor = new Color(0.2f, 0.6f, 1f, 0.9f);
private static readonly Color HandleColor = new Color(1f, 0.85f, 0.2f, 1f);
private static readonly GUIStyle LabelStyle = new GUIStyle
// 静态 GUIStyle 在域重载/字段初始化器顺序下可能产生 NREnormal.background=null 访问)。
// 改用懒加载 + 首次访问时构建,避免 EditorStyles 未初始化导致的访问问题。
private static GUIStyle s_labelStyle;
private static GUIStyle s_cornerLabelStyle;
private static GUIStyle LabelStyle => s_labelStyle ??= new GUIStyle(EditorStyles.boldLabel)
{
alignment = TextAnchor.MiddleCenter,
fontStyle = FontStyle.Bold,
normal = { textColor = Color.white },
};
private static GUIStyle CornerLabelStyle => s_cornerLabelStyle ??= new GUIStyle(EditorStyles.miniLabel)
{
alignment = TextAnchor.MiddleLeft,
normal = { textColor = new Color(1f, 0.85f, 0.2f, 1f) },
};
private MapRoomDataSO _target;
private void OnEnable() => _target = (MapRoomDataSO)target;
@@ -101,6 +112,8 @@ namespace BaseGames.Editor.Map
Color prev = Handles.color;
Handles.color = HandleColor;
var result = Handles.FreeMoveHandle(pos, sz, Vector3.zero, Handles.DotHandleCap);
// 角点标签:标识 BL / TR 角,便于多房间布局编辑时区分;偏移避免遮挡 handle
Handles.Label(pos + new Vector3(sz * 1.5f, sz * 0.5f, 0f), label, CornerLabelStyle);
Handles.color = prev;
return SnapToGrid(result);
}

View File

@@ -39,6 +39,12 @@ namespace BaseGames.Input
public event Action CancelEvent;
public event Action<Vector2> PointEvent;
/// <summary>小地图视野档位切换默认绑定Tab 键 / 手柄右肩键)。</summary>
public event Action CycleMinimapZoomEvent;
/// <summary>全屏地图"居中到玩家"默认绑定F 键 / 手柄右摇杆按下)。</summary>
public event Action MapCenterEvent;
// ── Polling ───────────────────────────────────────────────────────────
public Vector2 MoveInput { get; private set; }
@@ -192,6 +198,10 @@ namespace BaseGames.Input
BindStarted(_ui, "Cancel", () => CancelEvent?.Invoke());
BindStarted(_ui, "Pause", HandlePause);
BindPerformed(_ui, "Point", ctx => PointEvent?.Invoke(ctx.ReadValue<Vector2>()));
// R13-N3 小地图缩放档位切换Action 名称需在 InputActionAsset UI Map 中添加(可选)
BindStarted(_ui, "CycleMinimapZoom", () => CycleMinimapZoomEvent?.Invoke());
// R13-N4 全屏地图居中Action 名称需在 InputActionAsset UI Map 中添加(可选)
BindStarted(_ui, "MapCenter", () => MapCenterEvent?.Invoke());
}
_isBound = true;

View File

@@ -12,6 +12,7 @@
"BaseGames.Core",
"BaseGames.Core.Save",
"BaseGames.Core.Events",
"BaseGames.Input",
"BaseGames.Localization",
"Unity.TextMeshPro"
],

View File

@@ -2,6 +2,9 @@
// 地图服务接口,通过 ServiceLocator 注册与查询。
// MapManager 实现此接口MapPanel 等调用方通过接口解耦。
using System;
using System.Collections.Generic;
namespace BaseGames.World.Map
{
public interface IMapService
@@ -9,6 +12,13 @@ namespace BaseGames.World.Map
bool IsExplored(string roomId);
bool IsMapped(string roomId);
void SetMapped(string roomId);
/// <summary>
/// 批量解锁地图房间(地图碎片覆盖多间房间时调用)。
/// 内部去重,对每个新增房间触发 <see cref="OnRoomMapped"/>,最后只广播一次 EVT_MapUpdated。
/// </summary>
void SetMappedBatch(IEnumerable<string> roomIds);
MapDatabaseSO Database { get; }
/// <summary>玩家当前所在区域 ID最近一次 EVT_RegionChanged 对应的值)。</summary>
@@ -22,5 +32,27 @@ namespace BaseGames.World.Map
/// <summary>返回属于指定区域的所有房间数据regionId 为空时返回空数组。</summary>
MapRoomDataSO[] GetRoomsByRegion(string regionId);
/// <summary>
/// 当地图数据库结构发生变化(房间增删/编辑器热改/运行时热更)时触发。
/// MapPanel、MinimapHUD 等 UI 应订阅此事件以执行完整重建。
/// </summary>
event Action OnDatabaseChanged;
/// <summary>
/// 探索进度变化事件(读档恢复 / 房间状态变化 / SetMapped 等)。
/// 与 <see cref="OnDatabaseChanged"/> 区分:本事件不要求 UI 销毁重建结构,
/// 仅需调用 RefreshAllCells/RebuildPins 等轻量刷新即可。
/// </summary>
event Action OnExplorationChanged;
/// <summary>
/// 某个房间首次被标记为 Mapped 时触发(参数为 roomId
/// UI 可订阅做"地图碎片解锁"动画。批量 SetMappedBatch 时对每个新增房间各触发一次。
/// </summary>
event Action<string> OnRoomMapped;
/// <summary>主动通知所有订阅者数据库结构已变更(同时会让 Database 的空间索引失效)。</summary>
void NotifyDatabaseChanged();
}
}

View File

@@ -24,5 +24,12 @@ namespace BaseGames.World.Map
/// <summary>玩家进入新房间时触发(参数为新房间 ID。</summary>
event Action<string> OnRoomChanged;
/// <summary>
/// 将世界坐标转换为所属房间 ID 和房间内归一化坐标0~1
/// 用于在指定世界坐标处创建地图标记,无需手动计算格子坐标。
/// 坐标不在任何已知房间内时返回 false。
/// </summary>
bool TryGetRoomAtWorldPos(Vector3 worldPos, out string roomId, out Vector2 normalizedPos);
}
}

View File

@@ -0,0 +1,44 @@
using System;
namespace BaseGames.World.Map
{
/// <summary>
/// 传送/快速旅行服务接口。
/// 通过 ServiceLocator 注册;地图 UI 通过此接口触发传送,
/// 不持有具体传送实现的引用,保持架构解耦。
/// </summary>
public interface ITeleportService
{
/// <summary>当前目标房间 ID 是否可传送(已解锁传送点且已探索)。</summary>
bool CanTeleportTo(string roomId);
/// <summary>
/// 请求传送到目标房间。
/// 实现方负责播放淡出动画、切换场景、淡入等完整流程。
/// 调用前会触发 <see cref="OnTeleportRequested"/>。
/// </summary>
void RequestTeleport(string targetRoomId);
/// <summary>
/// 传送请求发出时触发(参数:源房间 ID目标房间 ID
/// 可用于 UI 播放关闭动画、记录日志等。
/// </summary>
event Action<string, string> OnTeleportRequested;
/// <summary>
/// 传送完成(玩家已到达目标房间)时触发(参数:目标房间 ID
/// 可用于 UI 播放进入动画、刷新小地图等。
/// </summary>
event Action<string> OnTeleportCompleted;
/// <summary>
/// 解锁指定房间的传送点(玩家首次激活传送站时由游戏触发器调用)。
/// </summary>
void UnlockTeleportStation(string roomId);
/// <summary>
/// 场景加载系统在传送流程完成后调用,触发 <see cref="OnTeleportCompleted"/> 事件。
/// </summary>
void NotifyTeleportCompleted(string arrivedRoomId);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d61e0b1cfc586754488378eba7b89cb5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,6 +1,7 @@
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using BaseGames.Input;
namespace BaseGames.World.Map
{
@@ -9,42 +10,90 @@ namespace BaseGames.World.Map
/// 挂在与 MapPanel 相同的 GameObject 上MapPanel OnEnable/OnDisable 联动启停)。
/// <list type="bullet">
/// <item>鼠标滚轮缩放(以鼠标位置为缩放中心)</item>
/// <item>键盘方向键 / WASD 平移</item>
/// <item>方向键 / 摇杆平移(经由 InputReaderSO.NavigateEvent 派发)</item>
/// </list>
/// </summary>
[RequireComponent(typeof(MapPanel))]
public class MapInputHandler : MonoBehaviour, IScrollHandler
{
[SerializeField] private InputReaderSO _inputReader;
[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.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("键盘平移")]
[Header("平移")]
[SerializeField] private float _keyPanSpeed = 600f; // px / 秒
private float _zoom = 1f;
private Vector2 _navInput;
private MapPanel _panel;
private void Awake()
{
_panel = GetComponent<MapPanel>();
#if UNITY_EDITOR || DEVELOPMENT_BUILD
// R25-N2 _zoomTarget 须配置为 MapPanel 的格子根节点(与 _roomContainer 相同),
// 否则 OnScroll 写入的 scale 与 MapPanel.CurrentZoom 读取的 scale 将来自不同节点,导致静默状态分裂。
if (_zoomTarget == null)
Debug.LogWarning("[MapInputHandler] _zoomTarget 未配置,缩放功能将失效。请在 Inspector 中指定 MapPanel 的格子根节点_roomContainer。", this);
#endif
}
private void OnEnable()
{
// 重新激活时还原缩放,避免上次关闭时的残留状态
if (_zoomTarget != null)
_zoom = _zoomTarget.localScale.x;
if (_inputReader != null)
{
_inputReader.NavigateEvent += OnNavigate;
// R13-N4 居中到玩家快捷键
_inputReader.MapCenterEvent += OnMapCenter;
}
}
private void OnDisable()
{
_navInput = Vector2.zero;
if (_inputReader != null)
{
_inputReader.NavigateEvent -= OnNavigate;
_inputReader.MapCenterEvent -= OnMapCenter;
}
}
private void OnNavigate(Vector2 dir) => _navInput = dir;
// R13-N4 将视图居中到玩家所在房间
private void OnMapCenter() => _panel?.CenterOnCurrentRoom();
private void Update()
{
if (_scrollRect == null) return;
if (_scrollRect == null || _navInput == Vector2.zero) return;
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
if (h == 0 && v == 0) return;
// R18-N1 contentSize 需乘以当前缩放系数,否则缩放后平移速度感知偏差。
// R19-N1 / R24-N2 统一读 _panel.CurrentZoom_zoomTarget.localScale.x
// 消除独立 _zoom 字段与 CurrentZoom 的双份状态——OnScroll 直接写 localScale
// CurrentZoom 即时反映最新值,不再需要额外同步。
var content = _scrollRect.content;
var viewport = _scrollRect.viewport != null
? _scrollRect.viewport
: (RectTransform)_scrollRect.transform;
Vector2 contentSize = content.rect.size;
Vector2 viewportSize = viewport.rect.size;
float currentZoom = _panel != null ? _panel.CurrentZoom
: (_zoomTarget != null ? _zoomTarget.localScale.x : 1f);
float rangeX = contentSize.x * currentZoom - viewportSize.x;
float rangeY = contentSize.y * currentZoom - viewportSize.y;
var delta = new Vector2(h, v) * (_keyPanSpeed * Time.unscaledDeltaTime);
_scrollRect.content.anchoredPosition += delta;
if (rangeX <= 0f && rangeY <= 0f) return; // 内容比视口小,无需平移
Vector2 delta = _navInput * (_keyPanSpeed * Time.unscaledDeltaTime);
Vector2 norm = _scrollRect.normalizedPosition;
if (rangeX > 0f) norm.x = Mathf.Clamp01(norm.x + delta.x / rangeX);
if (rangeY > 0f) norm.y = Mathf.Clamp01(norm.y + delta.y / rangeY);
_scrollRect.normalizedPosition = norm;
}
// ── 鼠标滚轮缩放 ─────────────────────────────────────────────────────
@@ -53,26 +102,26 @@ namespace BaseGames.World.Map
{
if (_zoomTarget == null) return;
// R24-N2 直接读 _zoomTarget.localScale.x 作为当前缩放,消除独立 _zoom 字段
float currentZoom = _zoomTarget.localScale.x;
float newZoom = Mathf.Clamp(
_zoom + eventData.scrollDelta.y * _zoomStep,
currentZoom + eventData.scrollDelta.y * _zoomStep,
_zoomMin, _zoomMax);
if (Mathf.Approximately(newZoom, _zoom)) return;
if (Mathf.Approximately(newZoom, currentZoom)) return;
// 将鼠标屏幕坐标转为 zoomTarget 本地坐标(缩放前)
RectTransformUtility.ScreenPointToLocalPointInRectangle(
_zoomTarget, eventData.position, eventData.pressEventCamera, out Vector2 pivotBefore);
_zoom = newZoom;
_zoomTarget.localScale = new Vector3(_zoom, _zoom, 1f);
_zoomTarget.localScale = new Vector3(newZoom, newZoom, 1f);
// 将同一屏幕点再次映射(缩放后),计算偏移量保持鼠标下方内容不动
RectTransformUtility.ScreenPointToLocalPointInRectangle(
_zoomTarget, eventData.position, eventData.pressEventCamera, out Vector2 pivotAfter);
// pivotAfter - pivotBefore 是 zoomTarget 本地空间的偏差,需转为父空间偏差
Vector2 offset = pivotAfter - pivotBefore;
_zoomTarget.anchoredPosition += offset * _zoom;
_zoomTarget.anchoredPosition += offset * newZoom;
}
}
}

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
@@ -19,7 +20,8 @@ namespace BaseGames.World.Map
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onRoomEntered; // 订阅 EVT_RoomEntered
[SerializeField] private StringEventChannelSO _onMapUpdated; // 发布:房间发现时
[Tooltip("房间被探索/标记时广播Explored/Mapped地图 UI 已改用 C# 事件,此通道保留供地图外部系统订阅(如成就、音效)。")]
[SerializeField] private StringEventChannelSO _onMapUpdated; // 发布:房间发现时(地图 UI 内当前无订阅者)
[SerializeField] private StringEventChannelSO _onRegionChanged; // 发布玩家首次进入新区域时EVT_RegionChanged
// 三级可见性:
@@ -30,22 +32,26 @@ namespace BaseGames.World.Map
private HashSet<string> _mappedRooms = new();
private string _currentRegionId;
private int _totalRoomCount = -1; // -1 = 未缓存OnLoad 后重置
private Dictionary<string, MapRoomDataSO[]> _regionCache; // R11-N6 GetRoomsByRegion 结果缓存
private bool _isDuplicate; // Awake 检测到重复实例时置位OnEnable/OnDisable 提前 return
private readonly CompositeDisposable _subs = new();
private void Awake()
{
if (ServiceLocator.GetOrDefault<IMapService>() != null) { Destroy(gameObject); return; }
if (ServiceLocator.GetOrDefault<IMapService>() != null) { _isDuplicate = true; Destroy(gameObject); return; }
ServiceLocator.Register<IMapService>(this);
}
private void OnEnable()
{
if (_isDuplicate) return;
_onRoomEntered?.Subscribe(OnRoomEntered).AddTo(_subs);
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
}
private void OnDisable()
{
if (_isDuplicate) return;
_subs.Clear();
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
}
@@ -56,21 +62,31 @@ namespace BaseGames.World.Map
{
data.Map.ExploredRooms = new HashSet<string>(_exploredRooms);
data.Map.MappedRooms = new HashSet<string>(_mappedRooms);
data.Map.LastRegionId = _currentRegionId;
}
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>();
_totalRoomCount = -1; // 强制下次调用 GetExplorationProgress 时重新计数
_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>();
_currentRegionId = data.Map.LastRegionId; // 恢复区域 ID避免读档后首次进房误触发 EVT_RegionChanged
_totalRoomCount = -1; // 强制下次调用 GetExplorationProgress 时重新计数
// 读档后广播UI 仅需轻量刷新(不重建结构);订阅 OnExplorationChanged 的 UI 会 RefreshAllCells
OnExplorationChanged?.Invoke();
}
// ── 事件驱动房间发现 ──────────────────────────────────────────────────
private void OnRoomEntered(string roomId)
{
if (string.IsNullOrEmpty(roomId)) return;
bool changed = _exploredRooms.Add(roomId);
if (changed) _onMapUpdated?.Raise(roomId);
if (changed)
{
_onMapUpdated?.Raise(roomId);
OnExplorationChanged?.Invoke();
}
// 区域变化检测RegionId 非空且与上一次不同时广播 EVT_RegionChanged
var regionId = _database?.GetRoom(roomId)?.RegionId;
@@ -88,8 +104,31 @@ namespace BaseGames.World.Map
/// </summary>
public void SetMapped(string roomId)
{
if (string.IsNullOrEmpty(roomId)) return;
if (_mappedRooms.Add(roomId))
{
_onMapUpdated?.Raise(roomId);
OnRoomMapped?.Invoke(roomId);
OnExplorationChanged?.Invoke();
}
}
/// <inheritdoc/>
public void SetMappedBatch(IEnumerable<string> roomIds)
{
if (roomIds == null) return;
bool anyAdded = false;
foreach (var roomId in roomIds)
{
if (string.IsNullOrEmpty(roomId)) continue;
if (_mappedRooms.Add(roomId))
{
_onMapUpdated?.Raise(roomId);
OnRoomMapped?.Invoke(roomId);
anyAdded = true;
}
}
if (anyAdded) OnExplorationChanged?.Invoke();
}
// ── 查询 API ──────────────────────────────────────────────────────────
@@ -112,11 +151,40 @@ namespace BaseGames.World.Map
{
if (string.IsNullOrEmpty(regionId) || _database?.AllRooms == null)
return System.Array.Empty<MapRoomDataSO>();
return _database.AllRooms.Where(r => r != null && r.RegionId == regionId).ToArray();
// R11-N6 懒加载缓存,避免每帧 LINQ 扫描全量房间数组
_regionCache ??= new Dictionary<string, MapRoomDataSO[]>();
if (!_regionCache.TryGetValue(regionId, out var cached))
{
cached = _database.AllRooms.Where(r => r != null && r.RegionId == regionId).ToArray();
_regionCache[regionId] = cached;
}
return cached;
}
// ── 数据库热更事件 ────────────────────────────────────────────────────
/// <inheritdoc/>
public event Action OnDatabaseChanged;
/// <inheritdoc/>
public event Action OnExplorationChanged;
/// <inheritdoc/>
public event Action<string> OnRoomMapped;
/// <inheritdoc/>
public void NotifyDatabaseChanged()
{
_database?.InvalidateIndex();
_totalRoomCount = -1;
_regionCache = null; // R11-N6 数据库变更时清空区域缓存
OnDatabaseChanged?.Invoke();
}
private void OnDestroy()
{
if (_isDuplicate) return;
ServiceLocator.Unregister<IMapService>(this);
}
}

View File

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

View File

@@ -1,5 +1,6 @@
// NOTE: 此文件包含 MapPinManager 类,但文件名为 MapPin.cs历史遗留Unity .meta 绑定限制不可安全重命名)。
// 如需搜索,请搜索 "MapPinManager" 类名,而非文件名。
// PinSpriteEntry 已迁移到 MapPinConfigSO.csR12-N3
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
@@ -7,14 +8,6 @@ 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)。
/// 实现 <see cref="ISaveable"/> 和 <see cref="IPinService"/>,通过 ServiceLocator 对外暴露。
@@ -24,6 +17,7 @@ namespace BaseGames.World.Map
/// </para>
/// MapPin/PinType 数据类定义在 SaveData.csBaseGames.Core.Save避免循环依赖。
/// </summary>
[DefaultExecutionOrder(-500)] // 晚于 MapPlayerTracker(-600),早于默认 0确保 IPinService 在 UI SubscribeServices 前已注册
public class MapPinManager : MonoBehaviour, ISaveable, IPinService
{
private List<MapPin> _pins = new();
@@ -33,15 +27,44 @@ namespace BaseGames.World.Map
/// <summary>每次 Pin 集合发生变化时自增;外部消费方通过此版本号实现脏检查。</summary>
public int PinsVersion { get; private set; }
private IMapService _mapSvc; // Start 中缓存,避免 CreatePin 每次调用 ServiceLocator
private bool _isDuplicate;
private void Awake()
{
if (ServiceLocator.GetOrDefault<IPinService>() != null)
{
_isDuplicate = true;
Destroy(gameObject);
return;
}
// 服务注册迁移到 Awake/OnDestroy与 MapManager / MapPlayerTracker 对齐)
// 避免 OnEnable/OnDisable 模式下反复 Register/Unregister 导致其他模块持有过期引用
ServiceLocator.Register<IPinService>(this);
}
private void Start()
{
if (_isDuplicate) return;
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
}
private void OnEnable()
{
if (_isDuplicate) return;
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
ServiceLocator.Register<IPinService>(this);
}
private void OnDisable()
{
if (_isDuplicate) return;
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
}
private void OnDestroy()
{
if (_isDuplicate) return;
ServiceLocator.Unregister<IPinService>(this);
}
@@ -57,15 +80,34 @@ namespace BaseGames.World.Map
if (_pins.Remove(pin)) PinsVersion++;
}
/// <summary>便捷方法:用枚举类型创建并添加标记。</summary>
/// <summary>
/// 便捷方法:用枚举类型创建并添加标记。
/// <para>
/// 参数会做安全校验roomId 为空时返回 nullnormX/normY 自动 Clamp01
/// note 限制 64 字符;可选检查 roomId 是否存在于数据库(不存在时 Warning但仍允许创建以兼容运行时尚未加载的数据库场景
/// </para>
/// </summary>
public MapPin CreatePin(string roomId, float normX, float normY,
PinType type = PinType.Marker, string note = "")
{
if (string.IsNullOrEmpty(roomId))
{
Debug.LogWarning("[MapPinManager] CreatePin 失败roomId 为空。");
return null;
}
// 可选 RoomId 存在性验证:数据库已就绪时检查,未就绪时跳过(不阻塞)
if (_mapSvc?.Database != null && _mapSvc.Database.GetRoom(roomId) == null)
Debug.LogWarning($"[MapPinManager] CreatePin: roomId '{roomId}' 在数据库中不存在,标记将不会渲染。");
if (!string.IsNullOrEmpty(note) && note.Length > 64)
note = note.Substring(0, 64);
var pin = new MapPin
{
RoomId = roomId,
NormalizedPosX = normX,
NormalizedPosY = normY,
NormalizedPosX = Mathf.Clamp01(normX),
NormalizedPosY = Mathf.Clamp01(normY),
PinTypeInt = (int)type,
Note = note,
};
@@ -75,11 +117,14 @@ namespace BaseGames.World.Map
// ── ISaveable ─────────────────────────────────────────────────────────
public void OnSave(SaveData data) => data.Map.Pins = _pins;
// 拷贝 List 而非直接共享引用,避免序列化期间 AddPin/RemovePin 修改集合产生并发问题
public void OnSave(SaveData data) => data.Map.Pins = new List<MapPin>(_pins);
public void OnLoad(SaveData data)
{
_pins = data.Map.Pins ?? new List<MapPin>();
// R11-N3 防御性拷贝:避免与 SaveData.Map.Pins 共享同一引用,
// 防止调用方后续修改 data 时污染 _pins与 OnSave 的方向对称)
_pins = data.Map.Pins != null ? new List<MapPin>(data.Map.Pins) : new List<MapPin>();
PinsVersion++; // 加载存档后通知消费方重绘
}
}

View File

@@ -0,0 +1,53 @@
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core.Save;
namespace BaseGames.World.Map
{
/// <summary>
/// 集中管理标记类型PinType与对应 Sprite 的映射配置。
/// 替代原先分散在 MapPanel 和 MinimapHUD 中各自维护的 PinSpriteEntry 数组,
/// 两个 UI 组件共享同一资产引用,保证标记外观统一且便于策划修改。
/// <para>内部使用 <see cref="Dictionary{TKey,TValue}"/> 惰性缓存GetSprite 为 O(1) 查找。</para>
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/World/Map/PinConfig")]
public class MapPinConfigSO : ScriptableObject
{
[SerializeField] private PinSpriteEntry[] _entries;
private Dictionary<PinType, Sprite> _cache;
/// <summary>
/// 根据 PinType 返回对应 SpriteO(1) 哈希查找)。
/// 首次调用时惰性构建内部字典entries 中无匹配项时返回 null。
/// </summary>
public Sprite GetSprite(PinType type)
{
EnsureCache();
return _cache.TryGetValue(type, out var s) ? s : null;
}
private void EnsureCache()
{
if (_cache != null) return;
_cache = new Dictionary<PinType, Sprite>();
if (_entries == null) return;
foreach (var e in _entries)
_cache[e.PinType] = e.Sprite;
}
private void OnValidate()
{
// 编辑器修改配置后立即重建缓存,确保 Play Mode 前数据已就绪
_cache = null;
}
}
/// <summary>标记类型与显示精灵的映射表项。</summary>
[System.Serializable]
public class PinSpriteEntry
{
public PinType PinType;
public Sprite Sprite;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e2f1b2a5e2ccfeb4d9389fcaa3276220
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
@@ -9,12 +8,13 @@ namespace BaseGames.World.Map
/// 将玩家世界坐标转换为地图格子坐标,供 MapPanel / MinimapHUD 显示玩家位置图标。
/// 挂在 Player GameObject 上LateUpdate 每帧计算)。
/// <para>
/// 性能:首次启动时建立 Dictionary&lt;Vector2Int, string&gt; 空间索引,
/// 性能:通过 <see cref="MapDatabaseSO.GetRoomIdAtCell"/> 共享空间索引,
/// LateUpdate 房间判定为 O(1) 哈希查找。归一化位置每帧从世界坐标精确插值,
/// 确保图标跟随玩家平滑移动,而非以格子步长离散跳动。
/// </para>
/// 通过 <see cref="ServiceLocator"/> 注册为 <see cref="IPlayerPositionProvider"/>。
/// </summary>
[DefaultExecutionOrder(-600)] // 晚于 MapManager(-700),早于默认 0确保 IPlayerPositionProvider 在 MinimapHUD.Awake 前已注册
public class MapPlayerTracker : MonoBehaviour, IPlayerPositionProvider
{
[SerializeField] private Transform _playerTransform;
@@ -22,7 +22,12 @@ namespace BaseGames.World.Map
[Header("世界坐标 → 格子坐标换算参数")]
[Tooltip("1 格对应的世界单位数。请在关卡编辑器中测量房间实际尺寸后填入,确保与关卡设计对齐。")]
[SerializeField] private float _worldUnitsPerCell = 18f;
[SerializeField, Min(0.01f)] private float _worldUnitsPerCell = 18f;
[Tooltip("R11-N9 世界原点偏移量(世界单位)。\n" +
"当关卡世界坐标原点不在格子地图 (0,0) 处时,填入偏移量以对齐格子坐标系。\n" +
"例如:关卡第一个房间的世界左下角在 (-36, -18) 时,此处填 (-36, -18)。")]
[SerializeField] private Vector2 _worldOriginOffset = Vector2.zero;
/// <summary>玩家当前所在房间 ID未在任何已知房间内时为 null。</summary>
public string CurrentRoomId { get; private set; }
@@ -37,52 +42,41 @@ namespace BaseGames.World.Map
public event Action<string> OnRoomChanged;
private MapDatabaseSO _database;
private Dictionary<Vector2Int, string> _cellToRoomId;
private MapRoomDataSO _currentRoom; // 当前房间数据缓存,避免 LateUpdate 每帧 GetRoom 查找
private Vector2Int _lastCellPos = new Vector2Int(int.MinValue, int.MinValue);
private bool _isDuplicate; // Awake 检测到重复实例时置位Start/LateUpdate/OnDestroy 提前 return
private void Awake()
{
// 单例保护:同一时刻只允许一个 IPlayerPositionProvider 存在
if (ServiceLocator.GetOrDefault<IPlayerPositionProvider>() != null) return;
if (ServiceLocator.GetOrDefault<IPlayerPositionProvider>() != null)
{
_isDuplicate = true;
Destroy(gameObject);
return;
}
ServiceLocator.Register<IPlayerPositionProvider>(this);
}
private void Start()
{
if (_isDuplicate) return;
_database = _databaseOverride
?? ServiceLocator.GetOrDefault<IMapService>()?.Database;
BuildSpatialIndex();
}
private void OnDestroy()
{
if (_isDuplicate) return;
// ServiceLocator.Unregister<T>(impl) 内部以 ReferenceEquals 守卫,
// 仅当当前注册实例即本实例时才移除,避免多个 Tracker 并存时误清。
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;
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);
_cellToRoomId[cell] = room.RoomId;
}
}
}
private void LateUpdate()
{
if (_playerTransform == null || _cellToRoomId == null) return;
if (_isDuplicate) return;
if (_playerTransform == null || _database == null) return;
Vector2Int cellPos = WorldToCell(_playerTransform.position);
bool cellChanged = cellPos != _lastCellPos;
@@ -91,7 +85,9 @@ namespace BaseGames.World.Map
{
_lastCellPos = cellPos;
if (!_cellToRoomId.TryGetValue(cellPos, out var newRoomId))
// 通过 MapDatabaseSO 共享的空间索引查询O(1)),避免本组件重复构建索引
var newRoomId = _database.GetRoomIdAtCell(cellPos);
if (string.IsNullOrEmpty(newRoomId))
{
// 玩家离开所有已知房间
CurrentRoomId = null;
@@ -111,9 +107,10 @@ namespace BaseGames.World.Map
// 每帧从世界坐标精确计算归一化位置,实现平滑图标跟随
if (_currentRoom != null)
{
// R11-N9 减去 _worldOriginOffset 将世界坐标对齐格子坐标系原点
var worldMin = new Vector2(
_currentRoom.GridPosition.x * _worldUnitsPerCell,
_currentRoom.GridPosition.y * _worldUnitsPerCell);
_currentRoom.GridPosition.x * _worldUnitsPerCell + _worldOriginOffset.x,
_currentRoom.GridPosition.y * _worldUnitsPerCell + _worldOriginOffset.y);
var worldSize = new Vector2(
_currentRoom.GridSize.x * _worldUnitsPerCell,
_currentRoom.GridSize.y * _worldUnitsPerCell);
@@ -125,9 +122,43 @@ namespace BaseGames.World.Map
}
private Vector2Int WorldToCell(Vector2 worldPos)
=> new(
Mathf.FloorToInt(worldPos.x / _worldUnitsPerCell),
Mathf.FloorToInt(worldPos.y / _worldUnitsPerCell));
{
// R11-N9 减去 _worldOriginOffset 对齐格子坐标系,再除以单格世界尺寸
var adjusted = worldPos - _worldOriginOffset;
return new(
Mathf.FloorToInt(adjusted.x / _worldUnitsPerCell),
Mathf.FloorToInt(adjusted.y / _worldUnitsPerCell));
}
/// <summary>
/// 将世界坐标转换为所属房间 ID 和房间内归一化位置0~1
/// 实现 <see cref="IPlayerPositionProvider.TryGetRoomAtWorldPos"/>。
/// </summary>
public bool TryGetRoomAtWorldPos(Vector3 worldPos, out string roomId, out Vector2 normalizedPos)
{
roomId = null;
normalizedPos = Vector2.zero;
if (_database == null) return false;
var cellPos = WorldToCell(worldPos);
roomId = _database.GetRoomIdAtCell(cellPos);
if (string.IsNullOrEmpty(roomId)) return false;
var room = _database.GetRoom(roomId);
if (room == null) return false;
var worldMin = new Vector2(
room.GridPosition.x * _worldUnitsPerCell + _worldOriginOffset.x,
room.GridPosition.y * _worldUnitsPerCell + _worldOriginOffset.y);
var worldSize = new Vector2(
room.GridSize.x * _worldUnitsPerCell,
room.GridSize.y * _worldUnitsPerCell);
var localPos = (Vector2)worldPos - worldMin;
normalizedPos = new Vector2(
Mathf.Clamp01(localPos.x / Mathf.Max(1f, worldSize.x)),
Mathf.Clamp01(localPos.y / Mathf.Max(1f, worldSize.y)));
return true;
}
}
}

View File

@@ -0,0 +1,138 @@
using UnityEngine;
using UnityEngine.Serialization;
using TMPro;
using System.Collections.Generic;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.World.Map
{
/// <summary>
/// 探索进度百分比 UI 组件。
/// 显示全局探索进度和当前区域探索进度,随 OnExplorationChanged 实时更新。
/// <para>挂在 HUD 或地图面板下;需配置两个 TMP_Text 字段和可选的 StringEventChannelSO。</para>
/// </summary>
public class MapProgressDisplay : MonoBehaviour
{
[Header("文本组件")]
[Tooltip("全局探索进度文本,例如 '已探索 42%'。留空则不更新。")]
[SerializeField] private TMP_Text _globalProgressText;
[Tooltip("当前区域进度文本,例如 '区域67%'。留空则不更新。")]
[SerializeField] private TMP_Text _regionProgressText;
[Header("格式字符串({0:P0} = 百分比,{1} = 区域显示名)")]
[SerializeField] private string _globalFormat = "已探索 {0:P0}";
[SerializeField] private string _regionFormat = "{1}{0:P0}";
[Header("区域名映射(与 RegionNameDisplay 共用同一机制)")]
[Tooltip("RegionId → 本地化显示名映射。未配置时直接显示 RegionId。")]
[FormerlySerializedAs("_regionNameEntries")]
[SerializeField] private RegionNameEntry[] _regionNames;
[Header("Event Channels")]
[Tooltip("区域切换事件;订阅后区域切换时自动刷新区域进度。")]
[SerializeField] private StringEventChannelSO _onRegionChanged;
private IMapService _mapSvc;
private string _currentRegionId;
private readonly CompositeDisposable _subs = new();
// R15-N2 RegionId → Entry 字典,将 ResolveRegionDisplayName 从 O(N) 降至 O(1)
private Dictionary<string, RegionNameEntry> _regionDict;
private void Awake()
{
BuildRegionDict();
}
private void OnValidate() => BuildRegionDict();
private void OnEnable()
{
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
if (_mapSvc != null)
{
_mapSvc.OnExplorationChanged += Refresh;
_currentRegionId = _mapSvc.CurrentRegionId;
}
_onRegionChanged?.Subscribe(OnRegionChanged).AddTo(_subs);
Refresh();
}
private void OnDisable()
{
if (_mapSvc != null)
{
_mapSvc.OnExplorationChanged -= Refresh;
_mapSvc = null;
}
_subs.Clear();
}
private void OnRegionChanged(string regionId)
{
_currentRegionId = regionId;
Refresh();
}
/// <summary>立即刷新进度文本。可由外部调用(例如地图面板打开时)。</summary>
public void Refresh()
{
if (_mapSvc == null)
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
if (_mapSvc == null) return;
// 全局探索进度
if (_globalProgressText != null)
{
float progress = _mapSvc.GetExplorationProgress();
try
{
_globalProgressText.text = string.Format(_globalFormat, progress);
}
catch (System.FormatException)
{
_globalProgressText.text = $"{progress:P0}";
Debug.LogWarning($"[MapProgressDisplay] _globalFormat 格式字符串配置有误:'{_globalFormat}',已回退到默认显示。", this);
}
}
// 当前区域进度
if (_regionProgressText != null && !string.IsNullOrEmpty(_currentRegionId))
{
var rooms = _mapSvc.GetRoomsByRegion(_currentRegionId);
if (rooms != null && rooms.Length > 0)
{
int exploredCount = 0;
foreach (var r in rooms)
if (r != null && _mapSvc.IsExplored(r.RoomId)) exploredCount++;
float regionProgress = (float)exploredCount / rooms.Length;
// R15-N2 解析区域显示名(本地化 Key → DisplayName → RegionId 回退)
string regionDisplayName = ResolveRegionDisplayName(_currentRegionId);
try
{
_regionProgressText.text = string.Format(_regionFormat, regionProgress, regionDisplayName);
}
catch (System.FormatException)
{
_regionProgressText.text = $"{regionDisplayName}{regionProgress:P0}";
Debug.LogWarning($"[MapProgressDisplay] _regionFormat 格式字符串配置有误:'{_regionFormat}',已回退到默认显示。", this);
}
}
}
}
// ── 辅助方法 ──────────────────────────────────────────────────────────
/// <summary>预建 RegionId → Entry 字典O(1) 查询。</summary>
private void BuildRegionDict()
=> _regionDict = MapServiceExtensions.BuildRegionDict(_regionNames);
/// <summary>
/// 将 regionId 解析为玩家可读的显示名。
/// 优先读 LocKey本地化其次 DisplayName最后回退到 regionId 本身。
/// </summary>
private string ResolveRegionDisplayName(string regionId)
=> MapServiceExtensions.ResolveRegionDisplayName(_regionDict, regionId);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 204d99175f344594bb17c1e5a8e1ac3f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
@@ -20,6 +21,7 @@ namespace BaseGames.World.Map
[SerializeField] private Image _icon;
[SerializeField] private RawImage _outlineImage; // 可选:房间非矩形轮廓纹理
[SerializeField] private Image _highlight; // 可选:当前房间高亮描边(玩家所在时激活)
[SerializeField] private Image _fogOverlay; // 可选未知房间雾效覆盖层R12-FD
// 实例颜色(默认值与原硬编码保持一致);可通过 SetColors 统一覆盖
private Color _colExplored = Color.white;
@@ -37,25 +39,17 @@ namespace BaseGames.World.Map
private void Awake() => RT = GetComponent<RectTransform>();
/// <summary>
/// 初始化格子(位置、可见性、图标、Tooltip 回调)。
/// 初始化格子可见性、图标、Tooltip 回调)。
/// <para>R11-N8不再在 Setup 中设置位置/尺寸,调用方按需调用
/// <see cref="SetGridLayout"/> 或直接操作 <see cref="RT"/>。</para>
/// </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)
Action<string> onHover = null, Action onHoverExit = null)
{
_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)
{
@@ -72,6 +66,19 @@ namespace BaseGames.World.Map
}
}
/// <summary>
/// R11-N8 独立布局接口:根据房间格子坐标与单格像素数设定位置和尺寸。
/// <para>MapPanel.BuildGrid 与 MinimapHUD.PlaceCell 均通过此接口定位,
/// 两者互不干扰,消除 Setup 中的重复赋值。</para>
/// </summary>
public void SetGridLayout(MapRoomDataSO room, float pixelsPerCell)
{
RT.anchoredPosition = new Vector2(room.GridPosition.x * pixelsPerCell,
room.GridPosition.y * pixelsPerCell);
RT.sizeDelta = new Vector2(room.GridSize.x * pixelsPerCell,
room.GridSize.y * pixelsPerCell);
}
/// <summary>覆盖此格子的三级可见性颜色(通常由 MapPanel / MinimapHUD 在创建后统一调用)。</summary>
public void SetColors(Color explored, Color mapped, Color unknown)
{
@@ -91,6 +98,9 @@ namespace BaseGames.World.Map
RoomVisibility.Mapped => _colMapped,
_ => _colUnknown,
};
// R12-FD 雾效覆盖层:仅在完全未知时显示
if (_fogOverlay != null)
_fogOverlay.enabled = v == RoomVisibility.Unknown;
}
/// <summary>向后兼容:直接传 bool 时等同于 Explored / Unknown。</summary>
@@ -103,6 +113,25 @@ namespace BaseGames.World.Map
if (_highlight != null) _highlight.enabled = v;
}
/// <summary>
/// 新发现房间时播放闪白淡出动画R12-FC
/// 由 MapPanel.OnRoomMappedAnim 调用;协程安全:组件被销毁后 Unity 自动终止。
/// </summary>
public IEnumerator PlayRevealAnim(Color flashColor, float duration)
{
if (_bg == null) yield break;
var original = _bg.color;
_bg.color = flashColor;
float elapsed = 0f;
while (elapsed < duration)
{
_bg.color = Color.Lerp(flashColor, original, elapsed / duration);
elapsed += Time.unscaledDeltaTime;
yield return null;
}
_bg.color = original;
}
public void OnPointerEnter(PointerEventData _)
{
if (!string.IsNullOrEmpty(_displayName)) _onHover?.Invoke(_displayName);

View File

@@ -2,10 +2,28 @@ using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.Serialization;
using BaseGames.Core.Events;
namespace BaseGames.World.Map
{
/// <summary>
/// 房间功能类型标记(可多选)。
/// 替代原先的 IsBossRoom / IsSavePoint / IsShop 三个独立 bool
/// 支持复合类型(如一个房间既是存档点也是商店),并便于扩展新类型。
/// </summary>
[System.Flags]
public enum RoomType
{
None = 0,
BossRoom = 1 << 0,
SavePoint = 1 << 1,
Shop = 1 << 2,
Merchant = 1 << 3,
Challenge = 1 << 4,
TeleportStation = 1 << 5,
}
/// <summary>
/// 单个房间的地图数据 SO架构 15_MapShopModule §1.1)。
/// 资产路径: Assets/_Game/Data/Map/Rooms/Room_{RoomId}.asset
@@ -28,11 +46,27 @@ namespace BaseGames.World.Map
[Header("出口信息")]
public RoomExitData[] Exits; // 该房间所有出口定义
[Header("特殊标记")]
public bool IsBossRoom;
public bool IsSavePoint;
public bool IsShop;
public Sprite MapIconOverride; // null = 按 isXxx 自动选择图标
[Header("房间类型标记(可多选)")]
public RoomType RoomFlags; // 支持多类型组合,替代旧的三个 bool 字段
[HideInInspector] public bool IsBossRoom; // 旧字段保留序列化兼容性OnValidate 自动迁移到 RoomFlags
[HideInInspector] public bool IsSavePoint; // 旧字段保留序列化兼容性OnValidate 自动迁移到 RoomFlags
[HideInInspector] public bool IsShop; // 旧字段保留序列化兼容性OnValidate 自动迁移到 RoomFlags
public Sprite MapIconOverride; // null = 按 RoomFlags 自动选择图标
/// <summary>
/// R20-N2 集中图标优先级逻辑,替代 MapPanel / MinimapHUD 各自重复的 ChooseIcon 实现。
/// 优先级MapIconOverride > SavePoint > BossRoom > Shop > TeleportStation。
/// 对应 Sprite 未配置时返回 null格子不显示图标
/// </summary>
public Sprite ChooseDisplayIcon(Sprite savePoint, Sprite boss, Sprite shop, Sprite teleport)
{
if (MapIconOverride != null) return MapIconOverride;
if (RoomFlags.HasFlag(RoomType.SavePoint) || IsSavePoint) return savePoint;
if (RoomFlags.HasFlag(RoomType.BossRoom) || IsBossRoom) return boss;
if (RoomFlags.HasFlag(RoomType.Shop) || IsShop) return shop;
if (RoomFlags.HasFlag(RoomType.TeleportStation)) return teleport;
return null;
}
[Header("流式加载")]
[Tooltip("此房间场景资产的预估内存KB。\n" +
@@ -45,7 +79,46 @@ namespace BaseGames.World.Map
{
// 保证 GridSize 每轴最小为 1防止零尺寸房间导致碰撞和渲染异常
GridSize = new Vector2Int(Mathf.Max(1, GridSize.x), Mathf.Max(1, GridSize.y));
// R11-N11 自动修剪 RoomId 首尾空格,避免 " Room_A " 与 "Room_A" 被视为不同键
if (!string.IsNullOrEmpty(RoomId) && RoomId != RoomId.Trim())
RoomId = RoomId.Trim();
// R12-N9 将旧 bool 字段迁移到 RoomFlagsRoomFlags 为 None 且旧字段有值时执行一次)
if (RoomFlags == RoomType.None)
{
if (IsBossRoom) RoomFlags |= RoomType.BossRoom;
if (IsSavePoint) RoomFlags |= RoomType.SavePoint;
if (IsShop) RoomFlags |= RoomType.Shop;
}
#if UNITY_EDITOR
// R11-N2 先 -= 再 +=,保证同一 delayCall 序列中最多执行一次,
// 防止 Inspector 快速拖动滑条时重复追加 N×FindAssets 导致卡顿
UnityEditor.EditorApplication.delayCall -= NotifyOwningDatabases;
UnityEditor.EditorApplication.delayCall += NotifyOwningDatabases;
#endif
}
#if UNITY_EDITOR
private void NotifyOwningDatabases()
{
if (this == null) return;
var guids = UnityEditor.AssetDatabase.FindAssets("t:MapDatabaseSO");
foreach (var guid in guids)
{
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
var db = UnityEditor.AssetDatabase.LoadAssetAtPath<MapDatabaseSO>(path);
if (db?.AllRooms == null) continue;
if (System.Array.IndexOf(db.AllRooms, this) < 0) continue;
db.InvalidateIndex();
// Play Mode 下同时广播事件,让 UI 立即重建
if (UnityEngine.Application.isPlaying)
BaseGames.Core.ServiceLocator.GetOrDefault<IMapService>()?.NotifyDatabaseChanged();
}
}
#endif
}
[Serializable]
@@ -59,6 +132,10 @@ namespace BaseGames.World.Map
"Seamless无缝切换同区域相邻房间首选\n" +
"AtmosphericFade短暂淡出 + 区域名提示(跨大区域边界首选)。")]
public TransitionType PreferredTransitionType;
[Tooltip("是否已手动配置出口格子坐标。\n" +
"未勾选时,连线回退到房间中心,避免 (0,0) 与合法原点坐标产生歧义。")]
public bool HasCustomExitPos; // R12-N5 替代 ExitGridPos != Vector2Int.zero 哨兵用法
}
public enum ExitDirection { Up, Down, Left, Right }
@@ -72,9 +149,20 @@ namespace BaseGames.World.Map
[CreateAssetMenu(menuName = "BaseGames/World/Map/MapDatabase")]
public class MapDatabaseSO : ScriptableObject
{
public MapRoomDataSO[] AllRooms;
[SerializeField, FormerlySerializedAs("AllRooms")]
private MapRoomDataSO[] _allRooms;
private Dictionary<string, MapRoomDataSO> _index;
[SerializeField, Tooltip("勾选后AssetPostprocessor 自动注册的新房间会优先加入此 Database多个勾选时取 GUID 排序首个。")]
private bool _isDefault;
/// <summary>所属全部房间(只读视图)。编辑器写入请通过 <see cref="EditorSetRooms"/>。</summary>
public MapRoomDataSO[] AllRooms => _allRooms;
/// <summary>是否被标记为默认 Database。<see cref="MapRoomAutoRegister"/> 据此决定新建房间归属。</summary>
public bool IsDefault => _isDefault;
private Dictionary<string, MapRoomDataSO> _index;
private Dictionary<Vector2Int, string> _cellToRoom; // 格子坐标 → 房间 ID 空间索引(共享给 MinimapHUD/MapPlayerTracker避免重复构建
/// <summary>运行时快速查找(首次调用时建立索引)。</summary>
public MapRoomDataSO GetRoom(string roomId)
@@ -82,16 +170,77 @@ namespace BaseGames.World.Map
if (_index == null)
{
if (AllRooms == null) return null;
_index = AllRooms.Where(r => r != null)
.ToDictionary(r => r.RoomId);
// R29-N2 使用 TryAdd首条胜出防止重复 RoomId 触发 ArgumentException
// 编辑器侧 ValidateAll 负责提示策划修复数据,运行时继续工作不崩溃。
_index = new Dictionary<string, MapRoomDataSO>();
foreach (var r in AllRooms)
if (r != null && !string.IsNullOrEmpty(r.RoomId))
_index.TryAdd(r.RoomId, r);
}
_index.TryGetValue(roomId, out var r);
return r;
}
private void OnDisable() => _index = null; // SO 卸载时清理缓存
/// <summary>
/// 在指定格子坐标处查询所属房间 IDO(1) 哈希查找)。
/// 首次调用时惰性构建空间索引,由所有消费方(小地图/玩家追踪)共享,避免重复构建。
/// </summary>
public string GetRoomIdAtCell(Vector2Int cell)
{
EnsureSpatialIndex();
return _cellToRoom != null && _cellToRoom.TryGetValue(cell, out var id) ? id : null;
}
private void OnValidate() => _index = null; // 编辑器中修改 AllRooms 后强制重建索引
private void EnsureSpatialIndex()
{
if (_cellToRoom != null) return;
_cellToRoom = new Dictionary<Vector2Int, string>();
if (AllRooms == null) return;
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++)
_cellToRoom[new Vector2Int(room.GridPosition.x + x, room.GridPosition.y + y)] = room.RoomId;
}
}
/// <summary>数据库变更时(编辑器热改 / 运行时热更)强制让索引下次访问时重建。</summary>
public void InvalidateIndex()
{
_index = null;
_cellToRoom = null;
}
#if UNITY_EDITOR
/// <summary>
/// 编辑器专用:写入 <see cref="AllRooms"/> 数组并强制失效空间索引。
/// 替代直接赋值 public 字段,确保 <see cref="MapRoomAutoRegister"/> / 测试代码不绕过封装。
/// </summary>
public void EditorSetRooms(MapRoomDataSO[] rooms)
{
_allRooms = rooms;
InvalidateIndex();
}
#endif
private void OnDisable()
{
// SO 卸载时清理缓存
_index = null;
_cellToRoom = null;
}
private void OnValidate()
{
// 编辑器中修改 AllRooms 后强制重建索引
_index = null;
_cellToRoom = null;
#if UNITY_EDITOR
if (UnityEngine.Application.isPlaying)
BaseGames.Core.ServiceLocator.GetOrDefault<IMapService>()?.NotifyDatabaseChanged();
#endif
}
// ── 配置验证 ──────────────────────────────────────────────────────────
#if UNITY_EDITOR
@@ -111,6 +260,15 @@ namespace BaseGames.World.Map
if (AllRooms[i] == null) { errors.Add($"AllRooms[{i}] 为 null"); continue; }
if (string.IsNullOrEmpty(AllRooms[i].RoomId))
errors.Add($"AllRooms[{i}]{AllRooms[i].name}RoomId 为空");
else
{
// R11-N11 首尾空格检查OnValidate 已自动 Trim此处兜底提示未经 OnValidate 的旧资产)
if (AllRooms[i].RoomId != AllRooms[i].RoomId.Trim())
errors.Add($"'{AllRooms[i].name}' RoomId 含首尾空格,请在 Inspector 中保存触发自动修剪");
// 特殊字符检查:/ \ | 等可能影响路径/键处理的字符
if (AllRooms[i].RoomId.IndexOfAny(new[]{ '/', '\\', '|', '<', '>', '*', '?' }) >= 0)
errors.Add($"'{AllRooms[i].RoomId}' 含非法字符(/ \\ | < > * ?),可能影响场景名匹配和存档键");
}
}
// ② RoomId 重复

View File

@@ -3,9 +3,41 @@ namespace BaseGames.World.Map
/// <summary>
/// IMapService 无状态扩展方法,集中可复用的查询逻辑。
/// MapPanel、MinimapHUD 等所有消费方均调用此处,避免分散的重复实现。
/// <para>
/// RegionNameEntry 字典构建与解析逻辑BuildRegionDict / ResolveRegionDisplayName
/// 也集中在此,供 RegionNameDisplay 和 MapProgressDisplay 共享,消除 DRY 违反。
/// </para>
/// </summary>
public static class MapServiceExtensions
{
/// <summary>
/// 将 RegionNameEntry 数组构建为 O(1) 查询字典。
/// RegionNameDisplay / MapProgressDisplay 共享此实现,避免重复代码。
/// </summary>
public static System.Collections.Generic.Dictionary<string, RegionNameEntry> BuildRegionDict(
RegionNameEntry[] entries)
{
var dict = new System.Collections.Generic.Dictionary<string, RegionNameEntry>();
if (entries == null) return dict;
foreach (var e in entries)
if (!string.IsNullOrEmpty(e.RegionId))
dict[e.RegionId] = e;
return dict;
}
/// <summary>
/// 将 regionId 解析为玩家可读的显示名。
/// 优先读 LocKey其次 DisplayName最后回退到 regionId 本身。
/// </summary>
public static string ResolveRegionDisplayName(
System.Collections.Generic.Dictionary<string, RegionNameEntry> dict,
string regionId)
{
if (dict != null && dict.TryGetValue(regionId, out var e))
return e.GetDisplayName();
return regionId;
}
/// <summary>
/// 根据探索状态推导房间三级可见性Explored > Mapped > Unknown
/// </summary>
@@ -16,5 +48,22 @@ namespace BaseGames.World.Map
if (svc.IsMapped(roomId)) return RoomVisibility.Mapped;
return RoomVisibility.Unknown;
}
/// <summary>
/// 在指定世界坐标处创建地图标记。
/// 通过 <see cref="IPlayerPositionProvider.TryGetRoomAtWorldPos"/> 将世界坐标转换为
/// 房间 ID 和归一化位置;坐标不在任何已知房间内时返回 null。
/// </summary>
public static BaseGames.Core.Save.MapPin CreatePinAtWorldPos(
this IPinService pinSvc,
IPlayerPositionProvider playerProvider,
UnityEngine.Vector3 worldPos,
BaseGames.Core.Save.PinType type = BaseGames.Core.Save.PinType.Marker,
string note = "")
{
if (pinSvc == null || playerProvider == null) return null;
if (!playerProvider.TryGetRoomAtWorldPos(worldPos, out var roomId, out var normPos)) return null;
return pinSvc.CreatePin(roomId, normPos.x, normPos.y, type, note);
}
}
}

View File

@@ -3,6 +3,7 @@ using UnityEngine;
using UnityEngine.UI;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
namespace BaseGames.World.Map
{
@@ -32,20 +33,44 @@ namespace BaseGames.World.Map
[SerializeField] private Color _colorUnknown = Color.black;
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onMapUpdated; // 房间发现/标注时局部刷新
// R18-N3 _onMapUpdated 订阅已废弃(对齐 MapPanel R12-N8
// OnExplorationChanged 全量刷新完全覆盖 OnMapUpdated 的单格更新,订阅形成冗余双重刷新。
// 保留 [HideInInspector, SerializeField] 维持 Prefab 序列化兼容,不再订阅事件。
[HideInInspector, SerializeField] private StringEventChannelSO _onMapUpdated;
[Header("地图标记(可选)")]
[SerializeField] private Image _pinPrefab; // 留空则不渲染 Pin
[SerializeField] private MapPinConfigSO _pinConfig; // R12-N3 集中 PinType→Sprite 映射
[Header("房间类型图标(可选,与全屏地图保持视觉一致)")]
[SerializeField] private Sprite _iconSavePoint;
[SerializeField] private Sprite _iconBossRoom;
[SerializeField] private Sprite _iconShop;
[Tooltip("传送站图标;房间含 TeleportStation 标志时显示。")]
[SerializeField] private Sprite _iconTeleport;
[Header("缩放档位(可选)")]
[SerializeField] private int[] _zoomLevels = { 2, 3, 5 }; // R12-FA 可用视野半径档位(格)
private int _zoomLevelIndex;
private IMapService _mapSvc;
private IPlayerPositionProvider _playerProvider;
private IPinService _pinService;
private readonly Dictionary<string, MapRoomCellUI> _cells = new();
private readonly CompositeDisposable _subs = new();
private readonly List<Image> _pinImages = new();
private readonly Stack<Image> _pinPool = new(); // R10-N3 Pin 对象池
private readonly Stack<MapRoomCellUI> _cellPool = new(); // R11-N5 Cell 对象池
private int _lastPinVersion = -1;
private bool _viewDirty; // R10-N2 关闭期间收到 OnRoomChanged → 下次 OnEnable RefreshView
private bool _databaseDirty; // R10-N1 关闭期间收到 OnDatabaseChanged → 下次 OnEnable 完整重建
private bool _servicesReady; // R21-N1 三服务全部就绪后置 true短路 LateUpdate 的每帧 ServiceLocator 查询(对齐 MapPanel
// 复用 List 避免 RefreshView 每次分配临时 ListGC 友好)
private readonly List<string> _toRemove = new List<string>(8);
// 空间索引:格子坐标 → 房间 ID将 RefreshView step② 的 O(N) 遍历降至 O(viewRadius²)
private Dictionary<Vector2Int, string> _spatialIndex;
// 复用 HashSet 避免 RefreshView 每次分配GC 友好)
private readonly HashSet<string> _roomsInViewBuffer = new HashSet<string>(32);
private readonly HashSet<string> _newlyAddedBuffer = new HashSet<string>(16);
private Vector2Int _currentCenter;
private string _lastDotRoomId;
@@ -53,73 +78,195 @@ namespace BaseGames.World.Map
// ── 生命周期 ──────────────────────────────────────────────────────────
private void Awake()
{
// R10-N2/N1 服务订阅在 Awake/OnDestroy 长期持有OnDisable 不解绑
// 即便 HUD 隐藏期间发生 OnRoomChanged / OnDatabaseChanged也能记录 dirty 标志
SubscribeServices();
}
private void OnEnable()
{
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
_playerProvider = ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
// 启动顺序兜底
SubscribeServices();
BuildSpatialIndex(_mapSvc?.Database);
// R10-N1/N2 应用关闭期间累积的状态变化
if (_databaseDirty)
{
ClearAllCells();
ClearPins();
_lastPinVersion = -1;
_databaseDirty = false;
_viewDirty = true; // 重建后需 RefreshView
}
if (_playerProvider != null)
_playerProvider.OnRoomChanged += OnRoomChanged;
_onMapUpdated?.Subscribe(OnMapUpdated).AddTo(_subs);
// 首次显示时立即刷新
RefreshView();
if (_viewDirty || _cells.Count == 0)
{
_viewDirty = false;
RefreshView();
}
}
private void OnDisable()
{
if (_playerProvider != null)
_playerProvider.OnRoomChanged -= OnRoomChanged;
_subs.Clear();
ClearAllCells();
_lastDotRoomId = null;
_mapSvc = null;
_playerProvider = null;
_spatialIndex = null;
// R10-N2 保留 cells/pins/服务订阅HUD 频繁开关时避免 GC 抖动
}
private void OnDestroy()
{
UnsubscribeServices();
ClearAllCells();
ClearPins();
foreach (var img in _pinPool)
if (img != null) Destroy(img.gameObject);
_pinPool.Clear();
foreach (var cell in _cellPool)
if (cell != null) Destroy(cell.gameObject);
_cellPool.Clear();
}
private void SubscribeServices()
{
// 各服务独立守门任意服务迟到就绪时后续调用仍能补订阅R11-N1 修复)
if (_playerProvider == null)
{
_playerProvider = ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
if (_playerProvider != null)
_playerProvider.OnRoomChanged += OnRoomChanged;
}
if (_mapSvc == null)
{
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
if (_mapSvc != null)
{
_mapSvc.OnDatabaseChanged += OnDatabaseChanged;
_mapSvc.OnExplorationChanged += OnExplorationChanged;
}
}
_pinService ??= ServiceLocator.GetOrDefault<IPinService>();
// R21-N1 三服务全部就绪后置 true短路 LateUpdate 的每帧查询(对齐 MapPanel._servicesReady
if (_playerProvider != null && _mapSvc != null && _pinService != null)
_servicesReady = true;
}
private void UnsubscribeServices()
{
_servicesReady = false; // R21-N3 重置,确保重建场景后的实例能重新订阅
if (_playerProvider != null)
{
_playerProvider.OnRoomChanged -= OnRoomChanged;
_playerProvider = null;
}
if (_mapSvc != null)
{
_mapSvc.OnDatabaseChanged -= OnDatabaseChanged;
_mapSvc.OnExplorationChanged -= OnExplorationChanged;
_mapSvc = null;
}
_pinService = null;
}
private void ClearAllCells()
{
// R11-N5 禁用入池而非销毁
foreach (var cell in _cells.Values)
if (cell != null) Destroy(cell.gameObject);
{
if (cell == null) continue;
cell.gameObject.SetActive(false);
_cellPool.Push(cell);
}
_cells.Clear();
}
/// <summary>
/// 构建格子坐标 → 房间 ID 的哈希映射。
/// 将 RefreshView step② 从 O(allRooms) 全量遍历降至 O(viewRadius²) 范围格点查询。
/// 数据库变更时(如热更)应再次调用。
/// </summary>
private void BuildSpatialIndex(MapDatabaseSO db)
private void ClearPins()
{
_spatialIndex = new Dictionary<Vector2Int, string>();
if (db?.AllRooms == null) return;
foreach (var room in db.AllRooms)
// R10-N3 禁用入池而非销毁
foreach (var img in _pinImages)
{
if (room == null) continue;
for (int x = 0; x < room.GridSize.x; x++)
for (int y = 0; y < room.GridSize.y; y++)
_spatialIndex[new Vector2Int(room.GridPosition.x + x, room.GridPosition.y + y)] = room.RoomId;
if (img == null) continue;
img.gameObject.SetActive(false);
_pinPool.Push(img);
}
_pinImages.Clear();
}
private void LateUpdate()
{
// R21-N1 服务懒加载重试_servicesReady 置 true 后短路,消除每帧 ServiceLocator 查询(对齐 MapPanel
if (!_servicesReady)
SubscribeServices();
UpdatePlayerDot();
RenderPinsIfDirty();
}
// ── 事件响应 ──────────────────────────────────────────────────────────
private void OnRoomChanged(string _) => RefreshView();
private void OnMapUpdated(string roomId)
private void OnRoomChanged(string _)
{
if (_cells.TryGetValue(roomId, out var cell))
cell.SetVisibility(_mapSvc.GetVisibility(roomId));
// R10-N2 禁用时累积 dirtyOnEnable 后再 RefreshView
if (!isActiveAndEnabled) { _viewDirty = true; return; }
RefreshView();
}
/// <summary>R10-N12 探索进度变化:仅刷新已实例化的格子可见性。</summary>
private void OnExplorationChanged()
{
if (!isActiveAndEnabled) { _viewDirty = true; return; }
if (_mapSvc == null) return;
foreach (var (id, cell) in _cells)
if (cell != null) cell.SetVisibility(_mapSvc.GetVisibility(id));
}
/// <summary>数据库结构变更时完整重建:清空所有格子,下次 RefreshView 重新实例化。</summary>
private void OnDatabaseChanged()
{
if (!isActiveAndEnabled) { _databaseDirty = true; return; }
ClearAllCells();
ClearPins();
_lastPinVersion = -1;
RefreshView();
}
// ── Pin 渲染(可视范围内)─────────────────────────────────────────────
/// <summary>
/// 仅渲染当前小地图视野内(已实例化格子的房间)的 Pin。
/// 基于 PinsVersion + RefreshView 时机做脏检查:版本未变化且格子未变化时跳过。
/// 单地图 N视野级别开销远小于 MapPanel 的全图 Pin 渲染。
/// </summary>
private void RenderPinsIfDirty()
{
if (_pinService == null || _pinPrefab == null) return;
if (_pinService.PinsVersion == _lastPinVersion) return;
_lastPinVersion = _pinService.PinsVersion;
RebuildPins();
}
private void RebuildPins()
{
ClearPins();
foreach (var pin in _pinService.Pins)
{
if (!_cells.TryGetValue(pin.RoomId, out var cell)) continue;
// R10-N3 优先从对象池取
Image img;
if (_pinPool.Count > 0)
{
img = _pinPool.Pop();
img.gameObject.SetActive(true);
}
else
{
img = Instantiate(_pinPrefab, _cellContainer);
}
img.sprite = _pinConfig != null ? _pinConfig.GetSprite((PinType)pin.PinTypeInt) : null;
img.rectTransform.anchoredPosition = cell.RT.anchoredPosition
+ new Vector2(pin.NormalizedPosX * cell.RT.sizeDelta.x,
pin.NormalizedPosY * cell.RT.sizeDelta.y);
_pinImages.Add(img);
}
}
// ── 视图重建 ──────────────────────────────────────────────────────────
@@ -153,42 +300,56 @@ namespace BaseGames.World.Map
var r = db.GetRoom(id);
if (r == null || !RoomInView(r, minX, maxX, minY, maxY))
{
if (cell != null) Destroy(cell.gameObject);
// R11-N5 禁用入池而非 Destroy下次复用避免 GC 抖动
if (cell != null)
{
cell.gameObject.SetActive(false);
_cellPool.Push(cell);
}
_toRemove.Add(id);
}
}
foreach (var id in _toRemove) _cells.Remove(id);
// ② 用空间索引替代 O(N) 全量遍历,在可视范围格点上查询所属房间
// ② 通过 MapDatabaseSO 共享空间索引,在可视范围格点上查询所属房间
// 复杂度O(viewRadius²) 替代 O(allRooms),大地图下效果显著
_roomsInViewBuffer.Clear();
if (_spatialIndex != null)
for (int x = minX; x <= maxX; x++)
for (int y = minY; y <= maxY; y++)
{
for (int x = minX; x <= maxX; x++)
for (int y = minY; y <= maxY; y++)
{
if (_spatialIndex.TryGetValue(new Vector2Int(x, y), out var rId))
_roomsInViewBuffer.Add(rId);
}
var rId = db.GetRoomIdAtCell(new Vector2Int(x, y));
if (!string.IsNullOrEmpty(rId)) _roomsInViewBuffer.Add(rId);
}
_newlyAddedBuffer.Clear();
foreach (var roomId in _roomsInViewBuffer)
{
if (_cells.ContainsKey(roomId)) continue;
var room = db.GetRoom(roomId);
if (room == null) continue;
var cell = Instantiate(_cellPrefab, _cellContainer);
cell.Setup(room, _mapSvc.GetVisibility(room.RoomId), null);
// R11-N5 优先从对象池取,避免高频 Instantiate
MapRoomCellUI cell;
if (_cellPool.Count > 0)
{
cell = _cellPool.Pop();
cell.gameObject.SetActive(true);
}
else
{
cell = Instantiate(_cellPrefab, _cellContainer);
}
cell.Setup(room, _mapSvc.GetVisibility(room.RoomId), ChooseIcon(room));
cell.SetColors(_colorExplored, _colorMapped, _colorUnknown);
PlaceCell(cell, room); // 立即设置正确的中心相对坐标,避免 Setup 默认偏移被 step③ 覆盖
PlaceCell(cell, room); // 立即设置正确的中心相对坐标
_cells[roomId] = cell;
_newlyAddedBuffer.Add(roomId);
}
// ③ 重定位所有格子(中心发生变化时
// ③ 重定位存量格子(新增格子在 step② 已 PlaceCell跳过避免重复写入
foreach (var (id, cell) in _cells)
{
if (cell == null) continue;
if (cell == null || _newlyAddedBuffer.Contains(id)) continue;
var r = db.GetRoom(id);
if (r != null) PlaceCell(cell, r);
}
@@ -206,6 +367,13 @@ namespace BaseGames.World.Map
(room.GridPosition.y - _currentCenter.y) * _cellPixels);
}
/// <summary>
/// R20-N2 委托到 MapRoomDataSO.ChooseDisplayIcon消除与 MapPanel 的重复实现。
/// 优先级MapIconOverride > SavePoint > BossRoom > Shop > TeleportStation。
/// </summary>
private Sprite ChooseIcon(MapRoomDataSO room)
=> room.ChooseDisplayIcon(_iconSavePoint, _iconBossRoom, _iconShop, _iconTeleport);
private static bool RoomInView(MapRoomDataSO room, int minX, int maxX, int minY, int maxY)
=> room.GridPosition.x + room.GridSize.x > minX &&
room.GridPosition.x < maxX &&
@@ -237,5 +405,29 @@ namespace BaseGames.World.Map
cell.RT.anchoredPosition
+ Vector2.Scale(normPos, cell.RT.sizeDelta);
}
// ── 缩放档位切换 ─────────────────────────────────────────────────────
/// <summary>
/// R12-FA 循环切换视野半径档位(可绑定到按键/按钮)。
/// 档位在 Inspector 中通过 _zoomLevels 数组配置默认2/3/5 格)。
/// 安全检查:数组为空时不切换,避免除零或越界。
/// </summary>
public void CycleZoom()
{
if (_zoomLevels == null || _zoomLevels.Length == 0) return;
_zoomLevelIndex = (_zoomLevelIndex + 1) % _zoomLevels.Length;
_viewRadiusCells = _zoomLevels[_zoomLevelIndex];
if (isActiveAndEnabled)
{
// R29-N1 先清除标志再刷新,防止 HUD 关闭后重新打开触发冗余 RefreshView
_viewDirty = false;
RefreshView();
}
else
{
_viewDirty = true;
}
}
}
}

View File

@@ -0,0 +1,38 @@
using UnityEngine;
using BaseGames.Input;
namespace BaseGames.World.Map
{
/// <summary>
/// 小地图 HUD 输入处理器R13-N3
/// 挂在与 MinimapHUD 相同的 GameObject 上,负责将 InputReaderSO 事件路由到 MinimapHUD。
/// <list type="bullet">
/// <item>CycleMinimapZoomEvent → MinimapHUD.CycleZoom()(循环切换视野半径档位)</item>
/// </list>
/// 输入动作在 InputActionAsset 的 UI Map 中命名为 "CycleMinimapZoom"
/// 若动作不存在InputReaderSO 会打印 Warning 并安全跳过,不影响运行。
/// </summary>
[RequireComponent(typeof(MinimapHUD))]
public class MinimapInputHandler : MonoBehaviour
{
[SerializeField] private InputReaderSO _inputReader;
private MinimapHUD _hud;
private void Awake() => _hud = GetComponent<MinimapHUD>();
private void OnEnable()
{
if (_inputReader != null)
_inputReader.CycleMinimapZoomEvent += OnCycleZoom;
}
private void OnDisable()
{
if (_inputReader != null)
_inputReader.CycleMinimapZoomEvent -= OnCycleZoom;
}
private void OnCycleZoom() => _hud?.CycleZoom();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d63ff68b5c5813a42866a97b22ec9850
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;
using BaseGames.Core;
@@ -31,14 +32,18 @@ namespace BaseGames.World.Map
private CanvasGroup _cg;
private Coroutine _showCoroutine;
private readonly CompositeDisposable _subs = new();
private Dictionary<string, RegionNameEntry> _regionDict;
private void Awake()
{
_cg = GetComponent<CanvasGroup>();
_cg.alpha = 0f;
gameObject.SetActive(false);
BuildRegionDict();
}
private void OnValidate() => BuildRegionDict();
private void OnEnable()
{
_onRegionChanged?.Subscribe(OnRegionChanged).AddTo(_subs);
@@ -47,6 +52,14 @@ namespace BaseGames.World.Map
private void OnDisable()
{
_subs.Clear();
// 协程持有 this 引用且 SetActive(false) 不会自动停止——必须在 OnDisable 显式终止,
// 否则禁用后重新启用时旧序列与新序列叠加CanvasGroup.alpha 与 SetActive 状态不一致。
if (_showCoroutine != null)
{
StopCoroutine(_showCoroutine);
_showCoroutine = null;
}
if (_cg != null) _cg.alpha = 0f;
}
// ── 事件响应 ──────────────────────────────────────────────────────────
@@ -87,14 +100,12 @@ namespace BaseGames.World.Map
// ── 辅助方法 ──────────────────────────────────────────────────────────
/// <summary>预建 RegionId → Entry 字典,将 ResolveDisplayName 从 O(N) 降至 O(1)。</summary>
private void BuildRegionDict()
=> _regionDict = MapServiceExtensions.BuildRegionDict(_regionNames);
private string ResolveDisplayName(string regionId)
{
if (_regionNames != null)
foreach (var e in _regionNames)
if (e.RegionId == regionId)
return e.GetDisplayName();
return regionId;
}
=> MapServiceExtensions.ResolveRegionDisplayName(_regionDict, regionId);
}
[Serializable]

View File

@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Save;
namespace BaseGames.World.Map
{
/// <summary>
/// 传送/快速旅行服务实现(架构 15_MapShopModule §1.4)。
/// 挂在 Persistent 场景 [GameManagers] 下,通过 ServiceLocator 对外暴露 <see cref="ITeleportService"/>。
/// <para>
/// 追踪已解锁传送点UnlockedTeleportRoomIds实现 ISaveable 跨存档持久化。
/// 传送流程UI 调用 <see cref="RequestTeleport"/> → 触发 <see cref="OnTeleportRequested"/>
/// → 场景加载系统监听事件执行实际切换 → 到达后调用 <see cref="NotifyTeleportCompleted"/>。
/// </para>
/// </summary>
[DefaultExecutionOrder(-400)] // 晚于 MapPinManager(-500),早于默认 0确保 ITeleportService 在 UI SubscribeServices 前已注册
public class TeleportService : MonoBehaviour, ISaveable, ITeleportService
{
private readonly HashSet<string> _unlockedRoomIds = new();
private IMapService _mapSvc;
private IPlayerPositionProvider _playerProvider;
private bool _isDuplicate;
// ── ITeleportService 事件 ─────────────────────────────────────────────
/// <inheritdoc/>
public event Action<string, string> OnTeleportRequested;
/// <inheritdoc/>
public event Action<string> OnTeleportCompleted;
// ── 生命周期 ──────────────────────────────────────────────────────────
private void Awake()
{
if (ServiceLocator.GetOrDefault<ITeleportService>() != null)
{
_isDuplicate = true;
Destroy(gameObject);
return;
}
ServiceLocator.Register<ITeleportService>(this);
}
private void Start()
{
if (_isDuplicate) return;
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
_playerProvider = ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
}
private void OnEnable()
{
if (_isDuplicate) return;
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
}
private void OnDisable()
{
if (_isDuplicate) return;
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
}
private void OnDestroy()
{
if (_isDuplicate) return;
ServiceLocator.Unregister<ITeleportService>(this);
}
// ── ITeleportService API ──────────────────────────────────────────────
/// <inheritdoc/>
public bool CanTeleportTo(string roomId)
{
if (string.IsNullOrEmpty(roomId)) return false;
if (!_unlockedRoomIds.Contains(roomId)) return false;
// 需要玩家已探索过目标房间(仅已知位置才允许传送);
// _mapSvc 不可用时 Fail-Safe拒绝传送而非放行。
_mapSvc ??= ServiceLocator.GetOrDefault<IMapService>();
return _mapSvc != null && _mapSvc.IsExplored(roomId);
}
/// <inheritdoc/>
public void RequestTeleport(string targetRoomId)
{
if (!CanTeleportTo(targetRoomId))
{
Debug.LogWarning($"[TeleportService] 无法传送到 '{targetRoomId}':未解锁或未探索。");
return;
}
_playerProvider ??= ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
// 使用当前房间 ID 作为传送来源(与接口参数语义一致)
string sourceRoomId = _playerProvider?.CurrentRoomId ?? string.Empty;
OnTeleportRequested?.Invoke(sourceRoomId, targetRoomId);
}
/// <summary>
/// 场景加载系统在传送完成(玩家到达目标房间)后调用此方法,
/// 触发 <see cref="OnTeleportCompleted"/> 事件供 UI 刷新。
/// </summary>
public void NotifyTeleportCompleted(string arrivedRoomId)
{
OnTeleportCompleted?.Invoke(arrivedRoomId);
}
/// <summary>
/// 解锁指定房间的传送点(通常在玩家首次使用传送站时调用)。
/// </summary>
public void UnlockTeleportStation(string roomId)
{
if (!string.IsNullOrEmpty(roomId))
_unlockedRoomIds.Add(roomId);
}
// ── ISaveable ─────────────────────────────────────────────────────────
public void OnSave(SaveData data)
{
// 防御性拷贝,避免序列化期间集合被修改
data.Map.UnlockedTeleportRoomIds = new HashSet<string>(_unlockedRoomIds);
}
public void OnLoad(SaveData data)
{
// readonly 字段限制 → Clear + foreach Add与 MapManager._exploredRooms 模式对称)
_unlockedRoomIds.Clear();
if (data.Map.UnlockedTeleportRoomIds != null)
foreach (var id in data.Map.UnlockedTeleportRoomIds)
if (!string.IsNullOrEmpty(id))
_unlockedRoomIds.Add(id);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2b4b8e55bb3e7b34f9559e1b60a92733
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: