うどんてっくメモ

技術的なメモをまったりと

【Unity】Unity Gaming Serviceのサンプルプロジェクトを触ってみる

はじめに

本記事はUnity Gaming Serviceが2023年1月現在で提供しているドキュメント、およびGitHub上でのサンプルプロジェクトについての手順などを紹介する記事になります。 検証環境は以下になります。バージョンが違う場合には動作しない場合がありますので、ご留意ください。

  • Unity2020.3.20f1
  • Unity Gaming Services Use Cases 1.8.0

Unity Gaming Service

Unity Gaming Service(以下、UGS)はゲームでよく必要となる機能を使いやすい形でUnityが提供しているサービスです。

unity.com

たとえばモバイルゲームでありがちなユーザー認証やデータ保存だったりとか、多人数のゲームで必要になりがちなマッチング機能やボイスチャットだったりとか、汎用的に需要のある機能について開発者が実装することなく、このサービスを使えばサクッと実現できますよというものです。

使い放題かというとそういうわけではなく、機能によって無料と有料の部分があったり、従量課金制だったりします。詳しい料金形態は公式を参照してください。

unity.com

サンプルプロジェクトの導入

導入にはまずGitHubリポジトリ上に公開されているサンプルを落としてきます。

github.com

UGSはUnity Service用のアカウントが必要になるのですが、このサンプルにはテスト用のエディタ上でだけ使用するアカウントが紐付けされています。なので自身のUnity Serviceアカウントを紐付けたりする必要はありません。(その代わり、エディタ上でアカウントについての警告が出たり、ビルドできないといった制約があります)

Note: This project is tied to a Unity Services Account that allows read-only testing in the Editor. The messages "Unable to link project to Unity Services" in Project Settings and "Unable to access Unity Services" in Build Settings are expected. Additionally, you will be unable to create a device build of this project.

起動するだけで最低限の準備は完了となります。サンプルは以下の用意があり、それぞれシーンとして作成されています。 サンプルごとに必要な手順などを実行します。手順についてはREADMEとして用意されています。

ひとつひとつのサービスを紹介するというよりは、ゲームでありがちな部分のサンプルです。Assets/Use Case Samplesにシーンやスクリプトが配置されています。 本記事ではこれらの中から「Idle Clicker Game」について概要や処理を追ってみつつ紹介します。

Idle Clicker game

Idle Clicker gameは時間経過とともに進捗する箱庭系のゲームのサンプルです。ルールは下記の通りです。

  • 5*5のマス目上のマスと水がある
  • マスの状態には何もない、井戸、岩がある
  • 何もないマスをクリックすると水を消費して井戸になる
  • 井戸は時間経過で水を補給する
  • 井戸同士を合成してより強力な井戸にできる。合成できるのは同じ合成回数の井戸同士のみ
  • 井戸の合成回数によってより強力な井戸(合成回数の多い井戸)が解禁されていく

細かいルールについてはREADMEのFunctionの部分に書いてあるので、そちらを参照してください。井戸を設置して水を貯めて井戸を強化して、というルーチンを繰り返して水を増やすゲームとなっています。 最近のゲームだと「トップウォー」などをイメージしてもらえるとわかりやすいかと思います。

このサンプルでは以下のUGSを利用し、ゲームスキームを構築しています。

  • Authentication : ユーザー単位でゲーム情報を管理するための認証
  • Cloud Code : ユーザーの初期データ処理、通貨や時間経過によるロジックの遂行
  • Cloud Save : ユーザーのゲーム進捗やタイムスタンプ、機能の解放状態などの保存
  • Economy : 通貨(このサンプルでは水のこと)情報の管理

とりあえずシーンを開いて実行してみます。マスのランダムな初期状態の策定と水の初期量が配布されてゲームが開始されます。

ログを見てみると、まずAuthenticationによってユーザーとそのIdを生成、そしてゲームの初期化処理が走っていそうなことがわかります。

実際の実装を追ってみます。 IdleClickerGameSceneManagerという部分でAuthentificationおよびEconomyによるユーザー情報の取得や通貨情報の取得を行なっています。

