유니티에서 Google AdMob를 사용해서 광고 삽입하기

굥지·2025년 12월 19일

Unity

목록 보기
4/8

Unity - Google AdMob 광고 삽입하는 법(플러그인 다운로드, 광고 코드)
이 게시물을 참고했습니다 감사합니다(__)

https://github.com/googleads/googleads-mobile-unity/releases/tag/v10.5.0

이 링크에서

패키지 다운 후 드래그

Asset - Google ~ - Settings

Enable 선택 후

여기서 Android 에 ID 입력

Google AdMob: 모바일 앱 수익 창출

뭔진 모르겠지만 있어야하는듯함

using GoogleMobileAds;
using GoogleMobileAds.Api;
using UnityEngine;

public class GoogleMobileAdsDemoScript : MonoBehaviour
{
    public void Awake()
    {
        // Initialize Google Mobile Ads SDK.
        MobileAds.Initialize((InitializationStatus initStatus) =>
        {
            // This callback is called once the MobileAds SDK is initialized.
        });
    }

}

겜매에 두개를 넣어줌

인스펙터의 저 아이디는 광고 단위에서 추가한 아이디임

AdsManager.cs

using System;
using GoogleMobileAds;
using GoogleMobileAds.Api;
using UnityEngine;

public class AdsManager : MonoBehaviour
{
    public static AdsManager I { get; private set; }

#if UNITY_ANDROID
    [SerializeField] private string rewardedAdRevivalId = "ca-app-pub-1881501262849586/3221896109"; // 테스트용
#elif UNITY_IOS
    [SerializeField] private string rewardedAdUnitId = "ca-app-pub-3940256099942544/1712485313"; // 테스트용
#else
    [SerializeField] private string rewardedAdUnitId = "unused";
#endif

    private RewardedAd _rewardedAd;
    private bool _initializing;

    private void Awake()
    {
        if (I != null) { Destroy(gameObject); return; }
        I = this;
        DontDestroyOnLoad(gameObject);
    }

    private void Start()
    {
        InitializeSdkIfNeeded(() => PreloadRewarded());
    }

    public void InitializeSdkIfNeeded(Action onDone = null)
    {
        if (_initializing) return;
        _initializing = true;

        MobileAds.Initialize((InitializationStatus status) =>
        {
            _initializing = false;
            onDone?.Invoke();
        });
    }

    // 미리 로드
    public void PreloadRewarded()
    {
        if (string.IsNullOrEmpty(rewardedAdRevivalId)) return;

        if (_rewardedAd != null)
        {
            _rewardedAd.Destroy();
            _rewardedAd = null;
        }

        var request = new AdRequest();
        RewardedAd.Load(rewardedAdRevivalId, request, (RewardedAd ad, LoadAdError error) =>
        {
            if (error != null)
            {
                Debug.LogWarning($"[Ads] Rewarded load failed: {error.GetMessage()}");
                return;
            }

            _rewardedAd = ad;
            HookRewardedEvents(_rewardedAd);
            Debug.Log("[Ads] Rewarded loaded.");
        });
    }

    private void HookRewardedEvents(RewardedAd ad)
    {
        ad.OnAdFullScreenContentOpened += () =>
        {
            Debug.Log("[Ads] Rewarded opened.");
        };
        ad.OnAdFullScreenContentClosed += () =>
        {
            Debug.Log("[Ads] Rewarded closed.");
            // 닫히면 다음 광고 미리 로드
            PreloadRewarded();
        };
        ad.OnAdFullScreenContentFailed += (AdError err) =>
        {
            Debug.LogWarning($"[Ads] Rewarded open failed: {err.GetMessage()}");
            PreloadRewarded();
        };
    }

    /// <summary>
    /// 공용: 리워드 광고 표시 (보상 콜백/미준비 콜백)
    /// </summary>
    public void ShowRewarded(Action onRewardEarned, Action onUnavailable = null)
    {
        if (_rewardedAd != null && _rewardedAd.CanShowAd())
        {
            _rewardedAd.Show((Reward reward) =>
            {
                onRewardEarned?.Invoke();
            });
        }
        else
        {
            Debug.LogWarning("[Ads] Rewarded not ready.");
            onUnavailable?.Invoke();
            PreloadRewarded();
        }
    }

