// WL778_Anim.cs — PD 지시 #778 콤보 전이 실측/집행 (에디트 모드 · UnityEditor.Animations API) // unity command run_script --file AgentScripts/WL778_Anim.cs --entry WL778_Anim.Dump // unity command run_script --file AgentScripts/WL778_Anim.cs --entry WL778_Anim.Apply --args '[0.45, 0.50, 0.85, 0.18]' // unity command run_script --file AgentScripts/WL778_Anim.cs --entry WL778_Anim.Restore // // ── 왜 이름을 코드에 넣지 않는가 (C45) ──────────────────────────────────────── // 콤보 사슬의 상태 이름은 컨트롤러마다 다르다(실측): // pcanim_onehand/bluntshield : attack → Attack2 → Attack3 → idle // pcanim_spellblade : attack → Attack2 → Attack3 → MoveAttack1 → idle ← 4타! // pcanim_paladin : attack → Sword&Shield_Attack2 → Sword&Shield_Attack3 → idle // 그래서 이름 대신 **구조**로 사슬을 찾는다: // 시작 = 클래스 테이블(table_classconfig)이 가리키는 컨트롤러의 Base Layer 에서 // "조건 없는 hasExitTime 전이"로만 이어지고, 목적지 클립에 Projectile 이벤트가 있는 상태. // 사슬의 마지막 링크(목적지 클립에 Projectile 이벤트가 없음) = **회복 전이**. // 컨트롤러 목록도 ClassConfig 테이블에서 읽는다(코드에 클래스 ID·경로를 넣지 않는다). // // 🔴 이름 충돌 주의 (2026-09-06 실측): 이 레포에는 전역 네임스페이스에 MonoBehaviour // `AnimatorController` 가 두 개 있다 (Assets/ResWork/Suriyun/Scripts/AnimatorController.cs, // Assets/ResWork/Suriyun/Cute Pet/Scripts/AnimatorController.cs). C# 이름 해석에서 // 전역 타입이 using 으로 들여온 타입을 이기므로 `using UnityEditor.Animations;` 를 써도 // `AnimatorController` 는 그 MonoBehaviour 로 잡힌다 (→ CS1061 layers 없음 · // LoadAssetAtPath 가 항상 null). 반드시 **완전 한정명**으로 쓴다. using System; using System.Collections.Generic; using System.Linq; using System.Text; using UnityEditor; using UnityEditor.Animations; using UnityEngine; public static class WL778_Anim { const string OutDir = "Screenshots_WL/combat4"; const string SnapPath = OutDir + "/anim_before.txt"; const string ClassTable = "Assets/ResWork/Table/Export/ClassConfig.json"; // ── 대상 컨트롤러 = ClassConfig 의 s_AnimationController 중 근접 전투 타입 ──── // 슬래시 프리팹을 가진 근접 클래스(OneHand·Shield)만 콤보 사슬을 갖는다(#747 실측). static string[] TargetControllers() { var txt = System.IO.File.ReadAllText(ClassTable).TrimStart(''); var list = new List(); foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(txt, "\\{[^{}]*\\}")) { string row = m.Value; var bt = System.Text.RegularExpressions.Regex.Match(row, "\"e_BattleType\":\\s*\"([^\"]*)\""); var ac = System.Text.RegularExpressions.Regex.Match(row, "\"s_AnimationController\":\\s*\"([^\"]*)\""); if (!bt.Success || !ac.Success) continue; if (bt.Groups[1].Value != "OneHand" && bt.Groups[1].Value != "Shield") continue; if (!list.Contains(ac.Groups[1].Value)) list.Add(ac.Groups[1].Value); } return list.ToArray(); } class Link { public AnimatorStateTransition tr; public AnimatorState src, dst; public bool dstIsAttack; // 목적지 클립에 Projectile 이벤트가 있는가 = 다음 타 } static bool HasProjectile(AnimatorState s) { var clip = s != null ? s.motion as AnimationClip : null; if (clip == null) return false; var evs = AnimationUtility.GetAnimationEvents(clip); for (int i = 0; i < evs.Length; i++) if (evs[i].functionName == "Projectile") return true; return false; } static float ProjectileNorm(AnimatorState s) { var clip = s != null ? s.motion as AnimationClip : null; if (clip == null || clip.length <= 0f) return -1f; var evs = AnimationUtility.GetAnimationEvents(clip); for (int i = 0; i < evs.Length; i++) if (evs[i].functionName == "Projectile") return evs[i].time / clip.length; return -1f; } // 🔴 Unity 6000.3 실측: run_script 컴파일 환경에서 AnimatorController.layers 가 해석되지 않는다 // (CS1061 · 타입 자체는 UnityEditor.CoreModule 에 있다). 그래서 레이어 API 대신 // .controller 의 서브에셋으로 들어 있는 AnimatorStateMachine 에서 상태를 모은다. // 상태 머신에 속하지 않은 고아 상태(pcanim_onehand 의 "Atk1 0" 등 7개)는 자연히 빠진다. static AnimatorState[] AllStates(string path) { var set = new List(); foreach (var o in AssetDatabase.LoadAllAssetsAtPath(path)) { var sm = o as AnimatorStateMachine; if (sm != null) Collect(sm, set); } return set.ToArray(); } static void Collect(AnimatorStateMachine sm, List outList) { if (sm == null) return; foreach (var cs in sm.states) if (cs.state != null && !outList.Contains(cs.state)) outList.Add(cs.state); foreach (var child in sm.stateMachines) Collect(child.stateMachine, outList); } /// 조건 없는 hasExitTime 전이 1개만 갖는 상태에서 사슬을 따라간다. static List FindChain(UnityEditor.Animations.AnimatorController c, string path) { var states = AllStates(path); // 시작 상태 = 클립에 Projectile 이 있고, 다른 어떤 공격 상태도 이 상태로 사슬을 잇지 않는 것. var chainable = new Dictionary(); foreach (var s in states) { if (!HasProjectile(s)) continue; var cands = s.transitions.Where(t => t != null && !t.mute && t.hasExitTime && (t.conditions == null || t.conditions.Length == 0) && t.destinationState != null).ToArray(); if (cands.Length != 1) continue; chainable[s] = new Link { tr = cands[0], src = s, dst = cands[0].destinationState, dstIsAttack = HasProjectile(cands[0].destinationState) }; } var incoming = new HashSet(chainable.Values.Where(l => l.dstIsAttack).Select(l => l.dst)); var heads = chainable.Keys.Where(s => !incoming.Contains(s)).ToArray(); var chain = new List(); if (heads.Length != 1) return chain; // 사슬이 하나로 특정되지 않으면 손대지 않는다 var cur = heads[0]; var guard = 0; while (chainable.ContainsKey(cur) && guard++ < 16) { var link = chainable[cur]; chain.Add(link); if (!link.dstIsAttack) break; cur = link.dst; } return chain; } static string Row(UnityEditor.Animations.AnimatorController c, Link l, int idx) { var clip = l.src.motion as AnimationClip; return string.Format(" [{0}] {1,-24} -> {2,-24} exit={3:F5} dur={4:F3} fixed={5} off={6:F3} intr={7} | clip={8} len={9:F3}s projNorm={10:F3} dstIsAttack={11}", idx, l.src.name, l.dst.name, l.tr.exitTime, l.tr.duration, l.tr.hasFixedDuration, l.tr.offset, l.tr.interruptionSource, clip != null ? clip.name : "(null)", clip != null ? clip.length : 0f, ProjectileNorm(l.src), l.dstIsAttack); } /// 현재 전이 값을 읽어 보고하고, 되돌리기용 스냅샷을 남긴다. public static object Dump() { System.IO.Directory.CreateDirectory(OutDir); var sb = new StringBuilder(); var snap = new StringBuilder(); foreach (var path in TargetControllers()) { var c = AssetDatabase.LoadAssetAtPath(path); if (c == null) { sb.AppendLine("MISSING " + path); continue; } sb.AppendLine("## " + path); var chain = FindChain(c, path); if (chain.Count == 0) { sb.AppendLine(" (콤보 사슬을 특정하지 못함 — 손대지 않는다)"); continue; } for (int i = 0; i < chain.Count; i++) { sb.AppendLine(Row(c, chain[i], i)); snap.AppendLine(string.Join("\t", new[] { path, chain[i].src.name, chain[i].dst.name, chain[i].tr.exitTime.ToString("R"), chain[i].tr.duration.ToString("R"), chain[i].tr.hasFixedDuration ? "1" : "0" })); } } if (!System.IO.File.Exists(SnapPath) && snap.Length > 0) { System.IO.File.WriteAllText(SnapPath, snap.ToString()); sb.AppendLine("snapshot -> " + SnapPath); } else sb.AppendLine("snapshot 유지(이미 있음) -> " + SnapPath); return sb.ToString(); } /// /// 콤보 전이를 당긴다. /// link1 = 1타→2타 exitTime /// linkN = 2타 이후 콤보 링크 exitTime (사슬이 4타면 3→4 에도 같은 값) /// recov = 마지막 타 → 비공격 상태(회복) exitTime /// dur = 콤보 링크 transitionDuration(초 · hasFixedDuration=true). 회복 전이는 건드리지 않는다. /// 안전판: exitTime 은 그 상태의 Projectile 정규화 시각 + minMargin 아래로는 내려가지 않는다. /// public static object Apply(float link1, float linkN, float recov, float dur, float minMargin = 0.15f) { var sb = new StringBuilder(); foreach (var path in TargetControllers()) { var c = AssetDatabase.LoadAssetAtPath(path); if (c == null) { sb.AppendLine("MISSING " + path); continue; } var chain = FindChain(c, path); if (chain.Count == 0) { sb.AppendLine("SKIP(사슬 미특정) " + path); continue; } sb.AppendLine("## " + path); for (int i = 0; i < chain.Count; i++) { var l = chain[i]; float before = l.tr.exitTime, beforeDur = l.tr.duration; if (l.dstIsAttack) { float want = (i == 0) ? link1 : linkN; float floorV = ProjectileNorm(l.src) + minMargin; float applied = Mathf.Max(want, floorV); l.tr.exitTime = applied; l.tr.hasFixedDuration = true; l.tr.duration = dur; sb.AppendLine(string.Format(" COMBO {0} -> {1}: exit {2:F5} => {3:F5}{4} dur {5:F3} => {6:F3}", l.src.name, l.dst.name, before, applied, applied > want + 1e-5f ? " (Projectile+" + minMargin.ToString("F2") + " 하한 적용)" : "", beforeDur, dur)); } else { l.tr.exitTime = recov; sb.AppendLine(string.Format(" RECOV {0} -> {1}: exit {2:F5} => {3:F5} dur {4:F3} (유지)", l.src.name, l.dst.name, before, recov, beforeDur)); } } EditorUtility.SetDirty(c); } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); return sb.ToString(); } /// Dump 가 남긴 스냅샷으로 되돌린다(C8 롤백 경로). public static object Restore() { if (!System.IO.File.Exists(SnapPath)) return "snapshot 없음: " + SnapPath; var sb = new StringBuilder(); foreach (var line in System.IO.File.ReadAllLines(SnapPath)) { var p = line.Split('\t'); if (p.Length < 6) continue; var c = AssetDatabase.LoadAssetAtPath(p[0]); if (c == null) continue; foreach (var s in AllStates(p[0])) { if (s.name != p[1]) continue; foreach (var t in s.transitions) { if (t.destinationState == null || t.destinationState.name != p[2]) continue; t.exitTime = float.Parse(p[3]); t.duration = float.Parse(p[4]); t.hasFixedDuration = p[5] == "1"; sb.AppendLine("restored " + p[0] + " " + p[1] + "->" + p[2]); } } EditorUtility.SetDirty(c); } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); return sb.ToString(); } }