地图系统

This commit is contained in:
2026-06-05 18:41:33 +08:00
parent 613f2a4d13
commit fe4fd60083
234 changed files with 33090 additions and 4899 deletions

View File

@@ -14,6 +14,7 @@
"BaseGames.Core.Events",
"BaseGames.Input",
"BaseGames.Localization",
"BaseGames.Player",
"Unity.TextMeshPro"
],
"autoReferenced": true,

View File

@@ -33,6 +33,24 @@ namespace BaseGames.World.Map
/// <summary>返回属于指定区域的所有房间数据regionId 为空时返回空数组。</summary>
MapRoomDataSO[] GetRoomsByRegion(string regionId);
// ── 定位门控Locator / 指南针)────────────────────────────────────────
/// <summary>
/// 是否已启用"定位"能力(由指南针类护符解锁)。
/// 为 false 时,地图 UI 应隐藏玩家位置点——玩家能看地图但不知自己在哪。
/// 由 <see cref="SetLocatorEnabled"/> 控制,跨存档持久化。
/// </summary>
bool IsLocatorEnabled { get; }
/// <summary>
/// 设置定位能力开关(拾取/装备指南针道具时调用 true
/// 状态变化时触发 <see cref="OnLocatorChanged"/> 并写入存档。
/// </summary>
void SetLocatorEnabled(bool enabled);
/// <summary>定位能力开关变化时触发;地图 UI 据此显示/隐藏玩家位置点。</summary>
event Action OnLocatorChanged;
/// <summary>
/// 当地图数据库结构发生变化(房间增删/编辑器热改/运行时热更)时触发。
/// MapPanel、MinimapHUD 等 UI 应订阅此事件以执行完整重建。

View File

@@ -31,6 +31,7 @@ namespace BaseGames.World.Map
private HashSet<string> _exploredRooms = new();
private HashSet<string> _mappedRooms = new();
private string _currentRegionId;
private bool _locatorEnabled; // 定位能力(指南针);控制地图玩家点是否显示,存档持久化
private int _totalRoomCount = -1; // -1 = 未缓存OnLoad 后重置
private Dictionary<string, MapRoomDataSO[]> _regionCache; // R11-N6 GetRoomsByRegion 结果缓存
private bool _isDuplicate; // Awake 检测到重复实例时置位OnEnable/OnDisable 提前 return
@@ -60,9 +61,10 @@ namespace BaseGames.World.Map
public void OnSave(SaveData data)
{
data.Map.ExploredRooms = new HashSet<string>(_exploredRooms);
data.Map.MappedRooms = new HashSet<string>(_mappedRooms);
data.Map.LastRegionId = _currentRegionId;
data.Map.ExploredRooms = new HashSet<string>(_exploredRooms);
data.Map.MappedRooms = new HashSet<string>(_mappedRooms);
data.Map.LastRegionId = _currentRegionId;
data.Map.LocatorUnlocked = _locatorEnabled;
}
public void OnLoad(SaveData data)
@@ -70,10 +72,13 @@ namespace BaseGames.World.Map
_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
_locatorEnabled = data.Map.LocatorUnlocked;
_totalRoomCount = -1; // 强制下次调用 GetExplorationProgress 时重新计数
// 读档后广播UI 仅需轻量刷新(不重建结构);订阅 OnExplorationChanged 的 UI 会 RefreshAllCells
OnExplorationChanged?.Invoke();
// 定位状态可能随存档变化,通知 UI 显示/隐藏玩家位置点
OnLocatorChanged?.Invoke();
}
// ── 事件驱动房间发现 ──────────────────────────────────────────────────
@@ -136,6 +141,23 @@ namespace BaseGames.World.Map
public bool IsExplored(string roomId) => _exploredRooms.Contains(roomId);
public bool IsMapped(string roomId) => _mappedRooms.Contains(roomId);
public string CurrentRegionId => _currentRegionId;
// ── 定位门控(指南针)─────────────────────────────────────────────────
/// <inheritdoc/>
public bool IsLocatorEnabled => _locatorEnabled;
/// <inheritdoc/>
public event Action OnLocatorChanged;
/// <inheritdoc/>
public void SetLocatorEnabled(bool enabled)
{
if (_locatorEnabled == enabled) return; // 幂等:状态未变化不广播
_locatorEnabled = enabled;
OnLocatorChanged?.Invoke();
}
public MapDatabaseSO Database => _database;
public int ExploredRoomCount => _exploredRooms.Count;

