Project_WL/AgentScripts/WL813e_Zones.cs

481 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 발주서 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); }
/// <summary>텍스트 편집으로 바꾼 프리팹·JSON 을 에디터 캐시에 반영한다(상주 에디터에서 필수).</summary>
static UnityEngine.GameObject LoadMap()
{
UnityEditor.AssetDatabase.ImportAsset(kMapPrefab, UnityEditor.ImportAssetOptions.ForceUpdate);
UnityEditor.AssetDatabase.ImportAsset(kAppearJson, UnityEditor.ImportAssetOptions.ForceUpdate);
return UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.GameObject>(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<System.Collections.Generic.Dictionary<string, string>> ReadRows(string relPath)
{
var list = new System.Collections.Generic.List<System.Collections.Generic.Dictionary<string, string>>();
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<string, string>();
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<string, string> 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<string, System.Collections.Generic.Dictionary<string, string>>();
foreach (var r in appear) byAppear[G(r, "n_SpawnerID")] = r;
var byMob = new System.Collections.Generic.Dictionary<string, System.Collections.Generic.Dictionary<string, string>>();
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<int>(kZoneIds); wanted.Add(kBossRowId);
foreach (var id in wanted)
{
System.Collections.Generic.Dictionary<string, string> 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<MobControlMgr>(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<string, string> 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<string, string> 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<UnityEngine.GameObject>(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<FieldBossData>(true);
L("── (d) FieldBossData " + bosses.Length + "기 ──");
var mapCfg = root.GetComponentInChildren<MapData>(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<string, string> 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<UnityEngine.GameObject>(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<UnityEngine.AI.NavMeshData>(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);
}
// ══════════════════════════════════════════════════════════════════════
/// <summary>
/// 아레나 좌표만 문제인지, 이 맵의 NavMesh 가 원래 섬으로 끊겨 있는지 가른다.
/// 시작 지점 → 스포너 19 원점 · 보스 1 좌표의 CalculatePath 상태를 전부 찍는다.
/// (MapData.cs:36 — WL_Start 는 NavMesh 위 1144점 중 37% 만 도달 가능하다는 실측이 있다)
/// </summary>
public static string Nav()
{
s = new System.Text.StringBuilder();
L("===== WL-813e NavMesh 도달성 스윕 (" + kNavAsset + ") =====");
var root = LoadMap();
var navData = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.AI.NavMeshData>(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<string>();
var pts = new System.Collections.Generic.List<UnityEngine.Vector3>();
var mgrs = root.GetComponentsInChildren<MobControlMgr>(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<FieldBossData>(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");
}
// ══════════════════════════════════════════════════════════════════════
/// <summary>
/// 아레나 후보 탐색 — 초안 후보A (137, 22) 주변 격자에서
/// (1) 시작 지점에서 PathComplete 이고 (2) SP1·SP4 원점에서 15m 이상 떨어진 점을
/// 후보A 로부터 가까운 순으로 찍는다. 초안의 "군집 간섭 없음" 조건을 유지한 최소 이동을 고른다.
/// </summary>
public static string Arena()
{
s = new System.Text.StringBuilder();
var root = LoadMap();
var navData = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.AI.NavMeshData>(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<MobControlMgr>(true);
L("===== 아레나 후보 탐색 · 이상점 " + ideal.ToString("F1") + " · ±" + kSpan + "m / " + kStep + "m 격자 =====");
L("조건: 시작에서 PathComplete · 모든 스포너 원점에서 " + kClear + "m 이상 · MobGenRange 10 기준 여유 5m");
var res = new System.Collections.Generic.List<string>();
var keys = new System.Collections.Generic.List<float>();
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<int>();
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<float> s_gaps;
static System.Collections.Generic.HashSet<int> s_prev;
static System.Collections.Generic.Dictionary<int, int> 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<float>();
s_prev = new System.Collections.Generic.HashSet<int>();
s_killBySpawner = new System.Collections.Generic.Dictionary<int, int>();
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<MobActor>(UnityEngine.FindObjectsInactive.Exclude, UnityEngine.FindObjectsSortMode.None);
var now = new System.Collections.Generic.HashSet<int>();
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 "감시 중이 아니다"; }
}