feat: 패시브 트리 프로토타입 구현

- 패시브 트리/노드/프리셋 데이터와 카탈로그 참조 구조를 추가하고 Resources 의존을 Data/Passives 자산 구조로 정리
- 플레이어 런타임, 전투 계수, 프리셋 적용, 멀티플레이 동기화 경로에 패시브 적용 로직 연결
- 프리팹 기반 패시브 트리 UI와 노드 아이콘/프리셋/상세 패널 흐름을 추가하고 HUD에 연동
- 패시브 디버그/부트스트랩 메뉴와 UI 프리팹 재생성 경로를 추가
This commit is contained in:
2026-03-26 22:59:39 +09:00
parent 13d1949ded
commit 8d1e97d01a
89 changed files with 10848 additions and 68 deletions

View File

@@ -5,6 +5,7 @@ using Unity.Netcode;
using UnityEngine;
using Colosseum.Enemy;
using Colosseum.Passives;
using Colosseum.Skills;
namespace Colosseum.Combat
@@ -18,6 +19,7 @@ namespace Colosseum.Combat
private sealed class ActorMetrics
{
public string label;
public string passivePresetName;
public float totalDamageDealt;
public float bossDamageDealt;
public float damageTaken;
@@ -193,6 +195,12 @@ namespace Colosseum.Combat
builder.AppendLine();
builder.Append("- ");
builder.Append(metrics.label);
if (!string.IsNullOrWhiteSpace(metrics.passivePresetName))
{
builder.Append(" [Passive=");
builder.Append(metrics.passivePresetName);
builder.Append(']');
}
builder.Append(" | BossDmg=");
builder.Append(metrics.bossDamageDealt.ToString("0.##"));
builder.Append(" | TotalDmg=");
@@ -244,6 +252,8 @@ namespace Colosseum.Combat
actorMetrics.Add(actorLabel, metrics);
}
metrics.passivePresetName = PassiveRuntimeModifierUtility.GetCurrentPresetName(actor);
return metrics;
}

View File

