using System.Collections; using System.Text; using UnityEngine; using WL.Player; namespace WL.Diagnostics { /// /// 자동 검증용 계측 프로브 (에디터 Play 전용). /// 실제 조작 대신 PlayerController 에 화면 기준 입력을 주입하고, 프레임 단위로 카메라 값을 샘플링해 /// 콘솔에 요약을 남긴다. 게임 로직에는 관여하지 않으며, 씬에 상주시키지 않고 검증 때만 붙였다 뗀다. /// /// 계측 항목 /// ① 요 추종 수렴 (PD #713 ③) — 8 방향 각각 유지 시 카메라 요가 입력 방향에 수렴하는 시각·프레임당 최대 각변화 /// ② 지그재그·급반전 (PD #709 ⑨) — 프레임당 카메라 위치·요 변화 최대값, 주시점 데드존 동작 /// public class WLAutoProbe : MonoBehaviour { private PlayerController _pc; private FollowCamera _cam; /// 마지막 계측 결과 요약(여러 줄). public string Report { get; private set; } /// 계측 진행 중인가. public bool Running { get; private set; } // 8 방향 화면 기준 입력 (x = 우, y = 상) private static readonly Vector2[] Dirs = { new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(1f, 0f), new Vector2(1f, -1f), new Vector2(0f, -1f), new Vector2(-1f, -1f), new Vector2(-1f, 0f), new Vector2(-1f, 1f) }; private static readonly string[] DirNames = { "상", "우상", "우", "우하", "하", "좌하", "좌", "좌상" }; private void Awake() { Resolve(); } private void Resolve() { if (_pc == null) _pc = Object.FindFirstObjectByType(); if (_cam == null) _cam = Object.FindFirstObjectByType(); } // ───────────────────────────────── ① 요 추종 수렴 /// 8 방향 스윕. holdSeconds 동안 각 방향을 유지하며 카메라 요의 수렴을 잰다. public void RunYawSweep(int modeIndex, float holdSeconds, float toleranceDeg) { Resolve(); StopAllCoroutines(); StartCoroutine(YawSweep(modeIndex, holdSeconds, toleranceDeg)); } private IEnumerator YawSweep(int modeIndex, float hold, float tol) { Running = true; var sb = new StringBuilder(); sb.AppendLine("[WLAutoProbe] 요 추종 스윕 — 모드 " + (modeIndex + 1) + " · 유지 " + hold.ToString("0.0") + "초 · 허용 ±" + tol.ToString("0") + "°"); yield return WaitPlayable(); if (_pc == null || _cam == null) { sb.AppendLine(" 대상 없음"); Finish(sb); yield break; } _cam.SetMode(modeIndex); yield return WaitBlend(); // 방향마다 같은 출발점에서 재기 위해 시작 지점을 기억한다. // (기억하지 않으면 8 방향을 도는 사이 캐릭터가 물까지 흘러가 수영 상태가 되어 계측이 오염된다 — 실측) Vector3 home = _pc.transform.position; for (int i = 0; i < Dirs.Length; i++) { // 방향 간 독립성 확보 — 원점으로 되돌리고 잠시 정지시켜 급반전 유예·속도 임계를 초기화한다 _pc.SetDebugMoveInput(true, Vector2.zero); Teleport(home); yield return new WaitForSeconds(0.4f); _pc.SetDebugMoveInput(true, Dirs[i].normalized); float t0 = Time.time; float convergeAt = -1f; float maxYawStep = 0f; float prevYaw = _cam.transform.eulerAngles.y; float desired = 0f; float endErr = 0f; float startErr = -1f; bool seenAbove = false; // 목표가 갱신되어 오차가 실제로 벌어진 뒤부터 수렴을 판정한다 while (Time.time - t0 < hold) { // LateUpdate(카메라 갱신) 이후 값을 읽어야 같은 프레임의 목표·결과가 짝이 맞는다. yield return new WaitForEndOfFrame(); float yaw = _cam.transform.eulerAngles.y; float step = Mathf.Abs(Mathf.DeltaAngle(prevYaw, yaw)); if (step > maxYawStep) maxYawStep = step; prevYaw = yaw; desired = _cam.DesiredFollowYaw; endErr = Mathf.Abs(Mathf.DeltaAngle(yaw, desired)); if (startErr < 0f) startErr = endErr; if (!seenAbove && endErr > tol) seenAbove = true; if (seenAbove && convergeAt < 0f && endErr <= tol) convergeAt = Time.time - t0; } float behind = Mathf.Repeat(desired + 180f, 360f); // 카메라가 서 있어야 할 쪽(입력 방향 뒤) Vector3 toCam = _cam.transform.position - _pc.transform.position; toCam.y = 0f; float camSideYaw = Mathf.Atan2(toCam.x, toCam.z) * Mathf.Rad2Deg; float sideErr = Mathf.Abs(Mathf.DeltaAngle(camSideYaw, behind)); sb.AppendLine(string.Format( " {0,-3} 입력요={1,6:0.0}° 시작오차={2,5:0.0}° 최종오차={3,4:0.0}° 수렴={4,-7} 프레임최대Δ요={5:0.00}° 카메라위치각={6,5:0.0}°(뒤쪽목표 {7,5:0.0}° 오차 {8,4:0.0}°) 상태={9}", DirNames[i], Mathf.Repeat(desired, 360f), startErr, endErr, convergeAt < 0f ? (seenAbove ? "미수렴" : "이미수렴") : (convergeAt.ToString("0.00") + "초"), maxYawStep, Mathf.Repeat(camSideYaw, 360f), behind, sideErr, _pc.CurrentActState)); } _pc.SetDebugMoveInput(false, Vector2.zero); Finish(sb); } // ───────────────────────────────── ② 지그재그·급반전 + 데드존 /// 좌우 지그재그·급반전 입력으로 프레임당 카메라 변화 최대값을 잰다. public void RunZigzag(int modeIndex, float seconds, float halfPeriod) { Resolve(); StopAllCoroutines(); StartCoroutine(Zigzag(modeIndex, seconds, halfPeriod)); } private IEnumerator Zigzag(int modeIndex, float seconds, float halfPeriod) { Running = true; var sb = new StringBuilder(); sb.AppendLine("[WLAutoProbe] 지그재그·급반전 — 모드 " + (modeIndex + 1) + " · " + seconds.ToString("0.0") + "초 · 반주기 " + halfPeriod.ToString("0.00") + "초"); yield return WaitPlayable(); if (_pc == null || _cam == null) { sb.AppendLine(" 대상 없음"); Finish(sb); yield break; } _cam.SetMode(modeIndex); yield return WaitBlend(); // 전환 보간 프레임은 계측에서 제외한다 _cam.ResetDiagnostics(); float t0 = Time.time; float flip = Time.time; int sign = 1; float maxPos = 0f, maxYaw = 0f, maxPivotLag = 0f, minPivotLag = 999f; int frames = 0; Vector3 prevPos = _cam.transform.position; float prevYaw = _cam.transform.eulerAngles.y; while (Time.time - t0 < seconds) { if (Time.time - flip >= halfPeriod) { sign = -sign; flip = Time.time; } _pc.SetDebugMoveInput(true, new Vector2(sign, 0.35f).normalized); yield return null; if (_cam.ModeBlendRemaining > 0f) { prevPos = _cam.transform.position; prevYaw = _cam.transform.eulerAngles.y; continue; } float dp = (_cam.transform.position - prevPos).magnitude; float dy = Mathf.Abs(Mathf.DeltaAngle(prevYaw, _cam.transform.eulerAngles.y)); if (dp > maxPos) maxPos = dp; if (dy > maxYaw) maxYaw = dy; prevPos = _cam.transform.position; prevYaw = _cam.transform.eulerAngles.y; Vector3 lag = _pc.transform.position - _cam.PivotAnchor; lag.y = 0f; float d = lag.magnitude; if (d > maxPivotLag) maxPivotLag = d; if (d < minPivotLag) minPivotLag = d; frames++; } _pc.SetDebugMoveInput(false, Vector2.zero); sb.AppendLine(string.Format(" 샘플 {0} 프레임 · 평균 {1:0.0} fps", frames, frames / Mathf.Max(seconds, 0.001f))); sb.AppendLine(string.Format(" 프레임당 최대 위치변화 = {0:0.0000} m (자체 MaxPosDelta {1:0.0000} m)", maxPos, _cam.MaxPosDelta)); sb.AppendLine(string.Format(" 프레임당 최대 요변화 = {0:0.000} ° (자체 MaxYawDelta {1:0.000} °)", maxYaw, _cam.MaxYawDelta)); // PD #719 ② 로 주시점 데드존은 제거됐다 — 이제 캐릭터·주시점 거리는 항상 0 이어야 한다(수평 기준) sb.AppendLine(string.Format(" 캐릭터·주시점 수평 거리 최소 {0:0.000} m / 최대 {1:0.000} m (데드존 제거 후 기대값 0)", minPivotLag, maxPivotLag)); Finish(sb); } // ───────────────────────────────── PD #719·#720·#721 카메라 재설계 검증 /// 한 구간의 카메라 궤적 통계. private class CamSeg { public string name; public readonly System.Collections.Generic.List yawStep = new System.Collections.Generic.List(); public float maxYawStep, maxPosStep, maxCamSpeed, maxCharSpeed, maxAccel; public int flips, stallFrames, movingFrames, frames; public int inViewFrames, clearFrames; public float minDist = 9999f, maxDist; // PD 긴급 피드백 ④ · #722 — 캐릭터 화면 위치 public int safeViolations, centerOkFrames; public float vpXMin = 9f, vpXMax = -9f, vpYMin = 9f, vpYMax = -9f; } private CamSeg _seg; private float _prevYaw, _prevSpeed, _prevStepSign; private Vector3 _prevPos; private bool _segInit; private void SegBegin(string name) { _seg = new CamSeg { name = name }; _segInit = false; } /// 프레임 1개 표본. 전환 보간 중에는 스파이크 판정에서 뺀다(PD 지시). private void SegSample(float dt) { if (_seg == null || _cam == null || _pc == null) return; Vector3 pos = _cam.transform.position; float yaw = _cam.transform.eulerAngles.y; bool blending = _cam.ModeBlendRemaining > 0f; if (!_segInit) { _prevPos = pos; _prevYaw = yaw; _prevSpeed = 0f; _prevStepSign = 0f; _segInit = true; return; } if (dt <= 0.00001f) return; float step = Mathf.DeltaAngle(_prevYaw, yaw); float dp = (pos - _prevPos).magnitude; float camSpeed = dp / dt; float charSpeed = _cam.CharacterSpeed; _prevPos = pos; _prevYaw = yaw; _seg.frames++; if (!blending) { _seg.yawStep.Add(Mathf.Abs(step)); if (Mathf.Abs(step) > _seg.maxYawStep) _seg.maxYawStep = Mathf.Abs(step); if (dp > _seg.maxPosStep) _seg.maxPosStep = dp; if (camSpeed > _seg.maxCamSpeed) _seg.maxCamSpeed = camSpeed; if (charSpeed > _seg.maxCharSpeed) _seg.maxCharSpeed = charSpeed; float accel = Mathf.Abs(camSpeed - _prevSpeed) / dt; if (accel > _seg.maxAccel) _seg.maxAccel = accel; // 진동 = 요 각속도 부호가 뒤집힌 횟수(잡음 제거 임계 0.02°) float sign = Mathf.Abs(step) > 0.02f ? Mathf.Sign(step) : 0f; if (sign != 0f && _prevStepSign != 0f && sign != _prevStepSign) _seg.flips++; if (sign != 0f) _prevStepSign = sign; // 캐릭터가 이동 중인데 카메라가 멈춘 프레임 if (charSpeed > 0.5f) { _seg.movingFrames++; if (dp < 0.0001f) _seg.stallFrames++; } } _prevSpeed = camSpeed; // PD #721 — 캐릭터가 절두체 안에 있고 시선이 안 막혔는가 float d = Vector3.Distance(pos, _pc.transform.position); if (d < _seg.minDist) _seg.minDist = d; if (d > _seg.maxDist) _seg.maxDist = d; var camc = _cam.GetComponent(); if (camc != null) { var planes = GeometryUtility.CalculateFrustumPlanes(camc); var b = new Bounds(_pc.transform.position + Vector3.up * 0.9f, new Vector3(0.8f, 1.8f, 0.8f)); if (GeometryUtility.TestPlanesAABB(planes, b)) _seg.inViewFrames++; // 캐릭터 머리·발 뷰포트 좌표 — 안전 사각형 이탈 / 중앙 유지 판정 var st = _cam.Settings; Vector2 lo = st != null ? st.safeViewportMin : new Vector2(0.15f, 0.12f); Vector2 hi = st != null ? st.safeViewportMax : new Vector2(0.85f, 0.80f); Vector3 vHead = camc.WorldToViewportPoint(_pc.transform.position + Vector3.up * 1.6f); Vector3 vFeet = camc.WorldToViewportPoint(_pc.transform.position); bool inside = vHead.z > 0f && vFeet.z > 0f && vHead.x >= lo.x && vHead.x <= hi.x && vHead.y >= lo.y && vHead.y <= hi.y && vFeet.x >= lo.x && vFeet.x <= hi.x && vFeet.y >= lo.y && vFeet.y <= hi.y; if (!inside) _seg.safeViolations++; float cx = (vHead.x + vFeet.x) * 0.5f, cy = (vHead.y + vFeet.y) * 0.5f; if (cx < _seg.vpXMin) _seg.vpXMin = cx; if (cx > _seg.vpXMax) _seg.vpXMax = cx; if (cy < _seg.vpYMin) _seg.vpYMin = cy; if (cy > _seg.vpYMax) _seg.vpYMax = cy; // PD #722 검증 — 가로 0.40~0.60 · 세로 앵커 ±0.08 float anchor = _cam.CurrentAnchorY; if (cx >= 0.40f && cx <= 0.60f && Mathf.Abs(cy - anchor) <= 0.08f) _seg.centerOkFrames++; } if (!_cam.IsOccluded) _seg.clearFrames++; } private string SegReport() { var s = _seg; if (s == null || s.frames == 0) return " (표본 없음)"; s.yawStep.Sort(); float p95 = s.yawStep.Count > 0 ? s.yawStep[Mathf.Clamp(Mathf.FloorToInt(s.yawStep.Count * 0.95f), 0, s.yawStep.Count - 1)] : 0f; return string.Format( " {0,-10} {1,4}f Δ요max={2,5:0.00}° p95={3,4:0.00}° 진동={4,3} 정지={5}/{6} 카메라속도max={7,5:0.00}(캐릭터 {8:0.00}) 가속max={9,6:0.0} 거리 {10:0.0}~{11:0.0}m 화면안={12:0.0}% 비가림={13:0.0}% 안전이탈={14} 중앙유지={15:0.0}% vpX {16:0.00}~{17:0.00} vpY {18:0.00}~{19:0.00}", s.name, s.frames, s.maxYawStep, p95, s.flips, s.stallFrames, s.movingFrames, s.maxCamSpeed, s.maxCharSpeed, s.maxAccel, s.minDist, s.maxDist, 100f * s.inViewFrames / s.frames, 100f * s.clearFrames / s.frames, s.safeViolations, 100f * s.centerOkFrames / s.frames, s.vpXMin, s.vpXMax, s.vpYMin, s.vpYMax); } /// PD #719·#722 모드 1 검증 — 4방향·지그재그·급반전 60초 + 궤도 회전 중심 유지. public void RunCameraCheckMode1() { Resolve(); StopAllCoroutines(); StartCoroutine(CameraCheckMode1()); } private IEnumerator CameraCheckMode1() { Running = true; var sb = new StringBuilder(); yield return WaitPlayable(); if (_pc == null || _cam == null) { sb.AppendLine(" 대상 없음"); Finish(sb); yield break; } string root = System.IO.Directory.GetParent(Application.dataPath).FullName.Replace("\\", "/"); var st = _cam.Settings; var m0 = st.GetMode(0); sb.AppendLine("[WLAutoProbe] PD #719·#722 모드 1 검증"); sb.AppendLine(string.Format(" 모드1 설정: 거리 {0:0.0}m 룩어헤드 {1:0.00}m 어깨 {2:0.00}m 요감쇠 {3:0.00}~{4:0.00}s 위치감쇠 {5:0.00}s 앵커 {6:0.00} · 모드2 룩어헤드 {7:0.00}m", m0.distance, st.EffectiveLookAhead(0), m0.followShoulderOffsetX, m0.followYawSmoothTimeNear, m0.followYawSmoothTimeFar, m0.positionSmoothTime, m0.screenAnchorY, st.EffectiveLookAhead(1))); _cam.SetMode(0); yield return WaitBlend(); Vector3 home = _pc.transform.position; // ① 4방향 유지 (각 6초) var dirs4 = new[] { new Vector2(0f, 1f), new Vector2(1f, 0f), new Vector2(0f, -1f), new Vector2(-1f, 0f) }; var names4 = new[] { "유지-상", "유지-우", "유지-하", "유지-좌" }; for (int i = 0; i < 4; i++) { _pc.SetDebugMoveInput(true, Vector2.zero); Teleport(home); yield return new WaitForSeconds(0.3f); _pc.SetDebugMoveInput(true, dirs4[i].normalized); SegBegin(names4[i]); float t0 = Time.time; while (Time.time - t0 < 6f) { yield return null; SegSample(Time.deltaTime); } sb.AppendLine(SegReport()); if (i == 0) { CaptureNow(root + "/Screenshots_WL/WL_REF21_mode1_a.png"); CaptureNow(root + "/Screenshots_WL/WL_REF22_orbit_a.png"); } if (i == 1) CaptureNow(root + "/Screenshots_WL/WL_REF22_orbit_b.png"); } // ② 지그재그 (24초) _pc.SetDebugMoveInput(true, Vector2.zero); Teleport(home); yield return new WaitForSeconds(0.3f); SegBegin("지그재그"); float tz = Time.time; while (Time.time - tz < 24f) { float ph = Mathf.Repeat(Time.time - tz, 1.0f) < 0.5f ? 1f : -1f; _pc.SetDebugMoveInput(true, new Vector2(ph, 1f).normalized); yield return null; SegSample(Time.deltaTime); if (Mathf.Abs(Time.time - tz - 12f) < 0.02f) CaptureNow(root + "/Screenshots_WL/WL_REF21_mode1_b.png"); } sb.AppendLine(SegReport()); CaptureNow(root + "/Screenshots_WL/WL_REF21_mode1_safe.png"); CaptureNow(root + "/Screenshots_WL/WL_REF22_orbit_c.png"); // ③ 급반전 180° (12초) _pc.SetDebugMoveInput(true, Vector2.zero); Teleport(home); yield return new WaitForSeconds(0.3f); SegBegin("급반전"); float tr = Time.time; while (Time.time - tr < 12f) { _pc.SetDebugMoveInput(true, Mathf.Repeat(Time.time - tr, 2.4f) < 1.2f ? new Vector2(0f, 1f) : new Vector2(0f, -1f)); yield return null; SegSample(Time.deltaTime); } sb.AppendLine(SegReport()); CaptureNow(root + "/Screenshots_WL/WL_REF21_mode1_c.png"); // ④ 모드 전환 1 <-> 2 튐 확인 SegBegin("모드전환"); _cam.SetMode(1); float tb = Time.time; while (Time.time - tb < 1.6f) { yield return null; SegSample(Time.deltaTime); } _cam.SetMode(0); tb = Time.time; while (Time.time - tb < 1.6f) { yield return null; SegSample(Time.deltaTime); } _pc.SetDebugMoveInput(false, Vector2.zero); sb.AppendLine(SegReport() + " ※ 전환 보간 프레임은 스파이크·Δ요 판정에서 제외"); Finish(sb); } /// PD #720·#721 모드 2 검증 — 30초 주행 중 가림·화면 이탈·속도 스파이크. public void RunCameraCheckMode2() { Resolve(); StopAllCoroutines(); StartCoroutine(CameraCheckMode2()); } private IEnumerator CameraCheckMode2() { Running = true; var sb = new StringBuilder(); yield return WaitPlayable(); if (_pc == null || _cam == null) { sb.AppendLine(" 대상 없음"); Finish(sb); yield break; } string root = System.IO.Directory.GetParent(Application.dataPath).FullName.Replace("\\", "/"); var st = _cam.Settings; sb.AppendLine("[WLAutoProbe] PD #720·#721 모드 2 검증"); _cam.SetMode(1); yield return WaitBlend(); SegBegin("모드2주행"); float td = Time.time; float maxLook = 0f, minAvoid = 9999f, maxBoost = 0f, minScale = 9f; while (Time.time - td < 30f) { // 넓게 도는 원호 — 나무 밀집·지형 가장자리·다리를 두루 지난다 float ang = (Time.time - td) * 42f * Mathf.Deg2Rad; _pc.SetDebugMoveInput(true, new Vector2(Mathf.Sin(ang), Mathf.Cos(ang))); yield return null; SegSample(Time.deltaTime); float lk = _cam.LookAheadOffset.magnitude; if (lk > maxLook) maxLook = lk; if (_cam.AvoidDistance < minAvoid) minAvoid = _cam.AvoidDistance; if (_cam.OcclusionPitchBoost > maxBoost) maxBoost = _cam.OcclusionPitchBoost; if (_cam.OffsetScale < minScale) minScale = _cam.OffsetScale; if (Mathf.Abs(Time.time - td - 15f) < 0.02f) CaptureNow(root + "/Screenshots_WL/WL_REF21_mode2_occlusion.png"); } _pc.SetDebugMoveInput(false, Vector2.zero); sb.AppendLine(SegReport()); sb.AppendLine(string.Format(" 룩어헤드 실측 최대 {0:0.00}m (설정 {1:0.00}m) · 회피거리 최소 {2:0.00}m (모드 {3:0.0}m) · 오프셋배율 최소 {4:0.00} · 가림피치 최대 +{5:0.0}°", maxLook, st.EffectiveLookAhead(1), minAvoid, st.GetMode(1).distance, minScale, maxBoost)); if (!System.IO.File.Exists(root + "/Screenshots_WL/WL_REF21_mode2_occlusion.png")) CaptureNow(root + "/Screenshots_WL/WL_REF21_mode2_occlusion.png"); Finish(sb); } // ───────────────────────────────── ⑨ 검 궤적 이펙트 8초 샘플링 (PD #718) /// PM 과 같은 방식(30Hz · 8초)으로 이펙트-궤적 일치와 Idle 잔상을 잰다. public void RunSlashTrailCheck(int characterIndex, string prefix) { Resolve(); StopAllCoroutines(); StartCoroutine(SlashTrailCheck(characterIndex, prefix)); } private IEnumerator SlashTrailCheck(int characterIndex, string prefix) { Running = true; var sb = new StringBuilder(); sb.AppendLine("[WLAutoProbe] 검 궤적 이펙트 8초 샘플링 (PD #718) · 30Hz"); string root = System.IO.Directory.GetParent(Application.dataPath).FullName.Replace("\\", "/"); yield return WaitPlayable(); var sw = Object.FindFirstObjectByType(); if (sw != null && sw.CurrentIndex != characterIndex) { sw.SwitchTo(characterIndex); yield return new WaitForSeconds(0.5f); } _pc = sw != null ? sw.Current : _pc; var vfx = _pc.GetComponent(); if (vfx == null) { sb.AppendLine(" SlashVfxPlayer 없음"); Finish(sb); yield break; } if (_cam != null) { _cam.SetMode(0); yield return WaitBlend(); } var pcombat = _pc.GetComponent(); sb.AppendLine(" 캐릭터 = " + (sw != null ? sw.CurrentDisplayName : "-") + " · 방식 = " + (pcombat != null && pcombat.Settings != null ? pcombat.Settings.slashMode.ToString() : "?")); var dummy = GameObject.Find("Dummy_Bot_Invincible"); if (dummy != null) Teleport(dummy.transform.position - dummy.transform.forward * 1.3f); sb.AppendLine(" 대상 = " + (dummy != null ? "Dummy_Bot_Invincible(무적)" : "일반 몬스터")); BladeTrail trail = vfx.Trail; int shots = 0, samples = 0, idleSamples = 0, idleAlive = 0, swingSamples = 0, swingVisible = 0; float worstDist = 0f; int lastStep = 0; float t0 = Time.time; while (Time.time - t0 < 8f) { yield return new WaitForSeconds(1f / 30f); samples++; if (trail == null) trail = vfx.Trail; bool swinging = vfx.IsSwinging; bool visible = trail != null && trail.IsVisible; int alivePool = CountAlivePool(); if (swinging) { swingSamples++; if (visible) swingVisible++; } else if (!_pc.IsAttacking) { idleSamples++; if (visible || alivePool > 0) idleAlive++; } if (swinging && vfx.ArcSampleCount > 0) { float d = Vector3.Distance(vfx.TipPosition, vfx.GetArcTip(vfx.ArcSampleCount - 1)); if (d > worstDist) worstDist = d; } if (swinging && shots < 3 && _pc.CurrentComboStep != lastStep && _pc.CurrentComboStep > 0) { lastStep = _pc.CurrentComboStep; shots++; yield return new WaitForEndOfFrame(); ScreenCapture.CaptureScreenshot(root + "/" + prefix + shots + ".png"); sb.AppendLine(string.Format(" {0}타 캡처 · 단계={1} 트레일표시={2} 궤적점={3}개", shots, _pc.CurrentComboStep, visible, vfx.ArcSampleCount)); } } sb.AppendLine(string.Format(" 샘플 {0}개 · 스윙중 {1}개(트레일 표시 {2} = {3:0}%) · Idle {4}개(잔상 {5} = {6:0}%)", samples, swingSamples, swingVisible, swingSamples > 0 ? 100f * swingVisible / swingSamples : 0f, idleSamples, idleAlive, idleSamples > 0 ? 100f * idleAlive / idleSamples : 0f)); sb.AppendLine(string.Format(" 이펙트 ↔ 검끝 궤적 최대 거리 = {0:0.000}m (기준 ≤0.15)", worstDist)); sb.AppendLine(" 캡처 " + shots + "장"); _pc.SetDebugMoveInput(false, Vector2.zero); Finish(sb); } private static int CountAlivePool() { int n = 0; foreach (var ps in Object.FindObjectsByType(FindObjectsSortMode.None)) { if (ps == null || !ps.gameObject.activeInHierarchy) continue; if (!ps.transform.root.name.Contains("pool") && !ps.name.Contains("pool")) continue; if (ps.particleCount > 0 || ps.isPlaying) n++; } return n; } // ───────────────────────────────── ⑧ PD #717 종합 검증 /// 캐릭터별 달리기 사이클 · 모드1 전진 피치/룩어헤드 · 하단 즉시 반전 · 무적 더미를 한 번에 잰다. public void RunP717Check() { Resolve(); StopAllCoroutines(); StartCoroutine(P717Check()); } private IEnumerator P717Check() { Running = true; var sb = new StringBuilder(); sb.AppendLine("[WLAutoProbe] PD #717 종합 검증"); string root = System.IO.Directory.GetParent(Application.dataPath).FullName.Replace("\\", "/"); yield return WaitPlayable(); var sw = Object.FindFirstObjectByType(); if (sw == null || _cam == null) { sb.AppendLine(" 대상 없음"); Finish(sb); yield break; } // ── ① 캐릭터별 달리기 사이클 sb.AppendLine(" ① 달리기 사이클 (Speed=1 에서 재생 중인 클립)"); for (int c = 0; c < sw.Count; c++) { if (sw.CurrentIndex != c) { sw.SwitchTo(c); yield return new WaitForSeconds(0.4f); } var p = sw.Current; var an = p.GetComponentInChildren(); p.enabled = false; an.SetFloat("Speed", 1f); an.Update(0.2f); an.Update(0.2f); string nm = "-"; float len = 0f; foreach (var ci in an.GetCurrentAnimatorClipInfo(0)) if (ci.clip != null && ci.weight > 0.5f) { nm = ci.clip.name; len = ci.clip.length; } an.SetFloat("Speed", 0f); an.Update(0.1f); p.enabled = true; sb.AppendLine(string.Format(" {0,-11} 클립={1,-24} 사이클={2:0.00}s · Animator.speed={3:0.00}", sw.CurrentDisplayName, nm, len, an.speed)); } if (sw.CurrentIndex != 0) { sw.SwitchTo(0); yield return new WaitForSeconds(0.4f); } _pc = sw.Current; Vector3 home = _pc.transform.position; // ── ② 모드 1 상단 이동 — 룩어헤드·피치 _cam.SetMode(0); yield return WaitBlend(); _pc.SetDebugMoveInput(true, Vector2.zero); Teleport(home); yield return new WaitForSeconds(0.8f); sb.AppendLine(string.Format(" ② 정지 피치={0:0.0}° 룩어헤드={1:0.00}m", _cam.CurrentPitchDeg, _cam.LookAheadOffset.magnitude)); _pc.SetDebugMoveInput(true, new Vector2(0f, 1f)); yield return new WaitForSeconds(2.0f); sb.AppendLine(string.Format(" 전진 중 피치={0:0.0}° 룩어헤드={1:0.00}m 속도={2:0.00}m/s", _cam.CurrentPitchDeg, _cam.LookAheadOffset.magnitude, _pc.CurrentSpeed)); yield return new WaitForEndOfFrame(); ScreenCapture.CaptureScreenshot(root + "/Screenshots_WL/WL_REF17_mode1_fwd.png"); yield return new WaitForSeconds(0.5f); // ── ③ 모드 1 하단 이동 — 즉시 반전 float yaw0 = _cam.transform.eulerAngles.y; float t0 = Time.time; _pc.SetDebugMoveInput(true, new Vector2(0f, -1f)); float turnedAt = -1f, maxTurn = 0f; while (Time.time - t0 < 2.0f) { yield return null; float turned = Mathf.Abs(Mathf.DeltaAngle(yaw0, _cam.transform.eulerAngles.y)); if (turned > maxTurn) maxTurn = turned; if (turnedAt < 0f && turned >= 150f) turnedAt = Time.time - t0; } sb.AppendLine(string.Format(" ③ 하단 이동 · 150° 반전 도달 = {0} · 2초간 최대 회전 {1:0.0}°", turnedAt < 0f ? "미도달" : turnedAt.ToString("0.00") + "초", maxTurn)); yield return new WaitForEndOfFrame(); ScreenCapture.CaptureScreenshot(root + "/Screenshots_WL/WL_REF17_mode1_back.png"); yield return new WaitForSeconds(0.5f); // ── ④ 무적 더미 봇 _pc.SetDebugMoveInput(false, Vector2.zero); var dummy = GameObject.Find("Dummy_Bot_Invincible"); if (dummy == null) sb.AppendLine(" ④ 더미 봇을 씬에서 찾지 못했습니다"); else { var de = dummy.GetComponent(); Teleport(dummy.transform.position - dummy.transform.forward * 1.3f); float hpBefore = de.CurrentHp; float guard = Time.time + 12f; int hits = 0; while (Time.time < guard && hits < 3) { yield return null; if (_pc.LastComboHitCount > hits) hits = _pc.LastComboHitCount; } yield return new WaitForSeconds(0.2f); sb.AppendLine(string.Format(" ④ 더미 봇 · 무적={0} 타격수={1} HP {2:0.0} → {3:0.0} (불변이어야 함) 살아있음={4}", de.IsInvincible, hits, hpBefore, de.CurrentHp, de.IsAlive)); sb.AppendLine(" AI 컴포넌트 = " + (dummy.GetComponent() == null ? "없음(이동·공격 안 함)" : "있음")); yield return new WaitForEndOfFrame(); ScreenCapture.CaptureScreenshot(root + "/Screenshots_WL/WL_REF17_dummy.png"); yield return new WaitForSeconds(0.5f); } _pc.SetDebugMoveInput(false, Vector2.zero); Finish(sb); } // ───────────────────────────────── ⑦ 패드 방향 ↔ 화면 방향 일치 (PD #716 ③) /// 패드 상·하·좌·우 입력 시 캐릭터가 화면상 같은 방향으로 가는지 두 모드에서 잰다. public void RunPadDirectionCheck(float hold) { Resolve(); StopAllCoroutines(); StartCoroutine(PadDirectionCheck(hold)); } private IEnumerator PadDirectionCheck(float hold) { Running = true; var sb = new StringBuilder(); sb.AppendLine("[WLAutoProbe] 패드 방향 ↔ 화면 방향 일치 (PD #716 ③) · 기준 내적 > 0.9"); yield return WaitPlayable(); if (_pc == null || _cam == null) { sb.AppendLine(" 대상 없음"); Finish(sb); yield break; } var cam = _cam.GetComponent(); Vector3 home = _pc.transform.position; Vector2[] dirs = { new Vector2(0f, 1f), new Vector2(0f, -1f), new Vector2(-1f, 0f), new Vector2(1f, 0f) }; string[] names = { "상", "하", "좌", "우" }; for (int mode = 0; mode < 2; mode++) { _cam.SetMode(mode); yield return WaitBlend(); sb.AppendLine(" ── 모드 " + (mode + 1)); float worst = 1f; for (int i = 0; i < dirs.Length; i++) { _pc.SetDebugMoveInput(true, Vector2.zero); Teleport(home); yield return new WaitForSeconds(0.4f); // 입력을 넣기 직전의 카메라 기준축을 기억한다(이 순간의 화면 방향이 판정 기준) Vector3 camF = Vector3.ProjectOnPlane(cam.transform.forward, Vector3.up).normalized; Vector3 camR = Vector3.ProjectOnPlane(cam.transform.right, Vector3.up).normalized; Vector3 expected = (camF * dirs[i].y + camR * dirs[i].x).normalized; _pc.SetDebugMoveInput(true, dirs[i]); Vector3 p0 = _pc.transform.position; yield return new WaitForSeconds(hold); Vector3 actual = _pc.transform.position - p0; actual.y = 0f; float dot = actual.sqrMagnitude > 0.0001f ? Vector3.Dot(actual.normalized, expected) : -9f; if (dot < worst) worst = dot; sb.AppendLine(string.Format(" {0,-2} 기대(화면기준)={1} 실제이동={2} 내적={3:0.000} {4} 이동거리={5:0.00}m", names[i], expected.ToString("F2"), actual.sqrMagnitude > 0.0001f ? actual.normalized.ToString("F2") : "(정지)", dot, dot > 0.9f ? "통과" : "실패", actual.magnitude)); } sb.AppendLine(" 모드 " + (mode + 1) + " 최저 내적 = " + worst.ToString("0.000") + (worst > 0.9f ? " → 4방향 전부 통과" : " → 실패 있음")); } _pc.SetDebugMoveInput(false, Vector2.zero); Finish(sb); } // ───────────────────────────────── ⑥ 플로팅 패드 3지점 (PD #706) /// 화면 3지점(우상단·중앙·좌하단)에서 터치를 시작해 패드 표시 위치·출력 벡터·해제를 잰다. public void RunPadCheck(string screenshotPath) { Resolve(); StopAllCoroutines(); StartCoroutine(PadCheck(screenshotPath)); } private IEnumerator PadCheck(string screenshotPath) { Running = true; var sb = new StringBuilder(); sb.AppendLine("[WLAutoProbe] 플로팅 패드 3지점 검증 (PD #706)"); yield return WaitPlayable(); if (_pc == null) { sb.AppendLine(" 대상 없음"); Finish(sb); yield break; } var pad = Object.FindFirstObjectByType(FindObjectsInactive.Include); var ts = _pc.TouchSettings; bool prevUnfocused = ts != null && ts.ignoreInputWhenUnfocused; if (ts != null) ts.ignoreInputWhenUnfocused = false; // 에디터 비포커스에서도 계측되게(끝나고 원복) float w = Screen.width, h = Screen.height; Vector2[] points = { new Vector2(w * 0.80f, h * 0.85f), new Vector2(w * 0.50f, h * 0.50f), new Vector2(w * 0.20f, h * 0.15f) }; string[] names = { "우상단", "중앙", "좌하단" }; Vector2[] drags = { new Vector2(0f, 120f), new Vector2(120f, 0f), new Vector2(-90f, -90f) }; string root = System.IO.Directory.GetParent(Application.dataPath).FullName.Replace("\\", "/"); for (int i = 0; i < points.Length; i++) { // 누르기 (새로 눌림) TouchInputProvider.SetDebugPointer(true, true, points[i], true); yield return null; yield return null; Vector2 startPos = _pc.Touch != null ? _pc.Touch.StartPosition : Vector2.zero; string mode = _pc.Touch != null ? _pc.Touch.CurrentMode.ToString() : "-"; bool blocked = _pc.Touch != null && _pc.Touch.IsBlockedByUI; // 드래그 TouchInputProvider.SetDebugPointer(true, true, points[i] + drags[i], false); yield return null; yield return null; yield return null; Vector2 outVec = _pc.Touch != null ? _pc.Touch.ScreenMove : Vector2.zero; bool padVisible = pad != null && pad.IsPadVisible; Vector2 padPos = pad != null ? pad.BackgroundScreenPosition : Vector2.zero; sb.AppendLine(string.Format(" {0,-4} 터치 {1} → 패드중심 {2} (터치와 차이 {3:0.0}px) 모드={4} UI차단={5} 출력벡터={6} 표시={7}", names[i], points[i].ToString("F0"), padPos.ToString("F0"), Vector2.Distance(padPos, startPos), mode, blocked, outVec.ToString("F3"), padVisible)); if (i == 1 && !string.IsNullOrEmpty(screenshotPath)) { yield return new WaitForEndOfFrame(); ScreenCapture.CaptureScreenshot(root + "/" + screenshotPath); sb.AppendLine(" 중앙 지점 캡처 = " + screenshotPath); yield return new WaitForSeconds(0.4f); } // 떼기 TouchInputProvider.SetDebugPointer(true, false, points[i] + drags[i], false); yield return null; yield return null; yield return null; sb.AppendLine(string.Format(" 해제 후 → pressed={0} 출력벡터={1} 패드표시={2}", _pc.Touch != null && _pc.Touch.IsPressed, _pc.Touch != null ? _pc.Touch.ScreenMove.ToString("F3") : "-", pad != null ? pad.IsPadVisible.ToString() : "-")); yield return new WaitForSeconds(0.3f); } TouchInputProvider.SetDebugPointer(false, false, Vector2.zero, false); if (ts != null) ts.ignoreInputWhenUnfocused = prevUnfocused; Finish(sb); } // ───────────────────────────────── ⑤ 모드 전환 표류 · Idle · 슬래시 3연속 (PD #715) /// 모드 2 → 1 전환 시 입력이 없는데 이동·회전이 생기는지, 그리고 Idle 진입을 잰다. public void RunSwitchIdleCheck() { Resolve(); StopAllCoroutines(); StartCoroutine(SwitchIdleCheck()); } private IEnumerator SwitchIdleCheck() { Running = true; var sb = new StringBuilder(); sb.AppendLine("[WLAutoProbe] 모드 전환 표류 · Idle 검증 (PD #715 ①②③)"); yield return WaitPlayable(); if (_pc == null || _cam == null) { sb.AppendLine(" 대상 없음"); Finish(sb); yield break; } // 주변 적을 멀리 치워 자동 추적이 개입하지 않게 한다 (순수 무입력 구간 확보) int moved = 0; foreach (var e in Object.FindObjectsByType(FindObjectsSortMode.None)) { if (e == null) continue; if (Vector3.Distance(e.transform.position, _pc.transform.position) > 30f) continue; var ecc = e.GetComponent(); if (ecc != null) ecc.enabled = false; e.transform.position += (e.transform.position - _pc.transform.position).normalized * 40f; if (ecc != null) ecc.enabled = true; moved++; } sb.AppendLine(" 주변 적 " + moved + "마리 40m 밖으로 이동(무입력 순수 구간 확보)"); _pc.SetDebugMoveInput(true, Vector2.zero); _cam.SetMode(1); yield return WaitBlend(); yield return new WaitForSeconds(1.0f); Vector3 p0 = _pc.transform.position; float y0 = _pc.transform.eulerAngles.y; float camY0 = _cam.transform.eulerAngles.y; _cam.SetMode(0); // 모드 2 → 1 float t0 = Time.time; float maxMove = 0f, maxTurn = 0f; while (Time.time - t0 < 2.5f) { yield return new WaitForEndOfFrame(); float d = Vector3.Distance(_pc.transform.position, p0); float a = Mathf.Abs(Mathf.DeltaAngle(_pc.transform.eulerAngles.y, y0)); if (d > maxMove) maxMove = d; if (a > maxTurn) maxTurn = a; } sb.AppendLine(string.Format(" 모드2→1 전환 후 2.5초 · 캐릭터 이동 최대 {0:0.000}m · 회전 최대 {1:0.00}° (둘 다 0 이어야 정상)", maxMove, maxTurn)); sb.AppendLine(string.Format(" 카메라 요 {0:0.0}° → {1:0.0}° (카메라는 움직여도 된다) · 상태={2} 속도={3:0.00}", camY0, _cam.transform.eulerAngles.y, _pc.CurrentActState, _pc.CurrentSpeed)); // Idle 진입 — Speed 파라미터가 0 에 닿는 시각 var an = _pc.GetComponentInChildren(); float t1 = Time.time; float zeroAt = -1f; while (Time.time - t1 < 3f) { yield return null; if (zeroAt < 0f && an != null && an.GetFloat("Speed") <= 0.0001f) zeroAt = Time.time - t1; } sb.AppendLine(" Idle 진입: Speed 파라미터 0 도달 = " + (zeroAt < 0f ? "3초 내 미도달" : zeroAt.ToString("0.00") + "초") + " · 현재 클립 = " + ClipNames(_pc)); // 대기 변형 float iv = 0f; var pms = _pc.GetType(); sb.AppendLine(" 대기 변형 대기 중(설정 시간 경과 후 1회 재생)…"); float t2 = Time.time; string variationClip = "-"; while (Time.time - t2 < 11f) { yield return null; string cn = ClipNames(_pc); if (!cn.Contains("Idle") && !cn.Contains("Walk") && !cn.Contains("Run")) { variationClip = cn; break; } } sb.AppendLine(" 대기 변형 클립 = " + variationClip + " (경과 " + (Time.time - t2).ToString("0.0") + "초)"); iv = iv + 0f; _pc.SetDebugMoveInput(false, Vector2.zero); Finish(sb); } /// 콤보 1~3타 각각의 타격 순간을 캡처한다 (PD #715 ⑤). public void RunSlashShots(int characterIndex, string prefix) { Resolve(); StopAllCoroutines(); StartCoroutine(SlashShots(characterIndex, prefix)); } private IEnumerator SlashShots(int characterIndex, string prefix) { Running = true; var sb = new StringBuilder(); sb.AppendLine("[WLAutoProbe] 슬래시 3연속 캡처 — 캐릭터 " + characterIndex); yield return WaitPlayable(); var sw = Object.FindFirstObjectByType(); if (sw != null && sw.CurrentIndex != characterIndex) { sw.SwitchTo(characterIndex); yield return new WaitForSeconds(0.5f); } _pc = sw != null ? sw.Current : _pc; if (_cam != null) { _cam.SetMode(0); yield return WaitBlend(); } var vfx = _pc.GetComponent(); if (vfx == null) { sb.AppendLine(" SlashVfxPlayer 없음"); Finish(sb); yield break; } sb.AppendLine(" 소켓 = " + (vfx.Socket != null ? vfx.Socket.name : "없음") + " 검날방향(로컬) = " + vfx.BladeLocalDir.ToString("F2") + " 길이 = " + vfx.BladeLength.ToString("F2") + "m"); string root = System.IO.Directory.GetParent(Application.dataPath).FullName.Replace("\\", "/"); int shots = 0; int lastCount = vfx.PlayCount; float guard = Time.time + 40f; while (shots < 3 && Time.time < guard) { var enemy = NearestEnemy(_pc.transform.position); if (enemy == null) { sb.AppendLine(" 살아 있는 적 없음"); break; } if (Vector3.Distance(enemy.transform.position, _pc.transform.position) > 2.2f) { var ecc = enemy.GetComponent(); if (ecc != null) ecc.enabled = false; enemy.transform.position = _pc.transform.position + _pc.transform.forward * 1.4f; if (ecc != null) ecc.enabled = true; } yield return null; if (vfx.PlayCount > lastCount) { lastCount = vfx.PlayCount; shots++; // 파티클은 활성화된 프레임에 아직 그려지지 않는다 — 조금 재생된 뒤에 찍는다 yield return new WaitForSeconds(0.07f); yield return new WaitForEndOfFrame(); ScreenCapture.CaptureScreenshot(root + "/" + prefix + shots + ".png"); // PD #716 ④ — 검끝이 실제로 그리는 궤적과 이펙트가 같은 평면에 있는지 잰다 Vector3 tip = vfx.TipPosition, hilt = vfx.HiltPosition, n = vfx.LastSwingNormal; var eff = vfx.LastSpawned; float planeDist = eff != null ? Mathf.Abs(Vector3.Dot(tip - eff.position, n)) : -1f; sb.AppendLine(string.Format(" {0}타 캡처 · 단계={1} 클립={2}", shots, vfx.LastPlayedStep, ClipNames(_pc))); sb.AppendLine(string.Format(" 검끝={0} 손잡이={1} 검끝속도={2:0.0}m/s 스윙법선={3}", tip.ToString("F2"), hilt.ToString("F2"), vfx.TipVelocity.magnitude, n.ToString("F2"))); sb.AppendLine(string.Format(" 이펙트원점={0} · 검끝~이펙트 평면거리 = {1:0.000}m (기준 ≤0.15)", eff != null ? eff.position.ToString("F2") : "-", planeDist)); yield return new WaitForSeconds(0.5f); } } sb.AppendLine(" 캡처 " + shots + "장 · 총 재생 " + vfx.PlayCount + "회"); Finish(sb); } // ───────────────────────────────── PD #718 마무리 — 트레일 외형 vs 원본 크레센트 /// 공격 3회를 트레일이 가장 길게 남은 순간에 찍고, 같은 씬에 원본 프리팹을 잠깐 띄워 한 장 더 찍는다. public void RunSlash718(int characterIndex, string prefix) { Resolve(); StopAllCoroutines(); StartCoroutine(Slash718(characterIndex, prefix)); } private IEnumerator Slash718(int characterIndex, string prefix) { Running = true; var sb = new StringBuilder(); sb.AppendLine("[WLAutoProbe] #718 트레일 외형 검증 — 캐릭터 " + characterIndex); yield return WaitPlayable(); var sw = Object.FindFirstObjectByType(); if (sw != null && sw.CurrentIndex != characterIndex) { sw.SwitchTo(characterIndex); yield return new WaitForSeconds(0.5f); } _pc = sw != null ? sw.Current : _pc; if (_cam != null) { _cam.SetMode(0); yield return WaitBlend(); } var vfx = _pc.GetComponent(); var trail = _pc.GetComponent(); if (vfx == null) { sb.AppendLine(" SlashVfxPlayer 없음"); Finish(sb); yield break; } sb.AppendLine(" 소켓 = " + (vfx.Socket != null ? vfx.Socket.name : "없음") + " 검날 = " + vfx.BladeLocalDir.ToString("F2") + " 길이 " + vfx.BladeLength.ToString("F2") + "m"); string root = System.IO.Directory.GetParent(Application.dataPath).FullName.Replace("\\", "/"); int shots = 0, last = vfx.PlayCount; float guard = Time.time + 45f; int maxSamples = 0; while (shots < 3 && Time.time < guard) { var enemy = NearestEnemy(_pc.transform.position); if (enemy == null) { sb.AppendLine(" 살아 있는 적 없음"); break; } if (Vector3.Distance(enemy.transform.position, _pc.transform.position) > 2.2f) { var ecc = enemy.GetComponent(); if (ecc != null) ecc.enabled = false; enemy.transform.position = _pc.transform.position + _pc.transform.forward * 1.4f; if (ecc != null) ecc.enabled = true; } yield return null; if (trail != null && trail.SampleCount > maxSamples) maxSamples = trail.SampleCount; if (vfx.PlayCount > last) { last = vfx.PlayCount; shots++; yield return new WaitForSeconds(0.06f); // 파티클이 한 번 그려질 때까지 CaptureNow(root + "/" + prefix + shots + ".png"); sb.AppendLine(string.Format(" {0}타 · 트레일 정점열 {1} · 표시 {2} · 검끝 {3}", shots, trail != null ? trail.SampleCount : -1, trail != null && trail.IsVisible, vfx.TipPosition.ToString("F2"))); yield return new WaitForSeconds(0.6f); } } // 아이들 — 트레일이 남아 있으면 안 된다 yield return new WaitForSeconds(1.2f); int alive = 0; foreach (var g in Object.FindObjectsByType(FindObjectsSortMode.None)) if (g != null && g.transform.root.name.Contains("_pool") && g.transform.root.gameObject.activeSelf) { alive++; } sb.AppendLine(" 아이들: 트레일 표시 = " + (trail != null ? trail.IsVisible.ToString() : "?") + " · 트레일 정점열 = " + (trail != null ? trail.SampleCount : -1) + " · 풀 활성 파티클 = " + alive + " · 스윙 중 최대 정점열 = " + maxSamples); // 원본 프리팹 1장 — 카메라 앞에 잠깐 띄웠다 지운다 (설정 에셋이 들고 있는 NamuFX 원본 그대로) var pcombat = _pc.GetComponent(); var src = (pcombat != null && pcombat.Settings != null) ? pcombat.Settings.slashVfxPrefab : null; if (src == null) sb.AppendLine(" 원본 프리팹 없음 (CombatSettings.slashVfxPrefab)"); else { var cam = Camera.main; var go = Object.Instantiate(src); go.name = "__WL718_OriginalRef"; go.transform.position = cam.transform.position + cam.transform.forward * 4.2f; go.transform.rotation = Quaternion.LookRotation(-cam.transform.forward, cam.transform.up); foreach (var ps in go.GetComponentsInChildren(true)) { ps.Clear(true); ps.Play(true); } yield return new WaitForSeconds(0.14f); // 크레센트가 가장 또렷한 순간 CaptureNow(root + "/Screenshots_WL/WL_REF20_original.png"); yield return new WaitForSeconds(0.4f); Object.Destroy(go); sb.AppendLine(" 원본 프리팹 캡처 1장 · 임시 오브젝트 제거"); } sb.AppendLine(" 캡처 " + shots + "장 · 총 재생 " + vfx.PlayCount + "회"); Finish(sb); } /// /// 메인 카메라를 그 자리에서 한 번 렌더해 PNG 로 쓴다. /// `ScreenCapture.CaptureScreenshot` + `WaitForEndOfFrame` 는 게임 뷰가 그려지지 않는 동안(에디터가 뒤에 있을 때) /// 아예 깨어나지 않아 코루틴이 멈춘다 — 실측으로 확인했다. 그래서 렌더 타이밍에 기대지 않는 방식을 쓴다. /// private static void CaptureNow(string absolutePath) { var cam = Camera.main; if (cam == null) return; int w = Mathf.Max(Screen.width, 640), h = Mathf.Max(Screen.height, 480); var rt = new RenderTexture(w, h, 24, RenderTextureFormat.ARGB32); var prevTarget = cam.targetTexture; var prevActive = RenderTexture.active; // URP 에서는 Camera.Render() 가 막혀 있다 — 렌더 요청(SubmitRenderRequest)이 정식 경로다 var req = new UnityEngine.Rendering.Universal.UniversalRenderPipeline.SingleCameraRequest(); req.destination = rt; if (UnityEngine.Rendering.RenderPipeline.SupportsRenderRequest(cam, req)) { UnityEngine.Rendering.RenderPipeline.SubmitRenderRequest(cam, req); } else { cam.targetTexture = rt; cam.Render(); cam.targetTexture = prevTarget; } RenderTexture.active = rt; var tex = new Texture2D(w, h, TextureFormat.RGB24, false); tex.ReadPixels(new Rect(0, 0, w, h), 0, 0); tex.Apply(false); RenderTexture.active = prevActive; var dir = System.IO.Path.GetDirectoryName(absolutePath); if (!string.IsNullOrEmpty(dir)) System.IO.Directory.CreateDirectory(dir); System.IO.File.WriteAllBytes(absolutePath, tex.EncodeToPNG()); Object.Destroy(tex); rt.Release(); Object.Destroy(rt); } private static string LastSlashPos(SlashVfxPlayer vfx) { var all = Object.FindObjectsByType(FindObjectsSortMode.None); for (int i = 0; i < all.Length; i++) { if (all[i] == null || !all[i].gameObject.activeInHierarchy) continue; if (!all[i].transform.root.name.Contains("pool") && !all[i].name.Contains("pool")) continue; var t = all[i].transform; float d = vfx.Socket != null ? Vector3.Distance(t.position, vfx.Socket.position) : -1f; return t.position.ToString("F2") + " (검 소켓까지 " + d.ToString("F2") + "m)"; } return "(활성 슬래시 없음)"; } // ───────────────────────────────── ④ 룩어헤드 (PD #712 ①) /// 두 모드에서 위·아래 이동 시 주시점이 밀리는지(화면 세로 위치 변화)를 잰다. public void RunLookAheadCheck(float hold) { Resolve(); StopAllCoroutines(); StartCoroutine(LookAheadCheck(hold)); } private IEnumerator LookAheadCheck(float hold) { Running = true; var sb = new StringBuilder(); sb.AppendLine("[WLAutoProbe] 룩어헤드 실측 (PD #712 ①) — 유지 " + hold.ToString("0.0") + "초"); yield return WaitPlayable(); if (_pc == null || _cam == null) { sb.AppendLine(" 대상 없음"); Finish(sb); yield break; } var cam = _cam.GetComponent(); Vector3 home = _pc.transform.position; for (int mode = 0; mode < 2; mode++) { _cam.SetMode(mode); yield return WaitBlend(); var entry = _cam.Settings != null ? _cam.Settings.GetMode(mode) : null; sb.AppendLine(" ── 모드 " + (mode + 1) + " (최대 " + (entry != null ? entry.lookAheadMaxDistance.ToString("0.0") : "?") + "m)"); // 기준 — 정지 _pc.SetDebugMoveInput(true, Vector2.zero); Teleport(home); yield return new WaitForSeconds(0.8f); float baseY = ViewportY(cam, _pc); sb.AppendLine(string.Format(" 정지 viewportY={0:0.000} 룩어헤드={1:0.00}m", baseY, _cam.LookAheadOffset.magnitude)); // 위쪽(카메라 전방)으로 이동 _pc.SetDebugMoveInput(true, new Vector2(0f, 1f)); yield return new WaitForSeconds(hold); float upY = ViewportY(cam, _pc); float upLa = _cam.LookAheadOffset.magnitude; // 다시 정지 후 아래쪽(카메라 쪽)으로 이동 _pc.SetDebugMoveInput(true, Vector2.zero); Teleport(home); yield return new WaitForSeconds(0.9f); _pc.SetDebugMoveInput(true, new Vector2(0f, -1f)); yield return new WaitForSeconds(hold); float downY = ViewportY(cam, _pc); float downLa = _cam.LookAheadOffset.magnitude; sb.AppendLine(string.Format(" 위로 viewportY={0:0.000} (기준대비 {1:+0.000;-0.000}) 룩어헤드={2:0.00}m 속도={3:0.0}m/s", upY, upY - baseY, upLa, _pc.CurrentSpeed)); sb.AppendLine(string.Format(" 아래로 viewportY={0:0.000} (기준대비 {1:+0.000;-0.000}) 룩어헤드={2:0.00}m 속도={3:0.0}m/s", downY, downY - baseY, downLa, _pc.CurrentSpeed)); sb.AppendLine(" 판정: 위로 이동 시 캐릭터가 화면 아래로 내려가(앞이 더 보임) = " + ((upY - baseY) < -0.001f ? "O" : "X") + " · 아래로 이동 시 화면 위로 올라가(뒤·아래가 더 보임) = " + ((downY - baseY) > 0.001f ? "O" : "X")); } _pc.SetDebugMoveInput(false, Vector2.zero); Finish(sb); } private static float ViewportY(Camera cam, PlayerController pc) { if (cam == null || pc == null) return -1f; return cam.WorldToViewportPoint(pc.transform.position + Vector3.up).y; } // ───────────────────────────────── ③ 캐릭터 교체 (PD #711) /// 토글 교체 → 이동·공격·피격·수영·복귀를 순서대로 돌며 재생 중인 클립 이름을 남긴다. public void RunCharacterSwitchCheck() { Resolve(); StopAllCoroutines(); StartCoroutine(CharacterSwitchCheck()); } private IEnumerator CharacterSwitchCheck() { Running = true; var sb = new StringBuilder(); sb.AppendLine("[WLAutoProbe] 캐릭터 교체 검증 (PD #711)"); yield return WaitPlayable(); var sw = Object.FindFirstObjectByType(); if (sw == null) { sb.AppendLine(" PlayerSwitcher 없음"); Finish(sb); yield break; } sb.AppendLine(" 교체 전 = " + sw.CurrentDisplayName + " pos=" + sw.Current.transform.position.ToString("F2") + " hp=" + sw.Current.CurrentHp.ToString("F0")); Vector3 posBefore = sw.Current.transform.position; sw.SwitchNext(); yield return new WaitForSeconds(0.4f); _pc = sw.Current; sb.AppendLine(" 교체 후 = " + sw.CurrentDisplayName + " pos=" + _pc.transform.position.ToString("F2") + " hp=" + _pc.CurrentHp.ToString("F0") + " 위치차=" + (_pc.transform.position - posBefore).magnitude.ToString("F3") + "m" + " 상태=" + _pc.CurrentWakeState + "(시작시퀀스 재생 안 함이면 Playable)"); // 이동 — P09 자체 러닝 클립이 나오는지 _pc.SetDebugMoveInput(true, new Vector2(0f, 1f)); yield return new WaitForSeconds(1.2f); sb.AppendLine(" 이동 중 클립 = " + ClipNames(_pc) + " speed=" + _pc.CurrentSpeed.ToString("F2") + " act=" + _pc.CurrentActState); // 공격 — 가까운 적 옆으로 옮기고 자동 전투에 맡긴다 _pc.SetDebugMoveInput(false, Vector2.zero); var enemy = NearestEnemy(_pc.transform.position); if (enemy != null) { Teleport(enemy.transform.position - enemy.transform.forward * 1.2f); float guard = Time.time + 8f; while (!_pc.IsAttacking && Time.time < guard) yield return null; yield return new WaitForSeconds(0.15f); sb.AppendLine(" 공격 클립 = " + ClipNames(_pc) + " 콤보=" + _pc.CurrentComboStep + " (P09 전용 공격 클립이 없으면 공용 01 세트)"); // 피격 — 적이 반격할 때까지 잠시 대기 float g2 = Time.time + 6f; while (!_pc.IsHurt && Time.time < g2) yield return null; sb.AppendLine(" 피격 클립 = " + (_pc.IsHurt ? ClipNames(_pc) : "(제한 시간 내 피격 없음)")); } else sb.AppendLine(" 주변에 적 없음 — 공격·피격 생략"); // 수영 — 물까지 이동 _pc.SetDebugMoveInput(true, new Vector2(0.7071f, 0.7071f)); float g3 = Time.time + 12f; while (!_pc.IsSwimming && Time.time < g3) yield return null; sb.AppendLine(" 수영 = " + (_pc.IsSwimming ? "진입 · 클립 " + ClipNames(_pc) : "(제한 시간 내 미진입)")); _pc.SetDebugMoveInput(false, Vector2.zero); // 복귀 yield return new WaitForSeconds(0.3f); sw.SwitchNext(); yield return new WaitForSeconds(0.5f); sb.AppendLine(" 복귀 = " + sw.CurrentDisplayName + " pos=" + sw.Current.transform.position.ToString("F2") + " hp=" + sw.Current.CurrentHp.ToString("F0") + " 클립=" + ClipNames(sw.Current)); _pc = sw.Current; Finish(sb); } /// /// 지정 캐릭터로 바꾸고 적을 바로 앞에 붙여 자동 전투를 유도한 뒤, /// 공격 모션이 재생되는 순간 게임 뷰를 캡처한다(UI 포함 · 프로젝트 루트 기준 경로). /// public void RunCombatShot(int characterIndex, int cameraMode, string relativePath, float hitNormalizedDelay) { Resolve(); StopAllCoroutines(); StartCoroutine(CombatShot(characterIndex, cameraMode, relativePath, hitNormalizedDelay)); } private IEnumerator CombatShot(int characterIndex, int cameraMode, string relativePath, float delay) { Running = true; var sb = new StringBuilder(); sb.AppendLine("[WLAutoProbe] 전투 캡처 — 캐릭터 " + characterIndex + " · 카메라 " + (cameraMode + 1) + " · " + relativePath); yield return WaitPlayable(); var sw = Object.FindFirstObjectByType(); if (sw != null && sw.CurrentIndex != characterIndex) { sw.SwitchTo(characterIndex); yield return new WaitForSeconds(0.5f); } _pc = sw != null ? sw.Current : _pc; if (_pc == null) { sb.AppendLine(" 플레이어 없음"); Finish(sb); yield break; } if (_cam != null) { _cam.SetMode(cameraMode); yield return WaitBlend(); } var enemy = NearestEnemy(_pc.transform.position); if (enemy == null) { sb.AppendLine(" 살아 있는 적 없음"); Finish(sb); yield break; } // 적을 캐릭터 정면 가까이로 옮긴다(공격 사거리 안) var ecc = enemy.GetComponent(); if (ecc != null) ecc.enabled = false; enemy.transform.position = _pc.transform.position + _pc.transform.forward * 1.4f; if (ecc != null) ecc.enabled = true; sb.AppendLine(" 적 배치 = " + enemy.name + " 거리 " + Vector3.Distance(enemy.transform.position, _pc.transform.position).ToString("F2") + "m"); float guard = Time.time + 10f; while (!_pc.IsAttacking && Time.time < guard) yield return null; if (!_pc.IsAttacking) { sb.AppendLine(" 제한 시간 내 공격 없음"); Finish(sb); yield break; } if (delay > 0f) yield return new WaitForSeconds(delay); sb.AppendLine(" 공격 순간 클립 = " + ClipNames(_pc) + " 콤보=" + _pc.CurrentComboStep); string root = System.IO.Directory.GetParent(Application.dataPath).FullName.Replace("\\", "/"); ScreenCapture.CaptureScreenshot(root + "/" + relativePath); sb.AppendLine(" 캡처 요청 = " + root + "/" + relativePath); yield return new WaitForSeconds(0.6f); Finish(sb); } private static string ClipNames(PlayerController pc) { var an = pc != null ? pc.GetComponentInChildren() : null; if (an == null || an.runtimeAnimatorController == null) return "(애니메이터 없음)"; var infos = an.GetCurrentAnimatorClipInfo(0); if (infos == null || infos.Length == 0) return "(재생 클립 없음)"; var s = ""; for (int i = 0; i < infos.Length; i++) { if (infos[i].clip == null) continue; if (s.Length > 0) s += " + "; s += infos[i].clip.name + "(w" + infos[i].weight.ToString("0.00") + ")"; } return s; } private static WL.Combat.EnemyController NearestEnemy(Vector3 from) { var all = Object.FindObjectsByType(FindObjectsSortMode.None); WL.Combat.EnemyController best = null; float bd = float.MaxValue; for (int i = 0; i < all.Length; i++) { if (all[i] == null || all[i].IsDead) continue; float d = (all[i].transform.position - from).sqrMagnitude; if (d < bd) { bd = d; best = all[i]; } } return best; } // ───────────────────────────────── 공통 /// CharacterController 를 잠시 끄고 순간이동한다(켜진 채로는 위치 대입이 무시된다). private void Teleport(Vector3 pos) { if (_pc == null) return; var cc = _pc.GetComponent(); if (cc != null) cc.enabled = false; _pc.transform.position = pos; if (cc != null) cc.enabled = true; } private IEnumerator WaitPlayable() { float guard = Time.time + 20f; while (_pc != null && !_pc.IsPlayable && Time.time < guard) yield return null; } private IEnumerator WaitBlend() { float guard = Time.time + 5f; while (_cam != null && _cam.ModeBlendRemaining > 0f && Time.time < guard) yield return null; yield return new WaitForSeconds(0.2f); } private void Finish(StringBuilder sb) { Report = sb.ToString(); Running = false; Debug.Log(Report); } } }