// ───────────────────────────────────────────────────────────────────────────── // 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 동안 가이드를 멈추고 원본에 넘긴다(즉시 응전). // // ───────────────────────────────────────────────────────────────────────────── // WL-813w2 (2026-09-09 · PD 지시 #813) — 근거 = qa/W2-5_재채점_v3.md §4 런 4회 실측 // // 🔴 실측 결함과 그 원인(한 곳이다) // · D-24 존 유도의 「도착」이 런 4회 내내 한 번도 안 잡혔다(`Arrived=0` · State=Travel 고착). // · D-22 보스 홀드 14 m 가 안 지켜져 PC 가 아레나에서 **최대 46.3 m** 이탈 → // 813d 리쉬(24 m)가 런당 **15~19회** 걸려 깎은 HP 를 되돌린다. // 되돌림(HoldPullbacks)은 런당 **3~4회**뿐 — 되돌림 검사 자체가 안 돈 것이다. // · D-19 화면 안 적 수 18 → 2. PC 가 존 반경 밖을 떠돈다. // // 원인 = **813w 의 Poll 이 피격 중단(`now < s_hitUntil`)에서 통째로 return** 했다(구 RunGuide.cs:291). // 그 return 이 ① 도착 판정(구 :303) ② 이탈 되돌림(구 :327) **위**에 있었다. // 잡몹 밭에서는 `resumeAfterHitSeconds`(3 s)가 매 피격마다 갱신돼 사실상 상시 참이므로 // **도착 판정도 되돌림도 폴링에서 실행되지 않았다.** (HoldPullbacks 3~4 ≪ ResetCount 15~19 이 // 그 증거다 — 이 두 값은 같은 함수의 같은 조건에서 나온다.) // // ■ 수리 (값은 전부 SO · 구조 변경 없음) // ① 순서 교정 — 도착 판정·이탈 되돌림은 **피격 중단과 무관하게 매 폴링 실행**한다. // 피격 중단은 이제 **Hold(= 목적지 반경 안)에서 조종을 놓는 것**만 뜻한다 // (`hitSuspendOnlyInHold`). 이동·되돌림은 맞아도 멈추지 않는다 = 「이동 중 잡몹 무시」. // ② 정지 판정 도착 — XZ 거리 판정에 더해, 조종 중인데 `arriveStillSeconds`(0.5 s) 동안 // `arriveStillSpeed`(0.3 m/s) 미만이면 **그 자리를 도착으로 확정**한다(목적지 반경 안일 때만). // NavMesh 높이·stoppingDistance·지형 때문에 반경 안으로 못 붙는 D-24 경로를 여기서 닫는다. // `maxTravelSeconds` 초과도 Hold 로 떨어뜨린다(이탈 감시는 계속 · 813w 와 동일). // ③ 되돌림 중 타깃 해제 — 이동 중에는 **보스 타깃도** 놓는다(`pullbackReleasesTarget`). // 타깃이 남아 있으면 원본 `Play_Run() → Set_Path(타깃)`(MyActor.cs:335)이 매 폴링 // 우리 목적지를 덮어써 두 이동이 싸운다. 락(`m_IsLockTarget`)은 유지하므로 // **보스 생존 중 잡몹이 타깃이 되는 일은 없다**(재타깃은 Hold 에서 · `bossRetargetOnlyInHold`). // ④ 진행 로그 — `RunEvents.RunTick` 구독으로 존 체류 초·이동 초·최대 이탈 m 를 남긴다 // (`runTickLogSeconds` · QA 가 이 줄로 존 도착 시각표를 적는다). // // ■ C8 롤백 — `hitSuspendOnlyInHold=0` · `arriveStillEnabled=0` · `pullbackReleasesTarget=0` // · `bossRetargetOnlyInHold=0` 을 전부 끄면 813w 동작 그대로다(값 하나로 되돌릴 수 있다). // ───────────────────────────────────────────────────────────────────────────── // // ■ 값(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 { /// 가이드 상태. public enum RunGuideState { /// 목적지 없음(원본 100%). Off = 0, /// 목적지로 이동 중(조종 ON). Travel = 1, /// 도착 — 그 자리에서 원본 자동전투(조종 OFF · 이탈만 감시). Hold = 2, } /// 목적지 종류. public enum RunGuideDest { None = 0, /// 존 스포너 앵커. Zone = 1, /// 보스 아레나 중심. Boss = 2, } /// 런 진행 가이드. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개) · GC 0. public static class RunGuide { static WLRunGuideSettings Cfg { get { return WLRunGuideSettings.Instance; } } /// 가이드가 살아 있는가(에셋 · enabled · 런타임 스위치 · 런 축). false 면 원본 자동전투 100%. public static bool Active { get { return WLRunGuideSettings.Enabled; } } // ───────────────────────────────────────── 상태 (프로브가 읽는다) public static RunGuideState State { get { return s_state; } } public static RunGuideDest DestKind { get { return s_destKind; } } /// 존 목적지일 때의 존 인덱스(0부터). 보스면 −1. public static int ZoneIndex { get { return s_zoneIndex; } } /// 현재 목적지(월드). 보스면 BossArena.Center 를 매 틱 다시 읽는다. public static Vector3 Destination { get { return s_dest; } } /// 지금 WL 이 PC 를 조종 중인가(JoystickStatus = 1). public static bool Steering { get { return s_steering; } } /// 구독 상태. public static bool Subscribed { get { return s_subscribed; } } /// 고장(예외) 상태 — 이후 아무 일도 하지 않는다. public static bool Faulted { get { return s_faulted; } } // ───────────────────────────────────────── 진단 카운터 public static int GuideBegun, Arrived, GaveUp, FarTargetIgnored, HoldPullbacks, Repaths, BossRetargets, BossLocks, HitSuspends, AnchorScans; /// 813w2: 정지 판정으로 확정한 도착 수(Arrived 에도 함께 센다) · 피격 중에도 실행한 되돌림 수. public static int ArrivedByStill, PullbacksWhileHit; /// 813w2: 목적지에서 잰 최대 이탈 거리(m · Q5 D-22 의 「46.3 m」에 대응하는 실측값). public static float MaxDistFromDest; /// 813w2: 존 체류 초 · 보스(아레나) 체류 초 · 이동(Travel) 초 — RunTick 로그와 QA 시각표용. public static float ZoneHoldSeconds, BossHoldSeconds, TravelSeconds; public static string LastLog = ""; /// 마지막으로 무시한 타깃 이름(진단). public static string LastIgnoredTarget = ""; /// 813w2: 마지막 RunTick 요약 줄(프로브·QA 가 읽는다). public static string LastTickLog = ""; // ───────────────────────────────────────── 내부 상태 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; // 813w2 §1-1 정지 판정 · §1-3 체류 계측 static Vector3 s_lastPos; static float s_lastPosTime = -1f, s_stillSince = -1f, s_lastAccumTime = -1f, s_nextTickLog; // 존 앵커 캐시(스포너 노드 실좌표 · 없으면 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 h_runStarted = OnRunStarted; static readonly CombatHandler h_zoneCleared = OnZoneCleared; static readonly CombatHandler h_gate = OnBossGateOpened; static readonly CombatHandler h_bossStarted = OnBossStarted; static readonly CombatHandler h_runEnded = OnRunEnded; static readonly CombatHandler h_damaged = OnDamaged; static readonly CombatHandler h_runTick = OnRunTick; // 813w2 §1-3 진행 로그 // ───────────────────────────────────────────────────────────────────── // 구독 // ───────────────────────────────────────────────────────────────────── /// RunEvents · CombatEvents.Damaged 구독(중복 안전). 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); RunEvents.RunTick.Add(h_runTick); CombatEvents.Damaged.Add(h_damaged); } /// 구독 해제(A/B · 프로브). 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); RunEvents.RunTick.Remove(h_runTick); CombatEvents.Damaged.Remove(h_damaged); } // ───────────────────────────────────────────────────────────────────── // 이벤트 → 목적지 // ───────────────────────────────────────────────────────────────────── static void OnRunStarted(in RunStartedEvent e) { StopGuide("런 시작"); s_boss = null; s_anchorCount = 0; // 맵이 바뀌었을 수 있다 — 앵커를 다시 읽는다 // 813w2 §1-3: 런마다 체류·이탈 계측을 새로 잡는다(런 간 누적 금지 — QA 가 런 1회 표를 읽는다). ZoneHoldSeconds = BossHoldSeconds = TravelSeconds = MaxDistFromDest = 0f; ArrivedByStill = PullbacksWhileHit = 0; s_nextTickLog = 0f; s_lastAccumTime = -1f; LastTickLog = ""; 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) return; if (s_destKind == RunGuideDest.None) return; // 813w2 §1-2: 보스 구간은 응전 중단을 짧게 둔다(Q5 D-22 처방 「피격 중단을 보스전에서는 짧게」). float hold = s_destKind == RunGuideDest.Boss ? cfg.bossHitSuspendSeconds : cfg.resumeAfterHitSeconds; if (hold <= 0f) return; var pc = MyValue.MyPC; if (DSUtil.CheckNull(pc) || !ReferenceEquals(e.victim, pc)) return; bool wasFree = s_hitUntil <= Time.unscaledTime; s_hitUntil = Time.unscaledTime + hold; if (wasFree) HitSuspends++; } /// 813w2 §1-3 — 존 체류/이동 초를 주기적으로 한 줄 남긴다(QA 시각표 · GC 는 로그를 실제로 낼 때만). static void OnRunTick(in RunTickEvent e) { var cfg = Cfg; if (cfg == null || cfg.runTickLogSeconds <= 0f) return; if (e.elapsedSec < s_nextTickLog) return; s_nextTickLog = e.elapsedSec + cfg.runTickLogSeconds; LastTickLog = "t=" + e.elapsedSec.ToString("F0") + "s · " + s_state + (s_destKind == RunGuideDest.Zone ? " 존" + s_zoneIndex : s_destKind == RunGuideDest.Boss ? " 보스" : " -") + " · 존체류 " + ZoneHoldSeconds.ToString("F0") + "s · 아레나체류 " + BossHoldSeconds.ToString("F0") + "s · 이동 " + TravelSeconds.ToString("F0") + "s · 최대이탈 " + MaxDistFromDest.ToString("F1") + "m · 도착 " + Arrived + "(정지 " + ArrivedByStill + ") · 되돌림 " + HoldPullbacks + " · 존 " + e.zonesCleared + "/" + e.zoneCount + " " + e.zoneKills + "/" + e.zoneRequired; Debug.Log("[RunGuide] " + LastTickLog); } // ───────────────────────────────────────────────────────────────────── // 목적지 지정 // ───────────────────────────────────────────────────────────────────── /// 존 i 로 유도한다(프로브도 직접 부를 수 있다). 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 폴백]")); } /// 보스 아레나로 유도한다(프로브도 직접 부를 수 있다). 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)); } /// 가이드를 끝낸다(조종·락 해제 포함). 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; s_stillSince = -1f; s_lastPosTime = -1f; if (had) Log("가이드 종료 — " + reason); } static void EnterTravel() { s_state = RunGuideState.Travel; s_travelStart = Time.unscaledTime; s_nextRepath = 0f; s_stillSince = -1f; s_lastPosTime = -1f; // 813w2: 정지 판정을 새로 잡는다 } // ───────────────────────────────────────────────────────────────────── // 틱 // ───────────────────────────────────────────────────────────────────── /// 런너가 매 프레임 부른다(에디트 모드에서는 프로브가 ForceTick 으로 부른다). 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); } } // 🔴 813w2 §1-1·§1-2 — 이 함수의 **순서**가 D-24 · D-22 의 원인이었다. // 813w 은 피격 중단(`now < s_hitUntil`)에서 통째로 return 해 도착 판정과 이탈 되돌림을 // 건너뛰었다. 이제 ① 목적지·거리 계산 ② 도착 판정 ③ 이탈 되돌림은 **항상** 실행하고, // 피격 중단은 **Hold 에서 조종을 놓는 것**만 뜻한다(`hitSuspendOnlyInHold`). static void Poll(WLRunGuideSettings cfg, float now) { var pc = MyValue.MyPC; if (DSUtil.CheckNull(pc) || pc.IsDead()) { ReleaseSteer(); s_lastPosTime = -1f; return; } if (s_destKind == RunGuideDest.None) { ReleaseSteer(); return; } bool isBoss = s_destKind == RunGuideDest.Boss; if (isBoss) s_dest = BossArena.Center; Vector3 pcPos = pc.Get_position(); float d = DistXZ(pcPos, s_dest); float arrive = isBoss ? cfg.bossArriveRadius : cfg.zoneArriveRadius; float hold = isBoss ? cfg.bossHoldRadius : cfg.zoneHoldRadius; if (d > MaxDistFromDest) MaxDistFromDest = d; // ── 체류/이동 초 누적(§1-3 · RunTick 로그가 읽는다) AccumDwell(now, isBoss); // ── 정지 판정용 이동량(조종 중일 때만 센다 — 원본이 싸우는 동안의 정지는 「도착」이 아니다) bool still = UpdateStill(cfg, pcPos, now); // ── 보스 우선 타깃 — 락은 언제나 유지 · 재타깃은 Hold 에서(이동 중 타깃은 우리 목적지를 덮어쓴다) if (isBoss) ApplyBossPriority(cfg, pc); // ── 피격 응전: Hold 에서만 건다(이동·되돌림은 맞아도 멈추지 않는다 = 「이동 중 잡몹 무시」) bool hit = now < s_hitUntil; bool suspend = SuspendDecision(cfg, hit, s_state); if (s_state == RunGuideState.Travel) { if (d <= arrive) { Arrived++; EnterHold(isBoss, "도착 — " + d.ToString("F1") + "m · 자동전투 재개"); return; } // 813w2 §1-1: 반경 안으로 못 붙어도 「멈춰 섰으면」 도착으로 확정한다(D-24). if (still && WithinStillRange(cfg, d, hold)) { Arrived++; ArrivedByStill++; EnterHold(isBoss, "정지 도착 — " + d.ToString("F1") + "m · " + cfg.arriveStillSeconds.ToString("F1") + "s 정지 · 자동전투 재개"); return; } if (cfg.maxTravelSeconds > 0f && now - s_travelStart >= cfg.maxTravelSeconds) { GaveUp++; EnterHold(isBoss, "이동 포기 — " + cfg.maxTravelSeconds.ToString("F0") + "s 초과(남은 " + d.ToString("F1") + "m) · 이탈 감시는 유지"); return; } if (suspend) { ReleaseSteer(); return; } // 813w 롤백 경로에서만 여기로 온다 ClearTravelTarget(cfg, pc); Steer(cfg, pc, now); return; } // ── Hold: 조종은 놓고, 먼 타깃만 무시 · 이탈하면 **피격 중이라도** 다시 끌어온다 // 피격 중(suspend)에는 「먼 타깃 무시」를 쉬어 준다 = 나를 때린 적에게 즉시 응전한다. // 그래도 이탈 되돌림은 그대로 도므로 존/아레나 반경 밖으로는 못 나간다(D-19 · D-22). if (!suspend) IgnoreFarTarget(cfg, pc); if (hold > 0f && d > hold) { EnterTravel(); HoldPullbacks++; if (hit) PullbacksWhileHit++; Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " 이탈 " + d.ToString("F1") + "m — 다시 유도" + (hit ? "(피격 중)" : "")); ClearTravelTarget(cfg, pc); Steer(cfg, pc, now); return; } ReleaseSteer(); } /// Travel → Hold 로 확정한다(도착 · 정지 도착 · 이동 포기 공통). static void EnterHold(bool isBoss, string why) { s_state = RunGuideState.Hold; ReleaseSteer(); s_stillSince = -1f; s_lastPosTime = -1f; Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " " + why); } /// /// 피격 응전 판정(순수 · 프로브가 임시 SO 로 그대로 부른다 · C8 증거). /// 🔴 813w2 의 핵심 — `hitSuspendOnlyInHold` 가 켜져 있으면 **Travel(= 이동·되돌림)에서는 /// 피격이 가이드를 멈추지 못한다.** 813w 은 상태와 무관하게 멈췄고, 그 때문에 도착 판정(D-24)과 /// 이탈 되돌림(D-22)이 폴링에서 통째로 빠졌다. /// public static bool SuspendDecision(WLRunGuideSettings cfg, bool hit, RunGuideState state) { if (!hit || cfg == null) return false; return !cfg.hitSuspendOnlyInHold || state == RunGuideState.Hold; } /// 정지 판정으로 도착을 인정할 거리인가 — 기본은 목적지 반경(holdRadius) 안(프로브가 그대로 부른다). public static bool WithinStillRange(WLRunGuideSettings cfg, float d, float hold) { float lim = cfg.arriveStillMaxDistance > 0f ? cfg.arriveStillMaxDistance : hold; return lim <= 0f || d <= lim; } /// 조종 중 XZ 속도가 arriveStillSpeed 미만인 상태가 arriveStillSeconds 이상 이어졌는가. static bool UpdateStill(WLRunGuideSettings cfg, Vector3 pcPos, float now) { if (s_state != RunGuideState.Travel || !s_steering) { s_stillSince = -1f; s_lastPosTime = -1f; s_lastPos = pcPos; return false; } if (s_lastPosTime < 0f) { s_lastPos = pcPos; s_lastPosTime = now; s_stillSince = -1f; return false; } float dt = now - s_lastPosTime; float moved = DistXZ(pcPos, s_lastPos); if (dt > 0f) { s_lastPos = pcPos; s_lastPosTime = now; } return StepStillDetect(cfg, moved, dt, now, ref s_stillSince); } /// /// 정지 판정 1스텝(순수 · Actor 접근 0). 프로브가 가짜 이동량·시각으로 그대로 부를 수 있다. /// moved = 지난 스텝 동안의 XZ 이동량(m) · dt = 그 간격(초) · stillSince = 정지 시작 시각(없으면 −1). /// public static bool StepStillDetect(WLRunGuideSettings cfg, float moved, float dt, float now, ref float stillSince) { if (cfg == null || !cfg.arriveStillEnabled || cfg.arriveStillSeconds <= 0f) { stillSince = -1f; return false; } if (dt > 0f) { if (moved / dt < cfg.arriveStillSpeed) { if (stillSince < 0f) stillSince = now; } else stillSince = -1f; } return stillSince >= 0f && now - stillSince >= cfg.arriveStillSeconds; } /// 존 체류·아레나 체류·이동 초를 누적한다(§1-3 QA 시각표). static void AccumDwell(float now, bool isBoss) { if (s_lastAccumTime >= 0f) { float dt = now - s_lastAccumTime; if (dt > 0f && dt < 1f) // 프레임 튐·일시정지 구간은 버린다 { if (s_state == RunGuideState.Travel) TravelSeconds += dt; else if (s_state == RunGuideState.Hold) { if (isBoss) BossHoldSeconds += dt; else ZoneHoldSeconds += dt; } } } s_lastAccumTime = now; } // ───────────────────────────────────────────────────────────────────── // 조종 · 타깃 // ───────────────────────────────────────────────────────────────────── /// /// 이동·되돌림 중에는 타깃을 놓는다. /// 🔴 813w2 §1-2: 보스 타깃도 놓는다(`pullbackReleasesTarget`). 타깃이 남아 있으면 원본 /// `Play_Run() → Set_Path(타깃)`(MyActor.cs:335)이 매 폴링 우리 목적지를 덮어써 두 이동이 싸운다. /// 보스 락(`m_IsLockTarget`)은 유지되므로 잡몹이 타깃을 가져가지 못한다 — 도착(Hold)하면 /// `ApplyBossPriority` 가 즉시 보스를 다시 건다. /// static void ClearTravelTarget(WLRunGuideSettings cfg, PCActor pc) { if (!pc.isTarget) return; var t = pc.Get_Target(); if (DSUtil.CheckNull(t)) return; if (!cfg.pullbackReleasesTarget && s_destKind == RunGuideDest.Boss && !DSUtil.CheckNull(s_boss) && ReferenceEquals(t, s_boss)) return; LastIgnoredTarget = t.name; pc.Del_Target(true); FarTargetIgnored++; } /// 도착 뒤: 목적지에서 guideLeashRadius 밖의 타깃만 무시한다(가까운 적은 원본이 잡는다). 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++; } /// PC 를 목적지로 민다. 원본 이동 API(NavMeshAgent)만 쓴다. 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; } // 🔴 813w2: 타깃이 남아 있으면 Play_Run() 이 `Set_Path(타깃)`(MyActor.cs:335)으로 경로를 덮어쓴다. // 보통은 바로 위 ClearTravelTarget 이 타깃을 비웠으므로 그런 일이 없지만, 원본이 같은 프레임에 // 다시 잡는 경우가 있어 **타깃이 있으면 이 폴링에서 목적지를 다시 지정**해 우리 쪽이 이기게 한다. bool targeted = pc.isTarget; pc.WL_PlayRunNow(); agent.isStopped = false; agent.stoppingDistance = cfg.guideStopDistance; if (targeted || now >= s_nextRepath) { s_nextRepath = now + Mathf.Max(0f, cfg.repathIntervalSeconds); agent.speed = pc.Get_MoveSpeed(); agent.SetDestination(s_dest); Repaths++; } } /// 조종을 놓는다(우리가 1 로 만든 경우에만 0 으로 되돌린다). 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++; } // 813w2 §1-2: 재타깃은 Hold(아레나 안)에서만. 이동·되돌림 중에 보스를 타깃으로 걸면 // 원본 Play_Run() 이 Set_Path(보스)로 우리 목적지를 덮어써 되돌림이 무력해진다. // 락은 위에서 계속 유지되므로 「보스 생존 중 잡몹 타깃」은 이동 중에도 생기지 않는다. if (cfg.bossRetargetOnlyInHold && s_state != RunGuideState.Hold) return; 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; } /// 존 i 의 앵커. 스포너 노드를 못 찾았으면 1회 스캔하고, 그래도 없으면 SO 폴백. 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; } /// 씬의 MobControlMgr 를 1회 훑어 존 앵커를 채운다(런당 몇 회 · 매 틱 아님). 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(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]; } } } /// 앵커가 스포너 노드에서 온 것인가(진단). public static bool AnchorFromNode(int i) { return i >= 0 && i < kMaxZones && s_anchorFromNode[i]; } /// 캐시된 앵커(진단). 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); } /// 프로브용: 카운터·상태 초기화(구독은 유지). public static void ResetDiagnostics() { GuideBegun = Arrived = GaveUp = FarTargetIgnored = HoldPullbacks = 0; Repaths = BossRetargets = BossLocks = HitSuspends = AnchorScans = 0; ArrivedByStill = PullbacksWhileHit = 0; MaxDistFromDest = ZoneHoldSeconds = BossHoldSeconds = TravelSeconds = 0f; LastLog = LastIgnoredTarget = LastTickLog = ""; 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 = s_nextTickLog = 0f; s_lastPosTime = s_stillSince = s_lastAccumTime = -1f; s_lastPos = Vector3.zero; s_faulted = false; s_anchorCount = 0; } /// 프로브용: 조종 플래그를 직접 세운다(에디트 모드엔 NavMeshAgent 가 없어 Steer 가 되돌아간다). public static void SetSteeringForProbe(bool on) { s_steering = on; if (!on) { s_stillSince = -1f; s_lastPosTime = -1f; } } /// 프로브용: 상태를 직접 읽는다(정지 판정 내부 시각). public static float StillSince { get { return s_stillSince; } } /// 프로브용: 러너 없이 1회 폴링(강제 틱). public static void ForceTick() { s_nextPoll = 0f; Tick(); } /// 프로브용: 피격 응전 구간을 직접 건다. public static void SimulateHit() { var c = Cfg; if (c == null) return; float hold = s_destKind == RunGuideDest.Boss ? c.bossHitSuspendSeconds : c.resumeAfterHitSeconds; if (hold <= 0f) hold = 0.01f; // 프로브가 「피격 중」 상태를 만들 수 있게 최소값 s_hitUntil = Time.unscaledTime + hold; HitSuspends++; } /// 프로브용: 피격 응전 구간을 즉시 푼다. public static void ClearHitSuspendForProbe() { s_hitUntil = 0f; } /// 프로브용: 보스 참조를 직접 넣는다. public static void SetBossForProbe(Actor boss) { s_boss = boss; } /// 프로브용: 이동 시작 시각을 밀어 넣는다(에디트 모드에서는 Time 이 흐르지 않아 maxTravelSeconds 를 못 넘긴다). public static void SetTravelStartForProbe(float t) { s_travelStart = t; } // ───────────────────────────────────────────────────────────────────── // 런너 (숨김 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(); } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { if (!Active) return; // 꺼져 있으면 오브젝트 0 · 구독 0(C8) EnsureSubscribed(); EnsureRunner(); } } /// RunGuide 의 시간 축. 숨김 GameObject 1개 · 코루틴 0. internal sealed class RunGuideRunner : MonoBehaviour { void Update() { RunGuide.Tick(); } } }