【ScenarioFlow】ScenarioFlowの基本

ScenarioFlow
使用しているツールのバージョン
  • Unity: 2022.3.35f1
  • UniTask: 2.5.4
  • ScenarioFlow: 1.1.0

今回の記事では、会話シーン作成ライブラリ「ScenarioFlow」についての基本を取り扱います。ScenarioFlowを使ってUnityで会話シーンを作成するための第一歩として、簡単な例とともに、このライブラリの構成と主要な機能、それらの使い方を見ていきます。

ScenarioFlowで”Hello, world!”

まずは、ScenarioFlowの主要な機能の一つである「コマンド」を使用して、デバッグコンソールに”Hello, world!”のメッセージを表示させることから始めましょう。以下の手順に従って、サンプルコードを実行してください。各機能の詳細については、次のセクションで学習します。

ライブラリのセットアップ

Unityで適当なプロジェクトを立ち上げ、ScenarioFlowを使用するために必要なライブラリをインポートします。UniTaskをインポートした後、続けてScenarioFlowをインポートしてください。

UniTask:

GitHub - Cysharp/UniTask: Provides an efficient allocation free async/await integration for Unity.
Provides an efficient allocation free async/await integration for Unity. - Cysharp/UniTask

ScenarioFlow:

ScenarioFlow | 機能統合 | Unity Asset Store
Use the ScenarioFlow from .PROLOGUE on your next project. Find this integration tool & more on the Unity Asset Store.

スクリプトの作成

コマンドの作成

指定したメッセージをデバッグコンソールに表示する「コマンド」を作成します。ただし、メッセージ表示前に、2秒の遅延が挟まれ、その間に実行がキャンセルされると、指定されたメッセージの代わりにキャンセルを通知するメッセージが表示されます。

MessageLogger.cs
using Cysharp.Threading.Tasks;
using ScenarioFlow;
using System;
using System.Threading;
using UnityEngine;

public class MessageLogger : IReflectable
{
    [CommandMethod("log message async")]
    public async UniTask LogMessageAsync(string message, CancellationToken cancellationToken)
    {
        try
        {
            await UniTask.Delay(TimeSpan.FromSeconds(2.0f), cancellationToken: cancellationToken);

            Debug.Log(message);
        }
        catch (OperationCanceledException)
        {
            Debug.Log("Message canceled!");
            throw;
        }
    }
}
C#

デコーダの作成

コマンド呼び出しのために使用される、「デコーダ」を作成します。

PrimitiveDecoder.cs
using ScenarioFlow;

public class PrimitiveDecoder : IReflectable
{
    [DecoderMethod]
    public string ConvertToString(string input)
    {
        return input;
    }
}
C#
CancellationTokenDecoder.cs
using ScenarioFlow;
using ScenarioFlow.Tasks;
using System;
using System.Threading;

public class CancellationTokenDecoder : IReflectable
{
    private readonly ICancellationTokenDecoder cancellationTokenDecoder;

    public CancellationTokenDecoder(ICancellationTokenDecoder cancellationTokenDecoder)
    {
        this.cancellationTokenDecoder = cancellationTokenDecoder ?? throw new ArgumentNullException(nameof(cancellationTokenDecoder));
    }

    [DecoderMethod]
    public CancellationToken ConvertToCancellationToken(string input)
    {
        return cancellationTokenDecoder.Decode(input);
    }
}
C#

コマンド制御クラスの実装

会話シーンの進行を制御するためのクラスを作成します。ここでは、スペースキーとエスケープキーで「呼び出し制御」と「キャンセル制御」をそれぞれ行うクラスを作成しています。

ちなみに、実行時のわかりやすさのため、各キーを押したとき、そのことがコンソールに表示されるようにしています。

SpacekeyNextNotifier.cs
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using ScenarioFlow.Tasks;
using System.Threading;
using UnityEngine;

public class SpacekeyNextNotifier : INextNotifier
{
    public UniTask NotifyNextAsync(CancellationToken cancellationToken)
    {
        return UniTaskAsyncEnumerable.EveryUpdate()
            .Select(_ => Input.GetKeyDown(KeyCode.Space))
            .Where(x => x)
            .Do(_ => Debug.Log("Spacekey pushed!"))
            .FirstOrDefaultAsync(cancellationToken: cancellationToken);
    }
}
C#
EscapekeyCancellationNotifier.cs
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using ScenarioFlow.Tasks;
using System.Threading;
using UnityEngine;

public class EscapekeyCancellationNotifier : ICancellationNotifier
{
    public UniTask NotifyCancellationAsync(CancellationToken cancellationToken)
    {
        return UniTaskAsyncEnumerable.EveryUpdate()
            .Select(_ => Input.GetKeyDown(KeyCode.Escape))
            .Where(x => x)
            .Do(_ => Debug.Log("Escapekey pushed!"))
            .FirstOrDefaultAsync(cancellationToken: cancellationToken);
    }
}
C#

会話システムの構成

これまでに作成したクラスを基に、会話システムを構成するマネージャクラスを作成します。

