てくメモ

trivial な notes

【Godot C#】async/await な流れで落ち物ゲームをつくる

本記事は、Godot Engine Advent Calendar 2023 参加記事となっています。

空きのある日があったので、枯れ木も山の賑わいとばかりに参加させていただきました。不勉強な部分があるかと思いますが、よろしくお願いいたします。


以下の画像(外部サイト投稿の apng につき、表示・再生されない場合があるかもしれません)のような雰囲気の落ち物ゲームを、async/await なフローの処理でつくってみました。そのアプローチについての記事です。

https://i.imgur.com/tROmIaP.png

(ゲーム利用の画像素材:Blue Archive (Global version) の digital merch より)

イカゲームライクの落ち物です。


本記事では、async/await のアプローチに焦点を当て、全体は示しません。Godot でのスイカゲームライク落ち物については、プロジェクト全体を公開のうえで解説されている記事がありますので、紹介します。

また、昨年のアドベントカレンダーにおいて、GDScript による同様のアプローチを検証されている記事がありますので、紹介します。



メインノード

async/await のフローを使えると何が嬉しいのか。それは、状態遷移を自然に書けることです。

例えば、メインノードのスクリプトは以下のようにしました。

参考:ノードツリー

public partial class Main : Node2D
{
    public override void _Ready() => _ = LoopAsync();

    private async Task LoopAsync()
    {
        var startScene = GetNode<StartScene>("StartScene");
        var mainGame = GetNode<MainGame>("MainGame");
        var resultScene = GetNode<ResultScene>("ResultScene");

        // シーン(1):Click to Start。入力を待機
        await startScene.WaitAsync();
        startScene.QueueFree();

        for(bool continuesToPlay = true; continuesToPlay;)
        {
            resultScene.Hide();

            // シーン(2):メインゲーム。ゲームオーバーを待機し、スコア結果を取得
            int score = await mainGame.RunAsync();

            // シーン(3):リザルト。スコア結果を表示し、再度プレイするかの入力を待機
            continuesToPlay = await resultScene.ConfirmAsync(score);
        }

        // ゲーム終了
        GetTree().Quit();
    }
}

このゲームは、① 起動したら入力を待機する ⇨ ②メインの落ち物 ⇨ ③リザルトを表示しゲームを再度プレイするか確認する ⇨ 続けるなら再度②・続けないなら終了、という流れで構成されています。

async/await を利用することで、その流れのとおりに処理を書くことができます。また、シーン状態フラグ等を自前で管理する必要がありません。嬉しい。

というわけで、こうした await を実現するために各シーンのスクリプトを書いていきます。

Click to Start シーン(単純な入力待機)

まずは Click to Start なスタートシーン。決定アクション (ui_accept) かマウスクリックを待機することにします。

Click to Start

こうした単純な待機であれば、ToSignalawaitすればよいでしょう。

public partial class StartScene : CanvasLayer
{
    [Signal] // Signal 属性 + EventHandler サフィックス命名でソースジェネレーターが諸々を自動生成してくれる
    public delegate void ClickEventHandler();

    // ToSignal で await。SignalName.Click は自動生成されている
    public async Task WaitAsync() => await ToSignal(this, SignalName.Click);

    public override void _Input(InputEvent @event)
    {
        if (@event.IsActionPressed("ui_accept")
            || @event.IsActionPressed("mouse_click_left")) // (※ このアクションは追加したもの)
        {
            EmitSignal(SignalName.Click); // EmitSignal で発火
        }
    }
} 

メインゲーム(ゲームオーバーを待機しながら落ち物)

次にメインゲームの落ち物を考えます。

今回は、デッドライン到達でのゲームオーバーでのみ、シーンが終了するとします。流れは次のようになります。

[Signal]
public delegate void ReachDeadEventHandler();

public async Task<int> RunAsync()
{
    // 開始処理
    await OnStarting();

    // ゲームオーバー(キャンセル)まで落下動作をループ
    using CancellationTokenSource cts = new();
    _ = ReleaseLoopAsync(cts.Token);

    // ゲームオーバーを待機
    await ToSignal(this, SignalName.ReachDead);

    // 終了処理
    OnClosing();
    cts.Cancel();

    // スコアを返す
    return Score.Value;
}

落下動作はゲームオーバーまで繰り返します。ゲームオーバーを待機するとすれば、落下動作の繰り返しはゲームオーバー時にキャンセルしなければなりません。


キャンセルの実現方法ですが、今回は Unity における UniTaskAsyncEventHandler の仕組みを参考とすることにしました。

Godot における UniTask ライクライブラリの GDTask では、AsyncEventHandler は(おそらく)ポートされていないようでした。というわけで、自前で用意します。以下に折りたたみます。

AsyncEventHandler 折りたたみ

internal static class AsyncEventHandler
{
    public static AsyncEventHandler<Action> Create(Action<Action> addHandler, Action<Action> removeHandler, bool callOnce, CancellationToken cancellationToken)
        => new(addHandler, removeHandler, callOnce, cancellationToken);

