Files
zeling_v2/Assets/_Game/Scripts/Editor/Camera/CameraZoneMigrationTool.cs
Joywayer 5ad6ed8ae6 feat: Enhance CameraZoneMigrationTool with rectilinear polygon reconstruction and fallback handling
- Implemented a new method for reconstructing rectilinear polygons from trigger points.
- Added a fallback mechanism using nearest neighbor ordering and 2-opt optimization for irregular shapes.
- Updated point sorting to ensure counter-clockwise orientation for polygon colliders.

fix: Suppress validation warnings during batch enemy placement in SceneObjectPlacerTool

- Introduced a static property in EnemyBase to suppress validation warnings during editor object placement.
- Updated SceneObjectPlacerTool to utilize this property when creating enemy game objects.

refactor: Clean up OnValidate method in EnemyBase to respect suppression flag

- Modified OnValidate to check for the suppression flag before logging warnings about missing configurations.
2026-05-26 16:17:24 +08:00

638 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using BaseGames.Camera;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
namespace BaseGames.Editor
{
/// <summary>
/// 将旧格式相机区域批量迁移到新架构。
///
/// 旧格式:
/// Zone_xxx挂 BoxCollider2D定义相机可视矩形
/// ├─ Zone_xxx_TriggerRegion
/// │ ├─ Zone_xxx_TriggerRegion_Point_0 … (多边形顶点)
/// └─ Zone_xxx_Confiner挂 BoxCollider2D / PolygonCollider2D定义限位边界
///
/// 新格式:
/// [新 CameraArea GO]CameraArea 组件_visibleBounds = 本地 Rect
/// ├─ AreaBoundaryBoxCollider对应旧 Confiner
/// ├─ TriggerZoneCameraTriggerZone + PolygonCollider2D对应旧 TriggerRegion
/// └─ VCam_xxxCinemachineCamera + 所有扩展组件,专属虚拟相机)
///
/// 菜单BaseGames → Camera → 相机区域迁移工具
/// </summary>
public class CameraZoneMigrationTool : EditorWindow
{
[MenuItem("BaseGames/Camera/相机区域迁移工具", priority = 110)]
public static void Open()
{
var win = GetWindow<CameraZoneMigrationTool>("相机区域迁移工具");
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<ZoneEntry> _entries = new List<ZoneEntry>();
private Vector2 _scroll;
private bool _scanned;
private int _lastMigratedCount;
// ── 单条目数据 ────────────────────────────────────────────────────────
private class ZoneEntry
{
public GameObject ZoneObj;
public BoxCollider2D VisibleBox; // Zone_xxx 上的 BoxCollider2D
public List<Vector2> TriggerWorldPts = new List<Vector2>(); // 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<BoxCollider2D>();
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<Collider2D>();
// 是否已经完成迁移(自身或子节点含 CameraArea
entry.AlreadyMigrated =
child.GetComponent<CameraArea>() != null ||
child.GetComponentInChildren<CameraArea>(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<CameraArea>();
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<BoxCollider>();
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<CameraTriggerZone>();
PolygonCollider2D triggerPoly = triggerGO.GetComponent<PolygonCollider2D>();
// 将旧触发多边形路径(本地坐标,相对于新 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})");
}
// ── 限位多边形路径 ────────────────────────────────────────────────────
/// <summary>
/// 按优先级构建限位多边形路径(本地坐标,相对于新 CameraArea
/// 1. Zone_xxx_Confiner 上的 PolygonCollider2D → 直接转换
/// 2. Zone_xxx_Confiner 上的 BoxCollider2D → 取 AABB 四角
/// 3. 兜底 → 使用可视矩形
/// </summary>
private static Vector2[] BuildConfinerPath(ZoneEntry entry, Vector3 areaWorldPos, Rect fallback)
{
if (entry.ConfinerCollider is PolygonCollider2D poly && poly.pathCount > 0)
{
var pts = new List<Vector2>();
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);
}
/// <summary>
/// 构建触发多边形路径(本地坐标,相对于新 CameraArea
/// 取 TriggerRegion 各点的世界坐标减去 areaWorldPos
/// 若点数不足 3 则兜底使用可视矩形。
///
/// 处理流程:
/// 1. 优先尝试直角多边形重建(坐标分组配对算法)→ 精确还原横竖边构成的触发区域
/// 2. 降级:最近邻贪心 + 2-opt 消除自相交 → 适用于含斜边的多边形
/// 3. Shoelace 叉积校正绕向 → 确保 CCWPolygonCollider2D 要求)
/// </summary>
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);
}
/// <summary>
/// 直角多边形Rectilinear Polygon顶点排序。
///
/// 算法原理:
/// 简单直角多边形中,每列(同 x的顶点按 y 升序两两配对为纵向边,
/// 每行(同 y的顶点按 x 升序两两配对为横向边Scanline 奇偶规则)。
/// 每个顶点恰好有 1 条纵向边 + 1 条横向边,即恰好 2 个邻居,可遍历为完整环路。
///
/// 返回 null 表示点集不满足直角多边形条件(奇数分组、邻居数 ≠ 2 等),调用方应降级处理。
/// </summary>
private static Vector2[] TryReconstructRectilinear(Vector2[] points)
{
// 同轴容差:小于此值的坐标差视为同行/同列(处理浮点误差)
const float kTol = 0.1f;
int n = points.Length;
var adj = new List<int>[n];
for (int i = 0; i < n; i++) adj[i] = new List<int>();
// 按 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;
}
/// <summary>
/// 按指定轴x 或 y将顶点分组每组内按垂直轴坐标排序后两两配对添加边。
/// 若任意组的顶点数为奇数(无法完整配对)则返回 false。
/// </summary>
private static bool AddAxisEdges(Vector2[] pts, List<int>[] adj, bool groupByX, float tol)
{
var groups = new Dictionary<int, List<int>>();
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<int>();
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;
}
/// <summary>
/// 最近邻贪心排序(降级路径):从最左侧点出发,每步选取欧氏距离最近的未访问点。
/// </summary>
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;
}
/// <summary>
/// 2-opt 优化(降级路径):检测并翻转所有自相交边对,直到无交叉为止。
/// </summary>
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;
}
}
}
}
/// <summary>用叉积判断线段 AB 与 CD 是否严格相交(不含端点接触)。</summary>
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;
/// <summary>
/// 使用 Shoelace 公式(叉积累加)验证绕向:
/// 2A = Σ (xᵢ · yᵢ₊₁ xᵢ₊₁ · yᵢ)
/// Unity Y 轴朝上A > 0 = CCWA &lt; 0 = CW。
/// 若为顺时针则翻转(不改变形状,只调整绕向)。
/// </summary>
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;
}
/// <summary>
/// 手动计算 BoxCollider2D 的世界 AABB不依赖 .boundsinactive 对象上 .bounds 无效)。
/// </summary>
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),
};
}
}
}