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