ScenarioManager.cs
using Cysharp.Threading.Tasks;
using ScenarioFlow;
using ScenarioFlow.Scripts;
using ScenarioFlow.Tasks;
using UnityEngine;

public class ScenarioManager : MonoBehaviour
{
    //実行するシナリオスクリプト
    [SerializeField]
    private ScenarioScript scenarioScript;
    
    private async UniTaskVoid Start()
    {
        // シナリオブックを実行するシステムを構成
			  ScenarioTaskExecutor scenarioTaskExecutor = new(
				  new SpacekeyNextNotifier(),
				  new EscapekeyCancellationNotifier());
        ScenarioBookReader scenarioBookReader = new(scenarioTaskExecutor);
        // シナリオブックをシナリオスクリプトから生成する変換器を構成
        ScenarioBookPublisher scenarioBookPublisher = new(
            new IReflectable[]
            {
                // デコーダ
                new CancellationTokenDecoder(scenarioTaskExecutor),
                new PrimitiveDecoder(),
                // コマンド
                new MessageLogger(),
            });
        // シナリオスクリプトをシナリオブックに変換
        ScenarioBook scenarioBook = scenarioBookPublisher.Publish(scenarioScript);
        try
        {
            // シナリオブックを実行
            Debug.Log("Story started.");
            await scenarioBookReader.ReadAsync(scenarioBook, this.GetCancellationTokenOnDestroy());
            Debug.Log("Story finished.");
        }
        finally
        {
            // ScenarioTaskExecutorクラスはIDisposableインターフェースを実装する
            scenarioTaskExecutor.Dispose();
        }
    }
}
C#

シナリオスクリプトの作成

実行する会話シーンの内容を記述する、「シナリオスクリプト」を作成します。Projectウィンドウ上で右クリックし、Create/ScenarioFlow/SFText Script より、「SFTextスクリプト」を作成します。名前は「hello」にします。

プロジェクトウィンドウで右クリック
作成されたSFTextスクリプト

テキストエディタで、以下のスクリプトを記述します。

hello.sftxt
$standard | log message async      | 
          | {Hello, world!}        | 
$standard | log message async      | 
          | {Hello, Unity!}        | 
$standard | log message async      | 
          | {Hello, ScenarioFlow!} | 
SFText

このSFTextスクリプトの編集時は、Visual Studio Code (VSCode)とその拡張機能による入力支援機能を利用することができます。その機能については別の記事で解説するので、ここでは好きなテキストエディタで編集してかまいません。

Unity側のセットアップ

シーン上に、オブジェクト「SampleManager」を作成し、SampleManager.cs をアタッチします。そして、SampleManager コンポーネントのScenarioScript プロパティにhello.sftxt をアタッチします。

CreateEmptyにより、SampleManagerオブジェクトを作成
ScenarioManagerコンポーネントのScenarioScriptプロパティに、hello.sftxtを割り当て

会話シーンの実行

プレイボタンを押し、コードを実行します。すると、hello.sftxt に記述された会話シーンが再生されます(今は、指定したメッセージが順番に表示されるだけですが)。

一つのメッセージが表示された後にスペースキーを押すと、その次のメッセージが表示されます。

一つのメッセージが表示された後、スペースキーを押すと次のメッセージが表示される

メッセージは、キャンセルすることもできます。メッセージ表示前の遅延時間中にエスケープキーを押すと、メッセージの表示がキャンセルされ、キャンセル時のメッセージが表示されます。以下の例では、二番目のメッセージのみをキャンセルしています。

二番目のメッセージのみキャンセル

会話シーンが再生される仕組み

ScenarioFlowで、会話シーンが再生されるメカニズムについて見ていきます。ScenarioFlowで会話シーンを再生するには、以下の手順を踏みます。

  1. 会話システムの構成
  2. シナリオスクリプトの作成
  3. シナリオスクリプトの変換と実行

会話システムの構成

C#スクリプトで構成される、会話シーンのデータを読み込み、会話シーンの進行を制御し、適切にセリフの表示やキャラクターの表示などの演出を実行するシステムを、会話システムと呼びます。

会話システムは2つの主要なクラス、ScenarioBookPublisher と、ScenarioBookReader から構成されます。

ScenarioBookPublisherクラス:シナリオスクリプトの変換

ScenarioFlowにおいて、実行される会話シーンの内容はスクリプトにシナリオのデータとして記述され、このスクリプトを「ScenarioFlowスクリプト」、「シナリオスクリプト」、あるいは単に「スクリプト」と呼びます。シナリオスクリプトは基本的にシナリオ記述に特化したC#以外のファイル形式で記述されるため、シナリオスクリプトを基にそれに対応する会話シーンを実行するには、シナリオスクリプトをC#で読み込み、それをC#で実行可能な形式に変換する必要があります。

この、シナリオスクリプトの変換処理を行うのがScenarioBookPublisher クラスです。

