【ScenarioFlow】実践的な機能:シナリオ分岐と早送り

ScenarioFlow
ツールのバージョン
  • Unity: 2022.3.35f1
  • UniTask: 2.5.4
  • ScenarioFlow: 1.1.0

はじめに

ScenarioFlowにおいて会話システムを構築するために最低限実装する必要があるのは、コマンドとデコーダ、INextNotifier インターフェースとICancellationNotifier インターフェースをそれぞれ実装するクラスです。これらの実装から会話システムを構成し、必要なリソースやオブジェクトなどを用意すれば、あとはシナリオスクリプトを書くことで単純な会話シーンを再生できます。しかし、プレイヤーがより快適に物語を楽しめるようにするため、あるいは会話シーンの表現の幅を広げるためには、さらなる会話システムの機能拡張が必要です。

今回の記事では、より実践的な機能であるシナリオ分岐、早送りの機能をScenarioFlowで実装する方法を学習します。

サンプルプロジェクトの準備

以下のpractical-features.unitypackage をUnityのプロジェクトにインポートし、学習に必要なプログラムとオブジェクトを準備してください。practical-features シーンを開いてプレイモードを開始すると、Story.sftxt が実行され、サンプル会話シーンが再生されます。

このサンプルでは、Nextボタンで会話シーンを進めます。シーン上には、後に学習する「スキップモード」を有効化するためのスキップボタンと、スキップの速さを調整するためのスライダーも配置されています。

サンプルシーン

今回の学習で重要なクラスとコマンドを、以下にまとめます。

クラス概要
ScenarioManager.cs会話システムを構成する
ScenarioBranchMakaerシナリオ分岐用のコマンドを実装する
SkipActivatorスキップモードを制御し、早送り機能を実装する
重要なクラスとその概要
コマンド概要実装クラス
show two selections async二つの選択肢を表示し、解答によって対象のラベルへシナリオを分岐させるScenarioBranchMaker
jump to label指定したラベルへシナリオを分岐させるScenarioBranchMaker

シナリオ分岐

シナリオ分岐は、プレイヤーの選択やフラグに基づいて再生する会話シーンを切り替える機能です。特にプレイヤーへ選択肢を提示し、プレイヤーが選んだ選択によって物語を切り替える演出は、プレイヤーの物語に対する没入感を高めることが期待できるでしょう。

二つの選択肢によるシナリオ分岐

ScenarioFlowにおけるシナリオ分岐の実装方法

ScenarioFlowでは、このようなシナリオ分岐の実装方法は主に二つあります。

一つ目は、物語の分岐点でシナリオスクリプトを区切り、分岐先として複数のシナリオスクリプトを用意する方法です。例えば何らかのフラグを変数として保存しておき、現在再生中のシナリオスクリプトが終了したときに、その変数に基づいてC#側で次に再生されるシナリオスクリプトを切り替えればシナリオ分岐が実現できます。

二つ目は、「ラベル」を使用する方法です。シナリオスクリプトは会話シーンのデータをコマンド呼び出しの配列として保存していますが、その各コマンド呼び出しにはラベルを付与することができます。シナリオスクリプトの再生中、そのラベルを参照することでコマンドの実行順を切り替えることができ、そのような処理を行うコマンドを定義することによりシナリオ分岐が実現できます。

一つ目の方法は、物語の章の切れ目で分岐を行う場合には有効ですが、プレイヤーの選択肢に基づくシナリオ分岐について、その頻度が高い場合はシナリオスクリプトのファイル数が多くなってしまうので向きません。使用される頻度が高い類のシナリオ分岐については二つ目の方法を使用して、一つのシナリオスクリプト内でシナリオ分岐を行います。

今回は、ScenarioFlowに特有の概念である、ラベルを使用したシナリオ分岐の実現方法について見ていきます。

ラベルの付与

ここでは、メインのシナリオスクリプトであるSFTextにおけるラベルの付与方法を紹介します(SFTextはシナリオスクリプトの一種であり、シナリオスクリプトで決まったラベルの付与方法があるわけではないことに注意してください)。SFTextの詳しい文法に関しては、【ScenarioFlow】SFTextの書き方を参照してください。

