// ───────────────────────────────────────────────────────────────────────────── // WL_CombatAnimSetup.cs — 클래스 컨트롤러의 공격 모션 교체 + 이벤트 재기록 (에디터 전용) // // PD 지시 #760 (2026-09-06) 1단계 산출물 · **스테이징**(아직 Assets/ 밖). // 호출 = Unity CLI: unity command run_script --file AgentScripts/WL_CombatAnimSetup.cs // --entry WL_CombatAnimSetup.Run --args '["dryRun"]' // // ── 왜 이 순서인가 ──────────────────────────────────────────────────────────── // 이벤트가 전투의 심장이다(INVENTORY §3): Projectile = 타격 판정, ShowEffect = 트레일 신호. // 따라서 "클립을 바꾼다"가 아니라 "이벤트를 실은 클립을 만들고 상태에 물린다" 가 본체다. // // ── 이벤트를 어디에 심는가 (실측으로 고른 방식) ─────────────────────────────── // 후보 ① AnimationUtility.SetAnimationEvents(clip, events) // → FBX 에서 임포트된 클립은 **읽기 전용**이다. 호출은 되지만 재임포트 때 사라진다. // .anim 사본에만 유효. ✕ 채택하지 않음 // 후보 ② ModelImporter.clipAnimations[i].events = … → SaveAndReimport() // → FBX 임포터가 클립을 구울 때 이벤트를 함께 굽는다. **영속적**이고 // 한 FBX 에서 이름·이벤트가 다른 서브클립을 여러 개 뽑을 수 있다(MAPPING §3-1). // ✔ **채택** // ※ ②는 SerializedObject 우회 없이 공개 API 로 된다. 단 clipAnimations 는 배열 복사본을 // 돌려주므로 **통째로 다시 대입**해야 반영된다(Unity 의 오래된 함정). // // ── 안전장치 ───────────────────────────────────────────────────────────────── // · dryRun 기본 ON. 실행하려면 인자에 "apply" 를 준다. // · 컨트롤러 13종 + 손대는 FBX 의 .meta 를 backupRoot 로 먼저 복사한다(C8 롤백 경로). // · Play 중이면 즉시 중단한다(#746 사고 재발 방지). // · 원본 클립(10502 등 기존 클래스가 쓰는 것)은 **절대 수정하지 않는다** — 항상 서브클립 추가. // ───────────────────────────────────────────────────────────────────────────── using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; public static class WL_CombatAnimSetup { // ── 진입점 ────────────────────────────────────────────────────────────── // args: "dryRun"(기본) | "apply" | "report" // "mapping=<경로>" 기본 Assets/WL/Combat/CombatAnimMapping.asset public static string Run(string[] args) { var sb = new StringBuilder(); bool apply = args != null && Array.IndexOf(args, "apply") >= 0; bool reportOnly = args != null && Array.IndexOf(args, "report") >= 0; string mappingPath = "Assets/WL/Combat/CombatAnimMapping.asset"; if (args != null) foreach (var a in args) if (a != null && a.StartsWith("mapping=")) mappingPath = a.Substring(8); // 1) 가드 — Play 중이면 손대지 않는다 (#746) if (UnityEditor.EditorApplication.isPlaying || UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode) return "ABORT: 에디터가 Play 중이다. PD 테스트 중일 수 있으므로 중단한다."; if (UnityEditor.EditorApplication.isCompiling) return "ABORT: 컴파일 중이다. recompile_status 가 completed 가 된 뒤 다시 호출할 것."; // 2) 매핑 데이터 로드 (C45 — 값은 전부 여기서 온다) var mapping = UnityEditor.AssetDatabase.LoadAssetAtPath(mappingPath); if (mapping == null) return "ABORT: 매핑 에셋을 찾지 못했다 — " + mappingPath; sb.AppendLine("WL_CombatAnimSetup mode=" + (reportOnly ? "report" : apply ? "APPLY" : "dryRun")); sb.AppendLine("mapping=" + mappingPath + " classes=" + mapping.classes.Count); // 3) 클래스 테이블에서 컨트롤러 경로를 읽는다 (SOT = ClassConfig.json · C45) var ctrlByClass = LoadControllerPathsFromClassConfig(sb); // 4) 백업 (C8) — apply 일 때만 string backupDir = null; if (apply && !reportOnly) { backupDir = ResolveBackupDir(mapping.backupRoot); Directory.CreateDirectory(backupDir); sb.AppendLine("backup -> " + backupDir); } int touchedClips = 0, touchedStates = 0, errors = 0; foreach (var spec in mapping.classes) { if (spec == null || !spec.enabled) continue; string ctrlPath = !string.IsNullOrEmpty(spec.controllerPathOverride) ? spec.controllerPathOverride : (ctrlByClass.ContainsKey(spec.classId) ? ctrlByClass[spec.classId] : null); if (string.IsNullOrEmpty(ctrlPath)) { sb.AppendLine(" [ERR] " + spec.classId + " 컨트롤러 경로 미해결"); errors++; continue; } var ctrl = UnityEditor.AssetDatabase.LoadAssetAtPath(ctrlPath); if (ctrl == null) { sb.AppendLine(" [ERR] 컨트롤러 로드 실패 " + ctrlPath); errors++; continue; } sb.AppendLine("── " + spec.classId + " " + spec.note + " (" + Path.GetFileName(ctrlPath) + ")"); if (apply && !reportOnly && backupDir != null) BackupFile(ctrlPath, backupDir, sb); foreach (var st in spec.states) { if (st == null || string.IsNullOrEmpty(st.sourceFbxPath)) continue; // 4-1) 서브클립 보장 (이벤트 포함) — ModelImporter 경유 string clipName = string.IsNullOrEmpty(st.subClipName) ? st.sourceClipName : st.subClipName.Replace("{CID}", spec.classId.ToString()); var clip = EnsureSubClip(st, spec.classId, clipName, apply && !reportOnly, backupDir, sb, ref errors); if (clip != null) touchedClips++; // 4-2) 상태에 물리기 var state = FindState(ctrl, st.stateName); if (state == null && st.createIfMissing && apply && !reportOnly) state = ctrl.layers[0].stateMachine.AddState(st.stateName); if (state == null) { sb.AppendLine(" [WARN] 상태 없음: " + st.stateName); continue; } sb.AppendLine(" " + st.stateName + " motion: " + (state.motion != null ? state.motion.name : "(null)") + " -> " + clipName + " events=" + st.events.Count); if (apply && !reportOnly && clip != null) { state.motion = clip; touchedStates++; } // 4-3) 들어오는 전이 exitTime 조정 if (st.incomingExitTime > 0f && apply && !reportOnly) SetIncomingExitTime(ctrl, state, st.incomingExitTime, mapping.transitionDuration, sb); } } if (apply && !reportOnly) { UnityEditor.AssetDatabase.SaveAssets(); UnityEditor.AssetDatabase.Refresh(); } sb.AppendLine("결과: clips=" + touchedClips + " states=" + touchedStates + " errors=" + errors + (apply ? "" : " (dryRun — 아무것도 쓰지 않았다)")); return sb.ToString(); } // ── 서브클립 보장 ──────────────────────────────────────────────────────── // FBX 임포터의 clipAnimations 에 이 없으면 원본 클립 구간을 복제해 추가하고, // events 를 매핑대로 다시 쓴다. 이미 있으면 events 만 갱신한다. // ※ 기존 클립(다른 클래스가 쓰는 것)은 건드리지 않는다. private static UnityEngine.AnimationClip EnsureSubClip( WL.Combat.StateClipSpec st, int classId, string clipName, bool write, string backupDir, StringBuilder sb, ref int errors) { var importer = UnityEditor.AssetImporter.GetAtPath(st.sourceFbxPath) as UnityEditor.ModelImporter; if (importer == null) { sb.AppendLine(" [ERR] ModelImporter 없음: " + st.sourceFbxPath); errors++; return null; } var clips = importer.clipAnimations; if (clips == null || clips.Length == 0) clips = importer.defaultClipAnimations; // 아직 손대지 않은 FBX if (clips == null || clips.Length == 0) { sb.AppendLine(" [ERR] 클립 0개: " + st.sourceFbxPath); errors++; return null; } var srcName = string.IsNullOrEmpty(st.sourceClipName) ? clips[0].name : st.sourceClipName; var src = clips.FirstOrDefault(c => c.name == srcName) ?? clips[0]; float clipLength = (src.lastFrame - src.firstFrame) / 30f; // 실측: 전 클립 sampleRate 30 var evs = st.events.Select(e => new UnityEngine.AnimationEvent { functionName = e.functionName, time = e.ResolveTime(clipLength), stringParameter = e.ResolveString(classId), intParameter = e.intParameter, floatParameter = e.floatParameter, }).OrderBy(e => e.time).ToArray(); var list = clips.ToList(); int idx = list.FindIndex(c => c.name == clipName); if (idx < 0) { var copy = new UnityEditor.ModelImporterClipAnimation { name = clipName, takeName = src.takeName, firstFrame = src.firstFrame, lastFrame = src.lastFrame, loopTime = src.loopTime, loopPose = src.loopPose, lockRootRotation = src.lockRootRotation, lockRootHeightY = src.lockRootHeightY, lockRootPositionXZ = src.lockRootPositionXZ, keepOriginalOrientation = src.keepOriginalOrientation, keepOriginalPositionY = src.keepOriginalPositionY, keepOriginalPositionXZ = src.keepOriginalPositionXZ, heightFromFeet = src.heightFromFeet, cycleOffset = src.cycleOffset, maskType = src.maskType, events = evs, }; list.Add(copy); sb.AppendLine(" + 서브클립 생성 " + clipName + " (" + src.name + " 복제 · " + src.firstFrame + "~" + src.lastFrame + "f · " + clipLength.ToString("0.000") + "s)"); } else { var c = list[idx]; c.events = evs; list[idx] = c; sb.AppendLine(" ~ 서브클립 이벤트 갱신 " + clipName); } if (write) { if (backupDir != null) BackupFile(st.sourceFbxPath + ".meta", backupDir, sb); importer.clipAnimations = list.ToArray(); // ★ 통째 대입해야 반영된다 importer.SaveAndReimport(); } // 재임포트 후에 서브에셋에서 찾는다 foreach (var o in UnityEditor.AssetDatabase.LoadAllAssetsAtPath(st.sourceFbxPath)) { var ac = o as UnityEngine.AnimationClip; if (ac != null && ac.name == clipName) return ac; } if (write) { sb.AppendLine(" [ERR] 재임포트 후 클립 미발견: " + clipName); errors++; } return null; } // ── 유틸 ──────────────────────────────────────────────────────────────── private static UnityEditor.Animations.AnimatorState FindState( UnityEditor.Animations.AnimatorController ctrl, string name) { foreach (var layer in ctrl.layers) foreach (var cs in layer.stateMachine.states) if (cs.state != null && cs.state.name == name) return cs.state; return null; } private static void SetIncomingExitTime( UnityEditor.Animations.AnimatorController ctrl, UnityEditor.Animations.AnimatorState dst, float exitTime, float duration, StringBuilder sb) { foreach (var layer in ctrl.layers) foreach (var cs in layer.stateMachine.states) { if (cs.state == null) continue; foreach (var t in cs.state.transitions) { if (t.destinationState != dst) continue; t.hasExitTime = true; t.exitTime = exitTime; t.duration = duration; sb.AppendLine(" transition " + cs.state.name + " -> " + dst.name + " exit=" + exitTime + " dur=" + duration); } } } private static Dictionary LoadControllerPathsFromClassConfig(StringBuilder sb) { // SOT = 테이블(JSON). 코드에 경로를 박지 않는다(C45). var map = new Dictionary(); const string p = "Assets/ResWork/Table/Export/ClassConfig.json"; if (!File.Exists(p)) { sb.AppendLine("[WARN] ClassConfig.json 없음 — controllerPathOverride 필요"); return map; } string txt = File.ReadAllText(p, new UTF8Encoding(false)); // 최소 파서: "n_ClassID":"10101" … "s_AnimationController":"Assets/…" var idRx = new System.Text.RegularExpressions.Regex("\"n_ClassID\"\\s*:\\s*\"?(\\d+)\"?"); var acRx = new System.Text.RegularExpressions.Regex("\"s_AnimationController\"\\s*:\\s*\"([^\"]+)\""); var ids = idRx.Matches(txt); var acs = acRx.Matches(txt); int n = Math.Min(ids.Count, acs.Count); for (int i = 0; i < n; i++) map[int.Parse(ids[i].Groups[1].Value)] = acs[i].Groups[1].Value; sb.AppendLine("ClassConfig 에서 컨트롤러 경로 " + map.Count + "건 로드"); return map; } private static string ResolveBackupDir(string template) { string stamp = DateTime.Now.ToString("yyyyMMdd_HHmm"); string rel = (template ?? "공유/개발팀_백업/WL/Animators_{yyyyMMdd_HHmm}").Replace("{yyyyMMdd_HHmm}", stamp); // 프로젝트 루트(Assets 의 부모) 기준 — Assets/ 밖에 남긴다(임포트 유발 방지) string root = Directory.GetParent(UnityEngine.Application.dataPath).FullName; return Path.Combine(root, rel.Replace('/', Path.DirectorySeparatorChar)); } private static void BackupFile(string assetPath, string backupDir, StringBuilder sb) { try { string abs = Path.Combine(Directory.GetParent(UnityEngine.Application.dataPath).FullName, assetPath); if (!File.Exists(abs)) return; string dst = Path.Combine(backupDir, assetPath.Replace('/', '_')); if (!File.Exists(dst)) File.Copy(abs, dst); } catch (Exception e) { sb.AppendLine(" [WARN] 백업 실패 " + assetPath + " : " + e.Message); } } }