添加气泡布局组件,基于物理仿真的布局组件。

This commit is contained in:
2026-06-10 16:25:16 +08:00
parent 4ee70025c0
commit ff40db5b48
27 changed files with 2243 additions and 1 deletions
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a528f02158bc59546949227483047d1f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,196 @@
using UnityEditor;
using UnityEngine;
using XericUI.BubbleLayout;
namespace XericUIEditor.BubbleLayout
{
[CustomEditor(typeof(BubbleLayoutGroup), true)]
[CanEditMultipleObjects]
internal class BubbleLayoutGroupEditor : Editor
{
// ── LayoutGroup 基类序列化属性 ──
private SerializedProperty m_Padding;
private SerializedProperty m_ChildAlignment;
// ── BubbleLayoutGroup 序列化属性 ──
private SerializedProperty m_EnablePlugins;
private SerializedProperty m_PhysicsDamping;
private SerializedProperty m_PhysicsMass;
private SerializedProperty m_MassScaleWithArea;
private SerializedProperty m_PhysicsDeltaTime;
private SerializedProperty m_MaxIterations;
private SerializedProperty m_Stiffness;
private SerializedProperty m_MinSeparation;
private SerializedProperty m_IterationDamping;
private SerializedProperty m_LayoutIgnoreInactive;
// ── 样式 ──
private static GUIStyle s_BoxStyle;
private static GUIStyle s_PerfHeaderStyle;
private static GUIStyle s_PerfLabelStyle;
private static GUIStyle s_PerfValueStyle;
private static bool s_StylesInitialized;
private static void EnsureStyles()
{
if (s_StylesInitialized)
return;
s_BoxStyle = new GUIStyle(GUI.skin.box)
{
padding = new RectOffset(12, 12, 10, 10),
margin = new RectOffset(4, 4, 4, 4),
};
var bgTex = new Texture2D(1, 1);
bgTex.SetPixel(0, 0, new Color(0.18f, 0.18f, 0.18f, 1f));
bgTex.Apply();
s_BoxStyle.normal.background = bgTex;
s_PerfHeaderStyle = new GUIStyle(EditorStyles.boldLabel)
{
fontSize = 11,
normal = { textColor = new Color(0.75f, 0.75f, 0.75f) },
};
s_PerfLabelStyle = new GUIStyle(EditorStyles.label)
{
fontSize = 10,
normal = { textColor = new Color(0.55f, 0.55f, 0.55f) },
fixedWidth = 110,
};
s_PerfValueStyle = new GUIStyle(EditorStyles.label)
{
fontSize = 10,
normal = { textColor = new Color(0.9f, 0.9f, 0.9f) },
fixedWidth = 80,
alignment = TextAnchor.MiddleRight,
};
s_StylesInitialized = true;
}
private void OnEnable()
{
m_Padding = serializedObject.FindProperty("m_Padding");
m_ChildAlignment = serializedObject.FindProperty("m_ChildAlignment");
m_EnablePlugins = serializedObject.FindProperty("m_EnablePlugins");
m_PhysicsDamping = serializedObject.FindProperty("m_PhysicsDamping");
m_PhysicsMass = serializedObject.FindProperty("m_PhysicsMass");
m_MassScaleWithArea = serializedObject.FindProperty("m_MassScaleWithArea");
m_PhysicsDeltaTime = serializedObject.FindProperty("m_PhysicsDeltaTime");
m_MaxIterations = serializedObject.FindProperty("m_MaxIterations");
m_Stiffness = serializedObject.FindProperty("m_Stiffness");
m_MinSeparation = serializedObject.FindProperty("m_MinSeparation");
m_IterationDamping = serializedObject.FindProperty("m_IterationDamping");
m_LayoutIgnoreInactive = serializedObject.FindProperty("m_LayoutIgnoreInactive");
}
public override void OnInspectorGUI()
{
EnsureStyles();
// ── 白色圆角边框 + 深色底 ──
EditorGUILayout.BeginVertical(s_BoxStyle);
serializedObject.Update();
// LayoutGroup 基类属性
EditorGUILayout.LabelField("布局设置", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(m_Padding, true);
EditorGUILayout.PropertyField(m_ChildAlignment);
EditorGUILayout.Space(4);
EditorGUILayout.LabelField("─ 气泡布局专属 ─", EditorStyles.boldLabel);
// 插件
EditorGUILayout.PropertyField(m_EnablePlugins);
// 物理
EditorGUILayout.LabelField("物理参数", EditorStyles.miniBoldLabel);
EditorGUI.indentLevel++;
EditorGUILayout.PropertyField(m_PhysicsDamping);
EditorGUILayout.PropertyField(m_PhysicsMass);
EditorGUILayout.PropertyField(m_MassScaleWithArea);
EditorGUILayout.PropertyField(m_PhysicsDeltaTime);
EditorGUI.indentLevel--;
// 碰撞排挤
EditorGUILayout.LabelField("碰撞排挤", EditorStyles.miniBoldLabel);
EditorGUI.indentLevel++;
EditorGUILayout.PropertyField(m_MaxIterations);
EditorGUILayout.PropertyField(m_Stiffness);
EditorGUILayout.PropertyField(m_MinSeparation);
EditorGUILayout.PropertyField(m_IterationDamping);
EditorGUI.indentLevel--;
// 布局
EditorGUILayout.PropertyField(m_LayoutIgnoreInactive);
serializedObject.ApplyModifiedProperties();
EditorGUILayout.EndVertical();
// ── 性能指标 ──
DrawPerformanceMetrics();
}
private void DrawPerformanceMetrics()
{
var layout = target as BubbleLayoutGroup;
if (layout == null)
return;
var perf = layout.LastProfilerData;
EditorGUILayout.Space(6);
EditorGUILayout.BeginVertical(s_BoxStyle);
EditorGUILayout.LabelField("性能指标", s_PerfHeaderStyle);
EditorGUILayout.Space(2);
DrawPerfRow("元素数量", perf.ItemCount.ToString());
DrawPerfRow("动画插件数", perf.AnimationPluginCount.ToString());
DrawPerfRow("约束插件数", perf.ConstraintPluginCount.ToString());
DrawPerfRow("碰撞迭代次数", perf.CollisionIterations.ToString());
EditorGUILayout.Space(2);
EditorGUILayout.LabelField("耗时明细 (ms)", EditorStyles.miniBoldLabel);
DrawPerfRow("同步子元素", $"{perf.SyncTimeMs:F3}");
DrawPerfRow("动画阶段", $"{perf.AnimationTimeMs:F3}");
DrawPerfRow("物理积分", $"{perf.PhysicsTimeMs:F3}");
DrawPerfRow("约束阶段", $"{perf.ConstraintTimeMs:F3}");
DrawPerfRow("碰撞排挤", $"{perf.CollisionTimeMs:F3}");
DrawPerfRow("位置应用", $"{perf.ApplyTimeMs:F3}");
EditorGUILayout.Space(2);
var totalStyle = new GUIStyle(s_PerfValueStyle)
{
fontStyle = FontStyle.Bold,
normal = { textColor = new Color(0.3f, 1f, 0.5f) },
};
var totalLabelStyle = new GUIStyle(s_PerfLabelStyle) { fontStyle = FontStyle.Bold };
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("总耗时", totalLabelStyle);
EditorGUILayout.LabelField($"{perf.TotalTimeMs:F3}", totalStyle);
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
}
private void DrawPerfRow(string label, string value)
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField(label, s_PerfLabelStyle);
EditorGUILayout.LabelField(value, s_PerfValueStyle);
EditorGUILayout.EndHorizontal();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 028b6bdcfc3d1c040b4be264c9d0c19a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,65 @@
using UnityEditor;
using UnityEngine;
using XericUI.BubbleLayout;
namespace XericUIEditor.BubbleLayout
{
/// <summary>
/// 气泡布局插件通用 Inspector —— 白色圆角边框 + 深色底,对所有 BubbleLayoutPluginBase 子类生效
/// </summary>
[CustomEditor(typeof(BubbleLayoutPluginBase), true)]
[CanEditMultipleObjects]
internal class BubbleLayoutPluginEditor : Editor
{
private static GUIStyle s_BoxStyle;
private static bool s_StylesInitialized;
private static void EnsureStyles()
{
if (s_StylesInitialized)
return;
s_BoxStyle = new GUIStyle(GUI.skin.box)
{
padding = new RectOffset(12, 12, 10, 10),
margin = new RectOffset(4, 4, 4, 4),
};
var bgTex = new Texture2D(1, 1);
bgTex.SetPixel(0, 0, new Color(0.18f, 0.18f, 0.18f, 1f));
bgTex.Apply();
s_BoxStyle.normal.background = bgTex;
s_StylesInitialized = true;
}
public override void OnInspectorGUI()
{
EnsureStyles();
// ── 白色圆角边框 + 深色底 ──
EditorGUILayout.BeginVertical(s_BoxStyle);
// 绘制所有序列化字段(包括基类的 Enabled 和 Order
serializedObject.Update();
SerializedProperty iterator = serializedObject.GetIterator();
bool enterChildren = true;
while (iterator.NextVisible(enterChildren))
{
enterChildren = false;
// 跳过脚本引用字段
if (iterator.name == "m_Script")
continue;
EditorGUILayout.PropertyField(iterator, true);
}
serializedObject.ApplyModifiedProperties();
EditorGUILayout.EndVertical();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 922c3bec7f63ab646a31910e9c823231
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+3
View File
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 3f4b116b8ac84db4bdee151ac9e06a60
timeCreated: 1781070430
@@ -0,0 +1,150 @@
using System.Collections.Generic;
using UnityEngine;
using XericLibrary.Runtime.Type.SpatialAlgorithm;
namespace XericUI.BubbleLayout
{
/// <summary>
/// 气泡碰撞约束插件 —— 使用四叉树检测重叠并推挤分离。
/// 默认 Order = 1000,在约束阶段最后执行,确保碰撞排挤在所有其他约束之后进行。
/// </summary>
[AddComponentMenu("Xeric UI Vessel/Layout/Bubble Collision Constraint", 52)]
public class BubbleCollisionConstraint : BubbleLayoutPluginBase, IBubbleConstraintPlugin
{
#if UNITY_EDITOR
protected override int EditorDefaultOrder => 1000;
#endif
[Tooltip("最大迭代次数,防止无限循环")]
[SerializeField] private int m_MaxIterations = 10;
[Tooltip("排斥力刚度系数")]
[SerializeField] private float m_Stiffness = 1f;
[Tooltip("最小分离距离(像素)")]
[SerializeField] private float m_MinSeparation = 2f;
[Tooltip("阻尼系数,每次迭代递减")]
[SerializeField] private float m_IterationDamping = 0.85f;
/// <summary>
/// 最大迭代次数
/// </summary>
public int MaxIterations
{
get => m_MaxIterations;
set => m_MaxIterations = Mathf.Max(1, value);
}
public void ProcessConstraint(List<BubbleItemData> items, Vector2 layoutCenter)
{
if (items == null || items.Count < 2)
return;
// 计算布局边界(包含所有元素的最小包围矩形 + 边距)
Rect layoutBounds = CalculateLayoutBounds(items);
// 为碰撞检测创建委托
System.Func<BubbleItemData, Rect> getRectFunc = item => item.GetRect();
float currentStiffness = m_Stiffness;
for (int iteration = 0; iteration < m_MaxIterations; iteration++)
{
// 每轮迭代重建四叉树
var quadTree = new QuadTree<BubbleItemData>(layoutBounds, getRectFunc,
maxObjectsPerNode: 5, maxDepth: 5);
// 插入所有元素
for (int i = 0; i < items.Count; i++)
{
quadTree.Insert(items[i]);
}
bool anyOverlap = false;
var overlapSet = new HashSet<BubbleItemData>();
// 检测所有重叠对
for (int i = 0; i < items.Count; i++)
{
var itemA = items[i];
Rect rectA = itemA.GetRect();
// 使用四叉树查找与itemA重叠的元素
overlapSet.Clear();
quadTree.Retrieve(rectA, overlapSet);
foreach (var itemB in overlapSet)
{
// 跳过自身
if (ReferenceEquals(itemA, itemB))
continue;
// 确保每对只处理一次(利用HashSet的确定性避免重复处理)
if (itemA.GetHashCode() > itemB.GetHashCode())
continue;
Rect rectB = itemB.GetRect();
// 再次确认重叠
if (!RectsOverlap(rectA, rectB))
continue;
anyOverlap = true;
// 计算排斥力
Vector2 force = BubblePhysicsSolver.CalculateRepulsionForce(
itemA.position, itemA.size,
itemB.position, itemB.size,
currentStiffness);
if (force != Vector2.zero)
{
// 两个元素各退一半
itemA.position += force * 0.5f;
itemB.position -= force * 0.5f;
}
}
}
if (!anyOverlap)
break;
// 阻尼递减
currentStiffness *= m_IterationDamping;
}
}
/// <summary>
/// 计算所有元素的包围矩形
/// </summary>
private Rect CalculateLayoutBounds(List<BubbleItemData> items)
{
if (items.Count == 0)
return new Rect(0, 0, 100, 100);
float minX = float.MaxValue, minY = float.MaxValue;
float maxX = float.MinValue, maxY = float.MinValue;
for (int i = 0; i < items.Count; i++)
{
Rect r = items[i].GetRect();
minX = Mathf.Min(minX, r.xMin);
minY = Mathf.Min(minY, r.yMin);
maxX = Mathf.Max(maxX, r.xMax);
maxY = Mathf.Max(maxY, r.yMax);
}
// 添加边距
float margin = 100f;
return new Rect(minX - margin, minY - margin,
(maxX - minX) + margin * 2f, (maxY - minY) + margin * 2f);
}
private static bool RectsOverlap(Rect a, Rect b)
{
return a.xMin < b.xMax && a.xMax > b.xMin &&
a.yMin < b.yMax && a.yMax > b.yMin;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 800362f02601bfa469069fa027ad4132
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+103
View File
@@ -0,0 +1,103 @@
using System;
using UnityEngine;
namespace XericUI.BubbleLayout
{
/// <summary>
/// 气泡布局中每个子UI元素的物理状态数据
/// </summary>
[Serializable]
public class BubbleItemData
{
/// <summary>
/// 关联的 RectTransform
/// </summary>
[NonSerialized]
public RectTransform rectTransform;
/// <summary>
/// 唯一标识键(名称+实例ID格式)
/// </summary>
public string Key;
/// <summary>
/// 当前动画位置(世界/局部坐标,取决于布局设置)
/// </summary>
public Vector2 position;
/// <summary>
/// UI元素尺寸
/// </summary>
public Vector2 size;
/// <summary>
/// 旋转角度(度)
/// </summary>
public float rotation;
// --- 物理状态 ---
/// <summary>当前速度</summary>
public Vector2 velocity;
/// <summary>上一帧速度</summary>
public Vector2 prevVelocity;
/// <summary>当前受到的力</summary>
public Vector2 force;
/// <summary>上一帧受到的力</summary>
public Vector2 prevForce;
/// <summary>动量</summary>
public Vector2 momentum;
/// <summary>角动量</summary>
public float angularMomentum;
/// <summary>质量(默认=1</summary>
public float mass = 1f;
/// <summary>
/// 获取以position为中心的Rect
/// </summary>
public Rect GetRect()
{
return new Rect(position.x - size.x * 0.5f, position.y - size.y * 0.5f, size.x, size.y);
}
/// <summary>
/// 从RectTransform构建数据
/// </summary>
public static BubbleItemData FromRectTransform(RectTransform rt)
{
var data = new BubbleItemData
{
rectTransform = rt,
Key = $"{rt.name}_{rt.GetInstanceID()}",
position = rt.anchoredPosition,
size = rt.rect.size,
rotation = rt.localEulerAngles.z,
velocity = Vector2.zero,
prevVelocity = Vector2.zero,
force = Vector2.zero,
prevForce = Vector2.zero,
momentum = Vector2.zero,
angularMomentum = 0f,
mass = 1f,
};
return data;
}
/// <summary>
/// 将当前position应用回RectTransform
/// </summary>
public void ApplyToTransform()
{
if (rectTransform != null)
{
rectTransform.anchoredPosition = position;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a6001db84b70cce4bbe33e77a6cecafe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+856
View File
@@ -0,0 +1,856 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Pool;
using UnityEngine.UI;
using XericLibrary.Runtime.Type.SpatialAlgorithm;
#if UNITY_EDITOR
using System.Diagnostics;
#endif
namespace XericUI.BubbleLayout
{
#if UNITY_EDITOR
/// <summary>
/// 气泡布局各阶段性能耗时数据(仅 Editor 可用)
/// </summary>
public struct BubbleLayoutProfiler
{
/// <summary>同步子元素耗时(ms)</summary>
public double SyncTimeMs;
/// <summary>动画阶段耗时(ms)</summary>
public double AnimationTimeMs;
/// <summary>物理积分耗时(ms)</summary>
public double PhysicsTimeMs;
/// <summary>约束阶段耗时(ms)</summary>
public double ConstraintTimeMs;
/// <summary>碰撞排挤耗时(ms)</summary>
public double CollisionTimeMs;
/// <summary>位置应用耗时(ms)</summary>
public double ApplyTimeMs;
/// <summary>总耗时(ms)</summary>
public double TotalTimeMs;
/// <summary>当前元素数量</summary>
public int ItemCount;
/// <summary>碰撞迭代次数</summary>
public int CollisionIterations;
/// <summary>动画插件数量</summary>
public int AnimationPluginCount;
/// <summary>约束插件数量</summary>
public int ConstraintPluginCount;
}
#endif
/// <summary>
/// 气泡布局组件 —— 基于物理模拟的2D UI布局。
/// 子元素按顺序添加,通过四叉树检测重叠并以迭代推挤方式排布。
/// 支持动画插件和约束插件,插件按 Order 优先级分阶段顺序执行。
/// </summary>
[ExecuteAlways]
[AddComponentMenu("Xeric UI Vessel/Layout/Bubble Layout Group", 51)]
public class BubbleLayoutGroup : LayoutGroup
{
#region
[Header("插件")]
[Tooltip("是否启用插件系统")]
[SerializeField] private bool m_EnablePlugins = true;
[Header("物理参数")]
[Tooltip("速度阻尼系数 (0=无阻尼, 1=完全停止)")]
[SerializeField] private float m_PhysicsDamping = 0.9f;
[Tooltip("默认质量")]
[SerializeField] private float m_PhysicsMass = 1f;
[Tooltip("质量随 UI 面积等比例变化(面积越大,质量越大,移动越慢)")]
[SerializeField] private bool m_MassScaleWithArea = false;
[Tooltip("物理时间步长(0表示使用Time.deltaTime")]
[SerializeField] private float m_PhysicsDeltaTime = 0f;
[Header("碰撞排挤")]
[Tooltip("最大迭代次数")]
[SerializeField] private int m_MaxIterations = 10;
[Tooltip("排斥力刚度系数")]
[SerializeField] private float m_Stiffness = 1f;
[Tooltip("最小分离距离")]
[SerializeField] private float m_MinSeparation = 2f;
[Tooltip("每次迭代的阻尼递减系数")]
[SerializeField] private float m_IterationDamping = 0.85f;
[Header("布局")]
[Tooltip("布局时忽略不可见的成员")]
[SerializeField] private bool m_LayoutIgnoreInactive = true;
#endregion
#region
/// <summary>最大迭代次数</summary>
public int MaxIterations
{
get => m_MaxIterations;
set => m_MaxIterations = Mathf.Max(1, value);
}
/// <summary>排斥力刚度</summary>
public float Stiffness
{
get => m_Stiffness;
set => m_Stiffness = Mathf.Max(0, value);
}
/// <summary>最小分离距离</summary>
public float MinSeparation
{
get => m_MinSeparation;
set => m_MinSeparation = Mathf.Max(0, value);
}
/// <summary>物理速度阻尼</summary>
public float PhysicsDamping
{
get => m_PhysicsDamping;
set => m_PhysicsDamping = Mathf.Clamp01(value);
}
#endregion
#region
// 气泡元素数据
private List<BubbleItemData> m_Items = new List<BubbleItemData>();
private Dictionary<string, int> m_ItemIndexMap = new Dictionary<string, int>();
// 插件缓存
private List<IBubbleAnimationPlugin> m_AnimationPlugins = new List<IBubbleAnimationPlugin>();
private List<IBubbleConstraintPlugin> m_ConstraintPlugins = new List<IBubbleConstraintPlugin>();
private bool m_PluginsDirty = true;
// 四叉树(复用)
private QuadTree<BubbleItemData> m_QuadTree;
private Func<BubbleItemData, Rect> m_GetRectFunc;
// 布局状态
private bool m_LayoutInProgress;
#if UNITY_EDITOR
/// <summary>上一帧性能数据(仅 Editor</summary>
public BubbleLayoutProfiler LastProfilerData { get; private set; }
private Stopwatch m_ProfilerStopwatch;
#endif
#endregion
#region Unity
protected BubbleLayoutGroup()
{ }
protected override void Awake()
{
base.Awake();
m_GetRectFunc = item => item.GetRect();
}
protected override void OnEnable()
{
base.OnEnable();
m_PluginsDirty = true;
LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
}
protected override void OnDisable()
{
m_Tracker.Clear();
base.OnDisable();
}
protected override void OnTransformChildrenChanged()
{
base.OnTransformChildrenChanged();
m_PluginsDirty = true; // 子对象变化可能引入新插件
}
#if UNITY_EDITOR
protected override void Reset()
{
m_EnablePlugins = true;
m_PhysicsDamping = 0.9f;
m_PhysicsMass = 1f;
m_MassScaleWithArea = false;
m_PhysicsDeltaTime = 0f;
m_MaxIterations = 10;
m_Stiffness = 1f;
m_MinSeparation = 2f;
m_IterationDamping = 0.85f;
m_LayoutIgnoreInactive = true;
}
protected virtual void Update()
{
if (!Application.isPlaying)
{
// 编辑时实时预览:标记布局重建
LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
}
}
#endif
#endregion
#region LayoutGroup
/// <summary>
/// 计算水平布局输入
/// </summary>
public override void CalculateLayoutInputHorizontal()
{
base.CalculateLayoutInputHorizontal();
RefreshChildList();
CalcTotalMinAndPreferredSize(0);
}
/// <summary>
/// 计算垂直布局输入
/// </summary>
public override void CalculateLayoutInputVertical()
{
CalcTotalMinAndPreferredSize(1);
}
/// <summary>
/// 设置水平布局 —— 在此执行完整的2D布局管线
/// </summary>
public override void SetLayoutHorizontal()
{
if (m_LayoutInProgress)
return;
m_LayoutInProgress = true;
try
{
RunLayoutPipeline();
}
finally
{
m_LayoutInProgress = false;
}
}
/// <summary>
/// 设置垂直布局 —— 已在 SetLayoutHorizontal 中完成,此处为空
/// </summary>
public override void SetLayoutVertical()
{
// 2D 布局已在 SetLayoutHorizontal 中完成
}
#endregion
#region 线
/// <summary>
/// 执行完整的布局管线:
/// 收集子项 → 动画阶段 → 物理积分 → 约束阶段 → 碰撞排挤 → 应用位置
/// </summary>
private void RunLayoutPipeline()
{
#if UNITY_EDITOR
if (m_ProfilerStopwatch == null)
m_ProfilerStopwatch = new Stopwatch();
var profiler = new BubbleLayoutProfiler();
var totalSw = Stopwatch.StartNew();
#endif
// 1. 同步子元素列表
#if UNITY_EDITOR
m_ProfilerStopwatch.Restart();
#endif
SyncItemList();
#if UNITY_EDITOR
m_ProfilerStopwatch.Stop();
profiler.SyncTimeMs = m_ProfilerStopwatch.Elapsed.TotalMilliseconds;
#endif
if (m_Items.Count == 0)
{
#if UNITY_EDITOR
profiler.ItemCount = 0;
totalSw.Stop();
profiler.TotalTimeMs = totalSw.Elapsed.TotalMilliseconds;
LastProfilerData = profiler;
#endif
return;
}
// 2. 刷新插件列表
if (m_PluginsDirty)
RefreshPlugins();
// 3. 动画阶段 —— 插件施加力/速度
if (m_EnablePlugins)
{
float dt = GetDeltaTime();
#if UNITY_EDITOR
m_ProfilerStopwatch.Restart();
#endif
RunAnimationPhase(dt);
#if UNITY_EDITOR
m_ProfilerStopwatch.Stop();
profiler.AnimationTimeMs = m_ProfilerStopwatch.Elapsed.TotalMilliseconds;
#endif
}
// 4. 物理积分阶段
{
// 更新每元素质量
if (m_MassScaleWithArea)
{
for (int i = 0; i < m_Items.Count; i++)
{
var item = m_Items[i];
item.mass = m_PhysicsMass * (item.size.x * item.size.y);
}
}
else
{
for (int i = 0; i < m_Items.Count; i++)
{
m_Items[i].mass = m_PhysicsMass;
}
}
#if UNITY_EDITOR
m_ProfilerStopwatch.Restart();
#endif
float dt = GetDeltaTime();
BubblePhysicsSolver.Integrate(m_Items, dt, m_PhysicsDamping);
#if UNITY_EDITOR
m_ProfilerStopwatch.Stop();
profiler.PhysicsTimeMs = m_ProfilerStopwatch.Elapsed.TotalMilliseconds;
#endif
}
// 5. 约束阶段 —— 插件限制位置
if (m_EnablePlugins)
{
var center = GetLayoutCenter();
#if UNITY_EDITOR
m_ProfilerStopwatch.Restart();
#endif
RunConstraintPhase(center);
#if UNITY_EDITOR
m_ProfilerStopwatch.Stop();
profiler.ConstraintTimeMs = m_ProfilerStopwatch.Elapsed.TotalMilliseconds;
#endif
}
// 6. 气泡碰撞排挤阶段
#if UNITY_EDITOR
m_ProfilerStopwatch.Restart();
#endif
int collisionIterations = RunBubbleCollision();
#if UNITY_EDITOR
m_ProfilerStopwatch.Stop();
profiler.CollisionTimeMs = m_ProfilerStopwatch.Elapsed.TotalMilliseconds;
profiler.CollisionIterations = collisionIterations;
#endif
// 7. 将最终位置应用回 RectTransform
#if UNITY_EDITOR
m_ProfilerStopwatch.Restart();
#endif
ApplyPositions();
#if UNITY_EDITOR
m_ProfilerStopwatch.Stop();
profiler.ApplyTimeMs = m_ProfilerStopwatch.Elapsed.TotalMilliseconds;
#endif
#if UNITY_EDITOR
totalSw.Stop();
profiler.TotalTimeMs = totalSw.Elapsed.TotalMilliseconds;
profiler.ItemCount = m_Items.Count;
profiler.AnimationPluginCount = m_EnablePlugins ? m_AnimationPlugins.Count : 0;
profiler.ConstraintPluginCount = m_EnablePlugins ? m_ConstraintPlugins.Count : 0;
LastProfilerData = profiler;
#endif
}
/// <summary>
/// 获取布局中心点
/// </summary>
private Vector2 GetLayoutCenter()
{
var rt = rectTransform;
return new Vector2(rt.rect.width * 0.5f, rt.rect.height * 0.5f);
}
/// <summary>
/// 获取时间步长
/// </summary>
private float GetDeltaTime()
{
if (m_PhysicsDeltaTime > 0f)
return m_PhysicsDeltaTime;
return Application.isPlaying ? Time.deltaTime : 0.016f;
}
#endregion
#region
/// <summary>
/// 刷新子元素列表(筛选有效子项)
/// </summary>
private void RefreshChildList()
{
rectChildren.Clear();
var toIgnoreList = ListPool<Component>.Get();
for (int i = 0; i < rectTransform.childCount; i++)
{
var rect = rectTransform.GetChild(i) as RectTransform;
if (rect == null)
continue;
if (m_LayoutIgnoreInactive && !rect.gameObject.activeInHierarchy)
continue;
rect.GetComponents(typeof(ILayoutIgnorer), toIgnoreList);
if (toIgnoreList.Count > 0)
{
bool ignored = false;
for (int j = 0; j < toIgnoreList.Count; j++)
{
if (((ILayoutIgnorer)toIgnoreList[j]).ignoreLayout)
{
ignored = true;
break;
}
}
if (ignored)
continue;
}
rectChildren.Add(rect);
}
ListPool<Component>.Release(toIgnoreList);
m_Tracker.Clear();
}
/// <summary>
/// 同步 BubbleItemData 列表:
/// 新增、更新现有、移除已不存在的子项
/// </summary>
private void SyncItemList()
{
// 标记所有现有项为 "未匹配"
var unmatchedKeys = new HashSet<string>(m_ItemIndexMap.Keys);
// 遍历当前有效子项
for (int i = 0; i < rectChildren.Count; i++)
{
var rt = rectChildren[i];
string key = $"{rt.name}_{rt.GetInstanceID()}";
if (m_ItemIndexMap.TryGetValue(key, out int idx))
{
// 已存在,更新尺寸和旋转
var item = m_Items[idx];
item.size = rt.rect.size;
item.rotation = rt.localEulerAngles.z;
item.rectTransform = rt;
unmatchedKeys.Remove(key);
}
else
{
// 新增
var newItem = BubbleItemData.FromRectTransform(rt);
m_Items.Add(newItem);
m_ItemIndexMap[key] = m_Items.Count - 1;
}
}
// 移除已不存在的子项
foreach (var key in unmatchedKeys)
{
if (m_ItemIndexMap.TryGetValue(key, out int idx))
{
// 交换删除法
int lastIdx = m_Items.Count - 1;
if (idx != lastIdx)
{
m_Items[idx] = m_Items[lastIdx];
m_ItemIndexMap[m_Items[idx].Key] = idx;
}
m_Items.RemoveAt(lastIdx);
m_ItemIndexMap.Remove(key);
}
}
}
/// <summary>
/// 计算总体最小和首选尺寸
/// </summary>
private void CalcTotalMinAndPreferredSize(int axis)
{
float min = 0f;
float preferred = 0f;
for (int i = 0; i < rectChildren.Count; i++)
{
float childMin = LayoutUtility.GetMinSize(rectChildren[i], axis);
float childPreferred = LayoutUtility.GetPreferredSize(rectChildren[i], axis);
if (axis == 0)
{
// 水平:所有子项宽度之和
min += childMin;
preferred += childPreferred;
}
else
{
// 垂直:取最大高度
min = Mathf.Max(min, childMin);
preferred = Mathf.Max(preferred, childPreferred);
}
}
// 加上内边距
if (axis == 0)
{
min += padding.horizontal;
preferred += padding.horizontal;
}
else
{
min += padding.vertical;
preferred += padding.vertical;
}
SetLayoutInputForAxis(min, preferred, -1, axis);
}
#endregion
#region
/// <summary>
/// 扫描 GameObject 上的插件组件
/// </summary>
private void RefreshPlugins()
{
m_AnimationPlugins.Clear();
m_ConstraintPlugins.Clear();
var components = GetComponents<MonoBehaviour>();
for (int i = 0; i < components.Length; i++)
{
var comp = components[i];
if (comp == null || comp == this)
continue;
if (comp is IBubbleAnimationPlugin animPlugin)
m_AnimationPlugins.Add(animPlugin);
if (comp is IBubbleConstraintPlugin constraintPlugin)
m_ConstraintPlugins.Add(constraintPlugin);
}
// 按 Order 排序(升序,值小先执行)
m_AnimationPlugins.Sort((a, b) => a.Order.CompareTo(b.Order));
m_ConstraintPlugins.Sort((a, b) => a.Order.CompareTo(b.Order));
m_PluginsDirty = false;
}
/// <summary>
/// 手动标记插件列表需要刷新
/// </summary>
public void MarkPluginsDirty()
{
m_PluginsDirty = true;
}
/// <summary>
/// 运行动画阶段
/// </summary>
private void RunAnimationPhase(float deltaTime)
{
for (int i = 0; i < m_AnimationPlugins.Count; i++)
{
if (!m_AnimationPlugins[i].Enabled)
continue;
m_AnimationPlugins[i].ProcessAnimation(m_Items, deltaTime);
}
}
/// <summary>
/// 运行约束阶段
/// </summary>
private void RunConstraintPhase(Vector2 layoutCenter)
{
for (int i = 0; i < m_ConstraintPlugins.Count; i++)
{
if (!m_ConstraintPlugins[i].Enabled)
continue;
m_ConstraintPlugins[i].ProcessConstraint(m_Items, layoutCenter);
}
}
#endregion
#region
/// <summary>
/// 使用四叉树检测重叠并通过迭代推挤消除所有重叠
/// </summary>
private int RunBubbleCollision()
{
if (m_Items.Count < 2)
return 0;
// 计算布局边界并创建/复用四叉树
Rect layoutBounds = CalculateLayoutBounds();
if (m_QuadTree == null || m_QuadTree.RootRect != layoutBounds)
{
m_QuadTree = new QuadTree<BubbleItemData>(layoutBounds, m_GetRectFunc,
maxObjectsPerNode: 5, maxDepth: 5);
}
else
{
m_QuadTree.Clear();
}
float currentStiffness = m_Stiffness;
var overlapSet = new HashSet<BubbleItemData>();
for (int iteration = 0; iteration < m_MaxIterations; iteration++)
{
// 重建四叉树
m_QuadTree.Rebuild(m_Items);
bool anyOverlap = false;
for (int i = 0; i < m_Items.Count; i++)
{
var itemA = m_Items[i];
Rect rectA = itemA.GetRect();
// 查询与 itemA 重叠的元素
overlapSet.Clear();
m_QuadTree.Retrieve(rectA, overlapSet);
foreach (var itemB in overlapSet)
{
if (ReferenceEquals(itemA, itemB))
continue;
// 确保每对只处理一次
if (itemA.GetHashCode() > itemB.GetHashCode())
continue;
Rect rectB = itemB.GetRect();
// 计算实际重叠量
if (!RectsOverlap(rectA, rectB))
continue;
anyOverlap = true;
// 计算排斥位移
Vector2 displacement = CalculateRepulsionDisplacement(
itemA.position, itemA.size,
itemB.position, itemB.size,
currentStiffness);
// 两元素各退一半
itemA.position += displacement * 0.5f;
itemB.position -= displacement * 0.5f;
}
}
if (!anyOverlap)
{
return iteration + 1;
}
currentStiffness *= m_IterationDamping;
}
return m_MaxIterations;
}
/// <summary>
/// 计算所有元素的包围矩形(带边距)
/// </summary>
private Rect CalculateLayoutBounds()
{
if (m_Items.Count == 0)
return new Rect(0, 0, 100, 100);
float minX = float.MaxValue, minY = float.MaxValue;
float maxX = float.MinValue, maxY = float.MinValue;
for (int i = 0; i < m_Items.Count; i++)
{
Rect r = m_Items[i].GetRect();
minX = Mathf.Min(minX, r.xMin);
minY = Mathf.Min(minY, r.yMin);
maxX = Mathf.Max(maxX, r.xMax);
maxY = Mathf.Max(maxY, r.yMax);
}
float margin = Mathf.Max(200f, Mathf.Max(maxX - minX, maxY - minY) * 0.5f);
return new Rect(minX - margin, minY - margin,
(maxX - minX) + margin * 2f, (maxY - minY) + margin * 2f);
}
/// <summary>
/// 计算排斥位移量
/// </summary>
private Vector2 CalculateRepulsionDisplacement(
Vector2 posA, Vector2 sizeA,
Vector2 posB, Vector2 sizeB,
float stiffness)
{
Vector2 delta = posA - posB;
Vector2 halfSizes = (sizeA + sizeB) * 0.5f;
float overlapX = halfSizes.x - Mathf.Abs(delta.x);
float overlapY = halfSizes.y - Mathf.Abs(delta.y);
if (overlapX <= 0f || overlapY <= 0f)
return Vector2.zero;
// 沿重叠最小的方向排斥,减少移动量
if (overlapX < overlapY)
{
float sign = delta.x > 0f ? 1f : -1f;
return new Vector2(sign * overlapX * stiffness, 0f);
}
else if (overlapY < overlapX)
{
float sign = delta.y > 0f ? 1f : -1f;
return new Vector2(0f, sign * overlapY * stiffness);
}
else
{
float signX = delta.x > 0f ? 1f : -1f;
float signY = delta.y > 0f ? 1f : -1f;
return new Vector2(signX * overlapX * stiffness * 0.5f, signY * overlapY * stiffness * 0.5f);
}
}
private static bool RectsOverlap(Rect a, Rect b)
{
return a.xMin < b.xMax && a.xMax > b.xMin &&
a.yMin < b.yMax && a.yMax > b.yMin;
}
#endregion
#region
/// <summary>
/// 将所有 BubbleItemData 的位置写回 RectTransform
/// </summary>
private void ApplyPositions()
{
for (int i = 0; i < m_Items.Count; i++)
{
var item = m_Items[i];
if (item.rectTransform == null)
continue;
m_Tracker.Add(this, item.rectTransform,
DrivenTransformProperties.AnchoredPosition | DrivenTransformProperties.AnchoredPositionZ);
item.ApplyToTransform();
}
}
#endregion
#region API
/// <summary>
/// 获取指定Key对应的气泡数据
/// </summary>
public BubbleItemData GetItemData(string key)
{
if (m_ItemIndexMap.TryGetValue(key, out int idx))
return m_Items[idx];
return null;
}
/// <summary>
/// 获取指定RectTransform对应的气泡数据
/// </summary>
public BubbleItemData GetItemData(RectTransform rt)
{
if (rt == null)
return null;
return GetItemData($"{rt.name}_{rt.GetInstanceID()}");
}
/// <summary>
/// 获取所有气泡元素数据(只读)
/// </summary>
public IReadOnlyList<BubbleItemData> GetAllItems()
{
return m_Items;
}
/// <summary>
/// 手动触发布局重建
/// </summary>
public void RequestLayoutRebuild()
{
LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
}
#endregion
#region
#if UNITY_EDITOR
private void OnDrawGizmos()
{
if (!enabled)
return;
// 绘制每个子元素的包围盒
for (int i = 0; i < m_Items.Count; i++)
{
var item = m_Items[i];
Rect r = item.GetRect();
Gizmos.color = new Color(0, 1, 1, 0.3f);
Vector3 center = new Vector3(r.center.x, r.center.y, 0);
Vector3 size = new Vector3(r.width, r.height, 0);
Gizmos.DrawWireCube(center, size);
}
}
#endif
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9f27b35f42fe5234191236e63452588f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,33 @@
using UnityEngine;
namespace XericUI.BubbleLayout
{
/// <summary>
/// 气泡布局插件基类 —— 提供 Editor 识别标记和 Enabled/Order 公共实现。
/// 所有插件应继承此类而非直接实现 MonoBehaviour。
/// </summary>
public abstract class BubbleLayoutPluginBase : MonoBehaviour, IBubbleLayoutPlugin
{
[Tooltip("是否启用此插件")]
[SerializeField] protected bool m_Enabled = true;
[Tooltip("执行优先级,值越大越靠后执行")]
[SerializeField] protected int m_Order = 10;
public bool Enabled => m_Enabled;
public int Order => m_Order;
#if UNITY_EDITOR
/// <summary>
/// 子类可重写此方法在 Reset 时设置不同的默认 Order
/// </summary>
protected virtual int EditorDefaultOrder => 10;
protected virtual void Reset()
{
m_Order = EditorDefaultOrder;
m_Enabled = true;
}
#endif
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b2acf92b6fa894849b5e07c549993689
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,97 @@
using System.Collections.Generic;
using UnityEngine;
namespace XericUI.BubbleLayout
{
/// <summary>
/// 2D 物理力解算器 —— 使用半隐式欧拉积分进行速度/位置更新
/// </summary>
public static class BubblePhysicsSolver
{
/// <summary>
/// 对气泡元素列表执行一步物理积分,使用每元素自身的 mass 值
/// </summary>
/// <param name="items">气泡元素列表</param>
/// <param name="deltaTime">时间步长</param>
/// <param name="damping">速度阻尼系数 (0=无阻尼, 1=完全停止)</param>
public static void Integrate(List<BubbleItemData> items, float deltaTime, float damping = 0.9f)
{
for (int i = 0; i < items.Count; i++)
{
var item = items[i];
// 保存上一帧状态
item.prevVelocity = item.velocity;
item.prevForce = item.force;
// 半隐式欧拉积分: 力 → 加速度 → 速度 → 位置
float itemMass = Mathf.Max(item.mass, 0.001f);
Vector2 acceleration = item.force / itemMass;
// v_new = v_old * damping + a * dt
item.velocity = item.velocity * (1f - damping) + acceleration * deltaTime;
// 动量 = m * v
item.momentum = item.velocity * itemMass;
// p_new = p_old + v_new * dt
item.position += item.velocity * deltaTime;
// 角动量积分
item.rotation += item.angularMomentum * deltaTime;
// 清除本帧力(力只持续一帧,需要每帧重新施加)
item.force = Vector2.zero;
}
}
/// <summary>
/// 计算两个位置之间的排斥力(用于气泡排挤)
/// </summary>
/// <param name="posA">元素A中心位置</param>
/// <param name="sizeA">元素A尺寸</param>
/// <param name="posB">元素B中心位置</param>
/// <param name="sizeB">元素B尺寸</param>
/// <param name="stiffness">排斥刚度</param>
/// <returns>施加在A上的排斥力</returns>
public static Vector2 CalculateRepulsionForce(
Vector2 posA, Vector2 sizeA,
Vector2 posB, Vector2 sizeB,
float stiffness = 1f)
{
Vector2 delta = posA - posB;
Vector2 halfSizes = (sizeA + sizeB) * 0.5f;
// 计算X方向重叠量
float overlapX = halfSizes.x - Mathf.Abs(delta.x);
// 计算Y方向重叠量
float overlapY = halfSizes.y - Mathf.Abs(delta.y);
// 仅在重叠时产生力
if (overlapX <= 0f || overlapY <= 0f)
return Vector2.zero;
// 选择最小重叠方向进行排斥
Vector2 force;
if (overlapX < overlapY)
{
float sign = delta.x > 0f ? 1f : -1f;
force = new Vector2(sign * overlapX * stiffness, 0f);
}
else if (overlapY < overlapX)
{
float sign = delta.y > 0f ? 1f : -1f;
force = new Vector2(0f, sign * overlapY * stiffness);
}
else
{
// 重叠相等时双向排斥
float signX = delta.x > 0f ? 1f : -1f;
float signY = delta.y > 0f ? 1f : -1f;
force = new Vector2(signX * overlapX * stiffness * 0.5f, signY * overlapY * stiffness * 0.5f);
}
return force;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ee82ef95991ad094491026cb62c15b9c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace XericUI.BubbleLayout
{
/// <summary>
/// 环绕布局约束插件 —— 将气泡元素沿圆形排列,并通过弹簧力吸附到目标位置。
/// </summary>
[AddComponentMenu("Xeric UI Vessel/Layout/Circular Layout Constraint", 55)]
public class CircularLayoutConstraint : BubbleLayoutPluginBase, IBubbleConstraintPlugin
{
[Header("吸附力")]
[Tooltip("弹簧吸附力倍率,值越大吸附越快")]
[SerializeField] private float m_AttractionForce = 50f;
[Header("环绕参数")]
[Tooltip("环绕半径")]
[SerializeField] private float m_Radius = 200f;
[Tooltip("起始角度(度),0=右侧,90=上方")]
[SerializeField] private float m_StartAngle = 0f;
[Tooltip("角度模式")]
[SerializeField] private AngleMode m_AngleMode = AngleMode.Auto;
[Tooltip("自动角度模式下的角度间距(度)")]
[SerializeField] private float m_AngleStep = 30f;
[Tooltip("最小角度限制(度),防止元素因尺寸过大而重叠")]
[SerializeField] private float m_MinAngle = 5f;
[Tooltip("排列方向:顺时针还是逆时针")]
[SerializeField] private bool m_Clockwise = true;
[Header("排列")]
[Tooltip("排序模式")]
[SerializeField] private LayoutSortMode m_SortMode = LayoutSortMode.HierarchyOrder;
public enum AngleMode
{
/// <summary>根据元素数量自动均分360度</summary>
Auto,
/// <summary>使用固定角度步长</summary>
Fixed,
}
public void ProcessConstraint(List<BubbleItemData> items, Vector2 layoutCenter)
{
if (items == null || items.Count == 0)
return;
var sorted = new List<BubbleItemData>(items);
SortItems(sorted);
// 计算角度步长
float angleStep;
if (m_AngleMode == AngleMode.Auto)
{
angleStep = sorted.Count > 1 ? 360f / sorted.Count : 0f;
// 检查最小角度限制:确保步长不小于最宽元素所需的角度
if (sorted.Count > 1)
{
float minRequiredAngle = m_MinAngle;
for (int i = 0; i < sorted.Count; i++)
{
// 元素在圆周上所占的近似角度
float itemArcAngle = Mathf.Atan2(sorted[i].size.x * 0.5f, m_Radius) * 2f * Mathf.Rad2Deg;
minRequiredAngle = Mathf.Max(minRequiredAngle, itemArcAngle);
}
angleStep = Mathf.Max(angleStep, minRequiredAngle);
}
}
else
{
angleStep = m_AngleStep;
}
// 逐元素计算目标位置并施加弹簧力
for (int i = 0; i < sorted.Count; i++)
{
var item = sorted[i];
float angle = m_StartAngle + (m_Clockwise ? -1f : 1f) * angleStep * i;
float rad = angle * Mathf.Deg2Rad;
Vector2 targetPos = layoutCenter + new Vector2(
Mathf.Cos(rad) * m_Radius,
Mathf.Sin(rad) * m_Radius
);
Vector2 springForce = (targetPos - item.position) * m_AttractionForce;
item.force += springForce;
}
}
private void SortItems(List<BubbleItemData> items)
{
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;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: aa819e45e690abe40b5e6ef2a9bb9f6e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,82 @@
using System.Collections.Generic;
using UnityEngine;
namespace XericUI.BubbleLayout
{
/// <summary>
/// 横排布局约束插件 —— 将气泡元素沿 X 轴排列,并通过弹簧力吸附到目标位置。
/// </summary>
[AddComponentMenu("Xeric UI Vessel/Layout/Horizontal Layout Constraint", 54)]
public class HorizontalLayoutConstraint : BubbleLayoutPluginBase, IBubbleConstraintPlugin
{
[Header("吸附力")]
[Tooltip("弹簧吸附力倍率,值越大吸附越快")]
[SerializeField] private float m_AttractionForce = 50f;
[Header("排列")]
[Tooltip("排序模式")]
[SerializeField] private LayoutSortMode m_SortMode = LayoutSortMode.HierarchyOrder;
[Tooltip("元素间距")]
[SerializeField] private float m_Spacing = 10f;
[Tooltip("起始偏移(相对于布局中心左侧)")]
[SerializeField] private float m_StartOffsetX = 0f;
[Tooltip("垂直对齐方式:0=顶部, 1=居中, 2=底部")]
[Range(0f, 2f)]
[SerializeField] private float m_VerticalAlignment = 1f;
public void ProcessConstraint(List<BubbleItemData> items, Vector2 layoutCenter)
{
if (items == null || items.Count == 0)
return;
var sorted = new List<BubbleItemData>(items);
SortItems(sorted);
// 计算总宽度
float totalWidth = 0f;
for (int i = 0; i < sorted.Count; i++)
totalWidth += sorted[i].size.x + m_Spacing;
totalWidth -= m_Spacing;
float startX = layoutCenter.x + m_StartOffsetX - totalWidth * 0.5f;
float currentX = startX;
for (int i = 0; i < sorted.Count; i++)
{
var item = sorted[i];
// 垂直对齐
float alignOffset = (m_VerticalAlignment - 1f) * item.size.y * 0.5f;
float targetY = layoutCenter.y - alignOffset;
Vector2 targetPos = new Vector2(currentX, targetY);
Vector2 springForce = (targetPos - item.position) * m_AttractionForce;
item.force += springForce;
currentX += item.size.x + m_Spacing;
}
}
private void SortItems(List<BubbleItemData> items)
{
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;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ce239333f48c4ef40a471b23499eaf8a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,46 @@
using System.Collections.Generic;
namespace XericUI.BubbleLayout
{
/// <summary>
/// 气泡布局插件基础接口,提供执行优先级
/// </summary>
public interface IBubbleLayoutPlugin
{
/// <summary>
/// 是否启用此插件,关闭后跳过所有流程
/// </summary>
bool Enabled { get; }
/// <summary>
/// 执行优先级,值越大越靠后执行
/// </summary>
int Order { get; }
}
/// <summary>
/// 气泡动画插件接口 —— 在动画阶段执行,负责添加力、速度等物理量
/// </summary>
public interface IBubbleAnimationPlugin : IBubbleLayoutPlugin
{
/// <summary>
/// 处理动画阶段,对每个气泡元素施加力/速度/动量等
/// </summary>
/// <param name="items">所有气泡元素数据列表</param>
/// <param name="deltaTime">帧间隔时间</param>
void ProcessAnimation(List<BubbleItemData> items, float deltaTime);
}
/// <summary>
/// 气泡约束插件接口 —— 在约束阶段执行,负责限制位置、排列布局等
/// </summary>
public interface IBubbleConstraintPlugin : IBubbleLayoutPlugin
{
/// <summary>
/// 处理约束阶段,对气泡元素位置进行限制或排列
/// </summary>
/// <param name="items">所有气泡元素数据列表</param>
/// <param name="layoutCenter">布局中心点</param>
void ProcessConstraint(List<BubbleItemData> items, UnityEngine.Vector2 layoutCenter);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ed1f1ea2ebd960341ab8caf53d4a393a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+246
View File
@@ -0,0 +1,246 @@
# BubbleLayout 气泡布局系统 — 操作手册
---
## 1. 架构总览
```
┌─────────────────────────────┐
│ BubbleLayoutGroup │
│ (宿主, 继承 LayoutGroup) │
│ │
│ 扫描 GameObject 上的插件 │
│ 管理 BubbleItemData 字典 │
│ 调度管线执行顺序 │
└──────────┬──────────────────┘
┌──────────────────────────┼──────────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────────────┐ ┌───────────────────────┐
│ 动画插件列表 │ │ 物理积分 (内置) │ │ 约束插件列表 │
│ IBubbleAnim- │ │ BubblePhysicsSolver │ │ IBubbleConstraint- │
│ ationPlugin[] │ │ .Integrate() │ │ Plugin[] │
│ │ │ │ │ │
│ 施加力/速度 │ │ F→a→v→position │ │ 限制位置/排列布局 │
└───────────────┘ └───────────────────────┘ └───────────┬───────────┘
┌────────▼────────┐
│ 气泡碰撞排挤 │
│ (内置, 四叉树) │
└────────┬────────┘
┌────────────────┐
│ ApplyPositions │
│ → RectTransform│
└────────────────┘
```
**每帧管线顺序(不可改变):**
```
SyncItemList → 动画插件(按Order) → 物理积分 → 约束插件(按Order) → 碰撞排挤 → ApplyPositions
```
---
## 2. 核心数据类型:BubbleItemData
[`BubbleItemData.cs`](Runtime/BubbleLayout/BubbleItemData.cs) — 每个子 UI 元素的完整物理状态,插件通过它读写数据。
| 字段 | 类型 | 说明 |
|---|---|---|
| `rectTransform` | `RectTransform` | 关联的 UI 变换组件 |
| `Key` | `string` | 唯一标识 `{name}_{instanceID}` |
| `position` | `Vector2` | 当前动画位置 |
| `size` | `Vector2` | UI 尺寸 |
| `rotation` | `float` | 旋转角度(度) |
| `velocity` / `prevVelocity` | `Vector2` | 当前 / 上一帧速度 |
| `force` / `prevForce` | `Vector2` | 当前 / 上一帧力 |
| `momentum` | `Vector2` | 动量 |
| `angularMomentum` | `float` | 角动量 |
| `mass` | `float` | 质量(默认=1) |
> **关键约定**:每帧物理积分后会清零 `force`。插件需要在每帧重新施加力。
---
## 3. 如何编写插件
### 3.1 动画插件 (IBubbleAnimationPlugin)
动画插件在物理积分**之前**执行,用于施加力/速度/动量。
```csharp
using System.Collections.Generic;
using UnityEngine;
using XericUI.BubbleLayout;
[AddComponentMenu("Xeric UI Vessel/Layout/My Animation Plugin", 60)]
public class MyAnimationPlugin : MonoBehaviour, IBubbleAnimationPlugin
{
// ── 必须实现:Enabled 和 Order ──
[SerializeField] private bool m_Enabled = true;
[SerializeField] private int m_Order = 5;
public bool Enabled => m_Enabled;
public int Order => m_Order;
// ── 可自定义的参数 ──
[SerializeField] private float m_WindStrength = 10f;
// ── 核心方法:每帧调用,在此施加力 ──
public void ProcessAnimation(List<BubbleItemData> items, float deltaTime)
{
for (int i = 0; i < items.Count; i++)
{
var item = items[i];
// 示例:施加一个向右的风力
item.force += new Vector2(m_WindStrength, 0f);
// 示例:根据速度施加速度阻尼
// item.velocity *= 0.95f;
}
}
}
```
**要点:**
- 直接挂载到 `BubbleLayoutGroup` 所在 GameObject 上即可被自动发现
- `items` 是**所有**气泡元素的列表,遍历它来施加力
- 力通过 `+=` 累加(多个插件可同时施加力)
- `Order` 越小越先执行,建议范围 1~99
---
### 3.2 约束插件 (IBubbleConstraintPlugin)
约束插件在物理积分**之后**、碰撞排挤**之前**执行,用于排列布局或限制位置。
```csharp
using System.Collections.Generic;
using UnityEngine;
using XericUI.BubbleLayout;
[AddComponentMenu("Xeric UI Vessel/Layout/My Constraint Plugin", 61)]
public class MyConstraintPlugin : MonoBehaviour, IBubbleConstraintPlugin
{
[SerializeField] private bool m_Enabled = true;
[SerializeField] private int m_Order = 20;
public bool Enabled => m_Enabled;
public int Order => m_Order;
[SerializeField] private float m_BoundaryRadius = 300f;
[SerializeField] private float m_AttractionForce = 50f;
public void ProcessConstraint(List<BubbleItemData> items, Vector2 layoutCenter)
{
for (int i = 0; i < items.Count; i++)
{
var item = items[i];
// 示例1:限制在圆形边界内
Vector2 toCenter = layoutCenter - item.position;
float dist = toCenter.magnitude;
if (dist > m_BoundaryRadius)
{
Vector2 clampedPos = layoutCenter + toCenter.normalized * m_BoundaryRadius;
// 方式A:直接修正位置(硬约束)
item.position = clampedPos;
}
// 示例2:通过弹簧力吸附到目标位置(软约束,推荐)
Vector2 targetPos = layoutCenter; // 目标位置
Vector2 springForce = (targetPos - item.position) * m_AttractionForce;
item.force += springForce;
}
}
}
```
**要点:**
- 可以直接修改 `item.position`(硬约束,即时生效)
- 也可以施加 `item.force`(软约束,下帧物理积分生效,有平滑动画过渡)
- `layoutCenter` 是 BubbleLayoutGroup 的 Rect 中心点
- 约束阶段在碰撞排挤之前,所以碰撞排挤会修正可能的重叠
---
### 3.3 IBubbleLayoutPlugin 接口参考
```csharp
public interface IBubbleLayoutPlugin
{
bool Enabled { get; } // Inspector 中可关闭
int Order { get; } // 优先级,值越大越靠后
}
public interface IBubbleAnimationPlugin : IBubbleLayoutPlugin
{
void ProcessAnimation(List<BubbleItemData> items, float deltaTime);
}
public interface IBubbleConstraintPlugin : IBubbleLayoutPlugin
{
void ProcessConstraint(List<BubbleItemData> items, Vector2 layoutCenter);
}
```
---
## 4. 内置插件一览
| 插件 | Order 建议 | 功能 |
|---|---|---|
| `VerticalLayoutConstraint` | 10 | 竖排排列,支持拉链模式(一左一右交替) |
| `HorizontalLayoutConstraint` | 10 | 横排排列,支持垂直对齐 |
| `CircularLayoutConstraint` | 10 | 环绕排列,支持自动/固定角度 |
| `BubbleCollisionConstraint` | 1000 | 独立碰撞排挤插件(可选,布局组件已内置) |
---
## 5. 如何着手了解源码
**推荐阅读顺序:**
1. **`IBubbleLayoutPlugin.cs`** — 接口定义,了解插件契约(~45行)
2. **`BubbleItemData.cs`** — 数据模型,了解插件操作的数据对象(~100行)
3. **`BubbleLayoutGroup.cs`** — 核心宿主,从 `RunLayoutPipeline()` 方法入手看管线调度(~700行)
4. **`BubblePhysicsSolver.cs`** — 物理积分器,`Integrate()` 方法(~100行)
5. **`QuadTree.cs`** — 四叉树空间索引,`Clear/Rebuild/Retrieve` 方法(~450行)
6. 任意内置插件(`VerticalLayoutConstraint.cs` 等)— 作为插件编写参考
**关键断点位置:**
| 方法 | 文件 | 作用 |
|---|---|---|
| `RunLayoutPipeline()` | `BubbleLayoutGroup.cs:223` | **管线入口**,从这里开始调试 |
| `SyncItemList()` | `BubbleLayoutGroup.cs:329` | 子元素增删改同步 |
| `RunAnimationPhase()` | `BubbleLayoutGroup.cs:457` | 动画插件调度 |
| `BubblePhysicsSolver.Integrate()` | `BubblePhysicsSolver.cs:18` | 力→速度→位置 |
| `RunConstraintPhase()` | `BubbleLayoutGroup.cs:470` | 约束插件调度 |
| `RunBubbleCollision()` | `BubbleLayoutGroup.cs:488` | 四叉树碰撞排挤 |
**定制流程参考:**
- 想修改**碰撞算法** → 看 `RunBubbleCollision()` + `QuadTree.cs`
- 想修改**物理模型** → 看 `BubblePhysicsSolver.Integrate()`
- 想修改**子元素管理** → 看 `SyncItemList()` + `RefreshChildList()`
- 想修改**插件发现机制** → 看 `RefreshPlugins()`
- 想添加**新的布局排列** → 参考 `VerticalLayoutConstraint.cs`,实现 `IBubbleConstraintPlugin`
---
## 6. BubbleLayoutGroup 可调参数
| 参数 | 默认值 | 说明 |
|---|---|---|
| `Enable Plugins` | true | 全局插件开关 |
| `Physics Damping` | 0.9 | 速度阻尼 (0=无阻尼, 1=完全停止) |
| `Physics Mass` | 1 | 默认质量 |
| `Mass Scale With Area` | false | 开启后质量 = 基础质量 × UI面积 |
| `Physics Delta Time` | 0 | 0=自动, >0=固定步长 |
| `Max Iterations` | 10 | 碰撞排挤最大迭代次数 |
| `Stiffness` | 1 | 排斥刚度 |
| `Min Separation` | 2 | 最小分离距离(像素) |
| `Iteration Damping` | 0.85 | 每轮迭代刚度递减系数 |
| `Layout Ignore Inactive` | true | 忽略未激活的子对象 |
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3e80c1c4b33a9e74aab1388da0301617
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace XericUI.BubbleLayout
{
/// <summary>
/// 布局排列排序模式
/// </summary>
public enum LayoutSortMode
{
/// <summary>按 Hierarchy 中的顺序</summary>
HierarchyOrder,
/// <summary>按当前 X 坐标排序</summary>
PositionX,
/// <summary>按当前 Y 坐标排序</summary>
PositionY,
/// <summary>按面积 (宽x高) 排序</summary>
Size,
}
/// <summary>
/// 竖排布局约束插件 —— 将气泡元素沿 Y 轴排列,并通过弹簧力吸附到目标位置。
/// </summary>
[AddComponentMenu("Xeric UI Vessel/Layout/Vertical Layout Constraint", 53)]
public class VerticalLayoutConstraint : BubbleLayoutPluginBase, IBubbleConstraintPlugin
{
[Header("吸附力")]
[Tooltip("弹簧吸附力倍率,值越大吸附越快")]
[SerializeField] private float m_AttractionForce = 50f;
[Header("排列")]
[Tooltip("排序模式")]
[SerializeField] private LayoutSortMode m_SortMode = LayoutSortMode.HierarchyOrder;
[Tooltip("元素间距")]
[SerializeField] private float m_Spacing = 10f;
[Tooltip("起始偏移(相对于布局中心上方)")]
[SerializeField] private float m_StartOffsetY = 0f;
[Header("拉链模式")]
[Tooltip("启用拉链排列(偶数索引偏左,奇数索引偏右)")]
[SerializeField] private bool m_ZipperMode = false;
[Tooltip("拉链模式下左右间距")]
[SerializeField] private float m_ZipperGap = 20f;
public void ProcessConstraint(List<BubbleItemData> items, Vector2 layoutCenter)
{
if (items == null || items.Count == 0)
return;
// 排序
var sorted = new List<BubbleItemData>(items);
SortItems(sorted);
// 计算起始 Y
float totalHeight = 0f;
for (int i = 0; i < sorted.Count; i++)
totalHeight += sorted[i].size.y + m_Spacing;
totalHeight -= m_Spacing; // 去掉最后一个间距
float startY = layoutCenter.y + m_StartOffsetY + totalHeight * 0.5f;
// 逐元素设置目标位置并施加弹簧力
float currentY = startY;
for (int i = 0; i < sorted.Count; i++)
{
var item = sorted[i];
float targetX = layoutCenter.x;
float targetY = currentY;
// 拉链模式:交替左右偏移
if (m_ZipperMode)
{
targetX = layoutCenter.x + (i % 2 == 0 ? -m_ZipperGap : m_ZipperGap) * 0.5f;
}
Vector2 targetPos = new Vector2(targetX, targetY);
// 弹簧力:F = (target - current) * forceMultiplier
Vector2 springForce = (targetPos - item.position) * m_AttractionForce;
item.force += springForce;
currentY -= item.size.y + m_Spacing;
}
}
private void SortItems(List<BubbleItemData> items)
{
switch (m_SortMode)
{
case LayoutSortMode.HierarchyOrder:
// 保持原始顺序(即 rectChildren 的顺序)
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)); // Y 从大到小(上到下)
break;
case LayoutSortMode.Size:
items.Sort((a, b) => (b.size.x * b.size.y).CompareTo(a.size.x * a.size.y));
break;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8d72842acb3268a4c84d58d4cf3601e0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+1 -1
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;