// 발주서 WL-813t2 — 지형 가드 · 스폰 위치 검증 프로브 (에디트 모드 · Play 0 · 로그인 0) // // 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기) // SO 생성 : unity command --project-path E:\NerdNavis\WL_wt\systems run_script --file AgentScripts/WL813t2_Guard.cs --entry WL813t2_Guard.MakeSettings --timeout 300 // 프로브 : … --entry WL813t2_Guard.Probe --timeout 900 // // 프로브가 재는 것 — 전부 읽기 전용(에셋 수정 0): // ① SO 실값(에셋에서 읽는다 · 코드 상수 대조 0) // ② 언덕 격자 1000점 — 재베이크 뒤 walkable 0 인지(SamplePosition) // ③ 되돌릴 지점 찾기 — D-48 실좌표(아레나 139/−20 위 y 3.66 · 5.48)에서 FindGround → y ≤ 2.96 // ④ 스폰 보정 경로 — 언덕 위 가짜 스폰점 200개에 §1-4 규약(재표집 3회 → 스포너 노드) 적용 // ⑤ C8 — enabled=0 · terrainGuardEnabled=0 · spawnGuardEnabled=0 · RuntimeDisabled 진리표 // ⑥ GC — 정상 상태 틱 1000회의 GC 증가분 public static class WL813t2_Guard { const string kSoPath = "Assets/WL/Combat/Settings/Resources/WL/WLTerrainGuardSettings.asset"; const string kNavAsset = "Assets/WL/Settings/WL_Nature_NavMesh.asset"; const string kMapPrefab= "Assets/Res_Addr/Map/WL_Nature.prefab"; 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 MakeSettings() { s = new System.Text.StringBuilder(); var existing = UnityEditor.AssetDatabase.LoadAssetAtPath(kSoPath); if (existing != null) { L("이미 있음: " + kSoPath); return Flush(kOutDir + "/SO.txt"); } var so = UnityEngine.ScriptableObject.CreateInstance(); // 값은 클래스 초기값(= 발주서 §1-3 · §1-4 값)을 그대로 굳힌다 — 여기서 새 수치를 만들지 않는다. UnityEditor.AssetDatabase.CreateAsset(so, kSoPath); UnityEditor.AssetDatabase.SaveAssets(); UnityEditor.AssetDatabase.ImportAsset(kSoPath, UnityEditor.ImportAssetOptions.ForceUpdate); L("생성: " + kSoPath); return Flush(kOutDir + "/SO.txt"); } // ───────────────────────────────────────────────────────────────────────── public static string Probe() { s = new System.Text.StringBuilder(); L("===== WL-813t2 가드 프로브 (" + System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + ") ====="); WL.Combat.Nav.WLTerrainGuardSettings.ClearCache(); var cfg = UnityEngine.Resources.Load( WL.Combat.Nav.WLTerrainGuardSettings.ResourcesPath); if (cfg == null) { L("[에러] SO 로드 실패 (" + kSoPath + ")"); return Flush(kOutDir + "/PROBE.txt"); } // ① SO 실값 L(""); L("── ① SO 실값 (에셋에서 읽음) ──"); L(" enabled=" + B(cfg.enabled) + " verboseLog=" + B(cfg.verboseLog) + " check=" + cfg.checkSeconds + "s rescan=" + cfg.rescanSeconds + "s"); L(" [지형] terrainGuardEnabled=" + B(cfg.terrainGuardEnabled) + " maxWalkableY=" + cfg.maxWalkableY + " offNavMeshTolerance=" + cfg.offNavMeshTolerance + " dwell=" + cfg.dwellSeconds + "s cool=" + cfg.warpCooldownSeconds + "s" + " near=" + cfg.warpNearRadius + " far=" + cfg.warpFarRadius); L(" [지형] guardPlayer=" + B(cfg.guardPlayer) + " guardMobs=" + B(cfg.guardMobs) + " guardBoss=" + B(cfg.guardBoss) + " warpSnapsCameraLead=" + B(cfg.warpSnapsCameraLead)); L(" [범위] heightGuardMapIds=[" + Join(cfg.heightGuardMapIds) + "] allMaps=" + B(cfg.heightGuardAllMaps) + " whenUnknown=" + B(cfg.heightGuardWhenMapUnknown) + " (WL_Nature = BattleMapConfig n_MapID -1)"); L(" [스폰] spawnGuardEnabled=" + B(cfg.spawnGuardEnabled) + " check=" + cfg.spawnCheckSeconds + "s" + " sampleRadius=" + cfg.spawnSampleRadius + " retries=" + cfg.spawnRetries + " fallbackToNode=" + B(cfg.spawnFallbackToSpawnerNode)); var navData = UnityEditor.AssetDatabase.LoadAssetAtPath(kNavAsset); var root = UnityEditor.AssetDatabase.LoadAssetAtPath(kMapPrefab); if (navData == null || root == null) { L("[에러] NavMesh/프리팹 로드 실패"); return Flush(kOutDir + "/PROBE.txt"); } var inst = UnityEngine.AI.NavMesh.AddNavMeshData(navData); try { // ② 언덕 격자 1000점 L(""); L("── ② 언덕 격자 — 재베이크 뒤 walkable 0 인지 ──"); // 813t2 실측 고지대 클러스터 C1(X 96~160 · Z −100~−16 · 최고 y 20.46)이 D-48 현장(아레나 139/−20 옆)이다. UnityEngine.Random.InitState(8132); int n = 1000, walkable = 0, aboveMax = 0; float hiY = -1e9f; for (int i = 0; i < n; i++) { float x = UnityEngine.Random.Range(96f, 160f); float z = UnityEngine.Random.Range(-100f, -16f); float y = UnityEngine.Random.Range(3.0f, 20.0f); // 언덕 높이대 UnityEngine.AI.NavMeshHit h; if (!UnityEngine.AI.NavMesh.SamplePosition(new UnityEngine.Vector3(x, y, z), out h, 1.0f, UnityEngine.AI.NavMesh.AllAreas)) continue; walkable++; if (h.position.y > hiY) hiY = h.position.y; if (h.position.y > cfg.maxWalkableY) aboveMax++; } L(" 클러스터 C1(X96~160 · Z−100~−16 · y 3~20) 1000점 · 반경 1 m 스냅 성공 " + walkable + " · maxWalkableY(" + cfg.maxWalkableY + ") 초과 " + aboveMax + (walkable > 0 ? " · 최고 스냅 y " + hiY.ToString("F2") : "")); // 맵 전역 y>3 walkable 이 정말 0 인지 한 번 더(삼각형 기준은 Bake 프로브가 이미 0.0 을 실측) var tri = UnityEngine.AI.NavMesh.CalculateTriangulation(); float top = -1e9f; for (int i = 0; i < tri.vertices.Length; i++) if (tri.vertices[i].y > top) top = tri.vertices[i].y; L(" NavMesh 정점 최고 y = " + top.ToString("F2") + " (maxWalkableY " + cfg.maxWalkableY + " 아래여야 한다)"); // ③ 되돌릴 지점 찾기 — D-48 실좌표 L(""); L("── ③ FindGround — D-48 실좌표에서 되돌릴 지점 ──"); UnityEngine.Vector3[] cases = { new UnityEngine.Vector3(139f, 3.66f, -20f), // D-48 R2 t=295.46 new UnityEngine.Vector3(139f, 5.48f, -20f), // D-48 R2 t=309.33 new UnityEngine.Vector3(139f, 5.27f, -20f), // D-48 결과창 방치 74 s new UnityEngine.Vector3(128f, 8.00f, -58f), // 클러스터 C1 중심 상공 new UnityEngine.Vector3(147f, 6.00f, 30f), // 존2 상공 }; for (int i = 0; i < cases.Length; i++) { UnityEngine.Vector3 got; bool ok = WL.Combat.Nav.TerrainGuard.FindGround(cfg, cases[i], out got); L(" " + cases[i].ToString("F2") + " → " + (ok ? got.ToString("F2") + " · Δy " + (cases[i].y - got.y).ToString("F2") + " · 수평 " + UnityEngine.Vector2.Distance(new UnityEngine.Vector2(cases[i].x, cases[i].z), new UnityEngine.Vector2(got.x, got.z)).ToString("F2") + " m" + (got.y <= cfg.maxWalkableY ? " · PASS" : " · **FAIL(언덕 위)**") : "실패(반경 " + cfg.warpFarRadius + " m 안에 NavMesh 없음)")); } // ④ 스폰 보정 경로 — 실제 SpawnPositionGuard.ResolveSpawnPoint 를 그대로 부른다(로직 복제 0) L(""); L("── ④ 스폰 보정 경로 — 언덕 위 가짜 스폰점 200개 (SpawnPositionGuard.ResolveSpawnPoint 실호출) ──"); var mgrs = root.GetComponentsInChildren(true); for (int i = 0; i < mgrs.Length; i++) // 에디트 모드에는 씬 인스턴스가 없다 → 노드 캐시를 직접 심는다 WL.Combat.Nav.SpawnPositionGuard.SeedSpawnerNodeForProbe(mgrs[i].SpawnerID, mgrs[i].transform.position); UnityEngine.Random.InitState(8133); int okStep1 = 0, okRetry = 0, okNode = 0, fail = 0, resultAbove = 0; for (int i = 0; i < 200; i++) { var mgr = mgrs[i % mgrs.Length]; var origin = mgr.transform.position; // 스포너 반경 안의 랜덤 XZ 를 언덕 높이(y 6)로 들어올린 가짜 스폰점 var off = UnityEngine.Random.insideUnitCircle * mgr.MobGenRange; var p = new UnityEngine.Vector3(origin.x + off.x, 6f, origin.z + off.y); UnityEngine.Vector3 res; int how = WL.Combat.Nav.SpawnPositionGuard.ResolveSpawnPoint(cfg, p, mgr.SpawnerID, out res); if (how == WL.Combat.Nav.SpawnPositionGuard.ResultValid) okStep1++; else if (how == WL.Combat.Nav.SpawnPositionGuard.ResultNode) okNode++; else if (how == WL.Combat.Nav.SpawnPositionGuard.ResultFailed) { fail++; continue; } else okRetry++; if (res.y > cfg.maxWalkableY) resultAbove++; } L(" 200점 · 원위치 유효 " + okStep1 + " · 재표집 " + okRetry + " · 스포너 노드 폴백 " + okNode + " · 보정 실패 " + fail + " · **보정 결과가 언덕 위 " + resultAbove + "**"); // 스포너 노드 자체가 전부 walkable 이고 언덕 아래인지 int nodeOk = 0, nodeHigh = 0; for (int i = 0; i < mgrs.Length; i++) { UnityEngine.AI.NavMeshHit h; if (!UnityEngine.AI.NavMesh.SamplePosition(mgrs[i].transform.position, out h, cfg.spawnSampleRadius, UnityEngine.AI.NavMesh.AllAreas)) continue; nodeOk++; if (h.position.y > cfg.maxWalkableY) { nodeHigh++; L(" [경고] 스포너 " + mgrs[i].SpawnerID + " 노드가 언덕 위 y=" + h.position.y.ToString("F2")); } } L(" 스포너 노드 " + mgrs.Length + "기 · walkable " + nodeOk + " · 언덕 위 " + nodeHigh + " (0 이어야 폴백이 안전하다)"); } finally { UnityEngine.AI.NavMesh.RemoveNavMeshData(inst); } // ⑤ C8 진리표 — 임시 SO 로만 실험한다(실에셋 무수정) L(""); L("── ⑤ C8 진리표 (임시 SO · 실에셋 무수정) ──"); var real = cfg; try { L(" 실에셋 → TerrainGuard.Active=" + B(WL.Combat.Nav.TerrainGuard.Active) + " · SpawnPositionGuard.Active=" + B(WL.Combat.Nav.SpawnPositionGuard.Active)); var tmp = UnityEngine.ScriptableObject.CreateInstance(); WL.Combat.Nav.WLTerrainGuardSettings.SetInstanceForProbe(tmp); tmp.enabled = false; L(" enabled=0 → " + B(WL.Combat.Nav.TerrainGuard.Active) + " / " + B(WL.Combat.Nav.SpawnPositionGuard.Active) + " (기대 false/false)"); tmp.enabled = true; tmp.terrainGuardEnabled = false; L(" terrainGuard=0 → " + B(WL.Combat.Nav.TerrainGuard.Active) + " / " + B(WL.Combat.Nav.SpawnPositionGuard.Active) + " (기대 false/true)"); tmp.terrainGuardEnabled = true; tmp.spawnGuardEnabled = false; L(" spawnGuard=0 → " + B(WL.Combat.Nav.TerrainGuard.Active) + " / " + B(WL.Combat.Nav.SpawnPositionGuard.Active) + " (기대 true/false)"); tmp.spawnGuardEnabled = true; WL.Combat.Nav.WLTerrainGuardSettings.RuntimeDisabled = true; L(" RuntimeDisabled → " + B(WL.Combat.Nav.TerrainGuard.Active) + " / " + B(WL.Combat.Nav.SpawnPositionGuard.Active) + " (기대 false/false)"); WL.Combat.Nav.WLTerrainGuardSettings.RuntimeDisabled = false; WL.Combat.Nav.WLTerrainGuardSettings.SetInstanceForProbe(null); L(" 에셋 없음 → " + B(WL.Combat.Nav.TerrainGuard.Active) + " / " + B(WL.Combat.Nav.SpawnPositionGuard.Active) + " (기대 false/false)"); UnityEngine.Object.DestroyImmediate(tmp); // ⑥ GC — 정상 상태 틱 + 실제 판정 경로(FindGround · ResolveSpawnPoint) 각 1000회 L(""); L("── ⑥ GC — 틱 1000회 + 판정 경로 1000회 ──"); var g = UnityEngine.ScriptableObject.CreateInstance(); g.rescanSeconds = 100000f; g.spawnCheckSeconds = 100000f; WL.Combat.Nav.WLTerrainGuardSettings.SetInstanceForProbe(g); WL.Combat.Nav.TerrainGuard.ResetDiagnostics(); WL.Combat.Nav.SpawnPositionGuard.ResetDiagnostics(); WL.Combat.Nav.TerrainGuard.ForceTick(); // 1회는 재수집(할당) — 이후가 정상 상태 WL.Combat.Nav.SpawnPositionGuard.ForceTick(); var navInst2 = UnityEngine.AI.NavMesh.AddNavMeshData(navData); try { var from = new UnityEngine.Vector3(139f, 5.48f, -20f); UnityEngine.Vector3 t1, t2; WL.Combat.Nav.TerrainGuard.FindGround(g, from, out t1); // JIT 워밍 WL.Combat.Nav.SpawnPositionGuard.ResolveSpawnPoint(g, from, 813001, out t2); System.GC.Collect(); System.GC.WaitForPendingFinalizers(); System.GC.Collect(); long before = System.GC.GetTotalMemory(false); for (int i = 0; i < 1000; i++) { WL.Combat.Nav.TerrainGuard.ProbeTick(); WL.Combat.Nav.SpawnPositionGuard.ProbeTick(); WL.Combat.Nav.TerrainGuard.FindGround(g, from, out t1); WL.Combat.Nav.SpawnPositionGuard.ResolveSpawnPoint(g, from, 813001, out t2); } long after = System.GC.GetTotalMemory(false); L(" 틱 1000 + FindGround 1000 + ResolveSpawnPoint 1000 · GC 총량 " + before + " → " + after + " (Δ " + (after - before) + " B)" + " · 재수집 " + WL.Combat.Nav.TerrainGuard.Scans + " · Spawn 스캔 " + WL.Combat.Nav.SpawnPositionGuard.Scans); } finally { UnityEngine.AI.NavMesh.RemoveNavMeshData(navInst2); } UnityEngine.Object.DestroyImmediate(g); } finally { WL.Combat.Nav.WLTerrainGuardSettings.RuntimeDisabled = false; WL.Combat.Nav.WLTerrainGuardSettings.ClearCache(); WL.Combat.Nav.TerrainGuard.ResetDiagnostics(); WL.Combat.Nav.SpawnPositionGuard.ResetDiagnostics(); L(" 정리: 캐시 초기화 · RuntimeDisabled=false · 실에셋 재로드 = " + (UnityEngine.Resources.Load(WL.Combat.Nav.WLTerrainGuardSettings.ResourcesPath) != null)); } L(""); L("===== 끝 ====="); return Flush(kOutDir + "/PROBE.txt"); } static string B(bool b) { return b ? "true" : "false"; } static string Join(int[] a) { if (a == null) return ""; var sb = new System.Text.StringBuilder(); for (int i = 0; i < a.Length; i++) { if (i > 0) sb.Append(", "); sb.Append(a[i]); } return sb.ToString(); } }