View File

@@ -7,6 +7,7 @@ using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
using BaseGames.Localization;
namespace BaseGames.World.Map
{
@@ -42,6 +43,17 @@ namespace BaseGames.World.Map
[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[]
@@ -74,6 +86,15 @@ namespace BaseGames.World.Map
private IMapService _mapSvc;
private IPlayerPositionProvider _playerProvider;
private IPinService _pinService;
private ITeleportService _teleportSvc;
/// <summary>
/// 玩家在地图上点击了一个"可传送"的已解锁站点(参数 RoomId
/// UI 侧MapTeleportConfirmController位于 BaseGames.UI 程序集)订阅此事件,
/// 弹出确认框并在确认后调用 ITeleportService.RequestTeleport——
/// MapPanel 自身不依赖 UI 程序集,避免与 BaseGames.UI 形成循环引用。
/// </summary>
public event Action<string> OnTeleportStationSelected;
private void Awake()
{
@@ -99,6 +120,7 @@ namespace BaseGames.World.Map
RenderPins();
UpdatePlayerIcon();
RefreshTeleportMarks(); // 每次打开刷新可传送标记(覆盖在站点新解锁后重开地图的场景)
CenterOnCurrentRoom();
// R12-N8移除 _onMapUpdated 订阅,避免与 OnExplorationChanged 双重刷新;
// _onMapUpdated 字段保留但标记 HideInInspector防止旧 Prefab 数据丢失。
@@ -144,10 +166,12 @@ namespace BaseGames.World.Map
_mapSvc.OnDatabaseChanged += OnDatabaseChanged;
_mapSvc.OnExplorationChanged += OnExplorationChanged;
_mapSvc.OnRoomMapped += OnRoomMappedAnim;
_mapSvc.OnLocatorChanged += OnLocatorChanged;
}
}
_playerProvider ??= ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
_pinService ??= ServiceLocator.GetOrDefault<IPinService>();
_teleportSvc ??= ServiceLocator.GetOrDefault<ITeleportService>(); // 可选:未注册时地图仍正常工作,仅不显示可传送标记
if (_mapSvc != null && _playerProvider != null && _pinService != null)
_servicesReady = true;
}
@@ -162,6 +186,7 @@ namespace BaseGames.World.Map
_mapSvc.OnDatabaseChanged -= OnDatabaseChanged;
_mapSvc.OnExplorationChanged -= OnExplorationChanged;
_mapSvc.OnRoomMapped -= OnRoomMappedAnim;
_mapSvc.OnLocatorChanged -= OnLocatorChanged;
}
}
@@ -190,6 +215,13 @@ namespace BaseGames.World.Map
RebuildAll();
}
/// <summary>定位能力开关变化:重置 dirty 标记强制 UpdatePlayerIcon 重新评估门控。</summary>
private void OnLocatorChanged()
{
_lastIconRoomId = null; // 绕过 LateUpdate 的 roomId/normPos 脏检查,确保门控状态立即生效
if (isActiveAndEnabled) UpdatePlayerIcon();
}
/// <summary>R10-N12 探索进度变化:仅刷新格子可见性,不重建结构(轻量级)。</summary>
private void OnExplorationChanged()
{
@@ -252,6 +284,27 @@ namespace BaseGames.World.Map
if (cell == null) continue;
cell.SetVisibility(_mapSvc.GetVisibility(roomId));
}
RefreshTeleportMarks(); // 探索状态影响 CanTeleportTo同步刷新可传送标记
}
// ── 传送选择 ──────────────────────────────────────────────────────────
/// <summary>房间是否可作为传送目的地:允许从地图传送 + 传送服务存在 + 已解锁且已探索。</summary>
private bool IsTeleportable(string roomId)
=> _allowTeleportFromMap && _teleportSvc != null && _teleportSvc.CanTeleportTo(roomId);
/// <summary>格子点击回调:仅对可传送站点转发选择事件,交由 UI 侧确认并执行传送。</summary>
private void OnCellClicked(string roomId)
{
if (IsTeleportable(roomId))
OnTeleportStationSelected?.Invoke(roomId);
}
/// <summary>刷新所有格子的"可传送"标记(探索/传送解锁变化、面板重开时调用)。</summary>
private void RefreshTeleportMarks()
{
foreach (var (roomId, cell) in _cells)
if (cell != null) cell.SetTeleportable(IsTeleportable(roomId));
}
// ── 格子 & 出口连接 ──────────────────────────────────────────────────
@@ -279,6 +332,8 @@ namespace BaseGames.World.Map
// 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();
@@ -343,6 +398,15 @@ namespace BaseGames.World.Map
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))
{
@@ -469,7 +533,8 @@ namespace BaseGames.World.Map
private void ShowTooltip(string text)
{
if (_tooltipPanel == null || string.IsNullOrEmpty(text)) return;
if (_tooltipText != null) _tooltipText.text = text;
// 房间名走本地化text 为本地化 Key 时解析为译文;为普通名称(未命中 Key时原样显示向后兼容
if (_tooltipText != null) _tooltipText.text = LocalizationManager.Get(text, LocalizationTable.UI);
_tooltipPanel.SetActive(true);
}

View File

@@ -1,6 +1,7 @@
using System;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.World.Map
{
@@ -29,6 +30,10 @@ namespace BaseGames.World.Map
"例如:关卡第一个房间的世界左下角在 (-36, -18) 时,此处填 (-36, -18)。")]
[SerializeField] private Vector2 _worldOriginOffset = Vector2.zero;
[Tooltip("玩家进入新房间时广播此频道EVT_RoomEnteredMapManager 据此标记已探索、" +
"RoomStreamingManager 重算流式集、EventChainManager 触发房间条件。留空则不广播。")]
[SerializeField] private StringEventChannelSO _onRoomEntered;
/// <summary>玩家当前所在房间 ID未在任何已知房间内时为 null。</summary>
public string CurrentRoomId { get; private set; }
@@ -101,7 +106,10 @@ namespace BaseGames.World.Map
_currentRoom = _database.GetRoom(newRoomId);
if (newRoomId != prevRoomId)
{
OnRoomChanged?.Invoke(newRoomId);
_onRoomEntered?.Raise(newRoomId); // 生产者:进房广播,驱动地图揭示/流式/事件链
}
}
// 每帧从世界坐标精确计算归一化位置,实现平滑图标跟随

