요리조리

[Unity][AR Foundation] 쓰레기 던지기 미니게임 (AR Throwing) 본문

게임/Unity

[Unity][AR Foundation] 쓰레기 던지기 미니게임 (AR Throwing)

나는수 2023. 6. 1. 18:00

구현 순서

0. 유니티 세팅

1. 평면 인식

2. 평면 위로 쓰레기 던지기

3. 평면에 쓰레기통 고정시키기 

4. 점수 시스템

5. 추가 기능들 (게임 재시작, 게임 오버)


0. Settings

AR 프로젝트 세팅과 빌드 세팅을 먼저 진행한다.

0.1 AR Project Setting

참고: https://coding-of-today.tistory.com/10

•   Package Manager에서 AR Foundation, ARCore XR Plugin 패키지 Install

•   [Hierarchy]에서 XR -> AR Session과 AR Session Origin 객체 추가

  -       AR Session OriginAR CameraFacing Direction > World(후면 카메라)

  -       Main Camera 삭제 -> AR Camera Tag > MainCamera

0.2. Build Setting

File > Build Settings

   [Add Open Scenes] > AndroidSwitch Platform

   Player Settings > Player > Other Settings > Auto Graphics API 체크 취소 > Graphics APIsVulkan 엔진 삭제(-), OpenGLES3 만 남기기! (AR에서는 Vulkan 엔진 지원 X)

   Minimum API Level(AR Core가 최소로 지원하는 레벨) > Android 7.0 ‘Nougat’ (API Level 24)

   > XR Plug-in Management(내부적으로 어떤 SDK 사용할 건지) > AR Core 체크!

+ 나무쌓기

2019년 구글 정책의 변경으로 64비트 앱만 호환가능하기 때문에

   Player Settings > Player > Other Settings > Configuration Scripting Backend IL2CPP로 변경, ARM64(추가로) 체크

   빌드 세팅에서 Android Switch Platform해준다.

+ Build Failure

   Key 추가

https://blog.naver.com/lyw94k/221290872599

https://appletreeworkplace.tistory.com/6


그 후 블록 쌓기 블로그 따라함!

https://seongju0007.tistory.com/167

1. 평면 인식

   AR CameraClipping Planes: 100으로 변경, AR Camera Manager facing Direction: World(후면 카메라)

   AR Session OriginAR Plane Manager(실제 세계의 평면 인식) AR Point Cloud Manager(특징점 생성해서 인식) 컴포넌트 추가

   각 컴포넌트의 Prefab에는 각각 [Hierarchy] > 마우스 우클릭 > XR > AR Default PlanePoint Cloud 객체를 생성 후 Prefab화 해서 넣어 연결

-         AR Plane ManagerDetection Mode(뭘 인식할 건지): Horizontal(지면)/Vertical(벽면)/Everything(둘 다)


2. 인식한 평면 위에 쓰레기 던지기

   <TouchHelper>, <ThrowController>, <FeedController> 스트립트 작성

<TouchHelper>

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// static: TouchHelper will be used frequently
public static class TouchHelper
{
    // Use mouse click when executed in Unity Editor(for testing)
    #if UNITY_EDITOR
    public static bool Touch2 => Input.GetMouseButtonDown(1);  // If mouse right is clicked
    public static bool IsDown => Input.GetMouseButtonDown(0);  // If mouse left is clicked
    public static bool IsUp => Input.GetMouseButtonUp(0);
    public static Vector2 TouchPosition => Input.mousePosition;

    // Use touch(tab) when executed in actual devices
    #else
    public static bool Touch2 => Input.touchCount == 2 && (Input.GetTouch(1).phase == TouchPhase.Began);  // if touched by two fingers
    public static bool IsDown => Input.GetTouch(0).phase == TouchPhase.Began;  // true if a touch has just begun (by one finger)
    public static bool IsUp => Input.GetTouch(0).phase == TouchPhase.Ended;  // true if a touch has ended or been released
    public static Vector2 TouchPosition => Input.GetTouch(0).position;
    
    #endif
}

<ThrowController>

