277 lines
15 KiB
C#
277 lines
15 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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;
|
|
|
|
/// <summary>최근 평균 피해(표본이 모자라면 -1).</summary>
|
|
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; } }
|
|
|
|
/// <summary>티어 시스템이 살아 있는가(에셋 · enabled · RuntimeDisabled · tierEnabled).</summary>
|
|
public static bool Active
|
|
{
|
|
get { var st = St; return st != null && st.enabled && !WLImpactTierSettings.RuntimeDisabled && st.tierEnabled; }
|
|
}
|
|
|
|
/// <summary>에셋은 살아 있는가(티어 판정은 꺼도 PC 피격·잔상은 돈다).</summary>
|
|
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;
|
|
}
|
|
|
|
// ───────────────────────────────────────── 소비자 진입점
|
|
/// <summary>
|
|
/// 이 타격의 티어. 구독자가 이미 확정했으면(같은 victim · 같은 프레임) 그 값을 그대로 돌려준다.
|
|
/// 코어 비활성(Dispatch 없음) 경로에서는 호출측 힌트로 그 자리에서 계산한다.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>초당 상한 적용 후 최종 티어를 확정하고 카운터를 남긴다.</summary>
|
|
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];
|
|
}
|
|
|
|
/// <summary>히트스톱 길이(초). 상한(hitStopMaxSeconds)으로 자른다. 0 = 그 티어는 히트스톱 없음.</summary>
|
|
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));
|
|
}
|
|
|
|
/// <summary>카메라 셰이크 진폭 배수. 0 = 셰이크 없음.</summary>
|
|
public static float ShakeMultiplier(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.shakeMultiplier, (int)tier, 1f); }
|
|
|
|
/// <summary>스케일 펀치 세기 배수(1 = WLHitFeelSettings.scalePunch 그대로).</summary>
|
|
public static float PunchMultiplier(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.punchMultiplier, (int)tier, 1f); }
|
|
|
|
/// <summary>몹 점멸 색 배수(RGB 에만 곱한다).</summary>
|
|
public static float BlinkColorMultiplier(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.blinkColorMultiplier, (int)tier, 1f); }
|
|
|
|
/// <summary>몹 점멸 시간 배수.</summary>
|
|
public static float BlinkTimeMultiplier(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.blinkTimeMultiplier, (int)tier, 1f); }
|
|
|
|
/// <summary>히트 이펙트 스케일 배수 — 【미적용 · 예약】 소비처가 원본(Assets/Script/**)이라 훅 0 제약에서 쓰지 못한다.</summary>
|
|
public static float HitEffectScale(ImpactTierKind tier) { var st = St; return st == null ? 1f : Get(st.hitEffectScale, (int)tier, 1f); }
|
|
|
|
/// <summary>
|
|
/// 소비자(MobHitFlash)용 편의 API. 도장이 찍혀 있으면 그 티어, 아니면 Light.
|
|
/// 티어 시스템이 꺼져 있으면 배수 1(= 기존 동작)을 돌려준다.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 프로브용: 메모이즈 도장만 지운다.
|
|
/// 에디트 모드에는 프레임 진행이 없어 연속 Resolve 가 전부 '같은 프레임의 같은 피격자' 로 보이기 때문이다.
|
|
/// </summary>
|
|
public static void ClearStamp() { s_stampVictim = null; s_stampFrame = -1; }
|
|
|
|
/// <summary>프로브용: 카운터·평균·도장 초기화(구독은 유지).</summary>
|
|
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
|
|
}
|
|
}
|
|
}
|