Files
XericUIActionVessel/Runtime/BubbleLayout/LinearLayoutConstraintBase.cs

532 lines
16 KiB
C#

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
}
}