Game Server Services(GS2)を使ってUnityでガチャ(抽選機能)を実装してみた

2024.02.25

こんにちは、ゲームソリューション部の入井です。

今回は、GS2-Lotteryを使ってモバイルゲームなどでよくあるガチャ(抽選機能)をUnityを実装してみたので、その内容をご紹介します。

なお、今回実装したものの中でGS2における認証やトランザクション機能が使われていますが、この記事ではそれらの要素についての解説は行いません。認証やトランザクションについて知りたい方は公式ドキュメントか以下の記事を参照してください。

Game Server Services(GS2)を使ってUnityでアカウントの引き継ぎ機能を実装してみた | DevelopersIO

Game Server Services(GS2)でアイテム購入処理を通してトランザクションを試してみた | DevelopersIO

環境

  • Unity 2022.3.18f1
  • UniTask 2.5.0
  • GS2 C# SDK 2023.12.10
  • GS2 SDK for Unity 2023.12.14

GS2-Lotteryにおける抽選の考え方

抽選機能を提供するGS2-Lotteryでは、通常ガチャBoxガチャの2種類の抽選方式が利用できます。どちらもランダムな景品を排出するという点では同じですが、確率の考え方が微妙に異なっています。

通常ガチャ

通常ガチャは、あらかじめ設定した景品テーブルの内容に基づいてランダムな景品を排出します。

景品テーブルとは、抽選によって排出される景品内容とその排出確率を設定するためのものです。各景品の排出確率はそれぞれの『重み』というパラメータで決まります。例えば、レア景品の重みを3にしてノーマル景品の重みを7に設定したとき、レア景品は30%の確率で排出され、ノーマル景品は70%の確率で排出されます。分かりやすく3:7としましたがこれを1:2にしたり1:100にしたりすることも可能で、テーブル全体の重みの設定に基づいてGS2-Lotteryが適切な確率を計算してくれます。

通常ガチャの景品排出確率は、プレイヤーが何回抽選を実行しても常に同じです。

Boxガチャ

Boxガチャも、通常ガチャ同様あらかじめ設定した景品テーブルの内容に基づいて景品を排出しますが、景品の数が有限という点で違いがあります。

Boxガチャでは、景品テーブルで各景品毎に個数を設定します。各景品の排出確率は、抽選実行時点での各景品の個数によって決まります。

例えば、以下の図のように1回目の抽選時はレアが3個でノーマルが7個入っているためそれぞれの排出確率は30%, 70%となります。しかし、1回目でノーマルが排出された後に再度抽選を実行した際は、ノーマルの個数が減っているためレアの排出確率が少し上がり、逆にノーマルの排出確率は少し下がります。このような形で各景品の個数が抽選の度に減っていくため、10回以内に必ず全てのレアが排出される仕組みになっています。

今回作成したもの

今回、プレイヤーの所持品を管理するサービスであるGS2-Inventoryと抽選処理を実行するGS2-Lottery、リソース交換機能を持つGS2-Exchangeを組み合わせて、1,000ポイントを支払って10連続景品ガチャを実行し、その実行結果を元にプレイヤーの所持品に景品を追加する機能をUnityで作成しました。なお、GS2-Lotteryの抽選方式は通常ガチャを使用しています。

また、ゲーム開始時にプレイヤーが一定ポイント所持しているように、GS2-LoginRewardで初回ログイン時のみポイントを取得できる機能も実装しています。

GS2-Inventory | Game Server Services | Docs

GS2-Lottery | Game Server Services | Docs

GS2-Exchange | Game Server Services | Docs

GS2-LoginReward | Game Server Services | Docs

画像のように、左側にはプレイヤーが現在所持しているものがリスト表示され、右側のボタンをクリックするとポイントを使ってガチャを実行することができるようになっています。

GS2の各サービスのマスタ設定

今回作成した景品ガチャ機能を実現するために、GS2の各サービスで設定したマスタの内容を紹介していきます。

なお、景品とそのレアリティは以下の表のように設定しています。