    /// <summary>
    /// 부활 전용 진입점
    /// </summary>
    public void ShowReviveAd(Action onRevive, Action onFail = null)
    {
        ShowRewarded(
            onRewardEarned: onRevive,
            onUnavailable: onFail
        );
    }
}

GameReviveSystem.cs

using System;
using UnityEngine;
using WordEater.Core;

[Serializable]
public class WordEaterCheckpoint
{
    public Vector3 Position;
    public int BatteryPercent;
    public int TurnsLeft;
    public int MistakesLeft;
    public GrowthStage Stage;
    public string CurrentAnswer;
}

public class GameReviveSystem : MonoBehaviour
{
    public static GameReviveSystem I { get; private set; }
    [SerializeField] private RevivePopup revivePopup;

    private WordEaterCheckpoint _cp;
    private bool _reviveOffered;

    void Awake() => I = this;

    public void SaveCheckpoint(WordEater.Core.WordEater we, int batteryPercent)
    {
        _cp = new WordEaterCheckpoint
        {
            Position = we.transform.position,
            BatteryPercent = Mathf.Clamp(batteryPercent, 0, 100),
            TurnsLeft = we.GetTurnsLeft(),         //
            MistakesLeft = we.GetMistakesLeft(),
            Stage = we.ReturnStage(),
            CurrentAnswer = we.CurrentAnswer
        };
    }
    public void OnPlayerDied(Action onGiveUp)
    {
        if (_reviveOffered) return;
        _reviveOffered = true;

        if (revivePopup == null)
        {
            Debug.LogWarning("[Revive] revivePopup 미할당");
            _reviveOffered = false;
            onGiveUp?.Invoke();
            return;
        }

        // ▶ 게임 정지: UI는 Unscaled로 뜨게
        Time.timeScale = 0f;

        revivePopup.Show(
            onAccept: () =>
            {
                ReviveFromCheckpoint();
                Debug.Log("[WordEater] 턴 고갈 → 사망 처리");
                _reviveOffered = false;
                Time.timeScale = 1f;  // ▶ 재개
            },
            onDecline: () =>
            {
                _reviveOffered = false;
                Time.timeScale = 1f;  // ▶ 재개
                onGiveUp?.Invoke();   // ▶ EndingController(1) 호출됨
            }
        );
    }

    public void ReviveFromCheckpoint()
    {
        var player = FindFirstObjectByType<WordEater.Core.WordEater>();
        if (player == null) { Debug.LogWarning("[Revive] WordEater 없음"); return; }
        if (_cp == null) { Debug.LogWarning("[Revive] 체크포인트 없음"); return; }

        // 위치/배터리 복원
        player.transform.position = _cp.Position;
        var battery = player.GetComponent<WordEater.Systems.BatterySystem>()
                     ?? FindFirstObjectByType<WordEater.Systems.BatterySystem>();
        if (battery != null) battery.SetBatteryPercent(Mathf.Max(_cp.BatteryPercent, 50));

        // 턴/오답/정답/단계 복원
        player.RestoreTurns(_cp.TurnsLeft, _cp.MistakesLeft);
        player.RestoreAnswer(_cp.CurrentAnswer, _cp.Stage);

        // 최종 활성화
        player.Reactivate();

        Debug.Log("[Revive] 체크포인트로 완전 복원 완료");
    }
}

얘는 초기 알파값을 0으로 일단 해두고

RevivePopup.cs

using System;
using UnityEngine;
using UnityEngine.UI;

public class RevivePopup : MonoBehaviour
{
    [Header("Refs")]
    [SerializeField] private CanvasGroup canvasGroup;
    [SerializeField] private Button watchAdButton;
    [SerializeField] private Button noThanksButton;
    [SerializeField] private GameObject spinner; // 로딩 표시용

    private Action _onAccept;
    private Action _onDecline;
    private bool _visible;

    private void Awake()
    {
        if (watchAdButton != null) watchAdButton.onClick.AddListener(OnClickWatchAd);
        if (noThanksButton != null) noThanksButton.onClick.AddListener(OnClickNoThanks);
        HideImmediate();
    }

