424 lines
16 KiB
C#
424 lines
16 KiB
C#
using System.Collections.Generic;
|
||
using Cielonos.MainGame.Map;
|
||
using SLSUtilities.UI;
|
||
using UnityEngine;
|
||
using UnityEngine.UI.Extensions;
|
||
|
||
namespace Cielonos.MainGame.UI
|
||
{
|
||
/// <summary>
|
||
/// 地图 UI 页面。根据 RunMapData 动态生成节点和连接线元素。
|
||
/// 监听 RunManager 阶段变化,自动在 MapSelection 阶段打开/刷新,其他阶段关闭。
|
||
/// 节点点击后调用 RunManager.SelectNode 执行传送。
|
||
/// 支持通过 MapPanZoom 组件进行拖拽平移和滚轮缩放。
|
||
/// </summary>
|
||
public class MapUIPage : UIPageBase
|
||
{
|
||
// ================================================================
|
||
// 常量
|
||
// ================================================================
|
||
|
||
private const float CONNECTION_LINE_THICKNESS = 3f;
|
||
private static readonly Color CONNECTION_LINE_COLOR = new Color(0.5f, 0.5f, 0.5f, 0.8f);
|
||
|
||
// ================================================================
|
||
// 序列化字段
|
||
// ================================================================
|
||
|
||
[Header("Containers")]
|
||
public RectTransform mapContainer;
|
||
[SerializeField] private RectTransform nodeContainer;
|
||
[SerializeField] private RectTransform connectionContainer;
|
||
|
||
[Header("Prefabs")]
|
||
[SerializeField] private GameObject nodePrefab;
|
||
|
||
[Header("Pan & Zoom")]
|
||
[SerializeField] private MapPanZoom panZoom;
|
||
|
||
// ================================================================
|
||
// 运行时状态
|
||
// ================================================================
|
||
|
||
/// <summary>已生成的节点元素,按网格坐标索引。</summary>
|
||
private readonly Dictionary<Vector2Int, MapNodeElement> _nodeElements =
|
||
new Dictionary<Vector2Int, MapNodeElement>();
|
||
|
||
/// <summary>已生成的连接线 GameObject 列表。</summary>
|
||
private readonly List<GameObject> _connectionObjects = new List<GameObject>();
|
||
|
||
/// <summary>当前绑定的地图数据。</summary>
|
||
private RunMapData _mapData;
|
||
|
||
// ================================================================
|
||
// 生命周期
|
||
// ================================================================
|
||
|
||
protected override void Start()
|
||
{
|
||
base.Start();
|
||
|
||
// 订阅 RunManager 阶段切换事件,自动控制地图页面的开关
|
||
if (RunManager.Instance != null)
|
||
{
|
||
RunManager.Instance.OnPhaseChanged += OnRunPhaseChanged;
|
||
}
|
||
else
|
||
{
|
||
Debug.LogWarning("[MapUIPage] RunManager.Instance 不存在,无法自动切换地图页面。");
|
||
}
|
||
}
|
||
|
||
private void OnDestroy()
|
||
{
|
||
// 取消订阅,防止 RunManager 被先销毁时的空引用
|
||
if (RunManager.Instance != null)
|
||
{
|
||
RunManager.Instance.OnPhaseChanged -= OnRunPhaseChanged;
|
||
}
|
||
}
|
||
|
||
// ================================================================
|
||
// 阶段切换响应
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// RunManager 阶段切换回调。
|
||
/// 离开 MapSelection 阶段时自动关闭地图;打开由玩家手动操作(地图按钮)。
|
||
/// </summary>
|
||
private void OnRunPhaseChanged(RunPhase phase)
|
||
{
|
||
if (phase != RunPhase.MapSelection && IsOpen)
|
||
{
|
||
Close();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 页面打开时自动刷新节点显示状态并居中到当前节点。
|
||
/// </summary>
|
||
protected override void OnPageOpened()
|
||
{
|
||
base.OnPageOpened();
|
||
|
||
if (RunManager.Instance != null && RunManager.Instance.currentRun != null)
|
||
{
|
||
RefreshDisplayStates(RunManager.Instance.currentRun);
|
||
CenterOnCurrentNode();
|
||
}
|
||
}
|
||
|
||
// ================================================================
|
||
// 公共 API
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// 根据 RunMapData 生成所有地图 UI 元素(节点 + 连接线)。
|
||
/// 若之前已生成则先清除。生成完成后自动应用初始显示状态。
|
||
/// </summary>
|
||
/// <param name="mapData">要显示的地图数据。</param>
|
||
public void Populate(RunMapData mapData)
|
||
{
|
||
if (mapData == null)
|
||
{
|
||
Debug.LogWarning("[MapUIPage] 传入的 RunMapData 为 null,无法生成地图。");
|
||
return;
|
||
}
|
||
|
||
_mapData = mapData;
|
||
|
||
ClearAll();
|
||
CreateNodes(mapData);
|
||
CreateConnections(mapData);
|
||
|
||
// 生成完成后立即应用初始显示状态(避免短暂显示原始外观)
|
||
if (RunManager.Instance != null && RunManager.Instance.currentRun != null)
|
||
{
|
||
RefreshDisplayStates(RunManager.Instance.currentRun);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 根据 RunState 更新所有节点的显示状态(迷雾、交互、使用状态、边框颜色)。
|
||
/// </summary>
|
||
/// <param name="state">当前 Run 的状态。</param>
|
||
public void RefreshDisplayStates(RunState state)
|
||
{
|
||
Dictionary<Vector2Int, NodeDisplayState> displayStates = MapFogCalculator.Calculate(state);
|
||
|
||
foreach (KeyValuePair<Vector2Int, NodeDisplayState> kvp in displayStates)
|
||
{
|
||
if (_nodeElements.TryGetValue(kvp.Key, out MapNodeElement element))
|
||
{
|
||
element.ApplyDisplayState(kvp.Value);
|
||
}
|
||
}
|
||
|
||
// 更新连接线的可见性(所有连接线始终可见,因为所有节点至少显示为 Silhouette)
|
||
RefreshConnectionVisibility(displayStates);
|
||
}
|
||
|
||
// ================================================================
|
||
// 节点点击处理
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// 节点被玩家点击后的回调。调用 RunManager.SelectNode 执行传送逻辑。
|
||
/// RunManager 内部会验证可达性并处理阶段切换和场景加载。
|
||
/// </summary>
|
||
/// <param name="gridPosition">被点击节点的网格坐标。</param>
|
||
private void OnNodeClicked(Vector2Int gridPosition)
|
||
{
|
||
if (RunManager.Instance == null)
|
||
{
|
||
Debug.LogWarning("[MapUIPage] RunManager 不存在,无法处理节点点击。");
|
||
return;
|
||
}
|
||
|
||
Debug.Log($"[MapUIPage] 玩家点击节点 {gridPosition},请求传送。");
|
||
RunManager.Instance.SelectNode(gridPosition);
|
||
}
|
||
|
||
// ================================================================
|
||
// 视角控制
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// 将地图视角居中到玩家当前所在节点的 UI 位置。
|
||
/// </summary>
|
||
private void CenterOnCurrentNode()
|
||
{
|
||
if (panZoom == null || _mapData == null) return;
|
||
if (RunManager.Instance == null || RunManager.Instance.currentRun == null) return;
|
||
|
||
Vector2Int currentPos = RunManager.Instance.currentRun.currentPosition;
|
||
if (_mapData.nodes.TryGetValue(currentPos, out RunMapNode node))
|
||
{
|
||
panZoom.CenterOn(node.position);
|
||
}
|
||
}
|
||
|
||
// ================================================================
|
||
// 节点生成
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// 为每个 RunMapNode 实例化一个节点 UI 元素,并绑定点击事件。
|
||
/// </summary>
|
||
private void CreateNodes(RunMapData mapData)
|
||
{
|
||
if (nodePrefab == null)
|
||
{
|
||
Debug.LogError("[MapUIPage] nodePrefab 未赋值,无法生成节点。");
|
||
return;
|
||
}
|
||
|
||
Transform parent = nodeContainer != null ? nodeContainer : mapContainer;
|
||
|
||
foreach (KeyValuePair<Vector2Int, RunMapNode> kvp in mapData.nodes)
|
||
{
|
||
RunMapNode node = kvp.Value;
|
||
GameObject nodeGo = Instantiate(nodePrefab, parent);
|
||
nodeGo.name = $"Node_{node.gridPosition.x}_{node.gridPosition.y}";
|
||
|
||
MapNodeElement element = nodeGo.GetComponent<MapNodeElement>();
|
||
if (element == null)
|
||
{
|
||
Debug.LogWarning($"[MapUIPage] nodePrefab 缺少 MapNodeElement 组件,跳过节点 {node.gridPosition}。");
|
||
Destroy(nodeGo);
|
||
continue;
|
||
}
|
||
|
||
element.Setup(node);
|
||
|
||
// 绑定点击事件 → OnNodeClicked → RunManager.SelectNode
|
||
element.OnClicked += OnNodeClicked;
|
||
|
||
_nodeElements[kvp.Key] = element;
|
||
}
|
||
}
|
||
|
||
// ================================================================
|
||
// 连接线生成
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// 为所有节点之间的连接创建 UILineRenderer。
|
||
/// 使用 HashSet 去重,确保双向连接只生成一条线。
|
||
/// </summary>
|
||
private void CreateConnections(RunMapData mapData)
|
||
{
|
||
Transform parent = connectionContainer != null ? connectionContainer : mapContainer;
|
||
HashSet<long> processedEdges = new HashSet<long>();
|
||
|
||
foreach (KeyValuePair<Vector2Int, RunMapNode> kvp in mapData.nodes)
|
||
{
|
||
Vector2Int fromPos = kvp.Key;
|
||
RunMapNode fromNode = kvp.Value;
|
||
|
||
foreach (Vector2Int toPos in fromNode.connectedPositions)
|
||
{
|
||
// 使用排序后的 hash 去重双向边
|
||
long edgeKey = GetEdgeKey(fromPos, toPos);
|
||
if (!processedEdges.Add(edgeKey)) continue;
|
||
|
||
// 确保目标节点存在
|
||
if (!mapData.nodes.TryGetValue(toPos, out RunMapNode toNode)) continue;
|
||
|
||
CreateConnectionLine(parent, fromNode.position, toNode.position, fromPos, toPos);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 创建一条从 startPos 到 endPos 的 UILineRenderer 连接线。
|
||
/// </summary>
|
||
private void CreateConnectionLine(Transform parent, Vector2 startUIPos, Vector2 endUIPos,
|
||
Vector2Int fromGrid, Vector2Int toGrid)
|
||
{
|
||
GameObject lineGo = new GameObject($"Connection_{fromGrid.x},{fromGrid.y}_to_{toGrid.x},{toGrid.y}");
|
||
lineGo.transform.SetParent(parent, false);
|
||
|
||
RectTransform rt = lineGo.AddComponent<RectTransform>();
|
||
// 锚定到容器中心,不拉伸
|
||
rt.anchorMin = new Vector2(0.5f, 0.5f);
|
||
rt.anchorMax = new Vector2(0.5f, 0.5f);
|
||
rt.pivot = new Vector2(0.5f, 0.5f);
|
||
rt.anchoredPosition = Vector2.zero;
|
||
rt.sizeDelta = mapContainer != null ? mapContainer.sizeDelta : new Vector2(1200f, 675f);
|
||
|
||
UILineRenderer lineRenderer = lineGo.AddComponent<UILineRenderer>();
|
||
lineRenderer.LineThickness = CONNECTION_LINE_THICKNESS;
|
||
lineRenderer.color = CONNECTION_LINE_COLOR;
|
||
lineRenderer.raycastTarget = false;
|
||
|
||
// UILineRenderer 使用相对于自身 RectTransform 的坐标
|
||
// 由于 RectTransform 的 pivot 在 (0.5, 0.5),节点的 position 是相对于 mapContainer 中心的坐标
|
||
lineRenderer.Points = new Vector2[] { startUIPos, endUIPos };
|
||
|
||
_connectionObjects.Add(lineGo);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成一条边的唯一标识 key(与方向无关)。
|
||
/// </summary>
|
||
private static long GetEdgeKey(Vector2Int a, Vector2Int b)
|
||
{
|
||
// 确保小的坐标在前,保证 (a→b) 和 (b→a) 生成相同的 key
|
||
Vector2Int min, max;
|
||
if (a.x < b.x || (a.x == b.x && a.y < b.y))
|
||
{
|
||
min = a;
|
||
max = b;
|
||
}
|
||
else
|
||
{
|
||
min = b;
|
||
max = a;
|
||
}
|
||
|
||
// 将两个 Vector2Int 打包为 long
|
||
// 每个坐标分量占 16 位,总共 64 位
|
||
return ((long)(min.x + 32768) << 48)
|
||
| ((long)(min.y + 32768) << 32)
|
||
| ((long)(max.x + 32768) << 16)
|
||
| (long)(max.y + 32768);
|
||
}
|
||
|
||
// ================================================================
|
||
// 连接线可见性
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// 根据节点的显示状态更新连接线的可见性。
|
||
/// 两端节点都隐藏时,连接线也隐藏。
|
||
/// </summary>
|
||
private void RefreshConnectionVisibility(Dictionary<Vector2Int, NodeDisplayState> displayStates)
|
||
{
|
||
foreach (GameObject connectionGo in _connectionObjects)
|
||
{
|
||
if (connectionGo == null) continue;
|
||
|
||
// 从命名中解析连接的两个节点坐标
|
||
if (!TryParseConnectionName(connectionGo.name, out Vector2Int fromPos, out Vector2Int toPos))
|
||
continue;
|
||
|
||
bool fromVisible = displayStates.TryGetValue(fromPos, out NodeDisplayState fromState)
|
||
&& fromState.visibility != MapNodeVisibility.Hidden;
|
||
bool toVisible = displayStates.TryGetValue(toPos, out NodeDisplayState toState)
|
||
&& toState.visibility != MapNodeVisibility.Hidden;
|
||
|
||
connectionGo.SetActive(fromVisible || toVisible);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从连接线 GameObject 名称中解析两端节点坐标。
|
||
/// 命名格式:Connection_{x1},{y1}_to_{x2},{y2}
|
||
/// </summary>
|
||
private static bool TryParseConnectionName(string name, out Vector2Int from, out Vector2Int to)
|
||
{
|
||
from = Vector2Int.zero;
|
||
to = Vector2Int.zero;
|
||
|
||
// Connection_{x1},{y1}_to_{x2},{y2}
|
||
if (!name.StartsWith("Connection_")) return false;
|
||
|
||
string body = name.Substring("Connection_".Length);
|
||
string[] parts = body.Split(new[] { "_to_" }, System.StringSplitOptions.None);
|
||
if (parts.Length != 2) return false;
|
||
|
||
return TryParseCoord(parts[0], out from) && TryParseCoord(parts[1], out to);
|
||
}
|
||
|
||
private static bool TryParseCoord(string s, out Vector2Int result)
|
||
{
|
||
result = Vector2Int.zero;
|
||
string[] components = s.Split(',');
|
||
if (components.Length != 2) return false;
|
||
if (!int.TryParse(components[0], out int x)) return false;
|
||
if (!int.TryParse(components[1], out int y)) return false;
|
||
result = new Vector2Int(x, y);
|
||
return true;
|
||
}
|
||
|
||
// ================================================================
|
||
// 清理
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// 清除所有已生成的节点和连接线。
|
||
/// </summary>
|
||
private void ClearAll()
|
||
{
|
||
ClearNodes();
|
||
ClearConnections();
|
||
}
|
||
|
||
private void ClearNodes()
|
||
{
|
||
foreach (KeyValuePair<Vector2Int, MapNodeElement> kvp in _nodeElements)
|
||
{
|
||
if (kvp.Value != null)
|
||
{
|
||
// 取消订阅点击事件,防止内存泄漏
|
||
kvp.Value.OnClicked -= OnNodeClicked;
|
||
Destroy(kvp.Value.gameObject);
|
||
}
|
||
}
|
||
|
||
_nodeElements.Clear();
|
||
}
|
||
|
||
private void ClearConnections()
|
||
{
|
||
foreach (GameObject go in _connectionObjects)
|
||
{
|
||
if (go != null) Destroy(go);
|
||
}
|
||
|
||
_connectionObjects.Clear();
|
||
}
|
||
}
|
||
}
|