レアリティ 景品
SSR ダイアモンド
SR 金、プラチナ
R 銀、銅
N 鉄、石

GS2-Inventoryのマスタ設定

プレイヤーが所有するlottery-testというインベントリ内で、ガチャの実行に使用するポイントと、ガチャの景品として取得できる各アイテムを所持できるようマスタを設定しています。

また、metaDataプロパティで各アイテムの日本語テキスト情報を保持しています。このデータがあることでGS2-Inventoryの処理に影響はありませんが、Unity側でこの情報を受け取ってUIへの表示などに使用することができます。

{
  "version": "2019-02-05",
  "inventoryModels": [
    {
      "name": "lottery-test",
      "initialCapacity": 10,
      "maxCapacity": 10,
      "protectReferencedItem": false,
      "itemModels": [
        {
          "name": "point",
          "metadata": "{\"displayName\": \"ポイント\"}",
          "stackingLimit": 10000,
          "allowMultipleStacks": false,
          "sortValue": 0
        },
        {
          "name": "diamond",
          "metadata": "{\"displayName\": \"ダイアモンド\"}",
          "stackingLimit": 10,
          "allowMultipleStacks": false,
          "sortValue": 1
        },
        {
          "name": "gold",
          "metadata": "{\"displayName\": \"金\"}",
          "stackingLimit": 10,
          "allowMultipleStacks": false,
          "sortValue": 2
        },
        {
          "name": "platinum",
          "metadata": "{\"displayName\": \"プラチナ\"}",
          "stackingLimit": 10,
          "allowMultipleStacks": false,
          "sortValue": 3
        },
        {
          "name": "silver",
          "metadata": "{\"displayName\": \"銀\"}",
          "stackingLimit": 10,
          "allowMultipleStacks": false,
          "sortValue": 4
        },
        {
          "name": "copper",
          "metadata": "{\"displayName\": \"銅\"}",
          "stackingLimit": 10,
          "allowMultipleStacks": false,
          "sortValue": 5
        },
        {
          "name": "iron",
          "metadata": "{\"displayName\": \"鉄\"}",
          "stackingLimit": 10,
          "allowMultipleStacks": false,
          "sortValue": 6
        },
        {
          "name": "stone",
          "metadata": "{\"displayName\": \"石\"}",
          "stackingLimit": 10,
          "allowMultipleStacks": false,
          "sortValue": 7
        }
      ]
    }
  ],
  "simpleInventoryModels": [],
  "bigInventoryModels": []
}

GS2-Lotteryのマスタ設定

抽選処理に使用する抽選モデルlottery-test-normalと、そのモデルで使用される景品テーブルについて設定しています。

抽選モデル

modeで抽選方式として通常ガチャを使用すること、methodで景品テーブルを参照して抽選すること、prizeTableNameで使用する景品テーブルの名前を指定しています。

methodは、他にもscriptと指定してGS2-Script経由で景品テーブルを決定するよう設定することもできます。

{
  "version": "2019-02-21",
  "lotteryModels": [
    {
      "name": "lottery-test-normal",
      "mode": "normal",
      "method": "prize_table",
      "prizeTableName": "prize-test"
    },
  ],
  "prizeTables": [
    // 中身は個別に紹介
  ]
}

1階層目の景品テーブル

景品テーブルprize-testで排出される景品について設定しています。景品には、インベントリにアイテムを追加するような入手アクションの他、更に景品テーブルを指定することもできます。

{
  "name": "prize-test",
  "prizes": [
    {
      "type": "action",
      "acquireActions": [
        {
          "action": "Gs2Inventory:AcquireItemSetByUserId",
          "request": "{\n  \"namespaceName\": \"game-001\",\n  \"inventoryName\": \"lottery-test\",\n  \"itemName\": \"diamond\",\n  \"userId\": \"#{userId}\",\n  \"acquireCount\": 1\n}"
        }
      ],
      "weight": 3
    },
    {
      "type": "prize_table",
      "acquireActions": [],
      "prizeTableName": "prize-test-sr",
      "weight": 7
    },
    {
      "type": "prize_table",
      "acquireActions": [],
      "prizeTableName": "prize-test-r",
      "weight": 20
    },
    {
      "type": "prize_table",
      "acquireActions": [],
      "prizeTableName": "prize-test-n",
      "weight": 70
    }
  ]
}

