🎼

【Unity】ScenarioFlowによるシナリオ実装#1-2(ScenarioFlowの思想)

2023/04/10に公開

はじめに

こんにちは.伊都アキラです.
前回の記事では,ScenarioFlowを使ってどのようなことができるのかを紹介しました.
今回の記事では,このライブラリはどのような思想で,どのようなことを目指して作られたのかを書いていこうと思います.

実際にScenarioFlowを使っていくわけではありませんが,この記事を読み終えたときに,なんとなく「使いやすそう」と感じていただけることを目的としています.抽象的な話が多くなってしまうかもしれませんが,ScenarioFlowの良さを説明する上で非常に重要ですので,最後まで読んでいただけると嬉しいです.

ScenarioFlowが追求する理想

私がScenarioFlowを開発する上で重要視していたことは,大きく分けて以下の3つです.

  • 高い拡張性
  • 単純な文法
  • 自由な引数

これから,これらが何を意味するのか,ScenarioFlowがどうやってこれらを達成しようとしているのかを見ていきましょう.

高い拡張性

ゲームにシナリオを実装するためには,キャラクターのセリフを表示したり,キャラクターの立ち絵を変更したり,音楽をかけたり,背景を変更したり,といった様々な機能が必要です.

実際,Unityにおけるノベルゲーム制作アセットは,大抵コマンドとして前述のような機能を提供しています.それらのコマンドをテキストファイル等に決められた文法に則って記述し,順番に実行していくような処理だと思います.

そのようなアセットは簡単にシナリオを実装することができるので便利なのですが,反面,私は拡張性の悪さが気になっていました.

私が確認した限りでは,前述のようなアセットにおいての,デフォルトで用意されていない新たなコマンドを追加するための手順はどれも煩雑に感じました.

そこで,ScenarioFlowは機能の拡張を前提に設計されています.そのため,新たなコマンドを追加するための手順はとてもシンプルです.
具体的には,以下のように,メソッドにScenarioMethod属性をつけます.そうすると,そのメソッド自体がコマンドになり,以下のLogMessgaeは"message"というコマンド名で登録されることになります(実際にはこれだけでは不十分ですが,他の手順も非常に簡単です).そして,メソッドの引数はそのまま,コマンドの引数となります.

NewCommand.cs
    //メッセージをログに出力するコマンド
    [ScenarioMethod("message")]
    public void LogMessage(string message)
    {
        Debug.Log(message);
    }

また,ScenarioFlowが標準で提供するコマンドはありません.使いたいコマンドは,すべてScenarioFlowの外で実装する必要があります.これは,ScenarioFlowはあくまでシナリオ実装のためのアーキテクチャを提供するものであり,実際に必要となるコマンドの実装はScenarioFlowの外に委ねられるべきであると考えているからです.

ですから,コマンドの他に,ScenarioFlow自体はログ機能やセーブ機能など,システムで処理すべきそのような機能も提供していません.

ただし,シナリオ実装の際はすべての機能を一から実装しなければならないかというと,そうではありません.

ScenarioFlowはDependency Injection Pettern (DI Pettern)というデザインパターンを採用しており,システム自体が拡張しやすくなっています.そこで,必要な機能を実現するために適切な拡張を加えたモジュールを作成することによって,様々なシーンでそれを使いまわすことができます.

これからの解説記事ではサンプルゲームの作成を行っていく予定ですが,サンプルゲームをプレイしていただければわかる通り,シナリオ進行に最低限必要な機能は実装する予定です.そこで実装したものはモジュールとして公開する予定なので,例えばそれを使いまわせば良いわけです.コマンドについても同様です.

ちなみに,ScenarioFlowには標準のモジュールとして,非同期処理の実行をコントロールするためのTaskFlowと,Excelファイルでのシナリオ記述を可能にするためのExcelFlowが付属しています.

単純な文法

ScenarioFlowでは,コマンドを指定された順番に実行することでシナリオ進行が行われますが,その順番を保持しているC#のオブジェクトを,ScenarioFlowではScenarioBookと呼んでいます.そしてサンプルゲームでは,そのScenarioBookを標準の拡張モジュールであるTaskFlowが提供するScenarioBookReaderに渡すことで,適切にシナリオ進行が行われているのです.

つまり,ScenarioBookReaderにシナリオ進行を任せるためには,ScenarioBookを作成する必要があります.そこで,適切な文法で書かれたテキストベースのソースファイル(すなわちシナリオの原稿)を用意し,ScenarioBookに変換する必要があるのです.

ソースファイルの種類としては,SceanrioFlowはテキストファイル(.txt)を,標準の拡張モジュールであるExcelFlowExcelファイル(.xlsx)を提供しています.

ただし,文法はどちらも同じで,非常に単純なものとなっています.
例として,サンプルゲームで実際に使われているソースファイルの一部を見てみましょう.


赤ずきん編.xlsx

一番左の列がコマンドの名前,それより右の列はすべてパラメーターとなっています.
基本的には上から順番にコマンドが実行されますが,Labelタグとそれ用のコマンドを組み合わせることによって,任意のコマンドへ移動・分岐することもできます.

