mirror of
http://47.107.252.169:3000/Roguelite-Game-Developing-Team/Gen_Hack-and-Slash-Roguelite.git
synced 2025-11-20 05:47:13 +08:00
(client) feat:实现技能树界面,实现地图生成器,实现维度指定,实现规则瓦片定义,实现逃跑逻辑,实现消息定义,实现武器动画,实现受击动画 fix: 修复单攻击子弹击中多个目标,修复人物属性计算错误 (#56)
Co-authored-by: m0_75251201 <m0_75251201@noreply.gitcode.com> Reviewed-on: http://47.107.252.169:3000/Roguelite-Game-Developing-Team/Gen_Hack-and-Slash-Roguelite/pulls/56
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using Prefab;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
|
||||
158
Client/Assets/Scripts/UI/ColorBar.cs
Normal file
158
Client/Assets/Scripts/UI/ColorBar.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace UI
|
||||
{
|
||||
// 自定义梯度效果类,继承自BaseMeshEffect
|
||||
public class ColorBar : BaseMeshEffect
|
||||
{
|
||||
[SerializeField, Range(0f, 360f)] // 使用Range特性,让角度在Inspector中更直观
|
||||
private float angle = 0f; // 渐变角度 (0-360度),0度为水平向右,90度为水平向上
|
||||
|
||||
[SerializeField]
|
||||
public Color32 color1 = Color.white; // 渐变起始颜色,默认白色
|
||||
|
||||
[SerializeField]
|
||||
public Color32 color2 = Color.white; // 渐变结束颜色,默认白色
|
||||
|
||||
[SerializeField, Range(0f, 1f)] // 限制范围在0到1之间,0表示全渐变,1表示无渐变(所有颜色为color2)
|
||||
private float range = 0f; // 渐变范围。此值用于缩短渐变覆盖的距离,0表示渐变覆盖所有范围,1表示所有像素都显示color2。
|
||||
|
||||
[SerializeField]
|
||||
private bool isFlip = false; // 是否翻转渐变方向,默认不翻转,影响color1和color2的分布
|
||||
|
||||
// 缓存Graphic组件引用,避免GetComponent频繁调用
|
||||
private Graphic m_Graphic;
|
||||
public Graphic Graphic
|
||||
{
|
||||
get
|
||||
{
|
||||
if (m_Graphic == null)
|
||||
{
|
||||
m_Graphic = GetComponent<Graphic>();
|
||||
}
|
||||
return m_Graphic;
|
||||
}
|
||||
}
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
if (Graphic != null)
|
||||
{
|
||||
Graphic.SetVerticesDirty();
|
||||
}
|
||||
}
|
||||
|
||||
// 重写ModifyMesh方法,用于修改UI元素的网格
|
||||
public override void ModifyMesh(VertexHelper vh)
|
||||
{
|
||||
if (!IsActive() || vh.currentVertCount == 0) // 如果组件未激活 或 没有顶点,则不执行后续操作
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var vertices = new List<UIVertex>(); // 创建顶点列表
|
||||
// 遍历所有顶点并添加到列表中
|
||||
for (var i = 0; i < vh.currentVertCount; i++)
|
||||
{
|
||||
var uIVertex = new UIVertex();
|
||||
vh.PopulateUIVertex(ref uIVertex, i); // 填充顶点信息
|
||||
vertices.Add(uIVertex);
|
||||
}
|
||||
|
||||
// 计算渐变方向向量
|
||||
Vector2 gradientDir = AngleToVector2(angle);
|
||||
|
||||
// 计算所有顶点在此方向上的最小和最大投影值
|
||||
float minProjection = float.MaxValue;
|
||||
float maxProjection = float.MinValue;
|
||||
|
||||
for (var i = 0; i < vh.currentVertCount; i++)
|
||||
{
|
||||
// 注意:UIVertex.position 是 Vector3,但对于UI渐变通常只关心在Canvas 2D平面上的投影
|
||||
// 这里我们使用x,y分量进行点积,以获得在渐变方向上的相对“深度”或“进度”
|
||||
Vector2 vertexPos2D = vertices[i].position;
|
||||
float projection = Vector2.Dot(vertexPos2D, gradientDir);
|
||||
|
||||
if (projection < minProjection) minProjection = projection;
|
||||
if (projection > maxProjection) maxProjection = projection;
|
||||
}
|
||||
|
||||
// 如果所有顶点投影相同(例如,只有一个点,或所有点在这个渐变方向上是平齐的),则没有渐变范围
|
||||
if (Mathf.Approximately(maxProjection, minProjection))
|
||||
{
|
||||
// 在这种情况下,所有顶点颜色应相同。为了与range=1时的行为一致,设置为color2。
|
||||
for (var i = 0; i < vh.currentVertCount; i++)
|
||||
{
|
||||
var vertex = vertices[i];
|
||||
vertex.color = color2;
|
||||
vh.SetUIVertex(vertex, i);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算渐变范围的实际有效长度。
|
||||
// 原始代码中的 (1f - range) 乘法意味着 range=0 时渐变覆盖100%,range=1 时覆盖0%。
|
||||
// 它的效果是“压缩”渐变区域,使得渐变在UI元素的一端更早地达到color1。
|
||||
// 例如,如果 range = 0.5,那么渐变只覆盖了总长度的一半。
|
||||
float totalProjectionRange = maxProjection - minProjection;
|
||||
float effectiveGradientLength = totalProjectionRange * (1f - range);
|
||||
|
||||
for (var i = 0; i < vh.currentVertCount; i++)
|
||||
{
|
||||
var vertex = vertices[i];
|
||||
Vector2 vertexPos2D = vertex.position;
|
||||
float projection = Vector2.Dot(vertexPos2D, gradientDir);
|
||||
|
||||
float t; // 归一化的插值因子 [0, 1]
|
||||
|
||||
// 计算归一化的插值因子
|
||||
// 如果 effectiveGradientLength 为0 (即 range=1),t 将是0,表示始终取 color2。
|
||||
if (Mathf.Approximately(effectiveGradientLength, 0f))
|
||||
{
|
||||
t = 0f; // 渐变长度为0,所有顶点颜色都将是 color2 (在Lerp前)
|
||||
}
|
||||
else
|
||||
{
|
||||
// 将顶点投影值映射到 [0, 1] 范围,考虑到有效渐变长度
|
||||
// 此时 t 可能超出 [0,1],例如,当 projection - minProjection > effectiveGradientLength 时,
|
||||
// 这通常发生在 range > 0 的情况下,超出的部分会被钳制,使其颜色保持在 color1。
|
||||
t = (projection - minProjection) / effectiveGradientLength;
|
||||
t = Mathf.Clamp01(t); // 钳制以确保插值因子在有效范围内
|
||||
}
|
||||
|
||||
// 根据 isFlip 调整插值因子
|
||||
// 如果 isFlip 为 true,则 color1 变为渐变起点,color2 变为渐变终点
|
||||
// 例如,t=0变为t=1,t=1变为t=0。
|
||||
if (isFlip)
|
||||
{
|
||||
t = 1f - t;
|
||||
}
|
||||
|
||||
// 使用Lerp进行颜色插值
|
||||
// Color32.Lerp(a, b, t) 表示当 t=0 时取 a,t=1 时取 b。
|
||||
// 此时,t=0 对应 minProjection 处,t=1 对应 maxProjection 处 (考虑effectiveGradientLength和isFlip)。
|
||||
vertex.color = Color32.Lerp(color2, color1, t);
|
||||
vh.SetUIVertex(vertex, i); // 更新网格中的顶点
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将角度转换为归一化的Vector2方向。
|
||||
/// 0度:(1, 0) - 右
|
||||
/// 90度:(0, 1) - 上
|
||||
/// 180度:(-1, 0) - 左
|
||||
/// 270度:(0, -1) - 下
|
||||
/// </summary>
|
||||
/// <param name="angleDegrees">角度,单位为度。</param>
|
||||
/// <returns>归一化的Vector2方向。</returns>
|
||||
private Vector2 AngleToVector2(float angleDegrees)
|
||||
{
|
||||
// 将角度转换为弧度
|
||||
float angleRadians = angleDegrees * Mathf.Deg2Rad;
|
||||
// 计算X和Y分量
|
||||
return new Vector2(Mathf.Cos(angleRadians), Mathf.Sin(angleRadians));
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Client/Assets/Scripts/UI/ColorBar.cs.meta
Normal file
2
Client/Assets/Scripts/UI/ColorBar.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8df3d16a358d74644b86e92ca5177fa1
|
||||
@@ -23,6 +23,7 @@ namespace UI
|
||||
{
|
||||
InitReloadGameButton();
|
||||
InitEvent();
|
||||
InitStory();
|
||||
InitCharacter();
|
||||
InitMonster();
|
||||
InitBuilding();
|
||||
@@ -50,7 +51,7 @@ namespace UI
|
||||
var title = InstantiatePrefab(textTemplate, menuContent.transform);
|
||||
title.Label = titleLabel;
|
||||
|
||||
var defList = Managers.DefineManager.Instance.QueryNamedDefinesByType<TDef>();
|
||||
var defList = Managers.DefineManager.Instance.QueryDefinesByType<TDef>();
|
||||
if (defList == null || defList.Length == 0)
|
||||
{
|
||||
var noDefTitle = InstantiatePrefab(textTemplate, menuContent.transform);
|
||||
@@ -72,17 +73,22 @@ namespace UI
|
||||
|
||||
private void InitEvent()
|
||||
{
|
||||
// 假设存在 Data.EventDef 类型,且它继承自 Data.DefBase,并包含一个可作为标签的字段。
|
||||
// 如果事件触发逻辑不同于生成实体,需要在此处定义相应的回调。
|
||||
InitDefineButtons<EventDef>(
|
||||
"事件菜单",
|
||||
"未定义任何事件",
|
||||
def => def.label,
|
||||
def => string.IsNullOrEmpty(def.label) ? def.defName : def.label,
|
||||
eventDef => { Managers.EventManager.Instance.Action(eventDef.defName); });
|
||||
}
|
||||
|
||||
private void InitStory()
|
||||
{
|
||||
InitDefineButtons<StoryDef>(
|
||||
"据泵菜单",
|
||||
"未定义任何剧本",
|
||||
def => string.IsNullOrEmpty(def.label) ? def.defName : def.label,
|
||||
eventDef =>
|
||||
{
|
||||
// TODO: 在这里实现事件触发逻辑
|
||||
Debug.Log($"触发事件: {eventDef.label}");
|
||||
// 示例: Managers.EventManager.Instance.TriggerEvent(eventDef.id);
|
||||
Managers.EventManager.Instance.PlayStory(eventDef.defName);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,7 +206,7 @@ namespace UI
|
||||
entityPlacementUI.snapEnabled = false;
|
||||
UIInputControl.Instance.Show(entityPlacementUI);
|
||||
}
|
||||
private void HotReload()
|
||||
public static void HotReload()
|
||||
{
|
||||
UIInputControl.Instance.HideAll();
|
||||
Program.Instance.needLoad = true;
|
||||
|
||||
@@ -1,15 +1,139 @@
|
||||
using System;
|
||||
using Base;
|
||||
using Entity;
|
||||
using UnityEngine;
|
||||
|
||||
|
||||
namespace UI
|
||||
{
|
||||
|
||||
public class EquipmentUI : MonoBehaviour
|
||||
/// <summary>
|
||||
/// 装备UI类,负责显示当前聚焦角色的装备(物品栏中的前三个槽位)。
|
||||
/// </summary>
|
||||
public class EquipmentUI : MonoBehaviour,ITick
|
||||
{
|
||||
public ItemUI currentUse;
|
||||
public ItemUI two;
|
||||
public ItemUI three;
|
||||
// 这些公共变量用于在 Inspector 中分配 ItemUI 对象,分别对应当前使用、第二个和第三个装备槽。
|
||||
public ItemUI currentUse; // 当前正在使用的装备槽 UI
|
||||
public ItemUI two; // 角色物品栏中的第二个槽位对应的 UI
|
||||
public ItemUI three; // 角色物品栏中的第三个槽位对应的 UI
|
||||
|
||||
// 存储当前聚焦的实体(通常是玩家角色)。
|
||||
private Character focusedEntity;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// 订阅 Program 实例的聚焦实体改变事件,以便在聚焦实体变化时更新 UI。
|
||||
Program.Instance.OnFocusedEntityChanged += FocusEntityChanged;
|
||||
// 组件启动时初始化 UI 显示。
|
||||
UpdateUI();
|
||||
|
||||
Clock.AddTick(this);
|
||||
}
|
||||
private void OnDestroy()
|
||||
{
|
||||
// 在销毁时取消订阅事件,防止内存泄漏。
|
||||
// 检查 Program.Instance 是否仍然存在,以避免在场景销毁时出现空引用异常。
|
||||
if (Program.Instance != null)
|
||||
{
|
||||
Program.Instance.OnFocusedEntityChanged -= FocusEntityChanged;
|
||||
}
|
||||
// 如果聚焦实体及其背包存在,则取消订阅背包改变事件。
|
||||
if (focusedEntity != null && focusedEntity.Inventory != null)
|
||||
{
|
||||
focusedEntity.Inventory.OnInventoryChanged -= UpdateUI;
|
||||
}
|
||||
Clock.RemoveTick(this);
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
if (!focusedEntity) return;
|
||||
if (Input.GetKeyDown(KeyCode.Alpha1))
|
||||
{
|
||||
|
||||
}
|
||||
else if(Input.GetKeyDown(KeyCode.Alpha2))
|
||||
{
|
||||
focusedEntity.CurrentSelected = focusedEntity.CurrentSelected == 0 ? 1 : 0;
|
||||
UpdateUI();
|
||||
}
|
||||
else if(Input.GetKeyDown(KeyCode.Alpha3))
|
||||
{
|
||||
focusedEntity.CurrentSelected = focusedEntity.CurrentSelected == 2 ? 1 : 2;
|
||||
UpdateUI();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当聚焦实体发生改变时调用此方法。
|
||||
/// </summary>
|
||||
/// <param name="e">新的聚焦实体。</param>
|
||||
private void FocusEntityChanged(Entity.Entity e)
|
||||
{
|
||||
// 如果存在旧的聚焦实体,则先取消订阅其背包改变事件。
|
||||
if (focusedEntity)
|
||||
{
|
||||
focusedEntity.Inventory.OnInventoryChanged -= UpdateUI;
|
||||
}
|
||||
|
||||
// 设置新的聚焦实体,并尝试将其转换为 Character 类型。
|
||||
focusedEntity = e as Character;
|
||||
|
||||
// 如果新的聚焦实体存在且是 Character 类型,则订阅其背包改变事件。
|
||||
if (focusedEntity)
|
||||
{
|
||||
focusedEntity.Inventory.OnInventoryChanged += UpdateUI;
|
||||
}
|
||||
|
||||
// 聚焦实体改变后更新 UI 显示。
|
||||
UpdateUI();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新装备 UI 的显示。
|
||||
/// 根据聚焦角色的当前选中物品和物品栏状态来更新UI。
|
||||
/// </summary>
|
||||
public void UpdateUI()
|
||||
{
|
||||
if (focusedEntity)
|
||||
{
|
||||
var currentSelectedIndex = focusedEntity.CurrentSelected;
|
||||
var nonCurrentUseCounter = 0; // 用于计数非当前选中项的索引,以分配给 two 和 three
|
||||
|
||||
// 遍历角色的前三个装备槽(假设物品栏只显示前3个槽位)。
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var slot = focusedEntity.Inventory.GetSlot(i);
|
||||
|
||||
if (i == currentSelectedIndex)
|
||||
{
|
||||
// 如果是当前选中的槽位,则将物品信息显示在 currentUse UI 上。
|
||||
currentUse.SetDisplayItem(slot);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果不是当前选中的槽位,则根据计数器决定显示在 two 或 three UI 上。
|
||||
if (nonCurrentUseCounter == 0)
|
||||
{
|
||||
two.SetDisplayItem(slot);
|
||||
}
|
||||
else if (nonCurrentUseCounter == 1)
|
||||
{
|
||||
three.SetDisplayItem(slot);
|
||||
}
|
||||
nonCurrentUseCounter++;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果没有聚焦的实体,则清空所有装备槽的 UI 显示。
|
||||
currentUse.SetDisplayItem(null);
|
||||
two.SetDisplayItem(null);
|
||||
three.SetDisplayItem(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,5 +30,10 @@ namespace UI
|
||||
return;
|
||||
SceneManager.LoadScene(0);
|
||||
}
|
||||
|
||||
public static void Reload()
|
||||
{
|
||||
DevMenuUI.HotReload();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Prefab;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using Base;
|
||||
using Prefab; // 假设 TextPrefab 在 Prefab 命名空间下
|
||||
using TMPro; // 假设 TextPrefab 内部使用了 TextMeshPro
|
||||
|
||||
@@ -127,5 +128,10 @@ namespace UI
|
||||
_logItems.Clear(); // 清空列表引用
|
||||
}
|
||||
}
|
||||
|
||||
public void HideCallback()
|
||||
{
|
||||
UIInputControl.Instance.Hide(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
169
Client/Assets/Scripts/UI/SkillNodeEnterLineUI.cs
Normal file
169
Client/Assets/Scripts/UI/SkillNodeEnterLineUI.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 技能节点入口连接线UI管理器。
|
||||
/// 负责生成、管理和清除技能节点进入线的UI表示。
|
||||
/// 这些线通常连接技能树中的多个起始点到一个公共的出口区域。
|
||||
/// </summary>
|
||||
public class SkillNodeEnterLineUI : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private SkillNodeLinkLineUI skillNodeLinkLineUIPrefab;
|
||||
[SerializeField] private RectTransform rectTransform;
|
||||
|
||||
private const float MIN_VERTICAL_SPACING = 12.9447f; // 两条连接线结束点之间的最小垂直间距
|
||||
private const float FIRST_POINT_Y_OFFSET = 39.0122f; // 第一个(最低的)结束点相对于RectTransform底部的Y轴偏移量
|
||||
|
||||
/// <summary>
|
||||
/// 初始化连接线并根据起始点数组绘制连接路径。
|
||||
/// </summary>
|
||||
/// <param name="startPoints">连接线的起始点数组,通常按从高到低排列。</param>
|
||||
/// <returns>所有连接线结束点所占据的最小总垂直高度,如果无连接线则返回0。</returns>
|
||||
public float Init(Vector2[] startPoints)
|
||||
{
|
||||
ClearExistingLines();
|
||||
|
||||
// 检查预制体是否已通过Inspector赋值,如果为null,则尝试从Resources加载。
|
||||
if (skillNodeLinkLineUIPrefab == null)
|
||||
{
|
||||
// 从Resources文件夹加载预制体。
|
||||
skillNodeLinkLineUIPrefab = Resources.Load<SkillNodeLinkLineUI>("Prefab/SkillTree/linkLine");
|
||||
if (skillNodeLinkLineUIPrefab == null)
|
||||
{
|
||||
// 打印错误日志。
|
||||
Debug.LogError("初始化失败:技能节点连接线预制体未在 'Prefab/SkillTree/linkLine' 路径找到。请检查路径或在Inspector中赋值。");
|
||||
return 0f; // 预制体缺失,返回0高度。
|
||||
}
|
||||
}
|
||||
|
||||
// 获取起始点的数量。
|
||||
var numLines = startPoints.Length;
|
||||
if (numLines == 0)
|
||||
{
|
||||
// 没有起始点,无需绘制,返回0高度。
|
||||
return GetRequiredHeight(0); // 使用统一的获取高度方法
|
||||
}
|
||||
|
||||
// 计算所有连接线共享的基准结束点X坐标。
|
||||
var commonEndPointX = transform.position.x - 6f;
|
||||
|
||||
// 计算RectTransform的世界坐标底部Y值。
|
||||
var rectBottomWorldY = rectTransform.position.y + rectTransform.rect.yMin * rectTransform.lossyScale.y;
|
||||
// 最低结束点Y坐标在此基础上向上偏移。
|
||||
var lowestEndPointBaseY = rectBottomWorldY + FIRST_POINT_Y_OFFSET;
|
||||
|
||||
// 初始化所有线结束点所占据的总垂直跨度。
|
||||
var requiredHeight = 0f;
|
||||
|
||||
// 根据连接线的数量,应用不同的布局逻辑。
|
||||
if (numLines == 1)
|
||||
{
|
||||
// 只有一个起始点时,其结束点直接放在最低点偏移量上。
|
||||
var endPointY = lowestEndPointBaseY;
|
||||
var endPoint = new Vector2(commonEndPointX, endPointY);
|
||||
|
||||
var line = Instantiate(skillNodeLinkLineUIPrefab, transform);
|
||||
line.SetConnectionPoints(startPoints[0], endPoint);
|
||||
|
||||
// 只有一个点时,垂直高度跨度为0。
|
||||
requiredHeight = GetRequiredHeight(1);
|
||||
}
|
||||
else if (numLines == 2)
|
||||
{
|
||||
// 仅有两个点时,它们占据“1号位”和“3号位”(即间隔是2倍的最小间距)。
|
||||
// startPoints[0](最高的起始点)连接到最上面的结束点。
|
||||
// startPoints[1](最低的起始点)连接到最下面的结束点。
|
||||
var endPointY_for_startPoints0 = lowestEndPointBaseY + (2 * MIN_VERTICAL_SPACING); // 上方的结束点
|
||||
var endPointY_for_startPoints1 = lowestEndPointBaseY; // 下方的结束点
|
||||
|
||||
var line0 = Instantiate(skillNodeLinkLineUIPrefab, transform);
|
||||
line0.SetConnectionPoints(startPoints[0], new Vector2(commonEndPointX, endPointY_for_startPoints0));
|
||||
|
||||
var line1 = Instantiate(skillNodeLinkLineUIPrefab, transform);
|
||||
line1.SetConnectionPoints(startPoints[1], new Vector2(commonEndPointX, endPointY_for_startPoints1));
|
||||
|
||||
// 两个点的高度跨度为它们之间的垂直距离。
|
||||
requiredHeight = GetRequiredHeight(2);
|
||||
}
|
||||
else // numLines > 2 (通用情况:从 lowestEndPointBaseY 向上累积)
|
||||
{
|
||||
// 遍历起始点数组,创建并设置每条连接线。
|
||||
// startPoints[i] 映射到相应的结束点Y坐标,确保从上到下顺序连接,避免交叉。
|
||||
for (var i = 0; i < numLines; i++)
|
||||
{
|
||||
// 计算当前线的结束点Y坐标。
|
||||
// 逻辑:i=0(最高的startPoint) 映射到 highestEndPoint (lowestEndPointBaseY + (numLines - 1) * MIN_VERTICAL_SPACING)
|
||||
// i=numLines-1(最低的startPoint) 映射到 lowestEndPoint (lowestEndPointBaseY + 0 * MIN_VERTICAL_SPACING)
|
||||
var currentEndPointY = lowestEndPointBaseY + (numLines - 1 - i) * MIN_VERTICAL_SPACING;
|
||||
|
||||
var endPoint = new Vector2(commonEndPointX, currentEndPointY);
|
||||
|
||||
var line = Instantiate(skillNodeLinkLineUIPrefab, transform);
|
||||
line.SetConnectionPoints(startPoints[i], endPoint);
|
||||
}
|
||||
|
||||
// 计算所有连接线结束点所占据的总垂直高度。
|
||||
requiredHeight = GetRequiredHeight(numLines);
|
||||
}
|
||||
|
||||
// 返回所有连接线的结束点所占据的最小总垂直高度。
|
||||
return requiredHeight;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据预期的连接线数量,预先计算所有连接线结束点将占据的最小总垂直高度。
|
||||
/// 这个方法不会实际创建任何连接线,仅用于高度预估。
|
||||
/// </summary>
|
||||
/// <param name="numNodes">预期的连接线数量。</param>
|
||||
/// <returns>所有连接线结束点所占据的最小总垂直高度,如果无连接线则返回0。</returns>
|
||||
public float GetRequiredHeight(int numNodes)
|
||||
{
|
||||
float requiredHeight = 0f;
|
||||
|
||||
if (numNodes <= 0)
|
||||
{
|
||||
requiredHeight = 0f;
|
||||
}
|
||||
else if (numNodes == 1)
|
||||
{
|
||||
// 与Init方法中只有一个起始点时的逻辑保持一致
|
||||
requiredHeight = 0f;
|
||||
}
|
||||
else if (numNodes == 2)
|
||||
{
|
||||
// 与Init方法中只有两个起始点时的特殊逻辑保持一致
|
||||
// 两个点的高度跨度为2倍的最小垂直间距
|
||||
requiredHeight = 2 * MIN_VERTICAL_SPACING;
|
||||
}
|
||||
else // numNodes > 2 (通用情况逻辑)
|
||||
{
|
||||
// 与Init方法中多于两个起始点时的通用逻辑保持一致
|
||||
// n个点占据 (n-1) * 最小垂直间距
|
||||
requiredHeight = (numNodes - 1) * MIN_VERTICAL_SPACING;
|
||||
}
|
||||
|
||||
return requiredHeight;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除所有当前作为该GameObject子对象的连接线。
|
||||
/// 在播放模式下使用 Destroy,在编辑器模式下使用 DestroyImmediate。
|
||||
/// </summary>
|
||||
private void ClearExistingLines()
|
||||
{
|
||||
var childrenToDestroy = new List<GameObject>();
|
||||
for (var i = transform.childCount - 1; i >= 0; i--)
|
||||
{
|
||||
childrenToDestroy.Add(transform.GetChild(i).gameObject);
|
||||
}
|
||||
foreach (var child in childrenToDestroy)
|
||||
{
|
||||
Destroy(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
Client/Assets/Scripts/UI/SkillNodeEnterLineUI.cs.meta
Normal file
3
Client/Assets/Scripts/UI/SkillNodeEnterLineUI.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4d15100cc2164e78bfaaef16efd7825a
|
||||
timeCreated: 1757469894
|
||||
121
Client/Assets/Scripts/UI/SkillNodeLinkLineUI.cs
Normal file
121
Client/Assets/Scripts/UI/SkillNodeLinkLineUI.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 技能节点连接线UI组件。
|
||||
/// 该组件用于管理贝塞尔曲线的生成和显示,连接两个技能节点或其他UI元素。
|
||||
/// 它通过控制贝塞尔曲线生成器的参数,实现UI元素之间的视觉连接。
|
||||
/// </summary>
|
||||
// 确保当前GameObject拥有RectTransform组件,因为这是一个UI组件,其布局和定位依赖于它。
|
||||
[RequireComponent(typeof(RectTransform))]
|
||||
public class SkillNodeLinkLineUI : MonoBehaviour
|
||||
{
|
||||
// 引用UILineRenderer组件,用于实际绘制曲线。
|
||||
public UILineRenderer line;
|
||||
// 引用UIBezierCurveGenerator组件,负责根据控制点计算贝塞尔曲线的几何点。
|
||||
public UIBezierCurveGenerator curveGenerator;
|
||||
// 曲线起始点关联的UI元素,例如:箭头的头部或起始连接点标识。
|
||||
public GameObject lineHead;
|
||||
// 曲线结束点关联的UI元素,例如:箭头的尾部或结束连接点标识。
|
||||
public GameObject lineTail;
|
||||
|
||||
// 当前GameObject的RectTransform组件,方便在代码中直接访问和操作。
|
||||
// [HideInInspector] 确保该字段在Inspector面板中不可见,通常因为它会被自动赋值。
|
||||
[HideInInspector] public RectTransform rectTransform;
|
||||
|
||||
/// <summary>
|
||||
/// 当脚本实例被启用时,或者首次加载时调用。
|
||||
/// 用于初始化和检查必要的组件。
|
||||
/// </summary>
|
||||
private void Awake()
|
||||
{
|
||||
// 获取当前GameObject的RectTransform组件。
|
||||
rectTransform = GetComponent<RectTransform>();
|
||||
|
||||
// 检查贝塞尔曲线生成器是否已赋值。
|
||||
if (curveGenerator == null)
|
||||
{
|
||||
Debug.LogError("UIBezierCurveGenerator未赋值给SkillNodeLinkLineUI!", this);
|
||||
}
|
||||
// 检查UILineRenderer是否已赋值。
|
||||
// 注意:UILineRenderer通常由UIBezierCurveGenerator管理,但此处也进行一个警告检查。
|
||||
if (line == null)
|
||||
{
|
||||
Debug.LogWarning("UILineRenderer未赋值给SkillNodeLinkLineUI。它可能由UIBezierCurveGenerator自动处理,或需要手动赋值。", this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置曲线的起始点和结束点,并更新贝塞尔曲线的生成以及线头线尾UI元素的位置。
|
||||
/// </summary>
|
||||
/// <param name="startWorldPos">曲线起始点的世界坐标。</param>
|
||||
/// <param name="endWorldPos">曲线结束点的世界坐标。</param>
|
||||
public void SetConnectionPoints(Vector2 startWorldPos, Vector2 endWorldPos)
|
||||
{
|
||||
// 如果贝塞尔曲线生成器未赋值,则无法设置连接点。
|
||||
if (curveGenerator == null)
|
||||
{
|
||||
Debug.LogError("无法设置连接点:UIBezierCurveGenerator未赋值。", this);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 将世界坐标转换为 UIBezierCurveGenerator 的局部坐标。
|
||||
// 贝塞尔曲线生成器的控制点是相对于其自身RectTransform的局部坐标。
|
||||
// 因此,我们需要先获取curveGenerator所在GameObject的RectTransform,并进行坐标转换。
|
||||
var curveGeneratorRectTransform = curveGenerator.GetComponent<RectTransform>();
|
||||
if (curveGeneratorRectTransform == null)
|
||||
{
|
||||
Debug.LogError("UIBezierCurveGenerator所在的GameObject没有RectTransform组件。", this);
|
||||
return;
|
||||
}
|
||||
|
||||
// 将世界坐标转换为curveGeneratorRectTransform的局部坐标。
|
||||
Vector2 localStartPos = curveGeneratorRectTransform.InverseTransformPoint(startWorldPos);
|
||||
Vector2 localEndPos = curveGeneratorRectTransform.InverseTransformPoint(endWorldPos);
|
||||
|
||||
// 2. 计算贝塞尔曲线的水平偏移量,用于控制曲线的弯曲程度。
|
||||
var horizontalOffset = 100f; // 默认水平偏移量。
|
||||
|
||||
// 根据起始点和结束点X坐标的绝对差值来调整偏移量,使曲线在近距离时更平滑。
|
||||
var xDiff = Mathf.Abs(startWorldPos.x - endWorldPos.x); // 使用世界坐标计算X轴差值更直观。
|
||||
if (xDiff < 200f)
|
||||
{
|
||||
horizontalOffset = xDiff / 2f + 20f; // 在X轴差值较小时,减小偏移量。
|
||||
}
|
||||
|
||||
// 3. 计算贝塞尔曲线的四个控制点 (P0, P1, P2, P3)。
|
||||
var p0 = localStartPos; // 贝塞尔曲线的起始点。
|
||||
var p3 = localEndPos; // 贝塞尔曲线的终止点。
|
||||
Vector2 p1, p2; // 贝塞尔曲线的两个控制点。
|
||||
|
||||
// 根据起始点和结束点X坐标的相对位置,确定控制点P1和P2的水平偏移方向،
|
||||
// 以保证曲线的自然走向。
|
||||
if (localStartPos.x <= localEndPos.x)
|
||||
{
|
||||
// 如果曲线从左向右(或垂直),P1在P0右侧,P2在P3左侧。
|
||||
p1 = new Vector2(localStartPos.x + horizontalOffset, localStartPos.y);
|
||||
p2 = new Vector2(localEndPos.x - horizontalOffset, localEndPos.y);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果曲线从右向左,P1在P0左侧,P2在P3右侧。
|
||||
p1 = new Vector2(localStartPos.x - horizontalOffset, localStartPos.y);
|
||||
p2 = new Vector2(localEndPos.x + horizontalOffset, localEndPos.y);
|
||||
}
|
||||
|
||||
// 4. 将计算出的控制点设置给 UIBezierCurveGenerator,使其生成新的曲线。
|
||||
curveGenerator.SetControlPoints(p0, p1, p2, p3);
|
||||
|
||||
// 5. 将线头和线尾UI元素移动到对应的世界坐标位置,使其与曲线的起始和结束对齐。
|
||||
if (lineHead != null)
|
||||
{
|
||||
lineHead.transform.position = startWorldPos; // 将线头UI元素移动到起始点的世界坐标。
|
||||
}
|
||||
if (lineTail != null)
|
||||
{
|
||||
lineTail.transform.position = endWorldPos; // 将线尾UI元素移动到结束点的世界坐标。
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
Client/Assets/Scripts/UI/SkillNodeLinkLineUI.cs.meta
Normal file
3
Client/Assets/Scripts/UI/SkillNodeLinkLineUI.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 04b3f5cbad874b31893c0d7e1de082b9
|
||||
timeCreated: 1757470314
|
||||
131
Client/Assets/Scripts/UI/SkillTreeNodeUI.cs
Normal file
131
Client/Assets/Scripts/UI/SkillTreeNodeUI.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using System.Collections.Generic;
|
||||
using Data;
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using TMPro;
|
||||
|
||||
namespace UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 技能树节点的用户界面组件。
|
||||
/// </summary>
|
||||
public class SkillTreeNodeUI : MonoBehaviour
|
||||
{
|
||||
/// <summary>
|
||||
/// 节点的RectTransform组件。
|
||||
/// </summary>
|
||||
public RectTransform rectTransform;
|
||||
/// <summary>
|
||||
/// 节点的最小高度。
|
||||
/// </summary>
|
||||
[SerializeField] private float NODE_MIN_HEIGHT = 39.0122f * 2;
|
||||
/// <summary>
|
||||
/// 两条连接线结束点之间的最小垂直间距。
|
||||
/// </summary>
|
||||
[SerializeField]private float MIN_VERTICAL_SPACING = 12.9447f;
|
||||
|
||||
/// <summary>
|
||||
/// 技能节点入口线UI组件。
|
||||
/// </summary>
|
||||
public SkillNodeEnterLineUI skillNodeEnterLineUI;
|
||||
/// <summary>
|
||||
/// 输出线连接点的参考或容器。
|
||||
/// </summary>
|
||||
public RectTransform outputHead;
|
||||
/// <summary>
|
||||
/// 文本显示组件。
|
||||
/// </summary>
|
||||
public TMP_Text text;
|
||||
|
||||
public SkillTreeDef skillTreeDef;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化技能树节点UI。
|
||||
/// </summary>
|
||||
/// <param name="linkLinePoints">连接线的点数组。</param>
|
||||
/// <param name="label">节点显示的文本标签。</param>
|
||||
public void Init(Vector2[] linkLinePoints, string label)
|
||||
{
|
||||
text.text = label;
|
||||
if (linkLinePoints is { Length: > 0 })
|
||||
{
|
||||
var height = skillNodeEnterLineUI.GetRequiredHeight(linkLinePoints.Length);
|
||||
height = Mathf.Max(height + NODE_MIN_HEIGHT / 2 + 10, NODE_MIN_HEIGHT);
|
||||
rectTransform.sizeDelta = new Vector2(rectTransform.sizeDelta.x, height);
|
||||
skillNodeEnterLineUI.Init(linkLinePoints);
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 预先计算技能树节点在初始化后所需的最小高度。
|
||||
/// 此方法可以在调用 Init 之前用于布局计算。
|
||||
/// </summary>
|
||||
/// <param name="linkLineCount">连接线(入口线)的数量。</param>
|
||||
/// <returns>节点所需的最小高度。</returns>
|
||||
public float GetRequiredNodeHeight(int linkLineCount)
|
||||
{
|
||||
// 如果没有连接线或 skillNodeEnterLineUI 组件为空,则高度为最小高度
|
||||
if (linkLineCount <= 0 || skillNodeEnterLineUI == null)
|
||||
{
|
||||
return NODE_MIN_HEIGHT;
|
||||
}
|
||||
// 获取 SkillNodeEnterLineUI 根据连接线数量计算出的所需高度
|
||||
var calculatedLineHeight = skillNodeEnterLineUI.GetRequiredHeight(linkLineCount);
|
||||
// 结合最小节点高度和额外的填充,确保节点有足够的空间
|
||||
// 逻辑与 Init() 方法中的高度计算保持一致
|
||||
var finalHeight = Mathf.Max(calculatedLineHeight + NODE_MIN_HEIGHT / 2 + 10, NODE_MIN_HEIGHT);
|
||||
|
||||
return finalHeight;
|
||||
}
|
||||
/// <summary>
|
||||
/// 查询出口位置。
|
||||
/// </summary>
|
||||
/// <param name="totalCount">总数量。</param>
|
||||
/// <param name="index">位置(从上往下,0-based)。</param>
|
||||
/// <returns>世界坐标。</returns>
|
||||
public Vector2 GetOutputPosition(int totalCount, int index)
|
||||
{
|
||||
// 输入验证,防止无效的totalCount或index导致错误
|
||||
if (totalCount <= 0 || index < 0 || index >= totalCount)
|
||||
{
|
||||
Debug.LogWarning($"获取输出位置时,总数 ({totalCount}) 或索引 ({index}) 无效。返回outputHead的默认位置。");
|
||||
// 返回outputHead的中心位置作为备用,或者Vector2.zero取决于具体需求
|
||||
return outputHead.position;
|
||||
}
|
||||
|
||||
// 1. 计算 X 坐标:outputHead 的右边界
|
||||
var corners = new Vector3[4];
|
||||
outputHead.GetWorldCorners(corners);
|
||||
// corners[2] 是右上角的世界坐标点
|
||||
var worldX = corners[2].x;
|
||||
|
||||
// 2. 计算 Y 坐标:以 outputHead 的中心Y坐标为基准进行偏移
|
||||
var worldY = outputHead.position.y;
|
||||
|
||||
if (totalCount == 2)
|
||||
{
|
||||
// 特殊情况:当输出点为2个时,点1为(x,y+d),点2为(x,y-d),d为MIN_VERTICAL_SPACING
|
||||
if (index == 0) // 第一个点 (最上面的点)
|
||||
{
|
||||
worldY += MIN_VERTICAL_SPACING;
|
||||
}
|
||||
else // 第二个点 (最下面的点)
|
||||
{
|
||||
worldY -= MIN_VERTICAL_SPACING;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 一般情况:多个点均匀纵向排列,以outputHead的Y为中心
|
||||
// 计算最顶部的点 (index=0) 相对于中心点Y的偏移量
|
||||
// 总共有 (totalCount - 1) 个 MIN_VERTICAL_SPACING 的间距
|
||||
// 索引为 index 的点的偏移量为:
|
||||
// ( (totalCount - 1) / 2.0f - index ) * MIN_VERTICAL_SPACING
|
||||
var halfTotalSpacing = (totalCount - 1) / 2.0f;
|
||||
var yOffset = (halfTotalSpacing - index) * MIN_VERTICAL_SPACING;
|
||||
worldY += yOffset;
|
||||
}
|
||||
|
||||
return new Vector2(worldX, worldY);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
Client/Assets/Scripts/UI/SkillTreeNodeUI.cs.meta
Normal file
3
Client/Assets/Scripts/UI/SkillTreeNodeUI.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a0bd6744a131480d872dc07f318b561d
|
||||
timeCreated: 1757469859
|
||||
676
Client/Assets/Scripts/UI/SkillTreePageUI.cs
Normal file
676
Client/Assets/Scripts/UI/SkillTreePageUI.cs
Normal file
@@ -0,0 +1,676 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using System.Linq;
|
||||
using Data;
|
||||
using Managers;
|
||||
|
||||
namespace UI
|
||||
{
|
||||
/// <summary>
|
||||
/// SkillTreePageUI 类负责在UI中生成和布局技能树。
|
||||
/// 它通过处理技能定义、创建UI节点并根据其层级关系进行定位来展示技能树。
|
||||
/// </summary>
|
||||
public class SkillTreePageUI : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private RectTransform rectTransform; // 技能树UI页面的RectTransform
|
||||
[SerializeField] private SkillTreeNodeUI skillTreeNodeUIPrefab; // 技能树节点UI预制体
|
||||
|
||||
// 常量定义,用于技能树节点的布局间距。
|
||||
private const float NODE_HORIZONTAL_SPACING = 500f; // 节点水平间距 (父子层之间)
|
||||
private const float NODE_VERTICAL_SPACING = 150f; // 节点垂直间距 (同层节点之间或不同树之间)
|
||||
private const float PAGE_PADDING_X = 100f; // 页面左右内边距
|
||||
private const float PAGE_PADDING_Y = 100f; // 页面上下内边距
|
||||
|
||||
public Vector2 Size=>rectTransform.sizeDelta;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 辅助方法:获取一个节点所在的整个连通组件(通过父子关系可达的所有节点)。
|
||||
/// 此方法使用广度优先搜索 (BFS) 遍历,同时探索正向边(子节点)和反向边(父节点)。
|
||||
/// </summary>
|
||||
/// <param name="startNode">开始遍历的节点。</param>
|
||||
/// <param name="allGraphNodes">整个图中的所有节点集合,用于限定搜索范围。</param>
|
||||
/// <param name="adjList">正向邻接列表 (节点 -> 子节点)。</param>
|
||||
/// <param name="revAdjList">反向邻接列表 (节点 -> 父节点)。</param>
|
||||
/// <returns>一个包含当前连通组件所有节点的HashSet。</returns>
|
||||
private HashSet<SkillTreeDef> GetConnectedComponent(
|
||||
SkillTreeDef startNode,
|
||||
HashSet<SkillTreeDef> allGraphNodes,
|
||||
Dictionary<SkillTreeDef, List<SkillTreeDef>> adjList,
|
||||
Dictionary<SkillTreeDef, List<SkillTreeDef>> revAdjList)
|
||||
{
|
||||
var componentNodes = new HashSet<SkillTreeDef>();
|
||||
var queue = new Queue<SkillTreeDef>();
|
||||
|
||||
// 确保起始节点属于当前图的有效节点集合,并将其加入队列和组件集合
|
||||
if (allGraphNodes.Contains(startNode) && componentNodes.Add(startNode))
|
||||
{
|
||||
queue.Enqueue(startNode);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果起始节点本身就不在allGraphNodes中,直接返回空集合
|
||||
return componentNodes;
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
// 探索子节点:遍历当前节点的直接子节点,如果它们属于当前图且尚未被发现,则加入队列
|
||||
if (adjList.TryGetValue(current, out var children) && children != null)
|
||||
{
|
||||
foreach (var child in children)
|
||||
{
|
||||
// 确保子节点也在当前图中,并且是新发现的节点
|
||||
if (allGraphNodes.Contains(child) && componentNodes.Add(child))
|
||||
{
|
||||
queue.Enqueue(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 探索父节点:遍历当前节点的直接父节点,如果它们属于当前图且尚未被发现,则加入队列
|
||||
if (revAdjList.TryGetValue(current, out var parents) && parents != null)
|
||||
{
|
||||
foreach (var parent in parents)
|
||||
{
|
||||
// 确保父节点也在当前图中,并且是新发现的节点
|
||||
if (allGraphNodes.Contains(parent) && componentNodes.Add(parent))
|
||||
{
|
||||
queue.Enqueue(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return componentNodes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据指定的标签生成技能树的层级结构,并将独立的技能树结构分开。
|
||||
/// 保证每层的父节点都在前层,同层不存在父子关系,在此基础上尽量减少层数并尽量靠前。
|
||||
/// </summary>
|
||||
/// <param name="tag">要生成层级的技能树标签。</param>
|
||||
/// <returns>一个包含多层SkillTreeDef列表的列表的列表。
|
||||
/// 外层List代表独立的技能树结构,中间List代表该树中的层,内层List代表该层中的技能节点。
|
||||
/// 如果没有找到相关节点或发生错误,返回空列表。</returns>
|
||||
public List<List<List<SkillTreeDef>>> GenerateSkillTreeLayers(string tag)
|
||||
{
|
||||
// 用于快速查找一个节点是否属于当前tag集合,仅包含本次处理的节点。
|
||||
// 这是处理步骤中所有相关节点的缓存,确保我们只关注与指定标签相关联的节点。
|
||||
HashSet<SkillTreeDef> allNodesInCurrentGraph;
|
||||
// 1. 获取所有带有指定tag的技能节点
|
||||
var allTaggedNodes = SkillTreeManager.Instance.GetNodesByTag(tag);
|
||||
if (allTaggedNodes == null || allTaggedNodes.Count == 0)
|
||||
{
|
||||
// 如果没有找到任何带有指定标签的节点,则返回一个空列表,表示没有技能树可生成。
|
||||
allNodesInCurrentGraph = new HashSet<SkillTreeDef>(); // 初始化为空集合,保持一致性
|
||||
return new List<List<List<SkillTreeDef>>>();
|
||||
}
|
||||
|
||||
// 缓存所有标记节点以便快速查找,此Set是后续图构建的节点范围
|
||||
allNodesInCurrentGraph = new HashSet<SkillTreeDef>(allTaggedNodes);
|
||||
// 2. 初始化全局数据结构:入度(In-degree)、正向邻接列表(Adjacency List)和反向邻接列表(Reverse Adjacency List)
|
||||
// 这些结构用于完整描述所有相关节点之间的关系,是构建连通分量的基础。
|
||||
var inDegrees = new Dictionary<SkillTreeDef, int>(); // 全局入度,用于拓扑排序的初始值(后会被局部化)
|
||||
var adjList = new Dictionary<SkillTreeDef, List<SkillTreeDef>>(); // 全局正向邻接列表 (节点 -> 子节点)
|
||||
var revAdjList = new Dictionary<SkillTreeDef, List<SkillTreeDef>>(); // 全局反向邻接列表 (节点 -> 父节点)
|
||||
foreach (var node in allTaggedNodes)
|
||||
{
|
||||
inDegrees[node] = 0; // 初始化所有节点的入度为0
|
||||
adjList[node] = new List<SkillTreeDef>(); // 初始化邻接列表
|
||||
revAdjList[node] = new List<SkillTreeDef>(); // 初始化反向邻接列表
|
||||
}
|
||||
|
||||
// 3. 填充inDegrees、adjList和revAdjList:遍历所有节点,建立完整的图结构
|
||||
foreach (var node in allTaggedNodes)
|
||||
{
|
||||
// 计算当前节点的入度 (只计算父节点也在当前allNodesInCurrentGraph集合中的依赖)
|
||||
var directParents = SkillTreeManager.Instance.GetAllDirectParents(node);
|
||||
if (directParents != null)
|
||||
{
|
||||
foreach (var parent in directParents)
|
||||
{
|
||||
if (parent != null && allNodesInCurrentGraph.Contains(parent))
|
||||
{
|
||||
inDegrees[node]++;
|
||||
// 填充反向邻接列表:记录当前节点的父节点
|
||||
revAdjList[node].Add(parent); // 理论上这里的ContainsKey检查可以省略,因为所有节点已初始化
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 填充邻接列表 (只添加子节点也在当前allNodesInCurrentGraph集合中的连接)
|
||||
var directChildren = SkillTreeManager.Instance.GetAllDirectChildren(node);
|
||||
if (directChildren != null)
|
||||
{
|
||||
foreach (var child in directChildren)
|
||||
{
|
||||
if (child != null && allNodesInCurrentGraph.Contains(child))
|
||||
{
|
||||
adjList[node].Add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 存储所有独立的技能树的层级结果
|
||||
var allSeparateTreesLayers = new List<List<List<SkillTreeDef>>>();
|
||||
// 追踪在连通分量发现过程中是否已访问的节点,确保每个节点只被分派到一个独立的连通分量中。
|
||||
var visitedNodesForComponents = new HashSet<SkillTreeDef>();
|
||||
// 4.1 识别独立的连通分量,并对每个分量独立进行拓扑排序(Kahn算法)
|
||||
foreach (var startNode in allTaggedNodes)
|
||||
{
|
||||
if (!visitedNodesForComponents.Contains(startNode))
|
||||
{
|
||||
// 发现一个尚未处理过的新连通分量
|
||||
var currentComponentNodes =
|
||||
GetConnectedComponent(startNode, allNodesInCurrentGraph, adjList, revAdjList);
|
||||
// 将此连通分量中的所有节点标记为已访问,避免重复处理
|
||||
foreach (var nodeInComponent in currentComponentNodes)
|
||||
{
|
||||
visitedNodesForComponents.Add(nodeInComponent);
|
||||
}
|
||||
|
||||
// 4.2 为当前连通分量准备各自的局部入度和局部邻接列表(构建此组件的局部图)
|
||||
// 这一步是关键,它确保拓扑排序只考虑当前组件内部的依赖关系,不被外部组件影响。
|
||||
var componentInDegrees = new Dictionary<SkillTreeDef, int>();
|
||||
var componentAdjList = new Dictionary<SkillTreeDef, List<SkillTreeDef>>();
|
||||
// 辅助集合,方便在当前组件内快速查找节点,优化性能
|
||||
var componentNodesSet = new HashSet<SkillTreeDef>(currentComponentNodes);
|
||||
foreach (var node in currentComponentNodes)
|
||||
{
|
||||
componentInDegrees[node] = 0; // 初始化为0
|
||||
componentAdjList[node] = new List<SkillTreeDef>();
|
||||
}
|
||||
|
||||
// 重新计算组件内部的入度和邻接列表,只考虑组件内部的关系
|
||||
foreach (var node in currentComponentNodes)
|
||||
{
|
||||
// 计算局部入度: 只考虑父节点也在当前组件内的依赖关系
|
||||
if (revAdjList.TryGetValue(node, out var parents) && parents != null)
|
||||
{
|
||||
foreach (var parent in parents)
|
||||
{
|
||||
if (componentNodesSet.Contains(parent)) // 只有父节点也在当前组件内,才计入局部入度
|
||||
{
|
||||
componentInDegrees[node]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 填充局部邻接列表: 只添加子节点也在当前组件内的连接
|
||||
if (adjList.TryGetValue(node, out var children) && children != null)
|
||||
{
|
||||
foreach (var child in children)
|
||||
{
|
||||
if (componentNodesSet.Contains(child)) // 只有子节点也在当前组件内,才计入局部邻接
|
||||
{
|
||||
componentAdjList[node].Add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4.3 对当前连通分量执行Kahn算法进行分层
|
||||
var currentTreeLayers = new List<List<SkillTreeDef>>();
|
||||
var queue = new Queue<SkillTreeDef>();
|
||||
var processedNodesInComponent = new HashSet<SkillTreeDef>(); // 追踪当前组件已处理的节点
|
||||
// 收集所有局部入度为0的起始节点,作为Kahn算法的第一层节点
|
||||
foreach (var node in currentComponentNodes)
|
||||
{
|
||||
if (componentInDegrees[node] == 0)
|
||||
{
|
||||
queue.Enqueue(node); // 入度为0的节点是当前DAG的起点
|
||||
}
|
||||
}
|
||||
|
||||
var totalNodesInComponent = currentComponentNodes.Count; // 记录组件总节点数,用于循环检测
|
||||
// Kahn算法主循环:逐层处理节点
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var currentLayerSize = queue.Count;
|
||||
var currentLayerNodes = new List<SkillTreeDef>();
|
||||
var nextQueue = new Queue<SkillTreeDef>(); // 用于存储下一层的节点
|
||||
for (var i = 0; i < currentLayerSize; i++)
|
||||
{
|
||||
var node = queue.Dequeue(); // 取出当前层的一个节点
|
||||
currentLayerNodes.Add(node); // 加入当前层的列表
|
||||
processedNodesInComponent.Add(node); // 标记为已处理
|
||||
// 遍历其子节点,减少子节点的入度。如果子节点入度变为0,则加入下一层队列。
|
||||
if (componentAdjList.TryGetValue(node, out var children) && children != null)
|
||||
{
|
||||
foreach (var child in children)
|
||||
{
|
||||
// 理论上 child 肯定在 componentInDegrees 中,因为 componentAdjList 是根据 componentNodesSet 填充的
|
||||
componentInDegrees[child]--;
|
||||
if (componentInDegrees[child] == 0)
|
||||
{
|
||||
nextQueue.Enqueue(child); // 入度为0,成为下一层的节点
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果当前层有节点,则将其添加到此技能树的层级结构中
|
||||
if (currentLayerNodes.Count > 0)
|
||||
{
|
||||
currentTreeLayers.Add(currentLayerNodes);
|
||||
}
|
||||
|
||||
queue = nextQueue; // 进入下一层处理
|
||||
}
|
||||
|
||||
// 4.4 检查当前组件处理结果:是否存在循环依赖
|
||||
if (processedNodesInComponent.Count != totalNodesInComponent)
|
||||
{
|
||||
// 如果已处理节点数不等于组件总节点数,则表示存在循环,Kahn算法未能完全排序。
|
||||
var remainingCycleNodes = currentComponentNodes
|
||||
.Where(n => !processedNodesInComponent.Contains(n))
|
||||
.ToList();
|
||||
Debug.LogWarning(
|
||||
$"[技能树] 标签 '{tag}' 的一个连通组件中检测到循环依赖。无法完全分层。构成循环的节点:{string.Join(", ", remainingCycleNodes.Select(n => n.defName))}");
|
||||
// 处理策略:
|
||||
// ① 将已成功分层的部分(无环部分)作为独立的技能树添加。
|
||||
if (currentTreeLayers.Any())
|
||||
{
|
||||
allSeparateTreesLayers.Add(currentTreeLayers);
|
||||
}
|
||||
|
||||
// ② 对于构成循环的节点,由于它们无法满足“父节点在前层”和“同层无父子关系”的要求,
|
||||
// 将它们作为独立的单层“树”添加到结果中,以最小化错误并保留信息。
|
||||
foreach (var cycleNode in remainingCycleNodes)
|
||||
{
|
||||
// 确保此循环节点尚未作为层中的一部分被添加(理论上不会,因为 remainingCycleNodes 是未处理的)
|
||||
bool alreadyAddedAsLayerNode = false;
|
||||
foreach (var layer in currentTreeLayers)
|
||||
{
|
||||
if (layer.Contains(cycleNode))
|
||||
{
|
||||
alreadyAddedAsLayerNode = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!alreadyAddedAsLayerNode)
|
||||
{
|
||||
// 将每个循环节点视为一个独立的、单层的技能树
|
||||
allSeparateTreesLayers.Add(new List<List<SkillTreeDef>>
|
||||
{ new List<SkillTreeDef> { cycleNode } });
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果所有节点都已处理,则该连通组件是无环的,成功分层。
|
||||
if (currentTreeLayers.Any())
|
||||
{
|
||||
allSeparateTreesLayers.Add(currentTreeLayers);
|
||||
}
|
||||
// 理论上,如果 totalNodesInComponent > 0 且 processedNodesInComponent == totalNodesInComponent,
|
||||
// 那么 currentTreeLayers 应该至少包含一层。此警告用于防御性编程。
|
||||
else if (totalNodesInComponent > 0)
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[技能树] 标签 '{tag}' 的一个无环连通组件被完全处理,但未生成任何层。这可能表示组件只包含一个独立节点,但未被正确处理。节点:{string.Join(", ", currentComponentNodes.Select(n => n.defName))}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 最终检查:理论上,所有 allTaggedNodes 都应该被 visitedNodesForComponents 标记。
|
||||
// 如果此检查触发,则表示 GetConnectedComponent 或组件发现逻辑存在漏洞。
|
||||
if (visitedNodesForComponents.Count != allTaggedNodes.Count)
|
||||
{
|
||||
var unvisitedNodes = allTaggedNodes
|
||||
.Where(n => !visitedNodesForComponents.Contains(n))
|
||||
.Select(n => n.defName)
|
||||
.ToList();
|
||||
Debug.LogError($"[技能树] 标签 '{tag}' 有未被连通分量识别逻辑处理的节点。可能存在逻辑错误。未处理节点:{string.Join(", ", unvisitedNodes)}");
|
||||
// 这些节点如果存在,也应该被添加到结果中,作为孤立的单层树处理,以防意外丢失数据。
|
||||
foreach (var nodeDefName in unvisitedNodes)
|
||||
{
|
||||
// 确保获取到原始的SkillTreeDef对象来添加
|
||||
var nodeToAdd = allTaggedNodes.First(n => n.defName == nodeDefName);
|
||||
allSeparateTreesLayers.Add(
|
||||
new List<List<SkillTreeDef>>() { new List<SkillTreeDef>() { nodeToAdd } });
|
||||
}
|
||||
}
|
||||
|
||||
return allSeparateTreesLayers;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据指定的SkillTreeDef列表实例化SkillTreeNodeUI节点。
|
||||
/// 只负责创建UI元素,不进行数据绑定或位置设置。
|
||||
/// </summary>
|
||||
/// <param name="skillDefs">一层中的技能定义列表。</param>
|
||||
/// <returns>已实例化的SkillTreeNodeUI列表。</returns>
|
||||
private List<SkillTreeNodeUI> GenerateLayerNodesUI(List<SkillTreeDef> skillDefs)
|
||||
{
|
||||
var layerNodesUI = new List<SkillTreeNodeUI>();
|
||||
foreach (var skillDef in skillDefs)
|
||||
{
|
||||
if (skillDef == null) continue; // 防止空引用
|
||||
|
||||
var nodeUI = Instantiate(skillTreeNodeUIPrefab, rectTransform);
|
||||
nodeUI.name = $"SkillNode_{skillDef.defName}";
|
||||
nodeUI.skillTreeDef = skillDef;
|
||||
// 暂时不设置位置,稍后统一布局
|
||||
layerNodesUI.Add(nodeUI);
|
||||
}
|
||||
|
||||
return layerNodesUI;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算一个SkillTreeNodeUI层的总高度和总宽度,用于布局。
|
||||
/// 假定层内的节点是纵向排列的。
|
||||
/// </summary>
|
||||
/// <param name="layerNodes">已实例化的一层SkillTreeNodeUI节点列表。</param>
|
||||
/// <param name="allTaggedNodesInGraph">所有具有指定标签的节点集合,用于过滤父节点。</param>
|
||||
/// <returns>包含总高度和总宽度的元组。</returns>
|
||||
private (float totalHeight, float totalWidth) CalculateLayerDimensions(List<SkillTreeNodeUI> layerNodes,
|
||||
HashSet<SkillTreeDef> allTaggedNodesInGraph)
|
||||
{
|
||||
if (layerNodes == null || !layerNodes.Any())
|
||||
{
|
||||
return (0f, 0f); // 空层,返回0高度和宽度
|
||||
}
|
||||
|
||||
var totalHeight = 0f;
|
||||
var maxWidth = 0f;
|
||||
|
||||
foreach (var nodeUI in layerNodes)
|
||||
{
|
||||
if (nodeUI.skillTreeDef == null)
|
||||
{
|
||||
Debug.LogWarning($"[技能树] 技能树节点UI '{nodeUI.name}' 没有关联的技能定义。跳过此节点的尺寸计算。");
|
||||
continue; // 无法计算,跳过此节点
|
||||
}
|
||||
|
||||
// 精确计算连接线数量:获取所有父节点,并筛选出属于当前tag的父节点
|
||||
var actualLinkLineCount = 0;
|
||||
var directParents = SkillTreeManager.Instance.GetAllDirectParents(nodeUI.skillTreeDef);
|
||||
if (directParents != null)
|
||||
{
|
||||
// 仅计算那些来自当前技能图谱内部的、具有指定tag的父节点作为连接线
|
||||
actualLinkLineCount =
|
||||
directParents.Count(parent => parent != null && allTaggedNodesInGraph.Contains(parent));
|
||||
}
|
||||
|
||||
// 使用实际的连接线数量来计算节点高度
|
||||
totalHeight += nodeUI.GetRequiredNodeHeight(actualLinkLineCount);
|
||||
|
||||
// 更新层的最大宽度
|
||||
if (nodeUI.rectTransform != null)
|
||||
{
|
||||
maxWidth = Mathf.Max(maxWidth, nodeUI.rectTransform.rect.width);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[技能树] 技能树节点UI '{nodeUI.name}' 的 RectTransform 为空。无法计算宽度。");
|
||||
}
|
||||
}
|
||||
|
||||
// 添加节点之间的垂直间距
|
||||
if (layerNodes.Count > 1)
|
||||
{
|
||||
totalHeight += (layerNodes.Count - 1) * NODE_VERTICAL_SPACING;
|
||||
}
|
||||
|
||||
return (totalHeight, maxWidth);
|
||||
}
|
||||
|
||||
public (float width, float height) CalculatePageSize(List<(float width, float height)> treeSize)
|
||||
{
|
||||
if (treeSize == null || !treeSize.Any())
|
||||
{
|
||||
// 如果列表为空,可以返回默认值或者抛出异常
|
||||
return (PAGE_PADDING_X * 2, PAGE_PADDING_Y * 2);
|
||||
}
|
||||
|
||||
// 高度相加
|
||||
var totalHeight = treeSize.Sum(node => node.height);
|
||||
// 加上 NODE_VERTICAL_SPACING * (数量 - 1)
|
||||
if (treeSize.Count > 1)
|
||||
{
|
||||
totalHeight += NODE_VERTICAL_SPACING * (treeSize.Count - 1);
|
||||
}
|
||||
|
||||
// 加上 PAGE_PADDING_Y * 2
|
||||
totalHeight += PAGE_PADDING_Y * 2;
|
||||
// 宽度取大值
|
||||
var maxWidth = treeSize.Max(node => node.width);
|
||||
// 加上 PAGE_PADDING_X * 2
|
||||
var totalWidth = maxWidth + PAGE_PADDING_X * 2;
|
||||
return (totalWidth, totalHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据指定的标签生成并布局所有独立的技能树。
|
||||
/// 该方法将根据内部逻辑将技能树分解为独立的组件和层级,然后将它们实例化为UI节点并定位。
|
||||
/// </summary>
|
||||
/// <param name="tag">要生成和布局的技能树标签。</param>
|
||||
public void GenerateAndLayoutAllSkillTrees(string tag)
|
||||
{
|
||||
// 1. 清理现有UI节点
|
||||
// 遍历rectTransform的所有子对象,销毁所有SkillTreeNodeUI实例。
|
||||
// 使用ToList()避免在循环中修改集合 (重要:否则会在循环中修改集合导致迭代器失效)
|
||||
foreach (var child in rectTransform.transform.Cast<Transform>().ToList())
|
||||
{
|
||||
if (child.GetComponent<SkillTreeNodeUI>() != null)
|
||||
{
|
||||
Destroy(child.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 获取所有独立的技能树的层级结构
|
||||
// Outer List: 独立的技能树
|
||||
// Middle List: 某棵树的层
|
||||
// Inner List: 某层中的技能定义
|
||||
var allSeparateTreesLayers = GenerateSkillTreeLayers(tag);
|
||||
if (allSeparateTreesLayers == null || !allSeparateTreesLayers.Any())
|
||||
{
|
||||
Debug.LogWarning($"[SkillTreePageUI] 没有找到标签 '{tag}' 的技能树节点或未能生成层级。");
|
||||
// 如果没有内容,将页面尺寸重置为0,或者保持默认大小
|
||||
rectTransform.sizeDelta = Vector2.zero; // 或者根据需要设置一个默认值
|
||||
return;
|
||||
}
|
||||
|
||||
var allSeparateTreesLayersHashSet = new HashSet<SkillTreeDef>(
|
||||
allSeparateTreesLayers.Where(outerList => outerList != null) // 过滤掉 null 的外层列表
|
||||
.SelectMany(outerList => outerList.Where(middleList => middleList != null)) // 展平外层列表
|
||||
.SelectMany(middleList => middleList.Where(skillTree => skillTree != null)) // 展平中间列表
|
||||
.ToList());
|
||||
|
||||
List<List<List<SkillTreeNodeUI>>> nodes = new();
|
||||
var treeSize = new List<(float, float)>();
|
||||
foreach (var trees in allSeparateTreesLayers)
|
||||
{
|
||||
var treeNodes = new List<List<SkillTreeNodeUI>>();
|
||||
var size = (x: 0f, y: 0f);
|
||||
foreach (var layer in trees)
|
||||
{
|
||||
var layerNodes = GenerateLayerNodesUI(layer);
|
||||
var layerSize = CalculateLayerDimensions(layerNodes, allSeparateTreesLayersHashSet);
|
||||
treeNodes.Add(new List<SkillTreeNodeUI>(layerNodes));
|
||||
size.x += layerSize.totalWidth;
|
||||
size.y = Mathf.Max(size.y, layerSize.totalHeight);
|
||||
}
|
||||
|
||||
size.x += NODE_HORIZONTAL_SPACING * (trees.Count - 1);
|
||||
treeSize.Add(size);
|
||||
nodes.Add(treeNodes);
|
||||
}
|
||||
|
||||
var pageSize = CalculatePageSize(treeSize);
|
||||
rectTransform.sizeDelta = new Vector2(pageSize.width, pageSize.height);
|
||||
|
||||
var treeX = PAGE_PADDING_X;
|
||||
var treeY = PAGE_PADDING_Y;
|
||||
|
||||
for (var i = 0; i < nodes.Count; i++)
|
||||
{
|
||||
var treeNodes = nodes[i];
|
||||
var currentTreeSize = treeSize[i];
|
||||
var layerX = treeX;
|
||||
var layerY = treeY + currentTreeSize.Item2 / 2f;
|
||||
for (var j = 0; j < treeNodes.Count; j++)
|
||||
{
|
||||
var layerNodes = treeNodes[j];
|
||||
var layerSize = CalculateLayerDimensions(layerNodes, allSeparateTreesLayersHashSet);
|
||||
|
||||
var nodeY = layerY - layerSize.totalHeight / 2;
|
||||
foreach (var node in layerNodes)
|
||||
{
|
||||
node.rectTransform.anchoredPosition = new Vector2(layerX, nodeY);
|
||||
Debug.Log($"{layerX},{nodeY}");
|
||||
nodeY += NODE_VERTICAL_SPACING +
|
||||
node.GetRequiredNodeHeight(Managers.SkillTreeManager.Instance
|
||||
.GetAllDirectParents(node.skillTreeDef).Count);
|
||||
}
|
||||
|
||||
layerX += NODE_HORIZONTAL_SPACING + layerSize.totalWidth;
|
||||
}
|
||||
|
||||
treeY += currentTreeSize.Item2 + NODE_VERTICAL_SPACING;
|
||||
}
|
||||
|
||||
foreach (var i in nodes)
|
||||
{
|
||||
foreach (var j in i)
|
||||
{
|
||||
foreach (var nodeUI in j)
|
||||
{
|
||||
if(string.IsNullOrEmpty(nodeUI.skillTreeDef.position))
|
||||
continue;
|
||||
var pos=Utils.StringUtils.StringToVector2(nodeUI.skillTreeDef.position);
|
||||
nodeUI.rectTransform.anchoredPosition = pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sortedNodes = nodes
|
||||
.SelectMany(middle => middle) // List<List<SkillTreeNodeUI>>
|
||||
.SelectMany(inner => inner) // SkillTreeNodeUI
|
||||
.OrderByDescending(n => n.rectTransform.localPosition.y) // 或 OrderBy
|
||||
.ToArray();
|
||||
var nodeDefToNodeUI = nodes
|
||||
.SelectMany(middle => middle)
|
||||
.SelectMany(inner => inner)
|
||||
.Where(node => node != null && node.skillTreeDef != null)
|
||||
.GroupBy(node => node.skillTreeDef)
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
foreach (var node in sortedNodes)
|
||||
{
|
||||
var parentOutputPoint = new List<Vector2>();
|
||||
var parentNodes=SkillTreeManager.Instance.GetAllDirectParents(node.skillTreeDef);
|
||||
ReorderParentNodesBySortedNodesAlternative(sortedNodes,parentNodes);
|
||||
foreach (var parentNodeDef in parentNodes)
|
||||
{
|
||||
var index = GetChildOrderUnderParent(sortedNodes, node.skillTreeDef, parentNodeDef, nodeDefToNodeUI);
|
||||
var parentNode=nodeDefToNodeUI[parentNodeDef];
|
||||
var outputCount = SkillTreeManager.Instance.GetAllDirectChildren(parentNodeDef).Count;
|
||||
var point= parentNode.GetOutputPosition(outputCount,index);
|
||||
parentOutputPoint.Add(point);
|
||||
}
|
||||
|
||||
node.Init(parentOutputPoint.ToArray(), node.skillTreeDef.label);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取子节点在父节点下的顺序。
|
||||
/// 顺序基于 sortedNodes (Y轴位置降序) 的排列。
|
||||
/// </summary>
|
||||
/// <param name="sortedNodes">所有UI节点,已按Y轴位置降序排序。</param>
|
||||
/// <param name="childDef">要查询顺序的子节点的 SkillTreeDef。</param>
|
||||
/// <param name="parentDef">子节点所属的父节点的 SkillTreeDef。</param>
|
||||
/// <param name="nodeDefToNodeUI">SkillTreeDef 到 SkillTreeNodeUI 的映射字典。</param>
|
||||
/// <returns>子节点在父节点下的0-based顺序,如果找不到或关系不正确则返回 -1。</returns>
|
||||
private static int GetChildOrderUnderParent(
|
||||
SkillTreeNodeUI[] sortedNodes,
|
||||
SkillTreeDef childDef,
|
||||
SkillTreeDef parentDef,
|
||||
Dictionary<SkillTreeDef, SkillTreeNodeUI> nodeDefToNodeUI)
|
||||
{
|
||||
// 1. 参数验证
|
||||
if (sortedNodes == null || sortedNodes.Length == 0 || childDef == null || parentDef == null ||
|
||||
nodeDefToNodeUI == null)
|
||||
{
|
||||
Debug.LogWarning("GetChildOrderUnderParent: Input parameters cannot be null or empty.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 2. 获取父节点的所有直接子节点 (SkillTreeDef)
|
||||
var allDirectChildrenDefs =
|
||||
Managers.SkillTreeManager.Instance.GetAllDirectChildren(parentDef);
|
||||
if (allDirectChildrenDefs == null || allDirectChildrenDefs.Count == 0)
|
||||
{
|
||||
// 父节点没有直接子节点
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 验证 childDef 是否确实是 parentDef 的直接子节点
|
||||
if (!allDirectChildrenDefs.Contains(childDef))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 3. 从 sortedNodes 中筛选出属于 allDirectChildrenDefs 且在 nodeDefToNodeUI 中有对应UI的节点
|
||||
// 由于 sortedNodes 已经按 Y 轴排序,过滤后的列表也保持这个顺序
|
||||
var orderedSiblingsUI = sortedNodes
|
||||
.Where(nodeUI =>
|
||||
nodeUI != null &&
|
||||
nodeUI.skillTreeDef != null &&
|
||||
allDirectChildrenDefs.Contains(nodeUI.skillTreeDef) &&
|
||||
nodeDefToNodeUI.ContainsKey(nodeUI.skillTreeDef) // 确保有UI对象映射
|
||||
)
|
||||
.ToList();
|
||||
// 4. 查找 childDef 在 orderedSiblingsUI 中的索引
|
||||
// 遍历找到 childDef 对应的 UI 节点
|
||||
for (var i = 0; i < orderedSiblingsUI.Count; i++)
|
||||
{
|
||||
if (orderedSiblingsUI[i].skillTreeDef == childDef)
|
||||
{
|
||||
return i; // 返回 0-based 索引
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 childDef 找不到对应的 UI 节点,或者不在 orderedSiblingsUI 列表中(尽管前面检查过关系)
|
||||
// 这种情况理论上不应该发生,除非 nodeDefToNodeUI 或 sortedNodes 有问题
|
||||
Debug.LogWarning(
|
||||
$"GetChildOrderUnderParent: Could not find UI node for childDef '{childDef.defName}' among its siblings, or mapping is inconsistent.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
void ReorderParentNodesBySortedNodesAlternative(SkillTreeNodeUI[] sortedNodes, List<SkillTreeDef> parentNodes)
|
||||
{
|
||||
var set = new HashSet<SkillTreeDef>(parentNodes); // 需要重排的集合
|
||||
var result = new List<SkillTreeDef>(parentNodes.Count);
|
||||
|
||||
foreach (var node in sortedNodes)
|
||||
{
|
||||
var def = node.skillTreeDef;
|
||||
if (def != null && set.Remove(def)) // 存在则添加并从集合中移除,避免重复
|
||||
{
|
||||
result.Add(def);
|
||||
}
|
||||
}
|
||||
|
||||
// 把剩余的(未在 sortedNodes 中出现的)按原顺序追加
|
||||
foreach (var def in parentNodes)
|
||||
{
|
||||
if (set.Contains(def))
|
||||
result.Add(def);
|
||||
}
|
||||
|
||||
parentNodes.Clear();
|
||||
parentNodes.AddRange(result);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
3
Client/Assets/Scripts/UI/SkillTreePageUI.cs.meta
Normal file
3
Client/Assets/Scripts/UI/SkillTreePageUI.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2de037c48d21426ba6aeade2585df058
|
||||
timeCreated: 1757599387
|
||||
175
Client/Assets/Scripts/UI/SkillTreeUI.cs
Normal file
175
Client/Assets/Scripts/UI/SkillTreeUI.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace UI
|
||||
{
|
||||
public class SkillTreeUI : FullScreenUI
|
||||
{
|
||||
public SkillTreePageUI skillTreePageUIPrefab;
|
||||
public Transform skillTreePageUIParent; // 用于承载 SkillTreePageUI 实例的 Transform
|
||||
|
||||
private List<SkillTreePageUI> _skillTreePageUIs = new List<SkillTreePageUI>();
|
||||
private int _currentPageIndex = 0; // 当前显示的页面索引
|
||||
// 动画相关
|
||||
private bool _isAnimating = false;
|
||||
private float _animationProgress = 0f; // 0到1的进度
|
||||
[SerializeField] private float _animationDuration = 0.3f; // 动画持续时间
|
||||
private int _animationDirection = 0; // -1:左翻页, 1:右翻页 (或0表示无动画)
|
||||
private SkillTreePageUI _currentMovingPage; // 当前正在移动的页面
|
||||
private SkillTreePageUI _targetMovingPage; // 正在移入的页面
|
||||
private float _pageWidth; // 页面的标准宽度,用于计算滑动位置
|
||||
private void Start()
|
||||
{
|
||||
// 获取所有技能树标签
|
||||
var allTags = Managers.SkillTreeManager.Instance.GetAllTag();
|
||||
if (allTags == null || allTags.Length == 0)
|
||||
{
|
||||
Debug.LogWarning("No skill tree tags found. SkillTreeUI will be empty.");
|
||||
// 可以在这里禁用翻页按钮或显示提示
|
||||
return;
|
||||
}
|
||||
// [改进] 动态获取页面的宽度
|
||||
// 为了准确获取 prefab 的尺寸,最好在编辑器中设置好 prefab 的 RectTransform,
|
||||
// 或者在这里实例化一个临时对象来获取其 RectTransform 信息。
|
||||
var prefabRect = skillTreePageUIPrefab.GetComponent<RectTransform>();
|
||||
if (prefabRect != null)
|
||||
{
|
||||
_pageWidth = prefabRect.rect.width;
|
||||
// 如果父级有LayoutGroup或者Canvas Scaler可能影响实际尺寸,需注意
|
||||
// 如果 prefab 是撑满 FullScreenUI 的,那 _pageWidth 应该等于 FullScreenUI 的宽度
|
||||
// 这里假设 SkillTreePageUI 会充满其父级,或者具有固定宽度。
|
||||
// 简单起见,我们也可以假设它充满父级,那么 _pageWidth = parent.GetComponent<RectTransform>().rect.width;
|
||||
if (skillTreePageUIParent != null)
|
||||
{
|
||||
_pageWidth = skillTreePageUIParent.GetComponent<RectTransform>().rect.width;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("SkillTreePageUI Prefab does not have a RectTransform!");
|
||||
_pageWidth = 1920; // fallback default
|
||||
}
|
||||
// 为每个标签实例化并初始化 SkillTreePageUI
|
||||
foreach (var tag in allTags)
|
||||
{
|
||||
var newPage = Instantiate(skillTreePageUIPrefab, skillTreePageUIParent);
|
||||
newPage.name = $"SkillTreePage_{tag}"; // 方便在 Hierarchy 中识别
|
||||
|
||||
// 设置 RectTransform 属性,确保页面正确布局
|
||||
var pageRectTransform = newPage.GetComponent<RectTransform>();
|
||||
pageRectTransform.anchorMin = Vector2.zero;
|
||||
pageRectTransform.anchorMax = Vector2.one;
|
||||
pageRectTransform.pivot = new Vector2(0.5f, 0.5f); // 中心
|
||||
pageRectTransform.anchoredPosition = Vector2.zero;
|
||||
pageRectTransform.sizeDelta = Vector2.zero; // 撑满父级
|
||||
newPage.GenerateAndLayoutAllSkillTrees(tag);
|
||||
_skillTreePageUIs.Add(newPage);
|
||||
// 初始状态下所有页面都先设为激活,通过位置控制显示
|
||||
newPage.gameObject.SetActive(true);
|
||||
}
|
||||
// 根据 _currentPageIndex 设置所有页面的初始位置
|
||||
// 只有当前页面在中央 (0,0),其他页面在屏幕外
|
||||
for (var i = 0; i < _skillTreePageUIs.Count; i++)
|
||||
{
|
||||
_skillTreePageUIs[i].GetComponent<RectTransform>().anchoredPosition =
|
||||
new Vector2(_pageWidth * (i - _currentPageIndex), 0);
|
||||
}
|
||||
// 确保当前页面在层级的最上方,防止被其他未隐藏的页面遮挡
|
||||
if (_skillTreePageUIs.Count > 0)
|
||||
{
|
||||
_skillTreePageUIs[_currentPageIndex].transform.SetAsLastSibling();
|
||||
}
|
||||
}
|
||||
// 实现翻页动画
|
||||
public override void TickUI()
|
||||
{
|
||||
base.TickUI();
|
||||
if (_isAnimating)
|
||||
{
|
||||
_animationProgress += Time.deltaTime / _animationDuration;
|
||||
_animationProgress = Mathf.Clamp01(_animationProgress);
|
||||
// 计算当前页面的目标位置:从 0 移动到 -_pageWidth * _animationDirection
|
||||
var currentX = Mathf.Lerp(0, -_pageWidth * _animationDirection, _animationProgress);
|
||||
_currentMovingPage.GetComponent<RectTransform>().anchoredPosition = new Vector2(currentX, 0);
|
||||
// 计算目标页面的目标位置:从 _pageWidth * _animationDirection 移动到 0
|
||||
var targetX = Mathf.Lerp(_pageWidth * _animationDirection, 0, _animationProgress);
|
||||
_targetMovingPage.GetComponent<RectTransform>().anchoredPosition = new Vector2(targetX, 0);
|
||||
if (_animationProgress >= 1f)
|
||||
{
|
||||
// 动画结束
|
||||
_isAnimating = false;
|
||||
_animationDirection = 0; // 重置动画方向
|
||||
// 更新当前页面索引 (使用 _targetMovingPage 的索引)
|
||||
_currentPageIndex = _skillTreePageUIs.IndexOf(_targetMovingPage);
|
||||
// 确保新的当前页面在正确位置 (0,0)
|
||||
_targetMovingPage.GetComponent<RectTransform>().anchoredPosition = Vector2.zero;
|
||||
// 隐藏所有非当前页面,将其放置到屏幕外,减少渲染负担
|
||||
for (var i = 0; i < _skillTreePageUIs.Count; i++)
|
||||
{
|
||||
if (i != _currentPageIndex)
|
||||
{
|
||||
// 将非当前页面放置到正确的位置,以便下次翻页时能从正确位置移入
|
||||
_skillTreePageUIs[i].GetComponent<RectTransform>().anchoredPosition =
|
||||
new Vector2(_pageWidth * (i - _currentPageIndex), 0);
|
||||
}
|
||||
}
|
||||
// 确保新的当前页面在层级最上方
|
||||
_skillTreePageUIs[_currentPageIndex].transform.SetAsLastSibling();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 控制切换到上一页。
|
||||
/// </summary>
|
||||
public void TurnPageLeft()
|
||||
{
|
||||
if (_isAnimating) return; // 动画进行中,忽略新的翻页请求
|
||||
// 检查是否已经到达第一页
|
||||
if (_currentPageIndex <= 0)
|
||||
{
|
||||
Debug.Log("SkillTreeUI: Already at the first page.");
|
||||
return;
|
||||
}
|
||||
_animationDirection = -1; // 左翻页方向
|
||||
StartPageTurnAnimation(_currentPageIndex - 1);
|
||||
}
|
||||
/// <summary>
|
||||
/// 控制切换到下一页。
|
||||
/// </summary>
|
||||
public void TurnPageRight()
|
||||
{
|
||||
if (_isAnimating) return; // 动画进行中,忽略新的翻页请求
|
||||
// 检查是否已经到达最后一页
|
||||
if (_currentPageIndex >= _skillTreePageUIs.Count - 1)
|
||||
{
|
||||
Debug.Log("SkillTreeUI: Already at the last page.");
|
||||
return;
|
||||
}
|
||||
_animationDirection = 1; // 右翻页方向
|
||||
StartPageTurnAnimation(_currentPageIndex + 1);
|
||||
}
|
||||
/// <summary>
|
||||
/// 启动翻页动画的内部方法。
|
||||
/// </summary>
|
||||
/// <param name="targetPageIndex">目标页面的索引。</param>
|
||||
private void StartPageTurnAnimation(int targetPageIndex)
|
||||
{
|
||||
_isAnimating = true;
|
||||
_animationProgress = 0f; // 重置动画进度
|
||||
_currentMovingPage = _skillTreePageUIs[_currentPageIndex];
|
||||
_targetMovingPage = _skillTreePageUIs[targetPageIndex];
|
||||
// 确保参与动画的两个页面都是激活状态
|
||||
_currentMovingPage.gameObject.SetActive(true);
|
||||
_targetMovingPage.gameObject.SetActive(true);
|
||||
// 将目标页面初始位置设置在当前页的左侧或右侧
|
||||
// 例如,如果向右翻页 (_animationDirection = 1),目标页从右边 (_pageWidth) 滑入
|
||||
_targetMovingPage.GetComponent<RectTransform>().anchoredPosition =
|
||||
new Vector2(_pageWidth * _animationDirection, 0);
|
||||
// 确保目标页面在当前页面之上,以便在滑动时覆盖旧页面
|
||||
_targetMovingPage.transform.SetAsLastSibling();
|
||||
}
|
||||
// 可以添加一个 GoToPage(int pageIndex) 方法来直接跳转到某一页
|
||||
// 这里为了简化,只实现了左右翻页
|
||||
}
|
||||
}
|
||||
3
Client/Assets/Scripts/UI/SkillTreeUI.cs.meta
Normal file
3
Client/Assets/Scripts/UI/SkillTreeUI.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7ad8ef90e9e043f09c09d806ff37a4f6
|
||||
timeCreated: 1757744217
|
||||
175
Client/Assets/Scripts/UI/UIBezierCurveGenerator.cs
Normal file
175
Client/Assets/Scripts/UI/UIBezierCurveGenerator.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace UI
|
||||
{
|
||||
/// <summary>
|
||||
/// UI贝塞尔曲线生成器。
|
||||
/// 此组件用于生成和显示三次贝塞尔曲线,并将其点数据传递给UILineRenderer进行绘制。
|
||||
/// </summary>
|
||||
// 确保当前GameObject上存在UILineRenderer组件,如果不存在则会自动添加。
|
||||
[RequireComponent(typeof(UILineRenderer))]
|
||||
// 允许在编辑器模式下,当参数修改时实时更新曲线,便于调试和预览。
|
||||
[ExecuteAlways]
|
||||
public class UIBezierCurveGenerator : MonoBehaviour
|
||||
{
|
||||
// 对UILineRenderer组件的引用,用于绘制生成的贝塞尔曲线。
|
||||
[SerializeField] public UILineRenderer lineRenderer;
|
||||
|
||||
// 贝塞尔曲线的四个控制点。
|
||||
[Header("贝塞尔控制点")]
|
||||
public Vector2 P0; // 曲线的起始点。
|
||||
public Vector2 P1; // 曲线的第一个控制点,影响曲线从P0开始的方向和曲率。
|
||||
public Vector2 P2; // 曲线的第二个控制点,影响曲线在接近P3时的方向和曲率。
|
||||
public Vector2 P3; // 曲线的终止点。
|
||||
|
||||
// 曲线的设置参数。
|
||||
[Header("曲线设置")]
|
||||
[Range(5, 200)] // 限制曲线段数的范围,确保曲线平滑度和性能之间的平衡。
|
||||
public int segmentCount = 50; // 用于近似曲线的线段数量,值越大曲线越平滑。
|
||||
|
||||
/// <summary>
|
||||
/// 当脚本实例被启用时,或者首次加载时调用。
|
||||
/// </summary>
|
||||
void Awake()
|
||||
{
|
||||
// 初始化组件,获取UILineRenderer的引用。
|
||||
Initialize();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当在编辑器中修改脚本的属性时调用。
|
||||
/// </summary>
|
||||
void OnValidate()
|
||||
{
|
||||
// 初始化组件,获取UILineRenderer的引用。
|
||||
Initialize();
|
||||
// 如果UILineRenderer组件有效,则在编辑器中实时重新生成曲线。
|
||||
if (lineRenderer != null)
|
||||
{
|
||||
GenerateCurvePoints();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化组件,获取 UILineRenderer 引用。
|
||||
/// </summary>
|
||||
private void Initialize()
|
||||
{
|
||||
// 如果UILineRenderer引用为空,则尝试获取组件。
|
||||
if (lineRenderer == null)
|
||||
{
|
||||
lineRenderer = GetComponent<UILineRenderer>();
|
||||
// 如果仍然无法获取UILineRenderer组件,则报错并禁用此组件。
|
||||
if (lineRenderer == null)
|
||||
{
|
||||
Debug.LogError("UILineRenderer组件未在此GameObject上找到,请添加一个。", this);
|
||||
enabled = false; // 禁用此组件实例,以防止后续空引用错误。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算三次贝塞尔曲线上的特定点。
|
||||
/// 贝塞尔曲线公式: B(t) = (1-t)^3 * P0 + 3 * (1-t)^2 * t * P1 + 3 * (1-t) * t^2 * P2 + t^3 * P3
|
||||
/// </summary>
|
||||
/// <param name="t">参数,表示曲线上的位置,范围 [0, 1]。</param>
|
||||
/// <param name="p0">曲线的起始点。</param>
|
||||
/// <param name="p1">曲线的第一个控制点。</param>
|
||||
/// <param name="p2">曲线的第二个控制点。</param>
|
||||
/// <param name="p3">曲线的终止点。</param>
|
||||
/// <returns>在参数 t 处的贝塞尔曲线上点的二维坐标。</returns>
|
||||
private Vector2 CalculateBezierPoint(float t, Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3)
|
||||
{
|
||||
var u = 1 - t; // 计算 (1-t)
|
||||
var tt = t * t; // 计算 t的平方
|
||||
var uu = u * u; // 计算 (1-t)的平方
|
||||
var uuu = uu * u; // 计算 (1-t)的立方
|
||||
var ttt = tt * t; // 计算 t的立方
|
||||
|
||||
var p = uuu * p0; // 计算 (1-t)^3 * P0
|
||||
p += 3 * uu * t * p1; // 计算 3 * (1-t)^2 * t * P1
|
||||
p += 3 * u * tt * p2; // 计算 3 * (1-t) * t^2 * P2
|
||||
p += ttt * p3; // 计算 t^3 * P3
|
||||
|
||||
return p; // 返回计算出的贝塞尔曲线上点。
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据当前的控制点和段数生成贝塞尔曲线上的所有点,并更新 UILineRenderer 进行绘制。
|
||||
/// </summary>
|
||||
public void GenerateCurvePoints()
|
||||
{
|
||||
// 如果UILineRenderer组件无效,则无法生成曲线。
|
||||
if (!lineRenderer)
|
||||
{
|
||||
Debug.LogWarning("UILineRenderer组件为空,无法生成曲线。", this);
|
||||
return;
|
||||
}
|
||||
|
||||
// 清空 UILineRenderer 当前的点列表,为填充新的曲线点做准备。
|
||||
lineRenderer.points.Clear();
|
||||
|
||||
// 遍历并计算曲线上的所有采样点。
|
||||
for (var i = 0; i <= segmentCount; i++)
|
||||
{
|
||||
var t = i / (float)segmentCount; // 计算当前点的归一化参数 [0, 1]。
|
||||
var point = CalculateBezierPoint(t, P0, P1, P2, P3); // 根据参数 t 计算贝塞尔曲线上点坐标。
|
||||
lineRenderer.points.Add(point); // 将计算出的点添加到UILineRenderer的点列表中。
|
||||
}
|
||||
|
||||
// 通知 UILineRenderer 需要重新绘制其几何体,以显示更新后的曲线。
|
||||
lineRenderer.SetAllDirty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提供了通过代码设置贝塞尔曲线控制点的方法,并立即刷新曲线。
|
||||
/// </summary>
|
||||
/// <param name="p0">新的起始点。</param>
|
||||
/// <param name="p1">新的第一个控制点。</param>
|
||||
/// <param name="p2">新的第二个控制点。</param>
|
||||
/// <param name="p3">新的终止点。</param>
|
||||
public void SetControlPoints(Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3)
|
||||
{
|
||||
this.P0 = p0; // 设置起始点。
|
||||
this.P1 = p1; // 设置第一个控制点。
|
||||
this.P2 = p2; // 设置第二个控制点。
|
||||
this.P3 = p3; // 设置终止点。
|
||||
GenerateCurvePoints(); // 更新曲线。
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提供了通过代码设置贝塞尔曲线段数的方法,并立即刷新曲线。
|
||||
/// </summary>
|
||||
/// <param name="count">新的曲线段数。</param>
|
||||
public void SetSegmentCount(int count)
|
||||
{
|
||||
segmentCount = Mathf.Max(5, count); // 设置曲线段数,确保至少为5段以保持一定平滑度。
|
||||
GenerateCurvePoints(); // 更新曲线。
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当组件首次添加到GameObject或在编辑器中选择“Reset”时调用。
|
||||
/// 用于设置组件的默认值。
|
||||
/// </summary>
|
||||
private void Reset()
|
||||
{
|
||||
Initialize(); // 确保UILineRenderer引用已初始化。
|
||||
|
||||
// 获取当前GameObject的RectTransform,以便根据其尺寸设置默认控制点。
|
||||
var rt = GetComponent<RectTransform>();
|
||||
var halfWidth = rt.rect.width / 2f; // 获取RectTransform宽度的一半。
|
||||
var halfHeight = rt.rect.height / 2f; // 获取RectTransform高度的一半。
|
||||
|
||||
// 设置一组默认的控制点,使得曲线在UI区域内可见且具有S形或拱形。
|
||||
// 这些点是相对于RectTransform的局部坐标。
|
||||
P0 = new Vector2(-halfWidth * 0.8f, -halfHeight * 0.5f); // 默认起始点,位于左下。
|
||||
P1 = new Vector2(-halfWidth * 0.4f, halfHeight * 0.8f); // 默认第一个控制点,位于左上。
|
||||
P2 = new Vector2(halfWidth * 0.4f, halfHeight * 0.8f); // 默认第二个控制点,位于右上。
|
||||
P3 = new Vector2(halfWidth * 0.8f, -halfHeight * 0.5f); // 默认终止点,位于右下。
|
||||
|
||||
segmentCount = 50; // 设置默认的曲线段数。
|
||||
|
||||
GenerateCurvePoints(); // 立即根据默认设置生成并显示曲线。
|
||||
}
|
||||
}
|
||||
}
|
||||
3
Client/Assets/Scripts/UI/UIBezierCurveGenerator.cs.meta
Normal file
3
Client/Assets/Scripts/UI/UIBezierCurveGenerator.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8623ad98baf0495b8c8634232e3cae2e
|
||||
timeCreated: 1757418458
|
||||
439
Client/Assets/Scripts/UI/UILineRenderer.cs
Normal file
439
Client/Assets/Scripts/UI/UILineRenderer.cs
Normal file
@@ -0,0 +1,439 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using static UI.UILineRenderer; // 允许LineCapType直接访问
|
||||
|
||||
// 确保在UI命名空间内
|
||||
namespace UI
|
||||
{
|
||||
/// <summary>
|
||||
/// UILineRenderer 是一个用于在UI中绘制自定义线条的Graphic组件。
|
||||
/// 它支持设置线条宽度、渐变颜色以及不同类型的线头(方形或圆形)。
|
||||
/// 同时支持线段之间的斜切连接,以确保转角平滑。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(CanvasRenderer))]
|
||||
public class UILineRenderer : Graphic
|
||||
{
|
||||
/// <summary>
|
||||
/// 存储构成线条的顶点列表。
|
||||
/// </summary>
|
||||
public List<Vector2> points = new List<Vector2>();
|
||||
|
||||
/// <summary>
|
||||
/// 线的宽度。
|
||||
/// </summary>
|
||||
[SerializeField] private float lineWidth = 5f;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置线的宽度。
|
||||
/// 设置新值时,如果宽度发生改变,将触发UI网格的重新绘制。
|
||||
/// </summary>
|
||||
public float LineWidth
|
||||
{
|
||||
get => lineWidth;
|
||||
set
|
||||
{
|
||||
// 仅当值发生显著变化时才更新,以避免不必要的重绘。
|
||||
// Mathf.Abs(lineWidth - value) > float.Epsilon 是一个更健壮的浮点数比较方式。
|
||||
if (Mathf.Abs(lineWidth - value) > float.Epsilon)
|
||||
{
|
||||
lineWidth = value;
|
||||
SetVerticesDirty(); // 标记UI网格需要重新生成。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于线条的渐变颜色。
|
||||
/// </summary>
|
||||
public Gradient lineGradient = new Gradient();
|
||||
|
||||
/// <summary>
|
||||
/// 定义线条两端的线头类型。
|
||||
/// </summary>
|
||||
public enum LineCapType
|
||||
{
|
||||
/// <summary>
|
||||
/// 无线头。
|
||||
/// </summary>
|
||||
None,
|
||||
/// <summary>
|
||||
/// 方形线头。
|
||||
/// </summary>
|
||||
Square,
|
||||
/// <summary>
|
||||
/// 圆形线头。
|
||||
/// </summary>
|
||||
Round
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 线的起始端线头类型。
|
||||
/// </summary>
|
||||
public LineCapType startCap = LineCapType.None;
|
||||
|
||||
/// <summary>
|
||||
/// 线的结束端线头类型。
|
||||
/// </summary>
|
||||
public LineCapType endCap = LineCapType.None;
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于UI渲染的相机。
|
||||
/// 如果Canvas的渲染模式是Screen Space - Overlay,则返回null,此时不需要相机。
|
||||
/// </summary>
|
||||
/// <returns>用于UI渲染的Camera实例,或在Overlay模式下返回null。</returns>
|
||||
private Camera GetCanvasRenderCamera()
|
||||
{
|
||||
Canvas _canvas = GetComponentInParent<Canvas>();
|
||||
// 作为Graphic组件,总会有一个Canvas作为父级。
|
||||
if (_canvas == null) return null;
|
||||
// 对于ScreenSpaceOverlay模式,不需要相机来将屏幕点转换为世界点。
|
||||
return _canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : _canvas.worldCamera;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在UI需要被重新绘制时调用,用于生成网格顶点数据。
|
||||
/// </summary>
|
||||
/// <param name="vh">VertexHelper,用于构建UI元素的网格。</param>
|
||||
protected override void OnPopulateMesh(VertexHelper vh)
|
||||
{
|
||||
vh.Clear();
|
||||
|
||||
// 如果点列表为空或点的数量不足以构成线段,则不绘制任何东西。
|
||||
if (points == null || points.Count < 2)
|
||||
return;
|
||||
|
||||
// 如果渐变色对象未设置或颜色键为空,则默认创建一个纯白色渐变。
|
||||
if (lineGradient == null || lineGradient.colorKeys.Length == 0)
|
||||
{
|
||||
lineGradient = new Gradient();
|
||||
lineGradient.SetKeys(
|
||||
new[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) },
|
||||
new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(1f, 1f) }
|
||||
);
|
||||
}
|
||||
|
||||
List<float> segmentLengths = new List<float>();
|
||||
float totalLength = 0f;
|
||||
|
||||
// 预计算线条总长度和每段长度,用于渐变色和线头定位
|
||||
for (int i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
float len = Vector2.Distance(points[i], points[i + 1]);
|
||||
segmentLengths.Add(len);
|
||||
totalLength += len;
|
||||
}
|
||||
|
||||
// 存储计算出的所有顶点,包括内外侧及颜色信息
|
||||
List<UIVertex> vertices = new List<UIVertex>();
|
||||
|
||||
float currentRenderedLength = 0f;
|
||||
float halfWidth = lineWidth / 2f;
|
||||
const float miterLimit = 4f; // Miter limit to prevent excessively long miters
|
||||
|
||||
for (int i = 0; i < points.Count; i++)
|
||||
{
|
||||
Vector2 p = points[i];
|
||||
Vector2 currentPerp = Vector2.zero; // 垂直于期望线段方向的偏移向量
|
||||
|
||||
// 计算当前点的渐变颜色
|
||||
float normalizedDistance = (totalLength > 0 && points.Count > 1) ? (currentRenderedLength / totalLength) : 0f;
|
||||
Color pointColor = lineGradient.Evaluate(normalizedDistance);
|
||||
|
||||
if (i == 0) // 第一个点 (起点)
|
||||
{
|
||||
// 使用第一段的法线方向
|
||||
Vector2 segmentDir = (points[1] - p).normalized;
|
||||
currentPerp = new Vector2(-segmentDir.y, segmentDir.x) * halfWidth;
|
||||
}
|
||||
else if (i == points.Count - 1) // 最后一个点 (终点)
|
||||
{
|
||||
// 使用最后一段的法线方向
|
||||
Vector2 segmentDir = (p - points[points.Count - 2]).normalized;
|
||||
currentPerp = new Vector2(-segmentDir.y, segmentDir.x) * halfWidth;
|
||||
}
|
||||
else // 中间点 (转角)
|
||||
{
|
||||
Vector2 prevDir = (p - points[i - 1]).normalized;
|
||||
Vector2 nextDir = (points[i + 1] - p).normalized;
|
||||
|
||||
// 计算两条线段的平均法线 (角平分线方向)
|
||||
Vector2 angleBisector = (prevDir + nextDir).normalized;
|
||||
|
||||
// 计算垂直于角平分线的向量
|
||||
Vector2 bisectorPerp = new Vector2(-angleBisector.y, angleBisector.x);
|
||||
|
||||
// 确定 bisectorPerp 的方向,使其始终指向“外侧”
|
||||
// 通过检查 prevDir 和 bisectorPerp 的点积来判断方向
|
||||
// 如果点积为负,表示 prevDir 的“向左”方向(perp)与 bisectorPerp 方向相反,需要翻转
|
||||
// (prevDir.x * bisectorPerp.y - prevDir.y * bisectorPerp.x) > 0 表示 bisectorPerp 在 prevDir 的左侧
|
||||
// (prevDir.x * bisectorPerp.y - prevDir.y * bisectorPerp.x) < 0 表示 bisectorPerp 在 prevDir 的右侧
|
||||
// 更直观的判断是看prevDir的右向量和bisectorPerp是否同向
|
||||
Vector2 prevDirPerp = new Vector2(-prevDir.y, prevDir.x); // prevDir的左侧法线
|
||||
if (Vector2.Dot(prevDirPerp, bisectorPerp) < 0) // 如果bisectorPerp不是指向prevDir左侧,则翻转
|
||||
{
|
||||
bisectorPerp *= -1;
|
||||
}
|
||||
|
||||
// 计算斜切的延伸长度
|
||||
// 角度越尖锐,cos(angle/2) 越小,miterFactor 越大
|
||||
float angleRad = Vector2.Angle(prevDir, nextDir) * Mathf.Deg2Rad;
|
||||
float miterLengthFactor = halfWidth / Mathf.Max(0.001f, Mathf.Cos(angleRad / 2f)); // 防止除以0
|
||||
|
||||
// 应用miter limit,避免极端尖锐角造成过长斜切
|
||||
miterLengthFactor = Mathf.Min(miterLengthFactor, lineWidth * miterLimit);
|
||||
|
||||
currentPerp = bisectorPerp * miterLengthFactor;
|
||||
}
|
||||
|
||||
// 添加内外侧顶点
|
||||
// Outer point
|
||||
vertices.Add(new UIVertex
|
||||
{
|
||||
position = p + currentPerp,
|
||||
color = pointColor,
|
||||
uv0 = new Vector2(0, normalizedDistance) // Simple UV mapping for gradient, 0 for outer
|
||||
});
|
||||
// Inner point
|
||||
vertices.Add(new UIVertex
|
||||
{
|
||||
position = p - currentPerp,
|
||||
color = pointColor,
|
||||
uv0 = new Vector2(1, normalizedDistance) // Simple UV mapping for gradient, 1 for inner
|
||||
});
|
||||
|
||||
// 更新已渲染长度,用于下一个点的渐变计算
|
||||
if (i < segmentLengths.Count)
|
||||
{
|
||||
currentRenderedLength += segmentLengths[i];
|
||||
}
|
||||
}
|
||||
|
||||
// --- 绘制线段网格 ---
|
||||
for (int i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
int prevOuter = i * 2; // 上一个点的外侧顶点索引
|
||||
int prevInner = i * 2 + 1; // 上一个点的内侧顶点索引
|
||||
int currentOuter = (i + 1) * 2; // 当前点的外侧顶点索引
|
||||
int currentInner = (i + 1) * 2 + 1; // 当前点的内侧顶点索引
|
||||
|
||||
// 添加顶点到 VertexHelper
|
||||
// 确保vh.AddVert的顺序与vertices列表中的顺序一致
|
||||
if (i == 0) // 为第一个点添加顶点
|
||||
{
|
||||
vh.AddVert(vertices[prevOuter]);
|
||||
vh.AddVert(vertices[prevInner]);
|
||||
}
|
||||
vh.AddVert(vertices[currentOuter]);
|
||||
vh.AddVert(vertices[currentInner]);
|
||||
|
||||
// 连接当前点和上一个点的顶点,形成一个矩形(两个三角形)
|
||||
vh.AddTriangle(prevOuter, currentOuter, currentInner);
|
||||
vh.AddTriangle(prevOuter, currentInner, prevInner);
|
||||
}
|
||||
|
||||
// --- 绘制线头 ---
|
||||
if (points.Count >= 2)
|
||||
{
|
||||
// 起点线头
|
||||
Vector2 firstSegmentDirection = (points[1] - points[0]).normalized;
|
||||
DrawCap(vh, points[0], -firstSegmentDirection, startCap, lineGradient.Evaluate(0f));
|
||||
|
||||
// 终点线头
|
||||
Vector2 lastSegmentDirection = (points[points.Count - 1] - points[points.Count - 2]).normalized;
|
||||
DrawCap(vh, points[points.Count - 1], lastSegmentDirection, endCap, lineGradient.Evaluate(1f));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 绘制指定类型的线头。
|
||||
/// </summary>
|
||||
/// <param name="vh">VertexHelper,用于构建线头网格。</param>
|
||||
/// <param name="center">线头的中心(即线条的端点)。</param>
|
||||
/// <param name="direction">线头延伸的方向。</param>
|
||||
/// <param name="capType">线头类型(无、方形或圆形)。</param>
|
||||
/// <param name="capColor">线头的颜色。</param>
|
||||
private void DrawCap(VertexHelper vh, Vector2 center, Vector2 direction, LineCapType capType, Color capColor)
|
||||
{
|
||||
if (capType == LineCapType.None) return;
|
||||
|
||||
// 计算线头垂直于方向的向量,用于确定线头的宽度。
|
||||
var perpendicular = new Vector2(-direction.y, direction.x) * lineWidth / 2f;
|
||||
// 记录当前VertexHelper中的顶点数量。
|
||||
var currentVertCount = vh.currentVertCount;
|
||||
|
||||
UIVertex vertexTemplate = UIVertex.simpleVert;
|
||||
vertexTemplate.color = capColor;
|
||||
|
||||
if (capType == LineCapType.Square)
|
||||
{
|
||||
// 绘制方形线头,通过在端点处添加一个与线段垂直的矩形。
|
||||
Vector2 p0 = center - perpendicular; // 线段边缘点1
|
||||
Vector2 p1 = center + perpendicular; // 线段边缘点2
|
||||
Vector2 p2 = center + perpendicular + (direction * (lineWidth / 2f)); // 延伸点1
|
||||
Vector2 p3 = center - perpendicular + (direction * (lineWidth / 2f)); // 延伸点2
|
||||
|
||||
// 添加方形线头的四个顶点。
|
||||
// 注意:这里需要确保顶点的顺序正确,形成两个三角形
|
||||
vertexTemplate.position = p0; vertexTemplate.uv0 = new Vector2(0f, 0f); vh.AddVert(vertexTemplate); // 0
|
||||
vertexTemplate.position = p1; vertexTemplate.uv0 = new Vector2(1f, 0f); vh.AddVert(vertexTemplate); // 1
|
||||
vertexTemplate.position = p2; vertexTemplate.uv0 = new Vector2(1f, 1f); vh.AddVert(vertexTemplate); // 2
|
||||
vertexTemplate.position = p3; vertexTemplate.uv0 = new Vector2(0f, 1f); vh.AddVert(vertexTemplate); // 3
|
||||
|
||||
// 添加两个三角形组成方形线头。
|
||||
vh.AddTriangle(currentVertCount, currentVertCount + 1, currentVertCount + 2);
|
||||
vh.AddTriangle(currentVertCount, currentVertCount + 2, currentVertCount + 3);
|
||||
}
|
||||
else if (capType == LineCapType.Round)
|
||||
{
|
||||
const int segments = 12; // 增加段数使圆形更平滑
|
||||
float radius = lineWidth / 2f; // 半圆的半径等于线宽的一半。
|
||||
|
||||
// 添加扇形中心点。
|
||||
vertexTemplate.position = center;
|
||||
vertexTemplate.uv0 = new Vector2(0.5f, 0.5f); // 中心UV
|
||||
vh.AddVert(vertexTemplate);
|
||||
var centerVertIndex = currentVertCount;
|
||||
currentVertCount++;
|
||||
|
||||
// 计算半圆弧的起始角度 (垂直向量的角度) 和每段的扫过角度。
|
||||
float baseAngle = Mathf.Atan2(perpendicular.y, perpendicular.x);
|
||||
float angleSweepStep = Mathf.PI / segments; // 180度 / 段数。
|
||||
|
||||
List<int> arcVerticesIndices = new List<int>();
|
||||
|
||||
// 遍历并添加半圆弧上的点。
|
||||
for (int j = 0; j <= segments; j++)
|
||||
{
|
||||
float angle = baseAngle - (j * angleSweepStep); // 逆时针扫描。
|
||||
Vector2 pointOnArc = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
|
||||
|
||||
vertexTemplate.position = center + pointOnArc;
|
||||
// 计算相对中心的UV,使纹理正确映射到圆形。
|
||||
vertexTemplate.uv0 = new Vector2(0.5f + pointOnArc.x / (radius * 2), 0.5f + pointOnArc.y / (radius * 2));
|
||||
vh.AddVert(vertexTemplate);
|
||||
arcVerticesIndices.Add(currentVertCount);
|
||||
currentVertCount++;
|
||||
}
|
||||
|
||||
// 连接扇形中心和弧上的相邻点形成三角形,构成半圆形线头。
|
||||
for (int j = 0; j < arcVerticesIndices.Count - 1; j++)
|
||||
{
|
||||
vh.AddTriangle(centerVertIndex, arcVerticesIndices[j], arcVerticesIndices[j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 将一个UI元素的世界坐标转换为此UILineRenderer的局部坐标,并将其作为线条上的一个点添加。
|
||||
/// </summary>
|
||||
/// <param name="uiElement">要作为线条点的RectTransform UI元素。</param>
|
||||
public void AppendUIElement(RectTransform uiElement)
|
||||
{
|
||||
Vector2 localPoint; // 转换后的局部坐标。
|
||||
|
||||
// 将UI元素的世界坐标转换为屏幕坐标,再将屏幕坐标转换为当前UILineRenderer的局部坐标。
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||||
rectTransform, // 当前 UILineRenderer 的 RectTransform。
|
||||
RectTransformUtility.WorldToScreenPoint(GetCanvasRenderCamera(), uiElement.position), // UI 元素的世界坐标转换为屏幕坐标。
|
||||
GetCanvasRenderCamera(), // 使用正确的相机进行屏幕到局部坐标转换。
|
||||
out localPoint // 输出的局部坐标。
|
||||
);
|
||||
|
||||
// 添加转换后的局部坐标到点列表中。
|
||||
points.Add(localPoint);
|
||||
|
||||
SetVerticesDirty(); // 标记UI网格需要重新生成。
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将鼠标的当前位置作为线条的末端点。
|
||||
/// 该方法支持对已有折线的末端点进行修改,也可用于绘制“橡皮筋”效果。
|
||||
/// </summary>
|
||||
public void SetMouse()
|
||||
{
|
||||
// 如果点列表为空,则无法设置鼠标位置为末端点,直接返回。
|
||||
if (points.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var mousePosition = Input.mousePosition; // 获取当前的鼠标屏幕坐标。
|
||||
Vector2 localPoint;
|
||||
|
||||
// 将鼠标屏幕坐标转换为当前UILineRenderer的局部坐标。
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, mousePosition, GetCanvasRenderCamera(), out localPoint);
|
||||
|
||||
if (points.Count == 1)
|
||||
{
|
||||
// 如果只有一个点,则将鼠标位置作为第二点添加到列表,形成第一条线段。
|
||||
points.Add(localPoint);
|
||||
}
|
||||
else // points.Count >= 2
|
||||
{
|
||||
// 如果已有多个点,则更新列表中的最后一个点为鼠标位置。
|
||||
points[points.Count - 1] = localPoint;
|
||||
}
|
||||
|
||||
SetVerticesDirty(); // 标记UI网格需要重新生成。
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置线的宽度。
|
||||
/// </summary>
|
||||
/// <param name="width">线条的新宽度。</param>
|
||||
public void SetWidth(float width)
|
||||
{
|
||||
LineWidth = width; // 调用公共属性,确保 SetVerticesDirty() 被调用。
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置线条的渐变颜色。
|
||||
/// </summary>
|
||||
/// <param name="newGradient">新的渐变色对象。</param>
|
||||
public void SetGradient(Gradient newGradient)
|
||||
{
|
||||
// 仅当新的渐变色不同于当前渐变色时才进行更新。
|
||||
if (newGradient != null && !newGradient.Equals(lineGradient))
|
||||
{
|
||||
lineGradient = newGradient;
|
||||
SetVerticesDirty(); // 标记UI网格需要重新生成。
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置线条的起始和结束线头类型。
|
||||
/// </summary>
|
||||
/// <param name="startCapType">新的起始线头类型。</param>
|
||||
/// <param name="endCapType">新的结束线头类型。</param>
|
||||
public void SetCaps(LineCapType startCapType, LineCapType endCapType)
|
||||
{
|
||||
// 仅当线头类型发生改变时才更新。
|
||||
if (startCap != startCapType || endCap != endCapType)
|
||||
{
|
||||
startCap = startCapType;
|
||||
endCap = endCapType;
|
||||
SetVerticesDirty(); // 标记UI网格需要重新生成。
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置此UILineRenderer组件的状态,包括清空所有点、重置宽度、渐变色和线头类型。
|
||||
/// </summary>
|
||||
public void ResetSelf()
|
||||
{
|
||||
points.Clear(); // 清空所有线条点。
|
||||
// 重置为默认的纯白色渐变。
|
||||
lineGradient = new Gradient();
|
||||
lineGradient.SetKeys(
|
||||
new[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) },
|
||||
new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(1f, 1f) }
|
||||
);
|
||||
lineWidth = 5f; // 重置线条宽度为默认值。
|
||||
startCap = LineCapType.None; // 重置线头类型为无。
|
||||
endCap = LineCapType.None; // 重置线头类型为无。
|
||||
SetVerticesDirty(); // 标记UI网格需要重新生成。
|
||||
}
|
||||
}
|
||||
}
|
||||
3
Client/Assets/Scripts/UI/UILineRenderer.cs.meta
Normal file
3
Client/Assets/Scripts/UI/UILineRenderer.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a7c56ce7d0f247639e5fff6ebba2edd6
|
||||
timeCreated: 1757415509
|
||||
Reference in New Issue
Block a user