// PD #755 2단계 — 이동 방향 카메라 리드/피치 실측 프로브 // Play 중에 EditorApplication.update 에 붙어 프레임당 1회 Run_byJoystick 을 주입하고 // 30 Hz 로 CSV 를 남긴다. 시퀀스가 끝나면 스스로 등록을 해제하고 파일을 쓴다. // 사용: unity command run_script --file AgentScripts/WL_CamLeadProbe.cs --entry WL_CamLeadProbe.Run --args '["on"]' using System; using System.Globalization; using System.IO; using System.Text; using UnityEditor; using UnityEngine; public static class WL_CamLeadProbe { const string PrefKey = "WL_CamLeadProbeGen"; static string RootPath(string file) { return Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Screenshots_WL", file)); } static string F(float v) { return v.ToString("F4", CultureInfo.InvariantCulture); } /// 현재 카메라·플레이어 상태 확인(주입 전 사전 점검). public static object Info() { if (!Application.isPlaying) return "not playing"; var pc = MyValue.MyPC; var rc = MyValue.m_RealCamera; if (pc == null) return "MyPC null (아직 인게임 아님)"; if (rc == null) return "RealCamera null"; var st = CameraMoveLead.Settings; var cam = Camera.main; var sb = new StringBuilder(); sb.Append("pc=").Append(pc.name).Append(" pos=").Append(pc.transform.position.ToString("F2")) .Append(" moveSpeed=").Append(F(pc.Get_MoveSpeed())) .Append(" | cam=").Append(cam ? cam.name : "null") .Append(" pos=").Append(rc.transform.position.ToString("F2")) .Append(" euler=").Append(rc.transform.eulerAngles.ToString("F2")) .Append(" fov=").Append(cam ? F(cam.fieldOfView) : "?") .Append(" cameraHeight=").Append(F(rc.cameraHeight)) .Append(" rotateAround=").Append(F(rc.rotateAround)) .Append(" m_LookAt=").Append(rc.m_LookAt.ToString("F2")) .Append(" smooth=").Append(F(rc.smooth)) .Append(" | settings=").Append(st == null ? "NULL(기능 비활성)" : ("enabled=" + st.enabled + " lead=" + F(st.leadDistance) + " backLead=" + F(st.backwardLeadDistance) + " pitch=" + F(st.backwardPitchAdd))) .Append(" | lead=").Append(rc.Get_MoveLead().LeadOffset.ToString("F3")) .Append(" pitchAdd=").Append(F(rc.Get_MoveLead().PitchAdd)); return sb.ToString(); } /// 누가 캐릭터를 움직이는가 — 사람(조이스틱) vs 게임 AI(NavMesh 경로) 판별. public static object Who() { if (!Application.isPlaying) return "not playing"; var pc = MyValue.MyPC; if (pc == null) return "MyPC null"; var ag = pc.GetComponent(); return "joystick=" + pc.JoystickStatus + " anim=" + pc.Get_CurAnim() + " pos=" + pc.transform.position.ToString("F2") + (ag == null ? " agent=null" : " agentVel=" + ag.velocity.magnitude.ToString("F2") + " hasPath=" + ag.hasPath + " remain=" + (ag.hasPath ? ag.remainingDistance.ToString("F2") : "-") + " stopDist=" + ag.stoppingDistance.ToString("F2")) + " timeScale=" + Time.timeScale.ToString("F2"); } /// 설정 에셋의 enabled 토글 (ⓖ OFF 대조군용). Play 중에도 즉시 반영된다. public static object SetEnabled(bool v) { var st = CameraMoveLead.Settings; if (st == null) return "settings asset null"; st.enabled = v; return "enabled=" + st.enabled; } // 시퀀스 — 사전 정지(기준선) 2s → 전진 5 → 정지 3 → 후진 5 → 정지 3 → 측면 3 → 정지 3 static readonly string[] s_phase = { "pre", "fwd", "stop1", "back", "stop2", "side", "stop3" }; static readonly float[] s_dur = { 2f, 5f, 3f, 5f, 3f, 3f, 3f }; /// 22초(+사전 정지 2초) 시퀀스를 시작한다. 즉시 반환하며, 끝나면 CSV 가 생성된다. public static object Run(string tag) { return RunCore(tag, true, 0f); } /// 입력을 주입하지 않고 순수 관찰만 한다(다른 사람이 조작 중인 세션에서도 안전). public static object Observe(string tag, float seconds) { return RunCore(tag, false, seconds); } static object RunCore(string tag, bool inject, float obsSeconds) { if (!Application.isPlaying) return "refused: not playing"; var pc = MyValue.MyPC; var rc = MyValue.m_RealCamera; if (pc == null || rc == null) return "refused: MyPC or RealCamera null"; long gen = DateTime.UtcNow.Ticks; EditorPrefs.SetString(PrefKey, gen.ToString()); // 이전 세대 델리게이트 자동 종료용 string csv = RootPath("camlead_" + tag + ".csv"); Directory.CreateDirectory(Path.GetDirectoryName(csv)); var sb = new StringBuilder(); sb.Append("t,phase,px,py,pz,spd,cx,cy,cz,cpitch,cyaw,leadLen,leadX,leadY,leadZ,pitchAdd,lookY,vyRoot,vyChest,camDist,maxAngSpd,maxPosSpd,fwdDot\n"); float t0 = Time.time; int lastFrame = -1; float nextSample = 0f; Vector3 prevPlayer = pc.transform.position; float prevPlayerT = Time.time; Vector3 prevCamPos = rc.transform.position; Vector3 prevCamEuler = rc.transform.eulerAngles; float prevTickT = Time.time; float maxAng = 0f, maxPos = 0f; // 방향 기준은 시퀀스 시작 시점의 카메라 수평 전방으로 고정한다(카메라 회전 피드백 차단). Vector3 camFwd = rc.transform.forward; camFwd.y = 0f; camFwd.Normalize(); Vector3 camRight = rc.transform.right; camRight.y = 0f; camRight.Normalize(); float total = 0f; for (int i = 0; i < s_dur.Length; i++) total += s_dur[i]; if (!inject) total = obsSeconds > 0f ? obsSeconds : 60f; EditorApplication.CallbackFunction cb = null; cb = () => { bool die = !Application.isPlaying || EditorPrefs.GetString(PrefKey, "") != gen.ToString(); float t = Time.time - t0; if (die || t > total) { EditorApplication.update -= cb; try { File.WriteAllText(csv, sb.ToString()); } catch { } return; } if (Time.frameCount == lastFrame) return; // 프레임당 1회만 lastFrame = Time.frameCount; var pcc = MyValue.MyPC; var rcc = MyValue.m_RealCamera; if (pcc == null || rcc == null) return; // 현재 페이즈 string phase = "obs"; float acc = 0f; if (inject) { phase = "?"; for (int i = 0; i < s_dur.Length; i++) { acc += s_dur[i]; if (t < acc) { phase = s_phase[i]; break; } } Vector3 dir = Vector3.zero; if (phase == "fwd") dir = camFwd; else if (phase == "back") dir = -camFwd; else if (phase == "side") dir = camRight; if (dir != Vector3.zero) pcc.Run_byJoystick(dir); } // 프레임 단위 스파이크 추적 float dtTick = Time.time - prevTickT; if (dtTick > 1e-5f) { Vector3 e = rcc.transform.eulerAngles; float dy = Mathf.DeltaAngle(prevCamEuler.y, e.y); float dx = Mathf.DeltaAngle(prevCamEuler.x, e.x); float ang = Mathf.Sqrt(dy * dy + dx * dx) / dtTick; float pos = Vector3.Distance(prevCamPos, rcc.transform.position) / dtTick; if (ang > maxAng) maxAng = ang; if (pos > maxPos) maxPos = pos; prevCamEuler = e; prevCamPos = rcc.transform.position; prevTickT = Time.time; } if (t < nextSample) return; nextSample = t + (1f / 30f); Vector3 p = pcc.transform.position; float dtp = Time.time - prevPlayerT; Vector3 dp = p - prevPlayer; dp.y = 0f; float spd = dtp > 1e-5f ? dp.magnitude / dtp : 0f; prevPlayer = p; prevPlayerT = Time.time; var ml = rcc.Get_MoveLead(); Vector3 lead = ml.LeadOffset; Vector3 cp = rcc.transform.position; Vector3 ce = rcc.transform.eulerAngles; var cam = Camera.main; Vector3 vRoot = cam ? cam.WorldToViewportPoint(p) : Vector3.zero; Vector3 vChest = cam ? cam.WorldToViewportPoint(p + rcc.m_LookAt) : Vector3.zero; sb.Append(F(t)).Append(',').Append(phase).Append(',') .Append(F(p.x)).Append(',').Append(F(p.y)).Append(',').Append(F(p.z)).Append(',') .Append(F(spd)).Append(',') .Append(F(cp.x)).Append(',').Append(F(cp.y)).Append(',').Append(F(cp.z)).Append(',') .Append(F(ce.x > 180f ? ce.x - 360f : ce.x)).Append(',').Append(F(ce.y)).Append(',') .Append(F(lead.magnitude)).Append(',').Append(F(lead.x)).Append(',').Append(F(lead.y)).Append(',').Append(F(lead.z)).Append(',') .Append(F(ml.PitchAdd)).Append(',') .Append(F(p.y + lead.y + rcc.m_LookAt.y)).Append(',') .Append(F(vRoot.y)).Append(',').Append(F(vChest.y)).Append(',') .Append(F(Vector3.Distance(cp, p + rcc.m_LookAt))).Append(',') .Append(F(maxAng)).Append(',').Append(F(maxPos)).Append(',') .Append(F(dp.sqrMagnitude > 1e-8f ? Vector3.Dot(dp.normalized, new Vector3(rcc.transform.forward.x, 0f, rcc.transform.forward.z).normalized) : 0f)).Append('\n'); maxAng = 0f; maxPos = 0f; // 중간 저장(진행 상황을 디스크에서 볼 수 있게) if (sb.Length % 4096 < 220) { try { File.WriteAllText(csv, sb.ToString()); } catch { } } }; EditorApplication.update += cb; return "started tag=" + tag + " total=" + F(total) + "s csv=" + csv + " camFwd=" + camFwd.ToString("F3") + " moveSpeed=" + F(pc.Get_MoveSpeed()); } }