Files
Colosseum/Assets/_Game/Scripts/UI/SkillGemInventoryUI.cs
dal4segno a94daf7968 feat: 젬 분류 사양과 장착 UI 반영
- 옵시디언 기준의 역할/발동 타입 분류를 스킬·젬 데이터와 장착 검증 로직에 반영
- 젬 보관 UI와 퀵슬롯 표시를 새 분류 및 실제 마나/쿨타임 계산 기준으로 갱신
- 테스트 스킬/젬 자산을 에디터 메뉴로 동기화하고 Unity 컴파일 및 플레이 검증 완료
2026-03-26 16:18:45 +09:00

1349 lines
52 KiB
C#

using System;
using System.Collections.Generic;
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
using Colosseum.Player;
using Colosseum.Skills;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace Colosseum.UI
{
/// <summary>
/// 젬 보관함과 스킬별 장착/탈착 UI를 관리합니다.
/// </summary>
public class SkillGemInventoryUI : MonoBehaviour
{
private const string DefaultGemSearchFolder = "Assets/_Game/Data/SkillGems";
[Serializable]
private class SkillGemStorageEntry
{
[SerializeField] private SkillGemData gem;
[Min(0)] [SerializeField] private int quantity = 1;
public SkillGemData Gem
{
get => gem;
set => gem = value;
}
public int Quantity
{
get => quantity;
set => quantity = Mathf.Max(0, value);
}
}
[Header("Toggle")]
[Tooltip("젬 UI를 여닫는 키")]
[SerializeField] private Key toggleKey = Key.G;
[Tooltip("토글 버튼에 표시할 텍스트")]
[SerializeField] private string toggleButtonLabel = "젬";
[Tooltip("토글 버튼의 캔버스 기준 위치")]
[SerializeField] private Vector2 toggleButtonAnchoredPosition = new Vector2(-48f, 164f);
[Header("Storage")]
[Tooltip("젬 보관 수량")]
[SerializeField] private List<SkillGemStorageEntry> ownedGemEntries = new();
[Header("Debug")]
[Tooltip("플레이 모드 시작 시 패널을 자동으로 엽니다.")]
[SerializeField] private bool openOnStartInPlayMode = false;
#if UNITY_EDITOR
[Header("Editor Auto Collect")]
[Tooltip("에디터에서 젬 자산을 자동으로 수집합니다.")]
[SerializeField] private bool autoCollectOwnedGemsInEditor = true;
[Tooltip("자동 수집할 젬 자산 폴더")]
[SerializeField] private string gemSearchFolder = DefaultGemSearchFolder;
[Tooltip("자동 수집 시 신규 젬 기본 수량")]
[Min(0)] [SerializeField] private int autoCollectedGemQuantity = 1;
#endif
[Header("Style")]
[SerializeField] private Color panelBackgroundColor = new Color(0.08f, 0.08f, 0.11f, 0.96f);
[SerializeField] private Color sectionBackgroundColor = new Color(0.14f, 0.14f, 0.18f, 0.95f);
[SerializeField] private Color buttonNormalColor = new Color(0.19f, 0.19f, 0.24f, 0.96f);
[SerializeField] private Color buttonSelectedColor = new Color(0.48f, 0.32f, 0.16f, 0.96f);
[SerializeField] private Color buttonDisabledColor = new Color(0.12f, 0.12f, 0.15f, 0.65f);
[SerializeField] private Color statusNormalColor = new Color(0.86f, 0.85f, 0.78f, 1f);
[SerializeField] private Color statusErrorColor = new Color(1f, 0.52f, 0.45f, 1f);
private static readonly string[] SlotDisplayNames =
{
"좌클릭",
"우클릭",
"기술 1",
"기술 2",
"기술 3",
"기술 4",
"긴급 회피",
};
private PlayerSkillInput playerSkillInput;
private PlayerMovement playerMovement;
private Canvas parentCanvas;
private RectTransform canvasRectTransform;
private TMP_FontAsset sharedFont;
private GameObject overlayRoot;
private RectTransform panelRectTransform;
private RectTransform skillListContent;
private RectTransform socketListContent;
private RectTransform gemListContent;
private TextMeshProUGUI storageSummaryText;
private TextMeshProUGUI skillSummaryText;
private TextMeshProUGUI gemDetailText;
private TextMeshProUGUI statusText;
private Button equipButton;
private Button unequipButton;
private TextMeshProUGUI equipButtonLabel;
private TextMeshProUGUI unequipButtonLabel;
private bool isPanelVisible;
private bool uiInitialized;
private int selectedSkillSlotIndex;
private int selectedSocketIndex;
private SkillGemData selectedGem;
private bool previousCursorVisible;
private CursorLockMode previousCursorLockState;
private void Awake()
{
EnsureUi();
HidePanelImmediate();
}
private void Start()
{
EnsureUi();
FindLocalPlayer();
RefreshAll();
if (Application.isPlaying && openOnStartInPlayMode)
{
SetPanelVisible(true);
}
}
private void Update()
{
if (playerSkillInput == null)
{
FindLocalPlayer();
}
if (Keyboard.current == null)
return;
if (Keyboard.current[toggleKey].wasPressedThisFrame)
{
TogglePanelVisibility();
}
else if (isPanelVisible && Keyboard.current.escapeKey.wasPressedThisFrame)
{
SetPanelVisible(false);
}
}
private void OnDestroy()
{
UnsubscribeFromPlayer();
SetGameplayInputBlocked(false);
RestoreCursorState();
}
#if UNITY_EDITOR
private void OnValidate()
{
if (Application.isPlaying)
return;
AutoCollectOwnedGemsInEditor();
}
#endif
private void FindLocalPlayer()
{
PlayerSkillInput[] players = FindObjectsByType<PlayerSkillInput>(FindObjectsSortMode.None);
for (int i = 0; i < players.Length; i++)
{
if (!players[i].IsOwner)
continue;
if (playerSkillInput == players[i])
return;
SetTarget(players[i]);
return;
}
}
private void SetTarget(PlayerSkillInput target)
{
UnsubscribeFromPlayer();
playerSkillInput = target;
playerMovement = playerSkillInput != null ? playerSkillInput.GetComponent<PlayerMovement>() : null;
if (playerSkillInput != null)
{
playerSkillInput.OnSkillSlotsChanged += HandleSkillSlotsChanged;
}
if (isPanelVisible)
{
SetGameplayInputBlocked(true);
}
RefreshAll();
}
private void UnsubscribeFromPlayer()
{
if (playerSkillInput != null)
{
playerSkillInput.OnSkillSlotsChanged -= HandleSkillSlotsChanged;
}
}
private void HandleSkillSlotsChanged()
{
RefreshAll();
}
private void TogglePanelVisibility()
{
SetPanelVisible(!isPanelVisible);
}
private void SetPanelVisible(bool visible)
{
EnsureUi();
isPanelVisible = visible;
UpdatePanelSize();
if (overlayRoot != null)
{
overlayRoot.SetActive(visible);
}
if (visible)
{
RememberCursorState();
Cursor.visible = true;
Cursor.lockState = CursorLockMode.None;
SetGameplayInputBlocked(true);
RefreshAll();
}
else
{
SetGameplayInputBlocked(false);
RestoreCursorState();
}
}
private void HidePanelImmediate()
{
isPanelVisible = false;
if (overlayRoot != null)
{
overlayRoot.SetActive(false);
}
}
private void RememberCursorState()
{
previousCursorVisible = Cursor.visible;
previousCursorLockState = Cursor.lockState;
}
private void RestoreCursorState()
{
Cursor.visible = previousCursorVisible;
Cursor.lockState = previousCursorLockState;
}
private void SetGameplayInputBlocked(bool blocked)
{
if (playerMovement != null)
{
playerMovement.SetGameplayInputEnabled(!blocked);
}
if (playerSkillInput != null)
{
playerSkillInput.SetGameplayInputEnabled(!blocked);
}
}
private void EnsureUi()
{
if (uiInitialized)
return;
parentCanvas = GetComponentInParent<Canvas>();
canvasRectTransform = parentCanvas != null ? parentCanvas.GetComponent<RectTransform>() : null;
if (parentCanvas == null || canvasRectTransform == null)
{
Debug.LogWarning("[SkillGemInventoryUI] Canvas를 찾지 못했습니다.");
return;
}
TextMeshProUGUI referenceText = GetComponentInChildren<TextMeshProUGUI>(true);
sharedFont = referenceText != null ? referenceText.font : TMP_Settings.defaultFontAsset;
CreateToggleButton();
CreateOverlay();
UpdatePanelSize();
uiInitialized = true;
}
private void UpdatePanelSize()
{
if (panelRectTransform == null || canvasRectTransform == null)
return;
Rect canvasRect = canvasRectTransform.rect;
float width = Mathf.Clamp(canvasRect.width - 120f, 920f, 1380f);
float height = Mathf.Clamp(canvasRect.height - 120f, 620f, 820f);
panelRectTransform.sizeDelta = new Vector2(width, height);
}
private void CreateToggleButton()
{
GameObject buttonObject = CreateUiObject("Button_GemInventory", canvasRectTransform);
RectTransform buttonRect = buttonObject.AddComponent<RectTransform>();
buttonRect.anchorMin = new Vector2(1f, 0f);
buttonRect.anchorMax = new Vector2(1f, 0f);
buttonRect.pivot = new Vector2(1f, 0f);
buttonRect.anchoredPosition = toggleButtonAnchoredPosition;
buttonRect.sizeDelta = new Vector2(72f, 34f);
Image buttonImage = buttonObject.AddComponent<Image>();
buttonImage.color = buttonNormalColor;
Button toggleButton = buttonObject.AddComponent<Button>();
toggleButton.targetGraphic = buttonImage;
toggleButton.onClick.AddListener(TogglePanelVisibility);
TextMeshProUGUI label = CreateFillLabel(buttonRect, "Label", toggleButtonLabel, 18f, TextAlignmentOptions.Center);
label.textWrappingMode = TextWrappingModes.NoWrap;
}
private void CreateOverlay()
{
overlayRoot = CreateUiObject("Overlay_GemInventory", canvasRectTransform);
RectTransform overlayRect = overlayRoot.AddComponent<RectTransform>();
StretchToParent(overlayRect);
GameObject backdropObject = CreateUiObject("Backdrop", overlayRect);
RectTransform backdropRect = backdropObject.AddComponent<RectTransform>();
StretchToParent(backdropRect);
Image backdropImage = backdropObject.AddComponent<Image>();
backdropImage.color = new Color(0f, 0f, 0f, 0.38f);
Button backdropButton = backdropObject.AddComponent<Button>();
backdropButton.targetGraphic = backdropImage;
backdropButton.onClick.AddListener(() => SetPanelVisible(false));
GameObject panelObject = CreateUiObject("Panel", overlayRect);
panelRectTransform = panelObject.AddComponent<RectTransform>();
panelRectTransform.anchorMin = new Vector2(0.5f, 0.5f);
panelRectTransform.anchorMax = new Vector2(0.5f, 0.5f);
panelRectTransform.pivot = new Vector2(0.5f, 0.5f);
panelRectTransform.anchoredPosition = Vector2.zero;
panelRectTransform.sizeDelta = new Vector2(1160f, 680f);
Image panelImage = panelObject.AddComponent<Image>();
panelImage.color = panelBackgroundColor;
VerticalLayoutGroup panelLayout = panelObject.AddComponent<VerticalLayoutGroup>();
panelLayout.padding = new RectOffset(18, 18, 18, 18);
panelLayout.spacing = 14f;
panelLayout.childAlignment = TextAnchor.UpperLeft;
panelLayout.childControlWidth = true;
panelLayout.childControlHeight = true;
panelLayout.childForceExpandWidth = true;
panelLayout.childForceExpandHeight = false;
ContentSizeFitter fitter = panelObject.AddComponent<ContentSizeFitter>();
fitter.verticalFit = ContentSizeFitter.FitMode.Unconstrained;
fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
CreateHeader(panelRectTransform);
CreateBody(panelRectTransform);
CreateFooter(panelRectTransform);
}
private void CreateHeader(RectTransform parent)
{
RectTransform header = CreateContainer(parent, "Header", 96f);
HorizontalLayoutGroup layout = header.gameObject.AddComponent<HorizontalLayoutGroup>();
layout.childAlignment = TextAnchor.MiddleLeft;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = false;
layout.childForceExpandHeight = true;
layout.spacing = 12f;
RectTransform titleGroup = CreateUiObject("TitleGroup", header).AddComponent<RectTransform>();
LayoutElement titleLayout = titleGroup.gameObject.AddComponent<LayoutElement>();
titleLayout.flexibleWidth = 1f;
VerticalLayoutGroup titleGroupLayout = titleGroup.gameObject.AddComponent<VerticalLayoutGroup>();
titleGroupLayout.spacing = 4f;
titleGroupLayout.childAlignment = TextAnchor.MiddleLeft;
titleGroupLayout.childControlWidth = true;
titleGroupLayout.childControlHeight = true;
titleGroupLayout.childForceExpandWidth = true;
titleGroupLayout.childForceExpandHeight = false;
TextMeshProUGUI title = CreateAutoLabel(titleGroup, "Title", "젬 보관함", 30f, TextAlignmentOptions.Left);
title.fontStyle = FontStyles.Bold;
title.textWrappingMode = TextWrappingModes.NoWrap;
LayoutElement titleTextLayout = title.gameObject.AddComponent<LayoutElement>();
titleTextLayout.minHeight = 36f;
TextMeshProUGUI subtitle = CreateAutoLabel(titleGroup, "Subtitle", "G 키 또는 버튼으로 열고 닫을 수 있습니다.", 18f, TextAlignmentOptions.Left);
subtitle.color = new Color(0.82f, 0.8f, 0.72f, 0.92f);
subtitle.textWrappingMode = TextWrappingModes.NoWrap;
LayoutElement subtitleTextLayout = subtitle.gameObject.AddComponent<LayoutElement>();
subtitleTextLayout.minHeight = 24f;
Button closeButton = CreateButton(header, "Button_Close", "닫기", new Vector2(90f, 42f), () => SetPanelVisible(false), out _);
LayoutElement closeButtonLayout = closeButton.gameObject.AddComponent<LayoutElement>();
closeButtonLayout.minWidth = 90f;
closeButtonLayout.preferredWidth = 90f;
closeButton.image.color = new Color(0.32f, 0.18f, 0.18f, 0.95f);
}
private void CreateBody(RectTransform parent)
{
RectTransform body = CreateContainer(parent, "Body", 0f);
LayoutElement bodyLayout = body.GetComponent<LayoutElement>();
bodyLayout.flexibleHeight = 1f;
bodyLayout.minHeight = 0f;
HorizontalLayoutGroup layout = body.gameObject.AddComponent<HorizontalLayoutGroup>();
layout.childAlignment = TextAnchor.UpperLeft;
layout.spacing = 14f;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = true;
CreateSkillSection(body);
CreateSocketSection(body);
CreateStorageSection(body);
}
private void CreateFooter(RectTransform parent)
{
RectTransform footer = CreateContainer(parent, "Footer", 64f);
Image footerImage = footer.gameObject.AddComponent<Image>();
footerImage.color = sectionBackgroundColor;
HorizontalLayoutGroup layout = footer.gameObject.AddComponent<HorizontalLayoutGroup>();
layout.padding = new RectOffset(14, 14, 12, 12);
layout.spacing = 10f;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = false;
layout.childForceExpandHeight = true;
layout.childAlignment = TextAnchor.MiddleLeft;
statusText = CreateAutoLabel(footer, "Label_Status", "로컬 플레이어를 찾는 중입니다.", 19f, TextAlignmentOptions.MidlineLeft);
LayoutElement statusLayout = statusText.gameObject.AddComponent<LayoutElement>();
statusLayout.flexibleWidth = 1f;
statusText.color = statusNormalColor;
}
private void CreateSkillSection(RectTransform parent)
{
RectTransform section = CreateSectionRoot(parent, "Section_Skills");
LayoutElement sectionLayout = section.GetComponent<LayoutElement>();
sectionLayout.preferredWidth = 300f;
CreateSectionTitle(section, "스킬 슬롯");
skillSummaryText = CreateInfoText(section, "Label_SkillSummary", 100f);
RectTransform scroll = CreateScrollView(section, "Scroll_Skills", out skillListContent);
LayoutElement scrollLayout = scroll.gameObject.AddComponent<LayoutElement>();
scrollLayout.flexibleHeight = 1f;
scrollLayout.minHeight = 180f;
}
private void CreateSocketSection(RectTransform parent)
{
RectTransform section = CreateSectionRoot(parent, "Section_Sockets");
LayoutElement sectionLayout = section.GetComponent<LayoutElement>();
sectionLayout.preferredWidth = 300f;
CreateSectionTitle(section, "장착 소켓");
RectTransform commandRow = CreateContainer(section, "Commands", 48f);
HorizontalLayoutGroup commandLayout = commandRow.gameObject.AddComponent<HorizontalLayoutGroup>();
commandLayout.spacing = 8f;
commandLayout.childControlWidth = true;
commandLayout.childControlHeight = true;
commandLayout.childForceExpandWidth = true;
commandLayout.childForceExpandHeight = true;
equipButton = CreateButton(commandRow, "Button_Equip", "선택한 젬 장착", Vector2.zero, TryEquipSelectedGem, out equipButtonLabel);
LayoutElement equipLayout = equipButton.gameObject.AddComponent<LayoutElement>();
equipLayout.flexibleWidth = 1f;
unequipButton = CreateButton(commandRow, "Button_Unequip", "선택한 젬 탈착", Vector2.zero, TryUnequipSelectedGem, out unequipButtonLabel);
LayoutElement unequipLayout = unequipButton.gameObject.AddComponent<LayoutElement>();
unequipLayout.flexibleWidth = 1f;
RectTransform scroll = CreateScrollView(section, "Scroll_Sockets", out socketListContent);
LayoutElement scrollLayout = scroll.gameObject.AddComponent<LayoutElement>();
scrollLayout.flexibleHeight = 1f;
scrollLayout.minHeight = 240f;
}
private void CreateStorageSection(RectTransform parent)
{
RectTransform section = CreateSectionRoot(parent, "Section_Storage");
LayoutElement sectionLayout = section.GetComponent<LayoutElement>();
sectionLayout.preferredWidth = 380f;
CreateSectionTitle(section, "젬 보관함");
storageSummaryText = CreateInfoText(section, "Label_StorageSummary", 72f);
RectTransform scroll = CreateScrollView(section, "Scroll_Gems", out gemListContent);
LayoutElement scrollLayout = scroll.gameObject.AddComponent<LayoutElement>();
scrollLayout.flexibleHeight = 1f;
scrollLayout.minHeight = 150f;
gemDetailText = CreateInfoText(section, "Label_GemDetail", 120f);
}
private RectTransform CreateSectionRoot(RectTransform parent, string name)
{
RectTransform section = CreateContainer(parent, name, 0f);
LayoutElement layout = section.GetComponent<LayoutElement>();
layout.flexibleWidth = 1f;
layout.flexibleHeight = 1f;
layout.minWidth = 0f;
Image image = section.gameObject.AddComponent<Image>();
image.color = sectionBackgroundColor;
VerticalLayoutGroup verticalLayout = section.gameObject.AddComponent<VerticalLayoutGroup>();
verticalLayout.padding = new RectOffset(14, 14, 14, 14);
verticalLayout.spacing = 10f;
verticalLayout.childAlignment = TextAnchor.UpperLeft;
verticalLayout.childControlWidth = true;
verticalLayout.childControlHeight = true;
verticalLayout.childForceExpandWidth = true;
verticalLayout.childForceExpandHeight = false;
return section;
}
private TextMeshProUGUI CreateSectionTitle(RectTransform parent, string text)
{
TextMeshProUGUI label = CreateAutoLabel(parent, "Title", text, 24f, TextAlignmentOptions.Left);
label.fontStyle = FontStyles.Bold;
LayoutElement layout = label.gameObject.AddComponent<LayoutElement>();
layout.minHeight = 34f;
return label;
}
private TextMeshProUGUI CreateInfoText(RectTransform parent, string name, float minHeight)
{
RectTransform container = CreateContainer(parent, $"{name}_Container", minHeight);
Image image = container.gameObject.AddComponent<Image>();
image.color = new Color(0f, 0f, 0f, 0.16f);
LayoutElement layout = container.GetComponent<LayoutElement>();
layout.minHeight = minHeight;
VerticalLayoutGroup containerLayout = container.gameObject.AddComponent<VerticalLayoutGroup>();
containerLayout.padding = new RectOffset(12, 12, 10, 10);
containerLayout.childAlignment = TextAnchor.UpperLeft;
containerLayout.childControlWidth = true;
containerLayout.childControlHeight = true;
containerLayout.childForceExpandWidth = true;
containerLayout.childForceExpandHeight = false;
ContentSizeFitter containerFitter = container.gameObject.AddComponent<ContentSizeFitter>();
containerFitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
containerFitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
TextMeshProUGUI label = CreateAutoLabel(container, name, string.Empty, 18f, TextAlignmentOptions.TopLeft);
label.textWrappingMode = TextWrappingModes.Normal;
label.overflowMode = TextOverflowModes.Ellipsis;
return label;
}
private RectTransform CreateScrollView(RectTransform parent, string name, out RectTransform content)
{
GameObject rootObject = CreateUiObject(name, parent);
RectTransform rootRect = rootObject.AddComponent<RectTransform>();
Image rootImage = rootObject.AddComponent<Image>();
rootImage.color = new Color(0f, 0f, 0f, 0.12f);
ScrollRect scrollRect = rootObject.AddComponent<ScrollRect>();
scrollRect.horizontal = false;
scrollRect.vertical = true;
scrollRect.scrollSensitivity = 24f;
GameObject viewportObject = CreateUiObject("Viewport", rootRect);
RectTransform viewportRect = viewportObject.AddComponent<RectTransform>();
StretchToParent(viewportRect);
Image viewportImage = viewportObject.AddComponent<Image>();
viewportImage.color = new Color(1f, 1f, 1f, 0.01f);
Mask viewportMask = viewportObject.AddComponent<Mask>();
viewportMask.showMaskGraphic = false;
GameObject contentObject = CreateUiObject("Content", viewportRect);
content = contentObject.AddComponent<RectTransform>();
content.anchorMin = new Vector2(0f, 1f);
content.anchorMax = new Vector2(1f, 1f);
content.pivot = new Vector2(0.5f, 1f);
content.anchoredPosition = Vector2.zero;
content.sizeDelta = Vector2.zero;
VerticalLayoutGroup contentLayout = contentObject.AddComponent<VerticalLayoutGroup>();
contentLayout.padding = new RectOffset(8, 8, 8, 8);
contentLayout.spacing = 6f;
contentLayout.childAlignment = TextAnchor.UpperLeft;
contentLayout.childControlWidth = true;
contentLayout.childControlHeight = true;
contentLayout.childForceExpandWidth = true;
contentLayout.childForceExpandHeight = false;
ContentSizeFitter contentFitter = contentObject.AddComponent<ContentSizeFitter>();
contentFitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
contentFitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
scrollRect.viewport = viewportRect;
scrollRect.content = content;
return rootRect;
}
private RectTransform CreateContainer(RectTransform parent, string name, float minHeight)
{
GameObject containerObject = CreateUiObject(name, parent);
RectTransform rectTransform = containerObject.AddComponent<RectTransform>();
LayoutElement layout = containerObject.AddComponent<LayoutElement>();
layout.minHeight = minHeight;
return rectTransform;
}
private Button CreateButton(RectTransform parent, string name, string labelText, Vector2 size, Action onClick, out TextMeshProUGUI label)
{
GameObject buttonObject = CreateUiObject(name, parent);
RectTransform rectTransform = buttonObject.AddComponent<RectTransform>();
if (size.sqrMagnitude > 0f)
{
rectTransform.sizeDelta = size;
}
Image image = buttonObject.AddComponent<Image>();
image.color = buttonNormalColor;
Button button = buttonObject.AddComponent<Button>();
button.targetGraphic = image;
button.onClick.AddListener(() => onClick?.Invoke());
label = CreateFillLabel(rectTransform, "Label", labelText, 18f, TextAlignmentOptions.Center);
label.textWrappingMode = TextWrappingModes.Normal;
label.margin = new Vector4(12f, 6f, 12f, 6f);
return button;
}
private TextMeshProUGUI CreateAutoLabel(RectTransform parent, string name, string text, float fontSize, TextAlignmentOptions alignment)
{
GameObject labelObject = CreateUiObject(name, parent);
RectTransform rectTransform = labelObject.AddComponent<RectTransform>();
rectTransform.anchorMin = new Vector2(0f, 1f);
rectTransform.anchorMax = new Vector2(1f, 1f);
rectTransform.pivot = new Vector2(0.5f, 1f);
rectTransform.sizeDelta = Vector2.zero;
TextMeshProUGUI label = labelObject.AddComponent<TextMeshProUGUI>();
label.font = sharedFont;
label.fontSize = fontSize;
label.color = new Color(0.94f, 0.92f, 0.86f, 1f);
label.text = text;
label.alignment = alignment;
label.textWrappingMode = TextWrappingModes.Normal;
ContentSizeFitter fitter = labelObject.AddComponent<ContentSizeFitter>();
fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
return label;
}
private TextMeshProUGUI CreateFillLabel(RectTransform parent, string name, string text, float fontSize, TextAlignmentOptions alignment)
{
TextMeshProUGUI label = CreateAutoLabel(parent, name, text, fontSize, alignment);
StretchToParent(label.rectTransform);
return label;
}
private static GameObject CreateUiObject(string name, RectTransform parent)
{
GameObject gameObject = new GameObject(name);
gameObject.transform.SetParent(parent, false);
return gameObject;
}
private static void StretchToParent(RectTransform rectTransform)
{
rectTransform.anchorMin = Vector2.zero;
rectTransform.anchorMax = Vector2.one;
rectTransform.pivot = new Vector2(0.5f, 0.5f);
rectTransform.offsetMin = Vector2.zero;
rectTransform.offsetMax = Vector2.zero;
}
private void RefreshAll()
{
if (!uiInitialized)
return;
UpdatePanelSize();
NormalizeSelection();
RefreshSkillSummary();
RefreshStorageSummary();
RebuildSkillButtons();
RebuildSocketButtons();
RebuildGemButtons();
RefreshGemDetail();
RefreshCommandButtons();
}
private void NormalizeSelection()
{
if (playerSkillInput == null)
{
selectedSkillSlotIndex = 0;
selectedSocketIndex = 0;
return;
}
int slotCount = playerSkillInput.SkillLoadoutEntries != null ? playerSkillInput.SkillLoadoutEntries.Length : 0;
if (slotCount <= 0)
{
selectedSkillSlotIndex = 0;
selectedSocketIndex = 0;
return;
}
selectedSkillSlotIndex = Mathf.Clamp(selectedSkillSlotIndex, 0, slotCount - 1);
SkillLoadoutEntry selectedEntry = playerSkillInput.GetSkillLoadout(selectedSkillSlotIndex);
int socketCount = selectedEntry?.SocketedGems != null ? selectedEntry.SocketedGems.Count : 0;
if (socketCount <= 0)
{
selectedSocketIndex = 0;
}
else
{
selectedSocketIndex = Mathf.Clamp(selectedSocketIndex, 0, socketCount - 1);
}
if (selectedGem != null && FindStorageEntry(selectedGem) == null)
{
selectedGem = null;
}
}
private void RefreshSkillSummary()
{
if (skillSummaryText == null)
return;
if (playerSkillInput == null)
{
skillSummaryText.text = "로컬 플레이어를 찾는 중입니다.\n플레이어가 생성되면 스킬과 젬 소켓을 표시합니다.";
return;
}
SkillLoadoutEntry entry = playerSkillInput.GetSkillLoadout(selectedSkillSlotIndex);
SkillData skill = entry?.BaseSkill;
if (skill == null)
{
skillSummaryText.text = $"{GetSlotDisplayName(selectedSkillSlotIndex)}\n비어 있는 슬롯입니다.";
return;
}
StringBuilder builder = new StringBuilder();
builder.AppendLine(GetSlotDisplayName(selectedSkillSlotIndex));
builder.AppendLine($"기반 기술: {skill.SkillName}");
builder.AppendLine($"분류: {SkillClassificationUtility.GetSkillClassificationLabel(skill)}");
builder.AppendLine($"젬 슬롯: {skill.MaxGemSlotCount}칸");
builder.AppendLine($"마나: {entry.GetResolvedManaCost():0.##} | 쿨타임: {entry.GetResolvedCooldown():0.##}초");
builder.Append($"장착 현황: {BuildSocketSummary(entry)}");
skillSummaryText.text = builder.ToString();
}
private void RefreshStorageSummary()
{
if (storageSummaryText == null)
return;
int gemTypeCount = 0;
int totalOwnedCount = 0;
int totalEquippedCount = 0;
for (int i = 0; i < ownedGemEntries.Count; i++)
{
SkillGemStorageEntry entry = ownedGemEntries[i];
if (entry == null || entry.Gem == null)
continue;
gemTypeCount++;
totalOwnedCount += entry.Quantity;
totalEquippedCount += GetEquippedCount(entry.Gem);
}
storageSummaryText.text =
$"보유 종류: {gemTypeCount}\n" +
$"총 보유 수량: {totalOwnedCount}\n" +
$"현재 장착 수량: {totalEquippedCount}";
}
private void RebuildSkillButtons()
{
if (skillListContent == null)
return;
ClearChildren(skillListContent);
if (playerSkillInput == null)
{
CreateListMessage(skillListContent, "플레이어 스킬 정보를 기다리는 중입니다.");
return;
}
int slotCount = playerSkillInput.SkillLoadoutEntries != null ? playerSkillInput.SkillLoadoutEntries.Length : 0;
for (int i = 0; i < slotCount; i++)
{
int capturedIndex = i;
SkillLoadoutEntry entry = playerSkillInput.GetSkillLoadout(i);
SkillData skill = entry?.BaseSkill;
string label =
$"{GetSlotDisplayName(i)}\n" +
$"{(skill != null ? skill.SkillName : " ")}\n" +
$"장착: {BuildSocketSummary(entry)}";
Button button = CreateListButton(skillListContent, $"Button_Skill_{i}", label, () =>
{
selectedSkillSlotIndex = capturedIndex;
selectedSocketIndex = 0;
RefreshAll();
});
ApplyButtonState(button, i == selectedSkillSlotIndex, true);
}
}
private void RebuildSocketButtons()
{
if (socketListContent == null)
return;
ClearChildren(socketListContent);
if (playerSkillInput == null)
{
CreateListMessage(socketListContent, "플레이어가 준비되면 소켓을 표시합니다.");
return;
}
SkillLoadoutEntry entry = playerSkillInput.GetSkillLoadout(selectedSkillSlotIndex);
SkillData skill = entry?.BaseSkill;
if (skill == null)
{
CreateListMessage(socketListContent, "비어 있는 슬롯입니다.");
return;
}
if (skill.MaxGemSlotCount <= 0)
{
CreateListMessage(socketListContent, "이 기술은 젬 슬롯이 없습니다.");
return;
}
for (int i = 0; i < skill.MaxGemSlotCount; i++)
{
int capturedIndex = i;
SkillGemData equippedGem = entry.GetGem(i);
string label =
$"소켓 {i + 1}\n" +
$"{(equippedGem != null ? equippedGem.GemName : " ")}";
Button button = CreateListButton(socketListContent, $"Button_Socket_{i}", label, () =>
{
selectedSocketIndex = capturedIndex;
RefreshAll();
});
ApplyButtonState(button, i == selectedSocketIndex, true);
}
}
private void RebuildGemButtons()
{
if (gemListContent == null)
return;
ClearChildren(gemListContent);
int createdCount = 0;
for (int i = 0; i < ownedGemEntries.Count; i++)
{
SkillGemStorageEntry entry = ownedGemEntries[i];
if (entry == null || entry.Gem == null)
continue;
int equippedCount = GetEquippedCount(entry.Gem);
int remainingCount = Mathf.Max(0, entry.Quantity - equippedCount);
SkillGemData gem = entry.Gem;
string label =
$"{gem.GemName}\n" +
$"효과: {SkillClassificationUtility.GetGemCategoryLabel(gem.Category)} | " +
$"장착: {GetGemAllowedSummary(gem)}\n" +
$"남은 수량 {remainingCount}/{entry.Quantity}";
Button button = CreateListButton(gemListContent, $"Button_Gem_{i}", label, () =>
{
selectedGem = gem;
RefreshAll();
});
ApplyButtonState(button, selectedGem == gem, entry.Quantity > 0);
createdCount++;
}
if (createdCount <= 0)
{
CreateListMessage(gemListContent, "보유한 젬이 없습니다.");
}
}
private void RefreshGemDetail()
{
if (gemDetailText == null)
return;
if (selectedGem == null)
{
gemDetailText.text = "보관함에서 젬을 선택하면 설명과 수치 정보를 표시합니다.";
return;
}
SkillGemStorageEntry storageEntry = FindStorageEntry(selectedGem);
int totalQuantity = storageEntry != null ? storageEntry.Quantity : 0;
int equippedCount = GetEquippedCount(selectedGem);
int remainingCount = Mathf.Max(0, totalQuantity - equippedCount);
StringBuilder builder = new StringBuilder();
builder.AppendLine(selectedGem.GemName);
builder.AppendLine($"효과 분류: {SkillClassificationUtility.GetGemCategoryLabel(selectedGem.Category)}");
builder.AppendLine($"장착 가능: {GetGemAllowedSummary(selectedGem)}");
builder.AppendLine($"보관: {remainingCount}/{totalQuantity}");
if (!string.IsNullOrWhiteSpace(selectedGem.Description))
{
builder.AppendLine();
builder.AppendLine(selectedGem.Description);
}
builder.AppendLine();
builder.AppendLine(BuildGemStatSummary(selectedGem));
string incompatibleSummary = BuildIncompatibleSummary(selectedGem);
if (!string.IsNullOrEmpty(incompatibleSummary))
{
builder.AppendLine();
builder.AppendLine(incompatibleSummary);
}
gemDetailText.text = builder.ToString().TrimEnd();
}
private void RefreshCommandButtons()
{
if (equipButton == null || unequipButton == null)
return;
string equipReason = GetEquipBlockedReason();
bool canEquip = string.IsNullOrEmpty(equipReason);
ApplyButtonState(equipButton, false, canEquip);
ApplyButtonState(unequipButton, false, CanUnequipSelectedSocket());
if (equipButtonLabel != null)
{
equipButtonLabel.text = canEquip ? "선택한 젬 장착" : "장착 불가";
}
if (unequipButtonLabel != null)
{
unequipButtonLabel.text = CanUnequipSelectedSocket() ? "선택한 젬 탈착" : "탈착할 젬 없음";
}
if (playerSkillInput == null)
{
SetStatusMessage("로컬 플레이어를 찾는 중입니다.", false);
}
else if (isPanelVisible)
{
SetStatusMessage(canEquip ? BuildSelectionHint() : equipReason, !canEquip);
}
}
private string BuildSelectionHint()
{
SkillLoadoutEntry entry = playerSkillInput != null ? playerSkillInput.GetSkillLoadout(selectedSkillSlotIndex) : null;
SkillData skill = entry?.BaseSkill;
if (skill == null)
return "젬을 장착할 기술 슬롯을 선택하세요.";
if (selectedGem == null)
return "보관함에서 장착할 젬을 선택하세요.";
return $"'{selectedGem.GemName}'을(를) {GetSlotDisplayName(selectedSkillSlotIndex)} 소켓 {selectedSocketIndex + 1}에 장착할 수 있습니다.";
}
private void TryEquipSelectedGem()
{
string blockedReason = GetEquipBlockedReason();
if (!string.IsNullOrEmpty(blockedReason))
{
SetStatusMessage(blockedReason, true);
RefreshAll();
return;
}
playerSkillInput.SetSkillGem(selectedSkillSlotIndex, selectedSocketIndex, selectedGem);
SetStatusMessage($"'{selectedGem.GemName}' 장착 완료", false);
RefreshAll();
}
private void TryUnequipSelectedGem()
{
if (playerSkillInput == null)
{
SetStatusMessage("로컬 플레이어를 찾지 못했습니다.", true);
return;
}
SkillLoadoutEntry entry = playerSkillInput.GetSkillLoadout(selectedSkillSlotIndex);
SkillGemData equippedGem = entry?.GetGem(selectedSocketIndex);
if (equippedGem == null)
{
SetStatusMessage("선택한 소켓에 장착된 젬이 없습니다.", true);
RefreshAll();
return;
}
playerSkillInput.SetSkillGem(selectedSkillSlotIndex, selectedSocketIndex, null);
SetStatusMessage($"'{equippedGem.GemName}' 탈착 완료", false);
RefreshAll();
}
private bool CanUnequipSelectedSocket()
{
if (playerSkillInput == null)
return false;
SkillLoadoutEntry entry = playerSkillInput.GetSkillLoadout(selectedSkillSlotIndex);
return entry?.GetGem(selectedSocketIndex) != null;
}
private string GetEquipBlockedReason()
{
if (playerSkillInput == null)
return "로컬 플레이어를 찾는 중입니다.";
SkillLoadoutEntry entry = playerSkillInput.GetSkillLoadout(selectedSkillSlotIndex);
SkillData skill = entry?.BaseSkill;
if (skill == null)
return "비어 있는 슬롯에는 젬을 장착할 수 없습니다.";
if (skill.MaxGemSlotCount <= 0)
return "이 기술은 젬 슬롯이 없습니다.";
if (selectedSocketIndex < 0 || selectedSocketIndex >= skill.MaxGemSlotCount)
return "유효한 소켓을 선택하세요.";
if (selectedGem == null)
return "보관함에서 장착할 젬을 선택하세요.";
SkillGemStorageEntry storageEntry = FindStorageEntry(selectedGem);
if (storageEntry == null || storageEntry.Quantity <= 0)
return "선택한 젬이 보관함에 없습니다.";
SkillGemData currentGem = entry.GetGem(selectedSocketIndex);
if (currentGem == selectedGem)
return "이미 같은 젬이 장착되어 있습니다.";
int remainingCount = storageEntry.Quantity - GetEquippedCount(selectedGem);
if (remainingCount <= 0)
return "보유한 수량을 모두 장착했습니다.";
SkillLoadoutEntry validationCopy = entry.CreateCopy();
if (!validationCopy.TrySetGem(selectedSocketIndex, selectedGem, out string reason))
return reason;
return string.Empty;
}
private SkillGemStorageEntry FindStorageEntry(SkillGemData gem)
{
if (gem == null)
return null;
for (int i = 0; i < ownedGemEntries.Count; i++)
{
SkillGemStorageEntry entry = ownedGemEntries[i];
if (entry != null && entry.Gem == gem)
return entry;
}
return null;
}
private int GetEquippedCount(SkillGemData gem)
{
if (gem == null || playerSkillInput == null || playerSkillInput.SkillLoadoutEntries == null)
return 0;
int count = 0;
for (int slotIndex = 0; slotIndex < playerSkillInput.SkillLoadoutEntries.Length; slotIndex++)
{
SkillLoadoutEntry entry = playerSkillInput.GetSkillLoadout(slotIndex);
if (entry?.SocketedGems == null)
continue;
for (int socketIndex = 0; socketIndex < entry.SocketedGems.Count; socketIndex++)
{
if (entry.GetGem(socketIndex) == gem)
{
count++;
}
}
}
return count;
}
private static string BuildSocketSummary(SkillLoadoutEntry entry)
{
if (entry == null || entry.SocketedGems == null || entry.SocketedGems.Count == 0)
return "없음";
StringBuilder builder = new StringBuilder();
for (int i = 0; i < entry.SocketedGems.Count; i++)
{
SkillGemData gem = entry.GetGem(i);
if (i > 0)
builder.Append(", ");
builder.Append(gem != null ? gem.GemName : "비어 있음");
}
return builder.ToString();
}
private static string BuildGemStatSummary(SkillGemData gem)
{
List<string> lines = new List<string>();
if (!Mathf.Approximately(gem.ManaCostMultiplier, 1f))
lines.Add($"마나 비용 x{gem.ManaCostMultiplier:0.##}");
if (!Mathf.Approximately(gem.CooldownMultiplier, 1f))
lines.Add($"쿨타임 x{gem.CooldownMultiplier:0.##}");
if (!Mathf.Approximately(gem.CastSpeedMultiplier, 1f))
lines.Add($"시전 속도 x{gem.CastSpeedMultiplier:0.##}");
if (!Mathf.Approximately(gem.DamageMultiplier, 1f))
lines.Add($"피해량 x{gem.DamageMultiplier:0.##}");
if (!Mathf.Approximately(gem.HealMultiplier, 1f))
lines.Add($"회복량 x{gem.HealMultiplier:0.##}");
if (!Mathf.Approximately(gem.ShieldMultiplier, 1f))
lines.Add($"보호막량 x{gem.ShieldMultiplier:0.##}");
if (!Mathf.Approximately(gem.ThreatMultiplier, 1f))
lines.Add($"위협량 x{gem.ThreatMultiplier:0.##}");
if (gem.AdditionalRepeatCount > 0)
lines.Add($"추가 반복 +{gem.AdditionalRepeatCount}");
return lines.Count <= 0 ? "추가 수치 보정 없음" : string.Join("\n", lines);
}
private static string BuildIncompatibleSummary(SkillGemData gem)
{
List<string> entries = new List<string>();
if (gem.IncompatibleCategories != null && gem.IncompatibleCategories.Count > 0)
{
StringBuilder categoryBuilder = new StringBuilder();
categoryBuilder.Append("함께 장착 불가 분류: ");
for (int i = 0; i < gem.IncompatibleCategories.Count; i++)
{
if (i > 0)
categoryBuilder.Append(", ");
categoryBuilder.Append(SkillClassificationUtility.GetGemCategoryLabel(gem.IncompatibleCategories[i]));
}
entries.Add(categoryBuilder.ToString());
}
if (gem.IncompatibleGems != null && gem.IncompatibleGems.Count > 0)
{
StringBuilder gemBuilder = new StringBuilder();
gemBuilder.Append("함께 장착 불가 젬: ");
bool hasGem = false;
for (int i = 0; i < gem.IncompatibleGems.Count; i++)
{
if (gem.IncompatibleGems[i] == null)
continue;
if (hasGem)
gemBuilder.Append(", ");
gemBuilder.Append(gem.IncompatibleGems[i].GemName);
hasGem = true;
}
if (hasGem)
{
entries.Add(gemBuilder.ToString());
}
}
return entries.Count > 0 ? string.Join("\n", entries) : string.Empty;
}
private static string GetSlotDisplayName(int slotIndex)
{
if (slotIndex >= 0 && slotIndex < SlotDisplayNames.Length)
return SlotDisplayNames[slotIndex];
return $"기술 {slotIndex + 1}";
}
private static string GetGemAllowedSummary(SkillGemData gem)
{
if (gem == null)
return "전체";
return
$"{SkillClassificationUtility.GetAllowedRoleSummary(gem.AllowedSkillRoles)}/" +
$"{SkillClassificationUtility.GetAllowedActivationSummary(gem.AllowedSkillActivationTypes)}";
}
private void ApplyButtonState(Button button, bool selected, bool interactable)
{
if (button == null)
return;
button.interactable = interactable;
Image image = button.image;
if (image == null)
return;
image.color = !interactable
? buttonDisabledColor
: selected ? buttonSelectedColor : buttonNormalColor;
}
private Button CreateListButton(RectTransform parent, string name, string label, Action onClick)
{
Button button = CreateButton(parent, name, label, Vector2.zero, onClick, out TextMeshProUGUI textLabel);
textLabel.alignment = TextAlignmentOptions.Left;
textLabel.margin = new Vector4(12f, 10f, 12f, 10f);
LayoutElement layout = button.gameObject.AddComponent<LayoutElement>();
layout.minHeight = 74f;
return button;
}
private void CreateListMessage(RectTransform parent, string message)
{
TextMeshProUGUI label = CreateAutoLabel(parent, "Label_Message", message, 18f, TextAlignmentOptions.TopLeft);
LayoutElement layout = label.gameObject.AddComponent<LayoutElement>();
layout.minHeight = 56f;
}
private static void ClearChildren(RectTransform parent)
{
for (int i = parent.childCount - 1; i >= 0; i--)
{
Destroy(parent.GetChild(i).gameObject);
}
}
private void SetStatusMessage(string message, bool isError)
{
if (statusText == null)
return;
statusText.text = message;
statusText.color = isError ? statusErrorColor : statusNormalColor;
}
#if UNITY_EDITOR
private void AutoCollectOwnedGemsInEditor()
{
if (!autoCollectOwnedGemsInEditor)
return;
string searchFolder = string.IsNullOrWhiteSpace(gemSearchFolder) ? DefaultGemSearchFolder : gemSearchFolder.Trim();
if (!AssetDatabase.IsValidFolder(searchFolder))
return;
Dictionary<SkillGemData, int> existingQuantities = new Dictionary<SkillGemData, int>();
for (int i = 0; i < ownedGemEntries.Count; i++)
{
SkillGemStorageEntry entry = ownedGemEntries[i];
if (entry == null || entry.Gem == null)
continue;
if (!existingQuantities.ContainsKey(entry.Gem))
{
existingQuantities.Add(entry.Gem, entry.Quantity);
}
}
string[] guids = AssetDatabase.FindAssets("t:SkillGemData", new[] { searchFolder });
Array.Sort(guids, (left, right) =>
{
string leftPath = AssetDatabase.GUIDToAssetPath(left);
string rightPath = AssetDatabase.GUIDToAssetPath(right);
return string.Compare(leftPath, rightPath, StringComparison.Ordinal);
});
ownedGemEntries.Clear();
for (int i = 0; i < guids.Length; i++)
{
string assetPath = AssetDatabase.GUIDToAssetPath(guids[i]);
SkillGemData gem = AssetDatabase.LoadAssetAtPath<SkillGemData>(assetPath);
if (gem == null)
continue;
SkillGemStorageEntry entry = new SkillGemStorageEntry
{
Gem = gem,
Quantity = existingQuantities.TryGetValue(gem, out int quantity) ? quantity : autoCollectedGemQuantity,
};
ownedGemEntries.Add(entry);
}
}
#endif
}
}