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

@@ -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;
}
}
}