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

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(65일차) - 플레이어 찾기 게임 (2)

by 독기품은토끼 2025. 8. 20.
✅ 오늘의 학습 목표
1. 플레이어 찾기 게임 (2)

1. NPC 스폰

NPC를 랜덤 위치에 생성하려고 한다.

using System.Collections;
using Photon.Pun;
using UnityEngine;

public class Find_GameManager : Singleton<Find_GameManager>
{
    IEnumerator Start()
    {
        float randomTime = Random.Range(0f, 1f);
        yield return new WaitForSeconds(randomTime);
        
        int ranIndex = Random.Range(0, 5); // 0, 1, 2, 3, 4
        
        var randomPos = new Vector3(Random.Range(-5f, 5f), 0, Random.Range(-5f, 5f));
        
        PhotonNetwork.Instantiate("Player_" + ranIndex, randomPos, Quaternion.identity);
    }
}

 

하지만 이 코드는 동기화 작업을 해주지 않았기 때문에 플레이어 별로 NPC의 위치나 인덱스 값이 다르다.

이럴 경우에는 IsMasterClient를 사용하여 마스터 클라이언트에서 최초 실행될 수 있게 만들어주면 된다.

 

using System.Collections;
using Photon.Pun;
using UnityEngine;

public class SpawnManager : MonoBehaviour
{
    [SerializeField] private GameObject[] npcPrefabs;
    [SerializeField] private int npcAmount = 10;

    IEnumerator Start()
    {
        yield return null;

        if (PhotonNetwork.IsMasterClient)
        {
            for (int i = 0; i < npcAmount; i++)
            {
                int ranIndex = Random.Range(0, npcPrefabs.Length);
                Vector3 ranPos = new Vector3(Random.Range(-10f, 10f), 0, Random.Range(-10f, 10f));
            
                GameObject npc = PhotonNetwork.Instantiate("Npc_" + ranIndex, ranPos, Quaternion.identity);
                npc.transform.SetParent(transform);

                yield return new WaitForSeconds(0.1f);
            }
        }
    }
}

 

🚨 PhotonNetwork.Instantiate로 생성하는 경우 프리팹이 Resource 폴더 안에 들어가 있어야지만 제대로 생성되는 점을 유의할 것

 

2. 공격 기능

NPC를 공격하는 경우와

플레이어를 공격하는 경우의 이벤트 처리를 달리 하려고 한다.

  • NPC 타격 : Death 애니메이션만 실행
  • Player 타격 : 애니메이션 실행 + Score 상승
using System;
using UnityEngine;

public class HitboxEvent : MonoBehaviour
{
    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Npc"))
        {
            other.GetComponent<AgentController>().GetHit();
        }
        else if (other.CompareTag("Player"))
        {
            other.GetComponent<Find_PlayerController>().GetHit();
        }
    }
}
using System.Collections;
using Photon.Pun;
using UnityEngine;
using UnityEngine.AI;

public class AgentController : MonoBehaviourPun
{
    private NavMeshAgent agent;
    private Animator anim;

    [SerializeField] private float wanderRadius = 30f;

    private float minWaitTime = 1f;
    private float maxWaitTime = 5f;

    [SerializeField] private float turnSpeed = 10f;

    private bool isDead = false;

    private void Awake()
    {
        agent = GetComponent<NavMeshAgent>();
        anim = GetComponent<Animator>();

        agent.updateRotation = false;
    }

    void Start()
    {
        if (PhotonNetwork.IsMasterClient)
            StartCoroutine(WanderRoutine());
    }

    IEnumerator WanderRoutine()
    {
        while (!isDead)
        {
            var randomDir = Random.insideUnitSphere * wanderRadius;
            randomDir += transform.position;
            
            photonView.RPC(nameof(SetDestination), RpcTarget.AllBuffered, randomDir);
            
            float moveType = Random.Range(0, 2) == 0 ? 0.5f : 1f;
            anim.SetFloat("Speed", moveType); // 이동 애니메이션
            agent.speed = moveType * 4f; // 2 or 4
            
            yield return new WaitUntil(() => !isDead && !agent.pathPending && agent.remainingDistance <= agent.stoppingDistance);
            
            anim.SetFloat("Speed", 0f); // 정지 애니메이션

            float idleTime = Random.Range(minWaitTime, maxWaitTime);
            yield return new WaitForSeconds(idleTime);
        }
    }

    void Update()
    {
        if (isDead)
            return;
        
        Vector3 dir = agent.desiredVelocity;
        if (dir != Vector3.zero)
        {
            Quaternion targetRot = Quaternion.LookRotation(dir);
            transform.rotation = Quaternion.Slerp(transform.rotation, targetRot, turnSpeed * Time.deltaTime);
        }
    }
    
