어제자까지 한 작업을 제외하고 오늘 한 작업은 다음과 같다.
맵 카메라 워킹 추가
오브젝트 물리 네트워크 동기화
작업목록만 봐서는 얼마 되지 않는 작업량이라 볼 수 있지만, 물리 연산의 네트워크 동기화가 아주 어려운 작업이었고, 하루만에 그럴싸한 수준까지 완성된 게 엄청난 성과이다.
아무래도 쉽지 않은 작업이다 보니 혼자서 스스로 해결한 것은 아니고, 팀원 포함 셋이서 거의 4시간 가까이 매달려서 해결한 문제이긴 한데, 배운 점이 많아서 해당 내용에 대해 적어보고자 한다.
카메라 워킹 추가 요청사항은 간단하게 해결했다. 요청사항은, 카메라가 왼쪽으로 조금 갔다가 오른쪽으로 슉 이동하는 연출을 원한다고 했다.
이 부분은 코루틴에 왼쪽으로 가는 내용만 추가하면 되서 금방 처리할 수 있었다.
using System.Collections;
using UnityEngine;
public class IngameCameraMovement : MonoBehaviour
{
[SerializeField] private bool isRoundOver = false;
[SerializeField] private bool isRoundSetOver = false;
[SerializeField] private float moveLeftDuration = 0.1f;
[SerializeField] private float moveLeftDistance = 2f;
[SerializeField] private float moveRightDuration = 0.5f;
// 게임매니저가 없어서 일단 Update로 처리 후 테스트
// 후에 이벤트로 라운드 및 라운드셋 종료 여부를 받아오는 방법 고려중
private RandomMapPresetCreator creator;
private Camera mainCamera;
private Vector2 startPosition;
private Vector2 targetPosition;
private Coroutine cameraCoroutine;
private void OnEnable()
{
creator = GetComponent<RandomMapPresetCreator>();
mainCamera = Camera.main;
startPosition = Camera.main.transform.position;
}
private void Update()
{
if(isRoundSetOver)
{
SceneChange();
}
else if (isRoundOver)
{
IngameCameraMove();
}
}
private void IngameCameraMove()
{
float offset = creator.GetTransformOffset();
targetPosition = startPosition + new Vector2(offset, 0);
cameraCoroutine = StartCoroutine(MoveCamera());
isRoundOver = false;
}
IEnumerator MoveCamera()
{
float elapsedTime = 0f;
while (elapsedTime < moveLeftDuration)
{
elapsedTime += Time.deltaTime;
float t = elapsedTime/moveLeftDuration;
mainCamera.transform.position = Vector2.Lerp(startPosition, startPosition - new Vector2(moveLeftDistance, 0), Mathf.SmoothStep(0, 1, t));
yield return null;
}
mainCamera.transform.position = startPosition - new Vector2(moveLeftDistance, 0);
startPosition = Camera.main.transform.position;
float elaspedTime = 0f;
while(elaspedTime < moveRightDuration)
{
elaspedTime += Time.deltaTime;
float t = elaspedTime/moveRightDuration;
mainCamera.transform.position = Vector2.Lerp(startPosition, targetPosition, Mathf.SmoothStep(0, 1, t));
yield return null;
}
mainCamera.transform.position = targetPosition;
startPosition = Camera.main.transform.position;
cameraCoroutine = null;
}
private void SceneChange()
{
// 씬 로드 - **님 비동기 로드 씬이 어느거지?
}
}
우선 해당 문제에 대한 고민을 시작하기 전 거쳐야 하는 초기 세팅에 대해 알아보고자 한다.
맵 바깥의 영역은 현재 Area Effector를 적용해놔서, 플레이어 등이 맵 밖으로 나갔을 경우 다시 맵 안쪽으로 튕겨져 들어오도록 설정되어 있다. 다만, 플레이어만 맵 바깥으로 튕겨져 들어와야 하고 무너지는 오브젝트나 총알 등이 밖으로 튕겨져 나가면 안되므로 해당 부분에 대한 처리를 해야 한다.
방식은 다음과 같다.
우선은 무너지는 오브젝트와 아웃 에어리어에 대한 레이어를 각각 만들었다. Bullet은 이미 레이어가 있으니, 해당 레이어 또한 무시할 수 있도록 만들어야 한다.
그리고 유의해야 할 건, 해당 부분에 대해서 처음엔 물리 충돌을 못하게 만들어야 한다고 생각했는데, 이 방법으론 안 된다.
이와 같이 설정되도 계속 물건이 해당 영역 안으로 들어가면 튀어오르는 현상이 발생했는데 이유는 다음과 같다.
effector는 애초에 trigger를 조건으로 발생하는 것이기 때문에 충돌이 일어나는 요소가 아니다. 따라서 수정해야 하는 것은 물리 처리가 아니라 Area Effector의 Collider Mask이다.
이와 같이 Bullet과 Breakable Object를 추가했다. 앞으로 맵 바깥으로 벗어날 오브젝트가 추가될 경우 해당 오브젝트에 대한 레이어를 추가한 후 Collider Mask를 체크해제해야 한다.
네트워크는 부분은 원래도 자신이 없던 부분이기도 했고, 아직 사용하는 게 익숙하지 않아서 어떻게 해당 동기화를 진행시킬지부터가 난감했다.
우선 오브젝트의 동기화를 위해서 PhotonView와 PhotonTransformView가 필요할 것이다.
그 다음으로 동기화를 위해서 필요한 건 뭘까? 우선은 변수 동기화를 진행해야 한다고 생각했다.
필요한 변수도 당장에 필요한 건 Position과 Rotation이라고 생각했다.
private Vector3 networkPos;
private Quaternion networkRot;
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
if (stream.IsWriting)
{
stream.SendNext(transform.position);
stream.SendNext(transform.rotation);
}
else if (stream.IsReading)
{
networkPos = (Vector3)stream.ReceiveNext();
networkRot = (Quaternion)stream.ReceiveNext();
}
}
그래서 해당 변수를 받아왔으니, 이제 어떻게 해야 하지? 이 부분이 난감했다.
Bullet의 경우에는 자신이 발사한 거면 그냥 그대로 날려 버리면 되고, 상대방이 날린 Bullet을 PhotonNetwork.Instantiate 시켜서 표현하면 될 것이다.
그런데 오브젝트란 건 처음부터 존재하는 거고, 누구의 소유라고 할 수 있는 건가. isMine으로 내 시야에서는 어떻게 보이고, 다른 사람의 시야에서는 어떻게 보이는지 처리를 해야 하는데, 어떻게 처리해야 하지? 도저히 감이 안 잡혀서 결국 네트워크를 잘 다루는 팀원에게 설명을 받았다.
팀장님 - 열심히 강의하듯 써 준 것에 감사합니다. 내용 기억용으로 남기고자 캡쳐
최대한 간단하고 이해햐기 편한 방식으로 정리하려고 해 보자면, 우선 핵심 로직은 다음과 같다.
네트워크상 물리 변화를 동기화하기 위해서 가장 확실한 방법은, 물리 연산을 단 한 명의 플레이어가 처리하도록 하는 것. 즉, 방장(마스터 클라이언트)이 모든 물리 연산을 처리하고, 해당 연산에 대한 결과를 마스터가 아닌 플레이어에게 전달해주는 방식이 가장 동기화가 잘 되는 방법이다.
해당 방식은 마스터 클라이언트한테 부담을 많이 주는 방식이기는 하나, 플레이어들이 보는 화면을 최대한 같게 만드는 방식으로 사용할 수 있다.
즉, 핵심 로직은 맵 내에 배치된 오브젝트를 '마스터 클라이언트의 것'으로 판단하고, 마스터 클라이언트만이 물리 연산을 처리하고 해당 연산 결과를 다른 플레이어한테 전달해주면 된다.
이걸 위해서 우선은 같이 작업한 팀원끼리 추가로 논의한 내용에는 이와 같은 것이 있었다.
-> 이 부분은 처음에 오브젝트를 고정하기 위해서 키네마틱으로 설정되어 있었기 때문에 오히려 다행인 부분이었다. 하지만 오브젝트가 총알과 부딪혔을 때 다이나믹으로 전환되는 방식 때문에 많은 고민과 논의가 오갔다.
이 부분이 정말 뼈를 때리는 핵심 로직이었다. 단순히 싱글의 상황에선 이런 상황에 대해 생각해 보지 않았는데, 결국 네트워크 상황에서 각 플레이어의 오브젝트 물리가 동기화되지 않는 이유는 플레이어가 각자 물리 연산을 처리하고 있기 때문. 즉 문제를 해결하기 위한 핵심은 물리 처리를 한 사람만 맡아서 하는 것이다.
이걸 위해서 가장 확실한 방법은 물리 처리를 하는 플레이어 외에는 모든 움직이는 오브젝트를 키네마틱 상태로 유지하도록 하는 것이다.
이와 같은 방식으로 계속 디버깅을 하고, 테스트를 해 보면서 나온 코드는 다음과 같다.
using Photon.Pun;
using UnityEngine;
public class BoxController : MonoBehaviourPun, IPunObservable
{
FixedJoint2D fixedJoint;
private Rigidbody2D rigid;
private bool isPhysicsEnabled = false;
private bool networkPhysicsEnabled = false;
private void Awake()
{
rigid = GetComponent<Rigidbody2D>();
fixedJoint = GetComponent<FixedJoint2D>();
}
private void Update()
{
// 플레이어 자신의 시점에서, Joint에 연결된 오브젝트가 물리 연산을 하도록 변환되었을 경우
// (즉, 이 오브젝트와 연결되어 있는 오브젝트 아래의 물체가 피격되었을 경우)
// 물리를 활성화한다.
if (photonView.IsMine &&
(fixedJoint != null && fixedJoint.connectedBody != null && fixedJoint.connectedBody.bodyType == RigidbodyType2D.Dynamic))
{
EnablePhysics();
}
// 포톤뷰가 내것이 아니다 = 마스터 클라이언트가 아닐 경우
if (!photonView.IsMine)
{
// 오브젝트의 물리가 활성화된 경우, 오브젝트의 위치 및 회전을 네트워크에서 수신한 위치로 이동시킨다.
if (isPhysicsEnabled)
{
// 지연보상을 위해, Lerp에서 Time.deltaTime * 10f으로 보정해준다.
// 이건 우선 노가다로 맞춘 보정 수치이며, 다른 좋은 방법이 있을 경우를 찾아봐야 한다.
transform.position = Vector3.Lerp(transform.position, networkPos, Time.deltaTime * 10f);
transform.rotation = Quaternion.Lerp(transform.rotation, networkRot, Time.deltaTime * 10f);
}
}
}
// 오브젝트가 피격되었을 경우(총알 혹은 로프기믹-해머 에 피격되었을 경우)
// 물리를 활성화한다.
private void OnCollisionEnter2D(Collision2D collision)
{
if (photonView.IsMine && (collision.gameObject.CompareTag("Bullet") || collision.gameObject.CompareTag("Hammer")))
{
EnablePhysics();
}
}
// 물리 활성화
// 네트워크에 신호를 주기 위한 bool 변수 활용
private void EnablePhysics()
{
networkPhysicsEnabled = true;
if (PhotonNetwork.IsMasterClient)
{
rigid.bodyType = RigidbodyType2D.Dynamic;
rigid.mass = 0.1f;
rigid.gravityScale = 0.3f;
isPhysicsEnabled = true;
}
}
private Vector3 networkPos;
private Quaternion networkRot;
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
// 물리 변화를 기록한다. (마스터 클라이언트가)
// 전달할 물리 정보는, 위치, 회전, 속도, 회전속도이다.
if (stream.IsWriting)
{
// 위치
stream.SendNext(transform.position);
// 회전
stream.SendNext(transform.rotation);
// 물리 활성화 여부
stream.SendNext(isPhysicsEnabled);
// 물리가 활성화되어 있으면 전달한다.
if (isPhysicsEnabled)
{
// 속도
stream.SendNext(rigid.velocity);
// 회전속도
stream.SendNext(rigid.angularVelocity);
}
}
// 물리 변화를 읽어온다.
// 읽어올 물리 정보는, 위치, 회전, 속도, 회전속도이다.
else if (stream.IsReading)
{
// 위치는 상시로 읽어온다.
networkPos = (Vector3)stream.ReceiveNext();
// 회전은 상시로 읽어온다.
networkRot = (Quaternion)stream.ReceiveNext();
// 물리 활성화 여부를 읽어온다.
networkPhysicsEnabled = (bool)stream.ReceiveNext();
// 해당 과정은, 네트워크 상황에서의 데이터 누락의 가능성을 생각하며 한 번 더 체크하는 과정
// 만약 상대방과 물리 상태가 다를 경우 상태를 보정해주는 과정
if (networkPhysicsEnabled != isPhysicsEnabled)
{
isPhysicsEnabled = networkPhysicsEnabled;
}
// 물리가 활성화되었을 경우
if (isPhysicsEnabled)
{
// 속도를 읽어온다.
Vector2 networkVelocity = (Vector2)stream.ReceiveNext();
// 회전속도를 읽어온다.
float networkAngularVelocity = (float)stream.ReceiveNext();
// 만약 플레이어가 마스터 클라이언트가 아닐 경우
// 속도와 회전속도를 반영한다.
if (!photonView.IsMine)
{
rigid.velocity = networkVelocity;
rigid.angularVelocity = networkAngularVelocity;
}
}
}
}
}
코드 분석을 하면 이와 같은 과정으로 이루어진다.
우선은 오브젝트 물리 네트워크 동기화가 되어서 최대한 그대로 두겠으나, FixedJoint와 관련된 부분이 Update로 돌아가고 있다든지 연산량이 많은 부분이 다소 있다. 이걸 이벤트로 처리하거나 하는 등의 최적화 과정을 할 수 있다면 해 보자.
현재 이것도 동기화가 잘 되어 있기는 하나, MultipleJoint를 사용하는 오브젝트나 로프 기믹 같은 경우에는 아직 동기화가 안 되어 있다. 여기까지도 마저 동기화작업을 진행해야 한다.
맵의 배경 부분은 현재 UI로 사용되고 있는 약간 검은 화면을 그대로 쓰기로 했고, 맵을 꾸미는 작업은 여전히 필요하다. 이번에는 다른 방법에 대한 쉐이더 그래프 사용방법에 대한 튜토리얼도 찾아놨으니, 해당 작업을 통해 쉐이더 그래프를 만들든, 에셋을 구하든 방법을 정하기로 했다.
이번에야말로 예쁜 셰이더를 만들어보자...