今回の場合は以下のように景品のレアリティを設定しています。

レアリティ 重み(排出確率) 景品
SSR 3 ダイアモンドを取得する入手アクション
SR 7 SR抽選用景品テーブル
R 10 R抽選用景品テーブル
N 10 N抽選用景品テーブル

SSRはダイアモンドしかないため、そのまま入手アクションを設定しています。それ以外のレアリティはそれぞれ2つ景品が設定されているため、それぞれのレアリティ毎の景品テーブルを指定しています。

これにより、例えばレアリティSRの金を引くまでの流れは以下のようになります

  1. 1階層目の景品テーブルで抽選実行
  2. SR抽選用景品テーブルが当選
  3. SR用景品テーブルで抽選実行
  4. 金が当選

このように入れ子でテーブルを設定することのメリットは、今回のようにレアリティ毎に景品の排出確率を設定したい場合、各レアリティの景品毎に1つずつ確率を設定しないで済むという点が挙げられます。

2階層目の景品テーブル

それぞれのレアリティ別に景品テーブルを定義しており、景品として対象のアイテムの入手アクションを設定しています。

同じレアリティ内ではどの景品も同じ確率で排出されるようにしたいので、重みは全て1で設定しています。

{
  "name": "prize-test-sr",
  "prizes": [
    {
      "type": "action",
      "acquireActions": [
        {
          "action": "Gs2Inventory:AcquireItemSetByUserId",
          "request": "{\n  \"namespaceName\": \"game-001\",\n  \"inventoryName\": \"lottery-test\",\n  \"itemName\": \"gold\",\n  \"userId\": \"#{userId}\",\n  \"acquireCount\": 1\n}"
        }
      ],
      "weight": 1
    },
    {
      "type": "action",
      "acquireActions": [
        {
          "action": "Gs2Inventory:AcquireItemSetByUserId",
          "request": "{\n  \"namespaceName\": \"game-001\",\n  \"inventoryName\": \"lottery-test\",\n  \"itemName\": \"platinum\",\n  \"userId\": \"#{userId}\",\n  \"acquireCount\": 1\n}"
        }
      ],
      "weight": 1
    }
  ]
},
{
  "name": "prize-test-r",
  "prizes": [
    {
      "type": "action",
      "acquireActions": [
        {
          "action": "Gs2Inventory:AcquireItemSetByUserId",
          "request": "{\n  \"namespaceName\": \"game-001\",\n  \"inventoryName\": \"lottery-test\",\n  \"itemName\": \"silver\",\n  \"userId\": \"#{userId}\",\n  \"acquireCount\": 1\n}"
        }
      ],
      "weight": 1
    },
    {
      "type": "action",
      "acquireActions": [
        {
          "action": "Gs2Inventory:AcquireItemSetByUserId",
          "request": "{\n  \"namespaceName\": \"game-001\",\n  \"inventoryName\": \"lottery-test\",\n  \"itemName\": \"copper\",\n  \"userId\": \"#{userId}\",\n  \"acquireCount\": 1\n}"
        }
      ],
      "weight": 1
    }
  ]
},
{
  "name": "prize-test-n",
  "prizes": [
    {
      "type": "action",
      "acquireActions": [
        {
          "action": "Gs2Inventory:AcquireItemSetByUserId",
          "request": "{\n  \"namespaceName\": \"game-001\",\n  \"inventoryName\": \"lottery-test\",\n  \"itemName\": \"iron\",\n  \"userId\": \"#{userId}\",\n  \"acquireCount\": 1\n}"
        }
      ],
      "weight": 1
    },
    {
      "type": "action",
      "acquireActions": [
        {
          "action": "Gs2Inventory:AcquireItemSetByUserId",
          "request": "{\n  \"namespaceName\": \"game-001\",\n  \"inventoryName\": \"lottery-test\",\n  \"itemName\": \"stone\",\n  \"userId\": \"#{userId}\",\n  \"acquireCount\": 1\n}"
        }
      ],
      "weight": 1
    }
  ]
}

