XR플밍 - 12. UnityEngine3D 네트워크 프로그래밍 - 네트워크 프로젝트 Round5 1일차 (7/22)

이형원·2025년 7월 22일
0

XR플밍

목록 보기
142/215

1. 게임 프로젝트 개요

이번 프로젝트는 네트워크 및 데이터베이스를 기반으로 한 프로젝트를 진행해야 한다. 지금까지 배운 매치메이킹 네트워크와 함께 데이터베이스를 활용할 게임을 만들어보고자 한다.

1.1 네트워크 게임 프로젝트 - Round5

  • 팀 이름 : Round5
  • 프로젝트 이름 : Round5
  • 프로젝트 소개 : 1대1 2D 액션 게임 Rounds를 레퍼런스로 한 게임 제작
  • 팀원 : 프로그래밍 팀 6명

(웃기게도 Round5라는 팀 이름 겸 프로젝트 이름은 5조이면서 5가 s랑 비슷하게 생겨서 짓게 되었다.)

1.2 맡은 역할

이번 프로젝트에 필요한 역할은 다음과 같이 나뉘었다.

  • 플레이어 + 네트워크
  • 카드 시스템
  • 무기 시스템
  • 이펙트 시스템 2명
  • 배경 및 로프 시스템

이 중에서도 내가 맡은 역할은 배경 및 로프 시스템이다. 맵의 전반적인 디자인 및 장애물 등의 요소를 만들어야 하는 역할을 맡게 되었다.

2. 금일 한 업무 정리

오늘은 1일차이기도 하고 본인 업무 외적인 업무도 좀 했기 때문에 작업량 자체가 많지는 않았다.

  • 테스트 맵 제작 및 테스트

레퍼런스 게임을 참고하여 테스트용 맵 디자인을 완성했다,

또한 게임 내 기능 중, 맵 바깥으로 나갈 경우 플레이어가 데미지를 입으면서 다시 맵 안쪽으로 튕겨져 들어오게 되어 있는데, 이렇게 튕겨져 들어오는 것 또한 Area Effector를 이용해 구현했다.

  • (기획업무 분담...?) PPT 및 자료 영상 촬영 및 편집

  • 로프 플랫폼 시스템 구현

로프에 묶여 이리저리 흔들리는 플랫폼에 대한 구현을 완료했다.

3. 문제의 발생과 해결 과정

3.1 테스트 맵의 제작 과정

테스트 맵의 제작 과정에 대해서는 큰 어려움은 없었다. 다만 어떻게 하면 효율적으로 맵을 디자인할 수 있을지에 대한 고민이 필요하기는 해 보인다.

1. 맵의 제작

맵은 이와 같이 플랫폼 별로 정리하였고, 아웃 에어리어와 플랫폼을 따로 정리하였다.

맵 타일은 필요에 따라 이와 같이 모양에 따라 전부 프리팹화시켰으며, 이 프리팹을 배치하는 것으로 맵 제작시간 단축을 노렸다.

각 플랫폼 프리팹의 경우 이와 같이 Polygon Collider로 처리를 했다. 원래는 Composite Collider를 사용하고 싶었으나, 다이아몬드 모양의 경우 바로 충돌체 생성이 불가능해서 어쩔 수 없이 Polygon Collider로 생성했다.

이와 같이 맵 프리셋 또한 만들어놓았다.

2. 아웃 에어리어의 제작

아웃 에어리어의 경우, 이전 2D 강좌에서 배웠던 내용을 활용하면 되겠다고 떠올렸다.
그 중 Area Effector를 활용하는 방식인데, Area Effector는 해당 영역에서의 물체에 Force를 가할 수 있는 기능을 말한다.

사용하는 방식은 영역으로 지정한 곳에 충돌체가 있어야 하며, 해당 충돌체는 Is Trigger와 Used By Effector가 체크되어 있어야 한다. 여기에서 Area Effector를 붙이고, Force Angle로 힘의 방향을, Force Magnitude로 힘의 크기를 조정하면 된다.

여기서 Force Angle에 따른 힘의 방향은 다음과 같다.

  • Force Angle
    0도 : 오른쪽
    90도 : 위쪽
    180도 : 왼쪽
    270도 : 아래쪽

3.2 로프 플랫폼의 구현

로프를 구현하는 방법에 대해서는 크게 두 가지 방법이 있다는 것을 알게 되었다.

  1. Joint를 사용해서 구현하는 방법
  2. Verlet Integration 알고리즘을 사용하는 방법

이 두 가지에 대해 전부 시도해봤지만, 결론적으로는 1번의 방법을 사용했다.

우선은 구현해 보았지만 쓰지는 않았던 Verlet Integration에 대한 부분을 다뤄보고, 최종적으로 사용한 Joint 구현 방식에 대해 다뤄보고자 한다.

1. Verlet Integration

