Fungusの会話テストができるEditorWindow機能【Unity,Fungus,Editor拡張】

はじめに

私が作っているゲーム (DreamIsland) Unityでの会話機能を作る際に OSSで公開されている Fungusを使用しています。

しかし、どのゲームでもそうかもしれませんが各NPCとの会話はNPCの前まで移動して会話テストする必要があります。このテストがかなり時間がかかっているため、今回は 場所を移動せずとも会話テストができる Editor拡張機能の作成を行いました。

GitHub ActionsでのCI/CD環境で Fungusのテストについては、以下の記事で書いていますので自動テストは以下の記事をご覧ください。

ayousanz.hatenadiary.jp

成果物

  • Editor実行時にシーン上にあるNPC(FungusがアタッチされているNPC)一覧の取得とリスト表示(NPC Numberにソート表示)
  • 会話テストしたいNPCを選択時に選択したNPCのボタンの色を変更
  • EditorWindowからNPC情報の確認と Phaseの変更、会話テスト実行機能

(注) * 内部で GetComponent をしているため、Editor実行したときしか使用することができません。

環境とゲーム内の会話処理周りの情報

環境

  • Unity 2021.3.0f1
  • Fungus v3.13.7
  • UniTask

会話処理周り

Fungusの会話処理は NPCController 内に実装が書かれていて、テスト用の Interfaceをして IDebugNPC を作成しています。

その他のクラス図は以下のようになっています。

機能詳細と実装

今回実装した機能は大きく分けると以下になります。

  1. NPC一覧の取得とソートでの一覧表示
  2. 選択したNPC Buttonのハイライトと情報の表示
  3. fungusのphaseを指定した会話テストの実行

なお、IDebugNPC は以下のように定義しています。

using Cysharp.Threading.Tasks;

namespace _DreamIsland.Card
{
    public interface IDebugNpc
    {
        string GetNpcName();
        string GetNpcObjectName();
        UniTask Talk();
        void SetTalkPhase(int phase);
        int GetTalkPhase();
    }
}

1. NPC一覧の取得とソートでの一覧表示

まず NPCの一覧取得には、FindGameObjectWithTag を使っています。 その後、名前の中から数字情報をとってきてソートをかけています。

        private static int NPCNumber(string npcObjectName)
        {
            if (npcObjectName.Contains("NPC"))
            {
                return Convert.ToInt32(npcObjectName.Substring(2, npcObjectName.IndexOf("NPC", StringComparison.Ordinal) - 2));
            }

            return 100;
        }

        private static List<IDebugNpc> GetNpcList()
        {
            var npcList = GameObject.FindGameObjectsWithTag("NPC");
            var conversationNpcList = new List<IDebugNpc>();
            foreach (var npc in npcList)
            {
                if (!npc.TryGetComponent<IDebugNpc>(out var debugNpc)) continue;
                conversationNpcList.Add(debugNpc);
            }

            conversationNpcList.Sort((a, b) => NPCNumber(a.GetNpcObjectName()) - NPCNumber(b.GetNpcObjectName()));
            return conversationNpcList;
        }

2. 選択したNPC Buttonのハイライトと情報の表示

NPC一覧表示と選択時の色変更は、以下のようにしています。

ボタンの背景は、OnEnable 時に色の取得をして保存しています。

また 内部で GetComponent を使用しているため、エディタ実行中のみ (EditorApplication.isPlaying == true )処理されるようにしています。

                using (new EditorGUILayout.VerticalScope(GUILayout.Width(200f)))
                {
                    _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition);
                    {
                        foreach (var npc in _npcList.Where(_ => EditorApplication.isPlaying))
                        {
                            var npcObjectName = npc.GetNpcObjectName();
                            GUI.backgroundColor = _selectNpcGameObjectName == npcObjectName ? Color.green : _editorBackgroundColor;

                            if (GUILayout.Button($"{npcObjectName}"))
                            {
                                _selectedNpc = npc;
                                _selectNpcGameObjectName = npc.GetNpcObjectName();
                                _selectNpcName = npc.GetNpcName();
                            }
                        }
                    }
                    EditorGUILayout.EndScrollView();
                }

