// ─────────────────────────────────────────────────────────────────────────────
// KillChainText.cs — 연쇄 처치 문구 표시 (WL-813g §1-1 · #813)
//
// 기준서 v1 §B 요소 2 「처치 연출 · 연쇄 처치 3 s 윈도우 2/3/5/8킬」 · 발주서 §1-1
// 「811e KillChain 티어 이벤트(DOUBLE/TRIPLE/RAMPAGE) 구독 → 화면 중앙 상단 0.8 s 팝(스케일 펀치)」.
//
// ■ 데이터/표시 분리
// 카운터·윈도우·티어 표는 전부 811e(`WL.Combat.Reaction.KillChain` + `WLReactionSettings.tiers`)가 갖는다.
// 이 파일은 `KillChain.TierReached` 를 **구독만** 하고 문구를 띄운다(원본 훅 0 · Gameplay 코드 0).
//
// ■ 문구 톤 — PD Q5 전까지 임시 영문(발주서 §1-1). 형식·색·크기는 전부 WLCombatTextSettings 에셋.
//
// ■ Safe Area (813c 규칙)
// 이 컴포넌트는 이미 SafeAreaFitter 가 붙은 `IngameUIs/WL_HUD` 아래에 놓인다 →
// 부모가 안전 영역이므로 여기서는 **부모 기준 중앙 상단 오프셋**만 계산한다(Screen.safeArea 재계산 0).
//
// ■ 롤백(C8)
// WLCombatTextSettings 가 없거나 textEnabled/killChainTextEnabled = false 면 구독조차 하지 않는다(표시 0).
//
// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using System.Text;
using TMPro;
using UnityEngine;
using WL.Combat.Reaction;
namespace WL.UI
{
[DisallowMultipleComponent]
[RequireComponent(typeof(RectTransform))]
public class KillChainText : MonoBehaviour
{
[Header("구성 요소 (BuildIfNeeded 가 만든다)")]
[SerializeField] private CanvasGroup group;
[SerializeField] private RectTransform box;
[SerializeField] private TextMeshProUGUI mainLabel;
[SerializeField] private TextMeshProUGUI subLabel;
[SerializeField] private TMP_FontAsset font;
private RectTransform _rt;
private float _life, _lifeMax, _elapsed;
private bool _subscribed;
// ── 진단(프로브가 읽는다 · 실측만)
public int TierSeen { get; private set; }
public int LastTier { get; private set; }
public int LastKills { get; private set; }
public string LastText { get; private set; }
public bool Visible { get { return group != null && group.alpha > 0f; } }
public bool Subscribed { get { return _subscribed; } }
public float LastScale { get; private set; }
public Vector2 LastAnchoredPos { get; private set; }
private void OnEnable() { Initialize(); }
private void OnDisable() { Subscribe(false); }
/// 에디트 모드에서도 런타임과 같은 경로를 타게 OnEnable 본체를 분리한다(813c 교훈).
public string Initialize()
{
_rt = GetComponent();
BuildIfNeeded();
string layout = ApplyLayout();
Hide();
var s = WLCombatTextSettings.Instance;
bool on = WLCombatTextSettings.Enabled && s != null && s.killChainTextEnabled;
Subscribe(on);
return (on ? "구독 ON · " : "설정 off — 구독 0 · ") + layout;
}
public void Subscribe(bool on)
{
if (on == _subscribed) return;
if (on) KillChain.TierReached.Add(OnTier);
else KillChain.TierReached.Remove(OnTier);
_subscribed = on;
}
/// 필요한 자식(박스 · 본문 · 부제)을 만든다. 이미 있으면 그대로 쓴다.
public bool BuildIfNeeded()
{
if (_rt == null) _rt = GetComponent();
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 (mainLabel == null) { mainLabel = WLTextFxUtil.NewText(box, "Main", font, TextAlignmentOptions.Center); made = true; }
if (subLabel == null) { subLabel = WLTextFxUtil.NewText(box, "Sub", font, TextAlignmentOptions.Center); made = true; }
return made;
}
/// 설정 값대로 위치·크기를 잡는다(px → 유닛 환산은 캔버스에서 구한다 · 상수 0).
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);
// 루트는 부모(Safe Area 레이어)를 가득 채운다 — 좌표 계산의 기준이 안전 영역이 되게.
WLTextFxUtil.Stretch(_rt);
// 박스 = 화면 중앙 기준으로 위로 killChainTopOffsetPx.
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, (s.killChainFontPx * 2.4f) * u);
box.anchoredPosition = new Vector2(0f, s.killChainTopOffsetPx * u);
LastAnchoredPos = box.anchoredPosition;
mainLabel.rectTransform.anchorMin = new Vector2(0f, 0.5f);
mainLabel.rectTransform.anchorMax = new Vector2(1f, 1f);
mainLabel.rectTransform.offsetMin = Vector2.zero;
mainLabel.rectTransform.offsetMax = Vector2.zero;
mainLabel.fontSize = s.killChainFontPx * u;
mainLabel.alignment = TextAlignmentOptions.Bottom;
subLabel.rectTransform.anchorMin = new Vector2(0f, 0f);
subLabel.rectTransform.anchorMax = new Vector2(1f, 0.5f);
subLabel.rectTransform.offsetMin = Vector2.zero;
subLabel.rectTransform.offsetMax = Vector2.zero;
subLabel.fontSize = s.killChainFontPx * u;
subLabel.alignment = TextAlignmentOptions.Top;
return "box pos=" + box.anchoredPosition + " size=" + box.sizeDelta + " unitsPerPx=" + u.ToString("F4") +
" fontPx=" + s.killChainFontPx + "→" + mainLabel.fontSize.ToString("F1");
}
// ───────────────────────────────────────────── 이벤트
private void OnTier(in KillChainTierEvent e)
{
var s = WLCombatTextSettings.Instance;
if (s == null || !WLCombatTextSettings.Enabled || !s.killChainTextEnabled) return;
Show(e.tier, e.name, e.kills, s);
}
/// 티어 문구를 띄운다(합성 이벤트·프로브도 이 경로를 쓴다).
public string Show(int tier, string tierName, int kills, WLCombatTextSettings s)
{
if (s == null) s = WLCombatTextSettings.Instance;
if (s == null) return "설정 없음";
BuildIfNeeded();
ApplyLayout();
TierSeen++; LastTier = tier; LastKills = kills;
string main = string.IsNullOrEmpty(s.killChainFormat) ? tierName : DSUtil.Format(s.killChainFormat, tierName, kills);
string sub = string.IsNullOrEmpty(s.killChainSubFormat) ? "" : DSUtil.Format(s.killChainSubFormat, kills);
mainLabel.text = main;
subLabel.text = sub;
LastText = main + (string.IsNullOrEmpty(sub) ? "" : " / " + sub);
var c = s.TierColor(tier);
mainLabel.color = c;
subLabel.color = new Color(c.r, c.g, c.b, c.a * 0.8f);
LastScale = s.TierScale(tier);
_lifeMax = _life = Mathf.Max(0.01f, s.killChainSeconds);
_elapsed = 0f;
group.alpha = 1f;
box.localScale = Vector3.one * (LastScale * WLTextFxUtil.PunchScale(0f, s.killChainPunchScale, s.killChainPunchSeconds));
enabled = true;
return "tier=" + tier + " name=" + tierName + " kills=" + kills + " text=\"" + LastText +
"\" scale=" + box.localScale.x.ToString("F3") + " pos=" + box.anchoredPosition + " alpha=" + group.alpha;
}
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; // 히트스톱(TimeScaleArbiter) 중에도 문구는 흐른다
_life -= dt; _elapsed += dt;
float passed = WLTextFxUtil.Passed(_life, _lifeMax);
group.alpha = WLTextFxUtil.FadeAlpha(passed, s.killChainFadeStartRatio);
box.localScale = Vector3.one * (LastScale * WLTextFxUtil.PunchScale(_elapsed, s.killChainPunchScale, s.killChainPunchSeconds));
if (_life <= 0f) Hide();
}
// ───────────────────────────────────────────── 프로브(에디트 모드 · Play 0)
/// 합성 티어 이벤트 — 811e 의 실제 Dispatch 경로로 보낸다(구독 배선까지 함께 검증).
public static string RaiseFakeTier(int tier, string name, int kills)
{
var e = new KillChainTierEvent
{
tier = tier, name = name, kills = kills, centroid = Vector3.zero,
lastVictim = null, burstSpawned = false, time = Time.unscaledTime, frame = Time.frameCount
};
KillChain.TierReached.Dispatch(in e);
return "dispatch tier=" + tier + " name=" + name + " kills=" + kills +
" 구독자=" + KillChain.TierReached.Count;
}
public string Dump()
{
var sb = new StringBuilder();
sb.AppendLine("KillChainText subscribed=" + _subscribed + " tierSeen=" + TierSeen +
" lastTier=" + LastTier + " lastKills=" + LastKills);
sb.AppendLine(" text=\"" + LastText + "\" alpha=" + (group != null ? group.alpha.ToString("F2") : "-") +
" scale=" + (box != null ? box.localScale.x.ToString("F3") : "-") +
" pos=" + (box != null ? box.anchoredPosition.ToString() : "-") +
" size=" + (box != null ? box.sizeDelta.ToString() : "-"));
sb.AppendLine(" fontSize=" + (mainLabel != null ? mainLabel.fontSize.ToString("F1") : "-") +
" color=" + (mainLabel != null ? ColorUtility.ToHtmlStringRGB(mainLabel.color) : "-"));
return sb.ToString();
}
public void SetFont(TMP_FontAsset f)
{
font = f;
if (mainLabel != null && f != null) mainLabel.font = f;
if (subLabel != null && f != null) subLabel.font = f;
}
}
}