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

24 KiB
Raw Permalink Blame History

15 · 地图与商店模块

命名空间 BaseGames.World.MapBaseGames.World.Shop
程序集 BaseGames.World
路径 Assets/Scripts/World/Map/Assets/Scripts/World/Shop/
依赖 BaseGames.Core.EventsBaseGames.Core.SaveBaseGames.DialogueIInteractable


目录

  1. 地图系统
  2. 商店系统
  3. ISaveable 集成
  4. 事件频道清单

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 = 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

// 路径: 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 StringEventChannelSOshopId ShopController.Open() HUDController(隐藏 HUDInputReaderSO(切 UI
EVT_ShopClosed VoidEventChannelSO ShopController HUDController(恢复 HUD
EVT_ItemPurchased ShopPurchaseEventChannelSO ShopController PlayerStats(扣 GeoAchievementManager(购买成就)

5. MapRoomDataEditor — Scene Handles 可视化编辑

P3 优化MapRoomDataSOGridPosition / 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

工作流

  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 即可,无需改运行时代码。