Carrot
본문 바로가기
Unity/멋쟁이사자처럼 부트캠프

[천도컴퍼니] 플레이어가 사망했을 때 타 플레이어가 업어가는 로직 구현 (1)

by 독기품은토끼 2025. 11. 25.
✅ 백로그
멋쟁이사자처럼 부트캠프가 종료되고 잠시 휴식시간을 거쳐
다시 천도컴퍼니 프로젝트가 시작되었다.

팀원 중 일부가 개인사정으로 이탈하였기에 기존에 다른 분이 구현하셨던 걸 디벨롭&확장해야하는 상황이 생겼는데
이 중 나는 플레이어 시체 이동 관련된 부분을 담당하기로 하였다.

1. 기존 로직 확인

using UnityEngine;
using System;
using UnityEngine.UI;
using Fusion;
using UnityEngine.InputSystem;
using System.Collections;
using TMPro;
using System.Collections.Generic;

// 서버에 접근한 플레이어를 귀신쪽에서 감지합니다. (GhostCOntroller.FindPlayerRegisteredPlayer에서 사용됨)
public static class ServerPlayerRegistry
{
    // 서버에서만 접근
    public static readonly HashSet<PlayerCondition> Players = new HashSet<PlayerCondition>();
}

public class PlayerCondition : NetworkBehaviour     //, IInteractable 플레이어 사망시 시체를 옮길 때 사용
{
    [Header("Component References")]
    private Animator animator;
    private PlayerController playerController;
    private InventoryManager inventoryManager;
    private AudioListener audioListen;
    private AudioSource audioSource;

    [Header("Health Settings")]
    public int MaxHealth = 2;

    [Header("Sanity Settings")]
    public float MaxSanity = 100f;

    [Header("Injury Settings")]
    public float injurySpeedMultiplier = 0.5f;
    private float originalWalkSpeed;
    private float originalRunSpeed;
    [SerializeField] private Image vignetteOverlay;
    [SerializeField] private float vignetteMaxAlpha = 0.6f;
    [SerializeField] private float vignetteFadeSpeed = 5f;

    [Header("Sound Settings")]
    [SerializeField] private AudioClip[] deathSounds;



    [Networked] public int CurrentHealth { get; set; }
    [Networked] public bool IsDead { get; set; }
    [Networked] public bool IsInjured { get; set; }
    [Networked] public bool IsProtected { get; set; }
    [Networked] public float CurrentSanity { get; set; }
    [Networked] private TickTimer _protectionTimer { get; set; }

    private int _cachedHealth;
    private bool _cachedIsDead;
    private bool _cachedIsInjured;
    private float _cachedSanity;
    [Networked] public NetworkBool isDebuffed { get; set; }
    // private Outline outline;

    [Header("DarkFilter")]
    [SerializeField] private Image darkFilter;
    [SerializeField] private float fadeDuration = 1f;

    private Coroutine darkFadeCoroutine;

    [Header("Hit Effect")]
    [SerializeField] private Image hitEffectOverlay;
    [SerializeField] private float hitEffectFadeInTime = 0.1f;
    [SerializeField] private float hitEffectStayTime = 0.1f;
    [SerializeField] private float hitEffectFadeOutTime = 0.4f;
    [SerializeField][Range(0, 1)] private float hitEffectMaxAlpha = 0.7f;
    private Coroutine hitEffectCoroutine;

    private PlayerInfo playerInfo;

    [Header("DeathCam Settings")]
    [Networked] public NetworkString<_32> Nickname { get; set; }
    private PlayerCameraManager _cameraManager;
    private SpectatorManager _spectatorManager;
    public TextMeshProUGUI overheadNicknameUI;
    private bool _localIsDead = false;

    public static event Action<PlayerCondition, bool> OnLowSanityStatus; // 정신력 30이하용 액션

