[Unity] 드래그 앤 드롭 시스템 만들기 : UI to World & World to World

ggm-_-·2024년 12월 9일
2

TIL (Tody I Learn)

목록 보기
39/51
post-thumbnail
2024.12.09(월) 월월...

오늘은 드래그 앤 드롭 시스템을 만들어 보았다.

드래그 앤 드롭 시스템

먼저 Unity에서 UI에서의 드래그 앤 드롭을 구현할 때, Unity에서 지원하는 IBeginDragHandler, IDragHandler, IEndDragHandler와 같은 인터페이스를 사용해서 구현할 수 있다.

Unity Documentation: Unity UI 지원되는 이벤트 메뉴얼

나는 UI -> World, World -> World 에 해당하는 드래그 앤 드롭 시스템을 만들었다.


DraggableUI

Unity에서 Drag and Drop을 위해 지원하는 3개의 인터페이스는 각각 다음과 같은 역할을 한다.

  • IBeginDragHandler
    OnBeginDrag - 드래그가 시작되는 시점에 드래그 대상 오브젝트에서 호출
  • IDragHandler
    OnDrag - 드래그 오브젝트가 드래그되는 동안 호출
  • IEndDragHandler
    OnEndDrag - 드래그가 종료됐을 때 드래그 오브젝트에서 호출

다음은 위 3개의 인터페이스를 이용해 만든 DraggableUI script이다. 해당 script를 드래그 가능하게 하고 싶은 UI object에 추가하면, 해당 UI object에서 프리펩이 World로 나오게 된다.

  • DraggableUI.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;

public class DraggableUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    public DraggableContent draggableContent; // 연결된 콘텐츠 설정

    public void OnBeginDrag(PointerEventData eventData)
    {
        DragAndDropManager.Instance.BeginDrag(draggableContent, eventData);
    }

    public void OnDrag(PointerEventData eventData)
    {
        DragAndDropManager.Instance.Drag(eventData);
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        DragAndDropManager.Instance.EndDrag(eventData);
    }
}

그리고, DraggableContent라는 scriptableObject를 DraggableUI에 달아주면, 이 scriptableObject에 달린 prefab이 Drag할 때, 생성되게 된다.
생성되는 과정은 DragAndDropManager에 작성이 되어 있다.

  • DraggableContent.cs
using UnityEngine;

[CreateAssetMenu(menuName = "Draggable Content")]
public class DraggableContent : ScriptableObject
{
    public GameObject prefab; // 드래그 후 생성할 프리팹
    public string contentName; // 콘텐츠 이름
    public Sprite icon; // UI 아이콘
    public string additionalData; // 추가 데이터 (설명 등)
}

여기서 public GameObject prefab;을 제외한 코드는 딱히 아직까지 의미가 있진 않다.


DragAndDropManager

아래는 DragAndDropManager다. 팀원분이 만들어주신 제네릭을 사용한 싱글톤 베이스 클래스를 상속받아 싱글톤 패턴을 적용했다. (제네릭을 모르는 분들은, 그냥, static instance 있는 매니저 하나 만들었다고 생각하면 된다.)

BeginDrag, Drag, EndDrag 는 각각 드래그 앤 드롭의 드래그 시작, 드래그 중, 드래그 끝일 때의 기능에 대한 메서드다. draggableContent가 null인지 아닌지로 UI to World인지, World to World인지 구분해서 작성할 수 있으며, BeginWorldDrag는 후술하겠지만, World to World 드래그 앤 드랍에 사용되는 드래스 시작 메서드이다. (World to World 드래그 앤 드랍은 DraggableWorldObject.cs 를 통해 동작한다)

SetWorldDragCamera 메서드는 드래그가 실행될 월드를 비추는 카메라를 지정해줄 수 있는 메서드다. 아무래도, 카메라를 기준으로 마우스와 월드가 소통을 하게 되는데, 이 메서드를 통해, Scene 전환이나 UI 전환 시 필요한 카메라가 바뀔 때, 이 메서드를 호출해서 카메라를 유동적으로 바꿀 수 있다.

GetWorldPosition를 이용해서 UI 상의 좌표를 World 좌표로 변환하여 맵핑한다.

UpdateObjectPositionWithGround를 이용해, Ground Layer의 y값을 기준으로 프리펩을 생성한다. 이렇게 하지 않으면, 프리펩의 y값이 들쭉날쭉하게 변하는 문제가 있다.

그리고, DropZone Tag를 만들어서, 드래그하는 동안 DropZone에 들어오면 프리펩을 초록색으로, 나가면 빨간색으로 만들어 주는 기능을 추가했다. UpdateObjectColor를 통해 프리팹의 색이 초록색과 빨간색으로 바뀌고, ResetObjectColor를 통해 EndDrag 시 원래의 색상으로 돌아온다.

