Files
zeling_v2/Assets/_Game/Scripts/World/Map/MapRoomDataSO.cs

316 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
/// </summary>
[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 自动选择图标
/// <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" +
"在 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 字段迁移到 RoomFlagsRoomFlags 为 None 且旧字段有值时执行一次)
if (RoomFlags == RoomType.None)
{
if (IsBossRoom) RoomFlags |= RoomType.BossRoom;
if (IsSavePoint) RoomFlags |= RoomType.SavePoint;
if (IsShop) RoomFlags |= RoomType.Shop;
}
#if UNITY_EDITOR
// R11-N2 先 -= 再 +=,保证同一 delayCall 序列中最多执行一次,
// 防止 Inspector 快速拖动滑条时重复追加 N×FindAssets 导致卡顿
UnityEditor.EditorApplication.delayCall -= NotifyOwningDatabases;
UnityEditor.EditorApplication.delayCall += NotifyOwningDatabases;
#endif
}
#if UNITY_EDITOR
private void NotifyOwningDatabases()
{
if (this == null) return;
var guids = UnityEditor.AssetDatabase.FindAssets("t:MapDatabaseSO");
foreach (var guid in guids)
{
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
var db = UnityEditor.AssetDatabase.LoadAssetAtPath<MapDatabaseSO>(path);
if (db?.AllRooms == null) continue;
if (System.Array.IndexOf(db.AllRooms, this) < 0) continue;
db.InvalidateIndex();
// Play Mode 下同时广播事件,让 UI 立即重建
if (UnityEngine.Application.isPlaying)
BaseGames.Core.ServiceLocator.GetOrDefault<IMapService>()?.NotifyDatabaseChanged();
}
}
#endif
}
[Serializable]
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 }
// ─── 全局地图数据库 ──────────────────────────────────────────────────────────
/// <summary>
/// 全局地图数据库 SO编辑器配置一次架构 15_MapShopModule §1.1)。
/// 资产路径: Assets/_Game/Data/Map/MapDatabase.asset
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/World/Map/MapDatabase")]
public class MapDatabaseSO : ScriptableObject
{
[SerializeField, FormerlySerializedAs("AllRooms")]
private MapRoomDataSO[] _allRooms;
[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)
{
if (_index == null)
{
if (AllRooms == null) return null;
// 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 result);
return result;
}
/// <summary>
/// 在指定格子坐标处查询所属房间 IDO(1) 哈希查找)。
/// 首次调用时惰性构建空间索引,由所有消费方(小地图/玩家追踪)共享,避免重复构建。
/// </summary>
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<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
/// <summary>
/// 检查数据库中的常见配置错误RoomId 重复、格子重叠、出口悬空)。
/// 编辑器侧调用;运行时不应调用(有 O(N²) 开销)。
/// 返回错误描述列表;空列表表示无错误。
/// </summary>
public List<string> ValidateAll()
{
var errors = new List<string>();
if (AllRooms == null) return errors;
// ① null / 空 RoomId
for (int i = 0; i < AllRooms.Length; i++)
{
if (AllRooms[i] == null) { errors.Add($"AllRooms[{i}] 为 null"); continue; }
if (string.IsNullOrEmpty(AllRooms[i].RoomId))
errors.Add($"AllRooms[{i}]{AllRooms[i].name}RoomId 为空");
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<string, string>();
foreach (var room in AllRooms)
{
if (room == null || string.IsNullOrEmpty(room.RoomId)) continue;
if (seenIds.TryGetValue(room.RoomId, out var first))
errors.Add($"RoomId '{room.RoomId}' 重复({first} 与 {room.name}");
else
seenIds[room.RoomId] = room.name;
}
// ③ 格子重叠
var cellOwner = new Dictionary<Vector2Int, string>();
foreach (var room in AllRooms)
{
if (room == null) continue;
for (int x = 0; x < room.GridSize.x; x++)
for (int y = 0; y < room.GridSize.y; y++)
{
var cell = new Vector2Int(room.GridPosition.x + x, room.GridPosition.y + y);
if (cellOwner.TryGetValue(cell, out var other))
errors.Add($"'{room.RoomId}' 与 '{other}' 在格子 {cell} 重叠");
else
cellOwner[cell] = room.RoomId;
}
}
// ④ 出口目标不存在(单向验证)
var validIds = new HashSet<string>(seenIds.Keys);
foreach (var room in AllRooms)
{
if (room?.Exits == null) continue;
foreach (var exit in room.Exits)
if (!string.IsNullOrEmpty(exit.TargetRoomId) && !validIds.Contains(exit.TargetRoomId))
errors.Add($"'{room.RoomId}' 出口指向不存在的房间 '{exit.TargetRoomId}'");
}
return errors;
}
#endif
}
}