参考にさせていただいたサイトは以下です。

www.hanachiru-blog.com

zenn.dev

www.codetd.com

3. fungusのphaseを指定した会話テストの実行

  1. SetPhase(int phase) で任意の会話フェーズを指定する
  2. 会話テストを開始(内部で Fungus ≒ Flowchartの SendFungusMessage を呼んでいる)
  3. phaseを初期状態に戻す ( set phaseしたデータが残ってしまうため)

会話テスト開始ボタンを押した後のコードは以下のようになっています。

                        var prePhase = _selectedNpc.GetTalkPhase();
                        _selectedNpc.SetTalkPhase(_selectNpcTalkPhase);
                        await _selectedNpc.Talk();
                        _selectedNpc.SetTalkPhase(prePhase);

3D音源の距離減衰で各サウンド音量の取得【Unity】

成果物

  • サウンド(色がついているオブジェクト)ごとに距離に応じた再生音量を取得

Repository

github.com

Demo(Unity Room)

unityroom.com

実装

AudioSouceとListenerの挙動

Unityで3D音源を鳴らすときには、基本的に AudioSourceのAudioClipに任意のサウンドデータをアタッチしてサウンドを鳴らすようになっています。

AudioSourceを設定した後に AudioListenerがアタッチされているオブジェクトを移動させると以下のようにAudio SourceのInspctorで現在のListenerと3D Sound設定した関数の交わっている個所を見ることができます。

Audio Sourceの距離減衰のAnimationCurveの取得

設定したAnimationCurveを取得するには、AudioSource.GetCustomCurve から取得します。

docs.unity3d.com

しかし、LogarithmicやLinearの場合は、こちらの関数を使うことができません(仕様?)。そのため、今回は以下のようにして Linerと Customを判別して AnimationCurveを返す関数を作成しました。

        private AnimationCurve GetAnimationCurve()
        {
            return animationCurve = _audioSource.rolloffMode switch
            {
                AudioRolloffMode.Linear => AnimationCurve.Linear(_audioSource.minDistance, 1, 1, 0),
                AudioRolloffMode.Custom => _audioSource.GetCustomCurve(AudioSourceCurveType.CustomRolloff),
                _ => animationCurve
            };
        }

ここの注意なのですが、Customで返ってくるAnimatinCurveは0-1のサイズに取得称されるため、計算する際に0-1の範囲で計算する必要があります。

プレイヤーと発生しているサウンドの距離からなっている音量を取得する

先ほど取得した Audio SourceのAnimation Curveから任意の点の音量値を取得するには、AnimationCurve.Evaluate を使います。

docs.unity3d.com

この関数は、引数に time(今回の場合は、なっているAudioSource Object とプレイヤーの距離) が必要になるため Vector3.Distance(AudioSource.position,Player.position) で 距離を計算します。

その後、取得した Animation Curveが0-1に丸め込みされるため 距離 / MaxDistanceで 0-1の範囲に収まるように変換します。

        public float GetAudiValue(Vector3 playerPosition)
        {
            var distance = Vector3.Distance(transform.position, playerPosition);
            return animationCurve.Evaluate(distance / _maxDistance);
        }

このときの MaxDistance は 、Audio Sourceの Max Distanceを _audioSource.maxDistance から取得してきた値になります。

docs.unity3d.com

後は 適当にViewスクリプトを作って取得した各サウンドの音量値を表示させてあげれば、以下のようにできます。

Notionのタスクのステータス変更をDiscordでメッセージを送る【Discord.py,Notion API,GitHub Actions,cron-job】

はじめに

チーム開発を行っているときのタスク管理はいろいろ方法があるかと思います。
私が参加しているインディーズゲームのチームの一つでは、Notionでのタスク管理を行っています。 このときにタスクのステータス(対応中・確認中、担当者へのFB)などが変わった際に、チャットで送るのはわりと手間になります。
今回はこちらの作業を自動化してチームの生産性をあげていこうと考えました

完成物

Discord側の通知メッセージ

  1. タスクステータス変更時

  2. タスク期限が迫っている時

