Add independent review reports for Minimap system (Rounds 8, 9, and 26)
- Round 8 report highlights improvements in architecture, editor usability, and data robustness, with a total score of 80/100. - Round 9 report focuses on editor extension capabilities, identifying issues with room data indexing and layout editing, resulting in a score of 76/100. - Round 26 report evaluates the system against commercial standards, noting new issues and confirming previous fixes, with a score of 95.8/100.
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"BaseGames.Core",
|
||||
"BaseGames.Core.Save",
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Input",
|
||||
"BaseGames.Localization",
|
||||
"Unity.TextMeshPro"
|
||||
],
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
// 地图服务接口,通过 ServiceLocator 注册与查询。
|
||||
// MapManager 实现此接口;MapPanel 等调用方通过接口解耦。
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
public interface IMapService
|
||||
@@ -9,6 +12,13 @@ namespace BaseGames.World.Map
|
||||
bool IsExplored(string roomId);
|
||||
bool IsMapped(string roomId);
|
||||
void SetMapped(string roomId);
|
||||
|
||||
/// <summary>
|
||||
/// 批量解锁地图房间(地图碎片覆盖多间房间时调用)。
|
||||
/// 内部去重,对每个新增房间触发 <see cref="OnRoomMapped"/>,最后只广播一次 EVT_MapUpdated。
|
||||
/// </summary>
|
||||
void SetMappedBatch(IEnumerable<string> roomIds);
|
||||
|
||||
MapDatabaseSO Database { get; }
|
||||
|
||||
/// <summary>玩家当前所在区域 ID(最近一次 EVT_RegionChanged 对应的值)。</summary>
|
||||
@@ -22,5 +32,27 @@ namespace BaseGames.World.Map
|
||||
|
||||
/// <summary>返回属于指定区域的所有房间数据;regionId 为空时返回空数组。</summary>
|
||||
MapRoomDataSO[] GetRoomsByRegion(string regionId);
|
||||
|
||||
/// <summary>
|
||||
/// 当地图数据库结构发生变化(房间增删/编辑器热改/运行时热更)时触发。
|
||||
/// MapPanel、MinimapHUD 等 UI 应订阅此事件以执行完整重建。
|
||||
/// </summary>
|
||||
event Action OnDatabaseChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 探索进度变化事件(读档恢复 / 房间状态变化 / SetMapped 等)。
|
||||
/// 与 <see cref="OnDatabaseChanged"/> 区分:本事件不要求 UI 销毁重建结构,
|
||||
/// 仅需调用 RefreshAllCells/RebuildPins 等轻量刷新即可。
|
||||
/// </summary>
|
||||
event Action OnExplorationChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 某个房间首次被标记为 Mapped 时触发(参数为 roomId)。
|
||||
/// UI 可订阅做"地图碎片解锁"动画。批量 SetMappedBatch 时对每个新增房间各触发一次。
|
||||
/// </summary>
|
||||
event Action<string> OnRoomMapped;
|
||||
|
||||
/// <summary>主动通知所有订阅者数据库结构已变更(同时会让 Database 的空间索引失效)。</summary>
|
||||
void NotifyDatabaseChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,5 +24,12 @@ namespace BaseGames.World.Map
|
||||
|
||||
/// <summary>玩家进入新房间时触发(参数为新房间 ID)。</summary>
|
||||
event Action<string> OnRoomChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 将世界坐标转换为所属房间 ID 和房间内归一化坐标(0~1)。
|
||||
/// 用于在指定世界坐标处创建地图标记,无需手动计算格子坐标。
|
||||
/// 坐标不在任何已知房间内时返回 false。
|
||||
/// </summary>
|
||||
bool TryGetRoomAtWorldPos(Vector3 worldPos, out string roomId, out Vector2 normalizedPos);
|
||||
}
|
||||
}
|
||||
|
||||
44
Assets/_Game/Scripts/World/Map/ITeleportService.cs
Normal file
44
Assets/_Game/Scripts/World/Map/ITeleportService.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// 传送/快速旅行服务接口。
|
||||
/// 通过 ServiceLocator 注册;地图 UI 通过此接口触发传送,
|
||||
/// 不持有具体传送实现的引用,保持架构解耦。
|
||||
/// </summary>
|
||||
public interface ITeleportService
|
||||
{
|
||||
/// <summary>当前目标房间 ID 是否可传送(已解锁传送点且已探索)。</summary>
|
||||
bool CanTeleportTo(string roomId);
|
||||
|
||||
/// <summary>
|
||||
/// 请求传送到目标房间。
|
||||
/// 实现方负责播放淡出动画、切换场景、淡入等完整流程。
|
||||
/// 调用前会触发 <see cref="OnTeleportRequested"/>。
|
||||
/// </summary>
|
||||
void RequestTeleport(string targetRoomId);
|
||||
|
||||
/// <summary>
|
||||
/// 传送请求发出时触发(参数:源房间 ID,目标房间 ID)。
|
||||
/// 可用于 UI 播放关闭动画、记录日志等。
|
||||
/// </summary>
|
||||
event Action<string, string> OnTeleportRequested;
|
||||
|
||||
/// <summary>
|
||||
/// 传送完成(玩家已到达目标房间)时触发(参数:目标房间 ID)。
|
||||
/// 可用于 UI 播放进入动画、刷新小地图等。
|
||||
/// </summary>
|
||||
event Action<string> OnTeleportCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// 解锁指定房间的传送点(玩家首次激活传送站时由游戏触发器调用)。
|
||||
/// </summary>
|
||||
void UnlockTeleportStation(string roomId);
|
||||
|
||||
/// <summary>
|
||||
/// 场景加载系统在传送流程完成后调用,触发 <see cref="OnTeleportCompleted"/> 事件。
|
||||
/// </summary>
|
||||
void NotifyTeleportCompleted(string arrivedRoomId);
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/World/Map/ITeleportService.cs.meta
Normal file
11
Assets/_Game/Scripts/World/Map/ITeleportService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d61e0b1cfc586754488378eba7b89cb5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,6 +1,7 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
using BaseGames.Input;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
@@ -9,42 +10,90 @@ namespace BaseGames.World.Map
|
||||
/// 挂在与 MapPanel 相同的 GameObject 上(MapPanel OnEnable/OnDisable 联动启停)。
|
||||
/// <list type="bullet">
|
||||
/// <item>鼠标滚轮缩放(以鼠标位置为缩放中心)</item>
|
||||
/// <item>键盘方向键 / WASD 平移</item>
|
||||
/// <item>方向键 / 摇杆平移(经由 InputReaderSO.NavigateEvent 派发)</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(MapPanel))]
|
||||
public class MapInputHandler : MonoBehaviour, IScrollHandler
|
||||
{
|
||||
[SerializeField] private InputReaderSO _inputReader;
|
||||
[SerializeField] private ScrollRect _scrollRect;
|
||||
[SerializeField] private RectTransform _zoomTarget; // 通常为 _roomContainer(格子根节点)
|
||||
|
||||
[Header("缩放")]
|
||||
[SerializeField, Range(0.2f, 1f)] private float _zoomMin = 0.4f;
|
||||
[SerializeField, Range(1f, 5f)] private float _zoomMax = 3.0f;
|
||||
[SerializeField, Range(0.2f, 1f)] private float _zoomMin = 0.4f;
|
||||
[SerializeField, Range(1f, 5f)] private float _zoomMax = 3.0f;
|
||||
[SerializeField, Range(0.05f, 0.5f)] private float _zoomStep = 0.12f;
|
||||
|
||||
[Header("键盘平移")]
|
||||
[Header("平移")]
|
||||
[SerializeField] private float _keyPanSpeed = 600f; // px / 秒
|
||||
|
||||
private float _zoom = 1f;
|
||||
private Vector2 _navInput;
|
||||
private MapPanel _panel;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_panel = GetComponent<MapPanel>();
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
// R25-N2 _zoomTarget 须配置为 MapPanel 的格子根节点(与 _roomContainer 相同),
|
||||
// 否则 OnScroll 写入的 scale 与 MapPanel.CurrentZoom 读取的 scale 将来自不同节点,导致静默状态分裂。
|
||||
if (_zoomTarget == null)
|
||||
Debug.LogWarning("[MapInputHandler] _zoomTarget 未配置,缩放功能将失效。请在 Inspector 中指定 MapPanel 的格子根节点(_roomContainer)。", this);
|
||||
#endif
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// 重新激活时还原缩放,避免上次关闭时的残留状态
|
||||
if (_zoomTarget != null)
|
||||
_zoom = _zoomTarget.localScale.x;
|
||||
if (_inputReader != null)
|
||||
{
|
||||
_inputReader.NavigateEvent += OnNavigate;
|
||||
// R13-N4 居中到玩家快捷键
|
||||
_inputReader.MapCenterEvent += OnMapCenter;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_navInput = Vector2.zero;
|
||||
|
||||
if (_inputReader != null)
|
||||
{
|
||||
_inputReader.NavigateEvent -= OnNavigate;
|
||||
_inputReader.MapCenterEvent -= OnMapCenter;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnNavigate(Vector2 dir) => _navInput = dir;
|
||||
|
||||
// R13-N4 将视图居中到玩家所在房间
|
||||
private void OnMapCenter() => _panel?.CenterOnCurrentRoom();
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_scrollRect == null) return;
|
||||
if (_scrollRect == null || _navInput == Vector2.zero) return;
|
||||
|
||||
float h = Input.GetAxisRaw("Horizontal");
|
||||
float v = Input.GetAxisRaw("Vertical");
|
||||
if (h == 0 && v == 0) return;
|
||||
// R18-N1 contentSize 需乘以当前缩放系数,否则缩放后平移速度感知偏差。
|
||||
// R19-N1 / R24-N2 统一读 _panel.CurrentZoom(_zoomTarget.localScale.x),
|
||||
// 消除独立 _zoom 字段与 CurrentZoom 的双份状态——OnScroll 直接写 localScale,
|
||||
// CurrentZoom 即时反映最新值,不再需要额外同步。
|
||||
var content = _scrollRect.content;
|
||||
var viewport = _scrollRect.viewport != null
|
||||
? _scrollRect.viewport
|
||||
: (RectTransform)_scrollRect.transform;
|
||||
Vector2 contentSize = content.rect.size;
|
||||
Vector2 viewportSize = viewport.rect.size;
|
||||
float currentZoom = _panel != null ? _panel.CurrentZoom
|
||||
: (_zoomTarget != null ? _zoomTarget.localScale.x : 1f);
|
||||
float rangeX = contentSize.x * currentZoom - viewportSize.x;
|
||||
float rangeY = contentSize.y * currentZoom - viewportSize.y;
|
||||
|
||||
var delta = new Vector2(h, v) * (_keyPanSpeed * Time.unscaledDeltaTime);
|
||||
_scrollRect.content.anchoredPosition += delta;
|
||||
if (rangeX <= 0f && rangeY <= 0f) return; // 内容比视口小,无需平移
|
||||
|
||||
Vector2 delta = _navInput * (_keyPanSpeed * Time.unscaledDeltaTime);
|
||||
Vector2 norm = _scrollRect.normalizedPosition;
|
||||
if (rangeX > 0f) norm.x = Mathf.Clamp01(norm.x + delta.x / rangeX);
|
||||
if (rangeY > 0f) norm.y = Mathf.Clamp01(norm.y + delta.y / rangeY);
|
||||
_scrollRect.normalizedPosition = norm;
|
||||
}
|
||||
|
||||
// ── 鼠标滚轮缩放 ─────────────────────────────────────────────────────
|
||||
@@ -53,26 +102,26 @@ namespace BaseGames.World.Map
|
||||
{
|
||||
if (_zoomTarget == null) return;
|
||||
|
||||
// R24-N2 直接读 _zoomTarget.localScale.x 作为当前缩放,消除独立 _zoom 字段
|
||||
float currentZoom = _zoomTarget.localScale.x;
|
||||
float newZoom = Mathf.Clamp(
|
||||
_zoom + eventData.scrollDelta.y * _zoomStep,
|
||||
currentZoom + eventData.scrollDelta.y * _zoomStep,
|
||||
_zoomMin, _zoomMax);
|
||||
|
||||
if (Mathf.Approximately(newZoom, _zoom)) return;
|
||||
if (Mathf.Approximately(newZoom, currentZoom)) return;
|
||||
|
||||
// 将鼠标屏幕坐标转为 zoomTarget 本地坐标(缩放前)
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||||
_zoomTarget, eventData.position, eventData.pressEventCamera, out Vector2 pivotBefore);
|
||||
|
||||
_zoom = newZoom;
|
||||
_zoomTarget.localScale = new Vector3(_zoom, _zoom, 1f);
|
||||
_zoomTarget.localScale = new Vector3(newZoom, newZoom, 1f);
|
||||
|
||||
// 将同一屏幕点再次映射(缩放后),计算偏移量保持鼠标下方内容不动
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||||
_zoomTarget, eventData.position, eventData.pressEventCamera, out Vector2 pivotAfter);
|
||||
|
||||
// pivotAfter - pivotBefore 是 zoomTarget 本地空间的偏差,需转为父空间偏差
|
||||
Vector2 offset = pivotAfter - pivotBefore;
|
||||
_zoomTarget.anchoredPosition += offset * _zoom;
|
||||
_zoomTarget.anchoredPosition += offset * newZoom;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
@@ -19,7 +20,8 @@ namespace BaseGames.World.Map
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private StringEventChannelSO _onRoomEntered; // 订阅 EVT_RoomEntered
|
||||
[SerializeField] private StringEventChannelSO _onMapUpdated; // 发布:房间发现时
|
||||
[Tooltip("房间被探索/标记时广播(Explored/Mapped);地图 UI 已改用 C# 事件,此通道保留供地图外部系统订阅(如成就、音效)。")]
|
||||
[SerializeField] private StringEventChannelSO _onMapUpdated; // 发布:房间发现时(地图 UI 内当前无订阅者)
|
||||
[SerializeField] private StringEventChannelSO _onRegionChanged; // 发布:玩家首次进入新区域时(EVT_RegionChanged)
|
||||
|
||||
// 三级可见性:
|
||||
@@ -30,22 +32,26 @@ namespace BaseGames.World.Map
|
||||
private HashSet<string> _mappedRooms = new();
|
||||
private string _currentRegionId;
|
||||
private int _totalRoomCount = -1; // -1 = 未缓存;OnLoad 后重置
|
||||
private Dictionary<string, MapRoomDataSO[]> _regionCache; // R11-N6 GetRoomsByRegion 结果缓存
|
||||
private bool _isDuplicate; // Awake 检测到重复实例时置位,OnEnable/OnDisable 提前 return
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (ServiceLocator.GetOrDefault<IMapService>() != null) { Destroy(gameObject); return; }
|
||||
if (ServiceLocator.GetOrDefault<IMapService>() != null) { _isDuplicate = true; Destroy(gameObject); return; }
|
||||
ServiceLocator.Register<IMapService>(this);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_isDuplicate) return;
|
||||
_onRoomEntered?.Subscribe(OnRoomEntered).AddTo(_subs);
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_isDuplicate) return;
|
||||
_subs.Clear();
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
|
||||
}
|
||||
@@ -56,21 +62,31 @@ namespace BaseGames.World.Map
|
||||
{
|
||||
data.Map.ExploredRooms = new HashSet<string>(_exploredRooms);
|
||||
data.Map.MappedRooms = new HashSet<string>(_mappedRooms);
|
||||
data.Map.LastRegionId = _currentRegionId;
|
||||
}
|
||||
|
||||
public void OnLoad(SaveData data)
|
||||
{
|
||||
_exploredRooms = data.Map.ExploredRooms != null ? new HashSet<string>(data.Map.ExploredRooms) : new HashSet<string>();
|
||||
_mappedRooms = data.Map.MappedRooms != null ? new HashSet<string>(data.Map.MappedRooms) : new HashSet<string>();
|
||||
_totalRoomCount = -1; // 强制下次调用 GetExplorationProgress 时重新计数
|
||||
_exploredRooms = data.Map.ExploredRooms != null ? new HashSet<string>(data.Map.ExploredRooms) : new HashSet<string>();
|
||||
_mappedRooms = data.Map.MappedRooms != null ? new HashSet<string>(data.Map.MappedRooms) : new HashSet<string>();
|
||||
_currentRegionId = data.Map.LastRegionId; // 恢复区域 ID,避免读档后首次进房误触发 EVT_RegionChanged
|
||||
_totalRoomCount = -1; // 强制下次调用 GetExplorationProgress 时重新计数
|
||||
|
||||
// 读档后广播:UI 仅需轻量刷新(不重建结构);订阅 OnExplorationChanged 的 UI 会 RefreshAllCells
|
||||
OnExplorationChanged?.Invoke();
|
||||
}
|
||||
|
||||
// ── 事件驱动房间发现 ──────────────────────────────────────────────────
|
||||
|
||||
private void OnRoomEntered(string roomId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(roomId)) return;
|
||||
bool changed = _exploredRooms.Add(roomId);
|
||||
if (changed) _onMapUpdated?.Raise(roomId);
|
||||
if (changed)
|
||||
{
|
||||
_onMapUpdated?.Raise(roomId);
|
||||
OnExplorationChanged?.Invoke();
|
||||
}
|
||||
|
||||
// 区域变化检测:RegionId 非空且与上一次不同时广播 EVT_RegionChanged
|
||||
var regionId = _database?.GetRoom(roomId)?.RegionId;
|
||||
@@ -88,8 +104,31 @@ namespace BaseGames.World.Map
|
||||
/// </summary>
|
||||
public void SetMapped(string roomId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(roomId)) return;
|
||||
if (_mappedRooms.Add(roomId))
|
||||
{
|
||||
_onMapUpdated?.Raise(roomId);
|
||||
OnRoomMapped?.Invoke(roomId);
|
||||
OnExplorationChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SetMappedBatch(IEnumerable<string> roomIds)
|
||||
{
|
||||
if (roomIds == null) return;
|
||||
bool anyAdded = false;
|
||||
foreach (var roomId in roomIds)
|
||||
{
|
||||
if (string.IsNullOrEmpty(roomId)) continue;
|
||||
if (_mappedRooms.Add(roomId))
|
||||
{
|
||||
_onMapUpdated?.Raise(roomId);
|
||||
OnRoomMapped?.Invoke(roomId);
|
||||
anyAdded = true;
|
||||
}
|
||||
}
|
||||
if (anyAdded) OnExplorationChanged?.Invoke();
|
||||
}
|
||||
|
||||
// ── 查询 API ──────────────────────────────────────────────────────────
|
||||
@@ -112,11 +151,40 @@ namespace BaseGames.World.Map
|
||||
{
|
||||
if (string.IsNullOrEmpty(regionId) || _database?.AllRooms == null)
|
||||
return System.Array.Empty<MapRoomDataSO>();
|
||||
return _database.AllRooms.Where(r => r != null && r.RegionId == regionId).ToArray();
|
||||
|
||||
// R11-N6 懒加载缓存,避免每帧 LINQ 扫描全量房间数组
|
||||
_regionCache ??= new Dictionary<string, MapRoomDataSO[]>();
|
||||
if (!_regionCache.TryGetValue(regionId, out var cached))
|
||||
{
|
||||
cached = _database.AllRooms.Where(r => r != null && r.RegionId == regionId).ToArray();
|
||||
_regionCache[regionId] = cached;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
// ── 数据库热更事件 ────────────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action OnDatabaseChanged;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action OnExplorationChanged;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<string> OnRoomMapped;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void NotifyDatabaseChanged()
|
||||
{
|
||||
_database?.InvalidateIndex();
|
||||
_totalRoomCount = -1;
|
||||
_regionCache = null; // R11-N6 数据库变更时清空区域缓存
|
||||
OnDatabaseChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_isDuplicate) return;
|
||||
ServiceLocator.Unregister<IMapService>(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ namespace BaseGames.World.Map
|
||||
[SerializeField] private Sprite _iconBossRoom;
|
||||
[SerializeField] private Sprite _iconShop;
|
||||
[SerializeField] private Sprite _iconPlayerPos;
|
||||
[Tooltip("传送站图标;房间含 TeleportStation 标志时显示。")]
|
||||
[SerializeField] private Sprite _iconTeleport;
|
||||
|
||||
[Header("颜色")]
|
||||
[SerializeField] private Color _colorExplored = Color.white;
|
||||
@@ -40,79 +42,137 @@ namespace BaseGames.World.Map
|
||||
[SerializeField] private Image _playerIconImg; // _roomContainer 内的玩家图标
|
||||
|
||||
[Header("地图标记")]
|
||||
[SerializeField] private Image _pinPrefab;
|
||||
[SerializeField] private PinSpriteEntry[] _pinSprites; // PinType → 对应 Sprite(在 Inspector 中配置)
|
||||
[SerializeField] private Image _pinPrefab;
|
||||
[SerializeField] private MapPinConfigSO _pinConfig; // R12-N3 集中 PinType→Sprite 映射,替代旧的 PinSpriteEntry[]
|
||||
|
||||
[Header("房间解锁动画")]
|
||||
[SerializeField] private Color _revealFlashColor = Color.white; // R12-FC 新房间发现时的闪光颜色
|
||||
[SerializeField] private float _revealDuration = 0.4f; // R12-FC 淡出动画持续时间(秒)
|
||||
|
||||
[Header("Tooltip")]
|
||||
[SerializeField] private GameObject _tooltipPanel;
|
||||
[SerializeField] private TMP_Text _tooltipText;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private StringEventChannelSO _onMapUpdated; // 房间发现/标注时刷新
|
||||
[HideInInspector, SerializeField] private StringEventChannelSO _onMapUpdated; // 已废弃,仅保留序列化兼容性(R12-N8)
|
||||
|
||||
private Dictionary<string, MapRoomCellUI> _cells = new();
|
||||
private List<Image> _pinImages = new();
|
||||
private List<Image> _exitImages= new();
|
||||
private readonly Dictionary<string, MapRoomCellUI> _cells = new();
|
||||
private readonly List<Image> _pinImages = new();
|
||||
private readonly Stack<Image> _pinPool = new(); // R10-N3 Pin 对象池,回收而非销毁
|
||||
private readonly Stack<MapRoomCellUI> _cellPool = new(); // R12-N1 Cell 对象池,BuildGrid/OnDestroy 共用
|
||||
private readonly Stack<Image> _exitPool = new(); // R12-N1 Exit connector 对象池
|
||||
private readonly List<Image> _exitImages= new();
|
||||
private readonly Dictionary<string, Coroutine> _revealCoroutines = new(); // R19-N2 跟踪进行中的发现动画协程
|
||||
private string _highlightedRoomId;
|
||||
private string _lastIconRoomId; // LateUpdate 脏标记
|
||||
private Vector2 _lastIconNormPos; // LateUpdate 脏标记
|
||||
private int _lastPinVersion = -1;
|
||||
private bool _databaseDirty; // R10-N1 关闭期间收到 OnDatabaseChanged → 下次 OnEnable 触发重建
|
||||
private bool _explorationDirty; // R10-N12 关闭期间收到 OnExplorationChanged → 下次 OnEnable RefreshAllCells
|
||||
private bool _servicesReady; // R12-N7 三个服务全部就绪后置 true,短路 LateUpdate 的每帧查询
|
||||
|
||||
private IMapService _mapSvc;
|
||||
private IPlayerPositionProvider _playerProvider;
|
||||
private IPinService _pinService;
|
||||
private Dictionary<PinType, Sprite> _pinSpriteDict;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// 预构建 PinType → Sprite 字典,将 GetPinSprite 从 O(N) 降至 O(1)
|
||||
_pinSpriteDict = new Dictionary<PinType, Sprite>();
|
||||
if (_pinSprites != null)
|
||||
foreach (var e in _pinSprites)
|
||||
_pinSpriteDict[e.PinType] = e.Sprite;
|
||||
// R10-N1 服务订阅在 Awake/OnDestroy 长期持有:即便面板关闭也能感知数据库变更,
|
||||
// 设置 dirty 标志后由 OnEnable 触发重建,避免错过事件导致下次打开仍展示陈旧布局。
|
||||
SubscribeServices();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
|
||||
_playerProvider = ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
|
||||
_pinService = ServiceLocator.GetOrDefault<IPinService>();
|
||||
// 若服务在 Awake 时还未注册(启动顺序),此处补订阅
|
||||
SubscribeServices();
|
||||
|
||||
// 首次打开时建立格子;后续打开只刷新探索状态,跳过重复 Instantiate
|
||||
if (_cells.Count == 0)
|
||||
BuildGrid();
|
||||
else
|
||||
else if (_databaseDirty)
|
||||
RebuildAll();
|
||||
else if (_explorationDirty)
|
||||
RefreshAllCells();
|
||||
|
||||
_databaseDirty = _explorationDirty = false;
|
||||
|
||||
RenderPins();
|
||||
UpdatePlayerIcon();
|
||||
CenterOnCurrentRoom();
|
||||
_onMapUpdated?.Subscribe(OnMapUpdated).AddTo(_subs);
|
||||
// R12-N8:移除 _onMapUpdated 订阅,避免与 OnExplorationChanged 双重刷新;
|
||||
// _onMapUpdated 字段保留但标记 HideInInspector,防止旧 Prefab 数据丢失。
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
_mapSvc = null;
|
||||
_playerProvider = null;
|
||||
_pinService = null;
|
||||
_lastIconRoomId = null;
|
||||
_lastIconNormPos = Vector2.zero;
|
||||
HideTooltip();
|
||||
// R10-N1 保持 _mapSvc 等订阅引用,监听器在 Awake 已挂;不再置空
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
UnsubscribeServices();
|
||||
foreach (var cell in _cells.Values)
|
||||
if (cell != null) Destroy(cell.gameObject);
|
||||
_cells.Clear();
|
||||
ClearPins();
|
||||
foreach (var img in _pinPool)
|
||||
if (img != null) Destroy(img.gameObject);
|
||||
_pinPool.Clear();
|
||||
ClearExits();
|
||||
// R12-N1 销毁对象池中的格子和出口连接线
|
||||
foreach (var cell in _cellPool)
|
||||
if (cell != null) Destroy(cell.gameObject);
|
||||
_cellPool.Clear();
|
||||
foreach (var img in _exitPool)
|
||||
if (img != null) Destroy(img.gameObject);
|
||||
_exitPool.Clear();
|
||||
}
|
||||
|
||||
/// <summary>统一订阅服务的 OnDatabaseChanged / OnExplorationChanged / OnRoomMapped 事件。</summary>
|
||||
private void SubscribeServices()
|
||||
{
|
||||
if (_servicesReady) return; // R12-N7 三服务全部就绪后短路
|
||||
if (_mapSvc == null)
|
||||
{
|
||||
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
|
||||
if (_mapSvc != null)
|
||||
{
|
||||
_mapSvc.OnDatabaseChanged += OnDatabaseChanged;
|
||||
_mapSvc.OnExplorationChanged += OnExplorationChanged;
|
||||
_mapSvc.OnRoomMapped += OnRoomMappedAnim;
|
||||
}
|
||||
}
|
||||
_playerProvider ??= ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
|
||||
_pinService ??= ServiceLocator.GetOrDefault<IPinService>();
|
||||
if (_mapSvc != null && _playerProvider != null && _pinService != null)
|
||||
_servicesReady = true;
|
||||
}
|
||||
|
||||
private void UnsubscribeServices()
|
||||
{
|
||||
// 仅在 OnDestroy 调用,生命周期末尾服务引用不需要清空(与 MinimapHUD.UnsubscribeServices 的有意差异:
|
||||
// MinimapHUD 是持久 HUD,需支持跨场景销毁/重建后重连;MapPanel 由 UIManager 管理,
|
||||
// OnDestroy 后不再重用,服务引用随对象销毁自然回收)。
|
||||
if (_mapSvc != null)
|
||||
{
|
||||
_mapSvc.OnDatabaseChanged -= OnDatabaseChanged;
|
||||
_mapSvc.OnExplorationChanged -= OnExplorationChanged;
|
||||
_mapSvc.OnRoomMapped -= OnRoomMappedAnim;
|
||||
}
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
// R12-N7 服务懒加载:_servicesReady 置 true 后短路,消除每帧 ServiceLocator 查询
|
||||
if (!_servicesReady)
|
||||
SubscribeServices();
|
||||
|
||||
// Pin 增删响应:基于 PinsVersion 脏检查,版本未变化时 RenderPins 立即 return,无开销
|
||||
RenderPins();
|
||||
|
||||
if (_playerProvider == null || _playerIconImg == null) return;
|
||||
// 脏标记:位置/房间未变化时跳过 RectTransform 读写,消除无效每帧开销
|
||||
if (_playerProvider.CurrentRoomId == _lastIconRoomId &&
|
||||
@@ -122,6 +182,67 @@ namespace BaseGames.World.Map
|
||||
UpdatePlayerIcon();
|
||||
}
|
||||
|
||||
/// <summary>数据库结构变更:禁用状态置 dirty,启用状态立即重建。</summary>
|
||||
private void OnDatabaseChanged()
|
||||
{
|
||||
if (!isActiveAndEnabled) { _databaseDirty = true; return; }
|
||||
RebuildAll();
|
||||
}
|
||||
|
||||
/// <summary>R10-N12 探索进度变化:仅刷新格子可见性,不重建结构(轻量级)。</summary>
|
||||
private void OnExplorationChanged()
|
||||
{
|
||||
if (!isActiveAndEnabled) { _explorationDirty = true; return; }
|
||||
RefreshAllCells();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R12-FC 房间被标 Mapped 时播放发现动画(格子存在才播放)。
|
||||
/// R19-N2 先停止该房间的旧协程,防止 RebuildAll 把格子回收后协程继续写颜色。
|
||||
/// R20-N1 通过 RunRevealAnim 包装协程,动画完成后自动从 _revealCoroutines 移除,
|
||||
/// 消除已完成协程引用在字典中积累至下次 RebuildAll 的问题。
|
||||
/// </summary>
|
||||
protected virtual void OnRoomMappedAnim(string roomId)
|
||||
{
|
||||
if (!_cells.TryGetValue(roomId, out var cell) || cell == null) return;
|
||||
|
||||
if (_revealCoroutines.TryGetValue(roomId, out var old) && old != null)
|
||||
StopCoroutine(old);
|
||||
|
||||
_revealCoroutines[roomId] = StartCoroutine(RunRevealAnim(roomId, cell));
|
||||
}
|
||||
|
||||
private IEnumerator RunRevealAnim(string roomId, MapRoomCellUI cell)
|
||||
{
|
||||
yield return cell.PlayRevealAnim(_revealFlashColor, _revealDuration);
|
||||
_revealCoroutines.Remove(roomId); // R20-N1 完成后自清理,避免过期引用积累
|
||||
}
|
||||
|
||||
private void RebuildAll()
|
||||
{
|
||||
// R19-N2 在格子回收前停止所有进行中的发现动画协程,防止协程写入已入池的格子
|
||||
foreach (var c in _revealCoroutines.Values)
|
||||
if (c != null) StopCoroutine(c);
|
||||
_revealCoroutines.Clear();
|
||||
|
||||
foreach (var cell in _cells.Values)
|
||||
{
|
||||
if (cell == null) continue;
|
||||
// R12-N1 入池而非销毁
|
||||
cell.gameObject.SetActive(false);
|
||||
_cellPool.Push(cell);
|
||||
}
|
||||
_cells.Clear();
|
||||
ClearExits();
|
||||
ClearPins();
|
||||
_lastPinVersion = -1;
|
||||
_highlightedRoomId = null;
|
||||
BuildGrid();
|
||||
RenderPins();
|
||||
UpdatePlayerIcon();
|
||||
CenterOnCurrentRoom();
|
||||
}
|
||||
|
||||
// 面板重新打开时同步关闭期间积累的探索进度
|
||||
private void RefreshAllCells()
|
||||
{
|
||||
@@ -141,13 +262,28 @@ namespace BaseGames.World.Map
|
||||
foreach (var room in db.AllRooms)
|
||||
{
|
||||
if (room == null) continue;
|
||||
var cell = Instantiate(_cellPrefab, _roomContainer);
|
||||
// R12-N1 优先从对象池取格子,避免高频 Instantiate/Destroy
|
||||
MapRoomCellUI cell;
|
||||
if (_cellPool.Count > 0)
|
||||
{
|
||||
cell = _cellPool.Pop();
|
||||
cell.gameObject.SetActive(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
cell = Instantiate(_cellPrefab, _roomContainer);
|
||||
}
|
||||
cell.Setup(room, _mapSvc.GetVisibility(room.RoomId), ChooseIcon(room),
|
||||
ShowTooltip, HideTooltip);
|
||||
// R11-N8 布局单独调用 SetGridLayout,与 MinimapHUD.PlaceCell 职责对称
|
||||
cell.SetGridLayout(room, MapGridConstants.FullMapCellPixels);
|
||||
cell.SetColors(_colorExplored, _colorMapped, _colorUnknown);
|
||||
_cells[room.RoomId] = cell;
|
||||
}
|
||||
DrawExits();
|
||||
// R11-N4 格子布局改变后统一重建一次,CenterOnCurrentRoom 不再重复调用
|
||||
if (_scrollRect != null)
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate(_scrollRect.content);
|
||||
}
|
||||
|
||||
/// <summary>为每条出口在格子坐标处实例化一个小矩形连接线图像。</summary>
|
||||
@@ -161,10 +297,24 @@ namespace BaseGames.World.Map
|
||||
if (room?.Exits == null) continue;
|
||||
foreach (var exit in room.Exits)
|
||||
{
|
||||
var conn = Instantiate(_exitConnectorPrefab, _roomContainer);
|
||||
// R12-N1 优先从对象池取连接线
|
||||
Image conn;
|
||||
if (_exitPool.Count > 0)
|
||||
{
|
||||
conn = _exitPool.Pop();
|
||||
conn.gameObject.SetActive(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
conn = Instantiate(_exitConnectorPrefab, _roomContainer);
|
||||
}
|
||||
// R13-N1 检查 HasCustomExitPos;未配置时按出口方向计算房间边缘中点,避免落在 (0,0)
|
||||
Vector2Int gridPos = exit.HasCustomExitPos
|
||||
? exit.ExitGridPos
|
||||
: GetExitFallbackGridPos(room, exit);
|
||||
conn.rectTransform.anchoredPosition = new Vector2(
|
||||
exit.ExitGridPos.x * MapGridConstants.FullMapCellPixels,
|
||||
exit.ExitGridPos.y * MapGridConstants.FullMapCellPixels);
|
||||
gridPos.x * MapGridConstants.FullMapCellPixels,
|
||||
gridPos.y * MapGridConstants.FullMapCellPixels);
|
||||
bool vertical = exit.Direction == ExitDirection.Up || exit.Direction == ExitDirection.Down;
|
||||
conn.rectTransform.sizeDelta = vertical ? new Vector2(16f, 8f) : new Vector2(8f, 16f);
|
||||
_exitImages.Add(conn);
|
||||
@@ -174,16 +324,18 @@ namespace BaseGames.World.Map
|
||||
|
||||
private void ClearExits()
|
||||
{
|
||||
// R12-N1 禁用入池而非销毁
|
||||
foreach (var img in _exitImages)
|
||||
if (img != null) Destroy(img.gameObject);
|
||||
{
|
||||
if (img == null) continue;
|
||||
img.gameObject.SetActive(false);
|
||||
_exitPool.Push(img);
|
||||
}
|
||||
_exitImages.Clear();
|
||||
}
|
||||
|
||||
private void OnMapUpdated(string roomId)
|
||||
{
|
||||
if (_cells.TryGetValue(roomId, out var cell))
|
||||
cell.SetVisibility(_mapSvc.GetVisibility(roomId));
|
||||
}
|
||||
[Obsolete("R12-N8: 由 OnExplorationChanged 统一处理,此方法仅保留序列化兼容性,请勿新增调用。")]
|
||||
private void OnMapUpdated(string roomId) { /* R12-N8 已废弃:由 OnExplorationChanged 统一处理,此方法保留避免序列化引用问题 */ }
|
||||
|
||||
// ── 玩家位置图标 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -202,6 +354,8 @@ namespace BaseGames.World.Map
|
||||
_playerIconImg.rectTransform.anchoredPosition =
|
||||
cell.RT.anchoredPosition
|
||||
+ Vector2.Scale(_playerProvider.NormalizedPositionInRoom, cell.RT.sizeDelta);
|
||||
// 强制玩家图标渲染在所有格子/出口连线/Pin 之上,避免 Prefab 中层级配置错误导致被遮挡
|
||||
_playerIconImg.transform.SetAsLastSibling();
|
||||
UpdateCellHighlight(roomId);
|
||||
}
|
||||
|
||||
@@ -218,16 +372,22 @@ namespace BaseGames.World.Map
|
||||
next.SetHighlight(true);
|
||||
}
|
||||
|
||||
/// <summary>面板打开时将 ScrollRect 视口居中到玩家当前所在房间。</summary>
|
||||
private void CenterOnCurrentRoom()
|
||||
/// <summary>
|
||||
/// 当前地图缩放系数(从 _roomContainer.localScale.x 读取)。
|
||||
/// 供 MapInputHandler 使用以消除双份状态:MapInputHandler._zoom 写入 _roomContainer,
|
||||
/// CenterOnCurrentRoom 与 Update 均从此属性读取,保证两处始终一致。
|
||||
/// </summary>
|
||||
public float CurrentZoom => _roomContainer != null ? _roomContainer.localScale.x : 1f;
|
||||
|
||||
/// <summary>将 ScrollRect 视口居中到玩家当前所在房间。可由外部(如 MapInputHandler)调用。</summary>
|
||||
public void CenterOnCurrentRoom()
|
||||
{
|
||||
if (_scrollRect == null || _playerProvider == null) return;
|
||||
var roomId = _playerProvider.CurrentRoomId;
|
||||
if (string.IsNullOrEmpty(roomId) || !_cells.TryGetValue(roomId, out var cell)) return;
|
||||
|
||||
// 仅重建 ScrollRect.content 的布局,避免全 Canvas 树强制刷新
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate(_scrollRect.content);
|
||||
|
||||
// R11-N4 不再在此调用 ForceRebuildLayoutImmediate(已移至 BuildGrid 末尾);
|
||||
// 直接从 cell.RT 读取位置——格子由 Setup 手动定位,无需 LayoutGroup 重建。
|
||||
var content = _scrollRect.content;
|
||||
var viewport = _scrollRect.viewport != null
|
||||
? _scrollRect.viewport
|
||||
@@ -243,11 +403,14 @@ namespace BaseGames.World.Map
|
||||
|
||||
Vector2 viewSize = viewport.rect.size;
|
||||
Vector2 contentSize = content.rect.size;
|
||||
float rangeX = contentSize.x - viewSize.x;
|
||||
float rangeY = contentSize.y - viewSize.y;
|
||||
// R18-N1 / R19-N1 使用 CurrentZoom 属性(读取 _roomContainer.localScale.x);
|
||||
// 缩放后实际可滚动范围 = contentSize * zoom - viewSize,同步修正 cellX/cellY 的像素偏移。
|
||||
float zoom = CurrentZoom;
|
||||
float rangeX = contentSize.x * zoom - viewSize.x;
|
||||
float rangeY = contentSize.y * zoom - viewSize.y;
|
||||
|
||||
float normX = rangeX > 0 ? Mathf.Clamp01((cellX - viewSize.x * 0.5f) / rangeX) : 0.5f;
|
||||
float normY = rangeY > 0 ? Mathf.Clamp01((cellY - viewSize.y * 0.5f) / rangeY) : 0.5f;
|
||||
float normX = rangeX > 0 ? Mathf.Clamp01((cellX * zoom - viewSize.x * 0.5f) / rangeX) : 0.5f;
|
||||
float normY = rangeY > 0 ? Mathf.Clamp01((cellY * zoom - viewSize.y * 0.5f) / rangeY) : 0.5f;
|
||||
|
||||
_scrollRect.normalizedPosition = new Vector2(normX, normY);
|
||||
}
|
||||
@@ -259,7 +422,8 @@ namespace BaseGames.World.Map
|
||||
if (_pinService == null) return;
|
||||
|
||||
// 版本号脏检查:Pin 集合未变化时跳过重绘,避免无效 Instantiate
|
||||
if (_pinService.PinsVersion == _lastPinVersion && _pinImages.Count > 0) return;
|
||||
// 初始值 -1 保证首次 RenderPins 必然执行
|
||||
if (_pinService.PinsVersion == _lastPinVersion) return;
|
||||
_lastPinVersion = _pinService.PinsVersion;
|
||||
|
||||
ClearPins();
|
||||
@@ -267,7 +431,17 @@ namespace BaseGames.World.Map
|
||||
foreach (var pin in _pinService.Pins)
|
||||
{
|
||||
if (!_cells.TryGetValue(pin.RoomId, out var cell)) continue;
|
||||
var img = Instantiate(_pinPrefab, _roomContainer);
|
||||
// R10-N3 优先复用对象池中的 Pin Image,避免高频 Instantiate
|
||||
Image img;
|
||||
if (_pinPool.Count > 0)
|
||||
{
|
||||
img = _pinPool.Pop();
|
||||
img.gameObject.SetActive(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
img = Instantiate(_pinPrefab, _roomContainer);
|
||||
}
|
||||
img.sprite = GetPinSprite((PinType)pin.PinTypeInt);
|
||||
img.rectTransform.anchoredPosition =
|
||||
cell.RT.anchoredPosition + new Vector2(
|
||||
@@ -279,8 +453,13 @@ namespace BaseGames.World.Map
|
||||
|
||||
private void ClearPins()
|
||||
{
|
||||
// R10-N3 禁用入池而非销毁,减少 GC 与下次创建开销
|
||||
foreach (var img in _pinImages)
|
||||
if (img != null) Destroy(img.gameObject);
|
||||
{
|
||||
if (img == null) continue;
|
||||
img.gameObject.SetActive(false);
|
||||
_pinPool.Push(img);
|
||||
}
|
||||
_pinImages.Clear();
|
||||
}
|
||||
|
||||
@@ -298,15 +477,28 @@ namespace BaseGames.World.Map
|
||||
// ── 辅助方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
private Sprite ChooseIcon(MapRoomDataSO room)
|
||||
{
|
||||
if (room.MapIconOverride != null) return room.MapIconOverride;
|
||||
if (room.IsSavePoint) return _iconSavePoint;
|
||||
if (room.IsBossRoom) return _iconBossRoom;
|
||||
if (room.IsShop) return _iconShop;
|
||||
return null;
|
||||
}
|
||||
// R20-N2 委托到 MapRoomDataSO.ChooseDisplayIcon,消除与 MinimapHUD 的重复实现
|
||||
=> room.ChooseDisplayIcon(_iconSavePoint, _iconBossRoom, _iconShop, _iconTeleport);
|
||||
|
||||
private Sprite GetPinSprite(PinType type)
|
||||
=> _pinSpriteDict.TryGetValue(type, out var s) ? s : null;
|
||||
=> _pinConfig != null ? _pinConfig.GetSprite(type) : null;
|
||||
|
||||
/// <summary>
|
||||
/// R13-N1 当出口未配置自定义坐标时,按 ExitDirection 推算房间边缘中点。
|
||||
/// 避免 ExitGridPos 默认 (0,0) 导致所有连接线渲染到容器原点。
|
||||
/// </summary>
|
||||
private static Vector2Int GetExitFallbackGridPos(MapRoomDataSO room, RoomExitData exit)
|
||||
=> exit.Direction switch
|
||||
{
|
||||
ExitDirection.Up => new Vector2Int(room.GridPosition.x + room.GridSize.x / 2,
|
||||
room.GridPosition.y + room.GridSize.y),
|
||||
ExitDirection.Down => new Vector2Int(room.GridPosition.x + room.GridSize.x / 2,
|
||||
room.GridPosition.y),
|
||||
ExitDirection.Right => new Vector2Int(room.GridPosition.x + room.GridSize.x,
|
||||
room.GridPosition.y + room.GridSize.y / 2),
|
||||
ExitDirection.Left => new Vector2Int(room.GridPosition.x,
|
||||
room.GridPosition.y + room.GridSize.y / 2),
|
||||
_ => room.GridPosition + room.GridSize / 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// NOTE: 此文件包含 MapPinManager 类,但文件名为 MapPin.cs(历史遗留,Unity .meta 绑定限制不可安全重命名)。
|
||||
// 如需搜索,请搜索 "MapPinManager" 类名,而非文件名。
|
||||
// PinSpriteEntry 已迁移到 MapPinConfigSO.cs(R12-N3)。
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
@@ -7,14 +8,6 @@ using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
/// <summary>标记类型与显示精灵的映射表项(从 MapPanel 移入,与数据同文件管理)。</summary>
|
||||
[System.Serializable]
|
||||
public class PinSpriteEntry
|
||||
{
|
||||
public PinType PinType;
|
||||
public Sprite Sprite;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 地图自定义标记管理器(架构 15_MapShopModule §1.5)。
|
||||
/// 实现 <see cref="ISaveable"/> 和 <see cref="IPinService"/>,通过 ServiceLocator 对外暴露。
|
||||
@@ -24,6 +17,7 @@ namespace BaseGames.World.Map
|
||||
/// </para>
|
||||
/// MapPin/PinType 数据类定义在 SaveData.cs(BaseGames.Core.Save)中,避免循环依赖。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-500)] // 晚于 MapPlayerTracker(-600),早于默认 0,确保 IPinService 在 UI SubscribeServices 前已注册
|
||||
public class MapPinManager : MonoBehaviour, ISaveable, IPinService
|
||||
{
|
||||
private List<MapPin> _pins = new();
|
||||
@@ -33,15 +27,44 @@ namespace BaseGames.World.Map
|
||||
/// <summary>每次 Pin 集合发生变化时自增;外部消费方通过此版本号实现脏检查。</summary>
|
||||
public int PinsVersion { get; private set; }
|
||||
|
||||
private IMapService _mapSvc; // Start 中缓存,避免 CreatePin 每次调用 ServiceLocator
|
||||
|
||||
private bool _isDuplicate;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (ServiceLocator.GetOrDefault<IPinService>() != null)
|
||||
{
|
||||
_isDuplicate = true;
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
// 服务注册迁移到 Awake/OnDestroy(与 MapManager / MapPlayerTracker 对齐)
|
||||
// 避免 OnEnable/OnDisable 模式下反复 Register/Unregister 导致其他模块持有过期引用
|
||||
ServiceLocator.Register<IPinService>(this);
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_isDuplicate) return;
|
||||
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_isDuplicate) return;
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
|
||||
ServiceLocator.Register<IPinService>(this);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_isDuplicate) return;
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_isDuplicate) return;
|
||||
ServiceLocator.Unregister<IPinService>(this);
|
||||
}
|
||||
|
||||
@@ -57,15 +80,34 @@ namespace BaseGames.World.Map
|
||||
if (_pins.Remove(pin)) PinsVersion++;
|
||||
}
|
||||
|
||||
/// <summary>便捷方法:用枚举类型创建并添加标记。</summary>
|
||||
/// <summary>
|
||||
/// 便捷方法:用枚举类型创建并添加标记。
|
||||
/// <para>
|
||||
/// 参数会做安全校验:roomId 为空时返回 null;normX/normY 自动 Clamp01;
|
||||
/// note 限制 64 字符;可选检查 roomId 是否存在于数据库(不存在时 Warning,但仍允许创建以兼容运行时尚未加载的数据库场景)。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public MapPin CreatePin(string roomId, float normX, float normY,
|
||||
PinType type = PinType.Marker, string note = "")
|
||||
{
|
||||
if (string.IsNullOrEmpty(roomId))
|
||||
{
|
||||
Debug.LogWarning("[MapPinManager] CreatePin 失败:roomId 为空。");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 可选 RoomId 存在性验证:数据库已就绪时检查,未就绪时跳过(不阻塞)
|
||||
if (_mapSvc?.Database != null && _mapSvc.Database.GetRoom(roomId) == null)
|
||||
Debug.LogWarning($"[MapPinManager] CreatePin: roomId '{roomId}' 在数据库中不存在,标记将不会渲染。");
|
||||
|
||||
if (!string.IsNullOrEmpty(note) && note.Length > 64)
|
||||
note = note.Substring(0, 64);
|
||||
|
||||
var pin = new MapPin
|
||||
{
|
||||
RoomId = roomId,
|
||||
NormalizedPosX = normX,
|
||||
NormalizedPosY = normY,
|
||||
NormalizedPosX = Mathf.Clamp01(normX),
|
||||
NormalizedPosY = Mathf.Clamp01(normY),
|
||||
PinTypeInt = (int)type,
|
||||
Note = note,
|
||||
};
|
||||
@@ -75,11 +117,14 @@ namespace BaseGames.World.Map
|
||||
|
||||
// ── ISaveable ─────────────────────────────────────────────────────────
|
||||
|
||||
public void OnSave(SaveData data) => data.Map.Pins = _pins;
|
||||
// 拷贝 List 而非直接共享引用,避免序列化期间 AddPin/RemovePin 修改集合产生并发问题
|
||||
public void OnSave(SaveData data) => data.Map.Pins = new List<MapPin>(_pins);
|
||||
|
||||
public void OnLoad(SaveData data)
|
||||
{
|
||||
_pins = data.Map.Pins ?? new List<MapPin>();
|
||||
// R11-N3 防御性拷贝:避免与 SaveData.Map.Pins 共享同一引用,
|
||||
// 防止调用方后续修改 data 时污染 _pins(与 OnSave 的方向对称)
|
||||
_pins = data.Map.Pins != null ? new List<MapPin>(data.Map.Pins) : new List<MapPin>();
|
||||
PinsVersion++; // 加载存档后通知消费方重绘
|
||||
}
|
||||
}
|
||||
|
||||
53
Assets/_Game/Scripts/World/Map/MapPinConfigSO.cs
Normal file
53
Assets/_Game/Scripts/World/Map/MapPinConfigSO.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// 集中管理标记类型(PinType)与对应 Sprite 的映射配置。
|
||||
/// 替代原先分散在 MapPanel 和 MinimapHUD 中各自维护的 PinSpriteEntry 数组,
|
||||
/// 两个 UI 组件共享同一资产引用,保证标记外观统一且便于策划修改。
|
||||
/// <para>内部使用 <see cref="Dictionary{TKey,TValue}"/> 惰性缓存,GetSprite 为 O(1) 查找。</para>
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/World/Map/PinConfig")]
|
||||
public class MapPinConfigSO : ScriptableObject
|
||||
{
|
||||
[SerializeField] private PinSpriteEntry[] _entries;
|
||||
|
||||
private Dictionary<PinType, Sprite> _cache;
|
||||
|
||||
/// <summary>
|
||||
/// 根据 PinType 返回对应 Sprite(O(1) 哈希查找)。
|
||||
/// 首次调用时惰性构建内部字典;entries 中无匹配项时返回 null。
|
||||
/// </summary>
|
||||
public Sprite GetSprite(PinType type)
|
||||
{
|
||||
EnsureCache();
|
||||
return _cache.TryGetValue(type, out var s) ? s : null;
|
||||
}
|
||||
|
||||
private void EnsureCache()
|
||||
{
|
||||
if (_cache != null) return;
|
||||
_cache = new Dictionary<PinType, Sprite>();
|
||||
if (_entries == null) return;
|
||||
foreach (var e in _entries)
|
||||
_cache[e.PinType] = e.Sprite;
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
// 编辑器修改配置后立即重建缓存,确保 Play Mode 前数据已就绪
|
||||
_cache = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>标记类型与显示精灵的映射表项。</summary>
|
||||
[System.Serializable]
|
||||
public class PinSpriteEntry
|
||||
{
|
||||
public PinType PinType;
|
||||
public Sprite Sprite;
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/World/Map/MapPinConfigSO.cs.meta
Normal file
11
Assets/_Game/Scripts/World/Map/MapPinConfigSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e2f1b2a5e2ccfeb4d9389fcaa3276220
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
|
||||
@@ -9,12 +8,13 @@ namespace BaseGames.World.Map
|
||||
/// 将玩家世界坐标转换为地图格子坐标,供 MapPanel / MinimapHUD 显示玩家位置图标。
|
||||
/// 挂在 Player GameObject 上(LateUpdate 每帧计算)。
|
||||
/// <para>
|
||||
/// 性能:首次启动时建立 Dictionary<Vector2Int, string> 空间索引,
|
||||
/// 性能:通过 <see cref="MapDatabaseSO.GetRoomIdAtCell"/> 共享空间索引,
|
||||
/// LateUpdate 房间判定为 O(1) 哈希查找。归一化位置每帧从世界坐标精确插值,
|
||||
/// 确保图标跟随玩家平滑移动,而非以格子步长离散跳动。
|
||||
/// </para>
|
||||
/// 通过 <see cref="ServiceLocator"/> 注册为 <see cref="IPlayerPositionProvider"/>。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-600)] // 晚于 MapManager(-700),早于默认 0,确保 IPlayerPositionProvider 在 MinimapHUD.Awake 前已注册
|
||||
public class MapPlayerTracker : MonoBehaviour, IPlayerPositionProvider
|
||||
{
|
||||
[SerializeField] private Transform _playerTransform;
|
||||
@@ -22,7 +22,12 @@ namespace BaseGames.World.Map
|
||||
|
||||
[Header("世界坐标 → 格子坐标换算参数")]
|
||||
[Tooltip("1 格对应的世界单位数。请在关卡编辑器中测量房间实际尺寸后填入,确保与关卡设计对齐。")]
|
||||
[SerializeField] private float _worldUnitsPerCell = 18f;
|
||||
[SerializeField, Min(0.01f)] private float _worldUnitsPerCell = 18f;
|
||||
|
||||
[Tooltip("R11-N9 世界原点偏移量(世界单位)。\n" +
|
||||
"当关卡世界坐标原点不在格子地图 (0,0) 处时,填入偏移量以对齐格子坐标系。\n" +
|
||||
"例如:关卡第一个房间的世界左下角在 (-36, -18) 时,此处填 (-36, -18)。")]
|
||||
[SerializeField] private Vector2 _worldOriginOffset = Vector2.zero;
|
||||
|
||||
/// <summary>玩家当前所在房间 ID;未在任何已知房间内时为 null。</summary>
|
||||
public string CurrentRoomId { get; private set; }
|
||||
@@ -37,52 +42,41 @@ namespace BaseGames.World.Map
|
||||
public event Action<string> OnRoomChanged;
|
||||
|
||||
private MapDatabaseSO _database;
|
||||
private Dictionary<Vector2Int, string> _cellToRoomId;
|
||||
private MapRoomDataSO _currentRoom; // 当前房间数据缓存,避免 LateUpdate 每帧 GetRoom 查找
|
||||
private Vector2Int _lastCellPos = new Vector2Int(int.MinValue, int.MinValue);
|
||||
private bool _isDuplicate; // Awake 检测到重复实例时置位,Start/LateUpdate/OnDestroy 提前 return
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// 单例保护:同一时刻只允许一个 IPlayerPositionProvider 存在
|
||||
if (ServiceLocator.GetOrDefault<IPlayerPositionProvider>() != null) return;
|
||||
if (ServiceLocator.GetOrDefault<IPlayerPositionProvider>() != null)
|
||||
{
|
||||
_isDuplicate = true;
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
ServiceLocator.Register<IPlayerPositionProvider>(this);
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_isDuplicate) return;
|
||||
_database = _databaseOverride
|
||||
?? ServiceLocator.GetOrDefault<IMapService>()?.Database;
|
||||
BuildSpatialIndex();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_isDuplicate) return;
|
||||
// ServiceLocator.Unregister<T>(impl) 内部以 ReferenceEquals 守卫,
|
||||
// 仅当当前注册实例即本实例时才移除,避免多个 Tracker 并存时误清。
|
||||
ServiceLocator.Unregister<IPlayerPositionProvider>(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建格子坐标 → 房间 ID 的哈希映射,将 LateUpdate 的查询从 O(N) 降至 O(1)。
|
||||
/// 房间数据变化时(运行时热更)可再次调用重建索引。
|
||||
/// </summary>
|
||||
public void BuildSpatialIndex()
|
||||
{
|
||||
_cellToRoomId = new Dictionary<Vector2Int, string>();
|
||||
if (_database?.AllRooms == null) return;
|
||||
foreach (var room in _database.AllRooms)
|
||||
{
|
||||
if (room == null) continue;
|
||||
for (int x = 0; x < room.GridSize.x; x++)
|
||||
for (int y = 0; y < room.GridSize.y; y++)
|
||||
{
|
||||
var cell = new Vector2Int(room.GridPosition.x + x, room.GridPosition.y + y);
|
||||
_cellToRoomId[cell] = room.RoomId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (_playerTransform == null || _cellToRoomId == null) return;
|
||||
if (_isDuplicate) return;
|
||||
if (_playerTransform == null || _database == null) return;
|
||||
|
||||
Vector2Int cellPos = WorldToCell(_playerTransform.position);
|
||||
bool cellChanged = cellPos != _lastCellPos;
|
||||
@@ -91,7 +85,9 @@ namespace BaseGames.World.Map
|
||||
{
|
||||
_lastCellPos = cellPos;
|
||||
|
||||
if (!_cellToRoomId.TryGetValue(cellPos, out var newRoomId))
|
||||
// 通过 MapDatabaseSO 共享的空间索引查询(O(1)),避免本组件重复构建索引
|
||||
var newRoomId = _database.GetRoomIdAtCell(cellPos);
|
||||
if (string.IsNullOrEmpty(newRoomId))
|
||||
{
|
||||
// 玩家离开所有已知房间
|
||||
CurrentRoomId = null;
|
||||
@@ -111,9 +107,10 @@ namespace BaseGames.World.Map
|
||||
// 每帧从世界坐标精确计算归一化位置,实现平滑图标跟随
|
||||
if (_currentRoom != null)
|
||||
{
|
||||
// R11-N9 减去 _worldOriginOffset 将世界坐标对齐格子坐标系原点
|
||||
var worldMin = new Vector2(
|
||||
_currentRoom.GridPosition.x * _worldUnitsPerCell,
|
||||
_currentRoom.GridPosition.y * _worldUnitsPerCell);
|
||||
_currentRoom.GridPosition.x * _worldUnitsPerCell + _worldOriginOffset.x,
|
||||
_currentRoom.GridPosition.y * _worldUnitsPerCell + _worldOriginOffset.y);
|
||||
var worldSize = new Vector2(
|
||||
_currentRoom.GridSize.x * _worldUnitsPerCell,
|
||||
_currentRoom.GridSize.y * _worldUnitsPerCell);
|
||||
@@ -125,9 +122,43 @@ namespace BaseGames.World.Map
|
||||
}
|
||||
|
||||
private Vector2Int WorldToCell(Vector2 worldPos)
|
||||
=> new(
|
||||
Mathf.FloorToInt(worldPos.x / _worldUnitsPerCell),
|
||||
Mathf.FloorToInt(worldPos.y / _worldUnitsPerCell));
|
||||
{
|
||||
// R11-N9 减去 _worldOriginOffset 对齐格子坐标系,再除以单格世界尺寸
|
||||
var adjusted = worldPos - _worldOriginOffset;
|
||||
return new(
|
||||
Mathf.FloorToInt(adjusted.x / _worldUnitsPerCell),
|
||||
Mathf.FloorToInt(adjusted.y / _worldUnitsPerCell));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将世界坐标转换为所属房间 ID 和房间内归一化位置(0~1)。
|
||||
/// 实现 <see cref="IPlayerPositionProvider.TryGetRoomAtWorldPos"/>。
|
||||
/// </summary>
|
||||
public bool TryGetRoomAtWorldPos(Vector3 worldPos, out string roomId, out Vector2 normalizedPos)
|
||||
{
|
||||
roomId = null;
|
||||
normalizedPos = Vector2.zero;
|
||||
if (_database == null) return false;
|
||||
|
||||
var cellPos = WorldToCell(worldPos);
|
||||
roomId = _database.GetRoomIdAtCell(cellPos);
|
||||
if (string.IsNullOrEmpty(roomId)) return false;
|
||||
|
||||
var room = _database.GetRoom(roomId);
|
||||
if (room == null) return false;
|
||||
|
||||
var worldMin = new Vector2(
|
||||
room.GridPosition.x * _worldUnitsPerCell + _worldOriginOffset.x,
|
||||
room.GridPosition.y * _worldUnitsPerCell + _worldOriginOffset.y);
|
||||
var worldSize = new Vector2(
|
||||
room.GridSize.x * _worldUnitsPerCell,
|
||||
room.GridSize.y * _worldUnitsPerCell);
|
||||
var localPos = (Vector2)worldPos - worldMin;
|
||||
normalizedPos = new Vector2(
|
||||
Mathf.Clamp01(localPos.x / Mathf.Max(1f, worldSize.x)),
|
||||
Mathf.Clamp01(localPos.y / Mathf.Max(1f, worldSize.y)));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
138
Assets/_Game/Scripts/World/Map/MapProgressDisplay.cs
Normal file
138
Assets/_Game/Scripts/World/Map/MapProgressDisplay.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Serialization;
|
||||
using TMPro;
|
||||
using System.Collections.Generic;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// 探索进度百分比 UI 组件。
|
||||
/// 显示全局探索进度和当前区域探索进度,随 OnExplorationChanged 实时更新。
|
||||
/// <para>挂在 HUD 或地图面板下;需配置两个 TMP_Text 字段和可选的 StringEventChannelSO。</para>
|
||||
/// </summary>
|
||||
public class MapProgressDisplay : MonoBehaviour
|
||||
{
|
||||
[Header("文本组件")]
|
||||
[Tooltip("全局探索进度文本,例如 '已探索 42%'。留空则不更新。")]
|
||||
[SerializeField] private TMP_Text _globalProgressText;
|
||||
|
||||
[Tooltip("当前区域进度文本,例如 '区域:67%'。留空则不更新。")]
|
||||
[SerializeField] private TMP_Text _regionProgressText;
|
||||
|
||||
[Header("格式字符串({0:P0} = 百分比,{1} = 区域显示名)")]
|
||||
[SerializeField] private string _globalFormat = "已探索 {0:P0}";
|
||||
[SerializeField] private string _regionFormat = "{1}:{0:P0}";
|
||||
|
||||
[Header("区域名映射(与 RegionNameDisplay 共用同一机制)")]
|
||||
[Tooltip("RegionId → 本地化显示名映射。未配置时直接显示 RegionId。")]
|
||||
[FormerlySerializedAs("_regionNameEntries")]
|
||||
[SerializeField] private RegionNameEntry[] _regionNames;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[Tooltip("区域切换事件;订阅后区域切换时自动刷新区域进度。")]
|
||||
[SerializeField] private StringEventChannelSO _onRegionChanged;
|
||||
|
||||
private IMapService _mapSvc;
|
||||
private string _currentRegionId;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
// R15-N2 RegionId → Entry 字典,将 ResolveRegionDisplayName 从 O(N) 降至 O(1)
|
||||
private Dictionary<string, RegionNameEntry> _regionDict;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
BuildRegionDict();
|
||||
}
|
||||
|
||||
private void OnValidate() => BuildRegionDict();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
|
||||
if (_mapSvc != null)
|
||||
{
|
||||
_mapSvc.OnExplorationChanged += Refresh;
|
||||
_currentRegionId = _mapSvc.CurrentRegionId;
|
||||
}
|
||||
_onRegionChanged?.Subscribe(OnRegionChanged).AddTo(_subs);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_mapSvc != null)
|
||||
{
|
||||
_mapSvc.OnExplorationChanged -= Refresh;
|
||||
_mapSvc = null;
|
||||
}
|
||||
_subs.Clear();
|
||||
}
|
||||
|
||||
private void OnRegionChanged(string regionId)
|
||||
{
|
||||
_currentRegionId = regionId;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
/// <summary>立即刷新进度文本。可由外部调用(例如地图面板打开时)。</summary>
|
||||
public void Refresh()
|
||||
{
|
||||
if (_mapSvc == null)
|
||||
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
|
||||
if (_mapSvc == null) return;
|
||||
|
||||
// 全局探索进度
|
||||
if (_globalProgressText != null)
|
||||
{
|
||||
float progress = _mapSvc.GetExplorationProgress();
|
||||
try
|
||||
{
|
||||
_globalProgressText.text = string.Format(_globalFormat, progress);
|
||||
}
|
||||
catch (System.FormatException)
|
||||
{
|
||||
_globalProgressText.text = $"{progress:P0}";
|
||||
Debug.LogWarning($"[MapProgressDisplay] _globalFormat 格式字符串配置有误:'{_globalFormat}',已回退到默认显示。", this);
|
||||
}
|
||||
}
|
||||
|
||||
// 当前区域进度
|
||||
if (_regionProgressText != null && !string.IsNullOrEmpty(_currentRegionId))
|
||||
{
|
||||
var rooms = _mapSvc.GetRoomsByRegion(_currentRegionId);
|
||||
if (rooms != null && rooms.Length > 0)
|
||||
{
|
||||
int exploredCount = 0;
|
||||
foreach (var r in rooms)
|
||||
if (r != null && _mapSvc.IsExplored(r.RoomId)) exploredCount++;
|
||||
float regionProgress = (float)exploredCount / rooms.Length;
|
||||
// R15-N2 解析区域显示名(本地化 Key → DisplayName → RegionId 回退)
|
||||
string regionDisplayName = ResolveRegionDisplayName(_currentRegionId);
|
||||
try
|
||||
{
|
||||
_regionProgressText.text = string.Format(_regionFormat, regionProgress, regionDisplayName);
|
||||
}
|
||||
catch (System.FormatException)
|
||||
{
|
||||
_regionProgressText.text = $"{regionDisplayName}:{regionProgress:P0}";
|
||||
Debug.LogWarning($"[MapProgressDisplay] _regionFormat 格式字符串配置有误:'{_regionFormat}',已回退到默认显示。", this);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 辅助方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>预建 RegionId → Entry 字典,O(1) 查询。</summary>
|
||||
private void BuildRegionDict()
|
||||
=> _regionDict = MapServiceExtensions.BuildRegionDict(_regionNames);
|
||||
|
||||
/// <summary>
|
||||
/// 将 regionId 解析为玩家可读的显示名。
|
||||
/// 优先读 LocKey(本地化),其次 DisplayName,最后回退到 regionId 本身。
|
||||
/// </summary>
|
||||
private string ResolveRegionDisplayName(string regionId)
|
||||
=> MapServiceExtensions.ResolveRegionDisplayName(_regionDict, regionId);
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/World/Map/MapProgressDisplay.cs.meta
Normal file
11
Assets/_Game/Scripts/World/Map/MapProgressDisplay.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 204d99175f344594bb17c1e5a8e1ac3f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.EventSystems;
|
||||
@@ -20,6 +21,7 @@ namespace BaseGames.World.Map
|
||||
[SerializeField] private Image _icon;
|
||||
[SerializeField] private RawImage _outlineImage; // 可选:房间非矩形轮廓纹理
|
||||
[SerializeField] private Image _highlight; // 可选:当前房间高亮描边(玩家所在时激活)
|
||||
[SerializeField] private Image _fogOverlay; // 可选:未知房间雾效覆盖层(R12-FD)
|
||||
|
||||
// 实例颜色(默认值与原硬编码保持一致);可通过 SetColors 统一覆盖
|
||||
private Color _colExplored = Color.white;
|
||||
@@ -37,25 +39,17 @@ namespace BaseGames.World.Map
|
||||
private void Awake() => RT = GetComponent<RectTransform>();
|
||||
|
||||
/// <summary>
|
||||
/// 初始化格子(位置、可见性、图标、Tooltip 回调)。
|
||||
/// 初始化格子(可见性、图标、Tooltip 回调)。
|
||||
/// <para>R11-N8:不再在 Setup 中设置位置/尺寸,调用方按需调用
|
||||
/// <see cref="SetGridLayout"/> 或直接操作 <see cref="RT"/>。</para>
|
||||
/// </summary>
|
||||
/// <param name="pixelsPerCell">
|
||||
/// 每格像素数,默认 <see cref="MapGridConstants.FullMapCellPixels"/>(32f)。
|
||||
/// MinimapHUD 调用后会立即通过 <c>PlaceCell</c> 按自身比例覆盖位置/尺寸。
|
||||
/// </param>
|
||||
public void Setup(MapRoomDataSO room, RoomVisibility visibility, Sprite icon,
|
||||
Action<string> onHover = null, Action onHoverExit = null,
|
||||
float pixelsPerCell = MapGridConstants.FullMapCellPixels)
|
||||
Action<string> onHover = null, Action onHoverExit = null)
|
||||
{
|
||||
_displayName = room.DisplayName;
|
||||
_onHover = onHover;
|
||||
_onHoverExit = onHoverExit;
|
||||
|
||||
RT.anchoredPosition = new Vector2(room.GridPosition.x * pixelsPerCell,
|
||||
room.GridPosition.y * pixelsPerCell);
|
||||
RT.sizeDelta = new Vector2(room.GridSize.x * pixelsPerCell,
|
||||
room.GridSize.y * pixelsPerCell);
|
||||
|
||||
// 房间轮廓纹理(非矩形形状,覆盖在矩形背景上方)
|
||||
if (_outlineImage != null)
|
||||
{
|
||||
@@ -72,6 +66,19 @@ namespace BaseGames.World.Map
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R11-N8 独立布局接口:根据房间格子坐标与单格像素数设定位置和尺寸。
|
||||
/// <para>MapPanel.BuildGrid 与 MinimapHUD.PlaceCell 均通过此接口定位,
|
||||
/// 两者互不干扰,消除 Setup 中的重复赋值。</para>
|
||||
/// </summary>
|
||||
public void SetGridLayout(MapRoomDataSO room, float pixelsPerCell)
|
||||
{
|
||||
RT.anchoredPosition = new Vector2(room.GridPosition.x * pixelsPerCell,
|
||||
room.GridPosition.y * pixelsPerCell);
|
||||
RT.sizeDelta = new Vector2(room.GridSize.x * pixelsPerCell,
|
||||
room.GridSize.y * pixelsPerCell);
|
||||
}
|
||||
|
||||
/// <summary>覆盖此格子的三级可见性颜色(通常由 MapPanel / MinimapHUD 在创建后统一调用)。</summary>
|
||||
public void SetColors(Color explored, Color mapped, Color unknown)
|
||||
{
|
||||
@@ -91,6 +98,9 @@ namespace BaseGames.World.Map
|
||||
RoomVisibility.Mapped => _colMapped,
|
||||
_ => _colUnknown,
|
||||
};
|
||||
// R12-FD 雾效覆盖层:仅在完全未知时显示
|
||||
if (_fogOverlay != null)
|
||||
_fogOverlay.enabled = v == RoomVisibility.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>向后兼容:直接传 bool 时等同于 Explored / Unknown。</summary>
|
||||
@@ -103,6 +113,25 @@ namespace BaseGames.World.Map
|
||||
if (_highlight != null) _highlight.enabled = v;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新发现房间时播放闪白淡出动画(R12-FC)。
|
||||
/// 由 MapPanel.OnRoomMappedAnim 调用;协程安全:组件被销毁后 Unity 自动终止。
|
||||
/// </summary>
|
||||
public IEnumerator PlayRevealAnim(Color flashColor, float duration)
|
||||
{
|
||||
if (_bg == null) yield break;
|
||||
var original = _bg.color;
|
||||
_bg.color = flashColor;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
_bg.color = Color.Lerp(flashColor, original, elapsed / duration);
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
yield return null;
|
||||
}
|
||||
_bg.color = original;
|
||||
}
|
||||
|
||||
public void OnPointerEnter(PointerEventData _)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_displayName)) _onHover?.Invoke(_displayName);
|
||||
|
||||
@@ -2,10 +2,28 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Serialization;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// 房间功能类型标记(可多选)。
|
||||
/// 替代原先的 IsBossRoom / IsSavePoint / IsShop 三个独立 bool,
|
||||
/// 支持复合类型(如一个房间既是存档点也是商店),并便于扩展新类型。
|
||||
/// </summary>
|
||||
[System.Flags]
|
||||
public enum RoomType
|
||||
{
|
||||
None = 0,
|
||||
BossRoom = 1 << 0,
|
||||
SavePoint = 1 << 1,
|
||||
Shop = 1 << 2,
|
||||
Merchant = 1 << 3,
|
||||
Challenge = 1 << 4,
|
||||
TeleportStation = 1 << 5,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单个房间的地图数据 SO(架构 15_MapShopModule §1.1)。
|
||||
/// 资产路径: Assets/_Game/Data/Map/Rooms/Room_{RoomId}.asset
|
||||
@@ -28,11 +46,27 @@ namespace BaseGames.World.Map
|
||||
[Header("出口信息")]
|
||||
public RoomExitData[] Exits; // 该房间所有出口定义
|
||||
|
||||
[Header("特殊标记")]
|
||||
public bool IsBossRoom;
|
||||
public bool IsSavePoint;
|
||||
public bool IsShop;
|
||||
public Sprite MapIconOverride; // null = 按 isXxx 自动选择图标
|
||||
[Header("房间类型标记(可多选)")]
|
||||
public RoomType RoomFlags; // 支持多类型组合,替代旧的三个 bool 字段
|
||||
[HideInInspector] public bool IsBossRoom; // 旧字段,保留序列化兼容性;OnValidate 自动迁移到 RoomFlags
|
||||
[HideInInspector] public bool IsSavePoint; // 旧字段,保留序列化兼容性;OnValidate 自动迁移到 RoomFlags
|
||||
[HideInInspector] public bool IsShop; // 旧字段,保留序列化兼容性;OnValidate 自动迁移到 RoomFlags
|
||||
public Sprite MapIconOverride; // null = 按 RoomFlags 自动选择图标
|
||||
|
||||
/// <summary>
|
||||
/// R20-N2 集中图标优先级逻辑,替代 MapPanel / MinimapHUD 各自重复的 ChooseIcon 实现。
|
||||
/// 优先级:MapIconOverride > SavePoint > BossRoom > Shop > TeleportStation。
|
||||
/// 对应 Sprite 未配置时返回 null(格子不显示图标)。
|
||||
/// </summary>
|
||||
public Sprite ChooseDisplayIcon(Sprite savePoint, Sprite boss, Sprite shop, Sprite teleport)
|
||||
{
|
||||
if (MapIconOverride != null) return MapIconOverride;
|
||||
if (RoomFlags.HasFlag(RoomType.SavePoint) || IsSavePoint) return savePoint;
|
||||
if (RoomFlags.HasFlag(RoomType.BossRoom) || IsBossRoom) return boss;
|
||||
if (RoomFlags.HasFlag(RoomType.Shop) || IsShop) return shop;
|
||||
if (RoomFlags.HasFlag(RoomType.TeleportStation)) return teleport;
|
||||
return null;
|
||||
}
|
||||
|
||||
[Header("流式加载")]
|
||||
[Tooltip("此房间场景资产的预估内存(KB)。\n" +
|
||||
@@ -45,7 +79,46 @@ namespace BaseGames.World.Map
|
||||
{
|
||||
// 保证 GridSize 每轴最小为 1,防止零尺寸房间导致碰撞和渲染异常
|
||||
GridSize = new Vector2Int(Mathf.Max(1, GridSize.x), Mathf.Max(1, GridSize.y));
|
||||
|
||||
// R11-N11 自动修剪 RoomId 首尾空格,避免 " Room_A " 与 "Room_A" 被视为不同键
|
||||
if (!string.IsNullOrEmpty(RoomId) && RoomId != RoomId.Trim())
|
||||
RoomId = RoomId.Trim();
|
||||
|
||||
// R12-N9 将旧 bool 字段迁移到 RoomFlags(RoomFlags 为 None 且旧字段有值时执行一次)
|
||||
if (RoomFlags == RoomType.None)
|
||||
{
|
||||
if (IsBossRoom) RoomFlags |= RoomType.BossRoom;
|
||||
if (IsSavePoint) RoomFlags |= RoomType.SavePoint;
|
||||
if (IsShop) RoomFlags |= RoomType.Shop;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// R11-N2 先 -= 再 +=,保证同一 delayCall 序列中最多执行一次,
|
||||
// 防止 Inspector 快速拖动滑条时重复追加 N×FindAssets 导致卡顿
|
||||
UnityEditor.EditorApplication.delayCall -= NotifyOwningDatabases;
|
||||
UnityEditor.EditorApplication.delayCall += NotifyOwningDatabases;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void NotifyOwningDatabases()
|
||||
{
|
||||
if (this == null) return;
|
||||
var guids = UnityEditor.AssetDatabase.FindAssets("t:MapDatabaseSO");
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
|
||||
var db = UnityEditor.AssetDatabase.LoadAssetAtPath<MapDatabaseSO>(path);
|
||||
if (db?.AllRooms == null) continue;
|
||||
if (System.Array.IndexOf(db.AllRooms, this) < 0) continue;
|
||||
db.InvalidateIndex();
|
||||
|
||||
// Play Mode 下同时广播事件,让 UI 立即重建
|
||||
if (UnityEngine.Application.isPlaying)
|
||||
BaseGames.Core.ServiceLocator.GetOrDefault<IMapService>()?.NotifyDatabaseChanged();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@@ -59,6 +132,10 @@ namespace BaseGames.World.Map
|
||||
"Seamless:无缝切换(同区域相邻房间首选);\n" +
|
||||
"AtmosphericFade:短暂淡出 + 区域名提示(跨大区域边界首选)。")]
|
||||
public TransitionType PreferredTransitionType;
|
||||
|
||||
[Tooltip("是否已手动配置出口格子坐标。\n" +
|
||||
"未勾选时,连线回退到房间中心,避免 (0,0) 与合法原点坐标产生歧义。")]
|
||||
public bool HasCustomExitPos; // R12-N5 替代 ExitGridPos != Vector2Int.zero 哨兵用法
|
||||
}
|
||||
|
||||
public enum ExitDirection { Up, Down, Left, Right }
|
||||
@@ -72,9 +149,20 @@ namespace BaseGames.World.Map
|
||||
[CreateAssetMenu(menuName = "BaseGames/World/Map/MapDatabase")]
|
||||
public class MapDatabaseSO : ScriptableObject
|
||||
{
|
||||
public MapRoomDataSO[] AllRooms;
|
||||
[SerializeField, FormerlySerializedAs("AllRooms")]
|
||||
private MapRoomDataSO[] _allRooms;
|
||||
|
||||
private Dictionary<string, MapRoomDataSO> _index;
|
||||
[SerializeField, Tooltip("勾选后,AssetPostprocessor 自动注册的新房间会优先加入此 Database;多个勾选时取 GUID 排序首个。")]
|
||||
private bool _isDefault;
|
||||
|
||||
/// <summary>所属全部房间(只读视图)。编辑器写入请通过 <see cref="EditorSetRooms"/>。</summary>
|
||||
public MapRoomDataSO[] AllRooms => _allRooms;
|
||||
|
||||
/// <summary>是否被标记为默认 Database。<see cref="MapRoomAutoRegister"/> 据此决定新建房间归属。</summary>
|
||||
public bool IsDefault => _isDefault;
|
||||
|
||||
private Dictionary<string, MapRoomDataSO> _index;
|
||||
private Dictionary<Vector2Int, string> _cellToRoom; // 格子坐标 → 房间 ID 空间索引(共享给 MinimapHUD/MapPlayerTracker,避免重复构建)
|
||||
|
||||
/// <summary>运行时快速查找(首次调用时建立索引)。</summary>
|
||||
public MapRoomDataSO GetRoom(string roomId)
|
||||
@@ -82,16 +170,77 @@ namespace BaseGames.World.Map
|
||||
if (_index == null)
|
||||
{
|
||||
if (AllRooms == null) return null;
|
||||
_index = AllRooms.Where(r => r != null)
|
||||
.ToDictionary(r => r.RoomId);
|
||||
// R29-N2 使用 TryAdd(首条胜出),防止重复 RoomId 触发 ArgumentException;
|
||||
// 编辑器侧 ValidateAll 负责提示策划修复数据,运行时继续工作不崩溃。
|
||||
_index = new Dictionary<string, MapRoomDataSO>();
|
||||
foreach (var r in AllRooms)
|
||||
if (r != null && !string.IsNullOrEmpty(r.RoomId))
|
||||
_index.TryAdd(r.RoomId, r);
|
||||
}
|
||||
_index.TryGetValue(roomId, out var r);
|
||||
return r;
|
||||
}
|
||||
|
||||
private void OnDisable() => _index = null; // SO 卸载时清理缓存
|
||||
/// <summary>
|
||||
/// 在指定格子坐标处查询所属房间 ID(O(1) 哈希查找)。
|
||||
/// 首次调用时惰性构建空间索引,由所有消费方(小地图/玩家追踪)共享,避免重复构建。
|
||||
/// </summary>
|
||||
public string GetRoomIdAtCell(Vector2Int cell)
|
||||
{
|
||||
EnsureSpatialIndex();
|
||||
return _cellToRoom != null && _cellToRoom.TryGetValue(cell, out var id) ? id : null;
|
||||
}
|
||||
|
||||
private void OnValidate() => _index = null; // 编辑器中修改 AllRooms 后强制重建索引
|
||||
private void EnsureSpatialIndex()
|
||||
{
|
||||
if (_cellToRoom != null) return;
|
||||
_cellToRoom = new Dictionary<Vector2Int, string>();
|
||||
if (AllRooms == null) return;
|
||||
foreach (var room in AllRooms)
|
||||
{
|
||||
if (room == null) continue;
|
||||
for (int x = 0; x < room.GridSize.x; x++)
|
||||
for (int y = 0; y < room.GridSize.y; y++)
|
||||
_cellToRoom[new Vector2Int(room.GridPosition.x + x, room.GridPosition.y + y)] = room.RoomId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>数据库变更时(编辑器热改 / 运行时热更)强制让索引下次访问时重建。</summary>
|
||||
public void InvalidateIndex()
|
||||
{
|
||||
_index = null;
|
||||
_cellToRoom = null;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
/// <summary>
|
||||
/// 编辑器专用:写入 <see cref="AllRooms"/> 数组并强制失效空间索引。
|
||||
/// 替代直接赋值 public 字段,确保 <see cref="MapRoomAutoRegister"/> / 测试代码不绕过封装。
|
||||
/// </summary>
|
||||
public void EditorSetRooms(MapRoomDataSO[] rooms)
|
||||
{
|
||||
_allRooms = rooms;
|
||||
InvalidateIndex();
|
||||
}
|
||||
#endif
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
// SO 卸载时清理缓存
|
||||
_index = null;
|
||||
_cellToRoom = null;
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
// 编辑器中修改 AllRooms 后强制重建索引
|
||||
_index = null;
|
||||
_cellToRoom = null;
|
||||
#if UNITY_EDITOR
|
||||
if (UnityEngine.Application.isPlaying)
|
||||
BaseGames.Core.ServiceLocator.GetOrDefault<IMapService>()?.NotifyDatabaseChanged();
|
||||
#endif
|
||||
}
|
||||
|
||||
// ── 配置验证 ──────────────────────────────────────────────────────────
|
||||
#if UNITY_EDITOR
|
||||
@@ -111,6 +260,15 @@ namespace BaseGames.World.Map
|
||||
if (AllRooms[i] == null) { errors.Add($"AllRooms[{i}] 为 null"); continue; }
|
||||
if (string.IsNullOrEmpty(AllRooms[i].RoomId))
|
||||
errors.Add($"AllRooms[{i}]({AllRooms[i].name})RoomId 为空");
|
||||
else
|
||||
{
|
||||
// R11-N11 首尾空格检查(OnValidate 已自动 Trim,此处兜底提示未经 OnValidate 的旧资产)
|
||||
if (AllRooms[i].RoomId != AllRooms[i].RoomId.Trim())
|
||||
errors.Add($"'{AllRooms[i].name}' RoomId 含首尾空格,请在 Inspector 中保存触发自动修剪");
|
||||
// 特殊字符检查:/ \ | 等可能影响路径/键处理的字符
|
||||
if (AllRooms[i].RoomId.IndexOfAny(new[]{ '/', '\\', '|', '<', '>', '*', '?' }) >= 0)
|
||||
errors.Add($"'{AllRooms[i].RoomId}' 含非法字符(/ \\ | < > * ?),可能影响场景名匹配和存档键");
|
||||
}
|
||||
}
|
||||
|
||||
// ② RoomId 重复
|
||||
|
||||
@@ -3,9 +3,41 @@ namespace BaseGames.World.Map
|
||||
/// <summary>
|
||||
/// IMapService 无状态扩展方法,集中可复用的查询逻辑。
|
||||
/// MapPanel、MinimapHUD 等所有消费方均调用此处,避免分散的重复实现。
|
||||
/// <para>
|
||||
/// RegionNameEntry 字典构建与解析逻辑(BuildRegionDict / ResolveRegionDisplayName)
|
||||
/// 也集中在此,供 RegionNameDisplay 和 MapProgressDisplay 共享,消除 DRY 违反。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MapServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 将 RegionNameEntry 数组构建为 O(1) 查询字典。
|
||||
/// RegionNameDisplay / MapProgressDisplay 共享此实现,避免重复代码。
|
||||
/// </summary>
|
||||
public static System.Collections.Generic.Dictionary<string, RegionNameEntry> BuildRegionDict(
|
||||
RegionNameEntry[] entries)
|
||||
{
|
||||
var dict = new System.Collections.Generic.Dictionary<string, RegionNameEntry>();
|
||||
if (entries == null) return dict;
|
||||
foreach (var e in entries)
|
||||
if (!string.IsNullOrEmpty(e.RegionId))
|
||||
dict[e.RegionId] = e;
|
||||
return dict;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 regionId 解析为玩家可读的显示名。
|
||||
/// 优先读 LocKey,其次 DisplayName,最后回退到 regionId 本身。
|
||||
/// </summary>
|
||||
public static string ResolveRegionDisplayName(
|
||||
System.Collections.Generic.Dictionary<string, RegionNameEntry> dict,
|
||||
string regionId)
|
||||
{
|
||||
if (dict != null && dict.TryGetValue(regionId, out var e))
|
||||
return e.GetDisplayName();
|
||||
return regionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据探索状态推导房间三级可见性(Explored > Mapped > Unknown)。
|
||||
/// </summary>
|
||||
@@ -16,5 +48,22 @@ namespace BaseGames.World.Map
|
||||
if (svc.IsMapped(roomId)) return RoomVisibility.Mapped;
|
||||
return RoomVisibility.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在指定世界坐标处创建地图标记。
|
||||
/// 通过 <see cref="IPlayerPositionProvider.TryGetRoomAtWorldPos"/> 将世界坐标转换为
|
||||
/// 房间 ID 和归一化位置;坐标不在任何已知房间内时返回 null。
|
||||
/// </summary>
|
||||
public static BaseGames.Core.Save.MapPin CreatePinAtWorldPos(
|
||||
this IPinService pinSvc,
|
||||
IPlayerPositionProvider playerProvider,
|
||||
UnityEngine.Vector3 worldPos,
|
||||
BaseGames.Core.Save.PinType type = BaseGames.Core.Save.PinType.Marker,
|
||||
string note = "")
|
||||
{
|
||||
if (pinSvc == null || playerProvider == null) return null;
|
||||
if (!playerProvider.TryGetRoomAtWorldPos(worldPos, out var roomId, out var normPos)) return null;
|
||||
return pinSvc.CreatePin(roomId, normPos.x, normPos.y, type, note);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
@@ -32,20 +33,44 @@ namespace BaseGames.World.Map
|
||||
[SerializeField] private Color _colorUnknown = Color.black;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private StringEventChannelSO _onMapUpdated; // 房间发现/标注时局部刷新
|
||||
// R18-N3 _onMapUpdated 订阅已废弃(对齐 MapPanel R12-N8):
|
||||
// OnExplorationChanged 全量刷新完全覆盖 OnMapUpdated 的单格更新,订阅形成冗余双重刷新。
|
||||
// 保留 [HideInInspector, SerializeField] 维持 Prefab 序列化兼容,不再订阅事件。
|
||||
[HideInInspector, SerializeField] private StringEventChannelSO _onMapUpdated;
|
||||
|
||||
[Header("地图标记(可选)")]
|
||||
[SerializeField] private Image _pinPrefab; // 留空则不渲染 Pin
|
||||
[SerializeField] private MapPinConfigSO _pinConfig; // R12-N3 集中 PinType→Sprite 映射
|
||||
|
||||
[Header("房间类型图标(可选,与全屏地图保持视觉一致)")]
|
||||
[SerializeField] private Sprite _iconSavePoint;
|
||||
[SerializeField] private Sprite _iconBossRoom;
|
||||
[SerializeField] private Sprite _iconShop;
|
||||
[Tooltip("传送站图标;房间含 TeleportStation 标志时显示。")]
|
||||
[SerializeField] private Sprite _iconTeleport;
|
||||
|
||||
[Header("缩放档位(可选)")]
|
||||
[SerializeField] private int[] _zoomLevels = { 2, 3, 5 }; // R12-FA 可用视野半径档位(格)
|
||||
private int _zoomLevelIndex;
|
||||
|
||||
private IMapService _mapSvc;
|
||||
private IPlayerPositionProvider _playerProvider;
|
||||
private IPinService _pinService;
|
||||
private readonly Dictionary<string, MapRoomCellUI> _cells = new();
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
private readonly List<Image> _pinImages = new();
|
||||
private readonly Stack<Image> _pinPool = new(); // R10-N3 Pin 对象池
|
||||
private readonly Stack<MapRoomCellUI> _cellPool = new(); // R11-N5 Cell 对象池
|
||||
private int _lastPinVersion = -1;
|
||||
private bool _viewDirty; // R10-N2 关闭期间收到 OnRoomChanged → 下次 OnEnable RefreshView
|
||||
private bool _databaseDirty; // R10-N1 关闭期间收到 OnDatabaseChanged → 下次 OnEnable 完整重建
|
||||
private bool _servicesReady; // R21-N1 三服务全部就绪后置 true,短路 LateUpdate 的每帧 ServiceLocator 查询(对齐 MapPanel)
|
||||
|
||||
// 复用 List 避免 RefreshView 每次分配临时 List(GC 友好)
|
||||
private readonly List<string> _toRemove = new List<string>(8);
|
||||
|
||||
// 空间索引:格子坐标 → 房间 ID,将 RefreshView step② 的 O(N) 遍历降至 O(viewRadius²)
|
||||
private Dictionary<Vector2Int, string> _spatialIndex;
|
||||
// 复用 HashSet 避免 RefreshView 每次分配(GC 友好)
|
||||
private readonly HashSet<string> _roomsInViewBuffer = new HashSet<string>(32);
|
||||
private readonly HashSet<string> _newlyAddedBuffer = new HashSet<string>(16);
|
||||
|
||||
private Vector2Int _currentCenter;
|
||||
private string _lastDotRoomId;
|
||||
@@ -53,73 +78,195 @@ namespace BaseGames.World.Map
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// R10-N2/N1 服务订阅在 Awake/OnDestroy 长期持有,OnDisable 不解绑
|
||||
// 即便 HUD 隐藏期间发生 OnRoomChanged / OnDatabaseChanged,也能记录 dirty 标志
|
||||
SubscribeServices();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
|
||||
_playerProvider = ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
|
||||
// 启动顺序兜底
|
||||
SubscribeServices();
|
||||
|
||||
BuildSpatialIndex(_mapSvc?.Database);
|
||||
// R10-N1/N2 应用关闭期间累积的状态变化
|
||||
if (_databaseDirty)
|
||||
{
|
||||
ClearAllCells();
|
||||
ClearPins();
|
||||
_lastPinVersion = -1;
|
||||
_databaseDirty = false;
|
||||
_viewDirty = true; // 重建后需 RefreshView
|
||||
}
|
||||
|
||||
if (_playerProvider != null)
|
||||
_playerProvider.OnRoomChanged += OnRoomChanged;
|
||||
|
||||
_onMapUpdated?.Subscribe(OnMapUpdated).AddTo(_subs);
|
||||
|
||||
// 首次显示时立即刷新
|
||||
RefreshView();
|
||||
if (_viewDirty || _cells.Count == 0)
|
||||
{
|
||||
_viewDirty = false;
|
||||
RefreshView();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_playerProvider != null)
|
||||
_playerProvider.OnRoomChanged -= OnRoomChanged;
|
||||
|
||||
_subs.Clear();
|
||||
ClearAllCells();
|
||||
_lastDotRoomId = null;
|
||||
_mapSvc = null;
|
||||
_playerProvider = null;
|
||||
_spatialIndex = null;
|
||||
// R10-N2 保留 cells/pins/服务订阅;HUD 频繁开关时避免 GC 抖动
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
UnsubscribeServices();
|
||||
ClearAllCells();
|
||||
ClearPins();
|
||||
foreach (var img in _pinPool)
|
||||
if (img != null) Destroy(img.gameObject);
|
||||
_pinPool.Clear();
|
||||
foreach (var cell in _cellPool)
|
||||
if (cell != null) Destroy(cell.gameObject);
|
||||
_cellPool.Clear();
|
||||
}
|
||||
|
||||
private void SubscribeServices()
|
||||
{
|
||||
// 各服务独立守门:任意服务迟到就绪时,后续调用仍能补订阅(R11-N1 修复)
|
||||
if (_playerProvider == null)
|
||||
{
|
||||
_playerProvider = ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
|
||||
if (_playerProvider != null)
|
||||
_playerProvider.OnRoomChanged += OnRoomChanged;
|
||||
}
|
||||
if (_mapSvc == null)
|
||||
{
|
||||
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
|
||||
if (_mapSvc != null)
|
||||
{
|
||||
_mapSvc.OnDatabaseChanged += OnDatabaseChanged;
|
||||
_mapSvc.OnExplorationChanged += OnExplorationChanged;
|
||||
}
|
||||
}
|
||||
_pinService ??= ServiceLocator.GetOrDefault<IPinService>();
|
||||
|
||||
// R21-N1 三服务全部就绪后置 true,短路 LateUpdate 的每帧查询(对齐 MapPanel._servicesReady)
|
||||
if (_playerProvider != null && _mapSvc != null && _pinService != null)
|
||||
_servicesReady = true;
|
||||
}
|
||||
|
||||
private void UnsubscribeServices()
|
||||
{
|
||||
_servicesReady = false; // R21-N3 重置,确保重建场景后的实例能重新订阅
|
||||
if (_playerProvider != null)
|
||||
{
|
||||
_playerProvider.OnRoomChanged -= OnRoomChanged;
|
||||
_playerProvider = null;
|
||||
}
|
||||
if (_mapSvc != null)
|
||||
{
|
||||
_mapSvc.OnDatabaseChanged -= OnDatabaseChanged;
|
||||
_mapSvc.OnExplorationChanged -= OnExplorationChanged;
|
||||
_mapSvc = null;
|
||||
}
|
||||
_pinService = null;
|
||||
}
|
||||
|
||||
private void ClearAllCells()
|
||||
{
|
||||
// R11-N5 禁用入池而非销毁
|
||||
foreach (var cell in _cells.Values)
|
||||
if (cell != null) Destroy(cell.gameObject);
|
||||
{
|
||||
if (cell == null) continue;
|
||||
cell.gameObject.SetActive(false);
|
||||
_cellPool.Push(cell);
|
||||
}
|
||||
_cells.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建格子坐标 → 房间 ID 的哈希映射。
|
||||
/// 将 RefreshView step② 从 O(allRooms) 全量遍历降至 O(viewRadius²) 范围格点查询。
|
||||
/// 数据库变更时(如热更)应再次调用。
|
||||
/// </summary>
|
||||
private void BuildSpatialIndex(MapDatabaseSO db)
|
||||
private void ClearPins()
|
||||
{
|
||||
_spatialIndex = new Dictionary<Vector2Int, string>();
|
||||
if (db?.AllRooms == null) return;
|
||||
foreach (var room in db.AllRooms)
|
||||
// R10-N3 禁用入池而非销毁
|
||||
foreach (var img in _pinImages)
|
||||
{
|
||||
if (room == null) continue;
|
||||
for (int x = 0; x < room.GridSize.x; x++)
|
||||
for (int y = 0; y < room.GridSize.y; y++)
|
||||
_spatialIndex[new Vector2Int(room.GridPosition.x + x, room.GridPosition.y + y)] = room.RoomId;
|
||||
if (img == null) continue;
|
||||
img.gameObject.SetActive(false);
|
||||
_pinPool.Push(img);
|
||||
}
|
||||
_pinImages.Clear();
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
// R21-N1 服务懒加载重试:_servicesReady 置 true 后短路,消除每帧 ServiceLocator 查询(对齐 MapPanel)
|
||||
if (!_servicesReady)
|
||||
SubscribeServices();
|
||||
UpdatePlayerDot();
|
||||
RenderPinsIfDirty();
|
||||
}
|
||||
|
||||
// ── 事件响应 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void OnRoomChanged(string _) => RefreshView();
|
||||
|
||||
private void OnMapUpdated(string roomId)
|
||||
private void OnRoomChanged(string _)
|
||||
{
|
||||
if (_cells.TryGetValue(roomId, out var cell))
|
||||
cell.SetVisibility(_mapSvc.GetVisibility(roomId));
|
||||
// R10-N2 禁用时累积 dirty;OnEnable 后再 RefreshView
|
||||
if (!isActiveAndEnabled) { _viewDirty = true; return; }
|
||||
RefreshView();
|
||||
}
|
||||
|
||||
/// <summary>R10-N12 探索进度变化:仅刷新已实例化的格子可见性。</summary>
|
||||
private void OnExplorationChanged()
|
||||
{
|
||||
if (!isActiveAndEnabled) { _viewDirty = true; return; }
|
||||
if (_mapSvc == null) return;
|
||||
foreach (var (id, cell) in _cells)
|
||||
if (cell != null) cell.SetVisibility(_mapSvc.GetVisibility(id));
|
||||
}
|
||||
|
||||
/// <summary>数据库结构变更时完整重建:清空所有格子,下次 RefreshView 重新实例化。</summary>
|
||||
private void OnDatabaseChanged()
|
||||
{
|
||||
if (!isActiveAndEnabled) { _databaseDirty = true; return; }
|
||||
ClearAllCells();
|
||||
ClearPins();
|
||||
_lastPinVersion = -1;
|
||||
RefreshView();
|
||||
}
|
||||
|
||||
// ── Pin 渲染(可视范围内)─────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 仅渲染当前小地图视野内(已实例化格子的房间)的 Pin。
|
||||
/// 基于 PinsVersion + RefreshView 时机做脏检查:版本未变化且格子未变化时跳过。
|
||||
/// 单地图 N(视野)级别开销,远小于 MapPanel 的全图 Pin 渲染。
|
||||
/// </summary>
|
||||
private void RenderPinsIfDirty()
|
||||
{
|
||||
if (_pinService == null || _pinPrefab == null) return;
|
||||
if (_pinService.PinsVersion == _lastPinVersion) return;
|
||||
_lastPinVersion = _pinService.PinsVersion;
|
||||
RebuildPins();
|
||||
}
|
||||
|
||||
private void RebuildPins()
|
||||
{
|
||||
ClearPins();
|
||||
foreach (var pin in _pinService.Pins)
|
||||
{
|
||||
if (!_cells.TryGetValue(pin.RoomId, out var cell)) continue;
|
||||
// R10-N3 优先从对象池取
|
||||
Image img;
|
||||
if (_pinPool.Count > 0)
|
||||
{
|
||||
img = _pinPool.Pop();
|
||||
img.gameObject.SetActive(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
img = Instantiate(_pinPrefab, _cellContainer);
|
||||
}
|
||||
img.sprite = _pinConfig != null ? _pinConfig.GetSprite((PinType)pin.PinTypeInt) : null;
|
||||
img.rectTransform.anchoredPosition = cell.RT.anchoredPosition
|
||||
+ new Vector2(pin.NormalizedPosX * cell.RT.sizeDelta.x,
|
||||
pin.NormalizedPosY * cell.RT.sizeDelta.y);
|
||||
_pinImages.Add(img);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 视图重建 ──────────────────────────────────────────────────────────
|
||||
@@ -153,42 +300,56 @@ namespace BaseGames.World.Map
|
||||
var r = db.GetRoom(id);
|
||||
if (r == null || !RoomInView(r, minX, maxX, minY, maxY))
|
||||
{
|
||||
if (cell != null) Destroy(cell.gameObject);
|
||||
// R11-N5 禁用入池而非 Destroy,下次复用避免 GC 抖动
|
||||
if (cell != null)
|
||||
{
|
||||
cell.gameObject.SetActive(false);
|
||||
_cellPool.Push(cell);
|
||||
}
|
||||
_toRemove.Add(id);
|
||||
}
|
||||
}
|
||||
foreach (var id in _toRemove) _cells.Remove(id);
|
||||
|
||||
// ② 用空间索引替代 O(N) 全量遍历,在可视范围格点上查询所属房间
|
||||
// ② 通过 MapDatabaseSO 共享空间索引,在可视范围格点上查询所属房间
|
||||
// 复杂度:O(viewRadius²) 替代 O(allRooms),大地图下效果显著
|
||||
_roomsInViewBuffer.Clear();
|
||||
if (_spatialIndex != null)
|
||||
for (int x = minX; x <= maxX; x++)
|
||||
for (int y = minY; y <= maxY; y++)
|
||||
{
|
||||
for (int x = minX; x <= maxX; x++)
|
||||
for (int y = minY; y <= maxY; y++)
|
||||
{
|
||||
if (_spatialIndex.TryGetValue(new Vector2Int(x, y), out var rId))
|
||||
_roomsInViewBuffer.Add(rId);
|
||||
}
|
||||
var rId = db.GetRoomIdAtCell(new Vector2Int(x, y));
|
||||
if (!string.IsNullOrEmpty(rId)) _roomsInViewBuffer.Add(rId);
|
||||
}
|
||||
|
||||
_newlyAddedBuffer.Clear();
|
||||
foreach (var roomId in _roomsInViewBuffer)
|
||||
{
|
||||
if (_cells.ContainsKey(roomId)) continue;
|
||||
var room = db.GetRoom(roomId);
|
||||
if (room == null) continue;
|
||||
|
||||
var cell = Instantiate(_cellPrefab, _cellContainer);
|
||||
cell.Setup(room, _mapSvc.GetVisibility(room.RoomId), null);
|
||||
// R11-N5 优先从对象池取,避免高频 Instantiate
|
||||
MapRoomCellUI cell;
|
||||
if (_cellPool.Count > 0)
|
||||
{
|
||||
cell = _cellPool.Pop();
|
||||
cell.gameObject.SetActive(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
cell = Instantiate(_cellPrefab, _cellContainer);
|
||||
}
|
||||
cell.Setup(room, _mapSvc.GetVisibility(room.RoomId), ChooseIcon(room));
|
||||
cell.SetColors(_colorExplored, _colorMapped, _colorUnknown);
|
||||
PlaceCell(cell, room); // 立即设置正确的中心相对坐标,避免 Setup 默认偏移被 step③ 覆盖
|
||||
PlaceCell(cell, room); // 立即设置正确的中心相对坐标
|
||||
_cells[roomId] = cell;
|
||||
_newlyAddedBuffer.Add(roomId);
|
||||
}
|
||||
|
||||
// ③ 重定位所有格子(中心发生变化时)
|
||||
// ③ 重定位存量格子(新增格子在 step② 已 PlaceCell,跳过避免重复写入)
|
||||
foreach (var (id, cell) in _cells)
|
||||
{
|
||||
if (cell == null) continue;
|
||||
if (cell == null || _newlyAddedBuffer.Contains(id)) continue;
|
||||
var r = db.GetRoom(id);
|
||||
if (r != null) PlaceCell(cell, r);
|
||||
}
|
||||
@@ -206,6 +367,13 @@ namespace BaseGames.World.Map
|
||||
(room.GridPosition.y - _currentCenter.y) * _cellPixels);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R20-N2 委托到 MapRoomDataSO.ChooseDisplayIcon,消除与 MapPanel 的重复实现。
|
||||
/// 优先级:MapIconOverride > SavePoint > BossRoom > Shop > TeleportStation。
|
||||
/// </summary>
|
||||
private Sprite ChooseIcon(MapRoomDataSO room)
|
||||
=> room.ChooseDisplayIcon(_iconSavePoint, _iconBossRoom, _iconShop, _iconTeleport);
|
||||
|
||||
private static bool RoomInView(MapRoomDataSO room, int minX, int maxX, int minY, int maxY)
|
||||
=> room.GridPosition.x + room.GridSize.x > minX &&
|
||||
room.GridPosition.x < maxX &&
|
||||
@@ -237,5 +405,29 @@ namespace BaseGames.World.Map
|
||||
cell.RT.anchoredPosition
|
||||
+ Vector2.Scale(normPos, cell.RT.sizeDelta);
|
||||
}
|
||||
|
||||
// ── 缩放档位切换 ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// R12-FA 循环切换视野半径档位(可绑定到按键/按钮)。
|
||||
/// 档位在 Inspector 中通过 _zoomLevels 数组配置(默认:2/3/5 格)。
|
||||
/// 安全检查:数组为空时不切换,避免除零或越界。
|
||||
/// </summary>
|
||||
public void CycleZoom()
|
||||
{
|
||||
if (_zoomLevels == null || _zoomLevels.Length == 0) return;
|
||||
_zoomLevelIndex = (_zoomLevelIndex + 1) % _zoomLevels.Length;
|
||||
_viewRadiusCells = _zoomLevels[_zoomLevelIndex];
|
||||
if (isActiveAndEnabled)
|
||||
{
|
||||
// R29-N1 先清除标志再刷新,防止 HUD 关闭后重新打开触发冗余 RefreshView
|
||||
_viewDirty = false;
|
||||
RefreshView();
|
||||
}
|
||||
else
|
||||
{
|
||||
_viewDirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
Assets/_Game/Scripts/World/Map/MinimapInputHandler.cs
Normal file
38
Assets/_Game/Scripts/World/Map/MinimapInputHandler.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Input;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// 小地图 HUD 输入处理器(R13-N3)。
|
||||
/// 挂在与 MinimapHUD 相同的 GameObject 上,负责将 InputReaderSO 事件路由到 MinimapHUD。
|
||||
/// <list type="bullet">
|
||||
/// <item>CycleMinimapZoomEvent → MinimapHUD.CycleZoom()(循环切换视野半径档位)</item>
|
||||
/// </list>
|
||||
/// 输入动作在 InputActionAsset 的 UI Map 中命名为 "CycleMinimapZoom";
|
||||
/// 若动作不存在,InputReaderSO 会打印 Warning 并安全跳过,不影响运行。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(MinimapHUD))]
|
||||
public class MinimapInputHandler : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private InputReaderSO _inputReader;
|
||||
|
||||
private MinimapHUD _hud;
|
||||
|
||||
private void Awake() => _hud = GetComponent<MinimapHUD>();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_inputReader != null)
|
||||
_inputReader.CycleMinimapZoomEvent += OnCycleZoom;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_inputReader != null)
|
||||
_inputReader.CycleMinimapZoomEvent -= OnCycleZoom;
|
||||
}
|
||||
|
||||
private void OnCycleZoom() => _hud?.CycleZoom();
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/World/Map/MinimapInputHandler.cs.meta
Normal file
11
Assets/_Game/Scripts/World/Map/MinimapInputHandler.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d63ff68b5c5813a42866a97b22ec9850
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
using BaseGames.Core;
|
||||
@@ -31,14 +32,18 @@ namespace BaseGames.World.Map
|
||||
private CanvasGroup _cg;
|
||||
private Coroutine _showCoroutine;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
private Dictionary<string, RegionNameEntry> _regionDict;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_cg = GetComponent<CanvasGroup>();
|
||||
_cg.alpha = 0f;
|
||||
gameObject.SetActive(false);
|
||||
BuildRegionDict();
|
||||
}
|
||||
|
||||
private void OnValidate() => BuildRegionDict();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onRegionChanged?.Subscribe(OnRegionChanged).AddTo(_subs);
|
||||
@@ -47,6 +52,14 @@ namespace BaseGames.World.Map
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
// 协程持有 this 引用且 SetActive(false) 不会自动停止——必须在 OnDisable 显式终止,
|
||||
// 否则禁用后重新启用时旧序列与新序列叠加,CanvasGroup.alpha 与 SetActive 状态不一致。
|
||||
if (_showCoroutine != null)
|
||||
{
|
||||
StopCoroutine(_showCoroutine);
|
||||
_showCoroutine = null;
|
||||
}
|
||||
if (_cg != null) _cg.alpha = 0f;
|
||||
}
|
||||
|
||||
// ── 事件响应 ──────────────────────────────────────────────────────────
|
||||
@@ -87,14 +100,12 @@ namespace BaseGames.World.Map
|
||||
|
||||
// ── 辅助方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>预建 RegionId → Entry 字典,将 ResolveDisplayName 从 O(N) 降至 O(1)。</summary>
|
||||
private void BuildRegionDict()
|
||||
=> _regionDict = MapServiceExtensions.BuildRegionDict(_regionNames);
|
||||
|
||||
private string ResolveDisplayName(string regionId)
|
||||
{
|
||||
if (_regionNames != null)
|
||||
foreach (var e in _regionNames)
|
||||
if (e.RegionId == regionId)
|
||||
return e.GetDisplayName();
|
||||
return regionId;
|
||||
}
|
||||
=> MapServiceExtensions.ResolveRegionDisplayName(_regionDict, regionId);
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
|
||||
136
Assets/_Game/Scripts/World/Map/TeleportService.cs
Normal file
136
Assets/_Game/Scripts/World/Map/TeleportService.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// 传送/快速旅行服务实现(架构 15_MapShopModule §1.4)。
|
||||
/// 挂在 Persistent 场景 [GameManagers] 下,通过 ServiceLocator 对外暴露 <see cref="ITeleportService"/>。
|
||||
/// <para>
|
||||
/// 追踪已解锁传送点(UnlockedTeleportRoomIds),实现 ISaveable 跨存档持久化。
|
||||
/// 传送流程:UI 调用 <see cref="RequestTeleport"/> → 触发 <see cref="OnTeleportRequested"/>
|
||||
/// → 场景加载系统监听事件执行实际切换 → 到达后调用 <see cref="NotifyTeleportCompleted"/>。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-400)] // 晚于 MapPinManager(-500),早于默认 0,确保 ITeleportService 在 UI SubscribeServices 前已注册
|
||||
public class TeleportService : MonoBehaviour, ISaveable, ITeleportService
|
||||
{
|
||||
private readonly HashSet<string> _unlockedRoomIds = new();
|
||||
|
||||
private IMapService _mapSvc;
|
||||
private IPlayerPositionProvider _playerProvider;
|
||||
private bool _isDuplicate;
|
||||
|
||||
// ── ITeleportService 事件 ─────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<string, string> OnTeleportRequested;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<string> OnTeleportCompleted;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (ServiceLocator.GetOrDefault<ITeleportService>() != null)
|
||||
{
|
||||
_isDuplicate = true;
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
ServiceLocator.Register<ITeleportService>(this);
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_isDuplicate) return;
|
||||
_mapSvc = ServiceLocator.GetOrDefault<IMapService>();
|
||||
_playerProvider = ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_isDuplicate) return;
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_isDuplicate) return;
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_isDuplicate) return;
|
||||
ServiceLocator.Unregister<ITeleportService>(this);
|
||||
}
|
||||
|
||||
// ── ITeleportService API ──────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool CanTeleportTo(string roomId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(roomId)) return false;
|
||||
if (!_unlockedRoomIds.Contains(roomId)) return false;
|
||||
// 需要玩家已探索过目标房间(仅已知位置才允许传送);
|
||||
// _mapSvc 不可用时 Fail-Safe:拒绝传送而非放行。
|
||||
_mapSvc ??= ServiceLocator.GetOrDefault<IMapService>();
|
||||
return _mapSvc != null && _mapSvc.IsExplored(roomId);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RequestTeleport(string targetRoomId)
|
||||
{
|
||||
if (!CanTeleportTo(targetRoomId))
|
||||
{
|
||||
Debug.LogWarning($"[TeleportService] 无法传送到 '{targetRoomId}':未解锁或未探索。");
|
||||
return;
|
||||
}
|
||||
_playerProvider ??= ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
|
||||
// 使用当前房间 ID 作为传送来源(与接口参数语义一致)
|
||||
string sourceRoomId = _playerProvider?.CurrentRoomId ?? string.Empty;
|
||||
OnTeleportRequested?.Invoke(sourceRoomId, targetRoomId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 场景加载系统在传送完成(玩家到达目标房间)后调用此方法,
|
||||
/// 触发 <see cref="OnTeleportCompleted"/> 事件供 UI 刷新。
|
||||
/// </summary>
|
||||
public void NotifyTeleportCompleted(string arrivedRoomId)
|
||||
{
|
||||
OnTeleportCompleted?.Invoke(arrivedRoomId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解锁指定房间的传送点(通常在玩家首次使用传送站时调用)。
|
||||
/// </summary>
|
||||
public void UnlockTeleportStation(string roomId)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(roomId))
|
||||
_unlockedRoomIds.Add(roomId);
|
||||
}
|
||||
|
||||
// ── ISaveable ─────────────────────────────────────────────────────────
|
||||
|
||||
public void OnSave(SaveData data)
|
||||
{
|
||||
// 防御性拷贝,避免序列化期间集合被修改
|
||||
data.Map.UnlockedTeleportRoomIds = new HashSet<string>(_unlockedRoomIds);
|
||||
}
|
||||
|
||||
public void OnLoad(SaveData data)
|
||||
{
|
||||
// readonly 字段限制 → Clear + foreach Add(与 MapManager._exploredRooms 模式对称)
|
||||
_unlockedRoomIds.Clear();
|
||||
if (data.Map.UnlockedTeleportRoomIds != null)
|
||||
foreach (var id in data.Map.UnlockedTeleportRoomIds)
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
_unlockedRoomIds.Add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/World/Map/TeleportService.cs.meta
Normal file
11
Assets/_Game/Scripts/World/Map/TeleportService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2b4b8e55bb3e7b34f9559e1b60a92733
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user