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

19 KiB
Raw Permalink Blame History

16 · 地图系统

命名空间 BaseGames.World.Map
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.WorldSaveSystem· BaseGames.UI


目录

  1. 系统总览
  2. MapRoomDataSO — 房间元数据
  3. MapManager — 探索状态管理
  4. 雾战渲染Fog of War
  5. 地图 UI — 全屏地图
  6. 小地图 HUD
  7. 玩家位置追踪
  8. 地图图钉系统
  9. 区域颜色与标记
  10. SaveData 集成
  11. 场景搭建规范
  12. 事件频道
  13. 编辑器友好设计

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整个大地图

MapRendererEditor Script + Runtime Renderer

  1. Edit TimeMapEditorTool(见 §13根据 MapRoomDataSO.roomOutlineTex + gridPosition 将所有房间烘焙到一张 MapAtlasTexture
  2. RuntimeMapRendererMapAtlasTexture 基础上,叠加 FogLayer(黑色遮罩 TextureMapManager 揭露房间时,擦除对应格子的 FogLayer(填充透明)
  3. 玩家位置图标通过 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      ← 图钉/图标 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 维护玩家在地图中的像素坐标,供全屏地图和小地图使用:

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

RoomMapSetupAwake 时通过事件频道向 MapManager 注册当前房间(实际是 OnRoomEntered 频道由 RoomTransition 触发,RoomMapSetup 不主动发布,只提供数据查询入口)。


12. 事件频道

频道资产 类型 发布方 主要订阅方
OnRoomEntered.asset StringEventChannelSOroomId RoomTransition MapManagerCameraStateController
OnMapOpened.asset VoidEventChannelSO InputReader MapUI(显示)、InputReader(切 ActionMap
OnMapClosed.asset VoidEventChannelSO MapUI InputReader(恢复 ActionMap
OnMapPinAdded.asset MapPinEventChannelSO MapUI SaveManager
OnMapPinRemoved.asset StringEventChannelSOpinId 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 的贴图并保存为 .pngAssetDatabase.CreateAsset
  4. 自动赋值到 MapRoomDataSO.roomOutlineTex

RoomMapSetup Inspector

  • Inspector 中显示预览图(对应 MapRoomDataSO.roomOutlineTex
  • 一键按钮「在地图编辑器中定位」,打开 MapEditorTool 并滚动到此房间
  • MapRoomDataSO 未指定null显示红色警告⚠ 缺少 MapRoomDataSO房间不会在地图中显示。