// 추가한 기능들 (#ES)
// 1. 한 번에 하나의 오브젝트만 들 수 있게끔 (기존에 이미 물체가 holding되어 있는데도 또 두 손가락으로 터치하면 새로운 물체 생성됨, 기존 물체와 부딪혀서 튕겨나감)
// 2. 한 번 떨어진 물체는 다시 움직일 수 없게끔 (한 번 떨어진 물체는 더 이상 충돌 불가하게 수정)

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ThrowController : MonoBehaviour
{
    private const float CameraDistance = 7.5f;
    public float positionY = 0.4f;
    public GameObject[] prefab;

    protected Camera mainCamera;
    protected GameObject HoldingObject;
    protected Vector3 InputPosition;

    // #ES1: Make sure to hold one object at a time (Prevent newly held object to hit the previous held one)
    public static bool isAlreadyTouched2 = false;

    void Start()
    {
        mainCamera = Camera.main; // Get the main camera of the scene
        Reset();
    }

    void Update()
    {
        // ES: Use mouse click if executed in Unity Editor(for testing)
        #if !UNITY_EDITOR
        if (Input.touchCount == 0) return;
        #endif

        InputPosition = TouchHelper.TouchPosition;  // If touched, get the touched position

        // touched with 2 fingers: Create new object to throw
        if(TouchHelper.Touch2)
        {
            if(!isAlreadyTouched2) {  // #ES1
                Reset();
            }
            return;
        }

        // dragged: Throw the object 
        if(HoldingObject)  // Check if the object is not null
        {
            // If a touch input has ended/been released
            if(TouchHelper.IsUp)
            {
                isAlreadyTouched2 = false;  // #ES1

                OnPut(InputPosition);  // Throw the object with force
                
                // #ES2 Trial1: Disable all kinds of collision for the thrown object
                // 평면에 닿자마자 충돌 불가로 변경 => 충돌을 아예 불가능하게 해버리면 평면에 쌓이지 않게 됨	 
                // Collider[] colliders = HoldingObject.GetComponentsInChildren<Collider>();
                // foreach (Collider collider in colliders)
                // {
                //     collider.enabled = false;
                // }

                // the object is no longer being held
                HoldingObject = null;  
                return;
            }
            Move(InputPosition);  // Move the object to the touched position
            return;
        }

        if (!TouchHelper.IsDown) return;

        // Check if the casted ray hits the object with tag "ThrownObject"(object thrown by the player)
        if(Physics.Raycast(mainCamera.ScreenPointToRay(InputPosition), out var hits, mainCamera.farClipPlane))
        {
            if(hits.transform.gameObject.tag.Equals("ThrownObject"))
            {
                // Assign the object that was hit
                HoldingObject = hits.transform.gameObject;
                OnHold();
            }
        }
    }

    // Stop holding the object
    protected virtual void OnPut(Vector3 pos)
    {
        HoldingObject.GetComponent<Rigidbody>().useGravity = true;  // Enable gravity for the held object
        HoldingObject.transform.SetParent(null);

        // Rigidbody rb = HoldingObject.GetComponent<Rigidbody>();
        // rb.isKinematic = true; // Make the Rigidbody kinematic to fix its position
    }

    // Move the held object towards the target position based on the camera's perspective
    private void Move(Vector3 pos)
    {
        pos.z = mainCamera.nearClipPlane * CameraDistance;
        HoldingObject.transform.position = Vector3.Lerp(HoldingObject.transform.position, mainCamera.ScreenToWorldPoint(pos), Time.deltaTime * 7f);
    }

    // Hold the object by positioning it in the game world based on the camera's viewpoint
    protected virtual void OnHold()
    {
        HoldingObject.GetComponent<Rigidbody>().useGravity = false;  // Hold the object by disabling gravity for it
        HoldingObject.transform.SetParent(mainCamera.transform);  // Make the object as a child of main camera (to move and rotate relative to the camera)
        HoldingObject.transform.rotation = Quaternion.identity;
        HoldingObject.transform.position = mainCamera.ViewportToWorldPoint(new Vector3(0.5f, positionY, mainCamera.nearClipPlane * CameraDistance));
    }

    // Create a new object and reset it to its initial state
    private void Reset()
    {
        var pos = mainCamera.ViewportToWorldPoint(new Vector3(0.5f, positionY, mainCamera.nearClipPlane * CameraDistance));
        var obj = Instantiate(prefab[0], pos, Quaternion.identity, mainCamera.transform);
        var rigidbody = obj.GetComponent<Rigidbody>();
        rigidbody.useGravity = false;
        rigidbody.velocity = Vector3.zero;
        rigidbody.angularVelocity = Vector3.zero;

        isAlreadyTouched2 = true;  // #ES1
    }
}
}

