Project_WL/Assets/WL/UI/Scripts/PowerDeltaToast.cs

690 lines
37 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// PowerDeltaToast.cs — 성장 체감 표시 (WL-813l · PD 지시 #813)
//
// 기준서 v1 §D-2 813l 행 · §E 5-3 「강화 후 체감(전투력 % + 처치 타수 변화) = 표시 + 타수 1 로그」.
// 🔴 원탭 강화 UI · 강화 화면 · BM 은 **이 파일에 없다**(PD Q6 결정 전 · 발주서 §2 금지). 「체감 표시」만이다.
//
// ■ 이 파일이 내는 것 3가지
// ① 전투력 % 토스트 — 레벨업(813k `GrowthEvents.LevelUp`) · 장비/강화로 전투력 값이 바뀌면 "전투력 +N %"
// ② 레벨업 팝업 전투력 줄 — 813y `StatPopup` 이 `WLPowerMeter` 를 불러 「전투력 A → B (+N %)」 1줄을 붙인다
// ③ 처치 타수 비교 — 성장 전후 「평균 처치 타수」를 재서 줄었으면 "이제 3방 → 2방"(다음 존 클리어 때 1회)
//
// ■ 원본 접촉 0
// · `Assets/Script/**` 수정 0 — 원본 값은 `Actor.Get_StatInfo()` → `ActorStatInfo.Get_Stat(eStat)` **읽기만**.
// · Gameplay(`Assets/WL/Combat/**`) 수정 0 — 811b `CombatEvents` · 813k `GrowthEvents` · 813p `RunEvents` **구독만**.
// · 프리팹 diff 0 — 토스트 노드는 런타임 생성(`HideFlags.DontSave`) · 813q `RunHud` 선례.
//
// ■ 🔴 장비/강화 변경 이벤트는 원본에 없다(실측)
// `ServerClass.Set_Equip`(`Assets/Script/Server/ServerClass.cs:2329`)·`ActorStatInfo.Reset` 어디에도
// 구독 가능한 신호가 없고, 원본 파일 수정은 금지다. → 발주서 §1-2 대안대로 **폴링**한다
// (`pollIntervalSeconds` · 기본 0.5 s). 폴링 경로는 값 비교뿐이라 **할당 0**(GC 0) — 변화가 있을 때만 문자열이 난다.
//
// ■ C8 롤백 — `WLPowerDeltaSettings.enabled_ = 0`(또는 `RuntimeDisabled`) → 구독 0 · 폴링 0 · 노드 0 · 표시 0.
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using System.Globalization;
using TMPro;
using UnityEngine;
using WL.Combat.Core;
using WL.Combat.Growth;
using WL.Combat.Run;
namespace WL.UI
{
/// <summary>
/// 전투력 계산기 — 🔴 **원본에 전투력/CP 개념이 없다**(실측: `Assets/Script/**` 전수 검색 0건).
/// 그래서 발주서 WL-813l §1-1 대안인 WL 산식을 쓴다. **「추정」이며 계수는 전부 SO** 다.
///
/// power = ATK × (1 + critWeight × 치명기여) × (1 + aspdWeight × (공속배율 base)) + HP × hpWeight
///
/// 실측한 원본 스탯 규약(`Assets/Script/Util/MyValue.cs` · `MyEnum.cs` · `DSUtil.cs`):
/// · `eStat.BaseDmg` = ATK × ATK_Multiplier × DMG_Multiplier(최종 공격력) — 전투 밖에서는 0 일 수 있어 `ATK` 로 폴백
/// · `eStat.CRI` 는 런타임에 **0~1 비율**이다(`DSUtil.RandomTrue(p)` 가 `Random.Range(0f,1f)` 와 비교)
/// · `eStat.ATK_SPD_Multiplier` 는 **1 기준 배율**(`FinalAttackSpeed = HERO_ATK_SPD × (배율 1)`)
/// </summary>
public static class WLPowerMeter
{
/// <summary>한 번의 측정 결과(전부 실측값 · 진단·프로브가 읽는다).</summary>
public struct Sample
{
public double atk, critRate, critDamage, aspd, hp, power;
public bool valid;
public override string ToString()
{
return "atk=" + atk.ToString("F1") + " cri=" + critRate.ToString("F3") +
" criDmg=" + critDamage.ToString("F3") + " aspd=" + aspd.ToString("F3") +
" hp=" + hp.ToString("F1") + " → power=" + power.ToString("F1");
}
}
/// <summary>순수 함수 — 스탯 5개와 계수로 전투력을 만든다(Unity 객체 0 · 프로브가 그대로 검증한다).</summary>
public static double Compute(double atk, double critRate, double critDamage, double aspd, double hp, WLPowerDeltaSettings s)
{
if (s == null) return 0d;
double crit = s.useCritDamage ? critRate * critDamage : critRate;
double critTerm = 1d + s.critWeight * crit;
double aspdTerm = 1d + s.attackSpeedWeight * (aspd - s.attackSpeedBase);
if (critTerm < 0d) critTerm = 0d;
if (aspdTerm < 0d) aspdTerm = 0d;
double p = atk * critTerm * aspdTerm + hp * s.hpWeight;
return p * s.powerScale;
}
/// <summary>원본 스탯 묶음에서 전투력을 읽는다(쓰기 0). 스탯이 없으면 false.</summary>
public static bool TryRead(ActorStatInfo stat, WLPowerDeltaSettings s, out Sample o)
{
o = default(Sample);
if (stat == null || s == null) return false;
double atk = stat.Get_Stat(s.atkStat);
if (atk <= 0d) atk = stat.Get_Stat(s.atkFallbackStat);
o.atk = atk;
o.critRate = stat.Get_Stat(s.critRateStat);
o.critDamage = s.useCritDamage ? stat.Get_Stat(s.critDamageStat) : 0d;
o.aspd = stat.Get_Stat(s.attackSpeedStat);
o.hp = stat.Get_Stat(s.hpStat);
o.power = Compute(o.atk, o.critRate, o.critDamage, o.aspd, o.hp, s);
o.valid = true;
return true;
}
/// <summary>메인 PC 의 현재 전투력. PC 나 스탯이 없으면 false(로비·에디트 모드).</summary>
public static bool TryReadMainPc(out Sample o)
{
o = default(Sample);
var s = WLPowerDeltaSettings.Instance;
if (s == null) return false;
if (!MyValue.bMyPC) return false;
var pc = MyValue.MyPC;
if (pc == null) return false;
return TryRead(pc.Get_StatInfo(), s, out o);
}
/// <summary>증가율(%) — prev 가 0 이하면 0(나눗셈 방지).</summary>
public static double PercentDelta(double prev, double next)
{
if (prev <= 0d) return 0d;
return (next - prev) / prev * 100d;
}
/// <summary>전투력 표기(천 단위 구분 · 정수).</summary>
public static string Format(double power)
{
return power.ToString("N0", CultureInfo.InvariantCulture);
}
/// <summary>퍼센트 표기(자릿수는 SO).</summary>
public static string FormatPercent(double percent, WLPowerDeltaSettings s)
{
int d = s != null ? Mathf.Clamp(s.percentDigits, 0, 3) : 1;
return percent.ToString("F" + d, CultureInfo.InvariantCulture);
}
}
/// <summary>
/// 성장 체감 토스트 — 정적 클래스(813q `RunHud` 선례). 씬·프리팹에 노드를 굽지 않는다.
/// 전투력 % 줄과 처치 타수 비교 줄이 같은 대역(SO `toastCenterOffsetPx`)에 쌓인다.
/// </summary>
public static class PowerDeltaToast
{
private sealed class Line
{
public RectTransform rt;
public TextMeshProUGUI label;
public CanvasGroup group;
public float life, lifeMax, elapsed;
public bool used;
}
private static WLPowerDeltaSettings St { get { return WLPowerDeltaSettings.Instance; } }
private static RectTransform s_root;
private static Line[] s_lines;
private static Transform s_uiRoot;
private static TMP_FontAsset s_font;
private static bool s_subscribed;
private static string s_lastEdge = "";
// ── 전투력 감시(폴링 · GC 0) ─────────────────────────────────────────
private static double s_lastPower; // 마지막으로 관측한 전투력
private static double s_baseline; // 마지막으로 **표시한** 전투력(증가율의 분모)
private static bool s_hasLastPower;
private static float s_nextPoll;
private static float s_lastAnnounceAt = -999f;
private static int s_suppressToastFrame = -1; // 레벨업 프레임 — 팝업이 전투력 줄을 이미 보여 준다
// 레벨업 프레임의 「성장 직전 전투력」 — 813y StatPopup 이 어떤 순서로 구독됐든 같은 값을 보게 한다.
private static double s_growthPrevPower;
private static int s_growthPrevFrame = -1;
// ── 처치 타수 계측 ───────────────────────────────────────────────────
private static int s_curHits, s_curKills;
private static int s_curAttacks; // 기준서 §E 5-3 검증 방법(AttackStarted)도 같이 센다
private static float s_windowStart;
private static float s_beforeRatio;
private static bool s_hasBefore;
private static bool s_armed;
private static int s_armFrom, s_armTo;
private static float s_armedAt;
// ── 진단(프로브가 읽는다 · 실측만) ───────────────────────────────────
public static int LevelUpSeen, PowerToastCount, CompareToastCount, ZoneClearedSeen;
public static int PollCount, ObserveCount, SkippedSmall, SkippedDown, MergedByCooldown, SuppressedByPopup;
public static string LastText = "";
public static string LastCompareText = "";
public static double LastPower { get { return s_lastPower; } }
public static bool HasPower { get { return s_hasLastPower; } }
public static bool Subscribed { get { return s_subscribed; } }
public static bool Bound { get { return s_root != null; } }
public static bool Armed { get { return s_armed; } }
public static int ArmedFrom { get { return s_armFrom; } }
public static int ArmedTo { get { return s_armTo; } }
public static string LastEdge { get { return s_lastEdge; } }
public static int WindowHits { get { return s_curHits; } }
public static int WindowAttacks { get { return s_curAttacks; } }
public static int WindowKills { get { return s_curKills; } }
public static bool HasBaseline { get { return s_hasBefore; } }
public static float BaselineRatio { get { return s_beforeRatio; } }
public static int VisibleLines
{
get { int n = 0; if (s_lines != null) for (int i = 0; i < s_lines.Length; i++) if (s_lines[i].used) n++; return n; }
}
// ── 부팅 (노드 0 · 프리팹 diff 0) ────────────────────────────────────
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void Boot()
{
if (!WLPowerDeltaSettings.Enabled) return;
Subscribe(true);
PowerDeltaRunner.Ensure();
}
// ── 구독 ─────────────────────────────────────────────────────────────
/// <summary>813k 성장 축 · 811b 전투 신호 · 813p 런 축을 구독한다(전부 읽기 전용 구독).</summary>
public static string Subscribe(bool on)
{
if (on == s_subscribed) return "구독 변화 없음(" + s_subscribed + ")";
var s = St;
if (on)
{
GrowthEvents.LevelUp.Add(OnLevelUp);
if (s == null || s.hitsPerKillEnabled)
{
CombatEvents.HitConfirmed.Add(OnHitConfirmed);
CombatEvents.AttackStarted.Add(OnAttackStarted);
CombatEvents.Killed.Add(OnKilled);
}
RunEvents.ZoneCleared.Add(OnZoneCleared);
}
else
{
GrowthEvents.LevelUp.Remove(OnLevelUp);
CombatEvents.HitConfirmed.Remove(OnHitConfirmed);
CombatEvents.AttackStarted.Remove(OnAttackStarted);
CombatEvents.Killed.Remove(OnKilled);
RunEvents.ZoneCleared.Remove(OnZoneCleared);
}
s_subscribed = on;
return "구독=" + on + " (GrowthEvents.LevelUp · HitConfirmed/AttackStarted/Killed · RunEvents.ZoneCleared)";
}
// ── 노드 연결 (런타임 생성) ──────────────────────────────────────────
/// <summary>NewGameUI 루트 밑 HUD 경로에 토스트 노드를 만든다. 이미 물려 있으면 그대로.</summary>
public static string Bind(Transform uiRoot)
{
var s = St;
if (s == null) return "WLPowerDeltaSettings 에셋 없음";
if (!WLPowerDeltaSettings.Enabled || !s.toastEnabled) return "설정 off — 노드 0";
if (uiRoot == null) return "uiRoot=null";
if (s_root != null && s_uiRoot == uiRoot) return "이미 연결됨";
var parent = WLVignetteUtil.FindUiPath(uiRoot, s.hudParentPath) as RectTransform;
if (parent == null) return "부모 없음 — 경로 \"" + s.hudParentPath + "\"";
s_uiRoot = uiRoot;
if (s_font == null) s_font = RunHud.BorrowFont(uiRoot);
if (s_root == null || s_root.parent != parent)
{
var go = new GameObject("WL_PowerDeltaToast", typeof(RectTransform));
go.layer = WLTextFxUtil.UILayer;
go.hideFlags = HideFlags.DontSave; // 씬·프리팹에 굽지 않는다
s_root = (RectTransform)go.transform;
s_root.SetParent(parent, false);
s_lines = null;
}
BuildIfNeeded(s);
string layout = ApplyLayout(s);
HideAll();
return "연결 " + s.hudParentPath + " · " + layout;
}
private static void BuildIfNeeded(WLPowerDeltaSettings s)
{
int want = Mathf.Clamp(s.toastLines, 1, 4);
if (s_lines != null && s_lines.Length == want) return;
var next = new Line[want];
for (int i = 0; i < want; i++)
{
if (s_lines != null && i < s_lines.Length && s_lines[i].rt != null) { next[i] = s_lines[i]; continue; }
var lrt = WLTextFxUtil.NewChild(s_root, "Line" + i);
var cg = lrt.GetComponent<CanvasGroup>();
if (cg == null) cg = lrt.gameObject.AddComponent<CanvasGroup>();
cg.interactable = false; cg.blocksRaycasts = false; cg.alpha = 0f;
var label = WLTextFxUtil.NewText(lrt, "Label", s_font, TextAlignmentOptions.Center);
next[i] = new Line { rt = lrt, label = label, group = cg };
}
// 줄 수를 줄였으면 남는 노드는 숨기기만 한다(파괴 0 · 813g LootToast 규약).
if (s_lines != null)
for (int i = want; i < s_lines.Length; i++)
if (s_lines[i].group != null) { s_lines[i].group.alpha = 0f; s_lines[i].used = false; }
s_lines = next;
}
/// <summary>px → 유닛 환산은 813g `WLCombatTextSettings.UnitsPerPx` 하나만 쓴다(산식 중복 0).</summary>
private static float UnitsPerPx()
{
var t = WLCombatTextSettings.Instance;
if (t == null || s_root == null) return 1f;
var canvas = s_root.GetComponentInParent<Canvas>();
return t.UnitsPerPx(canvas != null ? canvas.rootCanvas : null);
}
public static string ApplyLayout(WLPowerDeltaSettings s)
{
if (s == null || s_root == null || s_lines == null) return "노드 없음 — Bind 먼저";
float u = UnitsPerPx();
WLTextFxUtil.Stretch(s_root);
var text = WLCombatTextSettings.Instance;
float widthPx = text != null ? text.designWidthPx : 1080f;
var edge = s.toastEdge;
if (text != null) edge = text.Edge(edge); // 813y 전체 스위치(textEdgeEnabled)를 존중한다
for (int i = 0; i < s_lines.Length; i++)
{
var rt = s_lines[i].rt;
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f);
rt.pivot = new Vector2(0.5f, 0.5f);
rt.sizeDelta = new Vector2(widthPx * u, s.toastLineHeightPx * u);
rt.anchoredPosition = new Vector2(0f, (s.toastCenterOffsetPx - s.toastLineHeightPx * i) * u);
var lb = s_lines[i].label;
WLTextFxUtil.Stretch(lb.rectTransform);
lb.fontSize = s.toastFontPx * u;
s_lastEdge = WLTextFxUtil.ApplyEdge(lb, edge);
}
return "lines=" + s_lines.Length + " offset=" + s.toastCenterOffsetPx.ToString("F0") +
"px unitsPerPx=" + u.ToString("F4") + " font=" + (s.toastFontPx * u).ToString("F1") +
" edge[" + s_lastEdge + "]";
}
public static void HideAll()
{
if (s_lines == null) return;
for (int i = 0; i < s_lines.Length; i++)
{
s_lines[i].used = false; s_lines[i].life = 0f;
if (s_lines[i].group != null) s_lines[i].group.alpha = 0f;
if (s_lines[i].rt != null) s_lines[i].rt.localScale = Vector3.one;
}
}
// ── 표시 ─────────────────────────────────────────────────────────────
/// <summary>한 줄 띄운다. 빈 줄이 없으면 가장 오래된 줄을 재활용한다(줄 파괴·생성 0).</summary>
public static string Push(string text, Color color)
{
var s = St;
if (s == null || !WLPowerDeltaSettings.Enabled || !s.toastEnabled) return "설정 off — 표시 0";
if (s_lines == null || s_root == null) { LastText = text; return "노드 없음(문구만 기록) — \"" + text + "\""; }
BuildIfNeeded(s);
int slot = -1;
for (int i = 0; i < s_lines.Length; i++) if (!s_lines[i].used) { slot = i; break; }
if (slot < 0)
{
float oldest = float.MaxValue;
for (int i = 0; i < s_lines.Length; i++) if (s_lines[i].life < oldest) { oldest = s_lines[i].life; slot = i; }
}
var ln = s_lines[slot];
ln.label.text = text;
ln.label.color = color;
ln.lifeMax = ln.life = Mathf.Max(0.01f, s.toastSeconds);
ln.elapsed = 0f;
ln.used = true;
ln.group.alpha = 1f;
ln.rt.localScale = Vector3.one * WLTextFxUtil.PunchScale(0f, s.toastPunchScale, s.toastPunchSeconds);
LastText = text;
return "line=" + slot + " \"" + text + "\"";
}
/// <summary>수명·페이드·펀치 + 폴링 + 타수 창 마감. 러너가 매 프레임 부른다.</summary>
public static void Tick(float now, float dt)
{
var s = St;
if (s == null || !WLPowerDeltaSettings.Enabled) return;
if (s_lines != null)
{
for (int i = 0; i < s_lines.Length; i++)
{
var ln = s_lines[i];
if (!ln.used) continue;
ln.life -= dt; ln.elapsed += dt;
float passed = WLTextFxUtil.Passed(ln.life, ln.lifeMax);
ln.group.alpha = WLTextFxUtil.FadeAlpha(passed, s.toastFadeStartRatio);
ln.rt.localScale = Vector3.one * WLTextFxUtil.PunchScale(ln.elapsed, s.toastPunchScale, s.toastPunchSeconds);
if (ln.life <= 0f) { ln.used = false; ln.group.alpha = 0f; ln.rt.localScale = Vector3.one; }
}
}
// ① 전투력 폴링 — 값 비교뿐이라 할당 0(변화가 있을 때만 문자열이 난다).
if (s.toastEnabled && now >= s_nextPoll)
{
s_nextPoll = now + Mathf.Max(0.05f, s.pollIntervalSeconds);
PollCount++;
WLPowerMeter.Sample sm;
if (WLPowerMeter.TryReadMainPc(out sm)) Observe(sm.power, now, "poll");
}
// ② 하한·쿨다운으로 미뤄 둔 변화 마감(기준값과 마지막 관측값이 다르면 다시 본다)
if (s_hasLastPower && s_lastPower != s_baseline &&
now - s_lastAnnounceAt >= Mathf.Max(0f, s.announceCooldownSeconds))
Evaluate(s, now, "flush");
// ③ 타수 창 마감
TickWindow(s, now);
// ④ 무장한 비교 문구 — 런이 안 돌면 즉시(존 클리어가 영영 안 올 수 있다)
if (s_armed)
{
if (now - s_armedAt > Mathf.Max(1f, s.compareArmSeconds)) { s_armed = false; }
else if (!s.showCompareOnZoneCleared || (s.showCompareImmediatelyIfNoRun && !RunDirector.IsRunning))
ShowCompare(s, "즉시");
}
}
// ── 전투력 변화 감지 ─────────────────────────────────────────────────
/// <summary>
/// 전투력 실값 하나를 넣는다(프로브도 이 경로를 쓴다).
/// 증가율의 분모는 **마지막으로 표시한 값**(`s_baseline`)이다 — 그래서
/// · 하한 미만의 잔변화는 표시를 미루고 **다음 변화에 누적**된다(0.4% + 0.4% → 0.8% 로 뜬다)
/// · 쿨다운 안의 연타는 한 줄로 합쳐지되 **이미 표시한 분은 두 번 세지 않는다**
/// </summary>
public static string Observe(double power, float now, string reason)
{
var s = St;
if (s == null || !WLPowerDeltaSettings.Enabled) return "설정 off";
ObserveCount++;
if (!s_hasLastPower)
{
s_lastPower = power; s_baseline = power; s_hasLastPower = true;
return "기준값 " + WLPowerMeter.Format(power) + "(" + reason + ")";
}
if (power == s_lastPower) return "변화 없음";
s_lastPower = power;
return Evaluate(s, now, reason);
}
/// <summary>기준값 → 마지막 관측값의 차이를 보고 띄울지 결정한다(Observe · Tick 둘 다 여기로 온다).</summary>
private static string Evaluate(WLPowerDeltaSettings s, float now, string reason)
{
double from = s_baseline, to = s_lastPower;
if (from == to) return "변화 없음";
double pct = WLPowerMeter.PercentDelta(from, to);
// 하락은(기본 설정) 안 띄우되 기준값은 내려 준다 — 장비 해제 뒤 재장착이 가짜 상승으로 뜨지 않게.
if (pct < 0d && !s.showOnPowerDown) { s_baseline = to; SkippedDown++; return "하락 무시(" + pct.ToString("F2") + "%)"; }
double abs = pct < 0d ? -pct : pct;
// 하한 미만이면 기준값을 **그대로 둔다** = 다음 변화에 누적된다.
if (abs < Mathf.Max(0f, s.minPercentToShow)) { SkippedSmall++; return "변화 작음(" + pct.ToString("F2") + "% < " + s.minPercentToShow + "% · 누적 대기)"; }
// 쿨다운 안이면 기준값을 그대로 두고 다음 Tick 에서 다시 본다(연타 → 한 줄).
if (now - s_lastAnnounceAt < Mathf.Max(0f, s.announceCooldownSeconds)) { MergedByCooldown++; return "쿨다운 병합(기준 " + WLPowerMeter.Format(from) + ")"; }
return Announce(s, from, to, now, reason);
}
private static string Announce(WLPowerDeltaSettings s, double from, double to, float now, string reason)
{
double pct = WLPowerMeter.PercentDelta(from, to);
double abs = pct < 0d ? -pct : pct;
s_baseline = to;
s_lastAnnounceAt = now;
// 전투력이 올랐으면 「성장 이벤트」로 보고 타수 창을 새로 연다(레벨업이든 강화든 같다).
if (pct > 0d) MarkGrowth(now, "power " + WLPowerMeter.Format(from) + "→" + WLPowerMeter.Format(to));
// 레벨업 프레임이면 토스트를 띄우지 않는다 — 같은 값을 스탯 팝업의 전투력 줄이 이미 보여 준다(중복 표시 0).
if (s.suppressToastOnLevelUp && Time.frameCount == s_suppressToastFrame)
{
SuppressedByPopup++;
if (s.verboseLog) Debug.Log("[WL813l] 전투력 " + pct.ToString("F2") + "% — 레벨업 프레임이라 토스트 생략(팝업 줄이 표시)");
return "레벨업 프레임 — 팝업 줄이 표시(토스트 생략)";
}
string txt = DSUtil.Format(pct >= 0d ? s.powerUpFormat : s.powerDownFormat, WLPowerMeter.FormatPercent(abs, s));
PowerToastCount++;
string r = Push(txt, pct >= 0d ? s.powerUpColor : s.powerDownColor);
if (s.verboseLog)
Debug.Log("[WL813l] 전투력 " + WLPowerMeter.Format(from) + " → " + WLPowerMeter.Format(to) +
" (" + pct.ToString("F2") + "% · " + reason + ") · " + r);
return r;
}
// ── 813k 레벨업 ──────────────────────────────────────────────────────
private static void OnLevelUp(in PlayerLevelUpEvent e)
{
if (!WLPowerDeltaSettings.Enabled) return;
LevelUpSeen++;
float now = Time.unscaledTime;
// 팝업 줄이 읽을 「성장 직전 전투력」을 이 프레임에 못박는다(구독 순서 무관 · 아래 BuildStatPopupLine).
if (s_hasLastPower) { s_growthPrevPower = s_baseline; s_growthPrevFrame = Time.frameCount; }
s_suppressToastFrame = Time.frameCount; // 이 프레임의 전투력 토스트는 팝업 줄에 양보한다
// 레벨업은 「성장 이벤트」다 — 스탯이 그대로여도(원본 기본 동작 · 813k 보고 ①) 타수 창은 새로 연다.
MarkGrowth(now, "levelUp " + e.prevLevel + "→" + e.newLevel);
WLPowerMeter.Sample sm;
if (WLPowerMeter.TryReadMainPc(out sm)) Observe(sm.power, now, "levelUp");
}
// ── 처치 타수 계측 ───────────────────────────────────────────────────
private static void OnHitConfirmed(in HitConfirmedEvent e) { if (e.byMainPC) s_curHits++; }
private static void OnAttackStarted(in AttackStartedEvent e) { if (e.isMainPC) s_curAttacks++; }
private static void OnKilled(in KilledEvent e)
{
// 메인 PC 가 죽인 것만 센다(원본 killer 가 null 이면 셀 수 없다 = 표본에서 제외).
if (e.killer == null || !e.killer.IsMainPC()) return;
s_curKills++;
}
/// <summary>성장(레벨업·전투력 상승) 시점 — 지금까지의 창을 기준선으로 삼고 새 창을 연다.</summary>
public static string MarkGrowth(float now, string reason)
{
var s = St;
if (s == null || !s.hitsPerKillEnabled) return "타수 계측 off";
string before = "표본 부족";
if (s_curKills >= Mathf.Max(1, s.minKillsPerWindow))
{
s_beforeRatio = Numerator(s) / (float)s_curKills;
s_hasBefore = true;
before = "기준 타수 " + s_beforeRatio.ToString("F2") + "(" + Numerator(s) + "/" + s_curKills + ")";
}
ResetWindow(now);
if (s.verboseLog) Debug.Log("[WL813l] 성장 감지(" + reason + ") · " + before + " · 새 창 시작");
return before;
}
private static int Numerator(WLPowerDeltaSettings s)
{
return s.hitsSource == WLHitSource.AttackStarted ? s_curAttacks : s_curHits;
}
private static void ResetWindow(float now)
{
s_curHits = 0; s_curAttacks = 0; s_curKills = 0; s_windowStart = now;
}
private static void TickWindow(WLPowerDeltaSettings s, float now)
{
if (!s.hitsPerKillEnabled) return;
if (s_windowStart <= 0f) { s_windowStart = now; return; }
if (now - s_windowStart < Mathf.Max(1f, s.windowSeconds)) return;
CloseWindow(s, now);
}
/// <summary>창 하나를 마감한다(프로브가 60 s 를 기다리지 않고 직접 부른다).</summary>
public static string CloseWindow(WLPowerDeltaSettings s, float now)
{
if (s == null) return "설정 없음";
int kills = s_curKills;
int num = Numerator(s);
if (kills < Mathf.Max(1, s.minKillsPerWindow)) { ResetWindow(now); return "표본 부족(kills=" + kills + ") — 창 버림"; }
float after = num / (float)kills;
string r;
if (s_hasBefore)
{
int a = Mathf.Max(1, Mathf.RoundToInt(s_beforeRatio));
int b = Mathf.Max(1, Mathf.RoundToInt(after));
if (a - b >= Mathf.Max(1, s.compareMinDropHits))
{
s_armed = true; s_armFrom = a; s_armTo = b; s_armedAt = now;
r = "타수 감소 " + a + " → " + b + " · 비교 문구 무장";
}
else r = "타수 변화 없음(" + s_beforeRatio.ToString("F2") + " → " + after.ToString("F2") + ")";
s_hasBefore = false;
}
else
{
r = "기준선 갱신(" + after.ToString("F2") + ")";
}
s_beforeRatio = after; s_hasBefore = true;
ResetWindow(now);
if (s.verboseLog) Debug.Log("[WL813l] 창 마감 · " + num + "/" + kills + " = " + after.ToString("F2") + "방 · " + r);
return r;
}
private static void OnZoneCleared(in ZoneClearedEvent e)
{
if (!WLPowerDeltaSettings.Enabled) return;
ZoneClearedSeen++;
var s = St;
if (s == null || !s.showCompareOnZoneCleared) return;
ShowCompare(s, "존 " + (e.zoneIndex + 1) + " 클리어");
}
private static string ShowCompare(WLPowerDeltaSettings s, string reason)
{
if (!s_armed) return "무장 안 됨";
s_armed = false;
string txt = DSUtil.Format(s.hitsCompareFormat, s_armFrom, s_armTo);
LastCompareText = txt;
CompareToastCount++;
string r = Push(txt, s.hitsCompareColor);
if (s.verboseLog) Debug.Log("[WL813l] 타수 비교(" + reason + ") \"" + txt + "\" · " + r);
return r;
}
// ── 813y StatPopup 이 부르는 전투력 줄 ───────────────────────────────
/// <summary>
/// 레벨업 팝업에 붙일 「전투력 A → B (+N %)」 한 줄. 표시할 게 없으면 null.
/// 813y `StatPopup` 이 813k 페이로드를 받은 순간 부른다(값은 그때의 실측 스탯).
/// </summary>
public static string BuildStatPopupLine()
{
var s = St;
if (s == null || !WLPowerDeltaSettings.Enabled || !s.statPopupLineEnabled) return null;
WLPowerMeter.Sample sm;
if (!WLPowerMeter.TryReadMainPc(out sm)) return null;
double now = sm.power;
if (now <= 0d) return null;
// 이 프레임에 레벨업 스냅샷이 찍혔으면 그것을 쓴다(PowerDeltaToast 가 먼저 구독됐든 StatPopup 이 먼저든 같은 값).
double prev = s_growthPrevFrame == Time.frameCount ? s_growthPrevPower
: (s_hasLastPower ? s_baseline : now);
double pct = WLPowerMeter.PercentDelta(prev, now);
if (prev <= 0d || System.Math.Abs(pct) < 0.005d)
return DSUtil.Format(s.statPopupLineFormatNoChange, WLPowerMeter.Format(now));
string sign = pct >= 0d ? "+" : "-";
double abs = pct < 0d ? -pct : pct;
return DSUtil.Format(s.statPopupLineFormat, WLPowerMeter.Format(prev), WLPowerMeter.Format(now),
sign + WLPowerMeter.FormatPercent(abs, s));
}
// ── 진단 리셋(프로브 전용) ───────────────────────────────────────────
public static void ResetDiagnostics()
{
LevelUpSeen = PowerToastCount = CompareToastCount = ZoneClearedSeen = 0;
PollCount = ObserveCount = SkippedSmall = SkippedDown = MergedByCooldown = SuppressedByPopup = 0;
LastText = ""; LastCompareText = "";
s_lastPower = 0d; s_baseline = 0d; s_hasLastPower = false;
s_growthPrevPower = 0d; s_growthPrevFrame = -1; s_suppressToastFrame = -1;
s_lastAnnounceAt = -999f; s_nextPoll = 0f;
s_curHits = s_curAttacks = s_curKills = 0; s_windowStart = 0f;
s_beforeRatio = 0f; s_hasBefore = false; s_armed = false; s_armFrom = s_armTo = 0; s_armedAt = 0f;
HideAll();
}
// ── 프로브 전용 가짜 신호 (813g `LootToast.RaiseFakeKilled` 선례) ────
// `CombatEventList.Dispatch` 는 Assembly-CSharp internal 이라 AgentScripts 에서 못 부른다 →
// 구독 경로를 **그대로** 타는 헬퍼를 여기에 둔다(원본 Raise* 는 DamageInfo·사망 판정을 요구해 쓸 수 없다).
/// <summary>프로브 전용 — 811b 전투 신호(공격/적중/처치)를 가짜로 흘린다.</summary>
public static string RaiseFakeCombatForProbe(Actor pc, Actor victim, int hits, int kills)
{
for (int i = 0; i < hits; i++)
{
var a = new AttackStartedEvent { actor = pc, isMainPC = true, attackIndex = i, animSpeed = 1f, time = Time.unscaledTime, frame = Time.frameCount };
CombatEvents.AttackStarted.Dispatch(in a);
var h = new HitConfirmedEvent { victim = victim, attacker = pc, damage = 1d, byMainPC = true, time = Time.unscaledTime, frame = Time.frameCount };
CombatEvents.HitConfirmed.Dispatch(in h);
}
for (int i = 0; i < kills; i++)
{
var k = new KilledEvent { victim = victim, killer = pc, subRole = eSubRol.None, position = Vector3.zero, time = Time.unscaledTime, frame = Time.frameCount };
CombatEvents.Killed.Dispatch(in k);
}
return "hits=" + s_curHits + " attacks=" + s_curAttacks + " kills=" + s_curKills;
}
/// <summary>프로브 전용 — 813p `RunEvents.ZoneCleared` 를 가짜로 흘린다.</summary>
public static string RaiseFakeZoneClearedForProbe(int zoneIndex)
{
var e = new ZoneClearedEvent
{
zoneIndex = zoneIndex, spawnerId = 813001 + zoneIndex, elapsedSec = 0f,
kills = 0, required = 0, allZonesCleared = false, time = Time.unscaledTime, frame = Time.frameCount
};
RunEvents.ZoneCleared.Dispatch(in e);
return "dispatch ZoneCleared(" + zoneIndex + ") · 구독자=" + RunEvents.ZoneCleared.Count;
}
}
/// <summary>토스트 러너 — HUD 재생성 감지 + 매 프레임 Tick(813q `WLRunUiRunner` 선례).</summary>
internal sealed class PowerDeltaRunner : MonoBehaviour
{
private static PowerDeltaRunner s_ins;
private NewGameUI _lastUi;
internal static void Ensure()
{
if (s_ins != null) return;
var go = new GameObject("WL_PowerDeltaRunner");
go.hideFlags = HideFlags.HideAndDontSave;
Object.DontDestroyOnLoad(go);
s_ins = go.AddComponent<PowerDeltaRunner>();
}
private void Update()
{
var ui = NewGameUI.Ins;
if (ui != null && !ReferenceEquals(ui, _lastUi))
{
_lastUi = ui;
PowerDeltaToast.Bind(ui.transform);
}
PowerDeltaToast.Tick(Time.unscaledTime, Time.unscaledDeltaTime);
}
}
}