C#
// シナリオブックをシナリオスクリプトから生成する変換器を構成
ScenarioBookPublisher scenarioBookPublisher = new(
    new IReflectable[]
    {
        // デコーダ
        new CancellationTokenDecoder(scenarioTaskExecutor),
        new PrimitiveDecoder(),
        // コマンド
        new MessageLogger(),
    });
// シナリオスクリプトをシナリオブックに変換
ScenarioBook scenarioBook = scenarioBookPublisher.Publish(scenarioScript);
C#

ScenarioBookPublisher クラスは、シナリオスクリプトを変換するのに必要な「コマンド」と「デコーダ」を含んだクラスのインスタンスをIReflectable インターフェースの配列(IEnumerable<IReflectable> )として受け取り、インスタンス化されます。このクラスはメンバメソッドとしてScenarioBook Publish(IScenarioScript) を持ち、このメソッドはIScenarioScript インターフェースの実装を受け取り、ScenarioBook クラスのオブジェクトを返します。

IScenarioScript インターフェースは、シナリオスクリプトとしてC#に認識されるのに必要なデータ構造を定義したインターフェースです。このインターフェースを適切に実装するオブジェクトはすべて、ScenarioFlowではシナリオスクリプトとして扱うことができます。

しかし、Unityエディタでの取り扱いやすさを考慮して、基本的にシナリオスクリプトは、SerializeField 属性を付与可能なScenarioScript 抽象クラスとして扱います。ScenarioScript クラスは、ScriptableObject クラスを継承し、IScenarioScript インターフェースを実装する抽象クラスです。

C#
//実行するシナリオスクリプト
[SerializeField]
private ScenarioScript scenarioScript;
C#

ScenarioScript 抽象クラスはIScenarioScript インターフェースを実装するので、やはり、ScenarioScript 抽象クラスを継承するクラスのオブジェクトはすべて、シナリオスクリプトとして扱うことができます。”Hello, world!”の例で使用したSFTextスクリプトは、UnityにはSFText クラスのオブジェクトとしてインポートされ、このクラスはScenarioScript 抽象クラスを継承するので、ScenarioScript 型のプロパティに対して割り当てができます。

ScenarioScriptクラスのプロパティにSFText型のオブジェクトを割り当てる。ScenarioScriptクラスはSerializedFieldを付与可能で、SFTextクラスはScenarioScriptクラスを継承している

一方でScenarioBook クラスは、単に会話シーンのデータを生で保持するScenarioScript クラスとは異なり、C#で実行可能な形式で会話シーンのデータを保持するクラスです。基本的にはScenarioBook クラスを手動でインスタンス化することはなく、ScenarioScript クラスのオブジェクトを、ScenarioBookPublisher クラスで変換することによってそのインスタンスを取得します。

まとめると、ScenarioBookPublisher は「コマンド」と「デコーダ」を定義する複数のクラスとともにインスタンス化され、シナリオのデータを単にテキストとして保持するシナリオスクリプトを、C#で実行可能な形式でデータを保持するシナリオブックに変換します。

ScenarioBookReaderクラス:変換されたシナリオスクリプトの実行

ScenarioBookReader クラスは、シナリオスクリプトを変換して得られたシナリオブックを実行し、シナリオスクリプトに記述された会話シーンを再生します。

C#
using Cysharp.Threading.Tasks;
using ScenarioFlow;
using ScenarioFlow.Scripts;
using ScenarioFlow.Tasks;
using UnityEngine;

public class ScenarioManager : MonoBehaviour
{
    //実行するシナリオスクリプト
    [SerializeField]
    private ScenarioScript scenarioScript;
    
    private async UniTaskVoid Start()
    {
        // シナリオブックを実行するシステムを構成
			  ScenarioTaskExecutor scenarioTaskExecutor = new(
				  new SpacekeyNextNotifier(),
				  new EscapekeyCancellationNotifier());
        ScenarioBookReader scenarioBookReader = new(scenarioTaskExecutor);
        
        // (シナリオスクリプトを変換)
        
        try
        {
            // シナリオブックを実行
            Debug.Log("Story started.");
            await scenarioBookReader.ReadAsync(scenarioBook, this.GetCancellationTokenOnDestroy());
            Debug.Log("Story finished.");
        }
        finally
        {
            // ScenarioTaskExecutorクラスはIDisposableインターフェースを実装する
            scenarioTaskExecutor.Dispose();
        }
    }
}
C#

ScenarioBookReader クラスは、ScenarioTaskExecutor クラスと協調して、会話シーンを実行します。ScenarioBook オブジェクトには実行すべき処理が配列として格納されており、前者は処理の実行順を管理し、後者は処理一つ一つの実行を制御します。

ScenarioTaskExecutor クラスは、コンストラクタでINextNotifier インターフェースとICancellationNotifier インターフェースの実装を受け取ります。前者は、一つの処理を実行した後に、次の処理に移行するためのトリガーを規定し、後者は、一つの処理を実行中に、その処理をキャンセルするトリガーを規定します。なお、このクラスはIDispoable インターフェースを実装するので、プログラム終了後は忘れずにDisposeするようにします。

