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

1017 lines
59 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.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace TKS.OutGame
{
/// <summary>
/// 영웅·병사 카드(가로형 · UI 상업용 재설계 ② · 설계서 §3② · 팩 CardFrame01 5층 등급색) — 초상(뒤 빛) · 병종 원 · Lv 배지 · 세력 깃발 · 출전 꼬리 칩 ·
/// 아래 이름 띠(이름 · ★ · 등급 칩) · 미보유 = 빈 카드(문양 + 초상 어둡게). 목록 · 편성 보유 행 · 도감 공용.
/// </summary>
public static class HeroCards
{
public static Image Card(Transform content, OutGameSession s, HeroDb.Hero h, bool deployed, bool owned, System.Action onTap)
{
var cell = UiKit.Cell(content, "Card_" + h.id, Color.clear, onTap);
cell.sprite = null; // 바닥 = 클릭 판(투명) · 그림은 층으로
var lv = s.Save.Level(h.id);
var hs = s.Save.Hero(h.id);
string g = h.isSoldier || hs == null || string.IsNullOrEmpty(hs.grade) ? "white" : hs.grade;
string G = "grade_" + g + "_";
var t = cell.transform;
if (owned)
{
UiKit.Layer(t, "Bg", "card_bg", UiKit.Col(G + "card_bg", UiKit.Paper));
UiKit.Layer(t, "Grad", "card_grad", UiKit.Col(G + "card_grad", UiKit.White), 0f, 6f, 0f, 0f);
var glow = UiKit.Skin(UiKit.Rect("Glow", t, new Vector2(0f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(72f, 6f), new Vector2(190f, 190f)), "card_glow", UiKit.Col(G + "card_glow", UiKit.White));
glow.enabled = UISkin.Has("card_glow");
}
else
{
UiKit.Layer(t, "Bg", "card_empty", UiKit.Col("card_empty", UiKit.Slot));
var pat = UiKit.Layer(t, "Pattern", "card_empty_pattern", UiKit.Col("card_empty_pattern", UiKit.Slot), 6f, 6f, 6f, 6f);
pat.type = Image.Type.Tiled;
}
UiKit.Portrait("Face", t, h, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(12f, 6f), new Vector2(120f, 116f), false, 2f, Color.clear);
if (!owned) UiKit.Paint(UiKit.Rect("Dim", t, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(12f, 6f), new Vector2(120f, 116f)), UiKit.Col("card_dim", UiKit.Dark), false);
var band = UiKit.Rect("Band", t, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), Vector2.zero, new Vector2(0f, 62f));
band.anchorMin = new Vector2(0f, 0f);
band.anchorMax = new Vector2(1f, 0f);
band.sizeDelta = new Vector2(0f, 62f);
UiKit.Skin(band, "card_bottom", UiKit.Col("card_bottom", UiKit.Dark)).enabled = UISkin.Has("card_bottom");
UiKit.Layer(t, "Border", "card_border", UiKit.Col(owned ? "card_border" : "card_empty_border", UiKit.Line));
int star = hs != null ? hs.star : h.rarity;
UiKit.StarRow(t, star, star, new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(16f, 8f), 24f);
var name = UiKit.Title(UiKit.Label(t, h.nameKo, 40, owned ? UiKit.White : UiKit.InkSoft, TextAnchor.MiddleLeft, new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(142f, 4f), new Vector2(260f, 56f)), true);
name.horizontalOverflow = HorizontalWrapMode.Overflow;
// 위 줄: 병종 원 · Lv 배지 · 세력 깃발 · (출전 꼬리 칩)
// 병과 = 단색 심볼만(배경 틀 없음 · PD 2026-11-09)
var ring = UiKit.Rect("Type", t, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(142f, -12f), new Vector2(52f, 52f));
var cls = UiKit.Icon(ring, UiKit.TypeSymbolId(h.type), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, 50f);
if (cls == null) UiKit.Title(UiKit.FillLabel(ring, UnitTypeInfo.Mark(h.type), 26, UiKit.White), true);
if (owned)
{
var badge = UiKit.Icon(t, "lv_badge_s", new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(202f, -8f), 56f);
var lt = UiKit.Title(UiKit.Label(badge != null ? badge.transform : t, (lv != null ? lv.lv : 1).ToString(), 26, UiKit.White, TextAnchor.MiddleCenter,
badge != null ? new Vector2(0.5f, 0.5f) : new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), badge != null ? new Vector2(0f, 2f) : new Vector2(230f, -36f), new Vector2(56f, 40f)), true);
lt.horizontalOverflow = HorizontalWrapMode.Overflow;
}
UiKit.DropShadow(UiKit.FactionBadge(t, h.faction, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(264f, -12f), 46f), 2f); // 진영 원 배지(PD 2026-11-09)
if (deployed)
{
var tail = UiKit.Skin(UiKit.Rect("Deployed", t, new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(-10f, -10f), new Vector2(104f, 46f)), "tail", UiKit.Col("tail_red", UiKit.Red));
UiKit.Title(UiKit.FillLabel(tail.transform, "출전", 26, UiKit.White), true);
}
if (owned && hs != null && !h.isSoldier && UISkin.Has("para_" + g))
{
var chip = UiKit.Skin(UiKit.Rect("Grade", t, new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(-14f, 11f), new Vector2(66f, 40f)), "para_" + g, UiKit.Col(G + "chip", UiKit.White));
UiKit.Title(UiKit.FillLabel(chip.transform, UiKit.GradeKo(g), 24, UiKit.White), true);
}
if (owned && s.CanGrowAny(h.id) && UiKit.Icon(t, "new_dot", new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), new Vector2(14f, -10f), 34f) == null)
UiKit.Dot("New", t, new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), new Vector2(14f, -10f), 26f, UiKit.Red);
return cell;
}
}
/// <summary>
/// 영웅 목록 세로 카드(PD 2026-11-09 레퍼런스 = 팩 데모 Collection_List · 칸 320×431) — 등급색 바탕(마스크) · 그라데이션 · 일러 뒤 빛 · 일러 · 아래 어두운 띠 · 테두리 ·
/// 이름(왼쪽 위) · 병종 원 · 레벨 배지(왼쪽 아래 · 최고 = 노랑) · ★(오른쪽 아래) · 미보유 = 빈 카드(문양 + 얼굴 실루엣). 수치 = 팩 프리팹 RectTransform 그대로.
/// </summary>
public static class CollectionCards
{
public static readonly Vector2 CellSize = new Vector2(320f, 431f);
public static readonly Vector2 Spacing = new Vector2(22f, 20f);
/// <summary>크기 표 size_px(머리 크기 기준 표준 키)가 카드에서 차지할 px — 배율 = UnitPx ÷ size_px(모든 유닛 머리 크기 같음) · 머리 끝 위치(카드 위에서 px).</summary>
const float UnitPx = 360f; // 450 → 360 = 유닛 20 % 작게(PD 2026-11-09 「현재 비율보다 20 % 정도 작게」)
const float HeadTop = 50f;
/// <summary>걷는 유닛(보병·창병·궁병·책사) = 머리 끝을 더 내려 발이 카드 아래로 숨게(PD 2026-11-09).</summary>
const float FootDrop = 36f;
/// <summary>말 탄 유닛(크기 표 mounted) = 걷는 유닛보다 작게 · 말 얼굴이 카드 오른쪽 경계에 닿을 만큼(PD 2026-11-09 0.72 → 「조금 더 크게 · 내려도」).</summary>
const float MountedScale = 0.85f;
/// <summary>말 탄 유닛 머리 끝 내림(카드 px · PD 2026-11-09).</summary>
const float MountedDrop = 24f;
/// <summary>말 탄 유닛 전신(발 ~ 머리 끝) 높이 상한(카드 px) — 크기 표 실측이 튀는 그림(예 여포 size_px 449)도 다른 기병과 비슷하게.</summary>
const float MountedMaxBody = 460f;
/// <summary>눈 줄(카드 위에서 px · 크기 표 eye_up_px 가 있는 유닛) — 머리 끝 맞춤이면 관·투구가 낮은 유닛(전위·태사자)의 얼굴이 위로 뜸 →
/// 눈 높이를 맞춘다(PD 2026-11-10 「너무 위쪽에 등장하는 영웅은 내려」) · 값 = PD 확인 기존 20종의 머리 끝 맞춤 때 눈 높이 중앙값(걷는 216.5 · 기병 210.3).</summary>
const float EyeLine = 216f;
const float EyeLineMounted = 210f;
/// <param name="owned">false = 미보유(PD 2026-11-10 「유닛 목록에 장수들의 모습이 보이도록」) — 일러를 테마 card_locked_art 로 조금 어둡게 · 「미보유」 꼬리 칩 · 레벨 배지·성장 점 없음</param>
public static Image Card(Transform content, OutGameSession s, HeroDb.Hero h, bool deployed, System.Action onTap, bool owned = true)
{
var cell = UiKit.Cell(content, "Card_" + h.id, Color.clear, onTap);
cell.sprite = null; // 바닥 = 클릭 판(투명) · 그림은 층으로
var t = cell.transform;
var lv = s.Save.Level(h.id);
var hs = s.Save.Hero(h.id);
string g = h.isSoldier || hs == null || string.IsNullOrEmpty(hs.grade) ? "white" : hs.grade;
string G = "grade_" + g + "_";
// 바탕(마스크) 안 = 그라데이션 · 빛 · 일러 · 아래 띠(카드 둥근 모양으로 잘림)
var bg = UiKit.Layer(t, "Bg", "card_bg", UiKit.Col(G + "card_bg", UiKit.Paper), 2f, 2f, 2f, 2f);
bg.gameObject.AddComponent<Mask>().showMaskGraphic = true;
var b = bg.transform;
UiKit.Layer(b, "Gradient", "card_grad", UiKit.Col(G + "card_grad", UiKit.White), 0f, 0f, 0f, 44.6f);
UiKit.Skin(UiKit.Rect("Glow", b, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(226f, 226f)), "card_glow", UiKit.Col(G + "card_glow", UiKit.White)).enabled = UISkin.Has("card_glow");
var sp = UiKit.ArtSprite(h.art);
if (sp != null)
{
// 머리 크기 통일(PD 2026-11-09 「캐릭터 크기가 동일하게 보이도록」) = 배율 UnitPx ÷ 크기 표 size_px(눈 간격 기준 · 말 탄 유닛은 탄 사람 기준) ·
// 머리 끝(발 + bodyHeightPx · 무기 제외)을 카드 위 HeadTop 에 맞춤 → 다리·말은 아래 띠 뒤로 잘림(레퍼런스 흉상 느낌) · 메타가 없으면 칸 맞춤
var a = UnitArt.Load(h.art);
var m = a != null ? a.Meta : null;
float body = a != null ? a.BodyPx : 0f;
float size = a != null && a.SizePx > 0.5f ? a.SizePx : body;
Image art;
if (body > 0.5f && m.pivot != null && m.pivot.Length > 1)
{
float hgt = sp.rect.height;
bool mounted = a.Mounted;
float k = UnitPx / size * (mounted ? MountedScale : 1f);
if (mounted) k = Mathf.Min(k, MountedMaxBody / body);
float pivotY, y;
if (a.EyeUpPx > 0.5f)
{
pivotY = Mathf.Clamp01((m.pivot[1] * hgt + a.EyeUpPx) / hgt); // 눈 줄 = 프레임 아래에서의 비율
y = mounted ? EyeLineMounted : EyeLine;
}
else
{
pivotY = Mathf.Clamp01((m.pivot[1] * hgt + body) / hgt); // 머리 끝 = 프레임 아래에서의 비율(눈 높이 없는 그림)
y = HeadTop + (mounted ? MountedDrop : FootDrop);
}
art = UiKit.Rect("Character", b, new Vector2(0.5f, 1f), new Vector2(0.5f, pivotY), new Vector2(0f, -y), sp.rect.size * k).gameObject.AddComponent<Image>();
}
else
{
art = UiKit.Rect("Character", b, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, -56f), new Vector2(376f, 368f)).gameObject.AddComponent<Image>();
art.preserveAspect = true;
}
art.sprite = sp;
art.raycastTarget = false;
if (!owned) art.color = UiKit.Col("card_locked_art", UiKit.InkSoft);
}
else
{
var dot = UiKit.Dot("Shape", b, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, -10f), 200f, UiKit.TypeColor(h.type));
UiKit.Title(UiKit.FillLabel(dot.transform, UnitTypeInfo.Mark(h.type), 100, UiKit.White), true);
}
var bottom = UiKit.Rect("Bottom", b, new Vector2(0.5f, 0f), new Vector2(0.5f, 0.5f), new Vector2(0f, 21.7f), new Vector2(0f, 91.5f));
bottom.anchorMin = new Vector2(0f, 0f);
bottom.anchorMax = new Vector2(1f, 0f);
bottom.sizeDelta = new Vector2(52.6f, 91.5f);
UiKit.Skin(bottom, "card_bottom", UiKit.Col("card_bottom", UiKit.Dark)).enabled = UISkin.Has("card_bottom");
UiKit.Layer(t, "Border", "card_border", UiKit.Col("card_border", UiKit.Line));
// ★(오른쪽 아래 · 42×45 · 28 간격 겹침)
int star = hs != null ? hs.star : h.rarity;
for (int i = star - 1; i >= 0; i--)
UiKit.Icon(t, "star_on", new Vector2(1f, 0f), new Vector2(1f, 1f), new Vector2(-15.5f - i * 28f, 64.8f), 44f);
// 레벨 배지(왼쪽 아래 · 최고 레벨 = 노랑 · 미보유 = 없음)
int lvn = lv != null ? lv.lv : 1;
bool maxLv = s.Db.Levels != null && lvn >= s.Db.Levels.MaxLevel;
var badge = owned ? UiKit.Icon(t, maxLv ? "lv_badge_max" : "lv_badge_s", new Vector2(0f, 0f), new Vector2(0.5f, 0.5f), new Vector2(33.9f, 36.7f), 79f) : null;
if (badge != null)
{
var bt = UiKit.Title(UiKit.Label(badge.transform, lvn.ToString(), 30, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(1f, -3f), new Vector2(74f, 50f)), true);
bt.horizontalOverflow = HorizontalWrapMode.Overflow;
}
// PD 커스터마이징(2026-11-09): 왼쪽 위 = 진영 깃발 · 그 오른쪽 = 이름(45 · 긴 이름 자동 축소) · 깃발 아래(왼쪽 가장자리) = 출전 · 오른쪽 위 = 병종 원(#35374E 58×60)
// 진영 = 원 배지(위·촉·오·군 · PD 2026-11-09 · 구 세력 깃발 대체)
UiKit.DropShadow(UiKit.FactionBadge(t, h.faction, new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), new Vector2(44f, -42f), 62f), 3f);
var name = UiKit.Title(UiKit.Label(t, h.nameKo, 45, UiKit.White, TextAnchor.MiddleLeft, new Vector2(0f, 1f), new Vector2(0f, 0.5f), new Vector2(80f, -40.5f), new Vector2(172f, 66f)), true);
name.resizeTextForBestFit = true;
name.resizeTextMinSize = 26;
name.resizeTextMaxSize = 45;
if (deployed || !owned)
{
var tail = UiKit.Skin(UiKit.Rect(owned ? "Deployed" : "Locked", t, new Vector2(0f, 1f), new Vector2(0f, 0.5f), new Vector2(10f, -96f), new Vector2(owned ? 84f : 104f, 38f)), "tail",
owned ? UiKit.Col("tail_red", UiKit.Red) : UiKit.Col("tail_locked", UiKit.Dark));
UiKit.Title(UiKit.FillLabel(tail.transform, owned ? "출전" : "미보유", 24, UiKit.White), true);
}
// 병과 = 단색 심볼만(배경 틀 없음 · PD 2026-11-09 「심볼은 배경 프레임 없이 단색」)
var role = UiKit.Rect("Role", t, new Vector2(1f, 1f), new Vector2(0.5f, 0.5f), new Vector2(-42f, -42f), new Vector2(60f, 60f));
var sym = UiKit.Icon(role, UiKit.TypeSymbolId(h.type), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, 60f);
if (sym == null) UiKit.Title(UiKit.FillLabel(role, UnitTypeInfo.Mark(h.type), 28, UiKit.White), true);
if (owned && s.CanGrowAny(h.id)) UiKit.Icon(t, "new_dot", new Vector2(0f, 0f), new Vector2(0.5f, 0.5f), new Vector2(64f, 70f), 30f); // 성장 가능 = 레벨 배지 오른쪽 위
return cell;
}
/// <summary>원래 크기(320×431) 카드를 holder 가운데에 배율 k 로 넣는다(편성 자리 · 편성 보유 목록 · 영웅 목록 미보유 = owned false).</summary>
public static Image CardIn(Transform holder, OutGameSession s, HeroDb.Hero h, bool deployed, System.Action onTap, float k, bool owned = true)
{
return Fit(Card(holder, s, h, deployed, onTap, owned), k);
}
/// <summary>미보유·도감 안내 한 줄(이름(한자) · 병종 · ★ · 보유/미보유).</summary>
public static string InfoText(HeroDb.Hero h, bool own)
{
return h.nameKo + (string.IsNullOrEmpty(h.hanja) ? "" : "(" + h.hanja + ")") + " · " + UnitTypeInfo.Korean(h.type) + " · ★" + h.rarity +
(h.isSoldier ? " · 병사 유닛" : "") + (own ? " · 보유" : " · 미보유 — 거래 > 뽑기");
}
public static Image EmptyIn(Transform holder, float k)
{
return Fit(Empty(holder), k);
}
static Image Fit(Image card, float k)
{
var rt = card.rectTransform;
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f);
rt.pivot = new Vector2(0.5f, 0.5f);
rt.sizeDelta = CellSize;
rt.anchoredPosition = Vector2.zero;
rt.localScale = new Vector3(k, k, 1f);
return card;
}
/// <summary>빈 카드(미보유 자리) = 어두운 바탕 + 문양 + 테두리 + 오른쪽 아래 얼굴 실루엣(팩 CardFrame01_Empty).</summary>
public static Image Empty(Transform content)
{
var cell = UiKit.Cell(content, "Empty", Color.clear, null);
cell.sprite = null;
var t = cell.transform;
// 팩 CardFrame01_Empty 그대로: Bg(#303146 · 마스크) 안에 Bg_d_Pattern 타일(#2D2F42) → 테두리 → 얼굴
var bg = UiKit.Layer(t, "Bg", "card_bg", UiKit.Col("card_empty", UiKit.Slot), 2f, 2f, 2f, 2f);
bg.gameObject.AddComponent<Mask>().showMaskGraphic = true;
var pat = UiKit.Layer(bg.transform, "Pattern", "card_empty_pattern", UiKit.Col("card_empty_pattern", UiKit.Slot));
pat.type = Image.Type.Tiled;
pat.pixelsPerUnitMultiplier = 1f;
UiKit.Layer(t, "Border", "card_border", UiKit.Col("card_empty_border", UiKit.Line));
var face = UiKit.Icon(t, "face", new Vector2(1f, 0f), new Vector2(0.5f, 0.5f), new Vector2(-87f, 90.8f), 127f);
if (face != null) face.color = UiKit.Col("card_empty_face", UiKit.Dark);
return cell;
}
}
/// <summary>
/// 영웅 탭 = 영웅 목록(PD 2026-11-09 레퍼런스 = 팩 Collection_List) — 헤더(제목 · 장수/병사 고르기 ▼ · 사선 칸 「보유 / 전체」) ·
/// 세로 카드(보유 = 레벨 높은 순 → 탭 = 영웅 상세) + 미보유 = 그 영웅 카드(조금 어둡게 · 「미보유」 · 탭 = 획득 안내 · PD 2026-11-10).
/// </summary>
public class HeroListScreen : OutGameScreen
{
bool soldiers;
RectTransform content;
ScrollRect scroll;
Text filterText;
Text countText;
int ownedCards;
int lockedCards;
UiAutoGrid autoGrid;
public bool ShowingSoldiers
{
get { return soldiers; }
}
public override void Build()
{
Tab = 0;
// 바탕 = 진남(레퍼런스 실측) + 재화 바 뒤 띠
UiKit.Paint(UiKit.Fill("ListBg", Rt), UiKit.Col("list_bg", UiKit.Bg), false);
UiKit.Paint(UiKit.TopBand("ListTop", Rt, 0f, OutGameRoot.TopBarH), UiKit.Col("band_top", UiKit.Bg), false);
var head = Header("영웅", "", true);
// 오른쪽 사선 칸(보유 / 전체) — 팩 PanelFrame03_Oblique #D8E8FF 239 폭
var ob = UiKit.Rect("Oblique", head, new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), Vector2.zero, new Vector2(239f, OutGameRoot.HeaderH - 7f));
UiKit.Skin(ob, "oblique", UiKit.Col("oblique", UiKit.White));
countText = UiKit.Title(UiKit.Label(ob, "", 52, UiKit.Col("count_text", UiKit.Dark), TextAnchor.MiddleCenter, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(12f, 0f), new Vector2(220f, 80f)));
countText.horizontalOverflow = HorizontalWrapMode.Overflow;
// 장수 / 병사 고르기(팩 「Property ▼」 자리 · 탭 = 전환)
var pick = UiKit.Rect("Filter", head, new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), new Vector2(-252f, 0f), new Vector2(240f, 90f));
var hit = pick.gameObject.AddComponent<Image>();
hit.color = Color.clear;
pick.gameObject.AddComponent<Button>().onClick.AddListener(() => SetFilter(!soldiers));
filterText = UiKit.Title(UiKit.Label(pick, "", 38, UiKit.InkSoft, TextAnchor.MiddleRight, new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), new Vector2(-52f, 2f), new Vector2(180f, 70f)));
var arrow = UiKit.Icon(pick, "arrow_down", new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), new Vector2(-6f, 0f), 38f);
UiKit.DropShadow(arrow, 3f);
// 본문 = 화면 폭 전체(해상도 대응 · PD 2026-11-09) · 열 수·카드 배율 = UiAutoGrid(기준 1080 = 4열 · 좁으면 3 · 넓으면 5 이상)
var body = UiKit.Fill("ListBody", Rt, 0f, 0f, OutGameRoot.TopBarH + OutGameRoot.HeaderH, OutGameRoot.TabBarH);
content = UiKit.ScrollGrid("List", body, 24f, 24f, 20f, 0f, CollectionCards.CellSize, CollectionCards.Spacing, 4, out scroll);
autoGrid = content.gameObject.AddComponent<UiAutoGrid>();
autoGrid.design = CollectionCards.CellSize;
}
/// <summary>열 수(해상도 대응 · 테스트·캡처).</summary>
public int Columns
{
get { return autoGrid != null ? autoGrid.Columns : 0; }
}
/// <summary>격자 칸(holder) — 원래 크기 카드를 UiAutoGrid 배율로 넣는다.</summary>
Transform Slot()
{
var go = new GameObject("Slot", typeof(RectTransform));
go.transform.SetParent(content, false);
return go.transform;
}
public void SetFilter(bool soldier)
{
soldiers = soldier;
Refresh();
}
public override void Refresh()
{
filterText.text = soldiers ? "병사" : "장수";
UiKit.Clear(content);
var owned = new List<HeroDb.Hero>(S.Owned(soldiers));
// 보유 = 레벨 높은 순 → ★ 높은 순
owned.Sort((a, b) =>
{
int la = S.Save.Level(a.id) != null ? S.Save.Level(a.id).lv : 1, lb = S.Save.Level(b.id) != null ? S.Save.Level(b.id).lv : 1;
if (la != lb) return lb.CompareTo(la);
var ha = S.Save.Hero(a.id);
var hb = S.Save.Hero(b.id);
return (hb != null ? hb.star : b.rarity).CompareTo(ha != null ? ha.star : a.rarity);
});
ownedCards = 0;
foreach (var h in owned)
{
var id = h.id;
CollectionCards.CardIn(Slot(), S, h, S.InActivePreset(id), () => Root.ShowHero(id), autoGrid.Scale);
ownedCards++;
}
// 미보유 = 빈 카드 대신 그 영웅 카드(일러 조금 어둡게 · 「미보유」 · 탭 = 획득 안내) · ★ 높은 순 → 표 순서
// PD 2026-11-10 「왜 유닛 목록에 장수들의 모습이 보이지 않는거지? 이번에 추가된 리소스가 반영되도록」
// (상세 화면은 레벨·세이브가 있는 보유 영웅 전제라 미보유는 열지 않는다)
var locked = new List<HeroDb.Hero>();
var all = S.Db.Heroes.heroes;
foreach (var h in all)
if (h.isSoldier == soldiers && !S.Save.Owns(h.id)) locked.Add(h);
locked.Sort((a, b) => a.rarity != b.rarity ? b.rarity.CompareTo(a.rarity) : all.IndexOf(a).CompareTo(all.IndexOf(b)));
lockedCards = 0;
foreach (var h in locked)
{
var hero = h;
CollectionCards.CardIn(Slot(), S, h, false, () => Root.Toast(CollectionCards.InfoText(hero, false)), autoGrid.Scale, false);
lockedCards++;
}
countText.text = owned.Count + "/" + (owned.Count + locked.Count);
autoGrid.Apply();
}
/// <summary>보유 카드 수(미보유 제외).</summary>
public int CardCount
{
get { return ownedCards; }
}
/// <summary>미보유 영웅 카드 수(그림 카드 · PD 2026-11-10).</summary>
public int LockedCount
{
get { return lockedCards; }
}
}
/// <summary>
/// 영웅 상세(UI 상업용 재설계 ① · PD 2026-11-09 확정 목업 hero_detail.html = 좌표·부품·색 SOT · 본문 y = 목업 y − 224) —
/// 등급 색 바탕(글로우 + 문양 · 화면 전체) · 전투력 칩 · 일러(좌우 밀기 = 영웅 전환) + 양옆 장비 6칸 · 레벨 행(레벨업 · 승급) · 이름 띠(세력 · 병종 · 등급 · ★) ·
/// 스킬 패널(액티브 · 패시브 · 행마다 레벨업) · 메뉴 4버튼(정보 = 상세정보 + 도감 · 진화 = 조각 팝업 · 각성 · 외형 = 준비 중). 색·스프라이트 = 테마 표(ui_theme.csv) id.
/// </summary>
public class HeroDetailScreen : OutGameScreen
{
const float SwipeMin = 80f; // 일러 밀기 = 가로 80 px 이상(기준 해상도 px)
static readonly string[] MenuNames = { "정보", "진화", "각성", "외형" };
static readonly string[] MenuIcons = { "info", "star_l", "fire", "top" };
static readonly Vector2[] MenuIconSize = { new Vector2(26f, 60f), new Vector2(58f, 62f), new Vector2(64f, 50f), new Vector2(62f, 46f) };
string id;
readonly List<string> order = new List<string>();
Image bgFill;
Image bgGlow;
Text ownedText;
Text power;
RectTransform art;
float dragX;
readonly GameObject[] slotRoot = new GameObject[6];
readonly Image[] slotGlow = new Image[6];
readonly Image[] slotBg = new Image[6];
readonly Image[] slotDeco = new Image[6];
readonly Image[] slotLight = new Image[6];
readonly Image[] slotIcon = new Image[6];
readonly Text[] slotName = new Text[6];
readonly Image[] slotCheck = new Image[6];
readonly Image[] slotBag = new Image[6];
Text lvBadge;
Text lvText;
Text expText;
RectTransform expFill;
Image levelImg;
Text levelLabel;
Text levelCost;
Image goldIcon;
Image promoteImg;
Text promoteSub;
Image bandImg;
RectTransform factionRoot;
Image clsIcon;
Image paraImg;
Text gradeText;
Text nameText;
Text typeText;
RectTransform starRow;
Image panelImg;
GameObject coolChip;
Text coolText;
readonly Image[] rowImg = new Image[2];
readonly Image[] skFrame = new Image[2];
readonly Image[] skCls = new Image[2];
readonly Text[] skName = new Text[2];
readonly Text[] skHanja = new Text[2];
readonly Text[] skDesc = new Text[2];
readonly Text[] skLv = new Text[2];
readonly Image[] skBtn = new Image[2];
readonly Text[] skBtnText = new Text[2];
readonly Text[] skBtnSub = new Text[2];
readonly GameObject[] skCost = new GameObject[2];
readonly Text[] skBooks = new Text[2];
readonly Text[] skGold = new Text[2];
Image evolveDot;
public string HeroId
{
get { return id; }
}
public void Select(string heroId)
{
id = heroId;
var h = S.Def(heroId);
order.Clear();
if (h != null)
foreach (var o in S.Owned(h.isSoldier)) order.Add(o.id);
}
// ---------- 목업 좌표 도우미(부모 왼쪽 위 기준 · y 아래로) ----------
static Color Col(string colorId)
{
return UISkin.Col(colorId, Color.white);
}
static RectTransform At(Transform p, string name, float x, float y, float w, float h)
{
return UiKit.Rect(name, p, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(x, -y), new Vector2(w, h));
}
static Image Spr(Transform p, string name, string skin, float x, float y, float w, float h, string color, bool raycast = false)
{
return UiKit.Skin(At(p, name, x, y, w, h), skin, Col(color), raycast);
}
Image Press(Transform p, string name, string skin, float x, float y, float w, float h, string color, System.Action onClick)
{
var img = Spr(p, name, skin, x, y, w, h, color, true);
img.gameObject.AddComponent<Button>().onClick.AddListener(() => onClick());
return img;
}
static Image Ico(Transform p, string icon, float x, float y, float w, float h, float shadow = 0f)
{
var img = At(p, "Icon", x, y, w, h).gameObject.AddComponent<Image>();
img.preserveAspect = true;
img.raycastTarget = false;
SetIcon(img, icon, 1f);
if (shadow > 0f) Drop(img, shadow);
return img;
}
/// <summary>테마 아이콘(팩 임시 = 표 tint · 신규 제작 = 원색) · alpha = 곱.</summary>
static void SetIcon(Image img, string icon, float alpha)
{
var e = UISkin.Theme != null && icon != null ? UISkin.Theme.IconEntryOf(icon) : null;
img.sprite = e != null ? e.Sprite : null;
var c = e != null && e.samguk == null ? e.tint : Color.white;
c.a *= alpha;
img.color = c;
img.enabled = img.sprite != null;
}
static void Drop(Graphic g, float d)
{
var s = g.gameObject.AddComponent<Shadow>();
s.effectColor = Col("icon_shadow");
s.effectDistance = new Vector2(0f, -d);
}
/// <summary>목업 text() — y = 글자 윗변 · title = Sen(+ 한글 폴백) · false = 맑은 고딕 · stroke &lt; 0 = 기본(크기 × 0.06) · 0 = 없음 · 그 밖 = 목업 px(절반).</summary>
static Text Txt(Transform p, string s, float x, float y, int size, string color, TextAnchor align = TextAnchor.MiddleLeft, float w = 0f, bool title = true, float stroke = -1f, bool shadow = true)
{
float bw = w > 0f ? w : 900f;
float px = align == TextAnchor.MiddleCenter ? 0.5f : align == TextAnchor.MiddleRight ? 1f : 0f;
var rt = UiKit.Rect("Text", p, new Vector2(0f, 1f), new Vector2(px, 0.5f), new Vector2(x + bw * px, -(y + size * 0.5f)), new Vector2(bw, size * 1.6f));
var t = rt.gameObject.AddComponent<Text>();
t.font = title ? UISkin.TitleFont : TextUtil.Font;
t.fontSize = size;
t.fontStyle = FontStyle.Bold;
t.alignment = align;
t.color = Col(color);
t.horizontalOverflow = HorizontalWrapMode.Overflow;
t.verticalOverflow = VerticalWrapMode.Overflow;
t.supportRichText = true;
t.raycastTarget = false;
t.text = s;
if (stroke != 0f) UISkin.Stroke(t, Col("stroke"), stroke < 0f ? Mathf.Max(2f, size * 0.06f) : stroke * 0.5f);
if (shadow)
{
var sh = t.gameObject.AddComponent<Shadow>();
sh.effectColor = Col("text_shadow");
sh.effectDistance = new Vector2(0f, -Mathf.Round(size * 0.07f));
}
return t;
}
static Text Desc(Transform p, float x, float y, float w)
{
var t = Txt(p, "", x, y, 26, "text_desc", TextAnchor.UpperLeft, w, false, 0f, false);
var rt = t.rectTransform;
rt.pivot = new Vector2(0f, 1f);
rt.anchoredPosition = new Vector2(x, -y);
rt.sizeDelta = new Vector2(w, 70f);
t.horizontalOverflow = HorizontalWrapMode.Wrap;
t.lineSpacing = 1.05f;
return t;
}
static void PlaceX(Graphic g, float x)
{
var rt = g.rectTransform;
rt.anchoredPosition = new Vector2(x, rt.anchoredPosition.y);
}
// ---------- 화면 ----------
public override void Build()
{
Tab = 0;
// ① 바탕(화면 전체 · 재화 바·헤더 뒤까지) = 등급 색 + 세로 글로우 + 문양 타일
bgFill = UiKit.Fill("GradeBg", Rt).gameObject.AddComponent<Image>();
bgFill.raycastTarget = false;
bgGlow = UiKit.Skin(UiKit.Fill("GradeGlow", Rt), "bg_glow", Color.white);
bgGlow.enabled = UISkin.Has("bg_glow");
var pat = UiKit.Skin(UiKit.Fill("GradePattern", Rt), "bg_pattern", Col("grade_pattern"));
pat.type = Image.Type.Tiled;
pat.enabled = UISkin.Has("bg_pattern");
var head = Header("영웅", "", true);
var own = UiKit.Rect("Owned", head, new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), new Vector2(-24f, 0f), new Vector2(244f, 62f));
UiKit.Skin(own, "chip", Col("chip_dark"));
ownedText = Txt(own, "", 0f, 15f, 32, "white", TextAnchor.MiddleCenter, 244f);
// 본문 = 목업 1080 폭 틀(화면이 낮아 본문이 줄면서 가로로 늘어나도 가운데 고정)
var body = UiKit.Rect("Mock", Body(), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), Vector2.zero, new Vector2(OutGameRoot.Reference.x, OutGameRoot.BodyH));
// ② 일러 = 빛 원 + 발밑 그림자 + 그림(좌우 밀기 = 이전/다음 영웅)
Spr(body, "ArtGlow", "glow_circle", 210f, 14f, 660f, 660f, "hero_glow");
Spr(body, "ArtShadow", "oval_shadow", 310f, 550f, 460f, 64f, "hero_shadow");
art = At(body, "Art", 260f, 94f, 560f, 500f);
art.gameObject.AddComponent<Image>().color = Color.clear;
var drag = art.gameObject.AddComponent<TapDrag>();
drag.OnDragStart = e => dragX = 0f;
drag.OnDragMove = e => dragX += e.delta.x;
drag.OnDragEnd = e => Swipe(dragX / ScaleOf(art));
// 전투력 칩(두 장비 열 사이 위)
var pw = Spr(body, "Power", "chip", 380f, 24f, 320f, 70f, "chip_dark").transform;
Spr(pw, "Line", "chip_line", 6f, 6f, 308f, 58f, "chip_line");
Ico(pw, "sword", 18f, 4f, 62f, 62f);
Txt(pw, "전투력", 88f, 23f, 28, "text_soft", TextAnchor.MiddleLeft, 0f, true, 0f, false);
power = Txt(pw, "", 180f, 11f, 50, "white");
// ◀ ▶ = 장비 칸 바깥(장비 열 세로 가운데)
Arrow(body, "Prev", "arrow_prev", 10f, -1);
Arrow(body, "Next", "arrow_next", 1026f, 1);
// 장비 6칸(182×178 · ItemFrame 5층 · 장비 등급 색) — 탭 = 장착 · 필요 장비
for (int i = 0; i < 6; i++)
{
int k = i;
var s = At(body, "Slot" + i, i < 3 ? 66f : 832f, 24f + (i % 3) * 206f, 182f, 178f);
slotRoot[i] = s.gameObject;
slotGlow[i] = Spr(s, "Glow", "item_glow", -8f, -8f, 198f, 194f, "white");
slotBg[i] = Spr(s, "Bg", "item_bg", 0f, 0f, 182f, 178f, "white", true);
slotDeco[i] = Spr(s, "Deco", "item_deco", 4f, 4f, 174f, 170f, "white");
slotLight[i] = Spr(s, "Light", "item_light", 8f, 6f, 166f, 82f, "white");
Spr(s, "Border", "item_border", 0f, 0f, 182f, 178f, "gear_border");
slotIcon[i] = Ico(s, null, 41f, 17f, 100f, 100f, 5f);
Spr(s, "Plate", "plate12", 10f, 130f, 162f, 38f, "gear_plate");
slotName[i] = Txt(s, "", 10f, 135f, 26, "white", TextAnchor.MiddleCenter, 162f, false, 0f, false);
slotCheck[i] = Ico(s, "check", 138f, -12f, 54f, 52f, 3f);
slotBag[i] = Ico(s, "up_green", 142f, -14f, 48f, 56f, 3f);
s.gameObject.AddComponent<Button>().onClick.AddListener(() => OnSlot(k));
}
// ③ 레벨 행 = 배지 + 레벨 바 · 레벨업(녹 Primary + 골드) · 승급(하늘)
Ico(body, "lv_badge", 30f, 650f, 92f, 96f, 3f);
lvBadge = Txt(body, "", 30f, 674f, 46, "white", TextAnchor.MiddleCenter, 92f);
lvText = Txt(body, "", 132f, 648f, 34, "white");
expText = Txt(body, "", 400f, 654f, 28, "text_desc", TextAnchor.MiddleRight, 190f, false, 3f);
Spr(body, "LvBar", "lv_bg", 116f, 694f, 478f, 54f, "white");
expFill = (RectTransform)Spr(body, "LvFill", "lv_fill", 126f, 700f, 458f, 38f, "lv_fill").transform;
levelImg = Press(body, "LevelUp", "btn_l_green", 612f, 640f, 208f, 112f, "white", OnLevelUp);
levelLabel = Txt(levelImg.transform, "레벨업", 0f, 16f, 38, "text_on_green", TextAnchor.MiddleCenter, 208f, false, 0f, false);
goldIcon = Ico(levelImg.transform, "cur_gold", 48f, 56f, 40f, 40f);
levelCost = Txt(levelImg.transform, "", 92f, 60f, 32, "text_on_green", TextAnchor.MiddleLeft, 0f, true, 0f, false);
promoteImg = Press(body, "Promote", "btn_sky", 834f, 640f, 222f, 112f, "white", OnPromote);
Txt(promoteImg.transform, "승급", 0f, 14f, 38, "white", TextAnchor.MiddleCenter, 222f, false);
promoteSub = Txt(promoteImg.transform, "", 0f, 60f, 28, "white", TextAnchor.MiddleCenter, 222f, false, 3f);
// ④ 이름 띠 = 어두운 띠 + 세력 깃발 · 병종 원 · 등급 칩 · 이름 · 병종 · ★
bandImg = Spr(body, "NameBand", "card", 24f, 776f, 1032f, 114f, "white");
Spr(body, "BandLight", "band_light", 40f, 782f, 1000f, 12f, "band_light");
factionRoot = UiKit.At(body, "Faction", 44f, 789f, 88f, 88f); // 진영 원 배지 자리(PD 2026-11-09 · 구 세력 깃발)
clsIcon = Ico(body, null, 140f, 794f, 78f, 78f); // 병과 = 평면 심볼만(배경 틀 · 그림자 없음 · PD 2026-11-09)
paraImg = Spr(body, "Grade", "para_green", 232f, 805f, 96f, 56f, "white");
gradeText = Txt(body, "", 232f, 811f, 38, "white", TextAnchor.MiddleCenter, 96f);
nameText = Txt(body, "", 344f, 797f, 66, "white");
typeText = Txt(body, "", 500f, 819f, 30, "text_type", TextAnchor.MiddleLeft, 0f, false, 0f, false);
starRow = At(body, "Stars", 742f, 803f, 290f, 60f);
// ⑤ 스킬 패널 = 제목(액티브 + 쿨 칩 · 맨 오른쪽 Lv 칩 / 패시브 + Lv 칩) · 행 = 틀 + 병종 글리프 · 이름 · 한자 · 설명 · 레벨업
panelImg = Spr(body, "SkillPanel", "panel12", 24f, 920f, 1032f, 444f, "white");
for (int i = 0; i < 2; i++)
{
int k = i;
float ty = 934f + i * 214f, y = ty + 54f;
Txt(body, i == 0 ? "액티브" : "패시브", 52f, ty + 4f, 36, "white");
if (i == 0)
{
var cc = Spr(body, "Cool", "chip", 184f, ty, 112f, 46f, "chip_cool");
coolChip = cc.gameObject;
coolText = Txt(cc.transform, "", 0f, 9f, 26, "white", TextAnchor.MiddleCenter, 112f, false, 3f);
}
var lc = Spr(body, "SkillLv" + i, "chip", 912f, ty, 112f, 46f, "chip_dark");
skLv[i] = Txt(lc.transform, "", 0f, 9f, 26, "white", TextAnchor.MiddleCenter, 112f, true, 3f);
rowImg[i] = Spr(body, "SkillRow" + i, "panel12", 44f, y, 992f, 146f, "white");
skFrame[i] = Ico(body, null, 58f, y + 10f, 126f, 126f, 4f);
skCls[i] = Ico(body, null, 80f, y + 32f, 82f, 82f);
skName[i] = Txt(body, "", 200f, y + 18f, 42, "white");
skHanja[i] = Txt(body, "", 200f, y + 30f, 28, "white", TextAnchor.MiddleLeft, 0f, false, 0f, false);
skDesc[i] = Desc(body, 200f, y + 74f, 600f);
skBtn[i] = Press(body, "SkillUp" + i, "btn_purple", 818f, y + 18f, 204f, 110f, "white", () => OnSkillRow(k));
var bt = skBtn[i].transform;
skBtnText[i] = Txt(bt, "", 0f, 12f, 34, "white", TextAnchor.MiddleCenter, 204f, false);
skBtnSub[i] = Txt(bt, "", 0f, 58f, 24, "text_max_sub", TextAnchor.MiddleCenter, 204f, false, 3f);
var cost = At(bt, "Cost", 0f, 0f, 204f, 110f);
skCost[i] = cost.gameObject;
Ico(cost, "cur_skillbook", 22f, 56f, 38f, 38f);
skBooks[i] = Txt(cost, "", 62f, 60f, 28, "white", TextAnchor.MiddleLeft, 0f, true, 3f);
Ico(cost, "cur_gold", 98f, 59f, 32f, 32f);
skGold[i] = Txt(cost, "", 132f, 63f, 22, "text_book", TextAnchor.MiddleLeft, 0f, true, 3f);
}
// ⑥ 메뉴 4버튼 = 정보 · 진화 · 각성 · 외형(진화 가능 = 알림 점)
for (int i = 0; i < 4; i++)
{
int k = i;
var b = Press(body, "Menu" + i, "round08", 34f + i * 258f, 1390f, 236f, 108f, "menu_btn", () => OnMenu(k));
Spr(b.transform, "Light", "band_light", 14f, 8f, 208f, 12f, "menu_light");
var sz = MenuIconSize[i];
Ico(b.transform, MenuIcons[i], 30f + (64f - sz.x) / 2f, 54f - sz.y / 2f, sz.x, sz.y, 3f);
Txt(b.transform, MenuNames[i], 104f, 34f, 40, "white", TextAnchor.MiddleLeft, 0f, false);
}
evolveDot = Ico(body, "new_dot", 498f, 1380f, 36f, 36f);
}
void Arrow(Transform body, string name, string icon, float x, int d)
{
var hit = At(body, name, x - 18f, 249f, 80f, 140f);
hit.gameObject.AddComponent<Image>().color = Color.clear;
hit.gameObject.AddComponent<Button>().onClick.AddListener(() => Step(d));
Ico(hit, icon, 18f, 27f, 44f, 86f);
}
static float ScaleOf(Transform t)
{
var cv = t.GetComponentInParent<Canvas>();
return cv != null && cv.scaleFactor > 0f ? cv.scaleFactor : 1f;
}
/// <summary>일러 밀기(기준 px · 왼쪽 = 다음 · 오른쪽 = 이전 · 80 px 미만 = 무시).</summary>
public void Swipe(float dx)
{
if (Mathf.Abs(dx) >= SwipeMin) Step(dx < 0f ? 1 : -1);
}
void Step(int d)
{
if (order.Count == 0) return;
int i = Mathf.Max(0, order.IndexOf(id));
id = order[(i + d + order.Count) % order.Count];
Refresh();
}
public override void Refresh()
{
var h = S.Def(id);
if (h == null) return;
var db = S.Db;
var lv = S.Save.Level(id);
var hs = S.Save.Hero(id);
var st = S.StatsOf(id);
string g = h.isSoldier || hs == null || string.IsNullOrEmpty(hs.grade) ? "white" : hs.grade;
string G = "grade_" + g + "_";
bgFill.color = Col(G + "bg");
bgGlow.color = Col(G + "glow");
bandImg.color = Col(G + "band");
panelImg.color = Col(G + "stat");
for (int i = 0; i < 2; i++) rowImg[i].color = Col(G + "row");
if (UISkin.Apply(paraImg, "para_" + g)) paraImg.color = Col(G + "chip");
paraImg.gameObject.SetActive(!h.isSoldier);
gradeText.gameObject.SetActive(!h.isSoldier);
gradeText.text = UiKit.GradeKo(g);
int total = 0;
foreach (var o in db.Heroes.heroes)
if (o.isSoldier == h.isSoldier) total++;
ownedText.text = "보유 " + S.Owned(h.isSoldier).Count + " / " + total;
power.text = st.power.ToString("N0");
UiKit.Clear(art);
UiKit.Portrait("Hero", art, h, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(560f, 500f), false, 1f, Color.clear);
// 장비: 장착 = 체크 · 가방 보유 = 초록 화살표 · 없음 = 흐림
for (int i = 0; i < 6; i++)
{
string need = h.isSoldier ? null : HeroRules.RequiredGear(db, h, hs, i);
slotRoot[i].SetActive(need != null);
if (need == null) continue;
var gd = db.Gear.Get(need);
bool on = hs != null && i < hs.gear.Count && !string.IsNullOrEmpty(hs.gear[i]);
bool bag = !on && S.Save.inv.GearCount(need) > 0;
bool lit = on || bag;
string gg = "gear_" + (gd != null && !string.IsNullOrEmpty(gd.grade) ? gd.grade : "white") + "_";
slotGlow[i].color = lit ? Col(gg + "glow") : Color.clear;
slotBg[i].color = Col(gg + "bg");
slotDeco[i].color = Col(gg + "deco");
slotLight[i].color = Col(gg + "light");
SetIcon(slotIcon[i], gd != null ? "gear_" + gd.kind : null, lit ? 1f : Col("gear_off_icon").a);
slotIcon[i].GetComponent<Shadow>().enabled = lit;
slotName[i].text = gd != null ? gd.nameKo : need;
slotName[i].color = Col(lit ? "white" : "text_off");
slotCheck[i].gameObject.SetActive(on && slotCheck[i].sprite != null);
slotBag[i].gameObject.SetActive(bag && slotBag[i].sprite != null);
}
// 레벨 · 승급 — 구조적으로 불가(최고 · 병사) = 회색 · 조건 부족 = 원래 색 + 누르면 사유 토스트
int lvCap = HeroRules.LevelCap(db, S.Save);
var row = db.Levels.Get(lv.lv);
bool maxLv = lv.lv >= db.Levels.MaxLevel || row == null;
float pct = row != null && row.expToNext > 0 ? Mathf.Clamp01(lv.exp / (float)row.expToNext) : 1f;
lvBadge.text = lv.lv.ToString();
lvText.text = "Lv " + lv.lv + " / " + lvCap;
expText.text = maxLv ? "최고 레벨" : "경험치 " + (pct * 100f).ToString("0.##") + "%";
expFill.sizeDelta = new Vector2(458f * pct, expFill.sizeDelta.y);
expFill.gameObject.SetActive(pct > 0.01f);
levelImg.color = Col(maxLv ? "disabled" : "white");
levelLabel.color = levelCost.color = Col(maxLv ? "disabled_text" : "text_on_green");
levelCost.text = maxLv ? "최고" : row.goldCost.ToString("N0");
goldIcon.gameObject.SetActive(!maxLv);
var gearList = h.isSoldier || hs == null ? null : db.Heroes.GearFor(h, hs.grade);
promoteSub.text = h.isSoldier ? "병사 없음" : gearList == null ? "최고 등급" : "장비 " + HeroRules.EquippedCount(hs) + "/" + gearList.Count;
promoteImg.color = Col(gearList == null ? "disabled" : "white");
evolveDot.gameObject.SetActive(!h.isSoldier && hs != null && evolveDot.sprite != null && HeroRules.CanEvolve(db, h, hs) == null);
// 이름 띠
UiKit.Clear(factionRoot);
UiKit.DropShadow(UiKit.FactionBadge(factionRoot, h.faction, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, 84f), 3f);
SetIcon(clsIcon, UiKit.TypeSymbolId(h.type), 1f);
nameText.text = h.nameKo;
typeText.text = UnitTypeInfo.Korean(h.type);
PlaceX(typeText, 344f + nameText.preferredWidth + 24f);
UiKit.Clear(starRow);
int max = h.isSoldier ? 3 : db.Evolve.MaxStar;
int n = h.isSoldier ? h.rarity : hs != null ? hs.star : 1;
for (int i = 0; i < max; i++) Ico(starRow, i < n ? "star_l" : "star_off", i * 58f, 0f, 58f, 60f, i < n ? 3f : 0f);
// 스킬 — 상한(등급·★3) 또는 최고 = 회색 「최대」 · 그 밖 = 보라 레벨업(스킬서 · 골드)
string tk = UiKit.TypeKey(h.type);
for (int i = 0; i < 2; i++)
{
var sk = SkillRules.Skill(db, h, i);
bool has = sk != null && !h.isSoldier && hs != null;
SetIcon(skFrame[i], tk != null ? "skill_" + tk + (i == 0 ? "_a" : "_p") : null, has ? 1f : 0.5f);
SetIcon(skCls[i], UiKit.TypeSymbolId(h.type), has ? 1f : 0.5f);
skLv[i].transform.parent.gameObject.SetActive(has);
skBtn[i].gameObject.SetActive(has);
if (i == 0) coolChip.SetActive(has);
if (!has)
{
skName[i].text = h.isSoldier ? "스킬 없음" : "—";
skHanja[i].text = "";
skDesc[i].text = h.isSoldier ? "병사 유닛은 스킬이 없습니다" : "";
continue;
}
int slv = SkillRules.Level(hs, i), cap = SkillRules.Cap(db, h, hs, i);
skName[i].text = sk.nameKo;
skHanja[i].text = sk.hanja ?? "";
skHanja[i].color = Col(G + "label");
PlaceX(skHanja[i], 200f + skName[i].preferredWidth + 8f);
skDesc[i].text = SkillInfoDb.DisplayDesc(sk);
skLv[i].text = "Lv " + slv + "/" + cap;
if (i == 0) coolText.text = "쿨 " + sk.cooldown + "턴";
bool top = slv >= cap;
UISkin.Apply(skBtn[i], top ? "btn_gray" : "btn_purple");
skBtn[i].color = Color.white;
skBtnText[i].text = top ? "최대" : "레벨업";
skBtnText[i].color = Col(top ? "text_max" : "white");
skBtnSub[i].gameObject.SetActive(top);
skBtnSub[i].text = slv >= sk.lvMax ? "최고 레벨" : "등급 상한";
skCost[i].SetActive(!top);
var cost = db.Skills.CostTo(slv + 1);
skBooks[i].text = cost != null ? cost.books.ToString() : "-";
skGold[i].text = cost != null ? cost.gold.ToString("N0") : "-";
}
}
/// <summary>메뉴 4버튼: 0 정보(상세정보 + 도감) · 1 진화(조각 팝업) · 2 각성 · 3 외형(준비 중).</summary>
public void OnMenu(int i)
{
switch (i)
{
case 0: ShowStats(); break;
case 1: ShowEvolve(); break;
case 2: Root.ComingSoon("각성"); break;
case 3: Root.ComingSoon("외형"); break;
}
}
/// <summary>스킬 행 레벨업 = 그 스킬만(스킬서 + 골드 · 불가 = 사유 토스트).</summary>
public void OnSkillRow(int i)
{
var h = S.Def(id);
var hs = S.Save.Hero(id);
if (h == null || hs == null || h.isSoldier)
{
Root.Toast("병사 유닛은 스킬이 없습니다");
return;
}
var sk = SkillRules.Skill(S.Db, h, i);
if (sk == null) return;
var r = SkillRules.LevelUp(S.Db, S.Save, h, hs, i);
if (r == null) S.Commit();
Refresh();
Root.Toast(r ?? sk.nameKo + " Lv " + SkillRules.Level(hs, i) + "!");
}
/// <summary>진화 팝업 = ★ 줄 + 영웅 조각 바 + 다음 ★ · 진화 버튼(기존 진화 규칙).</summary>
public void ShowEvolve()
{
var h = S.Def(id);
var hs = S.Save.Hero(id);
if (h == null || hs == null || h.isSoldier)
{
Root.Toast("병사 유닛 — 진화 없음 (중복 = 골드)");
return;
}
var db = S.Db;
int need = HeroRules.ShardsForNext(db, hs);
bool top = hs.star >= db.Evolve.MaxStar;
var why = HeroRules.CanEvolve(db, h, hs);
var c = Root.ModalCustom("진화 — " + h.nameKo, 600f,
new OutGameRoot.ModalButton(top ? "최고 ★" : "진화", why == null ? UiKit.Red : UiKit.Disabled, OnEvolve, why != null),
new OutGameRoot.ModalButton("닫기", UiKit.Dark, null));
UiKit.StarRow(c, hs.star, db.Evolve.MaxStar, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -4f), 64f);
UiKit.IconOrDot(c, "cur_shard", "조", UiKit.Purple, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(20f, -90f), 60f);
UiKit.Label(c, "영웅 조각", 34, UiKit.Ink, TextAnchor.MiddleLeft, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(92f, -90f), new Vector2(400f, 60f));
var bar = UiKit.Bar("ShardBar", c, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -164f), new Vector2(820f, 46f), UiKit.Slot, UiKit.Purple);
UiKit.SetBar(bar, need > 0 ? Mathf.Clamp01(hs.shards / (float)need) : 1f);
UiKit.Title(UiKit.Label(bar.parent, need > 0 ? hs.shards + " / " + need : hs.shards + " · 최고 ★", 30, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(800f, 46f)), true);
UiKit.Label(c, top ? "최고 ★ 달성" : "다음 ★" + (hs.star + 1) + " · 필요 조각 " + need + (why != null ? "\n" + why : ""), 28, UiKit.InkSoft, TextAnchor.UpperCenter,
new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -226f), new Vector2(840f, 84f), false);
}
void OnSlot(int i)
{
var h = S.Def(id);
var hs = S.Save.Hero(id);
if (h == null || hs == null) return;
var need = HeroRules.RequiredGear(S.Db, h, hs, i);
var g = S.Db.Gear.Get(need);
if (need == null) return;
if (!string.IsNullOrEmpty(hs.gear[i]))
{
Root.Toast("장착됨: " + (g != null ? g.nameKo : need) + " — 6칸을 모두 채우면 승급");
return;
}
var why = S.Equip(id, i);
if (why == null)
{
Root.Toast((g != null ? g.nameKo : need) + " 장착 · 1칸 = 1강(누적 " + hs.gearTotal + ")");
return;
}
string from = g != null ? g.dropFromChapter + "장~ 스테이지 드랍" : "";
Root.Modal("필요 장비", (g != null ? g.nameKo + " (" + UiKit.GradeKo(g.grade) + " 등급)" : need) + "\n보유 0\n\n획득: " + from + " · 거래 > 상점(흰/녹)" +
(g != null && g.craftCount > 0 ? "\n합성 = 하위 " + g.craftCount + "개 + 도면 " + g.craftBlueprint + " (2차)" : ""),
new OutGameRoot.ModalButton("상점 가기", UiKit.Red, () => Root.Trade.OpenShop()),
new OutGameRoot.ModalButton("닫기", UiKit.Dark, null));
}
void OnLevelUp()
{
var why = S.LevelUp(id);
Root.Toast(why ?? S.Def(id).nameKo + " Lv " + S.Save.Level(id).lv + "!");
}
void OnPromote()
{
var h = S.Def(id);
var why = S.Promote(id);
Root.Toast(why ?? h.nameKo + " 승급 → " + UiKit.GradeKo(S.Save.Hero(id).grade) + " 등급 · 새 장비 6칸");
}
/// <summary>스킬 레벨업(PD #1093 B): 액티브·패시브 중 고르기 → 스킬서 + 골드(skill_levels.csv) · 상한 = 등급 + ★3 액티브 +1.</summary>
public void OnSkillUp()
{
var h = S.Def(id);
var hs = S.Save.Hero(id);
if (h == null || hs == null || h.isSoldier)
{
Root.Toast("병사 유닛은 스킬이 없습니다");
return;
}
var db = S.Db;
var buttons = new List<OutGameRoot.ModalButton>();
var body = new System.Text.StringBuilder("보유 스킬서 " + S.Save.inv.books.skill + " · 골드 " + S.Save.gold.ToString("N0") + "\n");
for (int i = 0; i < 2; i++)
{
int k = i;
var sk = SkillRules.Skill(db, h, i);
if (sk == null) continue;
int lv = SkillRules.Level(hs, i);
var cost = db.Skills.CostTo(lv + 1);
var why = SkillRules.CanLevelUp(db, S.Save, h, hs, i);
body.Append("\n").Append(i == 0 ? "액티브 " : "패시브 ").Append(sk.nameKo).Append(" Lv ").Append(lv).Append(" / ").Append(SkillRules.Cap(db, h, hs, i));
if (cost != null && lv < sk.lvMax) body.Append(" → 스킬서 ").Append(cost.books).Append(" · 골드 ").Append(cost.gold.ToString("N0"));
if (why != null) body.Append("\n <color=#a03a22>").Append(why).Append("</color>");
buttons.Add(new OutGameRoot.ModalButton((i == 0 ? "액티브" : "패시브") + " Lv+1", why == null ? UiKit.Red : UiKit.Disabled, () =>
{
var r = SkillRules.LevelUp(S.Db, S.Save, h, hs, k);
if (r == null) S.Commit();
Refresh();
if (r == null) OnSkillUp(); // 이어서 올릴 수 있게 다시 연다
Root.Toast(r ?? sk.nameKo + " Lv " + SkillRules.Level(hs, k) + "!");
}));
}
buttons.Add(new OutGameRoot.ModalButton("닫기", UiKit.Dark, null));
Root.Modal("스킬 레벨업 — " + h.nameKo, body.ToString(), buttons.ToArray());
}
void OnEvolve()
{
var why = S.Evolve(id);
Root.Toast(why ?? S.Def(id).nameKo + " 진화 → ★" + S.Save.Hero(id).star);
}
/// <summary>정보 = 상세정보(스탯 내역) + 도감 버튼.</summary>
public void ShowStats()
{
var h = S.Def(id);
var st = S.StatsOf(id);
var e = S.Db.Economy;
var hs = S.Save.Hero(id);
string body =
"기본(" + UnitTypeInfo.Korean(h.type) + ") HP " + st.baseHp + " · 공 " + st.baseAtk + " · 방 " + st.baseDef + "\n" +
"영웅 배율 ×" + st.hpMult + " · ×" + st.atkMult + " · ×" + st.defMult + "\n" +
"레벨 Lv " + S.Save.Level(id).lv + " +" + st.lvPct.ToString("0.#") + "%\n" +
"승급 " + (hs != null ? UiKit.GradeKo(hs.grade) : "—") + " +" + st.promoPct.ToString("0.#") + "% · 장비 누적 " + (hs != null ? hs.gearTotal : 0) + "개 +" + st.gearPct.ToString("0.#") + "%\n" +
"진화 " + (hs != null ? "★" + hs.star : "—") + " +" + st.starPct.ToString("0.#") + "%\n" +
"훈련(" + TrainingRules.RankName(S.Db, TrainingRules.Rank(S.Save, h.type)) + ") HP +" + st.trainHpPct.ToString("0.#") + "% · 공 +" + st.trainPct.ToString("0.#") + "% · 방 +" + st.trainDefPct.ToString("0.#") + "%\n" +
"보물(수집품·세트) HP +" + st.treasureHpPct.ToString("0.#") + "% · 공 +" + st.treasureAtkPct.ToString("0.#") + "% · 방 +" + st.treasureDefPct.ToString("0.#") + "%\n\n" +
"<size=40>HP " + st.Hp + " · 공 " + st.Atk + " · 방 " + st.Def + "</size>\n" +
"전투력 " + st.power.ToString("N0") + " = HP/" + e.PowerHpDiv + " + 공×" + e.PowerAtkW + " + 방×" + e.PowerDefW;
Root.Modal("상세정보 — " + h.nameKo, body,
new OutGameRoot.ModalButton("도감", UiKit.Dark, () => ShowCodex(Root)),
new OutGameRoot.ModalButton("닫기", UiKit.Dark, null));
}
/// <summary>도감 = 전 영웅·병사(미보유 회색).</summary>
public static void ShowCodex(OutGameRoot root)
{
var s = root.Session;
int owned = 0;
foreach (var h in s.Db.Heroes.heroes)
if (s.Save.Owns(h.id)) owned++;
var content = root.ModalCustom("도감 " + owned + " / " + s.Db.Heroes.heroes.Count, 1560f, new OutGameRoot.ModalButton("닫기", UiKit.Dark, null));
ScrollRect sr;
var grid = UiKit.ScrollGrid("Codex", content, 0f, 0f, 0f, 0f, new Vector2(420f, 140f), new Vector2(20f, 14f), 2, out sr);
foreach (var h in s.Db.Heroes.heroes)
{
bool own = s.Save.Owns(h.id);
var hero = h;
HeroCards.Card(grid, s, h, false, own, () => root.Toast(CollectionCards.InfoText(hero, own)));
}
}
}
}