// 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);
}
}
}