“Hello, world!”の例でそれぞれのインターフェースの実装として指定しているのは、スペースキーで処理を次に進めるSpacekeyNextNotifier クラスと、エスケープキーで実行中の処理をキャンセルさせるEscapekeyCancellationNotifier クラスです。

まず、SpacekeyNotifier クラスにより、一つのメッセージが表示された後、スペースキーを押すまで次のメッセージが表示されないようになります。

一つのメッセージ表示後、スペースキーが押されるまで次に進まない

次に、EscapekeyCancellationNotifier クラスにより、メッセージの表示処理を実行中(つまり、遅延時間中)にエスケープキーを押すと、処理がキャンセルされるようになります。

2番目のメッセージのみキャンセル

まとめると、ScenarioBookReader クラスはScenarioTaskExecutor クラス、そしてINextNotifier インターフェースとICancellationNotifier インターフェースの各実装から構成され、シナリオスクリプトから変換されたシナリオブックを実行し、会話シーンを開始します。ScenarioBookReader クラスは処理を実行する順番を制御し、ScenarioTaskExecutor クラスは実行する処理一つ一つの制御を行います。また、INextNotifier インターフェースは1つの処理の完了後に次の処理へ移行するためのトリガーを、ICancellationNotifier インターフェースは実行中の処理をキャンセルするためのトリガーを規定します。

シナリオスクリプトの記述

会話システムが構成できたら、実際の会話シーンの内容を記述した、シナリオスクリプトを作成します。シナリオスクリプトは、前述の通りUnityにScenarioScript のオブジェクトとしてインポートされるものであればなんでも良いですが、ScenarioFlowがデフォルトで用意していて、主に使用される形式はSFTextスクリプトです。

シナリオスクリプトには、会話システムに用意された「コマンド」を、適切な順番で、適切なパラメータとともに記述します。コマンドとは、シナリオスクリプトから呼び出すことができる、C#でいうところのメソッドのようなものです。あるコマンドを呼び出すと、パラメータに基づき、そのコマンド特有の処理がなされます。

“Hello, world!”の例では、log message async という名前のコマンドを、それぞれ異なるパラメータ(表示されるメッセージの内容)を与えて三度呼び出しています。ちなみに、スクリプトはSFTextです。

hello.sftxt
$standard | log message async      | 
          | {Hello, world!}        | 
$standard | log message async      | 
          | {Hello, Unity!}        | 
$standard | log message async      | 
          | {Hello, ScenarioFlow!} | 
SFText

このlog message async コマンドは、C#側で、MessageLogger クラスのLogMessageAsync メソッドとして定義されています。

MessageLogger.cs
using Cysharp.Threading.Tasks;
using ScenarioFlow;
using System;
using System.Threading;
using UnityEngine;

public class MessageLogger : IReflectable
{
    [CommandMethod("log message async")]
    public async UniTask LogMessageAsync(string message, CancellationToken cancellationToken)
    {
        try
        {
            await UniTask.Delay(TimeSpan.FromSeconds(2.0f), cancellationToken: cancellationToken);

            Debug.Log(message);
        }
        catch (OperationCanceledException)
        {
            Debug.Log("Message canceled!");
            throw;
        }
    }
}
C#

SFTextにおける”Hello, world!”, “Hello, Unity!”, “Hello, ScenarioFlow!”は、いずれもLogMessageAsync メソッドのmessage パラメータに、$standardcancellationToken パラメータに対応しています。

C#側で定義されるコマンドについては後のセクションで詳しく学習しますが、ここで重要なのは、「ScenarioFlowでは、適切なコマンドが適切な順に呼び出されることで、会話シーンが再生されているように見える」ということです。

“Hello, world!”の例ではlog message asyncという、単にメッセージを表示されるだけのコマンドしか定義していませんが、例えば、TextMeshProUGUI コンポーネントを操作するコマンドを定義すればセリフの表示が可能ですし、SpriteRenderer コンポーネントを操作するコマンドを定義すれば、キャラクターを画面に表示することが可能です。このように、会話シーンに必要な処理を記述したコマンドのセットを会話システム側で定義し、それらのコマンドを適切な順で、適切なパラメータとともにシナリオスクリプトに記述することによって、会話システムで実行したとき、狙った通りの会話シーンが再生されるというわけです。

シナリオスクリプトの変換と実行

会話システムと、実行したい会話シーンを記述したシナリオスクリプトの準備ができたら、シナリオスクリプトを会話システムで実行し、会話シーンを再生します。

とはいえ、そのためにやらねばならないことは、適切なタイミングでシナリオスクリプトをScenarioBookPublisher クラスのPublish メソッドでシナリオブックに変換し、ScenarioBookReader クラスのReadAsync メソッドでそれを実行するということだけです。

C#
// シナリオスクリプトをシナリオブックに変換
ScenarioBook scenarioBook = scenarioBookPublisher.Publish(scenarioScript);
// シナリオブックを実行
Debug.Log("Story started.");
await scenarioBookReader.ReadAsync(scenarioBook, this.GetCancellationTokenOnDestroy());
Debug.Log("Story finished.");
C#

