Network 환경에서 플레이어들의 Transform 및 Animator 동기화를 통해 모든 사람이 올바른 위치에 있을 수 있도록 하겠습니다.
Unity에는 NetworkObject 말고도 NetworkTransform, NetworkRigidbody, NetworkAnimator 를 지원하지만, 생각보다 성능이 좋지 않고 씹히는 경우가 있어서 ServerRPC로 직접 만들어서 사용하려고 합니다.
추가적으로, 기본적으로 이동에 대한 구현은 Rigidbody의 AddForce를 사용해서 구현하도록 하겠습니다.
Transform 조작, Rigidbody.MovePosition 은 Collider를 무시할 가능성이 있고, Velocity를 조작하는건 Slope 처리나 구 위에서 움직여야할 때 애로사항이 생길 수 있습니다.
기본적인 구조는 간단한 StateMachine을 사용합니다.
해당 StateMachine에 대한 글은 해당 글을 참고하시기 바랍니다.
이번 글에서는 구조보다는 개별 함수 구현을 살펴보도록 하겠습니다.
저는 여러가지 중력이 작용할 수 있는 게임을 구상하고 있기 때문에, 우선 중력부터 정의해야 합니다.
ground 레이어를 가진 collider들의 부모에 Gravity의 방향을 알 수 있는 클래스를 정의합니다.
/** GroundBase.cs **/
public abstract class GroundBase : MonoBehaviour
{
public abstract GravityType GravityType { get; }
public abstract Vector3 GetGravityVector();
}
/** PlanetGround.cs **/
public class PlanetGround : GroundBase
{
public override GravityType GravityType { get => GravityType.Point; }
public override Vector3 GetGravityVector()
{
return transform.position;
}
}
/** DirectionGround.cs **/
public class DirectionGround : GroundBase
{
public override GravityType GravityType { get => GravityType.Direction; }
public override Vector3 GetGravityVector()
{
return -transform.up;
}
}
PointGravity 와 DirectionalGravity 를 나누어서 캐릭터가 중력을 받을 방향을 실시간으로 갱신합니다.

