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.
This commit is contained in:
2026-05-26 16:17:24 +08:00
parent 5a0f1548ea
commit 5ad6ed8ae6
5 changed files with 21876 additions and 20902 deletions

View File

@@ -12,14 +12,14 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: f7dd720bca19fcc49b22106fb65f7652, type: 3} m_Script: {fileID: 11500000, guid: f7dd720bca19fcc49b22106fb65f7652, type: 3}
m_Name: ENM_E001_AnimConfig m_Name: ENM_E001_AnimConfig
m_EditorClassIdentifier: m_EditorClassIdentifier:
Idle: {fileID: 0} Idle: {fileID: 7400000, guid: 74d1c2f7f8e5c66409e9090885e7e007, type: 2}
Walk: {fileID: 0} Walk: {fileID: 7400000, guid: b6b9e34e957b9fa4b92e95aaa155099f, type: 2}
Run: {fileID: 0} Run: {fileID: 7400000, guid: b6b9e34e957b9fa4b92e95aaa155099f, type: 2}
Turn: {fileID: 0} Turn: {fileID: 7400000, guid: c6d78c8270549254f8c777e0c5d4f9bf, type: 2}
Attack: {fileID: 0} Attack: {fileID: 0}
Hurt: {fileID: 0} Hurt: {fileID: 7400000, guid: 9d5bb5bb32cdb344b80f01d998ed653f, type: 2}
Stagger: {fileID: 0} Stagger: {fileID: 0}
KnockUp: {fileID: 0} KnockUp: {fileID: 0}
Dead: {fileID: 0} Dead: {fileID: 7400000, guid: 7f07e13e67a5aba4b8a27234e5a84ee6, type: 2}
Alert: {fileID: 0} Alert: {fileID: 0}
Investigate: {fileID: 0} Investigate: {fileID: 0}

File diff suppressed because it is too large Load Diff

View File

