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;