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 } }