うどんてっくメモ

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

【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

2022年の振り返りと2023年の抱負

2022年にやったことを振り返りつつ、2023年頑張ることを書いていきます。

2022年やったこと

個人ブログ投稿 10本

この技術ブログである「うどんてっくメモ」、今年は10個の記事を投稿しました。 毎月1個が目標なんですが、ちょっと本を書いたり仕事が忙しかったりゲームが忙しかったりと想定していたペースよりやや落ちてしまいました。

myudon.hatenablog.com

仕事の関係でUIと向き合うことが多く、uPaletteやTextMeshProといったUnityでUIを実装する上での便利機能やTipsのような記事を書くことが多かったです。 また、Rustが楽しく、Rust×ゲームという部分に着目してゲームエンジンを触ったりGodotの実装をRustで行ったりもしました。

myudon.hatenablog.com

ただ、Rust自体の知見の深さや良さの掘り下げはあまりできてない気がしていて、ゲーム以外のアプリケーションだったり、言語そのものの知見を深めたりということも進めたいです。

会社ブログ投稿 2本

所属しているQualiArtsという会社のエンジニアブログにも2本の記事を投稿しました。

technote.qualiarts.jp

technote.qualiarts.jp

実は業務的に技術広報的な立ち位置におり、このエンジニアブログ自体の運用も行なっているのですが、自分で執筆するのも何回か経験しております。 会社のブログを書くときには「業務ではこういう見方でこういうアプローチを行った」という会社の知見ならではの記事を意識して書いています。 今年書いた記事もQualiArtsのUI開発やCI周りの知見が詰まっているので、興味のある方はぜひ覗いてみてください。

Zenn投稿 1本

ちょっとしたレベルの知見はZennに置こうと思っていたのですが、あまり手元に溜まらず一件だけZennの方に投稿させていただきました。

zenn.dev

さすがに使い所がかなり限定される記事ですが、刺さる人には刺さるかなと思います(笑)。 いわゆる小ネタ系の記事は思いついたらZennに投げることを徹底してやりたいなあと思っています。

技術書典の出典 会社と個人

技術書典で「UniTips」というUnityの技術書を定期的に会社で出しているのですが、2022年も執筆に参加しました。

creator.game.cyberagent.co.jp

個人執筆の本も2冊出させていただきました。

techbookfest.org

techbookfest.org

個人で出したのはUnityとテストに関する本、そしてIDEであるRiderの本と、どちらかというと仕事で個人的に得た知見を本にした形になります。 どちらもニッチというか、人を選ぶかなと思っていたのですが、想定を上回る数購入していただきました。 Twitter上でも購入したという声を多く聞けて嬉しかったです。今後も続けていきます。

商業誌執筆

ご縁がありまして、去年に引き続き技術書典で出していた個人執筆の本を商業誌として刊行していただきました。

nextpublishing.jp

商業誌で出すのも2冊目となり、より自分の制作物が世の市場に出回るようになって個人的にもアウトプットのモチベーションにつながっています。

CA.unityの継続開催と登壇

CA.unityを2021年にはじめ、2022年はその継続回となるCA.unity #2~5を開催いたしました。また、#5では実際に自ら登壇も行いました。

meetup.unity3d.jp

learning.unity3d.jp

ありがたいことに多くの企業様にご協力いただきまして、開催を続けられております。本当にありがたい限りです。開催したアーカイブについてもすべてUnity Learning Materialsに掲載していただいており、通算で20を超えるアーカイブがすでに揃っております。ありがたいことに専用のカテゴライズページも用意してもらえるようになりました。

learning.unity3d.jp

「企業の持つノウハウをしっかり業界に還元しつつ、業界の知見が共有される場を作る」という目的ではじめたこの企画ですが、コンスタントに続けられるのはひとえに協力してくれる皆様と見にきていただいている業界の皆様のおかげです。

今後も多くの企業の知見を届けられるよう運用を続けていく予定です。

FP3級取得

