ThreeKingdomsSlingshot/Assets/Scripts/OutGame/Treasure/TreasureScreen.cs

1204 lines
76 KiB
C#
Raw 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>
/// 보물(하단 탭 · 기획 S8 · 목업 img07·16·17 · 카피바라 수집품 구조 · PD #1093 C) — 상단 탭 3:
/// ① 보유 중 = 그리드 4열 · 등급 색 · ★ · 미보유 회색 · 탭 = 상세(기본 효과 · 조건 효과 n/max · 강화 ★)
/// ② 세트 = 세트마다 구성 · 조각 게이지 · [세트]/[3성]/[6성]/[10성] 효과 ③ 상자 = 상자 포인트 게이지 → 명장 상자 · 선택 상자 + 확률 · 보유 수 · 열기(상자는 이 탭에서만 연다).
/// </summary>
public class TreasureScreen : OutGameScreen
{
public static readonly string[] PageNames = { "보유 중", "세트", "상자" };
int page;
int chestIdx;
bool chestPicked;
string gradeFilter = ""; // 보유 중 등급 거르기("" = 전체 · PD 2026-11-13 「등급 칩 줄 → 드롭다운 · 전체 ~ 등급별」)
RectTransform filterList;
readonly List<Button> tabs = new List<Button>();
readonly List<RectTransform> pages = new List<RectTransform>();
/// <summary>배경 장면(PD 2026-11-13 · vault = 보물고 · chest = 금고) — 처음 볼 때 만들고 숨김/보임만.</summary>
class Scene
{
public List<LobbyScene.Row> rows;
public LobbyAmbient amb;
public RectTransform art;
public float artW;
public float artH;
}
RectTransform backdrop;
readonly Dictionary<string, Scene> scenes = new Dictionary<string, Scene>();
Image openChestIcon;
/// <summary>지금 보이는 배경 장면 이름(그림이 없으면 null · 테스트).</summary>
public string ShownScene { get; private set; }
/// <summary>보유 중·세트 칸을 진열대(칸 틀 + 선반)로 그렸는가(테스트).</summary>
public bool ShelfCells { get; private set; }
/// <summary>이 페이지에 그린 목패 이름표 수(테스트 · 리본 폐기 3차).</summary>
public int NameTags { get; private set; }
/// <summary>세트 제목을 명패로 그린 수(테스트 · 3차).</summary>
public int Plaques { get; private set; }
/// <summary>상자 탭을 금고 받침대 배치로 그렸는가(테스트).</summary>
public bool ChestOnPedestal { get; private set; }
/// <summary>받침대 위 상자 아이콘(테스트).</summary>
public Image PedestalChest
{
get { return openChestIcon; }
}
public int Page
{
get { return page; }
}
public int GridCount { get; private set; }
/// <summary>보유 중 등급 거르기(""= 전체 · 테스트).</summary>
public string GradeFilter
{
get { return gradeFilter; }
}
/// <summary>등급 드롭다운 목록이 펼쳐져 있는가(테스트).</summary>
public bool FilterOpen
{
get { return filterList != null; }
}
public int SetCount { get; private set; }
public ChestResult LastOpen { get; private set; }
public string SelectedChest
{
get
{
var l = S.Db.Chests.chests;
return l.Count > 0 ? l[Mathf.Clamp(chestIdx, 0, l.Count - 1)].id : "";
}
}
public override void Build()
{
Tab = 1;
if (UISkin.On) UiKit.Paint(UiKit.Fill("ListBg", Rt), UiKit.Col("list_bg", UiKit.Bg), false); // 목록형 바탕(Fable §5-3)
Header("보물", "", true); // 부제 삭제(설계서 §2-5)
var body = Body();
backdrop = UiKit.Fill("Backdrop", body); // 배경 장면(보물고 · 금고 · 탭·페이지 뒤 · PD 2026-11-13)
for (int i = 0; i < PageNames.Length; i++)
{
int k = i;
tabs.Add(UISkin.On
? UiKit.SubTab(body, "Tab" + i, PageNames[i], 24f + i * 348f, 18f, 332f, 84f, () => SetPage(k))
: UiKit.Btn("Tab" + i, body, PageNames[i], 36, UiKit.Paper, UiKit.Ink, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(24f + i * 348f, -18f), new Vector2(332f, 84f), () => SetPage(k)));
pages.Add(UiKit.Fill("Page" + i, body, 0f, 0f, 118f, 0f));
}
}
/// <summary>새 상자 화면 부품(Fable §4-5)이 테마에 있는가.</summary>
static bool Kit
{
get { return UISkin.Has("card3_bg") && UISkin.Has("banner_point") && UISkin.Has("btn_l_yellow"); }
}
/// <summary>보물 아이콘 id — 납품 그림(tre_&lt;id&gt;)이 있으면 그것 · 없으면 기본 스탯별 팩 임시 아이콘(tre_stat_* · Fable §6 N3 전까지).</summary>
static string IconId(TreasureDb.Item it)
{
if (UiKit.HasFinalIcon("tre_" + it.id)) return "tre_" + it.id;
return UISkin.Icon("tre_stat_" + it.baseStat) != null ? "tre_stat_" + it.baseStat : null;
}
public void SetPage(int p)
{
page = Mathf.Clamp(p, 0, pages.Count - 1);
Refresh();
}
public void SelectChest(string id)
{
int i = S.Db.Chests.IndexOf(id);
if (i >= 0) chestIdx = i;
chestPicked = true;
Refresh();
}
public override void Refresh()
{
for (int i = 0; i < tabs.Count; i++)
{
bool on = i == page;
if (UISkin.On) UiKit.SetSubTab(tabs[i], on);
else
{
tabs[i].GetComponent<Image>().color = on ? UiKit.Red : UiKit.Paper; // 선택 = 붉은 옻칠(다른 화면 탭과 같음)
UiKit.LabelOf(tabs[i]).color = on ? UiKit.White : UiKit.Ink;
}
pages[i].gameObject.SetActive(on);
}
var scene = ShowScene(TreasureScene.PageScene[Mathf.Clamp(page, 0, TreasureScene.PageScene.Length - 1)]);
UiKit.Clear(pages[page]);
filterList = null; // 펼친 목록은 페이지와 함께 지워짐
openChestIcon = null;
ShelfCells = false;
NameTags = 0;
Plaques = 0;
ChestOnPedestal = false;
if (page == 0) BuildOwned(pages[0]);
else if (page == 1) BuildSets(pages[1]);
else if (scene != null && Kit) BuildChestsVault(pages[2], scene);
else if (Kit) BuildChestsKit(pages[2]);
else BuildChests(pages[2]);
}
/// <summary>그 장면을 (처음이면 만들고) 보이고 나머지는 숨김 · 그림이 없으면 null(예전 바탕).</summary>
Scene ShowScene(string name)
{
Scene s;
if (!scenes.TryGetValue(name, out s))
{
s = new Scene { rows = TreasureScene.Scene(name) };
s.amb = DomesticScene.Backdrop(backdrop, "Scene_" + name, s.rows, out s.art, out s.artW, out s.artH);
scenes[name] = s;
}
foreach (var kv in scenes)
if (kv.Value.art != null)
kv.Value.art.parent.gameObject.SetActive(kv.Key == name);
ShownScene = s.art != null ? name : null;
return s.art != null ? s : null;
}
/// <summary>보물고 진열장 배경이 보일 때 그 장면 표 줄(아니면 null = 예전 배치).</summary>
LobbyScene.Row VaultRow(string kind)
{
Scene s;
return ShownScene == "vault" && scenes.TryGetValue("vault", out s) ? DomesticScene.First(s.rows, kind) : null;
}
/// <summary>장면 그림 px → 페이지 좌표(본문 = 1080 폭 설계 단위 · 페이지 = 본문 위 118 아래 · view f_y 줄이 본문 맨 위 · f_scale 배).</summary>
static Vector2 ArtToPage(Scene s, float x, float y)
{
var view = DomesticScene.First(s.rows, "view");
float vy = view != null ? view.y : 0f;
float z = view != null && view.scale > 0.01f ? view.scale : 1f;
float k = OutGameRoot.Reference.x * z / Mathf.Max(1f, s.artW); // 그림 px → 본문 px
return new Vector2(OutGameRoot.Reference.x * 0.5f + (x - s.artW * 0.5f) * k, (y - vy) * k - 118f);
}
/// <summary>보물 등급 대표 색(테마 tre_&lt;등급&gt; · 희귀 청 · 영웅 자 · 전설 주 · 신화 적 · 명장 금).</summary>
public static Color GradeColor(string g)
{
return UiKit.Col("tre_" + g, UiKit.Slot);
}
/// <summary>
/// 보물 칸(UI 상업용 재설계 ⑥ · 설계서 §3⑥) = 팩 ItemFrame 5층 등급색 + 아이콘(납품 tre_* · 없으면 이름 첫 글자) + ★ 또는 조각 바 + 아래 이름 판 ·
/// 미보유 = 진청 빈 칸 + 흰 25 % 실루엣.
/// </summary>
/// <summary>보물 칸 아래 이름 띠 높이(칸 아래 끝에서 · 아이콘 자리는 이 위 색 칸).</summary>
const float NameBandH = 62f;
/// <summary>조각 막대·별 줄 윗변(칸 아래 끝에서 · 막대 y 76 + 높이 26 = 별 줄 y 72 + 30).</summary>
const float ShardsTop = 102f;
void TreasureCell(Transform parent, TreasureDb.Item it, bool showShards)
{
var st = S.Save.inv.Treasure(it.id);
bool owned = st != null;
var cell = UiKit.Cell(parent, "T_" + it.id, Color.clear, owned ? (System.Action)(() => Detail(it.id)) : null);
cell.sprite = null; // 바닥 = 클릭 판(투명)
var t = cell.transform;
// 보물고 진열장 배경일 때 = 선반 진열(PD 2026-11-13 2차 「칸 틀이 튐 → 레퍼런스 게임 유물 목록처럼 가독성 · 내정처럼 공간 인지」) —
// 틀 없는 큰 아이콘이 이어진 선반 판 위에 서고, 선반 앞에 등급 색 목패 이름표(PD 2026-11-13 3차 · 리본 폐기)
Scene vs;
var shelfRow = ShownScene == "vault" && scenes.TryGetValue("vault", out vs) ? DomesticScene.First(vs.rows, "shelf") : null;
var tagRow = shelfRow != null ? DomesticScene.First(scenes["vault"].rows, "tag") : null;
var shelfSp = shelfRow != null ? UISkin.Icon(shelfRow.sprite) : null;
var tagSp = tagRow != null ? UISkin.Icon(tagRow.sprite) : null;
if (shelfSp != null && tagSp != null)
{
ShelfCells = true;
ShelfCell(t, it, owned, showShards, shelfRow, tagRow, shelfSp, tagSp);
return;
}
UiKit.ItemSlot(t, owned ? "tre_" + it.grade + "_" : "tre_none_", owned);
// 아이콘 자리 = 이름 띠 위 색 칸 전체 → 아이콘은 그 정가운데(PD 2026-11-11 「정중앙 · 20 % 키움」 · 전에는 위에서 100 px 고정 = 14 px 위로 뜸)
// 세트 화면(조각 막대 표시)은 막대 위부터 — 커진 아이콘 아래를 막대가 가리지 않게
var art = UiKit.Rect("Art", t, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero);
art.anchorMin = Vector2.zero;
art.anchorMax = Vector2.one;
art.offsetMin = new Vector2(0f, showShards ? ShardsTop : NameBandH);
art.offsetMax = Vector2.zero;
string icon = IconId(it);
if (icon != null)
{
// 아이콘 = 납품 그림 또는 기본 스탯별 임시(Fable §4-5 「글자 → 보물 아이콘」) · 미보유 = 흰 25 % 실루엣 · 크기 = 예전 140/120 × 1.2
var ic = UiKit.Icon(art, icon, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, icon.StartsWith("tre_stat_") ? 144f : 168f);
if (owned) UiKit.DropShadow(ic, 5f);
else ic.color = UiKit.Col("tre_none_icon", UiKit.InkSoft);
}
else if (owned) UiKit.Title(UiKit.FillLabel(art, it.nameKo.Substring(0, 1), 84, UiKit.White), true);
CellFooter(t, it, st, owned, showShards);
}
/// <summary>
/// 선반 진열 칸(PD 2026-11-13 2차 · 레퍼런스 게임 유물 목록 + 보물고 진열장) — 표 vault_shelf · vault_tag · vault_icon · vault_grid/setgrid:
/// 선반 판(칸 간격 절반씩 옆으로 = 한 줄이 이어진 선반) → 틀 없는 큰 아이콘(판 윗면에 섬 · 미보유 = 어둡게) → 선반 앞 등급 색 목패 이름표(미보유 = 어두운 목패 · 3차) → 별·조각 막대(판 앞면).
/// </summary>
void ShelfCell(Transform t, TreasureDb.Item it, bool owned, bool showShards, LobbyScene.Row shelfRow, LobbyScene.Row tagRow, Sprite shelfSp, Sprite tagSp)
{
var st = S.Save.inv.Treasure(it.id);
var rows = scenes["vault"].rows;
var grid = DomesticScene.First(rows, showShards ? "setgrid" : "grid");
var iconRow = DomesticScene.First(rows, "icon");
float gapX = grid != null && grid.gapMin > 0f ? grid.gapMin : 16f;
float cellW = grid != null && grid.w > 1f ? grid.w : 200f;
float shelfY = shelfRow.y > 0f ? shelfRow.y : 58f;
float shelfH = shelfRow.h > 0f ? shelfRow.h : 30f;
ShelfBoard(t, shelfRow, shelfSp, gapX);
string icon = IconId(it);
float size = iconRow != null ? (showShards ? iconRow.h : iconRow.w) : 160f;
if (size < 1f) size = 160f;
float stand = shelfY + shelfH * 0.6f; // 아이콘 아래끝 = 판 윗면
if (icon != null)
{
var ic = UiKit.Icon(t, icon, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, stand), icon.StartsWith("tre_stat_") ? size * 0.86f : size);
if (owned) UiKit.DropShadow(ic, 5f);
else
{
// 미보유 = 밝은 회색 틴트(표 vault_none · 어두운 판에 묻히지 않게 · QA r3 M1)
var none = DomesticScene.First(rows, "none");
ic.color = none != null ? none.color : UiKit.Col("tre_none_icon", UiKit.InkSoft);
}
}
else if (owned)
{
var art = UiKit.Rect("Art", t, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, stand), new Vector2(size, size));
UiKit.Title(UiKit.FillLabel(art, it.nameKo.Substring(0, 1), 84, UiKit.White), true);
}
// 미보유도 유물 이름(흐린 글자 · 레퍼런스 목록처럼 무엇인지 보임 · QA r3 M7) — 미보유 = 어두운 목패 + 흐린 아이콘으로 구분 · 폭 = 글자에 맞춤(칸 폭 안)
NameTag(t, it.nameKo, owned ? GradeColor(it.grade) : UiKit.Col("tre_none_tag", new Color(0.36f, 0.33f, 0.36f, 1f)),
owned ? UiKit.White : UiKit.Col("text_soft", UiKit.InkSoft), tagRow, tagSp, cellW, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, tagRow.y));
NameTags++;
if (owned)
{
// 진행 수치 = 최고 ★ 전이면 늘 조각 막대(레퍼런스 목록 「5/40」 · QA r3 U1) · 최고 ★ = 별 줄
if (st.star < it.starMax)
{
int need, iron;
TreasureRules.EnhanceCost(S.Db, it, st.star + 1, out need, out iron);
var fill = UiKit.Bar("Shards", t, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, shelfY + 2f), new Vector2(Mathf.Min(150f, size), 24f), UiKit.Slot, GradeColor(it.grade));
UiKit.SetBar(fill, st.shards / (float)Mathf.Max(1, need));
UiKit.Title(UiKit.FillLabel(fill.parent, st.shards + "/" + need, 18, UiKit.White), true);
}
else if (st.star > 0) UiKit.StarRow(t, st.star, st.star, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, shelfY + 2f), 26f);
}
}
/// <summary>칸 아래 선반 판 한 토막(표 vault_shelf · 칸 간격 절반 + 2 px 씩 옆으로 = 옆 칸 판과 겹쳐 한 줄로 이어짐 · QA r3 M3).</summary>
static void ShelfBoard(Transform t, LobbyScene.Row shelfRow, Sprite shelfSp, float gapX)
{
float shelfY = shelfRow.y > 0f ? shelfRow.y : 58f;
float shelfH = shelfRow.h > 0f ? shelfRow.h : 30f;
var shelf = UiKit.Rect("Shelf", t, Vector2.zero, Vector2.zero, Vector2.zero, Vector2.zero);
shelf.anchorMin = Vector2.zero;
shelf.anchorMax = new Vector2(1f, 0f);
shelf.offsetMin = new Vector2(-gapX * 0.5f - 2f, shelfY);
shelf.offsetMax = new Vector2(gapX * 0.5f + 2f, shelfY + shelfH);
var si = shelf.gameObject.AddComponent<Image>();
si.sprite = shelfSp;
si.raycastTarget = false;
if (shelfSp.border.sqrMagnitude > 0f) si.type = Image.Type.Sliced;
}
/// <summary>
/// 마지막 줄 빈 칸 = 선반 판만(QA r4 M3 「거른 목록 마지막 줄 판이 한 칸만 깔리고 끝이 잘려 떠 보임」) — 줄 끝까지 판이 이어지게. 반환 = 채운 칸 수.
/// </summary>
int FillShelfRow(Transform grid, int count, int cols)
{
Scene vs;
if (!ShelfCells || cols < 2 || count % cols == 0 || !scenes.TryGetValue("vault", out vs)) return 0;
var shelfRow = DomesticScene.First(vs.rows, "shelf");
var g = DomesticScene.First(vs.rows, "grid");
var shelfSp = shelfRow != null ? UISkin.Icon(shelfRow.sprite) : null;
if (shelfSp == null) return 0;
int n = cols - count % cols;
for (int i = 0; i < n; i++)
{
var cell = UiKit.Cell(grid, "ShelfOnly" + i, Color.clear, null);
cell.sprite = null;
cell.raycastTarget = false;
ShelfBoard(cell.transform, shelfRow, shelfSp, g != null && g.gapMin > 0f ? g.gapMin : 16f);
}
return n;
}
/// <summary>
/// 등급 색 목패 이름표(PD 2026-11-13 3차 「리본 폐기 — 불필요한 면적이 많고 글자가 길면 UI 침범 · 우리 게임 컨셉·화풍으로」) — 표 &lt;장면&gt;_tag:
/// s_sprite = 목패(밝은 회색 면 = 등급 색으로 물들임 · 좌우 끝만 고정 9 분할) · f_h = 높이 · f_scale = 글자 · f_min = 가장 작은 글자 · f_x = 글자 좌우 여백 · f_w = 가장 좁은 폭.
/// 폭 = 글자 폭 + 여백(maxW 까지) · maxW 에서도 넘치면 글자를 줄여 목패 안에 맞춤(한 줄 · 밖으로 나가지 않음).
/// </summary>
public static Text NameTag(Transform parent, string label, Color tint, Color ink, LobbyScene.Row row, Sprite sp, float maxW, Vector2 anchor, Vector2 pivot, Vector2 pos)
{
float h = row.h > 1f ? row.h : 52f;
int fs = row.scale > 1f ? Mathf.RoundToInt(row.scale) : 24;
int fsMin = row.min > 1f ? Mathf.RoundToInt(row.min) : 16;
float pad = row.x > 0f ? row.x : 14f;
var tag = UiKit.Rect("Tag", parent, anchor, pivot, pos, new Vector2(maxW, h));
var img = tag.gameObject.AddComponent<Image>();
img.sprite = sp;
img.color = tint;
img.raycastTarget = false;
if (sp.border.sqrMagnitude > 0f) img.type = Image.Type.Sliced;
var t = UiKit.Title(UiKit.FillLabel(tag, label, fs, ink, TextAnchor.MiddleCenter, true, pad), true);
t.horizontalOverflow = HorizontalWrapMode.Overflow;
t.rectTransform.offsetMin = new Vector2(pad, h * 0.06f); // 목패 아래 테가 두꺼워 글자를 조금 올림
float need = t.preferredWidth;
if (need + pad * 2f > maxW && need > 1f)
t.fontSize = Mathf.Max(fsMin, Mathf.FloorToInt(fs * (maxW - pad * 2f) / need));
float minW = Mathf.Min(row.w > 1f ? row.w : 0f, maxW);
tag.sizeDelta = new Vector2(Mathf.Clamp(t.preferredWidth + pad * 2f, minW, maxW), h);
return t;
}
/// <summary>칸 아래 = 별 줄 또는 조각 막대 + 이름 띠(예전 칸 · 진열대 칸 공통).</summary>
void CellFooter(Transform t, TreasureDb.Item it, TreasureState st, bool owned, bool showShards)
{
if (owned)
{
if (showShards && st.star < it.starMax)
{
int need, iron;
TreasureRules.EnhanceCost(S.Db, it, st.star + 1, out need, out iron);
var fill = UiKit.Bar("Shards", t, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 76f), new Vector2(150f, 26f), UiKit.Slot, GradeColor(it.grade));
UiKit.SetBar(fill, st.shards / (float)Mathf.Max(1, need));
UiKit.FillLabel(fill.parent, st.shards + "/" + need, 18, UiKit.White);
}
else if (st.star > 0) UiKit.StarRow(t, st.star, st.star, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 72f), 30f);
}
// 이름 띠 = 칸 아래에 붙은 카드 띠(CardFrame01_Bottom · Fable §4-5) · 테마가 없으면 둥근 판
var band = UISkin.Has("card_bottom")
? UiKit.Layer(t, "Name", "card_bottom", UiKit.Col("card_bottom", UiKit.Dark), 6f, 0f, 6f, 6f)
: UiKit.Skin(UiKit.Rect("Name", t, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 12f), new Vector2(206f, 52f)), "plate12", UiKit.Col("gear_plate", UiKit.Dark));
if (UISkin.Has("card_bottom"))
{
band.rectTransform.anchorMax = new Vector2(1f, 0f);
band.rectTransform.offsetMax = new Vector2(-6f, NameBandH);
}
var nt = UiKit.FillLabel(band.transform, owned ? it.nameKo : "미보유", 26, owned ? UiKit.White : UiKit.Col("text_soft", UiKit.InkSoft));
nt.horizontalOverflow = HorizontalWrapMode.Overflow;
}
// ---------- ① 보유 중 ----------
void BuildOwned(RectTransform p)
{
var db = S.Db;
var grades = db.Economy.TreasureGrades;
if (gradeFilter.Length > 0 && db.Economy.TreasureGradeOrder(gradeFilter) == 0) gradeFilter = ""; // 표에서 사라진 등급 = 전체
// 보유 수 = 고른 등급 안에서(전체면 전부)
int owned = 0, total = 0;
foreach (var it in db.Treasures.items)
{
if (gradeFilter.Length > 0 && it.grade != gradeFilter) continue;
total++;
if (S.Save.inv.Treasure(it.id) != null) owned++;
}
var cnt = UiKit.Title(UiKit.Label(p, "보유 " + owned + " / " + total, 36, UiKit.Ink, TextAnchor.MiddleLeft, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(28f, -8f), new Vector2(360f, 56f)), UISkin.On);
cnt.horizontalOverflow = HorizontalWrapMode.Overflow;
// 등급 = 오른쪽 위 드롭다운 하나(PD 2026-11-13 「등급 칩 줄이 진열장 왕관 가운데를 가림 → 드롭다운 · 전체 ~ 등급별」 · 펼친 목록 = 등급 색 칩 = 범례)
GradeFilterButton(p);
ScrollRect sr;
// 보물고 진열장 배경 = 진열장 안쪽 판에 맞춘 그리드(표 vault_grid · 위 = 진열장 왕관이 보이게) · 아니면 예전 그리드
var g = VaultRow("grid");
var grid = g != null
? UiKit.ScrollGrid("Grid", p, g.x, g.x, g.y, 64f, new Vector2(g.w, g.h), new Vector2(g.gapMin, g.gapMax), Mathf.Max(1, g.count), out sr)
: UiKit.ScrollGrid("Grid", p, 24f, 24f, 76f, 64f, new Vector2(240f, 290f), new Vector2(16f, 16f), 4, out sr);
// 정렬 = 보유 먼저 · 등급 높은 순
var list = new List<TreasureDb.Item>(db.Treasures.items);
list.Sort((a, b) =>
{
bool oa = S.Save.inv.Treasure(a.id) != null, ob = S.Save.inv.Treasure(b.id) != null;
if (oa != ob) return oa ? -1 : 1;
return db.Economy.TreasureGradeOrder(b.grade).CompareTo(db.Economy.TreasureGradeOrder(a.grade));
});
GridCount = 0;
foreach (var it in list)
{
if (gradeFilter.Length > 0 && it.grade != gradeFilter) continue;
TreasureCell(grid, it, false);
GridCount++;
}
if (g != null) FillShelfRow(grid, GridCount, g.count); // 마지막 줄 빈 칸에도 선반 판(QA r4 M3)
var bonus = new List<string>();
foreach (var s in TreasureDb.BaseStats)
{
float v = TreasureRules.StatPct(db, S.Save, s);
if (v > 0f) bonus.Add(TreasureRules.StatKo(s) + " +" + v.ToString("0.#") + "%");
}
string fx = bonus.Count > 0 ? "전체 효과 " + string.Join(" · ", bonus.ToArray()) : "보물을 모으면 전 유닛에 상시 효과";
if (UISkin.Has("chip"))
{
// 아래 효과 줄 = 정보 칩(§5-6)
var c =UiKit.Skin(UiKit.Rect("Effects", p, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 4f), new Vector2(1032f, 54f)), "chip", UiKit.Col("chip_dark", UiKit.Dark));
var ft = UiKit.FillLabel(c.transform, fx, 26, UiKit.White, TextAnchor.MiddleCenter, false, 20f);
ft.horizontalOverflow = HorizontalWrapMode.Overflow;
}
else UiKit.Label(p, fx, 26, UiKit.InkSoft, TextAnchor.MiddleCenter, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 8f), new Vector2(1030f, 50f), false);
}
// ---------- 보유 중 등급 드롭다운(PD 2026-11-13) ----------
/// <summary>표 vault_filter — 오른쪽 여백 · 위 · 버튼 폭·높이 · 글자 · 목록 한 줄 높이 · 줄 간격 · 화살표 아이콘(진열장 배경이 아니면 기본값).</summary>
void FilterMetrics(out Vector2 at, out Vector2 size, out float rowH, out float gap, out int fs, out string arrow)
{
var r = VaultRow("filter");
at = new Vector2(r != null && r.x > 0f ? r.x : 24f, r != null ? r.y : 8f);
size = new Vector2(r != null && r.w > 1f ? r.w : 250f, r != null && r.h > 1f ? r.h : 56f);
rowH = r != null && r.min > 1f ? r.min : 60f;
gap = r != null && r.gapMin > 0f ? r.gapMin : 6f;
fs = r != null && r.scale > 1f ? Mathf.RoundToInt(r.scale) : 26;
arrow = r != null && r.sprite.Length > 0 ? r.sprite : "arrow_down";
}
/// <summary>오른쪽 위 등급 버튼(지금 고른 등급 · 전체 = 어두운 판 · 등급 = 그 등급 색) + ▼.</summary>
void GradeFilterButton(RectTransform p)
{
Vector2 at, size;
float rowH, gap;
int fs;
string arrow;
FilterMetrics(out at, out size, out rowH, out gap, out fs, out arrow);
bool all = gradeFilter.Length == 0;
var img = UiKit.Skin(UiKit.Rect("Filter", p, new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(-at.x, -at.y), size), "subtab", all ? UiKit.Col("subtab_off", UiKit.Dark) : GradeColor(gradeFilter), true);
img.gameObject.AddComponent<Button>().onClick.AddListener(ToggleGradeFilter);
float aw = size.y * 0.6f;
var t = UiKit.Title(UiKit.Label(img.transform, all ? "전체" : TreasureRules.GradeKo(gradeFilter), fs, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(12f, 2f), new Vector2(size.x - aw - 40f, size.y)), true);
t.horizontalOverflow = HorizontalWrapMode.Overflow;
var ic = UiKit.Icon(img.transform, arrow, new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), new Vector2(-16f, 2f), aw);
if (ic != null) ic.raycastTarget = false;
filterArrow = ic != null ? ic.rectTransform
: UiKit.Label(img.transform, "▼", fs, UiKit.White, TextAnchor.MiddleCenter, new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), new Vector2(-16f, 2f), new Vector2(aw, size.y)).rectTransform;
filterArrow.pivot = new Vector2(0.5f, 0.5f); // 펼치면 ▲(180° · QA r4 M4) — 가운데 기준으로 돌림
filterArrow.anchoredPosition = new Vector2(-16f - aw * 0.5f, 2f);
}
RectTransform filterArrow;
/// <summary>등급 드롭다운 펼침/접음(버튼 · 테스트).</summary>
public void ToggleGradeFilter()
{
if (filterList != null)
{
CloseGradeFilter();
return;
}
if (page != 0) return;
Vector2 at, size;
float rowH, gap;
int fs;
string arrow;
FilterMetrics(out at, out size, out rowH, out gap, out fs, out arrow);
// 펼친 목록 = 누를 때만 생기는 Event 층(P41) · 페이지 전체 투명 막 = 바깥 누르면 접힘
filterList = UiKit.Fill("FilterList", pages[0]);
UiLayer.Nest(filterList.gameObject, UiRate.Event, true);
UiKit.Paint(filterList, Color.clear, false, true);
filterList.gameObject.AddComponent<Button>().onClick.AddListener(CloseGradeFilter);
var grades = S.Db.Economy.TreasureGrades;
int n = grades.Count + 1;
float pad = gap + 4f;
var panel = UiKit.Skin(UiKit.Rect("Panel", filterList, new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(-at.x, -(at.y + size.y + gap)), new Vector2(size.x, pad * 2f + n * rowH + (n - 1) * gap)), "panel_field", UiKit.Col("panel_field", UiKit.Dark), true);
UiKit.DropShadow(panel, 6f); // 아래 목록과 떨어져 보이게(QA r4 M4)
if (filterArrow != null) filterArrow.localEulerAngles = new Vector3(0f, 0f, 180f);
for (int i = 0; i < n; i++)
{
// 줄 = 전체 + 등급별(등급 색 칩 = 범례 겸함) · 지금 고른 줄 = 오른쪽 ✔(테두리 효과는 틀 그림 외곽선이 겹쳐 검게 보임 · 3차 캡처 실측)
string key = i == 0 ? "" : grades[i - 1].Key;
var o = UiKit.Skin(UiKit.At(panel.transform, "O" + i, pad, pad + i * (rowH + gap), size.x - pad * 2f, rowH), "subtab", key.Length == 0 ? UiKit.Col("subtab_off", UiKit.Dark) : GradeColor(key), true);
o.gameObject.AddComponent<Button>().onClick.AddListener(() => SetGradeFilter(key));
UiKit.Title(UiKit.FillLabel(o.transform, key.Length == 0 ? "전체" : TreasureRules.GradeKo(key), fs, UiKit.White), true);
if (key == gradeFilter)
UiKit.Title(UiKit.Label(o.transform, "✔", fs, UiKit.White, TextAnchor.MiddleCenter, new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), new Vector2(-12f, 3f), new Vector2(rowH * 0.7f, rowH)), true);
}
}
/// <summary>등급 고름("" = 전체) → 목록 접고 보유 중 다시 그림(테스트).</summary>
public void SetGradeFilter(string grade)
{
gradeFilter = grade ?? "";
CloseGradeFilter();
if (page == 0) Refresh();
}
public void CloseGradeFilter()
{
if (filterList == null) return;
filterList.gameObject.SetActive(false);
UnityEngine.Object.Destroy(filterList.gameObject);
filterList = null;
if (filterArrow != null) filterArrow.localEulerAngles = Vector3.zero;
}
public void Detail(string id)
{
var db = S.Db;
var d = S.Save;
var it = db.Treasures.Get(id);
var st = d.inv.Treasure(id);
if (it == null || st == null) return;
// 선반 진열 무대 팝업(PD 2026-11-13 2차 「예시 이미지와 같이 선택된 유물의 정보 화면에 선반 컨셉을 강조」) — 무대 그림이 없으면 예전 글 팝업
var stageRow = DomesticScene.First(TreasureScene.Scene("detail"), "stage");
var stageSp = stageRow != null ? UISkin.Icon(stageRow.sprite) : null;
if (stageSp != null)
{
DetailStage(it, st, stageRow, stageSp);
return;
}
var sb = new System.Text.StringBuilder();
sb.Append(TreasureRules.GradeKo(it.grade)).Append(" · ★").Append(st.star).Append("/").Append(it.starMax);
var tiers = it.setId.Length > 0 ? db.TreasureSets.TiersOf(it.setId) : null;
if (tiers != null && tiers.Count > 0) sb.Append(" · 세트 「").Append(tiers[0].nameKo).Append("」");
sb.Append("\n\n<b>기본 효과</b> ").Append(TreasureRules.StatKo(it.baseStat)).Append(" +").Append(TreasureRules.BasePct(db, it, st.star).ToString("0.##")).Append("%");
int stacks = TreasureRules.CondStacks(d, it);
sb.Append("\n<b>조건 효과</b> ").Append(TreasureRules.CondKo(it.condKey)).Append(" ").Append(it.condEvery).Append("회마다 +").Append(it.condPct.ToString("0.##")).Append("% (")
.Append(stacks).Append("/").Append(it.condMax).Append(" · 누적 ").Append(TreasureRules.Counter(d, it.condKey)).Append(")");
sb.Append("\n<size=28>= 지금 ").Append(TreasureRules.StatKo(it.baseStat)).Append(" +").Append(TreasureRules.Value(db, d, it).ToString("0.##")).Append("%</size>");
string btn;
var why = TreasureRules.CanEnhance(db, d, id);
if (st.star < it.starMax)
{
int shards, iron;
TreasureRules.EnhanceCost(db, it, st.star + 1, out shards, out iron);
sb.Append("\n\n<b>강화 ★").Append(st.star + 1).Append("</b> 조각 ").Append(st.shards).Append("/").Append(shards).Append(" · 운철 ").Append(d.inv.iron).Append("/").Append(iron);
btn = why == null ? "강화" : why;
}
else btn = "최고 ★";
Root.Modal(it.nameKo, sb.ToString(),
new OutGameRoot.ModalButton(btn, why == null ? UiKit.Red : UiKit.Disabled, () =>
{
var w = S.Do(() => TreasureRules.Enhance(S.Db, S.Save, id));
if (w == null) Detail(id);
else Root.Toast(w);
}, true),
new OutGameRoot.ModalButton("닫기", UiKit.Dark, null));
}
/// <summary>정보 팝업이 무대 배치로 그려졌는가 · 무대 위 유물 아이콘(테스트).</summary>
public Image DetailRelic { get; private set; }
/// <summary>
/// 유물 정보 = 선반 진열 무대(PD 2026-11-13 2차 · 예시 = 제목 → 등급 표시 → 선반 위 받침에 놓인 유물 → 보유 속성 표(★ 현재 → 다음) → 효과 상자) —
/// 표 detail_stage(무대 그림 · 방석 윗면 · 아이콘 크기 · 등급 목패 최대 폭) · detail_tag(3차 · 리본 폐기). 버튼 = 강화(또는 사유·최고 ★) · 닫기(+ 오른쪽 위 X · PD 「둘 다 남겨」).
/// </summary>
void DetailStage(TreasureDb.Item it, TreasureState st, LobbyScene.Row stageRow, Sprite stageSp)
{
var db = S.Db;
var d = S.Save;
string id = it.id;
var rows = TreasureScene.Scene("detail");
var tagRow = DomesticScene.First(rows, "tag"); // 등급 목패(3차 · 리본 폐기)
var tagSp = tagRow != null ? UISkin.Icon(tagRow.sprite) : null;
bool maxed = st.star >= it.starMax;
var why = TreasureRules.CanEnhance(db, d, id);
int needShards = 0, needIron = 0;
if (!maxed) TreasureRules.EnhanceCost(db, it, st.star + 1, out needShards, out needIron);
string btn = maxed ? "최고 ★" : (why == null ? "강화 ★" + (st.star + 1) : why);
const float PanelH = 1320f;
var content = Root.ModalCustom(it.nameKo, PanelH,
new OutGameRoot.ModalButton(btn, why == null && !maxed ? UiKit.Red : UiKit.Disabled, () =>
{
var w = S.Do(() => TreasureRules.Enhance(S.Db, S.Save, id));
if (w == null) Detail(id);
else Root.Toast(w);
}, true),
new OutGameRoot.ModalButton("닫기", UiKit.Dark, null));
const float W = 860f; // 내용 칸 폭(팝업 940 − 좌우 40)
float stageW = stageRow.w > 1f ? stageRow.w : W;
float k = stageW / Mathf.Max(1f, stageRow.h > 1f ? stageRow.h : stageSp.rect.width);
float stageH = stageSp.rect.height * stageW / Mathf.Max(1f, stageSp.rect.width);
// 무대 + 방석 위 유물
var stage = UiKit.At(content, "Stage", (W - stageW) * 0.5f, 26f, stageW, stageH);
var si = stage.gameObject.AddComponent<Image>();
si.sprite = stageSp;
si.raycastTarget = false;
UiKit.Border(si, UiKit.Line, 3f);
float iconSize = stageRow.min > 1f ? stageRow.min : 200f;
string icon = IconId(it);
DetailRelic = icon != null ? UiKit.Icon(stage, icon, new Vector2(0f, 1f), new Vector2(0.5f, 0f), new Vector2(stageRow.x * k, -stageRow.y * k - 6f), iconSize) : null;
if (DetailRelic != null) UiKit.DropShadow(DetailRelic, 6f);
// 등급 목패(무대 위 끝 가운데 · 등급 색 · 3차 리본 폐기) — 등급 · ★ · 폭 = 글자에 맞춤(표 detail_stage f_max 까지)
float rw = stageRow.max > 1f ? stageRow.max : 320f;
if (tagRow != null && tagSp != null)
{
NameTag(content, TreasureRules.GradeKo(it.grade) + " ★" + st.star + "/" + it.starMax, GradeColor(it.grade), UiKit.White, tagRow, tagSp, rw,
new Vector2(0.5f, 1f), new Vector2(0.5f, 0.5f), new Vector2(0f, -26f - (tagRow.y > 0f ? tagRow.y : 0f))).transform.parent.name = "Grade";
}
else
{
var rib = UiKit.Box("Grade", content, new Vector2(0.5f, 1f), new Vector2(0.5f, 0.5f), new Vector2(0f, -26f), new Vector2(rw, 64f), GradeColor(it.grade));
UiKit.Title(UiKit.FillLabel(rib.transform, TreasureRules.GradeKo(it.grade) + " ★" + st.star + "/" + it.starMax, 30, UiKit.White), true);
}
// 보유 효과 제목(가는 줄 사이)
float y = 26f + stageH + 26f;
UiKit.Label(content, "보유 효과", 30, UiKit.Col("text_soft", UiKit.InkSoft), TextAnchor.MiddleCenter, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -y), new Vector2(300f, 44f), false);
UiKit.Paint(UiKit.At(content, "LineL", 40f, y + 22f, 250f, 2f), UiKit.Col("text_soft", UiKit.InkSoft), false);
UiKit.Paint(UiKit.At(content, "LineR", W - 290f, y + 22f, 250f, 2f), UiKit.Col("text_soft", UiKit.InkSoft), false);
y += 58f;
// 강화 단계 표(머리 = 어두운 띠 · ★ 지금 → 다음)
var headBand = UiKit.Paint(UiKit.At(content, "TableHead", 0f, y, W, 60f), UiKit.Col("chip_dark", UiKit.Dark), false);
UiKit.Title(UiKit.Label(headBand.transform, "강화 단계", 30, UiKit.White, TextAnchor.MiddleLeft, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(24f, 0f), new Vector2(300f, 50f)), true);
UiKit.Title(UiKit.Label(headBand.transform, "★" + st.star, 30, UiKit.Col("star", UiKit.White), TextAnchor.MiddleCenter, new Vector2(0f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(520f, 0f), new Vector2(160f, 50f)), true);
if (!maxed) UiKit.Title(UiKit.Label(headBand.transform, "★" + (st.star + 1), 30, UiKit.Col("star", UiKit.White), TextAnchor.MiddleCenter, new Vector2(0f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(740f, 0f), new Vector2(160f, 50f)), true);
y += 60f;
var rowBand = UiKit.Paint(UiKit.At(content, "TableRow", 0f, y, W, 66f), UiKit.Col("chip", UiKit.Paper), false);
rowBand.color = new Color(rowBand.color.r, rowBand.color.g, rowBand.color.b, 0.55f);
UiKit.Label(rowBand.transform, TreasureRules.StatKo(it.baseStat), 30, UiKit.White, TextAnchor.MiddleLeft, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(24f, 0f), new Vector2(360f, 50f));
UiKit.Label(rowBand.transform, "+" + TreasureRules.BasePct(db, it, st.star).ToString("0.##") + "%", 32, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(520f, 0f), new Vector2(200f, 50f));
if (!maxed)
{
UiKit.Label(rowBand.transform, "≫", 30, UiKit.Col("text_soft", UiKit.InkSoft), TextAnchor.MiddleCenter, new Vector2(0f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(630f, 0f), new Vector2(60f, 50f));
UiKit.Label(rowBand.transform, "+" + TreasureRules.BasePct(db, it, st.star + 1).ToString("0.##") + "%", 32, UiKit.Ok, TextAnchor.MiddleCenter, new Vector2(0f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(740f, 0f), new Vector2(200f, 50f));
}
y += 66f + 22f;
// 조건 효과 상자(머리 띠 + 내용)
int stacks = TreasureRules.CondStacks(d, it);
var condHead = UiKit.Paint(UiKit.At(content, "CondHead", 0f, y, W, 56f), UiKit.Col("chip_dark", UiKit.Dark), false);
UiKit.Title(UiKit.Label(condHead.transform, "조건 효과", 28, UiKit.White, TextAnchor.MiddleLeft, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(24f, 0f), new Vector2(400f, 50f)), true);
y += 56f;
var condBody = UiKit.Paint(UiKit.At(content, "CondBody", 0f, y, W, 150f), UiKit.Col("chip", UiKit.Paper), false);
condBody.color = new Color(condBody.color.r, condBody.color.g, condBody.color.b, 0.55f);
var cb = new System.Text.StringBuilder();
cb.Append("✦ ").Append(TreasureRules.CondKo(it.condKey)).Append(" ").Append(it.condEvery).Append("회마다 ").Append(TreasureRules.StatKo(it.baseStat)).Append(" +").Append(it.condPct.ToString("0.##")).Append("%")
.Append(" (").Append(stacks).Append("/").Append(it.condMax).Append(" · 누적 ").Append(TreasureRules.Counter(d, it.condKey)).Append(")");
cb.Append("\n✦ 지금 ").Append(TreasureRules.StatKo(it.baseStat)).Append(" <color=#").Append(ColorUtility.ToHtmlStringRGB(UiKit.Ok)).Append(">+").Append(TreasureRules.Value(db, d, it).ToString("0.##")).Append("%</color>");
UiKit.FillLabel(condBody.transform, cb.ToString(), 28, UiKit.White, TextAnchor.MiddleLeft, false, 24f);
y += 150f + 18f;
// 세트 · 강화 재료
var tiers = it.setId.Length > 0 ? db.TreasureSets.TiersOf(it.setId) : null;
var foot = new List<string>();
if (tiers != null && tiers.Count > 0) foot.Add("세트 「" + tiers[0].nameKo + "」");
if (!maxed) foot.Add("조각 " + st.shards + "/" + needShards + " · 운철 " + d.inv.iron + "/" + needIron);
if (foot.Count > 0)
UiKit.Label(content, string.Join(" ", foot.ToArray()), 28, UiKit.Col("text_soft", UiKit.InkSoft), TextAnchor.MiddleCenter, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -y), new Vector2(W, 44f), false);
}
// ---------- ② 세트 ----------
void BuildSets(RectTransform p)
{
var db = S.Db;
var d = S.Save;
ScrollRect sr;
// 보물고 진열장 배경 = 묶음 폭을 진열장 안쪽 판에 맞춤(표 vault_setgrid) · 아니면 예전 1032
var sg = VaultRow("setgrid");
int perRow = sg != null && sg.count > 0 ? sg.count : 5;
float mw = sg != null && sg.w > 1f ? sg.w : 196f, mh = sg != null && sg.h > 1f ? sg.h : 290f, mgap = sg != null && sg.gapMin > 0f ? sg.gapMin : 12f;
float bw = sg != null ? perRow * mw + (perRow - 1) * mgap : 1032f;
float pad = sg != null ? (OutGameRoot.Reference.x - bw) * 0.5f : 24f;
var list = UiKit.ScrollGrid("Sets", p, pad, pad, sg != null ? sg.y : 6f, 12f, new Vector2(bw, 640f), new Vector2(0f, 24f), 1, out sr);
SetCount = 0;
foreach (var setId in db.TreasureSets.SetIds)
{
var tiers = db.TreasureSets.TiersOf(setId);
if (tiers.Count == 0) continue;
var head = tiers[0];
var block = UiKit.Cell(list, "Set_" + setId, new Color(0f, 0f, 0f, 0f), null);
block.raycastTarget = false;
int owned = TreasureRules.OwnedIn(d, head);
// 보물고(붉은 벽) 위 = 흰 글자 + 외곽선(보유 중 「보유 n / m」 과 같은 모양 · 붉은 글자는 벽에 묻힘 · QA r1 M2)
bool vaultBg = ShownScene == "vault";
string ownTxt = "보유 " + owned + "/" + head.members.Count;
var plq = VaultRow("plaque");
var plqSp = plq != null ? UISkin.Icon(plq.sprite) : null;
float membersTop = 84f;
if (plqSp != null)
{
// 세트 제목 = 명패(PD 2026-11-13 3차 「세트 장식장의 제목을 명패처럼」 · 「보유 n/n 과 세트 제목이 겹치지 않게 충분한 공간」) — 표 vault_plaque:
// 오른쪽 끝 = 보유 글자 칸(f_x 폭 · 진열장 틀에서 f_gap_max 띄움) · 명패 = 가운데 · 폭 = 이름 + 좌우 장식(f_min)이되 양옆에 보유 칸 + 간격(f_gap_min)을 비운 폭까지
float ph = plq.h > 1f ? plq.h : 96f, side = plq.min > 1f ? plq.min : 66f;
float ownW = plq.x > 0f ? plq.x : 150f, gap = plq.gapMin > 0f ? plq.gapMin : 16f, inset = plq.gapMax > 0f ? plq.gapMax : 14f;
int pfs = plq.scale > 1f ? Mathf.RoundToInt(plq.scale) : 38;
float maxW = Mathf.Min(plq.w > 1f ? plq.w : 560f, bw - 2f * (inset + ownW + gap));
var plate = UiKit.Rect("Plaque", block.transform, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), Vector2.zero, new Vector2(maxW, ph));
var pimg = plate.gameObject.AddComponent<Image>();
pimg.sprite = plqSp;
pimg.raycastTarget = false;
if (plqSp.border.sqrMagnitude > 0f) pimg.type = Image.Type.Sliced;
var pt = UiKit.Title(UiKit.FillLabel(plate, head.nameKo, pfs, UiKit.White, TextAnchor.MiddleCenter, true, side), true);
pt.horizontalOverflow = HorizontalWrapMode.Overflow;
float need = pt.preferredWidth;
if (need + side * 2f > maxW && need > 1f) pt.fontSize = Mathf.Max(24, Mathf.FloorToInt(pfs * (maxW - side * 2f) / need));
plate.sizeDelta = new Vector2(Mathf.Clamp(pt.preferredWidth + side * 2f, Mathf.Min(maxW, plq.max > 1f ? plq.max : ph * 3f), maxW), ph); // 가장 좁은 폭 = f_max
UiKit.Title(UiKit.Label(block.transform, ownTxt, 30, UiKit.Ink, TextAnchor.MiddleCenter, new Vector2(1f, 1f), new Vector2(1f, 0.5f), new Vector2(-inset, -ph * 0.5f), new Vector2(ownW, 50f)), true).name = "Owned";
membersTop = ph + (plq.y > 0f ? plq.y : 12f);
Plaques++;
}
else
{
var band = UiKit.Box("Band", block.transform, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), Vector2.zero, new Vector2(700f, 70f), UiKit.Dark);
UiKit.FillLabel(band.transform, head.nameKo, 40, UiKit.White);
var ownedLbl = UiKit.Label(block.transform, ownTxt, 30, owned == head.members.Count || vaultBg ? UiKit.Ink : UiKit.Red, TextAnchor.MiddleRight, new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(vaultBg ? -14f : 0f, -10f), new Vector2(200f, 50f));
if (vaultBg) UiKit.Title(ownedLbl, true); // 진열장 틀에서 14 px 띄움(QA r3 M8)
}
var row = UiKit.Rect("Members", block.transform, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -membersTop), new Vector2(bw, mh));
var hl = row.gameObject.AddComponent<HorizontalLayoutGroup>();
hl.spacing = mgap;
hl.childAlignment = TextAnchor.UpperCenter;
hl.childControlWidth = hl.childControlHeight = false;
foreach (var m in head.members)
{
var it = db.Treasures.Get(m);
if (it == null) continue;
var holder = UiKit.Rect("M", row, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(mw, mh));
TreasureCell(holder, it, true);
var c = holder.GetChild(0) as RectTransform;
c.anchorMin = Vector2.zero;
c.anchorMax = Vector2.one;
c.offsetMin = c.offsetMax = Vector2.zero;
}
var fx = UiKit.Box("Effects", block.transform, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), Vector2.zero, new Vector2(bw, 250f), UiKit.Paper);
UiKit.Border(fx, UiKit.Line, 2f);
var best = TreasureRules.SetTier(db, d, setId);
var sb = new System.Text.StringBuilder("<b>기본 효과</b> <size=26>(가장 높은 달성 단계 1개)</size>");
string okHex = "#" + ColorUtility.ToHtmlStringRGB(UiKit.Ok), offHex = "#" + ColorUtility.ToHtmlStringRGB(UiKit.InkSoft);
foreach (var t in tiers)
{
bool ok = TreasureRules.TierReached(d, t);
string label = t.stars == 0 ? "[세트]" : "[" + t.stars + "성]";
string stat = TreasureRules.StatKo(t.effectKey.Replace("_pct", ""));
sb.Append("\n").Append(ok ? (t == best ? "<color=" + okHex + "><b>" : "<color=" + okHex + ">") : "<color=" + offHex + ">").Append(label).Append(" ").Append(stat).Append(" +").Append(t.pct.ToString("0.#")).Append("%")
.Append(ok ? (t == best ? " ✔ 적용</b>" : " 달성") : " 미달성").Append("</color>");
}
UiKit.FillLabel(fx.transform, sb.ToString(), 30, UiKit.Ink, TextAnchor.MiddleLeft, false, 24f);
SetCount++;
}
}
// ---------- ③ 상자 ----------
void BuildChests(RectTransform p)
{
var db = S.Db;
var d = S.Save;
var e = db.Economy;
var goalBox = UiKit.TopBand("Points", p, 6f, 150f, 24f, 24f);
var gimg = UiKit.Paint(goalBox, UiKit.Paper);
UiKit.Border(gimg, UiKit.Line, 3f);
if (UiKit.Icon(goalBox, "chest_point", new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(16f, 0f), 100f) == null)
UiKit.FillLabel(UiKit.Dot("P", goalBox, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(20f, 0f), 90f, UiKit.Coin).transform, "P", 44, UiKit.Ink);
var reward = db.Chests.Get(e.ChestPointReward);
UiKit.Label(goalBox, (e.ChestPointGoal - d.inv.chestPoints) + "포인트 더 모으면 " + (reward != null ? reward.nameKo : ""), 34, UiKit.Ink, TextAnchor.MiddleLeft, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(130f, -12f), new Vector2(800f, 54f));
var fill = UiKit.Bar("Bar", goalBox, new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(130f, 52f), new Vector2(700f, 30f), UiKit.Slot, UiKit.Coin);
UiKit.SetBar(fill, d.inv.chestPoints / (float)Mathf.Max(1, e.ChestPointGoal));
UiKit.Label(goalBox, "상자 포인트 " + d.inv.chestPoints + " / " + e.ChestPointGoal, 26, UiKit.InkSoft, TextAnchor.MiddleLeft, new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(130f, 8f), new Vector2(700f, 40f), false);
var c = db.Chests.Get(SelectedChest);
if (c == null) return;
int count = TreasureRules.ChestCount(d, c.id);
var banner = UiKit.Box("Banner", p, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -180f), new Vector2(560f, 470f), ChestColor(c.id));
UiKit.Border(banner, new Color(0.25f, 0.2f, 0.18f), 4f);
UiKit.Title(UiKit.Label(banner.transform, c.nameKo, 60, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -40f), new Vector2(520f, 90f)), true);
UiKit.Icon(banner.transform, "chest_" + c.id, new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), new Vector2(10f, -10f), 150f);
int line = 0;
for (int i = 0; i < ChestDb.ProbGrades.Length; i++)
{
if (c.p[i] <= 0f) continue;
var chip = UiKit.Box("P" + i, banner.transform, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -150f - line * 62f), new Vector2(360f, 54f), GradeColor(ChestDb.ProbGrades[i]));
UiKit.FillLabel(chip.transform, TreasureRules.GradeKo(ChestDb.ProbGrades[i]) + " " + (c.p[i] * 100f).ToString("0.#") + " %", 30, UiKit.White);
line++;
}
var extra = new List<string>();
if (c.goldMax > 0) extra.Add("골드 " + c.goldMin + "~" + c.goldMax);
if (c.gemMax > 0) extra.Add("보옥 " + c.gemMin + "~" + c.gemMax);
if (c.ironMax > 0) extra.Add("운철 " + c.ironMin + "~" + c.ironMax);
extra.Add("포인트 +" + c.points);
UiKit.Label(banner.transform, string.Join(" · ", extra.ToArray()), 26, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 18f), new Vector2(540f, 50f), false);
UiKit.Btn("Prev", p, "◁", 70, new Color(0f, 0f, 0f, 0f), UiKit.Ink, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(40f, -360f), new Vector2(120f, 140f), () => Step(-1));
UiKit.Btn("Next", p, "▷", 70, new Color(0f, 0f, 0f, 0f), UiKit.Ink, new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(-40f, -360f), new Vector2(120f, 140f), () => Step(1));
var open = UiKit.Btn("Open", p, count > 0 ? "열기\n<size=28>보유 " + count + "</size>" : "열기\n<size=28>상자 없음</size>", 46, count > 0 ? UiKit.Red : UiKit.Disabled, UiKit.White,
new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -680f), new Vector2(480f, 140f), () => DoOpen(SelectedChest));
open.interactable = count > 0;
UiKit.Label(p, "상자는 이 탭에서만 엽니다 · 중복 = 조각 · 천기 = 적장 토벌 전용", 26, UiKit.InkSoft, TextAnchor.MiddleCenter, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -836f), new Vector2(1000f, 44f), false);
int n = db.Chests.chests.Count;
float w = (1032f - (n - 1) * 12f) / n;
for (int i = 0; i < n; i++)
{
var ch = db.Chests.chests[i];
int k = i;
var b = UiKit.Btn("Chest_" + ch.id, p, "", 28, i == chestIdx ? UiKit.Red : UiKit.Paper, UiKit.Ink, new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(24f + i * (w + 12f), 24f), new Vector2(w, 220f), () =>
{
chestIdx = k;
chestPicked = true;
Refresh();
});
UiKit.Border(b.GetComponent<Image>(), i == chestIdx ? UiKit.Coin : UiKit.Line, i == chestIdx ? 5f : 2f);
var box = UiKit.Box("Icon", b.transform, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -20f), new Vector2(w * 0.7f, 90f), ChestColor(ch.id));
box.raycastTarget = false;
UiKit.Icon(box.transform, "chest_" + ch.id, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, 4f), 96f);
UiKit.Label(b.transform, ch.nameKo + "\n<size=34>×" + TreasureRules.ChestCount(d, ch.id) + "</size>", 26, UiKit.Ink, TextAnchor.MiddleCenter, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 14f), new Vector2(w, 100f));
}
}
/// <summary>
/// 상자 탭(Fable 제안서 §4-5 · #1104) — 포인트 띠(BannerFrame05 · 가는 주황 바 · 「85 / 120」 · 오른쪽 보상 상자) →
/// 상자 카드(CardFrame03 상자 색 + 안쪽 + 광택 · 상자 아이콘 300 + 뒤 금빛 · 등급 칩 + % 한 줄 · 내용물 줄) · 좌우 화살표 →
/// 「열기」 Primary 노랑 + 보유 칩(없으면 회색) → 아래 상자 종류 행(ItemFrame 5층 상자 색 + 수량 칩 · 선택 = FocusLine 깜빡).
/// </summary>
void BuildChestsKit(RectTransform p)
{
var db = S.Db;
var d = S.Save;
var soft = UiKit.Col("text_soft", UiKit.InkSoft);
PointsBand(p);
AutoPickChest();
var c = db.Chests.Get(SelectedChest);
if (c == null) return;
int count = TreasureRules.ChestCount(d, c.id);
BuildChestsKitBody(p, c, count, soft);
ChestRow(p, soft);
}
/// <summary>
/// 상자 탭 = 금고(PD 2026-11-13 「상자 메뉴의 배경은 화려한 금고(주변에 금화가 쌓인) 속 올려진 상자를 여는 형태로 재구성」) —
/// 포인트 띠 → 금고 그림 받침대 방석 위에 상자(표 chest_spot · 뒤 금빛 맥동) · 위 이름 · 아래 확률 칩 · 내용물 줄 · 좌우 화살표 · 열기(누르면 상자가 흔들린 뒤 결과) · 아래 상자 종류 행.
/// 자리 = 표 Resources/UI/treasure_scene.csv(그림 px · ArtToPage 로 페이지 좌표).
/// </summary>
void BuildChestsVault(RectTransform p, Scene s)
{
var db = S.Db;
var d = S.Save;
PointsBand(p);
AutoPickChest();
var c = db.Chests.Get(SelectedChest);
if (c == null) return;
int count = TreasureRules.ChestCount(d, c.id);
ChestOnPedestal = true;
var spot = DomesticScene.First(s.rows, "spot");
float sx = spot != null ? spot.x : s.artW * 0.5f, sy = spot != null ? spot.y : s.artH * 0.33f;
float size = spot != null && spot.w > 1f ? spot.w : 360f;
var at = ArtToPage(s, sx, sy);
System.Func<string, float, float> rowY = (kind, def) =>
{
var r = DomesticScene.First(s.rows, kind);
return ArtToPage(s, sx, r != null ? r.y : def).y;
};
// 뒤 금빛 맥동 → 상자(받침대 방석 위)
if (UISkin.Has("glow_circle"))
{
var glow = UiKit.Skin(UiKit.Rect("Glow", p, new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), new Vector2(at.x, -at.y), new Vector2(size * 1.7f, size * 1.7f)), "glow_circle", UiKit.Col("chest_glow", Color.clear));
var pulse = glow.gameObject.AddComponent<UiPulse>();
pulse.min = 0.7f;
pulse.period = 1.6f;
}
openChestIcon = UiKit.Icon(p, "chest_" + c.id, new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), new Vector2(at.x, -at.y), size);
if (openChestIcon != null) UiKit.DropShadow(openChestIcon, 8f);
// 이름 · 확률 칩 · 내용물 줄(금 바닥 위 = 외곽선 글자)
var name = UiKit.Title(UiKit.Label(p, c.nameKo, 56, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), new Vector2(at.x, -rowY("name", sy - 210f)), new Vector2(700f, 80f)), true);
name.horizontalOverflow = HorizontalWrapMode.Overflow;
var probs = new List<int>();
for (int i = 0; i < ChestDb.ProbGrades.Length; i++)
if (c.p[i] > 0f) probs.Add(i);
const float Pw = 178f, Pg = 14f;
float py = rowY("probs", sy + 440f);
float px = at.x - (probs.Count * Pw + (probs.Count - 1) * Pg) * 0.5f;
for (int k = 0; k < probs.Count; k++)
{
int i = probs[k];
string g = ChestDb.ProbGrades[i];
var chip = UiKit.Skin(UiKit.At(p, "P" + i, px + k * (Pw + Pg), py - 30f, Pw, 60f), "para_orange", GradeColor(g));
var ct = UiKit.Title(UiKit.FillLabel(chip.transform, TreasureRules.GradeKo(g) + " " + (c.p[i] * 100f).ToString("0.#") + "%", 30, UiKit.White), true);
ct.horizontalOverflow = HorizontalWrapMode.Overflow;
}
var extra = new List<string>();
if (c.goldMax > 0) extra.Add("골드 " + c.goldMin + "~" + c.goldMax);
if (c.gemMax > 0) extra.Add("보옥 " + c.gemMin + "~" + c.gemMax);
if (c.ironMax > 0) extra.Add("운철 " + c.ironMin + "~" + c.ironMax);
extra.Add("포인트 +" + c.points);
var eb = UiKit.Skin(UiKit.Rect("ExtraBand", p, new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), new Vector2(at.x, -rowY("extra", sy + 500f)), new Vector2(720f, 52f)), "chip", UiKit.Col("chip_dark", UiKit.Dark));
var et = UiKit.Title(UiKit.FillLabel(eb.transform, string.Join(" · ", extra.ToArray()), 26, UiKit.White, TextAnchor.MiddleCenter, true, 16f), true);
et.horizontalOverflow = HorizontalWrapMode.Overflow;
// 좌우 화살표(상자 높이)
var ar = DomesticScene.First(s.rows, "arrow");
float ax = ar != null ? ar.x : 30f;
float ay = rowY("arrow", sy);
Arrow(p, "Prev", "arrow_prev", ax, ay - 70f, () => Step(-1));
Arrow(p, "Next", "arrow_next", 1080f - ax - 100f, ay - 70f, () => Step(1));
// 열기(받침대 위 상자가 흔들린 뒤 결과) · 안내
float oy = rowY("open", sy + 540f);
var open = UiKit.SkinBtn(p, "Open", count > 0 ? "btn_l_yellow" : "btn_gray", UiKit.White, count > 0 ? "열기" : "상자 없음", 46,
count > 0 ? UiKit.Col("text_on_yellow", UiKit.Ink) : UiKit.White, (1080f - 460f) * 0.5f, oy, 460f, 116f, () => OpenWithShake(SelectedChest));
open.interactable = count > 0;
if (count > 0) UiKit.BtnCost(open, "chest_" + c.id, "x" + count, 56f, 34);
var note = UiKit.Title(UiKit.Label(p, "상자는 이 탭에서만 엽니다 · 중복 = 조각 · 천기 = 적장 토벌 전용", 26, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0.5f, 1f), new Vector2(0.5f, 0.5f), new Vector2(0f, -rowY("note", sy + 690f)), new Vector2(1000f, 44f), false), true);
note.horizontalOverflow = HorizontalWrapMode.Overflow;
ChestRow(p, UiKit.White);
}
/// <summary>열기 버튼과 같은 동작(받침대 배치면 흔든 뒤 열기 · 아니면 바로) — 테스트·캡처용 공개 진입점.</summary>
public void OpenAnimated(string id)
{
OpenWithShake(id);
}
/// <summary>받침대 위 상자를 흔든 뒤(0.4 초 · 실제 시간) 연다 — 상자가 없으면 바로 DoOpen(토스트).</summary>
void OpenWithShake(string id)
{
if (openChestIcon == null || Root == null || !Root.isActiveAndEnabled)
{
DoOpen(id);
return;
}
Root.StartCoroutine(ShakeThenOpen(openChestIcon.rectTransform, id));
}
System.Collections.IEnumerator ShakeThenOpen(RectTransform rt, string id)
{
const float Dur = 0.4f;
float t = 0f;
while (t < Dur && rt != null)
{
t += Time.unscaledDeltaTime;
float k = t / Dur;
rt.localRotation = Quaternion.Euler(0f, 0f, Mathf.Sin(k * Mathf.PI * 8f) * 7f * (1f - k));
rt.localScale = Vector3.one * (1f + 0.12f * Mathf.Sin(k * Mathf.PI));
yield return null;
}
if (rt != null)
{
rt.localRotation = Quaternion.identity;
rt.localScale = Vector3.one;
}
DoOpen(id);
}
/// <summary>상자 포인트 띠(맨 위 · 「n포인트 더 모으면 명장 상자」 · 바 · 「85 / 120」 · 오른쪽 보상 상자).</summary>
void PointsBand(RectTransform p)
{
var db = S.Db;
var d = S.Save;
var e = db.Economy;
var band = UiKit.Skin(UiKit.At(p, "Points", 24f, 6f, 1032f, 92f), "banner_point", UiKit.Col("banner_point", UiKit.White)).transform;
var pi = UiKit.Icon(band, "chest_point", new Vector2(0f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(46f, 2f), 76f);
if (pi != null) UiKit.DropShadow(pi, 3f);
var reward = db.Chests.Get(e.ChestPointReward);
UiKit.Label(band, Mathf.Max(0, e.ChestPointGoal - d.inv.chestPoints) + "포인트 더 모으면 " + (reward != null ? reward.nameKo : ""), 28, UiKit.White, TextAnchor.MiddleLeft, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(112f, -6f), new Vector2(640f, 44f));
var bg = UiKit.Skin(UiKit.At(band, "Bar", 112f, 50f, 600f, 30f), "bar4_bg", UiKit.White);
var fill = UiKit.Skin(UiKit.At(bg.transform, "Fill", 4f, 4f, 592f, 20f), "bar4_fill", UiKit.White).rectTransform;
float v = Mathf.Clamp01(d.inv.chestPoints / (float)Mathf.Max(1, e.ChestPointGoal));
fill.sizeDelta = new Vector2(Mathf.Max(16f, 592f * v), 20f);
fill.gameObject.SetActive(v > 0.001f);
var pt = UiKit.Title(UiKit.Label(band, d.inv.chestPoints + " / " + e.ChestPointGoal, 30, UiKit.White, TextAnchor.MiddleLeft, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(730f, -38f), new Vector2(180f, 50f)), true);
pt.horizontalOverflow = HorizontalWrapMode.Overflow;
if (reward != null)
{
var ri = UiKit.Icon(band, "chest_" + reward.id, new Vector2(1f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(-62f, 4f), 80f);
if (ri != null) UiKit.DropShadow(ri, 3f);
}
}
/// <summary>처음 연 상자 탭에서 고른 상자가 0개면 가진 상자 중 첫 번째로.</summary>
void AutoPickChest()
{
var db = S.Db;
var d = S.Save;
if (!chestPicked && TreasureRules.ChestCount(d, SelectedChest) == 0)
for (int i = 0; i < db.Chests.chests.Count; i++)
if (TreasureRules.ChestCount(d, db.Chests.chests[i].id) > 0)
{
chestIdx = i;
break;
}
}
/// <summary>예전 상자 탭 몸통(무대 문양 + 카드 + 화살표 + 열기) — 금고 배경 그림이 없을 때.</summary>
void BuildChestsKitBody(RectTransform p, ChestDb.Chest c, int count, Color soft)
{
// 무대 = 카드 뒤 문양 흰 5 % + 바닥 그라데이션(검수2 결함 4)
if (UISkin.Has("bg_pattern"))
{
var stage = UiKit.At(p, "Stage", 0f, 108f, 1080f, 1000f);
var pat = UiKit.Skin(stage, "bg_pattern", UiKit.Col("stage_pattern", Color.clear));
pat.type = Image.Type.Tiled;
if (UISkin.Has("scroll_fade"))
{
var g = UiKit.Skin(UiKit.At(stage, "Ground", 0f, 1000f - 160f, 1080f, 160f), "scroll_fade", UiKit.Col("panel_ground", Color.clear));
g.rectTransform.localScale = new Vector3(1f, -1f, 1f);
g.rectTransform.pivot = new Vector2(0f, 0f);
}
}
// 상자 카드
const float Cw = 680f, Ch = 760f, Cy = 120f;
var card = UiKit.At(p, "Card", (1080f - Cw) * 0.5f, Cy, Cw, Ch);
UiKit.Skin(card, "card3_bg", ChestColor(c.id));
UiKit.Layer(card, "Inner", "card3_inner", UiKit.Col("card3_inner", Color.clear), 18f, 104f, 18f, 18f);
UiKit.Layer(card, "Light", "card3_light", UiKit.Col("card3_light", Color.clear), 6f, 6f, 6f, 10f);
var name = UiKit.Title(UiKit.Label(card, c.nameKo, 56, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -16f), new Vector2(Cw - 40f, 80f)), true);
name.horizontalOverflow = HorizontalWrapMode.Overflow;
if (UISkin.Has("glow_circle"))
{
var glow = UiKit.Skin(UiKit.Rect("Glow", card, new Vector2(0.5f, 1f), new Vector2(0.5f, 0.5f), new Vector2(0f, -320f), new Vector2(600f, 600f)), "glow_circle", UiKit.Col("chest_glow", Color.clear));
var pulse = glow.gameObject.AddComponent<UiPulse>();
pulse.min = 0.7f;
pulse.period = 1.6f;
}
var ci = UiKit.Icon(card, "chest_" + c.id, new Vector2(0.5f, 1f), new Vector2(0.5f, 0.5f), new Vector2(0f, -320f), 320f);
if (ci != null) UiKit.DropShadow(ci, 8f);
var probs = new List<int>();
for (int i = 0; i < ChestDb.ProbGrades.Length; i++)
if (c.p[i] > 0f) probs.Add(i);
const float Pw = 178f, Pg = 14f;
float px = (Cw - probs.Count * Pw - (probs.Count - 1) * Pg) * 0.5f;
for (int k = 0; k < probs.Count; k++)
{
int i = probs[k];
string g = ChestDb.ProbGrades[i];
var chip = UiKit.Skin(UiKit.At(card, "P" + i, px + k * (Pw + Pg), 528f, Pw, 60f), "para_orange", GradeColor(g));
var ct = UiKit.Title(UiKit.FillLabel(chip.transform, TreasureRules.GradeKo(g) + " " + (c.p[i] * 100f).ToString("0.#") + "%", 30, UiKit.White), true);
ct.horizontalOverflow = HorizontalWrapMode.Overflow;
}
var extra = new List<string>();
if (c.goldMax > 0) extra.Add("골드 " + c.goldMin + "~" + c.goldMax);
if (c.gemMax > 0) extra.Add("보옥 " + c.gemMin + "~" + c.gemMax);
if (c.ironMax > 0) extra.Add("운철 " + c.ironMin + "~" + c.ironMax);
extra.Add("포인트 +" + c.points);
// 내용물 줄 = 카드 아래 어두운 띠 위 흰 글자(검수2 결함 10)
var eb = UiKit.Layer(card, "ExtraBand", "card_bottom", UiKit.Col("card_bottom", UiKit.Dark), 6f, 0f, 6f, 6f);
eb.rectTransform.anchorMax = new Vector2(1f, 0f);
eb.rectTransform.offsetMax = new Vector2(-6f, 76f);
var et = UiKit.Title(UiKit.FillLabel(eb.transform, string.Join(" · ", extra.ToArray()), 26, UiKit.White, TextAnchor.MiddleCenter, true, 16f), true);
et.horizontalOverflow = HorizontalWrapMode.Overflow;
// 좌우 화살표(카드 가운데 높이)
Arrow(p, "Prev", "arrow_prev", 30f, Cy + Ch * 0.5f - 70f, () => Step(-1));
Arrow(p, "Next", "arrow_next", 1080f - 30f - 100f, Cy + Ch * 0.5f - 70f, () => Step(1));
// 열기
var open = UiKit.SkinBtn(p, "Open", count > 0 ? "btn_l_yellow" : "btn_gray", UiKit.White, count > 0 ? "열기" : "상자 없음", 46,
count > 0 ? UiKit.Col("text_on_yellow", UiKit.Ink) : UiKit.White, (1080f - 460f) * 0.5f, Cy + Ch + 26f, 460f, 116f, () => DoOpen(SelectedChest));
open.interactable = count > 0;
if (count > 0) UiKit.BtnCost(open, "chest_" + c.id, "x" + count, 56f, 34);
UiKit.Label(p, "상자는 이 탭에서만 엽니다 · 중복 = 조각 · 천기 = 적장 토벌 전용", 26, soft, TextAnchor.MiddleCenter, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -(Cy + Ch + 156f)), new Vector2(1000f, 44f), false);
}
/// <summary>상자 종류 행(아래 · ItemFrame 5층 상자 색 + 수량 칩 · 선택 = FocusLine 깜빡) — 예전 배치·금고 배치 공통.</summary>
void ChestRow(RectTransform p, Color soft)
{
var db = S.Db;
var d = S.Save;
int n = db.Chests.chests.Count;
const float Tw = 150f;
float gap = n > 1 ? Mathf.Min(64f, (1032f - n * Tw) / (n - 1)) : 0f;
float tx0 = (1080f - n * Tw - (n - 1) * gap) * 0.5f;
float ty = OutGameRoot.BodyH - 118f - 24f - 40f - Tw;
for (int i = 0; i < n; i++)
{
var ch = db.Chests.chests[i];
int k = i;
bool sel = i == chestIdx;
var cell = UiKit.At(p, "Chest_" + ch.id, tx0 + i * (Tw + gap), ty, Tw, Tw);
UiKit.ItemSlot(cell, "chest_" + ch.id + "_", sel);
var hit = cell.gameObject.AddComponent<Image>();
hit.color = Color.clear;
cell.gameObject.AddComponent<Button>().onClick.AddListener(() =>
{
chestIdx = k;
chestPicked = true;
Refresh();
});
var ic = UiKit.Icon(cell, "chest_" + ch.id, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, 6f), 104f);
if (ic != null) UiKit.DropShadow(ic, 4f);
if (sel && UISkin.Has("focus_line"))
{
var f = UiKit.Layer(cell, "Focus", "focus_line", UiKit.Col("chest_select", UiKit.White), -8f, -8f, -8f, -8f);
f.gameObject.AddComponent<UiPulse>();
// 1.08 배는 아래 가운데 기준(위·양옆으로만 커짐 = 아래끝·칩이 이름 쪽으로 내려오지 않음 · QA r1 B2 · r2 B3)
var pos = cell.anchoredPosition;
cell.pivot = new Vector2(0.5f, 0f);
cell.anchoredPosition = pos + new Vector2(Tw * 0.5f, -Tw);
cell.localScale = Vector3.one * 1.08f;
}
else UiKit.Layer(cell, "Dim", "item_bg", UiKit.Col("chest_dim", new Color(0f, 0f, 0f, 0.25f)));
// 수량 칩 = 선택 테두리 위(테두리가 칩을 덮지 않게 · QA r1 B2)
UiKit.QtyChip(cell, "x" + TreasureRules.ChestCount(d, ch.id), 28);
// 이름 = 모든 칸 같은 높이(선택 칸은 위로만 커짐 · 이름을 내리면 솟은 하단 「보물」 탭 뒤로 잘림 = QA r2 B3) · 금고 배경 = 외곽선(QA r1 M1)
float nameY = ty + Tw + 10f;
var nl = UiKit.Label(p, ch.nameKo, 26, sel ? UiKit.White : soft, TextAnchor.UpperCenter, new Vector2(0f, 1f), new Vector2(0.5f, 1f), new Vector2(tx0 + i * (Tw + gap) + Tw * 0.5f, -nameY), new Vector2(Tw + gap, 36f), sel);
nl.horizontalOverflow = HorizontalWrapMode.Overflow;
if (ShownScene == "chest") UiKit.Title(nl, true);
}
}
/// <summary>화살표 버튼(투명 판 + 팩 화살표 아이콘 · 없으면 글자).</summary>
static void Arrow(Transform p, string name, string icon, float x, float y, System.Action onClick)
{
var rt = UiKit.At(p, name, x, y, 100f, 140f);
var img = rt.gameObject.AddComponent<Image>();
img.color = Color.clear;
rt.gameObject.AddComponent<Button>().onClick.AddListener(() => onClick());
var ic = UiKit.Icon(rt, icon, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, 96f);
if (ic != null) UiKit.DropShadow(ic, 4f);
else UiKit.Title(UiKit.FillLabel(rt, name == "Prev" ? "◁" : "▷", 70, UiKit.White), true);
}
void Step(int dir)
{
int n = Mathf.Max(1, S.Db.Chests.chests.Count);
chestIdx = ((chestIdx + dir) % n + n) % n;
chestPicked = true;
Refresh();
}
public void DoOpen(string id)
{
var r = S.OpenChest(id);
LastOpen = r;
if (r == null)
{
Root.Toast("상자 없음");
return;
}
var it = r.treasureId != null ? S.Db.Treasures.Get(r.treasureId) : null;
var sb = new System.Text.StringBuilder();
if (it != null)
sb.Append("<size=48><b>").Append(it.nameKo).Append("</b></size>\n").Append(TreasureRules.GradeKo(it.grade)).Append(r.gradeUp ? " (상위 등급!)" : "")
.Append(r.isNew ? " <color=#b0301e>NEW</color>" : " 조각 +" + r.shards).Append("\n\n");
var parts = new List<string>();
if (r.gold > 0) parts.Add("골드 +" + r.gold.ToString("N0"));
if (r.gem > 0) parts.Add("보옥 +" + r.gem.ToString("N0"));
if (r.iron > 0) parts.Add("운철 +" + r.iron);
parts.Add("상자 포인트 +" + r.points);
sb.Append(string.Join(" · ", parts.ToArray()));
if (r.bonusChests > 0) sb.Append("\n<color=#b0301e>포인트 달성 → ").Append(S.Db.Chests.Get(S.Db.Economy.ChestPointReward).nameKo).Append(" +").Append(r.bonusChests).Append("</color>");
Root.Modal(S.Db.Chests.Get(id).nameKo + " 열기", sb.ToString(),
new OutGameRoot.ModalButton("한 번 더", UiKit.Red, () => DoOpen(id)),
new OutGameRoot.ModalButton("확인", UiKit.Dark, null));
}
/// <summary>상자 대표 색 = 테마 chest_&lt;id&gt;(구 코드 상수 → ui_theme.csv · C45 · #1104).</summary>
public static Color ChestColor(string id)
{
return UiKit.Col("chest_" + id, UiKit.Slot);
}
}
}