🎶

【Unity】ScenarioFlowによるシナリオ実装#3-2(シナリオ進行としてのセリフの表示)

2023/05/21に公開
2

はじめに

こんにちは.伊都アキラです.

前回は,シナリオ進行に欠かせない要素の一つである,セリフ表示のプログラムを作成しました.
ただし,ScenarioMethodとしては実行をしておらず,通常のメソッドとして呼び出し,テストを行いました.

今回は,基本的なDecoderとProgressorを実装してシナリオ進行の実行環境を整えるとともに,前回作成したセリフ表示プログラムをScenarioMethodとして実行します.

Decoderの作成

手始めに,文字列をScenarioMethodの引数として使用する型に変換するためのDecoderを作成していきます.
現時点ではまだ,引数としてはstring型を取るものしか作成していませんが,基本的な型についてはここで一度に作成してしまいましょう.

次のPrimitiveDecoder.cs,およびAsyncDecoder.csを作成します.

PrimitiveDecoder.cs
using ScenarioFlow;
using System;
using System.Linq;

namespace AliceStandard.Decoder
{
    public class PrimitiveDecoder : IReflectable
    {
        [Decoder]
        public int IntDecoder(string source)
        {
            return int.TryParse(source, out int result) ?
                result :
                throw new ArgumentException(DecodeErrorMessage<int>(source));
        }

        [Decoder]
        public int[] IntArrayDecoder(string[] sources)
        {
            return sources.Select(s => IntDecoder(s)).ToArray();
        }

        [Decoder]
        public float FloatDecoder(string source)
        {
            return float.TryParse(source, out float result) ?
                result :
                throw new ArgumentException(DecodeErrorMessage<float>(source));
        }

        [Decoder]
        public float[] FloatArrayDecoder(string[] sources)
        {
            return sources.Select(s => FloatDecoder(s)).ToArray();
        }

        [Decoder]
        public bool BoolDecoder(string source)
        {
            return bool.TryParse(source, out bool result) ?
                result :
                throw new ArgumentException(DecodeErrorMessage<bool>(source));
        }

        [Decoder]
        public bool[] BoolArrayDecoder(string[] sources)
        {
            return sources.Select(s => BoolDecoder(s)).ToArray();
        }

        [Decoder]
        public string StringDecoder(string source)
        {
            return source;
        }

        [Decoder]
        public string[] StringArrayDecoder(string[] sources)
        {
            return sources.Select(s => StringDecoder(s)).ToArray();
        }

        private string DecodeErrorMessage<T>(string source)
        {
            return $"'{source}' can't be converted into '{typeof(T).Name}'";
        }
    }
}
AsyncDecoder.cs
using ScenarioFlow;
using ScenarioFlow.TaskFlow;
using System;
using System.Threading;

namespace AliceStandard.Decoder
{
    public class AsyncDecoder : IReflectable
    {
        private readonly ICancellationTokenDecoder cancellationTokenDecoder;

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

        [Decoder]
        public CancellationToken CancellationTokenDecoder(string source)
        {
            return cancellationTokenDecoder.Decode(source);
        }
    }
}

CancellationToken型のDecoderは,ICancellationTokenDecoderインターフェースを使って作るのでした.

これで,ScenarioMethodの引数としてひとまず次の型が使えるようになります.

  • PrimitiveDecoder
    • int
    • float
    • bool
    • string
  • AsyncDecoder
    • CancellationToken

CancellationTokenに関しては配列用のDecoderを定義していませんが,その型の役割上,ScenarioMethodの引数としてその配列を取らせる必要はないでしょう(それに,CancellationToken型の配列をDecoderで渡そうとするとおかしなことになります.).

Progressorの作成

次に,基本的なProgressorをいくつか作成します.
ProgressorにはINextProgressorICancellationProgressorがあり,前者は一つの処理が終わった後,次の処理へ移行する許可を出すための進行命令を,後者は一つの処理をキャンセルするためのキャンセル命令を発行するために必要です.

INextProgressorNotifyNextAsyncを,ICancellationProgressorNotifyCancellationAsyncを実装する必要があります.
それぞれの処理が完了した時点で,進行命令及びキャンセル命令が発行されます.

さて,今回は次の4つのProgressorを作成します.

