292 lines
16 KiB
C#
292 lines
16 KiB
C#
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
// 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);
|
||
|
|
|
||
|
|
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 에 <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)
|
||
|
|
{
|
||
|
|
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<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); }
|
||
|
|
}
|
||
|
|
}
|