// WL797_Probe.cs — PD #797 타격 임팩트 연출 실측 · 캡처 (Play 전용) // // unity command run_script --file AgentScripts/WL797_Probe.cs --entry WL797_Probe.Watch --args '[8.0,"combo"]' // unity command run_script --file AgentScripts/WL797_Probe.cs --entry WL797_Probe.Result --args '[]' // unity command run_script --file AgentScripts/WL797_Probe.cs --entry WL797_Probe.Budget --args '[4.0]' // unity command run_script --file AgentScripts/WL797_Probe.cs --entry WL797_Probe.State --args '[]' // // 측정 항목 // · 타격 시각(훅 호출) vs 피드백 발동 시각(카메라 오프셋이 실제로 0 이 아닌 첫 프레임) — 지연 프레임 수 // · 스윙당 발동 횟수 (HitCount / FiredCount / ThrottledCount) // · 히트스톱 실측 길이 (timeScale 이 1 미만인 연속 구간을 realtime 으로 잰다) // · 카메라 오프셋 최대 크기(m) // · 피격 몹 스케일 배율 최대치 // · 콘솔 에러는 get_console_logs 로 별도 확인 // // 캡처: Screenshots_WL/feel/{tag}_{before|hit|p050|p100}.png // "before" 는 매 프레임 1 장을 메모리에 덮어쓰며 유지하다가, 타격이 감지되면 직전 프레임 것을 저장한다. // ⚠ 에셋(SO·머티리얼·프리팹)을 런타임에 고치지 않는다. A/B 는 WLHitFeel.RuntimeDisabled 만 토글한다. using System; using System.Collections; using System.Collections.Generic; using System.Text; using UnityEngine; using WL.Feel; public static class WL797_Probe { const string OutDir = "Screenshots_WL/feel"; public static string Result_ = "(not run)"; public static object Result() { return Result_; } /// 현재 상태 스냅샷 — Play 진입 직후 배선 확인용. public static object State() { var sb = new StringBuilder(); var st = WLHitFeel.Settings; sb.AppendLine("isPlaying=" + Application.isPlaying + " timeScale=" + Time.timeScale); sb.AppendLine("Settings=" + (st == null ? "NULL (에셋 없음 → 기능 비활성)" : "OK enabled=" + st.enabled + " mode=" + st.hitStopMode + " stopDur=" + st.hitStopDuration + " shakeAmp=" + st.shakeAmplitude + " punch=" + st.scalePunch + " flash=" + st.flashEnabled + " suppressLegacy=" + st.suppressLegacyShake)); var cam = Camera.main; sb.AppendLine("Camera.main=" + (cam == null ? "NULL" : cam.name) + " parent=" + (cam != null && cam.transform.parent != null ? cam.transform.parent.name : "(none)")); var sh = WLHitFeel.GetShaker(false); sb.AppendLine("Shaker=" + (sh == null ? "not attached (첫 타격 때 자동 부착)" : "attached maxOffset=" + sh.MaxOffsetMagnitude)); sb.AppendLine("RuntimeDisabled=" + WLHitFeel.RuntimeDisabled); sb.AppendLine("counts hit=" + WLHitFeel.HitCount + " fired=" + WLHitFeel.FiredCount + " throttled=" + WLHitFeel.ThrottledCount); return sb.ToString(); } /// seconds 동안 전투를 관찰하며 실측 + 첫 3 회 타격 캡처. public static object Watch(float seconds, string tag) { return WatchN(seconds, tag, 3); } /// /// maxShots = 0 이면 캡처를 하지 않는다 → 루프가 매 프레임 정확히 1 회 돌아 /// 히트스톱 길이를 프레임 단위로 정확히 잴 수 있다(캡처 대기가 측정을 오염시키지 않는다). /// public static object WatchN(float seconds, string tag, int maxShots) { if (!Application.isPlaying) return "not playing"; if (GameObject.Find("__wl797probe") != null) return "already running"; var h = new GameObject("__wl797probe").AddComponent(); h.MaxShots = maxShots; h.StartCoroutine(h.CoWatch(seconds, string.IsNullOrEmpty(tag) ? "combo" : tag)); return "started watch " + seconds + "s tag=" + tag + " maxShots=" + maxShots; } /// ON/OFF 각 seconds 초씩 돌려 모바일 예산 증분을 잰다(에셋 무수정). public static object Budget(float seconds) { if (!Application.isPlaying) return "not playing"; if (GameObject.Find("__wl797budget") != null) return "already running"; var h = new GameObject("__wl797budget").AddComponent(); h.StartCoroutine(h.CoBudget(seconds)); return "started budget " + seconds + "s x2"; } class Sample { public int frame; public float t; public float ts; public float camOff; public float scaleRatio; public int hit, fired, thr; } class H : MonoBehaviour { public int MaxShots = 3; // ── 관찰 + 캡처 ──────────────────────────────────────────────────── public IEnumerator CoWatch(float sec, string tag) { System.IO.Directory.CreateDirectory(OutDir); WLHitFeel.ResetDiagnostics(); var log = new List(); var samples = new List(); var st = WLHitFeel.Settings; log.Add("=== WL797 watch tag=" + tag + " sec=" + sec + " ==="); log.Add(st == null ? "Settings=NULL" : "Settings enabled=" + st.enabled + " throttle=" + st.throttleSeconds + " stopDur=" + st.hitStopDuration + " stopKill=" + st.hitStopKillDuration + " stopScale=" + st.hitStopTimeScale + " mode=" + st.hitStopMode + " shakeAmp=" + st.shakeAmplitude + " shakeDur=" + st.shakeDuration + " punch=" + st.scalePunch + " punchDur=" + st.scalePunchDuration + " flash=" + st.flashEnabled + " suppressLegacy=" + st.suppressLegacyShake); Texture2D prevShot = null; int prevFired = WLHitFeel.FiredCount; int shotsTaken = 0; // 히트스톱 구간 측정 bool inStop = false; float stopT0 = 0f; var stopLens = new List(); // 피드백 지연 측정 int pendingFireFrame = -1; float pendingFireTime = 0f; float maxCamOff = 0f, maxScaleRatio = 1f; float t0 = Time.realtimeSinceStartup; while (Time.realtimeSinceStartup - t0 < sec) { yield return new WaitForEndOfFrame(); var sh = WLHitFeel.GetShaker(false); float camOff = sh != null ? sh.CurrentOffset.magnitude : 0f; if (camOff > maxCamOff) maxCamOff = camOff; float ratio = 1f; if (WLHitFeel.LastVictimTf != null && WLHitFeel.LastVictimBaseScaleX > 0.0001f) { ratio = WLHitFeel.LastVictimTf.localScale.x / WLHitFeel.LastVictimBaseScaleX; if (ratio > maxScaleRatio) maxScaleRatio = ratio; } // 히트스톱 구간 if (!inStop && Time.timeScale < 0.9f) { inStop = true; stopT0 = Time.realtimeSinceStartup; } else if (inStop && Time.timeScale >= 0.9f) { inStop = false; stopLens.Add(Time.realtimeSinceStartup - stopT0); } samples.Add(new Sample { frame = Time.frameCount, t = Time.realtimeSinceStartup - t0, ts = Time.timeScale, camOff = camOff, scaleRatio = ratio, hit = WLHitFeel.HitCount, fired = WLHitFeel.FiredCount, thr = WLHitFeel.ThrottledCount, }); // ── 타격 감지 int fired = WLHitFeel.FiredCount; if (fired != prevFired) { prevFired = fired; pendingFireFrame = WLHitFeel.LastFiredFrame; pendingFireTime = Time.realtimeSinceStartup; log.Add(string.Format("[FIRE #{0}] frame={1} t={2:F3} kill={3} camOff={4:F4} ts={5:F3}", fired, WLHitFeel.LastFiredFrame, Time.realtimeSinceStartup - t0, WLHitFeel.LastWasKill, camOff, Time.timeScale)); if (shotsTaken < MaxShots) { shotsTaken++; string p = tag + "_" + shotsTaken; // 직전 프레임(유지해 둔 것) if (prevShot != null) SaveTex(prevShot, p + "_0before", log); // 타격 프레임 yield return Cap(p + "_1hit", log); // +0.05 s / +0.10 s (realtime — 히트스톱 중에도 흐른다) yield return WaitReal(0.05f); yield return Cap(p + "_2p050", log); yield return WaitReal(0.05f); yield return Cap(p + "_3p100", log); } } else if (pendingFireFrame >= 0 && camOff > 0.0001f) { log.Add(string.Format(" → 피드백 가시화 frame={0} (타격 frame={1}, 지연 {2} 프레임 · {3:F1} ms)", Time.frameCount, pendingFireFrame, Time.frameCount - pendingFireFrame, (Time.realtimeSinceStartup - pendingFireTime) * 1000f)); pendingFireFrame = -1; } // 다음 루프의 "직전" 후보로 이번 프레임을 유지 if (shotsTaken < MaxShots) { if (prevShot != null) UnityEngine.Object.Destroy(prevShot); prevShot = TryCapture(); } else if (prevShot != null) { UnityEngine.Object.Destroy(prevShot); prevShot = null; } } if (prevShot != null) UnityEngine.Object.Destroy(prevShot); // ── 요약 var sb = new StringBuilder(); foreach (var l in log) sb.AppendLine(l); sb.AppendLine("── 요약 ──"); sb.AppendLine("HitCount=" + WLHitFeel.HitCount + " FiredCount=" + WLHitFeel.FiredCount + " Throttled=" + WLHitFeel.ThrottledCount + " (스윙당 1회면 Fired ≒ 스윙 수, Throttled = 같은 스윙의 나머지 몹)"); sb.AppendLine("카메라 오프셋 최대 = " + maxCamOff.ToString("F4") + " m"); var shk = WLHitFeel.GetShaker(false); sb.AppendLine("Shaker.MaxOffsetMagnitude = " + (shk != null ? shk.MaxOffsetMagnitude.ToString("F4") : "(none)") + " m"); sb.AppendLine("몹 스케일 배율 최대 = " + maxScaleRatio.ToString("F3") + " x"); if (stopLens.Count > 0) { float sum = 0f, mx = 0f; foreach (var v in stopLens) { sum += v; if (v > mx) mx = v; } sb.AppendLine("히트스톱 실측 n=" + stopLens.Count + " 평균=" + (sum / stopLens.Count).ToString("F4") + "s 최대=" + mx.ToString("F4") + "s"); } else sb.AppendLine("히트스톱 실측 = 관측 없음"); sb.AppendLine("WLHitFeel.LastHitStopMeasured = " + WLHitFeel.LastHitStopMeasured.ToString("F4") + "s (-1 = 외부 개입으로 복원 생략)"); // 프레임 표 (타격 근처만) sb.AppendLine("── 프레임 표 (timeScale<1 또는 camOff>0 또는 scale≠1) ──"); int printed = 0; foreach (var s in samples) { if (printed >= 160) { sb.AppendLine(" ...(생략)"); break; } if (s.ts < 0.999f || s.camOff > 0.0001f || Mathf.Abs(s.scaleRatio - 1f) > 0.002f) { sb.AppendLine(string.Format(" f={0} t={1:F3} ts={2:F3} camOff={3:F4} scale={4:F3} h/f/t={5}/{6}/{7}", s.frame, s.t, s.ts, s.camOff, s.scaleRatio, s.hit, s.fired, s.thr)); printed++; } } Result_ = sb.ToString(); System.IO.File.WriteAllText(System.IO.Path.Combine(OutDir, "watch_" + tag + ".txt"), Result_); Debug.Log("[WL797 watch]\n" + Result_); Destroy(gameObject); } // ── 예산 A/B ─────────────────────────────────────────────────────── public IEnumerator CoBudget(float sec) { var sb = new StringBuilder(); bool orig = WLHitFeel.RuntimeDisabled; for (int pass = 0; pass < 2; pass++) { bool on = pass == 0; WLHitFeel.RuntimeDisabled = !on; // 에셋은 건드리지 않는다 yield return WaitReal(0.4f); // 안정화 int mSet = 0, mBat = 0, mDraw = 0, mTri = 0, n = 0; long aSet = 0, aBat = 0, aDraw = 0, aTri = 0; float t0 = Time.realtimeSinceStartup; while (Time.realtimeSinceStartup - t0 < sec) { yield return new WaitForEndOfFrame(); int sp = UnityEditor.UnityStats.setPassCalls, b = UnityEditor.UnityStats.batches; int d = UnityEditor.UnityStats.drawCalls, tr = UnityEditor.UnityStats.triangles; if (sp > mSet) mSet = sp; if (b > mBat) mBat = b; if (d > mDraw) mDraw = d; if (tr > mTri) mTri = tr; aSet += sp; aBat += b; aDraw += d; aTri += tr; n++; yield return null; } sb.AppendLine((on ? "HitFeel ON " : "HitFeel OFF ") + " max setPass=" + mSet + " batches=" + mBat + " draw=" + mDraw + " tris=" + mTri + " | avg setPass=" + (n > 0 ? aSet / n : 0) + " batches=" + (n > 0 ? aBat / n : 0) + " draw=" + (n > 0 ? aDraw / n : 0) + " tris=" + (n > 0 ? aTri / n : 0) + " frames=" + n); } WLHitFeel.RuntimeDisabled = orig; sb.AppendLine("RuntimeDisabled 원복 = " + WLHitFeel.RuntimeDisabled); sb.AppendLine("예산 상한: SetPass ≤150 · 배치 ≤300 · 드로우 ≤400 · 삼각형 ≤300000"); Result_ = sb.ToString(); System.IO.Directory.CreateDirectory(OutDir); System.IO.File.WriteAllText(System.IO.Path.Combine(OutDir, "budget.txt"), Result_); Debug.Log("[WL797 예산]\n" + Result_); Destroy(gameObject); } // ── 유틸 ─────────────────────────────────────────────────────────── static IEnumerator WaitReal(float s) { float t0 = Time.realtimeSinceStartup; while (Time.realtimeSinceStartup - t0 < s) yield return null; } static Texture2D TryCapture() { try { return ScreenCapture.CaptureScreenshotAsTexture(); } catch { return null; } } static void SaveTex(Texture2D tex, string name, List log) { if (tex == null) return; try { System.IO.Directory.CreateDirectory(OutDir); var path = System.IO.Path.Combine(OutDir, name + ".png"); System.IO.File.WriteAllBytes(path, tex.EncodeToPNG()); log.Add(" CAP " + path); } catch (Exception e) { log.Add(" CAP FAIL " + name + " " + e.GetType().Name); } } static IEnumerator Cap(string name, List log) { yield return new WaitForEndOfFrame(); var tex = TryCapture(); SaveTex(tex, name, log); if (tex != null) UnityEngine.Object.Destroy(tex); } } }