Publish メソッドでシナリオスクリプトを変換するのは、いつでも構いません。会話シーン開始の直前でもいいですし、それよりも前に変換しておいても良いです。実際に会話シーンが開始するのはReadAsync メソッドを呼び出したときなので、会話シーンを開始したいタイミングで、そのメソッドを実行したい会話シーンの情報を持ったシナリオブックに対して呼び出せばよいわけです。

会話システムの主要な要素

最後に、会話システムの主要な要素である、「コマンド」、「デコーダ」、「コマンド制御インターフェース」について、それぞれの仕様をもう少し詳しく見ていきます。

コマンド

コマンドは、シナリオスクリプト側から呼び出すことができる、最小の命令単位です。パラメータとともに呼び出し、コマンド特有の処理が実行されます。

ScenarioFlowでは、会話シーンはコマンド呼び出しの繰り返しによって実現されます。セリフを表示するコマンドや、キャラクターを表示するコマンド、音楽を再生するコマンドなどを、適切な順で呼び出すことにより、会話シーンが再生されます。

メソッドのエクスポート

ScenarioFlowにおけるコマンドについて最も重要な概念は、コマンドはC#のメソッドと等価であるということです。C#のメソッドをコマンドとしてエクスポートすることにより、新たなコマンドがシナリオスクリプトで使用可能になります。

メソッドをコマンドとしてエクスポートするには、メソッドにCommandMethod 属性をコマンド名とともに付与します。

C#
public class CSharpClass : IReflectable
{
    [Command Method("sync command name")]
    public void CSharpSyncMethod(Type1 arg1, Type2 arg2, ...)
    {
         // 同期処理
    }

    [CommandMethod("async command name")]
    public UniTask CSharpAsyncMethod(Type1 arg1, Type2 arg2, ...,
    CancellationToken cancellationToken)
    {
        // 非同期処理
    }
}
C#

メソッドをコマンドとしてエクスポートする際の規則は以下の通りです。

  • メソッドにCommandMethod 属性を与え、コマンド名を指定することでコマンドとしてエクスポートできる
  • メソッドのアクセス修飾子はpublic でなければならない
  • 返り値の型がUniTask であるメソッドは非同期コマンドとして、そうでなければ同期コマンドとしてエクスポートされる(UniTaskvoid のみの使用を推奨)
  • CancellationToken 型のパラメータは、非同期コマンドにのみ、一つだけ設定でき、それは最後のパラメータとして設定されていなければならない

メソッドの返り値について、”Hello, world!”の例では、返り値の型がUniTask であるメソッドを非同期コマンドとしてエクスポートしました。一方で、それ以外の返り値型の場合は同期コマンドとしてエクスポートされます。同期コマンドと非同期コマンドの違いは、INextNotifier インターフェースとICancellationNotifier インターフェースの実装から受ける影響にあります。まず同期コマンドの場合、当たり前ですが一瞬で処理が完了するのでキャンセルはできません(そもそもCancellationToken を渡しません)。そして、同期コマンドが完了後、INextNotifier の実装に関わらず、直ちに次のコマンドが実行されます。この挙動については、演習で実際の例を確認します。

メソッドのパラメータ型について、上の規則にある通りCancellationToken 型については少し制約がありますが、実用上は特に制約らしい制約にはなりません。それよりも、CancellationToken 型を除き、エクスポートするメソッドの引数型を自由に設定できるということの方が、はるかに重要です。ScenarioFlowでは、int 型でもGameObject 型でも、その他ユーザ定義型でも、任意の型のパラメータを持つメソッドを、コマンドとしてエクスポートすることができます。このことについては、「デコーダ」のセクションで詳しく見ていきます。

コマンドの登録

メソッドをコマンドとしてエクスポートしたら、実際にシナリオスクリプトからそれを呼び出せるようにするため、コマンドが定義されているクラスをScenarioBookPublisher クラスに登録します。

ScenarioBookPublisher クラスは、既に学習した通りシナリオスクリプトをシナリオブックに変換するクラスであり、そのコンストラクタにコマンドが定義されたクラスのインスタンスを渡すことで、そのコマンドを使用することができるようになります。

ScenarioBookPublisher クラスのコンストラクタが求めるのはIEnumerable<IReflectable>であり、渡されるクラスはIReflectable インターフェースを実装している必要があります。このインターフェースはメンバを持たないので、クラス名の横に: IReflectable を書くだけです。

C#
ScenarioBookPublisher scenarioBookPublisher = new(
    new IReflectable[]
    {
        new CSharpClass()
    });
C#

コマンドの呼び出し

利用可能になったコマンドは、コマンド名とパラメータを指定して、シナリオスクリプトから呼び出すことができます。SFTextの場合、次のようにコマンド呼び出しをします。

SFText
$sync     | sync command name  | 
          | {Arg1} {Arg2} ...  | 
$standard | async command name | 
          | {Arg1} {Arg2} ...  |
