2026-07-05 16:08:23 -04:00
|
|
|
|
using UnityEngine;
|
|
|
|
|
|
using UnityEngine.UI.Extensions;
|
|
|
|
|
|
|
|
|
|
|
|
namespace Ichni.Story.UI
|
|
|
|
|
|
{
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 故事树中两个 block 之间的连接线视图,使用 UILineRenderer 绘制 S 形曲线。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public class BlockConnectorView : MonoBehaviour
|
|
|
|
|
|
{
|
|
|
|
|
|
// 判定两端口坐标是否塌缩为同一点的阈值(像素)。
|
|
|
|
|
|
private const float DegenerateDistanceThreshold = 1f;
|
|
|
|
|
|
|
|
|
|
|
|
public UILineRenderer curve;
|
|
|
|
|
|
public StoryBlockView startBlock;
|
|
|
|
|
|
public StoryBlockView endBlock;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 设置连接线的起止 block 并重算曲线点。传入 null 时保留已有引用。
|
|
|
|
|
|
/// 注意:必须在 block 的 RectTransform 完成布局(Canvas 已刷新)后调用,
|
|
|
|
|
|
/// 否则端口世界坐标尚未生效,会导致所有顶点塌缩到同一点。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public void SetCurve(StoryBlockView start = null, StoryBlockView end = null)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (start != null) startBlock = start;
|
|
|
|
|
|
if (end != null) endBlock = end;
|
|
|
|
|
|
|
|
|
|
|
|
if (startBlock == null || endBlock == null)
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning("[BlockConnectorView] 起点或终点 block 未设置,无法绘制曲线。");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
RectTransform selfRect = (RectTransform)transform;
|
|
|
|
|
|
|
|
|
|
|
|
// ScreenSpaceCamera / WorldSpace 画布下的世界→屏幕转换需要对应相机;Overlay 传 null。
|
|
|
|
|
|
Camera uiCamera = ResolveUICamera();
|
|
|
|
|
|
|
|
|
|
|
|
Vector2 startPosition = GetLocalPoint(startBlock.outPort, selfRect, uiCamera);
|
|
|
|
|
|
Vector2 endPosition = GetLocalPoint(endBlock.inPort, selfRect, uiCamera);
|
2026-07-20 16:56:04 -04:00
|
|
|
|
Vector2 startBlockCenter = GetLocalRectCenter(startBlock.blockRect, selfRect, uiCamera);
|
|
|
|
|
|
Vector2 endBlockCenter = GetLocalRectCenter(endBlock.blockRect, selfRect, uiCamera);
|
2026-07-05 16:08:23 -04:00
|
|
|
|
|
|
|
|
|
|
if (Vector2.Distance(startPosition, endPosition) < DegenerateDistanceThreshold)
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning(
|
|
|
|
|
|
$"[BlockConnectorView] '{startBlock.blockId}' -> '{endBlock.blockId}' 端口坐标塌缩为同一点 " +
|
|
|
|
|
|
$"(start={startPosition}, end={endPosition}),通常是在 block 布局完成前调用了 SetCurve。");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 两个控制点构成 S 形:先水平离开起点,再水平进入终点。
|
2026-07-20 16:56:04 -04:00
|
|
|
|
// 转折列取两个 Block 的逻辑中心中点,而不是两个端口的中点。
|
|
|
|
|
|
// 这样当目标 Block 的宽度不同(例如 Important 与 Secondary)但位于同一列时,
|
|
|
|
|
|
// 连接线仍会在同一条竖线转折;尺寸差异只影响端口到转折列的水平线长度。
|
|
|
|
|
|
float midX = (startBlockCenter.x + endBlockCenter.x) * 0.5f;
|
2026-07-05 16:08:23 -04:00
|
|
|
|
Vector2 mid1 = new Vector2(midX, startPosition.y);
|
|
|
|
|
|
Vector2 mid2 = new Vector2(midX, endPosition.y);
|
|
|
|
|
|
|
|
|
|
|
|
curve.Points = new[] { startPosition, mid1, mid2, endPosition };
|
|
|
|
|
|
curve.SetVerticesDirty();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 使用 <see cref="RectTransformUtility"/> 将 <paramref name="port"/> 的世界位置
|
|
|
|
|
|
/// 转换到 <paramref name="space"/> 的局部坐标(相对 pivot),即 UILineRenderer 顶点所用的坐标系。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static Vector2 GetLocalPoint(RectTransform port, RectTransform space, Camera uiCamera)
|
|
|
|
|
|
{
|
|
|
|
|
|
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(uiCamera, port.position);
|
|
|
|
|
|
RectTransformUtility.ScreenPointToLocalPointInRectangle(space, screenPoint, uiCamera, out Vector2 localPoint);
|
|
|
|
|
|
return localPoint;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 16:56:04 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 将任意 RectTransform 的视觉中心转换为连接线容器的局部坐标。
|
|
|
|
|
|
/// 使用 rect.center 而非 transform.position,因此不依赖 Block Prefab 自身的 Pivot 配置;
|
|
|
|
|
|
/// StoryBlockView 运行时统一为中心 Pivot 后,仍可安全用于未来的任意尺寸 Block。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static Vector2 GetLocalRectCenter(RectTransform rect, RectTransform space, Camera uiCamera)
|
|
|
|
|
|
{
|
|
|
|
|
|
Vector3 worldCenter = rect.TransformPoint(rect.rect.center);
|
|
|
|
|
|
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(uiCamera, worldCenter);
|
|
|
|
|
|
RectTransformUtility.ScreenPointToLocalPointInRectangle(space, screenPoint, uiCamera, out Vector2 localPoint);
|
|
|
|
|
|
return localPoint;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 16:08:23 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 解析用于坐标转换的相机:Overlay 画布返回 null,其余(ScreenSpaceCamera / WorldSpace)返回画布相机。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private Camera ResolveUICamera()
|
|
|
|
|
|
{
|
|
|
|
|
|
Canvas canvas = curve != null ? curve.canvas : GetComponentInParent<Canvas>();
|
|
|
|
|
|
if (canvas == null || canvas.renderMode == RenderMode.ScreenSpaceOverlay)
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
|
|
return canvas.worldCamera;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|