diff --git a/AgentScripts/WL816v_Probe.cs b/AgentScripts/WL816v_Probe.cs index 10bf0fccf..e03dd1bcd 100644 --- a/AgentScripts/WL816v_Probe.cs +++ b/AgentScripts/WL816v_Probe.cs @@ -106,6 +106,106 @@ public static class WL816v_Probe hiOut = hi; return lo; } + /// 새 SO 필드를 에셋에 직렬화한다(기본값이 파일에 보이도록). + public static object SaveCoreSettings() + { + var cfg = Resources.Load(WL.Combat.Core.WLCombatCoreSettings.ResourcesPath); + if (cfg == null) return "WLCombatCoreSettings 없음"; + EditorUtility.SetDirty(cfg); AssetDatabase.SaveAssets(); + return string.Format("saved · hit1LookAtTarget={0} pattern='{1}' fn='{2}' poll={3:F2}", + cfg.hit1LookAtTarget, cfg.hit1ClipNamePattern, cfg.hit1EventFunctionName, cfg.hit1PollSeconds); + } + + /// + /// 이슈 ① — 1타 LookAtTarget 런타임 주입을 **에디트 모드에서 같은 코드 경로로** 검증한다. + /// ① 컨트롤러가 물고 있는 Attack1/2/3 클립의 이벤트 전/후 + /// ② Actor.LookAtTarget() = transform.LookAt(target) 의 정렬 각 오차 8방향 표 + /// ③ 검증 뒤 FBX 재임포트로 메모리 변경 폐기(에셋 무오염) + /// + public static object Hit1(int classId) + { + var sb = new StringBuilder(); + var cfg = Resources.Load(WL.Combat.Core.WLCombatCoreSettings.ResourcesPath); + if (cfg == null) return "WLCombatCoreSettings 없음"; + sb.AppendLine(string.Format("SO: hit1LookAtTarget={0} pattern='{1}' fn='{2}' poll={3:F2}s", + cfg.hit1LookAtTarget, cfg.hit1ClipNamePattern, cfg.hit1EventFunctionName, cfg.hit1PollSeconds)); + + // ── 컨트롤러 = ClassConfig s_AnimationController (C45) ── + string ctrlName = null; + var arr = Newtonsoft.Json.Linq.JArray.Parse(System.IO.File.ReadAllText(ClassConfigJson)); + foreach (var row in arr) + if ((string)row["n_ClassID"] == classId.ToString()) { ctrlName = (string)row["s_AnimationController"]; break; } + if (string.IsNullOrEmpty(ctrlName)) return sb + "\ns_AnimationController 비어 있음"; + string ctrlPath = null; + foreach (var g in AssetDatabase.FindAssets(System.IO.Path.GetFileNameWithoutExtension(ctrlName) + " t:AnimatorController")) + { + var p = AssetDatabase.GUIDToAssetPath(g); + if (System.IO.Path.GetFileNameWithoutExtension(p) == System.IO.Path.GetFileNameWithoutExtension(ctrlName)) { ctrlPath = p; break; } + } + if (ctrlPath == null) return sb + "\n컨트롤러 못 찾음: " + ctrlName; + var ctrl = AssetDatabase.LoadAssetAtPath(ctrlPath); + sb.AppendLine("controller = " + ctrlPath + " · clips=" + ctrl.animationClips.Length); + + string[] want = { "Attack1_S_" + classId, "Attack2_S_" + classId, "Attack3_S_" + classId }; + System.Action dump = tag => + { + foreach (var wn in want) + foreach (var c in ctrl.animationClips) + { + if (c == null || c.name != wn) continue; + var names = new List(); + foreach (var e in c.events) names.Add(string.Format("{0}@{1:F4}s(int={2},str='{3}')", e.functionName, e.time, e.intParameter, e.stringParameter)); + sb.AppendLine(string.Format(" [{0}] {1,-20} 이벤트 {2}개 : {3}", tag, c.name, c.events.Length, string.Join(" · ", names.ToArray()))); + break; + } + }; + dump("전"); + int added = WL.Combat.Auto.Hit1LookAtTarget.Inject(ctrl.animationClips, classId, cfg.hit1ClipNamePattern); + sb.AppendLine(" Inject() → 주입 " + added + "건 · " + WL.Combat.Auto.Hit1LookAtTarget.LastResult); + dump("후"); + + // ── ② 정렬 각 오차 — Actor.LookAtTarget() 은 transform.LookAt(m_Target.Get_position()) ── + sb.AppendLine(""); + sb.AppendLine("정렬 각 오차 (Actor.cs:2406 transform.LookAt · 대상 거리 = 공격 범위 1.70 m)"); + sb.AppendLine(" 캐릭터yaw | 대상방위 | yaw오차 전 | yaw오차 후 | pitch 후(대상 높이차 0 / +0.5 m)"); + var probe = new GameObject("WL816v_yawProbe"); + try + { + float[] yaws = { 0, 45, 90, 135, 180, 225, 270, 315 }; + for (int i = 0; i < yaws.Length; i++) + { + float charYaw = yaws[i]; + float tgtYaw = (yaws[i] + 37f) % 360f; // 임의 비정렬(오차가 0 이 아닌 상태) + Vector3 tgt = Quaternion.Euler(0f, tgtYaw, 0f) * Vector3.forward * 1.70f; + probe.transform.position = Vector3.zero; + probe.transform.rotation = Quaternion.Euler(0f, charYaw, 0f); + float before = Mathf.Abs(Mathf.DeltaAngle(probe.transform.eulerAngles.y, tgtYaw)); + probe.transform.LookAt(tgt); + float after = Mathf.Abs(Mathf.DeltaAngle(probe.transform.eulerAngles.y, tgtYaw)); + float pitch0 = Mathf.DeltaAngle(0f, probe.transform.eulerAngles.x); + probe.transform.rotation = Quaternion.Euler(0f, charYaw, 0f); + probe.transform.LookAt(tgt + Vector3.up * 0.5f); + float pitchH = Mathf.DeltaAngle(0f, probe.transform.eulerAngles.x); + sb.AppendLine(string.Format(" {0,8:F0}° | {1,7:F0}° | {2,9:F2}° | {3,9:F2}° | {4:F2}° / {5:F2}°", + charYaw, tgtYaw, before, after, pitch0, pitchH)); + } + } + finally { Object.DestroyImmediate(probe); } + + // ── ③ 메모리 변경 폐기 — 임포트된 서브클립을 원본에서 다시 읽는다 ── + var fbxSet = new HashSet(); + foreach (var c in ctrl.animationClips) + { + var p = AssetDatabase.GetAssetPath(c); + if (!string.IsNullOrEmpty(p) && p.EndsWith(".FBX", System.StringComparison.OrdinalIgnoreCase)) fbxSet.Add(p); + } + foreach (var p in fbxSet) AssetDatabase.ImportAsset(p, ImportAssetOptions.ForceUpdate); + sb.AppendLine(""); + sb.AppendLine("메모리 변경 폐기 — FBX 재임포트 " + fbxSet.Count + "건"); + dump("원복"); + return sb.ToString(); + } + /// 창 후보 전수 스캔 — 시작 프레임 × 끝 프레임별 칼끝 원 피팅 품질. public static object Scan(int classId, int stage) { return Windows(classId, stage); } diff --git a/Assets/WL/Combat/Auto/Hit1LookAtTarget.cs b/Assets/WL/Combat/Auto/Hit1LookAtTarget.cs new file mode 100644 index 000000000..4b6929a62 --- /dev/null +++ b/Assets/WL/Combat/Auto/Hit1LookAtTarget.cs @@ -0,0 +1,176 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Hit1LookAtTarget.cs — 1타 클립에 LookAtTarget 이벤트 런타임 주입 (WL-816v 이슈 ① · Lead 결정 (b)) +// +// ■ 관측된 결함 (실측 2026-09-15 · AnimationUtility.GetAnimationEvents) +// Attack1_S_10101 : ShowEffect(0.2071) · Move(0.2807) · Projectile(0.3117) ← LookAtTarget 없음 +// Attack2_S_10101 : **LookAtTarget(0.0000)** · ShowEffect · Move · Projectile +// Attack3_S_10101 : **LookAtTarget(0.0000)** · ShowEffect · Move · Projectile +// → 1타만 대상 방향으로 돌지 않은 채 나간다. 2·3타는 클립 시작에 정렬된다. +// +// ■ 이 파일이 하는 것 — 원본 훅 0 · FBX/meta 0줄 +// Play 중에만, 같은 컨트롤러 안의 2·3타 클립에서 LookAtTarget 이벤트를 **템플릿으로 읽어** +// (함수명·시간·파라미터를 코드에 넣지 않는다 · C45) 1타 클립 인스턴스에 AddEvent 로 1회 주입하고 +// Animator.Rebind() 로 바인딩을 갱신한다. 임포트된 FBX 서브클립은 .meta 가 SOT 이고 런타임 +// AddEvent 는 메모리 인스턴스만 바꾸므로 에셋 파일은 변하지 않는다(게이트에서 git status 로 실측). +// +// ■ 값(C45) 전부 WLCombatCoreSettings. hit1LookAtTarget = 0 이면 러너 자체가 뜨지 않는다(C8 롤백). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(Actor/PCActor 가 Assembly-CSharp 에 있다). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Auto +{ + /// 1타 클립에 LookAtTarget 이벤트를 런타임 주입한다. 클래스당 1회. + public static class Hit1LookAtTarget + { + static WLCombatCoreSettings Cfg { get { return WLCombatCoreSettings.Instance; } } + + static bool Active + { + get + { + var c = Cfg; + return c != null && c.hit1LookAtTarget; + } + } + + static float s_nextPoll; + static int s_doneClassId = -1; + static bool s_faulted; + + /// 마지막 주입 결과(검증 읽기 전용 · 게임 로직은 쓰지 않는다). + public static string LastResult = "(미실행)"; + + /// 주입한 클립 수 누계(검증용). + public static int InjectedCount; + + internal static void Tick() + { + if (s_faulted || !Active) return; + var cfg = Cfg; + float now = Time.unscaledTime; + if (now < s_nextPoll) return; + s_nextPoll = now + Mathf.Max(0.02f, cfg.hit1PollSeconds); + + try { Poll(cfg); } + catch (System.Exception ex) { s_faulted = true; LastResult = "중단(예외) — " + ex.Message; } + } + + static void Poll(WLCombatCoreSettings cfg) + { + var pc = MyValue.MyPC; + if (DSUtil.CheckNull(pc)) { s_doneClassId = -1; return; } // PC 없음(타이틀·로딩) + + int classId = pc.Get_ID(); + if (classId == s_doneClassId) return; // 이 클래스는 이미 처리 + + var animator = pc.m_animation; + if (animator == null || animator.runtimeAnimatorController == null) return; + + int n = Inject(animator, classId, cfg.hit1ClipNamePattern); + if (n > 0) animator.Rebind(); // 새 이벤트를 바인딩에 반영 + s_doneClassId = classId; // 0건이어도 재시도하지 않는다(이미 있는 경우) + } + + /// + /// 컨트롤러가 물고 있는 클립 중 이름이 ({CID} 치환)과 같은 것에 + /// LookAtTarget 이벤트를 주입한다. 템플릿(함수명·시간·파라미터)은 **같은 컨트롤러의 다른 클립**에서 + /// 읽는다 — 코드 상수 0개(C45). 이미 같은 함수명 이벤트가 있으면 건드리지 않는다. + /// + /// 주입한 클립 수 + public static int Inject(Animator animator, int classId, string pattern) + { + if (animator == null || animator.runtimeAnimatorController == null) return 0; + return Inject(animator.runtimeAnimatorController.animationClips, classId, pattern); + } + + /// 클립 배열 직접 주입(에디트 모드 프로브가 같은 경로를 태울 수 있게 공개). + public static int Inject(AnimationClip[] clips, int classId, string pattern) + { + if (clips == null || clips.Length == 0 || string.IsNullOrEmpty(pattern)) + { LastResult = "클립 없음 또는 패턴 비어 있음"; return 0; } + + string wanted = pattern.Replace("{CID}", classId.ToString()); + + // ── ① 템플릿 — 같은 컨트롤러의 다른 클립에 이미 있는 LookAtTarget 이벤트 ── + AnimationEvent template = null; + string templateFrom = null; + var fn = Cfg != null ? Cfg.hit1EventFunctionName : null; + for (int i = 0; i < clips.Length && template == null; i++) + { + var c = clips[i]; + if (c == null || c.name == wanted) continue; + var evs = c.events; + if (evs == null) continue; + for (int e = 0; e < evs.Length; e++) + { + if (string.IsNullOrEmpty(fn) || evs[e].functionName != fn) continue; + template = evs[e]; templateFrom = c.name; break; + } + } + if (template == null) + { LastResult = string.Format("템플릿 없음 — '{0}' 이벤트를 가진 클립이 컨트롤러에 없다(주입 안 함)", fn); return 0; } + + // ── ② 대상 클립에 주입 ── + int added = 0, already = 0, found = 0; + for (int i = 0; i < clips.Length; i++) + { + var c = clips[i]; + if (c == null || c.name != wanted) continue; + found++; + + bool has = false; + var evs = c.events; + if (evs != null) + for (int e = 0; e < evs.Length && !has; e++) has = evs[e].functionName == template.functionName; + if (has) { already++; continue; } + + c.AddEvent(new AnimationEvent + { + functionName = template.functionName, + time = Mathf.Clamp(template.time, 0f, Mathf.Max(c.length - 0.0001f, 0f)), + intParameter = template.intParameter, + stringParameter = template.stringParameter, + floatParameter = template.floatParameter, + messageOptions = template.messageOptions + }); + added++; + } + + InjectedCount += added; + LastResult = string.Format("class {0} · 대상 '{1}' {2}건(주입 {3} · 기존보유 {4}) · 템플릿 '{5}' from {6} t={7:F4}s int={8} str='{9}'", + classId, wanted, found, added, already, template.functionName, templateFrom, template.time, + template.intParameter, template.stringParameter); + return added; + } + + // ───────────────────────────────────────────────────────────────────── + // 런너 (숨김 GameObject 1개 · 플레이 모드 전용) + // ───────────────────────────────────────────────────────────────────── + + static Hit1LookAtTargetRunner s_runner; + + internal static void EnsureRunner() + { + if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return; + var go = new GameObject("[WL816v] Hit1LookAtTargetRunner"); + go.hideFlags = HideFlags.HideAndDontSave; + s_runner = go.AddComponent(); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Boot() + { + if (Active) EnsureRunner(); // 꺼져 있으면 오브젝트 0(C8) + } + } + + /// Hit1LookAtTarget 의 시간 축. 숨김 GameObject 1개 · 코루틴 0. + internal sealed class Hit1LookAtTargetRunner : MonoBehaviour + { + void Update() { Hit1LookAtTarget.Tick(); } + } +} diff --git a/Assets/WL/Combat/Auto/Hit1LookAtTarget.cs.meta b/Assets/WL/Combat/Auto/Hit1LookAtTarget.cs.meta new file mode 100644 index 000000000..139ac42f7 --- /dev/null +++ b/Assets/WL/Combat/Auto/Hit1LookAtTarget.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7a303f74af91ce54ca6d3d7a2e69f5fa \ No newline at end of file diff --git a/Assets/WL/Combat/Core/WLCombatCoreSettings.cs b/Assets/WL/Combat/Core/WLCombatCoreSettings.cs index bcfebba96..71b15e7da 100644 --- a/Assets/WL/Combat/Core/WLCombatCoreSettings.cs +++ b/Assets/WL/Combat/Core/WLCombatCoreSettings.cs @@ -157,5 +157,21 @@ namespace WL.Combat.Core [Tooltip("위가 false 일 때 스킬 배열을 비우는 주기(초). 0 이하면 매 프레임.")] public float activeSkillLockPollSeconds = 0f; + + [Header("1타 조준 정렬 (Hit1LookAtTarget · WL-816v 이슈 ①)")] + [Tooltip("1타 클립에만 없는 LookAtTarget 이벤트를 Play 중에 런타임 주입한다(FBX/meta 0줄). " + + "근거 = 실측 2026-09-15 — Attack1_S_10101 은 ShowEffect·Move·Projectile 뿐이고 " + + "Attack2/3_S_10101 만 LookAtTarget(t=0) 을 갖는다. 끄면 러너 자체가 뜨지 않는다(C8 롤백).")] + public bool hit1LookAtTarget = true; + + [Tooltip("주입 대상 클립 이름. {CID} 는 클래스 ID 로 치환된다(CombatAnimMapping 과 같은 규약).")] + public string hit1ClipNamePattern = "Attack1_S_{CID}"; + + [Tooltip("주입할 이벤트의 함수명. 같은 컨트롤러의 다른 클립에서 이 이름의 이벤트를 찾아 " + + "시간·파라미터까지 **그대로 복사**한다(코드 상수 0개 · C45). 찾지 못하면 주입하지 않는다.")] + public string hit1EventFunctionName = "LookAtTarget"; + + [Tooltip("PC 등장·클래스 변경 감시 폴링 주기(초). 클래스당 1회만 주입한다.")] + public float hit1PollSeconds = 0.5f; } } diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset index f73ebd229..f4717168c 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset @@ -40,3 +40,7 @@ MonoBehaviour: autoCombatIconRetrySeconds: 8 activeSkillsEnabled: 0 activeSkillLockPollSeconds: 0 + hit1LookAtTarget: 1 + hit1ClipNamePattern: Attack1_S_{CID} + hit1EventFunctionName: LookAtTarget + hit1PollSeconds: 0.5