SFTextでは、#label マクロスコープにより、ラベルをラベル名とともに付加します。このラベルは、その下にあるセリフスコープもしくはコマンドスコープに対して付与され、ラベル名を参照して後からその位置にシナリオを「ジャンプ」させることができます。

ラベルの付加
#label      | //============ {Q1-No} ============//                                        | 
Sheena      | Ok, see you later!                                                           | 
            | --> Change the icon to {smile}                                               | 
$sync       | jump to label                                                                | 
            | Jump to {End}                                                                | 
            |                                                                              | 
#label      | //============ {Q1-Yes} ============//                                       | 
Sheena      | Great!                                                                       | 
            | --> Change the icon to {smile}                                               | 
            |                                                                              | 
#label      | //============ {explain label} ============//                                | 
$parallel   | change character image async                                                 | 
            | Change the icon to {normal} immediately                                      | 
Sheena      | The first one is 'label'. We use it to realize branching scenarios.          | 
            |                                                                              | 
Sheena      | To realize branching scenarios, first of all, we attach labels to commands.  | 
SFText

InspectorウィンドウのScenario Scriptタブで、各スコープに対応するコマンド呼び出しにラベルが付加されていることが確認できます。以下の画像は、サンプルプロジェクト内のStory.sftxt のInspectorウィンドウでScenario Scriptタブを開いたものです。

ラベル”Q1-No”, “Q1-Yes”, “explain label” がそれぞれ5番目、8番目、9番目のコマンド呼び出しに付加されている

ラベルは最終行に置くこともでき、このラベルへジャンプした場合には、即座にそのシナリオスクリプトが終了します。

ラベルを最終行に置く
Sheena      | Good bye.                            | 
            | --> Change the icon to {smile}       | 
            |                                      | 
#label      | //============ {End} ============//  | 
SFText

シナリオ分岐コマンドの実装

シナリオスクリプト内のラベルを参照してシナリオ分岐を行うには、シナリオ分岐を引き起こすコマンドを実装します。このようなコマンドは、ScenarioBookReader クラスが提供するOpenLabel メソッドを、ILabelOpener インターフェースを介して呼び出すことで作成できます。

サンプルプロジェクトでは、シナリオ分岐のための二つのコマンド、show two selections asyncjump to labelScenarioBranchMaker クラス内で実装しています。前者は複数のラベルを受け取り、プレイヤーに選択肢を提示し、解答によって異なるラベルへシナリオを分岐させるコマンドで、後者は無条件で受け取ったラベルへ分岐させるコマンドです。ILabelOpener インターフェースの実装としてLabelOpener プロパティを持ち、それを利用していることに注意してください。

ScenarioBranchMaker.cs (不必要な部分を省略)
public class ScenarioBranchMaker : MonoBehaviour, IReflectable
{
	[SerializeField]
	private Button button1;
	[SerializeField]
	private Button button2;

	public ILabelOpener LabelOpener { get; set; }

	private void Awake()
	{
		button1.gameObject.SetActive(false);
		button2.gameObject.SetActive(false);
	}

	[CommandMethod("show two selections async")]
	public async UniTask ShowTwoSelectionsAsync(string selection1, string label1, string selection2, string label2, CancellationToken cancellationToken)
	{
		button1.gameObject.SetActive(true);
		button2.gameObject.SetActive(true);
		button1.GetComponentInChildren<TextMeshProUGUI>().text = selection1;
		button2.GetComponentInChildren<TextMeshProUGUI>().text = selection2;
		try
		{
		  // 分岐先の決定
			var answerIndex = await UniTask.WhenAny(WaitUntilClicked(button1, cancellationToken), WaitUntilClicked(button2, cancellationToken));
			var targetLabel = answerIndex == 0 ? label1 : label2;
			// 分岐先のラベルへシナリオ分岐
			LabelOpener.OpenLabel(targetLabel);
		}
		finally
		{
			button1.gameObject.SetActive(false);
			button2.gameObject.SetActive(false);
		}
	}

	[CommandMethod("jump to label")]
	public void JumpToLabel(string label)
	{
	  // 指定のラベルへシナリオ分岐
		LabelOpener.OpenLabel(label);
	}