あまりエンジニア的な観点では関係ないですが、社会人として一定のお金の知識をつけたく、FPの勉強を行い、3級を取得しました。

ここでいうお金の知識というのは投資やFXをあれこれして金銭を増やすという知識ではなく、社会人としての税金の知識や確定申告、保険、不動産などの知識を指します。

実は2022年にマンションのローン購入に踏み切ったのですが、FPで得た知識は非常に武器になりました。固定資産税の評価や借地権の評価、住宅ローン控除や贈与税などなど多くの知識によってあまりお金周りの大損をせずに契約に向き合うことができました。 また、親が所有資産や不動産まわりのやりくりで困っていたこともあり、それに対処する知識を身につけるという点でも大きく役立ちました。

FPは社会人として生きていく上でのお金の必須知識がかなり詰まっているので、個人的にはおすすめしたい資格のひとつです。 余談ですが、2023年には2級を取得したいと考えており、ひそかに勉強中です。

Qiitaアドベントカレンダー 2つ参加

毎年恒例アドベントカレンダー、2022年も会社のものとUnityのものに参加させていただきました。

technote.qualiarts.jp

myudon.hatenablog.com

今年も会社のアドベントカレンダーを運用して広報したりしていました。2022年はなかなかニッチな(?)ネタが集まったアドカレが仕上がったと思います。 ぜひぜひご覧ください。

qiita.com

2023年の抱負

より深くインプットし、より広く大きくアウトプットする

2022年はさまざまなものをインプットしながら広く学ぶ意味合いが大きい年でした。業務の担当が広くなったこともあり、単純なゲームシステム実装にとどまらない学びが多かったです。 2023年は広く学んだところからゲーム開発にフォーカスしてどのような使い方ができるか、掛け合わせで有用なことができるか、より具象化しつつ深く学んでいきたいと感じています。自分が目指すのはゲーム業界に貢献できるエンジニアなので、その軸をブラさないような知識の有効利用を考えていきます。

また、アウトプットについても色々な登壇機会に挑戦したり、もっと多くの人にみてもらえるようなアウトプットの大成を頑張りたいなという気持ちがあります。 これについてはまだまだ何も構想がないので、来年いちから考えるところから向き合っていきたいです。

知識獲得のための資格勉強

FP3級という資格獲得を経て、資格そのものというよりは必要な勉強による体系立てた知識の確立を魅力に感じました。 元々資格自体にさほど関心がなかったのですが、取っても無駄とは元々思っておらず、学んでみたいとはふんわり感じていました。それがFP3級を経て大きくなった形です。 情報系であればIPAの実施するものがありますし、お金周りで言えばFPなどが挙げられます。自分が学びに興味があるのがこの二軸なので、2023年はさらに挑戦してみたいという気持ちがあります。

最後に

2022年は何かと仕事が忙しかったなあ、と思いつつ実は一番忙しかったのはゲームをすることでした(笑)。一番やりこんでいるポケモンが新作出したり、スプラトゥーンをやったり、ソーシャルゲームの新作と向き合ったりなど、ゲーム好きとしてのインプットが結果的に濃い一年だったと感じています。 2023年もゲームに仕事に忙しくなりそうなので、私生活での時間を配分をしっかり戦略立てないとなあと感じています。しっかり学びつつ遊びつつを両立した日々の過ごし方を確立していきたいですね。目標にもある広い学びとこのゲームの経験をしっかり活かした生産的な時間を目指していきます。

【Unity】新しいUnity Searchの隠れた機能紹介

はじめに

この記事はUnity Advent Calendar 2022の11日目の記事です。 今年も多くの記事が上がっているので、ぜひぜひチェックしてみてください!

qiita.com

本記事で紹介および検証を行なっているツールのバージョンは次の通りです。

  • Unity 2023.1.0a14

バージョンによっては挙動に差異がある場合もありますので、ご了承ください。

Unity Search

Unity上で開発する上で息をするように使うのがアセットやスクリプトなどの検索機能です。