또한, DropZone 이외의 곳에 배치하게 되면 해당 오브젝트는 파괴된다. (TODO : 파괴가 아닌 오브젝트 풀링으로 전환할 수도 있다. and 파괴 시 원래 UI의 컨텐트에 다시 복구될 수 있도록 하는 로직이 필요하다.)

  • DragAndDropManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;

public class DragAndDropManager : MonoSingleton<DragAndDropManager>
{

    private GameObject draggedObject; // 현재 드래그 중인 오브젝트
    private DraggableContent draggableContent; // UI에서 드래그된 콘텐츠
    private bool isDragging = false; // 드래그 상태
    private Canvas currentCanvas; // UI 캔버스
    private Camera mainCamera; // 월드 드래그를 위한 카메라
    public LayerMask groundLayer; // 바닥 지형에 사용할 LayerMask
    private Renderer draggedRenderer; // 드래그된 오브젝트의 Renderer

    private Color defaultColor = Color.white; // 기본 색상
    private Color validColor = Color.green;   // DropZone 안의 색상
    private Color invalidColor = Color.red;   // DropZone 밖의 색상

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

    // 월드 드래그를 위한 카메라 설정
    public void SetWorldDragCamera(Camera worldDragCamera)
    {
        mainCamera = worldDragCamera;
    }

    // 드래그 시작 (UI 콘텐츠)
    public void BeginDrag(DraggableContent content, PointerEventData eventData)
    {
        draggableContent = content;

        if (content.prefab == null)
        {
            Debug.LogError("드래그할 Prefab이 설정되지 않았습니다!");
            return;
        }

        currentCanvas = eventData.pointerPress?.GetComponentInParent<Canvas>();
        if (currentCanvas == null)
        {
            Debug.LogError("Canvas를 식별할 수 없습니다!");
            return;
        }

        // 드래그 미리보기 프리팹 생성
        draggedObject = Instantiate(content.prefab, GetWorldPosition(eventData), Quaternion.identity);
        draggedRenderer = draggedObject.GetComponent<Renderer>();

        if (draggedRenderer != null)
        {
            defaultColor = draggedRenderer.material.color; // 원래 색상 저장
            draggedRenderer.material.color = invalidColor; // 드래그 시작 시 빨간색
        }

        draggedObject.SetActive(true);
        isDragging = true;
    }

    // 드래그 시작 (월드 오브젝트)
    public void BeginWorldDrag(GameObject worldObject)
    {
        draggedObject = worldObject;
        draggedRenderer = draggedObject.GetComponent<Renderer>();
        defaultColor = draggedRenderer.material.color;
        isDragging = true;
    }

    // 드래그 중
    public void Drag(PointerEventData eventData)
    {
        if (!isDragging || draggedObject == null)
            return;

        if (draggableContent != null)
        {
            // UI → 월드 드래그
            // Drag동안 추가할 작업 있으면 추가
        }
        else
        {
            // 월드 → 월드 드래그
            // Drag동안 추가할 작업 있으면 추가
        }
        // drag동안 위치에 따라 DragZone감지 후 색 변화
        Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
        if (Physics.Raycast(ray, out RaycastHit hitInfo, Mathf.Infinity, groundLayer))
        {
            draggedObject.transform.position = hitInfo.point;
            UpdateObjectColor(); // 드래그 중 색상 업데이트
        }
    }

    // 드래그 종료
    public void EndDrag(PointerEventData eventData)
    {
        if (!isDragging || draggedObject == null)
            return;

        if (draggableContent != null)
        {
            // UI → 월드 드래그 종료
            if (Physics.Raycast(mainCamera.ScreenPointToRay(Input.mousePosition), out RaycastHit hitInfo, Mathf.Infinity, groundLayer))
            {
                if (hitInfo.collider.CompareTag("DropZone"))
                {
                    Debug.Log($"Dropped {draggedObject.name} in {hitInfo.collider.name}");
                    draggedObject.transform.position = hitInfo.point;
                    ResetObjectColor();
                }
                else
                {
                    Destroy(draggedObject); // 드롭 실패 시 제거
                    // TODO : 없어진 content가 원래 있던 ui로 돌아감
                }
            }
        }
        else
        {
            // 월드 → 월드 드래그 종료
            if (Physics.Raycast(mainCamera.ScreenPointToRay(Input.mousePosition), out RaycastHit hitInfo, Mathf.Infinity, groundLayer))
            {
                if (hitInfo.collider.CompareTag("DropZone"))
                {
                    Debug.Log($"Dropped {draggedObject.name} at {hitInfo.point}");
                    ResetObjectColor();
                }
                else
                {
                    Destroy(draggedObject); // 드롭 실패 시 제거
                    // TODO : 없어진 content가 원래 있던 ui로 돌아감
                }
            }
        }

        draggedObject = null;
        draggableContent = null;
        isDragging = false;
    }

