// ───────────────────────────────────────────────────────────────────────────── // 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; namespace WL.UI { /// 스탯 한 줄 — 813k 가 이전/새 값을 실어 보낼 때 쓰는 페이로드. 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 _rows = new List(); private readonly Dictionary _snapshot = new Dictionary(); private RectTransform _rt; private bool _subscribed; private float _life, _lifeMax, _elapsed; /// 813k(또는 다른 발생원)가 찾아 쓸 수 있는 현재 활성 팝업. 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; } } private void OnEnable() { Initialize(); } private void OnDisable() { Subscribe(false); if (Active == this) Active = null; } /// OnEnable 본체 분리(813c 교훈) — 에디트 모드도 같은 경로. public string Initialize() { _rt = GetComponent(); 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; if (on) CombatEvents.LevelUp.Add(OnLevelUp); else CombatEvents.LevelUp.Remove(OnLevelUp); _subscribed = on; } public bool BuildIfNeeded() { if (_rt == null) _rt = GetComponent(); var s = WLCombatTextSettings.Instance; int want = s != null ? Mathf.Max(1, s.statPopupRows) : 3; bool made = false; if (group == null) { group = GetComponent(); if (group == null) { group = gameObject.AddComponent(); 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(); 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; } return "box pos=" + box.anchoredPosition + " size=" + box.sizeDelta + " rows=" + _rows.Count + " unitsPerPx=" + u.ToString("F4") + " titleFont=" + titleLabel.fontSize.ToString("F1"); } // ───────────────────────────────────────────── 이벤트 · 공개 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 연결점 — 이전/새 스탯을 그대로 실어 보내면 이 메서드가 팝업을 띄운다. /// deltas 가 null 이면 컴포넌트가 스스로 스냅샷 차이를 만든다. /// public static string Report(int newLevel, WLStatDelta[] deltas) { if (Active == null) return "StatPopup 없음(프리팹 노드 미배치)"; return Active.Show(newLevel, deltas); } /// 팝업을 띄운다(프로브도 이 경로를 쓴다). public string Show(int newLevel, WLStatDelta[] deltas) { 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++; } 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 전까지) /// 설정에 적힌 스탯들의 현재 값을 기억한다. 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) /// 합성 레벨업 이벤트 — 811b 코어의 실제 Dispatch 경로(구독 배선까지 검증). 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; } /// 합성 스탯 델타로 직접 표시(813k 페이로드가 오면 이 모양이 된다). 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 + " levelUpSeen=" + LevelUpSeen + " lastLevel=" + LastLevel + " rows=" + LastRowCount + " alpha=" + (group != null ? group.alpha.ToString("F2") : "-")); 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; } } }