    public void Show(Action onAccept, Action onDecline)
    {
        _onAccept = onAccept;
        _onDecline = onDecline;
        _visible = true;

        if (spinner != null) spinner.SetActive(false);

        // 팝업은 UnscaledTime 기반으로 동작 (애니메이션/타이머 쓴다면)
        var cg = canvasGroup;
        if (cg != null)
        {
            cg.ignoreParentGroups = true;
            cg.blocksRaycasts = true;
            cg.interactable = true;
        }

        // 최상단 보장(없다면 추가)
        var canvas = GetComponentInParent<Canvas>();
        if (canvas != null)
        {
            canvas.overrideSorting = true;
            canvas.sortingOrder = 10000;
        }

        SetCanvas(true);
    }

    public void Hide()
    {
        _visible = false;
        SetCanvas(false);
    }

    private void HideImmediate()
    {
        _visible = false;
        SetCanvas(false, immediate: true);
    }

    private void SetCanvas(bool show, bool immediate = false)
    {
        if (canvasGroup == null) return;
        canvasGroup.alpha = show ? 1f : 0f;
        canvasGroup.blocksRaycasts = show;
        canvasGroup.interactable = show;
        gameObject.SetActive(show);
    }

    private void OnClickWatchAd()
    {
        if (!_visible) return;

        if (spinner != null) spinner.SetActive(true);
        watchAdButton.interactable = false;
        noThanksButton.interactable = false;

        // 광고 표시
        AdsManager.I.ShowReviveAd(
            onRevive: () =>
            {
                // 광고 보상 수령 → 부활 승인
                _onAccept?.Invoke();
                Hide();
                ResetButtons();
            },
            onFail: () =>
            {
                // 광고가 준비 안됨/실패 → 안내 후 버튼 복구
                if (spinner != null) spinner.SetActive(false);
                watchAdButton.interactable = true;
                noThanksButton.interactable = true;
                Debug.LogWarning("[Revive] 광고가 준비되지 않았습니다. 잠시 후 다시 시도해주세요.");
            }
        );
    }

    private void OnClickNoThanks()
    {
        _onDecline?.Invoke();
        Hide();
        ResetButtons();
    }

    private void ResetButtons()
    {
        if (spinner != null) spinner.SetActive(false);
        watchAdButton.interactable = true;
        noThanksButton.interactable = true;
    }
}

워드이터가 죽었을때 띄우게 하려고 일단 전체 코드

using System;
using System.IO;
using UnityEngine;
using WordEater.Data;
using WordEater.Services;
using WordEater.Systems;
using static UnityEngine.EventSystems.EventTrigger;

namespace WordEater.Core
{
    /// <summary>
    /// 한 마리 워드 이터의 생애 주기를 관리하는 상태 머신
    /// </summary>
    public class WordEater : MonoBehaviour
    {
        [Header("할당")]
        [SerializeField] private GrowthConfig growthConfig;          // 단계 규칙 SO
        [SerializeField] private WordAssignmentService wordService;  // 단어 배정 
        [SerializeField] private BatterySystem battery;
        [SerializeField] private SubmitManager submitmanager;
        [SerializeField] private GameManager gamemanager;
        [SerializeField] private GalleryUIManager galleryUIManager;

        [SerializeField] private Sprite BitImg;
        [SerializeField] private Sprite ByteImg;
        [SerializeField] private Sprite WordImg;
        [SerializeField] private bool isDead = false;

        [Header("Runtime (read-only)")]
        [SerializeField] private GrowthStage stage = GrowthStage.Bit; // 현재 단계
        [SerializeField] private string currentAnswer;                // 현재 정답(프로토타입용 노출)

        private TurnController turn;   // 턴/오답 관리자
        private WordEntry currentEntry; // 현재 단어 데이터(주제/연관어 포함)

        private string pendingEvoId; // Bit/Byte 동안 쓸 임시 키=

        private void Awake()
        {
            turn = new TurnController(growthConfig);
        }

