// ─────────────────────────────────────────────────────────────────────────────
// 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(); }
}
}