본문 바로가기

개발일지/Unity_기능개발

Unity_그래플링 훅(로프 액션) 기능 구현 Feat. StarterAssets

개요

최근 퍼스트 디센던트 게임 플레이를 한 이후

그래플링 훅 기능을 직접 구현해보고 추후 다양하게 활용하면 어떨까 싶어서 개발을 시작해보았다.

 

<퍼스트 디센던트의 그래플링 훅 이미지>


이미지 출처 - 퍼스트 디센던트 공식 홈페이지 개발자 노트

 

퍼스트 디센던트

 

tfd.nexon.com

 

 

준비

개발 환경
유니티 버전 : Unity 6 (6000.0.16f1)
IDE : Visual Studio Code
OS : PC (windows 11)

 

다양한 프로젝트에 사용할 수 있게 모듈처럼 만들고자

기본적인 3인칭 뷰를 설정하고 3인칭 캐릭터는 유니티에서 지원하는 공식 애셋을 활용하였다.

애셋스토어 링크

 

Starter Assets - ThirdPerson | Updates in new CharacterController package | Unity 필수에셋 | Unity Asset Store

Get the Starter Assets - ThirdPerson | Updates in new CharacterController package package from Unity Technologies and speed up your game development process. Find this & other Unity 필수에셋 options on the Unity Asset Store.

assetstore.unity.com

 

Starter Assets - ThirdPerson 에서는 

기본적인 마네킹 아바타 캐릭터 모델링, 애니메이션들을 제공하고

사용환경인 유니티6에서 기본 제공하며 권장하는 Input System을 통한 Input Action으로 입력을 제어한다.

기본 제공하는 캐릭터 모델 PlayerArmature 프리팹의 구성

 

 

 

구현

캐릭터를 준비 한 후 

그래플링 훅 기능 구현을 시작하였다.

 

 

그래플링 훅 시스템을 구현하기 위해 필요한 기본 구현 목록

1. 훅을 걸어 이동할 수 있는 지형 or 오브젝트인지를 Raycast를 통해 검사

2. 훅을 보여주는 로프, 라인을 그리는 방법

3. 훅을 따라가며 날아가는 물리방식 구현

 

추가 구현해볼 수 있는 기능 목록

1. 쿨타임 사용 횟수 스택 기능

2. 날아가는 도중 상하좌우 추가 조작을 통한 관성이동

3. 이동 중 취소 기능

 

기본 구상안은  캐릭터 현재 시점의 중앙(카메라 화면 뷰의 중앙)에서 Raycast를 전방으로 쏴서 그래플링 훅이 가능한
오브젝트 인지를 검사 한 후 가능하다면 발사하여 로프가 나가서 걸리도록 생각했다.

여기에서 오브젝트의 검사는 LayerMask를 사용하였고 발사되는 로프는 LineRenderer를 통해 만들어주었다.

 

위의 그림처럼 캐릭터의 오른손 부분에서 훅이 발사되는 것을 원했기 때문에

캐릭터 모델링에서 오른손 부분에 그래플 훅 총이 될 오브젝트로 프리팹을 만들었다.

 

 

그래플링 훅을 구현하는 방식에는 여러가지 방식이 있는데

1. 유니티에서 제공하는 Spring Joint를 이용하여 만드는 방식(이 경우 스파이더맨처럼 스윙하는 방식을 만들기 유용하다.)

2. Raycast를 통해 받아온 위치정보들을 가지고 직접 물리연산을 통해 Addforce나 velocity를 주어 이동하는 방식

정도가 있다.

 

우선적으로 그래플링을 발사하여 로프는 카메라의 중점으로부터 Raycast를 발사하여 맞닿는 곳에
LineRenderer를 그려주는 방식으로 구현하고 날아가는 움직임의 경우

특정 위치까지의 중력의 영향을 받는 포물선 운동을 이용한 구현을 참고하여 개발했다.

using UnityEditor;
using UnityEngine;
using UnityEngine.UI;

public class GraplingHook : MonoBehaviour
{
	// 그래플링 움직임 제어 및 연산 컴포넌트
    [SerializeField] private PlayerGrapplingMovement playerGrapplingMovement;

    private RaycastHit _raycasthit;
    [SerializeField] private LineRenderer _lineRenderer;

    [SerializeField] private LayerMask _grapplingLayer;

    [SerializeField] private Transform hookGunPoint;
    [SerializeField] private Image crossHair;

    private Vector3 _grapplePoint;

    [SerializeField][Range(0, 100)] private float _ropeMaxDistance;

    private Camera _camera;

    private bool _doingGrappling = false;

    [SerializeField] private float overshootYAxis;

