Merge branch 'wl/gameplay/WL-813w4-restart-elite-levelfx'

This commit is contained in:
깃 관리자 2026-09-09 16:24:31 +09:00
commit 82076d7d6b
8 changed files with 1089 additions and 11 deletions

View File

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

View File

@ -0,0 +1,609 @@
// WL813w4_Probe.cs — #813w4(「다음 런」 가이드 재시작 · 엘리트 우선 타깃 · 레벨업 연출 값) 프로브
// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 는 즉시 파괴/회수)
//
// 상주 에디터:
// unity command run_script --project-path <wt> --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") + ")"; }
/// <summary>실에셋을 복사한 임시 SO(실에셋은 절대 안 건드린다). 쓰고 나면 DestroyImmediate.</summary>
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<PCActor>();
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<Actor>();
s_elite.m_Role = eRole.Mob; s_elite.m_SubRole = eSubRol.Elite;
s_elite2Go = new GameObject("__WL813w4_Elite2");
s_elite2 = s_elite2Go.AddComponent<Actor>();
s_elite2.m_Role = eRole.Mob; s_elite2.m_SubRole = eSubRol.Elite;
s_mobGo = new GameObject("__WL813w4_Mob");
s_mob = s_mobGo.AddComponent<Actor>();
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<ParticleSystemRenderer>(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<ParticleSystem>(); psA.transform.SetParent(s_fxGo.transform);
var psB = new GameObject("B").AddComponent<ParticleSystem>(); psB.transform.SetParent(s_fxGo.transform);
var psC = new GameObject("C").AddComponent<ParticleSystem>(); 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<ParticleSystemRenderer>().maxParticleSize = 1f;
psB.GetComponent<ParticleSystemRenderer>().maxParticleSize = 1f;
psC.GetComponent<ParticleSystemRenderer>().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<ParticleSystemRenderer>().maxParticleSize, 0.5f) &&
Mathf.Approximately(psC.GetComponent<ParticleSystemRenderer>().maxParticleSize, 0.2f)) +
" A=" + psA.GetComponent<ParticleSystemRenderer>().maxParticleSize.ToString("F2") +
" C=" + psC.GetComponent<ParticleSystemRenderer>().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<ParticleSystemRenderer>(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<ParticleSystemRenderer>().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<ParticleSystemRenderer>().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);
}
}

View File

@ -35,6 +35,9 @@ namespace WL.Combat.Growth
public static float LastLifetime;
public static GameObject LastBurstInstance;
public static string LastNote = "";
/// <summary>813w4 §1-3(D-37) 진단 — 화면 점유 상한을 실제로 내린 렌더러 수 · 알파를 적용/건너뛴 파티클 수 · 적용 후 최대 점유 비율.</summary>
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);
}
/// <summary>파티클에서 실제 수명을 잰다(duration + startLifetime + startDelay 최댓값 · 상한으로 자른다).</summary>
static float MeasureLifetime(GameObject go, float max)
/// <summary>
/// 813w4 §1-3(D-37) — 스폰된 인스턴스를 한 번만 훑어 ① 수명 측정 ② 화면 점유 상한 ③ 알파를 함께 처리한다.
/// 반환 = 측정 수명(duration + startLifetime + startDelay 최댓값 · 0 이면 측정 실패).
/// 🔴 프리팹 에셋과 공유 머티리얼은 건드리지 않는다 — 인스턴스의 ParticleSystem/Renderer 만 바꾼다.
/// </summary>
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<ParticleSystem>(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<ParticleSystemRenderer>();
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; }
/// <summary>에디트 모드 프로브용: 마지막 이펙트를 즉시 파괴하고 EffectBudget 슬롯을 반납한다.</summary>
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;
}
}

View File

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

View File