        /// <summary>
        /// 단계 시작(턴/오답 초기화 + 단어 배정)
        /// </summary>
        public void BeginStage(GrowthStage s, bool initial = false)
        {

            turn.StartStage(s);

            //처음 (다시시작이나 게임 클리어 포함)
            if (initial)
            {
                //BIT상태로 변경
                stage = GrowthStage.Bit;
                var sr = GetComponent<SpriteRenderer>();
                if (sr != null)
                {
                    sr.sprite = BitImg;
                }

                // 현재 단어 선택
                currentEntry = wordService.PickInitialWord();

                // ✅ 임시 키 생성 (한 생애 내내 고정)
                pendingEvoId = $"evo_{System.DateTime.UtcNow.Ticks}";

                // ✅ Bit 썸네일을 임시 키로 저장
                if (sr != null)
                    GalleryCapture.SaveSpriteThumb(sr, $"thumb_{pendingEvoId}_s0", 256);

                submitmanager.OnRelevantButton();
            }
            else
            {
                var sr = GetComponent<SpriteRenderer>();
                if (s == GrowthStage.Byte)
                {
                    if (sr != null) sr.sprite = ByteImg;
                    // ✅ Byte 썸네일을 임시 키로 저장
                    if (sr != null)
                        GalleryCapture.SaveSpriteThumb(sr, $"thumb_{pendingEvoId}_s1", 256);
                }
                else if (s == GrowthStage.Word)
                {
                    if (sr != null) sr.sprite = WordImg;
                }
            }

            // BeginStage 끝부분(초기 진입 포함)
            if (GameReviveSystem.I != null && battery != null)
            {
                GameReviveSystem.I.SaveCheckpoint(this, battery.CurrentPercent);
            }

            // EvolveOrFinish에서 다음 단계 단어 배정 직후
            if (GameReviveSystem.I != null && battery != null)
            {
                GameReviveSystem.I.SaveCheckpoint(this, battery.CurrentPercent);
            }

            currentAnswer = currentEntry.word;
            GameEvents.OnNewWordAssigned?.Invoke(currentAnswer); // UI: "새 단어 등장" (정답 직접 노출 대신 디버그/프로토타입용)
        }

        /// <summary>
        /// 데이터 주입(정답/오답 판정, 다음 문제 배정, 진화 체크)
        /// </summary>
        public void DoFeedData(string userInput)
        {

            if (!battery.TryConsume(ActionType.FeedData))
                return; // OnActionBlockedLowBattery 이벤트로 HUD/토스트 띄우기

            // 턴 소모(FeedData는 1턴)
            if (!turn.ConsumeTurn(ActionType.FeedData))
            {
                WordEaterDie();
                return;
            }

            if (turn.TurnsLeft <= 0) { WordEaterDie(); return; }
            // 정답 판정(v1 : 완전 일치, v2 : 오타/의미 유사도 확정 예정)
            bool ok = IsCorrect(userInput, currentAnswer);
            GameEvents.OnFeedResult?.Invoke(userInput, ok);

            if (ok)
            {
                EvolveOrFinish();
            }
            else
            {
                if (!turn.RegisterMistake()) { WordEaterDie(); return; }
            }

            // 턴 바닥나면 사망
            if (turn.TurnsLeft <= 0) { WordEaterDie(); return; }
        }

        /// <summary>
        /// 미니게임/힌트(턴 1 소모)
        /// </summary>
        public void DoOptimizeAlgo() // 미니게임 자리(힌트/버프 지급)
        {
            if (isDead) return;
            if (!turn.ConsumeTurn(ActionType.OptimizeAlgo))
            {
                WordEaterDie(); return;
            }
            // TODO: 힌트 토큰 +1, 상성 버프 스택 등
        }

        /// <summary>
        /// 노이즈 제거(턴 2 소모, 배율/버프 예정)
        /// </summary>
        public void DoCleanNoise() // 2턴 소모, 배율/보상 증가 버프
        {
            if (isDead) return;
            if (!turn.ConsumeTurn(ActionType.CleanNoise))
            {
                WordEaterDie(); return;
            }
            // TODO: 배율 스택 += 1
        }

        // === 내부 로직 =======================================================

