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