// 발주서 WL-813t2 — WL_Nature NavMesh 재베이크 (언덕 = 이동 불가) // // 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기) // 대조군(현행 프리팹 그대로 재베이크 · 저장 0) : // unity command --project-path E:\NerdNavis\WL_wt\systems run_script --file AgentScripts/WL813t2_Bake.cs --entry WL813t2_Bake.Control --timeout 1800 // 본 베이크(WL 볼륨 노드 포함 · 자산 갱신) : // … --entry WL813t2_Bake.Apply --timeout 1800 // 검증(자산 재로드 후 면적·연결성·스폰 격자) : // … --entry WL813t2_Bake.Verify --timeout 1800 // // 🔴 이 스크립트는 씬을 새로 만들고(EmptyScene) 프리팹을 임시 인스턴스화한 뒤 베이크한다. 씬은 저장하지 않는다. // 자산 갱신은 GUID 보존을 위해 「임시 자산 생성 → 파일 바이트 덮어쓰기 → 임시 삭제 → 재임포트」로 한다. public static class WL813t2_Bake { const string kMapPrefab = "Assets/Res_Addr/Map/WL_Nature.prefab"; const string kNavAsset = "Assets/WL/Settings/WL_Nature_NavMesh.asset"; const string kTmpAsset = "Assets/WL/Settings/WL_Nature_NavMesh_813t2TMP.asset"; const string kOutDir = "AgentScripts/staging/WL813t2"; 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 Control() { return Run(false, kOutDir + "/BAKE_CONTROL.txt"); } public static string Apply() { return Run(true, kOutDir + "/BAKE_APPLY.txt"); } static string Run(bool save, string logPath) { s = new System.Text.StringBuilder(); L("===== WL-813t2 베이크 " + (save ? "APPLY(자산 갱신)" : "CONTROL(저장 0)") + " · " + System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " ====="); UnityEditor.AssetDatabase.ImportAsset(kMapPrefab, UnityEditor.ImportAssetOptions.ForceUpdate); var prefab = UnityEditor.AssetDatabase.LoadAssetAtPath(kMapPrefab); if (prefab == null) { L("[에러] 프리팹 로드 실패"); return Flush(logPath); } // 현행(커밋본) 자산 상태 var oldData = UnityEditor.AssetDatabase.LoadAssetAtPath(kNavAsset); long oldBytes = 0; try { oldBytes = new System.IO.FileInfo(Abs(kNavAsset)).Length; } catch { } L("현행 자산 = " + kNavAsset + " · " + oldBytes + " bytes · " + (oldData != null ? "로드 OK" : "로드 실패")); if (oldData != null) Report("현행(커밋본)", oldData, null); // ── 임시 씬 + 프리팹 인스턴스 ──────────────────────────────────────── var scene = UnityEditor.SceneManagement.EditorSceneManager.NewScene( UnityEditor.SceneManagement.NewSceneSetup.EmptyScene, UnityEditor.SceneManagement.NewSceneMode.Single); var go = UnityEditor.PrefabUtility.InstantiatePrefab(prefab) as UnityEngine.GameObject; if (go == null) { L("[에러] 인스턴스화 실패"); return Flush(logPath); } L("인스턴스 root transform pos=" + go.transform.position.ToString("F3") + " rot=" + go.transform.rotation.eulerAngles.ToString("F1") + " scale=" + go.transform.localScale.ToString("F3")); var sf = go.GetComponentInChildren(true); if (sf == null) { L("[에러] NavMeshSurface 없음"); return Flush(logPath); } var vols = go.GetComponentsInChildren(true); L("NavMeshModifierVolume " + vols.Length + "개"); for (int i = 0; i < vols.Length; i++) { var v = vols[i]; var wc = v.transform.TransformPoint(v.center); L(" [" + i + "] " + Path(v.transform) + " · area=" + v.area + "(" + UnityEngine.AI.NavMesh.GetAreaNames()[UnityEngine.Mathf.Clamp(v.area, 0, 31)] + ")" + " · center(world)=" + wc.ToString("F1") + " size=" + v.size.ToString("F1") + " · Y[" + (wc.y - v.size.y * 0.5f).ToString("F2") + ", " + (wc.y + v.size.y * 0.5f).ToString("F2") + "]" + " · active=" + v.gameObject.activeInHierarchy + " enabled=" + v.enabled + " · affectsAgent0=" + v.AffectsAgentType(0) + " layer=" + v.gameObject.layer); } // ── 베이크 ────────────────────────────────────────────────────────── var sw = System.Diagnostics.Stopwatch.StartNew(); var keep = sf.navMeshData; sf.navMeshData = null; // 새 데이터로 굽는다(기존 자산을 덮어쓰지 않게) sf.BuildNavMesh(); sw.Stop(); var built = sf.navMeshData; L("베이크 " + (sw.ElapsedMilliseconds / 1000.0).ToString("F1") + " s · 결과 " + (built != null ? "OK" : "null")); if (built == null) { sf.navMeshData = keep; return Flush(logPath); } sf.RemoveData(); // BuildNavMesh 가 이미 AddData 했다 — Report 가 다시 올리므로 중복(면적 2배)을 막는다 Report("재베이크", built, go); if (save) { // GUID 보존 저장: 임시 자산 → 파일 바이트 덮어쓰기 → 임시 삭제 → 재임포트 if (UnityEditor.AssetDatabase.LoadAssetAtPath(kTmpAsset) != null) UnityEditor.AssetDatabase.DeleteAsset(kTmpAsset); UnityEditor.AssetDatabase.CreateAsset(built, kTmpAsset); UnityEditor.AssetDatabase.SaveAssets(); System.IO.File.Copy(Abs(kTmpAsset), Abs(kNavAsset), true); UnityEditor.AssetDatabase.DeleteAsset(kTmpAsset); UnityEditor.AssetDatabase.ImportAsset(kNavAsset, UnityEditor.ImportAssetOptions.ForceUpdate); long newBytes = 0; try { newBytes = new System.IO.FileInfo(Abs(kNavAsset)).Length; } catch { } L("자산 저장 완료 · " + oldBytes + " → " + newBytes + " bytes (" + (newBytes - oldBytes) + ")"); L("GUID = " + UnityEditor.AssetDatabase.AssetPathToGUID(kNavAsset) + " (기대 ef300913c80dbda47b476b9261fdd786)"); } else L("저장 안 함(대조군)."); sf.navMeshData = keep; UnityEngine.Object.DestroyImmediate(go); L("씬 정리 완료(저장 0) · scene=" + scene.name); return Flush(logPath); } // ───────────────────────────────────────────────────────────────────────── public static string Verify() { s = new System.Text.StringBuilder(); L("===== WL-813t2 검증 (" + System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + ") ====="); UnityEditor.AssetDatabase.ImportAsset(kNavAsset, UnityEditor.ImportAssetOptions.ForceUpdate); var data = UnityEditor.AssetDatabase.LoadAssetAtPath(kNavAsset); if (data == null) { L("[에러] 자산 로드 실패"); return Flush(kOutDir + "/VERIFY.txt"); } long bytes = 0; try { bytes = new System.IO.FileInfo(Abs(kNavAsset)).Length; } catch { } L("자산 " + kNavAsset + " · " + bytes + " bytes"); var prefab = UnityEditor.AssetDatabase.LoadAssetAtPath(kMapPrefab); Report("검증", data, prefab); return Flush(kOutDir + "/VERIFY.txt"); } // ───────────────────────────────────────────────────────────────────────── /// NavMeshData 를 잠시 올려 면적·y 대역·격자·연결성·스폰 표집을 잰다. static void Report(string tag, UnityEngine.AI.NavMeshData data, UnityEngine.GameObject mapRoot) { var inst = UnityEngine.AI.NavMesh.AddNavMeshData(data); try { var tri = UnityEngine.AI.NavMesh.CalculateTriangulation(); int n = tri.indices.Length / 3; double total = 0.0, a15 = 0.0, a20 = 0.0, a25 = 0.0, a30 = 0.0, a35 = 0.0, a50 = 0.0; float yMin = 1e9f, yMax = -1e9f; for (int t = 0; t < n; 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; float cy = (a.y + b.y + c.y) / 3f; float hy = UnityEngine.Mathf.Max(a.y, UnityEngine.Mathf.Max(b.y, c.y)); total += ar; if (cy > 1.5f) a15 += ar; if (cy > 2.0f) a20 += ar; if (cy > 2.5f) a25 += ar; if (cy > 3.0f) a30 += ar; if (cy > 3.5f) a35 += ar; if (cy > 5.0f) a50 += ar; if (cy < yMin) yMin = cy; if (hy > yMax) yMax = hy; } L("[" + tag + "] 삼각형 " + n + " · 정점 " + tri.vertices.Length + " · 총 면적 " + total.ToString("F1") + " m^2 · y " + yMin.ToString("F2") + " ~ " + yMax.ToString("F2")); L("[" + tag + "] y>1.5 " + a15.ToString("F1") + " · y>2.0 " + a20.ToString("F1") + " · y>2.5 " + a25.ToString("F1") + " · **y>3.0 " + a30.ToString("F1") + "** · y>3.5 " + a35.ToString("F1") + " · y>5.0 " + a50.ToString("F1") + " m^2"); if (mapRoot != null) { // 연결성 — 시작점 → 스포너 23 · 아레나 var portal = FindByName(mapRoot.transform, "Portal_Start"); UnityEngine.Vector3 startPos = portal != null ? portal.position : new UnityEngine.Vector3(115f, 0f, 10f); UnityEngine.AI.NavMeshHit sh; if (!UnityEngine.AI.NavMesh.SamplePosition(startPos, out sh, 5f, UnityEngine.AI.NavMesh.AllAreas)) { L("[" + tag + "] [에러] 시작 지점이 NavMesh 밖"); } else { var mgrs = mapRoot.GetComponentsInChildren(true); 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 밖 " + p.ToString("F1")); 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("[" + tag + "] 시작→스포너 " + mgrs.Length + " : PathComplete " + comp + " · Partial/Invalid " + part + " · 밖 " + none); var arenaCfg = UnityEngine.Resources.Load("WL/WLBossArenaSettings"); if (arenaCfg != null) { UnityEngine.AI.NavMeshHit ah; bool on = UnityEngine.AI.NavMesh.SamplePosition(arenaCfg.arenaCenter, out ah, 5f, UnityEngine.AI.NavMesh.AllAreas); string st = "밖"; if (on) { var p2 = new UnityEngine.AI.NavMeshPath(); UnityEngine.AI.NavMesh.CalculatePath(sh.position, ah.position, UnityEngine.AI.NavMesh.AllAreas, p2); st = ah.position.ToString("F2") + " · 시작→아레나 " + p2.status; } L("[" + tag + "] 아레나 " + arenaCfg.arenaCenter.ToString("F1") + " → " + st); // 아레나 원주 12방향에서 아레나 중심으로 경로(아레나 내부 연결성) int okr = 0; for (int i = 0; i < 12 && on; i++) { float ang = i * UnityEngine.Mathf.PI * 2f / 12f; var rp = arenaCfg.arenaCenter + new UnityEngine.Vector3(UnityEngine.Mathf.Cos(ang), 0f, UnityEngine.Mathf.Sin(ang)) * (arenaCfg.arenaRadius * 0.8f); UnityEngine.AI.NavMeshHit rh; if (!UnityEngine.AI.NavMesh.SamplePosition(rp, out rh, 5f, UnityEngine.AI.NavMesh.AllAreas)) continue; var p3 = new UnityEngine.AI.NavMeshPath(); UnityEngine.AI.NavMesh.CalculatePath(rh.position, ah.position, UnityEngine.AI.NavMesh.AllAreas, p3); if (p3.status == UnityEngine.AI.NavMeshPathStatus.PathComplete) okr++; } L("[" + tag + "] 아레나 원주 12점 → 중심 PathComplete " + okr + "/12"); } // 주요 앵커 y string[] anchors = { "Portal_Start", "Reincarnation_Start" }; for (int i = 0; i < anchors.Length; i++) { var tr = FindByName(mapRoot.transform, anchors[i]); if (tr == null) { L("[" + tag + "] 앵커 " + anchors[i] + " 없음"); continue; } UnityEngine.AI.NavMeshHit hh; bool onA = UnityEngine.AI.NavMesh.SamplePosition(tr.position, out hh, 5f, UnityEngine.AI.NavMesh.AllAreas); L("[" + tag + "] 앵커 " + anchors[i] + " " + tr.position.ToString("F2") + " → " + (onA ? hh.position.ToString("F2") : "NavMesh 밖")); } // 스폰 표집 — 스포너별 랜덤 표집(원본 DSUtil 규약 재현) 1000점의 y 분포 SpawnProbe(tag, mgrs, sh.position); } } } finally { UnityEngine.AI.NavMesh.RemoveNavMeshData(inst); } } /// 원본 Get_RandomPos_onNavMesh_Reachable 규약(스냅 2 m · 50회 · PathComplete)으로 1000점 표집. static void SpawnProbe(string tag, MobControlMgr[] mgrs, UnityEngine.Vector3 startOnMesh) { if (mgrs.Length == 0) return; UnityEngine.Random.InitState(813); int samples = 1000, hi = 0, offMesh = 0, fellBack = 0; var band = new System.Collections.Generic.SortedDictionary(); for (int i = 0; i < samples; i++) { var mgr = mgrs[i % mgrs.Length]; var origin = mgr.transform.position; var p = DSUtil.Get_RandomPos_onNavMesh_Reachable(origin, 0f, mgr.MobGenRange, 2f, 50); if ((p - origin).sqrMagnitude < 1e-6f) fellBack++; UnityEngine.AI.NavMeshHit h; if (!UnityEngine.AI.NavMesh.SamplePosition(p, out h, 1f, UnityEngine.AI.NavMesh.AllAreas)) offMesh++; float y = h.hit ? h.position.y : p.y; if (y > 3.0f) hi++; int k = UnityEngine.Mathf.FloorToInt(y); int c; band.TryGetValue(k, out c); band[k] = c + 1; } L("[" + tag + "] 스폰 표집 " + samples + "점 · **y>3.0 " + hi + "** · NavMesh 밖 " + offMesh + " · 원점 폴백 " + fellBack); var sb = new System.Text.StringBuilder(" y 분포:"); foreach (var kv in band) sb.Append(" [" + kv.Key + "]=" + kv.Value); L(sb.ToString()); } static string Path(UnityEngine.Transform t) { string p = t.name; while (t.parent != null) { t = t.parent; p = t.name + "/" + p; } return p; } 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; } }