[WL-813l] 성장 체감: 전투력 % 토스트 · 팝업 줄 · 타수 비교 (#813)

This commit is contained in:
깃 관리자 2026-09-09 12:56:15 +09:00
parent b668ff4d67
commit 80c0ee3ad8
9 changed files with 1718 additions and 2 deletions

View File

@ -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

View File

@ -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 <wt> --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);
}
}
/// <summary>에셋이 없으면 클래스 기본값으로 만든다(값은 전부 C# 기본값 = 코드에 적힌 그대로).</summary>
public static WLPowerDeltaSettings EnsureAsset()
{
var existing = AssetDatabase.LoadAssetAtPath<WLPowerDeltaSettings>(AssetPath);
if (existing == null)
{
var dir = Path.GetDirectoryName(AssetPath);
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
var so = ScriptableObject.CreateInstance<WLPowerDeltaSettings>();
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<PCActor>();
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<Actor>(); // 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 없음 — 문구만 기록)");
}
/// <summary>813k 의 공개 발생기를 그대로 쓴다(data=null 은 813k 가 허용 — 로비 경로와 같은 모양).</summary>
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<StatPopup>();
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<TextMeshProUGUI>();
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<CanvasGroup>();
var lb = lrt.GetComponentInChildren<TextMeshProUGUI>(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<TextMeshProUGUI>(true);
var cg0 = l0.GetComponent<CanvasGroup>();
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<TextMeshProUGUI>(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 + ")");
}
/// <summary>실제 이벤트 축으로 타수·처치를 채운다(카운터 직접 조작 0 · 구독 경로 전체를 탄다).</summary>
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");
}
}

View File

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

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b96f698eefb043b8b4103930fcb4e4c7

View File

@ -43,6 +43,8 @@ namespace WL.UI
[SerializeField] private RectTransform box; [SerializeField] private RectTransform box;
[SerializeField] private TextMeshProUGUI titleLabel; [SerializeField] private TextMeshProUGUI titleLabel;
[SerializeField] private TMP_FontAsset font; [SerializeField] private TMP_FontAsset font;
/// <summary>WL-813l — 「전투력 A → B (+N %)」 한 줄(스탯 줄 아래). 값·on/off 는 WLPowerDeltaSettings.</summary>
[SerializeField] private TextMeshProUGUI powerLabel;
private readonly List<TextMeshProUGUI> _rows = new List<TextMeshProUGUI>(); private readonly List<TextMeshProUGUI> _rows = new List<TextMeshProUGUI>();
private readonly Dictionary<int, double> _snapshot = new Dictionary<int, double>(); private readonly Dictionary<int, double> _snapshot = new Dictionary<int, double>();
@ -69,6 +71,17 @@ namespace WL.UI
public int LastLevelsGained { get; private set; } public int LastLevelsGained { get; private set; }
public int LastStatPointTotal { get; private set; } public int LastStatPointTotal { get; private set; }
public string LastEdge { get { return _lastEdge; } } public string LastEdge { get { return _lastEdge; } }
/// <summary>WL-813l — 마지막으로 채운 전투력 줄(빈 문자열 = 안 붙음).</summary>
public string LastPowerLine { get; private set; } = "";
/// <summary>WL-813l — 전투력 줄이 켜져 있는가(에셋 없음·off 면 false).</summary>
public static bool PowerLineOn
{
get
{
var p = WLPowerDeltaSettings.Instance;
return WLPowerDeltaSettings.Enabled && p != null && p.statPopupLineEnabled;
}
}
private void OnEnable() { Initialize(); } private void OnEnable() { Initialize(); }
private void OnDisable() { Subscribe(false); if (Active == this) Active = null; } 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)); _rows.Add(WLTextFxUtil.NewText(box, "Row" + _rows.Count, font, TextAlignmentOptions.Center));
made = true; made = true;
} }
// WL-813l — 전투력 줄은 스탯 줄과 따로 만든다(스탯 3줄을 밀어내지 않는다).
if (PowerLineOn && powerLabel == null)
{
powerLabel = WLTextFxUtil.NewText(box, "PowerRow", font, TextAlignmentOptions.Center);
made = true;
}
return made; return made;
} }
@ -141,7 +160,10 @@ namespace WL.UI
float u = WLTextFxUtil.UnitsPerPx(this, s); float u = WLTextFxUtil.UnitsPerPx(this, s);
WLTextFxUtil.Stretch(_rt); 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.anchorMin = box.anchorMax = new Vector2(0.5f, 0.5f);
box.pivot = new Vector2(0.5f, 0.5f); box.pivot = new Vector2(0.5f, 0.5f);
box.sizeDelta = new Vector2(s.designWidthPx * u, h); box.sizeDelta = new Vector2(s.designWidthPx * u, h);
@ -165,12 +187,27 @@ namespace WL.UI
_rows[i].color = s.statPopupRowColor; _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. // WL-813y — 밝은 배경 대비(Q3 D-6 ①). 값은 SO · 포스트 0.
var edge = s.Edge(s.statPopupEdge); var edge = s.Edge(s.statPopupEdge);
_lastEdge = WLTextFxUtil.ApplyEdge(titleLabel, edge); _lastEdge = WLTextFxUtil.ApplyEdge(titleLabel, edge);
for (int i = 0; i < _rows.Count; i++) WLTextFxUtil.ApplyEdge(_rows[i], 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 + return "box pos=" + box.anchoredPosition + " size=" + box.sizeDelta + " rows=" + _rows.Count +
" power=" + (powerOn ? "on" : "off") +
" unitsPerPx=" + u.ToString("F4") + " titleFont=" + titleLabel.fontSize.ToString("F1") + " unitsPerPx=" + u.ToString("F4") + " titleFont=" + titleLabel.fontSize.ToString("F1") +
" edge[" + _lastEdge + "]"; " edge[" + _lastEdge + "]";
} }
@ -262,6 +299,15 @@ namespace WL.UI
shown = 1; shown = 1;
UsedPointLine = true; 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; LastRowCount = shown;
LastText = sb.ToString(); LastText = sb.ToString();
@ -270,7 +316,8 @@ namespace WL.UI
group.alpha = 1f; group.alpha = 1f;
box.localScale = Vector3.one * WLTextFxUtil.PunchScale(0f, s.statPopupPunchScale, s.statPopupPunchSeconds); box.localScale = Vector3.one * WLTextFxUtil.PunchScale(0f, s.statPopupPunchScale, s.statPopupPunchSeconds);
TakeSnapshot(); 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() public void Hide()

