405 lines
21 KiB
C#
405 lines
21 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
|
// StatPopup.cs — 레벨업 스탯 팝업 (WL-813g §4 · #813)
|
|
//
|
|
// 기준서 v1 §B 요소 5 「레벨업 = FX + HP/MP 전회복 + **스탯 팝업 3개**」 · §C 요소 5 「이벤트 0 → 813k(S)」.
|
|
//
|
|
// 🔴 **현재 상태 = 인터페이스 + 표시만 (발생원은 후속)**
|
|
// · `WL.Combat.Core.CombatEvents.LevelUp` 는 811b 가 **API 만** 만들어 두었고 발생 지점
|
|
// (`ServerClass.Add_Exp :2729-2734` · Systems 소유)은 **813k** 몫이다 — 이 세션 시점에
|
|
// `wl/gameplay/WL-813k-levelup` 브랜치에는 실작업 커밋이 없다(`git diff main...` 비어 있음 · 실측).
|
|
// · 그래서 페이로드는 지금 `{pc, newLevel}` 뿐이다. 이전/새 스탯 3개를 813k 가 실어 보내면
|
|
// **`StatPopup.Report(...)` 로 그대로 밀어 넣으면 된다**(아래 공개 API · 이 파일만 수정하면 끝).
|
|
// · 그 전까지는 이 컴포넌트가 스스로 **직전 스냅샷과의 차이**로 스탯 증가분을 만든다(대체 경로).
|
|
//
|
|
// ■ 롤백(C8) — 설정이 없거나 textEnabled/statPopupEnabled = false 면 구독 0 · 표시 0.
|
|
//
|
|
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
using System.Collections.Generic;
|
|
using System.Text;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using WL.Combat.Core;
|
|
using WL.Combat.Growth;
|
|
|
|
namespace WL.UI
|
|
{
|
|
/// <summary>스탯 한 줄 — 813k 가 이전/새 값을 실어 보낼 때 쓰는 페이로드.</summary>
|
|
public struct WLStatDelta
|
|
{
|
|
public string name;
|
|
public double prev;
|
|
public double next;
|
|
public double Delta { get { return next - prev; } }
|
|
}
|
|
|
|
[DisallowMultipleComponent]
|
|
[RequireComponent(typeof(RectTransform))]
|
|
public class StatPopup : MonoBehaviour
|
|
{
|
|
[Header("구성 요소 (BuildIfNeeded 가 만든다)")]
|
|
[SerializeField] private CanvasGroup group;
|
|
[SerializeField] private RectTransform box;
|
|
[SerializeField] private TextMeshProUGUI titleLabel;
|
|
[SerializeField] private TMP_FontAsset font;
|
|
|
|
private readonly List<TextMeshProUGUI> _rows = new List<TextMeshProUGUI>();
|
|
private readonly Dictionary<int, double> _snapshot = new Dictionary<int, double>();
|
|
private RectTransform _rt;
|
|
private bool _subscribed;
|
|
private bool _usingGrowth;
|
|
private float _life, _lifeMax, _elapsed;
|
|
private string _lastEdge = "";
|
|
|
|
/// <summary>813k(또는 다른 발생원)가 찾아 쓸 수 있는 현재 활성 팝업.</summary>
|
|
public static StatPopup Active { get; private set; }
|
|
|
|
// ── 진단(프로브가 읽는다 · 실측만)
|
|
public int LevelUpSeen { get; private set; }
|
|
public int LastLevel { get; private set; }
|
|
public int LastRowCount { get; private set; }
|
|
public string LastText { get; private set; }
|
|
public bool Subscribed { get { return _subscribed; } }
|
|
public bool Visible { get { return group != null && group.alpha > 0f; } }
|
|
/// <summary>813k 축(GrowthEvents)을 물었는가. false = 811b 코어 축.</summary>
|
|
public bool UsingGrowthEvents { get { return _usingGrowth; } }
|
|
/// <summary>마지막 표시가 "포인트" 줄로 떨어졌는가(스탯 변화 0).</summary>
|
|
public bool UsedPointLine { get; private set; }
|
|
public int LastLevelsGained { get; private set; }
|
|
public int LastStatPointTotal { get; private set; }
|
|
public string LastEdge { get { return _lastEdge; } }
|
|
|
|
private void OnEnable() { Initialize(); }
|
|
private void OnDisable() { Subscribe(false); if (Active == this) Active = null; }
|
|
|
|
/// <summary>OnEnable 본체 분리(813c 교훈) — 에디트 모드도 같은 경로.</summary>
|
|
public string Initialize()
|
|
{
|
|
_rt = GetComponent<RectTransform>();
|
|
Active = this;
|
|
BuildIfNeeded();
|
|
string layout = ApplyLayout();
|
|
Hide();
|
|
var s = WLCombatTextSettings.Instance;
|
|
bool on = WLCombatTextSettings.Enabled && s != null && s.statPopupEnabled;
|
|
Subscribe(on);
|
|
TakeSnapshot();
|
|
return (on ? "구독 ON · " : "설정 off — 구독 0 · ") + layout;
|
|
}
|
|
|
|
public void Subscribe(bool on)
|
|
{
|
|
if (on == _subscribed) return;
|
|
var s = WLCombatTextSettings.Instance;
|
|
bool growth = s == null || s.statPopupUseGrowthEvents;
|
|
if (on)
|
|
{
|
|
// 813k 축(스탯 3종 + 잔여 포인트)이 우선. 811b 코어 축은 813k 가 forwardToCombatEvents 로
|
|
// **같은 프레임에 또** 쏘므로, 둘 다 구독하면 팝업이 두 번 뜬다 → 하나만 문다.
|
|
if (growth) { GrowthEvents.LevelUp.Add(OnGrowthLevelUp); _usingGrowth = true; }
|
|
else { CombatEvents.LevelUp.Add(OnLevelUp); _usingGrowth = false; }
|
|
}
|
|
else
|
|
{
|
|
if (_usingGrowth) GrowthEvents.LevelUp.Remove(OnGrowthLevelUp);
|
|
else CombatEvents.LevelUp.Remove(OnLevelUp);
|
|
}
|
|
_subscribed = on;
|
|
}
|
|
|
|
public bool BuildIfNeeded()
|
|
{
|
|
if (_rt == null) _rt = GetComponent<RectTransform>();
|
|
var s = WLCombatTextSettings.Instance;
|
|
int want = s != null ? Mathf.Max(1, s.statPopupRows) : 3;
|
|
bool made = false;
|
|
|
|
if (group == null)
|
|
{
|
|
group = GetComponent<CanvasGroup>();
|
|
if (group == null) { group = gameObject.AddComponent<CanvasGroup>(); made = true; }
|
|
group.interactable = false; group.blocksRaycasts = false;
|
|
}
|
|
if (box == null) { box = WLTextFxUtil.NewChild(_rt, "Box"); made = true; }
|
|
if (titleLabel == null) { titleLabel = WLTextFxUtil.NewText(box, "Title", font, TextAlignmentOptions.Center); made = true; }
|
|
while (_rows.Count < want)
|
|
{
|
|
_rows.Add(WLTextFxUtil.NewText(box, "Row" + _rows.Count, font, TextAlignmentOptions.Center));
|
|
made = true;
|
|
}
|
|
return made;
|
|
}
|
|
|
|
public string ApplyLayout()
|
|
{
|
|
var s = WLCombatTextSettings.Instance;
|
|
if (s == null) return "WLCombatTextSettings 없음 — 배치 건너뜀";
|
|
if (_rt == null) _rt = GetComponent<RectTransform>();
|
|
if (box == null) return "구성 요소 없음 — BuildIfNeeded 먼저";
|
|
|
|
float u = WLTextFxUtil.UnitsPerPx(this, s);
|
|
WLTextFxUtil.Stretch(_rt);
|
|
|
|
float h = (s.statPopupTitleFontPx + s.statPopupRowHeightPx * s.statPopupRows) * u;
|
|
box.anchorMin = box.anchorMax = new Vector2(0.5f, 0.5f);
|
|
box.pivot = new Vector2(0.5f, 0.5f);
|
|
box.sizeDelta = new Vector2(s.designWidthPx * u, h);
|
|
box.anchoredPosition = new Vector2(0f, s.statPopupCenterOffsetPx * u);
|
|
|
|
titleLabel.rectTransform.anchorMin = titleLabel.rectTransform.anchorMax = new Vector2(0.5f, 1f);
|
|
titleLabel.rectTransform.pivot = new Vector2(0.5f, 1f);
|
|
titleLabel.rectTransform.sizeDelta = new Vector2(s.designWidthPx * u, s.statPopupTitleFontPx * 1.2f * u);
|
|
titleLabel.rectTransform.anchoredPosition = Vector2.zero;
|
|
titleLabel.fontSize = s.statPopupTitleFontPx * u;
|
|
titleLabel.color = s.statPopupTitleColor;
|
|
|
|
for (int i = 0; i < _rows.Count; i++)
|
|
{
|
|
var rt = _rows[i].rectTransform;
|
|
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 1f);
|
|
rt.pivot = new Vector2(0.5f, 1f);
|
|
rt.sizeDelta = new Vector2(s.designWidthPx * u, s.statPopupRowHeightPx * u);
|
|
rt.anchoredPosition = new Vector2(0f, -(s.statPopupTitleFontPx * 1.2f + s.statPopupRowHeightPx * i) * u);
|
|
_rows[i].fontSize = s.statPopupRowFontPx * u;
|
|
_rows[i].color = s.statPopupRowColor;
|
|
}
|
|
|
|
// WL-813y — 밝은 배경 대비(Q3 D-6 ①). 값은 SO · 포스트 0.
|
|
var edge = s.Edge(s.statPopupEdge);
|
|
_lastEdge = WLTextFxUtil.ApplyEdge(titleLabel, edge);
|
|
for (int i = 0; i < _rows.Count; i++) WLTextFxUtil.ApplyEdge(_rows[i], edge);
|
|
|
|
return "box pos=" + box.anchoredPosition + " size=" + box.sizeDelta + " rows=" + _rows.Count +
|
|
" unitsPerPx=" + u.ToString("F4") + " titleFont=" + titleLabel.fontSize.ToString("F1") +
|
|
" edge[" + _lastEdge + "]";
|
|
}
|
|
|
|
// ───────────────────────────────────────────── 이벤트 · 공개 API
|
|
|
|
private void OnLevelUp(in LevelUpEvent e)
|
|
{
|
|
var s = WLCombatTextSettings.Instance;
|
|
if (s == null || !WLCombatTextSettings.Enabled || !s.statPopupEnabled) return;
|
|
Report(e.newLevel, BuildDeltasFromSnapshot(e.pc, s));
|
|
}
|
|
|
|
// ── 813k 축 (WL-813y §1-2 · Q3 결함 D-7) ──────────────────────────────
|
|
/// <summary>
|
|
/// 813k `GrowthEvents.LevelUp` 구독. 페이로드가 스탯 3종의 prev/new 와 잔여 스탯 포인트를 실어 온다
|
|
/// (813k 완료보고 §3). 원본은 레벨업으로 스탯을 올리지 않으므로 **prev == new 가 기본** —
|
|
/// 그때는 "포인트" 줄로 떨어진다(813k 보고 ①).
|
|
/// </summary>
|
|
private void OnGrowthLevelUp(in PlayerLevelUpEvent e)
|
|
{
|
|
var s = WLCombatTextSettings.Instance;
|
|
if (s == null || !WLCombatTextSettings.Enabled || !s.statPopupEnabled) return;
|
|
// 다단 레벨업(같은 Add_Exp 에서 여러 번)은 마지막 것만 남긴다 — 팝업이 겹쳐 깜빡이지 않도록.
|
|
LastLevelsGained = e.levelsGained;
|
|
LastStatPointTotal = e.statPointTotal;
|
|
Show(e.newLevel, BuildDeltasFromGrowth(in e, s), PointLine(s, e.statPointTotal));
|
|
}
|
|
|
|
private static WLStatDelta[] BuildDeltasFromGrowth(in PlayerLevelUpEvent e, WLCombatTextSettings s)
|
|
{
|
|
int n = Mathf.Clamp(s.statPopupRows, 1, 3);
|
|
var arr = new WLStatDelta[n];
|
|
if (n > 0) arr[0] = new WLStatDelta { name = e.statAKind.ToString(), prev = e.prevStatA, next = e.newStatA };
|
|
if (n > 1) arr[1] = new WLStatDelta { name = e.statBKind.ToString(), prev = e.prevStatB, next = e.newStatB };
|
|
if (n > 2) arr[2] = new WLStatDelta { name = e.statCKind.ToString(), prev = e.prevStatC, next = e.newStatC };
|
|
return arr;
|
|
}
|
|
|
|
private static string PointLine(WLCombatTextSettings s, int statPointTotal)
|
|
{
|
|
if (s == null || string.IsNullOrEmpty(s.statPopupPointFormat)) return null;
|
|
return DSUtil.Format(s.statPopupPointFormat, statPointTotal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 🔵 813k 연결점 — 이전/새 스탯을 그대로 실어 보내면 이 메서드가 팝업을 띄운다.
|
|
/// deltas 가 null 이면 컴포넌트가 스스로 스냅샷 차이를 만든다.
|
|
/// </summary>
|
|
public static string Report(int newLevel, WLStatDelta[] deltas)
|
|
{
|
|
if (Active == null) return "StatPopup 없음(프리팹 노드 미배치)";
|
|
return Active.Show(newLevel, deltas);
|
|
}
|
|
|
|
/// <summary>팝업을 띄운다(프로브도 이 경로를 쓴다).</summary>
|
|
public string Show(int newLevel, WLStatDelta[] deltas) { return Show(newLevel, deltas, null); }
|
|
|
|
/// <summary>
|
|
/// 팝업을 띄운다. <paramref name="fallbackLine"/> 은 **스탯 변화가 하나도 없을 때** 대신 채우는 줄
|
|
/// (WL-813y — 원본은 레벨업으로 스탯을 안 올리고 포인트만 준다 · 813k 보고 ①).
|
|
/// </summary>
|
|
public string Show(int newLevel, WLStatDelta[] deltas, string fallbackLine)
|
|
{
|
|
var s = WLCombatTextSettings.Instance;
|
|
if (s == null) return "설정 없음";
|
|
BuildIfNeeded();
|
|
ApplyLayout();
|
|
|
|
LevelUpSeen++; LastLevel = newLevel;
|
|
titleLabel.text = DSUtil.Format(s.statPopupTitleFormat, newLevel);
|
|
|
|
int shown = 0;
|
|
var sb = new StringBuilder(titleLabel.text);
|
|
for (int i = 0; i < _rows.Count; i++)
|
|
{
|
|
bool has = deltas != null && i < deltas.Length;
|
|
double d = has ? deltas[i].Delta : 0d;
|
|
if (!has || (!s.statPopupShowZero && d <= 0d)) { _rows[i].text = ""; continue; }
|
|
_rows[i].text = DSUtil.Format(s.statPopupRowFormat, deltas[i].name, d.ToString("N0"), deltas[i].next.ToString("N0"));
|
|
sb.Append(" / ").Append(_rows[i].text);
|
|
shown++;
|
|
}
|
|
UsedPointLine = false;
|
|
if (shown == 0 && !string.IsNullOrEmpty(fallbackLine) && _rows.Count > 0)
|
|
{
|
|
_rows[0].text = fallbackLine;
|
|
sb.Append(" / ").Append(fallbackLine);
|
|
shown = 1;
|
|
UsedPointLine = true;
|
|
}
|
|
LastRowCount = shown;
|
|
LastText = sb.ToString();
|
|
|
|
_lifeMax = _life = Mathf.Max(0.01f, s.statPopupSeconds);
|
|
_elapsed = 0f;
|
|
group.alpha = 1f;
|
|
box.localScale = Vector3.one * WLTextFxUtil.PunchScale(0f, s.statPopupPunchScale, s.statPopupPunchSeconds);
|
|
TakeSnapshot();
|
|
return "level=" + newLevel + " rows=" + shown + " text=\"" + LastText + "\" pos=" + box.anchoredPosition;
|
|
}
|
|
|
|
public void Hide()
|
|
{
|
|
if (group != null) group.alpha = 0f;
|
|
if (box != null) box.localScale = Vector3.one;
|
|
_life = 0f;
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (_life <= 0f) return;
|
|
var s = WLCombatTextSettings.Instance;
|
|
if (s == null) { Hide(); return; }
|
|
float dt = Time.unscaledDeltaTime;
|
|
_life -= dt; _elapsed += dt;
|
|
float passed = WLTextFxUtil.Passed(_life, _lifeMax);
|
|
group.alpha = WLTextFxUtil.FadeAlpha(passed, s.statPopupFadeStartRatio);
|
|
box.localScale = Vector3.one * WLTextFxUtil.PunchScale(_elapsed, s.statPopupPunchScale, s.statPopupPunchSeconds);
|
|
if (_life <= 0f) Hide();
|
|
}
|
|
|
|
// ───────────────────────────────────────────── 스냅샷 대체 경로(813k 전까지)
|
|
|
|
/// <summary>설정에 적힌 스탯들의 현재 값을 기억한다.</summary>
|
|
public void TakeSnapshot()
|
|
{
|
|
var s = WLCombatTextSettings.Instance;
|
|
if (s == null || s.statPopupStats == null) return;
|
|
var stat = (Application.isPlaying && MyValue.bMyPC && MyValue.MyPC != null) ? MyValue.MyPC.Get_StatInfo() : null;
|
|
if (stat == null) return;
|
|
for (int i = 0; i < s.statPopupStats.Length; i++)
|
|
_snapshot[(int)s.statPopupStats[i]] = stat.Get_Stat(s.statPopupStats[i]);
|
|
}
|
|
|
|
private WLStatDelta[] BuildDeltasFromSnapshot(Actor pc, WLCombatTextSettings s)
|
|
{
|
|
if (s.statPopupStats == null || s.statPopupStats.Length == 0) return null;
|
|
var actor = pc != null ? pc : (MyValue.bMyPC ? (Actor)MyValue.MyPC : null);
|
|
if (actor == null) return null;
|
|
var stat = actor.Get_StatInfo();
|
|
if (stat == null) return null;
|
|
|
|
int n = Mathf.Min(s.statPopupStats.Length, Mathf.Max(1, s.statPopupRows));
|
|
var arr = new WLStatDelta[n];
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
var key = s.statPopupStats[i];
|
|
double now = stat.Get_Stat(key);
|
|
double prev;
|
|
if (!_snapshot.TryGetValue((int)key, out prev)) prev = now;
|
|
arr[i] = new WLStatDelta { name = key.ToString(), prev = prev, next = now };
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
// ───────────────────────────────────────────── 프로브(에디트 모드 · Play 0)
|
|
|
|
/// <summary>합성 레벨업 이벤트 — 811b 코어의 실제 Dispatch 경로(구독 배선까지 검증).</summary>
|
|
public static string RaiseFakeLevelUp(int newLevel)
|
|
{
|
|
var e = new LevelUpEvent { pc = null, newLevel = newLevel, time = Time.unscaledTime, frame = Time.frameCount };
|
|
CombatEvents.LevelUp.Dispatch(in e);
|
|
return "dispatch LevelUp level=" + newLevel + " · 구독자=" + CombatEvents.LevelUp.Count;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 합성 813k 레벨업 — GrowthEvents 의 **실제 Dispatch 경로**로 쏜다(구독 배선까지 검증).
|
|
/// statDelta 가 0 이면 원본 기본값(prev == new) 상황 = "포인트" 줄로 떨어지는지 확인한다.
|
|
/// </summary>
|
|
public static string RaiseFakeGrowthLevelUp(int newLevel, int statPointTotal, double statDelta)
|
|
{
|
|
var e = new PlayerLevelUpEvent
|
|
{
|
|
data = null,
|
|
pc = null,
|
|
prevLevel = newLevel - 1,
|
|
newLevel = newLevel,
|
|
levelsGained = 1,
|
|
statPointTotal = statPointTotal,
|
|
statAKind = eStat.MaxHP, statBKind = eStat.MaxMP, statCKind = eStat.ATK,
|
|
prevStatA = 1000d, newStatA = 1000d + statDelta,
|
|
prevStatB = 200d, newStatB = 200d + statDelta,
|
|
prevStatC = 50d, newStatC = 50d + statDelta,
|
|
hpBefore = 370d, maxHp = 1000d, mpBefore = 120d, maxMp = 200d,
|
|
position = Vector3.zero,
|
|
time = Time.unscaledTime,
|
|
frame = Time.frameCount,
|
|
};
|
|
GrowthEvents.LevelUp.Dispatch(in e);
|
|
return "dispatch GrowthEvents.LevelUp level=" + newLevel + " point=" + statPointTotal +
|
|
" statDelta=" + statDelta + " · 구독자=" + GrowthEvents.LevelUp.Count;
|
|
}
|
|
|
|
/// <summary>합성 스탯 델타로 직접 표시(813k 페이로드가 오면 이 모양이 된다).</summary>
|
|
public string ShowFake(int newLevel, string[] names, double[] prev, double[] next)
|
|
{
|
|
int n = Mathf.Min(names.Length, Mathf.Min(prev.Length, next.Length));
|
|
var arr = new WLStatDelta[n];
|
|
for (int i = 0; i < n; i++) arr[i] = new WLStatDelta { name = names[i], prev = prev[i], next = next[i] };
|
|
return Show(newLevel, arr);
|
|
}
|
|
|
|
public string Dump()
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("StatPopup subscribed=" + _subscribed + " 축=" + (_usingGrowth ? "813k GrowthEvents" : "811b CombatEvents") +
|
|
"(구독자 growth=" + GrowthEvents.LevelUp.Count + " core=" + CombatEvents.LevelUp.Count + ")" +
|
|
" levelUpSeen=" + LevelUpSeen +
|
|
" lastLevel=" + LastLevel + " rows=" + LastRowCount +
|
|
" 포인트줄=" + UsedPointLine + " statPoint=" + LastStatPointTotal + " levelsGained=" + LastLevelsGained +
|
|
" alpha=" + (group != null ? group.alpha.ToString("F2") : "-"));
|
|
sb.AppendLine(" edge = " + _lastEdge);
|
|
sb.AppendLine(" title=\"" + (titleLabel != null ? titleLabel.text : "") + "\" pos=" +
|
|
(box != null ? box.anchoredPosition.ToString() : "-") +
|
|
" size=" + (box != null ? box.sizeDelta.ToString() : "-") +
|
|
" scale=" + (box != null ? box.localScale.x.ToString("F3") : "-"));
|
|
for (int i = 0; i < _rows.Count; i++)
|
|
sb.AppendLine(" row[" + i + "] \"" + _rows[i].text + "\" pos=" + _rows[i].rectTransform.anchoredPosition +
|
|
" font=" + _rows[i].fontSize.ToString("F1"));
|
|
return sb.ToString();
|
|
}
|
|
|
|
public void SetFont(TMP_FontAsset f)
|
|
{
|
|
font = f;
|
|
if (titleLabel != null && f != null) titleLabel.font = f;
|
|
for (int i = 0; i < _rows.Count; i++) if (_rows[i] != null && f != null) _rows[i].font = f;
|
|
}
|
|
}
|
|
}
|