// 발주서 WL-813t2 — 언덕 지형 이동 불가: 실측 프로브 (NavMesh 출처·베이크 설정·고지대 면적·스포너 겹침) // // 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기) // unity command --project-path E:\NerdNavis\WL_wt\systems run_script --file AgentScripts/WL813t2_Probe.cs --entry WL813t2_Probe.Measure --timeout 900 // // 재는 것 — 전부 AssetDatabase 읽기 전용(에셋 수정 0): // (a) WL_Nature 가 쓰는 NavMesh 출처 = NavMeshSurface.m_NavMeshData 자산 경로 · 베이크 설정(agent radius/height/slope/climb) // (b) NavMesh 삼각형 면적을 y 대역별로 집계 → 「언덕 위 NavMesh 면적」 // (c) 샘플 격자(2 m) — 표면 바운즈 안 (x,z) 마다 SamplePosition → on/off · y // (d) 고지대 삼각형을 4 m 격자로 묶어 연결 성분(클러스터) → AABB + 면적 (= NavMeshModifierVolume 배치 후보) // (e) 스포너 노드(MobControlMgr) 좌표 · y · 고지대 여부 // (f) 경로 연결성 — 시작점 · 존 앵커 · 아레나 사이 CalculatePath // // 수치는 전부 프로젝트 데이터에서 읽는다(C45). 프로브 상수는 "어디를 보나"(대역 폭 · 격자 간격)뿐이다. public static class WL813t2_Probe { const string kMapPrefab = "Assets/Res_Addr/Map/WL_Nature.prefab"; const string kOutDir = "AgentScripts/staging/WL813t2"; const string kLog = kOutDir + "/MEASURE.txt"; static System.Text.StringBuilder s; static void L(string t) { s.AppendLine(t); UnityEngine.Debug.Log("[WL813t2] " + t); } static string Abs(string rel) { return System.IO.Path.Combine(System.IO.Directory.GetCurrentDirectory(), rel.Replace('/', System.IO.Path.DirectorySeparatorChar)); } static string Flush(string path) { try { System.IO.Directory.CreateDirectory(Abs(kOutDir)); System.IO.File.WriteAllText(Abs(path), s.ToString(), new System.Text.UTF8Encoding(true)); } catch (System.Exception e) { UnityEngine.Debug.LogWarning("[WL813t2] 로그 저장 실패: " + e.Message); } return s.ToString(); } // ───────────────────────────────────────────────────────────────────────── public static string Measure() { s = new System.Text.StringBuilder(); L("===== WL-813t2 실측 (" + System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + ") ====="); var root = UnityEditor.AssetDatabase.LoadAssetAtPath(kMapPrefab); if (root == null) { L("[에러] 맵 프리팹 로드 실패: " + kMapPrefab); return Flush(kLog); } // ── (a) NavMesh 출처 · 베이크 설정 ─────────────────────────────────── L(""); L("── (a) NavMesh 출처 · 베이크 설정 ──"); var surfaces = root.GetComponentsInChildren(true); L(" NavMeshSurface 개수 = " + surfaces.Length); UnityEngine.AI.NavMeshData navData = null; UnityEngine.Bounds volBounds = new UnityEngine.Bounds(UnityEngine.Vector3.zero, UnityEngine.Vector3.one); for (int i = 0; i < surfaces.Length; i++) { var sf = surfaces[i]; string dataPath = sf.navMeshData != null ? UnityEditor.AssetDatabase.GetAssetPath(sf.navMeshData) : "(null)"; L(" [" + i + "] " + sf.name + " · agentTypeID=" + sf.agentTypeID + " · collectObjects=" + sf.collectObjects + " · size=" + sf.size.ToString("F0") + " · center=" + sf.center.ToString("F0") + " · layerMask=" + sf.layerMask.value + " · useGeometry=" + sf.useGeometry + " · defaultArea=" + sf.defaultArea + " · minRegionArea=" + sf.minRegionArea + " · overrideVoxel=" + sf.overrideVoxelSize + " voxel=" + sf.voxelSize.ToString("F5") + " · overrideTile=" + sf.overrideTileSize + " tile=" + sf.tileSize); L(" 데이터 = " + dataPath + (sf.navMeshData != null ? " · 원점=" + sf.navMeshData.position.ToString("F1") : "")); if (i == 0) { navData = sf.navMeshData; volBounds = new UnityEngine.Bounds(sf.transform.TransformPoint(sf.center), sf.size); } } var bs = UnityEngine.AI.NavMesh.GetSettingsByID(0); L(" Agent 0 (" + UnityEngine.AI.NavMesh.GetSettingsNameFromID(0) + ") radius=" + bs.agentRadius + " height=" + bs.agentHeight + " slope=" + bs.agentSlope + " climb=" + bs.agentClimb + " minRegionArea=" + bs.minRegionArea + " voxel=" + bs.voxelSize.ToString("F5")); L(" NavMeshModifierVolume 기존 개수 = " + root.GetComponentsInChildren(true).Length); L(" NavMeshModifier 기존 개수 = " + root.GetComponentsInChildren(true).Length); if (navData == null) { L("[에러] NavMeshData 없음"); return Flush(kLog); } // ── 기준 지면 · 앵커 ──────────────────────────────────────────────── var portal = FindByName(root.transform, "Portal_Start"); UnityEngine.Vector3 startPos = portal != null ? portal.position : new UnityEngine.Vector3(115f, 0f, 10f); L(" Portal_Start = " + startPos.ToString("F2")); var inst = UnityEngine.AI.NavMesh.AddNavMeshData(navData); try { var tri = UnityEngine.AI.NavMesh.CalculateTriangulation(); int triCount = tri.indices.Length / 3; L(" NavMesh 삼각형 " + triCount + " · 정점 " + tri.vertices.Length); // ── (b) y 대역별 면적 ──────────────────────────────────────────── L(""); L("── (b) NavMesh 면적 y 대역 분포 (삼각형 3D 면적 합 · m^2) ──"); double total = 0.0; double yMin = 1e9, yMax = -1e9; var band = new System.Collections.Generic.SortedDictionary(); for (int t = 0; t < triCount; t++) { var a = tri.vertices[tri.indices[t * 3 + 0]]; var b = tri.vertices[tri.indices[t * 3 + 1]]; var c = tri.vertices[tri.indices[t * 3 + 2]]; double ar = UnityEngine.Vector3.Cross(b - a, c - a).magnitude * 0.5; total += ar; float cy = (a.y + b.y + c.y) / 3f; if (cy < yMin) yMin = cy; if (cy > yMax) yMax = cy; int k = UnityEngine.Mathf.FloorToInt(cy); // 1 m 대역 double cur; band.TryGetValue(k, out cur); band[k] = cur + ar; } L(" 총 면적 = " + total.ToString("F1") + " m^2 · y 범위 " + yMin.ToString("F2") + " ~ " + yMax.ToString("F2")); double cum = 0.0; foreach (var kv in band) { cum += kv.Value; L(" y [" + kv.Key + ", " + (kv.Key + 1) + ") 면적 " + kv.Value.ToString("F1") + " (" + (kv.Value / total * 100.0).ToString("F2") + " %) · 누적 " + (cum / total * 100.0).ToString("F2") + " %"); } for (int i = 0; i < 6; i++) { float thr = 1.5f + i * 1.0f; double hi = 0.0; for (int t = 0; t < triCount; t++) { var a = tri.vertices[tri.indices[t * 3 + 0]]; var b = tri.vertices[tri.indices[t * 3 + 1]]; var c = tri.vertices[tri.indices[t * 3 + 2]]; float cy = (a.y + b.y + c.y) / 3f; if (cy > thr) hi += UnityEngine.Vector3.Cross(b - a, c - a).magnitude * 0.5; } L(" y > " + thr.ToString("F1") + " m 면적 = " + hi.ToString("F1") + " m^2 (" + (hi / total * 100.0).ToString("F2") + " %)"); } // ── (c) 샘플 격자 ──────────────────────────────────────────────── L(""); L("── (c) 샘플 격자 2 m (셀 중심 수직선 1 m 간격 · SamplePosition 반경 1 m) ──"); GridScan(volBounds, 2f, 1f, (float)yMin - 1f, (float)yMax + 1f); // ── (d) 고지대 클러스터 (4 m 셀 · 8이웃 연결 성분) ─────────────── L(""); L("── (d) 고지대 NavMesh 클러스터 (셀 4 m · y > 2.0 m 삼각형) ──"); Clusters(tri, triCount, 2.0f, 4f); L(""); L("── (d2) 고지대 NavMesh 클러스터 (셀 4 m · y > 1.5 m 삼각형) ──"); Clusters(tri, triCount, 1.5f, 4f); // ── (e) 스포너 노드 ────────────────────────────────────────────── L(""); L("── (e) 스포너 노드 (MobControlMgr) ──"); var mgrs = root.GetComponentsInChildren(true); L(" 스포너 " + mgrs.Length + "기"); for (int i = 0; i < mgrs.Length; i++) { var p = mgrs[i].transform.position; UnityEngine.AI.NavMeshHit h; bool on = UnityEngine.AI.NavMesh.SamplePosition(p, out h, 2f, UnityEngine.AI.NavMesh.AllAreas); L(" #" + mgrs[i].SpawnerID + " " + mgrs[i].name + " pos=" + p.ToString("F2") + " range=" + mgrs[i].MobGenRange + " · NavMesh " + (on ? "y=" + h.position.y.ToString("F2") + " d=" + UnityEngine.Vector3.Distance(p, h.position).ToString("F2") : "밖")); } // ── (f) 경로 연결성 ────────────────────────────────────────────── L(""); L("── (f) 경로 연결성 (시작점 ↔ 스포너 원점 · 아레나) ──"); Connectivity(root, mgrs, startPos); } finally { UnityEngine.AI.NavMesh.RemoveNavMeshData(inst); } L(""); L("===== 끝 ====="); return Flush(kLog); } // ───────────────────────────────────────────────────────────────────────── /// 격자 스캔. 셀 중심 수직선을 1 m 간격으로 훑어 그 (x,z) 위에 있는 NavMesh 높이를 전부 모은다. public static void GridScan(UnityEngine.Bounds b, float step, float snap, float yLo, float yHi) { int on = 0, off = 0, hiCells = 0; var band = new System.Collections.Generic.SortedDictionary(); float x0 = b.min.x, x1 = b.max.x, z0 = b.min.z, z1 = b.max.z; for (float x = x0; x <= x1; x += step) for (float z = z0; z <= z1; z += step) { bool found = false; float best = -1e9f; for (float y = yLo; y <= yHi; y += 1f) { UnityEngine.AI.NavMeshHit h; if (!UnityEngine.AI.NavMesh.SamplePosition(new UnityEngine.Vector3(x, y, z), out h, snap, UnityEngine.AI.NavMesh.AllAreas)) continue; if (UnityEngine.Mathf.Abs(h.position.x - x) > snap || UnityEngine.Mathf.Abs(h.position.z - z) > snap) continue; found = true; if (h.position.y > best) best = h.position.y; } if (found) { on++; int k = UnityEngine.Mathf.FloorToInt(best); int c; band.TryGetValue(k, out c); band[k] = c + 1; if (best > 2.0f) hiCells++; } else off++; } L(" 격자 " + (on + off) + " 점(간격 " + step + " m) · NavMesh 위 " + on + " · 밖 " + off + " · **y > 2.0 셀 " + hiCells + "**"); foreach (var kv in band) L(" y [" + kv.Key + ", " + (kv.Key + 1) + ") " + kv.Value + " 점"); } // 고지대 삼각형을 셀로 묶어 8이웃 연결 성분을 만든다. static void Clusters(UnityEngine.AI.NavMeshTriangulation tri, int triCount, float thr, float cell) { var cells = new System.Collections.Generic.Dictionary(); var cellY = new System.Collections.Generic.Dictionary(); // (minY, maxY) for (int t = 0; t < triCount; t++) { var a = tri.vertices[tri.indices[t * 3 + 0]]; var b = tri.vertices[tri.indices[t * 3 + 1]]; var c = tri.vertices[tri.indices[t * 3 + 2]]; float cy = (a.y + b.y + c.y) / 3f; if (cy <= thr) continue; double ar = UnityEngine.Vector3.Cross(b - a, c - a).magnitude * 0.5; float cx = (a.x + b.x + c.x) / 3f, cz = (a.z + b.z + c.z) / 3f; int ix = UnityEngine.Mathf.FloorToInt(cx / cell), iz = UnityEngine.Mathf.FloorToInt(cz / cell); long key = ((long)(ix + 100000) << 20) | (long)(iz + 100000); double cur; cells.TryGetValue(key, out cur); cells[key] = cur + ar; UnityEngine.Vector2 yy; float lo = UnityEngine.Mathf.Min(a.y, UnityEngine.Mathf.Min(b.y, c.y)); float hi = UnityEngine.Mathf.Max(a.y, UnityEngine.Mathf.Max(b.y, c.y)); if (cellY.TryGetValue(key, out yy)) cellY[key] = new UnityEngine.Vector2(UnityEngine.Mathf.Min(yy.x, lo), UnityEngine.Mathf.Max(yy.y, hi)); else cellY[key] = new UnityEngine.Vector2(lo, hi); } L(" 고지대 셀 " + cells.Count + "개 (셀 " + cell + " m)"); var seen = new System.Collections.Generic.HashSet(); var stack = new System.Collections.Generic.Stack(); var results = new System.Collections.Generic.List>(); int cid = 0; foreach (var kv in cells) { if (seen.Contains(kv.Key)) continue; seen.Add(kv.Key); stack.Clear(); stack.Push(kv.Key); double area = 0.0; int n = 0; float minX = 1e9f, maxX = -1e9f, minZ = 1e9f, maxZ = -1e9f, minY = 1e9f, maxY = -1e9f; while (stack.Count > 0) { long k = stack.Pop(); int ix = (int)((k >> 20) & 0xFFFFF) - 100000; int iz = (int)(k & 0xFFFFF) - 100000; area += cells[k]; n++; float cx0 = ix * cell, cz0 = iz * cell; if (cx0 < minX) minX = cx0; if (cx0 + cell > maxX) maxX = cx0 + cell; if (cz0 < minZ) minZ = cz0; if (cz0 + cell > maxZ) maxZ = cz0 + cell; var yy = cellY[k]; if (yy.x < minY) minY = yy.x; if (yy.y > maxY) maxY = yy.y; for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++) { if (dx == 0 && dz == 0) continue; long nk = ((long)(ix + dx + 100000) << 20) | (long)(iz + dz + 100000); if (cells.ContainsKey(nk) && !seen.Contains(nk)) { seen.Add(nk); stack.Push(nk); } } } cid++; results.Add(new System.Collections.Generic.KeyValuePair(area, string.Format(" 셀 {0,4} · 면적 {1,9:F1} m^2 · X[{2,7:F1},{3,7:F1}] Z[{4,7:F1},{5,7:F1}] Y[{6,5:F2},{7,5:F2}] · 중심 ({8:F1}, {9:F1}) · 크기 {10:F1}×{11:F1}", n, area, minX, maxX, minZ, maxZ, minY, maxY, (minX + maxX) * 0.5f, (minZ + maxZ) * 0.5f, maxX - minX, maxZ - minZ))); } results.Sort(delegate (System.Collections.Generic.KeyValuePair a, System.Collections.Generic.KeyValuePair b) { return b.Key.CompareTo(a.Key); }); for (int i = 0; i < results.Count && i < 60; i++) L(" C" + (i + 1) + results[i].Value); if (results.Count > 60) L(" … 외 " + (results.Count - 60) + "개 클러스터"); L(" 클러스터 총 " + cid + "개"); } static void Connectivity(UnityEngine.GameObject root, MobControlMgr[] mgrs, UnityEngine.Vector3 startPos) { UnityEngine.AI.NavMeshHit sh; if (!UnityEngine.AI.NavMesh.SamplePosition(startPos, out sh, 5f, UnityEngine.AI.NavMesh.AllAreas)) { L(" [에러] 시작 지점이 NavMesh 밖"); return; } L(" 시작 스냅 = " + sh.position.ToString("F2")); int comp = 0, part = 0, none = 0; for (int i = 0; i < mgrs.Length; i++) { var p = mgrs[i].transform.position; UnityEngine.AI.NavMeshHit h; if (!UnityEngine.AI.NavMesh.SamplePosition(p, out h, 5f, UnityEngine.AI.NavMesh.AllAreas)) { none++; L(" #" + mgrs[i].SpawnerID + " NavMesh 밖"); continue; } var path = new UnityEngine.AI.NavMeshPath(); UnityEngine.AI.NavMesh.CalculatePath(sh.position, h.position, UnityEngine.AI.NavMesh.AllAreas, path); if (path.status == UnityEngine.AI.NavMeshPathStatus.PathComplete) comp++; else { part++; L(" #" + mgrs[i].SpawnerID + " " + path.status + " " + p.ToString("F1")); } } L(" 스포너 " + mgrs.Length + " → PathComplete " + comp + " · Partial/Invalid " + part + " · NavMesh 밖 " + none); // 아레나 중심(813e = 139, 0.8, −20) 은 SO 에서 읽는다. var arenaCfg = UnityEngine.Resources.Load("WL/WLBossArenaSettings"); if (arenaCfg != null) { var ac = arenaCfg.arenaCenter; UnityEngine.AI.NavMeshHit ah; bool on = UnityEngine.AI.NavMesh.SamplePosition(ac, out ah, 5f, UnityEngine.AI.NavMesh.AllAreas); L(" 아레나 중심(SO) = " + ac.ToString("F2") + " r=" + arenaCfg.arenaRadius + " · NavMesh " + (on ? ah.position.ToString("F2") : "밖")); if (on) { var path = new UnityEngine.AI.NavMeshPath(); UnityEngine.AI.NavMesh.CalculatePath(sh.position, ah.position, UnityEngine.AI.NavMesh.AllAreas, path); L(" 시작 → 아레나 = " + path.status); } } else L(" [미확인] WLBossArenaSettings 로드 실패"); } static UnityEngine.Transform FindByName(UnityEngine.Transform t, string name) { if (t.name == name) return t; for (int i = 0; i < t.childCount; i++) { var r = FindByName(t.GetChild(i), name); if (r != null) return r; } return null; } }