본문 바로가기
Development/C#

멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 45일차

by Mobics 2025. 1. 24.

 

목차


    ※ Rider 업데이트


    레이싱 게임 제작

    25.01.24

    게임 화면 개발 마무리

    코드 작성

    - 벽에 겹치는 현상 수정 --> CarController.cs의 Move()에서 transform.position을 -1.5f, 1.5f로 수정

    // 자동차 이동 Method
    public void Move(float direction)
    {
        transform.Translate(Vector3.right * (direction * moveSpeed * Time.deltaTime));
        transform.position = new Vector3(Mathf.Clamp(transform.position.x, -1.5f, 1.5f), 0, transform.position.z);
    }

    - State에 따른 동작 추가

    - 게임 오버 추가

     

    └ Unity 작업

    >> Road Prefab에 Gas 추가 및 Road Controller에 바인딩

     

    >> Gas Model의 BoxCollider를 삭제, 부모 Object인 Gas에 BoxCollider를 추가 및 조정

     

    └ Jira에서 풀리퀘스트 만들기

    --> 화살표 눌러서 Rebase 등 선택 가능

    --> 이제 병합돼서 원격 저장소에 branch가 필요 없으니 삭제하는 것

     

    >> 이제 다시 branch를 main으로 바꾸고 Pull 받기

    --> 업데이트 할게 남아서 생기는 에러

     

    ※ 해결 : 필요 없는 파일이면 그냥 Discard로 업데이트 목록에서 제거 가능

    --> 이후 다시 Pull 받기

     

    >> 로컬에 있는 branch도 제거 --> 나중에 쌓이면 복잡하기 때문

     

    메인화면 개발

    : Jira에서 지난 스프린트 완료 + 새 스프린트 시작

     

    >> "메인화면 개발" 이슈의 branch 만들기

    --> 만든 모습

     

    └ Unity 작업

    >> 좌/우 Button 묶기

    --> 자식 Object로 들어가면서 바뀐 Transform 값 초기화 (좌/우 둘다)

     

    >> StartPanel 추가

     

    >> StartPanel에 Text 추가 (Title)

     

    >> Button 추가 (Start)

     

    Commit & Push

     

    >> StartPanelController.cs 생성 후, StartPanel Object에 할당 + Prefab화

     

    ※ 나중에 EndPanel도 만들텐데, 그럼 Panel끼리 겹치게 되어 제어하기가 힘듦

    --> Instantiate로 생성 및 삭제하여 해결

     

    ※ 옆으로 치워놓고 필요할 때 Left와 Right를 0으로 만드는 방법이 있음

     

    >> GameManager에 각각 바인딩

    --> Panel을 Instantiate할 때, Canvas의 자식 Object로 생성되어야 하기 때문에, Canvas의 Transform을 받아서 넣어줌

     

    >> Start 버튼을 누르면 게임이 시작되도록 바인딩

     

    >> StartPanel을 복붙하여 EndPanel 생성

    : EndPanel도 StartPanelController.cs를 그대로 써도 무관함 (Text만 수정함)

    --> 이럴 줄 알았으면 처음부터 이름을 범용적으로 지을 걸~ (결국 이름을 바꾸지 않은 채로 EndPanel을 만듦)

     

    >> GameManager에 EndPanel Prefab 바인딩

     

    └ 코드 작성

    >> GameManager.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using TMPro;
    using UnityEngine;
    
    public class GameManager : MonoBehaviour
    {
        [SerializeField] private GameObject carPrefab;
        [SerializeField] private GameObject roadPrefab;
        
        // UI 관련 코드
        [SerializeField] private MoveButton leftMoveButton;
        [SerializeField] private MoveButton rightMoveButton;
        [SerializeField] private TMP_Text gasText;
        [SerializeField] private GameObject startPanelPrefab;
        [SerializeField] private GameObject endPanelPrefab;
        [SerializeField] private Transform canvasTransform; // Panel들이 Canvas의 자식Object로 생성되어야 하기 때문에 Canvas 위치 저장
        
        // 자동차
        private CarController _carController;
        
        // 도로 오브젝트 풀
        private Queue<GameObject> _roadPool = new();
        private int _roadPoolSize = 3; // 도로가 생기는 개수 제한
        
        // 도로 이동
        private List<GameObject> _activeRoads = new();
        
        // 만들어지는 도로의 index
        private int _roadIndex = 0;
        
        // 상태
        public enum State { Start, Play, End }
        public State GameState { get; private set; } = State.Start;
    
        // Singleton
        private static GameManager _instance;
        public static GameManager Instance
        {
            get
            {
                if (_instance == null)
                    _instance = FindObjectOfType<GameManager>();
                return _instance;
            }
        }
    
        private void Awake()
        {
            if (_instance != null && _instance != this)
                Destroy(this.gameObject);
            else
            {
                _instance = this;
            }
    
            Time.timeScale = 5f;
        }
    
        private void Start()
        {
            // Road Object Pool 초기화
            InitializeRoadPool();
            
            // 게임 상태 Start로 변경
            GameState = State.Start;
            
            // Start Panel 표시
            ShowStartPanel();
        }
    
        private void Update()
        {
            // 게임 상태에 따라 동작
            switch (GameState)
            {
                case State.Start:
                    break;
                case State.Play:
                    // 활성화 된 도로를 아래로 서서히 이동
                    foreach (var activeRoad in _activeRoads)
                    {
                        activeRoad.transform.Translate(-Vector3.forward * Time.deltaTime);
                    }
                    
                    // Gas 정보 출력
                    if (_carController != null)
                        gasText.text = _carController.Gas.ToString();
                    break;
                case State.End:
                    break;
            }
        }
    
        private void StartGame() // 게임 재시작 시, 다시 생성할 수 있게끔 따로 빼둠
        {
            // 도로 생성
            SpawnRoad(Vector3.zero);
            
            // 자동차 생성
            _carController = Instantiate(carPrefab, new Vector3(0, 0, -3f), Quaternion.identity).GetComponent<CarController>();
            
            // Left, Right move button에 자동차 컨트롤 기능 적용
            leftMoveButton.OnMoveButtonDown += () => _carController.Move(-1f);
            rightMoveButton.OnMoveButtonDown += () => _carController.Move(1f);
            
            // 게임 상태를 Play로 변경
            GameState = State.Play;
        }
    
        public void EndGame()
        {
            // 게임 상태 변경
            GameState = State.End;
            
            // 자동차 제거
            Destroy(_carController.gameObject);
            
            // 도로 제거
            foreach (var activeRoad in _activeRoads)
            {
                activeRoad.SetActive(false);
            }
            
            // 게임 오버 패널 표시
            ShowEndPanel();
        }
    
        #region UI
    
        // 시작 화면을 표시
        private void ShowStartPanel()
        {
            StartPanelController startPanelController =
                Instantiate(startPanelPrefab, canvasTransform).GetComponent<StartPanelController>();
            startPanelController.OnStartButtonClick += () =>
            {
                StartGame();
                Destroy(startPanelController.gameObject);
            };
        }
        
        // 게임 오버 화면을 표시
        private void ShowEndPanel()
        {
            StartPanelController endPanelController =
                Instantiate(endPanelPrefab, canvasTransform).GetComponent<StartPanelController>();
            endPanelController.OnStartButtonClick += () =>
            {
                Destroy(endPanelController.gameObject);
                ShowStartPanel();
            };
        }
    
        #endregion
    
        #region 도로 생성 및 관리
        
        // 도로 오브젝트 풀 초기화
        private void InitializeRoadPool()
        {
            for (int i = 0; i < _roadPoolSize; i++)
            {
                GameObject road = Instantiate(roadPrefab);
                road.SetActive(false);
                _roadPool.Enqueue(road);
            }
        }
        
        // 도로 오브젝트 풀에서 불러와 배치하는 함수 --> 계속 새로 생성되지 않게 Object Pool 활용
        public void SpawnRoad(Vector3 position)
        {
            GameObject road;
            if (_roadPool.Count > 0)
            {
                road = _roadPool.Dequeue();
                road.transform.position = position;
                road.SetActive(true);
            }
            else
            {
                road = Instantiate(roadPrefab, position, Quaternion.identity);
            }
            
            // 가스 아이템 생성
            if (_roadIndex > 0 && _roadIndex % 2 == 0)
            {
                Debug.Log("Spawn Gas Road Index : " + _roadIndex);
                road.GetComponent<RoadController>().SpawnGas();
            }
            
            // 활성화 된 길을 움직이기 위해 List에 저장
            _activeRoads.Add(road);
            _roadIndex++;
        }
    
        public void DestroyRoad(GameObject road)
        {
            road.SetActive(false);
            _activeRoads.Remove(road);
            _roadPool.Enqueue(road); // 다시 road를 사용하기 위해 Enqueue
        }
        #endregion
    }

     

    >> StartPanelController.cs 생성

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class StartPanelController : MonoBehaviour
    {
        // 방법 1 --> GameManager의 StartGame()을 public으로 해야 함
        // public void OnClickStartButton()
        // {
        //     GameManager.Instance.StartGame();
        // }
        
        // 방법 2 --> GameManager의 StartGame()을 private로 해도 됨
        public delegate void StartPanelDelegate();
        public event StartPanelDelegate OnStartButtonClick;
        
        public void OnClickStartButton()
        {
            OnStartButtonClick?.Invoke();
        }
    }

     

    >> 버그 수정

    1. 게임 재시작시, 연료가 3개씩 나오는 문제와 매번 생성되는 문제

    --> 게임 시작 시, Initialize해주지 않아서 기존의 상태가 유지되어 발생하는 듯

    --> _roadIndex도 StartGame()에서 리셋

     

    ※ GameManager.cs

    private void StartGame() // 게임 재시작 시, 다시 생성할 수 있게끔 따로 빼둠
        {
        	// _roadIndex 초기화
            _roadIndex = 0;
            
            // 도로 생성
            SpawnRoad(Vector3.zero);
            
            // 자동차 생성
            _carController = Instantiate(carPrefab, new Vector3(0, 0, -3f), Quaternion.identity).GetComponent<CarController>();
            
            // Left, Right move button에 자동차 컨트롤 기능 적용
            leftMoveButton.OnMoveButtonDown += () => _carController.Move(-1f);
            rightMoveButton.OnMoveButtonDown += () => _carController.Move(1f);
            
            // 게임 상태를 Play로 변경
            GameState = State.Play;
            InitializeRoadPool(); // 추가
        }

     

    2. RoadController.cs의 OnDisable()을 OnEnable()로 수정 (오브젝트 풀을 파괴하고 다시 만드는 게 아까워서?)

     

    3. CarController.cs의 GasCoroutine() 수정

    IEnumerator GasCoroutine()
    {
        while (true)
        {
            gas -= 10;
            yield return new WaitForSeconds(1f);
            if (gas <= 0) break;
        }
        // 게임 종료
        GameManager.Instance.EndGame();
    }

     

    4. 게임을 재시작 할 때마다 Road가 여러 개 생성되면서 Car의 속도가 빨라지는 문제

    --> StartGame 할 때, Move 함수가 계속 더해서 재시작 할 때마다 속도가 빨라지는 듯

     

    ※ EXIT 할 때, StartGame()에 있는 양쪽 버튼의 OnMoveButtonDown을 제거해야 한다.

    --> MoveSpeed를 더하는게 아니라 이벤트가 계속 추가돼서 한번 누를 때 계속 개수가 늘어난다.

     

    >> 풀리퀘스트 생성 후 Merge

     

    ※ Jira에서 이슈를 메인화면 개발과 게임 오버 화면 개발로 나눴는데 하다보니 그냥 다같이 만들어버림..

     

    └ 이후 과제

    • Hyper Racer에 적 차량을 구현
    • 게임의 난이도가 점점 높아지도록 구현
    • 테스트를 통해 구현한 적을 충분히 피해갈 수 있는지 확인

    테스트

    Test

    : 개발 과정에서 이루어지는 단위 테스트, 종합 테스트

    • 단위 테스트 : 함수 단위로 이루어지는 테스트
    • 종합 테스트 : 여러 기능이 통합된 상태에서 이루어지는 테스트

     

    TDD (Test Driven Development)

    : 테스트 주도 개발

    • 함수가 동작했을 때 의도하는 결과를 먼저 작성하고 함수가 개발 되었을 때 그 조건에 부합하는지 확인한다.
    • 개발 요구사항이 비교적 확실한 SW 개발에서는 TDD가 버그를 줄이고 리펙토링에 자유를 주는 방법이다.
    • 게임의 개발 명세는 수시로 변하기 때문에 전체를 TDD 해서 테스트 커버리지를 높게 가져가기는 어렵다.
    • TDD 컨셉 전체를 수용하기 보다는 필요한 부분에 단위 테스트와 종합 테스트를 진행한다.

     

    Unity Test Framework

    • 설정(Arrange) --> 수행(Act) --> 결과 예상(Assert) 패턴을 사용한다.
    • 스탠드얼론, Android, iOS 등에서도 테스트 가능

    : EditMode와 PlayMode에서 코드를 테스트 할 수 있다.

    • EditMode : 주로 유닛 테스트
    • PlayMode : 게임을 실행한 상태에서의 테스트로 주로 통합 테스트

     

    └ 함수 테스트

    : 우선 Github Desktop에서 Branch 새로 생성

     

    >> Test Runner 켜기

     

    >> Scripts 폴더 안에 'Calculator' 폴더를 만든 뒤, 만든 폴더를 선택한 다음 'Create' 클릭 (EditMode)

    ※ 이름은 'Tests' 그대로 생성

     

    >> Calculator 폴더 안에 ' CalculatorAssembly' 생성

     

    >> 'Tests'에 만든 CalculatorAssembly 바인딩

    --> 적용에 성공한 모습

     

    >> 'NewTestScript' 생성

     

    >> Calculator 폴더에 Calculator.cs 생성

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public static class Calculator
    {
        public static int Add(int a, int b)
        {
            return a + b;
        }
        
        public static int Subtract(int a, int b)
        {
            return a - b;
        }
        
        public static int Multiply(int a, int b)
        {
            return a * b;
        }
        
        public static int Divide(int a, int b)
        {
            return a / b;
        }
    }

     

    >> NewTestScript에서 테스트 코드 입력

    • [Test] : 함수 테스트
    • [UnityTest] : Unity의 Play 상태에서 테스트

    :  Calculator 함수 테스트

    --> Assert.AreEqual(원하는 결과값, 함수())

     

    ※ NewTestScript.cs

    using System.Collections;
    using System.Collections.Generic;
    using NUnit.Framework;
    using UnityEngine;
    using UnityEngine.TestTools;
    
    public class NewTestScript
    {
        // A Test behaves as an ordinary method
        [Test]
        public void NewTestScriptSimplePasses()
        {
            // Use the Assert class to test conditions
            Assert.AreEqual(2, Calculator.Add(1, 1));
            Assert.AreEqual(0, Calculator.Subtract(1, 1));
            Assert.AreEqual(4, Calculator.Multiply(2, 2));
            Assert.AreEqual(2, Calculator.Divide(4, 2));
        }
    
        // A UnityTest behaves like a coroutine in Play Mode. In Edit Mode you can use
        // `yield return null;` to skip a frame.
        [UnityTest]
        public IEnumerator NewTestScriptWithEnumeratorPasses()
        {
            // Use the Assert class to test conditions.
            // Use yield to skip a frame.
            yield return null;
        }
    }

     

    >> Test 해보기

    • Run All : NewTestScriptSimplePasses와 NewTestScriptWithEnumeratorPasses를 전부 실행
    • Run Selected : 원하는 것만 선택해서 실행
    • Clear Results : 이전 결과물 삭제

    --> 테스트에 성공한 모습 (따로 선택해서 Run Selected)

     

    ※ Calculator.cs의 Add()함수를 수정하여 일부러 틀리게 한다면

    --> 실패한 모습 (기대한 값은 2지만 결과 값은 0으로 나왔다고 안내해줌)

     

    └ 게임 테스트

    : 우선 Scripts폴더 안에 Game 폴더를 만들어서 기존의 Game 관련 Script들을 이동

     

    >> Game 폴더에 Test Runner(PlayMode)를 이용하여 'GameTests' 생성

     

    >> 'Create Test Script in current folder' 를 선택하여 'GameTestScript' 자동 생성

    --> 사진은 EditMode인데, 실제론 PlayMode

     

    >> 'Game' 폴더 안에 Assembly 파일 생성 (이름은 'GameAssembly')

     

    >> GameTests의 Assembly Definition References에 바인딩

     

    ※ TMPro 에러 고치기

     

    >> 나온 파일을 Code로 열기

    --> 기본 상태

     

    >> 코드 추가 ("name" 끝에 ',' 추가 잊지말기 )

    "references": [ "Unity.TextMeshPro" ],
    "optionalUnityReferences": [ "TestAssemblies" ]

    --> 추가한 모습

     

    >> GameTestScript에서 테스트 코드 입력

    1. Scene이 잘 로드 되는지 확인

    : GameTestScript.cs 에 테스트 할 코드 작성

    using System.Collections;
    using System.Collections.Generic;
    using NUnit.Framework;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    using UnityEngine.TestTools;
    
    public class GameTestScript
    {
        // A Test behaves as an ordinary method
        [Test]
        public void GameTestScriptSimplePasses()
        {
            // Use the Assert class to test conditions
        }
    
        // A UnityTest behaves like a coroutine in Play Mode. In Edit Mode you can use
        // `yield return null;` to skip a frame.
        [UnityTest]
        public IEnumerator GameTestScriptWithEnumeratorPasses()
        {
            // Use the Assert class to test conditions.
            // Use yield to skip a frame.
    
            SceneManager.LoadScene("Scenes/Game", LoadSceneMode.Single);
            
            yield return null;
        }
    }

    --> 성공한 모습

     

    2. 게임을 관리하는 오브젝트가 제대로 존재하는지 확인

    : GameManager 확인

    using System.Collections;
    using System.Collections.Generic;
    using NUnit.Framework;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    using UnityEngine.TestTools;
    
    public class GameTestScript
    {
        // A Test behaves as an ordinary method
        [Test]
        public void GameTestScriptSimplePasses()
        {
            // Use the Assert class to test conditions
        }
    
        // A UnityTest behaves like a coroutine in Play Mode. In Edit Mode you can use
        // `yield return null;` to skip a frame.
        [UnityTest]
        public IEnumerator GameTestScriptWithEnumeratorPasses()
        {
            // Use the Assert class to test conditions.
            // Use yield to skip a frame.
    
            // Scene 로드하기
            SceneManager.LoadScene("Scenes/Game", LoadSceneMode.Single);
            yield return waitForSceneLoad();
            
            // 필수 오브젝트 확인
            var gameManagerObj = GameObject.Find("GameManager");
            Assert.IsNotNull(gameManagerObj, "GameManager Object is null");
            
            yield return null;
        }
    
        // Scene이 다 로드될 때까지 기다려주는 코드
        private IEnumerator waitForSceneLoad()
        {
            while (SceneManager.GetActiveScene().buildIndex > 0)
            {
                yield return null;
            }
        }
    }

    --> 성공한 모습

     

    ※ 처음엔 GameManager Object만 찾았는데, 컴포넌트도 있는지 확인

    using System.Collections;
    using System.Collections.Generic;
    using NUnit.Framework;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    using UnityEngine.TestTools;
    
    public class GameTestScript
    {
        // A Test behaves as an ordinary method
        [Test]
        public void GameTestScriptSimplePasses()
        {
            // Use the Assert class to test conditions
        }
    
        // A UnityTest behaves like a coroutine in Play Mode. In Edit Mode you can use
        // `yield return null;` to skip a frame.
        [UnityTest]
        public IEnumerator GameTestScriptWithEnumeratorPasses()
        {
            // Use the Assert class to test conditions.
            // Use yield to skip a frame.
    
            // Scene 로드하기
            SceneManager.LoadScene("Scenes/Game", LoadSceneMode.Single);
            yield return waitForSceneLoad();
            
            // 필수 오브젝트 확인
            var gameManager = GameObject.Find("GameManager").GetComponent<GameManager>();
            Assert.IsNotNull(gameManager, "GameManager Object is null");
            
            yield return null;
        }
    
        // Scene이 다 로드될 때까지 기다려주는 코드
        private IEnumerator waitForSceneLoad()
        {
            while (SceneManager.GetActiveScene().buildIndex > 0)
            {
                yield return null;
            }
        }
    }

     

    3. StartButton 찾기

    : 우선 버튼 찾고 그 다음 버튼 클릭

    --> 반복

    using System.Collections;
    using System.Collections.Generic;
    using NUnit.Framework;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    using UnityEngine.TestTools;
    
    public class GameTestScript
    {
        // A Test behaves as an ordinary method
        [Test]
        public void GameTestScriptSimplePasses()
        {
            // Use the Assert class to test conditions
        }
    
        // A UnityTest behaves like a coroutine in Play Mode. In Edit Mode you can use
        // `yield return null;` to skip a frame.
        [UnityTest]
        public IEnumerator GameTestScriptWithEnumeratorPasses()
        {
            // Use the Assert class to test conditions.
            // Use yield to skip a frame.
    
            // Scene 로드하기
            SceneManager.LoadScene("Scenes/Game", LoadSceneMode.Single);
            yield return waitForSceneLoad();
            
            // 필수 오브젝트 확인
            var gameManager = GameObject.Find("GameManager").GetComponent<GameManager>();
            Assert.IsNotNull(gameManager, "GameManager Object is null");
    
            var startButton = GameObject.Find("StartButton");
            Assert.IsNotNull(startButton, "Start Button is Null");
            
            // Start 버튼 클릭
            startButton.GetComponent<UnityEngine.UI.Button>().onClick.Invoke();
            
            // 반복
            while (gameManager.GameState == GameManager.State.Play)
            {
                yield return null;
            }
        }
    
        // Scene이 다 로드될 때까지 기다려주는 코드
        private IEnumerator waitForSceneLoad()
        {
            while (SceneManager.GetActiveScene().buildIndex > 0)
            {
                yield return null;
            }
        }
    }

     

    4. 좌/우 양쪽 버튼 체크

    : 플레이 테스트할 때 우리가 버튼을 눌릴 수도 있지만 테스트가 직접 누르도록

    using System.Collections;
    using System.Collections.Generic;
    using NUnit.Framework;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    using UnityEngine.TestTools;
    
    public class GameTestScript
    {
        // A Test behaves as an ordinary method
        [Test]
        public void GameTestScriptSimplePasses()
        {
            // Use the Assert class to test conditions
        }
    
        // A UnityTest behaves like a coroutine in Play Mode. In Edit Mode you can use
        // `yield return null;` to skip a frame.
        [UnityTest]
        public IEnumerator GameTestScriptWithEnumeratorPasses()
        {
            // Use the Assert class to test conditions.
            // Use yield to skip a frame.
    
            // Scene 로드하기
            SceneManager.LoadScene("Scenes/Game", LoadSceneMode.Single);
            yield return waitForSceneLoad();
            
            // 필수 오브젝트 확인
            var gameManager = GameObject.Find("GameManager").GetComponent<GameManager>();
            Assert.IsNotNull(gameManager, "GameManager Object is null");
    
            var startButton = GameObject.Find("StartButton");
            Assert.IsNotNull(startButton, "Start Button is Null");
            
            // 게임 실행
            startButton.GetComponent<UnityEngine.UI.Button>().onClick.Invoke();
            
            // 게임 제어 관련 버튼 확인
            var leftButton = GameObject.Find("LeftButton");
            Assert.IsNotNull(leftButton, "Left Button is Null");
            var rightButton = GameObject.Find("RightButton");
            Assert.IsNotNull(rightButton, "Right Button is Null");
            
            // 반복
            while (gameManager.GameState == GameManager.State.Play)
            {
                yield return null;
            }
        }
    
        // Scene이 다 로드될 때까지 기다려주는 코드
        private IEnumerator waitForSceneLoad()
        {
            while (SceneManager.GetActiveScene().buildIndex > 0)
            {
                yield return null;
            }
        }
    }

     

    5. 가스가 어디서 나오는 지 체크

    : Raycast를 통해 확인

    using System.Collections;
    using System.Collections.Generic;
    using NUnit.Framework;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    using UnityEngine.TestTools;
    
    public class GameTestScript
    {
        // A Test behaves as an ordinary method
        [Test]
        public void GameTestScriptSimplePasses()
        {
            // Use the Assert class to test conditions
        }
    
        // A UnityTest behaves like a coroutine in Play Mode. In Edit Mode you can use
        // `yield return null;` to skip a frame.
        [UnityTest]
        public IEnumerator GameTestScriptWithEnumeratorPasses()
        {
            // Use the Assert class to test conditions.
            // Use yield to skip a frame.
    
            // Scene 로드하기
            SceneManager.LoadScene("Scenes/Game", LoadSceneMode.Single);
            yield return waitForSceneLoad();
            
            // 필수 오브젝트 확인
            var gameManager = GameObject.Find("GameManager").GetComponent<GameManager>();
            Assert.IsNotNull(gameManager, "GameManager Object is null");
    
            var startButton = GameObject.Find("StartButton");
            Assert.IsNotNull(startButton, "Start Button is Null");
            
            // 게임 실행
            startButton.GetComponent<UnityEngine.UI.Button>().onClick.Invoke();
            
            // 게임 제어 관련 버튼 확인
            var leftMoveButton = GameObject.Find("LeftMoveButton");
            Assert.IsNotNull(leftMoveButton, "Left Move Button is Null");
            var rightMoveButton = GameObject.Find("RightMoveButton");
            Assert.IsNotNull(rightMoveButton, "Right Move Button is Null");
            
            // 가스의 등장 위치 파악하기
            Vector3 leftPosition = new Vector3(-1f, 0.2f, -3); // Ray가 발사되는 위치
            Vector3 rightPosition = new Vector3(1f, 0.2f, -3);
            Vector3 centerPosition = new Vector3(0f, 0.2f, -3);
    
            float rayDistance = 10f; // Ray 사정거리
            Vector3 rayDirection = Vector3.forward; // Ray를 쏘는 방향
            
            // 반복
            while (gameManager.GameState == GameManager.State.Play)
            {
                // LayerMask로 특정 Object만 감지, 지금은 Ray를 3개 쏘지만 Ray 1개를 두리번거리게 감지해도 됨
                RaycastHit hit;
                if (Physics.Raycast(leftPosition, rayDirection, out hit, rayDistance, LayerMask.GetMask("Gas")))
                {
                    Debug.Log("Left");
                }
                else if (Physics.Raycast(rightPosition, rayDirection, out hit, rayDistance, LayerMask.GetMask("Gas")))
                {
                    Debug.Log("Right");
                }
                else if (Physics.Raycast(centerPosition, rayDirection, out hit, rayDistance, LayerMask.GetMask("Gas")))
                {
                    Debug.Log("Center");
                }
                else
                {
                    Debug.Log("None");
                }
                
                Debug.DrawRay(leftPosition, rayDirection * rayDistance, Color.red);
                Debug.DrawRay(rightPosition, rayDirection * rayDistance, Color.green);
                Debug.DrawRay(centerPosition, rayDirection * rayDistance, Color.blue);
                
                yield return null;
            }
        }
    
        // Scene이 다 로드될 때까지 기다려주는 코드
        private IEnumerator waitForSceneLoad()
        {
            while (SceneManager.GetActiveScene().buildIndex > 0)
            {
                yield return null;
            }
        }
    }

     

    ※ Raycast는 Layer를 통해 감지하므로 Gas Prefab의 Layer를 Gas로 추가 및 변경

     

    6. 자동차 체크

    : Gas를 감지해서 알아서 차가 움직여서 Gas를 먹도록

     

    ※ 게임의 플레이시간이 10초가 넘는지 체크 --> 넘지 못하면 실패하도록

     

    ※ 게임 테스트 속도가 느려서 timeScale을 수정

    --> CarSpeed는 timeScale을 높여도 그대로기 때문에 같이 수정

    --> GameManager.cs에서도 timeScale을 수정한게 겹치면 적용이 안됨

     

    ※ 최종 코드

    using System.Collections;
    using System.Collections.Generic;
    using NUnit.Framework;
    using UnityEngine;
    using UnityEngine.EventSystems;
    using UnityEngine.SceneManagement;
    using UnityEngine.TestTools;
    
    public class GameTestScript
    {
        private CarController _carController;
        private GameObject _leftMoveButton;
        private GameObject _rightMoveButton;
        
        // A Test behaves as an ordinary method
        [Test]
        public void GameTestScriptSimplePasses()
        {
            // Use the Assert class to test conditions
        }
    
        // A UnityTest behaves like a coroutine in Play Mode. In Edit Mode you can use
        // `yield return null;` to skip a frame.
        [UnityTest]
        public IEnumerator GameTestScriptWithEnumeratorPasses()
        {
            // Use the Assert class to test conditions.
            // Use yield to skip a frame.
            
            // 타임스케일 변경
            Time.timeScale = 10f;
    
            // Scene 로드하기
            SceneManager.LoadScene("Scenes/Game", LoadSceneMode.Single);
            yield return waitForSceneLoad();
            
            // 필수 오브젝트 확인
            var gameManager = GameObject.Find("GameManager").GetComponent<GameManager>();
            Assert.IsNotNull(gameManager, "GameManager Object is null");
    
            var startButton = GameObject.Find("StartButton");
            Assert.IsNotNull(startButton, "Start Button is Null");
            
            // 게임 실행
            startButton.GetComponent<UnityEngine.UI.Button>().onClick.Invoke();
            
            // 플레이어 자동차 확인
            _carController = GameObject.Find("Car(Clone)").GetComponent<CarController>();
            Assert.IsNotNull(_carController, "CarController is Null");
            
            // 게임 제어 관련 버튼 확인
            _leftMoveButton = GameObject.Find("LeftMoveButton");
            Assert.IsNotNull(_leftMoveButton, "Left Move Button is Null");
            _rightMoveButton = GameObject.Find("RightMoveButton");
            Assert.IsNotNull(_rightMoveButton, "Right Move Button is Null");
            
            // 가스의 등장 위치 파악하기
            Vector3 leftPosition = new Vector3(-1f, 0.2f, -3); // Ray가 발사되는 위치
            Vector3 rightPosition = new Vector3(1f, 0.2f, -3);
            Vector3 centerPosition = new Vector3(0f, 0.2f, -3);
            
            float rayDistance = 10f; // Ray 사정거리
            Vector3 rayDirection = Vector3.forward; // Ray를 쏘는 방향
            
            // 플레이 시간
            float elapsedTime = 0f;
            float targetTime = 10f;
            
            // 반복
            while (gameManager.GameState == GameManager.State.Play)
            {
                // LayerMask로 특정 Object만 감지, 지금은 Ray를 3개 쏘지만 Ray 1개를 두리번거리게 감지해도 됨
                RaycastHit hit;
                if (Physics.Raycast(leftPosition, rayDirection, out hit, rayDistance, LayerMask.GetMask("Gas")))
                {
                    Debug.Log("Left");
                    MoveCar(hit.point);
                }
                else if (Physics.Raycast(rightPosition, rayDirection, out hit, rayDistance, LayerMask.GetMask("Gas")))
                {
                    Debug.Log("Right");
                    MoveCar(hit.point);
                }
                else if (Physics.Raycast(centerPosition, rayDirection, out hit, rayDistance, LayerMask.GetMask("Gas")))
                {
                    Debug.Log("Center");
                    MoveCar(hit.point);
                }
                else
                {
                    Debug.Log("None");
                    MoveButtonUp(_leftMoveButton);
                    MoveButtonUp(_rightMoveButton);
                }
                
                Debug.DrawRay(leftPosition, rayDirection * rayDistance, Color.red);
                Debug.DrawRay(rightPosition, rayDirection * rayDistance, Color.green);
                Debug.DrawRay(centerPosition, rayDirection * rayDistance, Color.blue);
                
                // 시간 체크
                elapsedTime += Time.deltaTime;
                
                yield return null;
            }
    
            if (elapsedTime < targetTime)
            {
                Assert.Fail("Game Time is too short");
            }
    
            Time.timeScale = 1f;
        }
    
        // Scene이 다 로드될 때까지 기다려주는 코드
        private IEnumerator waitForSceneLoad()
        {
            while (SceneManager.GetActiveScene().buildIndex > 0)
            {
                yield return null;
            }
        }
    
        // Move Button Down
        private void MoveButtonDown(GameObject moveButton)
        {
            PointerEventData pointerEventData = new PointerEventData(EventSystem.current);
            ExecuteEvents.Execute(moveButton, pointerEventData, ExecuteEvents.pointerDownHandler);
        }
        
        // Move Button Up
        private void MoveButtonUp(GameObject moveButton)
        {
            PointerEventData pointerEventData = new PointerEventData(EventSystem.current);
            ExecuteEvents.Execute(moveButton, pointerEventData, ExecuteEvents.pointerUpHandler);
        }
        
        // 플레이어 자동차 이동
        private void MoveCar(Vector3 targetPosition)
        {
            if (Mathf.Abs(targetPosition.x - _carController.transform.position.x) < 0.1f)
            {
                MoveButtonUp(_rightMoveButton);
                MoveButtonUp(_leftMoveButton);
                return;
            }
            
            if (targetPosition.x < _carController.transform.position.x)
            {
                // 왼쪽으로 이동
                MoveButtonDown(_leftMoveButton);
                MoveButtonUp(_rightMoveButton);
            }
            else if (targetPosition.x > _carController.transform.position.x)
            {
                // 오른쪽으로 이동
                MoveButtonDown(_rightMoveButton);
                MoveButtonUp(_leftMoveButton);
            }
            else
            {
                MoveButtonUp(_rightMoveButton);
                MoveButtonUp(_leftMoveButton);
            }
        }
    }

     

    레이싱 게임 최종 코드 모음

    GameManager.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using TMPro;
    using UnityEngine;
    
    public class GameManager : MonoBehaviour
    {
        [SerializeField] private GameObject carPrefab;
        [SerializeField] private GameObject roadPrefab;
        
        // UI 관련 코드
        [SerializeField] private MoveButton leftMoveButton;
        [SerializeField] private MoveButton rightMoveButton;
        [SerializeField] private TMP_Text gasText;
        [SerializeField] private GameObject startPanelPrefab;
        [SerializeField] private GameObject endPanelPrefab;
        [SerializeField] private Transform canvasTransform; // Panel들이 Canvas의 자식Object로 생성되어야 하기 때문에 Canvas 위치 저장
        
        // 자동차
        private CarController _carController;
        
        // 도로 오브젝트 풀
        private Queue<GameObject> _roadPool = new();
        private int _roadPoolSize = 3; // 도로가 생기는 개수 제한
        
        // 도로 이동
        private List<GameObject> _activeRoads = new();
        
        // 만들어지는 도로의 index
        private int _roadIndex;
        
        // 상태
        public enum State { Start, Play, End }
        public State GameState { get; private set; } = State.Start;
    
        // Singleton
        private static GameManager _instance;
        public static GameManager Instance
        {
            get
            {
                if (_instance == null)
                    _instance = FindObjectOfType<GameManager>();
                return _instance;
            }
        }
    
        private void Awake()
        {
            if (_instance != null && _instance != this)
                Destroy(this.gameObject);
            else
            {
                _instance = this;
            }
    
            // Time.timeScale = 5f;
        }
    
        private void Start()
        {
            // Road Object Pool 초기화
            InitializeRoadPool();
            
            // 게임 상태 Start로 변경
            GameState = State.Start;
            
            // Start Panel 표시
            ShowStartPanel();
        }
    
        private void Update()
        {
            // 게임 상태에 따라 동작
            switch (GameState)
            {
                case State.Start:
                    break;
                case State.Play:
                    // 활성화 된 도로를 아래로 서서히 이동
                    foreach (var activeRoad in _activeRoads)
                    {
                        activeRoad.transform.Translate(-Vector3.forward * Time.deltaTime);
                    }
                    
                    // Gas 정보 출력
                    if (_carController != null)
                        gasText.text = _carController.Gas.ToString();
                    break;
                case State.End:
                    break;
            }
        }
    
        private void StartGame() // 게임 재시작 시, 다시 생성할 수 있게끔 따로 빼둠
        {
            // _roadIndex 초기화
            _roadIndex = 0;
            
            // 도로 생성
            SpawnRoad(Vector3.zero);
            
            // 자동차 생성
            _carController = Instantiate(carPrefab, new Vector3(0, 0, -3f), Quaternion.identity).GetComponent<CarController>();
            
            // Left, Right move button에 자동차 컨트롤 기능 적용
            leftMoveButton.OnMoveButtonDown += () => _carController.Move(-1f);
            rightMoveButton.OnMoveButtonDown += () => _carController.Move(1f);
            
            // 게임 상태를 Play로 변경
            GameState = State.Play;
            InitializeRoadPool();
        }
    
        public void EndGame()
        {
            // 게임 상태 변경
            GameState = State.End;
            
            // 자동차 제거
            Destroy(_carController.gameObject);
            
            // 도로 제거
            foreach (var activeRoad in _activeRoads)
            {
                activeRoad.SetActive(false);
            }
            
            // 게임 오버 패널 표시
            ShowEndPanel();
        }
    
        #region UI
    
        // 시작 화면을 표시
        private void ShowStartPanel()
        {
            StartPanelController startPanelController =
                Instantiate(startPanelPrefab, canvasTransform).GetComponent<StartPanelController>();
            startPanelController.OnStartButtonClick += () =>
            {
                StartGame();
                Destroy(startPanelController.gameObject);
            };
        }
        
        // 게임 오버 화면을 표시
        private void ShowEndPanel()
        {
            StartPanelController endPanelController =
                Instantiate(endPanelPrefab, canvasTransform).GetComponent<StartPanelController>();
            endPanelController.OnStartButtonClick += () =>
            {
                Destroy(endPanelController.gameObject);
                ShowStartPanel();
            };
        }
    
        #endregion
    
        #region 도로 생성 및 관리
        
        // 도로 오브젝트 풀 초기화
        private void InitializeRoadPool()
        {
            for (int i = 0; i < _roadPoolSize; i++)
            {
                GameObject road = Instantiate(roadPrefab);
                road.SetActive(false);
                _roadPool.Enqueue(road);
            }
        }
        
        // 도로 오브젝트 풀에서 불러와 배치하는 함수 --> 계속 새로 생성되지 않게 Object Pool 활용
        public void SpawnRoad(Vector3 position)
        {
            GameObject road;
            if (_roadPool.Count > 0)
            {
                road = _roadPool.Dequeue();
                road.transform.position = position;
                road.SetActive(true);
            }
            else
            {
                road = Instantiate(roadPrefab, position, Quaternion.identity);
            }
            
            // 가스 아이템 생성
            if (_roadIndex > 0 && _roadIndex % 2 == 0)
            {
                Debug.Log("Spawn Gas Road Index : " + _roadIndex);
                road.GetComponent<RoadController>().SpawnGas();
            }
            
            // 활성화 된 길을 움직이기 위해 List에 저장
            _activeRoads.Add(road);
            _roadIndex++;
        }
    
        public void DestroyRoad(GameObject road)
        {
            road.SetActive(false);
            _activeRoads.Remove(road);
            _roadPool.Enqueue(road); // 다시 road를 사용하기 위해 Enqueue
        }
        #endregion
    }

     

    CarController.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class CarController : MonoBehaviour
    {
        [SerializeField] private int gas = 100;
        [SerializeField] private float moveSpeed = 1f;
        
        public int Gas { get => gas; } // Gas 정보
    
        private void Start()
        {
            StartCoroutine(GasCoroutine());
        }
    
        IEnumerator GasCoroutine()
        {
            while (true)
            {
                gas -= 10;
                yield return new WaitForSeconds(1f);
                if (gas <= 0) break;
            }
            // 게임 종료
            GameManager.Instance.EndGame();
        }
        
        // 자동차 이동 Method
        public void Move(float direction)
        {
            transform.Translate(Vector3.right * (direction * moveSpeed * Time.deltaTime));
            transform.position = new Vector3(Mathf.Clamp(transform.position.x, -1.5f, 1.5f), 0, transform.position.z);
        }
        
        // Gas Item 확득 시, 호출되는 Method
        public void OnTriggerEnter(Collider other)
        {
            if (other.CompareTag("Gas"))
            {
                gas += 30;
                
                // 가스 아이템 숨기기
                other.gameObject.SetActive(false);
            }
        }
    }

     

    RoadController.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using Random = UnityEngine.Random;
    
    public class RoadController : MonoBehaviour
    {
        [SerializeField] private GameObject[] gasObjects;
    
        private void OnEnable()
        {
            // 모든 가스 아이템 비활성
            foreach (var gasObject in gasObjects)
            {
                gasObject.SetActive(false);
            }
        }
    
        // 플레이어 차량이 도로에 진입하면 다음 도로를 생성
        private void OnTriggerEnter(Collider other)
        {
            if (other.CompareTag("Player"))
            {
                GameManager.Instance.SpawnRoad(transform.position + new Vector3(0, 0, 10));
            }
        }
    
        // 플레이어 차량이 도로를 벗어나면 해당 도로를 풀에서 제거
        private void OnTriggerExit(Collider other)
        {
            if (other.CompareTag("Player"))
            {
                GameManager.Instance.DestroyRoad(gameObject);
            }
        }
        
        // 랜덤으로 가스 아이템을 표시
        public void SpawnGas()
        {
            int index = Random.Range(0, gasObjects.Length);
            gasObjects[index].SetActive(true);
        }
    }

     

    MoveButton.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class MoveButton : MonoBehaviour
    {
        public delegate void MoveButtonDelegate();
        public event MoveButtonDelegate OnMoveButtonDown; // 외부 객체에서 전달받으면 실행
        
        private bool _isDown;
    
        private void Update()
        {
            if (_isDown)
            {
                OnMoveButtonDown?.Invoke();
            }
        }
    
        public void ButtonDown()
        {
            _isDown = true;
        }
    
        public void ButtonUp()
        {
            _isDown = false;
        }
    }

     

    StartPanelController.cs

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class StartPanelController : MonoBehaviour
    {
        // 방법 1 --> GameManager의 StartGame()을 public으로 해야 함
        // public void OnClickStartButton()
        // {
        //     GameManager.Instance.StartGame();
        // }
        
        // 방법 2 --> GameManager의 StartGame()을 private로 해도 됨
        public delegate void StartPanelDelegate();
        public event StartPanelDelegate OnStartButtonClick;
        
        public void OnClickStartButton()
        {
            OnStartButtonClick?.Invoke();
        }
    }

     

    UIController.cs

    : 따로 작성하지 않음

     

    강사님 Github Clone하는 법

    1. Github 홈페이지에서 Github Desktop 열기

    --> 바로 Clone repository로 연결됨

     

    2. Github Desktop에서 Clone repository를 선택하여 URL을 넣어서 Clone

    --> 강사님 Github 홈페이지에서 URL 복사