// ───────────────────────────────────────────────────────────────────────────── // LootToast.cs — 전리품 획득 토스트 (WL-813g §1-3 · #813) // // 기준서 v1 §B 요소 4 「토스트 1.2 s · 동시 3줄 · 등급 색(보유 팔레트 그대로)」 · §C 요소 4 「토스트 없음 → 813g(U)」. // // ■ 입력 경로 (지금 / 나중) // · 지금 = `CombatEvents.Killed`(811b 코어) 로 "뭔가 죽었다"를 알고, 그 뒤 `DropItemInfo.m_StageDropData` // 획득 집계의 **델타**를 폴링해 실제로 먹은 아이템만 줄로 띄운다(원본 훅 0 · Gameplay 코드 0). // 집계는 `DropItem.cs:125 Add_Item` 이 채우는 값이라 "떨어진 것"이 아니라 "**주운 것**"이다. // · 나중 = 813f 가 드랍/획득 이벤트를 내면 `Push(itemId, count)` 로 바로 밀어 넣고 폴링을 걷어낸다(후속). // // ■ 등급 색 = `MyValue.Get_GradeColor`(원본 팔레트) 를 그대로 쓴다 — 기준서 §B 요소 4 「새 색 만들 이유 없음」. // 설정 에셋의 lootToastGradeColors 를 채우면 그 값이 우선한다(SO 이관 대비). // // ■ Safe Area (813c 규칙) — 부모(`IngameUIs/WL_HUD`)가 이미 SafeAreaFitter 아래다. 여기서는 부모 기준 우측 정렬만 한다. // // ■ 롤백(C8) — 설정이 없거나 textEnabled/lootToastEnabled = false 면 구독·폴링 0 · 표시 0. // // 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using System.Text; using TMPro; using UnityEngine; using UnityEngine.UI; using WL.Combat.Core; namespace WL.UI { [DisallowMultipleComponent] [RequireComponent(typeof(RectTransform))] public class LootToast : MonoBehaviour { [Header("구성 요소 (BuildIfNeeded 가 만든다)")] [SerializeField] private RectTransform lineRoot; [SerializeField] private TMP_FontAsset font; private class Line { public RectTransform rt; public TextMeshProUGUI label; public CanvasGroup group; public Image panel; // WL-813y — 밝은 배경 대비용 반투명 패널 public int itemId, count; public float life, lifeMax, elapsed; public bool used; } private readonly List _lines = new List(); private readonly Dictionary _seen = new Dictionary(); // itemId → 마지막으로 본 누적 수량 private RectTransform _rt; private bool _subscribed; private float _nextPoll; private bool _armed; // Killed 를 한 번이라도 본 뒤부터 폴링한다(로비에서 헛돌지 않게) // ── 진단(프로브가 읽는다 · 실측만) public int KilledSeen { get; private set; } public int PushCount { get; private set; } public int MergeCount { get; private set; } public int DropCount { get; private set; } // 줄 상한 초과로 밀려난 수 public bool Subscribed { get { return _subscribed; } } /// 마지막으로 적용한 아웃라인/그림자 실측 문자열(WL-813y). public string LastEdge { get; private set; } = ""; public int VisibleLines { get { int n = 0; for (int i = 0; i < _lines.Count; i++) if (_lines[i].used) n++; return n; } } private void OnEnable() { Initialize(); } private void OnDisable() { Subscribe(false); } /// OnEnable 본체 분리(813c 교훈) — 에디트 모드도 같은 경로. public string Initialize() { _rt = GetComponent(); BuildIfNeeded(); string layout = ApplyLayout(); HideAll(); var s = WLCombatTextSettings.Instance; bool on = WLCombatTextSettings.Enabled && s != null && s.lootToastEnabled; Subscribe(on); return (on ? "구독 ON · " : "설정 off — 구독 0 · ") + layout; } public void Subscribe(bool on) { if (on == _subscribed) return; if (on) CombatEvents.Killed.Add(OnKilled); else CombatEvents.Killed.Remove(OnKilled); _subscribed = on; } /// 설정의 줄 수만큼 라벨을 만든다(줄 수를 바꾸면 다시 만든다). public bool BuildIfNeeded() { if (_rt == null) _rt = GetComponent(); var s = WLCombatTextSettings.Instance; int want = s != null ? Mathf.Max(1, s.lootToastLines) : 3; bool made = false; if (lineRoot == null) { lineRoot = WLTextFxUtil.NewChild(_rt, "Lines"); made = true; } while (_lines.Count < want) { int idx = _lines.Count; var lrt = WLTextFxUtil.NewChild(lineRoot, "Line" + idx); // 패널을 먼저 만든다 — uGUI 는 형제 순서가 곧 그리는 순서라 라벨이 위로 온다(813tj 교훈). var prt = WLTextFxUtil.NewChild(lrt, "Panel"); var panel = prt.GetComponent(); if (panel == null) panel = prt.gameObject.AddComponent(); panel.raycastTarget = false; var label = WLTextFxUtil.NewText(lrt, "Label", font, TextAlignmentOptions.Right); var cg = lrt.GetComponent(); if (cg == null) cg = lrt.gameObject.AddComponent(); cg.interactable = false; cg.blocksRaycasts = false; cg.alpha = 0f; _lines.Add(new Line { rt = lrt, label = label, group = cg, panel = panel }); made = true; } for (int i = want; i < _lines.Count; i++) if (_lines[i].rt != null) _lines[i].group.alpha = 0f; // 남는 줄은 숨기기만(파괴하지 않는다) return made; } /// 우측 정렬 · 줄 간격 · 글자 크기 (px → 유닛 환산은 캔버스에서 · 상수 0). public string ApplyLayout() { var s = WLCombatTextSettings.Instance; if (s == null) return "WLCombatTextSettings 없음 — 배치 건너뜀"; if (_rt == null) _rt = GetComponent(); if (lineRoot == null) return "구성 요소 없음 — BuildIfNeeded 먼저"; float u = WLTextFxUtil.UnitsPerPx(this, s); WLTextFxUtil.Stretch(_rt); lineRoot.anchorMin = lineRoot.anchorMax = new Vector2(1f, 0.5f); // 우측 · 세로 중앙 기준 lineRoot.pivot = new Vector2(1f, 0.5f); lineRoot.sizeDelta = new Vector2(s.lootToastWidthPx * u, s.lootToastLineHeightPx * s.lootToastLines * u); lineRoot.anchoredPosition = new Vector2(-s.lootToastRightMarginPx * u, s.lootToastCenterOffsetPx * u); var sb = new StringBuilder(); sb.Append("lines=").Append(_lines.Count).Append(" root pos=").Append(lineRoot.anchoredPosition) .Append(" size=").Append(lineRoot.sizeDelta).Append(" unitsPerPx=").Append(u.ToString("F4")); for (int i = 0; i < _lines.Count; i++) { var l = _lines[i]; l.rt.anchorMin = l.rt.anchorMax = new Vector2(1f, 1f); l.rt.pivot = new Vector2(1f, 1f); l.rt.sizeDelta = new Vector2(s.lootToastWidthPx * u, s.lootToastLineHeightPx * u); l.rt.anchoredPosition = new Vector2(0f, -s.lootToastLineHeightPx * i * u); WLTextFxUtil.Stretch(l.label.rectTransform); l.label.fontSize = s.lootToastFontPx * u; l.label.alignment = TextAlignmentOptions.MidlineRight; // WL-813y — 줄 뒤 패널(대비) + 글자 아웃라인. 값은 전부 SO. if (l.panel != null) { bool on = s.lootToastPanelEnabled; if (l.panel.enabled != on) l.panel.enabled = on; l.panel.color = s.lootToastPanelColor; var prt = l.panel.rectTransform; prt.anchorMin = Vector2.zero; prt.anchorMax = Vector2.one; prt.pivot = new Vector2(0.5f, 0.5f); prt.offsetMin = new Vector2(-s.lootToastPanelPadXPx * u, -s.lootToastPanelPadYPx * u); prt.offsetMax = new Vector2(s.lootToastPanelPadXPx * u, s.lootToastPanelPadYPx * u); } LastEdge = WLTextFxUtil.ApplyEdge(l.label, s.Edge(s.lootToastEdge)); sb.Append(" · [").Append(i).Append("] pos=").Append(l.rt.anchoredPosition); } return sb.ToString(); } // ───────────────────────────────────────────── 입력 private void OnKilled(in KilledEvent e) { KilledSeen++; _armed = true; // 처치가 있어야 드랍이 있다 — 그 뒤부터만 획득 집계를 본다 } /// 813f(후속) · 프로브 공용 입력 — 아이템 1건 획득을 줄로 띄운다. public string Push(int itemId, int count) { var s = WLCombatTextSettings.Instance; if (s == null || !WLCombatTextSettings.Enabled || !s.lootToastEnabled) return "설정 off — 표시 0"; if (!s.lootToastIncludeGold && itemId == GoldItemId) return "골드 제외(설정) — 표시 0"; int grade = 1; string name = itemId.ToString(); // 표가 아직 안 올라온 시점(에디트 모드·로딩 중)에도 죽지 않게 감싼다 — 이름/등급이 없으면 ID·등급 1 로 뜬다. try { var data = table_itemlist.Ins != null ? table_itemlist.Ins.Get_Data(itemId) : null; if (data != null) { grade = Mathf.Max(1, data.n_ItemGrade); var n = data.Get_Name(); if (!string.IsNullOrEmpty(n)) name = n; } } catch { /* 표 미로드 · ID 없음 — 기본값으로 표시 */ } if (grade < s.lootToastMinGrade) return "등급 " + grade + " < 최소 " + s.lootToastMinGrade + " — 표시 0"; BuildIfNeeded(); // 같은 아이템이 병합 창 안이면 기존 줄의 수량만 올린다(줄 폭주 방지) for (int i = 0; i < _lines.Count; i++) { var l = _lines[i]; if (!l.used || l.itemId != itemId) continue; if (l.elapsed > s.lootToastMergeSeconds) continue; l.count += count; l.life = l.lifeMax; l.elapsed = 0f; Render(l, name, grade, s); MergeCount++; return "merge item=" + itemId + " count=" + l.count + " line=" + i; } int slot = TakeSlot(s); var line = _lines[slot]; line.used = true; line.itemId = itemId; line.count = count; line.lifeMax = line.life = Mathf.Max(0.01f, s.lootToastSeconds); line.elapsed = 0f; line.group.alpha = 1f; Render(line, name, grade, s); PushCount++; return "push item=" + itemId + " \"" + name + "\" grade=" + grade + " count=" + count + " line=" + slot; } /// 빈 줄을 찾는다. 없으면 가장 오래된 줄을 밀어내고(위로 한 칸씩 당김) 마지막 자리를 준다. private int TakeSlot(WLCombatTextSettings s) { int n = Mathf.Min(_lines.Count, Mathf.Max(1, s.lootToastLines)); for (int i = 0; i < n; i++) if (!_lines[i].used) return i; // 전부 차 있음 — 0번(가장 오래된 줄)을 버리고 한 칸씩 올린다(내용만 옮기고 RectTransform 은 그대로). DropCount++; for (int i = 0; i < n - 1; i++) { var a = _lines[i]; var b = _lines[i + 1]; a.itemId = b.itemId; a.count = b.count; a.life = b.life; a.lifeMax = b.lifeMax; a.elapsed = b.elapsed; a.used = b.used; a.label.text = b.label.text; a.label.color = b.label.color; a.group.alpha = b.group.alpha; } _lines[n - 1].used = false; return n - 1; } private void Render(Line l, string name, int grade, WLCombatTextSettings s) { string colored = s.GradeColorTagBright(grade) + name; // WL-813y — 등급 1 회색이 모래 배경에서 사라진다(Q3 D-6 ②) l.label.text = l.count > 1 ? DSUtil.Format(s.lootToastFormat, colored, l.count) : DSUtil.Format(s.lootToastFormatSingle, colored); l.label.color = Color.white; // 등급색은 리치텍스트 태그가 담당한다(원본 팔레트 그대로) } public void HideAll() { for (int i = 0; i < _lines.Count; i++) { _lines[i].used = false; _lines[i].life = 0f; if (_lines[i].group != null) _lines[i].group.alpha = 0f; if (_lines[i].label != null) _lines[i].label.text = ""; } } // ───────────────────────────────────────────── 폴링 · 수명 /// 골드 아이템 ID — 원본 DropItemInfo 의 코인 분기 키(`DropItemInfo.cs:12` dic_addrPath[2]). public const int GoldItemId = 2; private void Update() { var s = WLCombatTextSettings.Instance; if (s == null) return; // ── 획득 집계 폴링(813f 이벤트가 오면 걷어낸다) if (_armed && Application.isPlaying && s.lootToastPollHz > 0f && Time.unscaledTime >= _nextPoll) { _nextPoll = Time.unscaledTime + 1f / Mathf.Max(0.1f, s.lootToastPollHz); PollStageDrop(); } // ── 줄 수명 float dt = Time.unscaledDeltaTime; float u = WLTextFxUtil.UnitsPerPx(this, s); for (int i = 0; i < _lines.Count; i++) { var l = _lines[i]; if (!l.used) continue; l.life -= dt; l.elapsed += dt; float passed = WLTextFxUtil.Passed(l.life, l.lifeMax); l.group.alpha = WLTextFxUtil.FadeAlpha(passed, s.lootToastFadeStartRatio); if (s.lootToastSlideInPx > 0f && s.lootToastSlideSeconds > 0f) { float k = Mathf.Clamp01(l.elapsed / s.lootToastSlideSeconds); float x = Mathf.Lerp(s.lootToastSlideInPx, 0f, k) * u; l.rt.anchoredPosition = new Vector2(x, -s.lootToastLineHeightPx * i * u); } if (l.life <= 0f) { l.used = false; l.group.alpha = 0f; l.label.text = ""; } } } /// StageDropData 의 아이템 누적 수량 델타 = 이번에 주운 것. public string PollStageDrop() { if (!DropItemInfo.isIns || DropItemInfo.Ins == null) return "DropItemInfo 없음"; var data = DropItemInfo.Ins.m_StageDropData; if (data == null) return "StageDropData 없음"; var dic = data.Get_Item(); if (dic == null) return "집계 없음"; var sb = new StringBuilder(); foreach (var kv in dic) { int id = kv.Key; int now = kv.Value; int last; if (!_seen.TryGetValue(id, out last)) last = 0; if (now > last) { DropCountedItems++; sb.Append(Push(id, now - last)).Append(" | "); } _seen[id] = now; } return sb.Length > 0 ? sb.ToString() : "델타 0"; } /// 진단 — 폴링으로 잡아낸 획득 건수. public int DropCountedItems { get; private set; } /// 스테이지가 바뀌면 집계 기준을 초기화한다(StageDropData.Init 과 짝). public void ResetPollBaseline() { _seen.Clear(); _armed = false; DropCountedItems = 0; } // ───────────────────────────────────────────── 프로브(에디트 모드 · Play 0) /// 합성 처치 이벤트 — 811b 코어의 실제 Dispatch 경로(구독 배선까지 검증). public static string RaiseFakeKilled() { var e = new KilledEvent { victim = null, killer = null, subRole = eSubRol.None, id = 0, position = Vector3.zero, byDirectHit = false, time = Time.unscaledTime, frame = Time.frameCount }; CombatEvents.Killed.Dispatch(in e); return "dispatch Killed · 구독자=" + CombatEvents.Killed.Count; } public string Dump() { var sb = new StringBuilder(); sb.AppendLine("LootToast subscribed=" + _subscribed + " killedSeen=" + KilledSeen + " push=" + PushCount + " merge=" + MergeCount + " drop=" + DropCount + " visible=" + VisibleLines); for (int i = 0; i < _lines.Count; i++) { var l = _lines[i]; sb.AppendLine(" [" + i + "] used=" + l.used + " item=" + l.itemId + " x" + l.count + " alpha=" + l.group.alpha.ToString("F2") + " pos=" + l.rt.anchoredPosition + " size=" + l.rt.sizeDelta + " font=" + l.label.fontSize.ToString("F1") + " panel=" + (l.panel != null ? (l.panel.enabled ? "#" + ColorUtility.ToHtmlStringRGBA(l.panel.color) + " off" + l.panel.rectTransform.offsetMin + l.panel.rectTransform.offsetMax : "off") : "없음") + " text=\"" + l.label.text + "\""); } var st = WLCombatTextSettings.Instance; if (st != null) sb.AppendLine(" 설정 seconds=" + st.lootToastSeconds + "s fontPx=" + st.lootToastFontPx + " widthPx=" + st.lootToastWidthPx + " lineHeightPx=" + st.lootToastLineHeightPx + " rightMarginPx=" + st.lootToastRightMarginPx + " centerOffsetPx=" + st.lootToastCenterOffsetPx + " panel=" + st.lootToastPanelEnabled + " 밝기하한=" + st.lootToastMinBrightness + " · 등급1 태그 " + st.GradeColorTag(1) + " → " + st.GradeColorTagBright(1)); sb.AppendLine(" edge = " + LastEdge); return sb.ToString(); } public void SetFont(TMP_FontAsset f) { font = f; for (int i = 0; i < _lines.Count; i++) if (_lines[i].label != null && f != null) _lines[i].label.font = f; } } }