Repositoryは以下で 公開しています。

github.com

やりたいこと

  1. Notionの特定のタスク状態が変更されたときに担当者及び確認者 Discordに通知を送る

  2. 一日一回タスクの期限が3日以内のタスクをDiscordで担当者及び確認者に知らせる

  3. タスクの変更状態をなるべくリアルタイムで監視する

(4. お金をかけずに無料で行う(インディーズゲームのため) )

前提として、Notionのタスクボードは以下の感じにわけられています。

準備

まずは今回使っていくAPI等の作成や登録準備を行います。

Notionの インテグレーション(API)の作成と登録

以下のサイトで Notionのアプリを作成します。

www.notion.so

タスク監視用に作成した インテグレーション情報は以下です。内部で使うだけであれば、 インテグレーションの種類は、内部インテグレーション で大丈夫です。

このときシークレットトークンは後ほど使うので、控えておいてください。

次に Notionのタスクページの share (日本語の場合は 共有 ) に先ほど作成した インテグレーションを以下のように追加します。

DiscordのWebHookの作成

今回は Discordのメッセージを送るだけなので、Botではなく 準備・導入が簡単な WebHookを使っています。

サーバー設定 → 連携サービス → ウェブフック から以下のように作成します。

WebHookのURLは後ほど使うため、控えておいてください。

GitHub のpersonal tokenの作成

Setting → Developer settings → personal access tokesから以下のように workflow にチェックを入れて アクセストークンを作成します。

自動化内容と実装

1. Notionの特定のタスク状態が変更されたときに担当者及び確認者 Discordに通知を送る

2. 一日一回タスクの期限が3日以内のタスクをDiscordで担当者及び確認者に知らせる

基本的に以下の記事とRepositoryをそのまま使っています。 細かい実装等や Notion APIについては、以下の記事でも解説していますのでご覧ください

ayousanz.hatenadiary.jp

github.com

3. タスクの変更状態をなるべくリアルタイムで監視する

実行環境の構築周辺について

今回 上記の二つの処理を定期的に実行するために、お金をかけたくない・なるべつ楽をしたい(開発・運用)ということでいろいろ調べました。 方法としては、だいたい以下があるではないかと考えました。 1. IaaS(EC2やGCE) で環境構築をして、cronコマンドで定期時的に実行する。 2. FaaS(lambdaやCloud Functions) + 監視系(Cloud Watchや schedule、AWS Batch) 3. Google Script Appでの実行 4. GitHub Actionsでの定期実行(cron) 5. その他 cronサービス

結果から言うと今回は、4 + 5の組み合わせで構築を行いました。 インディーズゲームでは、運用メンバーが技術に詳しいわけではない可能性が高いことや将来的にほかのサービスに移行する際に version管理されていてDockerなどにも載せやすいように考えています。

構築図は以下のようになっています。

Notionのタスク変更履歴の取得

まずNotionのタスク状態を監視するには、変更履歴を取得する必要があります。

そこで、NotionAPIのSearchを使って変更された内容を取得します。

Queryの書き方は以下のようになります。→ GitHubのソースコード

        db = self.notion.search(
            **{
                "sort": {
                    "direction": "descending",
                    "timestamp": "last_edited_time"
                },
                "filter": {
                    "value": "page",
                    "property": "object"
                },
                "page_size": 100,
            }
        )

次にその中から 変更されたタスクを探すのですが、一つ問題があります。定期実行では厳密にはリアルタイムでの監視ではないため、処理中などにタスクが移動された場合は監視できなくなります。

そこで、Notion側に タスクの移動前に状態を持たせて変数の代わりにしました。Propertyの preStatus という名前で隠しパラメータを作成しています。

移動前のパラメータがあるので、こちらを使ってフィルターをしていきます。

    def is_task_status_doing_from_confirm(self, result) -> bool:
        status = result['properties']['ステータス']['select']['name']
        pre_status = result['properties']['preStatus']['select']['name']
        if status == '対応中' and pre_status == '確認依頼':
            self.update_task_preStatus(result['id'], status)
            return True
        else:
            return False

    def is_task_status_confirm_from_doing(self, result) -> bool:
        status = result['properties']['ステータス']['select']['name']
        if status == '確認依頼':
            self.update_task_preStatus(result['id'], status)
            return True
        else:
            return False

