// ───────────────────────────────────────────────────────────────────────────── // BossArena.cs — 존 전멸 → 보스 등장 게이트 · 아레나 경계 · 처치 후 정리 // // PD 지시 #813 · 발주서 WL-813d §1 (2026-09-09) // 규격 = 813e 완료보고(아레나 (139,0.8,−20) r20 · FieldBossData 상시 배치 · 존 6행) // + 813h 완료보고(BossEvents.BossPhase · BossPatternTable) + 813b §6-7/6-8 // // ■ 무엇을 하나 // ① 게이트 — 813e 존 5개(813001~813005)의 생존/처치를 CombatEvents.Killed/Spawned 로 세고, // 게이트 존(기본 813004·813005) 전멸 또는 누적 처치 ≥ N 이면 보스를 등장시킨다. // ② 아레나 — 등장 직전에 MonsterList 행의 f_BaseChaseRange 를 아레나 반경으로 낮추고(코드 상수 0), // 보스가 아레나 밖으로 나가거나 PC 가 아레나를 떠나면 중심으로 복귀(리셋)시킨다. // ③ 정리 — 보스 처치 후 FieldBossData 의 RegenTime 재등장 코루틴을 끊는다(리젠 없음 · SO). // // ■ 「보스를 나중에 등장시키는」 방법 — 원본 스포너 무수정 (발주서 §2) // FieldBossData.Start() 가 유일한 스폰 경로다(813e 는 코드 0 으로 배치했다). // Unity 의 Start() 는 컴포넌트당 1회뿐이라 SetActive 토글로는 다시 부를 수 없고, // 스폰 코드를 이 파일에 복제하면 원본과 두 벌이 된다. // → 훅 1줄이 자기 자신(Start)을 System.Action 으로 넘긴다. 게이트가 닫혀 있으면 true 를 돌려 // 원본이 그 자리에서 return 하고, 게이트가 열릴 때 그 델리게이트를 다시 부른다. // 두 번째 진입에서는 HoldSpawn 이 false 를 주므로 원본 스폰 코드가 그대로 실행된다. // → 스폰 로직 복제 0 · AddrResourceMgr/ActorInfo 등록 경로 동일 · On_Regen 도 원본이 부른다 // (= Spawned(isBoss) 가 「등장 시점」에 제대로 난다 → 811d 보스 캠 · 813c HP 바 · 813t 배너). // // ■ C8 롤백 — WLBossArenaSettings.Active 가 false 면 HoldSpawn 이 즉시 false. // 에셋 없음 / enabled=false / RuntimeDisabled / 코어 off 중 하나면 813e 의 상시 배치 100%. // 🔴 코어(WLCombatCoreSettings)가 꺼지면 Killed/Spawned 가 0 이라 게이트가 영영 안 열린다. // 그래서 Active 가 코어 상태까지 포함한다 — 코어를 끄면 게이트도 안 걸린다(보스가 사라지지 않는다). // // ■ 값(C45) — 게임 수치 상수 0. 전부 Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset. // 추적 반경은 MonsterList 행(table_monsterlist)의 f_BaseChaseRange 에 「등장 직전」 대입한다. // MobActor.Set(mobtable,…) 이 그 값을 복사(MobActor.cs:92 · Check_Battle)하고, // Check_Patrol(MobActor.cs:356)은 테이블 값을 직접 읽으므로 어그로·귀환 양쪽에 같은 반경이 걸린다. // MonsterList.json(Systems 소유 · xlsm SOT)을 고치지 않고 값만 데이터화하는 경로다. // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using WL.Combat.Core; namespace WL.Combat.Boss { /// 보스전 게이트·아레나. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개). public static class BossArena { // ───────────────────────────────────────── 내부 상태 sealed class Held { public FieldBossData fbd; public System.Action resume; public Vector3 center; public bool released; } sealed class Zone { public int alive, kills, spawns; } static readonly List s_held = new List(2); static readonly Dictionary s_zones = new Dictionary(8); static readonly List s_gateZones = new List(4); static bool s_subscribed, s_armed, s_gateOpen, s_leashFaulted; static bool s_chaseApplied; static float s_chaseBefore, s_chaseAfter; static MobActor s_boss; static float s_openDueTime = -1f, s_cleanupDueTime = -1f; static float s_playerOutsideSince = -1f, s_nextLeashCheck; // ───────────────────────────────────────── 진단(프로브·QA 가 읽는다) public static bool GateArmed { get { return s_armed; } } public static bool GateOpen { get { return s_gateOpen; } } public static bool BossSpawned, BossKilled, CleanupDone; public static int TotalZoneKills, ResetCount, ReleaseCount, HoldCount, PassThroughCount; public static string LastLog = ""; public static bool ChaseRangeApplied { get { return s_chaseApplied; } } public static float ChaseRangeBefore { get { return s_chaseBefore; } } public static float ChaseRangeAfter { get { return s_chaseAfter; } } /// 구독 여부 — 게이트가 실제로 CombatEvents 에 붙었는지 확인용. public static bool Subscribed { get { return s_subscribed; } } /// 등장한 보스(리쉬 대상). 미등장이면 null. public static MobActor Boss { get { return s_boss; } } public static int AliveOf(int spawnerId) { Zone z; return s_zones.TryGetValue(spawnerId, out z) ? z.alive : 0; } public static int KillsOf(int spawnerId) { Zone z; return s_zones.TryGetValue(spawnerId, out z) ? z.kills : 0; } public static int SpawnsOf(int spawnerId) { Zone z; return s_zones.TryGetValue(spawnerId, out z) ? z.spawns : 0; } static WLBossArenaSettings Cfg { get { return WLBossArenaSettings.Instance; } } static bool Active { get { return WLBossArenaSettings.Active; } } // ───────────────────────────────────────────────────────────────────── // 훅 (Assets/Script/Map/FieldBossData.cs Start 첫 줄 · 1줄) // ───────────────────────────────────────────────────────────────────── /// /// 이 FieldBossData 의 스폰을 붙잡을지 판정한다. /// true = 원본 Start 가 그 자리에서 return(보스 미등장) · 게이트가 열릴 때 resume 으로 Start 를 다시 부른다. /// false = 원본 스폰 코드 그대로 진행(= 813e 상시 배치). /// public static bool HoldSpawn(FieldBossData fbd, System.Action resume) { if (DSUtil.CheckNull(fbd)) return false; var cfg = Cfg; if (!Active || cfg == null || !cfg.gateEnabled) { PassThroughCount++; return false; } if (cfg.bossSpawnerId != 0 && fbd.n_SpawnerId != cfg.bossSpawnerId) { PassThroughCount++; return false; } // 맵을 나갔다 다시 들어오면 이전 FieldBossData 가 파괴된 채 정적 상태만 남는다. // 보류 목록이 통째로 비었는데 새 FieldBossData 가 들어오면 「새 맵 세션」으로 보고 게이트를 다시 잠근다. for (int i = s_held.Count - 1; i >= 0; i--) if (DSUtil.CheckNull(s_held[i].fbd)) s_held.RemoveAt(i); if (s_armed && s_held.Count == 0) { ResetAll(); Log("새 맵 세션 — 게이트 재무장"); } // 두 번째 진입(= 게이트가 열려 resume 으로 다시 들어온 경우) — 원본 스폰을 통과시킨다. for (int i = 0; i < s_held.Count; i++) if (ReferenceEquals(s_held[i].fbd, fbd)) { if (s_held[i].released) { PassThroughCount++; return false; } return true; // 아직 닫혀 있다(중복 Start 방어) } Arm(cfg); var h = new Held { fbd = fbd, resume = resume, center = cfg.arenaEnabled && !cfg.useFieldBossPositionAsCenter ? cfg.arenaCenter : fbd.transform.position, }; s_held.Add(h); HoldCount++; EnsureRunner(); Log("보스 스폰 보류 — spawnerId=" + fbd.n_SpawnerId + " monsterId=" + fbd.n_MonsterID + " center=" + h.center.ToString("F1") + " mode=" + cfg.gateMode); // 이미 열려 있던 게이트(재등장 시나리오)라면 붙잡지 않는다 — resume 을 부르면 이중 스폰이 된다. if (s_gateOpen) { h.released = true; h.resume = null; ApplyChaseRange(cfg); PassThroughCount++; return false; } return true; } // ───────────────────────────────────────────────────────────────────── // 존 카운트 (CombatEvents 구독 · 프로브는 NoteSpawn/NoteKill 을 직접 부른다) // ───────────────────────────────────────────────────────────────────── static void Arm(WLBossArenaSettings cfg) { if (s_armed) return; s_armed = true; s_gateOpen = false; s_zones.Clear(); s_gateZones.Clear(); if (cfg.zoneSpawnerIds != null) for (int i = 0; i < cfg.zoneSpawnerIds.Length; i++) if (!s_zones.ContainsKey(cfg.zoneSpawnerIds[i])) s_zones.Add(cfg.zoneSpawnerIds[i], new Zone()); if (cfg.gateZoneSpawnerIds != null) for (int i = 0; i < cfg.gateZoneSpawnerIds.Length; i++) { int id = cfg.gateZoneSpawnerIds[i]; if (!s_zones.ContainsKey(id)) s_zones.Add(id, new Zone()); if (!s_gateZones.Contains(id)) s_gateZones.Add(id); } EnsureSubscribed(); } /// CombatEvents 구독(중복 구독은 CombatEventList.Add 가 막는다). public static void EnsureSubscribed() { if (s_subscribed) return; CombatEvents.Killed.Add(OnKilled); CombatEvents.Spawned.Add(OnSpawned); s_subscribed = true; } /// 구독 해제(프로브·롤백용). public static void Unsubscribe() { if (!s_subscribed) return; CombatEvents.Killed.Remove(OnKilled); CombatEvents.Spawned.Remove(OnSpawned); s_subscribed = false; } static void OnKilled(in KilledEvent e) { int sid = DSUtil.CheckNull(e.victim) ? 0 : e.victim.Get_Group(); NoteKill(sid, e.subRole == eSubRol.Boss); } static void OnSpawned(in SpawnedEvent e) { int sid = DSUtil.CheckNull(e.actor) ? 0 : e.actor.Get_Group(); if (e.isBoss && IsBossSpawner(sid)) s_boss = e.actor as MobActor; NoteSpawn(sid, e.isBoss); } static bool IsBossSpawner(int spawnerId) { var cfg = Cfg; return cfg != null && (cfg.bossSpawnerId == 0 || spawnerId == cfg.bossSpawnerId); } /// 스폰 1건 반영. 프로브가 가짜 스폰을 넣을 때도 이 경로를 쓴다. public static void NoteSpawn(int spawnerId, bool isBoss) { if (!s_armed) return; Zone z; if (s_zones.TryGetValue(spawnerId, out z)) { z.alive++; z.spawns++; } if (isBoss && IsBossSpawner(spawnerId)) { BossSpawned = true; s_playerOutsideSince = -1f; Log("보스 등장 확인 — Spawned(isBoss) spawnerId=" + spawnerId); } } /// 처치 1건 반영 + 게이트 재판정. 프로브가 가짜 Killed 를 넣을 때도 이 경로를 쓴다. public static void NoteKill(int spawnerId, bool isBoss) { if (!s_armed) return; Zone z; if (s_zones.TryGetValue(spawnerId, out z)) { z.kills++; if (z.alive > 0) z.alive--; TotalZoneKills++; } if (isBoss && IsBossSpawner(spawnerId)) { if (!BossKilled) { BossKilled = true; var c0 = Cfg; float delay = c0 != null ? c0.cleanupDelaySeconds : 0f; bool deferred = Application.isPlaying && delay > 0f; Log(deferred ? ("보스 처치 — 정리 예약(+" + delay.ToString("F1") + "s)") : "보스 처치 — 정리 즉시"); if (deferred) s_cleanupDueTime = Time.unscaledTime + delay; else Cleanup(); } return; } if (!s_gateOpen) EvaluateGate(); } // ───────────────────────────────────────────────────────────────────── // 게이트 판정 · 개방 // ───────────────────────────────────────────────────────────────────── /// 조건을 다시 보고 충족하면 게이트를 연다. 열려 있으면 아무 일도 하지 않는다. public static void EvaluateGate() { var cfg = Cfg; if (cfg == null || s_gateOpen || !s_armed) return; bool cleared = ZonesCleared(cfg); bool killed = cfg.requiredKills > 0 && TotalZoneKills >= cfg.requiredKills; bool ok; switch (cfg.gateMode) { case eBossGateMode.ZoneCleared: ok = cleared; break; case eBossGateMode.KillCount: ok = killed; break; case eBossGateMode.Both: ok = cleared && killed; break; default: ok = cleared || killed; break; // Either } if (!ok) return; s_gateOpen = true; Log("게이트 열림 — cleared=" + cleared + " kills=" + TotalZoneKills + "/" + cfg.requiredKills + " mode=" + cfg.gateMode); if (Application.isPlaying && cfg.gateOpenDelaySeconds > 0f) s_openDueTime = Time.unscaledTime + cfg.gateOpenDelaySeconds; else Release(cfg); } static bool ZonesCleared(WLBossArenaSettings cfg) { if (s_gateZones.Count == 0) return false; for (int i = 0; i < s_gateZones.Count; i++) { Zone z; if (!s_zones.TryGetValue(s_gateZones[i], out z)) return false; if (cfg.requireZoneSpawnedOnce && z.spawns <= 0) return false; if (z.alive > 0) return false; } return true; } /// 게이트를 강제로 연다(QA·프로브용 · 조건 무시). public static void ForceOpenGate() { var cfg = Cfg; if (cfg == null || !s_armed) return; s_gateOpen = true; Log("게이트 강제 개방"); Release(cfg); } /// 보류 중인 FieldBossData 를 실제로 등장시킨다(원본 Start 재진입). static void Release(WLBossArenaSettings cfg) { s_openDueTime = -1f; ApplyChaseRange(cfg); for (int i = 0; i < s_held.Count; i++) { var h = s_held[i]; if (h.released) continue; if (DSUtil.CheckNull(h.fbd)) { h.released = true; continue; } h.released = true; ReleaseCount++; var act = h.resume; h.resume = null; if (act != null) { try { act(); } catch (System.Exception ex) { Debug.LogException(ex); } } } Log("보스 등장 개시 — released=" + ReleaseCount); } // ───────────────────────────────────────────────────────────────────── // 아레나 — 추적 반경 데이터화 · 리쉬 · 리셋 // ───────────────────────────────────────────────────────────────────── /// /// 등장 직전에 MonsterList 행의 f_BaseChaseRange 를 아레나 반경으로 바꾼다(코드 상수 0 · MobActor 무수정). /// MobActor.Set(mobtable,…) 이 이 값을 읽어 복사하므로 「등장 전」에 해야 한다. /// static void ApplyChaseRange(WLBossArenaSettings cfg) { if (s_chaseApplied || !cfg.arenaEnabled || cfg.bossChaseRange <= 0f || cfg.bossMonsterId == 0) return; var row = GetMonsterRow(cfg.bossMonsterId); if (row == null) { Log("추적 반경 미적용 — MonsterList 행 없음(id=" + cfg.bossMonsterId + ")"); return; } s_chaseBefore = row.f_BaseChaseRange; s_chaseAfter = WriteChaseRange(row, cfg.bossChaseRange); s_chaseApplied = true; Log("추적 반경 데이터 적용 — id=" + cfg.bossMonsterId + " " + s_chaseBefore.ToString("F1") + " → " + s_chaseAfter.ToString("F1")); } /// /// MonsterList 행에 추적 반경을 쓰는 유일한 이음매(프로브가 같은 경로를 직접 부를 수 있다). /// MobActor.cs:92 가 Set 시점에 이 값을 복사하고(Check_Battle), MobActor.cs:356(Check_Patrol)은 이 행을 직접 읽는다. /// public static float WriteChaseRange(MonsterTableData row, float value) { if (row == null) return 0f; row.f_BaseChaseRange = value; return row.f_BaseChaseRange; } static void RestoreChaseRange(WLBossArenaSettings cfg) { if (!s_chaseApplied || !cfg.restoreChaseRangeOnCleanup) return; var row = GetMonsterRow(cfg.bossMonsterId); if (row != null) WriteChaseRange(row, s_chaseBefore); s_chaseApplied = false; Log("추적 반경 원복 — " + s_chaseBefore.ToString("F1")); } static MonsterTableData GetMonsterRow(int monsterId) { try { if (DSUtil.CheckNull(table_monsterlist.Ins)) return null; return table_monsterlist.Ins.Get_Data_orNull(monsterId); } catch (System.Exception) { return null; } // 행이 없으면 Get_Data_orNull 의 Dictionary 가 던진다 } /// 아레나 중심(보류/등록된 FieldBossData 기준). 없으면 SO 값. public static Vector3 Center { get { for (int i = 0; i < s_held.Count; i++) if (!DSUtil.CheckNull(s_held[i].fbd)) return s_held[i].center; var cfg = Cfg; return cfg != null ? cfg.arenaCenter : Vector3.zero; } } static float DistXZ(Vector3 a, Vector3 b) { float dx = a.x - b.x, dz = a.z - b.z; return Mathf.Sqrt(dx * dx + dz * dz); } /// 리쉬 판정 결과 — 0 유지 · 1 PC 이탈 리셋 · 2 보스 이탈 리셋. public const int kLeashNone = 0, kLeashPlayerOut = 1, kLeashBossOut = 2; /// /// 리쉬 판정(순수 함수 · Actor 접근 0). 프로브가 가짜 좌표·시각으로 그대로 부를 수 있다. /// hasPlayer = PC 참조가 있는가(없으면 PC 이탈 판정을 건너뛴다). /// 유예 시간 상태(s_playerOutsideSince)만 갱신하고, 실제 리셋은 호출측(LeashTick)이 한다. /// public static int EvaluateLeash(Vector3 bossPos, Vector3 pcPos, bool hasPlayer, Vector3 center, float now) { var cfg = Cfg; if (cfg == null || !cfg.arenaEnabled || !cfg.leashEnabled) return kLeashNone; if (cfg.resetWhenPlayerLeavesArena && hasPlayer) { float pcd = DistXZ(pcPos, center); if (pcd > cfg.arenaRadius + cfg.playerLeaveMargin) { if (s_playerOutsideSince < 0f) s_playerOutsideSince = now; if (now - s_playerOutsideSince >= cfg.playerLeaveGraceSeconds) { s_playerOutsideSince = -1f; LastLeashDistance = pcd; return kLeashPlayerOut; } } else s_playerOutsideSince = -1f; } float bd = DistXZ(bossPos, center); if (bd > cfg.arenaRadius + cfg.leashMargin) { LastLeashDistance = bd; return kLeashBossOut; } return kLeashNone; } /// 마지막 리쉬 판정에서 쓴 거리(진단). public static float LastLeashDistance; /// 경계 검사 1틱. 런너가 부르고, 프로브도 직접 부를 수 있다. public static void LeashTick() { var cfg = Cfg; if (cfg == null || !Active || !cfg.arenaEnabled || !cfg.leashEnabled || s_leashFaulted) return; if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) { s_playerOutsideSince = -1f; return; } Vector3 center = Center; float now = Application.isPlaying ? Time.unscaledTime : 0f; var pc = MyValue.MyPC; bool hasPc = !DSUtil.CheckNull(pc); int verdict = EvaluateLeash(s_boss.Get_position(), hasPc ? pc.Get_position() : Vector3.zero, hasPc, center, now); if (verdict == kLeashNone) return; ResetBoss(cfg, center, (verdict == kLeashPlayerOut ? "PC 아레나 이탈 " : "보스 아레나 이탈 ") + LastLeashDistance.ToString("F1") + "m"); } /// 보스를 아레나 중심으로 되돌린다. 회복은 reincarnation:true 라 Spawned 가 다시 나지 않는다. static void ResetBoss(WLBossArenaSettings cfg, Vector3 center, string reason) { if (DSUtil.CheckNull(s_boss)) return; s_boss.Del_Target(true); if (cfg.resetHealsBoss) s_boss.On_Regen(true, cfg.resetHealRate); s_boss.Set_Warp(center); ResetCount++; Log("보스 리셋 #" + ResetCount + " — " + reason); } // ───────────────────────────────────────────────────────────────────── // 처치 후 정리 // ───────────────────────────────────────────────────────────────────── /// 보스 처치 후 정리 — 재등장 코루틴 차단 · 반경 원복 · (옵션) 존 비활성. public static void Cleanup() { var cfg = Cfg; s_cleanupDueTime = -1f; if (cfg == null || CleanupDone) return; CleanupDone = true; if (cfg.suppressRegenAfterBossKill) { for (int i = 0; i < s_held.Count; i++) if (!DSUtil.CheckNull(s_held[i].fbd)) s_held[i].fbd.StopAllCoroutines(); // FieldBossData.AfterDie 의 RegenTime 코루틴을 끊는다 } if (cfg.deactivateZonesAfterBossKill && cfg.zoneSpawnerIds != null) { var spawners = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); for (int i = 0; i < spawners.Length; i++) { for (int j = 0; j < cfg.zoneSpawnerIds.Length; j++) if (spawners[i].SpawnerID == cfg.zoneSpawnerIds[j]) { spawners[i].StopAllCoroutines(); spawners[i].gameObject.SetActive(false); break; } } } RestoreChaseRange(cfg); if (cfg.rearmGateAfterBossKill) { s_gateOpen = false; BossKilled = false; BossSpawned = false; CleanupDone = false; for (int i = 0; i < s_held.Count; i++) s_held[i].released = false; Log("정리 완료 — 게이트 재무장"); } else Log("정리 완료 — 리젠 없음"); } // ───────────────────────────────────────────────────────────────────── // 런너 (숨김 GameObject 1개 · 플레이 모드 전용) // ───────────────────────────────────────────────────────────────────── static BossArenaRunner s_runner; static void EnsureRunner() { if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return; // HideAndDontSave 는 씬 전환에도 살아남는다(DontDestroyOnLoad 불필요) · 씬·프리팹에 저장되지 않는다. var go = new GameObject("[WL813d] BossArenaRunner"); go.hideFlags = HideFlags.HideAndDontSave; s_runner = go.AddComponent(); } /// 런너가 매 프레임 부른다(에디트 모드에서는 프로브가 직접 부를 수 있다). internal static void Tick() { var cfg = Cfg; if (cfg == null) return; float now = Time.unscaledTime; if (s_openDueTime > 0f && now >= s_openDueTime) Release(cfg); if (s_cleanupDueTime > 0f && now >= s_cleanupDueTime) Cleanup(); if (now < s_nextLeashCheck) return; s_nextLeashCheck = now + Mathf.Max(0f, cfg.leashCheckIntervalSeconds); try { LeashTick(); } catch (System.Exception ex) { s_leashFaulted = true; Debug.LogException(ex); } } // ───────────────────────────────────────────────────────────────────── // 진단 · 초기화 // ───────────────────────────────────────────────────────────────────── static void Log(string msg) { LastLog = msg; var cfg = Cfg; if (cfg != null && cfg.verboseLog) Debug.Log("[BossArena] " + msg); } /// 프로브·재시작용: 모든 상태를 비운다(구독은 Unsubscribe 로 따로 끊는다). public static void ResetAll() { var cfg = Cfg; if (cfg != null) RestoreChaseRange(cfg); s_held.Clear(); s_zones.Clear(); s_gateZones.Clear(); s_armed = s_gateOpen = s_leashFaulted = s_chaseApplied = false; s_boss = null; s_openDueTime = s_cleanupDueTime = -1f; s_playerOutsideSince = -1f; s_nextLeashCheck = 0f; s_chaseBefore = s_chaseAfter = 0f; BossSpawned = BossKilled = CleanupDone = false; TotalZoneKills = ResetCount = ReleaseCount = HoldCount = PassThroughCount = 0; LastLog = ""; } /// 813p 「다음 런」 — 게이트만 다시 잠근다. 존 누적 카운터(s_zones)·구독·보류 목록은 건드리지 않는다. public static void ResetGate() { if (!s_armed) return; s_gateOpen = false; BossSpawned = BossKilled = CleanupDone = false; s_openDueTime = s_cleanupDueTime = -1f; s_playerOutsideSince = -1f; Log("게이트 재무장(813p 다음 런)"); } /// 프로브용 — 보스 참조를 직접 넣는다(에디트 모드에는 실제 스폰이 없다). public static void SetBossForProbe(MobActor boss) { s_boss = boss; } /// 프로브용 — PC 이탈 유예 타이머만 비운다(리쉬 시나리오 사이 초기화). public static void ResetLeashState() { s_playerOutsideSince = -1f; s_leashFaulted = false; } /// PC 가 아레나 밖으로 나간 시각(unscaled · 안이면 −1). public static float PlayerOutsideSince { get { return s_playerOutsideSince; } } } /// BossArena 의 시간 축(개장 지연 · 정리 지연 · 리쉬 주기). 숨김 GameObject 1개. internal sealed class BossArenaRunner : MonoBehaviour { void Update() { BossArena.Tick(); } } }