From ff40db5b48bc42e89bc9d778ad0a98e7ede2a39f Mon Sep 17 00:00:00 2001
From: LiRuoChen <571244399@qq.com>
Date: Wed, 10 Jun 2026 16:22:14 +0800
Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B0=94=E6=B3=A1=E5=B8=83?=
=?UTF-8?q?=E5=B1=80=E7=BB=84=E4=BB=B6=EF=BC=8C=E5=9F=BA=E4=BA=8E=E7=89=A9?=
=?UTF-8?q?=E7=90=86=E4=BB=BF=E7=9C=9F=E7=9A=84=E5=B8=83=E5=B1=80=E7=BB=84?=
=?UTF-8?q?=E4=BB=B6=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Editor/BubbleLayout.meta | 8 +
.../BubbleLayout/BubbleLayoutGroupEditor.cs | 196 ++++
.../BubbleLayoutGroupEditor.cs.meta | 11 +
.../BubbleLayout/BubbleLayoutPluginEditor.cs | 65 ++
.../BubbleLayoutPluginEditor.cs.meta | 11 +
Runtime/BubbleLayout.meta | 3 +
.../BubbleLayout/BubbleCollisionConstraint.cs | 150 +++
.../BubbleCollisionConstraint.cs.meta | 11 +
Runtime/BubbleLayout/BubbleItemData.cs | 103 +++
Runtime/BubbleLayout/BubbleItemData.cs.meta | 11 +
Runtime/BubbleLayout/BubbleLayoutGroup.cs | 856 ++++++++++++++++++
.../BubbleLayout/BubbleLayoutGroup.cs.meta | 11 +
.../BubbleLayout/BubbleLayoutPluginBase.cs | 33 +
.../BubbleLayoutPluginBase.cs.meta | 11 +
Runtime/BubbleLayout/BubblePhysicsSolver.cs | 97 ++
.../BubbleLayout/BubblePhysicsSolver.cs.meta | 11 +
.../BubbleLayout/CircularLayoutConstraint.cs | 117 +++
.../CircularLayoutConstraint.cs.meta | 11 +
.../HorizontalLayoutConstraint.cs | 82 ++
.../HorizontalLayoutConstraint.cs.meta | 11 +
Runtime/BubbleLayout/IBubbleLayoutPlugin.cs | 46 +
.../BubbleLayout/IBubbleLayoutPlugin.cs.meta | 11 +
Runtime/BubbleLayout/README.md | 246 +++++
Runtime/BubbleLayout/README.md.meta | 7 +
.../BubbleLayout/VerticalLayoutConstraint.cs | 112 +++
.../VerticalLayoutConstraint.cs.meta | 11 +
Runtime/OverrideLayout/XericScrollRect.cs | 2 +-
27 files changed, 2243 insertions(+), 1 deletion(-)
create mode 100644 Editor/BubbleLayout.meta
create mode 100644 Editor/BubbleLayout/BubbleLayoutGroupEditor.cs
create mode 100644 Editor/BubbleLayout/BubbleLayoutGroupEditor.cs.meta
create mode 100644 Editor/BubbleLayout/BubbleLayoutPluginEditor.cs
create mode 100644 Editor/BubbleLayout/BubbleLayoutPluginEditor.cs.meta
create mode 100644 Runtime/BubbleLayout.meta
create mode 100644 Runtime/BubbleLayout/BubbleCollisionConstraint.cs
create mode 100644 Runtime/BubbleLayout/BubbleCollisionConstraint.cs.meta
create mode 100644 Runtime/BubbleLayout/BubbleItemData.cs
create mode 100644 Runtime/BubbleLayout/BubbleItemData.cs.meta
create mode 100644 Runtime/BubbleLayout/BubbleLayoutGroup.cs
create mode 100644 Runtime/BubbleLayout/BubbleLayoutGroup.cs.meta
create mode 100644 Runtime/BubbleLayout/BubbleLayoutPluginBase.cs
create mode 100644 Runtime/BubbleLayout/BubbleLayoutPluginBase.cs.meta
create mode 100644 Runtime/BubbleLayout/BubblePhysicsSolver.cs
create mode 100644 Runtime/BubbleLayout/BubblePhysicsSolver.cs.meta
create mode 100644 Runtime/BubbleLayout/CircularLayoutConstraint.cs
create mode 100644 Runtime/BubbleLayout/CircularLayoutConstraint.cs.meta
create mode 100644 Runtime/BubbleLayout/HorizontalLayoutConstraint.cs
create mode 100644 Runtime/BubbleLayout/HorizontalLayoutConstraint.cs.meta
create mode 100644 Runtime/BubbleLayout/IBubbleLayoutPlugin.cs
create mode 100644 Runtime/BubbleLayout/IBubbleLayoutPlugin.cs.meta
create mode 100644 Runtime/BubbleLayout/README.md
create mode 100644 Runtime/BubbleLayout/README.md.meta
create mode 100644 Runtime/BubbleLayout/VerticalLayoutConstraint.cs
create mode 100644 Runtime/BubbleLayout/VerticalLayoutConstraint.cs.meta
diff --git a/Editor/BubbleLayout.meta b/Editor/BubbleLayout.meta
new file mode 100644
index 0000000..bc5eb03
--- /dev/null
+++ b/Editor/BubbleLayout.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: a528f02158bc59546949227483047d1f
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Editor/BubbleLayout/BubbleLayoutGroupEditor.cs b/Editor/BubbleLayout/BubbleLayoutGroupEditor.cs
new file mode 100644
index 0000000..8a05ade
--- /dev/null
+++ b/Editor/BubbleLayout/BubbleLayoutGroupEditor.cs
@@ -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();
+ }
+ }
+}
diff --git a/Editor/BubbleLayout/BubbleLayoutGroupEditor.cs.meta b/Editor/BubbleLayout/BubbleLayoutGroupEditor.cs.meta
new file mode 100644
index 0000000..50c1504
--- /dev/null
+++ b/Editor/BubbleLayout/BubbleLayoutGroupEditor.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 028b6bdcfc3d1c040b4be264c9d0c19a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Editor/BubbleLayout/BubbleLayoutPluginEditor.cs b/Editor/BubbleLayout/BubbleLayoutPluginEditor.cs
new file mode 100644
index 0000000..5427cbd
--- /dev/null
+++ b/Editor/BubbleLayout/BubbleLayoutPluginEditor.cs
@@ -0,0 +1,65 @@
+using UnityEditor;
+using UnityEngine;
+using XericUI.BubbleLayout;
+
+namespace XericUIEditor.BubbleLayout
+{
+ ///
+ /// 气泡布局插件通用 Inspector —— 白色圆角边框 + 深色底,对所有 BubbleLayoutPluginBase 子类生效
+ ///
+ [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();
+ }
+ }
+}
diff --git a/Editor/BubbleLayout/BubbleLayoutPluginEditor.cs.meta b/Editor/BubbleLayout/BubbleLayoutPluginEditor.cs.meta
new file mode 100644
index 0000000..c829a24
--- /dev/null
+++ b/Editor/BubbleLayout/BubbleLayoutPluginEditor.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 922c3bec7f63ab646a31910e9c823231
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/BubbleLayout.meta b/Runtime/BubbleLayout.meta
new file mode 100644
index 0000000..c87a0ff
--- /dev/null
+++ b/Runtime/BubbleLayout.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 3f4b116b8ac84db4bdee151ac9e06a60
+timeCreated: 1781070430
\ No newline at end of file
diff --git a/Runtime/BubbleLayout/BubbleCollisionConstraint.cs b/Runtime/BubbleLayout/BubbleCollisionConstraint.cs
new file mode 100644
index 0000000..fa246f5
--- /dev/null
+++ b/Runtime/BubbleLayout/BubbleCollisionConstraint.cs
@@ -0,0 +1,150 @@
+using System.Collections.Generic;
+using UnityEngine;
+using XericLibrary.Runtime.Type.SpatialAlgorithm;
+
+namespace XericUI.BubbleLayout
+{
+ ///
+ /// 气泡碰撞约束插件 —— 使用四叉树检测重叠并推挤分离。
+ /// 默认 Order = 1000,在约束阶段最后执行,确保碰撞排挤在所有其他约束之后进行。
+ ///
+ [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;
+
+ ///
+ /// 最大迭代次数
+ ///
+ public int MaxIterations
+ {
+ get => m_MaxIterations;
+ set => m_MaxIterations = Mathf.Max(1, value);
+ }
+
+ public void ProcessConstraint(List items, Vector2 layoutCenter)
+ {
+ if (items == null || items.Count < 2)
+ return;
+
+ // 计算布局边界(包含所有元素的最小包围矩形 + 边距)
+ Rect layoutBounds = CalculateLayoutBounds(items);
+
+ // 为碰撞检测创建委托
+ System.Func getRectFunc = item => item.GetRect();
+
+ float currentStiffness = m_Stiffness;
+
+ for (int iteration = 0; iteration < m_MaxIterations; iteration++)
+ {
+ // 每轮迭代重建四叉树
+ var quadTree = new QuadTree(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();
+
+ // 检测所有重叠对
+ 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;
+ }
+ }
+
+ ///
+ /// 计算所有元素的包围矩形
+ ///
+ private Rect CalculateLayoutBounds(List 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;
+ }
+ }
+}
diff --git a/Runtime/BubbleLayout/BubbleCollisionConstraint.cs.meta b/Runtime/BubbleLayout/BubbleCollisionConstraint.cs.meta
new file mode 100644
index 0000000..82b164f
--- /dev/null
+++ b/Runtime/BubbleLayout/BubbleCollisionConstraint.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 800362f02601bfa469069fa027ad4132
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/BubbleLayout/BubbleItemData.cs b/Runtime/BubbleLayout/BubbleItemData.cs
new file mode 100644
index 0000000..3e09f56
--- /dev/null
+++ b/Runtime/BubbleLayout/BubbleItemData.cs
@@ -0,0 +1,103 @@
+using System;
+using UnityEngine;
+
+namespace XericUI.BubbleLayout
+{
+ ///
+ /// 气泡布局中每个子UI元素的物理状态数据
+ ///
+ [Serializable]
+ public class BubbleItemData
+ {
+ ///
+ /// 关联的 RectTransform
+ ///
+ [NonSerialized]
+ public RectTransform rectTransform;
+
+ ///
+ /// 唯一标识键(名称+实例ID格式)
+ ///
+ public string Key;
+
+ ///
+ /// 当前动画位置(世界/局部坐标,取决于布局设置)
+ ///
+ public Vector2 position;
+
+ ///
+ /// UI元素尺寸
+ ///
+ public Vector2 size;
+
+ ///
+ /// 旋转角度(度)
+ ///
+ public float rotation;
+
+ // --- 物理状态 ---
+
+ /// 当前速度
+ public Vector2 velocity;
+
+ /// 上一帧速度
+ public Vector2 prevVelocity;
+
+ /// 当前受到的力
+ public Vector2 force;
+
+ /// 上一帧受到的力
+ public Vector2 prevForce;
+
+ /// 动量
+ public Vector2 momentum;
+
+ /// 角动量
+ public float angularMomentum;
+
+ /// 质量(默认=1)
+ public float mass = 1f;
+
+ ///
+ /// 获取以position为中心的Rect
+ ///
+ public Rect GetRect()
+ {
+ return new Rect(position.x - size.x * 0.5f, position.y - size.y * 0.5f, size.x, size.y);
+ }
+
+ ///
+ /// 从RectTransform构建数据
+ ///
+ 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;
+ }
+
+ ///
+ /// 将当前position应用回RectTransform
+ ///
+ public void ApplyToTransform()
+ {
+ if (rectTransform != null)
+ {
+ rectTransform.anchoredPosition = position;
+ }
+ }
+ }
+}
diff --git a/Runtime/BubbleLayout/BubbleItemData.cs.meta b/Runtime/BubbleLayout/BubbleItemData.cs.meta
new file mode 100644
index 0000000..e1ff788
--- /dev/null
+++ b/Runtime/BubbleLayout/BubbleItemData.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: a6001db84b70cce4bbe33e77a6cecafe
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/BubbleLayout/BubbleLayoutGroup.cs b/Runtime/BubbleLayout/BubbleLayoutGroup.cs
new file mode 100644
index 0000000..202f061
--- /dev/null
+++ b/Runtime/BubbleLayout/BubbleLayoutGroup.cs
@@ -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
+ ///
+ /// 气泡布局各阶段性能耗时数据(仅 Editor 可用)
+ ///
+ public struct BubbleLayoutProfiler
+ {
+ /// 同步子元素耗时(ms)
+ public double SyncTimeMs;
+
+ /// 动画阶段耗时(ms)
+ public double AnimationTimeMs;
+
+ /// 物理积分耗时(ms)
+ public double PhysicsTimeMs;
+
+ /// 约束阶段耗时(ms)
+ public double ConstraintTimeMs;
+
+ /// 碰撞排挤耗时(ms)
+ public double CollisionTimeMs;
+
+ /// 位置应用耗时(ms)
+ public double ApplyTimeMs;
+
+ /// 总耗时(ms)
+ public double TotalTimeMs;
+
+ /// 当前元素数量
+ public int ItemCount;
+
+ /// 碰撞迭代次数
+ public int CollisionIterations;
+
+ /// 动画插件数量
+ public int AnimationPluginCount;
+
+ /// 约束插件数量
+ public int ConstraintPluginCount;
+ }
+#endif
+ ///
+ /// 气泡布局组件 —— 基于物理模拟的2D UI布局。
+ /// 子元素按顺序添加,通过四叉树检测重叠并以迭代推挤方式排布。
+ /// 支持动画插件和约束插件,插件按 Order 优先级分阶段顺序执行。
+ ///
+ [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 属性
+
+ /// 最大迭代次数
+ public int MaxIterations
+ {
+ get => m_MaxIterations;
+ set => m_MaxIterations = Mathf.Max(1, value);
+ }
+
+ /// 排斥力刚度
+ public float Stiffness
+ {
+ get => m_Stiffness;
+ set => m_Stiffness = Mathf.Max(0, value);
+ }
+
+ /// 最小分离距离
+ public float MinSeparation
+ {
+ get => m_MinSeparation;
+ set => m_MinSeparation = Mathf.Max(0, value);
+ }
+
+ /// 物理速度阻尼
+ public float PhysicsDamping
+ {
+ get => m_PhysicsDamping;
+ set => m_PhysicsDamping = Mathf.Clamp01(value);
+ }
+
+ #endregion
+
+ #region 私有字段
+
+ // 气泡元素数据
+ private List m_Items = new List();
+ private Dictionary m_ItemIndexMap = new Dictionary();
+
+ // 插件缓存
+ private List m_AnimationPlugins = new List();
+ private List m_ConstraintPlugins = new List();
+ private bool m_PluginsDirty = true;
+
+ // 四叉树(复用)
+ private QuadTree m_QuadTree;
+ private Func m_GetRectFunc;
+
+ // 布局状态
+ private bool m_LayoutInProgress;
+
+#if UNITY_EDITOR
+ /// 上一帧性能数据(仅 Editor)
+ 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 重写
+
+ ///
+ /// 计算水平布局输入
+ ///
+ public override void CalculateLayoutInputHorizontal()
+ {
+ base.CalculateLayoutInputHorizontal();
+ RefreshChildList();
+ CalcTotalMinAndPreferredSize(0);
+ }
+
+ ///
+ /// 计算垂直布局输入
+ ///
+ public override void CalculateLayoutInputVertical()
+ {
+ CalcTotalMinAndPreferredSize(1);
+ }
+
+ ///
+ /// 设置水平布局 —— 在此执行完整的2D布局管线
+ ///
+ public override void SetLayoutHorizontal()
+ {
+ if (m_LayoutInProgress)
+ return;
+
+ m_LayoutInProgress = true;
+
+ try
+ {
+ RunLayoutPipeline();
+ }
+ finally
+ {
+ m_LayoutInProgress = false;
+ }
+ }
+
+ ///
+ /// 设置垂直布局 —— 已在 SetLayoutHorizontal 中完成,此处为空
+ ///
+ public override void SetLayoutVertical()
+ {
+ // 2D 布局已在 SetLayoutHorizontal 中完成
+ }
+
+ #endregion
+
+ #region 布局管线
+
+ ///
+ /// 执行完整的布局管线:
+ /// 收集子项 → 动画阶段 → 物理积分 → 约束阶段 → 碰撞排挤 → 应用位置
+ ///
+ 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
+ }
+
+ ///
+ /// 获取布局中心点
+ ///
+ private Vector2 GetLayoutCenter()
+ {
+ var rt = rectTransform;
+ return new Vector2(rt.rect.width * 0.5f, rt.rect.height * 0.5f);
+ }
+
+ ///
+ /// 获取时间步长
+ ///
+ private float GetDeltaTime()
+ {
+ if (m_PhysicsDeltaTime > 0f)
+ return m_PhysicsDeltaTime;
+ return Application.isPlaying ? Time.deltaTime : 0.016f;
+ }
+
+ #endregion
+
+ #region 子元素管理
+
+ ///
+ /// 刷新子元素列表(筛选有效子项)
+ ///
+ private void RefreshChildList()
+ {
+ rectChildren.Clear();
+ var toIgnoreList = ListPool.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.Release(toIgnoreList);
+ m_Tracker.Clear();
+ }
+
+ ///
+ /// 同步 BubbleItemData 列表:
+ /// 新增、更新现有、移除已不存在的子项
+ ///
+ private void SyncItemList()
+ {
+ // 标记所有现有项为 "未匹配"
+ var unmatchedKeys = new HashSet(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);
+ }
+ }
+ }
+
+ ///
+ /// 计算总体最小和首选尺寸
+ ///
+ 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 插件管理
+
+ ///
+ /// 扫描 GameObject 上的插件组件
+ ///
+ private void RefreshPlugins()
+ {
+ m_AnimationPlugins.Clear();
+ m_ConstraintPlugins.Clear();
+
+ var components = GetComponents();
+ 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;
+ }
+
+ ///
+ /// 手动标记插件列表需要刷新
+ ///
+ public void MarkPluginsDirty()
+ {
+ m_PluginsDirty = true;
+ }
+
+ ///
+ /// 运行动画阶段
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// 运行约束阶段
+ ///
+ 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 气泡碰撞排挤
+
+ ///
+ /// 使用四叉树检测重叠并通过迭代推挤消除所有重叠
+ ///
+ 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(layoutBounds, m_GetRectFunc,
+ maxObjectsPerNode: 5, maxDepth: 5);
+ }
+ else
+ {
+ m_QuadTree.Clear();
+ }
+
+ float currentStiffness = m_Stiffness;
+ var overlapSet = new HashSet();
+
+ 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;
+ }
+
+ ///
+ /// 计算所有元素的包围矩形(带边距)
+ ///
+ 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);
+ }
+
+ ///
+ /// 计算排斥位移量
+ ///
+ 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 位置应用
+
+ ///
+ /// 将所有 BubbleItemData 的位置写回 RectTransform
+ ///
+ 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
+
+ ///
+ /// 获取指定Key对应的气泡数据
+ ///
+ public BubbleItemData GetItemData(string key)
+ {
+ if (m_ItemIndexMap.TryGetValue(key, out int idx))
+ return m_Items[idx];
+ return null;
+ }
+
+ ///
+ /// 获取指定RectTransform对应的气泡数据
+ ///
+ public BubbleItemData GetItemData(RectTransform rt)
+ {
+ if (rt == null)
+ return null;
+ return GetItemData($"{rt.name}_{rt.GetInstanceID()}");
+ }
+
+ ///
+ /// 获取所有气泡元素数据(只读)
+ ///
+ public IReadOnlyList GetAllItems()
+ {
+ return m_Items;
+ }
+
+ ///
+ /// 手动触发布局重建
+ ///
+ 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
+ }
+}
diff --git a/Runtime/BubbleLayout/BubbleLayoutGroup.cs.meta b/Runtime/BubbleLayout/BubbleLayoutGroup.cs.meta
new file mode 100644
index 0000000..d7906e3
--- /dev/null
+++ b/Runtime/BubbleLayout/BubbleLayoutGroup.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9f27b35f42fe5234191236e63452588f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/BubbleLayout/BubbleLayoutPluginBase.cs b/Runtime/BubbleLayout/BubbleLayoutPluginBase.cs
new file mode 100644
index 0000000..a83ec42
--- /dev/null
+++ b/Runtime/BubbleLayout/BubbleLayoutPluginBase.cs
@@ -0,0 +1,33 @@
+using UnityEngine;
+
+namespace XericUI.BubbleLayout
+{
+ ///
+ /// 气泡布局插件基类 —— 提供 Editor 识别标记和 Enabled/Order 公共实现。
+ /// 所有插件应继承此类而非直接实现 MonoBehaviour。
+ ///
+ 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
+ ///
+ /// 子类可重写此方法在 Reset 时设置不同的默认 Order
+ ///
+ protected virtual int EditorDefaultOrder => 10;
+
+ protected virtual void Reset()
+ {
+ m_Order = EditorDefaultOrder;
+ m_Enabled = true;
+ }
+#endif
+ }
+}
diff --git a/Runtime/BubbleLayout/BubbleLayoutPluginBase.cs.meta b/Runtime/BubbleLayout/BubbleLayoutPluginBase.cs.meta
new file mode 100644
index 0000000..091d54f
--- /dev/null
+++ b/Runtime/BubbleLayout/BubbleLayoutPluginBase.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b2acf92b6fa894849b5e07c549993689
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/BubbleLayout/BubblePhysicsSolver.cs b/Runtime/BubbleLayout/BubblePhysicsSolver.cs
new file mode 100644
index 0000000..bdd6d43
--- /dev/null
+++ b/Runtime/BubbleLayout/BubblePhysicsSolver.cs
@@ -0,0 +1,97 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace XericUI.BubbleLayout
+{
+ ///
+ /// 2D 物理力解算器 —— 使用半隐式欧拉积分进行速度/位置更新
+ ///
+ public static class BubblePhysicsSolver
+ {
+ ///
+ /// 对气泡元素列表执行一步物理积分,使用每元素自身的 mass 值
+ ///
+ /// 气泡元素列表
+ /// 时间步长
+ /// 速度阻尼系数 (0=无阻尼, 1=完全停止)
+ public static void Integrate(List 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;
+ }
+ }
+
+ ///
+ /// 计算两个位置之间的排斥力(用于气泡排挤)
+ ///
+ /// 元素A中心位置
+ /// 元素A尺寸
+ /// 元素B中心位置
+ /// 元素B尺寸
+ /// 排斥刚度
+ /// 施加在A上的排斥力
+ 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;
+ }
+ }
+}
diff --git a/Runtime/BubbleLayout/BubblePhysicsSolver.cs.meta b/Runtime/BubbleLayout/BubblePhysicsSolver.cs.meta
new file mode 100644
index 0000000..35d924d
--- /dev/null
+++ b/Runtime/BubbleLayout/BubblePhysicsSolver.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ee82ef95991ad094491026cb62c15b9c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/BubbleLayout/CircularLayoutConstraint.cs b/Runtime/BubbleLayout/CircularLayoutConstraint.cs
new file mode 100644
index 0000000..7d3823f
--- /dev/null
+++ b/Runtime/BubbleLayout/CircularLayoutConstraint.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace XericUI.BubbleLayout
+{
+ ///
+ /// 环绕布局约束插件 —— 将气泡元素沿圆形排列,并通过弹簧力吸附到目标位置。
+ ///
+ [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
+ {
+ /// 根据元素数量自动均分360度
+ Auto,
+
+ /// 使用固定角度步长
+ Fixed,
+ }
+
+ public void ProcessConstraint(List items, Vector2 layoutCenter)
+ {
+ if (items == null || items.Count == 0)
+ return;
+
+ var sorted = new List(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 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;
+ }
+ }
+ }
+}
diff --git a/Runtime/BubbleLayout/CircularLayoutConstraint.cs.meta b/Runtime/BubbleLayout/CircularLayoutConstraint.cs.meta
new file mode 100644
index 0000000..e5c2066
--- /dev/null
+++ b/Runtime/BubbleLayout/CircularLayoutConstraint.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: aa819e45e690abe40b5e6ef2a9bb9f6e
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/BubbleLayout/HorizontalLayoutConstraint.cs b/Runtime/BubbleLayout/HorizontalLayoutConstraint.cs
new file mode 100644
index 0000000..870ba65
--- /dev/null
+++ b/Runtime/BubbleLayout/HorizontalLayoutConstraint.cs
@@ -0,0 +1,82 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace XericUI.BubbleLayout
+{
+ ///
+ /// 横排布局约束插件 —— 将气泡元素沿 X 轴排列,并通过弹簧力吸附到目标位置。
+ ///
+ [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 items, Vector2 layoutCenter)
+ {
+ if (items == null || items.Count == 0)
+ return;
+
+ var sorted = new List(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 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;
+ }
+ }
+ }
+}
diff --git a/Runtime/BubbleLayout/HorizontalLayoutConstraint.cs.meta b/Runtime/BubbleLayout/HorizontalLayoutConstraint.cs.meta
new file mode 100644
index 0000000..648b2ff
--- /dev/null
+++ b/Runtime/BubbleLayout/HorizontalLayoutConstraint.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ce239333f48c4ef40a471b23499eaf8a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/BubbleLayout/IBubbleLayoutPlugin.cs b/Runtime/BubbleLayout/IBubbleLayoutPlugin.cs
new file mode 100644
index 0000000..9c78326
--- /dev/null
+++ b/Runtime/BubbleLayout/IBubbleLayoutPlugin.cs
@@ -0,0 +1,46 @@
+using System.Collections.Generic;
+
+namespace XericUI.BubbleLayout
+{
+ ///
+ /// 气泡布局插件基础接口,提供执行优先级
+ ///
+ public interface IBubbleLayoutPlugin
+ {
+ ///
+ /// 是否启用此插件,关闭后跳过所有流程
+ ///
+ bool Enabled { get; }
+
+ ///
+ /// 执行优先级,值越大越靠后执行
+ ///
+ int Order { get; }
+ }
+
+ ///
+ /// 气泡动画插件接口 —— 在动画阶段执行,负责添加力、速度等物理量
+ ///
+ public interface IBubbleAnimationPlugin : IBubbleLayoutPlugin
+ {
+ ///
+ /// 处理动画阶段,对每个气泡元素施加力/速度/动量等
+ ///
+ /// 所有气泡元素数据列表
+ /// 帧间隔时间
+ void ProcessAnimation(List items, float deltaTime);
+ }
+
+ ///
+ /// 气泡约束插件接口 —— 在约束阶段执行,负责限制位置、排列布局等
+ ///
+ public interface IBubbleConstraintPlugin : IBubbleLayoutPlugin
+ {
+ ///
+ /// 处理约束阶段,对气泡元素位置进行限制或排列
+ ///
+ /// 所有气泡元素数据列表
+ /// 布局中心点
+ void ProcessConstraint(List items, UnityEngine.Vector2 layoutCenter);
+ }
+}
diff --git a/Runtime/BubbleLayout/IBubbleLayoutPlugin.cs.meta b/Runtime/BubbleLayout/IBubbleLayoutPlugin.cs.meta
new file mode 100644
index 0000000..16731c3
--- /dev/null
+++ b/Runtime/BubbleLayout/IBubbleLayoutPlugin.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ed1f1ea2ebd960341ab8caf53d4a393a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/BubbleLayout/README.md b/Runtime/BubbleLayout/README.md
new file mode 100644
index 0000000..8862fc6
--- /dev/null
+++ b/Runtime/BubbleLayout/README.md
@@ -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 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 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 items, float deltaTime);
+}
+
+public interface IBubbleConstraintPlugin : IBubbleLayoutPlugin
+{
+ void ProcessConstraint(List 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 | 忽略未激活的子对象 |
diff --git a/Runtime/BubbleLayout/README.md.meta b/Runtime/BubbleLayout/README.md.meta
new file mode 100644
index 0000000..48b14a5
--- /dev/null
+++ b/Runtime/BubbleLayout/README.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 3e80c1c4b33a9e74aab1388da0301617
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/BubbleLayout/VerticalLayoutConstraint.cs b/Runtime/BubbleLayout/VerticalLayoutConstraint.cs
new file mode 100644
index 0000000..07a2898
--- /dev/null
+++ b/Runtime/BubbleLayout/VerticalLayoutConstraint.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace XericUI.BubbleLayout
+{
+ ///
+ /// 布局排列排序模式
+ ///
+ public enum LayoutSortMode
+ {
+ /// 按 Hierarchy 中的顺序
+ HierarchyOrder,
+
+ /// 按当前 X 坐标排序
+ PositionX,
+
+ /// 按当前 Y 坐标排序
+ PositionY,
+
+ /// 按面积 (宽x高) 排序
+ Size,
+ }
+
+ ///
+ /// 竖排布局约束插件 —— 将气泡元素沿 Y 轴排列,并通过弹簧力吸附到目标位置。
+ ///
+ [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 items, Vector2 layoutCenter)
+ {
+ if (items == null || items.Count == 0)
+ return;
+
+ // 排序
+ var sorted = new List(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 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;
+ }
+ }
+ }
+}
diff --git a/Runtime/BubbleLayout/VerticalLayoutConstraint.cs.meta b/Runtime/BubbleLayout/VerticalLayoutConstraint.cs.meta
new file mode 100644
index 0000000..709d508
--- /dev/null
+++ b/Runtime/BubbleLayout/VerticalLayoutConstraint.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 8d72842acb3268a4c84d58d4cf3601e0
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/OverrideLayout/XericScrollRect.cs b/Runtime/OverrideLayout/XericScrollRect.cs
index cce428b..cbc731e 100644
--- a/Runtime/OverrideLayout/XericScrollRect.cs
+++ b/Runtime/OverrideLayout/XericScrollRect.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;