SFText

SFTextの文法の詳細については別の記事で取り上げますが、ここでは、CancellationToken 型のパラメータにのみ、補足を加えておきます。

CancellationToken 型のパラメータは、唯一コマンドのパラメータとして制約を持つ特別なパラメータで、「トークンコード」と呼ばれます。SFTextでは、コマンドのパラメータはC#メソッドのパラメータと同じ順で書きますが、CancellationToken 型のパラメータのみ、トークンコードとして独立した場所に記述します。

トークンコードは、非同期コマンドの実行時の振る舞いを変更するという重大な役割を持っています。例えば、与えるトークンコードにより、非同期コマンドのキャンセルを禁止したり、そのコマンドに対するINextNotifier のトリガーを無視したりできます。上の例で指定されている$standard は、「キャンセルを許可し、このコマンドが完了後、INextNotifier によるトリガーを待ってから次のコマンドへ進む」という意味になります。ちなみに、同期コマンドにはトークンコードを与えないので、SFTextでは$syncを指定することになっています。

トークンコードは、ScenarioFlowにおけるシナリオスクリプトの文法を単純にしつつ、その表現力を引き上げることに大きく寄与する重要な概念です。トークンコードについては、別の記事で丁寧に学習します。

デコーダ

ScenarioFlowにおいて、コマンドとしてエクスポートするメソッドの引数型は(CancellationToken 型以外)自由に設定することができます。メソッドのパラメータについて、もう一つの重要なポイントは、メソッド内でパラメータの変換処理を考えなくていいということです。

いうまでもなく、シナリオスクリプトに書かれているパラメータは、C#にはstring 型のオブジェクトとして取り込まれます。これをもとにC#のエクスポートされたメソッドを呼び出すためには、string 型のオブジェクトを適切な型のオブジェクトに変換する必要があります。ScenarioFlowでは、この変換処理はコマンドとしてエクスポートするメソッドに記述する必要はなく、「デコーダ」と呼ばれる変換処理用のメソッドに、その処理を記述します。

覚えておかなければならないのは、コマンドのパラメータとして使用する型すべてに対して、デコーダを用意する必要があるということです。これは、string 型に対してもです。

“Hello, world!”の例では、コマンドのパラメータ型としてstring 型とCancellationToken 型を使用していたので、それらに対するデコーダを定義していました。もしint 型をコマンドのパラメータ型として使用したければ、int 型用のデコーダを用意する必要があります。

デコーダは、次のように作成します。

  • string 型のパラメータを受け取り、対象の型を返り値の型とする、アクセス修飾子がpublic であるメソッドを定義する
  • メソッドにDecoderMethod 属性を付加する
C#
public class DecoderClass : IReflectable
{
    [DecoderMethod]
    public TargetType DecoderForTargetType(string input)
    {
        // 変換処理
        TargetType targetTypeObject = ConvertToTargetType(input);
        
        return targetTypeObject;
    }
}
C#

つまり、C#のメソッドを、DecoderMethod 属性を付加することにより、デコーダとしてエクスポートします。そして、エクスポートしたデコーダを定義するクラスは、IReflectable インターフェースを実装したうえで、ScenarioBookPublisher クラスのコンストラクタに渡す必要があります。

C#
ScenarioBookPublisher scenarioBookPublisher = new(
    new IReflectable[]
    {
        new CSharpClass(),
        new DecoderClass(),
    });
C#

上のコードからわかる通り、ScenarioBookPublisher のコンストラクタには、使用するコマンドを定義するすべてのクラスのインスタンスと、デコーダを定義するすべてのクラスのインスタンスを渡します。

なお、デコーダは基本的にユーザ側で用意する必要がありますが、CancellationToken 型のデコーダのみ、ScenarioFlowが提供するScenarioTaskExecutor クラスを使用して作成しなければならないことに注意してください。ScenarioTaskExecutor クラスはICancellationTokenDecoder インターフェースを実装しており、そのメンバメソッドであるCancellationToken Decode(string) を使用して、CancellationToken 型用のデコーダを簡単に作成できます。

CancellationTokenDecoder.cs
using ScenarioFlow;
using ScenarioFlow.Tasks;
using System;
using System.Threading;

public class CancellationTokenDecoder : IReflectable
{
    private readonly ICancellationTokenDecoder cancellationTokenDecoder;

    public CancellationTokenDecoder(ICancellationTokenDecoder cancellationTokenDecoder)
    {
        this.cancellationTokenDecoder = cancellationTokenDecoder ?? throw new ArgumentNullException(nameof(cancellationTokenDecoder));
    }

    [DecoderMethod]
    public CancellationToken ConvertToCancellationToken(string input)
    {
        return cancellationTokenDecoder.Decode(input);
    }
}
C#
C#
ScenarioTaskExecutor scenarioTaskExecutor = new(
    new NextNotifier(),
    new CancellationNotifier());
CancellationTokenDecoder cancellationTokenDecoder = new(scenarioTaskExecutor);
C#

