본문 바로가기
Unity Boot Camp

Udemy STARTERS (유데미 스타터스) Unity 취업 부트캠프 20주차 - Among Us 미니 게임 제작

by 개발하는 디토 2022. 11. 6.

이번주는 어몽어스에 통신 기능을 추가해보았다. 과제로는 미니게임을 추가로 만들어보았다.

 

 

동기화 되는 캐릭터 생성 Script

GameSetup.cs

이 Script가 달린 빈 오브젝트를 게임 scene에 배치

Tip) Path.Combine() 사용해 Resources 폴더 내의 폴더 안에 있는 오브젝트도 생성 가능함

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

public class GameSetup : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        PlayerSpawn();
    }

    private void PlayerSpawn()
    {
        // Create an object with Photon View
        PhotonNetwork.Instantiate(Path.Combine("Prefabs", "PhotonPlayer"), Vector3.zero, Quaternion.identity);
    }
}

 

PhotonPlayer.cs

플레이어가 들어올 때 PhotonNetwork.Instantiate를 이용해 캐릭터를 생성한다. Photon View, Photon View Transform, Animation 등이 달려있는 Prefab을 이용해 생성하므로 이런 방식으로 생성하면 알아서 움직임이나 애니메이션은 동기화가 된다.

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

public class PhotonPlayer : MonoBehaviour
{
    PhotonView pv;
    GameObject avatar;

    // Start is called before the first frame update
    void Start()
    {
        pv = GetComponent<PhotonView>();
        if (pv.IsMine)
        {
            //if pv is mine, create actual character
            avatar = PhotonNetwork.Instantiate(Path.Combine("Prefabs", "AU_Player"), Vector3.zero, Quaternion.identity);
        }
    }
}

 

 

빛 동기화

각 사용자(클라이언트)는 본인이 움직이는 플레이어(pv.IsMine true일 때)의 카메라만 사용하므로 그 외의 카메라는 꺼주고 Light Mask, Light Caster 등 역시 꺼주어야 빛 효과 제대로 적용된다. 아래 AU_PlayerController.cs의 Start() if (!pv.IsMine) 부분 참고.

 

움직임 및 Sprite 방향 동기화

Photon View component를 pv라는 변수로 받아 기존에 bool hasControl로 판단하던 부분을 pv.IsMine으로 판단한다. pv.IsMine == true 조건을 걸면, 다른 클라이언트의 게임 화면에서도 내 캐릭터만 조작에 따른 움직임이나 변화를 적용할 수 있다. 모든 캐릭터가 AU_PlayerController component를 가지고 있으므로 pv.IsMine이라는 조건을 걸어놓지 않으면 내 키보드를 눌러 내 캐릭터만 움직이려고 했을 때 다른 클라이언트의 캐릭터도 함께 움직인다.

IPunObservable이라는 interface를 받아 OnPhotonSerializeView()를 작성한다. OnPhotonSerializeView는 변수를 실시간으로 동기화할 수 있게 도와주는 함수이다. stream.IsWriting일 때 기록해야 할 변수를 stream.SendNext()로 저장하고 다른 클라이언트에서는 stream.ReceiveNext()로 이 정보를 읽어온다. 

AU_PlayerController.cs

// AU_PlayerController.cs

using Photon.Pun;

// Sprite 바라보는 방향 동기화 위해 Photon.Pun.IPunObservable 필요
public class AU_PlayerController : MonoBehaviour, IPunObservable
{
    [Header("Network")]
    PhotonView pv;
    [SerializeField] GameObject LightMask;
    [SerializeField] lightcaster LightCaster;
		
	[Header("Movement")]    
    float direction = 1;
		