View File

@@ -4,6 +4,7 @@ using TMPro;
using System.Collections.Generic;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Localization;
namespace BaseGames.World.Map
{
@@ -88,7 +89,7 @@ namespace BaseGames.World.Map
float progress = _mapSvc.GetExplorationProgress();
try
{
_globalProgressText.text = string.Format(_globalFormat, progress);
_globalProgressText.text = string.Format(ResolveFormat(_globalFormat, "{0:P0}"), progress);
}
catch (System.FormatException)
{
@@ -111,7 +112,7 @@ namespace BaseGames.World.Map
string regionDisplayName = ResolveRegionDisplayName(_currentRegionId);
try
{
_regionProgressText.text = string.Format(_regionFormat, regionProgress, regionDisplayName);
_regionProgressText.text = string.Format(ResolveFormat(_regionFormat, "{1} {0:P0}"), regionProgress, regionDisplayName);
}
catch (System.FormatException)
{
@@ -124,6 +125,21 @@ namespace BaseGames.World.Map
// ── 辅助方法 ──────────────────────────────────────────────────────────
/// <summary>
/// 将格式串字段解析为本地化格式串,并保证始终含 {0} 占位符:
/// ① 字段填本地化 Key如 "MAP_PROGRESS_GLOBAL")→ 返回本地化值(如 "已探索 {0:P0}"
/// ② 字段直接是格式串 → Get 原样返回(向后兼容);
/// ③ 本地化服务缺失 / Key 未命中(返回值无 {0})→ 回退到 <paramref name="fallback"/>
/// 确保即使本地化未就绪也不丢失百分比数值。
/// </summary>
private static string ResolveFormat(string keyOrFormat, string fallback)
{
if (string.IsNullOrEmpty(keyOrFormat)) return fallback;
string s = LocalizationManager.Get(keyOrFormat, LocalizationTable.UI);
// 检测 "{0"(兼容 "{0}"、"{0:P0}"、"{0:F1}" 等格式说明符);缺占位符视为未解析,用回退
return (!string.IsNullOrEmpty(s) && s.Contains("{0")) ? s : fallback;
}
/// <summary>预建 RegionId → Entry 字典O(1) 查询。</summary>
private void BuildRegionDict()
=> _regionDict = MapServiceExtensions.BuildRegionDict(_regionNames);

View File

@@ -15,13 +15,14 @@ namespace BaseGames.World.Map
/// 颜色通过 <see cref="SetColors"/> 从外部注入,不在此处硬编码。
/// <para><see cref="RT"/> 属性在 Awake 中缓存,避免调用方反复 GetComponent。</para>
/// </summary>
public class MapRoomCellUI : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
public class MapRoomCellUI : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler, IPointerClickHandler
{
[SerializeField] private Image _bg;
[SerializeField] private Image _icon;
[SerializeField] private RawImage _outlineImage; // 可选:房间非矩形轮廓纹理
[SerializeField] private Image _highlight; // 可选:当前房间高亮描边(玩家所在时激活)
[SerializeField] private Image _fogOverlay; // 可选未知房间雾效覆盖层R12-FD
[SerializeField] private Image _teleportMarker;// 可选可传送站点标记CanTeleportTo 为真时激活)
// 实例颜色(默认值与原硬编码保持一致);可通过 SetColors 统一覆盖
private Color _colExplored = Color.white;
@@ -30,8 +31,10 @@ namespace BaseGames.World.Map
private RoomVisibility _currentVisibility;
private string _displayName;
private string _roomId; // 供点击回调携带(传送选择)
private Action<string> _onHover;
private Action _onHoverExit;
private Action<string> _onClick; // 点击回调(传送选择),由 MapPanel 注入
/// <summary>格子的 RectTransformAwake 中缓存,外部直接访问无需 GetComponent。</summary>
public RectTransform RT { get; private set; }
@@ -47,6 +50,7 @@ namespace BaseGames.World.Map
Action<string> onHover = null, Action onHoverExit = null)
{
_displayName = room.DisplayName;
_roomId = room.RoomId;
_onHover = onHover;
_onHoverExit = onHoverExit;
@@ -113,6 +117,15 @@ namespace BaseGames.World.Map
if (_highlight != null) _highlight.enabled = v;
}
/// <summary>注入点击回调(携带 RoomId由 MapPanel 在创建格子时设置MinimapHUD 不设置即不可点击。</summary>
public void SetClickHandler(Action<string> onClick) => _onClick = onClick;
/// <summary>设置"可传送站点"标记显隐CanTeleportTo 为真时由 MapPanel 调用)。</summary>
public void SetTeleportable(bool v)
{
if (_teleportMarker != null) _teleportMarker.enabled = v;
}
/// <summary>
/// 新发现房间时播放闪白淡出动画R12-FC
/// 由 MapPanel.OnRoomMappedAnim 调用;协程安全:组件被销毁后 Unity 自动终止。
@@ -138,5 +151,8 @@ namespace BaseGames.World.Map
}
public void OnPointerExit(PointerEventData _) => _onHoverExit?.Invoke();
/// <summary>点击格子:转发 RoomId 给注入的回调MapPanel 据 CanTeleportTo 决定是否发起传送选择)。</summary>
public void OnPointerClick(PointerEventData _) => _onClick?.Invoke(_roomId);
}
}