クラス名 命令発行のトリガー Next/Cancellation
KeyProgressor キー入力 両対応
ButtonProgressor ボタン 両対応
CompositeAnyNextProgressor 他のNextProgressorすべて Next
CompositeAnyCancellationProgressor 他のCancellationProgressorすべて Cancellation

KeyProgressor

KeyProgressor
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using ScenarioFlow.TaskFlow;
using System;
using System.Threading;
using UnityEngine;

namespace AliceStandard.Progressor
{
    public class KeyProgressor : INextProgressor, ICancellationProgressor
    {
        private readonly KeyCode[] keyCodes;

        public KeyProgressor(Settings settings)
        {
            this.keyCodes = settings.KeyCodes ?? throw new ArgumentNullException(nameof(settings.KeyCodes));
        }

        public UniTask NotifyNextAsync(CancellationToken cancellationToken)
        {
            return AwaitAnyKeyDownAsync(cancellationToken);
        }

        public UniTask NotifyCancellationAsync(CancellationToken cancellationToken)
        {
            return AwaitAnyKeyDownAsync(cancellationToken);
        }

        private UniTask AwaitAnyKeyDownAsync(CancellationToken cancellationToken)
        {
            //キーのどれかが押されたら完了
            return keyCodes.Length > 0 ? UniTaskAsyncEnumerable.EveryUpdate()
                .SelectMany(_ => keyCodes.ToUniTaskAsyncEnumerable().Select(keyCode => Input.GetKeyDown(keyCode)))
                .Where(x => x)
                .FirstOrDefaultAsync(cancellationToken: cancellationToken) :
                UniTask.Never(cancellationToken);
        }

        [Serializable]
        public class Settings
        {
            public KeyCode[] KeyCodes;
        }
    }
}

NotifyNextAsyncNotifyCancellationAsyncは,どちらもAwaitAnyKeyDownAsyncの完了をもって処理完了とします.
そして,AwaitAnyKeyDownAsyncは,keyCodes登録されたキーのどれか一つが押されたときに完了します.

すなわちKeyProgressorは,特定のキーを押すと進行命令あるいはキャンセル命令を発行するProgressorです.

ButtonProgressor

ButtonProgressor
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using ScenarioFlow.TaskFlow;
using System;
using System.Threading;
using UnityEngine.UI;

namespace AliceStandard.Progressor
{
    public class ButtonProgressor : INextProgressor, ICancellationProgressor
    {
        private readonly Button[] buttons;

        public ButtonProgressor(Settings settings)
        {
            this.buttons = settings.Buttons ?? throw new ArgumentNullException(nameof(settings.Buttons));
        }

        public UniTask NotifyNextAsync(CancellationToken cancellationToken)
        {
            return AwaitAnyButtonClikedAsync(cancellationToken);
        }

        public UniTask NotifyCancellationAsync(CancellationToken cancellationToken)
        {
            return AwaitAnyButtonClikedAsync(cancellationToken);
        }

        private UniTask AwaitAnyButtonClikedAsync(CancellationToken cancellationToken)
        {
            //ボタンのどれかが押されたら完了
            return buttons.Length > 0 ? buttons.ToUniTaskAsyncEnumerable()
                .SelectMany(button => button.OnClickAsAsyncEnumerable())
                .FirstOrDefaultAsync(cancellationToken: cancellationToken) :
                UniTask.Never(cancellationToken);
        }

        [Serializable]
        public class Settings
        {
            public Button[] Buttons;
        }
    }
}

ButtonProgressorは,トリガーをキー入力ではなくボタンの押下としている点以外はKeyProgressorと同じです.
NotifyNextAsyncNotifyCancellationAsyncも登録されたボタンのどれか一つを押すと処理が完了するので,これは特定のボタンの押下により進行命令およびキャンセル命令を発行するProgressorです.

CompositeAnyNext(Cancellation)Progressor

CompositeAnyNextProgressor
using Cysharp.Threading.Tasks;
using ScenarioFlow.TaskFlow;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;

namespace AliceStandard.Progressor
{
    public class CompositeAnyNextProgressor : INextProgressor
    {
        private readonly INextProgressor[] nextProgressors;