Unityではこの検索機能に最近注力していて、Unity Searchの進化やパフォーマンス改善、設定の拡充などさまざまな改修が入っています。 詳しくは公式で動画としてUnity Learning Materialsにまとめられているのでそちらを参考にしてください。

learning.unity3d.jp

実は2022年の頭に自分も記事を書いています。

myudon.hatenablog.com

そんなUnity Searchの機能について、今回の記事では基本的な機能というよりはあまり知られていなさそうな機能について紹介します。 Query Builder ModeといったUnity Searchの機能を前提に話したりするので、わからない単語など出てきた際には前述の公式資料や自分の記事などを読んでいただければと思います。

検索条件の保存

色々な検索条件が指定できるUnity Searchですが、検索条件のパターンを保存して使いまわすことが可能になっています。 検索条件を入力している状態で、左に表示されているSearchesから保存アイコンを押すと入力されている検索条件を保存できます。

また、保存した検索条件の名前も変えることが可能です。複雑な条件は文字列だけ見ても分かりづらいので、直感的な名前をつけるとより使いやすいです。

そしてさらに便利なところが、ユーザーのスコープで保存するものとプロジェクトのスコープで保存するものを指定できる点です。 チーム内全員に需要があるような検索条件は積極的に共有すると捗ります。よく使うPrefabだったり、特定の機能に使うパーツだったり、パッと出てくると嬉しい検索条件をまとめて、プロジェクトのスコープで保存しておくとよいでしょう。

Search AssetStore

実はAssetStoreもSearch Windowによって検索することが可能です。 検索欄の最初に「store: 」とつけるか、Query Builder Modeで「in Asset Store」を設定するとAsset Store内の検索を行います。

ちょっと個人でモックを作っていてAsset Storeにいい感じのものがないかな、という際にサクッとキーワードをもとに検索できるのでたまに便利だったりします。 Component条件なども指定できちゃうのかな?と思って試しましたが、さすがに効かないようでした。あくまでもキーワードのみのようですが、もし何かしらのフィルタリングができそうであれば指摘していただけると助かります。

また、Window -> Search -> Asset Storeを実行すると最初からAsset Storeを対象にしたWindowを開くことも可能です。

Performance

エディタ上で実行されている処理の呼び出し回数や処理時間などを一覧にして確認する機能も実装されています。 Window -> Search -> Performance Trackersを実行すると専用のWindowが開きます。

処理の関数名とパラメータが一覧になっており、リアルタイムで更新されます。呼び出し回数や処理時間について突出しているものについては黄色や赤色の文字でハイライトされるためわかりやすいです。 何かしらエディタの動作が重かったり、独自で重めの拡張処理などを実装した際には名前で検索することでフィルタリングできます。

何かしらのエディタ上での便利機能やUI Toolkitの実装などを行なった際に、パフォーマンスに不安があれば一度確認してみるといいでしょう。

おわりに

Unity Searchはかなり進化していて、使いこなすことで普段の何気ない作業がかなり効率化されます。 他にも機能が用意されているので色々と試してみるのをおすすめします。

【Unity】TextMeshProで一部の文字だけ精度を上げて綺麗に描画したい時の工夫

TextMeshProでUIを作成していて「どうしてもこの部分は大きいサイズで綺麗に文字を見せたい」というケースは多々あると思います。
TextMeshProを使用する場合、文字の精度はFontAssetにおけるテクスチャ上での文字のサイズを表すSampling Point Sizeに依存します。 小さめのSampling Point Sizeで設定したFontAssetを使用しても大きいサイズの文字は描画可能ですが、その綺麗さに影響します。
次の画像を見ればエッジのがたつきなどがわかるかと思います。

これに対応するにはSampling Point Sizeを上げたFontAssetを使用すれば良いのですが、当然必要となるテクスチャのサイズも上がってしまい、メモリへの懸念が大きくなります。 特に、すべての文字出力に使うフォントで文字サイズを上げるとなるとそれ相応のテクスチャのサイズ拡大が見込まれます。
源ノ角ゴシックこちらのサイトに乗っている文字を全て載せた場合を検証した所、4KテクスチャでSampling Point Sizeは31、8Kというサイズまで上げてようやくSampling Point Sizeは84となりました。

