[WL-811b] 전투 액션 코어 골격 (#811)

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 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-08 23:30:57 +09:00
parent 8060e1922f
commit ff208d6dbe
25 changed files with 1082 additions and 2 deletions

View File

@ -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/<이름>_<HHmmss>.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();
}
/// <summary>ⓖ 100회 디스패치 GC 증가. 구독자 1개(no-op) 등록 후 측정 · Play/Edit 모두 가능(코어 Enabled 필요).</summary>
public static object GcCheck()
{
var sb = new StringBuilder();
sb.AppendLine("# WL811b GcCheck " + DateTime.Now.ToString("HH:mm:ss") + " Enabled=" + WLCombatCoreSettings.Enabled);
CombatHandler<HitboxSpawnedEvent> 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<Host, IEnumerator> make)
{
var old = GameObject.Find("__wl811b_probe");
if (old != null) UnityEngine.Object.DestroyImmediate(old);
var go = new GameObject("__wl811b_probe");
var h = go.AddComponent<Host>();
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);
}
}
/// <summary>ⓒ 이벤트 순서·타이밍(자동 교전을 그대로 관찰 · 조작 없음).</summary>
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);
}
/// <summary>ⓔ 중재자: 외부 일시정지 스킵 · 상위 층 스킵 · 스로틀 · 복원. timeScale 은 끝에 1 로 되돌린다.</summary>
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);
}
/// <summary>ⓕ 코어 런타임 스위치: off 동안 디스패치 0 · 타격감은 기존 경로로 계속 → 끝에 원복.</summary>
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);
}
}
}

View File

