Files
zeling_v2/Docs/Design/16_MapSystem.md
2026-05-08 11:04:00 +08:00

489 lines
19 KiB
Markdown
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.
# 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<string> _exploredRooms = new(); // 已进入房间
readonly HashSet<string> _mappedRooms = new(); // 已存档房间(完整显示)
string _currentRoomId;
// 地图 UI 刷新事件MapUI 订阅)
public event Action<string> OnRoomRevealed; // roomId
public event Action<string> OnRoomMapped; // roomId
public event Action<string> 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<string, MapRoomDataSO> _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<MapRoomDataSO> 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 ← 图钉/图标 UIRectTransform 层)
│ ├── PlayerIcon ← 玩家当前位置Image跟随格子坐标
│ ├── BossIcons[] ← Boss 房间图标
│ ├── SaveIcons[] ← 存档点图标
│ └── PinIcons[] ← 玩家图钉
├── RegionLegend ← 右侧区域图例(颜色说明)
├── CurrentRoomLabel ← 左上角当前房间名
└── CloseHint ← "按 Tab/B 关闭"提示
```
### 地图交互
| 操作 | 键盘 | 手柄 | 效果 |
|------|------|------|------|
| 打开/关闭地图 | Tab | Select | 切换显示 |
| 移动视角 | WASD / 方向键 | 左摇杆 | 滚动 MapScrollView |
| 缩放 | 鼠标滚轮 / Z/X | L2/R2 | Scale MapContent0.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 — EditorWindowUI 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房间不会在地图中显示。`