🎵

【Unity】ScenarioFlowによるシナリオ実装#2-1(TaskFlowと非同期メソッド)

2023/05/03に公開

はじめに

こんにちは.伊都アキラです.
#1では,ScenarioFlowの紹介,およびScenarioMethodやScenarioBookなどといった基本的な概念を解説していました.既存のメソッドに対してScenarioMethod属性を付加することで,それを外部のソースファイルからコマンドとして実行することができていましたね.

ところで,#1で扱っていたのはすべて同期メソッドでした.しかし,シナリオを実装する場合にはセリフをゆっくりと表示したり,背景を徐々に変化させたり,キャラクターを動かしたりします.ですから,実践的には非同期メソッドの利用が欠かせません.

#2では,ScenarioFlowの拡張モジュールであるTaskFlowを利用して,非同期メソッドをScenarioMethodとして実行する方法を解説していきます.TaskFlowによって,非同期メソッドを実行中にキャンセルしたり,複数の非同期メソッドを並列に実行したりと,柔軟なコントロールが可能になります.

今回の記事では,TaskFlowを使用するための準備をします.ひとまずいくつかの非同期メソッドを実行するところまで行い,次回以降,詳細な解説をしていきたいと思います.

プロジェクトのセットアップ

#2に入るにあたり,新たなプロジェクトを作成しています.#1-3で行ったように,ScenarioFlowUniTaskをインポートしておいてください.

非同期メソッドの準備

まずは,今回から解説で使用していく非同期メソッドを実装します.
MessageLogger.csSimpleCounter.csを作成し,以下のようなコードを記述してください.

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

public class MessageLogger : IReflectable
{
    [ScenarioMethod("message.after", "指定の秒数待機した後,メッセージを表示")]
    public async UniTask LogMessageAfterSecondsAsync(string message, float delaySec, CancellationToken cancellationToken)
    {
        if (delaySec < 0)
            throw new ArgumentException("Given time is negative.");

        try
        {
            await UniTask.Delay(TimeSpan.FromSeconds(delaySec), cancellationToken: cancellationToken);
        }
        finally
        {
            Debug.Log(message);
        }
    }
}
SimpleCounter.cs
using Cysharp.Threading.Tasks;
using ScenarioFlow;
using System;
using System.Linq;
using System.Threading;
using UnityEngine;

public class SimpleCounter : IReflectable
{
    [ScenarioMethod("count.sec", "指定された秒数をカウント")]
    public async UniTask CountSecondsAsync(string counterName, int time, CancellationToken cancellationToken)
    {
        if (time < 0)
            throw new ArgumentException("Given time is negative.");

        try
        {
            foreach(var t in Enumerable.Range(0, time))
            {
                Debug.Log($"{counterName}: {time - t} sec.");

                await UniTask.Delay(TimeSpan.FromSeconds(1.0f), cancellationToken: cancellationToken);
            }

            Debug.Log($"{counterName} finished!");
        }
        catch (OperationCanceledException)
        {
            Debug.Log($"{counterName} canceled.");
        }
    }
}

ScenarioMethod属性の付加と,IReflectableインターフェースの実装を忘れないようにしましょう.

試しに,これをこのまま実行してみましょう.

シーン上に新たな"GameManager"オブジェクトを配置し,以下のGameManager.csを作成,アタッチして実行してみます.

GameManager.cs
using Cysharp.Threading.Tasks;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    private async UniTaskVoid Start()
    {
        var messageLogger = new MessageLogger();

        await messageLogger.LogMessageAfterSecondsAsync("Hello, TaskFlow!", 3.0f, this.GetCancellationTokenOnDestroy());

        var simpleCounter = new SimpleCounter();

        await simpleCounter.CountSecondsAsync("カウンターA", 2, this.GetCancellationTokenOnDestroy());
    }
}

以下のような結果が得られると思います.
3秒後に"Hello, TaskFlow!"のメッセージが表示され,その後,カウントダウンが始まったはずです.

Decoderの準備