    void Start()
    {
        pv = GetComponent<PhotonView>();
        //if (hasControl) localPlayer = this;
        if (pv.IsMine) localPlayer = this;

        // Get camera
        myCamera = transform.GetChild(1).GetComponent<Camera>();
        //if (hasControl) 
        if(pv.IsMine)
        {             
            rtControl = FindObjectOfType<RenderTextureControl>();
            rtControl.rtCam = myCamera.transform.GetChild(0).GetComponent<Camera>();
            rtControl.Resize(rtControl.rt, Screen.width, Screen.height);
        }
        
        //if (!hasControl) return; // do not change other characters
        if (!pv.IsMine)
        {
            myCamera.gameObject.SetActive(false);
            LightCaster.enabled = false;
            LightMask.SetActive(false);
            return;
        }
    }
    void Update()
    {
        avatar.localScale = new Vector2(direction, 1);
        if (!pv.IsMine) return; // do not move other characters
        //if (!hasControl) return; 

        // change character scale to make player look at left or right
        mousePos = MOUSE.ReadValue<Vector2>(); // originally Vector3 but z value is always zero, so only x, y values are used
        movementInput = Move.ReadValue<Vector2>();
        if (movementInput.x != 0)
        {
            //avatar.localScale = new Vector2(Mathf.Sign(movementInput.x), 1); // movementInput.x가 음수면 -1, Sprite 이미지가 왼쪽 보게 함
            direction = Mathf.Sign(movementInput.x);
        }

        animator.SetFloat("Speed", movementInput.magnitude); // magnitude 이용해 vector의 길이 반환
       
        if (allBodies.Count > 0) SearchBody();
    }

    // Direction Sync
    public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
    {
        if (stream.IsWriting)
        {
            stream.SendNext(direction);
        }
        else
        {
            this.direction = (float)stream.ReceiveNext();
        }
    }
}

 

AU_PlayerController.cs 전체

더보기
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
using Photon.Pun;

public class AU_PlayerController : MonoBehaviour, IPunObservable
{
    RenderTextureControl rtControl;

    [Header("Network")]
    PhotonView pv;
    [SerializeField] GameObject LightMask;
    [SerializeField] lightcaster LightCaster;


    [Header("Movement")]
    Rigidbody rb;
    Transform avatar;
    Animator animator;
    [SerializeField] InputAction Move;
    Vector2 movementInput;
    [SerializeField] float movementSpeed;
    float direction = 1;

    [Header("Color")]
    [SerializeField] bool hasControl;
    public static AU_PlayerController localPlayer;
    static Color myColor;
    SpriteRenderer myAvatarSprite;

    [Header("Death")]
    [SerializeField] bool isImposter;
    [SerializeField] InputAction KILL;
    List<AU_PlayerController> targets;
    [SerializeField] Collider myCollider;
    [SerializeField] Collider triggerCollider;
    bool isDead;
    [SerializeField] GameObject bodyPrefab; // dead body prefab

    [Header("Dead Report")]
    public static List<Transform> allBodies; // List of all dead bodies
    List<Transform> bodiesFound; // Found dead bodies
    [SerializeField] InputAction REPORT;
    [SerializeField] LayerMask ignoreForBody;


    [Header("Interact With Items")]
    [SerializeField] InputAction MOUSE;
    Vector2 mousePos;
    Camera myCamera;

    [SerializeField] InputAction INTERACTION;
    [SerializeField] LayerMask interactLayer;

    private void Awake()
    {
        KILL.performed += KillTarget; // execute function when KILL action is done
        REPORT.performed += ReportBody;
        INTERACTION.performed += Interact;
    }

    private void OnEnable()
    {
        Move.Enable();
        KILL.Enable();
        REPORT.Enable();
        MOUSE.Enable();
        INTERACTION.Enable();
    }
    private void OnDisable()
    {
        Move.Disable();
        KILL.Disable();
        REPORT.Disable();
        MOUSE.Disable();
        INTERACTION.Disable();
    }

