using System.Collections.Generic; using BaseGames.Camera; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; namespace BaseGames.Editor { /// /// 将旧格式相机区域批量迁移到新架构。 /// /// 旧格式: /// Zone_xxx(挂 BoxCollider2D,定义相机可视矩形) /// ├─ Zone_xxx_TriggerRegion /// │ ├─ Zone_xxx_TriggerRegion_Point_0 … (多边形顶点) /// └─ Zone_xxx_Confiner(挂 BoxCollider2D / PolygonCollider2D,定义限位边界) /// /// 新格式: /// [新 CameraArea GO](CameraArea 组件,_visibleBounds = 本地 Rect) /// ├─ AreaBoundary(BoxCollider,对应旧 Confiner) /// ├─ TriggerZone(CameraTriggerZone + PolygonCollider2D,对应旧 TriggerRegion) /// └─ VCam_xxx(CinemachineCamera + 所有扩展组件,专属虚拟相机) /// /// 菜单:BaseGames → Camera → 相机区域迁移工具 /// public class CameraZoneMigrationTool : EditorWindow { [MenuItem("BaseGames/Camera/相机区域迁移工具", priority = 110)] public static void Open() { var win = GetWindow("相机区域迁移工具"); win.minSize = new Vector2(500f, 440f); } // ── 设置字段 ────────────────────────────────────────────────────────── private Transform _sourcesParent; // 旧 Zone_xxx 的父节点(通常名为 Zones) private Transform _targetParent; // 新对象放置位置(留空 = 与旧区域同级) private CameraLensConfigSO _lensConfig; // 绑定到新 CameraArea._lensConfig private bool _createDedicatedVCam = true; // 为每个区域创建专属 CinemachineCamera // ── 运行时状态 ──────────────────────────────────────────────────────── private readonly List _entries = new List(); private Vector2 _scroll; private bool _scanned; private int _lastMigratedCount; // ── 单条目数据 ──────────────────────────────────────────────────────── private class ZoneEntry { public GameObject ZoneObj; public BoxCollider2D VisibleBox; // Zone_xxx 上的 BoxCollider2D public List TriggerWorldPts = new List(); // TriggerRegion 各点世界坐标 public Collider2D ConfinerCollider; // Zone_xxx_Confiner 上的碰撞体(可空) public bool AlreadyMigrated; public bool Selected = true; } // ── 颜色 ───────────────────────────────────────────────────────────── private static readonly Color kOk = new Color(0.30f, 0.85f, 0.30f); private static readonly Color kWarning = new Color(1.00f, 0.75f, 0.10f); private static readonly Color kMuted = new Color(0.55f, 0.55f, 0.60f); // ══ GUI ═══════════════════════════════════════════════════════════════ private void OnGUI() { EditorGUILayout.Space(6); EditorGUILayout.LabelField("旧格式相机区域迁移工具", EditorStyles.boldLabel); EditorGUILayout.HelpBox( "将旧架构(Zone_xxx + BoxCollider2D + TriggerRegion 点集 + Confiner)" + "批量转换为新架构(CameraArea + AreaBoundary + CameraTriggerZone)。\n" + "迁移结果可在 Scene 视图直接预览,原旧对象可选择禁用。", MessageType.Info); EditorGUILayout.Space(6); // ── 配置 ────────────────────────────────────────────────────────── _sourcesParent = (Transform)EditorGUILayout.ObjectField( new GUIContent("旧区域父节点 (Zones)", "包含所有 Zone_xxx 的父对象"), _sourcesParent, typeof(Transform), true); _targetParent = (Transform)EditorGUILayout.ObjectField( new GUIContent("新区域父节点(留空 = 同级)", "生成的 CameraArea 放在此节点下;留空则与旧 Zone_xxx 同级"), _targetParent, typeof(Transform), true); _lensConfig = (CameraLensConfigSO)EditorGUILayout.ObjectField( new GUIContent("镜头配置 SO", "赋给所有新 CameraArea._lensConfig;留空则不赋值"), _lensConfig, typeof(CameraLensConfigSO), false); _createDedicatedVCam = EditorGUILayout.Toggle( new GUIContent("创建专属 VCam", "为每个迁移区域创建子节点 CinemachineCamera(含所有扩展组件),\n" + "并绑定到 CameraArea._dedicatedCamera。"), _createDedicatedVCam); EditorGUILayout.Space(8); using (new EditorGUI.DisabledScope(_sourcesParent == null)) { if (GUILayout.Button("扫描区域", GUILayout.Height(30))) ScanZones(); } if (!_scanned) return; EditorGUILayout.Space(4); if (_entries.Count == 0) { EditorGUILayout.HelpBox( "未检测到旧格式区域。\n条件:BoxCollider2D + 含 \"TriggerRegion\" 字样的子节点。", MessageType.Warning); return; } // ── 统计行 ───────────────────────────────────────────────────────── int migrated = 0, pending = 0; foreach (var e in _entries) { if (e.AlreadyMigrated) migrated++; else if (e.Selected) pending++; } EditorGUILayout.LabelField( $"共 {_entries.Count} 个区域 | 已迁移 {migrated} | 已选待迁移 {pending}", EditorStyles.miniBoldLabel); EditorGUILayout.Space(2); // ── 条目列表 ─────────────────────────────────────────────────────── _scroll = EditorGUILayout.BeginScrollView(_scroll, GUILayout.MaxHeight(240)); foreach (var entry in _entries) DrawEntryRow(entry); EditorGUILayout.EndScrollView(); EditorGUILayout.Space(8); // ── 批量迁移 ─────────────────────────────────────────────────────── int selectedPending = _entries.FindAll(e => e.Selected && !e.AlreadyMigrated).Count; using (new EditorGUI.DisabledScope(selectedPending == 0)) { if (GUILayout.Button($"迁移已选中区域({selectedPending} 个)", GUILayout.Height(36))) { int count = 0; foreach (var e in _entries) if (e.Selected && !e.AlreadyMigrated) { MigrateZone(e); count++; } _lastMigratedCount = count; ScanZones(); Debug.Log($"[迁移工具] 完成迁移 {count} 个相机区域。"); } } if (_lastMigratedCount > 0) EditorGUILayout.HelpBox( $"上次迁移完成 {_lastMigratedCount} 个。请在 Scene 视图确认后保存场景(Ctrl+S)。", MessageType.Info); } private void DrawEntryRow(ZoneEntry entry) { using (new EditorGUILayout.HorizontalScope(EditorStyles.helpBox)) { // 选择框(已迁移的不可再选) using (new EditorGUI.DisabledScope(entry.AlreadyMigrated)) entry.Selected = EditorGUILayout.Toggle(entry.Selected, GUILayout.Width(16)); // 名称 Color prev = GUI.color; GUI.color = entry.AlreadyMigrated ? kMuted : kOk; EditorGUILayout.LabelField(entry.ZoneObj.name, GUILayout.Width(170)); GUI.color = prev; // 可视框尺寸 if (entry.VisibleBox != null) { Vector2 sz = entry.VisibleBox.size; EditorGUILayout.LabelField($"Box {sz.x:F0}×{sz.y:F0}", EditorStyles.miniLabel, GUILayout.Width(72)); } else { GUI.color = kWarning; EditorGUILayout.LabelField("无 Box2D", EditorStyles.miniLabel, GUILayout.Width(72)); GUI.color = prev; } // 触发点数 Color ptColor = entry.TriggerWorldPts.Count >= 3 ? kOk : kWarning; GUI.color = ptColor; EditorGUILayout.LabelField($"触发 {entry.TriggerWorldPts.Count}pt", EditorStyles.miniLabel, GUILayout.Width(54)); GUI.color = prev; // 限位来源 string confLabel = entry.ConfinerCollider is PolygonCollider2D ? "Poly限位" : entry.ConfinerCollider is BoxCollider2D ? "Box限位" : "默认矩形"; EditorGUILayout.LabelField(confLabel, EditorStyles.miniLabel, GUILayout.Width(54)); // 状态 / 单独迁移按钮 if (entry.AlreadyMigrated) { GUI.color = kMuted; EditorGUILayout.LabelField("已迁移", EditorStyles.miniLabel, GUILayout.Width(46)); GUI.color = prev; } else { if (GUILayout.Button("迁移", GUILayout.Width(44), GUILayout.Height(16))) { MigrateZone(entry); _lastMigratedCount = 1; ScanZones(); } } // Ping 旧对象 if (GUILayout.Button("●", GUILayout.Width(20), GUILayout.Height(16))) EditorGUIUtility.PingObject(entry.ZoneObj); } } // ══ 扫描 ══════════════════════════════════════════════════════════════ private void ScanZones() { _entries.Clear(); _scanned = true; if (_sourcesParent == null) return; foreach (Transform child in _sourcesParent) { Debug.Log($"[root]:{child.name}"); // 旧 Zone 的标识:子节点直属,且挂有 BoxCollider2D var box = child.GetComponent(); if (box == null) continue; var entry = new ZoneEntry { ZoneObj = child.gameObject, VisibleBox = box }; // 收集触发多边形顶点——xxx_TriggerRegion 下每个子节点的世界坐标即一个顶点, // 按子节点顺序依次连线围成多边形触发区域。 Transform triggerRoot = FindChildContaining(child, "Trigger"); if (triggerRoot != null){ Debug.Log($"[trigger]:{triggerRoot.name}"); foreach (Transform pt in triggerRoot){ Debug.Log($"{pt.name}"); entry.TriggerWorldPts.Add((Vector2)pt.position); } } // 读取限位碰撞体(Zone_xxx_Confiner 上的 Collider2D) Transform confinerT = FindChildContaining(child, "Confiner"); if (confinerT != null) entry.ConfinerCollider = confinerT.GetComponent(); // 是否已经完成迁移(自身或子节点含 CameraArea) entry.AlreadyMigrated = child.GetComponent() != null || child.GetComponentInChildren(true) != null; _entries.Add(entry); } Repaint(); } private static Transform FindChildContaining(Transform parent, string keyword) { foreach (Transform child in parent) if (child.name.IndexOf(keyword, System.StringComparison.OrdinalIgnoreCase) >= 0) return child; return null; } // ══ 迁移 ══════════════════════════════════════════════════════════════ private void MigrateZone(ZoneEntry entry) { GameObject zoneGO = entry.ZoneObj; Transform parent = _targetParent != null ? _targetParent : zoneGO.transform.parent; Vector3 worldPos = zoneGO.transform.position; // ── 1. 计算本地可视 Rect(相对于新 CameraArea 的世界位置)──────── Rect localBounds; if (entry.VisibleBox != null) { // 注意:BoxCollider2D.bounds 在 inactive 对象上无效,必须手动计算 Bounds wb = GetColliderWorldBounds(entry.VisibleBox); localBounds = new Rect( wb.min.x - worldPos.x, wb.min.y - worldPos.y, wb.size.x, wb.size.y); } else { localBounds = new Rect(-12f, -6f, 24f, 12f); } // ── 2. 创建 CameraArea 根节点 ───────────────────────────────────── GameObject areaGO = new GameObject(zoneGO.name); Undo.RegisterCreatedObjectUndo(areaGO, "Migrate Camera Zone"); areaGO.transform.SetParent(parent, worldPositionStays: false); areaGO.transform.position = worldPos; // 紧跟旧对象之后(同级排列时保持顺序直观) if (parent == zoneGO.transform.parent) areaGO.transform.SetSiblingIndex(zoneGO.transform.GetSiblingIndex() + 1); CameraArea area = areaGO.AddComponent(); var soArea = new SerializedObject(area); soArea.FindProperty("_visibleBounds").rectValue = localBounds; if (_lensConfig != null) soArea.FindProperty("_lensConfig").objectReferenceValue = _lensConfig; soArea.ApplyModifiedProperties(); // ── 3. 创建 AreaBoundary(限位体积 BoxCollider)───────────────────────────── GameObject boundaryGO = new GameObject($"{zoneGO.name}_AreaBoundary"); Undo.RegisterCreatedObjectUndo(boundaryGO, "Migrate Camera Zone"); boundaryGO.transform.SetParent(areaGO.transform, worldPositionStays: false); boundaryGO.transform.localPosition = Vector3.zero; BoxCollider confinerBox = boundaryGO.AddComponent(); confinerBox.isTrigger = true; confinerBox.center = new Vector3(0f, 0f, -10f); // Z 占位符,SyncConfiner 会立即重算 confinerBox.size = new Vector3(localBounds.width, localBounds.height, 1f); // 绑定 _confinerCollider soArea.Update(); soArea.FindProperty("_confinerCollider").objectReferenceValue = confinerBox; soArea.ApplyModifiedProperties(); EditorUtility.SetDirty(area); // 绑定完成后立即按 FOV/Depth 公式同步限位多边形, // 确保与 LensConfig 参数一致(创建时不会自动同步) float syncFOV = _lensConfig != null ? _lensConfig.fieldOfView : 60f; float syncAspect = UnityEngine.Camera.main != null ? UnityEngine.Camera.main.aspect : 16f / 9f; CameraAreaEditor.SyncConfinerFromVisibleBounds(area, syncFOV, syncAspect); // ── 4. 创建 TriggerZone(相机激活触发器)─────────────────────── GameObject triggerGO = new GameObject($"{zoneGO.name}_TriggerZone"); Undo.RegisterCreatedObjectUndo(triggerGO, "Migrate Camera Zone"); triggerGO.transform.SetParent(areaGO.transform, worldPositionStays: false); triggerGO.transform.localPosition = Vector3.zero; // AddComponent 会因 [RequireComponent] 自动添加 PolygonCollider2D CameraTriggerZone triggerComp = triggerGO.AddComponent(); PolygonCollider2D triggerPoly = triggerGO.GetComponent(); // 将旧触发多边形路径(本地坐标,相对于新 CameraArea 世界位置)直接赋给 PolygonCollider2D Vector2[] triggerPath = BuildTriggerPath(entry, worldPos, localBounds); triggerPoly.isTrigger = true; triggerPoly.SetPath(0, triggerPath); // _targetArea → 指向刚创建的 CameraArea var soTrigger = new SerializedObject(triggerComp); soTrigger.FindProperty("_targetArea").objectReferenceValue = area; soTrigger.ApplyModifiedProperties(); EditorUtility.SetDirty(triggerComp); // ── 5. 创建专属 VCam(每区域独立相机)───────────────────────────── if (_createDedicatedVCam) CameraAreaEditor.CreateDedicatedVCamForArea(area); // ── 6. 处理旧对象 ────────────────────────────────────────────── // 先记录原始激活状态,再对旧对象做处理,避免 SetActive(false) 后误读 bool wasActive = zoneGO.activeSelf; // 同步旧区域的激活状态(旧 Zone_xxx 若为禁用,新对象同样禁用) if (!wasActive) areaGO.SetActive(false); EditorUtility.SetDirty(areaGO); EditorSceneManager.MarkSceneDirty(zoneGO.scene); EditorGUIUtility.PingObject(areaGO); Debug.Log($"[迁移工具] {zoneGO.name} → {areaGO.name} " + $"可视 {localBounds.width:F0}×{localBounds.height:F0} " + $"触发 PolygonCollider2D ({triggerPath.Length} 点) " + $"限位 BoxCollider ({confinerBox.size.x:F1}×{confinerBox.size.y:F1})"); } // ── 限位多边形路径 ──────────────────────────────────────────────────── /// /// 按优先级构建限位多边形路径(本地坐标,相对于新 CameraArea): /// 1. Zone_xxx_Confiner 上的 PolygonCollider2D → 直接转换 /// 2. Zone_xxx_Confiner 上的 BoxCollider2D → 取 AABB 四角 /// 3. 兜底 → 使用可视矩形 /// private static Vector2[] BuildConfinerPath(ZoneEntry entry, Vector3 areaWorldPos, Rect fallback) { if (entry.ConfinerCollider is PolygonCollider2D poly && poly.pathCount > 0) { var pts = new List(); poly.GetPath(0, pts); var result = new Vector2[pts.Count]; for (int i = 0; i < pts.Count; i++) result[i] = (Vector2)poly.transform.TransformPoint(pts[i]) - (Vector2)areaWorldPos; return result; } if (entry.ConfinerCollider is BoxCollider2D box) { Bounds b = GetColliderWorldBounds(box); return new Vector2[] { new Vector2(b.min.x - areaWorldPos.x, b.min.y - areaWorldPos.y), new Vector2(b.min.x - areaWorldPos.x, b.max.y - areaWorldPos.y), new Vector2(b.max.x - areaWorldPos.x, b.max.y - areaWorldPos.y), new Vector2(b.max.x - areaWorldPos.x, b.min.y - areaWorldPos.y), }; } // 兜底:可视矩形 return RectToPolygon(fallback); } /// /// 构建触发多边形路径(本地坐标,相对于新 CameraArea): /// 取 TriggerRegion 各点的世界坐标减去 areaWorldPos; /// 若点数不足 3 则兜底使用可视矩形。 /// /// 处理流程: /// 1. 优先尝试直角多边形重建(坐标分组配对算法)→ 精确还原横竖边构成的触发区域 /// 2. 降级:最近邻贪心 + 2-opt 消除自相交 → 适用于含斜边的多边形 /// 3. Shoelace 叉积校正绕向 → 确保 CCW(PolygonCollider2D 要求) /// private static Vector2[] BuildTriggerPath(ZoneEntry entry, Vector3 areaWorldPos, Rect fallback) { if (entry.TriggerWorldPts.Count >= 3) { var localPts = new Vector2[entry.TriggerWorldPts.Count]; for (int i = 0; i < localPts.Length; i++) localPts[i] = entry.TriggerWorldPts[i] - (Vector2)areaWorldPos; // 优先:直角多边形精确重建 var rectilinear = TryReconstructRectilinear(localPts); if (rectilinear != null) return EnsureCounterClockwise(rectilinear); // 降级:最近邻 + 2-opt(适用于含斜边或点分布不规则的情况) Debug.LogWarning($"[迁移工具] {entry.ZoneObj.name} 无法识别为直角多边形,已使用 2-opt 降级处理,请手动检查形状。"); var ordered = NearestNeighborOrder(localPts); TwoOptFix(ordered); return EnsureCounterClockwise(ordered); } return RectToPolygon(fallback); } /// /// 直角多边形(Rectilinear Polygon)顶点排序。 /// /// 算法原理: /// 简单直角多边形中,每列(同 x)的顶点按 y 升序两两配对为纵向边, /// 每行(同 y)的顶点按 x 升序两两配对为横向边(Scanline 奇偶规则)。 /// 每个顶点恰好有 1 条纵向边 + 1 条横向边,即恰好 2 个邻居,可遍历为完整环路。 /// /// 返回 null 表示点集不满足直角多边形条件(奇数分组、邻居数 ≠ 2 等),调用方应降级处理。 /// private static Vector2[] TryReconstructRectilinear(Vector2[] points) { // 同轴容差:小于此值的坐标差视为同行/同列(处理浮点误差) const float kTol = 0.1f; int n = points.Length; var adj = new List[n]; for (int i = 0; i < n; i++) adj[i] = new List(); // 按 x 分组 → 纵向边;按 y 分组 → 横向边 if (!AddAxisEdges(points, adj, groupByX: true, tol: kTol)) return null; if (!AddAxisEdges(points, adj, groupByX: false, tol: kTol)) return null; // 每个顶点必须恰好有 2 个邻居(1 横 + 1 纵)才构成合法简单多边形 for (int i = 0; i < n; i++) if (adj[i].Count != 2) return null; // 从顶点 0 出发遍历环路 var result = new Vector2[n]; var visited = new bool[n]; result[0] = points[0]; visited[0] = true; int prev = -1, curr = 0; for (int step = 1; step < n; step++) { int next = -1; foreach (int nb in adj[curr]) if (nb != prev && !visited[nb]) { next = nb; break; } if (next == -1) return null; // 环路断裂 result[step] = points[next]; visited[next] = true; prev = curr; curr = next; } return result; } /// /// 按指定轴(x 或 y)将顶点分组,每组内按垂直轴坐标排序后两两配对添加边。 /// 若任意组的顶点数为奇数(无法完整配对)则返回 false。 /// private static bool AddAxisEdges(Vector2[] pts, List[] adj, bool groupByX, float tol) { var groups = new Dictionary>(); for (int i = 0; i < pts.Length; i++) { // 将坐标映射到整数 key,容差范围内的值归入同组 int key = Mathf.RoundToInt((groupByX ? pts[i].x : pts[i].y) / tol); if (!groups.TryGetValue(key, out var list)) groups[key] = list = new List(); list.Add(i); } foreach (var group in groups.Values) { if (group.Count % 2 != 0) return false; // 奇数个顶点无法配对 // 按垂直轴升序排列后两两配对为边 group.Sort((a, b) => (groupByX ? pts[a].y : pts[a].x).CompareTo(groupByX ? pts[b].y : pts[b].x)); for (int k = 0; k + 1 < group.Count; k += 2) { adj[group[k]].Add(group[k + 1]); adj[group[k + 1]].Add(group[k]); } } return true; } /// /// 最近邻贪心排序(降级路径):从最左侧点出发,每步选取欧氏距离最近的未访问点。 /// private static Vector2[] NearestNeighborOrder(Vector2[] points) { int n = points.Length; var visited = new bool[n]; var result = new Vector2[n]; int startIdx = 0; for (int i = 1; i < n; i++) if (points[i].x < points[startIdx].x) startIdx = i; result[0] = points[startIdx]; visited[startIdx] = true; for (int step = 1; step < n; step++) { float minSqDist = float.MaxValue; int nearest = -1; for (int i = 0; i < n; i++) { if (visited[i]) continue; float sq = (points[i] - result[step - 1]).sqrMagnitude; if (sq < minSqDist) { minSqDist = sq; nearest = i; } } result[step] = points[nearest]; visited[nearest] = true; } return result; } /// /// 2-opt 优化(降级路径):检测并翻转所有自相交边对,直到无交叉为止。 /// private static void TwoOptFix(Vector2[] pts) { int n = pts.Length; bool improved = true; while (improved) { improved = false; for (int i = 0; i < n - 1; i++) for (int j = i + 2; j < n; j++) { if (i == 0 && j == n - 1) continue; if (SegmentsIntersect(pts[i], pts[(i + 1) % n], pts[j], pts[(j + 1) % n])) { System.Array.Reverse(pts, i + 1, j - i); improved = true; } } } } /// 用叉积判断线段 AB 与 CD 是否严格相交(不含端点接触)。 private static bool SegmentsIntersect(Vector2 a, Vector2 b, Vector2 c, Vector2 d) { float d1 = Cross2D(d - c, a - c), d2 = Cross2D(d - c, b - c); float d3 = Cross2D(b - a, c - a), d4 = Cross2D(b - a, d - a); return ((d1 > 0f && d2 < 0f) || (d1 < 0f && d2 > 0f)) && ((d3 > 0f && d4 < 0f) || (d3 < 0f && d4 > 0f)); } private static float Cross2D(Vector2 u, Vector2 v) => u.x * v.y - u.y * v.x; /// /// 使用 Shoelace 公式(叉积累加)验证绕向: /// 2A = Σ (xᵢ · yᵢ₊₁ − xᵢ₊₁ · yᵢ) /// Unity Y 轴朝上:A > 0 = CCW,A < 0 = CW。 /// 若为顺时针则翻转(不改变形状,只调整绕向)。 /// private static Vector2[] EnsureCounterClockwise(Vector2[] points) { float signedArea = 0f; int n = points.Length; for (int i = 0; i < n; i++) { Vector2 curr = points[i]; Vector2 next = points[(i + 1) % n]; signedArea += curr.x * next.y - next.x * curr.y; } if (signedArea < 0f) System.Array.Reverse(points); return points; } /// /// 手动计算 BoxCollider2D 的世界 AABB,不依赖 .bounds(inactive 对象上 .bounds 无效)。 /// private static Bounds GetColliderWorldBounds(BoxCollider2D box) { Vector2 worldCenter = (Vector2)box.transform.TransformPoint(box.offset); Vector2 worldSize = new Vector2( box.size.x * Mathf.Abs(box.transform.lossyScale.x), box.size.y * Mathf.Abs(box.transform.lossyScale.y)); return new Bounds(worldCenter, worldSize); } private static Vector2[] RectToPolygon(Rect r) { return new Vector2[] { new Vector2(r.xMin, r.yMin), new Vector2(r.xMin, r.yMax), new Vector2(r.xMax, r.yMax), new Vector2(r.xMax, r.yMin), }; } } }