        private bool IsCorrect(string input, string answer)
        {
            // v1: 완전 일치. v2: 유사도(레벤슈타인/임베딩) 도입.
            return string.Equals(input.Trim(), answer.Trim(), System.StringComparison.Ordinal);
        }

        /// <summary>
        /// 단계 종료 처리(다음 단계 or 성체)
        /// - 현재 단계가 최종 단계(Word)이면: 도감에 등록하고 엔딩 처리
        /// - 아니면: 다음 단계로 진화
        /// </summary>
        private void EvolveOrFinish()
        {
            if (stage == GrowthStage.Word)
            {
                // 성체 달성 → UI/연출 등 외부 구독자에게 알림
                GameEvents.OnEvolved?.Invoke(stage);

                // 성체가 되었으므로 "도감"에 현재 개체를 등록
                RegisterToGallery();

                // 게임 클리어 처리(엔딩 등)
                gamemanager.EndingController(2);
                galleryUIManager.Refresh();
                return;
            }

            // (성체가 아니라면) 다음 단계 단어 배정 및 진화
            currentEntry = wordService.PickNextLinkedWord(currentEntry, stage);
            currentAnswer = currentEntry.word;
            GameEvents.OnNewWordAssigned?.Invoke(currentAnswer);

            stage = (GrowthStage)((int)stage + 1);
            GameEvents.OnEvolved?.Invoke(stage);

            BeginStage(stage);
        }

        /// <summary>
        /// 사망 처리(휴지통/광고 보상 등 훅)
        /// </summary>
        private void WordEaterDie()
        {
            if (isDead) return; 
            isDead = true;
            GameEvents.OnDied?.Invoke();

            enabled = false;

            // 광고 팝업 띄우고, 거절하면 그때 엔딩
            if (GameReviveSystem.I != null)
            {
                Debug.Log("광고 팝업 띄울겨");
                GameReviveSystem.I.OnPlayerDied(onGiveUp: () =>
                {
                    // 정말 포기한 경우에만 게임오버 연출로 이동
                    gamemanager.EndingController(1);
                });
            }
            else
            {
                // 시스템이 없으면 안전하게 기존 흐름 유지
                gamemanager.EndingController(1);
            }

        }

        // 부활시
        public void Reactivate()
        {
            isDead = false;       // 죽은 상태 해제
            enabled = true;       // 다시 동작
                                  // 필요하면 무적 타이머/상태 초기화/입력언락 등을 여기서 처리
        }

        public int GetTurnsLeft() => turn.TurnsLeft;
        public int GetMistakesLeft() => turn.MistakesLeft; // TurnController에 프로퍼티 노출 필요

        public void RestoreTurns(int turnsLeft, int mistakesLeft)
        {
            turn.ForceRestore(turnsLeft, mistakesLeft); // TurnController에 강제 복원 API 추가
        }

        public void RestoreAnswer(string answer, GrowthStage s)
        {
            stage = s;
            currentAnswer = answer;
            GameEvents.OnNewWordAssigned?.Invoke(currentAnswer);

            // 스프라이트 동기화
            var sr = GetComponent<SpriteRenderer>();
            if (sr != null)
            {
                if (stage == GrowthStage.Bit) sr.sprite = BitImg;
                if (stage == GrowthStage.Byte) sr.sprite = ByteImg;
                if (stage == GrowthStage.Word) sr.sprite = WordImg;
            }
        }

        public WordEntry returnCurrentEnrty()
        {
            return currentEntry;
        }

        public string CurrentAnswer => currentAnswer;

        public GrowthStage ReturnStage()
        {
            return stage;
        }