    [PunRPC]
    private void SetDestination(Vector3 dir)
    {
        NavMeshHit hit;
        if (NavMesh.SamplePosition(dir, out hit, wanderRadius, NavMesh.AllAreas))
        {
            if (!isDead)
                agent.SetDestination(hit.position);
        }
    }

    public void GetHit()
    {
        photonView.RPC(nameof(Dead), RpcTarget.AllBuffered);
    }

    [PunRPC]
    private void Dead()
    {
        isDead = true;
        GetComponent<Collider>().enabled = false;
        anim.SetTrigger("Death");
        agent.updatePosition = false;
        agent.isStopped = true;
        StopAllCoroutines();
    }
}
using System.Collections;
using Photon.Pun;
using StarterAssets;
using Unity.Cinemachine;
using UnityEngine;
using UnityEngine.InputSystem;

public class Find_PlayerController : MonoBehaviourPun
{
    private Animator anim;

    [SerializeField] private Transform playerRoot;
    
    [SerializeField] private GameObject punchBox;
    [SerializeField] private GameObject kickBox;
    
    private bool isAttack = false;
    private bool isDead = false;

    void Awake()
    {
        anim = GetComponent<Animator>();
    }

    void Start()
    {
        if (photonView.IsMine)
        {
            var followCamera = FindFirstObjectByType<CinemachineCamera>();
            followCamera.Target.TrackingTarget = playerRoot;
        }
        else
        {
            GetComponent<PlayerInput>().enabled = false;
        }
    }

    void OnPunch()
    {
        if (!isAttack && !isDead)
            photonView.RPC(nameof(RPC_Punch), RpcTarget.All);
    }

    [PunRPC]
    private void RPC_Punch()
    {
        StartCoroutine(PunchRoutine());
    }

    IEnumerator PunchRoutine()
    {
        isAttack = true;
        anim.SetTrigger("Punch");
        yield return new WaitForSeconds(0.5f);
        punchBox.SetActive(true);
        
        yield return new WaitForSeconds(0.3f);
        punchBox.SetActive(false);
        isAttack = false;
    }

    void OnKick()
    {
        if (!isAttack && !isDead)
            photonView.RPC(nameof(RPC_Kick), RpcTarget.All);
    }

    [PunRPC]
    private void RPC_Kick()
    {
        StartCoroutine(KickRoutine());
    }
    
    IEnumerator KickRoutine()
    {
        isAttack = true;
        anim.SetTrigger("Kick");
        yield return new WaitForSeconds(0.6f);
        kickBox.SetActive(true);

        yield return new WaitForSeconds(0.2f);
        kickBox.SetActive(false);
        isAttack = false;
    }

    public void GetHit()
    {
        photonView.RPC(nameof(Dead), RpcTarget.AllBuffered);
    }
    
    [PunRPC]
    private void Dead()
    {
        isDead = true;
        anim.SetTrigger("Death");
        GetComponent<CharacterController>().enabled = false;
        GetComponent<Collider>().enabled = false;
        GetComponent<ThirdPersonController>().enabled = false;
    }
}

 

[충돌 처리]

현재 캐릭터 오브젝트에는 Character Controller 컴포넌트가 이미 붙어 있기 때문에
추가로 Rigidbody를 넣어 충돌 이벤트를 처리하는 방식은 적합하지 않다.

하지만 단순히 충돌 감지(Event)만 필요하다면 Rigidbody를 넣더라도 IsKinematic을 활성화해서 물리 시뮬레이션에 영향을 주지 않고 이벤트만 받아낼 수 있다.

 

참고로 동기화를 위하여 NPC와 Player 모두 MonoBehaviourPun을 상속시켜 주었다.

 

[photonView.RPC(nameof(Dead), RpcTarget.AllBuffered);]

RpcTarget은 RPC를 보낼 때 "누구한테 전달할지"를 정하는 메소드이다.

 

  • All → 그냥 모두 실행
  • Others → 나 빼고 다 실행
  • MasterClient → 방장만 실행
  • AllBuffered → 모두 + 나중에 들어오는 사람도 실행
  • OthersBuffered → 나 빼고 다 + 나중에 들어오는 사람도 실행
  • AllViaServer / OthersViaServer → 서버를 거쳐 안정적 전달

플레이어의 죽음을 나타내는, 즉 한 번만 실행하는 이벤트의 경우 AllBuffered을 사용한다.

 

 

3. 닉네임

플레이어가 죽었을 때 관전 시점을 만들어주려고 한다.

관전 시점에서는 탑뷰에서 타 플레이어의 동향을 확인할 수 있도록 닉네임이 보이게 해 줄 것이다.

 

 

