Project_WL/AgentScripts/WL_CombatAnimSetup.cs

380 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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<WL.Combat.CombatAnimMapping>(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<UnityEditor.Animations.AnimatorController>(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타 등)
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 에 <clipName> 이 없으면 원본 클립 구간을 복제해 추가하고,
// 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,
};
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; }
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<UnityEngine.AnimationClip>(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<UnityEngine.AnimationClip>(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<UnityEngine.AnimationClip>(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<int, string> LoadControllerPathsFromClassConfig(StringBuilder sb)
{
// SOT = 테이블(JSON). 코드에 경로를 박지 않는다(C45).
var map = new Dictionary<int, string>();
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); }
}
}