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

443 lines
22 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.

// ─────────────────────────────────────────────────────────────────────────────
// TerrainGuard.cs — 언덕(이동 불가 지형) 가드 + 스폰 위치 검증
//
// PD 지시 #813 추가 (2026-09-10) · 발주서 WL-813t2 §1-3 · §1-4
// 「언덕 지형은 플레이어나 몬스터가 갈 수 없도록 · 언덕을 뚫고 가는 상황 및 카메라 연출 꼬임 ·
// 이동 불가 지역에는 몬스터가 생성되어서도 안 됨」
//
// ■ 관측된 결함 (QA W2-12 D-48 실측)
// 보스전 중 PC 가 아레나 밖 지형으로 올라갔다 — `y 0.66 → 3.66(t=295.46) → 5.48(t=309.33)`.
// 그 뒤 보스 HP 가 `797.2` 15 s · `730.0` 14 s 동안 완전 무변 → 「시간 종료 · 미처치」.
// 813w4 가이드는 `ArenaExits=1` 로 이탈을 **판정은 했지만** `ArenaWarps=0` 으로 **되돌리지 못했다**
// (되돌릴 지점이 NavMesh 밖이면 워프를 포기하도록 813w3 이 설계돼 있다 — 지형 뚫기 방지).
//
// ■ 이 파일이 맡는 것 = 「올라간 뒤」의 복구. 「올라가는 것」 자체는 같은 커밋의 NavMesh 재베이크가 막는다.
// ① NavMesh 재베이크(WL_Nature.prefab 의 NavMeshModifierVolume · Not Walkable · Y[3, 45])
// → 언덕 위 walkable 면적 6044.6 → **0.0 m²**(실측). 걸어서는 못 올라간다.
// ② 그래도 넉백·점프(보스 스킬2 `Actor.Co_JumpToPosition` 은 `m_NavMeshAgent.enabled=false` 로 끄고
// 트랜스폼을 직접 옮긴다)로 올라갈 수 있다 → **이 파일**이 유예 뒤 가장 가까운 NavMesh 위로 되돌린다.
//
// ■ 원본 훅 0 — 폴링만 쓴다(MobNavRecovery(813x) · Survival.PollRunBoundary 선례).
// 🔴 `CombatEvents.Spawned` 를 쓰지 않는 이유(실측): `CombatEvents.RaiseSpawned`(CombatEvents.cs:302)
// 첫 줄이 `if (!Enabled …) return;` 이라 **코어 스위치가 꺼지면 이벤트가 0** 이다.
// 스폰 보정은 코어 A/B 중에도 살아 있어야 해서(같은 이유로 813x 도 코어와 무관하게 만들었다) 폴링으로 간다.
//
// ■ 되돌리는 방법 = 원본 `Actor.Set_Warp`(Actor.cs:2111) — 813i `DeathFlow.On_Regen → Set_Warp`,
// 813w3 `RunGuide.WarpToArena`, 813x `MobNavRecovery.Repair` 와 같은 API. 이펙트·잔상 0.
// PC 는 `Get_NavSnapRadius()=0`(Actor.cs:2133) 이라 Set_Warp 이 스스로 스냅하지 않는다
// → **반드시 먼저 `NavMesh.SamplePosition` 으로 스냅한 지점**을 넘긴다(지형 뚫기 방지).
//
// ■ 값(C45) 전부 WLTerrainGuardSettings. 게임 수치 상수 0.
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(Actor/MobActor 가 Assembly-CSharp 에 있다).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
namespace WL.Combat.Nav
{
/// <summary>PC·몹·보스가 NavMesh 밖 또는 언덕 위에 머무르면 가장 가까운 NavMesh 위로 되돌린다.</summary>
public static class TerrainGuard
{
static WLTerrainGuardSettings Cfg { get { return WLTerrainGuardSettings.Instance; } }
/// <summary>지형 가드 축이 살아 있는가.</summary>
public static bool Active
{
get { var c = Cfg; return c != null && c.enabled && c.terrainGuardEnabled && !WLTerrainGuardSettings.RuntimeDisabled; }
}
// ───────────────────────────────────────── 진단 카운터 (프로브가 읽는다)
public static int Warped, WarpFailed, Scans, Checks, AnomaliesSeen, SkippedCooldown;
public static string LastLog = "";
public static Vector3 LastWarpFrom, LastWarpTo;
public static string LastReason = "";
// ───────────────────────────────────────── 내부 상태(GC 0 — 틱에서 새로 만들지 않는다)
static MobActor[] s_mobs = new MobActor[0];
static readonly Dictionary<int, float> s_badSince = new Dictionary<int, float>();
static readonly Dictionary<int, float> s_lastWarp = new Dictionary<int, float>();
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.rescanSeconds);
Rescan();
}
if (now < s_nextCheck) return;
s_nextCheck = now + Mathf.Max(0.02f, cfg.checkSeconds);
// 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 모달로 띄운다(813x).
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_badSince.Count > 128) s_badSince.Clear(); // 풀 재사용으로 쌓인 죽은 키 정리
if (s_lastWarp.Count > 128) s_lastWarp.Clear();
}
static void CheckAll(WLTerrainGuardSettings cfg, float now)
{
bool heightRule = HeightRuleApplies(cfg);
if (cfg.guardPlayer)
{
var pc = MyValue.MyPC;
if (!DSUtil.CheckNull(pc)) CheckOne(cfg, pc, now, heightRule, true);
}
var boss = WL.Combat.Boss.BossArena.Boss;
if (cfg.guardBoss && !DSUtil.CheckNull(boss)) CheckOne(cfg, boss, now, heightRule, false);
if (!cfg.guardMobs) return;
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, heightRule, false);
}
}
/// <summary>높이 규칙을 이 맵에 적용하는가. 다른 맵은 고지대가 정상 플레이 영역일 수 있다.</summary>
public static bool HeightRuleApplies(WLTerrainGuardSettings cfg)
{
if (cfg.heightGuardAllMaps) return true;
var md = MyValue.MyChoiceMapData;
if (md == null) return cfg.heightGuardWhenMapUnknown;
var ids = cfg.heightGuardMapIds;
if (ids == null) return false;
for (int i = 0; i < ids.Length; i++) if (ids[i] == md.n_MapID) return true;
return false;
}
static void CheckOne(WLTerrainGuardSettings cfg, Actor actor, float now, bool heightRule, bool isPlayer)
{
int key = actor.GetInstanceID();
if (actor.IsDead()) { s_badSince.Remove(key); return; }
Checks++;
Vector3 pos = actor.Get_position();
NavMeshHit near;
bool onMesh = NavMesh.SamplePosition(pos, out near, Mathf.Max(0.05f, cfg.offNavMeshTolerance), NavMesh.AllAreas);
bool tooHigh = heightRule && pos.y > cfg.maxWalkableY;
if (onMesh && !tooHigh) { s_badSince.Remove(key); return; }
float since;
if (!s_badSince.TryGetValue(key, out since))
{
s_badSince[key] = now; // 점프·넉백일 수 있다 — 유예를 준다
AnomaliesSeen++;
return;
}
if (now - since < Mathf.Max(0f, cfg.dwellSeconds)) return;
float last;
if (s_lastWarp.TryGetValue(key, out last) && now - last < Mathf.Max(0f, cfg.warpCooldownSeconds))
{ SkippedCooldown++; return; }
WarpBack(cfg, actor, key, pos, now, isPlayer, tooHigh ? (onMesh ? "언덕(y " + pos.y.ToString("F2") + ")" : "언덕+NavMesh 밖") : "NavMesh 밖");
}
static void WarpBack(WLTerrainGuardSettings cfg, Actor actor, int key, Vector3 from, float now, bool isPlayer, string reason)
{
s_badSince.Remove(key); // 성공이든 실패든 창을 다시 연다(실패 시 다음 유예 뒤 재시도)
Vector3 target;
if (!FindGround(cfg, from, out target))
{
WarpFailed++;
Log("복귀 실패 — 반경 " + cfg.warpFarRadius + " m 안에 NavMesh 없음 " + from.ToString("F2") + " (" + reason + ")");
return;
}
var agent = actor.Get_Agent();
if (agent != null && agent.enabled && agent.isOnNavMesh) agent.ResetPath(); // 남은 경로 제거
actor.Set_Warp(target); // 원본 API · 이펙트 0 · PC 는 이미 스냅된 지점을 받는다
if (isPlayer && cfg.warpSnapsCameraLead) SnapCameraLead();
s_lastWarp[key] = now;
Warped++;
LastWarpFrom = from; LastWarpTo = target; LastReason = reason;
Log("복귀 #" + Warped + " " + actor.name + " " + from.ToString("F2") + " → " + target.ToString("F2") + " (" + reason + ")");
}
/// <summary>가장 가까운 walkable 지점. 1차 반경 → 2차 반경. 언덕 위로 되돌리지 않는다.</summary>
public static bool FindGround(WLTerrainGuardSettings cfg, Vector3 from, out Vector3 target)
{
target = from;
NavMeshHit hit;
if (NavMesh.SamplePosition(from, out hit, Mathf.Max(0.1f, cfg.warpNearRadius), NavMesh.AllAreas)
|| NavMesh.SamplePosition(from, out hit, Mathf.Max(0.1f, cfg.warpFarRadius), NavMesh.AllAreas))
{
target = hit.position;
return true;
}
return false;
}
/// <summary>되돌린 직후 카메라 이동 리드를 0 으로 스냅한다(811 CameraMoveLead.Snap · RealCamera 무수정).</summary>
static void SnapCameraLead()
{
var cam = MyValue.m_RealCamera;
if (DSUtil.CheckNull(cam)) return;
var lead = cam.Get_MoveLead();
if (lead != null) lead.Snap();
}
static void Log(string msg)
{
LastLog = msg;
var c = Cfg;
if (c != null && c.verboseLog) Debug.Log("[TerrainGuard] " + msg);
}
// ───────────────────────────────────────── 진단 · 초기화
/// <summary>프로브용: 카운터·유예 창 초기화.</summary>
public static void ResetDiagnostics()
{
Warped = WarpFailed = Scans = Checks = AnomaliesSeen = SkippedCooldown = 0;
LastLog = ""; LastReason = ""; LastWarpFrom = LastWarpTo = Vector3.zero;
s_badSince.Clear(); s_lastWarp.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(); }
/// <summary>프로브용: 러너가 부르는 것과 똑같은 틱(타이머 초기화 0 — GC 측정용).</summary>
public static void ProbeTick() { Tick(); }
/// <summary>프로브용: 임의 액터 1명을 지금 판정한다(유예·쿨 무시하고 즉시 복귀까지).</summary>
public static bool ForceCheck(Actor actor)
{
var cfg = Cfg;
if (cfg == null || actor == null) return false;
int key = actor.GetInstanceID();
Vector3 pos = actor.Get_position();
NavMeshHit near;
bool onMesh = NavMesh.SamplePosition(pos, out near, Mathf.Max(0.05f, cfg.offNavMeshTolerance), NavMesh.AllAreas);
bool tooHigh = HeightRuleApplies(cfg) && pos.y > cfg.maxWalkableY;
if (onMesh && !tooHigh) return false;
WarpBack(cfg, actor, key, pos, Time.unscaledTime, ReferenceEquals(actor, MyValue.MyPC),
tooHigh ? "언덕(y " + pos.y.ToString("F2") + ")" : "NavMesh 밖");
return true;
}
}
// ─────────────────────────────────────────────────────────────────────────
/// <summary>새로 나타난 몬스터가 이동 불가 지역에 서 있으면 walkable 지점으로 보정한다(발주서 §1-4).</summary>
public static class SpawnPositionGuard
{
static WLTerrainGuardSettings Cfg { get { return WLTerrainGuardSettings.Instance; } }
/// <summary>스폰 보정 축이 살아 있는가.</summary>
public static bool Active
{
get { var c = Cfg; return c != null && c.enabled && c.spawnGuardEnabled && !WLTerrainGuardSettings.RuntimeDisabled; }
}
// ───────────────────────────────────────── 진단 카운터
public static int Seen, Checked, Fixed, FixFailed, FellBackToNode, Scans;
public static string LastLog = "";
public static Vector3 LastFixFrom, LastFixTo;
// ───────────────────────────────────────── 내부 상태
static MobActor[] s_mobs = new MobActor[0];
static readonly HashSet<int> s_seen = new HashSet<int>();
static readonly Dictionary<int, Vector3> s_spawnerNode = new Dictionary<int, Vector3>();
static MobControlMgr[] s_mgrs = new MobControlMgr[0];
static float s_nextScan;
static bool s_faulted;
internal static void Tick()
{
if (s_faulted || !Active) return;
var cfg = Cfg;
float now = Time.unscaledTime;
if (now < s_nextScan) return;
s_nextScan = now + Mathf.Max(0.05f, cfg.spawnCheckSeconds);
try { ScanNew(cfg); }
catch (System.Exception ex) { s_faulted = true; Log("중단(예외) — " + ex.Message); }
}
static void ScanNew(WLTerrainGuardSettings cfg)
{
s_mobs = Object.FindObjectsByType<MobActor>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
Scans++;
if (s_seen.Count > 512) s_seen.Clear(); // 풀 재사용으로 쌓인 죽은 키 정리
for (int i = 0; i < s_mobs.Length; i++)
{
var m = s_mobs[i];
if (DSUtil.CheckNull(m) || m.IsDead()) continue;
int key = m.GetInstanceID();
if (!s_seen.Add(key)) continue; // 이미 본 개체
Seen++;
Validate(cfg, m);
}
}
/// <summary>스폰 보정 결과. 0 = 원 위치가 이미 유효 · 1..n = n 번째 재표집 · 1 = 스포너 노드 · 2 = 실패.</summary>
public const int ResultValid = 0, ResultNode = -1, ResultFailed = -2;
/// <summary>
/// 스폰 지점 판정 — 원 위치 → 재표집(반경 확대) → 스포너 노드 순. **좌표만 다루는 순수 함수**라
/// 프로브가 실 MobActor 없이 같은 코드를 그대로 검증할 수 있다(GC 0 · NavMeshHit 는 struct).
/// </summary>
public static int ResolveSpawnPoint(WLTerrainGuardSettings cfg, Vector3 pos, int spawnerId, out Vector3 target)
{
target = pos;
bool heightRule = TerrainGuard.HeightRuleApplies(cfg);
float baseR = Mathf.Max(0.1f, cfg.spawnSampleRadius);
NavMeshHit hit;
// ① 원 위치가 유효하면 아무것도 하지 않는다.
if (NavMesh.SamplePosition(pos, out hit, baseR, NavMesh.AllAreas)
&& !(heightRule && hit.position.y > cfg.maxWalkableY))
{ target = hit.position; return ResultValid; }
// ② 재시도 — 시도마다 반경을 1배씩 넓힌다(원본 MapData.spawnSampleTries 와 같은 뜻의 재시도).
int tries = Mathf.Max(0, cfg.spawnRetries);
for (int t = 1; t <= tries; t++)
{
if (!NavMesh.SamplePosition(pos, out hit, baseR * (t + 1), NavMesh.AllAreas)) continue;
if (heightRule && hit.position.y > cfg.maxWalkableY) continue;
target = hit.position; return t;
}
// ③ 스포너 노드 중심.
if (cfg.spawnFallbackToSpawnerNode)
{
Vector3 node;
if (TryGetSpawnerNode(spawnerId, out node)
&& NavMesh.SamplePosition(node, out hit, baseR, NavMesh.AllAreas)
&& !(heightRule && hit.position.y > cfg.maxWalkableY))
{ target = hit.position; return ResultNode; }
}
return ResultFailed;
}
/// <summary>스폰 지점이 walkable 인지 확인하고, 아니면 재시도 → 스포너 노드 순으로 보정한다.</summary>
internal static bool Validate(WLTerrainGuardSettings cfg, MobActor mob)
{
Checked++;
Vector3 pos = mob.Get_position();
int spawnerId = mob.Get_Group();
Vector3 target;
int how = ResolveSpawnPoint(cfg, pos, spawnerId, out target);
if (how == ResultValid) return false;
if (how == ResultFailed)
{
FixFailed++;
Log("보정 실패 " + mob.name + " " + pos.ToString("F2"));
return false;
}
if (how == ResultNode) FellBackToNode++;
Apply(mob, pos, target, how == ResultNode ? "스포너 노드 " + spawnerId : "재표집 #" + how);
return true;
}
static void Apply(MobActor mob, Vector3 from, Vector3 to, string how)
{
var agent = mob.Get_Agent();
if (agent != null && agent.enabled && agent.isOnNavMesh) agent.ResetPath();
mob.Set_Warp(to);
Fixed++;
LastFixFrom = from; LastFixTo = to;
Log("보정 #" + Fixed + " " + mob.name + " " + from.ToString("F2") + " → " + to.ToString("F2") + " · " + how);
}
/// <summary>스포너 ID → 노드 월드 좌표. 노드는 움직이지 않으므로 1회만 수집한다.</summary>
public static bool TryGetSpawnerNode(int spawnerId, out Vector3 pos)
{
if (s_spawnerNode.Count == 0)
{
s_mgrs = Object.FindObjectsByType<MobControlMgr>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < s_mgrs.Length; i++)
{
var g = s_mgrs[i];
if (DSUtil.CheckNull(g)) continue;
s_spawnerNode[g.SpawnerID] = g.transform.position;
}
}
return s_spawnerNode.TryGetValue(spawnerId, out pos);
}
static void Log(string msg)
{
LastLog = msg;
var c = Cfg;
if (c != null && c.verboseLog) Debug.Log("[SpawnPositionGuard] " + msg);
}
// ───────────────────────────────────────── 진단 · 초기화
/// <summary>프로브용: 카운터·기억 초기화.</summary>
public static void ResetDiagnostics()
{
Seen = Checked = Fixed = FixFailed = FellBackToNode = Scans = 0;
LastLog = ""; LastFixFrom = LastFixTo = Vector3.zero;
s_seen.Clear(); s_spawnerNode.Clear(); s_mobs = new MobActor[0]; s_mgrs = new MobControlMgr[0];
s_nextScan = 0f; s_faulted = false;
}
/// <summary>프로브용: 러너 없이 1회 검사.</summary>
public static void ForceTick() { s_nextScan = 0f; Tick(); }
/// <summary>프로브용: 러너가 부르는 것과 똑같은 틱(타이머 초기화 0 — GC 측정용).</summary>
public static void ProbeTick() { Tick(); }
/// <summary>프로브용: 스포너 노드 캐시를 프리팹 인스턴스 없이 직접 심는다(에디트 모드).</summary>
public static void SeedSpawnerNodeForProbe(int spawnerId, Vector3 pos) { s_spawnerNode[spawnerId] = pos; }
}
// ─────────────────────────────────────────────────────────────────────────
/// <summary>TerrainGuard · SpawnPositionGuard 의 시간 축. 숨김 GameObject 1개 · 코루틴 0.</summary>
internal sealed class TerrainGuardRunner : MonoBehaviour
{
void Update()
{
TerrainGuard.Tick();
SpawnPositionGuard.Tick();
}
}
/// <summary>러너 부팅(C8 — SO 가 꺼져 있으면 오브젝트 0).</summary>
public static class TerrainGuardBoot
{
static TerrainGuardRunner s_runner;
internal static void EnsureRunner()
{
if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return;
var go = new GameObject("[WL813t2] TerrainGuardRunner");
go.hideFlags = HideFlags.HideAndDontSave; // 씬 전환에도 살아남고 씬·프리팹에 저장되지 않는다
s_runner = go.AddComponent<TerrainGuardRunner>();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
if (WLTerrainGuardSettings.Active) EnsureRunner();
}
}
}