// ───────────────────────────────────────────────────────────────────────────── // 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); UnityEditor.Animations.AnimatorState prevState = null; // #839 체인 연결용(직전 행의 상태) bool prevCreated = false; // #840 직전 행이 이번 apply 에서 신설된 상태인가 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); bool createdNow = false; if (state != null && prevState != null && prevCreated && apply && !reportOnly) { // #840 — 직전 행이 신설 상태(→idle 로 닫혀 있음)이고 이번 행이 기존 상태면, 직전 → 기존 으로 다시 잇는다(창 Attack2b/2c → Attack3). foreach (var old in prevState.transitions.ToArray()) prevState.RemoveTransition(old); var into2 = prevState.AddTransition(state); into2.hasExitTime = true; into2.exitTime = st.incomingExitTime > 0f ? st.incomingExitTime : 0.8f; into2.duration = mapping.transitionDuration; into2.hasFixedDuration = false; sb.AppendLine(" ~ 전이 재연결 " + prevState.name + " → " + st.stateName); } if (state == null && st.createIfMissing && apply && !reportOnly) { createdNow = true; // #839 — 새 콤보 상태: 직전 행 상태의 나가는 전이를 걷어내고 직전 → 새 → idle 로 잇는다(마법 3타 · 대검 4타). state = ctrl.layers[0].stateMachine.AddState(st.stateName); if (prevState != null) { foreach (var old in prevState.transitions.ToArray()) prevState.RemoveTransition(old); var into = prevState.AddTransition(state); into.hasExitTime = true; into.exitTime = st.incomingExitTime > 0f ? st.incomingExitTime : 0.8f; into.duration = mapping.transitionDuration; into.hasFixedDuration = false; var idle = FindState(ctrl, "idle"); if (idle != null) { var outT = state.AddTransition(idle); outT.hasExitTime = true; outT.exitTime = 0.8f; outT.duration = 0.25f; outT.hasFixedDuration = true; } sb.AppendLine(" + 상태 생성 " + st.stateName + " (" + prevState.name + " → " + st.stateName + " → idle)"); } else sb.AppendLine(" + 상태 생성 " + st.stateName + " (직전 상태 없음 · 전이 미연결)"); } else if (state == null && st.createIfMissing) sb.AppendLine(" (dryRun) 상태 생성 예정 " + st.stateName); if (state == null) { sb.AppendLine(" [WARN] 상태 없음: " + st.stateName); continue; } if (!st.stateName.Equals("idle", StringComparison.OrdinalIgnoreCase)) { prevState = state; prevCreated = createdNow; } 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; if (st.speed > 0f) state.speed = st.speed; // #834 상태 재생 속도(비수 3타 등) if (state.mirror != (st.mirror != 0)) { state.mirror = st.mirror != 0; state.mirrorParameterActive = false; sb.AppendLine(" 거울 = " + (st.mirror != 0 ? "켬" : "끔") + " → " + state.name); } // #948-6 빈손 내뻗기(오른손 클립 거울) 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(); } // #948 — 매핑 행의 maskPath(AvatarMask 에셋)를 임포터 서브클립에 씌운다(CopyFromOther). 비우면 원본 설정 그대로. // 상체 마스크(루트·다리 제외) = 지팡이 3타를 점프 없이 제자리에서 상체만(PD 「하체는 서 있는 상태에서 상체만」). private static void ApplyMask(UnityEditor.ModelImporterClipAnimation c, WL.Combat.StateClipSpec st, StringBuilder sb) { if (c == null || st == null) return; if (st.bakeRootY >= 0) // #948 Root Y Bake Into Pose 덮기(0 = 점프 제거) { c.lockRootHeightY = st.bakeRootY != 0; sb.AppendLine(" 루트 Y Bake Into Pose = " + (st.bakeRootY != 0 ? "켬" : "끔") + " → " + c.name); } if (string.IsNullOrEmpty(st.maskPath)) return; var mask = UnityEditor.AssetDatabase.LoadAssetAtPath(st.maskPath); if (mask == null) { sb.AppendLine(" [WARN] 마스크 에셋 없음: " + st.maskPath); return; } c.maskType = UnityEditor.ClipAnimationMaskType.CopyFromOther; c.maskSource = mask; sb.AppendLine(" 마스크 " + mask.name + " → " + c.name); } // ── 서브클립 보장 ──────────────────────────────────────────────────────── // 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) { // #838 — FR(FirstRising) .anim 에셋(휴머노이드 클립)을 소스로 받는다: 클래스별 사본(.anim)을 만들고 이벤트를 **초** 단위로 굽는다. if (st.sourceFbxPath.EndsWith(".anim", StringComparison.OrdinalIgnoreCase)) return EnsureAnimCopy(st, classId, clipName, write, backupDir, sb, ref 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]; // #839 — 프레임 범위 덮기(마법 1·2타 = 발사 구간만 등). 지정 시 이벤트 정규화는 이 범위 기준. bool rangeOverride = st.lastFrame > 0 && st.firstFrame >= 0 && st.lastFrame > st.firstFrame; float useFirst = rangeOverride ? st.firstFrame : src.firstFrame; float useLast = rangeOverride ? st.lastFrame : src.lastFrame; float clipLength = (useLast - useFirst) / 30f; // 실측: 전 클립 sampleRate 30 var evs = st.events.Select(e => new UnityEngine.AnimationEvent { functionName = e.functionName, // #828 — 임포터(clipAnimations) 이벤트의 time 은 **정규화(0~1)** 다. 초(= 정규화 × 길이)로 쓰면 길이가 1 s 가 // 아닌 클립에서 실효 시각이 길이 배만큼 어긋난다(09-07 실측: 매핑 0.25 → 메타 0.2667 → 런타임 0.279). // 정규화 지정은 그대로, 초 지정은 길이로 나눠 정규화한다. time = e.useNormalized ? UnityEngine.Mathf.Clamp01(e.normalizedTime) : (clipLength > 0f ? UnityEngine.Mathf.Clamp01(e.time / clipLength) : e.time), 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 = useFirst, lastFrame = useLast, 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, }; ApplyMask(copy, st, sb); // #948 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; if (rangeOverride) { c.firstFrame = useFirst; c.lastFrame = useLast; } ApplyMask(c, st, sb); // #948 list[idx] = c; sb.AppendLine(" ~ 서브클립 이벤트 갱신 " + clipName + (rangeOverride ? " (범위 " + useFirst + "~" + useLast + "f)" : "")); } 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; } // ── .anim 사본 (#838 FR 활) ───────────────────────────────────────────── // FBX 임포터 경로와 달리 .anim 에셋은 AnimationUtility.SetAnimationEvents 가 영속된다(사본에만 쓴다 · 원본 FR 클립 무수정). // AnimationEvent.time 은 초 — 매핑의 정규화 시각은 클립 길이를 곱한다(임포터 서브클립의 정규화 규칙과 반대 · #828 참조). private static UnityEngine.AnimationClip EnsureAnimCopy( WL.Combat.StateClipSpec st, int classId, string clipName, bool write, string backupDir, StringBuilder sb, ref int errors) { var src = UnityEditor.AssetDatabase.LoadAssetAtPath(st.sourceFbxPath); if (src == null) { sb.AppendLine(" [ERR] .anim 없음: " + st.sourceFbxPath); errors++; return null; } string dir = Path.GetDirectoryName(st.sourceFbxPath).Replace('\\', '/'); string copyPath = dir + "/" + clipName + ".anim"; var copy = UnityEditor.AssetDatabase.LoadAssetAtPath(copyPath); float len = src.length; var evs = st.events.Select(e => new UnityEngine.AnimationEvent { functionName = e.functionName, time = e.useNormalized ? UnityEngine.Mathf.Clamp01(e.normalizedTime) * len : UnityEngine.Mathf.Clamp(e.time, 0f, len), stringParameter = e.ResolveString(classId), intParameter = e.intParameter, floatParameter = e.floatParameter, }).OrderBy(e => e.time).ToArray(); if (!write) { sb.AppendLine(" " + (copy == null ? "+ .anim 사본 생성 예정 " : "~ .anim 사본 이벤트 갱신 예정 ") + clipName + " (" + src.name + " · " + len.ToString("0.000") + "s · events " + evs.Length + ")"); return copy; } if (copy == null) { if (!UnityEditor.AssetDatabase.CopyAsset(st.sourceFbxPath, copyPath)) { sb.AppendLine(" [ERR] .anim 복사 실패: " + copyPath); errors++; return null; } copy = UnityEditor.AssetDatabase.LoadAssetAtPath(copyPath); if (copy == null) { sb.AppendLine(" [ERR] 복사본 로드 실패: " + copyPath); errors++; return null; } sb.AppendLine(" + .anim 사본 생성 " + clipName + " (" + src.name + " · " + len.ToString("0.000") + "s)"); } else { if (backupDir != null) BackupFile(copyPath, backupDir, sb); sb.AppendLine(" ~ .anim 사본 이벤트 갱신 " + clipName); } copy.name = clipName; UnityEditor.AnimationUtility.SetAnimationEvents(copy, evs); UnityEditor.EditorUtility.SetDirty(copy); return copy; } // ── 유틸 ──────────────────────────────────────────────────────────────── 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); } } }