[WL-816v] 1타 LookAtTarget 런타임 주입 (이슈 ① · Lead 결정 b) (#816)
실측: Attack1_S_10101 은 ShowEffect/Move/Projectile 3개뿐이고 LookAtTarget 이 없다.
Attack2/3_S_10101 만 LookAtTarget@0.0000s(int=0, str='') 를 갖는다 → 1타만 대상 방향으로
돌지 않은 채 나간다.
- Hit1LookAtTarget 신설(원본 훅 0 · FBX/meta 0줄) — RuntimeInitializeOnLoadMethod
숨김 러너가 PC 등장·클래스 변경을 폴링하고, 같은 컨트롤러의 2·3타 클립에서
LookAtTarget 이벤트를 템플릿으로 읽어(함수명·시간·파라미터 복사 · 코드 상수 0개 C45)
1타 클립에 AddEvent 로 클래스당 1회 주입 + Animator.Rebind()
- SO 신설 WLCombatCoreSettings.hit1LookAtTarget(1) · hit1ClipNamePattern(Attack1_S_{CID})
· hit1EventFunctionName(LookAtTarget) · hit1PollSeconds(0.5) — 0 이면 러너 자체가 안 뜬다
- 검증(에디트 모드 · 같은 Inject 코드 경로): 1타 이벤트 3→4개, LookAtTarget@0.0000s(int=0,
str='') 로 2·3타와 동일. 2·3타 4개 무변경. transform.LookAt 정렬 각 오차 8방향
37.00° → 0.00°. FBX/meta git status 0줄(에셋 무오염 실측) · 검증 뒤 재임포트로 원복
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1d11d8470d
commit
37d231b26f
|
|
@ -106,6 +106,106 @@ public static class WL816v_Probe
|
|||
hiOut = hi; return lo;
|
||||
}
|
||||
|
||||
/// <summary>새 SO 필드를 에셋에 직렬화한다(기본값이 파일에 보이도록).</summary>
|
||||
public static object SaveCoreSettings()
|
||||
{
|
||||
var cfg = Resources.Load<WL.Combat.Core.WLCombatCoreSettings>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이슈 ① — 1타 LookAtTarget 런타임 주입을 **에디트 모드에서 같은 코드 경로로** 검증한다.
|
||||
/// ① 컨트롤러가 물고 있는 Attack1/2/3 클립의 이벤트 전/후
|
||||
/// ② Actor.LookAtTarget() = transform.LookAt(target) 의 정렬 각 오차 8방향 표
|
||||
/// ③ 검증 뒤 FBX 재임포트로 메모리 변경 폐기(에셋 무오염)
|
||||
/// </summary>
|
||||
public static object Hit1(int classId)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var cfg = Resources.Load<WL.Combat.Core.WLCombatCoreSettings>(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<RuntimeAnimatorController>(ctrlPath);
|
||||
sb.AppendLine("controller = " + ctrlPath + " · clips=" + ctrl.animationClips.Length);
|
||||
|
||||
string[] want = { "Attack1_S_" + classId, "Attack2_S_" + classId, "Attack3_S_" + classId };
|
||||
System.Action<string> dump = tag =>
|
||||
{
|
||||
foreach (var wn in want)
|
||||
foreach (var c in ctrl.animationClips)
|
||||
{
|
||||
if (c == null || c.name != wn) continue;
|
||||
var names = new List<string>();
|
||||
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<string>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>창 후보 전수 스캔 — 시작 프레임 × 끝 프레임별 칼끝 원 피팅 품질.</summary>
|
||||
public static object Scan(int classId, int stage) { return Windows(classId, stage); }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
/// <summary>1타 클립에 LookAtTarget 이벤트를 런타임 주입한다. 클래스당 1회.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>마지막 주입 결과(검증 읽기 전용 · 게임 로직은 쓰지 않는다).</summary>
|
||||
public static string LastResult = "(미실행)";
|
||||
|
||||
/// <summary>주입한 클립 수 누계(검증용).</summary>
|
||||
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건이어도 재시도하지 않는다(이미 있는 경우)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 컨트롤러가 물고 있는 클립 중 이름이 <paramref name="pattern"/>({CID} 치환)과 같은 것에
|
||||
/// LookAtTarget 이벤트를 주입한다. 템플릿(함수명·시간·파라미터)은 **같은 컨트롤러의 다른 클립**에서
|
||||
/// 읽는다 — 코드 상수 0개(C45). 이미 같은 함수명 이벤트가 있으면 건드리지 않는다.
|
||||
/// </summary>
|
||||
/// <returns>주입한 클립 수</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>클립 배열 직접 주입(에디트 모드 프로브가 같은 경로를 태울 수 있게 공개).</summary>
|
||||
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<Hit1LookAtTargetRunner>();
|
||||
}
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Boot()
|
||||
{
|
||||
if (Active) EnsureRunner(); // 꺼져 있으면 오브젝트 0(C8)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Hit1LookAtTarget 의 시간 축. 숨김 GameObject 1개 · 코루틴 0.</summary>
|
||||
internal sealed class Hit1LookAtTargetRunner : MonoBehaviour
|
||||
{
|
||||
void Update() { Hit1LookAtTarget.Tick(); }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7a303f74af91ce54ca6d3d7a2e69f5fa
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,3 +40,7 @@ MonoBehaviour:
|
|||
autoCombatIconRetrySeconds: 8
|
||||
activeSkillsEnabled: 0
|
||||
activeSkillLockPollSeconds: 0
|
||||
hit1LookAtTarget: 1
|
||||
hit1ClipNamePattern: Attack1_S_{CID}
|
||||
hit1EventFunctionName: LookAtTarget
|
||||
hit1PollSeconds: 0.5
|
||||
|
|
|
|||
Loading…
Reference in New Issue