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