// 발주서 WL-813e — 군중 존 5개 + 보스 아레나(Anubis) 데이터 적용 검증 프로브 // // 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기) // 에디트 모드 : unity command run_script --file AgentScripts/WL813e_Zones.cs --entry WL813e_Zones.Edit --timeout 300 // Play 60s : unity command run_script --file AgentScripts/WL813e_Zones.cs --entry WL813e_Zones.Watch (Play 중에만) // unity command run_script --file AgentScripts/WL813e_Zones.cs --entry WL813e_Zones.Result (감시 종료 후 결과) // // Edit() 가 재는 것 — 전부 AssetDatabase 읽기 전용, 씬/프리팹 변경 0: // (a) MonsterAppear.json 새 행 6개(813001~813005 · 813010) 존재·값 // (b) WL_Nature.prefab 의 MobControlMgr 19개 → SpawnerID 가 실제 행에 붙는가 · 존 5개 재바인딩 확인 // (c) s_AppearMonster 의 몬스터 ID 가 MonsterList 에 있고 Addressables 프리팹이 실존하는가 // (d) FieldBossData 1기 — n_MonsterID/n_SpawnerId/RegenTime/isChapterBoss · MapData.m_ChapterPortal null 정합 // (e) 동시 생존 수 · 인스턴스 풀 총계 (MobControlMgr.Get_Mobs 규칙 재현: Random/Order = ×2 · Phase* = 종류×수) // (f) 보스 아레나 좌표의 NavMesh 도달성 (WL_Nature_NavMesh.asset · SamplePosition + CalculatePath) — 813b 미확인 ③ // // 수치·경로는 전부 프로젝트 데이터에서 읽는다(C45). 프로브 상수는 "어디를 보나" 뿐이다. public static class WL813e_Zones { const string kAppearJson = "Assets/ResWork/Table/Export/MonsterAppear.json"; const string kListJson = "Assets/ResWork/Table/Export/MonsterList.json"; const string kMapPrefab = "Assets/Res_Addr/Map/WL_Nature.prefab"; const string kNavAsset = "Assets/WL/Settings/WL_Nature_NavMesh.asset"; const string kMobFmt = "Assets/Res_Addr/Mobs/{0}.prefab"; const string kOutDir = "AgentScripts/staging/WL813e"; const string kEditLog = kOutDir + "/ZONES_EDIT.txt"; const string kPlayLog = kOutDir + "/ZONES_PLAY.txt"; static readonly int[] kZoneIds = { 813001, 813002, 813003, 813004, 813005 }; const int kBossRowId = 813010; static System.Text.StringBuilder s; static void L(string t) { s.AppendLine(t); UnityEngine.Debug.Log("[WL813e] " + t); } /// 텍스트 편집으로 바꾼 프리팹·JSON 을 에디터 캐시에 반영한다(상주 에디터에서 필수). static UnityEngine.GameObject LoadMap() { UnityEditor.AssetDatabase.ImportAsset(kMapPrefab, UnityEditor.ImportAssetOptions.ForceUpdate); UnityEditor.AssetDatabase.ImportAsset(kAppearJson, UnityEditor.ImportAssetOptions.ForceUpdate); return UnityEditor.AssetDatabase.LoadAssetAtPath(kMapPrefab); } 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("[WL813e] 로그 저장 실패: " + e.Message); } return s.ToString(); } // ── 아주 얕은 JSON 배열 파서 (행 = {"k": "v", ...}) ─────────────────────── static System.Collections.Generic.List> ReadRows(string relPath) { var list = new System.Collections.Generic.List>(); var txt = System.IO.File.ReadAllText(Abs(relPath), new System.Text.UTF8Encoding(true)); foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(txt, "\\{[^{}]*\\}")) { var d = new System.Collections.Generic.Dictionary(); foreach (System.Text.RegularExpressions.Match kv in System.Text.RegularExpressions.Regex.Matches(m.Value, "\"([^\"]+)\"\\s*:\\s*\"([^\"]*)\"")) d[kv.Groups[1].Value] = kv.Groups[2].Value; if (d.Count > 0) list.Add(d); } return list; } static string G(System.Collections.Generic.Dictionary d, string k) { string v; return d.TryGetValue(k, out v) ? v : ""; } // ══════════════════════════════════════════════════════════════════════ public static string Edit() { s = new System.Text.StringBuilder(); L("===== WL-813e 존 리듬 + 보스 아레나 데이터 검증 ====="); L("playMode=" + UnityEngine.Application.isPlaying + " · unity=" + UnityEngine.Application.unityVersion + " · buildTarget=" + UnityEditor.EditorUserBuildSettings.activeBuildTarget); L(""); var appear = ReadRows(kAppearJson); var mlist = ReadRows(kListJson); var byAppear = new System.Collections.Generic.Dictionary>(); foreach (var r in appear) byAppear[G(r, "n_SpawnerID")] = r; var byMob = new System.Collections.Generic.Dictionary>(); foreach (var r in mlist) byMob[G(r, "n_MonsterID")] = r; // ── (a) 새 행 ──────────────────────────────────────────────────── L("── (a) MonsterAppear 새 행 (전체 " + appear.Count + "행) ──"); int newOk = 0; var wanted = new System.Collections.Generic.List(kZoneIds); wanted.Add(kBossRowId); foreach (var id in wanted) { System.Collections.Generic.Dictionary r; if (!byAppear.TryGetValue(id.ToString(), out r)) { L(" [에러] 행 없음 " + id); continue; } newOk++; L(string.Format(" {0} Lv{1} ATK {2} DEF {3} HP {4} · {5} {6} · 최대 {7}마리 · 리젠 {8}s · 순찰 {9} · Exp {10} Gold {11}", id, G(r, "n_AppearMonsterLv"), G(r, "n_MonsterATK"), G(r, "n_MonsterDEF"), G(r, "n_MonsterHP"), G(r, "e_SpawnerType"), G(r, "s_AppearMonster"), G(r, "n_MaxMonsterCount"), G(r, "f_SpawnerDelay"), G(r, "f_PatrolRange"), G(r, "n_DropExp"), G(r, "n_DropGold"))); } L(" 새 행 " + newOk + " / " + wanted.Count); L(""); // ── (b)(c)(e) 맵 프리팹의 스포너 ──────────────────────────────── var root = LoadMap(); if (root == null) { L("[에러] 맵 프리팹 로드 실패: " + kMapPrefab); return Flush(kEditLog); } var mgrs = root.GetComponentsInChildren(true); L("── (b) " + kMapPrefab + " 스포너 " + mgrs.Length + "개 ──"); int bound = 0, unbound = 0, zoneHit = 0, alive = 0, pool = 0, mobMissing = 0; for (int i = 0; i < mgrs.Length; i++) { var m = mgrs[i]; var p = m.transform.position; System.Collections.Generic.Dictionary r; bool has = byAppear.TryGetValue(m.SpawnerID.ToString(), out r); if (!has) { unbound++; L(string.Format(" [에러] {0} SpawnerID {1} → MonsterAppear 행 없음 (MobActor.cs:62 null → MyValue 에서 NullReference)", m.name, m.SpawnerID)); continue; } bound++; bool isZone = System.Array.IndexOf(kZoneIds, m.SpawnerID) >= 0; if (isZone) zoneHit++; int cnt = 0; int.TryParse(G(r, "n_MaxMonsterCount"), out cnt); var ids = G(r, "s_AppearMonster").Split('|'); string type = G(r, "e_SpawnerType"); int inst = (type == "Phase" || type == "PhaseAllKill") ? ids.Length * cnt : cnt * 2; alive += cnt; pool += inst; int miss = 0; for (int k = 0; k < ids.Length; k++) { System.Collections.Generic.Dictionary mr; if (!byMob.TryGetValue(ids[k], out mr)) { miss++; L(" [에러] MonsterList 에 없는 ID " + ids[k]); continue; } var path = string.Format(kMobFmt, G(mr, "s_MonsterPrefab")); if (UnityEditor.AssetDatabase.LoadAssetAtPath(path) == null) { miss++; L(" [에러] 프리팹 없음 " + path); } } mobMissing += miss; L(string.Format(" {0,-3} SpawnerID {1,-7} pos=({2,6:F1},{3,5:F2},{4,6:F1}) range {5} · {6,-12} {7,2}마리 · 종류 {8} · 인스턴스 {9}{10}", m.name, m.SpawnerID, p.x, p.y, p.z, m.MobGenRange, type, cnt, ids.Length, inst, isZone ? " ← 새 존" : "")); } L(string.Format(" 행 연결 {0} / 미연결 {1} · 새 존 {2}/5 · 몹 자산 누락 {3}", bound, unbound, zoneHit, mobMissing)); L(string.Format("── (e) 동시 생존 {0}마리 · 인스턴스 풀 {1}개 (MobControlMgr.Get_Mobs 규칙 재현)", alive, pool)); L(""); // ── (d) FieldBossData ─────────────────────────────────────────── var bosses = root.GetComponentsInChildren(true); L("── (d) FieldBossData " + bosses.Length + "기 ──"); var mapCfg = root.GetComponentInChildren(true); L(" MapData.m_ChapterPortal = " + (mapCfg == null ? "MapData 없음" : (mapCfg.m_ChapterPortal == null ? "null → isChapterBoss 는 반드시 0" : "있음")) + " · spawnRequireReachableFromPlayer = " + (mapCfg == null ? "?" : mapCfg.spawnRequireReachableFromPlayer.ToString())); for (int i = 0; i < bosses.Length; i++) { var b = bosses[i]; var p = b.transform.position; System.Collections.Generic.Dictionary mr, ar; bool hasMob = byMob.TryGetValue(b.n_MonsterID.ToString(), out mr); bool hasRow = byAppear.TryGetValue(b.n_SpawnerId.ToString(), out ar); string prefab = hasMob ? string.Format(kMobFmt, G(mr, "s_MonsterPrefab")) : "(MonsterList 행 없음)"; bool hasPrefab = hasMob && UnityEditor.AssetDatabase.LoadAssetAtPath(prefab) != null; L(string.Format(" {0} pos=({1:F1},{2:F1},{3:F1}) MonsterID {4} · SpawnerId {5} · Regen {6}s · isChapterBoss {7}", b.name, p.x, p.y, p.z, b.n_MonsterID, b.n_SpawnerId, b.RegenTime, b.isChapterBoss ? 1 : 0)); L(" MonsterList 행 " + (hasMob ? "O" : "[에러] X") + " · 프리팹 " + (hasPrefab ? "O " + prefab : "[에러] X " + prefab) + " · MonsterAppear 행 " + (hasRow ? "O" : "[에러] X")); if (hasMob) { float hpRate = 0f, atkRate = 0f, chase = 0f; float.TryParse(G(mr, "f_BaseHPRate"), out hpRate); float.TryParse(G(mr, "f_BaseATKRate"), out atkRate); float.TryParse(G(mr, "f_BaseChaseRange"), out chase); float hp = 0f, atk = 0f; if (hasRow) { float.TryParse(G(ar, "n_MonsterHP"), out hp); float.TryParse(G(ar, "n_MonsterATK"), out atk); } L(string.Format(" 실 HP = {0} × {1} = {2:N0} · 실 ATK = {3} × {4} = {5:N0} · 추적 사거리 {6} (MyValue.cs:370-371)", hpRate, hp, hpRate * hp, atkRate, atk, atkRate * atk, chase)); } if (b.isChapterBoss && mapCfg != null && mapCfg.m_ChapterPortal == null) L(" [에러] isChapterBoss=1 인데 m_ChapterPortal 이 null → AfterDie 에서 NullReference (FieldBossData.cs:47-49)"); } L(""); // ── (f) 아레나 NavMesh 도달성 ─────────────────────────────────── L("── (f) 아레나 NavMesh 도달성 (813b 미확인 ③) ──"); var navData = UnityEditor.AssetDatabase.LoadAssetAtPath(kNavAsset); if (navData == null) L(" [미확인] NavMeshData 없음: " + kNavAsset); else { var inst = UnityEngine.AI.NavMesh.AddNavMeshData(navData); try { var start = root.transform.Find("Portal_Start"); UnityEngine.Vector3 pcPos = start != null ? start.position : new UnityEngine.Vector3(115f, 0f, 10f); L(" 플레이어 시작 = " + (start != null ? "Portal_Start " : "(폴백 상수) ") + pcPos.ToString("F1")); UnityEngine.AI.NavMeshHit ph; bool pcOn = UnityEngine.AI.NavMesh.SamplePosition(pcPos, out ph, 5f, UnityEngine.AI.NavMesh.AllAreas); L(" 시작 지점 NavMesh 스냅 = " + (pcOn ? ph.position.ToString("F2") : "[에러] NavMesh 밖")); for (int i = 0; i < bosses.Length; i++) { var bp = bosses[i].transform.position; UnityEngine.AI.NavMeshHit bh; bool on = UnityEngine.AI.NavMesh.SamplePosition(bp, out bh, 5f, UnityEngine.AI.NavMesh.AllAreas); L(" 보스 " + bosses[i].name + " " + bp.ToString("F1") + " → NavMesh " + (on ? "O 스냅 " + bh.position.ToString("F2") + " (거리 " + UnityEngine.Vector3.Distance(bp, bh.position).ToString("F2") + "m)" : "[에러] X")); if (on && pcOn) { var path = new UnityEngine.AI.NavMeshPath(); bool okc = UnityEngine.AI.NavMesh.CalculatePath(ph.position, bh.position, UnityEngine.AI.NavMesh.AllAreas, path); float len = 0f; for (int k = 1; k < path.corners.Length; k++) len += UnityEngine.Vector3.Distance(path.corners[k - 1], path.corners[k]); L(" 시작→보스 경로 " + (okc ? path.status.ToString() : "CalculatePath 실패") + " · 코너 " + path.corners.Length + " · 경로장 " + len.ToString("F1") + "m · 직선 " + UnityEngine.Vector3.Distance(ph.position, bh.position).ToString("F1") + "m"); } } } finally { UnityEngine.AI.NavMesh.RemoveNavMeshData(inst); } } L(""); L("===== 요약: 새 행 " + newOk + "/6 · 스포너 연결 " + bound + "/" + mgrs.Length + " · 새 존 " + zoneHit + "/5 · 보스 " + bosses.Length + "기 · 몹 자산 누락 " + mobMissing + " ====="); return Flush(kEditLog); } // ══════════════════════════════════════════════════════════════════════ /// /// 아레나 좌표만 문제인지, 이 맵의 NavMesh 가 원래 섬으로 끊겨 있는지 가른다. /// 시작 지점 → 스포너 19 원점 · 보스 1 좌표의 CalculatePath 상태를 전부 찍는다. /// (MapData.cs:36 — WL_Start 는 NavMesh 위 1144점 중 37% 만 도달 가능하다는 실측이 있다) /// public static string Nav() { s = new System.Text.StringBuilder(); L("===== WL-813e NavMesh 도달성 스윕 (" + kNavAsset + ") ====="); var root = LoadMap(); var navData = UnityEditor.AssetDatabase.LoadAssetAtPath(kNavAsset); if (root == null || navData == null) { L("[에러] 프리팹 또는 NavMeshData 로드 실패"); return Flush(kEditLog); } var inst = UnityEngine.AI.NavMesh.AddNavMeshData(navData); try { var tri = UnityEngine.AI.NavMesh.CalculateTriangulation(); L("NavMesh 삼각형 " + (tri.indices.Length / 3) + " · 정점 " + tri.vertices.Length); var start = root.transform.Find("Portal_Start"); UnityEngine.Vector3 pcPos = start != null ? start.position : new UnityEngine.Vector3(115f, 0f, 10f); UnityEngine.AI.NavMeshHit ph; if (!UnityEngine.AI.NavMesh.SamplePosition(pcPos, out ph, 5f, UnityEngine.AI.NavMesh.AllAreas)) { L("[에러] 시작 지점이 NavMesh 밖"); return Flush(kEditLog); } L("시작 " + pcPos.ToString("F1") + " → 스냅 " + ph.position.ToString("F2")); L(""); int comp = 0, part = 0, invalid = 0; var targets = new System.Collections.Generic.List(); var pts = new System.Collections.Generic.List(); var mgrs = root.GetComponentsInChildren(true); for (int i = 0; i < mgrs.Length; i++) { targets.Add("스포너 " + mgrs[i].name + " (ID " + mgrs[i].SpawnerID + ")"); pts.Add(mgrs[i].transform.position); } var bs = root.GetComponentsInChildren(true); for (int i = 0; i < bs.Length; i++) { targets.Add("보스 " + bs[i].name + " (Mob " + bs[i].n_MonsterID + ")"); pts.Add(bs[i].transform.position); } for (int i = 0; i < pts.Count; i++) { UnityEngine.AI.NavMeshHit h; bool on = UnityEngine.AI.NavMesh.SamplePosition(pts[i], out h, 5f, UnityEngine.AI.NavMesh.AllAreas); string st = "NavMesh 밖"; float len = 0f; if (on) { var path = new UnityEngine.AI.NavMeshPath(); UnityEngine.AI.NavMesh.CalculatePath(ph.position, h.position, UnityEngine.AI.NavMesh.AllAreas, path); st = path.status.ToString(); for (int k = 1; k < path.corners.Length; k++) len += UnityEngine.Vector3.Distance(path.corners[k - 1], path.corners[k]); if (path.status == UnityEngine.AI.NavMeshPathStatus.PathComplete) comp++; else if (path.status == UnityEngine.AI.NavMeshPathStatus.PathPartial) part++; else invalid++; } else invalid++; L(string.Format(" {0,-28} {1,-22} 경로장 {2,6:F1}m · 직선 {3,6:F1}m · 스냅거리 {4:F2}m", targets[i], st, len, UnityEngine.Vector3.Distance(ph.position, pts[i]), on ? UnityEngine.Vector3.Distance(pts[i], h.position) : -1f)); } L(""); L("PathComplete " + comp + " · PathPartial " + part + " · 불가 " + invalid + " / " + pts.Count); } finally { UnityEngine.AI.NavMesh.RemoveNavMeshData(inst); } return Flush(kOutDir + "/ZONES_NAV.txt"); } // ══════════════════════════════════════════════════════════════════════ /// /// 아레나 후보 탐색 — 초안 후보A (137, −22) 주변 격자에서 /// (1) 시작 지점에서 PathComplete 이고 (2) SP1·SP4 원점에서 15m 이상 떨어진 점을 /// 후보A 로부터 가까운 순으로 찍는다. 초안의 "군집 간섭 없음" 조건을 유지한 최소 이동을 고른다. /// public static string Arena() { s = new System.Text.StringBuilder(); var root = LoadMap(); var navData = UnityEditor.AssetDatabase.LoadAssetAtPath(kNavAsset); if (root == null || navData == null) { L("[에러] 로드 실패"); return Flush(kOutDir + "/ZONES_ARENA.txt"); } var ideal = new UnityEngine.Vector3(137f, 0.6f, -22f); const float kStep = 2f, kSpan = 16f, kClear = 15f; var inst = UnityEngine.AI.NavMesh.AddNavMeshData(navData); try { var start = root.transform.Find("Portal_Start"); UnityEngine.Vector3 pcPos = start != null ? start.position : new UnityEngine.Vector3(115f, 0f, 10f); UnityEngine.AI.NavMeshHit ph; if (!UnityEngine.AI.NavMesh.SamplePosition(pcPos, out ph, 5f, UnityEngine.AI.NavMesh.AllAreas)) { L("[에러] 시작 지점이 NavMesh 밖"); return Flush(kOutDir + "/ZONES_ARENA.txt"); } var mgrs = root.GetComponentsInChildren(true); L("===== 아레나 후보 탐색 · 이상점 " + ideal.ToString("F1") + " · ±" + kSpan + "m / " + kStep + "m 격자 ====="); L("조건: 시작에서 PathComplete · 모든 스포너 원점에서 " + kClear + "m 이상 · MobGenRange 10 기준 여유 5m"); var res = new System.Collections.Generic.List(); var keys = new System.Collections.Generic.List(); for (float dx = -kSpan; dx <= kSpan; dx += kStep) for (float dz = -kSpan; dz <= kSpan; dz += kStep) { var q = new UnityEngine.Vector3(ideal.x + dx, ideal.y, ideal.z + dz); UnityEngine.AI.NavMeshHit h; if (!UnityEngine.AI.NavMesh.SamplePosition(q, out h, 3f, UnityEngine.AI.NavMesh.AllAreas)) continue; float nearest = 1e9f; string who = ""; for (int i = 0; i < mgrs.Length; i++) { float d = UnityEngine.Vector3.Distance(new UnityEngine.Vector3(h.position.x, 0f, h.position.z), new UnityEngine.Vector3(mgrs[i].transform.position.x, 0f, mgrs[i].transform.position.z)); if (d < nearest) { nearest = d; who = mgrs[i].name + "(ID " + mgrs[i].SpawnerID + ")"; } } if (nearest < kClear) continue; var path = new UnityEngine.AI.NavMeshPath(); UnityEngine.AI.NavMesh.CalculatePath(ph.position, h.position, UnityEngine.AI.NavMesh.AllAreas, path); if (path.status != UnityEngine.AI.NavMeshPathStatus.PathComplete) continue; float len = 0f; for (int k = 1; k < path.corners.Length; k++) len += UnityEngine.Vector3.Distance(path.corners[k - 1], path.corners[k]); float off = UnityEngine.Vector3.Distance(new UnityEngine.Vector3(ideal.x, 0f, ideal.z), new UnityEngine.Vector3(h.position.x, 0f, h.position.z)); keys.Add(off); res.Add(string.Format(" ({0,6:F1},{1,5:F2},{2,6:F1}) 후보A 로부터 {3,4:F1}m · 시작에서 경로 {4,5:F1}m · 최근접 스포너 {5,5:F1}m {6} · 스냅 {7:F2}m", h.position.x, h.position.y, h.position.z, off, len, nearest, who, UnityEngine.Vector3.Distance(q, h.position))); } var idx = new System.Collections.Generic.List(); for (int i = 0; i < res.Count; i++) idx.Add(i); idx.Sort(delegate (int a, int b) { return keys[a].CompareTo(keys[b]); }); L("후보 " + res.Count + "개 (가까운 순 상위 12)"); for (int i = 0; i < idx.Count && i < 12; i++) L(res[idx[i]]); } finally { UnityEngine.AI.NavMesh.RemoveNavMeshData(inst); } return Flush(kOutDir + "/ZONES_ARENA.txt"); } // ══════════════════════════════════════════════════════════════════════ // Play 슬롯 60s 실측 — 절두체 내 몹 수 · 처치 간격 · 보스 스폰 · fps // ══════════════════════════════════════════════════════════════════════ static System.Text.StringBuilder r; static bool s_on; static float s_t0, s_dur, s_next, s_lastKill, s_bossAt; static int s_ticks, s_frames, s_peakInView, s_sumInView, s_kills, s_peakAlive; static System.Collections.Generic.List s_gaps; static System.Collections.Generic.HashSet s_prev; static System.Collections.Generic.Dictionary s_killBySpawner; static float s_frameAcc; const float kWatchSeconds = 60f; const float kSample = 0.2f; static void R(string t) { r.AppendLine(t); UnityEngine.Debug.Log("[WL813e] " + t); } public static string Watch() { if (!UnityEngine.Application.isPlaying) return "[에러] Play 중이 아니다"; if (s_on) return "이미 감시 중 — 남은 " + (s_dur - (UnityEngine.Time.realtimeSinceStartup - s_t0)).ToString("F0") + "s"; r = new System.Text.StringBuilder(); s_gaps = new System.Collections.Generic.List(); s_prev = new System.Collections.Generic.HashSet(); s_killBySpawner = new System.Collections.Generic.Dictionary(); s_t0 = UnityEngine.Time.realtimeSinceStartup; s_dur = kWatchSeconds; s_next = 0f; s_lastKill = -1f; s_bossAt = -1f; s_ticks = 0; s_frames = 0; s_frameAcc = 0f; s_peakInView = 0; s_sumInView = 0; s_kills = 0; s_peakAlive = 0; s_on = true; UnityEditor.EditorApplication.update -= Tick; UnityEditor.EditorApplication.update += Tick; R("===== WL-813e Play 실측 시작 (" + kWatchSeconds + "s · " + kSample + "s 간격) ====="); R("맵 = " + UnityEngine.SceneManagement.SceneManager.GetActiveScene().name + " · PC = " + (MyValue.MyPC == null ? "null" : MyValue.MyPC.transform.position.ToString("F1"))); return "감시 시작 " + kWatchSeconds + "s — 끝나면 Result 로 결과를 받는다"; } static void Tick() { if (!s_on) { UnityEditor.EditorApplication.update -= Tick; return; } if (!UnityEngine.Application.isPlaying) { R("[중단] Play 가 멈췄다"); Stop(); return; } s_frames++; s_frameAcc += UnityEngine.Time.unscaledDeltaTime; float el = UnityEngine.Time.realtimeSinceStartup - s_t0; if (el < s_next) return; s_next = el + kSample; s_ticks++; var cam = UnityEngine.Camera.main; UnityEngine.Plane[] planes = null; if (cam != null) planes = UnityEngine.GeometryUtility.CalculateFrustumPlanes(cam); var mobs = UnityEngine.Object.FindObjectsByType(UnityEngine.FindObjectsInactive.Exclude, UnityEngine.FindObjectsSortMode.None); var now = new System.Collections.Generic.HashSet(); int inView = 0; for (int i = 0; i < mobs.Length; i++) { var m = mobs[i]; now.Add(m.GetInstanceID()); if (planes != null) { var b = new UnityEngine.Bounds(m.transform.position + UnityEngine.Vector3.up, UnityEngine.Vector3.one * 2f); if (UnityEngine.GeometryUtility.TestPlanesAABB(planes, b)) inView++; } // 보스 판별 = FieldBossData 가 넘긴 스포너 ID (MobActor.Get_Group() → m_SpawnerId · MobActor.cs:635) if (s_bossAt < 0f && m.Get_Group() == kBossRowId) { s_bossAt = el; R(string.Format(" [보스] t={0:F1}s {1} pos={2} HP={3:N0}/{4:N0}", el, m.name, m.transform.position.ToString("F1"), m.Get_HP(), m.Get_MaxHP())); } } if (mobs.Length > s_peakAlive) s_peakAlive = mobs.Length; if (inView > s_peakInView) s_peakInView = inView; s_sumInView += inView; // 처치 = 활성 몹 목록에서 사라진 인스턴스 (811b Killed 이벤트가 이 브랜치에 없어 쓰는 프록시) foreach (var id in s_prev) { if (now.Contains(id)) continue; s_kills++; if (s_lastKill >= 0f) s_gaps.Add(el - s_lastKill); s_lastKill = el; } s_prev = now; if (s_ticks % 25 == 0) R(string.Format(" t={0,4:F0}s 활성 {1,3} · 절두체 내 {2,2} · 누적 처치 {3,3} · fps {4:F1}", el, mobs.Length, inView, s_kills, s_frames / System.Math.Max(0.001f, s_frameAcc))); if (el >= s_dur) Stop(); } static void Stop() { s_on = false; UnityEditor.EditorApplication.update -= Tick; float el = UnityEngine.Time.realtimeSinceStartup - s_t0; R(""); R("===== 결과 (" + el.ToString("F1") + "s · 샘플 " + s_ticks + ") ====="); R("절두체 내 몹 수: 평균 " + (s_ticks > 0 ? ((float)s_sumInView / s_ticks) : 0f).ToString("F1") + " · 최대 " + s_peakInView + " (기준서 §B 요소1 = 8~15)"); R("동시 활성 몹(맵 전체) 최대 = " + s_peakAlive); float avg = 0f; for (int i = 0; i < s_gaps.Count; i++) avg += s_gaps[i]; if (s_gaps.Count > 0) avg /= s_gaps.Count; R("처치 " + s_kills + "회 · 처치 간격 평균 " + avg.ToString("F2") + "s · 표본 " + s_gaps.Count + " (기준서 §B 요소1 = ≤3s)"); R("보스 최초 관측 = " + (s_bossAt < 0f ? "관측 안 됨(절두체·거리 밖일 수 있다)" : s_bossAt.ToString("F1") + "s")); R("평균 fps = " + (s_frames / System.Math.Max(0.001f, s_frameAcc)).ToString("F1") + " (프레임 " + s_frames + ")"); try { System.IO.Directory.CreateDirectory(Abs(kOutDir)); System.IO.File.WriteAllText(Abs(kPlayLog), r.ToString(), new System.Text.UTF8Encoding(true)); } catch (System.Exception e) { UnityEngine.Debug.LogWarning("[WL813e] 로그 저장 실패: " + e.Message); } } public static string Result() { if (r == null) return "아직 감시한 적이 없다 (Watch 먼저)"; if (s_on) return "감시 중 — 남은 " + (s_dur - (UnityEngine.Time.realtimeSinceStartup - s_t0)).ToString("F0") + "s"; return r.ToString(); } public static string StopNow() { if (s_on) { Stop(); return "중단"; } return "감시 중이 아니다"; } }