[WL-813w2] 런 가이드 2차: 도착 판정 · 보스 홀드 · 군중 유지 · 리쉬 축소 (#813)

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 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-09 13:22:50 +09:00
parent 7494047e50
commit 5e97e26a91
8 changed files with 915 additions and 43 deletions

View File

@ -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

View File

@ -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 <wt> --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<PCActor>();
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<Actor>();
s_boss.m_Role = eRole.Mob; s_boss.m_SubRole = eSubRol.Boss;
s_mobGo = new GameObject("__WL813w2_Mob");
s_mob = s_mobGo.AddComponent<Actor>();
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<WLBossArenaSettings>();
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<WLRunGuideSettings>();
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);
}
}

View File

@ -490,7 +490,8 @@ namespace WL.Combat.Boss
/// <summary>
/// 보스를 아레나 중심으로 되돌린다.
/// 🔴 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).
/// </summary>

View File

@ -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;

View File

@ -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;
/// <summary>813w2: 정지 판정으로 확정한 도착 수(Arrived 에도 함께 센다) · 피격 중에도 실행한 되돌림 수.</summary>
public static int ArrivedByStill, PullbacksWhileHit;
/// <summary>813w2: 목적지에서 잰 최대 이탈 거리(m · Q5 D-22 의 「46.3 m」에 대응하는 실측값).</summary>
public static float MaxDistFromDest;
/// <summary>813w2: 존 체류 초 · 보스(아레나) 체류 초 · 이동(Travel) 초 — RunTick 로그와 QA 시각표용.</summary>
public static float ZoneHoldSeconds, BossHoldSeconds, TravelSeconds;
public static string LastLog = "";
/// <summary>마지막으로 무시한 타깃 이름(진단).</summary>
public static string LastIgnoredTarget = "";
/// <summary>813w2: 마지막 RunTick 요약 줄(프로브·QA 가 읽는다).</summary>
public static string LastTickLog = "";
// ───────────────────────────────────────── 내부 상태
static RunGuideState s_state;
@ -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<BossStartedEvent> h_bossStarted = OnBossStarted;
static readonly CombatHandler<RunResult> h_runEnded = OnRunEnded;
static readonly CombatHandler<DamagedEvent> h_damaged = OnDamaged;
static readonly CombatHandler<RunTickEvent> h_runTick = OnRunTick; // 813w2 §1-3 진행 로그
// ─────────────────────────────────────────────────────────────────────
// 구독
@ -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++;
}
/// <summary>813w2 §1-3 — 존 체류/이동 초를 주기적으로 한 줄 남긴다(QA 시각표 · GC 는 로그를 실제로 낼 때만).</summary>
static void OnRunTick(in RunTickEvent e)
{
var cfg = Cfg;
if (cfg == null || cfg.runTickLogSeconds <= 0f) return;
if (e.elapsedSec < s_nextTickLog) return;
s_nextTickLog = e.elapsedSec + cfg.runTickLogSeconds;
LastTickLog = "t=" + e.elapsedSec.ToString("F0") + "s · " + s_state +
(s_destKind == RunGuideDest.Zone ? " 존" + s_zoneIndex : s_destKind == RunGuideDest.Boss ? " 보스" : " -") +
" · 존체류 " + ZoneHoldSeconds.ToString("F0") + "s · 아레나체류 " + BossHoldSeconds.ToString("F0") +
"s · 이동 " + TravelSeconds.ToString("F0") + "s · 최대이탈 " + MaxDistFromDest.ToString("F1") +
"m · 도착 " + Arrived + "(정지 " + ArrivedByStill + ") · 되돌림 " + HoldPullbacks +
" · 존 " + e.zonesCleared + "/" + e.zoneCount + " " + e.zoneKills + "/" + e.zoneRequired;
Debug.Log("[RunGuide] " + LastTickLog);
}
// ─────────────────────────────────────────────────────────────────────
// 목적지 지정
// ─────────────────────────────────────────────────────────────────────
@ -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();
}
/// <summary>Travel → Hold 로 확정한다(도착 · 정지 도착 · 이동 포기 공통).</summary>
static void EnterHold(bool isBoss, string why)
{
s_state = RunGuideState.Hold;
ReleaseSteer();
s_stillSince = -1f; s_lastPosTime = -1f;
Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " " + why);
}
/// <summary>
/// 피격 응전 판정(순수 · 프로브가 임시 SO 로 그대로 부른다 · C8 증거).
/// 🔴 813w2 의 핵심 — `hitSuspendOnlyInHold` 가 켜져 있으면 **Travel(= 이동·되돌림)에서는
/// 피격이 가이드를 멈추지 못한다.** 813w 은 상태와 무관하게 멈췄고, 그 때문에 도착 판정(D-24)과
/// 이탈 되돌림(D-22)이 폴링에서 통째로 빠졌다.
/// </summary>
public static bool SuspendDecision(WLRunGuideSettings cfg, bool hit, RunGuideState state)
{
if (!hit || cfg == null) return false;
return !cfg.hitSuspendOnlyInHold || state == RunGuideState.Hold;
}
/// <summary>정지 판정으로 도착을 인정할 거리인가 — 기본은 목적지 반경(holdRadius) 안(프로브가 그대로 부른다).</summary>
public static bool WithinStillRange(WLRunGuideSettings cfg, float d, float hold)
{
float lim = cfg.arriveStillMaxDistance > 0f ? cfg.arriveStillMaxDistance : hold;
return lim <= 0f || d <= lim;
}
/// <summary>조종 중 XZ 속도가 arriveStillSpeed 미만인 상태가 arriveStillSeconds 이상 이어졌는가.</summary>
static bool UpdateStill(WLRunGuideSettings cfg, Vector3 pcPos, float now)
{
if (s_state != RunGuideState.Travel || !s_steering)
{ s_stillSince = -1f; s_lastPosTime = -1f; s_lastPos = pcPos; return false; }
if (s_lastPosTime < 0f) { s_lastPos = pcPos; s_lastPosTime = now; s_stillSince = -1f; return false; }
float dt = now - s_lastPosTime;
float moved = DistXZ(pcPos, s_lastPos);
if (dt > 0f) { s_lastPos = pcPos; s_lastPosTime = now; }
return StepStillDetect(cfg, moved, dt, now, ref s_stillSince);
}
/// <summary>
/// 정지 판정 1스텝(순수 · Actor 접근 0). 프로브가 가짜 이동량·시각으로 그대로 부를 수 있다.
/// moved = 지난 스텝 동안의 XZ 이동량(m) · dt = 그 간격(초) · stillSince = 정지 시작 시각(없으면 1).
/// </summary>
public static bool StepStillDetect(WLRunGuideSettings cfg, float moved, float dt, float now, ref float stillSince)
{
if (cfg == null || !cfg.arriveStillEnabled || cfg.arriveStillSeconds <= 0f) { stillSince = -1f; return false; }
if (dt > 0f)
{
if (moved / dt < cfg.arriveStillSpeed) { if (stillSince < 0f) stillSince = now; }
else stillSince = -1f;
}
return stillSince >= 0f && now - stillSince >= cfg.arriveStillSeconds;
}
/// <summary>존 체류·아레나 체류·이동 초를 누적한다(§1-3 QA 시각표).</summary>
static void AccumDwell(float now, bool isBoss)
{
if (s_lastAccumTime >= 0f)
{
float dt = now - s_lastAccumTime;
if (dt > 0f && dt < 1f) // 프레임 튐·일시정지 구간은 버린다
{
if (s_state == RunGuideState.Travel) TravelSeconds += dt;
else if (s_state == RunGuideState.Hold) { if (isBoss) BossHoldSeconds += dt; else ZoneHoldSeconds += dt; }
}
}
s_lastAccumTime = now;
}
// ─────────────────────────────────────────────────────────────────────
// 조종 · 타깃
// ─────────────────────────────────────────────────────────────────────
/// <summary>이동 중에는 타깃을 놓는다(보스가 목적지일 때 보스는 예외 — 그쪽으로 가는 것이 목적이다).</summary>
static void ClearTravelTarget(PCActor pc)
/// <summary>
/// 이동·되돌림 중에는 타깃을 놓는다.
/// 🔴 813w2 §1-2: 보스 타깃도 놓는다(`pullbackReleasesTarget`). 타깃이 남아 있으면 원본
/// `Play_Run() → Set_Path(타깃)`(MyActor.cs:335)이 매 폴링 우리 목적지를 덮어써 두 이동이 싸운다.
/// 보스 락(`m_IsLockTarget`)은 유지되므로 잡몹이 타깃을 가져가지 못한다 — 도착(Hold)하면
/// `ApplyBossPriority` 가 즉시 보스를 다시 건다.
/// </summary>
static void ClearTravelTarget(WLRunGuideSettings cfg, PCActor pc)
{
if (!pc.isTarget) return;
var t = pc.Get_Target();
if (DSUtil.CheckNull(t)) return;
if (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;
}
/// <summary>프로브용: 조종 플래그를 직접 세운다(에디트 모드엔 NavMeshAgent 가 없어 Steer 가 되돌아간다).</summary>
public static void SetSteeringForProbe(bool on) { s_steering = on; if (!on) { s_stillSince = -1f; s_lastPosTime = -1f; } }
/// <summary>프로브용: 상태를 직접 읽는다(정지 판정 내부 시각).</summary>
public static float StillSince { get { return s_stillSince; } }
/// <summary>프로브용: 러너 없이 1회 폴링(강제 틱).</summary>
public static void ForceTick() { s_nextPoll = 0f; Tick(); }
/// <summary>프로브용: 피격 응전 구간을 직접 건다.</summary>
public static void SimulateHit() { var c = Cfg; if (c != null) { 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++;
}
/// <summary>프로브용: 피격 응전 구간을 즉시 푼다.</summary>
public static void ClearHitSuspendForProbe() { s_hitUntil = 0f; }
@ -554,6 +753,9 @@ namespace WL.Combat.Run
/// <summary>프로브용: 보스 참조를 직접 넣는다.</summary>
public static void SetBossForProbe(Actor boss) { s_boss = boss; }
/// <summary>프로브용: 이동 시작 시각을 밀어 넣는다(에디트 모드에서는 Time 이 흐르지 않아 maxTravelSeconds 를 못 넘긴다).</summary>
public static void SetTravelStartForProbe(float t) { s_travelStart = t; }
// ─────────────────────────────────────────────────────────────────────
// 런너 (숨김 GameObject 1개 · 플레이 모드 전용)
// ─────────────────────────────────────────────────────────────────────

View File

@ -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("끄면 게이트 개방·보스 등장 때 아레나로 유도하지 않는다.")]

View File

@ -44,7 +44,7 @@ MonoBehaviour:
playerLeaveGraceSeconds: 1.5
resetHealsBoss: 1
resetHealRate: 1
leashHealPercent: 0.15
leashHealPercent: 0.05
leashHealCooldownSeconds: 10
bossNameFromTable: 1
suppressRegenAfterBossKill: 1

View File

@ -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