Project_WL/Assets/WL/Equip/WLEquipUi.cs

497 lines
30 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.

// ─────────────────────────────────────────────────────────────────────────────
// WLEquipUi.cs — FR 장비 시스템 더미 UI (#821 · PD 「더미 UI 라도 좋으니 실제 기능 동작」)
//
// 코드로 만드는 uGUI(프리팹 0 · 스프라이트 0 · 글자만). 자기 Canvas(Overlay · 1080×1920 기준)를 들고
// 하단 메뉴 DummyMenuButton(menuId = weapon/upgrade · WLEquipSettings.openMenuIds)에서 연다.
// 화면: 장착 슬롯 6 → 보유 목록(스크롤) → 선택 아이템 상세 + 버튼(장착/해제 · 강화 · 전부 강화 · 합성 · 전부 합성 · 강등 · 테스트 획득 · 초기화).
// 정식 UI 는 PD 기획 후 교체 — 로직(WLInventory)은 그대로 쓴다.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace WL.Equip
{
public class WLEquipUi : MonoBehaviour
{
public static WLEquipUi Instance { get; private set; }
public static bool IsOpen { get { return (Instance != null && Instance.gameObject.activeSelf) || WLEquipGui.IsOpen; } }
public static int Opens { get; private set; }
const int UILayer = 5;
WLEquipSettings _cfg;
TMP_FontAsset _font;
Canvas _canvas;
TextMeshProUGUI _title, _info, _stats, _boxInfo, _detail, _message, _gains;
readonly Button[] _slotBtn = new Button[6];
readonly TextMeshProUGUI[] _slotLabel = new TextMeshProUGUI[6];
RectTransform _listContent;
ScrollRect _scroll;
readonly List<Button> _rows = new List<Button>();
readonly List<TextMeshProUGUI> _rowLabels = new List<TextMeshProUGUI>();
readonly List<Image> _rowBg = new List<Image>();
readonly List<int> _rowIds = new List<int>();
Button _equipBtn, _lvBtn, _lvAllBtn, _composeBtn, _composeAllBtn, _downBtn, _cheatBtn, _resetBtn, _boxBtn, _box10Btn, _keyBtn;
TextMeshProUGUI _equipLabel;
int _selectedId = -1;
// ── 진입점 ───────────────────────────────────────────────────────
/// <summary>DummyMenuButton 1줄 훅 — 이 menuId 가 장비 메뉴면 열고 true.</summary>
public static bool TryOpen(string menuId)
{
var cfg = WLEquipSettings.Instance;
if (cfg == null || cfg.enabled_ == 0 || !cfg.IsOpenMenuId(menuId)) return false;
Open();
return true;
}
/// <summary>
/// 🔴 하단 「무기」 버튼(HUD_BottomMenu.prefab)에는 힘민지 원본 `NewGameUI.OnClick_Button`(원본 EquipmentUI 열기)이
/// 프리팹에 연결돼 있고 persistent 호출이 런타임 리스너보다 먼저 돈다 → 원본 메뉴가 초기화 없이 켜지면
/// `FollowTextEnd` 가 매 프레임 NRE 를 낸다(2026-10-08 실측 · 500건). 장비 화면을 열 때 그 메뉴가 켜져 있으면 끈다.
/// </summary>
public static bool CloseLegacyEquipmentUi()
{
var cfg = WLEquipSettings.Instance;
if (cfg == null || cfg.closeLegacyEquipmentUi == 0) return false;
try
{
var ng = NewGameUI.Ins;
if (ng != null && ng.m_EquipmentUI != null && ng.m_EquipmentUI.gameObject.activeSelf)
{
ng.m_EquipmentUI.gameObject.SetActive(false);
return true;
}
}
catch (System.Exception ex) { Debug.LogWarning("[WL-821] 원본 EquipmentUI 닫기 실패 — " + ex.Message); }
return false;
}
public static WLEquipUi Open()
{
var cfg = WLEquipSettings.Instance;
if (cfg == null || cfg.enabled_ == 0) return null;
CloseLegacyEquipmentUi();
if (cfg.useGuiV2 != 0) { WLEquipGui.Open(); return null; } // #824 XP Hero 레이아웃 GUI
if (Instance == null)
{
var go = new GameObject("WL_EquipUi", typeof(RectTransform));
go.layer = UILayer;
DontDestroyOnLoad(go);
Instance = go.AddComponent<WLEquipUi>();
Instance._cfg = cfg;
Instance.Build();
}
Instance.gameObject.SetActive(true);
Opens++;
WLInventory.EnsureLoaded();
Instance.Refresh();
return Instance;
}
public static void Close() { if (Instance != null) Instance.gameObject.SetActive(false); }
void OnEnable() { WLInventory.Changed += Refresh; }
void OnDisable() { WLInventory.Changed -= Refresh; }
void OnDestroy() { if (Instance == this) Instance = null; }
// ── 조립 ─────────────────────────────────────────────────────────
void Build()
{
_font = ResolveFont();
if (EventSystem.current == null)
{
var es = new GameObject("WL_EventSystem(Equip)", typeof(EventSystem), typeof(StandaloneInputModule));
DontDestroyOnLoad(es);
}
_canvas = gameObject.AddComponent<Canvas>();
_canvas.renderMode = RenderMode.ScreenSpaceOverlay;
_canvas.sortingOrder = _cfg.uiSortingOrder;
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1080f, 1920f);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>();
var root = (RectTransform)transform;
var dim = NewImage(root, "Dim", _cfg.dimColor); Stretch(dim.rectTransform); dim.raycastTarget = true;
var panel = NewImage(root, "Panel", _cfg.panelColor);
Stretch(panel.rectTransform);
float m = _cfg.panelMarginPx;
panel.rectTransform.offsetMin = new Vector2(m, m); panel.rectTransform.offsetMax = new Vector2(-m, -m);
var vl = panel.gameObject.AddComponent<VerticalLayoutGroup>();
vl.padding = new RectOffset(16, 16, 16, 16); vl.spacing = 10f;
vl.childControlWidth = true; vl.childControlHeight = true; vl.childForceExpandWidth = true; vl.childForceExpandHeight = false;
var prt = panel.rectTransform;
// 제목 + 닫기
var titleRow = NewRow(prt, "TitleRow", 70f);
_title = NewText(titleRow, "Title", _cfg.titleFontPx, TextAlignmentOptions.MidlineLeft); Flex(_title.rectTransform, 1f);
NewButton(titleRow, "닫기", 160f, Close);
_info = NewText(prt, "Info", _cfg.smallFontPx, TextAlignmentOptions.MidlineLeft); Pref(_info.rectTransform, 44f);
_stats = NewText(prt, "Stats", _cfg.smallFontPx, TextAlignmentOptions.MidlineLeft); Pref(_stats.rectTransform, 44f);
_stats.color = new Color(1f, 0.92f, 0.6f, 1f);
// 장착 슬롯 2×3
for (int r = 0; r < 2; r++)
{
var row = NewRow(prt, "SlotRow" + r, _cfg.slotHeightPx);
for (int c = 0; c < 3; c++)
{
int slotIndex = r * 3 + c;
_slotBtn[slotIndex] = NewButton(row, "", 0f, () => OnSlotClick(slotIndex));
Flex((RectTransform)_slotBtn[slotIndex].transform, 1f);
_slotLabel[slotIndex] = _slotBtn[slotIndex].GetComponentInChildren<TextMeshProUGUI>();
_slotLabel[slotIndex].fontSize = _cfg.smallFontPx;
}
}
// 장비 상자(PD 2026-10-09 — 10회마다 에픽 확정 · 50회 S급 천장 · 열쇠 가격은 보류)
_boxInfo = NewText(prt, "BoxInfo", _cfg.smallFontPx, TextAlignmentOptions.MidlineLeft); Pref(_boxInfo.rectTransform, 44f);
var boxRow = NewRow(prt, "BoxRow", _cfg.buttonHeightPx);
var boxDef = WLEquipGacha.Box(_cfg.defaultBoxId);
int keyCost = boxDef != null ? boxDef.keyCost : 1;
_boxBtn = NewButton(boxRow, "상자 1회 (열쇠 " + keyCost + ")", 0f, () => Act(() => WLEquipGacha.Draw(_cfg.defaultBoxId, 1) > 0)); Flex((RectTransform)_boxBtn.transform, 1f);
_box10Btn = NewButton(boxRow, "상자 10회 (열쇠 " + (keyCost * 10) + ")", 0f, () => Act(() => WLEquipGacha.Draw(_cfg.defaultBoxId, 10) > 0)); Flex((RectTransform)_box10Btn.transform, 1f);
_keyBtn = NewButton(boxRow, "열쇠 +" + _cfg.cheatKeyGrant, 0f, () => Act(() => { WLInventory.AddKeys(_cfg.cheatKeyGrant); WLInventory.Commit("열쇠 +" + _cfg.cheatKeyGrant); return true; })); Flex((RectTransform)_keyBtn.transform, 1f);
_keyBtn.gameObject.SetActive(_cfg.cheatGrantEnabled != 0);
// 목록(스크롤)
var listGo = new GameObject("List", typeof(RectTransform), typeof(Image), typeof(ScrollRect));
listGo.layer = UILayer; listGo.transform.SetParent(prt, false);
var listImg = listGo.GetComponent<Image>(); listImg.color = _cfg.boxColor;
var le = listGo.AddComponent<LayoutElement>(); le.flexibleHeight = 1f; le.minHeight = 200f;
_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);
viewport.GetComponent<Image>().color = new Color(0f, 0f, 0f, 0.01f);
var content = new GameObject("Content", typeof(RectTransform), typeof(VerticalLayoutGroup), typeof(ContentSizeFitter));
content.layer = UILayer; content.transform.SetParent(viewport.transform, false);
_listContent = (RectTransform)content.transform;
_listContent.anchorMin = new Vector2(0f, 1f); _listContent.anchorMax = new Vector2(1f, 1f); _listContent.pivot = new Vector2(0.5f, 1f);
_listContent.offsetMin = Vector2.zero; _listContent.offsetMax = Vector2.zero;
var cvl = content.GetComponent<VerticalLayoutGroup>();
cvl.padding = new RectOffset(8, 8, 8, 8); cvl.spacing = 6f;
cvl.childControlWidth = true; cvl.childControlHeight = true; cvl.childForceExpandWidth = true; cvl.childForceExpandHeight = false;
content.GetComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
_scroll.content = _listContent; _scroll.viewport = vrt; _scroll.horizontal = false; _scroll.vertical = true;
_scroll.movementType = ScrollRect.MovementType.Clamped; _scroll.scrollSensitivity = 40f;
// 상세 + 버튼
var detailBox = NewImage(prt, "Detail", _cfg.boxColor); Pref(detailBox.rectTransform, _cfg.detailHeightPx);
var dvl = detailBox.gameObject.AddComponent<VerticalLayoutGroup>();
dvl.padding = new RectOffset(12, 12, 10, 10); dvl.spacing = 8f;
dvl.childControlWidth = true; dvl.childControlHeight = true; dvl.childForceExpandWidth = true; dvl.childForceExpandHeight = false;
_detail = NewText(detailBox.rectTransform, "Text", _cfg.smallFontPx, TextAlignmentOptions.TopLeft); Flex(_detail.rectTransform, 1f);
var b1 = NewRow(detailBox.rectTransform, "Buttons1", _cfg.buttonHeightPx);
_equipBtn = NewButton(b1, "장착", 0f, OnEquipToggle); Flex((RectTransform)_equipBtn.transform, 1f);
_equipLabel = _equipBtn.GetComponentInChildren<TextMeshProUGUI>();
_lvBtn = NewButton(b1, "강화 +1", 0f, () => Act(() => WLInventory.ItemLevelUp(_selectedId, false))); Flex((RectTransform)_lvBtn.transform, 1f);
_lvAllBtn = NewButton(b1, "전부 강화", 0f, () => Act(() => WLInventory.ItemLevelUp(_selectedId, true))); Flex((RectTransform)_lvAllBtn.transform, 1f);
_composeBtn = NewButton(b1, "합성", 0f, () => Act(() => WLInventory.ComposeOne(_selectedId))); Flex((RectTransform)_composeBtn.transform, 1f);
var b2 = NewRow(detailBox.rectTransform, "Buttons2", _cfg.buttonHeightPx);
_composeAllBtn = NewButton(b2, "전부 합성", 0f, () => Act(() => WLInventory.ComposeAll() > 0)); Flex((RectTransform)_composeAllBtn.transform, 1f);
_downBtn = NewButton(b2, "강등", 0f, () => Act(() => WLInventory.Downgrade(_selectedId))); Flex((RectTransform)_downBtn.transform, 1f);
_cheatBtn = NewButton(b2, "테스트 획득", 0f, OnCheat); Flex((RectTransform)_cheatBtn.transform, 1f);
_cheatBtn.gameObject.SetActive(_cfg.cheatGrantEnabled != 0);
_resetBtn = NewButton(b2, "초기화", 0f, () => { WLInventory.ResetAll(); _selectedId = -1; Refresh(); }); Flex((RectTransform)_resetBtn.transform, 1f);
_message = NewText(prt, "Message", _cfg.smallFontPx, TextAlignmentOptions.MidlineLeft); Pref(_message.rectTransform, 40f);
_gains = NewText(prt, "Gains", _cfg.smallFontPx, TextAlignmentOptions.MidlineLeft); Pref(_gains.rectTransform, 40f);
_gains.color = new Color(0.8f, 0.9f, 0.6f, 1f);
}
TMP_FontAsset ResolveFont() { return ResolveKoreanFont(_cfg); }
/// <summary>한글 글리프('장'·'비')를 가진 TMP 폰트를 고른다 — 아무 라벨의 폰트를 빌리면 FI 데모 폰트(한글 없음)가 걸려 □ 로 나온다(2026-10-08 캡처 실측).</summary>
public static TMP_FontAsset ResolveKoreanFont(WLEquipSettings cfg)
{
TMP_FontAsset f = null;
if (cfg != null && !string.IsNullOrEmpty(cfg.fontPath)) f = WL.Island.WLIslandAssets.Font(cfg.fontPath);
if (f != null && HasKorean(f)) return f;
var labels = FindObjectsByType<TextMeshProUGUI>(FindObjectsInactive.Include, FindObjectsSortMode.None);
TMP_FontAsset fallback = null;
for (int i = 0; i < labels.Length; i++)
{
var lf = labels[i] != null ? labels[i].font : null;
if (lf == null) continue;
if (fallback == null) fallback = lf;
if (HasKorean(lf)) return lf;
}
return f != null ? f : fallback;
}
static bool HasKorean(TMP_FontAsset f)
{
try { return f.HasCharacter('장', true, true) && f.HasCharacter('비', true, true); }
catch { return false; }
}
// ── 갱신 ─────────────────────────────────────────────────────────
public void Refresh()
{
if (!gameObject.activeSelf) return;
if (!WLInventory.EnsureLoaded()) { _title.text = "장비 — 표 로드 실패"; _detail.text = WLEquipTables.LastLog; return; }
_title.text = "장비 (등급 7단계 · 더미 UI) 보유 " + WLInventory.ItemCount;
var sb = new System.Text.StringBuilder();
sb.Append("코인 ").Append(WLInventory.GetGold()).Append(" 강화석:");
if (_cfg.clearStoneItemIds != null)
for (int i = 0; i < _cfg.clearStoneItemIds.Length; i++)
{
var sj = WLEquipTables.FindItemJson(_cfg.clearStoneItemIds[i]);
sb.Append(' ').Append(sj != null ? sj.Item_name.Replace(" 강화석", "") : _cfg.clearStoneItemIds[i].ToString()).Append(' ').Append(WLInventory.GetItemCountByIndex(_cfg.clearStoneItemIds[i]));
}
_info.text = sb.ToString();
_stats.text = WLEquipStats.StatusLine();
_boxInfo.text = WLEquipGacha.InfoLine(_cfg.defaultBoxId);
var boxDef = WLEquipGacha.Box(_cfg.defaultBoxId);
int keyCost = boxDef != null ? boxDef.keyCost : 1;
int keys = WLInventory.GetKeys();
_boxBtn.interactable = boxDef != null && keys >= keyCost;
_box10Btn.interactable = boxDef != null && keys >= keyCost * 10;
SetButtonColor(_boxBtn, _boxBtn.interactable ? _cfg.buttonColor : _cfg.buttonDisabledColor);
SetButtonColor(_box10Btn, _box10Btn.interactable ? _cfg.buttonColor : _cfg.buttonDisabledColor);
for (int i = 0; i < 6; i++)
{
var slot = (eFREquipmentType)(i + 1);
var it = WLInventory.EquippedInSlot(slot);
_slotLabel[i].text = WLEquipTables.SlotName(slot) + "\n" + (it != null
? it.itemJson.Item_name + " [" + WLEquipGrades.Label(it.itemJson, true) + "] Lv" + it.rawItem.level
: "(비어 있음)");
SetButtonColor(_slotBtn[i], it != null && it.rawItem._id == _selectedId ? _cfg.selectedRowColor : (it != null ? _cfg.equippedRowColor : _cfg.buttonDisabledColor));
}
var items = WLInventory.SortedItems();
int n = Mathf.Min(items.Count, _cfg.maxListRows);
while (_rows.Count < n) AddRow();
for (int i = 0; i < _rows.Count; i++)
{
bool on = i < n;
_rows[i].gameObject.SetActive(on);
if (!on) continue;
var it = items[i];
_rowIds[i] = it.rawItem._id;
_rowLabels[i].text = WLInventory.RowText(it);
_rowBg[i].color = it.rawItem._id == _selectedId ? _cfg.selectedRowColor : (WLInventory.IsEquipped(it.rawItem._id) ? _cfg.equippedRowColor : _cfg.rowColor);
}
if (_selectedId >= 0 && WLInventory.FindItem(_selectedId) == null) _selectedId = -1;
var sel = WLInventory.FindItem(_selectedId);
_detail.text = sel != null ? WLInventory.Describe(sel)
: "목록에서 아이템을 고르세요.\n등급: " + WLEquipGrades.Legend(true) + "\n상자 확률(제안값): " + WLEquipGacha.RateText(_cfg.defaultBoxId) +
"\n던전 클리어 때 FR 드랍표(그룹 " + _cfg.clearDropGroupId + ")로 장비 1개 + 강화석이 들어옵니다.";
bool isEq = sel != null && sel.itemJson.IsEquipment;
bool equipped = sel != null && WLInventory.IsEquipped(sel.rawItem._id);
_equipLabel.text = equipped ? "해제" : "장착";
_equipBtn.interactable = isEq;
_lvBtn.interactable = isEq && WLInventory.NextLevelCost(sel) != null;
_lvAllBtn.interactable = _lvBtn.interactable;
_composeBtn.interactable = isEq && WLInventory.PlanCompose(sel) != null;
_downBtn.interactable = isEq && WLEquipTables.FindComposeByResult(sel.itemJson.ItemGrade, sel.itemJson.GradeLv) != null;
SetButtonColor(_equipBtn, _equipBtn.interactable ? _cfg.buttonColor : _cfg.buttonDisabledColor);
SetButtonColor(_lvBtn, _lvBtn.interactable ? _cfg.buttonColor : _cfg.buttonDisabledColor);
SetButtonColor(_lvAllBtn, _lvAllBtn.interactable ? _cfg.buttonColor : _cfg.buttonDisabledColor);
SetButtonColor(_composeBtn, _composeBtn.interactable ? _cfg.buttonColor : _cfg.buttonDisabledColor);
SetButtonColor(_downBtn, _downBtn.interactable ? _cfg.buttonColor : _cfg.buttonDisabledColor);
_message.text = WLInventory.LastMessage;
_gains.text = WLInventory.RecentGains.Count > 0 ? "최근 획득: " + string.Join(" · ", WLInventory.RecentGains.GetRange(0, Mathf.Min(4, WLInventory.RecentGains.Count))) : "";
}
void AddRow()
{
int i = _rows.Count;
var b = NewButton(_listContent, "", _cfg.rowHeightPx, () => OnRowClick(i));
var lbl = b.GetComponentInChildren<TextMeshProUGUI>();
lbl.alignment = TextAlignmentOptions.MidlineLeft; lbl.fontSize = _cfg.bodyFontPx;
lbl.rectTransform.offsetMin = new Vector2(14f, 0f);
_rows.Add(b); _rowLabels.Add(lbl); _rowBg.Add(b.GetComponent<Image>()); _rowIds.Add(-1);
_rowBg[i].color = _cfg.rowColor;
}
// ── 조작 ─────────────────────────────────────────────────────────
void OnRowClick(int i) { if (i < _rowIds.Count) { _selectedId = _rowIds[i]; Refresh(); } }
void OnSlotClick(int slotIndex)
{
var it = WLInventory.EquippedInSlot((eFREquipmentType)(slotIndex + 1));
if (it != null) { _selectedId = it.rawItem._id; Refresh(); }
}
void OnEquipToggle()
{
Act(() => WLInventory.IsEquipped(_selectedId) ? WLInventory.Unequip(_selectedId) : WLInventory.Equip(_selectedId));
}
void OnCheat()
{
WLEquipDrops.Grant(_cfg.cheatGrantGroupId, _cfg.cheatGrantRolls, _cfg.clearStoneItemIds, _cfg.clearStoneCountEach, 0);
Refresh();
}
void Act(System.Func<bool> f)
{
try { f(); } catch (System.Exception ex) { Debug.LogError("[WL-821] UI 동작 예외 — " + ex); }
Refresh();
}
// ── uGUI 헬퍼 ───────────────────────────────────────────────────
static void Stretch(RectTransform rt)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero; rt.pivot = new Vector2(0.5f, 0.5f);
}
static void Pref(RectTransform rt, float h) { var le = rt.gameObject.GetComponent<LayoutElement>() ?? rt.gameObject.AddComponent<LayoutElement>(); le.preferredHeight = h; le.minHeight = h; }
static void Flex(RectTransform rt, float w) { var le = rt.gameObject.GetComponent<LayoutElement>() ?? rt.gameObject.AddComponent<LayoutElement>(); le.flexibleWidth = w; le.flexibleHeight = w; }
Image NewImage(RectTransform parent, string name, Color c)
{
var go = new GameObject(name, typeof(RectTransform), typeof(Image));
go.layer = UILayer; go.transform.SetParent(parent, false);
var img = go.GetComponent<Image>(); img.color = c; img.raycastTarget = false;
return img;
}
RectTransform NewRow(RectTransform parent, string name, float h)
{
var go = new GameObject(name, typeof(RectTransform), typeof(HorizontalLayoutGroup));
go.layer = UILayer; go.transform.SetParent(parent, false);
var hl = go.GetComponent<HorizontalLayoutGroup>();
hl.spacing = 8f; hl.childControlWidth = true; hl.childControlHeight = true; hl.childForceExpandWidth = false; hl.childForceExpandHeight = true;
var rt = (RectTransform)go.transform; Pref(rt, h);
return rt;
}
TextMeshProUGUI NewText(RectTransform parent, string name, float size, TextAlignmentOptions align)
{
var go = new GameObject(name, typeof(RectTransform), typeof(TextMeshProUGUI));
go.layer = UILayer; go.transform.SetParent(parent, false);
var t = go.GetComponent<TextMeshProUGUI>();
if (_font != null) t.font = _font;
t.fontSize = size; t.alignment = align; t.raycastTarget = false; t.color = Color.white;
t.overflowMode = TextOverflowModes.Truncate;
return t;
}
Button NewButton(RectTransform parent, string label, float width, UnityEngine.Events.UnityAction onClick)
{
var go = new GameObject("btn_" + label, typeof(RectTransform), typeof(Image), typeof(Button));
go.layer = UILayer; go.transform.SetParent(parent, false);
var img = go.GetComponent<Image>(); img.color = _cfg.buttonColor; img.raycastTarget = true;
var b = go.GetComponent<Button>(); b.targetGraphic = img; b.onClick.AddListener(onClick);
var le = go.AddComponent<LayoutElement>();
if (width > 0f) { le.preferredWidth = width; le.minWidth = width; }
le.preferredHeight = _cfg.buttonHeightPx;
var t = NewText((RectTransform)go.transform, "Label", _cfg.bodyFontPx, TextAlignmentOptions.Center);
t.text = label; Stretch(t.rectTransform);
return b;
}
static void SetButtonColor(Button b, Color c) { var img = b.GetComponent<Image>(); if (img != null) img.color = c; }
// ── 런처 ─────────────────────────────────────────────────────────
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
var cfg = WLEquipSettings.Instance;
if (cfg == null || cfg.enabled_ == 0 || cfg.launcherEnabled == 0) return;
WLEquipLauncher.Ensure(cfg);
}
}
/// <summary>
/// 인게임(섬·던전)에서 항상 보이는 「장비」 버튼 — 하단 5메뉴는 섬에서 비활성이라(실측 2026-10-08) 별도 진입점이 필요하다.
/// 자기 Canvas(Overlay) 1장 · 버튼 1개 · 0.5 s 마다 보일 조건(활성 씬 = launcherSceneName · 서버 데이터 있음 · 장비 화면 닫힘)을 다시 본다.
/// </summary>
public class WLEquipLauncher : MonoBehaviour
{
public static WLEquipLauncher Instance { get; private set; }
public static int Clicks { get; private set; }
public bool Visible { get { return _btn != null && _btn.gameObject.activeSelf; } }
WLEquipSettings _cfg;
Button _btn;
float _timer;
public static WLEquipLauncher Ensure(WLEquipSettings cfg)
{
if (Instance != null) return Instance;
var go = new GameObject("WL_EquipLauncher", typeof(RectTransform));
go.layer = 5;
DontDestroyOnLoad(go);
Instance = go.AddComponent<WLEquipLauncher>();
Instance._cfg = cfg;
Instance.Build();
return Instance;
}
void Build()
{
var 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(1080f, 1920f);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>();
var bgo = new GameObject("btn_equip", typeof(RectTransform), typeof(Image), typeof(Button));
bgo.layer = 5; bgo.transform.SetParent(transform, false);
var rt = (RectTransform)bgo.transform;
rt.anchorMin = rt.anchorMax = new Vector2(1f, 0f); rt.pivot = new Vector2(1f, 0f);
rt.sizeDelta = _cfg.launcherSizePx;
rt.anchoredPosition = new Vector2(-_cfg.launcherOffsetPx.x, _cfg.launcherOffsetPx.y);
var img = bgo.GetComponent<Image>(); img.color = _cfg.buttonColor;
_btn = bgo.GetComponent<Button>(); _btn.targetGraphic = img;
_btn.onClick.AddListener(() => { Clicks++; WLEquipUi.Open(); });
var tgo = new GameObject("Label", typeof(RectTransform), typeof(TextMeshProUGUI));
tgo.layer = 5; tgo.transform.SetParent(bgo.transform, false);
var t = tgo.GetComponent<TextMeshProUGUI>();
var trt = t.rectTransform; trt.anchorMin = Vector2.zero; trt.anchorMax = Vector2.one; trt.offsetMin = trt.offsetMax = Vector2.zero;
t.text = _cfg.launcherLabel; t.fontSize = _cfg.bodyFontPx; t.alignment = TextAlignmentOptions.Center; t.raycastTarget = false;
_label = t;
_btn.gameObject.SetActive(false);
}
void Update()
{
_timer += Time.unscaledDeltaTime;
if (_timer < 0.5f) return;
_timer = 0f;
WLEquipStats.EnforceBaseAttackTick(); // PD 「기본 공격력 5」 — Reset 이 돌 때마다 원본 공식으로 돌아오므로 0.5 s 마다 다시 맞춘다
bool show = false;
try
{
show = _cfg != null && _cfg.enabled_ != 0 && _cfg.launcherEnabled != 0 && !WLEquipUi.IsOpen &&
UnityEngine.SceneManagement.SceneManager.GetActiveScene().name == _cfg.launcherSceneName &&
ServerInfo.Ins != null && ServerInfo.Ins.m_ServerData != null;
}
catch { show = false; }
if (_btn != null && _btn.gameObject.activeSelf != show)
{
// 폰트는 처음 보일 때(인게임) 고른다 — 타이틀 시점에 고르면 인게임 한글 폰트(Font SDF)가 아직 없다(2026-10-08 실측 「장□」).
if (show && !_fontResolved)
{
_fontResolved = true;
var kf = WLEquipUi.ResolveKoreanFont(_cfg);
if (kf != null && _label != null) _label.font = kf;
}
_btn.gameObject.SetActive(show);
}
}
TextMeshProUGUI _label;
bool _fontResolved;
public string FontName { get { return _label != null && _label.font != null ? _label.font.name : "null"; } }
}
}