Project_WL/Assets/WL/Combat/Run/RunGuide.cs

586 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// RunGuide.cs — 런 진행 가이드: 존 클리어 → 다음 존 자동 이동 · 보스 게이트 뒤 보스 우선
//
// PD 지시 #813 · 발주서 WL-813w §1-1·§1-2 (2026-09-09)
// 근거 = qa/W2-4b_재채점_v2.md
// · D-11(치명) 자동전투가 PC 를 잡몹 쪽으로 끌고 나가 아레나 이탈 → 813d 리쉬가 보스를
// 만피로 **69회** 리셋 → 4분 런이 항상 Timeout(보스 HP 1800/1800 유지).
// PC 를 아레나 중심으로 반복 텔레포트하면 HP 가 정상 감소(≈22 HP/s) = 전투식 문제가 아니다.
// · D-13(중) 존 2·3·4 가 0/14 · 0/12 · 0/8 그대로 — PC 가 존1 을 안 떠난다(존 이동 로직이 없다).
//
// ■ 원본 훅 0 · 원본 수정 0 — 공개 API 만 쓴다 (실측 근거)
// Actor.cs:956 `Get_Target()` 현재 타깃 읽기
// Actor.cs:957 `Del_Target(bool forcedel)` 타깃 해제(가이드 중 먼 적 무시)
// Actor.cs:988 `Change_Target(true, actor)` 타깃 지정 + 아웃라인 교체(원본 터치 타깃과 같은 경로)
// Actor.cs:63 `m_IsLockTarget { get; set; }` 원본 「락 타깃」 — MyActor.cs:106·121 이 이 플래그를
// 보고 타깃 교체를 스스로 막는다(보스 고정에 재사용)
// Actor.cs:900 `Get_Agent()` → NavMeshAgent 목적지 지정(SetDestination)
// Actor.cs:1812 `Get_MoveSpeed()` 이동 속도(원본 Set_Path 와 같은 값)
// Actor.cs:2414 `Get_CurAnim()` · 1063 `IsCC()` 공격·스킬·CC 중에는 조종하지 않는다
// PCActor.cs:461 `Set_JoystickStatus(int)` · :20 `JoystickStatus`
// PCActor.cs:65 `WL_PlayRunNow()` 달리기 애님(타깃이 없으면 경로를 건드리지 않는다)
// 🔴 리플렉션 0 · `PCActor.cs`·`Actor.cs`·`MobActor.cs` 변경 0.
//
// ■ 왜 JoystickStatus 인가 (실측)
// PCActor.cs:139 `if (JoystickStatus > 0 || IsStop || IsDead()) return;` 가 **base.Update()
// (= MyActor.Update 의 자동 교전 접근 루프)** 를 통째로 건너뛴다. 그 위(PCActor.cs:93)의
// 자동 스킬 루프는 그대로 돈다 = 「플레이어가 조이스틱으로 이동하는 동안」의 원본 상태 그대로다.
// 이 값을 쓰지 않고 목적지만 주면 원본이 매 프레임 `Play_Run() → Set_Path(타깃)`(MyActor.cs:335)
// 으로 경로를 덮어써 두 이동이 싸운다. 원본 코드를 한 줄도 고치지 않고 자동전투를
// 「끄지 않은 채」 잠시 비켜 세우는 유일한 공개 경로다.
// · 값이 2 인 동안(Skill_Whirlwind·Skill_SweepAttack 이 거는 잠금)에는 조종하지 않는다.
// · 조종을 놓을 때는 `Set_JoystickStatus(3)`(= 0 복귀) — 우리가 1 로 만든 경우에만 부른다.
//
// ■ 가이드 상태
// Off 목적지 없음 — 아무 일도 하지 않는다(원본 100%).
// Travel 목적지로 이동 중 — 조종 ON · 타깃 해제(보스는 예외).
// Hold 도착 — 조종 OFF(원본 자동전투가 그 존/아레나에서 싸운다).
// 다만 ① 목적지에서 guideLeashRadius 밖의 타깃은 무시(Del_Target)
// ② PC 가 holdRadius 밖으로 밀려나면 Travel 로 되돌아가 끌어온다
// → 존1 로 되돌아가는 것(D-13)과 아레나 이탈(D-11)이 여기서 막힌다.
// 피격 resumeAfterHitSeconds 동안 가이드를 멈추고 원본에 넘긴다(즉시 응전 · 발주서 §1-1).
//
// ■ 값(C45) 전부 WLRunGuideSettings. enabled = 0 이면 러너가 뜨지 않고 구독도 하지 않는다(C8).
//
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using UnityEngine.AI;
using WL.Combat.Boss;
using WL.Combat.Core;
namespace WL.Combat.Run
{
/// <summary>가이드 상태.</summary>
public enum RunGuideState
{
/// <summary>목적지 없음(원본 100%).</summary>
Off = 0,
/// <summary>목적지로 이동 중(조종 ON).</summary>
Travel = 1,
/// <summary>도착 — 그 자리에서 원본 자동전투(조종 OFF · 이탈만 감시).</summary>
Hold = 2,
}
/// <summary>목적지 종류.</summary>
public enum RunGuideDest
{
None = 0,
/// <summary>존 스포너 앵커.</summary>
Zone = 1,
/// <summary>보스 아레나 중심.</summary>
Boss = 2,
}
/// <summary>런 진행 가이드. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개) · GC 0.</summary>
public static class RunGuide
{
static WLRunGuideSettings Cfg { get { return WLRunGuideSettings.Instance; } }
/// <summary>가이드가 살아 있는가(에셋 · enabled · 런타임 스위치 · 런 축). false 면 원본 자동전투 100%.</summary>
public static bool Active { get { return WLRunGuideSettings.Enabled; } }
// ───────────────────────────────────────── 상태 (프로브가 읽는다)
public static RunGuideState State { get { return s_state; } }
public static RunGuideDest DestKind { get { return s_destKind; } }
/// <summary>존 목적지일 때의 존 인덱스(0부터). 보스면 1.</summary>
public static int ZoneIndex { get { return s_zoneIndex; } }
/// <summary>현재 목적지(월드). 보스면 BossArena.Center 를 매 틱 다시 읽는다.</summary>
public static Vector3 Destination { get { return s_dest; } }
/// <summary>지금 WL 이 PC 를 조종 중인가(JoystickStatus = 1).</summary>
public static bool Steering { get { return s_steering; } }
/// <summary>구독 상태.</summary>
public static bool Subscribed { get { return s_subscribed; } }
/// <summary>고장(예외) 상태 — 이후 아무 일도 하지 않는다.</summary>
public static bool Faulted { get { return s_faulted; } }
// ───────────────────────────────────────── 진단 카운터
public static int GuideBegun, Arrived, GaveUp, FarTargetIgnored, HoldPullbacks,
Repaths, BossRetargets, BossLocks, HitSuspends, AnchorScans;
public static string LastLog = "";
/// <summary>마지막으로 무시한 타깃 이름(진단).</summary>
public static string LastIgnoredTarget = "";
// ───────────────────────────────────────── 내부 상태
static RunGuideState s_state;
static RunGuideDest s_destKind;
static int s_zoneIndex = -1;
static Vector3 s_dest;
static float s_nextPoll, s_nextRepath, s_travelStart, s_hitUntil;
static bool s_steering, s_subscribed, s_faulted;
static Actor s_boss, s_lockedBoss;
// 존 앵커 캐시(스포너 노드 실좌표 · 없으면 SO 폴백)
const int kMaxZones = 16;
static readonly Vector3[] s_anchors = new Vector3[kMaxZones];
static readonly bool[] s_anchorFromNode = new bool[kMaxZones];
static int s_anchorCount;
// 구독 핸들러 캐시(할당 0 — Add/Remove 가 같은 델리게이트를 봐야 한다)
static readonly CombatHandler<RunStartedEvent> h_runStarted = OnRunStarted;
static readonly CombatHandler<ZoneClearedEvent> h_zoneCleared = OnZoneCleared;
static readonly CombatHandler<BossGateOpenedEvent> h_gate = OnBossGateOpened;
static readonly CombatHandler<BossStartedEvent> h_bossStarted = OnBossStarted;
static readonly CombatHandler<RunResult> h_runEnded = OnRunEnded;
static readonly CombatHandler<DamagedEvent> h_damaged = OnDamaged;
// ─────────────────────────────────────────────────────────────────────
// 구독
// ─────────────────────────────────────────────────────────────────────
/// <summary>RunEvents · CombatEvents.Damaged 구독(중복 안전).</summary>
public static void EnsureSubscribed()
{
if (s_subscribed) return;
s_subscribed = true;
RunEvents.RunStarted.Add(h_runStarted);
RunEvents.ZoneCleared.Add(h_zoneCleared);
RunEvents.BossGateOpened.Add(h_gate);
RunEvents.BossStarted.Add(h_bossStarted);
RunEvents.RunEnded.Add(h_runEnded);
CombatEvents.Damaged.Add(h_damaged);
}
/// <summary>구독 해제(A/B · 프로브).</summary>
public static void Unsubscribe()
{
if (!s_subscribed) return;
s_subscribed = false;
RunEvents.RunStarted.Remove(h_runStarted);
RunEvents.ZoneCleared.Remove(h_zoneCleared);
RunEvents.BossGateOpened.Remove(h_gate);
RunEvents.BossStarted.Remove(h_bossStarted);
RunEvents.RunEnded.Remove(h_runEnded);
CombatEvents.Damaged.Remove(h_damaged);
}
// ─────────────────────────────────────────────────────────────────────
// 이벤트 → 목적지
// ─────────────────────────────────────────────────────────────────────
static void OnRunStarted(in RunStartedEvent e)
{
StopGuide("런 시작");
s_boss = null;
s_anchorCount = 0; // 맵이 바뀌었을 수 있다 — 앵커를 다시 읽는다
var cfg = Cfg;
if (cfg != null && cfg.zoneGuideEnabled && cfg.guideOnRunStart) BeginZone(0, "런 시작");
}
static void OnZoneCleared(in ZoneClearedEvent e)
{
var cfg = Cfg;
if (cfg == null || !cfg.zoneGuideEnabled) return;
if (e.allZonesCleared) return; // 게이트 개방 통지가 따로 온다(보스 가이드)
BeginZone(e.zoneIndex + 1, "존 " + (e.zoneIndex + 1) + " 클리어");
}
static void OnBossGateOpened(in BossGateOpenedEvent e) { BeginBoss("게이트 개방"); }
static void OnBossStarted(in BossStartedEvent e)
{
if (!DSUtil.CheckNull(e.boss)) s_boss = e.boss;
BeginBoss("보스 등장");
}
static void OnRunEnded(in RunResult e) { StopGuide("런 종료"); }
static void OnDamaged(in DamagedEvent e)
{
var cfg = Cfg;
if (cfg == null || cfg.resumeAfterHitSeconds <= 0f) return;
if (s_destKind == RunGuideDest.None) return;
var pc = MyValue.MyPC;
if (DSUtil.CheckNull(pc) || !ReferenceEquals(e.victim, pc)) return;
bool wasFree = s_hitUntil <= Time.unscaledTime;
s_hitUntil = Time.unscaledTime + cfg.resumeAfterHitSeconds;
if (wasFree) HitSuspends++;
}
// ─────────────────────────────────────────────────────────────────────
// 목적지 지정
// ─────────────────────────────────────────────────────────────────────
/// <summary>존 i 로 유도한다(프로브도 직접 부를 수 있다).</summary>
public static void BeginZone(int zoneIndex, string reason)
{
var cfg = Cfg;
if (cfg == null || !cfg.zoneGuideEnabled) return;
int n = ZoneCount(cfg);
if (zoneIndex < 0 || zoneIndex >= n) { StopGuide("존 " + zoneIndex + " 없음"); return; }
Vector3 anchor;
if (!TryGetAnchor(cfg, zoneIndex, out anchor)) { StopGuide("존 " + zoneIndex + " 앵커 없음"); return; }
ReleaseBossLock();
s_destKind = RunGuideDest.Zone;
s_zoneIndex = zoneIndex;
s_dest = anchor;
EnterTravel();
GuideBegun++;
Log("존 " + zoneIndex + " 유도 시작(" + reason + ") → " + Fmt(anchor) +
(s_anchorFromNode[zoneIndex] ? " [스포너 노드]" : " [SO 폴백]"));
}
/// <summary>보스 아레나로 유도한다(프로브도 직접 부를 수 있다).</summary>
public static void BeginBoss(string reason)
{
var cfg = Cfg;
if (cfg == null || !cfg.bossGuideEnabled) return;
if (s_destKind == RunGuideDest.Boss && s_state != RunGuideState.Off) return; // 이미 보스 유도 중
s_destKind = RunGuideDest.Boss;
s_zoneIndex = -1;
s_dest = BossArena.Center;
EnterTravel();
GuideBegun++;
Log("보스 아레나 유도 시작(" + reason + ") → " + Fmt(s_dest));
}
/// <summary>가이드를 끝낸다(조종·락 해제 포함).</summary>
public static void StopGuide(string reason)
{
bool had = s_destKind != RunGuideDest.None;
ReleaseSteer();
ReleaseBossLock();
s_destKind = RunGuideDest.None;
s_state = RunGuideState.Off;
s_zoneIndex = -1;
s_hitUntil = 0f;
if (had) Log("가이드 종료 — " + reason);
}
static void EnterTravel()
{
s_state = RunGuideState.Travel;
s_travelStart = Time.unscaledTime;
s_nextRepath = 0f;
}
// ─────────────────────────────────────────────────────────────────────
// 틱
// ─────────────────────────────────────────────────────────────────────
/// <summary>런너가 매 프레임 부른다(에디트 모드에서는 프로브가 ForceTick 으로 부른다).</summary>
internal static void Tick()
{
if (s_faulted) return;
if (!Active) { if (s_steering) ReleaseSteer(); return; }
var cfg = Cfg;
float now = Time.unscaledTime;
if (now < s_nextPoll) return;
s_nextPoll = now + Mathf.Max(0.02f, cfg.pollSeconds);
// 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 팝업으로 띄운다(813x §D-1).
try { Poll(cfg, now); }
catch (System.Exception ex) { s_faulted = true; ReleaseSteer(); Log("중단(예외) — " + ex.Message); }
}
static void Poll(WLRunGuideSettings cfg, float now)
{
var pc = MyValue.MyPC;
if (DSUtil.CheckNull(pc) || pc.IsDead()) { ReleaseSteer(); return; }
if (s_destKind == RunGuideDest.None) { ReleaseSteer(); return; }
// ── 보스 우선 타깃(발주서 §1-2) — 조종/도착 상태와 무관하게 보스가 살아 있으면 건다
if (s_destKind == RunGuideDest.Boss) ApplyBossPriority(cfg, pc);
// ── 피격 → 즉시 응전(가이드 일시 중단)
if (now < s_hitUntil) { ReleaseSteer(); return; }
if (s_destKind == RunGuideDest.Boss) s_dest = BossArena.Center;
Vector3 pcPos = pc.Get_position();
float d = DistXZ(pcPos, s_dest);
bool isBoss = s_destKind == RunGuideDest.Boss;
float arrive = isBoss ? cfg.bossArriveRadius : cfg.zoneArriveRadius;
float hold = isBoss ? cfg.bossHoldRadius : cfg.zoneHoldRadius;
if (s_state == RunGuideState.Travel)
{
if (d <= arrive)
{
s_state = RunGuideState.Hold;
ReleaseSteer();
Arrived++;
Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " 도착 — " + d.ToString("F1") + "m · 자동전투 재개");
return;
}
if (cfg.maxTravelSeconds > 0f && now - s_travelStart >= cfg.maxTravelSeconds)
{
s_state = RunGuideState.Hold; // 포기하되 이탈 감시는 남긴다
ReleaseSteer();
GaveUp++;
Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " 이동 포기 — " +
cfg.maxTravelSeconds.ToString("F0") + "s 초과(남은 " + d.ToString("F1") + "m)");
return;
}
ClearTravelTarget(pc);
Steer(cfg, pc, now);
return;
}
// ── Hold: 조종은 놓고, 먼 타깃만 무시 · 이탈하면 다시 끌어온다
IgnoreFarTarget(cfg, pc);
if (hold > 0f && d > hold)
{
EnterTravel();
HoldPullbacks++;
Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " 이탈 " + d.ToString("F1") + "m — 다시 유도");
ClearTravelTarget(pc);
Steer(cfg, pc, now);
return;
}
ReleaseSteer();
}
// ─────────────────────────────────────────────────────────────────────
// 조종 · 타깃
// ─────────────────────────────────────────────────────────────────────
/// <summary>이동 중에는 타깃을 놓는다(보스가 목적지일 때 보스는 예외 — 그쪽으로 가는 것이 목적이다).</summary>
static void ClearTravelTarget(PCActor pc)
{
if (!pc.isTarget) return;
var t = pc.Get_Target();
if (DSUtil.CheckNull(t)) return;
if (s_destKind == RunGuideDest.Boss && !DSUtil.CheckNull(s_boss) && ReferenceEquals(t, s_boss)) return;
LastIgnoredTarget = t.name;
pc.Del_Target(true);
FarTargetIgnored++;
}
/// <summary>도착 뒤: 목적지에서 guideLeashRadius 밖의 타깃만 무시한다(가까운 적은 원본이 잡는다).</summary>
static void IgnoreFarTarget(WLRunGuideSettings cfg, PCActor pc)
{
if (cfg.guideLeashRadius <= 0f || !pc.isTarget) return;
var t = pc.Get_Target();
if (DSUtil.CheckNull(t)) return;
if (s_destKind == RunGuideDest.Boss && !DSUtil.CheckNull(s_boss) && ReferenceEquals(t, s_boss)) return;
if (DistXZ(t.Get_position(), s_dest) <= cfg.guideLeashRadius) return;
LastIgnoredTarget = t.name;
pc.Del_Target(true);
FarTargetIgnored++;
}
/// <summary>PC 를 목적지로 민다. 원본 이동 API(NavMeshAgent)만 쓴다.</summary>
static void Steer(WLRunGuideSettings cfg, PCActor pc, float now)
{
// 🔴 에이전트 검사를 맨 앞에 둔다 — 에디트 모드(프로브)에는 NavMeshAgent 도 dic_ccData 초기화도 없어
// IsCC() 가 KeyNotFoundException 을 던진다. 런타임 판정 순서는 어차피 결과가 같다.
var agent = pc.Get_Agent();
if (agent == null || !agent.enabled || !agent.isOnNavMesh) return; // NavMesh 밖 — 조종 포기
var anim = pc.Get_CurAnim();
if (anim == eAnim.Dodge || anim >= eAnim.Attack || pc.IsCC()) return; // 공격·스킬·회피·CC 는 원본 우선
if (pc.JoystickStatus == 2) return; // 스킬 이동 잠금(Whirlwind 등)
if (!s_steering && pc.JoystickStatus == 0)
{
pc.Set_JoystickStatus(1); // 원본 자동 교전 접근 루프를 비켜 세운다(PCActor.cs:139)
s_steering = true;
}
pc.WL_PlayRunNow(); // 타깃이 없으므로 경로를 건드리지 않는다(MyActor.cs:335)
agent.isStopped = false;
agent.stoppingDistance = cfg.guideStopDistance;
if (now >= s_nextRepath)
{
s_nextRepath = now + Mathf.Max(0f, cfg.repathIntervalSeconds);
agent.speed = pc.Get_MoveSpeed();
agent.SetDestination(s_dest);
Repaths++;
}
}
/// <summary>조종을 놓는다(우리가 1 로 만든 경우에만 0 으로 되돌린다).</summary>
static void ReleaseSteer()
{
if (!s_steering) return;
s_steering = false;
var pc = MyValue.MyPC;
if (DSUtil.CheckNull(pc)) return;
if (pc.JoystickStatus == 1) pc.Set_JoystickStatus(3); // 3 = 해제(PCActor.cs:461)
}
// ─────────────────────────────────────────────────────────────────────
// 보스 우선 타깃
// ─────────────────────────────────────────────────────────────────────
static void ApplyBossPriority(WLRunGuideSettings cfg, PCActor pc)
{
if (!cfg.bossPriorityEnabled) { ReleaseBossLock(); return; }
var boss = s_boss;
if (!IsAliveBoss(boss)) { boss = BossArena.Boss; s_boss = IsAliveBoss(boss) ? boss : null; }
if (!IsAliveBoss(s_boss)) { ReleaseBossLock(); return; }
boss = s_boss;
if (cfg.bossLockTarget && !boss.m_IsLockTarget)
{
boss.m_IsLockTarget = true; // 원본 락 타깃(MyActor.cs:106·121 이 스스로 교체를 막는다)
s_lockedBoss = boss;
BossLocks++;
}
if (!ReferenceEquals(pc.Get_Target(), boss))
{
pc.Change_Target(true, boss); // 아웃라인까지 원본 경로 그대로(Actor.cs:988)
BossRetargets++;
}
}
static void ReleaseBossLock()
{
if (DSUtil.CheckNull(s_lockedBoss)) { s_lockedBoss = null; return; }
s_lockedBoss.m_IsLockTarget = false;
s_lockedBoss = null;
}
static bool IsAliveBoss(Actor a)
{
return !DSUtil.CheckNull(a) && !a.IsDead() && a.gameObject.activeInHierarchy;
}
// ─────────────────────────────────────────────────────────────────────
// 존 앵커 (스포너 노드 실좌표 · 없으면 SO 폴백)
// ─────────────────────────────────────────────────────────────────────
static int ZoneCount(WLRunGuideSettings cfg)
{
var rs = WLRunSettings.Instance;
if (rs == null || rs.zoneSpawnerIds == null) return 0;
int n = rs.zoneSpawnerIds.Length;
if (rs.zoneClearKills != null && rs.zoneClearKills.Length < n) n = rs.zoneClearKills.Length;
return n > kMaxZones ? kMaxZones : n;
}
/// <summary>존 i 의 앵커. 스포너 노드를 못 찾았으면 1회 스캔하고, 그래도 없으면 SO 폴백.</summary>
public static bool TryGetAnchor(WLRunGuideSettings cfg, int i, out Vector3 anchor)
{
anchor = Vector3.zero;
int n = ZoneCount(cfg);
if (i < 0 || i >= n) return false;
if (s_anchorCount != n || !s_anchorFromNode[i]) ScanAnchors(cfg, n);
if (s_anchorFromNode[i]) { anchor = s_anchors[i]; return true; }
var fb = cfg.zoneAnchorFallbacks;
if (fb != null && i < fb.Length) { anchor = fb[i]; s_anchors[i] = anchor; return true; }
return false;
}
/// <summary>씬의 MobControlMgr 를 1회 훑어 존 앵커를 채운다(런당 몇 회 · 매 틱 아님).</summary>
static void ScanAnchors(WLRunGuideSettings cfg, int n)
{
var rs = WLRunSettings.Instance;
if (rs == null || rs.zoneSpawnerIds == null) return;
s_anchorCount = n;
AnchorScans++;
var spawners = Object.FindObjectsByType<MobControlMgr>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < n; i++)
{
s_anchorFromNode[i] = false;
int id = rs.zoneSpawnerIds[i];
for (int j = 0; spawners != null && j < spawners.Length; j++)
{
if (DSUtil.CheckNull(spawners[j]) || spawners[j].SpawnerID != id) continue;
s_anchors[i] = spawners[j].transform.position;
s_anchorFromNode[i] = true;
break;
}
if (!s_anchorFromNode[i])
{
var fb = cfg.zoneAnchorFallbacks;
if (fb != null && i < fb.Length) s_anchors[i] = fb[i];
}
}
}
/// <summary>앵커가 스포너 노드에서 온 것인가(진단).</summary>
public static bool AnchorFromNode(int i) { return i >= 0 && i < kMaxZones && s_anchorFromNode[i]; }
/// <summary>캐시된 앵커(진단).</summary>
public static Vector3 AnchorOf(int i) { return i >= 0 && i < kMaxZones ? s_anchors[i] : Vector3.zero; }
// ─────────────────────────────────────────────────────────────────────
// 유틸 · 진단
// ─────────────────────────────────────────────────────────────────────
static float DistXZ(Vector3 a, Vector3 b)
{
float dx = a.x - b.x, dz = a.z - b.z;
return Mathf.Sqrt(dx * dx + dz * dz);
}
static string Fmt(Vector3 v)
{
return "(" + v.x.ToString("F1") + ", " + v.y.ToString("F1") + ", " + v.z.ToString("F1") + ")";
}
static void Log(string msg)
{
LastLog = msg;
var c = Cfg;
if (c != null && c.verboseLog) Debug.Log("[RunGuide] " + msg);
}
/// <summary>프로브용: 카운터·상태 초기화(구독은 유지).</summary>
public static void ResetDiagnostics()
{
GuideBegun = Arrived = GaveUp = FarTargetIgnored = HoldPullbacks = 0;
Repaths = BossRetargets = BossLocks = HitSuspends = AnchorScans = 0;
LastLog = LastIgnoredTarget = "";
ReleaseSteer(); ReleaseBossLock();
s_state = RunGuideState.Off; s_destKind = RunGuideDest.None; s_zoneIndex = -1;
s_dest = Vector3.zero; s_boss = null;
s_nextPoll = s_nextRepath = s_travelStart = s_hitUntil = 0f;
s_faulted = false; s_anchorCount = 0;
}
/// <summary>프로브용: 러너 없이 1회 폴링(강제 틱).</summary>
public static void ForceTick() { s_nextPoll = 0f; Tick(); }
/// <summary>프로브용: 피격 응전 구간을 직접 건다.</summary>
public static void SimulateHit() { var c = Cfg; if (c != null) { s_hitUntil = Time.unscaledTime + c.resumeAfterHitSeconds; HitSuspends++; } }
/// <summary>프로브용: 피격 응전 구간을 즉시 푼다.</summary>
public static void ClearHitSuspendForProbe() { s_hitUntil = 0f; }
/// <summary>프로브용: 보스 참조를 직접 넣는다.</summary>
public static void SetBossForProbe(Actor boss) { s_boss = boss; }
// ─────────────────────────────────────────────────────────────────────
// 런너 (숨김 GameObject 1개 · 플레이 모드 전용)
// ─────────────────────────────────────────────────────────────────────
static RunGuideRunner s_runner;
internal static void EnsureRunner()
{
if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return;
var go = new GameObject("[WL813w] RunGuideRunner");
go.hideFlags = HideFlags.HideAndDontSave;
s_runner = go.AddComponent<RunGuideRunner>();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
if (!Active) return; // 꺼져 있으면 오브젝트 0 · 구독 0(C8)
EnsureSubscribed();
EnsureRunner();
}
}
/// <summary>RunGuide 의 시간 축. 숨김 GameObject 1개 · 코루틴 0.</summary>
internal sealed class RunGuideRunner : MonoBehaviour
{
void Update() { RunGuide.Tick(); }
}
}