Project_WL/AgentScripts/WL_MonsterSpawnerBuilder.cs

415 lines
23 KiB
C#
Raw Permalink Normal View History

WL 리뉴얼 세션 2026-09-06~07 (#760~#800) — 전투·배경·UI·타격감 (조직 PD 로그 #746~#800 · 대화로그 §14~§24) - 전투: Knight@Attack1~3_S 3콤보(4 컨트롤러·클래스별 서브클립) · 타겟팅 4규칙(정면 우선·재타겟·즉시 공격·어그로 금지 · WLTargetingSettings) · 대쉬 후 공격(Stander@Chase_Start · DashDriver · 5 m · 사정거리+0.8 · 근거리 적 우선) · 공격 이동 FrameTable(발 접지 실측 표 · AttackRootMotion) · 충돌 반경/투사체 0.3 배(WLCollisionTuning) · 무적(임시)·펫 금지(WLGameplaySettings) - 검기: NamuFX Slash_B 배리언트 Effect_WLSwingArc 원 피팅 정합 배치 + 캘리브레이션(SlashArcMeasure · SlashTrailSettings) · 찌르기 Effect_WLStab 대기 · 램프 리본(BladeTrail · WL_BladeRibbon.shader · T_WL_BladeRibbonRamp) 보존(drawRibbon 0) - 타격감: Assets/WL/Feel(WLHitFeel · 히트스톱 0.03 · 셰이크 0.10 m · 몹 펀치 1.12 · Actor.Get_Damage 훅 1줄 · 원본 RealCamera 셰이크 결함 대체) - 배경/맵: LMHPOLY Demo_01~10 → WL_Nature01~10(프리팹·씬·NavMesh·스포너·BattleMapConfig) · 물(ToonWaterU) · 포스트 블룸 0.9/0.3 · 잔디(BruteForce·드레싱) 제거 · 마젠타 머티리얼 URP 변환 - UI: 세로 HUD(WL_HUD · 하단 5메뉴 폭 전체 · 채팅/전투 패드 숨김 · WLIngameUiOverride) · Title/TitleInfo 1080×1920 Expand + 배경 높이 fit(WLBackgroundFit) · 로딩 SortOrder_5(WLRawImageAspectSync · 초점표 8장) · Loading1~8 ASTC 6×6 - 도구: AgentScripts/*(LightProbe · WL_MapSwitch · WL760~WL800 프로브/집행/검증 · 상단 사용법 주석) · 에디터 락 프로토콜 파일(staging) - 제외(별도 커밋 예정): Assets/LMHPOLY(703 MB) · Assets/Feel(422 MB) · Assets/Shinabro(300 MB) — 에셋 스토어/구 WL 팩 원본 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:24:25 +00:00
// 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<MobControlMgr>();
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<int, string>();
var mc = new System.Collections.Generic.Dictionary<int, int>();
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<MapData>();
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<UnityEngine.AI.NavMeshData>(kNavAsset);
if (navData == null) { E("NavMeshData 에셋 없음: " + kNavAsset); Flush(); return; }
var navInst = UnityEngine.AI.NavMesh.AddNavMeshData(navData);
System.Collections.Generic.List<UnityEngine.Vector3> 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<MobControlMgr>();
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<TMPro.TextMeshPro>();
W("#" + id + " text_lv 자동 복원 = " + (comp.text_lv == null ? "실패" : "성공"));
}
UnityEditor.EditorUtility.SetDirty(comp);
}
L("스포너 " + chosen.Count + "개 배치 완료 (컨테이너 '" + kContainerName + "')");
// 검증
var verify = dstRoot.GetComponentsInChildren<MobControlMgr>(true);
L("검증: GetComponentsInChildren<MobControlMgr>(루트) = " + 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<MobControlMgrList>();
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<UnityEngine.Vector3> Choose(
UnityEngine.Vector3 pcSpawn, System.Collections.Generic.List<UnityEngine.Bounds> 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<UnityEngine.Vector3>();
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<UnityEngine.Vector3>();
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<UnityEngine.Bounds> FindWaterVolumes(UnityEngine.GameObject root)
{
var list = new System.Collections.Generic.List<UnityEngine.Bounds>();
var rs = root.GetComponentsInChildren<UnityEngine.Renderer>(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<UnityEngine.Bounds> 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<int, string> lv,
System.Collections.Generic.Dictionary<int, int> 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);
}
}