Vector3 gravityDirection = Vector3.zero;
Collider[] hitObjects;
public void GroundedCheck()
{
Vector3 rectPosition = new Vector3(transform.position.x, transform.position.y - groundedOffset,
transform.position.z);
hitObjects = Physics.OverlapBox(rectPosition, groundRectSize, transform.rotation, Constants.LAYER_GROUND);
isGrounded = hitObjects.Length > 0;
if (!IsGrounded) return;
SetParentServerRPC(hitObjects[0].transform.parent.GetComponent<NetworkObject>().NetworkObjectId);
GroundBase gb = hitObjects[0].GetComponentInParent<GroundBase>();
gravityType = gb.GravityType;
gravityVector = gb.GetGravityVector();
}
public void CalculateGravity()
{
switch (gravityType)
{
case GravityType.Point:
gravityDirection = Vector3.Normalize(gravityVector - transform.position);
break;
case GravityType.Direction:
gravityDirection = gravityVector;
break;
}
}
OverlapBox 함수를 통해서 바닥의 Ground 레이어를 탐지합니다.
만약 Ground 레이어가 있다면 해당 오브젝트를 플레이어의 부모로 설정해서 해당 오브젝트에 따라다닐 수 있도록 했습니다. ( 우주선같은 경우 우주선의 속도를 따라가야함 )
GroundBase 를 찾으면 해당 Gravity에 대한 정보를 계산하여 gravityDirection 에 저장하여 중력을 계산합니다.
public void ApplyGravity()
{
if (!useGravity) return;
projectedForward = Vector3.ProjectOnPlane(transform.forward, gravityDirection).normalized;
Quaternion targetRotation = Quaternion.LookRotation(projectedForward, -gravityDirection);
rigid.MoveRotation(targetRotation);
rigid.AddForce(gravityDirection * gravityForce, ForceMode.Acceleration);
}
위에서 계산한 중력은 중력은 ApplyGravity 함수를 통해 플레이어에게 적용시킬 수 있습니다
여기서는 PointGravity의 경우 중력이 작용하는 방향이 계속 달라지기 때문에 그에 따라 Player의 Rotation을 조정해줍니다.
물론, 움직이는 것과 점프하는 것까지 중력의 방향을 고려해야 합니다.
public void SlopeCheck()
{
Debug.DrawRay(transform.position, -transform.up * slopeRayLength, Color.red);
if (Physics.Raycast(transform.position, -transform.up, out RaycastHit hit, slopeRayLength))
{
normalVector = hit.normal;
slopeAngle = Vector3.Angle(hit.normal, -gravityDirection);
float landAngle = Vector3.Angle(hit.normal, transform.up);
isOnSlope = slopeAngle > slopeLimit;
isOnSlopeWhileFlying = slopeAngle > slopeLimit || landAngle > enableToLandAngle;
}
}
우선 땅에 닿을 때 걸을 수 있는 경사인지 체크합니다.
이 함수에서 세팅하는 isOnSlope, isOnSlopeWhileFlying 변수는 Detect 함수들에서 사용됩니다.
public void DetectIsGround()
{
if (isGrounded)
{
stateMachine.ChangeState(idleState);
}
}
public void DetectIsFalling()
{
if (isGrounded) return;
DetectIsFallingWhileJump();
}
public void DetectIsFallingWhileJump()
{
if (Vector3.Dot(rigid.linearVelocity, transform.up) <= 0f)
{
stateMachine.ChangeState(fallState);
}
}
Detect 함수들은 특정 상태에서 다른 상태로 넘어가야할 때를 체크합니다.
public void WalkWithArrow(float vertInputRaw, float horzInputRaw, float diag)
{
Vector3 moveDir = (transform.forward * horzInputRaw + transform.right * vertInputRaw) * diag * walkSpeed;
currentSpeed = moveDir.magnitude * walkSpeed * diag;
rigid.AddForce(Vector3.ProjectOnPlane(moveDir, normalVector) * Time.fixedDeltaTime, ForceMode.VelocityChange);
}
우선 moveDir 에 키보드 입력값과 diag를 통해 대각선 방향으로의 속도를 보정해줍니다.
위에서와 마찬가지로 ProjectOnPlane 함수를 사용하면 바닥과 평행하게 힘을 줄 수 있습니다.
public void RotateWithMouse(float mouseX, float mouseY)
{
rigid.AddTorque(transform.up * mouseX * rotationPower, ForceMode.Force);
cameraTransform.GetComponent<Rigidbody>().AddTorque(-transform.right * mouseY * rotationPower, ForceMode.Force);
Vector3 camRot = cameraTransform.localRotation.eulerAngles;
camRot.y = Mathf.Clamp(camRot.y, minVertRot, maxVertRot);
cameraTransform.localRotation = Quaternion.Euler(camRot);
}
마우스로 고개를 돌리는 함수입니다.
AddTorque를 사용해서 구현했고, 위/아래를 보는 rotation은
[ServerRpc(RequireOwnership = false)]
private void UpdatePlayerTransformServerRPC(Vector3 playerPosition, Quaternion playerQuat, Quaternion camQuat)
{
UpdatePlayerTransformClientRPC(playerPosition, playerQuat, camQuat);
}
[ClientRpc]
private void UpdatePlayerTransformClientRPC(Vector3 playerPosition, Quaternion playerQuat, Quaternion camQuat)
{
if (IsOwner) return;
transform.position = playerPosition;
transform.rotation = playerQuat;
cameraTransform.localRotation = camQuat;
}
정말 간단하게 동기화를 시켜주는 RPC 함수입니다.
물리연산을 서버에서 시키면 딜레이가 생기고 위치가 불일치하는 경우가 생기며, 중간에 주기적으로 Transform 을 동기화 해주더라도 렉이 걸리는 것 같이 버벅입니다.
저는 경쟁 게임이 아닌, 협동 게임을 구상하고 있기 때문에 동기화는 간단하게 하고 이 이후에 필요하면 더 추가하는 방향으로 개발하겠습니다.
[ServerRpc(RequireOwnership = false)]
public void SetParentServerRPC(ulong parentId)
{
if(NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(parentId, out NetworkObject parentObject)){
transform.parent = parentObject.transform; // 감지하면 Parent로 설정
}
}
[ServerRpc(RequireOwnership = false)]
public void UnsetParentServerRPC()
{
transform.parent = null;
}
player는 NetworkObject 이므로 부모를 설정하기 위해선 두가지 규칙이 필요합니다.
물론, 설정하기 위해서 Transform을 보내면 Serialize 할 수 없다고 에러가 뜨기 때문에 NetworkObject 에서 NetworkObjectId 를 받아와서 ServerRPC 를 호출해야합니다.
( 위의 GroundCheck 함수 참고 )
public void ChangeAnimatorParam(int id, bool param)
{
animator.SetBool(id, param);
ChangeAnimatorParamServerRPC(id, param);
}
public void ChangeAnimatorParam(int id, float param)
{
animator.SetFloat(id, param);
ChangeAnimatorParamServerRPC(id, param);
}
[ServerRpc(RequireOwnership = false)]
public void ChangeAnimatorParamServerRPC(int id, bool param)
{
ChangeAnimatorParamClientRPC(id, param);
}
[ServerRpc(RequireOwnership = false)]
public void ChangeAnimatorParamServerRPC(int id, float param)
{
ChangeAnimatorParamClientRPC(id, param);
}
[ClientRpc]
public void ChangeAnimatorParamClientRPC(int id, bool param)
{
if (IsOwner) return;
animator.SetBool(id, param);
}
[ClientRpc]
public void ChangeAnimatorParamClientRPC(int id, float param)
{
if (IsOwner) return;
animator.SetFloat(id, param);
}
Animator 동기화는 오버로딩을 사용해서 Server, ClientRPC를 구현해줍니다.
이렇게 구현하면 한 프레임에 여러 번 바뀌는 경우에도 파라미터가 씹히지 않고 다 바뀌게 됩니다.


구 위를 걷거나 점프하는 것, 이동 및 애니메이션 동기화까지 잘 된 모습을 확인할 수 있습니다.
아무래도 Walk를 Force로 구현하니 미끄러지는 듯한 들어서 상태마다 linearDaming 과 PhysicsMaterial 을 지정해 줬는데도 그런 느낌이 완전히 사라지지는 않은 것 같습니다. 해당 부분은 계속 파라미터를 바꿔가며 고쳐보겠습니다.
다음은 운전하는 상태를 만들어서 입력이 플레이어가 아닌 우주선을 제어하도록 해보겠습니다.