// ───────────────────────────────────────────────────────────────────────────── // 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 { /// /// 전투력 계산기 — 🔴 **원본에 전투력/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)`) /// public static class WLPowerMeter { /// 한 번의 측정 결과(전부 실측값 · 진단·프로브가 읽는다). 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"); } } /// 순수 함수 — 스탯 5개와 계수로 전투력을 만든다(Unity 객체 0 · 프로브가 그대로 검증한다). 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; } /// 원본 스탯 묶음에서 전투력을 읽는다(쓰기 0). 스탯이 없으면 false. 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; } /// 메인 PC 의 현재 전투력. PC 나 스탯이 없으면 false(로비·에디트 모드). 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); } /// 증가율(%) — prev 가 0 이하면 0(나눗셈 방지). public static double PercentDelta(double prev, double next) { if (prev <= 0d) return 0d; return (next - prev) / prev * 100d; } /// 전투력 표기(천 단위 구분 · 정수). public static string Format(double power) { return power.ToString("N0", CultureInfo.InvariantCulture); } /// 퍼센트 표기(자릿수는 SO). 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); } } /// /// 성장 체감 토스트 — 정적 클래스(813q `RunHud` 선례). 씬·프리팹에 노드를 굽지 않는다. /// 전투력 % 줄과 처치 타수 비교 줄이 같은 대역(SO `toastCenterOffsetPx`)에 쌓인다. /// 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(); } // ── 구독 ───────────────────────────────────────────────────────────── /// 813k 성장 축 · 811b 전투 신호 · 813p 런 축을 구독한다(전부 읽기 전용 구독). 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)"; } // ── 노드 연결 (런타임 생성) ────────────────────────────────────────── /// NewGameUI 루트 밑 HUD 경로에 토스트 노드를 만든다. 이미 물려 있으면 그대로. 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(); if (cg == null) cg = lrt.gameObject.AddComponent(); 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; } /// px → 유닛 환산은 813g `WLCombatTextSettings.UnitsPerPx` 하나만 쓴다(산식 중복 0). private static float UnitsPerPx() { var t = WLCombatTextSettings.Instance; if (t == null || s_root == null) return 1f; var canvas = s_root.GetComponentInParent(); 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; } } // ── 표시 ───────────────────────────────────────────────────────────── /// 한 줄 띄운다. 빈 줄이 없으면 가장 오래된 줄을 재활용한다(줄 파괴·생성 0). 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 + "\""; } /// 수명·페이드·펀치 + 폴링 + 타수 창 마감. 러너가 매 프레임 부른다. 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, "즉시"); } } // ── 전투력 변화 감지 ───────────────────────────────────────────────── /// /// 전투력 실값 하나를 넣는다(프로브도 이 경로를 쓴다). /// 증가율의 분모는 **마지막으로 표시한 값**(`s_baseline`)이다 — 그래서 /// · 하한 미만의 잔변화는 표시를 미루고 **다음 변화에 누적**된다(0.4% + 0.4% → 0.8% 로 뜬다) /// · 쿨다운 안의 연타는 한 줄로 합쳐지되 **이미 표시한 분은 두 번 세지 않는다** /// 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); } /// 기준값 → 마지막 관측값의 차이를 보고 띄울지 결정한다(Observe · Tick 둘 다 여기로 온다). 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++; } /// 성장(레벨업·전투력 상승) 시점 — 지금까지의 창을 기준선으로 삼고 새 창을 연다. 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); } /// 창 하나를 마감한다(프로브가 60 s 를 기다리지 않고 직접 부른다). 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 이 부르는 전투력 줄 ─────────────────────────────── /// /// 레벨업 팝업에 붙일 「전투력 A → B (+N %)」 한 줄. 표시할 게 없으면 null. /// 813y `StatPopup` 이 813k 페이로드를 받은 순간 부른다(값은 그때의 실측 스탯). /// 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·사망 판정을 요구해 쓸 수 없다). /// 프로브 전용 — 811b 전투 신호(공격/적중/처치)를 가짜로 흘린다. 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; } /// 프로브 전용 — 813p `RunEvents.ZoneCleared` 를 가짜로 흘린다. 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; } } /// 토스트 러너 — HUD 재생성 감지 + 매 프레임 Tick(813q `WLRunUiRunner` 선례). 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(); } 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); } } }