이번주는 어몽어스에 통신 기능을 추가해보았다. 과제로는 미니게임을 추가로 만들어보았다.
동기화 되는 캐릭터 생성 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에 잡히지 않을 수 있음.
- Photon Rigidbody View 추가 후 Enable teleport 체크
- Photon Animator View 추가 후 Synchronize Parameters 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
댓글