これで変更されたタスクのみが取得できます。

定期実行サービスから GitHub Actionsを実行する

GitHub Actionsを手動実行するだけであれば、on: workflow_dispatch: をymlファイルに入れれば Actions画面から実行することができます。 しかし、これをほかのサービスから呼び出したい場合は少し設定する必要があります。

まずは workflowのidを取得する必要があるので、

curl -H "Accept: application/vnd.github.v3+json" https://api.github.com/repos/org-name/repository-name/actions/workflows 

を叩いて情報を取得します。

以下のような情報が取得できるかと思います。

{
    "total_count": 3,
    "workflows": [
        {
            "id": 25677508,
            "node_id": "W_kwDOHTP_Hc4Bh87E",
            "name": "CodeQL",
            "path": ".github/workflows/codeql-analysis.yml",
            "state": "active",
            "created_at": "2022-05-08T13:11:13.000Z",
            "updated_at": "2022-05-08T13:11:18.000Z",
            "url": "https://api.github.com/repos/astilbestudio/Notion-Watch-Task-Notification-discord/actions/workflows/25677508",
            "html_url": "https://github.com/astilbestudio/Notion-Watch-Task-Notification-discord/blob/main/.github/workflows/codeql-analysis.yml",
            "badge_url": "https://github.com/astilbestudio/Notion-Watch-Task-Notification-discord/workflows/CodeQL/badge.svg"
        },
        {
            "id": 25677510,
            "node_id": "W_kwDOHTP_Hc4Bh87G",
            "name": "watch-deadline-task",
            "path": ".github/workflows/watch-deadline-task.yml",
            "state": "active",
            "created_at": "2022-05-08T13:11:13.000Z",
            "updated_at": "2022-05-08T13:11:21.000Z",
            "url": "https://api.github.com/repos/astilbestudio/Notion-Watch-Task-Notification-discord/actions/workflows/25677510",
            "html_url": "https://github.com/astilbestudio/Notion-Watch-Task-Notification-discord/blob/main/.github/workflows/watch-deadline-task.yml",
            "badge_url": "https://github.com/astilbestudio/Notion-Watch-Task-Notification-discord/workflows/watch-deadline-task/badge.svg"
        },
        {
            "id": 25782832,
            "node_id": "W_kwDOHTP_Hc4BiWow",
            "name": "watch-task-status",
            "path": ".github/workflows/watch-task-status.yml",
            "state": "active",
            "created_at": "2022-05-10T04:35:43.000Z",
            "updated_at": "2022-05-10T04:35:43.000Z",
            "url": "https://api.github.com/repos/astilbestudio/Notion-Watch-Task-Notification-discord/actions/workflows/25782832",
            "html_url": "https://github.com/astilbestudio/Notion-Watch-Task-Notification-discord/blob/main/.github/workflows/watch-task-status.yml",
            "badge_url": "https://github.com/astilbestudio/Notion-Watch-Task-Notification-discord/workflows/watch-task-status/badge.svg"
        }
    ]
}

これで 実行したい workflowのIDを取得できました。

次にIDを使って外部から Actionsを実行します。

curl -XPOST -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.v3+json" https://api.github.com/repos/org-name/repository-name/actions/workflows/workflow-id/dispatches -d '{"ref": branchName"}' 

後からこれを任意の定期実行サービスで実行すれば定期的にタスクのステータス変更を監視することができます!

参考にさせていただいたサイト

swfz.hatenablog.com

サイドビューゲームでのURPでのライト表現とマルチシーン・DIコンテナを使ったInputSytemの実装【Unity,URP】

はじめに

こちらを作った際のいろいろな技術検証メモです

成果物

youtu.be

UniryRoomにも公開しています。

unityroom.com

やったこと

  • URPを使った ライト表現
  • マルチシーン及びDIコンテナを使った際のInputSystemのプレイヤー操作
  • sprite shapeを使ったマップ作製

