// ───────────────────────────────────────────────────────────────────────────── // 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(); } } }