ThrowController를 상속받은 <FeedController>

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class FeedController : ThrowController
{
    private Vector2 _inputPositionPivot;

    // Throw the feed with force
    protected override void OnPut(Vector3 pos)
    {
        var rigidbody = HoldingObject.GetComponent<Rigidbody>();
        rigidbody.useGravity = true;
        var direction = mainCamera.transform.TransformDirection(Vector3.forward).normalized;
        var delta = (pos.y - _inputPositionPivot.y) * 100f / Screen.height;
        rigidbody.AddForce((direction + Vector3.up) * 4.5f * delta);
        HoldingObject.transform.SetParent(null);
        _inputPositionPivot.y = pos.y;
    }

    protected override void OnHold()
    {
        _inputPositionPivot = InputPosition;
    }
}

   던질 물체의 프리팹[Hierarchy]에 추가, 해당 프리팹의 Tag > ThrownObject로 설정 (해당 태그 새롭게 생성 후)

   프리팹에 Rigidbody(중력 작용 가능하게) 컴포넌트 추가

   (Collider 없는 경우: Mesh/Square/Sphere Collider(충돌 가능하게) 컴포넌트 추가)

n   Mesh Collider Rigidbody를 같이 쓰면 에러감 남! (is kinetic을 체크하라고 하는데 그렇게 되면 외부 힘의 영향을 받지 않고 고정되므로 여기서는 적합X)

   AR Session Origin<Feed Controller 스크립트> 컴포넌트 추가

   Prefab 하나 추가(+) > Element 0에 던질 물체의 프리팹 연결

 

+ 던졌는데 뚝뚝 떨어진다?: RigidbodyMass1로 설정 (너무 크면 무거워서 그럼)


3. 평면 위에 고정된 쓰레기통 위치시키기

참고: https://www.youtube.com/watch?v=KqzlGApWPEA

 

   AR Session OriginAR Raycast Manager 컴포넌트 추가

   쓰레기통 프리팹에 Rigidbody, Box Collider 추가

   Create Empty > 이름을 PlacementIndicator로 변경 > 그 하위에 3D ObjectQuad 추가 > Transform 조정

   Create Empty > 이름을 PlacementController라 변경 > <ARPlacement 스크립트> 컴포넌트 추가

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;

public class ARPlacement : MonoBehaviour
{

    public GameObject arObjectToSpawn;  // bowl to place on the plane
    public GameObject placementIndicator;
    private GameObject spawnedObject;
    private Pose PlacementPose;
    private ARRaycastManager aRRaycastManager;
    private bool placementPoseIsValid = false;

    // #ES: No more plane showing after he bowl is placed
    private ARPlaneManager arPlaneManager;

    void Start()
    {
        aRRaycastManager = FindObjectOfType<ARRaycastManager>();
        arPlaneManager = FindObjectOfType<ARPlaneManager>();

    }

    // need to update placement indicator, placement pose and spawn 
    void Update()
    {
        // Create the bowl instance and place it on the plane only once
        if(spawnedObject == null && placementPoseIsValid && Input.touchCount > 0 && Input.GetTouch(0).phase == TouchPhase.Began)
        {
            ARPlaceObject();
        }

        UpdatePlacementPose();
        UpdatePlacementIndicator();

    }
    void UpdatePlacementIndicator()
    {
        if(spawnedObject == null && placementPoseIsValid)
        {
            placementIndicator.SetActive(true);
            placementIndicator.transform.SetPositionAndRotation(PlacementPose.position, PlacementPose.rotation);
        }
        else
        {
            placementIndicator.SetActive(false);
            
            // Stop showing perceived planes
            // foreach (var plane in arPlaneManager.trackables) {
            //     // #Trial1: 오브젝트가 생성되면 Plane 인스턴스 생성을 멈춤 => 기존의 평면도 사라지게 됨
            //     plane.gameObject.SetActive(false);
            // }
        }
    }

    void UpdatePlacementPose()
    {
        // Get the position of the screen center
        var screenCenter = Camera.main.ViewportToScreenPoint(new Vector3(0.5f, 0.5f));
        var hits = new List<ARRaycastHit>();
        aRRaycastManager.Raycast(screenCenter, hits, TrackableType.Planes);

        placementPoseIsValid = hits.Count > 0;
        if(placementPoseIsValid)
        {
            PlacementPose = hits[0].pose;  // Get the very first hit
        }
    }