    public static AsyncEventHandler<TEventHandler> Create<TEventHandler>(Action<TEventHandler> addHandler, Action<TEventHandler> removeHandler, bool callOnce, CancellationToken cancellationToken)
        where TEventHandler : Delegate
        => new(addHandler, removeHandler, callOnce, cancellationToken);

    public static ValueTask OnInvokeAsync(Action<Action> addHandler, Action<Action> removeHandler, CancellationToken cancellationToken)
        => new AsyncEventHandler<Action>(addHandler, removeHandler, true, cancellationToken).OnInvokeAsync();

    public static ValueTask OnInvokeAsync<TEventHandler>(Action<TEventHandler> addHandler, Action<TEventHandler> removeHandler, CancellationToken cancellationToken)
        where TEventHandler : Delegate
        => new AsyncEventHandler<TEventHandler>(addHandler, removeHandler, true, cancellationToken).OnInvokeAsync();
}

internal sealed class AsyncEventHandler<TEventHandler> : IValueTaskSource, IDisposable
    where TEventHandler : Delegate
{
    private static readonly Action<object?> cancellationCallback = CancellationCallback;

    private TEventHandler? handler;
    private Action<TEventHandler>? removeHandler;

    private readonly CancellationToken cancellationToken;
    private readonly CancellationTokenRegistration registration;
    private bool isDisposed = false;
    private readonly bool callOnce;

    private ManualResetValueTaskSourceCore<int> core;

    public AsyncEventHandler(Action<TEventHandler> addHandler, Action<TEventHandler> removeHandler, bool callOnce, CancellationToken cancellationToken)
    {
        this.cancellationToken = cancellationToken;
        if (cancellationToken.IsCancellationRequested)
        {
            isDisposed = true;
            return;
        }

        this.removeHandler = removeHandler;
        this.callOnce = callOnce;

        Action action = Invoke;
        handler = typeof(TEventHandler) == typeof(Action)
            ? (TEventHandler)(object)action
            : action.Method.CreateDelegate<TEventHandler>(action.Target); // reflection. some other way is preferable.
        addHandler(handler);

        if (cancellationToken.CanBeCanceled)
        {
            registration = cancellationToken.Register(cancellationCallback, this);
        }
    }

    public ValueTask OnInvokeAsync()
    {
        core.Reset();
        if (isDisposed)
        {
            TrySetException();
        }
        return new ValueTask(this, core.Version);
    }

    private bool TrySetException()
    {
        // sloppy TrySetCanceled
        if (core.GetStatus(core.Version) == ValueTaskSourceStatus.Pending)
        {
            core.SetException(new OperationCanceledException(cancellationToken));
            return true;

        }
        return false;
    }

    private void Invoke()
    {
        // sloppy TrySetResult
        if (core.GetStatus(core.Version) == ValueTaskSourceStatus.Pending)
        {
            core.SetResult(0);
        }
    }

    private static void CancellationCallback(object? state)
    {
        var self = (AsyncEventHandler<TEventHandler>)state!;
        self.Dispose();
    }

    public void Dispose()
    {
        if (isDisposed) return;

        isDisposed = true;
        registration.Dispose();
        if (removeHandler is not null)
        {
            removeHandler.Invoke(handler!);
            removeHandler = null;
            handler = null;
        }
        TrySetException();
    }

    void IValueTaskSource.GetResult(short token)
    {
        try
        {
            core.GetResult(token);
        }
        finally
        {
            if (callOnce)
            {
                Dispose();
            }
        }
    }

    ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) => core.GetStatus(token);
    void IValueTaskSource.OnCompleted(Action<object?> continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) => core.OnCompleted(continuation, state, token, flags);
}

参考とした UniTask はUnityEventを扱うのですが Godot には無い(当たり前)ので、イベントハンドラのアタッチ・デタッチを渡すかたちにしています。また、色々と姑息的な部分は見逃してください。