@@ -0,0 +1,630 @@
using System.IO;
using TMPro;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.UI;
using Colosseum.Passives;
using Colosseum.UI;
namespace Colosseum.Editor
{
/// <summary>
/// 패시브 트리 uGUI 프리팹을 재생성하는 에디터 유틸리티입니다.
/// </summary>
public static class PassiveTreeUiPrefabBuilder
{
private const string PrefabFolder = "Assets/_Game/Prefabs/UI/PassiveTree";
private const string ViewPrefabPath = PrefabFolder + "/UI_PassiveTreeView.prefab";
private const string NodePrefabPath = PrefabFolder + "/UI_PassiveTreeNode.prefab";
private const string PlayerHudPrefabPath = "Assets/_Game/Prefabs/UI/UI_PlayerResources.prefab";
private const string PreferredFontAssetPath = "Assets/_Game/Fonts/TMP/TMP_MaruBuri.asset";
private const string NormalIconSpritePath = "Assets/_Game/Icons/Icon_Passive_Normal.png";
private const string SpecialIconSpritePath = "Assets/_Game/Icons/Icon_Passive_Special.png";
private const string PassiveCatalogAssetPath = "Assets/_Game/Data/Passives/Data_PassivePrototypeCatalog.asset";
private static readonly Color PanelBackgroundColor = new(0.07f, 0.07f, 0.10f, 0.98f);
private static readonly Color SectionBackgroundColor = new(0.12f, 0.12f, 0.16f, 0.96f);
private static readonly Color SectionOverlayColor = new(0f, 0f, 0f, 0.12f);
private static readonly Color ToggleButtonColor = new(0.18f, 0.21f, 0.16f, 0.96f);
private static readonly Color CloseButtonColor = new(0.34f, 0.20f, 0.20f, 0.96f);
private static readonly Color ButtonColor = new(0.20f, 0.20f, 0.24f, 0.96f);
private static readonly Color TextColor = new(0.90f, 0.88f, 0.82f, 1f);
private static readonly Color NodeColor = new(0.16f, 0.16f, 0.18f, 0.98f);
private static readonly Color NodeOutlineColor = new(0f, 0f, 0f, 0.45f);
[MenuItem("Tools/Colosseum/Passives/Rebuild Passive UI Prefabs")]
public static void RebuildPassiveUiPrefabs()
{
EnsureFolders();
PassiveTreeNodeView nodePrefab = BuildNodePrefab();
PassiveTreeViewReferences viewPrefab = BuildViewPrefab();
BindPlayerHudPrefab(viewPrefab, nodePrefab);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("[PassiveTreeUiPrefabBuilder] 패시브 UI 프리팹 재생성을 완료했습니다.");
}
private static void EnsureFolders()
{
if (!AssetDatabase.IsValidFolder("Assets/_Game/Prefabs/UI"))
{
AssetDatabase.CreateFolder("Assets/_Game/Prefabs", "UI");
}
if (!AssetDatabase.IsValidFolder(PrefabFolder))
{
AssetDatabase.CreateFolder("Assets/_Game/Prefabs/UI", "PassiveTree");
}
}
private static PassiveTreeNodeView BuildNodePrefab()
{
GameObject root = new("UI_PassiveTreeNode", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(Button), typeof(Outline), typeof(PassiveTreeNodeView));
try
{
RectTransform rect = root.GetComponent<RectTransform>();
rect.sizeDelta = new Vector2(70f, 70f);
Image background = root.GetComponent<Image>();
background.sprite = LoadPassiveNodeSprite(false);
background.type = Image.Type.Simple;
background.preserveAspect = true;
background.color = NodeColor;
Outline outline = root.GetComponent<Outline>();
outline.effectColor = NodeOutlineColor;
outline.effectDistance = new Vector2(2f, 2f);
Button button = root.GetComponent<Button>();
button.targetGraphic = background;
GameObject innerObject = CreateUiObject("InnerIcon", root.transform, typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
RectTransform innerRect = innerObject.GetComponent<RectTransform>();
StretchRect(innerRect);
innerRect.offsetMin = new Vector2(10f, 10f);
innerRect.offsetMax = new Vector2(-10f, -10f);
Image innerImage = innerObject.GetComponent<Image>();
innerImage.sprite = LoadPassiveNodeSprite(false);
innerImage.type = Image.Type.Simple;
innerImage.preserveAspect = true;
innerImage.color = new Color(0.32f, 0.32f, 0.34f, 0.36f);
innerImage.raycastTarget = false;
PassiveTreeNodeView references = root.GetComponent<PassiveTreeNodeView>();
SetSerializedReference(references, "rootRect", rect);
SetSerializedReference(references, "backgroundImage", background);
SetSerializedReference(references, "innerImage", innerImage);
SetSerializedReference(references, "button", button);
SetSerializedReference(references, "outline", outline);
GameObject saved = SavePrefab(root, NodePrefabPath);
return saved.GetComponent<PassiveTreeNodeView>();
}
finally
{
Object.DestroyImmediate(root);
}
}
private static PassiveTreeViewReferences BuildViewPrefab()
{
GameObject root = new("UI_PassiveTreeView", typeof(RectTransform), typeof(PassiveTreeViewReferences));
try
{
RectTransform rootRect = root.GetComponent<RectTransform>();
StretchRect(rootRect);
Button toggleButton = CreateButton(root.transform, "Button_PassiveTree", "패시브", new Vector2(110f, 40f), 20f, FontStyles.Bold, ToggleButtonColor, TextAnchor.MiddleCenter);
RectTransform toggleRect = toggleButton.GetComponent<RectTransform>();
toggleRect.anchorMin = new Vector2(1f, 0f);
toggleRect.anchorMax = new Vector2(1f, 0f);
toggleRect.pivot = new Vector2(1f, 0f);
toggleRect.anchoredPosition = new Vector2(-132f, 48f);
GameObject overlayRootObject = CreateUiObject("OverlayRoot", root.transform, typeof(RectTransform));
RectTransform overlayRootRect = overlayRootObject.GetComponent<RectTransform>();
StretchRect(overlayRootRect);
overlayRootObject.SetActive(false);
Image backdrop = AddImage(overlayRootObject, "Backdrop", new Color(0f, 0f, 0f, 0.52f));
StretchRect(backdrop.rectTransform);
GameObject panelObject = CreateUiObject("Panel", overlayRootObject.transform, typeof(RectTransform), typeof(Image), typeof(VerticalLayoutGroup));
RectTransform panelRect = panelObject.GetComponent<RectTransform>();
panelRect.anchorMin = new Vector2(0.5f, 0.5f);
panelRect.anchorMax = new Vector2(0.5f, 0.5f);
panelRect.pivot = new Vector2(0.5f, 0.5f);
panelRect.sizeDelta = new Vector2(1380f, 820f);
Image panelImage = panelObject.GetComponent<Image>();
panelImage.sprite = GetBuiltinSprite();
panelImage.type = Image.Type.Sliced;
panelImage.color = PanelBackgroundColor;
VerticalLayoutGroup panelLayout = panelObject.GetComponent<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;
BuildHeader(panelObject.transform, out TextMeshProUGUI pointsSummaryText, out Button closeButton);
BuildBody(panelObject.transform,
out TextMeshProUGUI selectionSummaryText,
out Button nonePresetButton,
out Button defensePresetButton,
out Button supportPresetButton,
out Button attackPresetButton,
out Button clearButton,
out RectTransform graphRect,
out RectTransform connectionLayer,
out RectTransform nodeLayer,
out RectTransform detailContent,
out TextMeshProUGUI detailText,
out Button selectNodeButton,
out TextMeshProUGUI selectNodeButtonLabel);
BuildFooter(panelObject.transform, out TextMeshProUGUI statusText);
PassiveTreeViewReferences references = root.GetComponent<PassiveTreeViewReferences>();
SetSerializedReference(references, "rootRect", rootRect);
SetSerializedReference(references, "overlayRoot", overlayRootObject);
SetSerializedReference(references, "panelRect", panelRect);
SetSerializedReference(references, "toggleButton", toggleButton);
SetSerializedReference(references, "toggleButtonLabel", toggleButton.GetComponentInChildren<TextMeshProUGUI>(true));
SetSerializedReference(references, "pointsSummaryText", pointsSummaryText);
SetSerializedReference(references, "closeButton", closeButton);
SetSerializedReference(references, "selectionSummaryText", selectionSummaryText);
SetSerializedReference(references, "nonePresetButton", nonePresetButton);
SetSerializedReference(references, "defensePresetButton", defensePresetButton);
SetSerializedReference(references, "supportPresetButton", supportPresetButton);
SetSerializedReference(references, "attackPresetButton", attackPresetButton);
SetSerializedReference(references, "clearButton", clearButton);
SetSerializedReference(references, "graphRect", graphRect);
SetSerializedReference(references, "connectionLayer", connectionLayer);
SetSerializedReference(references, "nodeLayer", nodeLayer);
SetSerializedReference(references, "detailContent", detailContent);
SetSerializedReference(references, "detailText", detailText);
SetSerializedReference(references, "selectNodeButton", selectNodeButton);
SetSerializedReference(references, "selectNodeButtonLabel", selectNodeButtonLabel);
SetSerializedReference(references, "statusText", statusText);
GameObject saved = SavePrefab(root, ViewPrefabPath);
return saved.GetComponent<PassiveTreeViewReferences>();
}
finally
{
Object.DestroyImmediate(root);
}
}
private static void BindPlayerHudPrefab(PassiveTreeViewReferences viewPrefab, PassiveTreeNodeView nodePrefab)
{
GameObject root = PrefabUtility.LoadPrefabContents(PlayerHudPrefabPath);
try
{
PassiveTreeUI passiveTreeUi = root.GetComponent<PassiveTreeUI>();
if (passiveTreeUi == null)
{
passiveTreeUi = root.AddComponent<PassiveTreeUI>();
}
SerializedObject passiveTreeUiObject = new(passiveTreeUi);
passiveTreeUiObject.FindProperty("viewPrefab").objectReferenceValue = viewPrefab;
passiveTreeUiObject.FindProperty("nodePrefab").objectReferenceValue = nodePrefab;
passiveTreeUiObject.FindProperty("passivePrototypeCatalog").objectReferenceValue = LoadPassivePrototypeCatalog();
passiveTreeUiObject.FindProperty("normalNodeSprite").objectReferenceValue = LoadPassiveNodeSprite(false);
passiveTreeUiObject.FindProperty("specialNodeSprite").objectReferenceValue = LoadPassiveNodeSprite(true);
passiveTreeUiObject.FindProperty("innerNodeSprite").objectReferenceValue = LoadPassiveNodeSprite(false);
passiveTreeUiObject.ApplyModifiedPropertiesWithoutUndo();
PrefabUtility.SaveAsPrefabAsset(root, PlayerHudPrefabPath);
}
finally
{
PrefabUtility.UnloadPrefabContents(root);
}
}
private static void BuildHeader(Transform parent, out TextMeshProUGUI pointsSummaryText, out Button closeButton)
{
GameObject headerObject = CreateUiObject("Header", parent, typeof(RectTransform), typeof(Image), typeof(LayoutElement), typeof(HorizontalLayoutGroup));
RectTransform headerRect = headerObject.GetComponent<RectTransform>();
headerObject.GetComponent<LayoutElement>().minHeight = 54f;
Image headerImage = headerObject.GetComponent<Image>();
headerImage.sprite = GetBuiltinSprite();
headerImage.type = Image.Type.Sliced;
headerImage.color = SectionBackgroundColor;
HorizontalLayoutGroup headerLayout = headerObject.GetComponent<HorizontalLayoutGroup>();
headerLayout.padding = new RectOffset(14, 14, 8, 8);
headerLayout.spacing = 10f;
headerLayout.childAlignment = TextAnchor.MiddleLeft;
headerLayout.childControlWidth = true;
headerLayout.childControlHeight = true;
headerLayout.childForceExpandWidth = false;
headerLayout.childForceExpandHeight = false;
GameObject titleGroup = CreateUiObject("TitleGroup", headerObject.transform, typeof(RectTransform), typeof(LayoutElement), typeof(VerticalLayoutGroup));
titleGroup.GetComponent<LayoutElement>().flexibleWidth = 1f;
VerticalLayoutGroup titleLayout = titleGroup.GetComponent<VerticalLayoutGroup>();
titleLayout.spacing = 0f;
titleLayout.childAlignment = TextAnchor.MiddleLeft;
titleLayout.childControlWidth = true;
titleLayout.childControlHeight = true;
titleLayout.childForceExpandWidth = true;
titleLayout.childForceExpandHeight = false;
TextMeshProUGUI title = CreateText(titleGroup.transform, "Label_Title", 24f, TextAlignmentOptions.Left, FontStyles.Bold);
title.text = "패시브 트리";
title.textWrappingMode = TextWrappingModes.NoWrap;
pointsSummaryText = CreateText(headerObject.transform, "Label_Points", 17f, TextAlignmentOptions.Right, FontStyles.Normal);
pointsSummaryText.text = string.Empty;
pointsSummaryText.textWrappingMode = TextWrappingModes.NoWrap;
pointsSummaryText.gameObject.AddComponent<LayoutElement>().preferredWidth = 420f;
closeButton = CreateButton(headerObject.transform, "Button_Close", "닫기", new Vector2(84f, 34f), 18f, FontStyles.Bold, CloseButtonColor, TextAnchor.MiddleCenter);
}
private static void BuildBody(
Transform parent,
out TextMeshProUGUI selectionSummaryText,
out Button nonePresetButton,
out Button defensePresetButton,
out Button supportPresetButton,
out Button attackPresetButton,
out Button clearButton,
out RectTransform graphRect,
out RectTransform connectionLayer,
out RectTransform nodeLayer,
out RectTransform detailContent,
out TextMeshProUGUI detailText,
out Button selectNodeButton,
out TextMeshProUGUI selectNodeButtonLabel)
{
GameObject bodyObject = CreateUiObject("Body", parent, typeof(RectTransform), typeof(LayoutElement), typeof(HorizontalLayoutGroup));
LayoutElement bodyLayoutElement = bodyObject.GetComponent<LayoutElement>();
bodyLayoutElement.flexibleHeight = 1f;
HorizontalLayoutGroup bodyLayout = bodyObject.GetComponent<HorizontalLayoutGroup>();
bodyLayout.spacing = 16f;
bodyLayout.childAlignment = TextAnchor.UpperLeft;
bodyLayout.childControlWidth = true;
bodyLayout.childControlHeight = true;
bodyLayout.childForceExpandWidth = true;
bodyLayout.childForceExpandHeight = true;
RectTransform graphSection = CreateSectionRoot(bodyObject.transform, "Section_Graph", 0f);
graphSection.GetComponent<LayoutElement>().flexibleWidth = 1f;
graphSection.GetComponent<LayoutElement>().minWidth = 760f;
CreateSectionTitle(graphSection, "트리 그래프");
GameObject graphSurface = CreateUiObject("GraphSurface", graphSection, typeof(RectTransform), typeof(LayoutElement), typeof(Image));
graphRect = graphSurface.GetComponent<RectTransform>();
LayoutElement graphLayout = graphSurface.GetComponent<LayoutElement>();
graphLayout.flexibleHeight = 1f;
graphLayout.minHeight = 560f;
Image graphImage = graphSurface.GetComponent<Image>();
graphImage.sprite = GetBuiltinSprite();
graphImage.type = Image.Type.Sliced;
graphImage.color = SectionOverlayColor;
connectionLayer = CreateUiObject("Connections", graphSurface.transform, typeof(RectTransform)).GetComponent<RectTransform>();
StretchRect(connectionLayer);
nodeLayer = CreateUiObject("Nodes", graphSurface.transform, typeof(RectTransform)).GetComponent<RectTransform>();
StretchRect(nodeLayer);
RectTransform rightSection = CreateSectionRoot(bodyObject.transform, "Section_Right", 360f);
CreateSectionTitle(rightSection, "노드 상세");
RectTransform detailScroll = CreateScrollView(rightSection, "Scroll_Detail", out detailContent);
LayoutElement detailScrollLayout = detailScroll.gameObject.AddComponent<LayoutElement>();
detailScrollLayout.flexibleHeight = 1f;
detailScrollLayout.minHeight = 300f;
detailText = CreateText(detailContent, "Label_Detail", 19f, TextAlignmentOptions.TopLeft, FontStyles.Normal);
detailText.textWrappingMode = TextWrappingModes.Normal;
detailText.overflowMode = TextOverflowModes.Overflow;
detailText.margin = new Vector4(18f, 8f, 18f, 14f);
detailText.extraPadding = true;
detailText.gameObject.AddComponent<LayoutElement>().minHeight = 300f;
selectNodeButton = CreateButton(rightSection, "Button_SelectNode", "노드 선택", new Vector2(0f, 58f), 21f, FontStyles.Bold, ButtonColor, TextAnchor.MiddleCenter);
selectNodeButtonLabel = selectNodeButton.GetComponentInChildren<TextMeshProUGUI>(true);
CreateDivider(rightSection, "Divider_Actions");
selectionSummaryText = CreateInfoText(rightSection, "Label_SelectionSummary", 132f);
CreateSectionTitle(rightSection, "프리셋");
GameObject presetGrid = CreateUiObject("PresetGrid", rightSection, typeof(RectTransform), typeof(GridLayoutGroup));
GridLayoutGroup presetLayout = presetGrid.GetComponent<GridLayoutGroup>();
presetLayout.cellSize = new Vector2(154f, 56f);
presetLayout.spacing = new Vector2(8f, 8f);
presetLayout.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
presetLayout.constraintCount = 2;
nonePresetButton = CreateButton(presetGrid.transform, "Button_Preset_None", "패시브 없음", Vector2.zero, 18f, FontStyles.Normal, ButtonColor, TextAnchor.MiddleCenter);
defensePresetButton = CreateButton(presetGrid.transform, "Button_Preset_Defense", "방어형 패시브", Vector2.zero, 18f, FontStyles.Normal, ButtonColor, TextAnchor.MiddleCenter);
supportPresetButton = CreateButton(presetGrid.transform, "Button_Preset_Support", "지원형 패시브", Vector2.zero, 18f, FontStyles.Normal, ButtonColor, TextAnchor.MiddleCenter);
attackPresetButton = CreateButton(presetGrid.transform, "Button_Preset_Attack", "공격형 패시브", Vector2.zero, 18f, FontStyles.Normal, ButtonColor, TextAnchor.MiddleCenter);
clearButton = CreateButton(rightSection, "Button_Clear", "전체 초기화", new Vector2(0f, 52f), 18f, FontStyles.Bold, ButtonColor, TextAnchor.MiddleCenter);
}
private static void BuildFooter(Transform parent, out TextMeshProUGUI statusText)
{
GameObject footerObject = CreateUiObject("Footer", parent, typeof(RectTransform), typeof(Image), typeof(LayoutElement), typeof(HorizontalLayoutGroup));
footerObject.GetComponent<LayoutElement>().minHeight = 42f;
Image footerImage = footerObject.GetComponent<Image>();
footerImage.sprite = GetBuiltinSprite();
footerImage.type = Image.Type.Sliced;
footerImage.color = SectionBackgroundColor;
HorizontalLayoutGroup footerLayout = footerObject.GetComponent<HorizontalLayoutGroup>();
footerLayout.padding = new RectOffset(16, 16, 8, 8);
footerLayout.childAlignment = TextAnchor.MiddleLeft;
footerLayout.childControlWidth = true;
footerLayout.childControlHeight = true;
footerLayout.childForceExpandWidth = true;
footerLayout.childForceExpandHeight = true;
statusText = CreateText(footerObject.transform, "Label_Status", 17f, TextAlignmentOptions.MidlineLeft, FontStyles.Normal);
statusText.text = string.Empty;
statusText.textWrappingMode = TextWrappingModes.NoWrap;
statusText.overflowMode = TextOverflowModes.Ellipsis;
footerObject.SetActive(false);
}
private static RectTransform CreateSectionRoot(Transform parent, string name, float preferredWidth)
{
GameObject sectionObject = CreateUiObject(name, parent, typeof(RectTransform), typeof(Image), typeof(LayoutElement), typeof(VerticalLayoutGroup));
RectTransform rect = sectionObject.GetComponent<RectTransform>();
LayoutElement layoutElement = sectionObject.GetComponent<LayoutElement>();
layoutElement.flexibleHeight = 1f;
layoutElement.preferredWidth = preferredWidth;
if (preferredWidth > 0f)
{
layoutElement.minWidth = preferredWidth;
layoutElement.flexibleWidth = 0f;
}
Image sectionImage = sectionObject.GetComponent<Image>();
sectionImage.sprite = GetBuiltinSprite();
sectionImage.type = Image.Type.Sliced;
sectionImage.color = SectionBackgroundColor;
VerticalLayoutGroup layout = sectionObject.GetComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(16, 16, 16, 16);
layout.spacing = 12f;
layout.childAlignment = TextAnchor.UpperLeft;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = false;
return rect;
}
private static TextMeshProUGUI CreateSectionTitle(RectTransform parent, string text)
{
TextMeshProUGUI label = CreateText(parent, "Label_Title", 28f, TextAlignmentOptions.Left, FontStyles.Bold);
label.text = text;
return label;
}
private static TextMeshProUGUI CreateInfoText(RectTransform parent, string name, float minHeight)
{
GameObject containerObject = CreateUiObject($"{name}_Container", parent, typeof(RectTransform), typeof(Image), typeof(LayoutElement), typeof(VerticalLayoutGroup));
RectTransform containerRect = containerObject.GetComponent<RectTransform>();
containerObject.GetComponent<LayoutElement>().minHeight = minHeight;
Image containerImage = containerObject.GetComponent<Image>();
containerImage.sprite = GetBuiltinSprite();
containerImage.type = Image.Type.Sliced;
containerImage.color = SectionOverlayColor;
VerticalLayoutGroup layout = containerObject.GetComponent<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 = CreateText(containerRect, name, 18f, TextAlignmentOptions.TopLeft, FontStyles.Normal);
label.textWrappingMode = TextWrappingModes.Normal;
label.overflowMode = TextOverflowModes.Overflow;
label.margin = new Vector4(14f, 6f, 14f, 10f);
label.extraPadding = true;
return label;
}
private static void CreateDivider(Transform parent, string name)
{
GameObject dividerObject = CreateUiObject(name, parent, typeof(RectTransform), typeof(Image), typeof(LayoutElement));
dividerObject.GetComponent<Image>().color = new Color(0f, 0f, 0f, 0.22f);
LayoutElement layout = dividerObject.GetComponent<LayoutElement>();
layout.minHeight = 2f;
layout.preferredHeight = 2f;
}
private static RectTransform CreateScrollView(Transform parent, string name, out RectTransform content)
{
GameObject root = CreateUiObject(name, parent, typeof(RectTransform), typeof(Image), typeof(ScrollRect));
RectTransform rootRect = root.GetComponent<RectTransform>();
Image rootImage = root.GetComponent<Image>();
rootImage.sprite = GetBuiltinSprite();
rootImage.type = Image.Type.Sliced;
rootImage.color = SectionOverlayColor;
ScrollRect scrollRect = root.GetComponent<ScrollRect>();
scrollRect.horizontal = false;
scrollRect.vertical = true;
scrollRect.scrollSensitivity = 30f;
GameObject viewportObject = CreateUiObject("Viewport", root.transform, typeof(RectTransform), typeof(Image), typeof(Mask));
RectTransform viewport = viewportObject.GetComponent<RectTransform>();
StretchRect(viewport);
viewportObject.GetComponent<Image>().color = new Color(1f, 1f, 1f, 0.01f);
viewportObject.GetComponent<Mask>().showMaskGraphic = false;
GameObject contentObject = CreateUiObject("Content", viewportObject.transform, typeof(RectTransform), typeof(VerticalLayoutGroup), typeof(ContentSizeFitter));
content = contentObject.GetComponent<RectTransform>();
content.anchorMin = new Vector2(0f, 1f);
content.anchorMax = new Vector2(1f, 1f);
content.pivot = new Vector2(0f, 1f);
VerticalLayoutGroup contentLayout = contentObject.GetComponent<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 = contentObject.GetComponent<ContentSizeFitter>();
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
scrollRect.viewport = viewport;
scrollRect.content = content;
return rootRect;
}
private static Button CreateButton(Transform parent, string name, string labelText, Vector2 size, float fontSize, FontStyles fontStyle, Color color, TextAnchor anchor)
{
GameObject buttonObject = CreateUiObject(name, parent, typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(Button));
RectTransform rect = buttonObject.GetComponent<RectTransform>();
if (size.sqrMagnitude > 0f)
{
rect.sizeDelta = size;
}
Image image = buttonObject.GetComponent<Image>();
image.sprite = GetBuiltinSprite();
image.type = Image.Type.Sliced;
image.color = color;
Button button = buttonObject.GetComponent<Button>();
button.targetGraphic = image;
TextMeshProUGUI label = CreateText(buttonObject.transform, "Label", fontSize, ToTmpAlignment(anchor), fontStyle);
label.text = labelText;
StretchRect(label.rectTransform);
label.color = Color.white;
label.textWrappingMode = TextWrappingModes.Normal;
return button;
}
private static TextMeshProUGUI CreateText(Transform parent, string name, float fontSize, TextAlignmentOptions alignment, FontStyles fontStyle)
{
GameObject textObject = CreateUiObject(name, parent, typeof(RectTransform), typeof(CanvasRenderer), typeof(TextMeshProUGUI));
RectTransform rect = textObject.GetComponent<RectTransform>();
rect.anchorMin = new Vector2(0f, 1f);
rect.anchorMax = new Vector2(1f, 1f);
rect.pivot = new Vector2(0f, 1f);
TextMeshProUGUI text = textObject.GetComponent<TextMeshProUGUI>();
text.font = LoadPreferredFontAsset();
text.fontSize = fontSize;
text.fontStyle = fontStyle;
text.color = TextColor;
text.alignment = alignment;
text.textWrappingMode = TextWrappingModes.Normal;
text.extraPadding = true;
text.raycastTarget = false;
return text;
}
private static TMP_FontAsset LoadPreferredFontAsset()
{
TMP_FontAsset preferredFont = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(PreferredFontAssetPath);
if (preferredFont != null)
return preferredFont;
return TMP_Settings.defaultFontAsset;
}
private static Sprite LoadPassiveNodeSprite(bool special)
{
string assetPath = special ? SpecialIconSpritePath : NormalIconSpritePath;
return AssetDatabase.LoadAssetAtPath<Sprite>(assetPath);
}
private static PassivePrototypeCatalogData LoadPassivePrototypeCatalog()
{
return AssetDatabase.LoadAssetAtPath<PassivePrototypeCatalogData>(PassiveCatalogAssetPath);
}
private static Image AddImage(GameObject gameObject, string childName, Color color)
{
GameObject imageObject = CreateUiObject(childName, gameObject.transform, typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
Image image = imageObject.GetComponent<Image>();
image.sprite = GetBuiltinSprite();
image.type = Image.Type.Sliced;
image.color = color;
return image;
}
private static GameObject CreateUiObject(string name, Transform parent, params System.Type[] componentTypes)
{
GameObject gameObject = new(name, componentTypes);
gameObject.transform.SetParent(parent, false);
return gameObject;
}
private static void StretchRect(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 SetSerializedReference(Object target, string propertyName, Object value)
{
SerializedObject serializedObject = new(target);
serializedObject.FindProperty(propertyName).objectReferenceValue = value;
serializedObject.ApplyModifiedPropertiesWithoutUndo();
}
private static GameObject SavePrefab(GameObject root, string path)
{
PrefabUtility.SaveAsPrefabAsset(root, path);
return AssetDatabase.LoadAssetAtPath<GameObject>(path);
}
private static Sprite GetBuiltinSprite()
{
return AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/UISprite.psd");
}
private static TextAlignmentOptions ToTmpAlignment(TextAnchor anchor)
{
return anchor switch
{
TextAnchor.MiddleCenter => TextAlignmentOptions.Center,
TextAnchor.MiddleLeft => TextAlignmentOptions.MidlineLeft,
_ => TextAlignmentOptions.Center,
};
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 99f2c00c8ea8e7947978c046303348b4

View File

@@ -0,0 +1,787 @@
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using Colosseum.Passives;
using Colosseum.Player;
using Colosseum.Skills;
using Colosseum.Stats;
using Colosseum.UI;
namespace Colosseum.Editor
{
/// <summary>
/// 패시브 트리 프로토타입 에셋 생성 및 디버그 적용 메뉴입니다.
/// </summary>
public static class PlayerPassiveDebugMenu
{
private const string DataFolderPath = "Assets/_Game/Data";
private const string PassiveFolderPath = "Assets/_Game/Data/Passives";
private const string PassiveNodeFolderPath = "Assets/_Game/Data/Passives/Nodes";
private const string PassivePresetFolderPath = "Assets/_Game/Data/Passives/Presets";
private const string PassiveCatalogAssetPath = PassiveFolderPath + "/Data_PassivePrototypeCatalog.asset";
private const string PlayerPrefabPath = "Assets/_Game/Prefabs/Player/Prefab_Player_Default.prefab";
private const string PlayerResourcesPrefabPath = "Assets/_Game/Prefabs/UI/UI_PlayerResources.prefab";
private const string PassiveTreeAssetPath = PassiveFolderPath + "/Data_PassiveTree_Player_Prototype.asset";
private const string NonePresetAssetPath = PassivePresetFolderPath + "/Data_PassivePreset_Player_None.asset";
private const string DefensePresetAssetPath = PassivePresetFolderPath + "/Data_PassivePreset_Player_Tank.asset";
private const string SupportPresetAssetPath = PassivePresetFolderPath + "/Data_PassivePreset_Player_Support.asset";
private const string AttackPresetAssetPath = PassivePresetFolderPath + "/Data_PassivePreset_Player_Dps.asset";
private const string HubNodeAssetPath = PassiveNodeFolderPath + "/Data_PassiveNode_Player_Hub.asset";
private const string DefenseEntryNodeAssetPath = PassiveNodeFolderPath + "/Data_PassiveNode_Player_Tank_Entry.asset";
private const string DefenseCoreNodeAssetPath = PassiveNodeFolderPath + "/Data_PassiveNode_Player_Tank_Core.asset";
private const string DefenseCapstoneNodeAssetPath = PassiveNodeFolderPath + "/Data_PassiveNode_Player_Tank_Capstone.asset";
private const string SupportEntryNodeAssetPath = PassiveNodeFolderPath + "/Data_PassiveNode_Player_Support_Entry.asset";
private const string SupportCoreNodeAssetPath = PassiveNodeFolderPath + "/Data_PassiveNode_Player_Support_Core.asset";
private const string SupportCapstoneNodeAssetPath = PassiveNodeFolderPath + "/Data_PassiveNode_Player_Support_Capstone.asset";
private const string AttackEntryNodeAssetPath = PassiveNodeFolderPath + "/Data_PassiveNode_Player_Dps_Entry.asset";
private const string AttackCoreNodeAssetPath = PassiveNodeFolderPath + "/Data_PassiveNode_Player_Dps_Core.asset";
private const string AttackCapstoneNodeAssetPath = PassiveNodeFolderPath + "/Data_PassiveNode_Player_Dps_Capstone.asset";
private const string AttackDefenseBridgeNodeAssetPath = PassiveNodeFolderPath + "/Data_PassiveNode_Player_AttackDefense_Bridge.asset";
private const string DefenseSupportBridgeNodeAssetPath = PassiveNodeFolderPath + "/Data_PassiveNode_Player_DefenseSupport_Bridge.asset";
private const string SupportAttackBridgeNodeAssetPath = PassiveNodeFolderPath + "/Data_PassiveNode_Player_SupportAttack_Bridge.asset";
[MenuItem("Tools/Colosseum/Debug/Passive/Bootstrap Prototype Assets")]
private static void BootstrapPrototypeAssets()
{
EnsureFolder("Assets/_Game", "Data");
EnsureFolder(DataFolderPath, "Passives");
EnsureFolder(PassiveFolderPath, "Nodes");
EnsureFolder(PassiveFolderPath, "Presets");
PassiveNodeData hubNode = CreateOrLoadNode(HubNodeAssetPath);
PassiveNodeData defenseEntryNode = CreateOrLoadNode(DefenseEntryNodeAssetPath);
PassiveNodeData defenseCoreNode = CreateOrLoadNode(DefenseCoreNodeAssetPath);
PassiveNodeData defenseCapstoneNode = CreateOrLoadNode(DefenseCapstoneNodeAssetPath);
PassiveNodeData supportEntryNode = CreateOrLoadNode(SupportEntryNodeAssetPath);
PassiveNodeData supportCoreNode = CreateOrLoadNode(SupportCoreNodeAssetPath);
PassiveNodeData supportCapstoneNode = CreateOrLoadNode(SupportCapstoneNodeAssetPath);
PassiveNodeData attackEntryNode = CreateOrLoadNode(AttackEntryNodeAssetPath);
PassiveNodeData attackCoreNode = CreateOrLoadNode(AttackCoreNodeAssetPath);
PassiveNodeData attackCapstoneNode = CreateOrLoadNode(AttackCapstoneNodeAssetPath);
PassiveNodeData attackDefenseBridgeNode = CreateOrLoadNode(AttackDefenseBridgeNodeAssetPath);
PassiveNodeData defenseSupportBridgeNode = CreateOrLoadNode(DefenseSupportBridgeNodeAssetPath);
PassiveNodeData supportAttackBridgeNode = CreateOrLoadNode(SupportAttackBridgeNodeAssetPath);
ConfigureNode(
hubNode,
"hub",
"중심 허브",
"패시브 트리의 시작점입니다.",
PassiveNodeBranch.Common,
PassiveNodeKind.Hub,
PassiveAxisMask.None,
0,
0,
new Vector2(0f, 0f),
new PassiveNodeData[0],
new[] { attackEntryNode, defenseEntryNode, supportEntryNode },
new PassiveEffectConfig[0]);
ConfigureNode(
attackEntryNode,
"attack_entry",
"공세 적응",
"공격 축의 출발점으로, 기본 화력 계열 스탯을 끌어올립니다.",
PassiveNodeBranch.Attack,
PassiveNodeKind.Axis,
PassiveAxisMask.Attack,
1,
1,
new Vector2(0f, 0.34f),
new[] { hubNode },
new[] { hubNode, attackCoreNode, attackDefenseBridgeNode, supportAttackBridgeNode },
new[]
{
PassiveEffectConfig.CreateStat(StatType.Strength, StatModType.PercentAdd, 0.1f),
PassiveEffectConfig.CreateStat(StatType.Dexterity, StatModType.PercentAdd, 0.1f),
PassiveEffectConfig.CreateStat(StatType.Intelligence, StatModType.PercentAdd, 0.1f),
});
ConfigureNode(
attackCoreNode,
"attack_core",
"집중 공세",
"공격 스킬 계열의 핵심 화력을 강화합니다.",
PassiveNodeBranch.Attack,
PassiveNodeKind.Axis,
PassiveAxisMask.Attack,
2,
1,
new Vector2(0f, 0.6f),
new[] { attackEntryNode },
new[] { attackEntryNode, attackCapstoneNode },
new[]
{
PassiveEffectConfig.CreateScalar(PassiveEffectType.DamageMultiplier, 1.12f, SkillRoleType.Attack),
});
ConfigureNode(
attackCapstoneNode,
"attack_capstone",
"집행 증폭",
"공격 축 완성 노드로, 공격 계열 고위력 기술의 기여도를 강화합니다.",
PassiveNodeBranch.Attack,
PassiveNodeKind.Capstone,
PassiveAxisMask.Attack,
3,
2,
new Vector2(0f, 0.84f),
new[] { attackCoreNode },
new[] { attackCoreNode },
new[]
{
PassiveEffectConfig.CreateScalar(PassiveEffectType.DamageMultiplier, 1.10f, SkillRoleType.Attack),
PassiveEffectConfig.CreateScalar(PassiveEffectType.ManaCostMultiplier, 0.90f, SkillRoleType.Attack),
});
ConfigureNode(
defenseEntryNode,
"defense_entry",
"전열 적응",
"방어 축의 출발점으로, 전열 유지에 필요한 생존력을 확보합니다.",
PassiveNodeBranch.Defense,
PassiveNodeKind.Axis,
PassiveAxisMask.Defense,
1,
1,
new Vector2(-0.34f, -0.1f),
new[] { hubNode },
new[] { hubNode, defenseCoreNode, attackDefenseBridgeNode, defenseSupportBridgeNode },
new[]
{
PassiveEffectConfig.CreateStat(StatType.Vitality, StatModType.PercentAdd, 0.2f),
});
ConfigureNode(
defenseCoreNode,
"defense_core",
"방호 숙련",
"위협 유지와 보호막 수혜량을 함께 강화합니다.",
PassiveNodeBranch.Defense,
PassiveNodeKind.Axis,
PassiveAxisMask.Defense,
2,
1,
new Vector2(-0.58f, -0.34f),
new[] { defenseEntryNode },
new[] { defenseEntryNode, defenseCapstoneNode },
new[]
{
PassiveEffectConfig.CreateScalar(PassiveEffectType.ThreatGeneratedMultiplier, 1.30f),
PassiveEffectConfig.CreateScalar(PassiveEffectType.ShieldReceivedMultiplier, 1.15f),
});
ConfigureNode(
defenseCapstoneNode,
"defense_capstone",
"철벽 유지",
"받는 피해를 낮춰 전열 유지력을 완성합니다.",
PassiveNodeBranch.Defense,
PassiveNodeKind.Capstone,
PassiveAxisMask.Defense,
3,
2,
new Vector2(-0.82f, -0.58f),
new[] { defenseCoreNode },
new[] { defenseCoreNode },
new[]
{
PassiveEffectConfig.CreateScalar(PassiveEffectType.IncomingDamageMultiplier, 0.88f),
});
ConfigureNode(
supportEntryNode,
"support_entry",
"구호 적응",
"지원 축의 출발점으로, 회복 기반 능력을 높입니다.",
PassiveNodeBranch.Support,
PassiveNodeKind.Axis,
PassiveAxisMask.Support,
1,
1,
new Vector2(0.34f, -0.1f),
new[] { hubNode },
new[] { hubNode, supportCoreNode, defenseSupportBridgeNode, supportAttackBridgeNode },
new[]
{
PassiveEffectConfig.CreateStat(StatType.Wisdom, StatModType.PercentAdd, 0.2f),
});
ConfigureNode(
supportCoreNode,
"support_core",
"조율 숙련",
"회복과 보호막 부여 효율을 함께 강화합니다.",
PassiveNodeBranch.Support,
PassiveNodeKind.Axis,
PassiveAxisMask.Support,
2,
1,
new Vector2(0.58f, -0.34f),
new[] { supportEntryNode },
new[] { supportEntryNode, supportCapstoneNode },
new[]
{
PassiveEffectConfig.CreateScalar(PassiveEffectType.HealMultiplier, 1.15f, SkillRoleType.Support),
PassiveEffectConfig.CreateScalar(PassiveEffectType.ShieldDoneMultiplier, 1.15f, SkillRoleType.Support),
});
ConfigureNode(
supportCapstoneNode,
"support_capstone",
"마력 순환",
"최대 마나와 유지 효율을 함께 높입니다.",
PassiveNodeBranch.Support,
PassiveNodeKind.Capstone,
PassiveAxisMask.Support,
3,
2,
new Vector2(0.82f, -0.58f),
new[] { supportCoreNode },
new[] { supportCoreNode },
new[]
{
PassiveEffectConfig.CreateStat(StatType.Spirit, StatModType.PercentAdd, 0.2f),
PassiveEffectConfig.CreateScalar(PassiveEffectType.ManaCostMultiplier, 0.85f, SkillRoleType.Support),
});
ConfigureNode(
attackDefenseBridgeNode,
"attack_defense_bridge",
"압박 방벽",
"공격과 방어를 연결하는 브릿지로, 압박 유지력과 전투 안정성을 함께 챙깁니다.",
PassiveNodeBranch.Bridge,
PassiveNodeKind.Bridge,
PassiveAxisMask.Attack | PassiveAxisMask.Defense,
2,
1,
new Vector2(-0.24f, 0.14f),
new[] { attackEntryNode, defenseEntryNode },
new[] { attackEntryNode, defenseEntryNode },
new[]
{
PassiveEffectConfig.CreateScalar(PassiveEffectType.DamageMultiplier, 1.06f, SkillRoleType.Attack),
PassiveEffectConfig.CreateScalar(PassiveEffectType.IncomingDamageMultiplier, 0.95f),
});
ConfigureNode(
defenseSupportBridgeNode,
"defense_support_bridge",
"수호 순환",
"방어와 지원을 연결하는 브릿지로, 보호막과 회복 기여를 함께 끌어올립니다.",
PassiveNodeBranch.Bridge,
PassiveNodeKind.Bridge,
PassiveAxisMask.Defense | PassiveAxisMask.Support,
2,
1,
new Vector2(0f, -0.28f),
new[] { defenseEntryNode, supportEntryNode },
new[] { defenseEntryNode, supportEntryNode },
new[]
{
PassiveEffectConfig.CreateScalar(PassiveEffectType.ShieldReceivedMultiplier, 1.10f),
PassiveEffectConfig.CreateScalar(PassiveEffectType.HealMultiplier, 1.08f, SkillRoleType.Support),
});
ConfigureNode(
supportAttackBridgeNode,
"support_attack_bridge",
"전술 증폭",
"지원과 공격을 연결하는 브릿지로, 화력과 유지 효율을 함께 보조합니다.",
PassiveNodeBranch.Bridge,
PassiveNodeKind.Bridge,
PassiveAxisMask.Support | PassiveAxisMask.Attack,
2,
1,
new Vector2(0.24f, 0.14f),
new[] { supportEntryNode, attackEntryNode },
new[] { supportEntryNode, attackEntryNode },
new[]
{
PassiveEffectConfig.CreateScalar(PassiveEffectType.DamageMultiplier, 1.06f, SkillRoleType.Attack),
PassiveEffectConfig.CreateScalar(PassiveEffectType.ManaCostMultiplier, 0.95f),
});
PassiveTreeData tree = CreateOrLoadTree();
ConfigureTree(
tree,
"player_prototype_tree",
"플레이어 패시브 프로토타입",
"공격 / 방어 / 지원 3축과 브릿지 노드로 구성된 드로그전 밸런싱 검증용 트리입니다.",
8,
new[]
{
hubNode,
attackEntryNode,
attackCoreNode,
attackCapstoneNode,
defenseEntryNode,
defenseCoreNode,
defenseCapstoneNode,
supportEntryNode,
supportCoreNode,
supportCapstoneNode,
attackDefenseBridgeNode,
defenseSupportBridgeNode,
supportAttackBridgeNode,
});
CreateOrUpdatePreset(
NonePresetAssetPath,
"패시브 없음",
"비교 기준선 확보용 프리셋입니다.",
tree,
new[] { hubNode });
CreateOrUpdatePreset(
DefensePresetAssetPath,
"방어형 패시브",
"방어 축 완성과 함께 공격/지원 브릿지를 가볍게 여는 프리셋입니다.",
tree,
new[]
{
hubNode,
defenseEntryNode,
defenseCoreNode,
defenseCapstoneNode,
attackEntryNode,
supportEntryNode,
attackDefenseBridgeNode,
defenseSupportBridgeNode,
});
CreateOrUpdatePreset(
SupportPresetAssetPath,
"지원형 패시브",
"지원 축 완성과 함께 공격/방어 브릿지를 가볍게 여는 프리셋입니다.",
tree,
new[]
{
hubNode,
supportEntryNode,
supportCoreNode,
supportCapstoneNode,
defenseEntryNode,
attackEntryNode,
defenseSupportBridgeNode,
supportAttackBridgeNode,
});
CreateOrUpdatePreset(
AttackPresetAssetPath,
"공격형 패시브",
"공격 축 완성과 함께 방어/지원 브릿지를 가볍게 여는 프리셋입니다.",
tree,
new[]
{
hubNode,
attackEntryNode,
attackCoreNode,
attackCapstoneNode,
defenseEntryNode,
supportEntryNode,
attackDefenseBridgeNode,
supportAttackBridgeNode,
});
PassivePrototypeCatalogData catalog = CreateOrLoadCatalog();
ConfigureCatalog(
catalog,
tree,
AssetDatabase.LoadAssetAtPath<PassivePresetData>(NonePresetAssetPath),
AssetDatabase.LoadAssetAtPath<PassivePresetData>(DefensePresetAssetPath),
AssetDatabase.LoadAssetAtPath<PassivePresetData>(SupportPresetAssetPath),
AssetDatabase.LoadAssetAtPath<PassivePresetData>(AttackPresetAssetPath));
BindPrototypeCatalogToPrefabs(catalog);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("[Passive] 프로토타입 패시브 에셋 생성을 완료했습니다.");
}
[MenuItem("Tools/Colosseum/Debug/Passive/Apply Local None")]
private static void ApplyLocalNone()
{
ApplyLocalPreset(NonePresetAssetPath, "패시브 없음");
}
[MenuItem("Tools/Colosseum/Debug/Passive/Apply Local Defense")]
private static void ApplyLocalDefense()
{
ApplyLocalPreset(DefensePresetAssetPath, "방어형 패시브");
}
[MenuItem("Tools/Colosseum/Debug/Passive/Apply Local Support")]
private static void ApplyLocalSupport()
{
ApplyLocalPreset(SupportPresetAssetPath, "지원형 패시브");
}
[MenuItem("Tools/Colosseum/Debug/Passive/Apply Local Attack")]
private static void ApplyLocalAttack()
{
ApplyLocalPreset(AttackPresetAssetPath, "공격형 패시브");
}
[MenuItem("Tools/Colosseum/Debug/Passive/Apply Owner Presets To All Players")]
private static void ApplyOwnerPresetsToAllPlayers()
{
if (!EditorApplication.isPlaying)
{
Debug.LogWarning("[Passive] 플레이 모드에서만 사용할 수 있습니다.");
return;
}
PlayerNetworkController[] players = Object.FindObjectsByType<PlayerNetworkController>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
if (players == null || players.Length == 0)
{
Debug.LogWarning("[Passive] PlayerNetworkController를 찾지 못했습니다.");
return;
}
int appliedCount = 0;
for (int i = 0; i < players.Length; i++)
{
PlayerNetworkController player = players[i];
if (player != null && player.TryApplyPrototypePassivePresetForOwner())
{
appliedCount++;
}
}
Debug.Log($"[Passive] 역할별 패시브 프리셋 적용 완료 | Applied={appliedCount}");
}
[MenuItem("Tools/Colosseum/Debug/Passive/Log Local Passive Summary")]
private static void LogLocalPassiveSummary()
{
if (!EditorApplication.isPlaying)
{
Debug.LogWarning("[Passive] 플레이 모드에서만 사용할 수 있습니다.");
return;
}
PlayerNetworkController localNetworkController = FindLocalNetworkController();
if (localNetworkController == null)
{
Debug.LogWarning("[Passive] 로컬 PlayerNetworkController를 찾지 못했습니다.");
return;
}
Debug.Log(localNetworkController.BuildPassiveSummary());
}
private static void ApplyLocalPreset(string presetPath, string label)
{
if (!EditorApplication.isPlaying)
{
Debug.LogWarning("[Passive] 플레이 모드에서만 사용할 수 있습니다.");
return;
}
PlayerNetworkController localNetworkController = FindLocalNetworkController();
if (localNetworkController == null)
{
Debug.LogWarning("[Passive] 로컬 PlayerNetworkController를 찾지 못했습니다.");
return;
}
PassivePresetData preset = AssetDatabase.LoadAssetAtPath<PassivePresetData>(presetPath);
if (preset == null)
{
Debug.LogWarning($"[Passive] 패시브 프리셋을 찾지 못했습니다: {presetPath}");
return;
}
if (!localNetworkController.DebugApplyPassivePreset(preset))
{
Debug.LogWarning($"[Passive] {label} 적용에 실패했습니다. 호스트/서버 플레이 모드인지 확인하세요.");
return;
}
Debug.Log($"[Passive] {label} 적용 완료");
}
private static PlayerNetworkController FindLocalNetworkController()
{
PlayerNetworkController[] players = Object.FindObjectsByType<PlayerNetworkController>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
for (int i = 0; i < players.Length; i++)
{
PlayerNetworkController player = players[i];
if (player != null && player.IsOwner)
return player;
}
return null;
}
private static void EnsureFolder(string parentFolder, string childFolderName)
{
string fullPath = $"{parentFolder}/{childFolderName}";
if (AssetDatabase.IsValidFolder(fullPath))
return;
AssetDatabase.CreateFolder(parentFolder, childFolderName);
}
private static PassiveTreeData CreateOrLoadTree()
{
PassiveTreeData tree = AssetDatabase.LoadAssetAtPath<PassiveTreeData>(PassiveTreeAssetPath);
if (tree != null)
return tree;
tree = ScriptableObject.CreateInstance<PassiveTreeData>();
AssetDatabase.CreateAsset(tree, PassiveTreeAssetPath);
return tree;
}
private static PassivePrototypeCatalogData CreateOrLoadCatalog()
{
PassivePrototypeCatalogData catalog = AssetDatabase.LoadAssetAtPath<PassivePrototypeCatalogData>(PassiveCatalogAssetPath);
if (catalog != null)
return catalog;
catalog = ScriptableObject.CreateInstance<PassivePrototypeCatalogData>();
AssetDatabase.CreateAsset(catalog, PassiveCatalogAssetPath);
return catalog;
}
private static PassiveNodeData CreateOrLoadNode(string assetPath)
{
PassiveNodeData node = AssetDatabase.LoadAssetAtPath<PassiveNodeData>(assetPath);
if (node != null)
return node;
node = ScriptableObject.CreateInstance<PassiveNodeData>();
AssetDatabase.CreateAsset(node, assetPath);
return node;
}
private static void ConfigureTree(
PassiveTreeData tree,
string treeId,
string treeName,
string description,
int initialPoints,
IReadOnlyList<PassiveNodeData> nodes)
{
SerializedObject serializedTree = new SerializedObject(tree);
serializedTree.FindProperty("treeId").stringValue = treeId;
serializedTree.FindProperty("treeName").stringValue = treeName;
serializedTree.FindProperty("description").stringValue = description;
serializedTree.FindProperty("initialPoints").intValue = initialPoints;
SerializedProperty nodeProperty = serializedTree.FindProperty("nodes");
nodeProperty.arraySize = nodes != null ? nodes.Count : 0;
for (int i = 0; i < nodeProperty.arraySize; i++)
{
nodeProperty.GetArrayElementAtIndex(i).objectReferenceValue = nodes[i];
}
serializedTree.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(tree);
}
private static void ConfigureNode(
PassiveNodeData node,
string nodeId,
string displayName,
string description,
PassiveNodeBranch branch,
PassiveNodeKind nodeKind,
PassiveAxisMask axisMask,
int tier,
int cost,
Vector2 layoutPosition,
IReadOnlyList<PassiveNodeData> prerequisiteNodes,
IReadOnlyList<PassiveNodeData> connectedNodes,
IReadOnlyList<PassiveEffectConfig> effects)
{
SerializedObject serializedNode = new SerializedObject(node);
serializedNode.FindProperty("nodeId").stringValue = nodeId;
serializedNode.FindProperty("displayName").stringValue = displayName;
serializedNode.FindProperty("description").stringValue = description;
serializedNode.FindProperty("branch").enumValueIndex = (int)branch;
serializedNode.FindProperty("nodeKind").enumValueIndex = (int)nodeKind;
serializedNode.FindProperty("axisMask").intValue = (int)axisMask;
serializedNode.FindProperty("tier").intValue = tier;
serializedNode.FindProperty("cost").intValue = cost;
serializedNode.FindProperty("layoutPosition").vector2Value = layoutPosition;
SerializedProperty prerequisiteProperty = serializedNode.FindProperty("prerequisiteNodes");
prerequisiteProperty.arraySize = prerequisiteNodes != null ? prerequisiteNodes.Count : 0;
for (int i = 0; i < prerequisiteProperty.arraySize; i++)
{
prerequisiteProperty.GetArrayElementAtIndex(i).objectReferenceValue = prerequisiteNodes[i];
}
SerializedProperty connectedProperty = serializedNode.FindProperty("connectedNodes");
connectedProperty.arraySize = connectedNodes != null ? connectedNodes.Count : 0;
for (int i = 0; i < connectedProperty.arraySize; i++)
{
connectedProperty.GetArrayElementAtIndex(i).objectReferenceValue = connectedNodes[i];
}
SerializedProperty effectProperty = serializedNode.FindProperty("effects");
effectProperty.arraySize = effects != null ? effects.Count : 0;
for (int i = 0; i < effectProperty.arraySize; i++)
{
SerializedProperty effectEntry = effectProperty.GetArrayElementAtIndex(i);
PassiveEffectConfig effectConfig = effects[i];
effectEntry.FindPropertyRelative("effectType").enumValueIndex = (int)effectConfig.EffectType;
effectEntry.FindPropertyRelative("statType").enumValueIndex = (int)effectConfig.StatType;
effectEntry.FindPropertyRelative("modType").enumValueIndex = (int)effectConfig.ModType;
effectEntry.FindPropertyRelative("value").floatValue = effectConfig.Value;
effectEntry.FindPropertyRelative("skillRoleMask").intValue = (int)effectConfig.SkillRoleMask;
}
serializedNode.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(node);
}
private static void CreateOrUpdatePreset(
string assetPath,
string presetName,
string description,
PassiveTreeData tree,
IReadOnlyList<PassiveNodeData> selectedNodes)
{
PassivePresetData preset = AssetDatabase.LoadAssetAtPath<PassivePresetData>(assetPath);
if (preset == null)
{
preset = ScriptableObject.CreateInstance<PassivePresetData>();
AssetDatabase.CreateAsset(preset, assetPath);
}
SerializedObject serializedPreset = new SerializedObject(preset);
serializedPreset.FindProperty("presetName").stringValue = presetName;
serializedPreset.FindProperty("description").stringValue = description;
serializedPreset.FindProperty("tree").objectReferenceValue = tree;
SerializedProperty selectedNodesProperty = serializedPreset.FindProperty("selectedNodes");
selectedNodesProperty.arraySize = selectedNodes != null ? selectedNodes.Count : 0;
for (int i = 0; i < selectedNodesProperty.arraySize; i++)
{
selectedNodesProperty.GetArrayElementAtIndex(i).objectReferenceValue = selectedNodes[i];
}
serializedPreset.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(preset);
}
private static void ConfigureCatalog(
PassivePrototypeCatalogData catalog,
PassiveTreeData tree,
PassivePresetData nonePreset,
PassivePresetData defensePreset,
PassivePresetData supportPreset,
PassivePresetData attackPreset)
{
if (catalog == null)
return;
SerializedObject serializedCatalog = new SerializedObject(catalog);
serializedCatalog.FindProperty("prototypeTree").objectReferenceValue = tree;
serializedCatalog.FindProperty("nonePreset").objectReferenceValue = nonePreset;
serializedCatalog.FindProperty("defensePreset").objectReferenceValue = defensePreset;
serializedCatalog.FindProperty("supportPreset").objectReferenceValue = supportPreset;
serializedCatalog.FindProperty("attackPreset").objectReferenceValue = attackPreset;
serializedCatalog.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(catalog);
}
private static void BindPrototypeCatalogToPrefabs(PassivePrototypeCatalogData catalog)
{
BindCatalogToPlayerPrefab(catalog);
BindCatalogToPlayerResourcesPrefab(catalog);
}
private static void BindCatalogToPlayerPrefab(PassivePrototypeCatalogData catalog)
{
GameObject root = PrefabUtility.LoadPrefabContents(PlayerPrefabPath);
try
{
PlayerNetworkController controller = root.GetComponent<PlayerNetworkController>();
if (controller == null)
return;
SerializedObject serializedController = new SerializedObject(controller);
serializedController.FindProperty("passivePrototypeCatalog").objectReferenceValue = catalog;
if (serializedController.FindProperty("passiveTree").objectReferenceValue == null)
{
serializedController.FindProperty("passiveTree").objectReferenceValue = catalog != null ? catalog.PrototypeTree : null;
}
serializedController.ApplyModifiedPropertiesWithoutUndo();
PrefabUtility.SaveAsPrefabAsset(root, PlayerPrefabPath);
}
finally
{
PrefabUtility.UnloadPrefabContents(root);
}
}
private static void BindCatalogToPlayerResourcesPrefab(PassivePrototypeCatalogData catalog)
{
GameObject root = PrefabUtility.LoadPrefabContents(PlayerResourcesPrefabPath);
try
{
PassiveTreeUI passiveTreeUi = root.GetComponent<PassiveTreeUI>();
if (passiveTreeUi == null)
return;
SerializedObject serializedPassiveTreeUi = new SerializedObject(passiveTreeUi);
serializedPassiveTreeUi.FindProperty("passivePrototypeCatalog").objectReferenceValue = catalog;
serializedPassiveTreeUi.ApplyModifiedPropertiesWithoutUndo();
PrefabUtility.SaveAsPrefabAsset(root, PlayerResourcesPrefabPath);
}
finally
{
PrefabUtility.UnloadPrefabContents(root);
}
}
private readonly struct PassiveEffectConfig
{
public PassiveEffectConfig(
PassiveEffectType effectType,
StatType statType,
StatModType modType,
float value,
SkillRoleType skillRoleMask)
{
EffectType = effectType;
StatType = statType;
ModType = modType;
Value = value;
SkillRoleMask = skillRoleMask;
}
public PassiveEffectType EffectType { get; }
public StatType StatType { get; }
public StatModType ModType { get; }
public float Value { get; }
public SkillRoleType SkillRoleMask { get; }
public static PassiveEffectConfig CreateScalar(
PassiveEffectType effectType,
float value,
SkillRoleType skillRoleMask = SkillRoleType.All)
{
return new PassiveEffectConfig(effectType, StatType.Vitality, StatModType.Flat, value, skillRoleMask);
}
public static PassiveEffectConfig CreateStat(
StatType statType,
StatModType modType,
float value)
{
return new PassiveEffectConfig(PassiveEffectType.StatModifier, statType, modType, value, SkillRoleType.All);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9934975f568477d4191a9bcbcfd01f0a

View File

@@ -6,6 +6,7 @@ using UnityEngine;
using Unity.Netcode;
using Colosseum.Abnormalities;
using Colosseum.Passives;
using Colosseum.Stats;
using Colosseum.Combat;
using Colosseum.Skills;
@@ -746,7 +747,11 @@ namespace Colosseum.Enemy
return 1f;
ThreatController threatController = sourceObject.GetComponent<ThreatController>();
return threatController != null ? Mathf.Max(0f, threatController.CurrentThreatMultiplier) : 1f;
float runtimeThreatMultiplier = threatController != null
? Mathf.Max(0f, threatController.CurrentThreatMultiplier)
: 1f;
float passiveThreatMultiplier = PassiveRuntimeModifierUtility.GetThreatGeneratedMultiplier(sourceObject);
return runtimeThreatMultiplier * passiveThreatMultiplier;
}
}
}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 11df31e847a1eee40a91c23141d91bb4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,51 @@
using System;
using UnityEngine;
using Colosseum.Skills;
using Colosseum.Stats;
namespace Colosseum.Passives
{
/// <summary>
/// 패시브 노드의 개별 효과 정의입니다.
/// </summary>
[Serializable]
public class PassiveEffectEntry
{
[Tooltip("패시브 효과 종류")]
[SerializeField] private PassiveEffectType effectType = PassiveEffectType.StatModifier;
[Tooltip("스탯 수정자일 때 대상 스탯")]
[SerializeField] private StatType statType = StatType.Vitality;
[Tooltip("스탯 수정자일 때 적용 방식")]
[SerializeField] private StatModType modType = StatModType.PercentAdd;
[Tooltip("효과 값")]
[SerializeField] private float value = 1f;
[Tooltip("스킬 역할에 따른 제한이 필요한 경우 사용")]
[SerializeField] private SkillRoleType skillRoleMask = SkillRoleType.All;
public PassiveEffectType EffectType => effectType;
public StatType StatType => statType;
public StatModType ModType => modType;
public float Value => value;
public SkillRoleType SkillRoleMask => skillRoleMask;
/// <summary>
/// 이 효과가 현재 스킬에 적용 가능한지 확인합니다.
/// </summary>
public bool AppliesToSkill(SkillData skill)
{
if (skillRoleMask == SkillRoleType.None || skillRoleMask == SkillRoleType.All)
return true;
if (skill == null)
return false;
return (skillRoleMask & skill.SkillRole) != 0;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bcf6d3de9c557e5418acd39a75266509

View File

@@ -0,0 +1,66 @@
using System;
namespace Colosseum.Passives
{
/// <summary>
/// 패시브 노드가 속한 갈래입니다.
/// </summary>
public enum PassiveNodeBranch
{
Common,
Attack,
Defense,
Support,
Bridge,
}
/// <summary>
/// 패시브 노드의 시각적/구조적 분류입니다.
/// </summary>
public enum PassiveNodeKind
{
Hub,
Axis,
Bridge,
Capstone,
}
/// <summary>
/// 패시브 노드가 기여하는 축 마스크입니다.
/// </summary>
[Flags]
public enum PassiveAxisMask
{
None = 0,
Attack = 1 << 0,
Defense = 1 << 1,
Support = 1 << 2,
All = Attack | Defense | Support,
}
/// <summary>
/// 패시브 노드가 제공하는 효과 종류입니다.
/// </summary>
public enum PassiveEffectType
{
StatModifier,
DamageMultiplier,
HealMultiplier,
ShieldDoneMultiplier,
ShieldReceivedMultiplier,
ThreatGeneratedMultiplier,
IncomingDamageMultiplier,
ManaCostMultiplier,
}
/// <summary>
/// 프로토타입 패시브 프리셋 분류입니다.
/// </summary>
public enum PassivePrototypePresetKind
{
None,
Defense,
Support,
Attack,
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a72f5239fa8913747ae5dd7fbd7f66eb

View File

@@ -0,0 +1,67 @@
using System.Collections.Generic;
using UnityEngine;
namespace Colosseum.Passives
{
/// <summary>
/// 패시브 트리의 단일 노드 데이터입니다.
/// </summary>
[CreateAssetMenu(fileName = "PassiveNode", menuName = "Colosseum/Passives/Passive Node")]
public class PassiveNodeData : ScriptableObject
{
[Header("기본 정보")]
[SerializeField] private string nodeId;
[SerializeField] private string displayName;
[TextArea(2, 4)]
[SerializeField] private string description;
[SerializeField] private PassiveNodeBranch branch = PassiveNodeBranch.Common;
[SerializeField] private PassiveNodeKind nodeKind = PassiveNodeKind.Axis;
[SerializeField] private PassiveAxisMask axisMask = PassiveAxisMask.None;
[Min(0)] [SerializeField] private int tier = 0;
[Min(0)] [SerializeField] private int cost = 1;
[Header("트리 레이아웃")]
[Tooltip("트리 그래프 안에서 사용할 정규화 좌표 (-1 ~ 1 권장)")]
[SerializeField] private Vector2 layoutPosition = Vector2.zero;
[Header("연결 정보")]
[SerializeField] private List<PassiveNodeData> prerequisiteNodes = new();
[SerializeField] private List<PassiveNodeData> connectedNodes = new();
[Header("효과")]
[SerializeField] private List<PassiveEffectEntry> effects = new();
public string NodeId => nodeId;
public string DisplayName => displayName;
public string Description => description;
public PassiveNodeBranch Branch => branch;
public PassiveNodeKind NodeKind => nodeKind;
public PassiveAxisMask AxisMask => axisMask;
public int Tier => tier;
public int Cost => cost;
public Vector2 LayoutPosition => layoutPosition;
public IReadOnlyList<PassiveNodeData> PrerequisiteNodes => prerequisiteNodes;
public IReadOnlyList<PassiveNodeData> ConnectedNodes => connectedNodes;
public IReadOnlyList<PassiveEffectEntry> Effects => effects;
private void OnValidate()
{
if (string.IsNullOrWhiteSpace(nodeId))
{
nodeId = name;
}
if (axisMask == PassiveAxisMask.None)
{
axisMask = branch switch
{
PassiveNodeBranch.Attack => PassiveAxisMask.Attack,
PassiveNodeBranch.Defense => PassiveAxisMask.Defense,
PassiveNodeBranch.Support => PassiveAxisMask.Support,
_ => PassiveAxisMask.None,
};
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c72b2635385ed49498483636164bac87

View File

@@ -0,0 +1,225 @@
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using Colosseum.Skills;
using Colosseum.Stats;
namespace Colosseum.Passives
{
/// <summary>
/// 패시브 노드와 효과를 UI 친화적인 문자열로 변환합니다.
/// </summary>
public static class PassivePresentationUtility
{
public static string GetBranchLabel(PassiveNodeBranch branch)
{
return branch switch
{
PassiveNodeBranch.Common => "공통",
PassiveNodeBranch.Attack => "공격",
PassiveNodeBranch.Defense => "방어",
PassiveNodeBranch.Support => "지원",
PassiveNodeBranch.Bridge => "연결",
_ => "미분류",
};
}
public static string GetNodeKindLabel(PassiveNodeKind nodeKind)
{
return nodeKind switch
{
PassiveNodeKind.Hub => "허브",
PassiveNodeKind.Axis => "축 노드",
PassiveNodeKind.Bridge => "브릿지",
PassiveNodeKind.Capstone => "완성 노드",
_ => "노드",
};
}
public static string GetAxisSummary(PassiveAxisMask axisMask)
{
if (axisMask == PassiveAxisMask.None)
return "중립";
if (axisMask == PassiveAxisMask.All)
return "공격 / 방어 / 지원";
List<string> labels = new();
if ((axisMask & PassiveAxisMask.Attack) != 0)
labels.Add("공격");
if ((axisMask & PassiveAxisMask.Defense) != 0)
labels.Add("방어");
if ((axisMask & PassiveAxisMask.Support) != 0)
labels.Add("지원");
return labels.Count > 0 ? string.Join(" / ", labels) : "중립";
}
public static string GetStatLabel(StatType statType)
{
return statType switch
{
StatType.Strength => "근력 (STR)",
StatType.Dexterity => "민첩 (DEX)",
StatType.Intelligence => "지능 (INT)",
StatType.Vitality => "활력 (VIT)",
StatType.Wisdom => "지혜 (WIS)",
StatType.Spirit => "정신 (SPI)",
_ => "알 수 없는 스탯",
};
}
public static string BuildNodeSummary(PassiveNodeData node)
{
if (node == null)
return string.Empty;
StringBuilder builder = new StringBuilder();
builder.Append(node.DisplayName);
builder.Append('\n');
builder.Append(GetAxisSummary(node.AxisMask));
builder.Append(" | 비용 ");
builder.Append(node.Cost);
string effectSummary = BuildEffectSummary(node);
if (!string.IsNullOrWhiteSpace(effectSummary))
{
builder.Append('\n');
builder.Append(effectSummary);
}
return builder.ToString();
}
public static string BuildNodeDetail(PassiveNodeData node)
{
if (node == null)
return "노드를 선택하면 설명을 표시합니다.";
StringBuilder builder = new StringBuilder();
builder.AppendLine($"{node.DisplayName} [{GetBranchLabel(node.Branch)}]");
builder.AppendLine($"{GetNodeKindLabel(node.NodeKind)} | 축: {GetAxisSummary(node.AxisMask)}");
builder.AppendLine($"티어 {node.Tier} | 비용 {node.Cost}");
if (!string.IsNullOrWhiteSpace(node.Description))
{
builder.AppendLine();
builder.AppendLine(node.Description.Trim());
}
if (node.Effects != null && node.Effects.Count > 0)
{
builder.AppendLine();
builder.AppendLine("효과");
for (int i = 0; i < node.Effects.Count; i++)
{
PassiveEffectEntry effect = node.Effects[i];
if (effect == null)
continue;
builder.Append("• ");
builder.AppendLine(GetEffectLabel(effect));
}
}
if (node.PrerequisiteNodes != null && node.PrerequisiteNodes.Count > 0)
{
builder.AppendLine();
builder.AppendLine("선행 노드");
for (int i = 0; i < node.PrerequisiteNodes.Count; i++)
{
PassiveNodeData prerequisiteNode = node.PrerequisiteNodes[i];
if (prerequisiteNode == null)
continue;
builder.Append("• ");
builder.AppendLine(prerequisiteNode.DisplayName);
}
}
return builder.ToString().TrimEnd();
}
public static string BuildEffectSummary(PassiveNodeData node)
{
if (node?.Effects == null || node.Effects.Count <= 0)
return "효과 없음";
StringBuilder builder = new StringBuilder();
int writtenCount = 0;
for (int i = 0; i < node.Effects.Count; i++)
{
PassiveEffectEntry effect = node.Effects[i];
if (effect == null)
continue;
if (writtenCount > 0)
builder.Append(" / ");
builder.Append(GetEffectLabel(effect));
writtenCount++;
}
return writtenCount > 0 ? builder.ToString() : "효과 없음";
}
public static string GetEffectLabel(PassiveEffectEntry effect)
{
if (effect == null)
return string.Empty;
string label = effect.EffectType switch
{
PassiveEffectType.StatModifier => BuildStatModifierLabel(effect),
PassiveEffectType.DamageMultiplier => $"공격 피해 {FormatMultiplierDelta(effect.Value)}",
PassiveEffectType.HealMultiplier => $"회복량 {FormatMultiplierDelta(effect.Value)}",
PassiveEffectType.ShieldDoneMultiplier => $"보호막 부여량 {FormatMultiplierDelta(effect.Value)}",
PassiveEffectType.ShieldReceivedMultiplier => $"보호막 수혜량 {FormatMultiplierDelta(effect.Value)}",
PassiveEffectType.ThreatGeneratedMultiplier => $"위협 생성 {FormatMultiplierDelta(effect.Value)}",
PassiveEffectType.IncomingDamageMultiplier => $"받는 피해 {FormatMultiplierDelta(effect.Value)}",
PassiveEffectType.ManaCostMultiplier => $"마나 비용 {FormatMultiplierDelta(effect.Value)}",
_ => $"알 수 없는 효과 ({effect.EffectType})",
};
if (effect.SkillRoleMask != SkillRoleType.None && effect.SkillRoleMask != SkillRoleType.All)
{
label += $" ({SkillClassificationUtility.GetAllowedRoleSummary(effect.SkillRoleMask)} 스킬 한정)";
}
return label;
}
private static string BuildStatModifierLabel(PassiveEffectEntry effect)
{
string statLabel = GetStatLabel(effect.StatType);
return effect.ModType switch
{
StatModType.Flat => $"{statLabel} {FormatFlatValue(effect.Value)}",
StatModType.PercentAdd => $"{statLabel} {FormatSignedPercent(effect.Value * 100f)}",
StatModType.PercentMult => $"{statLabel} {FormatSignedPercent((effect.Value - 1f) * 100f)}",
_ => $"{statLabel} {effect.Value:0.##}",
};
}
private static string FormatMultiplierDelta(float multiplier)
{
return FormatSignedPercent((multiplier - 1f) * 100f);
}
private static string FormatSignedPercent(float percentValue)
{
string sign = percentValue >= 0f ? "+" : string.Empty;
return $"{sign}{percentValue:0.##}%";
}
private static string FormatFlatValue(float value)
{
string sign = value >= 0f ? "+" : string.Empty;
return $"{sign}{value:0.##}";
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 91dfed6432de76b43b187ce333faaa54

View File

@@ -0,0 +1,44 @@
using System.Collections.Generic;
using UnityEngine;
namespace Colosseum.Passives
{
/// <summary>
/// 패시브 선택 상태를 빠르게 적용하기 위한 프리셋입니다.
/// </summary>
[CreateAssetMenu(fileName = "PassivePreset", menuName = "Colosseum/Passives/Passive Preset")]
public class PassivePresetData : ScriptableObject
{
[Header("기본 정보")]
[SerializeField] private string presetName;
[TextArea(2, 4)]
[SerializeField] private string description;
[Header("트리")]
[SerializeField] private PassiveTreeData tree;
[Header("선택 노드")]
[SerializeField] private List<PassiveNodeData> selectedNodes = new List<PassiveNodeData>();
public string PresetName => presetName;
public string Description => description;
public PassiveTreeData Tree => tree;
public IReadOnlyList<PassiveNodeData> SelectedNodes => selectedNodes;
public List<string> BuildSelectedNodeIdList()
{
List<string> nodeIds = new List<string>(selectedNodes.Count);
for (int i = 0; i < selectedNodes.Count; i++)
{
PassiveNodeData node = selectedNodes[i];
if (node == null || string.IsNullOrWhiteSpace(node.NodeId))
continue;
nodeIds.Add(node.NodeId);
}
return nodeIds;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2f8d8a9657f495440af71ac932114128

View File

@@ -0,0 +1,28 @@
namespace Colosseum.Passives
{
/// <summary>
/// 프로토타입 패시브 카탈로그 접근을 돕는 정적 유틸리티입니다.
/// </summary>
public static class PassivePrototypeCatalog
{
public static PassiveTreeData LoadPrototypeTree(PassivePrototypeCatalogData catalog)
{
return catalog != null ? catalog.PrototypeTree : null;
}
public static PassivePresetData LoadPreset(PassivePrototypeCatalogData catalog, PassivePrototypePresetKind kind)
{
return catalog != null ? catalog.GetPreset(kind) : null;
}
public static PassivePrototypePresetKind ResolveOwnerPresetKind(ulong ownerClientId)
{
return ownerClientId switch
{
0 => PassivePrototypePresetKind.Defense,
1 => PassivePrototypePresetKind.Support,
_ => PassivePrototypePresetKind.Attack,
};
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 44a4706642cb88547be17c970370eb5a

View File

@@ -0,0 +1,37 @@
using UnityEngine;
namespace Colosseum.Passives
{
/// <summary>
/// 플레이어 패시브 프로토타입 트리와 기본 프리셋 참조를 묶는 카탈로그입니다.
/// </summary>
[CreateAssetMenu(fileName = "PassivePrototypeCatalog", menuName = "Colosseum/Passives/Passive Prototype Catalog")]
public class PassivePrototypeCatalogData : ScriptableObject
{
[Header("트리")]
[SerializeField] private PassiveTreeData prototypeTree;
[Header("프리셋")]
[SerializeField] private PassivePresetData nonePreset;
[SerializeField] private PassivePresetData defensePreset;
[SerializeField] private PassivePresetData supportPreset;
[SerializeField] private PassivePresetData attackPreset;
public PassiveTreeData PrototypeTree => prototypeTree;
public PassivePresetData NonePreset => nonePreset;
public PassivePresetData DefensePreset => defensePreset;
public PassivePresetData SupportPreset => supportPreset;
public PassivePresetData AttackPreset => attackPreset;
public PassivePresetData GetPreset(PassivePrototypePresetKind kind)
{
return kind switch
{
PassivePrototypePresetKind.Defense => defensePreset,
PassivePrototypePresetKind.Support => supportPreset,
PassivePrototypePresetKind.Attack => attackPreset,
_ => nonePreset,
};
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cf6077238ea8f1649a7cecd929b02c91

View File

@@ -0,0 +1,328 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using Colosseum.Skills;
using Colosseum.Stats;
namespace Colosseum.Passives
{
/// <summary>
/// 선택된 패시브 노드를 실제 전투 수치에 적용합니다.
/// </summary>
[DisallowMultipleComponent]
public class PassiveRuntimeController : MonoBehaviour
{
[Header("References")]
[Tooltip("CharacterStats 컴포넌트 (없으면 자동 검색)")]
[SerializeField] private CharacterStats characterStats;
[Header("Debug")]
[Tooltip("현재 적용된 패시브 프리셋 이름")]
[SerializeField] private string currentPresetName = string.Empty;
[Tooltip("현재 적용된 노드 ID 목록")]
[SerializeField] private string currentSelectionSummary = string.Empty;
[Tooltip("현재 사용한 패시브 포인트")]
[Min(0)] [SerializeField] private int usedPoints = 0;
private readonly List<PassiveEffectEntry> activeEffects = new List<PassiveEffectEntry>();
private readonly List<string> selectedNodeIds = new List<string>();
private PassiveTreeData currentTree;
public string CurrentPresetName => currentPresetName;
public int UsedPoints => usedPoints;
public int RemainingPoints => currentTree != null ? Mathf.Max(0, currentTree.InitialPoints - usedPoints) : 0;
public IReadOnlyList<string> SelectedNodeIds => selectedNodeIds;
private void Awake()
{
if (characterStats == null)
{
characterStats = GetComponent<CharacterStats>();
}
}
/// <summary>
/// 외부에서 참조를 보정합니다.
/// </summary>
public void Initialize(CharacterStats stats)
{
if (stats != null)
{
characterStats = stats;
}
else if (characterStats == null)
{
characterStats = GetComponent<CharacterStats>();
}
}
/// <summary>
/// 선택된 노드 구성을 적용합니다.
/// </summary>
public bool TryApplySelection(PassiveTreeData tree, IReadOnlyList<string> nodeIds, string presetName, out string reason)
{
Initialize(characterStats);
ClearAppliedState();
currentTree = tree;
currentPresetName = presetName ?? string.Empty;
if (currentTree == null)
{
reason = "패시브 트리 데이터가 없습니다.";
return false;
}
if (!currentTree.TryResolveSelection(nodeIds, out List<PassiveNodeData> resolvedNodes, out reason))
{
return false;
}
usedPoints = currentTree.CalculateUsedPoints(resolvedNodes);
for (int i = 0; i < resolvedNodes.Count; i++)
{
PassiveNodeData node = resolvedNodes[i];
if (node == null)
continue;
selectedNodeIds.Add(node.NodeId);
IReadOnlyList<PassiveEffectEntry> effects = node.Effects;
if (effects == null)
continue;
for (int j = 0; j < effects.Count; j++)
{
PassiveEffectEntry effect = effects[j];
if (effect == null)
continue;
ApplyEffect(effect);
}
}
currentSelectionSummary = BuildSelectionSummary();
reason = string.Empty;
return true;
}
/// <summary>
/// 현재 선택 상태를 해제합니다.
/// </summary>
public void ClearSelection()
{
ClearAppliedState();
currentTree = null;
currentPresetName = string.Empty;
currentSelectionSummary = string.Empty;
}
public float GetDamageMultiplier(SkillData skill = null)
{
return GetScalarMultiplier(PassiveEffectType.DamageMultiplier, skill);
}
public float GetHealMultiplier(SkillData skill = null)
{
return GetScalarMultiplier(PassiveEffectType.HealMultiplier, skill);
}
public float GetShieldDoneMultiplier(SkillData skill = null)
{
return GetScalarMultiplier(PassiveEffectType.ShieldDoneMultiplier, skill);
}
public float GetShieldReceivedMultiplier()
{
return GetScalarMultiplier(PassiveEffectType.ShieldReceivedMultiplier, null);
}
public float GetThreatGeneratedMultiplier()
{
return GetScalarMultiplier(PassiveEffectType.ThreatGeneratedMultiplier, null);
}
public float GetIncomingDamageMultiplier()
{
return GetScalarMultiplier(PassiveEffectType.IncomingDamageMultiplier, null);
}
public float GetManaCostMultiplier(SkillData skill = null)
{
return GetScalarMultiplier(PassiveEffectType.ManaCostMultiplier, skill);
}
public string BuildSummary()
{
StringBuilder builder = new StringBuilder();
builder.Append("[Passive] ");
builder.Append(string.IsNullOrWhiteSpace(currentPresetName) ? "미적용" : currentPresetName);
builder.Append(" | Used=");
builder.Append(usedPoints);
if (currentTree != null)
{
builder.Append('/');
builder.Append(currentTree.InitialPoints);
}
if (selectedNodeIds.Count > 0)
{
builder.Append(" | Nodes=");
builder.Append(currentSelectionSummary);
}
return builder.ToString();
}
private void ApplyEffect(PassiveEffectEntry effect)
{
if (effect.EffectType == PassiveEffectType.StatModifier)
{
ApplyStatModifier(effect);
return;
}
activeEffects.Add(effect);
}
private void ApplyStatModifier(PassiveEffectEntry effect)
{
if (characterStats == null || effect == null)
return;
CharacterStat stat = characterStats.GetStat(effect.StatType);
if (stat == null)
return;
stat.AddModifier(new StatModifier(effect.Value, effect.ModType, this));
}
private float GetScalarMultiplier(PassiveEffectType effectType, SkillData skill)
{
float result = 1f;
for (int i = 0; i < activeEffects.Count; i++)
{
PassiveEffectEntry effect = activeEffects[i];
if (effect == null || effect.EffectType != effectType)
continue;
if (!effect.AppliesToSkill(skill))
continue;
result *= Mathf.Max(0f, effect.Value);
}
return result;
}
private void ClearAppliedState()
{
RemoveAllPassiveStatModifiers();
activeEffects.Clear();
selectedNodeIds.Clear();
usedPoints = 0;
}
private void RemoveAllPassiveStatModifiers()
{
if (characterStats == null)
return;
foreach (StatType statType in Enum.GetValues(typeof(StatType)))
{
CharacterStat stat = characterStats.GetStat(statType);
stat?.RemoveAllModifiersFromSource(this);
}
}
private string BuildSelectionSummary()
{
if (selectedNodeIds.Count <= 0)
return string.Empty;
return string.Join(", ", selectedNodeIds);
}
}
/// <summary>
/// 패시브 전투 배율을 안전하게 조회하는 유틸리티입니다.
/// </summary>
public static class PassiveRuntimeModifierUtility
{
public static float GetDamageMultiplier(GameObject actor)
{
PassiveRuntimeController controller = GetController(actor);
return controller != null ? controller.GetDamageMultiplier(GetCurrentSkill(actor)) : 1f;
}
public static float GetHealMultiplier(GameObject actor)
{
PassiveRuntimeController controller = GetController(actor);
return controller != null ? controller.GetHealMultiplier(GetCurrentSkill(actor)) : 1f;
}
public static float GetShieldDoneMultiplier(GameObject actor)
{
PassiveRuntimeController controller = GetController(actor);
return controller != null ? controller.GetShieldDoneMultiplier(GetCurrentSkill(actor)) : 1f;
}
public static float GetShieldReceivedMultiplier(GameObject actor)
{
PassiveRuntimeController controller = GetController(actor);
return controller != null ? controller.GetShieldReceivedMultiplier() : 1f;
}
public static float GetThreatGeneratedMultiplier(GameObject actor)
{
PassiveRuntimeController controller = GetController(actor);
return controller != null ? controller.GetThreatGeneratedMultiplier() : 1f;
}
public static float GetIncomingDamageMultiplier(GameObject actor)
{
PassiveRuntimeController controller = GetController(actor);
return controller != null ? controller.GetIncomingDamageMultiplier() : 1f;
}
public static float GetManaCostMultiplier(GameObject actor, SkillData skill)
{
PassiveRuntimeController controller = GetController(actor);
return controller != null ? controller.GetManaCostMultiplier(skill) : 1f;
}
public static string GetCurrentPresetName(GameObject actor)
{
PassiveRuntimeController controller = GetController(actor);
return controller != null ? controller.CurrentPresetName : string.Empty;
}
public static string BuildSummary(GameObject actor)
{
PassiveRuntimeController controller = GetController(actor);
return controller != null ? controller.BuildSummary() : "[Passive] 미적용";
}
private static PassiveRuntimeController GetController(GameObject actor)
{
if (actor == null)
return null;
return actor.GetComponent<PassiveRuntimeController>() ?? actor.GetComponentInParent<PassiveRuntimeController>();
}
private static SkillData GetCurrentSkill(GameObject actor)
{
if (actor == null)
return null;
SkillController skillController = actor.GetComponent<SkillController>() ?? actor.GetComponentInParent<SkillController>();
return skillController != null ? skillController.CurrentSkill : null;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1e050120f06c9e447900d65e3629482e

View File

@@ -0,0 +1,129 @@
using System.Collections.Generic;
using UnityEngine;
namespace Colosseum.Passives
{
/// <summary>
/// 패시브 트리 전체 데이터를 정의합니다.
/// </summary>
[CreateAssetMenu(fileName = "PassiveTree", menuName = "Colosseum/Passives/Passive Tree")]
public class PassiveTreeData : ScriptableObject
{
[Header("기본 정보")]
[SerializeField] private string treeId;
[SerializeField] private string treeName;
[TextArea(2, 4)]
[SerializeField] private string description;
[Min(0)] [SerializeField] private int initialPoints = 0;
[Header("노드")]
[SerializeField] private List<PassiveNodeData> nodes = new List<PassiveNodeData>();
public string TreeId => treeId;
public string TreeName => treeName;
public string Description => description;
public int InitialPoints => initialPoints;
public IReadOnlyList<PassiveNodeData> Nodes => nodes;
public PassiveNodeData GetNodeById(string nodeId)
{
if (string.IsNullOrWhiteSpace(nodeId))
return null;
for (int i = 0; i < nodes.Count; i++)
{
PassiveNodeData node = nodes[i];
if (node != null && string.Equals(node.NodeId, nodeId, System.StringComparison.Ordinal))
return node;
}
return null;
}
/// <summary>
/// 선택된 노드 구성이 유효한지 검사합니다.
/// </summary>
public bool TryResolveSelection(IReadOnlyList<string> selectedNodeIds, out List<PassiveNodeData> resolvedNodes, out string reason)
{
resolvedNodes = new List<PassiveNodeData>();
reason = string.Empty;
HashSet<string> uniqueIds = new HashSet<string>();
int totalCost = 0;
if (selectedNodeIds == null)
return true;
for (int i = 0; i < selectedNodeIds.Count; i++)
{
string nodeId = selectedNodeIds[i];
if (string.IsNullOrWhiteSpace(nodeId))
continue;
if (!uniqueIds.Add(nodeId))
{
reason = $"중복 노드가 포함되어 있습니다: {nodeId}";
return false;
}
PassiveNodeData node = GetNodeById(nodeId);
if (node == null)
{
reason = $"트리에 없는 노드입니다: {nodeId}";
return false;
}
resolvedNodes.Add(node);
totalCost += node.Cost;
}
for (int i = 0; i < resolvedNodes.Count; i++)
{
PassiveNodeData node = resolvedNodes[i];
IReadOnlyList<PassiveNodeData> prerequisiteNodes = node.PrerequisiteNodes;
if (prerequisiteNodes == null)
continue;
for (int j = 0; j < prerequisiteNodes.Count; j++)
{
PassiveNodeData prerequisiteNode = prerequisiteNodes[j];
if (prerequisiteNode == null)
continue;
if (!uniqueIds.Contains(prerequisiteNode.NodeId))
{
reason = $"{node.DisplayName} 선택에는 선행 노드 {prerequisiteNode.DisplayName} 이(가) 필요합니다.";
return false;
}
}
}
if (totalCost > initialPoints)
{
reason = $"선택한 노드 비용이 보유 포인트를 초과합니다. Used={totalCost}, Max={initialPoints}";
return false;
}
return true;
}
public int CalculateUsedPoints(IReadOnlyList<PassiveNodeData> selectedNodes)
{
if (selectedNodes == null)
return 0;
int totalCost = 0;
for (int i = 0; i < selectedNodes.Count; i++)
{
PassiveNodeData node = selectedNodes[i];
if (node == null)
continue;
totalCost += Mathf.Max(0, node.Cost);
}
return totalCost;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 16b8c249387e8bf4f94e93d1776171a4

View File

@@ -1,16 +1,21 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using Unity.Collections;
using Unity.Netcode;
using Colosseum.Abnormalities;
using Colosseum.Stats;
using Colosseum.Combat;
using Colosseum.Passives;
using Colosseum.Skills;
using Colosseum.Stats;
namespace Colosseum.Player
{
/// <summary>
/// 플레이어 네트워크 상태 관리 (HP, MP 등)
/// 플레이어 네트워크 상태 관리 (HP, MP, 패시브 상태 등)
/// </summary>
public class PlayerNetworkController : NetworkBehaviour, IDamageable
{
@@ -25,12 +30,39 @@ namespace Colosseum.Player
[Tooltip("보호막 타입이 지정되지 않았을 때 사용할 기본 보호막 이상상태 데이터")]
[SerializeField] private AbnormalityData shieldStateAbnormality;
// 네트워크 동기화 변수
private NetworkVariable<float> currentHealth = new NetworkVariable<float>(100f);
private NetworkVariable<float> currentMana = new NetworkVariable<float>(50f);
private NetworkVariable<bool> isDead = new NetworkVariable<bool>(false);
private NetworkVariable<float> currentShield = new NetworkVariable<float>(0f);
[Header("Passive Prototype")]
[Tooltip("프로토타입 패시브 카탈로그")]
[SerializeField] private PassivePrototypeCatalogData passivePrototypeCatalog;
[Tooltip("기본 패시브 트리 (비어 있으면 카탈로그의 트리를 사용)")]
[SerializeField] private PassiveTreeData passiveTree;
[Tooltip("네트워크 스폰 시 기본 패시브 프리셋 자동 적용 여부")]
[SerializeField] private bool applyDefaultPassivePresetOnNetworkSpawn = false;
[Tooltip("자동 적용할 기본 패시브 프리셋 (없으면 패시브 미적용으로 시작)")]
[SerializeField] private PassivePresetData defaultPassivePreset;
[Header("Passive Debug")]
[Tooltip("현재 적용 중인 패시브 프리셋 이름")]
[SerializeField] private string currentPassivePresetName = string.Empty;
[Tooltip("현재 사용한 패시브 포인트")]
[Min(0)] [SerializeField] private int usedPassivePoints = 0;
[Tooltip("현재 남은 패시브 포인트")]
[Min(0)] [SerializeField] private int remainingPassivePoints = 0;
private readonly NetworkVariable<float> currentHealth = new NetworkVariable<float>(100f);
private readonly NetworkVariable<float> currentMana = new NetworkVariable<float>(50f);
private readonly NetworkVariable<bool> isDead = new NetworkVariable<bool>(false);
private readonly NetworkVariable<float> currentShield = new NetworkVariable<float>(0f);
private readonly NetworkVariable<FixedString512Bytes> selectedPassiveNodeIds = new NetworkVariable<FixedString512Bytes>();
private readonly NetworkVariable<FixedString64Bytes> selectedPassivePresetName = new NetworkVariable<FixedString64Bytes>();
private readonly ShieldCollection shieldCollection = new ShieldCollection();
private readonly List<string> passiveNodeIdBuffer = new List<string>();
private PassiveRuntimeController passiveRuntimeController;
public float Health => currentHealth.Value;
public float Mana => currentMana.Value;
@@ -38,24 +70,26 @@ namespace Colosseum.Player
public float MaxHealth => characterStats != null ? characterStats.MaxHealth : 100f;
public float MaxMana => characterStats != null ? characterStats.MaxMana : 50f;
public CharacterStats Stats => characterStats;
public PassivePrototypeCatalogData PassivePrototypeCatalogData => passivePrototypeCatalog;
public PassiveTreeData PassiveTree => passiveTree;
public string CurrentPassivePresetName => currentPassivePresetName;
public int UsedPassivePoints => usedPassivePoints;
public int RemainingPassivePoints => remainingPassivePoints;
public IReadOnlyList<string> SelectedPassiveNodeIds => passiveRuntimeController != null ? passiveRuntimeController.SelectedNodeIds : Array.Empty<string>();
// 체력/마나 변경 이벤트
public event Action<float, float> OnHealthChanged; // (oldValue, newValue)
public event Action<float, float> OnManaChanged; // (oldValue, newValue)
public event Action<float, float> OnShieldChanged; // (oldValue, newValue)
// 사망 이벤트
public event Action<float, float> OnHealthChanged;
public event Action<float, float> OnManaChanged;
public event Action<float, float> OnShieldChanged;
public event Action<PlayerNetworkController> OnDeath;
public event Action<bool> OnDeathStateChanged; // (isDead)
public event Action<bool> OnDeathStateChanged;
public event Action<PlayerNetworkController> OnRespawned;
public event Action OnPassiveSelectionChanged;
// IDamageable 구현
public float CurrentHealth => currentHealth.Value;
public bool IsDead => isDead.Value;
public override void OnNetworkSpawn()
{
// CharacterStats 참조 확인
if (characterStats == null)
{
characterStats = GetComponent<CharacterStats>();
@@ -66,18 +100,35 @@ namespace Colosseum.Player
abnormalityManager = GetComponent<AbnormalityManager>();
}
// 네트워크 변수 변경 콜백 등록
EnsurePassiveRuntimeReferences();
currentHealth.OnValueChanged += HandleHealthChanged;
currentMana.OnValueChanged += HandleManaChanged;
currentShield.OnValueChanged += HandleShieldChanged;
isDead.OnValueChanged += HandleDeathStateChanged;
selectedPassiveNodeIds.OnValueChanged += HandleSelectedPassiveNodeIdsChanged;
selectedPassivePresetName.OnValueChanged += HandleSelectedPassivePresetNameChanged;
ApplyPassiveSelectionFromNetworkState(false);
// 초기화
if (IsServer)
{
currentHealth.Value = MaxHealth;
currentMana.Value = MaxMana;
isDead.Value = false;
if (applyDefaultPassivePresetOnNetworkSpawn && IsPassiveSelectionEmpty())
{
PassivePresetData initialPreset = defaultPassivePreset != null
? defaultPassivePreset
: ResolvePrototypePreset(PassivePrototypePresetKind.None);
DebugApplyPassivePreset(initialPreset, true);
}
else
{
RefreshVitalsAfterPassiveChange(true);
}
RefreshShieldState();
}
}
@@ -95,11 +146,12 @@ namespace Colosseum.Player
public override void OnNetworkDespawn()
{
// 콜백 해제
currentHealth.OnValueChanged -= HandleHealthChanged;
currentMana.OnValueChanged -= HandleManaChanged;
currentShield.OnValueChanged -= HandleShieldChanged;
isDead.OnValueChanged -= HandleDeathStateChanged;
selectedPassiveNodeIds.OnValueChanged -= HandleSelectedPassiveNodeIdsChanged;
selectedPassivePresetName.OnValueChanged -= HandleSelectedPassivePresetNameChanged;
}
private void HandleHealthChanged(float oldValue, float newValue)
@@ -122,6 +174,22 @@ namespace Colosseum.Player
OnDeathStateChanged?.Invoke(newValue);
}
private void HandleSelectedPassiveNodeIdsChanged(FixedString512Bytes oldValue, FixedString512Bytes newValue)
{
if (oldValue.Equals(newValue))
return;
ApplyPassiveSelectionFromNetworkState(false);
}
private void HandleSelectedPassivePresetNameChanged(FixedString64Bytes oldValue, FixedString64Bytes newValue)
{
if (oldValue.Equals(newValue))
return;
ApplyPassiveSelectionFromNetworkState(false);
}
/// <summary>
/// 대미지 적용 (서버에서만 실행)
/// </summary>
@@ -137,7 +205,8 @@ namespace Colosseum.Player
[Rpc(SendTo.Server)]
public void UseManaRpc(float amount)
{
if (isDead.Value) return;
if (isDead.Value)
return;
currentMana.Value = Mathf.Max(0f, currentMana.Value - amount);
}
@@ -148,7 +217,8 @@ namespace Colosseum.Player
[Rpc(SendTo.Server)]
public void RestoreHealthRpc(float amount)
{
if (isDead.Value) return;
if (isDead.Value)
return;
currentHealth.Value = Mathf.Min(MaxHealth, currentHealth.Value + amount);
}
@@ -159,7 +229,8 @@ namespace Colosseum.Player
[Rpc(SendTo.Server)]
public void RestoreManaRpc(float amount)
{
if (isDead.Value) return;
if (isDead.Value)
return;
currentMana.Value = Mathf.Min(MaxMana, currentMana.Value + amount);
}
@@ -170,7 +241,7 @@ namespace Colosseum.Player
[Rpc(SendTo.Everyone)]
private void PlayDeathAnimationRpc()
{
var animator = GetComponentInChildren<Animator>();
Animator animator = GetComponentInChildren<Animator>();
if (animator != null)
{
animator.SetTrigger("Die");
@@ -182,56 +253,50 @@ namespace Colosseum.Player
/// </summary>
private void HandleDeath()
{
if (isDead.Value) return;
if (isDead.Value)
return;
isDead.Value = true;
shieldCollection.Clear();
RefreshShieldState();
// 사망 시 활성 이상 상태를 정리해 리스폰 시 잔존하지 않게 합니다.
if (abnormalityManager != null)
{
abnormalityManager.RemoveAllAbnormalities();
}
// 이동 비활성화
var movement = GetComponent<PlayerMovement>();
PlayerMovement movement = GetComponent<PlayerMovement>();
if (movement != null)
{
movement.ClearForcedMovement();
movement.enabled = false;
}
var hitReactionController = GetComponent<HitReactionController>();
HitReactionController hitReactionController = GetComponent<HitReactionController>();
if (hitReactionController != null)
{
hitReactionController.ClearHitReactionState();
}
var threatController = GetComponent<ThreatController>();
ThreatController threatController = GetComponent<ThreatController>();
if (threatController != null)
{
threatController.ClearThreatModifiers();
}
// 스킬 입력 비활성화
var skillInput = GetComponent<PlayerSkillInput>();
PlayerSkillInput skillInput = GetComponent<PlayerSkillInput>();
if (skillInput != null)
{
skillInput.enabled = false;
}
// 실행 중인 스킬 즉시 취소
var skillController = GetComponent<SkillController>();
SkillController skillController = GetComponent<SkillController>();
if (skillController != null)
{
skillController.CancelSkill(SkillCancelReason.Death);
}
// 모든 클라이언트에서 사망 애니메이션 재생
PlayDeathAnimationRpc();
// 사망 이벤트 발생
OnDeath?.Invoke(this);
Debug.Log($"[Player] Player {OwnerClientId} died!");
@@ -242,7 +307,8 @@ namespace Colosseum.Player
/// </summary>
public void Respawn()
{
if (!IsServer) return;
if (!IsServer)
return;
if (abnormalityManager != null)
{
@@ -250,46 +316,42 @@ namespace Colosseum.Player
}
isDead.Value = false;
currentHealth.Value = MaxHealth;
currentMana.Value = MaxMana;
RefreshVitalsAfterPassiveChange(true);
shieldCollection.Clear();
RefreshShieldState();
// 이동 재활성화
var movement = GetComponent<PlayerMovement>();
PlayerMovement movement = GetComponent<PlayerMovement>();
if (movement != null)
{
movement.ClearForcedMovement();
movement.enabled = true;
}
var hitReactionController = GetComponent<HitReactionController>();
HitReactionController hitReactionController = GetComponent<HitReactionController>();
if (hitReactionController != null)
{
hitReactionController.ClearHitReactionState();
}
var threatController = GetComponent<ThreatController>();
ThreatController threatController = GetComponent<ThreatController>();
if (threatController != null)
{
threatController.ClearThreatModifiers();
}
// 스킬 입력 재활성화
var skillInput = GetComponent<PlayerSkillInput>();
PlayerSkillInput skillInput = GetComponent<PlayerSkillInput>();
if (skillInput != null)
{
skillInput.enabled = true;
}
// 애니메이션 리셋
var animator = GetComponentInChildren<Animator>();
Animator animator = GetComponentInChildren<Animator>();
if (animator != null)
{
animator.Rebind();
}
var skillController = GetComponent<SkillController>();
SkillController skillController = GetComponent<SkillController>();
if (skillController != null)
{
skillController.CancelSkill(SkillCancelReason.Respawn);
@@ -300,6 +362,271 @@ namespace Colosseum.Player
Debug.Log($"[Player] Player {OwnerClientId} respawned!");
}
/// <summary>
/// 서버 기준으로 패시브 프리셋을 적용합니다.
/// </summary>
public bool DebugApplyPassivePreset(PassivePresetData preset, bool fillResourcesToMax = true)
{
if (!IsServer)
return false;
EnsurePassiveRuntimeReferences();
if (preset == null)
{
return DebugClearPassiveSelection(fillResourcesToMax);
}
PassiveTreeData targetTree = preset.Tree != null ? preset.Tree : passiveTree;
if (targetTree == null)
{
targetTree = ResolvePrototypeTree();
}
if (targetTree == null)
{
Debug.LogWarning("[Passive] 패시브 트리를 찾지 못해 프리셋을 적용할 수 없습니다.");
return false;
}
List<string> nodeIds = preset.BuildSelectedNodeIdList();
if (!targetTree.TryResolveSelection(nodeIds, out _, out string reason))
{
Debug.LogWarning($"[Passive] 프리셋 적용 실패 | Preset={preset.PresetName} | Reason={reason}");
return false;
}
passiveTree = targetTree;
selectedPassivePresetName.Value = new FixedString64Bytes(string.IsNullOrWhiteSpace(preset.PresetName) ? "패시브 프리셋" : preset.PresetName);
selectedPassiveNodeIds.Value = new FixedString512Bytes(BuildPassiveSelectionCsv(nodeIds));
ApplyPassiveSelectionFromNetworkState(fillResourcesToMax);
return true;
}
/// <summary>
/// 서버 기준으로 패시브 선택을 모두 해제합니다.
/// </summary>
public bool DebugClearPassiveSelection(bool fillResourcesToMax = false)
{
if (!IsServer)
return false;
EnsurePassiveRuntimeReferences();
if (passiveTree == null)
{
passiveTree = ResolvePrototypeTree();
}
selectedPassivePresetName.Value = new FixedString64Bytes("패시브 없음");
selectedPassiveNodeIds.Value = default;
ApplyPassiveSelectionFromNetworkState(fillResourcesToMax);
return true;
}
/// <summary>
/// MPP 역할 분배 기준 패시브 프리셋을 자동 적용합니다.
/// </summary>
public bool TryApplyPrototypePassivePresetForOwner()
{
if (!IsServer)
return false;
PassivePrototypePresetKind presetKind = PassivePrototypeCatalog.ResolveOwnerPresetKind(OwnerClientId);
PassivePresetData preset = ResolvePrototypePreset(presetKind);
return DebugApplyPassivePreset(preset);
}
/// <summary>
/// 지정한 노드가 이미 선택되어 있는지 확인합니다.
/// </summary>
public bool IsPassiveNodeSelected(string nodeId)
{
if (string.IsNullOrWhiteSpace(nodeId))
return false;
IReadOnlyList<string> selectedNodeIds = SelectedPassiveNodeIds;
for (int i = 0; i < selectedNodeIds.Count; i++)
{
if (string.Equals(selectedNodeIds[i], nodeId, StringComparison.Ordinal))
return true;
}
return false;
}
/// <summary>
/// 지정한 패시브 노드를 현재 상태에서 선택할 수 있는지 확인합니다.
/// </summary>
public bool CanSelectPassiveNode(PassiveNodeData node, out string reason, out int nextUsedPoints)
{
nextUsedPoints = usedPassivePoints;
if (node == null)
{
reason = "선택할 패시브 노드가 없습니다.";
return false;
}
EnsurePassiveRuntimeReferences();
if (passiveTree == null)
{
reason = "패시브 트리를 찾지 못했습니다.";
return false;
}
if (IsPassiveNodeSelected(node.NodeId))
{
reason = "이미 선택한 노드입니다.";
return false;
}
List<string> previewSelection = new List<string>(SelectedPassiveNodeIds.Count + 1);
IReadOnlyList<string> selectedNodeIds = SelectedPassiveNodeIds;
for (int i = 0; i < selectedNodeIds.Count; i++)
{
previewSelection.Add(selectedNodeIds[i]);
}
previewSelection.Add(node.NodeId);
if (!passiveTree.TryResolveSelection(previewSelection, out List<PassiveNodeData> resolvedNodes, out reason))
return false;
nextUsedPoints = passiveTree.CalculateUsedPoints(resolvedNodes);
reason = string.Empty;
return true;
}
/// <summary>
/// 오너 클라이언트 또는 서버에서 패시브 노드 선택을 요청합니다.
/// </summary>
public bool RequestSelectPassiveNode(string nodeId, out string reason)
{
if (string.IsNullOrWhiteSpace(nodeId))
{
reason = "선택할 노드 ID가 비어 있습니다.";
return false;
}
EnsurePassiveRuntimeReferences();
PassiveNodeData node = passiveTree != null ? passiveTree.GetNodeById(nodeId) : null;
if (!CanSelectPassiveNode(node, out reason, out _))
return false;
if (IsServer)
{
return TrySelectPassiveNodeServer(nodeId, false, out reason);
}
if (!IsOwner)
{
reason = "오너 플레이어만 패시브를 변경할 수 있습니다.";
return false;
}
RequestSelectPassiveNodeRpc(new FixedString64Bytes(nodeId));
reason = $"'{node.DisplayName}' 선택 요청을 전송했습니다.";
return true;
}
/// <summary>
/// 오너 클라이언트 또는 서버에서 패시브 전체 초기화를 요청합니다.
/// </summary>
public bool RequestClearPassiveSelection(out string reason)
{
if (IsServer)
{
bool cleared = DebugClearPassiveSelection(false);
reason = cleared ? "패시브 선택을 초기화했습니다." : "패시브 선택 초기화에 실패했습니다.";
return cleared;
}
if (!IsOwner)
{
reason = "오너 플레이어만 패시브를 변경할 수 있습니다.";
return false;
}
RequestClearPassiveSelectionRpc();
reason = "패시브 초기화 요청을 전송했습니다.";
return true;
}
/// <summary>
/// 오너 클라이언트 또는 서버에서 프로토타입 프리셋 적용을 요청합니다.
/// </summary>
public bool RequestApplyPrototypePassivePreset(PassivePrototypePresetKind presetKind, out string reason)
{
PassivePresetData preset = ResolvePrototypePreset(presetKind);
if (preset == null)
{
reason = "패시브 프리셋을 찾지 못했습니다.";
return false;
}
PassiveTreeData targetTree = preset.Tree != null ? preset.Tree : passiveTree;
if (targetTree == null)
{
targetTree = ResolvePrototypeTree();
}
if (targetTree == null)
{
reason = "패시브 트리를 찾지 못했습니다.";
return false;
}
List<string> nodeIds = preset.BuildSelectedNodeIdList();
if (!targetTree.TryResolveSelection(nodeIds, out _, out reason))
return false;
if (IsServer)
{
bool applied = DebugApplyPassivePreset(preset, false);
reason = applied ? $"'{preset.PresetName}' 프리셋을 적용했습니다." : $"'{preset.PresetName}' 프리셋 적용에 실패했습니다.";
return applied;
}
if (!IsOwner)
{
reason = "오너 플레이어만 패시브를 변경할 수 있습니다.";
return false;
}
RequestApplyPrototypePassivePresetRpc((int)presetKind);
reason = $"'{preset.PresetName}' 프리셋 적용 요청을 전송했습니다.";
return true;
}
/// <summary>
/// 현재 패시브 적용 상태를 문자열로 반환합니다.
/// </summary>
public string BuildPassiveSummary()
{
EnsurePassiveRuntimeReferences();
return passiveRuntimeController != null
? passiveRuntimeController.BuildSummary()
: "[Passive] 미적용";
}
/// <summary>
/// 패시브 변경 이후 현재 자원 수치를 재정렬합니다.
/// </summary>
public void RefreshVitalsAfterPassiveChange(bool fillToMax)
{
if (!IsServer)
return;
float nextMaxHealth = MaxHealth;
float nextMaxMana = MaxMana;
currentHealth.Value = fillToMax ? nextMaxHealth : Mathf.Min(currentHealth.Value, nextMaxHealth);
currentMana.Value = fillToMax ? nextMaxMana : Mathf.Min(currentMana.Value, nextMaxMana);
}
#region IDamageable
/// <summary>
/// 대미지 적용 (서버에서만 호출)
@@ -314,7 +641,8 @@ namespace Colosseum.Player
/// </summary>
public float Heal(float amount)
{
if (!IsServer || isDead.Value) return 0f;
if (!IsServer || isDead.Value)
return 0f;
float actualHeal = Mathf.Min(amount, MaxHealth - currentHealth.Value);
currentHealth.Value = Mathf.Min(MaxHealth, currentHealth.Value + amount);
@@ -330,8 +658,12 @@ namespace Colosseum.Player
if (!IsServer || isDead.Value || amount <= 0f)
return 0f;
float resolvedAmount = amount * PassiveRuntimeModifierUtility.GetShieldReceivedMultiplier(gameObject);
if (resolvedAmount <= 0f)
return 0f;
AbnormalityData shieldType = shieldAbnormality != null ? shieldAbnormality : shieldStateAbnormality;
float actualAppliedShield = shieldCollection.ApplyShield(shieldType, amount, duration, source);
float actualAppliedShield = shieldCollection.ApplyShield(shieldType, resolvedAmount, duration, source);
RefreshShieldState();
return actualAppliedShield;
}
@@ -343,10 +675,11 @@ namespace Colosseum.Player
private float GetIncomingDamageMultiplier()
{
if (abnormalityManager == null)
return 1f;
return Mathf.Max(0f, abnormalityManager.IncomingDamageMultiplier);
float abnormalityMultiplier = abnormalityManager != null
? Mathf.Max(0f, abnormalityManager.IncomingDamageMultiplier)
: 1f;
float passiveMultiplier = PassiveRuntimeModifierUtility.GetIncomingDamageMultiplier(gameObject);
return abnormalityMultiplier * passiveMultiplier;
}
private float ConsumeShield(float incomingDamage)
@@ -389,5 +722,189 @@ namespace Colosseum.Player
gameObject);
}
#endregion
private void EnsurePassiveRuntimeReferences()
{
if (characterStats == null)
{
characterStats = GetComponent<CharacterStats>();
}
if (passiveRuntimeController == null)
{
passiveRuntimeController = GetComponent<PassiveRuntimeController>();
}
if (passiveRuntimeController == null)
{
passiveRuntimeController = gameObject.AddComponent<PassiveRuntimeController>();
}
passiveRuntimeController.Initialize(characterStats);
if (passiveTree == null)
{
passiveTree = ResolvePrototypeTree();
}
}
private PassiveTreeData ResolvePrototypeTree()
{
PassiveTreeData catalogTree = PassivePrototypeCatalog.LoadPrototypeTree(passivePrototypeCatalog);
return catalogTree != null ? catalogTree : passiveTree;
}
private PassivePresetData ResolvePrototypePreset(PassivePrototypePresetKind presetKind)
{
PassivePresetData preset = PassivePrototypeCatalog.LoadPreset(passivePrototypeCatalog, presetKind);
if (preset != null)
return preset;
return presetKind == PassivePrototypePresetKind.None ? defaultPassivePreset : null;
}
[Rpc(SendTo.Server)]
private void RequestSelectPassiveNodeRpc(FixedString64Bytes nodeId)
{
TrySelectPassiveNodeServer(nodeId.ToString(), false, out _);
}
[Rpc(SendTo.Server)]
private void RequestClearPassiveSelectionRpc()
{
DebugClearPassiveSelection(false);
}
[Rpc(SendTo.Server)]
private void RequestApplyPrototypePassivePresetRpc(int presetKindValue)
{
PassivePrototypePresetKind presetKind = Enum.IsDefined(typeof(PassivePrototypePresetKind), presetKindValue)
? (PassivePrototypePresetKind)presetKindValue
: PassivePrototypePresetKind.None;
PassivePresetData preset = ResolvePrototypePreset(presetKind);
DebugApplyPassivePreset(preset, false);
}
private void ApplyPassiveSelectionFromNetworkState(bool fillResourcesToMax)
{
EnsurePassiveRuntimeReferences();
string presetName = selectedPassivePresetName.Value.ToString();
string selectionCsv = selectedPassiveNodeIds.Value.ToString();
ParsePassiveSelectionCsv(selectionCsv, passiveNodeIdBuffer);
if (passiveRuntimeController == null)
return;
if (passiveTree == null)
{
passiveRuntimeController.ClearSelection();
currentPassivePresetName = string.IsNullOrWhiteSpace(presetName) ? "미적용" : presetName;
usedPassivePoints = 0;
remainingPassivePoints = 0;
return;
}
if (!passiveRuntimeController.TryApplySelection(passiveTree, passiveNodeIdBuffer, presetName, out string reason))
{
passiveRuntimeController.ClearSelection();
currentPassivePresetName = string.IsNullOrWhiteSpace(presetName) ? "미적용" : presetName;
usedPassivePoints = 0;
remainingPassivePoints = passiveTree.InitialPoints;
if (!string.IsNullOrWhiteSpace(reason))
{
Debug.LogWarning($"[Passive] 패시브 적용 실패 | Player={gameObject.name} | Reason={reason}");
}
}
else
{
currentPassivePresetName = string.IsNullOrWhiteSpace(presetName) ? "패시브 없음" : presetName;
usedPassivePoints = passiveRuntimeController.UsedPoints;
remainingPassivePoints = passiveRuntimeController.RemainingPoints;
}
if (IsServer)
{
RefreshVitalsAfterPassiveChange(fillResourcesToMax);
}
OnPassiveSelectionChanged?.Invoke();
}
private bool IsPassiveSelectionEmpty()
{
return string.IsNullOrWhiteSpace(selectedPassiveNodeIds.Value.ToString());
}
private static string BuildPassiveSelectionCsv(IReadOnlyList<string> nodeIds)
{
if (nodeIds == null || nodeIds.Count <= 0)
return string.Empty;
return string.Join(",", nodeIds);
}
private static void ParsePassiveSelectionCsv(string csv, List<string> destination)
{
destination.Clear();
if (string.IsNullOrWhiteSpace(csv))
return;
string[] segments = csv.Split(',');
for (int i = 0; i < segments.Length; i++)
{
string nodeId = segments[i]?.Trim();
if (string.IsNullOrWhiteSpace(nodeId))
continue;
destination.Add(nodeId);
}
}
private bool TrySelectPassiveNodeServer(string nodeId, bool fillResourcesToMax, out string reason)
{
reason = string.Empty;
if (!IsServer)
{
reason = "서버에서만 패시브 노드를 확정할 수 있습니다.";
return false;
}
if (string.IsNullOrWhiteSpace(nodeId))
{
reason = "선택할 노드 ID가 비어 있습니다.";
return false;
}
EnsurePassiveRuntimeReferences();
if (passiveTree == null)
{
reason = "패시브 트리를 찾지 못했습니다.";
return false;
}
ParsePassiveSelectionCsv(selectedPassiveNodeIds.Value.ToString(), passiveNodeIdBuffer);
for (int i = 0; i < passiveNodeIdBuffer.Count; i++)
{
if (string.Equals(passiveNodeIdBuffer[i], nodeId, StringComparison.Ordinal))
{
reason = "이미 선택한 노드입니다.";
return false;
}
}
passiveNodeIdBuffer.Add(nodeId);
if (!passiveTree.TryResolveSelection(passiveNodeIdBuffer, out _, out reason))
return false;
selectedPassivePresetName.Value = new FixedString64Bytes("커스텀 패시브");
selectedPassiveNodeIds.Value = new FixedString512Bytes(BuildPassiveSelectionCsv(passiveNodeIdBuffer));
ApplyPassiveSelectionFromNetworkState(fillResourcesToMax);
return true;
}
}
}

View File

@@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using Colosseum.Skills;
using Colosseum.Passives;
using Colosseum.Weapons;
#if UNITY_EDITOR
@@ -368,8 +369,9 @@ namespace Colosseum.Player
float baseCost = loadoutEntry.GetResolvedManaCost();
float multiplier = weaponEquipment != null ? weaponEquipment.ManaCostMultiplier : 1f;
float passiveMultiplier = PassiveRuntimeModifierUtility.GetManaCostMultiplier(gameObject, loadoutEntry.BaseSkill);
return baseCost * multiplier;
return baseCost * multiplier * passiveMultiplier;
}
/// <summary>
@@ -660,6 +662,11 @@ namespace Colosseum.Player
return;
SetSkills(loadout);
EnsureRuntimeReferences();
if (networkController != null)
{
networkController.TryApplyPrototypePassivePresetForOwner();
}
Debug.Log($"[MPP] 자동 프리셋 적용: {GetMppmLoadoutLabel()} (OwnerClientId={OwnerClientId})");
}

View File

@@ -2,6 +2,7 @@ using UnityEngine;
using Colosseum.Stats;
using Colosseum.Combat;
using Colosseum.Passives;
using Colosseum.Skills;
using Colosseum.Weapons;
@@ -75,7 +76,8 @@ namespace Colosseum.Skills.Effects
// 무기 데미지 배율 적용
float damageMultiplier = GetDamageMultiplier(caster);
float gemMultiplier = SkillRuntimeModifierUtility.GetDamageMultiplier(caster);
return baseTotal * damageMultiplier * gemMultiplier;
float passiveMultiplier = PassiveRuntimeModifierUtility.GetDamageMultiplier(caster);
return baseTotal * damageMultiplier * gemMultiplier * passiveMultiplier;
}
/// <summary>

View File

@@ -2,6 +2,7 @@ using UnityEngine;
using Colosseum.Stats;
using Colosseum.Combat;
using Colosseum.Passives;
using Colosseum.Skills;
namespace Colosseum.Skills.Effects
@@ -42,11 +43,15 @@ namespace Colosseum.Skills.Effects
var stats = caster.GetComponent<CharacterStats>();
if (stats == null)
{
return baseHeal * SkillRuntimeModifierUtility.GetHealMultiplier(caster);
return baseHeal *
SkillRuntimeModifierUtility.GetHealMultiplier(caster) *
PassiveRuntimeModifierUtility.GetHealMultiplier(caster);
}
float resolvedHeal = baseHeal + (stats.HealPower * healScaling);
return resolvedHeal * SkillRuntimeModifierUtility.GetHealMultiplier(caster);
return resolvedHeal *
SkillRuntimeModifierUtility.GetHealMultiplier(caster) *
PassiveRuntimeModifierUtility.GetHealMultiplier(caster);
}
}
}

View File

@@ -5,6 +5,7 @@ using Colosseum.Enemy;
using Colosseum.Player;
using Colosseum.Stats;
using Colosseum.Combat;
using Colosseum.Passives;
using Colosseum.Skills;
namespace Colosseum.Skills.Effects
@@ -56,10 +57,16 @@ namespace Colosseum.Skills.Effects
{
CharacterStats stats = caster != null ? caster.GetComponent<CharacterStats>() : null;
if (stats == null)
return baseShield * SkillRuntimeModifierUtility.GetShieldMultiplier(caster);
{
return baseShield *
SkillRuntimeModifierUtility.GetShieldMultiplier(caster) *
PassiveRuntimeModifierUtility.GetShieldDoneMultiplier(caster);
}
float resolvedShield = baseShield + (stats.HealPower * shieldScaling);
return resolvedShield * SkillRuntimeModifierUtility.GetShieldMultiplier(caster);
return resolvedShield *
SkillRuntimeModifierUtility.GetShieldMultiplier(caster) *
PassiveRuntimeModifierUtility.GetShieldDoneMultiplier(caster);
}
}
}

View File

@@ -0,0 +1,23 @@
using UnityEngine;
using UnityEngine.UI;
namespace Colosseum.UI
{
/// <summary>
/// 패시브 트리 노드 프리팹의 참조 모음입니다.
/// </summary>
public class PassiveTreeNodeView : MonoBehaviour
{
[SerializeField] private RectTransform rootRect;
[SerializeField] private Image backgroundImage;
[SerializeField] private Image innerImage;
[SerializeField] private Button button;
[SerializeField] private Outline outline;
public RectTransform RootRect => rootRect;
public Image BackgroundImage => backgroundImage;
public Image InnerImage => innerImage;
public Button Button => button;
public Outline Outline => outline;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 68ca5a2201ac9a446ba14501b44a6222

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7cbd4a7a9310669419c8eb607903f8b1

View File

@@ -0,0 +1,64 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Colosseum.UI
{
/// <summary>
/// 패시브 트리 메인 뷰 프리팹의 참조 모음입니다.
/// </summary>
public class PassiveTreeViewReferences : MonoBehaviour
{
[Header("Root")]
[SerializeField] private RectTransform rootRect;
[SerializeField] private GameObject overlayRoot;
[SerializeField] private RectTransform panelRect;
[Header("Header")]
[SerializeField] private Button toggleButton;
[SerializeField] private TextMeshProUGUI toggleButtonLabel;
[SerializeField] private TextMeshProUGUI pointsSummaryText;
[SerializeField] private Button closeButton;
[Header("Body")]
[SerializeField] private TextMeshProUGUI selectionSummaryText;
[SerializeField] private Button nonePresetButton;
[SerializeField] private Button defensePresetButton;
[SerializeField] private Button supportPresetButton;
[SerializeField] private Button attackPresetButton;
[SerializeField] private Button clearButton;
[SerializeField] private RectTransform graphRect;
[SerializeField] private RectTransform connectionLayer;
[SerializeField] private RectTransform nodeLayer;
[SerializeField] private RectTransform detailContent;
[SerializeField] private TextMeshProUGUI detailText;
[SerializeField] private Button selectNodeButton;
[SerializeField] private TextMeshProUGUI selectNodeButtonLabel;
[Header("Footer")]
[SerializeField] private TextMeshProUGUI statusText;
public RectTransform RootRect => rootRect;
public GameObject OverlayRoot => overlayRoot;
public RectTransform PanelRect => panelRect;
public Button ToggleButton => toggleButton;
public TextMeshProUGUI ToggleButtonLabel => toggleButtonLabel;
public TextMeshProUGUI PointsSummaryText => pointsSummaryText;
public Button CloseButton => closeButton;
public TextMeshProUGUI SelectionSummaryText => selectionSummaryText;
public Button NonePresetButton => nonePresetButton;
public Button DefensePresetButton => defensePresetButton;
public Button SupportPresetButton => supportPresetButton;
public Button AttackPresetButton => attackPresetButton;
public Button ClearButton => clearButton;
public RectTransform GraphRect => graphRect;
public RectTransform ConnectionLayer => connectionLayer;
public RectTransform NodeLayer => nodeLayer;
public RectTransform DetailContent => detailContent;
public TextMeshProUGUI DetailText => detailText;
public Button SelectNodeButton => selectNodeButton;
public TextMeshProUGUI SelectNodeButtonLabel => selectNodeButtonLabel;
public TextMeshProUGUI StatusText => statusText;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 110020793eb86f044b14fe49801bd83e

View File

@@ -26,6 +26,10 @@ namespace Colosseum.UI
[Tooltip("이상상태 요약 텍스트를 자동 생성할지 여부")]
[SerializeField] private bool autoCreateAbnormalitySummary = true;
[Header("Passive UI")]
[Tooltip("런타임 패시브 UI 컴포넌트를 자동으로 보정할지 여부")]
[SerializeField] private bool autoCreatePassiveTreeUi = true;
[Header("Target")]
[Tooltip("자동으로 로컬 플레이어 찾기")]
[SerializeField] private bool autoFindPlayer = true;
@@ -41,6 +45,11 @@ namespace Colosseum.UI
/// </summary>
public string CurrentAbnormalitySummary => abnormalitySummaryText != null ? abnormalitySummaryText.text : string.Empty;
private void Awake()
{
EnsurePassiveTreeUi();
}
private void Start()
{
if (autoFindPlayer)
@@ -235,6 +244,14 @@ namespace Colosseum.UI
abnormalitySummaryText = summaryText;
}
private void EnsurePassiveTreeUi()
{
if (!autoCreatePassiveTreeUi || GetComponent<PassiveTreeUI>() != null)
return;
gameObject.AddComponent<PassiveTreeUI>();
}
private void UpdateAbnormalitySummary()
{
if (abnormalitySummaryText == null)