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

1164 lines
68 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 선례).
// ─────────────────────────────────────────────────────────────────────────────
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;
// ───────────────────────────────────────── 진단(프로브·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>이 런의 게이트 판정 기준 처치 수(= 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>지금 보스가 「이동·타깃 금지」 홀드 중인가.</summary>
public static bool BossHeld { get { return s_bossHoldUntil > 0f && Now() < s_bossHoldUntil; } }
/// <summary>RunEvents.RunEnded 구독 여부(프로브).</summary>
public static bool RunSubscribed { get { return s_runSubscribed; } }
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) — 이유는 고정 문자열로 남긴다
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);
}
}
/// <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);
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;
// 🔴 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 그대로).</summary>
static void ArmBossHold(WLBossArenaSettings cfg)
{
if (cfg == null || cfg.nextRunBossHoldSeconds <= 0f) { s_bossHoldUntil = -1f; return; }
s_bossHoldUntil = Now() + cfg.nextRunBossHoldSeconds;
Log("보스 홀드 " + cfg.nextRunBossHoldSeconds.ToString("F0") + "s (이동·타깃 금지) — 해제 " +
s_bossHoldUntil.ToString("F1"));
}
/// <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;
if (!s_nearZonesScanned) ScanNearZones(cfg);
for (int i = 0; i < s_nearZones.Count; i++)
{
var m = s_nearZones[i];
if (DSUtil.CheckNull(m) || !m.Get_LoadComplete()) continue;
m.StopAllCoroutines();
ZoneSpawnHoldTicks++;
}
}
/// <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)");
}
/// <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();
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;
// 813w5
s_bossHoldUntil = -1f; s_probeTime = -1f;
ParkCount = BossHoldTicks = OutsideRunLeashResets = GateMinBlocked = RunEndNotices = 0;
LastGateHeldElapsed = -1f;
LastRunOutcome = WL.Combat.Run.RunOutcome.None;
}
/// <summary>프로브·QA 용 — 홀드를 즉시 해제한다(값은 건드리지 않는다).</summary>
public static void ClearBossHold() { s_bossHoldUntil = -1f; }
/// <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; // 맵/스포너가 바뀌었을 수 있다 — 다시 훑는다
// ① 게이트 조기 개방(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(); }
}
}