220 lines
12 KiB
C#
220 lines
12 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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(); }
|
||
}
|
||
}
|