ThreeKingdomsSlingshot/Assets/Scripts/OutGame/UI/UiKit.cs

863 lines
46 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.

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace TKS.OutGame
{
/// <summary>
/// 아웃게임 UI 도우미 — uGUI + Malgun Gothic(TextUtil) · 기준 1080×1920 · 배치 = 확정 목업(img03·05·06·07·08·11·13·14·16·17).
/// 부품·색 = UI 테마(PD #1100 · Layer Lab GUI Pro-SuperCasual 팩 · UISkin) · 테마가 없으면 기존 도형 와이어프레임.
/// 위치 인자는 전부 「부모 안 기준점(anchor) · 피벗 · 기준점에서의 거리 · 크기」(UI px).
/// </summary>
public static class UiKit
{
// 팔레트 = 테마 색 id(ui_theme.csv) · 괄호 값 = 테마가 없을 때(구 와이어프레임)
public static Color Bg { get { return UISkin.Col("bg", new Color(0.95f, 0.92f, 0.85f)); } }
public static Color Paper { get { return UISkin.Col("paper", new Color(0.99f, 0.97f, 0.92f)); } }
public static Color Header { get { return UISkin.Col("header", new Color(0.92f, 0.87f, 0.78f)); } }
public static Color HeaderText { get { return UISkin.Col("header_text", Ink); } }
public static Color HeaderSub { get { return UISkin.Col("header_sub", InkSoft); } }
public static Color Ink { get { return UISkin.Col("ink", new Color(0.17f, 0.12f, 0.10f)); } }
public static Color InkSoft { get { return UISkin.Col("ink_soft", new Color(0.45f, 0.39f, 0.34f)); } }
public static Color Dark { get { return UISkin.Col("dark", new Color(0.21f, 0.16f, 0.14f)); } }
public static Color Red { get { return UISkin.Col("red", new Color(0.74f, 0.26f, 0.19f)); } }
public static Color Line { get { return UISkin.Col("line", new Color(0.33f, 0.26f, 0.22f)); } }
public static Color Slot { get { return UISkin.Col("slot", new Color(0.80f, 0.76f, 0.70f)); } }
public static Color Coin { get { return UISkin.Col("coin", new Color(0.88f, 0.67f, 0.20f)); } }
public static Color Jade { get { return UISkin.Col("jade", new Color(0.27f, 0.60f, 0.52f)); } }
public static Color Grain { get { return UISkin.Col("grain", new Color(0.76f, 0.58f, 0.38f)); } }
public static Color Ok { get { return UISkin.Col("ok", new Color(0.36f, 0.62f, 0.31f)); } }
public static Color Star { get { return UISkin.Col("star", new Color(0.93f, 0.68f, 0.13f)); } }
public static Color Disabled { get { return UISkin.Col("disabled", new Color(0.66f, 0.62f, 0.58f)); } }
public static Color Purple { get { return UISkin.Col("purple", new Color(0.58f, 0.36f, 0.70f)); } }
public static Color BarColor { get { return UISkin.Col("bar", new Color(0.15f, 0.11f, 0.09f)); } }
public static Color Stroke { get { return UISkin.Col("stroke", new Color(0.12f, 0.1f, 0.1f)); } }
public static readonly Color White = Color.white;
public static Color Col(string id, Color fallback)
{
return UISkin.Col(id, fallback);
}
static Sprite round;
/// <summary>둥근 모서리 9-slice(모서리 22 px).</summary>
public static Sprite Round
{
get
{
if (round == null) round = MakeRound(64, 22);
return round;
}
}
public static Sprite Circle
{
get { return ProceduralSprites.Circle; }
}
static Sprite MakeRound(int size, int radius)
{
var tex = new Texture2D(size, size, TextureFormat.RGBA32, false);
tex.wrapMode = TextureWrapMode.Clamp;
tex.hideFlags = HideFlags.DontSave;
var px = new Color[size * size];
for (int y = 0; y < size; y++)
for (int x = 0; x < size; x++)
{
float cx = Mathf.Clamp(x + 0.5f, radius, size - radius);
float cy = Mathf.Clamp(y + 0.5f, radius, size - radius);
float d = Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(cx, cy));
px[y * size + x] = new Color(1f, 1f, 1f, Mathf.Clamp01(radius - d + 0.5f));
}
tex.SetPixels(px);
tex.Apply();
float b = radius + 2;
var sp = Sprite.Create(tex, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f), 100f, 0, SpriteMeshType.FullRect, new Vector4(b, b, b, b));
sp.hideFlags = HideFlags.DontSave;
return sp;
}
// ---------- 기본 ----------
public static RectTransform Rect(string name, Transform parent, Vector2 anchor, Vector2 pivot, Vector2 pos, Vector2 size)
{
var go = new GameObject(name, typeof(RectTransform));
go.transform.SetParent(parent, false);
var rt = (RectTransform)go.transform;
rt.anchorMin = rt.anchorMax = anchor;
rt.pivot = pivot;
rt.anchoredPosition = pos;
rt.sizeDelta = size;
return rt;
}
/// <summary>부모 전체에서 안쪽 여백(왼·오·위·아래)만 뺀 영역.</summary>
public static RectTransform Fill(string name, Transform parent, float l = 0f, float r = 0f, float t = 0f, float b = 0f)
{
var go = new GameObject(name, typeof(RectTransform));
go.transform.SetParent(parent, false);
var rt = (RectTransform)go.transform;
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.pivot = new Vector2(0.5f, 0.5f);
rt.offsetMin = new Vector2(l, b);
rt.offsetMax = new Vector2(-r, -t);
return rt;
}
/// <summary>위쪽 띠(부모 가로 전체 · 위에서 top 아래 · 높이 h).</summary>
public static RectTransform TopBand(string name, Transform parent, float top, float h, float l = 0f, float r = 0f)
{
var go = new GameObject(name, typeof(RectTransform));
go.transform.SetParent(parent, false);
var rt = (RectTransform)go.transform;
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(1f, 1f);
rt.pivot = new Vector2(0.5f, 1f);
rt.offsetMin = new Vector2(l, -top - h);
rt.offsetMax = new Vector2(-r, -top);
return rt;
}
/// <summary>
/// 칠하기 — rounded = 팩 카드(흰 바탕 + 굵은 외곽선 · 색 틴트 · 반투명이면 외곽선 없는 판) · false = 평면 띠.
/// </summary>
public static Image Paint(RectTransform rt, Color c, bool rounded = true, bool raycast = false)
{
var img = rt.gameObject.AddComponent<Image>();
img.color = c;
if (rounded && !UISkin.Apply(img, c.a >= 0.95f ? "card" : "soft"))
{
img.sprite = Round;
img.type = Image.Type.Sliced;
}
img.raycastTarget = raycast;
return img;
}
/// <summary>스킨 id 로 칠하기(없으면 둥근 도형).</summary>
public static Image Skin(RectTransform rt, string skin, Color c, bool raycast = false)
{
var img = rt.gameObject.AddComponent<Image>();
img.color = c;
if (!UISkin.Apply(img, skin))
{
img.sprite = Round;
img.type = Image.Type.Sliced;
}
img.raycastTarget = raycast;
return img;
}
/// <summary>부모를 채우는 스킨 층(안쪽 여백 왼·위·오·아래 · 음수 = 바깥) · 스킨이 없으면 꺼 둔다(UI 상업용 재설계 #1104).</summary>
public static Image Layer(Transform p, string name, string skin, Color c, float l = 0f, float t = 0f, float r = 0f, float b = 0f)
{
var img = Skin(Fill(name, p, l, r, t, b), skin, c);
img.enabled = UISkin.Has(skin);
return img;
}
/// <summary>
/// 섹션 패널(Fable 제안서 §3-2 · §5-1) = 그림자(아래 6) → 프레임(#3F4262) → 필드(안쪽 6 · #262840 · 마스크) [→ 문양 타일 5 %] [→ 바닥 그라데이션] → 윗면 광택 h14.
/// 위치 = 부모 왼쪽 위 기준(x, y 아래로) · 반환 = 필드(내용은 필드 자식 · 필드 모양으로 잘림 · 잘리면 안 되는 내용은 패널 루트에).
/// </summary>
public static RectTransform SectionPanel(Transform parent, string name, float x, float y, float w, float h, bool pattern, bool ground, out RectTransform root)
{
root = Rect(name, parent, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(x, -y), new Vector2(w, h));
Layer(root, "Shadow", "panel_frame", Col("panel_shadow", new Color(0f, 0f, 0f, 0.35f)), 0f, 6f, 0f, -6f);
Layer(root, "Frame", "panel_frame", Col("panel_frame", Paper));
var field = Layer(root, "Field", "panel_field", Col("panel_field", Dark), 6f, 6f, 6f, 6f);
field.gameObject.AddComponent<Mask>().showMaskGraphic = true;
if (pattern && UISkin.Has("bg_pattern"))
{
var pat = Layer(field.transform, "Pattern", "bg_pattern", Col("panel_pattern", Color.clear));
pat.type = Image.Type.Tiled;
}
if (ground && UISkin.Has("scroll_fade"))
{
// 스프라이트 = 위 불투명 → 아래 투명(실측) → 뒤집어 바닥이 어둡게
var g = Skin(Rect("Ground", field.transform, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), Vector2.zero, new Vector2(0f, 100f)), "scroll_fade", Col("panel_ground", Color.clear));
g.rectTransform.anchorMin = new Vector2(0f, 0f);
g.rectTransform.anchorMax = new Vector2(1f, 0f);
g.rectTransform.sizeDelta = new Vector2(0f, 100f);
g.rectTransform.localScale = new Vector3(1f, -1f, 1f);
g.rectTransform.pivot = new Vector2(0.5f, 1f);
g.rectTransform.anchoredPosition = Vector2.zero;
}
var lrt = Rect("Light", root, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -8f), new Vector2(0f, 14f));
lrt.anchorMin = new Vector2(0f, 1f);
lrt.anchorMax = new Vector2(1f, 1f);
lrt.sizeDelta = new Vector2(-24f, 14f);
lrt.anchoredPosition = new Vector2(0f, -8f);
Skin(lrt, "panel_light", Col("panel_light", Color.clear)).enabled = UISkin.Has("panel_light");
return (RectTransform)field.transform;
}
/// <summary>섹션 제목 띠(팩 Title_Tapered02 · 패널 왼쪽 위 모서리 · 360×69 · 글자 36 흰 외곽선 · 왼쪽 여백 36).</summary>
public static RectTransform SectionTitle(Transform panelRoot, string text, float w = 360f)
{
var band = Rect("Title", panelRoot, new Vector2(0f, 1f), new Vector2(0f, 1f), Vector2.zero, new Vector2(w, 69f));
if (UISkin.Has("band_title")) Skin(band, "band_title", Col("band_title", Dark));
var t = Title(Label(band, text, 36, White, TextAnchor.MiddleLeft, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(36f, 2f), new Vector2(w - 60f, 60f)), true);
t.horizontalOverflow = HorizontalWrapMode.Overflow;
return band;
}
/// <summary>스크롤 위·아래 페이드(필드색 → 투명) — 목록이 패널 안에서 잘리는 느낌을 지움.</summary>
public static void ScrollFades(Transform parent, float top, float bottom, float h = 40f, float side = 12f)
{
if (!UISkin.Has("scroll_fade")) return;
var c = Col("panel_field", Dark);
var a = Skin(Rect("FadeTop", parent, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -top), new Vector2(0f, h)), "scroll_fade", c);
a.rectTransform.anchorMin = new Vector2(0f, 1f);
a.rectTransform.anchorMax = new Vector2(1f, 1f);
a.rectTransform.sizeDelta = new Vector2(-2f * side, h);
var b = Skin(Rect("FadeBottom", parent, new Vector2(0.5f, 0f), new Vector2(0.5f, 1f), new Vector2(0f, bottom), new Vector2(0f, h)), "scroll_fade", c);
b.rectTransform.anchorMin = new Vector2(0f, 0f);
b.rectTransform.anchorMax = new Vector2(1f, 0f);
b.rectTransform.sizeDelta = new Vector2(-2f * side, h);
b.rectTransform.localScale = new Vector3(1f, -1f, 1f);
}
/// <summary>아이템 칸 5층(팩 ItemFrame00~03 · 색 = prefix + bg · deco · light · glow · 테두리 gear_border) — 장비·보물·보상 칸 공용.</summary>
public static void ItemSlot(Transform cell, string prefix, bool lit)
{
Layer(cell, "Glow", "item_glow", lit ? Col(prefix + "glow", White) : Color.clear, -8f, -8f, -8f, -8f);
Layer(cell, "Bg", "item_bg", Col(prefix + "bg", White));
Layer(cell, "Deco", "item_deco", Col(prefix + "deco", White), 4f, 4f, 4f, 4f);
var light = Layer(cell, "Light", "item_light", Col(prefix + "light", White), 8f, 6f, 8f, 0f);
light.rectTransform.anchorMin = new Vector2(0f, 0.54f); // 윗면 빛 = 칸 높이 46 %(목업)
Layer(cell, "Border", "item_border", Col("gear_border", Line));
}
/// <summary>아이콘·글자 그림자(테마 icon_shadow).</summary>
public static void DropShadow(Graphic g, float d)
{
if (g == null) return;
var s = g.gameObject.AddComponent<Shadow>();
s.effectColor = Col("icon_shadow", new Color(0f, 0f, 0f, 0.35f));
s.effectDistance = new Vector2(0f, -d);
}
// ---------- 공용 부품 ②(Fable 제안서 §4~§5 · 나머지 화면 #1104) ----------
/// <summary>부모 왼쪽 위 기준 사각(x 오른쪽 · y 아래로).</summary>
public static RectTransform At(Transform p, string name, float x, float y, float w, float h)
{
return Rect(name, p, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(x, -y), new Vector2(w, h));
}
/// <summary>
/// 스킨 버튼(§5-5 버튼 위계 · Primary btn_l_yellow·btn_l_green·btn_l_red · Secondary btn_sky·btn_s_green·btn_purple · Tertiary round08) —
/// 틀 + 틴트 + 글자(제목 폰트 · 밝은 글자만 외곽선 · 팩 버튼 아래 턱만큼 올림) · 왼쪽 위 기준.
/// </summary>
public static Button SkinBtn(Transform p, string name, string skin, Color tint, string label, int size, Color fg, float x, float y, float w, float h, Action onClick)
{
var img = Skin(At(p, name, x, y, w, h), skin, tint, true);
var b = img.gameObject.AddComponent<Button>();
var cb = b.colors;
cb.disabledColor = new Color(0.75f, 0.75f, 0.75f, 0.85f);
b.colors = cb;
if (onClick != null) b.onClick.AddListener(() => onClick());
var t = Title(FillLabel(img.transform, label ?? "", size, fg, TextAnchor.MiddleCenter, true, 0f), fg.r + fg.g + fg.b > 2.4f);
t.rectTransform.offsetMin = new Vector2(14f, Mathf.Min(12f, h * 0.1f));
t.rectTransform.offsetMax = new Vector2(-14f, 0f);
t.horizontalOverflow = HorizontalWrapMode.Overflow;
return b;
}
/// <summary>버튼 글자 옆 비용(§5-5 「비용 = 아이콘 + 숫자 Sen」) — 글자 · 아이콘 · 숫자를 가운데 한 줄로 · 반환 = 숫자 글자.</summary>
public static Text BtnCost(Button b, string icon, string num, float iconSize, int numSize)
{
var label = LabelOf(b);
var lrt = label.rectTransform;
var row = Fill("Row", b.transform, lrt.offsetMin.x, -lrt.offsetMax.x, 0f, lrt.offsetMin.y);
var hl = row.gameObject.AddComponent<HorizontalLayoutGroup>();
hl.childAlignment = TextAnchor.MiddleCenter;
hl.spacing = 8f;
hl.childControlWidth = true; // 글자 = 선호 폭 · 아이콘 = LayoutElement 폭
hl.childControlHeight = false;
hl.childForceExpandWidth = hl.childForceExpandHeight = false;
label.transform.SetParent(row, false);
float rh = row.rect.height > 1f ? row.rect.height : 80f;
label.rectTransform.anchorMin = label.rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
label.rectTransform.sizeDelta = new Vector2(Mathf.Ceil(label.preferredWidth), rh);
var ic = Icon(row, icon, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, iconSize);
if (ic != null)
{
DropShadow(ic, 3f);
var le = ic.gameObject.AddComponent<LayoutElement>();
le.minWidth = le.preferredWidth = iconSize;
}
var n = Title(Label(row, num, numSize, label.color, TextAnchor.MiddleLeft, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(80f, rh)), label.color.r + label.color.g + label.color.b > 2.4f);
n.horizontalOverflow = HorizontalWrapMode.Overflow;
return n;
}
/// <summary>서브탭(§5-5 · Button_Round10 + 윗면 광택 · 선택 tab_focus / 꺼짐 subtab_off · 32 흰 외곽선) · 왼쪽 위 기준.</summary>
public static Button SubTab(Transform p, string name, string label, float x, float y, float w, float h, Action onClick)
{
var img = Skin(At(p, name, x, y, w, h), UISkin.Has("subtab") ? "subtab" : "button", Col("subtab_off", Paper), true);
var b = img.gameObject.AddComponent<Button>();
if (onClick != null) b.onClick.AddListener(() => onClick());
Title(FillLabel(img.transform, label, 32, White, TextAnchor.MiddleCenter, true, 8f), true);
return b;
}
public static void SetSubTab(Button b, bool on)
{
b.GetComponent<Image>().color = on ? Col("tab_focus", Red) : Col("subtab_off", Paper);
}
/// <summary>레벨형 게이지(§4-4 · Slider_Level02 바탕 + 채움 틴트 + 왼쪽 배지 · 영웅 상세 수치) · 반환 = 채움(SetGauge) · 배지 글자 = out.</summary>
public static RectTransform Gauge(Transform p, string name, float x, float y, float w, string badge, string fillColor, out Text badgeText)
{
var root = At(p, name, x, y, w + 86f, 96f);
Skin(At(root, "Bg", 86f, 21f, w, 54f), "lv_bg", White);
var fill = At(root, "Fill", 96f, 27f, w - 20f, 38f);
Skin(fill, "lv_fill", Col(fillColor, Coin));
var bi = Icon(root, badge, new Vector2(0f, 1f), new Vector2(0f, 1f), Vector2.zero, 96f);
if (bi != null) DropShadow(bi, 3f);
badgeText = Title(Label(root, "", 34, White, TextAnchor.MiddleCenter, new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), new Vector2(46f, -46f), new Vector2(92f, 60f)), true);
badgeText.horizontalOverflow = HorizontalWrapMode.Overflow;
return fill;
}
public static void SetGauge(RectTransform fill, float v, float w)
{
v = Mathf.Clamp01(v);
fill.sizeDelta = new Vector2(Mathf.Max(18f, (w - 20f) * v), fill.sizeDelta.y);
fill.gameObject.SetActive(v > 0.001f);
}
/// <summary>정보 칩(§5-6 · Oval46 chip_dark · 좌 아이콘 + 글자 Sen) · 왼쪽 위 기준 · 반환 = 글자.</summary>
public static Text InfoChip(Transform p, string name, float x, float y, float w, float h, string icon, string text, int size)
{
var chip = Skin(At(p, name, x, y, w, h), "chip", Col("chip_dark", Dark));
float lx = 20f;
if (!string.IsNullOrEmpty(icon))
{
float s = Mathf.Min(h + 6f, 48f);
var ic = Icon(chip.transform, icon, new Vector2(0f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(h * 0.5f + 2f, 0f), s);
if (ic != null)
{
DropShadow(ic, 3f);
lx = h + 8f;
}
}
var t = Title(Label(chip.transform, text, size, White, lx > 20f ? TextAnchor.MiddleLeft : TextAnchor.MiddleCenter, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(lx, 2f), new Vector2(w - lx - 16f, h)), true);
t.horizontalOverflow = HorizontalWrapMode.Overflow;
return t;
}
/// <summary>수량 칩(§4-2 · 칸 오른쪽 아래에 걸친 Oval46 · 글자 Sen 흰 외곽선) · 폭 = 글자에 맞춤.</summary>
public static Text QtyChip(Transform cell, string qty, int size)
{
float h = size + 12f;
var chip = Skin(Rect("Qty", cell, new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(8f, -8f), new Vector2(60f, h)), "chip", Col("chip_dark", Dark));
var t = Title(FillLabel(chip.transform, qty.Replace("×", "x"), size, White, TextAnchor.MiddleCenter, true, 0f), true); // Sen 에 × 글리프 없음
t.horizontalOverflow = HorizontalWrapMode.Overflow;
chip.rectTransform.sizeDelta = new Vector2(Mathf.Max(h + 8f, Mathf.Ceil(t.preferredWidth) + 30f), h);
return t;
}
/// <summary>보상 타일(§4-2 · ItemFrame 5층 + 아이콘 68 % + 수량 칩) · prefix = 칸 색(gear_white_ · gear_&lt;등급&gt;_ …) · 왼쪽 위 기준.</summary>
public static RectTransform RewardTile(Transform p, string name, float x, float y, float size, string prefix, string icon, string qty, bool lit)
{
var cell = At(p, name, x, y, size, size);
ItemSlot(cell, prefix, lit);
var ic = Icon(cell, icon, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, 4f), size * 0.68f);
DropShadow(ic, 4f);
if (!string.IsNullOrEmpty(qty)) QtyChip(cell, qty, size >= 120f ? 30 : 24);
return cell;
}
/// <summary>조건·정보 행(§4-2 · BasicFrame_Round12 panel_field · 좌 아이콘(tint = 켜짐 ok · 꺼짐 disabled) + 글자) · 왼쪽 위 기준 · icon null = 아이콘 없음.</summary>
public static Text CondRow(Transform p, string name, float x, float y, float w, float h, string icon, bool on, string text, int size, bool tint = true, string plate = "row_on_field")
{
var row = Skin(At(p, name, x, y, w, h), "panel12", Col(plate, Dark));
float lx = 24f;
if (!string.IsNullOrEmpty(icon))
{
var ic = Icon(row.transform, icon, new Vector2(0f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(h * 0.5f + 6f, 0f), Mathf.Min(40f, h - 16f));
if (ic != null)
{
if (tint) ic.color = on ? Col("ok", Ok) : Col("disabled", Disabled);
DropShadow(ic, 2f);
lx = h + 12f;
}
}
var t = Label(row.transform, text, size, on ? White : Col("text_soft", InkSoft), TextAnchor.MiddleLeft, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(lx, 0f), new Vector2(w - lx - 20f, h), false);
t.horizontalOverflow = HorizontalWrapMode.Overflow;
return t;
}
/// <summary>구역 소제목(§5-2 · Title_Line01_Divider 좌·우 + 글자 32 흰 외곽선) · x·y = 왼쪽 위 · w = 전체 폭.</summary>
public static Text SubTitle(Transform p, string text, float x, float y, float w, int size = 32)
{
var box = At(p, "Sub_" + text, x, y, w, 44f);
var t = Title(FillLabel(box, text, size, White, TextAnchor.MiddleCenter, true, 0f), true);
t.horizontalOverflow = HorizontalWrapMode.Overflow;
float tw = Mathf.Ceil(t.preferredWidth);
float lw = Mathf.Max(0f, (w - tw) * 0.5f - 18f);
if (lw > 0f && UISkin.Has("title_divider_l") && UISkin.Has("title_divider_r"))
{
Skin(Rect("L", box, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), Vector2.zero, new Vector2(lw, 30f)), "title_divider_l", Col("divider", White));
Skin(Rect("R", box, new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), Vector2.zero, new Vector2(lw, 30f)), "title_divider_r", Col("divider", White));
}
return t;
}
/// <summary>병과 단색 심볼 id(PD 2026-11-09 · 말 · 검 · 활 · 창 · 깃털부채) — 없으면 기존 병과 메달 아이콘.</summary>
public static string TypeSymbolId(UnitType t)
{
string id = "cls_sym_" + TypeKey(t);
return UISkin.Icon(id) != null ? id : TypeIconId(t);
}
/// <summary>진영 글자(위 · 촉 · 오 · 군) · 키 = heroes.csv faction(shu · wei · wu · other).</summary>
public static string FactionKo(string f)
{
switch (f)
{
case "shu": return "촉";
case "wei": return "위";
case "wu": return "오";
}
return "군";
}
/// <summary>진영 원 배지(PD 2026-11-09 「원형 프레임에 위·촉·오·군 · 촉 녹 · 위 청 · 오 적 · 군 짙은 회색」) = 원 틀(fbadge_*) + 안쪽 원(fbadge_*_in) + 흰 글자.</summary>
public static Image FactionBadge(Transform p, string faction, Vector2 anchor, Vector2 pivot, Vector2 pos, float d)
{
string f = faction == "shu" || faction == "wei" || faction == "wu" ? faction : "other";
var ring = Skin(Rect("Faction", p, anchor, pivot, pos, new Vector2(d, d)), "circle_frame", Col("fbadge_" + f, Dark));
ring.preserveAspect = true;
var inner = Rect("In", ring.transform, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(d * 0.72f, d * 0.72f)).gameObject.AddComponent<Image>();
inner.sprite = Circle;
inner.color = Col("fbadge_" + f + "_in", Dark);
inner.raycastTarget = false;
var t = Title(FillLabel(ring.transform, FactionKo(f), Mathf.RoundToInt(d * 0.48f), White), true);
t.rectTransform.offsetMin = new Vector2(0f, d * 0.03f);
t.horizontalOverflow = HorizontalWrapMode.Overflow;
return ring;
}
/// <summary>아이콘(테마 id · 없으면 null).</summary>
public static Image Icon(Transform parent, string id, Vector2 anchor, Vector2 pivot, Vector2 pos, float size)
{
return UISkin.IconImage(parent, id, anchor, pivot, pos, new Vector2(size, size));
}
/// <summary>아이콘이 있으면 아이콘 · 없으면 원 + 글자(구 표시).</summary>
public static Image IconOrDot(Transform parent, string id, string mark, Color c, Vector2 anchor, Vector2 pivot, Vector2 pos, float size)
{
var ic = Icon(parent, id, anchor, pivot, pos, size);
if (ic != null) return ic;
var dot = Dot("Icon", parent, anchor, pivot, pos, size, c);
FillLabel(dot.transform, mark, Mathf.RoundToInt(size * 0.5f), White);
return dot;
}
/// <summary>제목 폰트(Sen-ExtraBold + 한글 폴백) + 흰 글자 외곽선.</summary>
public static Text Title(Text t, bool stroke = false)
{
t.font = UISkin.TitleFont;
if (stroke && UISkin.On) UISkin.Stroke(t, Stroke, 3f);
return t;
}
public static Image Box(string name, Transform parent, Vector2 anchor, Vector2 pivot, Vector2 pos, Vector2 size, Color c, bool rounded = true)
{
return Paint(Rect(name, parent, anchor, pivot, pos, size), c, rounded);
}
public static Image Dot(string name, Transform parent, Vector2 anchor, Vector2 pivot, Vector2 pos, float d, Color c)
{
var img = Rect(name, parent, anchor, pivot, pos, new Vector2(d, d)).gameObject.AddComponent<Image>();
if (d > 280f || !UISkin.Apply(img, "dot")) img.sprite = Circle; // 큰 원(후광 등)은 팩 외곽선이 흐려져서 평면 원
img.preserveAspect = true;
img.color = c;
img.raycastTarget = false;
return img;
}
/// <summary>테두리(외곽선 효과 · 둥근 상자에 굵은 선처럼 보임) · 테마 카드는 외곽선을 이미 가져서 기본 선 색이면 생략.</summary>
public static void Border(Graphic g, Color c, float w = 4f)
{
if (UISkin.On && c == Line) return;
if (UISkin.On) w = Mathf.Min(w, 3f);
var o = g.gameObject.AddComponent<Outline>();
o.effectColor = c;
o.effectDistance = new Vector2(w, -w);
o.useGraphicAlpha = false;
var o2 = g.gameObject.AddComponent<Outline>();
o2.effectColor = c;
o2.effectDistance = new Vector2(-w, w);
o2.useGraphicAlpha = false;
}
public static Text Label(Transform parent, string s, int size, Color c, TextAnchor align, Vector2 anchor, Vector2 pivot, Vector2 pos, Vector2 box, bool bold = true)
{
var rt = Rect("Text", parent, anchor, pivot, pos, box);
return Style(rt.gameObject.AddComponent<Text>(), s, size, c, align, bold);
}
/// <summary>부모 전체에 꽉 찬 글자(가운데 정렬 기본).</summary>
public static Text FillLabel(Transform parent, string s, int size, Color c, TextAnchor align = TextAnchor.MiddleCenter, bool bold = true, float pad = 0f)
{
var rt = Fill("Text", parent, pad, pad, 0f, 0f);
return Style(rt.gameObject.AddComponent<Text>(), s, size, c, align, bold);
}
static Text Style(Text t, string s, int size, Color c, TextAnchor align, bool bold)
{
t.font = TextUtil.Font;
t.fontSize = size;
t.fontStyle = bold ? FontStyle.Bold : FontStyle.Normal;
t.alignment = align;
t.color = c;
t.horizontalOverflow = HorizontalWrapMode.Wrap;
t.verticalOverflow = VerticalWrapMode.Overflow;
t.supportRichText = true;
t.raycastTarget = false;
t.text = s;
return t;
}
public static Button Btn(string name, Transform parent, string label, int fontSize, Color bg, Color fg, Vector2 anchor, Vector2 pivot, Vector2 pos, Vector2 size, Action onClick)
{
var img = Skin(Rect(name, parent, anchor, pivot, pos, size), "button", bg, true);
var b = img.gameObject.AddComponent<Button>();
var cb = b.colors;
cb.disabledColor = new Color(0.75f, 0.75f, 0.75f, 0.85f);
b.colors = cb;
if (onClick != null) b.onClick.AddListener(() => onClick());
var t = FillLabel(img.transform, label, fontSize, fg, TextAnchor.MiddleCenter, true, 6f);
if (UISkin.On)
{
// 팩 버튼 = 아래 턱(약 16 px)이 있어 글자를 조금 올리고 흰 글자는 외곽선
var trt = t.rectTransform;
float side = Mathf.Min(16f, size.x * 0.08f); // 팩 버튼 외곽선 안쪽으로
trt.offsetMin = new Vector2(side, Mathf.Min(10f, size.y * 0.1f));
trt.offsetMax = new Vector2(-side, trt.offsetMax.y);
Title(t, fg.r + fg.g + fg.b > 2.4f);
}
return b;
}
public static Text LabelOf(Button b)
{
return b.GetComponentInChildren<Text>(true);
}
/// <summary>둥근 막대 게이지 · 반환 = 채움(SetBar 로 0~1).</summary>
public static RectTransform Bar(string name, Transform parent, Vector2 anchor, Vector2 pivot, Vector2 pos, Vector2 size, Color bg, Color fg)
{
if (UISkin.Has("bar_bg"))
{
// 팩 슬라이더: 어두운 홈(스킨 원색) + 안쪽 홈에 채움(용도 색 틴트)
var back = Skin(Rect(name, parent, anchor, pivot, pos, size), "bar_bg", Col("bar_track", White));
float pad = Mathf.Clamp(size.y * 0.16f, 3f, 6f);
var track = Fill("Track", back.transform, pad, pad, pad, pad);
var f = Fill("Fill", track);
f.anchorMax = new Vector2(0f, 1f);
Skin(f, "bar_fill", fg);
return f;
}
var b0 = Box(name, parent, anchor, pivot, pos, size, bg);
var fill = Fill("Fill", b0.transform);
fill.anchorMax = new Vector2(0f, 1f);
Paint(fill, fg);
return fill;
}
public static void SetBar(RectTransform fill, float v)
{
v = Mathf.Clamp01(v);
fill.anchorMax = new Vector2(v, 1f);
fill.gameObject.SetActive(v > 0.001f);
}
/// <summary>세로 스크롤 격자 목록(칸 크기 cell · 열 columns) · 반환 = 내용(자식 = 칸).</summary>
public static RectTransform ScrollGrid(string name, Transform parent, float l, float r, float t, float b, Vector2 cell, Vector2 spacing, int columns, out ScrollRect scroll)
{
var vp = Fill(name, parent, l, r, t, b);
var vimg = vp.gameObject.AddComponent<Image>();
vimg.color = new Color(1f, 1f, 1f, 0f);
vimg.raycastTarget = true;
vp.gameObject.AddComponent<RectMask2D>();
var content = new GameObject("Content", typeof(RectTransform)).GetComponent<RectTransform>();
content.SetParent(vp, false);
content.anchorMin = new Vector2(0f, 1f);
content.anchorMax = new Vector2(1f, 1f);
content.pivot = new Vector2(0.5f, 1f);
content.offsetMin = content.offsetMax = Vector2.zero;
// 스크롤 내용 = 자기 캔버스(Frame) — 끄는 동안 매 프레임 움직여도 화면 나머지를 다시 묶지 않게(PD 2026-11-10 · RectMask2D 자르기 유지)
UiLayer.Nest(content.gameObject, UiRate.Frame, true);
var grid = content.gameObject.AddComponent<GridLayoutGroup>();
grid.cellSize = cell;
grid.spacing = spacing;
grid.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
grid.constraintCount = columns;
grid.childAlignment = TextAnchor.UpperCenter;
grid.padding = new RectOffset(0, 0, 6, 12);
var fit = content.gameObject.AddComponent<ContentSizeFitter>();
fit.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
scroll = vp.gameObject.AddComponent<ScrollRect>();
scroll.viewport = vp;
scroll.content = content;
scroll.horizontal = false;
scroll.vertical = true;
scroll.movementType = ScrollRect.MovementType.Clamped;
scroll.scrollSensitivity = 60f;
return content;
}
public static void Clear(Transform t)
{
for (int i = t.childCount - 1; i >= 0; i--)
{
var c = t.GetChild(i).gameObject;
c.SetActive(false);
UnityEngine.Object.Destroy(c);
}
}
/// <summary>목록 칸(격자 자식) — 둥근 카드 + 클릭.</summary>
public static Image Cell(Transform content, string name, Color fill, Action onClick)
{
var go = new GameObject(name, typeof(RectTransform));
go.transform.SetParent(content, false);
var img = Paint((RectTransform)go.transform, fill);
img.raycastTarget = true;
if (onClick != null)
{
var b = go.AddComponent<Button>();
b.onClick.AddListener(() => onClick());
}
return img;
}
// ---------- 영웅 표시 ----------
public static Color TypeColor(UnitType t)
{
switch (t)
{
case UnitType.Infantry: return Col("type_infantry", new Color(0.62f, 0.33f, 0.22f));
case UnitType.Spear: return Col("type_spear", new Color(0.30f, 0.50f, 0.30f));
case UnitType.Cavalry: return Col("type_cavalry", new Color(0.28f, 0.32f, 0.45f));
case UnitType.Archer: return Col("type_archer", new Color(0.70f, 0.52f, 0.20f));
case UnitType.Strategist: return Col("type_strategist", Purple);
}
return Dark;
}
/// <summary>병종 키(아이콘 id 접미 · cls_&lt;키&gt; · skill_&lt;키&gt;_a|p).</summary>
public static string TypeKey(UnitType t)
{
switch (t)
{
case UnitType.Infantry: return "infantry";
case UnitType.Spear: return "spear";
case UnitType.Cavalry: return "cavalry";
case UnitType.Archer: return "archer";
case UnitType.Strategist: return "strategist";
}
return null;
}
/// <summary>병종 아이콘 id(테마 · 신규 제작 cls_*).</summary>
public static string TypeIconId(UnitType t)
{
var k = TypeKey(t);
return k != null ? "cls_" + k : null;
}
/// <summary>신규 제작(B) 치환이 들어온 아이콘인가(임시 팩 아이콘이 뜻과 어긋나는 곳은 그때만 아이콘을 쓴다 · 예: 병종).</summary>
public static bool HasFinalIcon(string id)
{
var e = UISkin.Theme != null ? UISkin.Theme.IconEntryOf(id) : null;
return e != null && e.samguk != null;
}
/// <summary>등급(승급 green… · 장비 white…) 표시 이름.</summary>
public static string GradeKo(string g)
{
switch (g)
{
case "white": return "흰";
case "green": return "녹";
case "blue": return "청";
case "purple": return "자";
case "orange": return "주";
case "gold": return "금";
}
return g;
}
public static Color GradeColor(string g)
{
switch (g)
{
case "white": return Col("grade_white", new Color(0.80f, 0.79f, 0.76f));
case "green": return Col("grade_green", new Color(0.38f, 0.64f, 0.33f));
case "blue": return Col("grade_blue", new Color(0.29f, 0.48f, 0.78f));
case "purple": return Col("grade_purple", Purple);
case "orange": return Col("grade_orange", new Color(0.90f, 0.50f, 0.18f));
case "gold": return Col("grade_gold", new Color(0.92f, 0.74f, 0.16f));
}
return Slot;
}
/// <summary>등급 칸(팩 ItemFrame 등급 색 · 신규 제작 frame_* 오면 치환) — 스킨이 없으면 등급 색 카드.</summary>
public static Image GradeFrame(Image img, string grade)
{
if (!string.IsNullOrEmpty(grade) && UISkin.Apply(img, "frame_" + grade))
{
img.color = White;
return img;
}
UISkin.Apply(img, "card");
img.color = GradeColor(grade);
return img;
}
/// <summary>별 줄(★ 아이콘 n개 · 테마 없으면 글자).</summary>
public static void StarRow(Transform parent, int n, int max, Vector2 anchor, Vector2 pivot, Vector2 pos, float size)
{
if (UISkin.Icon("star_on") == null)
{
Label(parent, Stars(n, max), Mathf.RoundToInt(size), Star, TextAnchor.MiddleCenter, anchor, pivot, pos, new Vector2(size * max, size + 6f));
return;
}
var row = Rect("Stars", parent, anchor, pivot, pos, new Vector2(size * max * 0.92f, size));
for (int i = 0; i < max; i++)
Icon(row, i < n ? "star_on" : "star_off", new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(i * size * 0.92f, 0f), size);
}
public static string Stars(int n, int max)
{
var sb = new System.Text.StringBuilder();
for (int i = 0; i < max; i++) sb.Append(i < n ? "★" : "☆");
return sb.ToString();
}
static readonly Dictionary<string, Sprite> artCache = new Dictionary<string, Sprite>();
/// <summary>일러 = Resources/Units/&lt;art&gt; front_idle 첫 프레임(없으면 다른 클립 첫 프레임 · 없으면 null = 도형).</summary>
public static Sprite ArtSprite(string art)
{
if (string.IsNullOrEmpty(art)) return null;
Sprite sp;
if (artCache.TryGetValue(art, out sp)) return sp;
var a = UnitArt.Load(art);
Sprite[] frames = null;
if (a != null && !a.Clips.TryGetValue("front_idle", out frames))
foreach (var kv in a.Clips)
{
frames = kv.Value;
break;
}
sp = frames != null && frames.Length > 0 ? frames[0] : null;
artCache[art] = sp;
return sp;
}
/// <summary>
/// 서 있는 유닛(발 = anchor + pos) — 크기 표 size_px 기준 배율(standardPx ÷ size_px = 머리 크기 통일 · 영웅 목록·전투와 같은 기준) ·
/// 기마 = 머리 끝이 standardPx × mountedMaxTop 을 넘지 않게(전투 FrameAnimator 와 같은 상한) · 일러·피벗이 없으면 null(호출 쪽이 초상으로 대체).
/// PD 2026-11-10 「로비에 배치된 관우의 크기만 작게 보임」 — 칸에 그림 틀을 맞추면 틀이 넓은 유닛(관우 언월도)만 작아짐.
/// </summary>
public static RectTransform UnitStand(string name, Transform parent, HeroDb.Hero h, Vector2 anchor, Vector2 pos, float standardPx, float mountedMaxTop)
{
var sp = h != null ? ArtSprite(h.art) : null;
var a = h != null ? UnitArt.Load(h.art) : null;
if (sp == null || a == null || a.Meta.pivot == null || a.Meta.pivot.Length < 2) return null;
float size = a.SizePx > 0.5f ? a.SizePx : a.BodyPx;
if (size <= 0.5f) return null;
float k = standardPx / size;
float top = a.BodyPx * k;
if (a.Mounted && mountedMaxTop > 0.01f && top > standardPx * mountedMaxTop) k *= standardPx * mountedMaxTop / top;
var rt = Rect(name, parent, anchor, new Vector2(0.5f, a.Meta.pivot[1]), pos, sp.rect.size * k);
var img = rt.gameObject.AddComponent<Image>();
img.sprite = sp;
img.raycastTarget = false;
return rt;
}
/// <summary>
/// 초상(틀 안을 마스크) — 일러가 있으면 그 그림(zoom &gt; 1 = 위쪽 얼굴 부분 확대) · 없으면 병종 색 원 + 병종 글자.
/// circle = 원형 틀(편성 자리) · false = 둥근 사각.
/// </summary>
public static RectTransform Portrait(string name, Transform parent, HeroDb.Hero h, Vector2 anchor, Vector2 pivot, Vector2 pos, Vector2 size, bool circle, float zoom, Color frame)
{
var outer = Rect(name, parent, anchor, pivot, pos, size);
var rt = outer;
bool framed = frame.a > 0.01f && UISkin.On;
if (framed)
{
// 팩 틀(외곽선) 바깥 + 안쪽 마스크(외곽선 안으로 그림이 들어가게)
var fimg = outer.gameObject.AddComponent<Image>();
fimg.color = frame;
fimg.raycastTarget = false;
if (circle)
{
if (!UISkin.Apply(fimg, "circle_frame")) fimg.sprite = Circle;
fimg.preserveAspect = true;
}
else UISkin.Apply(fimg, "card");
float pad = Mathf.Max(5f, Mathf.Min(size.x, size.y) * 0.05f);
rt = Fill("Mask", outer, pad, pad, pad, pad + (circle ? 0f : 2f));
size = new Vector2(size.x - pad * 2f, size.y - pad * 2f);
}
var bg = rt.gameObject.AddComponent<Image>();
bg.color = framed ? new Color(frame.r * 0.9f, frame.g * 0.9f, frame.b * 0.9f, 1f) : frame;
bg.raycastTarget = false;
if (circle) bg.sprite = Circle;
else if (!framed || !UISkin.Apply(bg, "portrait_mask"))
{
bg.sprite = Round;
bg.type = Image.Type.Sliced;
}
var mask = rt.gameObject.AddComponent<Mask>();
mask.showMaskGraphic = frame.a > 0.01f;
if (frame.a <= 0.01f) bg.color = Color.white; // 투명 틀: 알파 0 그래픽은 스텐실을 안 써서 자식이 안 보임 → 불투명 + 그리지 않음
var sp = h != null ? ArtSprite(h.art) : null;
if (sp != null)
{
// zoom ≤ 1 = 전신 가운데 · zoom > 1 = 위쪽(얼굴) 확대
bool bust = zoom > 1f;
var a = bust
? Rect("Art", rt, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, size.y * 0.06f * zoom), size * zoom)
: Rect("Art", rt, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, size * zoom);
var img = a.gameObject.AddComponent<Image>();
img.sprite = sp;
img.preserveAspect = true;
img.raycastTarget = false;
}
else if (h != null)
{
float d = Mathf.Min(size.x, size.y) * 0.72f;
var dot = Dot("Shape", rt, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, d, TypeColor(h.type));
Title(FillLabel(dot.transform, UnitTypeInfo.Mark(h.type), Mathf.RoundToInt(d * 0.5f), White), true);
}
return outer;
}
/// <summary>병종 배지(병종 색 칩 + 글자 · 신규 제작 cls_* 아이콘이 오면 아이콘).</summary>
public static Image TypeBadge(Transform parent, UnitType t, Vector2 anchor, Vector2 pivot, Vector2 pos, float h)
{
var b = Box("Type", parent, anchor, pivot, pos, new Vector2(h * 1.3f, h), UISkin.On ? Color.clear : Dark); // 테마 = 틀 없이 단색 심볼(PD 2026-11-09)
string icon = TypeSymbolId(t);
if (UISkin.Icon(icon) != null) Icon(b.transform, icon, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, 1f), h * 0.8f);
else Title(FillLabel(b.transform, UnitTypeInfo.Mark(t), Mathf.RoundToInt(h * 0.62f), White), true);
return b;
}
}
}