    void Start()
    {
        pv = GetComponent<PhotonView>();
        //if (hasControl) localPlayer = this;
        if (pv.IsMine) localPlayer = this;


        targets = new List<AU_PlayerController>();
        allBodies = new List<Transform>();
        bodiesFound = new List<Transform>();

        rb = GetComponent<Rigidbody>();
        avatar = transform.GetChild(0); // player sprite
        animator = GetComponent<Animator>();
        // collider to kill others -> only for Imposter
        triggerCollider = GetComponent<SphereCollider>();
        if (isImposter) triggerCollider.enabled = true;

        // Get camera
        myCamera = transform.GetChild(1).GetComponent<Camera>();
        //if (hasControl) 
        if(pv.IsMine)
        {             
            rtControl = FindObjectOfType<RenderTextureControl>();
            rtControl.rtCam = myCamera.transform.GetChild(0).GetComponent<Camera>();
            rtControl.Resize(rtControl.rt, Screen.width, Screen.height);
        }
        

        myAvatarSprite = avatar.GetComponent<SpriteRenderer>();
        if (myColor == Color.clear) myColor = Color.white; // if empty, set it as default, white
        //if (!hasControl) return; // do not change other characters
        if (!pv.IsMine)
        {
            myCamera.gameObject.SetActive(false);
            LightCaster.enabled = false;
            LightMask.SetActive(false);
            return;
        }
        myAvatarSprite.color = myColor;
    }

    // Update is called once per frame
    void Update()
    {
        avatar.localScale = new Vector2(direction, 1);
        if (!pv.IsMine) return; // do not move other characters
        //if (!hasControl) return; 

        // change character scale to make player look at left or right
        mousePos = MOUSE.ReadValue<Vector2>(); // originally Vector3 but z value is always zero, so only x, y values are used
        movementInput = Move.ReadValue<Vector2>();
        if (movementInput.x != 0)
        {
            //avatar.localScale = new Vector2(Mathf.Sign(movementInput.x), 1); // movementInput.x가 음수면 -1, Sprite 이미지가 왼쪽 보게 함
            direction = Mathf.Sign(movementInput.x);
        }

        animator.SetFloat("Speed", movementInput.magnitude); // magnitude 이용해 vector의 길이 반환
       
        if (allBodies.Count > 0) SearchBody();
    }

    private void FixedUpdate()
    {
        rb.velocity = movementInput * movementSpeed;
    }

    public void SetColor(Color newColor)
    {
        myColor = newColor;
        if (myAvatarSprite != null) myAvatarSprite.color = myColor;
    }
    public void SetRole(bool newRole)
    {
        isImposter = newRole;
    }
    #region Kill

    private void OnTriggerEnter(Collider other)
    {
        if(other.tag == "Player")
        {
            if (isImposter)
            {
                AU_PlayerController tempTarget = other.GetComponent<AU_PlayerController>();
                if (tempTarget.isImposter) return;
                else
                {
                    targets.Add(tempTarget);
                }
            }
            
        }
    }
    private void OnTriggerExit(Collider other)
    {
        if(other.tag == "Player")
        {
            AU_PlayerController tempTarget = other.GetComponent<AU_PlayerController>();
            if (targets.Contains(tempTarget)) targets.Remove(tempTarget);
        }
    }

    void KillTarget(InputAction.CallbackContext context)
    {
        if(context.phase == InputActionPhase.Performed)
        {
            if (targets.Count == 0) return;
            else
            {
                if (targets[targets.Count-1].isDead) return;
                else
                {
                    // Kill starts from last target
                    //Debug.Log(targets[targets.Count - 1].name);
                    transform.position = targets[targets.Count - 1].transform.position;
                    targets[targets.Count - 1].Die();
                    targets.RemoveAt(targets.Count - 1);
                    return;
                }
                
            }
        }
    }

    public void Die()
    {
        // set dead body color
        AU_Body tempBody = Instantiate(bodyPrefab, transform.position, transform.rotation).GetComponent<AU_Body>();
        tempBody.SetColor(myAvatarSprite.color);

        // set animation parameter
        isDead = true;
        animator.SetBool("isDead", isDead);
        myCollider.enabled = false; // turn off collider 
    }
    #endregion


    #region Report Dead Body

