From b4cb3f0976c9474fd71c004404e3d827d99c258d Mon Sep 17 00:00:00 2001 From: LiRuoChen <571244399@qq.com> Date: Fri, 12 Jun 2026 09:12:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B0=94=E6=B3=A1=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E5=AD=98=E5=9C=A8=E7=9A=84=E4=B8=80=E4=BA=9B=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BubbleLayout/BubbleLayoutEditorStyles.cs | 182 ++++++ .../BubbleLayoutEditorStyles.cs.meta | 11 + .../BubbleLayout/BubbleLayoutGroupEditor.cs | 16 +- .../BubbleLayout/BubbleLayoutPluginEditor.cs | 31 +- Runtime/BubbleLayout/BoundaryConstraint.cs | 330 +++++++++++ .../BubbleLayout/BoundaryConstraint.cs.meta | 11 + Runtime/BubbleLayout/BubbleItemData.cs | 4 +- Runtime/BubbleLayout/BubbleLayoutGroup.cs | 9 +- .../BubbleLayout/BubbleLayoutPluginBase.cs | 12 +- .../BubbleLayout/CircularLayoutConstraint.cs | 106 ++++ .../HorizontalLayoutConstraint.cs | 155 +++-- Runtime/BubbleLayout/LayoutBox.cs | 82 +++ Runtime/BubbleLayout/LayoutBox.cs.meta | 11 + .../LinearLayoutConstraintBase.cs | 531 ++++++++++++++++++ .../LinearLayoutConstraintBase.cs.meta | 11 + Runtime/BubbleLayout/QuadTree.cs | 451 +++++++++++++++ Runtime/BubbleLayout/QuadTree.cs.meta | 3 + .../BubbleLayout/VerticalLayoutConstraint.cs | 253 +++++++-- Runtime/DynamicActivitiesImpact.meta | 3 - .../UIDynamicActiveImpact.cs | 325 ----------- .../UIDynamicActiveImpact.cs.meta | 3 - 21 files changed, 2035 insertions(+), 505 deletions(-) create mode 100644 Editor/BubbleLayout/BubbleLayoutEditorStyles.cs create mode 100644 Editor/BubbleLayout/BubbleLayoutEditorStyles.cs.meta create mode 100644 Runtime/BubbleLayout/BoundaryConstraint.cs create mode 100644 Runtime/BubbleLayout/BoundaryConstraint.cs.meta create mode 100644 Runtime/BubbleLayout/LayoutBox.cs create mode 100644 Runtime/BubbleLayout/LayoutBox.cs.meta create mode 100644 Runtime/BubbleLayout/LinearLayoutConstraintBase.cs create mode 100644 Runtime/BubbleLayout/LinearLayoutConstraintBase.cs.meta create mode 100644 Runtime/BubbleLayout/QuadTree.cs create mode 100644 Runtime/BubbleLayout/QuadTree.cs.meta delete mode 100644 Runtime/DynamicActivitiesImpact.meta delete mode 100644 Runtime/DynamicActivitiesImpact/UIDynamicActiveImpact.cs delete mode 100644 Runtime/DynamicActivitiesImpact/UIDynamicActiveImpact.cs.meta diff --git a/Editor/BubbleLayout/BubbleLayoutEditorStyles.cs b/Editor/BubbleLayout/BubbleLayoutEditorStyles.cs new file mode 100644 index 0000000..47f966f --- /dev/null +++ b/Editor/BubbleLayout/BubbleLayoutEditorStyles.cs @@ -0,0 +1,182 @@ +using UnityEngine; +using UnityEditor; + +namespace XericUIEditor.BubbleLayout +{ + /// + /// BubbleLayout Editor 共享样式,提供白色圆角边框 + 深色底的 GUIStyle。 + /// + internal static class BubbleLayoutEditorStyles + { + private static GUIStyle s_BoxStyle; + private static bool s_Initialized; + + /// 白色圆角边框 + 深灰黑色底的 Box 样式 + public static GUIStyle BoxStyle + { + get + { + EnsureInitialized(); + return s_BoxStyle; + } + } + + private static void EnsureInitialized() + { + if (s_Initialized) + return; + + s_BoxStyle = new GUIStyle(GUI.skin.box) + { + padding = new RectOffset(12, 12, 10, 10), + margin = new RectOffset(4, 4, 4, 4), + }; + + const int borderThickness = 2; + const int cornerRadius = 4; + + s_BoxStyle.normal.background = GenerateRoundedBorderTexture( + borderThickness, cornerRadius, + Color.white, + new Color(0.155f, 0.155f, 0.155f, 1f), + out var borderInset); + + s_BoxStyle.border = borderInset; + + s_Initialized = true; + } + + /// + /// 生成一张用于 GUIStyle 9-slice 的圆角边框纹理。 + /// + /// 纹理布局(9-slice): + /// borderInset ─┤├─ edge ─┤├─ borderInset + /// + /// 左上角块 │ 上边缘 │ 右上角块 + /// ──────────┼─────────┼────────── + /// 左边缘 │ 中心 │ 右边缘 + /// ──────────┼─────────┼────────── + /// 左下角块 │ 下边缘 │ 右下角块 + /// + private static Texture2D GenerateRoundedBorderTexture( + int borderThickness, + int cornerRadius, + Color borderColor, + Color fillColor, + out RectOffset borderInset) + { + // 角块大小必须能容纳整个圆角装饰 (圆角半径 + 边框宽度) + int cornerBlockSize = cornerRadius + borderThickness + 1; + // 边缘条带宽度 + int edgeWidth = 4; + // 中心区域最小尺寸 + int centerSize = 2; + + int texSize = cornerBlockSize * 2 + edgeWidth; + // 确保中心区域有像素 + if (edgeWidth < centerSize) + { + edgeWidth = centerSize; + texSize = cornerBlockSize * 2 + edgeWidth; + } + + borderInset = new RectOffset(cornerBlockSize, cornerBlockSize, cornerBlockSize, cornerBlockSize); + + var tex = new Texture2D(texSize, texSize, TextureFormat.RGBA32, false); + tex.wrapMode = TextureWrapMode.Clamp; + tex.filterMode = FilterMode.Bilinear; + + Color transparent = new Color(0, 0, 0, 0); + Color[] pixels = new Color[texSize * texSize]; + + int maxCoord = texSize - 1; + float outerR = cornerRadius + borderThickness; // 外圆角半径(白色边框的外缘) + float innerR = cornerRadius; // 内圆角半径(深色填充的外缘) + + for (int y = 0; y < texSize; y++) + { + for (int x = 0; x < texSize; x++) + { + // 像素是否在「外圆角矩形」内(白色边框区域) + bool inOuter = IsInsideRoundedRect(x, y, 0, 0, maxCoord, maxCoord, outerR); + if (!inOuter) + { + pixels[y * texSize + x] = transparent; + continue; + } + + // 像素是否在「内圆角矩形」内(深色填充区域) + int innerMin = borderThickness; + int innerMax = maxCoord - borderThickness; + + bool inInner = IsInsideRoundedRect( + x, y, + innerMin, innerMin, + innerMax, innerMax, + innerR); + + pixels[y * texSize + x] = inInner ? fillColor : borderColor; + } + } + + tex.SetPixels(pixels); + tex.Apply(); + return tex; + } + + /// + /// 判断坐标 (x,y) 是否在由 (minX,minY)-(maxX,maxY) 定义的圆角矩形内。 + /// + private static bool IsInsideRoundedRect( + int x, int y, + int minX, int minY, + int maxX, int maxY, + float radius) + { + // 完全在边界外 + if (x < minX || x > maxX || y < minY || y > maxY) + return false; + + // 主体矩形区域(远离四个角) + if (x >= minX + radius && x <= maxX - radius) return true; + if (y >= minY + radius && y <= maxY - radius) return true; + + // 四个圆角区域 —— 判断是否在对应的四分之一圆内 + float cx, cy; + + if (x < minX + radius && y < minY + radius) + { + // 左上角 + cx = minX + radius; + cy = minY + radius; + } + else if (x > maxX - radius && y < minY + radius) + { + // 右上角 + cx = maxX - radius; + cy = minY + radius; + } + else if (x < minX + radius && y > maxY - radius) + { + // 左下角 + cx = minX + radius; + cy = maxY - radius; + } + else if (x > maxX - radius && y > maxY - radius) + { + // 右下角 + cx = maxX - radius; + cy = maxY - radius; + } + else + { + // 边缘条带区域(不在任何角区),在矩形边界内 + return true; + } + + float dx = x - cx; + float dy = y - cy; + return (dx * dx + dy * dy) <= (radius * radius); + } + } +} diff --git a/Editor/BubbleLayout/BubbleLayoutEditorStyles.cs.meta b/Editor/BubbleLayout/BubbleLayoutEditorStyles.cs.meta new file mode 100644 index 0000000..6c9b636 --- /dev/null +++ b/Editor/BubbleLayout/BubbleLayoutEditorStyles.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3b3b1ec1e8a20824488b035d1258c2e6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/BubbleLayout/BubbleLayoutGroupEditor.cs b/Editor/BubbleLayout/BubbleLayoutGroupEditor.cs index 8a05ade..446f7bb 100644 --- a/Editor/BubbleLayout/BubbleLayoutGroupEditor.cs +++ b/Editor/BubbleLayout/BubbleLayoutGroupEditor.cs @@ -25,7 +25,6 @@ namespace XericUIEditor.BubbleLayout private SerializedProperty m_LayoutIgnoreInactive; // ── 样式 ── - private static GUIStyle s_BoxStyle; private static GUIStyle s_PerfHeaderStyle; private static GUIStyle s_PerfLabelStyle; private static GUIStyle s_PerfValueStyle; @@ -36,17 +35,6 @@ namespace XericUIEditor.BubbleLayout 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, @@ -93,7 +81,7 @@ namespace XericUIEditor.BubbleLayout EnsureStyles(); // ── 白色圆角边框 + 深色底 ── - EditorGUILayout.BeginVertical(s_BoxStyle); + EditorGUILayout.BeginVertical(BubbleLayoutEditorStyles.BoxStyle); serializedObject.Update(); @@ -147,7 +135,7 @@ namespace XericUIEditor.BubbleLayout EditorGUILayout.Space(6); - EditorGUILayout.BeginVertical(s_BoxStyle); + EditorGUILayout.BeginVertical(BubbleLayoutEditorStyles.BoxStyle); EditorGUILayout.LabelField("性能指标", s_PerfHeaderStyle); EditorGUILayout.Space(2); diff --git a/Editor/BubbleLayout/BubbleLayoutPluginEditor.cs b/Editor/BubbleLayout/BubbleLayoutPluginEditor.cs index 5427cbd..443dadc 100644 --- a/Editor/BubbleLayout/BubbleLayoutPluginEditor.cs +++ b/Editor/BubbleLayout/BubbleLayoutPluginEditor.cs @@ -5,42 +5,16 @@ using XericUI.BubbleLayout; namespace XericUIEditor.BubbleLayout { /// - /// 气泡布局插件通用 Inspector —— 白色圆角边框 + 深色底,对所有 BubbleLayoutPluginBase 子类生效 + /// 气泡布局插件通用 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(BubbleLayoutEditorStyles.BoxStyle); - // ── 白色圆角边框 + 深色底 ── - EditorGUILayout.BeginVertical(s_BoxStyle); - - // 绘制所有序列化字段(包括基类的 Enabled 和 Order) serializedObject.Update(); SerializedProperty iterator = serializedObject.GetIterator(); @@ -50,7 +24,6 @@ namespace XericUIEditor.BubbleLayout { enterChildren = false; - // 跳过脚本引用字段 if (iterator.name == "m_Script") continue; diff --git a/Runtime/BubbleLayout/BoundaryConstraint.cs b/Runtime/BubbleLayout/BoundaryConstraint.cs new file mode 100644 index 0000000..f7ace6e --- /dev/null +++ b/Runtime/BubbleLayout/BoundaryConstraint.cs @@ -0,0 +1,330 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace XericUI.BubbleLayout +{ + /// + /// 边界限制模式 + /// + public enum BoundaryShapeMode + { + /// 矩形范围:尺寸的 x/y 代表矩形的宽度和高度 + Rectangle, + + /// 椭圆范围:尺寸的 x/y 代表椭圆的横轴半径和纵轴半径 + Ellipse, + } + + /// + /// 边界限制约束插件 —— 将气泡元素限制在指定范围内。 + /// 支持矩形/椭圆两种形状,并记录超出范围的元素。 + /// + [AddComponentMenu("Xeric UI Vessel/Layout/Boundary Constraint", 56)] + public class BoundaryConstraint : BubbleLayoutPluginBase, IBubbleConstraintPlugin + { + [Header("限制范围")] + [Tooltip("限制形状模式:矩形或椭圆")] + [SerializeField] private BoundaryShapeMode m_ShapeMode = BoundaryShapeMode.Rectangle; + + [Tooltip("范围尺寸。矩形模式 = 宽x高;椭圆模式 = 横轴半径x纵轴半径")] + [SerializeField] private Vector2 m_BoundarySize = new Vector2(400f, 300f); + + [Header("行为")] + [Tooltip("是否在元素超出边界时将其瞬移到边界上(关闭则仅记录不修正)")] + [SerializeField] private bool m_ClampToBoundary = true; + + #region 越界追踪 + + [System.NonSerialized] + private HashSet m_OutOfBoundsObjects = new HashSet(); + + /// + /// 记录越界元素:Key=GameObject实例ID,Value=true表示当前越界 + /// + [System.NonSerialized] + private Dictionary m_OutOfBoundsCache = new Dictionary(); + + /// + /// 检查指定 GameObject 是否超出了边界范围 + /// + public bool IsOutOfBounds(GameObject target) + { + if (target == null) + return false; + return m_OutOfBoundsCache.TryGetValue(target.GetInstanceID(), out bool value) && value; + } + + /// + /// 获取当前所有越界 GameObject 的实例 ID 集合(只读) + /// + public IReadOnlyCollection GetOutOfBoundsInstanceIDs() + { + return m_OutOfBoundsObjects; + } + + /// + /// 获取当前越界的元素数量 + /// + public int GetOutOfBoundsCount() => m_OutOfBoundsObjects.Count; + + /// + /// 清除越界记录 + /// + public void ClearOutOfBoundsHistory() + { + m_OutOfBoundsObjects.Clear(); + m_OutOfBoundsCache.Clear(); + } + + #endregion + + #region IBubbleConstraintPlugin + +#if UNITY_EDITOR + protected override int EditorDefaultOrder => 500; +#endif + + public void ProcessConstraint(List items, Vector2 layoutCenter) + { + if (items == null || items.Count == 0) + return; + + // 本轮重置越界缓存 + m_OutOfBoundsObjects.Clear(); + m_OutOfBoundsCache.Clear(); + + for (int i = 0; i < items.Count; i++) + { + var item = items[i]; + if (item.rectTransform == null) + continue; + + int instanceId = item.rectTransform.GetInstanceID(); + bool outOfBounds = false; + + // 根据形状模式检测 item 矩形是否完全或部分越界 + switch (m_ShapeMode) + { + case BoundaryShapeMode.Rectangle: + outOfBounds = !IsFullyInsideRect( + item.position, item.size, layoutCenter, m_BoundarySize); + break; + + case BoundaryShapeMode.Ellipse: + outOfBounds = !IsFullyInsideEllipse( + item.position, item.size, layoutCenter, m_BoundarySize); + break; + } + + m_OutOfBoundsCache[instanceId] = outOfBounds; + + if (outOfBounds) + { + m_OutOfBoundsObjects.Add(instanceId); + + if (m_ClampToBoundary) + { + // 将元素中心钳制到边界内 + item.position = ClampToBoundary(item.position, item.size, layoutCenter, m_BoundarySize, m_ShapeMode); + } + } + } + } + + #endregion + + #region 碰撞检测 + + /// + /// 检测矩形元素是否完全在矩形边界内 + /// + private static bool IsFullyInsideRect(Vector2 center, Vector2 size, Vector2 boundaryCenter, Vector2 boundarySize) + { + float halfBW = boundarySize.x * 0.5f; + float halfBH = boundarySize.y * 0.5f; + float halfW = size.x * 0.5f; + float halfH = size.y * 0.5f; + + return center.x - halfW >= boundaryCenter.x - halfBW + && center.x + halfW <= boundaryCenter.x + halfBW + && center.y - halfH >= boundaryCenter.y - halfBH + && center.y + halfH <= boundaryCenter.y + halfBH; + } + + /// + /// 检测矩形元素是否完全在椭圆边界内 + /// 椭圆方程:dx²/rx² + dy²/ry² ≤ 1 时需要元素所有角点均在椭圆内。 + /// 近似检测:元素中心 + 对角线半长构成的「外接圆」半径 vs 椭圆最大轴向半径。 + /// 精确检测:依次检查四个角点。 + /// + private static bool IsFullyInsideEllipse(Vector2 center, Vector2 size, Vector2 boundaryCenter, Vector2 boundaryRadius) + { + float rx = boundaryRadius.x; + float ry = boundaryRadius.y; + + if (rx <= 0f || ry <= 0f) + return false; + + // 取矩形的四个角偏移量 + float halfW = size.x * 0.5f; + float halfH = size.y * 0.5f; + + // 检查四个角点是否都在椭圆内 + return IsPointInsideEllipse(center.x - halfW, center.y - halfH, boundaryCenter, rx, ry) + && IsPointInsideEllipse(center.x + halfW, center.y - halfH, boundaryCenter, rx, ry) + && IsPointInsideEllipse(center.x - halfW, center.y + halfH, boundaryCenter, rx, ry) + && IsPointInsideEllipse(center.x + halfW, center.y + halfH, boundaryCenter, rx, ry); + } + + /// + /// 判断一个点是否在椭圆内:dx²/rx² + dy²/ry² ≤ 1 + /// + private static bool IsPointInsideEllipse(float px, float py, Vector2 center, float rx, float ry) + { + float dx = (px - center.x) / rx; + float dy = (py - center.y) / ry; + return dx * dx + dy * dy <= 1f; + } + + /// + /// 将元素中心钳制到边界内(保留最大可能的尺寸) + /// + private static Vector2 ClampToBoundary( + Vector2 center, Vector2 size, Vector2 boundaryCenter, Vector2 boundarySize, BoundaryShapeMode mode) + { + float halfW = size.x * 0.5f; + float halfH = size.y * 0.5f; + + switch (mode) + { + case BoundaryShapeMode.Rectangle: + { + float halfBW = boundarySize.x * 0.5f; + float halfBH = boundarySize.y * 0.5f; + + // 确保有足够空间容纳元素 + float maxW = Mathf.Max(halfBW - halfW, 0f); + float maxH = Mathf.Max(halfBH - halfH, 0f); + + center.x = Mathf.Clamp(center.x, boundaryCenter.x - maxW, boundaryCenter.x + maxW); + center.y = Mathf.Clamp(center.y, boundaryCenter.y - maxH, boundaryCenter.y + maxH); + return center; + } + + case BoundaryShapeMode.Ellipse: + { + float rx = boundarySize.x; + float ry = boundarySize.y; + + if (rx <= halfW || ry <= halfH) + { + // 椭圆太小无法容纳元素,强制居中 + return boundaryCenter; + } + + // 计算元素中心在椭圆内的有效范围 + float effectiveRx = rx - halfW; + float effectiveRy = ry - halfH; + + // 将 center 投影到有效椭圆内 + float dx = center.x - boundaryCenter.x; + float dy = center.y - boundaryCenter.y; + + // 椭圆约束:dx²/effectiveRx² + dy²/effectiveRy² ≤ 1 + if (effectiveRx > 0f && effectiveRy > 0f) + { + float nx = dx / effectiveRx; + float ny = dy / effectiveRy; + float dist = nx * nx + ny * ny; + + if (dist > 1f) + { + float scale = 1f / Mathf.Sqrt(dist); + dx *= scale; + dy *= scale; + } + } + + return boundaryCenter + new Vector2(dx, dy); + } + + default: + return center; + } + } + + #endregion + + #region Editor Gizmos + +#if UNITY_EDITOR + protected virtual void OnDrawGizmosSelected() + { + if (!isActiveAndEnabled) + return; + + var parentRT = transform as RectTransform; + Vector2 layoutCenter; + if (parentRT != null) + { + layoutCenter = new Vector2( + parentRT.rect.width * (0.5f - parentRT.pivot.x), + parentRT.rect.height * (0.5f - parentRT.pivot.y)); + } + else + { + layoutCenter = Vector2.zero; + } + + Vector3 worldCenter = transform.TransformPoint(new Vector3(layoutCenter.x, layoutCenter.y, 0)); + + switch (m_ShapeMode) + { + case BoundaryShapeMode.Rectangle: + { + Vector3 worldSize = transform.TransformVector(new Vector3(m_BoundarySize.x, m_BoundarySize.y, 0)); + Gizmos.color = new Color(1f, 0.3f, 0.3f, 0.25f); + Gizmos.DrawCube(worldCenter, worldSize); + Gizmos.color = new Color(1f, 0.2f, 0.2f, 0.7f); + Gizmos.DrawWireCube(worldCenter, worldSize); + break; + } + + case BoundaryShapeMode.Ellipse: + { + float rx = m_BoundarySize.x; + float ry = m_BoundarySize.y; + int segments = 64; + float step = 360f / segments * Mathf.Deg2Rad; + + Vector3 prevPoint = worldCenter + new Vector3(Mathf.Cos(0) * rx, Mathf.Sin(0) * ry, 0); + + // 半透明椭圆盘 + Gizmos.color = new Color(1f, 0.3f, 0.3f, 0.12f); + for (int i = 1; i <= segments; i++) + { + float angle = step * i; + Vector3 nextPoint = worldCenter + new Vector3(Mathf.Cos(angle) * rx, Mathf.Sin(angle) * ry, 0); + Gizmos.DrawLine(worldCenter, nextPoint); // 辐线填充 + prevPoint = nextPoint; + } + + // 椭圆边框 + prevPoint = worldCenter + new Vector3(Mathf.Cos(0) * rx, Mathf.Sin(0) * ry, 0); + Gizmos.color = new Color(1f, 0.2f, 0.2f, 0.7f); + for (int i = 1; i <= segments; i++) + { + float angle = step * i; + Vector3 nextPoint = worldCenter + new Vector3(Mathf.Cos(angle) * rx, Mathf.Sin(angle) * ry, 0); + Gizmos.DrawLine(prevPoint, nextPoint); + prevPoint = nextPoint; + } + break; + } + } + } +#endif + + #endregion + } +} diff --git a/Runtime/BubbleLayout/BoundaryConstraint.cs.meta b/Runtime/BubbleLayout/BoundaryConstraint.cs.meta new file mode 100644 index 0000000..bad7f36 --- /dev/null +++ b/Runtime/BubbleLayout/BoundaryConstraint.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bd7765032236e2c42aea99d854c4e754 +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 index 3e09f56..14eddd6 100644 --- a/Runtime/BubbleLayout/BubbleItemData.cs +++ b/Runtime/BubbleLayout/BubbleItemData.cs @@ -75,7 +75,7 @@ namespace XericUI.BubbleLayout { rectTransform = rt, Key = $"{rt.name}_{rt.GetInstanceID()}", - position = rt.anchoredPosition, + position = rt.localPosition, size = rt.rect.size, rotation = rt.localEulerAngles.z, velocity = Vector2.zero, @@ -96,7 +96,7 @@ namespace XericUI.BubbleLayout { if (rectTransform != null) { - rectTransform.anchoredPosition = position; + rectTransform.localPosition = new Vector3(position.x, position.y, rectTransform.localPosition.z); } } } diff --git a/Runtime/BubbleLayout/BubbleLayoutGroup.cs b/Runtime/BubbleLayout/BubbleLayoutGroup.cs index 202f061..189ae0f 100644 --- a/Runtime/BubbleLayout/BubbleLayoutGroup.cs +++ b/Runtime/BubbleLayout/BubbleLayoutGroup.cs @@ -66,7 +66,6 @@ namespace XericUI.BubbleLayout [Tooltip("是否启用插件系统")] [SerializeField] private bool m_EnablePlugins = true; - [Header("物理参数")] [Tooltip("速度阻尼系数 (0=无阻尼, 1=完全停止)")] [SerializeField] private float m_PhysicsDamping = 0.9f; @@ -79,7 +78,6 @@ namespace XericUI.BubbleLayout [Tooltip("物理时间步长(0表示使用Time.deltaTime)")] [SerializeField] private float m_PhysicsDeltaTime = 0f; - [Header("碰撞排挤")] [Tooltip("最大迭代次数")] [SerializeField] private int m_MaxIterations = 10; @@ -394,12 +392,15 @@ namespace XericUI.BubbleLayout } /// - /// 获取布局中心点 + /// 获取布局中心点(localPosition 空间) + /// localPosition (0,0) = pivot 位置,此方法返回 rect 中心相对 pivot 的偏移 /// private Vector2 GetLayoutCenter() { var rt = rectTransform; - return new Vector2(rt.rect.width * 0.5f, rt.rect.height * 0.5f); + return new Vector2( + rt.rect.width * (0.5f - rt.pivot.x), + rt.rect.height * (0.5f - rt.pivot.y)); } /// diff --git a/Runtime/BubbleLayout/BubbleLayoutPluginBase.cs b/Runtime/BubbleLayout/BubbleLayoutPluginBase.cs index a83ec42..a0082e2 100644 --- a/Runtime/BubbleLayout/BubbleLayoutPluginBase.cs +++ b/Runtime/BubbleLayout/BubbleLayoutPluginBase.cs @@ -9,13 +9,13 @@ namespace XericUI.BubbleLayout public abstract class BubbleLayoutPluginBase : MonoBehaviour, IBubbleLayoutPlugin { [Tooltip("是否启用此插件")] - [SerializeField] protected bool m_Enabled = true; + [SerializeField] private bool m_PluginEnabled = true; [Tooltip("执行优先级,值越大越靠后执行")] - [SerializeField] protected int m_Order = 10; + [SerializeField] private int m_PluginOrder = 10; - public bool Enabled => m_Enabled; - public int Order => m_Order; + public bool Enabled => m_PluginEnabled; + public int Order => m_PluginOrder; #if UNITY_EDITOR /// @@ -25,8 +25,8 @@ namespace XericUI.BubbleLayout protected virtual void Reset() { - m_Order = EditorDefaultOrder; - m_Enabled = true; + m_PluginOrder = EditorDefaultOrder; + m_PluginEnabled = true; } #endif } diff --git a/Runtime/BubbleLayout/CircularLayoutConstraint.cs b/Runtime/BubbleLayout/CircularLayoutConstraint.cs index 7d3823f..a3ca5b7 100644 --- a/Runtime/BubbleLayout/CircularLayoutConstraint.cs +++ b/Runtime/BubbleLayout/CircularLayoutConstraint.cs @@ -113,5 +113,111 @@ namespace XericUI.BubbleLayout break; } } + +#if UNITY_EDITOR + private struct GizmoSlot + { + public RectTransform rectTransform; + public Vector2 size; + public int index; + } + + private void OnDrawGizmosSelected() + { + if (!isActiveAndEnabled || transform.childCount == 0) + return; + + var slots = new List(); + for (int i = 0; i < transform.childCount; i++) + { + var rt = transform.GetChild(i) as RectTransform; + if (rt == null || !rt.gameObject.activeInHierarchy) + continue; + slots.Add(new GizmoSlot { rectTransform = rt, size = rt.rect.size, index = i }); + } + if (slots.Count == 0) return; + + switch (m_SortMode) + { + case LayoutSortMode.PositionX: + slots.Sort((a, b) => a.rectTransform.localPosition.x.CompareTo(b.rectTransform.localPosition.x)); + break; + case LayoutSortMode.PositionY: + slots.Sort((a, b) => b.rectTransform.localPosition.y.CompareTo(a.rectTransform.localPosition.y)); + break; + case LayoutSortMode.Size: + slots.Sort((a, b) => (b.size.x * b.size.y).CompareTo(a.size.x * a.size.y)); + break; + } + + var parentRT = (RectTransform)transform; + Vector2 center = new Vector2( + parentRT.rect.width * (0.5f - parentRT.pivot.x), + parentRT.rect.height * (0.5f - parentRT.pivot.y)); + + // 计算角度步长 + float angleStep; + if (m_AngleMode == AngleMode.Auto) + { + angleStep = slots.Count > 1 ? 360f / slots.Count : 0f; + if (slots.Count > 1) + { + float minRequired = m_MinAngle; + for (int i = 0; i < slots.Count; i++) + { + float arc = Mathf.Atan2(slots[i].size.x * 0.5f, m_Radius) * 2f * Mathf.Rad2Deg; + minRequired = Mathf.Max(minRequired, arc); + } + angleStep = Mathf.Max(angleStep, minRequired); + } + } + else + { + angleStep = m_AngleStep; + } + + Vector3 worldCenter = transform.TransformPoint(new Vector3(center.x, center.y, 0)); + + // 绘制环绕参考圆 + Color circleColor = new Color(0.2f, 1f, 0.5f, 0.4f); + Gizmos.color = circleColor; + DrawWireCircle(worldCenter, m_Radius, 64); + + // 绘制元素目标位置标记 + Color slotColor = new Color(0.2f, 1f, 0.5f, 0.7f); + + for (int i = 0; i < slots.Count; i++) + { + var slot = slots[i]; + float angle = m_StartAngle + (m_Clockwise ? -1f : 1f) * angleStep * i; + float rad = angle * Mathf.Deg2Rad; + + Vector2 targetLocal = center + new Vector2(Mathf.Cos(rad) * m_Radius, Mathf.Sin(rad) * m_Radius); + Vector3 targetWorld = transform.TransformPoint(new Vector3(targetLocal.x, targetLocal.y, 0)); + + // 小方框标记 + Vector3 slotScale = transform.TransformVector(new Vector3(slot.size.x * 0.3f, slot.size.y * 0.3f, 0)); + Gizmos.color = slotColor; + Gizmos.DrawWireCube(targetWorld, slotScale); + + // 从圆心到目标的连线 + Gizmos.color = circleColor; + Gizmos.DrawLine(worldCenter, targetWorld); + } + } + + private static void DrawWireCircle(Vector3 center, float radius, int segments) + { + float anglePerSegment = 360f / segments * Mathf.Deg2Rad; + Vector3 prevPoint = center + new Vector3(Mathf.Cos(0) * radius, Mathf.Sin(0) * radius, 0); + for (int i = 1; i <= segments; i++) + { + float a = anglePerSegment * i; + Vector3 nextPoint = center + new Vector3(Mathf.Cos(a) * radius, Mathf.Sin(a) * radius, 0); + Gizmos.DrawLine(prevPoint, nextPoint); + prevPoint = nextPoint; + } + } +#endif } } diff --git a/Runtime/BubbleLayout/HorizontalLayoutConstraint.cs b/Runtime/BubbleLayout/HorizontalLayoutConstraint.cs index 870ba65..2cb8149 100644 --- a/Runtime/BubbleLayout/HorizontalLayoutConstraint.cs +++ b/Runtime/BubbleLayout/HorizontalLayoutConstraint.cs @@ -5,78 +5,115 @@ namespace XericUI.BubbleLayout { /// /// 横排布局约束插件 —— 将气泡元素沿 X 轴排列,并通过弹簧力吸附到目标位置。 + /// 支持多布局框穿插排列。 /// [AddComponentMenu("Xeric UI Vessel/Layout/Horizontal Layout Constraint", 54)] - public class HorizontalLayoutConstraint : BubbleLayoutPluginBase, IBubbleConstraintPlugin + public class HorizontalLayoutConstraint : LinearLayoutConstraintBase { - [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; - + [Header("横排特有")] [Tooltip("垂直对齐方式:0=顶部, 1=居中, 2=底部")] [Range(0f, 2f)] [SerializeField] private float m_VerticalAlignment = 1f; - public void ProcessConstraint(List items, Vector2 layoutCenter) + [Tooltip("起始水平偏移(相对布局框区域中心)")] + [SerializeField] private float m_StartOffsetX = 0f; + + #region LinearLayoutConstraintBase 实现 + + /// + protected override string PrimaryAxisName => "X"; + + /// + protected override bool IsPrimaryHorizontal => true; + + /// + protected override float GetPrimarySize(BubbleItemData item) => item.size.x; + + /// + protected override float GetPrimarySizeFromRT(RectTransform rt) => rt.rect.width; + + /// + protected override Vector2 MakeTargetPosition(float primary, float secondary) { - 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; - } + return new Vector2(primary, secondary); } - private void SortItems(List items) + /// + protected override float GetSecondaryAlignOffset(BubbleItemData item, LayoutBox box) { - switch (m_SortMode) + // 垂直对齐偏移:0=顶部(top), 1=居中, 2=底部(bottom) + // 在 localPosition 空间,Y 轴向上,所以 top = 正方向偏移 + float offset = (1f - m_VerticalAlignment) * (box.Region.height - item.size.y) * 0.5f; + return offset; + } + + #endregion + + #region Editor Gizmos + +#if UNITY_EDITOR + /// + protected override void DrawItemTargets(List allSlots, Vector2 layoutCenter) + { + // 将 slots 按 boxIndex 分组 + var boxSlots = new List[m_LayoutBoxes.Count]; + for (int b = 0; b < m_LayoutBoxes.Count; b++) + boxSlots[b] = new List(); + + for (int i = 0; i < allSlots.Count; i++) { - 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; + var slot = allSlots[i]; + int bIdx = Mathf.Clamp(slot.boxIndex, 0, m_LayoutBoxes.Count - 1); + boxSlots[bIdx].Add(slot); + } + + // 穿插迭代,计算每个 slot 的目标位置 + int[] indices = new int[boxSlots.Length]; + float[] advances = new float[boxSlots.Length]; + for (int b = 0; b < boxSlots.Length; b++) + { + float total = 0f; + for (int i = 0; i < boxSlots[b].Count; i++) + total += boxSlots[b][i].size.x + m_Spacing; + if (boxSlots[b].Count > 0) total -= m_Spacing; + advances[b] = m_LayoutBoxes[b].Region.center.x + m_StartOffsetX - total * 0.5f; + } + + Color fillColor = new Color(1f, 0.6f, 0.1f, 0.15f); + Color wireColor = new Color(1f, 0.55f, 0f, 0.7f); + + bool anyRemaining = true; + while (anyRemaining) + { + anyRemaining = false; + for (int b = 0; b < boxSlots.Length; b++) + { + if (indices[b] >= boxSlots[b].Count) + continue; + + anyRemaining = true; + var slot = boxSlots[b][indices[b]]; + var box = m_LayoutBoxes[b]; + + float alignOffset = (1f - m_VerticalAlignment) * (box.Region.height - slot.size.y) * 0.5f; + float targetY = box.Region.center.y + alignOffset; + float targetX = advances[b]; + + Rect localRect = new Rect( + targetX - slot.size.x * 0.5f, + targetY - slot.size.y * 0.5f, + slot.size.x, + slot.size.y); + + DrawLocalRectAsWireCube(localRect, wireColor, fillColor); + + advances[b] += slot.size.x + m_Spacing; + indices[b]++; + } } } +#endif + + #endregion } } diff --git a/Runtime/BubbleLayout/LayoutBox.cs b/Runtime/BubbleLayout/LayoutBox.cs new file mode 100644 index 0000000..b824d8f --- /dev/null +++ b/Runtime/BubbleLayout/LayoutBox.cs @@ -0,0 +1,82 @@ +using System; +using UnityEngine; + +namespace XericUI.BubbleLayout +{ + /// + /// 布局框定义 —— 描述一个在父级坐标空间中的矩形区域, + /// 用作横向/竖向布局中特定轨道的目标区域。 + /// + [Serializable] + public class LayoutBox + { + /// + /// 布局框名称(仅用于 Inspector 标识) + /// + [Tooltip("布局框名称")] + public string Name = "Box"; + + /// + /// 此框在父级 RectTransform localPosition 空间中的矩形区域 + /// + [Tooltip("布局框的矩形区域(localPosition 空间)")] + public Rect Region = new Rect(-100, -100, 200, 200); + + /// + /// 矩形区域内使用的枢轴点(0~1),用于计算子项在区域内的排列起点 + /// + [Tooltip("区域内枢轴")] + public Vector2 Pivot = new Vector2(0.5f, 0.5f); + + /// + /// 每行/列的最大元素数量(0=不限制),仅对穿插模式有意义 + /// + [Tooltip("每轨最大元素数,0=不限制")] + public int MaxItemsPerTrack; + + public LayoutBox() { } + + public LayoutBox(string name) + { + Name = name; + } + + public LayoutBox(string name, Rect region, Vector2 pivot = default) + { + Name = name; + Region = region; + Pivot = pivot == default ? new Vector2(0.5f, 0.5f) : pivot; + } + + /// + /// 获取 Region 在 localPosition 空间中的中心点 + /// + public Vector2 GetCenter() + { + return Region.center; + } + + /// + /// 获取排列的起始锚点位置(根据 Pivot 在 Region 内的映射) + /// + public Vector2 GetAnchorPoint() + { + return new Vector2( + Region.x + Region.width * Pivot.x, + Region.y + Region.height * Pivot.y); + } + } + + /// + /// 元素到布局框的序列化映射项(仅用于 Editor 持久化) + /// + [Serializable] + public struct LayoutBoxAssignment + { + /// 目标子项的 Key(name_instanceID) + public string ItemKey; + + /// 分配的布局框索引 + public int BoxIndex; + } +} diff --git a/Runtime/BubbleLayout/LayoutBox.cs.meta b/Runtime/BubbleLayout/LayoutBox.cs.meta new file mode 100644 index 0000000..0decc95 --- /dev/null +++ b/Runtime/BubbleLayout/LayoutBox.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 72b571aa1837cf54ba45b65983f7999f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/BubbleLayout/LinearLayoutConstraintBase.cs b/Runtime/BubbleLayout/LinearLayoutConstraintBase.cs new file mode 100644 index 0000000..00df63b --- /dev/null +++ b/Runtime/BubbleLayout/LinearLayoutConstraintBase.cs @@ -0,0 +1,531 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace XericUI.BubbleLayout +{ + /// + /// 横向 / 竖向线性布局约束插件的共用基类。 + /// 提供布局框管理、元素分组排序、穿插迭代、弹簧吸附/瞬移、Editor Gizmo 框架等复用逻辑。 + /// + public abstract class LinearLayoutConstraintBase : BubbleLayoutPluginBase, IBubbleConstraintPlugin + { + #region 字段 + + [Header("吸附力")] + [Tooltip("弹簧吸附力倍率,值越大吸附越快")] + [SerializeField] protected float m_AttractionForce = 50f; + + [Header("排列")] + [Tooltip("排序模式")] + [SerializeField] protected LayoutSortMode m_SortMode = LayoutSortMode.HierarchyOrder; + + [Tooltip("元素间距")] + [SerializeField] protected float m_Spacing = 10f; + + [Header("布局框")] + [Tooltip("布局框列表(默认拥有一个全区域框)。每个框代表一个独立排列轨道,子项按框穿插排列。")] + [SerializeField] protected List m_LayoutBoxes = new List + { + new LayoutBox("Default", new Rect(-200, -200, 400, 400)), + }; + + /// + /// 元素到布局框的分配表(运行时字典的序列化后备) + /// + [SerializeField] private List m_SerializedAssignments = new List(); + + [NonSerialized] + protected Dictionary m_BoxAssignments; + + #endregion + + #region 属性 + + /// 主轴名称("X" / "Y"),用于子类在 Gizmo / 日志中标识 + protected abstract string PrimaryAxisName { get; } + + /// 获取元素沿主轴方向的尺寸 + protected abstract float GetPrimarySize(BubbleItemData item); + + /// + /// 获取元素沿主轴方向的尺寸(用于 Gizmo 预览的 RectTransform 版本) + /// + protected abstract float GetPrimarySizeFromRT(RectTransform rt); + + /// + /// 根据主轴/辅轴坐标合成最终 localPosition + /// + protected abstract Vector2 MakeTargetPosition(float primary, float secondary); + + #endregion + + #region 布局框管理 + + /// + /// 惰性初始化运行时字典 + /// + protected void EnsureAssignmentsInitialized() + { + if (m_BoxAssignments != null) + return; + + m_BoxAssignments = new Dictionary(); + for (int i = 0; i < m_SerializedAssignments.Count; i++) + { + var entry = m_SerializedAssignments[i]; + if (!string.IsNullOrEmpty(entry.ItemKey)) + m_BoxAssignments[entry.ItemKey] = entry.BoxIndex; + } + } + + /// + /// 确保布局框列表至少有一项 + /// + protected void EnsureAtLeastOneBox() + { + if (m_LayoutBoxes == null) + m_LayoutBoxes = new List(); + if (m_LayoutBoxes.Count == 0) + m_LayoutBoxes.Add(new LayoutBox("Default", new Rect(-200, -200, 400, 400))); + } + + /// + /// 将指定 GameObject 分配到指定编号的布局框。 + /// 默认所有子项属于第 0 号框。 + /// + /// 布局框索引(从 0 开始),超出范围会被钳制 + /// 要分配的子项 GameObject + public void MarkObject2LayoutBox(int boxIndex, GameObject target) + { + if (target == null) + return; + + EnsureAtLeastOneBox(); + boxIndex = Mathf.Clamp(boxIndex, 0, m_LayoutBoxes.Count - 1); + EnsureAssignmentsInitialized(); + + string key = $"{target.name}_{target.GetInstanceID()}"; + m_BoxAssignments[key] = boxIndex; + + // 同步回序列化列表 + SyncDictionaryToSerialized(); + } + + /// + /// 清除指定 GameObject 的布局框分配(恢复默认框 0) + /// + public void ClearBoxAssignment(GameObject target) + { + if (target == null) + return; + + EnsureAssignmentsInitialized(); + string key = $"{target.name}_{target.GetInstanceID()}"; + m_BoxAssignments.Remove(key); + SyncDictionaryToSerialized(); + } + + /// + /// 根据屏幕空间坐标和指定 Pivot,将布局框的 Region 映射到屏幕对应位置。 + /// 适用于 Canvas ScreenSpace-Overlay / Camera 模式。 + /// + /// 目标布局框索引 + /// 屏幕空间矩形(像素坐标) + /// 渲染 Canvas 的相机,ScreenSpace-Overlay 时传 null + public void SetBoxRegionFromScreenSpace(int boxIndex, Rect screenRect, Camera camera = null) + { + EnsureAtLeastOneBox(); + if (boxIndex < 0 || boxIndex >= m_LayoutBoxes.Count) + return; + + var parentRT = transform as RectTransform; + if (parentRT == null) + return; + + Vector2 localCenter; + RectTransformUtility.ScreenPointToLocalPointInRectangle( + parentRT, screenRect.center, camera, out localCenter); + + Vector2 localSize = screenRect.size / parentRT.lossyScale; + + m_LayoutBoxes[boxIndex].Region = new Rect( + localCenter.x - localSize.x * 0.5f, + localCenter.y - localSize.y * 0.5f, + localSize.x, + localSize.y); + } + + /// + /// 获取指定 GameObject 当前分配的布局框索引 + /// + public int GetBoxIndex(GameObject target) + { + if (target == null) + return 0; + + EnsureAssignmentsInitialized(); + string key = $"{target.name}_{target.GetInstanceID()}"; + return m_BoxAssignments.TryGetValue(key, out int idx) ? idx : 0; + } + + /// + /// 获取所有布局框(只读) + /// + public IReadOnlyList GetLayoutBoxes() + { + EnsureAtLeastOneBox(); + return m_LayoutBoxes; + } + + private void SyncDictionaryToSerialized() + { + m_SerializedAssignments.Clear(); + if (m_BoxAssignments == null) + return; + + foreach (var kv in m_BoxAssignments) + { + m_SerializedAssignments.Add(new LayoutBoxAssignment + { + ItemKey = kv.Key, + BoxIndex = kv.Value, + }); + } + } + + #endregion + + #region IBubbleConstraintPlugin 实现 + + public virtual void ProcessConstraint(List items, Vector2 layoutCenter) + { + if (items == null || items.Count == 0) + return; + + EnsureAtLeastOneBox(); + EnsureAssignmentsInitialized(); + + // 1. 将元素按布局框分组 + var boxGroups = new List[m_LayoutBoxes.Count]; + for (int b = 0; b < m_LayoutBoxes.Count; b++) + boxGroups[b] = new List(); + + for (int i = 0; i < items.Count; i++) + { + var item = items[i]; + int boxIdx = 0; + if (item.rectTransform != null) + { + string key = $"{item.rectTransform.name}_{item.rectTransform.GetInstanceID()}"; + if (m_BoxAssignments.TryGetValue(key, out int assigned)) + boxIdx = Mathf.Clamp(assigned, 0, m_LayoutBoxes.Count - 1); + } + boxGroups[boxIdx].Add(item); + } + + // 2. 各组内部排序 + for (int b = 0; b < boxGroups.Length; b++) + SortItems(boxGroups[b]); + + // 3. 计算各组总主轴尺寸 + float[] boxTotalPrimarySize = new float[boxGroups.Length]; + float[] boxAdvance = new float[boxGroups.Length]; + for (int b = 0; b < boxGroups.Length; b++) + { + float total = 0f; + for (int i = 0; i < boxGroups[b].Count; i++) + total += GetPrimarySize(boxGroups[b][i]) + m_Spacing; + if (boxGroups[b].Count > 0) + total -= m_Spacing; + boxTotalPrimarySize[b] = total; + } + + // 4. 计算各框的起始位置(主轴方向),位于 Region 中心偏前 + for (int b = 0; b < boxGroups.Length; b++) + { + var region = m_LayoutBoxes[b].Region; + float primaryCenter = IsPrimaryHorizontal + ? region.center.x + : region.center.y; + + boxAdvance[b] = primaryCenter - boxTotalPrimarySize[b] * 0.5f; + } + + // 5. 穿插迭代:按轨轮询各框 + int[] boxIndices = new int[boxGroups.Length]; + bool anyRemaining = true; + var targetPositions = new List<(BubbleItemData item, int boxIndex, Vector2 targetPos)>(); + + while (anyRemaining) + { + anyRemaining = false; + for (int b = 0; b < boxGroups.Length; b++) + { + if (boxIndices[b] >= boxGroups[b].Count) + continue; + + anyRemaining = true; + var item = boxGroups[b][boxIndices[b]]; + var box = m_LayoutBoxes[b]; + + // 计算辅轴位置(box 区域中心 ± 对齐偏移) + float secondary = IsPrimaryHorizontal + ? box.Region.center.y + GetSecondaryAlignOffset(item, box) + : box.Region.center.x + GetSecondaryAlignOffset(item, box); + + Vector2 targetPos = MakeTargetPosition(boxAdvance[b], secondary); + + // 确保目标在 box 区域内 + targetPos = ClampToBox(targetPos, item, box); + + targetPositions.Add((item, b, targetPos)); + + // 推进主轴位置 + boxAdvance[b] += GetPrimarySize(item) + m_Spacing; + boxIndices[b]++; + } + } + + // 6. 按计算顺序依次施加力(确保穿插顺序被物理系统感知) + for (int i = 0; i < targetPositions.Count; i++) + { + var (item, boxIndex, targetPos) = targetPositions[i]; + var box = m_LayoutBoxes[boxIndex]; + + // 检查是否超出 box 允许范围,超出则瞬移 + if (IsOutOfBox(item.position, item, box)) + { + item.position = targetPos; + item.velocity = Vector2.zero; + } + else + { + Vector2 springForce = (targetPos - item.position) * m_AttractionForce; + item.force += springForce; + } + } + } + + /// + /// 判断主轴是水平还是垂直 + /// + protected abstract bool IsPrimaryHorizontal { get; } + + /// + /// 子类提供辅轴对齐偏移量(如横排的垂直对齐、竖排的拉链偏移) + /// + /// 偏移量(正值 = 远离区域左上角方向) + protected virtual float GetSecondaryAlignOffset(BubbleItemData item, LayoutBox box) + { + return 0f; + } + + /// + /// 将目标位置钳制到布局框区域内(考虑元素自身尺寸) + /// + protected Vector2 ClampToBox(Vector2 target, BubbleItemData item, LayoutBox box) + { + Rect r = box.Region; + float halfW = item.size.x * 0.5f; + float halfH = item.size.y * 0.5f; + + target.x = Mathf.Clamp(target.x, r.xMin + halfW, r.xMax - halfW); + target.y = Mathf.Clamp(target.y, r.yMin + halfH, r.yMax - halfH); + return target; + } + + /// + /// 判断位置是否超出布局框(用于瞬移判断) + /// + protected bool IsOutOfBox(Vector2 pos, BubbleItemData item, LayoutBox box) + { + Rect r = box.Region; + float halfW = item.size.x * 0.5f; + float halfH = item.size.y * 0.5f; + + return pos.x - halfW < r.xMin || pos.x + halfW > r.xMax + || pos.y - halfH < r.yMin || pos.y + halfH > r.yMax; + } + + /// + /// 排序元素列表 + /// + protected void SortItems(List items) + { + if (items.Count <= 1) + return; + + switch (m_SortMode) + { + case LayoutSortMode.HierarchyOrder: + break; + case LayoutSortMode.PositionX: + items.Sort((a, b) => a.position.x.CompareTo(b.position.x)); + break; + case LayoutSortMode.PositionY: + items.Sort((a, b) => b.position.y.CompareTo(a.position.y)); + break; + case LayoutSortMode.Size: + items.Sort((a, b) => (b.size.x * b.size.y).CompareTo(a.size.x * a.size.y)); + break; + } + } + + #endregion + + #region Editor Gizmos + +#if UNITY_EDITOR + /// + /// Gizmo 预览数据结构 + /// + protected struct GizmoSlot + { + public RectTransform rectTransform; + public Vector2 size; + public int index; + public int boxIndex; + } + + protected virtual void OnDrawGizmosSelected() + { + if (!isActiveAndEnabled || transform.childCount == 0) + return; + + // 收集子元素 + var allSlots = new List(); + for (int i = 0; i < transform.childCount; i++) + { + var rt = transform.GetChild(i) as RectTransform; + if (rt == null || !rt.gameObject.activeInHierarchy) + continue; + allSlots.Add(new GizmoSlot { rectTransform = rt, size = rt.rect.size, index = i, boxIndex = 0 }); + } + if (allSlots.Count == 0) return; + + // 读回分配 + EnsureAssignmentsInitialized(); + for (int i = 0; i < allSlots.Count; i++) + { + var slot = allSlots[i]; + string key = $"{slot.rectTransform.name}_{slot.rectTransform.GetInstanceID()}"; + if (m_BoxAssignments.TryGetValue(key, out int bIdx)) + { + slot.boxIndex = Mathf.Clamp(bIdx, 0, m_LayoutBoxes.Count - 1); + allSlots[i] = slot; + } + } + + // 按当前排序模式排序 + switch (m_SortMode) + { + case LayoutSortMode.PositionX: + allSlots.Sort((a, b) => a.rectTransform.localPosition.x.CompareTo(b.rectTransform.localPosition.x)); + break; + case LayoutSortMode.PositionY: + allSlots.Sort((a, b) => b.rectTransform.localPosition.y.CompareTo(a.rectTransform.localPosition.y)); + break; + case LayoutSortMode.Size: + allSlots.Sort((a, b) => (b.size.x * b.size.y).CompareTo(a.size.x * a.size.y)); + break; + } + + var parentRT = (RectTransform)transform; + Vector2 layoutCenter = new Vector2( + parentRT.rect.width * (0.5f - parentRT.pivot.x), + parentRT.rect.height * (0.5f - parentRT.pivot.y)); + + // 1. 绘制所有布局框区域 + DrawBoxRegions(); + + // 2. 委托子类绘制元素目标位置 + DrawItemTargets(allSlots, layoutCenter); + } + + /// + /// 绘制所有布局框的矩形区域(白色虚线框) + /// + protected virtual void DrawBoxRegions() + { + Color[] boxColors = + { + new Color(0.5f, 0.5f, 1f, 0.35f), // 蓝色调 + new Color(1f, 0.5f, 0.5f, 0.35f), // 红色调 + new Color(0.5f, 1f, 0.5f, 0.35f), // 绿色调 + new Color(1f, 1f, 0.5f, 0.35f), // 黄色调 + new Color(0.5f, 1f, 1f, 0.35f), // 青色调 + new Color(1f, 0.5f, 1f, 0.35f), // 紫色调 + }; + + for (int b = 0; b < m_LayoutBoxes.Count; b++) + { + var box = m_LayoutBoxes[b]; + Color c = boxColors[b % boxColors.Length]; + + Rect r = box.Region; + Vector3 centerWorld = transform.TransformPoint(new Vector3(r.center.x, r.center.y, 0)); + Vector3 sizeWorld = transform.TransformVector(new Vector3(r.width, r.height, 0)); + + // 半透明填充 + Gizmos.color = new Color(c.r, c.g, c.b, 0.08f); + Gizmos.DrawCube(centerWorld, sizeWorld); + + // 虚线边框 + Gizmos.color = c; + DrawWireRect(centerWorld, sizeWorld); + + // 中心十字 + float crossLen = Mathf.Min(r.width, r.height) * 0.08f; + Vector3 cxWorld = transform.TransformVector(new Vector3(crossLen, 0, 0)); + Vector3 cyWorld = transform.TransformVector(new Vector3(0, crossLen, 0)); + Gizmos.DrawLine(centerWorld - cxWorld, centerWorld + cxWorld); + Gizmos.DrawLine(centerWorld - cyWorld, centerWorld + cyWorld); + } + } + + /// + /// 子类重写:根据已排序的 slots 和 layoutCenter 绘制各元素的目标位置预览 + /// + protected abstract void DrawItemTargets(List allSlots, Vector2 layoutCenter); + + /// + /// 绘制一个线框矩形 + /// + protected static void DrawWireRect(Vector3 center, Vector3 size) + { + Vector3 half = size * 0.5f; + Vector3[] corners = new Vector3[5] + { + center + new Vector3(-half.x, -half.y, 0), + center + new Vector3( half.x, -half.y, 0), + center + new Vector3( half.x, half.y, 0), + center + new Vector3(-half.x, half.y, 0), + center + new Vector3(-half.x, -half.y, 0), + }; + for (int i = 0; i < 4; i++) + Gizmos.DrawLine(corners[i], corners[i + 1]); + } + + /// + /// 将 localPosition 空间的 Rect 绘制为世界空间线框 + /// + protected void DrawLocalRectAsWireCube(Rect localRect, Color wireColor, Color? fillColor = null) + { + Vector3 worldCenter = transform.TransformPoint(new Vector3(localRect.center.x, localRect.center.y, 0)); + Vector3 worldSize = transform.TransformVector(new Vector3(localRect.width, localRect.height, 0)); + + if (fillColor.HasValue) + { + Gizmos.color = fillColor.Value; + Gizmos.DrawCube(worldCenter, worldSize); + } + + Gizmos.color = wireColor; + Gizmos.DrawWireCube(worldCenter, worldSize); + } +#endif + + #endregion + } +} diff --git a/Runtime/BubbleLayout/LinearLayoutConstraintBase.cs.meta b/Runtime/BubbleLayout/LinearLayoutConstraintBase.cs.meta new file mode 100644 index 0000000..f8caa61 --- /dev/null +++ b/Runtime/BubbleLayout/LinearLayoutConstraintBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0158a0d6480014d46ac381596bfe78fe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/BubbleLayout/QuadTree.cs b/Runtime/BubbleLayout/QuadTree.cs new file mode 100644 index 0000000..94ac665 --- /dev/null +++ b/Runtime/BubbleLayout/QuadTree.cs @@ -0,0 +1,451 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +using UnityEngine; +#if Unity_Runtime +namespace XericLibrary.Runtime.Type.SpatialAlgorithm +{ + /// + /// 灵活的四叉树实现,支持多种坐标类型 + /// + public class QuadTree + { + #region 字段属性 + + private int maxObjectsPerNode = 5; + private int maxDepth = 4; + + private Node root; + private Func getRectFunc; // 获取对象边界的委托 + + /// + /// 获取此四叉树的范围 + /// + public Rect RootRect => root != null ? root.boundary : Rect.zero; + + public int Count { get; private set; } + + /// + /// 清空四叉树,保留边界和配置以便重用 + /// + public void Clear() + { + if (root != null) + { + root.ClearAll(); + } + Count = 0; + } + + /// + /// 重建四叉树,使用相同边界和配置 + /// + public void Rebuild(IEnumerable items) + { + Clear(); + foreach (var item in items) + { + Insert(item); + } + } + + /// + /// 获取四叉树中所有元素 + /// + public void GetAllItems(HashSet result) + { + if (result == null) + return; + root?.CollectAllItems(result); + } + + #endregion + + #region 构造函数 + + /// + /// 初始化四叉树 + /// + /// 四叉树边界 + /// 获取对象边界的函数 + public QuadTree(Rect boundary, Func getRectFunc, int maxObjectsPerNode = 5, int maxDepth = 4) + { + this.maxObjectsPerNode = Math.Max(1, maxObjectsPerNode); + this.maxDepth = Math.Max(1, maxDepth); + this.root = new Node(boundary, 0, getRectFunc, this); + this.getRectFunc = getRectFunc ?? throw new ArgumentNullException(nameof(getRectFunc)); + } + + #endregion + + #region 公共方法 + + /// + /// 插入元素 + /// + public bool Insert(T item) + { + return root.Insert(item); + } + + /// + /// 查找范围内的元素 + /// + /// 查找范围 + /// 建议使用数据容器对象池控制,避免频繁GC,默认空将自动创建新的数据容器 + /// + public HashSet Retrieve(Rect area, HashSet result = null) + { + return root.Retrieve(area, result); + } + + /// + /// 查找最近的元素 + /// + public T FindNearest(Vector2 target, float maxDistance = float.MaxValue) + { + T nearest = default; + float minDistanceSquared = maxDistance * maxDistance; + root.FindNearest(target, ref nearest, ref minDistanceSquared); + return nearest; + } + + /// + /// 调试绘制网格 + /// + public void DrawGrid() + { + root.DrawGrid(Color.white); + } + + #endregion + + #region 节点类 + + private class Node : IEnumerable + { + internal Rect boundary; + internal HashSet objects = new HashSet(); + internal Node[] children; + internal readonly int depth; + internal readonly Func getRectFunc; + internal readonly QuadTree parent; + + public Node(Rect boundary, int depth, Func getRectFunc, QuadTree parent) + { + this.boundary = boundary; + this.depth = depth; + this.getRectFunc = getRectFunc; + this.parent = parent; + this.children = null; + } + + /// + /// 插入元素 + /// + public bool Insert(T item) + { + Rect itemRect = getRectFunc(item); + + // 如果元素与当前节点不重叠,无法插入 + if (!IsOverlapping(boundary, itemRect)) + return false; + + // 如果是叶子节点且未达容量上限 + if (objects != null && (objects.Count < parent.maxObjectsPerNode || depth >= parent.maxDepth)) + { + objects.Add(item); + parent.Count++; + return true; + } + + // 分裂节点 + if (children == null) + Split(); + + // 插入到子节点 + bool inserted = false; + foreach (var child in children) + { + if (child.Insert(item)) + inserted = true; + } + + return inserted; + } + + /// + /// 分裂为四个子节点 + /// + private void Split() + { + float halfWidth = boundary.width / 2; + float halfHeight = boundary.height / 2; + float midX = boundary.x + halfWidth; + float midY = boundary.y + halfHeight; + + children = new Node[4]; + children[0] = new Node(new Rect(boundary.x, boundary.y, halfWidth, halfHeight), depth + 1, getRectFunc, parent); // 左上 + children[1] = new Node(new Rect(midX, boundary.y, halfWidth, halfHeight), depth + 1, getRectFunc, parent); // 右上 + children[2] = new Node(new Rect(boundary.x, midY, halfWidth, halfHeight), depth + 1, getRectFunc, parent); // 左下 + children[3] = new Node(new Rect(midX, midY, halfWidth, halfHeight), depth + 1, getRectFunc, parent); // 右下 + + // 移动当前节点的对象到子节点 + var tempObjects = new List(objects); + objects.Clear(); + objects = null; // 标记为非叶子节点 + + foreach (var obj in tempObjects) + { + Insert(obj); + } + } + + /// + /// 查找最近的元素 + /// + public void FindNearest(Vector2 target, ref T nearest, ref float minDistanceSquared) + { + // 如果当前节点距离大于已知最小距离,直接返回 + float distanceToBoundary = GetDistanceToRectSquared(target, boundary); + if (distanceToBoundary >= minDistanceSquared) + return; + + // 检查当前节点中的对象 + if (objects != null) + { + foreach (var obj in objects) + { + Rect objRect = getRectFunc(obj); + float distanceSquared = GetDistanceToRectSquared(target, objRect); + + if (distanceSquared < minDistanceSquared) + { + minDistanceSquared = distanceSquared; + nearest = obj; + } + } + } + + // 递归检查子节点 + if (children != null) + { + // 按距离排序子节点,优先检查更近的 + var childDistances = new List>(); + foreach (var child in children) + { + float dist = child.GetDistanceToRectSquared(target, child.boundary); + childDistances.Add(Tuple.Create(child, dist)); + } + + // 按距离升序排序 + childDistances.Sort((a, b) => a.Item2.CompareTo(b.Item2)); + + foreach (var tuple in childDistances) + { + if (tuple.Item2 < minDistanceSquared) + { + tuple.Item1.FindNearest(target, ref nearest, ref minDistanceSquared); + } + } + } + } + + /// + /// 查找范围内的元素 + /// + public HashSet Retrieve(Rect area, HashSet result) + { + if (result == null) + result = new HashSet(); + + // 如果与当前节点不重叠,返回空集 + if (!IsOverlapping(boundary, area)) + return result; + + // 检查当前节点中的对象 + if (objects != null) + { + foreach (var obj in objects) + { + if (IsOverlapping(getRectFunc(obj), area)) + { + result.Add(obj); + } + } + } + + // 递归查询子节点 + if (children != null) + { + foreach (var child in children) + { + var childResults = child.Retrieve(area, result); + foreach (var item in childResults) + { + result.Add(item); + } + } + } + + return result; + } + + /// + /// 绘制节点网格 + /// + public void DrawGrid(Color color) + { + Debug.DrawLine(new Vector2(boundary.x, boundary.y), new Vector2(boundary.xMax, boundary.y), color); + Debug.DrawLine(new Vector2(boundary.x, boundary.y), new Vector2(boundary.x, boundary.yMax), color); + Debug.DrawLine(new Vector2(boundary.xMax, boundary.y), new Vector2(boundary.xMax, boundary.yMax), color); + Debug.DrawLine(new Vector2(boundary.x, boundary.yMax), new Vector2(boundary.xMax, boundary.yMax), color); + + if (children != null) + { + foreach (var child in children) + { + child.DrawGrid(color); + } + } + } + + #region 辅助方法 + + /// + /// 判断两个矩形是否重叠 + /// + private bool IsOverlapping(Rect a, Rect b) + { + return a.x < b.xMax && a.xMax > b.x && + a.y < b.yMax && a.yMax > b.y; + } + + /// + /// 计算点到矩形的最小距离平方 + /// + private float GetDistanceToRectSquared(Vector2 point, Rect rect) + { + float dx = Mathf.Max(rect.x - point.x, point.x - rect.xMax, 0); + float dy = Mathf.Max(rect.y - point.y, point.y - rect.yMax, 0); + return dx * dx + dy * dy; + } + + /// + /// 递归清空所有节点 + /// + public void ClearAll() + { + if (objects != null) + { + objects.Clear(); + } + + if (children != null) + { + for (int i = 0; i < children.Length; i++) + { + children[i]?.ClearAll(); + } + children = null; + } + + objects = new HashSet(); + } + + /// + /// 递归收集所有元素 + /// + public void CollectAllItems(HashSet result) + { + if (objects != null) + { + foreach (var obj in objects) + { + result.Add(obj); + } + return; + } + + if (children != null) + { + for (int i = 0; i < children.Length; i++) + { + children[i]?.CollectAllItems(result); + } + } + } + + public IEnumerator GetEnumerator() + { + if (objects != null) + yield return this; + else + foreach (var child in children) + foreach (var node in child) + yield return node; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + #endregion + } + + #endregion + } + + /// + /// 四叉树扩展方法,提供不同类型的矩形转换 + /// + public static class QuadTreeExtensions + { + // Vector2 扩展 - 视为点 + public static Rect ToQuadTreeRect(this Vector2 vector) + { + return new Rect(vector, Vector2.zero); + } + + // Vector3 扩展 - 使用X和Y坐标,视为点 + public static Rect ToQuadTreeRect(this Vector3 vector) + { + return new Rect(vector.x, vector.y, 0, 0); + } + + // Rect 扩展 - 直接使用自身作为边界 + public static Rect ToQuadTreeRect(this Rect rect) + { + return rect; + } + + // RectTransform 扩展 - 使用Rect范围 + public static Rect ToQuadTreeRect(this RectTransform transform) + { + return transform.rect; + } + + // Transform 扩展 - 使用位置的X和Y坐标,视为点 + public static Rect ToQuadTreeRect(this Transform transform) + { + Vector3 pos = transform.position; + return new Rect(pos.x, pos.y, 0, 0); + } + + // 简化四叉树创建的扩展方法 + public static QuadTree CreateQuadTree(this IEnumerable collection, Rect boundary, Func getRectFunc) + { + var quadTree = new QuadTree(boundary, getRectFunc); + foreach (var item in collection) + { + quadTree.Insert(item); + } + return quadTree; + } + } + +} +#endif \ No newline at end of file diff --git a/Runtime/BubbleLayout/QuadTree.cs.meta b/Runtime/BubbleLayout/QuadTree.cs.meta new file mode 100644 index 0000000..0b9361b --- /dev/null +++ b/Runtime/BubbleLayout/QuadTree.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: eac931867b3f4645a5c0e454b9deb626 +timeCreated: 1781071846 \ No newline at end of file diff --git a/Runtime/BubbleLayout/VerticalLayoutConstraint.cs b/Runtime/BubbleLayout/VerticalLayoutConstraint.cs index 07a2898..645f40e 100644 --- a/Runtime/BubbleLayout/VerticalLayoutConstraint.cs +++ b/Runtime/BubbleLayout/VerticalLayoutConstraint.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using UnityEngine; @@ -24,89 +23,223 @@ namespace XericUI.BubbleLayout /// /// 竖排布局约束插件 —— 将气泡元素沿 Y 轴排列,并通过弹簧力吸附到目标位置。 + /// 支持多布局框穿插排列和拉链模式(一左一右交替)。 /// [AddComponentMenu("Xeric UI Vessel/Layout/Vertical Layout Constraint", 53)] - public class VerticalLayoutConstraint : BubbleLayoutPluginBase, IBubbleConstraintPlugin + public class VerticalLayoutConstraint : LinearLayoutConstraintBase { - [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("起始偏移(相对于布局中心上方)")] + [Header("竖排特有")] + [Tooltip("起始竖直偏移(相对布局框区域中心)")] [SerializeField] private float m_StartOffsetY = 0f; [Header("拉链模式")] - [Tooltip("启用拉链排列(偶数索引偏左,奇数索引偏右)")] + [Tooltip("启用拉链排列(偶数索引偏左,奇数索引偏右,仅对默认单框布局有效)")] [SerializeField] private bool m_ZipperMode = false; [Tooltip("拉链模式下左右间距")] [SerializeField] private float m_ZipperGap = 20f; - public void ProcessConstraint(List items, Vector2 layoutCenter) + #region LinearLayoutConstraintBase 实现 + + /// + protected override string PrimaryAxisName => "Y"; + + /// + protected override bool IsPrimaryHorizontal => false; + + /// + protected override float GetPrimarySize(BubbleItemData item) => item.size.y; + + /// + protected override float GetPrimarySizeFromRT(RectTransform rt) => rt.rect.height; + + /// + protected override Vector2 MakeTargetPosition(float primary, float secondary) + { + return new Vector2(secondary, primary); + } + + /// + protected override float GetSecondaryAlignOffset(BubbleItemData item, LayoutBox box) + { + // 拉链偏移由外部交叉索引决定,这里返回 0 + return 0f; + } + + #endregion + + #region 重写 ProcessConstraint 以支持拉链模式 + + /// + public override void ProcessConstraint(List items, Vector2 layoutCenter) { if (items == null || items.Count == 0) return; - // 排序 - var sorted = new List(items); - SortItems(sorted); + EnsureAtLeastOneBox(); + EnsureAssignmentsInitialized(); - // 计算起始 Y - float totalHeight = 0f; - for (int i = 0; i < sorted.Count; i++) - totalHeight += sorted[i].size.y + m_Spacing; - totalHeight -= m_Spacing; // 去掉最后一个间距 + // 分组 + var boxGroups = new List[m_LayoutBoxes.Count]; + for (int b = 0; b < m_LayoutBoxes.Count; b++) + boxGroups[b] = new List(); - float startY = layoutCenter.y + m_StartOffsetY + totalHeight * 0.5f; - - // 逐元素设置目标位置并施加弹簧力 - float currentY = startY; - for (int i = 0; i < sorted.Count; i++) + for (int i = 0; i < items.Count; i++) { - var item = sorted[i]; - float targetX = layoutCenter.x; - float targetY = currentY; - - // 拉链模式:交替左右偏移 - if (m_ZipperMode) + var item = items[i]; + int boxIdx = 0; + if (item.rectTransform != null) { - targetX = layoutCenter.x + (i % 2 == 0 ? -m_ZipperGap : m_ZipperGap) * 0.5f; + string key = $"{item.rectTransform.name}_{item.rectTransform.GetInstanceID()}"; + if (m_BoxAssignments.TryGetValue(key, out int assigned)) + boxIdx = Mathf.Clamp(assigned, 0, m_LayoutBoxes.Count - 1); } - - 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; + boxGroups[boxIdx].Add(item); } - } - private void SortItems(List items) - { - switch (m_SortMode) + // 排序各组 + for (int b = 0; b < boxGroups.Length; b++) + SortItems(boxGroups[b]); + + // 计算各组总主轴尺寸及起始位置 + float[] boxTotalPrimary = new float[boxGroups.Length]; + float[] boxAdvance = new float[boxGroups.Length]; + for (int b = 0; b < boxGroups.Length; b++) { - 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; + float total = 0f; + for (int i = 0; i < boxGroups[b].Count; i++) + total += boxGroups[b][i].size.y + m_Spacing; + if (boxGroups[b].Count > 0) total -= m_Spacing; + boxTotalPrimary[b] = total; + boxAdvance[b] = m_LayoutBoxes[b].Region.center.y + m_StartOffsetY + total * 0.5f; + } + + // 穿插迭代 + int[] boxIndices = new int[boxGroups.Length]; + int globalIndex = 0; + bool anyRemaining = true; + var targetList = new List<(BubbleItemData item, int boxIndex, Vector2 targetPos)>(); + + while (anyRemaining) + { + anyRemaining = false; + for (int b = 0; b < boxGroups.Length; b++) + { + if (boxIndices[b] >= boxGroups[b].Count) + continue; + + anyRemaining = true; + var item = boxGroups[b][boxIndices[b]]; + var box = m_LayoutBoxes[b]; + + // 辅轴位置:拉链模式下的 X 偏移 + float secondaryX = box.Region.center.x; + if (m_ZipperMode) + { + secondaryX += (globalIndex % 2 == 0 ? -m_ZipperGap : m_ZipperGap) * 0.5f; + } + + // 主轴位置推进:从上往下(Y 减小) + float targetY = boxAdvance[b]; + Vector2 targetPos = new Vector2(secondaryX, targetY); + targetPos = ClampToBox(targetPos, item, box); + + targetList.Add((item, b, targetPos)); + + boxAdvance[b] -= item.size.y + m_Spacing; + boxIndices[b]++; + globalIndex++; + } + } + + // 施加力 / 瞬移 + for (int i = 0; i < targetList.Count; i++) + { + var (item, boxIndex, targetPos) = targetList[i]; + var box = m_LayoutBoxes[boxIndex]; + + if (IsOutOfBox(item.position, item, box)) + { + item.position = targetPos; + item.velocity = Vector2.zero; + } + else + { + Vector2 springForce = (targetPos - item.position) * m_AttractionForce; + item.force += springForce; + } } } + + #endregion + + #region Editor Gizmos + +#if UNITY_EDITOR + /// + protected override void DrawItemTargets(List allSlots, Vector2 layoutCenter) + { + var boxSlots = new List[m_LayoutBoxes.Count]; + for (int b = 0; b < m_LayoutBoxes.Count; b++) + boxSlots[b] = new List(); + + for (int i = 0; i < allSlots.Count; i++) + { + var slot = allSlots[i]; + int bIdx = Mathf.Clamp(slot.boxIndex, 0, m_LayoutBoxes.Count - 1); + boxSlots[bIdx].Add(slot); + } + + int[] indices = new int[boxSlots.Length]; + float[] advances = new float[boxSlots.Length]; + for (int b = 0; b < boxSlots.Length; b++) + { + float total = 0f; + for (int i = 0; i < boxSlots[b].Count; i++) + total += boxSlots[b][i].size.y + m_Spacing; + if (boxSlots[b].Count > 0) total -= m_Spacing; + advances[b] = m_LayoutBoxes[b].Region.center.y + m_StartOffsetY + total * 0.5f; + } + + Color fillColor = new Color(0.1f, 0.7f, 1f, 0.15f); + Color wireColor = new Color(0f, 0.6f, 1f, 0.7f); + + int globalIndex = 0; + bool anyRemaining = true; + while (anyRemaining) + { + anyRemaining = false; + for (int b = 0; b < boxSlots.Length; b++) + { + if (indices[b] >= boxSlots[b].Count) + continue; + + anyRemaining = true; + var slot = boxSlots[b][indices[b]]; + var box = m_LayoutBoxes[b]; + + float targetX = box.Region.center.x; + if (m_ZipperMode) + targetX += (globalIndex % 2 == 0 ? -m_ZipperGap : m_ZipperGap) * 0.5f; + + float targetY = advances[b]; + + Rect localRect = new Rect( + targetX - slot.size.x * 0.5f, + targetY - slot.size.y * 0.5f, + slot.size.x, + slot.size.y); + + DrawLocalRectAsWireCube(localRect, wireColor, fillColor); + + advances[b] -= slot.size.y + m_Spacing; + indices[b]++; + globalIndex++; + } + } + } +#endif + + #endregion } } diff --git a/Runtime/DynamicActivitiesImpact.meta b/Runtime/DynamicActivitiesImpact.meta deleted file mode 100644 index ef0553a..0000000 --- a/Runtime/DynamicActivitiesImpact.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 73eb6a65472648ddafa15ed1a6f525a2 -timeCreated: 1757990443 \ No newline at end of file diff --git a/Runtime/DynamicActivitiesImpact/UIDynamicActiveImpact.cs b/Runtime/DynamicActivitiesImpact/UIDynamicActiveImpact.cs deleted file mode 100644 index dde3a14..0000000 --- a/Runtime/DynamicActivitiesImpact/UIDynamicActiveImpact.cs +++ /dev/null @@ -1,325 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using UnityEngine; -using UnityEngine.Pool; -using UnityEngine.Serialization; -using UnityEngine.UI; -using XericLibrary.Runtime.MacroLibrary; -using XericLibrary.Runtime.Type.SpatialAlgorithm; -using XericUI.Core.Base; -using XericUI.OverrideLayout; - -#if UNITY_EDITOR -using XericLibraryEditor.Debug; -#endif -#if ODIN_INSPECTOR -using Sirenix.OdinInspector; -#endif - -namespace XericUI.DynamicActivitiesImpact -{ - /// - /// 导入动态激活组件(需要外部控制部分检查与生命周期事件) - /// - [Obsolete("直接用XericScrollRect")] - public class UIDynamicActiveImpact : XericUIBehaciour - { - #region 字段属性 - -#if ODIN_INSPECTOR - [LabelText("主要容器")] - [BoxGroup("基本容器", centerLabel: true)] -#endif - [Tooltip("容器子集应呈现平摊的样式")] - public RectTransform containerTransform = null; - -#if ODIN_INSPECTOR - [BoxGroup("基本容器")] - [LabelText("遮罩容器")] -#endif - [Tooltip("用作遮罩计算的组件")] - public RectTransform maskRectTransform = null; - -#if ODIN_INSPECTOR - [BoxGroup("基本容器")] - [LabelText("容器排版控制")] -#endif - public LayoutGroup layoutGroup; - - -#if ODIN_INSPECTOR - [BoxGroup("容器性能")] - [LabelText("预加载像素范围")] -#endif - public float preloadMargin = 200f; - -#if ODIN_INSPECTOR - [BoxGroup("容器性能")] - [LabelText("可被察觉的响应变化长度")] -#endif - public float discernibleDistance = 10f; - -#if ODIN_INSPECTOR - [BoxGroup("容器性能")] - [LabelText("每秒检查更新的次数")] -#endif - [Range(0.1f, 50)] - public float updateRateLimit = 2; - - #endregion - - #region 容器成员 - - internal bool _layoutDirty; - internal bool _transformDirty; - internal int _lastChildrenCount; - internal Vector2 _lastPosition; - internal HashSet _activeSet = null; - internal HashSet _allObjsSet = null; - - private HashSetPool _tempQuadUIHashSetPool = new HashSetPool(); - - /// - /// 保存所有ui对象的四叉树 - /// - protected QuadTree QuadUITargetTree; - - - public virtual IEnumerable ContainerChildren => containerTransform.GetChildren().Select(a => a.RectTransform()); - public virtual int ContainerChildrenCount => containerTransform.childCount; - public virtual Rect ContainerTransformRectRange => containerTransform.ToQuadTreeRect(); - public virtual Rect MaskRectTransformRectRange => maskRectTransform.ToQuadTreeRect(); - public bool IsLayoutDirty => _layoutDirty; - public bool IsTransformDirty => _transformDirty; - public float SaftyDiscernibleUpdateTime => Mathf.Max(1f / 60, 1f / updateRateLimit); - - public LayoutGroup MainLayoutGroup => transform.GetComponent(); - - #endregion - - #region 生命周期 - -#if UNITY_EDITOR - private Vector3[] vertex1 = new Vector3[4]; - private Vector3[] vertex2 = new Vector3[4]; - private Vector3[] vertex3 = new Vector3[4]; - private void OnDrawGizmosSelected() - { - var lossyScale = rectTransform.lossyScale; - var scale = lossyScale * preloadMargin; - var dis2 = lossyScale * discernibleDistance; - - containerTransform.GetWorldCorners(vertex1); - - vertex2[0] = new Vector3(vertex1[0].x - scale.x, vertex1[0].y - scale.y, vertex1[0].z); - vertex2[1] = new Vector3(vertex1[1].x - scale.x, vertex1[1].y + scale.y, vertex1[1].z); - vertex2[2] = new Vector3(vertex1[2].x + scale.x, vertex1[2].y + scale.y, vertex1[2].z); - vertex2[3] = new Vector3(vertex1[3].x + scale.x, vertex1[3].y - scale.y, vertex1[3].z); - - vertex3[0] = new Vector3(vertex1[0].x - dis2.x, vertex1[0].y - dis2.y, vertex1[0].z); - vertex3[1] = new Vector3(vertex1[1].x - dis2.x, vertex1[1].y + dis2.y, vertex1[1].z); - vertex3[2] = new Vector3(vertex1[2].x + dis2.x, vertex1[2].y + dis2.y, vertex1[2].z); - vertex3[3] = new Vector3(vertex1[3].x + dis2.x, vertex1[3].y - dis2.y, vertex1[3].z); - - Gizmos.color = Color.yellow; - MacroGizmosDraw.DrawPolygonLineByPointArray(vertex3); - MacroGizmosDraw.DrawGizmos2RectLinkCube(vertex1, vertex2, Color.grey, Color.cyan, true); - - } -#endif - - protected override void Awake() - { - base.Awake(); - // 容器 - if (containerTransform == null) - containerTransform = rectTransform; - // 遮罩 - if (maskRectTransform == null) - { - var mask = transform.GetComponentInParent(); - maskRectTransform = mask.RectTransform(); - } - // 布局 - if (layoutGroup == null) - layoutGroup = transform.GetComponentInParent(); - if (layoutGroup is XericHorizontalOrVerticalLayoutGroup xericLayoutGroup) - { - xericLayoutGroup.layoutIgnoreInactive = false; - } - - CheckIsDirty(); - } - - protected override void OnEnable() - { - base.OnEnable(); - } - - protected override void OnDisable() - { - base.OnDisable(); - } - - private float accTime = 0; - - protected virtual void Update() - { - if (accTime < SaftyDiscernibleUpdateTime) - { - accTime += Time.unscaledDeltaTime; - return; - } - // 毕竟这里不是粒子计算器 - accTime = accTime % SaftyDiscernibleUpdateTime; - - if (IsLayoutDirty) - { - ResetDirtyFlag(); - RebuildQurdTree(); - } - else if (IsTransformDirty) - { - ResetDirtyFlag(); - } - } - - protected override void OnRectTransformDimensionsChange() - { - base.OnRectTransformDimensionsChange(); - CheckIsDirty(); - } - - private void OnDestroy() - { - // 恢复所有元素的原始状态 - foreach (var element in _allObjsSet) - { - if (element != null && _activeSet.Contains(element)) - { - element.gameObject.SetActive(true); - } - } - } - - #endregion - - #region 刷新框架 - - /// - /// 设置布局脏 - /// - public void LayoutDirty() - => _layoutDirty = true; - - /// - /// 设置变换脏 - /// - public void TransformDirty() - => _transformDirty = true; - - /// - /// 重置脏标记 - /// - private void ResetDirtyFlag() - { - _layoutDirty = false; - _transformDirty = false; - } - - /// - /// 检查容器脏 - /// - public virtual void CheckIsDirty() - { - if (QuadUITargetTree == null || // 四叉树不存在? - _lastChildrenCount != ContainerChildrenCount // 成员数量明显不对 - ) - { - LayoutDirty(); - BackupQuadRectValue(); - return; - } - - // todo 这里暂时只简单考虑直来直去的移动方式,如果有其他复杂移动,需要修改判别条件 - var deltaPosIsDirty = Mathf.Abs(QuadUITargetTree.RootRect.position.sqrMagnitude - _lastPosition.sqrMagnitude) < discernibleDistance; - - if (deltaPosIsDirty // 发生不可忽视的变换? - ) - { - TransformDirty(); - BackupQuadRectValue(); - return; - } - } - - /// - /// 重建四叉树 - /// - public void RebuildQurdTree() - { - // 重建四叉树 - QuadUITargetTree = new QuadTree(ContainerTransformRectRange, a => a.rect); - // 记录参数 - BackupQuadRectValue(); - } - - /// - /// 保存自动重建四叉树所需的参数 - /// - /// - protected virtual void BackupQuadRectValue() - { - _lastPosition = ContainerTransformRectRange.position; - _lastChildrenCount = ContainerChildrenCount; - } - - -#if ODIN_INSPECTOR - [Button("更新所有可见性")] -#endif - /// - /// 更新所有成员的可见性 - /// - public virtual void UpdateAllObjectVisible() - { - if (QuadUITargetTree == null) - { - Debug.LogError($"在更新成员可见性之前,需要确保四叉树存在!"); - return; - } - - var visibalSet = QuadUITargetTree.Retrieve(MaskRectTransformRectRange); - ContainerChildren.ForEachDo(obj => - { - obj.gameObject.SetActive(visibalSet.Contains(obj)); - }); - } - - /// - /// 要求控制布局组的激活一次,提供为何激活的原因 - /// - /// 由于新增成员导致布局更新 - /// 由于销毁成员导致布局更新 - public virtual void UpdateLayoutGroupOnce(bool hasIncreased, bool hasDecreased) - { - MainLayoutGroup.enabled = true; - StartCoroutine(WaitForLayoutDisable()); - return; - IEnumerator WaitForLayoutDisable() - { - if (CanvasUpdateRegistry.IsRebuildingLayout()) - yield return new WaitForEndOfFrame(); - else - yield return new WaitForFixedUpdate(); - MainLayoutGroup.enabled = false; - } - } - - #endregion - } -} - - diff --git a/Runtime/DynamicActivitiesImpact/UIDynamicActiveImpact.cs.meta b/Runtime/DynamicActivitiesImpact/UIDynamicActiveImpact.cs.meta deleted file mode 100644 index 1dbfbf5..0000000 --- a/Runtime/DynamicActivitiesImpact/UIDynamicActiveImpact.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 48016863e20f4c61a91245c593d575da -timeCreated: 1757990480 \ No newline at end of file