使用したアセット

マップ・キャラクターには以下のアセットを使わせていただいています。

内容

URPを使ったライト表現

こちらのライト表現の実装方法です。

以下のように Unity URP 2DのSpot Lightを使っています。

マルチシーン及びDIコンテナを使った際のInputSystemのプレイヤー操作

シーン構成は、以下のようになっています。

また以下のような流れでシーンをロードしています。 1. Playerシーンをロード 2. Townシーンをロード 3. ワープポイントに移動すると Caveシーンをロード 4. 2 ~3のループ

シーンのロードはマルチシーンを導入しているため、 LoadSceneMode.Additive で行っています。 また自然な位置での遷移移動を実現したかったので、各シーンのワープポイントは以下ようにほぼ同じ位置に設定しています

このときに各シーンので InputSytemのAction Mapa及びシーン遷移は以下のように実装しました。

        public static void SwitchStage(SideGameInputActions sideGameInputActions)
        {
            var currentStageCount = SceneManager.sceneCount;
            for (var sceneIndex = 0; sceneIndex < currentStageCount; sceneIndex++)
            {
                var currentStage = SceneManager.GetSceneAt(sceneIndex);
                switch (currentStage.name)
                {
                    case "Town":
                        SceneManager.LoadSceneAsync("Cave", LoadSceneMode.Additive);
                        SceneManager.UnloadSceneAsync("Town");
                        sideGameInputActions.Cave.Enable();
                        sideGameInputActions.Town.Disable();
                        break;
                    case "Cave":
                        SceneManager.LoadSceneAsync("Town", LoadSceneMode.Additive);
                        SceneManager.UnloadSceneAsync("Cave");
                        sideGameInputActions.Town.Enable();
                        sideGameInputActions.Cave.Disable();
                        break;
                }
            }
        }

またワープポイントが同じ位置にあり、加算シーンロードを行っています。そのため同一シーンの多重ロードが起きる問題が起きたため、ロード処理は UniRxの ThrottleFirst を使用して一定秒間シーン処理をされないように制限をかけています。

            _playerMove.Collider2D.OnTriggerEnter2DAsObservable()
                .Where(tag => tag.CompareTag("Respawn"))
                .ThrottleFirst(TimeSpan.FromSeconds(5f))
                .Subscribe(_ => { LoadScene.SwitchStage(_inputActions); }).AddTo(_disposables);

sprite shapeを使ったマップ作製

マップにおける変形した画像の実装には、Sprite Shapeを使っています。

当たり判定は、Colliderの追加と以下の設定でやってくれるみたいです。
しかも、コライダー追加後に Spriteを変形させても自動的に変更してくれる!

blog.unity.com

gamedev65535.com

VContainerでRootLifetimeScopeを使うときの設定【Unity,VContainer,DI】

はじめに

Unityでゲームを作る際に シーン間での値共有の方法がいくつかあると思います。
それらの方法を調査している中 (*1) で、VContainerの RootLifetimeScope を使う方法があります。
RootLifetimeScope を設定する際に少し手間取ったので、メモしておきます。

(*1) ほかの方法については以下の記事にいろいろ記載がありますので、こちらを参考にしました。

qiita.com

やりたいこと

  1. Loading シーンで Google SpreadSheetから取得したデータを 保存したい
  2. 1で取得したデータをゲーム内で使用したい
  3. (*1) 中にもあるようになるべく綺麗な実装にしたい

成果物

Loadingシーンでデバッグ表示されている リスト内データ数と 、Mainシーンでデバッグ表示されているリスト内データ数が同じことが以下の動画からわかります。
(わかりにくい場合は、cloneしてお試しください)

RootLifetimeScopeの作り方及び使い方

こちらは公式サイトほかのサイトがあるので、そちらを読めばわかる方もいるかと思います。
(私はわからずつよつよエンジニアに聞きました)

RootLifetimeScopeを作成

まずは ゲーム全体の Scopeで使える RootLifetimeScope を作ります。

今回は以下のような RootLifetimeScope クラスを作成しました。

