// ───────────────────────────────────────────────────────────────────────────── // MobNavRecovery.cs — 몬스터 NavMeshAgent 이탈 복구 (Q3 D-1 / Q2 D-4 근본 수정) // // PD 지시 #813 · 발주서 WL-813x §1-1 (2026-09-09) · 근거 = qa/W2-3_바퀴2.md D-1 · qa/W2-2_스모크.md D-4 // // ■ 관측된 결함 (QA 실측) // 플레이 중 화면 중앙에 모달 에러 팝업 — // `"Resume" can only be called on an active agent that has been placed on a NavMesh.` // 보스(Anubis)가 Skill2 를 쓰는 순간 발생하고, 한 번 뜬 뒤로는 세션 끝까지 반복 출력된다. // // ■ 원인 사슬 (코드 실측) // ① Boss_Anubis.Co_Skill2 → Co_ShootSkill2 가 JumpToPosition(Get_PositionBack/Right/Foward(5f), onNavMesh:false) // 로 3회 점프한다(Boss_Anubis.cs:140). // ② Actor.Co_JumpToPosition(Actor.cs:1867) 은 점프 동안 `m_NavMeshAgent.enabled = false` 로 끄고 // 트랜스폼을 직접 옮긴 뒤 다시 켠다. 착지 보정은 Snap_toNavMesh() = 맵 데이터 mobNavSnapRadius(WL_Nature = 2 m) // 뿐이라, 착지 지점이 NavMesh 에서 2 m 넘게 벗어나면 스냅이 실패하고 에이전트는 **켜졌지만 NavMesh 밖**으로 남는다. // (Q3 D-4 실측 — 이 보스는 아레나(139,−20) 를 벗어나 (128,−9) · 존2(147,30) 까지 나가 있었다.) // ③ 다음 Co_Skill2 첫 줄의 Change_MobStatus(eMobStatus.Skill) → MobActor.cs:339 이 가드 없이 // `m_NavMeshAgent.isStopped = false`(= Resume) 를 대입 → 예외. // ④ Util/ErrorLogHookManager.cs:26-35 가 모든 Error/Exception 을 Popup.Ins.Set(ePopupType.One, 174, …) 로 // 플레이어 팝업으로 띄운다(#if !FGB_LIVE) → 화면 중앙 모달. // // ■ 이 파일이 고치는 것 = ② // MobActor.cs:339 의 1줄 가드(같은 커밋)는 **예외만** 막는다. 그 가드만으로는 보스가 NavMesh 밖에 굳은 채 // 영영 못 움직이는 상태(추격·순찰 정지)가 남는다. 여기서 그 상태 자체를 되돌린다 — // 에이전트가 꺼졌거나 NavMesh 밖인 몬스터를 유예 시간 뒤 가장 가까운 NavMesh 위로 Set_Warp 한다. // // ■ 원본 훅 0 — 폴링만 쓴다(Survival.PollRunBoundary · BossArenaRunner 선례). // 유예(graceSeconds)를 두는 이유: 점프·넉백 중에는 원본이 의도적으로 에이전트를 끈다. // 그 구간을 건드리면 점프가 깨진다. 실측 = Anubis 점프 0.25 s · 기본 유예 1.5 s. // // ■ 값(C45) 전부 WLCombatCoreSettings(에셋 Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset). // 게임 수치 상수 0. mobNavRecoveryEnabled = 0 이면 러너 자체가 뜨지 않는다(C8 롤백). // 🔴 코어 스위치(WLCombatCoreSettings.Enabled)에는 의존하지 않는다 — 코어를 끈 A/B 중에도 팝업이 되살아나면 안 된다. // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(Actor/MobActor 가 Assembly-CSharp 에 있다). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; using WL.Combat.Core; namespace WL.Combat.Nav { /// 몬스터 NavMeshAgent 이탈 복구. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개). public static class MobNavRecovery { static WLCombatCoreSettings Cfg { get { return WLCombatCoreSettings.Instance; } } /// 기능이 살아 있는가. 에셋이 없거나 플래그가 꺼져 있으면 false = 원본 동작 100%. public static bool Active { get { var c = Cfg; return c != null && c.mobNavRecoveryEnabled && !RuntimeDisabled; } } /// 진단·A/B 전용 런타임 스위치(WLHitFeel.RuntimeDisabled 선례). Play 종료 시 도메인 리로드로 자동 false. public static bool RuntimeDisabled; // ───────────────────────────────────────── 진단 카운터 (프로브가 읽는다) public static int Repaired, RepairFailed, Scans, AnomaliesSeen; public static string LastLog = ""; public static Vector3 LastRepairFrom, LastRepairTo; // ───────────────────────────────────────── 내부 상태 static MobActor[] s_mobs = new MobActor[0]; static readonly Dictionary s_anomalySince = new Dictionary(); static float s_nextRescan, s_nextCheck; static bool s_faulted; // ───────────────────────────────────────────────────────────────────── // 틱 // ───────────────────────────────────────────────────────────────────── /// 런너가 매 프레임 부른다(에디트 모드에서는 프로브가 직접 부를 수 있다). internal static void Tick() { if (s_faulted || !Active) return; var cfg = Cfg; float now = Time.unscaledTime; if (now >= s_nextRescan) { s_nextRescan = now + Mathf.Max(0.1f, cfg.mobNavRecoveryRescanSeconds); Rescan(); } if (now < s_nextCheck) return; s_nextCheck = now + Mathf.Max(0.02f, cfg.mobNavRecoveryCheckSeconds); // 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 다시 플레이어 팝업으로 띄운다. // 이 파일의 목적이 그 팝업을 없애는 것이므로, 사고가 나면 조용히 자기 자신만 끈다(원본 동작으로 복귀). try { CheckAll(cfg, now); } catch (System.Exception ex) { s_faulted = true; Log("중단(예외) — " + ex.Message); } } static void Rescan() { // FindObjectsInactive.Exclude — 비활성 몹은 풀에 들어간 사체다(Actor.cs:633 에서 에이전트를 끈다). s_mobs = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); Scans++; if (s_anomalySince.Count > 64) s_anomalySince.Clear(); // 풀 재사용으로 쌓인 죽은 키 정리 } static void CheckAll(WLCombatCoreSettings cfg, float now) { // 보스는 목록 갱신(3 s)을 기다리지 않고 매 검사마다 본다 — 결함의 실제 피해자다. var boss = WL.Combat.Boss.BossArena.Boss; if (!DSUtil.CheckNull(boss)) CheckOne(cfg, boss, now); var mobs = s_mobs; for (int i = 0; i < mobs.Length; i++) { var m = mobs[i]; if (DSUtil.CheckNull(m) || ReferenceEquals(m, boss)) continue; CheckOne(cfg, m, now); } } static void CheckOne(WLCombatCoreSettings cfg, MobActor mob, float now) { if (mob.IsDead()) { s_anomalySince.Remove(mob.GetInstanceID()); return; } var agent = mob.Get_Agent(); if (DSUtil.CheckNull(agent)) return; int key = mob.GetInstanceID(); // 정상 = 켜져 있고 NavMesh 위. isOnNavMesh 는 꺼진 에이전트에도 false 를 준다. if (agent.enabled && agent.isOnNavMesh) { s_anomalySince.Remove(key); return; } float since; if (!s_anomalySince.TryGetValue(key, out since)) { s_anomalySince[key] = now; // 점프·넉백일 수 있다 — 유예를 준다 AnomaliesSeen++; return; } if (now - since < Mathf.Max(0f, cfg.mobNavRecoveryGraceSeconds)) return; Repair(cfg, mob, agent, key); } static void Repair(WLCombatCoreSettings cfg, MobActor mob, NavMeshAgent agent, int key) { Vector3 from = mob.Get_position(); NavMeshHit hit; bool found = NavMesh.SamplePosition(from, out hit, Mathf.Max(0.1f, cfg.mobNavRecoveryNearRadius), NavMesh.AllAreas) || NavMesh.SamplePosition(from, out hit, Mathf.Max(0.1f, cfg.mobNavRecoveryFarRadius), NavMesh.AllAreas); s_anomalySince.Remove(key); // 성공이든 실패든 창을 다시 연다(실패 시 다음 유예 뒤 재시도) if (!found) { RepairFailed++; Log("복구 실패 — 반경 " + cfg.mobNavRecoveryFarRadius + " m 안에 NavMesh 없음 " + from); return; } // Set_Warp 이 에이전트를 다시 켜고(Actor.cs:2116) 맵 스냅 반경을 한 번 더 적용한 뒤 Warp 한다 — 이동 로직 복제 0. mob.Set_Warp(hit.position); Repaired++; LastRepairFrom = from; LastRepairTo = hit.position; Log("복구 #" + Repaired + " " + mob.name + " " + from + " → " + hit.position); } static void Log(string msg) { LastLog = msg; var c = Cfg; if (c != null && c.verboseLog) Debug.Log("[MobNavRecovery] " + msg); } // ───────────────────────────────────────────────────────────────────── // 진단 · 초기화 // ───────────────────────────────────────────────────────────────────── /// 프로브용: 카운터·유예 창 초기화. public static void ResetDiagnostics() { Repaired = RepairFailed = Scans = AnomaliesSeen = 0; LastLog = ""; LastRepairFrom = LastRepairTo = Vector3.zero; s_anomalySince.Clear(); s_mobs = new MobActor[0]; s_nextRescan = s_nextCheck = 0f; s_faulted = false; } /// 프로브용: 러너 없이 1회 검사(에디트 모드 · 강제 틱). public static void ForceTick() { s_nextRescan = s_nextCheck = 0f; Tick(); } // ───────────────────────────────────────────────────────────────────── // 런너 (숨김 GameObject 1개 · 플레이 모드 전용) // ───────────────────────────────────────────────────────────────────── static MobNavRecoveryRunner s_runner; internal static void EnsureRunner() { if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return; var go = new GameObject("[WL813x] MobNavRecoveryRunner"); go.hideFlags = HideFlags.HideAndDontSave; // 씬 전환에도 살아남고 씬·프리팹에 저장되지 않는다 s_runner = go.AddComponent(); } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { if (Active) EnsureRunner(); // 꺼져 있으면 오브젝트 0(C8) } } /// MobNavRecovery 의 시간 축. 숨김 GameObject 1개 · 코루틴 0. internal sealed class MobNavRecoveryRunner : MonoBehaviour { void Update() { MobNavRecovery.Tick(); } } }