우선 플레이어의 닉네임에 Layer을 설정해 준다.

 

 

씬에 생성한 닉네임을 평소에는 표시하지 않기 위하여

Camera의 Culling Mask를 활용해주려고 한다.

 

🚨 Culling Mask란?

  • 카메라가 어떤 레이어의 오브젝트를 렌더링 할지 선택하는 필터 역할
  • 기본값은 Everything → 씬의 모든 레이어가 다 보임
  • 특정 레이어만 보이게 하려면 원하는 레이어를 체크해 주면 된다.

 

 

탑뷰 화면을 만들기 위해서 새로운 씨네머신 카메라도 생성해 주었다.

 

public class Find_PlayerController : MonoBehaviourPun
{
    private Camera mainCamera;

    [SerializeField] private TextMeshPro nickNameUI;

    void Awake()
    {
        mainCamera = Camera.main;
    }

    void Start()
    {
        if (photonView.IsMine)
        {
            nickNameUI.text = PhotonNetwork.NickName;
            nickNameUI.color = Color.green;

            var followCamera = FindFirstObjectByType<CinemachineCamera>();
            followCamera.Target.TrackingTarget = playerRoot;
        }
        else
        {
            nickNameUI.text = photonView.Owner.NickName;
            nickNameUI.color = new Color(Random.Range(0f, 1f), Random.Range(0f, 1f), Random.Range(0f, 1f));

            GetComponent<PlayerInput>().enabled = false;
        }
    }

    [PunRPC]
    private void Dead()
    {
        isDead = true;
        anim.SetTrigger("Death");
        GetComponent<CharacterController>().enabled = false;
        GetComponent<Collider>().enabled = false;
        GetComponent<ThirdPersonController>().enabled = false;

        if (photonView.IsMine)
        {
            mainCamera.cullingMask |= (1 << 9);
            Find_GameManager.Instance.SetObserver();
        }
    }
}
public class Find_GameManager : Singleton<Find_GameManager>
{
    [SerializeField] private GameObject observerCamera;

    public void SetObserver()
    {
        observerCamera.SetActive(true);
    }
}

 

4. Score 구현

랜덤하게 생성된 캐릭터 오브젝트 중에서 NPC가 아닌 플레이어를 공격하였을 때에만 점수를 상승시키도록 구현해 줄 것이다.

using System;
using Photon.Pun;
using UnityEngine;

public class HitboxEvent : MonoBehaviour
{
    private PhotonView myPv;

    void Awake()
    {
        myPv = transform.root.GetComponent<PhotonView>();
    }

    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Npc"))
        {
            other.GetComponent<AgentController>().GetHit();
        }
        else if (other.CompareTag("Player"))
        {
            other.GetComponent<Find_PlayerController>().GetHit();

            if (myPv.IsMine)
            {
                bool isWinner = Find_GameManager.Instance.SetScore();

                if (isWinner)
                {
                    string nickName = myPv.Owner.NickName;
                    myPv.RPC("Winner", RpcTarget.AllBuffered, nickName);
                }
            }
        }
    }
}
public class Find_PlayerController : MonoBehaviourPun
{
    [PunRPC]
    public void Winner(string nickName)
    {
        Find_GameManager.Instance.EndGame(nickName);
    }
}
public class Find_GameManager : Singleton<Find_GameManager>
{
    [SerializeField] private TextMeshProUGUI scoreUI;
    [SerializeField] private TextMeshProUGUI winnerUI

    private int score;
    [SerializeField] private int winnerScore = 2;
    
    IEnumerator Start()
    {
        float randomTime = Random.Range(0f, 1f);
        yield return new WaitForSeconds(randomTime);
        
        int ranPoint = Random.Range(0, spawnPoints.Length);
        int ranIndex = Random.Range(0, 5); // 0, 1, 2, 3, 4
        
        var randomPos = new Vector3(Random.Range(-5f, 5f), 0, Random.Range(-5f, 5f));
        var spawnPos = spawnPoints[ranPoint].position + randomPos;
        
        PhotonNetwork.Instantiate("Player_" + ranIndex, spawnPos, Quaternion.identity);

        scoreUI.text = $"현재 스코어는 0 입니다.";
    }

    public bool SetScore()
    {
        score++;
        scoreUI.text = $"현재 스코어는 {score} 입니다.";

        if (score >= winnerScore)
            return true;

        return false;
    }
    
    public void EndGame(string nickName)
    {
        Fade.onFadeAction(3f, Color.white, true, () =>
        {
            winnerUI.text = $"{nickName}이(가) 승자입니다!!";
            winnerUI.gameObject.SetActive(true);
        });
    }
}