そうすると、通常使いするFontAssetをいじらずに調整したくなってきます。このような場合、特定の部分のみに使用する文字だけを集約したFontAssetを利用するという工夫が可能です。
限られた文字のみであれば、大きいSampling Point Sizeでもテクスチャのサイズを抑えられます。Fallbackには通常使いするFontAssetを指定しておけば、想定外の文字が表示されることも防止できます。

使用する文字が限定できること、開発上使用する部分の設定に気を使うこと、開発途中で文字が増えたら対応することなど、色々と制約も存在しますが、覚えておいて損はないテクニックです。 演出の都合など、どうしても大きい文字を出したいケースなどで使ってみてください。

Rust製プラグインで動くGodotのサンプルゲームを公開しました

はじめに

Rust製プラグインで動くGodotのサンプルrungame_sample_godotを公開しました。

rungame_sample_godot

github.com

Godotのプロジェクトとプラグイン、そして必要なアセットが入ったリポジトリです。 submoduleとして後述するrungame_sample_rustを参照しています。

シーン配置とスクリプトのバインドだけを行っており、GDScriptなどのRust以外の実装は行っていません。 基本的にすべてRustのプラグインを読み込み、その動作に委ねます。

オブジェクトの位置や当たり判定などは雑に動けばいいや程度の作りになっています。
ちなみにアセット自体はMagicaVoxelで自分で適当に作ったものです。

rungame_sample_rust

GDNativeのRustライブラリを活用したプラグインです。

github.com

ゲームロジックであるプレイヤーの移動やルールの処理などを行っています。

Player

プレイヤーはPlayerというstructで表現し、入力をもとにRust側での速度を反映したのちに、Godot側のRigidbodyの更新を呼び出しています。

#[export]
fn _physics_process(&mut self, owner: &RigidBody, delta: f64) {
    // 終了時に飛んできた際の処理
    if self.is_active == false {
        owner.set_linear_velocity(Vector3::zero());
        owner.set_angular_velocity(Vector3::zero());
        return;
    }
    
    // Godotの入力
    let input = Input::godot_singleton();
    
    // 単純な加速処理
    if self.move_velocity.z < self.max_forward_speed {
        self.move_velocity.z += self.move_acceleration * delta as f32; 
    } else if self.move_velocity.z > self.max_forward_speed {
        self.move_velocity.z -= self.move_acceleration * delta as f32;
    };
    
    // 左右の移動速度処理
    self.move_velocity.x = 0.;
    if input.is_action_pressed("ui_left") {
        self.move_velocity.x += self.move_horizontal_speed;
    }

    if input.is_action_pressed("ui_right") {
        self.move_velocity.x -= self.move_horizontal_speed;
    }

    // Rust側の速度をGodot側に反映
    owner.set_linear_velocity(self.move_velocity);
}

Field

ステージ内の加速ゾーンや障害物はFieldというPlayerに干渉するtraitとして実装しています。

// コース上のPlayerに干渉するtrait
pub trait Field {
    fn on_player_entered(&self, player: &mut Player);
}

この実装部分で共通する部分をいい感じにmacroで共通化しようと思ったのですが、あえて至極シンプルにしようとコピペしています。

Rule

ゲーム全体のルールはRuleというstructでゲーム状況と時間を更新します。

#[export]
fn _physics_process(&mut self, owner: &Node, delta: f64) {
    let screen = &mut self.screen;
    // ゲームステートの監視
    match &self.state {
        GameState::Ready => {
            // カウントダウンするUIの更新
            if let Some(start_timer) = &self.start_timer {
                screen.set_countdown(start_timer.time_left() as i64);
            }
        }
        GameState::Game => {
            // 時間の更新
            self.time += delta;
            screen.set_time(self.time);

            let player = unsafe {
                owner
                    .get_node_as_instance::<Player>("World/Player")
                    .expect("Playerが取得できなかった")
            };

            player.map(|player, _| {
                screen.set_player_speed(player.move_velocity.z as f64)
            }).expect("Playerを参照できなかった");
        }
        GameState::Over => {}
    }
}