@ -532,7 +532,7 @@ public class Actor : MyCoroutine
//} //}
// WL #797 — 타격 임팩트(히트스톱·카메라 셰이크·몹 스케일 펀치). OnHit 이 false 를 반환하면 // WL #797 — 타격 임팩트(히트스톱·카메라 셰이크·몹 스케일 펀치). OnHit 이 false 를 반환하면
// (설정 에셋 없음 · enabled=false) 기존 ShakeCamera(1) 경로가 그대로 실행된다 = 100% 롤백. // (설정 에셋 없음 · 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 UNITY_EDITOR
if (SceneManager.GetActiveScene().name == "StatTest") 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; m_animation.speed -= m_animation.speed * (float)dic_ccData[eCC.ATKSPD_Down].CCDmg;
AnimationPlay(aniname); AnimationPlay(aniname);
actsuccess?.Invoke(); actsuccess?.Invoke();
WL.Combat.Core.CombatEvents.RaiseAttackStarted(this, _attack, m_animation.speed, m_Target);
if (isTarget) transform.LookAt(m_Target.transform); if (isTarget) transform.LookAt(m_Target.transform);
if (gos_Effect != null && gos_Effect.Length > _attack) if (gos_Effect != null && gos_Effect.Length > _attack)
@ -1967,6 +1968,7 @@ public class Actor : MyCoroutine
{ {
m_animation.speed = 1f; m_animation.speed = 1f;
CurAnim = eAnim.Skill; CurAnim = eAnim.Skill;
WL.Combat.Core.CombatEvents.RaiseSkillCast(this, m_Magic);
AnimationPlay(IsRole(eRole.Pet) ? "casting" : motion); AnimationPlay(IsRole(eRole.Pet) ? "casting" : motion);
if (isTarget) transform.LookAt(m_Target.transform); 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) 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 (ProjectileInfo.isIns)
{ {
if (adddmg <= 0f) adddmg = 1f; if (adddmg <= 0f) adddmg = 1f;
@ -2367,6 +2370,7 @@ public class Actor : MyCoroutine
#if UNITY_EDITOR #if UNITY_EDITOR
if (SettingsMain.Ins) SettingsMain.Ins.Add_Event(System.Reflection.MethodBase.GetCurrentMethod().Name, 1, a); if (SettingsMain.Ins) SettingsMain.Ins.Add_Event(System.Reflection.MethodBase.GetCurrentMethod().Name, 1, a);
#endif #endif
WL.Combat.Core.CombatEvents.RaiseSkillFired(this, a, m_Magic);
Shoot_Skill(); Shoot_Skill();
} }
public virtual void FootR(string s) public virtual void FootR(string s)

View File

@ -146,6 +146,7 @@ public class MobActor : Actor
{ {
Set_Warp(OriginalPos); Set_Warp(OriginalPos);
InGameInfo.Ins.Show_Effect("Effect_MonsterSpawn", Get_position()); InGameInfo.Ins.Show_Effect("Effect_MonsterSpawn", Get_position());
WL.Combat.Core.CombatEvents.RaiseSpawned(this);
Reset_Extra(); Reset_Extra();
list_summon.ForEach(f => f.Off()); list_summon.ForEach(f => f.Off());
} }
@ -431,6 +432,7 @@ public class MobActor : Actor
protected override void Set_Die() protected override void Set_Die()
{ {
WL.Combat.Core.CombatEvents.RaiseKilled(this);
#if UNITY_EDITOR #if UNITY_EDITOR
if (SceneManager.GetActiveScene().name == "StatTest") if (SceneManager.GetActiveScene().name == "StatTest")
return; return;

View File

@ -306,6 +306,7 @@ public class MyActor : Actor
// 무적이어도 그대로 돌아야 한다 — 가드가 함수 맨 앞에서 return 하던 동안 플레이어가 공격을 시작하지 못했다. // 무적이어도 그대로 돌아야 한다 — 가드가 함수 맨 앞에서 return 하던 동안 플레이어가 공격을 시작하지 못했다.
bool wlInvincible = IsRole(eRole.PC) && WL.Settings.WLGameplaySettings.PlayerInvincible; bool wlInvincible = IsRole(eRole.PC) && WL.Settings.WLGameplaySettings.PlayerInvincible;
if (!wlInvincible) base.Get_Damage(_dinfo); if (!wlInvincible) base.Get_Damage(_dinfo);
WL.Combat.Core.CombatEvents.RaiseDamaged(this, _dinfo, wlInvincible);
if (_dinfo == null || DSUtil.CheckNull(_dinfo.Beater)) return; if (_dinfo == null || DSUtil.CheckNull(_dinfo.Beater)) return;

View File

@ -440,6 +440,7 @@ public class ProjectileBase : MonoBehaviour
if (m_ProjecTileData.shooter.IsEnemy() != hitter.IsEnemy()) if (m_ProjecTileData.shooter.IsEnemy() != hitter.IsEnemy())
{ {
WL.Combat.Core.CombatEvents.RaiseHitboxHit(this, m_ProjecTileData, hitter, noDmg);
if (!noDmg) hitter.Get_Damage(Get_DamageInfo()); if (!noDmg) hitter.Get_Damage(Get_DamageInfo());
m_ProjecTileData.Action_OnHit?.Invoke(hitter); m_ProjecTileData.Action_OnHit?.Invoke(hitter);
if (Off_byHit) Off(false, false); if (Off_byHit) Off(false, false);

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e890ca2827cb86f45a69d77d3130593e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
{
/// <summary>구독자 시그니처. 페이로드는 읽기 전용 참조로 받는다(복사 0).</summary>
public delegate void CombatHandler<T>(in T e) where T : struct;
/// <summary>이벤트 1종의 구독자 목록. 사전 할당 배열 · Add 때만 성장 · 예외 격리.</summary>
public sealed class CombatEventList<T> where T : struct
{
CombatHandler<T>[] _handlers;
int _count;
/// <summary>구독자 수.</summary>
public int Count { get { return _count; } }
/// <summary>Raise 된 횟수(구독자 0 이어도 센다) · 구독자 호출 횟수 — 프로브가 읽는다.</summary>
public int Raised, Dispatched;
public CombatEventList(int capacity) { _handlers = new CombatHandler<T>[Mathf.Max(1, capacity)]; }
public void Add(CombatHandler<T> 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<T> 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; }
/// <summary>전투 이벤트 허브. 정적 · GC 0 · 구독은 각 이벤트 목록의 Add/Remove.</summary>
public static class CombatEvents
{
public static readonly CombatEventList<AttackStartedEvent> AttackStarted = new CombatEventList<AttackStartedEvent>(8);
public static readonly CombatEventList<ComboStageEvent> ComboStage = new CombatEventList<ComboStageEvent>(8);
public static readonly CombatEventList<SwingEffectEvent> SwingEffect = new CombatEventList<SwingEffectEvent>(8);
public static readonly CombatEventList<HitboxSpawnedEvent> HitboxSpawned = new CombatEventList<HitboxSpawnedEvent>(8);
public static readonly CombatEventList<HitboxHitEvent> HitboxHit = new CombatEventList<HitboxHitEvent>(8);
public static readonly CombatEventList<HitConfirmedEvent> HitConfirmed = new CombatEventList<HitConfirmedEvent>(8);
public static readonly CombatEventList<KilledEvent> Killed = new CombatEventList<KilledEvent>(8);
public static readonly CombatEventList<DashEvent> Dash = new CombatEventList<DashEvent>(8);
public static readonly CombatEventList<SkillCastEvent> SkillCast = new CombatEventList<SkillCastEvent>(8);
public static readonly CombatEventList<SkillFiredEvent> SkillFired = new CombatEventList<SkillFiredEvent>(8);
public static readonly CombatEventList<DamagedEvent> Damaged = new CombatEventList<DamagedEvent>(8);
public static readonly CombatEventList<SpawnedEvent> Spawned = new CombatEventList<SpawnedEvent>(8);
public static readonly CombatEventList<LevelUpEvent> LevelUp = new CombatEventList<LevelUpEvent>(4);
/// <summary>코어 활성 여부 = WLCombatCoreSettings.Enabled(에셋 · enabled · 런타임 스위치).</summary>
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줄)
/// <summary>Actor.Play_Attack — actsuccess 직후. 메인 PC 면 ComboTracker 를 보장한다.</summary>
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);
}
/// <summary>ComboTracker — Animator 가 콤보 단계 상태에 들어온 프레임.</summary>
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);
}
/// <summary>WeaponTrailDriver.TryHandleShowEffect — 근접 PC 의 ShowEffect 클립 이벤트(검기 창 시작).</summary>
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);
}
/// <summary>Actor.Shoot_Projectile 첫 줄 — 클립 Projectile 이벤트가 히트박스(투사체)를 쏘기 직전.</summary>
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);
}
/// <summary>ProjectileBase.ProcessCollider — 진영 판정 통과 · Get_Damage 직전. 처치자 귀속 컨텍스트를 남긴다.</summary>
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);
}
/// <summary>
/// Actor.Get_Damage — Cal_Damage · CC · Extra 뒤(최종 피해·크리·킬 확정). 기존 WLHitFeel 훅 자리.
/// 반환 = true 면 호출측이 원본 ShakeCamera(1) 을 부르지 않는다(기존 규약 = `!(Beater.IsMainPC() && !OnHit)`).
/// WLHitFeel 은 HitConfirmed 의 구독자로 돌며 LastCoreHandled 에 OnHit 반환값을 남긴다.
/// </summary>
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);
}
/// <summary>MobActor.Set_Die 첫 줄 — 몹 사망 확정. 처치자는 같은 프레임의 HitboxHit 컨텍스트에서 귀속(없으면 null · 도트 등).</summary>
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 : "?"));
}
/// <summary>DashDriver — 대시 시작.</summary>
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);
}
/// <summary>DashDriver — 대시 종료(도착 즉시 공격 여부 포함).</summary>
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);
}
/// <summary>Actor.Play_Skill — CurAnim = Skill 직후. magic 은 시전 중 스킬(null 가능).</summary>
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);
}
/// <summary>Actor.SkillEvent — 클립 SkillEvent 이벤트(실체 발사) 직전.</summary>
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);
}
/// <summary>MyActor.Get_Damage — PC 피격(무적이면 피해 0 으로 통과 · 이벤트는 발생).</summary>
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);
}
/// <summary>MobActor.On_Regen — 스폰 이펙트 직후(부활 제외).</summary>
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);
}
/// <summary>레벨업 — 발생 지점(ServerClass.Add_Exp · Systems 소유)은 후속 웨이브. 지금은 API 만.</summary>
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);
}
/// <summary>프로브용: 카운터 초기화(구독은 유지).</summary>
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;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3a174c43a0d624e42b0167392166cae5

