19 KiB
16 · 地图系统
命名空间
BaseGames.World.Map
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Core.Events·BaseGames.World(SaveSystem)·BaseGames.UI
目录
- 系统总览
- MapRoomDataSO — 房间元数据
- MapManager — 探索状态管理
- 雾战渲染(Fog of War)
- 地图 UI — 全屏地图
- 小地图 HUD
- 玩家位置追踪
- 地图图钉系统
- 区域颜色与标记
- SaveData 集成
- 场景搭建规范
- 事件频道
- 编辑器友好设计
1. 系统总览
地图系统是类银河恶魔城游戏的核心特征之一:玩家在黑暗中探索,地图随着进入新房间逐渐揭开。
地图系统职责:
├─ MapRoomDataSO → 每个房间的形状/出口/区域元数据(Edit Time 配置)
├─ MapManager → 运行时探索状态,驱动地图揭露(OnRoomEntered 监听)
├─ Fog of War → 未探索黑色,已进入轮廓,已存档实色——三级显示
├─ 全屏地图 UI → 按 Map 键打开,可滚动/缩放
├─ 小地图 HUD → HUD 角落实时显示当前区域缩略图
├─ 玩家位置 → 随房间切换实时更新图标
└─ 地图图钉 → 玩家手动标记感兴趣位置
零耦合原则:MapManager 仅监听 OnRoomEntered 事件频道,不持有 PlayerController 引用。
2. MapRoomDataSO — 房间元数据
每个房间场景对应一个 MapRoomDataSO 资产,Edit Time 配置,不在运行时生成。
[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 刷新。
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 — 全局房间索引
[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):
- Edit Time:
MapEditorTool(见 §13)根据MapRoomDataSO.roomOutlineTex+gridPosition将所有房间烘焙到一张MapAtlasTexture - Runtime:
MapRenderer在MapAtlasTexture基础上,叠加FogLayer(黑色遮罩 Texture);MapManager揭露房间时,擦除对应格子的FogLayer(填充透明) - 玩家位置图标通过 UI Image 覆盖在最上层,位置由格子坐标实时映射
雾战擦除算法
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 维护玩家在地图中的像素坐标,供全屏地图和小地图使用:
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. 地图图钉系统
玩家可在地图任意已探索位置放置图钉,用于标记未完成的密室、待回来的通道等。
图钉数据
[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:
"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 基础上扩展):
"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:
- 打开对应场景(
EditorSceneManager.OpenScene,仅读取) - 在场景 Tilemap 上执行
Physics2D.OverlapAreaAll扫描地形轮廓 - 将轮廓绘制到 32×n px 的贴图并保存为
.png(AssetDatabase.CreateAsset) - 自动赋值到
MapRoomDataSO.roomOutlineTex
RoomMapSetup Inspector
- Inspector 中显示预览图(对应
MapRoomDataSO.roomOutlineTex) - 一键按钮「在地图编辑器中定位」,打开 MapEditorTool 并滚动到此房间
- 若
MapRoomDataSO未指定(null),显示红色警告:⚠ 缺少 MapRoomDataSO!房间不会在地图中显示。