    [Header("Cooldown")]
    private float _cooldownTimer = 0;
    [SerializeField] private float _coolTime = 0;
    [SerializeField] private float _grappleDelayTime = 0;

    [Header("Input Key")]
    public KeyCode grappleKeyCode = KeyCode.Mouse2; // Wheel Click

    private void Start()
    {
        _camera = Camera.main;
    }

    private void Update()
    {
        if(Input.GetKeyDown(grappleKeyCode))
        {
            StartGrappling();
        }

        if(_cooldownTimer > 0)
        {
            _cooldownTimer -= Time.deltaTime;
        }
    }

    private void LateUpdate() {
        if(_doingGrappling)
        {
            _lineRenderer.SetPosition(0, hookGunPoint.position);
        }
    }

    // ... 크로스헤어 변경 부 생략 ... //
	
    // 키 입력을 받고 Raycast를 통해 그래플링 발사
    private void StartGrappling()
    {
        if(_cooldownTimer > 0) return;

        _doingGrappling = true;
		// 현재 메인 카메라의 정면 방향으로 Raycast 검사
        if (Physics.Raycast(_camera.transform.position, _camera.transform.forward, out _raycasthit, _ropeMaxDistance, _grapplingLayer))
        {
            _grapplePoint = _raycasthit.point;
            Invoke(nameof(ExecuteGrappling), _grappleDelayTime);
        }
        else
        {
            _grapplePoint = _camera.transform.position + _camera.transform.forward * _ropeMaxDistance;
            Invoke(nameof(StopGrappling), _grappleDelayTime);
        }
		// 설정된 라인렌더러를 활성화
        _lineRenderer.enabled = true;
        _lineRenderer.SetPosition(1, _grapplePoint);
    }
    
	// 그래플링 진행
    private void ExecuteGrappling()
    {
        playerGrapplingMovement.SetDoingGrapple(true);
		// 플레이어의 위치에서 제일 낮은 Y축 부분 검사용 변수
        Vector3 lowestPoint = new Vector3(transform.position.x, transform.position.y - 1f, transform.position.z);
		// 현재 그래플이 꽂힌 위치와 플레이어의 Y축 최저점의 차이 계산
        float grapplePointRelativeYPos = _grapplePoint.y - lowestPoint.y;
        // 플레이어가 점프를 하며 날아갈 시에 원호의 가장 최상단이 될 Y축의 높이
        float highestPointOnArc = grapplePointRelativeYPos + overshootYAxis;

        if(grapplePointRelativeYPos < 0) highestPointOnArc = overshootYAxis;
        playerGrapplingMovement.GrapplingJumpToPosition(_grapplePoint, highestPointOnArc);
        Invoke(nameof(StopGrappling), 1f);
    }
	
    // 그래플링 중단
    private void StopGrappling()
    {
        playerGrapplingMovement.SetDoingGrapple(false);
        playerGrapplingMovement.ResetRestrictions();

        _doingGrappling = false;
        _cooldownTimer = _coolTime;
        _lineRenderer.enabled = false;
    }
}

ExecuteGrappling 부분을 그림으로 표현

 

using StarterAssets;
using UnityEngine;

public class PlayerGrapplingMovement : MonoBehaviour
{
    /*
    * 아래 ThirdPersonController는 예시로 사용된 컨트롤러
    * 사용자 환경에 따라 그래플링 중 이동을 막고자 하면 이동하는 함수가 있는 컴포넌트 추가
    */
    [SerializeField] ThirdPersonController thirdPersonController;

    private Rigidbody _rb;

    private Vector3 velocityToSet;
    [SerializeField] [Range(0, 10)] private float grappleSpeed;
    public bool ActiveGrapple;

    private void Start() {
        _rb = GetComponent<Rigidbody>();
    }

    public void SetDoingGrapple(bool isGrappling)
    {
        thirdPersonController.DoingGrapple = isGrappling;
    }

    public void GrapplingJumpToPosition(Vector3 targetPosition, float trajectoryHeight)
    {
        ActiveGrapple = true;
        velocityToSet = CalculateJumpVelocity(transform.position, targetPosition, trajectoryHeight);
        Invoke(nameof(SetVelocity), 0.1f);
    }

    public void ResetRestrictions()
    {
        ActiveGrapple = false;
    }

    private void SetVelocity()
    {
        _rb.linearVelocity = velocityToSet * grappleSpeed;
    }
	
