Project_WL/AgentScripts/WL770_Probe.cs

528 lines
31 KiB
C#
Raw Permalink Normal View History

WL 리뉴얼 세션 2026-09-06~07 (#760~#800) — 전투·배경·UI·타격감 (조직 PD 로그 #746~#800 · 대화로그 §14~§24) - 전투: Knight@Attack1~3_S 3콤보(4 컨트롤러·클래스별 서브클립) · 타겟팅 4규칙(정면 우선·재타겟·즉시 공격·어그로 금지 · WLTargetingSettings) · 대쉬 후 공격(Stander@Chase_Start · DashDriver · 5 m · 사정거리+0.8 · 근거리 적 우선) · 공격 이동 FrameTable(발 접지 실측 표 · AttackRootMotion) · 충돌 반경/투사체 0.3 배(WLCollisionTuning) · 무적(임시)·펫 금지(WLGameplaySettings) - 검기: NamuFX Slash_B 배리언트 Effect_WLSwingArc 원 피팅 정합 배치 + 캘리브레이션(SlashArcMeasure · SlashTrailSettings) · 찌르기 Effect_WLStab 대기 · 램프 리본(BladeTrail · WL_BladeRibbon.shader · T_WL_BladeRibbonRamp) 보존(drawRibbon 0) - 타격감: Assets/WL/Feel(WLHitFeel · 히트스톱 0.03 · 셰이크 0.10 m · 몹 펀치 1.12 · Actor.Get_Damage 훅 1줄 · 원본 RealCamera 셰이크 결함 대체) - 배경/맵: LMHPOLY Demo_01~10 → WL_Nature01~10(프리팹·씬·NavMesh·스포너·BattleMapConfig) · 물(ToonWaterU) · 포스트 블룸 0.9/0.3 · 잔디(BruteForce·드레싱) 제거 · 마젠타 머티리얼 URP 변환 - UI: 세로 HUD(WL_HUD · 하단 5메뉴 폭 전체 · 채팅/전투 패드 숨김 · WLIngameUiOverride) · Title/TitleInfo 1080×1920 Expand + 배경 높이 fit(WLBackgroundFit) · 로딩 SortOrder_5(WLRawImageAspectSync · 초점표 8장) · Loading1~8 ASTC 6×6 - 도구: AgentScripts/*(LightProbe · WL_MapSwitch · WL760~WL800 프로브/집행/검증 · 상단 사용법 주석) · 에디터 락 프로토콜 파일(staging) - 제외(별도 커밋 예정): Assets/LMHPOLY(703 MB) · Assets/Feel(422 MB) · Assets/Shinabro(300 MB) — 에셋 스토어/구 WL 팩 원본 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:24:25 +00:00
// WL770_Probe.cs — PD 지시 #770 (플레이어 타겟팅 규칙 4건) 실측/검증 프로브
// Play 전용 · 읽기 전용(설정·서버 쓰기 없음) · 결과는 Screenshots_WL/targeting/ 아래 txt
//
// unity command run_script --file AgentScripts/WL770_Probe.cs --entry WL770_Probe.Env
// unity command run_script --file AgentScripts/WL770_Probe.cs --entry WL770_Probe.Setup --args '[2.5, 0]'
// unity command run_script --file AgentScripts/WL770_Probe.cs --entry WL770_Probe.Watch --args '[12.0, "base"]'
// unity command run_script --file AgentScripts/WL770_Probe.cs --entry WL770_Probe.Report --args '["base"]'
// unity command run_script --file AgentScripts/WL770_Probe.cs --entry WL770_Probe.Aggro --args '["base"]'
//
// 설계 메모 — 프로브는 규칙 값을 하드코딩하지 않는다(C45). WL.Settings.WLTargetingSettings 가
// 컴파일돼 있으면 리플렉션으로 읽고, 없으면(변경 전 베이스라인 런) 각도/거리만 기록하고 점수는 비운다.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using UnityEngine;
using UnityEngine.AI;
public static class WL770_Probe
{
const string OutDir = "Screenshots_WL/targeting";
const BindingFlags BF = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
const BindingFlags SBF = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static;
// ── 설정 읽기 (없으면 null → 베이스라인 런) ──────────────────────────────
static Type SettingsType { get { return AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("WL.Settings.WLTargetingSettings")).FirstOrDefault(t => t != null); } }
static object SProp(string name)
{
var t = SettingsType; if (t == null) return null;
var p = t.GetProperty(name, SBF); if (p == null) return null;
try { return p.GetValue(null, null); } catch { return null; }
}
static float SFloat(string name, float dflt) { var o = SProp(name); return o == null ? dflt : Convert.ToSingle(o); }
static bool SBool(string name, bool dflt) { var o = SProp(name); return o == null ? dflt : Convert.ToBoolean(o); }
static bool HasSettings { get { return SettingsType != null; } }
// ── 액터 헬퍼 ────────────────────────────────────────────────────────────
static PCActor PC()
{
return UnityEngine.Object.FindObjectsByType<PCActor>(FindObjectsInactive.Exclude, FindObjectsSortMode.None)
.FirstOrDefault(p => !p.IsEnemy() && !p.IsDead());
}
static float FindRange(Actor me)
{
try
{
var fi = typeof(MyActor).GetField("f_FindEnemyRange", BF);
if (fi != null) { float v = Convert.ToSingle(fi.GetValue(me)); if (v > 0f) return v; }
}
catch { }
return 6f; // table_GlobalValue f_PC_FindEnemyRange 를 못 읽었을 때만 (보고에 표기)
}
static float Ang(Actor me, Vector3 pos)
{
var d = pos - me.Get_position(); d.y = 0f;
var f = me.transform.forward; f.y = 0f;
if (d.sqrMagnitude < 1e-4f || f.sqrMagnitude < 1e-4f) return 0f;
return Vector3.Angle(f, d);
}
/// <summary>MyActor.Update 의 정지 거리 계산과 같은 식(+ 설정 여유). 값은 전부 런타임에서 읽는다(C45).</summary>
static float Reach(Actor me, Actor t)
{
float stop = 0f;
try { var f = typeof(Actor).GetField("m_NavStopDistance", BF); if (f != null) stop = Convert.ToSingle(f.GetValue(me)); } catch { }
if (stop <= 0f) { var a = me.Get_Agent(); stop = a != null ? a.stoppingDistance : 0f; }
try { stop += (float)me.Get_StatInfo().Get_Stat(eStat.AttackRange_Up); } catch { }
var ta = t.Get_Agent();
if (ta != null && ta.radius > stop) stop = ta.radius + 0.5f;
return stop + t.Get_ScaleSizeDist() + SFloat("ImmediateAttackMargin", 0f);
}
static string TName(Actor a) { return a == null ? "(none)" : a.name; }
/// <summary>현재 시점 후보표(거리·각도·점수·사거리내). 선택 규칙 검증용.</summary>
static string CandidateTable(Actor me)
{
var sb = new StringBuilder();
if (me == null || ActorInfo.Ins == null) return " (no ActorInfo)\n";
float range = FindRange(me);
float w = SFloat("FacingWeight", 1f), cone = SFloat("FrontConeDeg", 90f) * 0.5f;
var list = ActorInfo.Ins.Get_EnemyActors(me.IsEnemy(), me.Get_position(), range);
var rows = new List<string>();
Actor bestScore = null, bestDist = null, bestInReach = null;
float bs = float.MaxValue, bd = float.MaxValue, bir = float.MaxValue;
bool anyFront = false;
foreach (var a in list) { if (a != null && Ang(me, a.Get_position()) <= cone) anyFront = true; }
foreach (var a in list)
{
if (a == null) continue;
float d = Vector3.Distance(me.Get_position(), a.Get_position());
float ang = Ang(me, a.Get_position());
float reach = Reach(me, a);
bool inCone = ang <= cone;
float sc = d * (1f + w * (1f - Mathf.Cos(ang * Mathf.Deg2Rad)));
rows.Add(string.Format(" {0,-22} d={1,5:F2} ang={2,5:F0} score={3,6:F2} reach={4,4:F2} inReach={5} inCone={6}",
a.name, d, ang, sc, reach, d <= reach ? "Y" : "n", inCone ? "Y" : "n"));
if (d < bd) { bd = d; bestDist = a; }
if ((!anyFront || inCone) && sc < bs) { bs = sc; bestScore = a; }
if (d <= reach && (!anyFront || inCone) && sc < bir) { bir = sc; bestInReach = a; }
}
ActorInfo.Ins.Release_Actors(list);
sb.AppendLine(" candidates=" + rows.Count + " range=" + range.ToString("F1")
+ " weight=" + w.ToString("F2") + " coneHalf=" + cone.ToString("F0")
+ " settings=" + (HasSettings ? "on" : "ABSENT(baseline)"));
foreach (var r in rows) sb.AppendLine(r);
sb.AppendLine(" bestByScore=" + TName(bestScore) + " bestByDist=" + TName(bestDist) + " bestInReach=" + TName(bestInReach));
return sb.ToString();
}
// ── Env : 설정·PC 상태 한 번 찍기 ────────────────────────────────────────
public static object Env()
{
if (!Application.isPlaying) return "not playing";
var me = PC(); if (me == null) return "no PC";
var a = me.Get_Agent();
var sb = new StringBuilder();
sb.AppendLine("scene=" + UnityEngine.SceneManagement.SceneManager.GetActiveScene().name);
sb.AppendLine("settingsType=" + (HasSettings ? "WL.Settings.WLTargetingSettings" : "(none · baseline)"));
if (HasSettings)
sb.AppendLine(" frontConeDeg=" + SFloat("FrontConeDeg", -1) + " facingWeight=" + SFloat("FacingWeight", -1)
+ " attackStartRetarget=" + SBool("AttackStartRetarget", false) + " retargetOnKill=" + SBool("RetargetOnKill", false)
+ " pcAggroOnHit=" + SBool("PcAggroOnHit", true) + " immediateAttackMargin=" + SFloat("ImmediateAttackMargin", -1)
+ " retargetSearchRange=" + SFloat("RetargetSearchRange", -1));
var gs = AppDomain.CurrentDomain.GetAssemblies().Select(x => x.GetType("WL.Settings.WLGameplaySettings")).FirstOrDefault(x => x != null);
if (gs != null)
{
var pi = gs.GetProperty("PlayerInvincible", SBF); var pp = gs.GetProperty("SpawnPlayerPet", SBF);
sb.AppendLine(" playerInvincible=" + (pi != null ? pi.GetValue(null, null) : null) + " spawnPlayerPet=" + (pp != null ? pp.GetValue(null, null) : null));
}
sb.AppendLine("PC pos=" + me.Get_position().ToString("F2") + " fwd=" + me.transform.forward.ToString("F2")
+ " curAnim=" + me.Get_CurAnim() + " target=" + TName(me.Get_Target())
+ " stoppingDistance=" + (a != null ? a.stoppingDistance.ToString("F2") : "-")
+ " agentRadius=" + (a != null ? a.radius.ToString("F2") : "-")
+ " findRange=" + FindRange(me).ToString("F1")
+ " auto=" + AutoOn(me));
sb.AppendLine("pets=" + UnityEngine.Object.FindObjectsByType<PetActor>(FindObjectsInactive.Exclude, FindObjectsSortMode.None).Length
+ " liveMobs=" + UnityEngine.Object.FindObjectsByType<MobActor>(FindObjectsInactive.Exclude, FindObjectsSortMode.None).Count(m => !m.IsDead()));
sb.Append(CandidateTable(me));
return sb.ToString();
}
static string AutoOn(Actor me)
{
try { var f = typeof(Actor).GetField("m_Auto", BF); return f != null ? f.GetValue(me).ToString() : "?"; } catch { return "?"; }
}
/// <summary>자동 전투 토글 — Actor.m_Auto 필드만 바꾼다(메모리 전용 · ServerData·서버 쓰기 없음 · Play 종료 시 원복).</summary>
public static object Auto(int on)
{
if (!Application.isPlaying) return "not playing";
var me = PC(); if (me == null) return "no PC";
var f = typeof(Actor).GetField("m_Auto", BF);
if (f == null) return "m_Auto field not found";
f.SetValue(me, on != 0);
return "m_Auto=" + f.GetValue(me) + " (memory only)";
}
// ── Setup : PC 를 몹 무리 근처로 워프하고 정면을 지정 ────────────────────
/// <summary>dist = 가장 큰 몹 무리 중심에서의 거리(m), yawOffsetDeg = 무리 방향 대비 정면 각도(0 = 정면, 180 = 등짐).</summary>
public static object Setup(float dist, float yawOffsetDeg) { return SetupFilter("", dist, yawOffsetDeg); }
/// <summary>nameContains 로 후보를 거른 뒤 그 무리 근처로 워프한다(약한 몹 무리를 골라 처치 재타겟을 관찰할 때).</summary>
public static object SetupFilter(string nameContains, float dist, float yawOffsetDeg)
{
if (!Application.isPlaying) return "not playing";
var me = PC(); if (me == null) return "no PC";
// 🔴 후보는 반드시 ActorInfo.Get_EnemyActors 가 인정하는 적만 쓴다
// (FindObjectsByType 로 잡히는 몹 중에는 isNoseeMob·Is_Immortal 로 타겟이 될 수 없는 개체가 섞인다).
var all = ActorInfo.Ins.Get_EnemyActors(me.IsEnemy(), me.Get_position(), 0f);
var mobs = all.Where(a => a != null && !a.IsDead()
&& (string.IsNullOrEmpty(nameContains) || a.name.IndexOf(nameContains, StringComparison.OrdinalIgnoreCase) >= 0)).ToArray();
ActorInfo.Ins.Release_Actors(all);
if (mobs.Length == 0) return "no valid enemy candidate";
// 가장 몹이 밀집한 지점 = 반경 5 m 안 이웃이 가장 많은 적
Actor hub = mobs.OrderByDescending(m => mobs.Count(o => Vector3.Distance(o.Get_position(), m.Get_position()) < 5f)).First();
var center = hub.Get_position();
var dir = (me.Get_position() - center); dir.y = 0f;
if (dir.sqrMagnitude < 1e-4f) dir = Vector3.forward;
var pos = center + dir.normalized * Mathf.Max(0.5f, dist);
var na = me.Get_Agent();
if (na != null && na.enabled) na.Warp(pos); else me.transform.position = pos;
var look = center - me.Get_position(); look.y = 0f;
me.transform.rotation = Quaternion.Euler(0f, Quaternion.LookRotation(look).eulerAngles.y + yawOffsetDeg, 0f);
int near = mobs.Count(m => Vector3.Distance(m.Get_position(), me.Get_position()) < 6f);
return "warped hub=" + hub.name + " pos=" + me.Get_position().ToString("F2") + " yaw+" + yawOffsetDeg
+ " mobsWithin6m=" + near + "\n" + CandidateTable(me);
}
// ── Aggro : 규칙 4 결정적 검증 (등 뒤 몹이 때렸을 때 타겟 불변?) ─────────
public static object Aggro(string tag)
{
if (!Application.isPlaying) return "not playing";
var me = PC(); if (me == null) return "no PC";
var sb = new StringBuilder();
var mobs = UnityEngine.Object.FindObjectsByType<MobActor>(FindObjectsInactive.Exclude, FindObjectsSortMode.None).Where(m => !m.IsDead()).ToList();
if (mobs.Count == 0) return "no live mob";
// A1 : 현재 타겟보다 "더 가까운" 다른 몹이 때린다 → 기존 코드라면 그쪽으로 타겟이 바뀐다.
var cur = me.Get_Target();
float curD = cur != null ? Vector3.Distance(me.Get_position(), cur.Get_position()) : float.MaxValue;
var beater = mobs.Where(m => (Actor)m != cur)
.OrderBy(m => Vector3.Distance(me.Get_position(), m.transform.position)).FirstOrDefault();
if (beater == null) return "no other mob";
float bD = Vector3.Distance(me.Get_position(), beater.transform.position);
sb.AppendLine("[A1] before target=" + TName(cur) + " d=" + (cur != null ? curD.ToString("F2") : "-")
+ " curAnim=" + me.Get_CurAnim()
+ " | beater=" + beater.name + " d=" + bD.ToString("F2") + " ang=" + Ang(me, beater.transform.position).ToString("F0")
+ " (beaterCloser=" + (bD < curD ? "Y" : "N") + ")");
var di = new DamageInfo(); di.Beater = beater; di.Damage = 0; di.BaseDmg = 0;
try { me.Get_Damage(di); } catch (Exception e) { sb.AppendLine(" Get_Damage ex " + e.GetType().Name + " " + e.Message); }
var after = me.Get_Target();
sb.AppendLine("[A1] after target=" + TName(after)
+ " => " + (after == (Actor)beater && cur != (Actor)beater ? "CHANGED to beater (어그로 발동)"
: after == cur ? "UNCHANGED (어그로 없음)" : "다른 대상으로 변경"));
// A2 : 타겟이 없는 상태에서 맞으면 타겟을 잡는가 (자동 공격 시작점 여부)
me.Del_Target(true);
sb.AppendLine("[A2] Del_Target → target=" + TName(me.Get_Target()));
var di2 = new DamageInfo(); di2.Beater = beater; di2.Damage = 0; di2.BaseDmg = 0;
try { me.Get_Damage(di2); } catch (Exception e) { sb.AppendLine(" Get_Damage ex " + e.GetType().Name + " " + e.Message); }
var after2 = me.Get_Target();
sb.AppendLine("[A2] after hit target=" + TName(after2)
+ " => " + (after2 == (Actor)beater ? "ACQUIRED beater (어그로 발동)" : after2 == null ? "여전히 없음 (어그로 없음)" : "다른 대상"));
// 상태 복구 — 테스트로 지운 타겟을 정상 규칙으로 다시 잡아 준다
me.Set_Target();
sb.AppendLine("[restore] Set_Target → " + TName(me.Get_Target()));
Save(tag + "_aggro", sb.ToString());
return sb.ToString();
}
// ── RuleCheck : 규칙 1 결정적 검증 (회전 전 기하로 기대값 산출 → 실제 선택과 비교) ──
/// <summary>
/// trials 회 반복: 캐릭터 정면(yaw)을 바꾸고, **Play_Attack 호출 직전의** 후보표로 기대 타겟을 계산한 뒤
/// 실제 Play_Attack 이 고른 타겟과 대조한다(base.Play_Attack 이 LookAt 으로 회전하기 전 기하를 쓴다).
/// </summary>
public static object RuleCheck(int trials, string tag)
{
if (!Application.isPlaying) return "not playing";
var me = PC(); if (me == null) return "no PC";
var sb = new StringBuilder();
float w = SFloat("FacingWeight", 1f), cone = SFloat("FrontConeDeg", 90f) * 0.5f;
bool useCone = cone > 0f && cone < 180f;
float aspd = 1f;
try { aspd = (float)me.Get_StatInfo().Get_Stat(eStat.FinalAttackSpeed); } catch { }
sb.AppendLine("== RuleCheck trials=" + trials + " weight=" + w + " coneHalf=" + cone + " aspd=" + aspd.ToString("F2") + " ==");
int match = 0, valid = 0;
for (int t = 0; t < trials; t++)
{
float yaw = t * (360f / Mathf.Max(1, trials));
me.transform.rotation = Quaternion.Euler(0f, yaw, 0f);
// 회전 직후·공격 호출 직전의 기하로 기대값 산출
var list = ActorInfo.Ins.Get_EnemyActors(me.IsEnemy(), me.Get_position(), FindRange(me));
Actor expect = null; float best = float.MaxValue; bool anyFrontInReach = false;
foreach (var a in list)
{
if (a == null) continue;
float d = Vector3.Distance(me.Get_position(), a.Get_position());
if (d > Reach(me, a)) continue;
if (Ang(me, a.Get_position()) <= cone) anyFrontInReach = true;
}
int inReachCount = 0;
foreach (var a in list)
{
if (a == null) continue;
float d = Vector3.Distance(me.Get_position(), a.Get_position());
if (d > Reach(me, a)) continue;
inReachCount++;
float ang = Ang(me, a.Get_position());
if (useCone && anyFrontInReach && ang > cone) continue;
float sc = d * (1f + w * (1f - Mathf.Cos(ang * Mathf.Deg2Rad)));
if (sc < best) { best = sc; expect = a; }
}
ActorInfo.Ins.Release_Actors(list);
var before = me.Get_Target();
me.Set_CurAnim(eAnim.Idle); // 콤보 시작으로 인정되게 (base 가 CurAnim==Attack 이면 무시한다)
me.Play_Attack(0, aspd);
var after = me.Get_Target();
string verdict;
if (expect == null) { verdict = "사거리 내 후보 없음 → 기존 타겟 유지 " + (after == before ? "OK" : "CHANGED(!)"); }
else { valid++; if (after == expect) { match++; verdict = "MATCH"; } else verdict = "MISMATCH"; }
sb.AppendLine(string.Format(" #{0,2} yaw={1,3:F0} inReach={2,2} expect={3,-22} actual={4,-22} {5}",
t + 1, yaw, inReachCount, TName(expect), TName(after), verdict));
}
sb.AppendLine(" => 사거리 내 후보가 있던 시행 " + valid + "회 중 규칙 일치 " + match + "회");
Save(tag + "_rulecheck", sb.ToString());
return sb.ToString();
}
/// <summary>
/// 검증 하네스 — PC 의 현재 타겟 HP 를 1 로 낮춘다. 죽는 순간은 **플레이어의 실제 타격**이 만든다
/// (합성 DamageInfo 로 직접 죽이면 Beater_pd 가 필요해 실제 피해 경로를 우회하게 된다).
/// </summary>
public static object WeakenTarget()
{
if (!Application.isPlaying) return "not playing";
var me = PC(); if (me == null) return "no PC";
var t = me.Get_Target();
if (t == null || t.IsDead()) return "no live target";
double hp0 = t.Get_HP();
if (hp0 > 1d) t.Get_StatInfo().Set_Stat(false, eStat.HP, hp0 - 1d); // false = 감산(실측)
return "weakened " + t.name + " hp " + hp0.ToString("F0") + "->" + t.Get_HP().ToString("F0") + " anim=" + me.Get_CurAnim();
}
// ── Watch : 프레임 로그 (규칙 1·2·3 실측) ────────────────────────────────
class Host : MonoBehaviour
{
public string logTag; public float seconds;
public IEnumerator Co()
{
System.IO.Directory.CreateDirectory(OutDir);
var sb = new StringBuilder();
var me = PC();
if (me == null) { Done(sb.AppendLine("no PC")); yield break; }
var anim = me.GetComponentInChildren<Animator>();
sb.AppendLine("== WL770 Watch tag=" + logTag + " sec=" + seconds + " settings=" + (HasSettings ? "on" : "ABSENT(baseline)") + " ==");
sb.AppendLine(Env().ToString());
sb.AppendLine("== frame events ==");
var mobs = UnityEngine.Object.FindObjectsByType<MobActor>(FindObjectsInactive.Include, FindObjectsSortMode.None).ToList();
var lastHp = new Dictionary<MobActor, double>();
foreach (var m in mobs) { if (m != null) lastHp[m] = m.Get_HP(); }
Actor lastTarget = me.Get_Target();
var lastAnim = me.Get_CurAnim();
bool lastTargetDead = lastTarget != null && lastTarget.IsDead();
float t0 = Time.time;
int frame = 0;
// 스위치 추적 상태
int switchFrame = -1; Vector3 switchPos = Vector3.zero; bool ranSinceSwitch = false;
string switchKind = ""; int deadFrame = -1; string deadName = ""; eAnim deadAnim = eAnim.Idle; Actor justKilled = null;
var switchRows = new List<string>(); // (b)(c) 표
var startRows = new List<string>(); // (a) 표
int attackStarts = 0, hitCount = 0;
while (Time.time - t0 < seconds)
{
frame++;
if (me == null) { sb.AppendLine(" PC destroyed at f" + frame); break; }
float el = Time.time - t0;
var curAnim = me.Get_CurAnim();
var tgt = me.Get_Target();
var agent = me.Get_Agent();
// 타겟 사망 감지
if (lastTarget != null && !lastTargetDead && lastTarget.IsDead())
{
deadFrame = frame; deadName = lastTarget.name; deadAnim = curAnim; justKilled = lastTarget;
sb.AppendLine(string.Format(" f{0,4} {1,6:F2}s TARGET_DEAD {2} anim={3}", frame, el, deadName, curAnim));
lastTargetDead = true;
}
// 타겟 교체 감지
if (tgt != lastTarget)
{
float d = tgt != null ? Vector3.Distance(me.Get_position(), tgt.Get_position()) : -1f;
float ag = tgt != null ? Ang(me, tgt.Get_position()) : -1f;
float rc = tgt != null ? Reach(me, tgt) : -1f;
sb.AppendLine(string.Format(" f{0,4} {1,6:F2}s TARGET_SET {2} -> {3} d={4:F2} ang={5:F0} reach={6:F2} anim={7}{8}",
frame, el, TName(lastTarget), TName(tgt), d, ag, rc, curAnim,
deadFrame > 0 ? " (afterKill +" + (frame - deadFrame) + "f)" : ""));
if (tgt != null)
{
switchFrame = frame; switchPos = me.Get_position(); ranSinceSwitch = false;
switchKind = deadFrame > 0 ? "kill(+" + (frame - deadFrame) + "f, comboAnim=" + deadAnim + "→" + curAnim + ")" : "acquire";
switchRows.Add(string.Format(" switch#{0} {1,6:F2}s {2} -> {3} d={4:F2} ang={5:F0} reach={6:F2} inReach={7} kind={8} anim={9}",
switchRows.Count + 1, el, TName(lastTarget), TName(tgt), d, ag, rc, d <= rc ? "Y" : "n", switchKind, curAnim));
}
deadFrame = -1;
lastTarget = tgt; lastTargetDead = tgt != null && tgt.IsDead();
}
if (curAnim == eAnim.Run) ranSinceSwitch = true;
// 공격 시작 감지 (Idle/Run/Skill 등 → Attack · 콤보 1타 시작)
if (lastAnim != eAnim.Attack && curAnim == eAnim.Attack)
{
attackStarts++;
float d = tgt != null ? Vector3.Distance(me.Get_position(), tgt.Get_position()) : -1f;
float ag = tgt != null ? Ang(me, tgt.Get_position()) : -1f;
sb.AppendLine(string.Format(" f{0,4} {1,6:F2}s ATTACK_START target={2} d={3:F2} ang={4:F0}", frame, el, TName(tgt), d, ag));
var tab = CandidateTable(me);
sb.Append(tab);
startRows.Add(string.Format(" start#{0} {1,6:F2}s selected={2} d={3:F2} ang={4:F0}", attackStarts, el, TName(tgt), d, ag)
+ "\n" + tab.TrimEnd());
}
if (curAnim != lastAnim)
{
sb.AppendLine(string.Format(" f{0,4} {1,6:F2}s ANIM {2} -> {3} vel={4:F2} stop={5:F2}", frame, el, lastAnim, curAnim,
agent != null ? agent.velocity.magnitude : 0f, agent != null ? agent.stoppingDistance : 0f));
lastAnim = curAnim;
}
// 타격 감지 (몹 HP 감소)
foreach (var m in mobs)
{
if (m == null) continue;
double hp; if (!lastHp.TryGetValue(m, out hp)) { lastHp[m] = m.Get_HP(); continue; }
double now = m.Get_HP();
if (now < hp - 0.01)
{
hitCount++;
float moved = switchFrame > 0 ? Vector3.Distance(switchPos, me.Get_position()) : -1f;
sb.AppendLine(string.Format(" f{0,4} {1,6:F2}s HIT {2} {3:F0}->{4:F0} d={5:F2}{6}", frame, el, m.name, hp, now,
Vector3.Distance(me.Get_position(), m.transform.position),
switchFrame > 0 ? " [firstHitAfterSwitch +" + (frame - switchFrame) + "f moved=" + moved.ToString("F2") + "m ran=" + (ranSinceSwitch ? "Y" : "N") + "]" : ""));
// 교체 직전에 죽은 대상에게 들어간 '마지막 일격'은 새 타겟 공격의 증거가 아니다 → 제외
if (switchFrame > 0 && (Actor)m != justKilled)
{
switchRows.Add(string.Format(" -> firstHit(신규대상) +{0}f moved={1:F2}m ran={2} victim={3}",
frame - switchFrame, moved, ranSinceSwitch ? "Y" : "N", m.name));
switchFrame = -1;
}
}
lastHp[m] = now;
}
yield return null;
}
sb.AppendLine("== (a) 공격 시작 시점 선택 ==");
foreach (var r in startRows) sb.AppendLine(r);
sb.AppendLine("== (b)(c) 타겟 교체 → 첫 타격 ==");
foreach (var r in switchRows) sb.AppendLine(r);
sb.AppendLine("== summary: frames=" + frame + " attackStarts=" + attackStarts + " hits=" + hitCount
+ " switches=" + switchRows.Count(x => x.StartsWith(" switch#")) + " ==");
Done(sb);
}
void Done(StringBuilder sb) { Save(logTag, sb.ToString()); Destroy(gameObject); }
}
static void Save(string tag, string text)
{
System.IO.Directory.CreateDirectory(OutDir);
System.IO.File.WriteAllText(System.IO.Path.Combine(OutDir, "wl770_" + tag + ".txt"), text);
}
public static object Watch(float seconds, string tag)
{
if (!Application.isPlaying) return "not playing";
if (GameObject.Find("__wl770watch") != null) return "already running";
var host = new GameObject("__wl770watch").AddComponent<Host>();
host.seconds = seconds;
host.logTag = string.IsNullOrEmpty(tag) ? "run" : tag;
host.StartCoroutine(host.Co());
return "started " + seconds + "s tag=" + host.logTag;
}
/// <summary>회귀 확인 — 펫·몬스터 타겟팅이 변경 대상이 아님을 런타임에서 확인한다.</summary>
public static object Regress()
{
if (!Application.isPlaying) return "not playing";
var me = PC(); if (me == null) return "no PC";
var sb = new StringBuilder();
var pets = UnityEngine.Object.FindObjectsByType<PetActor>(FindObjectsInactive.Include, FindObjectsSortMode.None);
sb.AppendLine("pets(all)=" + pets.Length + " (PD #763 spawnPlayerPet=False → 등장 없음. 펫 경로는 코드상 UseWLTargeting()=IsRole(PC) 로 제외)");
var mobs = UnityEngine.Object.FindObjectsByType<MobActor>(FindObjectsInactive.Exclude, FindObjectsSortMode.None).Where(m => !m.IsDead()).ToList();
int withTarget = mobs.Count(m => m.Get_Target() != null);
int targetingPC = mobs.Count(m => m.Get_Target() == (Actor)me);
var near = mobs.Where(m => Vector3.Distance(m.Get_position(), me.Get_position()) < 8f).ToList();
sb.AppendLine("liveMobs=" + mobs.Count + " hasTarget=" + withTarget + " targetingPC=" + targetingPC
+ " | within8m=" + near.Count + " ofWhichTargetingPC=" + near.Count(m => m.Get_Target() == (Actor)me));
sb.AppendLine("PC hp=" + me.Get_HP().ToString("F0") + "/" + me.Get_MaxHP().ToString("F0") + " (무적 ON 이면 불변)");
sb.AppendLine("PC target=" + TName(me.Get_Target()) + " anim=" + me.Get_CurAnim() + " auto=" + AutoOn(me));
return sb.ToString();
}
class OffHost : MonoBehaviour
{
public IEnumerator Co()
{
var sb = new StringBuilder();
var me = PC();
var f = typeof(Actor).GetField("m_Auto", BF);
f.SetValue(me, false);
me.Del_Target(true);
sb.AppendLine("[autoOFF] m_Auto=false, Del_Target → target=" + TName(me.Get_Target()));
float t0 = Time.time; int hadTarget = 0, attackFrames = 0, frames = 0;
while (Time.time - t0 < 5f)
{
frames++;
if (me.Get_Target() != null) hadTarget++;
if (me.Get_CurAnim() >= eAnim.Attack) attackFrames++;
yield return null;
}
sb.AppendLine("[autoOFF] 5초 관찰: frames=" + frames + " 타겟보유프레임=" + hadTarget + " 공격/스킬프레임=" + attackFrames
+ " → " + (hadTarget == 0 ? "스스로 타겟을 잡지 않음(규칙4 귀결)" : "타겟이 생김(!)"));
// 공격 버튼 경로(BattleUI.OnClick_Attack = Set_Target + Play_Attack)가 여전히 시작점인지
var ui = UnityEngine.Object.FindObjectsByType<BattleUI>(FindObjectsInactive.Include, FindObjectsSortMode.None).FirstOrDefault();
if (ui == null) sb.AppendLine("[autoOFF] BattleUI 없음 → 버튼 경로 미확인");
else
{
ui.OnClick_Attack();
sb.AppendLine("[autoOFF] BattleUI.OnClick_Attack() → target=" + TName(me.Get_Target()) + " anim=" + me.Get_CurAnim()
+ " → " + (me.Get_Target() != null ? "버튼으로 타겟 획득 OK" : "버튼으로도 타겟 없음(!)"));
}
Save("fix_autooff_scenario", sb.ToString());
Destroy(gameObject);
}
}
public static object AutoOffScenario()
{
if (!Application.isPlaying) return "not playing";
if (GameObject.Find("__wl770off") != null) return "already running";
var h = new GameObject("__wl770off").AddComponent<OffHost>();
h.StartCoroutine(h.Co());
return "started";
}
public static object Report(string tag)
{
var p = System.IO.Path.Combine(OutDir, "wl770_" + (string.IsNullOrEmpty(tag) ? "run" : tag) + ".txt");
return System.IO.File.Exists(p) ? System.IO.File.ReadAllText(p) : "(no report: " + p + ")";
}
}