Files
Northbound/Assets/Scripts/NetworkPlayerController.cs

706 lines
21 KiB
C#

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<ulong> _ownerPlayerId = new NetworkVariable<ulong>(
ulong.MaxValue,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
private NetworkVariable<TeamType> _team = new NetworkVariable<TeamType>(
TeamType.Player,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
private NetworkVariable<int> _currentHealth = new NetworkVariable<int>(
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<ulong> OnOwnerChanged;
// 입력 시스템 초기화 완료 이벤트
public event Action OnInputInitialized;
void Awake()
{
_controller = GetComponent<CharacterController>();
_animator = GetComponent<Animator>();
_networkAnimator = GetComponent<NetworkAnimator>();
_playerStats = GetComponent<PlayerStats>();
}
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<RespawnCountdownUI>();
}
// 체력바 생성
if (showHealthBar && healthBarPrefab != null)
{
CreateHealthBar();
}
// 이미 로컬 플레이어로 설정되어 있으면 입력 초기화
TryInitializeLocalPlayer();
}
private void OnOwnerPlayerIdChanged(ulong previousValue, ulong newValue)
{
OnOwnerChanged?.Invoke(newValue);
TryInitializeLocalPlayer();
// 로컬 플레이어가 되면 리스폰 UI 참조 가져오기
if (IsLocalPlayer && _respawnCountdownUI == null)
{
_respawnCountdownUI = FindFirstObjectByType<RespawnCountdownUI>();
}
}
private void TryInitializeLocalPlayer()
{
if (!IsLocalPlayer) return;
if (_inputActions != null) return; // 이미 초기화됨
var vcam = GameObject.FindFirstObjectByType<CinemachineCamera>();
if (vcam != null)
{
vcam.Follow = transform;
vcam.LookAt = transform;
}
_inputActions = new PlayerInputActions();
_inputActions.Enable();
// 입력 초기화 완료 이벤트 발생
OnInputInitialized?.Invoke();
}
/// <summary>
/// 플레이어 초기화 (서버에서 호출)
/// </summary>
public void Initialize(ulong ownerPlayerId)
{
if (!IsServer) return;
_ownerPlayerId.Value = ownerPlayerId;
}
/// <summary>
/// 입력 강제 복구 (입력이 멈췄을 때 호출)
/// </summary>
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<AttackAction>();
var playerInteraction = GetComponent<PlayerInteraction>();
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<Vector2>();
// 일정 간격으로만 서버에 전송 (네트워크 부하 감소)
_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<ITeamMember>();
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);
}
}
/// <summary>
/// 플레이어가 가진 자원을 ResourcePickup으로 드랍
/// </summary>
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<NetworkPlayerController>();
if (controller != null && controller.OwnerPlayerId == playerId)
{
var inventory = kvp.Value.GetComponent<PlayerResourceInventory>();
if (inventory != null)
{
inventory.RequestResourceUpdateServerRpc(playerId);
break;
}
}
}
// ResourcePickup 프리팹 생성
GameObject pickupObj = Instantiate(resourcePickupPrefab, transform.position + Vector3.up, Quaternion.identity);
NetworkObject pickupNetworkObj = pickupObj.GetComponent<NetworkObject>();
ResourcePickup pickup = pickupObj.GetComponent<ResourcePickup>();
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);
}
/// <summary>
/// 사망 시 플레이어 숨김
/// </summary>
[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<Renderer>();
foreach (Renderer renderer in renderers)
{
renderer.enabled = false;
}
}
/// <summary>
/// 리스폰 시 플레이어 다시 보임
/// </summary>
[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<Renderer>();
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<PlayerSpawnPoint>(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;
/// <summary>
/// 전투 상태 기록 (데미지를 받거나 입힐 때 호출)
/// </summary>
public void MarkInCombat()
{
_lastCombatTime = Time.time;
}
/// <summary>
/// 현재 전투 중인지 확인 (회복 대기 시간이 지났는지)
/// </summary>
public bool IsInCombat()
{
if (_playerStats == null) return false;
float regenDelay = _playerStats.GetHpRegenDelay();
return (Time.time - _lastCombatTime) < regenDelay;
}
/// <summary>
/// 서버에서 체력 자연 회복 처리
/// </summary>
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<UnitHealthBar>();
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
}