	private UniTask WaitUntilClicked(Button button, CancellationToken cancellationToken)
	{
		return button.OnClickAsAsyncEnumerable(cancellationToken: cancellationToken)
			.FirstOrDefaultAsync(cancellationToken: cancellationToken);
	}
}
C#

LabelOpener プロパティは、別のクラスから渡します。サンプルプロジェクトでは、ScenarioManager クラスがScenarioBookReader クラスのインスタンスをそのプロパティにセットしています。繰り返しますが、ScenarioBookReader クラスはILabelOpener インターフェースを実装しており、ここではこのインターフェースを介してそのメンバーメソッドであるOpenLabel を呼び出しています。

SampleManager.cs (不必要な部分を省略)
	public class ScenarioManager : MonoBehaviour
	{
		private ScenarioBranchMaker scenarioBranchMaker;

		private async void Start()
		{
			// Build ScenarioBookReader
			ScenarioTaskExecutor scenarioTaskExecutor = new(buttonNotifier, buttonNotifier);
			ScenarioBookReader scenarioBookReader = new(scenarioTaskExecutor);
			// Build ScenarioBookPublisher
			scenarioBranchMaker.LabelOpener = scenarioBookReader; // プロパティを設定
			ScenarioBookPublisher scenarioBookPublisher = new(
				new IReflectable[]
				{
					// Commands
					characterAnimator,
					dialogueWriter,
					scenarioBranchMaker,
					// Decoders
					new CancellationTokenDecoder(scenarioTaskExecutor),
					new PrimitiveDecoders(),
					spriteProvider,
				});
		}
	}
C#

シナリオ分岐コマンドの呼び出し

シナリオ分岐のためのコマンドを実装したら、後は適切にそれをシナリオスクリプトで呼び出すだけです。

シナリオ分岐コマンドの呼び出し
#label      | //============ {explain label} ============//                        | 
$parallel   | change character image async                                         | 
            | Change the icon to {normal} immediately                              | 
Sheena      | The first one is 'label'. We use it to realize branching scenarios.  | 
            |                                                                      | 
// ------   | -------------------------------------------------------------------- | 省略
            |                                                                      | 
Sheena      | Did you get it?                                                      | 
            |                                                                      | 
$f-promised | show two selections async                                            | 
            | {Yes!}                                                               | 
            | --- Jump to {Q2-Yes}                                                 | 
            | {No, could you repeat it again?}                                     | 
            | --- Jump to {Q2-No}                                                  | 
            |                                                                      | 
#label      | //============ {Q2-No} ============//                                | 
Sheena      | Of course!                                                           | 
            | --> Change the icon to {normal}                                      | 
$sync       | jump to label                                                        | 
            | Jump to {explain label}                                              | 
SFText
二つの選択肢によるシナリオ分岐

過去方向のラベルへ分岐させることもできることに注意してください。

分岐先の数や、分岐先を決定する基準は自由です。ScenarioFlowでシナリオ分岐用のコマンドを実装するには、要求される分岐の機能に合わせて、必要な条件分岐処理を含み、何らかの基準で分岐先のラベルを決定して、対象のラベルに対してOpenLabel メソッドを呼び出す処理を書くだけです。

早送り

早送りは、プレイヤーが物語を読み飛ばす場合に使用する機能です。プレイヤーが物語を一部だけ読み直したい場合、(望ましくない状況ではありますが)物語をつまらないと感じて読み飛ばしたいときなどに、早送りを有効にし、セリフを次々と高速に表示させます。

早送り機能の実装

ScenarioFlowにおいて、早送りの機能はScenarioTaskExecutor クラスの「スキップモード」を利用することで実現できます。スキップモードが有効のとき、promisedなトークンコードが指定されたコマンドを除き、コマンドの実行とキャンセルが高速に繰り返されます。トークンコードとスキップの関係については、【ScenarioFlow】トークンコードの選び方 を参照してください。

スキップモードに関する設定は、ScenarioTaskExecutor クラスのIsActive プロパティとDuration プロパティに、ISkipActivator インターフェースを介してアクセスすることで行います。IsActive プロパティはスキップモードが有効か無効かを、Duraction プロパティはスキップモード有効時の、コマンド実行とキャンセルの間隔(秒数で指定)を表します。