    // UI 좌표 → 월드 좌표 변환
    private Vector3 GetWorldPosition(PointerEventData eventData)
    {
        RectTransformUtility.ScreenPointToWorldPointInRectangle(
            currentCanvas.GetComponent<RectTransform>(),
            eventData.position,
            mainCamera,
            out Vector3 worldPosition
        );
        return worldPosition;
    }

    // 지형에 따라 Y값 업데이트
    private void UpdateObjectPositionWithGround(Vector3 position)
    {
        Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
        if (Physics.Raycast(ray, out RaycastHit hitInfo, Mathf.Infinity, groundLayer))
        {
            position.y = hitInfo.point.y; // 지형 충돌 위치의 Y값으로 업데이트
        }
        draggedObject.transform.position = position;
    }
    // 드래그 중 색상 업데이트
    private void UpdateObjectColor()
    {
        if (draggedRenderer == null)
            return;

        Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
        if (Physics.Raycast(ray, out RaycastHit hitInfo, Mathf.Infinity, groundLayer))
        {
            if (hitInfo.collider.CompareTag("DropZone"))
            {
                draggedRenderer.material.color = validColor; // 초록색
            }
            else
            {
                draggedRenderer.material.color = invalidColor; // 빨간색
            }
        }
    }

    // 드래그 종료 시 색상 초기화
    private void ResetObjectColor()
    {
        if (draggedRenderer != null)
        {
            draggedRenderer.material.color = defaultColor; // 원래 색상으로 복구
        }
    }
}

DraggableWorldObject

World to Wold로의 드래그 앤 드랍을 할 때, 대상이 되는 오브젝트에 DraggableWorldObject script를 붙이면 드래그 대상으로 사용할 수 있다.

UI 드래그 앤 드롭과 달리 인터페이스는 따로 필요하지 않다.

  • DraggableWorldObject.cs
using UnityEngine;

public class DraggableWorldObject : MonoBehaviour
{
    private void OnMouseDown()
    {
        DragAndDropManager.Instance.BeginWorldDrag(gameObject);
    }

    private void OnMouseDrag()
    {
        DragAndDropManager.Instance.Drag(null); // 이벤트 데이터가 필요 없음
    }

    private void OnMouseUp()
    {
        DragAndDropManager.Instance.EndDrag(null); // 이벤트 데이터가 필요 없음
    }
}

Inspector 설정

다음 코드를 이용해 설정할 오브젝트들의 Inspector 창 설정에 대한 설명이다.

  • DropZone Tag 및 Layer

    DropZone 생성 후, Tag와 Layer 설정을 다음과 같이 해준다. (본인은 Plane으로 DropZone오브젝트를 만들었다. 나중에 지형이 생겼을 때는 어떻게 할지는 고려해야 할 듯하다.)

  • Content인 UI의 Tag 및 부착 script

    ScrollView의 Content로 쓰이는 btnOwnedKnight라는 UI의 Inspector창이다. 드래그할 UI에는 DraggableUI script를 달아주면 된다. Tag는 Draggable로 설정.

  • Content UI에 연결할 ScriptableObject (프리팹 is here)

    Content인 UI에 연결된 ScriptableObject이다. 여기서, Prefab을 연결해준다.

  • 드래그 앤 드롭으로 꺼낼 프리팹의 Tag 및 부착 script

    드래그 앤 드롭으로 꺼낼 프리팹이다. 나는 해당 프리팹도 Drag 가능하게 해 줄 예정이기 때문에, World 용 드래그 script인 DraggableWorldObject를 달아준다. Tag도 Draggable로 설정.

이상으로 드래그 앤 드롭 시스템 만들기를 마친다.
뭔가 조금 더 보완할 수 있을 것 같지만, 아직까진 딱히 사용하는데 문제는 없어 보인다.

내가 나중에 다시 보기 쉽게 정리한 거지만, 다들 이 글을 보고 드래그 앤 드롭을 조금 더 쉽게 쓸 수 있으면 뿌듯할 듯 싶다.

이상 오늘의 TIL 끝!


문제점

ScrollView와 함께 쓸 때의 문제점

위의 코드를 사용했을 때, ScrollView와 함께 사용하는 Content가 드래그 앤 드롭 시스템과만 반응하고, ScrollView의 Content로서 클릭이 안된다는 문제가 있었다.

다음글에서 다음 문제를 해결한다.

다음글: [Unity] 드래그 앤 드롭 시스템 만들기: ScrollView를 반영한 드래그 앤 드롭 시스템

profile
미숙한 초보 게임 개발자

4개의 댓글

comment-user-thumbnail
2024년 12월 9일

우수 TIL 미리 축하드립니다

1개의 답글
comment-user-thumbnail
2024년 12월 10일

크...색 변환까지...완벽합니다

1개의 답글