using System; using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using TMPro; using BaseGames.Core; using BaseGames.Core.Events; using BaseGames.Core.Save; using BaseGames.Localization; namespace BaseGames.World.Map { /// /// 全屏地图 UI 面板(架构 15_MapShopModule §1.3)。 /// 由 UIManager PanelStack 管理开关;OnEnable 时重建格子并订阅更新事件。 /// /// 依赖项均通过 获取(、 /// ), /// 不持有任何具体 MonoBehaviour 的 SerializeField 引用,实现架构解耦。 /// /// public class MapPanel : MonoBehaviour { [SerializeField] private RectTransform _roomContainer; // 格子图放置根节点 [SerializeField] private MapRoomCellUI _cellPrefab; // 地图格子预制 [SerializeField] private Image _exitConnectorPrefab; // 出口连接线预制(小矩形 Image) [SerializeField] private ScrollRect _scrollRect; // 包裹 _roomContainer 的滚动矩形(可空,无滚动时留空) [Header("图标 Sprites")] [SerializeField] private Sprite _iconSavePoint; [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; [SerializeField] private Color _colorMapped = new Color(0.45f, 0.45f, 0.45f, 1f); [SerializeField] private Color _colorUnknown = Color.black; [Header("玩家位置")] [SerializeField] private Image _playerIconImg; // _roomContainer 内的玩家图标 [Tooltip("勾选后,未启用定位能力(IMapService.IsLocatorEnabled)时隐藏玩家图标与当前房间高亮——\n" + "设计:拿到指南针护符前能看地图但不知自己在哪。\n" + "默认 false 保持现有\"恒显\"行为。与 MinimapHUD._requireLocatorForPlayerDot 对称。")] [SerializeField] private bool _requireLocatorForPlayerIcon; [Header("传送选择")] [Tooltip("勾选后,点击地图上\"已解锁且已探索\"的传送站房间会触发 OnTeleportStationSelected,\n" + "由 UI 侧 MapTeleportConfirmController 弹出确认框并调用 ITeleportService.RequestTeleport。\n" + "取消勾选可禁止从全屏地图发起传送(如改为仅在传送站处选择目的地)。")] [SerializeField] private bool _allowTeleportFromMap = true; [Header("地图标记")] [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; [HideInInspector, SerializeField] private StringEventChannelSO _onMapUpdated; // 已废弃,仅保留序列化兼容性(R12-N8) private readonly Dictionary _cells = new(); private readonly List _pinImages = new(); private readonly Stack _pinPool = new(); // R10-N3 Pin 对象池,回收而非销毁 private readonly Stack _cellPool = new(); // R12-N1 Cell 对象池,BuildGrid/OnDestroy 共用 private readonly Stack _exitPool = new(); // R12-N1 Exit connector 对象池 private readonly List _exitImages= new(); private readonly Dictionary _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 ITeleportService _teleportSvc; /// /// 玩家在地图上点击了一个"可传送"的已解锁站点(参数 RoomId)。 /// UI 侧(MapTeleportConfirmController,位于 BaseGames.UI 程序集)订阅此事件, /// 弹出确认框并在确认后调用 ITeleportService.RequestTeleport—— /// MapPanel 自身不依赖 UI 程序集,避免与 BaseGames.UI 形成循环引用。 /// public event Action OnTeleportStationSelected; private void Awake() { // R10-N1 服务订阅在 Awake/OnDestroy 长期持有:即便面板关闭也能感知数据库变更, // 设置 dirty 标志后由 OnEnable 触发重建,避免错过事件导致下次打开仍展示陈旧布局。 SubscribeServices(); } private void OnEnable() { // 若服务在 Awake 时还未注册(启动顺序),此处补订阅 SubscribeServices(); // 首次打开时建立格子;后续打开只刷新探索状态,跳过重复 Instantiate if (_cells.Count == 0) BuildGrid(); else if (_databaseDirty) RebuildAll(); else if (_explorationDirty) RefreshAllCells(); _databaseDirty = _explorationDirty = false; RenderPins(); UpdatePlayerIcon(); RefreshTeleportMarks(); // 每次打开刷新可传送标记(覆盖在站点新解锁后重开地图的场景) CenterOnCurrentRoom(); // R12-N8:移除 _onMapUpdated 订阅,避免与 OnExplorationChanged 双重刷新; // _onMapUpdated 字段保留但标记 HideInInspector,防止旧 Prefab 数据丢失。 } private void OnDisable() { _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(); } /// 统一订阅服务的 OnDatabaseChanged / OnExplorationChanged / OnRoomMapped 事件。 private void SubscribeServices() { if (_servicesReady) return; // R12-N7 三服务全部就绪后短路 if (_mapSvc == null) { _mapSvc = ServiceLocator.GetOrDefault(); if (_mapSvc != null) { _mapSvc.OnDatabaseChanged += OnDatabaseChanged; _mapSvc.OnExplorationChanged += OnExplorationChanged; _mapSvc.OnRoomMapped += OnRoomMappedAnim; _mapSvc.OnLocatorChanged += OnLocatorChanged; } } _playerProvider ??= ServiceLocator.GetOrDefault(); _pinService ??= ServiceLocator.GetOrDefault(); _teleportSvc ??= ServiceLocator.GetOrDefault(); // 可选:未注册时地图仍正常工作,仅不显示可传送标记 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; _mapSvc.OnLocatorChanged -= OnLocatorChanged; } } 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 && _playerProvider.NormalizedPositionInRoom == _lastIconNormPos) return; _lastIconRoomId = _playerProvider.CurrentRoomId; _lastIconNormPos = _playerProvider.NormalizedPositionInRoom; UpdatePlayerIcon(); } /// 数据库结构变更:禁用状态置 dirty,启用状态立即重建。 private void OnDatabaseChanged() { if (!isActiveAndEnabled) { _databaseDirty = true; return; } RebuildAll(); } /// 定位能力开关变化:重置 dirty 标记强制 UpdatePlayerIcon 重新评估门控。 private void OnLocatorChanged() { _lastIconRoomId = null; // 绕过 LateUpdate 的 roomId/normPos 脏检查,确保门控状态立即生效 if (isActiveAndEnabled) UpdatePlayerIcon(); } /// R10-N12 探索进度变化:仅刷新格子可见性,不重建结构(轻量级)。 private void OnExplorationChanged() { if (!isActiveAndEnabled) { _explorationDirty = true; return; } RefreshAllCells(); } /// /// R12-FC 房间被标 Mapped 时播放发现动画(格子存在才播放)。 /// R19-N2 先停止该房间的旧协程,防止 RebuildAll 把格子回收后协程继续写颜色。 /// R20-N1 通过 RunRevealAnim 包装协程,动画完成后自动从 _revealCoroutines 移除, /// 消除已完成协程引用在字典中积累至下次 RebuildAll 的问题。 /// 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() { foreach (var (roomId, cell) in _cells) { if (cell == null) continue; cell.SetVisibility(_mapSvc.GetVisibility(roomId)); } RefreshTeleportMarks(); // 探索状态影响 CanTeleportTo,同步刷新可传送标记 } // ── 传送选择 ────────────────────────────────────────────────────────── /// 房间是否可作为传送目的地:允许从地图传送 + 传送服务存在 + 已解锁且已探索。 private bool IsTeleportable(string roomId) => _allowTeleportFromMap && _teleportSvc != null && _teleportSvc.CanTeleportTo(roomId); /// 格子点击回调:仅对可传送站点转发选择事件,交由 UI 侧确认并执行传送。 private void OnCellClicked(string roomId) { if (IsTeleportable(roomId)) OnTeleportStationSelected?.Invoke(roomId); } /// 刷新所有格子的"可传送"标记(探索/传送解锁变化、面板重开时调用)。 private void RefreshTeleportMarks() { foreach (var (roomId, cell) in _cells) if (cell != null) cell.SetTeleportable(IsTeleportable(roomId)); } // ── 格子 & 出口连接 ────────────────────────────────────────────────── private void BuildGrid() { var db = _mapSvc?.Database; if (db?.AllRooms == null) return; foreach (var room in db.AllRooms) { if (room == null) continue; // 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); cell.SetClickHandler(OnCellClicked); cell.SetTeleportable(IsTeleportable(room.RoomId)); _cells[room.RoomId] = cell; } DrawExits(); // R11-N4 格子布局改变后统一重建一次,CenterOnCurrentRoom 不再重复调用 if (_scrollRect != null) LayoutRebuilder.ForceRebuildLayoutImmediate(_scrollRect.content); } /// 为每条出口在格子坐标处实例化一个小矩形连接线图像。 private void DrawExits() { var db = _mapSvc?.Database; if (_exitConnectorPrefab == null || db?.AllRooms == null) return; ClearExits(); foreach (var room in db.AllRooms) { if (room?.Exits == null) continue; foreach (var exit in room.Exits) { // 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( 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); } } } private void ClearExits() { // R12-N1 禁用入池而非销毁 foreach (var img in _exitImages) { if (img == null) continue; img.gameObject.SetActive(false); _exitPool.Push(img); } _exitImages.Clear(); } [Obsolete("R12-N8: 由 OnExplorationChanged 统一处理,此方法仅保留序列化兼容性,请勿新增调用。")] private void OnMapUpdated(string roomId) { /* R12-N8 已废弃:由 OnExplorationChanged 统一处理,此方法保留避免序列化引用问题 */ } // ── 玩家位置图标 ────────────────────────────────────────────────────── private void UpdatePlayerIcon() { if (_playerIconImg == null || _playerProvider == null) return; // 定位门控:未启用定位能力时隐藏玩家图标与当前房间高亮(由指南针护符解锁) if (_requireLocatorForPlayerIcon && (_mapSvc == null || !_mapSvc.IsLocatorEnabled)) { _playerIconImg.enabled = false; UpdateCellHighlight(null); return; } var roomId = _playerProvider.CurrentRoomId; if (string.IsNullOrEmpty(roomId) || !_cells.TryGetValue(roomId, out var cell)) { _playerIconImg.enabled = false; UpdateCellHighlight(null); return; } _playerIconImg.sprite = _iconPlayerPos; _playerIconImg.enabled = true; _playerIconImg.rectTransform.anchoredPosition = cell.RT.anchoredPosition + Vector2.Scale(_playerProvider.NormalizedPositionInRoom, cell.RT.sizeDelta); // 强制玩家图标渲染在所有格子/出口连线/Pin 之上,避免 Prefab 中层级配置错误导致被遮挡 _playerIconImg.transform.SetAsLastSibling(); UpdateCellHighlight(roomId); } // ── 当前房间高亮 & ScrollRect 居中 ───────────────────────────────── /// 切换高亮描边:取消旧房间高亮,激活新房间高亮。 private void UpdateCellHighlight(string roomId) { if (roomId == _highlightedRoomId) return; if (_highlightedRoomId != null && _cells.TryGetValue(_highlightedRoomId, out var prev)) prev.SetHighlight(false); _highlightedRoomId = roomId; if (roomId != null && _cells.TryGetValue(roomId, out var next)) next.SetHighlight(true); } /// /// 当前地图缩放系数(从 _roomContainer.localScale.x 读取)。 /// 供 MapInputHandler 使用以消除双份状态:MapInputHandler._zoom 写入 _roomContainer, /// CenterOnCurrentRoom 与 Update 均从此属性读取,保证两处始终一致。 /// public float CurrentZoom => _roomContainer != null ? _roomContainer.localScale.x : 1f; /// 将 ScrollRect 视口居中到玩家当前所在房间。可由外部(如 MapInputHandler)调用。 public void CenterOnCurrentRoom() { if (_scrollRect == null || _playerProvider == null) return; var roomId = _playerProvider.CurrentRoomId; if (string.IsNullOrEmpty(roomId) || !_cells.TryGetValue(roomId, out var cell)) return; // R11-N4 不再在此调用 ForceRebuildLayoutImmediate(已移至 BuildGrid 末尾); // 直接从 cell.RT 读取位置——格子由 Setup 手动定位,无需 LayoutGroup 重建。 var content = _scrollRect.content; var viewport = _scrollRect.viewport != null ? _scrollRect.viewport : (RectTransform)_scrollRect.transform; // 将 cell 中心转换到 content 本地坐标系 Vector2 cellWorldCenter = cell.RT.TransformPoint(cell.RT.rect.center); Vector2 cellLocal = content.InverseTransformPoint(cellWorldCenter); // 距 content 左下角的距离(pivot 无关) float cellX = cellLocal.x - content.rect.xMin; float cellY = cellLocal.y - content.rect.yMin; Vector2 viewSize = viewport.rect.size; Vector2 contentSize = content.rect.size; // 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 * 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); } // ── 地图标记渲染 ────────────────────────────────────────────────────── private void RenderPins() { if (_pinService == null) return; // 版本号脏检查:Pin 集合未变化时跳过重绘,避免无效 Instantiate // 初始值 -1 保证首次 RenderPins 必然执行 if (_pinService.PinsVersion == _lastPinVersion) return; _lastPinVersion = _pinService.PinsVersion; ClearPins(); if (_pinPrefab == null) return; foreach (var pin in _pinService.Pins) { if (!_cells.TryGetValue(pin.RoomId, out var cell)) continue; // 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( pin.NormalizedPosX * cell.RT.sizeDelta.x, pin.NormalizedPosY * cell.RT.sizeDelta.y); _pinImages.Add(img); } } private void ClearPins() { // R10-N3 禁用入池而非销毁,减少 GC 与下次创建开销 foreach (var img in _pinImages) { if (img == null) continue; img.gameObject.SetActive(false); _pinPool.Push(img); } _pinImages.Clear(); } // ── Tooltip ─────────────────────────────────────────────────────────── private void ShowTooltip(string text) { if (_tooltipPanel == null || string.IsNullOrEmpty(text)) return; // 房间名走本地化:text 为本地化 Key 时解析为译文;为普通名称(未命中 Key)时原样显示(向后兼容)。 if (_tooltipText != null) _tooltipText.text = LocalizationManager.Get(text, LocalizationTable.UI); _tooltipPanel.SetActive(true); } private void HideTooltip() => _tooltipPanel?.SetActive(false); // ── 辅助方法 ────────────────────────────────────────────────────────── private Sprite ChooseIcon(MapRoomDataSO room) // R20-N2 委托到 MapRoomDataSO.ChooseDisplayIcon,消除与 MinimapHUD 的重复实现 => room.ChooseDisplayIcon(_iconSavePoint, _iconBossRoom, _iconShop, _iconTeleport); private Sprite GetPinSprite(PinType type) => _pinConfig != null ? _pinConfig.GetSprite(type) : null; /// /// R13-N1 当出口未配置自定义坐标时,按 ExitDirection 推算房间边缘中点。 /// 避免 ExitGridPos 默认 (0,0) 导致所有连接线渲染到容器原点。 /// 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, }; } }