Verlet Integration은 뉴턴의 운동 방정식을 적분하는 데 사용되는 수치적 방법이다. 분자 동역학 시뮬레이션과 컴퓨터 그래픽 에서 입자의 궤적을 계산하는 데 자주 사용되는 알고리즘으로, 거의 물리엔진 구현에 사용되는 알고리즘이라 해도 과언이 아닌 것으로 보인다(?!)

실제로 Verlet Integration으로 구현할 수 있는 운동학적 시뮬레이션은 공을 바닥에 떨어트렸을 때의 경로라든지, 옷감의 움직임이라든지, 지금 구현하는 로프라든지, 다방면으로 쓰이는 알고리즘이다.

그 말은 무엇인가, 물리 및 수학 공부가 들어가야 한다는 것이다.

  1. 쉽지 않지만 짚고 넘어가자. 핵심 원리는?

Verlet Integration은 모든 물체의 운동에 대한 운동학 방정식에 대한 솔루션이다.

어떤 입자의 위치 x는 v(속도)와 t(시간)의 곱으로 구할 수 있다.

이 공식을 이용해 컴퓨터에 맞게 적산하려면, 즉 시간을 잘개 쪼개 매순간 △t씩 흘러가는 동안
i번째 시간의 위치를 기준으로 i+1번째 시점의 위치 x(i+1)의 시점의 위치는 수치 해석적으로는 다음과 같이 쓴다.

그리고 i+2번째는, 다음과 같을 것이다.

여기서 다음 위치가 매순간 속도에 의해 결정된다는 것은 번거로운 일일 것이다. 그래서 속도는 가속도에 시간을 곱해 구한다는 것에 착안하여 다음과 같이 전개할 수 있다.

  • 속도를 가속도 * 시간으로 변환하여 상수와 같이 처리

  • 이후 해당식을 전개

이제 여기에서 맨 위에 구했던 식으로 연립방정식으로 전개하면 다음과 같아진다.

이와 같은 유도과정으로 인한 결론은 다음과 같다.

i+1번째를 현재 시점으로 본 다면 그 다음 시점인 i+2번째의 위치는 현재 시점과 지난 시점의 위치, 가속도만 가지고 구할 수 있다.

  1. 해당 방식을 적용한 로프 생성방식

로프는 시작점과 끝점을 설정할 수 있도록 하였다.
또한, 핵심으로 사용하는 기능으로 LineRenderer를 활용한다.

using System.Collections.Generic;
using UnityEngine;

public class RopePhysics : MonoBehaviour
{
    [SerializeField] LineRenderer lineRenderer;
    [SerializeField] int segmentCount = 15;
    [SerializeField] int constraintLoop = 15;
    [SerializeField] float segmentLength = 0.1f;
    [SerializeField] float ropeWidth = 0.1f;

    [Header("로프 좌표")]
    [SerializeField] Transform startTransform;
    [SerializeField] GameObject endObject;

    private List<Segment> segments = new List<Segment>();
    private Vector2 gravity = new Vector2(0, -9.81f);

    private void Awake()
    {
        Vector2 segmentPos = startTransform.position;

        Init();

        for(int i = 0; i < segmentCount; i++)
        {
            segments.Add(new Segment(segmentPos));
            segmentPos.y -= segmentLength;
        }
    }

    private void Init()
    {
        lineRenderer = GetComponent<LineRenderer>();
    }

    private void FixedUpdate()
    {
        UpdateSegments();
        for(int i = 0; i < constraintLoop; i++)
        {
            ApplyConstraint();
        }        
        DrawRope();
    }

    private void DrawRope()
    {
        lineRenderer.startWidth = ropeWidth;
        lineRenderer.endWidth = ropeWidth;

        Vector3[] segmentPositions = new Vector3[segments.Count];
        for(int i =0; i<segments.Count; i++)
        {
            segmentPositions[i] = segments[i].Position;
        }

        lineRenderer.positionCount = segmentPositions.Length;
        lineRenderer.SetPositions(segmentPositions);
    }

    private void UpdateSegments()
    {
        for(int i = 0;i < segments.Count; i++)
        {
            segments[i].Velocity = segments[i].Position - segments[i].PreviousPos;
            segments[i].PreviousPos = segments[i].Position;
            segments[i].Position += gravity * Time.fixedDeltaTime * Time.fixedDeltaTime;
            segments[i].Position += segments[i].Velocity;
        }
    }

    private void ApplyConstraint()
    {
        segments[0].Position = startTransform.position;
        segments[segmentCount - 1].Position = endObject.transform.position;

        for(int i = 0; i < segments.Count - 1; i++)
        {
            float distance = (segments[i].Position - segments[i + 1].Position).magnitude;
            float difference = segmentLength - distance;
            Vector2 dir = (segments[i + 1].Position - segments[i].Position).normalized;

            Vector2 movement = dir * difference;

            if(i == 0) segments[i + 1].Position += movement;
            else if(i == segments.Count - 2) segments[i].Position -= movement;
            else
            {
                segments[i].Position -= movement * 0.5f;
                segments[i + 1].Position += movement * 0.5f;
            }
        }
    }

