1593 lines
95 KiB
C#
1593 lines
95 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// BossArena.cs — 존 전멸 → 보스 등장 게이트 · 아레나 경계 · 처치 후 정리
|
||
//
|
||
// PD 지시 #813 · 발주서 WL-813d §1 (2026-09-09)
|
||
// 규격 = 813e 완료보고(아레나 (139,0.8,−20) r20 · FieldBossData 상시 배치 · 존 6행)
|
||
// + 813h 완료보고(BossEvents.BossPhase · BossPatternTable) + 813b §6-7/6-8
|
||
//
|
||
// ■ 무엇을 하나
|
||
// ① 게이트 — 813e 존 5개(813001~813005)의 생존/처치를 CombatEvents.Killed/Spawned 로 세고,
|
||
// 게이트 존(기본 813004·813005) 전멸 또는 누적 처치 ≥ N 이면 보스를 등장시킨다.
|
||
// ② 아레나 — 등장 직전에 MonsterList 행의 f_BaseChaseRange 를 아레나 반경으로 낮추고(코드 상수 0),
|
||
// 보스가 아레나 밖으로 나가거나 PC 가 아레나를 떠나면 중심으로 복귀(리셋)시킨다.
|
||
// ③ 정리 — 보스 처치 후 FieldBossData 의 RegenTime 재등장 코루틴을 끊는다(리젠 없음 · SO).
|
||
//
|
||
// ■ 「보스를 나중에 등장시키는」 방법 — 원본 스포너 무수정 (발주서 §2)
|
||
// FieldBossData.Start() 가 유일한 스폰 경로다(813e 는 코드 0 으로 배치했다).
|
||
// Unity 의 Start() 는 컴포넌트당 1회뿐이라 SetActive 토글로는 다시 부를 수 없고,
|
||
// 스폰 코드를 이 파일에 복제하면 원본과 두 벌이 된다.
|
||
// → 훅 1줄이 자기 자신(Start)을 System.Action 으로 넘긴다. 게이트가 닫혀 있으면 true 를 돌려
|
||
// 원본이 그 자리에서 return 하고, 게이트가 열릴 때 그 델리게이트를 다시 부른다.
|
||
// 두 번째 진입에서는 HoldSpawn 이 false 를 주므로 원본 스폰 코드가 그대로 실행된다.
|
||
// → 스폰 로직 복제 0 · AddrResourceMgr/ActorInfo 등록 경로 동일 · On_Regen 도 원본이 부른다
|
||
// (= Spawned(isBoss) 가 「등장 시점」에 제대로 난다 → 811d 보스 캠 · 813c HP 바 · 813t 배너).
|
||
//
|
||
// ■ C8 롤백 — WLBossArenaSettings.Active 가 false 면 HoldSpawn 이 즉시 false.
|
||
// 에셋 없음 / enabled=false / RuntimeDisabled / 코어 off 중 하나면 813e 의 상시 배치 100%.
|
||
// 🔴 코어(WLCombatCoreSettings)가 꺼지면 Killed/Spawned 가 0 이라 게이트가 영영 안 열린다.
|
||
// 그래서 Active 가 코어 상태까지 포함한다 — 코어를 끄면 게이트도 안 걸린다(보스가 사라지지 않는다).
|
||
//
|
||
// ■ 값(C45) — 게임 수치 상수 0. 전부 Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset.
|
||
// 추적 반경은 MonsterList 행(table_monsterlist)의 f_BaseChaseRange 에 「등장 직전」 대입한다.
|
||
// MobActor.Set(mobtable,…) 이 그 값을 복사(MobActor.cs:92 · Check_Battle)하고,
|
||
// Check_Patrol(MobActor.cs:356)은 테이블 값을 직접 읽으므로 어그로·귀환 양쪽에 같은 반경이 걸린다.
|
||
// MonsterList.json(Systems 소유 · xlsm SOT)을 고치지 않고 값만 데이터화하는 경로다.
|
||
//
|
||
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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(() => 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 < −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;
|
||
}
|
||
|
||
/// <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(); }
|
||
}
|
||
}
|