# 15 · 地图与商店模块 > **命名空间** `BaseGames.World.Map`、`BaseGames.World.Shop` > **程序集** `BaseGames.World` > **路径** `Assets/Scripts/World/Map/`、`Assets/Scripts/World/Shop/` > **依赖** `BaseGames.Core.Events`、`BaseGames.Core.Save`、`BaseGames.Dialogue`(IInteractable) --- ## 目录 1. [地图系统](#1-地图系统) - [MapRoomDataSO](#11-maproomdataso) - [MapManager](#12-mapmanager) - [MapPanel(全屏地图 UI)](#13-mappanel) 2. [商店系统](#2-商店系统) - [ShopItemSO](#21-shopitemso) - [ShopInventorySO](#22-shopinventoryso) - [ShopController](#23-shopcontroller) - [ShopNPC](#24-shopnpc) 3. [ISaveable 集成](#3-isaveable-集成) 4. [事件频道清单](#4-事件频道清单) --- ## 1. 地图系统 ### 1.1 MapRoomDataSO ```csharp // 路径: Assets/Scripts/World/Map/MapRoomDataSO.cs [CreateAssetMenu(menuName = "World/Map/RoomData")] public class MapRoomDataSO : ScriptableObject { [Header("基础信息")] public string RoomId; // 与场景名一致,如 "Room_Forest_01" public string RegionId; // 所属区域,如 "Forest" public string DisplayName; // 可选,地图 Tooltip [Header("地图布局(格子坐标,单位:格)")] public Vector2Int GridPosition; // 左下角坐标 public Vector2Int GridSize; // 宽×高(格) [Header("房间轮廓纹理")] public Texture2D RoomOutlineTex; // 用于地图 UI 显示房间形状(可空,回退到矩形格子) [Header("出口信息")] public RoomExitData[] Exits; // 该房间所有出口定义 [Header("特殊标记")] public bool IsBossRoom; public bool IsSavePoint; public bool IsShop; public Sprite MapIconOverride; // null = 按 isXxx 自动选择图标 } [Serializable] public struct RoomExitData { public string TargetRoomId; // 连接的目标房间 ID public Vector2Int ExitGridPos; // 出口在格子地图上的位置 public ExitDirection Direction; // 出口方向 } public enum ExitDirection { Up, Down, Left, Right } // 全局地图数据库 SO(编辑器配置一次,不重复) [CreateAssetMenu(menuName = "World/Map/MapDatabase")] public class MapDatabaseSO : ScriptableObject { public MapRoomDataSO[] AllRooms; // 运行时快速查找 private Dictionary _index; public MapRoomDataSO GetRoom(string roomId) { if (_index == null) _index = AllRooms.ToDictionary(r => r.RoomId); _index.TryGetValue(roomId, out var r); return r; } } ``` ### 1.2 MapManager ```csharp // 路径: Assets/Scripts/World/Map/MapManager.cs [DefaultExecutionOrder(-700)] public class MapManager : MonoBehaviour, ISaveable { public static MapManager Instance { get; private set; } void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; } [SerializeField] private MapDatabaseSO _database; [Header("Event Channels")] [SerializeField] private StringEventChannelSO _onRoomEntered; // 订阅:房间进入时 [SerializeField] private StringEventChannelSO _onMapUpdated; // 发布:房间发现时刷新地图 // 三级可见性: // Unknown → 未进入过(默认) // Explored → 进入过但未购买地图(显示轮廓/格子) // Mapped → 已完整获取地图信息(显示图标/名称) private HashSet _exploredRooms = new(); // 玩家踏入过 private HashSet _mappedRooms = new(); // 完整地图信息(购买 MapFragment 或存档点揭示) // ── ISaveable ───────────────────────────────────────────────────── public void OnSave(SaveData data) { data.Map.ExploredRooms = _exploredRooms.ToList(); data.Map.MappedRooms = _mappedRooms.ToList(); } public void OnLoad(SaveData data) { _exploredRooms = new HashSet(data.Map.ExploredRooms ?? new List()); _mappedRooms = new HashSet(data.Map.MappedRooms ?? new List()); } // ── 事件驱动房间发现 ───────────────────────────────────────────── private void OnEnable() => _onRoomEntered.OnEventRaised += OnRoomEntered; private void OnDisable() => _onRoomEntered.OnEventRaised -= OnRoomEntered; private void OnRoomEntered(string roomId) { bool changed = _exploredRooms.Add(roomId); if (changed) _onMapUpdated.Raise(roomId); } /// 标记为已完整获取地图信息(购买 MapFragment SO 触发)。 public void SetMapped(string roomId) { _exploredRooms.Add(roomId); if (_mappedRooms.Add(roomId)) _onMapUpdated.Raise(roomId); } public bool IsExplored(string roomId) => _exploredRooms.Contains(roomId); public bool IsMapped(string roomId) => _mappedRooms.Contains(roomId); // 向后兼容:仅检查已探索 public bool IsDiscovered(string roomId) => _exploredRooms.Contains(roomId); } ``` ### 1.3 MapPanel ```csharp // 路径: Assets/Scripts/World/Map/MapPanel.cs // 全屏地图 UI,由 UIManager PanelStack 管理 public class MapPanel : MonoBehaviour { [SerializeField] private MapDatabaseSO _database; [SerializeField] private RectTransform _roomContainer; // 格子图放置根节点 [SerializeField] private MapRoomCellUI _cellPrefab; // 地图格子预制 [Header("图标 Sprites")] [SerializeField] private Sprite _iconSavePoint; [SerializeField] private Sprite _iconBossRoom; [SerializeField] private Sprite _iconShop; [SerializeField] private Sprite _iconPlayerPos; [Header("颜色")] [SerializeField] private Color _colorDiscovered = Color.white; [SerializeField] private Color _colorUndiscovered = Color.black; [Header("Event Channels")] [SerializeField] private StringEventChannelSO _onMapUpdated; // 房间发现时刷新 private Dictionary _cells = new(); private void OnEnable() { BuildGrid(); _onMapUpdated.OnEventRaised += OnMapUpdated; } private void OnDisable() => _onMapUpdated.OnEventRaised -= OnMapUpdated; // 根据 MapDatabaseSO 生成格子 UI private void BuildGrid() { foreach (var room in _database.AllRooms) { var cell = Instantiate(_cellPrefab, _roomContainer); cell.Setup(room, MapManager.Instance.IsDiscovered(room.RoomId)); _cells[room.RoomId] = cell; } } private void OnMapUpdated(string roomId) { if (_cells.TryGetValue(roomId, out var cell)) cell.SetDiscovered(true); } } // 单个地图格子 UI 组件 public class MapRoomCellUI : MonoBehaviour { [SerializeField] private Image _bg; [SerializeField] private Image _icon; public void Setup(MapRoomDataSO room, bool discovered) { /* 设置 grid 位置+颜色 */ } public void SetDiscovered(bool v) => _bg.color = v ? Color.white : Color.black; } ``` ### 1.4 MapPlayerTracker ```csharp // 路径: Assets/Scripts/World/Map/MapPlayerTracker.cs // 将玩家世界坐标转换为地图像素/格子坐标,供 MapPanel 显示玩家位置图标 public class MapPlayerTracker : MonoBehaviour { [SerializeField] private Transform _playerTransform; [SerializeField] private MapDatabaseSO _database; [SerializeField] private MapManager _mapManager; [Header("世界坐标 → 格子坐标换算参数")] [SerializeField] private float _worldUnitsPerCell = 18f; // 1 格 = N 世界单位 /// 返回玩家当前所在房间 ID(用于地图高亮当前房间)。 public string CurrentRoomId { get; private set; } /// 玩家在当前格子房间内的归一化坐标(0~1)。 public Vector2 NormalizedPositionInRoom { get; private set; } private void LateUpdate() { if (_playerTransform == null) return; Vector2 worldPos = _playerTransform.position; Vector2Int cellPos = WorldToCell(worldPos); // 遍历已知房间,找到包含该格子的房间 foreach (var room in _database.AllRooms) { var rect = new RectInt(room.GridPosition, room.GridSize); if (rect.Contains(cellPos)) { CurrentRoomId = room.RoomId; Vector2 inRoom = (Vector2)(cellPos - room.GridPosition); NormalizedPositionInRoom = new Vector2( inRoom.x / room.GridSize.x, inRoom.y / room.GridSize.y); return; } } } private Vector2Int WorldToCell(Vector2 worldPos) => new Vector2Int( Mathf.FloorToInt(worldPos.x / _worldUnitsPerCell), Mathf.FloorToInt(worldPos.y / _worldUnitsPerCell)); } ``` ### 1.5 MapPin 系统 ```csharp // 路径: Assets/Scripts/World/Map/MapPin.cs /// 玩家在地图上放置的自定义标记。 [Serializable] public class MapPin { public string RoomId; // 所在房间 ID public Vector2 NormalizedPos; // 房间内归一化位置(0~1) public PinType Type; public string Note; // 玩家文字备注(可选,最多 64 字符) } public enum PinType { Marker, // 通用标记 Chest, // 宝箱/收藏品 Enemy, // 危险/敌人 Path, // 路径指引 Note, // 笔记 } // MapPinManager 负责增删查;存档通过 ISaveable 持久化 public class MapPinManager : MonoBehaviour, ISaveable { private List _pins = new(); public IReadOnlyList Pins => _pins; public void AddPin(MapPin pin) => _pins.Add(pin); public void RemovePin(MapPin pin) => _pins.Remove(pin); public void OnSave(SaveData data) => data.Map.Pins = _pins; public void OnLoad(SaveData data) => _pins = data.Map.Pins ?? new List(); } ``` --- ## 2. 商店系统 ### 2.1 ShopItemSO ```csharp // 路径: Assets/Scripts/World/Shop/ShopItemSO.cs [CreateAssetMenu(menuName = "World/Shop/ShopItem")] public class ShopItemSO : ScriptableObject { [Header("标识")] public string ItemId; public string DisplayName; [TextArea(2, 5)] public string Description; public Sprite Icon; [Header("价格")] public int BasePrice; public bool IsUnique; // 购买一次后永久从库存移除 [Header("商品类型")] public ShopItemType ItemType; // 按 ItemType 填写以下字段(其余留空) public int HealthRestoreAmount; // HealthRestoration public CharmSO CharmReference; // CharmItem public string KeyItemId; // KeyItem public int MaxPurchaseCount = -1; // -1 = 无限 } public enum ShopItemType { HealthRestoration, CharmItem, KeyItem, ConsumableBuff, MapFragment, } ``` ### 2.2 ShopInventorySO ```csharp // 路径: Assets/Scripts/World/Shop/ShopInventorySO.cs [CreateAssetMenu(menuName = "World/Shop/ShopInventory")] public class ShopInventorySO : ScriptableObject { public string ShopId; // 全局唯一 public List DefaultInventory; // 初始商品列表 public int MaxDisplaySlots = 6; // UI 最多同时显示的商品格数 public RestockPolicy RestockPolicy = RestockPolicy.Never; public Sprite KeeperPortrait; public string KeeperName; } /// 库存补货时机策略。 public enum RestockPolicy { Never, // 永不补货(唯一商品卖完即消失) OnSavePoint, // 激活存档点时补货 OnBossDefeat, // 击败 Boss 后补货 Periodic, // 周期性补货(由 ShopController 定时或条件检查) } ``` ### 2.3 ShopController ```csharp // 路径: Assets/Scripts/World/Shop/ShopController.cs public class ShopController : MonoBehaviour, ISaveable { [SerializeField] private ShopInventorySO _inventory; [SerializeField] private ShopPanel _shopPanel; [Header("Event Channels")] [SerializeField] private StringEventChannelSO _onShopOpen; // Raise 商店开启 [SerializeField] private ShopPurchaseEventChannelSO _onItemPurchased; // Raise → PlayerStats 扣 Geo [SerializeField] private StringEventChannelSO _onBossDefeated; // 订阅 → 可能触发补货 [SerializeField] private VoidEventChannelSO _onSavePointActivated; // 订阅 → 可能触发补货 // key = itemId,value = 已购次数 private Dictionary _purchaseCounts = new(); private HashSet _soldUniqueItems = new(); private void OnEnable() { if (_inventory.RestockPolicy == RestockPolicy.OnBossDefeat && _onBossDefeated != null) _onBossDefeated.OnEventRaised += _ => Restock(); if (_inventory.RestockPolicy == RestockPolicy.OnSavePoint && _onSavePointActivated != null) _onSavePointActivated.OnEventRaised += Restock; } private void OnDisable() { if (_onBossDefeated != null) _onBossDefeated.OnEventRaised -= _ => Restock(); if (_onSavePointActivated != null) _onSavePointActivated.OnEventRaised -= Restock; } public void Open() { _shopPanel.Show(GetAvailableItems(), this); _onShopOpen.Raise(_inventory.ShopId); } public void Close() => _shopPanel.Hide(); public List GetAvailableItems() { return _inventory.DefaultInventory .Take(_inventory.MaxDisplaySlots) .Where(item => !_soldUniqueItems.Contains(item.ItemId) && (item.MaxPurchaseCount < 0 || GetPurchaseCount(item.ItemId) < item.MaxPurchaseCount)) .ToList(); } /// /// 按 RestockPolicy 补货:重置非唯一商品的购买次数(唯一商品已售出不恢复)。 /// public void Restock() { var nonUniqueIds = _inventory.DefaultInventory .Where(i => !i.IsUnique) .Select(i => i.ItemId); foreach (var id in nonUniqueIds) _purchaseCounts.Remove(id); } // 由 ShopPanel 的购买按钮调用 public bool TryPurchase(ShopItemSO item, int playerGeo) { if (playerGeo < item.BasePrice) return false; if (_soldUniqueItems.Contains(item.ItemId)) return false; // 扣 Geo(通过事件频道,PlayerStats 监听) _onItemPurchased.Raise(new ShopPurchaseEvent { Item = item, Price = item.BasePrice }); // 更新库存 _purchaseCounts[item.ItemId] = GetPurchaseCount(item.ItemId) + 1; if (item.IsUnique) _soldUniqueItems.Add(item.ItemId); return true; } private int GetPurchaseCount(string id) => _purchaseCounts.TryGetValue(id, out var c) ? c : 0; // ── ISaveable ──────────────────────────────────────────────────── public void OnSave(SaveData data) { if (!data.Shops.ShopRecords.ContainsKey(_inventory.ShopId)) data.Shops.ShopRecords[_inventory.ShopId] = new ShopRecord(); var record = data.Shops.ShopRecords[_inventory.ShopId]; record.SoldUniqueItems = _soldUniqueItems.ToList(); record.PurchaseCounts = new Dictionary(_purchaseCounts); } public void OnLoad(SaveData data) { if (data.Shops.ShopRecords.TryGetValue(_inventory.ShopId, out var record)) { _soldUniqueItems = new HashSet(record.SoldUniqueItems ?? new List()); _purchaseCounts = record.PurchaseCounts ?? new Dictionary(); } } } ``` ### 2.4 ShopNPC ```csharp // 路径: Assets/Scripts/World/Shop/ShopNPC.cs public class ShopNPC : MonoBehaviour, IInteractable { [SerializeField] private ShopController _shopController; [SerializeField] private DialogueSequenceSO _greetDialogue; // 可选开场白 [SerializeField] private DialogueManager _dialogueManager; [SerializeField] private VoidEventChannelSO _onDialogueEnded; // 订阅:对话结束后开商店 public bool CanInteract => true; public string InteractPrompt => "购物"; public void Interact(Transform player) { if (_greetDialogue != null) { _dialogueManager.StartDialogue(_greetDialogue); // 等对话结束后再 Open Shop:订阅 EVT_DialogueEnded 一次性 void OpenAfterDialogue() { _shopController.Open(); _onDialogueEnded.OnEventRaised -= OpenAfterDialogue; } _onDialogueEnded.OnEventRaised += OpenAfterDialogue; } else { _shopController.Open(); } } public void OnPlayerEnterRange(Transform player) { } public void OnPlayerExitRange() { } } ``` --- ## 3. ISaveable 集成 | 组件 | SaveData 目标字段 | |------|-----------------| | `MapManager` | `SaveData.Map.DiscoveredRooms` | | `ShopController` | `SaveData.ExtensionData["shops"]` (JObject, key=ShopId) | --- ## 4. 事件频道清单 | 资产名 | 类型 | Raise 方 | Subscribe 方 | |--------|------|---------|-------------| | `EVT_RoomEntered` | `StringEventChannelSO` | `RoomController` | `MapManager`(标记发现)| | `EVT_MapUpdated` | `StringEventChannelSO` | `MapManager` | `MapPanel`(刷新格子)| | `EVT_ShopOpened` | `StringEventChannelSO`(shopId) | `ShopController.Open()` | `HUDController`(隐藏 HUD)、`InputReaderSO`(切 UI)| | `EVT_ShopClosed` | `VoidEventChannelSO` | `ShopController` | `HUDController`(恢复 HUD)| | `EVT_ItemPurchased` | `ShopPurchaseEventChannelSO` | `ShopController` | `PlayerStats`(扣 Geo)、`AchievementManager`(购买成就)| --- ## 5. MapRoomDataEditor — Scene Handles 可视化编辑 > **P3 优化**:`MapRoomDataSO` 的 `GridPosition` / `GridSize` 依赖手动输入整数,易与实际场景房间大小错位。`MapRoomDataEditor` 在 Scene View 中叠加可拖拽边界框,直接将世界坐标映射为格子坐标,消除手动对齐误差。 ```csharp // 路径: Assets/Editor/Map/MapRoomDataEditor.cs #if UNITY_EDITOR using UnityEditor; using UnityEngine; namespace BaseGames.Editor.Map { [CustomEditor(typeof(MapRoomDataSO))] public class MapRoomDataEditor : UnityEditor.Editor { // 每格对应的世界单位大小(与 MapPanel 的 cellSize 一致) private const float CELL_SIZE = 1f; private static readonly Color FillColor = new(0.2f, 0.6f, 1f, 0.15f); private static readonly Color OutlineColor = new(0.2f, 0.6f, 1f, 0.9f); private static readonly Color HandleColor = new(1f, 0.85f, 0.2f, 1f); private MapRoomDataSO _target; private void OnEnable() => _target = (MapRoomDataSO)target; // ── Inspector 覆盖:保留默认 Inspector 外加 "Edit in Scene" 按钮 ── public override void OnInspectorGUI() { DrawDefaultInspector(); EditorGUILayout.Space(8); EditorGUILayout.HelpBox( "在 Scene View 中可直接拖拽房间角点调整 GridPosition / GridSize。\n" + "需要 Scene View 处于激活状态。", MessageType.Info); if (GUILayout.Button("居中 Scene View 到此房间", GUILayout.Height(28))) FocusSceneViewOnRoom(); } // ── Scene GUI:绘制可拖拽边界框 ────────────────────────────────── private void OnSceneGUI() { if (_target == null) return; var origin = (Vector3)(Vector2)_target.GridPosition * CELL_SIZE; var size = (Vector3)(Vector2)_target.GridSize * CELL_SIZE; // 填充矩形 Handles.DrawSolidRectangleWithOutline( new Rect(origin.x, origin.y, size.x, size.y), FillColor, OutlineColor); // 房间 ID 标签(居中) Handles.Label(origin + size * 0.5f, _target.RoomId, new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontStyle = FontStyle.Bold, normal = { textColor = Color.white } }); // ── 四角 FreeMoveHandle ────────────────────────────────────── EditorGUI.BeginChangeCheck(); // 左下角(= GridPosition) var newBL = DragHandle(origin, "BL"); // 右上角(= GridPosition + GridSize) var newTR = DragHandle(origin + size, "TR"); if (EditorGUI.EndChangeCheck()) { Undo.RecordObject(_target, "Resize MapRoom"); // 保证 BL ≤ TR var bl = new Vector2( Mathf.Min(newBL.x, newTR.x), Mathf.Min(newBL.y, newTR.y)); var tr = new Vector2( Mathf.Max(newBL.x, newTR.x), Mathf.Max(newBL.y, newTR.y)); _target.GridPosition = ToGrid(bl); _target.GridSize = ToGrid(tr) - _target.GridPosition; // GridSize 最小 1×1 _target.GridSize = new Vector2Int( Mathf.Max(1, _target.GridSize.x), Mathf.Max(1, _target.GridSize.y)); EditorUtility.SetDirty(_target); } } // ── 拖拽把手(黄色圆点)──────────────────────────────────────── private static Vector3 DragHandle(Vector3 pos, string id) { float size = HandleUtility.GetHandleSize(pos) * 0.12f; var oldColor = Handles.color; Handles.color = HandleColor; var result = Handles.FreeMoveHandle(pos, size, Vector3.zero, Handles.DotHandleCap); Handles.color = oldColor; return SnapToGrid(result); } // 吸附到格子 private static Vector3 SnapToGrid(Vector3 world) => new(Mathf.Round(world.x / CELL_SIZE) * CELL_SIZE, Mathf.Round(world.y / CELL_SIZE) * CELL_SIZE, 0f); private static Vector2Int ToGrid(Vector2 world) => new(Mathf.RoundToInt(world.x / CELL_SIZE), Mathf.RoundToInt(world.y / CELL_SIZE)); // ── Scene View 定位 ─────────────────────────────────────────── private void FocusSceneViewOnRoom() { var sv = SceneView.lastActiveSceneView; if (sv == null) return; var center = ((Vector2)_target.GridPosition + (Vector2)_target.GridSize * 0.5f) * CELL_SIZE; sv.pivot = new Vector3(center.x, center.y, 0f); sv.size = Mathf.Max(_target.GridSize.x, _target.GridSize.y) * CELL_SIZE * 1.5f; sv.Repaint(); } } } #endif ``` **工作流**: 1. 在 Project 面板选中 `MapRoomDataSO` 资产(或在 Inspector 固定它) 2. 打开 Scene View,即可看到蓝色房间边界框 + 黄色角点把手 3. 拖动左下角把手调整房间起点;拖动右上角把手调整房间大小 4. 所有拖动操作自动吸附到 1 格精度,并支持 Undo 5. 点击 **"居中 Scene View 到此房间"** 可快速定位视图 > **`CELL_SIZE` 配置**:若地图格子实际对应世界坐标不是 1 单位(如 16px 像素游戏 = 0.16 世界单位),修改 `MapRoomDataEditor` 顶部 `const float CELL_SIZE` 即可,无需改运行时代码。