# 16 · 地图系统 > **命名空间** `BaseGames.World.Map` > **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md) > **依赖** `BaseGames.Core.Events` · `BaseGames.World`(SaveSystem)· `BaseGames.UI` --- ## 目录 1. [系统总览](#1-系统总览) 2. [MapRoomDataSO — 房间元数据](#2-maproomdataso--房间元数据) 3. [MapManager — 探索状态管理](#3-mapmanager--探索状态管理) 4. [雾战渲染(Fog of War)](#4-雾战渲染fog-of-war) 5. [地图 UI — 全屏地图](#5-地图-ui--全屏地图) 6. [小地图 HUD](#6-小地图-hud) 7. [玩家位置追踪](#7-玩家位置追踪) 8. [地图图钉系统](#8-地图图钉系统) 9. [区域颜色与标记](#9-区域颜色与标记) 10. [SaveData 集成](#10-savedata-集成) 11. [场景搭建规范](#11-场景搭建规范) 12. [事件频道](#12-事件频道) 13. [编辑器友好设计](#13-编辑器友好设计) --- ## 1. 系统总览 地图系统是类银河恶魔城游戏的核心特征之一:玩家在黑暗中探索,地图随着进入新房间逐渐揭开。 ``` 地图系统职责: ├─ MapRoomDataSO → 每个房间的形状/出口/区域元数据(Edit Time 配置) ├─ MapManager → 运行时探索状态,驱动地图揭露(OnRoomEntered 监听) ├─ Fog of War → 未探索黑色,已进入轮廓,已存档实色——三级显示 ├─ 全屏地图 UI → 按 Map 键打开,可滚动/缩放 ├─ 小地图 HUD → HUD 角落实时显示当前区域缩略图 ├─ 玩家位置 → 随房间切换实时更新图标 └─ 地图图钉 → 玩家手动标记感兴趣位置 ``` **零耦合原则**:`MapManager` 仅监听 `OnRoomEntered` 事件频道,不持有 `PlayerController` 引用。 --- ## 2. MapRoomDataSO — 房间元数据 每个房间场景对应一个 `MapRoomDataSO` 资产,**Edit Time 配置**,不在运行时生成。 ```csharp [CreateAssetMenu(menuName = "World/Map/RoomData")] public class MapRoomDataSO : ScriptableObject { [Header("基础信息")] public string roomId; // 与场景名一致,如 "Room_Forest_01" public string displayName; // 可选,地图 Tooltip 显示 public string regionId; // 所属区域,如 "Forest" [Header("地图布局")] public Vector2Int gridPosition; // 地图格子坐标(手动填写,单位:格) public Vector2Int gridSize; // 房间占据的格子数,如 (2, 1) public Texture2D roomOutlineTex; // 房间轮廓贴图(Edit Tool 自动生成,见 §13) [Header("出口")] public RoomExitData[] exits; // 本房间所有出口(方向 + 目标房间 ID) [Header("特殊标记")] public bool isBossRoom; // Boss 房间(地图特殊图标) public bool isSavePoint; // 含存档点(地图存档图标) public bool isShop; // 含商店(地图商店图标) public Sprite mapIconOverride; // 自定义图标(如 None 则按 isXxx 自动选择) } [Serializable] public struct RoomExitData { public ExitDirection direction; // Left / Right / Up / Down public string targetRoomId; public Vector2Int exitGridPos; // 出口在格子中的相对位置(用于绘制连接线) } ``` **资产存放路径**:`Assets/ScriptableObjects/World/MapRooms/` **命名规范**:`MR_{SceneName}.asset`,如 `MR_Room_Forest_01.asset` --- ## 3. MapManager — 探索状态管理 `MapManager` 常驻 Persistent 场景,维护运行时探索状态并驱动地图 UI 刷新。 ```csharp namespace BaseGames.World.Map { public class MapManager : MonoBehaviour { [Header("配置")] [SerializeField] MapRoomDataSO[] _allRooms; // 所有房间 SO 数组 [SerializeField] MapDatabaseSO _mapDatabase; // 统一索引(见下) [Header("事件频道(监听)")] [SerializeField] StringEventChannelSO _onRoomEntered; // 监听:进入新房间(传入 roomId) [SerializeField] VoidEventChannelSO _onSavePointActivated; // 监听:存档点激活 [Header("事件频道(发布)")] [SerializeField] StringEventChannelSO _onRoomExploredForSave; // 发布:新房间已探索,SaveSystem 监听并写入 SaveData // 运行时状态(与 SaveData 同步) readonly HashSet _exploredRooms = new(); // 已进入房间 readonly HashSet _mappedRooms = new(); // 已存档房间(完整显示) string _currentRoomId; // 地图 UI 刷新事件(MapUI 订阅) public event Action OnRoomRevealed; // roomId public event Action OnRoomMapped; // roomId public event Action OnCurrentRoomChanged; void OnEnable() { _onRoomEntered.OnEventRaised += HandleRoomEntered; _onSavePointActivated.OnEventRaised += HandleSavePointActivated; } void OnDisable() { _onRoomEntered.OnEventRaised -= HandleRoomEntered; _onSavePointActivated.OnEventRaised -= HandleSavePointActivated; } void HandleRoomEntered(string roomId) { _currentRoomId = roomId; OnCurrentRoomChanged?.Invoke(roomId); if (_exploredRooms.Add(roomId)) // 首次进入 { OnRoomRevealed?.Invoke(roomId); // 触发地图揭露 // 零耦合:通过事件频道通知 SaveSystem 记录,而非直接调用 SaveManager.Instance _onRoomExploredForSave.Raise(roomId); } } void HandleSavePointActivated() { if (_currentRoomId == null) return; if (_mappedRooms.Add(_currentRoomId)) // 存档后升级为 Mapped OnRoomMapped?.Invoke(_currentRoomId); } // 启动时从 SaveData 恢复状态 public void Initialize(SaveData data) { _exploredRooms.UnionWith(data.world.discoveredRooms); _mappedRooms.UnionWith(data.world.mappedRooms); } // 查询接口(供 MapUI 使用) public MapRoomState GetRoomState(string roomId) { if (_mappedRooms.Contains(roomId)) return MapRoomState.Mapped; if (_exploredRooms.Contains(roomId)) return MapRoomState.Explored; return MapRoomState.Unknown; } public string CurrentRoomId => _currentRoomId; } public enum MapRoomState { Unknown, Explored, Mapped } } ``` ### MapDatabaseSO — 全局房间索引 ```csharp [CreateAssetMenu(menuName = "World/Map/Database")] public class MapDatabaseSO : ScriptableObject { [SerializeField] MapRoomDataSO[] _rooms; Dictionary _dict; public void BuildIndex() => _dict = _rooms.ToDictionary(r => r.roomId, r => r); public MapRoomDataSO GetRoom(string roomId) => _dict.TryGetValue(roomId, out var r) ? r : null; public IEnumerable GetRoomsByRegion(string regionId) => _rooms.Where(r => r.regionId == regionId); } ``` --- ## 4. 雾战渲染(Fog of War) 地图采用**三级可见度**区分探索程度: | 状态 | 触发条件 | 渲染效果 | |------|---------|---------| | `Unknown` | 从未进入 | 完全黑色(不显示轮廓)| | `Explored` | 已进入但未存档 | 显示房间轮廓线(半透明灰色,填充黑色)| | `Mapped` | 已进入且存档 | 完整显示(填充区域色,出口连接线)| ### 渲染实现 地图使用 **Unity UI Image + RenderTexture** 渲染,而非 World Space GameObject: ``` MapUI Canvas └── MapRawImage (RawImage,显示 _mapRenderTexture) └── 分辨率: 512×512(整个大地图) ``` `MapRenderer`(Editor Script + Runtime Renderer): 1. **Edit Time**:`MapEditorTool`(见 §13)根据 `MapRoomDataSO.roomOutlineTex` + `gridPosition` 将所有房间烘焙到一张 `MapAtlasTexture` 2. **Runtime**:`MapRenderer` 在 `MapAtlasTexture` 基础上,叠加 `FogLayer`(黑色遮罩 Texture);`MapManager` 揭露房间时,擦除对应格子的 `FogLayer`(填充透明) 3. 玩家位置图标通过 UI Image 覆盖在最上层,位置由格子坐标实时映射 ### 雾战擦除算法 ```csharp void RevealRoom(string roomId) { var data = _database.GetRoom(roomId); if (data == null) return; // 将格子坐标映射到贴图像素范围 int px = data.gridPosition.x * CELL_SIZE_PX; int py = data.gridPosition.y * CELL_SIZE_PX; int w = data.gridSize.x * CELL_SIZE_PX; int h = data.gridSize.y * CELL_SIZE_PX; // 按状态设置不同 Alpha float alpha = state == MapRoomState.Mapped ? 0f : 0.6f; // Mapped=全透明,Explored=半透 Color[] pixels = _fogTexture.GetPixels(px, py, w, h); for (int i = 0; i < pixels.Length; i++) pixels[i] = new Color(0, 0, 0, alpha); _fogTexture.SetPixels(px, py, w, h, pixels); _fogTexture.Apply(); } ``` 常量 `CELL_SIZE_PX = 32`(每个格子对应 32×32 像素)。 --- ## 5. 地图 UI — 全屏地图 按 **Map 键**(默认 Tab)打开全屏地图,`GameState` 不变(仍为 Gameplay),仅叠加地图 UI 层。 ### Canvas 结构 ``` Canvas_Map (Sorting Order: 25,叠加在 HUD 之上) └── MapPanel (全屏 Panel,默认隐藏) ├── MapScrollView ← ScrollRect,支持鼠标/摇杆滚动 │ └── MapContent │ ├── MapRawImage ← 地图渲染(RenderTexture) │ └── MapIconLayer ← 图钉/图标 UI(RectTransform 层) │ ├── PlayerIcon ← 玩家当前位置(Image,跟随格子坐标) │ ├── BossIcons[] ← Boss 房间图标 │ ├── SaveIcons[] ← 存档点图标 │ └── PinIcons[] ← 玩家图钉 ├── RegionLegend ← 右侧区域图例(颜色说明) ├── CurrentRoomLabel ← 左上角当前房间名 └── CloseHint ← "按 Tab/B 关闭"提示 ``` ### 地图交互 | 操作 | 键盘 | 手柄 | 效果 | |------|------|------|------| | 打开/关闭地图 | Tab | Select | 切换显示 | | 移动视角 | WASD / 方向键 | 左摇杆 | 滚动 MapScrollView | | 缩放 | 鼠标滚轮 / Z/X | L2/R2 | Scale MapContent(0.5× ~ 2×)| | 放置图钉 | F | Y(△)| 在当前光标位置放置图钉 | | 移除图钉 | F(在图钉上)| Y | 移除选中图钉 | | 快速居中(玩家)| R | R3 | ScrollView 居中到玩家图标 | --- ## 6. 小地图 HUD 小地图始终显示在 HUD 右上角(游戏中常驻),仅显示当前区域的缩略图: ``` Canvas_HUD └── MinimapPanel (右上角,固定位置) ├── MinimapMask (圆形 Mask,裁剪显示范围) │ └── MinimapImage (RawImage,与全屏地图共享 RenderTexture,但 ViewportRect 限制) └── MinimapBorder (装饰边框) ``` - 小地图使用与全屏地图**相同的 RenderTexture**,通过 UV Offset + Scale 仅显示当前房间周围 5×5 格 - 玩家在当前格子的移动通过 `MinimapImage.uvRect.position` 实时微调(平滑偏移) - 死亡/Boss 战时:小地图隐藏(`Canvas_HUD` 对应 GameObject 由 `UIManager` 控制) --- ## 7. 玩家位置追踪 `MapPlayerTracker` 维护玩家在地图中的像素坐标,供全屏地图和小地图使用: ```csharp public class MapPlayerTracker : MonoBehaviour { [SerializeField] MapManager _mapManager; [SerializeField] Transform _player; // 注入玩家 Transform(通过事件频道,不直接 Find) void OnEnable() => _onPlayerSpawned.OnEventRaised += t => _player = t; // 当前玩家在地图中的像素坐标(供 MapUI 设置 Image.rectTransform.anchoredPosition) public Vector2 GetPlayerMapPixelPos() { var room = _mapManager.Database.GetRoom(_mapManager.CurrentRoomId); if (room == null || _player == null) return Vector2.zero; // 房间格子左下角像素 + 玩家在房间内的相对位置(0~1)映射到像素范围 Vector2 roomOrigin = (Vector2)room.gridPosition * CELL_SIZE_PX; Vector2 roomSize = (Vector2)room.gridSize * CELL_SIZE_PX; // 玩家世界坐标 → 房间内归一化坐标(需要 RoomBoundsData 提供世界坐标范围) Vector2 normalized = GetNormalizedPosInRoom(_player.position, room); return roomOrigin + normalized * roomSize; } } ``` --- ## 8. 地图图钉系统 玩家可在地图任意已探索位置放置图钉,用于标记未完成的密室、待回来的通道等。 ### 图钉数据 ```csharp [Serializable] public class MapPin { public string roomId; // 所在房间 public Vector2 normalizedPos; // 在房间内的归一化坐标(0~1) public PinType type; // Exclamation / Question / Heart / Star public string note; // 玩家可选输入的备注文字(最多 20 字) } public enum PinType { Exclamation, Question, Heart, Star } ``` ### 图钉持久化 图钉数组存入 SaveData: ```json "mapPins": [ { "roomId": "Room_Cave_03", "normalizedPos": { "x": 0.7, "y": 0.5 }, "type": "Question", "note": "有密室待探索" } ] ``` ### 图钉 UI 预制件 ``` MapPin_Prefab (World Space UI,挂在 MapIconLayer) ├── PinIcon (Image,按 PinType 切换 Sprite) └── NoteTooltip (TextMeshPro,鼠标悬停时显示) ``` --- ## 9. 区域颜色与标记 每个区域在地图中使用不同颜色区分(颜色定义在 `RegionDefinitionSO.mapColor`): | 区域 | 颜色(参考)| 地图图标 | |------|-----------|---------| | Forest(扎根森林)| 绿色 `#4A7C3F` | 叶片图标 | | Cave(腐蚀洞穴)| 紫色 `#6A3FA0` | 骨骼图标 | | Ruins(坍塌废墟)| 棕色 `#8B6530` | 石柱图标 | | Abyss(深渊裂隙)| 深蓝 `#1A2A4A` | 深渊图标 | | Core(核心熔炉)| 红橙 `#C84B20` | 火焰图标 | **特殊房间标记**(覆盖在格子上的图标层): | 类型 | 图标 | 触发条件 | |------|------|---------| | Boss 房间 | 骷髅/特殊图标 | `MapRoomDataSO.isBossRoom = true` | | 存档点 | 长椅/火焰图标 | `MapRoomDataSO.isSavePoint = true` | | 商店 | 硬币图标 | `MapRoomDataSO.isShop = true` | | 传送点 | 旋涡图标 | 由 `QuickTravelManager` 解锁后标记 | --- ## 10. SaveData 集成 `SaveData.world` 新增字段(在 `08_WorldSystem.md §5` JSON Schema 基础上扩展): ```json "world": { "discoveredRooms": ["Room_Forest_01", "Room_Forest_02"], "mappedRooms": ["Room_Forest_01"], "mapPins": [ { "roomId": "Room_Cave_03", "normalizedPos": {"x":0.7,"y":0.5}, "type": "Question", "note": "密室" } ] } ``` | 字段 | 类型 | 说明 | |------|------|------| | `discoveredRooms` | `string[]` | 已进入的房间(轮廓显示)| | `mappedRooms` | `string[]` | 存档后升级的房间(完整显示)| | `mapPins` | `MapPin[]` | 玩家图钉列表 | --- ## 11. 场景搭建规范 每个房间场景添加 `RoomMapSetup` 组件,链接到对应 `MapRoomDataSO`: ``` Room_Forest_01 (Scene) └── [MapSetup] (GameObject) └── RoomMapSetup.cs └── _roomData: MapRoomDataSO ← 拖入 MR_Room_Forest_01.asset ``` `RoomMapSetup` 在 `Awake` 时通过事件频道向 `MapManager` 注册当前房间(实际是 `OnRoomEntered` 频道由 `RoomTransition` 触发,`RoomMapSetup` 不主动发布,只提供数据查询入口)。 --- ## 12. 事件频道 | 频道资产 | 类型 | 发布方 | 主要订阅方 | |---------|------|--------|----------| | `OnRoomEntered.asset` | `StringEventChannelSO`(roomId)| `RoomTransition` | `MapManager`、`CameraStateController` | | `OnMapOpened.asset` | `VoidEventChannelSO` | `InputReader` | `MapUI`(显示)、`InputReader`(切 ActionMap)| | `OnMapClosed.asset` | `VoidEventChannelSO` | `MapUI` | `InputReader`(恢复 ActionMap)| | `OnMapPinAdded.asset` | `MapPinEventChannelSO` | `MapUI` | `SaveManager` | | `OnMapPinRemoved.asset` | `StringEventChannelSO`(pinId)| `MapUI` | `SaveManager` | --- ## 13. 编辑器友好设计 ### MapEditorTool — EditorWindow(UI Toolkit) 路径:`Tools → Zeling → Map Editor` 实现:继承 `EditorWindow`,覆写 `CreateGUI()`,使用 `TwoPaneSplitView`(左侧房间列表 `ListView` + 右侧属性面板) + 自绘格子地图(`generateVisualContent` + `Painter2D`)。 功能: ``` ┌─ Map Editor ──────────────────────────────────────────────┐ │ [自动扫描所有 MapRoomDataSO] [全部重新生成轮廓贴图] │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ 地图预览(格子视图,颜色区分区域) │ │ │ │ │ │ │ │ [Forest] [Forest_01] [Forest_02] │ │ │ │ [Cave] [Cave_01] [Cave_02] [Cave_03] │ │ │ │ ... │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │ 选中 Room_Forest_01: │ │ gridPosition: (0, 0) gridSize: (2, 1) │ │ 区域: Forest 出口: [Right→Forest_02] │ │ [生成轮廓贴图] [定位场景] │ └────────────────────────────────────────────────────────────┘ ``` ### 轮廓贴图自动生成 `MapEditorTool` 对每个 `MapRoomDataSO` 自动生成 `roomOutlineTex`: 1. 打开对应场景(`EditorSceneManager.OpenScene`,仅读取) 2. 在场景 Tilemap 上执行 `Physics2D.OverlapAreaAll` 扫描地形轮廓 3. 将轮廓绘制到 32×n px 的贴图并保存为 `.png`(`AssetDatabase.CreateAsset`) 4. 自动赋值到 `MapRoomDataSO.roomOutlineTex` ### RoomMapSetup Inspector - Inspector 中显示**预览图**(对应 `MapRoomDataSO.roomOutlineTex`) - 一键按钮「在地图编辑器中定位」,打开 MapEditorTool 并滚动到此房间 - 若 `MapRoomDataSO` 未指定(null),显示红色警告:`⚠ 缺少 MapRoomDataSO!房间不会在地图中显示。`