Deff_Dev

[Unity/C#] 3D 문 상호작용 본문

Unity(유니티)/유니티 공부

[Unity/C#] 3D 문 상호작용

Deff_a 2024. 6. 5. 21:05
 

Abandoned Asylum | 3D 도시 | Unity Asset Store

Elevate your workflow with the Abandoned Asylum asset from Lukas Bobor. Find this & other 도시 options on the Unity Asset Store.

assetstore.unity.com

 

문 상호작용

 

위 에셋에 포함된 문 관련 오브젝트들을 사용하여 문 상호작용 기능을 구현해 보겠다.


클래스 구성 및 오브젝트 탐색

점선은 상속, 파란색 선은 사용

 

문 상호작용 기능을 구현하기 위한 클래스 구조도이다.

진행 중인 프로젝트를 기반으로 설명하므로, 클래스 구조가 다소 복잡할 수 있다.

 

입력 및 이벤트 실행  

PlayerInputController (좌), PlayerController (우)

 

PlayerInputController에서 InputSystem을 이용하여 상호작용 키를 입력을 받고 PlayerController에 선언된 Event Action을 실행시킨다.

 

오브젝트 탐색

Interactable 레이어를 추가한다.

 

레이어를 추가한 다음 아래의 스크립트들을 작성한다.

 

IInteractable.cs

더보기

상호작용 함수가 정의되어 있는 인터페이스

public interface IInteractable
{
    public void OnInteract(); // 상호작용 효과
}

ItemObject.cs

더보기

상호작용하는 오브젝트에 추가하는 스크립트

 

이후에 나오는 문 상호작용 관련 클래스들은 ItemObject 클래스를 상속받고, OnInteract 함수를 오버라이딩한다.

using UnityEngine;

public class ItemObject : MonoBehaviour , IInteractable
{
    public virtual void OnInteract() // TODO: 상호작용 아직 개발해야 함
    {
        Destroy(gameObject);        
    }
}

InteractEventHandler.cs

더보기
해당 스크립트를 플레이어 오브젝트에 추가한다.

 

게임 화면 정중앙에 레이를 발사하여 상호작용 가능한 오브젝트를 탐색한다.

 

상호작용 오브젝트를 찾았을 때, PlayerController에 선언된 OnInteractEvent에 해당 오브젝트의 OnInteract 함수를 구독한다.

 

반대로 오브젝트를 찾지 못했을 때, 현재 상호작용하는 오브젝트 정보가 있을 경우 해당 오브젝트의 OnInteract 함수를 구독 해제한다.

using System.Collections;
using UnityEngine;

public class InteractEventHandler : MonoBehaviour
{
    [Header("# Interact")]
    [SerializeField]private float checkRate = 0.05f; // 얼마나 자주 레이를 쏠 것 인가 ?
    [SerializeField]private float maxCheckDistance; // 탐지 거리
    private LayerMask layerMask; // 탐지 레이어
    
    
    // 현재 상호작용하는 오브젝트 정보
    private GameObject curInteractGameObject;
    private IInteractable curInteractable;
    
    private Camera camera;
    private PlayerController playerController;
    private Coroutine searchCoroutine;
    private WaitForSeconds wait;
    private void Awake()
    {
        wait = new WaitForSeconds(checkRate);
        playerController = GetComponent<PlayerController>();
        layerMask = LayerMask.GetMask("Interactable"); // 레이어 설정
    }

    private void Start()
    {
        camera = Camera.main;

        StartSearch();
    }

    private void StartSearch()
    {
        if (searchCoroutine != null)
        {
            StopCoroutine(searchCoroutine);
        }

        searchCoroutine = StartCoroutine(CheckForInteractables());
    }

    private IEnumerator CheckForInteractables()
    {
        while (true)
        {
            // ScreenToViewportPoint : 터치했을 때 기준
            // ScreenPointToRay : 카메라 기준으로 레이를 쏨
            // new Vector3(Screen.width / 2, Screen.height / 2) => 정 중앙에서 쏘기 위해
            // 카메라가 찍고 있는 방향이 기본적으로 앞을 바라보기 때문에 따로 방향 설정 X
            Ray ray = camera.ScreenPointToRay(new Vector3(Screen.width / 2, Screen.height / 2));
            
            if (Physics.Raycast(ray, out RaycastHit hit, maxCheckDistance, layerMask)) // 충돌이 됐을 때
            {
                if (hit.collider.gameObject != curInteractGameObject) // 충돌한 오브젝트가 현재 상호작용하는 오브젝트가 아닐 때
                {
                    if (curInteractable != null) // 바로 상호작용이 될 수 도 있으니 구독되어 있는 이벤트르 해제함
                    {
                        playerController.OnInteractEvent -= curInteractable.OnInteract; // 구독 해제
                    }
                    
                    if (hit.collider.TryGetComponent(out curInteractable))
                    {
                        curInteractGameObject = hit.collider.gameObject; // 오브젝트 변경
                        playerController.OnInteractEvent += curInteractable.OnInteract; // 상호작용 구독
                    }
                }
            }
            else // 충돌한 오브젝트가 없다면 기존에 있던 정보들을 초기화
            {
                InteractionOff();
            }
            yield return wait;
        }
    }

    public void InteractionOff() // 레이 캐스트에 맞은 오브젝트가 없을 때
    {
        if (curInteractGameObject != null) // 오브젝트 초기화
        {
            playerController.OnInteractEvent -= curInteractable.OnInteract;
            curInteractGameObject = null;
            curInteractable = null;
        }
    }
}

 

위 스크립트를 전부 작성했다면 이제 문을 만들어보자 !


하나의 문 

 

현재 바라보는 방향으로 문을 열 수 있도록 기능을 구현해보겠다.

 

문이 열릴려면 y축으로 90도 or -90도로 회전시키면 된다.

 

어떻게 회전시켜야할 지 알았으니 스크립트를 작성해보자.

 

OneDoor.cs

더보기
using System.Collections;
using UnityEngine;

public class OneDoor : ItemObject
{
    [SerializeField] private float openSpeed = 200f;
    private bool isOpen = false;
    private Coroutine toggleCoroutine;


    private Quaternion targetRotation;
    private bool isRot // 문이 회전 되어 있는 지
    {
        get => transform.parent.rotation.y % 180 != 0;
    }
    
    public override void OnInteract()
    {
        isOpen = !isOpen;
        ToggleDoor();
    }

    private void ToggleDoor()
    {
        if (toggleCoroutine != null)
        {
            StopCoroutine(toggleCoroutine);
        }

        // 문이 열리는 각도 구하기
        Vector3 dir = (GameManager.Instance.PlayerController.transform.position - transform.position).normalized;
        // 문을 열 때는 각도를 구하고 문을 닫을 때는 각도를 0으로 설정
        float targetAngle = isOpen ? GetOpenTargetAngle(dir) : 0;
        targetRotation = Quaternion.Euler(0, targetAngle, 0);
        
        toggleCoroutine = StartCoroutine(DoorToggleCoroutine());
    }
    
    private IEnumerator DoorToggleCoroutine()
    {
        while (true)
        {
            // 현재 회전과 목표 회전 비교
            if (Quaternion.Angle(transform.localRotation, targetRotation) < 0.1f)
            {
                transform.localRotation = targetRotation;
                break;
            }
            // 회전
            transform.localRotation = Quaternion.RotateTowards(transform.localRotation, targetRotation, Time.deltaTime * openSpeed);
            yield return null;
        }
    }
    
    private float GetOpenTargetAngle(Vector3 dir)
    {
        return isRot ? (dir.x > 0 ? 90 : -90) : (dir.z > 0 ? -90 : 90);
    }
    
}

 

상호작용 키를 입력받게 된다면 OnInteract 함수가 실행되고, 문 상호작용 코루틴이 실행된다.

이때, 플레이어의 방향을 구한 뒤, 플레이어의 방향에 맞게 문이 열리는 각도를 설정한다.

GetOpenTargetAngle() 함수를 쓰는 이유

해당 문은 회전되어 있기 때문에 dir.z 값을 비교하게 된다면 이런식으로 문이 열리게 된다.

문이 회전되어 있을 수 있으므로, 회전된 상태에서는 dir.x 값을 비교하여 문이 열리는 방향을 구해야 하기 때문에 해당 함수를 사용한다.

 

위와 같이 스크립트를 작성한 뒤, Door 오브젝트에 추가하고 레이어를 변경한다.

 

해당 프리팹을 씬에 배치 후 확인해본다면 위와 같이 잘 작동하는 것을 볼 수 있다.

 

혹시나 문이 움직이지 않는다면 문 프리팹이 Static으로 묶여있지 않는지 확인해보면 된다.

 

이슈

이 방법으로 구현할 경우, 루트 오브젝트의 y축 회전이 0도나 -90도일 때만 정상적으로 작동한다.

만약 문이 대각선으로 위치해 있다면, 이 방법은 사용하기 어려울 것이다.

 

targetAngle을 구할 때, 문의 transform.forward 값과 플레이어 위치를 내적하여 각도를 비교하는 방법으로 바꾸면 모든 각도에 대해 targetAngle을 정확하게 구할 수 있을 것이다.


두 개의 문

 

문이 두 개 있을 때, 하나의 문을 탐색하고 상호작용했을 때, 두 개의 문이 동시에 열리고 닫힐 수 있도록 구현해 보겠다.

 

두 개의 문 프리팹의 문 Pivot이 양 끝쪽이 아닌 가운데에 위치해 있기 때문에 Pivot 오브젝트를 생성하고 각 문을 그 자식으로 넣어주었다.

왼쪽 문은 오른쪽 문의 Scale을 -1로 변환시키고 x를 -180도로 변환시켰으므로 양쪽 문이 같은 방향으로 열리려면 각 문이 반대로 회전되어야 한다.

 

ex) 한 쪽은 90도, 다른 한 쪽은 -90도

 

어떤 식으로 회전시켜야 할 지 알았으니 구현 해보자

 

첫 번째 구현

루트 오브젝트에 BoxCollider를 추가한 후, 문 크기에 맞게 조절하고 Layer Overrides Exclude LayersPlayer를 설정해 플레이어가 콜라이더에 충돌하지 않도록 했다.

그리고 플레이어가 BoxCollider로 문을 인식하고 이때 상호작용 키를 누르면 상호작용이 일어나도록 구현했다.

 

 

하지만 플레이어가 허공을 바라보고 문을 닫는 상황이 디테일적으로 맞지 않다고 생각했다.

그래서 문을 바라보고 상호작용해야만 두 문이 열리도록 구현하기로 했다.

두 번째 구현

TwoDoor 클래스를 각 문에 추가하고, 상호작용 키를 눌렀을 때 TwoDoorInteractEventHandler에 있는 함수를 호출하여 문의 상호작용을 처리하는 방식으로 설계했습니다.

 

TwoDoor.cs

더보기
using UnityEngine;

public class TwoDoor : ItemObject
{
    private TwoDoorInteractEventHandler twoDoorInteractEventHandler;
    private void Awake()
    {
        twoDoorInteractEventHandler = transform.parent.parent.gameObject.GetComponent<TwoDoorInteractEventHandler>();
    }

    public override void OnInteract() // 상호작용
    {
        twoDoorInteractEventHandler.TwoDoorToggleEvent();
    }
}

 

TwoDoor 스크립트에서는 상호작용 이벤트가 발생하면 TwoDoorInteractEventHandler의 상호작용 이벤트를 호출합니다.

TwoDoorInteractEventHandler .cs

더보기
using System;
using System.Collections;
using UnityEngine;

public class TwoDoorInteractEventHandler : MonoBehaviour
{
    [SerializeField] private float openSpeed = 200f;
    private bool isOpen = false;
    [SerializeField]private Transform leftDoorPivot;
    [SerializeField] private Transform rightDoorPivot;
    private Coroutine toggleCoroutine;

    public void TwoDoorToggleEvent()
    {
        isOpen = !isOpen;
        ToggleDoor();
    }

    private void ToggleDoor()
    {
        if (toggleCoroutine != null)
        {
            StopCoroutine(toggleCoroutine);
        }
        
        toggleCoroutine = StartCoroutine(TwoDoorToggleCoroutine());
    }

    private IEnumerator TwoDoorToggleCoroutine() // 문 두개 열고 닫히는 이벤트 코루틴
    {
        // 플레이어의 위치와 문의 위치를 비교하여 회전 각도 설정
        Vector3 dir = (GameManager.Instance.PlayerController.transform.position - transform.position).normalized;
    
        // 문이 닫히거나 열릴 때 양쪽문의 각도를 구해줌
        float leftTargetAngle = isOpen ? (dir.z > 0 ? -90 : 90) : 0;
        float rightTargetAngle = isOpen ? (dir.z > 0 ? 90 : -90) : 0;
        
        Quaternion leftTargetRotation = Quaternion.Euler(0, leftTargetAngle, 0);
        Quaternion rightTargetRotation = Quaternion.Euler(0, rightTargetAngle, 0);

        while (true)
        {
            // 현재 회전과 목표 회전 비교
            if (Quaternion.Angle(leftDoorPivot.localRotation, leftTargetRotation) < 0.1f && Quaternion.Angle(rightDoorPivot.localRotation, rightTargetRotation) < 0.1f)
            {
                leftDoorPivot.localRotation = leftTargetRotation;
                rightDoorPivot.localRotation = rightTargetRotation;
                break;
            }
            // 회전
            leftDoorPivot.localRotation = Quaternion.RotateTowards(leftDoorPivot.localRotation, leftTargetRotation, Time.deltaTime * openSpeed);
            rightDoorPivot.localRotation = Quaternion.RotateTowards(rightDoorPivot.localRotation, rightTargetRotation, Time.deltaTime * openSpeed);
            yield return null;
        }
    }
}
 
 
OneDoor 스크립트와 유사하게 양쪽 문 Pivot을 TargetAngle에 맞게 회전시켜준다.
 

 

 

스크립트를 작성했다면 양쪽 문에 TwoDoor 스크립트를 추가하고 Layer를 변경한다.

그리고 기존에 존재하던 MeshCollider를 제거하고 BoxCollider를 추가한다.

 

여기서 MeshCollider가 아닌 BoxCollider를 사용한 이유는 해당 문 오브젝트는 자식에 유리창 오브젝트가 존재하기 때문에 MeshCollider를 사용한다면 유리창은 인식을 못하기 때문에 BoxCollder를 사용해 문 전체를 탐색할 수 있도록 했다.

 

루트 오브젝트에 TwoDoorInteractEventHandler 스크립트를 추가하고 인스펙터 창에서 Pivot 오브젝트를 캐싱한다.

 

2개의 문 상호작용 완성

 

해당 프리팹을 씬에 배치하고 테스트를 해본다면 위와 같이 잘 작동하는 것을 확인할 수 있다.

 

하지만 이 코드도 두개의 문 y축 회전이 180일 때만 정상적으로 작동하는 이슈가 있다.

 

나머지 문 프리팹들은 위 코드들을 맞게 잘 변형해서 구현한다면 쉽게 구현할 수 있을 것이다.


글을 마치며

한정된 각도에서만 동작이 되도록 구현한 부분이 아쉬움이 많이 남는다.

내적을 사용해 모든 각도에서 대응이 될 수 있도록 꼭 수정해서 다시 글을 쓰도록 하겠다.