// ─────────────────────────────────────────────────────────────────────────────
// 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` = `(Lv−1)*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
{
///
/// 전투력 계산기 — 🔴 **원본에 전투력/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;
/// WL-813l2 성장 입력 — 레벨(`SD_PC.Lv`)과 미사용 스탯 포인트(`SD_PC.Get_Point()`).
public double level, statPoints;
/// 공격력이 1순위 스탯(BaseDmg)이 아니라 폴백(ATK)에서 왔는가 — 페이로드 환산 방식이 갈린다.
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");
}
}
/// 순수 함수(성장 입력 없음) — 813l 원래 산식. 아래 8인자 판에 0/0 을 넘긴다.
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);
}
///
/// 순수 함수 — 스탯 5개 + 성장 입력 2개(레벨 · 미사용 스탯 포인트)와 계수로 전투력을 만든다
/// (Unity 객체 0 · 프로브가 그대로 검증한다).
///
/// 🔴 WL-813l2 — 미사용 스탯 포인트를 넣는 이유는 **원본이 레벨업으로 스탯을 안 올리기 때문**이다
/// (실측 근거는 파일 머리 주석). 포인트를 「아직 안 쓴 힘」으로 보고 원본 실측 환산
/// (주스탯 1점 → `ATK +1` · `MaxHP +22` · `MyValue.cs:335-336`)으로 산식에 그대로 넣으면,
/// 포인트를 실제로 찍는 순간(포인트 −1 · 스탯 +1/+22) 전투력이 **튀지 않는다**(잠재→실현 연속).
/// `statPointWeight = 0` 이면 813l 원래 산식과 완전히 같다(C8).
///
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;
}
/// 원본 스탯 묶음에서 전투력을 읽는다(쓰기 0). 스탯이 없으면 false. 성장 입력은 0 = 스탯만.
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;
}
///
/// WL-813l2 — 서버 데이터에서 성장 입력(레벨 · 미사용 스탯 포인트)을 채우고 전투력을 다시 만든다.
/// 🔴 **할당 0**: 원본 `SD_PC.Get_Point()`(`ServerClass.cs:2769-2770`)는 LINQ(`Select`+`Sum`)라
/// 폴링마다 가비지가 난다 → **같은 식을 for 루프로 다시 센다**(값 동일 · GC 0).
/// 「레벨당 5포인트」는 코드 상수가 아니라 SO(`statPointsPerLevel` · 원본 실측 5)다.
///
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);
}
/// 메인 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;
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;
}
/// 증가율(%) — 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 이 어떤 순서로 구독됐든 같은 값을 보게 한다.
// 🔴 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();
}
// ── 구독 ─────────────────────────────────────────────────────────────
/// 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