목차
※ 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 복사
'Development > C#' 카테고리의 다른 글
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 47일차 (1) | 2025.02.04 |
---|---|
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 46일차 (0) | 2025.02.03 |
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 44일차 (0) | 2025.01.23 |
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 43일차 (0) | 2025.01.22 |
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 42일차 (0) | 2025.01.21 |