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