    // Rigidbody에 전달해줄 움직임 속도 계산
    private Vector3 CalculateJumpVelocity(Vector3 startPoint, Vector3 endPoint, float trajectoryHeight)
    {
        float gravity = Physics.gravity.y;
        float displcementY = endPoint.y - startPoint.y;
        Vector3 displacementXZ = new Vector3(endPoint.x - startPoint.x, 0f, endPoint.z - startPoint.z);

        Vector3 velocityY = Vector3.up * Mathf.Sqrt(-2 * gravity * trajectoryHeight);
        Vector3 velocityXZ = displacementXZ / (Mathf.Sqrt(-2 * trajectoryHeight / gravity) + Mathf.Sqrt(2 * (displcementY - trajectoryHeight) / gravity));

        return velocityXZ + velocityY;
    }
}

 

CalculateJumpVelocity 메서드는 Sebastian Lague - Kinematic Equations (E03: Ball problem) 영상을 참고했다.

이미지 출처: Sebastian Lague - Kinematic Equations (E03: Ball problem)

 

 

문제 발생

두 가지의 문제가 발생하였는데

첫번째는 훅 구현에는 크게 상관 없지만 애셋에 구현되어 있는 ThirdPersonController에서의 지면 충돌 검사 부분이었다.

 

주석 처리된 부분이 기존의 코드인데

별도의 콜라이더를 두지 않는 대신 매 업데이트마다 발 밑에 Physics.CheckSphere를 통해 구체 충돌체를 만든 후
지면이라 설정한 레이어에 충돌하면 땅에 닿아 있다고 판단하는 로직으로 되어있었다.

해당 부분이 가만히 서있어도 프레임마다 true와 false를 번갈아가며 리턴하기에 점프가 원하는 타이밍에 되지 않았기에 

발쪽에 별도의 트리거 충돌체를 만들어 그라운드 체크를 하도록 간단하게 변경했다.

 

두번째 문제는 그래플링 훅을 한 다음 캐릭터가 원래의 위치로 되돌아오는 문제가 발생했다.

그래플링 이후 갑자기 제자리로 돌아온다

 

문제는 마찬가지로 기존에 ThirdPersonController에 구현된 이동인 Move 함수에 있었다.

주석을 제거한 Move 메서드 기존 구현

 

input값을 받은 다음 입력값을 토대로 속도, 이동방향을 계산한 후 플레이어의 위치를 재배치하는 형식으로 되어있었기 때문에 별다른 이동입력이 없는 상황인 그래플링이 끝나면 다시 원위치로 돌아오는 현상이 발생하였다.

그 외에도 CharacterController 컴포넌트를 사용하는 위주로 구현되어 있다보니 Rigidbody를 사용하지 않아
충돌처리 부분에서의 직관성이 떨어지는 부분이 있었다.

따라서 CharacterController를 사용하지 않고 별도의 캐릭터 충돌체와 Rigidbody를 사용하는 방식으로 수정했다.

기존 구현되어 있는 _controller.Move 메서드를 _rigidbody.MovePosition 메서드로 변경했다.

 

수정한 Move 함수 이쯤되니 처음 의도와 다르게 애셋 마개조 중이었다.

 

어색한 부분은 있었지만 돌아오는 버그가 수정됐다

 

 

TPS 숄더뷰를 기준으로 작업을 하였지만 힘을 가해주는 축 방향 코드 수정만 거치면 탑다운뷰 3D 액션 게임 등에도 활용할 수 있을 것 같다.
기본적으로는 크게 구현을 하는 방식이 어려운 개념은 아니지만 물리를 어떻게 활용하느냐에 따라서 전혀 다른 움직임이 나올 수 있는
좋은 컨텐츠 개발이었다고 생각한다.

별개로 의도치않게 그래플링 훅 기능 구현보다 유니티 StarterAsset에 대한 분석 시간이 더 길었던 것 같다.

 

추가로

Addforce를 이용하여 간단하게 작업을 해보면

크게 달라진 것 없이 간단하게 (그래플포인트 - 현재위치) * 속도로 구현했다

 

조금 더 해당 방향으로 쭉 날아가는 느낌의 것을 만들 수는 있다.

다만 착지 시 미끄러지는 문제가 있으니 착지하는 부분에서의 현재 Rigidbody의 Velocity를 0으로 바꿔준다거나
조금 더 연산을 통해 도착점에 가까울수록 감속을 준다는 식으로 더 자연스럽게 처리가 가능할 것이다.

 

스파이더맨 같은 매달리는 스윙느낌은 Spring Joint,
퍼스트 디센던트나 배트맨 아캄시리즈, 세키로 등 같이 속도감있게 날아가고 추가 액션이 있는 경우는 Addforce 같은 느낌으로 직접 가속하는 방식으로 구현하는 것이 좋을 것 같다.

 

 


참고자료

[게임개발] 로프액션 구현하는 방법 - 오늘코딩

 

ADVANCED GRAPPLING HOOK in 11 MINUTES - Unity Tutorial - Dave / GameDevelopment

 

Kinematic Equations (E03: ball problem) - Sebastian Lague