        /// <summary>
        /// [도감 등록] 성체 달성 시 현재 워드이터를 도감 JSON에 등록한다.
        /// 동작 순서:
        /// 1) 현재 단어/단계를 이용해 "고유 ID" 생성 (충돌 방지용)
        /// 2) UI에 표시할 제목/카테고리(설명) 텍스트 구성
        /// 3) 현재 SpriteRenderer의 스프라이트를 썸네일 PNG로 저장
        /// 4) GalleryStore(싱글톤)로 Upsert → gallery.json에 반영
        /// </summary>
        private void RegisterToGallery()
        {
            var entry = currentEntry;

            // ✅ 최종 키: Word 단계의 MakeStableId (예: "2-수학")
            string finalId = MakeStableId(entry);

            // 임시 경로/최종 경로
            string baseDir = Application.persistentDataPath;
            string tmpS0 = Path.Combine(baseDir, $"thumb_{pendingEvoId}_s0.png");
            string tmpS1 = Path.Combine(baseDir, $"thumb_{pendingEvoId}_s1.png");
            string finS0 = Path.Combine(baseDir, $"thumb_{finalId}_s0.png");
            string finS1 = Path.Combine(baseDir, $"thumb_{finalId}_s1.png");

            // ✅ Bit/Byte 파일을 최종 키 이름으로 이동(있을 때만)
            MoveIfExists(tmpS0, finS0);
            MoveIfExists(tmpS1, finS1);

            // ✅ Word 썸네일은 최종 키로 저장
            var sr = GetComponent<SpriteRenderer>();
            string finS2 = Path.Combine(baseDir, $"thumb_{finalId}_s2.png");
            GalleryCapture.SaveSpriteThumb(sr, $"thumb_{finalId}_s2", 256);

            // 도감 등록: 대표 썸네일은 Word
            var item = new GalleryItem
            {
                id = finalId,
                displayName = entry.word,
                desc = GetTopicForDisplay(entry),
                thumbPath = finS2,
                dateCaught = System.DateTime.Now.ToString("yyyy-MM-dd")
            };
            GalleryStore.Instance.Upsert(item);
        }

        static void MoveIfExists(string src, string dst)
        {
            try
            {
                if (File.Exists(src))
                {
                    // 덮어쓰기 방지: 기존 있으면 삭제
                    if (File.Exists(dst)) File.Delete(dst);
                    File.Move(src, dst);
                }
            }
            catch (System.Exception ex)
            {
                Debug.LogWarning($"[Gallery] Move fail {src} -> {dst} : {ex.Message}");
            }
        }
        /// <summary>
        /// [도감 고유키 생성] 단어가 중복될 수 있으므로 "단계-단어" 형태로 고정 키를 만든다.
        /// 예: stage=2, word="수학" → "2-수학"
        /// </summary>
        static string MakeStableId(WordEntry e)
        {
            string slug = e.word.Trim().Replace(" ", ""); // 공백 제거 등 최소 정규화
            return $"{e.stage}-{slug}";
        }

        /// <summary>
        /// [표시용 카테고리 텍스트] 데이터 구조상 topic 필드는 없으므로 다음 규칙 적용:
        /// - Word(2단계)는 상위 개념이므로 "자기 자신"을 카테고리로 사용
        /// - Bit/Byte는 related[] 첫 번째 항목을 대표 카테고리로 사용
        /// - 없을 경우 "기타"
        /// </summary>
        static string GetTopicForDisplay(WordEntry e)
        {
            if (e.stage == 2) return e.word;                     // 최상위 카테고리
            if (e.related != null && e.related.Length > 0)
                return e.related[0];                              // 대표 카테고리 하나만 표시
            return "기타";
        }

    }
}

길다 길어