色々とコメントは残してあるので、詳細な実装はリポジトリを確認してみてください。また、間違っている部分などあればぜひ指摘いただけると大変助かります。

おわりに

Rustでゲームを実装してみたい!という選択肢のうち、一番実装環境が整っているのがGodot+Rustでの開発かなと思います。
BevyなどのRust製ゲームエンジンの開発も進んではいるものの、まだGUIのグラフィカルなエディタや便利なカスタマイズなどは充実していない印象です。 とりあえずRustをゲームで書いてみたい方にはおすすめできる選択肢です。

参考文献

環境構築に当たって、以下のリンクを参考にさせていただきました。

Godot EngineからRustを呼ぶ

また、実装に当たって、以下のリンクを参考にさせていただきました。

Godot + Rust + wasmによる3Dブラウザゲームの作り方またはRustはゲーム制作向き言語なのかの考察的な何か - Qiita

Getting Started - The godot-rust Book

gdnative - Rust

Godot API — Godot Engine (stable)の日本語のドキュメント

技術書典13に個人と会社として技術書を出しました

2022年9月10日から開催される技術書典に個人としてと会社としてと2冊の本を出しました。 また、9月11日に開催されたオフラインでの技術書典にも参加してきました。

オフラインでの実施は久しぶりで、実際にイベントとして書いた本を受け取ってもらうのが懐かしい感覚でした。 後述するUniTipsシリーズのブースとして参加したのですが、多くの方に来ていただけて嬉しかったです。 設営も後輩が頑張ってくれた結果、過去一番しっかりしたブースの見た目になりました。

既刊もVol.7とVol.8の物理本で在庫があったものを持ち込みました。Vol.8については持ち込んだ分が見事に完売しまして、皆様ありがとうございました。 久々の物理参加でバタバタするところもありましたが、実りのある機会になったかなと思います。
最後の方にイベント運営の方も「いつも参加していただきありがとうございます」と挨拶をわざわざしに来ていただきました。連続参加していることも認知していただいており嬉しかったです。

個人ではUnity+Mac環境の開発で採用されることの多いIDE「Rider」について機能をまとめた「Rider Guide Tips with Unity」を執筆しました。 IDEとして基本的な機能から知っておくと少しコードを書くのが効率的になる豆知識的な機能まで色々と取り上げています。

リンクはこちらになります。現在も販売中ですので、興味のある方はこちらからお願いします。

techbookfest.org

そして会社としては、UnityのいろんなTipsを有志で書き連ねた「UniTips Vol.9」を執筆しました。
自分は隠れた(?)機能であるUnityのエディタ上でチュートリアルを製作するためのパッケージであるTutorial Frameworkについて紹介しています。

リンクはこちらになります。こちらも販売中ですので、興味のある方はこちらからお願いします。

techbookfest.org

UniTipsも技術書典5から継続して刊行しており、なんと9作目という形になりました。 当時内定者として企画の立ち上げに携わった身として非常に感慨深いです。

技術書典も9回連続サークルとして参加させていただいており、本当にありがたい限りです。 技術書の執筆は大変ですが、自分の知識を見直しながら学ぶことが可能で自己学習としても非常にいい機会となっております。 今後も積極的に参加していく予定です。

【Unity】DeviceSimulatorPluginを使ってDevice Simulatorの機能を拡張する

本記事で紹介および検証を行なっているツールのバージョンは次の通りです。

  • Unity 2021.3.1f1

バージョンによっては挙動に差異がある場合もありますので、ご了承ください。(少なくともUnity2021.1.0f1以降のバージョンで提供される機能となります)