        public CompositeAnyNextProgressor(IEnumerable<INextProgressor> nextProgressors)
        {
            if (nextProgressors == null)
                throw new ArgumentNullException(nameof(nextProgressors));

            this.nextProgressors = nextProgressors.ToArray();
        }

        public UniTask NotifyNextAsync(CancellationToken cancellationToken)
        {
            return nextProgressors.Length > 0 ?
                UniTask.WhenAny(nextProgressors.Select(nextProgressor => nextProgressor.NotifyNextAsync(cancellationToken))) :
                UniTask.Never(cancellationToken);
        }
    }
}
CompositeAnyCancellationProgressor
using Cysharp.Threading.Tasks;
using ScenarioFlow.TaskFlow;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;

namespace AliceStandard.Progressor
{
    public class CompositeAnyCancellationProgressor : ICancellationProgressor
    {
        private readonly ICancellationProgressor[] cancellationProgressors;

        public CompositeAnyCancellationProgressor(IEnumerable<ICancellationProgressor> cancellationProgressors)
        {
            if (cancellationProgressors == null)
                throw new ArgumentNullException(nameof(cancellationProgressors));

            this.cancellationProgressors = cancellationProgressors.ToArray();
        }

        public UniTask NotifyCancellationAsync(CancellationToken cancellationToken)
        {
            return cancellationProgressors.Length > 0 ?
                UniTask.WhenAny(cancellationProgressors.Select(cancellationProgressor => cancellationProgressor.NotifyCancellationAsync(cancellationToken))) :
                UniTask.Never(cancellationToken);
        }
    }
}

2つの役割はほぼ同じなので,まとめて解説します.

Progressorは,当然ですが作成しただけでは意味がありません.
適切なクラスのコンストラクタに渡すことで,初めて使えるようになります.

例えば,NextProgressorについては,次のようにScenarioTaskExecuterTokenCodeDecoratorクラスのコンストラクタに渡して初めて意味を持ちます.

        var scenarioBookReader = new ScenarioBookReader(
            new ScenarioTaskExecuterTokenCodeDecorator(
                new ScenarioTaskExecuter(tokenCodeHolder),
                nextProgressor,
                tokenCodeHolder));

ここで,渡せるNextProgressorの数は一つです.
先ほど作成したKeyProgressorButtonProgressor両方を渡すことはできません

では,キーの押下またはボタンの押下を進行あるいはキャンセルのトリガーとしたい場合は,そのためのクラスをまた作らなければならないのでしょうか.

そうではなく,上記の2つのクラスのように,自身が特定のインターフェースを実装しながら,実際の処理は他の実装に任せるようなものを用意します.

例えば上記のCompositeAnyNextProgressor自身がINextProgressorを実装しながらも,コンストラクタに複数のINextProgressorを要求し,それらの内,最も早かった処理の完了をもって自身のNotifyNextAsyncの完了としています.

このとき,CompositeAnyNextProgressorはあくまで一つのクラスですから,複数の実装をあたかも一つの実装のように見せることができます.
INextProgressorを一つしか要求しないScenarioTaskExecuterTokenCodeDecoratorにも,KeyProgressorButtonProgressorを渡したCompositeAnyNextProgressor一つを渡すことで,専用のクラスを作ることなく,ボタン押下をトリガーとするクラスキー押下をトリガーとするクラスを組み合わせて,ボタン押下またはキー押下をトリガーとするクラスを作ることができますね.

ScenarioMethodとしてのLineWriterの実行

では,作成したDecoderとProgressorを使って,前回に作成したLineWriterを実行していきます.

まず,次のGameManager.csを用意してください.

GameManager.cs
using AliceStandard.Decoder;
using AliceStandard.Line;
using AliceStandard.Progressor;
using Cysharp.Threading.Tasks;
using ScenarioFlow;
using ScenarioFlow.ExcelFlow;
using ScenarioFlow.TaskFlow;
using UnityEngine;
using System;

public class GameManager : MonoBehaviour
{
    //------各設定

    [SerializeField]
    private LineWriter.Settings lineWriterSettings;

    [SerializeField]
    private KeyProgressor.Settings keyProgressorSettings;

    [SerializeField]
    private ButtonProgressor.Settings buttonProgressorSettings;

    //------

    //実行したいソースファイル
    [SerializeField]
    private ExcelAsset excelAsset;

