// ───────────────────────────────────────────────────────────────────────────── // 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` 으로 스냅한 지점**을 넘긴다(지형 뚫기 방지). // // ■ 813t3 추가 (2026-09-11 · PD 「보스가 언덕에 생성된다」) — `SpawnPositionGuard` 에 **보스 평지 고정**을 넣었다. // 보스는 `FieldBossData`(MobControlMgr 가 아니다)가 스폰하므로 아래 ③ 스포너 노드 폴백이 **구조적으로 못 걸린다** // (`TryGetSpawnerNode` 캐시가 MobControlMgr 만 모은다 · 실측). → 보스는 등장 즉시 `BossArena.Center` 로 붙인다. // // ■ 값(C45) 전부 WLTerrainGuardSettings. 게임 수치 상수 0. // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(Actor/MobActor 가 Assembly-CSharp 에 있다). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using UnityEngine.AI; namespace WL.Combat.Nav { /// PC·몹·보스가 NavMesh 밖 또는 언덕 위에 머무르면 가장 가까운 NavMesh 위로 되돌린다. public static class TerrainGuard { static WLTerrainGuardSettings Cfg { get { return WLTerrainGuardSettings.Instance; } } /// 지형 가드 축이 살아 있는가. 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 s_badSince = new Dictionary(); static readonly Dictionary s_lastWarp = 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.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(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); } } /// 높이 규칙을 이 맵에 적용하는가. 다른 맵은 고지대가 정상 플레이 영역일 수 있다. 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 + ")"); } /// 가장 가까운 walkable 지점. 1차 반경 → 2차 반경. 언덕 위로 되돌리지 않는다. 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; } /// 되돌린 직후 카메라 이동 리드를 0 으로 스냅한다(811 CameraMoveLead.Snap · RealCamera 무수정). 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); } // ───────────────────────────────────────── 진단 · 초기화 /// 프로브용: 카운터·유예 창 초기화. 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; } /// 프로브용: 러너 없이 1회 검사(에디트 모드 · 강제 틱). public static void ForceTick() { s_nextRescan = s_nextCheck = 0f; Tick(); } /// 프로브용: 러너가 부르는 것과 똑같은 틱(타이머 초기화 0 — GC 측정용). public static void ProbeTick() { Tick(); } /// 프로브용: 임의 액터 1명을 지금 판정한다(유예·쿨 무시하고 즉시 복귀까지). 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; } } // ───────────────────────────────────────────────────────────────────────── /// 새로 나타난 몬스터가 이동 불가 지역에 서 있으면 walkable 지점으로 보정한다(발주서 §1-4). public static class SpawnPositionGuard { static WLTerrainGuardSettings Cfg { get { return WLTerrainGuardSettings.Instance; } } /// 스폰 보정 축이 살아 있는가. 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 int BossPinned, BossPinSkipped, BossPinFails; // 813t3 §1-2 public static string LastLog = ""; public static Vector3 LastFixFrom, LastFixTo; // ───────────────────────────────────────── 내부 상태 static MobActor[] s_mobs = new MobActor[0]; static readonly HashSet s_seen = new HashSet(); static readonly Dictionary s_spawnerNode = new Dictionary(); 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(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); } } /// 스폰 보정 결과. 0 = 원 위치가 이미 유효 · 1..n = n 번째 재표집 · −1 = 스포너 노드 · −2 = 실패. public const int ResultValid = 0, ResultNode = -1, ResultFailed = -2; /// /// 스폰 지점 판정 — 원 위치 → 재표집(반경 확대) → 스포너 노드 순. **좌표만 다루는 순수 함수**라 /// 프로브가 실 MobActor 없이 같은 코드를 그대로 검증할 수 있다(GC 0 · NavMeshHit 는 struct). /// 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; } // ───────────────────────────────────────────────────────────────────── // 813t3 §1-2 — 보스 평지 스폰 (PD 2026-09-11 「보스가 언덕에 생성된다」) // ───────────────────────────────────────────────────────────────────── /// 보스 고정 판정 결과 — 0 앵커 없음(손대지 않음) · 1 이미 제자리 · 2 옮긴다 · 3 앵커가 부적격. public const int BossPinNoAnchor = 0, BossPinAlready = 1, BossPinMoved = 2, BossPinFailed = 3; /// /// 보스를 고정할 지점 판정 — **좌표만 다루는 순수 함수**(프로브가 실 MobActor 없이 그대로 검증한다). /// 앵커 = `BossArena.Center`(SO `arenaCenter` · `useFieldBossPositionAsCenter = 0` 이면 스포너 노드가 아니라 이 값). /// 앵커를 NavMesh 에 스냅하고 813t2 의 높이 컷(maxWalkableY) 아래인지까지 본다. /// public static int ResolveBossPin(WLTerrainGuardSettings cfg, Vector3 pos, out Vector3 target) { target = pos; if (cfg == null || !cfg.bossSpawnPinEnabled) return BossPinNoAnchor; Vector3 anchor = WL.Combat.Boss.BossArena.Center; if (anchor == Vector3.zero) return BossPinNoAnchor; // 아레나를 모른다 = 원본 위치 그대로 NavMeshHit hit; if (!NavMesh.SamplePosition(anchor, out hit, Mathf.Max(0.1f, cfg.bossSpawnPinRadius), NavMesh.AllAreas)) return BossPinFailed; if (TerrainGuard.HeightRuleApplies(cfg) && hit.position.y > cfg.maxWalkableY) return BossPinFailed; target = hit.position; // 🔴 수평만 보면 「아레나 바로 위 공중/바위 위」(예: 보스 점프 착지 y 6.0)를 「제자리」로 놓친다(프로브 실측). // 수평·수직 둘 다 여유 안이어야 제자리로 본다. float dx = pos.x - target.x, dz = pos.z - target.z; float tol = Mathf.Max(0f, cfg.bossSpawnPinTolerance); if (Mathf.Sqrt(dx * dx + dz * dz) <= tol && Mathf.Abs(pos.y - target.y) <= tol) return BossPinAlready; return BossPinMoved; } /// 스폰 지점이 walkable 인지 확인하고, 아니면 재시도 → 스포너 노드 순으로 보정한다. internal static bool Validate(WLTerrainGuardSettings cfg, MobActor mob) { Checked++; Vector3 pos = mob.Get_position(); int spawnerId = mob.Get_Group(); // 813t3 §1-2 — 보스는 등장하는 순간 아레나 평지에 고정한다. // 🔴 아래 일반 경로의 ③ 스포너 노드 폴백은 보스에 **절대 안 걸린다**(실측) — // 노드 캐시(TryGetSpawnerNode)는 MobControlMgr 만 모으는데 보스 스포너는 FieldBossData 다. if (mob.IsSubRole(eSubRol.Boss)) { Vector3 pin; int r = ResolveBossPin(cfg, pos, out pin); if (r == BossPinMoved) { BossPinned++; Apply(mob, pos, pin, "보스 아레나 고정"); return true; } if (r == BossPinAlready) { BossPinSkipped++; return false; } if (r == BossPinFailed) BossPinFails++; // 앵커가 부적격 → 아래 일반 경로로 계속 } 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); } /// 스포너 ID → 노드 월드 좌표. 노드는 움직이지 않으므로 1회만 수집한다. public static bool TryGetSpawnerNode(int spawnerId, out Vector3 pos) { if (s_spawnerNode.Count == 0) { s_mgrs = Object.FindObjectsByType(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); } // ───────────────────────────────────────── 진단 · 초기화 /// 프로브용: 카운터·기억 초기화. public static void ResetDiagnostics() { Seen = Checked = Fixed = FixFailed = FellBackToNode = Scans = 0; BossPinned = BossPinSkipped = BossPinFails = 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; } /// 프로브용: 러너 없이 1회 검사. public static void ForceTick() { s_nextScan = 0f; Tick(); } /// 프로브용: 러너가 부르는 것과 똑같은 틱(타이머 초기화 0 — GC 측정용). public static void ProbeTick() { Tick(); } /// 프로브용: 스포너 노드 캐시를 프리팹 인스턴스 없이 직접 심는다(에디트 모드). public static void SeedSpawnerNodeForProbe(int spawnerId, Vector3 pos) { s_spawnerNode[spawnerId] = pos; } } // ───────────────────────────────────────────────────────────────────────── /// TerrainGuard · SpawnPositionGuard 의 시간 축. 숨김 GameObject 1개 · 코루틴 0. internal sealed class TerrainGuardRunner : MonoBehaviour { void Update() { TerrainGuard.Tick(); SpawnPositionGuard.Tick(); } } /// 러너 부팅(C8 — SO 가 꺼져 있으면 오브젝트 0). 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(); } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { if (WLTerrainGuardSettings.Active) EnsureRunner(); } } }