    private void Awake()
    {
        if (darkFilter != null)
            SetAlpha(0f);
        if (hitEffectOverlay != null)
        {
            Color c = hitEffectOverlay.color;
            c.a = 0;
            hitEffectOverlay.color = c;
        }
        if (vignetteOverlay != null)
        {
            Color vColor = vignetteOverlay.color;
            vColor.a = 0;
            vignetteOverlay.color = vColor;
        }

        animator = GetComponent<Animator>();
        playerController = GetComponent<PlayerController>();
        inventoryManager = GetComponent<InventoryManager>();
        audioListen = GetComponentInChildren<AudioListener>();
        audioSource = GetComponent<AudioSource>();

        if (playerController != null)
        {
            originalWalkSpeed = playerController.walkSpeed;
            originalRunSpeed = playerController.runSpeed;
        }
        _cameraManager = GetComponent<PlayerCameraManager>();
        _spectatorManager = GetComponent<SpectatorManager>();
        // outline = GetComponent<Outline>();
        // if (outline != null) outline.enabled = false;
    }

    public override void Spawned()
    {
        if (Runner.IsServer)
        {
            CurrentHealth = MaxHealth;
            CurrentSanity = MaxSanity;
            IsDead = false;
            IsInjured = false;
            IsProtected = false;
        }

        _cachedHealth = -1;
        _cachedIsDead = true;
        _cachedIsInjured = true;
        _cachedSanity = -1f;

        if (Object.HasInputAuthority)
        {
            RPC_SetNickname($"Player_{Object.InputAuthority.PlayerId}");
        }
        _localIsDead = IsDead;
        RefreshVisuals();

        if (Object.HasStateAuthority)
        {
            ServerPlayerRegistry.Players.Add(this);
        }

        if (!Object.HasInputAuthority && audioListen != null)
            audioListen.enabled = false;

        // if (Object.HasInputAuthority)
        // {
        //     playerInfo = FindFirstObjectByType<PlayerInfo>();

        //     if (playerInfo != null)
        //     {
        //         playerInfo.maxSanity = this.MaxSanity;
        //         playerInfo.UpdateSanityUI(CurrentSanity);
        //     }

        // }
    }

    public override void Despawned(NetworkRunner runner, bool hasState)
    {
        if (Object.HasStateAuthority)
        {
            ServerPlayerRegistry.Players.Remove(this);
        }
    }

    public override void Render()
    {
        RefreshVisuals();

        if (overheadNicknameUI != null && overheadNicknameUI.text != Nickname.ToString())
        {
            overheadNicknameUI.text = Nickname.ToString();
        }
        if (!Object.HasInputAuthority) return;

        if (playerInfo == null && PlayerInfo.Instance != null)
        {
            playerInfo = PlayerInfo.Instance; 
            
            Debug.Log("PlayerInfo 싱글톤 찾음! UI 초기화.");
            playerInfo.maxSanity = this.MaxSanity;
            playerInfo.UpdateSanityUI(CurrentSanity);
            int currentLevel = 0;
            if (CurrentSanity <= 15f) currentLevel = 2;
            else if (CurrentSanity <= 30f) currentLevel = 1;
            
            playerInfo.UpdateSanityEffect(currentLevel);
            _cachedSanityLevel = currentLevel;
        }

        if (IsDead != _localIsDead)
        {
            if (IsDead == true)
            {
                _cameraManager.ActivateDeathCam();
                _spectatorManager.enabled = true;
            }
            _localIsDead = IsDead;
        }
    }

    private void RefreshVisuals()
    {

        if (_cachedHealth != CurrentHealth || _cachedIsInjured != IsInjured)
        {
            if (Object.HasInputAuthority && _cachedHealth != -1)//&& CurrentHealth < _cachedHealth
            {
                ShowHitEffect();
            }

            SetInjuredState(IsInjured);
            _cachedHealth = CurrentHealth;
            _cachedIsInjured = IsInjured;
        }
        if (Object.HasInputAuthority)
        {
            UpdateVignetteEffect();
        }


        if (_cachedIsDead != IsDead)
        {
            SetDeadState(IsDead);
            _cachedIsDead = IsDead;
        }

        if (_cachedSanity != CurrentSanity)
        {
            OnSanityChanged(CurrentSanity, _cachedSanity);
            _cachedSanity = CurrentSanity;
        }
    }