    //Disposable
    private IDisposable[] disposables;

    private async UniTaskVoid Start()
    {
        //CancellationTokenを取得
        var cancellationToken = this.GetCancellationTokenOnDestroy();

        //------Progressorの準備
        var keyProgressor = new KeyProgressor(keyProgressorSettings);
        var buttonProgressor = new ButtonProgressor(buttonProgressorSettings);
        //使用するNextProgressorをここに
        INextProgressor nextProgressor = new CompositeAnyNextProgressor(
            new INextProgressor[]
            {
                keyProgressor,
                buttonProgressor,
            });
        //使用するCancellationProgressorをここに
        ICancellationProgressor cancellationProgressor = new CompositeAnyCancellationProgressor(
            new ICancellationProgressor[]
            {
                keyProgressor,
                buttonProgressor,
            });
        //------

        //------ScenarioBookReaderの準備
        var tokenCodeHolder = new TokenCodeHolder();

        var scenarioBookReader = new ScenarioBookReader(
            new ScenarioTaskExecuterTokenCodeDecorator(
                new ScenarioTaskExecuter(tokenCodeHolder),
                nextProgressor,
                tokenCodeHolder));
        //------
        //------CancellationToken用のDecoderの準備
        var cancellationTokenDecoder = new CancellationTokenDecoder(tokenCodeHolder);

        var cancellationTokenDecoderTokenCodeDecorator = new CancellationTokenDecoderTokenCodeDecorator(
            cancellationTokenDecoder,
            new CancellationProgressorTokenCodeDecorator(cancellationProgressor, tokenCodeHolder),
            tokenCodeHolder);
        //-----

        //ScenarioPublisherの準備
        var excelScenarioPublisher = new ExcelScenarioPublisher(
            new ScenarioMethodSeacher(
                new IReflectable[]
                {
                    //使用するScenarioMethodとDecoderをここに
                    new PrimitiveDecoder(),
                    new AsyncDecoder(cancellationTokenDecoderTokenCodeDecorator),
                    new LineWriter(lineWriterSettings),
                }));

        //Disposableの登録
        disposables = new IDisposable[]
        {
            cancellationTokenDecoder,
            cancellationTokenDecoderTokenCodeDecorator,
        };

        //ScenarioBookの作成
        var scenarioBook = excelScenarioPublisher.Publish(excelAsset);
        //ScenarioBookを実行
        await scenarioBookReader.ReadScenarioBookAsync(scenarioBook, cancellationToken);
    }

    //最後にDisposableをDispose
    private void OnDestroy()
    {
        foreach (var disposable in disposables)
        {
            disposable.Dispose();
        }
    }
}

次に,適当なボタンをCanvasUIに配置します.
以下の画像では,名前をButtonNextとしています.また,見やすさのためにここではボタンの色を赤っぽくしていますが,実使用では透明にしています.


透明なボタンを配置している

次に,シナリオのソースファイルを準備します.


シナリオのソースファイル

以下はコピペ用です.タブ区切りになっているので,Excelファイルに直接貼り付けることができます.

コピペ用
ScenarioMethod	Param1	Param2	Param3
<Page>			
line.erase	std		
line.write	アリス	私はアリス。	std
line.write	アリス	好きな動物は<color=orange>うさぎ</color>。	std
line.write	アリス	とってもかわいいよね!	std
line.write	マッチ売りの少女	私はマッチ売りの少女。	std
line.write	マッチ売りの少女	<color=red>マッチ</color>はいりませんか?	std
line.erase	std		
</Page>			

最後に,GameManagerに必要な設定を行い,実行しましょう.

エンターキー,スペースキー,またはボタンのクリックによって,シナリオが進むことを確認してください.

おわりに

今回は,シナリオの実行環境を整えるために基本的なDecoderとProgressorを作成しました.
自動進行などの便利機能はまだ作成していませんが,これで最低限のシナリオ進行を行うことができるようになります.

次回以降はしばらく,キャラクターの表示,背景の表示など,目に見えて分かるシナリオ進行の要素をどんどん足していきます.

Discussion

伊都アキラ伊都アキラ

2023/06/16
PrimitiveDecoder.csのStringArrayDecoderにDecoder属性が付加されていなかったので,修正しました.