586 lines
30 KiB
C#
586 lines
30 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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 동안 가이드를 멈추고 원본에 넘긴다(즉시 응전 · 발주서 §1-1).
|
||
//
|
||
// ■ 값(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;
|
||
public static string LastLog = "";
|
||
/// <summary>마지막으로 무시한 타깃 이름(진단).</summary>
|
||
public static string LastIgnoredTarget = "";
|
||
|
||
// ───────────────────────────────────────── 내부 상태
|
||
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;
|
||
|
||
// 존 앵커 캐시(스포너 노드 실좌표 · 없으면 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;
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 구독
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
/// <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);
|
||
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);
|
||
CombatEvents.Damaged.Remove(h_damaged);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 이벤트 → 목적지
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
static void OnRunStarted(in RunStartedEvent e)
|
||
{
|
||
StopGuide("런 시작");
|
||
s_boss = null;
|
||
s_anchorCount = 0; // 맵이 바뀌었을 수 있다 — 앵커를 다시 읽는다
|
||
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 || cfg.resumeAfterHitSeconds <= 0f) return;
|
||
if (s_destKind == RunGuideDest.None) return;
|
||
var pc = MyValue.MyPC;
|
||
if (DSUtil.CheckNull(pc) || !ReferenceEquals(e.victim, pc)) return;
|
||
bool wasFree = s_hitUntil <= Time.unscaledTime;
|
||
s_hitUntil = Time.unscaledTime + cfg.resumeAfterHitSeconds;
|
||
if (wasFree) HitSuspends++;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 목적지 지정
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
/// <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;
|
||
if (had) Log("가이드 종료 — " + reason);
|
||
}
|
||
|
||
static void EnterTravel()
|
||
{
|
||
s_state = RunGuideState.Travel;
|
||
s_travelStart = Time.unscaledTime;
|
||
s_nextRepath = 0f;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 틱
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
/// <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); }
|
||
}
|
||
|
||
static void Poll(WLRunGuideSettings cfg, float now)
|
||
{
|
||
var pc = MyValue.MyPC;
|
||
if (DSUtil.CheckNull(pc) || pc.IsDead()) { ReleaseSteer(); return; }
|
||
|
||
if (s_destKind == RunGuideDest.None) { ReleaseSteer(); return; }
|
||
|
||
// ── 보스 우선 타깃(발주서 §1-2) — 조종/도착 상태와 무관하게 보스가 살아 있으면 건다
|
||
if (s_destKind == RunGuideDest.Boss) ApplyBossPriority(cfg, pc);
|
||
|
||
// ── 피격 → 즉시 응전(가이드 일시 중단)
|
||
if (now < s_hitUntil) { ReleaseSteer(); return; }
|
||
|
||
if (s_destKind == RunGuideDest.Boss) s_dest = BossArena.Center;
|
||
|
||
Vector3 pcPos = pc.Get_position();
|
||
float d = DistXZ(pcPos, s_dest);
|
||
bool isBoss = s_destKind == RunGuideDest.Boss;
|
||
float arrive = isBoss ? cfg.bossArriveRadius : cfg.zoneArriveRadius;
|
||
float hold = isBoss ? cfg.bossHoldRadius : cfg.zoneHoldRadius;
|
||
|
||
if (s_state == RunGuideState.Travel)
|
||
{
|
||
if (d <= arrive)
|
||
{
|
||
s_state = RunGuideState.Hold;
|
||
ReleaseSteer();
|
||
Arrived++;
|
||
Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " 도착 — " + d.ToString("F1") + "m · 자동전투 재개");
|
||
return;
|
||
}
|
||
if (cfg.maxTravelSeconds > 0f && now - s_travelStart >= cfg.maxTravelSeconds)
|
||
{
|
||
s_state = RunGuideState.Hold; // 포기하되 이탈 감시는 남긴다
|
||
ReleaseSteer();
|
||
GaveUp++;
|
||
Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " 이동 포기 — " +
|
||
cfg.maxTravelSeconds.ToString("F0") + "s 초과(남은 " + d.ToString("F1") + "m)");
|
||
return;
|
||
}
|
||
ClearTravelTarget(pc);
|
||
Steer(cfg, pc, now);
|
||
return;
|
||
}
|
||
|
||
// ── Hold: 조종은 놓고, 먼 타깃만 무시 · 이탈하면 다시 끌어온다
|
||
IgnoreFarTarget(cfg, pc);
|
||
if (hold > 0f && d > hold)
|
||
{
|
||
EnterTravel();
|
||
HoldPullbacks++;
|
||
Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " 이탈 " + d.ToString("F1") + "m — 다시 유도");
|
||
ClearTravelTarget(pc);
|
||
Steer(cfg, pc, now);
|
||
return;
|
||
}
|
||
ReleaseSteer();
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 조종 · 타깃
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>이동 중에는 타깃을 놓는다(보스가 목적지일 때 보스는 예외 — 그쪽으로 가는 것이 목적이다).</summary>
|
||
static void ClearTravelTarget(PCActor pc)
|
||
{
|
||
if (!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;
|
||
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;
|
||
}
|
||
|
||
pc.WL_PlayRunNow(); // 타깃이 없으므로 경로를 건드리지 않는다(MyActor.cs:335)
|
||
agent.isStopped = false;
|
||
agent.stoppingDistance = cfg.guideStopDistance;
|
||
|
||
if (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++;
|
||
}
|
||
|
||
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;
|
||
LastLog = LastIgnoredTarget = "";
|
||
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 = 0f;
|
||
s_faulted = false; s_anchorCount = 0;
|
||
}
|
||
|
||
/// <summary>프로브용: 러너 없이 1회 폴링(강제 틱).</summary>
|
||
public static void ForceTick() { s_nextPoll = 0f; Tick(); }
|
||
|
||
/// <summary>프로브용: 피격 응전 구간을 직접 건다.</summary>
|
||
public static void SimulateHit() { var c = Cfg; if (c != null) { s_hitUntil = Time.unscaledTime + c.resumeAfterHitSeconds; HitSuspends++; } }
|
||
|
||
/// <summary>프로브용: 피격 응전 구간을 즉시 푼다.</summary>
|
||
public static void ClearHitSuspendForProbe() { s_hitUntil = 0f; }
|
||
|
||
/// <summary>프로브용: 보스 참조를 직접 넣는다.</summary>
|
||
public static void SetBossForProbe(Actor boss) { s_boss = boss; }
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 런너 (숨김 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(); }
|
||
}
|
||
}
|