- 애니메이션 이벤트 기반 방어/유지/해제 흐름과 HUD 피드백, 방어 디버그 로그를 추가했다. - 드로그 기본기1 테스트 패턴을 정리하고 공격 판정을 OnEffect 기반으로 옮기며 드로그 범위 효과의 타겟 레이어를 정상화했다. - 플레이어 퀵슬롯 테스트 세팅과 적-플레이어 겹침 방지 로직을 조정해 충돌 시 적이 수평 이동을 멈추고 최소 분리만 수행하게 했다.
993 lines
35 KiB
C#
993 lines
35 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
|
|
using UnityEngine;
|
|
|
|
using Unity.Collections;
|
|
using Unity.Netcode;
|
|
|
|
using Colosseum.Abnormalities;
|
|
using Colosseum.Combat;
|
|
using Colosseum.Passives;
|
|
using Colosseum.Skills;
|
|
using Colosseum.Stats;
|
|
|
|
namespace Colosseum.Player
|
|
{
|
|
/// <summary>
|
|
/// 플레이어 네트워크 상태 관리 (HP, MP, 패시브 상태 등)
|
|
/// </summary>
|
|
public class PlayerNetworkController : NetworkBehaviour, IDamageable
|
|
{
|
|
[Header("References")]
|
|
[Tooltip("CharacterStats 컴포넌트 (없으면 자동 검색)")]
|
|
[SerializeField] private CharacterStats characterStats;
|
|
|
|
[Tooltip("이상상태 관리자 (없으면 자동 검색)")]
|
|
[SerializeField] private AbnormalityManager abnormalityManager;
|
|
|
|
[Tooltip("방어 상태 관리자 (없으면 자동 검색)")]
|
|
[SerializeField] private PlayerDefenseController defenseController;
|
|
|
|
[Header("Shield")]
|
|
[Tooltip("보호막 타입이 지정되지 않았을 때 사용할 기본 보호막 이상상태 데이터")]
|
|
[SerializeField] private AbnormalityData shieldStateAbnormality;
|
|
|
|
[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;
|
|
public float Shield => currentShield.Value;
|
|
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;
|
|
public event Action<float, float> OnManaChanged;
|
|
public event Action<float, float> OnShieldChanged;
|
|
public event Action<PlayerNetworkController> OnDeath;
|
|
public event Action<bool> OnDeathStateChanged;
|
|
public event Action<PlayerNetworkController> OnRespawned;
|
|
public event Action<PlayerNetworkController> OnRevived;
|
|
public event Action OnPassiveSelectionChanged;
|
|
|
|
public float CurrentHealth => currentHealth.Value;
|
|
public bool IsDead => isDead.Value;
|
|
|
|
public override void OnNetworkSpawn()
|
|
{
|
|
if (characterStats == null)
|
|
{
|
|
characterStats = GetComponent<CharacterStats>();
|
|
}
|
|
|
|
if (abnormalityManager == null)
|
|
{
|
|
abnormalityManager = GetComponent<AbnormalityManager>();
|
|
}
|
|
|
|
if (defenseController == null)
|
|
{
|
|
defenseController = GetComponent<PlayerDefenseController>();
|
|
if (defenseController == null)
|
|
defenseController = gameObject.AddComponent<PlayerDefenseController>();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (!IsServer || isDead.Value)
|
|
return;
|
|
|
|
if (shieldCollection.Tick(Time.deltaTime))
|
|
{
|
|
RefreshShieldState();
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
OnHealthChanged?.Invoke(oldValue, newValue);
|
|
}
|
|
|
|
private void HandleManaChanged(float oldValue, float newValue)
|
|
{
|
|
OnManaChanged?.Invoke(oldValue, newValue);
|
|
}
|
|
|
|
private void HandleShieldChanged(float oldValue, float newValue)
|
|
{
|
|
OnShieldChanged?.Invoke(oldValue, newValue);
|
|
}
|
|
|
|
private void HandleDeathStateChanged(bool oldValue, bool newValue)
|
|
{
|
|
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>
|
|
[Rpc(SendTo.Server)]
|
|
public void TakeDamageRpc(float damage)
|
|
{
|
|
ApplyDamageInternal(new DamageContext(damage));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 마나 소모 (서버에서만 실행)
|
|
/// </summary>
|
|
[Rpc(SendTo.Server)]
|
|
public void UseManaRpc(float amount)
|
|
{
|
|
SpendMana(amount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 체력 회복 (서버에서만 실행)
|
|
/// </summary>
|
|
[Rpc(SendTo.Server)]
|
|
public void RestoreHealthRpc(float amount)
|
|
{
|
|
if (isDead.Value)
|
|
return;
|
|
|
|
currentHealth.Value = Mathf.Min(MaxHealth, currentHealth.Value + amount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 마나 회복 (서버에서만 실행)
|
|
/// </summary>
|
|
[Rpc(SendTo.Server)]
|
|
public void RestoreManaRpc(float amount)
|
|
{
|
|
RestoreMana(amount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 사망 애니메이션 재생 (모든 클라이언트에서 실행)
|
|
/// </summary>
|
|
[Rpc(SendTo.Everyone)]
|
|
private void PlayDeathAnimationRpc()
|
|
{
|
|
Animator animator = GetComponentInChildren<Animator>();
|
|
if (animator != null)
|
|
{
|
|
animator.SetTrigger("Die");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 사망(빈사) 처리 (서버에서만 실행).
|
|
/// HP가 0 이하가 되면 호출되며, 부활 스킬로 복귀 가능한 빈사 상태로 전환합니다.
|
|
/// </summary>
|
|
private void HandleDeath()
|
|
{
|
|
if (isDead.Value)
|
|
return;
|
|
|
|
isDead.Value = true;
|
|
shieldCollection.Clear();
|
|
RefreshShieldState();
|
|
|
|
if (abnormalityManager != null)
|
|
{
|
|
abnormalityManager.RemoveAllAbnormalities();
|
|
}
|
|
|
|
PlayerMovement movement = GetComponent<PlayerMovement>();
|
|
if (movement != null)
|
|
{
|
|
movement.ClearForcedMovement();
|
|
movement.enabled = false;
|
|
}
|
|
|
|
HitReactionController hitReactionController = GetComponent<HitReactionController>();
|
|
if (hitReactionController != null)
|
|
{
|
|
hitReactionController.ClearHitReactionState();
|
|
}
|
|
|
|
ThreatController threatController = GetComponent<ThreatController>();
|
|
if (threatController != null)
|
|
{
|
|
threatController.ClearThreatModifiers();
|
|
}
|
|
|
|
PlayerSkillInput skillInput = GetComponent<PlayerSkillInput>();
|
|
if (skillInput != null)
|
|
{
|
|
skillInput.enabled = false;
|
|
}
|
|
|
|
SkillController skillController = GetComponent<SkillController>();
|
|
if (skillController != null)
|
|
{
|
|
skillController.CancelSkill(SkillCancelReason.Death);
|
|
}
|
|
|
|
PlayDeathAnimationRpc();
|
|
OnDeath?.Invoke(this);
|
|
|
|
Debug.Log($"[Player] Player {OwnerClientId} died!");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 리스폰 (서버에서만 실행)
|
|
/// </summary>
|
|
public void Respawn()
|
|
{
|
|
if (!IsServer)
|
|
return;
|
|
|
|
if (abnormalityManager != null)
|
|
{
|
|
abnormalityManager.RemoveAllAbnormalities();
|
|
}
|
|
|
|
isDead.Value = false;
|
|
RefreshVitalsAfterPassiveChange(true);
|
|
shieldCollection.Clear();
|
|
RefreshShieldState();
|
|
|
|
PlayerMovement movement = GetComponent<PlayerMovement>();
|
|
if (movement != null)
|
|
{
|
|
movement.ClearForcedMovement();
|
|
movement.enabled = true;
|
|
}
|
|
|
|
HitReactionController hitReactionController = GetComponent<HitReactionController>();
|
|
if (hitReactionController != null)
|
|
{
|
|
hitReactionController.ClearHitReactionState();
|
|
}
|
|
|
|
ThreatController threatController = GetComponent<ThreatController>();
|
|
if (threatController != null)
|
|
{
|
|
threatController.ClearThreatModifiers();
|
|
}
|
|
|
|
PlayerSkillInput skillInput = GetComponent<PlayerSkillInput>();
|
|
if (skillInput != null)
|
|
{
|
|
skillInput.enabled = true;
|
|
}
|
|
|
|
Animator animator = GetComponentInChildren<Animator>();
|
|
if (animator != null)
|
|
{
|
|
animator.Rebind();
|
|
}
|
|
|
|
SkillController skillController = GetComponent<SkillController>();
|
|
if (skillController != null)
|
|
{
|
|
skillController.CancelSkill(SkillCancelReason.Respawn);
|
|
}
|
|
|
|
OnRespawned?.Invoke(this);
|
|
|
|
Debug.Log($"[Player] Player {OwnerClientId} respawned!");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 빈사 상태에서 부활 (서버에서만 실행)
|
|
/// </summary>
|
|
/// <param name="healthPercent">부활 시 체력 비율 (0~1)</param>
|
|
public void Revive(float healthPercent = 0.3f)
|
|
{
|
|
if (!IsServer || !isDead.Value)
|
|
return;
|
|
|
|
isDead.Value = false;
|
|
float revivedHealth = Mathf.Max(1f, MaxHealth * Mathf.Clamp01(healthPercent));
|
|
currentHealth.Value = revivedHealth;
|
|
|
|
PlayerMovement movement = GetComponent<PlayerMovement>();
|
|
if (movement != null)
|
|
{
|
|
movement.enabled = true;
|
|
}
|
|
|
|
PlayerSkillInput skillInput = GetComponent<PlayerSkillInput>();
|
|
if (skillInput != null)
|
|
{
|
|
skillInput.enabled = true;
|
|
}
|
|
|
|
OnRevived?.Invoke(this);
|
|
|
|
Debug.Log($"[Player] Player {OwnerClientId} revived! HP={revivedHealth:F0}");
|
|
}
|
|
|
|
/// <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>
|
|
/// 대미지 적용 (서버에서만 호출)
|
|
/// </summary>
|
|
public float TakeDamage(float damage, object source = null)
|
|
{
|
|
return ApplyDamageInternal(new DamageContext(damage, source));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 대미지 컨텍스트를 사용해 대미지를 적용합니다.
|
|
/// </summary>
|
|
public float TakeDamage(DamageContext damageContext)
|
|
{
|
|
return ApplyDamageInternal(damageContext);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 체력 회복 (서버에서만 호출)
|
|
/// </summary>
|
|
public float Heal(float amount)
|
|
{
|
|
if (!IsServer || isDead.Value)
|
|
return 0f;
|
|
|
|
float actualHeal = Mathf.Min(amount, MaxHealth - currentHealth.Value);
|
|
currentHealth.Value = Mathf.Min(MaxHealth, currentHealth.Value + amount);
|
|
|
|
return actualHeal;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 마나를 소모하고 실제 소모량을 반환합니다.
|
|
/// </summary>
|
|
public float SpendMana(float amount)
|
|
{
|
|
if (!IsServer || isDead.Value || amount <= 0f)
|
|
return 0f;
|
|
|
|
float actualSpent = Mathf.Min(amount, currentMana.Value);
|
|
currentMana.Value = Mathf.Max(0f, currentMana.Value - actualSpent);
|
|
return actualSpent;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 마나를 회복하고 실제 회복량을 반환합니다.
|
|
/// </summary>
|
|
public float RestoreMana(float amount)
|
|
{
|
|
if (!IsServer || isDead.Value || amount <= 0f)
|
|
return 0f;
|
|
|
|
float actualRestore = Mathf.Min(amount, MaxMana - currentMana.Value);
|
|
currentMana.Value = Mathf.Min(MaxMana, currentMana.Value + actualRestore);
|
|
return actualRestore;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 보호막을 적용합니다.
|
|
/// </summary>
|
|
public float ApplyShield(float amount, float duration, AbnormalityData shieldAbnormality = null, GameObject source = null)
|
|
{
|
|
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, resolvedAmount, duration, source);
|
|
RefreshShieldState();
|
|
return actualAppliedShield;
|
|
}
|
|
|
|
private bool IsDamageImmune()
|
|
{
|
|
return abnormalityManager != null && abnormalityManager.IsInvincible;
|
|
}
|
|
|
|
private float GetIncomingDamageMultiplier()
|
|
{
|
|
float abnormalityMultiplier = abnormalityManager != null
|
|
? Mathf.Max(0f, abnormalityManager.IncomingDamageMultiplier)
|
|
: 1f;
|
|
float passiveMultiplier = PassiveRuntimeModifierUtility.GetIncomingDamageMultiplier(gameObject);
|
|
return abnormalityMultiplier * passiveMultiplier;
|
|
}
|
|
|
|
private float ConsumeShield(float incomingDamage)
|
|
{
|
|
if (incomingDamage <= 0f || currentShield.Value <= 0f)
|
|
return incomingDamage;
|
|
|
|
float remainingDamage = shieldCollection.ConsumeDamage(incomingDamage);
|
|
RefreshShieldState();
|
|
return remainingDamage;
|
|
}
|
|
|
|
private float ApplyDamageInternal(DamageContext damageContext)
|
|
{
|
|
if (!IsServer || isDead.Value || IsDamageImmune())
|
|
return 0f;
|
|
|
|
if (defenseController == null)
|
|
defenseController = GetComponent<PlayerDefenseController>();
|
|
|
|
float rawDamage = damageContext.Amount;
|
|
if (rawDamage <= 0f)
|
|
return 0f;
|
|
|
|
if (defenseController != null)
|
|
{
|
|
rawDamage = defenseController.ResolveIncomingDamage(damageContext.WithAmount(rawDamage));
|
|
}
|
|
|
|
float finalDamage = rawDamage * GetIncomingDamageMultiplier();
|
|
float mitigatedDamage = ConsumeShield(finalDamage);
|
|
float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value);
|
|
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
|
|
|
|
CombatBalanceTracker.RecordDamage(damageContext.SourceGameObject, gameObject, actualDamage);
|
|
|
|
if (currentHealth.Value <= 0f)
|
|
{
|
|
HandleDeath();
|
|
}
|
|
|
|
return actualDamage;
|
|
}
|
|
|
|
private void RefreshShieldState()
|
|
{
|
|
currentShield.Value = shieldCollection.TotalAmount;
|
|
|
|
ShieldAbnormalityUtility.SyncShieldAbnormalities(
|
|
abnormalityManager,
|
|
shieldCollection.ActiveShields,
|
|
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;
|
|
}
|
|
}
|
|
}
|