本記事は、Godot Engine Advent Calendar 2023 参加記事となっています。
空きのある日があったので、枯れ木も山の賑わいとばかりに参加させていただきました。不勉強な部分があるかと思いますが、よろしくお願いいたします。
以下の画像(外部サイト投稿の apng につき、表示・再生されない場合があるかもしれません)のような雰囲気の落ち物ゲームを、async/await なフローの処理でつくってみました。そのアプローチについての記事です。
(ゲーム利用の画像素材: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");
await startScene.WaitAsync();
startScene.QueueFree();
for(bool continuesToPlay = true; continuesToPlay;)
{
resultScene.Hide();
int score = await mainGame.RunAsync();
continuesToPlay = await resultScene.ConfirmAsync(score);
}
GetTree().Quit();
}
}
このゲームは、① 起動したら入力を待機する ⇨ ②メインの落ち物 ⇨ ③リザルトを表示しゲームを再度プレイするか確認する ⇨ 続けるなら再度②・続けないなら終了、という流れで構成されています。
async/await を利用することで、その流れのとおりに処理を書くことができます。また、シーン状態フラグ等を自前で管理する必要がありません。嬉しい。
というわけで、こうした await を実現するために各シーンのスクリプトを書いていきます。
Click to Start シーン(単純な入力待機)
まずは Click to Start なスタートシーン。決定アクション (ui_accept
) かマウスクリックを待機することにします。
こうした単純な待機であれば、ToSignal
をawait
すればよいでしょう。
public partial class StartScene : CanvasLayer
{
[Signal]
public delegate void ClickEventHandler();
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);
}
}
}
メインゲーム(ゲームオーバーを待機しながら落ち物)
次にメインゲームの落ち物を考えます。
今回は、デッドライン到達でのゲームオーバーでのみ、シーンが終了するとします。流れは次のようになります。
[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 における UniTask の AsyncEventHandler の仕組みを参考とすることにしました。
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);
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()
{
if (core.GetStatus(core.Version) == ValueTaskSourceStatus.Pending)
{
core.SetException(new OperationCanceledException(cancellationToken));
return true;
}
return false;
}
private void Invoke()
{
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);
}
}
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);
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));
}
}
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
レスでやりたかったな~」と思いながら泣く泣く使いました。(デッドラインとなるArea2D
のBodyEntered
イベントは、対象がBodyEntered
⇨ 落下判定の順でデッドライン上に留まってしまった場合に、ゲームオーバーを発火できなかった)
リザルトシーン(複数ボタンの待機)
リザルト画面では、ゲームを続けるかどうか確認します。言い換えれば、複数のボタンのいずれかが押されるまで待機します。
前項で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();
int result = await ValueTaskEx.WhenAny(
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 は不思議と触って楽しいと感じさせてくれます。これからも情報を追っていきたいです。