676 lines
24 KiB
Markdown
676 lines
24 KiB
Markdown
# 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 = 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
|
||
|
||
```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` 即可,无需改运行时代码。
|