ここで意識していただきたいのが,すべてのコマンドは等価だということです.
シナリオ進行では主な演出がキャラクター同士の会話ですから,会話の表示(上の例だとquote.write)のためのコマンドを特別なものとして扱うこともあり得ますし,記述する文字数の削減が期待できるなど,そうすることによる利点もあると思います.しかし,この場合は文法の単純さを追求し,会話の表示も他のコマンドと同じように,あくまで数あるコマンドのうちの一つとして扱っています.

ScenarioFlowにおけるシナリオ実装の基本は,上から順にコマンド実行です.上から実行するべきコマンドを順に並べるだけで,かつ,どのコマンドも書き方が同じなので文法が単純になるわけです.

また,ScenarioFlowは拡張性を意識して設計されていますから,この文法規則が気に入らなければ新たな文法規則を作ることもできます.

自由な引数

前述の通り,ScenarioFlowではテキストベースのソースファイルをScenarioBookに変換することで,コマンドを順番に実行し,シナリオ進行を実現します.
Excelファイルのようなテキストベースのものをソースファイルとして採用するのは,シナリオの編集がしやすいからです.初期段階ではScriptable Objectをシナリオのソースとして提供することも検討していましたが,テキストベースの方が圧倒的に編集の効率が良かったので採用しませんでした.

ただし,テキストベースにも欠点はあります.
それは,C#に文字列しか渡せないことです.

当然ですが,テキストで「1」と書いても,C#にとってはただの文字列です.ですから,テキストの「1」を数値として扱いたければ,C#内で適切な関数を使って変換を行う必要があります.

数値くらいであれば,Excelファイルを使えばそのまま数値として読み込むことができるかもしれません.しかし,シナリオ進行のために扱いたい変数の型というのは他にもあります.絵を変更したければSprite型が必要ですし,音楽を流したければAudioClip型が必要になります.
結局,ほぼすべての型に対して,文字列を適切な型に変換するための処理をC#に記述しなければなりません.

ただし,問題の本質はそこではありません.テキストベースにする以上,文字列を変換する処理が必要なのは仕方のないことでしょう.

問題なのは,文字列の変換という責務をどこに割り当てるべきかです.
すなわち,文字列の変換処理はどこに記述すべきか,ということです.

コマンドの中で行うべきでしょうか.

BadCommand.cs
    //BGMを流すコマンド
    [ScenarioMethod("BGM再生")]
    public void PlayBgm(string bgmName)
    {
        //string -> AudioClip
        var audioClip = ConvertAudioClip(bgmName);
        //BGM再生
        audioSource.clip = audioClip;
        audioSource.Play();
    }

これは良くありません.AudioClipを使いたいコマンドの処理を記述している,あらゆるコードに文字列からAudioClipへの変換処理が散らばることになってしまいます.それに,メソッドの再利用性も低くなってしまいます.

コマンドの処理は,以下のように記述するべきです.

GoodCommand.cs
    //BGMを流すコマンド
    [ScenarioMethod("BGM再生")]
    public void PlayBGM(AudioClip audioClip)
    {
        //BGM再生
        audioSource.clip = audioClip;
        audioSource.Play();
    }

つまり,「BGMを流す」コマンドは,「文字列をAudioClipに変換する」責務を負わないべきです.
文字列を適切な型に変換する処理は,どこか1か所に,統一的に記述されていることが理想です.

実際に,ScenarioFlowでは後者のようにコマンドを記述することができます.型の変換はScenarioFlowが実行してくれるので,各コマンドは元々パラメータとして渡されていた文字列が何であったかを気にする必要がありません.

そして,これを実現しているのが,Decoderという概念です.
Decoderを実装することで,使用が許されているパラメータの型,およびその変換方法を管理することができます.

int型用のDecoderの実装例を以下に示します.

IntDecoder.cs
    [Decoder]
    public int IntDecoder(string source)
    {
        return int.TryParse(source, out int result) ?
            result :
            throw new ArgumentException($"Failed to decode {source}");
    }

string型を引数として受け取り,int型を返すような関数にDecoder属性をつけることで,Decoderを実装することができます.そして,このDecoderを登録することで,コマンドのパラメータとしてint型が使えるようになります.

ScenarioFlowは標準でDecoderを提供しませんが,非同期処理をコントロールするためのモジュールであるTaskFlowには,CancellationToken型向けのDecoderを実装するための機能が提供されています.それによって,コマンドをキャンセル可能にしたり,並列に実行したりと,シンプルな文法を維持しつつ,幅広い非同期コマンドの表現が可能になります.

おわりに

この記事では,ScenarioFlowがどのような経緯で,何を理想に掲げて開発されたのかを解説しました.実際には他にも語りたいことはたくさんあるのですが,同様の目的を持つ既存のライブラリと比べて特に優れていると考えている点に絞ってお話ししました.少しでも使いやすそうだと感じていただたら幸いです.

次回からは,ScenarioFlowの使い方を解説していきます.
最後までお読みいただき,ありがとうございました.

Discussion