@ -206,6 +206,14 @@ namespace WL.Combat.Run
public static int ArenaExits, ArenaWarps;
/// <summary>워프를 건너뛴 사유별 수 — 쿨다운 · 보스 추적 중(정상) · NavMesh 지점 없음.</summary>
public static int WarpsSkippedCooldown, WarpsSkippedNearBoss, WarpsFailedNavMesh;
// ───────────────────────────────────────── 813w4 진단 (D-34 · D-20)
/// <summary>813w4 §1-1: 「다음 런」에서 존0 유도를 다시 건 횟수 · 정지 감시로 건 횟수.</summary>
public static int RestartGuides, StallGuides;
/// <summary>813w4 §1-2: 엘리트 락 · 재타깃 · 해제 횟수(진단 · QA 가 읽는다).</summary>
public static int EliteLocks, EliteRetargets, EliteReleases;
/// <summary>813w4 §1-2: 마지막으로 고정한 엘리트 이름(진단).</summary>
public static string LastEliteName = "";
/// <summary>마지막 이탈 진단 줄 원문(프로브·QA 가 읽는다 · 콘솔에 나간 그 문자열).</summary>
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<RunResult> h_runEnded = OnRunEnded;
static readonly CombatHandler<DamagedEvent> h_damaged = OnDamaged;
static readonly CombatHandler<RunTickEvent> h_runTick = OnRunTick; // 813w2 §1-3 진행 로그
static readonly CombatHandler<SpawnedEvent> h_spawned = OnSpawned; // 813w4 §1-2 엘리트 등록
static readonly CombatHandler<KilledEvent> 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
}
/// <summary>구독 해제(A/B · 프로브).</summary>
@ -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 ? "다음 런" : "런 시작");
}
/// <summary>
/// 이 RunStarted 에서 존0 유도를 걸어야 하는가(순수 함수 · C8 검증용).
/// 🔴 D-34 가 끊긴 지점이 바로 이 판정이다 — 813w3 까지는 `guideOnRunStart` 하나뿐이었고 에셋 값이 0 이었다.
/// </summary>
public static bool ShouldGuideOnStart(WLRunGuideSettings cfg, bool restart)
{
if (cfg == null || !cfg.zoneGuideEnabled) return false;
return cfg.guideOnRunStart || (restart && cfg.guideOnRestart);
}
/// <summary>
/// 이 RunStarted 가 「다음 런」인가(순수 함수 · 프로브가 직접 부른다).
/// 813p 보고 §4 의 계약 = `RunDirector.Restart()` → `StartRun("restart")`.
/// 문자열이 바뀌어도 살아남도록 호출측이 `RunDirector.RestartCount` 증가도 함께 본다.
/// </summary>
public static bool IsRestartReason(string reason)
{
return string.Equals(reason, "restart", System.StringComparison.Ordinal);
}
/// <summary>프로브용 — 진짜 RunStarted 페이로드를 만들어 핸들러를 그대로 태운다(Play 불필요).</summary>
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); }
/// <summary>엘리트를 등록부에 넣는다(중복 안전 · 자리가 없으면 죽은 칸을 재사용). 프로브도 직접 부른다.</summary>
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;
}
/// <summary>엘리트를 등록부에서 뺀다(고정 중이었으면 함께 해제). 프로브도 직접 부른다.</summary>
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();
}
/// <summary>813w2 §1-3 — 존 체류/이동 초를 주기적으로 한 줄 남긴다(QA 시각표 · GC 는 로그를 실제로 낼 때만).</summary>
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 안전망)
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 런은 도는데 ① 가이드 목적지가 없고 ② PC 에게 타깃이 없고 ③ PC 가 `stallGuideSeconds` 동안 제자리면
/// 존 0 유도를 건다. D-34 의 「시작 좌표에서 166 s 정지」를 원인과 무관하게 되돌리는 안전망이다.
/// 🔴 교전 중(타깃 보유)에는 절대 끼어들지 않는다 — 제자리에서 때리는 것을 「정지」로 세면 전투를 끊는다.
/// 반환 true = 이번 폴링에서 유도를 걸었다(호출측은 즉시 return).
/// 프로브가 가짜 좌표·가짜 시각으로 그대로 부를 수 있다.
/// </summary>
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;
}
/// <summary>살아 있고 월드에 붙어 있는 타깃인가(보스·엘리트 공통 판정).</summary>
static bool IsAliveTarget(Actor a)
{
return !DSUtil.CheckNull(a) && !a.IsDead() && a.gameObject.activeInHierarchy;
}
// ─────────────────────────────────────────────────────────────────────
// 813w4 §1-2 — 엘리트 우선 타깃 (D-20 · 「PC 가 엘리트를 안 때린다」)
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// PC 에서 `eliteTargetRadius` 안의 가장 가까운 살아 있는 엘리트. 없으면 null.
/// 등록부(사전 할당 16칸)만 훑는다 — FindObjectsByType 0 · 할당 0.
/// </summary>
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;
}
/// <summary>
/// 존 구간에서 엘리트를 우선 타깃으로 고정한다(813w 보스 락타깃과 같은 방식).
/// 킬캠(CombatCameraDirector)·BossKill 티어(ImpactTier)는 `Killed` 이벤트만 보므로 여기서 건드리지 않는다.
/// </summary>
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
/// <summary>지금 고정 중인 엘리트(없으면 null).</summary>
public static Actor LockedElite { get { return s_lockedElite; } }
/// <summary>등록부에 살아 있는 엘리트 수(진단).</summary>
public static int EliteCount
{
get
{
int n = 0;
for (int i = 0; i < kMaxElites; i++) if (IsAliveTarget(s_elites[i])) n++;
return n;
}
}
/// <summary>등록부 칸 i 의 엘리트(진단 · 없으면 null).</summary>
public static Actor EliteAt(int i) { return i >= 0 && i < kMaxElites ? s_elites[i] : null; }
/// <summary>등록부 최대 칸 수(진단).</summary>
public static int EliteCapacity { get { return kMaxElites; } }
/// <summary>프로브용 — 존 구간 엘리트 우선 타깃을 1회 실행한다.</summary>
public static void ApplyElitePriorityForProbe(WLRunGuideSettings cfg, PCActor pc)
{
if (cfg != null && !DSUtil.CheckNull(pc)) ApplyElitePriority(cfg, pc);
}
/// <summary>프로브용 — 정지 감시 타이머만 비운다.</summary>
public static void ResetStallStateForProbe() { s_idleSince = -1f; s_lastStallGuide = -999f; }
/// <summary>프로브용 — RunStarted 재시작 판정의 기준 카운트를 지금 값으로 맞춘다.</summary>
public static void SyncRestartCountForProbe() { s_lastRestartCount = RunDirector.RestartCount; }
/// <summary>프로브용 — 고장(예외) 상태를 강제로 만든다(restartResetsGuideState 검증).</summary>
public static void ForceFaultForProbe() { s_faulted = true; }
/// <summary>프로브용: 워프 쿨다운·이탈 타이머만 비운다(시나리오 사이 초기화).</summary>
public static void ResetWarpStateForProbe() { s_outsideSince = -1f; s_lastWarp = -999f; s_nextExitDiag = 0f; }
/// <summary>프로브용: 마지막 워프 시각을 밀어 넣는다(에디트 모드에서는 실제 워프가 못 돌아 쿨다운을 못 만든다).</summary>

View File

@ -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 를 반경 안으로 못 넣으면 "

View File

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

View File

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