여기만 보면됨


        /// <summary>
        /// 사망 처리(휴지통/광고 보상 등 훅)
        /// </summary>
        private void WordEaterDie()
        {
            if (isDead) return; 
            isDead = true;
            GameEvents.OnDied?.Invoke();

            enabled = false;

            // 광고 팝업 띄우고, 거절하면 그때 엔딩
            if (GameReviveSystem.I != null)
            {
                Debug.Log("광고 팝업 띄울겨");
                GameReviveSystem.I.OnPlayerDied(onGiveUp: () =>
                {
                    // 정말 포기한 경우에만 게임오버 연출로 이동
                    gamemanager.EndingController(1);
                });
            }
            else
            {
                // 시스템이 없으면 안전하게 기존 흐름 유지
                gamemanager.EndingController(1);
            }

        }

        // 부활시
        public void Reactivate()
        {
            isDead = false;       // 죽은 상태 해제
            enabled = true;       // 다시 동작
                                  // 필요하면 무적 타이머/상태 초기화/입력언락 등을 여기서 처리
        }

        public int GetTurnsLeft() => turn.TurnsLeft;
        public int GetMistakesLeft() => turn.MistakesLeft; // TurnController에 프로퍼티 노출 필요

        public void RestoreTurns(int turnsLeft, int mistakesLeft)
        {
            turn.ForceRestore(turnsLeft, mistakesLeft); // TurnController에 강제 복원 API 추가
        }

        public void RestoreAnswer(string answer, GrowthStage s)
        {
            stage = s;
            currentAnswer = answer;
            GameEvents.OnNewWordAssigned?.Invoke(currentAnswer);

            // 스프라이트 동기화
            var sr = GetComponent<SpriteRenderer>();
            if (sr != null)
            {
                if (stage == GrowthStage.Bit) sr.sprite = BitImg;
                if (stage == GrowthStage.Byte) sr.sprite = ByteImg;
                if (stage == GrowthStage.Word) sr.sprite = WordImg;
            }
        }

TurnController.cs 변경점 (강제 복원 API 추가)

using UnityEngine;
using WordEater.Core;
using WordEater.Data;

namespace WordEater.Core
{
    /// <summary>
    /// 현재 단계(StageConfig)에 따라 '턴/오답' 수를 관리하는 컨트롤러
    /// </summary>
    
    public class TurnController
    {
        private readonly GrowthConfig _growth;
        public int TurnsLeft { get; private set; }
        public int MistakesLeft { get; private set; }

        public TurnController(GrowthConfig growth) => _growth = growth;

        /// <summary>
        /// 단계 시작 시 규칙 초기화
        /// </summary>
        public void StartStage(GrowthStage stage)
        {
            var cfg = _growth.Get(stage);
            TurnsLeft = cfg.turnsPerStage;
            MistakesLeft = cfg.maxMistakes;
            GameEvents.OnStageStarted?.Invoke(stage, TurnsLeft, MistakesLeft);
        }

        /// <summary>
        /// 액션 수행 시 턴 차감 (Clean은 2턴)
        /// </summary>
        public bool ConsumeTurn(ActionType action)
        {
            int cost = action == ActionType.CleanNoise ? 2 : 1;
            TurnsLeft -= cost;

            // (OnTurnsChanged는 기존대로 두되, 필요시 RaiseTurnsChanged로 교체 가능)
            GameEvents.OnTurnsChanged?.Invoke(TurnsLeft);
            return TurnsLeft > 0;
        }

        /// <summary>
        /// 오답 1회 등록
        /// </summary>
        public bool RegisterMistake()
        {
            MistakesLeft -= 1;
            // 래퍼로 호출
            GameEvents.RaiseMistakesChanged(MistakesLeft);
            return MistakesLeft >= 0;
        }

        /// <summary>
        /// ✅ 부활 복원을 위한 강제 복원 API (권장: stage 넘겨서 룰에 맞게 Clamp)
        /// </summary>
        public void ForceRestore(int turnsLeft, int mistakesLeft, GrowthStage stage)
        {
            var cfg = _growth.Get(stage);
            TurnsLeft = Mathf.Clamp(turnsLeft, 0, cfg.turnsPerStage);
            MistakesLeft = Mathf.Clamp(mistakesLeft, 0, cfg.maxMistakes);

            GameEvents.OnStageStarted?.Invoke(stage, TurnsLeft, MistakesLeft);
            GameEvents.OnTurnsChanged?.Invoke(TurnsLeft);
            GameEvents.RaiseMistakesChanged(MistakesLeft);
        }

        /// <summary>
        /// (옵션) stage 없이 그대로 복원 – 기존 호출부 호환용
        /// </summary>
        public void ForceRestore(int turnsLeft, int mistakesLeft)
        {
            TurnsLeft = turnsLeft;
            MistakesLeft = mistakesLeft;

            GameEvents.OnTurnsChanged?.Invoke(TurnsLeft);
            GameEvents.RaiseMistakesChanged(MistakesLeft);
        }
    }

}

0개의 댓글