    void SearchBody()
    {
        // 시야에 들어온 시체 찾기
        foreach(Transform body in allBodies)
        {
            RaycastHit hit;
            Ray ray = new Ray(transform.position, body.position - transform.position);
            Debug.DrawRay(transform.position, body.position - transform.position, Color.red);

            if(Physics.Raycast(ray, out hit, 1000f, ~ignoreForBody))
            {
                // 시체가 아니라 벽 등이 hit이면 report 하지 못함
                if(hit.transform == body)
                {
                    if (bodiesFound.Contains(body.transform)) return;
                    bodiesFound.Add(body.transform);
                }
                else bodiesFound.Remove(body.transform);
            }
        }
    }

    private void ReportBody(InputAction.CallbackContext obj)
    {
        // called when R key is pressed
        if (bodiesFound == null) return;
        if (bodiesFound.Count==0) return;    // no dead bodies -> do nothing

        Transform tempBody = bodiesFound[bodiesFound.Count-1];
        allBodies.Remove(tempBody);
        bodiesFound.Remove(tempBody); // after reporting, remove it
        tempBody.GetComponent<AU_Body>().Report();
    }
    #endregion
    
    private void Interact(InputAction.CallbackContext context)
    {
        if(context.phase == InputActionPhase.Performed)
        {
            //RaycastHit hit;
            //Ray ray = myCamera.ScreenPointToRay(mousePos);

            //if(Physics.Raycast(ray, out hit, interactLayer))
            //{
            //    if(hit.transform.tag == "Interactable")
            //    {
            //        if (!hit.transform.GetChild(0).gameObject.activeInHierarchy) return; // highlihgt 꺼져 있으면 작동 멈춤

            //        AU_Interactable temp = hit.transform.GetComponent<AU_Interactable>();
            //        temp.PlayMiniGame();
            //    }
            //}

            // 캐릭터 겹치는 경우 대비해 RaycastAll로 전부 다 받기
            Ray ray = myCamera.ScreenPointToRay(mousePos);
            RaycastHit[] hits = Physics.RaycastAll(ray, interactLayer);
            foreach (var hit in hits)
            {
                if (hit.transform.tag.Equals("Interactable"))
                {
                    Debug.Log(hit.transform.name);
                    if (!hit.transform.GetChild(0).gameObject.activeInHierarchy)
                        return;
                    AU_Interactable temp = hit.transform.GetComponent<AU_Interactable>();
                    temp.PlayMiniGame();
                }
            }
        }


        
    }


    #region Direction Sync
    // 변수 동기화
    public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
    {
        if (stream.IsWriting)
        {
            stream.SendNext(direction);
        }
        else
        {
            this.direction = (float)stream.ReceiveNext();
        }
    }
    #endregion



    /*
    #region Move Input Action
    public void OnWalk(InputValue value)
    {
        if (!pv.IsMine) return;
        if (Keyboard.current.leftArrowKey.isPressed) Debug.Log("leftArrow OnWalk");
        movementInput = value.Get<Vector2>();
        if (movementInput.x != 0)
        {
            avatar.localScale = new Vector2(Mathf.Sign(movementInput.x), 1); // movementInput.x가 음수면 -1, Sprite 이미지가 왼쪽 보게 함
        }

        animator.SetFloat("Speed", movementInput.magnitude); // magnitude 이용해 vector의 길이 반환
    }

    public void Walk(InputAction.CallbackContext context)
    {
        if (!pv.IsMine) return;
        if (Keyboard.current.leftArrowKey.isPressed) Debug.Log("leftArrow");
        movementInput = context.ReadValue<Vector2>();
        if (movementInput.x != 0)
        {
            avatar.localScale = new Vector2(Mathf.Sign(movementInput.x), 1); // movementInput.x가 음수면 -1, Sprite 이미지가 왼쪽 보게 함
        }

        animator.SetFloat("Speed", movementInput.magnitude); // magnitude 이용해 vector의 길이 반환
    }
    #endregion
    */
}

 

IPunObservable interface를 받아 OnPhotonSerializeView에서 direction 값 동기화

  • IPunObservable 이 달린 Script가 Observed Components에 들어오는지 확인
    • 주의) 만약 Build한 창이 살아있거나 하면 Auto Find로 해도 AU_PlayerController.cs의 OnPhotonSerializeView가 Observed Components에 잡히지 않을 수 있음.

