// 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(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); } /// MyActor.Update 의 정지 거리 계산과 같은 식(+ 설정 여유). 값은 전부 런타임에서 읽는다(C45). 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; } /// 현재 시점 후보표(거리·각도·점수·사거리내). 선택 규칙 검증용. 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(); 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(FindObjectsInactive.Exclude, FindObjectsSortMode.None).Length + " liveMobs=" + UnityEngine.Object.FindObjectsByType(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 "?"; } } /// 자동 전투 토글 — Actor.m_Auto 필드만 바꾼다(메모리 전용 · ServerData·서버 쓰기 없음 · Play 종료 시 원복). 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 를 몹 무리 근처로 워프하고 정면을 지정 ──────────────────── /// dist = 가장 큰 몹 무리 중심에서의 거리(m), yawOffsetDeg = 무리 방향 대비 정면 각도(0 = 정면, 180 = 등짐). public static object Setup(float dist, float yawOffsetDeg) { return SetupFilter("", dist, yawOffsetDeg); } /// nameContains 로 후보를 거른 뒤 그 무리 근처로 워프한다(약한 몹 무리를 골라 처치 재타겟을 관찰할 때). 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(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 결정적 검증 (회전 전 기하로 기대값 산출 → 실제 선택과 비교) ── /// /// trials 회 반복: 캐릭터 정면(yaw)을 바꾸고, **Play_Attack 호출 직전의** 후보표로 기대 타겟을 계산한 뒤 /// 실제 Play_Attack 이 고른 타겟과 대조한다(base.Play_Attack 이 LookAt 으로 회전하기 전 기하를 쓴다). /// 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(); } /// /// 검증 하네스 — PC 의 현재 타겟 HP 를 1 로 낮춘다. 죽는 순간은 **플레이어의 실제 타격**이 만든다 /// (합성 DamageInfo 로 직접 죽이면 Beater_pd 가 필요해 실제 피해 경로를 우회하게 된다). /// 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(); 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(FindObjectsInactive.Include, FindObjectsSortMode.None).ToList(); var lastHp = new Dictionary(); 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(); // (b)(c) 표 var startRows = new List(); // (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.seconds = seconds; host.logTag = string.IsNullOrEmpty(tag) ? "run" : tag; host.StartCoroutine(host.Co()); return "started " + seconds + "s tag=" + host.logTag; } /// 회귀 확인 — 펫·몬스터 타겟팅이 변경 대상이 아님을 런타임에서 확인한다. 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(FindObjectsInactive.Include, FindObjectsSortMode.None); sb.AppendLine("pets(all)=" + pets.Length + " (PD #763 spawnPlayerPet=False → 등장 없음. 펫 경로는 코드상 UseWLTargeting()=IsRole(PC) 로 제외)"); var mobs = UnityEngine.Object.FindObjectsByType(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(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(); 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 + ")"; } }