Project_WL/Assets/WL/Combat/Run/RunGuide.cs

1110 lines
66 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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.
// ─────────────────────────────────────────────────────────────────────────────
//
// ■ 값(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
{
/// <summary>가이드 상태.</summary>
public enum RunGuideState
{
/// <summary>목적지 없음(원본 100%).</summary>
Off = 0,
/// <summary>목적지로 이동 중(조종 ON).</summary>
Travel = 1,
/// <summary>도착 — 그 자리에서 원본 자동전투(조종 OFF · 이탈만 감시).</summary>
Hold = 2,
}
/// <summary>목적지 종류.</summary>
public enum RunGuideDest
{
None = 0,
/// <summary>존 스포너 앵커.</summary>
Zone = 1,
/// <summary>보스 아레나 중심.</summary>
Boss = 2,
}
/// <summary>813w3 §1-1 — 아레나 이탈 1건의 원본 상태 스냅샷(struct · 링버퍼에 사전 할당 · GC 0).</summary>
public struct ArenaExitRecord
{
/// <summary>발생 시각(unscaled) · 프레임.</summary>
public float time; public int frame;
/// <summary>PC↔아레나 중심 XZ(m) · PC↔보스 XZ(m · 보스 없으면 1) · 보스↔중심 XZ(m · 보스 없으면 1).</summary>
public float distFromCenter, distFromBoss, bossFromCenter;
/// <summary>NavMeshAgent 의 현재 목적지가 아레나 중심에서 몇 m 인가(1 = 경로 없음/에이전트 없음).
/// 🔴 이 값이 크면 **원본이 우리 목적지를 덮어썼다**는 직접 증거다(MyActor.cs:335 Set_Path).</summary>
public float agentDestFromCenter;
/// <summary>0 없음 · 1 보스 · 2 잡몹(MobActor) · 3 그 밖.</summary>
public int targetKind;
/// <summary>PCActor.JoystickStatus 실측값(0 원본 / 1 우리 조종 / 2 스킬 이동 잠금).</summary>
public int joystickStatus;
/// <summary>Actor.Get_CurAnim()(eAnim 정수) · 가이드 상태(RunGuideState 정수).</summary>
public int anim, guideState;
/// <summary>보스 m_IsLockTarget · 우리 조종 중 · PC CC · 보스 점프 중(NavMeshAgent.enabled=false) · 최근 피격 · 이 줄이 워프인가.</summary>
public bool bossLocked, steering, pcCC, bossJumping, recentHit, warped;
/// <summary>NavMeshAgent 정지 플래그 · NavMesh 위인가.</summary>
public bool agentStopped, agentOnNavMesh;
/// <summary>마지막 피격 후 경과(초 · 응전 구간이 아니면 1) · NavMeshAgent 수평 속도(m/s).</summary>
public float sinceHit, agentSpeed;
/// <summary>이 이탈이 몇 번째인가(ArenaExits 값).</summary>
public int index;
}
/// <summary>런 진행 가이드. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개) · GC 0.</summary>
public static class RunGuide
{
static WLRunGuideSettings Cfg { get { return WLRunGuideSettings.Instance; } }
/// <summary>가이드가 살아 있는가(에셋 · enabled · 런타임 스위치 · 런 축). false 면 원본 자동전투 100%.</summary>
public static bool Active { get { return WLRunGuideSettings.Enabled; } }
// ───────────────────────────────────────── 상태 (프로브가 읽는다)
public static RunGuideState State { get { return s_state; } }
public static RunGuideDest DestKind { get { return s_destKind; } }
/// <summary>존 목적지일 때의 존 인덱스(0부터). 보스면 1.</summary>
public static int ZoneIndex { get { return s_zoneIndex; } }
/// <summary>현재 목적지(월드). 보스면 BossArena.Center 를 매 틱 다시 읽는다.</summary>
public static Vector3 Destination { get { return s_dest; } }
/// <summary>지금 WL 이 PC 를 조종 중인가(JoystickStatus = 1).</summary>
public static bool Steering { get { return s_steering; } }
/// <summary>구독 상태.</summary>
public static bool Subscribed { get { return s_subscribed; } }
/// <summary>고장(예외) 상태 — 이후 아무 일도 하지 않는다.</summary>
public static bool Faulted { get { return s_faulted; } }
// ───────────────────────────────────────── 진단 카운터
public static int GuideBegun, Arrived, GaveUp, FarTargetIgnored, HoldPullbacks,
Repaths, BossRetargets, BossLocks, HitSuspends, AnchorScans;
/// <summary>813w2: 정지 판정으로 확정한 도착 수(Arrived 에도 함께 센다) · 피격 중에도 실행한 되돌림 수.</summary>
public static int ArrivedByStill, PullbacksWhileHit;
/// <summary>813w2: 목적지에서 잰 최대 이탈 거리(m · Q5 D-22 의 「46.3 m」에 대응하는 실측값).</summary>
public static float MaxDistFromDest;
/// <summary>813w2: 존 체류 초 · 보스(아레나) 체류 초 · 이동(Travel) 초 — RunTick 로그와 QA 시각표용.</summary>
public static float ZoneHoldSeconds, BossHoldSeconds, TravelSeconds;
public static string LastLog = "";
/// <summary>마지막으로 무시한 타깃 이름(진단).</summary>
public static string LastIgnoredTarget = "";
/// <summary>813w2: 마지막 RunTick 요약 줄(프로브·QA 가 읽는다).</summary>
public static string LastTickLog = "";
// ───────────────────────────────────────── 813w3 진단 (§1-1 · §1-2)
/// <summary>아레나 이탈 에피소드 수 · 실제 워프 수.</summary>
public static int ArenaExits, ArenaWarps;
/// <summary>워프를 건너뛴 사유별 수 — 쿨다운 · 보스 추적 중(정상) · NavMesh 지점 없음.</summary>
public static int WarpsSkippedCooldown, WarpsSkippedNearBoss, WarpsFailedNavMesh;
/// <summary>마지막 이탈 진단 줄 원문(프로브·QA 가 읽는다 · 콘솔에 나간 그 문자열).</summary>
public static string LastExitLog = "";
/// <summary>진단 링버퍼 칸 수. 게임 수치가 아니라 버퍼 크기다(발주서 §1-1 「링버퍼 32」).</summary>
const int kExitLogCapacity = 32;
static readonly ArenaExitRecord[] s_exitLog = new ArenaExitRecord[kExitLogCapacity];
static int s_exitLogNext, s_exitLogCount;
/// <summary>링버퍼에 쌓인 기록 수(최대 32).</summary>
public static int ExitLogCount { get { return s_exitLogCount; } }
/// <summary>링버퍼 읽기 — 0 = 가장 오래된 기록. 범위 밖이면 빈 구조체.</summary>
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;
// 존 앵커 캐시(스포너 노드 실좌표 · 없으면 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<RunStartedEvent> h_runStarted = OnRunStarted;
static readonly CombatHandler<ZoneClearedEvent> h_zoneCleared = OnZoneCleared;
static readonly CombatHandler<BossGateOpenedEvent> h_gate = OnBossGateOpened;
static readonly CombatHandler<BossStartedEvent> h_bossStarted = OnBossStarted;
static readonly CombatHandler<RunResult> h_runEnded = OnRunEnded;
static readonly CombatHandler<DamagedEvent> h_damaged = OnDamaged;
static readonly CombatHandler<RunTickEvent> h_runTick = OnRunTick; // 813w2 §1-3 진행 로그
// ─────────────────────────────────────────────────────────────────────
// 구독
// ─────────────────────────────────────────────────────────────────────
/// <summary>RunEvents · CombatEvents.Damaged 구독(중복 안전).</summary>
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);
}
/// <summary>구독 해제(A/B · 프로브).</summary>
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);
}
// ─────────────────────────────────────────────────────────────────────
// 이벤트 → 목적지
// ─────────────────────────────────────────────────────────────────────
static void OnRunStarted(in RunStartedEvent e)
{
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;
var cfg = Cfg;
if (cfg != null && cfg.zoneGuideEnabled && cfg.guideOnRunStart) BeginZone(0, "런 시작");
}
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("보스 등장");
}
static void OnRunEnded(in RunResult e) { StopGuide("런 종료"); }
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++;
}
/// <summary>813w2 §1-3 — 존 체류/이동 초를 주기적으로 한 줄 남긴다(QA 시각표 · GC 는 로그를 실제로 낼 때만).</summary>
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 + ")" +
" · 존 " + e.zonesCleared + "/" + e.zoneCount + " " + e.zoneKills + "/" + e.zoneRequired;
Debug.Log("[RunGuide] " + LastTickLog);
}
// ─────────────────────────────────────────────────────────────────────
// 목적지 지정
// ─────────────────────────────────────────────────────────────────────
/// <summary>존 i 로 유도한다(프로브도 직접 부를 수 있다).</summary>
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 폴백]"));
}
/// <summary>보스 아레나로 유도한다(프로브도 직접 부를 수 있다).</summary>
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));
}
/// <summary>가이드를 끝낸다(조종·락 해제 포함).</summary>
public static void StopGuide(string reason)
{
bool had = s_destKind != RunGuideDest.None;
ReleaseSteer();
ReleaseBossLock();
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;
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: 정지 판정을 새로 잡는다
}
// ─────────────────────────────────────────────────────────────────────
// 틱
// ─────────────────────────────────────────────────────────────────────
/// <summary>런너가 매 프레임 부른다(에디트 모드에서는 프로브가 ForceTick 으로 부른다).</summary>
internal static void Tick()
{
if (s_faulted) return;
if (!Active) { if (s_steering) ReleaseSteer(); return; }
var cfg = Cfg;
float now = Time.unscaledTime;
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()) { ReleaseSteer(); s_lastPosTime = -1f; 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 에서(이동 중 타깃은 우리 목적지를 덮어쓴다)
if (isBoss) ApplyBossPriority(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();
}
/// <summary>Travel → Hold 로 확정한다(도착 · 정지 도착 · 이동 포기 공통).</summary>
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);
}
// ─────────────────────────────────────────────────────────────────────
// 813w3 §1-1 · §1-2 — 아레나 이탈 진단 · 강제 복귀(워프)
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 보스 구간 이탈 추적. 이탈 시작에 진단 1줄을 남기고, `warpBackAfterSeconds` 동안 반경 밖이면 워프한다.
/// 반환 true = 이번 폴링에서 워프했다(호출측은 즉시 return 한다).
/// 프로브가 임시 SO·가짜 좌표로 그대로 부를 수 있다(pc 는 null 허용 — 그러면 워프만 건너뛴다).
/// </summary>
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;
}
}
/// <summary>워프 판정 결과 — 0 반경 안 · 1 아직 기다린다(되돌림만) · 2 워프 · 3 쿨다운 · 4 보스 곁(정상 추적).</summary>
public const int kWarpNone = 0, kWarpWait = 1, kWarpGo = 2, kWarpCooldown = 3, kWarpNearBoss = 4;
/// <summary>
/// 강제 복귀 판정(순수 함수 · Actor 접근 0 · 프로브가 가짜 거리·시각으로 그대로 부른다 · C8 증거).
/// `outsideSince` = 반경 밖이 된 시각(안이면 1) · `lastWarp` = 마지막 워프 시각.
/// </summary>
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;
}
/// <summary>
/// 보스가 아레나 중심에서 `bossAwayFromCenterRadius` 밖이고 PC 가 그 보스에게서 `followBossRadius` 안인가.
/// = Anubis 스킬2(`Co_JumpToPosition`)로 보스가 튄 뒤 PC 가 정상적으로 따라간 상태(워프하면 안 된다).
/// </summary>
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;
}
/// <summary>원본 `Actor.Set_Warp`(Actor.cs:2111)으로 아레나 중심 ±jitter 지점에 내려놓는다. NavMesh 밖이면 하지 않는다.</summary>
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;
}
/// <summary>워프 직후 카메라 이동 리드를 0 으로 스냅한다(811 `CameraMoveLead.Snap()` · RealCamera 무수정).</summary>
static void SnapCameraLead()
{
var cam = MyValue.m_RealCamera;
if (DSUtil.CheckNull(cam)) return;
var lead = cam.Get_MoveLead();
if (lead != null) lead.Snap();
}
/// <summary>
/// 이탈 1건을 링버퍼(사전 할당 · GC 0)에 넣고 콘솔에 1줄 남긴다.
/// 🔴 `verboseLog` 와 무관하게 남긴다 — Q7 이 이 줄만 보고 「누가 PC 를 끌고 나갔는지」를 판정한다.
/// </summary>
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);
}
/// <summary>피격 응전 구간 길이(초) — sinceHit 역산용.</summary>
static float HitHold(WLRunGuideSettings cfg)
{
return s_destKind == RunGuideDest.Boss ? cfg.bossHitSuspendSeconds : cfg.resumeAfterHitSeconds;
}
/// <summary>진단 1줄 포맷(프로브·Q7 공용 · 순수 함수).</summary>
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 "없음"; }
}
/// <summary>
/// 피격 응전 판정(순수 · 프로브가 임시 SO 로 그대로 부른다 · C8 증거).
/// 🔴 813w2 의 핵심 — `hitSuspendOnlyInHold` 가 켜져 있으면 **Travel(= 이동·되돌림)에서는
/// 피격이 가이드를 멈추지 못한다.** 813w 은 상태와 무관하게 멈췄고, 그 때문에 도착 판정(D-24)과
/// 이탈 되돌림(D-22)이 폴링에서 통째로 빠졌다.
/// </summary>
public static bool SuspendDecision(WLRunGuideSettings cfg, bool hit, RunGuideState state)
{
if (!hit || cfg == null) return false;
return !cfg.hitSuspendOnlyInHold || state == RunGuideState.Hold;
}
/// <summary>정지 판정으로 도착을 인정할 거리인가 — 기본은 목적지 반경(holdRadius) 안(프로브가 그대로 부른다).</summary>
public static bool WithinStillRange(WLRunGuideSettings cfg, float d, float hold)
{
float lim = cfg.arriveStillMaxDistance > 0f ? cfg.arriveStillMaxDistance : hold;
return lim <= 0f || d <= lim;
}
/// <summary>조종 중 XZ 속도가 arriveStillSpeed 미만인 상태가 arriveStillSeconds 이상 이어졌는가.</summary>
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);
}
/// <summary>
/// 정지 판정 1스텝(순수 · Actor 접근 0). 프로브가 가짜 이동량·시각으로 그대로 부를 수 있다.
/// moved = 지난 스텝 동안의 XZ 이동량(m) · dt = 그 간격(초) · stillSince = 정지 시작 시각(없으면 1).
/// </summary>
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;
}
/// <summary>존 체류·아레나 체류·이동 초를 누적한다(§1-3 QA 시각표).</summary>
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;
}
// ─────────────────────────────────────────────────────────────────────
// 조종 · 타깃
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 이동·되돌림 중에는 타깃을 놓는다.
/// 🔴 813w2 §1-2: 보스 타깃도 놓는다(`pullbackReleasesTarget`). 타깃이 남아 있으면 원본
/// `Play_Run() → Set_Path(타깃)`(MyActor.cs:335)이 매 폴링 우리 목적지를 덮어써 두 이동이 싸운다.
/// 보스 락(`m_IsLockTarget`)은 유지되므로 잡몹이 타깃을 가져가지 못한다 — 도착(Hold)하면
/// `ApplyBossPriority` 가 즉시 보스를 다시 건다.
/// </summary>
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++;
}
/// <summary>도착 뒤: 목적지에서 guideLeashRadius 밖의 타깃만 무시한다(가까운 적은 원본이 잡는다).</summary>
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;
if (DistXZ(t.Get_position(), s_dest) <= cfg.guideLeashRadius) return;
LastIgnoredTarget = t.name;
pc.Del_Target(true);
FarTargetIgnored++;
}
/// <summary>PC 를 목적지로 민다. 원본 이동 API(NavMeshAgent)만 쓴다.</summary>
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 등)
if (!s_steering && pc.JoystickStatus == 0)
{
pc.Set_JoystickStatus(1); // 원본 자동 교전 접근 루프를 비켜 세운다(PCActor.cs:139)
s_steering = true;
}
// 🔴 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++;
}
}
/// <summary>조종을 놓는다(우리가 1 로 만든 경우에만 0 으로 되돌린다).</summary>
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;
}
// ─────────────────────────────────────────────────────────────────────
// 존 앵커 (스포너 노드 실좌표 · 없으면 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;
}
/// <summary>존 i 의 앵커. 스포너 노드를 못 찾았으면 1회 스캔하고, 그래도 없으면 SO 폴백.</summary>
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;
}
/// <summary>씬의 MobControlMgr 를 1회 훑어 존 앵커를 채운다(런당 몇 회 · 매 틱 아님).</summary>
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<MobControlMgr>(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];
}
}
}
/// <summary>앵커가 스포너 노드에서 온 것인가(진단).</summary>
public static bool AnchorFromNode(int i) { return i >= 0 && i < kMaxZones && s_anchorFromNode[i]; }
/// <summary>캐시된 앵커(진단).</summary>
public static Vector3 AnchorOf(int i) { return i >= 0 && i < kMaxZones ? s_anchors[i] : 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);
}
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);
}
/// <summary>프로브용: 카운터·상태 초기화(구독은 유지).</summary>
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 = "";
}
/// <summary>프로브용: 워프 쿨다운·이탈 타이머만 비운다(시나리오 사이 초기화).</summary>
public static void ResetWarpStateForProbe() { s_outsideSince = -1f; s_lastWarp = -999f; s_nextExitDiag = 0f; }
/// <summary>프로브용: 마지막 워프 시각을 밀어 넣는다(에디트 모드에서는 실제 워프가 못 돌아 쿨다운을 못 만든다).</summary>
public static void SetLastWarpForProbe(float t) { s_lastWarp = t; }
/// <summary>프로브용: 이탈 시작 시각을 밀어 넣는다(에디트 모드에서는 Time 이 흐르지 않는다).</summary>
public static void SetOutsideSinceForProbe(float t) { s_outsideSince = t; }
/// <summary>프로브용: PC 가 반경 밖이 된 시각(unscaled · 안이면 1) · 마지막 워프 시각.</summary>
public static float OutsideSince { get { return s_outsideSince; } }
public static float LastWarpTime { get { return s_lastWarp; } }
/// <summary>프로브용: 가이드 상태를 직접 세운다(에디트 모드에서 Hold 시나리오를 만들 때).</summary>
public static void SetStateForProbe(RunGuideState st) { s_state = st; }
/// <summary>아레나에 한 번이라도 도착했는가 — 이탈 감시는 이 뒤부터 돈다(진단·프로브).</summary>
public static bool BossArrivedOnce { get { return s_bossArrivedOnce; } }
/// <summary>프로브용: 조종 플래그를 직접 세운다(에디트 모드엔 NavMeshAgent 가 없어 Steer 가 되돌아간다).</summary>
public static void SetSteeringForProbe(bool on) { s_steering = on; if (!on) { s_stillSince = -1f; s_lastPosTime = -1f; } }
/// <summary>프로브용: 상태를 직접 읽는다(정지 판정 내부 시각).</summary>
public static float StillSince { get { return s_stillSince; } }
/// <summary>프로브용: 러너 없이 1회 폴링(강제 틱).</summary>
public static void ForceTick() { s_nextPoll = 0f; Tick(); }
/// <summary>프로브용: 피격 응전 구간을 직접 건다.</summary>
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++;
}
/// <summary>프로브용: 피격 응전 구간을 즉시 푼다.</summary>
public static void ClearHitSuspendForProbe() { s_hitUntil = 0f; }
/// <summary>프로브용: 보스 참조를 직접 넣는다.</summary>
public static void SetBossForProbe(Actor boss) { s_boss = boss; }
/// <summary>프로브용: 이동 시작 시각을 밀어 넣는다(에디트 모드에서는 Time 이 흐르지 않아 maxTravelSeconds 를 못 넘긴다).</summary>
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<RunGuideRunner>();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
if (!Active) return; // 꺼져 있으면 오브젝트 0 · 구독 0(C8)
EnsureSubscribed();
EnsureRunner();
}
}
/// <summary>RunGuide 의 시간 축. 숨김 GameObject 1개 · 코루틴 0.</summary>
internal sealed class RunGuideRunner : MonoBehaviour
{
void Update() { RunGuide.Tick(); }
}
}