From 80c0ee3ad871f894fef2d39eea859c74589dacf6 Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 12:56:15 +0900 Subject: [PATCH] =?UTF-8?q?[WL-813l]=20=EC=84=B1=EC=9E=A5=20=EC=B2=B4?= =?UTF-8?q?=EA=B0=90:=20=EC=A0=84=ED=88=AC=EB=A0=A5=20%=20=ED=86=A0?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=C2=B7=20=ED=8C=9D=EC=97=85=20=EC=A4=84=20?= =?UTF-8?q?=C2=B7=20=ED=83=80=EC=88=98=20=EB=B9=84=EA=B5=90=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AgentScripts/WL813l_PROBE.txt | 92 +++ AgentScripts/WL813l_Probe.cs | 551 ++++++++++++++ Assets/WL/UI/Scripts/PowerDeltaToast.cs | 689 ++++++++++++++++++ Assets/WL/UI/Scripts/PowerDeltaToast.cs.meta | 2 + Assets/WL/UI/Scripts/StatPopup.cs | 51 +- .../Resources/WL/WLPowerDeltaSettings.asset | 71 ++ .../WL/WLPowerDeltaSettings.asset.meta | 8 + Assets/WL/UI/Settings/WLPowerDeltaSettings.cs | 254 +++++++ .../UI/Settings/WLPowerDeltaSettings.cs.meta | 2 + 9 files changed, 1718 insertions(+), 2 deletions(-) create mode 100644 AgentScripts/WL813l_PROBE.txt create mode 100644 AgentScripts/WL813l_Probe.cs create mode 100644 Assets/WL/UI/Scripts/PowerDeltaToast.cs create mode 100644 Assets/WL/UI/Scripts/PowerDeltaToast.cs.meta create mode 100644 Assets/WL/UI/Settings/Resources/WL/WLPowerDeltaSettings.asset create mode 100644 Assets/WL/UI/Settings/Resources/WL/WLPowerDeltaSettings.asset.meta create mode 100644 Assets/WL/UI/Settings/WLPowerDeltaSettings.cs create mode 100644 Assets/WL/UI/Settings/WLPowerDeltaSettings.cs.meta diff --git a/AgentScripts/WL813l_PROBE.txt b/AgentScripts/WL813l_PROBE.txt new file mode 100644 index 000000000..abdee9438 --- /dev/null +++ b/AgentScripts/WL813l_PROBE.txt @@ -0,0 +1,92 @@ +# WL813l Probe 2026-09-09 12:54:55 (edit mode · Play 0 · 로그인 0) +playMode=False + +── ⓪ 설정 에셋 (C45 · 값 전부 SO) + [OK] 에셋 로드 Assets/WL/UI/Settings/Resources/WL/WLPowerDeltaSettings.asset + enabled_=True toastEnabled=True statPopupLineEnabled=True hitsPerKillEnabled=True + 산식 스탯: atk=BaseDmg(폴백 ATK) cri=CRI criDmg=CRI_DMG_Multiplier(useCritDamage=True) aspd=ATK_SPD_Multiplier(base 1) hp=MaxHP + 계수: critWeight=1 aspdWeight=1 hpWeight=0.1 powerScale=1 + 토스트: offset=430px lines=2 font=52px 2s poll=0.5s 최소 0.5% + 타수: source=HitConfirmed window=60s minKills=5 감소 하한 1방 · 존 클리어 표시=True + 문구: "전투력 +{0}%" / "전투력 {0} → {1} ({2}%)" / "이제 {0}방 → {1}방" + [OK] WLPowerDeltaSettings.Enabled + [OK] 대역 회피 — 내 토스트 [340, 460] · 스탯 팝업 위 328 · 연쇄 아래 472 · 전리품 -120 + +── 준비 + [OK] 가짜 메인 PC IsMainPC()=true · MyValue.bMyPC=True + [OK] 구독 ON · PC 스탯=null(에디트 모드 — 산식은 §① 에서 손으로 채운 ActorStatInfo 로 잰다) + +── ① 전투력 산식 (🔴 원본에 CP 없음 = 「추정」 · 발주서 §1-1 대안) + [OK] atk100 cri0 aspd1 hp1000 → 200.00 (기대 200.00) + [OK] 치명 20%×50% → 210.00 (기대 210.00 · +5%) + [OK] 공속 1.3 → 230.00 (기대 230.00 · +15%) + [OK] 전 스탯 2배급 → 486.00 (기대 486.00) + [OK] 증가율 200→230 = 15.00% (기대 15.00) + [OK] 이전 0 → 0%(나눗셈 방지) + [OK] ActorStatInfo 읽기 → atk=500.0 cri=0.150 criDmg=0.600 aspd=1.200 hp=8000.0 → power=1454.0 (기대 1454.00) + [OK] BaseDmg 우선(ATK 400 이 아니라 500) + [OK] BaseDmg 0 → ATK 폴백 400 + [OK] 스탯 null → false(로비 안전) + +── ② 전투력 % 토스트 + 기준값 1,000(probe) ← 기준값 + [OK] 1000 → 1150 · 문구 "전투력 +15.0%" · 노드 없음(문구만 기록) — "전투력 +15.0%" + [OK] 잡음 차단 — +0.43% 는 안 띄운다(SkippedSmall=1) + [OK] 하락 무시(showOnPowerDown=False · SkippedDown=1) + [OK] 쿨다운 병합 1건(연타해도 토스트가 도배되지 않는다) + [OK] 마감 문구 "전투력 +10.0%" (직전 표시값 1100 → 1210 = +10.0% · 중복 계상 0) + [OK] 잔변화 누적 — 0.40% 대기 후 0.80% 로 표시 "전투력 +0.8%" + [OK] 레벨업 프레임 토스트 생략(중복 표시 0 · Suppressed=1 Toast=0) + [OK] GrowthEvents.LevelUp 수신 1회(PC 스탯이 없어 전투력 값은 안 읽힌다 = 에디트 모드 한계) + Bound=False (에디트 모드 · NewGameUI 없음 — 문구만 기록) + +── ③ 레벨업 팝업 전투력 줄 (813y StatPopup · 1줄 추가) + init: 구독 ON · box pos=(0.00, 180.00) size=(1080.00, 296.00) rows=3 power=on unitsPerPx=1.0000 titleFont=72.0 edge[LiberationSans SDF Material (WL813y Edge) shader=TextMeshPro/Mobile/Distance Field outlineW=0.260 outlineC=0D0805FF underlay=000000BF off(0.45,-0.45) soft=0.20 UNDERLAY_ON=True] + [OK] StatPopup.PowerLineOn(에셋 · enabled_ · statPopupLineEnabled) + [OK] 배치 — box pos=(0.00, 180.00) size=(1080.00, 296.00) rows=3 power=on unitsPerPx=1.0000 titleFont=72.0 edge[LiberationSans SDF Material (WL813y Edge) shader=TextMeshPro/Mobile/Distance Field outlineW=0.260 outlineC=0D0805FF underlay=000000BF off(0.45,-0.45) soft=0.20 UNDERLAY_ON=True] + [OK] PowerRow 노드 생성(스탯 줄 3개는 그대로) + [OK] 마지막 스탯 줄 아래 y=-254.4(스탯 줄 -198.4) · font=44.0 color=RGBA(1.000, 0.840, 0.350, 1.000) + Show: level=7 rows=1 power="" text="LEVEL 7 / MaxHP +12" pos=(0.00, 180.00) + [OK] 스탯 줄 1개(813y 동작 불변) + 전투력 줄="" (PC 스탯 null → 빈 줄이 정상 · 실값은 인게임 QA) + [OK] 문구 조립 "전투력 12,000 → 13,800 (+15.0%)" + [OK] 변화 없을 때 "전투력 12,000" + +── ③-b 토스트 노드 배치 (프리팹 diff 0 · 런타임 생성) + bind: 연결 IngameUIs/WL_HUD · lines=2 offset=430px unitsPerPx=1.0000 font=52.0 edge[LiberationSans SDF Material (WL813y Edge) shader=TextMeshPro/Mobile/Distance Field outlineW=0.260 outlineC=08080FFF underlay=00000000 off(0.55,-0.55) soft=0.20 UNDERLAY_ON=False] + [OK] 노드 연결(부모 경로 "IngameUIs/WL_HUD") + [OK] WL_PowerDeltaToast 노드 + [OK] hideFlags=DontSave(씬·프리팹에 굽지 않는다) · layer=5 + [OK] 줄 노드 2개(SO toastLines=2) + Line0 y=430.0 size=(1080.00, 60.00) alpha=0.00 font=52.0 + Line1 y=370.0 size=(1080.00, 60.00) alpha=0.00 font=52.0 + [OK] 표시 "전투력 +25.0%" alpha=1.00 color=RGBA(1.000, 0.840, 0.350, 1.000) scale=1.250(펀치) + [OK] 둘째 줄 "이제 3방 → 2방" y=370.0(첫 줄 430.0 · 간격 60px) + [OK] 2s 뒤 전부 사라짐(alpha=0.00 · 보이는 줄 0) + +── ④ 처치 타수 비교 (기준서 §E 5-3 「타수 −1」) + 창1 누적 hits=30 attacks=30 kills=10 + [OK] 성장 시점에 기준선 확정 — 기준 타수 3.00(30/10) + [OK] 새 창 리셋(kills=0) + [OK] 창2 마감 — 타수 감소 3 → 2 · 비교 문구 무장 + [OK] ZoneCleared → "이제 3방 → 2방" (1회) + [OK] 다음 존에서 다시 뜨지 않는다(무장 1회 소모 · 1회) + [OK] 표본 부족(kills=2) — 창 버림 (minKills=5) + [OK] 3.00 → 2.90 은 무장 안 함 — 타수 변화 없음(3.00 → 2.90) + [OK] 몹이 죽인 것은 표본에서 제외(kills=0) + +── ⑤ GC 0 (폴링 · 카운터) + [OK] Tick × 1000 → GetTotalMemory 0 B · MonoUsedSize 0 B (폴링 2회) + [OK] AttackStarted+HitConfirmed+Killed 2,200발 → 0 B · 0 B (헬퍼 반환 문자열 1개 제외하면 카운터는 int 증가뿐 · hits=1000) + 대조군 new object() × 1000 = 20480 B (0 이 아니어야 측정이 살아 있다) · keep=1000 + +── ⑥ C8 롤백 (WLPowerDeltaSettings.enabled_ = 0 → 표시 0) + [OK] Enabled=false + [OK] 전투력 2배 변화에도 토스트 0회 + [OK] 레벨업 무시(LevelUpSeen=0) + [OK] StatPopup.PowerLineOn=false → 팝업 전투력 줄 0 + [OK] 팝업 전투력 줄 ""(빈 문자열) + [OK] BuildStatPopupLine()=null + [OK] 롤백 해제 — 다시 Enabled=true + +RESULT PASS diff --git a/AgentScripts/WL813l_Probe.cs b/AgentScripts/WL813l_Probe.cs new file mode 100644 index 000000000..f4c9177e6 --- /dev/null +++ b/AgentScripts/WL813l_Probe.cs @@ -0,0 +1,551 @@ +// WL813l_Probe.cs — #813l(성장 체감: 전투력 % 토스트 · 레벨업 팝업 전투력 줄 · 처치 타수 비교) 에디트 모드 검증 프로브 +// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬 무수정(임시 GameObject 는 즉시 파괴) +// 유일한 에셋 쓰기 = `Assets/WL/UI/Settings/Resources/WL/WLPowerDeltaSettings.asset` **생성**(발주 산출물 · EnsureAsset) +// +// 상주 에디터: +// unity command run_script --project-path --file AgentScripts/WL813l_Probe.cs --entry WL813l_Probe.RunAll --timeout 600 +// 결과: Console + AgentScripts/WL813l_PROBE.txt (RESULT PASS / RESULT FAIL n) +// +// 검사 (발주서 WL-813l §1 · §2) +// ⓪ 에셋 생성·로드 · 값 전수 +// ① 전투력 산식(순수 함수 · 원본 스탯 규약 실측 대조) + 실제 ActorStatInfo 읽기 +// ② 전투력 % 토스트 — 가짜 LevelUp · 값 변화 · 하락 무시 · 잡음 차단 · 쿨다운 병합 +// ③ 레벨업 팝업 전투력 줄 — 813y StatPopup 에 실제로 한 줄이 붙는가(구독 순서 무관) +// ④ 처치 타수 비교 — 성장 전후 창 · "이제 N방 → M방" · 813p ZoneCleared 에서 1회 +// ⑤ GC 0 — 폴링 Tick × 1000 · 카운터 이벤트 × 1000 +// ⑥ C8 — WLPowerDeltaSettings.enabled_ = 0 → 표시 0 · 팝업 줄 0 +// +// 에디트 모드 한계(보고서 「미확인」으로 올린다) +// · 진짜 PC 는 ServerData 없이 m_Stat 이 안 만들어진다 → 산식은 손으로 채운 ActorStatInfo 로 검증한다. +// · Canvas 가 없어 UnitsPerPx = 1(px == 유닛). 실제 배치 px 는 병합 후 QA 캡처로 본다. + +using System; +using System.IO; +using System.Text; +using UnityEngine; +using UnityEngine.UI; +using TMPro; +using WL.Combat.Core; +using WL.Combat.Growth; +using WL.Combat.Run; +using WL.UI; +using UnityEditor; + +public static class WL813l_Probe +{ + const string OutCommit = "AgentScripts/WL813l_PROBE.txt"; + const string AssetPath = "Assets/WL/UI/Settings/Resources/WL/WLPowerDeltaSettings.asset"; + + static StringBuilder sb; + static int s_fail; + + static GameObject s_pcGo, s_mobGo, s_canvasGo; + static PCActor s_pc; + static Actor s_mob; + static PCActor s_prevMyPc; + static bool s_prevBMyPc; + static StatPopup s_popup; + + public static object RunAll() + { + sb = new StringBuilder(); + s_fail = 0; + sb.AppendLine("# WL813l Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)"); + sb.AppendLine("playMode=" + Application.isPlaying); + + try + { + Sec0_Asset(); + Setup(); + Sec1_Formula(); + Sec2_Toast(); + Sec3_PopupLine(); + Sec3b_ToastNode(); + Sec4_Hits(); + Sec5_Gc(); + Sec6_Rollback(); + } + catch (Exception ex) + { + s_fail++; + L("EXCEPTION " + ex); + } + finally { Teardown(); } + + sb.AppendLine(); + sb.AppendLine(s_fail == 0 ? "RESULT PASS" : "RESULT FAIL " + s_fail); + string text = sb.ToString(); + try { File.WriteAllText(Path.Combine(Directory.GetCurrentDirectory(), OutCommit), text, new UTF8Encoding(true)); } catch { } + Debug.Log(text); + return text; + } + + static void L(string s) { sb.AppendLine(s); } + static string P(bool ok) { if (!ok) s_fail++; return ok ? "[OK]" : "[FAIL]"; } + + // ───────────────────────────────────────── ⓪ 에셋 + static void Sec0_Asset() + { + L(""); + L("── ⓪ 설정 에셋 (C45 · 값 전부 SO)"); + var s = EnsureAsset(); + L(" " + P(s != null) + " 에셋 로드 " + AssetPath); + if (s == null) return; + WLPowerDeltaSettings.RuntimeDisabled = false; + L(" enabled_=" + s.enabled_ + " toastEnabled=" + s.toastEnabled + + " statPopupLineEnabled=" + s.statPopupLineEnabled + " hitsPerKillEnabled=" + s.hitsPerKillEnabled); + L(" 산식 스탯: atk=" + s.atkStat + "(폴백 " + s.atkFallbackStat + ") cri=" + s.critRateStat + + " criDmg=" + s.critDamageStat + "(useCritDamage=" + s.useCritDamage + ") aspd=" + s.attackSpeedStat + + "(base " + s.attackSpeedBase + ") hp=" + s.hpStat); + L(" 계수: critWeight=" + s.critWeight + " aspdWeight=" + s.attackSpeedWeight + + " hpWeight=" + s.hpWeight + " powerScale=" + s.powerScale); + L(" 토스트: offset=" + s.toastCenterOffsetPx + "px lines=" + s.toastLines + " font=" + s.toastFontPx + + "px " + s.toastSeconds + "s poll=" + s.pollIntervalSeconds + "s 최소 " + s.minPercentToShow + "%"); + L(" 타수: source=" + s.hitsSource + " window=" + s.windowSeconds + "s minKills=" + s.minKillsPerWindow + + " 감소 하한 " + s.compareMinDropHits + "방 · 존 클리어 표시=" + s.showCompareOnZoneCleared); + L(" 문구: \"" + s.powerUpFormat + "\" / \"" + s.statPopupLineFormat + "\" / \"" + s.hitsCompareFormat + "\""); + L(" " + P(WLPowerDeltaSettings.Enabled) + " WLPowerDeltaSettings.Enabled"); + + // 813y 대역과 겹치지 않는지 실측 대조 (연쇄 +520 · 스탯 팝업 +180 · 전리품 −120) + var t = WLCombatTextSettings.Instance; + if (t != null) + { + // 🔴 전투력 줄이 켜지면 스탯 팝업 상자가 한 줄 커진다 — 그 높이로 대조해야 한다. + int popupRows = t.statPopupRows + (s.statPopupLineEnabled ? 1 : 0); + float statTop = t.statPopupCenterOffsetPx + (t.statPopupTitleFontPx + t.statPopupRowHeightPx * popupRows) * 0.5f; + float mineLo = s.toastCenterOffsetPx - s.toastLineHeightPx * (s.toastLines - 0.5f); + float mineHi = s.toastCenterOffsetPx + s.toastLineHeightPx * 0.5f; + bool clear = mineLo > statTop && mineHi < t.killChainTopOffsetPx - t.killChainFontPx * 0.5f; + L(" " + P(clear) + " 대역 회피 — 내 토스트 [" + mineLo.ToString("F0") + ", " + mineHi.ToString("F0") + + "] · 스탯 팝업 위 " + statTop.ToString("F0") + " · 연쇄 아래 " + + (t.killChainTopOffsetPx - t.killChainFontPx * 0.5f).ToString("F0") + " · 전리품 " + t.lootToastCenterOffsetPx); + } + } + + /// 에셋이 없으면 클래스 기본값으로 만든다(값은 전부 C# 기본값 = 코드에 적힌 그대로). + public static WLPowerDeltaSettings EnsureAsset() + { + var existing = AssetDatabase.LoadAssetAtPath(AssetPath); + if (existing == null) + { + var dir = Path.GetDirectoryName(AssetPath); + if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); + var so = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(so, AssetPath); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + Debug.Log("[WL813l] 설정 에셋 생성 " + AssetPath); + } + WLPowerDeltaSettings.ClearCache(); + return WLPowerDeltaSettings.Instance; + } + + // ───────────────────────────────────────── 준비 + static void Setup() + { + s_prevMyPc = MyValue.MyPC; + s_prevBMyPc = MyValue.bMyPC; + + s_pcGo = new GameObject("__WL813l_PC"); + s_pc = s_pcGo.AddComponent(); + s_pc.m_Role = eRole.PC; s_pc.m_SubRole = eSubRol.None; + MyValue.MyPC = s_pc; // setter 가 bMyPC 도 켠다 + + s_mobGo = new GameObject("__WL813l_Mob"); + s_mob = s_mobGo.AddComponent(); // 813w 선례 — MobActor 는 테이블 없이 못 세운다 + s_mob.m_Role = eRole.Mob; s_mob.m_SubRole = eSubRol.None; + + PowerDeltaToast.ResetDiagnostics(); + PowerDeltaToast.Subscribe(false); + PowerDeltaToast.Subscribe(true); + + L(""); + L("── 준비"); + L(" " + P(s_pc.IsMainPC()) + " 가짜 메인 PC IsMainPC()=true · MyValue.bMyPC=" + MyValue.bMyPC); + L(" " + P(PowerDeltaToast.Subscribed) + " 구독 ON · PC 스탯=" + + (s_pc.Get_StatInfo() == null ? "null(에디트 모드 — 산식은 §① 에서 손으로 채운 ActorStatInfo 로 잰다)" : "있음")); + } + + static void Teardown() + { + try + { + PowerDeltaToast.Subscribe(false); + PowerDeltaToast.ResetDiagnostics(); + WLPowerDeltaSettings.RuntimeDisabled = false; + MyValue.MyPC = s_prevMyPc; + MyValue.bMyPC = s_prevBMyPc; + if (s_popup != null) s_popup.Subscribe(false); + Kill(s_pcGo, s_mobGo, s_canvasGo); + } + catch { } + } + + static void Kill(params GameObject[] gos) + { + for (int i = 0; i < gos.Length; i++) if (gos[i] != null) UnityEngine.Object.DestroyImmediate(gos[i]); + } + + // ───────────────────────────────────────── ① 산식 + static void Sec1_Formula() + { + L(""); + L("── ① 전투력 산식 (🔴 원본에 CP 없음 = 「추정」 · 발주서 §1-1 대안)"); + var s = WLPowerDeltaSettings.Instance; + + // 순수 함수 — 손으로 계산한 기대값과 대조 + double p1 = WLPowerMeter.Compute(100d, 0d, 0d, 1d, 1000d, s); // 100 × 1 × 1 + 1000×0.1 = 200 + double p2 = WLPowerMeter.Compute(100d, 0.2d, 0.5d, 1d, 1000d, s); // 100 × 1.1 × 1 + 100 = 210 + double p3 = WLPowerMeter.Compute(100d, 0d, 0d, 1.3d, 1000d, s); // 100 × 1 × 1.3 + 100 = 230 + double p4 = WLPowerMeter.Compute(200d, 0.2d, 0.5d, 1.3d, 2000d, s); // 200 × 1.1 × 1.3 + 200 = 486 + L(" " + P(Math.Abs(p1 - 200d) < 0.001d) + " atk100 cri0 aspd1 hp1000 → " + p1.ToString("F2") + " (기대 200.00)"); + L(" " + P(Math.Abs(p2 - 210d) < 0.001d) + " 치명 20%×50% → " + p2.ToString("F2") + " (기대 210.00 · +5%)"); + L(" " + P(Math.Abs(p3 - 230d) < 0.001d) + " 공속 1.3 → " + p3.ToString("F2") + " (기대 230.00 · +15%)"); + L(" " + P(Math.Abs(p4 - 486d) < 0.001d) + " 전 스탯 2배급 → " + p4.ToString("F2") + " (기대 486.00)"); + L(" " + P(WLPowerMeter.PercentDelta(200d, 230d) > 14.99d && WLPowerMeter.PercentDelta(200d, 230d) < 15.01d) + + " 증가율 200→230 = " + WLPowerMeter.PercentDelta(200d, 230d).ToString("F2") + "% (기대 15.00)"); + L(" " + P(WLPowerMeter.PercentDelta(0d, 100d) == 0d) + " 이전 0 → 0%(나눗셈 방지)"); + + // 실제 ActorStatInfo 읽기 (원본 API 만 · 쓰기 0) + var stat = new ActorStatInfo(eRole.PC); + stat.Set_Stat(eStat.BaseDmg, 500d); + stat.Set_Stat(eStat.ATK, 400d); + stat.Set_Stat(eStat.CRI, 0.15d); + stat.Set_Stat(eStat.CRI_DMG_Multiplier, 0.6d); + stat.Set_Stat(eStat.ATK_SPD_Multiplier, 1.2d); + stat.Set_Stat(eStat.MaxHP, 8000d); + WLPowerMeter.Sample sm; + bool read = WLPowerMeter.TryRead(stat, s, out sm); + double expect = 500d * (1d + 0.15d * 0.6d) * (1d + 0.2d) + 8000d * 0.1d; // 500×1.09×1.2 + 800 = 1454 + L(" " + P(read && Math.Abs(sm.power - expect) < 0.01d) + " ActorStatInfo 읽기 → " + sm + + " (기대 " + expect.ToString("F2") + ")"); + L(" " + P(sm.atk == 500d) + " BaseDmg 우선(ATK 400 이 아니라 500)"); + + // BaseDmg 가 0 이면 ATK 로 폴백 + var stat2 = new ActorStatInfo(eRole.PC); + stat2.Set_Stat(eStat.ATK, 400d); + stat2.Set_Stat(eStat.ATK_SPD_Multiplier, 1d); + WLPowerMeter.Sample sm2; + WLPowerMeter.TryRead(stat2, s, out sm2); + L(" " + P(sm2.atk == 400d) + " BaseDmg 0 → ATK 폴백 " + sm2.atk.ToString("F0")); + + WLPowerMeter.Sample none; + L(" " + P(!WLPowerMeter.TryRead(null, s, out none)) + " 스탯 null → false(로비 안전)"); + } + + // ───────────────────────────────────────── ② 토스트 + static void Sec2_Toast() + { + L(""); + L("── ② 전투력 % 토스트"); + var s = WLPowerDeltaSettings.Instance; + PowerDeltaToast.ResetDiagnostics(); + float t = 100f; + + L(" " + PowerDeltaToast.Observe(1000d, t, "probe") + " ← 기준값"); + t += 10f; + string r1 = PowerDeltaToast.Observe(1150d, t, "probe"); + bool up = PowerDeltaToast.LastText.Contains("15.0") && PowerDeltaToast.PowerToastCount == 1; + L(" " + P(up) + " 1000 → 1150 · 문구 \"" + PowerDeltaToast.LastText + "\" · " + r1); + + t += 10f; + PowerDeltaToast.Observe(1155d, t, "probe"); // +0.43% < 0.5% 하한 + L(" " + P(PowerDeltaToast.SkippedSmall == 1 && PowerDeltaToast.PowerToastCount == 1) + + " 잡음 차단 — +0.43% 는 안 띄운다(SkippedSmall=" + PowerDeltaToast.SkippedSmall + ")"); + + t += 10f; + PowerDeltaToast.Observe(900d, t, "probe"); // 하락 + L(" " + P(PowerDeltaToast.SkippedDown == 1 && PowerDeltaToast.PowerToastCount == 1) + + " 하락 무시(showOnPowerDown=" + s.showOnPowerDown + " · SkippedDown=" + PowerDeltaToast.SkippedDown + ")"); + + // 쿨다운 병합 — 마지막 표시 직후의 연속 변화는 한 줄로 합친다 + PowerDeltaToast.ResetDiagnostics(); + t = 200f; + PowerDeltaToast.Observe(1000d, t, "probe"); + PowerDeltaToast.Observe(1100d, t, "probe"); // 첫 표시 (+10%) + int after1 = PowerDeltaToast.PowerToastCount; + PowerDeltaToast.Observe(1210d, t + 0.3f, "probe"); // 쿨다운(1 s) 안 → 병합 + L(" " + P(PowerDeltaToast.MergedByCooldown == 1 && PowerDeltaToast.PowerToastCount == after1) + + " 쿨다운 병합 1건(연타해도 토스트가 도배되지 않는다)"); + PowerDeltaToast.Tick(t + 2f, 0.016f); // 쿨다운 지나 마감 + // 분모는 **마지막으로 표시한 값**(1100) — 이미 보여 준 +10% 를 두 번 세지 않는다. + bool merged = PowerDeltaToast.LastText.Contains("10.0"); // 1100 → 1210 = +10% + L(" " + P(merged && PowerDeltaToast.PowerToastCount == after1 + 1) + + " 마감 문구 \"" + PowerDeltaToast.LastText + "\" (직전 표시값 1100 → 1210 = +10.0% · 중복 계상 0)"); + + // 하한 미만의 잔변화는 버리지 않고 **누적**된다 + PowerDeltaToast.ResetDiagnostics(); + t = 300f; + PowerDeltaToast.Observe(1000d, t, "probe"); + PowerDeltaToast.Observe(1004d, t + 5f, "probe"); // +0.40% → 대기 + PowerDeltaToast.Observe(1008d, t + 10f, "probe"); // 누적 +0.80% → 표시 + L(" " + P(PowerDeltaToast.PowerToastCount == 1 && PowerDeltaToast.LastText.Contains("0.8")) + + " 잔변화 누적 — 0.40% 대기 후 0.80% 로 표시 \"" + PowerDeltaToast.LastText + "\""); + + // 레벨업 프레임에는 토스트를 팝업 줄에 양보한다 + PowerDeltaToast.ResetDiagnostics(); + PowerDeltaToast.Observe(1000d, 400f, "probe"); + RaiseFakeLevelUp(8); // suppressToastOnLevelUp 프레임 표시 + PowerDeltaToast.Observe(1200d, 401f, "probe"); // 같은 프레임 → 생략 + L(" " + P(PowerDeltaToast.PowerToastCount == 0 && PowerDeltaToast.SuppressedByPopup == 1) + + " 레벨업 프레임 토스트 생략(중복 표시 0 · Suppressed=" + PowerDeltaToast.SuppressedByPopup + + " Toast=" + PowerDeltaToast.PowerToastCount + ")"); + + // 가짜 LevelUp — 813k 축을 그대로 쏜다 + PowerDeltaToast.ResetDiagnostics(); + int before = PowerDeltaToast.LevelUpSeen; + RaiseFakeLevelUp(7); + L(" " + P(PowerDeltaToast.LevelUpSeen == before + 1) + + " GrowthEvents.LevelUp 수신 " + PowerDeltaToast.LevelUpSeen + "회(PC 스탯이 없어 전투력 값은 안 읽힌다 = 에디트 모드 한계)"); + + // 노드 없이도 문구는 난다(HUD 가 없는 에디트 모드) — 노드 배치는 §③ 에서 가짜 캔버스로 본다 + L(" Bound=" + PowerDeltaToast.Bound + " (에디트 모드 · NewGameUI 없음 — 문구만 기록)"); + } + + /// 813k 의 공개 발생기를 그대로 쓴다(data=null 은 813k 가 허용 — 로비 경로와 같은 모양). + static void RaiseFakeLevelUp(int newLevel) + { + GrowthEvents.RaiseLevelUp(null, newLevel); + } + + // ───────────────────────────────────────── ③ 팝업 전투력 줄 + static void Sec3_PopupLine() + { + L(""); + L("── ③ 레벨업 팝업 전투력 줄 (813y StatPopup · 1줄 추가)"); + var s = WLPowerDeltaSettings.Instance; + + s_canvasGo = new GameObject("__WL813l_Canvas", typeof(Canvas), typeof(CanvasScaler)); + s_canvasGo.layer = 5; + var croot = (RectTransform)s_canvasGo.transform; + croot.sizeDelta = new Vector2(1080f, 1920f); + + var prt = new GameObject("WL_StatPopup", typeof(RectTransform)); + prt.layer = 5; + prt.transform.SetParent(croot, false); + s_popup = prt.AddComponent(); + string init = s_popup.Initialize(); + L(" init: " + init); + L(" " + P(StatPopup.PowerLineOn) + " StatPopup.PowerLineOn(에셋 · enabled_ · statPopupLineEnabled)"); + + // 전투력 값을 읽을 수 있게 스탯이 살아 있는 PC 가 필요하다 — 에디트 모드에서는 못 만든다. + // 대신 팝업 경로 자체(줄 노드 생성 · 배치 · 문구 주입)를 검증한다. + string layout = s_popup.ApplyLayout(); + bool hasPowerRow = layout.Contains("power=on"); + L(" " + P(hasPowerRow) + " 배치 — " + layout); + + var box = prt.transform.Find("Box"); + var powerRow = box != null ? box.Find("PowerRow") : null; + L(" " + P(powerRow != null) + " PowerRow 노드 생성(스탯 줄 " + WLCombatTextSettings.Instance.statPopupRows + "개는 그대로)"); + if (powerRow != null) + { + var lb = powerRow.GetComponent(); + var rt = (RectTransform)powerRow; + var row2 = box.Find("Row" + (WLCombatTextSettings.Instance.statPopupRows - 1)) as RectTransform; + bool below = row2 != null && rt.anchoredPosition.y < row2.anchoredPosition.y; + L(" " + P(below && lb != null) + " 마지막 스탯 줄 아래 y=" + rt.anchoredPosition.y.ToString("F1") + + "(스탯 줄 " + (row2 != null ? row2.anchoredPosition.y.ToString("F1") : "?") + ") · font=" + + (lb != null ? lb.fontSize.ToString("F1") : "?") + " color=" + (lb != null ? lb.color.ToString() : "?")); + } + + // 문구 조립 — 813y 팝업 표시 경로를 그대로 탄다(전투력 줄은 PC 스탯이 없으면 빈 줄 = 안전) + var deltas = new[] { new WLStatDelta { name = "MaxHP", prev = 100d, next = 112d } }; + string shown = s_popup.Show(7, deltas, "+5 포인트"); + L(" Show: " + shown); + L(" " + P(s_popup.LastRowCount >= 1) + " 스탯 줄 " + s_popup.LastRowCount + "개(813y 동작 불변)"); + L(" 전투력 줄=\"" + s_popup.LastPowerLine + "\" (PC 스탯 null → 빈 줄이 정상 · 실값은 인게임 QA)"); + + // 문구 포맷 자체는 순수 조립으로 확인한다(값 3개가 자리에 들어가는가) + string sample = DSUtil.Format(s.statPopupLineFormat, WLPowerMeter.Format(12000d), WLPowerMeter.Format(13800d), "+15.0"); + L(" " + P(sample.Contains("12,000") && sample.Contains("13,800") && sample.Contains("+15.0")) + + " 문구 조립 \"" + sample + "\""); + string sample2 = DSUtil.Format(s.statPopupLineFormatNoChange, WLPowerMeter.Format(12000d)); + L(" " + P(sample2.Contains("12,000")) + " 변화 없을 때 \"" + sample2 + "\""); + } + + // ───────────────────────────────────────── ③-b 토스트 노드 (가짜 NewGameUI 트리) + static void Sec3b_ToastNode() + { + L(""); + L("── ③-b 토스트 노드 배치 (프리팹 diff 0 · 런타임 생성)"); + var s = WLPowerDeltaSettings.Instance; + + // 원본 계층과 같은 모양만 만든다 — NewGameUI/IngameUIs/WL_HUD + var croot = (RectTransform)s_canvasGo.transform; + var ing = new GameObject("IngameUIs", typeof(RectTransform)); + ing.transform.SetParent(croot, false); + var hud = new GameObject("WL_HUD", typeof(RectTransform)); + hud.transform.SetParent(ing.transform, false); + var hudRt = (RectTransform)hud.transform; + hudRt.anchorMin = Vector2.zero; hudRt.anchorMax = Vector2.one; + hudRt.offsetMin = Vector2.zero; hudRt.offsetMax = Vector2.zero; + + string bind = PowerDeltaToast.Bind(croot); + L(" bind: " + bind); + L(" " + P(PowerDeltaToast.Bound) + " 노드 연결(부모 경로 \"" + s.hudParentPath + "\")"); + + var root = hudRt.Find("WL_PowerDeltaToast") as RectTransform; + L(" " + P(root != null) + " WL_PowerDeltaToast 노드"); + if (root == null) return; + L(" " + P(root.gameObject.hideFlags == HideFlags.DontSave) + " hideFlags=" + root.gameObject.hideFlags + + "(씬·프리팹에 굽지 않는다) · layer=" + root.gameObject.layer); + L(" " + P(root.childCount == s.toastLines) + " 줄 노드 " + root.childCount + "개(SO toastLines=" + s.toastLines + ")"); + + for (int i = 0; i < root.childCount; i++) + { + var lrt = (RectTransform)root.GetChild(i); + var cg = lrt.GetComponent(); + var lb = lrt.GetComponentInChildren(true); + L(" Line" + i + " y=" + lrt.anchoredPosition.y.ToString("F1") + " size=" + lrt.sizeDelta + + " alpha=" + (cg != null ? cg.alpha.ToString("F2") : "?") + " font=" + (lb != null ? lb.fontSize.ToString("F1") : "?")); + } + + // 실제 표시 — 전투력 줄 + PowerDeltaToast.ResetDiagnostics(); + PowerDeltaToast.Observe(1000d, 600f, "probe"); + PowerDeltaToast.Observe(1250d, 610f, "probe"); + var l0 = (RectTransform)root.GetChild(0); + var lab0 = l0.GetComponentInChildren(true); + var cg0 = l0.GetComponent(); + L(" " + P(PowerDeltaToast.VisibleLines == 1 && lab0 != null && lab0.text == "전투력 +25.0%" && cg0.alpha > 0.99f) + + " 표시 \"" + (lab0 != null ? lab0.text : "?") + "\" alpha=" + cg0.alpha.ToString("F2") + + " color=" + (lab0 != null ? lab0.color.ToString() : "?") + " scale=" + l0.localScale.x.ToString("F3") + "(펀치)"); + + // 타수 비교 줄이 같은 대역 둘째 줄에 쌓인다 + PowerDeltaToast.RaiseFakeCombatForProbe(s_pc, s_mob, 30, 10); + PowerDeltaToast.MarkGrowth(620f, "probe"); + PowerDeltaToast.RaiseFakeCombatForProbe(s_pc, s_mob, 20, 10); + PowerDeltaToast.CloseWindow(s, 620f + s.windowSeconds); + PowerDeltaToast.RaiseFakeZoneClearedForProbe(0); + var l1 = (RectTransform)root.GetChild(1); + var lab1 = l1.GetComponentInChildren(true); + L(" " + P(PowerDeltaToast.VisibleLines == 2 && lab1 != null && lab1.text.Contains("방")) + + " 둘째 줄 \"" + (lab1 != null ? lab1.text : "?") + "\" y=" + l1.anchoredPosition.y.ToString("F1") + + "(첫 줄 " + l0.anchoredPosition.y.ToString("F1") + " · 간격 " + s.toastLineHeightPx + "px)"); + + // 수명 — toastSeconds 뒤 사라진다 + for (int i = 0; i < 200; i++) PowerDeltaToast.Tick(700f + i * 0.02f, 0.02f); + L(" " + P(PowerDeltaToast.VisibleLines == 0 && cg0.alpha < 0.01f) + + " " + s.toastSeconds + "s 뒤 전부 사라짐(alpha=" + cg0.alpha.ToString("F2") + " · 보이는 줄 " + + PowerDeltaToast.VisibleLines + ")"); + PowerDeltaToast.ResetDiagnostics(); + } + + // ───────────────────────────────────────── ④ 처치 타수 + static void Sec4_Hits() + { + L(""); + L("── ④ 처치 타수 비교 (기준서 §E 5-3 「타수 −1」)"); + var s = WLPowerDeltaSettings.Instance; + PowerDeltaToast.ResetDiagnostics(); + float t = 1000f; + + // 창 1 — 성장 전: 30타 / 10킬 = 3.0방 + FeedCombat(30, 10); + L(" 창1 누적 hits=" + PowerDeltaToast.WindowHits + " attacks=" + PowerDeltaToast.WindowAttacks + + " kills=" + PowerDeltaToast.WindowKills); + string mark = PowerDeltaToast.MarkGrowth(t, "probe 성장"); + L(" " + P(PowerDeltaToast.HasBaseline && Math.Abs(PowerDeltaToast.BaselineRatio - 3f) < 0.001f) + + " 성장 시점에 기준선 확정 — " + mark); + L(" " + P(PowerDeltaToast.WindowKills == 0) + " 새 창 리셋(kills=" + PowerDeltaToast.WindowKills + ")"); + + // 창 2 — 성장 후: 20타 / 10킬 = 2.0방 → 3방 → 2방 + FeedCombat(20, 10); + string close = PowerDeltaToast.CloseWindow(s, t + s.windowSeconds); + L(" " + P(PowerDeltaToast.Armed && PowerDeltaToast.ArmedFrom == 3 && PowerDeltaToast.ArmedTo == 2) + + " 창2 마감 — " + close); + + // 813p ZoneCleared 에서 1회만 표시 + RaiseFakeZoneCleared(0); + bool shown = PowerDeltaToast.CompareToastCount == 1 && PowerDeltaToast.LastCompareText.Contains("3") && + PowerDeltaToast.LastCompareText.Contains("2"); + L(" " + P(shown) + " ZoneCleared → \"" + PowerDeltaToast.LastCompareText + "\" (" + + PowerDeltaToast.CompareToastCount + "회)"); + RaiseFakeZoneCleared(1); + L(" " + P(PowerDeltaToast.CompareToastCount == 1) + " 다음 존에서 다시 뜨지 않는다(무장 1회 소모 · " + + PowerDeltaToast.CompareToastCount + "회)"); + + // 표본 부족 창은 버린다 + PowerDeltaToast.ResetDiagnostics(); + FeedCombat(6, 2); + string poor = PowerDeltaToast.CloseWindow(s, t + 500f); + L(" " + P(!PowerDeltaToast.HasBaseline && poor.Contains("표본 부족")) + " " + poor + + " (minKills=" + s.minKillsPerWindow + ")"); + + // 타수가 안 줄면 무장하지 않는다 + PowerDeltaToast.ResetDiagnostics(); + FeedCombat(30, 10); + PowerDeltaToast.MarkGrowth(t, "probe"); + FeedCombat(29, 10); // 2.9방 → 반올림 3방 = 변화 없음 + string flat = PowerDeltaToast.CloseWindow(s, t + s.windowSeconds); + L(" " + P(!PowerDeltaToast.Armed) + " 3.00 → 2.90 은 무장 안 함 — " + flat); + + // 메인 PC 가 아닌 처치는 안 센다 + PowerDeltaToast.ResetDiagnostics(); + PowerDeltaToast.RaiseFakeCombatForProbe(s_mob, s_mob, 0, 3); // killer = 몹 + L(" " + P(PowerDeltaToast.WindowKills == 0) + " 몹이 죽인 것은 표본에서 제외(kills=" + PowerDeltaToast.WindowKills + ")"); + } + + /// 실제 이벤트 축으로 타수·처치를 채운다(카운터 직접 조작 0 · 구독 경로 전체를 탄다). + static void FeedCombat(int hits, int kills) { PowerDeltaToast.RaiseFakeCombatForProbe(s_pc, s_mob, hits, kills); } + + static void RaiseFakeZoneCleared(int zoneIndex) { PowerDeltaToast.RaiseFakeZoneClearedForProbe(zoneIndex); } + + // ───────────────────────────────────────── ⑤ GC + static void Sec5_Gc() + { + L(""); + L("── ⑤ GC 0 (폴링 · 카운터)"); + PowerDeltaToast.ResetDiagnostics(); + PowerDeltaToast.Observe(1000d, 0f, "base"); + + long a0 = Gc(), b0 = Prof(); + for (int i = 0; i < 1000; i++) PowerDeltaToast.Tick(3000f + i * 0.001f, 0.001f); // 폴링·창·수명 + long a1 = Gc(), b1 = Prof(); + L(" " + P(a1 - a0 <= 0 && b1 - b0 <= 0) + " Tick × 1000 → GetTotalMemory " + (a1 - a0) + + " B · MonoUsedSize " + (b1 - b0) + " B (폴링 " + PowerDeltaToast.PollCount + "회)"); + + long c0 = Gc(), d0 = Prof(); + PowerDeltaToast.RaiseFakeCombatForProbe(s_pc, s_mob, 1000, 200); // 이벤트 2,200발 · 헬퍼 반환 문자열 1개만 할당 + long c1 = Gc(), d1 = Prof(); + L(" " + P(c1 - c0 < 512) + " AttackStarted+HitConfirmed+Killed 2,200발 → " + (c1 - c0) + " B · " + (d1 - d0) + + " B (헬퍼 반환 문자열 1개 제외하면 카운터는 int 증가뿐 · hits=" + PowerDeltaToast.WindowHits + ")"); + + // 대조군 — 측정이 살아 있음을 보인다(813k 선례) + long e0 = Gc(); + var keep = new object[1000]; + for (int i = 0; i < 1000; i++) keep[i] = new object(); + long e1 = Gc(); + L(" 대조군 new object() × 1000 = " + (e1 - e0) + " B (0 이 아니어야 측정이 살아 있다) · keep=" + keep.Length); + PowerDeltaToast.ResetDiagnostics(); + } + + static long Gc() { return GC.GetTotalMemory(false); } + static long Prof() { return UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); } + + // ───────────────────────────────────────── ⑥ C8 + static void Sec6_Rollback() + { + L(""); + L("── ⑥ C8 롤백 (WLPowerDeltaSettings.enabled_ = 0 → 표시 0)"); + PowerDeltaToast.ResetDiagnostics(); + WLPowerDeltaSettings.RuntimeDisabled = true; + + L(" " + P(!WLPowerDeltaSettings.Enabled) + " Enabled=false"); + PowerDeltaToast.Observe(1000d, 500f, "probe"); + PowerDeltaToast.Observe(2000d, 510f, "probe"); + L(" " + P(PowerDeltaToast.PowerToastCount == 0) + " 전투력 2배 변화에도 토스트 " + PowerDeltaToast.PowerToastCount + "회"); + RaiseFakeLevelUp(10); + L(" " + P(PowerDeltaToast.LevelUpSeen == 0) + " 레벨업 무시(LevelUpSeen=" + PowerDeltaToast.LevelUpSeen + ")"); + L(" " + P(!StatPopup.PowerLineOn) + " StatPopup.PowerLineOn=false → 팝업 전투력 줄 0"); + if (s_popup != null) + { + s_popup.Show(11, null, null); + L(" " + P(string.IsNullOrEmpty(s_popup.LastPowerLine)) + " 팝업 전투력 줄 \"" + s_popup.LastPowerLine + "\"(빈 문자열)"); + } + L(" " + P(PowerDeltaToast.BuildStatPopupLine() == null) + " BuildStatPopupLine()=null"); + + WLPowerDeltaSettings.RuntimeDisabled = false; + L(" " + P(WLPowerDeltaSettings.Enabled) + " 롤백 해제 — 다시 Enabled=true"); + } +} diff --git a/Assets/WL/UI/Scripts/PowerDeltaToast.cs b/Assets/WL/UI/Scripts/PowerDeltaToast.cs new file mode 100644 index 000000000..8315b0070 --- /dev/null +++ b/Assets/WL/UI/Scripts/PowerDeltaToast.cs @@ -0,0 +1,689 @@ +// ───────────────────────────────────────────────────────────────────────────── +// 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); + } + } +} diff --git a/Assets/WL/UI/Scripts/PowerDeltaToast.cs.meta b/Assets/WL/UI/Scripts/PowerDeltaToast.cs.meta new file mode 100644 index 000000000..3c9b99e84 --- /dev/null +++ b/Assets/WL/UI/Scripts/PowerDeltaToast.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b96f698eefb043b8b4103930fcb4e4c7 diff --git a/Assets/WL/UI/Scripts/StatPopup.cs b/Assets/WL/UI/Scripts/StatPopup.cs index 42245e400..7d0bbd282 100644 --- a/Assets/WL/UI/Scripts/StatPopup.cs +++ b/Assets/WL/UI/Scripts/StatPopup.cs @@ -43,6 +43,8 @@ namespace WL.UI [SerializeField] private RectTransform box; [SerializeField] private TextMeshProUGUI titleLabel; [SerializeField] private TMP_FontAsset font; + /// WL-813l — 「전투력 A → B (+N %)」 한 줄(스탯 줄 아래). 값·on/off 는 WLPowerDeltaSettings. + [SerializeField] private TextMeshProUGUI powerLabel; private readonly List _rows = new List(); private readonly Dictionary _snapshot = new Dictionary(); @@ -69,6 +71,17 @@ namespace WL.UI public int LastLevelsGained { get; private set; } public int LastStatPointTotal { get; private set; } public string LastEdge { get { return _lastEdge; } } + /// WL-813l — 마지막으로 채운 전투력 줄(빈 문자열 = 안 붙음). + public string LastPowerLine { get; private set; } = ""; + /// WL-813l — 전투력 줄이 켜져 있는가(에셋 없음·off 면 false). + public static bool PowerLineOn + { + get + { + var p = WLPowerDeltaSettings.Instance; + return WLPowerDeltaSettings.Enabled && p != null && p.statPopupLineEnabled; + } + } private void OnEnable() { Initialize(); } private void OnDisable() { Subscribe(false); if (Active == this) Active = null; } @@ -128,6 +141,12 @@ namespace WL.UI _rows.Add(WLTextFxUtil.NewText(box, "Row" + _rows.Count, font, TextAlignmentOptions.Center)); made = true; } + // WL-813l — 전투력 줄은 스탯 줄과 따로 만든다(스탯 3줄을 밀어내지 않는다). + if (PowerLineOn && powerLabel == null) + { + powerLabel = WLTextFxUtil.NewText(box, "PowerRow", font, TextAlignmentOptions.Center); + made = true; + } return made; } @@ -141,7 +160,10 @@ namespace WL.UI float u = WLTextFxUtil.UnitsPerPx(this, s); WLTextFxUtil.Stretch(_rt); - float h = (s.statPopupTitleFontPx + s.statPopupRowHeightPx * s.statPopupRows) * u; + // WL-813l — 전투력 줄이 켜져 있으면 상자 높이에 한 줄을 더 준다(스탯 줄 자리는 그대로). + var pw = WLPowerDeltaSettings.Instance; + bool powerOn = PowerLineOn && powerLabel != null; + float h = (s.statPopupTitleFontPx + s.statPopupRowHeightPx * (s.statPopupRows + (powerOn ? 1 : 0))) * u; box.anchorMin = box.anchorMax = new Vector2(0.5f, 0.5f); box.pivot = new Vector2(0.5f, 0.5f); box.sizeDelta = new Vector2(s.designWidthPx * u, h); @@ -165,12 +187,27 @@ namespace WL.UI _rows[i].color = s.statPopupRowColor; } + // WL-813l — 전투력 줄은 스탯 줄 **다음 칸**에 앉는다(줄 간격·기준선은 스탯 줄과 같다). + if (powerOn) + { + var prt = powerLabel.rectTransform; + prt.anchorMin = prt.anchorMax = new Vector2(0.5f, 1f); + prt.pivot = new Vector2(0.5f, 1f); + prt.sizeDelta = new Vector2(s.designWidthPx * u, s.statPopupRowHeightPx * u); + prt.anchoredPosition = new Vector2(0f, -(s.statPopupTitleFontPx * 1.2f + s.statPopupRowHeightPx * s.statPopupRows) * u); + powerLabel.fontSize = pw.statPopupLineFontPx * u; + powerLabel.color = pw.statPopupLineColor; + } + else if (powerLabel != null) powerLabel.text = ""; + // WL-813y — 밝은 배경 대비(Q3 D-6 ①). 값은 SO · 포스트 0. var edge = s.Edge(s.statPopupEdge); _lastEdge = WLTextFxUtil.ApplyEdge(titleLabel, edge); for (int i = 0; i < _rows.Count; i++) WLTextFxUtil.ApplyEdge(_rows[i], edge); + if (powerOn) WLTextFxUtil.ApplyEdge(powerLabel, edge); return "box pos=" + box.anchoredPosition + " size=" + box.sizeDelta + " rows=" + _rows.Count + + " power=" + (powerOn ? "on" : "off") + " unitsPerPx=" + u.ToString("F4") + " titleFont=" + titleLabel.fontSize.ToString("F1") + " edge[" + _lastEdge + "]"; } @@ -262,6 +299,15 @@ namespace WL.UI shown = 1; UsedPointLine = true; } + // WL-813l — 「전투력 A → B (+N %)」 한 줄. 값도 on/off 도 WLPowerDeltaSettings(코드 상수 0). + LastPowerLine = ""; + if (powerLabel != null) + { + string line = PowerLineOn ? PowerDeltaToast.BuildStatPopupLine() : null; + powerLabel.text = line ?? ""; + if (!string.IsNullOrEmpty(line)) { LastPowerLine = line; sb.Append(" / ").Append(line); } + } + LastRowCount = shown; LastText = sb.ToString(); @@ -270,7 +316,8 @@ namespace WL.UI group.alpha = 1f; box.localScale = Vector3.one * WLTextFxUtil.PunchScale(0f, s.statPopupPunchScale, s.statPopupPunchSeconds); TakeSnapshot(); - return "level=" + newLevel + " rows=" + shown + " text=\"" + LastText + "\" pos=" + box.anchoredPosition; + return "level=" + newLevel + " rows=" + shown + " power=\"" + LastPowerLine + "\" text=\"" + LastText + + "\" pos=" + box.anchoredPosition; } public void Hide() diff --git a/Assets/WL/UI/Settings/Resources/WL/WLPowerDeltaSettings.asset b/Assets/WL/UI/Settings/Resources/WL/WLPowerDeltaSettings.asset new file mode 100644 index 000000000..498b75809 --- /dev/null +++ b/Assets/WL/UI/Settings/Resources/WL/WLPowerDeltaSettings.asset @@ -0,0 +1,71 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: ed82a4ef1a7c450caeedf5bb347f1ddc, type: 3} + m_Name: WLPowerDeltaSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.UI.WLPowerDeltaSettings + enabled_: 1 + verboseLog: 0 + atkStat: 44 + atkFallbackStat: 8 + critRateStat: 22 + critDamageStat: 23 + useCritDamage: 1 + critWeight: 1 + attackSpeedStat: 29 + attackSpeedBase: 1 + attackSpeedWeight: 1 + hpStat: 10 + hpWeight: 0.1 + powerScale: 1 + toastEnabled: 1 + hudParentPath: IngameUIs/WL_HUD + toastCenterOffsetPx: 430 + toastLines: 2 + toastLineHeightPx: 60 + toastFontPx: 52 + toastSeconds: 2 + toastFadeStartRatio: 0.6 + toastPunchScale: 1.25 + toastPunchSeconds: 0.18 + powerUpColor: {r: 1, g: 0.84, b: 0.35, a: 1} + powerDownColor: {r: 0.72, g: 0.78, b: 0.88, a: 1} + hitsCompareColor: {r: 0.62, g: 1, b: 0.78, a: 1} + powerUpFormat: "\uC804\uD22C\uB825 +{0}%" + powerDownFormat: "\uC804\uD22C\uB825 -{0}%" + percentDigits: 1 + minPercentToShow: 0.5 + showOnPowerDown: 0 + pollIntervalSeconds: 0.5 + announceCooldownSeconds: 1 + suppressToastOnLevelUp: 1 + toastEdge: + enabled: 1 + outlineWidth: 0.26 + outlineColor: {r: 0.03, g: 0.03, b: 0.06, a: 1} + shadowOffset: {x: 0.55, y: -0.55} + shadowColor: {r: 0, g: 0, b: 0, a: 0} + shadowSoftness: 0.2 + faceDilate: 0 + statPopupLineEnabled: 1 + statPopupLineFormat: "\uC804\uD22C\uB825 {0} \u2192 {1} ({2}%)" + statPopupLineFormatNoChange: "\uC804\uD22C\uB825 {0}" + statPopupLineFontPx: 44 + statPopupLineColor: {r: 1, g: 0.84, b: 0.35, a: 1} + hitsPerKillEnabled: 1 + hitsSource: 0 + windowSeconds: 60 + minKillsPerWindow: 5 + hitsCompareFormat: "\uC774\uC81C {0}\uBC29 \u2192 {1}\uBC29" + showCompareOnZoneCleared: 1 + showCompareImmediatelyIfNoRun: 1 + compareArmSeconds: 120 + compareMinDropHits: 1 diff --git a/Assets/WL/UI/Settings/Resources/WL/WLPowerDeltaSettings.asset.meta b/Assets/WL/UI/Settings/Resources/WL/WLPowerDeltaSettings.asset.meta new file mode 100644 index 000000000..9b4c940a0 --- /dev/null +++ b/Assets/WL/UI/Settings/Resources/WL/WLPowerDeltaSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c8aec95b3373e0045aee111f98b85caf +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/UI/Settings/WLPowerDeltaSettings.cs b/Assets/WL/UI/Settings/WLPowerDeltaSettings.cs new file mode 100644 index 000000000..761819f5e --- /dev/null +++ b/Assets/WL/UI/Settings/WLPowerDeltaSettings.cs @@ -0,0 +1,254 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLPowerDeltaSettings.cs — 성장 체감 표시(전투력 % 토스트 · 팝업 전투력 줄 · 처치 타수 비교) 값의 단일 출처 +// (WL-813l · PD 지시 #813) +// +// 기준서 v1 §D-2 813l 행 + §E 5-3 「강화 후 체감(전투력 % + 처치 타수 변화) = 표시 + 타수 −1 로그」. +// 발주서 WL-813l §1(Lead 설계 결정) · 구독 계약 = 813k `GrowthEvents.LevelUp`(완료보고 §3) · +// 811b `CombatEvents`(AttackStarted/HitConfirmed/Killed) · 813p `RunEvents.ZoneCleared`(완료보고 §4). +// +// ■ 🔴 전투력 산식은 「추정」이다 +// 원본(`Assets/Script/**`)에는 전투력·CP 개념이 **없다**(실측: `combatpower|battlepower|전투력` 전수 검색 0건). +// 그래서 발주서 §1-1 대안인 WL 산식을 쓴다: +// power = ATK × (1 + 치명 기여) × (1 + 공속 기여) + MaxHP × hpWeight +// 구성 스탯도 계수도 전부 이 에셋 값이다 — 코드 상수 0. 기획이 바꾸면 표시가 따라 바뀐다. +// +// ■ 값 읽기는 원본 API 만 쓴다 — `Actor.Get_StatInfo()` → `ActorStatInfo.Get_Stat(eStat)`. 원본 훅 0 · 쓰기 0. +// +// ■ 좌표계 (px) +// 813c·813g·813q 와 같은 **세로 1080×1920 기준 화면 픽셀**. px → 캔버스 유닛 환산은 +// `WLCombatTextSettings.UnitsPerPx(canvas)` **하나만** 쓴다(산식 중복 0 · 813tj 선례). +// +// ■ C8 롤백 +// `enabled_ = 0` → 구독 0 · 폴링 0 · 노드 0 · 표시 0. 팝업 전투력 줄도 붙지 않는다. +// 개별 스위치(`toastEnabled` · `statPopupLineEnabled` · `hitsPerKillEnabled`)로 셋을 따로 끌 수도 있다. +// +// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.UI +{ + /// 「몇 방에 죽었나」를 세는 분자(타수)의 출처. + public enum WLHitSource + { + /// 메인 PC 가 **실제로 적중시킨** 타격 수(`CombatEvents.HitConfirmed.byMainPC`). + /// 광역기로 여러 마리를 때리면 마리 수만큼 센다 = 「한 마리를 몇 방에」에 가장 가깝다. + HitConfirmed = 0, + /// 메인 PC 의 **공격 모션 시작** 수(`CombatEvents.AttackStarted.isMainPC`). + /// 기준서 §E 5-3 의 검증 방법(`AttackStarted`/`Killed` 비)과 같은 분자다. + AttackStarted = 1, + } + + [CreateAssetMenu(fileName = "WLPowerDeltaSettings", menuName = "WL/Power Delta Settings", order = 34)] + public sealed class WLPowerDeltaSettings : ScriptableObject + { + /// Resources 경로 — 런타임 로드 키. + public const string ResourcesPath = "WL/WLPowerDeltaSettings"; + + private static WLPowerDeltaSettings s_instance; + private static bool s_tried; + + /// 에셋이 없으면 null(호출부는 null 이면 전부 건너뛴다 = C8 롤백). + public static WLPowerDeltaSettings Instance + { + get + { + if (s_instance == null && !s_tried) + { + s_tried = true; + s_instance = Resources.Load(ResourcesPath); + } + return s_instance; + } + } + + /// 에디터에서 에셋을 새로 만들거나 값을 바꾼 뒤 캐시를 버린다. + public static void ClearCache() { s_instance = null; s_tried = false; } + + /// 프로브·QA 용 런타임 스위치(에셋을 건드리지 않고 끈다). + public static bool RuntimeDisabled; + + /// 성장 체감 축이 살아 있는가 = 에셋 존재 · enabled_ · 런타임 스위치 off. + public static bool Enabled + { + get + { + if (RuntimeDisabled) return false; + var s = Instance; + return s != null && s.enabled_; + } + } + + // ── 공통 ────────────────────────────────────────────────────────────── + [Header("공통 (C8 롤백)")] + [Tooltip("끄면 전투력 토스트·팝업 전투력 줄·타수 비교가 전부 무동작(구독 0 · 노드 0 · 표시 0).")] + // ScriptableObject 에는 `enabled` 멤버가 없지만(그건 Behaviour) 혼동을 피해 뒤에 _ 를 붙인다 — + // 813q WLRunUiSettings 와 같은 규약. 인스펙터·에셋 YAML 에는 `enabled_` 로 나온다. + public bool enabled_ = true; + + [Tooltip("콘솔 로그(전투력 변화 · 타수 창 마감). QA·프로브 때만 켠다.")] + public bool verboseLog = false; + + // ── 전투력 산식 (🔴 원본에 CP 없음 = 「추정」 · 발주서 §1-1 대안) ────── + [Header("전투력 산식 — 원본에 CP 개념이 없어 WL 산식을 쓴다(「추정」)")] + [Tooltip("공격력 스탯. 기본 BaseDmg = 원본이 ATK × ATK_Multiplier × DMG_Multiplier 로 만든 최종 공격력.")] + public eStat atkStat = eStat.BaseDmg; + + [Tooltip("위 값이 0 일 때 쓰는 대체 공격력 스탯(전투 밖·로비에서 BaseDmg 가 안 채워진 경우).")] + public eStat atkFallbackStat = eStat.ATK; + + [Tooltip("치명타 확률 스탯. 원본 런타임 값은 0~1 비율이다(실측: DSUtil.RandomTrue(p) 가 Random.Range(0,1) 와 비교).")] + public eStat critRateStat = eStat.CRI; + + [Tooltip("치명타 피해 증가량 스탯(0~1 비율). useCritDamage 가 켜져 있을 때만 읽는다.")] + public eStat critDamageStat = eStat.CRI_DMG_Multiplier; + + [Tooltip("켜면 치명 기여 = 확률 × 피해증가(기대값). 끄면 확률만 쓴다(발주서 §1-1 문자 그대로의 (1 + CRIT%)).")] + public bool useCritDamage = true; + + [Tooltip("치명 기여에 곱하는 가중치. 0 이면 치명을 무시한다.")] + public float critWeight = 1f; + + [Tooltip("공격 속도 배율 스탯. 원본 기본값은 1 이다(실측: MyValue.cs Set_Stat(ATK_SPD_Multiplier, 1) · FinalAttackSpeed = HERO_ATK_SPD × (배율 − 1)).")] + public eStat attackSpeedStat = eStat.ATK_SPD_Multiplier; + + [Tooltip("공속 배율의 기준값(이 값을 빼서 증가분을 만든다). 원본 실측 기본값 1.")] + public float attackSpeedBase = 1f; + + [Tooltip("공속 기여 가중치. 0 이면 공속을 무시한다.")] + public float attackSpeedWeight = 1f; + + [Tooltip("체력 스탯(최종 최대 체력).")] + public eStat hpStat = eStat.MaxHP; + + [Tooltip("체력 계수. 발주서 §1-1 = 0.1.")] + public float hpWeight = 0.1f; + + [Tooltip("최종 전투력에 곱하는 표시 배율(자릿수 조정용). 1 = 산식 그대로.")] + public float powerScale = 1f; + + // ── 전투력 % 토스트 ─────────────────────────────────────────────────── + [Header("전투력 % 토스트")] + [Tooltip("끄면 토스트만 표시 안 함(팝업 줄·타수 비교는 그대로).")] + public bool toastEnabled = true; + + [Tooltip("토스트를 붙일 HUD 경로(NewGameUI 루트 기준). 813q RunHud 와 같은 부모.")] + public string hudParentPath = "IngameUIs/WL_HUD"; + + [Tooltip("화면 중심에서의 세로 오프셋(px · +위). 실측 대역: 스탯 팝업(+180 · 전투력 줄 포함 시 위 끝 +328) 과 연쇄 처치(+520 · 아래 끝 +472) 사이의 빈 구간에 앉힌다.")] + public float toastCenterOffsetPx = 430f; + + [Tooltip("줄 수(전투력 줄 + 타수 비교 줄이 같이 뜰 수 있다).")] + [Range(1, 4)] public int toastLines = 2; + + [Tooltip("줄 간격(px).")] + public float toastLineHeightPx = 60f; + + [Tooltip("글자 크기(px).")] + public float toastFontPx = 52f; + + [Tooltip("표시 시간(초). 발주서 §1-2 = 2.0.")] + public float toastSeconds = 2f; + + [Tooltip("이 비율이 지난 뒤부터 사라진다(0.6 = 마지막 40%).")] + [Range(0f, 1f)] public float toastFadeStartRatio = 0.6f; + + [Tooltip("등장 펀치 배율(1 = 없음).")] + public float toastPunchScale = 1.25f; + + [Tooltip("펀치가 1 로 수렴하는 시간(초).")] + public float toastPunchSeconds = 0.18f; + + [Tooltip("전투력이 오를 때 색.")] + public Color powerUpColor = new Color(1f, 0.84f, 0.35f, 1f); + + [Tooltip("전투력이 내릴 때 색(showOnPowerDown 이 켜져 있을 때만 쓴다).")] + public Color powerDownColor = new Color(0.72f, 0.78f, 0.88f, 1f); + + [Tooltip("타수 비교 줄 색.")] + public Color hitsCompareColor = new Color(0.62f, 1f, 0.78f, 1f); + + [Tooltip("전투력 상승 문구. {0} = 퍼센트.")] + public string powerUpFormat = "전투력 +{0}%"; + + [Tooltip("전투력 하락 문구. {0} = 퍼센트(부호 없는 절대값).")] + public string powerDownFormat = "전투력 -{0}%"; + + [Tooltip("퍼센트 소수 자릿수.")] + [Range(0, 3)] public int percentDigits = 1; + + [Tooltip("이 퍼센트 미만의 변화는 띄우지 않는다(반올림 잡음 차단).")] + public float minPercentToShow = 0.5f; + + [Tooltip("전투력이 내려갈 때도 띄울지(장비 해제 등). 기본 off = 「성장 체감」만 보여준다.")] + public bool showOnPowerDown = false; + + [Tooltip("전투력 값 변화를 확인하는 주기(초). 원본에 장비·강화 변경 이벤트가 없어 폴링한다(실측: Assets/Script 훅 0).")] + public float pollIntervalSeconds = 0.5f; + + [Tooltip("직전 토스트 뒤 이 시간 안의 변화는 한 줄로 합친다(강화 연타 대비).")] + public float announceCooldownSeconds = 1f; + + [Tooltip("레벨업 프레임에는 토스트를 생략한다 — 같은 값을 스탯 팝업의 전투력 줄이 이미 보여 준다(중복 표시 0).")] + public bool suppressToastOnLevelUp = true; + + [Tooltip("토스트 테두리(813y WLTextEdge 규약 · 밝은 배경 대비). enabled=0 이면 원본 머티리얼 그대로.")] + public WLTextEdge toastEdge = new WLTextEdge + { + enabled = true, + outlineWidth = 0.26f, + outlineColor = new Color(0.03f, 0.03f, 0.06f, 1f), + shadowOffset = new Vector2(0.55f, -0.55f), + shadowColor = new Color(0f, 0f, 0f, 0f), // 알파 0 = 그림자 없음(813y 규약) + shadowSoftness = 0.2f, + faceDilate = 0f, + }; + + // ── 레벨업 팝업 전투력 줄 (813y StatPopup) ─────────────────────────── + [Header("레벨업 팝업 전투력 줄 (813y StatPopup 에 1줄)")] + [Tooltip("끄면 팝업에 전투력 줄이 붙지 않는다(팝업 자체는 813y 그대로).")] + public bool statPopupLineEnabled = true; + + [Tooltip("변화가 있을 때 문구. {0} = 이전 전투력 · {1} = 새 전투력 · {2} = 퍼센트(부호 포함).")] + public string statPopupLineFormat = "전투력 {0} → {1} ({2}%)"; + + [Tooltip("변화가 없을 때 문구(원본은 레벨업으로 스탯이 바로 오르지 않는다 — 813k 보고 ①). {0} = 현재 전투력.")] + public string statPopupLineFormatNoChange = "전투력 {0}"; + + [Tooltip("전투력 줄 글자 크기(px).")] + public float statPopupLineFontPx = 44f; + + [Tooltip("전투력 줄 색.")] + public Color statPopupLineColor = new Color(1f, 0.84f, 0.35f, 1f); + + // ── 처치 타수 비교 ──────────────────────────────────────────────────── + [Header("처치 타수 비교 (기준서 §E 5-3 「타수 −1」)")] + [Tooltip("끄면 타수 계측·비교 토스트가 전부 무동작.")] + public bool hitsPerKillEnabled = true; + + [Tooltip("타수의 분자. HitConfirmed = 실제 적중 수(권장) · AttackStarted = 기준서 §E 5-3 검증 방법과 동일.")] + public WLHitSource hitsSource = WLHitSource.HitConfirmed; + + [Tooltip("한 창(window)의 길이(초). 발주서 §1-4 = 60.")] + public float windowSeconds = 60f; + + [Tooltip("이 처치 수를 못 채운 창은 버린다(표본 부족).")] + public int minKillsPerWindow = 5; + + [Tooltip("비교 문구. {0} = 이전 타수 · {1} = 새 타수.")] + public string hitsCompareFormat = "이제 {0}방 → {1}방"; + + [Tooltip("켜면 다음 존 클리어(813p RunEvents.ZoneCleared) 때 띄운다 — 발주서 §1-4.")] + public bool showCompareOnZoneCleared = true; + + [Tooltip("런 축이 안 돌 때(ZoneCleared 가 안 옴) 무장 즉시 띄울지.")] + public bool showCompareImmediatelyIfNoRun = true; + + [Tooltip("무장한 비교 문구가 이 시간 안에 표시되지 않으면 버린다(초).")] + public float compareArmSeconds = 120f; + + [Tooltip("타수 감소가 이 값 이상일 때만 띄운다(방). 1 = 「타수 −1」 = 기준서 §E 5-3 2점 조건.")] + public int compareMinDropHits = 1; + } +} diff --git a/Assets/WL/UI/Settings/WLPowerDeltaSettings.cs.meta b/Assets/WL/UI/Settings/WLPowerDeltaSettings.cs.meta new file mode 100644 index 000000000..120e5d22a --- /dev/null +++ b/Assets/WL/UI/Settings/WLPowerDeltaSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ed82a4ef1a7c450caeedf5bb347f1ddc