(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:
2025-09-19 08:26:54 +08:00
parent 78849e0cc5
commit 87a8abe86c
282 changed files with 19364 additions and 8824 deletions

View File

@@ -1,4 +1,5 @@
using System;
using Prefab;
using UnityEngine;
using UnityEngine.UI;

View 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=1t=1变为t=0。
if (isFlip)
{
t = 1f - t;
}
// 使用Lerp进行颜色插值
// Color32.Lerp(a, b, t) 表示当 t=0 时取 at=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));
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8df3d16a358d74644b86e92ca5177fa1

View File

@@ -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;

View File

@@ -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);
}
}
}
}

View File

@@ -30,5 +30,10 @@ namespace UI
return;
SceneManager.LoadScene(0);
}
public static void Reload()
{
DevMenuUI.HotReload();
}
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Linq;
using Prefab;
using UnityEngine;
using UnityEngine.UI;

View File

@@ -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);
}
}
}

View 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);
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 4d15100cc2164e78bfaaef16efd7825a
timeCreated: 1757469894

View 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元素移动到结束点的世界坐标。
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 04b3f5cbad874b31893c0d7e1de082b9
timeCreated: 1757470314

View 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);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: a0bd6744a131480d872dc07f318b561d
timeCreated: 1757469859

View 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);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2de037c48d21426ba6aeade2585df058
timeCreated: 1757599387

View 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) 方法来直接跳转到某一页
// 这里为了简化,只实现了左右翻页
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 7ad8ef90e9e043f09c09d806ff37a4f6
timeCreated: 1757744217

View 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(); // 立即根据默认设置生成并显示曲线。
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 8623ad98baf0495b8c8634232e3cae2e
timeCreated: 1757418458

View 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网格需要重新生成。
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: a7c56ce7d0f247639e5fff6ebba2edd6
timeCreated: 1757415509