View File

@@ -27,6 +27,12 @@ namespace BaseGames.World.Map
[SerializeField, Min(1)] private int _viewRadiusCells = 3; // 以玩家房间中心为圆心的可视半径(格)
[SerializeField] private float _cellPixels = 16f; // 每格显示像素数
[Header("定位门控(指南针)")]
[Tooltip("勾选后未启用定位能力IMapService.IsLocatorEnabled时隐藏玩家位置点——\n" +
"设计:拿到指南针护符前能看地图但不知自己在哪。\n" +
"默认 false 保持现有\"恒显\"行为,需要该玩法时再开启。")]
[SerializeField] private bool _requireLocatorForPlayerDot;
[Header("颜色Inspector 覆盖)")]
[SerializeField] private Color _colorExplored = Color.white;
[SerializeField] private Color _colorMapped = new Color(0.45f, 0.45f, 0.45f, 1f);
@@ -53,6 +59,12 @@ namespace BaseGames.World.Map
[SerializeField] private int[] _zoomLevels = { 2, 3, 5 }; // R12-FA 可用视野半径档位(格)
private int _zoomLevelIndex;
[Header("自动贴边(可选)")]
[Tooltip("勾选后,启用时把小地图定位到所在 Canvas 的右上角(按 _screenMargin 像素留边)。\n" +
"用于父节点非 RectTransform如普通 Transform 的 HUDRoot时锚定失效的情况保证运行时正确贴边。")]
[SerializeField] private bool _autoAnchorTopRight = true;
[SerializeField] private Vector2 _screenMargin = new Vector2(16f, 16f);
private IMapService _mapSvc;
private IPlayerPositionProvider _playerProvider;
private IPinService _pinService;
@@ -90,6 +102,8 @@ namespace BaseGames.World.Map
// 启动顺序兜底
SubscribeServices();
if (_autoAnchorTopRight) AnchorToCanvasTopRight();
// R10-N1/N2 应用关闭期间累积的状态变化
if (_databaseDirty)
{
@@ -142,6 +156,7 @@ namespace BaseGames.World.Map
{
_mapSvc.OnDatabaseChanged += OnDatabaseChanged;
_mapSvc.OnExplorationChanged += OnExplorationChanged;
_mapSvc.OnLocatorChanged += OnLocatorChanged;
}
}
_pinService ??= ServiceLocator.GetOrDefault<IPinService>();
@@ -163,6 +178,7 @@ namespace BaseGames.World.Map
{
_mapSvc.OnDatabaseChanged -= OnDatabaseChanged;
_mapSvc.OnExplorationChanged -= OnExplorationChanged;
_mapSvc.OnLocatorChanged -= OnLocatorChanged;
_mapSvc = null;
}
_pinService = null;
@@ -219,6 +235,13 @@ namespace BaseGames.World.Map
if (cell != null) cell.SetVisibility(_mapSvc.GetVisibility(id));
}
/// <summary>定位能力开关变化:重置 dirty 标记强制 UpdatePlayerDot 重新评估门控。</summary>
private void OnLocatorChanged()
{
_lastDotRoomId = null; // 绕过 UpdatePlayerDot 的 roomId/normPos 脏检查,确保门控状态立即生效
if (isActiveAndEnabled) UpdatePlayerDot();
}
/// <summary>数据库结构变更时完整重建:清空所有格子,下次 RefreshView 重新实例化。</summary>
private void OnDatabaseChanged()
{
@@ -386,6 +409,13 @@ namespace BaseGames.World.Map
{
if (_playerDot == null || _playerProvider == null) return;
// 定位门控:未启用定位能力时隐藏玩家点(由指南针护符解锁)
if (_requireLocatorForPlayerDot && (_mapSvc == null || !_mapSvc.IsLocatorEnabled))
{
_playerDot.enabled = false;
return;
}
var roomId = _playerProvider.CurrentRoomId;
var normPos = _playerProvider.NormalizedPositionInRoom;
@@ -429,5 +459,26 @@ namespace BaseGames.World.Map
_viewDirty = true;
}
}
// ── 自动贴边 ──────────────────────────────────────────────────────────
/// <summary>
/// 将小地图定位到所在 Canvas 的右上角pivot 设为右上position 设到画布右上角内缩 _screenMargin
/// 不依赖父节点为 RectTransform——直接用世界坐标定位规避父节点为普通 Transform 时锚定失效的问题。
/// </summary>
private void AnchorToCanvasTopRight()
{
var canvas = GetComponentInParent<Canvas>();
if (canvas == null) return;
if (canvas.transform is not RectTransform canvasRt) return;
if (transform is not RectTransform rt) return;
var corners = new Vector3[4];
canvasRt.GetWorldCorners(corners); // 0=BL, 1=TL, 2=TR, 3=BR
rt.pivot = new Vector2(1f, 1f);
rt.position = new Vector3(corners[2].x - _screenMargin.x,
corners[2].y - _screenMargin.y,
rt.position.z);
}
}
}