さて、これを利用することで、落下動作の繰り返しは以下のように書けます。
(コメントの「モモフレンズ」は、スイカゲームにおける果物だと思ってください1

[Signal]
public delegate void ReleaseEventHandler();

private async Task ReleaseLoopAsync(CancellationToken cancellationToken)
{
    var releaseEventHandler = AsyncEventHandler.Create<ReleaseEventHandler>(
        h => Release += h, // アタッチ
        h => Release -= h, // デタッチ
        false, // 単発か否か
        cancellationToken); // キャンセレーショントークン

    // キャンセルがかかるまでループ
    while(cancellationToken.IsCancellationRequested is false)
    {
        var momo = CreateMomoToDrop(); // 落とすモモフレンズの生成処理

        // リリース入力を待機
        await releaseEventHandler.OnInvokeAsync();

        ReleaseMomoToDrop(momo); // 実際に落とす処理(というか Freeze = false)
    }
}

awaitできるようにしたことで、同期的な流れで記述できました。

なお実際には、各種の処理を含めて以下のようになっています。

private async Task ReleaseLoopAsync(CancellationToken cancellationToken)
{
    var releaseEventHandler = AsyncEventHandler.Create<ReleaseEventHandler>(
        h => Release += h,
        h => Release -= h,
        false,
        cancellationToken);

    // キャンセルがかかるまでループ
    while(cancellationToken.IsCancellationRequested is false)
    {
        var momo = CreateMomoToDrop(); // 落とすモモフレンズの生成処理

        void setPosition(MouseMotionNotification v) => SetPosition(momo, v.Position);
        using (MessageBroker.Default.Subscribe<MouseMotionNotification>(setPosition))
        {
            // リリース入力を待機
            await releaseEventHandler.OnInvokeAsync();
        }
        ReleaseMomoToDrop(momo); // 実際に落とす処理(というか Freeze = false)

        // ディレイ
        if (cancellationToken.IsCancellationRequested) return;
        await Task.Delay(300, cancellationToken);
    }
}

落下させるアイテムのマウス追従は、楽をして、InputEventMouseMotionの座標を発行してそれを購読するかたちにしました。ReactiveProperty というライブラリのMessageBrokerを利用しています。


落下動作の発火は_Inputで、デッドライン到達の発火は_PhysicsProcessで行いました。

internal readonly struct MouseMotionNotification(Vector2 position)
{
    public readonly Vector2 Position = position;
}

public override void _Input(InputEvent @event)
{
    if(@event.IsActionPressed("ui_accept")
        || @event.IsActionPressed("mouse_click_left"))
    {
        EmitSignal(SignalName.Release);
    }

    if(@event is InputEventMouseMotion motion)
    {
        MessageBroker.Default.Publish<MouseMotionNotification>(new(motion.Position));
    }
}

// private Area2D deadline
public override void _PhysicsProcess(double delta)
{
    if(deadline.HasOverlappingBodies())
    {
        foreach(var body in deadline.GetOverlappingBodies())
        {
            if (body is not Momo { IsDropping: false }) continue;

            // 落下判定を受けたモモフレンズがデッドライン上にいたら発火
            EmitSignal(SignalName.ReachDead);
            break;
        }
    }
}

async/await な流れで、と銘打ちつつ_PhysicsProcessを使っています。とはいえ、すべての処理をフレームループから追い出したいわけではありません。適材適所です。

……正直に言えば今回は、「_Processレスでやりたかったな~」と思いながら泣く泣く使いました。(デッドラインとなるArea2DBodyEnteredイベントは、対象がBodyEntered ⇨ 落下判定の順でデッドライン上に留まってしまった場合に、ゲームオーバーを発火できなかった)

リザルトシーン(複数ボタンの待機)

リザルト画面では、ゲームを続けるかどうか確認します。言い換えれば、複数のボタンのいずれかが押されるまで待機します。

play again?

前項でAsyncEventHandlerを用意したので、それを利用します。ボタンくらい頻出の UI であれば、ショートハンドの拡張メソッドをつくるとよさそうです。

internal static class Extensions
{
    public static ValueTask OnPressedAsync(this BaseButton button, CancellationToken cancellationToken)
    {
        return AsyncEventHandler.OnInvokeAsync(
            h => button.Pressed += h,
            h => button.Pressed -= h,
            cancellationToken);
    }
}

これで、以下のように書けます。

public partial class ResultScene : CanvasLayer
{
    private Label scoreLabel;
    private Button okButton;
    private Button quitButton;

    public override void _Ready()
    {
        scoreLabel = GetNode<Label>("Container/ScoreLabel");
        okButton = GetNode<Button>("Container/OkButton");
        quitButton = GetNode<Button>("Container/QuitButton");
    }

    public async Task<bool> ConfirmAsync(int score)
    {
        scoreLabel.Text = $"{score} pts.";
        Show();

        using CancellationTokenSource cts = new();

        // Pressed イベントを WhenAny で待機
        int result = await ValueTaskEx.WhenAny( // ValueTaskSupplement ライブラリ利用
            okButton.OnPressedAsync(cts.Token),
            quitButton.OnPressedAsync(cts.Token));

        // キャンセル
        cts.Cancel();

        return result == 0;
    }
}

アタッチ・デタッチを外に追い出せていると、見た目がすっきりしますね。

おわりに

というわけで、async/await な流れで冒頭の apng のような落ち物ゲームをつくることができました。

色々試すためのものだったのですが、Godot のフレンドリーさもあり、とても楽しかったです。


一方、async/await アプローチには課題もありました。

まず、UniTask がない点です。UniTask は単に Task-like を提供しているだけでなく、Unity にも C# にも寄り添ったつくりになっています。ない以上、同様のことをしたいと思えば、何らかの無理が生じると感じました。

二点目として、デバッグです。これはまぁ……という感じですね。

Godot に入っては Godot に従えと現状ある流儀に従えばよい、というのもあるでしょうが、async/await の有用性は明白なのでこの方向も発展してほしいなと感じます。


最後に。Godot は不思議と触って楽しいと感じさせてくれます。これからも情報を追っていきたいです。



  1. 記事用に「アイテム」みたいな表記に変えようかと思いましたが、コードの Momo の意味が分からなくなるし、命名を変えるのも手間と思い、注釈することにしました