    public override void FixedUpdateNetwork()
    {
        if (Runner.IsServer)
        {
            if (IsProtected && _protectionTimer.Expired(Runner))
            {
                IsProtected = false;
            }
        }
    }

    [Rpc(RpcSources.All, RpcTargets.StateAuthority)]
    public void Rpc_TakeDamage(int amount)
    {
        if (IsDead) return;

        if (IsProtected)
        {
            IsProtected = false;
            Debug.Log($"서버: 플레이어 {Object.Id}가 공격을 막았습니다");
            return;
        }

        if (amount >= MaxHealth)
        {
            Debug.Log($"서버: 플레이어 {Object.Id}가 즉사했습니다.");
            Die();
            return;
        }
        if (IsInjured)
        {
            Debug.Log($"서버: 플레이어 {Object.Id}가 부상 상태에서 공격받아 사망.");
            Die();
        }
        else if (CurrentHealth == 1)
        {
            Debug.Log($"서버: 플레이어 {Object.Id}가 부상 상태가 됨.");
            CurrentHealth = 1;
            IsInjured = true;
            if (playerController != null) playerController.IsInjured = true;
        }
        else
        {
            CurrentHealth -= amount;
            Debug.Log($"서버: 플레이어 {Object.Id}가 데미지 {amount} 받음. 체력: {CurrentHealth}");
        }
    }

