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