コマンド制御インターフェース

ScenarioFlowでは、コマンドが順番に呼び出されることにより、会話シーンが再生されます。

実際の会話シーンでは、1つのセリフが表示された後、プレイヤーが何らかのアクション(画面をクリックするなど)を起こすことにより、次のセリフに進むのが普通です。この、「セリフを次に進める」処理は、ScenarioFlowでは「コマンドを次に進める」処理に対応し、INextNotifier インターフェースの実装によって、この処理を実行するためのトリガーが規定されます。

また、セリフは通常、一瞬で表示されるものではなく、1文字ずつ時間をかけて表示されます。その間にユーザーからのアクション(画面をクリックするなど)があると、そのアニメーションがキャンセルされ、瞬時にセリフの全文が表示されます。この「セリフのアニメーションをキャンセルする」処理は、ScenarioFlowでは「コマンドをキャンセルする」処理に対応し、ICancellationNotifier インターフェースの実装によって、この処理を実行するためのトリガーが規定されます。

C#
public class NextNotifier : INextNotifier
{
    public async UniTask NotifyNextAsync(CancellationToken cancellationToken)
    {
        // 何らかの遅延処理
    }
}
C#
C#
public class CancellationNotifier : ICancellationNotifier
{
    public async UniTask NotifyCancellationAsync(CancellationToken cancellationToken)
    {
        // 何らかの遅延処理
    }
}
C#

INextNotifier インターフェースとICancellationNotifier インターフェースは、それぞれメンバメソッドとしてNotifyNextAsync メソッドとNotifyCancellationAsync メソッドを持ちます。これらのメソッドの完了が、それぞれのトリガーとなります。つまり、前者の完了時点で次のコマンドが実行開始され、後者の完了時点で実行中のコマンドがキャンセルされます。

作成したインターフェースの実装は、ScenarioTaskExecutor クラスのコンストラクタに渡すことで、有効になります。

C#
ScenarioTaskExecutor scenarioTaskExecutor = new(
    new NextNotifier(),
    new CancellationNotifier());
C#

まとめ

今回の記事で学習したことをまとめます。

ScenarioFlowで会話シーンを作成するには、次の3つのステップに従います。

  1. 会話システムの作成
  2. シナリオスクリプトの作成
  3. シナリオスクリプトの変換と実行

会話システムは、主要な二つのクラス、ScenarioBookPublisher クラスとScenarioBookReader クラスから構成されます。会話シーンの内容は「シナリオスクリプト」として記述され、それがScenarioBookPublisher クラスにより、C#で実行可能なシナリオブックに変換されます。そして、そのシナリオブックがScenarioBookReader クラスによって実行され、シナリオスクリプトに記述された会話シーンが再生されます。

シナリオスクリプトには、呼び出したい「コマンド」をパラメータとともに記述します。コマンドとは、シナリオスクリプトから呼び出すことのできる命令の最小単位です。あるコマンドを呼び出すと、与えられたパラメータに基づいてコマンド特有の処理が実行されます。ScenarioFlowでは、適切なコマンドが適切な順で、適切なパラメータとともに呼び出された結果として、会話シーンが再生されます。ちなみに、ScenarioScript 抽象クラスを継承するオブジェクトであればなんでもシナリオスクリプトととして使うことができ、ScenarioFlowはデフォルトのシナリオスクリプトとしてSFTextスクリプトを提供します。

コマンドは、C#のメソッドをそのままエクスポートすることで作ることができます。コマンドはC#メソッドのパラメータを引き継ぎ、その型としては、CancellationToken 型以外は自由に使用することができます。コマンドは、メソッドの返り値の型によって非同期コマンドと同期コマンドに分かれ、非同期コマンドの場合、「トークンコード」と呼ばれるCancellationToken 型のパラメータによって、その実行方法を制御することができます。

シナリオスクリプトからコマンドを呼び出すために、文字列としてのパラメータ型を変換するための「デコーダ」が必要です。デコーダは、string 型のパラメータを受け取り、ターゲットとなる型のオブジェクトを返すメソッドをエクスポートすることによって作成できます。string 型を含め、コマンドのパラメータとして使用したいすべての型に対して、デコーダを用意する必要があります。

シナリオスクリプトの実行時、あるコマンドが終了したとき、次のコマンドの実行を開始するためのトリガーをINextNotifier インターフェースの実装が、あるコマンドの実行中、そのコマンドの実行をキャンセルするトリガーをICancellationNotifier の実装が規定します。これらのインターフェースのメンバメソッドであるNotifyNextAsyncNotifyCancellationAsync が完了したとき、それぞれのトリガーが発行されます。

演習

同期版メッセージ表示コマンドの作成

“Hello, world!”の例では、遅延を挟んだ後にメッセージを表示する非同期コマンド、log message async メソッドを作成しました。

同期コマンドと非同期コマンドの違いを確かめるため、遅延を挟まずに、即座にメッセージを表示する、同期版のメッセージ表示コマンドを作成しましょう。

