- 패시브 트리 노드 배치를 삼각형 기반 자동 배치 구조로 전환하고 축 및 브릿지 반경을 재정리\n- 패시브 UI 프리팹과 런타임 렌더링을 수정해 노드 겹침, 링크 관통, 상태별 간격 변화, 하단 여백 문제를 정리\n- 프로토타입 패시브 노드, 트리, 프리셋 자산을 재생성해 최신 레이아웃과 확장 노드 구성을 반영
1454 lines
58 KiB
C#
1454 lines
58 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Text;
|
|
|
|
using TMPro;
|
|
|
|
using UnityEngine;
|
|
using UnityEngine.InputSystem;
|
|
using UnityEngine.UI;
|
|
|
|
using Colosseum.Passives;
|
|
using Colosseum.Player;
|
|
|
|
namespace Colosseum.UI
|
|
{
|
|
/// <summary>
|
|
/// 비전투 중 전체 화면으로 여는 패시브 트리 UI입니다.
|
|
/// </summary>
|
|
public class PassiveTreeUI : MonoBehaviour
|
|
{
|
|
private sealed class NodeVisual
|
|
{
|
|
public RectTransform RectTransform;
|
|
public Button Button;
|
|
public Image Image;
|
|
public Image FillImage;
|
|
public Image InnerImage;
|
|
public Outline Outline;
|
|
}
|
|
|
|
[Header("Toggle")]
|
|
[Tooltip("패시브 UI를 여닫는 키")]
|
|
[SerializeField] private Key toggleKey = Key.P;
|
|
[Tooltip("토글 버튼에 표시할 텍스트")]
|
|
[SerializeField] private string toggleButtonLabel = "패시브";
|
|
[Tooltip("토글 버튼의 캔버스 기준 위치")]
|
|
[SerializeField] private Vector2 toggleButtonAnchoredPosition = new Vector2(-132f, 48f);
|
|
|
|
[Header("Debug")]
|
|
[Tooltip("플레이 모드 시작 시 패널을 자동으로 엽니다.")]
|
|
[SerializeField] private bool openOnStartInPlayMode = false;
|
|
|
|
[Header("Prefabs")]
|
|
[Tooltip("패시브 트리 메인 뷰 프리팹")]
|
|
[SerializeField] private PassiveTreeViewReferences viewPrefab;
|
|
[Tooltip("패시브 노드 프리팹")]
|
|
[SerializeField] private PassiveTreeNodeView nodePrefab;
|
|
[Tooltip("프로토타입 패시브 카탈로그")]
|
|
[SerializeField] private PassivePrototypeCatalogData passivePrototypeCatalog;
|
|
[Tooltip("일반 패시브 노드에 사용할 아이콘")]
|
|
[SerializeField] private Sprite normalNodeSprite;
|
|
[Tooltip("허브/브릿지/완성 패시브 노드에 사용할 아이콘")]
|
|
[SerializeField] private Sprite specialNodeSprite;
|
|
[Tooltip("노드 안쪽에 겹쳐서 사용할 보조 아이콘")]
|
|
[SerializeField] private Sprite innerNodeSprite;
|
|
|
|
[Header("Style")]
|
|
[SerializeField] private Color panelBackgroundColor = new Color(0.07f, 0.07f, 0.1f, 0.98f);
|
|
[SerializeField] private Color sectionBackgroundColor = new Color(0.12f, 0.12f, 0.16f, 0.96f);
|
|
[SerializeField] private Color sectionOverlayColor = new Color(0f, 0f, 0f, 0.12f);
|
|
[SerializeField] private Color attackColor = new Color(0.78f, 0.3f, 0.22f, 1f);
|
|
[SerializeField] private Color defenseColor = new Color(0.2f, 0.56f, 0.6f, 1f);
|
|
[SerializeField] private Color supportColor = new Color(0.56f, 0.67f, 0.24f, 1f);
|
|
[SerializeField] private Color bridgeColor = new Color(0.58f, 0.47f, 0.23f, 1f);
|
|
[SerializeField] private Color lockedNodeColor = new Color(0.15f, 0.15f, 0.17f, 0.96f);
|
|
[SerializeField] private Color selectedNodeTint = new Color(1f, 0.92f, 0.68f, 1f);
|
|
[SerializeField] private Color focusedOutlineColor = new Color(0.95f, 0.92f, 0.82f, 1f);
|
|
[SerializeField] private Color normalTextColor = new Color(0.9f, 0.88f, 0.82f, 1f);
|
|
[SerializeField] private Color mutedTextColor = new Color(0.72f, 0.71f, 0.66f, 0.95f);
|
|
[SerializeField] private Color statusErrorColor = new Color(1f, 0.52f, 0.45f, 1f);
|
|
[SerializeField] private Color lineColor = new Color(0.72f, 0.67f, 0.53f, 0.4f);
|
|
[SerializeField] private Color activeLineColor = new Color(0.98f, 0.9f, 0.7f, 0.92f);
|
|
[SerializeField] private float graphCenterYOffset = -118f;
|
|
private const float LeftPanelWidth = 292f;
|
|
private const float RightPanelWidth = 308f;
|
|
private const float GraphPadding = 24f;
|
|
private const float ConnectionThickness = 7f;
|
|
|
|
private readonly Dictionary<PassiveNodeData, NodeVisual> nodeVisuals = new();
|
|
|
|
private PlayerNetworkController targetPlayer;
|
|
private PlayerMovement playerMovement;
|
|
private PlayerSkillInput playerSkillInput;
|
|
private Canvas parentCanvas;
|
|
private RectTransform canvasRectTransform;
|
|
private PassiveTreeViewReferences viewInstance;
|
|
|
|
private GameObject overlayRoot;
|
|
private RectTransform panelRectTransform;
|
|
private RectTransform graphRectTransform;
|
|
private RectTransform connectionLayer;
|
|
private RectTransform nodeLayer;
|
|
private RectTransform detailContent;
|
|
|
|
private TextMeshProUGUI pointsSummaryText;
|
|
private TextMeshProUGUI selectionSummaryText;
|
|
private TextMeshProUGUI detailText;
|
|
private TextMeshProUGUI statusText;
|
|
|
|
private Button nonePresetButton;
|
|
private Button defensePresetButton;
|
|
private Button supportPresetButton;
|
|
private Button attackPresetButton;
|
|
private Button clearButton;
|
|
private Button selectNodeButton;
|
|
private TextMeshProUGUI selectNodeButtonLabel;
|
|
|
|
private bool isPanelVisible;
|
|
private bool uiInitialized;
|
|
private bool previousCursorVisible;
|
|
private CursorLockMode previousCursorLockState;
|
|
private PassiveNodeData focusedNode;
|
|
private string lastStatusMessage = string.Empty;
|
|
private bool lastStatusIsError;
|
|
|
|
private void Awake()
|
|
{
|
|
EnsureUi();
|
|
HidePanelImmediate();
|
|
}
|
|
|
|
private void Start()
|
|
{
|
|
EnsureUi();
|
|
FindLocalPlayer();
|
|
RefreshAll();
|
|
|
|
if (Application.isPlaying && openOnStartInPlayMode)
|
|
{
|
|
SetPanelVisible(true);
|
|
}
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (targetPlayer == null)
|
|
{
|
|
FindLocalPlayer();
|
|
}
|
|
|
|
if (Keyboard.current == null)
|
|
return;
|
|
|
|
if (Keyboard.current[toggleKey].wasPressedThisFrame)
|
|
{
|
|
TogglePanelVisibility();
|
|
}
|
|
else if (isPanelVisible && Keyboard.current.escapeKey.wasPressedThisFrame)
|
|
{
|
|
SetPanelVisible(false);
|
|
}
|
|
|
|
if (isPanelVisible)
|
|
{
|
|
UpdatePanelSize();
|
|
}
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
UnsubscribeFromPlayer();
|
|
SetGameplayInputBlocked(false);
|
|
RestoreCursorState();
|
|
}
|
|
|
|
private void FindLocalPlayer()
|
|
{
|
|
PlayerNetworkController[] players = FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);
|
|
for (int i = 0; i < players.Length; i++)
|
|
{
|
|
if (!players[i].IsOwner)
|
|
continue;
|
|
|
|
if (targetPlayer == players[i])
|
|
return;
|
|
|
|
SetTarget(players[i]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
private void SetTarget(PlayerNetworkController target)
|
|
{
|
|
UnsubscribeFromPlayer();
|
|
|
|
targetPlayer = target;
|
|
playerMovement = targetPlayer != null ? targetPlayer.GetComponent<PlayerMovement>() : null;
|
|
playerSkillInput = targetPlayer != null ? targetPlayer.GetComponent<PlayerSkillInput>() : null;
|
|
|
|
if (targetPlayer != null)
|
|
{
|
|
targetPlayer.OnPassiveSelectionChanged += HandlePassiveSelectionChanged;
|
|
}
|
|
|
|
if (isPanelVisible)
|
|
{
|
|
SetGameplayInputBlocked(true);
|
|
}
|
|
|
|
RefreshAll();
|
|
}
|
|
|
|
private void UnsubscribeFromPlayer()
|
|
{
|
|
if (targetPlayer != null)
|
|
{
|
|
targetPlayer.OnPassiveSelectionChanged -= HandlePassiveSelectionChanged;
|
|
}
|
|
}
|
|
|
|
private void HandlePassiveSelectionChanged()
|
|
{
|
|
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("[PassiveTreeUI] Canvas를 찾지 못했습니다.");
|
|
return;
|
|
}
|
|
|
|
if (!EnsureViewInstance())
|
|
return;
|
|
|
|
BindViewReferences();
|
|
UpdatePanelSize();
|
|
uiInitialized = overlayRoot != null && panelRectTransform != null && graphRectTransform != null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 런타임에 사용할 패시브 트리 뷰 프리팹을 외부에서 주입합니다.
|
|
/// </summary>
|
|
public void ConfigurePrefabReferences(PassiveTreeViewReferences configuredViewPrefab, PassiveTreeNodeView configuredNodePrefab)
|
|
{
|
|
viewPrefab = configuredViewPrefab;
|
|
nodePrefab = configuredNodePrefab;
|
|
}
|
|
|
|
private bool EnsureViewInstance()
|
|
{
|
|
if (viewInstance != null)
|
|
return true;
|
|
|
|
viewInstance = GetComponentInChildren<PassiveTreeViewReferences>(true);
|
|
if (viewInstance != null)
|
|
return true;
|
|
|
|
if (viewPrefab == null)
|
|
{
|
|
Debug.LogWarning("[PassiveTreeUI] 메인 뷰 프리팹이 지정되지 않았습니다.");
|
|
return false;
|
|
}
|
|
|
|
viewInstance = Instantiate(viewPrefab, canvasRectTransform);
|
|
viewInstance.name = viewPrefab.name;
|
|
return viewInstance != null;
|
|
}
|
|
|
|
private void BindViewReferences()
|
|
{
|
|
if (viewInstance == null)
|
|
return;
|
|
|
|
overlayRoot = viewInstance.OverlayRoot;
|
|
panelRectTransform = viewInstance.PanelRect;
|
|
graphRectTransform = viewInstance.GraphRect;
|
|
connectionLayer = viewInstance.ConnectionLayer;
|
|
nodeLayer = viewInstance.NodeLayer;
|
|
detailContent = viewInstance.DetailContent;
|
|
pointsSummaryText = viewInstance.PointsSummaryText;
|
|
selectionSummaryText = viewInstance.SelectionSummaryText;
|
|
detailText = viewInstance.DetailText;
|
|
statusText = viewInstance.StatusText;
|
|
nonePresetButton = viewInstance.NonePresetButton;
|
|
defensePresetButton = viewInstance.DefensePresetButton;
|
|
supportPresetButton = viewInstance.SupportPresetButton;
|
|
attackPresetButton = viewInstance.AttackPresetButton;
|
|
clearButton = viewInstance.ClearButton;
|
|
selectNodeButton = viewInstance.SelectNodeButton;
|
|
selectNodeButtonLabel = viewInstance.SelectNodeButtonLabel;
|
|
|
|
ApplyStaticViewSettings();
|
|
if (statusText != null)
|
|
{
|
|
statusText.textWrappingMode = TextWrappingModes.NoWrap;
|
|
statusText.overflowMode = TextOverflowModes.Ellipsis;
|
|
}
|
|
BindButton(viewInstance.ToggleButton, TogglePanelVisibility);
|
|
BindButton(viewInstance.CloseButton, () => SetPanelVisible(false));
|
|
BindButton(nonePresetButton, () => TryApplyPreset(PassivePrototypePresetKind.None));
|
|
BindButton(defensePresetButton, () => TryApplyPreset(PassivePrototypePresetKind.Defense));
|
|
BindButton(supportPresetButton, () => TryApplyPreset(PassivePrototypePresetKind.Support));
|
|
BindButton(attackPresetButton, () => TryApplyPreset(PassivePrototypePresetKind.Attack));
|
|
BindButton(clearButton, TryClearSelection);
|
|
BindButton(selectNodeButton, TrySelectFocusedNode);
|
|
}
|
|
|
|
private void ApplyStaticViewSettings()
|
|
{
|
|
if (viewInstance?.ToggleButtonLabel != null)
|
|
{
|
|
viewInstance.ToggleButtonLabel.text = toggleButtonLabel;
|
|
}
|
|
|
|
if (viewInstance?.ToggleButton != null)
|
|
{
|
|
RectTransform toggleRect = viewInstance.ToggleButton.GetComponent<RectTransform>();
|
|
if (toggleRect != null)
|
|
{
|
|
toggleRect.anchoredPosition = toggleButtonAnchoredPosition;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void BindButton(Button button, Action callback)
|
|
{
|
|
if (button == null || callback == null)
|
|
return;
|
|
|
|
button.onClick.RemoveAllListeners();
|
|
button.onClick.AddListener(() => callback());
|
|
}
|
|
|
|
private void UpdatePanelSize()
|
|
{
|
|
if (panelRectTransform == null || canvasRectTransform == null)
|
|
return;
|
|
|
|
Rect canvasRect = canvasRectTransform.rect;
|
|
panelRectTransform.sizeDelta = new Vector2(
|
|
Mathf.Max(1160f, canvasRect.width - 24f),
|
|
Mathf.Max(720f, canvasRect.height - 24f));
|
|
}
|
|
|
|
private void CreateToggleButton()
|
|
{
|
|
GameObject buttonObject = CreateUiObject("Button_PassiveTree", 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(110f, 40f);
|
|
|
|
Image buttonImage = buttonObject.AddComponent<Image>();
|
|
buttonImage.color = new Color(0.18f, 0.21f, 0.16f, 0.96f);
|
|
|
|
Button button = buttonObject.AddComponent<Button>();
|
|
button.targetGraphic = buttonImage;
|
|
button.onClick.AddListener(TogglePanelVisibility);
|
|
|
|
TextMeshProUGUI label = CreateFillLabel(buttonRect, "Label", toggleButtonLabel, 20f, TextAlignmentOptions.Center);
|
|
label.fontStyle = FontStyles.Bold;
|
|
label.textWrappingMode = TextWrappingModes.NoWrap;
|
|
}
|
|
|
|
private void CreateOverlay()
|
|
{
|
|
overlayRoot = CreateUiObject("Overlay_PassiveTree", 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.52f);
|
|
|
|
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(1380f, 820f);
|
|
|
|
Image panelImage = panelObject.AddComponent<Image>();
|
|
panelImage.color = panelBackgroundColor;
|
|
|
|
VerticalLayoutGroup panelLayout = panelObject.AddComponent<VerticalLayoutGroup>();
|
|
panelLayout.padding = new RectOffset(22, 22, 22, 22);
|
|
panelLayout.spacing = 16f;
|
|
panelLayout.childAlignment = TextAnchor.UpperLeft;
|
|
panelLayout.childControlWidth = true;
|
|
panelLayout.childControlHeight = true;
|
|
panelLayout.childForceExpandWidth = true;
|
|
panelLayout.childForceExpandHeight = false;
|
|
|
|
CreateHeader(panelRectTransform);
|
|
CreateBody(panelRectTransform);
|
|
CreateFooter(panelRectTransform);
|
|
}
|
|
|
|
private void CreateHeader(RectTransform parent)
|
|
{
|
|
RectTransform header = CreateContainer(parent, "Header", 54f);
|
|
header.gameObject.AddComponent<Image>().color = sectionBackgroundColor;
|
|
|
|
HorizontalLayoutGroup layout = header.gameObject.AddComponent<HorizontalLayoutGroup>();
|
|
layout.padding = new RectOffset(14, 14, 8, 8);
|
|
layout.spacing = 10f;
|
|
layout.childAlignment = TextAnchor.MiddleLeft;
|
|
layout.childControlWidth = true;
|
|
layout.childControlHeight = true;
|
|
layout.childForceExpandWidth = false;
|
|
layout.childForceExpandHeight = false;
|
|
|
|
RectTransform titleGroup = CreateUiObject("TitleGroup", header).AddComponent<RectTransform>();
|
|
titleGroup.gameObject.AddComponent<LayoutElement>().flexibleWidth = 1f;
|
|
|
|
VerticalLayoutGroup titleLayout = titleGroup.gameObject.AddComponent<VerticalLayoutGroup>();
|
|
titleLayout.spacing = 0f;
|
|
titleLayout.childAlignment = TextAnchor.MiddleLeft;
|
|
titleLayout.childControlWidth = true;
|
|
titleLayout.childControlHeight = true;
|
|
titleLayout.childForceExpandWidth = true;
|
|
titleLayout.childForceExpandHeight = false;
|
|
|
|
TextMeshProUGUI title = CreateAutoLabel(titleGroup, "Label_Title", "패시브 트리", 24f, TextAlignmentOptions.Left);
|
|
title.fontStyle = FontStyles.Bold;
|
|
title.textWrappingMode = TextWrappingModes.NoWrap;
|
|
|
|
pointsSummaryText = CreateAutoLabel(header, "Label_Points", string.Empty, 17f, TextAlignmentOptions.Right);
|
|
pointsSummaryText.textWrappingMode = TextWrappingModes.NoWrap;
|
|
pointsSummaryText.gameObject.AddComponent<LayoutElement>().preferredWidth = 420f;
|
|
|
|
Button closeButton = CreateButton(header, "Button_Close", "닫기", new Vector2(84f, 34f), () => SetPanelVisible(false), out _);
|
|
closeButton.image.color = new Color(0.34f, 0.2f, 0.2f, 0.96f);
|
|
}
|
|
|
|
private void CreateBody(RectTransform parent)
|
|
{
|
|
RectTransform body = CreateContainer(parent, "Body", 0f);
|
|
LayoutElement bodyLayout = body.GetComponent<LayoutElement>();
|
|
bodyLayout.flexibleHeight = 1f;
|
|
|
|
HorizontalLayoutGroup layout = body.gameObject.AddComponent<HorizontalLayoutGroup>();
|
|
layout.spacing = 16f;
|
|
layout.childAlignment = TextAnchor.UpperLeft;
|
|
layout.childControlWidth = true;
|
|
layout.childControlHeight = true;
|
|
layout.childForceExpandWidth = true;
|
|
layout.childForceExpandHeight = true;
|
|
|
|
CreateLeftPanel(body);
|
|
CreateGraphPanel(body);
|
|
CreateRightPanel(body);
|
|
}
|
|
|
|
private void CreateFooter(RectTransform parent)
|
|
{
|
|
RectTransform footer = CreateContainer(parent, "Footer", 68f);
|
|
footer.gameObject.AddComponent<Image>().color = sectionBackgroundColor;
|
|
|
|
HorizontalLayoutGroup layout = footer.gameObject.AddComponent<HorizontalLayoutGroup>();
|
|
layout.padding = new RectOffset(16, 16, 12, 12);
|
|
layout.childAlignment = TextAnchor.MiddleLeft;
|
|
layout.childControlWidth = true;
|
|
layout.childControlHeight = true;
|
|
layout.childForceExpandWidth = true;
|
|
layout.childForceExpandHeight = true;
|
|
|
|
statusText = CreateAutoLabel(footer, "Label_Status", "로컬 플레이어를 찾는 중입니다.", 19f, TextAlignmentOptions.MidlineLeft);
|
|
}
|
|
|
|
private void CreateLeftPanel(RectTransform parent)
|
|
{
|
|
RectTransform section = CreateSectionRoot(parent, "Section_Left", LeftPanelWidth);
|
|
LayoutElement layout = section.GetComponent<LayoutElement>();
|
|
layout.flexibleWidth = 0f;
|
|
layout.minWidth = LeftPanelWidth;
|
|
layout.preferredWidth = LeftPanelWidth;
|
|
|
|
CreateSectionTitle(section, "현재 선택");
|
|
selectionSummaryText = CreateInfoText(section, "Label_SelectionSummary", 144f);
|
|
|
|
CreateSectionTitle(section, "프리셋");
|
|
RectTransform presetGrid = CreateContainer(section, "PresetGrid", 0f);
|
|
GridLayoutGroup presetLayout = presetGrid.gameObject.AddComponent<GridLayoutGroup>();
|
|
presetLayout.cellSize = new Vector2(140f, 58f);
|
|
presetLayout.spacing = new Vector2(8f, 8f);
|
|
presetLayout.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
|
|
presetLayout.constraintCount = 2;
|
|
|
|
nonePresetButton = CreateButton(presetGrid, "Button_Preset_None", "패시브 없음", Vector2.zero, () => TryApplyPreset(PassivePrototypePresetKind.None), out _);
|
|
defensePresetButton = CreateButton(presetGrid, "Button_Preset_Defense", "방어형 패시브", Vector2.zero, () => TryApplyPreset(PassivePrototypePresetKind.Defense), out _);
|
|
supportPresetButton = CreateButton(presetGrid, "Button_Preset_Support", "지원형 패시브", Vector2.zero, () => TryApplyPreset(PassivePrototypePresetKind.Support), out _);
|
|
attackPresetButton = CreateButton(presetGrid, "Button_Preset_Attack", "공격형 패시브", Vector2.zero, () => TryApplyPreset(PassivePrototypePresetKind.Attack), out _);
|
|
|
|
clearButton = CreateButton(section, "Button_Clear", "전체 초기화", new Vector2(0f, 52f), TryClearSelection, out _);
|
|
}
|
|
|
|
private void CreateGraphPanel(RectTransform parent)
|
|
{
|
|
RectTransform section = CreateSectionRoot(parent, "Section_Graph", 0f);
|
|
LayoutElement layout = section.GetComponent<LayoutElement>();
|
|
layout.flexibleWidth = 1f;
|
|
layout.minWidth = 420f;
|
|
|
|
CreateSectionTitle(section, "트리 그래프");
|
|
|
|
graphRectTransform = CreateContainer(section, "GraphSurface", 0f);
|
|
LayoutElement graphLayout = graphRectTransform.GetComponent<LayoutElement>();
|
|
graphLayout.flexibleHeight = 1f;
|
|
graphLayout.minHeight = 460f;
|
|
|
|
graphRectTransform.gameObject.AddComponent<Image>().color = sectionOverlayColor;
|
|
|
|
connectionLayer = CreateUiObject("Connections", graphRectTransform).AddComponent<RectTransform>();
|
|
StretchToParent(connectionLayer);
|
|
|
|
nodeLayer = CreateUiObject("Nodes", graphRectTransform).AddComponent<RectTransform>();
|
|
StretchToParent(nodeLayer);
|
|
}
|
|
|
|
private void CreateRightPanel(RectTransform parent)
|
|
{
|
|
RectTransform section = CreateSectionRoot(parent, "Section_Right", RightPanelWidth);
|
|
LayoutElement layout = section.GetComponent<LayoutElement>();
|
|
layout.flexibleWidth = 0f;
|
|
layout.minWidth = RightPanelWidth;
|
|
layout.preferredWidth = RightPanelWidth;
|
|
|
|
CreateSectionTitle(section, "노드 상세");
|
|
|
|
RectTransform detailScroll = CreateScrollView(section, "Scroll_Detail", out detailContent);
|
|
LayoutElement detailScrollLayout = detailScroll.gameObject.AddComponent<LayoutElement>();
|
|
detailScrollLayout.flexibleHeight = 1f;
|
|
detailScrollLayout.minHeight = 360f;
|
|
|
|
detailText = CreateAutoLabel(detailContent, "Label_Detail", string.Empty, 19f, TextAlignmentOptions.TopLeft);
|
|
detailText.textWrappingMode = TextWrappingModes.Normal;
|
|
detailText.overflowMode = TextOverflowModes.Overflow;
|
|
detailText.margin = new Vector4(18f, 8f, 18f, 14f);
|
|
detailText.gameObject.AddComponent<LayoutElement>().minHeight = 360f;
|
|
|
|
selectNodeButton = CreateButton(section, "Button_SelectNode", "노드 선택", new Vector2(0f, 58f), TrySelectFocusedNode, out selectNodeButtonLabel);
|
|
selectNodeButtonLabel.fontSize = 21f;
|
|
selectNodeButtonLabel.fontStyle = FontStyles.Bold;
|
|
}
|
|
|
|
private void RefreshAll()
|
|
{
|
|
if (!uiInitialized)
|
|
return;
|
|
|
|
PassiveTreeData tree = GetCurrentTree();
|
|
EnsureFocusedNode(tree);
|
|
|
|
UpdatePointsSummary(tree);
|
|
RefreshSelectionSummary(tree);
|
|
RefreshPresetButtons();
|
|
RebuildGraph(tree);
|
|
RefreshDetail(tree);
|
|
|
|
if (!string.IsNullOrWhiteSpace(lastStatusMessage))
|
|
{
|
|
ApplyStatusMessage();
|
|
}
|
|
else
|
|
{
|
|
ApplyStatusMessage();
|
|
}
|
|
}
|
|
|
|
private PassiveTreeData GetCurrentTree()
|
|
{
|
|
if (targetPlayer != null && targetPlayer.PassiveTree != null)
|
|
return targetPlayer.PassiveTree;
|
|
|
|
PassivePrototypeCatalogData catalog = targetPlayer != null && targetPlayer.PassivePrototypeCatalogData != null
|
|
? targetPlayer.PassivePrototypeCatalogData
|
|
: passivePrototypeCatalog;
|
|
|
|
return PassivePrototypeCatalog.LoadPrototypeTree(catalog);
|
|
}
|
|
|
|
private void EnsureFocusedNode(PassiveTreeData tree)
|
|
{
|
|
if (tree == null || tree.Nodes == null || tree.Nodes.Count <= 0)
|
|
{
|
|
focusedNode = null;
|
|
return;
|
|
}
|
|
|
|
if (focusedNode != null)
|
|
{
|
|
PassiveNodeData currentNode = tree.GetNodeById(focusedNode.NodeId);
|
|
if (currentNode != null)
|
|
{
|
|
focusedNode = currentNode;
|
|
return;
|
|
}
|
|
}
|
|
|
|
IReadOnlyList<string> selectedNodeIds = targetPlayer != null ? targetPlayer.SelectedPassiveNodeIds : Array.Empty<string>();
|
|
for (int i = 0; i < selectedNodeIds.Count; i++)
|
|
{
|
|
PassiveNodeData selectedNode = tree.GetNodeById(selectedNodeIds[i]);
|
|
if (selectedNode != null)
|
|
{
|
|
focusedNode = selectedNode;
|
|
return;
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < tree.Nodes.Count; i++)
|
|
{
|
|
if (tree.Nodes[i] != null && tree.Nodes[i].NodeKind == PassiveNodeKind.Hub)
|
|
{
|
|
focusedNode = tree.Nodes[i];
|
|
return;
|
|
}
|
|
}
|
|
|
|
focusedNode = tree.Nodes[0];
|
|
}
|
|
|
|
private void UpdatePointsSummary(PassiveTreeData tree)
|
|
{
|
|
if (pointsSummaryText == null)
|
|
return;
|
|
|
|
if (targetPlayer == null || tree == null)
|
|
{
|
|
pointsSummaryText.text = "포인트 정보 없음";
|
|
return;
|
|
}
|
|
|
|
pointsSummaryText.text =
|
|
$"{targetPlayer.CurrentPassivePresetName} | 포인트 {targetPlayer.UsedPassivePoints}/{tree.InitialPoints} | 남음 {targetPlayer.RemainingPassivePoints}";
|
|
}
|
|
|
|
private void RefreshSelectionSummary(PassiveTreeData tree)
|
|
{
|
|
if (selectionSummaryText == null)
|
|
return;
|
|
|
|
if (tree == null)
|
|
{
|
|
selectionSummaryText.text = "패시브 트리 자산을 찾지 못했습니다.";
|
|
return;
|
|
}
|
|
|
|
StringBuilder builder = new StringBuilder();
|
|
string presetName = targetPlayer != null ? targetPlayer.CurrentPassivePresetName : "미적용";
|
|
int usedPoints = targetPlayer != null ? targetPlayer.UsedPassivePoints : 0;
|
|
int remainingPoints = targetPlayer != null ? targetPlayer.RemainingPassivePoints : tree.InitialPoints;
|
|
IReadOnlyList<string> selectedNodeIds = targetPlayer != null ? targetPlayer.SelectedPassiveNodeIds : Array.Empty<string>();
|
|
|
|
builder.AppendLine($"현재 프리셋: {presetName}");
|
|
builder.AppendLine($"포인트: {usedPoints}/{tree.InitialPoints}");
|
|
builder.AppendLine($"남은 포인트: {remainingPoints}");
|
|
builder.AppendLine($"선택 노드 수: {selectedNodeIds.Count}개");
|
|
|
|
selectionSummaryText.text = builder.ToString().TrimEnd();
|
|
}
|
|
|
|
private void RefreshPresetButtons()
|
|
{
|
|
ApplyPresetButtonState(nonePresetButton, string.Equals(targetPlayer?.CurrentPassivePresetName, "패시브 없음", StringComparison.Ordinal));
|
|
ApplyPresetButtonState(defensePresetButton, string.Equals(targetPlayer?.CurrentPassivePresetName, "방어형 패시브", StringComparison.Ordinal));
|
|
ApplyPresetButtonState(supportPresetButton, string.Equals(targetPlayer?.CurrentPassivePresetName, "지원형 패시브", StringComparison.Ordinal));
|
|
ApplyPresetButtonState(attackPresetButton, string.Equals(targetPlayer?.CurrentPassivePresetName, "공격형 패시브", StringComparison.Ordinal));
|
|
|
|
if (clearButton != null)
|
|
{
|
|
clearButton.interactable = targetPlayer != null;
|
|
clearButton.image.color = clearButton.interactable
|
|
? new Color(0.2f, 0.18f, 0.16f, 0.98f)
|
|
: lockedNodeColor;
|
|
}
|
|
}
|
|
|
|
private void ApplyPresetButtonState(Button button, bool selected)
|
|
{
|
|
if (button == null)
|
|
return;
|
|
|
|
bool interactable = targetPlayer != null;
|
|
button.interactable = interactable;
|
|
button.image.color = !interactable
|
|
? lockedNodeColor
|
|
: selected ? new Color(0.48f, 0.38f, 0.16f, 0.96f) : new Color(0.2f, 0.2f, 0.24f, 0.96f);
|
|
}
|
|
|
|
private void RebuildGraph(PassiveTreeData tree)
|
|
{
|
|
if (graphRectTransform == null || connectionLayer == null || nodeLayer == null)
|
|
return;
|
|
|
|
ClearChildren(connectionLayer);
|
|
ClearChildren(nodeLayer);
|
|
nodeVisuals.Clear();
|
|
|
|
if (tree == null || tree.Nodes == null || tree.Nodes.Count <= 0)
|
|
return;
|
|
|
|
Canvas.ForceUpdateCanvases();
|
|
HashSet<string> drawnConnections = new HashSet<string>();
|
|
|
|
for (int i = 0; i < tree.Nodes.Count; i++)
|
|
{
|
|
PassiveNodeData node = tree.Nodes[i];
|
|
if (node == null)
|
|
continue;
|
|
|
|
DrawConnectionList(node, node.ConnectedNodes, drawnConnections);
|
|
DrawConnectionList(node, node.PrerequisiteNodes, drawnConnections);
|
|
}
|
|
|
|
for (int i = 0; i < tree.Nodes.Count; i++)
|
|
{
|
|
if (tree.Nodes[i] != null)
|
|
{
|
|
CreateNodeButton(tree.Nodes[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void DrawConnectionList(PassiveNodeData fromNode, IReadOnlyList<PassiveNodeData> nodes, HashSet<string> drawnConnections)
|
|
{
|
|
if (nodes == null)
|
|
return;
|
|
|
|
for (int i = 0; i < nodes.Count; i++)
|
|
{
|
|
PassiveNodeData targetNode = nodes[i];
|
|
if (targetNode == null)
|
|
continue;
|
|
|
|
string key = BuildConnectionKey(fromNode, targetNode);
|
|
if (!drawnConnections.Add(key))
|
|
continue;
|
|
|
|
CreateConnectionVisual(fromNode, targetNode);
|
|
}
|
|
}
|
|
|
|
private void CreateConnectionVisual(PassiveNodeData fromNode, PassiveNodeData toNode)
|
|
{
|
|
Vector2 fromPosition = GetGraphAnchoredPosition(fromNode.LayoutPosition);
|
|
Vector2 toPosition = GetGraphAnchoredPosition(toNode.LayoutPosition);
|
|
Vector2 delta = toPosition - fromPosition;
|
|
float distance = delta.magnitude;
|
|
if (distance <= 0.01f)
|
|
return;
|
|
|
|
Vector2 direction = delta / distance;
|
|
float fromInset = GetConnectionInset(fromNode);
|
|
float toInset = GetConnectionInset(toNode);
|
|
fromPosition += direction * fromInset;
|
|
toPosition -= direction * toInset;
|
|
delta = toPosition - fromPosition;
|
|
distance = delta.magnitude;
|
|
if (distance <= 0.01f)
|
|
return;
|
|
|
|
GameObject lineObject = CreateUiObject($"Line_{fromNode.NodeId}_{toNode.NodeId}", connectionLayer);
|
|
RectTransform lineRect = lineObject.AddComponent<RectTransform>();
|
|
lineRect.anchorMin = new Vector2(0.5f, 0.5f);
|
|
lineRect.anchorMax = new Vector2(0.5f, 0.5f);
|
|
lineRect.pivot = new Vector2(0.5f, 0.5f);
|
|
lineRect.anchoredPosition = (fromPosition + toPosition) * 0.5f;
|
|
lineRect.sizeDelta = new Vector2(distance, ConnectionThickness);
|
|
lineRect.localRotation = Quaternion.Euler(0f, 0f, Mathf.Atan2(delta.y, delta.x) * Mathf.Rad2Deg);
|
|
|
|
lineObject.AddComponent<Image>().color = IsConnectionActive(fromNode, toNode) ? activeLineColor : lineColor;
|
|
}
|
|
|
|
private bool IsConnectionActive(PassiveNodeData leftNode, PassiveNodeData rightNode)
|
|
{
|
|
bool leftSelected = IsSelected(leftNode);
|
|
bool rightSelected = IsSelected(rightNode);
|
|
if (leftSelected && rightSelected)
|
|
return true;
|
|
|
|
return leftSelected && CanNodePreviewConnect(rightNode) ||
|
|
rightSelected && CanNodePreviewConnect(leftNode);
|
|
}
|
|
|
|
private bool CanNodePreviewConnect(PassiveNodeData node)
|
|
{
|
|
if (node == null || targetPlayer == null)
|
|
return false;
|
|
|
|
return IsSelected(node) || targetPlayer.CanSelectPassiveNode(node, out _, out _);
|
|
}
|
|
|
|
private void CreateNodeButton(PassiveNodeData node)
|
|
{
|
|
if (nodePrefab == null)
|
|
{
|
|
Debug.LogWarning("[PassiveTreeUI] 노드 프리팹이 지정되지 않았습니다.");
|
|
return;
|
|
}
|
|
|
|
PassiveTreeNodeView nodeView = Instantiate(nodePrefab, nodeLayer);
|
|
nodeView.name = $"Node_{node.NodeId}";
|
|
|
|
RectTransform rectTransform = nodeView.RootRect != null
|
|
? nodeView.RootRect
|
|
: nodeView.GetComponent<RectTransform>();
|
|
rectTransform.anchorMin = new Vector2(0.5f, 0.5f);
|
|
rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
|
|
rectTransform.pivot = new Vector2(0.5f, 0.5f);
|
|
rectTransform.anchoredPosition = GetGraphAnchoredPosition(node.LayoutPosition);
|
|
rectTransform.sizeDelta = GetNodeSize(node);
|
|
|
|
Image image = nodeView.BackgroundImage != null
|
|
? nodeView.BackgroundImage
|
|
: nodeView.GetComponent<Image>();
|
|
image.sprite = GetNodeSprite(node);
|
|
image.type = Image.Type.Simple;
|
|
image.preserveAspect = true;
|
|
Image fillImage = nodeView.FillImage;
|
|
if (fillImage != null)
|
|
{
|
|
fillImage.type = Image.Type.Sliced;
|
|
}
|
|
Image innerImage = nodeView.InnerImage;
|
|
if (innerImage != null)
|
|
{
|
|
innerImage.sprite = GetInnerNodeSprite();
|
|
innerImage.type = Image.Type.Simple;
|
|
innerImage.preserveAspect = true;
|
|
}
|
|
Button button = nodeView.Button != null
|
|
? nodeView.Button
|
|
: nodeView.GetComponent<Button>();
|
|
button.targetGraphic = image;
|
|
|
|
PassiveNodeData capturedNode = node;
|
|
button.onClick.AddListener(() => FocusNode(capturedNode));
|
|
|
|
if (node.NodeKind == PassiveNodeKind.Bridge)
|
|
{
|
|
rectTransform.localRotation = Quaternion.Euler(0f, 0f, 45f);
|
|
}
|
|
else
|
|
{
|
|
rectTransform.localRotation = Quaternion.identity;
|
|
}
|
|
|
|
nodeVisuals[node] = new NodeVisual
|
|
{
|
|
RectTransform = rectTransform,
|
|
Button = button,
|
|
Image = image,
|
|
FillImage = fillImage,
|
|
InnerImage = innerImage,
|
|
Outline = nodeView.Outline != null ? nodeView.Outline : button.GetComponent<Outline>(),
|
|
};
|
|
ApplyNodeVisual(node);
|
|
}
|
|
|
|
private void FocusNode(PassiveNodeData node)
|
|
{
|
|
focusedNode = node;
|
|
RefreshDetail(GetCurrentTree());
|
|
|
|
foreach (KeyValuePair<PassiveNodeData, NodeVisual> entry in nodeVisuals)
|
|
{
|
|
ApplyNodeVisual(entry.Key);
|
|
}
|
|
}
|
|
|
|
private void RefreshDetail(PassiveTreeData tree)
|
|
{
|
|
if (detailText == null)
|
|
return;
|
|
|
|
if (tree == null || focusedNode == null)
|
|
{
|
|
detailText.text = "노드를 선택하면 설명을 표시합니다.";
|
|
RefreshSelectButton(null);
|
|
return;
|
|
}
|
|
|
|
StringBuilder builder = new StringBuilder();
|
|
if (!string.IsNullOrWhiteSpace(focusedNode.DisplayName))
|
|
{
|
|
builder.AppendLine($"<size=30><b>{focusedNode.DisplayName}</b></size>");
|
|
}
|
|
|
|
builder.AppendLine($"<size=18>{PassivePresentationUtility.GetBranchLabel(focusedNode.Branch)} | {PassivePresentationUtility.GetNodeKindLabel(focusedNode.NodeKind)} | 축 {PassivePresentationUtility.GetAxisSummary(focusedNode.AxisMask)}</size>");
|
|
builder.AppendLine($"<size=18>비용 {focusedNode.Cost}</size>");
|
|
|
|
if (!string.IsNullOrWhiteSpace(focusedNode.Description))
|
|
{
|
|
builder.AppendLine();
|
|
builder.AppendLine("<b>설명</b>");
|
|
builder.AppendLine(focusedNode.Description.Trim());
|
|
}
|
|
|
|
if (focusedNode.Effects != null && focusedNode.Effects.Count > 0)
|
|
{
|
|
builder.AppendLine();
|
|
builder.AppendLine("<b>효과</b>");
|
|
for (int i = 0; i < focusedNode.Effects.Count; i++)
|
|
{
|
|
PassiveEffectEntry effect = focusedNode.Effects[i];
|
|
if (effect == null)
|
|
continue;
|
|
|
|
builder.Append("• ");
|
|
builder.AppendLine(PassivePresentationUtility.GetEffectLabel(effect));
|
|
}
|
|
}
|
|
|
|
string stateText = BuildFocusedNodeStateText(tree);
|
|
if (!string.IsNullOrWhiteSpace(stateText))
|
|
{
|
|
builder.AppendLine();
|
|
builder.AppendLine("<b>현재 상태</b>");
|
|
builder.AppendLine(stateText);
|
|
}
|
|
|
|
detailText.text = builder.ToString().TrimEnd();
|
|
RefreshSelectButton(focusedNode);
|
|
}
|
|
|
|
private string BuildFocusedNodeStateText(PassiveTreeData tree)
|
|
{
|
|
if (focusedNode == null)
|
|
return string.Empty;
|
|
|
|
if (IsSelected(focusedNode))
|
|
return "이미 적용된 노드입니다.";
|
|
|
|
if (targetPlayer == null)
|
|
return "로컬 플레이어를 찾으면 선택 가능 여부를 갱신합니다.";
|
|
|
|
if (targetPlayer.CanSelectPassiveNode(focusedNode, out string reason, out int nextUsedPoints))
|
|
{
|
|
return $"선택 가능 | 비용 {focusedNode.Cost} | 적용 시 사용 포인트 {nextUsedPoints}/{tree.InitialPoints}";
|
|
}
|
|
|
|
return string.IsNullOrWhiteSpace(reason) ? "선택할 수 없습니다." : reason;
|
|
}
|
|
|
|
private void RefreshSelectButton(PassiveNodeData node)
|
|
{
|
|
if (selectNodeButton == null || selectNodeButtonLabel == null)
|
|
return;
|
|
|
|
if (node == null)
|
|
{
|
|
selectNodeButton.interactable = false;
|
|
selectNodeButton.image.color = lockedNodeColor;
|
|
selectNodeButtonLabel.text = "노드 선택";
|
|
return;
|
|
}
|
|
|
|
if (IsSelected(node))
|
|
{
|
|
selectNodeButton.interactable = false;
|
|
selectNodeButton.image.color = new Color(0.28f, 0.3f, 0.18f, 0.96f);
|
|
selectNodeButtonLabel.text = "이미 선택됨";
|
|
return;
|
|
}
|
|
|
|
bool canSelect = targetPlayer != null && targetPlayer.CanSelectPassiveNode(node, out _, out _);
|
|
selectNodeButton.interactable = canSelect;
|
|
selectNodeButton.image.color = canSelect ? new Color(0.24f, 0.36f, 0.22f, 0.98f) : lockedNodeColor;
|
|
selectNodeButtonLabel.text = canSelect ? "이 노드 선택" : "선택 불가";
|
|
}
|
|
|
|
private void TrySelectFocusedNode()
|
|
{
|
|
if (focusedNode == null)
|
|
{
|
|
SetStatusMessage("선택할 노드를 먼저 지정하세요.", true);
|
|
return;
|
|
}
|
|
|
|
if (targetPlayer == null)
|
|
{
|
|
SetStatusMessage("로컬 플레이어를 찾지 못했습니다.", true);
|
|
return;
|
|
}
|
|
|
|
bool success = targetPlayer.RequestSelectPassiveNode(focusedNode.NodeId, out string reason);
|
|
SetStatusMessage(reason, !success);
|
|
RefreshAll();
|
|
}
|
|
|
|
private void TryApplyPreset(PassivePrototypePresetKind presetKind)
|
|
{
|
|
if (targetPlayer == null)
|
|
{
|
|
SetStatusMessage("로컬 플레이어를 찾지 못했습니다.", true);
|
|
return;
|
|
}
|
|
|
|
bool success = targetPlayer.RequestApplyPrototypePassivePreset(presetKind, out string reason);
|
|
SetStatusMessage(reason, !success);
|
|
RefreshAll();
|
|
}
|
|
|
|
private void TryClearSelection()
|
|
{
|
|
if (targetPlayer == null)
|
|
{
|
|
SetStatusMessage("로컬 플레이어를 찾지 못했습니다.", true);
|
|
return;
|
|
}
|
|
|
|
bool success = targetPlayer.RequestClearPassiveSelection(out string reason);
|
|
SetStatusMessage(reason, !success);
|
|
RefreshAll();
|
|
}
|
|
|
|
private void ApplyNodeVisual(PassiveNodeData node)
|
|
{
|
|
if (node == null || !nodeVisuals.TryGetValue(node, out NodeVisual visual))
|
|
return;
|
|
|
|
bool selected = IsSelected(node);
|
|
bool focused = focusedNode != null && string.Equals(focusedNode.NodeId, node.NodeId, StringComparison.Ordinal);
|
|
bool selectable = !selected && targetPlayer != null && targetPlayer.CanSelectPassiveNode(node, out _, out _);
|
|
Color baseColor = GetNodeBaseColor(node);
|
|
Color selectedFillColor = Color.Lerp(baseColor, Color.white, 0.2f);
|
|
Color selectableFillColor = Color.Lerp(baseColor, lockedNodeColor, 0.76f);
|
|
Color lockedFillColor = Color.Lerp(baseColor, lockedNodeColor, 0.9f);
|
|
Color fillColor = selected
|
|
? selectedFillColor
|
|
: selectable ? selectableFillColor : lockedFillColor;
|
|
|
|
visual.Button.interactable = true;
|
|
visual.Image.sprite = GetNodeSprite(node);
|
|
visual.Image.color = fillColor;
|
|
if (visual.FillImage != null)
|
|
{
|
|
visual.FillImage.color = GetNodeFillColor(selected, selectable, focused);
|
|
}
|
|
if (visual.InnerImage != null)
|
|
{
|
|
visual.InnerImage.sprite = GetInnerNodeSprite();
|
|
visual.InnerImage.color = GetInnerNodeColor(selected, selectable, focused);
|
|
}
|
|
visual.RectTransform.localScale = Vector3.one;
|
|
|
|
Outline outline = visual.Outline != null ? visual.Outline : visual.Button.GetComponent<Outline>();
|
|
if (outline == null)
|
|
{
|
|
outline = visual.Button.gameObject.AddComponent<Outline>();
|
|
visual.Outline = outline;
|
|
}
|
|
|
|
outline.effectDistance = selected
|
|
? Vector2.zero
|
|
: focused ? new Vector2(1.5f, 1.5f)
|
|
: selectable ? new Vector2(1f, 1f)
|
|
: Vector2.zero;
|
|
outline.effectColor = selected
|
|
? Color.clear
|
|
: focused ? new Color(focusedOutlineColor.r, focusedOutlineColor.g, focusedOutlineColor.b, 0.55f)
|
|
: selectable ? new Color(baseColor.r, baseColor.g, baseColor.b, 0.35f)
|
|
: Color.clear;
|
|
}
|
|
|
|
private bool IsSelected(PassiveNodeData node)
|
|
{
|
|
return node != null && targetPlayer != null && targetPlayer.IsPassiveNodeSelected(node.NodeId);
|
|
}
|
|
|
|
private Vector2 GetGraphAnchoredPosition(Vector2 layoutPosition)
|
|
{
|
|
Rect rect = graphRectTransform != null ? graphRectTransform.rect : new Rect(0f, 0f, 640f, 480f);
|
|
float halfExtent = Mathf.Max(0f, Mathf.Min(rect.width, rect.height) * 0.5f - GraphPadding);
|
|
|
|
return new Vector2(
|
|
Mathf.Clamp(layoutPosition.x, -1f, 1f) * halfExtent,
|
|
Mathf.Clamp(layoutPosition.y, -1f, 1f) * halfExtent + graphCenterYOffset);
|
|
}
|
|
|
|
private static string BuildConnectionKey(PassiveNodeData leftNode, PassiveNodeData rightNode)
|
|
{
|
|
return string.Compare(leftNode.NodeId, rightNode.NodeId, StringComparison.Ordinal) <= 0
|
|
? $"{leftNode.NodeId}|{rightNode.NodeId}"
|
|
: $"{rightNode.NodeId}|{leftNode.NodeId}";
|
|
}
|
|
|
|
private Sprite GetNodeSprite(PassiveNodeData node)
|
|
{
|
|
bool useSpecialSprite = node != null && (node.NodeKind == PassiveNodeKind.Bridge || node.NodeKind == PassiveNodeKind.Capstone);
|
|
Sprite fallbackSprite = useSpecialSprite ? normalNodeSprite : specialNodeSprite;
|
|
Sprite preferredSprite = useSpecialSprite ? specialNodeSprite : normalNodeSprite;
|
|
return preferredSprite != null ? preferredSprite : fallbackSprite;
|
|
}
|
|
|
|
private Sprite GetInnerNodeSprite()
|
|
{
|
|
if (innerNodeSprite != null)
|
|
return innerNodeSprite;
|
|
|
|
return normalNodeSprite != null ? normalNodeSprite : specialNodeSprite;
|
|
}
|
|
|
|
private static Color GetInnerNodeColor(bool selected, bool selectable, bool focused)
|
|
{
|
|
if (selected)
|
|
return new Color(0.62f, 0.60f, 0.56f, 0.7f);
|
|
|
|
if (focused)
|
|
return new Color(0.5f, 0.5f, 0.5f, 0.5f);
|
|
|
|
if (selectable)
|
|
return new Color(0.42f, 0.42f, 0.44f, 0.4f);
|
|
|
|
return new Color(0.3f, 0.3f, 0.32f, 0.34f);
|
|
}
|
|
|
|
private static Color GetNodeFillColor(bool selected, bool selectable, bool focused)
|
|
{
|
|
if (selected)
|
|
return new Color(0.09f, 0.09f, 0.11f, 0.96f);
|
|
|
|
if (focused)
|
|
return new Color(0.08f, 0.08f, 0.10f, 0.94f);
|
|
|
|
if (selectable)
|
|
return new Color(0.07f, 0.07f, 0.09f, 0.92f);
|
|
|
|
return new Color(0.06f, 0.06f, 0.08f, 0.90f);
|
|
}
|
|
|
|
private Color GetNodeBaseColor(PassiveNodeData node)
|
|
{
|
|
if (node.Branch == PassiveNodeBranch.Bridge)
|
|
return bridgeColor;
|
|
|
|
if ((node.AxisMask & PassiveAxisMask.Attack) != 0 && (node.AxisMask & PassiveAxisMask.Defense) != 0)
|
|
return Color.Lerp(attackColor, defenseColor, 0.5f);
|
|
|
|
if ((node.AxisMask & PassiveAxisMask.Defense) != 0 && (node.AxisMask & PassiveAxisMask.Support) != 0)
|
|
return Color.Lerp(defenseColor, supportColor, 0.5f);
|
|
|
|
if ((node.AxisMask & PassiveAxisMask.Support) != 0 && (node.AxisMask & PassiveAxisMask.Attack) != 0)
|
|
return Color.Lerp(supportColor, attackColor, 0.5f);
|
|
|
|
if ((node.AxisMask & PassiveAxisMask.Attack) != 0)
|
|
return attackColor;
|
|
if ((node.AxisMask & PassiveAxisMask.Defense) != 0)
|
|
return defenseColor;
|
|
if ((node.AxisMask & PassiveAxisMask.Support) != 0)
|
|
return supportColor;
|
|
|
|
return new Color(0.42f, 0.45f, 0.22f, 0.96f);
|
|
}
|
|
|
|
private static Vector2 GetNodeSize(PassiveNodeData node)
|
|
{
|
|
return node.NodeKind switch
|
|
{
|
|
PassiveNodeKind.Hub => new Vector2(78f, 78f),
|
|
PassiveNodeKind.Capstone => new Vector2(70f, 70f),
|
|
PassiveNodeKind.Bridge => new Vector2(56f, 56f),
|
|
_ => new Vector2(62f, 62f),
|
|
};
|
|
}
|
|
|
|
private static float GetConnectionInset(PassiveNodeData node)
|
|
{
|
|
Vector2 size = GetNodeSize(node);
|
|
float radius = Mathf.Min(size.x, size.y) * 0.5f + ConnectionThickness * 0.5f + 2f;
|
|
if (node != null && node.NodeKind == PassiveNodeKind.Bridge)
|
|
radius += 4f;
|
|
else if (node != null && (node.NodeKind == PassiveNodeKind.Hub || node.NodeKind == PassiveNodeKind.Capstone))
|
|
radius += 2f;
|
|
|
|
return radius;
|
|
}
|
|
|
|
private RectTransform CreateSectionRoot(RectTransform parent, string name, float preferredWidth)
|
|
{
|
|
RectTransform section = CreateContainer(parent, name, 0f);
|
|
LayoutElement layout = section.GetComponent<LayoutElement>();
|
|
layout.flexibleHeight = 1f;
|
|
layout.preferredWidth = preferredWidth;
|
|
|
|
section.gameObject.AddComponent<Image>().color = sectionBackgroundColor;
|
|
|
|
VerticalLayoutGroup verticalLayout = section.gameObject.AddComponent<VerticalLayoutGroup>();
|
|
verticalLayout.padding = new RectOffset(16, 16, 16, 16);
|
|
verticalLayout.spacing = 12f;
|
|
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, "Label_Title", text, 28f, TextAlignmentOptions.Left);
|
|
label.fontStyle = FontStyles.Bold;
|
|
return label;
|
|
}
|
|
|
|
private TextMeshProUGUI CreateInfoText(RectTransform parent, string name, float minHeight)
|
|
{
|
|
RectTransform container = CreateContainer(parent, $"{name}_Container", minHeight);
|
|
container.gameObject.AddComponent<Image>().color = sectionOverlayColor;
|
|
|
|
VerticalLayoutGroup layout = container.gameObject.AddComponent<VerticalLayoutGroup>();
|
|
layout.padding = new RectOffset(16, 16, 12, 12);
|
|
layout.childAlignment = TextAnchor.UpperLeft;
|
|
layout.childControlWidth = true;
|
|
layout.childControlHeight = true;
|
|
layout.childForceExpandWidth = true;
|
|
layout.childForceExpandHeight = false;
|
|
|
|
TextMeshProUGUI label = CreateAutoLabel(container, name, string.Empty, 18f, TextAlignmentOptions.TopLeft);
|
|
label.textWrappingMode = TextWrappingModes.Normal;
|
|
label.overflowMode = TextOverflowModes.Overflow;
|
|
label.extraPadding = true;
|
|
label.margin = new Vector4(14f, 6f, 14f, 10f);
|
|
return label;
|
|
}
|
|
|
|
private RectTransform CreateScrollView(RectTransform parent, string name, out RectTransform content)
|
|
{
|
|
GameObject rootObject = CreateUiObject(name, parent);
|
|
RectTransform rootRect = rootObject.AddComponent<RectTransform>();
|
|
rootObject.AddComponent<Image>().color = sectionOverlayColor;
|
|
|
|
ScrollRect scrollRect = rootObject.AddComponent<ScrollRect>();
|
|
scrollRect.horizontal = false;
|
|
scrollRect.vertical = true;
|
|
scrollRect.scrollSensitivity = 30f;
|
|
|
|
RectTransform viewport = CreateUiObject("Viewport", rootRect).AddComponent<RectTransform>();
|
|
StretchToParent(viewport);
|
|
viewport.gameObject.AddComponent<Image>().color = new Color(1f, 1f, 1f, 0.01f);
|
|
Mask mask = viewport.gameObject.AddComponent<Mask>();
|
|
mask.showMaskGraphic = false;
|
|
|
|
content = CreateUiObject("Content", viewport).AddComponent<RectTransform>();
|
|
content.anchorMin = new Vector2(0f, 1f);
|
|
content.anchorMax = new Vector2(1f, 1f);
|
|
content.pivot = new Vector2(0f, 1f);
|
|
|
|
VerticalLayoutGroup contentLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
|
contentLayout.padding = new RectOffset(16, 16, 14, 14);
|
|
contentLayout.spacing = 8f;
|
|
contentLayout.childAlignment = TextAnchor.UpperLeft;
|
|
contentLayout.childControlWidth = true;
|
|
contentLayout.childControlHeight = true;
|
|
contentLayout.childForceExpandWidth = true;
|
|
contentLayout.childForceExpandHeight = false;
|
|
|
|
ContentSizeFitter fitter = content.gameObject.AddComponent<ContentSizeFitter>();
|
|
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
|
fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
|
|
|
|
scrollRect.viewport = viewport;
|
|
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 = new Color(0.2f, 0.2f, 0.24f, 0.96f);
|
|
|
|
Button button = buttonObject.AddComponent<Button>();
|
|
button.targetGraphic = image;
|
|
if (onClick != null)
|
|
{
|
|
button.onClick.AddListener(() => onClick());
|
|
}
|
|
|
|
label = CreateFillLabel(rectTransform, "Label", labelText, 18f, TextAlignmentOptions.Center);
|
|
label.color = Color.white;
|
|
label.textWrappingMode = TextWrappingModes.Normal;
|
|
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(0f, 1f);
|
|
|
|
TextMeshProUGUI label = labelObject.AddComponent<TextMeshProUGUI>();
|
|
label.font = TMP_Settings.defaultFontAsset;
|
|
label.fontSize = fontSize;
|
|
label.text = text;
|
|
label.alignment = alignment;
|
|
label.color = normalTextColor;
|
|
label.extraPadding = true;
|
|
label.raycastTarget = false;
|
|
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 void SetStatusMessage(string message, bool isError)
|
|
{
|
|
lastStatusMessage = message ?? string.Empty;
|
|
lastStatusIsError = isError;
|
|
ApplyStatusMessage();
|
|
}
|
|
|
|
private void ApplyStatusMessage()
|
|
{
|
|
if (statusText == null)
|
|
return;
|
|
|
|
bool hasMessage = !string.IsNullOrWhiteSpace(lastStatusMessage);
|
|
Transform footerTransform = statusText.transform.parent;
|
|
if (footerTransform != null)
|
|
{
|
|
Image footerImage = footerTransform.GetComponent<Image>();
|
|
if (footerImage != null)
|
|
{
|
|
Color color = sectionBackgroundColor;
|
|
color.a = hasMessage ? sectionBackgroundColor.a : 0f;
|
|
footerImage.color = color;
|
|
}
|
|
}
|
|
|
|
statusText.text = hasMessage ? lastStatusMessage : string.Empty;
|
|
statusText.color = lastStatusIsError ? statusErrorColor : normalTextColor;
|
|
}
|
|
|
|
private static GameObject CreateUiObject(string name, Transform parent)
|
|
{
|
|
GameObject gameObject = new GameObject(name);
|
|
gameObject.transform.SetParent(parent, false);
|
|
gameObject.layer = parent.gameObject.layer;
|
|
return gameObject;
|
|
}
|
|
|
|
private static void StretchToParent(RectTransform rectTransform)
|
|
{
|
|
rectTransform.anchorMin = Vector2.zero;
|
|
rectTransform.anchorMax = Vector2.one;
|
|
rectTransform.offsetMin = Vector2.zero;
|
|
rectTransform.offsetMax = Vector2.zero;
|
|
rectTransform.anchoredPosition = Vector2.zero;
|
|
rectTransform.sizeDelta = Vector2.zero;
|
|
}
|
|
|
|
private static void ClearChildren(RectTransform parent)
|
|
{
|
|
for (int i = parent.childCount - 1; i >= 0; i--)
|
|
{
|
|
Destroy(parent.GetChild(i).gameObject);
|
|
}
|
|
}
|
|
}
|
|
}
|