// WL778_Seq.cs — PD 지시 #777 + #778 런타임 실측 (Play 중 · Assets 무수정 · 서버 쓰기 없음) // unity command run_script --file AgentScripts/WL778_Seq.cs --entry WL778_Seq.RunAt --args '[10501, "before_10501", 1.5, 1, 4.5]' // unity command run_script --file AgentScripts/WL778_Seq.cs --entry WL778_Seq.Report --args '["before_10501"]' // unity command run_script --file AgentScripts/WL778_Seq.cs --entry WL778_Seq.SetFollow --args '[1, 0, 0]' // unity command run_script --file AgentScripts/WL778_Seq.cs --entry WL778_Seq.Cfg // // #777 : 슬래시 이펙트 인스턴스의 월드 위치 vs PC 위치 vs 검(자루·검끝) 위치를 매 프레임 기록해 // 「스폰 후 PC 이동량」과 「이펙트가 무기에서 벌어진 거리」를 수치로 낸다. // #778 : Animator 상태 진입/전이 시각 · 타격(몹 HP 감소) 시각 · 스윙(ShowEffect) 시각을 기록해 // 타격 간 간격과 콤보 사이클 길이를 낸다. // 결과 = Screenshots_WL/combat4/seq_.txt + 캡처 PNG (모두 Assets 밖). using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Text; using UnityEngine; using UnityEngine.AI; public static class WL778_Seq { const string OutDir = "Screenshots_WL/combat4"; const BindingFlags BF = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; public static object RunAt(int classId, string tag, float dist, int noPet, float seconds) { if (!Application.isPlaying) return "not playing"; var me = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None).FirstOrDefault(); if (me == null) return "MyActor none"; if (GameObject.Find("__wl778seq") != null) return "sequence already running"; var host = new GameObject("__wl778seq").AddComponent(); host.StartCoroutine(host.Co(me, classId, tag, dist, noPet != 0, seconds <= 0f ? 4.5f : seconds)); return "started " + tag + " class=" + classId + " dist=" + dist + " noPet=" + noPet + " sec=" + seconds; } public static object Report(string tag) { var p = System.IO.Path.Combine(OutDir, "seq_" + tag + ".txt"); return System.IO.File.Exists(p) ? System.IO.File.ReadAllText(p) : "(no report yet) " + p; } /// #777 추종 설정을 런타임에 바꾼다(메모리만 · 디스크 저장 없음 · a/b 비교용). public static object SetFollow(int follow, int followRotation, float seconds) { var st = WL.Combat.SlashTrailSettings.Instance; if (st == null) return "settings null"; st.arcFollowPlayer = follow != 0; st.arcFollowRotation = followRotation != 0; st.arcFollowSeconds = seconds; return Cfg(); } public static object Cfg() { var st = WL.Combat.SlashTrailSettings.Instance; var mo = WL.Combat.WLCombatMotionSettings.Instance; var sb = new StringBuilder(); sb.AppendLine(st == null ? "SlashTrailSettings=null" : string.Format("Slash: enable={0} burstMode={1} timing={2} suppress={3} drawRibbon={4} autoOrient={5} fit={6} scale={7} anchor={8} | FOLLOW player={9} rot={10} sec={11}", st.enableWeaponTrail, st.burstMode, st.burstTiming, st.suppressMode, st.drawRibbon, st.arcAutoOrient, st.arcFitToBlade, st.arcScale, st.arcAnchor, st.arcFollowPlayer, st.arcFollowRotation, st.arcFollowSeconds)); sb.AppendLine(mo == null ? "WLCombatMotionSettings=null" : string.Format("Motion: enable={0} sec={1} scale={2} max={3} easeOut={4} stopDist={5}", mo.enableAttackStep, mo.attackStepSeconds, mo.attackStepScale, mo.attackStepMaxDistance, mo.attackStepEaseOut, mo.attackStepStopDistance)); sb.AppendLine("follower: attaches=" + WL.Combat.SlashArcFollower.AttachCount + " last=" + (WL.Combat.SlashArcFollower.LastInfo ?? "(none)")); sb.AppendLine("lastArc=" + (WL.Combat.WeaponTrailDriver.LastArcInfo ?? "(none)")); return sb.ToString(); } /// 스폰된 슬래시 인스턴스의 파티클 시뮬레이션 공간을 덤프한다(#777 잔상 위험 실측). public static object SimSpace() { var sb = new StringBuilder(); foreach (var m in UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) { sb.Append(m.gameObject.name).Append(" active=").Append(m.gameObject.activeInHierarchy).Append(" :: "); foreach (var ps in m.GetComponentsInChildren(true)) sb.Append(ps.gameObject.name).Append('=').Append(ps.main.simulationSpace).Append(' '); sb.AppendLine(); } return sb.Length == 0 ? "(스폰된 슬래시 인스턴스 없음 — 공격 후 다시 호출)" : sb.ToString(); } class SeqHost : MonoBehaviour { static double HP(Actor a) { var mi = typeof(Actor).GetMethod("Get_HP", BF); if (mi == null || a == null) return double.NaN; try { return Convert.ToDouble(mi.Invoke(a, null)); } catch { return double.NaN; } } static void SuppressPets(bool suppress) { foreach (var p in UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) if (p.gameObject.activeSelf == suppress) p.gameObject.SetActive(!suppress); } public IEnumerator Co(MyActor me, int classId, string tag, float dist, bool noPet, float seconds) { var sb = new StringBuilder(); System.IO.Directory.CreateDirectory(OutDir); if (noPet) SuppressPets(true); var pc = me as PCActor; if (classId > 0 && pc != null) { var data = table_classconfig.Ins.Get_Data_orNull(classId); if (data == null) { sb.AppendLine("class data null " + classId); Done(tag, sb); yield break; } pc.Change_Class(data, true); yield return new WaitForSeconds(1.5f); } var anim = me.GetComponentInChildren(); sb.AppendLine("class=" + classId + " ctrl=" + (anim != null && anim.runtimeAnimatorController != null ? anim.runtimeAnimatorController.name : "(null)") + " scene=" + UnityEngine.SceneManagement.SceneManager.GetActiveScene().name + " fixedDt=" + Time.fixedDeltaTime.ToString("F4")); sb.Append(Cfg()); var mobs = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None) .Where(m => HP(m) > 0).OrderBy(m => Vector3.Distance(m.transform.position, me.transform.position)).ToArray(); if (mobs.Length == 0) { sb.AppendLine("no live MobActor"); Done(tag, sb); yield break; } var t = mobs[0]; var dir = t.transform.position - me.transform.position; dir.y = 0f; if (dir.sqrMagnitude > 1e-4f && Mathf.Abs(dir.magnitude - dist) > 0.1f) { var pos = t.transform.position - dir.normalized * dist; var na = me.GetComponent(); if (na != null && na.enabled && na.isOnNavMesh) na.Warp(pos); else me.transform.position = pos; } me.transform.LookAt(new Vector3(t.transform.position.x, me.transform.position.y, t.transform.position.z)); var setT = typeof(Actor).GetMethod("Change_Target", BF); if (setT != null) { try { setT.Invoke(me, new object[] { false, (Actor)t, false }); } catch { } } yield return null; var watch = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None) .Where(m => HP(m) > 0 && Vector3.Distance(m.transform.position, me.transform.position) < 8f).ToList(); var lastHpAll = watch.ToDictionary(m => m, m => HP(m)); Vector3 pos0 = me.transform.position; sb.AppendLine("target=" + t.name + " hp0=" + HP(t).ToString("F0") + " d=" + Vector3.Distance(pos0, t.transform.position).ToString("F2") + " pcPos0=" + pos0.ToString("F2") + " watched=" + watch.Count); double aspd = 1.0; try { var fi = typeof(Actor).GetField("m_Stat", BF); var stat = fi != null ? fi.GetValue(me) : null; var gm = stat != null ? stat.GetType().GetMethod("Get_Stat", new[] { typeof(eStat) }) : null; if (gm != null) aspd = Convert.ToDouble(gm.Invoke(stat, new object[] { eStat.FinalAttackSpeed })); } catch { } var drv = me.GetComponent(); me.Play_Attack(0, (float)aspd); sb.AppendLine("Play_Attack(0," + aspd.ToString("F2") + ") animSpeed=" + (anim != null ? anim.speed : 0f).ToString("F2")); float t0 = Time.time; int lastStateHash = 0; bool lastInTr = false; int lastSwing = drv != null ? drv.SwingCount : 0; int lastStep = WL.Combat.AttackStepDriver.StepCount; int lastAttach = WL.Combat.SlashArcFollower.AttachCount; string lastArc = WL.Combat.WeaponTrailDriver.LastArcInfo; var stateLog = new List(); var hitLog = new List(); var swingLog = new List(); var stepLog = new List(); var fxLog = new List(); var capLog = new List(); // #777 — 추적 중인 이펙트 인스턴스 Transform fx = null; Vector3 fxSpawnPos = Vector3.zero, pcAtSpawn = Vector3.zero; float fxSpawnT = -1f, fxMaxPcMove = 0f, fxMaxDrift = 0f, fxMaxHiltDist = 0f, fxMinHiltDist = 999f; int fxSamples = 0; string fxName = ""; var fxRows = new List(); var fxSummaries = new List(); float capNext = -1f; int caps = 0; float capInterval = 0.1f; int capMax = 8; var hitTimes = new List(); while (Time.time - t0 < seconds) { if (noPet) SuppressPets(true); float el = Time.time - t0; // ── #778 상태·전이 if (anim != null && anim.layerCount > 0) { var cur = anim.GetCurrentAnimatorStateInfo(0); bool inTr = anim.IsInTransition(0); if (cur.fullPathHash != lastStateHash) { lastStateHash = cur.fullPathHash; string cn = "(?)"; var ci = anim.GetCurrentAnimatorClipInfo(0); if (ci.Length > 0 && ci[0].clip != null) cn = ci[0].clip.name + " len=" + ci[0].clip.length.ToString("F3"); stateLog.Add(string.Format("{0:F3}s STATE {1} n={2:F3}", el, cn, cur.normalizedTime)); } if (inTr != lastInTr) { lastInTr = inTr; if (inTr) { var ti = anim.GetAnimatorTransitionInfo(0); var nx = anim.GetNextAnimatorStateInfo(0); string nn = "(?)"; var nci = anim.GetNextAnimatorClipInfo(0); if (nci.Length > 0 && nci[0].clip != null) nn = nci[0].clip.name; stateLog.Add(string.Format("{0:F3}s TRANS-BEGIN dur={1:F3}s -> {2} (src n={3:F3})", el, ti.duration, nn, cur.normalizedTime)); } else stateLog.Add(string.Format("{0:F3}s TRANS-END (now n={1:F3})", el, cur.normalizedTime)); } } // ── 스윙(ShowEffect) · 전진(Move) · 추종 부착 if (drv != null && drv.SwingCount != lastSwing) { lastSwing = drv.SwingCount; swingLog.Add(string.Format("{0:F3}s SWING#{1}", el, lastSwing)); } if (WL.Combat.AttackStepDriver.StepCount != lastStep) { lastStep = WL.Combat.AttackStepDriver.StepCount; stepLog.Add(string.Format("{0:F3}s STEP#{1} planned={2:F2}m", el, lastStep, WL.Combat.AttackStepDriver.LastPlannedDistance)); } // ── #777 이펙트 스폰 감지 → 추적 시작 string arcNow = WL.Combat.WeaponTrailDriver.LastArcInfo; bool spawned = (arcNow != lastArc) || (WL.Combat.SlashArcFollower.AttachCount != lastAttach); if (spawned) { lastArc = arcNow; lastAttach = WL.Combat.SlashArcFollower.AttachCount; if (fx != null) fxSummaries.Add(FxSummary(fxName, fxSpawnT, fxSamples, fxMaxPcMove, fxMaxDrift, fxMinHiltDist, fxMaxHiltDist)); var mask = WL.Combat.WeaponTrailDriver.LastMask; fx = mask != null ? mask.transform : null; fxName = mask != null ? mask.gameObject.name : "(none)"; fxSpawnT = el; fxSpawnPos = fx != null ? fx.position : Vector3.zero; pcAtSpawn = me.transform.position; fxMaxPcMove = 0f; fxMaxDrift = 0f; fxMaxHiltDist = 0f; fxMinHiltDist = 999f; fxSamples = 0; fxLog.Add(string.Format("{0:F3}s SPAWN {1} fxPos={2} pcPos={3} follower={4} | {5}", el, fxName, fxSpawnPos.ToString("F2"), pcAtSpawn.ToString("F2"), fx != null && fx.GetComponent() != null && fx.GetComponent().IsFollowing, arcNow)); if (capNext < 0f) capNext = el; // 첫 스폰부터 캡처 시퀀스 시작 } // ── #777 프레임 시계열 if (fx != null && fx.gameObject.activeInHierarchy) { Vector3 pcNow = me.transform.position; Vector3 fxNow = fx.position; float pcMove = Vector3.Distance(pcAtSpawn, pcNow); // 「떨어진 느낌」의 크기 = PC 대비 상대 위치가 스폰 시점에서 얼마나 벌어졌는가 float drift = Vector3.Distance(fxNow - pcNow, fxSpawnPos - pcAtSpawn); float hiltD = (drv != null && drv.ActiveSlotCount > 0) ? Vector3.Distance(fxNow, drv.GetHiltWorld(0)) : -1f; if (pcMove > fxMaxPcMove) fxMaxPcMove = pcMove; if (drift > fxMaxDrift) fxMaxDrift = drift; if (hiltD >= 0f) { if (hiltD > fxMaxHiltDist) fxMaxHiltDist = hiltD; if (hiltD < fxMinHiltDist) fxMinHiltDist = hiltD; } fxSamples++; if (fxRows.Count < 220) fxRows.Add(string.Format("{0:F3}s [{1}] +{2:F3}s fx={3} pc={4} pcMove={5:F3} drift={6:F3} hiltD={7:F3}", el, fxName, el - fxSpawnT, fxNow.ToString("F2"), pcNow.ToString("F2"), pcMove, drift, hiltD)); } else if (fx != null) { fxSummaries.Add(FxSummary(fxName, fxSpawnT, fxSamples, fxMaxPcMove, fxMaxDrift, fxMinHiltDist, fxMaxHiltDist)); fxLog.Add(string.Format("{0:F3}s OFF {1} (수명 {2:F3}s)", el, fxName, el - fxSpawnT)); fx = null; } // ── 타격 foreach (var m in watch) { if (m == null) continue; double hp = HP(m), prev = lastHpAll[m]; if (Math.Abs(hp - prev) > 0.01) { hitLog.Add(string.Format("{0:F3}s HIT {1} {2:F0} -> {3:F0}", el, m.name, prev, hp)); hitTimes.Add(el); lastHpAll[m] = hp; } } // ── 캡처 시퀀스 (0.1 s 간격) if (capNext >= 0f && caps < capMax && el >= capNext) { capNext = el + capInterval; yield return new WaitForEndOfFrame(); Texture2D tex = null; try { tex = ScreenCapture.CaptureScreenshotAsTexture(); } catch { } if (tex != null) { var path = System.IO.Path.Combine(OutDir, string.Format("seq_{0}_{1:00}_{2:F2}s.png", tag, ++caps, el)); System.IO.File.WriteAllBytes(path, tex.EncodeToPNG()); UnityEngine.Object.Destroy(tex); capLog.Add(string.Format("{0:F3}s CAP {1}", el, path)); } continue; } // 🔴 반드시 프레임 끝에서 샘플링한다 — SlashArcFollower 는 LateUpdate 에서 // 좌표를 갱신하므로 Update 단계(yield return null)에서 읽으면 이펙트 위치가 // 한 프레임 뒤처져 보이고, 그 차이가 그 프레임의 PC 이동량으로 잘못 잡힌다. yield return new WaitForEndOfFrame(); } if (fx != null) fxSummaries.Add(FxSummary(fxName, fxSpawnT, fxSamples, fxMaxPcMove, fxMaxDrift, fxMinHiltDist, fxMaxHiltDist)); sb.AppendLine("== #778 states/transitions (" + stateLog.Count + ") =="); foreach (var s in stateLog) sb.AppendLine(" " + s); sb.AppendLine("== swings ShowEffect (" + swingLog.Count + ") =="); foreach (var s in swingLog) sb.AppendLine(" " + s); sb.AppendLine("== hits (" + hitLog.Count + ") =="); foreach (var s in hitLog) sb.AppendLine(" " + s); if (hitTimes.Count >= 2) { var gaps = new StringBuilder(); for (int i = 1; i < hitTimes.Count; i++) gaps.Append((hitTimes[i] - hitTimes[i - 1]).ToString("F3")).Append("s "); sb.AppendLine(" hit gaps: " + gaps + " | first->last = " + (hitTimes[hitTimes.Count - 1] - hitTimes[0]).ToString("F3") + "s"); } sb.AppendLine("== attack steps (" + stepLog.Count + ") =="); foreach (var s in stepLog) sb.AppendLine(" " + s); sb.AppendLine("== #777 fx events (" + fxLog.Count + ") =="); foreach (var s in fxLog) sb.AppendLine(" " + s); sb.AppendLine("== #777 fx summary (" + fxSummaries.Count + ") =="); foreach (var s in fxSummaries) sb.AppendLine(" " + s); sb.AppendLine("== #777 fx timeline (" + fxRows.Count + ") =="); foreach (var s in fxRows) sb.AppendLine(" " + s); sb.AppendLine("== captures (" + capLog.Count + ") =="); foreach (var s in capLog) sb.AppendLine(" " + s); Vector3 pend = me.transform.position; sb.AppendLine("final pcPos=" + pend.ToString("F2") + " totalΔ=" + Vector3.Distance(pos0, pend).ToString("F3") + "m" + " swings=" + (drv != null ? drv.SwingCount : 0) + " steps=" + WL.Combat.AttackStepDriver.StepCount + " attaches=" + WL.Combat.SlashArcFollower.AttachCount); if (noPet) SuppressPets(false); Done(tag, sb); } static string FxSummary(string name, float t, int n, float pcMove, float drift, float minH, float maxH) { return string.Format("{0} spawn@{1:F3}s frames={2} PC최대이동={3:F3}m 이펙트상대이탈최대={4:F3}m 자루거리 {5:F3}~{6:F3}m", name, t, n, pcMove, drift, minH > 900f ? 0f : minH, maxH); } void Done(string tag, StringBuilder sb) { System.IO.File.WriteAllText(System.IO.Path.Combine(OutDir, "seq_" + tag + ".txt"), sb.ToString()); Destroy(gameObject); } } }