// PD 지시 #757 — WL_Start 프리팹에 힘민지 방식 몬스터 스포너 19개를 배치한다. // 실행: unity command run_script --file AgentScripts/WL_MonsterSpawnerBuilder.cs --entry WL_MonsterSpawnerBuilder.Run --timeout 300 // CLAUDE.md §1 준수: using 금지 · 네임스페이스 풀어 쓰기 // // 방식 (힘민지 실측) // · 스포너 오브젝트 = Map_C01.prefab 의 maplv/1 (Transform + MobControlMgr, 자식 lv = TextMeshPro 바닥 라벨) 을 그대로 복제 // · 컨테이너 = WL_Start 루트 아래 'maplv' (루트에 MobControlMgrList 가 있어 GetComponentsInChildren 로 잡힌다) // · SpawnerID = 1~19 (MonsterAppear.json 기존 행 재사용 · PD 결정 #757) // · 위치 = 베이크된 NavMeshData(Assets/WL/Settings/WL_Start_NavMesh.asset) 위에서 최원점 표집(farthest-point sampling) // // 수치는 전부 데이터에서 읽는다 (C45): MobGenRange·회전 = Map_C01 템플릿 / CamRot = WL_Start MapData / 레벨·몬스터수 = MonsterAppear.json // // 롤백: WL_Start.prefab 의 'maplv' 자식을 통째로 삭제 (kRemoveOnly = true 로 실행해도 된다) public static class WL_MonsterSpawnerBuilder { // ── 실행 스위치 ──────────────────────────────────────────── const bool kDryRun = false; // true = 아무것도 쓰지 않고 계획만 기록 static bool kRemoveOnly = false; // true = 기존 maplv 만 제거하고 종료 (롤백용) // ── 경로 (#771 인자화 · 기본값 = WL_Start 그대로) ───────── const string kTemplatePrefab = "Assets/Res_Addr/Map/Map_C01.prefab"; const string kTemplatePath = "maplv/1"; // 템플릿 스포너 (SpawnerID 1) static string kTargetPrefab = "Assets/Res_Addr/Map/WL_Start.prefab"; static string kNavAsset = "Assets/WL/Settings/WL_Start_NavMesh.asset"; const string kAppearJson = "Assets/ResWork/Table/Export/MonsterAppear.json"; const string kContainerName = "maplv"; static string kLogPath = "AgentScripts/staging/WL_Monsters/BUILDER_LOG.txt"; // ── 배치 제약 (발주 #757) ────────────────────────────────── const int kSpawnerCount = 19; // PD 지정 const int kFirstSpawnerID = 1; // PD 지정 (1~19) static float kMinDistFromPC = 15f; // 플레이어 스폰 지점에서 최소 거리 static float kMinMutual = 12f; // 스포너 간 최소 거리 const float kFlatProbeRadius = 3f; // 근접 평탄도 확인 반경 const float kFlatMaxDrop = 2.0f; // 근접 반경 내 높이 편차 상한 const float kAreaProbeRadius = 8f; // MobGenRange 확보 확인 반경 (템플릿 MobGenRange 10 의 80%) const int kAreaProbeHits = 6; // 8방향 중 최소 통과 수 const float kAreaMaxDrop = 4.0f; // 확보 반경 내 높이 편차 상한 (절벽 배제) const float kWaterMargin = 0.5f; // 수면 위 최소 여유 static float kGridStep = 4f; // 후보 격자 간격 const float kLabelLift = 0.05f; // 바닥 라벨 z-fighting 여유 // 플레이 영역 한정 (#771 · <=0 이면 제한 없음 = 기존 동작) static float kAreaCX = 0f, kAreaCZ = 0f, kAreaHalf = -1f; static System.Text.StringBuilder s_log = new System.Text.StringBuilder(); static void ResetDefaults() { kRemoveOnly = false; kTargetPrefab = "Assets/Res_Addr/Map/WL_Start.prefab"; kNavAsset = "Assets/WL/Settings/WL_Start_NavMesh.asset"; kLogPath = "AgentScripts/staging/WL_Monsters/BUILDER_LOG.txt"; kMinDistFromPC = 15f; kMinMutual = 12f; kGridStep = 4f; kAreaCX = 0f; kAreaCZ = 0f; kAreaHalf = -1f; } // #771 인자화 진입점 — 임의의 맵 프리팹 · 플레이 영역 한정 · 안전거리 지정 public static object RunFor(string targetPrefab, string navAsset, string logPath, float minDistFromPC, float minMutual, float gridStep, float areaCX, float areaCZ, float areaHalf, bool removeOnly) { ResetDefaults(); kTargetPrefab = targetPrefab; kNavAsset = navAsset; kLogPath = logPath; kMinDistFromPC = minDistFromPC; kMinMutual = minMutual; kGridStep = gridStep; kAreaCX = areaCX; kAreaCZ = areaCZ; kAreaHalf = areaHalf; kRemoveOnly = removeOnly; Core(); return "done — log=" + kLogPath; } // 기존 진입점 — 기본값(WL_Start) 복원 후 실행 · 동작 무변경 public static void Run() { ResetDefaults(); Core(); } static void Core() { s_log.Length = 0; L("===== WL_MonsterSpawnerBuilder dryRun=" + kDryRun + " removeOnly=" + kRemoveOnly + " ====="); // ── 1. 템플릿 실측 ──────────────────────────────────── var srcRoot = UnityEditor.PrefabUtility.LoadPrefabContents(kTemplatePrefab); UnityEngine.GameObject template = null; float tplRange = 10f; try { var t = srcRoot.transform.Find(kTemplatePath); if (t == null) { E("템플릿을 찾지 못했다: " + kTemplatePath); Flush(); return; } template = t.gameObject; var mcm = template.GetComponent(); if (mcm == null) { E("템플릿에 MobControlMgr 이 없다"); Flush(); return; } tplRange = mcm.MobGenRange; L("템플릿 = " + kTemplatePrefab + " / " + kTemplatePath + " MobGenRange=" + tplRange + " layer=" + UnityEngine.LayerMask.LayerToName(template.layer) + " 자식lv=" + (mcm.text_lv == null ? "NULL" : mcm.text_lv.name)); // ── 2. 테이블 확인 (C45 — SOT 직접 읽기) ────────── var lv = new System.Collections.Generic.Dictionary(); var mc = new System.Collections.Generic.Dictionary(); ReadAppearTable(lv, mc); int missing = 0, totalMobs = 0; for (int id = kFirstSpawnerID; id < kFirstSpawnerID + kSpawnerCount; id++) { if (!lv.ContainsKey(id)) { E("MonsterAppear 에 SpawnerID " + id + " 행이 없다"); missing++; } else totalMobs += mc[id]; } if (missing > 0) { Flush(); return; } L("MonsterAppear 확인 OK — SpawnerID " + kFirstSpawnerID + "~" + (kFirstSpawnerID + kSpawnerCount - 1) + " · n_MaxMonsterCount 합계 = " + totalMobs); // ── 3. 대상 프리팹 열기 ─────────────────────────── var dstRoot = UnityEditor.PrefabUtility.LoadPrefabContents(kTargetPrefab); try { // 기존 컨테이너 제거 (재실행 가능하게) var old = dstRoot.transform.Find(kContainerName); if (old != null) { L("기존 '" + kContainerName + "' 발견 (자식 " + old.childCount + "개) → 제거"); if (!kDryRun) UnityEngine.Object.DestroyImmediate(old.gameObject); } if (kRemoveOnly) { if (!kDryRun) { UnityEditor.PrefabUtility.SaveAsPrefabAsset(dstRoot, kTargetPrefab); L("제거 후 저장 완료"); } Flush(); return; } // 스폰 지점 (LoadMapMgr 은 Portal_Start + Vector3.forward 에 세운다) var portal = dstRoot.transform.Find("Portal_Start"); if (portal == null) { E("Portal_Start 가 없다"); Flush(); return; } var pcSpawn = portal.position + UnityEngine.Vector3.forward; L("플레이어 스폰 지점 = " + pcSpawn.ToString("F3")); // 카메라 초기 yaw (바닥 라벨 방향 결정용) float camYaw = 180f; var md = dstRoot.GetComponent(); if (md != null && md.m_MapZoneData != null && md.m_MapZoneData.Count > 0) camYaw = md.m_MapZoneData[0].CamRot_Portal; L("MapData.CamRot_Portal = " + camYaw + " → 스포너 yaw 로 사용 (바닥 라벨이 초기 카메라에 정립)"); // 수면 (전역 수위가 아니라 개별 수역 — 바운즈 목록으로 판정) var water = FindWaterVolumes(dstRoot); // ── 4. NavMesh 후보 산출 ────────────────────── var navData = UnityEditor.AssetDatabase.LoadAssetAtPath(kNavAsset); if (navData == null) { E("NavMeshData 에셋 없음: " + kNavAsset); Flush(); return; } var navInst = UnityEngine.AI.NavMesh.AddNavMeshData(navData); System.Collections.Generic.List chosen; try { chosen = Choose(pcSpawn, water); } finally { UnityEngine.AI.NavMesh.RemoveNavMeshData(navInst); } if (chosen == null || chosen.Count < kSpawnerCount) { E("후보가 부족하다 — 선택 " + (chosen == null ? 0 : chosen.Count) + " / 필요 " + kSpawnerCount); Flush(); return; } // 스폰 지점에서 가까운 순 → SpawnerID 1..19 (Lv.1 이 가장 가깝다) chosen.Sort((a, b) => UnityEngine.Vector3.Distance(a, pcSpawn).CompareTo(UnityEngine.Vector3.Distance(b, pcSpawn))); L(""); L("── 배치표 (ID · 위치 · 스폰지점거리 · 최근접스포너 · Lv · 몬스터수 · MobGenRange) ──"); for (int i = 0; i < chosen.Count; i++) { int id = kFirstSpawnerID + i; float dPC = UnityEngine.Vector3.Distance(chosen[i], pcSpawn); float dMin = float.MaxValue; for (int j = 0; j < chosen.Count; j++) if (j != i) dMin = UnityEngine.Mathf.Min(dMin, UnityEngine.Vector3.Distance(chosen[i], chosen[j])); L(string.Format(" {0,2} | ({1,8:F2},{2,7:F2},{3,8:F2}) | PC {4,6:F1}m | 인접 {5,5:F1}m | Lv.{6,-3} | {7,2}마리 | range {8}", id, chosen[i].x, chosen[i].y, chosen[i].z, dPC, dMin, lv[id], mc[id], tplRange)); } L(""); if (kDryRun) { L("--- dryRun: 여기서 중단. kDryRun=false 로 재실행 ---"); Flush(); return; } // ── 5. 배치 ──────────────────────────────────── var container = new UnityEngine.GameObject(kContainerName); container.transform.SetParent(dstRoot.transform, false); container.layer = template.layer; for (int i = 0; i < chosen.Count; i++) { int id = kFirstSpawnerID + i; var clone = UnityEngine.Object.Instantiate(template, container.transform, false); clone.name = id.ToString(); clone.transform.position = chosen[i] + UnityEngine.Vector3.up * kLabelLift; clone.transform.rotation = UnityEngine.Quaternion.Euler(0f, camYaw, 0f); clone.transform.localScale = UnityEngine.Vector3.one; var comp = clone.GetComponent(); comp.SpawnerID = id; comp.MobGenRange = tplRange; if (comp.text_lv == null) { var lvt = clone.transform.Find("lv"); if (lvt != null) comp.text_lv = lvt.GetComponent(); W("#" + id + " text_lv 자동 복원 = " + (comp.text_lv == null ? "실패" : "성공")); } UnityEditor.EditorUtility.SetDirty(comp); } L("스포너 " + chosen.Count + "개 배치 완료 (컨테이너 '" + kContainerName + "')"); // 검증 var verify = dstRoot.GetComponentsInChildren(true); L("검증: GetComponentsInChildren(루트) = " + verify.Length + "개"); int nullLv = 0; for (int i = 0; i < verify.Length; i++) if (verify[i].text_lv == null) nullLv++; L("검증: text_lv null = " + nullLv + "개"); var lst = dstRoot.GetComponent(); L("검증: 루트 MobControlMgrList = " + (lst == null ? "없음 🔴" : "있음")); UnityEditor.PrefabUtility.SaveAsPrefabAsset(dstRoot, kTargetPrefab); L("프리팹 저장: " + kTargetPrefab); } finally { UnityEditor.PrefabUtility.UnloadPrefabContents(dstRoot); } } finally { UnityEditor.PrefabUtility.UnloadPrefabContents(srcRoot); } UnityEditor.AssetDatabase.SaveAssets(); UnityEditor.AssetDatabase.Refresh(); L("===== 완료 ====="); Flush(); } // ── 후보 산출 + 최원점 표집 ──────────────────────────────── static System.Collections.Generic.List Choose( UnityEngine.Vector3 pcSpawn, System.Collections.Generic.List water) { var tri = UnityEngine.AI.NavMesh.CalculateTriangulation(); L("NavMesh 삼각형 " + (tri.indices.Length / 3) + "개"); if (tri.vertices.Length == 0) { E("NavMesh 가 비어 있다"); return null; } 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]); } L("NavMesh 범위 min=" + min.ToString("F1") + " max=" + max.ToString("F1")); // 경로 판정 기준점은 NavMesh 에 스냅한 실제 플레이어 위치 UnityEngine.AI.NavMeshHit pcHit; if (!UnityEngine.AI.NavMesh.SamplePosition(pcSpawn, out pcHit, 5f, UnityEngine.AI.NavMesh.AllAreas)) { E("플레이어 스폰 지점이 NavMesh 위가 아니다"); return null; } var pcOnNav = pcHit.position; L("스폰 지점 NavMesh 스냅 = " + pcOnNav.ToString("F3") + " (원점에서 " + UnityEngine.Vector3.Distance(pcSpawn, pcOnNav).ToString("F2") + "m)"); // 격자 표집 — 삼각형 무게중심보다 훨씬 고르게 섬 전역을 덮는다 // 플레이 영역 한정 (#771) if (kAreaHalf > 0f) { min.x = UnityEngine.Mathf.Max(min.x, kAreaCX - kAreaHalf); max.x = UnityEngine.Mathf.Min(max.x, kAreaCX + kAreaHalf); min.z = UnityEngine.Mathf.Max(min.z, kAreaCZ - kAreaHalf); max.z = UnityEngine.Mathf.Min(max.z, kAreaCZ + kAreaHalf); L("플레이 영역 한정: x " + min.x.ToString("F1") + "~" + max.x.ToString("F1") + " / z " + min.z.ToString("F1") + "~" + max.z.ToString("F1")); } var cand = new System.Collections.Generic.List(); int nGrid = 0, rejOff = 0, rejWater = 0, rejPC = 0, rejFlat = 0, rejArea = 0, rejPath = 0, nReach = 0, nOnNav = 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)) { rejOff++; continue; } var p = h.position; if (UnityEngine.Mathf.Abs(p.x - x) > kGridStep || UnityEngine.Mathf.Abs(p.z - z) > kGridStep) { rejOff++; continue; } nOnNav++; bool reach = Reachable(pcOnNav, p); if (reach) nReach++; if (InWater(p, water)) { rejWater++; continue; } if (UnityEngine.Vector3.Distance(p, pcSpawn) < kMinDistFromPC) { rejPC++; continue; } if (!Flat(p, kFlatProbeRadius, 8, 8, kFlatMaxDrop)) { rejFlat++; continue; } if (!Flat(p, kAreaProbeRadius, 8, kAreaProbeHits, kAreaMaxDrop)) { rejArea++; continue; } if (!reach) { rejPath++; continue; } // 플레이어 스폰에서 도달 가능해야 한다 cand.Add(p); } L("격자 " + nGrid + "점(" + kGridStep + "m) → 후보 " + cand.Count + "개 (제외: NavMesh밖 " + rejOff + " · 수중 " + rejWater + " · 스폰근접 " + rejPC + " · 비평탄 " + rejFlat + " · 협소 " + rejArea + " · 경로없음 " + rejPath + ")"); L("연결성 실측: NavMesh 위 격자점 " + nOnNav + "개 중 스폰 지점에서 도달 가능 " + nReach + "개 (" + (nOnNav == 0 ? 0 : nReach * 100 / nOnNav) + "%)"); if (cand.Count == 0) return null; // 최원점 표집: 플레이어 스폰에서 가장 먼 후보로 시작해, 매번 기선택집합과의 최소거리가 최대인 후보를 추가 var sel = new System.Collections.Generic.List(); int first = 0; float best = -1f; for (int i = 0; i < cand.Count; i++) { float d = UnityEngine.Vector3.Distance(cand[i], pcSpawn); if (d > best) { best = d; first = i; } } sel.Add(cand[first]); while (sel.Count < kSpawnerCount) { int pick = -1; float pickD = -1f; for (int i = 0; i < cand.Count; i++) { float dmin = float.MaxValue; for (int j = 0; j < sel.Count; j++) dmin = UnityEngine.Mathf.Min(dmin, UnityEngine.Vector3.Distance(cand[i], sel[j])); if (dmin > pickD) { pickD = dmin; pick = i; } } if (pick < 0 || pickD < kMinMutual) { W("최원점 표집 중단 — 남은 최대 이격 " + pickD.ToString("F1") + "m < 하한 " + kMinMutual + "m (선택 " + sel.Count + "개)"); break; } sel.Add(cand[pick]); } L("최원점 표집 결과 " + sel.Count + "개"); return sel; } // 플레이어 스폰 지점에서 완전 경로가 나오는지 (고립된 섬·바위 배제) 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; } // 반경 r 의 8방향 표본 중 minHits 이상이 NavMesh 위이고 높이 편차가 maxDrop 이하이면 true static bool Flat(UnityEngine.Vector3 p, float r, int rays, int minHits, float maxDrop) { int hits = 0; float ymin = p.y, ymax = p.y; for (int k = 0; k < rays; k++) { float ang = k * (360f / rays) * UnityEngine.Mathf.Deg2Rad; var q = p + new UnityEngine.Vector3(UnityEngine.Mathf.Cos(ang) * r, 0f, UnityEngine.Mathf.Sin(ang) * r); UnityEngine.AI.NavMeshHit h; if (!UnityEngine.AI.NavMesh.SamplePosition(q, out h, 1.5f, UnityEngine.AI.NavMesh.AllAreas)) continue; hits++; if (h.position.y < ymin) ymin = h.position.y; if (h.position.y > ymax) ymax = h.position.y; } return hits >= minHits && (ymax - ymin) <= maxDrop; } // ── 수역 실측 (전역 수위가 아니라 개별 렌더러 바운즈) ─────── static System.Collections.Generic.List FindWaterVolumes(UnityEngine.GameObject root) { var list = new System.Collections.Generic.List(); var rs = root.GetComponentsInChildren(true); for (int i = 0; i < rs.Length; i++) { var mats = rs[i].sharedMaterials; bool isWater = false; for (int m = 0; m < mats.Length; m++) { if (mats[m] == null || mats[m].shader == null) continue; if (mats[m].shader.name.ToLowerInvariant().Contains("water") || mats[m].name.ToLowerInvariant().Contains("water")) { isWater = true; break; } } if (!isWater) continue; list.Add(rs[i].bounds); L(" 수역: " + rs[i].name + " center=" + rs[i].bounds.center.ToString("F1") + " size=" + rs[i].bounds.size.ToString("F1") + " topY=" + rs[i].bounds.max.y.ToString("F2")); } if (list.Count == 0) W("수역 렌더러를 찾지 못했다 — 수중 판정을 건너뛴다"); else L("수역 렌더러 " + list.Count + "개 (여유 " + kWaterMargin + "m)"); return list; } // 후보가 어떤 수역의 XZ 범위 안이고 그 수면보다 낮으면 수중으로 본다 static bool InWater(UnityEngine.Vector3 p, System.Collections.Generic.List water) { for (int i = 0; i < water.Count; i++) { var b = water[i]; if (p.x < b.min.x || p.x > b.max.x || p.z < b.min.z || p.z > b.max.z) continue; if (p.y < b.max.y + kWaterMargin) return true; } return false; } // ── MonsterAppear.json 직접 파싱 (C45) ───────────────────── static void ReadAppearTable(System.Collections.Generic.Dictionary lv, System.Collections.Generic.Dictionary maxCount) { if (!System.IO.File.Exists(kAppearJson)) { E("테이블 없음: " + kAppearJson); return; } var txt = System.IO.File.ReadAllText(kAppearJson); // 행 단위로 n_SpawnerID / n_AppearMonsterLv / n_MaxMonsterCount 를 뽑는다 var rx = new System.Text.RegularExpressions.Regex( "\"n_SpawnerID\"\\s*:\\s*\"?(-?\\d+)\"?.*?\"n_AppearMonsterLv\"\\s*:\\s*\"?(-?\\d+)\"?.*?\"n_MaxMonsterCount\"\\s*:\\s*\"?(-?\\d+)\"?", System.Text.RegularExpressions.RegexOptions.Singleline); int idx = 0; while (true) { var m = rx.Match(txt, idx); if (!m.Success) break; int id = int.Parse(m.Groups[1].Value); if (!lv.ContainsKey(id)) { lv[id] = m.Groups[2].Value; maxCount[id] = int.Parse(m.Groups[3].Value); } idx = m.Index + 1; } L("MonsterAppear 파싱: " + lv.Count + "행"); } // ── 로그 ─────────────────────────────────────────────────── static void L(string s) { s_log.AppendLine(s); } static void W(string s) { s_log.AppendLine("[경고] " + s); } static void E(string s) { s_log.AppendLine("[오류] " + s); } static void Flush() { var dir = System.IO.Path.GetDirectoryName(kLogPath); if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir); System.IO.File.WriteAllText(kLogPath, s_log.ToString()); UnityEngine.Debug.Log("[SpawnerBuilder] 로그: " + kLogPath); } }