#1で解説した通り,ScenarioMethodで使用したい型の全てに対してDecoderを用意する必要があります.
今回使用する型はint, float, string, CancellationTokenですから,それら専用のDecoderを準備する必要があります.

始めに,以下のPrimitiveDecoder.csを作成し,CancellationToken以外の型に対してDecoderを実装します.

PrimitiveDecoder.cs
using ScenarioFlow;
using System;

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

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

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

次に,CancellationToken用のDecoderを実装します.以下のように,TaskDecoder.csを作成してください.

TaskDecoder.cs
using ScenarioFlow;
using ScenarioFlow.TaskFlow;
using System;
using System.Threading;

public class TaskDecoder : IReflectable
{
    private readonly ICancellationTokenDecoder cancellationTokenDecoder;

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

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

TaskFlowは,文字列をCancellationTokenに変換するためのクラスを提供しており,ICancellationTokenDedcoderインターフェースによって抽象化されています.ひとまず,このインターフェースの CancellationToken Decode(string source) を使用する形にしておきましょう.

後にTaskDecoderクラスのインスタンスを生成する際に,コンストラクタにTaskFlowが提供するICancellationTokenDecoderインターフェースの実装を渡すことにします.

Progressorの準備

TaskFlowでは,ScenarioBookをScenarioBookReaderに渡すことで,TaskFlowが上手く非同期処理をコントロールします.その結果,シナリオ進行が実現されます.

シナリオ進行の流れですが,簡単に説明すると,以下の処理の繰り返しになります.

  • ScenarioMethodを実行する
  • 同期メソッドの場合
    • 完了次第,次のScenarioMethodへ
  • 非同期メソッドの場合
    • ScenarioMethodの完了またはキャンセルを待つ
    • シナリオ進行の命令を待つ

ScenarioBookの中のScenarioMethodを順に実行するという点は今までと同様ですが,ScenarioMethodが非同期メソッドだった場合(正確には,返り値の型がUniTaskであるとき),ScenarioMethodが完了後,ユーザーあるいはシステムからの 「次へ進む許可」を待つという処理が加わる点が異なります.また,非同期メソッドの場合は,実行途中であってもキャンセルすることが可能です.

このとき,「どのアクションを受けて次に進むか?」 「どのアクションを受けて非同期処理をキャンセルするか?」 という進行のタイミングを制御するためのものが,NextProgressorCancellationProgressorです.

さて,これらのProgressorは自身で用意する必要があります.
ここでは試しに,スペースキーを押すと次へ進む(またはキャンセルする) ようなProgressorを実装してみます.

以下のようにSpaceKeyProgressor.csを実装してください.

SpaceKeyProgressor.cs
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using ScenarioFlow.TaskFlow;
using System.Threading;
using UnityEngine;

public class SpaceKeyProgressor : INextProgressor, ICancellationProgressor
{
    public UniTask NotifyNextAsync(CancellationToken cancellationToken)
    {
        return AwaitSpaceKeyDownAsync(cancellationToken);
    }

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

    private UniTask AwaitSpaceKeyDownAsync(CancellationToken cancellationToken)
    {
        return UniTaskAsyncEnumerable.EveryUpdate()
            .Select(_ => Input.GetKeyDown(KeyCode.Space))
            .Where(x => x)
            .FirstOrDefaultAsync(cancellationToken: cancellationToken);
    }
}

NextProgressorはINextProgressorインターフェースを,CancellationProgressorはICancellationProgressorインターフェースを実装している必要があります.それぞれ,NotifyNextAsyncと,NotifyCancellationAsyncがメンバに当たります.

どちらも,メンバに当たるメソッドの非同期処理が完了した瞬間に,ScenarioBookReaderに対して次のシナリオへの進行あるいは処理のキャンセルを要請することになります.

ですから,この場合はスペースキーを押すとそれぞれの非同期メソッドが完了し,シナリオをが次に進む,あるいは処理のキャンセルが行われることになるわけです.

ScenarioTaskの実行

さて,いよいよ,始めに準備した非同期のScenarioMethodをTaskFlowによって呼び出してみます.

ただ,その前に,非同期のScenarioMethodと同期のScenarioMethodは区別がややこしいです.ですから,これからは非同期のScenarioMethodを特に同期のものと区別する際はScenarioTaskと呼ぶことにします.

では,以下のソースファイルを準備しましょう.
CancellationToken型の引数に当たるところには"std"と書いてありますが,これの意味は今後の記事で解説します.


ソースファイル

そして,以下のGameManager.csを準備してください.
複雑なことが書いてありますが,これも詳細は今後の記事で解説していきますので,今のところはこれを写しておいてください.

GameManger.cs
using Cysharp.Threading.Tasks;
using ScenarioFlow;
using ScenarioFlow.ExcelFlow;
using ScenarioFlow.TaskFlow;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    //読む対象のソースファイル
    [SerializeField]
    private ExcelAsset excelAsset;

