Project_WL/Assets/WL/Equip/WLEquipGui.cs

940 lines
65 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.

// ─────────────────────────────────────────────────────────────────────────────
// WLEquipGui.cs — 장비 화면 GUI v3 · 레퍼런스(캐릭터 중심) 형태 재설계 + Layer Lab 스프라이트 (2026-10-12 · PD 지시)
//
// 허브 화면(레퍼런스 1): 상단(자동 장착 · 골드/열쇠(+상자)/강화석) → 캐릭터 무대(3D 미리보기 RenderTexture · 좌 3·우 3 장착 슬롯 ·
// 공격/생명 알약) → 부위 탭 7 + 「합성」 버튼 → 5열 그리드(등급 색 프레임 · 부위 핀 · Lv · 장착됨 태그) → 뒤로 가기.
// 상세 팝업(장착/강화/합성/강등) · 뽑기 시트(WLEquipGui.Gacha.cs) · 합성 화면(WLEquipGui.Compose.cs · 레퍼런스 2 · FR ComposePopup 흐름).
// 코드로 만드는 uGUI(프리팹 0). 치수·색·스프라이트 이름은 전부 `Resources/WL/WLEquipGui.json`(C45). 로직은 WLInventory·WLEquipStats·WLEquipGacha.
// 스프라이트가 없으면 색 사각형으로 대체해 기능은 그대로 돈다.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace WL.Equip
{
public partial class WLEquipGui : MonoBehaviour
{
public static WLEquipGui Instance { get; private set; }
public static bool IsOpen { get { return Instance != null && Instance.gameObject.activeSelf; } }
public static int Opens { get; private set; }
public static string LastLog { get; private set; } = "";
const int UILayer = 5;
const float RefW = 1080f, RefH = 1920f;
WLEquipSettings _cfg;
WLEquipGuiConfig _g;
WLGuiLayout L; WLGuiHub H; WLGuiCompose C; WLGuiGacha G;
TMP_FontAsset _font;
Canvas _canvas;
RectTransform _root;
// ── 허브 ──
RectTransform _hub, _gridContent;
ScrollRect _scroll;
RawImage _preview;
WLEquipPreview _previewRig;
TextMeshProUGUI _coinText, _keyText, _stoneText, _atkText, _hpText, _message;
readonly Cell[] _slots = new Cell[6];
readonly TextMeshProUGUI[] _slotNames = new TextMeshProUGUI[6];
readonly List<Button> _tabBtns = new List<Button>();
readonly List<Image> _tabIcons = new List<Image>();
readonly List<Image> _tabLines = new List<Image>();
int _tab;
readonly List<Cell> _cells = new List<Cell>();
Button _autoBtn, _composeOpenBtn, _cheatBtn, _backBtn;
// ── 상세 팝업 ──
GameObject _popup;
Cell _popupForm;
TextMeshProUGUI _popupName, _popupRarity, _popupHeaderName, _popupHeaderSub, _popupDps, _popupOptions;
Image _popupRarityImg;
Button _equipBtn, _lvBtn, _lvAllBtn, _composeBtn, _downBtn;
TextMeshProUGUI _equipLabel;
int _selectedId = -1;
/// <summary>그리드 셀·장착 슬롯·결과 카드 공용 조각.</summary>
class Cell
{
public RectTransform root;
public Image back, icon, frame, glow, pinBg, pin, lvBg, tagBg, badge, badgeIcon, sBadge, check, dim, lockIcon, plusBg;
public TextMeshProUGUI level, tag, empty, name, plus;
public Button btn;
public int id = -1;
public float size;
public bool slotStyle;
}
// ── 진입 ─────────────────────────────────────────────────────────
public static WLEquipGui Open()
{
var cfg = WLEquipSettings.Instance;
if (cfg == null || cfg.enabled_ == 0) return null;
if (Instance == null)
{
var go = new GameObject("WL_EquipGui", typeof(RectTransform));
go.layer = UILayer;
DontDestroyOnLoad(go);
Instance = go.AddComponent<WLEquipGui>();
Instance._cfg = cfg;
Instance._g = WLEquipGuiAssets.Config;
Instance.L = Instance._g.layout ?? new WLGuiLayout();
Instance.H = Instance._g.hub ?? new WLGuiHub();
Instance.C = Instance._g.compose ?? new WLGuiCompose();
Instance.G = Instance._g.gacha ?? new WLGuiGacha();
try { Instance.Build(); }
catch (System.Exception ex) { Debug.LogError("[WL-824] GUI 조립 예외 — " + ex); }
}
Instance.gameObject.SetActive(true);
if (Instance._previewRig != null) Instance._previewRig.SetVisible(true);
Opens++;
WLInventory.EnsureLoaded();
Instance.ShowHub();
return Instance;
}
public static void Close() { if (Instance != null) Instance.gameObject.SetActive(false); }
void OnEnable() { WLInventory.Changed += Refresh; if (_previewRig != null) _previewRig.SetVisible(true); }
void OnDisable()
{
WLInventory.Changed -= Refresh;
_drawCo = null; _revealing = false; _skipReveal = false; // 비활성화되면 코루틴이 멈추므로 상태도 같이 비운다
if (_previewRig != null) _previewRig.SetVisible(false);
}
void OnDestroy() { if (Instance == this) Instance = null; }
// ── 조립 ─────────────────────────────────────────────────────────
void Build()
{
_font = WLEquipUi.ResolveKoreanFont(_cfg);
if (EventSystem.current == null)
{
var es = new GameObject("WL_EventSystem(EquipGui)", typeof(EventSystem), typeof(StandaloneInputModule));
DontDestroyOnLoad(es);
}
_canvas = gameObject.AddComponent<Canvas>();
_canvas.renderMode = RenderMode.ScreenSpaceOverlay;
_canvas.sortingOrder = _cfg.uiSortingOrder + 1;
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(RefW, RefH);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>();
_root = (RectTransform)transform;
// 배경 — 단색 + 아래쪽 세로 그라데이션(Layer Lab background_gradient_00)
var bg = NewImage(_root, "Bg", null, Col("bg", new Color(0.17f, 0.14f, 0.21f, 1f)), Image.Type.Simple);
Stretch(bg.rectTransform); bg.raycastTarget = true;
var grad = NewImage(_root, "Grad", Spr("bgGradient"), Spr("bgGradient") != null ? Col("bgBottom", Color.black) : new Color(0f, 0f, 0f, 0f));
grad.rectTransform.anchorMin = new Vector2(0f, 0f); grad.rectTransform.anchorMax = new Vector2(1f, 0.55f);
grad.rectTransform.offsetMin = grad.rectTransform.offsetMax = Vector2.zero;
BuildHub();
IP = _g.itemPopup ?? new WLGuiItemPopup();
if (IpOn) BuildItemPopupV2(); else BuildDetailPopup(); // #881 PD 레퍼런스 팝업(WLEquipGui.RefLayout.cs)
BuildCompose();
BuildBoxPopup();
BuildResultView();
BuildRatesPopup();
BuildPartSkillUi(); // #879 장비 파츠 스킬(WLEquipGui.PartSkill.cs)
LastLog ="조립 완료 · 스프라이트 미발견 " + WLEquipGuiAssets.Missing + (WLEquipGuiAssets.MissingNames.Count > 0 ? " (" + string.Join(",", WLEquipGuiAssets.MissingNames.ToArray()) + ")" : "");
}
/// <summary>가로 띠(위에서 top px ~ bottom px · bottom ≤ 0 이면 바닥까지) — 캐주얼 톤의 상단 띠·아래 남색 패널.</summary>
Image AddBand(RectTransform parent, string name, float top, float bottom, string colorKey, string spriteKey = null)
{
var spr = spriteKey != null ? Spr(spriteKey) : null;
var img = NewImage(parent, name, spr, Col(colorKey, new Color(0f, 0f, 0f, 0f)), spr != null ? Image.Type.Sliced : Image.Type.Simple);
var rt = img.rectTransform;
rt.anchorMin = new Vector2(0f, bottom > 0f ? 1f : 0f); rt.anchorMax = new Vector2(1f, 1f); rt.pivot = new Vector2(0.5f, 1f);
rt.offsetMax = new Vector2(0f, -top);
rt.offsetMin = new Vector2(0f, bottom > 0f ? -bottom : 0f);
return img;
}
void BuildHub()
{
_hub = NewRT(_root, "Hub"); Stretch(_hub);
if (H.topBarFill > 0f) AddBand(_hub, "TopBar", 0f, H.topBarFill, "topBar");
if (H.lowerPanelTop > 0f) AddBand(_hub, "LowerPanel", H.lowerPanelTop, 0f, "lowerPanel");
// 상단 — 자동 장착(레퍼런스의 좌상단 버튼 자리) + 재화 알약 3개(오른쪽부터 강화석 · 열쇠(+상자) · 골드)
_autoBtn = NewButton(_hub, "자동 장착", Spr("btnPrimary"), Color.white, H.autoBtnW, H.autoBtnH, EquipBest, H.autoFontPx, DarkCol());
Place((RectTransform)_autoBtn.transform, new Vector2(0f, 1f), new Vector2(H.edge, -H.edge), new Vector2(H.autoBtnW, H.autoBtnH), new Vector2(0f, 1f));
float px = -H.edge;
_stoneText = NewPill(_hub, "Stone", "stoneIcon", new Vector2(1f, 1f), new Vector2(px, -H.edge - 10f), false, null); px -= H.pillW + H.pillGap + H.pillIcon * 0.5f;
_keyText = NewPill(_hub, "Key", "keyIcon", new Vector2(1f, 1f), new Vector2(px, -H.edge - 10f), true, OpenSheet); px -= H.pillW + H.pillGap + H.pillIcon * 0.5f + H.pillAddBtn * 0.5f;
_coinText = NewPill(_hub, "Coin", "goldIcon", new Vector2(1f, 1f), new Vector2(px, -H.edge - 10f), false, null);
// 캐릭터 무대
var stage = NewRT(_hub, "Stage");
stage.anchorMin = new Vector2(0f, 1f); stage.anchorMax = new Vector2(1f, 1f); stage.pivot = new Vector2(0.5f, 1f);
stage.anchoredPosition = new Vector2(0f, -H.stageTop); stage.sizeDelta = new Vector2(0f, H.stageH);
var glow = NewImage(stage, "Glow", Spr("charGlow"), Spr("charGlow") != null ? Col("charGlowTint", new Color(1f, 1f, 1f, 0.55f)) : new Color(1f, 1f, 1f, 0f), Image.Type.Simple);
Place(glow.rectTransform, new Vector2(0.5f, 0.5f), new Vector2(0f, H.glowY), new Vector2(H.glowW, H.glowH));
var plat = NewImage(stage, "Platform", Spr("platform"), Spr("platform") != null ? Col("platformTint", Color.white) : new Color(0f, 0f, 0f, 0.3f), Image.Type.Simple);
if (H.shadowAtFeet > 0f)
{
// 발밑 그림자 — 미리보기 카메라(직교 · 반높이 camOrtho · 높이 camY)에서 모델 원점(발)이 그려지는 y 를 계산해 그 자리에 중심을 둔다.
// 발 = RenderTexture 아래에서 (camOrtho − camY)/(2·camOrtho) 비율 위 · RawImage 는 무대 가운데 + previewY.
float feetFromBottom = (H.camOrtho - H.camY) / (2f * H.camOrtho) * H.previewH;
float feetY = H.previewY - H.previewH * 0.5f + feetFromBottom + H.platformY; // platformY = 미세 보정
Place(plat.rectTransform, new Vector2(0.5f, 0.5f), new Vector2(0f, feetY), new Vector2(H.platformW, H.platformH), new Vector2(0.5f, 0.5f));
}
else Place(plat.rectTransform, new Vector2(0.5f, 0f), new Vector2(0f, H.platformY), new Vector2(H.platformW, H.platformH), new Vector2(0.5f, 0f));
var prt = NewRT(stage, "Preview");
_preview = prt.gameObject.AddComponent<RawImage>(); _preview.raycastTarget = false; _preview.color = Color.white;
Place(prt, new Vector2(0.5f, 0.5f), new Vector2(0f, H.previewY), new Vector2(H.previewW, H.previewH));
try
{
_previewRig = WLEquipPreview.Ensure(H);
_preview.texture = _previewRig.Texture;
_previewRig.SetVisible(true); // OnEnable 은 리그가 생기기 전에 지나갔다 — 여기서 켠다(2026-10-12 스모크: 카메라 꺼진 채 빈 무대)
}
catch (System.Exception ex) { Debug.LogWarning("[WL-846] 3D 미리보기 리그 생성 실패 — " + ex.Message); _preview.enabled = false; }
// 장착 슬롯 — 자리별 부위 = hub.slotTypes(#879 PD 10-16: 왼쪽 무기 · 장갑 · 반지 / 오른쪽 갑옷 · 신발 · 목걸이)
for (int i = 0; i < 6; i++)
{
int slotIndex = i;
bool left = i < 3; int row = i % 3;
var c = NewCell(stage, "Slot" + i, H.slotSize, H.slotIconInset, H.slotFontPx, H.slotPin != 0, true, () => OnSlotClick(slotIndex));
var anchor = left ? new Vector2(0f, 1f) : new Vector2(1f, 1f);
float x = left ? H.slotX + H.slotSize * 0.5f : -(H.slotX + H.slotSize * 0.5f);
float y = -(H.slotTop + row * (H.slotSize + H.slotGap + H.slotNameFontPx + 6f) + H.slotSize * 0.5f);
Place(c.root, anchor, new Vector2(x, y), new Vector2(H.slotSize, H.slotSize), new Vector2(0.5f, 0.5f));
_slots[i] = c;
var nm = NewText(stage, "SlotName" + i, H.slotNameFontPx, TextAlignmentOptions.Top, Col("textDim", Color.white));
Place(nm.rectTransform, anchor, new Vector2(x, y - H.slotSize * 0.5f - 4f), new Vector2(H.slotSize + 40f, H.slotNameFontPx + 8f), new Vector2(0.5f, 1f));
nm.text = WLEquipTables.SlotName(SlotType(i));
_slotNames[i] = nm;
if (H.slotNameFontPx <= 0f) nm.gameObject.SetActive(false); // #881 레퍼런스 = 슬롯 아래 글자 없음(부위 = 핀)
}
// 공격 / 생명 알약(레퍼런스 CP · HP)
_atkText = NewStatPill(stage, "Atk", "atkIcon", "공격", new Vector2(-(H.statPillW * 0.5f + H.statGap * 0.5f), -H.statY), Col("atk", Color.white));
_hpText = NewStatPill(stage, "Hp", "hpIcon", "생명", new Vector2(H.statPillW * 0.5f + H.statGap * 0.5f, -H.statY), Col("hp", Color.white));
// 부위 탭 7 + 합성 버튼 (캐주얼 톤 = 남색 띠 위에 아이콘 · 선택 탭은 밑줄)
if (H.tabStripPad > 0f || Col("tabStrip", new Color(0f, 0f, 0f, 0f)).a > 0f)
AddBand(_hub, "TabStrip", H.tabTop - H.tabStripPad, H.tabTop + H.tabH + H.tabStripPad, "tabStrip");
if (H.headerMode != 0) BuildGridHeader(); // #881 레퍼런스 = 「정렬 · 나의 장비 · 합성」 머리줄(WLEquipGui.RefLayout.cs)
else BuildPartTabs();
// 그리드
_scroll = BuildGrid(_hub, "Grid", H.gridTop, H.gridBottom, out _gridContent);
// 뒤로 가기 · 메시지 · 테스트 획득
BuildHubBottom();
}
void BuildPartTabs()
{
var tabRow = NewRT(_hub, "Tabs");
tabRow.anchorMin = new Vector2(0f, 1f); tabRow.anchorMax = new Vector2(1f, 1f); tabRow.pivot = new Vector2(0.5f, 1f);
tabRow.anchoredPosition = new Vector2(0f, -H.tabTop); tabRow.sizeDelta = new Vector2(0f, H.tabH);
var hl = tabRow.gameObject.AddComponent<HorizontalLayoutGroup>();
hl.padding = new RectOffset((int)H.edge, (int)H.edge, 0, 0); hl.spacing = H.tabGap;
hl.childControlWidth = true; hl.childControlHeight = true; hl.childForceExpandWidth = false; hl.childForceExpandHeight = true; hl.childAlignment = TextAnchor.MiddleLeft;
var tabs = _g.tabs != null && _g.tabs.Count > 0 ? _g.tabs : new List<WLGuiTab> { new WLGuiTab { id = 0, label = "전체" } };
for (int i = 0; i < tabs.Count; i++)
{
int idx = i;
var b = NewButton(tabRow, "", Spr("tabBg"), Color.white, H.tabH, H.tabH, () => { _tab = tabs[idx].id; Refresh(); }, H.tabH * 0.3f, TextCol());
var ic = NewImage((RectTransform)b.transform, "Icon", WLEquipGuiAssets.Get(tabs[i].icon), Color.white, Image.Type.Simple); ic.preserveAspect = true;
Place(ic.rectTransform, new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(H.tabIcon, H.tabIcon));
if (ic.sprite == null) SetButtonLabel(b, tabs[i].label);
Image line = null;
if (H.tabLineH > 0f)
{
line = NewImage((RectTransform)b.transform, "Line", Spr("tabLineSpr"), Col("tabLine", new Color(0.2f, 0.55f, 1f, 1f)));
var lrt = line.rectTransform; lrt.anchorMin = new Vector2(0f, 0f); lrt.anchorMax = new Vector2(1f, 0f); lrt.pivot = new Vector2(0.5f, 0f);
lrt.anchoredPosition = new Vector2(0f, -H.tabStripPad); lrt.sizeDelta = new Vector2(0f, H.tabLineH);
}
_tabBtns.Add(b); _tabIcons.Add(ic); _tabLines.Add(line);
}
var spacer = NewRT(tabRow, "Spacer"); Flex(spacer, 1f);
_composeOpenBtn = NewButton(tabRow, "합성", Spr("btnPrimary"), Color.white, H.composeBtnW, H.composeBtnH, () => OpenCompose(-1), H.composeFontPx, DarkCol());
}
void BuildHubBottom()
{
_backBtn = NewBackButton(_hub, Close);
_cheatBtn = NewButton(_hub, "테스트 획득", Spr("btnNeutral"), Color.white, H.cheatBtnW, H.cheatBtnH, () => { WLEquipDrops.Grant(_cfg.cheatGrantGroupId, _cfg.cheatGrantRolls, _cfg.clearStoneItemIds, _cfg.clearStoneCountEach, 0); Refresh(); }, H.messageFontPx, TextCol());
Place((RectTransform)_cheatBtn.transform, new Vector2(1f, 0f), new Vector2(-H.edge, H.backY + (H.backBtn - H.cheatBtnH) * 0.5f), new Vector2(H.cheatBtnW, H.cheatBtnH), new Vector2(1f, 0f));
_cheatBtn.gameObject.SetActive(_cfg.cheatGrantEnabled != 0);
_message = NewText(_hub, "Message", H.messageFontPx, TextAlignmentOptions.MidlineLeft, Col("textDim", Color.white));
_message.enableWordWrapping = true; _message.overflowMode = TextOverflowModes.Ellipsis;
var mrt = _message.rectTransform; mrt.anchorMin = new Vector2(0f, 0f); mrt.anchorMax = new Vector2(1f, 0f); mrt.pivot = new Vector2(0.5f, 0f);
mrt.offsetMin = new Vector2(BackRight() + 14f, H.backY); mrt.offsetMax = new Vector2(-(H.edge + (_cfg.cheatGrantEnabled != 0 ? H.cheatBtnW + 14f : 0f)), H.backY + H.backBtn);
}
float BackRight() { return (H.backFlushLeft > 0f ? 0f : H.edge) + (H.backBtnW > 0f ? H.backBtnW : H.backBtn); }
/// <summary>뒤로 가기(레퍼런스 = 왼쪽 가장자리에 붙은 어두운 탭 + 화살표).</summary>
Button NewBackButton(RectTransform parent, UnityEngine.Events.UnityAction onClick)
{
float w = H.backBtnW > 0f ? H.backBtnW : H.backBtn;
var b = NewIconButton(parent, "Back", "backIcon", H.backBtn, onClick, "backBtnBg");
Place((RectTransform)b.transform, new Vector2(0f, 0f), new Vector2(H.backFlushLeft > 0f ? 0f : H.edge, H.backY), new Vector2(w, H.backBtn), new Vector2(0f, 0f));
return b;
}
/// <summary>스크롤 그리드(허브·합성 공용) — 패널 배경 + 뷰포트 + GridLayout 콘텐츠.</summary>
ScrollRect BuildGrid(RectTransform parent, string name, float top, float bottom, out RectTransform content)
{
var listGo = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(ScrollRect));
listGo.layer = UILayer; listGo.transform.SetParent(parent, false);
var lrt = (RectTransform)listGo.transform;
lrt.anchorMin = new Vector2(0f, 0f); lrt.anchorMax = new Vector2(1f, 1f); lrt.pivot = new Vector2(0.5f, 1f);
lrt.offsetMin = new Vector2(H.edge, RefH - bottom); lrt.offsetMax = new Vector2(-H.edge, -top);
var listImg = listGo.GetComponent<Image>();
listImg.sprite = Spr("panelBg"); listImg.type = SlicedIf(listImg.sprite); listImg.color = Col("panel", new Color(0f, 0f, 0f, 0.35f)); listImg.raycastTarget = true;
var scroll = listGo.GetComponent<ScrollRect>();
var viewport = new GameObject("Viewport", typeof(RectTransform), typeof(Image), typeof(RectMask2D));
viewport.layer = UILayer; viewport.transform.SetParent(listGo.transform, false);
var vrt = (RectTransform)viewport.transform; Stretch(vrt, 8f, 8f, 8f, 8f);
viewport.GetComponent<Image>().color = new Color(0f, 0f, 0f, 0.01f);
var cgo = new GameObject("Content", typeof(RectTransform), typeof(GridLayoutGroup), typeof(ContentSizeFitter));
cgo.layer = UILayer; cgo.transform.SetParent(viewport.transform, false);
content = (RectTransform)cgo.transform;
content.anchorMin = new Vector2(0f, 1f); content.anchorMax = new Vector2(1f, 1f); content.pivot = new Vector2(0.5f, 1f);
content.offsetMin = Vector2.zero; content.offsetMax = Vector2.zero;
var grid = cgo.GetComponent<GridLayoutGroup>();
grid.cellSize = new Vector2(H.gridCell, H.gridCell);
grid.spacing = new Vector2(H.gridGap, H.gridGap);
grid.padding = new RectOffset((int)H.gridPad, (int)H.gridPad, (int)H.gridPad, (int)H.gridPad);
grid.constraint = GridLayoutGroup.Constraint.FixedColumnCount; grid.constraintCount = Mathf.Max(1, H.gridCols);
grid.childAlignment = TextAnchor.UpperCenter;
cgo.GetComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
scroll.content = content; scroll.viewport = vrt; scroll.horizontal = false; scroll.vertical = true;
scroll.movementType = ScrollRect.MovementType.Clamped; scroll.scrollSensitivity = 40f;
return scroll;
}
/// <summary>재화 알약(Layer Lab status_frame) — 왼쪽에 아이콘이 튀어나오고 오른쪽 끝에 「+」 버튼(열쇠 = 상자 시트).</summary>
TextMeshProUGUI NewPill(RectTransform parent, string name, string iconKey, Vector2 anchor, Vector2 pos, bool withAdd, UnityEngine.Events.UnityAction onAdd)
{
var box = NewImage(parent, name, Spr("pillBg"), Col("pill", new Color(0.1f, 0.08f, 0.13f, 0.92f)));
Place(box.rectTransform, anchor, pos, new Vector2(H.pillW, H.pillH), new Vector2(1f, 1f));
var icon = NewImage(box.rectTransform, "Icon", Spr(iconKey), Color.white, Image.Type.Simple); icon.preserveAspect = true;
Place(icon.rectTransform, new Vector2(0f, 0.5f), new Vector2(-H.pillIcon * 0.25f, 0f), new Vector2(H.pillIcon, H.pillIcon), new Vector2(0f, 0.5f));
var t = NewText(box.rectTransform, "Value", H.pillFontPx, TextAlignmentOptions.MidlineRight, TextCol());
t.fontStyle = FontStyles.Bold;
var trt = t.rectTransform; trt.anchorMin = Vector2.zero; trt.anchorMax = Vector2.one; trt.offsetMin = new Vector2(H.pillIcon * 0.8f, 0f); trt.offsetMax = new Vector2(withAdd ? -(H.pillAddBtn * 0.6f + 6f) : -14f, 0f);
if (withAdd)
{
var add = NewButton(box.rectTransform, "", Spr("pillAdd"), Color.white, H.pillAddBtn, H.pillAddBtn, onAdd, H.pillFontPx + 8f, TextCol()); MakeSimple(add);
Place((RectTransform)add.transform, new Vector2(1f, 0.5f), new Vector2(H.pillAddBtn * 0.3f, 0f), new Vector2(H.pillAddBtn, H.pillAddBtn), new Vector2(1f, 0.5f));
var plus = Spr("pillAddIcon");
if (plus != null) { var pi = NewImage((RectTransform)add.transform, "Icon", plus, Color.white, Image.Type.Simple); pi.preserveAspect = true; Stretch(pi.rectTransform, 10f, 10f, 10f, 10f); }
else SetButtonLabel(add, "+");
}
return t;
}
/// <summary>공격/생명 알약 — 아이콘 + 작은 제목 + 큰 수치. 누르면 전체 스탯 한 줄을 메시지에 띄운다.</summary>
TextMeshProUGUI NewStatPill(RectTransform parent, string name, string iconKey, string caption, Vector2 pos, Color captionCol)
{
var box = NewImage(parent, name, Spr("statPill"), Col("statPill", new Color(0.1f, 0.08f, 0.13f, 0.92f))); box.raycastTarget = true;
Place(box.rectTransform, new Vector2(0.5f, 1f), pos, new Vector2(H.statPillW, H.statPillH), new Vector2(0.5f, 0.5f));
var b = box.gameObject.AddComponent<Button>(); b.targetGraphic = box; b.onClick.AddListener(() => { _message.text = WLEquipStats.StatusLine(); });
var icon = NewImage(box.rectTransform, "Icon", Spr(iconKey), Color.white, Image.Type.Simple); icon.preserveAspect = true;
Place(icon.rectTransform, new Vector2(0f, 0.5f), new Vector2(-H.statIcon * 0.2f, 0f), new Vector2(H.statIcon, H.statIcon), new Vector2(0f, 0.5f));
var cap = NewText(box.rectTransform, "Caption", H.statFontPx * 0.55f, TextAlignmentOptions.BottomLeft, captionCol); cap.text = caption; cap.fontStyle = FontStyles.Bold;
var crt = cap.rectTransform; crt.anchorMin = new Vector2(0f, 0f); crt.anchorMax = new Vector2(0.42f, 1f); crt.offsetMin = new Vector2(H.statIcon * 0.9f, 10f); crt.offsetMax = new Vector2(0f, -8f);
var t = NewText(box.rectTransform, "Value", H.statFontPx, TextAlignmentOptions.MidlineRight, TextCol()); t.fontStyle = FontStyles.Bold;
var trt = t.rectTransform; trt.anchorMin = new Vector2(0.35f, 0f); trt.anchorMax = new Vector2(1f, 1f); trt.offsetMin = Vector2.zero; trt.offsetMax = new Vector2(-18f, 0f);
return t;
}
/// <summary>팝업·시트 상자 — Layer Lab popup_01_frame 은 속이 비쳐 보이므로 어두운 바탕을 먼저 깔고 프레임을 얹는다.</summary>
Image NewPopupBox(RectTransform parent, string name, Vector2 size)
{
var fill = NewImage(parent, name, Spr("innerBox"), Col("popupFill", new Color(0.14f, 0.11f, 0.17f, 0.98f))); fill.raycastTarget = true;
var frt = fill.rectTransform; frt.anchorMin = frt.anchorMax = new Vector2(0.5f, 0.5f); frt.pivot = new Vector2(0.5f, 0.5f); frt.sizeDelta = size - new Vector2(28f, 28f);
var frame = NewImage(parent, name + "Frame", Spr("popupBg"), Color.white);
var brt = frame.rectTransform; brt.anchorMin = brt.anchorMax = new Vector2(0.5f, 0.5f); brt.pivot = new Vector2(0.5f, 0.5f); brt.sizeDelta = size;
return frame;
}
Button NewIconButton(RectTransform parent, string name, string iconKey, float size, UnityEngine.Events.UnityAction onClick, string bgKey = "squareBtn")
{
var b = NewButton(parent, name, Spr(bgKey) ?? Spr("squareBtn"), Color.white, size, size, onClick, size * 0.3f, TextCol());
var ic = NewImage((RectTransform)b.transform, "Icon", Spr(iconKey), Color.white, Image.Type.Simple); ic.preserveAspect = true;
Place(ic.rectTransform, new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(size * 0.6f, size * 0.6f));
SetButtonLabel(b, ic.sprite == null ? "<" : "");
return b;
}
// ── 상세 팝업(Layer Lab popup_01_frame — 위 150 px 띠가 제목 자리) ──
void BuildDetailPopup()
{
_popup = new GameObject("DetailPopup", typeof(RectTransform)); _popup.layer = UILayer; _popup.transform.SetParent(_root, false);
var prt = (RectTransform)_popup.transform; Stretch(prt);
var dim = NewImage(prt, "Dim", null, Col("dim", new Color(0f, 0f, 0f, 0.7f)), Image.Type.Simple); Stretch(dim.rectTransform); dim.raycastTarget = true;
var dimBtn = dim.gameObject.AddComponent<Button>(); dimBtn.targetGraphic = dim; dimBtn.onClick.AddListener(() => _popup.SetActive(false));
var box = NewPopupBox(prt, "Box", new Vector2(L.popupW, L.popupH));
var brt = box.rectTransform;
// 제목 띠
var titleArea = NewRT(brt, "TitleArea");
titleArea.anchorMin = new Vector2(0f, 1f); titleArea.anchorMax = new Vector2(1f, 1f); titleArea.pivot = new Vector2(0.5f, 1f);
titleArea.anchoredPosition = Vector2.zero; titleArea.sizeDelta = new Vector2(0f, L.popupTitleH);
_popupName = NewText(titleArea, "Name", L.popupNameFontPx, TextAlignmentOptions.Midline, TextCol()); _popupName.fontStyle = FontStyles.Bold;
var nrt = _popupName.rectTransform; nrt.anchorMin = new Vector2(0f, 0.35f); nrt.anchorMax = new Vector2(1f, 1f); nrt.offsetMin = new Vector2(110f, 0f); nrt.offsetMax = new Vector2(-110f, -14f);
_popupRarityImg = NewImage(titleArea, "RarityBg", Spr("gradeLabel"), Color.white);
Place(_popupRarityImg.rectTransform, new Vector2(0.5f, 0f), new Vector2(0f, 6f), new Vector2(300f, 48f), new Vector2(0.5f, 0f));
_popupRarity = NewText(_popupRarityImg.rectTransform, "Rarity", L.popupRarityFontPx, TextAlignmentOptions.Midline, Color.white); _popupRarity.fontStyle = FontStyles.Bold; Stretch(_popupRarity.rectTransform);
var closeBtn = NewIconButton(titleArea, "Close", "closeIcon", L.closeBtnW, () => _popup.SetActive(false), "closeBtnBg");
Place((RectTransform)closeBtn.transform, new Vector2(1f, 1f), new Vector2(-14f, -14f), new Vector2(L.closeBtnW, L.closeBtnH), new Vector2(1f, 1f));
// 본문(세로 레이아웃)
var body = NewRT(brt, "Body");
body.anchorMin = new Vector2(0f, 0f); body.anchorMax = new Vector2(1f, 1f); body.offsetMin = new Vector2(36f, 36f); body.offsetMax = new Vector2(-36f, -L.popupTitleH - 10f);
var vl = body.gameObject.AddComponent<VerticalLayoutGroup>();
vl.padding = new RectOffset(0, 0, 0, 0); vl.spacing = 14f;
vl.childControlWidth = true; vl.childControlHeight = true; vl.childForceExpandWidth = true; vl.childForceExpandHeight = false;
var header = NewImage(body, "Header", Spr("innerBox"), Col("innerBox", new Color(0f, 0f, 0f, 0.35f))); Pref(header.rectTransform, 0f, L.headerH);
_popupForm = NewCell(header.rectTransform, "Form", L.headerForm, L.cellIconInset, L.cellFontPx, true, false, null);
Place(_popupForm.root, new Vector2(0f, 0.5f), new Vector2(14f, 0f), new Vector2(L.headerForm, L.headerForm), new Vector2(0f, 0.5f));
_popupHeaderName = NewText(header.rectTransform, "HName", L.headerNameFontPx, TextAlignmentOptions.TopLeft, TextCol()); _popupHeaderName.fontStyle = FontStyles.Bold;
var hn = _popupHeaderName.rectTransform; hn.anchorMin = new Vector2(0f, 1f); hn.anchorMax = new Vector2(1f, 1f); hn.pivot = new Vector2(0f, 1f); hn.offsetMin = new Vector2(L.headerForm + 30f, -64f); hn.offsetMax = new Vector2(-12f, -14f);
_popupHeaderSub = NewText(header.rectTransform, "HSub", L.headerDpsFontPx, TextAlignmentOptions.TopLeft, Col("textDim", Color.white));
var hs = _popupHeaderSub.rectTransform; hs.anchorMin = new Vector2(0f, 1f); hs.anchorMax = new Vector2(1f, 1f); hs.pivot = new Vector2(0f, 1f); hs.offsetMin = new Vector2(L.headerForm + 30f, -112f); hs.offsetMax = new Vector2(-12f, -66f);
var dpsBox = NewImage(header.rectTransform, "DpsBox", Spr("innerBox"), Col("dpsBox", new Color(0f, 0f, 0f, 0.5f)));
var drt = dpsBox.rectTransform; drt.anchorMin = new Vector2(0f, 0f); drt.anchorMax = new Vector2(1f, 0f); drt.pivot = new Vector2(0f, 0f); drt.offsetMin = new Vector2(L.headerForm + 30f, 14f); drt.offsetMax = new Vector2(-14f, 14f + L.headerDpsH);
_popupDps = NewText(drt, "Dps", L.headerDpsFontPx, TextAlignmentOptions.MidlineLeft, TextCol()); _popupDps.fontStyle = FontStyles.Bold; Stretch(_popupDps.rectTransform, 16f, 2f, 16f, 2f);
var opt = NewImage(body, "Options", Spr("innerBox"), Col("innerBox", new Color(0f, 0f, 0f, 0.35f)));
var ole = opt.gameObject.AddComponent<LayoutElement>(); ole.flexibleHeight = 1f; ole.minHeight = 200f;
_popupOptions = NewText(opt.rectTransform, "Text", L.optionFontPx, TextAlignmentOptions.TopLeft, TextCol()); Stretch(_popupOptions.rectTransform, 20f, 16f, 20f, 16f);
_popupOptions.overflowMode = TextOverflowModes.Truncate; _popupOptions.enableWordWrapping = true;
var b1 = NewRow(body, "Buttons1", L.popupBtnH, 10f);
_equipBtn = NewButton(b1, "장착", Spr("btnPrimary"), Color.white, 0f, L.popupBtnH, OnEquipToggle, L.popupBtnFontPx, DarkCol()); Flex((RectTransform)_equipBtn.transform, 1f);
_equipLabel = _equipBtn.GetComponentInChildren<TextMeshProUGUI>();
_lvBtn = NewButton(b1, "강화 +1", Spr("btnSecondary"), Color.white, 0f, L.popupBtnH, () => Act(() => WLInventory.ItemLevelUp(_selectedId, false)), L.popupBtnFontPx, TextCol()); Flex((RectTransform)_lvBtn.transform, 1f);
_lvAllBtn = NewButton(b1, "전부 강화", Spr("btnSecondary"), Color.white, 0f, L.popupBtnH, () => Act(() => WLInventory.ItemLevelUp(_selectedId, true)), L.popupBtnFontPx, TextCol()); Flex((RectTransform)_lvAllBtn.transform, 1f);
var b2 = NewRow(body, "Buttons2", L.popupBtnH, 10f);
_composeBtn = NewButton(b2, "합성", Spr("btnBlue"), Color.white, 0f, L.popupBtnH, () => OpenCompose(_selectedId), L.popupBtnFontPx, TextCol()); Flex((RectTransform)_composeBtn.transform, 1f);
_downBtn = NewButton(b2, "강등", Spr("btnDanger"), Color.white, 0f, L.popupBtnH, () => Act(() => WLInventory.Downgrade(_selectedId)), L.popupBtnFontPx, TextCol()); Flex((RectTransform)_downBtn.transform, 1f);
var closeBtn2 = NewButton(b2, "닫기", Spr("btnNeutral"), Color.white, 0f, L.popupBtnH, () => _popup.SetActive(false), L.popupBtnFontPx, TextCol()); Flex((RectTransform)closeBtn2.transform, 1f);
_popup.SetActive(false);
}
// ── 셀(그리드 · 장착 슬롯 · 결과 카드 공용) ─────────────────────
/// <param name="withPin">좌상단 부위 핀(그리드용)</param>
/// <param name="slotStyle">장착 슬롯(프레임 = slotFrame · 빈 칸에 부위 아이콘·이름)</param>
Cell NewCell(RectTransform parent, string name, float size, float iconInset, float fontPx, bool withPin, bool slotStyle, UnityEngine.Events.UnityAction onClick)
{
var c = new Cell(); c.size = size; c.slotStyle = slotStyle;
c.root = NewRT(parent, name);
var le = c.root.gameObject.AddComponent<LayoutElement>(); le.preferredWidth = size; le.minWidth = size; le.preferredHeight = size; le.minHeight = size; le.flexibleHeight = 0f;
// 선택 글로우(맨 뒤 · 셀보다 크게 → 테두리 밖으로 번지는 빛)
c.glow = NewImage(c.root, "Glow", Spr("selectGlow"), Col("selectGlow", new Color(1f, 0.9f, 0.45f, 1f)));
Stretch(c.glow.rectTransform, -16f, -16f, -16f, -16f); c.glow.gameObject.SetActive(false);
// 등급 색 판(Layer Lab 흰 프레임은 속이 찬 모양이라 등급 색으로 물들여 바닥에 깔고, 그 위에 어두운 속을 조금 작게 얹어 테두리만 남긴다) — 버튼 본체
c.frame = NewImage(c.root, "Frame", Spr(slotStyle ? "slotFrame" : "cellFrame"), Col("frameEmpty", Color.gray));
Stretch(c.frame.rectTransform); c.frame.raycastTarget = true;
if (onClick != null) { c.btn = c.frame.gameObject.AddComponent<Button>(); c.btn.targetGraphic = c.frame; c.btn.onClick.AddListener(onClick); }
// 어두운 속(테두리 두께 = frameRim)
float rim = slotStyle ? H.slotRim : H.cellRim;
c.back = NewImage(c.root, "Back", Spr(slotStyle ? "slotInner" : "cellBack"), Col(slotStyle ? "slotInner" : "cellBack", new Color(0.12f, 0.1f, 0.15f, 1f)));
Stretch(c.back.rectTransform, rim, rim, rim, rim);
// 아이콘
c.icon = NewImage(c.root, "Icon", null, Color.white, Image.Type.Simple); c.icon.preserveAspect = true;
Stretch(c.icon.rectTransform, iconInset, iconInset, iconInset, iconInset);
// 어둡게(합성 화면에서 재료가 아닌 것) + 자물쇠
c.dim = NewImage(c.root, "Dim", null, Col("dimCell", new Color(0f, 0f, 0f, 0.62f)), Image.Type.Simple); Stretch(c.dim.rectTransform, 4f, 4f, 4f, 4f);
c.lockIcon = NewImage(c.dim.rectTransform, "Lock", Spr("lockIcon"), new Color(1f, 1f, 1f, 0.85f), Image.Type.Simple); c.lockIcon.preserveAspect = true;
Place(c.lockIcon.rectTransform, new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(size * 0.36f, size * 0.36f));
c.dim.gameObject.SetActive(false);
// 좌상단 부위 핀
if (withPin)
{
c.pinBg = NewImage(c.root, "PinBg", Spr("pinBg"), Col("pinBg", new Color(0.2f, 0.18f, 0.24f, 0.95f)));
Place(c.pinBg.rectTransform, new Vector2(0f, 1f), new Vector2(4f, -4f), new Vector2(H.cellPin, H.cellPin), new Vector2(0f, 1f));
c.pin = NewImage(c.pinBg.rectTransform, "Pin", null, Color.white, Image.Type.Simple); c.pin.preserveAspect = true; Stretch(c.pin.rectTransform, 7f, 7f, 7f, 7f);
c.pinBg.gameObject.SetActive(false);
}
// 레벨(슬롯 = 좌상단 · 그리드 = 우하단)
float lvH = slotStyle ? H.slotLvH : H.cellLvH;
c.lvBg = NewImage(c.root, "LvBg", Spr("lvBg"), Col("lvBg", new Color(0f, 0f, 0f, 0.65f)));
if (slotStyle) Place(c.lvBg.rectTransform, new Vector2(0f, 1f), new Vector2(6f, -6f), new Vector2(size * 0.5f, lvH), new Vector2(0f, 1f));
else Place(c.lvBg.rectTransform, new Vector2(1f, 0f), new Vector2(-6f, 6f), new Vector2(size * 0.5f, lvH), new Vector2(1f, 0f));
c.level = NewText(c.lvBg.rectTransform, "Level", fontPx - 2f, slotStyle ? TextAlignmentOptions.Midline : TextAlignmentOptions.MidlineRight, Color.white); c.level.fontStyle = FontStyles.Bold; Stretch(c.level.rectTransform); ApplyOutline(c.level);
c.lvBg.gameObject.SetActive(false);
// 장착됨 태그(가운데 띠)
c.tagBg = NewImage(c.root, "TagBg", Spr("tag"), Col("tagBg", new Color(0.08f, 0.08f, 0.1f, 0.9f)));
var tgr = c.tagBg.rectTransform; tgr.anchorMin = new Vector2(0f, 0.5f); tgr.anchorMax = new Vector2(1f, 0.5f); tgr.pivot = new Vector2(0.5f, 0.5f);
tgr.anchoredPosition = new Vector2(0f, -size * 0.08f); tgr.sizeDelta = new Vector2(-8f, H.cellTagH);
c.tag = NewText(tgr, "Tag", fontPx, TextAlignmentOptions.Midline, Color.white); c.tag.fontStyle = FontStyles.Bold; Stretch(c.tag.rectTransform); ApplyOutline(c.tag);
c.tagBg.gameObject.SetActive(false);
// 우상단 초록 배지(강화 가능 ↑)
float bs = slotStyle ? H.slotBadge : H.cellPin;
c.badge = NewImage(c.root, "Badge", Spr("badgeBg") ?? Spr("pinBg"), Col("upBadge", new Color(0.35f, 0.85f, 0.3f, 1f)), Image.Type.Simple); c.badge.preserveAspect = true;
Place(c.badge.rectTransform, new Vector2(1f, 1f), new Vector2(bs * 0.25f, bs * 0.25f), new Vector2(bs, bs), new Vector2(1f, 1f));
c.badgeIcon = NewImage(c.badge.rectTransform, "Icon", Spr("badgeIcon"), Color.white, Image.Type.Simple); c.badgeIcon.preserveAspect = true; Stretch(c.badgeIcon.rectTransform, 8f, 8f, 8f, 8f);
c.badgeIcon.gameObject.SetActive(c.badgeIcon.sprite != null);
c.badge.gameObject.SetActive(false);
// S 등급 표식(우상단 · 배지 없을 때)
c.sBadge = NewImage(c.root, "S", Spr("sBadge"), new Color(1f, 0.85f, 0.3f, 1f), Image.Type.Simple); c.sBadge.preserveAspect = true;
Place(c.sBadge.rectTransform, new Vector2(1f, 1f), new Vector2(-4f, -4f), new Vector2(bs * 0.8f, bs * 0.8f), new Vector2(1f, 1f));
c.sBadge.gameObject.SetActive(false);
// 체크(합성 재료 선택)
c.check = NewImage(c.root, "Check", Spr("checkBg") ?? Spr("pinBg"), Col("check", new Color(0.4f, 0.9f, 0.35f, 1f)));
Place(c.check.rectTransform, new Vector2(1f, 1f), new Vector2(bs * 0.25f, bs * 0.25f), new Vector2(bs, bs), new Vector2(1f, 1f));
var ck = NewImage(c.check.rectTransform, "Icon", Spr("checkIcon"), Color.white, Image.Type.Simple); ck.preserveAspect = true; Stretch(ck.rectTransform, 8f, 8f, 8f, 8f);
c.check.gameObject.SetActive(false);
// 빈 슬롯 글자
if (slotStyle)
{
c.empty = NewText(c.root, "Empty", fontPx, TextAlignmentOptions.Bottom, Col("textDim", Color.white));
Stretch(c.empty.rectTransform, 6f, 6f, 6f, 10f); c.empty.gameObject.SetActive(false);
}
if (H.cellRefLayout != 0) ApplyRefCellLayout(c, size, slotStyle);
return c;
}
/// <summary>#881 PD 레퍼런스 칸 — Lv = 오른쪽 위 · S = 왼쪽 아래 · +단계 = 아래 가운데 마름모(부위 핀 = 왼쪽 위 그대로).</summary>
void ApplyRefCellLayout(Cell c, float size, bool slotStyle)
{
float lvH = slotStyle ? H.slotLvH : H.cellLvH;
Place(c.lvBg.rectTransform, new Vector2(1f, 1f), new Vector2(-6f, -6f), new Vector2(size * 0.5f, lvH), new Vector2(1f, 1f));
c.lvBg.color = new Color(0f, 0f, 0f, 0f); // 레퍼런스 = 바탕 없이 외곽선 글자
c.level.alignment = TextAlignmentOptions.MidlineRight;
float bs = slotStyle ? H.slotBadge : H.cellPin;
Place(c.sBadge.rectTransform, new Vector2(0f, 0f), new Vector2(4f, 4f), new Vector2(bs * 0.8f, bs * 0.8f), new Vector2(0f, 0f));
c.plusBg = NewImage(c.root, "PlusBg", null, Col("plusBg", new Color(0.16f, 0.12f, 0.3f, 1f)), Image.Type.Simple);
Place(c.plusBg.rectTransform, new Vector2(0.5f, 0f), new Vector2(0f, 0f), new Vector2(H.plusSize * 0.72f, H.plusSize * 0.72f), new Vector2(0.5f, 0.5f));
c.plusBg.rectTransform.localRotation = Quaternion.Euler(0f, 0f, 45f);
c.plus = NewText(c.root, "Plus", H.plusFontPx, TextAlignmentOptions.Midline, Color.white); c.plus.fontStyle = FontStyles.Bold; ApplyOutline(c.plus);
Place(c.plus.rectTransform, new Vector2(0.5f, 0f), new Vector2(0f, 0f), new Vector2(H.plusSize, H.plusSize), new Vector2(0.5f, 0.5f));
c.plusBg.gameObject.SetActive(false); c.plus.gameObject.SetActive(false);
}
void SetPlus(Cell c, int plus)
{
if (c.plusBg == null) return;
bool on = plus > 0;
SetOn(c.plusBg, on); SetOn(c.plus, on);
if (on) c.plus.text = plus.ToString();
}
static void SetOn(Component c, bool on) { if (c != null && c.gameObject.activeSelf != on) c.gameObject.SetActive(on); }
/// <summary>
/// 칸 바탕 — tier > 0 = 등급 · 0 = 빈 칸 · -1 = 소모품.
/// tintTierBg 1 = 흰 프레임 1장에 등급 색을 곱한다(판타지 키트) · 0 = 등급마다 색 칸 그림을 그대로 쓴다(캐주얼 키트 ItemFrame01_Single_*).
/// </summary>
void PaintFrame(Cell c, int tier)
{
if (_g.tintTierBg != 0)
{
c.frame.sprite = Spr(c.slotStyle ? "slotFrame" : "cellFrame"); c.frame.type = SlicedIf(c.frame.sprite);
c.frame.color = tier > 0 ? WLEquipIcons.TierColor(tier) : Col("frameEmpty", Color.gray);
return;
}
Sprite s = tier > 0 ? WLEquipIcons.TierBg(tier) : Spr(tier < 0 ? "cellConsumable" : "cellEmpty");
if (s == null) s = Spr("cellEmpty");
c.frame.sprite = s; c.frame.type = SlicedIf(s);
c.frame.color = s != null ? (tier == 0 ? Col("cellEmptyTint", Color.white) : Color.white) : (tier > 0 ? WLEquipIcons.TierColor(tier) : Col("frameEmpty", Color.gray));
}
Material _outlineMat;
/// <summary>글자 외곽선 — 글자마다 머티리얼 사본을 만들지 않도록 공유 머티리얼 1장(모바일 드로우콜·메모리).</summary>
void ApplyOutline(TextMeshProUGUI t)
{
if (t == null || _g.textOutline <= 0f || _font == null) return;
if (_outlineMat == null)
{
_outlineMat = new Material(_font.material) { name = "WL_EquipGui_Outline" };
_outlineMat.EnableKeyword(ShaderUtilities.Keyword_Outline);
_outlineMat.SetFloat(ShaderUtilities.ID_OutlineWidth, _g.textOutline);
_outlineMat.SetColor(ShaderUtilities.ID_OutlineColor, Col("textOutline", new Color(0.05f, 0.08f, 0.2f, 1f)));
_outlineMat.SetFloat(ShaderUtilities.ID_FaceDilate, _g.textOutline * 0.5f);
}
t.fontSharedMaterial = _outlineMat;
}
/// <summary>아이템 1건을 셀에 그린다. tagEquipped = 장착 중이면 「장착됨」 띠.</summary>
void BindCell(Cell c, Item it, bool tagEquipped)
{
c.id = it != null ? it.rawItem._id : -1;
bool has = it != null;
SetOn(c.lvBg, false); SetOn(c.tagBg, false); SetOn(c.badge, false); SetOn(c.sBadge, false); SetOn(c.check, false); SetOn(c.dim, false); SetOn(c.pinBg, false);
SetOn(c.glow, false); SetPlus(c, 0);
if (c.empty != null) SetOn(c.empty, false);
PaintFrame(c, 0);
c.icon.color = Color.white;
SetOn(c.icon, has);
if (!has) return;
var j = it.itemJson;
int tier = 1, plus = 0; WLGradeTier t = null;
if (j.IsEquipment && WLEquipGrades.TryMap(j, out t, out plus)) tier = t.tier;
PaintFrame(c, j.IsEquipment ? tier : -1);
Color tint; var icon = WLEquipIcons.For(j, out tint);
c.icon.sprite = icon; c.icon.color = icon != null ? tint : new Color(1f, 1f, 1f, 0.15f);
if (j.IsEquipment)
{
SetOn(c.lvBg, true); c.level.text = "Lv." + it.rawItem.level + (plus > 0 && c.plusBg == null ? " +" + plus : "");
SetPlus(c, plus);
if (c.pinBg != null) { var ps = WLEquipIcons.PartIcon(j.EquipmentType); c.pin.sprite = ps; SetOn(c.pinBg, ps != null); }
if (t != null && t.isS != 0) SetOn(c.sBadge, true);
if (tagEquipped && WLInventory.IsEquipped(c.id)) { SetOn(c.tagBg, true); c.tag.text = "장착됨"; }
}
else { SetOn(c.lvBg, true); c.level.text = "×" + it.rawItem.count; }
}
/// <summary>표 행(ItemJson)만으로 셀을 그린다 — 합성 결과 미리보기.</summary>
void BindCellJson(Cell c, ItemJson j, int level)
{
c.id = -1;
SetOn(c.lvBg, false); SetOn(c.tagBg, false); SetOn(c.badge, false); SetOn(c.sBadge, false); SetOn(c.check, false); SetOn(c.dim, false); SetOn(c.pinBg, false); SetOn(c.glow, false);
SetPlus(c, 0);
if (c.empty != null) SetOn(c.empty, false);
PaintFrame(c, 0);
SetOn(c.icon, j != null);
if (j == null) return;
int tier = 1, plus = 0; WLGradeTier t = null;
if (WLEquipGrades.TryMap(j, out t, out plus)) tier = t.tier;
PaintFrame(c, tier);
Color tint; var icon = WLEquipIcons.For(j, out tint);
c.icon.sprite = icon; c.icon.color = icon != null ? tint : new Color(1f, 1f, 1f, 0.15f);
SetOn(c.lvBg, true); c.level.text = "Lv." + level + (plus > 0 && c.plusBg == null ? " +" + plus : "");
SetPlus(c, plus);
if (t != null && t.isS != 0) SetOn(c.sBadge, true);
}
/// <summary>장착 슬롯 — 비어 있으면 부위 아이콘을 흐리게 + 이름.</summary>
void BindSlot(int i)
{
var slot = SlotType(i);
var c = _slots[i];
var it = WLInventory.EquippedInSlot(slot);
BindCell(c, it, false);
if (it == null)
{
SetOn(c.icon, true); c.icon.sprite = WLEquipIcons.PartIcon(slot); c.icon.color = new Color(1f, 1f, 1f, c.icon.sprite != null ? 0.28f : 0f);
return; // 이름은 칸 아래 라벨(_slotNames)이 맡는다
}
SetOn(c.badge, CanLevelUpNow(it));
if (c.badge.gameObject.activeSelf) SetOn(c.sBadge, false);
SetOn(c.glow, it.rawItem._id == _selectedId && _popup.activeSelf);
}
/// <summary>지금 가진 골드·강화석으로 다음 강화가 가능한가(슬롯의 초록 ↑ 배지).</summary>
static bool CanLevelUpNow(Item it)
{
var cost = WLInventory.NextLevelCost(it);
if (cost == null) return false;
if (WLInventory.GetGold() < cost.MoneyCountRequired) return false;
if (cost.ItemKeyRequired > 0 && cost.ItemCountRequired > 0 && WLInventory.GetItemCountByIndex(cost.ItemKeyRequired) < cost.ItemCountRequired) return false;
return true;
}
// ── 갱신 ─────────────────────────────────────────────────────────
void ShowHub()
{
if (_cView != null) _cView.gameObject.SetActive(false);
_hub.gameObject.SetActive(true);
_popup.SetActive(false);
ClosePartSkillPopups();
Refresh();
}
public void Refresh()
{
if (!gameObject.activeSelf) return;
if (!WLInventory.EnsureLoaded()) { if (_message != null) _message.text = "장비 표 로드 실패 — " + WLEquipTables.LastLog; return; }
try { RefreshInner(); }
catch (System.Exception ex) { Debug.LogError("[WL-824] Refresh 예외 — " + ex); }
}
void RefreshInner()
{
_coinText.text = Short(WLInventory.GetGold());
_keyText.text = WLInventory.GetKeys().ToString();
long stones = 0;
if (_cfg.clearStoneItemIds != null) for (int i = 0; i < _cfg.clearStoneItemIds.Length; i++) stones += WLInventory.GetItemCountByIndex(_cfg.clearStoneItemIds[i]);
_stoneText.text = stones.ToString();
var info = WLEquipStats.LivePcInfo();
if (info != null) { try { WLEquipStats.EnforceBaseAttack(info); } catch { } }
_atkText.text = info != null ? Short(info.Get_Stat(eStat.ATK)) : "-";
_hpText.text = info != null ? Short(info.Get_Stat(eStat.MaxHP)) : "-";
for (int i = 0; i < 6; i++) BindSlot(i);
RefreshPartSkillUi(); // #879 슬롯 능력치 · 스킬 뱃지 · 장착 스킬 줄 · 시너지 알림
var tabs = _g.tabs;
for (int i = 0; i < _tabBtns.Count; i++)
{
bool on = tabs != null && i < tabs.Count && tabs[i].id == _tab;
var img = _tabBtns[i].GetComponent<Image>();
if (img != null) img.color = Col(on ? "tabOnBg" : "tabOffBg", on ? Color.white : new Color(0.62f, 0.6f, 0.66f, 1f));
_tabIcons[i].color = Col(on ? "tabOn" : "tabOff", Color.white);
_tabBtns[i].transform.localScale = on ? Vector3.one * 1.08f : Vector3.one;
if (i < _tabLines.Count && _tabLines[i] != null) SetOn(_tabLines[i], on);
}
var items = GridItems(WLInventory.SortedItems()); // #881 장착분 빼기 · 정렬(머리줄)
RefreshGridHeader();
int n = 0;
for (int i = 0; i < items.Count; i++)
{
var it = items[i];
if (_tab != 0 && (!it.itemJson.IsEquipment || (int)it.itemJson.EquipmentType != _tab)) continue;
if (n >= _cells.Count)
{
int idx = n;
_cells.Add(NewCell(_gridContent, "Cell" + n, H.gridCell, H.cellIconInset, H.cellFontPx, true, false, () => OnCellClick(idx)));
}
var c = _cells[n];
c.root.gameObject.SetActive(true);
BindCell(c, it, true);
SetOn(c.glow, c.id == _selectedId && _popup.activeSelf);
n++;
if (n >= _cfg.maxListRows) break;
}
for (int i = n; i < _cells.Count; i++) _cells[i].root.gameObject.SetActive(false);
if (_selectedId >= 0 && WLInventory.FindItem(_selectedId) == null) { _selectedId = -1; _popup.SetActive(false); }
_message.text = WLInventory.LastMessage;
if (_previewRig != null) { try { _previewRig.Refresh(); } catch (System.Exception ex) { Debug.LogWarning("[WL-846] 미리보기 갱신 실패 — " + ex.Message); } }
if (_popup.activeSelf) RefreshPopup();
if (_boxPopup != null && _boxPopup.activeSelf) RefreshBox();
if (_cView != null && _cView.gameObject.activeSelf) RefreshCompose();
}
void RefreshPopup()
{
var sel = WLInventory.FindItem(_selectedId);
if (sel == null) { _popup.SetActive(false); return; }
if (IpOn) { RefreshItemPopupV2(sel); return; } // #881 레퍼런스 팝업
var j = sel.itemJson;
BindCell(_popupForm, sel, false);
_popupName.text = j.Item_name;
int tier = 1, plus = 0; WLGradeTier t = null;
if (j.IsEquipment && WLEquipGrades.TryMap(j, out t, out plus)) tier = t.tier;
_popupRarityImg.color = j.IsEquipment ? TierCol(tier) : Col("frameEmpty", Color.gray);
_popupRarity.text = j.IsEquipment ? WLEquipGrades.Label(j, false) : "소모품";
bool isEq = j.IsEquipment;
if (isEq)
{
_popupHeaderName.text = j.Item_name + " <size=80%>" + WLEquipTables.SlotName(j.EquipmentType) + "</size>";
_popupHeaderSub.text = "Lv " + sel.rawItem.level + " / " + j.ItemMaxLv + (WLInventory.IsEquipped(sel.rawItem._id) ? " · 장착 중" : "");
eStat o; double bv, bonus; string line;
_popupDps.text = WLEquipStats.ItemValues(sel, WLEquipStats.CurrentMainStat(), out o, out bv, out bonus, out line)
? WL.PartSkill.WLPartStats.ItemStatLine(sel, false) + " <size=75%>(기본 " + bv.ToString("F1") + " + 스탯 " + bonus.ToString("F1") + ")</size>"
: WL.PartSkill.WLPartStats.ItemStatLine(sel, false); // #879 신발 이동 속도 · 목걸이 랜덤 스탯 = 배율
}
else { _popupHeaderName.text = j.Item_name; _popupHeaderSub.text = "보유 ×" + sel.rawItem.count; _popupDps.text = ""; }
_popupOptions.text = PartSkillPopupText(sel) // #879 스킬 · 시너지(맨 위)
+ WLInventory.Describe(sel)
+ (isEq && j.EquipmentType == eFREquipmentType.Weapon ? "\n<color=#F0C070>공격 형태: " + WL.Combat.Weapon.WLWeaponAttackTable.Describe(j.GroupNo) + "</color>" : "");
bool equipped = isEq && WLInventory.IsEquipped(sel.rawItem._id);
_equipLabel.text = equipped ? "해제" : "장착";
SetEnabled(_equipBtn, isEq, "btnPrimary");
bool canLv = isEq && WLInventory.NextLevelCost(sel) != null;
SetEnabled(_lvBtn, canLv, "btnSecondary"); SetEnabled(_lvAllBtn, canLv, "btnSecondary");
ItemComposeJson cj; ItemJson rj;
SetEnabled(_composeBtn, isEq && WLInventory.ComposeInfo(sel, out cj, out rj), "btnBlue");
SetEnabled(_downBtn, isEq && WLEquipTables.FindComposeByResult(j.ItemGrade, j.GradeLv) != null, "btnDanger");
}
// ── 조작 ─────────────────────────────────────────────────────────
void OnCellClick(int idx)
{
if (idx < 0 || idx >= _cells.Count) return;
_selectedId = _cells[idx].id;
if (_selectedId < 0) return;
_popup.SetActive(true);
Refresh();
}
void OnSlotClick(int slotIndex)
{
var it = WLInventory.EquippedInSlot(SlotType(slotIndex));
if (it == null) { _tab = (int)SlotType(slotIndex); Refresh(); return; }
_selectedId = it.rawItem._id;
_popup.SetActive(true);
Refresh();
}
void OnEquipToggle() { Act(() => WLInventory.IsEquipped(_selectedId) ? WLInventory.Unequip(_selectedId) : WLInventory.Equip(_selectedId)); }
/// <summary>부위별로 (등급, 단계, 레벨)이 가장 높은 장비를 장착.</summary>
void EquipBest()
{
var best = new Dictionary<int, Item>();
var score = new Dictionary<int, long>();
foreach (var it in WLInventory.SortedItems())
{
if (!it.itemJson.IsEquipment) continue;
int tier = 1, plus = 0; WLGradeTier t;
if (WLEquipGrades.TryMap(it.itemJson, out t, out plus)) tier = t.tier;
long s = tier * 1000000L + plus * 10000L + it.rawItem.level;
int part = (int)it.itemJson.EquipmentType;
long cur;
if (!score.TryGetValue(part, out cur) || s > cur) { score[part] = s; best[part] = it; }
}
int changed = 0;
foreach (var kv in best) if (!WLInventory.IsEquipped(kv.Value.rawItem._id)) { if (WLInventory.Equip(kv.Value.rawItem._id)) changed++; }
WLInventory.Commit(changed > 0 ? "자동 장착 — " + changed + "부위 교체" : "자동 장착 — 이미 최선");
Refresh();
}
void Act(System.Func<bool> f)
{
try { f(); } catch (System.Exception ex) { Debug.LogError("[WL-824] GUI 동작 예외 — " + ex); }
Refresh();
}
// 프로브(CLI 스모크)용 — 셀 탭·버튼과 같은 경로
public void SelectForProbe(int id) { _selectedId = id; _popup.SetActive(true); Refresh(); }
public void ClosePopupsForProbe()
{
_popup.SetActive(false); if (_boxPopup != null) _boxPopup.SetActive(false);
if (_resultView != null) _resultView.SetActive(false);
if (_ratesPopup != null) _ratesPopup.SetActive(false);
_drawCo = null; _revealing = false; _skipReveal = false;
ShowHub();
}
public void SetTabForProbe(int tab) { _tab = tab; Refresh(); }
public void EquipBestForProbe() { EquipBest(); }
public bool PopupVisibleForProbe { get { return _popup != null && _popup.activeSelf; } }
public bool HubVisibleForProbe { get { return _hub != null && _hub.gameObject.activeSelf; } }
public int VisibleCellsForProbe { get { int n = 0; for (int i = 0; i < _cells.Count; i++) if (_cells[i].root.gameObject.activeSelf) n++; return n; } }
public string PreviewStateForProbe
{
get
{
return _previewRig == null ? "rig null" : ("model=" + _previewRig.HasModel + " loading=" + _previewRig.Loading + " class=" + _previewRig.ShownClassId + " prefab=" + _previewRig.ShownPrefab + " loads=" + WLEquipPreview.Loads + " applies=" + WLEquipPreview.ClassApplies + " · " + WLEquipPreview.LastLog);
}
}
// ── uGUI 헬퍼 ───────────────────────────────────────────────────
Sprite Spr(string key) { return WLEquipGuiAssets.GetByKey(key); }
Color Col(string key, Color fallback) { return _g.Col(key, fallback); }
Color TextCol() { return _g.Col("text", new Color(0.96f, 0.94f, 0.9f, 1f)); }
Color DarkCol() { return _g.Col("textDark", new Color(0.28f, 0.17f, 0.05f, 1f)); }
/// <summary>등급 색(라벨·확률 알약 등 흰 그림에 곱하는 색) — 칸 바탕은 PaintFrame 이 따로 정한다.</summary>
Color TierCol(int tier) { return WLEquipIcons.TierColor(tier); }
static Image.Type SlicedIf(Sprite s) { return s != null && s.border.sqrMagnitude > 0f ? Image.Type.Sliced : Image.Type.Simple; }
/// <summary>4.86K · 19.5K · 1.2M 식 짧은 수치(레퍼런스 CP/HP 표기).</summary>
static string Short(double v)
{
double a = System.Math.Abs(v);
if (a >= 1e9) return (v / 1e9).ToString("0.##") + "B";
if (a >= 1e6) return (v / 1e6).ToString("0.##") + "M";
if (a >= 1e4) return (v / 1e3).ToString("0.#") + "K";
if (a >= 1e3) return (v / 1e3).ToString("0.##") + "K";
return v.ToString("0");
}
static RectTransform NewRT(RectTransform parent, string name)
{
var go = new GameObject(name, typeof(RectTransform)); go.layer = UILayer; go.transform.SetParent(parent, false);
return (RectTransform)go.transform;
}
static void Stretch(RectTransform rt, float l = 0f, float t = 0f, float r = 0f, float b = 0f)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.pivot = new Vector2(0.5f, 0.5f);
rt.offsetMin = new Vector2(l, b); rt.offsetMax = new Vector2(-r, -t);
}
static void Pref(RectTransform rt, float w, float h)
{
var le = rt.gameObject.GetComponent<LayoutElement>() ?? rt.gameObject.AddComponent<LayoutElement>();
if (w > 0f) { le.preferredWidth = w; le.minWidth = w; }
// flexibleHeight 0 명시 — 가로 줄이 flexible 1 을 보고해 세로 레이아웃이 남는 공간을 나눠 주던 결함(2026-10-09 계측)
if (h > 0f) { le.preferredHeight = h; le.minHeight = h; le.flexibleHeight = 0f; }
}
static void MakeSimple(Button b)
{
var img = b.GetComponent<Image>();
if (img == null) return;
img.type = Image.Type.Simple; img.preserveAspect = true;
}
static void Flex(RectTransform rt, float w)
{
var le = rt.gameObject.GetComponent<LayoutElement>() ?? rt.gameObject.AddComponent<LayoutElement>();
le.flexibleWidth = w;
}
/// <summary>앵커 한 점 배치(레이아웃 그룹 밖) — pivot 생략 시 앵커와 같다.</summary>
static void Place(RectTransform rt, Vector2 anchor, Vector2 pos, Vector2 size, Vector2? pivot = null)
{
rt.anchorMin = rt.anchorMax = anchor; rt.pivot = pivot ?? anchor; rt.anchoredPosition = pos; rt.sizeDelta = size;
}
Image NewImage(RectTransform parent, string name, Sprite spr, Color color, Image.Type type = Image.Type.Sliced)
{
var rt = NewRT(parent, name);
var img = rt.gameObject.AddComponent<Image>();
img.sprite = spr; img.color = color; img.raycastTarget = false;
img.type = type == Image.Type.Sliced ? SlicedIf(spr) : type;
return img;
}
RectTransform NewRow(RectTransform parent, string name, float h, float spacing)
{
var rt = NewRT(parent, name);
var hl = rt.gameObject.AddComponent<HorizontalLayoutGroup>();
hl.spacing = spacing; hl.childControlWidth = true; hl.childControlHeight = true; hl.childForceExpandWidth = false; hl.childForceExpandHeight = true;
Pref(rt, 0f, h);
return rt;
}
TextMeshProUGUI NewText(RectTransform parent, string name, float size, TextAlignmentOptions align, Color color)
{
var rt = NewRT(parent, name);
var t = rt.gameObject.AddComponent<TextMeshProUGUI>();
if (_font != null) t.font = _font;
t.fontSize = size; t.alignment = align; t.raycastTarget = false; t.color = color;
t.overflowMode = TextOverflowModes.Truncate; t.richText = true;
return t;
}
Button NewButton(RectTransform parent, string label, Sprite spr, Color color, float w, float h, UnityEngine.Events.UnityAction onClick, float fontPx, Color textColor)
{
var rt = NewRT(parent, "btn_" + label);
var img = rt.gameObject.AddComponent<Image>();
img.sprite = spr; img.type = SlicedIf(spr); img.color = spr != null ? color : new Color(0.32f, 0.36f, 0.5f, 1f); img.raycastTarget = true;
var b = rt.gameObject.AddComponent<Button>(); b.targetGraphic = img; b.onClick.AddListener(onClick);
var le = rt.gameObject.AddComponent<LayoutElement>();
if (w > 0f) { le.preferredWidth = w; le.minWidth = w; }
le.preferredHeight = h; le.minHeight = h; le.flexibleHeight = 0f;
var t = NewText(rt, "Label", fontPx, TextAlignmentOptions.Midline, textColor);
t.text = label; t.fontStyle = FontStyles.Bold; Stretch(t.rectTransform, 6f, 2f, 6f, 6f);
ApplyOutline(t);
return b;
}
static void SetButtonLabel(Button b, string label) { var t = b.GetComponentInChildren<TextMeshProUGUI>(); if (t != null) t.text = label; }
void SetEnabled(Button b, bool on, string spriteKey)
{
if (b == null) return;
b.interactable = on;
var img = b.GetComponent<Image>();
if (img == null) return;
var spr = on ? Spr(spriteKey) : (Spr("btnDisabledSpr") ?? Spr(spriteKey));
img.sprite = spr; img.type = SlicedIf(spr);
img.color = on ? (spr != null ? Color.white : new Color(0.32f, 0.36f, 0.5f, 1f)) : (Spr("btnDisabledSpr") != null ? Color.white : Col("btnDisabled", new Color(0.45f, 0.45f, 0.48f, 1f)));
var lbl = b.GetComponentInChildren<TextMeshProUGUI>();
if (lbl != null) { var c = lbl.color; c.a = on ? 1f : 0.55f; lbl.color = c; }
}
}
}