528 lines
31 KiB
C#
528 lines
31 KiB
C#
// 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 + ")";
|
|
}
|
|
}
|