    [Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
    public void Rpc_Heal(int amount)
    {
        if (IsDead) return;

        CurrentHealth += amount;
        CurrentHealth = Mathf.Clamp(CurrentHealth, 0, MaxHealth);
        Debug.Log($"서버: 플레이어 {Object.Id}가 체력 {amount} 회복. 체력: {CurrentHealth}");

        if (CurrentHealth > 1 && IsInjured)
        {
            Debug.Log($"서버: 플레이어 {Object.Id}가 부상 상태에서 회복됨.");
            IsInjured = false;
            if (playerController != null) playerController.IsInjured = false;
        }
    }
    [Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
    public void Rpc_HealInjury()
    {
        if (IsDead || !IsInjured) return;
        IsInjured = false;
        if (playerController != null) playerController.IsInjured = false;
        Debug.Log($"서버: 플레이어 {Object.InputAuthority}가 부상을 치료함.");
    }

    [Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
    public void Rpc_ApplyTimedProtection(float duration)
    {
        if (IsDead) return;
        IsProtected = true;
        _protectionTimer = TickTimer.CreateFromSeconds(Runner, duration);
        Debug.Log($"서버: 플레이어 {Object.Id}에게 {duration}초 보호막 부여");
    }

    [Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
    public void Rpc_ApplyProtectionCharges(int amount)
    {
        if (IsDead) return;
        IsProtected = true;
        Debug.Log($"서버: 플레이어 {Object.Id}에게 1회용 보호막 부여");
    }

    private IEnumerator ProtectionCoroutine(float duration)
    {
        IsProtected = true;
        Debug.Log($"{duration}초 동안 보호막을 얻었습니다.");
        yield return new WaitForSeconds(duration);
        if (IsProtected)
        {
            IsProtected = false;
            Debug.Log("보호막이 해제되었습니다.");
        }
    }

    [Rpc(RpcSources.All, RpcTargets.StateAuthority)]
    public void Rpc_DecreaseSanity(float amount)
    {
        if (IsDead) return;
        CurrentSanity -= amount;
        CurrentSanity = Mathf.Clamp(CurrentSanity, 0f, MaxSanity);
    }

    [Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
    public void Rpc_RestoreSanity(float amount)
    {
        if (IsDead) return;
        CurrentSanity += amount;
        CurrentSanity = Mathf.Clamp(CurrentSanity, 0f, MaxSanity);
    }

    private void Die()
    {
        if (!Runner.IsServer || IsDead) return;

        Debug.Log($"서버: 플레이어 {Object.Id} 사망");

        if (inventoryManager != null)
        {
            // Vector3 positionToDrop = inventoryManager.dropPosition.position;
            inventoryManager.Server_DropAllItems();
        }
        IsDead = true;
    }

    private void SetDeadState(bool isDead)
    {
        if (isDead)
        {
            Debug.Log("로컬: 사망 상태가 됨.");
            if (audioSource != null && deathSounds != null && deathSounds.Length > 0)
            {
                int index = UnityEngine.Random.Range(0, deathSounds.Length);
                AudioClip clipToPlay = deathSounds[index];

                if (clipToPlay != null)
                {
                    audioSource.PlayOneShot(clipToPlay);
                }
            }
            if (animator != null) { animator.SetInteger("EquippedItemType", 0); }
            animator.SetTrigger("Die");
            // playerController.enabled = false;
            if (TryGetComponent<PlayerInput>(out var playerInput))
                playerInput.enabled = false;
            if (TryGetComponent<PlayerInteraction>(out var interaction))
                interaction.enabled = false;
            if (Object.HasInputAuthority && playerInfo != null)
            {
                playerInfo.gameObject.SetActive(false);
            }
        }
        else
        {
            if (Object.HasInputAuthority && playerInfo != null)
            {
                playerInfo.gameObject.SetActive(true);
            }
        }
    }

    private void SetInjuredState(bool state)
    {
        // if(playerController != null) playerController.IsInjured = state;
        animator.SetBool("IsInjured", state);

        if (state)
        {
            playerController.IsCrouching = false;
            animator.SetBool("IsCrouch", false);
        }
    }

    private void UpdateVignetteEffect()
    {
        if (vignetteOverlay == null) return;

        Color vColor = vignetteOverlay.color;
        float targetAlpha = (IsInjured && !IsDead) ? vignetteMaxAlpha : 0f;
        vColor.a = Mathf.Lerp(vColor.a, targetAlpha, Time.deltaTime * vignetteFadeSpeed);
        vignetteOverlay.color = vColor;
    }

    public void StartFade(float targetAlpha)
    {
        if (darkFadeCoroutine != null)
            StopCoroutine(darkFadeCoroutine);

        darkFadeCoroutine = StartCoroutine(DarkFadeCoroutine(targetAlpha));
    }

    private IEnumerator DarkFadeCoroutine(float targetAlpha)
    {
        float startAlpha = darkFilter.color.a;
        float elapsed = 0f;

        while (elapsed < fadeDuration)
        {
            elapsed += Time.deltaTime;
            float alpha = Mathf.Lerp(startAlpha, targetAlpha, elapsed / fadeDuration);
            SetAlpha(alpha);
            yield return null;
        }

        SetAlpha(targetAlpha);
    }

    private void SetAlpha(float alpha)
    {
        Color c = darkFilter.color;
        c.a = alpha;
        darkFilter.color = c;
    }

    private int _cachedSanityLevel = 0;
    private void OnSanityChanged(float newSanity, float oldSanity)
    {
        Debug.Log($"로컬: 현재 정신력: {newSanity}%");

        if (Object.HasInputAuthority && playerInfo != null)
        {
            playerInfo.UpdateSanityUI(newSanity);
            int currentLevel = 0;
            if (newSanity <= 15f) currentLevel = 2;
            else if (newSanity <= 30f) currentLevel = 1;


            if (currentLevel != _cachedSanityLevel)
            {
                playerInfo.UpdateSanityEffect(currentLevel);
                _cachedSanityLevel = currentLevel;
            }
        }

        // 정신력 30% 이벤트 로직 (로컬)
        bool wasLowSan = oldSanity <= 30f;
        bool isLowSan = newSanity <= 30f;

        if (!wasLowSan && isLowSan) // 30% 이하 진입
        {
            OnLowSanityStatus?.Invoke(this, true);
        }
        else if (wasLowSan && !isLowSan) // 30% 이상 회복
        {
            OnLowSanityStatus?.Invoke(this, false);
        }
    }

    private void ShowHitEffect()
    {
        if (hitEffectOverlay == null) return;

        if (hitEffectCoroutine != null)
            StopCoroutine(hitEffectCoroutine);

        hitEffectCoroutine = StartCoroutine(HitEffectFadeCoroutine());
    }
    private IEnumerator HitEffectFadeCoroutine()
    {
        float timer = 0f;
        Color color = hitEffectOverlay.color;

        timer = 0f;
        while (timer < hitEffectFadeInTime)
        {
            timer += Time.deltaTime;
            color.a = Mathf.Lerp(0, hitEffectMaxAlpha, timer / hitEffectFadeInTime);
            hitEffectOverlay.color = color;
            yield return null;
        }
        color.a = hitEffectMaxAlpha;
        hitEffectOverlay.color = color;

        yield return new WaitForSeconds(hitEffectStayTime);

        timer = 0f;
        while (timer < hitEffectFadeOutTime)
        {
            timer += Time.deltaTime;
            color.a = Mathf.Lerp(hitEffectMaxAlpha, 0, timer / hitEffectFadeOutTime);
            hitEffectOverlay.color = color;
            yield return null;
        }

        color.a = 0;
        hitEffectOverlay.color = color;
        hitEffectCoroutine = null;
    }
    [Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
    private void RPC_SetNickname(string nickname)
    {
        Nickname = nickname;
    }

    // public void Interact(GameObject interactor) // 플레이어 사망 관련 로직
    // {
    //     Debug.Log($"[PlayerCondition] {interactor.name}이(가) {this.name}과(와) 상호작용 시도.");
    // }

    // public void EnableOutline()
    // {
    //     if (outline != null && IsDead) 
    //     {
    //         outline.enabled = true;
    //     }
    // }

    // public void DisableOutline()
    // {
    //     if (outline != null)
    //     {
    //         outline.enabled = false;
    //     }
    // }

}

 

현재 PlayerCondition 스크립트는 플레이어 상태 전반을 관리하는 매니저 역할을 하고 있다.
대략 아래 기능들이 한 클래스 안에 모두 들어가 있다.

  • 체력 / 부상 / 사망 관리
    • 체력 감소 및 즉사 처리
    • 부상 상태 진입/회복
    • 사망 시 PlayerController 제어(입력/이동 막기)
    • InventoryManager를 통한 인벤토리 드랍
  • 정신력(Sanity) 수치 및 이펙트
    • 정신력 감소/회복
    • 특정 퍼센트 이하/이상 진입 시 이벤트 발생
    • 정신력 단계에 따른 화면 이펙트, UI 갱신
  • 사망 시 카메라/관전자 모드 전환
    • 플레이어가 죽으면 DeathCam 활성화
    • 관전자 모드(SpectatorManager) 활성화
    • PlayerInfo UI 숨김 등
  • 서버 측 플레이어 목록 유지
    • 서버에서 ServerPlayerRegistry를 통해 플레이어 리스트 관리
    • 귀신(적) 쪽에서 이 리스트를 사용해 플레이어 탐색 가능

주석이 거의 없다 보니 정확한 의도가 100% 보이진 않지만....
전체적인 구조를 보면 플레이어의 생존 상태/정신 상태와 그에 따른 연출 전부를 한 곳에서 처리하는 매니저라고 이해했다.

 

 

🚨 문제점

지금 구조에서 가장 크게 느껴지는 문제점은 너무 많은 책임이 한 클래스에 몰려있다는 점이다.

 

현재는 거의 모든 변화가 PlayerCondition에 귀속된다.

  • UI 연출 수정 → PlayerCondition 수정
  • 사운드 변경 → PlayerCondition 수정
  • 사망 조건 변경 → PlayerCondition 수정
  • 정신력 시스템 수정 → PlayerCondition 수정

예를 들어

  • 사망 연출을 타입별로 다르게 만들고 싶다
  • 부상 상태를 “경상 / 중상” 같은 여러 단계로 세분화하고 싶다
  • 정신력 단계에 따른 연출(화면 효과, 사운드 등)을 하나 더 추가하고 싶다

이런 변경을 하고 싶을 때마다 모든 로직을 PlayerCondition 안에 계속 끼워 넣는 방식이 되기 때문에

  • 단일 책임 원칙(SRP)을 위반하고
  • 개방-폐쇄 원칙(OCP)도 만족시키기 어렵고
  • 유지보수/확장 난이도가 점점 올라가는 구조다.

결국 플레이어에 관련된 건 다 PlayerCondition에 우겨 넣는 상황이라고 볼 수 있다.

 

허허 이걸 다 뜯어고치자니 이전에 잘 돌아가던게 안될까봐 걱정이고..

그렇다고 안 뜯어고치면 나중에 유지보수할 때 지옥일 것 같고.. 어떡하지..ㅠㅠ 막막하다..

 

 

🥕 개선 방향

내가 생각한 개선 방향은

우선 PlayerCondition에서 상태와 반응/연출을 분리하여

다른 클래스가 PlayerCondition의 상태를 구독하여 그 상태에 따라 반응/연출하도록 하는 것이 좋지 않을까 생각한다. (옵저버 패턴 ?)

 

public event Action OnDied;

 

 

이렇게 죽음 관련된 이벤트 변수를 만들어 놓고

 

private void Die()
{
    if (!Runner.IsServer || IsDead) return;

    IsDead = true;
    OnDied?.Invoke();
}

 

죽음 메서드는 그대로 활용하고

 

void Start()
{
    playerCondition.OnDied += HandleDeath;
}

void HandleDeath()
{
    // 애니메이션, UI, 카메라, 사운드 등
}

 

다른 클래스에서 PlayerCondition을 구독하여 이에 따라 로직을 처리하는 방향으로 개선하자는 뜻이다!

 

이부분은 일단 개인적인 생각이고.. 팀원간 회의를 진행한 다음 바꾸는 방식으로 하던가,

당장 내가 구현해야할 부분만이라도 이렇게 분리해서 구현해보려고 한다.

 

2. 시체 옮기기 기능

public class PlayerCondition : NetworkBehaviour, IInteractable
{
    // .. 생략
    
    // 시체 운반 상태 체크용
    [Networked] public NetworkBool IsBeingCarried { get; set; }
    [Networked] public NetworkObject Carrier { get; set; }

    // 생략 ...

    public override void Spawned()
    {
        if (Runner.IsServer)
        {
            // 생략 ...

            IsBeingCarried = false;
            Carrier = null;
        }

        // 생략 ...
    }
    
    // 생략 ...

    public void Interact(GameObject interactor)
    {
        Debug.Log($"[PlayerCondition] {interactor.name}이(가) {this.name}과(와) 상호작용 시도.");

        // 살아있으면 시체 상호작용 X
        if (!IsDead)
            return;

        // 이미 누가 들고 있으면 또 들 수 없음
        if (IsBeingCarried)
            return;

        if (!interactor.TryGetComponent<CorpseCarryHandler>(out var carrier))
            return;

        carrier.TryRequestCarry(this);
    }
}

 

우선 PlayerConditon 클래스에서

시체 운반 상태를 체크할 수 있도록 네트워크 변수를 만들어 주었고

이제 이부분을 인터페이스를 활용해서 이벤트를 호출하도록 구현해주었다.

 

using Fusion;
using UnityEngine;
using static Unity.Collections.Unicode;

/// <summary>
/// 플레이어가 죽은 다른 플레이어를 업어서 옮기는 기능을 담당.
/// - 상태(죽었냐, 들려 있냐)는 PlayerCondition이 가짐
/// - 이 스크립트는 살아있는 플레이어가 시체를 들고 다니는 행위만 담당
/// </summary>
[RequireComponent(typeof(NetworkObject))]
[RequireComponent(typeof(PlayerCondition))]
public class CorpseCarryHandler : NetworkBehaviour
{
    [SerializeField] private Transform carryPoint; // 시체를 붙일 위치

    private PlayerCondition _playerCondition;

    // 플레이어가 들고 있는 시체(죽은 PlayerCondition이 붙어 있는 NetworkObject)
    [Networked] public NetworkObject CarriedCorpse { get; set; }

    public bool IsCarryingCorpse => CarriedCorpse != null;

    public override void Spawned()
    {
        _playerCondition = GetComponent<PlayerCondition>();

        if (Runner.IsServer)
        {
            CarriedCorpse = null;
        }

        if (carryPoint == null)
        {
            Debug.Log($"[CorpseCarryHandler] {name} 의 carryPoint가 설정되지 않았습니다.");
        }
    }

    /// <summary>
    /// 시체를 업고 싶을 때 호출
    /// PlayerCondition.Interact에서 이 메서드를 호출
    /// </summary>
    public void TryRequestCarry(PlayerCondition corpseCondition)
    {
        if (corpseCondition == null)
            return;

        if (_playerCondition == null)
            _playerCondition = GetComponent<PlayerCondition>();

        // 내가 시체이면 안 됨
        if (_playerCondition.IsDead)
            return;

        // 이미 다른 시체 들고 있으면 안 됨
        if (IsCarryingCorpse)
            return;

        // 대상이 실제로 죽은 상태인지, 이미 누가 들고 있는지 체크
        if (!corpseCondition.IsDead || corpseCondition.IsBeingCarried)
            return;

        // 서버에게 이 시체 들게 해달라고 요청
        RPC_RequestStartCarry(corpseCondition.Object);
    }

    /// <summary>
    /// 시체 내려놓기 요청
    /// 드랍 키 입력에서 이 메서드를 호출
    /// </summary>
    public void TryRequestDrop()
    {
        if (!IsCarryingCorpse)
            return;

        RPC_RequestStopCarry();
    }

    #region 시체 운반 멀티 적용
    [Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
    private void RPC_RequestStartCarry(NetworkObject corpseObject)
    {
        if (!Object || !Object.HasStateAuthority)
            return;

        if (_playerCondition == null)
            _playerCondition = GetComponent<PlayerCondition>();

        if (_playerCondition.IsDead)
            return;

        if (IsCarryingCorpse)
            return;

        if (corpseObject == null)
            return;

        var corpseCondition = corpseObject.GetComponent<PlayerCondition>();
        if (corpseCondition == null)
            return;

        if (!corpseCondition.IsDead || corpseCondition.IsBeingCarried)
            return;

        // 상태 세팅
        CarriedCorpse = corpseObject;
        corpseCondition.IsBeingCarried = true;
        corpseCondition.Carrier = Object;   // PlayerCondition 쪽 Networked NetworkObject
    }

    [Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
    private void RPC_RequestStopCarry()
    {
        if (!Object || !Object.HasStateAuthority)
            return;

        if (!IsCarryingCorpse)
            return;

        var corpseCondition = CarriedCorpse.GetComponent<PlayerCondition>();
        ClearCorpseLink(corpseCondition);
    }
    #endregion

    public override void FixedUpdateNetwork()
    {
        if (!Object || !Object.HasStateAuthority)
            return;

        if (!IsCarryingCorpse)
            return;

        if (carryPoint == null)
            return;

        var corpseCondition = CarriedCorpse.GetComponent<PlayerCondition>();

        if (corpseCondition == null)
        {
            // 레퍼런스 깨짐
            CarriedCorpse = null;
            return;
        }

        // 시체가 이제 더 이상 죽은 상태가 아니거나, 들려있지 않다고 표시되면 링크 해제
        if (!corpseCondition.IsDead || !corpseCondition.IsBeingCarried || corpseCondition.Carrier != Object)
        {
            ClearCorpseLink(corpseCondition);
            return;
        }

        // 실제 위치/회전 붙이기 (서버 기준)
        corpseCondition.transform.position = carryPoint.position;
        corpseCondition.transform.rotation = carryPoint.rotation;
    }

    /// <summary>
    /// 시체 상태 초기화
    /// 업은 사람 쪽 상태 초기화
    /// => 누가 누구를 들고 있다는 상태가 꼬이지 않도록 하기 위함
    /// </summary>
    private void ClearCorpseLink(PlayerCondition corpseCondition)
    {
        if (corpseCondition != null)
        {
            corpseCondition.IsBeingCarried = false;
            corpseCondition.Carrier = null;
        }

        CarriedCorpse = null;
    }
}

 

그리고 상태에 따라 시체를 업고, 내려놓는 로직은 이렇게 구현하였다.

 

테스트를 해봐야되는데 갑자기 씬이 넘어가지 않는 문제가 있어서 오늘은 여기까지만 작업하고 마무리..!