24 KiB
24 KiB
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.1 MapRoomDataSO
// 路径: 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
// 路径: 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
// 路径: 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
// 路径: 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 系统
// 路径: 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
// 路径: 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
// 路径: 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
// 路径: 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<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
// 路径: 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 中叠加可拖拽边界框,直接将世界坐标映射为格子坐标,消除手动对齐误差。
// 路径: 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
工作流:
- 在 Project 面板选中
MapRoomDataSO资产(或在 Inspector 固定它) - 打开 Scene View,即可看到蓝色房间边界框 + 黄色角点把手
- 拖动左下角把手调整房间起点;拖动右上角把手调整房间大小
- 所有拖动操作自动吸附到 1 格精度,并支持 Undo
- 点击 "居中 Scene View 到此房间" 可快速定位视图
CELL_SIZE配置:若地图格子实际对应世界坐标不是 1 单位(如 16px 像素游戏 = 0.16 世界单位),修改MapRoomDataEditor顶部const float CELL_SIZE即可,无需改运行时代码。