using System; using System.Collections.Generic; using System.Linq; using UnityEngine; using UnityEngine.Serialization; using BaseGames.Core.Events; namespace BaseGames.World.Map { /// /// 房间功能类型标记(可多选)。 /// 替代原先的 IsBossRoom / IsSavePoint / IsShop 三个独立 bool, /// 支持复合类型(如一个房间既是存档点也是商店),并便于扩展新类型。 /// [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, } /// /// 单个房间的地图数据 SO(架构 15_MapShopModule §1.1)。 /// 资产路径: Assets/_Game/Data/Map/Rooms/Room_{RoomId}.asset /// [CreateAssetMenu(menuName = "BaseGames/World/Map/RoomData")] public class MapRoomDataSO : ScriptableObject { [Header("基础信息")] public string RoomId; // 与场景名一致,如 "Room_Forest_01" public string RegionId; // 所属区域,如 "Forest" public string DisplayName; // 可选,地图 Tooltip [Header("地图布局(格子坐标,单位:格)")] public Vector2Int GridPosition; // 左下角坐标 public Vector2Int GridSize; // 宽×高(格) [Header("房间轮廓纹理")] public Texture2D RoomOutlineTex; // 用于地图 UI 显示房间形状(可空,回退到矩形格子) [Header("出口信息")] public RoomExitData[] Exits; // 该房间所有出口定义 [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 自动选择图标 /// /// R20-N2 集中图标优先级逻辑,替代 MapPanel / MinimapHUD 各自重复的 ChooseIcon 实现。 /// 优先级:MapIconOverride > SavePoint > BossRoom > Shop > TeleportStation。 /// 对应 Sprite 未配置时返回 null(格子不显示图标)。 /// 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" + "在 Profiler 中测量场景实际内存后填入,供流式管理器执行内存预算检查使用。\n" + "建议在关卡内容基本定型后更新此值。0 = 未填写,将跳过内存预算检查。")] [Min(0)] public int EstimatedMemoryKB; private void OnValidate() { // 保证 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(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()?.NotifyDatabaseChanged(); } } #endif } [Serializable] public struct RoomExitData { public string TargetRoomId; // 连接的目标房间 ID public Vector2Int ExitGridPos; // 出口在格子地图上的位置 public ExitDirection Direction; // 出口方向 [Tooltip("此出口触发的过渡类型。\n" + "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 } // ─── 全局地图数据库 ────────────────────────────────────────────────────────── /// /// 全局地图数据库 SO(编辑器配置一次;架构 15_MapShopModule §1.1)。 /// 资产路径: Assets/_Game/Data/Map/MapDatabase.asset /// [CreateAssetMenu(menuName = "BaseGames/World/Map/MapDatabase")] public class MapDatabaseSO : ScriptableObject { [SerializeField, FormerlySerializedAs("AllRooms")] private MapRoomDataSO[] _allRooms; [SerializeField, Tooltip("勾选后,AssetPostprocessor 自动注册的新房间会优先加入此 Database;多个勾选时取 GUID 排序首个。")] private bool _isDefault; /// 所属全部房间(只读视图)。编辑器写入请通过 public MapRoomDataSO[] AllRooms => _allRooms; /// 是否被标记为默认 Database。 据此决定新建房间归属。 public bool IsDefault => _isDefault; private Dictionary _index; private Dictionary _cellToRoom; // 格子坐标 → 房间 ID 空间索引(共享给 MinimapHUD/MapPlayerTracker,避免重复构建) /// 运行时快速查找(首次调用时建立索引)。 public MapRoomDataSO GetRoom(string roomId) { if (_index == null) { if (AllRooms == null) return null; // R29-N2 使用 TryAdd(首条胜出),防止重复 RoomId 触发 ArgumentException; // 编辑器侧 ValidateAll 负责提示策划修复数据,运行时继续工作不崩溃。 _index = new Dictionary(); 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; } /// /// 在指定格子坐标处查询所属房间 ID(O(1) 哈希查找)。 /// 首次调用时惰性构建空间索引,由所有消费方(小地图/玩家追踪)共享,避免重复构建。 /// public string GetRoomIdAtCell(Vector2Int cell) { EnsureSpatialIndex(); return _cellToRoom != null && _cellToRoom.TryGetValue(cell, out var id) ? id : null; } private void EnsureSpatialIndex() { if (_cellToRoom != null) return; _cellToRoom = new Dictionary(); 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; } } /// 数据库变更时(编辑器热改 / 运行时热更)强制让索引下次访问时重建。 public void InvalidateIndex() { _index = null; _cellToRoom = null; } #if UNITY_EDITOR /// /// 编辑器专用:写入 数组并强制失效空间索引。 /// 替代直接赋值 public 字段,确保 / 测试代码不绕过封装。 /// 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()?.NotifyDatabaseChanged(); #endif } // ── 配置验证 ────────────────────────────────────────────────────────── #if UNITY_EDITOR /// /// 检查数据库中的常见配置错误(RoomId 重复、格子重叠、出口悬空)。 /// 编辑器侧调用;运行时不应调用(有 O(N²) 开销)。 /// 返回错误描述列表;空列表表示无错误。 /// public List ValidateAll() { var errors = new List(); if (AllRooms == null) return errors; // ① null / 空 RoomId for (int i = 0; i < AllRooms.Length; i++) { if (AllRooms[i] == null) { errors.Add($"AllRooms[{i}] 为 null"); continue; } if (string.IsNullOrEmpty(AllRooms[i].RoomId)) errors.Add($"AllRooms[{i}]({AllRooms[i].name})RoomId 为空"); 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 重复 var seenIds = new Dictionary(); foreach (var room in AllRooms) { if (room == null || string.IsNullOrEmpty(room.RoomId)) continue; if (seenIds.TryGetValue(room.RoomId, out var first)) errors.Add($"RoomId '{room.RoomId}' 重复({first} 与 {room.name})"); else seenIds[room.RoomId] = room.name; } // ③ 格子重叠 var cellOwner = new Dictionary(); foreach (var room in AllRooms) { if (room == null) continue; for (int x = 0; x < room.GridSize.x; x++) for (int y = 0; y < room.GridSize.y; y++) { var cell = new Vector2Int(room.GridPosition.x + x, room.GridPosition.y + y); if (cellOwner.TryGetValue(cell, out var other)) errors.Add($"'{room.RoomId}' 与 '{other}' 在格子 {cell} 重叠"); else cellOwner[cell] = room.RoomId; } } // ④ 出口目标不存在(单向验证) var validIds = new HashSet(seenIds.Keys); foreach (var room in AllRooms) { if (room?.Exits == null) continue; foreach (var exit in room.Exits) if (!string.IsNullOrEmpty(exit.TargetRoomId) && !validIds.Contains(exit.TargetRoomId)) errors.Add($"'{room.RoomId}' 出口指向不存在的房间 '{exit.TargetRoomId}'"); } return errors; } #endif } }