// ───────────────────────────────────────────────────────────────────────────── // WLEquipGui.Compose.cs — 합성 화면(레퍼런스 2 · 「대장간」 대신 「합성」) · FR ComposePopup 흐름 이식 (2026-10-12) // // FR 원본(ComposePopup.cs): 그리드에서 본 아이템을 누르면 합성 슬롯으로 올라가고(RequiredItemCount 만큼 재료 칸이 열림) → // 같은 부위·등급의 재료만 누를 수 있고(나머지는 잠김) → 재료가 다 차면 「합성」 버튼 → 결과 = 상위 등급(레벨 유지) · 「전부 합성」 = 가능한 조합 전부. // WL 판: 위 = 결과 미리보기(큰 칸) ← 초록 화살표 ← 본 아이템 + 재료 칸 N · 오른쪽 = 합성 장인 초상(Layer Lab 데모 캐릭터 임시) · // 말풍선이 다음 할 일을 안내 · 하단 [필터] [전부 합성] [합성] · 그리드(본 아이템 = 글로우 · 재료 후보 = 체크 · 그 외 = 어둡게+자물쇠 · 장착 중 = 「장착됨」). // 본 아이템을 고르면 PlanCompose 가 재료를 자동으로 채워 주고, 재료 칸을 눌러 빼거나 다른 후보를 눌러 바꿀 수 있다. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using TMPro; using UnityEngine; using UnityEngine.UI; namespace WL.Equip { public partial class WLEquipGui { RectTransform _cView, _cGridContent; ScrollRect _cScroll; TextMeshProUGUI _cBubble, _cInfo, _cResultLabel, _cBaseLabel, _cMessage, _cFilterLabel; Cell _cResult, _cBase; readonly List _cMats = new List(); readonly List _cCells = new List(); Button _cFilterBtn, _cAllBtn, _cDoBtn, _cBackBtn; Image _cArrow; int _cTab; int _cBaseId = -1; readonly List _cMatIds = new List(); public static int Composes; void BuildCompose() { _cView = NewRT(_root, "Compose"); Stretch(_cView); if (H.lowerPanelTop > 0f) AddBand(_cView, "LowerPanel", C.gridTop - H.gridPad, 0f, "lowerPanel"); // 캐주얼 톤 — 그리드 뒤 남색 패널 // 말풍선 var bubble = NewImage(_cView, "Bubble", Spr("bubble"), Spr("bubble") != null ? Col("bubbleBg", new Color(0.93f, 0.91f, 0.95f, 1f)) : new Color(0.93f, 0.91f, 0.95f, 1f)); Place(bubble.rectTransform, new Vector2(0.5f, 1f), new Vector2(0f, -C.bubbleTop), new Vector2(C.bubbleW, C.bubbleH), new Vector2(0.5f, 1f)); _cBubble = NewText(bubble.rectTransform, "Text", C.bubbleFontPx, TextAlignmentOptions.Midline, Col("bubbleText", new Color(0.2f, 0.16f, 0.12f, 1f))); _cBubble.fontStyle = FontStyles.Bold; _cBubble.enableWordWrapping = true; Stretch(_cBubble.rectTransform, 30f, 10f, 30f, 10f); // 무대 — 결과(큰 칸) ← 화살표 ← 본 아이템 + 재료 칸 · 오른쪽 초상 var stage = NewRT(_cView, "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, -C.stageTop); stage.sizeDelta = new Vector2(0f, C.stageH); // 세로: 결과(큰 칸) + 라벨 → 화살표 → [본 장비] [재료]… 한 줄 + 라벨 → 안내 글(무대 바닥) var center = new Vector2(0.5f, 0.5f); float cx = C.resultX + C.resultSize * 0.5f; float y = -(C.resultY + C.resultSize * 0.5f); _cResult = NewCell(stage, "Result", C.resultSize, L.cellIconInset, C.slotFontPx + 4f, false, true, null); Place(_cResult.root, new Vector2(0f, 1f), new Vector2(cx, y), new Vector2(C.resultSize, C.resultSize), center); _cResultLabel = NewText(stage, "ResultLabel", C.slotFontPx, TextAlignmentOptions.Top, Col("textDim", Color.white)); Place(_cResultLabel.rectTransform, new Vector2(0f, 1f), new Vector2(cx, y - C.resultSize * 0.5f - 2f), new Vector2(C.resultSize + 160f, C.slotFontPx + 8f), new Vector2(0.5f, 1f)); y -= C.resultSize * 0.5f + C.slotFontPx + 8f + C.arrowGap + C.arrowSize * 0.5f; _cArrow = NewImage(stage, "Arrow", Spr("upArrow"), Col("upBadge", new Color(0.35f, 0.85f, 0.3f, 1f)), Image.Type.Simple); _cArrow.preserveAspect = true; Place(_cArrow.rectTransform, new Vector2(0f, 1f), new Vector2(cx, y), new Vector2(C.arrowSize, C.arrowSize), center); y -= C.arrowSize * 0.5f + C.arrowGap + C.baseSize * 0.5f; float bx = C.resultX + C.baseSize * 0.5f; _cBase = NewCell(stage, "Base", C.baseSize, L.cellIconInset, C.slotFontPx, false, true, () => { _cBaseId = -1; _cMatIds.Clear(); RefreshCompose(); }); Place(_cBase.root, new Vector2(0f, 1f), new Vector2(bx, y), new Vector2(C.baseSize, C.baseSize), center); _cBaseLabel = NewText(stage, "BaseLabel", C.slotFontPx, TextAlignmentOptions.TopLeft, Col("textDim", Color.white)); Place(_cBaseLabel.rectTransform, new Vector2(0f, 1f), new Vector2(C.resultX, y - C.baseSize * 0.5f - 2f), new Vector2(C.infoW, C.slotFontPx + 8f), new Vector2(0f, 1f)); for (int i = 0; i < 3; i++) { int mi = i; var m = NewCell(stage, "Mat" + i, C.matSize, L.cellIconInset, C.slotFontPx, false, true, () => OnMatSlotClick(mi)); _cMats.Add(m); } _cMatRowY = y; LayoutMatSlots(2); var portrait = NewImage(stage, "Portrait", WLEquipGuiAssets.Get(C.portrait), Color.white, Image.Type.Simple); portrait.preserveAspect = true; Place(portrait.rectTransform, new Vector2(1f, 1f), new Vector2(C.portraitX, -C.portraitY), new Vector2(C.portraitW, C.portraitH), new Vector2(1f, 1f)); if (portrait.sprite == null) portrait.color = new Color(1f, 1f, 1f, 0f); _cInfo = NewText(stage, "Info", C.infoFontPx, TextAlignmentOptions.BottomLeft, TextCol()); _cInfo.enableWordWrapping = true; Place(_cInfo.rectTransform, new Vector2(0f, 0f), new Vector2(C.resultX, 6f), new Vector2(C.infoW, C.infoH), new Vector2(0f, 0f)); // 버튼 줄 var row = NewRT(_cView, "Buttons"); row.anchorMin = new Vector2(0f, 1f); row.anchorMax = new Vector2(1f, 1f); row.pivot = new Vector2(0.5f, 1f); row.anchoredPosition = new Vector2(0f, -C.btnRowY); row.sizeDelta = new Vector2(0f, C.btnH); var hl = row.gameObject.AddComponent(); hl.padding = new RectOffset((int)H.edge, (int)H.edge, 0, 0); hl.spacing = C.btnGap; hl.childControlWidth = true; hl.childControlHeight = true; hl.childForceExpandWidth = false; hl.childForceExpandHeight = true; _cFilterBtn = NewButton(row, "필터: 전체", Spr("btnBlue"), Color.white, C.filterBtnW, C.btnH, CycleComposeFilter, C.btnFontPx * 0.8f, TextCol()); _cFilterLabel = _cFilterBtn.GetComponentInChildren(); var spacer = NewRT(row, "Spacer"); Flex(spacer, 1f); _cAllBtn = NewButton(row, "전부 합성", Spr("btnSecondary"), Color.white, C.allBtnW, C.btnH, () => { Act(() => WLInventory.ComposeAll() > 0); _cBaseId = -1; _cMatIds.Clear(); RefreshCompose(); }, C.btnFontPx * 0.85f, TextCol()); _cDoBtn = NewButton(row, "합성", Spr("btnPrimary"), Color.white, C.composeBtnW, C.btnH, DoCompose, C.btnFontPx, DarkCol()); // 그리드 + 뒤로 가기 + 메시지 _cScroll = BuildGrid(_cView, "Grid", C.gridTop, C.gridBottom, out _cGridContent); _cBackBtn = NewBackButton(_cView, ShowHub); _cMessage = NewText(_cView, "Message", H.messageFontPx, TextAlignmentOptions.MidlineLeft, Col("textDim", Color.white)); _cMessage.enableWordWrapping = true; _cMessage.overflowMode = TextOverflowModes.Ellipsis; var mrt = _cMessage.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, H.backY + H.backBtn); _cView.gameObject.SetActive(false); } float _cMatRowY; /// 재료 칸 n 개를 본 장비 오른쪽에 한 줄로(세로 중심 = 본 장비 중심). void LayoutMatSlots(int n) { n = Mathf.Clamp(n, 1, _cMats.Count); float x0 = C.resultX + C.baseSize + C.matGap + 20f + C.matSize * 0.5f; for (int i = 0; i < _cMats.Count; i++) { bool on = i < n; _cMats[i].root.gameObject.SetActive(on); if (!on) continue; float x = x0 + i * (C.matSize + C.matGap); Place(_cMats[i].root, new Vector2(0f, 1f), new Vector2(x, _cMatRowY), new Vector2(C.matSize, C.matSize), new Vector2(0.5f, 0.5f)); } } /// 허브 「합성」 버튼 · 상세 팝업 「합성」 → 합성 화면. baseId ≥ 0 이면 그 장비를 본 아이템으로 올려 둔다. public void OpenCompose(int baseId) { _popup.SetActive(false); _hub.gameObject.SetActive(false); _cView.gameObject.SetActive(true); _cBaseId = -1; _cMatIds.Clear(); if (baseId >= 0) SetComposeBase(baseId); RefreshCompose(); } void SetComposeBase(int id) { var it = WLInventory.FindItem(id); ItemComposeJson c; ItemJson r; _cMatIds.Clear(); if (it == null || !WLInventory.ComposeInfo(it, out c, out r)) { _cBaseId = -1; _cMessage.text = "합성 불가 — 최고 등급이거나 합성표에 없는 장비"; return; } _cBaseId = id; _cTab = (int)it.itemJson.EquipmentType; var plan = WLInventory.PlanCompose(it); if (plan != null) for (int i = 0; i < plan.materials.Count; i++) _cMatIds.Add(plan.materials[i].rawItem._id); _cMessage.text = ""; } int CurrentMatCount() { var mats = new List(); for (int i = 0; i < _cMatIds.Count; i++) { var m = WLInventory.FindItem(_cMatIds[i]); if (m != null) mats.Add(m); } return WLInventory.MaterialCount(mats); } void OnComposeCellClick(int idx) { if (idx < 0 || idx >= _cCells.Count) return; int id = _cCells[idx].id; if (id < 0) return; if (_cBaseId < 0) { SetComposeBase(id); RefreshCompose(); return; } if (id == _cBaseId) { _cBaseId = -1; _cMatIds.Clear(); RefreshCompose(); return; } var src = WLInventory.FindItem(_cBaseId); var it = WLInventory.FindItem(id); ItemComposeJson c; ItemJson r; if (src == null || it == null || !WLInventory.ComposeInfo(src, out c, out r)) { _cBaseId = -1; _cMatIds.Clear(); RefreshCompose(); return; } if (WLInventory.IsEquipped(id) || !WLInventory.IsMaterialItem(it, src, c)) { _cMessage.text = "재료로 쓸 수 없는 장비입니다 — 같은 부위·같은 등급만"; RefreshCompose(); return; } if (_cMatIds.Contains(id)) _cMatIds.Remove(id); else { if (CurrentMatCount() >= c.RequiredItemCount && _cMatIds.Count > 0) _cMatIds.RemoveAt(_cMatIds.Count - 1); // 꽉 찼으면 마지막 재료를 바꾼다 _cMatIds.Add(id); } RefreshCompose(); } void OnMatSlotClick(int i) { if (i < _cMatIds.Count) { _cMatIds.RemoveAt(i); RefreshCompose(); } } void CycleComposeFilter() { var tabs = _g.tabs; if (tabs == null || tabs.Count == 0) return; int pos = 0; for (int i = 0; i < tabs.Count; i++) if (tabs[i].id == _cTab) { pos = i; break; } _cTab = tabs[(pos + 1) % tabs.Count].id; RefreshCompose(); } void DoCompose() { if (_cBaseId < 0) { _cMessage.text = "합성할 장비를 먼저 고르세요"; return; } int keep = _cBaseId; var mats = new List(_cMatIds); bool ok = false; try { ok = WLInventory.ComposeWith(keep, mats); } catch (System.Exception ex) { Debug.LogError("[WL-846] 합성 예외 — " + ex); } if (ok) Composes++; // 결과 장비를 그대로 본 아이템으로 두면 바로 다음 등급 재료를 이어서 고를 수 있다(없으면 비운다) _cMatIds.Clear(); var src = WLInventory.FindItem(keep); ItemComposeJson c; ItemJson r; if (ok && src != null && WLInventory.ComposeInfo(src, out c, out r)) SetComposeBase(keep); else _cBaseId = ok ? -1 : _cBaseId; string msg = WLInventory.LastMessage; Refresh(); // Notify 가 Changed 로 이미 Refresh 를 불렀을 수 있다 — 한 번 더 그려도 싸다 _cMessage.text = msg; } void RefreshCompose() { if (_cView == null || !_cView.gameObject.activeSelf) return; var src = _cBaseId >= 0 ? WLInventory.FindItem(_cBaseId) : null; if (_cBaseId >= 0 && src == null) { _cBaseId = -1; _cMatIds.Clear(); } ItemComposeJson c = null; ItemJson r = null; bool ok = src != null && WLInventory.ComposeInfo(src, out c, out r); if (src != null && !ok) { _cBaseId = -1; src = null; _cMatIds.Clear(); } // 유효하지 않게 된 재료 정리 for (int i = _cMatIds.Count - 1; i >= 0; i--) { var m = WLInventory.FindItem(_cMatIds[i]); if (m == null || src == null || m == src || WLInventory.IsEquipped(m.rawItem._id) || !WLInventory.IsMaterialItem(m, src, c)) _cMatIds.RemoveAt(i); } int need = ok ? c.RequiredItemCount : 0; int have = ok ? CurrentMatCount() : 0; bool ready = ok && have >= need; _cBubble.text = src == null ? "합성할 장비를 선택하세요." : (ready ? "준비 완료! 합성 버튼을 누르세요." : "같은 부위 · 같은 등급의 재료를 " + (need - have) + "개 더 고르세요."); // 결과 · 본 아이템 · 재료 칸 if (ok) { BindCellJson(_cResult, r, src.rawItem.level > r.ItemMaxLv ? r.ItemMaxLv : src.rawItem.level); _cResultLabel.text = WLEquipGrades.Label(r, true); } else { BindCellJson(_cResult, null, 1); if (_cResult.empty != null) { SetOn(_cResult.empty, true); _cResult.empty.text = "?"; } _cResultLabel.text = "합성 결과"; } BindCell(_cBase, src, false); if (src == null) { if (_cBase.empty != null) { SetOn(_cBase.empty, true); _cBase.empty.text = "본 장비"; } _cBaseLabel.text = ""; } else _cBaseLabel.text = src.itemJson.Item_name + " " + WLEquipGrades.Label(src.itemJson, true) + " Lv" + src.rawItem.level + " + 재료 " + have + "/" + need; LayoutMatSlots(Mathf.Max(1, need > 0 ? need : 2)); var matItems = new List(); for (int i = 0; i < _cMatIds.Count; i++) { var m = WLInventory.FindItem(_cMatIds[i]); if (m != null) matItems.Add(m); } for (int i = 0; i < _cMats.Count; i++) { if (!_cMats[i].root.gameObject.activeSelf) continue; var m = i < matItems.Count ? matItems[i] : null; BindCell(_cMats[i], m, false); if (m == null && _cMats[i].empty != null) { SetOn(_cMats[i].empty, true); _cMats[i].empty.text = src == null ? "" : "재료"; } if (m != null) SetOn(_cMats[i].check, true); } _cArrow.color = ready ? Col("upBadge", Color.green) : new Color(0.6f, 0.6f, 0.6f, 0.6f); _cInfo.text = src == null ? "본 장비 1 + 같은 등급 재료 → 한 단계 위 등급 (레벨 유지 · 재료에 쓴 강화석은 돌려받음)" : "결과: " + (ok ? WLEquipGrades.Label(r, true) : "-") + " · Lv " + src.rawItem.level + " 유지 · 재료 칸을 누르면 빠지고, 체크된 장비를 누르면 바뀝니다"; SetEnabled(_cDoBtn, ready, "btnPrimary"); SetEnabled(_cAllBtn, AnyComposable(), "btnSecondary"); string tabName = "전체"; var tabs = _g.tabs; if (tabs != null) for (int i = 0; i < tabs.Count; i++) if (tabs[i].id == _cTab) tabName = tabs[i].label; if (_cFilterLabel != null) _cFilterLabel.text = "필터: " + tabName; // 그리드 — 본 아이템을 골랐으면 그 부위만 · 재료 후보 먼저 var items = WLInventory.SortedItems(); var shown = new List(); var later = new List(); int tab = src != null ? (int)src.itemJson.EquipmentType : _cTab; for (int i = 0; i < items.Count; i++) { var it = items[i]; if (!it.itemJson.IsEquipment) continue; if (it == src) continue; // 본 장비는 아래에서 맨 앞에 한 번만 넣는다(2026-10-12 캡처: 두 번 나오던 결함) if (tab != 0 && (int)it.itemJson.EquipmentType != tab) continue; if (src != null && it != src && !WLInventory.IsEquipped(it.rawItem._id) && WLInventory.IsMaterialItem(it, src, c)) shown.Add(it); else later.Add(it); } if (src != null) { shown.Insert(0, src); } shown.AddRange(later); int n = 0; for (int i = 0; i < shown.Count; i++) { var it = shown[i]; if (n >= _cCells.Count) { int idx = n; _cCells.Add(NewCell(_cGridContent, "CCell" + n, H.gridCell, H.cellIconInset, H.cellFontPx, true, false, () => OnComposeCellClick(idx))); } var cell = _cCells[n]; cell.root.gameObject.SetActive(true); BindCell(cell, it, true); if (src != null) { bool isBase = it == src; bool cand = !isBase && !WLInventory.IsEquipped(it.rawItem._id) && WLInventory.IsMaterialItem(it, src, c); SetOn(cell.glow, isBase); SetOn(cell.check, cand && _cMatIds.Contains(it.rawItem._id)); SetOn(cell.dim, !isBase && !cand); } n++; if (n >= _cfg.maxListRows) break; } for (int i = n; i < _cCells.Count; i++) _cCells[i].root.gameObject.SetActive(false); } bool AnyComposable() { foreach (var it in WLInventory.SortedItems()) { if (!it.itemJson.IsEquipment) continue; if (WLInventory.PlanCompose(it) != null) return true; } return false; } // 프로브용 public void OpenComposeForProbe(int baseId) { OpenCompose(baseId); } public bool ComposeForProbe() { int before = Composes; DoCompose(); return Composes > before; } public bool ComposeVisibleForProbe { get { return _cView != null && _cView.gameObject.activeSelf; } } public string ComposeStateForProbe { get { var src = _cBaseId >= 0 ? WLInventory.FindItem(_cBaseId) : null; int n = 0; for (int i = 0; i < _cCells.Count; i++) if (_cCells[i].root.gameObject.activeSelf) n++; return "base=" + _cBaseId + (src != null ? "(" + src.itemJson.Item_name + " " + WLEquipGrades.Label(src.itemJson, false) + " Lv" + src.rawItem.level + ")" : "") + " mats=" + _cMatIds.Count + " have=" + (src != null ? CurrentMatCount() : 0) + " cells=" + n + " ready=" + (_cDoBtn != null && _cDoBtn.interactable) + " bubble=" + (_cBubble != null ? _cBubble.text : ""); } } } }