View File

@@ -0,0 +1,40 @@
using UnityEngine;
using BaseGames.Player;
namespace BaseGames.World.Map
{
/// <summary>
/// 区域定义 SO。集中管理区域元数据Identity、地图展示、存档槽背景图、解锁条件。
///
/// 房间归属关系由 <see cref="MapRoomDataSO.RegionId"/> 维护(单一权威来源)。
/// <see cref="RegionRegistrySO"/> 运行时从 <see cref="MapDatabaseSO"/> 构建 SceneName → Region 缓存,
/// 不再在此 SO 维护 roomSceneNames 冗余字段。
///
/// 资产路径: Assets/_Game/Data/Map/Regions/Region_{regionId}.asset
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Map/RegionDefinition", fileName = "Region_")]
public class RegionDefinitionSO : ScriptableObject
{
[Header("Identity")]
public string regionId;
public string displayName;
[Header("Map")]
public Color mapColor;
public Sprite mapIconSprite;
[Header("存档槽展示")]
[Tooltip("存档槽卡片背景图。建议 480×270放于 Assets/_Game/Art/UI/SaveSlot/ 下,命名 SaveSlot_BG_{regionId}.png。")]
public Sprite saveSlotBackground;
[Header("解锁条件")]
[Tooltip("击败指定 Boss 后解锁此区域;留空 = 无条件。")]
public string requiredBossDefeated;
[Tooltip("需持有指定能力方可进入None = 无要求。")]
public AbilityType requiredAbility;
[Header("导航")]
[Tooltip("从区域外部进入时的首个房间场景名RoomId。")]
public string entrySceneName;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bfbb6453efee45c4fbe6f3b6b2cd463b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,76 @@
using System.Collections.Generic;
using UnityEngine;
namespace BaseGames.World.Map
{
/// <summary>
/// 全局区域注册表 SO。
///
/// 设计原则:<see cref="MapRoomDataSO.RegionId"/> 是房间归属的单一权威来源。
/// 本 SO 持有所有 <see cref="RegionDefinitionSO"/> 资产引用,运行时从
/// <see cref="MapDatabaseSO"/> 惰性构建 SceneName → Region 缓存,
/// 消除了原 RegionDefinitionSO.roomSceneNames 与 MapRoomDataSO.RegionId 的双重冗余。
///
/// 资产路径Assets/_Game/Data/Map/RegionRegistry.asset
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Map/RegionRegistry", fileName = "RegionRegistry")]
public class RegionRegistrySO : ScriptableObject
{
[Tooltip("项目中所有 RegionDefinitionSO 资产。新增区域 SO 时同步添加到此数组。")]
[SerializeField] private RegionDefinitionSO[] _regions;
[Tooltip("地图数据库,用于从 MapRoomDataSO.RegionId 构建 SceneName → Region 缓存(单一权威来源)。")]
[SerializeField] private MapDatabaseSO _mapDatabase;
private Dictionary<string, RegionDefinitionSO> _regionById;
private Dictionary<string, RegionDefinitionSO> _sceneToRegion;
/// <summary>根据场景名MapRoomDataSO.RoomId查找所属区域未找到返回 null。</summary>
public RegionDefinitionSO FindBySceneName(string sceneName)
{
if (string.IsNullOrEmpty(sceneName)) return null;
BuildCacheIfNeeded();
_sceneToRegion.TryGetValue(sceneName, out var region);
return region;
}
/// <summary>根据 regionId 直接查找;未找到返回 null。</summary>
public RegionDefinitionSO FindById(string regionId)
{
if (string.IsNullOrEmpty(regionId)) return null;
BuildCacheIfNeeded();
_regionById.TryGetValue(regionId, out var region);
return region;
}
private void BuildCacheIfNeeded()
{
if (_sceneToRegion != null) return;
// Step 1: regionId → RegionDefinitionSO
_regionById = new Dictionary<string, RegionDefinitionSO>(System.StringComparer.OrdinalIgnoreCase);
if (_regions != null)
foreach (var r in _regions)
if (r != null && !string.IsNullOrEmpty(r.regionId))
_regionById[r.regionId] = r;
// Step 2: sceneName → RegionDefinitionSO以 MapRoomDataSO.RegionId 为权威来源
_sceneToRegion = new Dictionary<string, RegionDefinitionSO>(System.StringComparer.OrdinalIgnoreCase);
if (_mapDatabase?.AllRooms == null) return;
foreach (var room in _mapDatabase.AllRooms)
{
if (room == null || string.IsNullOrEmpty(room.RoomId) || string.IsNullOrEmpty(room.RegionId))
continue;
if (_regionById.TryGetValue(room.RegionId, out var def))
_sceneToRegion[room.RoomId] = def;
}
}
private void OnValidate()
{
_sceneToRegion = null;
_regionById = null;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b8854f90900839747b904fcd1515774d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -2,6 +2,7 @@
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
namespace BaseGames.World.Map
@@ -22,8 +23,13 @@ namespace BaseGames.World.Map
private IMapService _mapSvc;
private IPlayerPositionProvider _playerProvider;
private ISceneService _sceneSvc;
private bool _isDuplicate;
// 传送进行中状态:防止重入,并在玩家到达目标房间后触发 NotifyTeleportCompleted
private bool _teleportInProgress;
private string _pendingTargetRoomId;
// ── ITeleportService 事件 ─────────────────────────────────────────────
/// <inheritdoc/>
@@ -67,6 +73,9 @@ namespace BaseGames.World.Map
private void OnDestroy()
{
if (_isDuplicate) return;
// 传送途中销毁(如场景重建)时解绑,避免悬挂订阅
if (_playerProvider != null)
_playerProvider.OnRoomChanged -= OnArrivedRoomChanged;
ServiceLocator.Unregister<ITeleportService>(this);
}
@@ -91,10 +100,63 @@ namespace BaseGames.World.Map
Debug.LogWarning($"[TeleportService] 无法传送到 '{targetRoomId}':未解锁或未探索。");
return;
}
if (_teleportInProgress)
{
Debug.LogWarning("[TeleportService] 传送进行中,忽略新的传送请求。");
return;
}
_playerProvider ??= ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
// 使用当前房间 ID 作为传送来源(与接口参数语义一致)
string sourceRoomId = _playerProvider?.CurrentRoomId ?? string.Empty;
// 通知 UI 播放关闭/淡出动画地图面板、HUD 等)
OnTeleportRequested?.Invoke(sourceRoomId, targetRoomId);
// 传送到当前所在房间:无需加载场景,直接收尾
if (targetRoomId == sourceRoomId)
{
NotifyTeleportCompleted(targetRoomId);
return;
}
// 通过通用场景过渡入口执行实际加载Scene 类型 = 完整淡出 + 加载画面)。
// 流式系统已注册时SceneService 会委托 ISceneLoadCoordinator 走房间生命周期。
_sceneSvc ??= ServiceLocator.GetOrDefault<ISceneService>();
if (_sceneSvc == null)
{
Debug.LogWarning("[TeleportService] ISceneService 未注册,无法执行传送场景加载。");
return;
}
_pendingTargetRoomId = targetRoomId;
_teleportInProgress = true;
// 监听玩家到达目标房间 → 触发 NotifyTeleportCompleted一次性OnArrivedRoomChanged 内解绑)
if (_playerProvider != null)
_playerProvider.OnRoomChanged += OnArrivedRoomChanged;
_sceneSvc.RequestTransition(new SceneLoadRequest
{
SceneName = targetRoomId, // RoomId 即场景 Addressable key"Room_" 前缀,见 ISceneLoadCoordinator
EntryTransitionId = null, // 默认出生点;精确落到传送站点为后续增强项
TransitionType = TransitionType.Scene, // 完整淡出 + 加载画面,符合快速旅行演出
ShowLoadingScreen = true,
IsRespawn = false,
});
}
/// <summary>
/// 传送途中监听玩家房间变化:到达目标房间后解绑并触发 <see cref="OnTeleportCompleted"/>。
/// </summary>
private void OnArrivedRoomChanged(string arrivedRoomId)
{
if (arrivedRoomId != _pendingTargetRoomId) return; // 仅在到达目标房间时收尾
if (_playerProvider != null)
_playerProvider.OnRoomChanged -= OnArrivedRoomChanged;
_teleportInProgress = false;
_pendingTargetRoomId = null;
NotifyTeleportCompleted(arrivedRoomId);
}
/// <summary>

View File

@@ -0,0 +1,108 @@
using UnityEngine;
using UnityEngine.Events;
using BaseGames.Core;
using BaseGames.Player;
using BaseGames.World;
namespace BaseGames.World.Map
{
/// <summary>
/// 世界中的传送站/驿站交互点(快速旅行网络节点)。
/// <para>
/// 玩家交互时:① 通过 <see cref="ITeleportService.UnlockTeleportStation"/> 将本房间注册为
/// 快速旅行节点(持久化到存档);② 若玩家已解锁 <see cref="AbilityType.FastTravel"/>
/// 则触发 <see cref="_onTravelMenuRequested"/>(由设计师在 Inspector 中接到地图/传送选择 UI
/// 否则触发 <see cref="_onActivatedWithoutTravel"/>(如显示"快速旅行网络尚未激活"提示)。
/// </para>
/// <para>
/// 本组件只负责"解锁节点 + 请求打开 UI",不持有具体 UI 引用,保持架构解耦——
/// 实际传送由 <see cref="ITeleportService.RequestTeleport"/> 在目的地选择 UI 中调用。
/// </para>
/// </summary>
[RequireComponent(typeof(Collider2D))]
public class TeleportStationTrigger : MonoBehaviour, IInteractable
{
[Header("传送站标识")]
[Tooltip("本传送站所属房间 ID应与场景名/MapRoomDataSO.RoomId 一致)。\n" +
"留空时回退到 IPlayerPositionProvider.CurrentRoomId玩家当前所在房间。")]
[SerializeField] private string _stationRoomId;
[Tooltip("交互提示文字(互动系统读取)。")]
[SerializeField] private string _interactPrompt = "使用传送站";
[Header("能力门控")]
[Tooltip("勾选后,仅当玩家已解锁 FastTravel 能力时才触发传送菜单;\n" +
"节点的\"解锁/注册\"不受此限制(发现即记录:先点亮站点、后接通网络)。")]
[SerializeField] private bool _requireFastTravelAbility = true;
[Header("回调(接入 UI / 演出)")]
[Tooltip("成功解锁本站且满足能力门控时触发——接到\"打开地图/传送选择 UI\"。")]
[SerializeField] private UnityEvent _onTravelMenuRequested;
[Tooltip("已解锁本站但快速旅行能力未解锁时触发——接到提示文字/音效等。")]
[SerializeField] private UnityEvent _onActivatedWithoutTravel;
// ── IInteractable ─────────────────────────────────────────────────────
public bool CanInteract => true;
public string InteractPrompt => _interactPrompt;
public void Interact(Transform player)
{
var teleportSvc = ServiceLocator.GetOrDefault<ITeleportService>();
if (teleportSvc == null)
{
Debug.LogWarning("[TeleportStationTrigger] ITeleportService 未注册,无法解锁传送站。", this);
return;
}
string roomId = ResolveRoomId();
if (string.IsNullOrEmpty(roomId))
{
Debug.LogWarning("[TeleportStationTrigger] 无法解析传送站房间 ID未配置且玩家不在已知房间内。", this);
return;
}
// 发现即注册:解锁节点并持久化(幂等,重复激活无副作用)
teleportSvc.UnlockTeleportStation(roomId);
// 能力门控:决定是否打开传送选择菜单
if (_requireFastTravelAbility && !PlayerHasFastTravel(player))
{
_onActivatedWithoutTravel?.Invoke();
return;
}
_onTravelMenuRequested?.Invoke();
}
public void OnPlayerEnterRange(Transform player) { }
public void OnPlayerExitRange() { }
// ── 辅助 ──────────────────────────────────────────────────────────────
/// <summary>解析本站房间 ID优先序列化字段回退到玩家当前房间。</summary>
private string ResolveRoomId()
{
if (!string.IsNullOrEmpty(_stationRoomId)) return _stationRoomId;
return ServiceLocator.GetOrDefault<IPlayerPositionProvider>()?.CurrentRoomId;
}
/// <summary>检查玩家是否已解锁快速旅行能力(从玩家 Transform 取 PlayerStats。</summary>
private static bool PlayerHasFastTravel(Transform player)
{
if (player == null) return false;
var stats = player.GetComponentInParent<PlayerStats>();
// PlayerStats 不可用时 Fail-Open放行打开菜单避免因引用缺失卡死交互
return stats == null || stats.HasAbility(AbilityType.FastTravel);
}
private void OnDrawGizmos()
{
var col = GetComponent<Collider2D>();
if (col == null) return;
Gizmos.color = new Color(0.3f, 0.7f, 1f, 0.6f); // 浅蓝:传送站
Gizmos.DrawWireCube(transform.position, col.bounds.size);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 85d9028e7948476449c679941ce8baf2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: