[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:
parent
8060e1922f
commit
ff208d6dbe
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e890ca2827cb86f45a69d77d3130593e
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3a174c43a0d624e42b0167392166cae5
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3ac94be55ab8c404d9529fd2f47c7a78
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 21627c2bc807c8b418c8f8e6fade247f
|
||||||
|
|
@ -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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4d7ca3dccd928f948b7c4ebe99f4c823
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: da398ee8add144d88f8d005315a688e0
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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 경계·장애물·도망가는 대상이라 대쉬로는 못 붙는다.
|
||||||
// 짧은 쿨다운이면 대쉬↔달리기를 반복하며 제자리걸음한다(실측) → 한동안 기존 추적에 맡긴다.
|
// 짧은 쿨다운이면 대쉬↔달리기를 반복하며 제자리걸음한다(실측) → 한동안 기존 추적에 맡긴다.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 57179ab776dcdce4085f58bd4a3a46d6
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4b00f4a150c74cd44be96c9d6ff86e11
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b000993da9c0e7047b1efc1174522f71
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: cdf39aa40e9944c0b33df646883ecbfb
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue