401 lines
24 KiB
C#
401 lines
24 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);
|
||
|
||
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<UnityEngine.AvatarMask>(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 에 <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,
|
||
};
|
||
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<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); }
|
||
}
|
||
}
|