// WL833_FrameTables.cs — 무기 계열별 공격 전진 「프레임 표」를 팩 원본(루트모션) 클립에서 굽는다 (PD 2026-10-10 「제자리 고정 공격 패턴 → 모션에 맞는 전진」) // unity command run_script --file AgentScripts/WL833_FrameTables.cs --entry WL833_FrameTables.Run --args "[false, 0.6]" (dry · 표만 출력) // unity command run_script --file AgentScripts/WL833_FrameTables.cs --entry WL833_FrameTables.Run --args "[true, 0.6]" (WLCombatMotionSettings.asset 에 기록) // // 구조(실측): 전진은 AttackRootMotion.FrameTable 모드 — 재생 중 클립 이름에 clipContains 가 포함된 표의 프레임별 cm 를 NavMeshAgent.Move 로 민다. // #791 표는 검(Knight) 3클립뿐이라 망치·단검(이름이 Attack{N}_S_… 라 검 표를 잘못 씀)·창·활·지팡이(표 없음 = 제자리)가 어긋났다. // 방법: 팩의 루트모션 원본 클립(Knight@Attack1.FBX 등 · _S 아님)의 RootT.z 커브를 30 fps 프레임마다 읽어 Δ × 치비 humanScale(LH_M05 0.4233) // × 100 = 프레임별 전진 cm(뒤로 가는 프레임은 0). scale 인자(기본 0.6)는 #791 검 표(발 접지 실측 15/30/28 cm)가 원본 루트모션(22/58/54 cm)의 // 약 0.5~0.7 배였던 것에 맞춘 배율 — 발 미끄러짐을 줄인다. 검 3표는 PD 승인값(09-07)이라 **값 그대로** 두고 키만 Attack{N}_S_101 로 좁힌다. // 활 2·3타(MoveAttack 7.4 m 달리기 사격)는 표를 만들지 않는다(제자리 사격). using System.Collections.Generic; using System.Linq; using System.Text; using UnityEditor; using UnityEngine; using WL.Combat; public static class WL833_FrameTables { const string SettingsPath = "Assets/WL/Settings/Resources/WL/WLCombatMotionSettings.asset"; const string PcPath = "Assets/Res_Addr/PC/LH_M05.prefab"; const string R = "Assets/ExplosiveLLC/"; const string K = R + "Knight Warrior Mecanim Animation Pack/Animations/Knight@"; const string H = R + "Hammer Warrior Mecanim Animation Pack/Animations/Hammer@"; const string N = R + "Ninja Warrior Mecanim Animation Pack/Animations/Ninja@"; const string A = R + "Archer Warrior Mecanim Animation Pack/Animations/Archer@"; const string M = R + "Mage Warrior Mecanim Animation Pack/Animations/Mage@"; const string S = "Assets/Shinabro/Platform_Animation/Animation/07_Spear/Stander@"; class Spec { public string key; public string fbx; public string note; public float mult = 1f; public bool pass; public bool stepGate; public Spec(string k, string f, string n, float m = 1f, bool p = false, bool g = false) { key = k; fbx = f; note = n; mult = m; pass = p; stepGate = g; } } // #841 스텝 창: 팩 FBX 임포터 원본 클립의 Foot* 이벤트(발 접지) 앞 0.30 s ~ 뒤 0.05 s 만 전진을 남긴다(발이 고정된 구간 = 0). 총량은 재분배하지 않는다(미끄러짐 제거가 목적). const float StepGateBefore = 0.30f, StepGateAfter = 0.05f; static List FootEventTimes(string fbx) { var l = new List(); var importer = AssetImporter.GetAtPath(fbx) as ModelImporter; if (importer == null) return l; var clips = importer.clipAnimations; if (clips == null || clips.Length == 0) clips = importer.defaultClipAnimations; if (clips == null || clips.Length == 0) return l; var c = clips[0]; if (c.events != null) foreach (var e in c.events) if (e.functionName != null && e.functionName.StartsWith("Foot")) l.Add(e.time); // 임포터 이벤트 time = 정규화 return l; } static readonly Spec[] Specs = { // #839 해머 재배열(1타 = 팩 Attack2 · 2타 = Attack1 · 3타 = Attack3) · #841 PD 「발 스텝이 나갈 때만 전진 · 발 고정 구간은 이동 금지」 → stepGate(팩 Foot 이벤트 앞 0.3 s 창 밖은 0) new Spec("Attack1_S_105", H + "Attack2.FBX", "망치 1타(팩 Attack2 · 스텝 창)", 1f, false, true), new Spec("Attack2_S_105", H + "Attack1.FBX", "망치 2타(팩 Attack1 · 스텝 창)", 1f, false, true), new Spec("Attack3_S_105", H + "Attack3.FBX", "망치 3타(팩 Attack3 회전 · 스텝 창)", 1f, false, true), new Spec("Attack1_S_102", N + "Attack1.FBX", "단검 1타"), new Spec("Attack2_S_102", N + "Attack2.FBX", "단검 2타"), new Spec("Attack3_S_102", N + "Attack3.FBX", "단검 3타(#834 PD 「마지막 타는 전진하며」 · 배율 ×1.6 = 원본 루트모션 거의 그대로)", 1.6f), // #839 PD 「단검 마지막 타(4타 DodgeAttack) 공격 시점에 한발짝 전진」 — 원본 힘민지 클립 루트 이동 실측용(0 이면 합성 표로) // #840 PD 「4타 돌진베기 경로에 적이 있으면 통과하며 공격」 — 멈춤 거리 무시(passThrough) · 배율 1.2 ≈ 116 cm(적 0.6~1 m 를 지나쳐 뒤로) new Spec("Attack4_S_102", "Assets/Res_Addr/Animations/Animation/Assassin/Stander@DualBlades_DodgeAttack_Attack4.FBX", "단검 4타(#840 통과 돌진 · 원본 루트 1.62 m × 1.2 배율)", 1.2f, true), // #839 창 = Spearman 팩(Shinabro 표 3개는 더 이상 쓰는 클립이 없어 폐기) // #840 창 재배열: 1타 = Attack2(찌르기) · 2타 = 찌르기 구간 ×3(표 없음 · 제자리 연타) · 3타 = Attack1(내려찌르기) new Spec("Attack1_S_107", R + "Spearman Warrior Mecanim Animation Pack/Animations/Spearman@Attack2.FBX", "창 1타(Spearman Attack2 찌르기)"), new Spec("Attack3_S_107", R + "Spearman Warrior Mecanim Animation Pack/Animations/Spearman@Attack1.FBX", "창 3타(Spearman Attack1 내려찌르기)"), new Spec("Bow1_S", A + "Attack1.FBX", "활 1타(제자리 사격 · 한 걸음)"), new Spec("Bow2_S", A + "Attack1.FBX", "활 2타(#834 Attack1 로 통일)"), new Spec("Bow3_S", A + "Attack1.FBX", "활 3타(#834 Attack1 로 통일)"), new Spec("Cast1_S", M + "Attack2.FBX", "지팡이 1타"), new Spec("Cast2_S", M + "Attack3.FBX", "지팡이 2타"), // #838 대검 10801 = 2Handed Warrior 팩 루트모션 클립 // #841 대검 재배열: 1타 = 팩 Attack2 · 2타 = Attack3 · 3타 = Attack1 new Spec("Attack1_S_108", R + "2 Handed Warrior Mecanim Animation Pack/Animations/2Handed@Attack2.FBX", "대검 1타(팩 Attack2)"), new Spec("Attack2_S_108", R + "2 Handed Warrior Mecanim Animation Pack/Animations/2Handed@Attack3.FBX", "대검 2타(팩 Attack3)"), new Spec("Attack3_S_108", R + "2 Handed Warrior Mecanim Animation Pack/Animations/2Handed@Attack1.FBX", "대검 3타(팩 Attack1)"), new Spec("Attack4_S_108", R + "Spearman Warrior Mecanim Animation Pack/Animations/Spearman@Attack3.FBX", "대검 4타(#840 Spearman Attack3 점프 찌르기 · 원본 1.16 m × 0.6 ≈ 69 cm)"), }; public static object Run(bool apply, float scale) { var sb = new StringBuilder(); var settings = AssetDatabase.LoadAssetAtPath(SettingsPath); if (settings == null) return "설정 에셋 없음 " + SettingsPath; var pc = AssetDatabase.LoadAssetAtPath(PcPath); var an = pc != null ? pc.GetComponentInChildren(true) : null; float hs = an != null && an.avatar != null ? an.humanScale : 0f; if (hs <= 0f) return "치비 humanScale 을 못 읽음 " + PcPath; sb.AppendLine(string.Format("humanScale={0:F4} scale={1:F2} mode={2} frameTableScale={3:F2}", hs, scale, settings.attackMoveMode, settings.frameTableScale)); var result = new List(); // ① 검 3표(#791 PD 승인값) — 키만 좁힌다 foreach (var old in settings.attackFrameTables) { if (old == null) continue; string k = old.clipContains; if (k == "Attack1_S" || k == "Attack2_S" || k == "Attack3_S") k += "_101"; if (!k.StartsWith("Attack") || !k.EndsWith("_101")) continue; // 검 3표만 보존(다른 키는 아래에서 새로) result.Add(new AttackFrameTable { clipContains = k, frames = (int[])old.frames.Clone(), cm = (float[])old.cm.Clone() }); sb.AppendLine(string.Format("[검] {0,-14} 보존 · 합 {1:F1} cm · 프레임 {2}", k, old.Total(), string.Join(",", old.frames.Select(x => x.ToString()).ToArray()))); } // ② 새 표 — 팩 원본 루트모션 foreach (var s in Specs) { var clip = AssetDatabase.LoadAllAssetsAtPath(s.fbx).OfType().FirstOrDefault(c => !c.name.StartsWith("__preview")); if (clip == null) { sb.AppendLine("[" + s.note + "] 클립 없음 " + s.fbx); continue; } AnimationCurve z = null; foreach (var b in AnimationUtility.GetCurveBindings(clip)) if (b.propertyName == "RootT.z") { z = AnimationUtility.GetEditorCurve(clip, b); break; } if (z == null) { sb.AppendLine("[" + s.note + "] RootT.z 없음 " + clip.name); continue; } float fr = clip.frameRate > 0f ? clip.frameRate : 30f; int n = Mathf.Max(1, Mathf.RoundToInt(fr * clip.length)); var frames = new List(); var cms = new List(); float prev = z.Evaluate(0f), raw = 0f, fwdRaw = 0f, gatedOut = 0f; List foot = s.stepGate ? FootEventTimes(s.fbx) : null; string gateNote = ""; if (s.stepGate) { var w = new List(); foreach (var t in foot) w.Add(Mathf.RoundToInt((t - StepGateBefore / clip.length) * n) + "~" + Mathf.RoundToInt((t + StepGateAfter / clip.length) * n)); gateNote = " · 스텝 창 " + (foot.Count == 0 ? "없음(Foot 이벤트 0 → 전부 0)" : string.Join(",", w.ToArray())); } for (int f = 1; f <= n; f++) { float v = z.Evaluate(Mathf.Min(f / fr, clip.length)); float d = v - prev; prev = v; raw += d; if (d <= 0f) continue; fwdRaw += d; float cm = d * hs * 100f * scale * Mathf.Max(s.mult, 0f); if (s.stepGate) { bool inWin = false; float tf = f / (float)n; foreach (var t in foot) if (tf >= t - StepGateBefore / clip.length && tf <= t + StepGateAfter / clip.length) { inWin = true; break; } if (!inWin) { gatedOut += cm; continue; } } if (cm < 0.1f) continue; frames.Add(f); cms.Add(Mathf.Round(cm * 10f) / 10f); } if (s.stepGate) gateNote += " · 제거 " + gatedOut.ToString("0.0") + " cm"; float total = 0f; foreach (var c in cms) total += c; result.Add(new AttackFrameTable { clipContains = s.key, frames = frames.ToArray(), cm = cms.ToArray(), passThrough = s.pass }); sb.AppendLine(string.Format("[{0}] {1,-14} {2} len={3:F2}s n={4} · 원본 Δz 순={5:F2} 전진={6:F2}(×hs {7:F2} m) → 표 합 {8:F1} cm · 프레임 {9}~{10} ({11}개){12}", s.note, s.key, clip.name, clip.length, n, raw, fwdRaw, fwdRaw * hs, total, frames.Count > 0 ? frames[0] : 0, frames.Count > 0 ? frames[frames.Count - 1] : 0, frames.Count, gateNote)); } if (!apply) return sb.Append("(dry)").ToString(); settings.attackFrameTables = result; EditorUtility.SetDirty(settings); AssetDatabase.SaveAssets(); sb.AppendLine("APPLIED — 표 " + result.Count + "개 기록 (" + SettingsPath + ")"); return sb.ToString(); } }