using _Project;
using VContainer;
using VContainer.Unity;

public class RootLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        builder.Register<CharacterDataModel>(Lifetime.Singleton);
        builder.Register<InputSystemTest>(Lifetime.Singleton);
    }
}

次に 上記のクラスを GameObjectにアタッチをして、 Prefab化します。

最後に ProjectSettings > Player > Optimization > Preloaded Assetsに VContainerSettings がセットされていることを確認してください。
特にないもしなければ、生成時に入るかと思います。

これで RootLifetimeScope の作成は終了です。

各クラスから RootLifetimeScopeに注入されているクラスを使う

(Unity内では Loadingシーンと Mainシーンのどちらでも Rootにあるものを使っているのですが、説明上 Mainのみとさせて頂きます。
詳細はRepositoryからcloneしてご確認ください。 )

まずは シーン用の LifetimeScope を作成します。

using VContainer;
using VContainer.Unity;

namespace _Project.Main
{
    public class MainLifetimeScope : LifetimeScope
    {
        protected override void Configure(IContainerBuilder builder)
        {
            builder.RegisterEntryPoint<MainManager>();
        }
    }
}

これを 適当なオブジェクトにアタッチします。
このとき Parentは None のままで大丈夫です

以上で RootLifetimeScope が使えるようになっているかと思います。

Unityでゲーム起動時に スプレッドシートから データを取得する方法

こちらの機能のみを試したコードは以下のRepositoryにありますので、詳細は以下をご確認ください。

github.com

Notionのタスク 一覧でタスクがDoneになったときに完了日付を自動入力する【Notion,Python,GitHub Actions】

はじめに

最近 Notionを使ってタスク管理を再度始めました(昔やっていたのですが、飽きてしまっていて)
しかし タスクがいつ完了したものなのか後から確認するためには、日付を入力してカレンダーから見る必要があります。
この日付入力が少しめんどくさいので、自動化しました。

成果物

コードや 実際に動いている スクリプト :

github.com

実装簡易説明

細かい部分の詳細はRepositoryもしくは以下の参考にしたサイトを確認してください。   実装時に時間がかかった部分のみ記載しておきます。

Notion DB 取得時に query Filter

Notion のDBからデータを取得する際にすべて取得してもいいのですが、重たくなる・一括で取得できる数に制限という問題のため Queryでフィルターをかけてなるべく必要なデータだけ取得していきます。

公式 API DOcumentに フィルターの書き方があるので、こちらを参考にしています。

developers.notion.com

フィルターを書く際に 期限は select 型にしているため、一致しているかどうか( equals )で判別することができます。 しかし、日付( 'date' )は nullなどがないため is_empty で判定をしています。

                "filter": {
                    "and": [
                        {
                            "property": "期限",
                            "select": {
                                "equals": 'Done'
                            }
                        },
                        {
                            "property": "日付",
                            "date": {
                                "is_empty": True
                            }
                        }

                    ]
                }

フィルターはほかにも書き方があるため、必要に応じて変更していくといいかと思います。

developers.notion.com

GitHub Actionsから環境変数を読み込む

GitHub Actionsをpublic Repositoryで呼び出しているため、スクリプトにハードコーディングすることができません。 そのため、環境変数を使っていきたいと思います。

yml ファイルに以下のように設定すると スクリプト側から 呼ぶことができます。

      - name: Run script
        env:
          NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
          PRIVATE_DB: ${{ secrets.PRIVATE_DB }}
          WORK_DB: ${{ secrets.WORK_DB }}

この際に GitHub 側に sercret keyで事前に登録しておく必要があります。

参考にさせていただいたサイト qiita.com

Notionのpage Property(date) の更新

地味に詰まったところで、data を更新するときには object 型を入れないといけないみたいです。(公式ドキュメントより)

今回は starttime_zone をオブジェクト側で指定していきます。 data 型のオブジェクト内容は PostManなどで以下のように確認できます。

                    "date": {
                        "start": "2022-05-02",
                        "end": null,
                        "time_zone": null
                    }

その他参考サイト

developers.notion.com

