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

902 lines
51 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` 선례.
//
// ■ 🔴 WL-813l2 — Q6 D-28(레벨 1→16 인데 `LastPower 52.000000` 불변 · 토스트 0회) 원인과 수리
// 원인(실측 · 「추정」 아님):
// · `ActorStatInfo.Reset`(`Assets/Script/Util/MyValue.cs:286-453`)이 **`SD_PC.Lv` 를 한 번도 안 읽는다.**
// PC 스탯은 `table_classconfig.Get_PureStat`(`Table/PC/table_classconfig.cs:232-248`
// = 보유 직업 기본치 + **사용한** 포인트 `SD_PC.Get_Stat`) + 장비·각성·초월·유물에서만 만들어진다.
// · 레벨업(`Server/ServerClass.cs:2725-2739 Add_Exp`)은 `++Lv` 뿐 — 스탯은 안 건드리고
// `Get_Point()`(`:2770` = `(Lv1)*5 사용분`)의 **미사용 포인트만** 늘린다.
// 포인트를 실제로 찍는 곳은 수동 UI `PCInfoStatMgrUI.cs:81` 하나뿐이다(자동 배분 0).
// → 813l 이 레벨업 때도 `ActorStatInfo` 를 **다시 읽기만** 해서(`Observe(sm.power)`) 값이 상수 = 표시 0회.
// 폴링은 살아 있었다(Q6 `PollCount 1573`) — **읽는 값이 안 움직인 것**이다.
// 수리(발주서 WL-813l2 §1-2):
// ① 레벨업 경로는 813k `GrowthEvents.LevelUp` **페이로드**(prev/new 스탯 3종 · prevLevel/newLevel ·
// statPointTotal)로 성장 전/후 전투력을 만든다 — 813y `StatPopup` 이 읽는 것과 **같은 값**이다.
// ② 산식 입력에 **미사용 스탯 포인트**를 넣는다. 원본 실측 환산(주스탯 1점 → `ATK +1`·`MaxHP +22` ·
// `MyValue.cs:335-336`)을 그대로 써서, 포인트를 찍어도 전투력이 튀지 않는다(잠재→실현 연속).
// 가중치·환산·레벨 기여는 전부 SO(`statPointWeight`·`statPointAtkGain`·`statPointHpGain`·`levelWeight`).
// ③ 장비/강화 경로는 그대로 폴링이다 — 실측으로 **폴링이 옳다**는 것을 확인했다(아래 항).
//
// ■ 🔴 장비/강화 변경 이벤트는 원본에 없다(실측)
// `ServerClass.Set_Equip`(`Assets/Script/Server/ServerClass.cs:2329`)·`ActorStatInfo.Reset` 어디에도
// 구독 가능한 신호가 없고, 원본 파일 수정은 금지다. → 발주서 §1-2 대안대로 **폴링**한다
// (`pollIntervalSeconds` · 기본 0.5 s). 폴링 경로는 값 비교뿐이라 **할당 0**(GC 0) — 변화가 있을 때만 문자열이 난다.
// 🔴 813l2 재실측 — **폴링이 실제로 잡는다**: 장비 착용·강화·각성·초월·유물·스탯 포인트 사용이 전부
// `MyValue.Get_ActorStatInfoorNull(eRole.PC)`(예: `UI/Eqiupment/EquipmentPopup_Obtain.cs:85` ·
// `EquipmentItemPopup.cs:77` · `EquipmentUI.cs:96` · `PCInfoStatMgrUI.cs:85`)를 부르고, 그게
// `MyValue.cs:122` 에서 **공유 캐시 인스턴스**(`dic_ActorStatInfo[(PC,"0",0)]`)에 `Reset()` 을 건다.
// 인게임 PC 도 `Actor.cs:322` 로 **같은 인스턴스**를 물고 있다 → 다음 폴링(≤0.5 s)에 값이 바뀐다.
//
// ■ 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;
/// <summary>WL-813l2 성장 입력 — 레벨(`SD_PC.Lv`)과 미사용 스탯 포인트(`SD_PC.Get_Point()`).</summary>
public double level, statPoints;
/// <summary>공격력이 1순위 스탯(BaseDmg)이 아니라 폴백(ATK)에서 왔는가 — 페이로드 환산 방식이 갈린다.</summary>
public bool atkFromFallback;
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") + " lv=" + level.ToString("F0") +
" pt=" + statPoints.ToString("F0") + " → power=" + power.ToString("F1");
}
}
/// <summary>순수 함수(성장 입력 없음) — 813l 원래 산식. 아래 8인자 판에 0/0 을 넘긴다.</summary>
public static double Compute(double atk, double critRate, double critDamage, double aspd, double hp, WLPowerDeltaSettings s)
{
return Compute(atk, critRate, critDamage, aspd, hp, 0d, 0d, s);
}
/// <summary>
/// 순수 함수 — 스탯 5개 + 성장 입력 2개(레벨 · 미사용 스탯 포인트)와 계수로 전투력을 만든다
/// (Unity 객체 0 · 프로브가 그대로 검증한다).
///
/// 🔴 WL-813l2 — 미사용 스탯 포인트를 넣는 이유는 **원본이 레벨업으로 스탯을 안 올리기 때문**이다
/// (실측 근거는 파일 머리 주석). 포인트를 「아직 안 쓴 힘」으로 보고 원본 실측 환산
/// (주스탯 1점 → `ATK +1` · `MaxHP +22` · `MyValue.cs:335-336`)으로 산식에 그대로 넣으면,
/// 포인트를 실제로 찍는 순간(포인트 1 · 스탯 +1/+22) 전투력이 **튀지 않는다**(잠재→실현 연속).
/// `statPointWeight = 0` 이면 813l 원래 산식과 완전히 같다(C8).
/// </summary>
public static double Compute(double atk, double critRate, double critDamage, double aspd, double hp,
double level, double statPoints, 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 pts = statPoints > 0d ? statPoints * s.statPointWeight : 0d;
if (pts < 0d) pts = 0d;
double p = (atk + pts * s.statPointAtkGain) * critTerm * aspdTerm
+ (hp + pts * s.statPointHpGain) * s.hpWeight;
double lv = level - s.levelBase;
if (lv > 0d) p += lv * s.levelWeight;
return p * s.powerScale;
}
/// <summary>원본 스탯 묶음에서 전투력을 읽는다(쓰기 0). 스탯이 없으면 false. 성장 입력은 0 = 스탯만.</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.atkFromFallback = true; }
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, o.level, o.statPoints, s);
o.valid = true;
return true;
}
/// <summary>
/// WL-813l2 — 서버 데이터에서 성장 입력(레벨 · 미사용 스탯 포인트)을 채우고 전투력을 다시 만든다.
/// 🔴 **할당 0**: 원본 `SD_PC.Get_Point()`(`ServerClass.cs:2769-2770`)는 LINQ(`Select`+`Sum`)라
/// 폴링마다 가비지가 난다 → **같은 식을 for 루프로 다시 센다**(값 동일 · GC 0).
/// 「레벨당 5포인트」는 코드 상수가 아니라 SO(`statPointsPerLevel` · 원본 실측 5)다.
/// </summary>
public static void ApplyGrowth(SD_PC pc, WLPowerDeltaSettings s, ref Sample o)
{
if (s == null) return;
if (pc != null && s.readGrowthFromServerData)
{
int lv = (int)pc.Lv;
o.level = lv;
int used = 0;
var lst = pc.Stat;
// ObscuredInt 는 int 와 양방향 암시 변환이 있어 연산자가 모호해진다 → 명시 캐스트로 고정.
if (lst != null) for (int i = 0; i < lst.Count; i++) used += (int)lst[i];
int pts = (lv - 1) * s.statPointsPerLevel - used;
o.statPoints = pts > 0 ? pts : 0;
}
o.power = Compute(o.atk, o.critRate, o.critDamage, o.aspd, o.hp, o.level, o.statPoints, s);
}
/// <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;
if (!TryRead(pc.Get_StatInfo(), s, out o)) return false;
// 🔴 `ServerInfo.Ins` 는 게터가 GameObject 를 새로 만든다(부작용 · `Server/ServerInfo.cs:19`) →
// 쓰지 않는다. PC 가 이미 물고 있는 `ServerData` 만 읽는다(`Actor.cs:987` · 읽기 전용).
var sd = pc.Get_ServerData();
ApplyGrowth(sd != null ? sd.PC : null, s, ref o);
return true;
}
/// <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 이 어떤 순서로 구독됐든 같은 값을 보게 한다.
// 🔴 813l2: 직후 값도 못박는다 — 팝업이 그때 `ActorStatInfo` 를 다시 읽으면 레벨업 분이 안 보인다(D-28).
private static double s_growthPrevPower;
private static double s_growthNewPower;
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 = "";
// WL-813l2 진단 — 레벨업 전투력이 어느 소스에서 왔는가(페이로드/스탯 재읽기/실패) · 그때의 prev·new.
public static string LastPowerSource = "";
public static double LastPayloadPrev, LastPayloadNew;
public static double LastLevelSeen, LastStatPointsSeen;
public static int PayloadUsed, PayloadMissed;
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))
{
// 진단용 대입뿐 — 문자열·박싱 0(GC 0 유지).
LastLevelSeen = sm.level; LastStatPointsSeen = sm.statPoints;
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 레벨업 (🔴 WL-813l2 = D-28 수리 지점) ───────────────────────
private static void OnLevelUp(in PlayerLevelUpEvent e)
{
if (!WLPowerDeltaSettings.Enabled) return;
LevelUpSeen++;
var s = St;
float now = Time.unscaledTime;
s_suppressToastFrame = Time.frameCount; // 이 프레임의 전투력 토스트는 팝업 줄에 양보한다
// 레벨업은 「성장 이벤트」다 — 스탯이 그대로여도(원본 기본 동작 · 813k 보고 ①) 타수 창은 새로 연다.
MarkGrowth(now, "levelUp " + e.prevLevel + "→" + e.newLevel);
double prevP, newP;
string src;
if (s != null && s.useLevelUpPayload && TryPayloadPower(s, in e, out prevP, out newP))
{
PayloadUsed++;
src = "levelUp(813k 페이로드)";
}
else
{
// 폴백 = 813l 원래 동작. 🔴 원본은 레벨업으로 스탯을 안 올리므로 여기로 떨어지면 값이 안 움직인다(D-28).
WLPowerMeter.Sample sm;
if (!WLPowerMeter.TryReadMainPc(out sm))
{
PayloadMissed++; LastPowerSource = "levelUp(스탯 읽기 실패)";
return;
}
PayloadMissed++;
newP = sm.power;
prevP = s_hasLastPower ? s_baseline : newP;
src = "levelUp(스탯 재읽기)";
}
LastPowerSource = src; LastPayloadPrev = prevP; LastPayloadNew = newP;
LastLevelSeen = e.newLevel; LastStatPointsSeen = e.statPointTotal;
// 기준값이 아직 없으면 페이로드의 prev 로 연다 — 첫 레벨업이 「기준값 세우기」로 삼켜지지 않게.
if (!s_hasLastPower) { s_lastPower = prevP; s_baseline = prevP; s_hasLastPower = true; }
// 팝업 줄이 읽을 값을 이 프레임에 못박는다(813y StatPopup 과 구독 순서 무관 · 아래 BuildStatPopupLine).
// prev 는 「마지막으로 **표시한** 값」(s_baseline)이라 토스트와 이중 계상이 나지 않는다.
s_growthPrevPower = s_baseline;
s_growthNewPower = newP;
s_growthPrevFrame = Time.frameCount;
Observe(newP, now, src);
}
/// <summary>
/// 🔴 WL-813l2 — 813k `LevelUp` 페이로드로 성장 전/후 전투력을 만든다(D-28 수리의 핵심).
/// 레벨업 순간 `ActorStatInfo` 를 다시 읽으면 **prev == new** 다(원본이 레벨업으로 스탯을 안 올린다).
/// 그래서 ① 스탯 3종은 페이로드의 `prevStatX`/`newStatX`(= 813y `StatPopup` 이 읽는 바로 그 값)
/// ② 레벨은 `prevLevel`/`newLevel` ③ 미사용 포인트는 `statPointTotal` 과 그 역산값을 쓴다.
/// </summary>
private static bool TryPayloadPower(WLPowerDeltaSettings s, in PlayerLevelUpEvent e, out double prevP, out double newP)
{
prevP = 0d; newP = 0d;
WLPowerMeter.Sample cur;
if (!WLPowerMeter.TryReadMainPc(out cur))
{
// 월드에 PC 가 없다(로비·오프라인 보상·에디트 모드) — 페이로드만으로 만든다.
cur = default(WLPowerMeter.Sample);
cur.aspd = s.attackSpeedBase; // 기준 배율 = 공속 기여 0
}
// 산식이 BaseDmg(1순위)를 쓰는 중인가 — 페이로드가 ATK 를 줄 때 환산 방식이 갈린다.
bool atkPrimary = cur.valid && !cur.atkFromFallback && cur.atk > 0d;
var nw = cur; var pv = cur;
ApplyPayloadStat(s, ref nw, e.statAKind, e.newStatA, e.newStatA, atkPrimary);
ApplyPayloadStat(s, ref nw, e.statBKind, e.newStatB, e.newStatB, atkPrimary);
ApplyPayloadStat(s, ref nw, e.statCKind, e.newStatC, e.newStatC, atkPrimary);
ApplyPayloadStat(s, ref pv, e.statAKind, e.prevStatA, e.newStatA, atkPrimary);
ApplyPayloadStat(s, ref pv, e.statBKind, e.prevStatB, e.newStatB, atkPrimary);
ApplyPayloadStat(s, ref pv, e.statCKind, e.prevStatC, e.newStatC, atkPrimary);
// 「레벨업 직전 미사용 포인트」 역산 — 원본 Get_Point() = (Lv1)*5 사용분 이므로
// 사용분이 그대로면 오른 레벨 수 × 레벨당 포인트만큼 적었다.
int gained = e.newLevel - e.prevLevel; if (gained < 0) gained = 0;
int per = s.statPointsPerLevel > 0 ? s.statPointsPerLevel : 0;
int prevPts = e.statPointTotal - gained * per; if (prevPts < 0) prevPts = 0;
newP = WLPowerMeter.Compute(nw.atk, nw.critRate, nw.critDamage, nw.aspd, nw.hp, e.newLevel, e.statPointTotal, s);
prevP = WLPowerMeter.Compute(pv.atk, pv.critRate, pv.critDamage, pv.aspd, pv.hp, e.prevLevel, prevPts, s);
return newP > 0d || prevP > 0d;
}
/// <summary>페이로드 스탯 1개를 산식 입력에 얹는다. 산식에 없는 스탯(예 MaxMP)은 그냥 버린다.</summary>
private static void ApplyPayloadStat(WLPowerDeltaSettings s, ref WLPowerMeter.Sample o,
eStat kind, double v, double refNew, bool atkPrimary)
{
if (kind == s.hpStat) { o.hp = v; return; }
if (kind == s.critRateStat) { o.critRate = v; return; }
if (kind == s.critDamageStat) { if (s.useCritDamage) o.critDamage = v; return; }
if (kind == s.attackSpeedStat) { o.aspd = v; return; }
if (kind == s.atkStat) { o.atk = v; return; }
if (kind == s.atkFallbackStat)
{
// 산식이 BaseDmg(= ATK × ATK_Multiplier × DMG_Multiplier · 실측 MyValue.cs:384-386)를 쓰는 중이면
// 배율을 모르는 채 ATK 로 덮어쓸 수 없다 → 같은 비율로 환산한다.
if (!atkPrimary) o.atk = v;
else if (refNew > 0d) o.atk = o.atk * (v / refNew);
}
}
// ── 처치 타수 계측 ───────────────────────────────────────────────────
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;
// 이 프레임에 레벨업 스냅샷이 찍혔으면 **그 값만** 쓴다(PowerDeltaToast 가 먼저 구독됐든 StatPopup 이 먼저든 같다).
// 🔴 813l2: 여기서 `ActorStatInfo` 를 다시 읽으면 안 된다 — 원본은 레벨업으로 스탯을 안 올려서
// prev == new 가 되고 팝업 줄이 「변화 없음」으로 굳는다(D-28 과 같은 뿌리).
bool stamped = s_growthPrevFrame == Time.frameCount;
double now;
if (stamped) now = s_growthNewPower;
else
{
WLPowerMeter.Sample sm;
if (!WLPowerMeter.TryReadMainPc(out sm)) return null;
now = sm.power;
}
if (now <= 0d) return null;
double prev = stamped ? 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 = "";
LastPowerSource = ""; LastPayloadPrev = LastPayloadNew = 0d;
LastLevelSeen = LastStatPointsSeen = 0d; PayloadUsed = PayloadMissed = 0;
s_lastPower = 0d; s_baseline = 0d; s_hasLastPower = false;
s_growthPrevPower = 0d; s_growthNewPower = 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>
/// 프로브 전용(WL-813l2) — 813k `GrowthEvents.LevelUp` 을 **실제로 Dispatch** 한다(구독 경로 그대로).
/// 813y `StatPopup.RaiseFakeGrowthLevelUp` 은 스탯 3종이 고정이라 「prev ATK 100 → new 112」를 못 만든다.
/// </summary>
public static string RaiseFakeLevelUpForProbe(int prevLevel, int newLevel, int statPointTotal,
eStat kindA, double prevA, double newA,
eStat kindB, double prevB, double newB,
eStat kindC, double prevC, double newC)
{
var e = new PlayerLevelUpEvent
{
data = null, pc = null,
prevLevel = prevLevel, newLevel = newLevel,
levelsGained = Mathf.Max(1, newLevel - prevLevel),
statPointTotal = statPointTotal,
statAKind = kindA, prevStatA = prevA, newStatA = newA,
statBKind = kindB, prevStatB = prevB, newStatB = newB,
statCKind = kindC, prevStatC = prevC, newStatC = newC,
hpBefore = 0d, maxHp = 0d, mpBefore = 0d, maxMp = 0d,
position = Vector3.zero, time = Time.unscaledTime, frame = Time.frameCount,
};
GrowthEvents.LevelUp.Dispatch(in e);
return "dispatch GrowthEvents.LevelUp " + prevLevel + "→" + newLevel +
" pt=" + statPointTotal + " · 구독자=" + GrowthEvents.LevelUp.Count;
}
/// <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);
}
}
}