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