Fungusを使ったゲーム制作におけるテスト作成と自動テスト環境の作成及び構築【Unity,Fungus】

はじめに

2021年5月から複数人で3D オープンワールドゲーム( DreamIsland ) [PVは以下動画]を制作しています。 ゲーム内では、ゲームでよくなる NPCとの会話やセーブ・ロード機能、メニュなどいろいろな機能があります。
制作をしていく中で、これらを実装していくといくつかバグが出てきますが毎回どこが原因がデバッグをするのはとても手間だと感じていました。 特に NPCとの会話は 数十体いるNPCと会話するのは、数時間に及ぶものになっています。
そこで本チームではNPCとの会話におけるデバッグの一部のテスト環境の構築と自動化できる環境を作成を行いました。

www.youtube.com

制作環境

(内容に関わるもののみ記載)

  • Unity 2021.3.0f1

  • Fungus v3.1.9

  • UniTask
  • UniRx

対象者

  • UnityでFungusを使った大きめの(*1) ゲーム制作をしている方
  • テストコードやC#をある程度理解している方

(*1) : 人によって感覚が違いますが、ここではテストコードを書いたほうがプロジェクトの生産性が上がる規模としています。

本プロジェクトにおいての会話実装及び仕様説明

本プロジェクトでは、NPCとの会話に 会話アセットして有名な Fungus を使用して NPCとの会話機能を実装しています。 Fungusは、以下の感じで Insptorから会話内容を確認できたり、ノードベースで会話内容の分岐やループ、終了を作成することができます。

f:id:ayousanz:20220416112737p:plain

会話パートの説明

NPCとの会話では以下の順序で処理を行っています。

  1. プレイヤーが NPCに近づき 特定のキーを押して話しかけるトリガーをONにする。
  2. 目の前にいる NPCから 会話用 Interfaceを GetComponentして、会話処理を開始する(NPC側の会話処理を始める)
  3. NPC側にある会話処理が終了するまで、ほかの処理を待機する。
  4. 会話処理が終了するとプレイヤー側に処理を戻して、ほかの処理を開始する。

制作時に起きた問題点

Fungusを使用して会話を実装する場合、以下のような運用ルールが必ず必要になると思います。

1. flowchartのMessageとスクリプト側のFlowchart用 Message Stringが一致しない問題

Fungus側のMessgeを呼ぶときには、FlowChart.SendFungusMessage(message); を呼び出す必要があります。
このとき message が 呼び出すオブジェクトにアタッチされている Flowchart内の Messageと一致していないと呼び出すことができません

f:id:ayousanz:20220416114116p:plain

こちらのstring値は、作業者が設定するためどうしてもミスが起きてしまうことがあります。

2. Fungus内の変数とスクリプトから呼び際に変数名の一致

Fungusにおいて、会話内の分岐や変数の扱いは variables という flowchart内変数を用いて実装します。

f:id:ayousanz:20220416123928p:plain

この変数をスクリプト側から参照したり変更したりするときは、スクリプト側から変数名を string値で指定する必要性があります。

(以下例 : 常に変数を監視して、会話処理を行っている)

        private void ObservationTalk()
        {
            FlowChart.ObserveEveryValueChanged(value => value.GetBooleanVariable(TalkEventValueName))
                .Skip(1)
                .Where(_ => IsMissionEvent())
                .Subscribe(isTalk => { IsTalk = isTalk; }).AddTo(this);
        }

このときにスクリプト側の変数名と flowchart内の変数名が違っている場合は、処理ができずバグの原因になってしまいます。

こちらも作業者がスクリプト側及びflowchart内のどちらとも設定するため、ミスが起きることが考えられます。

Flowchartでのテストについて

上記の問題点について以下のテストコードで自動化を行いました。
また PRを出した際に GitHub Actionsを用いて CI で自動テストをすることにより、プロジェクトの品質を保つことができています。

(前提として、本プロジェクトでは NPC周りは NPCのBaseクラスを各NPCで使用していたり継承したりしています。以下NPC周りのクラス図)

f:id:ayousanz:20220416125114p:plain