    public class Segment
    {
        public Vector2 PreviousPos;
        public Vector2 Position;
        public Vector2 Velocity;

        public Segment(Vector2 position)
        {
            PreviousPos = position;
            Position = position;
            Velocity = Vector2.zero;
        }
    }
}

로프에 대한 현재 위치를 바탕으로, 나중위치에 대한 계산을 진행한 후에 이에 대한 Constraint를 부여한다. 그 다음 로프를 직접 그리는 기능으로 나뉘어 있다.
대략적인 구조는 이해했지만, 나중에 따로 시간을 내서 더 분석해 볼 필요성이 있어 보인다.

  1. 해당 기능을 최종적으로 사용하지 않은 이유

이와 같은 방식으로 로프를 구성했을 때, 정말 자연스러운 방법으로 로프가 작동한다.
하지만 실질적으로 '로프에 묶여 있는 플랫폼'이라는 기능에까지 확장하기가 매우 까다롭다는 것을 느꼈고, 아무래도 물리와 수학 지식이 들어가다 보니 코드를 분석하는 데에만 한세월이었다.

로프 플랫폼을 구현하기 위해 로프의 길이에 따라 달린 무게의 중력을 바꾸는 등의 계산이 들어가야 하는데, 아무래도 이렇게 맨 땅에 헤딩하는 방식 보다는 짧은 프로젝트 기간 안에 빠르게 개발하는 방식을 채택하는 것이 좋겠다고 판단했다.

따라서 Joint를 이용한 다음 방식으로 방향을 바꿨다.

2. Hinge Joint 2D와 Sprite Skin을 이용한 방식

아주 단순하게 그림판으로 그린 로프를 준비해주자.

로프가 Sprite Mode - Single로 되어 있는지 확인하고, Sprite Editor로 들어가보자.

Skinning Editor라는 기능이 이번에 쓰일 기능이다.

해당 기능을 누르면 이와 같이 다양한 옵션이 나오는데, 여기서 사용할 건 Create Bone을 이용한 관절의 생성이다.

우선은 이와 같이 관절을 적당한 크기로 나눠주자.

그 다음으로 Auto Weight로 자동으로 무게까지 설정한 다음에 해당 창을 나가면 된다.

로프 스프라이트를 씬에 넣은 뒤 Sprite Skin을 넣고 Create Bones를 눌러보자.

이와 같이 아까 작업했던 Bone이 나뉘는 것을 확인할 수 있으며, 여기서 이 Bone을 다 끄집어내고 Rope의 자식으로 둔다. 그리고 맨 위에 Anchor를 두고 bone_1의 Transform과 같게 만든다.

앵커를 포함한 모든 bone을 선택한 채로 Hinge Joint 2D를 추가한다. 그러면 Rigidbody도 같이 추가되는 것을 확인할 수 있다.

다음으로 각각의 Bone마다 Connected Rigidbody를 연결하는데, 자신의 바로 위의 Rigidbody를 계속해서 연결하여 끝까지 다 연결하도록 한다. (끝까지 진행하면, Anchor는 아무것도 연결되지 않을 것이다.)

다음으로 Bone_1은 Auto Configure Cunnected Anchor을 꺼 준다. 이 편이 좀 더 자연스러운 움직임이 나온다.

  • 좀 더 자연스러운 움직임을 주기 위한 방법
  1. Bone의 회전에 제한을 두기

Bone의 회전에 이와 같이 제한을 둘 수 있다. 시험해봤을 때 적당한 각도는 -50 ~ 50 도 정도였다.

  1. 로프만 있을 경우에는 Bone의 무게와 중력 늘리기, 무게를 잡을 고정 물체가 있으면 해당 물체를 무겁게, 중력 강하게 설정하기.

만드는 게임에는 플랫폼이 들어가야 하기 때문에 플랫폼에 무게를 주는 방식으로 자연스러운 로프 물리를 만들었다.

4. 개선점 및 과제

4.1 (디자인) 로프가 허접함

일단 에셋 적용이 안된 것도 있지만, 로프가 너무 허접하다는 문제가 있다. 그리고 꾸준히 테스트를 해 봐야겠지만, 좀 더 안정적으로 로프가 시각적으로 보일 수 있도로 할 필요가 있어 보인다.

4.2 (구현) 톱날 장치 구현

Rounds를 보면 이런 톱날 장치 같은 것도 있다. 이것 또한 구현해 봐야 할 과제이다.

4.3 (백업) 인게임 플레이 GIF 파일 확보

현재 백업 중에 있는 인게임 자료 모으기에 대한 부분을 맡고 있으며, 동영상 편집과 같이 GIF 파일을 만들어 놓아야 한다.

4.4 (백업) 다른 팀원 백업하기

로프 때문에 오래 걸릴 거라고 예상했던 것과 달리, 로프가 금방 끝나서 내가 할 일이 적다 싶으면, 백업 위주로 작업해야 할 수도 있다.

profile
게임 만들러 코딩 공부중

0개의 댓글