Project_WL/Assets/WL/Scripts/Debug/WLAutoProbe.cs

1292 lines
68 KiB
C#

using System.Collections;
using System.Text;
using UnityEngine;
using WL.Player;
namespace WL.Diagnostics
{
/// <summary>
/// 자동 검증용 계측 프로브 (에디터 Play 전용).
/// 실제 조작 대신 PlayerController 에 화면 기준 입력을 주입하고, 프레임 단위로 카메라 값을 샘플링해
/// 콘솔에 요약을 남긴다. 게임 로직에는 관여하지 않으며, 씬에 상주시키지 않고 검증 때만 붙였다 뗀다.
///
/// 계측 항목
/// ① 요 추종 수렴 (PD #713 ③) — 8 방향 각각 유지 시 카메라 요가 입력 방향에 수렴하는 시각·프레임당 최대 각변화
/// ② 지그재그·급반전 (PD #709 ⑨) — 프레임당 카메라 위치·요 변화 최대값, 주시점 데드존 동작
/// </summary>
public class WLAutoProbe : MonoBehaviour
{
private PlayerController _pc;
private FollowCamera _cam;
/// <summary>마지막 계측 결과 요약(여러 줄).</summary>
public string Report { get; private set; }
/// <summary>계측 진행 중인가.</summary>
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<PlayerController>();
if (_cam == null) _cam = Object.FindFirstObjectByType<FollowCamera>();
}
// ───────────────────────────────── ① 요 추종 수렴
/// <summary>8 방향 스윕. holdSeconds 동안 각 방향을 유지하며 카메라 요의 수렴을 잰다.</summary>
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);
}
// ───────────────────────────────── ② 지그재그·급반전 + 데드존
/// <summary>좌우 지그재그·급반전 입력으로 프레임당 카메라 변화 최대값을 잰다.</summary>
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 카메라 재설계 검증
/// <summary>한 구간의 카메라 궤적 통계.</summary>
private class CamSeg
{
public string name;
public readonly System.Collections.Generic.List<float> yawStep = new System.Collections.Generic.List<float>();
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; }
/// <summary>프레임 1개 표본. 전환 보간 중에는 스파이크 판정에서 뺀다(PD 지시).</summary>
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<Camera>();
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);
}
/// <summary>PD #719·#722 모드 1 검증 — 4방향·지그재그·급반전 60초 + 궤도 회전 중심 유지.</summary>
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);
}
/// <summary>PD #720·#721 모드 2 검증 — 30초 주행 중 가림·화면 이탈·속도 스파이크.</summary>
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)
/// <summary>PM 과 같은 방식(30Hz · 8초)으로 이펙트-궤적 일치와 Idle 잔상을 잰다.</summary>
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<PlayerSwitcher>();
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<SlashVfxPlayer>();
if (vfx == null) { sb.AppendLine(" SlashVfxPlayer 없음"); Finish(sb); yield break; }
if (_cam != null) { _cam.SetMode(0); yield return WaitBlend(); }
var pcombat = _pc.GetComponent<PlayerCombat>();
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<ParticleSystem>(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 종합 검증
/// <summary>캐릭터별 달리기 사이클 · 모드1 전진 피치/룩어헤드 · 하단 즉시 반전 · 무적 더미를 한 번에 잰다.</summary>
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<PlayerSwitcher>();
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<Animator>();
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<Enemy>();
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<WL.Combat.EnemyController>() == 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 ③)
/// <summary>패드 상·하·좌·우 입력 시 캐릭터가 화면상 같은 방향으로 가는지 두 모드에서 잰다.</summary>
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<Camera>();
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)
/// <summary>화면 3지점(우상단·중앙·좌하단)에서 터치를 시작해 패드 표시 위치·출력 벡터·해제를 잰다.</summary>
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<WL.UI.VirtualPadView>(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)
/// <summary>모드 2 → 1 전환 시 입력이 없는데 이동·회전이 생기는지, 그리고 Idle 진입을 잰다.</summary>
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<WL.Combat.EnemyController>(FindObjectsSortMode.None))
{
if (e == null) continue;
if (Vector3.Distance(e.transform.position, _pc.transform.position) > 30f) continue;
var ecc = e.GetComponent<CharacterController>();
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<Animator>();
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);
}
/// <summary>콤보 1~3타 각각의 타격 순간을 캡처한다 (PD #715 ⑤).</summary>
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<PlayerSwitcher>();
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<SlashVfxPlayer>();
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<CharacterController>();
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 원본 크레센트
/// <summary>공격 3회를 트레일이 가장 길게 남은 순간에 찍고, 같은 씬에 원본 프리팹을 잠깐 띄워 한 장 더 찍는다.</summary>
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<PlayerSwitcher>();
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<SlashVfxPlayer>();
var trail = _pc.GetComponent<BladeTrail>();
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<CharacterController>();
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<ParticleSystem>(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<PlayerCombat>();
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<ParticleSystem>(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);
}
/// <summary>
/// 메인 카메라를 그 자리에서 한 번 렌더해 PNG 로 쓴다.
/// `ScreenCapture.CaptureScreenshot` + `WaitForEndOfFrame` 는 게임 뷰가 그려지지 않는 동안(에디터가 뒤에 있을 때)
/// 아예 깨어나지 않아 코루틴이 멈춘다 — 실측으로 확인했다. 그래서 렌더 타이밍에 기대지 않는 방식을 쓴다.
/// </summary>
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<ParticleSystem>(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 ①)
/// <summary>두 모드에서 위·아래 이동 시 주시점이 밀리는지(화면 세로 위치 변화)를 잰다.</summary>
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<Camera>();
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)
/// <summary>토글 교체 → 이동·공격·피격·수영·복귀를 순서대로 돌며 재생 중인 클립 이름을 남긴다.</summary>
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<PlayerSwitcher>();
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);
}
/// <summary>
/// 지정 캐릭터로 바꾸고 적을 바로 앞에 붙여 자동 전투를 유도한 뒤,
/// 공격 모션이 재생되는 순간 게임 뷰를 캡처한다(UI 포함 · 프로젝트 루트 기준 경로).
/// </summary>
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<PlayerSwitcher>();
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<CharacterController>();
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<Animator>() : 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<WL.Combat.EnemyController>(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;
}
// ───────────────────────────────── 공통
/// <summary>CharacterController 를 잠시 끄고 순간이동한다(켜진 채로는 위치 대입이 무시된다).</summary>
private void Teleport(Vector3 pos)
{
if (_pc == null) return;
var cc = _pc.GetComponent<CharacterController>();
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);
}
}
}