View File

@ -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

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c8aec95b3373e0045aee111f98b85caf
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
{
/// <summary>「몇 방에 죽었나」를 세는 분자(타수)의 출처.</summary>
public enum WLHitSource
{
/// <summary>메인 PC 가 **실제로 적중시킨** 타격 수(`CombatEvents.HitConfirmed.byMainPC`).
/// 광역기로 여러 마리를 때리면 마리 수만큼 센다 = 「한 마리를 몇 방에」에 가장 가깝다.</summary>
HitConfirmed = 0,
/// <summary>메인 PC 의 **공격 모션 시작** 수(`CombatEvents.AttackStarted.isMainPC`).
/// 기준서 §E 5-3 의 검증 방법(`AttackStarted`/`Killed` 비)과 같은 분자다.</summary>
AttackStarted = 1,
}
[CreateAssetMenu(fileName = "WLPowerDeltaSettings", menuName = "WL/Power Delta Settings", order = 34)]
public sealed class WLPowerDeltaSettings : ScriptableObject
{
/// <summary>Resources 경로 — 런타임 로드 키.</summary>
public const string ResourcesPath = "WL/WLPowerDeltaSettings";
private static WLPowerDeltaSettings s_instance;
private static bool s_tried;
/// <summary>에셋이 없으면 null(호출부는 null 이면 전부 건너뛴다 = C8 롤백).</summary>
public static WLPowerDeltaSettings Instance
{
get
{
if (s_instance == null && !s_tried)
{
s_tried = true;
s_instance = Resources.Load<WLPowerDeltaSettings>(ResourcesPath);
}
return s_instance;
}
}
/// <summary>에디터에서 에셋을 새로 만들거나 값을 바꾼 뒤 캐시를 버린다.</summary>
public static void ClearCache() { s_instance = null; s_tried = false; }
/// <summary>프로브·QA 용 런타임 스위치(에셋을 건드리지 않고 끈다).</summary>
public static bool RuntimeDisabled;
/// <summary>성장 체감 축이 살아 있는가 = 에셋 존재 · enabled_ · 런타임 스위치 off.</summary>
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;
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ed82a4ef1a7c450caeedf5bb347f1ddc