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

1624 lines
98 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 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
// 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 선례).
//
// ■ WL-813w6 (Q9 D-43 · D-44 · 2026-09-09) — 813w5 가 낸 두 구멍
// ① 🔴 D-43 「앞판 보스를 **처치하고** [다음 런]을 누르면 30 s 고정이 아예 안 걸린다」
// 실측 = R2n `ParkCount=0`(런 종료 시 보스가 죽어 있어 `ParkBoss` 가 건너뛴다) → 원본 리젠으로 **새로 스폰된**
// 보스에는 홀드가 없다 → **t=41.9 에 존0 추격**(`mobStatus=Chase` · 중심 18.96 m). 이기는 런일수록 다음 판이 빨리 무너진다.
// → 수리 ⓐ `parkBossOnRespawn` — `Spawned(isBoss)`(= `NoteSpawn`)가 **게이트가 닫힌 상태**로 들어오면 그 자리에서 고정한다.
// ⓑ `bossHoldUntilGateOpen`(기본 on) — 홀드를 30 s 가 아니라 **다음 런의 게이트 개방까지** 유지한다
// (813w5 는 30 s 뒤 풀려서 R1n 도 t=77 에 중심 21.89 m 까지 왔다). 게이트가 열리는 순간(`Release`) 해제 = 정상 보스전.
// ⓒ `preGateLeashDistanceOnly` — 홀드가 꺼져 있어도 게이트 전에는 **거리 판정만**(회복 0)으로 아레나에 묶는다.
// ② 🔴 D-44 「결과창 "보스 처치 : 미처치 (HP 남음 N %)" 가 항상 100 %」(실제 24.4 % = 439/1800)
// 원인 = `RunEvents.RunEnded` **발행 도중** `OnRunEnded` → `ParkBoss` → `On_Regen`(만피)이 돌아,
// 같은 발행을 구독하는 결과창이 **만피를 읽는다**(813c3 ⓕ-4 회귀).
// → 수리 순서 = **HP 스냅샷(`SnapshotBossHp` · RunDirector.EndRun) → `RunEnded` 발행 → Park**.
// `parkBossOnRunEndDeferred`(기본 on)가 고정을 **발행 뒤(다음 Tick)** 로 미루고, `RunResult.bossHpRatio/bossHp/bossHpMax`
// 가 Park 이전 값을 싣는다. **Park 뒤 만피는 그대로 유지**한다(D-27 ③ 방지 로직 불변).
//
// ■ WL-813w7 (Q10 D-45 · 2026-09-09) — 813w3 §1-3 의 잡몹 유입 차단이 813w6 과 만나 존을 영구 소진시킨다
// 🔴 원인(코드로 확정 · 발주서 §1-1 「원인 실측」)
// ⓐ `HoldNearbyZoneSpawns`(이 파일)는 **보스가 살아 있는 동안** 아레나 중심 `arenaRadius + zoneSpawnHoldMargin`
// (= 20 + 10 = **30 m**) 안의 존 스포너에 `StopAllCoroutines()` 를 건다.
// ⓑ 존0(813001) 앵커 `(115, 0.39, 22)` ↔ 아레나 중심 `(139, 0.8, 20)` = **24.1 m** → **차단 대상이다.**
// ⓒ 원본 `MobControlMgr` 의 PhaseAllKill 리젠은 **그 코루틴 안에서** `killmobcount = 0` 을 한다
// (MobControlMgr.cs:216-222). 코루틴이 끊기면 `killmobcount` 가 `n_MaxMonsterCount` 에 굳고,
// 판정이 `==`(MobControlMgr.cs:214)라 다음 사망부터 Max+1, Max+2… → **두 번 다시 참이 되지 않는다.**
// = 그 스포너는 맵 세션이 끝날 때까지 **영구 소진**된다(리셋 API 없음 · 전부 private).
// ⓓ 813w6 이 재스폰 보스를 **게이트 개방까지** 살려 두면서, 차단이 「보스전 동안」이 아니라
// **다음 런 내내** 걸렸다 → R1n 0/18 · R2n 1/18(PC 는 존0 앵커에서 5.8 m 에 서 있었다) · R3n 14/18 정지.
// ■ 수리 ① `holdZoneSpawnOnlyAfterGate`(기본 on) — 차단을 **게이트 개방 뒤**(= 진짜 보스전)로 좁힌다.
// ② `holdZoneSpawnSkipsRunZones`(기본 on) — 런이 쓰는 존(WLRunSettings.zoneSpawnerIds)은 아예 대상에서 뺀다.
// ③ `rearmZoneSpawnsEnabled` — 존이 `rearmZoneEmptySeconds`(8 s > 원본 리듬 5.0 s) 넘게 비면
// **원본과 같은 공개 경로**(`MobActor.On_Regen()` = MobControlMgr.cs:221 과 동일한 호출)로 1웨이브를 되살린다.
// `RunDirector.Restart()` 도 즉시 1회 부른다(`rearmZoneSpawnsOnRestart` · 이미 굳은 세션 구제).
// ■ C8 롤백 — 위 4개 스위치를 전부 끄면 813w6 동작 그대로다.
// ─────────────────────────────────────────────────────────────────────────────
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;
/// <summary>813w3: 「다음 런」 재무장용 원본 델리게이트(resume 은 개방 때 null 이 된다).</summary>
public System.Action resumeOriginal;
public Vector3 center;
public bool released;
}
/// <summary>813w3: killBase·spawnBase = 이 런 시작 시점의 스냅샷(게이트 판정은 그 차이만 본다).</summary>
sealed class Zone { public int alive, kills, spawns, killBase, spawnBase; }
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;
// 813w3 §1-3 · §1-4
static readonly List<MobControlMgr> s_nearZones = new List<MobControlMgr>(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;
// 813w6 §1-1 · §1-2 — 재스폰 보스 고정(D-43) · 결과 HP 스냅샷 순서(D-44)
static bool s_holdUntilGate; // 홀드를 「게이트 개방까지」 유지하는가(시간제 홀드와 OR)
static bool s_parkPending; // RunEnded 발행이 끝난 뒤로 미뤄 둔 고정이 있는가
static float s_parkDueTime = -1f; // 그 고정을 실행할 시각(unscaled · 0 지연이면 다음 Tick)
// 813w7 §1-2 ② — 존 스포너 재무장(D-45). 감시 대상 id 와 「빈 지 얼마나 됐나」를 사전 할당 배열로 든다(GC 0).
const int kMaxRearmZones = 16; // 버퍼 크기지 게임 수치가 아니다
static readonly int[] s_rearmIds = new int[kMaxRearmZones];
static readonly float[] s_rearmEmptySince = new float[kMaxRearmZones];
static int s_rearmCount = -1; // 1 = 아직 목록을 안 잡았다
static float s_nextZoneRearmCheck;
// ───────────────────────────────────────── 진단(프로브·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; }
// ───────────────────────────────────────── 813w3 진단
/// <summary>「다음 런」 재무장 수 · 살아 있는 보스 리셋 수 · 죽은 보스 재등장 수.</summary>
public static int RearmCount, LivingBossResetCount, DeadBossRegenCount;
/// <summary>런 밖이라 건너뛴 리쉬 판정 수 · 게이트 평가 수(D-27 ③ 증거).</summary>
public static int LeashSuspendedTicks, GateSuspendedEvals;
/// <summary>존 스포너 리젠 예약을 끊은 횟수 · 대상 스포너 수(§1-3).</summary>
public static int ZoneSpawnHoldTicks, ZoneSpawnHoldTargets;
/// <summary>813w7 §1-2 ②: 차단을 건너뛴 횟수(게이트 전 · 런 존 제외) — D-45 수리가 실제로 도는지의 증거.</summary>
public static int ZoneSpawnHoldSkipped;
/// <summary>이 런의 게이트 판정 기준 처치 수(= TotalZoneKills 런 시작 스냅샷).</summary>
public static int GateKills { get { int v = TotalZoneKills - s_totalKillBase; return v > 0 ? v : 0; } }
// ───────────────────────────────────────── 813w5 진단(D-39)
/// <summary>보스를 아레나에 고정한 횟수(런 종료 + 다음 런) · 홀드 틱 수 · 런 밖 거리 리셋 수 · 게이트 조기 개방 차단 수.</summary>
public static int ParkCount, BossHoldTicks, OutsideRunLeashResets, GateMinBlocked;
/// <summary>RunEnded 를 받아 「보스 고정」 경로에 들어간 횟수(보스 유무와 무관 · 구독·스위치 증거).</summary>
public static int RunEndNotices;
/// <summary>마지막 RunEnded 의 결과(진단).</summary>
public static WL.Combat.Run.RunOutcome LastRunOutcome;
/// <summary>마지막으로 게이트를 막았을 때의 런 경과 초(진단).</summary>
public static float LastGateHeldElapsed = -1f;
/// <summary>홀드 종료 시각(unscaled · 홀드 중이 아니면 1).</summary>
public static float BossHoldUntil { get { return s_bossHoldUntil; } }
/// <summary>
/// 지금 보스가 「이동·타깃 금지」 홀드 중인가.
/// 🔴 813w6 — ⓐ 「게이트 개방까지」 홀드(`bossHoldUntilGateOpen`) ⓑ 시간제 홀드(`nextRunBossHoldSeconds`)의 **OR**.
/// 813w5 는 ⓑ 뿐이라 30 s 뒤 보스가 다시 걸어 나왔다(Q9 R1n t≈30~38 해제 → t=77 중심 21.89 m).
/// </summary>
public static bool BossHeld
{
get
{
if (s_holdUntilGate && !s_gateOpen) return true;
return s_bossHoldUntil > 0f && Now() < s_bossHoldUntil;
}
}
/// <summary>RunEvents.RunEnded 구독 여부(프로브).</summary>
public static bool RunSubscribed { get { return s_runSubscribed; } }
// ───────────────────────────────────────── 813w6 진단(D-43 · D-44)
/// <summary>홀드가 「게이트 개방까지」 모드인가(진단).</summary>
public static bool HoldUntilGate { get { return s_holdUntilGate; } }
/// <summary>런 종료 뒤로 미뤄 둔 고정이 남아 있는가 · 그 실행 예정 시각(진단).</summary>
public static bool ParkPending { get { return s_parkPending; } }
public static float ParkDueTime { get { return s_parkDueTime; } }
/// <summary>재스폰 보스를 즉시 고정한 횟수(D-43) · 게이트 전 거리 리셋 수 · 미뤄서 실행한 고정 수 · 게이트 개방으로 홀드를 푼 횟수.</summary>
public static int RespawnParkCount, RespawnParkAttempts, PreGateLeashResets, DeferredParkCount, HoldClearedByGate;
/// <summary>마지막 런 종료 스냅샷(D-44 · Park 이전 값 · 보스가 없으면 1 / 0 / 0).</summary>
public static float LastBossHpRatio = -1f;
public static double LastBossHp, LastBossHpMax;
/// <summary>SnapshotBossHp 호출 수(진단).</summary>
public static int BossHpSnapshots;
static WLBossArenaSettings Cfg { get { return WLBossArenaSettings.Instance; } }
static bool Active { get { return WLBossArenaSettings.Active; } }
/// <summary>시간 축 하나(프로브가 가짜 시각을 넣을 수 있다 · 에디트 모드는 0).</summary>
static float Now() { return s_probeTime >= 0f ? s_probeTime : (Application.isPlaying ? Time.unscaledTime : 0f); }
/// <summary>프로브용 — 가짜 시각을 넣는다(음수면 실시간으로 되돌린다).</summary>
public static void SetProbeTime(float t) { s_probeTime = t; }
/// <summary>
/// 813w3 §1-4 ③ — 런이 진행 중인가. 런 밖(RunEnded 뒤 · 결과창)에서는 리쉬·게이트 평가를 쉰다.
/// 🔴 C8 — 런 축(WLRunSettings)이 꺼졌거나 런이 한 번도 시작된 적 없으면 언제나 true(813d 그대로).
/// </summary>
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;
}
}
/// <summary>프로브용 — 런 상태를 강제한다(0 자동 · 1 진행 중 · 1 런 밖).</summary>
public static void SetRunActiveForProbe(int mode) { s_runActiveOverride = mode; }
// ─────────────────────────────────────────────────────────────────────
// 훅 (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,
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();
}
/// <summary>CombatEvents 구독(중복 구독은 CombatEventList.Add 가 막는다).</summary>
public static void EnsureSubscribed()
{
EnsureRunSubscribed();
if (s_subscribed) return;
CombatEvents.Killed.Add(OnKilled);
CombatEvents.Spawned.Add(OnSpawned);
s_subscribed = true;
}
/// <summary>구독 해제(프로브·롤백용).</summary>
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<WL.Combat.Run.RunResult> h_runEnded = OnRunEnded;
/// <summary>RunEvents.RunEnded 구독(중복 안전 · 813w5).</summary>
public static void EnsureRunSubscribed()
{
if (s_runSubscribed) return;
WL.Combat.Run.RunEvents.RunEnded.Add(h_runEnded);
s_runSubscribed = true;
}
/// <summary>RunEnded 구독 해제(프로브·롤백용).</summary>
public static void UnsubscribeRun()
{
if (!s_runSubscribed) return;
WL.Combat.Run.RunEvents.RunEnded.Remove(h_runEnded);
s_runSubscribed = false;
}
/// <summary>
/// 813w5 §1-2 — 런 종료(Win/Timeout/Abandon) 시 살아 있는 보스를 아레나 중심에 고정한다.
/// 🔴 D-39: 813w3 은 [다음 런]을 누른 순간에만 되돌렸다 → 결과창 동안 보스가 존까지 걸어 나갔다.
/// </summary>
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) — 이유는 고정 문자열로 남긴다
// 🔴 813w6 §1-2 (D-44) — 여기서 바로 ParkBoss 를 부르면 만피(On_Regen)가 **같은 발행 안에서** 일어나,
// 뒤에 오는 구독자(813q 결과창)가 만피를 읽는다(Q9 실측: 실제 24.4 % → 성적표 100 %).
// 발행이 끝난 뒤(기본 = 다음 Tick)로 미룬다. 순서 = HP 스냅샷 → RunEnded 발행 → Park.
if (cfg.parkBossOnRunEndDeferred)
{
s_parkPending = true;
s_parkDueTime = Now() + (cfg.parkBossOnRunEndDelaySeconds > 0f ? cfg.parkBossOnRunEndDelaySeconds : 0f);
return;
}
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);
}
/// <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);
// 🔴 813w6 §1-1 (D-43) — 게이트가 닫힌 채로 등장한 보스(= 앞판 보스 처치 뒤 원본 리젠)는 즉시 고정한다.
ParkRespawnedBoss();
}
}
/// <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)
{
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();
}
}
// ─────────────────────────────────────────────────────────────────────
// 게이트 판정 · 개방
// ─────────────────────────────────────────────────────────────────────
/// <summary>조건을 다시 보고 충족하면 게이트를 연다. 열려 있으면 아무 일도 하지 않는다.</summary>
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;
}
/// <summary>
/// 813w5 §1-2 ③ — 「런 시작 뒤 gateMinSeconds 전인가」. true 면 게이트를 열지 않는다(D-39).
/// 🔴 예외 = ⓐ 진짜 존 전멸(`ZonesCleared` · SO 로 끌 수 있다) ⓑ 시간제한(`TimeLimit`) ⓒ 외부 호출(`External` · QA·치트).
/// 🔴 C8 — `gateMinSeconds = 0` · 런 축 off · 런이 한 번도 시작 안 됨 · 런 밖 → 언제나 false(813p/813w3 그대로).
/// `RunDirector` 는 이 판정을 부르는 2줄만 바뀐다(발주서 「RunDirector ≤10줄」).
/// </summary>
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;
}
/// <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);
// 🔴 813w6 §1-1 — 게이트가 열렸다 = 이제부터는 정상 보스전이다. 홀드를 푼다(시간제 잔여분까지).
// C8: bossHoldUntilGateOpen 을 끄면 813w5 그대로(시간제 홀드가 남아 있으면 그대로 흐른다).
if (cfg != null && cfg.bossHoldUntilGateOpen && (s_holdUntilGate || s_bossHoldUntil > 0f))
{
HoldClearedByGate++;
ClearBossHold();
Log("게이트 개방 — 보스 홀드 해제");
}
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);
}
/// <summary>
/// 813w3 §1-4 ② — 「다음 런」에서 게이트가 열릴 때, 지난 런에 죽어 있던 보스를 **원본 리스폰 규칙**으로 되살린다.
/// 원본 규칙 실측 = `FieldBossData.AfterDie` → `Set_Coroutine(() =&gt; m_FieldBoss.On_Regen(), RegenTime)`
/// (813d `suppressRegenAfterBossKill` 이 그 코루틴을 끊어 두므로 시간이 지나도 스스로는 안 돌아온다).
/// 여기서 같은 `MobActor.On_Regen()` 을 한 번 태운다 — `OriginalPos` 복귀 · 스폰 이펙트 ·
/// `CombatEvents.RaiseSpawned`(= `BossSpawned` 재설정 · 813p 폴백이 `BossStarted` 를 낸다)까지 원본 경로 그대로.
/// 🔴 `resume`(= FieldBossData.Start) 재진입으로 되살리지 않는다 — 그러면 클론이 런마다 하나씩 늘어난다.
/// </summary>
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 와 같은 경로)");
}
// ─────────────────────────────────────────────────────────────────────
// 아레나 — 추적 반경 데이터화 · 리쉬 · 리셋
// ─────────────────────────────────────────────────────────────────────
/// <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; }
// 🔴 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;
// 🔴 813w6 §1-1 (D-43) — 게이트 개방 전인데 보스가 살아 있다 = 원본 리젠으로 되살아난 앞판 보스다.
// 홀드가 꺼져 있어도 아레나 밖으로는 못 나가게 「거리 판정만」 유지한다(회복 0).
if (!s_gateOpen && cfg.preGateLeashDistanceOnly && !BossHeld)
{
PreGateLeashTick(cfg, center);
return;
}
// 🔴 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)
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 런 밖(결과창·다음 런 대기)의 1틱 — **거리만** 본다. PC 이탈 판정은 하지 않고(결과창에서 의미가 없다),
/// 보스가 아레나(반경 + leashMargin)를 벗어나면 **회복 없이** 중심으로 되돌린다.
/// 홀드 중이면 되돌리기 대신 홀드를 유지한다.
/// </summary>
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");
}
/// <summary>
/// 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 — 문자열·배열 할당 없음(로그는 홀드 시작/해제에서만 남긴다).
/// </summary>
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);
}
/// <summary>보스 쪽 타깃(+ 옵션으로 PC 쪽 락·타깃)을 뗀다.</summary>
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);
}
/// <summary>
/// 원본 `Actor.StopMove_Imm`(protected · Actor.cs:2146) 본체를 공개 `Get_Agent()`(Actor.cs:900)로 재현한다.
/// `isOnNavMesh` 가드는 원본과 #813x 선례 그대로 — NavMesh 밖/비활성 에이전트에 쓰면 예외가 난다.
/// </summary>
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;
}
/// <summary>
/// 813w5 §1-2 — 살아 있는 보스를 아레나 중심에 「고정」한다(런 종료 · 다음 런 공통 진입점).
/// ① 워프 → ② 만피 → ③ 타깃·어그로 해제 → ④ nextRunBossHoldSeconds 동안 이동·타깃 금지.
/// 보스가 없거나 죽어 있으면 false.
/// </summary>
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;
}
/// <summary>
/// 홀드를 지금부터 다시 건다(시간제 0 이면 시간 홀드 없음 = 813w3 그대로).
/// 🔴 813w6 — `bossHoldUntilGateOpen` 이면 「다음 런의 게이트 개방까지」 모드도 함께 켠다(둘은 OR).
/// </summary>
static void ArmBossHold(WLBossArenaSettings cfg)
{
if (cfg == null) { s_bossHoldUntil = -1f; s_holdUntilGate = false; return; }
s_holdUntilGate = cfg.bossHoldUntilGateOpen;
if (cfg.nextRunBossHoldSeconds <= 0f) { s_bossHoldUntil = -1f; }
else s_bossHoldUntil = Now() + cfg.nextRunBossHoldSeconds;
Log("보스 홀드 — 시간제 " + (s_bossHoldUntil > 0f ? cfg.nextRunBossHoldSeconds.ToString("F0") + "s(해제 " +
s_bossHoldUntil.ToString("F1") + ")" : "없음") + " · 게이트개방까지 " + (s_holdUntilGate ? "on" : "off"));
}
// ─────────────────────────────────────────────────────────────────────
// 813w6 §1-1 · §1-2 — 재스폰 보스 고정(D-43) · 결과 HP 스냅샷 순서(D-44)
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 🔴 813w6 §1-2 (D-44) — 지금 보스 HP 를 읽는다. **RunDirector.EndRun 이 RunEnded 발행 전에** 부른다.
/// 보스가 없거나(스폰 전·파괴) 스위치가 꺼져 있으면 `ratio = 1 · hp = max = 0`.
/// 죽은 보스는 원본 값 그대로(보통 0) — 「미처치」가 아니므로 결과창은 처치 시각을 쓴다.
/// GC 0 · 예외 0(원본 `Actor.Get_HP/Get_MaxHP`(Actor.cs:952/953)는 공개).
/// </summary>
public static void SnapshotBossHp(out float ratio, out double hp, out double maxHp)
{
ratio = -1f; hp = 0d; maxHp = 0d;
BossHpSnapshots++;
var cfg = Cfg;
if (cfg == null || !Active || !cfg.snapshotBossHpOnRunEnd) { LastBossHpRatio = -1f; LastBossHp = LastBossHpMax = 0d; return; }
if (s_probeHpRatio > -2f) // 프로브 주입(에디트 모드에는 m_Stat 이 없는 가짜 보스뿐이다)
{
ratio = s_probeHpRatio; hp = s_probeHp; maxHp = s_probeHpMax;
LastBossHpRatio = ratio; LastBossHp = hp; LastBossHpMax = maxHp;
return;
}
if (DSUtil.CheckNull(s_boss)) { LastBossHpRatio = -1f; LastBossHp = LastBossHpMax = 0d; return; }
// 🔴 m_Stat 은 원본 Init 이 채운다(Actor.cs:321) — 스폰 직후·에디트 모드에는 없을 수 있다.
// 런 종료 경로에서 예외를 내면 안 되므로 감싼다(정상 경로에서는 던지지 않으므로 GC 0).
try
{
hp = s_boss.Get_HP();
maxHp = s_boss.Get_MaxHP();
}
catch (System.Exception) { LastBossHpRatio = -1f; LastBossHp = LastBossHpMax = 0d; hp = 0d; maxHp = 0d; return; }
ratio = maxHp > 0d ? (float)(hp / maxHp) : -1f;
if (ratio > 1f) ratio = 1f;
else if (ratio < 0f) ratio = 0f;
LastBossHpRatio = ratio; LastBossHp = hp; LastBossHpMax = maxHp;
}
static float s_probeHpRatio = -2f;
static double s_probeHp, s_probeHpMax;
/// <summary>프로브용 — 보스 HP 읽기를 주입값으로 대체한다(ratio &lt; 1 이면 해제 · 런타임 게임 경로에서는 부르지 않는다).</summary>
public static void SetBossHpForProbe(float ratio, double hp, double maxHp)
{
s_probeHpRatio = ratio; s_probeHp = hp; s_probeHpMax = maxHp;
}
/// <summary>
/// 🔴 813w6 §1-2 — 미뤄 둔 고정을 실행한다(`Tick` 이 부른다 · 에디트 모드는 프로브가 직접 부를 수 있다).
/// 발행이 끝난 뒤라 결과창은 이미 Park 이전 HP 를 읽었다.
/// </summary>
public static void PumpDeferredPark()
{
if (!s_parkPending) return;
if (s_parkDueTime > 0f && Now() < s_parkDueTime) return;
s_parkPending = false; s_parkDueTime = -1f;
DeferredParkCount++;
// 🔴 고정이 실패해도 홀드는 반드시 남긴다(D-43 의 본질 = 홀드 미무장) — 813w3 ResetGate 선례.
try { ParkBoss("런 종료(발행 뒤)"); }
catch (System.Exception ex) { Log("런 종료 고정 실패 — " + ex.Message); ArmBossHold(Cfg); }
}
/// <summary>
/// 🔴 813w6 §1-1 (D-43) — 게이트가 닫혀 있는데 보스가 새로 등장했다(= 앞판 보스를 처치한 뒤 원본 리젠).
/// 813w5 `ParkBoss` 는 런 종료 시점에 「살아 있는 보스」만 잡아서(`ParkCount=0` 실측) 이 보스에는 홀드가 없었고,
/// Q9 R2n 은 **42 초 만에** 존0 까지 와서 PC 를 쫓았다(`mobStatus=Chase`). 등장 즉시 고정한다.
/// 게이트가 열린 뒤의 등장(= 정상 보스전)은 여기 들어오지 않는다.
/// </summary>
static void ParkRespawnedBoss()
{
var cfg = Cfg;
if (cfg == null || !Active || !cfg.parkBossOnRespawn) return;
if (s_gateOpen) return; // 정상 보스전(게이트 개방 뒤 등장) — 건드리지 않는다
if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) return;
RespawnParkAttempts++;
try { if (ParkBoss("재스폰 보스 고정(게이트 전)")) RespawnParkCount++; }
catch (System.Exception ex) { Log("재스폰 보스 고정 실패 — " + ex.Message); }
// 🔴 워프·회복이 실패해도 **홀드는 반드시 무장한다** — D-43 의 본질은 「홀드가 안 걸린 것」이다.
ArmBossHold(cfg);
}
/// <summary>
/// 🔴 813w6 §1-1 — 게이트 개방 전 1틱(런 중 · 보스가 비정상적으로 살아 있는 구간) — **거리만** 본다.
/// 회복은 걸지 않는다(D-27 ③ 재발 방지). 홀드 중이면 홀드를 유지한다.
/// </summary>
static void PreGateLeashTick(WLBossArenaSettings cfg, Vector3 center)
{
if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) return;
s_playerOutsideSince = -1f;
float d = DistXZ(s_boss.Get_position(), center);
if (d <= cfg.arenaRadius + cfg.leashMargin) return;
LastLeashDistance = d;
PreGateLeashResets++;
ResetBossNoHeal(cfg, center, "게이트 전 아레나 이탈 " + d.ToString("F1") + "m");
}
/// <summary>회복 없이 되돌리기(런 밖 전용) — 워프 + 타깃 해제 + 순찰 대기 + 정지.</summary>
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 — 보스 생존 중 아레나 근처 존 스포너의 리젠 예약을 끊는다(잡몹 유입 차단)
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 보스가 살아 있는 동안 아레나 중심에서 (arenaRadius + zoneSpawnHoldMargin) 안의 존 스포너가
/// 예약한 「사망 후 리젠」 코루틴(원본 `MobControlMgr.actDie` → `MyCoroutine.Set_Coroutine`)을 끊는다.
/// 이미 살아 있는 잡몹은 손대지 않는다(발주서 §1-3 「아레나 안 잡몹은 원본 규칙대로」).
/// 로딩이 끝나지 않은 스포너(`Get_LoadComplete()==false`)는 건너뛴다 — 그 `Start()` 코루틴을 끊으면 존이 죽는다.
/// </summary>
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;
// 🔴 813w7 §1-2 ②(D-45) — 「보스 생존 중」이 아니라 「게이트 개방 뒤」로 좁힌다.
// 813w6 이 재스폰 보스를 **게이트 개방까지** 살려 두면서, 이 차단이 다음 런 내내 걸렸다.
// 차단은 `StopAllCoroutines` 라 PhaseAllKill 의 `killmobcount = 0`(MobControlMgr.cs:218)까지 함께
// 끊어 카운터를 `n_MaxMonsterCount` 에 굳힌다 → `== 비교`(:214) 가 영영 참이 안 된다(영구 소진).
if (cfg.holdZoneSpawnOnlyAfterGate && !s_gateOpen) { ZoneSpawnHoldSkipped++; 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;
// 🔴 런이 쓰는 존(= 다음 런의 사냥터)은 건드리지 않는다 — 여기서 굳으면 D-45 가 그대로 재발한다.
if (!ShouldHoldZoneSpawn(cfg, m.SpawnerID, s_gateOpen)) { ZoneSpawnHoldSkipped++; continue; }
m.StopAllCoroutines();
ZoneSpawnHoldTicks++;
}
}
/// <summary>
/// 813w7 §1-2 ② — 이 스포너의 리젠 예약을 끊어도 되는가(순수 함수 · Actor 접근 0 · 프로브가 그대로 부른다 · C8 증거).
/// 🔴 「끊는다 = 그 스포너의 PhaseAllKill 카운터를 영구히 굳힌다」이므로 판정은 보수적이어야 한다.
/// </summary>
public static bool ShouldHoldZoneSpawn(WLBossArenaSettings cfg, int spawnerId, bool gateOpen)
{
if (cfg == null || !cfg.holdZoneSpawnNearArena) return false;
if (cfg.holdZoneSpawnOnlyAfterGate && !gateOpen) return false;
if (cfg.holdZoneSpawnSkipsRunZones && IsRunZoneSpawner(spawnerId)) return false;
return true;
}
// ─────────────────────────────────────────────────────────────────────
// 813w8 §1-2 — 게이트 개방 전에는 보스 UI(HP 바·배너)를 띄우지 않는다 (Q11 D-23 재발)
//
// 🔴 근거 = qa/W2-11_재채점_v9.md D-23 — 「다음 런」의 **존 사냥 내내 상단에 만피 빨간 보스 바**가 떠 있다
// (캡처 2장 · 계기 `bhp=1800/1800 · balive=True`). 바는 **정상 추종**이고, 문제는 813w6 이
// 재스폰 보스를 게이트 개방까지 **살려서 고정**해 두는데 UI 가 그 보스를 물어 버리는 **표시 정책**이다.
// ■ 첫 런은 영향 0 — 첫 런의 보스는 813d 게이트가 스폰 자체를 붙잡았다가 **게이트 개방(ForceOpenGate → Release)
// 안에서** 등장시킨다. 그 시점에는 이미 `s_gateOpen=true` 라 아래 판정이 false 다.
// ■ 게이트 축이 무장돼 있지 않으면(= 813d 가 안 도는 판) 정책을 걸지 않는다 — 원본/타 흐름의 보스를 가리면 안 된다.
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 지금 보스 UI 를 숨겨야 하는가(BossHpBar·BossBanner 가 읽는다).
/// 🔴 아레나 축 자체가 꺼져 있으면(SO off · RuntimeDisabled) 정책을 걸지 않는다 — 그때 게이트 상태는 의미가 없다(C8).
/// </summary>
public static bool HideBossUiNow
{
get { return WLBossArenaSettings.Active && ShouldHideBossUi(Cfg, s_armed, s_gateOpen, WL.Combat.Run.RunDirector.GateOpened); }
}
/// <summary>
/// 위 판정의 순수 함수(정적 상태 접근 0 · 프로브가 가짜 값으로 그대로 부른다 · C8 증거).
/// 게이트 축이 무장돼 있고(gateArmed) 아직 어느 축으로도 열리지 않았을 때만 true.
/// </summary>
public static bool ShouldHideBossUi(WLBossArenaSettings cfg, bool gateArmed, bool gateOpen, bool runGateOpened)
{
if (cfg == null || !cfg.hideBossUiBeforeGate) return false;
if (!gateArmed) return false;
return !gateOpen && !runGateOpened;
}
/// <summary>이 스포너가 런 축(WLRunSettings.zoneSpawnerIds)의 사냥 존인가(순수 · 프로브가 그대로 부른다).</summary>
public static bool IsRunZoneSpawner(int spawnerId)
{
var rs = WL.Combat.Run.WLRunSettings.Instance;
if (rs == null || rs.zoneSpawnerIds == null) return false;
for (int i = 0; i < rs.zoneSpawnerIds.Length; i++) if (rs.zoneSpawnerIds[i] == spawnerId) return true;
return false;
}
/// <summary>대상 존 스포너를 1회 훑는다(런당 몇 회 · 매 틱 아님).</summary>
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<MobControlMgr>(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)");
}
// ─────────────────────────────────────────────────────────────────────
// 813w7 §1-2 ② — 존 스포너 재무장 (D-45 · 다음 런 사냥터가 비어 있다)
// ─────────────────────────────────────────────────────────────────────
//
// 🔴 원본에 「스포너 리셋 API」는 없다(실측) — `MobControlMgr` 의 공개 멤버는
// `SpawnerID` · `MobGenRange` · `text_lv` · `Get_LoadComplete()` 뿐이고,
// `killmobcount` · `orderIndex` · `dic_actors` 는 전부 private 이다.
// 그래서 **원본이 스스로 쓰는 공개 경로**를 그대로 다시 태운다 —
// 813e2 표 A 의 `MobControlMgr.cs:220-221` `dic_actors[orderIndex][i].On_Regen()` 와
// 같은 호출을, 그 스포너에 등록된 비활성 몹(`ActorInfo.Get_Actors`)에 대해 한다.
// `MobActor.On_Regen()` 은 원본 그대로 `Set_Warp(OriginalPos)` + 스폰 이펙트 +
// `CombatEvents.RaiseSpawned` 까지 하므로 존 카운터(z.alive/z.spawns)도 정상으로 붙는다.
//
// 🔴 이중 스폰 방지 = `rearmZoneEmptySeconds`(기본 8 s) > 원본 리듬 5.0 s(시체 3.0 + 리젠 2.0 · 813e2 표 A).
// 스포너가 멀쩡하면 5.0 s 에 스스로 되살아나 alive > 0 이 되므로 이 경로는 아예 들어오지 않는다.
/// <summary>감시 대상 존 id 목록을 다시 잡는다(런 존만 / 아레나 존 전체 · SO).</summary>
static void BuildRearmZones(WLBossArenaSettings cfg)
{
s_rearmCount = 0;
int[] src = null;
if (cfg.rearmZoneRunZonesOnly)
{
var rs = WL.Combat.Run.WLRunSettings.Instance;
if (rs != null) src = rs.zoneSpawnerIds;
}
if (src == null) src = cfg.zoneSpawnerIds;
if (src == null) return;
for (int i = 0; i < src.Length && s_rearmCount < kMaxRearmZones; i++)
{
s_rearmIds[s_rearmCount] = src[i];
s_rearmEmptySince[s_rearmCount] = -1f;
s_rearmCount++;
}
}
/// <summary>
/// 빈 존 감시 1틱 — 살아 있는 몹이 0 인 상태가 `rearmZoneEmptySeconds` 이상이면 그 존을 재무장한다.
/// 🔴 폴링 자체는 카운터만 본다 = 할당 0. 실제 재무장(드문 경로)만 원본 등록부를 훑는다.
/// 프로브가 가짜 시각으로 그대로 부를 수 있다.
/// </summary>
public static void ZoneRearmTick(WLBossArenaSettings cfg, float now)
{
if (cfg == null || !Active || !cfg.rearmZoneSpawnsEnabled || !s_armed) return;
if (cfg.rearmZoneEmptySeconds <= 0f) return;
if (!RunActive) return; // 결과창·다음 런 대기 중에는 손대지 않는다
if (s_rearmCount < 0) BuildRearmZones(cfg);
for (int i = 0; i < s_rearmCount; i++)
{
if (AliveOf(s_rearmIds[i]) > 0) { s_rearmEmptySince[i] = -1f; continue; }
if (s_rearmEmptySince[i] < 0f) { s_rearmEmptySince[i] = now; continue; }
if (now - s_rearmEmptySince[i] < cfg.rearmZoneEmptySeconds) continue;
s_rearmEmptySince[i] = now; // 성공·실패와 무관하게 다음 주기를 다시 잰다
RearmZone(cfg, s_rearmIds[i], "빈 존 " + cfg.rearmZoneEmptySeconds.ToString("F0") + "s");
}
}
/// <summary>
/// 대상 존을 전부 즉시 1회 재무장한다(「다음 런」 진입점 · `RunDirector.Restart()` 가 부른다).
/// 이미 살아 있는 몹이 있으면 그 존은 건너뛴다.
/// </summary>
public static int RearmZoneSpawns(string reason)
{
var cfg = Cfg;
if (cfg == null || !Active || !cfg.rearmZoneSpawnsEnabled || !cfg.rearmZoneSpawnsOnRestart) return 0;
BuildRearmZones(cfg); // 런 존 목록이 바뀌었을 수 있다
s_nextZoneRearmCheck = 0f;
int total = 0;
for (int i = 0; i < s_rearmCount; i++)
{
s_rearmEmptySince[i] = Now();
if (AliveOf(s_rearmIds[i]) > 0) continue;
total += RearmZone(cfg, s_rearmIds[i], reason);
}
if (total > 0) Log("존 재무장(" + reason + ") — " + total + "마리 재등장");
return total;
}
/// <summary>
/// 존 하나를 재무장한다 — 그 스포너의 **비활성** 몹을 1웨이브분만큼 `On_Regen()` 한다.
/// 반환 = 실제로 되살린 마리 수. 원본 등록부(`ActorInfo`)가 없거나 대상이 없으면 0.
/// </summary>
public static int RearmZone(WLBossArenaSettings cfg, int spawnerId, string reason)
{
if (cfg == null || !Application.isPlaying) return 0;
var info = ActorInfo.Ins;
if (DSUtil.CheckNull(info)) return 0;
int want = WaveSize(cfg, spawnerId);
if (want <= 0) return 0;
// 🔴 `ActorInfo.Get_Actors` 는 dic_actor[role][spawnerId] 를 직접 인덱싱한다 —
// 등록이 없는 스포너면 KeyNotFoundException 이 난다. 재무장 실패가 런을 죽이면 안 되므로 감싼다.
List<Actor> actors = null;
int done = 0;
try
{
actors = info.Get_Actors(eRole.Mob, spawnerId, true);
if (actors == null) return 0;
// 이미 살아 있는 개체가 있으면(카운터 드리프트) 재무장하지 않는다 — 이중 스폰 방지.
for (int i = 0; i < actors.Count; i++)
{
var a = actors[i];
if (!DSUtil.CheckNull(a) && a.isActiveAndEnabled && !a.IsDead()) return 0;
}
for (int i = 0; i < actors.Count && done < want; i++)
{
var mob = actors[i] as MobActor;
if (DSUtil.CheckNull(mob) || mob.isActiveAndEnabled) continue;
mob.On_Regen(); // 원본 웨이브와 같은 호출(MobControlMgr.cs:221) — 좌표·이펙트·Spawned 이벤트까지 원본 그대로
done++;
}
}
catch (System.Exception ex) { Log("존 " + spawnerId + " 재무장 실패 — " + ex.Message); }
finally { if (actors != null) info.Release_Actors(actors); }
if (done > 0)
{
ZoneRearmCount++;
ZoneRearmMobs += done;
LastRearmZoneId = spawnerId;
Log("존 " + spawnerId + " 재무장(" + reason + ") — " + done + "/" + want + "마리");
}
return done;
}
/// <summary>
/// 1웨이브 마리 수 — 원본 테이블 `n_MaxMonsterCount`(= `MobControlMgr` 이 쓰는 그 값)를 읽고,
/// 못 읽으면 SO `rearmZoneRegenMax`. SO 값은 언제나 상한으로 쓴다(안전).
/// </summary>
public static int WaveSize(WLBossArenaSettings cfg, int spawnerId)
{
int cap = cfg != null && cfg.rearmZoneRegenMax > 0 ? cfg.rearmZoneRegenMax : 0;
int n = cap;
// 🔴 table_monsterappear.Get_Data_orNull 은 dic_Data[id] 직접 인덱싱이라 없는 행이면 던진다.
try
{
var tbl = table_monsterappear.Ins;
if (!DSUtil.CheckNull(tbl))
{
var row = tbl.Get_Data_orNull(spawnerId);
if (row != null && row.n_MaxMonsterCount > 0) n = row.n_MaxMonsterCount;
}
}
catch (System.Exception) { n = cap; }
if (cap > 0 && n > cap) n = cap;
return n;
}
/// <summary>813w7 진단 — 재무장을 실행한 존 수 · 되살린 총 마리 수 · 마지막 대상 존 id.</summary>
public static int ZoneRearmCount, ZoneRearmMobs;
public static int LastRearmZoneId;
/// <summary>감시 대상 존 수(진단 · 아직 목록을 안 잡았으면 1).</summary>
public static int RearmZoneCount { get { return s_rearmCount; } }
/// <summary>감시 대상 i 의 스포너 id · 「비었다」로 잡힌 시각(unscaled · 안 비었으면 1).</summary>
public static int RearmZoneIdAt(int i) { return i >= 0 && i < s_rearmCount ? s_rearmIds[i] : 0; }
public static float RearmZoneEmptySinceAt(int i) { return i >= 0 && i < s_rearmCount ? s_rearmEmptySince[i] : -1f; }
/// <summary>프로브용 — 감시 목록을 다시 잡는다.</summary>
public static void RebuildRearmZonesForProbe() { var c = Cfg; if (c != null) BuildRearmZones(c); }
/// <summary>마지막 부분 회복 시각(unscaled · 쿨다운 판정).</summary>
static float s_lastLeashHeal = -999f;
/// <summary>리쉬 회복을 실제로 넣은 횟수 · 쿨다운으로 건너뛴 횟수(진단).</summary>
public static int LeashHealCount, LeashHealSkipped;
/// <summary>마지막 리쉬 회복량(HP · 진단).</summary>
public static double LastLeashHeal;
/// <summary>리쉬 회복 판정 — 0 없음 · 1 만피(레거시) · 2 부분 회복 · 3 쿨다운으로 건너뜀.</summary>
public const int kHealNone = 0, kHealFull = 1, kHealPartial = 2, kHealCooldown = 3;
/// <summary>
/// 리쉬 회복 판정(Actor 접근 0 · 프로브가 가짜 시각으로 그대로 부를 수 있다).
/// 부분 회복으로 판정되면 그 시각을 쿨다운 기준으로 기록한다(호출측이 실제 Heal 을 한다).
/// </summary>
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;
}
/// <summary>
/// 보스를 아레나 중심으로 되돌린다.
/// 🔴 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).
/// </summary>
static void ResetBoss(WLBossArenaSettings cfg, Vector3 center, string reason) { ResetBoss(cfg, center, reason, false); }
/// <param name="forceFull">813w3 §1-4 — 「다음 런」 리셋은 쿨다운·부분 회복을 무시하고 만피로 되돌린다.</param>
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);
}
// ─────────────────────────────────────────────────────────────────────
// 처치 후 정리
// ─────────────────────────────────────────────────────────────────────
/// <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();
// 🔴 813w6 §1-2 (D-44) — RunEnded 발행이 끝난 뒤로 미뤄 둔 고정을 여기서 실행한다.
if (s_parkPending) PumpDeferredPark();
// 🔴 813w7 §1-2 ② (D-45) — 빈 존 감시(자체 주기 · 리쉬 주기와 독립).
if (cfg.rearmZoneSpawnsEnabled && now >= s_nextZoneRearmCheck)
{
s_nextZoneRearmCheck = now + Mathf.Max(0.1f, cfg.rearmZoneCheckSeconds);
try { ZoneRearmTick(cfg, now); }
catch (System.Exception ex) { Log("존 재무장 감시 실패 — " + ex.Message); }
}
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;
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;
// 813w7
ZoneSpawnHoldSkipped = ZoneRearmCount = ZoneRearmMobs = LastRearmZoneId = 0;
s_rearmCount = -1; s_nextZoneRearmCheck = 0f;
for (int i = 0; i < kMaxRearmZones; i++) s_rearmEmptySince[i] = -1f;
// 813w5
s_bossHoldUntil = -1f; s_probeTime = -1f;
ParkCount = BossHoldTicks = OutsideRunLeashResets = GateMinBlocked = RunEndNotices = 0;
LastGateHeldElapsed = -1f;
LastRunOutcome = WL.Combat.Run.RunOutcome.None;
// 813w6
s_holdUntilGate = false; s_parkPending = false; s_parkDueTime = -1f;
RespawnParkCount = RespawnParkAttempts = PreGateLeashResets = DeferredParkCount = HoldClearedByGate = BossHpSnapshots = 0;
s_probeHpRatio = -2f; s_probeHp = s_probeHpMax = 0d;
LastBossHpRatio = -1f; LastBossHp = LastBossHpMax = 0d;
}
/// <summary>프로브·QA 용 — 홀드를 즉시 해제한다(값은 건드리지 않는다 · 813w6 「게이트 개방까지」 모드도 함께 푼다).</summary>
public static void ClearBossHold() { s_bossHoldUntil = -1f; s_holdUntilGate = false; }
/// <summary>프로브용 — 보스 없이 홀드 타이머만 건다(홀드 판정 검증).</summary>
public static void ArmBossHoldForProbe() { ArmBossHold(Cfg); }
/// <summary>프로브용 — 런 밖 1틱을 직접 태운다(보스·설정은 호출측이 준비한다).</summary>
public static void OutsideRunLeashTickForProbe() { var cfg = Cfg; if (cfg != null) OutsideRunLeashTick(cfg); }
/// <summary>
/// 813p 「다음 런」 — 게이트를 다시 잠근다. `RunDirector.Restart()` 가 부르는 유일한 진입점이다.
/// 🔴 813w3 §1-4(D-27): 813p 판은 `s_gateOpen` 만 내려서 ① 지난 런의 누적 처치로 게이트가 즉시 다시 열리고
/// ② 보류 목록이 `released` 인 채라 보스가 영영 안 나왔다. 아래 세 가지를 함께 처리한다(전부 SO 스위치).
/// 존 누적 카운터(`s_zones.kills/spawns`)는 813p 계약대로 **지우지 않고**, 기준선만 다시 잡는다.
/// </summary>
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; // 맵/스포너가 바뀌었을 수 있다 — 다시 훑는다
s_parkPending = false; s_parkDueTime = -1f; // 813w6 — 여기서 보스를 직접 처리하므로 미뤄 둔 고정은 버린다
s_rearmCount = -1; // 813w7 — 존 감시 목록도 다시 잡는다(런 존이 바뀌었을 수 있다)
// ① 게이트 조기 개방(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);
}
/// <summary>813w3 §1-4 ① — 게이트 판정 기준선을 지금 값으로 다시 잡는다(누적값 자체는 유지 · 813p 계약).</summary>
static void RebaseGateCounters()
{
s_totalKillBase = TotalZoneKills;
foreach (var kv in s_zones) { kv.Value.killBase = kv.Value.kills; kv.Value.spawnBase = kv.Value.spawns; }
}
/// <summary>813w3 §1-4 ② — 보류 목록을 다시 무장한다(released·resume 원복). 개방 때 원본 Start 가 다시 돈다.</summary>
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++;
}
}
/// <summary>프로브용 — 보스 참조를 직접 넣는다(에디트 모드에는 실제 스폰이 없다).</summary>
public static void SetBossForProbe(MobActor boss) { s_boss = boss; }
/// <summary>프로브용 — PC 이탈 유예 타이머만 비운다(리쉬 시나리오 사이 초기화).</summary>
public static void ResetLeashState() { s_playerOutsideSince = -1f; s_leashFaulted = false; s_lastLeashHeal = -999f; }
/// <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(); }
}
}