// ───────────────────────────────────────────────────────────────────────────── // WLTextFxUtil.cs — 전투 텍스트 연출 3종이 공유하는 소소한 UI 조립 헬퍼 (WL-813g · #813) // // KillChainText · LootToast · StatPopup 이 자기 노드를 스스로 만든다(BuildIfNeeded 패턴 · 813c BossHpBar 선례). // 프리팹에는 **빈 노드 1개 + 컴포넌트**만 들어가고 자식·좌표는 런타임에 만든다 → // NewGameUI.prefab diff 를 "노드 추가"로만 유지한다(발주서 §2 ⓒ). // // 값은 하나도 들고 있지 않다(전부 WLCombatTextSettings). 여기 있는 숫자는 Unity 기본값(앵커 0/1)뿐이다. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using TMPro; using UnityEngine; namespace WL.UI { internal static class WLTextFxUtil { internal const int UILayer = 5; // 813c 와 같은 UI 레이어 인덱스 /// 이름으로 자식을 찾고 없으면 RectTransform 자식을 만든다. internal static RectTransform NewChild(RectTransform parent, string name) { var t = parent.Find(name) as RectTransform; if (t != null) return t; var go = new GameObject(name, typeof(RectTransform)); go.layer = UILayer; t = (RectTransform)go.transform; t.SetParent(parent, false); t.localScale = Vector3.one; t.localRotation = Quaternion.identity; return t; } /// 부모를 가득 채우도록 늘린다. internal static void Stretch(RectTransform rt) { rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero; rt.pivot = new Vector2(0.5f, 0.5f); } /// 자식 TextMeshProUGUI 를 만들거나 찾는다(레이캐스트 off · 폰트는 있으면 주입). internal static TextMeshProUGUI NewText(RectTransform parent, string name, TMP_FontAsset font, TextAlignmentOptions align) { var t = NewChild(parent, name); var label = t.GetComponent(); if (label == null) label = t.gameObject.AddComponent(); label.raycastTarget = false; label.alignment = align; // 줄바꿈·오버플로 API 는 TMP 버전마다 이름이 갈려(enableWordWrapping ↔ textWrappingMode) 건드리지 않는다 — 기본값 유지. if (font != null) label.font = font; return label; } /// px → 캔버스 유닛 계수(802b 규약 · 캔버스 폭 / 기준 폭). 캔버스가 없으면 1. internal static float UnitsPerPx(Component c, WLCombatTextSettings s) { if (s == null) return 1f; var canvas = c != null ? c.GetComponentInParent() : null; return s.UnitsPerPx(canvas != null ? canvas.rootCanvas : null); } /// 0 → 1 로 지난 비율(life 는 남은 시간). internal static float Passed(float life, float lifeMax) { return lifeMax > 0f ? 1f - Mathf.Clamp01(life / lifeMax) : 1f; } /// fadeStart 비율 이후에만 1 → 0 으로 떨어지는 알파. internal static float FadeAlpha(float passed, float fadeStart) { fadeStart = Mathf.Clamp01(fadeStart); if (passed < fadeStart) return 1f; if (fadeStart >= 1f) return 0f; return 1f - Mathf.Clamp01((passed - fadeStart) / (1f - fadeStart)); } /// 등장 펀치 스케일(punchSeconds 안에 punchScale → 1 로 수렴). internal static float PunchScale(float elapsed, float punchScale, float punchSeconds) { if (punchSeconds <= 0f || punchScale <= 1f) return 1f; float k = Mathf.Clamp01(elapsed / punchSeconds); return Mathf.Lerp(punchScale, 1f, k * k); } // ── 아웃라인 · 그림자 (WL-813y · Q3 결함 D-6) ───────────────────────── // uGUI `Outline`/`Shadow`(BaseMeshEffect)는 **TextMeshPro 에 적용되지 않는다** — // TMP_Text 는 IMeshModifier 를 부르지 않고 자체 메시를 만든다(실측 근거는 완료보고). // 그래서 TMP 의 정식 경로인 **머티리얼 아웃라인(_OutlineWidth/_OutlineColor)** + // **언더레이 그림자(UNDERLAY_ON · _UnderlayOffsetX/Y · _UnderlaySoftness)** 를 쓴다. 포스트 0. // 라벨마다 인스턴스 머티리얼을 만들면 배칭이 깨지므로, (원본 머티리얼 + 값) 조합마다 // **변형 머티리얼 1장**을 캐시해 `fontSharedMaterial` 로 공유한다. private static readonly Dictionary s_edgeMats = new Dictionary(); private static readonly Dictionary s_edgeOrigin = new Dictionary(); /// 지금까지 만든 아웃라인 변형 머티리얼 수(진단). internal static int EdgeMaterialCount { get { return s_edgeMats.Count; } } /// TMP 라벨에 아웃라인 + 언더레이 그림자를 입힌다. 적용 결과 문자열(실측)을 돌려준다. internal static string ApplyEdge(TMP_Text label, WLTextEdge edge) { if (label == null) return "label=null"; Material src = label.fontSharedMaterial; if (src == null) return "sharedMaterial=null"; Material origin; if (s_edgeOrigin.TryGetValue(src, out origin)) src = origin; // 이미 우리 변형이면 원본으로 되돌아가서 다시 만든다 if (!edge.enabled || (edge.outlineWidth <= 0f && edge.shadowColor.a <= 0f)) { if (!ReferenceEquals(label.fontSharedMaterial, src)) { label.fontSharedMaterial = src; label.UpdateMeshPadding(); } return "edge off → 원본 머티리얼(" + src.name + ")"; } string key = src.GetInstanceID() + "|" + edge.Key(); Material mat; if (!s_edgeMats.TryGetValue(key, out mat) || mat == null) { mat = new Material(src); mat.name = src.name + " (WL813y Edge)"; mat.hideFlags = HideFlags.DontSave; Write(mat, edge); s_edgeMats[key] = mat; s_edgeOrigin[mat] = src; } if (!ReferenceEquals(label.fontSharedMaterial, mat)) { label.fontSharedMaterial = mat; label.UpdateMeshPadding(); // 아웃라인이 글리프 밖으로 나가므로 패딩을 다시 잡는다 } return Describe(mat); } private static void Write(Material m, WLTextEdge e) { if (m.HasProperty(ShaderUtilities.ID_OutlineWidth)) m.SetFloat(ShaderUtilities.ID_OutlineWidth, Mathf.Clamp01(e.outlineWidth)); if (m.HasProperty(ShaderUtilities.ID_OutlineColor)) m.SetColor(ShaderUtilities.ID_OutlineColor, e.outlineColor); if (m.HasProperty(ShaderUtilities.ID_FaceDilate)) m.SetFloat(ShaderUtilities.ID_FaceDilate, Mathf.Clamp(e.faceDilate, -1f, 1f)); bool shadow = e.shadowColor.a > 0f && (Mathf.Abs(e.shadowOffset.x) > 0f || Mathf.Abs(e.shadowOffset.y) > 0f || e.shadowSoftness > 0f); if (m.HasProperty(ShaderUtilities.ID_UnderlayColor)) { m.SetColor(ShaderUtilities.ID_UnderlayColor, shadow ? e.shadowColor : new Color(0f, 0f, 0f, 0f)); if (m.HasProperty(ShaderUtilities.ID_UnderlayOffsetX)) m.SetFloat(ShaderUtilities.ID_UnderlayOffsetX, e.shadowOffset.x); if (m.HasProperty(ShaderUtilities.ID_UnderlayOffsetY)) m.SetFloat(ShaderUtilities.ID_UnderlayOffsetY, e.shadowOffset.y); if (m.HasProperty(ShaderUtilities.ID_UnderlaySoftness)) m.SetFloat(ShaderUtilities.ID_UnderlaySoftness, Mathf.Clamp01(e.shadowSoftness)); if (m.HasProperty(ShaderUtilities.ID_UnderlayDilate)) m.SetFloat(ShaderUtilities.ID_UnderlayDilate, 0f); if (shadow) m.EnableKeyword("UNDERLAY_ON"); else m.DisableKeyword("UNDERLAY_ON"); } if (m.HasProperty(ShaderUtilities.ID_OutlineWidth) && e.outlineWidth > 0f) m.EnableKeyword("OUTLINE_ON"); } /// 머티리얼에 실제로 들어간 값(실측 덤프용). internal static string Describe(Material m) { if (m == null) return "(머티리얼 없음)"; var sb = new System.Text.StringBuilder(); sb.Append(m.name).Append(" shader=").Append(m.shader != null ? m.shader.name : "-"); sb.Append(" outlineW=").Append(m.HasProperty(ShaderUtilities.ID_OutlineWidth) ? m.GetFloat(ShaderUtilities.ID_OutlineWidth).ToString("F3") : "없음"); sb.Append(" outlineC=").Append(m.HasProperty(ShaderUtilities.ID_OutlineColor) ? ColorUtility.ToHtmlStringRGBA(m.GetColor(ShaderUtilities.ID_OutlineColor)) : "없음"); if (m.HasProperty(ShaderUtilities.ID_UnderlayColor)) { sb.Append(" underlay=").Append(ColorUtility.ToHtmlStringRGBA(m.GetColor(ShaderUtilities.ID_UnderlayColor))); sb.Append(" off(").Append(m.GetFloat(ShaderUtilities.ID_UnderlayOffsetX).ToString("F2")).Append(',') .Append(m.GetFloat(ShaderUtilities.ID_UnderlayOffsetY).ToString("F2")).Append(')'); sb.Append(" soft=").Append(m.GetFloat(ShaderUtilities.ID_UnderlaySoftness).ToString("F2")); sb.Append(" UNDERLAY_ON=").Append(m.IsKeywordEnabled("UNDERLAY_ON")); } return sb.ToString(); } /// 라벨을 원본 머티리얼로 되돌린다(프로브·롤백). internal static string ClearEdge(TMP_Text label) { if (label == null) return "label=null"; Material origin; if (label.fontSharedMaterial != null && s_edgeOrigin.TryGetValue(label.fontSharedMaterial, out origin)) { label.fontSharedMaterial = origin; label.UpdateMeshPadding(); return "원복 → " + origin.name; } return "변형 아님 — 그대로"; } } /// /// 텍스트 테두리·그림자 값 묶음 (WL-813y · C8/C45 — 코드 상수 0 · 전부 SO). /// TMP 머티리얼 규격 그대로다: outlineWidth 0~1 · shadowOffset 은 폰트 단위(±1) · softness 0~1. /// [System.Serializable] public struct WLTextEdge { [Tooltip("이 텍스트에 아웃라인/그림자를 입힐지")] public bool enabled; [Tooltip("아웃라인 두께 (TMP _OutlineWidth · 0~1 · 0.2~0.3 이 육안으로 또렷)")] [Range(0f, 1f)] public float outlineWidth; [Tooltip("아웃라인 색")] public Color outlineColor; [Tooltip("그림자(언더레이) 오프셋 — 폰트 단위(±1). 예 (0.6, -0.6) = 우하단")] public Vector2 shadowOffset; [Tooltip("그림자 색 — 알파 0 이면 그림자 없음")] public Color shadowColor; [Tooltip("그림자 번짐 (TMP _UnderlaySoftness · 0~1)")] [Range(0f, 1f)] public float shadowSoftness; [Tooltip("글자 두께 보정 (TMP _FaceDilate · -1~1). 아웃라인이 글자를 먹으면 살짝 올린다")] [Range(-1f, 1f)] public float faceDilate; internal string Key() { return (enabled ? "1" : "0") + outlineWidth.ToString("F3") + outlineColor + shadowOffset + shadowColor + shadowSoftness.ToString("F3") + faceDilate.ToString("F3"); } public override string ToString() { return "on=" + enabled + " w=" + outlineWidth.ToString("F2") + " oc=#" + ColorUtility.ToHtmlStringRGBA(outlineColor) + " sh" + shadowOffset + " sc=#" + ColorUtility.ToHtmlStringRGBA(shadowColor) + " soft=" + shadowSoftness.ToString("F2") + " dilate=" + faceDilate.ToString("F2"); } } }