diff --git a/AgentScripts/WL813w_Probe.cs b/AgentScripts/WL813w_Probe.cs new file mode 100644 index 000000000..ad14f1620 --- /dev/null +++ b/AgentScripts/WL813w_Probe.cs @@ -0,0 +1,559 @@ +// WL813w_Probe.cs — #813w(런 진행 가이드 · 보스 우선 타깃 · 리쉬 완화 · 보스 바 이름) 에디트 모드 검증 프로브 +// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 는 즉시 파괴) +// +// 상주 에디터: unity command run_script --project-path --file AgentScripts/WL813w_Probe.cs --entry WL813w_Probe.RunAll --timeout 300 +// 결과: Console + AgentScripts/WL813w_PROBE.txt (RESULT PASS / RESULT FAIL n) +// +// 검사 (발주서 WL-813w §1-5) +// ① 에셋·SOT: WLRunGuideSettings 로드·값 전수 · WLBossArenaSettings 신규 3필드 +// ② 앵커: 스포너 노드(MobControlMgr.SpawnerID) 실좌표 우선 · 노드가 없으면 SO 폴백 · WL_Nature.prefab 좌표 대조 +// ③ 존 이동: 가짜 ZoneCleared(0) → 목적지 = 존2 앵커 · Travel → 도착(Hold) → 이탈 → 되돌림 +// ④ 타깃 규칙: Travel 중 타깃 해제 · Hold 중 「앵커에서 guideLeashRadius 밖」만 무시(가까운 적은 유지) +// ⑤ 보스: 가짜 BossGateOpened → 아레나 중심 · BossStarted(boss) → 타깃 고정 + 원본 락타깃 · 사망 → 해제 +// ⑥ 피격: SimulateHit → 가이드 중단(조종 0) · 해제 → 재개 +// ⑦ 리쉬 회복 판정(임시 SO): 부분 회복 → 쿨다운 → 쿨다운 뒤 다시 회복 · percent>=1 이면 만피(813d 레거시) +// ⑧ 보스 바 이름: StripClone · ResolveBossLabel · MonsterList/Localization 실측 행 대조 +// ⑨ GC: ForceTick 100회 0 B(대조군 = 의도적 할당) +// ⑩ C8: WLRunGuideSettings.RuntimeDisabled → 아무 일도 하지 않는다(조종 해제 · 상태 불변) +// +// 에디트 모드 한계(보고서 「미확인」으로 올린다) +// · NavMesh 가 없어 NavMeshAgent.SetDestination 실이동은 못 잰다 → Steer 는 agent 검사에서 되돌아간다. +// 상태 전이·타깃 규칙은 PC transform 을 직접 옮겨 검증한다(원본 Get_position() 이 transform 이다). +// · table_monsterlist/localtext 가 없어 「테이블 이름」 실경로는 JSON 행 대조로만 확인한다. +// · 실제 보스 Heal 은 살아 있는 m_Stat 이 필요해(813d 미확인 ②와 같은 뿌리) 판정만 검증한다. + +using System; +using System.IO; +using System.Text; +using UnityEngine; +using WL.Combat.Boss; +using WL.Combat.Core; +using WL.Combat.Run; + +public static class WL813w_Probe +{ + const string OutCommit = "AgentScripts/WL813w_PROBE.txt"; + const string MonsterListJson = "Assets/ResWork/Table/Export/MonsterList.json"; + const string LocalizationJson = "Assets/ResWork/Table/Export/Localization.json"; + const string NaturePrefab = "Assets/Res_Addr/Map/WL_Nature.prefab"; + + static StringBuilder sb; + static int s_fail; + + static GameObject s_pcGo, s_bossGo, s_mobFarGo, s_mobNearGo; + static GameObject[] s_spawnerGo; + static PCActor s_pc; + static Actor s_boss, s_mobFar, s_mobNear; + static PCActor s_prevMyPc; + + public static object RunAll() + { + sb = new StringBuilder(); + s_fail = 0; + sb.AppendLine("# WL813w Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)"); + sb.AppendLine("playMode=" + Application.isPlaying); + + try + { + Setup(); + Sec1_Asset(); + Sec2_Anchor(); + Sec3_ZoneMove(); + Sec4_TargetRule(); + Sec5_Boss(); + Sec6_Hit(); + Sec7_LeashHeal(); + Sec8_BossName(); + Sec9_GC(); + Sec10_C8(); + } + catch (Exception ex) { L("[FAIL] 예외 — " + ex.GetType().Name + ": " + ex.Message); L(ex.StackTrace); s_fail++; } + finally { Teardown(); } + + L(""); + L(s_fail == 0 ? "RESULT PASS" : "RESULT FAIL " + s_fail); + var text = sb.ToString(); + try { File.WriteAllText(OutCommit, text, new UTF8Encoding(false)); } catch { } + Debug.Log(text); + return text; + } + + // ───────────────────────────────────────── 유틸 + static void L(string m) { sb.AppendLine(m); } + static string P(bool ok) { if (!ok) s_fail++; return ok ? "[PASS]" : "[FAIL]"; } + static long Alloc() { return GC.GetTotalMemory(false); } + static string V(Vector3 v) { return "(" + v.x.ToString("F1") + "," + v.y.ToString("F1") + "," + v.z.ToString("F1") + ")"; } + static float DXZ(Vector3 a, Vector3 b) { float dx = a.x - b.x, dz = a.z - b.z; return Mathf.Sqrt(dx * dx + dz * dz); } + + static Actor MakeActor(string name, eRole role, eSubRol sub, Vector3 pos, out GameObject go) + { + go = new GameObject("__WL813w_" + name); + go.transform.position = pos; + var a = go.AddComponent(); + a.m_Role = role; a.m_SubRole = sub; + return a; + } + + static void Setup() + { + s_prevMyPc = MyValue.MyPC; + + s_pcGo = new GameObject("__WL813w_PC"); + s_pcGo.transform.position = Vector3.zero; + s_pc = s_pcGo.AddComponent(); + s_pc.m_Role = eRole.PC; s_pc.m_SubRole = eSubRol.None; // m_Enemy 기본 false → IsMainPC() true + MyValue.MyPC = s_pc; + + s_boss = MakeActor("Anubis(Clone)", eRole.Mob, eSubRol.Boss, Vector3.zero, out s_bossGo); + s_mobFar = MakeActor("mobFar", eRole.Mob, eSubRol.None, Vector3.zero, out s_mobFarGo); + s_mobNear = MakeActor("mobNear", eRole.Mob, eSubRol.None, Vector3.zero, out s_mobNearGo); + + RunGuide.ResetDiagnostics(); + WLRunGuideSettings.RuntimeDisabled = false; + + L(""); + L("── ⓪ 준비"); + L(" " + P(s_pc.IsMainPC()) + " 가짜 메인 PC IsMainPC()=true · MyValue.MyPC 등록"); + L(" " + P(RunGuide.Active) + " RunGuide.Active(에셋 · enabled · 런 축)"); + L(" Agent=" + (s_pc.Get_Agent() == null ? "없음(에디트 모드 · Steer 는 여기서 되돌아간다)" : "있음")); + } + + static void Teardown() + { + try + { + RunGuide.StopGuide("probe teardown"); + RunGuide.ResetDiagnostics(); + WLRunGuideSettings.RuntimeDisabled = false; + MyValue.MyPC = s_prevMyPc; + Kill(s_pcGo, s_bossGo, s_mobFarGo, s_mobNearGo); + if (s_spawnerGo != null) for (int i = 0; i < s_spawnerGo.Length; i++) Kill(s_spawnerGo[i]); + s_spawnerGo = null; + } + catch { } + } + + static void Kill(params GameObject[] gos) + { + for (int i = 0; i < gos.Length; i++) + if (gos[i] != null) UnityEngine.Object.DestroyImmediate(gos[i]); + } + + // ───────────────────────────────────────── ① 에셋 · SOT + static void Sec1_Asset() + { + L(""); + L("── ① 에셋 · SOT"); + var c = WLRunGuideSettings.Instance; + L(" " + P(c != null) + " Resources.Load(\"" + WLRunGuideSettings.ResourcesPath + "\")"); + if (c == null) return; + L(" enabled=" + c.enabled + " verboseLog=" + c.verboseLog + " pollSeconds=" + c.pollSeconds); + L(" zoneGuideEnabled=" + c.zoneGuideEnabled + " guideOnRunStart=" + c.guideOnRunStart + + " zoneArriveRadius=" + c.zoneArriveRadius + " zoneHoldRadius=" + c.zoneHoldRadius); + L(" guideLeashRadius=" + c.guideLeashRadius + " guideStopDistance=" + c.guideStopDistance + + " repathIntervalSeconds=" + c.repathIntervalSeconds); + L(" resumeAfterHitSeconds=" + c.resumeAfterHitSeconds + " maxTravelSeconds=" + c.maxTravelSeconds); + L(" bossGuideEnabled=" + c.bossGuideEnabled + " bossPriorityEnabled=" + c.bossPriorityEnabled + + " bossArriveRadius=" + c.bossArriveRadius + " bossHoldRadius=" + c.bossHoldRadius + + " bossLockTarget=" + c.bossLockTarget); + L(" zoneAnchorFallbacks=" + (c.zoneAnchorFallbacks == null ? 0 : c.zoneAnchorFallbacks.Length) + "개"); + + var b = WLBossArenaSettings.Instance; + L(" " + P(b != null) + " WLBossArenaSettings 로드"); + if (b == null) return; + L(" leashHealPercent=" + b.leashHealPercent + " leashHealCooldownSeconds=" + b.leashHealCooldownSeconds + + " bossNameFromTable=" + b.bossNameFromTable + " (기존 resetHealsBoss=" + b.resetHealsBoss + " resetHealRate=" + b.resetHealRate + ")"); + L(" " + P(b.leashHealPercent > 0f && b.leashHealPercent < 1f) + " 리쉬 부분 회복 구간(0 0) + L(" " + P(!RunGuide.AnchorFromNode(0) && a0 == c.zoneAnchorFallbacks[0]) + " 폴백 값 일치"); + + // (b) 가짜 스포너 노드를 심고 다시 읽는다 = 노드 실좌표 우선 + s_spawnerGo = new GameObject[n]; + for (int i = 0; i < n; i++) + { + s_spawnerGo[i] = new GameObject("__WL813w_Spawner" + rs.zoneSpawnerIds[i]); + s_spawnerGo[i].transform.position = new Vector3(1000f + i, 0f, 2000f + i); + var m = s_spawnerGo[i].AddComponent(); + m.SpawnerID = rs.zoneSpawnerIds[i]; + } + RunGuide.ResetDiagnostics(); + Vector3 a1; + bool got1 = RunGuide.TryGetAnchor(c, 1, out a1); + L(" " + P(got1 && RunGuide.AnchorFromNode(1) && a1 == s_spawnerGo[1].transform.position) + + " 존1 앵커 = " + V(a1) + " · 노드 실좌표 우선(스캔 " + RunGuide.AnchorScans + "회)"); + + // (c) WL_Nature.prefab 실좌표와 SO 폴백 대조 (원본 프리팹은 읽기만 한다) + L(" 프리팹 실측 대조(" + NaturePrefab + "):"); + for (int i = 0; i < n && i < (c.zoneAnchorFallbacks == null ? 0 : c.zoneAnchorFallbacks.Length); i++) + { + Vector3 pf; + bool found = PrefabSpawnerPos(rs.zoneSpawnerIds[i], out pf); + bool same = found && DXZ(pf, c.zoneAnchorFallbacks[i]) < 0.5f; + L(" " + P(found && same) + " " + rs.zoneSpawnerIds[i] + " 프리팹 " + (found ? V(pf) : "없음") + + " ↔ SO 폴백 " + V(c.zoneAnchorFallbacks[i])); + } + } + + /// WL_Nature.prefab 을 텍스트로 읽어 SpawnerID 노드의 월드 좌표를 구한다(프리팹 무수정). + static bool PrefabSpawnerPos(int spawnerId, out Vector3 pos) + { + pos = Vector3.zero; + try + { + var go = UnityEditor.AssetDatabase.LoadAssetAtPath(NaturePrefab); + if (go == null) return false; + var mgrs = go.GetComponentsInChildren(true); + for (int i = 0; i < mgrs.Length; i++) + if (mgrs[i].SpawnerID == spawnerId) { pos = mgrs[i].transform.position; return true; } + } + catch { } + return false; + } + + // ───────────────────────────────────────── ③ 존 이동 + static void Sec3_ZoneMove() + { + L(""); + L("── ③ 존 이동 (ZoneCleared → 다음 존 · 도착 · 이탈 복귀)"); + var c = WLRunGuideSettings.Instance; + RunGuide.ResetDiagnostics(); + + Vector3 anchor1; + RunGuide.TryGetAnchor(c, 1, out anchor1); + + // 존0 클리어 → 존1 유도 + RunGuide.BeginZone(1, "probe ZoneCleared(0)"); + L(" " + P(RunGuide.DestKind == RunGuideDest.Zone && RunGuide.ZoneIndex == 1) + " 목적지 = 존1(Zone)"); + L(" " + P(DXZ(RunGuide.Destination, anchor1) < 0.01f) + " 목적지 좌표 = " + V(RunGuide.Destination)); + L(" " + P(RunGuide.State == RunGuideState.Travel) + " 상태 Travel · GuideBegun=" + RunGuide.GuideBegun); + + // 멀리 있을 때 = Travel 유지 + s_pcGo.transform.position = anchor1 + new Vector3(0f, 0f, 30f); + RunGuide.ForceTick(); + L(" " + P(RunGuide.State == RunGuideState.Travel) + " 30 m — Travel 유지(조종 시도 · Repaths=" + RunGuide.Repaths + ")"); + + // 도착 + s_pcGo.transform.position = anchor1 + new Vector3(0f, 0f, c.zoneArriveRadius - 1f); + RunGuide.ForceTick(); + L(" " + P(RunGuide.State == RunGuideState.Hold && RunGuide.Arrived == 1) + + " " + (c.zoneArriveRadius - 1f).ToString("F1") + " m — 도착 → Hold(자동전투 재개) · Arrived=" + RunGuide.Arrived); + L(" " + P(!RunGuide.Steering) + " 도착 뒤 조종 해제(JoystickStatus=" + s_pc.JoystickStatus + ")"); + + // Hold 유지 범위 + s_pcGo.transform.position = anchor1 + new Vector3(0f, 0f, c.zoneHoldRadius - 2f); + RunGuide.ForceTick(); + L(" " + P(RunGuide.State == RunGuideState.Hold && RunGuide.HoldPullbacks == 0) + + " " + (c.zoneHoldRadius - 2f).ToString("F1") + " m — Hold 유지(끌어오지 않는다)"); + + // 이탈 → 되돌림 + s_pcGo.transform.position = anchor1 + new Vector3(0f, 0f, c.zoneHoldRadius + 5f); + RunGuide.ForceTick(); + L(" " + P(RunGuide.State == RunGuideState.Travel && RunGuide.HoldPullbacks == 1) + + " " + (c.zoneHoldRadius + 5f).ToString("F1") + " m — 이탈 → 다시 유도 · HoldPullbacks=" + RunGuide.HoldPullbacks); + L(" 로그=" + RunGuide.LastLog); + } + + // ───────────────────────────────────────── ④ 타깃 규칙 + static void Sec4_TargetRule() + { + L(""); + L("── ④ 타깃 규칙 (Travel 전면 해제 · Hold 는 앵커에서 먼 것만 무시)"); + var c = WLRunGuideSettings.Instance; + RunGuide.ResetDiagnostics(); + + Vector3 anchor1; + RunGuide.TryGetAnchor(c, 1, out anchor1); + RunGuide.BeginZone(1, "probe target rule"); + + // Travel 중: 어떤 타깃이든 놓는다 + s_pcGo.transform.position = anchor1 + new Vector3(0f, 0f, 25f); + s_mobNearGo.transform.position = anchor1 + new Vector3(0f, 0f, 24f); // PC 코앞이지만 이동 중 + s_pc.Set_Target(s_mobNear); + RunGuide.ForceTick(); + L(" " + P(!s_pc.isTarget && RunGuide.FarTargetIgnored == 1) + + " Travel 중 타깃 해제(무시 " + RunGuide.FarTargetIgnored + " · 마지막=" + RunGuide.LastIgnoredTarget + ")"); + + // 도착 → Hold + s_pcGo.transform.position = anchor1; + RunGuide.ForceTick(); + L(" " + P(RunGuide.State == RunGuideState.Hold) + " 도착 → Hold"); + + // Hold: 앵커에서 먼 타깃 = 무시 + int before = RunGuide.FarTargetIgnored; + s_mobFarGo.transform.position = anchor1 + new Vector3(0f, 0f, c.guideLeashRadius + 8f); + s_pc.Set_Target(s_mobFar); + RunGuide.ForceTick(); + L(" " + P(!s_pc.isTarget && RunGuide.FarTargetIgnored == before + 1) + + " 앵커 +" + (c.guideLeashRadius + 8f).ToString("F0") + " m 타깃 무시(Del_Target)"); + + // Hold: 앵커 가까이의 타깃 = 유지(원본 자동전투가 잡는다) + before = RunGuide.FarTargetIgnored; + s_mobNearGo.transform.position = anchor1 + new Vector3(0f, 0f, c.guideLeashRadius - 4f); + s_pc.Set_Target(s_mobNear); + RunGuide.ForceTick(); + L(" " + P(s_pc.isTarget && RunGuide.FarTargetIgnored == before) + + " 앵커 +" + (c.guideLeashRadius - 4f).ToString("F0") + " m 타깃 유지(원본 자동전투)"); + + s_pc.Del_Target(true); + } + + // ───────────────────────────────────────── ⑤ 보스 + static void Sec5_Boss() + { + L(""); + L("── ⑤ 보스 (아레나 유도 · 우선 타깃 고정)"); + var c = WLRunGuideSettings.Instance; + RunGuide.ResetDiagnostics(); + + Vector3 center = BossArena.Center; + RunGuide.BeginBoss("probe BossGateOpened"); + L(" " + P(RunGuide.DestKind == RunGuideDest.Boss) + " 목적지 = 보스 아레나 " + V(RunGuide.Destination) + + " (BossArena.Center=" + V(center) + ")"); + + // 보스 등장 → 아레나 밖에서 시작 + s_bossGo.transform.position = center; + RunGuide.SetBossForProbe(s_boss); + s_pcGo.transform.position = center + new Vector3(0f, 0f, 25f); + s_pc.Set_Target(s_mobFar); + s_mobFarGo.transform.position = center + new Vector3(0f, 0f, 40f); + RunGuide.ForceTick(); + L(" " + P(RunGuide.State == RunGuideState.Travel) + " 25 m — Travel(아레나로 유도)"); + L(" " + P(ReferenceEquals(s_pc.Get_Target(), s_boss) && RunGuide.BossRetargets >= 1) + + " 타깃 = 보스 고정(BossRetargets=" + RunGuide.BossRetargets + ")"); + L(" " + P(!c.bossLockTarget || s_boss.m_IsLockTarget) + + " 원본 락타깃 m_IsLockTarget=" + s_boss.m_IsLockTarget + "(BossLocks=" + RunGuide.BossLocks + ")"); + + // 잡몹으로 타깃이 새면 폴링이 되돌린다 + s_pc.Set_Target(s_mobFar); + RunGuide.ForceTick(); + L(" " + P(ReferenceEquals(s_pc.Get_Target(), s_boss)) + " 잡몹 타깃 전환 → 다음 폴링에서 보스로 복귀"); + + // 아레나 도착 + s_pcGo.transform.position = center + new Vector3(0f, 0f, c.bossArriveRadius - 2f); + RunGuide.ForceTick(); + L(" " + P(RunGuide.State == RunGuideState.Hold) + " " + (c.bossArriveRadius - 2f).ToString("F1") + " m — 도착 → Hold"); + + // 아레나 이탈(리쉬 발동선 전에) → 되돌림 + var b = WLBossArenaSettings.Instance; + float leashLine = b != null ? b.arenaRadius + b.playerLeaveMargin : 24f; + s_pcGo.transform.position = center + new Vector3(0f, 0f, c.bossHoldRadius + 2f); + RunGuide.ForceTick(); + L(" " + P(RunGuide.State == RunGuideState.Travel && RunGuide.HoldPullbacks == 1) + + " " + (c.bossHoldRadius + 2f).ToString("F1") + " m 이탈 → 되돌림(리쉬 발동선 " + leashLine.ToString("F0") + " m 前)"); + L(" " + P(c.bossHoldRadius + 2f < leashLine) + " 되돌림 지점 < 리쉬 발동선 = 리셋이 아예 안 걸린다(D-11)"); + + // 보스 사망 → 락 해제 + s_bossGo.SetActive(false); // IsDead() = !activeInHierarchy + RunGuide.ForceTick(); + L(" " + P(!s_boss.m_IsLockTarget) + " 보스 사망/비활성 → 락타깃 해제"); + s_bossGo.SetActive(true); + RunGuide.StopGuide("probe ⑤ 종료"); + } + + // ───────────────────────────────────────── ⑥ 피격 응전 + static void Sec6_Hit() + { + L(""); + L("── ⑥ 피격 → 즉시 응전(가이드 일시 중단)"); + var c = WLRunGuideSettings.Instance; + RunGuide.ResetDiagnostics(); + + Vector3 anchor1; + RunGuide.TryGetAnchor(c, 1, out anchor1); + RunGuide.BeginZone(1, "probe hit"); + s_pcGo.transform.position = anchor1 + new Vector3(0f, 0f, 25f); + s_mobFarGo.transform.position = anchor1 + new Vector3(0f, 0f, 40f); + + RunGuide.SimulateHit(); + s_pc.Set_Target(s_mobFar); + int before = RunGuide.FarTargetIgnored; + RunGuide.ForceTick(); + L(" " + P(s_pc.isTarget && RunGuide.FarTargetIgnored == before && !RunGuide.Steering) + + " 피격 중 — 타깃 유지 · 조종 0(원본 자동전투가 응전) · HitSuspends=" + RunGuide.HitSuspends); + L(" " + P(RunGuide.DestKind == RunGuideDest.Zone) + " 목적지는 유지(중단일 뿐 취소 아님)"); + + RunGuide.ClearHitSuspendForProbe(); + RunGuide.ForceTick(); + L(" " + P(!s_pc.isTarget && RunGuide.FarTargetIgnored == before + 1) + " 무피격 구간 복귀 → 가이드 재개"); + RunGuide.StopGuide("probe ⑥ 종료"); + } + + // ───────────────────────────────────────── ⑦ 리쉬 회복 + static void Sec7_LeashHeal() + { + L(""); + L("── ⑦ 리쉬 회복 판정 (임시 SO · 에셋 무수정)"); + var tmp = ScriptableObject.CreateInstance(); + try + { + tmp.resetHealsBoss = true; + tmp.leashHealPercent = 0.15f; + tmp.leashHealCooldownSeconds = 10f; + BossArena.ResetLeashState(); + + int v1 = BossArena.EvaluateLeashHeal(tmp, 100f); + int v2 = BossArena.EvaluateLeashHeal(tmp, 105f); + int v3 = BossArena.EvaluateLeashHeal(tmp, 111f); + L(" " + P(v1 == BossArena.kHealPartial) + " t=100 첫 리쉬 → 부분 회복(+15 %)"); + L(" " + P(v2 == BossArena.kHealCooldown) + " t=105 (5 s 뒤) → 쿨다운으로 건너뜀 = 만피 복구 없음"); + L(" " + P(v3 == BossArena.kHealPartial) + " t=111 (11 s 뒤) → 다시 부분 회복 · 최대 1회/10 s"); + + tmp.leashHealPercent = 1f; + BossArena.ResetLeashState(); + L(" " + P(BossArena.EvaluateLeashHeal(tmp, 200f) == BossArena.kHealFull) + + " leashHealPercent=1 → 813d 원래 동작(On_Regen 만피) = C8 롤백 경로"); + + tmp.leashHealPercent = 0.15f; tmp.resetHealsBoss = false; + BossArena.ResetLeashState(); + L(" " + P(BossArena.EvaluateLeashHeal(tmp, 300f) == BossArena.kHealNone) + " resetHealsBoss=0 → 회복 없음(워프만)"); + + // Q4b 실측 대비: 69회 리쉬 · 90 s 보스전이면 회복은 최대 몇 회인가 + var real = WLBossArenaSettings.Instance; + if (real != null && real.leashHealCooldownSeconds > 0f) + { + int maxHeals = Mathf.FloorToInt(90f / real.leashHealCooldownSeconds) + 1; + L(" 참고: Q4b 런① = 리쉬 69회 / 보스전 90 s → 이 값으로는 최대 " + maxHeals + + "회 × " + (real.leashHealPercent * 100f).ToString("F0") + "% (예전 = 69회 × 100 %)"); + } + } + finally { UnityEngine.Object.DestroyImmediate(tmp); BossArena.ResetLeashState(); } + } + + // ───────────────────────────────────────── ⑧ 보스 바 이름 + static void Sec8_BossName() + { + L(""); + L("── ⑧ 보스 HP 바 이름 (D-12 · Anubis(Clone))"); + L(" " + P(WL.UI.BossHpBar.StripClone("Anubis(Clone)") == "Anubis") + " StripClone(\"Anubis(Clone)\") = \"" + + WL.UI.BossHpBar.StripClone("Anubis(Clone)") + "\""); + L(" " + P(WL.UI.BossHpBar.StripClone("Anubis (Clone)") == "Anubis") + " StripClone(\"Anubis (Clone)\") = \"" + + WL.UI.BossHpBar.StripClone("Anubis (Clone)") + "\""); + + // 가짜 보스 오브젝트 이름 = "__WL813w_Anubis(Clone)"(프로브 접두사 + QA 가 본 이름) + string objName = s_boss.name; + string label = WL.UI.BossHpBar.ResolveBossLabel(s_boss); + L(" " + P(label == objName.Replace("(Clone)", "") && !label.Contains("(Clone)")) + + " 표가 없는 에디트 모드 → 폴백 = \"" + label + "\" (원본 오브젝트 이름 \"" + objName + + "\" · 경로=" + WL.UI.BossHpBar.LastNameSource + ")"); + + var tmp = ScriptableObject.CreateInstance(); + try + { + tmp.bossNameFromTable = false; + string raw = WL.UI.BossHpBar.ResolveBossLabel(s_boss, tmp); + L(" " + P(raw == objName) + " C8 롤백(bossNameFromTable=0) → 원본 이름 그대로 \"" + raw + "\""); + } + finally { UnityEngine.Object.DestroyImmediate(tmp); } + + // 인게임에서 실제로 나올 이름 = MonsterList → Localization 실측 + var b = WLBossArenaSettings.Instance; + int mid = b != null ? b.bossMonsterId : 10006; + string nameId = JsonField(MonsterListJson, "n_MonsterID", mid.ToString(), "n_MonsterName"); + string kor = nameId != null ? JsonField(LocalizationJson, "ID", nameId, "s_Korean") : null; + L(" " + P(!string.IsNullOrEmpty(kor)) + " MonsterList " + mid + " → n_MonsterName=" + nameId + + " → Localization s_Korean=\"" + (kor ?? "없음") + "\" (인게임 라벨 기대값 · 813tj 배너와 동일)"); + } + + /// Export JSON 에서 keyField=keyValue 인 행의 outField 를 읽는다(표 로드 없이 실측). + static string JsonField(string path, string keyField, string keyValue, string outField) + { + try + { + string txt = File.ReadAllText(path); + string key = "\"" + keyField + "\":\"" + keyValue + "\""; + int i = txt.IndexOf(key, StringComparison.Ordinal); + if (i < 0) { key = "\"" + keyField + "\": \"" + keyValue + "\""; i = txt.IndexOf(key, StringComparison.Ordinal); } + if (i < 0) return null; + int rowStart = txt.LastIndexOf('{', i); + int rowEnd = txt.IndexOf('}', i); + if (rowStart < 0 || rowEnd < 0) return null; + string row = txt.Substring(rowStart, rowEnd - rowStart); + int j = row.IndexOf("\"" + outField + "\"", StringComparison.Ordinal); + if (j < 0) return null; + int q1 = row.IndexOf('"', row.IndexOf(':', j) + 1); + int q2 = row.IndexOf('"', q1 + 1); + if (q1 < 0 || q2 < 0) return null; + return row.Substring(q1 + 1, q2 - q1 - 1); + } + catch { return null; } + } + + // ───────────────────────────────────────── ⑨ GC + static void Sec9_GC() + { + L(""); + L("── ⑨ GC (ForceTick 100회)"); + var c = WLRunGuideSettings.Instance; + RunGuide.ResetDiagnostics(); + Vector3 anchor1; + RunGuide.TryGetAnchor(c, 1, out anchor1); + RunGuide.BeginZone(1, "probe gc"); + s_pcGo.transform.position = anchor1 + new Vector3(0f, 0f, 8f); // Hold 도 Travel 도 아닌 중간 = 매 틱 판정 + RunGuide.ForceTick(); + + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + long a0 = Alloc(); + for (int i = 0; i < 100; i++) RunGuide.ForceTick(); + long a1 = Alloc(); + L(" " + P(a1 - a0 <= 0) + " ForceTick ×100 = " + (a1 - a0) + " B"); + + long b0 = Alloc(); + var junk = new string[256]; + for (int i = 0; i < junk.Length; i++) junk[i] = new string('x', 4096); + long b1 = Alloc(); + L(" 대조군(의도적 할당 256×4096자) = " + (b1 - b0) + " B · " + P(b1 - b0 > 0) + " 측정기 동작 확인"); + GC.KeepAlive(junk); + RunGuide.StopGuide("probe ⑨ 종료"); + } + + // ───────────────────────────────────────── ⑩ C8 + static void Sec10_C8() + { + L(""); + L("── ⑩ C8 롤백 (RuntimeDisabled → 원본 자동전투 100%)"); + var c = WLRunGuideSettings.Instance; + RunGuide.ResetDiagnostics(); + Vector3 anchor1; + RunGuide.TryGetAnchor(c, 1, out anchor1); + RunGuide.BeginZone(1, "probe c8"); + s_pcGo.transform.position = anchor1 + new Vector3(0f, 0f, 30f); + s_mobFarGo.transform.position = anchor1 + new Vector3(0f, 0f, 60f); + s_pc.Set_Target(s_mobFar); + + WLRunGuideSettings.RuntimeDisabled = true; + int before = RunGuide.FarTargetIgnored; + var st = RunGuide.State; + for (int i = 0; i < 10; i++) RunGuide.ForceTick(); + L(" " + P(!RunGuide.Active) + " RuntimeDisabled → Active=false"); + L(" " + P(RunGuide.FarTargetIgnored == before && s_pc.isTarget) + " 틱 ×10 — 타깃 무수정(원본 그대로)"); + L(" " + P(!RunGuide.Steering && s_pc.JoystickStatus == 0) + " 조종 0(JoystickStatus=" + s_pc.JoystickStatus + ")"); + L(" " + P(RunGuide.State == st) + " 상태 불변"); + + WLRunGuideSettings.RuntimeDisabled = false; + RunGuide.ForceTick(); + L(" " + P(RunGuide.FarTargetIgnored == before + 1) + " 스위치 복귀 → 다시 동작"); + RunGuide.StopGuide("probe ⑩ 종료"); + s_pc.Del_Target(true); + } +} diff --git a/Assets/WL/Combat/Boss/BossArena.cs b/Assets/WL/Combat/Boss/BossArena.cs index 2d8ab3824..7a079dec5 100644 --- a/Assets/WL/Combat/Boss/BossArena.cs +++ b/Assets/WL/Combat/Boss/BossArena.cs @@ -462,15 +462,63 @@ namespace WL.Combat.Boss LastLeashDistance.ToString("F1") + "m"); } - /// 보스를 아레나 중심으로 되돌린다. 회복은 reincarnation:true 라 Spawned 가 다시 나지 않는다. + /// 마지막 부분 회복 시각(unscaled · 쿨다운 판정). + static float s_lastLeashHeal = -999f; + /// 리쉬 회복을 실제로 넣은 횟수 · 쿨다운으로 건너뛴 횟수(진단). + public static int LeashHealCount, LeashHealSkipped; + /// 마지막 리쉬 회복량(HP · 진단). + public static double LastLeashHeal; + + /// 리쉬 회복 판정 — 0 없음 · 1 만피(레거시) · 2 부분 회복 · 3 쿨다운으로 건너뜀. + public const int kHealNone = 0, kHealFull = 1, kHealPartial = 2, kHealCooldown = 3; + + /// + /// 리쉬 회복 판정(Actor 접근 0 · 프로브가 가짜 시각으로 그대로 부를 수 있다). + /// 부분 회복으로 판정되면 그 시각을 쿨다운 기준으로 기록한다(호출측이 실제 Heal 을 한다). + /// + public static int EvaluateLeashHeal(WLBossArenaSettings cfg, float now) + { + if (cfg == null || !cfg.resetHealsBoss) return kHealNone; + if (cfg.leashHealPercent >= 1f) return kHealFull; + if (cfg.leashHealPercent <= 0f) return kHealNone; + if (now - s_lastLeashHeal < cfg.leashHealCooldownSeconds) { LeashHealSkipped++; return kHealCooldown; } + s_lastLeashHeal = now; + LeashHealCount++; + return kHealPartial; + } + + /// + /// 보스를 아레나 중심으로 되돌린다. + /// 🔴 WL-813w §1-3 (Q4b D-11): 예전에는 매번 `On_Regen(true, 1)` = **만피 복구**였고 한 런에서 69회 + /// 일어나 보스 HP 가 한 칸도 줄지 않았다. 이제 기본은 `leashHealPercent`(0.15) **부분 회복**이고 + /// `leashHealCooldownSeconds`(10 s) 안에서는 회복을 건너뛴다(워프·타깃 해제는 그대로). + /// `leashHealPercent >= 1` 로 두면 813d 원래 동작(On_Regen 만피)으로 정확히 되돌아간다(C8). + /// static void ResetBoss(WLBossArenaSettings cfg, Vector3 center, string reason) { if (DSUtil.CheckNull(s_boss)) return; s_boss.Del_Target(true); - if (cfg.resetHealsBoss) s_boss.On_Regen(true, cfg.resetHealRate); + + string healInfo; + switch (EvaluateLeashHeal(cfg, Application.isPlaying ? Time.unscaledTime : 0f)) + { + case kHealFull: + s_boss.On_Regen(true, cfg.resetHealRate); // 813d 원래 동작(만피 리셋) + healInfo = "만피(레거시)"; + break; + case kHealPartial: + double before = s_boss.Get_HP(); + s_boss.Heal(cfg.leashHealPercent, false); // Actor.cs:781 — 최대 HP × 비율만큼 「더한다」 + LastLeashHeal = s_boss.Get_HP() - before; + healInfo = "+" + (cfg.leashHealPercent * 100f).ToString("F0") + "%"; + break; + case kHealCooldown: healInfo = "쿨다운(건너뜀)"; break; + default: healInfo = "회복 없음"; break; + } + s_boss.Set_Warp(center); ResetCount++; - Log("보스 리셋 #" + ResetCount + " — " + reason); + Log("보스 리셋 #" + ResetCount + " — " + reason + " · " + healInfo); } // ───────────────────────────────────────────────────────────────────── @@ -573,6 +621,7 @@ namespace WL.Combat.Boss s_chaseBefore = s_chaseAfter = 0f; BossSpawned = BossKilled = CleanupDone = false; TotalZoneKills = ResetCount = ReleaseCount = HoldCount = PassThroughCount = 0; + LeashHealCount = LeashHealSkipped = 0; LastLeashHeal = 0d; s_lastLeashHeal = -999f; LastLog = ""; } @@ -591,7 +640,7 @@ namespace WL.Combat.Boss public static void SetBossForProbe(MobActor boss) { s_boss = boss; } /// 프로브용 — PC 이탈 유예 타이머만 비운다(리쉬 시나리오 사이 초기화). - public static void ResetLeashState() { s_playerOutsideSince = -1f; s_leashFaulted = false; } + public static void ResetLeashState() { s_playerOutsideSince = -1f; s_leashFaulted = false; s_lastLeashHeal = -999f; } /// PC 가 아레나 밖으로 나간 시각(unscaled · 안이면 −1). public static float PlayerOutsideSince { get { return s_playerOutsideSince; } } diff --git a/Assets/WL/Combat/Boss/WLBossArenaSettings.cs b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs index 973e70437..b44fa6374 100644 --- a/Assets/WL/Combat/Boss/WLBossArenaSettings.cs +++ b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs @@ -157,12 +157,27 @@ namespace WL.Combat.Boss [Tooltip("PC 이탈이 이 시간(초 · unscaled) 이상 이어져야 리셋한다. 경계에서 왔다 갔다 할 때 리셋이 튀는 것을 막는다.")] public float playerLeaveGraceSeconds = 1.5f; - [Tooltip("리셋 때 보스를 회복시킨다. On_Regen(reincarnation: true) 라 Spawned 이벤트는 다시 나지 않는다(연출 재발동 방지).")] + [Tooltip("리셋 때 보스를 회복시킨다. 끄면 워프·타깃 해제만 한다(회복 0).")] public bool resetHealsBoss = true; - [Tooltip("리셋 회복 비율(1 = 풀피).")] + [Tooltip("만피 리셋(On_Regen) 회복 비율(1 = 풀피). leashHealPercent >= 1 일 때만 쓰인다.")] public float resetHealRate = 1f; + // ── WL-813w §1-3 (Q4b D-11): 리쉬 1회당 만피 복구가 69회 = 보스 HP 가 절대 안 준다 → 부분 회복으로 완화 + [Tooltip("리쉬 리셋 때의 회복량 = 최대 HP 대비 비율(Actor.Heal(rate) · 누적 회복). " + + "🔴 1 이상이면 813d 원래 동작(On_Regen 만피 리셋 = C8 롤백). 0 이면 회복 없이 워프만.")] + public float leashHealPercent = 0.15f; + + [Tooltip("부분 회복의 최소 간격(초 · unscaled). 이 안에 리쉬가 다시 걸리면 워프·타깃 해제만 하고 회복은 건너뛴다(최대 1회/10 s).")] + public float leashHealCooldownSeconds = 10f; + + // ─────────────────────────────────────────── 보스 이름 (D-12) + [Header("보스 이름 (D-12 · HP 바 라벨)")] + [Tooltip("보스 HP 바 라벨을 MonsterList.n_MonsterName(테이블 이름 · 813tj 배너와 같은 경로)으로 쓴다. " + + "끄면 원본 gameObject.name 그대로(= 「Anubis(Clone)」). " + + "🔴 발주서 §2 가 UI 수정을 BossHpBar.cs 1파일로 묶어 UI 설정 SO 에 필드를 넣을 수 없어 여기 둔다(bossMonsterId 폴백과 같은 자리).")] + public bool bossNameFromTable = true; + // ─────────────────────────────────────────── 처치 후 정리 [Header("처치 후 정리")] [Tooltip("보스 처치 후 FieldBossData 의 RegenTime 재등장 코루틴을 끊는다(발주서 §1-3 「리젠 없음」).")] diff --git a/Assets/WL/Combat/Run/RunGuide.cs b/Assets/WL/Combat/Run/RunGuide.cs new file mode 100644 index 000000000..698db1c84 --- /dev/null +++ b/Assets/WL/Combat/Run/RunGuide.cs @@ -0,0 +1,585 @@ +// ───────────────────────────────────────────────────────────────────────────── +// 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 +{ + /// 가이드 상태. + public enum RunGuideState + { + /// 목적지 없음(원본 100%). + Off = 0, + /// 목적지로 이동 중(조종 ON). + Travel = 1, + /// 도착 — 그 자리에서 원본 자동전투(조종 OFF · 이탈만 감시). + Hold = 2, + } + + /// 목적지 종류. + public enum RunGuideDest + { + None = 0, + /// 존 스포너 앵커. + Zone = 1, + /// 보스 아레나 중심. + Boss = 2, + } + + /// 런 진행 가이드. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개) · GC 0. + public static class RunGuide + { + static WLRunGuideSettings Cfg { get { return WLRunGuideSettings.Instance; } } + + /// 가이드가 살아 있는가(에셋 · enabled · 런타임 스위치 · 런 축). false 면 원본 자동전투 100%. + public static bool Active { get { return WLRunGuideSettings.Enabled; } } + + // ───────────────────────────────────────── 상태 (프로브가 읽는다) + public static RunGuideState State { get { return s_state; } } + public static RunGuideDest DestKind { get { return s_destKind; } } + /// 존 목적지일 때의 존 인덱스(0부터). 보스면 −1. + public static int ZoneIndex { get { return s_zoneIndex; } } + /// 현재 목적지(월드). 보스면 BossArena.Center 를 매 틱 다시 읽는다. + public static Vector3 Destination { get { return s_dest; } } + /// 지금 WL 이 PC 를 조종 중인가(JoystickStatus = 1). + public static bool Steering { get { return s_steering; } } + /// 구독 상태. + public static bool Subscribed { get { return s_subscribed; } } + /// 고장(예외) 상태 — 이후 아무 일도 하지 않는다. + public static bool Faulted { get { return s_faulted; } } + + // ───────────────────────────────────────── 진단 카운터 + public static int GuideBegun, Arrived, GaveUp, FarTargetIgnored, HoldPullbacks, + Repaths, BossRetargets, BossLocks, HitSuspends, AnchorScans; + public static string LastLog = ""; + /// 마지막으로 무시한 타깃 이름(진단). + 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 h_runStarted = OnRunStarted; + static readonly CombatHandler h_zoneCleared = OnZoneCleared; + static readonly CombatHandler h_gate = OnBossGateOpened; + static readonly CombatHandler h_bossStarted = OnBossStarted; + static readonly CombatHandler h_runEnded = OnRunEnded; + static readonly CombatHandler h_damaged = OnDamaged; + + // ───────────────────────────────────────────────────────────────────── + // 구독 + // ───────────────────────────────────────────────────────────────────── + + /// RunEvents · CombatEvents.Damaged 구독(중복 안전). + public static void EnsureSubscribed() + { + if (s_subscribed) return; + s_subscribed = true; + RunEvents.RunStarted.Add(h_runStarted); + RunEvents.ZoneCleared.Add(h_zoneCleared); + RunEvents.BossGateOpened.Add(h_gate); + RunEvents.BossStarted.Add(h_bossStarted); + RunEvents.RunEnded.Add(h_runEnded); + CombatEvents.Damaged.Add(h_damaged); + } + + /// 구독 해제(A/B · 프로브). + public static void Unsubscribe() + { + if (!s_subscribed) return; + s_subscribed = false; + RunEvents.RunStarted.Remove(h_runStarted); + RunEvents.ZoneCleared.Remove(h_zoneCleared); + RunEvents.BossGateOpened.Remove(h_gate); + RunEvents.BossStarted.Remove(h_bossStarted); + RunEvents.RunEnded.Remove(h_runEnded); + 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++; + } + + // ───────────────────────────────────────────────────────────────────── + // 목적지 지정 + // ───────────────────────────────────────────────────────────────────── + + /// 존 i 로 유도한다(프로브도 직접 부를 수 있다). + public static void BeginZone(int zoneIndex, string reason) + { + var cfg = Cfg; + if (cfg == null || !cfg.zoneGuideEnabled) return; + int n = ZoneCount(cfg); + if (zoneIndex < 0 || zoneIndex >= n) { StopGuide("존 " + zoneIndex + " 없음"); return; } + + Vector3 anchor; + if (!TryGetAnchor(cfg, zoneIndex, out anchor)) { StopGuide("존 " + zoneIndex + " 앵커 없음"); return; } + + ReleaseBossLock(); + s_destKind = RunGuideDest.Zone; + s_zoneIndex = zoneIndex; + s_dest = anchor; + EnterTravel(); + GuideBegun++; + Log("존 " + zoneIndex + " 유도 시작(" + reason + ") → " + Fmt(anchor) + + (s_anchorFromNode[zoneIndex] ? " [스포너 노드]" : " [SO 폴백]")); + } + + /// 보스 아레나로 유도한다(프로브도 직접 부를 수 있다). + public static void BeginBoss(string reason) + { + var cfg = Cfg; + if (cfg == null || !cfg.bossGuideEnabled) return; + if (s_destKind == RunGuideDest.Boss && s_state != RunGuideState.Off) return; // 이미 보스 유도 중 + s_destKind = RunGuideDest.Boss; + s_zoneIndex = -1; + s_dest = BossArena.Center; + EnterTravel(); + GuideBegun++; + Log("보스 아레나 유도 시작(" + reason + ") → " + Fmt(s_dest)); + } + + /// 가이드를 끝낸다(조종·락 해제 포함). + 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; + } + + // ───────────────────────────────────────────────────────────────────── + // 틱 + // ───────────────────────────────────────────────────────────────────── + + /// 런너가 매 프레임 부른다(에디트 모드에서는 프로브가 ForceTick 으로 부른다). + 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(); + } + + // ───────────────────────────────────────────────────────────────────── + // 조종 · 타깃 + // ───────────────────────────────────────────────────────────────────── + + /// 이동 중에는 타깃을 놓는다(보스가 목적지일 때 보스는 예외 — 그쪽으로 가는 것이 목적이다). + 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++; + } + + /// 도착 뒤: 목적지에서 guideLeashRadius 밖의 타깃만 무시한다(가까운 적은 원본이 잡는다). + static void IgnoreFarTarget(WLRunGuideSettings cfg, PCActor pc) + { + if (cfg.guideLeashRadius <= 0f || !pc.isTarget) return; + var t = pc.Get_Target(); + if (DSUtil.CheckNull(t)) return; + if (s_destKind == RunGuideDest.Boss && !DSUtil.CheckNull(s_boss) && ReferenceEquals(t, s_boss)) return; + if (DistXZ(t.Get_position(), s_dest) <= cfg.guideLeashRadius) return; + LastIgnoredTarget = t.name; + pc.Del_Target(true); + FarTargetIgnored++; + } + + /// PC 를 목적지로 민다. 원본 이동 API(NavMeshAgent)만 쓴다. + static void Steer(WLRunGuideSettings cfg, PCActor pc, float now) + { + // 🔴 에이전트 검사를 맨 앞에 둔다 — 에디트 모드(프로브)에는 NavMeshAgent 도 dic_ccData 초기화도 없어 + // IsCC() 가 KeyNotFoundException 을 던진다. 런타임 판정 순서는 어차피 결과가 같다. + var agent = pc.Get_Agent(); + if (agent == null || !agent.enabled || !agent.isOnNavMesh) return; // NavMesh 밖 — 조종 포기 + + var anim = pc.Get_CurAnim(); + if (anim == eAnim.Dodge || anim >= eAnim.Attack || pc.IsCC()) return; // 공격·스킬·회피·CC 는 원본 우선 + if (pc.JoystickStatus == 2) return; // 스킬 이동 잠금(Whirlwind 등) + + 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++; + } + } + + /// 조종을 놓는다(우리가 1 로 만든 경우에만 0 으로 되돌린다). + static void ReleaseSteer() + { + if (!s_steering) return; + s_steering = false; + var pc = MyValue.MyPC; + if (DSUtil.CheckNull(pc)) return; + if (pc.JoystickStatus == 1) pc.Set_JoystickStatus(3); // 3 = 해제(PCActor.cs:461) + } + + // ───────────────────────────────────────────────────────────────────── + // 보스 우선 타깃 + // ───────────────────────────────────────────────────────────────────── + + static void ApplyBossPriority(WLRunGuideSettings cfg, PCActor pc) + { + if (!cfg.bossPriorityEnabled) { ReleaseBossLock(); return; } + + var boss = s_boss; + if (!IsAliveBoss(boss)) { boss = BossArena.Boss; s_boss = IsAliveBoss(boss) ? boss : null; } + if (!IsAliveBoss(s_boss)) { ReleaseBossLock(); return; } + boss = s_boss; + + if (cfg.bossLockTarget && !boss.m_IsLockTarget) + { + boss.m_IsLockTarget = true; // 원본 락 타깃(MyActor.cs:106·121 이 스스로 교체를 막는다) + s_lockedBoss = boss; + BossLocks++; + } + + 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; + } + + /// 존 i 의 앵커. 스포너 노드를 못 찾았으면 1회 스캔하고, 그래도 없으면 SO 폴백. + public static bool TryGetAnchor(WLRunGuideSettings cfg, int i, out Vector3 anchor) + { + anchor = Vector3.zero; + int n = ZoneCount(cfg); + if (i < 0 || i >= n) return false; + + if (s_anchorCount != n || !s_anchorFromNode[i]) ScanAnchors(cfg, n); + + if (s_anchorFromNode[i]) { anchor = s_anchors[i]; return true; } + + var fb = cfg.zoneAnchorFallbacks; + if (fb != null && i < fb.Length) { anchor = fb[i]; s_anchors[i] = anchor; return true; } + return false; + } + + /// 씬의 MobControlMgr 를 1회 훑어 존 앵커를 채운다(런당 몇 회 · 매 틱 아님). + static void ScanAnchors(WLRunGuideSettings cfg, int n) + { + var rs = WLRunSettings.Instance; + if (rs == null || rs.zoneSpawnerIds == null) return; + s_anchorCount = n; + AnchorScans++; + + var spawners = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + for (int i = 0; i < n; i++) + { + s_anchorFromNode[i] = false; + int id = rs.zoneSpawnerIds[i]; + for (int j = 0; spawners != null && j < spawners.Length; j++) + { + if (DSUtil.CheckNull(spawners[j]) || spawners[j].SpawnerID != id) continue; + s_anchors[i] = spawners[j].transform.position; + s_anchorFromNode[i] = true; + break; + } + if (!s_anchorFromNode[i]) + { + var fb = cfg.zoneAnchorFallbacks; + if (fb != null && i < fb.Length) s_anchors[i] = fb[i]; + } + } + } + + /// 앵커가 스포너 노드에서 온 것인가(진단). + public static bool AnchorFromNode(int i) { return i >= 0 && i < kMaxZones && s_anchorFromNode[i]; } + /// 캐시된 앵커(진단). + public static Vector3 AnchorOf(int i) { return i >= 0 && i < kMaxZones ? s_anchors[i] : Vector3.zero; } + + // ───────────────────────────────────────────────────────────────────── + // 유틸 · 진단 + // ───────────────────────────────────────────────────────────────────── + + static float DistXZ(Vector3 a, Vector3 b) + { + float dx = a.x - b.x, dz = a.z - b.z; + return Mathf.Sqrt(dx * dx + dz * dz); + } + + static string Fmt(Vector3 v) + { + return "(" + v.x.ToString("F1") + ", " + v.y.ToString("F1") + ", " + v.z.ToString("F1") + ")"; + } + + static void Log(string msg) + { + LastLog = msg; + var c = Cfg; + if (c != null && c.verboseLog) Debug.Log("[RunGuide] " + msg); + } + + /// 프로브용: 카운터·상태 초기화(구독은 유지). + public static void ResetDiagnostics() + { + GuideBegun = Arrived = GaveUp = FarTargetIgnored = HoldPullbacks = 0; + Repaths = BossRetargets = BossLocks = HitSuspends = AnchorScans = 0; + 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; + } + + /// 프로브용: 러너 없이 1회 폴링(강제 틱). + public static void ForceTick() { s_nextPoll = 0f; Tick(); } + + /// 프로브용: 피격 응전 구간을 직접 건다. + public static void SimulateHit() { var c = Cfg; if (c != null) { s_hitUntil = Time.unscaledTime + c.resumeAfterHitSeconds; HitSuspends++; } } + + /// 프로브용: 피격 응전 구간을 즉시 푼다. + public static void ClearHitSuspendForProbe() { s_hitUntil = 0f; } + + /// 프로브용: 보스 참조를 직접 넣는다. + 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(); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Boot() + { + if (!Active) return; // 꺼져 있으면 오브젝트 0 · 구독 0(C8) + EnsureSubscribed(); + EnsureRunner(); + } + } + + /// RunGuide 의 시간 축. 숨김 GameObject 1개 · 코루틴 0. + internal sealed class RunGuideRunner : MonoBehaviour + { + void Update() { RunGuide.Tick(); } + } +} diff --git a/Assets/WL/Combat/Run/RunGuide.cs.meta b/Assets/WL/Combat/Run/RunGuide.cs.meta new file mode 100644 index 000000000..478f353ad --- /dev/null +++ b/Assets/WL/Combat/Run/RunGuide.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d13d0221a2b3c4d5e6f7a8b9c0d1e22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Run/WLRunGuideSettings.cs b/Assets/WL/Combat/Run/WLRunGuideSettings.cs new file mode 100644 index 000000000..2a0d27ef9 --- /dev/null +++ b/Assets/WL/Combat/Run/WLRunGuideSettings.cs @@ -0,0 +1,137 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLRunGuideSettings.cs — 런 진행 가이드(존 이동 · 보스 우선) 값의 단일 출처(SOT · C45) +// +// PD 지시 #813 · 발주서 WL-813w §1 (2026-09-09) · 근거 = qa/W2-4b_재채점_v2.md D-11 · D-13 +// +// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset +// → Resources.Load("WL/WLRunGuideSettings") 로 1회 로드 후 캐시 +// (WLRunSettings · WLBossArenaSettings 관례 그대로). +// +// ■ C8 롤백 (하나라도 false 면 RunGuide 는 아무 일도 하지 않는다 = 원본 자동전투 100%) +// ① 에셋이 없다 → Enabled false +// ② enabled = false → Enabled false (러너 자체가 뜨지 않는다 · 구독 0) +// ③ RuntimeDisabled = true → Enabled false (진단·A/B 전용 · 도메인 리로드로 자동 해제) +// ④ WLRunSettings.Enabled = false → Enabled false +// 🔴 ④ 가 필요한 이유: 가이드의 목적지는 RunEvents(존 클리어·게이트·보스)로만 정해진다. +// 런 축이 꺼지면 이벤트가 0 이라 가이드가 목적지를 영원히 못 받는다 → 아예 붙지 않는다. +// +// ■ 아래 필드 기본값은 에셋 생성 시 초기값이다. 실제로 읽히는 값은 항상 .asset 이며 +// 코드에는 게임 수치 상수를 두지 않는다(C45). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(PCActor 가 Assembly-CSharp 에 있다). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Combat.Run +{ + [CreateAssetMenu(fileName = "WLRunGuideSettings", menuName = "WL/Run Guide Settings", order = 328)] + public sealed class WLRunGuideSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLRunGuideSettings"; + + static WLRunGuideSettings s_cached; + static bool s_lookupDone; + + /// 설정 에셋(1회 로드 후 캐시). 없으면 null. + public static WLRunGuideSettings Instance + { + get + { + if (!s_lookupDone) + { + s_cached = Resources.Load(ResourcesPath); + s_lookupDone = true; + } + return s_cached; + } + } + + /// 진단·A/B 전용 런타임 스위치(도메인 리로드로 자동 false). + public static bool RuntimeDisabled; + + /// 가이드가 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off · 런 축 on. + public static bool Enabled + { + get + { + var i = Instance; + return i != null && i.enabled && !RuntimeDisabled && WLRunSettings.Enabled; + } + } + + /// 테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다. + public static void ClearCache() { s_cached = null; s_lookupDone = false; } + + // ─────────────────────────────────────────── 전체 스위치 + [Header("전체 스위치")] + [Tooltip("끄면 존 이동 가이드·보스 우선 타깃이 전부 비활성 — 원본 자동전투 100%(Q4b 상태 그대로). 롤백 1순위.")] + public bool enabled = true; + + [Tooltip("가이드 상태 전이·목적지·타깃 무시를 Console 에 남긴다(검증용 · 기본 off).")] + public bool verboseLog = false; + + [Tooltip("가이드 폴링 주기(초 · unscaled). 0.02 미만은 0.02 로 올린다.")] + public float pollSeconds = 0.1f; + + // ─────────────────────────────────────────── 존 이동 + [Header("존 이동 (D-13 · 존 클리어 → 다음 존)")] + [Tooltip("끄면 존 이동 가이드를 하지 않는다(보스 가이드만 쓴다).")] + public bool zoneGuideEnabled = true; + + [Tooltip("런 시작(RunStarted) 때도 첫 존으로 유도한다. 기본 off — Q4b 실측상 PC 는 존1 에서 이미 교전한다.")] + public bool guideOnRunStart = false; + + [Tooltip("존 앵커에 이 거리(m · XZ) 안으로 들어오면 「도착」 = 조종을 놓고 원본 자동전투에 넘긴다.")] + public float zoneArriveRadius = 3f; + + [Tooltip("도착 뒤에도 PC 가 앵커에서 이 거리(m) 밖으로 밀려나면 다시 끌어온다(존1 로 되돌아가는 것을 막는다). 0 이하면 유지 안 함.")] + public float zoneHoldRadius = 18f; + + [Tooltip("스포너 노드를 못 찾았을 때 쓸 존 앵커(월드 좌표). 순서 = WLRunSettings.zoneSpawnerIds. " + + "런타임에는 언제나 MobControlMgr.SpawnerID 가 같은 노드의 실좌표를 먼저 쓴다.")] + public Vector3[] zoneAnchorFallbacks = new Vector3[] + { + new Vector3(115f, 0.39f, -22f), // 813001 + new Vector3(147f, 2.59f, 30f), // 813002 + new Vector3( 71f, -0.29f, 18f), // 813003 + new Vector3(159f, 0.88f, -22f), // 813004 + }; + + // ─────────────────────────────────────────── 공통 조종 규칙 + [Header("조종 규칙 (원본 타깃/이동 API 만 쓴다)")] + [Tooltip("가이드 중 PC 의 타깃이 목적지(앵커/아레나 중심)에서 이 거리(m) 밖이면 무시한다(Del_Target). " + + "이 안의 적은 원본 자동전투가 그대로 잡는다.")] + public float guideLeashRadius = 12f; + + [Tooltip("조종 중 NavMeshAgent.stoppingDistance(m). 목적지 코앞에서 멈춰 서서 도착 판정이 안 나는 것을 막는다.")] + public float guideStopDistance = 0.5f; + + [Tooltip("목적지 재지정(SetDestination) 주기(초). 매 폴링마다 부르지 않게 벌린다. 0 이면 매 폴링.")] + public float repathIntervalSeconds = 0.5f; + + [Tooltip("PC 가 맞으면 이 시간(초) 동안 가이드를 멈추고 즉시 응전한다(원본 자동전투가 잡는다). 0 이면 응전 중단 없음.")] + public float resumeAfterHitSeconds = 3f; + + [Tooltip("한 목적지에 이 시간(초) 안에 도착 못 하면 가이드를 포기한다(경로 불가·지형 끼임 보호). 0 이하면 무제한.")] + public float maxTravelSeconds = 60f; + + // ─────────────────────────────────────────── 보스 + [Header("보스 (D-11 · 아레나 이탈 → 리쉬 만피 리셋)")] + [Tooltip("끄면 게이트 개방·보스 등장 때 아레나로 유도하지 않는다.")] + public bool bossGuideEnabled = true; + + [Tooltip("보스가 살아 있는 동안 PC 타깃을 보스로 고정한다(원본 Set_Target(Actor) + m_IsLockTarget). 끄면 원본 타깃 선택 그대로.")] + public bool bossPriorityEnabled = true; + + [Tooltip("아레나 중심에 이 거리(m) 안으로 들어오면 도착으로 본다. 813d arenaRadius 20 보다 충분히 작아야 한다.")] + public float bossArriveRadius = 10f; + + [Tooltip("보스전 중 PC 가 아레나 중심에서 이 거리(m) 밖으로 나가면 다시 끌어온다. " + + "🔴 813d 리쉬 발동선(arenaRadius + playerLeaveMargin = 24 m)보다 작아야 리셋이 아예 안 일어난다.")] + public float bossHoldRadius = 14f; + + [Tooltip("보스에게 원본 락타깃(m_IsLockTarget)을 건다 — 잡몹으로 타깃이 새는 원본 경로 3곳을 함께 막는다. 보스 사망 시 해제.")] + public bool bossLockTarget = true; + } +} diff --git a/Assets/WL/Combat/Run/WLRunGuideSettings.cs.meta b/Assets/WL/Combat/Run/WLRunGuideSettings.cs.meta new file mode 100644 index 000000000..0f7aa3053 --- /dev/null +++ b/Assets/WL/Combat/Run/WLRunGuideSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d13d0221a2b3c4d5e6f7a8b9c0d1e21 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset index 209dd32f6..1a31f79a8 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset @@ -44,6 +44,9 @@ MonoBehaviour: playerLeaveGraceSeconds: 1.5 resetHealsBoss: 1 resetHealRate: 1 + leashHealPercent: 0.15 + leashHealCooldownSeconds: 10 + bossNameFromTable: 1 suppressRegenAfterBossKill: 1 cleanupDelaySeconds: 3 deactivateZonesAfterBossKill: 0 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset new file mode 100644 index 000000000..850c134de --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset @@ -0,0 +1,36 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 8d13d0221a2b3c4d5e6f7a8b9c0d1e21, type: 3} + m_Name: WLRunGuideSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Run.WLRunGuideSettings + enabled: 1 + verboseLog: 0 + pollSeconds: 0.1 + zoneGuideEnabled: 1 + guideOnRunStart: 0 + zoneArriveRadius: 3 + zoneHoldRadius: 18 + zoneAnchorFallbacks: + - {x: 115, y: 0.39, z: -22} + - {x: 147, y: 2.59, z: 30} + - {x: 71, y: -0.29, z: 18} + - {x: 159, y: 0.88, z: -22} + guideLeashRadius: 12 + guideStopDistance: 0.5 + repathIntervalSeconds: 0.5 + resumeAfterHitSeconds: 3 + maxTravelSeconds: 60 + bossGuideEnabled: 1 + bossPriorityEnabled: 1 + bossArriveRadius: 10 + bossHoldRadius: 14 + bossLockTarget: 1 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset.meta b/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset.meta new file mode 100644 index 000000000..ed35d2c64 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8d13d0221a2b3c4d5e6f7a8b9c0d1e23 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/UI/Scripts/BossHpBar.cs b/Assets/WL/UI/Scripts/BossHpBar.cs index 41266ffe5..585fe57aa 100644 --- a/Assets/WL/UI/Scripts/BossHpBar.cs +++ b/Assets/WL/UI/Scripts/BossHpBar.cs @@ -205,6 +205,74 @@ namespace WL.UI return (float)st.Get_Stat(eStat.MaxHP); } + // ── WL-813w §1-4 (Q4b D-12): HP 바 라벨이 `Anubis(Clone)` 로 뜬다 ─────────── + // 등장 배너(813tj)는 MonsterList 이름("보스 몬스터 6")을 쓰는데 HP 바만 gameObject.name 을 썼다. + // 같은 경로(BossBanner.ResolveMonsterName = MonsterList.n_MonsterName → localtext)를 쓰고, + // 표를 못 읽을 때만 오브젝트 이름으로 떨어지되 `(Clone)` 접미사를 지운다. + // 이 경로는 Spawned 구독·늦은 구독(813y ⑤) **양쪽이 지나는 Bind() 안**이라 두 경우 모두 고쳐진다. + // C8 롤백 = WLBossArenaSettings.bossNameFromTable = 0 → 원본 gameObject.name 그대로. + + /// 마지막 라벨을 어느 경로로 얻었는가(진단 · "테이블"/"오브젝트이름"/"없음"). + public static string LastNameSource = ""; + + /// 보스 라벨 결정 — 테이블 이름 우선, 폴백은 `(Clone)` 을 지운 오브젝트 이름. + public static string ResolveBossLabel(Actor actor) + { + return ResolveBossLabel(actor, WL.Combat.Boss.WLBossArenaSettings.Instance); + } + + /// 설정을 직접 받는 오버로드(프로브가 임시 SO 로 A/B 한다 · 에셋을 건드리지 않는다). + public static string ResolveBossLabel(Actor actor, WL.Combat.Boss.WLBossArenaSettings arena) + { + bool useTable = arena == null || arena.bossNameFromTable; + + if (useTable) + { + string t = ResolveTableName(actor, arena); + if (!string.IsNullOrEmpty(t)) { LastNameSource = "테이블"; return t; } + } + + if (actor == null) { LastNameSource = "없음"; return ""; } + string n = actor.name; + if (!useTable) { LastNameSource = "오브젝트이름(원본)"; return n; } + LastNameSource = "오브젝트이름"; + return StripClone(n); + } + + /// `Anubis(Clone)` · `Anubis (Clone)` → `Anubis`. + public static string StripClone(string n) + { + if (string.IsNullOrEmpty(n)) return n; + int i = n.IndexOf("(Clone)", System.StringComparison.Ordinal); + if (i < 0) return n; + return n.Substring(0, i).TrimEnd(); + } + + /// MonsterList 이름 — 액터의 스포너 표(MonsterAppear)에서 몬스터 ID 를 얻고, 없으면 SO 의 bossMonsterId. + private static string ResolveTableName(Actor actor, WL.Combat.Boss.WLBossArenaSettings arena) + { + try + { + if (actor != null) + { + var ap = actor.Get_MonsterStatTableData(); // MobActor.cs:635 (public virtual) + if (ap != null && ap.Get_MobTypeCount() > 0) + { + var ids = ap.Get_MobIds(); + if (ids != null && ids.Count > 0) + { + string byRow = BossBanner.ResolveMonsterName(ids[0]); + if (!string.IsNullOrEmpty(byRow)) return byRow; + } + } + } + if (arena != null && arena.bossMonsterId > 0) + return BossBanner.ResolveMonsterName(arena.bossMonsterId); + } + catch { } // 표 미로드(에디트 모드 등)에서 연출이 전투를 끊지 않게 — 813tj 와 같은 규약 + return null; + } + /// 보스를 바에 물린다. actor 가 null 이면 합성 검증 모드(최대 HP 는 fallbackMaxHp). public string Bind(Actor actor, string displayName = null, float fallbackMaxHp = 100f) { @@ -223,7 +291,7 @@ namespace WL.UI if (nameLabel != null) { string n = displayName; - if (string.IsNullOrEmpty(n)) n = actor != null ? actor.name : ""; + if (string.IsNullOrEmpty(n)) n = ResolveBossLabel(actor); nameLabel.text = n; nameLabel.gameObject.SetActive(showName && !string.IsNullOrEmpty(n)); }