// ─────────────────────────────────────────────────────────────────────────────
// 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 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
// WL-813w3 (2026-09-09 · PD 지시 #813) — 근거 = qa/W2-6_재채점_v4.md **D-27**
//
// 🔴 실측 — 「다음 런」(RunDirector.Restart → ResetGate) 뒤 두 번째 런이 통째로 망가진다.
// 원인은 **한 개가 아니라 세 개**였다(발주서 §1-4 의 「보류 재무장 안 함」 추정은 그중 하나다).
// ① 게이트 조기 개방 — ResetGate 가 `s_gateOpen` 만 내리고 **존 누적 카운터를 그대로 둔다.**
// Either 판정은 `TotalZoneKills >= requiredKills` 라 지난 런의 누적(실측 121/54)이 남아
// 두 번째 런 **첫 처치에서 즉시** 게이트가 열렸다(Q6 R3 = 런 시작 30 s 안 open).
// ② 보스 미등장 — 보류 목록의 `released` 가 true 인 채라 `Release()` 가 전부 건너뛴다.
// 게다가 `BossSpawned` 를 무조건 false 로 내려, **살아 있던 보스**마저 813p 폴백
// (`RunDirector.Tick` 의 `BossArena.BossSpawned` 검사)에 안 걸려 `BossStarted` 가 안 났다
// → 813c HP 바가 재바인딩되지 않고 이전 런의 바가 화면에 남는다(Q6 `PD3_07`).
// ③ 런 밖 리쉬 — `Tick()` 이 런 상태를 안 보므로 결과창·다음 런 대기 중에도 리쉬가 계속 걸려
// 보스가 5 %씩 회복했다(Q6 실측 리셋 68 → 190 · 결과 「HP 남음 96 %」).
//
// ■ 수리 (전부 SO 스위치 · 끄면 813d/813p 동작 그대로)
// ① `restartRebasesGateKills` — ResetGate 가 존 누적 처치·스폰 수를 **이 런 기준으로 재스냅샷**한다
// (`RunDirector` 가 존 진행률에 쓰는 「스냅샷 차감」과 같은 방식 · BossArena 누적값 자체는 유지).
// ② `restartRearmsHoldSpawn` — 보류 목록의 `released`·`resume` 을 되살린다.
// `restartResetsLivingBoss` — 살아 있는 보스는 리쉬 리셋 1회(워프·만피·타깃 해제)로 처리하고
// `BossSpawned` 를 **유지**한다(재스폰 0 · 클론 증가 0).
// `restartRegensDeadBoss` — 죽은 보스는 게이트가 열릴 때 **원본 리스폰 규칙**
// (`FieldBossData.AfterDie` 의 `MobActor.On_Regen()`)을 한 번 태워 되살린다.
// 🔴 `FieldBossData.Start` 재진입(= resume)으로 되살리지 않는다 — 그러면 클론이 런마다 하나씩 는다.
// ③ `suspendOutsideRun` — 런이 진행 중이 아니면 리쉬 판정·게이트 평가를 쉰다.
// 런 축이 꺼져 있거나 런이 한 번도 시작된 적 없으면 이 스위치와 무관하게 813d 그대로 돈다(C8).
//
// ■ 잡몹 유입 차단(§1-3) — `HoldSpawn` 은 FieldBossData 전용이라 존 스포너에는 안 쓰인다(실측).
// `holdZoneSpawnNearArena` 가 보스 생존 중 아레나 근처 `MobControlMgr` 의 **리젠 예약 코루틴만** 끊는다
// (원본 `Set_Coroutine` → `StartCoroutine` · `Cleanup` 의 `StopAllCoroutines` 과 같은 이음매).
// 이미 아레나 안에 있는 잡몹은 원본 규칙 그대로 둔다.
// ─────────────────────────────────────────────────────────────────────────────
// WL-813w5 (2026-09-09 · PD 지시 #813) — 근거 = qa/W2-8_재채점_v6.md **D-39**
//
// 🔴 실측 — 813w3 의 「다음 런」 처리(워프·만피·타깃 해제)를 넣었는데도 앞판 보스가 PC 를 존0 까지 따라와
// 두 번째 판이 20 초 만에 보스전이 된다(3회 중 2회 · 보스 좌표 139 → 123.7 → 127.5 · 존 단계에서 보스 HP 1.0 → 0.527).
// 원본 코드를 읽어 원인을 셋으로 확정했다(발주서 §1-1 후보 ①②③ 전부 해당).
//
// ① 🔴 **되돌림이 자기 호출 안에서 되돌려진다** — 원본 `MobActor.On_Regen`(MobActor.cs:138)은
// `base.On_Regen` 뒤에 **`Check_Battle()` 을 부른다**(MobActor.cs:141). `Check_Battle`(MobActor.cs:347)은
// `b_IsFirstAttack || isTarget` 이면 자기 위치 반경 `f_BaseChaseRange` 안의 적을 찾아 `Change_MobStatus(Battle)`
// 을 걸고, `Change_MobStatus(Battle)`(MobActor.cs:322)은 **`FindNextTarget(10000f)`** = 사실상 무한 반경 재타깃이다.
// 813w3 의 `ResetBoss` 는 `Del_Target` → **`On_Regen`(만피)** → `Set_Warp(center)` 순서였다 →
// 재아그로가 **「런이 끝난 자리」(= PC 옆)** 에서 일어나고, 워프 뒤에도 `MobStatus=Battle · m_Target=PC` 가 남는다.
// → 수리: `nextRunWarpBeforeHeal` — **워프를 만피보다 먼저** 하고, 회복 뒤에 타깃·전투 상태를 한 번 더 뗀다.
//
// ② 🔴 **런 밖에서는 리쉬 판정 자체가 꺼져 있다** — 813w3 §1-4 ③ `suspendOutsideRun` 이 `LeashTick` 을 통째로
// 건너뛴다(회복 누적을 막으려는 조치였다). 그래서 **결과창~[다음 런] 사이**에 보스가 아무 데나 걸어갈 수 있고,
// `ResetGate` 는 [다음 런]을 누른 **그 순간 1회만** 되돌린다.
// → 수리: `leashOutsideRunDistanceOnly` — 런 밖에서도 **거리 판정만** 유지한다(**회복 0** 이라 D-27 ③ 재발 없음).
// `parkBossOnRunEnd` — `RunEvents.RunEnded` 를 구독해 **런이 끝나는 순간** 보스를 아레나에 고정한다.
//
// ③ 🔴 **게이트가 존 진행 0/2 인데 t≈19.6 에 열린다** — `openGateAtSec` 는 170(에셋 실측)이라 TimeLimit 이 아니고,
// `RunDirector.NoteBossSpawned`(RunDirector.cs:261)가 `if (!s_gateOpened) OpenBossGate(AlreadySpawned)` 로
// **보스 `Spawned` 한 건에 게이트를 연다**. `BossArena.EvaluateGate` 의 `Either`(`ZonesCleared`) 경로도
// 지난 런에서 굳은 `alive=0` 으로 열릴 수 있다.
// → 수리: `gateMinSeconds`(60 s) — 런 시작 뒤 그 시간 전에는 **진짜 존 전멸(ZonesCleared)·시간제한·외부 호출을 뺀**
// 개방 경로를 막는다. `RunDirector` 는 이 판정을 부르는 **2줄**만 바뀐다.
//
// ■ 홀드(`nextRunBossHoldSeconds` 30 s) — 워프 뒤 그 시간 동안 이동·타깃을 금지한다.
// 🔴 원본 `Actor.StopMove_Imm`(Actor.cs:2146) · `Actor.IsStop`(Actor.cs:53) · `MobActor.NoUpdate_byTool` 은
// **전부 protected/private 라 우리 코드에서 부를 수 없다**(실측 · `PCActor.WL_StopNavMove` 는 PC 전용).
// 그래서 공개 API 만으로 같은 결과를 만든다 —
// `Del_Target(true)` + `Change_MobStatus(eMobStatus.PatrolWait)`(원본이 `Del_Target`·`Play_Idle`·순찰 대기 시간을
// 다시 채운다 = 순찰로 넘어가지 않는다) + `Get_Agent()`(Actor.cs:900 공개)로 `StopMove_Imm` 본체 재현
// (`isStopped=true` · `ResetPath()` · `velocity=0` · `isOnNavMesh` 가드는 #813x 선례).
// ─────────────────────────────────────────────────────────────────────────────
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;
/// 813w3: 「다음 런」 재무장용 원본 델리게이트(resume 은 개방 때 null 이 된다).
public System.Action resumeOriginal;
public Vector3 center;
public bool released;
}
/// 813w3: killBase·spawnBase = 이 런 시작 시점의 스냅샷(게이트 판정은 그 차이만 본다).
sealed class Zone { public int alive, kills, spawns, killBase, spawnBase; }
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;
// 813w3 §1-3 · §1-4
static readonly List s_nearZones = new List(4);
static bool s_nearZonesScanned, s_regenPending;
static int s_totalKillBase;
static int s_runActiveOverride; // 0 자동(RunDirector) · 1 강제 on · −1 강제 off (프로브)
// 813w5 §1-2 — 다음 런 앞판 보스 정리(D-39)
static float s_bossHoldUntil = -1f;
static float s_probeTime = -1f; // ≥0 이면 프로브가 넣은 가짜 시각(에디트 모드 검증용)
static bool s_runSubscribed;
// ───────────────────────────────────────── 진단(프로브·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; }
// ───────────────────────────────────────── 813w3 진단
/// 「다음 런」 재무장 수 · 살아 있는 보스 리셋 수 · 죽은 보스 재등장 수.
public static int RearmCount, LivingBossResetCount, DeadBossRegenCount;
/// 런 밖이라 건너뛴 리쉬 판정 수 · 게이트 평가 수(D-27 ③ 증거).
public static int LeashSuspendedTicks, GateSuspendedEvals;
/// 존 스포너 리젠 예약을 끊은 횟수 · 대상 스포너 수(§1-3).
public static int ZoneSpawnHoldTicks, ZoneSpawnHoldTargets;
/// 이 런의 게이트 판정 기준 처치 수(= TotalZoneKills − 런 시작 스냅샷).
public static int GateKills { get { int v = TotalZoneKills - s_totalKillBase; return v > 0 ? v : 0; } }
// ───────────────────────────────────────── 813w5 진단(D-39)
/// 보스를 아레나에 고정한 횟수(런 종료 + 다음 런) · 홀드 틱 수 · 런 밖 거리 리셋 수 · 게이트 조기 개방 차단 수.
public static int ParkCount, BossHoldTicks, OutsideRunLeashResets, GateMinBlocked;
/// RunEnded 를 받아 「보스 고정」 경로에 들어간 횟수(보스 유무와 무관 · 구독·스위치 증거).
public static int RunEndNotices;
/// 마지막 RunEnded 의 결과(진단).
public static WL.Combat.Run.RunOutcome LastRunOutcome;
/// 마지막으로 게이트를 막았을 때의 런 경과 초(진단).
public static float LastGateHeldElapsed = -1f;
/// 홀드 종료 시각(unscaled · 홀드 중이 아니면 −1).
public static float BossHoldUntil { get { return s_bossHoldUntil; } }
/// 지금 보스가 「이동·타깃 금지」 홀드 중인가.
public static bool BossHeld { get { return s_bossHoldUntil > 0f && Now() < s_bossHoldUntil; } }
/// RunEvents.RunEnded 구독 여부(프로브).
public static bool RunSubscribed { get { return s_runSubscribed; } }
static WLBossArenaSettings Cfg { get { return WLBossArenaSettings.Instance; } }
static bool Active { get { return WLBossArenaSettings.Active; } }
/// 시간 축 하나(프로브가 가짜 시각을 넣을 수 있다 · 에디트 모드는 0).
static float Now() { return s_probeTime >= 0f ? s_probeTime : (Application.isPlaying ? Time.unscaledTime : 0f); }
/// 프로브용 — 가짜 시각을 넣는다(음수면 실시간으로 되돌린다).
public static void SetProbeTime(float t) { s_probeTime = t; }
///
/// 813w3 §1-4 ③ — 런이 진행 중인가. 런 밖(RunEnded 뒤 · 결과창)에서는 리쉬·게이트 평가를 쉰다.
/// 🔴 C8 — 런 축(WLRunSettings)이 꺼졌거나 런이 한 번도 시작된 적 없으면 언제나 true(813d 그대로).
///
public static bool RunActive
{
get
{
var cfg = Cfg;
if (cfg == null || !cfg.suspendOutsideRun) return true;
if (s_runActiveOverride != 0) return s_runActiveOverride > 0;
if (!WL.Combat.Run.WLRunSettings.Enabled) return true;
if (WL.Combat.Run.RunDirector.StartCount <= 0) return true;
return WL.Combat.Run.RunDirector.IsRunning;
}
}
/// 프로브용 — 런 상태를 강제한다(0 자동 · 1 진행 중 · −1 런 밖).
public static void SetRunActiveForProbe(int mode) { s_runActiveOverride = mode; }
// ─────────────────────────────────────────────────────────────────────
// 훅 (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,
resumeOriginal = resume, // 813w3: 「다음 런」 재무장에 다시 쓴다(개방 때 resume 은 null 이 된다)
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()
{
EnsureRunSubscribed();
if (s_subscribed) return;
CombatEvents.Killed.Add(OnKilled);
CombatEvents.Spawned.Add(OnSpawned);
s_subscribed = true;
}
/// 구독 해제(프로브·롤백용).
public static void Unsubscribe()
{
UnsubscribeRun();
if (!s_subscribed) return;
CombatEvents.Killed.Remove(OnKilled);
CombatEvents.Spawned.Remove(OnSpawned);
s_subscribed = false;
}
// 813w5 §1-2 ② — 런이 끝나는 순간(결과창) 보스를 아레나에 고정한다.
static readonly CombatHandler h_runEnded = OnRunEnded;
/// RunEvents.RunEnded 구독(중복 안전 · 813w5).
public static void EnsureRunSubscribed()
{
if (s_runSubscribed) return;
WL.Combat.Run.RunEvents.RunEnded.Add(h_runEnded);
s_runSubscribed = true;
}
/// RunEnded 구독 해제(프로브·롤백용).
public static void UnsubscribeRun()
{
if (!s_runSubscribed) return;
WL.Combat.Run.RunEvents.RunEnded.Remove(h_runEnded);
s_runSubscribed = false;
}
///
/// 813w5 §1-2 — 런 종료(Win/Timeout/Abandon) 시 살아 있는 보스를 아레나 중심에 고정한다.
/// 🔴 D-39: 813w3 은 [다음 런]을 누른 순간에만 되돌렸다 → 결과창 동안 보스가 존까지 걸어 나갔다.
///
static void OnRunEnded(in WL.Combat.Run.RunResult e)
{
var cfg = Cfg;
if (cfg == null || !Active || !cfg.parkBossOnRunEnd) return;
RunEndNotices++;
LastRunOutcome = e.outcome; // 문자열 조립 0(GC) — 이유는 고정 문자열로 남긴다
ParkBoss("런 종료");
}
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)
{
s_regenPending = false; // 813w3: 죽었으니 「다음 런에 되살릴 대상」으로 다시 잡는다(ResetGate 가 세운다)
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)
{
// 813w3 §1-4 ③ — 런 밖(결과창·다음 런 대기)에서는 게이트를 평가하지 않는다.
if (!RunActive) { GateSuspendedEvals++; return; }
EvaluateGate();
}
}
// ─────────────────────────────────────────────────────────────────────
// 게이트 판정 · 개방
// ─────────────────────────────────────────────────────────────────────
/// 조건을 다시 보고 충족하면 게이트를 연다. 열려 있으면 아무 일도 하지 않는다.
public static void EvaluateGate()
{
var cfg = Cfg;
if (cfg == null || s_gateOpen || !s_armed) return;
// 🔴 813w3 §1-4 ① — 누적값이 아니라 **이 런의 처치 수**로 본다(ResetGate 가 기준선을 다시 잡는다).
// 813p RunDirector 의 존 진행률(「스냅샷 차감」)과 같은 방식이다.
bool cleared = ZonesCleared(cfg);
bool killed = cfg.requiredKills > 0 && GateKills >= 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;
// 🔴 813w5 §1-2 ③ — 런 시작 뒤 gateMinSeconds 전에는 열지 않는다(D-39 · t≈19.6 개방).
// 🔴 여기의 `cleared` 는 **아레나 자체 판정**(게이트 존 alive==0)이라 지난 런에서 굳은 카운터로도 참이 될 수 있다
// (= D-27/D-39 의 실패 모드 그 자체). 그래서 `gateMinAllowsZonesCleared` 예외를 **주지 않는다**.
// 「진짜 존 전멸」 예외는 런 축의 존 진행률(RunDirector 의 zoneClearKills 18/20)로 판정하는
// `OpenBossGate(RunGateReason.ZonesCleared)` 경로에만 준다.
if (GateHeldByMinSeconds(WL.Combat.Run.RunGateReason.None)) return;
s_gateOpen = true;
Log("게이트 열림 — cleared=" + cleared + " kills=" + GateKills + "/" + cfg.requiredKills +
"(누적 " + TotalZoneKills + ") 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;
// 813w3 §1-4 ①: 「이 런에 한 번이라도 스폰됐는가」 — 지난 런의 스폰 이력으로 전멸을 오판하지 않는다.
if (cfg.requireZoneSpawnedOnce && z.spawns - z.spawnBase <= 0) return false;
if (z.alive > 0) return false;
}
return true;
}
///
/// 813w5 §1-2 ③ — 「런 시작 뒤 gateMinSeconds 전인가」. true 면 게이트를 열지 않는다(D-39).
/// 🔴 예외 = ⓐ 진짜 존 전멸(`ZonesCleared` · SO 로 끌 수 있다) ⓑ 시간제한(`TimeLimit`) ⓒ 외부 호출(`External` · QA·치트).
/// 🔴 C8 — `gateMinSeconds = 0` · 런 축 off · 런이 한 번도 시작 안 됨 · 런 밖 → 언제나 false(813p/813w3 그대로).
/// `RunDirector` 는 이 판정을 부르는 2줄만 바뀐다(발주서 「RunDirector ≤10줄」).
///
public static bool GateHeldByMinSeconds(WL.Combat.Run.RunGateReason reason)
{
var cfg = Cfg;
if (cfg == null || !Active || cfg.gateMinSeconds <= 0f) return false;
if (reason == WL.Combat.Run.RunGateReason.TimeLimit || reason == WL.Combat.Run.RunGateReason.External) return false;
if (reason == WL.Combat.Run.RunGateReason.ZonesCleared && cfg.gateMinAllowsZonesCleared) return false;
if (!WL.Combat.Run.WLRunSettings.Enabled) return false;
if (WL.Combat.Run.RunDirector.StartCount <= 0 || !WL.Combat.Run.RunDirector.IsRunning) return false;
float el = WL.Combat.Run.RunDirector.Elapsed;
if (el >= cfg.gateMinSeconds) return false;
GateMinBlocked++;
LastGateHeldElapsed = el;
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); }
}
}
RegenDeadBoss(cfg);
Log("보스 등장 개시 — released=" + ReleaseCount);
}
///
/// 813w3 §1-4 ② — 「다음 런」에서 게이트가 열릴 때, 지난 런에 죽어 있던 보스를 **원본 리스폰 규칙**으로 되살린다.
/// 원본 규칙 실측 = `FieldBossData.AfterDie` → `Set_Coroutine(() => m_FieldBoss.On_Regen(), RegenTime)`
/// (813d `suppressRegenAfterBossKill` 이 그 코루틴을 끊어 두므로 시간이 지나도 스스로는 안 돌아온다).
/// 여기서 같은 `MobActor.On_Regen()` 을 한 번 태운다 — `OriginalPos` 복귀 · 스폰 이펙트 ·
/// `CombatEvents.RaiseSpawned`(= `BossSpawned` 재설정 · 813p 폴백이 `BossStarted` 를 낸다)까지 원본 경로 그대로.
/// 🔴 `resume`(= FieldBossData.Start) 재진입으로 되살리지 않는다 — 그러면 클론이 런마다 하나씩 늘어난다.
///
static void RegenDeadBoss(WLBossArenaSettings cfg)
{
if (!s_regenPending) return;
s_regenPending = false;
if (cfg == null || !cfg.restartRegensDeadBoss) return;
if (DSUtil.CheckNull(s_boss) || !s_boss.IsDead()) return;
if (!Application.isPlaying) { Log("죽은 보스 재등장 보류 — 에디트 모드"); return; }
DeadBossRegenCount++;
BossKilled = false; CleanupDone = false;
s_boss.On_Regen(); // reincarnation=false → Set_Warp(OriginalPos) + Spawned(isBoss)
Log("죽은 보스 재등장 #" + DeadBossRegenCount + " — 원본 On_Regen()(FieldBossData.AfterDie 와 같은 경로)");
}
// ─────────────────────────────────────────────────────────────────────
// 아레나 — 추적 반경 데이터화 · 리쉬 · 리셋
// ─────────────────────────────────────────────────────────────────────
///
/// 등장 직전에 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; }
// 🔴 813w3 §1-4 ③ — 런 밖(RunEnded 뒤 · 결과창 · 다음 런 대기)에서는 리쉬 「회복」을 걸지 않는다.
// Q6 실측: 여기서 리셋이 68 → 190 으로 계속 쌓여 보스 HP 가 56 % → 96 % 로 되돌아왔다.
// 🔴 813w5 §1-2 ② — 그런데 813w3 은 판정을 통째로 껐다 → 결과창 동안 보스가 존까지 걸어 나갔다(D-39).
// 이제 런 밖에서도 **거리 판정만** 유지한다(회복 0 이라 D-27 ③ 은 재발하지 않는다).
if (!RunActive)
{
LeashSuspendedTicks++;
if (cfg.leashOutsideRunDistanceOnly) OutsideRunLeashTick(cfg);
else s_playerOutsideSince = -1f;
return;
}
HoldNearbyZoneSpawns(cfg);
Vector3 center = Center;
// 🔴 813w5 §1-2 — 홀드 중(런 종료·다음 런 직후 nextRunBossHoldSeconds)에는 이동·타깃을 금지한다.
// 런이 다시 시작돼도 홀드가 남아 있으면 여기서 계속 붙잡는다(= PC 가 존으로 갈 시간을 준다).
if (BossHeld) { HoldBossTick(cfg, center); return; }
float now = Now();
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");
}
// ─────────────────────────────────────────────────────────────────────
// 813w5 §1-2 — 런 밖 거리 판정 · 보스 홀드(D-39)
// ─────────────────────────────────────────────────────────────────────
///
/// 런 밖(결과창·다음 런 대기)의 1틱 — **거리만** 본다. PC 이탈 판정은 하지 않고(결과창에서 의미가 없다),
/// 보스가 아레나(반경 + leashMargin)를 벗어나면 **회복 없이** 중심으로 되돌린다.
/// 홀드 중이면 되돌리기 대신 홀드를 유지한다.
///
static void OutsideRunLeashTick(WLBossArenaSettings cfg)
{
if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) return;
Vector3 center = Center;
s_playerOutsideSince = -1f;
if (BossHeld) { HoldBossTick(cfg, center); return; }
float d = DistXZ(s_boss.Get_position(), center);
if (d <= cfg.arenaRadius + cfg.leashMargin) return;
LastLeashDistance = d;
OutsideRunLeashResets++;
ResetBossNoHeal(cfg, center, "런 밖 아레나 이탈 " + d.ToString("F1") + "m");
}
///
/// 813w5 §1-2 — 홀드 1틱: 타깃 해제 + 순찰 대기 고정 + NavMesh 정지 + 중심 이탈 시 재워프.
/// 🔴 원본 `StopMove_Imm`(Actor.cs:2146)·`IsStop`(Actor.cs:53)·`NoUpdate_byTool` 은 전부 protected/private 라
/// 부를 수 없다(실측). 공개 API 만으로 같은 결과를 만든다 —
/// `Change_MobStatus(PatrolWait)` 가 원본에서 `Del_Target`·`Play_Idle`·`m_PatrolDelay` 재장전을 하므로
/// 매 틱 다시 걸면 **순찰(PatrolRot/Patroling)로 넘어가지 않는다** = 이동·재아그로 0.
/// GC 0 — 문자열·배열 할당 없음(로그는 홀드 시작/해제에서만 남긴다).
///
static void HoldBossTick(WLBossArenaSettings cfg, Vector3 center)
{
if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) return;
BossHoldTicks++;
ReleaseBossTarget(cfg);
s_boss.Change_MobStatus(eMobStatus.PatrolWait);
StopBossAgent();
if (DistXZ(s_boss.Get_position(), center) > (cfg.nextRunBossHoldRewarpMargin > 0f ? cfg.nextRunBossHoldRewarpMargin : 0f))
s_boss.Set_Warp(center);
}
/// 보스 쪽 타깃(+ 옵션으로 PC 쪽 락·타깃)을 뗀다.
static void ReleaseBossTarget(WLBossArenaSettings cfg)
{
if (DSUtil.CheckNull(s_boss)) return;
s_boss.Del_Target(true);
if (cfg == null || !cfg.nextRunClearsPlayerTarget) return;
s_boss.m_IsLockTarget = false; // 813w 보스 락타깃 해제(Actor.m_IsLockTarget 은 공개 프로퍼티)
var pc = MyValue.MyPC;
if (DSUtil.CheckNull(pc)) return;
if (ReferenceEquals(pc.Get_Target(), s_boss)) pc.Del_Target(true);
}
///
/// 원본 `Actor.StopMove_Imm`(protected · Actor.cs:2146) 본체를 공개 `Get_Agent()`(Actor.cs:900)로 재현한다.
/// `isOnNavMesh` 가드는 원본과 #813x 선례 그대로 — NavMesh 밖/비활성 에이전트에 쓰면 예외가 난다.
///
static void StopBossAgent()
{
if (DSUtil.CheckNull(s_boss)) return;
var agent = s_boss.Get_Agent();
if (DSUtil.CheckNull(agent) || !agent.enabled || !agent.isOnNavMesh) return;
agent.isStopped = true;
agent.ResetPath();
agent.velocity = Vector3.zero;
}
///
/// 813w5 §1-2 — 살아 있는 보스를 아레나 중심에 「고정」한다(런 종료 · 다음 런 공통 진입점).
/// ① 워프 → ② 만피 → ③ 타깃·어그로 해제 → ④ nextRunBossHoldSeconds 동안 이동·타깃 금지.
/// 보스가 없거나 죽어 있으면 false.
///
public static bool ParkBoss(string reason)
{
var cfg = Cfg;
if (cfg == null) return false;
if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) return false;
ParkCount++;
ResetBoss(cfg, Center, reason, true);
ArmBossHold(cfg);
return true;
}
/// 홀드 타이머를 지금부터 다시 건다(0 이면 홀드 없음 = 813w3 그대로).
static void ArmBossHold(WLBossArenaSettings cfg)
{
if (cfg == null || cfg.nextRunBossHoldSeconds <= 0f) { s_bossHoldUntil = -1f; return; }
s_bossHoldUntil = Now() + cfg.nextRunBossHoldSeconds;
Log("보스 홀드 " + cfg.nextRunBossHoldSeconds.ToString("F0") + "s (이동·타깃 금지) — 해제 " +
s_bossHoldUntil.ToString("F1"));
}
/// 회복 없이 되돌리기(런 밖 전용) — 워프 + 타깃 해제 + 순찰 대기 + 정지.
static void ResetBossNoHeal(WLBossArenaSettings cfg, Vector3 center, string reason)
{
if (DSUtil.CheckNull(s_boss)) return;
ReleaseBossTarget(cfg);
s_boss.Set_Warp(center);
s_boss.Change_MobStatus(eMobStatus.PatrolWait);
StopBossAgent();
ResetCount++;
Log("보스 리셋 #" + ResetCount + " — " + reason + " · 회복 없음(런 밖)");
}
// ─────────────────────────────────────────────────────────────────────
// 813w3 §1-3 — 보스 생존 중 아레나 근처 존 스포너의 리젠 예약을 끊는다(잡몹 유입 차단)
// ─────────────────────────────────────────────────────────────────────
///
/// 보스가 살아 있는 동안 아레나 중심에서 (arenaRadius + zoneSpawnHoldMargin) 안의 존 스포너가
/// 예약한 「사망 후 리젠」 코루틴(원본 `MobControlMgr.actDie` → `MyCoroutine.Set_Coroutine`)을 끊는다.
/// 이미 살아 있는 잡몹은 손대지 않는다(발주서 §1-3 「아레나 안 잡몹은 원본 규칙대로」).
/// 로딩이 끝나지 않은 스포너(`Get_LoadComplete()==false`)는 건너뛴다 — 그 `Start()` 코루틴을 끊으면 존이 죽는다.
///
public static void HoldNearbyZoneSpawns(WLBossArenaSettings cfg)
{
if (cfg == null || !Active || !cfg.holdZoneSpawnNearArena || cfg.zoneSpawnHoldMargin < 0f) return;
if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) return;
if (!s_nearZonesScanned) ScanNearZones(cfg);
for (int i = 0; i < s_nearZones.Count; i++)
{
var m = s_nearZones[i];
if (DSUtil.CheckNull(m) || !m.Get_LoadComplete()) continue;
m.StopAllCoroutines();
ZoneSpawnHoldTicks++;
}
}
/// 대상 존 스포너를 1회 훑는다(런당 몇 회 · 매 틱 아님).
static void ScanNearZones(WLBossArenaSettings cfg)
{
s_nearZonesScanned = true;
s_nearZones.Clear();
if (cfg.zoneSpawnerIds == null) return;
Vector3 center = Center;
float lim = cfg.arenaRadius + cfg.zoneSpawnHoldMargin;
var all = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; all != null && i < all.Length; i++)
{
if (DSUtil.CheckNull(all[i])) continue;
bool listed = false;
for (int j = 0; j < cfg.zoneSpawnerIds.Length; j++) if (all[i].SpawnerID == cfg.zoneSpawnerIds[j]) { listed = true; break; }
if (!listed) continue;
if (DistXZ(all[i].transform.position, center) > lim) continue;
s_nearZones.Add(all[i]);
}
ZoneSpawnHoldTargets = s_nearZones.Count;
Log("존 스포너 유입 차단 대상 " + s_nearZones.Count + "개(중심 " + center.ToString("F1") + " 반경 " + lim.ToString("F0") + "m)");
}
/// 마지막 부분 회복 시각(unscaled · 쿨다운 판정).
static float s_lastLeashHeal = -999f;
/// 리쉬 회복을 실제로 넣은 횟수 · 쿨다운으로 건너뛴 횟수(진단).
public static int LeashHealCount, LeashHealSkipped;
/// 마지막 리쉬 회복량(HP · 진단).
public static double LastLeashHeal;
/// 리쉬 회복 판정 — 0 없음 · 1 만피(레거시) · 2 부분 회복 · 3 쿨다운으로 건너뜀.
public const int kHealNone = 0, kHealFull = 1, kHealPartial = 2, kHealCooldown = 3;
///
/// 리쉬 회복 판정(Actor 접근 0 · 프로브가 가짜 시각으로 그대로 부를 수 있다).
/// 부분 회복으로 판정되면 그 시각을 쿨다운 기준으로 기록한다(호출측이 실제 Heal 을 한다).
///
public static int EvaluateLeashHeal(WLBossArenaSettings cfg, float now)
{
if (cfg == null || !cfg.resetHealsBoss) return kHealNone;
if (cfg.leashHealPercent >= 1f) return kHealFull;
if (cfg.leashHealPercent <= 0f) return kHealNone;
if (now - s_lastLeashHeal < cfg.leashHealCooldownSeconds) { LeashHealSkipped++; return kHealCooldown; }
s_lastLeashHeal = now;
LeashHealCount++;
return kHealPartial;
}
///
/// 보스를 아레나 중심으로 되돌린다.
/// 🔴 WL-813w §1-3 (Q4b D-11): 예전에는 매번 `On_Regen(true, 1)` = **만피 복구**였고 한 런에서 69회
/// 일어나 보스 HP 가 한 칸도 줄지 않았다. 이제 기본은 `leashHealPercent`(813w2 §1-4 로 **0.05**)
/// **부분 회복**이고
/// `leashHealCooldownSeconds`(10 s) 안에서는 회복을 건너뛴다(워프·타깃 해제는 그대로).
/// `leashHealPercent >= 1` 로 두면 813d 원래 동작(On_Regen 만피)으로 정확히 되돌아간다(C8).
///
static void ResetBoss(WLBossArenaSettings cfg, Vector3 center, string reason) { ResetBoss(cfg, center, reason, false); }
/// 813w3 §1-4 — 「다음 런」 리셋은 쿨다운·부분 회복을 무시하고 만피로 되돌린다.
static void ResetBoss(WLBossArenaSettings cfg, Vector3 center, string reason, bool forceFull)
{
if (DSUtil.CheckNull(s_boss)) return;
// 🔴 813w5 §1 원인 ① (D-39) — 원본 `MobActor.On_Regen`(MobActor.cs:138)은 그 안에서 `Check_Battle()` 을 부르고,
// `Check_Battle`(MobActor.cs:347) → `Change_MobStatus(Battle)`(MobActor.cs:322) → **`FindNextTarget(10000f)`**
// 로 사실상 무한 반경 재타깃을 한다. 813w3 은 `On_Regen`(만피)을 **워프보다 먼저** 불렀기 때문에
// 그 재아그로가 「런이 끝난 자리(= PC 옆)」에서 일어나 워프·타깃 해제가 같은 호출 안에서 되돌려졌다.
// → 「다음 런/런 종료」 리셋에서는 **워프를 먼저** 하고, 회복 뒤에 타깃·전투 상태를 한 번 더 뗀다.
// 🔴 인런 리쉬(forceFull=false)는 813w2/813w3 동작 그대로다(회귀 0).
bool park = forceFull && cfg != null && cfg.nextRunWarpBeforeHeal;
if (park) { ReleaseBossTarget(cfg); s_boss.Set_Warp(center); }
else s_boss.Del_Target(true);
string healInfo;
switch (forceFull ? kHealFull : EvaluateLeashHeal(cfg, Now()))
{
case kHealFull:
s_boss.On_Regen(true, cfg.resetHealRate); // 813d 원래 동작(만피 리셋 · reincarnation=true 라 Spawned 는 안 난다)
healInfo = forceFull ? "만피(다음 런)" : "만피(레거시)";
break;
case kHealPartial:
double before = s_boss.Get_HP();
s_boss.Heal(cfg.leashHealPercent, false); // Actor.cs:781 — 최대 HP × 비율만큼 「더한다」
LastLeashHeal = s_boss.Get_HP() - before;
healInfo = "+" + (cfg.leashHealPercent * 100f).ToString("F0") + "%";
break;
case kHealCooldown: healInfo = "쿨다운(건너뜀)"; break;
default: healInfo = "회복 없음"; break;
}
s_boss.Set_Warp(center);
// 🔴 813w5 — 회복(On_Regen) 안에서 다시 붙은 타깃·전투 상태를 마지막에 한 번 더 떼어 낸다.
// 이게 없으면 워프를 먼저 해도 「아레나 중심 반경 f_BaseChaseRange 안에 PC 가 있으면」 그대로 다시 붙는다.
if (park)
{
ReleaseBossTarget(cfg);
s_boss.Change_MobStatus(eMobStatus.PatrolWait);
StopBossAgent();
}
ResetCount++;
Log("보스 리셋 #" + ResetCount + " — " + reason + " · " + healInfo);
}
// ─────────────────────────────────────────────────────────────────────
// 처치 후 정리
// ─────────────────────────────────────────────────────────────────────
/// 보스 처치 후 정리 — 재등장 코루틴 차단 · 반경 원복 · (옵션) 존 비활성.
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;
LeashHealCount = LeashHealSkipped = 0; LastLeashHeal = 0d; s_lastLeashHeal = -999f;
LastLog = "";
// 813w3
s_nearZones.Clear(); s_nearZonesScanned = false; s_regenPending = false;
s_totalKillBase = 0; s_runActiveOverride = 0;
RearmCount = LivingBossResetCount = DeadBossRegenCount = 0;
LeashSuspendedTicks = GateSuspendedEvals = ZoneSpawnHoldTicks = ZoneSpawnHoldTargets = 0;
// 813w5
s_bossHoldUntil = -1f; s_probeTime = -1f;
ParkCount = BossHoldTicks = OutsideRunLeashResets = GateMinBlocked = RunEndNotices = 0;
LastGateHeldElapsed = -1f;
LastRunOutcome = WL.Combat.Run.RunOutcome.None;
}
/// 프로브·QA 용 — 홀드를 즉시 해제한다(값은 건드리지 않는다).
public static void ClearBossHold() { s_bossHoldUntil = -1f; }
/// 프로브용 — 보스 없이 홀드 타이머만 건다(홀드 판정 검증).
public static void ArmBossHoldForProbe() { ArmBossHold(Cfg); }
/// 프로브용 — 런 밖 1틱을 직접 태운다(보스·설정은 호출측이 준비한다).
public static void OutsideRunLeashTickForProbe() { var cfg = Cfg; if (cfg != null) OutsideRunLeashTick(cfg); }
///
/// 813p 「다음 런」 — 게이트를 다시 잠근다. `RunDirector.Restart()` 가 부르는 유일한 진입점이다.
/// 🔴 813w3 §1-4(D-27): 813p 판은 `s_gateOpen` 만 내려서 ① 지난 런의 누적 처치로 게이트가 즉시 다시 열리고
/// ② 보류 목록이 `released` 인 채라 보스가 영영 안 나왔다. 아래 세 가지를 함께 처리한다(전부 SO 스위치).
/// 존 누적 카운터(`s_zones.kills/spawns`)는 813p 계약대로 **지우지 않고**, 기준선만 다시 잡는다.
///
public static void ResetGate()
{
if (!s_armed) return;
var cfg = Cfg;
s_gateOpen = false;
BossKilled = CleanupDone = false;
s_openDueTime = s_cleanupDueTime = -1f;
s_playerOutsideSince = -1f;
s_lastLeashHeal = -999f;
s_nearZonesScanned = false; // 맵/스포너가 바뀌었을 수 있다 — 다시 훑는다
// ① 게이트 조기 개방(D-27) — 이 런 기준선으로 재스냅샷
if (cfg != null && cfg.restartRebasesGateKills) RebaseGateCounters();
bool alive = !DSUtil.CheckNull(s_boss) && !s_boss.IsDead() && s_boss.gameObject.activeInHierarchy;
s_regenPending = false;
if (alive && cfg != null && cfg.restartResetsLivingBoss)
{
// ② 살아 있는 보스 — 재스폰하지 않는다. 리쉬 리셋 1회(워프·만피·타깃 해제)로 처리하고
// BossSpawned 를 유지해 두 번째 런 게이트 개방에서 813p 폴백이 BossStarted 를 내게 한다.
BossSpawned = true;
LivingBossResetCount++;
// 🔴 리셋 자체가 실패해도 게이트 재무장은 끝내야 한다(다음 런이 통째로 망가지는 것보다 낫다).
// 🔴 813w5 — ParkBoss = 워프 먼저 → 만피 → 타깃 해제 → nextRunBossHoldSeconds 홀드(D-39 원인 ①).
try { ParkBoss("다음 런 — 살아 있는 보스 복귀"); }
catch (System.Exception ex) { Log("살아 있는 보스 리셋 실패 — " + ex.Message); }
}
else
{
BossSpawned = false;
// 🔴 죽은 보스는 **재무장이 아니라 원본 리스폰 규칙**(On_Regen)으로 되살린다 —
// resume(= FieldBossData.Start) 을 다시 태우면 죽은 클론 위에 새 클론이 하나 더 생긴다.
if (!DSUtil.CheckNull(s_boss) && s_boss.IsDead())
s_regenPending = cfg != null && cfg.restartRegensDeadBoss;
else if (cfg != null && cfg.restartRearmsHoldSpawn)
RearmHoldSpawn(); // 보스 액터가 아예 없다(Spawned 를 놓쳤거나 파괴됨) = 원본 스폰을 다시 태운다
}
Log("게이트 재무장(다음 런) — 기준 처치 " + s_totalKillBase + " · 보스 " +
(alive ? "생존(리셋 " + LivingBossResetCount + ")" : (s_regenPending ? "사망(개방 시 재등장 예약)" : "없음")) +
" · 보류 재무장 " + RearmCount);
}
/// 813w3 §1-4 ① — 게이트 판정 기준선을 지금 값으로 다시 잡는다(누적값 자체는 유지 · 813p 계약).
static void RebaseGateCounters()
{
s_totalKillBase = TotalZoneKills;
foreach (var kv in s_zones) { kv.Value.killBase = kv.Value.kills; kv.Value.spawnBase = kv.Value.spawns; }
}
/// 813w3 §1-4 ② — 보류 목록을 다시 무장한다(released·resume 원복). 개방 때 원본 Start 가 다시 돈다.
static void RearmHoldSpawn()
{
for (int i = 0; i < s_held.Count; i++)
{
var h = s_held[i];
if (DSUtil.CheckNull(h.fbd) || !h.released || h.resumeOriginal == null) continue;
// 보스 액터가 남아 있으면(살아 있든 죽어 있든) 재무장하지 않는다 — resume 은 클론을 하나 더 만든다.
if (!DSUtil.CheckNull(s_boss)) continue;
h.released = false;
h.resume = h.resumeOriginal;
RearmCount++;
}
}
/// 프로브용 — 보스 참조를 직접 넣는다(에디트 모드에는 실제 스폰이 없다).
public static void SetBossForProbe(MobActor boss) { s_boss = boss; }
/// 프로브용 — PC 이탈 유예 타이머만 비운다(리쉬 시나리오 사이 초기화).
public static void ResetLeashState() { s_playerOutsideSince = -1f; s_leashFaulted = false; s_lastLeashHeal = -999f; }
/// PC 가 아레나 밖으로 나간 시각(unscaled · 안이면 −1).
public static float PlayerOutsideSince { get { return s_playerOutsideSince; } }
}
/// BossArena 의 시간 축(개장 지연 · 정리 지연 · 리쉬 주기). 숨김 GameObject 1개.
internal sealed class BossArenaRunner : MonoBehaviour
{
void Update() { BossArena.Tick(); }
}
}