またテストを実行する際に 会話処理がシーンごとにテストを行っています。    テストを実行する際には、以下のコードのようにテスト実行後シーン遷移が終わったのちにヒエラルキー上に存在するNPCを取得して、各テストの引数に渡しています。

        [UnityTest]
        [Category("FungusValuesTest")]
        public IEnumerator NPCFungusValuesInGame()
        {
            SceneManager.LoadSceneAsync("_DreamIsland/_Scenes/NPCs").completed += _ => { NPCFlowchartValueTest(); };
            yield return null;
        }

flowchart内の必須変数の存在テスト

スクリプト側で設定したflowchart内の変数が存在しているかどうかについては、Fungus/Flowchart にある HasVariable を使用しています。

        private static void NPCControllerFlowchartValueTest(IEnumerable<GameObject> npcList)
        {
            foreach (var npc in npcList)
            {
                if (!npc.TryGetComponent<NPCController>(out _)) continue;
                if (!npc.TryGetComponent<Flowchart>(out var flowchart)) continue;
                var isTalkValue = flowchart.HasVariable("isTalk");
                var isPhaseValue = flowchart.HasVariable("phase");
                Assert.True(isTalkValue);
                Assert.True(isPhaseValue);
            }
        }

スクリプトのmessageとflowchart側のmessageの文字列の一致テスト

スクリプト側から FlowchartのMessageBlockを呼び出す際の文字一致に関しては、Fungus/MessageReceivedGetSummary を使用しています。

using System.Linq;
using Fungus;
using UnityEngine;

#if UNITY_EDITOR
using UnityEditor;

namespace _DreamIsland.Editor
{
    public class SetFlowchartMessage : EditorWindow
    {
        [MenuItem("DreamIsland/SetFlowchartMessage")]
        private static void SetMessage()
        {
            var allObject = Resources.FindObjectsOfTypeAll(typeof(GameObject))
                .Select(c => c as GameObject)
                .Where(c => GetFullPath(c).Contains("NPCs"));
            foreach (var obj in allObject)
            {
                if (!obj.TryGetComponent<Flowchart>(out _)) continue;
                var message = obj.GetComponent<MessageReceived>();
                if (!message.GetSummary().Equals(obj.name))
                {
                    Debug.Log($"{obj.name}のflowchartのmessageを{message.GetSummary()}から{obj.name}に変更します");
                    message.SetMessage(obj.name);
                }
            }
        }

        private static string GetFullPath(GameObject obj)
        {
            return GetFullPath(obj.transform);
        }

        private static string GetFullPath(Transform t)
        {
            var path = t.name;
            var parent = t.parent;
            while (parent)
            {
                path = $"{parent.name}/{path}";
                parent = parent.parent;
            }

            return path;
        }
    }
}
#endif

また、このテストで失敗した際に 以下のような Editor拡張を使用して一括で プロジェクト内にある NPC Prefabを一括で正しいもの変更するようにしています。

f:id:ayousanz:20220416133202p:plain

        private static void IsFlowchartMessageMatchNPCNameTest(IEnumerable<GameObject> npcList)
        {
            foreach (var npc in npcList)
            {
                if (!npc.TryGetComponent<Flowchart>(out _)) continue;
                var messageReceived = npc.GetComponent<MessageReceived>();
                Assert.AreEqual(npc.name, messageReceived.GetSummary());
            }
        }

GitHub Actionsを用いた CI/CD環境構築方法

ayousanz.hatenadiary.jp

GitHub ActionsのTest Reuslt画面では以下のように確認できます。

f:id:ayousanz:20220416132043p:plain

まとめ

テスト環境を作成したこととテストを自動化することによって、リリース前にバグを心配する部分をだいぶ減らすことができました。
また日々の開発でも会話周りの原因をテストができる環境により、原因の解明を迅速の行うことができています。

みなさん テストを頑張って書きましょう! (*注)

(*注) : 個人開発の場合、テストを書くコストに見合わない場合も結構あります。。。

DreamIslandが近々 SteamにReleaseされるため、宣伝及びプレイをお願い致します!

f:id:ayousanz:20220416133559p:plain