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 { /// /// 플레이어 네트워크 상태 관리 (HP, MP, 패시브 상태 등) /// public class PlayerNetworkController : NetworkBehaviour, IDamageable { [Header("References")] [Tooltip("CharacterStats 컴포넌트 (없으면 자동 검색)")] [SerializeField] private CharacterStats characterStats; [Tooltip("이상상태 관리자 (없으면 자동 검색)")] [SerializeField] private AbnormalityManager abnormalityManager; [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 currentHealth = new NetworkVariable(100f); private readonly NetworkVariable currentMana = new NetworkVariable(50f); private readonly NetworkVariable isDead = new NetworkVariable(false); private readonly NetworkVariable currentShield = new NetworkVariable(0f); private readonly NetworkVariable selectedPassiveNodeIds = new NetworkVariable(); private readonly NetworkVariable selectedPassivePresetName = new NetworkVariable(); private readonly ShieldCollection shieldCollection = new ShieldCollection(); private readonly List passiveNodeIdBuffer = new List(); 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 SelectedPassiveNodeIds => passiveRuntimeController != null ? passiveRuntimeController.SelectedNodeIds : Array.Empty(); public event Action OnHealthChanged; public event Action OnManaChanged; public event Action OnShieldChanged; public event Action OnDeath; public event Action OnDeathStateChanged; public event Action OnRespawned; public event Action OnPassiveSelectionChanged; public float CurrentHealth => currentHealth.Value; public bool IsDead => isDead.Value; public override void OnNetworkSpawn() { if (characterStats == null) { characterStats = GetComponent(); } if (abnormalityManager == null) { abnormalityManager = GetComponent(); } 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); } /// /// 대미지 적용 (서버에서만 실행) /// [Rpc(SendTo.Server)] public void TakeDamageRpc(float damage) { ApplyDamageInternal(damage, null); } /// /// 마나 소모 (서버에서만 실행) /// [Rpc(SendTo.Server)] public void UseManaRpc(float amount) { if (isDead.Value) return; currentMana.Value = Mathf.Max(0f, currentMana.Value - amount); } /// /// 체력 회복 (서버에서만 실행) /// [Rpc(SendTo.Server)] public void RestoreHealthRpc(float amount) { if (isDead.Value) return; currentHealth.Value = Mathf.Min(MaxHealth, currentHealth.Value + amount); } /// /// 마나 회복 (서버에서만 실행) /// [Rpc(SendTo.Server)] public void RestoreManaRpc(float amount) { if (isDead.Value) return; currentMana.Value = Mathf.Min(MaxMana, currentMana.Value + amount); } /// /// 사망 애니메이션 재생 (모든 클라이언트에서 실행) /// [Rpc(SendTo.Everyone)] private void PlayDeathAnimationRpc() { Animator animator = GetComponentInChildren(); if (animator != null) { animator.SetTrigger("Die"); } } /// /// 사망 처리 (서버에서만 실행) /// private void HandleDeath() { if (isDead.Value) return; isDead.Value = true; shieldCollection.Clear(); RefreshShieldState(); if (abnormalityManager != null) { abnormalityManager.RemoveAllAbnormalities(); } PlayerMovement movement = GetComponent(); if (movement != null) { movement.ClearForcedMovement(); movement.enabled = false; } HitReactionController hitReactionController = GetComponent(); if (hitReactionController != null) { hitReactionController.ClearHitReactionState(); } ThreatController threatController = GetComponent(); if (threatController != null) { threatController.ClearThreatModifiers(); } PlayerSkillInput skillInput = GetComponent(); if (skillInput != null) { skillInput.enabled = false; } SkillController skillController = GetComponent(); if (skillController != null) { skillController.CancelSkill(SkillCancelReason.Death); } PlayDeathAnimationRpc(); OnDeath?.Invoke(this); Debug.Log($"[Player] Player {OwnerClientId} died!"); } /// /// 리스폰 (서버에서만 실행) /// public void Respawn() { if (!IsServer) return; if (abnormalityManager != null) { abnormalityManager.RemoveAllAbnormalities(); } isDead.Value = false; RefreshVitalsAfterPassiveChange(true); shieldCollection.Clear(); RefreshShieldState(); PlayerMovement movement = GetComponent(); if (movement != null) { movement.ClearForcedMovement(); movement.enabled = true; } HitReactionController hitReactionController = GetComponent(); if (hitReactionController != null) { hitReactionController.ClearHitReactionState(); } ThreatController threatController = GetComponent(); if (threatController != null) { threatController.ClearThreatModifiers(); } PlayerSkillInput skillInput = GetComponent(); if (skillInput != null) { skillInput.enabled = true; } Animator animator = GetComponentInChildren(); if (animator != null) { animator.Rebind(); } SkillController skillController = GetComponent(); if (skillController != null) { skillController.CancelSkill(SkillCancelReason.Respawn); } OnRespawned?.Invoke(this); Debug.Log($"[Player] Player {OwnerClientId} respawned!"); } /// /// 서버 기준으로 패시브 프리셋을 적용합니다. /// 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 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; } /// /// 서버 기준으로 패시브 선택을 모두 해제합니다. /// 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; } /// /// MPP 역할 분배 기준 패시브 프리셋을 자동 적용합니다. /// public bool TryApplyPrototypePassivePresetForOwner() { if (!IsServer) return false; PassivePrototypePresetKind presetKind = PassivePrototypeCatalog.ResolveOwnerPresetKind(OwnerClientId); PassivePresetData preset = ResolvePrototypePreset(presetKind); return DebugApplyPassivePreset(preset); } /// /// 지정한 노드가 이미 선택되어 있는지 확인합니다. /// public bool IsPassiveNodeSelected(string nodeId) { if (string.IsNullOrWhiteSpace(nodeId)) return false; IReadOnlyList selectedNodeIds = SelectedPassiveNodeIds; for (int i = 0; i < selectedNodeIds.Count; i++) { if (string.Equals(selectedNodeIds[i], nodeId, StringComparison.Ordinal)) return true; } return false; } /// /// 지정한 패시브 노드를 현재 상태에서 선택할 수 있는지 확인합니다. /// 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 previewSelection = new List(SelectedPassiveNodeIds.Count + 1); IReadOnlyList selectedNodeIds = SelectedPassiveNodeIds; for (int i = 0; i < selectedNodeIds.Count; i++) { previewSelection.Add(selectedNodeIds[i]); } previewSelection.Add(node.NodeId); if (!passiveTree.TryResolveSelection(previewSelection, out List resolvedNodes, out reason)) return false; nextUsedPoints = passiveTree.CalculateUsedPoints(resolvedNodes); reason = string.Empty; return true; } /// /// 오너 클라이언트 또는 서버에서 패시브 노드 선택을 요청합니다. /// 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; } /// /// 오너 클라이언트 또는 서버에서 패시브 전체 초기화를 요청합니다. /// 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; } /// /// 오너 클라이언트 또는 서버에서 프로토타입 프리셋 적용을 요청합니다. /// 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 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; } /// /// 현재 패시브 적용 상태를 문자열로 반환합니다. /// public string BuildPassiveSummary() { EnsurePassiveRuntimeReferences(); return passiveRuntimeController != null ? passiveRuntimeController.BuildSummary() : "[Passive] 미적용"; } /// /// 패시브 변경 이후 현재 자원 수치를 재정렬합니다. /// 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 /// /// 대미지 적용 (서버에서만 호출) /// public float TakeDamage(float damage, object source = null) { return ApplyDamageInternal(damage, source); } /// /// 체력 회복 (서버에서만 호출) /// 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; } /// /// 보호막을 적용합니다. /// 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(float damage, object source) { if (!IsServer || isDead.Value || IsDamageImmune()) return 0f; float finalDamage = damage * GetIncomingDamageMultiplier(); float mitigatedDamage = ConsumeShield(finalDamage); float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value); currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage); CombatBalanceTracker.RecordDamage(source as GameObject, 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(); } if (passiveRuntimeController == null) { passiveRuntimeController = GetComponent(); } if (passiveRuntimeController == null) { passiveRuntimeController = gameObject.AddComponent(); } 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 nodeIds) { if (nodeIds == null || nodeIds.Count <= 0) return string.Empty; return string.Join(",", nodeIds); } private static void ParsePassiveSelectionCsv(string csv, List 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; } } }