using System; using Unity.Netcode; using Unity.Netcode.Components; using UnityEngine; using UnityEngine.InputSystem; using Unity.Cinemachine; using Northbound; public class NetworkPlayerController : NetworkBehaviour, ITeamMember, IDamageable, IHealthProvider { [Header("Movement Settings")] public float rotationSpeed = 10f; [Header("Team Settings")] [SerializeField] private TeamType initialTeam = TeamType.Player; [Header("Visual Effects")] [SerializeField] private GameObject damageEffectPrefab; [SerializeField] private GameObject deathEffectPrefab; [Header("Death Settings")] [SerializeField] private GameObject resourcePickupPrefab; // 자원 드랍 프리팹 [SerializeField] private float respawnDelay = 10f; // 부활 대기 시간 (초) [Header("Health Bar")] [SerializeField] private bool showHealthBar = true; [SerializeField] private GameObject healthBarPrefab; // 이 플레이어를 제어하는 클라이언트 ID (서버 소유권이지만 논리적 소유자) private NetworkVariable _ownerPlayerId = new NetworkVariable( ulong.MaxValue, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server ); private NetworkVariable _team = new NetworkVariable( TeamType.Player, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server ); private NetworkVariable _currentHealth = new NetworkVariable( 100, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server ); private Vector2 _moveInput; private CharacterController _controller; private PlayerInputActions _inputActions; private Animator _animator; private NetworkAnimator _networkAnimator; private PlayerStats _playerStats; private UnitHealthBar _healthBar; private RespawnCountdownUI _respawnCountdownUI; // 체력 자연 회복 private float _lastCombatTime; private float _hpRegenAccumulator; // 이 플레이어가 로컬 플레이어인지 확인 public bool IsLocalPlayer => _ownerPlayerId.Value == NetworkManager.Singleton.LocalClientId; public ulong OwnerPlayerId => _ownerPlayerId.Value; // 중앙 집중식 입력 관리 - 다른 컴포넌트에서 참조 public PlayerInputActions InputActions => _inputActions; // 소유자 변경 이벤트 public event Action OnOwnerChanged; // 입력 시스템 초기화 완료 이벤트 public event Action OnInputInitialized; void Awake() { _controller = GetComponent(); _animator = GetComponent(); _networkAnimator = GetComponent(); _playerStats = GetComponent(); } public override void OnNetworkSpawn() { base.OnNetworkSpawn(); // 서버에서 초기화 if (IsServer) { if (_team.Value == TeamType.Neutral) { _team.Value = initialTeam; } if (_currentHealth.Value == 0) { _currentHealth.Value = GetMaxHealth(); } } // 이벤트 구독 _currentHealth.OnValueChanged += OnHealthChanged; _ownerPlayerId.OnValueChanged += OnOwnerPlayerIdChanged; // 로컬 플레이어만 씬에서 리스폰 UI 찾기 if (IsLocalPlayer) { _respawnCountdownUI = FindFirstObjectByType(); } // 체력바 생성 if (showHealthBar && healthBarPrefab != null) { CreateHealthBar(); } // 이미 로컬 플레이어로 설정되어 있으면 입력 초기화 TryInitializeLocalPlayer(); } private void OnOwnerPlayerIdChanged(ulong previousValue, ulong newValue) { OnOwnerChanged?.Invoke(newValue); TryInitializeLocalPlayer(); // 로컬 플레이어가 되면 리스폰 UI 참조 가져오기 if (IsLocalPlayer && _respawnCountdownUI == null) { _respawnCountdownUI = FindFirstObjectByType(); } } private void TryInitializeLocalPlayer() { if (!IsLocalPlayer) return; if (_inputActions != null) return; // 이미 초기화됨 var vcam = GameObject.FindFirstObjectByType(); if (vcam != null) { vcam.Follow = transform; vcam.LookAt = transform; } _inputActions = new PlayerInputActions(); _inputActions.Enable(); // 입력 초기화 완료 이벤트 발생 OnInputInitialized?.Invoke(); } /// /// 플레이어 초기화 (서버에서 호출) /// public void Initialize(ulong ownerPlayerId) { if (!IsServer) return; _ownerPlayerId.Value = ownerPlayerId; } /// /// 입력 강제 복구 (입력이 멈췄을 때 호출) /// public void ForceRecoverInput() { if (!IsLocalPlayer) return; // InputActions가 없으면 초기화 시도 if (_inputActions == null) { TryInitializeLocalPlayer(); return; } // InputActions가 있지만 비활성화되어 있으면 활성화 if (!_inputActions.Player.enabled && _currentHealth.Value > 0) { _inputActions.Enable(); Debug.Log("[NetworkPlayerController] 입력 강제 복구 완료"); } } public override void OnNetworkDespawn() { _currentHealth.OnValueChanged -= OnHealthChanged; _ownerPlayerId.OnValueChanged -= OnOwnerPlayerIdChanged; if (IsLocalPlayer && _inputActions != null) { _inputActions.Disable(); } base.OnNetworkDespawn(); } // 이동 동기화 설정 private float _moveSendInterval = 0.016f; // ~60Hz 전송 private float _moveSendTimer; private Vector2 _lastSentMoveInput; // 서버 측 이동 입력 저장 private Vector2 _serverMoveInput; // 입력 복구 체크 private float _inputRecoveryCheckInterval = 1f; // 1초마다 체크 private float _inputRecoveryCheckTimer; void Update() { // 서버에서 체력 자연 회복 처리 if (IsServer) { UpdateHealthRegeneration(); } // 로컬 플레이어만 입력 처리 if (!IsLocalPlayer) return; // 입력 시스템이 초기화되지 않았으면 초기화 시도 if (_inputActions == null) { TryInitializeLocalPlayer(); return; } // 주기적으로 입력 상태 확인 및 자동 복구 (살아있는데 입력이 비활성화된 경우) _inputRecoveryCheckTimer += Time.deltaTime; if (_inputRecoveryCheckTimer >= _inputRecoveryCheckInterval) { _inputRecoveryCheckTimer = 0f; if (_currentHealth.Value > 0 && !_inputActions.Player.enabled) { Debug.LogWarning("[NetworkPlayerController] 입력이 비활성화되어 있음 - 자동 복구"); _inputActions.Enable(); } } // 죽었으면 이동 불가 if (_currentHealth.Value <= 0) return; // 액션/상호작용 중이면 이동 불가 var attackAction = GetComponent(); var playerInteraction = GetComponent(); bool isActionBlocked = (attackAction != null && attackAction.IsAttacking) || (playerInteraction != null && playerInteraction.IsInteracting); if (isActionBlocked) { // 서버에 이동 중지 알림 if (_lastSentMoveInput != Vector2.zero) { _lastSentMoveInput = Vector2.zero; MoveServerRpc(Vector2.zero); } return; } _moveInput = _inputActions.Player.Move.ReadValue(); // 일정 간격으로만 서버에 전송 (네트워크 부하 감소) _moveSendTimer += Time.deltaTime; if (_moveSendTimer >= _moveSendInterval || _moveInput != _lastSentMoveInput) { _moveSendTimer = 0f; _lastSentMoveInput = _moveInput; MoveServerRpc(_moveInput); } } void FixedUpdate() { // 서버에서만 물리 이동 처리 if (!IsServer) return; if (_currentHealth.Value <= 0) return; Vector3 move = new Vector3(_serverMoveInput.x, 0, _serverMoveInput.y).normalized; if (move.magnitude >= 0.1f) { Quaternion targetRotation = Quaternion.LookRotation(move); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSpeed * Time.fixedDeltaTime); if (_controller != null) { float moveSpeed = _playerStats?.GetMoveSpeed() ?? 5f; _controller.Move(move * moveSpeed * Time.fixedDeltaTime); } } // 애니메이션 업데이트 if (_networkAnimator != null) { _networkAnimator.Animator.SetFloat("MoveSpeed", move.magnitude); } } [Rpc(SendTo.Server)] private void MoveServerRpc(Vector2 moveInput) { // 서버에 입력만 저장, 실제 이동은 FixedUpdate에서 처리 _serverMoveInput = moveInput; } #region ITeamMember Implementation public TeamType GetTeam() => _team.Value; public void SetTeam(TeamType team) { if (!IsServer) return; TeamType previousTeam = _team.Value; _team.Value = team; } #endregion #region IDamageable Implementation public void TakeDamage(int damage, ulong attackerId) { if (!IsServer) return; // 이미 죽었으면 무시 if (_currentHealth.Value <= 0) return; // 공격자의 팀 확인 if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(attackerId, out NetworkObject attackerObj)) { var attackerTeamMember = attackerObj.GetComponent(); if (attackerTeamMember != null) { if (!TeamManager.CanAttack(attackerTeamMember, this)) { return; } } } // 데미지 적용 int actualDamage = Mathf.Min(damage, _currentHealth.Value); _currentHealth.Value -= actualDamage; // 전투 상태 기록 (회복 방지) MarkInCombat(); // 데미지 이펙트 ShowDamageEffectClientRpc(); // 체력이 0이 되면 사망 if (_currentHealth.Value <= 0) { Die(attackerId); } } /// /// 플레이어가 가진 자원을 ResourcePickup으로 드랍 /// private void DropPlayerResources() { if (!IsServer) return; // ServerResourceManager에서 플레이어의 자원 확인 var resourceManager = ServerResourceManager.Instance; if (resourceManager == null || resourcePickupPrefab == null) return; ulong playerId = _ownerPlayerId.Value; int resourceAmount = resourceManager.GetPlayerResourceAmount(playerId); if (resourceAmount > 0) { // 자원을 0으로 설정 (드랍 후 소멸) resourceManager.RemoveResource(playerId, resourceAmount); // 플레이어의 UI 업데이트 요청 var spawnedObjects = NetworkManager.Singleton.SpawnManager.SpawnedObjects; foreach (var kvp in spawnedObjects) { var controller = kvp.Value.GetComponent(); if (controller != null && controller.OwnerPlayerId == playerId) { var inventory = kvp.Value.GetComponent(); if (inventory != null) { inventory.RequestResourceUpdateServerRpc(playerId); break; } } } // ResourcePickup 프리팹 생성 GameObject pickupObj = Instantiate(resourcePickupPrefab, transform.position + Vector3.up, Quaternion.identity); NetworkObject pickupNetworkObj = pickupObj.GetComponent(); ResourcePickup pickup = pickupObj.GetComponent(); if (pickupNetworkObj != null && pickup != null) { // 드랍된 자원량 설정 pickup.resourceAmount = resourceAmount; pickup.resourceName = "Dropped Resource"; // NetworkObject로 스폰 pickupNetworkObj.Spawn(); } else { Debug.LogError("[NetworkPlayerController] ResourcePickup 프리팹이 올바르지 않습니다."); Destroy(pickupObj); } } } private void Die(ulong killerId) { if (!IsServer) return; // 플레이어가 가진 자원을 ResourcePickup으로 드랍 DropPlayerResources(); // 사망 이펙트 ShowDeathEffectClientRpc(); // 애니메이션 - NetworkAnimator로 동기화 if (_networkAnimator != null) { _networkAnimator.SetTrigger("Die"); } // 사망 즉시 플레이어를 숨김 (크립의 감지 방지) HidePlayerClientRpc(); // 일정 시간 후 리스폰 Invoke(nameof(HandleDeath), respawnDelay); } /// /// 사망 시 플레이어 숨김 /// [ClientRpc] private void HidePlayerClientRpc() { // 로컬 플레이어만 입력 비활성화 및 UI 표시 if (IsLocalPlayer) { if (_inputActions != null) { _inputActions.Disable(); } // 리스폰 카운트다운 UI 표시 if (_respawnCountdownUI != null) { double endServerTime = NetworkManager.Singleton.ServerTime.Time + respawnDelay; _respawnCountdownUI.Show(endServerTime, respawnDelay); } } // CharacterController 비활성화 (이동 및 충돌 방지) if (_controller != null) { _controller.enabled = false; } // 모든 Renderer 비활성화 (시각적으로 숨김) Renderer[] renderers = GetComponentsInChildren(); foreach (Renderer renderer in renderers) { renderer.enabled = false; } } /// /// 리스폰 시 플레이어 다시 보임 /// [ClientRpc] private void ShowPlayerClientRpc() { // 로컬 플레이어만 입력 활성화 및 UI 숨김 if (IsLocalPlayer) { if (_inputActions != null) { _inputActions.Enable(); } // 리스폰 카운트다운 UI 숨김 if (_respawnCountdownUI != null) { _respawnCountdownUI.Hide(); } } // CharacterController 활성화 if (_controller != null) { _controller.enabled = true; } // 모든 Renderer 활성화 Renderer[] renderers = GetComponentsInChildren(); foreach (Renderer renderer in renderers) { renderer.enabled = true; } } private void HandleDeath() { if (!IsServer) return; Respawn(); } private void Respawn() { if (!IsServer) return; // 체력 회복 _currentHealth.Value = GetMaxHealth(); // 스폰 포인트로 이동 var spawnPoints = FindObjectsByType(FindObjectsSortMode.None); if (spawnPoints.Length > 0) { var spawnPoint = spawnPoints[UnityEngine.Random.Range(0, spawnPoints.Length)]; transform.position = spawnPoint.transform.position; transform.rotation = spawnPoint.transform.rotation; } // NetworkAnimator로 애니메이션 동기화 if (_networkAnimator != null) { _networkAnimator.SetTrigger("Revive"); } // 플레이어 다시 보임 (이동 후 실행) ShowPlayerClientRpc(); } [ClientRpc] private void ShowDamageEffectClientRpc() { if (damageEffectPrefab != null) { GameObject effect = Instantiate(damageEffectPrefab, transform.position + Vector3.up, Quaternion.identity); Destroy(effect, 2f); } } [ClientRpc] private void ShowDeathEffectClientRpc() { if (deathEffectPrefab != null) { GameObject effect = Instantiate(deathEffectPrefab, transform.position, Quaternion.identity); Destroy(effect, 3f); } } #endregion #region Health Management public int GetCurrentHealth() => _currentHealth.Value; /// /// 전투 상태 기록 (데미지를 받거나 입힐 때 호출) /// public void MarkInCombat() { _lastCombatTime = Time.time; } /// /// 현재 전투 중인지 확인 (회복 대기 시간이 지났는지) /// public bool IsInCombat() { if (_playerStats == null) return false; float regenDelay = _playerStats.GetHpRegenDelay(); return (Time.time - _lastCombatTime) < regenDelay; } /// /// 서버에서 체력 자연 회복 처리 /// private void UpdateHealthRegeneration() { // 죽었으면 회복하지 않음 if (_currentHealth.Value <= 0) return; // 이미 최대 체력이면 회복하지 않음 int maxHealth = GetMaxHealth(); if (_currentHealth.Value >= maxHealth) { _hpRegenAccumulator = 0f; return; } // 전투 중이면 회복하지 않음 if (IsInCombat()) return; // 회복량 계산 float hpRegen = _playerStats?.GetHpRegen() ?? 0f; if (hpRegen <= 0f) return; // 프레임당 회복량 누적 _hpRegenAccumulator += hpRegen * Time.deltaTime; // 1 이상 누적되면 정수만큼 회복 if (_hpRegenAccumulator >= 1f) { int healAmount = Mathf.FloorToInt(_hpRegenAccumulator); _hpRegenAccumulator -= healAmount; // 실제 회복 int actualHeal = Mathf.Min(healAmount, maxHealth - _currentHealth.Value); _currentHealth.Value += actualHeal; } } public int GetMaxHealth() => _playerStats?.GetMaxHp() ?? 100; public float GetHealthPercentage() { int max = GetMaxHealth(); return max > 0 ? (float)_currentHealth.Value / max : 0f; } public bool IsDead() => _currentHealth.Value <= 0; public void Heal(int amount) { if (!IsServer) return; int healAmount = Mathf.Min(amount, GetMaxHealth() - _currentHealth.Value); _currentHealth.Value += healAmount; } private void OnHealthChanged(int previousValue, int newValue) { // 체력바 업데이트 if (_healthBar != null) { _healthBar.UpdateHealth(); } if (IsLocalPlayer) { // UI 업데이트 등 } } #endregion #region Health Bar private void CreateHealthBar() { if (_healthBar != null) return; if (healthBarPrefab == null) return; GameObject healthBarObj = Instantiate(healthBarPrefab, transform); _healthBar = healthBarObj.GetComponent(); if (_healthBar != null) { // 플레이어 체력바는 상시 표시 _healthBar.hideWhenFull = false; _healthBar.Initialize(this); } } #endregion #region Gizmos private void OnDrawGizmosSelected() { #if UNITY_EDITOR if (Application.isPlaying) { string teamName = TeamManager.GetTeamName(_team.Value); UnityEditor.Handles.Label(transform.position + Vector3.up * 3f, $"Player: {gameObject.name}\nTeam: {teamName}\nHP: {_currentHealth.Value}/{GetMaxHealth()}"); } else { string teamName = TeamManager.GetTeamName(initialTeam); UnityEditor.Handles.Label(transform.position + Vector3.up * 3f, $"Player: {gameObject.name}\nTeam: {teamName}"); } #endif } #endregion }