角色能力,存档
This commit is contained in:
62
Assets/_Game/Scripts/World/CheckpointMarker.cs
Normal file
62
Assets/_Game/Scripts/World/CheckpointMarker.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 检查点标记。放置于跳跳乐段落或危险区域的关键节点处。
|
||||
/// 玩家经过时自动激活,成为 LethalTrap 回溯的目标位置。
|
||||
///
|
||||
/// 设计要点:
|
||||
/// - 同一房间可放置多个,取最近经过的一个
|
||||
/// - 激活状态仅存于运行时,不持久化,换房间后重置
|
||||
/// - 每个 CheckpointMarker 仅激活一次(重复进入同一触发区无效)
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class CheckpointMarker : MonoBehaviour
|
||||
{
|
||||
[Header("检测")]
|
||||
[Tooltip("勾选 Player 层")]
|
||||
[SerializeField] private LayerMask _playerLayers;
|
||||
|
||||
[Header("事件")]
|
||||
[Tooltip("EVT_CheckpointReached — 激活时广播,供 VFX/SFX 使用")]
|
||||
[SerializeField] private VoidEventChannelSO _onCheckpointReached;
|
||||
|
||||
private bool _isActivated;
|
||||
|
||||
/// <summary>当前运行时是否已被激活。</summary>
|
||||
public bool IsActivated => _isActivated;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
var col = GetComponent<Collider2D>();
|
||||
if (!col.isTrigger)
|
||||
{
|
||||
col.isTrigger = true;
|
||||
Debug.LogWarning($"[CheckpointMarker] {name}: Collider2D.isTrigger 已自动设为 true。", this);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (_isActivated) return;
|
||||
if ((_playerLayers.value & (1 << other.gameObject.layer)) == 0) return;
|
||||
|
||||
_isActivated = true;
|
||||
ServiceLocator.GetOrDefault<ICheckpointService>()?.RegisterCheckpoint(transform.position);
|
||||
_onCheckpointReached?.Raise();
|
||||
}
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
Gizmos.color = _isActivated
|
||||
? new Color(0f, 1f, 0.3f, 0.9f)
|
||||
: new Color(0.8f, 0.8f, 0f, 0.6f);
|
||||
Gizmos.DrawWireSphere(transform.position, 0.4f);
|
||||
Gizmos.DrawLine(transform.position + Vector3.down * 0.4f,
|
||||
transform.position + Vector3.up * 0.8f);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/World/CheckpointMarker.cs.meta
Normal file
11
Assets/_Game/Scripts/World/CheckpointMarker.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 81b8a6464ad9a90459fa28f2a0e1ce02
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -11,7 +11,8 @@
|
||||
"BaseGames.World",
|
||||
"BaseGames.Core",
|
||||
"BaseGames.Core.Save",
|
||||
"BaseGames.Core.Events"
|
||||
"BaseGames.Core.Events",
|
||||
"Unity.TextMeshPro"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"overrideReferences": false,
|
||||
|
||||
@@ -83,10 +83,13 @@ namespace BaseGames.World.Map
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>标记为已完整获取地图信息(购买 MapFragment SO 触发)。</summary>
|
||||
/// <summary>
|
||||
/// 标记为已获取地图碎片信息(购买 MapFragment SO 触发)。
|
||||
/// 仅写入 _mappedRooms;若玩家尚未亲自踏入该房间则显示为灰色轮廓(Mapped),
|
||||
/// 亲自踏入后(Explored)优先级更高,显示为白色。
|
||||
/// </summary>
|
||||
public void SetMapped(string roomId)
|
||||
{
|
||||
_exploredRooms.Add(roomId);
|
||||
if (_mappedRooms.Add(roomId))
|
||||
_onMapUpdated?.Raise(roomId);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.EventSystems;
|
||||
using TMPro;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
/// <summary>房间可见性三级状态:未知 / 已踏入 / 已标注(购买地图碎片)。</summary>
|
||||
public enum RoomVisibility { Unknown, Explored, Mapped }
|
||||
|
||||
/// <summary>
|
||||
/// 全屏地图 UI 面板(架构 15_MapShopModule §1.3)。
|
||||
/// 由 UIManager PanelStack 管理开关;OnEnable 时重建格子并订阅更新事件。
|
||||
@@ -13,8 +20,10 @@ namespace BaseGames.World.Map
|
||||
public class MapPanel : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private MapDatabaseSO _database;
|
||||
[SerializeField] private RectTransform _roomContainer; // 格子图放置根节点
|
||||
[SerializeField] private MapRoomCellUI _cellPrefab; // 地图格子预制
|
||||
[SerializeField] private RectTransform _roomContainer; // 格子图放置根节点
|
||||
[SerializeField] private MapRoomCellUI _cellPrefab; // 地图格子预制
|
||||
[SerializeField] private Image _exitConnectorPrefab; // 出口连接线预制(小矩形 Image)
|
||||
[SerializeField] private ScrollRect _scrollRect; // 包裹 _roomContainer 的滚动矩形(可空,无滚动时留空)
|
||||
|
||||
[Header("图标 Sprites")]
|
||||
[SerializeField] private Sprite _iconSavePoint;
|
||||
@@ -23,73 +32,241 @@ namespace BaseGames.World.Map
|
||||
[SerializeField] private Sprite _iconPlayerPos;
|
||||
|
||||
[Header("颜色")]
|
||||
[SerializeField] private Color _colorDiscovered = Color.white;
|
||||
[SerializeField] private Color _colorUndiscovered = Color.black;
|
||||
[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 MapPlayerTracker _playerTracker; // 挂在 Player 上的追踪器
|
||||
[SerializeField] private Image _playerIconImg; // _roomContainer 内的玩家图标
|
||||
|
||||
[Header("地图标记")]
|
||||
[SerializeField] private Image _pinPrefab;
|
||||
[SerializeField] private PinSpriteEntry[] _pinSprites; // PinType → 对应 Sprite
|
||||
[SerializeField] private MapPinManager _pinManager;
|
||||
|
||||
[Header("Tooltip")]
|
||||
[SerializeField] private GameObject _tooltipPanel;
|
||||
[SerializeField] private TMP_Text _tooltipText;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private StringEventChannelSO _onMapUpdated; // 房间发现时刷新
|
||||
[SerializeField] private StringEventChannelSO _onMapUpdated; // 房间发现/标注时刷新
|
||||
|
||||
private Dictionary<string, MapRoomCellUI> _cells = new();
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
private Dictionary<string, MapRoomCellUI> _cells = new();
|
||||
private List<Image> _pinImages = new();
|
||||
private List<Image> _exitImages = new();
|
||||
private string _highlightedRoomId;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// 首次打开时建立格子;后续打开只刷新探索状态,跳过 N 次 Instantiate
|
||||
// 首次打开时建立格子;后续打开只刷新探索状态,跳过重复 Instantiate
|
||||
if (_cells.Count == 0)
|
||||
BuildGrid();
|
||||
else
|
||||
RefreshAllCells();
|
||||
|
||||
RenderPins();
|
||||
UpdatePlayerIcon();
|
||||
CenterOnCurrentRoom();
|
||||
_onMapUpdated?.Subscribe(OnMapUpdated).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
// 格子保留,不销毁——随面板 GameObject 一同隐藏
|
||||
HideTooltip();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
// 面板真正销毁时才清理格子
|
||||
foreach (var cell in _cells.Values)
|
||||
if (cell != null) Destroy(cell.gameObject);
|
||||
_cells.Clear();
|
||||
ClearPins();
|
||||
ClearExits();
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
UpdatePlayerIcon();
|
||||
}
|
||||
|
||||
// 面板重新打开时同步关闭期间积累的探索进度
|
||||
private void RefreshAllCells()
|
||||
{
|
||||
var mapManager = ServiceLocator.GetOrDefault<IMapService>();
|
||||
var svc = ServiceLocator.GetOrDefault<IMapService>();
|
||||
foreach (var (roomId, cell) in _cells)
|
||||
{
|
||||
if (cell == null) continue;
|
||||
bool discovered = mapManager != null && mapManager.IsExplored(roomId);
|
||||
cell.SetDiscovered(discovered);
|
||||
cell.SetVisibility(GetVisibility(svc, roomId));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────────────────────────────
|
||||
// ── 格子 & 出口连接 ──────────────────────────────────────────────────
|
||||
|
||||
private void BuildGrid()
|
||||
{
|
||||
if (_database == null || _database.AllRooms == null) return;
|
||||
|
||||
var mapManager = ServiceLocator.GetOrDefault<IMapService>();
|
||||
if (_database?.AllRooms == null) return;
|
||||
var svc = ServiceLocator.GetOrDefault<IMapService>();
|
||||
foreach (var room in _database.AllRooms)
|
||||
{
|
||||
if (room == null) continue;
|
||||
var cell = Instantiate(_cellPrefab, _roomContainer);
|
||||
bool discovered = mapManager != null && mapManager.IsExplored(room.RoomId);
|
||||
cell.Setup(room, discovered, ChooseIcon(room));
|
||||
cell.Setup(room, GetVisibility(svc, room.RoomId), ChooseIcon(room),
|
||||
ShowTooltip, HideTooltip);
|
||||
_cells[room.RoomId] = cell;
|
||||
}
|
||||
DrawExits();
|
||||
}
|
||||
|
||||
/// <summary>为每条出口在格子坐标处实例化一个小矩形连接线图像。</summary>
|
||||
private void DrawExits()
|
||||
{
|
||||
if (_exitConnectorPrefab == null || _database?.AllRooms == null) return;
|
||||
ClearExits();
|
||||
const float px = 32f;
|
||||
foreach (var room in _database.AllRooms)
|
||||
{
|
||||
if (room?.Exits == null) continue;
|
||||
foreach (var exit in room.Exits)
|
||||
{
|
||||
var conn = Instantiate(_exitConnectorPrefab, _roomContainer);
|
||||
conn.rectTransform.anchoredPosition = new Vector2(exit.ExitGridPos.x * px, exit.ExitGridPos.y * px);
|
||||
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()
|
||||
{
|
||||
foreach (var img in _exitImages)
|
||||
if (img != null) Destroy(img.gameObject);
|
||||
_exitImages.Clear();
|
||||
}
|
||||
|
||||
private void OnMapUpdated(string roomId)
|
||||
{
|
||||
var svc = ServiceLocator.GetOrDefault<IMapService>();
|
||||
if (_cells.TryGetValue(roomId, out var cell))
|
||||
cell.SetDiscovered(true);
|
||||
cell.SetVisibility(GetVisibility(svc, roomId));
|
||||
}
|
||||
|
||||
// ── 玩家位置图标 ──────────────────────────────────────────────────────
|
||||
|
||||
private void UpdatePlayerIcon()
|
||||
{
|
||||
if (_playerIconImg == null || _playerTracker == null) return;
|
||||
var roomId = _playerTracker.CurrentRoomId;
|
||||
if (string.IsNullOrEmpty(roomId) || !_cells.TryGetValue(roomId, out var cell))
|
||||
{
|
||||
_playerIconImg.enabled = false;
|
||||
UpdateCellHighlight(null);
|
||||
return;
|
||||
}
|
||||
var cellRT = cell.GetComponent<RectTransform>();
|
||||
_playerIconImg.sprite = _iconPlayerPos;
|
||||
_playerIconImg.enabled = true;
|
||||
_playerIconImg.rectTransform.anchoredPosition =
|
||||
cellRT.anchoredPosition
|
||||
+ Vector2.Scale(_playerTracker.NormalizedPositionInRoom, cellRT.sizeDelta);
|
||||
UpdateCellHighlight(roomId);
|
||||
}
|
||||
// ── 当前房间高亮 & ScrollRect 居中 ──────────────────────────────────────
|
||||
|
||||
/// <summary>切换高亮描边:取消旧房间高亮,激活新房间高亮。</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>面板打开时将 ScrollRect 视口居中到玩家当前所在房间。</summary>
|
||||
private void CenterOnCurrentRoom()
|
||||
{
|
||||
if (_scrollRect == null || _playerTracker == null) return;
|
||||
var roomId = _playerTracker.CurrentRoomId;
|
||||
if (string.IsNullOrEmpty(roomId) || !_cells.TryGetValue(roomId, out var cell)) return;
|
||||
|
||||
Canvas.ForceUpdateCanvases();
|
||||
|
||||
var content = _scrollRect.content;
|
||||
var viewport = _scrollRect.viewport != null
|
||||
? _scrollRect.viewport
|
||||
: (RectTransform)_scrollRect.transform;
|
||||
var cellRT = cell.GetComponent<RectTransform>();
|
||||
|
||||
// 将 cell 中心转换到 content 本地坐标系
|
||||
Vector2 cellWorldCenter = cellRT.TransformPoint(cellRT.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;
|
||||
float rangeX = contentSize.x - viewSize.x;
|
||||
float rangeY = contentSize.y - 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;
|
||||
|
||||
_scrollRect.normalizedPosition = new Vector2(normX, normY);
|
||||
}
|
||||
// ── 地图标记渲染 ──────────────────────────────────────────────────────
|
||||
|
||||
private void RenderPins()
|
||||
{
|
||||
ClearPins();
|
||||
if (_pinPrefab == null || _pinManager == null) return;
|
||||
foreach (var pin in _pinManager.Pins)
|
||||
{
|
||||
if (!_cells.TryGetValue(pin.RoomId, out var cell)) continue;
|
||||
var img = Instantiate(_pinPrefab, _roomContainer);
|
||||
img.sprite = GetPinSprite((PinType)pin.PinTypeInt);
|
||||
var cellRT = cell.GetComponent<RectTransform>();
|
||||
img.rectTransform.anchoredPosition =
|
||||
cellRT.anchoredPosition + new Vector2(
|
||||
pin.NormalizedPosX * cellRT.sizeDelta.x,
|
||||
pin.NormalizedPosY * cellRT.sizeDelta.y);
|
||||
_pinImages.Add(img);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearPins()
|
||||
{
|
||||
foreach (var img in _pinImages)
|
||||
if (img != null) Destroy(img.gameObject);
|
||||
_pinImages.Clear();
|
||||
}
|
||||
|
||||
// ── Tooltip ───────────────────────────────────────────────────────────
|
||||
|
||||
private void ShowTooltip(string text)
|
||||
{
|
||||
if (_tooltipPanel == null || string.IsNullOrEmpty(text)) return;
|
||||
if (_tooltipText != null) _tooltipText.text = text;
|
||||
_tooltipPanel.SetActive(true);
|
||||
}
|
||||
|
||||
private void HideTooltip() => _tooltipPanel?.SetActive(false);
|
||||
|
||||
// ── 辅助方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>按优先级推导可见性:Explored > Mapped > Unknown。</summary>
|
||||
private static RoomVisibility GetVisibility(IMapService svc, string roomId)
|
||||
{
|
||||
if (svc == null) return RoomVisibility.Unknown;
|
||||
if (svc.IsExplored(roomId)) return RoomVisibility.Explored;
|
||||
if (svc.IsMapped(roomId)) return RoomVisibility.Mapped;
|
||||
return RoomVisibility.Unknown;
|
||||
}
|
||||
|
||||
private Sprite ChooseIcon(MapRoomDataSO room)
|
||||
@@ -100,34 +277,63 @@ namespace BaseGames.World.Map
|
||||
if (room.IsShop) return _iconShop;
|
||||
return null;
|
||||
}
|
||||
|
||||
private Sprite GetPinSprite(PinType type)
|
||||
{
|
||||
if (_pinSprites != null)
|
||||
foreach (var e in _pinSprites)
|
||||
if (e.PinType == type) return e.Sprite;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct PinSpriteEntry
|
||||
{
|
||||
public PinType PinType;
|
||||
public Sprite Sprite;
|
||||
}
|
||||
|
||||
// ─── 单个地图格子 UI ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>地图面板中每个房间对应的格子 UI 组件。</summary>
|
||||
public class MapRoomCellUI : MonoBehaviour
|
||||
public class MapRoomCellUI : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
|
||||
{
|
||||
[SerializeField] private Image _bg;
|
||||
[SerializeField] private Image _icon;
|
||||
[SerializeField] private Image _bg;
|
||||
[SerializeField] private Image _icon;
|
||||
[SerializeField] private RawImage _outlineImage; // 可选:房间非矩形轮廓纹理
|
||||
[SerializeField] private Image _highlight; // 可选:当前房间高亮描边(玩家所在时激活)
|
||||
|
||||
private static readonly Color Discovered = Color.white;
|
||||
private static readonly Color Undiscovered = Color.black;
|
||||
private static readonly Color ColExplored = Color.white;
|
||||
private static readonly Color ColMapped = new Color(0.45f, 0.45f, 0.45f, 1f);
|
||||
private static readonly Color ColUnknown = Color.black;
|
||||
|
||||
/// <summary>初始化格子(位置、颜色、图标)。</summary>
|
||||
public void Setup(MapRoomDataSO room, bool discovered, Sprite icon)
|
||||
private string _displayName;
|
||||
private Action<string> _onHover;
|
||||
private Action _onHoverExit;
|
||||
|
||||
/// <summary>初始化格子(位置、可见性、图标、Tooltip 回调)。</summary>
|
||||
public void Setup(MapRoomDataSO room, RoomVisibility visibility, Sprite icon,
|
||||
Action<string> onHover = null, Action onHoverExit = null)
|
||||
{
|
||||
// 根据 GridPosition/GridSize 设置 RectTransform 位置与大小
|
||||
_displayName = room.DisplayName;
|
||||
_onHover = onHover;
|
||||
_onHoverExit = onHoverExit;
|
||||
|
||||
if (TryGetComponent<RectTransform>(out var rt))
|
||||
{
|
||||
rt.anchoredPosition = new Vector2(
|
||||
room.GridPosition.x * 32f,
|
||||
room.GridPosition.y * 32f);
|
||||
rt.sizeDelta = new Vector2(
|
||||
room.GridSize.x * 32f,
|
||||
room.GridSize.y * 32f);
|
||||
rt.anchoredPosition = new Vector2(room.GridPosition.x * 32f, room.GridPosition.y * 32f);
|
||||
rt.sizeDelta = new Vector2(room.GridSize.x * 32f, room.GridSize.y * 32f);
|
||||
}
|
||||
|
||||
SetDiscovered(discovered);
|
||||
// 房间轮廓纹理(非矩形形状,覆盖在矩形背景上方)
|
||||
if (_outlineImage != null)
|
||||
{
|
||||
_outlineImage.texture = room.RoomOutlineTex;
|
||||
_outlineImage.enabled = room.RoomOutlineTex != null;
|
||||
}
|
||||
|
||||
SetVisibility(visibility);
|
||||
|
||||
if (_icon != null)
|
||||
{
|
||||
@@ -136,9 +342,32 @@ namespace BaseGames.World.Map
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDiscovered(bool v)
|
||||
public void SetVisibility(RoomVisibility v)
|
||||
{
|
||||
if (_bg != null) _bg.color = v ? Discovered : Undiscovered;
|
||||
if (_bg == null) return;
|
||||
_bg.color = v switch
|
||||
{
|
||||
RoomVisibility.Explored => ColExplored,
|
||||
RoomVisibility.Mapped => ColMapped,
|
||||
_ => ColUnknown,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>向后兼容:直接传 bool 时等同于 Explored / Unknown。</summary>
|
||||
public void SetDiscovered(bool v)
|
||||
=> SetVisibility(v ? RoomVisibility.Explored : RoomVisibility.Unknown);
|
||||
|
||||
/// <summary>激活/取消当前房间高亮描边。</summary>
|
||||
public void SetHighlight(bool v)
|
||||
{
|
||||
if (_highlight != null) _highlight.enabled = v;
|
||||
}
|
||||
|
||||
public void OnPointerEnter(PointerEventData _)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_displayName)) _onHover?.Invoke(_displayName);
|
||||
}
|
||||
|
||||
public void OnPointerExit(PointerEventData _) => _onHoverExit?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
104
Assets/_Game/Scripts/World/Map/RegionNameDisplay.cs
Normal file
104
Assets/_Game/Scripts/World/Map/RegionNameDisplay.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.World.Map
|
||||
{
|
||||
/// <summary>
|
||||
/// 进入新区域时在屏幕中央短暂渐显区域名称(架构 15_MapShopModule §1.6)。
|
||||
/// 挂在 HUD 根节点下,订阅 EVT_RegionChanged,执行淡入—保持—淡出动画序列。
|
||||
/// 可通过 _regionNames 配置原始 RegionId 到本地化显示名的映射;未配置时直接显示 RegionId。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(CanvasGroup))]
|
||||
public class RegionNameDisplay : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Text _regionText;
|
||||
|
||||
[Header("动画时长(秒)")]
|
||||
[SerializeField] [Range(0.1f, 2f)] private float _fadeDuration = 0.4f;
|
||||
[SerializeField] [Range(0.5f, 5f)] private float _holdDuration = 2.0f;
|
||||
|
||||
[Header("区域名映射(留空则直接显示 RegionId)")]
|
||||
[SerializeField] private RegionNameEntry[] _regionNames;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private StringEventChannelSO _onRegionChanged;
|
||||
|
||||
private CanvasGroup _cg;
|
||||
private Coroutine _showCoroutine;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_cg = GetComponent<CanvasGroup>();
|
||||
_cg.alpha = 0f;
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onRegionChanged?.Subscribe(OnRegionChanged).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
}
|
||||
|
||||
// ── 事件响应 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void OnRegionChanged(string regionId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(regionId)) return;
|
||||
if (_regionText != null)
|
||||
_regionText.text = ResolveDisplayName(regionId);
|
||||
|
||||
if (_showCoroutine != null) StopCoroutine(_showCoroutine);
|
||||
_showCoroutine = StartCoroutine(ShowSequence());
|
||||
}
|
||||
|
||||
// ── 动画序列 ──────────────────────────────────────────────────────────
|
||||
|
||||
private IEnumerator ShowSequence()
|
||||
{
|
||||
gameObject.SetActive(true);
|
||||
yield return StartCoroutine(FadeTo(1f));
|
||||
yield return new WaitForSecondsRealtime(_holdDuration);
|
||||
yield return StartCoroutine(FadeTo(0f));
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
private IEnumerator FadeTo(float target)
|
||||
{
|
||||
float start = _cg.alpha;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < _fadeDuration)
|
||||
{
|
||||
_cg.alpha = Mathf.Lerp(start, target, elapsed / _fadeDuration);
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
yield return null;
|
||||
}
|
||||
_cg.alpha = target;
|
||||
}
|
||||
|
||||
// ── 辅助方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
private string ResolveDisplayName(string regionId)
|
||||
{
|
||||
if (_regionNames != null)
|
||||
foreach (var e in _regionNames)
|
||||
if (e.RegionId == regionId) return e.DisplayName;
|
||||
return regionId;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct RegionNameEntry
|
||||
{
|
||||
public string RegionId;
|
||||
public string DisplayName;
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/World/Map/RegionNameDisplay.cs.meta
Normal file
11
Assets/_Game/Scripts/World/Map/RegionNameDisplay.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dbfe38b58fb6efe4d9d9190236e3b4ca
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -6,37 +6,81 @@ namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 房间控制器。挂在每个房间场景的 [RoomRoot] 下。
|
||||
/// Start 时切换摄像机到该房间的 CameraArea,并提供出生点查询。
|
||||
/// Start 时切换摄像机到玩家当前所在的 CameraArea,并提供出生点查询。
|
||||
/// 支持房间内存在多个 CameraArea 的情况:动态检测玩家位于哪个触发区域,
|
||||
/// 无匹配时回退到场景内第一个 CameraArea。
|
||||
/// </summary>
|
||||
public class RoomController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private string _roomId;
|
||||
[SerializeField] private PlayerSpawnPoint[] _spawnPoints;
|
||||
[SerializeField] private CameraArea _cameraArea; // 该房间默认的相机区域
|
||||
|
||||
[Tooltip("(可选)显式指定此房间的基线相机区域。\n" +
|
||||
"设置后将优先使用此区域作为房间基准,而非动态检测玩家所在触发区域。\n" +
|
||||
"通常由 SceneObjectPlacerTool 自动赋值,也可手动拖入。")]
|
||||
[SerializeField] private CameraArea _cameraArea;
|
||||
|
||||
public string RoomId => _roomId;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
CameraArea area = _cameraArea;
|
||||
|
||||
// 未手动绑定时,自动在当前场景中查找(每个房间场景通常只有一个 CameraArea)
|
||||
if (area == null)
|
||||
{
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
area = Object.FindFirstObjectByType<CameraArea>();
|
||||
#else
|
||||
area = Object.FindObjectOfType<CameraArea>();
|
||||
#endif
|
||||
if (area != null)
|
||||
Debug.LogWarning($"[RoomController] {name}:_cameraArea 未绑定,自动找到 {area.name}。建议在 Inspector 中手动指定。");
|
||||
else
|
||||
Debug.LogError($"[RoomController] {name}:未找到 CameraArea,相机不会切换。");
|
||||
}
|
||||
// 显式覆盖优先:直接使用编辑器/工具指定的基线区域
|
||||
CameraArea area = _cameraArea != null ? _cameraArea : FindAreaForPlayer();
|
||||
|
||||
if (area != null)
|
||||
// instantCut = true:房间入口传送后相机硬切,无混合拖影
|
||||
ServiceLocator.GetOrDefault<ICameraService>()?.SwitchArea(area, 0, instantCut: true);
|
||||
else
|
||||
Debug.LogError($"[RoomController] {name}:未找到任何 CameraArea,相机不会切换。");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找玩家当前所在的 CameraArea。
|
||||
/// 优先检测玩家位置与哪个 CameraTriggerZone 重叠,选取其中优先级最高者;
|
||||
/// 无重叠时回退到场景内第一个 CameraArea。
|
||||
/// </summary>
|
||||
private CameraArea FindAreaForPlayer()
|
||||
{
|
||||
GameObject player = GameObject.FindWithTag("Player");
|
||||
if (player != null)
|
||||
{
|
||||
Vector2 playerPos = player.transform.position;
|
||||
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
var zones = Object.FindObjectsByType<CameraTriggerZone>(FindObjectsSortMode.None);
|
||||
#else
|
||||
var zones = Object.FindObjectsOfType<CameraTriggerZone>();
|
||||
#endif
|
||||
// 选取优先级最高的匹配区域(避免多区域重叠时选错基线)
|
||||
CameraArea bestArea = null;
|
||||
int bestPriority = int.MinValue;
|
||||
|
||||
foreach (var zone in zones)
|
||||
{
|
||||
var poly = zone.GetComponent<PolygonCollider2D>();
|
||||
if (poly != null && poly.OverlapPoint(playerPos))
|
||||
{
|
||||
var area = zone.GetComponentInParent<CameraArea>();
|
||||
if (area != null && zone.Priority > bestPriority)
|
||||
{
|
||||
bestPriority = zone.Priority;
|
||||
bestArea = area;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestArea != null) return bestArea;
|
||||
}
|
||||
|
||||
// 回退:返回场景中第一个 CameraArea
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
var fallback = Object.FindFirstObjectByType<CameraArea>();
|
||||
#else
|
||||
var fallback = Object.FindObjectOfType<CameraArea>();
|
||||
#endif
|
||||
if (fallback != null)
|
||||
Debug.LogWarning($"[RoomController] {name}:玩家不在任何触发区域内,回退到 {fallback.name}。建议确认玩家出生位置与 CameraTriggerZone 的重叠关系。");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/// <summary>通过 transitionId 查找对应的出生点。</summary>
|
||||
@@ -53,3 +97,4 @@ namespace BaseGames.World
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,17 +5,48 @@ using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
public class SavePoint : MonoBehaviour, IInteractable, ISaveable
|
||||
/// <summary>
|
||||
/// 存档点(也是复活点)。
|
||||
/// 玩家互动后:恢复 HP → 写入存档(场景、出生点 ID、世界状态)→ 广播激活事件。
|
||||
/// 传送点(快速旅行面板)是独立的 TeleportStation 组件,不在此处处理。
|
||||
///
|
||||
/// 继承 SaveableMonoBehaviour 以自动向 ISaveableRegistry 注册/注销,
|
||||
/// 确保 GameSaveManager 在 SaveAsync/LoadAsync 时回调本组件。
|
||||
///
|
||||
/// OnSave 仅在 _isActivated=true 时写入复活字段,
|
||||
/// 防止自动存档期间未激活的存档点覆盖玩家的正确复活位置。
|
||||
/// </summary>
|
||||
public class SavePoint : SaveableMonoBehaviour, IInteractable
|
||||
{
|
||||
[Header("Config")]
|
||||
[SerializeField] private string _savePointId;
|
||||
[SerializeField] private bool _restoreSpring = true;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private StringEventChannelSO _onSavePointActivated;
|
||||
[SerializeField] private VoidEventChannelSO _onFastTravelOpen;
|
||||
[Header("Event Channels - Listen")]
|
||||
[Tooltip("EVT_SceneLoaded — 用于追踪当前场景名,写入 Player.Scene")]
|
||||
[SerializeField] private StringEventChannelSO _onSceneLoaded;
|
||||
|
||||
private bool _isActivated;
|
||||
[Header("Event Channels - Raise")]
|
||||
[SerializeField] private StringEventChannelSO _onSavePointActivated;
|
||||
|
||||
private bool _isActivated;
|
||||
private string _currentSceneName;
|
||||
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
protected override void OnEnable()
|
||||
{
|
||||
base.OnEnable(); // 向 ISaveableRegistry 注册,确保 OnSave/OnLoad 被回调
|
||||
_onSceneLoaded?.Subscribe(OnSceneLoaded).AddTo(_subs);
|
||||
}
|
||||
|
||||
protected override void OnDisable()
|
||||
{
|
||||
base.OnDisable(); // 从 ISaveableRegistry 注销
|
||||
_subs.Clear();
|
||||
}
|
||||
|
||||
private void OnSceneLoaded(string sceneName) => _currentSceneName = sceneName;
|
||||
|
||||
// ── IInteractable ──────────────────────────────────────────────────────
|
||||
public bool CanInteract => true;
|
||||
@@ -24,6 +55,7 @@ namespace BaseGames.World
|
||||
public void Interact(Transform player)
|
||||
{
|
||||
_isActivated = true;
|
||||
|
||||
var restorer = player.GetComponentInChildren<IRestoreOnSave>();
|
||||
if (restorer != null)
|
||||
{
|
||||
@@ -32,27 +64,43 @@ namespace BaseGames.World
|
||||
}
|
||||
|
||||
_onSavePointActivated?.Raise(_savePointId);
|
||||
_onFastTravelOpen?.Raise();
|
||||
|
||||
// 触发存档:OnSave() 由 SaveAsync 回调所有 ISaveable,包含本组件
|
||||
var svc = ServiceLocator.GetOrDefault<ISaveService>();
|
||||
if (svc != null)
|
||||
_ = svc.SaveAsync(svc.ActiveSlot);
|
||||
else
|
||||
Debug.LogWarning("[SavePoint] ISaveService 未注册,跳过存档。", this);
|
||||
}
|
||||
|
||||
public void OnPlayerEnterRange(Transform player) { }
|
||||
public void OnPlayerExitRange() { }
|
||||
|
||||
// ── 存档集成 ────────────────────────────────────────────────────────────
|
||||
// ── ISaveable(通过 SaveableMonoBehaviour 注册)────────────────────────
|
||||
public bool IsActivated => _isActivated;
|
||||
public void SetActivated(bool val) => _isActivated = val;
|
||||
|
||||
public void OnSave(SaveData data)
|
||||
public override void OnSave(SaveData data)
|
||||
{
|
||||
if (_isActivated && !string.IsNullOrEmpty(_savePointId)
|
||||
// 仅在本存档点已激活(玩家坐过)时更新复活字段。
|
||||
// 自动存档也会触发此方法,必须守卫以防止覆盖玩家真正的复活位置。
|
||||
if (!_isActivated) return;
|
||||
|
||||
if (!string.IsNullOrEmpty(_currentSceneName))
|
||||
data.Player.Scene = _currentSceneName;
|
||||
if (!string.IsNullOrEmpty(_savePointId))
|
||||
data.Meta.SavePointId = _savePointId;
|
||||
|
||||
if (!string.IsNullOrEmpty(_savePointId)
|
||||
&& !data.World.ActivatedSavePoints.Contains(_savePointId))
|
||||
data.World.ActivatedSavePoints.Add(_savePointId);
|
||||
}
|
||||
|
||||
public void OnLoad(SaveData data)
|
||||
public override void OnLoad(SaveData data)
|
||||
{
|
||||
_isActivated = !string.IsNullOrEmpty(_savePointId)
|
||||
&& data.World.ActivatedSavePoints.Contains(_savePointId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Core.Save",
|
||||
"BaseGames.Equipment",
|
||||
"BaseGames.Dialogue"
|
||||
"BaseGames.Dialogue",
|
||||
"BaseGames.World.Map"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"overrideReferences": false,
|
||||
|
||||
@@ -4,6 +4,7 @@ using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Core.Save;
|
||||
using BaseGames.World.Map;
|
||||
|
||||
namespace BaseGames.World.Shop
|
||||
{
|
||||
@@ -124,9 +125,17 @@ namespace BaseGames.World.Shop
|
||||
if (item.IsUnique) _soldUniqueItems.Add(item.ItemId);
|
||||
_isDirty = true;
|
||||
|
||||
ApplyItemEffect(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>应用商品效果(购买成功后调用)。当前处理地图碎片揭示逻辑。</summary>
|
||||
private void ApplyItemEffect(ShopItemSO item)
|
||||
{
|
||||
if (item.Effect is MapFragmentEffect mapFrag && !string.IsNullOrEmpty(mapFrag.RoomId))
|
||||
ServiceLocator.GetOrDefault<IMapService>()?.SetMapped(mapFrag.RoomId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回应用难度乘数后的实际价格。UI 层可通过此方法显示正确标价。
|
||||
/// </summary>
|
||||
|
||||
33
Assets/_Game/Scripts/World/TeleportStation.cs
Normal file
33
Assets/_Game/Scripts/World/TeleportStation.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 传送点。玩家互动后打开快速旅行面板(由 UIManager 响应 EVT_FastTravelOpen)。
|
||||
/// 与存档点(SavePoint)完全独立:传送点不存档、不复活、不恢复 HP。
|
||||
/// </summary>
|
||||
public class TeleportStation : MonoBehaviour, IInteractable
|
||||
{
|
||||
[Header("Config")]
|
||||
[Tooltip("传送站唯一 ID,用于地图 UI 显示和解锁状态查询")]
|
||||
[SerializeField] private string _stationId;
|
||||
|
||||
[Header("Event Channels - Raise")]
|
||||
[Tooltip("EVT_FastTravelOpen — 触发后由 UIManager 打开快速旅行面板")]
|
||||
[SerializeField] private VoidEventChannelSO _onFastTravelOpen;
|
||||
|
||||
// ── IInteractable ──────────────────────────────────────────────────────
|
||||
public bool CanInteract => true;
|
||||
public string InteractPrompt => "快速旅行";
|
||||
|
||||
public void Interact(Transform player)
|
||||
{
|
||||
_onFastTravelOpen?.Raise();
|
||||
}
|
||||
|
||||
public void OnPlayerEnterRange(Transform player) { }
|
||||
public void OnPlayerExitRange() { }
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/World/TeleportStation.cs.meta
Normal file
11
Assets/_Game/Scripts/World/TeleportStation.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 83d3628daab1dce43b05b894746c7c26
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user