// IdleClickerGameSceneManagerのStart
async void Start()
{
    try
    {
        await UnityServices.InitializeAsync();

        // Check that scene has not been unloaded while processing async wait to prevent throw.
        if (this == null)
            return;

        if (!AuthenticationService.Instance.IsSignedIn)
        {
            await AuthenticationService.Instance.SignInAnonymouslyAsync();
            if (this == null)
                return;
        }

        Debug.Log($"Player id:{AuthenticationService.Instance.PlayerId}");

        // Economy configuration should be refreshed every time the app initializes.
        // Doing so updates the cached configuration data and initializes for this player any items or
        // currencies that were recently published.
        // 
        // It's important to do this update before making any other calls to the Economy or Remote Config
        // APIs as both use the cached data list. (Though it wouldn't be necessary to do if only using Remote
        // Config in your project and not Economy.)
        await EconomyManager.instance.RefreshEconomyConfiguration();
        if (this == null)
            return;

        await GetUpdatedState();
        if (this == null)
            return;

        await EconomyManager.instance.RefreshCurrencyBalances();
        if (this == null)
            return;

        ShowStateAndStartSimulating();

        sceneView.SetInteractable();

        Debug.Log("Initialization and signin complete.");
    }
    catch (Exception e)
    {
        Debug.LogException(e);
    }
}
  1. Authentificationを通して匿名サインイン (AuthenticationService.Instance.SignInAnonymouslyAsync)
  2. Economyから通貨情報を取得 (EconomyManager.instance.RefreshEconomyConfiguration)
  3. ユーザーの開始タイミングでの状態を通貨情報と照らし合わせて取得(GetUpdatedState)
  4. 現在の通貨残高を取得(EconomyManager.instance.RefreshCurrencyBalances())
  5. ゲームのUIに情報を反映

モバイルゲームでよくバックエンド側で実装を行うユーザー管理や通貨管理といった部分がUGSによって成立していることがわかるかと思います。 3の手順をもう少し深く追ってみましょう。

async Task GetUpdatedState()
{
    try
    {
        var updatedState = await CloudCodeManager.instance.CallGetUpdatedStateEndpoint();
        if (this == null)
            return;

        UpdateState(updatedState);
        Debug.Log($"Starting State: {updatedState}");
    }
    catch (CloudCodeResultUnavailableException)
    {
        // Exception already handled by CloudCodeManager
    }
    catch (Exception e)
    {
        Debug.LogException(e);
    }
}

void UpdateState(IdleClickerResult updatedState)
{
    EconomyManager.instance.SetCurrencyBalance(k_WellGrantCurrency, updatedState.currencyBalance);
    SimulatedCurrencyManager.instance.UpdateServerTimestampOffset(updatedState.timestamp);

    m_AllWells[0] = SetWellLevels(updatedState.wells_level1, 1);
    m_AllWells[1] = SetWellLevels(updatedState.wells_level2, 2);
    m_AllWells[2] = SetWellLevels(updatedState.wells_level3, 3);
    m_AllWells[3] = SetWellLevels(updatedState.wells_level4, 4);

    m_Obstacles = updatedState.obstacles;

    UnlockManager.instance.SetUnlockCounters(updatedState.unlockCounters);
}

/// CloudCodeManagerより抜粋
public async Task<IdleClickerResult> CallGetUpdatedStateEndpoint()
{
    try
    {
        var updatedState = await CloudCodeService.Instance.CallEndpointAsync<IdleClickerResult>(
            "IdleClicker_GetUpdatedState",
            new Dictionary<string, object>());

        return updatedState;
    }
    catch (CloudCodeException e)
    {
        HandleCloudCodeException(e);
        throw new CloudCodeResultUnavailableException(e,
            "Handled exception in CallGetUpdatedStateEndpoint.");
    }
}

CloudCodeServiceからCallEndpointAsyncを介して更新処理を実行し、その結果をIdleClickerResultという形で受け取っています。

Cloud Codeはゲームロジックなどの実装をUGSが提供するクラウド側を介して呼び出せるようにするものです。これによって、アプリの更新なしでゲームロジックを更新したり、悪意のあるアプリへの攻撃からゲームロジックを守ることができます。ユーザー情報はサーバー側で管理し、ログイン、カードの強化、ガチャ、といったユーザーの情報を更新するような処理はサーバーと通信して、サーバーが処理してその結果を返す形です。これによってそれらの処理に更新やメンテナンスが必要であればサーバー側の実装を更新すれば済みますし、アプリに対する攻撃で不正なロジックを実行しようとも通信しないといけないのでそのタイミングで弾くことができます。