View File

@ -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<int, int> _stageByHash = new Dictionary<int, int>(16);
readonly Dictionary<int, string> _clipByHash = new Dictionary<int, string>(16);
// ── 진단
public static int Instances;
public static string LastInfo = "";
public int LastStage { get { return _lastStage; } }
/// <summary>메인 PC 에 1개만 붙인다(이미 있으면 재사용). 모델 교체 등으로 Animator 가 바뀌어도 매번 갱신한다.</summary>
public static void Ensure(PCActor pc)
{
if (pc == null) return;
var t = pc.GetComponent<ComboTracker>();
if (t == null) { t = pc.gameObject.AddComponent<ComboTracker>(); 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;
}
/// <summary>토큰 뒤의 첫 숫자 = 단계. 토큰이 없으면 0(공격 아님) · 토큰은 있는데 숫자가 없으면 1.</summary>
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;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3ac94be55ab8c404d9529fd2f47c7a78

View File

@ -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]; }
/// <summary>슬롯을 하나 잡는다. 상한이면 false(스킵). 코어 비활성이면 항상 true(세지 않음).</summary>
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;
}
/// <summary>슬롯을 돌려준다(이펙트 종료·풀 반납 시). 0 아래로 내려가지 않는다.</summary>
public static void Release(EffectBudgetKind kind)
{
int i = (int)kind;
if (s_count[i] > 0) s_count[i]--;
}
/// <summary>씬 전환 등에서 전부 비운다.</summary>
public static void ResetAll() { Array.Clear(s_count, 0, s_count.Length); }
/// <summary>월드 위치가 메인 카메라 화면 안(여유 viewMargin 포함)인가. 카메라가 없으면 true.</summary>
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;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 21627c2bc807c8b418c8f8e6fade247f