UnityにはDevice Simulatorというエディタ上でモバイル端末などの出力をシミュレートするGame Viewが提供されています。

myudon.hatenablog.com

そんなDevice Simulatorですが、DeviceSimulatorPluginを活用し、機能を拡張することが可能です。

docs.unity3d.com

実際に上記の公式ページにあるサンプルを実装したスクリプトをAssets配下に置き、Device Simulatorの画面を見てみます。

Control Panelに「Touch Info」という名前のメニューが追加されました。実行し、タッチ操作を行うとその合計回数が表示されるようになっています。
サンプルではDeviceSimulatorPluginを継承したクラスを用意し、「Touch Info」というメニューの名前を指定するtitle、生成時処理を行うOnCreate、中身のUIを構成するOnCreateUIをoverrideして実装されています。 サンプルのコードを引用して次に示します。

public class TouchInfoPlugin : DeviceSimulatorPlugin
{
    public override string title => "Touch Info";

...

    public override void OnCreate()
    {
        deviceSimulator.touchScreenInput += touchEvent =>
        {
            m_TouchCount += 1;
            UpdateTouchCounterText();
            m_LastTouchEvent.text = $"Last touch event: {touchEvent.phase.ToString()}";
        };
    }

    public override VisualElement OnCreateUI()
    {
        VisualElement root = new VisualElement();

        m_LastTouchEvent = new Label("Last touch event: None");

        m_TouchCountLabel = new Label();
        UpdateTouchCounterText();

        m_ResetCountButton = new Button {text = "Reset Count" };
        m_ResetCountButton.clicked += () =>
        {
            m_TouchCount = 0;
            UpdateTouchCounterText();
        };

        root.Add(m_LastTouchEvent);
        root.Add(m_TouchCountLabel);
        root.Add(m_ResetCountButton);

        return root;
    }

...

}

DeviceSimulatorPluginではこれらのoverrideするメソッドの他に、DeviceSimulatorの参照がpublicで用意されています。

/// <summary>
///   <para>Extend this class to create a Device Simulator plug-in.</para>
/// </summary>
/// <footer><a href="https://docs.unity3d.com/2021.3/Documentation/ScriptReference/30_search.html?q=DeviceSimulatorPlugin">`DeviceSimulatorPlugin` on docs.unity3d.com</a></footer>
public abstract class DeviceSimulatorPlugin
{
  internal string resolvedTitle;

  /// <summary>
  ///   <para>Device Simulator in which this plug-in is instantiated.</para>
  /// </summary>
  /// <footer><a href="https://docs.unity3d.com/2021.3/Documentation/ScriptReference/30_search.html?q=DeviceSimulation.DeviceSimulatorPlugin-deviceSimulator">`DeviceSimulatorPlugin.deviceSimulator` on docs.unity3d.com</a></footer>
  public DeviceSimulator deviceSimulator { get; internal set; }

  /// <summary>
  ///   <para>Title for the plug-in UI.</para>
  /// </summary>
  /// <footer><a href="https://docs.unity3d.com/2021.3/Documentation/ScriptReference/30_search.html?q=DeviceSimulation.DeviceSimulatorPlugin-title">`DeviceSimulatorPlugin.title` on docs.unity3d.com</a></footer>
  public abstract string title { get; }

  /// <summary>
  ///   <para>Called when Unity creates the Device Simulator window.</para>
  /// </summary>
  /// <footer><a href="https://docs.unity3d.com/2021.3/Documentation/ScriptReference/30_search.html?q=DeviceSimulation.DeviceSimulatorPlugin.OnCreate">`DeviceSimulatorPlugin.OnCreate` on docs.unity3d.com</a></footer>
  public virtual void OnCreate()
  {
  }

  /// <summary>
  ///   <para>Called when Device Simulator window is destroyed.</para>
  /// </summary>
  /// <footer><a href="https://docs.unity3d.com/2021.3/Documentation/ScriptReference/30_search.html?q=DeviceSimulation.DeviceSimulatorPlugin.OnDestroy">`DeviceSimulatorPlugin.OnDestroy` on docs.unity3d.com</a></footer>
  public virtual void OnDestroy()
  {
  }

