// WL804_TipProbe.cs — #804 공격 클립의 칼끝 궤적 실측 → ShowEffect 이벤트 시각을 "실제 스윙 구간"으로 옮기기 위한 근거 (에디트 모드 · 읽기 전용 · 프리뷰 씬) // unity command run_script --file AgentScripts/WL804_TipProbe.cs --entry WL804_TipProbe.All // unity command run_script --file AgentScripts/WL804_TipProbe.cs --entry WL804_TipProbe.Run --args "[\"Assets/Res_Addr/Animations/Animation/OneHand/Knight@Attack3_S.FBX\",\"Attack3\",\"Elven_Sword_01\",2,0.29305]" // 방법: WL791_FootProbe 와 같은 프리뷰 씬 샘플링. Ai01.prefab(PCActor.tfs_weapon 소켓) 에 클래스 무기 프리팹(ClassConfig s_WeaponRHPrefab)을 // 런타임 PCActor.Make_Weapon 과 같은 방식(소켓 자식 · 로컬 0/identity/1)으로 붙이고, WeaponTrailDriver.MeasureSlot 과 같은 규칙 // (가장 긴 렌더러 바운딩의 장축 양끝 · 소켓에 가까운 쪽 = 자루)으로 칼끝/자루를 정한 뒤 30fps 프레임마다 루트 기준 위치를 기록한다. // 출력: 프레임별 칼끝 이동(cm) · 칼날 방향 변화(deg) · 누적 경로 → "스윙 구간"(이동량 ≥ 최대의 30% 연속 구간) 과 // 현재 ShowEffect 시각 + 창(swingWindowNormalized 0.35) 이 스윙을 얼마나 덮는지(경로 비율). using System.Linq; using System.Text; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; public static class WL804_TipProbe { const string ModelPath = "Assets/Res_Addr/PC/Ai01.prefab"; const float Window = 0.35f; // SlashTrailSettings.swingWindowNormalized (실측값 · 표시용) public static object All() { var sb = new StringBuilder(); sb.Append(Run("Assets/Res_Addr/Animations/Animation/OneHand/Knight@Attack1_S.FBX", "Attack1", "Elven_Sword_01", 2, 0.29821f)).AppendLine(); sb.Append(Run("Assets/Res_Addr/Animations/Animation/OneHand/Knight@Attack2_S.FBX", "Attack2", "Elven_Sword_01", 2, 0.26643f)).AppendLine(); sb.Append(Run("Assets/Res_Addr/Animations/Animation/OneHand/Knight@Attack3_S.FBX", "Attack3", "Elven_Sword_01", 2, 0.29305f)).AppendLine(); return sb.ToString(); } public static object Run(string fbxPath, string clipName, string weaponName, int socketIndex, float currentEventNorm) { var model = AssetDatabase.LoadAssetAtPath(ModelPath); var clips = AssetDatabase.LoadAllAssetsAtPath(fbxPath).OfType().Where(c => !c.name.StartsWith("__preview")).ToArray(); var clip = clips.FirstOrDefault(c => c.name == clipName) ?? clips.FirstOrDefault(c => c.name.Contains(clipName)); var weapon = AssetDatabase.LoadAssetAtPath("Assets/Res_Addr/Weapon/" + weaponName + ".prefab"); if (model == null || clip == null || weapon == null) return "load fail model=" + (model != null) + " clip=" + (clip != null) + " weapon=" + (weapon != null) + " clips=[" + string.Join(",", clips.Select(c => c.name)) + "]"; var scene = EditorSceneManager.NewPreviewScene(); var sb = new StringBuilder(); bool animMode = false; try { var go = (GameObject)PrefabUtility.InstantiatePrefab(model, scene); go.transform.position = Vector3.zero; go.transform.rotation = Quaternion.identity; var pc = go.GetComponentInChildren(true); if (pc == null || pc.tfs_weapon == null || socketIndex < 0 || socketIndex >= pc.tfs_weapon.Length) return "PCActor/tfs_weapon null or index out of range"; var socket = pc.tfs_weapon[socketIndex]; if (socket == null) return "socket null idx=" + socketIndex; var w = (GameObject)PrefabUtility.InstantiatePrefab(weapon, scene); w.transform.SetParent(socket, false); w.transform.localPosition = Vector3.zero; w.transform.localRotation = Quaternion.identity; w.transform.localScale = Vector3.one; // MeasureSlot 규칙 Renderer best = null; Mesh bestMesh = null; float bestExtent = 0f; foreach (var r in socket.GetComponentsInChildren(true)) { var mf = r.GetComponent(); var smr = r as SkinnedMeshRenderer; var mesh = smr != null ? smr.sharedMesh : (mf != null ? mf.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 "no renderer under socket " + socket.name; 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); float bladeLen = Vector3.Distance(hiltLocal, tipLocal); var anim = go.GetComponentInChildren(); if (anim == null) return "animator null"; var animGo = anim.gameObject; anim.applyRootMotion = false; float fps = clip.frameRate; int n = Mathf.RoundToInt(clip.length * fps); var T = new Vector3[n + 1]; var H = new Vector3[n + 1]; AnimationMode.StartAnimationMode(); animMode = true; for (int i = 0; i <= n; i++) { AnimationMode.BeginSampling(); AnimationMode.SampleAnimationClip(animGo, clip, i / fps); AnimationMode.EndSampling(); T[i] = animGo.transform.InverseTransformPoint(socket.TransformPoint(tipLocal)); H[i] = animGo.transform.InverseTransformPoint(socket.TransformPoint(hiltLocal)); } sb.AppendLine("== " + clipName + " (" + clip.name + ") len=" + clip.length.ToString("F3") + "s frames=" + n + " fps=" + fps + " weapon=" + weaponName + " socket=" + socket.name + " blade=" + best.name + " len=" + bladeLen.ToString("F2") + "m · currentShowEffect=" + currentEventNorm.ToString("F3") + " window=" + Window + " =="); sb.AppendLine(" f norm | tip x y z | dTip(cm) | dDir(deg) | cum(cm)"); var d = new float[n + 1]; float accum = 0f; for (int i = 1; i <= n; i++) { d[i] = Vector3.Distance(T[i], T[i - 1]); accum += d[i]; float ang = Vector3.Angle(T[i] - H[i], T[i - 1] - H[i - 1]); sb.AppendLine(string.Format(" {0,3} {1,5:F3} | {2,6:F2} {3,5:F2} {4,5:F2} | {5,7:F1} | {6,6:F1} | {7,7:F1}", i, i / (float)n, T[i].x, T[i].y, T[i].z, d[i] * 100f, ang, accum * 100f)); } float dmax = d.Max(); int imax = System.Array.IndexOf(d, dmax); float thr = dmax * 0.30f; int s = imax, e = imax; while (s - 1 >= 1 && d[s - 1] >= thr) s--; while (e + 1 <= n && d[e + 1] >= thr) e++; float swingPath = 0f; for (int i = s; i <= e; i++) swingPath += d[i]; sb.AppendLine(string.Format("SWING(dTip >= 30% of max {0:F1}cm @f{1}) = f{2}~f{3} -> norm {4:F3}~{5:F3} · path {6:F2} m / total {7:F2} m", dmax * 100f, imax, s, e, s / (float)n, e / (float)n, swingPath, accum)); float cover = Coverage(d, n, currentEventNorm, Window); sb.AppendLine(string.Format("CURRENT ShowEffect {0:F3} + window {1:F2} (f{2}~f{3}) covers path {4:F2} m ({5:P0} of total)", currentEventNorm, Window, Mathf.CeilToInt(currentEventNorm * n), Mathf.Min(n, Mathf.FloorToInt((currentEventNorm + Window) * n)), cover, accum > 0 ? cover / accum : 0f)); float rec = Mathf.Max(0f, (s - 1) / (float)n); float recCover = Coverage(d, n, rec, Window); sb.AppendLine(string.Format("RECOMMEND ShowEffect ~ {0:F3} (f{1}) -> window covers {2:F2} m ({3:P0}) · window end f{4}{5}", rec, s - 1, recCover, accum > 0 ? recCover / accum : 0f, Mathf.Min(n, Mathf.FloorToInt((rec + Window) * n)), (rec + Window) * n < e ? " !! window shorter than swing end f" + e : "")); } finally { if (animMode) AnimationMode.StopAnimationMode(); EditorSceneManager.ClosePreviewScene(scene); } return sb.ToString(); } static float Coverage(float[] d, int n, float startNorm, float window) { int a = Mathf.Max(1, Mathf.CeilToInt(startNorm * n)), z = Mathf.Min(n, Mathf.FloorToInt((startNorm + window) * n)); float c = 0f; for (int i = a; i <= z; i++) c += d[i]; return c; } static Vector3 Abs(Vector3 v) { return new Vector3(Mathf.Abs(v.x), Mathf.Abs(v.y), Mathf.Abs(v.z)); } }