    private void Start()
    {
        //--- TaskFlowのセットアップ
        TokenCodeHolder tokenCodeHolder = new TokenCodeHolder();

        SpaceKeyProgressor spaceKeyProgressor = new SpaceKeyProgressor();

        IScenarioTaskExecuter scenarioTaskExecuter = new ScenarioTaskExecuterTokenCodeDecorator(
            new ScenarioTaskExecuter(tokenCodeHolder),
            spaceKeyProgressor,
            tokenCodeHolder);
        
        IScenarioBookReader scenarioBookReader = new ScenarioBookReader(scenarioTaskExecuter);

        ICancellationTokenDecoder cancellationTokenDecoder = new CancellationTokenDecoderTokenCodeDecorator(
            new CancellationTokenDecoder(tokenCodeHolder),
            new CancellationProgressorTokenCodeDecorator(spaceKeyProgressor, tokenCodeHolder),
            tokenCodeHolder);
        //---

        //--- ScenarioFlowのセットアップ
        IScenarioMethodSearcher scenarioMethodSearcher = new ScenarioMethodSeacher(
            new IReflectable[]
            {
                new PrimitiveDecoder(),
                new TaskDecoder(cancellationTokenDecoder),
                new MessageLogger(),
                new SimpleCounter(),
            });

        IScenarioPublisher<ExcelAsset> excelScenarioPublisher = new ExcelScenarioPublisher(scenarioMethodSearcher);
        //---

        //ScenarioBookを読む
        IScenarioBook scenarioBook = excelScenarioPublisher.Publish(excelAsset);

        scenarioBookReader.ReadScenarioBookAsync(scenarioBook, this.GetCancellationTokenOnDestroy()).Forget();
    }
}

では,実行してみます.
実行結果の例を以下に示します.


実行例その1


実行例その2

実行中,次のことを確かめてみてください.

  • 実行してから3秒後に"Hello, TaskFlow!"と表示される
  • "message.after"が終了後,スペースキーを押すとカウントダウンが開始する
  • カウントダウン中,スペースキーを押すとカウントダウンがキャンセルされる
  • 実行直後,スペースキーを押すとその瞬間にメッセージが表示される

おわりに

今回は,TaskFlowを使用して非同期のScenarioMethod(特に同期のScenarioMethodと区別するとき,ScenarioTaskと呼びます)を実行しました.そして,NextProgressorで次のシナリオへ進むタイミングを,CancellationProgressorで実行中のScenarioTaskをキャンセルするためのアクションを設定できることが確かめられましたね.

次回以降は,TaskFlowが提供するクラスの構造,ソースファイル中の書いた"std"の意味,ラベルの利用方法などについて解説していきます.
これらを理解することによって,より柔軟なScenarioTask,ScenarioMethodの実行が可能になります.

また,TaskFlowは非同期処理を実行するために,UniTaskを利用します.そこで,今回もそうですが,これからもUniTaskが提供する様々な機能を積極的に使用していきます.ですから,もしもよく分からないコードがあれば,ぜひコメントで質問していただければと思います.

最後までお読みいただき,ありがとうございました.

Discussion