본문 바로가기
Development/C#

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

by Mobics 2025. 2. 26.

 

목차


    퀴즈 게임 만들기

    25.02.26

    Quiz Game의 Stage 팝업을 재사용 Cell 방식으로 만들기

    : 강사님의 구현방식은 3개의 Stage를 1개의 Cell로 생각 --> 실제론 Stage 1개가 Cell이고, Stage 3개를 묶어주는 하나의 타입을 새로 만듦 (배열을 활용)

     

    └ 재사용 Cell 방식 구현

    >> StagePopupPanelController.cs에 Scroll View 바인딩

     

    ※ Cell 영역 안에 있는 Cell의 개수에 따른 차이

    : Cell의 위치를 지정할 때, Cell 개수가 홀수인지 짝수인지에 따라 centerIndex 값이 달라진다.

    • 홀수 : Cell 개수 / 2 를 하면 centerIndex가 나온다
    • 짝수 : Cell 개수 / 2 를 해도 한 쪽에 치우져 있으므로 0.5f 만큼 Index값을 빼준다.

    --> centerIndex를 기준으로 그보다 작은 Index는 왼쪽에, 그보다 큰 Index는 오른쪽에 배치하면 된다.

     

    >> Stage Cell Prefab의 Anchor과 Width, Height 조정

    --> Anchor는 Alt + Shift

     

    >> StagePopupPanelController.cs에 값 바인딩

     

    >> Stage Popup Panel의 자식으로 빈 게임 오브젝트로 'Obejct Pool' 생성

    : ObjectPool.cs 추가 및 바인딩

     

    >> Content의 'Grid Layout Group'과 'Content Size Fitter' Component 제거

    (나는 비활성화로 해두자)

     

    >> StagePopupPanelController.cs에 값 바인딩

     

    └ Scroll 동작 구현

    >> OnValueChanged에 함수 바인딩

     

    게임에 광고 넣기

    : Admob 사용

     

    >> 광고 플랫폼

    Unity Ads

    • 장점 : Unity를 위해 만들어졌기 때문에 적용이 쉽다.
    • 단점 : 다른 광고 플랫폼에 비해 광고가 부족하다. --> 광고가 나오지 않는 문제 발생

    Admob

    • 장점 : 다른 플랫폼에 비해 광고가 많다.
    • 단점 : Unity에 적용하기가 비교적 어렵다.

    >> 광고 종류

    • 배너 광고 : 화면 위나 아래에 계속 떠있기 때문에, 게임의 디자인을 해치고 화면이 좁게 느껴지게 만든다. 유저가 실수로 눌렀을 때 광고를 보게 되면 받는 스트레스는 덤.
    • 전면 광고 : 게임 중간에 띄우는 광고로, 약간의 시간이 지나면 종료할 수 있다.
    • 보상형 광고 : 일정 시간 동안 광고를 종료할 수 없는 광고, 광고를 전부 시청한 뒤에 보상을 주는 형태

     

    >> Admob 시작하기

    1. 앱 추가

     

     

    2. 광고 단위 만들기

    → 배너

    --> 이후 완료 (나오는 앱 ID와 광고 단위 ID는 나중에도 볼 수 있다.)

     

    → 전면

    --> 이후 완료

     

    → 리워드

    --> 이후 완료

     

    └ 초기화 + 배너광고

    >> SDK 설치

    : Google에 'Admob Unity SDK' 검색

    --> 업데이트가 될 때마다 방법이 달라지기 때문에 이 글의 방식보다 가이드를 따르자 (Github를 통해 설치)

    https://developers.google.com/admob/unity/quick-start?hl=ko

     

    시작하기  |  Unity  |  Google for Developers

    A mobile ads SDK for AdMob publishers who are building apps on Unity.

    developers.google.com

     

    1. 다운 받은 UnityPackage 설치

    --> 설치할 때 팝업창이 사라졌다고 해도 끝까지 기다리기 (우측 하단에 기다리는 표시가 없어질 때까지 기다리기)

     

    ※ 에러 발생 --> 재부팅으로 해결

     

    ※ 설치 완료 시, 나오는 창

    --> Yes는 2번 누름

     

    2. 계속 뜨는 에러를 지우려면 Build Settings에서 IOS Module을 설치해야한다.

    : 설치 후, 에러가 떠있는데 이는 재부팅으로 해결

     

    3. Project Settings에서 설정하기

     

    4. 추가 설정

    --> 선택하면 나오는 창은 OK누르면 끝

     

    5. 앱 ID 넣기

    : Admob 홈페이지의 '앱 설정'에서 앱 ID를 복사하여 파일에다가 붙여넣기

     

    >> AdmobAdsManager.cs 생성

    : 가이드를 따라 작성하되, 세 가지 타입의 광고를 전부 사용할 것이기 때문에 어느 정도 조정

     

    ※ 광고가 나오는 순서

    : SDK 초기화 -> (미리)광고 로드 -> (필요한 순간에)광고 표시 -> 광고 로드 -> ...

     

    >> 빈 게임 오브젝트로 Admob Ads Manager를 생성 후, AdmobAdsManager.cs 추가

     

    >> 추가 세팅

    : Project Settings - Player - Android - Other Settings

    --> 요새는 전부 64비트로 지원되기 때문에 IL2CPP로 설정 및 ARM64 체크

     

    : Project Settings - Player - Android - Resolution and Presentation

    --> 화면이 가로(왼쪽, 오른족)나 거꾸로 전환되지 않도록 체크 해제

     

    : App Icon 설정

     

    >> Build

    빌드해서 폰으로 광고가 뜨는지 테스트 해보자

    --> 광고를 클릭하면 정책 위반이니 주의하자

     

    >> 폰에 배너 광고가 바로 안 뜨는 경우가 있다.

    : 광고 단위 생성 후, 24시간 이내에는 안 뜨는 경우가 있다 (수강생님 정보)

    --> 테스트 단위 ID를 사용하면 해결된다.

    #if UNITY_ANDROID
      private string _adUnitId = "ca-app-pub-3940256099942544/6300978111";
    #elif UNITY_IPHONE
      private string _adUnitId = "ca-app-pub-3940256099942544/2934735716";
    #endif

     

    └ 테스트 기기 추가

    --> 수업에서 따로 진행하지 않으니, 따로 해보자

     

    최종 코드

    >> StagePopupPanelController.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using System.Linq;
    using DG.Tweening;
    using UnityEngine;
    using UnityEngine.UI;
    
    [RequireComponent(typeof(PopupPanelController))]
    public class StagePopupPanelController : MonoBehaviour
    {
        [SerializeField] private GameObject stageCellPrefab;
        [SerializeField] private Transform contentTransform;
    
        [SerializeField] private GameObject scrollView;
        [SerializeField] private int cellColumnCount;
    
        [SerializeField] private float cellWidth;
        [SerializeField] private float cellHeight;
        [SerializeField] private Vector2 spacing;
        
        private ScrollRect _scrollViewScrollRect;
        private RectTransform _scrollViewRectTransform;
    
        // 화면에 나타나는 Cell 영역을 담고 있는 List
        private List<(int index, StageCellButton[] stageCellButtons)> _visibleCells;
    
        private float _previousScrollRectYValue = 1f;
    
        private int _maxStageCount = 100;   // MAX_STAGE_COUNT를 대신하여 테스트용으로 작성
    
        private void Awake()
        {
            _scrollViewScrollRect = scrollView.GetComponent<ScrollRect>();
            _scrollViewRectTransform = scrollView.GetComponent<RectTransform>();
        }
    
        private void Start()
        {
            // 타이틀 지정
            GetComponent<PopupPanelController>().SetTitleText("STAGE");
            
            // 데이터 로드
            ReloadData();
            
            #region 임시
            
            // var lastStageIndex = 90;         // UserInformations.LastStageIndex;
            // var maxStageCount = 100;        // Constants.MAX_STAGE_COUNT;
            //
            // // Stage Popup Panel을 켰을 때 바로 마지막에 클리어한 Stage로 점프
            // var contentPos = 340 * (lastStageIndex / 3); // Cell Size : 300, Cell Spacing : 40, 한 줄에 Cell 3개
            // contentTransform.gameObject.GetComponent<RectTransform>().DOAnchorPosY(contentPos, 1f);
            //
            // // Stage Cell 만들기
            // for (int i = 0; i < maxStageCount; i++)
            // {
            //     GameObject stageCellObject = Instantiate(stageCellPrefab, contentTransform);
            //     StageCellButton stageCellButton = stageCellObject.GetComponent<StageCellButton>();
            //
            //     if (i < lastStageIndex)
            //     {
            //         stageCellButton.SetStageCell(i, StageCellButton.StageCellType.Clear);
            //     }
            //     else if (i == lastStageIndex)
            //     {
            //         stageCellButton.SetStageCell(i, StageCellButton.StageCellType.Normal);
            //     }
            //     else
            //     {
            //         stageCellButton.SetStageCell(i, StageCellButton.StageCellType.Lock);
            //     }
            // }
    
            #endregion
        }
    
        private (int start, int count) GetVisibleIndexRange()
        {
            var visibleRect = new Rect(
                _scrollViewScrollRect.content.anchoredPosition.x,
                _scrollViewScrollRect.content.anchoredPosition.y,
                _scrollViewRectTransform.rect.width,
                _scrollViewRectTransform.rect.height);
    
            var start = Mathf.FloorToInt(visibleRect.y / (cellHeight + spacing.y));
            var visibleCount = Mathf.CeilToInt(visibleRect.height / (cellHeight + spacing.y));
    
            start = Mathf.Max(0, start - 1); // Stage가 0 이하로 내려가지 않도록
            
            // 버퍼 추가
            
            // Count 값 설정
            var count = Mathf.CeilToInt(_maxStageCount / cellColumnCount);  // Stage가 10개라면 4개의 Cell이 나와야 하므로 올림
            count = Mathf.Min(count, start + visibleCount);
            
            return (start, count);
        }
    
        /// <summary>
        /// 특정 Index가 화면에 나와야 할 Index인지 확인
        /// </summary>
        /// <param name="index">특정 Index</param>
        /// <returns>true : 나와야 한다, false : 나오지 않아도 된다</returns>
        private bool IsVisibleIndex(int index)
        {
            var (start, end) = GetVisibleIndexRange();
            end = Mathf.Min(end, _maxStageCount - 1);
            return start <= index && index <= end;
        }
    
        private StageCellButton CreateStageCellButton(int index, int row, int col)
        {
            var stageCellButton = ObjectPool.Instance.GetObject().GetComponent<StageCellButton>();
            stageCellButton.SetStageCell(index, StageCellButton.StageCellType.Normal);
            
            // StageCellButton 위치 지정
            float centerIndex = 0;
            if (cellColumnCount % 2 == 0)
            {
                centerIndex = cellColumnCount / 2 - 0.5f;
            }
            else
            {
                centerIndex = cellColumnCount / 2;  // AI는 Mathf.Floor()를 이용하여 소수점을 버리기를 추천
            }
    
            var offset = col - centerIndex;
    
            var x = cellWidth * offset + spacing.x * offset;
            var y = -(cellHeight + spacing.y) * row;
            
            // Cell에 위치 지정
            stageCellButton.RectTransform.anchoredPosition = new Vector2(x, y);
            
            return stageCellButton;
        }
    
        private void ReloadData()
        {
            // Scroll View의 Content 사이즈 조절
            // _maxStageCount와 cellColumnCount가 둘다 int 이므로 _maxStageCount를 float으로 변환시켜 계산하여 올림처리
            _scrollViewScrollRect.content.sizeDelta = 
                new Vector2(0, Mathf.CeilToInt((float)_maxStageCount / cellColumnCount) * (cellHeight + spacing.y));
            
            // 화면에 보이는 Cell을 담고 있는 _visibleCell을 초기화
            _visibleCells = new List<(int index, StageCellButton[] stageCellButtons)>();
            
            // Cell 생성
            
            // 1. 만들어야 하는 셀 Index 찾기
            var (start, count) = GetVisibleIndexRange();
    
            for (int i = start; i < count; i++)             // Cell 영역을 하나씩 생성
            {
                List<StageCellButton> stageCellButtons = new List<StageCellButton>();
    
                for (int j = 0; j < cellColumnCount; j++)   // Cell 영역 안에 3개의 Cell 생성
                {
                    var index = i * cellColumnCount + j;
                    if (index < _maxStageCount) // Cell 생성
                    {
                        var stageCellButton = CreateStageCellButton(index, i, j);
                        stageCellButtons.Add(stageCellButton);
                    }
                }
                
                _visibleCells.Add((i, stageCellButtons.ToArray()));
            }
        }
    
        /// <summary>
        /// Scroll View가 Scroll 될 때 호출되는 Method
        /// </summary>
        /// <param name="value">Scroll 정도</param>
        public void OnValueChanged(Vector2 value)
        {
            if (_previousScrollRectYValue < value.y)
            {
                // 위로 스크롤
                
                // 상단에 새로운 Cell을 만들 필요가 있으면 만들기
                var firstRow = _visibleCells.First(); // _visibleCells[0]
                var newFirstIndex = firstRow.index - 1;
    
                if (IsVisibleIndex(newFirstIndex))
                {
                    List<StageCellButton> stageCellButtons = new();
                    for (int i = 0; i < cellColumnCount; i++)
                    {
                        var index = newFirstIndex * cellColumnCount + i;
                        if (index < _maxStageCount)
                        {
                            var stageCellButton = CreateStageCellButton(index, newFirstIndex, i);
                            stageCellButtons.Add(stageCellButton);
                        }
                    }
                    _visibleCells.Insert(0, (newFirstIndex, stageCellButtons.ToArray()));
                }
    
                // 하단에 더 이상 보이지 않는 Cell이 있으면 제거하기
                var lastRow = _visibleCells.Last(); // _visibleCells[]
                if (!IsVisibleIndex(lastRow.index))
                {
                    var stageCellButtons = lastRow.stageCellButtons;
                    foreach (var stageCellButton in stageCellButtons)
                    {
                        ObjectPool.Instance.ReturnObject(stageCellButton.gameObject);
                    }
                    _visibleCells.RemoveAt(_visibleCells.Count - 1);
                }
            }
            else
            {
                // 아래로 스크롤
                
                // 하단에 새로운 Cell을 만들 필요가 있으면 만들기
                var lastRow = _visibleCells.Last();
                var newLastIndex = lastRow.index + 1;
                if (IsVisibleIndex(newLastIndex))
                {
                    List<StageCellButton> stageCellButtons = new();
                    for (int i = 0; i < cellColumnCount; i++)
                    {
                        var index = newLastIndex * cellColumnCount + i;
                        if (index < _maxStageCount)
                        {
                            var stageCellButton = CreateStageCellButton(index, newLastIndex, i);
                            stageCellButtons.Add(stageCellButton);
                        }
                    }
                    _visibleCells.Add((newLastIndex, stageCellButtons.ToArray()));
                }
    
                // 상단에 더 이상 보이지 않는 Cell이 있으면 제거하기
                var firstRow = _visibleCells.First();
                if (!IsVisibleIndex(firstRow.index))
                {
                    var stageCellButtons = firstRow.stageCellButtons;
                    foreach (var stageCellButton in stageCellButtons)
                    {
                        ObjectPool.Instance.ReturnObject(stageCellButton.gameObject);
                    }
                    _visibleCells.RemoveAt(0);
                }
            }
            _previousScrollRectYValue = value.y;
        }
    }

     

    >> StageCellButton.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using TMPro;
    using UnityEngine;
    
    public class StageCellButton : MonoBehaviour
    {
        [SerializeField] private GameObject normalImageObject;
        [SerializeField] private GameObject clearImageObject;
        [SerializeField] private GameObject lockImageObject;
        [SerializeField] private TMP_Text[] stageIndexText;
        
        private RectTransform _rectTransform;
        public RectTransform RectTransform => _rectTransform;
    
        public enum StageCellType { Normal, Clear, Lock }
        private StageCellType _stageCellType;
        private int _stageIndex;
    
        private void Awake()
        {
            _rectTransform = GetComponent<RectTransform>();
        }
    
        public void SetStageCell(int stageIndex, StageCellType stageCellType)
        {
            _stageIndex = stageIndex;
            _stageCellType = stageCellType;
    
            // Stage Index 텍스트에 출력
            foreach (var stageIndexText in stageIndexText)
            {
                var indexText = _stageIndex + 1;
                stageIndexText.text = indexText.ToString();
            }
            
            // 클리어 상태에 따라 Cell 이미지 변경
            switch (_stageCellType)
            {
                case StageCellType.Normal:
                    normalImageObject.SetActive(true);
                    clearImageObject.SetActive(false);
                    lockImageObject.SetActive(false);
                    break;
                case StageCellType.Clear:
                    normalImageObject.SetActive(false);
                    clearImageObject.SetActive(true);
                    lockImageObject.SetActive(false);
                    break;
                case StageCellType.Lock:
                    normalImageObject.SetActive(false);
                    clearImageObject.SetActive(false);
                    lockImageObject.SetActive(true);
                    break;
            }
        }
    
        public void OnClickStageCellButton()
        {
            if (_stageCellType != StageCellType.Clear) return;
            
            // TODO: _stageIndex에 해당하는 게임 시작
        }
    }

     

    >> AdmobAdsManager.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using GoogleMobileAds.Api;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    
    public class AdmobAdsManager : Singleton<AdmobAdsManager>
    {
    #if UNITY_ANDROID
        private string _bannerAdUnitId = "ca-app-pub-3940256099942544/6300978111"; // 테스트 광고 ID
    #elif UNITY_IOS
        private string _bannerAdUnitId = "ca-app-pub-3940256099942544/2934735716"; // 테스트 광고 ID
    #endif
    
        private BannerView _bannerView;
        
        private void Start()
        {
            // SDK 초기화
            MobileAds.Initialize(initStatus =>
            {
                // 배너 광고 표시 --> 광고 제거 구매 여부를 확인 후 표시할지 말지 결정해야 한다.
                LoadBannerAd();
            });
        }
    
        protected override void OnSceneLoaded(Scene scene, LoadSceneMode mode)
        {
            
        }
    
        #region Banner Ads
    
        public void CreateBannerView()
        {
            Debug.Log("Creating banner view");
    
            if (_bannerView != null)
            {
                // Banner View 소멸
                _bannerView.Destroy();
                _bannerView = null;
            }
    
            _bannerView = new BannerView(_bannerAdUnitId, AdSize.Banner, AdPosition.Bottom);
        }
    
        public void LoadBannerAd()
        {
            if (_bannerView == null)
            {
                CreateBannerView();
            }
    
            var adRequest = new AdRequest();
            
            _bannerView.LoadAd(adRequest);
        }
    
        private void RegisterBannerAdsEventHandler()
        {
            // Raised when an ad is loaded into the banner view.
            _bannerView.OnBannerAdLoaded += () =>
            {
                Debug.Log("Banner view loaded an ad with response : "
                          + _bannerView.GetResponseInfo());
            };
            
            // Raised when an ad fails to load into the banner view.
            _bannerView.OnBannerAdLoadFailed += (LoadAdError error) =>
            {
                Debug.LogError("Banner view failed to load an ad with error : "
                               + error);
            };
            // Raised when the ad is estimated to have earned money.
            _bannerView.OnAdPaid += (AdValue adValue) =>
            {
                Debug.Log(String.Format("Banner view paid {0} {1}.",
                    adValue.Value,
                    adValue.CurrencyCode));
            };
            // Raised when an impression is recorded for an ad.
            _bannerView.OnAdImpressionRecorded += () =>
            {
                Debug.Log("Banner view recorded an impression.");
            };
            // Raised when a click is recorded for an ad.
            _bannerView.OnAdClicked += () =>
            {
                Debug.Log("Banner view was clicked.");
            };
            // Raised when an ad opened full screen content.
            _bannerView.OnAdFullScreenContentOpened += () =>
            {
                Debug.Log("Banner view full screen content opened.");
            };
            // Raised when the ad closed full screen content.
            _bannerView.OnAdFullScreenContentClosed += () =>
            {
                Debug.Log("Banner view full screen content closed.");
            };
        }
    
        #endregion
    }

     

    >> UserInformations.cs

    : Build할 때, 다른 수강생님이 만든 레지스트리 초기화 함수 때문에 에러가 나서 주석 처리

    using UnityEngine;
    using UnityEditor;
    
    public static class UserInformations
    {
        // 다른 수강생님이 만든 레지스트리 초기화 함수
        // [MenuItem("Window/PlayerPrefs 초기화")]
        // private static void ResetPrefs()
        // {
        //     PlayerPrefs.DeleteAll();
        //     Debug.Log("PlayerPrefs has been reset.");
        // }
        
        private const string HEART_COUNT = "HeartCount"; // string key 값 저장
        private const string LAST_STAGE_INDEX = "LastStageIndex";
        
        // 하트 수
        public static int HeartCount
        {
            get
            {
                // "HeartCount"라는 이름의 정보를 Int로 가져오는데, Default 값은 5 --> 최초로 게임이 시작되면 저장된 값이 없기 때문
                return PlayerPrefs.GetInt(HEART_COUNT, 5);
            }
            set
            {
                // value 값으로 PlayerPrefs에 저장
                PlayerPrefs.SetInt(HEART_COUNT, value);
            }
        }
        
        // 스테이지 클리어 정보
        public static int LastStageIndex
        {
            get { return PlayerPrefs.GetInt(LAST_STAGE_INDEX, 0); }
            set { PlayerPrefs.GetInt(LAST_STAGE_INDEX, value); }
        }
        
        // 효과음 재생 여부
        public static bool IsPlaySFX
        {
            get { return PlayerPrefs.GetInt("IsPlaySFX", 1) == 1; }
            set { PlayerPrefs.SetInt("IsPlaySFX", value ? 1 : 0); }
        }
        
        // 배경음악 재생 여부
        public static bool IsPlayBGM
        {
            get { return PlayerPrefs.GetInt("IsPlayBGM", 1) == 1; }
            set { PlayerPrefs.SetInt("IsPlayBGM", value ? 1 : 0); }
        }
    }

    C# 단기 교육 보강

    15일차

    장염 이슈로 추후 작성 예정....