게임 링크
ASMR Solitaire! : https://apps.apple.com/us/app/asmr-solitaire/id6746960868
// =============================================
// ShopManager.cs (Unity IAP v5.0.6 compatible)
// 전체 리팩터링 버전 – 2025‑07‑03
// ---------------------------------------------
// • StoreController 기반 비동기 초기화 / 구매 / 복원
// • Entitlement API 로 영수증 확인 (hasReceipt 제거)
// • Firebase Guest 차단, 광고제거/커피 보상 처리
// • IDisposable 로 이벤트 해제
// =============================================
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Firebase.Auth;
using Unity.Services.Core;
using UnityEngine;
using UnityEngine.Localization.Settings;
using UnityEngine.Purchasing;
using Product = UnityEngine.Purchasing.Product;
#region Data‑helper struct
public class IAPProductPurchasedMsg
{
public string productId;
}
#endregion
public class ShopManager : IDisposable
{
// --------------------------------------------------
// Fields & State
// --------------------------------------------------
private StoreController _store; // v5 핵심 컨트롤러
private ReadOnlyObservableCollection<Product> _products; // 최신 상품 캐시 , 다른 컨테이너 사용 무방함
private bool _isInitializing; //초기화 여부
// --------------------------------------------------
// 초기 진입 – UGS 완료 후 호출
// --------------------------------------------------
public void Init()
{
if (!Managers.IsUgsInitialized)
{
Logger.LogError("[IAP] Init() called before UGS initialization! Delaying...");
return;
}
if (IsInitialized() || _isInitializing)
return;
_ = InitIAPAsync(); // fire‑and‑forget
}
// --------------------------------------------------
// 1) IAP 초기화 (비동기)
// --------------------------------------------------
private async Task InitIAPAsync()
{
_isInitializing = true;
try
{
if (UnityServices.State == ServicesInitializationState.Uninitialized)
await UnityServices.InitializeAsync();
// (1) 컨트롤러 생성 + 이벤트 구독
_store = new StoreController();
_store.OnProductsFetched += OnProductsFetched;
_store.OnProductsFetchFailed += OnProductsFetchFailed;
_store.OnPurchasePending += HandlePendingPurchase;
_store.OnPurchaseConfirmed += OnPurchaseConfirmed;
_store.OnPurchaseFailed += OnPurchaseFailed;
_store.OnPurchaseDeferred += OnPurchaseDeferred;
// (2) 스토어 연결
await _store.Connect();
// (3) 상품 정의 → Fetch
List<ProductDefinition> defs = BuildProductDefinitions();
_store.FetchProducts(defs);
// (4) 기존 구매 내역 캐싱 (옵션)
_store.FetchPurchases();
}
catch (Exception ex)
{
Logger.LogError($"[IAP] Init failed: {ex}");
Dispose();
}
finally
{
_isInitializing = false;
// 모든 초기화가 끝난 후 "준비 완료" 이벤트를 보냅니다.
// Action등으로 교체 가능합니다.
Managers.Get<EventManager>().TriggerEvent(Define.EEventType.OnIAPReady);
Logger.Log("[IAP] IAP System is ready.");
}
}
// --------------------------------------------------
// 2) 상품 정의 (데이터 테이블 기반)
// --------------------------------------------------
/*
* public enum EProductType
{
Pack,
Coffee,
Chest,
Gem,
Gold
}
*/
private List<ProductDefinition> BuildProductDefinitions()
{
List<ProductDefinition> list = new List<ProductDefinition>();
foreach (var pd in Managers.Get<DataManager>().ProductDataDict.Values)
{
if (pd.PurchaseType != Define.EPurchaseType.IAP) continue;
ProductType type = pd.ProductType == Define.EProductType.Pack ? ProductType.NonConsumable : ProductType.Consumable;
list.Add(new ProductDefinition(pd.ProductId, type));
}
return list;
}
// --------------------------------------------------
// 3) 퍼블릭 헬퍼 (가격, 소유 여부, 구매)
// --------------------------------------------------
//Json,스크립터등을 활용한 단일 상품 데이터 가져오기- 헬퍼
public ProductData GetProductData(string productId)
{
return Managers.Get<DataManager>().ProductDataDict.FirstOrDefault(item => item.Value.ProductId.ToString() == productId).Value;
}
//Json,스크립터등을 활용한 상품 데이터 리스트 가져오기
public List<ProductData> GetProductDataList(Define.EProductType productType)
{
return Managers.Get<DataManager>().ProductDataDict.Where(item => item.Value.ProductType == productType)
.Select(item => item.Value).ToList();
}
public string GetLocalizedPrice(string productId)
{
Product p = _products?.FirstOrDefault(x => x.definition.id == productId);
// v5: Metadata.PriceString (2025‑05 spec) – fallback to v4 field
return p?.metadata?.localizedPriceString ?? "";
}
//지연 문제로 사용하지 않음 - 테스트 필요
public async Task<bool> IsProductOwnedAsync(string productId, int timeoutMs = 8000)
{
Product p = _products?.FirstOrDefault(x => x.definition.id == productId);
if (p == null) return false;
var tcs = new TaskCompletionSource<Entitlement>();
void OnEnt(Entitlement e)
{
if (e.Product == p)
{
_store.OnCheckEntitlement -= OnEnt;
tcs.TrySetResult(e);
}
}
_store.OnCheckEntitlement += OnEnt;
_store.CheckEntitlement(p);
Task done = await Task.WhenAny(tcs.Task, Task.Delay(timeoutMs));
if (done != tcs.Task)
{
_store.OnCheckEntitlement -= OnEnt;
return false; // timeout
}
return tcs.Task.Result.Status == EntitlementStatus.FullyEntitled;
}
public void PurchaseProduct(string productId)
{
// 1. 초기화 여부 및 상품 ID 유효성 검사 강화
if (!IsInitialized())
{
Logger.LogWarning("[IAP] PurchaseProduct failed: IAP is not initialized.");
ShowPurchaseErrorPopup("오류", "결제 시스템을 준비하는 중입니다. 잠시 후 다시 시도해 주세요.");
return;
}
if (string.IsNullOrEmpty(productId))
{
Logger.LogError("[IAP] PurchaseProduct failed: Product ID is null or empty.");
ShowPurchaseErrorPopup("오류", "상품 정보가 올바르지 않습니다.");
return;
}
Product product = _products?.FirstOrDefault(p => p.definition.id == productId);
if (product == null)
{
Logger.LogError($"[IAP] PurchaseProduct failed: Product '{productId}' not found in the fetched list.");
ShowPurchaseErrorPopup("오류", "현재 구매할 수 없는 상품입니다.");
return;
}
// 게스트 구매 차단
FirebaseUser user = FirebaseAuth.DefaultInstance.CurrentUser;
if (user != null && user.IsAnonymous)
{
string title =
LocalizationSettings.StringDatabase.GetLocalizedString(Define.LOCALIZATION_DATA_TABLE,
"shop_guest_error_title");
string desc =
LocalizationSettings.StringDatabase.GetLocalizedString(Define.LOCALIZATION_DATA_TABLE,
"shop_guest_error_desc");
string ok = LocalizationSettings.StringDatabase.GetLocalizedString(Define.LOCALIZATION_DATA_TABLE,
"shop_guest_error_ok");
Logger.LogWarning("[IAP] Guest cannot purchase");
Managers.Get<UIManager>().ShowPopupUI<UI_ConfirmOnButtonPopup>()
.SetConfirm(title, desc, ok);
return;
}
_store.PurchaseProduct(productId);
}
// 오류 팝업을 띄우는 헬퍼 메서드 (중복 코드 감소)
private void ShowPurchaseErrorPopup(string title, string description, string buttonText = "확인")
{
// 확인 버튼만 있는 간단한 팝업을 재사용합니다.
Managers.Get<UIManager>().ShowPopupUI<UI_ConfirmOnButtonPopup>()
.SetConfirm(title, description, buttonText);
}
// --------------------------------------------------
// 4) 구매 복원 (iOS/Android)
// --------------------------------------------------
//로컬라이즈 된것은 하드코딩하셔도 됩니다.
private bool _isRestoring = false;
public void RestorePurchases()
{
// ★ 개선 2: 중복 실행 방지 로직
if (_isRestoring)
{
Logger.LogWarning("[IAP] Restore – 이미 구매 복원 작업이 진행 중입니다.");
return;
}
if (!IsInitialized())
{
Logger.LogWarning("[IAP] Restore – store not ready");
return;
}
try
{
// ★ 개선 3: 작업 시작을 알리고, 사용자 입력을 막기 위해 로딩 UI 표시
_isRestoring = true;
// var loadingPopup = Managers.UI.ShowPopupUI<UI_LoadingPopup>(); // 로딩 팝업 클래스가 있다면 활용
_store.RestoreTransactions((ok, err) =>
{
if (ok)
{
string title =
LocalizationSettings.StringDatabase.GetLocalizedString(Define.LOCALIZATION_DATA_TABLE,
"shop_restore_success_title");
string desc =
LocalizationSettings.StringDatabase.GetLocalizedString(Define.LOCALIZATION_DATA_TABLE,
"shop_restore_success_desc");
string success = LocalizationSettings.StringDatabase.GetLocalizedString(Define.LOCALIZATION_DATA_TABLE,
"shop_restore_success_ok");
Managers.Get<UIManager>().ShowPopupUI<UI_ConfirmOnButtonPopup>()
.SetConfirm(title, desc, success);
}
else
{
string title =
LocalizationSettings.StringDatabase.GetLocalizedString(Define.LOCALIZATION_DATA_TABLE,
"shop_restore_failed_title");
string desc =
LocalizationSettings.StringDatabase.GetLocalizedString(Define.LOCALIZATION_DATA_TABLE,
"shop_restore_failed_desc");
string failed = LocalizationSettings.StringDatabase.GetLocalizedString(Define.LOCALIZATION_DATA_TABLE,
"shop_restore_failed_ok");
Managers.Get<UIManager>().ShowPopupUI<UI_ConfirmOnButtonPopup>()
.SetConfirm(title, $"{desc}: {err}", failed);
}
// ★ 개선 4: 작업이 완전히 끝났으므로 플래그를 다시 false로 설정
_isRestoring = false;
});
}
catch (Exception e)
{
// 예기치 않은 오류 발생 시에도 반드시 플래그를 초기화해야 합니다.
Logger.LogError($"[IAP] Restore – 예외 발생: {e.Message}");
_isRestoring = false;
Managers.Get<UIManager>().CloseAllPopupUI(); // 모든 팝업 닫기
}
}
// --------------------------------------------------
// 5) 이벤트 핸들러
// --------------------------------------------------
private void OnProductsFetched(IReadOnlyCollection<Product> products)
{
_products = new ReadOnlyObservableCollection<Product>(new ObservableCollection<Product>(products));
Logger.Log($"[IAP] {products.Count} products fetched");
Managers.Get<EventManager>().TriggerEvent(Define.EEventType.OnLobbyRefreshUI);
}
private static void OnProductsFetchFailed(ProductFetchFailed reason)
=> Logger.LogError($"[IAP] Products fetch failed – {reason}");
private void HandlePendingPurchase(PendingOrder order)
{
foreach (var item in order.CartOrdered.Items())
GrantReward(item.Product.definition.id);
_store.ConfirmPurchase(order); // 필수!
}
private static void OnPurchaseConfirmed(Order order)
{
var pid = order.CartOrdered.Items().FirstOrDefault()?.Product.definition.id ?? "N/A";
Logger.Log($"[IAP] Purchase confirmed – {pid}");
}
private static void OnPurchaseFailed(FailedOrder fo)
{
var pid = fo.CartOrdered.Items().FirstOrDefault()?.Product.definition.id ?? "N/A";
// 4. 구매 실패 시 사용자에게 명확한 피드백 제공
string errorMessage = $"상품 '{pid}' 구매에 실패했습니다. (사유: {fo.FailureReason})";
Logger.LogWarning($"[IAP] Purchase failed – {errorMessage}");
// 사용자가 취소한 경우는 일반적인 상황이므로 팝업을 띄우지 않을 수 있습니다.
if (fo.FailureReason != PurchaseFailureReason.UserCancelled)
{
// Managers.UI를 메인 스레드에서 호출하도록 안전장치 추가 (필요 시)
// Managers.Main.RunOnMainThread(() => {
Managers.Get<UIManager>().ShowPopupUI<UI_ConfirmOnButtonPopup>()
.SetConfirm("구매 실패", $"오류가 발생하여 구매를 완료할 수 없습니다.\n({fo.FailureReason})", "확인");
// });
}
}
private static void OnPurchaseDeferred(DeferredOrder d)
=> Logger.Log($"[IAP] Purchase deferred – waiting for approval");
// --------------------------------------------------
// 6) 보상 지급 및 이벤트 발송
// --------------------------------------------------
private void GrantReward(string productId)
{
var pd = Managers.Get<DataManager>().ProductDataDict.Values.FirstOrDefault(x => x.ProductId == productId);
if (pd == null)
{
Logger.LogError($"[IAP] Reward – unknown product {productId}");
return;
}
var userData = Managers.Get<UserSaveDataManager>().GetUserData<UserPlayData>(); //firebase 등에 저장
switch (productId)
{
case "noads_001":
if (userData != null && !userData.IsAdsRemoved)
{
userData.IsAdsRemoved = true;
userData.SaveData();
Logger.Log("[IAP] Ads removed");
}
break;
default:
if (pd.ProductType == Define.EProductType.Coffee && userData != null)
{
userData.CoffeeCount += pd.RewardCount;
userData.SaveData();
Logger.Log($"[IAP] Coffee +{pd.RewardCount}");
}
break;
}
Managers.Get<EventManager>().TriggerEvent(Define.EEventType.OnIAPProductPurchasedMsg,
new IAPProductPurchasedMsg { productId = productId });
}
// --------------------------------------------------
// 7) Status helpers
// --------------------------------------------------
public bool IsInitialized() => _store != null && _products != null;
public bool IsAdsRemoved() => Managers.Get<UserSaveDataManager>().GetUserData<UserPlayData>()?.IsAdsRemoved ?? false;
public bool IsBuyCoffee() => (Managers.Get<UserSaveDataManager>().GetUserData<UserPlayData>()?.CoffeeCount ?? 0) > 0;
// --------------------------------------------------
// 8) Dispose – 이벤트 해제
// --------------------------------------------------
public void Dispose()
{
if (_store == null) return;
_store.OnProductsFetched -= OnProductsFetched;
_store.OnProductsFetchFailed -= OnProductsFetchFailed;
_store.OnPurchasePending -= HandlePendingPurchase;
_store.OnPurchaseConfirmed -= OnPurchaseConfirmed;
_store.OnPurchaseFailed -= OnPurchaseFailed;
_store.OnPurchaseDeferred -= OnPurchaseDeferred;
_store = null;
_products = null;
Logger.Log("[IAP] StoreController disposed");
}
}