Files
zeling_v2/Assets/_Game/Scripts/World/Map/MapPlayerTracker.cs
Joywayer f74d7f1877 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.
2026-05-25 23:15:12 +08:00

165 lines
7.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using UnityEngine;
using BaseGames.Core;
namespace BaseGames.World.Map
{
/// <summary>
/// 将玩家世界坐标转换为地图格子坐标,供 MapPanel / MinimapHUD 显示玩家位置图标。
/// 挂在 Player GameObject 上LateUpdate 每帧计算)。
/// <para>
/// 性能:通过 <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;
[SerializeField] private MapDatabaseSO _databaseOverride; // 可选:直接指定;留空则从 IMapService 获取
[Header("世界坐标 → 格子坐标换算参数")]
[Tooltip("1 格对应的世界单位数。请在关卡编辑器中测量房间实际尺寸后填入,确保与关卡设计对齐。")]
[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; }
/// <summary>
/// 玩家在当前房间内的归一化坐标0~1
/// 基于世界坐标精确插值,每帧更新,可用于平滑移动图标。
/// </summary>
public Vector2 NormalizedPositionInRoom { get; private set; }
/// <summary>玩家进入新房间时触发(参数为新房间 ID。</summary>
public event Action<string> OnRoomChanged;
private MapDatabaseSO _database;
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)
{
_isDuplicate = true;
Destroy(gameObject);
return;
}
ServiceLocator.Register<IPlayerPositionProvider>(this);
}
private void Start()
{
if (_isDuplicate) return;
_database = _databaseOverride
?? ServiceLocator.GetOrDefault<IMapService>()?.Database;
}
private void OnDestroy()
{
if (_isDuplicate) return;
// ServiceLocator.Unregister<T>(impl) 内部以 ReferenceEquals 守卫,
// 仅当当前注册实例即本实例时才移除,避免多个 Tracker 并存时误清。
ServiceLocator.Unregister<IPlayerPositionProvider>(this);
}
private void LateUpdate()
{
if (_isDuplicate) return;
if (_playerTransform == null || _database == null) return;
Vector2Int cellPos = WorldToCell(_playerTransform.position);
bool cellChanged = cellPos != _lastCellPos;
if (cellChanged)
{
_lastCellPos = cellPos;
// 通过 MapDatabaseSO 共享的空间索引查询O(1)),避免本组件重复构建索引
var newRoomId = _database.GetRoomIdAtCell(cellPos);
if (string.IsNullOrEmpty(newRoomId))
{
// 玩家离开所有已知房间
CurrentRoomId = null;
_currentRoom = null;
NormalizedPositionInRoom = Vector2.zero;
return;
}
var prevRoomId = CurrentRoomId;
CurrentRoomId = newRoomId;
_currentRoom = _database.GetRoom(newRoomId);
if (newRoomId != prevRoomId)
OnRoomChanged?.Invoke(newRoomId);
}
// 每帧从世界坐标精确计算归一化位置,实现平滑图标跟随
if (_currentRoom != null)
{
// R11-N9 减去 _worldOriginOffset 将世界坐标对齐格子坐标系原点
var worldMin = new Vector2(
_currentRoom.GridPosition.x * _worldUnitsPerCell + _worldOriginOffset.x,
_currentRoom.GridPosition.y * _worldUnitsPerCell + _worldOriginOffset.y);
var worldSize = new Vector2(
_currentRoom.GridSize.x * _worldUnitsPerCell,
_currentRoom.GridSize.y * _worldUnitsPerCell);
var localPos = (Vector2)_playerTransform.position - worldMin;
NormalizedPositionInRoom = new Vector2(
Mathf.Clamp01(localPos.x / Mathf.Max(1f, worldSize.x)),
Mathf.Clamp01(localPos.y / Mathf.Max(1f, worldSize.y)));
}
}
private Vector2Int WorldToCell(Vector2 worldPos)
{
// 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;
}
}
}