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

788 lines
45 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 동작 그대로다(값 하나로 되돌릴 수 있다).
// ─────────────────────────────────────────────────────────────────────────────
//
// ■ 값(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>런 진행 가이드. 정적 · 새 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 = "";
// ───────────────────────────────────────── 내부 상태
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;
// 존 앵커 캐시(스포너 노드 실좌표 · 없으면 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 = "";
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 +
" · 존 " + 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;
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;
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);
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;
Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " " + why);
}
/// <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;
}
/// <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(); }
}
}