feat: 패시브 트리 프로토타입 구현
- 패시브 트리/노드/프리셋 데이터와 카탈로그 참조 구조를 추가하고 Resources 의존을 Data/Passives 자산 구조로 정리 - 플레이어 런타임, 전투 계수, 프리셋 적용, 멀티플레이 동기화 경로에 패시브 적용 로직 연결 - 프리팹 기반 패시브 트리 UI와 노드 아이콘/프리셋/상세 패널 흐름을 추가하고 HUD에 연동 - 패시브 디버그/부트스트랩 메뉴와 UI 프리팹 재생성 경로를 추가
This commit is contained in:
51
Assets/_Game/Scripts/Passives/PassiveEffectEntry.cs
Normal file
51
Assets/_Game/Scripts/Passives/PassiveEffectEntry.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Passives/PassiveEffectEntry.cs.meta
Normal file
2
Assets/_Game/Scripts/Passives/PassiveEffectEntry.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bcf6d3de9c557e5418acd39a75266509
|
||||
66
Assets/_Game/Scripts/Passives/PassiveEnums.cs
Normal file
66
Assets/_Game/Scripts/Passives/PassiveEnums.cs
Normal 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,
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Passives/PassiveEnums.cs.meta
Normal file
2
Assets/_Game/Scripts/Passives/PassiveEnums.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a72f5239fa8913747ae5dd7fbd7f66eb
|
||||
67
Assets/_Game/Scripts/Passives/PassiveNodeData.cs
Normal file
67
Assets/_Game/Scripts/Passives/PassiveNodeData.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Passives/PassiveNodeData.cs.meta
Normal file
2
Assets/_Game/Scripts/Passives/PassiveNodeData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c72b2635385ed49498483636164bac87
|
||||
225
Assets/_Game/Scripts/Passives/PassivePresentationUtility.cs
Normal file
225
Assets/_Game/Scripts/Passives/PassivePresentationUtility.cs
Normal 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.##}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 91dfed6432de76b43b187ce333faaa54
|
||||
44
Assets/_Game/Scripts/Passives/PassivePresetData.cs
Normal file
44
Assets/_Game/Scripts/Passives/PassivePresetData.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Passives/PassivePresetData.cs.meta
Normal file
2
Assets/_Game/Scripts/Passives/PassivePresetData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2f8d8a9657f495440af71ac932114128
|
||||
28
Assets/_Game/Scripts/Passives/PassivePrototypeCatalog.cs
Normal file
28
Assets/_Game/Scripts/Passives/PassivePrototypeCatalog.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 44a4706642cb88547be17c970370eb5a
|
||||
37
Assets/_Game/Scripts/Passives/PassivePrototypeCatalogData.cs
Normal file
37
Assets/_Game/Scripts/Passives/PassivePrototypeCatalogData.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cf6077238ea8f1649a7cecd929b02c91
|
||||
328
Assets/_Game/Scripts/Passives/PassiveRuntimeController.cs
Normal file
328
Assets/_Game/Scripts/Passives/PassiveRuntimeController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1e050120f06c9e447900d65e3629482e
|
||||
129
Assets/_Game/Scripts/Passives/PassiveTreeData.cs
Normal file
129
Assets/_Game/Scripts/Passives/PassiveTreeData.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Passives/PassiveTreeData.cs.meta
Normal file
2
Assets/_Game/Scripts/Passives/PassiveTreeData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 16b8c249387e8bf4f94e93d1776171a4
|
||||
Reference in New Issue
Block a user