  /// <summary>
  ///   <para>The VisualElement that this method returns is embedded in the Device Simulator window. If the method returns null, plug-in UI is not embedded.</para>
  /// </summary>
  /// <footer><a href="https://docs.unity3d.com/2021.3/Documentation/ScriptReference/30_search.html?q=DeviceSimulation.DeviceSimulatorPlugin.OnCreateUI">`DeviceSimulatorPlugin.OnCreateUI` on docs.unity3d.com</a></footer>
  public virtual VisualElement OnCreateUI() => (VisualElement) null;
}

DeviceSimulator自体はとくに多くの操作や情報が取れるというわけではなく、タッチした際のイベントとなるtouchScreenInputを登録する程度です。

/// <summary>
///   <para>Class for interacting with a Device Simulator window from a script.</para>
/// </summary>
/// <footer><a href="https://docs.unity3d.com/2021.3/Documentation/ScriptReference/30_search.html?q=DeviceSimulator">`DeviceSimulator` on docs.unity3d.com</a></footer>
public class DeviceSimulator
{
  internal ApplicationSimulation applicationSimulation;

  internal DeviceSimulator()
  {
  }

  public event Action<TouchEvent> touchScreenInput;

  internal void OnTouchScreenInput(TouchEvent touchEvent)
  {
    Delegate[] invocationList = this.touchScreenInput?.GetInvocationList();
    if (invocationList == null)
      return;
    foreach (Action<TouchEvent> action in invocationList)
    {
      try
      {
        action(touchEvent);
      }
      catch (Exception ex)
      {
        Debug.LogException(ex);
      }
    }
  }
}

TouchEventには座標とその動作となる情報が含まれています。

/// <summary>
///   <para>Representation of a single touch event coming from a Device Simulator. Subscribe to DeviceSimulator.touchScreenInput to receive these events.</para>
/// </summary>
/// <footer><a href="https://docs.unity3d.com/2021.3/Documentation/ScriptReference/30_search.html?q=TouchEvent">`TouchEvent` on docs.unity3d.com</a></footer>
public struct TouchEvent
{
  internal TouchEvent(int touchId, Vector2 position, TouchPhase phase)
  {
    this.touchId = touchId;
    this.position = position;
    this.phase = phase;
  }

  /// <summary>
  ///   <para>The unique identifier for the touch. Unity reuses identifiers after the touch ends.</para>
  /// </summary>
  /// <footer><a href="https://docs.unity3d.com/2021.3/Documentation/ScriptReference/30_search.html?q=DeviceSimulation.TouchEvent-touchId">`TouchEvent.touchId` on docs.unity3d.com</a></footer>
  public int touchId { get; }

  /// <summary>
  ///   <para>On-screen position of the touch event. The zero point is at the bottom-left corner of the screen in pixel coordinates.</para>
  /// </summary>
  /// <footer><a href="https://docs.unity3d.com/2021.3/Documentation/ScriptReference/30_search.html?q=DeviceSimulation.TouchEvent-position">`TouchEvent.position` on docs.unity3d.com</a></footer>
  public Vector2 position { get; }

  /// <summary>
  ///   <para>Phase of the touch event.</para>
  /// </summary>
  /// <footer><a href="https://docs.unity3d.com/2021.3/Documentation/ScriptReference/30_search.html?q=DeviceSimulation.TouchEvent-phase">`TouchEvent.phase` on docs.unity3d.com</a></footer>
  public TouchPhase phase { get; }
}

たとえば最後にタッチした座標のCanvas座標とワールド座標を常時表示するようにしたり、タッチした部分にヒットしたオブジェクトを表示したりといった機能ならば、このDeviceSimulatorPluginsを使ってお手軽に追加することが可能そうです。

参考・引用

docs.unity3d.com