コマンド名はlog message とし、MessageLogger クラス内に追加してください。

コマンドが追加できたら、以下のSFTextを実行します。新たなクラスは作成しないので、作成済みのScenarioManagerクラスをそのまま使えます。

SFText
$sync     | log message                   | 
          | {This is a sync message A}    | 
$standard | log message async             | 
          | {This is an async message A.} | 
$sync     | log message                   | 
          | {This is a sync message B-1.} | 
$sync     | log message                   | 
          | {This is a sync message B-2.} | 
$standard | log message async             | 
          | {This is an async message B.} | 
SFText

サイズ指定メッセージ表示コマンドの作成

テキストサイズを指定して、メッセージを表示できる非同期コマンドを新たに追加します。

コマンド名はlog adjustable message async とし、MessageLogger クラス内に追加してください。パラメータは表示するメッセージを指定するためのstring 型を一つ、テキストのサイズを指定するためのint 型を一つとるものとします。また、テキストのサイズを指定できること以外は、LogMessageAsync メソッドと全く同じ処理を行うものとします。

このコマンドを実行するためには、int 型用のデコーダを追加する必要があります。このデコーダは、PrimitiveDecoder クラス内に追加してください。

以下のSFTextを実行します。

SFText
$standard | log adjustable message async | 
          | {Hello, world!} {10}         | 
$standard | log adjustable message async | 
          | {Hello, Unity!} {15}         | 
$standard | log adjustable message async | 
          | {Hello, ScenarioFlow!} {20}  | 
SFText
実行例

オート進行・NextNotifierの作成

1つの非同期コマンドが完了した後、1秒後に自動的に次のコマンドが実行されるようにするため、INextNotifier インターフェースを実装する新たなクラス、AutoNextNotifier クラスを作成してください。このクラスを有効にするには、ScenarioManager クラスも書き換える必要があります。

以下のSFTextを実行します。

SFText
$standard | log message async      | 
          | {Hello, world!}        | 
$standard | log message async      | 
          | {Hello, Unity!}        | 
$standard | log message async      | 
          | {Hello, ScenarioFlow!} | 
SFText

非同期コマンド完了後、スペースキーを押さずに、時間経過で次のコマンドが実行されることを確認してください。

実行例

解答例

同期版メッセージ表示コマンドの作成
MessageLogger.cs
using Cysharp.Threading.Tasks;
using ScenarioFlow;
using System;
using System.Threading;
using UnityEngine;

public class MessageLogger : IReflectable
{
    [CommandMethod("log message async")]
    public async UniTask LogMessageAsync(string message, CancellationToken cancellationToken)
    {
        // 省略
    }

    [CommandMethod("log message")]
    public void LogMessage(string message)
    {
        Debug.Log(message);
    }
}
C#
実行例

同期コマンドは非同期コマンドと違い、処理が完了後にINextNotifier インターフェースの実装からのトリガーを待機せず、即座に次のコマンドが実行されます。また、いうまでもなくキャンセルはできません。

サイズ指定メッセージ表示コマンドの作成
MessageLogger.cs
using Cysharp.Threading.Tasks;
using ScenarioFlow;
using System;
using System.Threading;
using UnityEngine;

public class MessageLogger : IReflectable
{
    // 省略

    [CommandMethod("log adjustable message async")]
    public UniTask LogAdjustableMessageAsync(string message, int size, CancellationToken cancellationToken)
    {
        return LogMessageAsync($"<size={size}>{message}</size>", cancellationToken);
    }
}
C#
PrimitiveDecoder.cs
using ScenarioFlow;

public class PrimitiveDecoder : IReflectable
{
    [DecoderMethod]
    public string ConvertToString(string input)
    {
        return input;
    }

    [DecoderMethod]
    public int ConvertToInt(string input)
    {
        return int.Parse(input);
    }
}
C#
オート進行・NextNotifierの作成
AutoNextNotifier.cs
using Cysharp.Threading.Tasks;
using ScenarioFlow.Tasks;
using System;
using System.Threading;

public class AutoNextNotifier : INextNotifier
{
	public async UniTask NotifyNextAsync(CancellationToken cancellationToken)
	{
		await UniTask.Delay(TimeSpan.FromSeconds(1.0f), cancellationToken: cancellationToken);
	}
}
C#
ScenarioManager.cs
using Cysharp.Threading.Tasks;
using ScenarioFlow;
using ScenarioFlow.Scripts;
using ScenarioFlow.Tasks;
using UnityEngine;

public class ScenarioManager : MonoBehaviour
{
    //実行するシナリオスクリプト
    [SerializeField]
    private ScenarioScript scenarioScript;
    
    private async UniTaskVoid Start()
    {
        // シナリオブックを実行するシステムを構成
			  ScenarioTaskExecutor scenarioTaskExecutor = new(
				  new AutoNextNotifier(), // <-- ここを変更
				  new EscapekeyCancellationNotifier());
        ScenarioBookReader scenarioBookReader = new(scenarioTaskExecutor);
        // 省略
    }
}
C#

コメント