본문 바로가기
Development/C#

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

by Mobics 2025. 3. 5.

 

목차


    퀴즈 게임 만들기

    25.02.28

    Google Play Games Service

    : Google Play Console을 사용

     

    ※ 강사님의 Notion

    https://spice-theory-152.notion.site/Google-Play-Games-Service-1a7d3cfdca3480609cffc4a1da9a9a74

     

    Google Play Games Service 설정하기 | Notion

    참고 자료

    spice-theory-152.notion.site

     

    --> 구글에 'google play games services unity' 검색

    https://github.com/playgameservices/play-games-plugin-for-unity

     

    GitHub - playgameservices/play-games-plugin-for-unity: Google Play Games plugin for Unity

    Google Play Games plugin for Unity. Contribute to playgameservices/play-games-plugin-for-unity development by creating an account on GitHub.

    github.com

    --> README의 Documentation에 나와있는 가이드 참고

    https://developer.android.com/games/pgs/unity/unity-start?hl=ko

     

    Unity용 Google Play 게임즈 설정 및 로그인  |  Android game development  |  Android Developers

    이 페이지는 Cloud Translation API를 통해 번역되었습니다. Unity용 Google Play 게임즈 설정 및 로그인 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이 문서에서는

    developer.android.com

     

    >> GPGS를 사용하기 위해 Project 설정

    --> Package Name을 'com.likelion.quizgame'으로 변경

     

    >> Keystore 생성

    : 우리가 만든 게임을 Store에 올리기 위해 보안적으로 안전하게 만들어주는 Key 파일

    --> Key 파일이 Github에 올라가지 않도록 생성할 때 경로를 아예 다른 곳으로 지정하거나 '.gitignore'에 설정

    --> 아래의 First and Last Name 등은 Optional 항목

    --> Add Key 를 누르면 나오는 팝업에서는 Yes를 선택

     

    ※ Keystore의 Password와 Key의 Password를 각각 설정 가능하다. (여기선 Test로 likelion1234로 설정)

    ※ Key 파일은 담당자 1명이 담당해서 관리하는 것이 좋다.

     

    ※ 모바일 게임을 Build하여 Store에 올릴 때, 무조건 확인해봐야 하는 중요한 세팅들

    : 63일차에서 이미 설정한 것들이 대부분

    1. Keystore 파일 등록
    2. Package Name
    3. IL2CPP
    4. ARM64

     

    >> Build

    : Store에 업로드할 때, 과거에는 'apk'확장자의 파일을 올렸지만, 이젠 개선되어 'aab' 확장자의 파일을 올리기 때문에 'aab' 확장자로 빌드하기 위해 Build App Bundle 체크

     

    Google Play Console

    : Google Play Console에 개발자 계정을 만들어서 앱 등록을 해야하는데, 개발자 계정을 만들 때 등록 수수료가 든다..

     

    >> 앱 만들기

     

    >> 앱 세부 정보 입력

    : 앱인지 게임인지 정확하게 입력해야한다.

     

    >> 내부 테스트 버전 만들기

    : 테스트 및 출시 - 테스트 - 내부 테스트 - aab 파일 업로드 - 다음

     

    >> 스토어 등록정보

    : 사용자 늘리기 - 앱 정보 - 스토어 등록정보 - 기본 스토어 등록정보 만들기

    --> 훑어보고 넘어감

     

    Play 게임즈 서비스 설정

    : 사용자 늘리기 - Play 게임즈 서비스 - 설정 및 관리 - 설정

    --> 만들어둔게 없으니 새 클라우드 프로젝트 만들기

     

    1. Google Cloud 콘솔 선택

     

    2. 프로젝트 이름을 원하는 이름으로 설정 후 만들기

    : 강사님은 사진과 같이 지어준 이름 그대로 사용

    --> 조직이 있으면 선택

     

    3. 다시 Google Play Console로 돌아와서 새로고침 후, 선택

    : 이후 사용 클릭

     

    └ Google Cloud Platform에서 OAuth 동의 화면 만들기

    --> Google Cloud Platform을 눌러서 들어가기

    1. 앱 이름, 이메일 작성 후 다음
    2. 외부 선택 후 다음
    3. 연락처 정보로 이메일 주소 작성 후 다음
    4. 동의 후 계속, 그리고 만들기

    --> 모든 과정을 마친 후, 구성 확인 클릭

     

    └ 사용자 인증 정보 만들기

    1. 사용자 인증 정보 추가 선택

     

    2. OAuth 클라이언트 만들기 선택

     

    3. OAuth 클라이언트 ID 만들기 선택

    : 상단의 '+ CREATE CLIENT' 선택

     

    └ 애플리케이션 유형은 'Android'

    --> Project Settings - Player - Android - Other Settings - Package Name 과 패키지 이름이 동일하도록 작성

     

    └ SHA-1 인증서 디지털 지문 생성하기

    1. 생성을 위해 Keytool 설치

    https://www.oracle.com/kr/java/technologies/downloads/#jdk21-windows

     

    Download the Latest Java LTS Free

    Subscribe to Java SE and get the most comprehensive Java support available, with 24/7 global access to the experts.

    www.oracle.com

     

    2. JDK 21로 설치

     

    3. Windows  Path에 JDK의 Bin 폴더 추가

    : 내 PC 우클릭 - 속성 - 고급 시스템 설정 - 고급 - 환경 변수 - 시스템 변수 - Path 더블 클릭 - 새로 만들기 - 경로 복붙

    --> cmd에서 'keytool' 명령어를 실행하여 설치가 되었는지 확인

     

    4. cmd에서 keystore를 만든 경로로 이동하여 아래 명령어 실행

    keytool -keystore path-to-debug-or-production-keystore -list -v

    --> 'path-to-debug-or-production-keystore' 부분을 지우고 keystore 파일명과 확장명을 넣기 (현재는 likelion.keystore)

    --> keystore의 password를 입력

     

    5. SHA1 부분을 복사하여 'OAuth 클라이언트 만들기'로 돌아가 'SHA-1 인증서 디지털 지문'에 붙여넣기

     

    >> 만든 OAuth 클라이언트를 선택하고 변경사항 저장을 클릭하여 사용자 인증 정보 만들기

     

    └ 플러그인 설치

    : 구글에 'google play games services unity' 검색

    https://github.com/playgameservices/play-games-plugin-for-unity

     

    GitHub - playgameservices/play-games-plugin-for-unity: Google Play Games plugin for Unity

    Google Play Games plugin for Unity. Contribute to playgameservices/play-games-plugin-for-unity development by creating an account on GitHub.

    github.com

    --> 해당 페이지에서 Source code.zip 파일을 다운로드 받아서 압축 해제

     

    >> Unitypackage Import

    --> 압축 해제한 폴더에서 'current-build' 폴더에 들어가 unitypackage 파일 선택

     

    --> New가 아니라 주의표시와 함께 화살표로 나오는 것은 기존 파일이 바뀐다는 의미이므로 살펴보아야 한다.

     

    --> Import 후 아래와 같은 Google Version Handler 팝업이 나타나면 Apply 버튼을 클릭

    --> 텅 빈 Google Version Handler 창은 닫기

     

     

    - 이후, Assets > External Dependency Manager > Android Resolver > Resolve 버튼을 클릭

    --> 클릭 후 아래와 같은 팝업이 나타나는지 확인

     

    └ 리더보드 설정

    : Play 게임즈 서비스 > 설정 및 관리 화면에서 리더보드를 선택 후 “리더보드 생성” 버튼을 클릭

     

    - 아래와 같이 리더보드를 설정 (수업에서는 Quiz Ranking으로 이름을 설정)

    --> 수업 때는 Icon도 넣음

     

    └ 업적 만들기

    : 사용자 늘리기 > Play 게임즈 서비스 > 설정 및 관리 > 업적을 선택 후 “업적 만들기” 버튼을 클릭

     

    - 이름을 'quiz1'로 변경하고 임시보관함에 저장

     

    └ XML 입력

    Play 게임즈 서비스 > 설정 및 관리 > 리더보드(or 업적) > 리소스 보기

    --> 사진은 리더보드, 수업 때는 업적에서 '리소스 보기' 클릭 (둘다 만든 뒤라 둘다 추가되어 있으니 상관 없음)

    --> 나온 Android(XML)을 복사

     

    - 복사한 XML을 Unity에서 Window - Google Play Games - Setup - Android setup 에 붙여넣기

    --> Client ID는 Optional

    --> 이후 Setup 클릭

     

    ※ 세팅을 하고나면 빌드가 잘 안 되는 문제가 발생할 수 있음

    : 현재는 이미 Minimum API Level과 Target API Level을 잘 설정해놔서 문제 없이 빌드가 된다.

    --> 하지만 이거 외에도 많은 문제가 발생할 수 있음

     

    └ 프로젝트 게시하기

    : 사용자 늘리기 > Play 게임즈 서비스 > 설정 및 관리 > 설정 > 우측 상단의 '검토 및 게시' 클릭

    --> '조치 필요'는 조치를 무조건 해줘야 한다.

     

    >> 설명

    : 게임의 표시 이름, 설명, 게임 카테고리 등을 입력합니다. (수업에선 이름은 그대로 둠)

    --> 설명에 '테스트' 넣음

     

    - 게임의 아이콘 이미지와 그래픽 이미지를 등록

    --> 요구하는 사이즈에 맞게 준비

    --> 이후 변경사항 저장

     

    >> 업적

    : 설명, 아이콘 추가 (수업에선 설명을 업적1, 업적2로 넣음)

    --> 이후 '임시보관함에 저장' 클릭

     

    >> 모든 조치를 마친 뒤, 우측 상단의 '출시' 클릭

     

    └ Play 게임즈 서비스 SDK를 APK에 추가하여 API 사용

    : GPGSManager.cs 생성

     

    ※ 아래 링크의 '로그인 서비스 확인' 이하 참고

    https://developer.android.com/games/pgs/unity/unity-start?hl=ko

     

    Unity용 Google Play 게임즈 설정 및 로그인  |  Android game development  |  Android Developers

    이 페이지는 Cloud Translation API를 통해 번역되었습니다. Unity용 Google Play 게임즈 설정 및 로그인 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이 문서에서는

    developer.android.com

     

    >> 빈 게임 오브젝트로 'GPGS Manager'를 만들고 GPGSManager.cs 추가

     

    >> Leaderboard Button의 OnClick()에 함수 추가 및 바인딩

     

    >> 테스트용으로 점수를 부여해주는 Button 생성

    : Reward Ad Test Button을 복붙하여 Score Add Test Button을 만들어서 위치 조정

    --> Text는 "점수 추가"로 수정

    --> Button의 OnClick()에 함수 추가 및 바인딩, int 값은 10으로 설정

     

    >> Build하여 테스트

    --> 현재 Google Play Console을 사용하지 못해서 테스트 못 해봄..

     

    ※ 테스트 이메일과 폰의 이메일이 다르다면 플레이 게임즈를 설치한 후, 계정 전환을 하면 된다.

     

    인앱결제

    : Unity Cloud 활용

     

    >> In App Purchasing 설치

     

    >> Project Settings > Services > In-App Purchasing

    --> Unity Hub에서 Project를 만들 때, Unity Cloud에 연결했다면 'Use an existing cloud project'를 선택하고 연결하지 않았다면 'Create a new cloud project'를 선택 (현재 나는 연결되어있지 않기 때문에 'Create a new cloud project' 선택)

     

    ※ 'Create a new cloud project'를 선택한 경우

    : 'Will this app be targeted to children as defined by applicable laws?' --> 아이들도 즐기는 앱인 지를 묻는 것이 아니라 아이들만을 위한 앱인 지를 묻는 것

     

    >> 추가 설정

    --> 상품 등록은 현재 계정 설정으로 인해 힘들기 때문에 넘어감

     

    >> IAPManager.cs 생성 및 코드 작성

    : IAP = In-App Purchasing

     

    최종 코드

    >> GPGSManager.cs

    : leaderboardId는 Window - Google Play Games - Setup - Android setup에 입력한 XML 안에 있음

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    using GooglePlayGames;
    using GooglePlayGames.BasicApi;
    
    public class GPGSManager : Singleton<GPGSManager>
    {
    #if UNITY_ANDROID
        private const string leaderboardId = "CgkIw_uD_pgCEAIQAQ";
    #elif UNITY_IOS
    
    #endif
    
        private void Start()
        {
            PlayGamesPlatform.Instance.Authenticate(ProcessAuthentication);
            
        }
    
        private void ProcessAuthentication(SignInStatus status)
        {
            if (status == SignInStatus.Success) // 로그인 성공
            {
                Debug.Log("GPGS Authentication Success");
    
                string name = PlayGamesPlatform.Instance.GetUserDisplayName();
                string id = PlayGamesPlatform.Instance.GetUserId();
                string profileImage = PlayGamesPlatform.Instance.GetUserImageUrl();
            }
            else                                // 로그인 실패
            {
                Debug.Log("GPGS Authentication Failed");
            }
        }
    
        protected override void OnSceneLoaded(Scene scene, LoadSceneMode mode)
        {
    
        }
    
        // 스코어를 리더보드에 전달
        public void ReportScore(int score)
        {
            Social.ReportScore(score, leaderboardId, success =>
            {
                if (success)
                {
                    Debug.Log("Leaderboard report score success.");
                }
                else
                {
                    Debug.Log("Leaderboard report score failed.");
                }
            });
        }
    
        public void ShowLeaderboard()
        {
            Social.ShowLeaderboardUI();
        }
    }

     

    >> IAPManager.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.Purchasing;
    
    public class IAPManager : MonoBehaviour, IStoreListener
    {
        public enum Type
        {
            NOADS,          // 광고 제거
            NOADS_HEART_60, // 광고 제거 + 하트 60개
            HEART_20,
            HEART_60,
            HEART_150,
            HEART_320,
            HEART_450
        }
    
        public const string kPID_Heart20 = "heart_20";
        public const string kPID_Heart60 = "heart_60";
        public const string kPID_Heart150 = "heart_150";
        public const string kPID_Heart320 = "heart_320";
        public const string kPID_Heart450 = "heart_450";
        public const string kPID_NoAds = "noads";
        public const string kPID_NoAds_Heart60 = "noads_heart_60";
    
        private IStoreController _storeController;
        private IExtensionProvider _storeExtensionProvider;
    
        private void Start()
        {
            if (_storeController == null)
            {
                InitializePurchasing();
            }
        }
    
        private void InitializePurchasing()
        {
            if (IsInitialized())
            {
                return;
            }
            var builder = ConfigurationBuilder.Instance(StandardPurchasingModule.Instance());
            builder.AddProduct(kPID_Heart20, ProductType.Consumable);   // Consumable : 소모성 아이템
            builder.AddProduct(kPID_Heart60, ProductType.Consumable);
            builder.AddProduct(kPID_Heart150, ProductType.Consumable);
            builder.AddProduct(kPID_Heart320, ProductType.Consumable);
            builder.AddProduct(kPID_Heart450, ProductType.Consumable);
            builder.AddProduct(kPID_NoAds, ProductType.NonConsumable);  // NonConsumable : 비소모성 아이템
            builder.AddProduct(kPID_NoAds_Heart60, ProductType.NonConsumable);
            UnityPurchasing.Initialize(this, builder);
        }
    
        private bool IsInitialized()
        {
            return _storeController != null && _storeExtensionProvider != null;
        }
    
        public void BuyProduct(Type type)   // 상품을 구매할 때 호출
        {
            switch (type)
            {
                case Type.HEART_20:
                    BuyProductID(kPID_Heart20);
                    break;
                case Type.HEART_60:
                    BuyProductID(kPID_Heart60);
                    break;
                case Type.HEART_150:
                    BuyProductID(kPID_Heart150);
                    break;
                case Type.HEART_320:
                    BuyProductID(kPID_Heart320);
                    break;
                case Type.HEART_450:
                    BuyProductID(kPID_Heart450);
                    break;
                case Type.NOADS:
                    BuyProductID(kPID_NoAds);
                    break;
                case Type.NOADS_HEART_60:
                    BuyProductID(kPID_NoAds_Heart60);
                    break;
            }
        }
    
        private void BuyProductID(string productId)
        {
            if (IsInitialized())
            {
                Product product = _storeController.products.WithID(productId);
                if (product != null && product.availableToPurchase)
                {
                    Debug.Log(string.Format("Purchasing product asychronously: '{0}'", product.definition.id));
                    _storeController.InitiatePurchase(product);
                }
                else
                {
                    Debug.Log("BuyProductID: FAIL. Not purchasing product, either is not found or is not available for purchase");
                }
            }
            else
            {
                Debug.Log("BuyProductID FAIL. Not initialized.");
            }
        }
    
    
        public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
        {
            _storeController = controller;
            _storeExtensionProvider = extensions;
        }
    
        public void OnInitializeFailed(InitializationFailureReason error)
        {
            Debug.Log("OnInitializeFailed InitializationFailureReason:" + error);
        }
    
        public void OnInitializeFailed(InitializationFailureReason error, string message)
        {
            Debug.Log("OnInitializeFailed InitializationFailureReason:" + error + " message:" + message);
        }
    
        public void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason)
        {
            Debug.Log(string.Format("OnPurchaseFailed: FAIL. Product: '{0}', PurchaseFailureReason: {1}",
                product.definition.storeSpecificId, failureReason));
        }
    
        public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs purchaseEvent)
        {
            if (String.Equals(purchaseEvent.purchasedProduct.definition.id, kPID_Heart20, StringComparison.Ordinal))
            {
                UserInformations.HeartCount += 20;
            }
            else if (String.Equals(purchaseEvent.purchasedProduct.definition.id, kPID_Heart60, StringComparison.Ordinal))
            {
                UserInformations.HeartCount += 60;
            }
            else if (String.Equals(purchaseEvent.purchasedProduct.definition.id, kPID_Heart150, StringComparison.Ordinal))
            {
                UserInformations.HeartCount += 150;
            }
            else if (String.Equals(purchaseEvent.purchasedProduct.definition.id, kPID_Heart320, StringComparison.Ordinal))
            {
                UserInformations.HeartCount += 320;
            }
            else if (String.Equals(purchaseEvent.purchasedProduct.definition.id, kPID_Heart450, StringComparison.Ordinal))
            {
                UserInformations.HeartCount += 450;
            }
            else if (String.Equals(purchaseEvent.purchasedProduct.definition.id, kPID_NoAds, StringComparison.Ordinal))
            {
                UserInformations.IsNoAds = true;
            }
            else if (String.Equals(purchaseEvent.purchasedProduct.definition.id, kPID_NoAds_Heart60, StringComparison.Ordinal))
            {
                UserInformations.HeartCount += 60;
                UserInformations.IsNoAds = true;
            }
            else
            {
                Debug.Log(string.Format("ProcessPurchase: FAIL. Unrecognized product: '{0}'", purchaseEvent.purchasedProduct.definition.id));
            }
    
            return PurchaseProcessingResult.Complete;
        }
    }

     

    >> UserInformations.cs

    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); }
        }
        
        // 광고 제거 여부
        public static bool IsNoAds
        {
            get { return PlayerPrefs.GetInt("IsNoAds", 0) == 1; }
            set { PlayerPrefs.SetInt("IsNoAds", value ? 1 : 0); }
        }
    }

     

    고민해야할 개선 사항

    1. Item을 구매할 때, IAPManager.cs의 BuyProduct()를 호출할텐데, 그럼 BuyProductID()로 들어가고 InitiatePuchase()함수를 통해 해당 product를 요청하고 끝난다. 즉, 동기적이지 않고 비동기적이다. --> 유저에게 팝업이 뜨고 유저가 구매를 승인하면 ProcessPurchase()가 호출된다. 따라서 구매 버튼을 눌렀을 때와 구매가 결정되고 끝났을 때가 순차적으로 진행되는 것이 아니므로 구매가 완료되면 호출되도록 Event 함수(Action 등)를 이용해야 한다.
    2. UserInfomation에서 데이터를 변경하면 GameManager랑 HeartPanel이 변경된 값을 참조하지 않고 있어서 나중에 고쳐야한다. (수강생님 의견)