GS2-Exchangeのマスタ設定

lottery-testインベントリのポイントを1,000消費する代わりに、lottery-test-normal抽選モデルでの抽選を10回実行する、というリソース交換を定義しています。

{
  "version": "2019-08-19",
  "rateModels": [
    {
      "name": "lottery-test-normal-10",
      "consumeActions": [
        {
          "action": "Gs2Inventory:ConsumeItemSetByUserId",
          "request": "{\n  \"namespaceName\": \"game-001\",\n  \"inventoryName\": \"lottery-test\",\n  \"userId\": \"#{userId}\",\n  \"itemName\": \"point\",\n  \"consumeCount\": 1000\n}"
        }
      ],
      "timingType": "immediate",
      "lockTime": 0,
      "enableSkip": false,
      "skipConsumeActions": [],
      "acquireActions": [
        {
          "action": "Gs2Lottery:DrawByUserId",
          "request": "{\n  \"lotteryName\": \"lottery-test-normal\",\n  \"userId\": \"#{userId}\",\n  \"count\": 10,\n  \"config\": []\n}"
        }
      ]
    }
  ],
  "incrementalRateModels": []
}

GS2-LoginRewardのマスタ設定

1回だけlottery-testインベントリにポイントが10,000入手するアクションが実行されるstartRewardという名前のログインボーナスを定義しています。

{
  "version": "2023-07-11",
  "bonusModels": [
    {
      "name": "startReward",
      "mode": "streaming",
      "resetHour": 0,
      "repeat": "disabled",
      "rewards": [
        {
          "acquireActions": [
            {
              "action": "Gs2Inventory:AcquireItemSetByUserId",
              "request": "{\n  \"namespaceName\": \"game-001\",\n  \"inventoryName\": \"lottery-test\",\n  \"itemName\": \"point\",\n  \"userId\": \"#{userId}\",\n  \"acquireCount\": 10000\n}"
            }
          ]
        }
      ],
      "missedReceiveRelief": "disabled",
      "missedReceiveReliefConsumeActions": []
    }
  ]
}

Unityの実装

続いて、Unityで作成したゲームプロジェクトの内容をご紹介します。

冒頭の画像のように、UIコンポーネントを活用してプレイヤーの現在の所持品一覧を表示したり、ポイントを払って景品ガチャを実行するためのボタンを設定したりしています。

それぞれの機能別にどのようなコードで実装しているかを紹介していきます。

クエストによるポイント獲得

プレイヤーがログインすると同時に以下のGetStartReward()を実行しています。このメソッドでは、GS2-LoginRewardのマスタで定義したstartRewardというログインボーナスの報酬を受け取っています。

具体的な処理の流れとしては、RecieveStatus()startRewardの状態を取得し、受け取り回数が0であることが確認できたらReceiveAsync()にて報酬を受け取っています。1度報酬を受け取ると、それ以降ReceivedSteps.Countの値は1になるため、このGetStartReward()で報酬を受け取ることができるのは最初のログイン時のみとなります。

これにより、プレイヤーが初回起動時にガチャ実行用のポイントを10,000取得する機能を実現しています。

private async UniTask GetStartReward()
{
        // ログインボーナスの状態を取得
    var status = await gs2Data.Client
        .LoginReward
        .Namespace("game-0001")
        .Me(gs2Data.LoginUserGameSession)
        .ReceiveStatus(bonusModelName: "startReward")
        .ModelAsync();

    // 報酬受け取り回数が0であることを確認
    if (status.ReceivedSteps.Count == 0)
    {
        // 報酬受け取り実行
        var transaction = await gs2Data.Client
            .LoginReward
            .Namespace("game-0001")
            .Me(gs2Data.LoginUserGameSession)
            .Bonus()
            .ReceiveAsync(bonusModelName: "startReward");

        await transaction.WaitAsync();
    }
}

