Project_WL/Assets/WL/Combat/Boss/BossArena.cs

606 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// <summary>보스전 게이트·아레나. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개).</summary>
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<Held> s_held = new List<Held>(2);
static readonly Dictionary<int, Zone> s_zones = new Dictionary<int, Zone>(8);
static readonly List<int> s_gateZones = new List<int>(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; } }
/// <summary>구독 여부 — 게이트가 실제로 CombatEvents 에 붙었는지 확인용.</summary>
public static bool Subscribed { get { return s_subscribed; } }
/// <summary>등장한 보스(리쉬 대상). 미등장이면 null.</summary>
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줄)
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 이 FieldBossData 의 스폰을 붙잡을지 판정한다.
/// true = 원본 Start 가 그 자리에서 return(보스 미등장) · 게이트가 열릴 때 resume 으로 Start 를 다시 부른다.
/// false = 원본 스폰 코드 그대로 진행(= 813e 상시 배치).
/// </summary>
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();
}
/// <summary>CombatEvents 구독(중복 구독은 CombatEventList.Add 가 막는다).</summary>
public static void EnsureSubscribed()
{
if (s_subscribed) return;
CombatEvents.Killed.Add(OnKilled);
CombatEvents.Spawned.Add(OnSpawned);
s_subscribed = true;
}
/// <summary>구독 해제(프로브·롤백용).</summary>
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);
}
/// <summary>스폰 1건 반영. 프로브가 가짜 스폰을 넣을 때도 이 경로를 쓴다.</summary>
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);
}
}
/// <summary>처치 1건 반영 + 게이트 재판정. 프로브가 가짜 Killed 를 넣을 때도 이 경로를 쓴다.</summary>
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();
}
// ─────────────────────────────────────────────────────────────────────
// 게이트 판정 · 개방
// ─────────────────────────────────────────────────────────────────────
/// <summary>조건을 다시 보고 충족하면 게이트를 연다. 열려 있으면 아무 일도 하지 않는다.</summary>
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;
}
/// <summary>게이트를 강제로 연다(QA·프로브용 · 조건 무시).</summary>
public static void ForceOpenGate()
{
var cfg = Cfg;
if (cfg == null || !s_armed) return;
s_gateOpen = true;
Log("게이트 강제 개방");
Release(cfg);
}
/// <summary>보류 중인 FieldBossData 를 실제로 등장시킨다(원본 Start 재진입).</summary>
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);
}
// ─────────────────────────────────────────────────────────────────────
// 아레나 — 추적 반경 데이터화 · 리쉬 · 리셋
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 등장 직전에 MonsterList 행의 f_BaseChaseRange 를 아레나 반경으로 바꾼다(코드 상수 0 · MobActor 무수정).
/// MobActor.Set(mobtable,…) 이 이 값을 읽어 복사하므로 「등장 전」에 해야 한다.
/// </summary>
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"));
}
/// <summary>
/// MonsterList 행에 추적 반경을 쓰는 유일한 이음매(프로브가 같은 경로를 직접 부를 수 있다).
/// MobActor.cs:92 가 Set 시점에 이 값을 복사하고(Check_Battle), MobActor.cs:356(Check_Patrol)은 이 행을 직접 읽는다.
/// </summary>
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 가 던진다
}
/// <summary>아레나 중심(보류/등록된 FieldBossData 기준). 없으면 SO 값.</summary>
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);
}
/// <summary>리쉬 판정 결과 — 0 유지 · 1 PC 이탈 리셋 · 2 보스 이탈 리셋.</summary>
public const int kLeashNone = 0, kLeashPlayerOut = 1, kLeashBossOut = 2;
/// <summary>
/// 리쉬 판정(순수 함수 · Actor 접근 0). 프로브가 가짜 좌표·시각으로 그대로 부를 수 있다.
/// hasPlayer = PC 참조가 있는가(없으면 PC 이탈 판정을 건너뛴다).
/// 유예 시간 상태(s_playerOutsideSince)만 갱신하고, 실제 리셋은 호출측(LeashTick)이 한다.
/// </summary>
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;
}
/// <summary>마지막 리쉬 판정에서 쓴 거리(진단).</summary>
public static float LastLeashDistance;
/// <summary>경계 검사 1틱. 런너가 부르고, 프로브도 직접 부를 수 있다.</summary>
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");
}
/// <summary>보스를 아레나 중심으로 되돌린다. 회복은 reincarnation:true 라 Spawned 가 다시 나지 않는다.</summary>
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);
}
// ─────────────────────────────────────────────────────────────────────
// 처치 후 정리
// ─────────────────────────────────────────────────────────────────────
/// <summary>보스 처치 후 정리 — 재등장 코루틴 차단 · 반경 원복 · (옵션) 존 비활성.</summary>
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<MobControlMgr>(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<BossArenaRunner>();
}
/// <summary>런너가 매 프레임 부른다(에디트 모드에서는 프로브가 직접 부를 수 있다).</summary>
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);
}
/// <summary>프로브·재시작용: 모든 상태를 비운다(구독은 Unsubscribe 로 따로 끊는다).</summary>
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 = "";
}
/// <summary>813p 「다음 런」 — 게이트만 다시 잠근다. 존 누적 카운터(s_zones)·구독·보류 목록은 건드리지 않는다.</summary>
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 다음 런)");
}
/// <summary>프로브용 — 보스 참조를 직접 넣는다(에디트 모드에는 실제 스폰이 없다).</summary>
public static void SetBossForProbe(MobActor boss) { s_boss = boss; }
/// <summary>프로브용 — PC 이탈 유예 타이머만 비운다(리쉬 시나리오 사이 초기화).</summary>
public static void ResetLeashState() { s_playerOutsideSince = -1f; s_leashFaulted = false; }
/// <summary>PC 가 아레나 밖으로 나간 시각(unscaled · 안이면 1).</summary>
public static float PlayerOutsideSince { get { return s_playerOutsideSince; } }
}
/// <summary>BossArena 의 시간 축(개장 지연 · 정리 지연 · 리쉬 주기). 숨김 GameObject 1개.</summary>
internal sealed class BossArenaRunner : MonoBehaviour
{
void Update() { BossArena.Tick(); }
}
}