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