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:
@@ -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>
|
||||
|
||||
@@ -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] 验证通过,未发现问题 ✓");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
105
Assets/_Game/Scripts/Editor/World/Map/MapRoomAutoRegister.cs
Normal file
105
Assets/_Game/Scripts/Editor/World/Map/MapRoomAutoRegister.cs
Normal 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
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 770823afe767eef48a373ddbbb7eeaa0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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 在域重载/字段初始化器顺序下可能产生 NRE(normal.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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"BaseGames.Core",
|
||||
"BaseGames.Core.Save",
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Input",
|
||||
"BaseGames.Localization",
|
||||
"Unity.TextMeshPro"
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
44
Assets/_Game/Scripts/World/Map/ITeleportService.cs
Normal file
44
Assets/_Game/Scripts/World/Map/ITeleportService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/World/Map/ITeleportService.cs.meta
Normal file
11
Assets/_Game/Scripts/World/Map/ITeleportService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d61e0b1cfc586754488378eba7b89cb5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// NOTE: 此文件包含 MapPinManager 类,但文件名为 MapPin.cs(历史遗留,Unity .meta 绑定限制不可安全重命名)。
|
||||
// 如需搜索,请搜索 "MapPinManager" 类名,而非文件名。
|
||||
// PinSpriteEntry 已迁移到 MapPinConfigSO.cs(R12-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.cs(BaseGames.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 为空时返回 null;normX/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++; // 加载存档后通知消费方重绘
|
||||
}
|
||||
}
|
||||
|
||||
53
Assets/_Game/Scripts/World/Map/MapPinConfigSO.cs
Normal file
53
Assets/_Game/Scripts/World/Map/MapPinConfigSO.cs
Normal 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 返回对应 Sprite(O(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;
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/World/Map/MapPinConfigSO.cs.meta
Normal file
11
Assets/_Game/Scripts/World/Map/MapPinConfigSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e2f1b2a5e2ccfeb4d9389fcaa3276220
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<Vector2Int, string> 空间索引,
|
||||
/// 性能:通过 <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
138
Assets/_Game/Scripts/World/Map/MapProgressDisplay.cs
Normal file
138
Assets/_Game/Scripts/World/Map/MapProgressDisplay.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/World/Map/MapProgressDisplay.cs.meta
Normal file
11
Assets/_Game/Scripts/World/Map/MapProgressDisplay.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 204d99175f344594bb17c1e5a8e1ac3f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
|
||||
@@ -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 字段迁移到 RoomFlags(RoomFlags 为 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>
|
||||
/// 在指定格子坐标处查询所属房间 ID(O(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 重复
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 每次分配临时 List(GC 友好)
|
||||
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 禁用时累积 dirty;OnEnable 后再 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
Assets/_Game/Scripts/World/Map/MinimapInputHandler.cs
Normal file
38
Assets/_Game/Scripts/World/Map/MinimapInputHandler.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/World/Map/MinimapInputHandler.cs.meta
Normal file
11
Assets/_Game/Scripts/World/Map/MinimapInputHandler.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d63ff68b5c5813a42866a97b22ec9850
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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]
|
||||
|
||||
136
Assets/_Game/Scripts/World/Map/TeleportService.cs
Normal file
136
Assets/_Game/Scripts/World/Map/TeleportService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/World/Map/TeleportService.cs.meta
Normal file
11
Assets/_Game/Scripts/World/Map/TeleportService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2b4b8e55bb3e7b34f9559e1b60a92733
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user