diff --git a/AgentScripts/WL813_Score.cs b/AgentScripts/WL813_Score.cs new file mode 100644 index 000000000..1cc6d5f86 --- /dev/null +++ b/AgentScripts/WL813_Score.cs @@ -0,0 +1,50 @@ +// WL813_Score.cs — #813m 채점 프로브 진입점 (기준서 v1 §E 채점표 24항목 1:1 로그) +// +// unity command run_script --file AgentScripts/WL813_Score.cs --entry WL813_Score.Run --args '[60]' // 60 s 샘플 시작(비차단) +// unity command run_script --file AgentScripts/WL813_Score.cs --entry WL813_Score.Status // 진행 상황 +// unity command run_script --file AgentScripts/WL813_Score.cs --entry WL813_Score.Result // 끝난 뒤 표 전문 +// unity command run_script --file AgentScripts/WL813_Score.cs --entry WL813_Score.Summary // 한 줄 요약 + 파일 경로 +// unity command run_script --file AgentScripts/WL813_Score.cs --entry WL813_Score.RunAt --args '[60,0.5]' // 샘플 주기 지정 +// unity command run_script --file AgentScripts/WL813_Score.cs --entry WL813_Score.Stop // 조기 종료 후 표 기록 +// +// 결과 파일: Screenshots_WL/Score/score_.txt (Assets 밖 · 미추적) +// +// 실제 계측·채점 로직은 `Assets/WL/Tools/WL813_ScoreProbe.cs`(에디터 전용 · 컴파일 게이트 대상)에 있고 +// 이 파일은 run_script 진입점만 노출한다. Play 중에만 동작하며 게임 상태·에셋을 수정하지 않는다. +// +// 🔴 `Run` 은 즉시 돌아온다(비차단). 샘플이 끝나기 전에 `Result` 를 부르면 "(running)" 이 나온다 — +// `Status` 로 남은 시간을 보고 기다린 뒤 `Result` 를 부른다. +// 🔴 CLI 명령(`wl_info` 등)은 802c 소관이라 여기서 다시 만들지 않는다. 적 수·timeScale·HP 확인은 `unity command wl_info`. + +using WL.Tools; + +public static class WL813_Score +{ + /// seconds 초 동안 샘플 → §E 24항목 표 기록. 0 이하면 기본 60 s. + public static object Run(float seconds) { return WL813_ScoreProbe.Start(seconds, 0f); } + + /// 샘플 주기(초)까지 지정. 기본 1 s(기준서 §E 의 1 s 샘플). + public static object RunAt(float seconds, float sampleInterval) { return WL813_ScoreProbe.Start(seconds, sampleInterval); } + + /// 진행 상황(경과 · 프레임 · 샘플 수 · Killed/Spawned). + public static object Status() { return WL813_ScoreProbe.Status(); } + + /// 완료된 표 전문(§E 24항목). 콘솔에도 같은 내용이 남는다. + public static object Result() + { + var t = WL813_ScoreProbe.LastTable; + return string.IsNullOrEmpty(t) ? WL813_ScoreProbe.LastResult : t; + } + + /// 한 줄 요약(제안 합계 · 「미확인」 수 · 파일 경로). + public static object Summary() + { + return WL813_ScoreProbe.LastResult + + " | proposed=" + WL813_ScoreProbe.LastProposedTotal + + " unknown=" + WL813_ScoreProbe.LastUnknownCount + + " path=" + WL813_ScoreProbe.LastPath; + } + + /// 진행 중인 샘플을 조기 종료하고 지금까지의 표를 쓴다. + public static object Stop() { return WL813_ScoreProbe.Stop(); } +} diff --git a/AgentScripts/WL813_Shots.cs b/AgentScripts/WL813_Shots.cs new file mode 100644 index 000000000..c5fe88e7c --- /dev/null +++ b/AgentScripts/WL813_Shots.cs @@ -0,0 +1,58 @@ +// WL813_Shots.cs — #813m 세로 캡처 5장 세트 + 엄지 반경 오버레이 진입점 +// +// # 0) 게임뷰를 세로 규격으로 (기존 도구 재사용 · 중복 구현 없음) +// unity command run_script --file AgentScripts/WL_GameViewSize.cs --entry WL_GameViewSize.Set --args '[1080,1920]' +// +// # 1) 5장 세트 — 6 s 간격으로 title → hud → combat → boss → popup 을 자동 저장 +// # 간격 사이에 QA 가 해당 화면을 만든다(콘솔에 "다음 캡처 = <슬롯>" 이 뜬다). +// unity command run_script --file AgentScripts/WL813_Shots.cs --entry WL813_Shots.Run --args '[6]' +// +// # 2) 한 장만 · 오버레이 포함 한 장 더 +// unity command run_script --file AgentScripts/WL813_Shots.cs --entry WL813_Shots.Shot --args '["hud"]' +// unity command run_script --file AgentScripts/WL813_Shots.cs --entry WL813_Shots.ShotReach --args '["hud"]' +// +// # 3) 오버레이만 켜고/끄기(육안 확인용) · 수치 요약 +// unity command run_script --file AgentScripts/WL813_Shots.cs --entry WL813_Shots.Overlay --args '[true]' +// unity command run_script --file AgentScripts/WL813_Shots.cs --entry WL813_Shots.Layout +// unity command run_script --file AgentScripts/WL813_Shots.cs --entry WL813_Shots.Result +// +// 저장 위치: Screenshots_WL/Shots/<번호>_<슬롯>_x_.png + shots_.txt (Assets 밖 · 미추적) +// +// 엄지 반경 오버레이 = 우하단(미러면 좌하단) 모서리 기준 `WLHudLayoutSettings.thumbReachRadiusPx` 원 + +// 버튼별 실제 지름 원(반경 안 초록 · 밖 빨강) + 조이스틱 원(파랑). **캡처 직후 제거**한다. +// +// 🔴 `capture_game_view --source screen` 은 에디터 창 비율로 나와 세로 검증에 못 쓴다 → 여기서는 ScreenCapture 로 직접 뜬다. +// 🔴 Play 중에만 동작하고 게임 상태·에셋을 수정하지 않는다(오버레이는 HideFlags.DontSave 임시 오브젝트). + +using WL.Tools; + +public static class WL813_Shots +{ + /// 5장 세트(title · hud · combat · boss · popup). intervalSeconds 마다 한 장, 인게임 슬롯은 오버레이 1장 추가. + public static object Run(float intervalSeconds) { return WL813_ShotOverlay.RunSet(intervalSeconds, true); } + + /// 오버레이 없이 5장만. + public static object RunPlain(float intervalSeconds) { return WL813_ShotOverlay.RunSet(intervalSeconds, false); } + + /// 지금 화면 1장(오버레이 없음). + public static object Shot(string slot) { return WL813_ShotOverlay.Shot(slot, false); } + + /// 지금 화면 2장(원본 + 엄지 반경 오버레이). + public static object ShotReach(string slot) { return WL813_ShotOverlay.Shot(slot, true); } + + /// 오버레이만 켜기/끄기(캡처 없이 육안 확인). + public static object Overlay(bool on) + { + return on ? WL813_ShotOverlay.ShowOverlay() : WL813_ShotOverlay.HideOverlay(); + } + + /// 엄지 반경 수치 요약(설정 기하 vs 실제 배치 · §E 3-1 · 7-2 근거). + public static object Layout() { return WL813_ShotOverlay.LayoutSummary(); } + + /// 진행 상황 · 저장 목록 · 매니페스트. + public static object Result() + { + var m = WL813_ShotOverlay.LastManifest; + return WL813_ShotOverlay.Status() + (string.IsNullOrEmpty(m) ? "" : "\n" + m); + } +} diff --git a/Assets/WL/Tools/WL813_ScoreProbe.cs b/Assets/WL/Tools/WL813_ScoreProbe.cs new file mode 100644 index 000000000..79a965818 --- /dev/null +++ b/Assets/WL/Tools/WL813_ScoreProbe.cs @@ -0,0 +1,1298 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WL813_ScoreProbe.cs — #813m 채점 프로브 엔진 (에디터/개발빌드 전용 · 읽기 전용) +// +// 무엇인가: 기준서 v1 §E 채점표 24항목(8요소 × 3)과 **1:1 인 표**를 실측으로 채운다. +// · 절두체 내 생존 몹 수(1 s 샘플 · 중앙값/평균/최대) → 1-1 +// · Killed 간격(평균 · 중앙값 · ≤3 s 비율) → 1-2 +// · Spawned 버스트(일괄 재등장 간격 · 버스트 크기) → 1-3 +// · 히트 반응 3종 동프레임 수(히트스톱 · 점멸 · 데미지 숫자) → 2-1 +// · 처치 연출 3종(디졸브 · 연쇄 문구 · 시체 잔존 실측) → 2-2 +// · timeScale 감속 구간 · 복원 실패 수 · 중재자 카운터 → 2-3 +// · 전투 패드 슬롯 수 · 최대 도달 반경 · 최소 간격 → 3-1 · 7-2 +// · 스킬 1발당 명중 수(HitboxHit(skill) / SkillFired) → 3-2 +// · fps(평균 · 최저 · 중앙값 · 히치 수) · EffectBudget 거부 → 3-3 · 7-3 +// · 드랍 산포/빔/등급 · 드랍→획득 시간 · 토스트 동시 수 → 4-1 · 4-2 · 4-3 +// · LevelUp(FX · 전회복 · 팝업) · 10분 환산 횟수 → 5-1 · 5-2 +// · 보스 스폰→처치 시간 · 페이즈 수 · 예고→발동 ms → 6-1 · 6-2 · 6-3 +// · 킬캠 발동/스킵/하단 침범(viewportY) · 복원 → 7-1 +// · PC 피격 damage>0 · 무적 비율 · 사망/부활 → 8-1 · 8-3 +// · GC 증가(GC.GetTotalMemory · Profiler.MonoUsed) +// +// 왜 Assets/WL/Tools 인가: 802c(`WLPipelineCommands.cs`)와 같은 에디터 전용 폴더다. +// 여기 두면 ① 컴파일 게이트가 이 코드를 검증하고 ② run_script 호출마다 Roslyn 재컴파일이 없고 +// ③ 병합 뒤 QA 가 메인 에디터에서 그대로 쓴다. 진입점은 `AgentScripts/WL813_Score.cs`(얇은 래퍼). +// +// 🔴 읽기 전용: 게임 상태·에셋·설정을 하나도 바꾸지 않는다. 이벤트 구독 + 정적 카운터 읽기 + 씬 조회뿐이다. +// 종료 시 구독을 전부 해제한다(중단·도메인 리로드 포함). +// 🔴 CLI 명령(`[CliCommand]`)을 만들지 않는다 — `wl_info`·`wl_timescale` 등 CLI 는 802c 소관(중복 금지). +// 🔴 제안 점수는 자동 계산일 뿐이다. 최종 판정은 QA·PD 이고, 측정이 불가능한 항목은 「미확인」(C23). +// 🔴 릴리즈 빌드 제외(`UNITY_EDITOR || DEVELOPMENT_BUILD`). +// ───────────────────────────────────────────────────────────────────────────── + +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEngine; +using UnityEngine.SceneManagement; +using WL.Combat.Boss; +using WL.Combat.Core; +using WL.Combat.Growth; +using WL.Combat.Loot; +using WL.Combat.Reaction; +using WL.CombatCamera; +using WL.UI; + +namespace WL.Tools +{ + /// + /// #813m 채점 프로브. `WL813_ScoreProbe.Start(60)` → 60 s 샘플 후 + /// `Screenshots_WL/Score/score_<HHmmss>.txt` 에 §E 24항목 표를 쓴다. + /// + public static class WL813_ScoreProbe + { + /// 결과 파일 폴더(프로젝트 루트 기준 · `Assets/` 밖 · 미추적). + public const string OutDir = "Screenshots_WL/Score"; + + /// 기본 샘플 길이(초). 기준서 §E 의 「60 s 샘플」. + public const float DefaultSeconds = 60f; + + /// 절두체 몹 수·fps 샘플 주기(초). + public const float DefaultSampleInterval = 1f; + + // ── 결과 ────────────────────────────────────────────────────────────── + public static string LastResult = "(not run)"; + public static string LastPath = ""; + public static string LastTable = ""; + public static int LastProposedTotal = -1; + public static int LastUnknownCount = -1; + + static Runner s_runner; + + public static bool Running { get { return s_runner != null; } } + + /// 샘플 시작(비차단). 진행 상황은 `Status()`, 결과는 `LastResult`/`LastTable`. + public static string Start(float seconds, float sampleInterval) + { + if (!Application.isPlaying) return "not playing — Title 에서 Play 후 실행한다"; + if (s_runner != null) return "already running — " + Status(); + + float sec = seconds > 0f ? seconds : DefaultSeconds; + float itv = sampleInterval > 0f ? sampleInterval : DefaultSampleInterval; + + var go = GameObject.Find("__wl813_score"); + if (go != null) UnityEngine.Object.DestroyImmediate(go); + + go = new GameObject("__wl813_score"); + go.hideFlags = HideFlags.DontSave; + s_runner = go.AddComponent(); + LastResult = "(running)"; + s_runner.Begin(sec, itv); + return "started seconds=" + sec.ToString("F1") + " interval=" + itv.ToString("F2") + + " — 끝나면 WL813_Score.Result 로 표를 읽는다"; + } + + /// 진행 중이면 조기 종료하고 지금까지의 표를 쓴다. + public static string Stop() + { + if (s_runner == null) return "not running"; + s_runner.RequestStop(); + return "stopping — 다음 프레임에 표를 쓴다"; + } + + public static string Status() + { + if (s_runner == null) return "idle · last=" + (string.IsNullOrEmpty(LastPath) ? "(none)" : LastPath); + return s_runner.StatusLine(); + } + + internal static void ClearRunner(Runner r) { if (s_runner == r) s_runner = null; } + + // ── 유틸 ────────────────────────────────────────────────────────────── + + /// 어셈블리 전수에서 타입을 찾는다(아직 병합되지 않은 태스크의 타입 존재 확인용). + internal static bool TypeExists(string fullName) + { + var asms = AppDomain.CurrentDomain.GetAssemblies(); + for (int i = 0; i < asms.Length; i++) + { + Type t = null; + try { t = asms[i].GetType(fullName); } catch { } + if (t != null) return true; + } + return false; + } + + internal static float Median(List v) + { + if (v == null || v.Count == 0) return 0f; + var c = new List(v); + c.Sort(); + int n = c.Count; + return (n & 1) == 1 ? c[n / 2] : (c[n / 2 - 1] + c[n / 2]) * 0.5f; + } + + internal static float Percentile(List v, float p) + { + if (v == null || v.Count == 0) return 0f; + var c = new List(v); + c.Sort(); + int idx = Mathf.Clamp(Mathf.RoundToInt((c.Count - 1) * Mathf.Clamp01(p)), 0, c.Count - 1); + return c[idx]; + } + + internal static float Mean(List v) + { + if (v == null || v.Count == 0) return 0f; + double s = 0d; + for (int i = 0; i < v.Count; i++) s += v[i]; + return (float)(s / v.Count); + } + + internal static float Max(List v) + { + if (v == null || v.Count == 0) return 0f; + float m = float.MinValue; + for (int i = 0; i < v.Count; i++) if (v[i] > m) m = v[i]; + return m; + } + + internal static float Min(List v) + { + if (v == null || v.Count == 0) return 0f; + float m = float.MaxValue; + for (int i = 0; i < v.Count; i++) if (v[i] < m) m = v[i]; + return m; + } + + // ───────────────────────────────────────────────────────────────────── + // 샘플러 + // ───────────────────────────────────────────────────────────────────── + internal sealed class Runner : MonoBehaviour + { + // 설정 + float _seconds, _interval; + bool _stopRequested; + float _t0, _lastSample; + int _frames; + + // ── 1-1 절두체 샘플 ── + readonly List _inView = new List(256); + readonly List _aliveAll = new List(256); + readonly List _eliteInView = new List(256); + int _maxBossAlive; + + // ── fps ── + readonly List _fps = new List(8192); + readonly List _fpsPerSample = new List(256); + int _hitchCount; // 프레임 시간이 중앙값 2배를 넘은 프레임 수(히치) + float _worstFrameMs; + + // ── 이벤트 타임스탬프 ── + readonly List _killT = new List(2048); + readonly List _spawnT = new List(4096); + + // 카운터(구독 델타) + int _cAttack, _cCombo, _cSwing, _cHbSpawn, _cHbHit, _cHbHitSkill, _cHitConf, _cHitConfKill; + int _cKilled, _cSkillCast, _cSkillFired, _cDamaged, _cDamagedPc, _cDamagedPcReal, _cDamagedPcInv; + int _cSpawned, _cSpawnedBoss, _cSpawnedElite, _cDash, _cLevelUp, _cBossPhase; + int _cLootDrop, _cLootPick, _cLootBeam, _cLootGrade4; + double _pcDamageTotal; + + // 기준선(정적 카운터) + int _b_feelHit, _b_feelFired, _b_feelThrottled; + int _b_flashOn, _b_flashThrottled; + int _b_aggFlush, _b_aggHits; + int _b_chainKills, _b_chainTier, _b_chainChain, _b_chainBurst; + int _b_budgetAcq, _b_budgetDeny; + int _b_arbApplied, _b_arbIgnored, _b_arbSkipHigher, _b_arbSkipPause, _b_arbThrottled, _b_arbPreempt; + int _b_killCam, _b_killCamCd, _b_killCamRole, _b_killCamRunCap, _b_killCamGuard, _b_killCamBusy, _b_killCamAband; + int _b_bossCam, _b_comboLead; + int _b_lootDrops, _b_lootScatter, _b_lootBeam; + int _b_lvBurst, _b_lvHandled; + int _b_killConfirmed; + long _b_gc, _b_mono; + + // ── 2-1 동프레임 3종 ── + int _frames3of3, _frames2of3, _frames1of3; + int _fFeelPrev, _fFlashPrev, _fAggPrev; + bool _frameDamageText; + + // ── 2-2 시체 잔존 ── + struct Corpse { public GameObject go; public float t; } + readonly List _corpsePending = new List(128); + readonly List _corpseLife = new List(256); + + // ── 2-3 timeScale ── + readonly List _slowRuns = new List(512); + readonly List _arbiterMeasured = new List(256); + bool _inSlow; + float _slowStart, _prevScale = 1f, _lastArbMeasured = -1f; + int _restoreFail, _slowOverlap; + float _endTimeScale = 1f; + + // ── 4-2 드랍→획득 ── + struct Drop { public DropItem item; public float t; public int grade; } + readonly List _dropPending = new List(256); + readonly List _pickSeconds = new List(256); + readonly List _pickT = new List(512); + int _maxToastConcurrent; + + // ── 5-1 레벨업 ── + bool _lvSeen; + double _lvHpBefore, _lvMaxHp, _lvMpBefore, _lvMaxMp; + int _lvStatPoint; + + // ── 6-x 보스 ── + Actor _bossActor; + float _bossSpawnT = -1f, _bossKillT = -1f; + readonly List _bossPhases = new List(8); + float _bossCastT = -1f; + Actor _bossCaster; + readonly List _bossTelegraph = new List(32); + + // ── 8-3 사망 ── + bool _pcDeadPrev; + int _pcDeaths; + + // 인게임 판정 — 로비/전투에 들어가 있었는가(Title 에서는 전투 항목이 「미확인」이어야 한다) + bool _sawPc; + + // 메타 + string _sceneNames = ""; + string _pcName = "(none)"; + int _pcLevel; + + // ── 수명 ──────────────────────────────────────────────────────── + public void Begin(float seconds, float interval) + { + _seconds = seconds; + _interval = interval; + StartCoroutine(Co_Run()); + } + + public void RequestStop() { _stopRequested = true; } + + public string StatusLine() + { + float el = Time.unscaledTime - _t0; + return "running " + el.ToString("F1") + "/" + _seconds.ToString("F1") + " s" + + " · frames=" + _frames + " · samples=" + _inView.Count + + " · killed=" + _cKilled + " · spawned=" + _cSpawned; + } + + void OnDisable() { Unsubscribe(); } + void OnDestroy() { Unsubscribe(); ClearRunner(this); } + + // ── 구독 ──────────────────────────────────────────────────────── + bool _subscribed; + + void Subscribe() + { + if (_subscribed) return; + CombatEvents.AttackStarted.Add(OnAttackStarted); + CombatEvents.ComboStage.Add(OnComboStage); + CombatEvents.SwingEffect.Add(OnSwingEffect); + CombatEvents.HitboxSpawned.Add(OnHitboxSpawned); + CombatEvents.HitboxHit.Add(OnHitboxHit); + CombatEvents.HitConfirmed.Add(OnHitConfirmed); + CombatEvents.Killed.Add(OnKilled); + CombatEvents.Dash.Add(OnDash); + CombatEvents.SkillCast.Add(OnSkillCast); + CombatEvents.SkillFired.Add(OnSkillFired); + CombatEvents.Damaged.Add(OnDamaged); + CombatEvents.Spawned.Add(OnSpawned); + CombatEvents.LevelUp.Add(OnCoreLevelUp); + BossEvents.BossPhase.Add(OnBossPhase); + GrowthEvents.LevelUp.Add(OnGrowthLevelUp); + LootBurst.Dropped.Add(OnLootDropped); + LootBurst.Picked.Add(OnLootPicked); + _subscribed = true; + } + + void Unsubscribe() + { + if (!_subscribed) return; + CombatEvents.AttackStarted.Remove(OnAttackStarted); + CombatEvents.ComboStage.Remove(OnComboStage); + CombatEvents.SwingEffect.Remove(OnSwingEffect); + CombatEvents.HitboxSpawned.Remove(OnHitboxSpawned); + CombatEvents.HitboxHit.Remove(OnHitboxHit); + CombatEvents.HitConfirmed.Remove(OnHitConfirmed); + CombatEvents.Killed.Remove(OnKilled); + CombatEvents.Dash.Remove(OnDash); + CombatEvents.SkillCast.Remove(OnSkillCast); + CombatEvents.SkillFired.Remove(OnSkillFired); + CombatEvents.Damaged.Remove(OnDamaged); + CombatEvents.Spawned.Remove(OnSpawned); + CombatEvents.LevelUp.Remove(OnCoreLevelUp); + BossEvents.BossPhase.Remove(OnBossPhase); + GrowthEvents.LevelUp.Remove(OnGrowthLevelUp); + LootBurst.Dropped.Remove(OnLootDropped); + LootBurst.Picked.Remove(OnLootPicked); + _subscribed = false; + } + + float T { get { return Time.unscaledTime - _t0; } } + + // ── 핸들러(전부 읽기 전용) ────────────────────────────────────── + void OnAttackStarted(in AttackStartedEvent e) { _cAttack++; } + void OnComboStage(in ComboStageEvent e) { _cCombo++; } + void OnSwingEffect(in SwingEffectEvent e) { _cSwing++; } + void OnHitboxSpawned(in HitboxSpawnedEvent e) + { + _cHbSpawn++; + if (_bossCaster != null && ReferenceEquals(e.shooter, _bossCaster) && _bossCastT >= 0f) + { + _bossTelegraph.Add((T - _bossCastT) * 1000f); + _bossCastT = -1f; _bossCaster = null; + } + } + void OnHitboxHit(in HitboxHitEvent e) { _cHbHit++; if (e.isSkill) _cHbHitSkill++; } + void OnHitConfirmed(in HitConfirmedEvent e) + { + _cHitConf++; + if (e.isKill) _cHitConfKill++; + if (e.damage > 0d) _frameDamageText = true; + } + void OnKilled(in KilledEvent e) + { + _cKilled++; + _killT.Add(T); + if (e.victim != null && _corpsePending.Count < 128) + _corpsePending.Add(new Corpse { go = e.victim.gameObject, t = T }); + if (_bossActor != null && ReferenceEquals(e.victim, _bossActor) && _bossKillT < 0f) _bossKillT = T; + } + void OnDash(in DashEvent e) { _cDash++; } + void OnSkillCast(in SkillCastEvent e) + { + _cSkillCast++; + var bm = e.actor as BossMobActor; + if (bm != null) { _bossCastT = T; _bossCaster = e.actor; } + } + void OnSkillFired(in SkillFiredEvent e) { _cSkillFired++; } + void OnDamaged(in DamagedEvent e) + { + _cDamaged++; + var pc = MyValue.MyPC; + if (pc != null && e.victim != null && ReferenceEquals(e.victim, pc)) + { + _cDamagedPc++; + if (e.invincible) _cDamagedPcInv++; + if (e.damage > 0d) { _cDamagedPcReal++; _pcDamageTotal += e.damage; } + } + } + void OnSpawned(in SpawnedEvent e) + { + _cSpawned++; + _spawnT.Add(T); + if (e.isBoss) + { + _cSpawnedBoss++; + if (_bossActor == null) { _bossActor = e.actor; _bossSpawnT = T; } + } + if (e.isElite) _cSpawnedElite++; + } + void OnCoreLevelUp(in LevelUpEvent e) { } + void OnBossPhase(in BossPhaseEvent e) + { + _cBossPhase++; + if (!_bossPhases.Contains(e.phase)) _bossPhases.Add(e.phase); + } + void OnGrowthLevelUp(in PlayerLevelUpEvent e) + { + _cLevelUp++; + if (!_lvSeen) + { + _lvSeen = true; + _lvHpBefore = e.hpBefore; _lvMaxHp = e.maxHp; + _lvMpBefore = e.mpBefore; _lvMaxMp = e.maxMp; + _lvStatPoint = e.statPointTotal; + } + } + void OnLootDropped(in LootDropEvent e) + { + _cLootDrop++; + if (e.beamSpawned) _cLootBeam++; + if (e.grade >= 4) _cLootGrade4++; + if (_dropPending.Count < 256) + _dropPending.Add(new Drop { item = e.item, t = T, grade = e.grade }); + } + void OnLootPicked(in LootPickedEvent e) + { + _cLootPick++; + float now = T; + _pickT.Add(now); + for (int i = 0; i < _dropPending.Count; i++) + { + if (ReferenceEquals(_dropPending[i].item, e.item)) + { + _pickSeconds.Add(now - _dropPending[i].t); + _dropPending.RemoveAt(i); + break; + } + } + // 토스트 동시 수 추정 = 토스트 표시 시간 창 안의 획득 건수 + float win = 1.2f; + var ts = WLCombatTextSettings.Instance; + if (ts != null && ts.lootToastSeconds > 0f) win = ts.lootToastSeconds; + int c = 0; + for (int i = _pickT.Count - 1; i >= 0 && now - _pickT[i] <= win; i--) c++; + if (c > _maxToastConcurrent) _maxToastConcurrent = c; + } + + // ── 본 루프 ───────────────────────────────────────────────────── + IEnumerator Co_Run() + { + var scene = SceneManager.GetActiveScene(); + var sb = new StringBuilder(); + for (int i = 0; i < SceneManager.sceneCount; i++) + { + var s = SceneManager.GetSceneAt(i); + if (!s.isLoaded) continue; + if (sb.Length > 0) sb.Append('+'); + sb.Append(s.name); + } + _sceneNames = sb.Length > 0 ? sb.ToString() : scene.name; + + SnapshotBaseline(); + Subscribe(); + _t0 = Time.unscaledTime; + _lastSample = -999f; + _prevScale = Time.timeScale; + _inSlow = _prevScale < 0.999f; + if (_inSlow) _slowStart = 0f; + + var pc0 = MyValue.MyPC; + if (pc0 != null) + { + _pcName = pc0.name; + var sd = pc0.Get_ServerData(); + _pcLevel = sd != null && sd.PC != null ? (int)sd.PC.Lv : 0; + _pcDeadPrev = pc0.IsDead(); + } + + while (!_stopRequested && Time.unscaledTime - _t0 < _seconds) + { + yield return null; + _frames++; + FrameTick(); + if (Time.unscaledTime - _t0 - _lastSample >= _interval) + { + _lastSample = Time.unscaledTime - _t0; + SampleTick(); + } + } + + // 미결 시체 정산(샘플 종료 시점까지 살아 있으면 하한만 안다) + _endTimeScale = Time.timeScale; + if (_inSlow) + { + float d = T - _slowStart; + _slowRuns.Add(d); + if (_endTimeScale < 0.999f) _restoreFail++; + } + + Report(); + Unsubscribe(); + ClearRunner(this); + Destroy(gameObject); + } + + void SnapshotBaseline() + { + _b_feelHit = WL.Feel.WLHitFeel.HitCount; + _b_feelFired = WL.Feel.WLHitFeel.FiredCount; + _b_feelThrottled = WL.Feel.WLHitFeel.ThrottledCount; + _b_flashOn = MobHitFlash.OnCount; + _b_flashThrottled = MobHitFlash.ThrottledCount; + _b_aggFlush = DamageAggregator.FlushCount; + _b_aggHits = DamageAggregator.HitCount; + _b_chainKills = KillChain.KillCount; + _b_chainTier = KillChain.TierCount; + _b_chainChain = KillChain.ChainCount; + _b_chainBurst = KillChain.BurstCount; + _b_budgetAcq = EffectBudget.AcquiredCount; + _b_budgetDeny = EffectBudget.DeniedCount; + _b_arbApplied = TimeScaleArbiter.AppliedCount; + _b_arbIgnored = TimeScaleArbiter.IgnoredCount; + _b_arbSkipHigher = TimeScaleArbiter.SkippedHigherCount; + _b_arbSkipPause = TimeScaleArbiter.SkippedPauseCount; + _b_arbThrottled = TimeScaleArbiter.ThrottledCount; + _b_arbPreempt = TimeScaleArbiter.PreemptedCount; + _b_killCam = CombatCameraDirector.KillCamCount; + _b_killCamCd = CombatCameraDirector.KillCamSkippedCooldown; + _b_killCamRole = CombatCameraDirector.KillCamSkippedRole; + _b_killCamRunCap = CombatCameraDirector.KillCamSkippedRunCap; + _b_killCamGuard = CombatCameraDirector.KillCamSkippedGuard; + _b_killCamBusy = CombatCameraDirector.KillCamSkippedBusy; + _b_killCamAband = CombatCameraDirector.KillCamAbandoned; + _b_bossCam = CombatCameraDirector.BossCamCount; + _b_comboLead = CombatCameraDirector.ComboLeadCount; + _b_lootDrops = LootBurst.DropsSeen; + _b_lootScatter = LootBurst.Scattered; + _b_lootBeam = LootBurst.BeamSpawned; + _b_lvBurst = LevelUpBurst.BurstSpawned; + _b_lvHandled = LevelUpBurst.Handled; + _b_killConfirmed = CombatEvents.KillConfirmedCount; + _b_gc = GC.GetTotalMemory(false); + _b_mono = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); + + _fFeelPrev = _b_feelFired; + _fFlashPrev = _b_flashOn; + _fAggPrev = _b_aggFlush; + } + + void FrameTick() + { + float dt = Time.unscaledDeltaTime; + if (dt > 0.00001f) + { + float f = 1f / dt; + if (_fps.Count < 40000) _fps.Add(f); + float ms = dt * 1000f; + if (ms > _worstFrameMs) _worstFrameMs = ms; + } + + // 2-1 동프레임 3종 = 히트스톱 연출 · 몹 점멸 · 데미지 숫자 + int feel = WL.Feel.WLHitFeel.FiredCount; + int flash = MobHitFlash.OnCount; + int agg = DamageAggregator.FlushCount; + bool bFeel = feel != _fFeelPrev; + bool bFlash = flash != _fFlashPrev; + bool bText = agg != _fAggPrev || _frameDamageText; + int n = (bFeel ? 1 : 0) + (bFlash ? 1 : 0) + (bText ? 1 : 0); + if (n >= 3) _frames3of3++; + else if (n == 2) _frames2of3++; + else if (n == 1) _frames1of3++; + _fFeelPrev = feel; _fFlashPrev = flash; _fAggPrev = agg; + _frameDamageText = false; + + // 2-3 timeScale 감속 구간 + float ts = Time.timeScale; + bool slow = ts < 0.999f; + if (slow && !_inSlow) { _inSlow = true; _slowStart = T; } + else if (!slow && _inSlow) + { + _inSlow = false; + float d = T - _slowStart; + _slowRuns.Add(d); + // 중재자가 소유하지 않는 2 s 초과 감속 = 복원 실패로 본다 + if (d > 2f && TimeScaleArbiter.Active == TimeLayer.None) _restoreFail++; + } + else if (slow && _inSlow && ts > _prevScale + 0.0001f && ts < 0.999f) + { + _slowOverlap++; // 감속 중 다른 값으로 갈아탐(겹침) + } + _prevScale = ts; + + float am = TimeScaleArbiter.LastMeasured; + if (am > 0f && Math.Abs(am - _lastArbMeasured) > 0.0001f) + { + _arbiterMeasured.Add(am); + _lastArbMeasured = am; + } + + // 2-2 시체 잔존 + for (int i = _corpsePending.Count - 1; i >= 0; i--) + { + var c = _corpsePending[i]; + bool gone = c.go == null || !c.go.activeInHierarchy; + if (gone) + { + _corpseLife.Add(T - c.t); + _corpsePending.RemoveAt(i); + } + } + + // 8-3 PC 사망 전이 + var pc = MyValue.MyPC; + if (pc != null) + { + _sawPc = true; + bool dead = pc.IsDead(); + if (dead && !_pcDeadPrev) _pcDeaths++; + _pcDeadPrev = dead; + } + } + + void SampleTick() + { + var cam = Camera.main; + if (cam == null) + { + var cams = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + if (cams != null && cams.Length > 0) cam = cams[0]; + } + + int inView = 0, alive = 0, eliteView = 0, bossAlive = 0; + var mobs = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + Plane[] planes = cam != null ? GeometryUtility.CalculateFrustumPlanes(cam) : null; + + for (int i = 0; i < mobs.Length; i++) + { + var m = mobs[i]; + if (m == null || m.IsDead() || !m.gameObject.activeInHierarchy) continue; + alive++; + if (m.IsSubRole(eSubRol.Boss)) bossAlive++; + if (planes == null) continue; + + Bounds b; + var rend = m.GetComponentInChildren(); + if (rend != null) b = rend.bounds; + else b = new Bounds(m.Get_position() + Vector3.up, new Vector3(1f, 2f, 1f)); + + if (GeometryUtility.TestPlanesAABB(planes, b)) + { + inView++; + if (m.IsSubRole(eSubRol.Elite)) eliteView++; + } + } + + if (bossAlive > _maxBossAlive) _maxBossAlive = bossAlive; + _inView.Add(inView); + _aliveAll.Add(alive); + _eliteInView.Add(eliteView); + float dt = Time.unscaledDeltaTime; + _fpsPerSample.Add(dt > 0.00001f ? 1f / dt : 0f); + } + + // ── 리포트 ────────────────────────────────────────────────────── + void Report() + { + float elapsed = Time.unscaledTime - _t0; + var rows = new List(24); + + // ── 파생 지표 ──────────────────────────────────────────────── + float inViewMed = Median(_inView), inViewAvg = Mean(_inView), inViewMax = Max(_inView), inViewMin = Min(_inView); + float aliveAvg = Mean(_aliveAll); + + var killGaps = Gaps(_killT); + float killGapAvg = Mean(killGaps), killGapMed = Median(killGaps); + float killGapLe3 = Ratio(killGaps, 3f); + + int burstCount; float burstSizeAvg, burstGapAvg; + Bursts(_spawnT, 1f, out burstCount, out burstSizeAvg, out burstGapAvg); + + float fpsAvg = Mean(_fps), fpsMed = Median(_fps), fpsMin = Min(_fps), fpsP05 = Percentile(_fps, 0.05f); + float fpsDrop = fpsMed - fpsP05; + _hitchCount = 0; + float medMs = fpsMed > 0.001f ? 1000f / fpsMed : 0f; + if (medMs > 0.001f) + for (int i = 0; i < _fps.Count; i++) + if (_fps[i] > 0.001f && 1000f / _fps[i] > medMs * 2f) _hitchCount++; + + float corpseAvg = Mean(_corpseLife), corpseMax = Max(_corpseLife); + float slowAvg = Mean(_slowRuns), slowMax = Max(_slowRuns); + float arbAvg = Mean(_arbiterMeasured); + float pickAvg = Mean(_pickSeconds), pickMax = Max(_pickSeconds); + + int dFeelFired = WL.Feel.WLHitFeel.FiredCount - _b_feelFired; + int dFeelHit = WL.Feel.WLHitFeel.HitCount - _b_feelHit; + int dFlashOn = MobHitFlash.OnCount - _b_flashOn; + int dAggFlush = DamageAggregator.FlushCount - _b_aggFlush; + int dChainTier = KillChain.TierCount - _b_chainTier; + int dChainChain = KillChain.ChainCount - _b_chainChain; + int dBudgetDeny = EffectBudget.DeniedCount - _b_budgetDeny; + int dArbApplied = TimeScaleArbiter.AppliedCount - _b_arbApplied; + int dArbThrottled = TimeScaleArbiter.ThrottledCount - _b_arbThrottled; + int dArbSkipHigher = TimeScaleArbiter.SkippedHigherCount - _b_arbSkipHigher; + int dArbSkipPause = TimeScaleArbiter.SkippedPauseCount - _b_arbSkipPause; + int dArbPreempt = TimeScaleArbiter.PreemptedCount - _b_arbPreempt; + int dArbIgnored = TimeScaleArbiter.IgnoredCount - _b_arbIgnored; + int dKillCam = CombatCameraDirector.KillCamCount - _b_killCam; + int dKillCamCd = CombatCameraDirector.KillCamSkippedCooldown - _b_killCamCd; + int dKillCamRole = CombatCameraDirector.KillCamSkippedRole - _b_killCamRole; + int dKillCamRunCap = CombatCameraDirector.KillCamSkippedRunCap - _b_killCamRunCap; + int dKillCamAband = CombatCameraDirector.KillCamAbandoned - _b_killCamAband; + int dBossCam = CombatCameraDirector.BossCamCount - _b_bossCam; + int dLootScatter = LootBurst.Scattered - _b_lootScatter; + int dLootBeam = LootBurst.BeamSpawned - _b_lootBeam; + int dLvBurst = LevelUpBurst.BurstSpawned - _b_lvBurst; + int dKillConfirmed = CombatEvents.KillConfirmedCount - _b_killConfirmed; + long dGc = GC.GetTotalMemory(false) - _b_gc; + long dMono = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong() - _b_mono; + + // HUD 레이아웃(3-1 · 7-2) + var hud = WLHudLayoutSettings.Instance; + var pad = UnityEngine.Object.FindFirstObjectByType(FindObjectsInactive.Include); + float reachRadius = hud != null ? hud.thumbReachRadiusPx : -1f; + float maxReach = pad != null ? pad.LastMaxReachPx : -1f; + float minGap = pad != null ? pad.LastMinGapPx : -1f; + int activeSlots = pad != null ? pad.LastActiveSlots : (hud != null ? hud.activeSkillSlots : -1); + bool padEnabled = hud != null && hud.battlePadEnabled; + // 프리팹 배선이 없을 때는 설정값만으로 기하 검증(§B 요소3) + float geoReach = hud != null ? GeoMaxReach(hud) : -1f; + + // 씬 컴포넌트 존재 + int nToast = CountOf(); + int nChainText = CountOf(); + int nBossBar = CountOf(); + int nStatPopup = CountOf(); + + // 미병합 타입(바퀴 3 이후) + bool hasPotion = TypeExists("WL.Combat.Survival.PotionUse") || TypeExists("WL.UI.PotionButton"); + bool hasDeathFlow = TypeExists("WL.Combat.Survival.DeathFlow") || TypeExists("WL.UI.ReviveDialog"); + bool hasBanner = TypeExists("WL.UI.BossBanner"); + bool hasPowerDelta = TypeExists("WL.UI.PowerDeltaToast"); + bool hasRimLight = TypeExists("WL.Combat.Reaction.HitRimLight") || TypeExists("WL.UI.HitVignette"); + + // 인게임(로비·전투) 도달 여부 — Title 에서는 전투 항목을 0 이 아니라 「미확인」으로 둔다(C23) + bool inGame = _sawPc || Max(_aliveAll) > 0f || _cKilled > 0 || _cHbHit > 0; + bool batch = Application.isBatchMode; + string offNote = inGame ? "" : " · 인게임 미도달(Title) → 「미확인」"; + string batchNote = batch ? " · 🔴 헤드리스(batchmode) 는 프레임 시간이 실제와 다르다" : ""; + + // ── §E 24항목 ──────────────────────────────────────────────── + // 1-1 + { + int sc; + string note = "절두체 내 생존 몹 · " + _interval.ToString("F1") + " s 샘플 n=" + _inView.Count + offNote; + if (!inGame) sc = Unknown; + else if (_inView.Count == 0) sc = Unknown; + else if (inViewMed >= 8f && inViewMed <= 15f) sc = 2; + else if (inViewMed > 15f) { sc = 1; note += " · 🔴 상한 15 초과(규정 밖 · QA 판단)"; } + else if (inViewMed >= 5f) sc = 1; + else sc = 0; + rows.Add(new Row("1-1", "군중", "밀집 존 화면 내 적 수(중앙값)", + _inView.Count == 0 ? "미측정" : + "중앙값 " + inViewMed.ToString("F0") + " (평균 " + inViewAvg.ToString("F1") + + " · 최소 " + inViewMin.ToString("F0") + " · 최대 " + inViewMax.ToString("F0") + + " · 씬 생존 평균 " + aliveAvg.ToString("F0") + ")", sc, note)); + } + // 1-2 + { + int sc; + if (killGaps.Count == 0) sc = Unknown; + else if (killGapAvg <= 3f) sc = 2; + else if (killGapAvg <= 5f) sc = 1; + else sc = 0; + rows.Add(new Row("1-2", "군중", "평균 처치 간격(존 안)", + killGaps.Count == 0 ? "Killed " + _cKilled + "회 — 간격 없음" : + "평균 " + killGapAvg.ToString("F2") + " s (중앙값 " + killGapMed.ToString("F2") + + " · ≤3 s 비율 " + (killGapLe3 * 100f).ToString("F0") + "% · n=" + killGaps.Count + ")", + sc, "Killed 타임스탬프 연속 간격 · 존 구분 없이 씬 전체")); + } + // 1-3 + { + int sc; + string note = "Spawned 을 1 s 간격으로 버스트 묶음 · 전멸→일괄 재등장 판정"; + if (_spawnT.Count < 2) sc = Unknown; + else if (burstSizeAvg <= 2f) { sc = 0; note += " · 개별 즉시 리젠(평탄)"; } + else if (burstGapAvg >= 4f && burstGapAvg <= 6f) sc = 2; + else sc = 1; + rows.Add(new Row("1-3", "군중", "리젠 리듬", + _spawnT.Count < 2 ? "Spawned " + _cSpawned + "건" : + "버스트 " + burstCount + "회 · 평균 " + burstSizeAvg.ToString("F1") + "마리/버스트 · 버스트 간격 평균 " + + burstGapAvg.ToString("F1") + " s (Spawned " + _cSpawned + ")", sc, note)); + } + // 2-1 + { + int kinds = (dFeelFired > 0 ? 1 : 0) + (dFlashOn > 0 ? 1 : 0) + ((dAggFlush > 0 || _cHitConf > 0) ? 1 : 0); + int sc; + if (_cHitConf == 0) sc = Unknown; + else if (_frames3of3 > 0) sc = 2; + else if (kinds >= 2) sc = 1; + else sc = 0; + rows.Add(new Row("2-1", "타격", "히트 반응 3종 동시(히트스톱 · 점멸 · 숫자)", + "3종 동프레임 " + _frames3of3 + "프레임 (2종 " + _frames2of3 + " · 1종 " + _frames1of3 + + ") · 히트스톱 fired " + dFeelFired + " · 점멸 on " + dFlashOn + " · 숫자 flush " + dAggFlush + + "/HitConfirmed " + _cHitConf, sc, + "프레임별 WLHitFeel.FiredCount · MobHitFlash.OnCount · DamageAggregator.FlushCount 델타")); + } + // 2-2 + { + bool dissolve = MobHitFlash.DissolveResolved; + bool chain = dChainTier > 0 && nChainText > 0; + bool corpseOk = _corpseLife.Count > 0 && corpseAvg <= 1.5f; + int kinds = (dissolve ? 1 : 0) + (chain ? 1 : 0) + (corpseOk ? 1 : 0); + int sc = _cKilled == 0 ? Unknown : (kinds >= 3 ? 2 : (kinds == 2 ? 1 : 0)); + rows.Add(new Row("2-2", "타격", "처치 연출(디졸브 · 연쇄 문구 · 시체 ≤1.5 s)", + "디졸브=" + (dissolve ? "O" : "X") + " · 연쇄 티어 " + dChainTier + "회/체인 " + dChainChain + + "(문구 컴포넌트 " + nChainText + ") · 시체 잔존 평균 " + + (_corpseLife.Count > 0 ? corpseAvg.ToString("F2") + " s (최대 " + corpseMax.ToString("F2") + " · n=" + _corpseLife.Count + ")" : "미측정") + + " · 미소멸 " + _corpsePending.Count, sc, + "Killed→몹 비활성 실측 · MobHitFlash.DissolveResolved · KillChain.TierCount")); + } + // 2-3 + { + int sc; + if (_slowRuns.Count == 0 && dArbApplied == 0) sc = Unknown; + else if (_restoreFail > 0 || _endTimeScale < 0.999f) sc = 0; + else if (_slowOverlap > 0) sc = 1; + else sc = 2; + rows.Add(new Row("2-3", "타격", "감속 충돌 0 · timeScale 복원", + "복원 실패 " + _restoreFail + " · 종료 timeScale " + _endTimeScale.ToString("F2") + + " · 감속 구간 n=" + _slowRuns.Count + " 평균 " + (slowAvg * 1000f).ToString("F1") + + " ms 최대 " + (slowMax * 1000f).ToString("F1") + " ms · 겹침 " + _slowOverlap + + " · 중재자 applied " + dArbApplied + "/throttled " + dArbThrottled + + "/skipHigher " + dArbSkipHigher + "/skipPause " + dArbSkipPause + + "/preempt " + dArbPreempt + "/ignored " + dArbIgnored, sc, + "프레임 폴링 전이 간격(811b 지표) · 중재자 카운터 델타 · QA D-7 은 이 지표로 판정")); + } + // 3-1 + { + int sc; + string note = "WLBattlePadLayout.LastMaxReachPx vs thumbReachRadiusPx · 쿨 링은 캡처 확인(813m 오버레이)"; + bool reachOk = reachRadius > 0f && ((maxReach > 0f && maxReach <= reachRadius) || (maxReach <= 0f && geoReach > 0f && geoReach <= reachRadius)); + if (!padEnabled || activeSlots <= 0) sc = 0; + else if (activeSlots >= 4 && reachOk) { sc = 2; note += " · 링 실물은 「미확인」(캡처 판정)"; } + else sc = 1; + rows.Add(new Row("3-1", "스킬", "스킬 버튼 4 + 쿨 링 · 엄지 반경 안", + "패드=" + (padEnabled ? "표시" : "숨김") + " · 활성 슬롯 " + activeSlots + + " · 최대 도달 " + (maxReach > 0f ? maxReach.ToString("F1") : geoReach.ToString("F1") + "(설정 기하)") + + " px / 반경 " + reachRadius.ToString("F0") + " px · 최소 간격 " + + (minGap > 0f ? minGap.ToString("F1") + " px" : "미측정"), sc, note)); + } + // 3-2 + { + int sc; + float per = _cSkillFired > 0 ? (float)_cHbHitSkill / _cSkillFired : 0f; + if (_cSkillFired == 0) sc = Unknown; + else if (per >= 3f) sc = 2; + else if (per >= 2f) sc = 1; + else sc = 0; + rows.Add(new Row("3-2", "스킬", "스킬 1발당 명중 수(밀집 존)", + _cSkillFired == 0 ? "SkillFired 0 · SkillCast " + _cSkillCast + " — 스킬 발동 없음" : + per.ToString("F2") + " 마리/발 (HitboxHit(skill) " + _cHbHitSkill + " / SkillFired " + _cSkillFired + + " · SkillCast " + _cSkillCast + ")", sc, "HitboxHit.isSkill 카운트 ÷ SkillFired")); + } + // 3-3 + { + int sc; + if (!inGame || _fps.Count < 30 || batch) sc = Unknown; + else if (fpsDrop <= 3f && dBudgetDeny == 0) sc = 2; + else if (fpsDrop <= 6f && dBudgetDeny == 0) sc = 1; + else sc = 0; + rows.Add(new Row("3-3", "스킬", "대형 이펙트 상한 준수 · fps 저하", + "fps 중앙값 " + fpsMed.ToString("F1") + " · p05 " + fpsP05.ToString("F1") + + " · 저하 " + fpsDrop.ToString("F1") + " fps · EffectBudget 거부 " + dBudgetDeny + + " (heavy " + EffectBudget.Count(EffectBudgetKind.Heavy) + "/" + EffectBudget.Limit(EffectBudgetKind.Heavy) + + " · burst " + EffectBudget.Count(EffectBudgetKind.ClusterBurst) + "/" + EffectBudget.Limit(EffectBudgetKind.ClusterBurst) + ")", + sc, "저하 = fps 중앙값 − 5퍼센타일 「대리 지표」 · 상한 초과 = EffectBudget.DeniedCount 델타" + + offNote + batchNote)); + } + // 4-1 + { + int sc; + if (_cLootDrop == 0) sc = Unknown; + else if (dLootScatter > 0 && (dLootBeam > 0 || _cLootGrade4 == 0)) sc = 2; + else if (dLootScatter > 0) sc = 1; + else sc = 1; + rows.Add(new Row("4-1", "전리품", "처치 시 드랍 가시성 · 등급 색", + "드랍 " + _cLootDrop + "건 · 산포 " + dLootScatter + " · 빔 " + dLootBeam + + " · 등급4+ " + _cLootGrade4 + " · 처치당 " + (_cKilled > 0 ? ((float)_cLootDrop / _cKilled).ToString("F2") : "-"), + sc, "LootBurst.Dropped 이벤트 · Scattered/BeamSpawned 델타 · 색 자체는 캡처 판정")); + } + // 4-2 + { + int sc; + if (_pickSeconds.Count == 0) sc = Unknown; + else if (pickAvg <= 2f) sc = 2; + else if (pickAvg <= 4f) sc = 1; + else sc = 0; + rows.Add(new Row("4-2", "전리품", "자동 줍기 완료 시간", + _pickSeconds.Count == 0 ? "획득 " + _cLootPick + "건 · 드랍 매칭 0" : + "평균 " + pickAvg.ToString("F2") + " s (최대 " + pickMax.ToString("F2") + " · n=" + _pickSeconds.Count + + ") · 자동 줍기 반경 " + (WLLootSettings.Instance != null ? WLLootSettings.Instance.autoPickupRadius.ToString("F2") + " m" : "설정 없음"), + sc, "LootBurst.Dropped→Picked 같은 DropItem 매칭")); + } + // 4-3 + { + int sc; + if (!inGame) sc = Unknown; + else if (nToast == 0) sc = 0; + else if (_cLootPick == 0) sc = Unknown; + else if (_maxToastConcurrent <= 3) sc = 2; + else sc = 1; + rows.Add(new Row("4-3", "전리품", "획득 토스트 즉시 표시", + "LootToast 컴포넌트 " + nToast + " · 획득 " + _cLootPick + "건 · 창 안 최대 동시 " + + _maxToastConcurrent + "줄 · 표시 시간 " + + (WLCombatTextSettings.Instance != null ? WLCombatTextSettings.Instance.lootToastSeconds.ToString("F2") + " s" : "설정 없음"), + sc, "Picked 이벤트를 토스트 표시 시간 창으로 슬라이딩 집계 · 겹침 실물은 캡처 판정" + offNote)); + } + // 5-1 + { + bool fx = dLvBurst > 0; + bool heal = _lvSeen && _lvMaxHp > 0d && LevelUpBurst.LastHealHp > 0d; + bool popup = nStatPopup > 0; + int kinds = (fx ? 1 : 0) + (heal ? 1 : 0) + (popup ? 1 : 0); + int sc = _cLevelUp == 0 ? Unknown : (kinds >= 3 ? 2 : (kinds == 2 ? 1 : 0)); + rows.Add(new Row("5-1", "성장", "레벨업 연출 3요소(FX · 전회복 · 팝업)", + "LevelUp " + _cLevelUp + "회 · FX " + dLvBurst + " · 회복 hp+" + LevelUpBurst.LastHealHp.ToString("F0") + + "/mp+" + LevelUpBurst.LastHealMp.ToString("F0") + + (_lvSeen ? " (첫 이벤트 hp " + _lvHpBefore.ToString("F0") + "/" + _lvMaxHp.ToString("F0") + ")" : "") + + " · StatPopup 컴포넌트 " + nStatPopup, sc, + "GrowthEvents.LevelUp · LevelUpBurst.BurstSpawned/LastHealHp · 씬 StatPopup")); + } + // 5-2 + { + int sc; + float per600 = elapsed > 1f ? _cLevelUp * (600f / elapsed) : 0f; + string note = elapsed < 600f ? "샘플 " + elapsed.ToString("F0") + " s → 600 s 환산 「추정」(10분 런 필요)" : "600 s 실측"; + if (_cLevelUp == 0) sc = Unknown; + else if (per600 >= 5f && per600 <= 8f) sc = 2; + else if (per600 >= 3f) sc = 1; + else sc = 0; + rows.Add(new Row("5-2", "성장", "첫 10분 레벨업 횟수", + _cLevelUp + "회 / " + elapsed.ToString("F0") + " s → 600 s 환산 " + per600.ToString("F1") + "회", + sc, note)); + } + // 5-3 + { + int sc = hasPowerDelta ? Unknown : 0; + rows.Add(new Row("5-3", "성장", "강화 후 체감(전투력 % + 처치 타수 변화)", + "PowerDeltaToast 타입=" + (hasPowerDelta ? "있음" : "없음(813l · 바퀴 3)") + + " · 강화 전후 비교는 이 프로브 밖(강화 화면 왕복 필요)", + sc, hasPowerDelta ? "표시 유무는 캡처 판정 · 타수 변화는 강화 전후 2회 샘플 필요" : "813l 미병합")); + } + // 6-1 + { + bool bar = nBossBar > 0; + bool cam = dBossCam > 0; + int kinds = (hasBanner ? 1 : 0) + (cam ? 1 : 0) + (bar ? 1 : 0); + int sc = _cSpawnedBoss == 0 ? Unknown : (kinds >= 3 ? 2 : (kinds == 2 ? 1 : 0)); + rows.Add(new Row("6-1", "보스", "등장 연출 3요소(배너 · 캠 · HP 바)", + "보스 Spawned " + _cSpawnedBoss + " · 배너 타입=" + (hasBanner ? "있음" : "없음(813t)") + + " · 보스 캠 " + dBossCam + " · BossHpBar 컴포넌트 " + nBossBar, sc, + "Spawned(isBoss) · CombatCameraDirector.BossCamCount · 씬 BossHpBar · 같은 프레임 판정은 캡처")); + } + // 6-2 + { + int sc; + float tel = Mean(_bossTelegraph); + if (_bossTelegraph.Count == 0) sc = Unknown; + else if (tel >= 1000f && tel <= 1500f) sc = 2; + else if ((tel >= 500f && tel < 1000f) || (tel > 1500f && tel <= 3000f)) sc = 1; + else sc = 0; + rows.Add(new Row("6-2", "보스", "패턴 예고 → 발동 시간", + _bossTelegraph.Count == 0 ? "보스 SkillCast→HitboxSpawned 관측 0" : + "평균 " + tel.ToString("F0") + " ms (n=" + _bossTelegraph.Count + " · 최소 " + Min(_bossTelegraph).ToString("F0") + + " · 최대 " + Max(_bossTelegraph).ToString("F0") + ")", sc, + "보스 SkillCast → 같은 보스의 HitboxSpawned 간격 · 인디케이터 on 시각과는 다를 수 있다")); + } + // 6-3 + { + int sc; + float len = (_bossSpawnT >= 0f && _bossKillT >= 0f) ? _bossKillT - _bossSpawnT : -1f; + int phases = _bossPhases.Count; + if (_cSpawnedBoss == 0) sc = Unknown; + else if (phases >= 2 && len >= 60f && len <= 120f) sc = 2; + else if (phases >= 2 || (len >= 60f && len <= 120f)) sc = 1; + else sc = 0; + rows.Add(new Row("6-3", "보스", "페이즈 전환 · 전투 길이", + "페이즈 " + phases + "종(BossPhase " + _cBossPhase + "회) · 길이 " + + (len >= 0f ? len.ToString("F1") + " s" : "미완결(스폰 " + (_bossSpawnT >= 0f ? "O" : "X") + " · 처치 " + (_bossKillT >= 0f ? "O" : "X") + ")") + + " · 패턴 표 " + (BossPatternTable.Enabled ? BossPatternTable.RowCount + "행" : "off"), sc, + "BossEvents.BossPhase 고유 phase · Spawned(isBoss)→Killed 시간")); + } + // 7-1 + { + int sc; + float vy = CombatCameraDirector.LastVictimViewportY; + bool everyKill = _cKilled > 0 && dKillCam >= _cKilled; + bool lower40 = vy >= 0f && vy < 0.4f; + if (dKillCam == 0 && _cKilled == 0) sc = Unknown; + else if (dKillCam == 0) sc = Unknown; + else if (everyKill || dKillCamAband > 0) sc = 0; + else if (lower40) sc = 1; + else sc = 2; + rows.Add(new Row("7-1", "카메라", "킬캠 조건 · 복원 · 하단 비침범", + "킬캠 " + dKillCam + "회 / 처치 " + _cKilled + " · 스킵(쿨 " + dKillCamCd + " · 역할 " + dKillCamRole + + " · 런상한 " + dKillCamRunCap + " · 가드 " + (CombatCameraDirector.KillCamSkippedGuard - _b_killCamGuard) + + " · 점유 " + (CombatCameraDirector.KillCamSkippedBusy - _b_killCamBusy) + ") · 포기 " + dKillCamAband + + " · 마지막 피해자 viewportY " + (vy >= 0f ? vy.ToString("F2") : "-") + + " · 복원 실측 " + CombatCameraDirector.LastKillCamMeasured.ToString("F2") + " s", sc, + "CombatCameraDirector 카운터 델타 · viewportY < 0.40 = 하단 40 % 침범")); + } + // 7-2 + { + int sc; + string note = "설정 기하(부채꼴·예약·공격·자동 버튼 중심+반지름) 최대 도달 vs thumbReachRadiusPx · 5해상도·Safe Area 는 캡처 판정"; + float reach = maxReach > 0f ? maxReach : geoReach; + if (hud == null || !padEnabled) sc = 0; + else if (reach > 0f && reachRadius > 0f && reach <= reachRadius) { sc = 2; note += " · 5해상도 실측은 「미확인」"; } + else sc = 1; + rows.Add(new Row("7-2", "조작", "한 손 도달성(조이스틱 + 버튼 전부 반경 안 · Safe Area)", + "최대 도달 " + (reach > 0f ? reach.ToString("F1") : "미측정") + " px / 반경 " + + (reachRadius > 0f ? reachRadius.ToString("F0") : "-") + " px · 조이스틱 중심 " + + (hud != null ? "(" + hud.joystickCenterPx.x.ToString("F0") + "," + hud.joystickCenterPx.y.ToString("F0") + ") r" + + hud.joystickRadiusPx.ToString("F0") : "-") + + " · 미러=" + (hud != null && hud.mirrorLeftHanded ? "좌" : "우"), sc, note)); + } + // 7-3 + { + int sc; + if (!inGame || _fps.Count < 30 || batch) sc = Unknown; + else if (_hitchCount == 0) sc = 2; + else if (_hitchCount <= Mathf.Max(3, _frames / 200)) sc = 1; + else sc = 0; + rows.Add(new Row("7-3", "카메라", "안정성(리드 · 셰이크 · 줌 중 프레임 드롭 · 가림)", + "히치 " + _hitchCount + "프레임/" + _frames + " (프레임 시간 중앙값 2배 초과) · 최악 " + + _worstFrameMs.ToString("F1") + " ms · fps 평균 " + fpsAvg.ToString("F1") + " 최저 " + fpsMin.ToString("F1") + + " · 콤보 리드 " + (CombatCameraDirector.ComboLeadCount - _b_comboLead), sc, + "가림(occlusion) 은 이 프로브가 못 잰다 → 캡처 판정 「미확인」" + offNote + batchNote)); + } + // 8-1 + { + int sc; + if (_cDamagedPc == 0) sc = Unknown; + else if (_cDamagedPcReal == 0) sc = 0; + else if (hasRimLight && dFlashOn > 0) sc = 2; + else sc = 1; + rows.Add(new Row("8-1", "위험", "피격 가시성(HP 감소 + 림라이트/넉백)", + "PC 피격 " + _cDamagedPc + "회 (damage>0 " + _cDamagedPcReal + " · 무적 " + _cDamagedPcInv + + " · 총 피해 " + _pcDamageTotal.ToString("F0") + ") · 피격 연출 타입=" + + (hasRimLight ? "있음" : "없음(811g · 813i)"), sc, + "Damaged(victim=MyPC) 의 damage·invincible · playerInvincible 이 켜져 있으면 전부 0")); + } + // 8-2 + { + int sc = hasPotion ? Unknown : 0; + rows.Add(new Row("8-2", "회복", "물약 흐름(버튼 → 회복 → 쿨)", + "물약 타입=" + (hasPotion ? "있음" : "없음(813i · 813j · 바퀴 3)") + + " · 예약 슬롯 " + (pad != null ? pad.LastReserveSlots.ToString() : "-"), + sc, hasPotion ? "사용 로그는 별도 이벤트 필요" : "전투 물약 미구현")); + } + // 8-3 + { + int sc = hasDeathFlow ? Unknown : 0; + rows.Add(new Row("8-3", "회복", "사망 → 부활 흐름 · 런 사망률", + "PC 사망 " + _pcDeaths + "회 / " + elapsed.ToString("F0") + " s · 사망 흐름 타입=" + + (hasDeathFlow ? "있음" : "없음(813i · 813j · 즉시 On_Regen)") + + " · 런 사망률은 3분 런(813p) 필요", sc, + hasDeathFlow ? "연출 시간·팝업은 캡처 판정" : "즉시 부활(연출 0)")); + } + + // ── 표 조립 ───────────────────────────────────────────────── + var o = new StringBuilder(16384); + o.AppendLine("# WL813 채점 프로브 (#813m) — 기준서 v1 §E 채점표 24항목 " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + o.AppendLine("# 샘플 " + _seconds.ToString("F1") + " s 요청 / 실측 " + elapsed.ToString("F1") + + " s · 프레임 " + _frames + " · 샘플 " + _inView.Count + " (" + _interval.ToString("F2") + " s 주기)" + + (_stopRequested ? " · 조기 종료" : "")); + o.AppendLine("# 씬 " + _sceneNames + " · PC " + _pcName + " Lv" + _pcLevel + + " · 코어 Enabled=" + WLCombatCoreSettings.Enabled + + " · 중재자 " + (TimeScaleArbiter.Enabled ? "on" : "off") + + " · 타격감 " + (WL.Feel.WLHitFeel.Settings != null ? "on" : "null")); + o.AppendLine("# 🔴 제안 점수는 자동 계산이다. 최종 판정 = QA·PD(기준서 §E) · 「미확인」 = 이 샘플에서 측정 불가(C23)."); + if (!inGame) + o.AppendLine("# 🔴 인게임(로비·전투) 미도달 — PC 도 몹도 관측되지 않았다. 전투 항목은 전부 「미확인」이다."); + if (batch) + o.AppendLine("# 🔴 헤드리스(batchmode) 실행 — 프레임 시간이 실제 렌더와 다르고 run_script 컴파일이 수 초짜리 히치로 잡힌다." + + " fps·히치(3-3 · 7-3)는 자동으로 「미확인」 처리한다. 성능 항목은 GUI 에디터에서 다시 잰다."); + if (_inView.Count < Mathf.FloorToInt(elapsed / Mathf.Max(0.01f, _interval)) - 1) + o.AppendLine("# ⚠ 샘플 수(" + _inView.Count + ")가 기대치(" + Mathf.FloorToInt(elapsed / Mathf.Max(0.01f, _interval)) + + ")보다 적다 — 프레임 사이에 시간이 크게 점프했다(에디터 스톨 · 컴파일). 군중 통계의 표본이 그만큼 성기다."); + o.AppendLine(); + o.AppendLine("| ID | 요소 | 항목 | 측정값 | 제안 | 근거 |"); + o.AppendLine("|---|---|---|---|---|---|"); + + int total = 0, unknown = 0; + for (int i = 0; i < rows.Count; i++) + { + var r = rows[i]; + if (r.score == Unknown) unknown++; else total += r.score; + o.AppendLine("| " + r.id + " | " + r.element + " | " + r.title + " | " + r.value + " | " + + (r.score == Unknown ? "「미확인」" : r.score.ToString()) + " | " + r.note + " |"); + } + o.AppendLine(); + o.AppendLine("**제안 합계 = " + total + " / 48** (채점 " + (rows.Count - unknown) + "항목 · 「미확인」 " + unknown + "항목)"); + o.AppendLine(); + + // 요소별 소계 + o.AppendLine("요소별 소계(제안): " + ElementSubtotals(rows)); + o.AppendLine(); + + // ── 원시 지표 ──────────────────────────────────────────────── + o.AppendLine("## 원시 지표 (표에 들어가지 않은 값 포함)"); + o.AppendLine(); + o.AppendLine("### 코어 이벤트 카운트 (샘플 창 델타)"); + o.AppendLine("| 이벤트 | 수 | 이벤트 | 수 |"); + o.AppendLine("|---|---|---|---|"); + o.AppendLine("| AttackStarted | " + _cAttack + " | Killed | " + _cKilled + " |"); + o.AppendLine("| ComboStage | " + _cCombo + " | SkillCast | " + _cSkillCast + " |"); + o.AppendLine("| SwingEffect | " + _cSwing + " | SkillFired | " + _cSkillFired + " |"); + o.AppendLine("| HitboxSpawned | " + _cHbSpawn + " | Damaged | " + _cDamaged + " (PC " + _cDamagedPc + ") |"); + o.AppendLine("| HitboxHit | " + _cHbHit + " (skill " + _cHbHitSkill + ") | Spawned | " + _cSpawned + + " (boss " + _cSpawnedBoss + " · elite " + _cSpawnedElite + ") |"); + o.AppendLine("| HitConfirmed | " + _cHitConf + " | Dash | " + _cDash + " |"); + o.AppendLine("| LevelUp(Growth) | " + _cLevelUp + " | BossPhase | " + _cBossPhase + " |"); + o.AppendLine("| LootDropped | " + _cLootDrop + " | LootPicked | " + _cLootPick + " |"); + o.AppendLine(); + o.AppendLine("### isKill 정합 (QA 결함 D-1)"); + o.AppendLine("- `HitConfirmed.isKill=true` **" + _cHitConfKill + "** vs 실제 `Killed` **" + _cKilled + "**" + + " · 코어 `KillConfirmedCount` 델타 " + dKillConfirmed + + " → 차 " + (_cHitConfKill - _cKilled) + + (_cKilled > 0 ? " (" + (((float)_cHitConfKill / _cKilled - 1f) * 100f).ToString("F1") + " %)" : "") + + " · **0 이어야 정합**"); + o.AppendLine(); + o.AppendLine("### 타격감 · 감속"); + o.AppendLine("- WLHitFeel hit +" + dFeelHit + " · fired +" + dFeelFired + + " · throttled +" + (WL.Feel.WLHitFeel.ThrottledCount - _b_feelThrottled) + + " · lastHitStopMeasured " + WL.Feel.WLHitFeel.LastHitStopMeasured.ToString("F3") + " s"); + o.AppendLine("- 감속 구간(프레임 폴링) n=" + _slowRuns.Count + " · 평균 " + (slowAvg * 1000f).ToString("F1") + + " ms · 최대 " + (slowMax * 1000f).ToString("F1") + " ms"); + o.AppendLine("- 중재자 보고(LastMeasured) n=" + _arbiterMeasured.Count + " · 평균 " + + (arbAvg * 1000f).ToString("F1") + " ms ← 두 지표 정의가 다르다(QA D-7)"); + o.AppendLine("- 몹 점멸 on +" + dFlashOn + " · throttled +" + (MobHitFlash.ThrottledCount - _b_flashThrottled) + + " · 디졸브 해석 " + MobHitFlash.DissolveResolved); + o.AppendLine("- 데미지 합산 flush +" + dAggFlush + " · hits +" + (DamageAggregator.HitCount - _b_aggHits) + + " · 최대 병합 " + DamageAggregator.MaxHitsMerged); + o.AppendLine("- 연쇄 처치 kills +" + (KillChain.KillCount - _b_chainKills) + " · tier +" + dChainTier + + " · chain +" + dChainChain + " · burst +" + (KillChain.BurstCount - _b_chainBurst) + + " · 최고 연쇄 " + KillChain.BestChainEver); + o.AppendLine(); + o.AppendLine("### 군중 샘플"); + o.AppendLine("- 절두체 내 생존 몹: 중앙값 " + inViewMed.ToString("F0") + " · 평균 " + inViewAvg.ToString("F1") + + " · 최소 " + inViewMin.ToString("F0") + " · 최대 " + inViewMax.ToString("F0") + + " · 엘리트(화면 내) 평균 " + Mean(_eliteInView).ToString("F1") + + " · 보스 동시 최대 " + _maxBossAlive); + o.AppendLine("- 씬 생존 몹 평균 " + aliveAvg.ToString("F0") + " · 최대 " + Max(_aliveAll).ToString("F0") + + " · Spawned(elite) " + _cSpawnedElite + " · fps 샘플 n=" + _fpsPerSample.Count); + o.AppendLine("- 첫 레벨업 스탯 포인트 " + _lvStatPoint + " · mp " + _lvMpBefore.ToString("F0") + "/" + _lvMaxMp.ToString("F0")); + o.AppendLine(); + o.AppendLine("### 성능 · GC"); + o.AppendLine("- fps 평균 " + fpsAvg.ToString("F1") + " · 중앙값 " + fpsMed.ToString("F1") + + " · p05 " + fpsP05.ToString("F1") + " · 최저 " + fpsMin.ToString("F1") + + " · 히치 " + _hitchCount + " · 최악 프레임 " + _worstFrameMs.ToString("F1") + " ms"); + o.AppendLine("- EffectBudget acquired +" + (EffectBudget.AcquiredCount - _b_budgetAcq) + + " · denied +" + dBudgetDeny); + o.AppendLine("- GC.GetTotalMemory 증가 " + dGc + " B · Profiler.MonoUsed 증가 " + dMono + + " B ← 프로브 자신의 리스트 + 에디터(run_script Roslyn 컴파일 · 임포트) 할당이 섞인다. 순수 게임 GC 아님"); + o.AppendLine(); + o.AppendLine("### 전리품 · 성장"); + o.AppendLine("- 러너 DropsSeen +" + (LootBurst.DropsSeen - _b_lootDrops) + + " · KilledHandled " + LootBurst.KilledHandled + " · 구독 " + LootBurst.Subscribed); + o.AppendLine("- 드랍 " + _cLootDrop + " (이벤트 빔 " + _cLootBeam + " · 러너 빔 +" + dLootBeam + + " · 산포 +" + dLootScatter + " · 등급4+ " + _cLootGrade4 + ") · 획득 " + _cLootPick + + " · 매칭 " + _pickSeconds.Count + " · 미획득 대기 " + _dropPending.Count); + o.AppendLine("- 레벨업 처리 +" + (LevelUpBurst.Handled - _b_lvHandled) + " · FX +" + dLvBurst + + " · 예산 스킵 " + LevelUpBurst.SkippedByBudget + " · 프리팹 없음 " + LevelUpBurst.SkippedNoPrefab); + o.AppendLine(); + o.AppendLine("### HUD 레이아웃 (813c 산출물)"); + o.AppendLine("- 패드 " + (padEnabled ? "표시" : "숨김") + " · 활성 슬롯 " + activeSlots + + " · 예약 " + (pad != null ? pad.LastReserveSlots.ToString() : "-") + + " · 최대 도달 " + (maxReach > 0f ? maxReach.ToString("F1") : "미측정") + + " px · 설정 기하 " + geoReach.ToString("F1") + " px · 반경 " + reachRadius.ToString("F0") + " px"); + o.AppendLine("- 최소 간격 " + (minGap > 0f ? minGap.ToString("F1") + " px" : "미측정") + + " (기준 ≥16) · 슬롯 지름 " + (hud != null ? hud.slotDiameterPx.ToString("F0") : "-") + " px"); + o.AppendLine("- 씬 컴포넌트: BossHpBar " + nBossBar + " · KillChainText " + nChainText + + " · LootToast " + nToast + " · StatPopup " + nStatPopup); + o.AppendLine(); + o.AppendLine("### 미확인 · 이 프로브가 못 재는 것 (C23)"); + o.AppendLine("- 색·가림·겹침·같은 프레임 동시성의 **눈 판정** = 캡처(`WL813_Shots`) 몫이다."); + o.AppendLine("- 5해상도 Safe Area 실인셋 = 에디터에서 `Screen.safeArea` 가 전체화면이라 재현 불가(실기 빌드 필요)."); + o.AppendLine("- 인디케이터 on 시각(6-2 의 정확한 예고 시작) = 인디케이터 프리팹 배선 뒤 별도 훅 필요. 지금은 보스 SkillCast 기준."); + o.AppendLine("- 런 사망률(8-3) · 첫 10분 레벨업(5-2) = 3분 런/10분 런 실행 필요(813p)."); + o.AppendLine("- 헤드리스(PlayFab 로그인 불가) 에디터에서는 인게임 항목 전부 「미확인」으로 남는다."); + + LastTable = o.ToString(); + LastProposedTotal = total; + LastUnknownCount = unknown; + + string path = ""; + try + { + Directory.CreateDirectory(OutDir); + path = Path.Combine(OutDir, "score_" + DateTime.Now.ToString("HHmmss") + ".txt"); + File.WriteAllText(path, LastTable, new UTF8Encoding(false)); + LastPath = path; + } + catch (Exception e) { path = "(저장 실패: " + e.Message + ")"; } + + LastResult = "제안 합계 " + total + "/48 · 「미확인」 " + unknown + "항목 · " + path; + Debug.Log("[WL813m] " + LastResult + "\n" + LastTable); + } + + // ── 계산 헬퍼 ─────────────────────────────────────────────────── + const int Unknown = -1; + + struct Row + { + public string id, element, title, value, note; + public int score; + public Row(string id, string element, string title, string value, int score, string note) + { this.id = id; this.element = element; this.title = title; this.value = value; this.score = score; this.note = note; } + } + + static string ElementSubtotals(List rows) + { + var order = new List(); + var sum = new Dictionary(); + var unk = new Dictionary(); + for (int i = 0; i < rows.Count; i++) + { + string k = rows[i].element; + if (!sum.ContainsKey(k)) { sum[k] = 0; unk[k] = 0; order.Add(k); } + if (rows[i].score == Unknown) unk[k] = unk[k] + 1; else sum[k] = sum[k] + rows[i].score; + } + var sb = new StringBuilder(); + for (int i = 0; i < order.Count; i++) + { + if (i > 0) sb.Append(" · "); + sb.Append(order[i]).Append(' ').Append(sum[order[i]]); + if (unk[order[i]] > 0) sb.Append("(미확인 ").Append(unk[order[i]]).Append(')'); + } + return sb.ToString(); + } + + static List Gaps(List t) + { + var g = new List(Mathf.Max(1, t.Count)); + for (int i = 1; i < t.Count; i++) g.Add(t[i] - t[i - 1]); + return g; + } + + static float Ratio(List v, float le) + { + if (v.Count == 0) return 0f; + int c = 0; + for (int i = 0; i < v.Count; i++) if (v[i] <= le) c++; + return (float)c / v.Count; + } + + /// 타임스탬프를 gap 이상 벌어질 때마다 끊어 버스트로 묶는다. + static void Bursts(List t, float gap, out int count, out float sizeAvg, out float gapAvg) + { + count = 0; sizeAvg = 0f; gapAvg = 0f; + if (t.Count == 0) return; + var sizes = new List(32); + var gaps = new List(32); + int cur = 1; + float burstEnd = t[0]; + for (int i = 1; i < t.Count; i++) + { + if (t[i] - t[i - 1] <= gap) { cur++; } + else { sizes.Add(cur); gaps.Add(t[i] - burstEnd); cur = 1; } + burstEnd = t[i]; + } + sizes.Add(cur); + count = sizes.Count; + sizeAvg = Mean(sizes); + gapAvg = Mean(gaps); + } + + /// 프리팹 배선이 없을 때 설정값만으로 계산한 최대 도달 반경(px). + static float GeoMaxReach(WLHudLayoutSettings s) + { + float m = 0f; + for (int i = 0; i < Mathf.Max(0, s.activeSkillSlots); i++) + m = Mathf.Max(m, s.FanSlotPx(i).magnitude + s.slotDiameterPx * 0.5f); + for (int i = 0; i < 2; i++) + m = Mathf.Max(m, s.ReserveSlotPx(i).magnitude + s.reserveDiameterPx * 0.5f); + m = Mathf.Max(m, s.attackCenterPx.magnitude + s.attackDiameterPx * 0.5f); + m = Mathf.Max(m, s.autoButtonCenterPx.magnitude + s.autoButtonDiameterPx * 0.5f); + return m; + } + + static int CountOf() where T : Component + { + var a = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + return a != null ? a.Length : 0; + } + } + } +} +#endif diff --git a/Assets/WL/Tools/WL813_ScoreProbe.cs.meta b/Assets/WL/Tools/WL813_ScoreProbe.cs.meta new file mode 100644 index 000000000..2056be662 --- /dev/null +++ b/Assets/WL/Tools/WL813_ScoreProbe.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fbc940e0fc8132a46863c8ff5d335079 \ No newline at end of file diff --git a/Assets/WL/Tools/WL813_ShotOverlay.cs b/Assets/WL/Tools/WL813_ShotOverlay.cs new file mode 100644 index 000000000..069b0c3b1 --- /dev/null +++ b/Assets/WL/Tools/WL813_ShotOverlay.cs @@ -0,0 +1,378 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WL813_ShotOverlay.cs — #813m 캡처 세트 + 엄지 반경 오버레이 (에디터/개발빌드 전용) +// +// 무엇인가: +// ① 세로 캡처 5장 세트(타이틀 · HUD · 전투 · 보스 · 팝업)를 이름 규약으로 자동 저장한다. +// 파일명 `<번호>_<슬롯>_x_.png` · 폴더 `Screenshots_WL/Shots/`(Assets 밖 · 미추적). +// ② **엄지 반경 오버레이** — 우하단(미러면 좌하단) 모서리 기준 반경 원 + 버튼 실제 지름 원을 +// `WLHudLayoutSettings` 값 그대로 그린다. 반경 안 = 초록 · 밖 = 빨강 · 조이스틱 = 파랑. +// 캡처 직후 반드시 제거한다(런타임 GameObject · `HideFlags.DontSave` · 씬 저장 대상 아님). +// +// 왜 `capture_game_view` 를 그대로 쓰지 않는가: `--source screen` 은 에디터 창 비율로 나와 +// 세로 1080×1920 검증에 못 쓴다(WL796_Shot 주석과 같은 이유). 여기서는 `ScreenCapture` 로 직접 뜬다. +// 게임뷰 해상도 전환은 기존 도구 `AgentScripts/WL_GameViewSize.cs` 를 쓴다(중복 구현 금지). +// +// 🔴 읽기 전용: 게임 상태·에셋·설정을 바꾸지 않는다. 오버레이는 Play 중 임시 오브젝트이고 캡처 후 파괴된다. +// 🔴 CLI 명령을 만들지 않는다(802c 소관). 진입점은 `AgentScripts/WL813_Shots.cs`(얇은 래퍼). +// 🔴 릴리즈 빌드 제외. +// ───────────────────────────────────────────────────────────────────────────── + +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEngine; +using UnityEngine.UI; +using WL.UI; + +namespace WL.Tools +{ + /// #813m 캡처 세트 · 엄지 반경 오버레이. + public static class WL813_ShotOverlay + { + public const string OutDir = "Screenshots_WL/Shots"; + + /// 기준서 §D-1 813m 의 5장 세트 순서. + public static readonly string[] SlotOrder = { "title", "hud", "combat", "boss", "popup" }; + + /// 오버레이를 같이 뜨는 슬롯(패드가 보이는 인게임 화면). + static readonly string[] ReachSlots = { "hud", "combat", "boss" }; + + public const int DesignWidth = 1080, DesignHeight = 1920; + + public static string LastResult = "(not run)"; + public static string LastPath = ""; + public static string LastManifest = ""; + public static readonly List Saved = new List(16); + + static Runner s_runner; + static GameObject s_overlay; + + public static bool OverlayActive { get { return s_overlay != null; } } + public static bool Running { get { return s_runner != null; } } + + // ── 진입점 ──────────────────────────────────────────────────────────── + + /// 5장 세트. `interval` 초마다 한 장씩(그 사이에 QA 가 화면을 만든다). + public static string RunSet(float intervalSeconds, bool withOverlay) + { + if (!Application.isPlaying) return "not playing — Title 에서 Play 후 실행한다"; + if (s_runner != null) return "already running — " + Status(); + + float itv = intervalSeconds > 0f ? intervalSeconds : 6f; + EnsureRunner(); + Saved.Clear(); + LastResult = "(running)"; + s_runner.StartCoroutine(s_runner.Co_Set(itv, withOverlay)); + return "started — 슬롯 " + string.Join(" → ", SlotOrder) + " · " + itv.ToString("F1") + + " s 간격 · 오버레이 " + (withOverlay ? "on" : "off") + + (SizeWarning() ?? "") + " · 진행은 WL813_Shots.Result"; + } + + /// 한 장만. slot = title|hud|combat|boss|popup(그 밖의 이름도 그대로 파일명에 쓴다). + public static string Shot(string slot, bool withOverlay) + { + if (!Application.isPlaying) return "not playing"; + if (s_runner != null) return "busy — 세트 진행 중 (" + Status() + ")"; + EnsureRunner(); + string s = string.IsNullOrEmpty(slot) ? "shot" : slot.Trim(); + s_runner.StartCoroutine(s_runner.Co_One(s, withOverlay)); + return "capturing " + s + (withOverlay ? " (+엄지 반경 오버레이)" : "") + (SizeWarning() ?? "") + + " — WL813_Shots.Result 로 확인"; + } + + public static string Status() + { + if (s_runner != null) return "running · saved " + Saved.Count; + return "idle · saved " + Saved.Count + " · last=" + (string.IsNullOrEmpty(LastPath) ? "(none)" : LastPath); + } + + /// 세로 규격이 아니면 경고 문구를 돌려준다(전환은 WL_GameViewSize 몫). + public static string SizeWarning() + { + if (Screen.width == DesignWidth && Screen.height == DesignHeight) return null; + return " · 🔴 게임뷰 " + Screen.width + "x" + Screen.height + " (규격 " + DesignWidth + "x" + DesignHeight + + " — WL_GameViewSize.Set [1080,1920] 로 맞춘 뒤 다시)"; + } + + static void EnsureRunner() + { + if (s_runner != null) return; + var go = GameObject.Find("__wl813_shots"); + if (go == null) + { + go = new GameObject("__wl813_shots"); + go.hideFlags = HideFlags.DontSave; + } + s_runner = go.GetComponent(); + if (s_runner == null) s_runner = go.AddComponent(); + } + + // ── 오버레이 ────────────────────────────────────────────────────────── + + /// 엄지 반경 오버레이를 띄운다. 이미 있으면 다시 그린다. + public static string ShowOverlay() + { + HideOverlay(); + var hud = WLHudLayoutSettings.Instance; + if (hud == null) return "WLHudLayoutSettings 없음 — 오버레이 생략"; + + float k = hud.designWidthPx > 0f ? Screen.width / hud.designWidthPx : 1f; + bool mirror = hud.mirrorLeftHanded; + + s_overlay = new GameObject("__wl813_reach_overlay", typeof(RectTransform), typeof(Canvas)); + s_overlay.hideFlags = HideFlags.DontSave; + var canvas = s_overlay.GetComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + canvas.sortingOrder = 32760; // 게임 UI 위 + + var sb = new StringBuilder(); + sb.Append("반경 ").Append(hud.thumbReachRadiusPx.ToString("F0")).Append(" px · k=").Append(k.ToString("F3")); + + // 반경 원(모서리 기준) + AddRing(canvas.transform, hud.fanOriginPx, hud.thumbReachRadiusPx * 2f, k, mirror, + new Color(1f, 0.85f, 0.15f, 0.9f), 4f, "reach"); + + float r = hud.thumbReachRadiusPx; + int outside = 0, inside = 0; + float maxReach = 0f; + + for (int i = 0; i < Mathf.Max(0, hud.activeSkillSlots); i++) + Mark(canvas.transform, hud.FanSlotPx(i), hud.slotDiameterPx, k, mirror, r, ref inside, ref outside, ref maxReach, "skill" + i, sb); + for (int i = 0; i < 2; i++) + Mark(canvas.transform, hud.ReserveSlotPx(i), hud.reserveDiameterPx, k, mirror, r, ref inside, ref outside, ref maxReach, "reserve" + i, sb); + Mark(canvas.transform, hud.attackCenterPx, hud.attackDiameterPx, k, mirror, r, ref inside, ref outside, ref maxReach, "attack", sb); + Mark(canvas.transform, hud.autoButtonCenterPx, hud.autoButtonDiameterPx, k, mirror, r, ref inside, ref outside, ref maxReach, "auto", sb); + + // 조이스틱(반대쪽 모서리 기준 · 반경 밖 판정 대상 아님) + AddRing(canvas.transform, hud.joystickCenterPx, hud.joystickRadiusPx * 2f, k, !mirror, + new Color(0.3f, 0.7f, 1f, 0.85f), 3f, "joystick"); + + LastResult = "overlay on · " + sb + " · 안 " + inside + " · 밖 " + outside + + " · 최대 도달 " + maxReach.ToString("F1") + " px"; + return LastResult; + } + + public static string HideOverlay() + { + if (s_overlay == null) return "overlay off"; + UnityEngine.Object.DestroyImmediate(s_overlay); + s_overlay = null; + return "overlay removed"; + } + + /// 버튼 하나를 실제 지름 원으로 표시하고 반경 안/밖을 색으로 구분한다. + static void Mark(Transform parent, Vector2 px, float diameterPx, float k, bool mirror, float radiusPx, + ref int inside, ref int outside, ref float maxReach, string name, StringBuilder sb) + { + float reach = px.magnitude + diameterPx * 0.5f; + if (reach > maxReach) maxReach = reach; + bool ok = radiusPx <= 0f || reach <= radiusPx; + if (ok) inside++; else outside++; + var c = ok ? new Color(0.2f, 0.95f, 0.45f, 0.95f) : new Color(1f, 0.25f, 0.28f, 0.95f); + AddRing(parent, px, diameterPx, k, mirror, c, 3f, name); + sb.Append(" · ").Append(name).Append(' ').Append(reach.ToString("F0")); + } + + /// 모서리 기준 (dx, dy) px 자리에 지름 diameterPx 인 링을 놓는다. + static void AddRing(Transform parent, Vector2 px, float diameterPx, float k, bool mirror, + Color color, float thicknessPx, string name) + { + var go = new GameObject("ring_" + name, typeof(RectTransform), typeof(RawImage)); + go.hideFlags = HideFlags.DontSave; + var rt = (RectTransform)go.transform; + rt.SetParent(parent, false); + + var img = go.GetComponent(); + img.raycastTarget = false; + img.color = color; + img.texture = RingTexture(Mathf.Max(2f, thicknessPx * 128f / Mathf.Max(8f, diameterPx * k))); + + Vector2 anchor = new Vector2(mirror ? 0f : 1f, 0f); + rt.anchorMin = anchor; + rt.anchorMax = anchor; + rt.pivot = new Vector2(0.5f, 0.5f); + float sx = mirror ? px.x : -px.x; + rt.anchoredPosition = new Vector2(sx * k, px.y * k); + rt.sizeDelta = new Vector2(diameterPx * k, diameterPx * k); + rt.localScale = Vector3.one; + } + + // 링 텍스처는 두께 비율별로 캐시한다(캡처마다 새로 만들지 않는다). + static readonly Dictionary s_ringCache = new Dictionary(); + + static Texture2D RingTexture(float thicknessTexels) + { + const int N = 128; + float th = Mathf.Clamp(thicknessTexels, 1.5f, 24f); + int key = Mathf.RoundToInt(th * 4f); + Texture2D tex; + if (s_ringCache.TryGetValue(key, out tex) && tex != null) return tex; + + tex = new Texture2D(N, N, TextureFormat.RGBA32, false); + tex.hideFlags = HideFlags.DontSave; + tex.wrapMode = TextureWrapMode.Clamp; + var px = new Color32[N * N]; + float c = (N - 1) * 0.5f; + float outer = c; // 바깥 반지름 + float mid = outer - th * 0.5f; // 링 중심선 + for (int y = 0; y < N; y++) + { + for (int x = 0; x < N; x++) + { + float dx = x - c, dy = y - c; + float d = Mathf.Sqrt(dx * dx + dy * dy); + float a = 1f - Mathf.Clamp01((Mathf.Abs(d - mid) - th * 0.5f + 1f) / 1.5f); + byte b = (byte)Mathf.RoundToInt(Mathf.Clamp01(a) * 255f); + px[y * N + x] = new Color32(255, 255, 255, b); + } + } + tex.SetPixels32(px); + tex.Apply(false, false); + s_ringCache[key] = tex; + return tex; + } + + // ── 캡처 ────────────────────────────────────────────────────────────── + + static int SlotIndex(string slot) + { + for (int i = 0; i < SlotOrder.Length; i++) + if (string.Equals(SlotOrder[i], slot, StringComparison.OrdinalIgnoreCase)) return i + 1; + return 9; + } + + static bool WantsReach(string slot) + { + for (int i = 0; i < ReachSlots.Length; i++) + if (string.Equals(ReachSlots[i], slot, StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + + internal sealed class Runner : MonoBehaviour + { + public IEnumerator Co_One(string slot, bool withOverlay) + { + yield return Capture(slot, false); + if (withOverlay) + { + ShowOverlay(); + yield return Capture(slot + "_reach", true); + HideOverlay(); + } + Finish(); + } + + public IEnumerator Co_Set(float interval, bool withOverlay) + { + var log = new StringBuilder(); + log.AppendLine("# WL813 캡처 세트 (#813m) " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + log.AppendLine("# 게임뷰 " + Screen.width + "x" + Screen.height + " · 간격 " + interval.ToString("F1") + + " s · 오버레이 " + (withOverlay ? "on" : "off")); + var w = SizeWarning(); + if (w != null) log.AppendLine("#" + w); + log.AppendLine("# 슬롯 사이 " + interval.ToString("F0") + " s 안에 해당 화면을 만든다(타이틀 → HUD → 전투 → 보스 → 팝업)."); + log.AppendLine(); + + for (int i = 0; i < SlotOrder.Length; i++) + { + string slot = SlotOrder[i]; + Debug.Log("[WL813m] 다음 캡처 = " + slot + " (" + interval.ToString("F0") + " s 뒤)"); + float t0 = Time.unscaledTime; + while (Time.unscaledTime - t0 < interval) yield return null; + + yield return Capture(slot, false); + log.AppendLine("- " + slot + " → " + (Saved.Count > 0 ? Saved[Saved.Count - 1] : "(실패)")); + + if (withOverlay && WantsReach(slot)) + { + string ov = ShowOverlay(); + yield return Capture(slot + "_reach", true); + log.AppendLine(" · 오버레이 → " + (Saved.Count > 0 ? Saved[Saved.Count - 1] : "(실패)") + " [" + ov + "]"); + HideOverlay(); + } + } + + log.AppendLine(); + log.AppendLine(LayoutSummary()); + LastManifest = log.ToString(); + try + { + Directory.CreateDirectory(OutDir); + var p = Path.Combine(OutDir, "shots_" + DateTime.Now.ToString("HHmmss") + ".txt"); + File.WriteAllText(p, LastManifest, new UTF8Encoding(false)); + LastManifest += "\n(매니페스트 " + p + ")"; + } + catch (Exception e) { LastManifest += "\n(매니페스트 저장 실패 " + e.Message + ")"; } + + LastResult = "세트 완료 · " + Saved.Count + "장 · " + OutDir; + Debug.Log("[WL813m] " + LastResult + "\n" + LastManifest); + Finish(); + } + + IEnumerator Capture(string slot, bool overlayOn) + { + yield return new WaitForEndOfFrame(); + Texture2D tex = null; + try + { + tex = ScreenCapture.CaptureScreenshotAsTexture(); + Directory.CreateDirectory(OutDir); + string name = SlotIndex(slot.Replace("_reach", "")).ToString("00") + "_" + slot + "_" + + tex.width + "x" + tex.height + "_" + DateTime.Now.ToString("HHmmss") + ".png"; + string path = Path.Combine(OutDir, name); + File.WriteAllBytes(path, tex.EncodeToPNG()); + Saved.Add(path); + LastPath = path; + Debug.Log("[WL813m] 캡처 " + path + (overlayOn ? " (엄지 반경 오버레이)" : "")); + } + catch (Exception e) { Debug.LogWarning("[WL813m] 캡처 실패 " + slot + " · " + e.Message); } + finally { if (tex != null) UnityEngine.Object.Destroy(tex); } + } + + void Finish() + { + HideOverlay(); + s_runner = null; + if (gameObject != null) Destroy(gameObject); + } + + void OnDestroy() { if (s_runner == this) s_runner = null; } + } + + /// 캡처 판정에 같이 붙일 레이아웃 수치(설정 + 실제 배치). + public static string LayoutSummary() + { + var hud = WLHudLayoutSettings.Instance; + if (hud == null) return "WLHudLayoutSettings 없음"; + var pad = UnityEngine.Object.FindFirstObjectByType(FindObjectsInactive.Include); + + var sb = new StringBuilder(); + sb.AppendLine("## 엄지 반경 수치 (기준서 §E 3-1 · 7-2)"); + sb.AppendLine("- 반경 " + hud.thumbReachRadiusPx.ToString("F0") + " px · 슬롯 " + hud.activeSkillSlots + + "개 지름 " + hud.slotDiameterPx.ToString("F0") + " px · 최소 간격 기준 " + hud.slotMinGapPx.ToString("F0") + " px"); + sb.AppendLine("- 부채꼴 원점 (" + hud.fanOriginPx.x.ToString("F0") + "," + hud.fanOriginPx.y.ToString("F0") + + ") · 반지름 " + hud.fanRadiusPx.ToString("F0") + " px · 시작 " + hud.fanStartAngleDeg.ToString("F1") + + "° 간격 " + hud.fanStepAngleDeg.ToString("F1") + "°"); + float maxGeo = 0f; + for (int i = 0; i < Mathf.Max(0, hud.activeSkillSlots); i++) + maxGeo = Mathf.Max(maxGeo, hud.FanSlotPx(i).magnitude + hud.slotDiameterPx * 0.5f); + for (int i = 0; i < 2; i++) + maxGeo = Mathf.Max(maxGeo, hud.ReserveSlotPx(i).magnitude + hud.reserveDiameterPx * 0.5f); + maxGeo = Mathf.Max(maxGeo, hud.attackCenterPx.magnitude + hud.attackDiameterPx * 0.5f); + maxGeo = Mathf.Max(maxGeo, hud.autoButtonCenterPx.magnitude + hud.autoButtonDiameterPx * 0.5f); + sb.AppendLine("- 설정 기하 최대 도달 " + maxGeo.ToString("F1") + " px → 반경 " + + (maxGeo <= hud.thumbReachRadiusPx ? "안 ✅" : "밖 🔴")); + sb.AppendLine("- 실제 배치 " + (pad != null + ? "maxReach " + pad.LastMaxReachPx.ToString("F1") + " px · minGap " + pad.LastMinGapPx.ToString("F1") + + " px · 슬롯 " + pad.LastActiveSlots + "+예약 " + pad.LastReserveSlots + : "WLBattlePadLayout 없음(패드 미표시 · 「미확인」)")); + sb.AppendLine("- 조이스틱 (" + hud.joystickCenterPx.x.ToString("F0") + "," + hud.joystickCenterPx.y.ToString("F0") + + ") 반경 " + hud.joystickRadiusPx.ToString("F0") + " px · 미러=" + (hud.mirrorLeftHanded ? "좌손" : "우손")); + return sb.ToString(); + } + } +} +#endif diff --git a/Assets/WL/Tools/WL813_ShotOverlay.cs.meta b/Assets/WL/Tools/WL813_ShotOverlay.cs.meta new file mode 100644 index 000000000..786e66347 --- /dev/null +++ b/Assets/WL/Tools/WL813_ShotOverlay.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 048bebe914fadfa46b9eaa045f6f5551 \ No newline at end of file