// ───────────────────────────────────────────────────────────────────────────── // ImpactTier.cs — 임팩트 티어 리졸버(811h · 정적 · GC 0 · 원본 훅 0) // // PD 지시 #813 · 발주서 WL-811gh §1-1 · 설계안 §C 811h 행 (2026-09-09) // // ■ 무엇을 하나 // HitConfirmed 1건을 Light / Heavy / Kill / BossKill 4단으로 분류하고, // 티어별 값(히트스톱 ms · 셰이크 배수 · 펀치 배수 · 점멸 색/시간 배수 · 초당 상한)을 **한 곳에서** 공급한다. // 소비자 = WLHitFeel(히트스톱·셰이크·펀치) · MobHitFlash(점멸 강도). 둘 다 같은 티어를 본다. // // ■ 왜 「구독 + 메모이즈」 인가 // WLHitFeel 과 MobHitFlash 는 둘 다 HitConfirmed 구독자다. 어느 쪽이 먼저 불릴지는 정해져 있지 않은데 // 티어는 둘 다 같아야 한다. 그래서 ImpactTier 가 **SubsystemRegistration**(AfterAssembliesLoaded 보다 먼저)에서 // 구독해 목록 0번을 차지하고, 이벤트의 authoritative 한 isKill 로 티어를 먼저 확정해 (victim, frame) 로 도장을 찍는다. // 두 소비자의 Resolve(...) 는 그 도장을 그대로 읽는다(재계산 0 · 평균 링버퍼 중복 적재 0). // 코어가 꺼져 있으면(RaiseHitConfirmed 가 Dispatch 하지 않는 C8 경로) 도장이 없으므로 호출측 힌트로 그 자리에서 계산한다. // // ■ GC 0 // 구조체·기본형만 쓴다. 평균은 사전 할당 링버퍼(float[]). 진단 문자열은 **읽을 때만** 만든다(TimeScaleArbiter FIX-1 선례). // verboseLog 가 켜졌을 때만 Debug.Log 를 부른다(문자열 결합도 그 안에서만). // // ■ 초당 상한(발주서 「티어별 동시 상한」) // 히트스톱·셰이크는 구조상 동시 1개다(TimeScaleArbiter 는 층 1개 · 셰이커는 오브젝트 1개). // 그래서 '동시' 상한은 **초당 상한**으로 실현한다 — 근접 스윙 1회가 군집 5~7 마리를 같은 프레임에 때리므로 // Heavy/Kill 폭주를 여기서 막는다. 상한을 넘긴 타격은 Light 로 강등한다(연출을 통째로 버리지 않는다). // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; using WL.Combat.Core; namespace WL.Combat.Reaction { public static class ImpactTier { // ── 티어 도장(구독자가 먼저 찍는다) ───────────────────────────────── static Actor s_stampVictim; static int s_stampFrame = -1; static ImpactTierKind s_stampTier; // ── 최근 피해 평균(링버퍼 · 사전 할당 · GC 0) static float[] s_samples = new float[64]; static int s_sampleHead, s_sampleCount; static float s_sampleSum; // ── 티어별 초당 상한(마지막 발동 시각 · 남은 토큰) static readonly float[] s_lastFire = new float[4]; static readonly int[] s_fired = new int[4]; // ── 진단(프로브가 읽는다) public static bool Subscribed; public static ImpactTierKind LastTier; public static int LastTierIndex { get { return (int)LastTier; } } public static readonly int[] TierCount = new int[4]; public static int DowngradedCount, ResolvedCount, StampHitCount; // WL #815f — 예고 중 히트스톱 스킵(기준서 §G-4). s_skipHitStop 은 Resolve 가 매 타격 갱신한다. static bool s_skipHitStop; public static int TelegraphHitStopSkipped; public static bool LastSkipHitStop { get { return s_skipHitStop; } } public static float LastDamage, LastAverage; public static bool LastCritical, LastSkill, LastKill, LastBoss; /// 최근 평균 피해(표본이 모자라면 -1). public static float Average { get { return s_sampleCount >= MinSamples ? s_sampleSum / s_sampleCount : -1f; } } static int MinSamples { get { var st = St; return st == null ? 8 : Mathf.Clamp(st.damageAverageSamples, 2, s_samples.Length); } } static WLImpactTierSettings St { get { return WLImpactTierSettings.Instance; } } /// 티어 시스템이 살아 있는가(에셋 · enabled · RuntimeDisabled · tierEnabled). public static bool Active { get { var st = St; return st != null && st.enabled && !WLImpactTierSettings.RuntimeDisabled && st.tierEnabled; } } /// 에셋은 살아 있는가(티어 판정은 꺼도 PC 피격·잔상은 돈다). public static bool AssetEnabled { get { return WLImpactTierSettings.Enabled; } } // ───────────────────────────────────────── 구독(목록 0번 확보) [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] static void Register() { CombatEvents.HitConfirmed.Add(OnHitConfirmed); Subscribed = true; } static void OnHitConfirmed(in HitConfirmedEvent e) { if (!Active || e.victim == null) return; s_stampTier = Compute(e.victim, (float)e.damage, e.critical, IsSkillHit(e.projectile), e.isKill, e.byMainPC); s_stampVictim = e.victim; s_stampFrame = Time.frameCount; } // ───────────────────────────────────────── 소비자 진입점 /// /// 이 타격의 티어. 구독자가 이미 확정했으면(같은 victim · 같은 프레임) 그 값을 그대로 돌려준다. /// 코어 비활성(Dispatch 없음) 경로에서는 호출측 힌트로 그 자리에서 계산한다. /// public static ImpactTierKind Resolve(Actor victim, DamageInfo dinfo, bool killHint) { if (!Active || victim == null) return ImpactTierKind.Light; // WL #815f(기준서 §G-4) — 예고 중인 몹을 때렸으면 이 타격의 히트스톱을 건너뛴다. // 히트스톱이 예고 구간에 겹치면 timeScale 이 바뀌어 예고 길이가 흔들린다. SO 플래그로 끌 수 있다(기본 on). s_skipHitStop = WL.Combat.Telegraph.WLTelegraphSettings.SkipHitStop && WL.Combat.Telegraph.Telegraph.IsTelegraphing(victim); if (s_stampVictim == victim && s_stampFrame == Time.frameCount) { StampHitCount++; LastTier = s_stampTier; return s_stampTier; } float dmg = dinfo != null ? (float)dinfo.Damage : 0f; bool crit = dinfo != null && dinfo.Critical != eStat.None; bool skill = dinfo != null && IsSkillHit(dinfo.Beater_pd); bool byPC = dinfo != null && dinfo.Beater != null && dinfo.Beater.IsMainPC(); var t = Compute(victim, dmg, crit, skill, killHint, byPC); s_stampTier = t; s_stampVictim = victim; s_stampFrame = Time.frameCount; return t; } static bool IsSkillHit(ProjectileData pd) { // 근접 평타도 0.02 s 박스 '투사체' 라 pd != null 만으로는 전부 스킬이 된다 → // CombatEvents.RaiseHitboxHit 의 isSkill 과 같은 식(스킬 테이블이 붙었는가)으로 본다. return pd != null && pd.m_SkillListTableData != null; } // ───────────────────────────────────────── 판정 static ImpactTierKind Compute(Actor victim, float damage, bool critical, bool skill, bool kill, bool byMainPC) { var st = St; LastDamage = damage; LastCritical = critical; LastSkill = skill; LastKill = kill; if (st.onlyByMainPC && !byMainPC) { LastBoss = false; return Finish(st, ImpactTierKind.Light); } bool boss = st.bossKillEnabled && (victim.IsSubRole(eSubRol.Boss) || (st.bossKillIncludesElite && victim.IsSubRole(eSubRol.Elite))); LastBoss = boss; ImpactTierKind tier; if (kill) tier = boss ? ImpactTierKind.BossKill : ImpactTierKind.Kill; else if (IsHeavy(st, damage, critical, skill)) tier = ImpactTierKind.Heavy; else tier = ImpactTierKind.Light; // 평균은 **평타(비킬·비크리)** 만 적재한다 — 크리/스킬/킬을 넣으면 기준선이 스스로 올라가 Heavy 판정이 사라진다. if (!kill && !critical && !skill && damage > 0f) PushSample(st, damage); return Finish(st, tier); } static bool IsHeavy(WLImpactTierSettings st, float damage, bool critical, bool skill) { if (st.heavyOnCritical && critical) return true; if (st.heavyOnSkill && skill) return true; if (st.heavyDamageAverageMultiplier > 0f) { float avg = Average; LastAverage = avg; if (avg > 0f && damage >= avg * st.heavyDamageAverageMultiplier) return true; } return false; } /// 초당 상한 적용 후 최종 티어를 확정하고 카운터를 남긴다. static ImpactTierKind Finish(WLImpactTierSettings st, ImpactTierKind tier) { if (tier != ImpactTierKind.Light && !TryConsume(st, tier)) { DowngradedCount++; tier = ImpactTierKind.Light; } LastTier = tier; TierCount[(int)tier]++; ResolvedCount++; if (st.verboseLog) Debug.Log("[ImpactTier] " + tier + " dmg=" + LastDamage.ToString("F0") + " avg=" + LastAverage.ToString("F0") + " crit=" + LastCritical + " skill=" + LastSkill + " kill=" + LastKill + " boss=" + LastBoss); return tier; } static bool TryConsume(WLImpactTierSettings st, ImpactTierKind tier) { int i = (int)tier; float max = Get(st.tierMaxPerSecond, i, 0f); if (max <= 0f) return true; float now = Time.unscaledTime; if (now - s_lastFire[i] >= 1f) { s_lastFire[i] = now; s_fired[i] = 0; } if (s_fired[i] >= Mathf.CeilToInt(max)) return false; s_fired[i]++; return true; } static void PushSample(WLImpactTierSettings st, float damage) { int cap = Mathf.Clamp(st.damageAverageSamples, 2, s_samples.Length); if (s_sampleCount == cap) { s_sampleSum -= s_samples[s_sampleHead]; s_samples[s_sampleHead] = damage; s_sampleSum += damage; s_sampleHead = (s_sampleHead + 1) % cap; } else { int idx = (s_sampleHead + s_sampleCount) % cap; s_samples[idx] = damage; s_sampleSum += damage; s_sampleCount++; } } // ───────────────────────────────────────── 티어별 값 공급(소비자용) static float Get(float[] arr, int i, float fallback) { if (arr == null || i < 0 || i >= arr.Length) return fallback; return arr[i]; } /// 히트스톱 길이(초). 상한(hitStopMaxSeconds)으로 자른다. 0 = 그 티어는 히트스톱 없음. public static float HitStopSeconds(ImpactTierKind tier) { var st = St; if (st == null) return 0f; if (s_skipHitStop) { TelegraphHitStopSkipped++; return 0f; } // WL #815f — 직전 Resolve 가 「예고 중」으로 판정한 타격 float sec = Get(st.hitStopMs, (int)tier, 0f) * 0.001f; return Mathf.Clamp(sec, 0f, Mathf.Max(0f, st.hitStopMaxSeconds)); } /// 카메라 셰이크 진폭 배수. 0 = 셰이크 없음. public static float ShakeMultiplier(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.shakeMultiplier, (int)tier, 1f); } /// 스케일 펀치 세기 배수(1 = WLHitFeelSettings.scalePunch 그대로). public static float PunchMultiplier(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.punchMultiplier, (int)tier, 1f); } /// 몹 점멸 색 배수(RGB 에만 곱한다). public static float BlinkColorMultiplier(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.blinkColorMultiplier, (int)tier, 1f); } /// 몹 점멸 시간 배수. public static float BlinkTimeMultiplier(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.blinkTimeMultiplier, (int)tier, 1f); } /// 히트 이펙트 스케일 배수 — 【미적용 · 예약】 소비처가 원본(Assets/Script/**)이라 훅 0 제약에서 쓰지 못한다. public static float HitEffectScale(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.hitEffectScale, (int)tier, 1f); } /// /// 소비자(MobHitFlash)용 편의 API. 도장이 찍혀 있으면 그 티어, 아니면 Light. /// 티어 시스템이 꺼져 있으면 배수 1(= 기존 동작)을 돌려준다. /// public static void GetBlinkModifiers(Actor victim, out float colorMul, out float timeMul) { colorMul = 1f; timeMul = 1f; if (!Active || victim == null) return; if (s_stampVictim != victim || s_stampFrame != Time.frameCount) return; colorMul = BlinkColorMultiplier(s_stampTier); timeMul = BlinkTimeMultiplier(s_stampTier); } /// /// 프로브용: 메모이즈 도장만 지운다. /// 에디트 모드에는 프레임 진행이 없어 연속 Resolve 가 전부 '같은 프레임의 같은 피격자' 로 보이기 때문이다. /// public static void ClearStamp() { s_stampVictim = null; s_stampFrame = -1; } /// 프로브용: 카운터·평균·도장 초기화(구독은 유지). public static void ResetDiagnostics() { LastTier = ImpactTierKind.Light; for (int i = 0; i < 4; i++) { TierCount[i] = 0; s_lastFire[i] = -999f; s_fired[i] = 0; } DowngradedCount = ResolvedCount = StampHitCount = 0; LastDamage = LastAverage = 0f; LastCritical = LastSkill = LastKill = LastBoss = false; s_sampleHead = s_sampleCount = 0; s_sampleSum = 0f; s_stampVictim = null; s_stampFrame = -1; TelegraphHitStopSkipped = 0; s_skipHitStop = false; // WL #815f } } }