// ───────────────────────────────────────────────────────────────────────────── // RunGuide.cs — 런 진행 가이드: 존 클리어 → 다음 존 자동 이동 · 보스 게이트 뒤 보스 우선 // // PD 지시 #813 · 발주서 WL-813w §1-1·§1-2 (2026-09-09) // 근거 = qa/W2-4b_재채점_v2.md // · D-11(치명) 자동전투가 PC 를 잡몹 쪽으로 끌고 나가 아레나 이탈 → 813d 리쉬가 보스를 // 만피로 **69회** 리셋 → 4분 런이 항상 Timeout(보스 HP 1800/1800 유지). // PC 를 아레나 중심으로 반복 텔레포트하면 HP 가 정상 감소(≈22 HP/s) = 전투식 문제가 아니다. // · D-13(중) 존 2·3·4 가 0/14 · 0/12 · 0/8 그대로 — PC 가 존1 을 안 떠난다(존 이동 로직이 없다). // // ■ 원본 훅 0 · 원본 수정 0 — 공개 API 만 쓴다 (실측 근거) // Actor.cs:956 `Get_Target()` 현재 타깃 읽기 // Actor.cs:957 `Del_Target(bool forcedel)` 타깃 해제(가이드 중 먼 적 무시) // Actor.cs:988 `Change_Target(true, actor)` 타깃 지정 + 아웃라인 교체(원본 터치 타깃과 같은 경로) // Actor.cs:63 `m_IsLockTarget { get; set; }` 원본 「락 타깃」 — MyActor.cs:106·121 이 이 플래그를 // 보고 타깃 교체를 스스로 막는다(보스 고정에 재사용) // Actor.cs:900 `Get_Agent()` → NavMeshAgent 목적지 지정(SetDestination) // Actor.cs:1812 `Get_MoveSpeed()` 이동 속도(원본 Set_Path 와 같은 값) // Actor.cs:2414 `Get_CurAnim()` · 1063 `IsCC()` 공격·스킬·CC 중에는 조종하지 않는다 // PCActor.cs:461 `Set_JoystickStatus(int)` · :20 `JoystickStatus` // PCActor.cs:65 `WL_PlayRunNow()` 달리기 애님(타깃이 없으면 경로를 건드리지 않는다) // 🔴 리플렉션 0 · `PCActor.cs`·`Actor.cs`·`MobActor.cs` 변경 0. // // ■ 왜 JoystickStatus 인가 (실측) // PCActor.cs:139 `if (JoystickStatus > 0 || IsStop || IsDead()) return;` 가 **base.Update() // (= MyActor.Update 의 자동 교전 접근 루프)** 를 통째로 건너뛴다. 그 위(PCActor.cs:93)의 // 자동 스킬 루프는 그대로 돈다 = 「플레이어가 조이스틱으로 이동하는 동안」의 원본 상태 그대로다. // 이 값을 쓰지 않고 목적지만 주면 원본이 매 프레임 `Play_Run() → Set_Path(타깃)`(MyActor.cs:335) // 으로 경로를 덮어써 두 이동이 싸운다. 원본 코드를 한 줄도 고치지 않고 자동전투를 // 「끄지 않은 채」 잠시 비켜 세우는 유일한 공개 경로다. // · 값이 2 인 동안(Skill_Whirlwind·Skill_SweepAttack 이 거는 잠금)에는 조종하지 않는다. // · 조종을 놓을 때는 `Set_JoystickStatus(3)`(= 0 복귀) — 우리가 1 로 만든 경우에만 부른다. // // ■ 가이드 상태 // Off 목적지 없음 — 아무 일도 하지 않는다(원본 100%). // Travel 목적지로 이동 중 — 조종 ON · 타깃 해제. // Hold 도착 — 조종 OFF(원본 자동전투가 그 존/아레나에서 싸운다). // 다만 ① 목적지에서 guideLeashRadius 밖의 타깃은 무시(Del_Target) // ② PC 가 holdRadius 밖으로 밀려나면 Travel 로 되돌아가 끌어온다 // → 존1 로 되돌아가는 것(D-13)과 아레나 이탈(D-11)이 여기서 막힌다. // 피격 resumeAfterHitSeconds 동안 가이드를 멈추고 원본에 넘긴다(즉시 응전). // // ───────────────────────────────────────────────────────────────────────────── // WL-813w2 (2026-09-09 · PD 지시 #813) — 근거 = qa/W2-5_재채점_v3.md §4 런 4회 실측 // // 🔴 실측 결함과 그 원인(한 곳이다) // · D-24 존 유도의 「도착」이 런 4회 내내 한 번도 안 잡혔다(`Arrived=0` · State=Travel 고착). // · D-22 보스 홀드 14 m 가 안 지켜져 PC 가 아레나에서 **최대 46.3 m** 이탈 → // 813d 리쉬(24 m)가 런당 **15~19회** 걸려 깎은 HP 를 되돌린다. // 되돌림(HoldPullbacks)은 런당 **3~4회**뿐 — 되돌림 검사 자체가 안 돈 것이다. // · D-19 화면 안 적 수 18 → 2. PC 가 존 반경 밖을 떠돈다. // // 원인 = **813w 의 Poll 이 피격 중단(`now < s_hitUntil`)에서 통째로 return** 했다(구 RunGuide.cs:291). // 그 return 이 ① 도착 판정(구 :303) ② 이탈 되돌림(구 :327) **위**에 있었다. // 잡몹 밭에서는 `resumeAfterHitSeconds`(3 s)가 매 피격마다 갱신돼 사실상 상시 참이므로 // **도착 판정도 되돌림도 폴링에서 실행되지 않았다.** (HoldPullbacks 3~4 ≪ ResetCount 15~19 이 // 그 증거다 — 이 두 값은 같은 함수의 같은 조건에서 나온다.) // // ■ 수리 (값은 전부 SO · 구조 변경 없음) // ① 순서 교정 — 도착 판정·이탈 되돌림은 **피격 중단과 무관하게 매 폴링 실행**한다. // 피격 중단은 이제 **Hold(= 목적지 반경 안)에서 조종을 놓는 것**만 뜻한다 // (`hitSuspendOnlyInHold`). 이동·되돌림은 맞아도 멈추지 않는다 = 「이동 중 잡몹 무시」. // ② 정지 판정 도착 — XZ 거리 판정에 더해, 조종 중인데 `arriveStillSeconds`(0.5 s) 동안 // `arriveStillSpeed`(0.3 m/s) 미만이면 **그 자리를 도착으로 확정**한다(목적지 반경 안일 때만). // NavMesh 높이·stoppingDistance·지형 때문에 반경 안으로 못 붙는 D-24 경로를 여기서 닫는다. // `maxTravelSeconds` 초과도 Hold 로 떨어뜨린다(이탈 감시는 계속 · 813w 와 동일). // ③ 되돌림 중 타깃 해제 — 이동 중에는 **보스 타깃도** 놓는다(`pullbackReleasesTarget`). // 타깃이 남아 있으면 원본 `Play_Run() → Set_Path(타깃)`(MyActor.cs:335)이 매 폴링 // 우리 목적지를 덮어써 두 이동이 싸운다. 락(`m_IsLockTarget`)은 유지하므로 // **보스 생존 중 잡몹이 타깃이 되는 일은 없다**(재타깃은 Hold 에서 · `bossRetargetOnlyInHold`). // ④ 진행 로그 — `RunEvents.RunTick` 구독으로 존 체류 초·이동 초·최대 이탈 m 를 남긴다 // (`runTickLogSeconds` · QA 가 이 줄로 존 도착 시각표를 적는다). // // ■ C8 롤백 — `hitSuspendOnlyInHold=0` · `arriveStillEnabled=0` · `pullbackReleasesTarget=0` // · `bossRetargetOnlyInHold=0` 을 전부 끄면 813w 동작 그대로다(값 하나로 되돌릴 수 있다). // ───────────────────────────────────────────────────────────────────────────── // WL-813w3 (2026-09-09 · PD 지시 #813) — 근거 = qa/W2-6_재채점_v4.md D-22 **재발** // // 🔴 실측 — 813w2 의 되돌림은 「돌긴 도는데 못 이긴다」 // · 이탈 39.2 / 54.5 / 49.2 / 55.5 m · 아레나 리쉬 거리 25.2~56.5 m // · 되돌림(HoldPullbacks) 3~5회 ≪ 리쉬 리셋 10~21회 · 보스 HP 최저 34~56 %(처치 0회) // · 813w2 프로브 ⑥ 은 「바깥 드리프트 4 m/s vs 되돌림 5 m/s」 가정으로 최대 이탈 14.4 m 를 냈다. // 인게임에서 39~56 m 가 나왔다 = **그 가정(드리프트 4 m/s)이 틀렸다.** 되돌림은 NavMeshAgent // 목적지 지정이라 원본이 같은 프레임에 Set_Path(타깃)로 덮어쓰거나, 잡몹에 막히거나, // 공격·CC·스킬 잠금(Steer 의 원본 우선 가드)으로 조종 자체를 못 하면 **속도가 0 이 된다.** // // ■ ① 이탈 원인 진단(코드로 남긴다 · §1-1) // 이탈 순간의 **원본 상태를 그대로 찍는다** — 타깃(보스/잡몹/없음) · JoystickStatus · // 보스 락(m_IsLockTarget) · 현재 애님·CC · NavMeshAgent(속도·정지·목적지가 아레나에서 몇 m) · // 가이드 상태(Travel/Hold) · 최근 피격 경과 · **보스가 점프(Anubis 스킬2 Co_JumpToPosition · // 그 동안 보스 NavMeshAgent.enabled=false)로 멀어져 PC 가 따라간 것인지**. // 링버퍼 32칸(사전 할당 · 기록 자체는 GC 0) + `verboseLog` 와 무관하게 콘솔 1줄/이탈. // → 「누가 PC 를 끌고 나갔는가」를 Q7 이 로그만 보고 판정할 수 있다. // // ■ ② 강제 복귀 2단(§1-2) // ⓐ 기존 되돌림(SetDestination) 그대로 → ⓑ `warpBackAfterSeconds`(1.5 s) 동안 반경 밖이면 // **원본 `Actor.Set_Warp`**(Actor.cs:2111 · 813i DeathFlow 의 `On_Regen → Set_Warp` 와 같은 API)로 // 아레나 중심 ±`warpJitterRadius`(3 m) 지점으로 워프한다. NavMesh 위로 먼저 스냅(PC 는 // `Get_NavSnapRadius()=0` 이라 Set_Warp 이 스스로 스냅하지 않는다) · 잔상/이펙트 0 · // 카메라 리드는 `RealCamera.Get_MoveLead().Snap()`(811 카메라 리드 API)로 0 스냅 · 쿨 5 s · 로그 1줄. // 🔴 보스가 스킬로 아레나 밖에 나가 있고 PC 가 그 보스 곁(`followBossRadius`)이면 **워프하지 않는다** — // 그건 이탈이 아니라 정상 추적이고, 워프하면 오히려 전투가 끊긴다. // // ■ C8 롤백 — `warpBackEnabled=0` 이면 워프 경로가 통째로 죽고 813w2 동작 그대로다. // `exitDiagEnabled=0` 이면 진단 로그도 0. // ───────────────────────────────────────────────────────────────────────────── // WL-813w7 (2026-09-09 · PD 지시 #813) — 근거 = qa/W2-10_재채점_v8.md **D-45** // // 🔴 실측 결함 — 「다음 런」의 사냥터가 비어 있다(R1n 0/18 · R2n 1/18 · R3n 14/18 정지). // R2n 은 PC 좌표 `(112.93, 0.06, −16.60)` = 존0 앵커 `(115, 0.39, −22)` 에서 5.8 m, // 즉 **PC 는 존0 에 도착해 Hold 로 서 있는데 때릴 몹이 없다**(HP 310/310 무피해). // // ■ 이 파일이 맡은 몫 = ① 결과창 동안 PC 를 세운다(§1-2 ①) // 813w6 까지는 `OnRunEnded` 가 `StopGuide` 만 했다 = 가이드를 끄고 **원본 자동전투 100%** 로 돌려줬다. // Q10 실측: 결과창을 100 초 띄워 둔 사이 존0 처치가 **26 → 47**(21마리)로 늘었다. // 이제 런이 끝나면 다음 런이 시작될 때까지(또는 `runEndHoldMaxSeconds`) PC 를 붙잡는다 — // ⓐ `Set_JoystickStatus(1)` → PCActor.cs:139 이 `base.Update()`(자동 교전 접근 루프)를 건너뛴다 // ⓑ `Del_Target(true)` 매 폴링 → PCActor.cs:93 자동 스킬 루프는 `isTarget` 이 있어야만 돈다 // ⓒ `NavMeshAgent.ResetPath()` → 런 종료 순간의 잔여 경로로 계속 걸어가지 않는다 // 복귀 = 다음 `RunStarted`(= `RunDirector.Restart()`) 또는 상한 만료 또는 PC 사망. // 🔴 UI 0 — RunGuide 는 결과창을 보지 않는다. 「결과창 닫힘」은 상한으로만 판정한다(§4 미확인). // // ■ C8 롤백 — `holdPcOnRunEnd=0` 이면 이 경로가 통째로 죽고 813w6 동작 그대로다. // ───────────────────────────────────────────────────────────────────────────── // WL-813w8 §1-1 (2026-09-09 · PD 지시 #813) — 근거 = qa/W2-11_재채점_v9.md **D-46** // // 🔴 실측 결함 — 위 정지는 **PC 의 공격만** 막는다. 몹은 그대로 때려 결과창을 켜 둔 채 // **초당 3.4~4.9 HP** 가 깎이고 **55~80 초에 PC 가 죽는다**(런 3회 3/3 · 사망 파일 3건). // 813w7 §6-7 이 「무적 유지 중이라 실질 피해 0」이라고 남긴 「추정」이 뒤집힌 자리다. // `mobHitInvincible=0`(813i2 설계값)이라 잡몹 연타를 끊는 창이 없고, // 상한 `runEndHoldMaxSeconds 120 s` 는 사망(55~80 s)보다 **늦다**. // // ■ 이 파일이 맡은 몫 = 정지 구간과 **정확히 같은 구간**에 813i 무적 API 래치를 건다 // `Survival.SetHoldInvincible(pc, true)` — 시간 창이 아니라 래치라서 결과창을 얼마나 오래 켜도 유지된다. // 걸기 = `BeginRunEndHold` + 매 정지 틱(재확인) · 풀기 = `EndRunEndHold`(런 시작·상한 만료·스위치 off·가이드 off). // 🔴 사망 중에는 걸지 않는다(부활 우선) — 부활 뒤 첫 정지 틱에서 다시 걸린다. // 피해 0 은 813i2 피격 창과 같은 원본 경로(플래그를 호출 동안만 올린다)라 **비네트·타격 반응도 0**이다. // // ■ C8 롤백 — `invincibleWhileRunEndHold=0` 이면 래치를 한 번도 걸지 않는다(= 813w7 동작 · D-46 재현). // ───────────────────────────────────────────────────────────────────────────── // WL-813i2 §1-2 (2026-09-09 · PD 지시 #813) — 보스 예고 중 자동 회피 스텝 // // 🔴 근거 = qa/W2-10_재채점_v8.md §6 — 사망 **14/14 가 보스 연타**(0.52 s 간격 5타 · 한 타에 HP 6~26 %). // 본 방어선은 813i2 의 「피격 후 무적 창」이고, 이 스텝은 그 앞에서 **애초에 덜 맞는** 축이다 // (813r 회피의 축소판 · 실패해도 무적이 받쳐 준다). // ■ 예고 감지는 `Actor.m_animation`(public) 의 애니메이터 상태 이름 하나뿐이다 — 아래 회피 스텝 구역의 // 주석에 그 실측 근거(왜 `Get_CurAnim()`·`MobStatus` 로는 못 읽는지)를 적어 두었다. // ■ C8 롤백 = `dodgeStepEnabled = 0`(또는 `dodgeTelegraphStates` 를 비운다) → 813w4 동작 100 %. // // 🔴 813w7 과의 관계(2026-09-09 rebase 로 두 축이 한 파일에서 만났다) // 결과창 중 PC 정지(813w7)가 걸려 있는 동안에는 회피 스텝을 **시작하지 않는다** — // 두 축이 같은 프레임에 PC 를 반대로 밀면 「결과창 동안 PC 를 세운다」가 깨진다. // 정지가 걸리는 순간 진행 중이던 스텝도 함께 끝낸다(BeginRunEndHold → EndDodgeStep). // 런이 끝나면 보스도 없으므로 실전에서 겹칠 일은 없고, 이 가드는 순서 보장용이다. // ───────────────────────────────────────────────────────────────────────────── // // ■ 값(C45) 전부 WLRunGuideSettings. enabled = 0 이면 러너가 뜨지 않고 구독도 하지 않는다(C8). // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; using UnityEngine.AI; using WL.Combat.Boss; using WL.Combat.Core; namespace WL.Combat.Run { /// 가이드 상태. public enum RunGuideState { /// 목적지 없음(원본 100%). Off = 0, /// 목적지로 이동 중(조종 ON). Travel = 1, /// 도착 — 그 자리에서 원본 자동전투(조종 OFF · 이탈만 감시). Hold = 2, } /// 목적지 종류. public enum RunGuideDest { None = 0, /// 존 스포너 앵커. Zone = 1, /// 보스 아레나 중심. Boss = 2, } /// 813w3 §1-1 — 아레나 이탈 1건의 원본 상태 스냅샷(struct · 링버퍼에 사전 할당 · GC 0). public struct ArenaExitRecord { /// 발생 시각(unscaled) · 프레임. public float time; public int frame; /// PC↔아레나 중심 XZ(m) · PC↔보스 XZ(m · 보스 없으면 −1) · 보스↔중심 XZ(m · 보스 없으면 −1). public float distFromCenter, distFromBoss, bossFromCenter; /// NavMeshAgent 의 현재 목적지가 아레나 중심에서 몇 m 인가(−1 = 경로 없음/에이전트 없음). /// 🔴 이 값이 크면 **원본이 우리 목적지를 덮어썼다**는 직접 증거다(MyActor.cs:335 Set_Path). public float agentDestFromCenter; /// 0 없음 · 1 보스 · 2 잡몹(MobActor) · 3 그 밖. public int targetKind; /// PCActor.JoystickStatus 실측값(0 원본 / 1 우리 조종 / 2 스킬 이동 잠금). public int joystickStatus; /// Actor.Get_CurAnim()(eAnim 정수) · 가이드 상태(RunGuideState 정수). public int anim, guideState; /// 보스 m_IsLockTarget · 우리 조종 중 · PC CC · 보스 점프 중(NavMeshAgent.enabled=false) · 최근 피격 · 이 줄이 워프인가. public bool bossLocked, steering, pcCC, bossJumping, recentHit, warped; /// NavMeshAgent 정지 플래그 · NavMesh 위인가. public bool agentStopped, agentOnNavMesh; /// 마지막 피격 후 경과(초 · 응전 구간이 아니면 −1) · NavMeshAgent 수평 속도(m/s). public float sinceHit, agentSpeed; /// 이 이탈이 몇 번째인가(ArenaExits 값). public int index; } /// 런 진행 가이드. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개) · GC 0. public static class RunGuide { static WLRunGuideSettings Cfg { get { return WLRunGuideSettings.Instance; } } /// 가이드가 살아 있는가(에셋 · enabled · 런타임 스위치 · 런 축). false 면 원본 자동전투 100%. public static bool Active { get { return WLRunGuideSettings.Enabled; } } // ───────────────────────────────────────── 상태 (프로브가 읽는다) public static RunGuideState State { get { return s_state; } } public static RunGuideDest DestKind { get { return s_destKind; } } /// 존 목적지일 때의 존 인덱스(0부터). 보스면 −1. public static int ZoneIndex { get { return s_zoneIndex; } } /// 현재 목적지(월드). 보스면 BossArena.Center 를 매 틱 다시 읽는다. public static Vector3 Destination { get { return s_dest; } } /// 지금 WL 이 PC 를 조종 중인가(JoystickStatus = 1). public static bool Steering { get { return s_steering; } } /// 구독 상태. public static bool Subscribed { get { return s_subscribed; } } /// 고장(예외) 상태 — 이후 아무 일도 하지 않는다. public static bool Faulted { get { return s_faulted; } } // ───────────────────────────────────────── 진단 카운터 public static int GuideBegun, Arrived, GaveUp, FarTargetIgnored, HoldPullbacks, Repaths, BossRetargets, BossLocks, HitSuspends, AnchorScans; /// 813w2: 정지 판정으로 확정한 도착 수(Arrived 에도 함께 센다) · 피격 중에도 실행한 되돌림 수. public static int ArrivedByStill, PullbacksWhileHit; /// 813w2: 목적지에서 잰 최대 이탈 거리(m · Q5 D-22 의 「46.3 m」에 대응하는 실측값). public static float MaxDistFromDest; /// 813w2: 존 체류 초 · 보스(아레나) 체류 초 · 이동(Travel) 초 — RunTick 로그와 QA 시각표용. public static float ZoneHoldSeconds, BossHoldSeconds, TravelSeconds; public static string LastLog = ""; /// 마지막으로 무시한 타깃 이름(진단). public static string LastIgnoredTarget = ""; /// 813w2: 마지막 RunTick 요약 줄(프로브·QA 가 읽는다). public static string LastTickLog = ""; // ───────────────────────────────────────── 813w3 진단 (§1-1 · §1-2) /// 아레나 이탈 에피소드 수 · 실제 워프 수. public static int ArenaExits, ArenaWarps; /// 워프를 건너뛴 사유별 수 — 쿨다운 · 보스 추적 중(정상) · NavMesh 지점 없음. public static int WarpsSkippedCooldown, WarpsSkippedNearBoss, WarpsFailedNavMesh; // ───────────────────────────────────────── 813w4 진단 (D-34 · D-20) /// 813w4 §1-1: 「다음 런」에서 존0 유도를 다시 건 횟수 · 정지 감시로 건 횟수. public static int RestartGuides, StallGuides; /// 813w4 §1-2: 엘리트 락 · 재타깃 · 해제 횟수(진단 · QA 가 읽는다). public static int EliteLocks, EliteRetargets, EliteReleases; /// 813w4 §1-2: 마지막으로 고정한 엘리트 이름(진단). public static string LastEliteName = ""; /// 마지막 이탈 진단 줄 원문(프로브·QA 가 읽는다 · 콘솔에 나간 그 문자열). public static string LastExitLog = ""; // ───────────────────────────────────────── 813w7 진단 (D-45 · §1-2 ①) /// 813w7 §1-2 ①: 런 종료 정지를 건 횟수 · 푼 횟수 · 정지 폴링 틱 수 · 정지 중 뗀 타깃 수. public static int RunEndHolds, RunEndHoldReleases, RunEndHoldTicks, RunEndTargetClears; /// 지금 「결과창 중 PC 정지」 상태인가(프로브·QA 가 읽는다). public static bool RunEndHeld { get { return s_runEndHold; } } /// 정지 상한 만료 시각(unscaled · 무제한이면 −1). public static float RunEndHoldUntil { get { return s_runEndHoldUntil; } } /// 마지막으로 정지를 푼 사유(진단). public static string LastRunEndHoldReason = ""; // ───────────────────────────────────────── 813w8 진단 (D-46 · §1-1) /// 813w8: 정지 무적 래치를 건 횟수 · 푼 횟수(QA 는 N=M 을 본다). public static int RunEndInvincibleOn, RunEndInvincibleOff; /// 지금 정지 무적이 걸려 있는가(Survival 래치 실측 · 프로브·QA 가 읽는다). public static bool RunEndInvincible { get { return WL.Combat.Survival.Survival.HoldInvincibleActive; } } /// 진단 링버퍼 칸 수. 게임 수치가 아니라 버퍼 크기다(발주서 §1-1 「링버퍼 32」). const int kExitLogCapacity = 32; static readonly ArenaExitRecord[] s_exitLog = new ArenaExitRecord[kExitLogCapacity]; static int s_exitLogNext, s_exitLogCount; /// 링버퍼에 쌓인 기록 수(최대 32). public static int ExitLogCount { get { return s_exitLogCount; } } /// 링버퍼 읽기 — 0 = 가장 오래된 기록. 범위 밖이면 빈 구조체. public static ArenaExitRecord ExitLogAt(int i) { if (i < 0 || i >= s_exitLogCount) return default(ArenaExitRecord); int start = s_exitLogCount < kExitLogCapacity ? 0 : s_exitLogNext; return s_exitLog[(start + i) % kExitLogCapacity]; } // ───────────────────────────────────────── 내부 상태 static RunGuideState s_state; static RunGuideDest s_destKind; static int s_zoneIndex = -1; static Vector3 s_dest; static float s_nextPoll, s_nextRepath, s_travelStart, s_hitUntil; static bool s_steering, s_subscribed, s_faulted; static Actor s_boss, s_lockedBoss; // 813w2 §1-1 정지 판정 · §1-3 체류 계측 static Vector3 s_lastPos; static float s_lastPosTime = -1f, s_stillSince = -1f, s_lastAccumTime = -1f, s_nextTickLog; // 813w3 §1-2 강제 복귀 — 이탈 시작 시각 · 마지막 워프 시각 · 다음 반복 진단 시각 static float s_outsideSince = -1f, s_lastWarp = -999f, s_nextExitDiag; // 🔴 813w3: 아레나에 **한 번이라도 도착한 뒤**부터만 이탈로 센다. // 게이트가 열린 순간 PC 는 존에 있어 중심에서 50 m 밖일 수 있는데, 그건 이탈이 아니라 「가는 중」이다. // 이 가드가 없으면 게이트 개방 1.5 s 뒤에 PC 가 아레나로 순간이동해 이동 구간이 통째로 사라진다. static bool s_bossArrivedOnce; // 813w4 §1-1 정지 감시 — 마지막으로 본 PC 좌표·그 시각(가이드가 쉬는 동안만 쓴다) static Vector3 s_idlePos; static float s_idleSince = -1f, s_lastStallGuide = -999f; static int s_lastRestartCount; // 813w7 §1-2 ① 결과창 중 PC 정지 — 정지 중인가 · 상한 만료 시각(−1 = 무제한) static bool s_runEndHold; static float s_runEndHoldUntil = -1f; // 813w4 §1-2 엘리트 등록부 — 사전 할당 배열(GC 0 · 버퍼 크기지 게임 수치가 아니다) const int kMaxElites = 16; static readonly Actor[] s_elites = new Actor[kMaxElites]; static Actor s_lockedElite; // 존 앵커 캐시(스포너 노드 실좌표 · 없으면 SO 폴백) const int kMaxZones = 16; static readonly Vector3[] s_anchors = new Vector3[kMaxZones]; static readonly bool[] s_anchorFromNode = new bool[kMaxZones]; static int s_anchorCount; // 구독 핸들러 캐시(할당 0 — Add/Remove 가 같은 델리게이트를 봐야 한다) static readonly CombatHandler h_runStarted = OnRunStarted; static readonly CombatHandler h_zoneCleared = OnZoneCleared; static readonly CombatHandler h_gate = OnBossGateOpened; static readonly CombatHandler h_bossStarted = OnBossStarted; static readonly CombatHandler h_runEnded = OnRunEnded; static readonly CombatHandler h_damaged = OnDamaged; static readonly CombatHandler h_runTick = OnRunTick; // 813w2 §1-3 진행 로그 static readonly CombatHandler h_spawned = OnSpawned; // 813w4 §1-2 엘리트 등록 static readonly CombatHandler h_killed = OnKilled; // 813w4 §1-2 엘리트 해제 // ───────────────────────────────────────────────────────────────────── // 구독 // ───────────────────────────────────────────────────────────────────── /// RunEvents · CombatEvents.Damaged 구독(중복 안전). public static void EnsureSubscribed() { if (s_subscribed) return; s_subscribed = true; RunEvents.RunStarted.Add(h_runStarted); RunEvents.ZoneCleared.Add(h_zoneCleared); RunEvents.BossGateOpened.Add(h_gate); RunEvents.BossStarted.Add(h_bossStarted); RunEvents.RunEnded.Add(h_runEnded); RunEvents.RunTick.Add(h_runTick); CombatEvents.Damaged.Add(h_damaged); CombatEvents.Spawned.Add(h_spawned); // 813w4 §1-2 CombatEvents.Killed.Add(h_killed); // 813w4 §1-2 } /// 구독 해제(A/B · 프로브). public static void Unsubscribe() { if (!s_subscribed) return; s_subscribed = false; RunEvents.RunStarted.Remove(h_runStarted); RunEvents.ZoneCleared.Remove(h_zoneCleared); RunEvents.BossGateOpened.Remove(h_gate); RunEvents.BossStarted.Remove(h_bossStarted); RunEvents.RunEnded.Remove(h_runEnded); RunEvents.RunTick.Remove(h_runTick); CombatEvents.Damaged.Remove(h_damaged); CombatEvents.Spawned.Remove(h_spawned); CombatEvents.Killed.Remove(h_killed); } // ───────────────────────────────────────────────────────────────────── // 이벤트 → 목적지 // ───────────────────────────────────────────────────────────────────── static void OnRunStarted(in RunStartedEvent e) { // 🔴 813w7 §1-2 ① — 런이 다시 시작됐다 = 결과창이 끝났다. PC 정지를 먼저 푼다(StopGuide 보다 앞). EndRunEndHold("런 시작"); StopGuide("런 시작"); s_boss = null; s_anchorCount = 0; // 맵이 바뀌었을 수 있다 — 앵커를 다시 읽는다 // 813w2 §1-3: 런마다 체류·이탈 계측을 새로 잡는다(런 간 누적 금지 — QA 가 런 1회 표를 읽는다). ZoneHoldSeconds = BossHoldSeconds = TravelSeconds = MaxDistFromDest = 0f; ArrivedByStill = PullbacksWhileHit = 0; s_nextTickLog = 0f; s_lastAccumTime = -1f; LastTickLog = ""; // 813w3: 이탈·워프 집계도 런 단위(QA 가 런 1회 표를 읽는다). 링버퍼 기록은 남긴다. ArenaExits = ArenaWarps = WarpsSkippedCooldown = WarpsSkippedNearBoss = WarpsFailedNavMesh = 0; s_outsideSince = -1f; s_lastWarp = -999f; s_nextExitDiag = 0f; s_bossArrivedOnce = false; // 813w4 §1-1: 정지 감시도 런마다 새로 잡는다. s_idleSince = -1f; s_lastStallGuide = -999f; // 813i2 §1-2: 예고 회피 스텝 집계도 런 단위(QA 가 런 1회 표를 읽는다). EndDodgeStep("런 시작"); DodgeSteps = DodgeTelegraphsSeen = 0; DodgeSkippedCooldown = DodgeSkippedPcBusy = DodgeFailedNavMesh = 0; s_wasTelegraphing = false; s_lastDodge = -999f; var cfg = Cfg; if (cfg == null) return; // 🔴 813w4 §1-1 (D-34) — 「다음 런」이 존0 유도를 다시 걸지 않는 것이 166 s 정지의 원인이다. // RunDirector.Restart() → StartRun("restart") → 이 핸들러까지는 실제로 도달한다(구독 경로 실측 완료). // 끊긴 곳은 그 다음 줄의 `cfg.guideOnRunStart` 로, 에셋 값이 0 이라 BeginZone(0) 이 호출되지 않았다. // 런 1 은 PC 가 맵 진입점(존0 교전 범위 안)에서 시작해 원본 자동전투가 대신 해 주지만, // Restart() 는 PC 를 부활 좌표로 워프시켜(RunDirector.cs:508 `pc.On_Regen`) 존0 에서 33 m 떨어진 // 빈 자리에 세운다 → 타깃도 목적지도 없어 그대로 멈춰 선다. bool restart = IsRestartReason(e.reason) || RunDirector.RestartCount > s_lastRestartCount; s_lastRestartCount = RunDirector.RestartCount; if (restart && cfg.restartResetsGuideState) { s_faulted = false; // 런 1 의 예외가 세션 끝까지 가이드를 죽이는 것을 막는다 EnsureSubscribed(); // 구독이 어떤 이유로 풀렸으면 여기서 다시 건다 } if (!ShouldGuideOnStart(cfg, restart)) return; if (restart && !cfg.guideOnRunStart) RestartGuides++; BeginZone(0, restart ? "다음 런" : "런 시작"); } /// /// 이 RunStarted 에서 존0 유도를 걸어야 하는가(순수 함수 · C8 검증용). /// 🔴 D-34 가 끊긴 지점이 바로 이 판정이다 — 813w3 까지는 `guideOnRunStart` 하나뿐이었고 에셋 값이 0 이었다. /// public static bool ShouldGuideOnStart(WLRunGuideSettings cfg, bool restart) { if (cfg == null || !cfg.zoneGuideEnabled) return false; return cfg.guideOnRunStart || (restart && cfg.guideOnRestart); } /// /// 이 RunStarted 가 「다음 런」인가(순수 함수 · 프로브가 직접 부른다). /// 813p 보고 §4 의 계약 = `RunDirector.Restart()` → `StartRun("restart")`. /// 문자열이 바뀌어도 살아남도록 호출측이 `RunDirector.RestartCount` 증가도 함께 본다. /// public static bool IsRestartReason(string reason) { return string.Equals(reason, "restart", System.StringComparison.Ordinal); } /// 프로브용 — 진짜 RunStarted 페이로드를 만들어 핸들러를 그대로 태운다(Play 불필요). public static void ProbeRunStarted(int runIndex, string reason) { var e = new RunStartedEvent { runIndex = runIndex, startTime = Time.unscaledTime, runSeconds = 0f, zoneCount = 0, reason = reason, time = Time.unscaledTime, frame = Time.frameCount }; OnRunStarted(in e); } static void OnZoneCleared(in ZoneClearedEvent e) { var cfg = Cfg; if (cfg == null || !cfg.zoneGuideEnabled) return; if (e.allZonesCleared) return; // 게이트 개방 통지가 따로 온다(보스 가이드) BeginZone(e.zoneIndex + 1, "존 " + (e.zoneIndex + 1) + " 클리어"); } static void OnBossGateOpened(in BossGateOpenedEvent e) { BeginBoss("게이트 개방"); } static void OnBossStarted(in BossStartedEvent e) { if (!DSUtil.CheckNull(e.boss)) s_boss = e.boss; BeginBoss("보스 등장"); } // 🔴 813w7 §1-2 ① (D-45) — 813w6 까지는 여기서 가이드만 껐다 = 결과창 동안 원본 자동전투 100%. // Q10 실측: 결과창 100 s 사이 존0 처치 26 → 47 → 다음 런 시작 시 사냥터가 비었다. static void OnRunEnded(in RunResult e) { StopGuide("런 종료"); BeginRunEndHold("런 종료"); } static void OnDamaged(in DamagedEvent e) { var cfg = Cfg; if (cfg == null) return; if (s_destKind == RunGuideDest.None) return; // 813w2 §1-2: 보스 구간은 응전 중단을 짧게 둔다(Q5 D-22 처방 「피격 중단을 보스전에서는 짧게」). float hold = s_destKind == RunGuideDest.Boss ? cfg.bossHitSuspendSeconds : cfg.resumeAfterHitSeconds; if (hold <= 0f) return; var pc = MyValue.MyPC; if (DSUtil.CheckNull(pc) || !ReferenceEquals(e.victim, pc)) return; bool wasFree = s_hitUntil <= Time.unscaledTime; s_hitUntil = Time.unscaledTime + hold; if (wasFree) HitSuspends++; } // ── 813w4 §1-2 엘리트 등록부 (813s EliteMarker 와 같은 출처 = Actor.m_SubRole · 별도 스캔 0 · GC 0) static void OnSpawned(in SpawnedEvent e) { if (e.isElite) NoteEliteSpawn(e.actor); } static void OnKilled(in KilledEvent e) { if (e.subRole == eSubRol.Elite) NoteEliteKill(e.victim); } /// 엘리트를 등록부에 넣는다(중복 안전 · 자리가 없으면 죽은 칸을 재사용). 프로브도 직접 부른다. public static void NoteEliteSpawn(Actor a) { if (!IsAliveTarget(a)) return; int free = -1; for (int i = 0; i < kMaxElites; i++) { if (ReferenceEquals(s_elites[i], a)) return; if (free < 0 && !IsAliveTarget(s_elites[i])) free = i; } if (free >= 0) s_elites[free] = a; } /// 엘리트를 등록부에서 뺀다(고정 중이었으면 함께 해제). 프로브도 직접 부른다. public static void NoteEliteKill(Actor a) { if (a == null) return; for (int i = 0; i < kMaxElites; i++) if (ReferenceEquals(s_elites[i], a)) s_elites[i] = null; if (ReferenceEquals(s_lockedElite, a)) ReleaseEliteLock(); } /// 813w2 §1-3 — 존 체류/이동 초를 주기적으로 한 줄 남긴다(QA 시각표 · GC 는 로그를 실제로 낼 때만). static void OnRunTick(in RunTickEvent e) { var cfg = Cfg; if (cfg == null || cfg.runTickLogSeconds <= 0f) return; if (e.elapsedSec < s_nextTickLog) return; s_nextTickLog = e.elapsedSec + cfg.runTickLogSeconds; LastTickLog = "t=" + e.elapsedSec.ToString("F0") + "s · " + s_state + (s_destKind == RunGuideDest.Zone ? " 존" + s_zoneIndex : s_destKind == RunGuideDest.Boss ? " 보스" : " -") + " · 존체류 " + ZoneHoldSeconds.ToString("F0") + "s · 아레나체류 " + BossHoldSeconds.ToString("F0") + "s · 이동 " + TravelSeconds.ToString("F0") + "s · 최대이탈 " + MaxDistFromDest.ToString("F1") + "m · 도착 " + Arrived + "(정지 " + ArrivedByStill + ") · 되돌림 " + HoldPullbacks + " · 이탈 " + ArenaExits + " 워프 " + ArenaWarps + "(쿨 " + WarpsSkippedCooldown + " 보스곁 " + WarpsSkippedNearBoss + " 맵밖 " + WarpsFailedNavMesh + ")" + // 813w4: 「다음 런」 재유도 · 정지 감시 · 엘리트 우선 타깃(D-34 · D-20 을 이 줄로 판정한다) " · 재유도 " + RestartGuides + "/정지 " + StallGuides + // 813w7 §1-2 ①: 결과창 중 PC 정지(D-45) — QA 가 이 값으로 「빈 사냥터」 수리를 판정한다 " · 결과정지 " + RunEndHolds + "→" + RunEndHoldReleases + "(틱 " + RunEndHoldTicks + " 타깃 " + RunEndTargetClears + ")" + // 813w8 §1-1: 정지 중 무적(D-46) — QA 는 「무적 N→M · 막은타 B」로 판정한다 " 무적 " + RunEndInvincibleOn + "→" + RunEndInvincibleOff + "(막은타 " + WL.Combat.Survival.Survival.BlockedHoldInvincible + ")" + " · 엘리트 " + EliteCount + "(고정 " + (DSUtil.CheckNull(s_lockedElite) ? "-" : LastEliteName) + " 락 " + EliteLocks + " 재타깃 " + EliteRetargets + " 해제 " + EliteReleases + ")" + // 813i2: 예고 감지 · 회피 스텝(QA 가 「예고 → 스텝 1회」를 이 줄로 센다) " · 회피 " + DodgeSteps + "/예고 " + DodgeTelegraphsSeen + "(쿨 " + DodgeSkippedCooldown + " PC " + DodgeSkippedPcBusy + " 맵밖 " + DodgeFailedNavMesh + ")" + " · 존 " + e.zonesCleared + "/" + e.zoneCount + " " + e.zoneKills + "/" + e.zoneRequired; Debug.Log("[RunGuide] " + LastTickLog); } // ───────────────────────────────────────────────────────────────────── // 목적지 지정 // ───────────────────────────────────────────────────────────────────── /// 존 i 로 유도한다(프로브도 직접 부를 수 있다). public static void BeginZone(int zoneIndex, string reason) { var cfg = Cfg; if (cfg == null || !cfg.zoneGuideEnabled) return; int n = ZoneCount(cfg); if (zoneIndex < 0 || zoneIndex >= n) { StopGuide("존 " + zoneIndex + " 없음"); return; } Vector3 anchor; if (!TryGetAnchor(cfg, zoneIndex, out anchor)) { StopGuide("존 " + zoneIndex + " 앵커 없음"); return; } ReleaseBossLock(); s_destKind = RunGuideDest.Zone; s_zoneIndex = zoneIndex; s_dest = anchor; EnterTravel(); GuideBegun++; Log("존 " + zoneIndex + " 유도 시작(" + reason + ") → " + Fmt(anchor) + (s_anchorFromNode[zoneIndex] ? " [스포너 노드]" : " [SO 폴백]")); } /// 보스 아레나로 유도한다(프로브도 직접 부를 수 있다). public static void BeginBoss(string reason) { var cfg = Cfg; if (cfg == null || !cfg.bossGuideEnabled) return; if (s_destKind == RunGuideDest.Boss && s_state != RunGuideState.Off) return; // 이미 보스 유도 중 s_destKind = RunGuideDest.Boss; s_zoneIndex = -1; s_dest = BossArena.Center; s_bossArrivedOnce = false; // 813w3: 새 보스 유도 = 「가는 중」부터 다시 시작 EnterTravel(); GuideBegun++; Log("보스 아레나 유도 시작(" + reason + ") → " + Fmt(s_dest)); } /// 가이드를 끝낸다(조종·락 해제 포함). public static void StopGuide(string reason) { bool had = s_destKind != RunGuideDest.None; ReleaseSteer(); ReleaseBossLock(); ReleaseEliteLock(); // 813w4 §1-2 s_destKind = RunGuideDest.None; s_state = RunGuideState.Off; s_zoneIndex = -1; s_hitUntil = 0f; s_stillSince = -1f; s_lastPosTime = -1f; s_outsideSince = -1f; s_nextExitDiag = 0f; // 813w3: 이탈 타이머는 목적지와 함께 끝난다 s_bossArrivedOnce = false; EndDodgeStep("가이드 종료"); // 813i2: 스텝이 목적지보다 오래 살지 않게 한다 if (had) Log("가이드 종료 — " + reason); } static void EnterTravel() { s_state = RunGuideState.Travel; s_travelStart = Time.unscaledTime; s_nextRepath = 0f; s_stillSince = -1f; s_lastPosTime = -1f; // 813w2: 정지 판정을 새로 잡는다 } // ───────────────────────────────────────────────────────────────────── // 틱 // ───────────────────────────────────────────────────────────────────── /// 런너가 매 프레임 부른다(에디트 모드에서는 프로브가 ForceTick 으로 부른다). internal static void Tick() { if (s_faulted) return; // 813w7 + 813i2 합류: 가이드가 꺼지면 결과창 정지도 풀고(813w7) 진행 중인 회피 스텝도 끝낸다(813i2). if (!Active) { if (s_runEndHold) EndRunEndHold("가이드 off"); else if (s_steering) ReleaseSteer(); EndDodgeStep("비활성"); return; } var cfg = Cfg; float now = Time.unscaledTime; // 🔴 813i2 §1-2 — 예고 회피 스텝은 **매 프레임** 돈다(폴링 0.1 s 로는 0.3 s 스텝을 못 민다). // 스텝 중에는 폴링을 쉰다 — 폴링이 조종을 놓거나(ReleaseSteer) 목적지를 다시 주면 두 이동이 싸운다. // 🔴 813w7 결과창 정지 중에는 스텝을 돌리지 않는다 — 「결과창 동안 PC 를 세운다」가 우선이다. // (정지가 시작될 때 BeginRunEndHold 가 진행 중이던 스텝을 이미 끝냈으므로 s_dodging 이 남지 않는다.) if (!s_runEndHold && TickDodgeStep(cfg, now, Time.deltaTime)) return; if (now < s_nextPoll) return; s_nextPoll = now + Mathf.Max(0.02f, cfg.pollSeconds); // 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 팝업으로 띄운다(813x §D-1). try { Poll(cfg, now); } catch (System.Exception ex) { s_faulted = true; ReleaseSteer(); Log("중단(예외) — " + ex.Message); } } // 🔴 813w2 §1-1·§1-2 — 이 함수의 **순서**가 D-24 · D-22 의 원인이었다. // 813w 은 피격 중단(`now < s_hitUntil`)에서 통째로 return 해 도착 판정과 이탈 되돌림을 // 건너뛰었다. 이제 ① 목적지·거리 계산 ② 도착 판정 ③ 이탈 되돌림은 **항상** 실행하고, // 피격 중단은 **Hold 에서 조종을 놓는 것**만 뜻한다(`hitSuspendOnlyInHold`). static void Poll(WLRunGuideSettings cfg, float now) { var pc = MyValue.MyPC; if (DSUtil.CheckNull(pc) || pc.IsDead()) { // 🔴 813w8 §1-1(D-46) — 사망하면 정지 무적을 즉시 푼다(부활 우선 · 813i DeathFlow 가 먼저). // 정지 자체는 유지되고, 부활 뒤 첫 정지 틱에서 무적이 다시 걸린다. ClearRunEndInvincible("사망"); ReleaseSteer(); s_lastPosTime = -1f; return; } // 🔴 813w7 §1-2 ①(D-45) — 결과창 동안 PC 정지. 목적지 판정보다 **앞**에 둔다 // (이 구간에는 목적지가 없고, 원본 자동전투가 존 몹을 소진하는 것을 막는 것이 전부다). if (s_runEndHold) { RunEndHoldTick(cfg, pc, now); return; } // 🔴 813w4 §1-1(D-34) 안전망 — 목적지가 없는 동안에도 「런은 도는데 PC 가 멈춰 있는지」를 본다. if (s_destKind == RunGuideDest.None && TrackIdleStall(cfg, pc, pc.Get_position(), now)) return; if (s_destKind == RunGuideDest.None) { ReleaseSteer(); return; } bool isBoss = s_destKind == RunGuideDest.Boss; if (isBoss) s_dest = BossArena.Center; Vector3 pcPos = pc.Get_position(); float d = DistXZ(pcPos, s_dest); float arrive = isBoss ? cfg.bossArriveRadius : cfg.zoneArriveRadius; float hold = isBoss ? cfg.bossHoldRadius : cfg.zoneHoldRadius; if (d > MaxDistFromDest) MaxDistFromDest = d; // ── 체류/이동 초 누적(§1-3 · RunTick 로그가 읽는다) AccumDwell(now, isBoss); // ── 정지 판정용 이동량(조종 중일 때만 센다 — 원본이 싸우는 동안의 정지는 「도착」이 아니다) bool still = UpdateStill(cfg, pcPos, now); // ── 보스 우선 타깃 — 락은 언제나 유지 · 재타깃은 Hold 에서(이동 중 타깃은 우리 목적지를 덮어쓴다) // 존 구간에서는 같은 방식으로 엘리트를 우선 타깃으로 고정한다(813w4 §1-2 · D-20). if (isBoss) { ReleaseEliteLock(); ApplyBossPriority(cfg, pc); } else ApplyElitePriority(cfg, pc); // ── 피격 응전: Hold 에서만 건다(이동·되돌림은 맞아도 멈추지 않는다 = 「이동 중 잡몹 무시」) bool hit = now < s_hitUntil; bool suspend = SuspendDecision(cfg, hit, s_state); // ── 813w3 §1-1·§1-2: 보스 구간 이탈 추적 → 진단 1줄 → (1.5 s 뒤에도 밖이면) 강제 워프. // ① 아레나에 한 번이라도 도착한 뒤부터(= 「가는 중」은 이탈이 아니다) // ② 상태(Travel/Hold)와 무관하게 — 되돌림 중(Travel)에 못 돌아오는 것이 D-22 재발의 실체다. if (isBoss && s_bossArrivedOnce && TrackArenaExit(cfg, pc, pcPos, d, hold, now, hit)) return; // 워프 = 폴링 끝 if (s_state == RunGuideState.Travel) { if (d <= arrive) { Arrived++; EnterHold(isBoss, "도착 — " + d.ToString("F1") + "m · 자동전투 재개"); return; } // 813w2 §1-1: 반경 안으로 못 붙어도 「멈춰 섰으면」 도착으로 확정한다(D-24). if (still && WithinStillRange(cfg, d, hold)) { Arrived++; ArrivedByStill++; EnterHold(isBoss, "정지 도착 — " + d.ToString("F1") + "m · " + cfg.arriveStillSeconds.ToString("F1") + "s 정지 · 자동전투 재개"); return; } if (cfg.maxTravelSeconds > 0f && now - s_travelStart >= cfg.maxTravelSeconds) { GaveUp++; EnterHold(isBoss, "이동 포기 — " + cfg.maxTravelSeconds.ToString("F0") + "s 초과(남은 " + d.ToString("F1") + "m) · 이탈 감시는 유지"); return; } if (suspend) { ReleaseSteer(); return; } // 813w 롤백 경로에서만 여기로 온다 ClearTravelTarget(cfg, pc); Steer(cfg, pc, now); return; } // ── Hold: 조종은 놓고, 먼 타깃만 무시 · 이탈하면 **피격 중이라도** 다시 끌어온다 // 피격 중(suspend)에는 「먼 타깃 무시」를 쉬어 준다 = 나를 때린 적에게 즉시 응전한다. // 그래도 이탈 되돌림은 그대로 도므로 존/아레나 반경 밖으로는 못 나간다(D-19 · D-22). if (!suspend) IgnoreFarTarget(cfg, pc); if (hold > 0f && d > hold) { EnterTravel(); HoldPullbacks++; if (hit) PullbacksWhileHit++; Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " 이탈 " + d.ToString("F1") + "m — 다시 유도" + (hit ? "(피격 중)" : "")); ClearTravelTarget(cfg, pc); Steer(cfg, pc, now); return; } ReleaseSteer(); } /// Travel → Hold 로 확정한다(도착 · 정지 도착 · 이동 포기 공통). static void EnterHold(bool isBoss, string why) { s_state = RunGuideState.Hold; ReleaseSteer(); s_stillSince = -1f; s_lastPosTime = -1f; if (isBoss) s_bossArrivedOnce = true; // 813w3: 여기서부터 「이탈」을 센다 Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " " + why); } // ───────────────────────────────────────────────────────────────────── // 813w7 §1-2 ① — 결과창 중 PC 정지 (D-45) // ───────────────────────────────────────────────────────────────────── /// /// 런 종료(결과창) 정지를 건다. 다음 `RunStarted`·상한 만료·가이드 off 에서 풀린다. /// `holdPcOnRunEnd` 가 꺼져 있으면 아무 일도 하지 않는다(C8 = 813w6 그대로). /// 프로브가 직접 부를 수 있다. /// public static void BeginRunEndHold(string reason) { var cfg = Cfg; if (cfg == null || !cfg.holdPcOnRunEnd) return; if (s_runEndHold) return; // 중복 안전(RunEnded 는 런당 1회지만 Abandon 경로가 겹칠 수 있다) s_runEndHold = true; // 🔴 rebase 합류(813i2): 정지가 걸리면 진행 중이던 예고 회피 스텝을 끝낸다 — // 스텝이 살아 있으면 다음 프레임에 PC 를 밀어 「결과창 동안 정지」가 깨진다. EndDodgeStep("결과창 정지"); s_runEndHoldUntil = cfg.runEndHoldMaxSeconds > 0f ? Time.unscaledTime + cfg.runEndHoldMaxSeconds : -1f; RunEndHolds++; // 🔴 813w8 §1-1(D-46) — 정지와 **같은 순간**에 무적 래치를 건다(사망 중이면 부활 우선 · 틱이 다시 건다). ApplyRunEndInvincible(cfg, MyValue.MyPC, "결과창 정지"); Log("결과창 중 PC 정지 시작(" + reason + ") — 상한 " + (s_runEndHoldUntil > 0f ? cfg.runEndHoldMaxSeconds.ToString("F0") + "s" : "무제한") + " 무적 " + (cfg.invincibleWhileRunEndHold ? (RunEndInvincible ? "on" : "대기(사망 중)") : "off")); } /// 런 종료 정지를 푼다(조종 원복 포함). 프로브가 직접 부를 수 있다. public static void EndRunEndHold(string reason) { if (!s_runEndHold) { s_runEndHoldUntil = -1f; return; } s_runEndHold = false; s_runEndHoldUntil = -1f; LastRunEndHoldReason = reason; RunEndHoldReleases++; // 🔴 813w8 §1-1(D-46) — 정지가 풀리면 무적도 **반드시** 같이 풀린다(런 안에서 무적이 남으면 안 된다). ClearRunEndInvincible(reason); // 🔴 정지 중 세워 둔 에이전트를 반드시 다시 풀어 준다 — 안 그러면 다음 런에서 PC 가 못 걷는다. var pc0 = MyValue.MyPC; if (!DSUtil.CheckNull(pc0)) { var ag = pc0.Get_Agent(); if (ag != null && ag.enabled && ag.isOnNavMesh) ag.isStopped = false; } ReleaseSteer(); // JoystickStatus 를 우리가 1 로 만든 경우에만 3(=0)으로 되돌린다 Log("결과창 중 PC 정지 해제 — " + reason); } /// /// 정지 1틱 — 조종을 잡고(자동 교전 접근 루프 차단) 타깃을 떼고(자동 스킬 차단) 잔여 경로를 지운다. /// 🔴 GC 0 — 문자열·배열 할당 없음(로그는 시작·해제에서만). /// 프로브가 가짜 시각으로 그대로 부를 수 있다(pc 는 살아 있는 참조여야 한다). /// public static void RunEndHoldTick(WLRunGuideSettings cfg, PCActor pc, float now) { if (cfg == null || !cfg.holdPcOnRunEnd) { EndRunEndHold("스위치 off"); return; } if (s_runEndHoldUntil > 0f && now >= s_runEndHoldUntil) { EndRunEndHold("상한 만료"); return; } if (DSUtil.CheckNull(pc)) return; RunEndHoldTicks++; // 🔴 813w8 §1-1(D-46) — 무적 래치 재확인. 이미 걸려 있으면 무동작이고, 사망 중 진입·PC 교체·부활 뒤에는 // 여기서 (다시) 걸린다. 스위치가 꺼져 있으면 아무 것도 하지 않는다(C8 = 813w7 그대로). if (!cfg.invincibleWhileRunEndHold) ClearRunEndInvincible("스위치 off"); else ApplyRunEndInvincible(cfg, pc, "정지 틱"); // ⓐ 자동 교전 접근 루프 차단(PCActor.cs:139) — 우리가 0 → 1 로 만든 경우에만. AcquireSteer(pc); // ⓑ 자동 스킬 루프 차단(PCActor.cs:93 `isTarget` 조건) — 매 폴링 뗀다. if (cfg.runEndHoldClearsTarget && pc.isTarget) { var t = pc.Get_Target(); if (!DSUtil.CheckNull(t)) { LastIgnoredTarget = t.name; pc.Del_Target(true); } else pc.isTarget = false; // 참조 없이 플래그만 남은 상태 — Del_Target 은 여기서 m_Target 을 NRE 로 만진다 RunEndTargetClears++; } // ⓒ 런 종료 순간의 잔여 경로로 계속 걸어가지 않게 한다. if (!cfg.runEndHoldStopsAgent) return; var agent = pc.Get_Agent(); if (agent == null || !agent.enabled || !agent.isOnNavMesh) return; if (agent.hasPath) agent.ResetPath(); agent.isStopped = true; } // ───────────────────────────────────────────────────────────────────── // 813w8 §1-1 — 결과창 정지 중 PC 무적 (D-46) // ───────────────────────────────────────────────────────────────────── /// /// 정지 무적 래치를 건다(813i 무적 API · `Survival.SetHoldInvincible`). /// 🔴 사망 중에는 걸지 않는다 — 부활 흐름(813i `DeathFlow`)이 먼저다(발주서 §1-1 「부활 우선」). /// 이미 같은 PC 에 걸려 있으면 무동작 = 매 틱 불러도 카운터는 한 번만 오른다(GC 0). /// 프로브가 직접 부를 수 있다. /// public static void ApplyRunEndInvincible(WLRunGuideSettings cfg, PCActor pc, string reason) { if (cfg == null || !cfg.invincibleWhileRunEndHold) return; if (DSUtil.CheckNull(pc) || pc.IsDead()) return; // 부활 우선 if (WL.Combat.Survival.Survival.IsHoldInvincible(pc)) return; // 이미 걸려 있다 WL.Combat.Survival.Survival.SetHoldInvincible(pc, true, reason); RunEndInvincibleOn++; } /// 정지 무적 래치를 푼다(정지 해제 · 사망 진입 · 스위치 off). 안 걸려 있으면 무동작. public static void ClearRunEndInvincible(string reason) { if (!WL.Combat.Survival.Survival.HoldInvincibleActive) return; WL.Combat.Survival.Survival.SetHoldInvincible(null, false, reason); RunEndInvincibleOff++; } // ───────────────────────────────────────────────────────────────────── // 813w4 §1-1 — 정지 감시(D-34 안전망) // ───────────────────────────────────────────────────────────────────── /// /// 런은 도는데 ① 가이드 목적지가 없고 ② PC 에게 타깃이 없고 ③ PC 가 `stallGuideSeconds` 동안 제자리면 /// 존 0 유도를 건다. D-34 의 「시작 좌표에서 166 s 정지」를 원인과 무관하게 되돌리는 안전망이다. /// 🔴 교전 중(타깃 보유)에는 절대 끼어들지 않는다 — 제자리에서 때리는 것을 「정지」로 세면 전투를 끊는다. /// 반환 true = 이번 폴링에서 유도를 걸었다(호출측은 즉시 return). /// 프로브가 가짜 좌표·가짜 시각으로 그대로 부를 수 있다. /// public static bool TrackIdleStall(WLRunGuideSettings cfg, PCActor pc, Vector3 pcPos, float now) { if (cfg == null || cfg.stallGuideSeconds <= 0f || !cfg.zoneGuideEnabled) { s_idleSince = -1f; return false; } if (s_destKind != RunGuideDest.None) { s_idleSince = -1f; return false; } // 이미 유도 중 if (RunDirector.Phase != RunPhase.Zones) { s_idleSince = -1f; return false; } // 존 단계에서만 if (!DSUtil.CheckNull(pc) && pc.isTarget) { s_idleSince = -1f; return false; } // 교전 중 — 끼어들지 않는다 if (s_idleSince < 0f || DistXZ(pcPos, s_idlePos) > Mathf.Max(0f, cfg.stallMoveEpsilon)) { s_idlePos = pcPos; s_idleSince = now; return false; } // 움직였다 — 다시 잰다 if (now - s_idleSince < cfg.stallGuideSeconds) return false; if (now - s_lastStallGuide < cfg.stallGuideSeconds) return false; // 연타 방지 s_idleSince = -1f; s_lastStallGuide = now; StallGuides++; Log("정지 " + cfg.stallGuideSeconds.ToString("F1") + "s 감지(타깃 없음 · " + Fmt(pcPos) + ") — 존 0 유도"); BeginZone(0, "정지 감지"); return s_destKind != RunGuideDest.None; } // ───────────────────────────────────────────────────────────────────── // 813w3 §1-1 · §1-2 — 아레나 이탈 진단 · 강제 복귀(워프) // ───────────────────────────────────────────────────────────────────── /// /// 보스 구간 이탈 추적. 이탈 시작에 진단 1줄을 남기고, `warpBackAfterSeconds` 동안 반경 밖이면 워프한다. /// 반환 true = 이번 폴링에서 워프했다(호출측은 즉시 return 한다). /// 프로브가 임시 SO·가짜 좌표로 그대로 부를 수 있다(pc 는 null 허용 — 그러면 워프만 건너뛴다). /// public static bool TrackArenaExit(WLRunGuideSettings cfg, PCActor pc, Vector3 pcPos, float d, float hold, float now, bool hit) { if (cfg == null || hold <= 0f || d <= hold) { s_outsideSince = -1f; s_nextExitDiag = 0f; return false; } if (s_outsideSince < 0f) { s_outsideSince = now; ArenaExits++; s_nextExitDiag = cfg.exitDiagRepeatSeconds > 0f ? now + cfg.exitDiagRepeatSeconds : 0f; RecordExit(cfg, pc, pcPos, d, now, hit, false); } else if (s_nextExitDiag > 0f && now >= s_nextExitDiag) { s_nextExitDiag = now + cfg.exitDiagRepeatSeconds; RecordExit(cfg, pc, pcPos, d, now, hit, false); } switch (WarpDecision(cfg, d, hold, s_outsideSince, s_lastWarp, now, IsFollowingAwayBoss(cfg, pcPos))) { // 🔴 보스가 스킬로 멀어졌고 PC 가 그 보스 곁이면 이탈이 아니다(§1-2) — 타이머만 다시 잡는다. case kWarpNearBoss: WarpsSkippedNearBoss++; s_outsideSince = now; return false; case kWarpCooldown: WarpsSkippedCooldown++; return false; case kWarpGo: return !DSUtil.CheckNull(pc) && WarpToArena(cfg, pc, d, now, hit); default: return false; } } /// 워프 판정 결과 — 0 반경 안 · 1 아직 기다린다(되돌림만) · 2 워프 · 3 쿨다운 · 4 보스 곁(정상 추적). public const int kWarpNone = 0, kWarpWait = 1, kWarpGo = 2, kWarpCooldown = 3, kWarpNearBoss = 4; /// /// 강제 복귀 판정(순수 함수 · Actor 접근 0 · 프로브가 가짜 거리·시각으로 그대로 부른다 · C8 증거). /// `outsideSince` = 반경 밖이 된 시각(안이면 −1) · `lastWarp` = 마지막 워프 시각. /// public static int WarpDecision(WLRunGuideSettings cfg, float d, float hold, float outsideSince, float lastWarp, float now, bool followingAwayBoss) { if (cfg == null || hold <= 0f || d <= hold) return kWarpNone; if (!cfg.warpBackEnabled || cfg.warpBackAfterSeconds <= 0f) return kWarpWait; // C8 = 813w2 되돌림만 if (outsideSince < 0f || now - outsideSince < cfg.warpBackAfterSeconds) return kWarpWait; if (followingAwayBoss) return kWarpNearBoss; if (now - lastWarp < cfg.warpCooldownSeconds) return kWarpCooldown; return kWarpGo; } /// /// 보스가 아레나 중심에서 `bossAwayFromCenterRadius` 밖이고 PC 가 그 보스에게서 `followBossRadius` 안인가. /// = Anubis 스킬2(`Co_JumpToPosition`)로 보스가 튄 뒤 PC 가 정상적으로 따라간 상태(워프하면 안 된다). /// public static bool IsFollowingAwayBoss(WLRunGuideSettings cfg, Vector3 pcPos) { if (cfg == null || cfg.followBossRadius <= 0f) return false; var boss = s_boss; if (!IsAliveBoss(boss)) return false; Vector3 bp = boss.Get_position(); if (DistXZ(bp, BossArena.Center) <= cfg.bossAwayFromCenterRadius) return false; return DistXZ(pcPos, bp) <= cfg.followBossRadius; } /// 원본 `Actor.Set_Warp`(Actor.cs:2111)으로 아레나 중심 ±jitter 지점에 내려놓는다. NavMesh 밖이면 하지 않는다. static bool WarpToArena(WLRunGuideSettings cfg, PCActor pc, float d, float now, bool hit) { Vector3 center = BossArena.Center; Vector3 target = center; if (cfg.warpJitterRadius > 0f) { Vector2 o = UnityEngine.Random.insideUnitCircle * cfg.warpJitterRadius; // struct 반환 · GC 0 target.x += o.x; target.z += o.y; } float r = cfg.warpNavSampleRadius > 0f ? cfg.warpNavSampleRadius : 1f; NavMeshHit nh; if (NavMesh.SamplePosition(target, out nh, r, NavMesh.AllAreas)) target = nh.position; else if (NavMesh.SamplePosition(center, out nh, r, NavMesh.AllAreas)) target = nh.position; else { WarpsFailedNavMesh++; return false; } // 지형 뚫기 방지 — 워프 포기 ReleaseSteer(); // 조종을 먼저 놓는다(JoystickStatus 원상복귀) pc.Del_Target(true); // 끌고 나간 타깃을 끊는다 — 도착 뒤 ApplyBossPriority 가 보스를 다시 건다 var agent = pc.Get_Agent(); if (agent != null && agent.enabled && agent.isOnNavMesh) agent.ResetPath(); // 남은 경로 제거 pc.Set_Warp(target); // 813i DeathFlow 의 On_Regen → Set_Warp 와 같은 원본 API · 이펙트 0 if (cfg.warpSnapsCameraLead) SnapCameraLead(); s_lastWarp = now; s_outsideSince = -1f; s_nextExitDiag = 0f; ArenaWarps++; RecordExit(cfg, pc, target, d, now, hit, true); EnterHold(true, "강제 복귀(워프) #" + ArenaWarps + " — 이탈 " + d.ToString("F1") + "m → " + Fmt(target)); return true; } /// 워프 직후 카메라 이동 리드를 0 으로 스냅한다(811 `CameraMoveLead.Snap()` · RealCamera 무수정). static void SnapCameraLead() { var cam = MyValue.m_RealCamera; if (DSUtil.CheckNull(cam)) return; var lead = cam.Get_MoveLead(); if (lead != null) lead.Snap(); } /// /// 이탈 1건을 링버퍼(사전 할당 · GC 0)에 넣고 콘솔에 1줄 남긴다. /// 🔴 `verboseLog` 와 무관하게 남긴다 — Q7 이 이 줄만 보고 「누가 PC 를 끌고 나갔는지」를 판정한다. /// static void RecordExit(WLRunGuideSettings cfg, PCActor pc, Vector3 pcPos, float d, float now, bool hit, bool warped) { if (cfg == null || !cfg.exitDiagEnabled) return; var rec = default(ArenaExitRecord); rec.time = now; rec.frame = Time.frameCount; rec.index = ArenaExits; rec.distFromCenter = d; rec.guideState = (int)s_state; rec.steering = s_steering; rec.recentHit = hit; rec.sinceHit = hit ? Mathf.Max(0f, now - (s_hitUntil - HitHold(cfg))) : -1f; rec.warped = warped; rec.distFromBoss = rec.bossFromCenter = rec.agentDestFromCenter = -1f; Vector3 center = BossArena.Center; var boss = s_boss; if (IsAliveBoss(boss)) { Vector3 bp = boss.Get_position(); rec.distFromBoss = DistXZ(pcPos, bp); rec.bossFromCenter = DistXZ(bp, center); rec.bossLocked = boss.m_IsLockTarget; var ba = boss.Get_Agent(); rec.bossJumping = ba != null && !ba.enabled; // Co_JumpToPosition 이 에이전트를 끈다(Actor.cs:1868) } if (!DSUtil.CheckNull(pc)) { rec.joystickStatus = pc.JoystickStatus; rec.anim = (int)pc.Get_CurAnim(); var t = pc.isTarget ? pc.Get_Target() : null; if (!DSUtil.CheckNull(t)) rec.targetKind = (!DSUtil.CheckNull(boss) && ReferenceEquals(t, boss)) ? 1 : (t.IsRole(eRole.Mob) ? 2 : 3); // 원본 역할(Actor.cs:978)로 분류한다 var agent = pc.Get_Agent(); if (agent != null && agent.enabled) { rec.agentOnNavMesh = agent.isOnNavMesh; if (agent.isOnNavMesh) { rec.agentStopped = agent.isStopped; Vector3 v = agent.velocity; v.y = 0f; rec.agentSpeed = v.magnitude; if (agent.hasPath) rec.agentDestFromCenter = DistXZ(agent.destination, center); } } try { rec.pcCC = pc.IsCC(); } catch (System.Exception) { rec.pcCC = false; } // 에디트 모드엔 dic_ccData 가 없다 } s_exitLog[s_exitLogNext] = rec; s_exitLogNext = (s_exitLogNext + 1) % kExitLogCapacity; if (s_exitLogCount < kExitLogCapacity) s_exitLogCount++; LastExitLog = FormatExit(in rec); Debug.Log("[RunGuide][이탈] " + LastExitLog); } /// 피격 응전 구간 길이(초) — sinceHit 역산용. static float HitHold(WLRunGuideSettings cfg) { return s_destKind == RunGuideDest.Boss ? cfg.bossHitSuspendSeconds : cfg.resumeAfterHitSeconds; } /// 진단 1줄 포맷(프로브·Q7 공용 · 순수 함수). public static string FormatExit(in ArenaExitRecord r) { return "#" + r.index + (r.warped ? " 워프" : "") + " t=" + r.time.ToString("F1") + "s f=" + r.frame + " · 중심 " + r.distFromCenter.ToString("F1") + "m" + " · 보스 " + r.distFromBoss.ToString("F1") + "m(보스-중심 " + r.bossFromCenter.ToString("F1") + "m 점프=" + (r.bossJumping ? "Y" : "N") + " 락=" + (r.bossLocked ? "Y" : "N") + ")" + " · 타깃=" + TargetName(r.targetKind) + " · 조이스틱=" + r.joystickStatus + "(조종 " + (r.steering ? "Y" : "N") + ")" + " · 애님=" + (eAnim)r.anim + " CC=" + (r.pcCC ? "Y" : "N") + " · 에이전트 v=" + r.agentSpeed.ToString("F1") + " 정지=" + (r.agentStopped ? "Y" : "N") + " NavMesh=" + (r.agentOnNavMesh ? "Y" : "N") + " 목적지-중심=" + r.agentDestFromCenter.ToString("F1") + "m" + " · 가이드=" + (RunGuideState)r.guideState + " · 피격=" + (r.recentHit ? r.sinceHit.ToString("F1") + "s전" : "없음"); } static string TargetName(int kind) { switch (kind) { case 1: return "보스"; case 2: return "잡몹"; case 3: return "기타"; default: return "없음"; } } /// /// 피격 응전 판정(순수 · 프로브가 임시 SO 로 그대로 부른다 · C8 증거). /// 🔴 813w2 의 핵심 — `hitSuspendOnlyInHold` 가 켜져 있으면 **Travel(= 이동·되돌림)에서는 /// 피격이 가이드를 멈추지 못한다.** 813w 은 상태와 무관하게 멈췄고, 그 때문에 도착 판정(D-24)과 /// 이탈 되돌림(D-22)이 폴링에서 통째로 빠졌다. /// public static bool SuspendDecision(WLRunGuideSettings cfg, bool hit, RunGuideState state) { if (!hit || cfg == null) return false; return !cfg.hitSuspendOnlyInHold || state == RunGuideState.Hold; } /// 정지 판정으로 도착을 인정할 거리인가 — 기본은 목적지 반경(holdRadius) 안(프로브가 그대로 부른다). public static bool WithinStillRange(WLRunGuideSettings cfg, float d, float hold) { float lim = cfg.arriveStillMaxDistance > 0f ? cfg.arriveStillMaxDistance : hold; return lim <= 0f || d <= lim; } /// 조종 중 XZ 속도가 arriveStillSpeed 미만인 상태가 arriveStillSeconds 이상 이어졌는가. static bool UpdateStill(WLRunGuideSettings cfg, Vector3 pcPos, float now) { if (s_state != RunGuideState.Travel || !s_steering) { s_stillSince = -1f; s_lastPosTime = -1f; s_lastPos = pcPos; return false; } if (s_lastPosTime < 0f) { s_lastPos = pcPos; s_lastPosTime = now; s_stillSince = -1f; return false; } float dt = now - s_lastPosTime; float moved = DistXZ(pcPos, s_lastPos); if (dt > 0f) { s_lastPos = pcPos; s_lastPosTime = now; } return StepStillDetect(cfg, moved, dt, now, ref s_stillSince); } /// /// 정지 판정 1스텝(순수 · Actor 접근 0). 프로브가 가짜 이동량·시각으로 그대로 부를 수 있다. /// moved = 지난 스텝 동안의 XZ 이동량(m) · dt = 그 간격(초) · stillSince = 정지 시작 시각(없으면 −1). /// public static bool StepStillDetect(WLRunGuideSettings cfg, float moved, float dt, float now, ref float stillSince) { if (cfg == null || !cfg.arriveStillEnabled || cfg.arriveStillSeconds <= 0f) { stillSince = -1f; return false; } if (dt > 0f) { if (moved / dt < cfg.arriveStillSpeed) { if (stillSince < 0f) stillSince = now; } else stillSince = -1f; } return stillSince >= 0f && now - stillSince >= cfg.arriveStillSeconds; } /// 존 체류·아레나 체류·이동 초를 누적한다(§1-3 QA 시각표). static void AccumDwell(float now, bool isBoss) { if (s_lastAccumTime >= 0f) { float dt = now - s_lastAccumTime; if (dt > 0f && dt < 1f) // 프레임 튐·일시정지 구간은 버린다 { if (s_state == RunGuideState.Travel) TravelSeconds += dt; else if (s_state == RunGuideState.Hold) { if (isBoss) BossHoldSeconds += dt; else ZoneHoldSeconds += dt; } } } s_lastAccumTime = now; } // ───────────────────────────────────────────────────────────────────── // 조종 · 타깃 // ───────────────────────────────────────────────────────────────────── /// /// 이동·되돌림 중에는 타깃을 놓는다. /// 🔴 813w2 §1-2: 보스 타깃도 놓는다(`pullbackReleasesTarget`). 타깃이 남아 있으면 원본 /// `Play_Run() → Set_Path(타깃)`(MyActor.cs:335)이 매 폴링 우리 목적지를 덮어써 두 이동이 싸운다. /// 보스 락(`m_IsLockTarget`)은 유지되므로 잡몹이 타깃을 가져가지 못한다 — 도착(Hold)하면 /// `ApplyBossPriority` 가 즉시 보스를 다시 건다. /// static void ClearTravelTarget(WLRunGuideSettings cfg, PCActor pc) { if (!pc.isTarget) return; var t = pc.Get_Target(); if (DSUtil.CheckNull(t)) return; if (!cfg.pullbackReleasesTarget && s_destKind == RunGuideDest.Boss && !DSUtil.CheckNull(s_boss) && ReferenceEquals(t, s_boss)) return; LastIgnoredTarget = t.name; pc.Del_Target(true); FarTargetIgnored++; } /// 도착 뒤: 목적지에서 guideLeashRadius 밖의 타깃만 무시한다(가까운 적은 원본이 잡는다). static void IgnoreFarTarget(WLRunGuideSettings cfg, PCActor pc) { if (cfg.guideLeashRadius <= 0f || !pc.isTarget) return; var t = pc.Get_Target(); if (DSUtil.CheckNull(t)) return; if (s_destKind == RunGuideDest.Boss && !DSUtil.CheckNull(s_boss) && ReferenceEquals(t, s_boss)) return; // 813w4 §1-2: 고정한 엘리트는 앵커에서 멀어도 놓지 않는다(놓으면 다음 폴링에 다시 걸어 타깃이 떨린다). // PC 가 존 반경 밖으로 끌려가는 것은 아래 Hold 되돌림(zoneHoldRadius)이 그대로 막는다. if (!DSUtil.CheckNull(s_lockedElite) && ReferenceEquals(t, s_lockedElite)) return; if (DistXZ(t.Get_position(), s_dest) <= cfg.guideLeashRadius) return; LastIgnoredTarget = t.name; pc.Del_Target(true); FarTargetIgnored++; } /// PC 를 목적지로 민다. 원본 이동 API(NavMeshAgent)만 쓴다. static void Steer(WLRunGuideSettings cfg, PCActor pc, float now) { // 🔴 에이전트 검사를 맨 앞에 둔다 — 에디트 모드(프로브)에는 NavMeshAgent 도 dic_ccData 초기화도 없어 // IsCC() 가 KeyNotFoundException 을 던진다. 런타임 판정 순서는 어차피 결과가 같다. var agent = pc.Get_Agent(); if (agent == null || !agent.enabled || !agent.isOnNavMesh) return; // NavMesh 밖 — 조종 포기 var anim = pc.Get_CurAnim(); if (anim == eAnim.Dodge || anim >= eAnim.Attack || pc.IsCC()) return; // 공격·스킬·회피·CC 는 원본 우선 if (pc.JoystickStatus == 2) return; // 스킬 이동 잠금(Whirlwind 등) AcquireSteer(pc); // 🔴 813w2: 타깃이 남아 있으면 Play_Run() 이 `Set_Path(타깃)`(MyActor.cs:335)으로 경로를 덮어쓴다. // 보통은 바로 위 ClearTravelTarget 이 타깃을 비웠으므로 그런 일이 없지만, 원본이 같은 프레임에 // 다시 잡는 경우가 있어 **타깃이 있으면 이 폴링에서 목적지를 다시 지정**해 우리 쪽이 이기게 한다. bool targeted = pc.isTarget; pc.WL_PlayRunNow(); agent.isStopped = false; agent.stoppingDistance = cfg.guideStopDistance; if (targeted || now >= s_nextRepath) { s_nextRepath = now + Mathf.Max(0f, cfg.repathIntervalSeconds); agent.speed = pc.Get_MoveSpeed(); agent.SetDestination(s_dest); Repaths++; } } /// /// 조종을 잡는다 — 원본 자동 교전 접근 루프를 비켜 세운다(PCActor.cs:139). /// 🔴 우리가 **0 → 1** 로 만든 경우에만 잡는다. 2(Whirlwind 등 스킬 이동 잠금)는 건드리지 않는다. /// 813w7 §1-2 ①: 이동(Steer)과 결과창 정지(RunEndHoldTick)가 같은 진입점을 쓴다. /// static void AcquireSteer(PCActor pc) { if (s_steering || DSUtil.CheckNull(pc)) return; if (pc.JoystickStatus != 0) return; pc.Set_JoystickStatus(1); s_steering = true; } /// 조종을 놓는다(우리가 1 로 만든 경우에만 0 으로 되돌린다). static void ReleaseSteer() { if (!s_steering) return; s_steering = false; var pc = MyValue.MyPC; if (DSUtil.CheckNull(pc)) return; if (pc.JoystickStatus == 1) pc.Set_JoystickStatus(3); // 3 = 해제(PCActor.cs:461) } // ───────────────────────────────────────────────────────────────────── // 보스 우선 타깃 // ───────────────────────────────────────────────────────────────────── static void ApplyBossPriority(WLRunGuideSettings cfg, PCActor pc) { if (!cfg.bossPriorityEnabled) { ReleaseBossLock(); return; } var boss = s_boss; if (!IsAliveBoss(boss)) { boss = BossArena.Boss; s_boss = IsAliveBoss(boss) ? boss : null; } if (!IsAliveBoss(s_boss)) { ReleaseBossLock(); return; } boss = s_boss; if (cfg.bossLockTarget && !boss.m_IsLockTarget) { boss.m_IsLockTarget = true; // 원본 락 타깃(MyActor.cs:106·121 이 스스로 교체를 막는다) s_lockedBoss = boss; BossLocks++; } // 813w2 §1-2: 재타깃은 Hold(아레나 안)에서만. 이동·되돌림 중에 보스를 타깃으로 걸면 // 원본 Play_Run() 이 Set_Path(보스)로 우리 목적지를 덮어써 되돌림이 무력해진다. // 락은 위에서 계속 유지되므로 「보스 생존 중 잡몹 타깃」은 이동 중에도 생기지 않는다. if (cfg.bossRetargetOnlyInHold && s_state != RunGuideState.Hold) return; if (!ReferenceEquals(pc.Get_Target(), boss)) { pc.Change_Target(true, boss); // 아웃라인까지 원본 경로 그대로(Actor.cs:988) BossRetargets++; } } static void ReleaseBossLock() { if (DSUtil.CheckNull(s_lockedBoss)) { s_lockedBoss = null; return; } s_lockedBoss.m_IsLockTarget = false; s_lockedBoss = null; } static bool IsAliveBoss(Actor a) { return !DSUtil.CheckNull(a) && !a.IsDead() && a.gameObject.activeInHierarchy; } /// 살아 있고 월드에 붙어 있는 타깃인가(보스·엘리트 공통 판정). static bool IsAliveTarget(Actor a) { return !DSUtil.CheckNull(a) && !a.IsDead() && a.gameObject.activeInHierarchy; } // ───────────────────────────────────────────────────────────────────── // 813w4 §1-2 — 엘리트 우선 타깃 (D-20 · 「PC 가 엘리트를 안 때린다」) // ───────────────────────────────────────────────────────────────────── /// /// PC 에서 `eliteTargetRadius` 안의 가장 가까운 살아 있는 엘리트. 없으면 null. /// 등록부(사전 할당 16칸)만 훑는다 — FindObjectsByType 0 · 할당 0. /// public static Actor NearestElite(WLRunGuideSettings cfg, Vector3 pcPos, float radius) { if (cfg == null || radius <= 0f) return null; Actor best = null; float bestD = radius; for (int i = 0; i < kMaxElites; i++) { var a = s_elites[i]; if (!IsAliveTarget(a)) { s_elites[i] = null; continue; } float d = DistXZ(a.Get_position(), pcPos); if (d > bestD) continue; bestD = d; best = a; } return best; } /// /// 존 구간에서 엘리트를 우선 타깃으로 고정한다(813w 보스 락타깃과 같은 방식). /// 킬캠(CombatCameraDirector)·BossKill 티어(ImpactTier)는 `Killed` 이벤트만 보므로 여기서 건드리지 않는다. /// static void ApplyElitePriority(WLRunGuideSettings cfg, PCActor pc) { if (!cfg.elitePriorityEnabled) { ReleaseEliteLock(); return; } Vector3 pcPos = pc.Get_position(); // ① 고정 중인 엘리트가 죽었거나 너무 멀어졌으면 푼다(이탈 해제). if (!DSUtil.CheckNull(s_lockedElite)) { float rel = cfg.eliteReleaseRadius > 0f ? cfg.eliteReleaseRadius : cfg.eliteTargetRadius; if (!IsAliveTarget(s_lockedElite) || DistXZ(s_lockedElite.Get_position(), pcPos) > rel) ReleaseEliteLock(); } // ② 고정 대상이 없으면 반경 안에서 가장 가까운 엘리트를 새로 고정한다. var elite = s_lockedElite; if (DSUtil.CheckNull(elite)) { elite = NearestElite(cfg, pcPos, cfg.eliteTargetRadius); if (DSUtil.CheckNull(elite)) return; if (cfg.eliteLockTarget) elite.m_IsLockTarget = true; // 원본 락(MyActor.cs:106·121 이 교체를 막는다) s_lockedElite = elite; LastEliteName = elite.name; EliteLocks++; Log("엘리트 우선 타깃 고정 — " + elite.name + " " + DistXZ(elite.Get_position(), pcPos).ToString("F1") + "m"); } // ③ 재타깃은 Hold 에서만(이동 중에 걸면 원본 Set_Path 가 우리 목적지를 덮어쓴다 = 813w2 D-22 와 같은 축). if (cfg.eliteRetargetOnlyInHold && s_state != RunGuideState.Hold) return; if (ReferenceEquals(pc.Get_Target(), elite)) return; pc.Change_Target(true, elite); // 아웃라인까지 원본 경로 그대로(Actor.cs:988) EliteRetargets++; } static void ReleaseEliteLock() { if (DSUtil.CheckNull(s_lockedElite)) { s_lockedElite = null; return; } s_lockedElite.m_IsLockTarget = false; s_lockedElite = null; EliteReleases++; } // ───────────────────────────────────────────────────────────────────── // 존 앵커 (스포너 노드 실좌표 · 없으면 SO 폴백) // ───────────────────────────────────────────────────────────────────── static int ZoneCount(WLRunGuideSettings cfg) { var rs = WLRunSettings.Instance; if (rs == null || rs.zoneSpawnerIds == null) return 0; int n = rs.zoneSpawnerIds.Length; if (rs.zoneClearKills != null && rs.zoneClearKills.Length < n) n = rs.zoneClearKills.Length; return n > kMaxZones ? kMaxZones : n; } /// 존 i 의 앵커. 스포너 노드를 못 찾았으면 1회 스캔하고, 그래도 없으면 SO 폴백. public static bool TryGetAnchor(WLRunGuideSettings cfg, int i, out Vector3 anchor) { anchor = Vector3.zero; int n = ZoneCount(cfg); if (i < 0 || i >= n) return false; if (s_anchorCount != n || !s_anchorFromNode[i]) ScanAnchors(cfg, n); if (s_anchorFromNode[i]) { anchor = s_anchors[i]; return true; } var fb = cfg.zoneAnchorFallbacks; if (fb != null && i < fb.Length) { anchor = fb[i]; s_anchors[i] = anchor; return true; } return false; } /// 씬의 MobControlMgr 를 1회 훑어 존 앵커를 채운다(런당 몇 회 · 매 틱 아님). static void ScanAnchors(WLRunGuideSettings cfg, int n) { var rs = WLRunSettings.Instance; if (rs == null || rs.zoneSpawnerIds == null) return; s_anchorCount = n; AnchorScans++; var spawners = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); for (int i = 0; i < n; i++) { s_anchorFromNode[i] = false; int id = rs.zoneSpawnerIds[i]; for (int j = 0; spawners != null && j < spawners.Length; j++) { if (DSUtil.CheckNull(spawners[j]) || spawners[j].SpawnerID != id) continue; s_anchors[i] = spawners[j].transform.position; s_anchorFromNode[i] = true; break; } if (!s_anchorFromNode[i]) { var fb = cfg.zoneAnchorFallbacks; if (fb != null && i < fb.Length) s_anchors[i] = fb[i]; } } } /// 앵커가 스포너 노드에서 온 것인가(진단). public static bool AnchorFromNode(int i) { return i >= 0 && i < kMaxZones && s_anchorFromNode[i]; } /// 캐시된 앵커(진단). public static Vector3 AnchorOf(int i) { return i >= 0 && i < kMaxZones ? s_anchors[i] : Vector3.zero; } // ───────────────────────────────────────────────────────────────────── // 813i2 §1-2 — 보스 예고 중 자동 회피 스텝 (Q10 D-21 · 813r 회피의 축소판) // // 🔴 예고를 읽는 유일한 공개 경로 (원본 수정 0 · 리플렉션 0 · 실측) // 보스 차징 코루틴(Boss_Anubis.cs:64 `AnimationPlay("boss_skill1charging")` · // :117 `"boss_skill2charging"`)은 `Actor.AnimationPlay`(Actor.cs:2029)를 부르는데 // 그 함수는 `m_animation.Play(...)` 만 하고 **`CurAnim` 을 바꾸지 않는다** → // `Get_CurAnim()` 으로는 예고를 못 읽는다. `MobActor.MobStatus`(:17)·`UseSkillIndex`· // `dic_indicator`(BossMobActor:9)는 전부 **protected** 라 밖에서 못 읽는다. // 남는 공개 경로 = `Actor.m_animation`(public Animator · Actor.cs:30) 하나뿐이라 // `GetCurrentAnimatorStateInfo(0).IsName(<상태 이름>)` 로 판정한다(상태 이름은 SO 값 · C45). // // ■ 스텝은 「예고 → 발동 사이 1회」다 — 쿨다운(`dodgeStepCooldownSeconds` 1.5 s)이 // 실측 차징 1.2 s 보다 길어 한 예고에 한 번만 나간다. 실패해도 813i2 무적 창이 받쳐 준다. // ■ 이동은 DashDriver 와 같은 근거로 `NavMeshAgent.Move`(NavMesh 표면 투영 · 내부 상태 갱신)만 쓴다. // `transform.position` 대입·`Warp` 는 지형을 통과한다(PD 지시 4). // ───────────────────────────────────────────────────────────────────── /// 회피 스텝 판정 결과 — 0 스텝 · 1 꺼짐 · 2 쿨다운 · 3 보스 없음/멀다 · 4 예고 아님 · 5 PC 상태 불가. public const int kDodgeGo = 0, kDodgeOff = 1, kDodgeCooldown = 2, kDodgeNoBoss = 3, kDodgeNoTelegraph = 4, kDodgePcBusy = 5; // ── 진단(프로브·QA 가 읽는다) /// 실제로 나간 회피 스텝 수 · 예고 **에피소드** 수(차징 상태의 상승 에지만 센다 · 프레임마다 세지 않는다). public static int DodgeSteps, DodgeTelegraphsSeen; /// 예고를 봤는데 못 나간 사유별 수 — 쿨다운 · PC 상태(공격·CC) · NavMesh 지점 없음. /// 🔴 「보스 없음」·「예고 아님」은 평시 상태라 세지 않는다(세면 프레임 수만큼 늘어 값이 무의미해진다). public static int DodgeSkippedCooldown, DodgeSkippedPcBusy, DodgeFailedNavMesh; /// 마지막 스텝의 계획 거리(m) · 실제 이동 거리(m · NavMesh 투영 후) · 걸린 시간(초). public static float LastDodgePlanned, LastDodgeMoved, LastDodgeSeconds; /// 마지막 스텝 요약 줄(프로브·QA 가 읽는다). public static string LastDodgeLog = ""; static bool s_dodging, s_wasTelegraphing; static Vector3 s_dodgeDir, s_dodgeFrom; static float s_dodgeElapsed, s_dodgeSpeed, s_dodgePlanned, s_lastDodge = -999f; /// 지금 회피 스텝 중인가(진단·프로브). public static bool Dodging { get { return s_dodging; } } /// 마지막 스텝 시각(unscaled · 쿨다운 기준). public static float LastDodgeTime { get { return s_lastDodge; } } /// /// 매 프레임 1회. 반환 true = 스텝이 진행 중이니 이번 프레임 폴링은 쉰다. /// dt 는 게임 시간(Time.deltaTime) — 세계가 멈추면(timeScale 0) 스텝도 멈춘다. /// internal static bool TickDodgeStep(WLRunGuideSettings cfg, float now, float dt) { if (cfg == null) { EndDodgeStep("설정 없음"); return false; } if (s_dodging) return DriveDodgeStep(cfg, dt); TryBeginDodgeStep(cfg, now); return s_dodging; } /// /// 스텝 시작 판정(순수 함수 · Actor 접근 0 · 프로브가 가짜 값으로 그대로 부른다 · C8 증거). /// `sinceLastDodge` = 마지막 스텝 이후 경과(초 · 없으면 큰 값). /// public static int DodgeDecision(WLRunGuideSettings cfg, bool bossInRange, bool telegraphing, bool pcReady, float sinceLastDodge) { if (cfg == null || !cfg.dodgeStepEnabled || cfg.dodgeStepDistance <= 0f || cfg.dodgeStepSeconds <= 0f) return kDodgeOff; if (sinceLastDodge < cfg.dodgeStepCooldownSeconds) return kDodgeCooldown; if (!bossInRange) return kDodgeNoBoss; if (!telegraphing) return kDodgeNoTelegraph; if (!pcReady) return kDodgePcBusy; return kDodgeGo; } /// /// 보스가 지금 예고(차징) 상태인가. `Actor.m_animation`(public) 의 상태 이름만 본다. /// 🔴 **모든 레이어**를 본다 — 813b 실측: Anubis 컨트롤러는 레이어가 2개고 /// `boss_skill1charging` 과 `boss_skill2charging` 이 서로 다른 레이어에 있다. /// 원본 `Actor.AnimationPlay`(:2029)의 `Animator.Play(name)` 도 레이어 −1(= 그 상태가 있는 첫 레이어)로 건다. /// 이름 목록은 SO 값(C45) — 목록이 비면 언제나 false = 스텝 0(C8). 할당 0(문자열 비교만). /// public static bool IsBossTelegraphing(WLRunGuideSettings cfg, Actor boss) { if (cfg == null || DSUtil.CheckNull(boss)) return false; var names = cfg.dodgeTelegraphStates; if (names == null || names.Length == 0) return false; var anim = boss.m_animation; if (anim == null || !anim.isActiveAndEnabled || anim.runtimeAnimatorController == null) return false; int layers = anim.layerCount; for (int L = 0; L < layers; L++) { var info = anim.GetCurrentAnimatorStateInfo(L); for (int i = 0; i < names.Length; i++) { if (string.IsNullOrEmpty(names[i])) continue; if (info.IsName(names[i])) return true; } } return false; } /// PC 가 지금 스텝을 받을 수 있는 상태인가(공격·스킬·CC·사망 중이면 원본 우선). static bool DodgePcReady(PCActor pc) { if (DSUtil.CheckNull(pc) || pc.IsDead()) return false; if (pc.JoystickStatus == 2) return false; // 스킬 이동 잠금(Whirlwind 등) if (pc.Get_CurAnim() >= eAnim.Attack) return false; // 공격·스킬 중에는 끼어들지 않는다 try { if (pc.IsCC()) return false; } catch (System.Exception) { } // 에디트 모드엔 dic_ccData 가 없다 return true; } static void TryBeginDodgeStep(WLRunGuideSettings cfg, float now) { var pc = MyValue.MyPC; var boss = IsAliveBoss(s_boss) ? s_boss : BossArena.Boss; if (!IsAliveBoss(boss)) boss = null; Vector3 pcPos = DSUtil.CheckNull(pc) ? Vector3.zero : pc.Get_position(); Vector3 bossPos = boss == null ? Vector3.zero : boss.Get_position(); bool inRange = boss != null && cfg.dodgeStepBossRadius > 0f && DistXZ(pcPos, bossPos) <= cfg.dodgeStepBossRadius; bool telegraphing = inRange && IsBossTelegraphing(cfg, boss); // 🔴 상승 에지만 센다 — 프레임마다 세면 차징 1.2 s 당 70여 건이 찍혀 「예고 건수」가 무의미해진다. if (telegraphing && !s_wasTelegraphing) DodgeTelegraphsSeen++; s_wasTelegraphing = telegraphing; switch (DodgeDecision(cfg, inRange, telegraphing, DodgePcReady(pc), now - s_lastDodge)) { case kDodgeCooldown: if (telegraphing) DodgeSkippedCooldown++; return; case kDodgePcBusy: DodgeSkippedPcBusy++; return; // 예고를 봤는데 공격·CC 중이었다 case kDodgeGo: break; default: return; // 꺼짐 · 보스 없음 · 예고 아님 = 조용히 무동작 } var agent = pc.Get_Agent(); if (agent == null || !agent.enabled || !agent.isOnNavMesh) { DodgeFailedNavMesh++; return; } // ── 방향 = 예고 방향(보스→PC)의 **반대** = 보스에서 멀어지는 쪽. 옆 성분은 SO 비율로 섞는다. Vector3 away = pcPos - bossPos; away.y = 0f; if (away.sqrMagnitude < 1e-4f) { away = -boss.transform.forward; away.y = 0f; } if (away.sqrMagnitude < 1e-4f) away = Vector3.forward; away.Normalize(); Vector3 side = Vector3.Cross(Vector3.up, away); // XZ 직교(정규 · away 가 단위라 단위) float bias = Mathf.Clamp01(cfg.dodgeStepSideBias); Vector3 dir = (away * (1f - bias) + side * bias); if (dir.sqrMagnitude < 1e-4f) dir = away; else dir.Normalize(); float r = cfg.dodgeStepNavSampleRadius > 0f ? cfg.dodgeStepNavSampleRadius : 1f; Vector3 target; if (!SampleStep(pcPos, dir, cfg.dodgeStepDistance, r, out target)) { // 옆으로 섞은 방향이 막혔으면 반대쪽 옆으로 한 번 더(벽에 붙어 있을 때). Vector3 alt = (away * (1f - bias) - side * bias); if (alt.sqrMagnitude > 1e-4f) alt.Normalize(); else alt = away; if (!SampleStep(pcPos, alt, cfg.dodgeStepDistance, r, out target)) { DodgeFailedNavMesh++; return; } dir = alt; } Vector3 real = target - pcPos; real.y = 0f; float planned = real.magnitude; if (planned < 0.05f) { DodgeFailedNavMesh++; return; } // 갈 데가 없다 s_dodgeDir = real / planned; // NavMesh 경로 이동을 끊고 직접 민다(DashDriver 와 같은 이유 — 두 이동이 싸우지 않게). pc.WL_StopNavMove(); AcquireSteer(pc); // 813w7 이 뽑아 둔 공통 진입점(우리가 0 → 1 로 만든 경우에만 잡는다) s_dodging = true; s_dodgeFrom = pcPos; s_dodgeElapsed = 0f; s_dodgePlanned = planned; s_dodgeSpeed = planned / Mathf.Max(0.05f, cfg.dodgeStepSeconds); s_lastDodge = now; DodgeSteps++; LastDodgePlanned = planned; if (cfg.dodgeStepLog) { LastDodgeLog = "#" + DodgeSteps + " 예고 회피 — 보스 " + DistXZ(pcPos, bossPos).ToString("F1") + "m · 반대로 " + planned.ToString("F1") + "m / " + cfg.dodgeStepSeconds.ToString("F2") + "s · 옆비율 " + bias.ToString("F2"); Debug.Log("[RunGuide][회피] " + LastDodgeLog); } } /// 스텝 지점을 NavMesh 위로 스냅한다(밖이면 false = 스텝하지 않는다 · 지형 뚫기 방지). static bool SampleStep(Vector3 from, Vector3 dir, float dist, float sampleRadius, out Vector3 target) { target = from + dir * dist; NavMeshHit nh; if (!NavMesh.SamplePosition(target, out nh, sampleRadius, NavMesh.AllAreas)) return false; target = nh.position; return true; } /// 진행 중인 스텝을 민다. 반환 true = 아직 스텝 중(폴링은 쉰다). static bool DriveDodgeStep(WLRunGuideSettings cfg, float dt) { var pc = MyValue.MyPC; if (!DodgePcReady(pc)) { EndDodgeStep("PC 상태"); return false; } var agent = pc.Get_Agent(); if (agent == null || !agent.enabled || !agent.isOnNavMesh) { EndDodgeStep("NavMesh"); return false; } if (dt > 0f) { float remain = Mathf.Max(0f, s_dodgePlanned - Vector3.Distance(s_dodgeFrom, pc.Get_position())); float step = Mathf.Min(s_dodgeSpeed * dt, remain); if (step > 0f) agent.Move(s_dodgeDir * step); s_dodgeElapsed += dt; } if (s_dodgeElapsed >= Mathf.Max(0.05f, cfg.dodgeStepSeconds)) { EndDodgeStep("완료"); return false; } return true; } /// 스텝을 끝낸다(조종은 다음 폴링이 정리한다 — 우리가 1 로 만든 경우에만 0 으로 돌아간다). static void EndDodgeStep(string why) { if (!s_dodging) return; s_dodging = false; var pc = MyValue.MyPC; LastDodgeMoved = DSUtil.CheckNull(pc) ? 0f : DistXZ(s_dodgeFrom, pc.Get_position()); LastDodgeSeconds = s_dodgeElapsed; LastDodgeLog = "#" + DodgeSteps + " " + why + " — 계획 " + s_dodgePlanned.ToString("F1") + "m / 실제 " + LastDodgeMoved.ToString("F1") + "m / " + s_dodgeElapsed.ToString("F2") + "s"; var c = Cfg; if (c != null && c.dodgeStepLog) Debug.Log("[RunGuide][회피] " + LastDodgeLog); } // ── 813i2 프로브 API /// 프로브용 — 스텝 상태·쿨다운을 비운다. public static void ResetDodgeStateForProbe() { s_dodging = false; s_wasTelegraphing = false; s_dodgeElapsed = 0f; s_lastDodge = -999f; } /// 프로브용 — 마지막 스텝 시각을 밀어 넣는다(쿨다운 시나리오). public static void SetLastDodgeForProbe(float t) { s_lastDodge = t; } /// 프로브용 — 러너 없이 회피 스텝 1틱(가짜 dt). public static bool ForceDodgeTick(float now, float dt) { return TickDodgeStep(Cfg, now, dt); } // ───────────────────────────────────────────────────────────────────── // 유틸 · 진단 // ───────────────────────────────────────────────────────────────────── 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); } static string Fmt(Vector3 v) { return "(" + v.x.ToString("F1") + ", " + v.y.ToString("F1") + ", " + v.z.ToString("F1") + ")"; } static void Log(string msg) { LastLog = msg; var c = Cfg; if (c != null && c.verboseLog) Debug.Log("[RunGuide] " + msg); } /// 프로브용: 카운터·상태 초기화(구독은 유지). public static void ResetDiagnostics() { GuideBegun = Arrived = GaveUp = FarTargetIgnored = HoldPullbacks = 0; Repaths = BossRetargets = BossLocks = HitSuspends = AnchorScans = 0; ArrivedByStill = PullbacksWhileHit = 0; MaxDistFromDest = ZoneHoldSeconds = BossHoldSeconds = TravelSeconds = 0f; LastLog = LastIgnoredTarget = LastTickLog = ""; ReleaseSteer(); ReleaseBossLock(); s_state = RunGuideState.Off; s_destKind = RunGuideDest.None; s_zoneIndex = -1; s_dest = Vector3.zero; s_boss = null; s_nextPoll = s_nextRepath = s_travelStart = s_hitUntil = s_nextTickLog = 0f; s_lastPosTime = s_stillSince = s_lastAccumTime = -1f; s_lastPos = Vector3.zero; s_faulted = false; s_anchorCount = 0; // 813w3 ArenaExits = ArenaWarps = WarpsSkippedCooldown = WarpsSkippedNearBoss = WarpsFailedNavMesh = 0; s_outsideSince = -1f; s_lastWarp = -999f; s_nextExitDiag = 0f; s_bossArrivedOnce = false; s_exitLogNext = s_exitLogCount = 0; LastExitLog = ""; // 813w4 RestartGuides = StallGuides = EliteLocks = EliteRetargets = EliteReleases = 0; LastEliteName = ""; ReleaseEliteLock(); EliteReleases = 0; for (int i = 0; i < kMaxElites; i++) s_elites[i] = null; s_idleSince = -1f; s_lastStallGuide = -999f; s_idlePos = Vector3.zero; s_lastRestartCount = RunDirector.RestartCount; // 813w7 EndRunEndHold("진단 초기화"); RunEndHolds = RunEndHoldReleases = RunEndHoldTicks = RunEndTargetClears = 0; LastRunEndHoldReason = ""; // 813w8 — 래치는 EndRunEndHold 가 이미 풀었다(안 걸려 있으면 무동작). 카운터만 0 으로. ClearRunEndInvincible("진단 초기화"); RunEndInvincibleOn = RunEndInvincibleOff = 0; // 813i2 DodgeSteps = DodgeTelegraphsSeen = 0; DodgeSkippedCooldown = DodgeSkippedPcBusy = DodgeFailedNavMesh = 0; LastDodgePlanned = LastDodgeMoved = LastDodgeSeconds = 0f; LastDodgeLog = ""; s_dodging = false; s_wasTelegraphing = false; s_dodgeElapsed = 0f; s_lastDodge = -999f; s_dodgeDir = s_dodgeFrom = Vector3.zero; s_dodgeSpeed = s_dodgePlanned = 0f; } // ── 813w7 프로브 API /// 프로브용 — 정지 상한 만료 시각을 직접 밀어 넣는다(에디트 모드에서는 Time 이 흐르지 않는다). public static void SetRunEndHoldUntilForProbe(float t) { s_runEndHoldUntil = t; } /// 프로브용 — 진짜 RunEnded 페이로드로 핸들러를 그대로 태운다(Play 불필요). public static void ProbeRunEnded(RunOutcome outcome) { var e = default(RunResult); e.outcome = outcome; e.time = Time.unscaledTime; e.frame = Time.frameCount; OnRunEnded(in e); } // ── 813w4 프로브 API /// 지금 고정 중인 엘리트(없으면 null). public static Actor LockedElite { get { return s_lockedElite; } } /// 등록부에 살아 있는 엘리트 수(진단). public static int EliteCount { get { int n = 0; for (int i = 0; i < kMaxElites; i++) if (IsAliveTarget(s_elites[i])) n++; return n; } } /// 등록부 칸 i 의 엘리트(진단 · 없으면 null). public static Actor EliteAt(int i) { return i >= 0 && i < kMaxElites ? s_elites[i] : null; } /// 등록부 최대 칸 수(진단). public static int EliteCapacity { get { return kMaxElites; } } /// 프로브용 — 존 구간 엘리트 우선 타깃을 1회 실행한다. public static void ApplyElitePriorityForProbe(WLRunGuideSettings cfg, PCActor pc) { if (cfg != null && !DSUtil.CheckNull(pc)) ApplyElitePriority(cfg, pc); } /// 프로브용 — 정지 감시 타이머만 비운다. public static void ResetStallStateForProbe() { s_idleSince = -1f; s_lastStallGuide = -999f; } /// 프로브용 — RunStarted 재시작 판정의 기준 카운트를 지금 값으로 맞춘다. public static void SyncRestartCountForProbe() { s_lastRestartCount = RunDirector.RestartCount; } /// 프로브용 — 고장(예외) 상태를 강제로 만든다(restartResetsGuideState 검증). public static void ForceFaultForProbe() { s_faulted = true; } /// 프로브용: 워프 쿨다운·이탈 타이머만 비운다(시나리오 사이 초기화). public static void ResetWarpStateForProbe() { s_outsideSince = -1f; s_lastWarp = -999f; s_nextExitDiag = 0f; } /// 프로브용: 마지막 워프 시각을 밀어 넣는다(에디트 모드에서는 실제 워프가 못 돌아 쿨다운을 못 만든다). public static void SetLastWarpForProbe(float t) { s_lastWarp = t; } /// 프로브용: 이탈 시작 시각을 밀어 넣는다(에디트 모드에서는 Time 이 흐르지 않는다). public static void SetOutsideSinceForProbe(float t) { s_outsideSince = t; } /// 프로브용: PC 가 반경 밖이 된 시각(unscaled · 안이면 −1) · 마지막 워프 시각. public static float OutsideSince { get { return s_outsideSince; } } public static float LastWarpTime { get { return s_lastWarp; } } /// 프로브용: 가이드 상태를 직접 세운다(에디트 모드에서 Hold 시나리오를 만들 때). public static void SetStateForProbe(RunGuideState st) { s_state = st; } /// 아레나에 한 번이라도 도착했는가 — 이탈 감시는 이 뒤부터 돈다(진단·프로브). public static bool BossArrivedOnce { get { return s_bossArrivedOnce; } } /// 프로브용: 조종 플래그를 직접 세운다(에디트 모드엔 NavMeshAgent 가 없어 Steer 가 되돌아간다). public static void SetSteeringForProbe(bool on) { s_steering = on; if (!on) { s_stillSince = -1f; s_lastPosTime = -1f; } } /// 프로브용: 상태를 직접 읽는다(정지 판정 내부 시각). public static float StillSince { get { return s_stillSince; } } /// 프로브용: 러너 없이 1회 폴링(강제 틱). public static void ForceTick() { s_nextPoll = 0f; Tick(); } /// 프로브용: 피격 응전 구간을 직접 건다. public static void SimulateHit() { var c = Cfg; if (c == null) return; float hold = s_destKind == RunGuideDest.Boss ? c.bossHitSuspendSeconds : c.resumeAfterHitSeconds; if (hold <= 0f) hold = 0.01f; // 프로브가 「피격 중」 상태를 만들 수 있게 최소값 s_hitUntil = Time.unscaledTime + hold; HitSuspends++; } /// 프로브용: 피격 응전 구간을 즉시 푼다. public static void ClearHitSuspendForProbe() { s_hitUntil = 0f; } /// 프로브용: 보스 참조를 직접 넣는다. public static void SetBossForProbe(Actor boss) { s_boss = boss; } /// 프로브용: 이동 시작 시각을 밀어 넣는다(에디트 모드에서는 Time 이 흐르지 않아 maxTravelSeconds 를 못 넘긴다). public static void SetTravelStartForProbe(float t) { s_travelStart = t; } // ───────────────────────────────────────────────────────────────────── // 런너 (숨김 GameObject 1개 · 플레이 모드 전용) // ───────────────────────────────────────────────────────────────────── static RunGuideRunner s_runner; internal static void EnsureRunner() { if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return; var go = new GameObject("[WL813w] RunGuideRunner"); go.hideFlags = HideFlags.HideAndDontSave; s_runner = go.AddComponent(); } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { if (!Active) return; // 꺼져 있으면 오브젝트 0 · 구독 0(C8) EnsureSubscribed(); EnsureRunner(); } } /// RunGuide 의 시간 축. 숨김 GameObject 1개 · 코루틴 0. internal sealed class RunGuideRunner : MonoBehaviour { void Update() { RunGuide.Tick(); } } }