現在のインベントリ状況表示処理

GS2-Inventoryのプレイヤーのアイテム所持状況をアイテム一覧UIコンポーネントに表示しています。

最初にGetItemModels()でlottery-testインベントリで管理しているアイテム情報を取得してメンバ変数_itemModelsに保存しています。これは、後でアイテム情報をUIに表示する際に使用します。

RefreshItemList()では、プレイヤーの最新のアイテム所持情報を元にアイテム一覧UIコンポーネントを更新しています。[アイテム名 x 個数]というフォーマットで各アイテムの情報をテキストで表示していますが、アイテム名の表示にはGetItemDisplayName()を使用しています。このメソッドは、引数に与えられたitemNameを元にメンバ変数_itemModelsを検索し、対象のアイテムのmetadataプロパティに保管されている日本語テキストを返しています。GS2-InventoryのマスタのitemModel.nameには英数字しか定義できないため、代わりにmetadata内に日本語のアイテム名を定義しておき、それをUIへの表示に活用している形です。

// ItemModel内のmetadataに入っているjsonテキストをオブジェクトとして扱うためのクラス定義
public class ItemMetaData
{
    public string displayName;
}

public class Gs2InventoryList : MonoBehaviour
{
    [SerializeField] Gs2Data gs2Data;
    [SerializeField] GameObject itemListContent;
    [SerializeField] TMP_Text itemListValue;

    private List<EzItemModel> _itemModels;

    public async void Start()
    {
        await GetItemModels();
        await RefreshItemList();
    }

    // lottery-testで管理するアイテムの情報をメンバ変数_itemModelsに保持
    private async UniTask GetItemModels()
    {
        _itemModels = await gs2Data.Client
            .Inventory
            .Namespace("game-001")
            .InventoryModel("lottery-test")
            .ItemModelsAsync()
            .ToListAsync();
    }

    // アイテム一覧UIコンポーネントの情報更新
    public async UniTask RefreshItemList()
    {
        // コンポーネント内の情報を一旦全部削除
        foreach (Transform child in itemListContent.transform)
        {
            Destroy(child.gameObject);
        }

        try
        {
            // lottery-testインベントリ内のアイテム所持情報取得
            var myItems = await gs2Data.Client
                .Inventory
                .Namespace("game-001")
                .Me(gs2Data.LoginUserGameSession)
                .Inventory("lottery-test")
                .ItemSetsAsync()
                .ToListAsync();

            // アイテム所持情報を元にUIコンポーネントのテキスト表示追加
            myItems.ForEach(myItem =>
            {
                var displayName = GetItemDisplayName(myItem.ItemName);
                itemListValue.text = $"{displayName} x {myItem.Count}";
                Instantiate(itemListValue, itemListContent.transform);
            });
        }
        catch (Exception e)
        {
            Debug.LogError(e);
        }
    }

    // GS2-Inventoryのmetadata情報を元に各アイテムの日本語テキストを取得
    public string GetItemDisplayName(string itemName)
    {
        var itemModel = _itemModels.Find(item => item.Name == itemName);
        var itemMetaData = JsonUtility.FromJson<ItemMetaData>(itemModel.Metadata);
        return itemMetaData.displayName;
    }
}

ガチャ実行処理

GS2-Exchangeを使って、GS2-Inventoryで保持しているポイントを1,000消費してガチャを10回実行するリソース交換を行っています。

NormalLotterySubmit()では、最初にClearDrawnResult()でガチャ景品情報保管バッファを初期化しています。このバッファには、GS2-Lotteryによる抽選の実行結果がリスト形式で格納されているのですが、今回の結果以外を取得しないように初期化する必要があります。ExchangeAsync()で、あらかじめマスタで定義したlottery-test-normal-10によるリソース交換を実行しています。

