Project_WL/Assets/WL/UI/Scripts/LootToast.cs

593 lines
31 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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<Line> _lines = new List<Line>();
private readonly Dictionary<int, int> _seen = new Dictionary<int, int>(); // itemId → 마지막으로 본 누적 수량
private RectTransform _rt;
private bool _subscribed;
private float _nextPoll;
private bool _armed; // Killed 를 한 번이라도 본 뒤부터 폴링한다(로비에서 헛돌지 않게)
// ── WL-813z(D-7) Safe Area 클램프 실값(ApplyLayout 이 계산 · Update 슬라이드가 읽는다)
private float _widthPx, _rightPx, _padPx, _slideMaxPx;
/// <summary>WL-813z — 마지막 클램프 실측(프로브·Dump 용).</summary>
public string LastClamp { get; private set; } = "";
// ── 진단(프로브가 읽는다 · 실측만)
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; } }
/// <summary>마지막으로 적용한 아웃라인/그림자 실측 문자열(WL-813y).</summary>
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); }
/// <summary>OnEnable 본체 분리(813c 교훈) — 에디트 모드도 같은 경로.</summary>
public string Initialize()
{
_rt = GetComponent<RectTransform>();
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;
}
/// <summary>설정의 줄 수만큼 라벨을 만든다(줄 수를 바꾸면 다시 만든다).</summary>
public bool BuildIfNeeded()
{
if (_rt == null) _rt = GetComponent<RectTransform>();
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<Image>();
if (panel == null) panel = prt.gameObject.AddComponent<Image>();
panel.raycastTarget = false;
var label = WLTextFxUtil.NewText(lrt, "Label", font, TextAlignmentOptions.Right);
var cg = lrt.GetComponent<CanvasGroup>();
if (cg == null) cg = lrt.gameObject.AddComponent<CanvasGroup>();
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;
}
/// <summary>우측 정렬 · 줄 간격 · 글자 크기 (px → 유닛 환산은 캔버스에서 · 상수 0).</summary>
public string ApplyLayout()
{
var s = WLCombatTextSettings.Instance;
if (s == null) return "WLCombatTextSettings 없음 — 배치 건너뜀";
if (_rt == null) _rt = GetComponent<RectTransform>();
if (lineRoot == null) return "구성 요소 없음 — BuildIfNeeded 먼저";
float u = WLTextFxUtil.UnitsPerPx(this, s);
WLTextFxUtil.Stretch(_rt);
// ── WL-813z(D-7) — 폭·오른쪽 여백·슬라이드 시작 x 를 Safe Area 안으로 클램프.
// 부모(IngameUIs/WL_HUD)는 SafeAreaFitter 아래이므로 이 RectTransform 의 rect 가 곧 Safe Area 다.
// 중심 오프셋(lootToastCenterOffsetPx)은 발주서 지시대로 **그대로 둔다**.
_widthPx = s.lootToastWidthPx;
_rightPx = s.lootToastRightMarginPx;
_padPx = 0f;
_slideMaxPx = s.lootToastSlideInPx;
float parentPx = u > 0.0001f ? _rt.rect.width / u : 0f;
if (s.lootToastClampToSafeArea)
{
_padPx = Mathf.Max(0f, s.lootToastSafePadPx) + (s.lootToastPanelEnabled ? Mathf.Max(0f, s.lootToastPanelPadXPx) : 0f);
_rightPx = Mathf.Max(_padPx, _rightPx);
if (s.lootToastMaxWidthPx > 0f) _widthPx = Mathf.Min(_widthPx, s.lootToastMaxWidthPx);
if (parentPx > 1f) _widthPx = Mathf.Min(_widthPx, parentPx - _rightPx - _padPx);
_widthPx = Mathf.Max(1f, _widthPx);
_slideMaxPx = Mathf.Clamp(_rightPx - _padPx, 0f, s.lootToastSlideInPx); // 등장 중에도 오른쪽 밖으로 못 나간다
}
LastClamp = "부모=" + parentPx.ToString("F1") + "px · 폭 " + s.lootToastWidthPx.ToString("F0") + "→" + _widthPx.ToString("F1") +
"px(상한 " + s.lootToastMaxWidthPx.ToString("F0") + ") · 우여백 " + s.lootToastRightMarginPx.ToString("F0") + "→" + _rightPx.ToString("F1") +
"px · pad=" + _padPx.ToString("F1") + "px · 슬라이드 " + s.lootToastSlideInPx.ToString("F0") + "→" + _slideMaxPx.ToString("F1") +
"px · 우측 최대 침범 " + Mathf.Max(0f, _slideMaxPx + _padPx - _rightPx).ToString("F1") + "px · clamp=" + s.lootToastClampToSafeArea;
lineRoot.anchorMin = lineRoot.anchorMax = new Vector2(1f, 0.5f); // 우측 · 세로 중앙 기준
lineRoot.pivot = new Vector2(1f, 0.5f);
lineRoot.sizeDelta = new Vector2(_widthPx * u, s.lootToastLineHeightPx * s.lootToastLines * u);
lineRoot.anchoredPosition = new Vector2(-_rightPx * 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"))
.Append(" · ").Append(LastClamp);
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(_widthPx * 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-813z(D-7) — 긴 아이템 이름은 줄바꿈 대신 말줄임(…)으로 자른다(폭 밖으로 밀려나지 않게).
if (s.lootToastEllipsis)
{
if (l.label.textWrappingMode != TextWrappingModes.NoWrap) l.label.textWrappingMode = TextWrappingModes.NoWrap;
if (l.label.overflowMode != TextOverflowModes.Ellipsis) l.label.overflowMode = TextOverflowModes.Ellipsis;
}
// 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; // 처치가 있어야 드랍이 있다 — 그 뒤부터만 획득 집계를 본다
}
/// <summary>813f(후속) · 프로브 공용 입력 — 아이템 1건 획득을 줄로 띄운다.</summary>
public string Push(int itemId, int count)
{
int r = PushCore(itemId, count);
switch (r)
{
case kOff: return "설정 off — 표시 0";
case kGold: return "골드 제외(설정) — 표시 0";
case kGrade: return "등급 하한 미만 — 표시 0";
case kMerged: return "merge item=" + itemId + " count=" +
(_lastPushLine >= 0 && _lastPushLine < _lines.Count ? _lines[_lastPushLine].count : count) +
" line=" + _lastPushLine;
case kQueued: return "queue item=" + itemId + " count=" + count + " 대기=" + _qCount + "(오래된 줄 페이드)";
case kQueueDropped: return "queue full item=" + itemId + " — 버림(대기 " + _qCount + ")";
default: return "push item=" + itemId + " count=" + count + " line=" + _lastPushLine;
}
}
// ── WL-813c2 (Q6 D-31) — 큐 · 병합 · 넘침 페이드 ────────────────────────
// 실측: 화면 동시 줄 수는 813g 때부터 `lootToastLines`(3)로 막혀 있었다. Q6 의 「창 안 6줄」은
// 프로브가 **표시 시간 창 안의 Picked 이벤트 수**를 센 값이다(`WL813_ScoreProbe.cs:437~443`).
// 실물 결함은 넘칠 때 오래된 줄이 **페이드 없이 즉시 다른 내용으로 교체**돼 깜빡이는 것이다.
// → ① 같은 아이템은 보이는 동안 계속 수량 병합("금화 ×8") ② 넘치면 오래된 줄을 즉시 페이드시키고
// 새 줄은 **고정 크기 링 큐**에서 자리를 기다린다(동시 줄 수 상한 유지 · 할당 0).
// C8 롤백 = `lootToastOverflowFadeSec = 0` → 813g 의 즉시 한 칸 당김 그대로.
private const int kOff = 0, kGold = 1, kGrade = 2, kMerged = 3, kShown = 4, kQueued = 5, kQueueDropped = 6;
private int _lastPushLine = -1;
private struct Pending { public int itemId, count; }
private Pending[] _queue = new Pending[0];
private int _qHead, _qCount;
/// <summary>지금 자리를 기다리는 건수(진단).</summary>
public int QueuedCount { get { return _qCount; } }
/// <summary>큐가 꽉 차 버린 건수(진단).</summary>
public int QueueDropCount { get; private set; }
/// <summary>넘침으로 즉시 페이드시킨 줄 수(진단).</summary>
public int OverflowFadeCount { get; private set; }
/// <summary>문자열을 만들지 않는 입력 본체(GC 0). 돌려주는 값은 위 k* 코드.</summary>
private int PushCore(int itemId, int count)
{
_lastPushLine = -1;
var s = WLCombatTextSettings.Instance;
if (s == null || !WLCombatTextSettings.Enabled || !s.lootToastEnabled) return kOff;
if (!s.lootToastIncludeGold && itemId == GoldItemId) return kGold;
int grade; string name;
ResolveItem(itemId, out grade, out name);
if (grade < s.lootToastMinGrade) return kGrade;
BuildIfNeeded();
int n = LineCap(s);
// ① 같은 아이템 병합 — 보이는 동안이면 창과 무관하게(설정) 수량만 올린다.
for (int i = 0; i < n; i++)
{
var l = _lines[i];
if (!l.used || l.itemId != itemId) continue;
if (!s.lootToastMergeWhileVisible && l.elapsed > s.lootToastMergeSeconds) continue;
l.count += count;
l.life = l.lifeMax; l.elapsed = 0f; l.group.alpha = 1f;
Render(l, name, grade, s);
MergeCount++; _lastPushLine = i;
return kMerged;
}
// ② 빈 줄이 있으면 바로 띄운다(지연 0).
int slot = FreeSlot(s);
if (slot >= 0) { Occupy(slot, itemId, count, name, grade, s); return kShown; }
// ③ 넘침 — 오래된 줄을 즉시 페이드시키고 새 줄은 큐에서 기다린다.
if (s.lootToastOverflowFadeSec > 0f)
{
FadeOldest(s);
return Enqueue(s, itemId, count) ? kQueued : kQueueDropped;
}
// ④ C8 롤백 경로(페이드 0) — 813g 그대로 한 칸씩 당기고 마지막 자리를 쓴다.
slot = ShiftUp(s);
Occupy(slot, itemId, count, name, grade, s);
return kShown;
}
/// <summary>표가 아직 안 올라온 시점(에디트 모드·로딩 중)에도 죽지 않게 감싼다 — 없으면 ID·등급 1.</summary>
private static void ResolveItem(int itemId, out int grade, out string name)
{
grade = 1; name = null;
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 (name == null) name = itemId.ToString();
}
/// <summary>지금 쓸 수 있는 줄 수 = min(만들어 둔 줄, 설정 상한).</summary>
private int LineCap(WLCombatTextSettings s)
{
return Mathf.Min(_lines.Count, Mathf.Max(1, s.lootToastLines));
}
private int FreeSlot(WLCombatTextSettings s)
{
int n = LineCap(s);
for (int i = 0; i < n; i++) if (!_lines[i].used) return i;
return -1;
}
private void Occupy(int slot, int itemId, int count, string name, int grade, WLCombatTextSettings 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++; _lastPushLine = slot;
}
/// <summary>남은 수명이 가장 짧은 줄(= 가장 오래된 줄)을 페이드 시간으로 줄인다 — 「넘치면 오래된 줄 즉시 페이드」.</summary>
private void FadeOldest(WLCombatTextSettings s)
{
int n = LineCap(s), pick = -1; float least = float.MaxValue;
for (int i = 0; i < n; i++)
{
var l = _lines[i];
if (!l.used) continue;
if (l.life < least) { least = l.life; pick = i; }
}
if (pick < 0) return;
float f = Mathf.Max(0.01f, s.lootToastOverflowFadeSec);
if (_lines[pick].life <= f) return; // 이미 그만큼만 남았으면 건드리지 않는다
_lines[pick].life = f;
_lines[pick].lifeMax = f; // Passed = 1 life/lifeMax → 곧바로 페이드 구간
OverflowFadeCount++; DropCount++;
}
private void EnsureQueue(WLCombatTextSettings s)
{
int want = Mathf.Max(0, s.lootToastQueueMax);
if (_queue.Length == want) return;
_queue = new Pending[want]; // 설정을 바꿀 때만 1회 — 폴링·프레임 경로에서는 할당 0
_qHead = 0; _qCount = 0;
}
private bool Enqueue(WLCombatTextSettings s, int itemId, int count)
{
EnsureQueue(s);
if (_queue.Length == 0) return false;
for (int k = 0; k < _qCount; k++) // 큐 안에서도 같은 아이템은 수량만 더한다
{
int idx = (_qHead + k) % _queue.Length;
if (_queue[idx].itemId != itemId) continue;
_queue[idx].count += count; MergeCount++;
return true;
}
if (_qCount >= _queue.Length) // 꽉 차면 가장 오래 기다린 항목을 버린다
{
_qHead = (_qHead + 1) % _queue.Length; _qCount--;
QueueDropCount++;
}
int tail = (_qHead + _qCount) % _queue.Length;
_queue[tail].itemId = itemId; _queue[tail].count = count;
_qCount++;
return true;
}
/// <summary>자리가 나면 큐에서 꺼내 띄운다(매 프레임 · 문자열 0).</summary>
private void FlushQueue(WLCombatTextSettings s)
{
if (_qCount <= 0 || _queue.Length == 0) return;
int guard = _queue.Length;
while (_qCount > 0 && guard-- > 0)
{
int slot = FreeSlot(s);
if (slot < 0) return;
var p = _queue[_qHead];
_qHead = (_qHead + 1) % _queue.Length; _qCount--;
int grade; string name;
ResolveItem(p.itemId, out grade, out name);
Occupy(slot, p.itemId, p.count, name, grade, s);
}
}
/// <summary>프로브 전용 — 자리가 난 만큼 큐를 밀어 넣고 띄운 건수를 돌려준다(런타임은 Update 가 매 프레임 부른다).</summary>
public int FlushQueueNow()
{
var s = WLCombatTextSettings.Instance;
if (s == null) return 0;
int before = _qCount;
FlushQueue(s);
return before - _qCount;
}
/// <summary>C8 롤백 경로 — 0번을 버리고 한 칸씩 올린다(내용만 옮기고 RectTransform 은 그대로).</summary>
private int ShiftUp(WLCombatTextSettings s)
{
int n = LineCap(s);
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 = "";
}
_qHead = 0; _qCount = 0; // WL-813c2 — 대기 큐도 비운다
}
// ───────────────────────────────────────────── 폴링 · 수명
/// <summary>골드 아이템 ID — 원본 DropItemInfo 의 코인 분기 키(`DropItemInfo.cs:12` dic_addrPath[2]).</summary>
public const int GoldItemId = 2;
private void Update()
{
var s = WLCombatTextSettings.Instance;
if (s == null) return;
// ── 획득 집계 폴링(813f 이벤트가 오면 걷어낸다)
// 🔴 WL-813c2 — 게임 경로는 **문자열을 만들지 않는 갈래**로 부른다(기존에는 폴링마다
// `new StringBuilder()` + 진단 문자열이 초당 10회 났다 = GC). 진단용 문자열 갈래는 프로브 전용.
if (_armed && Application.isPlaying && s.lootToastPollHz > 0f && Time.unscaledTime >= _nextPoll)
{
_nextPoll = Time.unscaledTime + 1f / Mathf.Max(0.1f, s.lootToastPollHz);
PollStageDropCore(null);
}
// ── WL-813c2(D-31) — 넘쳐서 기다리던 줄을 자리가 나는 대로 띄운다
FlushQueue(s);
// ── 줄 수명
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);
// WL-813z(D-7) — 등장 x 상한 = Safe Area 를 넘지 않는 값(ApplyLayout 계산 · 클램프 off 면 원래 값).
float startPx = s.lootToastClampToSafeArea ? _slideMaxPx : s.lootToastSlideInPx;
float x = Mathf.Lerp(startPx, 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 = ""; }
}
}
/// <summary>StageDropData 의 아이템 누적 수량 델타 = 이번에 주운 것(진단 문자열 갈래 · 프로브 전용).</summary>
public string PollStageDrop() { return PollStageDropCore(new StringBuilder()); }
/// <summary>폴링 본체. <paramref name="sb"/> 가 null 이면 문자열을 하나도 만들지 않는다(게임 경로 · GC 0).</summary>
private string PollStageDropCore(StringBuilder sb)
{
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 "집계 없음";
foreach (var kv in dic) // Dictionary 열거자는 구조체 = 할당 0
{
int id = kv.Key;
int now = kv.Value;
int last;
if (!_seen.TryGetValue(id, out last)) last = 0;
if (now > last)
{
DropCountedItems++;
if (sb != null) sb.Append(Push(id, now - last)).Append(" | ");
else PushCore(id, now - last);
}
_seen[id] = now;
}
return sb != null && sb.Length > 0 ? sb.ToString() : "델타 0";
}
/// <summary>진단 — 폴링으로 잡아낸 획득 건수.</summary>
public int DropCountedItems { get; private set; }
/// <summary>스테이지가 바뀌면 집계 기준을 초기화한다(StageDropData.Init 과 짝).</summary>
public void ResetPollBaseline()
{
_seen.Clear(); _armed = false; DropCountedItems = 0;
_qHead = 0; _qCount = 0; QueueDropCount = 0; OverflowFadeCount = 0; // WL-813c2
}
// ───────────────────────────────────────────── 프로브(에디트 모드 · Play 0)
/// <summary>합성 처치 이벤트 — 811b 코어의 실제 Dispatch 경로(구독 배선까지 검증).</summary>
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);
var sq = WLCombatTextSettings.Instance;
sb.AppendLine(" [813c2] 상한=" + (sq != null ? sq.lootToastLines : 0) +
" 대기=" + _qCount + "/" + _queue.Length + " 큐버림=" + QueueDropCount +
" 넘침페이드=" + OverflowFadeCount + "회 · 병합창=" +
(sq != null ? sq.lootToastMergeSeconds.ToString("F2") : "-") + "s(보이는동안=" +
(sq != null ? sq.lootToastMergeWhileVisible.ToString() : "-") + ") · 페이드=" +
(sq != null ? sq.lootToastOverflowFadeSec.ToString("F2") : "-") + "s");
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(" clamp = " + LastClamp);
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;
}
}
}