Observed Components에 AU_PlayerController 들어오는지 확인

  • Photon Rigidbody View 추가 후 Enable teleport 체크
  • Photon Animator View 추가 후 Synchronize Parameters Discrete로 변경

Enable teleport 체크 / speed, isDead Discrete로 설정

결과

각 플레이어의 움직임에 따라 2D Sprite의 방향이 동기화된다. 빛 효과는 자신의 플레이어 위치에 따라 따로 적용되는 것을 알 수 있다.

 

 

 

 

혼자 공부

이번주 스터디에서는 예전에 기획 개발 문제를 복습해보았는데, 예전에 만들어두었던 것을 보여주니 사람들이 신기해해서 따로 정리해보았다. 오브젝트의 직선 이동은 주로 sin 또는 cos 함수를 이용해 만드는데, 곡선 이동은 베지어 곡선을 만들어 그 위를 오브젝트가 움직이게 해야 한다. 베지어 곡선을 만드는 것은 유튜브를 참고했고 오브젝트가 곡선을 따라 이동하는 부분을 생각해서 구현해봤더니 잘 된다. 방법은 아래 게시물 참고.

 

[Unity] 베지어 곡선 활용해 오브젝트 곡선 이동 시키기

목표 베지어 곡선을 활용해 오브젝트가 예쁜 곡선을 그리며 이동하게 할 수 있다. 참고한 영상은 여기 Bezier 곡선을 따라 이동하는 오브젝트 구현 using System.Collections; using System.Collections.Generic; usi

psych-dobby.tistory.com

 

어몽어스 미니게임도 제작해보았다. 개인이 하는 미션이라 멀티는 아니다. 임포스터가 사보타지를 일으켰을 때 멀티로 수행하게 되는 미션은 아직 통신 부분이 마무리가 안 되어 다음주 중으로 완성할 것 같다.

 

[Unity] 어몽어스 미니게임 - 방패 임무 (Among Us Shield Task)

Among Us 미니 게임 중 Shield 게임을 만들어보았다. 준비 Canvas - Panel 하나 만들고 배경 이미지 (TurtleBackGround), 12각형 이미지, 육각형 이미지, Exit 버튼, GameEnd Text 등을 만들어둔다. 배경 이미지 TurtleBa

psych-dobby.tistory.com

 

후기

Photon... 동기화할 때 범위를 생각하는 부분이 아직 어렵다. 또한 생각한 흐름대로라면 되어야 하는데 안 될 때가 많아서 당황스럽다. Photon 하다가 미니게임 만드니까 너무 재밌어서 시간이 가는 줄 몰랐는데 Photon으로 멀티 미니게임을 만드려니 머리가 아프다. 다음주에 멀티 미니게임 꼭 완성시키고 말리라.

UnityEngine.UI를 상속하는 UnityEngine.UI.Extensions를 알게 되었다. 기본 UI를 상속하면서 여러 재미있는 기능을 만들었는데 그 중 하나가 Line Renderer가 아닐까 싶다. UI Extension을 활용해 전선 잇기 미니게임을 만들었다. 다음주 중으로 만드는 법을 정리할 것 같다.

오랜만에 회식인 척 번개를 했다. 금요일 회식인데 내가 목요일에 갈 사람? 물어봄ㅋㅋ 같이 딸기 막걸리 두 사발이나 노나마시고 노래방까지 야무지게 다녀왔다. 같이 공부하고 서로에게 배울 수 있어 즐겁다.

 

 

유데미코리아 바로가기 : https://bit.ly/3b8JGeD

본 포스팅은 유데미-웅진씽크빅 취업 부트캠프 유니티 1기 과정 후기로 작성되었습니다.

 

새로운 가능성의 시작, 유데미 x 웅진씽크빅

글로벌 최신 IT 기술과 실무 교육을 입문부터 심화까지! 프로그래밍, 인공지능, 데이터, 마케팅, 디자인 등 세계 최고의 강의를 경험하세요.

www.udemykorea.com

 

댓글