489 lines
19 KiB
Markdown
489 lines
19 KiB
Markdown
# 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 ← 图钉/图标 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!房间不会在地图中显示。`
|