// ───────────────────────────────────────────────────────────────────────────── // 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; } } }