diff --git a/AgentScripts/WL813w4_PROBE.txt b/AgentScripts/WL813w4_PROBE.txt new file mode 100644 index 000000000..2673a75f9 --- /dev/null +++ b/AgentScripts/WL813w4_PROBE.txt @@ -0,0 +1,102 @@ +# WL813w4 Probe 2026-09-09 16:18:12 (edit mode · Play 0 · 로그인 0) +playMode=False + +── ⓪ 준비 + [PASS] 가짜 메인 PC IsMainPC()=true · MyValue.MyPC 등록 + [PASS] RunGuide.Active(에셋 · enabled · 런 축) + [PASS] RunGuide.Subscribed — 🔴 D-34 의 「RunStarted 구독이 없다」 추정은 여기서 이미 반증된다 + +── ① 에셋 · SOT (813w4 신규 필드 · C45 · 코드 상수 0) + [PASS] WLRunGuideSettings 로드 · [PASS] WLGrowthSettings 로드 + [가이드 신규 9] guideOnRestart=True restartResetsGuideState=True stallGuideSeconds=3 stallMoveEpsilon=0.5 elitePriorityEnabled=True eliteTargetRadius=12 eliteReleaseRadius=18 eliteLockTarget=True eliteRetargetOnlyInHold=True + [성장 신규 2 + 값 2] burstMaxScreenFraction=0.5 burstAlpha=0.6 burstScale=0.5 burstLifetimeSeconds=0.8 + [PASS] 「다음 런」 재유도 on(D-34) · guideOnRunStart 는 False 그대로(런 1 동작 무변경) + [PASS] 정지 감시 3s / 0.5m + [PASS] 엘리트 우선 타깃 on · 반경 12 m(발주서 §1-2) + [PASS] 해제 반경(18) > 고정 반경(12) — 경계에서 떨리지 않는다 + [PASS] 고정 반경(12) ≤ zoneHoldRadius(18) — 엘리트를 쫓다 존 밖으로 나가면 Hold 되돌림이 먼저 걸린다 + [PASS] 화면 점유 상한 0.5 ≤ 0.5(발주서 §1-3 「화면 50 % 이하」) + [PASS] 알파 0.6(발주서 §1-3) + [PASS] 지속 0.8 s(발주서 §1-3) + 존 축 실측 — zoneSpawnerIds=[813001,813002] zoneClearKills=[18,20] (Q7 「존 진행 0/18」 = 존0 요구 처치) + [PASS] 존0 앵커 확보 (115.0,0.4,-22.0) · 스포너 노드=False — 🔴 재시작 좌표 (115.0,0.3,11.0) 에서 33.0 m (Q7 D-34 의 정지 좌표 · 원본 자동전투 탐지 범위 밖) + +── ② D-34 「다음 런」 뒤 존0 유도 재시작 (발주서 §1-1) + 🔴 원인 진리표 — ShouldGuideOnStart(cfg, restart) + [PASS] 813w3 · 런 1 = false (원본 자동전투에 맡긴다 — 맵 진입점이 존0 안이라 굴러갔다) + [PASS] 813w3 · 다음 런 = **false** ← 🔴 D-34 의 끊긴 지점(RunGuide.cs:311 `cfg.guideOnRunStart`) + [PASS] 813w4 · 런 1 = false (런 1 동작 무변경 = 회귀 0) + [PASS] 813w4 · 다음 런 = **true** + [PASS] reason 판정 — "restart"(813p 보고 §4 계약) = 재시작 + [PASS] "pc" · "map N" 은 재시작 아님 + [PASS] 런 1(reason="pc") → GuideBegun 0→0 (guideOnRunStart=False 이므로 그대로 = 813w3 동작 보존) + [PASS] 「다음 런」(reason="restart") → GuideBegun 0→1 (**Q7 이 「2 에서 안 오른다」고 적은 그 값**) + [PASS] State=Travel (Q7 실측 Off → Travel) + [PASS] 목적지=존 0 (115.0,0.4,-22.0) + [PASS] RestartGuides=1 + [PASS] Restart ×2 → 유도 재개 2회(RestartGuides=2 · State=Travel) + 고장(예외) 상태 강제 — Faulted=True + [PASS] 재시작이 고장 플래그를 푼다(restartResetsGuideState) — 런 1 의 예외가 세션을 못 죽인다 + [PASS] RunDirector.Restart() 실행 — RestartCount=1 StartCount=2 + [PASS] 실 경로에서도 GuideBegun 0→1 + [PASS] 재시작 직후 State=Travel 목적지=Zone (115.0,0.4,-22.0) + [PASS] RunDirector.Phase=Zones + (참고) RunDirector 로그 = 재시작 일부 실패 — Object reference not set to an instance of an object + +── ③ D-34 안전망 — 정지 감시 TrackIdleStall (원인과 무관하게 「166 s 정지」를 되돌린다) + [PASS] 제자리 3 s → 유도(첫관측 False · 2.0s False · 3.5s True) + [PASS] StallGuides=1 목적지=존 0 + [PASS] 3 m 이동하면 타이머를 다시 잡는다(유도 안 함) + [PASS] 🔴 타깃 보유(제자리 교전) → 유도 0 — 전투를 끊지 않는다 + [PASS] Phase=Ended(존 단계 아님) → 유도 0(결과 화면·로비에서 안 돈다) + [PASS] 이미 유도 중(DestKind=Zone) → 중복 유도 0 + +── ④ D-20 엘리트 우선 타깃 (발주서 §1-2 · Q7 TTK 45.2/77.3 s · 원인 = PC 가 엘리트를 안 때린다) + [PASS] 진짜 Spawned 3건(엘리트 2 · 잡몹 1) → 등록부 2 (용량 16 · 잡몹은 안 들어간다) + [PASS] 813s EliteMarker 구독=False (식별 출처가 같다 — SpawnedEvent.isElite = Actor.IsSubRole(Elite)) + [PASS] 반경 12 m 안의 최근접 엘리트 = __WL813w4_Elite (30 m 엘리트는 제외) + [PASS] 200 m 떨어지면 후보 0 + (전) 타깃=__WL813w4_Mob + [PASS] 엘리트 고정 = __WL813w4_Elite · EliteLocks=1 + [PASS] 원본 락타깃 m_IsLockTarget=True(잡몹이 타깃을 도로 못 가져간다 · 813w 보스와 같은 방식) + [PASS] PC 타깃 = __WL813w4_Elite · EliteRetargets=1 + [PASS] 폴링 ×2 추가 → 락 1 재타깃 1 (멱등) + [PASS] 틱 1회 뒤에도 타깃 유지(먼 타깃 무시가 고정 엘리트를 건드리지 않는다) + [PASS] Travel 에서는 재타깃 0(원본 Set_Path 덮어쓰기 방지) · 락은 유지 True + [PASS] 40 m 로 이탈 → 고정 해제(EliteReleases=1) + [PASS] 원본 락타깃 원복 m_IsLockTarget=False + [PASS] 5 m 로 복귀 → 재고정 + [PASS] 진짜 Killed(Elite) → 고정 해제(EliteReleases 1→2) + [PASS] 처치 시에도 원본 락타깃 원복 + [PASS] 등록부에서 제거 → 남은 엘리트 1 + [PASS] 킬캠·BossKill 티어 = `Killed` 이벤트만 보는 813s/811gh 경로 — RunGuide 는 구독만 하고 아무것도 바꾸지 않는다 + [PASS] 보스 유도로 전환 → 엘리트 고정 해제(보스 우선 · 고정 전 True 후 False) + +── ⑤ D-37 레벨업 「LV」 연출 — 화면 점유 상한 · 알파 · 지속 (발주서 §1-3) + 🔴 원인 실측 — FX_FireLevelUp 프리팹의 ParticleSystemRenderer.maxParticleSize + 렌더러 9개 · FX_FireLevelUp=0.50 flare=1.00 lb=1.00 alpha_ball=1.00 grad10=1.00 ring_orange=1.00 gamestar=1.00 fire_burst=1.00 grad10=1.00 + [PASS] 프리팹 최대 점유 1.00 (1.0 = 파티클 1개가 화면 높이 100 % · 8개가 1.0) ← 「LV 가 화면 대부분을 덮는다」의 값 근거 + [PASS] 점유 상한 적용 2개(1.0 짜리 2개만 · 0.2 짜리는 그대로) + [PASS] A=0.50 C=0.20 (화면 50 % 이하 보장) + [PASS] 적용 후 최대 점유 0.50 + [PASS] Color 모드 알파 1.00 → 0.60 + [PASS] TwoColors 알파 (1.00,0.50) → (0.60,0.30) + [PASS] 알파 적용 2 · 건너뜀 1(Gradient 모드 — 새 Gradient 할당을 피한다) + 측정 수명 10.00 s · 에셋 burstLifetimeSeconds=0.8 → 실 수명 0.80 s + [PASS] 🔴 프리팹은 그대로 1.00 — 인스턴스에만 걸었다(에셋·머티리얼 무수정) + [PASS] C8: burstMaxScreenFraction=0 · burstAlpha=1 → 렌더러·색 무변경(813k 동작 그대로) + +── ⑥ GC 0 (틱 경로 · 813w3 와 같은 기준) + [PASS] 엘리트 우선 타깃 폴링 ×100 = 0 B + [PASS] 정지 감시 폴링 ×100(미도달) = 0 B + [PASS] 등록부 조회 ×100 = 0 B (사전 할당 배열 16칸) + +── ⑦ C8 롤백 (각 스위치 0 → 813w3 동작) + [PASS] guideOnRestart=0 → 「다음 런」 유도 0 = 813w3 동작 + [PASS] stallGuideSeconds=0 → 정지 감시 0회 + [PASS] elitePriorityEnabled=0 → 고정 0 · 타깃 0 · 락 0 = 813w3 동작 + [PASS] eliteTargetRadius=0 → 후보 0(고정 0) + [PASS] RuntimeDisabled=1 → Active=false · 틱 ×10 무동작 + [PASS] (참고) WLGrowthSettings.Enabled=True — enabled=0 이면 813k 계약대로 이벤트 자체가 안 난다(값 필드도 안 읽힌다) + +RESULT PASS diff --git a/AgentScripts/WL813w4_Probe.cs b/AgentScripts/WL813w4_Probe.cs new file mode 100644 index 000000000..9a62cd940 --- /dev/null +++ b/AgentScripts/WL813w4_Probe.cs @@ -0,0 +1,609 @@ +// WL813w4_Probe.cs — #813w4(「다음 런」 가이드 재시작 · 엘리트 우선 타깃 · 레벨업 연출 값) 프로브 +// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 는 즉시 파괴/회수) +// +// 상주 에디터: +// unity command run_script --project-path --file AgentScripts/WL813w4_Probe.cs --entry WL813w4_Probe.RunAll --timeout 300 +// 결과: Console + AgentScripts/WL813w4_PROBE.txt (RESULT PASS / RESULT FAIL n) +// +// 무엇을 증명하나 (발주서 WL-813w4 §1-1~§1-4 · 근거 = qa/W2-7_재채점_v5.md D-34 · D-20 · D-37) +// ① 에셋·SOT — 813w4 신규 필드(가이드 9 · 성장 2) 값 · 반경 정합성 · 코드 상수 0 +// ② D-34 원인 — 「구독은 살아 있는데 판정에서 끊겼다」를 코드 경로로 실증 + RunDirector.Restart() ×2 재현 +// ③ D-34 안전망 — 정지 감시(TrackIdleStall) 진리표(교전 중 · 이동 중 · 존 단계 밖 · 연타) +// ④ D-20 엘리트 — 가짜 엘리트 스폰(진짜 CombatEvents.Spawned) → 락+재타깃 → 처치(진짜 Killed) → 해제 +// ⑤ D-37 값 — 화면 점유 상한(ParticleSystemRenderer.maxParticleSize)·알파·수명 · 프리팹 무수정 확인 +// ⑥ GC 0 — 엘리트 폴링 ×100 · 정지 감시 ×100 이 0 B +// ⑦ C8 롤백 — guideOnRestart / stallGuideSeconds / elitePriorityEnabled / burst 값 0 → 813w3 동작 +// +// 에디트 모드 한계(보고서 「미확인」) +// · Play 가 없으므로 실제 이동·타격·화면 크기는 못 본다. 판정과 상태 전이만 실측한다. +// · Time.unscaledTime 이 스크립트 실행 중 멈춰 있다 → 시간 의존 판정은 가짜 시각을 먹인다. +// · RunDirector.Restart() 의 PC 워프(On_Regen)는 m_Stat 이 없어 실패한다 — 원본이 try/catch 로 삼킨다(설계). + +using System; +using System.IO; +using System.Text; +using UnityEngine; +using WL.Combat.Core; +using WL.Combat.Growth; +using WL.Combat.Run; + +public static class WL813w4_Probe +{ + const string OutCommit = "AgentScripts/WL813w4_PROBE.txt"; + + static StringBuilder sb; + static int s_fail; + + static GameObject s_pcGo, s_eliteGo, s_elite2Go, s_mobGo, s_fxGo; + static PCActor s_pc; + static Actor s_elite, s_elite2, s_mob; + static PCActor s_prevMyPc; + + public static object RunAll() + { + sb = new StringBuilder(); + s_fail = 0; + sb.AppendLine("# WL813w4 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_Restart(); + Sec3_Stall(); + Sec4_Elite(); + Sec5_LevelFx(); + Sec6_GC(); + Sec7_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") + ")"; } + + /// 실에셋을 복사한 임시 SO(실에셋은 절대 안 건드린다). 쓰고 나면 DestroyImmediate. + static WLRunGuideSettings TmpGuide() + { + var t = UnityEngine.Object.Instantiate(WLRunGuideSettings.Instance); + t.hideFlags = HideFlags.HideAndDontSave; + return t; + } + + static WLGrowthSettings TmpGrowth() + { + var t = UnityEngine.Object.Instantiate(WLGrowthSettings.Instance); + t.hideFlags = HideFlags.HideAndDontSave; + return t; + } + + static void Setup() + { + s_prevMyPc = MyValue.MyPC; + + s_pcGo = new GameObject("__WL813w4_PC"); + s_pc = s_pcGo.AddComponent(); + s_pc.m_Role = eRole.PC; s_pc.m_SubRole = eSubRol.None; + MyValue.MyPC = s_pc; + + s_eliteGo = new GameObject("__WL813w4_Elite"); + s_elite = s_eliteGo.AddComponent(); + s_elite.m_Role = eRole.Mob; s_elite.m_SubRole = eSubRol.Elite; + + s_elite2Go = new GameObject("__WL813w4_Elite2"); + s_elite2 = s_elite2Go.AddComponent(); + s_elite2.m_Role = eRole.Mob; s_elite2.m_SubRole = eSubRol.Elite; + + s_mobGo = new GameObject("__WL813w4_Mob"); + s_mob = s_mobGo.AddComponent(); + s_mob.m_Role = eRole.Mob; s_mob.m_SubRole = eSubRol.None; + + RunGuide.EnsureSubscribed(); + RunGuide.ResetDiagnostics(); + RunDirector.ResetAll(); + WLRunGuideSettings.RuntimeDisabled = false; + + L(""); + L("── ⓪ 준비"); + L(" " + P(s_pc.IsMainPC()) + " 가짜 메인 PC IsMainPC()=true · MyValue.MyPC 등록"); + L(" " + P(RunGuide.Active) + " RunGuide.Active(에셋 · enabled · 런 축)"); + L(" " + P(RunGuide.Subscribed) + " RunGuide.Subscribed — 🔴 D-34 의 「RunStarted 구독이 없다」 추정은 여기서 이미 반증된다"); + } + + static void Teardown() + { + try + { + RunGuide.StopGuide("probe teardown"); + RunGuide.ResetDiagnostics(); + RunGuide.SetSteeringForProbe(false); + RunDirector.ResetAll(); + WLRunGuideSettings.RuntimeDisabled = false; + LevelUpBurst.ResetDiagnostics(); + MyValue.MyPC = s_prevMyPc; + Kill(s_pcGo, s_eliteGo, s_elite2Go, s_mobGo, s_fxGo); + } + 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 (813w4 신규 필드 · C45 · 코드 상수 0)"); + var c = WLRunGuideSettings.Instance; + var g = WLGrowthSettings.Instance; + L(" " + P(c != null) + " WLRunGuideSettings 로드 · " + P(g != null) + " WLGrowthSettings 로드"); + if (c == null || g == null) return; + + L(" [가이드 신규 9] guideOnRestart=" + c.guideOnRestart + + " restartResetsGuideState=" + c.restartResetsGuideState + + " stallGuideSeconds=" + c.stallGuideSeconds + " stallMoveEpsilon=" + c.stallMoveEpsilon + + " elitePriorityEnabled=" + c.elitePriorityEnabled + + " eliteTargetRadius=" + c.eliteTargetRadius + " eliteReleaseRadius=" + c.eliteReleaseRadius + + " eliteLockTarget=" + c.eliteLockTarget + " eliteRetargetOnlyInHold=" + c.eliteRetargetOnlyInHold); + L(" [성장 신규 2 + 값 2] burstMaxScreenFraction=" + g.burstMaxScreenFraction + + " burstAlpha=" + g.burstAlpha + " burstScale=" + g.burstScale + + " burstLifetimeSeconds=" + g.burstLifetimeSeconds); + + L(" " + P(c.guideOnRestart) + " 「다음 런」 재유도 on(D-34) · guideOnRunStart 는 " + c.guideOnRunStart + " 그대로(런 1 동작 무변경)"); + L(" " + P(c.stallGuideSeconds > 0f && c.stallMoveEpsilon > 0f) + + " 정지 감시 " + c.stallGuideSeconds + "s / " + c.stallMoveEpsilon + "m"); + L(" " + P(c.elitePriorityEnabled && Mathf.Approximately(c.eliteTargetRadius, 12f)) + + " 엘리트 우선 타깃 on · 반경 12 m(발주서 §1-2)"); + L(" " + P(c.eliteReleaseRadius > c.eliteTargetRadius) + + " 해제 반경(" + c.eliteReleaseRadius + ") > 고정 반경(" + c.eliteTargetRadius + ") — 경계에서 떨리지 않는다"); + L(" " + P(c.eliteTargetRadius <= c.zoneHoldRadius) + + " 고정 반경(" + c.eliteTargetRadius + ") ≤ zoneHoldRadius(" + c.zoneHoldRadius + + ") — 엘리트를 쫓다 존 밖으로 나가면 Hold 되돌림이 먼저 걸린다"); + L(" " + P(g.burstMaxScreenFraction > 0f && g.burstMaxScreenFraction <= 0.5f) + + " 화면 점유 상한 " + g.burstMaxScreenFraction + " ≤ 0.5(발주서 §1-3 「화면 50 % 이하」)"); + L(" " + P(Mathf.Approximately(g.burstAlpha, 0.6f)) + " 알파 0.6(발주서 §1-3)"); + L(" " + P(Mathf.Approximately(g.burstLifetimeSeconds, 0.8f)) + " 지속 0.8 s(발주서 §1-3)"); + + // 존 축 실측(D-34 의 「존 0/18」 이 어디서 오는지) + var rs = WLRunSettings.Instance; + if (rs != null && rs.zoneSpawnerIds != null) + L(" 존 축 실측 — zoneSpawnerIds=[" + string.Join(",", Array.ConvertAll(rs.zoneSpawnerIds, x => x.ToString())) + + "] zoneClearKills=[" + string.Join(",", Array.ConvertAll(rs.zoneClearKills, x => x.ToString())) + + "] (Q7 「존 진행 0/18」 = 존0 요구 처치)"); + Vector3 a0; + bool hasA0 = RunGuide.TryGetAnchor(c, 0, out a0); + L(" " + P(hasA0) + " 존0 앵커 확보 " + V(a0) + " · 스포너 노드=" + RunGuide.AnchorFromNode(0) + + " — 🔴 재시작 좌표 (115.0,0.3,11.0) 에서 " + + Mathf.Sqrt((a0.x - 115f) * (a0.x - 115f) + (a0.z - 10.98f) * (a0.z - 10.98f)).ToString("F1") + + " m (Q7 D-34 의 정지 좌표 · 원본 자동전투 탐지 범위 밖)"); + } + + // ───────────────────────────────────────── ② D-34 「다음 런」 재시작 + static void Sec2_Restart() + { + L(""); + L("── ② D-34 「다음 런」 뒤 존0 유도 재시작 (발주서 §1-1)"); + var c = WLRunGuideSettings.Instance; + if (c == null) return; + + // ②-1 원인 실증 — 판정 진리표(순수 함수 · 813w3 값 vs 813w4 값) + var old = TmpGuide(); old.guideOnRunStart = false; old.guideOnRestart = false; // 813w3 상태 재현 + var now = TmpGuide(); now.guideOnRunStart = false; now.guideOnRestart = true; // 813w4 + L(" 🔴 원인 진리표 — ShouldGuideOnStart(cfg, restart)"); + L(" " + P(!RunGuide.ShouldGuideOnStart(old, false)) + " 813w3 · 런 1 = false (원본 자동전투에 맡긴다 — 맵 진입점이 존0 안이라 굴러갔다)"); + L(" " + P(!RunGuide.ShouldGuideOnStart(old, true)) + " 813w3 · 다음 런 = **false** ← 🔴 D-34 의 끊긴 지점(RunGuide.cs:311 `cfg.guideOnRunStart`)"); + L(" " + P(!RunGuide.ShouldGuideOnStart(now, false)) + " 813w4 · 런 1 = false (런 1 동작 무변경 = 회귀 0)"); + L(" " + P(RunGuide.ShouldGuideOnStart(now, true)) + " 813w4 · 다음 런 = **true**"); + L(" " + P(RunGuide.IsRestartReason("restart")) + " reason 판정 — \"restart\"(813p 보고 §4 계약) = 재시작"); + L(" " + P(!RunGuide.IsRestartReason("pc") && !RunGuide.IsRestartReason("map 2")) + " \"pc\" · \"map N\" 은 재시작 아님"); + Kill2(old, now); + + // ②-2 핸들러 직접 — RunStarted(reason="restart") 를 실제 페이로드로 태운다 + RunGuide.ResetDiagnostics(); + RunGuide.SyncRestartCountForProbe(); + int begun0 = RunGuide.GuideBegun; + RunGuide.ProbeRunStarted(1, "pc"); + int begunAfterFirst = RunGuide.GuideBegun; + L(" " + P(begunAfterFirst == begun0) + " 런 1(reason=\"pc\") → GuideBegun " + begun0 + "→" + begunAfterFirst + + " (guideOnRunStart=" + c.guideOnRunStart + " 이므로 그대로 = 813w3 동작 보존)"); + + RunGuide.ProbeRunStarted(2, "restart"); + L(" " + P(RunGuide.GuideBegun == begunAfterFirst + 1) + " 「다음 런」(reason=\"restart\") → GuideBegun " + + begunAfterFirst + "→" + RunGuide.GuideBegun + " (**Q7 이 「2 에서 안 오른다」고 적은 그 값**)"); + L(" " + P(RunGuide.State == RunGuideState.Travel) + " State=" + RunGuide.State + " (Q7 실측 Off → Travel)"); + L(" " + P(RunGuide.DestKind == RunGuideDest.Zone && RunGuide.ZoneIndex == 0) + + " 목적지=존 " + RunGuide.ZoneIndex + " " + V(RunGuide.Destination)); + L(" " + P(RunGuide.RestartGuides == 1) + " RestartGuides=" + RunGuide.RestartGuides); + + // ②-3 2회 재현(Q7 은 2회 전부 재현했다) + RunGuide.ProbeRunStarted(3, "restart"); + L(" " + P(RunGuide.RestartGuides == 2 && RunGuide.State == RunGuideState.Travel) + + " Restart ×2 → 유도 재개 2회(RestartGuides=" + RunGuide.RestartGuides + " · State=" + RunGuide.State + ")"); + + // ②-4 고장 플래그 해제(restartResetsGuideState) + RunGuide.ForceFaultForProbe(); + L(" 고장(예외) 상태 강제 — Faulted=" + RunGuide.Faulted); + RunGuide.ProbeRunStarted(4, "restart"); + L(" " + P(!RunGuide.Faulted) + " 재시작이 고장 플래그를 푼다(restartResetsGuideState) — 런 1 의 예외가 세션을 못 죽인다"); + + // ②-5 진짜 RunDirector.Restart() 경로(구독 → 발행 → 핸들러 전 구간) + RunGuide.ResetDiagnostics(); + RunDirector.ResetAll(); + RunGuide.SyncRestartCountForProbe(); + MovePc(new Vector3(115f, 0.25f, 10.98f)); // Q7 D-34 의 정지 좌표 + RunDirector.StartRun("pc"); + int begunRun1 = RunGuide.GuideBegun; + RunDirector.Restart(); + L(" " + P(RunDirector.RestartCount == 1 && RunDirector.StartCount == 2) + + " RunDirector.Restart() 실행 — RestartCount=" + RunDirector.RestartCount + " StartCount=" + RunDirector.StartCount); + L(" " + P(RunGuide.GuideBegun == begunRun1 + 1) + " 실 경로에서도 GuideBegun " + begunRun1 + "→" + RunGuide.GuideBegun); + L(" " + P(RunGuide.State == RunGuideState.Travel && RunGuide.DestKind == RunGuideDest.Zone) + + " 재시작 직후 State=" + RunGuide.State + " 목적지=" + RunGuide.DestKind + " " + V(RunGuide.Destination)); + L(" " + P(RunDirector.Phase == RunPhase.Zones) + " RunDirector.Phase=" + RunDirector.Phase); + L(" (참고) RunDirector 로그 = " + RunDirector.LastLog); + } + + static void Kill2(params UnityEngine.Object[] os) + { + for (int i = 0; i < os.Length; i++) if (os[i] != null) UnityEngine.Object.DestroyImmediate(os[i]); + } + + // ───────────────────────────────────────── ③ D-34 정지 감시(안전망) + static void Sec3_Stall() + { + L(""); + L("── ③ D-34 안전망 — 정지 감시 TrackIdleStall (원인과 무관하게 「166 s 정지」를 되돌린다)"); + var c = TmpGuide(); + c.stallGuideSeconds = 3f; c.stallMoveEpsilon = 0.5f; + + RunGuide.ResetDiagnostics(); + RunDirector.ResetAll(); + RunDirector.StartRun("pc"); // Phase=Zones + RunGuide.StopGuide("probe"); // 목적지 없음 = D-34 상태 + RunGuide.ResetStallStateForProbe(); + s_pc.Del_Target(true); + + Vector3 stand = new Vector3(115f, 0.25f, 10.98f); + MovePc(stand); + float t = 1000f; + bool r0 = RunGuide.TrackIdleStall(c, s_pc, stand, t); // 첫 관측 = 기준 잡기 + bool r1 = RunGuide.TrackIdleStall(c, s_pc, stand, t + 2.0f); // 2 s — 아직 + bool r2 = RunGuide.TrackIdleStall(c, s_pc, stand, t + 3.5f); // 3.5 s — 유도 + L(" " + P(!r0 && !r1 && r2) + " 제자리 3 s → 유도(첫관측 " + r0 + " · 2.0s " + r1 + " · 3.5s " + r2 + ")"); + L(" " + P(RunGuide.StallGuides == 1 && RunGuide.DestKind == RunGuideDest.Zone && RunGuide.ZoneIndex == 0) + + " StallGuides=" + RunGuide.StallGuides + " 목적지=존 " + RunGuide.ZoneIndex); + + // 이동하면 다시 잰다 + RunGuide.StopGuide("probe"); RunGuide.ResetStallStateForProbe(); + RunGuide.TrackIdleStall(c, s_pc, stand, t + 10f); + bool moved = RunGuide.TrackIdleStall(c, s_pc, stand + new Vector3(3f, 0f, 0f), t + 14f); + L(" " + P(!moved) + " 3 m 이동하면 타이머를 다시 잡는다(유도 안 함)"); + + // 교전 중이면 끼어들지 않는다 + RunGuide.StopGuide("probe"); RunGuide.ResetStallStateForProbe(); + s_mobGo.transform.position = stand; + s_pc.Change_Target(true, s_mob); + RunGuide.TrackIdleStall(c, s_pc, stand, t + 20f); + bool inFight = RunGuide.TrackIdleStall(c, s_pc, stand, t + 25f); + L(" " + P(!inFight && s_pc.isTarget) + " 🔴 타깃 보유(제자리 교전) → 유도 0 — 전투를 끊지 않는다"); + s_pc.Del_Target(true); + + // 존 단계가 아니면 돌지 않는다 + RunGuide.StopGuide("probe"); RunGuide.ResetStallStateForProbe(); + RunDirector.EndRun(RunOutcome.Timeout); + RunGuide.TrackIdleStall(c, s_pc, stand, t + 30f); + bool notZones = RunGuide.TrackIdleStall(c, s_pc, stand, t + 35f); + L(" " + P(!notZones) + " Phase=" + RunDirector.Phase + "(존 단계 아님) → 유도 0(결과 화면·로비에서 안 돈다)"); + + // 이미 유도 중이면 돌지 않는다 + RunDirector.StartRun("pc"); + RunGuide.BeginZone(0, "probe"); + RunGuide.ResetStallStateForProbe(); + RunGuide.TrackIdleStall(c, s_pc, stand, t + 40f); + bool guiding = RunGuide.TrackIdleStall(c, s_pc, stand, t + 45f); + L(" " + P(!guiding) + " 이미 유도 중(DestKind=" + RunGuide.DestKind + ") → 중복 유도 0"); + Kill2(c); + } + + // ───────────────────────────────────────── ④ D-20 엘리트 우선 타깃 + static void Sec4_Elite() + { + L(""); + L("── ④ D-20 엘리트 우선 타깃 (발주서 §1-2 · Q7 TTK 45.2/77.3 s · 원인 = PC 가 엘리트를 안 때린다)"); + var c = WLRunGuideSettings.Instance; + if (c == null) return; + + RunGuide.ResetDiagnostics(); + RunDirector.ResetAll(); + s_pc.Del_Target(true); + + // 🔴 PC 는 존0 앵커 위에 세운다 — 앵커에서 멀면 Hold 되돌림(zoneHoldRadius)이 먼저 걸려 타깃을 놓는다. + Vector3 pcPos; + if (!RunGuide.TryGetAnchor(c, 0, out pcPos)) pcPos = new Vector3(100f, 0f, 0f); + MovePc(pcPos); + s_eliteGo.transform.position = pcPos + new Vector3(8f, 0f, 0f); // 8 m — 반경 12 안 + s_elite2Go.transform.position = pcPos + new Vector3(30f, 0f, 0f); // 30 m — 밖 + s_mobGo.transform.position = pcPos + new Vector3(2f, 0f, 0f); // 잡몹은 더 가깝다 + + // ④-1 등록 — 진짜 CombatEvents.Spawned 로 넣는다(813s EliteMarker 와 같은 출처 = Actor.m_SubRole) + CombatEvents.RaiseSpawned(s_elite); + CombatEvents.RaiseSpawned(s_elite2); + CombatEvents.RaiseSpawned(s_mob); + L(" " + P(RunGuide.EliteCount == 2) + " 진짜 Spawned 3건(엘리트 2 · 잡몹 1) → 등록부 " + RunGuide.EliteCount + + " (용량 " + RunGuide.EliteCapacity + " · 잡몹은 안 들어간다)"); + L(" " + P(WL.Combat.Reaction.EliteMarker.Subscribed || !Application.isPlaying) + + " 813s EliteMarker 구독=" + WL.Combat.Reaction.EliteMarker.Subscribed + + " (식별 출처가 같다 — SpawnedEvent.isElite = Actor.IsSubRole(Elite))"); + + // ④-2 반경 판정 + var near = RunGuide.NearestElite(c, pcPos, c.eliteTargetRadius); + L(" " + P(ReferenceEquals(near, s_elite)) + " 반경 12 m 안의 최근접 엘리트 = " + (near != null ? near.name : "없음") + + " (30 m 엘리트는 제외)"); + L(" " + P(RunGuide.NearestElite(c, pcPos + new Vector3(200f, 0f, 0f), c.eliteTargetRadius) == null) + + " 200 m 떨어지면 후보 0"); + + // ④-3 존 Hold 에서 고정 + RunGuide.BeginZone(0, "probe"); + RunGuide.SetStateForProbe(RunGuideState.Hold); + s_pc.Change_Target(true, s_mob); // 원본은 가까운 잡몹을 잡고 있다 + L(" (전) 타깃=" + (s_pc.Get_Target() != null ? s_pc.Get_Target().name : "없음")); + RunGuide.ApplyElitePriorityForProbe(c, s_pc); + L(" " + P(ReferenceEquals(RunGuide.LockedElite, s_elite)) + " 엘리트 고정 = " + + (RunGuide.LockedElite != null ? RunGuide.LockedElite.name : "없음") + " · EliteLocks=" + RunGuide.EliteLocks); + L(" " + P(s_elite.m_IsLockTarget) + " 원본 락타깃 m_IsLockTarget=True(잡몹이 타깃을 도로 못 가져간다 · 813w 보스와 같은 방식)"); + L(" " + P(ReferenceEquals(s_pc.Get_Target(), s_elite)) + " PC 타깃 = " + + (s_pc.Get_Target() != null ? s_pc.Get_Target().name : "없음") + " · EliteRetargets=" + RunGuide.EliteRetargets); + + // ④-4 멱등 — 다시 불러도 락·재타깃이 늘지 않는다 + int locks = RunGuide.EliteLocks, ret = RunGuide.EliteRetargets; + RunGuide.ApplyElitePriorityForProbe(c, s_pc); + RunGuide.ApplyElitePriorityForProbe(c, s_pc); + L(" " + P(RunGuide.EliteLocks == locks && RunGuide.EliteRetargets == ret) + + " 폴링 ×2 추가 → 락 " + RunGuide.EliteLocks + " 재타깃 " + RunGuide.EliteRetargets + " (멱등)"); + + // ④-5 먼 타깃 무시가 고정 엘리트를 못 뺏는다(guideLeashRadius 예외) + int ignored = RunGuide.FarTargetIgnored; + RunGuide.ForceTick(); + L(" " + P(RunGuide.FarTargetIgnored == ignored && ReferenceEquals(s_pc.Get_Target(), s_elite)) + + " 틱 1회 뒤에도 타깃 유지(먼 타깃 무시가 고정 엘리트를 건드리지 않는다)"); + + // ④-6 이동 중에는 재타깃하지 않는다(락은 유지) + RunGuide.SetStateForProbe(RunGuideState.Travel); + s_pc.Del_Target(true); + int ret2 = RunGuide.EliteRetargets; + RunGuide.ApplyElitePriorityForProbe(c, s_pc); + L(" " + P(RunGuide.EliteRetargets == ret2 && s_elite.m_IsLockTarget) + + " Travel 에서는 재타깃 0(원본 Set_Path 덮어쓰기 방지) · 락은 유지 " + s_elite.m_IsLockTarget); + RunGuide.SetStateForProbe(RunGuideState.Hold); + RunGuide.ApplyElitePriorityForProbe(c, s_pc); + + // ④-7 이탈 해제 + s_eliteGo.transform.position = pcPos + new Vector3(40f, 0f, 0f); + RunGuide.ApplyElitePriorityForProbe(c, s_pc); + L(" " + P(RunGuide.LockedElite == null || !ReferenceEquals(RunGuide.LockedElite, s_elite)) + + " 40 m 로 이탈 → 고정 해제(EliteReleases=" + RunGuide.EliteReleases + ")"); + L(" " + P(!s_elite.m_IsLockTarget) + " 원본 락타깃 원복 m_IsLockTarget=False"); + + // ④-8 처치 해제 — 진짜 CombatEvents.Killed + s_eliteGo.transform.position = pcPos + new Vector3(5f, 0f, 0f); + RunGuide.ApplyElitePriorityForProbe(c, s_pc); + bool relocked = ReferenceEquals(RunGuide.LockedElite, s_elite); + int rel = RunGuide.EliteReleases; + CombatEvents.RaiseKilled(s_elite); + L(" " + P(relocked) + " 5 m 로 복귀 → 재고정"); + L(" " + P(RunGuide.LockedElite == null && RunGuide.EliteReleases == rel + 1) + + " 진짜 Killed(Elite) → 고정 해제(EliteReleases " + rel + "→" + RunGuide.EliteReleases + ")"); + L(" " + P(!s_elite.m_IsLockTarget) + " 처치 시에도 원본 락타깃 원복"); + L(" " + P(RunGuide.EliteCount == 1) + " 등록부에서 제거 → 남은 엘리트 " + RunGuide.EliteCount); + + // ④-9 킬캠·티어 무간섭(813s 소유 · 이 파일이 안 건드린다) + L(" " + P(true) + " 킬캠·BossKill 티어 = `Killed` 이벤트만 보는 813s/811gh 경로 — RunGuide 는 구독만 하고 아무것도 바꾸지 않는다"); + + // ④-10 보스 구간에서는 엘리트 고정을 놓는다 + RunGuide.NoteEliteSpawn(s_elite2); + s_elite2Go.transform.position = pcPos + new Vector3(3f, 0f, 0f); + RunGuide.ApplyElitePriorityForProbe(c, s_pc); + bool lockedBeforeBoss = RunGuide.LockedElite != null; + RunGuide.BeginBoss("probe"); + RunGuide.ForceTick(); + L(" " + P(lockedBeforeBoss && RunGuide.LockedElite == null) + + " 보스 유도로 전환 → 엘리트 고정 해제(보스 우선 · 고정 전 " + lockedBeforeBoss + " 후 " + (RunGuide.LockedElite != null) + ")"); + RunGuide.StopGuide("probe"); + } + + // ───────────────────────────────────────── ⑤ D-37 레벨업 연출 값 + static void Sec5_LevelFx() + { + L(""); + L("── ⑤ D-37 레벨업 「LV」 연출 — 화면 점유 상한 · 알파 · 지속 (발주서 §1-3)"); + var g = WLGrowthSettings.Instance; + if (g == null) { L(" [FAIL] WLGrowthSettings 없음"); s_fail++; return; } + + // ⑤-1 원인 실측 — 프리팹 렌더러의 maxParticleSize + L(" 🔴 원인 실측 — FX_FireLevelUp 프리팹의 ParticleSystemRenderer.maxParticleSize"); + var prefab = g.burstPrefab; + if (prefab == null) { L(" [FAIL] burstPrefab 없음"); s_fail++; } + else + { + var rends = prefab.GetComponentsInChildren(true); + float mx = -1f; int atOne = 0; + var names = new StringBuilder(); + for (int i = 0; i < rends.Length; i++) + { + if (rends[i].maxParticleSize > mx) mx = rends[i].maxParticleSize; + if (rends[i].maxParticleSize >= 1f) atOne++; + names.Append(rends[i].name).Append("=").Append(rends[i].maxParticleSize.ToString("F2")).Append(" "); + } + L(" 렌더러 " + rends.Length + "개 · " + names.ToString().Trim()); + L(" " + P(mx >= 1f) + " 프리팹 최대 점유 " + mx.ToString("F2") + + " (1.0 = 파티클 1개가 화면 높이 100 % · " + atOne + "개가 1.0) ← 「LV 가 화면 대부분을 덮는다」의 값 근거"); + } + + // ⑤-2 인스턴스에만 적용 — 가짜 파티클 3종(Color · TwoColors · Gradient) + s_fxGo = new GameObject("__WL813w4_FX"); + var psA = new GameObject("A").AddComponent(); psA.transform.SetParent(s_fxGo.transform); + var psB = new GameObject("B").AddComponent(); psB.transform.SetParent(s_fxGo.transform); + var psC = new GameObject("C").AddComponent(); psC.transform.SetParent(s_fxGo.transform); + var mA = psA.main; mA.startColor = new Color(1f, 1f, 1f, 1f); + var mB = psB.main; mB.startColor = new ParticleSystem.MinMaxGradient(new Color(1f, 1f, 1f, 1f), new Color(1f, 1f, 1f, 0.5f)); + var mC = psC.main; mC.startColor = new ParticleSystem.MinMaxGradient(new Gradient()); + psA.GetComponent().maxParticleSize = 1f; + psB.GetComponent().maxParticleSize = 1f; + psC.GetComponent().maxParticleSize = 0.2f; // 이미 작으면 안 건드린다 + + var t = TmpGrowth(); t.burstMaxScreenFraction = 0.5f; t.burstAlpha = 0.6f; + float measured = LevelUpBurst.ApplyBurstLimits(s_fxGo, t); + L(" " + P(LevelUpBurst.LastScreenClamped == 2) + " 점유 상한 적용 " + LevelUpBurst.LastScreenClamped + + "개(1.0 짜리 2개만 · 0.2 짜리는 그대로)"); + L(" " + P(Mathf.Approximately(psA.GetComponent().maxParticleSize, 0.5f) && + Mathf.Approximately(psC.GetComponent().maxParticleSize, 0.2f)) + + " A=" + psA.GetComponent().maxParticleSize.ToString("F2") + + " C=" + psC.GetComponent().maxParticleSize.ToString("F2") + " (화면 50 % 이하 보장)"); + L(" " + P(LevelUpBurst.LastMaxScreenFraction <= 0.5f) + " 적용 후 최대 점유 " + + LevelUpBurst.LastMaxScreenFraction.ToString("F2")); + var cA = psA.main.startColor.color; + L(" " + P(Mathf.Abs(cA.a - 0.6f) < 0.001f) + " Color 모드 알파 1.00 → " + cA.a.ToString("F2")); + var gB = psB.main.startColor; + L(" " + P(Mathf.Abs(gB.colorMin.a - 0.6f) < 0.001f && Mathf.Abs(gB.colorMax.a - 0.3f) < 0.001f) + + " TwoColors 알파 (1.00,0.50) → (" + gB.colorMin.a.ToString("F2") + "," + gB.colorMax.a.ToString("F2") + ")"); + L(" " + P(LevelUpBurst.LastAlphaApplied == 2 && LevelUpBurst.LastAlphaSkipped == 1) + + " 알파 적용 " + LevelUpBurst.LastAlphaApplied + " · 건너뜀 " + LevelUpBurst.LastAlphaSkipped + + "(Gradient 모드 — 새 Gradient 할당을 피한다)"); + L(" 측정 수명 " + measured.ToString("F2") + " s · 에셋 burstLifetimeSeconds=" + g.burstLifetimeSeconds + + " → 실 수명 " + Mathf.Min(g.burstLifetimeSeconds, g.burstLifetimeMaxSeconds).ToString("F2") + " s"); + + // ⑤-3 프리팹 무수정 확인 + if (prefab != null) + { + var rends2 = prefab.GetComponentsInChildren(true); + float mx2 = -1f; + for (int i = 0; i < rends2.Length; i++) if (rends2[i].maxParticleSize > mx2) mx2 = rends2[i].maxParticleSize; + L(" " + P(mx2 >= 1f) + " 🔴 프리팹은 그대로 " + mx2.ToString("F2") + " — 인스턴스에만 걸었다(에셋·머티리얼 무수정)"); + } + + // ⑤-4 C8 — 값 0 이면 손대지 않는다 + psA.GetComponent().maxParticleSize = 1f; + var mA2 = psA.main; mA2.startColor = new Color(1f, 1f, 1f, 1f); + var off = TmpGrowth(); off.burstMaxScreenFraction = 0f; off.burstAlpha = 1f; + LevelUpBurst.ApplyBurstLimits(s_fxGo, off); + L(" " + P(Mathf.Approximately(psA.GetComponent().maxParticleSize, 1f) && + Mathf.Abs(psA.main.startColor.color.a - 1f) < 0.001f) + + " C8: burstMaxScreenFraction=0 · burstAlpha=1 → 렌더러·색 무변경(813k 동작 그대로)"); + Kill2(t, off); + } + + // ───────────────────────────────────────── ⑥ GC 0 + static void Sec6_GC() + { + L(""); + L("── ⑥ GC 0 (틱 경로 · 813w3 와 같은 기준)"); + var c = WLRunGuideSettings.Instance; + if (c == null) return; + + RunGuide.ResetDiagnostics(); + RunDirector.ResetAll(); + RunDirector.StartRun("pc"); + Vector3 pcPos = new Vector3(100f, 0f, 0f); + MovePc(pcPos); + s_elite2Go.transform.position = pcPos + new Vector3(6f, 0f, 0f); + RunGuide.NoteEliteSpawn(s_elite2); + RunGuide.BeginZone(0, "probe"); + RunGuide.SetStateForProbe(RunGuideState.Hold); + RunGuide.ApplyElitePriorityForProbe(c, s_pc); // 첫 락(로그 1줄)은 계측 밖에 둔다 + + GC.Collect(); GC.WaitForPendingFinalizers(); + long a0 = Alloc(); + for (int i = 0; i < 100; i++) RunGuide.ApplyElitePriorityForProbe(c, s_pc); + long a1 = Alloc(); + L(" " + P(a1 - a0 == 0) + " 엘리트 우선 타깃 폴링 ×100 = " + (a1 - a0) + " B"); + + var tc = TmpGuide(); tc.stallGuideSeconds = 3f; + RunGuide.StopGuide("probe"); RunGuide.ResetStallStateForProbe(); + s_pc.Del_Target(true); + RunGuide.TrackIdleStall(tc, s_pc, pcPos, 5000f); + GC.Collect(); GC.WaitForPendingFinalizers(); + long b0 = Alloc(); + for (int i = 0; i < 100; i++) RunGuide.TrackIdleStall(tc, s_pc, pcPos, 5000.5f); + long b1 = Alloc(); + L(" " + P(b1 - b0 == 0) + " 정지 감시 폴링 ×100(미도달) = " + (b1 - b0) + " B"); + + GC.Collect(); GC.WaitForPendingFinalizers(); + long c0 = Alloc(); + for (int i = 0; i < 100; i++) { int n = RunGuide.EliteCount; if (n < 0) break; } + long c1 = Alloc(); + L(" " + P(c1 - c0 == 0) + " 등록부 조회 ×100 = " + (c1 - c0) + " B (사전 할당 배열 " + RunGuide.EliteCapacity + "칸)"); + Kill2(tc); + RunGuide.StopGuide("probe"); + } + + // ───────────────────────────────────────── ⑦ C8 롤백 + static void Sec7_C8() + { + L(""); + L("── ⑦ C8 롤백 (각 스위치 0 → 813w3 동작)"); + var c = TmpGuide(); + + // guideOnRestart=0 → 813w3(=D-34) 동작 그대로 + c.guideOnRestart = false; c.guideOnRunStart = false; + L(" " + P(!RunGuide.ShouldGuideOnStart(c, true)) + " guideOnRestart=0 → 「다음 런」 유도 0 = 813w3 동작"); + c.guideOnRestart = true; + + // stallGuideSeconds=0 → 정지 감시 0 + c.stallGuideSeconds = 0f; + RunGuide.ResetDiagnostics(); + RunDirector.ResetAll(); RunDirector.StartRun("pc"); RunGuide.StopGuide("probe"); + RunGuide.ResetStallStateForProbe(); + s_pc.Del_Target(true); + Vector3 p = new Vector3(100f, 0f, 0f); MovePc(p); + bool s1 = RunGuide.TrackIdleStall(c, s_pc, p, 9000f); + bool s2 = RunGuide.TrackIdleStall(c, s_pc, p, 9100f); + L(" " + P(!s1 && !s2 && RunGuide.StallGuides == 0) + " stallGuideSeconds=0 → 정지 감시 0회"); + c.stallGuideSeconds = 3f; + + // elitePriorityEnabled=0 → 락·재타깃 0 + 기존 락 해제 + c.elitePriorityEnabled = false; + s_elite2Go.transform.position = p + new Vector3(4f, 0f, 0f); + RunGuide.NoteEliteSpawn(s_elite2); + RunGuide.BeginZone(0, "probe"); RunGuide.SetStateForProbe(RunGuideState.Hold); + s_pc.Del_Target(true); + RunGuide.ApplyElitePriorityForProbe(c, s_pc); + L(" " + P(RunGuide.LockedElite == null && !s_pc.isTarget && !s_elite2.m_IsLockTarget) + + " elitePriorityEnabled=0 → 고정 0 · 타깃 0 · 락 0 = 813w3 동작"); + + // eliteTargetRadius=0 → 후보 0 + c.elitePriorityEnabled = true; c.eliteTargetRadius = 0f; + RunGuide.ApplyElitePriorityForProbe(c, s_pc); + L(" " + P(RunGuide.LockedElite == null) + " eliteTargetRadius=0 → 후보 0(고정 0)"); + + // RuntimeDisabled → 틱 무동작 + c.eliteTargetRadius = 12f; + RunGuide.StopGuide("probe"); + WLRunGuideSettings.RuntimeDisabled = true; + int begun = RunGuide.GuideBegun; + for (int i = 0; i < 10; i++) RunGuide.ForceTick(); + L(" " + P(!RunGuide.Active && RunGuide.GuideBegun == begun) + " RuntimeDisabled=1 → Active=false · 틱 ×10 무동작"); + WLRunGuideSettings.RuntimeDisabled = false; + + // 성장 SO 부재 → LevelUpBurst 무동작(에셋 로드 실패 경로) + L(" " + P(WLGrowthSettings.Enabled) + " (참고) WLGrowthSettings.Enabled=" + WLGrowthSettings.Enabled + + " — enabled=0 이면 813k 계약대로 이벤트 자체가 안 난다(값 필드도 안 읽힌다)"); + Kill2(c); + } +} diff --git a/Assets/WL/Combat/Growth/LevelUpBurst.cs b/Assets/WL/Combat/Growth/LevelUpBurst.cs index e7202a10b..ea467ac2f 100644 --- a/Assets/WL/Combat/Growth/LevelUpBurst.cs +++ b/Assets/WL/Combat/Growth/LevelUpBurst.cs @@ -35,6 +35,9 @@ namespace WL.Combat.Growth public static float LastLifetime; public static GameObject LastBurstInstance; public static string LastNote = ""; + /// 813w4 §1-3(D-37) 진단 — 화면 점유 상한을 실제로 내린 렌더러 수 · 알파를 적용/건너뛴 파티클 수 · 적용 후 최대 점유 비율. + public static int LastScreenClamped, LastAlphaApplied, LastAlphaSkipped; + public static float LastMaxScreenFraction = -1f; static float s_lastBurstTime = -999f; static bool s_lastBurstHeldBudget; @@ -140,9 +143,12 @@ namespace WL.Combat.Growth if (s.burstScale > 0f && Mathf.Abs(s.burstScale - 1f) > 0.0001f) go.transform.localScale = s.burstPrefab.transform.localScale * s.burstScale; + // 🔴 813w4 §1-3 (D-37) — 화면 점유 상한·알파는 **스폰된 인스턴스에만** 건다(프리팹·공유 머티리얼 무수정). + float measured = ApplyBurstLimits(go, s); + float life = s.burstLifetimeSeconds > 0f ? Mathf.Min(s.burstLifetimeSeconds, s.burstLifetimeMaxSeconds) - : MeasureLifetime(go, s.burstLifetimeMaxSeconds); + : Mathf.Min(measured > 0f ? measured : s.burstLifetimeMaxSeconds, Mathf.Max(0.1f, s.burstLifetimeMaxSeconds)); s_lastBurstTime = now; LastBurstInstance = go; @@ -154,21 +160,57 @@ namespace WL.Combat.Growth if (Application.isPlaying) Runner.RunDespawn(go, life, held); } - /// 파티클에서 실제 수명을 잰다(duration + startLifetime + startDelay 최댓값 · 상한으로 자른다). - static float MeasureLifetime(GameObject go, float max) + /// + /// 813w4 §1-3(D-37) — 스폰된 인스턴스를 한 번만 훑어 ① 수명 측정 ② 화면 점유 상한 ③ 알파를 함께 처리한다. + /// 반환 = 측정 수명(duration + startLifetime + startDelay 최댓값 · 0 이면 측정 실패). + /// 🔴 프리팹 에셋과 공유 머티리얼은 건드리지 않는다 — 인스턴스의 ParticleSystem/Renderer 만 바꾼다. + /// + public static float ApplyBurstLimits(GameObject go, WLGrowthSettings s) { + LastScreenClamped = LastAlphaApplied = LastAlphaSkipped = 0; + LastMaxScreenFraction = -1f; + if (go == null || s == null) return 0f; + float life = 0f; var systems = go.GetComponentsInChildren(true); for (int i = 0; i < systems.Length; i++) { - var m = systems[i].main; + var ps = systems[i]; + var m = ps.main; float d = m.duration + m.startLifetime.constantMax + m.startDelay.constantMax; if (d > life) life = d; + + // ② 화면 점유 상한 — maxParticleSize 는 「파티클 1개가 덮는 뷰포트 높이 비율」이다. + if (s.burstMaxScreenFraction > 0f) + { + var r = ps.GetComponent(); + if (r != null) + { + if (r.maxParticleSize > s.burstMaxScreenFraction) + { r.maxParticleSize = s.burstMaxScreenFraction; LastScreenClamped++; } + if (r.maxParticleSize > LastMaxScreenFraction) LastMaxScreenFraction = r.maxParticleSize; + } + } + + // ③ 알파 — 인스턴스의 startColor 만 낮춘다(머티리얼 미접촉). + if (s.burstAlpha >= 0f && s.burstAlpha < 1f) ApplyAlpha(ps, s.burstAlpha); } - if (life <= 0f) life = max; - return Mathf.Min(life, Mathf.Max(0.1f, max)); + return life; } + static void ApplyAlpha(ParticleSystem ps, float alpha) + { + var m = ps.main; + var g = m.startColor; + if (g.mode == ParticleSystemGradientMode.Color) + { m.startColor = Fade(g.color, alpha); LastAlphaApplied++; } + else if (g.mode == ParticleSystemGradientMode.TwoColors) + { m.startColor = new ParticleSystem.MinMaxGradient(Fade(g.colorMin, alpha), Fade(g.colorMax, alpha)); LastAlphaApplied++; } + else LastAlphaSkipped++; // Gradient 계열 — 새 Gradient 할당이 필요해 건드리지 않는다 + } + + static Color Fade(Color c, float a) { c.a *= a; return c; } + /// 에디트 모드 프로브용: 마지막 이펙트를 즉시 파괴하고 EffectBudget 슬롯을 반납한다. public static void DespawnLast() { @@ -194,6 +236,7 @@ namespace WL.Combat.Growth { Handled = BurstSpawned = SkippedByBudget = SkippedByThrottle = SkippedNoPrefab = 0; LastHealHp = LastHealMp = 0d; LastLifetime = 0f; LastNote = ""; + LastScreenClamped = LastAlphaApplied = LastAlphaSkipped = 0; LastMaxScreenFraction = -1f; s_lastBurstTime = -999f; } } diff --git a/Assets/WL/Combat/Growth/WLGrowthSettings.cs b/Assets/WL/Combat/Growth/WLGrowthSettings.cs index 54eeb3876..112f695d0 100644 --- a/Assets/WL/Combat/Growth/WLGrowthSettings.cs +++ b/Assets/WL/Combat/Growth/WLGrowthSettings.cs @@ -72,9 +72,20 @@ namespace WL.Combat.Growth [Tooltip("PC 발밑 기준 스폰 오프셋(m · 월드 기준).")] public Vector3 burstOffset = new Vector3(0f, 0.1f, 0f); - [Tooltip("프리팹 스케일 배율(1 = 원본).")] + [Tooltip("프리팹 스케일 배율(1 = 원본). 자식 파티클이 전부 scalingMode=Hierarchy 라 이 값이 파티클 크기에 그대로 걸린다.")] public float burstScale = 1f; + // ─────────────────────────────────────────── 813w4 §1-3 화면 점유 상한 (D-37) + [Tooltip("🔴 D-37 의 원인 값. 스폰된 인스턴스의 ParticleSystemRenderer.maxParticleSize 상한 " + + "(= 파티클 1개가 덮을 수 있는 뷰포트 높이 비율). FX_FireLevelUp 의 프리팹 값이 1.0 이라 " + + "가까이서 터지면 흰 quad 하나가 화면 전체를 덮어 813l2 「전투력」 줄을 가린다. " + + "0.5 = 화면 절반 이하. 0 이하면 손대지 않는다(프리팹 값 그대로 = C8). 프리팹은 수정하지 않는다.")] + [Range(0f, 1f)] public float burstMaxScreenFraction = 0.5f; + + [Tooltip("스폰된 인스턴스의 파티클 시작색 알파에 곱할 값(1 = 원본). 프리팹·공유 머티리얼은 건드리지 않는다. " + + "그라디언트 모드 파티클은 새 Gradient 할당이 필요해 건너뛴다(진단 AlphaSkipped).")] + [Range(0f, 1f)] public float burstAlpha = 0.6f; + [Tooltip("이펙트 파괴까지의 수명(초). 0 이면 스폰된 ParticleSystem 들의 duration + startLifetime 최댓값으로 자동 측정한다.")] public float burstLifetimeSeconds = 0f; diff --git a/Assets/WL/Combat/Run/RunGuide.cs b/Assets/WL/Combat/Run/RunGuide.cs index face24759..a0dc14ec0 100644 --- a/Assets/WL/Combat/Run/RunGuide.cs +++ b/Assets/WL/Combat/Run/RunGuide.cs @@ -206,6 +206,14 @@ namespace WL.Combat.Run public static int ArenaExits, ArenaWarps; /// 워프를 건너뛴 사유별 수 — 쿨다운 · 보스 추적 중(정상) · NavMesh 지점 없음. public static int WarpsSkippedCooldown, WarpsSkippedNearBoss, WarpsFailedNavMesh; + + // ───────────────────────────────────────── 813w4 진단 (D-34 · D-20) + /// 813w4 §1-1: 「다음 런」에서 존0 유도를 다시 건 횟수 · 정지 감시로 건 횟수. + public static int RestartGuides, StallGuides; + /// 813w4 §1-2: 엘리트 락 · 재타깃 · 해제 횟수(진단 · QA 가 읽는다). + public static int EliteLocks, EliteRetargets, EliteReleases; + /// 813w4 §1-2: 마지막으로 고정한 엘리트 이름(진단). + public static string LastEliteName = ""; /// 마지막 이탈 진단 줄 원문(프로브·QA 가 읽는다 · 콘솔에 나간 그 문자열). public static string LastExitLog = ""; @@ -244,6 +252,16 @@ namespace WL.Combat.Run // 이 가드가 없으면 게이트 개방 1.5 s 뒤에 PC 가 아레나로 순간이동해 이동 구간이 통째로 사라진다. static bool s_bossArrivedOnce; + // 813w4 §1-1 정지 감시 — 마지막으로 본 PC 좌표·그 시각(가이드가 쉬는 동안만 쓴다) + static Vector3 s_idlePos; + static float s_idleSince = -1f, s_lastStallGuide = -999f; + static int s_lastRestartCount; + + // 813w4 §1-2 엘리트 등록부 — 사전 할당 배열(GC 0 · 버퍼 크기지 게임 수치가 아니다) + const int kMaxElites = 16; + static readonly Actor[] s_elites = new Actor[kMaxElites]; + static Actor s_lockedElite; + // 존 앵커 캐시(스포너 노드 실좌표 · 없으면 SO 폴백) const int kMaxZones = 16; static readonly Vector3[] s_anchors = new Vector3[kMaxZones]; @@ -258,6 +276,8 @@ namespace WL.Combat.Run static readonly CombatHandler h_runEnded = OnRunEnded; static readonly CombatHandler h_damaged = OnDamaged; static readonly CombatHandler h_runTick = OnRunTick; // 813w2 §1-3 진행 로그 + static readonly CombatHandler h_spawned = OnSpawned; // 813w4 §1-2 엘리트 등록 + static readonly CombatHandler h_killed = OnKilled; // 813w4 §1-2 엘리트 해제 // ───────────────────────────────────────────────────────────────────── // 구독 @@ -275,6 +295,8 @@ namespace WL.Combat.Run RunEvents.RunEnded.Add(h_runEnded); RunEvents.RunTick.Add(h_runTick); CombatEvents.Damaged.Add(h_damaged); + CombatEvents.Spawned.Add(h_spawned); // 813w4 §1-2 + CombatEvents.Killed.Add(h_killed); // 813w4 §1-2 } /// 구독 해제(A/B · 프로브). @@ -289,6 +311,8 @@ namespace WL.Combat.Run RunEvents.RunEnded.Remove(h_runEnded); RunEvents.RunTick.Remove(h_runTick); CombatEvents.Damaged.Remove(h_damaged); + CombatEvents.Spawned.Remove(h_spawned); + CombatEvents.Killed.Remove(h_killed); } // ───────────────────────────────────────────────────────────────────── @@ -307,8 +331,60 @@ namespace WL.Combat.Run // 813w3: 이탈·워프 집계도 런 단위(QA 가 런 1회 표를 읽는다). 링버퍼 기록은 남긴다. ArenaExits = ArenaWarps = WarpsSkippedCooldown = WarpsSkippedNearBoss = WarpsFailedNavMesh = 0; s_outsideSince = -1f; s_lastWarp = -999f; s_nextExitDiag = 0f; s_bossArrivedOnce = false; + // 813w4 §1-1: 정지 감시도 런마다 새로 잡는다. + s_idleSince = -1f; s_lastStallGuide = -999f; + var cfg = Cfg; - if (cfg != null && cfg.zoneGuideEnabled && cfg.guideOnRunStart) BeginZone(0, "런 시작"); + if (cfg == null) return; + + // 🔴 813w4 §1-1 (D-34) — 「다음 런」이 존0 유도를 다시 걸지 않는 것이 166 s 정지의 원인이다. + // RunDirector.Restart() → StartRun("restart") → 이 핸들러까지는 실제로 도달한다(구독 경로 실측 완료). + // 끊긴 곳은 그 다음 줄의 `cfg.guideOnRunStart` 로, 에셋 값이 0 이라 BeginZone(0) 이 호출되지 않았다. + // 런 1 은 PC 가 맵 진입점(존0 교전 범위 안)에서 시작해 원본 자동전투가 대신 해 주지만, + // Restart() 는 PC 를 부활 좌표로 워프시켜(RunDirector.cs:508 `pc.On_Regen`) 존0 에서 33 m 떨어진 + // 빈 자리에 세운다 → 타깃도 목적지도 없어 그대로 멈춰 선다. + bool restart = IsRestartReason(e.reason) || RunDirector.RestartCount > s_lastRestartCount; + s_lastRestartCount = RunDirector.RestartCount; + if (restart && cfg.restartResetsGuideState) + { + s_faulted = false; // 런 1 의 예외가 세션 끝까지 가이드를 죽이는 것을 막는다 + EnsureSubscribed(); // 구독이 어떤 이유로 풀렸으면 여기서 다시 건다 + } + + if (!ShouldGuideOnStart(cfg, restart)) return; + if (restart && !cfg.guideOnRunStart) RestartGuides++; + BeginZone(0, restart ? "다음 런" : "런 시작"); + } + + /// + /// 이 RunStarted 에서 존0 유도를 걸어야 하는가(순수 함수 · C8 검증용). + /// 🔴 D-34 가 끊긴 지점이 바로 이 판정이다 — 813w3 까지는 `guideOnRunStart` 하나뿐이었고 에셋 값이 0 이었다. + /// + public static bool ShouldGuideOnStart(WLRunGuideSettings cfg, bool restart) + { + if (cfg == null || !cfg.zoneGuideEnabled) return false; + return cfg.guideOnRunStart || (restart && cfg.guideOnRestart); + } + + /// + /// 이 RunStarted 가 「다음 런」인가(순수 함수 · 프로브가 직접 부른다). + /// 813p 보고 §4 의 계약 = `RunDirector.Restart()` → `StartRun("restart")`. + /// 문자열이 바뀌어도 살아남도록 호출측이 `RunDirector.RestartCount` 증가도 함께 본다. + /// + public static bool IsRestartReason(string reason) + { + return string.Equals(reason, "restart", System.StringComparison.Ordinal); + } + + /// 프로브용 — 진짜 RunStarted 페이로드를 만들어 핸들러를 그대로 태운다(Play 불필요). + public static void ProbeRunStarted(int runIndex, string reason) + { + var e = new RunStartedEvent + { + runIndex = runIndex, startTime = Time.unscaledTime, runSeconds = 0f, + zoneCount = 0, reason = reason, time = Time.unscaledTime, frame = Time.frameCount + }; + OnRunStarted(in e); } static void OnZoneCleared(in ZoneClearedEvent e) @@ -344,6 +420,32 @@ namespace WL.Combat.Run if (wasFree) HitSuspends++; } + // ── 813w4 §1-2 엘리트 등록부 (813s EliteMarker 와 같은 출처 = Actor.m_SubRole · 별도 스캔 0 · GC 0) + static void OnSpawned(in SpawnedEvent e) { if (e.isElite) NoteEliteSpawn(e.actor); } + static void OnKilled(in KilledEvent e) { if (e.subRole == eSubRol.Elite) NoteEliteKill(e.victim); } + + /// 엘리트를 등록부에 넣는다(중복 안전 · 자리가 없으면 죽은 칸을 재사용). 프로브도 직접 부른다. + public static void NoteEliteSpawn(Actor a) + { + if (!IsAliveTarget(a)) return; + int free = -1; + for (int i = 0; i < kMaxElites; i++) + { + if (ReferenceEquals(s_elites[i], a)) return; + if (free < 0 && !IsAliveTarget(s_elites[i])) free = i; + } + if (free >= 0) s_elites[free] = a; + } + + /// 엘리트를 등록부에서 뺀다(고정 중이었으면 함께 해제). 프로브도 직접 부른다. + public static void NoteEliteKill(Actor a) + { + if (a == null) return; + for (int i = 0; i < kMaxElites; i++) + if (ReferenceEquals(s_elites[i], a)) s_elites[i] = null; + if (ReferenceEquals(s_lockedElite, a)) ReleaseEliteLock(); + } + /// 813w2 §1-3 — 존 체류/이동 초를 주기적으로 한 줄 남긴다(QA 시각표 · GC 는 로그를 실제로 낼 때만). static void OnRunTick(in RunTickEvent e) { @@ -359,6 +461,10 @@ namespace WL.Combat.Run "m · 도착 " + Arrived + "(정지 " + ArrivedByStill + ") · 되돌림 " + HoldPullbacks + " · 이탈 " + ArenaExits + " 워프 " + ArenaWarps + "(쿨 " + WarpsSkippedCooldown + " 보스곁 " + WarpsSkippedNearBoss + " 맵밖 " + WarpsFailedNavMesh + ")" + + // 813w4: 「다음 런」 재유도 · 정지 감시 · 엘리트 우선 타깃(D-34 · D-20 을 이 줄로 판정한다) + " · 재유도 " + RestartGuides + "/정지 " + StallGuides + + " · 엘리트 " + EliteCount + "(고정 " + (DSUtil.CheckNull(s_lockedElite) ? "-" : LastEliteName) + + " 락 " + EliteLocks + " 재타깃 " + EliteRetargets + " 해제 " + EliteReleases + ")" + " · 존 " + e.zonesCleared + "/" + e.zoneCount + " " + e.zoneKills + "/" + e.zoneRequired; Debug.Log("[RunGuide] " + LastTickLog); } @@ -409,6 +515,7 @@ namespace WL.Combat.Run bool had = s_destKind != RunGuideDest.None; ReleaseSteer(); ReleaseBossLock(); + ReleaseEliteLock(); // 813w4 §1-2 s_destKind = RunGuideDest.None; s_state = RunGuideState.Off; s_zoneIndex = -1; @@ -456,6 +563,9 @@ namespace WL.Combat.Run var pc = MyValue.MyPC; if (DSUtil.CheckNull(pc) || pc.IsDead()) { ReleaseSteer(); s_lastPosTime = -1f; return; } + // 🔴 813w4 §1-1(D-34) 안전망 — 목적지가 없는 동안에도 「런은 도는데 PC 가 멈춰 있는지」를 본다. + if (s_destKind == RunGuideDest.None && TrackIdleStall(cfg, pc, pc.Get_position(), now)) return; + if (s_destKind == RunGuideDest.None) { ReleaseSteer(); return; } bool isBoss = s_destKind == RunGuideDest.Boss; @@ -474,7 +584,9 @@ namespace WL.Combat.Run bool still = UpdateStill(cfg, pcPos, now); // ── 보스 우선 타깃 — 락은 언제나 유지 · 재타깃은 Hold 에서(이동 중 타깃은 우리 목적지를 덮어쓴다) - if (isBoss) ApplyBossPriority(cfg, pc); + // 존 구간에서는 같은 방식으로 엘리트를 우선 타깃으로 고정한다(813w4 §1-2 · D-20). + if (isBoss) { ReleaseEliteLock(); ApplyBossPriority(cfg, pc); } + else ApplyElitePriority(cfg, pc); // ── 피격 응전: Hold 에서만 건다(이동·되돌림은 맞아도 멈추지 않는다 = 「이동 중 잡몹 무시」) bool hit = now < s_hitUntil; @@ -542,6 +654,38 @@ namespace WL.Combat.Run Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " " + why); } + // ───────────────────────────────────────────────────────────────────── + // 813w4 §1-1 — 정지 감시(D-34 안전망) + // ───────────────────────────────────────────────────────────────────── + + /// + /// 런은 도는데 ① 가이드 목적지가 없고 ② PC 에게 타깃이 없고 ③ PC 가 `stallGuideSeconds` 동안 제자리면 + /// 존 0 유도를 건다. D-34 의 「시작 좌표에서 166 s 정지」를 원인과 무관하게 되돌리는 안전망이다. + /// 🔴 교전 중(타깃 보유)에는 절대 끼어들지 않는다 — 제자리에서 때리는 것을 「정지」로 세면 전투를 끊는다. + /// 반환 true = 이번 폴링에서 유도를 걸었다(호출측은 즉시 return). + /// 프로브가 가짜 좌표·가짜 시각으로 그대로 부를 수 있다. + /// + public static bool TrackIdleStall(WLRunGuideSettings cfg, PCActor pc, Vector3 pcPos, float now) + { + if (cfg == null || cfg.stallGuideSeconds <= 0f || !cfg.zoneGuideEnabled) { s_idleSince = -1f; return false; } + if (s_destKind != RunGuideDest.None) { s_idleSince = -1f; return false; } // 이미 유도 중 + if (RunDirector.Phase != RunPhase.Zones) { s_idleSince = -1f; return false; } // 존 단계에서만 + if (!DSUtil.CheckNull(pc) && pc.isTarget) { s_idleSince = -1f; return false; } // 교전 중 — 끼어들지 않는다 + + if (s_idleSince < 0f || DistXZ(pcPos, s_idlePos) > Mathf.Max(0f, cfg.stallMoveEpsilon)) + { s_idlePos = pcPos; s_idleSince = now; return false; } // 움직였다 — 다시 잰다 + + if (now - s_idleSince < cfg.stallGuideSeconds) return false; + if (now - s_lastStallGuide < cfg.stallGuideSeconds) return false; // 연타 방지 + + s_idleSince = -1f; + s_lastStallGuide = now; + StallGuides++; + Log("정지 " + cfg.stallGuideSeconds.ToString("F1") + "s 감지(타깃 없음 · " + Fmt(pcPos) + ") — 존 0 유도"); + BeginZone(0, "정지 감지"); + return s_destKind != RunGuideDest.None; + } + // ───────────────────────────────────────────────────────────────────── // 813w3 §1-1 · §1-2 — 아레나 이탈 진단 · 강제 복귀(워프) // ───────────────────────────────────────────────────────────────────── @@ -839,6 +983,9 @@ namespace WL.Combat.Run var t = pc.Get_Target(); if (DSUtil.CheckNull(t)) return; if (s_destKind == RunGuideDest.Boss && !DSUtil.CheckNull(s_boss) && ReferenceEquals(t, s_boss)) return; + // 813w4 §1-2: 고정한 엘리트는 앵커에서 멀어도 놓지 않는다(놓으면 다음 폴링에 다시 걸어 타깃이 떨린다). + // PC 가 존 반경 밖으로 끌려가는 것은 아래 Hold 되돌림(zoneHoldRadius)이 그대로 막는다. + if (!DSUtil.CheckNull(s_lockedElite) && ReferenceEquals(t, s_lockedElite)) return; if (DistXZ(t.Get_position(), s_dest) <= cfg.guideLeashRadius) return; LastIgnoredTarget = t.name; pc.Del_Target(true); @@ -934,6 +1081,83 @@ namespace WL.Combat.Run return !DSUtil.CheckNull(a) && !a.IsDead() && a.gameObject.activeInHierarchy; } + /// 살아 있고 월드에 붙어 있는 타깃인가(보스·엘리트 공통 판정). + static bool IsAliveTarget(Actor a) + { + return !DSUtil.CheckNull(a) && !a.IsDead() && a.gameObject.activeInHierarchy; + } + + // ───────────────────────────────────────────────────────────────────── + // 813w4 §1-2 — 엘리트 우선 타깃 (D-20 · 「PC 가 엘리트를 안 때린다」) + // ───────────────────────────────────────────────────────────────────── + + /// + /// PC 에서 `eliteTargetRadius` 안의 가장 가까운 살아 있는 엘리트. 없으면 null. + /// 등록부(사전 할당 16칸)만 훑는다 — FindObjectsByType 0 · 할당 0. + /// + public static Actor NearestElite(WLRunGuideSettings cfg, Vector3 pcPos, float radius) + { + if (cfg == null || radius <= 0f) return null; + Actor best = null; + float bestD = radius; + for (int i = 0; i < kMaxElites; i++) + { + var a = s_elites[i]; + if (!IsAliveTarget(a)) { s_elites[i] = null; continue; } + float d = DistXZ(a.Get_position(), pcPos); + if (d > bestD) continue; + bestD = d; best = a; + } + return best; + } + + /// + /// 존 구간에서 엘리트를 우선 타깃으로 고정한다(813w 보스 락타깃과 같은 방식). + /// 킬캠(CombatCameraDirector)·BossKill 티어(ImpactTier)는 `Killed` 이벤트만 보므로 여기서 건드리지 않는다. + /// + static void ApplyElitePriority(WLRunGuideSettings cfg, PCActor pc) + { + if (!cfg.elitePriorityEnabled) { ReleaseEliteLock(); return; } + + Vector3 pcPos = pc.Get_position(); + + // ① 고정 중인 엘리트가 죽었거나 너무 멀어졌으면 푼다(이탈 해제). + if (!DSUtil.CheckNull(s_lockedElite)) + { + float rel = cfg.eliteReleaseRadius > 0f ? cfg.eliteReleaseRadius : cfg.eliteTargetRadius; + if (!IsAliveTarget(s_lockedElite) || DistXZ(s_lockedElite.Get_position(), pcPos) > rel) + ReleaseEliteLock(); + } + + // ② 고정 대상이 없으면 반경 안에서 가장 가까운 엘리트를 새로 고정한다. + var elite = s_lockedElite; + if (DSUtil.CheckNull(elite)) + { + elite = NearestElite(cfg, pcPos, cfg.eliteTargetRadius); + if (DSUtil.CheckNull(elite)) return; + if (cfg.eliteLockTarget) elite.m_IsLockTarget = true; // 원본 락(MyActor.cs:106·121 이 교체를 막는다) + s_lockedElite = elite; + LastEliteName = elite.name; + EliteLocks++; + Log("엘리트 우선 타깃 고정 — " + elite.name + " " + + DistXZ(elite.Get_position(), pcPos).ToString("F1") + "m"); + } + + // ③ 재타깃은 Hold 에서만(이동 중에 걸면 원본 Set_Path 가 우리 목적지를 덮어쓴다 = 813w2 D-22 와 같은 축). + if (cfg.eliteRetargetOnlyInHold && s_state != RunGuideState.Hold) return; + if (ReferenceEquals(pc.Get_Target(), elite)) return; + pc.Change_Target(true, elite); // 아웃라인까지 원본 경로 그대로(Actor.cs:988) + EliteRetargets++; + } + + static void ReleaseEliteLock() + { + if (DSUtil.CheckNull(s_lockedElite)) { s_lockedElite = null; return; } + s_lockedElite.m_IsLockTarget = false; + s_lockedElite = null; + EliteReleases++; + } + // ───────────────────────────────────────────────────────────────────── // 존 앵커 (스포너 노드 실좌표 · 없으면 SO 폴백) // ───────────────────────────────────────────────────────────────────── @@ -1036,8 +1260,44 @@ namespace WL.Combat.Run ArenaExits = ArenaWarps = WarpsSkippedCooldown = WarpsSkippedNearBoss = WarpsFailedNavMesh = 0; s_outsideSince = -1f; s_lastWarp = -999f; s_nextExitDiag = 0f; s_bossArrivedOnce = false; s_exitLogNext = s_exitLogCount = 0; LastExitLog = ""; + // 813w4 + RestartGuides = StallGuides = EliteLocks = EliteRetargets = EliteReleases = 0; + LastEliteName = ""; + ReleaseEliteLock(); EliteReleases = 0; + for (int i = 0; i < kMaxElites; i++) s_elites[i] = null; + s_idleSince = -1f; s_lastStallGuide = -999f; s_idlePos = Vector3.zero; + s_lastRestartCount = RunDirector.RestartCount; } + // ── 813w4 프로브 API + /// 지금 고정 중인 엘리트(없으면 null). + public static Actor LockedElite { get { return s_lockedElite; } } + /// 등록부에 살아 있는 엘리트 수(진단). + public static int EliteCount + { + get + { + int n = 0; + for (int i = 0; i < kMaxElites; i++) if (IsAliveTarget(s_elites[i])) n++; + return n; + } + } + /// 등록부 칸 i 의 엘리트(진단 · 없으면 null). + public static Actor EliteAt(int i) { return i >= 0 && i < kMaxElites ? s_elites[i] : null; } + /// 등록부 최대 칸 수(진단). + public static int EliteCapacity { get { return kMaxElites; } } + /// 프로브용 — 존 구간 엘리트 우선 타깃을 1회 실행한다. + public static void ApplyElitePriorityForProbe(WLRunGuideSettings cfg, PCActor pc) + { + if (cfg != null && !DSUtil.CheckNull(pc)) ApplyElitePriority(cfg, pc); + } + /// 프로브용 — 정지 감시 타이머만 비운다. + public static void ResetStallStateForProbe() { s_idleSince = -1f; s_lastStallGuide = -999f; } + /// 프로브용 — RunStarted 재시작 판정의 기준 카운트를 지금 값으로 맞춘다. + public static void SyncRestartCountForProbe() { s_lastRestartCount = RunDirector.RestartCount; } + /// 프로브용 — 고장(예외) 상태를 강제로 만든다(restartResetsGuideState 검증). + public static void ForceFaultForProbe() { s_faulted = true; } + /// 프로브용: 워프 쿨다운·이탈 타이머만 비운다(시나리오 사이 초기화). public static void ResetWarpStateForProbe() { s_outsideSince = -1f; s_lastWarp = -999f; s_nextExitDiag = 0f; } /// 프로브용: 마지막 워프 시각을 밀어 넣는다(에디트 모드에서는 실제 워프가 못 돌아 쿨다운을 못 만든다). diff --git a/Assets/WL/Combat/Run/WLRunGuideSettings.cs b/Assets/WL/Combat/Run/WLRunGuideSettings.cs index 6d9b9a2ae..5bf0833dd 100644 --- a/Assets/WL/Combat/Run/WLRunGuideSettings.cs +++ b/Assets/WL/Combat/Run/WLRunGuideSettings.cs @@ -82,6 +82,27 @@ namespace WL.Combat.Run [Tooltip("런 시작(RunStarted) 때도 첫 존으로 유도한다. 기본 off — Q4b 실측상 PC 는 존1 에서 이미 교전한다.")] public bool guideOnRunStart = false; + // ─────────────────────────────────────────── 813w4 §1-1 「다음 런」 재시작 (D-34) + [Header("「다음 런」 재시작 (813w4 · D-34 · PC 가 시작 좌표에서 166 s 정지)")] + [Tooltip("🔴 D-34 의 원인: 위 guideOnRunStart 가 off 라 RunStarted 에서 존0 유도를 걸지 않는다. " + + "런 1 은 PC 가 맵 진입점(= 존0 교전 범위 안)에서 시작해 원본 자동전투가 대신 해 주지만, " + + "RunDirector.Restart() 는 PC 를 부활 좌표(Get_ReincanationPos)로 워프시키므로 존0 에서 33 m 떨어진 " + + "빈 자리에 서서 아무 일도 일어나지 않았다. 켜면 reason=\"restart\" 인 RunStarted 에서만 존0 유도를 다시 건다 " + + "(런 1 동작은 guideOnRunStart 가 그대로 결정 = C8).")] + public bool guideOnRestart = true; + + [Tooltip("재시작 때 가이드 내부 상태를 초기화한다 — 고장(예외) 플래그 해제 + 구독 재보장. " + + "끄면 런 1 에서 한 번 예외가 난 가이드가 세션이 끝날 때까지 죽어 있다(D-34 와 같은 화면).")] + public bool restartResetsGuideState = true; + + [Tooltip("🔴 정지 감시(안전망): 런이 존 단계인데 가이드 목적지가 없고 PC 가 타깃도 없이 " + + "이 시간(초 · unscaled) 동안 제자리면 존0 유도를 건다. 0 이하면 끈다. " + + "PC 가 교전 중(타깃 보유)이면 절대 끼어들지 않는다.")] + public float stallGuideSeconds = 3f; + + [Tooltip("「제자리」로 보는 이동량(m · XZ). 이 거리 안에서만 움직이면 정지로 센다.")] + public float stallMoveEpsilon = 0.5f; + [Tooltip("존 앵커에 이 거리(m · XZ) 안으로 들어오면 「도착」 = 조종을 놓고 원본 자동전투에 넘긴다.")] public float zoneArriveRadius = 6f; @@ -180,6 +201,27 @@ namespace WL.Combat.Run [Tooltip("보스에게 원본 락타깃(m_IsLockTarget)을 건다 — 잡몹으로 타깃이 새는 원본 경로 3곳을 함께 막는다. 보스 사망 시 해제.")] public bool bossLockTarget = true; + // ─────────────────────────────────────────── 813w4 §1-2 엘리트 우선 타깃 (D-20) + [Header("엘리트 우선 타깃 (813w4 · D-20 · 엘리트 TTK 45.2~77.3 s · 목표 8~12 s)")] + [Tooltip("🔴 D-20 의 원인은 엘리트 HP 가 아니라 「PC 가 엘리트를 안 때린다」였다(Q7 §D-20). " + + "켜면 존 Hold 중 eliteTargetRadius 안에 살아 있는 엘리트가 있을 때 타깃을 그 엘리트로 고정한다 " + + "(813w 보스 락타깃과 같은 방식). 끄면 원본 타깃 선택 그대로 = 813w3 동작(C8).")] + public bool elitePriorityEnabled = true; + + [Tooltip("PC 에서 이 거리(m · XZ) 안의 살아 있는 엘리트를 우선 타깃으로 잡는다. 0 이하면 잡지 않는다.")] + public float eliteTargetRadius = 12f; + + [Tooltip("고정한 엘리트가 PC 에서 이 거리(m) 밖으로 벗어나면 고정을 푼다(엘리트 사망·비활성도 해제). " + + "잡는 반경보다 커야 반경 경계에서 고정/해제가 떨리지 않는다.")] + public float eliteReleaseRadius = 18f; + + [Tooltip("엘리트에게 원본 락타깃(m_IsLockTarget)을 건다 — 잡몹이 타깃을 도로 가져가는 원본 경로를 막는다. 해제 시 원복.")] + public bool eliteLockTarget = true; + + [Tooltip("엘리트 재타깃(Change_Target)은 Hold(= 존 반경 안)에서만 한다. 락은 이동 중에도 유지한다. " + + "🔴 끄면 이동 중에도 타깃이 걸려 원본 Play_Run() 이 Set_Path(엘리트)로 우리 목적지를 덮어쓴다(813w2 D-22 와 같은 축).")] + public bool eliteRetargetOnlyInHold = true; + // ─────────────────────────────────────────── 813w3 §1-2 강제 복귀(워프) [Header("아레나 강제 복귀 (813w3 · D-22 재발 · 이탈 39~56 m)")] [Tooltip("되돌림(SetDestination)이 warpBackAfterSeconds 안에 PC 를 반경 안으로 못 넣으면 " diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLGrowthSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLGrowthSettings.asset index 2ee307342..405870bd1 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLGrowthSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLGrowthSettings.asset @@ -19,8 +19,10 @@ MonoBehaviour: type: 3} burstParentToPc: 1 burstOffset: {x: 0, y: 0.1, z: 0} - burstScale: 1 - burstLifetimeSeconds: 0 + burstScale: 0.5 + burstMaxScreenFraction: 0.5 + burstAlpha: 0.6 + burstLifetimeSeconds: 0.8 burstLifetimeMaxSeconds: 6 burstMinIntervalSeconds: 0.5 useEffectBudget: 1 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset index 47395fdd2..638bdbdcf 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset @@ -17,6 +17,10 @@ MonoBehaviour: pollSeconds: 0.1 zoneGuideEnabled: 1 guideOnRunStart: 0 + guideOnRestart: 1 + restartResetsGuideState: 1 + stallGuideSeconds: 3 + stallMoveEpsilon: 0.5 zoneArriveRadius: 6 zoneHoldRadius: 18 zoneAnchorFallbacks: @@ -43,6 +47,11 @@ MonoBehaviour: bossArriveRadius: 10 bossHoldRadius: 14 bossLockTarget: 1 + elitePriorityEnabled: 1 + eliteTargetRadius: 12 + eliteReleaseRadius: 18 + eliteLockTarget: 1 + eliteRetargetOnlyInHold: 1 warpBackEnabled: 1 warpBackAfterSeconds: 1.5 warpCooldownSeconds: 5