본문 바로가기
Development/C#

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

by Mobics 2025. 2. 13.

 

목차


    강사님의 꿀팁

    >> Object의 역할에 따라 구분짓는 것이 중요하다

    : 아트, 기획, 프로그래밍을 각각 담당자를 정해서 나누는 것처럼

     

    >> Scene과 Scene 사이의 연동은 후반에 구현하는 것이 좋다.

    : 즉, Scene 하나만으로 구현이 되면 좋다.


    퀴즈 게임 만들기

    25.02.13

    카드 전환 구현

    : GamePanelController.cs에 구현

     

    >> 임시로 버튼을 배치하여 카드를 전환하도록

     

    ※ 나중에 Animation 추가해보자

     

    QuizCardController 관련

    >> 대략적인 흐름

    • GamePanelController에서 퀴즈 정보와 delegate(onCompleted)를 QuizCardController에게 전달
    • 퀴즈 정보는 Quiz file(.csv)이 가지고 있다.

     

    >> delegate와 event

    event : 외부에서 delegate를 호출하지 못하게, null을 할당하지 못하게 만들어준다.

     

    >> Struct vs Class

    - 참조 타입의 Class는 데이터가 있는 위치를 가르키기 때문에 어디서 참조하든 같은 값을 가진다.

    - 값 타입의 Struct는 복사하여 만드는 것이기 때문에 복사한 뒤, 실제 데이터가 변하면 실제와 복사한 것은 서로 다른 값을 가지게 된다.

     

    >> 그럼 QuizData를 왜 Struct로 만들었는가?

    : Quiz를 참조 형태로 전달 받으면 Quiz의 Data가 변할 가능성이 있기 때문에, 전달 받은 시점에서 그 상태 그대로 변하지 않도록 유지하기 위해 Struct를 사용한다.

     

    ※ 다른 수강생님의 답변

    : 깊게 들어가면 구조체는 스택에 저장되고 클래스는 힙에 저장되는데 스택은 할당이 빠르고 가비지 컬렉션의 영향을 적게 받습니다. 큰 데이터 혹은 데이터 공유와 참조가 필요하면 클래스를 사용하는게 더 좋습니다. 구조체로 만들더라도 내부 값이 참조값인 배열과 같은게 잔뜩 들어 있다면 불변성은 보장되지 않아요

     

    CSV파일로 퀴즈 만들기

    ※ Rons Data Edit 설치

    https://www.ronsplace.ca/products/ronsdataedit

     

    Professional CSV Editor - Rons Data Edit

    Professional modern CSV file editor for editing files in any tabular text format, combining elegance, power and ease of use.

    www.ronsplace.ca

     

    >> 새 파일 만들기

     

    >> Column 이름 변경

     

    >> 내용 추가 및 저장

    --> 'quiz-data' 로 저장...했었지만 이후 파일 이름 수정함 ('QuizData-0')

     

    ※ Visual code로 csv파일을 열면 Rainbow CSV 설치

    --> 뜨는 팝업창을 눌러 설치 가능하다.

     

    ※ 혹시 팝업창을 껐다면 Extension에서 설치 가능

     

    >> 적용된 모습

     

    >> Rons Data Edit의 특징

    : Column의 타입을 지정 가능

     

    Quiz Data 추가

    : QuizDataController.cs 생성 및 QuizData-0.csv 파일 추가

     

    ※ RegularExpressions (Regex) --> 정규표현식 (정규식)

    >> 설명해놓은 사이트 (강사님이 알려주심)

    https://learn.microsoft.com/ko-kr/dotnet/standard/base-types/regular-expressions

     

    .NET 정규식 - .NET

    .NET에서 정규식을 사용하여 특정 문자 패턴을 찾고, 텍스트의 유효성을 검사하고, 텍스트 부분 문자열로 작업하고, 추출된 문자열을 컬렉션에 추가합니다.

    learn.microsoft.com

     

    >> 정규식 생성기 (다른 수강생님의 정보)

    https://regex-generator.olafneumann.org/

     

    Regex Generator - Creating regex is easy again!

     

    regex-generator.olafneumann.org

     

    >> 하나의 Quiz Data 파일에 모든 Quiz가 포함되어 있을 필요는 없다.

    : 이미 푼 문제도 있을테고 문제를 푸는 시간이 한정적이므로 모든 문제를 불러올 필요가 없다.

    --> Quiz Data 파일을 여러 개로 나누고 Stage에 맞는 파일만 불러오기

    --> 정한 파일 이름 형식대로 이름 변경

     

    >> 테스트 중 자꾸 발생했던 오류

    해결법 : QuizData-0.csv 파일의 ',,,,' 을 지우자 (혹시 마지막 줄에 빈 여백이 있다면 그것도 지우자)

    >> 실제 파일의 이름도 변경해주고, 실제 파일에서 변경사항이 있었다면 기존 파일을 지우고 다시 추가하자!

     

    Quiz Card 구성

    >> Text 추가

    >> Button 추가

    : 빈 오브젝트로 Buttons를 만들어서 부모 오브젝트로 변경

    --> Anchor는 Alt + Shift

    --> Button들도 Width, Height 조정 및 Button의 Text도 중앙 정렬

     

    >> CardController.cs에 바인딩

    : Button을 바인딩 할 때 순서가 중요하다 --> Quiz의 정답에 대한 것을 Index로 담고 있기 때문

    --> Description은 뒤에 나올 카드에서 바인딩 할 예정 (잠시 비워둠)

     

    활동

    >> 10개의 퀴즈 카드를 순서대로 보여주는 코드 작성

    --> QuizData-1.csv 파일을 읽어서 10개의 퀴즈를 화면에 순서대로 표시하기 (강사님과 함께 작성함)

     

    >> 폰트 설정하기

    : 47일차 블로그 글 참고 (나눔고딕 볼드체 사용)

    --> 적용된 모습

     

    최종 코드

    >> ObjectPool.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class ObjectPool : MonoBehaviour
    {
        [SerializeField] private GameObject prefab;
        [SerializeField] private int poolSize;
        [SerializeField] private Transform parent;  // Object Pool이 생성될 Parent
    
        private Queue<GameObject> _pool;
        private static ObjectPool _instance; // Singleton을 상속받지 않고 패턴만 사용
    
        public static ObjectPool Instance
        {
            get { return _instance; }
        }
    
        private void Awake()
        {
            _instance = this;
            _pool = new Queue<GameObject>();
        }
    
        /// <summary>
        /// Object Pool에 새로운 Object 생성 Method
        /// </summary>
        private void CreateNewObject()
        {
            GameObject newObject = Instantiate(prefab, parent);
            newObject.SetActive(false);
            _pool.Enqueue(newObject);
        }
    
        /// <summary>
        /// Object Pool에 있는 Object를 반환하는 Method
        /// </summary>
        /// <returns>Object Pool에 있는 Object</returns>
        public GameObject GetObject()
        {
            if (_pool.Count == 0) CreateNewObject();
                
            GameObject obj = _pool.Dequeue();
            obj.SetActive(true);
            return obj;
        }
    
        /// <summary>
        /// 사용한 Object를 Object Pool로 되돌려 주는 Method
        /// </summary>
        /// <param name="obj">반환할 Object</param>
        public void ReturnObject(GameObject obj)
        {
            obj.SetActive(false);
            _pool.Enqueue(obj);
        }
    }

    --> Awake()에서 poolSize만큼 반복하여 CreateNewObject() 해주던 것 삭제

    >> GamePanelController.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.UI;
    
    public class GamePanelController : MonoBehaviour
    {
        private GameObject _firstQuizCardObject;
        private GameObject _secondQuizCardObject;
    
        private List<QuizData> _quizDataList;
        
        private int _lastGeneratedQuizIndex;
    
        private const int MAX_QUIZ_COUNT = 10;
        
        private void Start()
        {
            _quizDataList = QuizDataController.LoadQuizData(0); // 테스트 코드
            
            InitQuizCard();
        }
    
        private void InitQuizCard()
        {
            _firstQuizCardObject = ObjectPool.Instance.GetObject();
            _firstQuizCardObject.GetComponent<QuizCardController>().SetQuiz(_quizDataList[0], OnCompletedQuiz);
            
            _secondQuizCardObject = ObjectPool.Instance.GetObject();
            _secondQuizCardObject.GetComponent<QuizCardController>().SetQuiz(_quizDataList[1], OnCompletedQuiz);
            
            //var thirdCardObject = ObjectPool.Instance.GetObject();
    
            _secondQuizCardObject.GetComponent<Image>().color = Color.gray;
            //thirdCardObject.GetComponent<Image>().color = Color.black;
            
            SetQuizCardPosition(_firstQuizCardObject, 0);
            SetQuizCardPosition(_secondQuizCardObject, 1);
            
            // 마지막으로 생성된 Quiz Index
            _lastGeneratedQuizIndex = 1;
        }
    
        private void OnCompletedQuiz(int cardIndex)
        {
            
        }
    
        private void SetQuizCardPosition(GameObject quizCardObject, int index)
        {
            var quizCardTransform = quizCardObject.GetComponent<RectTransform>();
            if (index == 0)
            {
                quizCardTransform.anchoredPosition = new Vector2(0, 0);
                quizCardTransform.localScale = Vector3.one;
                quizCardTransform.SetAsLastSibling(); // 같은 depth에서 마지막으로 이동 --> 카드가 앞으로 배치됨
            }
            else if (index == 1)
            {
                quizCardTransform.anchoredPosition = new Vector2(0, 160);
                quizCardTransform.localScale = Vector3.one * 0.9f;
                quizCardTransform.SetAsFirstSibling(); // 같은 depth에서 처음으로 이동
            }
        }
    
        private void ChangeQuizCard()
        {
            if (_lastGeneratedQuizIndex >= MAX_QUIZ_COUNT) return;
            
            var temp = _firstQuizCardObject;
            _firstQuizCardObject = _secondQuizCardObject;
            _secondQuizCardObject = ObjectPool.Instance.GetObject();
    
            if (_lastGeneratedQuizIndex < _quizDataList.Count - 1)
            {
                _lastGeneratedQuizIndex++;
                _secondQuizCardObject.GetComponent<QuizCardController>()
                    .SetQuiz(_quizDataList[_lastGeneratedQuizIndex], OnCompletedQuiz);
            }
            
            SetQuizCardPosition(_firstQuizCardObject, 0);
            SetQuizCardPosition(_secondQuizCardObject, 1);
            
            ObjectPool.Instance.ReturnObject(temp);
        }
    
        public void OnClickNextButton()
        {
            ChangeQuizCard();
        }
    }

     

    >> QuizCardController.cs

    using System.Collections;
    using System.Collections.Generic;
    using TMPro;
    using UnityEngine;
    using UnityEngine.UI;
    
    public struct QuizData
    {
        public string question;
        public string description;
        public int type;
        public int answer;
        public string firstOption;  // 원래는 string[] Options로 했었다
        public string secondOption;
        public string thirdOption;
    }
    
    public class QuizCardController : MonoBehaviour
    {
        [SerializeField] private TMP_Text questionText; // 퀴즈
        [SerializeField] private TMP_Text descriptionText; // 설명
        [SerializeField] private Button[] optionButtons;   // 보기 --> 타입을 TMP_Text로 해서 text를 직접 받아도 된다. 
        
        public delegate void QuizCardDelegate(int cardIndex);
        private event QuizCardDelegate onCompleted;
        
        public void SetQuiz(QuizData quizData, QuizCardDelegate onCompleted)
        {
            // 1. 퀴즈
            // 2. 설명
            // 3. 타입 (0: OX퀴즈, 1: 보기 3개 객관식)
            // 4. 정답
            // 5. 보기 (1, 2, 3)
            
            // 퀴즈 데이터 표현
            questionText.text = quizData.question;
            //descriptionText.text = quizData.description;
            
            var firstButtonText = optionButtons[0].GetComponentInChildren<TMP_Text>();
            firstButtonText.text = quizData.firstOption;
            var secondButtonText = optionButtons[1].GetComponentInChildren<TMP_Text>();
            secondButtonText.text = quizData.secondOption;
            var thirdButtonText = optionButtons[2].GetComponentInChildren<TMP_Text>();
            thirdButtonText.text = quizData.thirdOption;
            
            this.onCompleted = onCompleted;
        }
    }

     

    >> QuizDataController.cs

    using System.Collections.Generic;
    using UnityEngine;
    using System.Text.RegularExpressions; // Regex를 사용하기 위해 선언
    
    public static class QuizDataController
    {
        static string ROW_SEPARATOR = @"\r\n|\n\r|\n|\r";
        static string COL_SEPARATOR = @",(?=(?:[^""]*""[^""]*"")*(?![^""]*""))";
        private static char[] TRIM_CHARS = { '\"' }; // Trim : 특정한 문자 제거
        
        public static List<QuizData> LoadQuizData(int stageIndex)
        {
            // 퀴즈를 스테이지별로 나누기 위해 불러오는 quizData 파일의 이름 형식을 고정
            var fileName = "QuizData-" + stageIndex;
            
            // Resources.Load()는 Object 타입으로 반환하기 때문에 'as'로 형변환
            TextAsset quizDataAsset = Resources.Load(fileName) as TextAsset;
            var lines = Regex.Split(quizDataAsset.text, ROW_SEPARATOR);
    
            var quizDataList = new List<QuizData>();
    
            for (var i = 1; i < lines.Length; i++)
            {
                var values = Regex.Split(lines[i], COL_SEPARATOR);
                
                QuizData quizData = new QuizData();
                
                for (var j = 0; j < values.Length; j++)
                {
                    var value = values[j];
                    // value의 시작(TrimStart)과 끝(TrimEnd)에 " 가 있으면 잘라주고 "\\"는 ""로 바꿔준다.
                    value = value.TrimStart(TRIM_CHARS).TrimEnd(TRIM_CHARS).Replace("\\", "");
                    
                    switch (j)
                    {
                        case 0:
                            quizData.question = value;
                            break;
                        case 1:
                            quizData.description = value;
                            break;
                        case 2:
                            quizData.type = int.Parse(value); // int.Parse()를 통해 int로 변환
                            break;
                        case 3:
                            quizData.answer = int.Parse(value);
                            break;
                        case 4:
                            quizData.firstOption = value;
                            break;
                        case 5:
                            quizData.secondOption = value;
                            break;
                        case 6:
                            quizData.thirdOption = value;
                            break;
                    }
                }
                quizDataList.Add(quizData);
            }
            return quizDataList;
        }
    }

     

    스스로 해보기

    1. Animation 추가

    2. Card 색 변경


    C# 단기 교육 보강

    8일차

    Hanoi Tower

    ※ 자료구조 로직을 활용해서 만드는 것에 집중하고, 세부적인 예외처리나 UI는 만들지 않음

    : 나중에 개인적으로 공부하면서 따로 완성해보자

    --> 현재 하드코딩으로 구현 중이니 나중에 Pattern, Action, Event, Lambda, Callback 등을 활용해서 만들어보자

     

    >> 3개의 Bar를 Board의 자식 오브젝트로 이동 후, Board를 Prefab화

     

    >> MainCamera의 Position 변경

     

    └ Donut

    >> Shape의 Mesh Collider 삭제

    : Mesh Collider와 Rigidbody가 같이 호환이 안 되기 때문에 --> Cube로 Collider 대체

     

    >> Donut의 자식으로 Cube를 4개 만들어서 Donut을 감싸도록 만든 후, Cube의 Mesh Renderer를 꺼줌

     

    ※ Donut의 색상도 입혀줌

    >> 이후 Donut의 변경사항을 Prefab에 저장 (Override)

    --> 이후 Hierarchy에서 Donut 삭제

     

    └ GameManager

    >> 빈 게임 오브젝트로 Game Manager 생성 후, GameManager.cs 추가하고 바인딩

    : GameManager.cs는 namespace 활용

    --> GameManager라는 이름이 많이 쓰이므로 namespace로 구분 (사실 지금 필수는 아님)

     

    >>Stack을 활용하여 Donut 옮기기 구현

     

    └ Bar

    1. 3개의 Bar에 모두 DonutBar.cs 추가 각 Bar에 맞게 Type 설정

    2. 3개의 Bar에 모두 Capsule Collider를 하나 더 추가하고 아래 Collider에는 Trigger 체크 및 사이즈 조정

     

    └ 오류 수정

    : Hanoi Tower는 더 큰 원반이 작은 원반 위에 올라갈 수 없으므로 이를 수정

     

    >> Donut.cs 생성하여 Donut에 추가

     

    └ 최종 코드

    >> GameManager.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    namespace Hanoi_Tower
    {
        public class GameManager : MonoBehaviour
        {
            [SerializeField] private GameObject donutPrefab;
            
            public enum HanoiLevel { LV1 = 3, LV2 = 4, LV3 = 5 } // 생성되는 Donut 개수
            public HanoiLevel hanoiLevel;
            
            public DonutBar[] donutBars;
    
            public static bool isSelected = false;
            
            // Unity Inspector에서 보기 위해 억지로 하드코딩 --> 실제로는 쓰지 말자
            public List<GameObject> leftBar = new();
            public List<GameObject> centerBar = new();
            public List<GameObject> rightBar = new();
            
            public GameObject selectedDonut;
    
            IEnumerator Start()
            {
                for (int i = (int)hanoiLevel; i >= 1; i--)
                {
                    // 도넛 생성 위치
                    // Board의 X의 Scale이 10이라서 (int)DonutBar.BarType.LEFT에 0.1f를 곱할 필요가 없다.
                    Vector3 createPos = new Vector3((int)DonutBar.BarType.LEFT, 3.5f, 0f);
                    
                    GameObject donutObj = Instantiate(donutPrefab, createPos, Quaternion.identity); // 도넛 생성
                    donutObj.name = "Donut_" + i; // 도넛 이름 설정
                    donutObj.transform.localScale = Vector3.one * (i * 0.3f + 1f); // 도넛 크기 설정
    
                    donutObj.GetComponent<Donut>().donutNumber = i; // 도넛에 번호 부여
                    
                    donutBars[0].PushDonut(donutObj, true); // 생성한 Donut을 LeftBar에 Push (index 0 == left)
                    
                    yield return new WaitForSeconds(1f);
                }
            }
        }
    }

     

    >> DonutBar.cs

    using System.Collections;
    using System.Collections.Generic;
    using System.Linq;
    using UnityEngine;
    using Hanoi_Tower; // namespace를 사용하기 위해 선언
    
    public class DonutBar : MonoBehaviour
    {
        // position.x 값인데, enum은 int이므로 10배 크게 작성
        public enum BarType { LEFT = -3, CENTER = 0, RIGHT = 3 }
        public BarType barType;
    
        public Stack<GameObject> stack = new();
    
        // Unity Inspector에서 보기 위해 억지로 하드코딩 --> 실제로는 쓰지 말자
        public GameManager gameManager;
    
        void OnMouseDown()
        {
            if (!GameManager.isSelected) // 도넛을 가져올 기둥을 선택
            {
                gameManager.selectedDonut = PopDonut();
                GameManager.isSelected = true;
            }
            else // 도넛을 넣을 기둥 선택
            {
                PushDonut(gameManager.selectedDonut, false);
            }
        }
    
        public void PushDonut(GameObject donut, bool isInit)
        {
            if (!isInit) // 이것도 완전 하드코딩
            {
                if (stack.Count > 0)
                {
                    var peekNumber = stack.Peek().GetComponent<Donut>().donutNumber;
                    var pushNumber = donut.GetComponent<Donut>().donutNumber;
                
                    if (pushNumber < peekNumber) // 큰 도넛이 작은 도넛 위에 올라가는 것 방지
                    {
                        GameManager.isSelected = false;
                
                        stack.Push(donut);
                        donut.transform.position = new Vector3((int)barType, 3.5f, 0f);
            
                        // Unity Inspector에서 보기 위해 억지로 하드코딩 --> 실제로는 쓰지 말자
                        switch (barType)
                        {
                            case BarType.LEFT:
                                gameManager.leftBar = stack.ToList();
                                break;
                            case BarType.CENTER:
                                gameManager.centerBar = stack.ToList();
                                break;
                            case BarType.RIGHT:
                                gameManager.rightBar = stack.ToList();
                                break;
                        }
                    }
                    else
                    {
                        Debug.Log($"놓으려는 도넛은 {pushNumber}이고, 해당 기둥의 도넛은 {peekNumber}입니다.");
                    }
                }
                else
                {
                    GameManager.isSelected = false;
                
                    stack.Push(donut);
                    donut.transform.position = new Vector3((int)barType, 3.5f, 0f);
            
                    // Unity Inspector에서 보기 위해 억지로 하드코딩 --> 실제로는 쓰지 말자
                    switch (barType)
                    {
                        case BarType.LEFT:
                            gameManager.leftBar = stack.ToList();
                            break;
                        case BarType.CENTER:
                            gameManager.centerBar = stack.ToList();
                            break;
                        case BarType.RIGHT:
                            gameManager.rightBar = stack.ToList();
                            break;
                    }
                }
            }
            else
            {
                stack.Push(donut);
                donut.transform.position = new Vector3((int)barType, 3.5f, 0f);
                
                switch (barType) // 하드코딩
                {
                    case BarType.LEFT:
                        gameManager.leftBar = stack.ToList();
                        break;
                    case BarType.CENTER:
                        gameManager.centerBar = stack.ToList();
                        break;
                    case BarType.RIGHT:
                        gameManager.rightBar = stack.ToList();
                        break;
                }
            }
        }
    
        public GameObject PopDonut()
        {
            if (stack.Count > 0) return stack.Pop();
            
            return null;
        }
    }

     

    >> Donut.cs

    using UnityEngine;
    
    public class Donut : MonoBehaviour
    {
        public int donutNumber;
    }

     

    알고리즘

    >> Big-O

    : 코드의 성능 분석

     

    >> 복잡도 비교

     

    >> 선형 구조와 비선형 구조

     

    └ 그래프 (Graph)

    : 정점과 간선으로 구성된 자료구조

     

    >> 깊이 우선 탐색(DFS)과 너비 우선 탐색(BFS)

     

    └ 트리 (Tree)

    : 정점과 간선으로 계층적 관계를 표현하는 자료구조

     

    >> 트리의 순회

     

    └ 재귀 함수 (Recursion)

    : 자기 자신을 다시 호출하는 함수 --> 반복된 로직에 사용하면 좋다.

     

    >> Hanoi Tower의 재귀