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