From ff208d6dbe4ac8659c414c311b0a4f70093bd6bb Mon Sep 17 00:00:00 2001 From: swrring Date: Tue, 8 Sep 2026 23:30:57 +0900 Subject: [PATCH] =?UTF-8?q?[WL-811b]=20=EC=A0=84=ED=88=AC=20=EC=95=A1?= =?UTF-8?q?=EC=85=98=20=EC=BD=94=EC=96=B4=20=EA=B3=A8=EA=B2=A9=20(#811)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CombatEvents(이벤트 12종 + LevelUp API · 구조체 페이로드 · GC 0 · 예외 격리) · ComboTracker(PC Animator 상태 폴링) · TimeScaleArbiter(Pause > Cinematic > HitStop · D-1(a)) · EffectBudget(동시 상한) · WLCombatCoreSettings SO + 에셋. 원본 훅 9줄(D-5(a) · Actor.cs 5 / MobActor.cs 2 / ProjectileBase.cs 1 / MyActor.cs 1 · 이후 Actor.cs 동결) · 편입: WLHitFeel = HitConfirmed 구독자(false 반환 규약 유지) · WLHitFeelRunner 히트스톱 중재자 경유 · DashDriver/WeaponTrailDriver 통지. 에셋 없음/enabled=false/RuntimeDisabled = 100% 기존 동작(C8). 프로브 WL811b_Probe(Watch/GcCheck/Arbiter/Toggle/Dump/Status). 게이트 oneshot PASS(error CS 0 · 로그 wl_compile_gate_20260908_231602.log). Co-Authored-By: Claude Fable 5.1 --- AgentScripts/WL811b_Probe.cs | 236 ++++++++++++++ Assets/Script/Character/Actor.cs | 6 +- Assets/Script/Character/Mob/MobActor.cs | 2 + Assets/Script/Character/MyActor.cs | 1 + .../Character/Projectile/ProjectileBase.cs | 1 + Assets/WL/Combat/Core.meta | 8 + Assets/WL/Combat/Core/CombatEvents.cs | 289 ++++++++++++++++++ Assets/WL/Combat/Core/CombatEvents.cs.meta | 2 + Assets/WL/Combat/Core/ComboTracker.cs | 93 ++++++ Assets/WL/Combat/Core/ComboTracker.cs.meta | 2 + Assets/WL/Combat/Core/EffectBudget.cs | 71 +++++ Assets/WL/Combat/Core/EffectBudget.cs.meta | 2 + Assets/WL/Combat/Core/TimeScaleArbiter.cs | 163 ++++++++++ .../WL/Combat/Core/TimeScaleArbiter.cs.meta | 2 + Assets/WL/Combat/Core/WLCombatCoreSettings.cs | 111 +++++++ .../Combat/Core/WLCombatCoreSettings.cs.meta | 11 + Assets/WL/Combat/DashDriver.cs | 2 + Assets/WL/Combat/Settings.meta | 8 + Assets/WL/Combat/Settings/Resources.meta | 8 + Assets/WL/Combat/Settings/Resources/WL.meta | 8 + .../Resources/WL/WLCombatCoreSettings.asset | 30 ++ .../WL/WLCombatCoreSettings.asset.meta | 8 + Assets/WL/Combat/WeaponTrailDriver.cs | 4 +- Assets/WL/Feel/WLHitFeel.cs | 15 + Assets/WL/Feel/WLHitFeelRunner.cs | 1 + 25 files changed, 1082 insertions(+), 2 deletions(-) create mode 100644 AgentScripts/WL811b_Probe.cs create mode 100644 Assets/WL/Combat/Core.meta create mode 100644 Assets/WL/Combat/Core/CombatEvents.cs create mode 100644 Assets/WL/Combat/Core/CombatEvents.cs.meta create mode 100644 Assets/WL/Combat/Core/ComboTracker.cs create mode 100644 Assets/WL/Combat/Core/ComboTracker.cs.meta create mode 100644 Assets/WL/Combat/Core/EffectBudget.cs create mode 100644 Assets/WL/Combat/Core/EffectBudget.cs.meta create mode 100644 Assets/WL/Combat/Core/TimeScaleArbiter.cs create mode 100644 Assets/WL/Combat/Core/TimeScaleArbiter.cs.meta create mode 100644 Assets/WL/Combat/Core/WLCombatCoreSettings.cs create mode 100644 Assets/WL/Combat/Core/WLCombatCoreSettings.cs.meta create mode 100644 Assets/WL/Combat/Settings.meta create mode 100644 Assets/WL/Combat/Settings/Resources.meta create mode 100644 Assets/WL/Combat/Settings/Resources/WL.meta create mode 100644 Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset create mode 100644 Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset.meta diff --git a/AgentScripts/WL811b_Probe.cs b/AgentScripts/WL811b_Probe.cs new file mode 100644 index 000000000..97fe1ed75 --- /dev/null +++ b/AgentScripts/WL811b_Probe.cs @@ -0,0 +1,236 @@ +// WL811b_Probe.cs — #811b 전투 액션 코어 골격 검증 프로브 (Play 전용 · 읽기 전용 · 에셋 무수정) +// +// unity command run_script --file AgentScripts/WL811b_Probe.cs --entry WL811b_Probe.Watch --args '[10]' // ⓒ 이벤트 순서·ms 로그(10 s · 3콤보 1사이클 이상) +// unity command run_script --file AgentScripts/WL811b_Probe.cs --entry WL811b_Probe.GcCheck // ⓖ 100회 디스패치 GC 증가(바이트) +// unity command run_script --file AgentScripts/WL811b_Probe.cs --entry WL811b_Probe.Arbiter // ⓔ 일시정지 스킵 · 상위 층 스킵 · 스로틀 · 복원 +// unity command run_script --file AgentScripts/WL811b_Probe.cs --entry WL811b_Probe.Toggle --args '[false, 6]' // ⓕ 코어 off 6 s: 디스패치 0 · 타격감(FiredCount) 계속 → 자동 on 복귀 +// unity command run_script --file AgentScripts/WL811b_Probe.cs --entry WL811b_Probe.Dump // ⓗ 설정 에셋 값 · 활성 상태 +// unity command run_script --file AgentScripts/WL811b_Probe.cs --entry WL811b_Probe.Status // 이벤트별 Raised/Dispatched · 중재자 · 상한 카운터 +// 결과: Screenshots_WL/WL811b/<이름>_.txt + Console +// 코어·원본 코드를 수정하지 않는다. 구독은 프로브 종료 시 해제한다. + +using System; +using System.Collections; +using System.Text; +using UnityEngine; +using WL.Combat.Core; + +public static class WL811b_Probe +{ + const string OutDir = "Screenshots_WL/WL811b"; + + public static void Watch(float seconds) { Launch(h => h.Co_Watch(seconds <= 0f ? 10f : seconds)); } + public static void Arbiter() { Launch(h => h.Co_Arbiter()); } + public static void Toggle(bool on, float seconds) { Launch(h => h.Co_Toggle(on, seconds <= 0f ? 6f : seconds)); } + + public static object Dump() + { + var sb = new StringBuilder(); + var c = WLCombatCoreSettings.Instance; + sb.AppendLine("# WL811b Dump " + DateTime.Now.ToString("HH:mm:ss")); + sb.AppendLine("asset=" + (c != null ? "found" : "NULL") + " Enabled=" + WLCombatCoreSettings.Enabled + " RuntimeDisabled=" + WLCombatCoreSettings.RuntimeDisabled + + " arbiter=" + TimeScaleArbiter.Enabled + " feel=" + (WL.Feel.WLHitFeel.Settings != null ? WL.Feel.WLHitFeel.Settings.enabled.ToString() : "null") + " coreSubscribed=" + WL.Feel.WLHitFeel.CoreSubscribed); + if (c != null) sb.AppendLine(JsonUtility.ToJson(c, true)); + Save("dump", sb); return sb.ToString(); + } + + public static object Status() + { + var sb = new StringBuilder(); + sb.AppendLine("# WL811b Status " + DateTime.Now.ToString("HH:mm:ss") + " timeScale=" + Time.timeScale.ToString("F2") + " TotalRaised=" + CombatEvents.TotalRaised + " last=" + CombatEvents.LastEvent); + sb.AppendLine(Row("AttackStarted", CombatEvents.AttackStarted.Count, CombatEvents.AttackStarted.Raised, CombatEvents.AttackStarted.Dispatched)); + sb.AppendLine(Row("ComboStage", CombatEvents.ComboStage.Count, CombatEvents.ComboStage.Raised, CombatEvents.ComboStage.Dispatched)); + sb.AppendLine(Row("SwingEffect", CombatEvents.SwingEffect.Count, CombatEvents.SwingEffect.Raised, CombatEvents.SwingEffect.Dispatched)); + sb.AppendLine(Row("HitboxSpawned", CombatEvents.HitboxSpawned.Count, CombatEvents.HitboxSpawned.Raised, CombatEvents.HitboxSpawned.Dispatched)); + sb.AppendLine(Row("HitboxHit", CombatEvents.HitboxHit.Count, CombatEvents.HitboxHit.Raised, CombatEvents.HitboxHit.Dispatched)); + sb.AppendLine(Row("HitConfirmed", CombatEvents.HitConfirmed.Count, CombatEvents.HitConfirmed.Raised, CombatEvents.HitConfirmed.Dispatched)); + sb.AppendLine(Row("Killed", CombatEvents.Killed.Count, CombatEvents.Killed.Raised, CombatEvents.Killed.Dispatched)); + sb.AppendLine(Row("Dash", CombatEvents.Dash.Count, CombatEvents.Dash.Raised, CombatEvents.Dash.Dispatched)); + sb.AppendLine(Row("SkillCast", CombatEvents.SkillCast.Count, CombatEvents.SkillCast.Raised, CombatEvents.SkillCast.Dispatched)); + sb.AppendLine(Row("SkillFired", CombatEvents.SkillFired.Count, CombatEvents.SkillFired.Raised, CombatEvents.SkillFired.Dispatched)); + sb.AppendLine(Row("Damaged", CombatEvents.Damaged.Count, CombatEvents.Damaged.Raised, CombatEvents.Damaged.Dispatched)); + sb.AppendLine(Row("Spawned", CombatEvents.Spawned.Count, CombatEvents.Spawned.Raised, CombatEvents.Spawned.Dispatched)); + sb.AppendLine(Row("LevelUp", CombatEvents.LevelUp.Count, CombatEvents.LevelUp.Raised, CombatEvents.LevelUp.Dispatched)); + sb.AppendLine("arbiter active=" + TimeScaleArbiter.Active + " applied=" + TimeScaleArbiter.AppliedCount + " ignored=" + TimeScaleArbiter.IgnoredCount + " skipHigher=" + TimeScaleArbiter.SkippedHigherCount + + " skipPause=" + TimeScaleArbiter.SkippedPauseCount + " throttled=" + TimeScaleArbiter.ThrottledCount + " preempted=" + TimeScaleArbiter.PreemptedCount + " lastMeasured=" + TimeScaleArbiter.LastMeasured.ToString("F3") + " last=" + TimeScaleArbiter.LastResult + " " + TimeScaleArbiter.LastInfo); + sb.AppendLine("feel hit=" + WL.Feel.WLHitFeel.HitCount + " fired=" + WL.Feel.WLHitFeel.FiredCount + " throttled=" + WL.Feel.WLHitFeel.ThrottledCount + " lastHitStop=" + WL.Feel.WLHitFeel.LastHitStopMeasured.ToString("F3")); + sb.AppendLine("budget heavy=" + EffectBudget.Count(EffectBudgetKind.Heavy) + "/" + EffectBudget.Limit(EffectBudgetKind.Heavy) + " afterImage=" + EffectBudget.Count(EffectBudgetKind.AfterImage) + "/" + EffectBudget.Limit(EffectBudgetKind.AfterImage) + + " dissolve=" + EffectBudget.Count(EffectBudgetKind.Dissolve) + "/" + EffectBudget.Limit(EffectBudgetKind.Dissolve) + " burst=" + EffectBudget.Count(EffectBudgetKind.ClusterBurst) + "/" + EffectBudget.Limit(EffectBudgetKind.ClusterBurst) + " combo=" + ComboTracker.LastInfo); + Save("status", sb); return sb.ToString(); + } + + /// ⓖ 100회 디스패치 GC 증가. 구독자 1개(no-op) 등록 후 측정 · Play/Edit 모두 가능(코어 Enabled 필요). + public static object GcCheck() + { + var sb = new StringBuilder(); + sb.AppendLine("# WL811b GcCheck " + DateTime.Now.ToString("HH:mm:ss") + " Enabled=" + WLCombatCoreSettings.Enabled); + CombatHandler h = NoOpHitbox; + CombatEvents.HitboxSpawned.Add(h); + var pos = Vector3.zero; + for (int i = 0; i < 20; i++) CombatEvents.RaiseHitboxSpawned(null, "warm", 1f, 1f, pos); // 워밍업(JIT) + long m0 = GC.GetTotalMemory(false); + long p0 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); + for (int i = 0; i < 100; i++) CombatEvents.RaiseHitboxSpawned(null, "gc", 1f, 1f, pos); + long m1 = GC.GetTotalMemory(false); + long p1 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); + CombatEvents.HitboxSpawned.Remove(h); + sb.AppendLine("100 dispatch: GC.GetTotalMemory delta=" + (m1 - m0) + " B · Profiler.MonoUsed delta=" + (p1 - p0) + " B · noop calls=" + s_noop + " (기준 < 1024 B)"); + Save("gc", sb); return sb.ToString(); + } + static int s_noop; + static void NoOpHitbox(in HitboxSpawnedEvent e) { s_noop++; } + + // ── 공용 + static string Row(string n, int subs, int raised, int dispatched) { return string.Format(" {0,-14} subs={1} raised={2} dispatched={3}", n, subs, raised, dispatched); } + + static void Save(string name, StringBuilder sb) + { + try + { + System.IO.Directory.CreateDirectory(OutDir); + string path = System.IO.Path.Combine(OutDir, name + "_" + DateTime.Now.ToString("HHmmss") + ".txt"); + System.IO.File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); + Debug.Log("[WL811b] 저장 " + path); + } + catch (Exception e) { Debug.LogWarning("[WL811b] 저장 실패 " + e.Message); } + Debug.Log(sb.ToString()); + } + + static void Launch(Func make) + { + var old = GameObject.Find("__wl811b_probe"); + if (old != null) UnityEngine.Object.DestroyImmediate(old); + var go = new GameObject("__wl811b_probe"); + var h = go.AddComponent(); + h.StartCoroutine(make(h)); + } + + public class Host : MonoBehaviour + { + StringBuilder _sb; float _t0; + string Pre() { return string.Format("{0,7:F0} | f{1,-5} | ts={2:F2} | ", (Time.unscaledTime - _t0) * 1000f, Time.frameCount, Time.timeScale); } + static string N(UnityEngine.Object o) { return o != null ? o.name : "null"; } + + // ── 구독자(메서드 그룹 · 종료 시 해제) + void OnAttack(in AttackStartedEvent e) { _sb.AppendLine(Pre() + "AttackStarted actor=" + N(e.actor) + " idx=" + e.attackIndex + " speed=" + e.animSpeed.ToString("F2") + " target=" + N(e.target)); } + void OnCombo(in ComboStageEvent e) { _sb.AppendLine(Pre() + "ComboStage stage=" + e.stage + " clip=" + e.clip + " nt=" + e.normalizedTime.ToString("F3") + " last=" + e.isLast); } + void OnSwing(in SwingEffectEvent e) { _sb.AppendLine(Pre() + "SwingEffect effect=" + e.effect + " int=" + e.intParam + " handled=" + e.handled); } + void OnHitbox(in HitboxSpawnedEvent e) { _sb.AppendLine(Pre() + "HitboxSpawned prefab=" + e.prefab + " addDmg=" + e.addDmg.ToString("F2") + " life=" + e.lifetime.ToString("F2")); } + void OnHitboxHit(in HitboxHitEvent e) { _sb.AppendLine(Pre() + "HitboxHit proj=" + N(e.projectile) + " victim=" + N(e.victim) + " noDmg=" + e.noDmg + " skill=" + e.isSkill); } + void OnHitConfirmed(in HitConfirmedEvent e) { _sb.AppendLine(Pre() + "HitConfirmed victim=" + N(e.victim) + " dmg=" + e.damage.ToString("F0") + " cri=" + e.critical + " kill=" + e.isKill + " byPC=" + e.byMainPC + " feelFired=" + WL.Feel.WLHitFeel.FiredCount); } + void OnKilled(in KilledEvent e) { _sb.AppendLine(Pre() + "Killed victim=" + N(e.victim) + " killer=" + N(e.killer) + " sub=" + e.subRole + " id=" + e.id + " direct=" + e.byDirectHit); } + void OnDash(in DashEvent e) { _sb.AppendLine(Pre() + (e.began ? "DashBegan " : "DashEnded ") + "dist=" + e.distance.ToString("F2") + " attack=" + e.endedInAttack); } + void OnSkillCast(in SkillCastEvent e) { _sb.AppendLine(Pre() + "SkillCast skill=" + (e.skill != null ? e.skill.n_SkillID.ToString() : "null") + " slot=" + e.slot); } + void OnSkillFired(in SkillFiredEvent e) { _sb.AppendLine(Pre() + "SkillFired param=" + e.eventParam); } + void OnDamaged(in DamagedEvent e) { _sb.AppendLine(Pre() + "Damaged(PC) from=" + N(e.attacker) + " dmg=" + e.damage.ToString("F0") + " invincible=" + e.invincible); } + void OnSpawned(in SpawnedEvent e) { _sb.AppendLine(Pre() + "Spawned actor=" + N(e.actor) + " boss=" + e.isBoss + " elite=" + e.isElite); } + void OnLevelUp(in LevelUpEvent e) { _sb.AppendLine(Pre() + "LevelUp lv=" + e.newLevel); } + + void Subscribe(bool on) + { + if (on) + { + CombatEvents.AttackStarted.Add(OnAttack); CombatEvents.ComboStage.Add(OnCombo); CombatEvents.SwingEffect.Add(OnSwing); + CombatEvents.HitboxSpawned.Add(OnHitbox); CombatEvents.HitboxHit.Add(OnHitboxHit); CombatEvents.HitConfirmed.Add(OnHitConfirmed); + CombatEvents.Killed.Add(OnKilled); CombatEvents.Dash.Add(OnDash); CombatEvents.SkillCast.Add(OnSkillCast); + CombatEvents.SkillFired.Add(OnSkillFired); CombatEvents.Damaged.Add(OnDamaged); CombatEvents.Spawned.Add(OnSpawned); CombatEvents.LevelUp.Add(OnLevelUp); + } + else + { + CombatEvents.AttackStarted.Remove(OnAttack); CombatEvents.ComboStage.Remove(OnCombo); CombatEvents.SwingEffect.Remove(OnSwing); + CombatEvents.HitboxSpawned.Remove(OnHitbox); CombatEvents.HitboxHit.Remove(OnHitboxHit); CombatEvents.HitConfirmed.Remove(OnHitConfirmed); + CombatEvents.Killed.Remove(OnKilled); CombatEvents.Dash.Remove(OnDash); CombatEvents.SkillCast.Remove(OnSkillCast); + CombatEvents.SkillFired.Remove(OnSkillFired); CombatEvents.Damaged.Remove(OnDamaged); CombatEvents.Spawned.Remove(OnSpawned); CombatEvents.LevelUp.Remove(OnLevelUp); + } + } + + /// ⓒ 이벤트 순서·타이밍(자동 교전을 그대로 관찰 · 조작 없음). + public IEnumerator Co_Watch(float seconds) + { + _sb = new StringBuilder(); _t0 = Time.unscaledTime; + _sb.AppendLine("# WL811b Watch " + DateTime.Now.ToString("HH:mm:ss") + " " + seconds.ToString("F1") + " s · Enabled=" + WLCombatCoreSettings.Enabled + " arbiter=" + TimeScaleArbiter.Enabled + " coreSubscribed=" + WL.Feel.WLHitFeel.CoreSubscribed + " pc=" + N(MyValue.MyPC)); + _sb.AppendLine("# 열: t(ms unscaled) | frame | timeScale | 이벤트 페이로드"); + int fired0 = WL.Feel.WLHitFeel.FiredCount, hit0 = WL.Feel.WLHitFeel.HitCount, applied0 = TimeScaleArbiter.AppliedCount; + float lastScale = Time.timeScale; + Subscribe(true); + while (Time.unscaledTime - _t0 < seconds) + { + yield return null; + if (!Mathf.Approximately(Time.timeScale, lastScale)) { _sb.AppendLine(Pre() + "timeScale " + lastScale.ToString("F2") + " → " + Time.timeScale.ToString("F2") + " (arbiter " + TimeScaleArbiter.Active + ")"); lastScale = Time.timeScale; } + } + Subscribe(false); + _sb.AppendLine("# 요약: TotalRaised=" + CombatEvents.TotalRaised + " feel hit+" + (WL.Feel.WLHitFeel.HitCount - hit0) + " fired+" + (WL.Feel.WLHitFeel.FiredCount - fired0) + " arbiterApplied+" + (TimeScaleArbiter.AppliedCount - applied0) + + " lastHitStop=" + WL.Feel.WLHitFeel.LastHitStopMeasured.ToString("F3") + " s · timeScale=" + Time.timeScale.ToString("F2")); + Save("watch", _sb); Destroy(gameObject); + } + + /// ⓔ 중재자: 외부 일시정지 스킵 · 상위 층 스킵 · 스로틀 · 복원. timeScale 은 끝에 1 로 되돌린다. + public IEnumerator Co_Arbiter() + { + _sb = new StringBuilder(); _t0 = Time.unscaledTime; + _sb.AppendLine("# WL811b Arbiter " + DateTime.Now.ToString("HH:mm:ss") + " Enabled=" + TimeScaleArbiter.Enabled); + var feel = WL.Feel.WLHitFeel.Settings; + float hs = feel != null ? feel.hitStopDuration : 0.04f; + float hsScale = feel != null ? feel.hitStopTimeScale : 0f; + TimeScaleArbiter.ResetDiagnostics(); + float saved = Time.timeScale; + + // ① 외부 일시정지(0) 중 히트스톱 요청 → SkippedPause + Time.timeScale = 0f; yield return null; + var r1 = TimeScaleArbiter.Request(TimeLayer.HitStop, hsScale, hs); + _sb.AppendLine(Pre() + "① pause(0) 중 HitStop 요청 → " + r1 + " (" + TimeScaleArbiter.LastInfo + ") 기대=SkippedPause"); + Time.timeScale = 1f; yield return null; + + // ② Cinematic 활성 중 HitStop → SkippedHigher · Cinematic 종료 후 1 복원 + var r2a = TimeScaleArbiter.Request(TimeLayer.Cinematic, WLCombatCoreSettings.Instance.cinematicDefaultScale, 0.3f); + yield return null; + var r2b = TimeScaleArbiter.Request(TimeLayer.HitStop, hsScale, hs); + _sb.AppendLine(Pre() + "② Cinematic → " + r2a + " · 그 중 HitStop → " + r2b + " 기대=SkippedHigher · timeScale=" + Time.timeScale.ToString("F2")); + float w0 = Time.unscaledTime; while (Time.unscaledTime - w0 < 0.5f) yield return null; + _sb.AppendLine(Pre() + " 0.5 s 후 timeScale=" + Time.timeScale.ToString("F2") + " (기대 1.00) measured=" + TimeScaleArbiter.LastMeasured.ToString("F3") + " active=" + TimeScaleArbiter.Active); + + // ③ HitStop 활성 중 Cinematic → 선점(Preempted) · 종료 후 복원 + var r3a = TimeScaleArbiter.Request(TimeLayer.HitStop, hsScale, 0.3f); + yield return null; + var r3b = TimeScaleArbiter.Request(TimeLayer.Cinematic, 0.5f, 0.2f); + _sb.AppendLine(Pre() + "③ HitStop → " + r3a + " · 그 중 Cinematic → " + r3b + " 기대=Applied(preempt) preempted=" + TimeScaleArbiter.PreemptedCount + " timeScale=" + Time.timeScale.ToString("F2")); + w0 = Time.unscaledTime; while (Time.unscaledTime - w0 < 0.4f) yield return null; + _sb.AppendLine(Pre() + " 0.4 s 후 timeScale=" + Time.timeScale.ToString("F2") + " (기대 1.00) active=" + TimeScaleArbiter.Active); + + // ④ 스로틀: 0.5 s 안에 HitStop 5회 요청 → Applied 1 · Throttled/Ignored 나머지 (1.5/s → 최소 간격 0.667 s) + int applied0 = TimeScaleArbiter.AppliedCount, thr0 = TimeScaleArbiter.ThrottledCount, ign0 = TimeScaleArbiter.IgnoredCount; + w0 = Time.unscaledTime; + for (int i = 0; i < 5; i++) { TimeScaleArbiter.Request(TimeLayer.HitStop, hsScale, 0.03f); float t = Time.unscaledTime; while (Time.unscaledTime - t < 0.1f) yield return null; } + _sb.AppendLine(Pre() + "④ 0.5 s 안 HitStop 5회 → applied+" + (TimeScaleArbiter.AppliedCount - applied0) + " throttled+" + (TimeScaleArbiter.ThrottledCount - thr0) + " ignored+" + (TimeScaleArbiter.IgnoredCount - ign0) + " 기대 applied 1 · 초당 ≤ 1.5"); + w0 = Time.unscaledTime; while (Time.unscaledTime - w0 < 0.3f) yield return null; + + // ⑤ 실측 길이: HitStop 1회 → measured ≈ hitStopDuration + w0 = Time.unscaledTime; while (Time.unscaledTime - w0 < 0.7f) yield return null; // 스로틀 간격 확보 + var r5 = TimeScaleArbiter.Request(TimeLayer.HitStop, hsScale, hs); + w0 = Time.unscaledTime; while (Time.unscaledTime - w0 < hs + 0.1f) yield return null; + _sb.AppendLine(Pre() + "⑤ HitStop " + hs.ToString("F3") + " s → " + r5 + " measured=" + TimeScaleArbiter.LastMeasured.ToString("F3") + " (기대 " + hs.ToString("F3") + " ±1프레임) feel.LastHitStopMeasured=" + WL.Feel.WLHitFeel.LastHitStopMeasured.ToString("F3") + " timeScale=" + Time.timeScale.ToString("F2")); + + TimeScaleArbiter.ReleaseAll(); + Time.timeScale = saved > 0f ? saved : 1f; + _sb.AppendLine("# 종료 timeScale=" + Time.timeScale.ToString("F2") + " active=" + TimeScaleArbiter.Active); + Save("arbiter", _sb); Destroy(gameObject); + } + + /// ⓕ 코어 런타임 스위치: off 동안 디스패치 0 · 타격감은 기존 경로로 계속 → 끝에 원복. + public IEnumerator Co_Toggle(bool on, float seconds) + { + _sb = new StringBuilder(); _t0 = Time.unscaledTime; + bool prev = WLCombatCoreSettings.RuntimeDisabled; + WLCombatCoreSettings.RuntimeDisabled = !on; + int raised0 = CombatEvents.TotalRaised, fired0 = WL.Feel.WLHitFeel.FiredCount, hit0 = WL.Feel.WLHitFeel.HitCount; + float minScale = 1f; + _sb.AppendLine("# WL811b Toggle on=" + on + " " + seconds.ToString("F1") + " s · Enabled=" + WLCombatCoreSettings.Enabled + " arbiter=" + TimeScaleArbiter.Enabled); + while (Time.unscaledTime - _t0 < seconds) { yield return null; minScale = Mathf.Min(minScale, Time.timeScale); } + _sb.AppendLine("결과: TotalRaised+" + (CombatEvents.TotalRaised - raised0) + " (기대 " + (on ? ">0" : "0") + ") · feel hit+" + (WL.Feel.WLHitFeel.HitCount - hit0) + " fired+" + (WL.Feel.WLHitFeel.FiredCount - fired0) + + " · 최소 timeScale=" + minScale.ToString("F2") + "(히트스톱 발생 여부) · lastHitStop=" + WL.Feel.WLHitFeel.LastHitStopMeasured.ToString("F3")); + WLCombatCoreSettings.RuntimeDisabled = prev; + _sb.AppendLine("# 원복 RuntimeDisabled=" + WLCombatCoreSettings.RuntimeDisabled + " Enabled=" + WLCombatCoreSettings.Enabled); + Save("toggle", _sb); Destroy(gameObject); + } + } +} diff --git a/Assets/Script/Character/Actor.cs b/Assets/Script/Character/Actor.cs index 2a2f609ee..d37acc896 100644 --- a/Assets/Script/Character/Actor.cs +++ b/Assets/Script/Character/Actor.cs @@ -532,7 +532,7 @@ public class Actor : MyCoroutine //} // WL #797 — 타격 임팩트(히트스톱·카메라 셰이크·몹 스케일 펀치). OnHit 이 false 를 반환하면 // (설정 에셋 없음 · enabled=false) 기존 ShakeCamera(1) 경로가 그대로 실행된다 = 100% 롤백. - if (_dinfo.Beater.IsMainPC() && !WL.Feel.WLHitFeel.OnHit(this, _dinfo)) MyValue.m_RealCamera.ShakeCamera(1); + if (!WL.Combat.Core.CombatEvents.RaiseHitConfirmed(this, _dinfo)) MyValue.m_RealCamera.ShakeCamera(1); #if UNITY_EDITOR if (SceneManager.GetActiveScene().name == "StatTest") @@ -1954,6 +1954,7 @@ public class Actor : MyCoroutine m_animation.speed -= m_animation.speed * (float)dic_ccData[eCC.ATKSPD_Down].CCDmg; AnimationPlay(aniname); actsuccess?.Invoke(); + WL.Combat.Core.CombatEvents.RaiseAttackStarted(this, _attack, m_animation.speed, m_Target); if (isTarget) transform.LookAt(m_Target.transform); if (gos_Effect != null && gos_Effect.Length > _attack) @@ -1967,6 +1968,7 @@ public class Actor : MyCoroutine { m_animation.speed = 1f; CurAnim = eAnim.Skill; + WL.Combat.Core.CombatEvents.RaiseSkillCast(this, m_Magic); AnimationPlay(IsRole(eRole.Pet) ? "casting" : motion); if (isTarget) transform.LookAt(m_Target.transform); @@ -2337,6 +2339,7 @@ public class Actor : MyCoroutine } protected void Shoot_Projectile(string proj, float adddmg, float lifetime, Vector3 startpos) { + WL.Combat.Core.CombatEvents.RaiseHitboxSpawned(this, proj, adddmg, lifetime, startpos); if (ProjectileInfo.isIns) { if (adddmg <= 0f) adddmg = 1f; @@ -2367,6 +2370,7 @@ public class Actor : MyCoroutine #if UNITY_EDITOR if (SettingsMain.Ins) SettingsMain.Ins.Add_Event(System.Reflection.MethodBase.GetCurrentMethod().Name, 1, a); #endif + WL.Combat.Core.CombatEvents.RaiseSkillFired(this, a, m_Magic); Shoot_Skill(); } public virtual void FootR(string s) diff --git a/Assets/Script/Character/Mob/MobActor.cs b/Assets/Script/Character/Mob/MobActor.cs index 92a72a6ce..9a2cbfe16 100644 --- a/Assets/Script/Character/Mob/MobActor.cs +++ b/Assets/Script/Character/Mob/MobActor.cs @@ -146,6 +146,7 @@ public class MobActor : Actor { Set_Warp(OriginalPos); InGameInfo.Ins.Show_Effect("Effect_MonsterSpawn", Get_position()); + WL.Combat.Core.CombatEvents.RaiseSpawned(this); Reset_Extra(); list_summon.ForEach(f => f.Off()); } @@ -431,6 +432,7 @@ public class MobActor : Actor protected override void Set_Die() { + WL.Combat.Core.CombatEvents.RaiseKilled(this); #if UNITY_EDITOR if (SceneManager.GetActiveScene().name == "StatTest") return; diff --git a/Assets/Script/Character/MyActor.cs b/Assets/Script/Character/MyActor.cs index 96ce625d5..82984f0ab 100644 --- a/Assets/Script/Character/MyActor.cs +++ b/Assets/Script/Character/MyActor.cs @@ -306,6 +306,7 @@ public class MyActor : Actor // 무적이어도 그대로 돌아야 한다 — 가드가 함수 맨 앞에서 return 하던 동안 플레이어가 공격을 시작하지 못했다. bool wlInvincible = IsRole(eRole.PC) && WL.Settings.WLGameplaySettings.PlayerInvincible; if (!wlInvincible) base.Get_Damage(_dinfo); + WL.Combat.Core.CombatEvents.RaiseDamaged(this, _dinfo, wlInvincible); if (_dinfo == null || DSUtil.CheckNull(_dinfo.Beater)) return; diff --git a/Assets/Script/Character/Projectile/ProjectileBase.cs b/Assets/Script/Character/Projectile/ProjectileBase.cs index fe9df81a4..98219e3cc 100644 --- a/Assets/Script/Character/Projectile/ProjectileBase.cs +++ b/Assets/Script/Character/Projectile/ProjectileBase.cs @@ -440,6 +440,7 @@ public class ProjectileBase : MonoBehaviour if (m_ProjecTileData.shooter.IsEnemy() != hitter.IsEnemy()) { + WL.Combat.Core.CombatEvents.RaiseHitboxHit(this, m_ProjecTileData, hitter, noDmg); if (!noDmg) hitter.Get_Damage(Get_DamageInfo()); m_ProjecTileData.Action_OnHit?.Invoke(hitter); if (Off_byHit) Off(false, false); diff --git a/Assets/WL/Combat/Core.meta b/Assets/WL/Combat/Core.meta new file mode 100644 index 000000000..195990eb7 --- /dev/null +++ b/Assets/WL/Combat/Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e890ca2827cb86f45a69d77d3130593e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Core/CombatEvents.cs b/Assets/WL/Combat/Core/CombatEvents.cs new file mode 100644 index 000000000..62792a017 --- /dev/null +++ b/Assets/WL/Combat/Core/CombatEvents.cs @@ -0,0 +1,289 @@ +// ───────────────────────────────────────────────────────────────────────────── +// CombatEvents.cs — WL 전투 액션 코어 · 전투 이벤트 파이프라인(정적 허브) +// +// PD 지시 #811 → #813 · 설계안 WL-811a §B-2 · 발주서 WL-811b §1-1 (2026-09-08) +// +// ■ 무엇을 하나 +// 원본(힘민지)·기존 WL 코드의 "전투에서 무슨 일이 일어났다"를 이벤트 12종으로 통보한다. +// 연출(킬캠·연쇄 처치·점멸·데미지 텍스트·잔상·원소 파열 = 811d~j)은 여기를 **구독**만 하고 +// 원본·코어를 다시 건드리지 않는다. +// +// ■ 발생 지점(원본 훅 9줄 · 설계안 §B-2 · D-5(a) = Actor.cs 5줄 일괄 후 동결) +// Actor.Play_Attack actsuccess 직후 ─ RaiseAttackStarted · Actor.Play_Skill CurAnim=Skill 직후 ─ RaiseSkillCast +// Actor.Shoot_Projectile 첫 줄 ─ RaiseHitboxSpawned · Actor.SkillEvent Shoot_Skill 직전 ─ RaiseSkillFired +// Actor.Get_Damage 기존 WLHitFeel 훅 ─ RaiseHitConfirmed(반환값이 원본 ShakeCamera(1) 규약을 그대로 잇는다) +// MobActor.Set_Die 첫 줄 ─ RaiseKilled · MobActor.On_Regen 스폰 이펙트 직후 ─ RaiseSpawned +// ProjectileBase.ProcessCollider Get_Damage 직전 ─ RaiseHitboxHit(처치자 귀속용 컨텍스트) +// MyActor.Get_Damage base 호출 직후 ─ RaiseDamaged(PC 피격 · 무적이어도 발생) +// WL 코드: DashDriver(Began/Ended) · WeaponTrailDriver(SwingEffect) · ComboTracker(ComboStage · 원본 훅 0) +// LevelUp 은 발생 지점이 Systems 소유(ServerClass.cs:2734)라 API 만 두고 발생 0(Lead 경유 후속). +// +// ■ 왜 정적 클래스 + 구조체 페이로드인가 (P5 · GC 0) +// 새 Manager/Singleton 을 만들지 않는다(WLHitFeel 선례). 페이로드는 struct 를 `in` 으로 넘겨 복사·박싱이 없고, +// 구독자는 사전 할당 배열을 순회한다(Add 때만 성장). 구독자 예외는 try/catch 로 격리해 원본 전투 흐름을 끊지 않는다. +// +// ■ 롤백(C8) +// WLCombatCoreSettings 에셋이 없거나 enabled=false 또는 RuntimeDisabled 면 모든 Raise 가 즉시 반환하고, +// RaiseHitConfirmed 는 원본 식 `Beater.IsMainPC() && !WLHitFeel.OnHit(...)` 을 그대로 평가한다 = 100% 기존 동작. +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using System; +using UnityEngine; + +namespace WL.Combat.Core +{ + /// 구독자 시그니처. 페이로드는 읽기 전용 참조로 받는다(복사 0). + public delegate void CombatHandler(in T e) where T : struct; + + /// 이벤트 1종의 구독자 목록. 사전 할당 배열 · Add 때만 성장 · 예외 격리. + public sealed class CombatEventList where T : struct + { + CombatHandler[] _handlers; + int _count; + + /// 구독자 수. + public int Count { get { return _count; } } + /// Raise 된 횟수(구독자 0 이어도 센다) · 구독자 호출 횟수 — 프로브가 읽는다. + public int Raised, Dispatched; + + public CombatEventList(int capacity) { _handlers = new CombatHandler[Mathf.Max(1, capacity)]; } + + public void Add(CombatHandler h) + { + if (h == null) return; + for (int i = 0; i < _count; i++) if (_handlers[i] == h) return; // 중복 구독 방지 + if (_count == _handlers.Length) Array.Resize(ref _handlers, _handlers.Length * 2); + _handlers[_count++] = h; + } + + public void Remove(CombatHandler h) + { + for (int i = 0; i < _count; i++) + if (_handlers[i] == h) { _handlers[i] = _handlers[--_count]; _handlers[_count] = null; return; } + } + + public void Clear() { Array.Clear(_handlers, 0, _handlers.Length); _count = 0; } + + internal void Dispatch(in T e) + { + Raised++; + for (int i = 0; i < _count; i++) + { + try { _handlers[i](in e); Dispatched++; } + catch (Exception ex) { Debug.LogException(ex); } // 구독자 예외는 원본 흐름을 끊지 않는다 + } + } + } + + // ───────────────────────────────────────────── 페이로드(구조체 · 값은 발생 시점 스냅샷) + public struct AttackStartedEvent { public Actor actor; public int attackIndex; public bool isMainPC; public float animSpeed; public Actor target; public float time; public int frame; } + public struct ComboStageEvent { public PCActor actor; public int stage; public string clip; public float normalizedTime; public bool isLast; public float time; public int frame; } + public struct SwingEffectEvent { public PCActor actor; public string effect; public int intParam; public float floatParam; public bool handled; public float time; public int frame; } + public struct HitboxSpawnedEvent{ public Actor shooter; public string prefab; public float addDmg; public float lifetime; public Vector3 startPos; public float time; public int frame; } + public struct HitboxHitEvent { public ProjectileBase projectile; public ProjectileData data; public Actor shooter; public Actor victim; public bool noDmg; public bool isSkill; public SkillListTableData skill; public float time; public int frame; } + public struct HitConfirmedEvent { public Actor victim; public Actor attacker; public DamageInfo dinfo; public double damage; public bool critical; public bool isKill; public bool byMainPC; public ProjectileData projectile; public float time; public int frame; } + public struct KilledEvent { public Actor victim; public Actor killer; public eSubRol subRole; public int id; public Vector3 position; public bool byDirectHit; public float time; public int frame; } + public struct DashEvent { public PCActor actor; public bool began; public float distance; public bool endedInAttack; public float time; public int frame; } + public struct SkillCastEvent { public Actor actor; public SkillListTableData skill; public int slot; public float time; public int frame; } + public struct SkillFiredEvent { public Actor actor; public int eventParam; public SkillListTableData skill; public float time; public int frame; } + public struct DamagedEvent { public Actor victim; public Actor attacker; public DamageInfo dinfo; public double damage; public bool invincible; public float time; public int frame; } + public struct SpawnedEvent { public Actor actor; public bool isBoss; public bool isElite; public Vector3 position; public float time; public int frame; } + public struct LevelUpEvent { public Actor pc; public int newLevel; public float time; public int frame; } + + /// 전투 이벤트 허브. 정적 · GC 0 · 구독은 각 이벤트 목록의 Add/Remove. + public static class CombatEvents + { + public static readonly CombatEventList AttackStarted = new CombatEventList(8); + public static readonly CombatEventList ComboStage = new CombatEventList(8); + public static readonly CombatEventList SwingEffect = new CombatEventList(8); + public static readonly CombatEventList HitboxSpawned = new CombatEventList(8); + public static readonly CombatEventList HitboxHit = new CombatEventList(8); + public static readonly CombatEventList HitConfirmed = new CombatEventList(8); + public static readonly CombatEventList Killed = new CombatEventList(8); + public static readonly CombatEventList Dash = new CombatEventList(8); + public static readonly CombatEventList SkillCast = new CombatEventList(8); + public static readonly CombatEventList SkillFired = new CombatEventList(8); + public static readonly CombatEventList Damaged = new CombatEventList(8); + public static readonly CombatEventList Spawned = new CombatEventList(8); + public static readonly CombatEventList LevelUp = new CombatEventList(4); + + /// 코어 활성 여부 = WLCombatCoreSettings.Enabled(에셋 · enabled · 런타임 스위치). + public static bool Enabled { get { return WLCombatCoreSettings.Enabled; } } + + // ── 진단(프로브가 읽는다) + public static int TotalRaised; + public static string LastEvent = ""; + public static float LastEventTime; + + // ── 처치자 귀속 컨텍스트: HitboxHit(피해 적용 직전) → 같은 호출 스택의 Set_Die(Killed) 에서 소비 + static Actor s_pendingHitShooter, s_pendingHitVictim; + static int s_pendingHitFrame = -1; + + static void Stamp(string name) { TotalRaised++; LastEvent = name; LastEventTime = Time.unscaledTime; } + + static void Log(string msg) + { + var c = WLCombatCoreSettings.Instance; + if (c != null && c.verboseLog) Debug.Log("[CombatEvents] " + msg); + } + + // ───────────────────────────────────────── Raise (원본 훅이 부른다 · 1줄) + + /// Actor.Play_Attack — actsuccess 직후. 메인 PC 면 ComboTracker 를 보장한다. + public static void RaiseAttackStarted(Actor actor, int attackIndex, float animSpeed, Actor target) + { + if (!Enabled || actor == null) return; + bool mainPC = actor.IsMainPC(); + if (mainPC && WLCombatCoreSettings.Instance.comboTrackerEnabled) ComboTracker.Ensure(actor as PCActor); + var e = new AttackStartedEvent { actor = actor, attackIndex = attackIndex, isMainPC = mainPC, animSpeed = animSpeed, target = target, time = Time.unscaledTime, frame = Time.frameCount }; + Stamp("AttackStarted"); AttackStarted.Dispatch(in e); + } + + /// ComboTracker — Animator 가 콤보 단계 상태에 들어온 프레임. + public static void RaiseComboStage(PCActor actor, int stage, string clip, float normalizedTime, bool isLast) + { + if (!Enabled || actor == null) return; + var e = new ComboStageEvent { actor = actor, stage = stage, clip = clip, normalizedTime = normalizedTime, isLast = isLast, time = Time.unscaledTime, frame = Time.frameCount }; + Stamp("ComboStage"); ComboStage.Dispatch(in e); + } + + /// WeaponTrailDriver.TryHandleShowEffect — 근접 PC 의 ShowEffect 클립 이벤트(검기 창 시작). + public static void RaiseSwingEffect(PCActor actor, AnimationEvent ev, bool handled) + { + if (!Enabled || actor == null) return; + var e = new SwingEffectEvent { actor = actor, effect = ev != null ? ev.stringParameter : null, intParam = ev != null ? ev.intParameter : 0, floatParam = ev != null ? ev.floatParameter : 0f, handled = handled, time = Time.unscaledTime, frame = Time.frameCount }; + Stamp("SwingEffect"); SwingEffect.Dispatch(in e); + } + + /// Actor.Shoot_Projectile 첫 줄 — 클립 Projectile 이벤트가 히트박스(투사체)를 쏘기 직전. + public static void RaiseHitboxSpawned(Actor shooter, string prefab, float addDmg, float lifetime, Vector3 startPos) + { + if (!Enabled) return; + var e = new HitboxSpawnedEvent { shooter = shooter, prefab = prefab, addDmg = addDmg, lifetime = lifetime, startPos = startPos, time = Time.unscaledTime, frame = Time.frameCount }; + Stamp("HitboxSpawned"); HitboxSpawned.Dispatch(in e); + } + + /// ProjectileBase.ProcessCollider — 진영 판정 통과 · Get_Damage 직전. 처치자 귀속 컨텍스트를 남긴다. + public static void RaiseHitboxHit(ProjectileBase projectile, ProjectileData data, Actor victim, bool noDmg) + { + if (!Enabled) return; + Actor shooter = data != null ? data.shooter : null; + s_pendingHitShooter = shooter; s_pendingHitVictim = victim; s_pendingHitFrame = Time.frameCount; + var e = new HitboxHitEvent { projectile = projectile, data = data, shooter = shooter, victim = victim, noDmg = noDmg, isSkill = data != null && data.m_SkillListTableData != null, skill = data != null ? data.m_SkillListTableData : null, time = Time.unscaledTime, frame = Time.frameCount }; + Stamp("HitboxHit"); HitboxHit.Dispatch(in e); + } + + /// + /// Actor.Get_Damage — Cal_Damage · CC · Extra 뒤(최종 피해·크리·킬 확정). 기존 WLHitFeel 훅 자리. + /// 반환 = true 면 호출측이 원본 ShakeCamera(1) 을 부르지 않는다(기존 규약 = `!(Beater.IsMainPC() && !OnHit)`). + /// WLHitFeel 은 HitConfirmed 의 구독자로 돌며 LastCoreHandled 에 OnHit 반환값을 남긴다. + /// + public static bool RaiseHitConfirmed(Actor victim, DamageInfo dinfo) + { + bool beaterMainPC = dinfo != null && dinfo.Beater != null && dinfo.Beater.IsMainPC(); + if (!Enabled) + return !(beaterMainPC && !WL.Feel.WLHitFeel.OnHit(victim, dinfo)); // C8: 원본 식 그대로 + + 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, + byMainPC = beaterMainPC, projectile = dinfo != null ? dinfo.Beater_pd : null, + time = Time.unscaledTime, frame = Time.frameCount + }; + Stamp("HitConfirmed"); HitConfirmed.Dispatch(in e); + if (!WL.Feel.WLHitFeel.CoreSubscribed) // 구독 등록 전(도메인 리로드 직후 등) 안전망 — 타격감을 잃지 않는다 + WL.Feel.WLHitFeel.LastCoreHandled = WL.Feel.WLHitFeel.OnHit(victim, dinfo); + if (s_pendingHitVictim == victim) { s_pendingHitVictim = null; s_pendingHitShooter = null; } + return !(beaterMainPC && !WL.Feel.WLHitFeel.LastCoreHandled); + } + + /// MobActor.Set_Die 첫 줄 — 몹 사망 확정. 처치자는 같은 프레임의 HitboxHit 컨텍스트에서 귀속(없으면 null · 도트 등). + public static void RaiseKilled(Actor victim) + { + if (!Enabled || victim == null) return; + bool direct = s_pendingHitVictim == victim && s_pendingHitFrame == Time.frameCount; + var e = new KilledEvent + { + victim = victim, killer = direct ? s_pendingHitShooter : null, + subRole = victim.m_SubRole, id = victim.Get_ID(), position = victim.Get_position(), + 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 : "?")); + } + + /// DashDriver — 대시 시작. + public static void RaiseDashBegan(PCActor actor, float distance) + { + if (!Enabled) return; + var e = new DashEvent { actor = actor, began = true, distance = distance, endedInAttack = false, time = Time.unscaledTime, frame = Time.frameCount }; + Stamp("DashBegan"); Dash.Dispatch(in e); + } + + /// DashDriver — 대시 종료(도착 즉시 공격 여부 포함). + public static void RaiseDashEnded(PCActor actor, float movedDistance, bool endedInAttack) + { + if (!Enabled) return; + var e = new DashEvent { actor = actor, began = false, distance = movedDistance, endedInAttack = endedInAttack, time = Time.unscaledTime, frame = Time.frameCount }; + Stamp("DashEnded"); Dash.Dispatch(in e); + } + + /// Actor.Play_Skill — CurAnim = Skill 직후. magic 은 시전 중 스킬(null 가능). + public static void RaiseSkillCast(Actor actor, UseMagicInfo magic) + { + if (!Enabled || actor == null) return; + var e = new SkillCastEvent { actor = actor, skill = magic != null ? magic.m_SkillTableData : null, slot = magic != null ? magic.m_uiIndex : -1, time = Time.unscaledTime, frame = Time.frameCount }; + Stamp("SkillCast"); SkillCast.Dispatch(in e); + } + + /// Actor.SkillEvent — 클립 SkillEvent 이벤트(실체 발사) 직전. + public static void RaiseSkillFired(Actor actor, int eventParam, UseMagicInfo magic) + { + if (!Enabled || actor == null) return; + var e = new SkillFiredEvent { actor = actor, eventParam = eventParam, skill = magic != null ? magic.m_SkillTableData : null, time = Time.unscaledTime, frame = Time.frameCount }; + Stamp("SkillFired"); SkillFired.Dispatch(in e); + } + + /// MyActor.Get_Damage — PC 피격(무적이면 피해 0 으로 통과 · 이벤트는 발생). + public static void RaiseDamaged(Actor victim, DamageInfo dinfo, bool invincible) + { + if (!Enabled || victim == null) return; + var e = new DamagedEvent { victim = victim, attacker = dinfo != null ? dinfo.Beater : null, dinfo = dinfo, damage = dinfo != null && !invincible ? dinfo.Damage : 0d, invincible = invincible, time = Time.unscaledTime, frame = Time.frameCount }; + Stamp("Damaged"); Damaged.Dispatch(in e); + } + + /// MobActor.On_Regen — 스폰 이펙트 직후(부활 제외). + public static void RaiseSpawned(Actor actor) + { + if (!Enabled || actor == null) return; + var e = new SpawnedEvent { actor = actor, isBoss = actor.IsSubRole(eSubRol.Boss), isElite = actor.IsSubRole(eSubRol.Elite), position = actor.Get_position(), time = Time.unscaledTime, frame = Time.frameCount }; + Stamp("Spawned"); Spawned.Dispatch(in e); + } + + /// 레벨업 — 발생 지점(ServerClass.Add_Exp · Systems 소유)은 후속 웨이브. 지금은 API 만. + public static void RaiseLevelUp(Actor pc, int newLevel) + { + if (!Enabled) return; + var e = new LevelUpEvent { pc = pc, newLevel = newLevel, time = Time.unscaledTime, frame = Time.frameCount }; + Stamp("LevelUp"); LevelUp.Dispatch(in e); + } + + /// 프로브용: 카운터 초기화(구독은 유지). + public static void ResetDiagnostics() + { + TotalRaised = 0; LastEvent = ""; LastEventTime = 0f; + 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; + Killed.Raised = Killed.Dispatched = 0; Dash.Raised = Dash.Dispatched = 0; + SkillCast.Raised = SkillCast.Dispatched = 0; SkillFired.Raised = SkillFired.Dispatched = 0; + Damaged.Raised = Damaged.Dispatched = 0; Spawned.Raised = Spawned.Dispatched = 0; LevelUp.Raised = LevelUp.Dispatched = 0; + } + } +} diff --git a/Assets/WL/Combat/Core/CombatEvents.cs.meta b/Assets/WL/Combat/Core/CombatEvents.cs.meta new file mode 100644 index 000000000..6122d363f --- /dev/null +++ b/Assets/WL/Combat/Core/CombatEvents.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3a174c43a0d624e42b0167392166cae5 \ No newline at end of file diff --git a/Assets/WL/Combat/Core/ComboTracker.cs b/Assets/WL/Combat/Core/ComboTracker.cs new file mode 100644 index 000000000..d2884bba3 --- /dev/null +++ b/Assets/WL/Combat/Core/ComboTracker.cs @@ -0,0 +1,93 @@ +// ───────────────────────────────────────────────────────────────────────────── +// ComboTracker.cs — 콤보 단계 추적(PC 1체 · Animator 상태 폴링 · 원본 훅 0) +// +// 발주서 WL-811b §1-2 · 설계안 §A 4행: 2·3타는 코드가 아니라 컨트롤러 전이(attack → Attack2 → Attack3)가 쥔다. +// 그래서 콤보 단계는 Animator 상태 변화로만 알 수 있다. CombatEvents.RaiseAttackStarted 가 메인 PC 에 이 컴포넌트를 +// 런타임 자동 부착하고(AttackStepDriver 선례 · 프리팹 무수정), LateUpdate 마다 상태 해시가 바뀐 프레임에 단계를 낸다. +// +// · 전이 중에는 GetNextAnimatorStateInfo(목적 상태)를 본다 — 크로스페이드 0.15 만큼 늦게 잡히는 것을 막는다. +// · 클립 이름 → 단계 해석은 상태 해시당 1회만(문자열 alloc 은 새 상태를 처음 볼 때뿐) · 이후는 Dictionary 조회. +// · 단계 규칙(데이터 = WLCombatCoreSettings): comboClipToken 뒤의 첫 숫자 = 단계(없으면 1) · stage >= comboStages → isLast. +// ───────────────────────────────────────────────────────────────────────────── + +using System.Collections.Generic; +using UnityEngine; + +namespace WL.Combat.Core +{ + [DisallowMultipleComponent] + public sealed class ComboTracker : MonoBehaviour + { + PCActor _pc; + Animator _anim; + int _lastHash; + int _lastStage; + readonly Dictionary _stageByHash = new Dictionary(16); + readonly Dictionary _clipByHash = new Dictionary(16); + + // ── 진단 + public static int Instances; + public static string LastInfo = ""; + public int LastStage { get { return _lastStage; } } + + /// 메인 PC 에 1개만 붙인다(이미 있으면 재사용). 모델 교체 등으로 Animator 가 바뀌어도 매번 갱신한다. + public static void Ensure(PCActor pc) + { + if (pc == null) return; + var t = pc.GetComponent(); + if (t == null) { t = pc.gameObject.AddComponent(); Instances++; } + t._pc = pc; + t._anim = pc.m_animation; + } + + void LateUpdate() + { + if (_pc == null || _anim == null || !WLCombatCoreSettings.Enabled) return; + if (!_anim.isActiveAndEnabled || _anim.runtimeAnimatorController == null) return; + + bool inTransition = _anim.IsInTransition(0); + var st = inTransition ? _anim.GetNextAnimatorStateInfo(0) : _anim.GetCurrentAnimatorStateInfo(0); + int hash = st.fullPathHash; + if (hash == _lastHash) return; + _lastHash = hash; + + int stage; string clip; + if (!_stageByHash.TryGetValue(hash, out stage)) + { + clip = ResolveClipName(inTransition); + stage = ParseStage(clip, WLCombatCoreSettings.Instance.comboClipToken); + _stageByHash[hash] = stage; + _clipByHash[hash] = clip; + } + else clip = _clipByHash[hash]; + + if (stage <= 0) { _lastStage = 0; return; } // 공격 상태가 아니다(Idle/Run/Skill/dash …) + + _lastStage = stage; + bool isLast = stage >= WLCombatCoreSettings.Instance.comboStages; + LastInfo = clip + " stage=" + stage; // 진단용(문자열 결합 · verbose 와 무관하게 1회/단계 · 프로브가 읽는다) + CombatEvents.RaiseComboStage(_pc, stage, clip, st.normalizedTime % 1f, isLast); + } + + string ResolveClipName(bool inTransition) + { + var infos = inTransition ? _anim.GetNextAnimatorClipInfo(0) : _anim.GetCurrentAnimatorClipInfo(0); + return infos != null && infos.Length > 0 && infos[0].clip != null ? infos[0].clip.name : string.Empty; + } + + /// 토큰 뒤의 첫 숫자 = 단계. 토큰이 없으면 0(공격 아님) · 토큰은 있는데 숫자가 없으면 1. + public static int ParseStage(string clip, string token) + { + if (string.IsNullOrEmpty(clip) || string.IsNullOrEmpty(token)) return 0; + int idx = clip.IndexOf(token, System.StringComparison.OrdinalIgnoreCase); + if (idx < 0) return 0; + for (int i = idx + token.Length; i < clip.Length; i++) + { + char c = clip[i]; + if (c >= '0' && c <= '9') return c - '0'; + if (c == '_' || c == ' ' || c == '@' || c == '-') return 1; // Attack_S · attack 같은 무번호 상태 = 1타 + } + return 1; + } + } +} diff --git a/Assets/WL/Combat/Core/ComboTracker.cs.meta b/Assets/WL/Combat/Core/ComboTracker.cs.meta new file mode 100644 index 000000000..5ed70a079 --- /dev/null +++ b/Assets/WL/Combat/Core/ComboTracker.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3ac94be55ab8c404d9529fd2f47c7a78 \ No newline at end of file diff --git a/Assets/WL/Combat/Core/EffectBudget.cs b/Assets/WL/Combat/Core/EffectBudget.cs new file mode 100644 index 000000000..850849fcb --- /dev/null +++ b/Assets/WL/Combat/Core/EffectBudget.cs @@ -0,0 +1,71 @@ +// ───────────────────────────────────────────────────────────────────────────── +// EffectBudget.cs — 동시 이펙트 상한 카운터(모바일 세로 화면 과밀 방지 · 설계안 §B-5-2) +// +// 발주서 WL-811b §1-4. 등록/해제/허용 판정 API 만 둔다 — 소비자(스킬 스펙터클 · 잔상 · 디졸브 · 군집 폭발)는 후속 웨이브. +// · 상한값은 전부 WLCombatCoreSettings 에셋(Heavy 2 · 잔상 4 · 디졸브 6 · 군집 폭발 1) · 초과 = 스폰 스킵(큐잉 없음) +// · 코어 비활성 = 제한 없음(TryAcquire 가 세지 않고 true) → 원본 동작 +// · IsInView = 카메라 뷰포트 기준 화면 밖 스킵 판정 헬퍼(alloc 0) +// ───────────────────────────────────────────────────────────────────────────── + +using System; +using UnityEngine; + +namespace WL.Combat.Core +{ + public enum EffectBudgetKind { Heavy = 0, AfterImage = 1, Dissolve = 2, ClusterBurst = 3 } + + public static class EffectBudget + { + static readonly int[] s_count = new int[4]; + + // ── 진단 + public static int AcquiredCount, DeniedCount; + + public static int Limit(EffectBudgetKind kind) + { + var c = WLCombatCoreSettings.Instance; + if (c == null) return int.MaxValue; + switch (kind) + { + case EffectBudgetKind.Heavy: return c.heavyEffectMax; + case EffectBudgetKind.AfterImage: return c.afterImageMax; + case EffectBudgetKind.Dissolve: return c.dissolveMax; + case EffectBudgetKind.ClusterBurst: return c.clusterBurstMax; + default: return int.MaxValue; + } + } + + public static int Count(EffectBudgetKind kind) { return s_count[(int)kind]; } + + /// 슬롯을 하나 잡는다. 상한이면 false(스킵). 코어 비활성이면 항상 true(세지 않음). + public static bool TryAcquire(EffectBudgetKind kind) + { + if (!WLCombatCoreSettings.Enabled) return true; + int i = (int)kind; + if (s_count[i] >= Limit(kind)) { DeniedCount++; return false; } + s_count[i]++; AcquiredCount++; + return true; + } + + /// 슬롯을 돌려준다(이펙트 종료·풀 반납 시). 0 아래로 내려가지 않는다. + public static void Release(EffectBudgetKind kind) + { + int i = (int)kind; + if (s_count[i] > 0) s_count[i]--; + } + + /// 씬 전환 등에서 전부 비운다. + public static void ResetAll() { Array.Clear(s_count, 0, s_count.Length); } + + /// 월드 위치가 메인 카메라 화면 안(여유 viewMargin 포함)인가. 카메라가 없으면 true. + public static bool IsInView(Vector3 worldPos) + { + var cam = Camera.main; + if (cam == null) return true; + var v = cam.WorldToViewportPoint(worldPos); + var c = WLCombatCoreSettings.Instance; + float m = c != null ? c.viewMargin : 0f; + return v.z > 0f && v.x >= -m && v.x <= 1f + m && v.y >= -m && v.y <= 1f + m; + } + } +} diff --git a/Assets/WL/Combat/Core/EffectBudget.cs.meta b/Assets/WL/Combat/Core/EffectBudget.cs.meta new file mode 100644 index 000000000..f8f142fa8 --- /dev/null +++ b/Assets/WL/Combat/Core/EffectBudget.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 21627c2bc807c8b418c8f8e6fade247f \ No newline at end of file diff --git a/Assets/WL/Combat/Core/TimeScaleArbiter.cs b/Assets/WL/Combat/Core/TimeScaleArbiter.cs new file mode 100644 index 000000000..18476960d --- /dev/null +++ b/Assets/WL/Combat/Core/TimeScaleArbiter.cs @@ -0,0 +1,163 @@ +// ───────────────────────────────────────────────────────────────────────────── +// TimeScaleArbiter.cs — 감속 중재자(우선순위 Pause > Cinematic > HitStop · D-1(a) 확정) +// +// 발주서 WL-811b §1-3 · 설계안 §B-5-1. Time.timeScale 을 쓰는 주체가 5곳(WL 히트스톱 · 킬캠 슬로모(811d) · +// 일시정지/서버 팝업 0 · 원본 PetBattleResult 0.3 · 원본 TimeMgr 미니 히트스톱)이라 겹치면 곱해지거나 덮어써진다. +// 규칙: +// · 상위 층이 활성이면 하위 요청은 **스킵**(곱하지 않는다) · 같은 층이 활성이면 무시(WLHitFeelRunner "겹치면 무시" 승계) +// · 하위 층이 활성인데 상위가 오면 하위를 즉시 끝내고(복원) 상위를 시작(선점) +// · Pause 층은 요청이 아니라 **관측**: 우리가 쓴 값이 아닌데 timeScale < pauseObserveThreshold 면 외부 일시정지로 본다 +// (원본 PauseUI · TimeMgr 는 손대지 않는다 — 발주서 §5) +// · 복원은 "우리가 쓴 값이 그대로 남아 있을 때만"(외부가 바꿨으면 덮어쓰지 않음) · 시간은 전부 unscaled +// · HitStop 은 초당 hitStopPerSecondMax 로 스로틀(최소 간격 = 1/값) +// 값은 전부 WLCombatCoreSettings 에셋. 코어 비활성이면 Request 가 Disabled 를 돌려 호출측(WLHitFeelRunner)이 기존 경로를 탄다. +// 틱은 숨은 러너 1개(WLHitFeel 의 __WLHitFeel 선례 · 코루틴 없음 · GC 0). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Combat.Core +{ + public enum TimeLayer { None = 0, HitStop = 1, Cinematic = 2, Pause = 3 } + + public enum ArbiterResult { Disabled = 0, Applied = 1, IgnoredSameLayer = 2, SkippedHigher = 3, SkippedPause = 4, Throttled = 5, Invalid = 6 } + + public static class TimeScaleArbiter + { + static TimeLayer s_active = TimeLayer.None; + static float s_prev = 1f, s_written = 1f, s_start, s_end; + static float s_lastHitStopStart = -999f; + static TimeScaleArbiterRunner s_runner; + + // ── 진단(프로브가 읽는다) + public static TimeLayer Active { get { return s_active; } } + public static float WrittenScale { get { return s_written; } } + public static float LastMeasured; // 마지막 감속의 실측 길이(초 · unscaled) · 외부가 덮어썼으면 -1 + public static ArbiterResult LastResult; + public static int AppliedCount, IgnoredCount, SkippedHigherCount, SkippedPauseCount, ThrottledCount, PreemptedCount; + public static string LastInfo = ""; + + public static bool Enabled + { + get { var c = WLCombatCoreSettings.Instance; return WLCombatCoreSettings.Enabled && c.arbiterEnabled; } + } + + static TimeScaleArbiterRunner Runner + { + get + { + if (s_runner == null) + { + var go = new GameObject("__WLTimeScaleArbiter"); + Object.DontDestroyOnLoad(go); + s_runner = go.AddComponent(); + } + return s_runner; + } + } + + static ArbiterResult Set(ArbiterResult r, string info) + { + LastResult = r; LastInfo = info; + var c = WLCombatCoreSettings.Instance; + if (c != null && c.verboseLog) Debug.Log("[TimeScaleArbiter] " + r + " " + info + " timeScale=" + Time.timeScale.ToString("F2")); + return r; + } + + /// + /// 감속 요청. layer = HitStop/Cinematic(Pause 는 관측 전용) · scale = 쓸 timeScale · seconds = 유지 시간(unscaled). + /// + public static ArbiterResult Request(TimeLayer layer, float scale, float seconds) + { + if (!Enabled) return Set(ArbiterResult.Disabled, "core off"); + var cfg = WLCombatCoreSettings.Instance; + if (layer == TimeLayer.None || layer == TimeLayer.Pause || seconds <= 0f || scale < 0f) return Set(ArbiterResult.Invalid, "layer=" + layer); + + float cur = Time.timeScale; + bool oursActive = s_active != TimeLayer.None && Mathf.Approximately(cur, s_written); + + // 우리 상태가 있는데 값이 바뀌었다 = 외부(일시정지·팝업·TimeMgr)가 덮어썼다 → 우리 상태 포기(복원 안 함) + 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 (s_active == layer) { IgnoredCount++; return Set(ArbiterResult.IgnoredSameLayer, layer.ToString()); } + if (s_active > layer) { SkippedHigherCount++; return Set(ArbiterResult.SkippedHigher, layer + " under " + s_active); } + + 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")); } + } + + if (s_active != TimeLayer.None) { Restore(); PreemptedCount++; } // 하위 층 선점 + + s_prev = Time.timeScale; + s_written = scale; + s_active = layer; + s_start = Time.unscaledTime; + s_end = s_start + Mathf.Min(seconds, cfg.layerMaxSeconds); + if (layer == TimeLayer.HitStop) s_lastHitStopStart = s_start; + Time.timeScale = scale; + Runner.enabled = true; + AppliedCount++; + return Set(ArbiterResult.Applied, layer + " " + scale.ToString("F2") + " for " + seconds.ToString("F3")); + } + + /// 층을 명시적으로 끝낸다(활성 층이 다르면 무시). + public static void Release(TimeLayer layer) + { + if (s_active != layer) return; + Restore(); + } + + /// 활성 층이 무엇이든 끝낸다(러너 파괴·앱 종료 안전망). + public static void ReleaseAll() { if (s_active != TimeLayer.None) Restore(); } + + static void Restore() + { + var layer = s_active; + if (Mathf.Approximately(Time.timeScale, s_written)) + { + Time.timeScale = s_prev; + LastMeasured = Time.unscaledTime - s_start; + } + else LastMeasured = -1f; // 외부가 이미 바꿨다 — 덮어쓰지 않는다 + if (layer == TimeLayer.HitStop) WL.Feel.WLHitFeel.LastHitStopMeasured = LastMeasured; // WL797_Probe 지표 유지 + s_active = TimeLayer.None; + } + + internal static void Tick() + { + if (s_active != TimeLayer.None && Time.unscaledTime >= s_end) Restore(); + } + + /// + /// WLHitFeelRunner.RunHitStop 이 부른다. true = 코어가 처리했다(적용·스킵·스로틀 모두) → 기존 코루틴을 돌리지 말 것. + /// false = 코어 비활성 또는 Feel 프리즈프레임 모드 → 기존 경로 그대로. + /// + 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; + } + + /// 프로브용 카운터 초기화. + public static void ResetDiagnostics() + { + AppliedCount = IgnoredCount = SkippedHigherCount = SkippedPauseCount = ThrottledCount = PreemptedCount = 0; + LastMeasured = 0f; LastInfo = ""; s_lastHitStopStart = -999f; + } + } + + /// 중재자 틱 러너(숨은 오브젝트 1개 · DontDestroyOnLoad). 파괴·비활성 시 감속을 반드시 되돌린다. + public sealed class TimeScaleArbiterRunner : MonoBehaviour + { + void Update() { TimeScaleArbiter.Tick(); } + void OnDisable() { TimeScaleArbiter.ReleaseAll(); } + void OnApplicationQuit() { TimeScaleArbiter.ReleaseAll(); } + } +} diff --git a/Assets/WL/Combat/Core/TimeScaleArbiter.cs.meta b/Assets/WL/Combat/Core/TimeScaleArbiter.cs.meta new file mode 100644 index 000000000..254179ce8 --- /dev/null +++ b/Assets/WL/Combat/Core/TimeScaleArbiter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4d7ca3dccd928f948b7c4ebe99f4c823 \ No newline at end of file diff --git a/Assets/WL/Combat/Core/WLCombatCoreSettings.cs b/Assets/WL/Combat/Core/WLCombatCoreSettings.cs new file mode 100644 index 000000000..2b55df5d2 --- /dev/null +++ b/Assets/WL/Combat/Core/WLCombatCoreSettings.cs @@ -0,0 +1,111 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLCombatCoreSettings.cs — 전투 액션 코어(이벤트 파이프라인 · 감속 중재자 · 이펙트 상한) 값의 단일 출처(SOT · C45) +// +// PD 지시 #811 → #813 · 설계안 WL-811a §B-5 · 발주서 WL-811b §1-5 (2026-09-08) +// +// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset +// → Resources.Load("WL/WLCombatCoreSettings") 로 1회 로드 후 캐시(기존 설정 SO 5종과 같은 관례). +// 에셋이 없거나 enabled = false 면 Enabled == false → 코어 디스패치 0 · 원본/기존 WL 동작 100%(C8 롤백 1순위 스위치). +// 아래 필드 기본값은 "SOT 부재 시의 실패 처리"가 아니라 에셋 생성 시 초기값이다 — Enabled 가 false 면 어떤 값도 읽히지 않는다. +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(Actor/PCActor 가 Assembly-CSharp 에 있다). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Combat.Core +{ + [CreateAssetMenu(fileName = "WLCombatCoreSettings", menuName = "WL/Combat Core Settings", order = 320)] + public sealed class WLCombatCoreSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLCombatCoreSettings"; + + static WLCombatCoreSettings s_cached; + static bool s_lookupDone; + + /// 설정 에셋(1회 로드 후 캐시). 없으면 null. + public static WLCombatCoreSettings Instance + { + get + { + if (!s_lookupDone) + { + s_cached = Resources.Load(ResourcesPath); + s_lookupDone = true; + } + return s_cached; + } + } + + /// + /// 진단·A/B 전용 런타임 스위치(WLHitFeel.RuntimeDisabled 선례). 에셋을 건드리지 않고 코어만 끈다. + /// Play 종료 시 도메인 리로드로 자동 false. + /// + public static bool RuntimeDisabled; + + /// 코어가 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off. false 면 모든 Raise/Request 가 원본 동작으로 떨어진다. + public static bool Enabled + { + get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; } + } + + /// 테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다(에셋 제거/복구 실험). + public static void ClearCache() { s_cached = null; s_lookupDone = false; } + + // ─────────────────────────────────────────── 전체 스위치 + [Header("전체 스위치")] + [Tooltip("끄면 CombatEvents 디스패치 · 감속 중재 · 이펙트 상한이 전부 비활성 — 원본과 기존 WL(타격감·검기·대시) 동작 100%. 롤백 1순위.")] + public bool enabled = true; + + [Tooltip("이벤트·중재 결정을 Console 에 남긴다(검증용 · 기본 off · GC 발생하므로 서비스 빌드에서 켜지 말 것).")] + public bool verboseLog = false; + + // ─────────────────────────────────────────── 콤보 추적 + [Header("콤보 추적 (ComboTracker · PC 1체 · Animator 상태 폴링)")] + [Tooltip("공격 시작 시 PC 에 ComboTracker 를 자동 부착해 ComboStage 이벤트를 낸다.")] + public bool comboTrackerEnabled = true; + + [Tooltip("콤보 마지막 단계 번호. stage >= 이 값이면 ComboStage.isLast = true. 근접 4클래스 Knight@Attack1_S/2_S/3_S = 3.")] + public int comboStages = 3; + + [Tooltip("클립 이름에서 콤보 단계를 읽는 토큰. 토큰 바로 뒤의 첫 숫자가 단계(없으면 1). 예: Attack1_S_10501 → 1 · attack → 1 · Bow_Attack2 → 2.")] + public string comboClipToken = "Attack"; + + // ─────────────────────────────────────────── 감속 중재자 + [Header("감속 중재자 (TimeScaleArbiter · 우선순위 Pause > Cinematic > HitStop · D-1(a))")] + [Tooltip("끄면 WLHitFeelRunner 가 기존 코루틴 경로로 히트스톱을 건다(중재 없음).")] + public bool arbiterEnabled = true; + + [Tooltip("외부 일시정지 관측 임계값. 우리가 쓴 값이 아닌데 Time.timeScale 이 이 값 미만이면 Pause 층 활성으로 보고 하위 요청을 스킵한다. Feel MMF_FreezeFrame · WLHitFeelSettings.hitStopMinTimescale 과 같은 0.1.")] + public float pauseObserveThreshold = 0.1f; + + [Tooltip("초당 허용 히트스톱 횟수 상한(기획안 F-1 KPI ≤ 1.5). 최소 간격 = 1/값 (unscaled). 0 이면 제한 없음.")] + public float hitStopPerSecondMax = 1.5f; + + [Tooltip("어떤 층이든 한 번의 감속이 유지될 수 있는 최대 시간(초 · unscaled · 안전 상한). timeScale 이 낮은 채로 남는 사고 방지.")] + public float layerMaxSeconds = 2f; + + [Tooltip("Cinematic 층(킬캠 슬로모 · 811d)의 기본 timeScale — 811b 에서는 자리만 둔다(기획안 제안값 0.35).")] + public float cinematicDefaultScale = 0.35f; + + // ─────────────────────────────────────────── 이펙트 상한 + [Header("동시 이펙트 상한 (EffectBudget · 초과 시 스폰 스킵 · 큐잉 없음)")] + [Tooltip("대형(Heavy) 이펙트 동시 상한 — 스킬 스펙터클(811i) 소비자. 기획안 F-0B 제안값 2.")] + public int heavyEffectMax = 2; + + [Tooltip("대시 잔상 동시 상한 — 811g 소비자. 기획안 F-0C 제안값 4.")] + public int afterImageMax = 4; + + [Tooltip("처치 디졸브 사망체 동시 상한 — 811f 소비자. 기획안 F-0C 제안값 6.")] + public int dissolveMax = 6; + + [Tooltip("군집 일소 폭발 동시 상한 — 811e 소비자. 기획안 F-0D 제안값 1.")] + public int clusterBurstMax = 1; + + [Tooltip("킬캠 발동 최소 간격(초) — 811d 소비자. 811b 에서는 자리만 둔다(기획안 F-0A 제안값 8).")] + public float killCamCooldownSeconds = 8f; + + [Tooltip("화면 밖 판정 여유(뷰포트 비율). IsInView 는 [−margin, 1+margin] 안이면 화면 안으로 본다.")] + public float viewMargin = 0.1f; + } +} diff --git a/Assets/WL/Combat/Core/WLCombatCoreSettings.cs.meta b/Assets/WL/Combat/Core/WLCombatCoreSettings.cs.meta new file mode 100644 index 000000000..92a0a213f --- /dev/null +++ b/Assets/WL/Combat/Core/WLCombatCoreSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: da398ee8add144d88f8d005315a688e0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/DashDriver.cs b/Assets/WL/Combat/DashDriver.cs index fc908b471..e4ecd9ab1 100644 --- a/Assets/WL/Combat/DashDriver.cs +++ b/Assets/WL/Combat/DashDriver.cs @@ -155,6 +155,7 @@ namespace WL.Combat LastBeginTime = Time.time; LastStartDistance = distance; LastEndedInAttack = false; + WL.Combat.Core.CombatEvents.RaiseDashBegan(_pc, distance); // WL #811b 코어 통지 #if UNITY_EDITOR if (cfg.verboseLog) @@ -173,6 +174,7 @@ namespace WL.Combat LastEndTime = Time.time; LastMovedDistance = Vector3.Distance(_startPos, transform.position); LastEndedInAttack = attack; + WL.Combat.Core.CombatEvents.RaiseDashEnded(_pc, LastMovedDistance, attack); // WL #811b 코어 통지 // 계획 거리의 절반도 못 갔다 = NavMesh 경계·장애물·도망가는 대상이라 대쉬로는 못 붙는다. // 짧은 쿨다운이면 대쉬↔달리기를 반복하며 제자리걸음한다(실측) → 한동안 기존 추적에 맡긴다. diff --git a/Assets/WL/Combat/Settings.meta b/Assets/WL/Combat/Settings.meta new file mode 100644 index 000000000..0e78a50e0 --- /dev/null +++ b/Assets/WL/Combat/Settings.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 57179ab776dcdce4085f58bd4a3a46d6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Settings/Resources.meta b/Assets/WL/Combat/Settings/Resources.meta new file mode 100644 index 000000000..b7331ab8f --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4b00f4a150c74cd44be96c9d6ff86e11 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Settings/Resources/WL.meta b/Assets/WL/Combat/Settings/Resources/WL.meta new file mode 100644 index 000000000..ae1764b28 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b000993da9c0e7047b1efc1174522f71 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset new file mode 100644 index 000000000..f9b03ce08 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset @@ -0,0 +1,30 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: da398ee8add144d88f8d005315a688e0, type: 3} + m_Name: WLCombatCoreSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Core.WLCombatCoreSettings + enabled: 1 + verboseLog: 0 + comboTrackerEnabled: 1 + comboStages: 3 + comboClipToken: Attack + arbiterEnabled: 1 + pauseObserveThreshold: 0.1 + hitStopPerSecondMax: 1.5 + layerMaxSeconds: 2 + cinematicDefaultScale: 0.35 + heavyEffectMax: 2 + afterImageMax: 4 + dissolveMax: 6 + clusterBurstMax: 1 + killCamCooldownSeconds: 8 + viewMargin: 0.1 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset.meta b/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset.meta new file mode 100644 index 000000000..7a6ff6d43 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLCombatCoreSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cdf39aa40e9944c0b33df646883ecbfb +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/WeaponTrailDriver.cs b/Assets/WL/Combat/WeaponTrailDriver.cs index f891449d6..ce2f844c4 100644 --- a/Assets/WL/Combat/WeaponTrailDriver.cs +++ b/Assets/WL/Combat/WeaponTrailDriver.cs @@ -224,7 +224,9 @@ namespace WL.Combat var driver = actor.GetComponent(); if (driver == null) driver = actor.gameObject.AddComponent(); - return driver.HandleAttackEvent(pc, settings, ov, ev); + bool handled = driver.HandleAttackEvent(pc, settings, ov, ev); + WL.Combat.Core.CombatEvents.RaiseSwingEffect(pc, ev, handled); // WL #811b 코어 통지(검기 창 시작) + return handled; } /// diff --git a/Assets/WL/Feel/WLHitFeel.cs b/Assets/WL/Feel/WLHitFeel.cs index 6b3ace951..1e2eee65e 100644 --- a/Assets/WL/Feel/WLHitFeel.cs +++ b/Assets/WL/Feel/WLHitFeel.cs @@ -59,6 +59,21 @@ namespace WL.Feel /// public static bool RuntimeDisabled; + // ── WL #811b: 코어 이벤트 파이프라인 편입 — HitConfirmed 구독자. + // CombatEvents.RaiseHitConfirmed 가 LastCoreHandled(= OnHit 반환값)로 기존 false 반환 규약(원본 ShakeCamera 폴백)을 그대로 잇는다. + // 코어가 비활성이면 RaiseHitConfirmed 가 OnHit 을 직접 부르므로 이 구독은 돌지 않는다(동작 동일). + public static bool LastCoreHandled; + public static bool CoreSubscribed; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + static void RegisterCoreSubscriber() + { + WL.Combat.Core.CombatEvents.HitConfirmed.Add(OnHitConfirmedEvent); + CoreSubscribed = true; + } + + static void OnHitConfirmedEvent(in WL.Combat.Core.HitConfirmedEvent e) { LastCoreHandled = OnHit(e.victim, e.dinfo); } + // ── 상태 static float s_LastFiredTime = -999f; static WLHitFeelRunner s_Runner; diff --git a/Assets/WL/Feel/WLHitFeelRunner.cs b/Assets/WL/Feel/WLHitFeelRunner.cs index 7330b86a0..77c022fb2 100644 --- a/Assets/WL/Feel/WLHitFeelRunner.cs +++ b/Assets/WL/Feel/WLHitFeelRunner.cs @@ -27,6 +27,7 @@ public class WLHitFeelRunner : MonoBehaviour { // 이미 진행 중이면 새로 걸지 않는다. 중간에 StopCoroutine 으로 끊으면 // timeScale 이 0 인 채로 영구히 남는 사고가 나므로 '겹치면 무시' 가 유일하게 안전하다. + if (WL.Combat.Core.TimeScaleArbiter.TryHandleHitStop(st, duration)) return; // WL #811b: 코어 감속 중재자 경유(코어 비활성이면 false → 아래 기존 경로) if (_hitStopActive) return; StartCoroutine(CoHitStop(st, duration)); }