サンプルプロジェクトでは、SkipActivator クラスで早送り機能の実装がされています。

SkipActivator.cs (不必要な部分を省略)
public class SkipActivator : MonoBehaviour
{
	[SerializeField]
	private Button skipButton;
	[SerializeField]
	private TextMeshProUGUI skipButtonText;
	[SerializeField]
	private Slider waitTimeSlider;
	[SerializeField]
	private TextMeshProUGUI waitTimeText;

	private readonly float InitialWaitTime = 0.01f;
	private readonly float MinWaitTime = 0.0f;
	private readonly float MaxWaitTime = 0.5f;

	public void ActivateSkipButton(ISkipActivator skipActivator)
	{
		waitTimeSlider.minValue = MinWaitTime;
		waitTimeSlider.maxValue = MaxWaitTime;
		// スライダーの値が変更されたら待機時間を変更する
		waitTimeSlider.OnValueChangedAsAsyncEnumerable(destroyCancellationToken)
			.Select(value => System.MathF.Round(value, 2))
			.ForEachAsync(value =>
			{
				skipActivator.Duration = value;
				waitTimeText.text = $"Wait time: {skipActivator.Duration} sec.";
			}, destroyCancellationToken);
		waitTimeSlider.value = InitialWaitTime;
    // スキップボタンが押されたらスキップの有効と無効を切り替える
		skipButton.OnClickAsAsyncEnumerable(destroyCancellationToken)
			.ForEachAsync(_ =>
			{
				skipActivator.IsActive = !skipActivator.IsActive;
				skipButtonText.text = skipActivator.IsActive ? "Skip ON" : "SKip OFF";
			}, destroyCancellationToken);
	}
}
C#

早送り機能はISkipActivator インターフェースの実装をActivateSkipButton メソッドに渡すことで有効化されるようになっており、これはScenarioManager クラスで行われています。

ScenarioManager.cs (不必要な部分を省略)
public class ScenarioManager : MonoBehaviour
{
	private async void Start()
	{
		try
		{
		  // 早送り機能の有効化
			skipActivator.ActivateSkipButton(scenarioTaskExecutor);
			ScenarioBook scenarioBook = scenarioBookPublisher.Publish(scenarioScript);
			await scenarioBookReader.ReadAsync(scenarioBook, destroyCancellationToken);
		}
		finally
		{
			scenarioTaskExecutor?.Dispose();
			UnityEditor.EditorApplication.isPlaying = false;
		}
	}
}
C#

サンプルプロジェクトでは、SkipボタンでIsActive プロパティのtrue/false を切り替え、スライダーでDuration の値を変更するようになっています。特にスライダーの値を切り替え、早送りの速度が変わることを確かめてください。

スライダーで早送りの速度を変更する

まとめ

今回は、会話システムのより実践的な機能として、シナリオ分岐と、早送り機能の実装方法について学習しました。

ScenarioFlowでは、シナリオ分岐はScenarioBookReader クラスが実装するILabelOpener インターフェースのメンバーメソッドであるOpenLabel メソッドを利用してシナリオ分岐用のコマンドを実装することで実現できます。このコマンドは、典型的には候補となるラベルを受け取り、何らかの基準に基づいて分岐先のラベルを決定し、OpenLabel メソッドを呼び出すことでシナリオ分岐を引き起こします。本質的には、シナリオ分岐のためにはOpenLabel メソッドに分岐先のラベルを渡せばよいので、要求に応じて自由にシナリオ分岐の機能を実装できます。

早送り機能は、ScenarioTaskExecutor クラスが実装するISkipActivator インターフェースのメンバ、IsActiveDuration を使用して実装します。IsActivetrue のとき、スキップモードが有効となり、promisedなコマンドを除くすべてのコマンドがスキップ(処理開始とキャンセルの繰り返し)されます。Duration では、スキップのインターバルを秒数指定します。

これらの機能は、会話シーンに必ず必要なものではありませんが、物語への没入感を高めたり、ゲーム体験を快適なものにしたりすることができます。これらの機能のScenarioFlowにおける実装方法を理解し、プロジェクトにおける必要性に応じて会話システムへ組み込みましょう。

コメント