From 5e97e26a91e878f733c86030377e35325f5b5815 Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 13:22:50 +0900 Subject: [PATCH] =?UTF-8?q?[WL-813w2]=20=EB=9F=B0=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=202=EC=B0=A8:=20=EB=8F=84=EC=B0=A9=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=20=C2=B7=20=EB=B3=B4=EC=8A=A4=20=ED=99=80=EB=93=9C=20?= =?UTF-8?q?=C2=B7=20=EA=B5=B0=EC=A4=91=20=EC=9C=A0=EC=A7=80=20=C2=B7=20?= =?UTF-8?q?=EB=A6=AC=EC=89=AC=20=EC=B6=95=EC=86=8C=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q5 §4 실측(존 Arrived=0 · 아레나 이탈 46.3 m · 되돌림 3~4 vs 리셋 15~19)의 원인은 RunGuide.Poll 이 피격 중단에서 통째로 return 한 것(구 RunGuide.cs:291) — 그 return 이 도착 판정(구 :303)과 이탈 되돌림(구 :327) 위에 있었다. - D-24: 도착 판정·이탈 되돌림을 피격과 무관하게 매 폴링 실행 + 정지 판정(0.5 s · 0.3 m/s) 도착 - D-22: 되돌림 즉시 · 되돌림 중 타깃 해제 · 보스 재타깃은 Hold 에서만(락은 상시 유지) - D-19: 존 반경(18 m) 이탈 즉시 복귀 · guideLeashRadius 12 → 14 - §1-4: WLBossArenaSettings.leashHealPercent 0.15 → 0.05 (쿨 10 s 유지) - RunTick 로그(존 체류·이동 초·최대 이탈) · 신규 SO 9필드 · C8 스위치 4개 Co-Authored-By: Claude Fable 5.1 --- AgentScripts/WL813w2_PROBE.txt | 89 +++ AgentScripts/WL813w2_Probe.cs | 524 ++++++++++++++++++ Assets/WL/Combat/Boss/BossArena.cs | 3 +- Assets/WL/Combat/Boss/WLBossArenaSettings.cs | 5 +- Assets/WL/Combat/Run/RunGuide.cs | 268 +++++++-- Assets/WL/Combat/Run/WLRunGuideSettings.cs | 54 +- .../Resources/WL/WLBossArenaSettings.asset | 2 +- .../Resources/WL/WLRunGuideSettings.asset | 13 +- 8 files changed, 915 insertions(+), 43 deletions(-) create mode 100644 AgentScripts/WL813w2_PROBE.txt create mode 100644 AgentScripts/WL813w2_Probe.cs diff --git a/AgentScripts/WL813w2_PROBE.txt b/AgentScripts/WL813w2_PROBE.txt new file mode 100644 index 000000000..4a618d747 --- /dev/null +++ b/AgentScripts/WL813w2_PROBE.txt @@ -0,0 +1,89 @@ +# WL813w2 Probe 2026-09-09 13:25:14 (edit mode · Play 0 · 로그인 0) +playMode=False + +── ⓪ 준비 + [PASS] 가짜 메인 PC IsMainPC()=true · MyValue.MyPC 등록 + [PASS] RunGuide.Active(에셋 · enabled · 런 축) + Agent=없음(에디트 모드 · Steer 는 여기서 되돌아간다) + +── ① 에셋 · SOT (813w2 신규 9필드 · C45) + [PASS] Resources.Load("WL/WLRunGuideSettings") + [813w 값] zoneArriveRadius=6 zoneHoldRadius=18 guideLeashRadius=14 bossArriveRadius=10 bossHoldRadius=14 pollSeconds=0.1 resumeAfterHitSeconds=3 maxTravelSeconds=60 + [813w2 신규] hitSuspendOnlyInHold=True bossHitSuspendSeconds=0.5 arriveStillEnabled=True arriveStillSeconds=0.5 arriveStillSpeed=0.3 arriveStillMaxDistance=0 pullbackReleasesTarget=True bossRetargetOnlyInHold=True runTickLogSeconds=10 + [PASS] hitSuspendOnlyInHold=1 — 피격 중단이 도착·되돌림을 막지 않는다(D-24·D-22 원인 차단) + [PASS] 정지 판정 도착 on(0.5s · 0.3m/s) + [PASS] 되돌림 중 타깃 해제 + 보스 재타깃은 Hold 에서만 + [PASS] 보스 피격 중단(0.5s) < 존 피격 중단(3s) — Q5 D-22 처방 + [PASS] guideLeashRadius(14) < zoneHoldRadius(18) — 존 안의 적은 버리지 않는다(D-19) + [PASS] WLBossArenaSettings 로드 + leashHealPercent=0.05 leashHealCooldownSeconds=10 arenaRadius=20 playerLeaveMargin=4 playerLeaveGraceSeconds=1.5 + [PASS] 리쉬 회복 0.15 → **0.05**(§1-4) + [PASS] 쿨다운 10 s 유지(§1-4) + [PASS] bossHoldRadius(14) < 리쉬 발동선(24) + +── ② D-24 도착 판정 (Q5 §4-2 「존 Arrived=0 · State=Travel 고착」) + 목적지=(147.0,2.6,30.0) · 시작 상태=Travel + [PASS] 피격 중 · 40 m — Travel 유지(도착 아님) + [PASS] 🔴 **피격 중에도 도착 → Hold**(813w = Travel 고착 · D-24 원인) + [PASS] Arrived=1 (실측 1) · y 차이 25 m 는 무시(XZ 평면 판정) + LastLog=존 1 도착 — 1.0m · 자동전투 재개 + [PASS] 정지 판정: 0.0s=False 0.3s=False 0.6s=True (arriveStillSeconds=0.5) + [PASS] 다시 움직이면 정지 타이머 리셋(still=-1.0) + [PASS] 정지 도착은 목적지 반경 안에서만 인정(12 m ok · 40 m 거부) + [PASS] maxTravelSeconds(60s) 초과 → Hold · GaveUp=1(이탈 감시는 유지) + +── ③ D-22 보스 홀드 실효화 (Q5 실측 이탈 46.3 m · 되돌림 3~4회 vs 리셋 15~19회) + 아레나 중심=(139.0,0.8,-20.0) · [PASS] 도착(Hold) + [PASS] 이동(Travel) 중에는 보스 재타깃 0 — 우리 목적지를 덮어쓰지 않는다 + [PASS] Hold — 타깃 = 보스(BossRetargets=1) + [PASS] 원본 락타깃 m_IsLockTarget=true(BossLocks=1) + [PASS] 🔴 **피격 중에도 이탈 16.0m 에서 즉시 되돌림**(813w = 되돌림 0 · D-22 원인) + [PASS] HoldPullbacks=1 · 그중 피격 중=1 + [PASS] 되돌림 중 타깃 해제(보스 타깃도 놓는다 · pullbackReleasesTarget) + [PASS] 그래도 보스 락은 유지 → 잡몹이 타깃을 가져갈 수 없다 + [PASS] 최대 이탈 실측 16.0m (리쉬 발동선 24 m 미만) + LastLog=아레나 이탈 16.0m — 다시 유도(피격 중) + [PASS] 복귀 → Hold + [PASS] Hold 복귀 즉시 타깃 = 보스로 복원 + +── ④ 보스 생존 중 잡몹 타깃 금지 (§1-2) + [PASS] 잡몹이 타깃을 가져간 상황을 만든다(__WL813w2_Mob) + [PASS] 다음 폴링(0.1 s)에 보스로 복귀 · BossRetargets 2 → 3 + [PASS] 가이드 종료 시 보스 락 해제(원본 타깃 선택 복귀) + +── ⑤ D-19 군중 유지 — 존 안에서는 존 반경 밖으로 나가지 않는다 + [PASS] 앵커 위 = Hold(자동전투 재개) + [PASS] 반경 안(12m < guideLeashRadius 14) 타깃은 유지 — 원본이 계속 싸운다 + [PASS] 반경 밖 타깃은 무시(Del_Target · FarTargetIgnored 0 → 1) + [PASS] 피격 중에는 먼 타깃도 유지 = 즉시 응전(이탈 되돌림은 그대로 돈다) + [PASS] 존 이탈 23m — 피격 중에도 즉시 되돌림(HoldPullbacks=1) + 체류 계측: 존체류=0.00s · 이동=0.00s · 최대이탈=23.0m (에디트 모드는 Time 이 멈춰 있어 초는 0 · 인게임에서 RunTick 로그로 남는다) + +── ⑥ 보스 구간 240 s 시뮬 — 리쉬 리셋 0 (§1-4) + 드리프트 4 m/s · 되돌림 5 m/s · 240 s · 판정 800회 + [PASS] 최대 이탈 14.4 m < 리쉬 발동선 24 m (Q5 실측 46.3 m) + [PASS] 🔴 **리쉬 리셋 0회**(Q5 실측 15~19회) + [PASS] 대조군(되돌림 0) 리셋 30회 — 판정기가 실제로 작동한다 + +── ⑦ 리쉬 회복 축소 0.15 → 0.05 · 쿨다운 10 s 유지 (§1-4) + [PASS] 1회차 = 부분 회복(+5%) + [PASS] 4 s 뒤 = 쿨다운으로 건너뜀 + [PASS] 11 s 뒤 = 다시 회복 + 최악 회복 총량(240 s · 쿨다운마다) = 120% (0.15 였다면 360%) + [PASS] 리셋이 남더라도 회복 총량이 보스 HP 1.5배 미만 + [PASS] C8: percent>=1 → 813d 만피 리셋 그대로 + +── ⑧ GC 0 (정상 틱 경로) + [PASS] ForceTick ×100 할당 = 0 B + [PASS] 대조군(64 KB 할당) = 69632 B — 측정기가 살아 있다 + +── ⑨ C8 — 813w2 스위치를 끄면 813w 동작 그대로 · 전체를 끄면 무동작 + [PASS] 813w(hitSuspendOnlyInHold=0): Travel=True Hold=True → **이동 중에도 멈춘다 = 도착 판정·되돌림이 폴링에서 빠진다**(D-24·D-22 원인) + [PASS] 813w2(=1): Travel=False Hold=True → 이동·되돌림은 안 멈추고, 제자리 응전만 남는다 + [PASS] 안 맞으면 언제나 false + [PASS] arriveStillEnabled=0 → 정지 판정 도착 없음(반경 판정만 = 813w) + [PASS] RuntimeDisabled=1 → 틱 ×10 에 상태 불변(Travel) · 조종 0 · 타깃 무수정 + [PASS] JoystickStatus=0(원본 자동전투 100%) + [PASS] 실에셋 무수정 확인 — hitSuspendOnlyInHold=True arriveStillEnabled=True pullbackReleasesTarget=True + +RESULT PASS diff --git a/AgentScripts/WL813w2_Probe.cs b/AgentScripts/WL813w2_Probe.cs new file mode 100644 index 000000000..404121916 --- /dev/null +++ b/AgentScripts/WL813w2_Probe.cs @@ -0,0 +1,524 @@ +// WL813w2_Probe.cs — #813w2(런 가이드 2차: 도착 판정 D-24 · 보스 홀드 D-22 · 군중 유지 D-19 · 리쉬 축소) 프로브 +// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 는 즉시 파괴/회수) +// +// 상주 에디터: +// unity command run_script --project-path --file AgentScripts/WL813w2_Probe.cs --entry WL813w2_Probe.RunAll --timeout 300 +// 결과: Console + AgentScripts/WL813w2_PROBE.txt (RESULT PASS / RESULT FAIL n) +// +// 무엇을 증명하나 (발주서 WL-813w2 §1-1~§1-5 · 근거 = qa/W2-5_재채점_v3.md §4) +// ① 에셋·SOT — 813w2 신규 9필드 · 값 · 리쉬 0.05 · bossHoldRadius < 리쉬 발동선 +// ② D-24 도착 — 🔴 **피격 중에도 도착 판정이 돈다**(813w 은 여기서 통째로 return 했다) · +// XZ 평면 판정(y 차이 무시) · 정지 판정 도착 · maxTravelSeconds 초과 → Hold +// ③ D-22 보스홀드 — 🔴 **피격 중에도 이탈 되돌림이 즉시** · 되돌림 중 타깃 해제 · +// 보스 재타깃은 Hold 에서만(락은 이동 중에도 유지) +// ④ 잡몹 타깃 금지 — 보스 생존 중 Hold 에서 잡몹 타깃 → 다음 폴링에 보스로 복귀 +// ⑤ D-19 군중 유지 — 존 Hold 에서 zoneHoldRadius 밖 = 즉시 되돌림 · 반경 안 타깃은 유지(교전 계속) +// ⑥ 리쉬 0 시뮬 — 보스 구간 240 s 를 0.1 s 스텝으로 돌려 BossArena.EvaluateLeash 리셋 **0** 확인 +// ⑦ 리쉬 회복 — EvaluateLeashHeal 판정(부분 → 쿨다운 → 쿨다운 뒤 재회복) · 0.05 +// ⑧ GC 0 — ForceTick ×100 이 0 B(대조군으로 측정기 동작 확인) +// ⑨ C8 롤백 — 813w2 스위치 4개를 끄면 813w 동작(피격 중 도착·되돌림 0) 그대로 · RuntimeDisabled → 무동작 +// +// 에디트 모드 한계(보고서 「미확인」) +// · NavMesh/NavMeshAgent 가 없어 Steer() 는 에이전트 검사에서 되돌아간다 → 실이동은 못 잰다. +// 상태 전이·판정은 PC transform 을 직접 옮겨 검증한다(원본 Get_position() 이 transform 이다). +// · Time.unscaledTime 이 스크립트 실행 중에는 멈춰 있다 → 시간 의존 판정은 순수 함수 +// (RunGuide.StepStillDetect · BossArena.EvaluateLeash/EvaluateLeashHeal)에 가짜 시각을 먹여 검증한다. +// 그래서 「피격 중」 상태는 프로브 내내 유지된다 = ②③ 이 노린 조건 그대로다. + +using System; +using System.IO; +using System.Text; +using UnityEngine; +using WL.Combat.Boss; +using WL.Combat.Run; + +public static class WL813w2_Probe +{ + const string OutCommit = "AgentScripts/WL813w2_PROBE.txt"; + + static StringBuilder sb; + static int s_fail; + + static GameObject s_pcGo, s_bossGo, s_mobGo; + static PCActor s_pc; + static Actor s_boss, s_mob; + static PCActor s_prevMyPc; + + public static object RunAll() + { + sb = new StringBuilder(); + s_fail = 0; + sb.AppendLine("# WL813w2 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_Arrive(); + Sec3_BossHold(); + Sec4_NoMobTarget(); + Sec5_ZoneHold(); + Sec6_LeashZeroSim(); + Sec7_LeashHeal(); + Sec8_GC(); + Sec9_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 void Setup() + { + s_prevMyPc = MyValue.MyPC; + + s_pcGo = new GameObject("__WL813w2_PC"); + s_pc = s_pcGo.AddComponent(); + s_pc.m_Role = eRole.PC; s_pc.m_SubRole = eSubRol.None; + MyValue.MyPC = s_pc; + + s_bossGo = new GameObject("__WL813w2_Boss"); + s_boss = s_bossGo.AddComponent(); + s_boss.m_Role = eRole.Mob; s_boss.m_SubRole = eSubRol.Boss; + + s_mobGo = new GameObject("__WL813w2_Mob"); + s_mob = s_mobGo.AddComponent(); + s_mob.m_Role = eRole.Mob; s_mob.m_SubRole = eSubRol.None; + + 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(); + RunGuide.SetSteeringForProbe(false); + WLRunGuideSettings.RuntimeDisabled = false; + MyValue.MyPC = s_prevMyPc; + Kill(s_pcGo, s_bossGo, s_mobGo); + } + catch { } + } + + static void Kill(params GameObject[] gos) + { + for (int i = 0; i < gos.Length; i++) + if (gos[i] != null) UnityEngine.Object.DestroyImmediate(gos[i]); + } + + static void MovePc(Vector3 p) { s_pcGo.transform.position = p; } + + // ───────────────────────────────────────── ① 에셋 · SOT + static void Sec1_Asset() + { + L(""); + L("── ① 에셋 · SOT (813w2 신규 9필드 · C45)"); + var c = WLRunGuideSettings.Instance; + L(" " + P(c != null) + " Resources.Load(\"" + WLRunGuideSettings.ResourcesPath + "\")"); + if (c == null) return; + + L(" [813w 값] zoneArriveRadius=" + c.zoneArriveRadius + " zoneHoldRadius=" + c.zoneHoldRadius + + " guideLeashRadius=" + c.guideLeashRadius + " bossArriveRadius=" + c.bossArriveRadius + + " bossHoldRadius=" + c.bossHoldRadius + " pollSeconds=" + c.pollSeconds + + " resumeAfterHitSeconds=" + c.resumeAfterHitSeconds + " maxTravelSeconds=" + c.maxTravelSeconds); + L(" [813w2 신규] hitSuspendOnlyInHold=" + c.hitSuspendOnlyInHold + + " bossHitSuspendSeconds=" + c.bossHitSuspendSeconds + + " arriveStillEnabled=" + c.arriveStillEnabled + " arriveStillSeconds=" + c.arriveStillSeconds + + " arriveStillSpeed=" + c.arriveStillSpeed + " arriveStillMaxDistance=" + c.arriveStillMaxDistance + + " pullbackReleasesTarget=" + c.pullbackReleasesTarget + + " bossRetargetOnlyInHold=" + c.bossRetargetOnlyInHold + " runTickLogSeconds=" + c.runTickLogSeconds); + + L(" " + P(c.hitSuspendOnlyInHold) + " hitSuspendOnlyInHold=1 — 피격 중단이 도착·되돌림을 막지 않는다(D-24·D-22 원인 차단)"); + L(" " + P(c.arriveStillEnabled && c.arriveStillSeconds > 0f) + " 정지 판정 도착 on(" + c.arriveStillSeconds + "s · " + c.arriveStillSpeed + "m/s)"); + L(" " + P(c.pullbackReleasesTarget && c.bossRetargetOnlyInHold) + " 되돌림 중 타깃 해제 + 보스 재타깃은 Hold 에서만"); + L(" " + P(c.bossHitSuspendSeconds < c.resumeAfterHitSeconds) + " 보스 피격 중단(" + c.bossHitSuspendSeconds + + "s) < 존 피격 중단(" + c.resumeAfterHitSeconds + "s) — Q5 D-22 처방"); + L(" " + P(c.guideLeashRadius < c.zoneHoldRadius) + " guideLeashRadius(" + c.guideLeashRadius + + ") < zoneHoldRadius(" + c.zoneHoldRadius + ") — 존 안의 적은 버리지 않는다(D-19)"); + + var b = WLBossArenaSettings.Instance; + L(" " + P(b != null) + " WLBossArenaSettings 로드"); + if (b == null) return; + L(" leashHealPercent=" + b.leashHealPercent + " leashHealCooldownSeconds=" + b.leashHealCooldownSeconds + + " arenaRadius=" + b.arenaRadius + " playerLeaveMargin=" + b.playerLeaveMargin + + " playerLeaveGraceSeconds=" + b.playerLeaveGraceSeconds); + L(" " + P(Mathf.Approximately(b.leashHealPercent, 0.05f)) + " 리쉬 회복 0.15 → **0.05**(§1-4)"); + L(" " + P(Mathf.Approximately(b.leashHealCooldownSeconds, 10f)) + " 쿨다운 10 s 유지(§1-4)"); + float leashLine = b.arenaRadius + b.playerLeaveMargin; + L(" " + P(c.bossHoldRadius < leashLine) + " bossHoldRadius(" + c.bossHoldRadius + ") < 리쉬 발동선(" + leashLine + ")"); + } + + // ───────────────────────────────────────── ② D-24 도착 판정 + static void Sec2_Arrive() + { + L(""); + L("── ② D-24 도착 판정 (Q5 §4-2 「존 Arrived=0 · State=Travel 고착」)"); + var c = WLRunGuideSettings.Instance; + if (c == null) return; + + Vector3 anchor; + if (!RunGuide.TryGetAnchor(c, 1, out anchor)) { L(" [FAIL] 존1 앵커 없음"); s_fail++; return; } + + // ── ②-1 피격 중에도 도착 판정이 돈다 (813w 은 여기서 return 했다) + RunGuide.ResetDiagnostics(); + MovePc(anchor + new Vector3(40f, 0f, 0f)); + RunGuide.BeginZone(1, "probe"); + L(" 목적지=" + V(RunGuide.Destination) + " · 시작 상태=" + RunGuide.State); + + RunGuide.SimulateHit(); // 에디트 모드에선 Time 이 멈춰 있어 「피격 중」이 계속된다 + RunGuide.ForceTick(); + bool hitStillTravel = RunGuide.State == RunGuideState.Travel; + L(" " + P(hitStillTravel) + " 피격 중 · 40 m — Travel 유지(도착 아님)"); + + // 앵커 위로 옮긴다(y 는 크게 다르게 둔다 = XZ 판정 확인) + MovePc(new Vector3(anchor.x + 1.0f, anchor.y + 25f, anchor.z)); + RunGuide.ForceTick(); + L(" " + P(RunGuide.State == RunGuideState.Hold) + " 🔴 **피격 중에도 도착 → Hold**(813w = Travel 고착 · D-24 원인)"); + L(" " + P(RunGuide.Arrived == 1) + " Arrived=1 (실측 " + RunGuide.Arrived + ") · y 차이 25 m 는 무시(XZ 평면 판정)"); + L(" LastLog=" + RunGuide.LastLog); + + // ── ②-2 정지 판정 도착 (순수 함수 · 가짜 시각) + float still = -1f; + bool a0 = RunGuide.StepStillDetect(c, 0.01f, 0.1f, 10.0f, ref still); // 정지 시작 + bool a1 = RunGuide.StepStillDetect(c, 0.01f, 0.1f, 10.3f, ref still); // 0.3 s — 아직 + bool a2 = RunGuide.StepStillDetect(c, 0.01f, 0.1f, 10.6f, ref still); // 0.6 s — 도착 + L(" " + P(!a0 && !a1 && a2) + " 정지 판정: 0.0s=" + a0 + " 0.3s=" + a1 + " 0.6s=" + a2 + + " (arriveStillSeconds=" + c.arriveStillSeconds + ")"); + bool a3 = RunGuide.StepStillDetect(c, 2.0f, 0.1f, 10.7f, ref still); // 20 m/s = 이동 중 + L(" " + P(!a3 && still < 0f) + " 다시 움직이면 정지 타이머 리셋(still=" + still.ToString("F1") + ")"); + L(" " + P(RunGuide.WithinStillRange(c, 12f, 18f) && !RunGuide.WithinStillRange(c, 40f, 18f)) + + " 정지 도착은 목적지 반경 안에서만 인정(12 m ok · 40 m 거부)"); + + // ── ②-3 maxTravelSeconds 초과 → Hold(이탈 감시 유지) + RunGuide.ResetDiagnostics(); + MovePc(anchor + new Vector3(40f, 0f, 0f)); + RunGuide.BeginZone(1, "probe timeout"); + RunGuide.SetTravelStartForProbe(Time.unscaledTime - (c.maxTravelSeconds + 1f)); + RunGuide.ForceTick(); + L(" " + P(RunGuide.State == RunGuideState.Hold && RunGuide.GaveUp == 1) + + " maxTravelSeconds(" + c.maxTravelSeconds + "s) 초과 → Hold · GaveUp=" + RunGuide.GaveUp + "(이탈 감시는 유지)"); + } + + // ───────────────────────────────────────── ③ D-22 보스 홀드 + static void Sec3_BossHold() + { + L(""); + L("── ③ D-22 보스 홀드 실효화 (Q5 실측 이탈 46.3 m · 되돌림 3~4회 vs 리셋 15~19회)"); + var c = WLRunGuideSettings.Instance; + if (c == null) return; + + RunGuide.ResetDiagnostics(); + Vector3 center = BossArena.Center; + s_bossGo.transform.position = center; + RunGuide.SetBossForProbe(s_boss); + + MovePc(center); + RunGuide.BeginBoss("probe"); + RunGuide.ForceTick(); // 중심 = 도착(이 틱에서는 아직 Travel → 재타깃 없음) + L(" 아레나 중심=" + V(center) + " · " + P(RunGuide.State == RunGuideState.Hold) + " 도착(Hold)"); + L(" " + P(RunGuide.BossRetargets == 0) + " 이동(Travel) 중에는 보스 재타깃 0 — 우리 목적지를 덮어쓰지 않는다"); + + RunGuide.ForceTick(); // Hold 에서 첫 폴링 → 보스 타깃 + bool lockedInHold = !DSUtil.CheckNull(s_pc.Get_Target()) && ReferenceEquals(s_pc.Get_Target(), s_boss); + L(" " + P(lockedInHold) + " Hold — 타깃 = 보스(BossRetargets=" + RunGuide.BossRetargets + ")"); + L(" " + P(s_boss.m_IsLockTarget) + " 원본 락타깃 m_IsLockTarget=true(BossLocks=" + RunGuide.BossLocks + ")"); + + // 🔴 피격 중에 아레나 밖으로 밀려난다 → 즉시 되돌림이 걸려야 한다 + RunGuide.SimulateHit(); + MovePc(center + new Vector3(c.bossHoldRadius + 2f, 0f, 0f)); // 16 m — 홀드(14) 밖 · 리쉬선(24) 안 + RunGuide.ForceTick(); + L(" " + P(RunGuide.State == RunGuideState.Travel) + " 🔴 **피격 중에도 이탈 " + + (c.bossHoldRadius + 2f).ToString("F1") + "m 에서 즉시 되돌림**(813w = 되돌림 0 · D-22 원인)"); + L(" " + P(RunGuide.HoldPullbacks == 1 && RunGuide.PullbacksWhileHit == 1) + + " HoldPullbacks=" + RunGuide.HoldPullbacks + " · 그중 피격 중=" + RunGuide.PullbacksWhileHit); + L(" " + P(!s_pc.isTarget) + " 되돌림 중 타깃 해제(보스 타깃도 놓는다 · pullbackReleasesTarget)"); + L(" " + P(s_boss.m_IsLockTarget) + " 그래도 보스 락은 유지 → 잡몹이 타깃을 가져갈 수 없다"); + L(" " + P(RunGuide.MaxDistFromDest >= c.bossHoldRadius) + " 최대 이탈 실측 " + + RunGuide.MaxDistFromDest.ToString("F1") + "m (리쉬 발동선 24 m 미만)"); + L(" LastLog=" + RunGuide.LastLog); + + // 되돌아오면 다시 Hold + 보스 타깃 복귀 + MovePc(center + new Vector3(2f, 0f, 0f)); + RunGuide.ForceTick(); + L(" " + P(RunGuide.State == RunGuideState.Hold) + " 복귀 → Hold"); + RunGuide.ForceTick(); + L(" " + P(ReferenceEquals(s_pc.Get_Target(), s_boss)) + " Hold 복귀 즉시 타깃 = 보스로 복원"); + } + + // ───────────────────────────────────────── ④ 보스 생존 중 잡몹 타깃 금지 + static void Sec4_NoMobTarget() + { + L(""); + L("── ④ 보스 생존 중 잡몹 타깃 금지 (§1-2)"); + Vector3 center = BossArena.Center; + s_mobGo.transform.position = center + new Vector3(3f, 0f, 0f); + + // Hold 상태에서 원본 경로가 잡몹으로 타깃을 바꿔치기한 상황을 만든다 + s_pc.Change_Target(true, s_mob); + bool stolen = ReferenceEquals(s_pc.Get_Target(), s_mob); + int before = RunGuide.BossRetargets; + RunGuide.ForceTick(); + bool back = ReferenceEquals(s_pc.Get_Target(), s_boss); + L(" " + P(stolen) + " 잡몹이 타깃을 가져간 상황을 만든다(" + s_mob.name + ")"); + L(" " + P(back && RunGuide.BossRetargets == before + 1) + + " 다음 폴링(0.1 s)에 보스로 복귀 · BossRetargets " + before + " → " + RunGuide.BossRetargets); + + // 보스가 죽으면 락·우선 타깃을 놓는다(원본 자동전투 복귀) + s_boss.m_IsLockTarget = true; + RunGuide.StopGuide("probe boss done"); + L(" " + P(!s_boss.m_IsLockTarget) + " 가이드 종료 시 보스 락 해제(원본 타깃 선택 복귀)"); + } + + // ───────────────────────────────────────── ⑤ D-19 군중 유지 (존 반경 이탈 0) + static void Sec5_ZoneHold() + { + L(""); + L("── ⑤ D-19 군중 유지 — 존 안에서는 존 반경 밖으로 나가지 않는다"); + var c = WLRunGuideSettings.Instance; + if (c == null) return; + + Vector3 anchor; + if (!RunGuide.TryGetAnchor(c, 1, out anchor)) { L(" [FAIL] 앵커 없음"); s_fail++; return; } + + RunGuide.ResetDiagnostics(); + MovePc(anchor); + RunGuide.BeginZone(1, "probe zone hold"); + RunGuide.ForceTick(); + L(" " + P(RunGuide.State == RunGuideState.Hold) + " 앵커 위 = Hold(자동전투 재개)"); + + // 존 반경 안의 적은 유지한다(교전 계속 = 화면 안 적 수) + RunGuide.ClearHitSuspendForProbe(); + s_mobGo.transform.position = anchor + new Vector3(c.guideLeashRadius - 2f, 0f, 0f); + s_pc.Change_Target(true, s_mob); + RunGuide.ForceTick(); + L(" " + P(ReferenceEquals(s_pc.Get_Target(), s_mob)) + " 반경 안(" + (c.guideLeashRadius - 2f).ToString("F0") + + "m < guideLeashRadius " + c.guideLeashRadius + ") 타깃은 유지 — 원본이 계속 싸운다"); + + // 반경 밖 적은 무시한다(따라 나가지 않는다) + s_mobGo.transform.position = anchor + new Vector3(c.guideLeashRadius + 6f, 0f, 0f); + s_pc.Change_Target(true, s_mob); + int fi = RunGuide.FarTargetIgnored; + RunGuide.ForceTick(); + L(" " + P(!s_pc.isTarget && RunGuide.FarTargetIgnored == fi + 1) + + " 반경 밖 타깃은 무시(Del_Target · FarTargetIgnored " + fi + " → " + RunGuide.FarTargetIgnored + ")"); + + // 피격 중에는 먼 타깃 무시를 쉬어 준다 = 나를 때린 적에게 즉시 응전(반경 밖으로는 어차피 못 나간다) + s_pc.Change_Target(true, s_mob); + RunGuide.SimulateHit(); + RunGuide.ForceTick(); + L(" " + P(ReferenceEquals(s_pc.Get_Target(), s_mob)) + + " 피격 중에는 먼 타깃도 유지 = 즉시 응전(이탈 되돌림은 그대로 돈다)"); + + // 존 반경 밖으로 밀려나면 피격 중이라도 즉시 되돌림 + MovePc(anchor + new Vector3(c.zoneHoldRadius + 5f, 0f, 0f)); + RunGuide.ForceTick(); + L(" " + P(RunGuide.State == RunGuideState.Travel && RunGuide.HoldPullbacks == 1) + + " 존 이탈 " + (c.zoneHoldRadius + 5f).ToString("F0") + "m — 피격 중에도 즉시 되돌림(HoldPullbacks=" + + RunGuide.HoldPullbacks + ")"); + L(" 체류 계측: 존체류=" + RunGuide.ZoneHoldSeconds.ToString("F2") + "s · 이동=" + + RunGuide.TravelSeconds.ToString("F2") + "s · 최대이탈=" + RunGuide.MaxDistFromDest.ToString("F1") + + "m (에디트 모드는 Time 이 멈춰 있어 초는 0 · 인게임에서 RunTick 로그로 남는다)"); + } + + // ───────────────────────────────────────── ⑥ 리쉬 0 시뮬 (보스 구간 240 s) + static void Sec6_LeashZeroSim() + { + L(""); + L("── ⑥ 보스 구간 240 s 시뮬 — 리쉬 리셋 0 (§1-4)"); + var c = WLRunGuideSettings.Instance; + var b = WLBossArenaSettings.Instance; + if (c == null || b == null) return; + + // 813w2 홀드 규칙만으로 PC 궤적을 만든다(NavMesh 없이 순수 계산): + // Hold = 원본 자동전투가 잡몹 쪽으로 끌고 나간다 → 바깥으로 drift + // Travel(되돌림) = 우리가 중심으로 민다 → 안쪽으로 pull + // 실측 근거: Q5 는 「되돌림이 3~4회뿐」이라 drift 가 46 m 까지 갔다. 되돌림이 매 폴링 걸리면 + // PC 는 bossHoldRadius 근처에서 톱니 모양으로 붙잡힌다 → 리쉬 발동선 24 m 에 닿지 않는다. + const float step = 0.1f; // 폴링 주기 + const float drift = 4.0f; // 잡몹 추격으로 바깥으로 끌리는 속도(m/s · 보수적으로 크게) + const float pull = 5.0f; // 되돌림 이동 속도(m/s · PC 이동속도 하한) + float d = 0f, maxD = 0f; + bool traveling = false; + int resets = 0, ticks = 0; + BossArena.ResetLeashState(); + Vector3 center = BossArena.Center; + + for (float t = 0f; t < 240f; t += step) + { + // 813w2 상태 기계와 같은 규칙 + if (!traveling && d > c.bossHoldRadius) traveling = true; // 이탈 → 되돌림(피격 무관) + else if (traveling && d <= c.bossArriveRadius) traveling = false; // 도착 → Hold + + d += (traveling ? -pull : drift) * step; + if (d < 0f) d = 0f; + if (d > maxD) maxD = d; + + // 0.25 s 마다 813d 리쉬 판정(보스는 중심 · PC 는 d 만큼 떨어져 있다) + if (((int)Mathf.Round(t / step)) % 3 != 0) continue; + ticks++; + int v = BossArena.EvaluateLeash(center, center + new Vector3(d, 0f, 0f), true, center, t); + if (v != BossArena.kLeashNone) resets++; + } + + L(" 드리프트 " + drift + " m/s · 되돌림 " + pull + " m/s · 240 s · 판정 " + ticks + "회"); + L(" " + P(maxD < b.arenaRadius + b.playerLeaveMargin) + " 최대 이탈 " + maxD.ToString("F1") + + " m < 리쉬 발동선 " + (b.arenaRadius + b.playerLeaveMargin) + " m (Q5 실측 46.3 m)"); + L(" " + P(resets == 0) + " 🔴 **리쉬 리셋 " + resets + "회**(Q5 실측 15~19회)"); + + // 대조군: 813w 동작(되돌림이 거의 안 걸린다) → 리셋이 실제로 난다 = 시뮬레이터가 살아 있다는 증거 + BossArena.ResetLeashState(); + int resets813w = 0; float d2 = 0f; + for (float t = 0f; t < 240f; t += step) + { + d2 += drift * step; // 되돌림 없음 + if (((int)Mathf.Round(t / step)) % 3 != 0) continue; + if (BossArena.EvaluateLeash(center, center + new Vector3(d2, 0f, 0f), true, center, t) != BossArena.kLeashNone) + { resets813w++; d2 = 0f; } // 리셋 = 보스 워프(PC 는 그대로 = 거리 0 로 근사) + } + L(" " + P(resets813w > 0) + " 대조군(되돌림 0) 리셋 " + resets813w + "회 — 판정기가 실제로 작동한다"); + BossArena.ResetLeashState(); + } + + // ───────────────────────────────────────── ⑦ 리쉬 회복 판정 + static void Sec7_LeashHeal() + { + L(""); + L("── ⑦ 리쉬 회복 축소 0.15 → 0.05 · 쿨다운 10 s 유지 (§1-4)"); + var tmp = ScriptableObject.CreateInstance(); + try + { + var real = WLBossArenaSettings.Instance; + tmp.resetHealsBoss = true; + tmp.leashHealPercent = real != null ? real.leashHealPercent : 0.05f; + tmp.leashHealCooldownSeconds = real != null ? real.leashHealCooldownSeconds : 10f; + + BossArena.ResetLeashState(); + int v1 = BossArena.EvaluateLeashHeal(tmp, 100f); + int v2 = BossArena.EvaluateLeashHeal(tmp, 104f); // 쿨다운 안 + int v3 = BossArena.EvaluateLeashHeal(tmp, 111f); // 쿨다운 뒤 + L(" " + P(v1 == BossArena.kHealPartial) + " 1회차 = 부분 회복(+" + (tmp.leashHealPercent * 100f).ToString("F0") + "%)"); + L(" " + P(v2 == BossArena.kHealCooldown) + " 4 s 뒤 = 쿨다운으로 건너뜀"); + L(" " + P(v3 == BossArena.kHealPartial) + " 11 s 뒤 = 다시 회복"); + + // 240 s 동안 최악(매 10 s 리쉬)이라도 회복 총량은 24 × 5 % = 120 % → 0.15 였다면 360 % + float worst = (240f / tmp.leashHealCooldownSeconds) * tmp.leashHealPercent; + L(" 최악 회복 총량(240 s · 쿨다운마다) = " + (worst * 100f).ToString("F0") + + "% (0.15 였다면 " + ((240f / tmp.leashHealCooldownSeconds) * 0.15f * 100f).ToString("F0") + "%)"); + L(" " + P(worst < 1.5f) + " 리셋이 남더라도 회복 총량이 보스 HP 1.5배 미만"); + + tmp.leashHealPercent = 1f; + BossArena.ResetLeashState(); + L(" " + P(BossArena.EvaluateLeashHeal(tmp, 200f) == BossArena.kHealFull) + " C8: percent>=1 → 813d 만피 리셋 그대로"); + } + finally { UnityEngine.Object.DestroyImmediate(tmp); BossArena.ResetLeashState(); } + } + + // ───────────────────────────────────────── ⑧ GC + static void Sec8_GC() + { + L(""); + L("── ⑧ GC 0 (정상 틱 경로)"); + var c = WLRunGuideSettings.Instance; + Vector3 anchor; + if (c == null || !RunGuide.TryGetAnchor(c, 1, out anchor)) return; + + RunGuide.ResetDiagnostics(); + MovePc(anchor); + RunGuide.BeginZone(1, "probe gc"); + for (int i = 0; i < 5; i++) RunGuide.ForceTick(); + + GC.Collect(); GC.WaitForPendingFinalizers(); + 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 byte[64 * 1024]; + junk[0] = 1; + long b1 = Alloc(); + L(" " + P(b1 - b0 > 0) + " 대조군(64 KB 할당) = " + (b1 - b0) + " B — 측정기가 살아 있다"); + RunGuide.StopGuide("probe gc done"); + } + + // ───────────────────────────────────────── ⑨ C8 롤백 + static void Sec9_C8() + { + L(""); + L("── ⑨ C8 — 813w2 스위치를 끄면 813w 동작 그대로 · 전체를 끄면 무동작"); + var c = WLRunGuideSettings.Instance; + Vector3 anchor; + if (c == null || !RunGuide.TryGetAnchor(c, 1, out anchor)) return; + + // ⑨-1 피격 응전 판정 — 실에셋을 건드리지 않고 임시 SO 로 두 동작을 나란히 확인한다 + // (Poll 이 부르는 것과 **같은 함수** RunGuide.SuspendDecision 이다) + var tmp = ScriptableObject.CreateInstance(); + try + { + tmp.hitSuspendOnlyInHold = false; // 813w + bool w_travel = RunGuide.SuspendDecision(tmp, true, RunGuideState.Travel); + bool w_hold = RunGuide.SuspendDecision(tmp, true, RunGuideState.Hold); + tmp.hitSuspendOnlyInHold = true; // 813w2 + bool w2_travel = RunGuide.SuspendDecision(tmp, true, RunGuideState.Travel); + bool w2_hold = RunGuide.SuspendDecision(tmp, true, RunGuideState.Hold); + bool w2_nohit = RunGuide.SuspendDecision(tmp, false, RunGuideState.Hold); + + L(" " + P(w_travel && w_hold) + " 813w(hitSuspendOnlyInHold=0): Travel=" + w_travel + " Hold=" + w_hold + + " → **이동 중에도 멈춘다 = 도착 판정·되돌림이 폴링에서 빠진다**(D-24·D-22 원인)"); + L(" " + P(!w2_travel && w2_hold) + " 813w2(=1): Travel=" + w2_travel + " Hold=" + w2_hold + + " → 이동·되돌림은 안 멈추고, 제자리 응전만 남는다"); + L(" " + P(!w2_nohit) + " 안 맞으면 언제나 false"); + + tmp.arriveStillEnabled = false; + float stillOff = -1f; + bool off = RunGuide.StepStillDetect(tmp, 0f, 0.1f, 50f, ref stillOff) || + RunGuide.StepStillDetect(tmp, 0f, 0.1f, 60f, ref stillOff); + L(" " + P(!off) + " arriveStillEnabled=0 → 정지 판정 도착 없음(반경 판정만 = 813w)"); + } + finally { UnityEngine.Object.DestroyImmediate(tmp); } + + // ⑨-2 RuntimeDisabled → 아무 일도 하지 않는다 + RunGuide.ResetDiagnostics(); + MovePc(anchor + new Vector3(40f, 0f, 0f)); + RunGuide.BeginZone(1, "probe c8-2"); + var st = RunGuide.State; + WLRunGuideSettings.RuntimeDisabled = true; + try + { + s_pc.Change_Target(true, s_mob); + for (int i = 0; i < 10; i++) RunGuide.ForceTick(); + L(" " + P(RunGuide.State == st && !RunGuide.Steering && ReferenceEquals(s_pc.Get_Target(), s_mob)) + + " RuntimeDisabled=1 → 틱 ×10 에 상태 불변(" + RunGuide.State + ") · 조종 0 · 타깃 무수정"); + L(" " + P(s_pc.JoystickStatus == 0) + " JoystickStatus=0(원본 자동전투 100%)"); + } + finally { WLRunGuideSettings.RuntimeDisabled = false; RunGuide.StopGuide("probe c8 done"); } + + // 실에셋은 프로브가 한 번도 쓰지 않았다(전부 임시 SO · 읽기만) + L(" " + P(c.hitSuspendOnlyInHold && c.arriveStillEnabled && c.pullbackReleasesTarget) + + " 실에셋 무수정 확인 — hitSuspendOnlyInHold=" + c.hitSuspendOnlyInHold + + " arriveStillEnabled=" + c.arriveStillEnabled + " pullbackReleasesTarget=" + c.pullbackReleasesTarget); + } +} diff --git a/Assets/WL/Combat/Boss/BossArena.cs b/Assets/WL/Combat/Boss/BossArena.cs index 7a079dec5..2a083bf04 100644 --- a/Assets/WL/Combat/Boss/BossArena.cs +++ b/Assets/WL/Combat/Boss/BossArena.cs @@ -490,7 +490,8 @@ namespace WL.Combat.Boss /// /// 보스를 아레나 중심으로 되돌린다. /// 🔴 WL-813w §1-3 (Q4b D-11): 예전에는 매번 `On_Regen(true, 1)` = **만피 복구**였고 한 런에서 69회 - /// 일어나 보스 HP 가 한 칸도 줄지 않았다. 이제 기본은 `leashHealPercent`(0.15) **부분 회복**이고 + /// 일어나 보스 HP 가 한 칸도 줄지 않았다. 이제 기본은 `leashHealPercent`(813w2 §1-4 로 **0.05**) + /// **부분 회복**이고 /// `leashHealCooldownSeconds`(10 s) 안에서는 회복을 건너뛴다(워프·타깃 해제는 그대로). /// `leashHealPercent >= 1` 로 두면 813d 원래 동작(On_Regen 만피)으로 정확히 되돌아간다(C8). /// diff --git a/Assets/WL/Combat/Boss/WLBossArenaSettings.cs b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs index b44fa6374..22e874542 100644 --- a/Assets/WL/Combat/Boss/WLBossArenaSettings.cs +++ b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs @@ -165,8 +165,9 @@ namespace WL.Combat.Boss // ── 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; + + "🔴 1 이상이면 813d 원래 동작(On_Regen 만피 리셋 = C8 롤백). 0 이면 회복 없이 워프만. " + + "🔴 813w2 §1-4: Q5 실측(런당 리셋 15~19회 · 회복 8~9회)에서 0.15 는 깎은 HP 를 되돌렸다 → 0.05.")] + public float leashHealPercent = 0.05f; [Tooltip("부분 회복의 최소 간격(초 · unscaled). 이 안에 리쉬가 다시 걸리면 워프·타깃 해제만 하고 회복은 건너뛴다(최대 1회/10 s).")] public float leashHealCooldownSeconds = 10f; diff --git a/Assets/WL/Combat/Run/RunGuide.cs b/Assets/WL/Combat/Run/RunGuide.cs index 698db1c84..06418f4ea 100644 --- a/Assets/WL/Combat/Run/RunGuide.cs +++ b/Assets/WL/Combat/Run/RunGuide.cs @@ -33,12 +33,47 @@ // // ■ 가이드 상태 // Off 목적지 없음 — 아무 일도 하지 않는다(원본 100%). -// Travel 목적지로 이동 중 — 조종 ON · 타깃 해제(보스는 예외). +// Travel 목적지로 이동 중 — 조종 ON · 타깃 해제. // Hold 도착 — 조종 OFF(원본 자동전투가 그 존/아레나에서 싸운다). // 다만 ① 목적지에서 guideLeashRadius 밖의 타깃은 무시(Del_Target) // ② PC 가 holdRadius 밖으로 밀려나면 Travel 로 되돌아가 끌어온다 // → 존1 로 되돌아가는 것(D-13)과 아레나 이탈(D-11)이 여기서 막힌다. -// 피격 resumeAfterHitSeconds 동안 가이드를 멈추고 원본에 넘긴다(즉시 응전 · 발주서 §1-1). +// 피격 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). // @@ -98,9 +133,17 @@ namespace WL.Combat.Run // ───────────────────────────────────────── 진단 카운터 public static int GuideBegun, Arrived, GaveUp, FarTargetIgnored, HoldPullbacks, Repaths, BossRetargets, BossLocks, HitSuspends, AnchorScans; + /// 813w2: 정지 판정으로 확정한 도착 수(Arrived 에도 함께 센다) · 피격 중에도 실행한 되돌림 수. + public static int ArrivedByStill, PullbacksWhileHit; + /// 813w2: 목적지에서 잰 최대 이탈 거리(m · Q5 D-22 의 「46.3 m」에 대응하는 실측값). + public static float MaxDistFromDest; + /// 813w2: 존 체류 초 · 보스(아레나) 체류 초 · 이동(Travel) 초 — RunTick 로그와 QA 시각표용. + public static float ZoneHoldSeconds, BossHoldSeconds, TravelSeconds; public static string LastLog = ""; /// 마지막으로 무시한 타깃 이름(진단). public static string LastIgnoredTarget = ""; + /// 813w2: 마지막 RunTick 요약 줄(프로브·QA 가 읽는다). + public static string LastTickLog = ""; // ───────────────────────────────────────── 내부 상태 static RunGuideState s_state; @@ -111,6 +154,10 @@ namespace WL.Combat.Run 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]; @@ -124,6 +171,7 @@ namespace WL.Combat.Run static readonly CombatHandler h_bossStarted = OnBossStarted; static readonly CombatHandler h_runEnded = OnRunEnded; static readonly CombatHandler h_damaged = OnDamaged; + static readonly CombatHandler h_runTick = OnRunTick; // 813w2 §1-3 진행 로그 // ───────────────────────────────────────────────────────────────────── // 구독 @@ -139,6 +187,7 @@ namespace WL.Combat.Run 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); } @@ -152,6 +201,7 @@ namespace WL.Combat.Run 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); } @@ -164,6 +214,10 @@ namespace WL.Combat.Run 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, "런 시작"); } @@ -189,15 +243,35 @@ namespace WL.Combat.Run static void OnDamaged(in DamagedEvent e) { var cfg = Cfg; - if (cfg == null || cfg.resumeAfterHitSeconds <= 0f) return; + 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 + cfg.resumeAfterHitSeconds; + s_hitUntil = Time.unscaledTime + hold; if (wasFree) HitSuspends++; } + /// 813w2 §1-3 — 존 체류/이동 초를 주기적으로 한 줄 남긴다(QA 시각표 · GC 는 로그를 실제로 낼 때만). + static void OnRunTick(in RunTickEvent e) + { + var cfg = Cfg; + if (cfg == null || cfg.runTickLogSeconds <= 0f) return; + if (e.elapsedSec < s_nextTickLog) return; + s_nextTickLog = e.elapsedSec + cfg.runTickLogSeconds; + + LastTickLog = "t=" + e.elapsedSec.ToString("F0") + "s · " + s_state + + (s_destKind == RunGuideDest.Zone ? " 존" + s_zoneIndex : s_destKind == RunGuideDest.Boss ? " 보스" : " -") + + " · 존체류 " + ZoneHoldSeconds.ToString("F0") + "s · 아레나체류 " + BossHoldSeconds.ToString("F0") + + "s · 이동 " + TravelSeconds.ToString("F0") + "s · 최대이탈 " + MaxDistFromDest.ToString("F1") + + "m · 도착 " + Arrived + "(정지 " + ArrivedByStill + ") · 되돌림 " + HoldPullbacks + + " · 존 " + e.zonesCleared + "/" + e.zoneCount + " " + e.zoneKills + "/" + e.zoneRequired; + Debug.Log("[RunGuide] " + LastTickLog); + } + // ───────────────────────────────────────────────────────────────────── // 목적지 지정 // ───────────────────────────────────────────────────────────────────── @@ -247,6 +321,7 @@ namespace WL.Combat.Run s_state = RunGuideState.Off; s_zoneIndex = -1; s_hitUntil = 0f; + s_stillSince = -1f; s_lastPosTime = -1f; if (had) Log("가이드 종료 — " + reason); } @@ -255,6 +330,7 @@ namespace WL.Combat.Run s_state = RunGuideState.Travel; s_travelStart = Time.unscaledTime; s_nextRepath = 0f; + s_stillSince = -1f; s_lastPosTime = -1f; // 813w2: 정지 판정을 새로 잡는다 } // ───────────────────────────────────────────────────────────────────── @@ -277,76 +353,176 @@ namespace WL.Combat.Run 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(); return; } + if (DSUtil.CheckNull(pc) || pc.IsDead()) { ReleaseSteer(); s_lastPosTime = -1f; 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; + bool isBoss = s_destKind == RunGuideDest.Boss; + if (isBoss) 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 (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) { - s_state = RunGuideState.Hold; - ReleaseSteer(); Arrived++; - Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " 도착 — " + d.ToString("F1") + "m · 자동전투 재개"); + 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) { - s_state = RunGuideState.Hold; // 포기하되 이탈 감시는 남긴다 - ReleaseSteer(); GaveUp++; - Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " 이동 포기 — " + - cfg.maxTravelSeconds.ToString("F0") + "s 초과(남은 " + d.ToString("F1") + "m)"); + EnterHold(isBoss, "이동 포기 — " + cfg.maxTravelSeconds.ToString("F0") + + "s 초과(남은 " + d.ToString("F1") + "m) · 이탈 감시는 유지"); return; } - ClearTravelTarget(pc); + if (suspend) { ReleaseSteer(); return; } // 813w 롤백 경로에서만 여기로 온다 + ClearTravelTarget(cfg, pc); Steer(cfg, pc, now); return; } - // ── Hold: 조종은 놓고, 먼 타깃만 무시 · 이탈하면 다시 끌어온다 - IgnoreFarTarget(cfg, pc); + // ── Hold: 조종은 놓고, 먼 타깃만 무시 · 이탈하면 **피격 중이라도** 다시 끌어온다 + // 피격 중(suspend)에는 「먼 타깃 무시」를 쉬어 준다 = 나를 때린 적에게 즉시 응전한다. + // 그래도 이탈 되돌림은 그대로 도므로 존/아레나 반경 밖으로는 못 나간다(D-19 · D-22). + if (!suspend) IgnoreFarTarget(cfg, pc); if (hold > 0f && d > hold) { EnterTravel(); HoldPullbacks++; - Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " 이탈 " + d.ToString("F1") + "m — 다시 유도"); - ClearTravelTarget(pc); + if (hit) PullbacksWhileHit++; + Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " 이탈 " + d.ToString("F1") + "m — 다시 유도" + + (hit ? "(피격 중)" : "")); + ClearTravelTarget(cfg, pc); Steer(cfg, pc, now); return; } ReleaseSteer(); } + /// Travel → Hold 로 확정한다(도착 · 정지 도착 · 이동 포기 공통). + static void EnterHold(bool isBoss, string why) + { + s_state = RunGuideState.Hold; + ReleaseSteer(); + s_stillSince = -1f; s_lastPosTime = -1f; + Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " " + why); + } + + /// + /// 피격 응전 판정(순수 · 프로브가 임시 SO 로 그대로 부른다 · C8 증거). + /// 🔴 813w2 의 핵심 — `hitSuspendOnlyInHold` 가 켜져 있으면 **Travel(= 이동·되돌림)에서는 + /// 피격이 가이드를 멈추지 못한다.** 813w 은 상태와 무관하게 멈췄고, 그 때문에 도착 판정(D-24)과 + /// 이탈 되돌림(D-22)이 폴링에서 통째로 빠졌다. + /// + public static bool SuspendDecision(WLRunGuideSettings cfg, bool hit, RunGuideState state) + { + if (!hit || cfg == null) return false; + return !cfg.hitSuspendOnlyInHold || state == RunGuideState.Hold; + } + + /// 정지 판정으로 도착을 인정할 거리인가 — 기본은 목적지 반경(holdRadius) 안(프로브가 그대로 부른다). + public static bool WithinStillRange(WLRunGuideSettings cfg, float d, float hold) + { + float lim = cfg.arriveStillMaxDistance > 0f ? cfg.arriveStillMaxDistance : hold; + return lim <= 0f || d <= lim; + } + + /// 조종 중 XZ 속도가 arriveStillSpeed 미만인 상태가 arriveStillSeconds 이상 이어졌는가. + static bool UpdateStill(WLRunGuideSettings cfg, Vector3 pcPos, float now) + { + if (s_state != RunGuideState.Travel || !s_steering) + { s_stillSince = -1f; s_lastPosTime = -1f; s_lastPos = pcPos; return false; } + + if (s_lastPosTime < 0f) { s_lastPos = pcPos; s_lastPosTime = now; s_stillSince = -1f; return false; } + + float dt = now - s_lastPosTime; + float moved = DistXZ(pcPos, s_lastPos); + if (dt > 0f) { s_lastPos = pcPos; s_lastPosTime = now; } + return StepStillDetect(cfg, moved, dt, now, ref s_stillSince); + } + + /// + /// 정지 판정 1스텝(순수 · Actor 접근 0). 프로브가 가짜 이동량·시각으로 그대로 부를 수 있다. + /// moved = 지난 스텝 동안의 XZ 이동량(m) · dt = 그 간격(초) · stillSince = 정지 시작 시각(없으면 −1). + /// + public static bool StepStillDetect(WLRunGuideSettings cfg, float moved, float dt, float now, ref float stillSince) + { + if (cfg == null || !cfg.arriveStillEnabled || cfg.arriveStillSeconds <= 0f) { stillSince = -1f; return false; } + if (dt > 0f) + { + if (moved / dt < cfg.arriveStillSpeed) { if (stillSince < 0f) stillSince = now; } + else stillSince = -1f; + } + return stillSince >= 0f && now - stillSince >= cfg.arriveStillSeconds; + } + + /// 존 체류·아레나 체류·이동 초를 누적한다(§1-3 QA 시각표). + static void AccumDwell(float now, bool isBoss) + { + if (s_lastAccumTime >= 0f) + { + float dt = now - s_lastAccumTime; + if (dt > 0f && dt < 1f) // 프레임 튐·일시정지 구간은 버린다 + { + if (s_state == RunGuideState.Travel) TravelSeconds += dt; + else if (s_state == RunGuideState.Hold) { if (isBoss) BossHoldSeconds += dt; else ZoneHoldSeconds += dt; } + } + } + s_lastAccumTime = now; + } + // ───────────────────────────────────────────────────────────────────── // 조종 · 타깃 // ───────────────────────────────────────────────────────────────────── - /// 이동 중에는 타깃을 놓는다(보스가 목적지일 때 보스는 예외 — 그쪽으로 가는 것이 목적이다). - static void ClearTravelTarget(PCActor pc) + /// + /// 이동·되돌림 중에는 타깃을 놓는다. + /// 🔴 813w2 §1-2: 보스 타깃도 놓는다(`pullbackReleasesTarget`). 타깃이 남아 있으면 원본 + /// `Play_Run() → Set_Path(타깃)`(MyActor.cs:335)이 매 폴링 우리 목적지를 덮어써 두 이동이 싸운다. + /// 보스 락(`m_IsLockTarget`)은 유지되므로 잡몹이 타깃을 가져가지 못한다 — 도착(Hold)하면 + /// `ApplyBossPriority` 가 즉시 보스를 다시 건다. + /// + static void ClearTravelTarget(WLRunGuideSettings cfg, PCActor pc) { if (!pc.isTarget) return; var t = pc.Get_Target(); if (DSUtil.CheckNull(t)) return; - if (s_destKind == RunGuideDest.Boss && !DSUtil.CheckNull(s_boss) && ReferenceEquals(t, s_boss)) 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++; @@ -383,11 +559,15 @@ namespace WL.Combat.Run s_steering = true; } - pc.WL_PlayRunNow(); // 타깃이 없으므로 경로를 건드리지 않는다(MyActor.cs:335) + // 🔴 813w2: 타깃이 남아 있으면 Play_Run() 이 `Set_Path(타깃)`(MyActor.cs:335)으로 경로를 덮어쓴다. + // 보통은 바로 위 ClearTravelTarget 이 타깃을 비웠으므로 그런 일이 없지만, 원본이 같은 프레임에 + // 다시 잡는 경우가 있어 **타깃이 있으면 이 폴링에서 목적지를 다시 지정**해 우리 쪽이 이기게 한다. + bool targeted = pc.isTarget; + pc.WL_PlayRunNow(); agent.isStopped = false; agent.stoppingDistance = cfg.guideStopDistance; - if (now >= s_nextRepath) + if (targeted || now >= s_nextRepath) { s_nextRepath = now + Mathf.Max(0f, cfg.repathIntervalSeconds); agent.speed = pc.Get_MoveSpeed(); @@ -426,6 +606,11 @@ namespace WL.Combat.Run 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) @@ -534,19 +719,33 @@ namespace WL.Combat.Run { GuideBegun = Arrived = GaveUp = FarTargetIgnored = HoldPullbacks = 0; Repaths = BossRetargets = BossLocks = HitSuspends = AnchorScans = 0; - LastLog = LastIgnoredTarget = ""; + 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 = 0f; + 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; } + /// 프로브용: 조종 플래그를 직접 세운다(에디트 모드엔 NavMeshAgent 가 없어 Steer 가 되돌아간다). + public static void SetSteeringForProbe(bool on) { s_steering = on; if (!on) { s_stillSince = -1f; s_lastPosTime = -1f; } } + /// 프로브용: 상태를 직접 읽는다(정지 판정 내부 시각). + public static float StillSince { get { return s_stillSince; } } + /// 프로브용: 러너 없이 1회 폴링(강제 틱). public static void ForceTick() { s_nextPoll = 0f; Tick(); } /// 프로브용: 피격 응전 구간을 직접 건다. - public static void SimulateHit() { var c = Cfg; if (c != null) { s_hitUntil = Time.unscaledTime + c.resumeAfterHitSeconds; HitSuspends++; } } + public static void SimulateHit() + { + var c = Cfg; if (c == null) return; + float hold = s_destKind == RunGuideDest.Boss ? c.bossHitSuspendSeconds : c.resumeAfterHitSeconds; + if (hold <= 0f) hold = 0.01f; // 프로브가 「피격 중」 상태를 만들 수 있게 최소값 + s_hitUntil = Time.unscaledTime + hold; HitSuspends++; + } /// 프로브용: 피격 응전 구간을 즉시 푼다. public static void ClearHitSuspendForProbe() { s_hitUntil = 0f; } @@ -554,6 +753,9 @@ namespace WL.Combat.Run /// 프로브용: 보스 참조를 직접 넣는다. public static void SetBossForProbe(Actor boss) { s_boss = boss; } + /// 프로브용: 이동 시작 시각을 밀어 넣는다(에디트 모드에서는 Time 이 흐르지 않아 maxTravelSeconds 를 못 넘긴다). + public static void SetTravelStartForProbe(float t) { s_travelStart = t; } + // ───────────────────────────────────────────────────────────────────── // 런너 (숨김 GameObject 1개 · 플레이 모드 전용) // ───────────────────────────────────────────────────────────────────── diff --git a/Assets/WL/Combat/Run/WLRunGuideSettings.cs b/Assets/WL/Combat/Run/WLRunGuideSettings.cs index 2a0d27ef9..96b121683 100644 --- a/Assets/WL/Combat/Run/WLRunGuideSettings.cs +++ b/Assets/WL/Combat/Run/WLRunGuideSettings.cs @@ -83,7 +83,7 @@ namespace WL.Combat.Run public bool guideOnRunStart = false; [Tooltip("존 앵커에 이 거리(m · XZ) 안으로 들어오면 「도착」 = 조종을 놓고 원본 자동전투에 넘긴다.")] - public float zoneArriveRadius = 3f; + public float zoneArriveRadius = 6f; [Tooltip("도착 뒤에도 PC 가 앵커에서 이 거리(m) 밖으로 밀려나면 다시 끌어온다(존1 로 되돌아가는 것을 막는다). 0 이하면 유지 안 함.")] public float zoneHoldRadius = 18f; @@ -101,8 +101,9 @@ namespace WL.Combat.Run // ─────────────────────────────────────────── 공통 조종 규칙 [Header("조종 규칙 (원본 타깃/이동 API 만 쓴다)")] [Tooltip("가이드 중 PC 의 타깃이 목적지(앵커/아레나 중심)에서 이 거리(m) 밖이면 무시한다(Del_Target). " - + "이 안의 적은 원본 자동전투가 그대로 잡는다.")] - public float guideLeashRadius = 12f; + + "이 안의 적은 원본 자동전투가 그대로 잡는다. " + + "🔴 813w2: zoneHoldRadius(18) 보다 작게 두되 너무 작으면 존 안의 적을 스스로 버려 화면 내 적이 줄어든다(D-19).")] + public float guideLeashRadius = 14f; [Tooltip("조종 중 NavMeshAgent.stoppingDistance(m). 목적지 코앞에서 멈춰 서서 도착 판정이 안 나는 것을 막는다.")] public float guideStopDistance = 0.5f; @@ -113,9 +114,54 @@ namespace WL.Combat.Run [Tooltip("PC 가 맞으면 이 시간(초) 동안 가이드를 멈추고 즉시 응전한다(원본 자동전투가 잡는다). 0 이면 응전 중단 없음.")] public float resumeAfterHitSeconds = 3f; - [Tooltip("한 목적지에 이 시간(초) 안에 도착 못 하면 가이드를 포기한다(경로 불가·지형 끼임 보호). 0 이하면 무제한.")] + [Tooltip("한 목적지에 이 시간(초) 안에 도착 못 하면 가이드를 포기한다(경로 불가·지형 끼임 보호). 0 이하면 무제한. " + + "포기해도 상태는 Hold 로 떨어져 이탈 감시(되돌림)는 계속한다.")] public float maxTravelSeconds = 60f; + // ─────────────────────────────────────────── 813w2 §1-1 · §1-2 (D-24 · D-22 공통 원인) + [Header("피격 응전 범위 (813w2 · D-22/D-24 공통 원인)")] + [Tooltip("🔴 813w 는 피격 중단이 「도착 판정·이탈 되돌림」까지 통째로 막았다(RunGuide.cs:291). " + + "켜면 피격 중단은 Hold(= 목적지 반경 안)에서만 걸고, 이동·되돌림 중에는 무시한다. " + + "끄면 813w 그대로(C8 롤백 · Q5 실측 = 존 Arrived 0 · 아레나 이탈 46 m).")] + public bool hitSuspendOnlyInHold = true; + + [Tooltip("보스 Hold 에서의 피격 중단(초). 존은 resumeAfterHitSeconds 를 쓴다. " + + "0 이면 보스전에서는 피격 중단 없음(되돌림·보스 타깃이 항상 우선).")] + public float bossHitSuspendSeconds = 0.5f; + + // ─────────────────────────────────────────── 813w2 §1-1 도착 판정(정지 판정) + [Header("도착 판정 보강 (813w2 · D-24 · Q5 §4-2 「존 Arrived=0」)")] + [Tooltip("도착 반경 안으로 못 들어가도, 조종 중 PC 가 사실상 멈춰 서면 도착으로 확정한다 " + + "(NavMesh 높이·stoppingDistance·지형 때문에 반경 안으로 못 붙는 경우). 끄면 반경 판정만.")] + public bool arriveStillEnabled = true; + + [Tooltip("「멈춰 섰다」로 보는 시간(초 · unscaled). 이 시간 내내 arriveStillSpeed 미만이면 도착.")] + public float arriveStillSeconds = 0.5f; + + [Tooltip("「멈춰 섰다」로 보는 XZ 속도(m/s). 이 값 미만이면 정지로 센다.")] + public float arriveStillSpeed = 0.3f; + + [Tooltip("정지 판정으로 도착을 확정할 수 있는 최대 거리(m). 목적지 반경(holdRadius) 안일 때만 인정한다 — " + + "0 이면 holdRadius 를 그대로 쓴다. 훨씬 멀리서 끼인 경우는 maxTravelSeconds 가 처리한다.")] + public float arriveStillMaxDistance = 0f; + + // ─────────────────────────────────────────── 813w2 §1-2 되돌림 + [Header("되돌림 (813w2 · D-22 · 이탈 46 m)")] + [Tooltip("이동·되돌림 중에는 보스 타깃도 놓는다. " + + "🔴 이유: 타깃이 있으면 원본 Play_Run() 이 매 폴링 Set_Path(타깃)(MyActor.cs:335)으로 " + + "우리 목적지를 덮어써 두 이동이 싸운다. 끄면 813w 그대로(보스 타깃 유지).")] + public bool pullbackReleasesTarget = true; + + [Tooltip("보스 재타깃(Change_Target)은 Hold 에서만 한다(락 m_IsLockTarget 은 언제나 유지). " + + "끄면 이동 중에도 매 폴링 보스로 되돌린다(= 813w · pullbackReleasesTarget 와 서로 싸운다).")] + public bool bossRetargetOnlyInHold = true; + + // ─────────────────────────────────────────── 813w2 §1-3 진행 로그 + [Header("진행 로그 (813w2 · D-19 · QA 시각표)")] + [Tooltip("RunTick 마다 「존 체류 초 · 이동 초 · 목적지 거리 · 최대 이탈」 한 줄을 남기는 주기(초). " + + "0 이하면 남기지 않는다. QA 가 존 도착 시각표를 이 줄로 적는다.")] + public float runTickLogSeconds = 10f; + // ─────────────────────────────────────────── 보스 [Header("보스 (D-11 · 아레나 이탈 → 리쉬 만피 리셋)")] [Tooltip("끄면 게이트 개방·보스 등장 때 아레나로 유도하지 않는다.")] diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset index 1a31f79a8..c07c47ab4 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset @@ -44,7 +44,7 @@ MonoBehaviour: playerLeaveGraceSeconds: 1.5 resetHealsBoss: 1 resetHealRate: 1 - leashHealPercent: 0.15 + leashHealPercent: 0.05 leashHealCooldownSeconds: 10 bossNameFromTable: 1 suppressRegenAfterBossKill: 1 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset index 850c134de..1fc838fcc 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset @@ -17,18 +17,27 @@ MonoBehaviour: pollSeconds: 0.1 zoneGuideEnabled: 1 guideOnRunStart: 0 - zoneArriveRadius: 3 + zoneArriveRadius: 6 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 + guideLeashRadius: 14 guideStopDistance: 0.5 repathIntervalSeconds: 0.5 resumeAfterHitSeconds: 3 maxTravelSeconds: 60 + hitSuspendOnlyInHold: 1 + bossHitSuspendSeconds: 0.5 + arriveStillEnabled: 1 + arriveStillSeconds: 0.5 + arriveStillSpeed: 0.3 + arriveStillMaxDistance: 0 + pullbackReleasesTarget: 1 + bossRetargetOnlyInHold: 1 + runTickLogSeconds: 10 bossGuideEnabled: 1 bossPriorityEnabled: 1 bossArriveRadius: 10