こういった仕組みをバックエンド側の開発をしなくとも導入したい...!という方に向けたサービスがCloud Codeです(と思っています)。バックエンド側の開発という課題をパスして、ゲームロジックをCloud Code側に委譲することで、アプリ側にゲームロジックをおかないという仕組みを作ることができます。

ここの処理では「IdleClicker_GetUpdatedState」の処理をアプリ側からCloud Code側に依頼し、その結果を取得しています。 Cloud Codeで実行できるスクリプトJavaScriptで、ロジック自体はつまりJavaScriptで実装することになります。今回のサンプルのAssets -> Use Case Samples -> Idle Clicker Game -> Cloud CodeにCloud Code側で実際に実行されているJavaScriptが用意されています。

// Entry point for the Cloud Code script 
module.exports = async ({ params, context, logger }) => {
  try {
    const { projectId, playerId, accessToken } = context;
    const cloudSaveApi = new DataApi({ accessToken });
    const currencyApi = new CurrenciesApi({ accessToken });

    let instance = { projectId, playerId, cloudSaveApi, currencyApi, logger };

    const timestamp = getCurrentTimestamp();
    instance.currentTimestamp = timestamp;

    // Cloud Save側で保存されているユーザー情報を見に行く
    instance.state = await readState(instance);
    if (instance.state) {

      logger.info("Read start state: " + JSON.stringify(instance.state));

    // ユーザー情報が無ければ初期ユーザーなので、ランダムな初期状態を構築する
    } else {

      createRandomState(instance);
      logger.info("Created random start state: " + JSON.stringify(instance.state));

      await setInitialCurrencyBalance(instance);
    }

    // 時間経過による状態の更新を行う(井戸の影響など)
    await updateState(instance);
    logger.info("Updated state: " + JSON.stringify(instance.state));

    // 更新した状態をCloud Saveに保存する
    await saveGameState(instance);

    // 保存後、レスポンスに更新された水の量の情報を付与する
    instance.state.currencyBalance = instance.currencyBalance;

    return instance.state;

  } catch (error) {
    transformAndThrowCaughtError(error);
  }
}

具体的なCloud Codeでの実装や、デプロイの方法などはこちらのCloud Code自体の公式のドキュメントを参考にしてもらえると幸いです。

docs.unity.com

また、Cloud Codeの利点としてCloud Saveと連携ができるという部分もあげられます。サンプルのゲームではユーザー情報はCloud Saveに保存していますが、この部分ではCloud CodeがユーザーのCloud Saveに問い合わせて情報を取得してIdleClickerResultという形で返しています。UGS同士が連携してロジックを構成できるのも利点のひとつです。

[Serializable]
public struct IdleClickerResult
{
    public long timestamp;
    public long currencyBalance;
    public List<WellInfo> wells_level1;
    public List<WellInfo> wells_level2;
    public List<WellInfo> wells_level3;
    public List<WellInfo> wells_level4;
    public List<Coord> obstacles;
    public Dictionary<string, int> unlockCounters;

    public override string ToString()
    {
        var unlockCountersStr = string.Join(",", unlockCounters.Select(kv => $"{kv.Key}={kv.Value}"));
        return $"timestamp:{timestamp}, " + 
            $"currencyBalance:{currencyBalance}, " + 
            $"wells_level1:[{string.Join(",", wells_level1)}], " +
            $"wells_level2:[{string.Join(",", wells_level2)}], " +
            $"wells_level3:[{string.Join(",", wells_level3)}], " +
            $"wells_level4:[{string.Join(",", wells_level4)}], " +
            $"obstacles:[{string.Join(",", obstacles)}], " +
            $"unlockCounters:[{unlockCountersStr}]";
    }
}

初期化処理の部分だけではなく、実際に井戸を作ったり合成したりする部分も見てみましょう。試しに適当に井戸を配置してみます。

井戸を作成したというログが流れてきます。

実装を見てみます。前述したCloudCodeManagerにゲームに干渉する一連の処理が実装されています。 返り値としてIdleClickerResultを受け取る形になっており、ロジックによる結果だけを反映するフローです。

