Files
zeling_v2/Docs/Architecture/15_MapShopModule.md
2026-05-08 11:04:00 +08:00

676 lines
24 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.
# 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<string, MapRoomDataSO> _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<string> _exploredRooms = new(); // 玩家踏入过
private HashSet<string> _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<string>(data.Map.ExploredRooms ?? new List<string>());
_mappedRooms = new HashSet<string>(data.Map.MappedRooms ?? new List<string>());
}
// ── 事件驱动房间发现 ─────────────────────────────────────────────
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);
}
/// <summary>标记为已完整获取地图信息(购买 MapFragment SO 触发)。</summary>
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<string, MapRoomCellUI> _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 世界单位
/// <summary>返回玩家当前所在房间 ID用于地图高亮当前房间。</summary>
public string CurrentRoomId { get; private set; }
/// <summary>玩家在当前格子房间内的归一化坐标0~1。</summary>
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
/// <summary>玩家在地图上放置的自定义标记。</summary>
[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<MapPin> _pins = new();
public IReadOnlyList<MapPin> 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<MapPin>();
}
```
---
## 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<ShopItemSO> DefaultInventory; // 初始商品列表
public int MaxDisplaySlots = 6; // UI 最多同时显示的商品格数
public RestockPolicy RestockPolicy = RestockPolicy.Never;
public Sprite KeeperPortrait;
public string KeeperName;
}
/// <summary>库存补货时机策略。</summary>
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 = itemIdvalue = 已购次数
private Dictionary<string, int> _purchaseCounts = new();
private HashSet<string> _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<ShopItemSO> GetAvailableItems()
{
return _inventory.DefaultInventory
.Take(_inventory.MaxDisplaySlots)
.Where(item =>
!_soldUniqueItems.Contains(item.ItemId) &&
(item.MaxPurchaseCount < 0 || GetPurchaseCount(item.ItemId) < item.MaxPurchaseCount))
.ToList();
}
/// <summary>
/// 按 RestockPolicy 补货:重置非唯一商品的购买次数(唯一商品已售出不恢复)。
/// </summary>
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<string, int>(_purchaseCounts);
}
public void OnLoad(SaveData data)
{
if (data.Shops.ShopRecords.TryGetValue(_inventory.ShopId, out var record))
{
_soldUniqueItems = new HashSet<string>(record.SoldUniqueItems ?? new List<string>());
_purchaseCounts = record.PurchaseCounts ?? new Dictionary<string, int>();
}
}
}
```
### 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` 即可,无需改运行时代码。