    // Create the new instance of the bowl
    void ARPlaceObject()
    {
        spawnedObject = Instantiate(arObjectToSpawn, PlacementPose.position, PlacementPose.rotation);
    
        var rigidbody = spawnedObject.GetComponent<Rigidbody>();
        rigidbody.isKinematic = true;
    }

}

   AR Placement 컴포넌트의 Ar Object To Spawn에는 쓰레기통 프리팹 연결, Placement Indicator에는 PlacementIndicator 연결

쓰레기통 놓을 위치 정할 때 target icon 애니메이션

   Assets 폴더 하위에 Textures 폴더 생성 > target icon 이미지 안에 위치

   Assets 폴더 하위에 Materials 폴더 생성 > 안에 새 Mateirial(이름: Indicator) 생성 > Indicator MaterialShaderUnlit/Transparent로 변경

   PlacementIndicator아래의 QuadMaterials > Element 0Indicator 연결

추가한 기능들
①      한 번에 하나의 오브젝트만 들 수 있게 제한
②      한 번 던진 오브젝트는 더 이상 움직이지 않게 (실패)
③      쓰레기통을 평면 위 지점 중, 카메라로부터 고정된 거리에 두기
-         https://www.youtube.com/watch?v=KqzlGApWPEA 
④      쓰레기통의 위치는 계속 고정되게 하기 (충돌은 가능하지만 움직이지는 않게)

4. 쓰레기통과 쓰레기의 충돌 이벤트 처리: UI에 들어간 횟수 카운팅

참고: 

https://ruinchan.tistory.com/28

https://novlog.tistory.com/entry/Unity3D-%EC%B6%A9%EB%8F%8C-%EC%B2%98%EB%A6%AC-Collision-Trigger-%EA%B4%80%EB%A0%A8-%ED%95%A8%EC%88%98%EC%98%88%EC%A0%9C

 

쓰레기통 프리팹 태그를 “Container”로 설정

   쓰레기통에 Box Collider 추가 > 크기 및 위치 조정(박스 바닥 부근에 위치한 얇은 박스) > Is Trigger 체크하기!

   HeirarchyUI > Canvas 추가 

  Canvas 아래에 UI > Legacy > Text 추가, 이름을 ‘SuccessScoreText’로 변경

   <ScoreManager> 스크립트 작성 > 쓰레기통 프리팹에 컴포넌트로 추가

-         OnCollisionEntered를 사용하게 되면, 던진 쓰레기가 쓰레기통과 충돌한 후 튕겨서 다시 충돌하는 것까지 모두 카운팅하게됨. 따라서 물리적인 충돌로 점수를 계산하는 건 비추!

-         비추하는 이유 2: 쓰레기통 바깥과 충돌한 후 튕겨 나가는 경우에도 점수를 카운팅하게 됨. 따라서 쓰레기통 안으로 들어간 경우에만 점수가 증가하도록 하자!

-         쓰레기통 프리팹에: 쓰레기통 안으로 들어갈 때만 충돌이 가능할 만한 위치와 크기의 투명 box collider를 추가, 해당 박스 콜라이더와 충돌한 경우에만 점수 1 증가!

•   스크립트 작성

<ScoreManager>

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class ScoreManager : MonoBehaviour
{
    private int currentScore = 0;
    private int targetScore = 5;
    private Text scoreText;

    private void Awake()
    {
        scoreText = GameObject.Find("SuccessScoreText").GetComponent<Text>();
    }

    //(충돌이 일어나는 동안 점수가 무한 증가하지 않게 쓰레기통 안에 투명 box collider 추가)
    private void OnTriggerEnter(Collider other) 
    {
        if(other.CompareTag("ThrownObject"))
        {
            UpdateScoreText();
        }
    }
    
    // Increase score by one for one collision 
    private void UpdateScoreText()
    {
        currentScore++;
        scoreText.text = "성공: " + currentScore + "/" + targetScore;
    }
}

5. 추가 기능들

5.1. 게임 재시작 버튼

참고: https://blog.naver.com/PostView.naver?blogId=harrison1995&logNo=221988980881&redirect=Dlog&widgetTypeCall=true&directAccess=false