RefreshLotteryResult()では、ガチャの実行結果をダイアログに表示する処理を行っています。DrawnPrizesAsync()でガチャ景品情報保管バッファから抽選の実行結果を取得し、それをUIで表示しています。

// 景品結果情報のjsonテキストをオブジェクトとして扱うためのクラス定義
[Serializable]
public class DrawnPrizeRequest
{
    public string namespaceName;
    public string inventoryName;
    public string itemName;
    public string userId;
    public int acquireCount;
}

public class Gs2Lottery : MonoBehaviour
{
    [SerializeField] Gs2Data gs2Data;
    [SerializeField] GameObject LotteryResultListContent;
    [SerializeField] TMP_Text LotteryResultListValue;
    [SerializeField] GameObject gs2InventoryListObject;
    [SerializeField] GameObject LotteryMenuPanel;
    [SerializeField] GameObject LotteryResultPanel;

    private Gs2InventoryList _gs2InventoryList;
    public void Start()
    { 
        _gs2InventoryList = gs2InventoryListObject.GetComponent<Gs2InventoryList>();
    }

    // 通常ガチャの実行
    public async void NormalLotterySubmit()
    {
        try
        {
            // ガチャ景品情報保管バッファを初期化
            gs2Data.Client.Lottery.ClearDrawnResult("game-001");

            // ポイントの対価にガチャを実行するExchangeを実行
            var transaction = await gs2Data.Client.Exchange
                .Namespace("game-0001")
                .Me(gs2Data.LoginUserGameSession)
                .Exchange()
                .ExchangeAsync(rateName: "lottery-test-normal-10", count: 1);

            await transaction.WaitAsync(true);

            await RefreshLotteryResult();
        }
        catch (Exception e)
        {
            Debug.LogError(e);
        }
    }

    // ガチャの実行結果をダイアログに表示    
    async UniTask RefreshLotteryResult()
    {

        // ダイアログ内の情報を一旦全部削除
        foreach (Transform child in LotteryResultListContent.transform)
        {
            Destroy(child.gameObject);
        }

        try
        {
            // ガチャ景品情報保管バッファから、最新の景品排出結果情報を取得
            var drawnPrizes = await gs2Data.Client.Lottery
                .Namespace("game-001")
                .Me(gs2Data.LoginUserGameSession)
                .Lottery()
                .DrawnPrizesAsync()
                .ToListAsync();

            // 排出結果情報を元にUIコンポーネントのテキスト表示追加
            foreach (var drawnPrize in drawnPrizes) {
                // 入手アクションの内容から景品のアイテム情報取得
                var drawnPrizeRequest = JsonUtility.FromJson<DrawnPrizeRequest>(drawnPrize.AcquireActions[0].Request);
                var displayName = _gs2InventoryList.GetItemDisplayName(drawnPrizeRequest.itemName);
                LotteryResultListValue.text = displayName;
                Instantiate(LotteryResultListValue, LotteryResultListContent.transform);
            }

            LotteryMenuPanel.SetActive(false);
            LotteryResultPanel.SetActive(true);
        }
        catch (Exception e)
        {
            Debug.LogError(e);
        }
    }
}

実行結果

ゲームを起動すると、裏側でログインボーナスの報酬を取得し、以下の画像のように現在の所持品として10,000ポイントだけ持った状態でスタートします。

「ガチャ実行」ボタンをクリックすると、GS2-Exchangeでのリソース交換処理によって1,000ポイントを消費する代わりにガチャが10回実行され、ゲーム画面は以下のようにガチャによる景品の取得結果が表示されます。

抽選結果ダイアログを閉じると、所持品一覧にガチャによる景品排出結果が反映されています。

もう一度ガチャを実行した結果が以下の画像です。レアリティによって景品の取得数が違っています。

まとめ

GS2-Lotteryによってガチャ機能を実装する例をご紹介しました。

様々なソーシャルゲームで使われているガチャ機能が、GS2を使用することで少ない労力で実装することが可能になっています。