// 井戸の配置
public async Task<IdleClickerResult> CallPlaceWellEndpoint(Vector2 coord)
{
    try
    {
        var updatedState = await CloudCodeService.Instance.CallEndpointAsync<IdleClickerResult>(
            "IdleClicker_PlaceWell",
            new Dictionary<string, object> {
                { "coord", new Coord { x = (int)coord.x, y = (int)coord.y }}
            });

        return updatedState;
    }
    catch (CloudCodeException e)
    {
        HandleCloudCodeException(e);
        throw new CloudCodeResultUnavailableException(e,
            "Handled exception in CallPlaceWellEndpoint.");
    }
}

// 井戸の合成
public async Task<IdleClickerResult> CallMergeWellsEndpoint(Vector2 drag, Vector2 drop)
{
    try
    {
        var updatedState = await CloudCodeService.Instance.CallEndpointAsync<IdleClickerResult>(
            "IdleClicker_MergeWells",
            new Dictionary<string, object> {
                { "drag", new Coord { x = (int)drag.x, y = (int)drag.y }},
                { "drop", new Coord { x = (int)drop.x, y = (int)drop.y }}
            });

        return updatedState;
    }
    catch (CloudCodeException e)
    {
        HandleCloudCodeException(e);
        throw new CloudCodeResultUnavailableException(e,
            "Handled exception in CallMergeWellsEndpoint.");
    }
}

井戸の操作を行ったタイミングでCloud Code側で処理が走り、その情報もCloud Save側に保存されます。すべての操作がこうなっているため、アプリを終了してもその時の状態に復帰できる形となっています。Cloud Code側の実装を次に示します。

// Entry point for the Cloud Code script 
module.exports = async ({ params, context, logger }) => {
  try {
    logger.info("Script parameters: " + JSON.stringify(params));

    const { projectId, playerId, accessToken } = context;
    const cloudSaveApi = new DataApi({ accessToken });
    const currencyApi = new CurrenciesApi({ accessToken });
    const purchasesApi = new PurchasesApi({ accessToken });

    let coord = params.coord;

    let instance = { projectId, playerId, cloudSaveApi, currencyApi, purchasesApi, logger };

    const timestamp = getCurrentTimestamp();
    instance.currentTimestamp = timestamp;

    // Update the current state by granting Water and updating timestamp.
    instance.state = await readState(instance);
    if (!instance.state) {
      throw new StateMissingError("PlaceWell script executed without valid state setup. Be sure to call GetUpdatedState script before merging wells.");
    }
    logger.info("Start state: " + JSON.stringify(instance.state));

    // 時間経過による状態の更新を行う(井戸の影響など)
    await updateState(instance);
    logger.info("Updated state: " + JSON.stringify(instance.state));

    // 更新した状態をCloud Saveに保存する
    await saveGameState(instance);

    // 井戸を置こうとした場所が変ならエラー
    throwIfLocationInvalid(instance, coord);

    // 井戸を置こうとした場所が使用ずみならエラー
    throwIfSpaceOccupied(instance, coord);

    // 井戸を水で購入
    await purchaseWell(instance);

    // 井戸を配置
    addWellToState(instance, coord);

    // 状態を保存
    await saveGameState(instance);

    // レスポンスに含める情報として、井戸の分減った水の量を入れる
    instance.state.currencyBalance = instance.currencyBalance - wellCurrencyCost;

    return instance.state;

  } catch (error) {
    transformAndThrowCaughtError(error);
  }
}

他にもロック解放処理やゲームのリセットなどの機能もありますが、これらもCloud Codeを介して実装されています。 本記事では以上の初期化処理と井戸を置く部分についての紐解きで終了となりますが、ぜひ気になった方は実際にサンプルを動かしてみてください。

おわりに

Unity Gaming Serviceは最近Unityから色々と発表されているものの、どういう機能があるんだろうか、どういう場面で有用なんだろうかと様子見してる方もいるかと思います。 サンプルプロジェクトとドキュメントがユースケースごとに用意されているので、そういう方は一度触ってみるのが良さそうです。 一定の規模感があるとなかなか要件が難しく使うことができない場面もありますが、個人開発の方などは一度使ってみると良さそうな機能が揃っていそうです。

2023年の記事の書き初めになりますが、今年もよろしくお願いいたします。

参考文献

公式ドキュメント

docs.unity.com