// WL816t_Bake.cs — 검기 호 ↔ 무기 정합 실측·재베이크 (PD #816t) // unity command run_script --file AgentScripts/WL816t_Bake.cs --entry WL816t_Bake.Probe --args "[10101, false]" // unity command run_script --file AgentScripts/WL816t_Bake.cs --entry WL816t_Bake.Probe --args "[10101, true]" (SO 기록) // // WL804_Bake 와 같은 수식(SlashArcMeasure)·같은 프리뷰 샘플링을 쓰되 **런타임과 같은 캐릭터**로 잰다. // · WL804_Bake : Assets/Res_Addr/PC/Ai01.prefab · root scale 1 · 무기 localScale 1 · root.InverseTransformPoint(스케일 포함) // · 런타임 : WLCharacterSwapSettings.Resolve("Ai01") = LH_M05 · root scale = f_Scale × heightCompensation // · 무기 = WLWeaponFitter.Fit(targetRatio 0.6) · ToLocalPos = 회전만(스케일 제외) // 두 좌표계가 달라서 베이크 행이 현재 캐릭터의 칼끝 원과 어긋난다 — 그 편차를 재고 새 행을 만든다. using System.Collections.Generic; using System.Linq; using System.Text; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using WL.Combat; public static class WL816t_Bake { const string SettingsPath = "Assets/WL/Settings/Resources/WL/SlashTrailSettings.asset"; const string ClassConfigJson = "Assets/ResWork/Table/Export/ClassConfig.json"; const string AnimDir = "Assets/Res_Addr/Animations/Animation/OneHand/Knight@Attack{0}_S.FBX"; const float BakedQuality = 50f; class Row { public int stage; public string clipName; public Vector3 pivot, normal, bisector, startDir; public float tipR, hiltR, sweep, path, fitQ, q; public Vector3 tipMid; // 스윙 중간 프레임의 칼끝(런타임 로컬) public string src; } static readonly int[] Classes = { 10101, 10102, 10501, 10502 }; public static object Probe(int classId, bool apply) { return Run(classId, apply, true); } public static object ProbeAll() { var sb = new StringBuilder(); foreach (var c in Classes) sb.AppendLine(Run(c, false, false)); return sb.ToString(); } public static object ApplyAll() { var sb = new StringBuilder(); foreach (var c in Classes) sb.AppendLine(Run(c, true, false)); return sb.ToString(); } /// 공격 범위 정합 스위치 on/off + 배수. 되돌리기 = Set(false, 1) public static object SetRangeFit(bool on, float rangeScale) { var s = AssetDatabase.LoadAssetAtPath(SettingsPath); if (s == null) return "설정 에셋 없음"; string before = string.Format("before: arcFitAttackRange={0} arcRangeScale={1:F2} arcSizeScale={2:F2}", s.arcFitAttackRange, s.arcRangeScale, s.arcSizeScale); s.arcFitAttackRange = on; s.arcRangeScale = rangeScale; EditorUtility.SetDirty(s); AssetDatabase.SaveAssets(); return before + string.Format(" → after: arcFitAttackRange={0} arcRangeScale={1:F2} arcSizeScale={2:F2}", s.arcFitAttackRange, s.arcRangeScale, s.arcSizeScale); } /// 공격 범위(사거리) 정적 검산 — 런타임 ResolveAttackReach 와 같은 SOT. public static object Reach(int classId) { float atk = 0f; var arr = Newtonsoft.Json.Linq.JArray.Parse(System.IO.File.ReadAllText(ClassConfigJson)); foreach (var row in arr) { if ((string)row["n_ClassID"] == classId.ToString()) { float.TryParse((string)row["f_AttackRange"], out atk); break; } } float margin = WL.Settings.WLTargetingSettings.ImmediateAttackMargin; return string.Format("class {0} · f_AttackRange={1:F2} + AttackRange_Up(평시 0) + immediateAttackMargin={2:F2} = reach {3:F2} m", classId, atk, margin, atk + margin); } static string Run(int classId, bool apply, bool table) { var sb = new StringBuilder(); var settings = AssetDatabase.LoadAssetAtPath(SettingsPath); if (settings == null) return "설정 에셋 없음"; // ── ClassConfig (C45 · 상수 금지) ──────────────────────────────────── string weaponName = null; int socketIndex = -1; float fScale = 1f, atkRange = 0f; try { var arr = Newtonsoft.Json.Linq.JArray.Parse(System.IO.File.ReadAllText(ClassConfigJson)); foreach (var row in arr) { if ((string)row["n_ClassID"] != classId.ToString()) continue; weaponName = (string)row["s_WeaponRHPrefab"]; socketIndex = int.Parse((string)row["n_WeaponRHIndex"]); if (string.IsNullOrEmpty(weaponName)) { weaponName = (string)row["s_WeaponLHPrefab"]; socketIndex = int.Parse((string)row["n_WeaponLHIndex"]); } float.TryParse((string)row["f_Scale"], out fScale); float.TryParse((string)row["f_AttackRange"], out atkRange); break; } } catch (System.Exception ex) { return "ClassConfig parse fail: " + ex.Message; } if (string.IsNullOrEmpty(weaponName) || socketIndex < 0) return "class " + classId + " weapon not found"; // ── 런타임과 같은 PC 프리팹·스케일 ────────────────────────────────── string pcName = WL.Character.WLCharacterSwapSettings.Resolve("Ai01"); float heightComp = WL.Character.WLCharacterSwapSettings.HeightCompensation; string pcPath = "Assets/Res_Addr/PC/" + pcName + ".prefab"; var model = AssetDatabase.LoadAssetAtPath(pcPath); var weaponPrefab = AssetDatabase.LoadAssetAtPath("Assets/Res_Addr/Weapon/" + weaponName + ".prefab"); if (model == null || weaponPrefab == null) return "load fail pc=" + pcPath + " weapon=" + weaponName; float rootScale = Mathf.Max(fScale, 0.01f) * Mathf.Max(heightComp, 0.01f); sb.AppendLine(string.Format("== class {0} · PC {1}(swap:{2}) rootScale={3:F4}(f_Scale {4:F2} × heightComp {5:F4}) · weapon {6}@{7} · f_AttackRange={8:F2}", classId, pcName, pcName != "Ai01", rootScale, fScale, heightComp, weaponName, socketIndex, atkRange)); var scene = EditorSceneManager.NewPreviewScene(); bool animMode = false; var rows = new List(); try { var go = (GameObject)PrefabUtility.InstantiatePrefab(model, scene); go.transform.position = Vector3.zero; go.transform.rotation = Quaternion.identity; go.transform.localScale = Vector3.one * rootScale; var pc = go.GetComponentInChildren(true); if (pc == null || pc.tfs_weapon == null || socketIndex >= pc.tfs_weapon.Length) return sb + "\ntfs_weapon missing"; var socket = pc.tfs_weapon[socketIndex]; var w = (GameObject)PrefabUtility.InstantiatePrefab(weaponPrefab, scene); w.transform.SetParent(socket, false); w.transform.localPosition = Vector3.zero; w.transform.localRotation = Quaternion.identity; w.transform.localScale = Vector3.one; // ── 814p 무기 축소(런타임과 동일 경로) ────────────────────────── var fitter = go.GetComponent(); if (fitter == null) fitter = go.AddComponent(); float wk = fitter.Fit(w.transform, socket); sb.AppendLine(string.Format(" 무기 보정 ×{0:F4} (WLWeaponFitter.Fit · targetRatio={1:F2})", wk, WL.Character.WLWeaponFitSettings.ResolveRatio(w.name))); // ── 칼자루/칼끝 로컬(WL804_Bake·MeasureSlot 과 같은 규칙) ──────── Renderer best = null; Mesh bestMesh = null; float bestExtent = 0f; foreach (var r in socket.GetComponentsInChildren(true)) { var mf2 = r.GetComponent(); var smr = r as SkinnedMeshRenderer; var mesh = smr != null ? smr.sharedMesh : (mf2 != null ? mf2.sharedMesh : null); if (mesh == null) continue; var ws = Vector3.Scale(mesh.bounds.size, Abs(r.transform.lossyScale)); float longest = Mathf.Max(ws.x, Mathf.Max(ws.y, ws.z)); if (longest > bestExtent) { bestExtent = longest; best = r; bestMesh = mesh; } } if (best == null) return sb + "\nno renderer under socket"; var b = bestMesh.bounds; var size = Vector3.Scale(b.size, Abs(best.transform.lossyScale)); int axis = size.x >= size.y && size.x >= size.z ? 0 : (size.y >= size.z ? 1 : 2); Vector3 axisDir = Vector3.zero; axisDir[axis] = 1f; Vector3 eA = best.transform.TransformPoint(b.center - axisDir * b.extents[axis]); Vector3 eB = best.transform.TransformPoint(b.center + axisDir * b.extents[axis]); bool aIsHilt = (eA - socket.position).sqrMagnitude <= (eB - socket.position).sqrMagnitude; Vector3 hiltLocal = socket.InverseTransformPoint(aIsHilt ? eA : eB); Vector3 tipLocal = socket.InverseTransformPoint(aIsHilt ? eB : eA); sb.AppendLine(string.Format(" 무기 월드 길이 {0:F3} m · 칼끝-자루 {1:F3} m", bestExtent, Vector3.Distance(socket.TransformPoint(tipLocal), socket.TransformPoint(hiltLocal)))); var anim = go.GetComponentInChildren(); var animGo = anim.gameObject; anim.applyRootMotion = false; var root = go.transform; AnimationMode.StartAnimationMode(); animMode = true; for (int stage = 1; stage <= 3; stage++) { var fbx = string.Format(AnimDir, stage); var clips = AssetDatabase.LoadAllAssetsAtPath(fbx).OfType().Where(c => !c.name.StartsWith("__preview")).ToArray(); var clip = clips.FirstOrDefault(c => c.name == "Attack" + stage + "_S_" + classId) ?? clips.FirstOrDefault(c => c.name.StartsWith("Attack" + stage)); if (clip == null) { sb.AppendLine(" [" + stage + "타] 클립 없음 " + fbx); continue; } // 클립 전체를 60 Hz 로 훑고 런타임과 같은 속도 트림으로 스윙 구간만 남긴다 int n0 = Mathf.Max(8, Mathf.RoundToInt(clip.length * 60f) + 1); var tAll = new Vector3[n0]; var hAll = new Vector3[n0]; for (int i = 0; i < n0; i++) { float t = clip.length * i / (n0 - 1); AnimationMode.BeginSampling(); AnimationMode.SampleAnimationClip(animGo, clip, t); AnimationMode.EndSampling(); // 🔴 런타임 ToLocalPos 와 동일 = 회전만(스케일 제외) tAll[i] = Quaternion.Inverse(root.rotation) * (socket.TransformPoint(tipLocal) - root.position); hAll[i] = Quaternion.Inverse(root.rotation) * (socket.TransformPoint(hiltLocal) - root.position); } int lo = 0, hi = n0 - 1; float ratio = settings.swingTrimSpeedRatio; if (ratio > 0f) { float maxStep = 0f; var step = new float[n0]; for (int i = 1; i < n0; i++) { step[i] = Vector3.Distance(tAll[i], tAll[i - 1]); if (step[i] > maxStep) maxStep = step[i]; } float th = maxStep * ratio; int f = -1, l = -1; for (int i = 1; i < n0; i++) if (step[i] >= th) { if (f < 0) f = i - 1; l = i; } if (f >= 0 && l > f) { lo = f; hi = l; } } int n = hi - lo + 1; if (n < 3) { lo = 0; hi = n0 - 1; n = n0; } var tips = new Vector3[n]; var hilts = new Vector3[n]; for (int i = 0; i < n; i++) { tips[i] = tAll[lo + i]; hilts[i] = hAll[lo + i]; } float fullPath = 0f; Vector3 pd = (tips[0] - hilts[0]).normalized; float bladeAng = 0f; for (int i = 1; i < n; i++) { fullPath += Vector3.Distance(tips[i], tips[i - 1]); var d = (tips[i] - hilts[i]).normalized; bladeAng += Vector3.Angle(pd, d); pd = d; } var fit = SlashArcMeasure.FitArcOrdered(tips, n); if (!fit.valid) { sb.AppendLine(" [" + stage + "타] 원 피팅 실패 (n=" + n + ")"); continue; } Vector3 nrm = fit.normal, pivot = fit.center; float tipR = SlashArcMeasure.MeanRadius(tips, n, pivot, nrm); float hiltR = SlashArcMeasure.MeanRadius(hilts, n, pivot, nrm); float dev = SlashArcMeasure.RadiusDeviation(tips, n, pivot, nrm, tipR); string pivotSrc = "칼끝원"; if (tipR < 1e-3f || tipR <= hiltR * 1.15f || dev > tipR * 0.25f) { Vector3 tc = SlashArcMeasure.CentroidOnPlane(tips, n, nrm, tips[0]); pivot = SlashArcMeasure.CentroidOnPlane(hilts, n, nrm, tc); tipR = SlashArcMeasure.MeanRadius(tips, n, pivot, nrm); hiltR = SlashArcMeasure.MeanRadius(hilts, n, pivot, nrm); pivotSrc = "손잡이중심"; } Vector3 sd2, bis; float sweep; if (!SlashArcMeasure.RecomputeAboutPivot(tips, n, pivot, ref nrm, out sd2, out bis, out sweep)) { sb.AppendLine(" [" + stage + "타] RecomputeAboutPivot 실패"); continue; } float fitQ = Mathf.Clamp01(1f - (dev / Mathf.Max(tipR, 1e-3f)) * 2f); rows.Add(new Row { stage = stage, clipName = "Effect_Slash_" + classId + "_" + stage, pivot = pivot, normal = nrm, bisector = bis, startDir = sd2, tipR = tipR, hiltR = hiltR, sweep = sweep, path = fullPath, fitQ = fitQ, q = fullPath * Mathf.Max(fitQ, 0.05f), tipMid = tips[n / 2], src = string.Format("wl816t {0:yyyy-MM-dd HH:mm} {1} f{2}~f{3}/{4}@60Hz n={5} PC={6} rootScale={7:F4} weaponK={8:F4} path={9:F2} bladeAng={10:F0}° sweep={11:F1}° pivot={12} tipR={13:F3} hiltR={14:F3} dev={15:F3} fitQ={16:F2} (런타임 로컬=회전만)", System.DateTime.Now, clip.name, lo, hi, n0 - 1, n, pcName, rootScale, wk, fullPath, bladeAng, sweep, pivotSrc, tipR, hiltR, dev, fitQ) }); } AnimationMode.StopAnimationMode(); animMode = false; // ── 비교 + 8방향 편차 ─────────────────────────────────────────── sb.AppendLine(""); sb.AppendLine("clip | old pivot(로컬) tipR | new pivot(로컬) tipR | Δpivot(m) ΔtipR(m) Δnormal(°)"); var cams = CamBasis(); var devTable = new StringBuilder(); foreach (var r in rows) { var old = settings.FindCalibration(classId, r.clipName); if (old == null) { sb.AppendLine(string.Format("{0,-23} | (없음) | {1} {2:F3} | -", r.clipName, V(r.pivot), r.tipR)); continue; } float dPiv = Vector3.Distance(old.pivot, r.pivot); float dTip = r.tipR - old.tipRadius; float dNrm = Vector3.Angle(old.planeNormal, r.normal); sb.AppendLine(string.Format("{0,-23} | {1} {2:F3} | {3} {4:F3} | {5:F3} {6:+0.000;-0.000} {7:F1}", r.clipName, V(old.pivot), old.tipRadius, V(r.pivot), r.tipR, dPiv, dTip, dNrm)); // 8방향: 구 행으로 배치한 호의 중심/테두리 vs 실제 칼끝 if (!table) continue; devTable.AppendLine(" " + r.clipName); devTable.AppendLine(" yaw | Δ중심(m) 화면Δ가로(m) 화면Δ세로(m) | 칼끝-호테두리 반경차(m)"); for (int k = 0; k < 8; k++) { float yaw = k * 45f; var rot = Quaternion.Euler(0f, yaw, 0f); Vector3 oldPivW = rot * old.pivot; // root at origin Vector3 newPivW = rot * r.pivot; Vector3 tipW = rot * r.tipMid; Vector3 d = oldPivW - newPivW; float radialGap = Vector3.Distance(tipW, oldPivW) - old.tipRadius * Mathf.Max(settings.arcSizeScale, 0.01f); devTable.AppendLine(string.Format(" {0,3:F0} | {1:F3} {2,8:+0.000;-0.000} {3,8:+0.000;-0.000} | {4,8:+0.000;-0.000}", yaw, d.magnitude, Vector3.Dot(d, cams[0]), Vector3.Dot(d, cams[1]), radialGap)); } } sb.AppendLine(""); sb.AppendLine("8방향 편차(카메라 기준 · PD 구도 yaw45 내려보기 45°)"); sb.Append(devTable); if (apply) { int removed = 0; foreach (var r in rows) { removed += settings.swingCalibrations.RemoveAll(c => c != null && c.classId == classId && c.clipName == r.clipName); settings.swingCalibrations.Add(new SwingCalibration { classId = classId, clipName = r.clipName, kind = SwingKind.Swing, planeNormal = r.normal, bisector = r.bisector, startDir = r.startDir, pivot = r.pivot, tipRadius = r.tipR, hiltRadius = r.hiltR, sweepDeg = r.sweep, stabAxis = Vector3.forward, stabStart = r.pivot, stabLength = Mathf.Max(r.tipR, 1e-3f), quality = Mathf.Max(BakedQuality, r.q), source = r.src }); } EditorUtility.SetDirty(settings); AssetDatabase.SaveAssets(); sb.AppendLine(string.Format("APPLIED {0}행 (기존 {1}행 교체 · 총 {2}행)", rows.Count, removed, settings.swingCalibrations.Count)); } else sb.AppendLine("(dry)"); } finally { if (animMode) AnimationMode.StopAnimationMode(); EditorSceneManager.ClosePreviewScene(scene); } return sb.ToString(); } /// PD 구도(비스듬히 내려보는 카메라)의 화면 가로·세로 축(월드). static Vector3[] CamBasis() { var rot = Quaternion.Euler(45f, 45f, 0f); return new[] { rot * Vector3.right, rot * Vector3.up }; } static string V(Vector3 v) { return string.Format("({0,6:F3},{1,6:F3},{2,6:F3})", v.x, v.y, v.z); } static Vector3 Abs(Vector3 v) { return new Vector3(Mathf.Abs(v.x), Mathf.Abs(v.y), Mathf.Abs(v.z)); } }