View File

@ -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<TimeScaleArbiterRunner>();
}
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;
}
/// <summary>
/// 감속 요청. layer = HitStop/Cinematic(Pause 는 관측 전용) · scale = 쓸 timeScale · seconds = 유지 시간(unscaled).
/// </summary>
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"));
}
/// <summary>층을 명시적으로 끝낸다(활성 층이 다르면 무시).</summary>
public static void Release(TimeLayer layer)
{
if (s_active != layer) return;
Restore();
}
/// <summary>활성 층이 무엇이든 끝낸다(러너 파괴·앱 종료 안전망).</summary>
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();
}
/// <summary>
/// WLHitFeelRunner.RunHitStop 이 부른다. true = 코어가 처리했다(적용·스킵·스로틀 모두) → 기존 코루틴을 돌리지 말 것.
/// false = 코어 비활성 또는 Feel 프리즈프레임 모드 → 기존 경로 그대로.
/// </summary>
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;
}
/// <summary>프로브용 카운터 초기화.</summary>
public static void ResetDiagnostics()
{
AppliedCount = IgnoredCount = SkippedHigherCount = SkippedPauseCount = ThrottledCount = PreemptedCount = 0;
LastMeasured = 0f; LastInfo = ""; s_lastHitStopStart = -999f;
}
}
/// <summary>중재자 틱 러너(숨은 오브젝트 1개 · DontDestroyOnLoad). 파괴·비활성 시 감속을 반드시 되돌린다.</summary>
public sealed class TimeScaleArbiterRunner : MonoBehaviour
{
void Update() { TimeScaleArbiter.Tick(); }
void OnDisable() { TimeScaleArbiter.ReleaseAll(); }
void OnApplicationQuit() { TimeScaleArbiter.ReleaseAll(); }
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4d7ca3dccd928f948b7c4ebe99f4c823

View File

@ -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<WLCombatCoreSettings>("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;
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null.</summary>
public static WLCombatCoreSettings Instance
{
get
{
if (!s_lookupDone)
{
s_cached = Resources.Load<WLCombatCoreSettings>(ResourcesPath);
s_lookupDone = true;
}
return s_cached;
}
}
/// <summary>
/// 진단·A/B 전용 런타임 스위치(WLHitFeel.RuntimeDisabled 선례). 에셋을 건드리지 않고 코어만 끈다.
/// Play 종료 시 도메인 리로드로 자동 false.
/// </summary>
public static bool RuntimeDisabled;
/// <summary>코어가 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off. false 면 모든 Raise/Request 가 원본 동작으로 떨어진다.</summary>
public static bool Enabled
{
get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; }
}
/// <summary>테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다(에셋 제거/복구 실험).</summary>
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;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: da398ee8add144d88f8d005315a688e0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -155,6 +155,7 @@ namespace WL.Combat
LastBeginTime = Time.time; LastBeginTime = Time.time;
LastStartDistance = distance; LastStartDistance = distance;
LastEndedInAttack = false; LastEndedInAttack = false;
WL.Combat.Core.CombatEvents.RaiseDashBegan(_pc, distance); // WL #811b 코어 통지
#if UNITY_EDITOR #if UNITY_EDITOR
if (cfg.verboseLog) if (cfg.verboseLog)
@ -173,6 +174,7 @@ namespace WL.Combat
LastEndTime = Time.time; LastEndTime = Time.time;
LastMovedDistance = Vector3.Distance(_startPos, transform.position); LastMovedDistance = Vector3.Distance(_startPos, transform.position);
LastEndedInAttack = attack; LastEndedInAttack = attack;
WL.Combat.Core.CombatEvents.RaiseDashEnded(_pc, LastMovedDistance, attack); // WL #811b 코어 통지
// 계획 거리의 절반도 못 갔다 = NavMesh 경계·장애물·도망가는 대상이라 대쉬로는 못 붙는다. // 계획 거리의 절반도 못 갔다 = NavMesh 경계·장애물·도망가는 대상이라 대쉬로는 못 붙는다.
// 짧은 쿨다운이면 대쉬↔달리기를 반복하며 제자리걸음한다(실측) → 한동안 기존 추적에 맡긴다. // 짧은 쿨다운이면 대쉬↔달리기를 반복하며 제자리걸음한다(실측) → 한동안 기존 추적에 맡긴다.

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 57179ab776dcdce4085f58bd4a3a46d6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4b00f4a150c74cd44be96c9d6ff86e11
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b000993da9c0e7047b1efc1174522f71
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: cdf39aa40e9944c0b33df646883ecbfb
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -224,7 +224,9 @@ namespace WL.Combat
var driver = actor.GetComponent<WeaponTrailDriver>(); var driver = actor.GetComponent<WeaponTrailDriver>();
if (driver == null) driver = actor.gameObject.AddComponent<WeaponTrailDriver>(); if (driver == null) driver = actor.gameObject.AddComponent<WeaponTrailDriver>();
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;
} }
/// <summary> /// <summary>

View File

@ -59,6 +59,21 @@ namespace WL.Feel
/// </summary> /// </summary>
public static bool RuntimeDisabled; 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 float s_LastFiredTime = -999f;
static WLHitFeelRunner s_Runner; static WLHitFeelRunner s_Runner;

View File

@ -27,6 +27,7 @@ public class WLHitFeelRunner : MonoBehaviour
{ {
// 이미 진행 중이면 새로 걸지 않는다. 중간에 StopCoroutine 으로 끊으면 // 이미 진행 중이면 새로 걸지 않는다. 중간에 StopCoroutine 으로 끊으면
// timeScale 이 0 인 채로 영구히 남는 사고가 나므로 '겹치면 무시' 가 유일하게 안전하다. // timeScale 이 0 인 채로 영구히 남는 사고가 나므로 '겹치면 무시' 가 유일하게 안전하다.
if (WL.Combat.Core.TimeScaleArbiter.TryHandleHitStop(st, duration)) return; // WL #811b: 코어 감속 중재자 경유(코어 비활성이면 false → 아래 기존 경로)
if (_hitStopActive) return; if (_hitStopActive) return;
StartCoroutine(CoHitStop(st, duration)); StartCoroutine(CoHitStop(st, duration));
} }