Files
XericUIActionVessel/Runtime/BubbleLayout/LinearLayoutConstraintBase.cs

532 lines
16 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using UnityEngine;
namespace XericUI.BubbleLayout
{
/// <summary>
/// 横向 / 竖向线性布局约束插件的共用基类。
/// 提供布局框管理、元素分组排序、穿插迭代、弹簧吸附/瞬移、Editor Gizmo 框架等复用逻辑。
/// </summary>
public abstract class LinearLayoutConstraintBase : BubbleLayoutPluginBase, IBubbleConstraintPlugin
{
#region
[Header("吸附力")]
[Tooltip("弹簧吸附力倍率,值越大吸附越快")]
[SerializeField] protected float m_AttractionForce = 50f;
[Header("排列")]
[Tooltip("排序模式")]
[SerializeField] protected LayoutSortMode m_SortMode = LayoutSortMode.HierarchyOrder;
[Tooltip("元素间距")]
[SerializeField] protected float m_Spacing = 10f;
[Header("布局框")]
[Tooltip("布局框列表(默认拥有一个全区域框)。每个框代表一个独立排列轨道,子项按框穿插排列。")]
[SerializeField] protected List<LayoutBox> m_LayoutBoxes = new List<LayoutBox>
{
new LayoutBox("Default", new Rect(-200, -200, 400, 400)),
};
/// <summary>
/// 元素到布局框的分配表(运行时字典的序列化后备)
/// </summary>
[SerializeField] private List<LayoutBoxAssignment> m_SerializedAssignments = new List<LayoutBoxAssignment>();
[NonSerialized]
protected Dictionary<string, int> m_BoxAssignments;
#endregion
#region
/// <summary>主轴名称("X" / "Y"),用于子类在 Gizmo / 日志中标识</summary>
protected abstract string PrimaryAxisName { get; }
/// <summary>获取元素沿主轴方向的尺寸</summary>
protected abstract float GetPrimarySize(BubbleItemData item);
/// <summary>
/// 获取元素沿主轴方向的尺寸(用于 Gizmo 预览的 RectTransform 版本)
/// </summary>
protected abstract float GetPrimarySizeFromRT(RectTransform rt);
/// <summary>
/// 根据主轴/辅轴坐标合成最终 localPosition
/// </summary>
protected abstract Vector2 MakeTargetPosition(float primary, float secondary);
#endregion
#region
/// <summary>
/// 惰性初始化运行时字典
/// </summary>
protected void EnsureAssignmentsInitialized()
{
if (m_BoxAssignments != null)
return;
m_BoxAssignments = new Dictionary<string, int>();
for (int i = 0; i < m_SerializedAssignments.Count; i++)
{
var entry = m_SerializedAssignments[i];
if (!string.IsNullOrEmpty(entry.ItemKey))
m_BoxAssignments[entry.ItemKey] = entry.BoxIndex;
}
}
/// <summary>
/// 确保布局框列表至少有一项
/// </summary>
protected void EnsureAtLeastOneBox()
{
if (m_LayoutBoxes == null)
m_LayoutBoxes = new List<LayoutBox>();
if (m_LayoutBoxes.Count == 0)
m_LayoutBoxes.Add(new LayoutBox("Default", new Rect(-200, -200, 400, 400)));
}
/// <summary>
/// 将指定 GameObject 分配到指定编号的布局框。
/// 默认所有子项属于第 0 号框。
/// </summary>
/// <param name="boxIndex">布局框索引(从 0 开始),超出范围会被钳制</param>
/// <param name="target">要分配的子项 GameObject</param>
public void MarkObject2LayoutBox(int boxIndex, GameObject target)
{
if (target == null)
return;
EnsureAtLeastOneBox();
boxIndex = Mathf.Clamp(boxIndex, 0, m_LayoutBoxes.Count - 1);
EnsureAssignmentsInitialized();
string key = $"{target.name}_{target.GetInstanceID()}";
m_BoxAssignments[key] = boxIndex;
// 同步回序列化列表
SyncDictionaryToSerialized();
}
/// <summary>
/// 清除指定 GameObject 的布局框分配(恢复默认框 0
/// </summary>
public void ClearBoxAssignment(GameObject target)
{
if (target == null)
return;
EnsureAssignmentsInitialized();
string key = $"{target.name}_{target.GetInstanceID()}";
m_BoxAssignments.Remove(key);
SyncDictionaryToSerialized();
}
/// <summary>
/// 根据屏幕空间坐标和指定 Pivot将布局框的 Region 映射到屏幕对应位置。
/// 适用于 Canvas ScreenSpace-Overlay / Camera 模式。
/// </summary>
/// <param name="boxIndex">目标布局框索引</param>
/// <param name="screenRect">屏幕空间矩形(像素坐标)</param>
/// <param name="camera">渲染 Canvas 的相机ScreenSpace-Overlay 时传 null</param>
public void SetBoxRegionFromScreenSpace(int boxIndex, Rect screenRect, Camera camera = null)
{
EnsureAtLeastOneBox();
if (boxIndex < 0 || boxIndex >= m_LayoutBoxes.Count)
return;
var parentRT = transform as RectTransform;
if (parentRT == null)
return;
Vector2 localCenter;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
parentRT, screenRect.center, camera, out localCenter);
Vector2 localSize = screenRect.size / parentRT.lossyScale;
m_LayoutBoxes[boxIndex].Region = new Rect(
localCenter.x - localSize.x * 0.5f,
localCenter.y - localSize.y * 0.5f,
localSize.x,
localSize.y);
}
/// <summary>
/// 获取指定 GameObject 当前分配的布局框索引
/// </summary>
public int GetBoxIndex(GameObject target)
{
if (target == null)
return 0;
EnsureAssignmentsInitialized();
string key = $"{target.name}_{target.GetInstanceID()}";
return m_BoxAssignments.TryGetValue(key, out int idx) ? idx : 0;
}
/// <summary>
/// 获取所有布局框(只读)
/// </summary>
public IReadOnlyList<LayoutBox> GetLayoutBoxes()
{
EnsureAtLeastOneBox();
return m_LayoutBoxes;
}
private void SyncDictionaryToSerialized()
{
m_SerializedAssignments.Clear();
if (m_BoxAssignments == null)
return;
foreach (var kv in m_BoxAssignments)
{
m_SerializedAssignments.Add(new LayoutBoxAssignment
{
ItemKey = kv.Key,
BoxIndex = kv.Value,
});
}
}
#endregion
#region IBubbleConstraintPlugin
public virtual void ProcessConstraint(List<BubbleItemData> items, Vector2 layoutCenter)
{
if (items == null || items.Count == 0)
return;
EnsureAtLeastOneBox();
EnsureAssignmentsInitialized();
// 1. 将元素按布局框分组
var boxGroups = new List<BubbleItemData>[m_LayoutBoxes.Count];
for (int b = 0; b < m_LayoutBoxes.Count; b++)
boxGroups[b] = new List<BubbleItemData>();
for (int i = 0; i < items.Count; i++)
{
var item = items[i];
int boxIdx = 0;
if (item.rectTransform != null)
{
string key = $"{item.rectTransform.name}_{item.rectTransform.GetInstanceID()}";
if (m_BoxAssignments.TryGetValue(key, out int assigned))
boxIdx = Mathf.Clamp(assigned, 0, m_LayoutBoxes.Count - 1);
}
boxGroups[boxIdx].Add(item);
}
// 2. 各组内部排序
for (int b = 0; b < boxGroups.Length; b++)
SortItems(boxGroups[b]);
// 3. 计算各组总主轴尺寸
float[] boxTotalPrimarySize = new float[boxGroups.Length];
float[] boxAdvance = new float[boxGroups.Length];
for (int b = 0; b < boxGroups.Length; b++)
{
float total = 0f;
for (int i = 0; i < boxGroups[b].Count; i++)
total += GetPrimarySize(boxGroups[b][i]) + m_Spacing;
if (boxGroups[b].Count > 0)
total -= m_Spacing;
boxTotalPrimarySize[b] = total;
}
// 4. 计算各框的起始位置(主轴方向),位于 Region 中心偏前
for (int b = 0; b < boxGroups.Length; b++)
{
var region = m_LayoutBoxes[b].Region;
float primaryCenter = IsPrimaryHorizontal
? region.center.x
: region.center.y;
boxAdvance[b] = primaryCenter - boxTotalPrimarySize[b] * 0.5f;
}
// 5. 穿插迭代:按轨轮询各框
int[] boxIndices = new int[boxGroups.Length];
bool anyRemaining = true;
var targetPositions = new List<(BubbleItemData item, int boxIndex, Vector2 targetPos)>();
while (anyRemaining)
{
anyRemaining = false;
for (int b = 0; b < boxGroups.Length; b++)
{
if (boxIndices[b] >= boxGroups[b].Count)
continue;
anyRemaining = true;
var item = boxGroups[b][boxIndices[b]];
var box = m_LayoutBoxes[b];
// 计算辅轴位置box 区域中心 ± 对齐偏移)
float secondary = IsPrimaryHorizontal
? box.Region.center.y + GetSecondaryAlignOffset(item, box)
: box.Region.center.x + GetSecondaryAlignOffset(item, box);
Vector2 targetPos = MakeTargetPosition(boxAdvance[b], secondary);
// 确保目标在 box 区域内
targetPos = ClampToBox(targetPos, item, box);
targetPositions.Add((item, b, targetPos));
// 推进主轴位置
boxAdvance[b] += GetPrimarySize(item) + m_Spacing;
boxIndices[b]++;
}
}
// 6. 按计算顺序依次施加力(确保穿插顺序被物理系统感知)
for (int i = 0; i < targetPositions.Count; i++)
{
var (item, boxIndex, targetPos) = targetPositions[i];
var box = m_LayoutBoxes[boxIndex];
// 检查是否超出 box 允许范围,超出则瞬移
if (IsOutOfBox(item.position, item, box))
{
item.position = targetPos;
item.velocity = Vector2.zero;
}
else
{
Vector2 springForce = (targetPos - item.position) * m_AttractionForce;
item.force += springForce;
}
}
}
/// <summary>
/// 判断主轴是水平还是垂直
/// </summary>
protected abstract bool IsPrimaryHorizontal { get; }
/// <summary>
/// 子类提供辅轴对齐偏移量(如横排的垂直对齐、竖排的拉链偏移)
/// </summary>
/// <returns>偏移量(正值 = 远离区域左上角方向)</returns>
protected virtual float GetSecondaryAlignOffset(BubbleItemData item, LayoutBox box)
{
return 0f;
}
/// <summary>
/// 将目标位置钳制到布局框区域内(考虑元素自身尺寸)
/// </summary>
protected Vector2 ClampToBox(Vector2 target, BubbleItemData item, LayoutBox box)
{
Rect r = box.Region;
float halfW = item.size.x * 0.5f;
float halfH = item.size.y * 0.5f;
target.x = Mathf.Clamp(target.x, r.xMin + halfW, r.xMax - halfW);
target.y = Mathf.Clamp(target.y, r.yMin + halfH, r.yMax - halfH);
return target;
}
/// <summary>
/// 判断位置是否超出布局框(用于瞬移判断)
/// </summary>
protected bool IsOutOfBox(Vector2 pos, BubbleItemData item, LayoutBox box)
{
Rect r = box.Region;
float halfW = item.size.x * 0.5f;
float halfH = item.size.y * 0.5f;
return pos.x - halfW < r.xMin || pos.x + halfW > r.xMax
|| pos.y - halfH < r.yMin || pos.y + halfH > r.yMax;
}
/// <summary>
/// 排序元素列表
/// </summary>
protected void SortItems(List<BubbleItemData> items)
{
if (items.Count <= 1)
return;
switch (m_SortMode)
{
case LayoutSortMode.HierarchyOrder:
break;
case LayoutSortMode.PositionX:
items.Sort((a, b) => a.position.x.CompareTo(b.position.x));
break;
case LayoutSortMode.PositionY:
items.Sort((a, b) => b.position.y.CompareTo(a.position.y));
break;
case LayoutSortMode.Size:
items.Sort((a, b) => (b.size.x * b.size.y).CompareTo(a.size.x * a.size.y));
break;
}
}
#endregion
#region Editor Gizmos
#if UNITY_EDITOR
/// <summary>
/// Gizmo 预览数据结构
/// </summary>
protected struct GizmoSlot
{
public RectTransform rectTransform;
public Vector2 size;
public int index;
public int boxIndex;
}
protected virtual void OnDrawGizmosSelected()
{
if (!isActiveAndEnabled || transform.childCount == 0)
return;
// 收集子元素
var allSlots = new List<GizmoSlot>();
for (int i = 0; i < transform.childCount; i++)
{
var rt = transform.GetChild(i) as RectTransform;
if (rt == null || !rt.gameObject.activeInHierarchy)
continue;
allSlots.Add(new GizmoSlot { rectTransform = rt, size = rt.rect.size, index = i, boxIndex = 0 });
}
if (allSlots.Count == 0) return;
// 读回分配
EnsureAssignmentsInitialized();
for (int i = 0; i < allSlots.Count; i++)
{
var slot = allSlots[i];
string key = $"{slot.rectTransform.name}_{slot.rectTransform.GetInstanceID()}";
if (m_BoxAssignments.TryGetValue(key, out int bIdx))
{
slot.boxIndex = Mathf.Clamp(bIdx, 0, m_LayoutBoxes.Count - 1);
allSlots[i] = slot;
}
}
// 按当前排序模式排序
switch (m_SortMode)
{
case LayoutSortMode.PositionX:
allSlots.Sort((a, b) => a.rectTransform.localPosition.x.CompareTo(b.rectTransform.localPosition.x));
break;
case LayoutSortMode.PositionY:
allSlots.Sort((a, b) => b.rectTransform.localPosition.y.CompareTo(a.rectTransform.localPosition.y));
break;
case LayoutSortMode.Size:
allSlots.Sort((a, b) => (b.size.x * b.size.y).CompareTo(a.size.x * a.size.y));
break;
}
var parentRT = (RectTransform)transform;
Vector2 layoutCenter = new Vector2(
parentRT.rect.width * (0.5f - parentRT.pivot.x),
parentRT.rect.height * (0.5f - parentRT.pivot.y));
// 1. 绘制所有布局框区域
DrawBoxRegions();
// 2. 委托子类绘制元素目标位置
DrawItemTargets(allSlots, layoutCenter);
}
/// <summary>
/// 绘制所有布局框的矩形区域(白色虚线框)
/// </summary>
protected virtual void DrawBoxRegions()
{
Color[] boxColors =
{
new Color(0.5f, 0.5f, 1f, 0.35f), // 蓝色调
new Color(1f, 0.5f, 0.5f, 0.35f), // 红色调
new Color(0.5f, 1f, 0.5f, 0.35f), // 绿色调
new Color(1f, 1f, 0.5f, 0.35f), // 黄色调
new Color(0.5f, 1f, 1f, 0.35f), // 青色调
new Color(1f, 0.5f, 1f, 0.35f), // 紫色调
};
for (int b = 0; b < m_LayoutBoxes.Count; b++)
{
var box = m_LayoutBoxes[b];
Color c = boxColors[b % boxColors.Length];
Rect r = box.Region;
Vector3 centerWorld = transform.TransformPoint(new Vector3(r.center.x, r.center.y, 0));
Vector3 sizeWorld = transform.TransformVector(new Vector3(r.width, r.height, 0));
// 半透明填充
Gizmos.color = new Color(c.r, c.g, c.b, 0.08f);
Gizmos.DrawCube(centerWorld, sizeWorld);
// 虚线边框
Gizmos.color = c;
DrawWireRect(centerWorld, sizeWorld);
// 中心十字
float crossLen = Mathf.Min(r.width, r.height) * 0.08f;
Vector3 cxWorld = transform.TransformVector(new Vector3(crossLen, 0, 0));
Vector3 cyWorld = transform.TransformVector(new Vector3(0, crossLen, 0));
Gizmos.DrawLine(centerWorld - cxWorld, centerWorld + cxWorld);
Gizmos.DrawLine(centerWorld - cyWorld, centerWorld + cyWorld);
}
}
/// <summary>
/// 子类重写:根据已排序的 slots 和 layoutCenter 绘制各元素的目标位置预览
/// </summary>
protected abstract void DrawItemTargets(List<GizmoSlot> allSlots, Vector2 layoutCenter);
/// <summary>
/// 绘制一个线框矩形
/// </summary>
protected static void DrawWireRect(Vector3 center, Vector3 size)
{
Vector3 half = size * 0.5f;
Vector3[] corners = new Vector3[5]
{
center + new Vector3(-half.x, -half.y, 0),
center + new Vector3( half.x, -half.y, 0),
center + new Vector3( half.x, half.y, 0),
center + new Vector3(-half.x, half.y, 0),
center + new Vector3(-half.x, -half.y, 0),
};
for (int i = 0; i < 4; i++)
Gizmos.DrawLine(corners[i], corners[i + 1]);
}
/// <summary>
/// 将 localPosition 空间的 Rect 绘制为世界空间线框
/// </summary>
protected void DrawLocalRectAsWireCube(Rect localRect, Color wireColor, Color? fillColor = null)
{
Vector3 worldCenter = transform.TransformPoint(new Vector3(localRect.center.x, localRect.center.y, 0));
Vector3 worldSize = transform.TransformVector(new Vector3(localRect.width, localRect.height, 0));
if (fillColor.HasValue)
{
Gizmos.color = fillColor.Value;
Gizmos.DrawCube(worldCenter, worldSize);
}
Gizmos.color = wireColor;
Gizmos.DrawWireCube(worldCenter, worldSize);
}
#endif
#endregion
}
}