// PD 지시 #764 — 몬스터 스폰/이동 지형 실측 프로브 (읽기 전용) // // 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기) // 에디트 모드 스폰 도달성 + NavMesh 섬 통계 // unity command run_script --file AgentScripts/WL764_Probe.cs --entry WL764_Probe.Edit --timeout 300 // Play 중 몹 이탈 감시 시작 / 상태 / 결과 // unity command run_script --file AgentScripts/WL764_Probe.cs --entry WL764_Probe.Watch // unity command run_script --file AgentScripts/WL764_Probe.cs --entry WL764_Probe.Status // unity command run_script --file AgentScripts/WL764_Probe.cs --entry WL764_Probe.Dump // // 수치는 전부 프로젝트 데이터에서 읽는다 (C45): // 스포너 위치·MobGenRange = WL_Start.prefab / 플레이어 시작 지점 = MapData.m_MapZoneData[].go_portal (+Vector3.forward) // NavMesh = Assets/WL/Settings/WL_Start_NavMesh.asset // 프로브 자체의 격자 간격·표집 수는 "측정 해상도"이지 게임 데이터가 아니다. public static class WL764_Probe { const string kTargetPrefab = "Assets/Res_Addr/Map/WL_Start.prefab"; const string kNavAsset = "Assets/WL/Settings/WL_Start_NavMesh.asset"; const string kEditLog = "AgentScripts/staging/WL_Spawn/EDIT_PROBE.txt"; const string kRunLog = "AgentScripts/staging/WL_Spawn/RUNTIME_PROBE.txt"; // 측정 해상도 (게임 데이터 아님) const float kGridStep = 4f; // 섬 통계 격자 const int kSamplesPerSpawner = 30; const int kSeed = 764; const float kOffMeshRadius = 0.3f; // 발주서 지정 — 이 반경 안에 NavMesh 가 없으면 "이탈" static System.Text.StringBuilder s; // ───────────────────────────────────────────────────────────────────── // 1. 에디트 모드: 스포너별 도달률 + 섬 통계 // ───────────────────────────────────────────────────────────────────── public static string Edit() { s = new System.Text.StringBuilder(); L("===== WL764 에디트 모드 실측 ====="); L("playMode=" + UnityEngine.Application.isPlaying); var navData = UnityEditor.AssetDatabase.LoadAssetAtPath(kNavAsset); if (navData == null) { L("[에러] NavMeshData 없음: " + kNavAsset); return Flush(kEditLog); } var root = UnityEditor.PrefabUtility.LoadPrefabContents(kTargetPrefab); var navInst = UnityEngine.AI.NavMesh.AddNavMeshData(navData); try { // 플레이어 시작 지점 — MapData 의 포탈 (런타임 LoadMapMgr.Get_PortalPos 와 동일 식) var md = root.GetComponent(); UnityEngine.Vector3 pcSpawn; if (md != null && md.m_MapZoneData != null && md.m_MapZoneData.Count > 0 && md.m_MapZoneData[0].go_portal != null) { pcSpawn = md.m_MapZoneData[0].go_portal.transform.position + UnityEngine.Vector3.forward; L("플레이어 시작 = MapData.m_MapZoneData[0].go_portal(" + md.m_MapZoneData[0].go_portal.name + ") + forward = " + pcSpawn.ToString("F2")); } else { var p = root.transform.Find("Portal_Start"); if (p == null) { L("[에러] 포탈을 찾지 못했다"); return Flush(kEditLog); } pcSpawn = p.position + UnityEngine.Vector3.forward; L("플레이어 시작 = Portal_Start + forward = " + pcSpawn.ToString("F2") + " (MapData 경로 실패 → 폴백)"); } UnityEngine.AI.NavMeshHit pcHit; if (!UnityEngine.AI.NavMesh.SamplePosition(pcSpawn, out pcHit, 5f, UnityEngine.AI.NavMesh.AllAreas)) { L("[에러] 시작 지점이 NavMesh 위가 아니다"); return Flush(kEditLog); } var pcOnNav = pcHit.position; L("시작 지점 NavMesh 스냅 = " + pcOnNav.ToString("F2")); // ── (d) 섬 통계 ────────────────────────────────────────────── IslandStats(pcOnNav); // ── (a) 스포너별 도달률 ────────────────────────────────────── var mgrs = root.GetComponentsInChildren(true); L(""); L("── (a) 스포너 " + mgrs.Length + "개 · 스포너당 " + kSamplesPerSpawner + "점 표집 ──"); L(" ID | 스포너위치 | range | 스포너도달 | 현행표집 도달 | 신방식 도달 | 표집반경초과"); System.Array.Sort(mgrs, delegate (MobControlMgr a, MobControlMgr b) { return a.SpawnerID.CompareTo(b.SpawnerID); }); int totBadOld = 0, totBadNew = 0, badSpawners = 0, overRange = 0, totFallback = 0; float sumSpread = 0f; for (int i = 0; i < mgrs.Length; i++) { var m = mgrs[i]; var org = m.transform.position; bool spawnerReach = Reachable(pcOnNav, org); if (!spawnerReach) badSpawners++; UnityEngine.Random.InitState(kSeed + m.SpawnerID); int okOld = 0, over = 0; for (int k = 0; k < kSamplesPerSpawner; k++) { var p = DSUtil.Get_RandomPos_onNavMesh(org, 0f, m.MobGenRange); if (Reachable(pcOnNav, p)) okOld++; if (UnityEngine.Vector3.Distance(org, p) > m.MobGenRange + 0.01f) over++; } overRange += over; UnityEngine.Random.InitState(kSeed + m.SpawnerID); int okNew = 0, fallback = 0; float sumD = 0f; for (int k = 0; k < kSamplesPerSpawner; k++) { s_fellBack = false; var p = SampleStrict(org, m.MobGenRange, pcOnNav); if (s_fellBack) fallback++; sumD += UnityEngine.Vector3.Distance(org, p); if (Reachable(pcOnNav, p)) okNew++; } totFallback += fallback; sumSpread += sumD / kSamplesPerSpawner; totBadOld += (kSamplesPerSpawner - okOld); totBadNew += (kSamplesPerSpawner - okNew); L(string.Format(" {0,2} | ({1,8:F1},{2,6:F1},{3,8:F1}) | {4,5:F1} | {5,-6} | {6,3}/{7} ({8,3}%) | {9,3}/{10} ({11,3}%) | {12,2}", m.SpawnerID, org.x, org.y, org.z, m.MobGenRange, spawnerReach ? "OK" : "🔴불가", okOld, kSamplesPerSpawner, okOld * 100 / kSamplesPerSpawner, okNew, kSamplesPerSpawner, okNew * 100 / kSamplesPerSpawner, over)); } L(""); L("합계: 스포너 원점 도달불가 " + badSpawners + "/" + mgrs.Length + " · 현행 표집 도달불가 " + totBadOld + "/" + (mgrs.Length * kSamplesPerSpawner) + " (" + Pct(totBadOld, mgrs.Length * kSamplesPerSpawner) + "%)" + " · 신방식 도달불가 " + totBadNew + "/" + (mgrs.Length * kSamplesPerSpawner) + " (" + Pct(totBadNew, mgrs.Length * kSamplesPerSpawner) + "%)"); L("표집점이 MobGenRange 를 벗어난 횟수(현행) = " + overRange + " — SamplePosition 의 스냅 반경이 MobGenRange 라 최대 2×range 까지 튄다"); L("신방식 산포: 원점 폴백 " + totFallback + "/" + (mgrs.Length * kSamplesPerSpawner) + " · 스포너별 평균 이격 " + (sumSpread / mgrs.Length).ToString("F2") + "m (MobGenRange 10 기준 · 몹이 원점에 뭉치지 않는지 확인)"); } finally { UnityEngine.AI.NavMesh.RemoveNavMeshData(navInst); UnityEditor.PrefabUtility.UnloadPrefabContents(root); } return Flush(kEditLog); } static void IslandStats(UnityEngine.Vector3 pcOnNav) { var tri = UnityEngine.AI.NavMesh.CalculateTriangulation(); if (tri.vertices.Length == 0) { L("[에러] NavMesh 가 비어 있다"); return; } var min = tri.vertices[0]; var max = tri.vertices[0]; for (int i = 1; i < tri.vertices.Length; i++) { min = UnityEngine.Vector3.Min(min, tri.vertices[i]); max = UnityEngine.Vector3.Max(max, tri.vertices[i]); } int nGrid = 0, nOnNav = 0, nReach = 0; for (float x = min.x; x <= max.x; x += kGridStep) for (float z = min.z; z <= max.z; z += kGridStep) { nGrid++; UnityEngine.AI.NavMeshHit h; var probe = new UnityEngine.Vector3(x, (min.y + max.y) * 0.5f, z); if (!UnityEngine.AI.NavMesh.SamplePosition(probe, out h, (max.y - min.y) * 0.5f + kGridStep, UnityEngine.AI.NavMesh.AllAreas)) continue; if (UnityEngine.Mathf.Abs(h.position.x - x) > kGridStep || UnityEngine.Mathf.Abs(h.position.z - z) > kGridStep) continue; nOnNav++; if (Reachable(pcOnNav, h.position)) nReach++; } L(""); L("── (d) NavMesh 섬 통계 (" + kGridStep + "m 격자) ──"); L(" 삼각형 " + (tri.indices.Length / 3) + "개 · 범위 min=" + min.ToString("F0") + " max=" + max.ToString("F0")); L(" 격자 " + nGrid + "점 → NavMesh 위 " + nOnNav + "점 → 시작 지점에서 도달 가능 " + nReach + "점 (" + Pct(nReach, nOnNav) + "%) · 도달 불가 " + (nOnNav - nReach) + "점 (" + Pct(nOnNav - nReach, nOnNav) + "%)"); } static bool s_fellBack; /// 신방식 표집 — 스냅 반경을 좁히고 완전 경로(PathComplete)를 요구한다. MobControlMgr 에 넣을 로직과 동일. public static UnityEngine.Vector3 SampleStrict(UnityEngine.Vector3 origin, float range, UnityEngine.Vector3 reachFrom) { float snap = range * 0.2f; // Edit 프로브 기본 — 실제 값은 MapData.spawnSnapRadius for (int i = 0; i < 50; i++) { var dir = UnityEngine.Random.insideUnitSphere * range + origin; UnityEngine.AI.NavMeshHit hit; if (!UnityEngine.AI.NavMesh.SamplePosition(dir, out hit, snap, UnityEngine.AI.NavMesh.AllAreas)) continue; if (UnityEngine.Vector3.Distance(origin, hit.position) > range) continue; if (!Reachable(origin, hit.position)) continue; if (!Reachable(reachFrom, hit.position)) continue; return hit.position; } s_fellBack = true; return origin; } static bool Reachable(UnityEngine.Vector3 from, UnityEngine.Vector3 to) { var path = new UnityEngine.AI.NavMeshPath(); if (!UnityEngine.AI.NavMesh.CalculatePath(from, to, UnityEngine.AI.NavMesh.AllAreas, path)) return false; return path.status == UnityEngine.AI.NavMeshPathStatus.PathComplete; } // ───────────────────────────────────────────────────────────────────── // 2. Play 모드: 몹 이탈 감시 // ───────────────────────────────────────────────────────────────────── static bool s_watching; static float s_next, s_t0, s_dur; static int s_ticks; static System.Text.StringBuilder s_run; static System.Collections.Generic.Dictionary s_offCount; // instanceID → 이탈 틱 수 static System.Collections.Generic.Dictionary s_offName; static int s_peakMobs, s_offEvents; const float kWatchSeconds = 60f; // 발주서 지정 감시 시간(측정 조건) public static string Watch() { float seconds = kWatchSeconds; if (!UnityEngine.Application.isPlaying) return "[에러] Play 중이 아니다"; if (s_watching) return "이미 감시 중 — 남은 " + (s_dur - (UnityEngine.Time.realtimeSinceStartup - s_t0)).ToString("F0") + "s"; s_run = new System.Text.StringBuilder(); s_offCount = new System.Collections.Generic.Dictionary(); s_offName = new System.Collections.Generic.Dictionary(); s_peakMobs = 0; s_offEvents = 0; s_ticks = 0; s_t0 = UnityEngine.Time.realtimeSinceStartup; s_dur = seconds; s_next = 0f; s_watching = true; UnityEditor.EditorApplication.update -= Tick; UnityEditor.EditorApplication.update += Tick; R("===== 런타임 이탈 감시 시작 (" + seconds + "s · 1s 간격 · SamplePosition 반경 " + kOffMeshRadius + ") ====="); ReachSweep("시작"); return "감시 시작 " + seconds + "s"; } static void Tick() { if (!s_watching) { UnityEditor.EditorApplication.update -= Tick; return; } if (!UnityEngine.Application.isPlaying) { R("[중단] Play 가 멈췄다"); StopWatch(); return; } float el = UnityEngine.Time.realtimeSinceStartup - s_t0; if (el < s_next) return; s_next = el + 1f; s_ticks++; var mobs = UnityEngine.Object.FindObjectsByType(UnityEngine.FindObjectsInactive.Exclude, UnityEngine.FindObjectsSortMode.None); if (mobs.Length > s_peakMobs) s_peakMobs = mobs.Length; int off = 0; for (int i = 0; i < mobs.Length; i++) { var m = mobs[i]; var p = m.transform.position; UnityEngine.AI.NavMeshHit h; if (UnityEngine.AI.NavMesh.SamplePosition(p, out h, kOffMeshRadius, UnityEngine.AI.NavMesh.AllAreas)) continue; off++; s_offEvents++; int id = m.GetInstanceID(); if (!s_offCount.ContainsKey(id)) { s_offCount[id] = 0; s_offName[id] = m.name; } s_offCount[id]++; if (s_offCount[id] <= 3) // 몹당 3회까지만 상세 기록 { var ag = m.GetComponent(); R(string.Format(" [이탈] t={0,5:F1}s {1} pos=({2:F1},{3:F1},{4:F1}) agent.enabled={5} isOnNavMesh={6} CC={7}", el, m.name, p.x, p.y, p.z, ag == null ? "null" : ag.enabled.ToString(), ag == null ? "null" : (ag.enabled ? ag.isOnNavMesh.ToString() : "-"), m.IsCC())); } } if (s_ticks % 10 == 0) R(" t=" + el.ToString("F0") + "s 활성몹=" + mobs.Length + " 이탈=" + off); if (el >= s_dur) { ReachSweep("종료"); StopWatch(); } } static void ReachSweep(string tag) { var pc = MyValue.MyPC; if (pc == null) { R("[" + tag + "] PC 없음 — 도달성 스윕 생략"); return; } var pcPos = pc.transform.position; UnityEngine.AI.NavMeshHit ph; if (!UnityEngine.AI.NavMesh.SamplePosition(pcPos, out ph, 3f, UnityEngine.AI.NavMesh.AllAreas)) { R("[" + tag + "] PC 가 NavMesh 위가 아니다 pos=" + pcPos.ToString("F1")); return; } var mobs = UnityEngine.Object.FindObjectsByType(UnityEngine.FindObjectsInactive.Exclude, UnityEngine.FindObjectsSortMode.None); int bad = 0; var names = new System.Text.StringBuilder(); for (int i = 0; i < mobs.Length; i++) { if (Reachable(ph.position, mobs[i].transform.position)) continue; bad++; if (bad <= 15) { if (names.Length > 0) names.Append(", "); names.Append(mobs[i].name + mobs[i].transform.position.ToString("F0")); } } R("[" + tag + "] PC=" + ph.position.ToString("F1") + " 활성몹 " + mobs.Length + " 중 도달 불가 " + bad + " (" + Pct(bad, mobs.Length) + "%)" + (bad > 0 ? " 예: " + names : "")); } static void StopWatch() { s_watching = false; UnityEditor.EditorApplication.update -= Tick; R(""); R("===== 감시 종료 · 틱 " + s_ticks + " · 최대 활성몹 " + s_peakMobs + " · 이탈 이벤트 " + s_offEvents + " · 이탈 몹 " + (s_offCount == null ? 0 : s_offCount.Count) + "종 ====="); if (s_offCount != null) foreach (var kv in s_offCount) R(" 이탈몹 " + s_offName[kv.Key] + " ×" + kv.Value + "틱"); WriteFile(kRunLog, s_run.ToString()); } public static string Stop() { if (s_watching) StopWatch(); return "중지"; } /// 즉시 1회 측정 — 활성 몹 전원의 (a) PC 로부터의 도달성 (b) NavMesh 이탈 여부. public static string Sweep() { if (!UnityEngine.Application.isPlaying) return "[에러] Play 중이 아니다"; var pc = MyValue.MyPC; if (pc == null) return "[에러] PC 없음"; UnityEngine.AI.NavMeshHit ph; if (!UnityEngine.AI.NavMesh.SamplePosition(pc.transform.position, out ph, 3f, UnityEngine.AI.NavMesh.AllAreas)) return "[에러] PC 가 NavMesh 위가 아니다 " + pc.transform.position.ToString("F1"); var mobs = UnityEngine.Object.FindObjectsByType(UnityEngine.FindObjectsInactive.Exclude, UnityEngine.FindObjectsSortMode.None); int unreach = 0, off = 0; var bad = new System.Text.StringBuilder(); for (int i = 0; i < mobs.Length; i++) { var p = mobs[i].transform.position; UnityEngine.AI.NavMeshHit h; if (!UnityEngine.AI.NavMesh.SamplePosition(p, out h, kOffMeshRadius, UnityEngine.AI.NavMesh.AllAreas)) off++; if (Reachable(ph.position, p)) continue; unreach++; if (unreach <= 12) { if (bad.Length > 0) bad.Append(", "); bad.Append(mobs[i].name + p.ToString("F0")); } } return "활성몹 " + mobs.Length + " · PC(" + ph.position.ToString("F1") + ") 에서 도달 불가 " + unreach + " (" + Pct(unreach, mobs.Length) + "%) · NavMesh 이탈(" + kOffMeshRadius + "m) " + off + (unreach > 0 ? " · 예: " + bad : ""); } public static string Status() { if (!s_watching) return "감시 중 아님 (틱 " + s_ticks + " · 이탈 " + s_offEvents + ")"; float el = UnityEngine.Time.realtimeSinceStartup - s_t0; var mobs = UnityEngine.Object.FindObjectsByType(UnityEngine.FindObjectsInactive.Exclude, UnityEngine.FindObjectsSortMode.None); return "감시 중 " + el.ToString("F0") + "/" + s_dur + "s · 틱 " + s_ticks + " · 현재 활성몹 " + mobs.Length + " · 누적 이탈 " + s_offEvents + " (" + (s_offCount == null ? 0 : s_offCount.Count) + "종)"; } public static string Dump() { if (s_run == null) return "기록 없음"; WriteFile(kRunLog, s_run.ToString()); return s_run.ToString(); } /// #763 펫 판정 — 활성 PetActor 수와 펫 카드 상태. public static string PetCheck() { var sb = new System.Text.StringBuilder(); var all = UnityEngine.Object.FindObjectsByType(UnityEngine.FindObjectsInactive.Include, UnityEngine.FindObjectsSortMode.None); var act = UnityEngine.Object.FindObjectsByType(UnityEngine.FindObjectsInactive.Exclude, UnityEngine.FindObjectsSortMode.None); sb.Append("PetActor 전체=" + all.Length + " 활성=" + act.Length); for (int i = 0; i < all.Length; i++) sb.Append(" | " + all[i].name + " active=" + all[i].gameObject.activeInHierarchy); var pets = ActorInfo.Ins != null ? ActorInfo.Ins.Get_Actors(eRole.Pet, 0, true) : null; sb.Append(" || ActorInfo eRole.Pet(includeDie) = " + (pets == null ? "n/a" : pets.Count.ToString())); // 계정에 실제로 펫이 장착돼 있는지 — 장착 0 이면 이 검증은 무의미하다 if (ServerInfo.Ins != null && ServerInfo.Ins.m_ServerData != null) { int equipped = ServerInfo.Ins.m_ServerData.Get_EqiupmentEquip(eItem.Pet); sb.Append(" || 장착 펫ID = " + equipped + (equipped > 0 ? " (장착됨 — 차단이 실제로 동작해야 함)" : " (미장착 🔴 검증 무의미)")); } // 펫 카드 UI 상태 if (NewGameUI.isIns && NewGameUI.Ins.arr_PetCard != null && NewGameUI.Ins.arr_PetCard.Length > 0) { var card = NewGameUI.Ins.arr_PetCard[0]; sb.Append(" || arr_PetCard[0] = " + (card == null ? "null" : card.name + " active=" + card.gameObject.activeInHierarchy)); } else sb.Append(" || NewGameUI/arr_PetCard 없음"); sb.Append(" || MyPC.Get_MyPet() = " + (MyValue.MyPC == null ? "PC없음" : (MyValue.MyPC.Get_MyPet() == null ? "null(OK)" : MyValue.MyPC.Get_MyPet().name))); // 설정 에셋은 리플렉션으로 읽는다 — 이 프로브가 WLGameplaySettings 존재 여부와 무관하게 컴파일되도록. var t = System.Type.GetType("WL.Settings.WLGameplaySettings, Assembly-CSharp"); if (t == null) sb.Append(" || WLGameplaySettings 타입 없음"); else { var inst = t.GetProperty("Instance", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static).GetValue(null, null); if (inst == null) sb.Append(" || WLGameplaySettings 에셋 없음"); else sb.Append(" || WLGameplaySettings.spawnPlayerPet=" + t.GetField("spawnPlayerPet").GetValue(inst)); } return sb.ToString(); } // ───────────────────────────────────────────────────────────────────── static string Pct(int a, int b) { return b == 0 ? "0" : (a * 100 / b).ToString(); } static void L(string t) { s.Append(t); s.Append("\n"); UnityEngine.Debug.Log("[WL764] " + t); } static void R(string t) { s_run.Append(t); s_run.Append("\n"); } static string Flush(string path) { WriteFile(path, s.ToString()); return s.ToString(); } static void WriteFile(string path, string body) { var full = System.IO.Path.Combine(System.IO.Directory.GetCurrentDirectory(), path); var dir = System.IO.Path.GetDirectoryName(full); if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir); System.IO.File.WriteAllText(full, body, new System.Text.UTF8Encoding(true)); } }