•   Create Empty > 이름 ESMinigameManager로 변경

-         <ESMinigameManager> 스크립트 수정: 현재 씬을 재로딩하는 함수 추가

public void onClickRestartMinigame()
    {
        print("RESTART!");

        // Load the minigame scene again
        SceneManager.LoadScene(SceneManager.GetActiveScene().name);

    }

•   Canvas 아래에 Button 추가 > 이름을 RestartButton으로 변경

-         버튼 클릭 시 위 함수 호출하기: On Click() > + > ESMinigameManager 오브젝트 연결 > onClickRestartMinigame() 함수 연결

5.2. 미니게임 성공 시: 성공 화면 보여주기

참고: https://blog.naver.com/PostView.naver?blogId=harrison1995&logNo=221988980881&redirect=Dlog&widgetTypeCall=true&directAccess=false

 

•   Canvas 아래에 UI > Panel 추가, 이름을 GameoverPanel로 변경

•   (Canvas는 활성화, GameoverPanel만 비활성화시키기!

https://wikidocs.net/91263

•   <ESMinigameManager> 스크립트에 함수 ‘()’ 추가: 게임오버 판넬을 활성화하는 함수

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class ESMinigameManager : MonoBehaviour
{
    public GameObject gameoverPanel;

    // Start is called before the first frame update
    void Awake()
    {
        // When the scene is loaded, the gameover panel should be invisible
        gameoverPanel = GameObject.Find("Canvas").transform.Find("GameoverPanel").gameObject;
        gameoverPanel.SetActive(false);
    }

    public void onClickRestartMinigame()
    {
        print("RESTART!");

        // Load the minigame scene again
        SceneManager.LoadScene(SceneManager.GetActiveScene().name);

    }
    
    public void showGameoverPanel()
    {
        print("GAME OVER!");

        gameoverPanel.SetActive(true);
    }

    // Update is called once per frame
    void Update()
    {
        
    }

}

https://prosto.tistory.com/164

•   <ScoreManager> 스크립트에 함수 ‘checkScore()’ 추가: 목표 점수에 도달하면 게임 오버 함수를 호출하는 함수

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System.Collections;

public class ScoreManager : MonoBehaviour
{
    private int currentScore = 0;
    private int targetScore = 5;
    private Text scoreText;

    private void Awake()
    {
        scoreText = GameObject.Find("SuccessScoreText").GetComponent<Text>();
        scoreText.text = "성공: " + currentScore + "/" + targetScore;
    }

    private void Update() {
        checkScore();
    }

    //(충돌이 일어나는 동안 점수가 무한 증가하지 않게 쓰레기통 안에 투명 box collider 추가)
    private void OnTriggerEnter(Collider other) 
    {
        if(other.CompareTag("ThrownObject"))
        {
            UpdateScoreText();
        }
    }
    
    // Increase score by one for one collision 
    private void UpdateScoreText()
    {
        currentScore++;
        scoreText.text = "성공: " + currentScore + "/" + targetScore;
    }

    private void checkScore()
    {
        if(currentScore >= targetScore)
        {
            GameObject.Find("ESMinigameManager").GetComponent<ESMinigameManager>().showGameoverPanel();
        }
    }
}

+ 미니게임 가이드(안내) 텍스트 추가

•   Canvas > PlayerGuideText 이름으로 텍스트 추가

•   스크립트 <MinigameManager>에서 안내 텍스트를 배열에 저장하는 함수, 주어진 인덱스를 가지고 배열에 저장된 텍스트를 읽어서 Ui에 보여주는 함수를 추가 -> 스크립트 <ThrowController>, <ARPlacement>에서 MinigameManager 스크립트를 find한 후, 타이밍에 딱딱 맞게 가이드 텍스트를 보여주는 함수를 호출하는 코드 추가

 

Trashcan 프리팹 설정들


결과 시연 영상

EunsooMinigame.mp4
1.57MB


여유 있을 때 수정하면 좋을 점들

   Readability 개선: ‘성공 0/5’ 문구와, 다시하기 버튼을 평면 인식 > 쓰레기통 놓기 단계까지 수행한 후에 나타나게 한다

   평면 인식하는 주황색 plane의 색깔을 좀 더 투명하게 변경한다.

   그래픽 좀 더 예쁘게, 안내 텍스트는 박스 안에 보이게 디자인 변경

 

Comments