@@ -420,43 +420,195 @@ namespace BaseGames.Editor
/// 构建触发多边形路径(本地坐标,相对于新 CameraArea /// 构建触发多边形路径(本地坐标,相对于新 CameraArea
/// 取 TriggerRegion 各点的世界坐标减去 areaWorldPos /// 取 TriggerRegion 各点的世界坐标减去 areaWorldPos
/// 若点数不足 3 则兜底使用可视矩形。 /// 若点数不足 3 则兜底使用可视矩形。
///
/// 处理流程:
/// 1. 优先尝试直角多边形重建(坐标分组配对算法)→ 精确还原横竖边构成的触发区域
/// 2. 降级:最近邻贪心 + 2-opt 消除自相交 → 适用于含斜边的多边形
/// 3. Shoelace 叉积校正绕向 → 确保 CCWPolygonCollider2D 要求)
/// </summary> /// </summary>
private static Vector2[] BuildTriggerPath(ZoneEntry entry, Vector3 areaWorldPos, Rect fallback) private static Vector2[] BuildTriggerPath(ZoneEntry entry, Vector3 areaWorldPos, Rect fallback)
{ {
if (entry.TriggerWorldPts.Count >= 3) if (entry.TriggerWorldPts.Count >= 3)
{ {
// 将世界坐标转换为 areaGO 本地坐标
var localPts = new Vector2[entry.TriggerWorldPts.Count]; var localPts = new Vector2[entry.TriggerWorldPts.Count];
for (int i = 0; i < localPts.Length; i++) for (int i = 0; i < localPts.Length; i++)
localPts[i] = entry.TriggerWorldPts[i] - (Vector2)areaWorldPos; localPts[i] = entry.TriggerWorldPts[i] - (Vector2)areaWorldPos;
// 按照质心角度排序,确保顶点顺序能够围成合法多边形 // 优先:直角多边形精确重建
return SortPointsByAngle(localPts); 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); return RectToPolygon(fallback);
} }
/// <summary> /// <summary>
/// 将一组点按照围绕质心的角度(逆时针)排序,使其能够围成合法的简单多边形 /// 直角多边形Rectilinear Polygon顶点排序
/// 适用于凸多边形及质心在多边形内部的凹多边形。 ///
/// 算法原理:
/// 简单直角多边形中,每列(同 x的顶点按 y 升序两两配对为纵向边,
/// 每行(同 y的顶点按 x 升序两两配对为横向边Scanline 奇偶规则)。
/// 每个顶点恰好有 1 条纵向边 + 1 条横向边,即恰好 2 个邻居,可遍历为完整环路。
///
/// 返回 null 表示点集不满足直角多边形条件(奇数分组、邻居数 ≠ 2 等),调用方应降级处理。
/// </summary> /// </summary>
private static Vector2[] SortPointsByAngle(Vector2[] points) private static Vector2[] TryReconstructRectilinear(Vector2[] points)
{ {
// 计算质心 // 同轴容差:小于此值的坐标差视为同行/同列(处理浮点误差)
Vector2 centroid = Vector2.zero; const float kTol = 0.1f;
foreach (var p in points) int n = points.Length;
centroid += p;
centroid /= points.Length;
// 按照相对质心的极角升序排列(逆时针) var adj = new List<int>[n];
var sorted = new System.Collections.Generic.List<Vector2>(points); for (int i = 0; i < n; i++) adj[i] = new List<int>();
sorted.Sort((a, b) =>
// 按 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++)
{ {
float angleA = Mathf.Atan2(a.y - centroid.y, a.x - centroid.x); int next = -1;
float angleB = Mathf.Atan2(b.y - centroid.y, b.x - centroid.x); foreach (int nb in adj[curr])
return angleA.CompareTo(angleB); if (nb != prev && !visited[nb]) { next = nb; break; }
}); if (next == -1) return null; // 环路断裂
return sorted.ToArray(); 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> /// <summary>

View File

@@ -389,7 +389,7 @@ namespace BaseGames.Editor
var report = new List<string>(); var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup(); int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place E001 草蛭"); Undo.SetCurrentGroupName("Place E001 草蛭");
EnemyBase.SuppressValidationWarnings = true;
GameObject go = new GameObject("ENM_CaoZhi"); GameObject go = new GameObject("ENM_CaoZhi");
Undo.RegisterCreatedObjectUndo(go, "Place E001"); Undo.RegisterCreatedObjectUndo(go, "Place E001");
go.transform.position = GetDropPosition(); go.transform.position = GetDropPosition();
@@ -470,6 +470,7 @@ namespace BaseGames.Editor
Undo.CollapseUndoOperations(undoGroup); Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go; Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("E001 草蛭", go, report); MarkDirtyAndLog("E001 草蛭", go, report);
} }
@@ -479,7 +480,7 @@ namespace BaseGames.Editor
var report = new List<string>(); var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup(); int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place E002 簧蛭"); Undo.SetCurrentGroupName("Place E002 簧蛭");
EnemyBase.SuppressValidationWarnings = true;
GameObject go = new GameObject("ENM_HuangZhi"); GameObject go = new GameObject("ENM_HuangZhi");
Undo.RegisterCreatedObjectUndo(go, "Place E002"); Undo.RegisterCreatedObjectUndo(go, "Place E002");
go.transform.position = GetDropPosition(); go.transform.position = GetDropPosition();
@@ -548,6 +549,7 @@ namespace BaseGames.Editor
Undo.CollapseUndoOperations(undoGroup); Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go; Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("E002 簧蛭", go, report); MarkDirtyAndLog("E002 簧蛭", go, report);
} }
@@ -557,7 +559,7 @@ namespace BaseGames.Editor
var report = new List<string>(); var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup(); int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place E003 幼蛭"); Undo.SetCurrentGroupName("Place E003 幼蛭");
EnemyBase.SuppressValidationWarnings = true;
GameObject go = new GameObject("ENM_YouZhi"); GameObject go = new GameObject("ENM_YouZhi");
Undo.RegisterCreatedObjectUndo(go, "Place E003"); Undo.RegisterCreatedObjectUndo(go, "Place E003");
go.transform.position = GetDropPosition(); go.transform.position = GetDropPosition();
@@ -631,6 +633,7 @@ namespace BaseGames.Editor
Undo.CollapseUndoOperations(undoGroup); Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go; Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("E003 幼蛭", go, report); MarkDirtyAndLog("E003 幼蛭", go, report);
} }
@@ -640,7 +643,7 @@ namespace BaseGames.Editor
var report = new List<string>(); var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup(); int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place E004 蛭母"); Undo.SetCurrentGroupName("Place E004 蛭母");
EnemyBase.SuppressValidationWarnings = true;
GameObject go = new GameObject("ENM_ZhiMu"); GameObject go = new GameObject("ENM_ZhiMu");
Undo.RegisterCreatedObjectUndo(go, "Place E004"); Undo.RegisterCreatedObjectUndo(go, "Place E004");
go.transform.position = GetDropPosition(); go.transform.position = GetDropPosition();
@@ -757,6 +760,7 @@ namespace BaseGames.Editor
Undo.CollapseUndoOperations(undoGroup); Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go; Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("E004 蛭母", go, report); MarkDirtyAndLog("E004 蛭母", go, report);
} }
@@ -766,7 +770,7 @@ namespace BaseGames.Editor
var report = new List<string>(); var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup(); int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place E005 肥蛭"); Undo.SetCurrentGroupName("Place E005 肥蛭");
EnemyBase.SuppressValidationWarnings = true;
GameObject go = new GameObject("ENM_FeiZhi"); GameObject go = new GameObject("ENM_FeiZhi");
Undo.RegisterCreatedObjectUndo(go, "Place E005"); Undo.RegisterCreatedObjectUndo(go, "Place E005");
go.transform.position = GetDropPosition(); go.transform.position = GetDropPosition();
@@ -849,6 +853,7 @@ namespace BaseGames.Editor
Undo.CollapseUndoOperations(undoGroup); Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go; Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("E005 肥蛭", go, report); MarkDirtyAndLog("E005 肥蛭", go, report);
} }
@@ -858,7 +863,7 @@ namespace BaseGames.Editor
var report = new List<string>(); var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup(); int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place E006 讙"); Undo.SetCurrentGroupName("Place E006 讙");
EnemyBase.SuppressValidationWarnings = true;
GameObject go = new GameObject("ENM_Huan"); GameObject go = new GameObject("ENM_Huan");
Undo.RegisterCreatedObjectUndo(go, "Place E006"); Undo.RegisterCreatedObjectUndo(go, "Place E006");
go.transform.position = GetDropPosition(); go.transform.position = GetDropPosition();
@@ -951,6 +956,7 @@ namespace BaseGames.Editor
Undo.CollapseUndoOperations(undoGroup); Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go; Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("E006 讙", go, report); MarkDirtyAndLog("E006 讙", go, report);
} }
@@ -960,7 +966,7 @@ namespace BaseGames.Editor
var report = new List<string>(); var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup(); int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place Boss 嘲风"); Undo.SetCurrentGroupName("Place Boss 嘲风");
EnemyBase.SuppressValidationWarnings = true;
GameObject go = new GameObject("ENM_ChaoFeng"); GameObject go = new GameObject("ENM_ChaoFeng");
Undo.RegisterCreatedObjectUndo(go, "Place ChaoFeng"); Undo.RegisterCreatedObjectUndo(go, "Place ChaoFeng");
go.transform.position = GetDropPosition(); go.transform.position = GetDropPosition();
@@ -1067,6 +1073,7 @@ namespace BaseGames.Editor
Undo.CollapseUndoOperations(undoGroup); Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go; Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("Boss 嘲风 (ChaoFeng)", go, report); MarkDirtyAndLog("Boss 嘲风 (ChaoFeng)", go, report);
} }

View File

@@ -710,8 +710,12 @@ namespace BaseGames.Enemies
} }
#if UNITY_EDITOR #if UNITY_EDITOR
/// <summary>Set to true during batch editor placement to suppress mid-wiring OnValidate warnings.</summary>
public static bool SuppressValidationWarnings { get; set; }
protected virtual void OnValidate() protected virtual void OnValidate()
{ {
if (SuppressValidationWarnings) return;
if (_statsSO == null) if (_statsSO == null)
Debug.LogWarning($"[EnemyBase] {gameObject.name} 缺少 EnemyStatsSO 配置(运行时会 NullRef。", this); Debug.LogWarning($"[EnemyBase] {gameObject.name} 缺少 EnemyStatsSO 配置(运行时会 NullRef。", this);
if (_stats == null) if (_stats == null)