Project_WL/Assets/WL/Combat/Nav/MobNavRecovery.cs

220 lines
12 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.

// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// <summary>몬스터 NavMeshAgent 이탈 복구. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개).</summary>
public static class MobNavRecovery
{
static WLCombatCoreSettings Cfg { get { return WLCombatCoreSettings.Instance; } }
/// <summary>기능이 살아 있는가. 에셋이 없거나 플래그가 꺼져 있으면 false = 원본 동작 100%.</summary>
public static bool Active
{
get { var c = Cfg; return c != null && c.mobNavRecoveryEnabled && !RuntimeDisabled; }
}
/// <summary>진단·A/B 전용 런타임 스위치(WLHitFeel.RuntimeDisabled 선례). Play 종료 시 도메인 리로드로 자동 false.</summary>
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<int, float> s_anomalySince = new Dictionary<int, float>();
static float s_nextRescan, s_nextCheck;
static bool s_faulted;
// ─────────────────────────────────────────────────────────────────────
// 틱
// ─────────────────────────────────────────────────────────────────────
/// <summary>런너가 매 프레임 부른다(에디트 모드에서는 프로브가 직접 부를 수 있다).</summary>
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<MobActor>(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);
}
// ─────────────────────────────────────────────────────────────────────
// 진단 · 초기화
// ─────────────────────────────────────────────────────────────────────
/// <summary>프로브용: 카운터·유예 창 초기화.</summary>
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;
}
/// <summary>프로브용: 러너 없이 1회 검사(에디트 모드 · 강제 틱).</summary>
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<MobNavRecoveryRunner>();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
if (Active) EnsureRunner(); // 꺼져 있으면 오브젝트 0(C8)
}
}
/// <summary>MobNavRecovery 의 시간 축. 숨김 GameObject 1개 · 코루틴 0.</summary>
internal sealed class MobNavRecoveryRunner : MonoBehaviour
{
void Update() { MobNavRecovery.Tick(); }
}
}