Project_WL/AgentScripts/WL_CombatAnimSetup.cs

292 lines
16 KiB
C#
Raw Permalink Normal View History

WL 리뉴얼 세션 2026-09-06~07 (#760~#800) — 전투·배경·UI·타격감 (조직 PD 로그 #746~#800 · 대화로그 §14~§24) - 전투: Knight@Attack1~3_S 3콤보(4 컨트롤러·클래스별 서브클립) · 타겟팅 4규칙(정면 우선·재타겟·즉시 공격·어그로 금지 · WLTargetingSettings) · 대쉬 후 공격(Stander@Chase_Start · DashDriver · 5 m · 사정거리+0.8 · 근거리 적 우선) · 공격 이동 FrameTable(발 접지 실측 표 · AttackRootMotion) · 충돌 반경/투사체 0.3 배(WLCollisionTuning) · 무적(임시)·펫 금지(WLGameplaySettings) - 검기: NamuFX Slash_B 배리언트 Effect_WLSwingArc 원 피팅 정합 배치 + 캘리브레이션(SlashArcMeasure · SlashTrailSettings) · 찌르기 Effect_WLStab 대기 · 램프 리본(BladeTrail · WL_BladeRibbon.shader · T_WL_BladeRibbonRamp) 보존(drawRibbon 0) - 타격감: Assets/WL/Feel(WLHitFeel · 히트스톱 0.03 · 셰이크 0.10 m · 몹 펀치 1.12 · Actor.Get_Damage 훅 1줄 · 원본 RealCamera 셰이크 결함 대체) - 배경/맵: LMHPOLY Demo_01~10 → WL_Nature01~10(프리팹·씬·NavMesh·스포너·BattleMapConfig) · 물(ToonWaterU) · 포스트 블룸 0.9/0.3 · 잔디(BruteForce·드레싱) 제거 · 마젠타 머티리얼 URP 변환 - UI: 세로 HUD(WL_HUD · 하단 5메뉴 폭 전체 · 채팅/전투 패드 숨김 · WLIngameUiOverride) · Title/TitleInfo 1080×1920 Expand + 배경 높이 fit(WLBackgroundFit) · 로딩 SortOrder_5(WLRawImageAspectSync · 초점표 8장) · Loading1~8 ASTC 6×6 - 도구: AgentScripts/*(LightProbe · WL_MapSwitch · WL760~WL800 프로브/집행/검증 · 상단 사용법 주석) · 에디터 락 프로토콜 파일(staging) - 제외(별도 커밋 예정): Assets/LMHPOLY(703 MB) · Assets/Feel(422 MB) · Assets/Shinabro(300 MB) — 에셋 스토어/구 WL 팩 원본 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:24:25 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// 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); }
}
}