diff --git a/AgentScripts/WL811bfix_Probe.cs b/AgentScripts/WL811bfix_Probe.cs new file mode 100644 index 000000000..7c74788a9 --- /dev/null +++ b/AgentScripts/WL811bfix_Probe.cs @@ -0,0 +1,173 @@ +// WL811bfix_Probe.cs — #811b-fix(코어 리뷰 FIX 6 + isKill 판정) 검증 프로브 +// 에디트 모드 전용 · Play 불필요 · 에셋/씬 무수정(임시 오브젝트는 즉시 파괴) +// +// 상주 에디터: unity command run_script --file AgentScripts/WL811bfix_Probe.cs --entry WL811bfix_Probe.RunAll +// 헤드리스 : 이 파일을 Assets/<무언가>/Editor/ 로 복사 후 +// Unity -batchmode -nographics -quit -projectPath <이 worktree> -executeMethod WL811bfix_Probe.RunAll +// 결과: Console + Screenshots_WL/WL811bfix/probe_.txt (RESULT PASS / RESULT FAIL n) +// +// 검사(발주서 WL-811b-fix ⓒ) +// ① FIX-1 스로틀 경로 100회 GC 증가 0 B (Set(r, info) 문자열 생성이 verboseLog 안으로 들어갔는가) +// ② QA D-1 isKill 판정 4케이스 — 살아 있음(전이 플래그) · 죽음 · 시체 재타격 · 사망통지만(StatTest) +// ③ FIX-4 TryHandleHitStop(seconds ≤ 0) → false (기존 코루틴 폴백 유지) +// +// 에디트 모드 한계: Applied 경로는 틱 러너를 만들며 DontDestroyOnLoad 를 부르는데 이건 Play 전용이라 +// 에디트 모드에서 예외가 난다(런타임에는 정상). 그래서 s_lastHitStopStart 를 리플렉션으로 세워 +// "직전에 히트스톱이 발동한 상태" 를 만든 뒤 스로틀 경로만 측정한다(오브젝트 생성 0). + +using System; +using System.IO; +using System.Reflection; +using System.Text; +using UnityEngine; +using WL.Combat.Core; + +public static class WL811bfix_Probe +{ + const string OutDir = "Screenshots_WL/WL811bfix"; + + public static object RunAll() + { + var sb = new StringBuilder(); + sb.AppendLine("# WL811b-fix Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode)"); + int fail = 0; + try { fail += ThrottleGc(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ① 예외 " + ex); } + try { fail += IsKill(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ② 예외 " + ex); } + try { fail += InvalidHitStop(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ③ 예외 " + ex); } + sb.AppendLine(fail == 0 ? "RESULT PASS" : "RESULT FAIL " + fail); + var s = sb.ToString(); + Debug.Log(s); + try + { + Directory.CreateDirectory(OutDir); + File.WriteAllText(Path.Combine(OutDir, "probe_" + DateTime.Now.ToString("HHmmss") + ".txt"), s); + } + catch { /* 출력 저장 실패는 판정에 영향 없음 */ } + return s; + } + + // ── ① FIX-1: 스로틀 경로 할당 0 ------------------------------------------------- + static int ThrottleGc(StringBuilder sb) + { + var cfg = WLCombatCoreSettings.Instance; + sb.AppendLine("asset=" + (cfg != null ? "found" : "NULL") + + " coreEnabled=" + WLCombatCoreSettings.Enabled + " arbiterEnabled=" + TimeScaleArbiter.Enabled + + " hitStopPerSecondMax=" + (cfg != null ? cfg.hitStopPerSecondMax.ToString("F2") : "?") + + " verboseLog=" + (cfg != null && cfg.verboseLog)); + if (!TimeScaleArbiter.Enabled) { sb.AppendLine("[FAIL] ① 중재자 비활성 — 측정 불가"); return 1; } + + // Time.timeScale 은 읽기만 한다 — 에디트 모드에서 대입하면 ProjectSettings/TimeManager.asset 에 그대로 적힌다(실측 사고). + TimeScaleArbiter.ResetDiagnostics(); + if (!ArmThrottle()) { sb.AppendLine("[FAIL] ① s_lastHitStopStart 리플렉션 실패 — 스로틀 상태를 만들 수 없음"); return 1; } + var rThrottled = TimeScaleArbiter.Request(TimeLayer.HitStop, 0.05f, 0.05f); // 워밍업(JIT) · 기대 Throttled + for (int i = 0; i < 20; i++) TimeScaleArbiter.Request(TimeLayer.HitStop, 0.05f, 0.05f); + + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + long a0 = Alloc(), m0 = GC.GetTotalMemory(false), p0 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); + for (int i = 0; i < 100; i++) TimeScaleArbiter.Request(TimeLayer.HitStop, 0.05f, 0.05f); + long a1 = Alloc(), m1 = GC.GetTotalMemory(false), p1 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); + + int throttled = TimeScaleArbiter.ThrottledCount; + var lastInfo = TimeScaleArbiter.LastInfo; // 읽는 순간에만 조립된다(측정 뒤에 읽는다) + Cleanup("__WLTimeScaleArbiter"); + + long alloc = a1 - a0; + bool ok = rThrottled == ArbiterResult.Throttled && throttled >= 121 && alloc == 0L; + sb.AppendLine((ok ? "[PASS]" : "[FAIL]") + " ① 스로틀 100회: result=" + rThrottled + + " throttledCount=" + throttled + " (기대 121)" + + " · GC.GetAllocatedBytesForCurrentThread 증가=" + alloc + " B (기대 0)" + + " · GetTotalMemory 증가=" + (m1 - m0) + " B · MonoUsedSize 증가=" + (p1 - p0) + " B" + + " · LastInfo=\"" + lastInfo + "\""); + return ok ? 0 : 1; + } + + static long Alloc() + { + try { return GC.GetAllocatedBytesForCurrentThread(); } + catch (Exception) { return GC.GetTotalMemory(false); } + } + + /// "방금 히트스톱이 발동했다" 상태를 만든다 — 이후 Request 는 전부 스로틀 경로(오브젝트 생성 0). + static bool ArmThrottle() + { + var f = typeof(TimeScaleArbiter).GetField("s_lastHitStopStart", BindingFlags.NonPublic | BindingFlags.Static); + if (f == null) return false; + f.SetValue(null, Time.unscaledTime); + return true; + } + + // ── ② QA D-1: isKill 판정 -------------------------------------------------------- + static int IsKill(StringBuilder sb) + { + var go = new GameObject("__WL811bfix_victim"); + try + { + var actor = go.AddComponent(); + var fDead = typeof(Actor).GetField("DeadStatus", BindingFlags.NonPublic | BindingFlags.Instance); + if (fDead == null) { sb.AppendLine("[FAIL] ② Actor.DeadStatus 리플렉션 실패"); return 1; } + + // 공유 DamageInfo 가 앞 피격자의 사망으로 이미 오염된 상태(QA D-1 원인 재현) + var stale = new DamageInfo(); stale.IsDeadByThisDamage = true; + var clean = new DamageInfo(); clean.IsDeadByThisDamage = false; + + CombatEvents.ResetDiagnostics(); + + fDead.SetValue(actor, false); + CombatEvents.RaiseHitConfirmed(actor, stale); + bool a = !CombatEvents.LastIsKill; // A 살아 있는 피격자 + 전이 플래그 → false + + fDead.SetValue(actor, true); + CombatEvents.RaiseKilled(actor); + CombatEvents.RaiseHitConfirmed(actor, clean); + bool b = CombatEvents.LastIsKill; // B 실제 사망(플래그는 false) → true + + CombatEvents.RaiseHitConfirmed(actor, stale); + bool c = !CombatEvents.LastIsKill; // C 같은 프레임 시체 재타격 → false + + fDead.SetValue(actor, false); + CombatEvents.RaiseKilled(actor); + CombatEvents.RaiseHitConfirmed(actor, stale); + bool d = !CombatEvents.LastIsKill; // D 사망통지만 있고 실제 생존(StatTest) → false + + int killed = CombatEvents.Killed.Raised, kills = CombatEvents.KillConfirmedCount; + bool e = kills == 1; // E 실제 사망 1건 = isKill 1건 + bool ok = a && b && c && d && e; + sb.AppendLine((ok ? "[PASS]" : "[FAIL]") + " ② isKill: A살아있음+전이플래그=" + (a ? "false✔" : "true✘") + + " B사망=" + (b ? "true✔" : "false✘") + " C시체재타격=" + (c ? "false✔" : "true✘") + + " D사망통지만=" + (d ? "false✔" : "true✘") + + " · HitConfirmed=" + CombatEvents.HitConfirmed.Raised + " Killed=" + killed + " isKillTrue=" + kills + " (기대 1)"); + return ok ? 0 : 1; + } + finally { UnityEngine.Object.DestroyImmediate(go); CombatEvents.ResetDiagnostics(); } + } + + // ── ③ FIX-4: Invalid 요청은 코어가 처리하지 않았다고 답한다 ------------------------ + static int InvalidHitStop(StringBuilder sb) + { + var st = WL.Feel.WLHitFeel.Settings; + if (st == null) { sb.AppendLine("[FAIL] ③ WLHitFeelSettings 에셋 없음 — 측정 불가"); return 1; } + TimeScaleArbiter.ResetDiagnostics(); + ArmThrottle(); // 에디트 모드에서 Applied(러너 생성) 를 피한다 — 정상 요청은 Throttled 로 받는다 + + bool zero = TimeScaleArbiter.TryHandleHitStop(st, 0f); + var rZero = TimeScaleArbiter.LastResult; + bool neg = TimeScaleArbiter.TryHandleHitStop(st, -1f); + bool valid = TimeScaleArbiter.TryHandleHitStop(st, 0.04f); // 코어가 처리한 요청은 그대로 true + var rValid = TimeScaleArbiter.LastResult; + + TimeScaleArbiter.ReleaseAll(); + Cleanup("__WLTimeScaleArbiter"); + + bool ok = !zero && !neg && valid && rZero == ArbiterResult.Invalid && rValid == ArbiterResult.Throttled; + sb.AppendLine((ok ? "[PASS]" : "[FAIL]") + " ③ TryHandleHitStop: seconds=0 → " + zero + "(기대 false · " + rZero + ")" + + " · seconds=-1 → " + neg + "(기대 false) · seconds=0.04 → " + valid + "(기대 true · " + rValid + ")" + + " · hitStopMode=" + st.hitStopMode); + return ok ? 0 : 1; + } + + static void Cleanup(string name) + { + var go = GameObject.Find(name); + if (go != null) UnityEngine.Object.DestroyImmediate(go); + } +} diff --git a/Assets/WL/Combat/Core/CombatEvents.cs b/Assets/WL/Combat/Core/CombatEvents.cs index 62792a017..7d7079165 100644 --- a/Assets/WL/Combat/Core/CombatEvents.cs +++ b/Assets/WL/Combat/Core/CombatEvents.cs @@ -116,17 +116,30 @@ namespace WL.Combat.Core public static int TotalRaised; public static string LastEvent = ""; public static float LastEventTime; + public static bool LastIsKill; // 마지막 HitConfirmed 의 isKill 판정 + public static int KillConfirmedCount; // isKill = true 로 확정한 수 — QA D-1 검증: Killed.Raised 와 같아야 한다 // ── 처치자 귀속 컨텍스트: HitboxHit(피해 적용 직전) → 같은 호출 스택의 Set_Die(Killed) 에서 소비 static Actor s_pendingHitShooter, s_pendingHitVictim; static int s_pendingHitFrame = -1; + // ── 사망 통지 상관(QA D-1): 공유 DamageInfo.IsDeadByThisDamage 는 리셋되지 않아(Actor.cs:584) + // 같은 투사체가 여러 몹을 때리면 뒤의 **살아 있는** 피격자에게 전이된다. 그 플래그를 읽지 않고 + // Killed 통지(= Cal_Damage 안 Set_Die · HitConfirmed 보다 먼저 · 같은 스택)를 대상·프레임으로 1회만 소비한다. + static Actor s_deathVictim; + static int s_deathFrame = -1; + + // ── FIX-5: LastCoreHandled 재진입 오염 방지(구독자가 연쇄 피해를 내면 중첩 RaiseHitConfirmed 가 바깥 값을 덮어썼다) + static bool s_inHitConfirmed; + static void Stamp(string name) { TotalRaised++; LastEvent = name; LastEventTime = Time.unscaledTime; } + /// verboseLog 여부. 로그 문자열을 **만들기 전에** 이걸로 막는다(FIX-3). + static bool Verbose { get { var c = WLCombatCoreSettings.Instance; return c != null && c.verboseLog; } } + static void Log(string msg) { - var c = WLCombatCoreSettings.Instance; - if (c != null && c.verboseLog) Debug.Log("[CombatEvents] " + msg); + if (Verbose) Debug.Log("[CombatEvents] " + msg); } // ───────────────────────────────────────── Raise (원본 훅이 부른다 · 1줄) @@ -186,27 +199,52 @@ namespace WL.Combat.Core if (!Enabled) return !(beaterMainPC && !WL.Feel.WLHitFeel.OnHit(victim, dinfo)); // C8: 원본 식 그대로 + bool outerHandled = WL.Feel.WLHitFeel.LastCoreHandled; // FIX-5: 재진입이면 바깥 호출의 판정을 보존한다 + bool nested = s_inHitConfirmed; + s_inHitConfirmed = true; WL.Feel.WLHitFeel.LastCoreHandled = false; var e = new HitConfirmedEvent { victim = victim, attacker = dinfo != null ? dinfo.Beater : null, dinfo = dinfo, damage = dinfo != null ? dinfo.Damage : 0d, critical = dinfo != null && dinfo.Critical != eStat.None, - isKill = dinfo != null && dinfo.IsDeadByThisDamage, + isKill = ResolveIsKill(victim), byMainPC = beaterMainPC, projectile = dinfo != null ? dinfo.Beater_pd : null, time = Time.unscaledTime, frame = Time.frameCount }; + LastIsKill = e.isKill; if (e.isKill) KillConfirmedCount++; Stamp("HitConfirmed"); HitConfirmed.Dispatch(in e); if (!WL.Feel.WLHitFeel.CoreSubscribed) // 구독 등록 전(도메인 리로드 직후 등) 안전망 — 타격감을 잃지 않는다 WL.Feel.WLHitFeel.LastCoreHandled = WL.Feel.WLHitFeel.OnHit(victim, dinfo); + bool handled = WL.Feel.WLHitFeel.LastCoreHandled; // 내 결과를 지역으로 확정한 뒤 + if (nested) WL.Feel.WLHitFeel.LastCoreHandled = outerHandled; // 바깥 호출이 읽을 값을 되돌린다 + s_inHitConfirmed = nested; if (s_pendingHitVictim == victim) { s_pendingHitVictim = null; s_pendingHitShooter = null; } - return !(beaterMainPC && !WL.Feel.WLHitFeel.LastCoreHandled); + return !(beaterMainPC && !handled); + } + + /// + /// isKill 확정(QA D-1 · #813). dinfo.IsDeadByThisDamage 는 공유 객체라 뒤 피격자에게 전이되므로 읽지 않는다. + /// (a) 호출 시점 victim 이 실제로 죽어 있고(DeadStatus 또는 HP ≤ 0 실측) (b) 이 프레임의 미소비 Killed 통지가 이 victim 것일 때만 true. + /// (b) 가 "직전 생존" 을 보장한다 — Killed 는 살아 있던 대상이 죽는 순간 1회만 나므로 시체 재타격·전이는 전부 false. + /// (a) 는 Killed 훅이 실제 사망보다 앞서는 경로(MobActor.Set_Die 의 StatTest 조기 return)를 걸러낸다. + /// 한계: Killed 훅이 없는 사망 경로(비 MobActor)는 보수적으로 false. + /// + static bool ResolveIsKill(Actor victim) + { + if (victim == null) return false; + bool deadNow = victim.IsOnlyDead() || (victim.Get_StatInfo() != null && victim.Get_HP() <= 0d); + if (!deadNow) return false; + if (s_deathVictim != victim || s_deathFrame != Time.frameCount) return false; + s_deathVictim = null; // 1 사망 = 1 isKill + return true; } /// MobActor.Set_Die 첫 줄 — 몹 사망 확정. 처치자는 같은 프레임의 HitboxHit 컨텍스트에서 귀속(없으면 null · 도트 등). public static void RaiseKilled(Actor victim) { if (!Enabled || victim == null) return; + s_deathVictim = victim; s_deathFrame = Time.frameCount; // QA D-1: 뒤따르는 HitConfirmed 가 1회만 소비한다 bool direct = s_pendingHitVictim == victim && s_pendingHitFrame == Time.frameCount; var e = new KilledEvent { @@ -215,7 +253,7 @@ namespace WL.Combat.Core byDirectHit = direct, time = Time.unscaledTime, frame = Time.frameCount }; Stamp("Killed"); Killed.Dispatch(in e); - Log("Killed " + victim.name + " by " + (e.killer != null ? e.killer.name : "?")); + if (Verbose) Log("Killed " + victim.name + " by " + (e.killer != null ? e.killer.name : "?")); // FIX-3: 결합 자체를 verbose 안으로 } /// DashDriver — 대시 시작. @@ -277,7 +315,8 @@ namespace WL.Combat.Core /// 프로브용: 카운터 초기화(구독은 유지). public static void ResetDiagnostics() { - TotalRaised = 0; LastEvent = ""; LastEventTime = 0f; + TotalRaised = 0; LastEvent = ""; LastEventTime = 0f; LastIsKill = false; KillConfirmedCount = 0; + s_deathVictim = null; s_deathFrame = -1; AttackStarted.Raised = AttackStarted.Dispatched = 0; ComboStage.Raised = ComboStage.Dispatched = 0; SwingEffect.Raised = SwingEffect.Dispatched = 0; HitboxSpawned.Raised = HitboxSpawned.Dispatched = 0; HitboxHit.Raised = HitboxHit.Dispatched = 0; HitConfirmed.Raised = HitConfirmed.Dispatched = 0; diff --git a/Assets/WL/Combat/Core/ComboTracker.cs b/Assets/WL/Combat/Core/ComboTracker.cs index d2884bba3..0fb9811d1 100644 --- a/Assets/WL/Combat/Core/ComboTracker.cs +++ b/Assets/WL/Combat/Core/ComboTracker.cs @@ -25,9 +25,12 @@ namespace WL.Combat.Core readonly Dictionary _stageByHash = new Dictionary(16); readonly Dictionary _clipByHash = new Dictionary(16); - // ── 진단 + // ── 진단 (FIX-6: 단계마다 문자열을 만들지 않는다 — 값만 남기고 LastInfo 는 읽을 때 조립) public static int Instances; - public static string LastInfo = ""; + public static string LastClip = ""; + public static int LastStageRaised; + /// 마지막 단계의 설명(프로브 전용). 읽는 순간에만 문자열을 만든다. + public static string LastInfo { get { return LastClip + " stage=" + LastStageRaised; } } public int LastStage { get { return _lastStage; } } /// 메인 PC 에 1개만 붙인다(이미 있으면 재사용). 모델 교체 등으로 Animator 가 바뀌어도 매번 갱신한다. @@ -40,6 +43,8 @@ namespace WL.Combat.Core t._anim = pc.m_animation; } + void OnDestroy() { Instances--; } // FIX-6: 씬 전환·PC 교체로 사라진 인스턴스를 세지 않는다 + void LateUpdate() { if (_pc == null || _anim == null || !WLCombatCoreSettings.Enabled) return; @@ -65,7 +70,7 @@ namespace WL.Combat.Core _lastStage = stage; bool isLast = stage >= WLCombatCoreSettings.Instance.comboStages; - LastInfo = clip + " stage=" + stage; // 진단용(문자열 결합 · verbose 와 무관하게 1회/단계 · 프로브가 읽는다) + LastClip = clip; LastStageRaised = stage; // 진단용 값만 기록(결합 0 · 프로브가 LastInfo 로 읽는다) CombatEvents.RaiseComboStage(_pc, stage, clip, st.normalizedTime % 1f, isLast); } diff --git a/Assets/WL/Combat/Core/TimeScaleArbiter.cs b/Assets/WL/Combat/Core/TimeScaleArbiter.cs index 18476960d..cd881990a 100644 --- a/Assets/WL/Combat/Core/TimeScaleArbiter.cs +++ b/Assets/WL/Combat/Core/TimeScaleArbiter.cs @@ -35,7 +35,31 @@ namespace WL.Combat.Core public static float LastMeasured; // 마지막 감속의 실측 길이(초 · unscaled) · 외부가 덮어썼으면 -1 public static ArbiterResult LastResult; public static int AppliedCount, IgnoredCount, SkippedHigherCount, SkippedPauseCount, ThrottledCount, PreemptedCount; - public static string LastInfo = ""; + + // FIX-1(#813 리뷰 Major-1): 진단 문자열은 **읽을 때만** 만든다. Set() 은 값 4개만 남기므로 + // 스로틀·스킵 경로(타격마다 도는 경로)의 할당이 0 이다 — 이전에는 verboseLog 와 무관하게 ToString("F3")×2 + 결합 3회를 항상 했다. + static TimeLayer s_infoLayer, s_infoActive; + static float s_infoA, s_infoB; + static bool s_infoSet; + + /// 마지막 결정의 설명(프로브·verboseLog 전용). 읽는 순간에만 문자열을 조립한다. + public static string LastInfo + { + get + { + if (!s_infoSet) return ""; + switch (LastResult) + { + case ArbiterResult.Disabled: return "core off"; + case ArbiterResult.Invalid: return "layer=" + s_infoLayer; + case ArbiterResult.SkippedPause: return "external timeScale " + s_infoA.ToString("F2"); + case ArbiterResult.IgnoredSameLayer: return s_infoLayer.ToString(); + case ArbiterResult.SkippedHigher: return s_infoLayer + " under " + s_infoActive; + case ArbiterResult.Throttled: return "hitstop " + s_infoA.ToString("F3") + " < " + s_infoB.ToString("F3"); + default: return s_infoLayer + " " + s_infoA.ToString("F2") + " for " + s_infoB.ToString("F3"); + } + } + } public static bool Enabled { @@ -56,11 +80,12 @@ namespace WL.Combat.Core } } - static ArbiterResult Set(ArbiterResult r, string info) + /// 결정 1건 기록. 문자열을 만들지 않는다(FIX-1) — verboseLog 일 때만 LastInfo 로 조립한다. + static ArbiterResult Set(ArbiterResult r, TimeLayer layer, float a, float b) { - LastResult = r; LastInfo = info; + LastResult = r; s_infoLayer = layer; s_infoActive = s_active; s_infoA = a; s_infoB = b; s_infoSet = true; var c = WLCombatCoreSettings.Instance; - if (c != null && c.verboseLog) Debug.Log("[TimeScaleArbiter] " + r + " " + info + " timeScale=" + Time.timeScale.ToString("F2")); + if (c != null && c.verboseLog) Debug.Log("[TimeScaleArbiter] " + r + " " + LastInfo + " timeScale=" + Time.timeScale.ToString("F2")); return r; } @@ -69,9 +94,9 @@ namespace WL.Combat.Core /// public static ArbiterResult Request(TimeLayer layer, float scale, float seconds) { - if (!Enabled) return Set(ArbiterResult.Disabled, "core off"); + if (!Enabled) return Set(ArbiterResult.Disabled, TimeLayer.None, 0f, 0f); var cfg = WLCombatCoreSettings.Instance; - if (layer == TimeLayer.None || layer == TimeLayer.Pause || seconds <= 0f || scale < 0f) return Set(ArbiterResult.Invalid, "layer=" + layer); + if (layer == TimeLayer.None || layer == TimeLayer.Pause || seconds <= 0f || scale < 0f) return Set(ArbiterResult.Invalid, layer, scale, seconds); float cur = Time.timeScale; bool oursActive = s_active != TimeLayer.None && Mathf.Approximately(cur, s_written); @@ -80,15 +105,16 @@ namespace WL.Combat.Core if (s_active != TimeLayer.None && !oursActive) { s_active = TimeLayer.None; LastMeasured = -1f; } // 외부 일시정지(또는 외부 저속) 관측 → Pause 층 활성으로 보고 스킵 - if (!oursActive && cur < cfg.pauseObserveThreshold) { SkippedPauseCount++; return Set(ArbiterResult.SkippedPause, "external timeScale " + cur.ToString("F2")); } + if (!oursActive && cur < cfg.pauseObserveThreshold) { SkippedPauseCount++; return Set(ArbiterResult.SkippedPause, layer, cur, 0f); } - if (s_active == layer) { IgnoredCount++; return Set(ArbiterResult.IgnoredSameLayer, layer.ToString()); } - if (s_active > layer) { SkippedHigherCount++; return Set(ArbiterResult.SkippedHigher, layer + " under " + s_active); } + if (s_active == layer) { IgnoredCount++; return Set(ArbiterResult.IgnoredSameLayer, layer, 0f, 0f); } + if (s_active > layer) { SkippedHigherCount++; return Set(ArbiterResult.SkippedHigher, layer, 0f, 0f); } if (layer == TimeLayer.HitStop && cfg.hitStopPerSecondMax > 0f) { float minInterval = 1f / cfg.hitStopPerSecondMax; - if (Time.unscaledTime - s_lastHitStopStart < minInterval) { ThrottledCount++; return Set(ArbiterResult.Throttled, "hitstop " + (Time.unscaledTime - s_lastHitStopStart).ToString("F3") + " < " + minInterval.ToString("F3")); } + float since = Time.unscaledTime - s_lastHitStopStart; + if (since < minInterval) { ThrottledCount++; return Set(ArbiterResult.Throttled, layer, since, minInterval); } } if (s_active != TimeLayer.None) { Restore(); PreemptedCount++; } // 하위 층 선점 @@ -102,7 +128,7 @@ namespace WL.Combat.Core Time.timeScale = scale; Runner.enabled = true; AppliedCount++; - return Set(ArbiterResult.Applied, layer + " " + scale.ToString("F2") + " for " + seconds.ToString("F3")); + return Set(ArbiterResult.Applied, layer, scale, seconds); } /// 층을 명시적으로 끝낸다(활성 층이 다르면 무시). @@ -135,21 +161,20 @@ namespace WL.Combat.Core /// /// WLHitFeelRunner.RunHitStop 이 부른다. true = 코어가 처리했다(적용·스킵·스로틀 모두) → 기존 코루틴을 돌리지 말 것. - /// false = 코어 비활성 또는 Feel 프리즈프레임 모드 → 기존 경로 그대로. + /// false = 코어 비활성 · Feel 프리즈프레임 모드 · 요청 자체가 Invalid → 기존 코루틴 경로 그대로(FIX-4: 무음 유실 방지). /// public static bool TryHandleHitStop(WLHitFeelSettings st, float seconds) { if (!Enabled || st == null) return false; if (st.hitStopMode == WLHitFeelSettings.eHitStopMode.FeelFreezeFrame) return false; - Request(TimeLayer.HitStop, st.hitStopTimeScale, seconds); - return true; + return Request(TimeLayer.HitStop, st.hitStopTimeScale, seconds) != ArbiterResult.Invalid; } /// 프로브용 카운터 초기화. public static void ResetDiagnostics() { AppliedCount = IgnoredCount = SkippedHigherCount = SkippedPauseCount = ThrottledCount = PreemptedCount = 0; - LastMeasured = 0f; LastInfo = ""; s_lastHitStopStart = -999f; + LastMeasured = 0f; s_infoSet = false; s_lastHitStopStart = -999f; } } diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset index f9b03ce08..e3338f005 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset @@ -19,7 +19,7 @@ MonoBehaviour: comboClipToken: Attack arbiterEnabled: 1 pauseObserveThreshold: 0.1 - hitStopPerSecondMax: 1.5 + hitStopPerSecondMax: 3 layerMaxSeconds: 2 cinematicDefaultScale: 0.35 heavyEffectMax: 2 diff --git a/Assets/WL/Feel/WLHitFeel.cs b/Assets/WL/Feel/WLHitFeel.cs index 1e2eee65e..7220d2cbb 100644 --- a/Assets/WL/Feel/WLHitFeel.cs +++ b/Assets/WL/Feel/WLHitFeel.cs @@ -6,11 +6,12 @@ namespace WL.Feel /// /// PD #797 — "타격 시점에만 살짝" 임팩트 연출의 진입점. /// - /// ■ 훅 (힘민지 코어 변경은 이 1 줄뿐) - /// Actor.cs Get_Damage() 안, Cal_Damage(_dinfo) 직후의 기존 카메라 셰이크 줄을 - /// if (_dinfo.Beater.IsMainPC() && !WL.Feel.WLHitFeel.OnHit(this, _dinfo)) - /// MyValue.m_RealCamera.ShakeCamera(1); - /// 로 바꾼다. OnHit 이 false 를 반환하면(에셋 없음·enabled=false) 기존 동작이 100% 그대로다. + /// ■ 훅 (힘민지 코어 변경은 이 1 줄뿐 · #811b 이후 코어 이벤트 경유) + /// Actor.cs Get_Damage() 안, Cal_Damage(_dinfo) 직후의 기존 카메라 셰이크 줄은 현재 + /// if (!WL.Combat.Core.CombatEvents.RaiseHitConfirmed(this, _dinfo)) MyValue.m_RealCamera.ShakeCamera(1); + /// 이고, RaiseHitConfirmed 가 이 클래스를 HitConfirmed 구독자로 돌려 OnHit 반환값(LastCoreHandled)으로 + /// 원본 규약 `!(Beater.IsMainPC() && !OnHit(...))` 를 그대로 잇는다. + /// OnHit 이 false 를 반환하면(에셋 없음·enabled=false) 기존 ShakeCamera(1) 동작이 100% 그대로다. /// /// ■ 왜 Cal_Damage 직후인가 /// · 그 시점에 _dinfo.Damage 가 방어력·감쇠까지 끝난 최종값이다.