diff --git a/AgentScripts/WL813t2_Guard.cs b/AgentScripts/WL813t2_Guard.cs new file mode 100644 index 000000000..5cec429b2 --- /dev/null +++ b/AgentScripts/WL813t2_Guard.cs @@ -0,0 +1,259 @@ +// 발주서 WL-813t2 — 지형 가드 · 스폰 위치 검증 프로브 (에디트 모드 · Play 0 · 로그인 0) +// +// 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기) +// SO 생성 : unity command --project-path E:\NerdNavis\WL_wt\systems run_script --file AgentScripts/WL813t2_Guard.cs --entry WL813t2_Guard.MakeSettings --timeout 300 +// 프로브 : … --entry WL813t2_Guard.Probe --timeout 900 +// +// 프로브가 재는 것 — 전부 읽기 전용(에셋 수정 0): +// ① SO 실값(에셋에서 읽는다 · 코드 상수 대조 0) +// ② 언덕 격자 1000점 — 재베이크 뒤 walkable 0 인지(SamplePosition) +// ③ 되돌릴 지점 찾기 — D-48 실좌표(아레나 139/−20 위 y 3.66 · 5.48)에서 FindGround → y ≤ 2.96 +// ④ 스폰 보정 경로 — 언덕 위 가짜 스폰점 200개에 §1-4 규약(재표집 3회 → 스포너 노드) 적용 +// ⑤ C8 — enabled=0 · terrainGuardEnabled=0 · spawnGuardEnabled=0 · RuntimeDisabled 진리표 +// ⑥ GC — 정상 상태 틱 1000회의 GC 증가분 + +public static class WL813t2_Guard +{ + const string kSoPath = "Assets/WL/Combat/Settings/Resources/WL/WLTerrainGuardSettings.asset"; + const string kNavAsset = "Assets/WL/Settings/WL_Nature_NavMesh.asset"; + const string kMapPrefab= "Assets/Res_Addr/Map/WL_Nature.prefab"; + const string kOutDir = "AgentScripts/staging/WL813t2"; + + static System.Text.StringBuilder s; + static void L(string t) { s.AppendLine(t); UnityEngine.Debug.Log("[WL813t2] " + t); } + + static string Abs(string rel) + { + return System.IO.Path.Combine(System.IO.Directory.GetCurrentDirectory(), + rel.Replace('/', System.IO.Path.DirectorySeparatorChar)); + } + + static string Flush(string path) + { + try + { + System.IO.Directory.CreateDirectory(Abs(kOutDir)); + System.IO.File.WriteAllText(Abs(path), s.ToString(), new System.Text.UTF8Encoding(true)); + } + catch (System.Exception e) { UnityEngine.Debug.LogWarning("[WL813t2] 로그 저장 실패: " + e.Message); } + return s.ToString(); + } + + // ───────────────────────────────────────────────────────────────────────── + public static string MakeSettings() + { + s = new System.Text.StringBuilder(); + var existing = UnityEditor.AssetDatabase.LoadAssetAtPath(kSoPath); + if (existing != null) { L("이미 있음: " + kSoPath); return Flush(kOutDir + "/SO.txt"); } + + var so = UnityEngine.ScriptableObject.CreateInstance(); + // 값은 클래스 초기값(= 발주서 §1-3 · §1-4 값)을 그대로 굳힌다 — 여기서 새 수치를 만들지 않는다. + UnityEditor.AssetDatabase.CreateAsset(so, kSoPath); + UnityEditor.AssetDatabase.SaveAssets(); + UnityEditor.AssetDatabase.ImportAsset(kSoPath, UnityEditor.ImportAssetOptions.ForceUpdate); + L("생성: " + kSoPath); + return Flush(kOutDir + "/SO.txt"); + } + + // ───────────────────────────────────────────────────────────────────────── + public static string Probe() + { + s = new System.Text.StringBuilder(); + L("===== WL-813t2 가드 프로브 (" + System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + ") ====="); + + WL.Combat.Nav.WLTerrainGuardSettings.ClearCache(); + var cfg = UnityEngine.Resources.Load( + WL.Combat.Nav.WLTerrainGuardSettings.ResourcesPath); + if (cfg == null) { L("[에러] SO 로드 실패 (" + kSoPath + ")"); return Flush(kOutDir + "/PROBE.txt"); } + + // ① SO 실값 + L(""); + L("── ① SO 실값 (에셋에서 읽음) ──"); + L(" enabled=" + B(cfg.enabled) + " verboseLog=" + B(cfg.verboseLog) + + " check=" + cfg.checkSeconds + "s rescan=" + cfg.rescanSeconds + "s"); + L(" [지형] terrainGuardEnabled=" + B(cfg.terrainGuardEnabled) + " maxWalkableY=" + cfg.maxWalkableY + + " offNavMeshTolerance=" + cfg.offNavMeshTolerance + " dwell=" + cfg.dwellSeconds + "s cool=" + cfg.warpCooldownSeconds + "s" + + " near=" + cfg.warpNearRadius + " far=" + cfg.warpFarRadius); + L(" [지형] guardPlayer=" + B(cfg.guardPlayer) + " guardMobs=" + B(cfg.guardMobs) + " guardBoss=" + B(cfg.guardBoss) + + " warpSnapsCameraLead=" + B(cfg.warpSnapsCameraLead)); + L(" [범위] heightGuardMapIds=[" + Join(cfg.heightGuardMapIds) + "] allMaps=" + B(cfg.heightGuardAllMaps) + + " whenUnknown=" + B(cfg.heightGuardWhenMapUnknown) + " (WL_Nature = BattleMapConfig n_MapID -1)"); + L(" [스폰] spawnGuardEnabled=" + B(cfg.spawnGuardEnabled) + " check=" + cfg.spawnCheckSeconds + "s" + + " sampleRadius=" + cfg.spawnSampleRadius + " retries=" + cfg.spawnRetries + + " fallbackToNode=" + B(cfg.spawnFallbackToSpawnerNode)); + + var navData = UnityEditor.AssetDatabase.LoadAssetAtPath(kNavAsset); + var root = UnityEditor.AssetDatabase.LoadAssetAtPath(kMapPrefab); + if (navData == null || root == null) { L("[에러] NavMesh/프리팹 로드 실패"); return Flush(kOutDir + "/PROBE.txt"); } + var inst = UnityEngine.AI.NavMesh.AddNavMeshData(navData); + try + { + // ② 언덕 격자 1000점 + L(""); + L("── ② 언덕 격자 — 재베이크 뒤 walkable 0 인지 ──"); + // 813t2 실측 고지대 클러스터 C1(X 96~160 · Z −100~−16 · 최고 y 20.46)이 D-48 현장(아레나 139/−20 옆)이다. + UnityEngine.Random.InitState(8132); + int n = 1000, walkable = 0, aboveMax = 0; + float hiY = -1e9f; + for (int i = 0; i < n; i++) + { + float x = UnityEngine.Random.Range(96f, 160f); + float z = UnityEngine.Random.Range(-100f, -16f); + float y = UnityEngine.Random.Range(3.0f, 20.0f); // 언덕 높이대 + UnityEngine.AI.NavMeshHit h; + if (!UnityEngine.AI.NavMesh.SamplePosition(new UnityEngine.Vector3(x, y, z), out h, 1.0f, UnityEngine.AI.NavMesh.AllAreas)) continue; + walkable++; + if (h.position.y > hiY) hiY = h.position.y; + if (h.position.y > cfg.maxWalkableY) aboveMax++; + } + L(" 클러스터 C1(X96~160 · Z−100~−16 · y 3~20) 1000점 · 반경 1 m 스냅 성공 " + walkable + + " · maxWalkableY(" + cfg.maxWalkableY + ") 초과 " + aboveMax + + (walkable > 0 ? " · 최고 스냅 y " + hiY.ToString("F2") : "")); + + // 맵 전역 y>3 walkable 이 정말 0 인지 한 번 더(삼각형 기준은 Bake 프로브가 이미 0.0 을 실측) + var tri = UnityEngine.AI.NavMesh.CalculateTriangulation(); + float top = -1e9f; + for (int i = 0; i < tri.vertices.Length; i++) if (tri.vertices[i].y > top) top = tri.vertices[i].y; + L(" NavMesh 정점 최고 y = " + top.ToString("F2") + " (maxWalkableY " + cfg.maxWalkableY + " 아래여야 한다)"); + + // ③ 되돌릴 지점 찾기 — D-48 실좌표 + L(""); + L("── ③ FindGround — D-48 실좌표에서 되돌릴 지점 ──"); + UnityEngine.Vector3[] cases = { + new UnityEngine.Vector3(139f, 3.66f, -20f), // D-48 R2 t=295.46 + new UnityEngine.Vector3(139f, 5.48f, -20f), // D-48 R2 t=309.33 + new UnityEngine.Vector3(139f, 5.27f, -20f), // D-48 결과창 방치 74 s + new UnityEngine.Vector3(128f, 8.00f, -58f), // 클러스터 C1 중심 상공 + new UnityEngine.Vector3(147f, 6.00f, 30f), // 존2 상공 + }; + for (int i = 0; i < cases.Length; i++) + { + UnityEngine.Vector3 got; + bool ok = WL.Combat.Nav.TerrainGuard.FindGround(cfg, cases[i], out got); + L(" " + cases[i].ToString("F2") + " → " + (ok ? got.ToString("F2") + " · Δy " + (cases[i].y - got.y).ToString("F2") + + " · 수평 " + UnityEngine.Vector2.Distance(new UnityEngine.Vector2(cases[i].x, cases[i].z), new UnityEngine.Vector2(got.x, got.z)).ToString("F2") + " m" + + (got.y <= cfg.maxWalkableY ? " · PASS" : " · **FAIL(언덕 위)**") + : "실패(반경 " + cfg.warpFarRadius + " m 안에 NavMesh 없음)")); + } + + // ④ 스폰 보정 경로 — 실제 SpawnPositionGuard.ResolveSpawnPoint 를 그대로 부른다(로직 복제 0) + L(""); + L("── ④ 스폰 보정 경로 — 언덕 위 가짜 스폰점 200개 (SpawnPositionGuard.ResolveSpawnPoint 실호출) ──"); + var mgrs = root.GetComponentsInChildren(true); + for (int i = 0; i < mgrs.Length; i++) // 에디트 모드에는 씬 인스턴스가 없다 → 노드 캐시를 직접 심는다 + WL.Combat.Nav.SpawnPositionGuard.SeedSpawnerNodeForProbe(mgrs[i].SpawnerID, mgrs[i].transform.position); + UnityEngine.Random.InitState(8133); + int okStep1 = 0, okRetry = 0, okNode = 0, fail = 0, resultAbove = 0; + for (int i = 0; i < 200; i++) + { + var mgr = mgrs[i % mgrs.Length]; + var origin = mgr.transform.position; + // 스포너 반경 안의 랜덤 XZ 를 언덕 높이(y 6)로 들어올린 가짜 스폰점 + var off = UnityEngine.Random.insideUnitCircle * mgr.MobGenRange; + var p = new UnityEngine.Vector3(origin.x + off.x, 6f, origin.z + off.y); + + UnityEngine.Vector3 res; + int how = WL.Combat.Nav.SpawnPositionGuard.ResolveSpawnPoint(cfg, p, mgr.SpawnerID, out res); + if (how == WL.Combat.Nav.SpawnPositionGuard.ResultValid) okStep1++; + else if (how == WL.Combat.Nav.SpawnPositionGuard.ResultNode) okNode++; + else if (how == WL.Combat.Nav.SpawnPositionGuard.ResultFailed) { fail++; continue; } + else okRetry++; + if (res.y > cfg.maxWalkableY) resultAbove++; + } + L(" 200점 · 원위치 유효 " + okStep1 + " · 재표집 " + okRetry + " · 스포너 노드 폴백 " + okNode + + " · 보정 실패 " + fail + " · **보정 결과가 언덕 위 " + resultAbove + "**"); + + // 스포너 노드 자체가 전부 walkable 이고 언덕 아래인지 + int nodeOk = 0, nodeHigh = 0; + for (int i = 0; i < mgrs.Length; i++) + { + UnityEngine.AI.NavMeshHit h; + if (!UnityEngine.AI.NavMesh.SamplePosition(mgrs[i].transform.position, out h, cfg.spawnSampleRadius, UnityEngine.AI.NavMesh.AllAreas)) continue; + nodeOk++; + if (h.position.y > cfg.maxWalkableY) { nodeHigh++; L(" [경고] 스포너 " + mgrs[i].SpawnerID + " 노드가 언덕 위 y=" + h.position.y.ToString("F2")); } + } + L(" 스포너 노드 " + mgrs.Length + "기 · walkable " + nodeOk + " · 언덕 위 " + nodeHigh + " (0 이어야 폴백이 안전하다)"); + } + finally { UnityEngine.AI.NavMesh.RemoveNavMeshData(inst); } + + // ⑤ C8 진리표 — 임시 SO 로만 실험한다(실에셋 무수정) + L(""); + L("── ⑤ C8 진리표 (임시 SO · 실에셋 무수정) ──"); + var real = cfg; + try + { + L(" 실에셋 → TerrainGuard.Active=" + B(WL.Combat.Nav.TerrainGuard.Active) + + " · SpawnPositionGuard.Active=" + B(WL.Combat.Nav.SpawnPositionGuard.Active)); + var tmp = UnityEngine.ScriptableObject.CreateInstance(); + WL.Combat.Nav.WLTerrainGuardSettings.SetInstanceForProbe(tmp); + tmp.enabled = false; + L(" enabled=0 → " + B(WL.Combat.Nav.TerrainGuard.Active) + " / " + B(WL.Combat.Nav.SpawnPositionGuard.Active) + " (기대 false/false)"); + tmp.enabled = true; tmp.terrainGuardEnabled = false; + L(" terrainGuard=0 → " + B(WL.Combat.Nav.TerrainGuard.Active) + " / " + B(WL.Combat.Nav.SpawnPositionGuard.Active) + " (기대 false/true)"); + tmp.terrainGuardEnabled = true; tmp.spawnGuardEnabled = false; + L(" spawnGuard=0 → " + B(WL.Combat.Nav.TerrainGuard.Active) + " / " + B(WL.Combat.Nav.SpawnPositionGuard.Active) + " (기대 true/false)"); + tmp.spawnGuardEnabled = true; WL.Combat.Nav.WLTerrainGuardSettings.RuntimeDisabled = true; + L(" RuntimeDisabled → " + B(WL.Combat.Nav.TerrainGuard.Active) + " / " + B(WL.Combat.Nav.SpawnPositionGuard.Active) + " (기대 false/false)"); + WL.Combat.Nav.WLTerrainGuardSettings.RuntimeDisabled = false; + WL.Combat.Nav.WLTerrainGuardSettings.SetInstanceForProbe(null); + L(" 에셋 없음 → " + B(WL.Combat.Nav.TerrainGuard.Active) + " / " + B(WL.Combat.Nav.SpawnPositionGuard.Active) + " (기대 false/false)"); + UnityEngine.Object.DestroyImmediate(tmp); + + // ⑥ GC — 정상 상태 틱 + 실제 판정 경로(FindGround · ResolveSpawnPoint) 각 1000회 + L(""); + L("── ⑥ GC — 틱 1000회 + 판정 경로 1000회 ──"); + var g = UnityEngine.ScriptableObject.CreateInstance(); + g.rescanSeconds = 100000f; g.spawnCheckSeconds = 100000f; + WL.Combat.Nav.WLTerrainGuardSettings.SetInstanceForProbe(g); + WL.Combat.Nav.TerrainGuard.ResetDiagnostics(); + WL.Combat.Nav.SpawnPositionGuard.ResetDiagnostics(); + WL.Combat.Nav.TerrainGuard.ForceTick(); // 1회는 재수집(할당) — 이후가 정상 상태 + WL.Combat.Nav.SpawnPositionGuard.ForceTick(); + + var navInst2 = UnityEngine.AI.NavMesh.AddNavMeshData(navData); + try + { + var from = new UnityEngine.Vector3(139f, 5.48f, -20f); + UnityEngine.Vector3 t1, t2; + WL.Combat.Nav.TerrainGuard.FindGround(g, from, out t1); // JIT 워밍 + WL.Combat.Nav.SpawnPositionGuard.ResolveSpawnPoint(g, from, 813001, out t2); + System.GC.Collect(); System.GC.WaitForPendingFinalizers(); System.GC.Collect(); + long before = System.GC.GetTotalMemory(false); + for (int i = 0; i < 1000; i++) + { + WL.Combat.Nav.TerrainGuard.ProbeTick(); + WL.Combat.Nav.SpawnPositionGuard.ProbeTick(); + WL.Combat.Nav.TerrainGuard.FindGround(g, from, out t1); + WL.Combat.Nav.SpawnPositionGuard.ResolveSpawnPoint(g, from, 813001, out t2); + } + long after = System.GC.GetTotalMemory(false); + L(" 틱 1000 + FindGround 1000 + ResolveSpawnPoint 1000 · GC 총량 " + before + " → " + after + + " (Δ " + (after - before) + " B)" + + " · 재수집 " + WL.Combat.Nav.TerrainGuard.Scans + " · Spawn 스캔 " + WL.Combat.Nav.SpawnPositionGuard.Scans); + } + finally { UnityEngine.AI.NavMesh.RemoveNavMeshData(navInst2); } + UnityEngine.Object.DestroyImmediate(g); + } + finally + { + WL.Combat.Nav.WLTerrainGuardSettings.RuntimeDisabled = false; + WL.Combat.Nav.WLTerrainGuardSettings.ClearCache(); + WL.Combat.Nav.TerrainGuard.ResetDiagnostics(); + WL.Combat.Nav.SpawnPositionGuard.ResetDiagnostics(); + L(" 정리: 캐시 초기화 · RuntimeDisabled=false · 실에셋 재로드 = " + (UnityEngine.Resources.Load(WL.Combat.Nav.WLTerrainGuardSettings.ResourcesPath) != null)); + } + + L(""); + L("===== 끝 ====="); + return Flush(kOutDir + "/PROBE.txt"); + } + + static string B(bool b) { return b ? "true" : "false"; } + static string Join(int[] a) + { + if (a == null) return ""; + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < a.Length; i++) { if (i > 0) sb.Append(", "); sb.Append(a[i]); } + return sb.ToString(); + } +} diff --git a/AgentScripts/WL813t2_PROBE.txt b/AgentScripts/WL813t2_PROBE.txt new file mode 100644 index 000000000..821f971dc --- /dev/null +++ b/AgentScripts/WL813t2_PROBE.txt @@ -0,0 +1,228 @@ +===== WL-813t2 프로브 산출물 (커밋본) ===== + +########## 1. 실측 · 재베이크 후 (WL813t2_Probe.Measure) ########## +===== WL-813t2 실측 (2026-09-10 00:53:47) ===== + +── (a) NavMesh 출처 · 베이크 설정 ── + NavMeshSurface 개수 = 1 + [0] WL_Nature · agentTypeID=0 · collectObjects=Volume · size=(260, 80, 260) · center=(115, 5, 10) · layerMask=-1 · useGeometry=PhysicsColliders · defaultArea=0 · minRegionArea=15 · overrideVoxel=False voxel=0.16667 · overrideTile=False tile=256 + 데이터 = Assets/WL/Settings/WL_Nature_NavMesh.asset · 원점=(0.0, 0.0, 0.0) + Agent 0 (Humanoid) radius=0.5 height=2 slope=45 climb=0.75 minRegionArea=2 voxel=0.16667 + NavMeshModifierVolume 기존 개수 = 1 + NavMeshModifier 기존 개수 = 0 + Portal_Start = (115.00, 0.00, 9.98) + NavMesh 삼각형 4969 · 정점 11151 + +── (b) NavMesh 면적 y 대역 분포 (삼각형 3D 면적 합 · m^2) ── + 총 면적 = 61215.0 m^2 · y 범위 -6.21 ~ 2.85 + y [-7, -6) 면적 12.2 (0.02 %) · 누적 0.02 % + y [-6, -5) 면적 162.1 (0.26 %) · 누적 0.28 % + y [-5, -4) 면적 643.4 (1.05 %) · 누적 1.34 % + y [-4, -3) 면적 463.3 (0.76 %) · 누적 2.09 % + y [-3, -2) 면적 107.3 (0.18 %) · 누적 2.27 % + y [-2, -1) 면적 1824.6 (2.98 %) · 누적 5.25 % + y [-1, 0) 면적 14014.8 (22.89 %) · 누적 28.14 % + y [0, 1) 면적 38504.5 (62.90 %) · 누적 91.04 % + y [1, 2) 면적 4920.3 (8.04 %) · 누적 99.08 % + y [2, 3) 면적 562.5 (0.92 %) · 누적 100.00 % + y > 1.5 m 면적 = 1926.4 m^2 (3.15 %) + y > 2.5 m 면적 = 104.0 m^2 (0.17 %) + y > 3.5 m 면적 = 0.0 m^2 (0.00 %) + y > 4.5 m 면적 = 0.0 m^2 (0.00 %) + y > 5.5 m 면적 = 0.0 m^2 (0.00 %) + y > 6.5 m 면적 = 0.0 m^2 (0.00 %) + +── (c) 샘플 격자 2 m (셀 중심 수직선 1 m 간격 · SamplePosition 반경 1 m) ── + 격자 17161 점(간격 2 m) · NavMesh 위 15710 · 밖 1451 · **y > 2.0 셀 336** + y [-5, -4) 12 점 + y [-4, -3) 30 점 + y [-3, -2) 76 점 + y [-2, -1) 1233 점 + y [-1, 0) 1485 점 + y [0, 1) 11174 점 + y [1, 2) 1364 점 + y [2, 3) 336 점 + +── (d) 고지대 NavMesh 클러스터 (셀 4 m · y > 2.0 m 삼각형) ── + 고지대 셀 64개 (셀 4 m) + C1 셀 1 · 면적 166.9 m^2 · X[ 136.0, 140.0] Z[ 132.0, 136.0] Y[ 1.21, 2.79] · 중심 (138.0, 134.0) · 크기 4.0×4.0 + C2 셀 1 · 면적 111.3 m^2 · X[ 148.0, 152.0] Z[ 128.0, 132.0] Y[ 1.21, 2.79] · 중심 (150.0, 130.0) · 크기 4.0×4.0 + C3 셀 1 · 면적 72.9 m^2 · X[ 144.0, 148.0] Z[ 136.0, 140.0] Y[ 2.38, 2.79] · 중심 (146.0, 138.0) · 크기 4.0×4.0 + C4 셀 5 · 면적 52.1 m^2 · X[ 216.0, 224.0] Z[ -92.0, -76.0] Y[ 0.63, 2.88] · 중심 (220.0, -84.0) · 크기 8.0×16.0 + C5 셀 8 · 면적 27.1 m^2 · X[ 144.0, 156.0] Z[ -48.0, -24.0] Y[ 0.71, 2.79] · 중심 (150.0, -36.0) · 크기 12.0×24.0 + C6 셀 2 · 면적 25.1 m^2 · X[ 144.0, 152.0] Z[ 20.0, 28.0] Y[ 1.46, 2.54] · 중심 (148.0, 24.0) · 크기 8.0×8.0 + C7 셀 8 · 면적 24.5 m^2 · X[ 100.0, 124.0] Z[ -80.0, -64.0] Y[ 1.38, 2.88] · 중심 (112.0, -72.0) · 크기 24.0×16.0 + C8 셀 5 · 면적 18.4 m^2 · X[ 44.0, 60.0] Z[ -24.0, -12.0] Y[ 0.88, 2.79] · 중심 (52.0, -18.0) · 크기 16.0×12.0 + C9 셀 2 · 면적 12.6 m^2 · X[ 240.0, 248.0] Z[ 32.0, 40.0] Y[ 1.04, 2.88] · 중심 (244.0, 36.0) · 크기 8.0×8.0 + C10 셀 2 · 면적 9.9 m^2 · X[ 136.0, 140.0] Z[ 20.0, 28.0] Y[ 1.46, 2.54] · 중심 (138.0, 24.0) · 크기 4.0×8.0 + C11 셀 5 · 면적 8.9 m^2 · X[ 236.0, 248.0] Z[ -68.0, -60.0] Y[ 1.96, 2.88] · 중심 (242.0, -64.0) · 크기 12.0×8.0 + C12 셀 1 · 면적 5.2 m^2 · X[ 224.0, 228.0] Z[ -104.0, -100.0] Y[ 2.63, 2.79] · 중심 (226.0, -102.0) · 크기 4.0×4.0 + C13 셀 1 · 면적 4.6 m^2 · X[ 200.0, 204.0] Z[ -8.0, -4.0] Y[ 1.29, 2.79] · 중심 (202.0, -6.0) · 크기 4.0×4.0 + C14 셀 1 · 면적 3.8 m^2 · X[ 108.0, 112.0] Z[ -44.0, -40.0] Y[ 1.88, 2.79] · 중심 (110.0, -42.0) · 크기 4.0×4.0 + C15 셀 3 · 면적 3.8 m^2 · X[ 156.0, 164.0] Z[ 128.0, 136.0] Y[ 2.79, 2.88] · 중심 (160.0, 132.0) · 크기 8.0×8.0 + C16 셀 2 · 면적 3.7 m^2 · X[ 72.0, 76.0] Z[ 4.0, 12.0] Y[ 1.04, 2.71] · 중심 (74.0, 8.0) · 크기 4.0×8.0 + C17 셀 2 · 면적 2.4 m^2 · X[ 120.0, 124.0] Z[ -92.0, -84.0] Y[ 2.54, 2.79] · 중심 (122.0, -88.0) · 크기 4.0×8.0 + C18 셀 1 · 면적 1.4 m^2 · X[ 232.0, 236.0] Z[ -120.0, -116.0] Y[ 2.71, 2.88] · 중심 (234.0, -118.0) · 크기 4.0×4.0 + C19 셀 2 · 면적 1.3 m^2 · X[ 100.0, 104.0] Z[ 36.0, 44.0] Y[ 1.96, 2.13] · 중심 (102.0, 40.0) · 크기 4.0×8.0 + C20 셀 1 · 면적 1.2 m^2 · X[ 140.0, 144.0] Z[ -88.0, -84.0] Y[ 1.54, 2.79] · 중심 (142.0, -86.0) · 크기 4.0×4.0 + C21 셀 1 · 면적 0.9 m^2 · X[ 236.0, 240.0] Z[ 24.0, 28.0] Y[ 1.96, 2.13] · 중심 (238.0, 26.0) · 크기 4.0×4.0 + C22 셀 1 · 면적 0.7 m^2 · X[ 180.0, 184.0] Z[ 124.0, 128.0] Y[ 2.71, 2.88] · 중심 (182.0, 126.0) · 크기 4.0×4.0 + C23 셀 1 · 면적 0.7 m^2 · X[ 236.0, 240.0] Z[ 16.0, 20.0] Y[ 2.29, 2.63] · 중심 (238.0, 18.0) · 크기 4.0×4.0 + C24 셀 1 · 면적 0.6 m^2 · X[ 132.0, 136.0] Z[ -100.0, -96.0] Y[ 2.21, 2.63] · 중심 (134.0, -98.0) · 크기 4.0×4.0 + C25 셀 1 · 면적 0.6 m^2 · X[ 60.0, 64.0] Z[ -36.0, -32.0] Y[ 2.04, 2.71] · 중심 (62.0, -34.0) · 크기 4.0×4.0 + C26 셀 1 · 면적 0.6 m^2 · X[ 164.0, 168.0] Z[ 120.0, 124.0] Y[ 2.71, 2.88] · 중심 (166.0, 122.0) · 크기 4.0×4.0 + C27 셀 1 · 면적 0.4 m^2 · X[ 56.0, 60.0] Z[ 12.0, 16.0] Y[ 1.63, 2.63] · 중심 (58.0, 14.0) · 크기 4.0×4.0 + C28 셀 1 · 면적 0.4 m^2 · X[ 244.0, 248.0] Z[ 16.0, 20.0] Y[ 2.71, 2.96] · 중심 (246.0, 18.0) · 크기 4.0×4.0 + C29 셀 1 · 면적 0.3 m^2 · X[ 132.0, 136.0] Z[ -68.0, -64.0] Y[ 2.04, 2.63] · 중심 (134.0, -66.0) · 크기 4.0×4.0 + C30 셀 1 · 면적 0.3 m^2 · X[ 132.0, 136.0] Z[ -20.0, -16.0] Y[ 1.46, 2.46] · 중심 (134.0, -18.0) · 크기 4.0×4.0 + 클러스터 총 30개 + +── (d2) 고지대 NavMesh 클러스터 (셀 4 m · y > 1.5 m 삼각형) ── + 고지대 셀 109개 (셀 4 m) + C1 셀 6 · 면적 228.5 m^2 · X[ 132.0, 144.0] Z[ 16.0, 32.0] Y[ 1.38, 2.54] · 중심 (138.0, 24.0) · 크기 12.0×16.0 + C2 셀 1 · 면적 166.9 m^2 · X[ 136.0, 140.0] Z[ 132.0, 136.0] Y[ 1.21, 2.79] · 중심 (138.0, 134.0) · 크기 4.0×4.0 + C3 셀 4 · 면적 155.6 m^2 · X[ 152.0, 164.0] Z[ 112.0, 120.0] Y[ 0.04, 2.79] · 중심 (158.0, 116.0) · 크기 12.0×8.0 + C4 셀 1 · 면적 152.1 m^2 · X[ 220.0, 224.0] Z[ -112.0, -108.0] Y[ 0.04, 2.71] · 중심 (222.0, -110.0) · 크기 4.0×4.0 + C5 셀 12 · 면적 142.1 m^2 · X[ 144.0, 160.0] Z[ 20.0, 40.0] Y[ 0.46, 2.54] · 중심 (152.0, 30.0) · 크기 16.0×20.0 + C6 셀 3 · 면적 137.7 m^2 · X[ 220.0, 232.0] Z[ -64.0, -56.0] Y[-0.12, 2.88] · 중심 (226.0, -60.0) · 크기 12.0×8.0 + C7 셀 1 · 면적 111.3 m^2 · X[ 148.0, 152.0] Z[ 128.0, 132.0] Y[ 1.21, 2.79] · 중심 (150.0, 130.0) · 크기 4.0×4.0 + C8 셀 4 · 면적 96.4 m^2 · X[ 180.0, 192.0] Z[ 120.0, 132.0] Y[ 0.04, 2.88] · 중심 (186.0, 126.0) · 크기 12.0×12.0 + C9 셀 11 · 면적 95.3 m^2 · X[ 144.0, 164.0] Z[ -48.0, -24.0] Y[ 0.71, 2.79] · 중심 (154.0, -36.0) · 크기 20.0×24.0 + C10 셀 6 · 면적 89.8 m^2 · X[ 216.0, 224.0] Z[ -92.0, -76.0] Y[ 0.63, 2.88] · 중심 (220.0, -84.0) · 크기 8.0×16.0 + C11 셀 1 · 면적 86.7 m^2 · X[ 224.0, 228.0] Z[ -120.0, -116.0] Y[ 0.04, 2.71] · 중심 (226.0, -118.0) · 크기 4.0×4.0 + C12 셀 1 · 면적 84.9 m^2 · X[ 240.0, 244.0] Z[ -56.0, -52.0] Y[ 0.04, 2.79] · 중심 (242.0, -54.0) · 크기 4.0×4.0 + C13 셀 1 · 면적 72.9 m^2 · X[ 144.0, 148.0] Z[ 136.0, 140.0] Y[ 2.38, 2.79] · 중심 (146.0, 138.0) · 크기 4.0×4.0 + C14 셀 1 · 면적 56.1 m^2 · X[ 188.0, 192.0] Z[ 136.0, 140.0] Y[ 0.04, 2.79] · 중심 (190.0, 138.0) · 크기 4.0×4.0 + C15 셀 1 · 면적 44.8 m^2 · X[ 176.0, 180.0] Z[ 116.0, 120.0] Y[ 0.13, 2.88] · 중심 (178.0, 118.0) · 크기 4.0×4.0 + C16 셀 9 · 면적 31.6 m^2 · X[ 96.0, 124.0] Z[ -80.0, -64.0] Y[ 0.71, 2.88] · 중심 (110.0, -72.0) · 크기 28.0×16.0 + C17 셀 2 · 면적 31.2 m^2 · X[ 152.0, 156.0] Z[ -76.0, -68.0] Y[ 1.21, 1.88] · 중심 (154.0, -72.0) · 크기 4.0×8.0 + C18 셀 7 · 면적 29.5 m^2 · X[ 236.0, 248.0] Z[ 12.0, 40.0] Y[ 0.54, 2.88] · 중심 (242.0, 26.0) · 크기 12.0×28.0 + C19 셀 6 · 면적 20.1 m^2 · X[ 44.0, 60.0] Z[ -24.0, -12.0] Y[ 0.79, 2.79] · 중심 (52.0, -18.0) · 크기 16.0×12.0 + C20 셀 1 · 면적 18.5 m^2 · X[ 144.0, 148.0] Z[ -72.0, -68.0] Y[ 1.46, 1.88] · 중심 (146.0, -70.0) · 크기 4.0×4.0 + C21 셀 1 · 면적 13.9 m^2 · X[ 244.0, 248.0] Z[ 8.0, 12.0] Y[-0.87, 2.96] · 중심 (246.0, 10.0) · 크기 4.0×4.0 + C22 셀 5 · 면적 10.8 m^2 · X[ 236.0, 248.0] Z[ -68.0, -60.0] Y[ 1.63, 2.88] · 중심 (242.0, -64.0) · 크기 12.0×8.0 + C23 셀 2 · 면적 9.1 m^2 · X[ 200.0, 204.0] Z[ -8.0, 0.0] Y[ 1.13, 2.79] · 중심 (202.0, -4.0) · 크기 4.0×8.0 + C24 셀 3 · 면적 7.0 m^2 · X[ 68.0, 76.0] Z[ 4.0, 12.0] Y[-0.04, 2.71] · 중심 (72.0, 8.0) · 크기 8.0×8.0 + C25 셀 2 · 면적 6.7 m^2 · X[ 100.0, 104.0] Z[ 36.0, 44.0] Y[ 1.29, 2.13] · 중심 (102.0, 40.0) · 크기 4.0×8.0 + C26 셀 1 · 면적 5.2 m^2 · X[ 224.0, 228.0] Z[ -104.0, -100.0] Y[ 2.63, 2.79] · 중심 (226.0, -102.0) · 크기 4.0×4.0 + C27 셀 1 · 면적 3.8 m^2 · X[ 108.0, 112.0] Z[ -44.0, -40.0] Y[ 1.88, 2.79] · 중심 (110.0, -42.0) · 크기 4.0×4.0 + C28 셀 3 · 면적 3.8 m^2 · X[ 156.0, 164.0] Z[ 128.0, 136.0] Y[ 2.79, 2.88] · 중심 (160.0, 132.0) · 크기 8.0×8.0 + C29 셀 2 · 면적 2.4 m^2 · X[ 120.0, 124.0] Z[ -92.0, -84.0] Y[ 2.54, 2.79] · 중심 (122.0, -88.0) · 크기 4.0×8.0 + C30 셀 1 · 면적 2.3 m^2 · X[ 132.0, 136.0] Z[ -100.0, -96.0] Y[ 0.96, 2.63] · 중심 (134.0, -98.0) · 크기 4.0×4.0 + C31 셀 1 · 면적 2.1 m^2 · X[ 132.0, 136.0] Z[ -68.0, -64.0] Y[ 1.29, 2.63] · 중심 (134.0, -66.0) · 크기 4.0×4.0 + C32 셀 1 · 면적 1.7 m^2 · X[ 60.0, 64.0] Z[ -36.0, -32.0] Y[ 1.21, 2.71] · 중심 (62.0, -34.0) · 크기 4.0×4.0 + C33 셀 1 · 면적 1.4 m^2 · X[ 232.0, 236.0] Z[ -120.0, -116.0] Y[ 2.71, 2.88] · 중심 (234.0, -118.0) · 크기 4.0×4.0 + C34 셀 2 · 면적 1.3 m^2 · X[ 140.0, 144.0] Z[ -92.0, -84.0] Y[ 1.29, 2.79] · 중심 (142.0, -88.0) · 크기 4.0×8.0 + C35 셀 1 · 면적 1.0 m^2 · X[ 132.0, 136.0] Z[ -20.0, -16.0] Y[ 1.46, 2.46] · 중심 (134.0, -18.0) · 크기 4.0×4.0 + C36 셀 1 · 면적 0.9 m^2 · X[ 56.0, 60.0] Z[ 12.0, 16.0] Y[ 1.21, 2.63] · 중심 (58.0, 14.0) · 크기 4.0×4.0 + C37 셀 1 · 면적 0.6 m^2 · X[ 164.0, 168.0] Z[ 120.0, 124.0] Y[ 2.71, 2.88] · 중심 (166.0, 122.0) · 크기 4.0×4.0 + C38 셀 1 · 면적 0.4 m^2 · X[ 244.0, 248.0] Z[ 16.0, 20.0] Y[ 2.71, 2.96] · 중심 (246.0, 18.0) · 크기 4.0×4.0 + 클러스터 총 38개 + +── (e) 스포너 노드 (MobControlMgr) ── + 스포너 23기 + #813001 1 pos=(115.00, 0.39, -22.00) range=10 · NavMesh y=0.34 d=0.05 + #813002 2 pos=(147.00, 2.59, 30.00) range=10 · NavMesh y=2.54 d=0.05 + #813003 3 pos=(71.00, -0.29, 18.00) range=10 · NavMesh y=-0.34 d=0.05 + #813004 4 pos=(159.00, 0.88, -22.00) range=10 · NavMesh y=0.83 d=0.05 + #813005 5 pos=(103.00, 0.22, 74.00) range=10 · NavMesh y=0.17 d=0.05 + #6 6 pos=(55.00, 0.22, -38.00) range=10 · NavMesh y=0.17 d=0.05 + #7 7 pos=(191.00, 0.34, 42.00) range=10 · NavMesh y=0.29 d=0.05 + #8 8 pos=(199.00, 0.66, -2.00) range=10 · NavMesh y=0.61 d=0.05 + #9 9 pos=(47.00, 0.07, 62.00) range=10 · NavMesh y=0.02 d=0.05 + #10 10 pos=(155.00, 1.34, -70.00) range=10 · NavMesh y=1.29 d=0.05 + #11 11 pos=(19.00, 0.20, 10.00) range=10 · NavMesh y=0.15 d=0.05 + #12 12 pos=(195.00, 0.26, -50.00) range=10 · NavMesh y=0.21 d=0.05 + #13 13 pos=(143.00, 0.38, 110.00) range=10 · NavMesh y=0.29 d=0.09 + #14 14 pos=(91.00, 0.20, -90.00) range=10 · NavMesh y=0.15 d=0.05 + #15 15 pos=(67.00, 0.09, 110.00) range=10 · NavMesh y=0.04 d=0.05 + #16 16 pos=(195.00, 0.12, 90.00) range=10 · NavMesh y=0.07 d=0.05 + #17 17 pos=(19.00, -0.62, -90.00) range=10 · NavMesh y=-0.67 d=0.05 + #18 18 pos=(15.00, 0.12, 110.00) range=10 · NavMesh y=0.07 d=0.05 + #19 19 pos=(215.00, 1.00, -90.00) range=10 · NavMesh y=0.91 d=0.09 + #813021 e1 pos=(115.00, 0.39, -22.00) range=6 · NavMesh y=0.34 d=0.05 + #813022 e2 pos=(147.00, 2.59, 30.00) range=6 · NavMesh y=2.54 d=0.05 + #813023 e3 pos=(71.00, -0.29, 18.00) range=6 · NavMesh y=-0.34 d=0.05 + #813024 e4 pos=(159.00, 0.88, -22.00) range=6 · NavMesh y=0.83 d=0.05 + +── (f) 경로 연결성 (시작점 ↔ 스포너 원점 · 아레나) ── + 시작 스냅 = (115.00, 0.11, 9.98) + 스포너 23 → PathComplete 23 · Partial/Invalid 0 · NavMesh 밖 0 + 아레나 중심(SO) = (139.00, 0.80, -20.00) r=20 · NavMesh (139.00, 0.81, -20.00) + 시작 → 아레나 = PathComplete + +===== 끝 ===== + +########## 2. 베이크 전 기준선 (WL813t2_Bake.Verify · 커밋본 NavMesh) ########## +===== WL-813t2 검증 (2026-09-10 00:43:36) ===== +자산 Assets/WL/Settings/WL_Nature_NavMesh.asset · 667268 bytes +[검증] 삼각형 5504 · 정점 12364 · 총 면적 67536.0 m^2 · y -6.21 ~ 22.79 +[검증] y>1.5 9602.4 · y>2.0 8487.1 · y>2.5 7465.1 · **y>3.0 6044.6** · y>3.5 5590.4 · y>5.0 3428.5 m^2 +[검증] 시작→스포너 23 : PathComplete 23 · Partial/Invalid 0 · 밖 0 +[검증] 아레나 (139.0, 0.8, -20.0) → (139.00, 0.81, -20.00) · 시작→아레나 PathComplete +[검증] 아레나 원주 12점 → 중심 PathComplete 8/12 +[검증] 앵커 Portal_Start (115.00, 0.00, 9.98) → (115.00, 0.11, 9.98) +[검증] 앵커 Reincarnation_Start (115.00, 0.00, 9.98) → (115.00, 0.11, 9.98) +[검증] 스폰 표집 1000점 · **y>3.0 8** · NavMesh 밖 0 · 원점 폴백 0 + y 분포: [-2]=89 [-1]=53 [0]=668 [1]=93 [2]=89 [3]=1 [4]=7 + +########## 3. 베이크 적용 (WL813t2_Bake.Apply) ########## +===== WL-813t2 베이크 APPLY(자산 갱신) · 2026-09-10 00:43:46 ===== +현행 자산 = Assets/WL/Settings/WL_Nature_NavMesh.asset · 667268 bytes · 로드 OK +[현행(커밋본)] 삼각형 5504 · 정점 12364 · 총 면적 67536.0 m^2 · y -6.21 ~ 22.79 +[현행(커밋본)] y>1.5 9602.4 · y>2.0 8487.1 · y>2.5 7465.1 · **y>3.0 6044.6** · y>3.5 5590.4 · y>5.0 3428.5 m^2 +인스턴스 root transform pos=(0.000, 0.000, 0.000) rot=(0.0, 0.0, 0.0) scale=(1.000, 1.000, 1.000) +NavMeshModifierVolume 1개 + [0] WL_Nature/WL813t2_HillNotWalkable · area=1(Not Walkable) · center(world)=(115.0, 24.0, 10.0) size=(260.0, 42.0, 260.0) · Y[3.00, 45.00] · active=True enabled=True · affectsAgent0=True layer=0 +베이크 0.2 s · 결과 OK +[재베이크] 삼각형 4969 · 정점 11151 · 총 면적 61215.0 m^2 · y -6.21 ~ 2.96 +[재베이크] y>1.5 1926.4 · y>2.0 562.5 · y>2.5 104.0 · **y>3.0 0.0** · y>3.5 0.0 · y>5.0 0.0 m^2 +[재베이크] 시작→스포너 23 : PathComplete 23 · Partial/Invalid 0 · 밖 0 +[재베이크] 아레나 (139.0, 0.8, -20.0) → (139.00, 0.81, -20.00) · 시작→아레나 PathComplete +[재베이크] 아레나 원주 12점 → 중심 PathComplete 8/12 +[재베이크] 앵커 Portal_Start (115.00, 0.00, 9.98) → (115.00, 0.11, 9.98) +[재베이크] 앵커 Reincarnation_Start (115.00, 0.00, 9.98) → (115.00, 0.11, 9.98) +[재베이크] 스폰 표집 1000점 · **y>3.0 0** · NavMesh 밖 0 · 원점 폴백 0 + y 분포: [-2]=88 [-1]=60 [0]=678 [1]=94 [2]=80 +자산 저장 완료 · 667268 → 593864 bytes (-73404) +GUID = ef300913c80dbda47b476b9261fdd786 (기대 ef300913c80dbda47b476b9261fdd786) +씬 정리 완료(저장 0) · scene= + +########## 4. 가드 프로브 (WL813t2_Guard.Probe) ########## +===== WL-813t2 가드 프로브 (2026-09-10 00:53:32) ===== + +── ① SO 실값 (에셋에서 읽음) ── + enabled=true verboseLog=false check=0.2s rescan=3s + [지형] terrainGuardEnabled=true maxWalkableY=3.5 offNavMeshTolerance=1 dwell=1s cool=3s near=4 far=30 + [지형] guardPlayer=true guardMobs=true guardBoss=true warpSnapsCameraLead=true + [범위] heightGuardMapIds=[-1] allMaps=false whenUnknown=true (WL_Nature = BattleMapConfig n_MapID -1) + [스폰] spawnGuardEnabled=true check=0.5s sampleRadius=2 retries=3 fallbackToNode=true + +── ② 언덕 격자 — 재베이크 뒤 walkable 0 인지 ── + 클러스터 C1(X96~160 · Z−100~−16 · y 3~20) 1000점 · 반경 1 m 스냅 성공 0 · maxWalkableY(3.5) 초과 0 + NavMesh 정점 최고 y = 2.96 (maxWalkableY 3.5 아래여야 한다) + +── ③ FindGround — D-48 실좌표에서 되돌릴 지점 ── + (139.00, 3.66, -20.00) → (139.00, 0.81, -20.00) · Δy 2.85 · 수평 0.00 m · PASS + (139.00, 5.48, -20.00) → (139.00, 0.81, -20.00) · Δy 4.67 · 수평 0.00 m · PASS + (139.00, 5.27, -20.00) → (139.00, 0.81, -20.00) · Δy 4.46 · 수평 0.00 m · PASS + (128.00, 8.00, -58.00) → (128.00, 1.28, -58.00) · Δy 6.72 · 수평 0.00 m · PASS + (147.00, 6.00, 30.00) → (147.00, 2.54, 30.00) · Δy 3.46 · 수평 0.00 m · PASS + +── ④ 스폰 보정 경로 — 언덕 위 가짜 스폰점 200개 (SpawnPositionGuard.ResolveSpawnPoint 실호출) ── + 200점 · 원위치 유효 0 · 재표집 200 · 스포너 노드 폴백 0 · 보정 실패 0 · **보정 결과가 언덕 위 0** + 스포너 노드 23기 · walkable 23 · 언덕 위 0 (0 이어야 폴백이 안전하다) + +── ⑤ C8 진리표 (임시 SO · 실에셋 무수정) ── + 실에셋 → TerrainGuard.Active=true · SpawnPositionGuard.Active=true + enabled=0 → false / false (기대 false/false) + terrainGuard=0 → false / true (기대 false/true) + spawnGuard=0 → true / false (기대 true/false) + RuntimeDisabled → false / false (기대 false/false) + 에셋 없음 → false / false (기대 false/false) + +── ⑥ GC — 틱 1000회 + 판정 경로 1000회 ── + 틱 1000 + FindGround 1000 + ResolveSpawnPoint 1000 · GC 총량 1181982720 → 1181982720 (Δ 0 B) · 재수집 1 · Spawn 스캔 1 + 정리: 캐시 초기화 · RuntimeDisabled=false · 실에셋 재로드 = True + +===== 끝 ===== diff --git a/Assets/WL/Combat/Nav/TerrainGuard.cs b/Assets/WL/Combat/Nav/TerrainGuard.cs new file mode 100644 index 000000000..7674c2669 --- /dev/null +++ b/Assets/WL/Combat/Nav/TerrainGuard.cs @@ -0,0 +1,442 @@ +// ───────────────────────────────────────────────────────────────────────────── +// 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 +{ + /// 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 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; + } + + /// 스폰 지점이 walkable 인지 확인하고, 아니면 재시도 → 스포너 노드 순으로 보정한다. + 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); + } + + /// 스포너 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; + 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(); + } + } +} diff --git a/Assets/WL/Combat/Nav/TerrainGuard.cs.meta b/Assets/WL/Combat/Nav/TerrainGuard.cs.meta new file mode 100644 index 000000000..656f2cf95 --- /dev/null +++ b/Assets/WL/Combat/Nav/TerrainGuard.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7159f67e61b5aa64fb6e3f96eca4cdff \ No newline at end of file diff --git a/Assets/WL/Combat/Nav/WLTerrainGuardSettings.cs b/Assets/WL/Combat/Nav/WLTerrainGuardSettings.cs new file mode 100644 index 000000000..47c34a9b7 --- /dev/null +++ b/Assets/WL/Combat/Nav/WLTerrainGuardSettings.cs @@ -0,0 +1,142 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLTerrainGuardSettings.cs — 언덕(이동 불가 지형) 가드 값의 단일 출처(SOT · C45) +// +// PD 지시 #813 (2026-09-10) · 발주서 WL-813t2 §1-3 · §1-4 +// 「언덕 지형은 플레이어나 몬스터가 갈 수 없도록 · 언덕을 뚫고 가는 상황 및 카메라 연출 꼬임 · +// 이동 불가 지역에는 몬스터가 생성되어서도 안 됨」 +// 근거 = QA W2-12 D-48(PC 가 아레나 밖 고지대 y 3.66 → 5.48 로 올라가 보스를 한 대도 못 때리고 시간 종료) +// +// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLTerrainGuardSettings.asset +// → Resources.Load("WL/WLTerrainGuardSettings") 로 1회 로드 후 캐시 +// (WLCombatCoreSettings·WLBossArenaSettings 등 기존 설정 SO 관례 그대로) +// +// ■ C8 롤백 +// ① 에셋이 없다 / ② enabled = 0 / ③ RuntimeDisabled = true → 러너 자체가 뜨지 않는다(오브젝트 0). +// 축별로도 끌 수 있다 — terrainGuardEnabled = 0(가드만 off) · spawnGuardEnabled = 0(스폰 보정만 off). +// 🔴 코어 스위치(WLCombatCoreSettings.Enabled)에 의존하지 않는다 — MobNavRecovery(813x)와 같은 이유로, +// 코어를 끈 A/B 중에도 「언덕에 올라간 채 굳는」 상태가 되살아나면 안 된다. +// +// ■ 아래 기본값은 에셋 생성 시 초기값이다. 실제로 읽히는 값은 항상 .asset 이며 코드에 수치 상수는 없다(C45). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(Actor/MobActor 가 Assembly-CSharp 에 있다). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Combat.Nav +{ + [CreateAssetMenu(fileName = "WLTerrainGuardSettings", menuName = "WL/Terrain Guard Settings", order = 332)] + public sealed class WLTerrainGuardSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLTerrainGuardSettings"; + + static WLTerrainGuardSettings s_cached; + static bool s_lookupDone; + + /// 설정 에셋(1회 로드 후 캐시). 없으면 null = 기능 전체 off. + public static WLTerrainGuardSettings Instance + { + get + { + if (!s_lookupDone) + { + s_cached = Resources.Load(ResourcesPath); + s_lookupDone = true; + } + return s_cached; + } + } + + /// 진단·A/B 전용 런타임 스위치. 도메인 리로드로 자동 false. + public static bool RuntimeDisabled; + + /// 두 축(지형 가드 · 스폰 보정)이 살아 있는가. + public static bool Active + { + get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; } + } + + /// 테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다. + public static void ClearCache() { s_cached = null; s_lookupDone = false; } + + /// 프로브용: 임시 SO 를 잠시 Instance 자리에 끼운다(C8 진리표 · 실에셋은 읽기만 한다). + public static void SetInstanceForProbe(WLTerrainGuardSettings so) { s_cached = so; s_lookupDone = true; } + + // ─────────────────────────────────────────────────────── 공통 + [Header("공통")] + [Tooltip("0 이면 러너가 뜨지 않는다 = 원본 동작 100% (C8 롤백).")] + public bool enabled = true; + + [Tooltip("복구·보정 1건마다 로그 1줄.")] + public bool verboseLog; + + [Tooltip("점검 주기(초). 액터 1명당 판정 1회.")] + public float checkSeconds = 0.2f; + + [Tooltip("몬스터 목록 재수집 주기(초). FindObjectsByType 는 여기서만 부른다(GC).")] + public float rescanSeconds = 3f; + + // ─────────────────────────────────────────────────────── 지형 가드(§1-3) + [Header("지형 가드 — 언덕 위/NavMesh 밖 복귀")] + [Tooltip("0 이면 지형 가드만 끈다.")] + public bool terrainGuardEnabled = true; + + [Tooltip("이 높이(m)를 넘는 곳에 서 있으면 언덕으로 본다. 813t2 재베이크 후 WL_Nature 의 walkable 최고 y = 2.96 m → 여유 0.5 m.")] + public float maxWalkableY = 3.5f; + + [Tooltip("이 반경(m) 안에 NavMesh 가 없으면 「NavMesh 밖」으로 본다. 원본 넉백·점프의 짧은 이탈을 흘려보낼 만큼은 커야 한다.")] + public float offNavMeshTolerance = 1f; + + [Tooltip("이상 상태가 이 시간(초)을 넘게 지속돼야 되돌린다. 점프·넉백 중에는 원본이 의도적으로 에이전트를 끈다.")] + public float dwellSeconds = 1f; + + [Tooltip("같은 액터를 다시 되돌리기까지의 최소 간격(초).")] + public float warpCooldownSeconds = 3f; + + [Tooltip("되돌릴 지점을 찾는 1차 반경(m).")] + public float warpNearRadius = 4f; + + [Tooltip("1차에서 못 찾으면 쓰는 2차 반경(m).")] + public float warpFarRadius = 30f; + + [Tooltip("플레이어를 가드 대상에 넣는다(D-48 의 당사자).")] + public bool guardPlayer = true; + + [Tooltip("일반 몬스터를 가드 대상에 넣는다.")] + public bool guardMobs = true; + + [Tooltip("보스를 가드 대상에 넣는다(Anubis 스킬2 점프).")] + public bool guardBoss = true; + + [Tooltip("플레이어를 되돌린 직후 카메라 이동 리드를 0 으로 스냅한다(PD 「카메라 연출 꼬임」 · RealCamera 무수정).")] + public bool warpSnapsCameraLead = true; + + [Header("높이 규칙의 적용 범위")] + [Tooltip("높이 규칙(maxWalkableY)을 적용할 맵 ID. WL_Nature = -1(BattleMapConfig 실측). " + + "다른 맵은 고지대가 정상 플레이 영역일 수 있어 높이 규칙을 적용하지 않는다(「NavMesh 밖」 규칙만 남는다).")] + public int[] heightGuardMapIds = new int[] { -1 }; + + [Tooltip("1 이면 맵 ID 와 무관하게 높이 규칙을 적용한다(진단용).")] + public bool heightGuardAllMaps; + + [Tooltip("현재 맵 ID 를 못 읽을 때 높이 규칙을 적용할지. WL_Nature 는 로비 겸용(Load_Map(-1))이라 기본 1.")] + public bool heightGuardWhenMapUnknown = true; + + // ─────────────────────────────────────────────────────── 스폰 위치 검증(§1-4) + [Header("스폰 위치 검증 — 이동 불가 지역 스폰 0")] + [Tooltip("0 이면 스폰 보정만 끈다.")] + public bool spawnGuardEnabled = true; + + [Tooltip("새로 나타난 몬스터를 훑는 주기(초).")] + public float spawnCheckSeconds = 0.5f; + + [Tooltip("스폰 지점이 유효한지 볼 때 쓰는 NavMesh 표집 반경(m). 원본 MapData.spawnSnapRadius(WL_Nature = 2)와 같은 뜻.")] + public float spawnSampleRadius = 2f; + + [Tooltip("표집 재시도 횟수. 시도마다 반경을 1배씩 넓힌다(2 → 4 → 6 m).")] + public int spawnRetries = 3; + + [Tooltip("재시도가 전부 실패하면 스포너 노드 중심으로 옮긴다(발주서 §1-4).")] + public bool spawnFallbackToSpawnerNode = true; + } +} diff --git a/Assets/WL/Combat/Nav/WLTerrainGuardSettings.cs.meta b/Assets/WL/Combat/Nav/WLTerrainGuardSettings.cs.meta new file mode 100644 index 000000000..6405ee4bb --- /dev/null +++ b/Assets/WL/Combat/Nav/WLTerrainGuardSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 562e876b3638e364496cbc31202aae5c \ No newline at end of file diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLTerrainGuardSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLTerrainGuardSettings.asset new file mode 100644 index 000000000..77ad81875 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLTerrainGuardSettings.asset @@ -0,0 +1,37 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 562e876b3638e364496cbc31202aae5c, type: 3} + m_Name: WLTerrainGuardSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Nav.WLTerrainGuardSettings + enabled: 1 + verboseLog: 0 + checkSeconds: 0.2 + rescanSeconds: 3 + terrainGuardEnabled: 1 + maxWalkableY: 3.5 + offNavMeshTolerance: 1 + dwellSeconds: 1 + warpCooldownSeconds: 3 + warpNearRadius: 4 + warpFarRadius: 30 + guardPlayer: 1 + guardMobs: 1 + guardBoss: 1 + warpSnapsCameraLead: 1 + heightGuardMapIds: ffffffff + heightGuardAllMaps: 0 + heightGuardWhenMapUnknown: 1 + spawnGuardEnabled: 1 + spawnCheckSeconds: 0.5 + spawnSampleRadius: 2 + spawnRetries: 3 + spawnFallbackToSpawnerNode: 1 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLTerrainGuardSettings.asset.meta b/Assets/WL/Combat/Settings/Resources/WL/WLTerrainGuardSettings.asset.meta new file mode 100644 index 000000000..d94aa9893 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLTerrainGuardSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9284524d54685de4288e8a26718c046e +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: