【Android】Unity IAPで課金実装

## はじめに
Unity製のゲームに簡易的な課金機能を実装したため、備忘録として残しておこうと思います。
なお、今回は自社サーバーとの通信は行なっておらず、全てローカルで実装しております。

## ゲームの紹介
研修の一環としてチームメンバーと作成したゲームです！
作成期間が２週間ほどで短いため、ボリュームは少ないです😂
ぜひ遊んでみてください！

https://play.google.com/store/apps/details?id=com.LoveFlag

## Unity IAPとは
>Unity IAPは、Unity Technologiesが提供する統合型のアプリ内課金ソリューションです。主にゲームやアプリケーション内でのデジタルコンテンツ、バーチャルアイテム、サブスクリプションなどの販売を容易に実装・管理するためのツールです。


## 実際のコード
```C#
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using UnityEngine.Purchasing;
using UnityEngine.Purchasing.Security;
using System;

public class PurchasePointManager : MonoBehaviour, IStoreListener
{
    [SerializeField] private Button purchaseButton1;
    [SerializeField] private Button purchaseButton2;
    [SerializeField] private Button purchaseButton3;
    [SerializeField] private Button purchaseButton4;
    [SerializeField] private Button purchaseButton5;
    
    [SerializeField] private Button closeButton;
    
    [SerializeField] private Text possessionPointText;

    private IStoreController storeController;
    private IExtensionProvider extensionProvider;

    // ストア側で設定したプロダクトID
    private string productId1 = "love_flag_package1";
    private string productId2 = "love_flag_package2";
    private string productId3 = "love_flag_package3";
    private string productId4 = "love_flag_package4";
    private string productId5 = "love_flag_package5";

    void Start()
    {
        // ボタンにリスナーを追加
        purchaseButton1.onClick.AddListener(() => BuyProductID(productId1));
        purchaseButton2.onClick.AddListener(() => BuyProductID(productId2));
        purchaseButton3.onClick.AddListener(() => BuyProductID(productId3));
        purchaseButton4.onClick.AddListener(() => BuyProductID(productId4));
        purchaseButton5.onClick.AddListener(() => BuyProductID(productId5));
        
        closeButton.onClick.AddListener(OnCloseButtonClicked);
        
        possessionPointText.text = "所持ポイント: " + PlayerPrefs.GetInt("possessionPointText", 0).ToString() + "pt";

        // IAPの初期化
        InitializePurchasing();
    }

    private void InitializePurchasing()
    {
        if (IsInitialized())
        {
            return;
        }

        var builder = ConfigurationBuilder.Instance(StandardPurchasingModule.Instance());

        // プロダクトの追加
        builder.AddProduct(productId1, ProductType.Consumable);
        builder.AddProduct(productId2, ProductType.Consumable);
        builder.AddProduct(productId3, ProductType.Consumable);
        builder.AddProduct(productId4, ProductType.Consumable);
        builder.AddProduct(productId5, ProductType.Consumable);

        UnityPurchasing.Initialize(this, builder);
    }

    private bool IsInitialized()
    {
        return storeController != null && extensionProvider != null;
    }

    public void BuyProductID(string productId)
    {
        if (IsInitialized())
        {
            Product product = storeController.products.WithID(productId);

            if (product != null && product.availableToPurchase)
            {
                Debug.Log($"Purchasing product asynchronously: {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.");
        }
    }

    // IStoreListenerの実装
    public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
    {
        Debug.Log("OnInitialized: PASS");

        storeController = controller;
        extensionProvider = extensions;
    }

    public void OnInitializeFailed(InitializationFailureReason error)
    {
        Debug.Log($"OnInitializeFailed InitializationFailureReason:{error}");
    }

    public void OnInitializeFailed(InitializationFailureReason error, string message)
    {
        throw new NotImplementedException();
    }

    public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
    {
        // 購入されたプロダクトIDを取得
        string purchasedProductId = args.purchasedProduct.definition.id;
        Debug.Log($"ProcessPurchase: PASS. Product: {purchasedProductId}");

        // 購入内容に応じた処理を実装
        switch (purchasedProductId)
        {
            case "love_flag_package1":
                AddPoints(370);
                break;
            case "love_flag_package2":
                AddPoints(630);
                break;
            case "love_flag_package3":
                AddPoints(2300);
                break;
            case "love_flag_package4":
                AddPoints(4000);
                break;
            case "love_flag_package5":
                AddPoints(8500);
                break;
            default:
                Debug.LogWarning($"ProcessPurchase: Unrecognized product: {purchasedProductId}");
                break;
        }

        // 購入処理が完了したことを通知
        return PurchaseProcessingResult.Complete;
    }

    public void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason)
    {
        Debug.Log($"OnPurchaseFailed: FAIL. Product: {product.definition.storeSpecificId}, PurchaseFailureReason: {failureReason}");
    }

    private void AddPoints(int points)
    {
        // ポイントを追加するロジックを実装
        Debug.Log($"Added {points} points to the user.");
        // ユーザーのポイントシステムに追加
        int point = PlayerPrefs.GetInt("possessionPointText", 0) + points;
        PlayerPrefs.SetInt("possessionPointText", point);
        possessionPointText.text = "所持ポイント: " + point + "pt";
    }
    
    private void OnCloseButtonClicked()
    {
        // 画面を閉じる
        SceneManager.LoadScene("SelectionScene");
    }
}
```

## 解説

### クラス宣言とインターフェースの実装
```C#
public class PurchasePointManager : MonoBehaviour, IStoreListener
```
IStoreListener を実装することで、IAP の初期化や購入処理のコールバックを受け取ります。

### UI 要素の設定

```C#
[SerializeField] private Button purchaseButton1;
// 他の購入ボタン
[SerializeField] private Button closeButton;
[SerializeField] private Text possessionPointText;
```
購入ボタンやクローズボタン、所持ポイントを表示するテキストを Inspector から設定できるようにしています。

### プロダクトIDの定義
```C#
private string productId1 = "love_flag_package1";
// 他のプロダクトID
```
ストア側で設定した各プロダクトのIDを定義しています。

### IAP の初期化

```C#
private void InitializePurchasing()
{
    if (IsInitialized())
    {
        return;
    }

    var builder = ConfigurationBuilder.Instance(StandardPurchasingModule.Instance());

    // プロダクトの追加
    builder.AddProduct(productId1, ProductType.Consumable);
    // 他のプロダクトも追加

    UnityPurchasing.Initialize(this, builder);
}

private bool IsInitialized()
{
    return storeController != null && extensionProvider != null;
}
```

ConfigurationBuilder を用いて購入可能なプロダクトを設定。
UnityPurchasing.Initialize で IAP を初期化。
既に初期化済みかを確認するメソッドも用意。
### 購入処理の開始
```C#
public void BuyProductID(string productId)
{
    if (IsInitialized())
    {
        Product product = storeController.products.WithID(productId);

        if (product != null && product.availableToPurchase)
        {
            Debug.Log($"Purchasing product asynchronously: {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.");
    }
}
```
指定された productId の購入を開始します。初期化されていない場合やプロダクトが利用できない場合はログを出力します。

### IStoreListener のコールバック実装
**初期化成功**
```C#
public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
{
    Debug.Log("OnInitialized: PASS");
    storeController = controller;
    extensionProvider = extensions;
}
```

IAP の初期化が成功した際に呼び出され、コントローラーと拡張プロバイダーを保存します。

**初期化失敗**

```C#
public void OnInitializeFailed(InitializationFailureReason error)
{
    Debug.Log($"OnInitializeFailed InitializationFailureReason:{error}");
}

public void OnInitializeFailed(InitializationFailureReason error, string message)
{
    throw new NotImplementedException();
}
```
**購入処理の成功**

```C#
public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
{
    string purchasedProductId = args.purchasedProduct.definition.id;
    Debug.Log($"ProcessPurchase: PASS. Product: {purchasedProductId}");

    switch (purchasedProductId)
    {
        case "love_flag_package1":
            AddPoints(370);
            break;
        // 他のプロダクトIDに対する処理
        default:
            Debug.LogWarning($"ProcessPurchase: Unrecognized product: {purchasedProductId}");
            break;
    }

    return PurchaseProcessingResult.Complete;
}
```

購入が成功した際に呼び出され、購入されたプロダクトIDに応じてポイントを追加します。

**購入失敗**

```C#
public void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason)
{
    Debug.Log($"OnPurchaseFailed: FAIL. Product: {product.definition.storeSpecificId}, PurchaseFailureReason: {failureReason}");
}
```

## 実際の購入画面
![Screenshot_20250123-193704.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3113615/46529338-a8eb-8af7-06fc-ebc73a1cff40.png)

## まとめ
今回はAndroidで実装しましたが、iOSも同じ要領で問題ないと思います！
Unity、Androidで課金を実装する際はぜひ参考にしてみてください！