LoginSignup
0
1

ゲーム処理とUI処理の共存検討、その3 ~async/awaitで書きやすさ改善~

Last updated at Posted at 2024-04-27

検討内容

前回記事ではUI処理を主に、定期更新処理を従にする考え方によって設計を単純化した。しかしまだコードに書きにくさが残っている。主原因はイベント待機のためのコールバック関数や状態変数で、これらがコード記述を飛び飛びにさせてしまっている。対策として今回はC#のasync/awaitを導入して待機処理記述の単純化を検討する。

※これまでの記事はC言語のような基本的な構文のみの言語でも書けたが今回の記事ではC#依存を許容した。理由は個人的に今後Cでゲームを作る機会がなさそうなこと、Cでも結局スクリプトを導入してawaitと似たことをやること、async/awaitは良いものだと思ったこと等。

目標:awaitでUIメニュー操作完了を待つ

目標は下記のようなコードが書けるようになること。例えばメインメニューの選択結果が決まるまでその場で待機、結果を受け取ったら次の行へと動き出すという処理をawaitを使って var menuResult = await WaitMainMenuSelection(); と書けるようにしたい。

async/await対応後の目標コード:

async Task MainMenu()
{
    ...
    // メインメニューを開く。
    OpenMainMenu();

    while (true)
    {
        // メインメニューで項目が選択されるまで待つ。
        var menuResult = await WaitMainMenuSelection();

        // メインメニューで「キャラクターステータス」が選択された。
        if (menuResult == MainMenuResultEnum.CharacterStatus)
        {
            // キャラクターステータスウィンドウを開く
            OpenCharacterStatusWindow();

            // キャラクターステータスウィンドウが閉じられるまで待つ。
            await ReceiveEventOfCharacterStatusWindow();

            // キャラクターステータスウィンドウを閉じる
            CloseCharacterStatusWindow();
        }
        // メインメニューで「インベントリ」が選択された。
        else if (menuResult == MainMenuResultEnum.Inventory)
        {
            // インベントリウィンドウを開く
            OpenInventoryWindow();

            // インベントリウィンドウで結果が決まるまで待つ。
            var result = await ReceiveEventOfInventoryWindow();

            // インベントリで「アイテム使用」が選択された。
            if (result == InventoryResultEnum.UseItem)
            {
                var itemId = GetSelectedItemId();
                gamePlayerCommand.UseItem(itemId);

                // インベントリウィンドウを閉じる。
                CloseInventoryWindow();

                // メインメニューを閉じる
                CloseMainMenu();

                // タスクを終える。
                break;
            }
            // インベントリで「閉じる」が実行された。
            else if (result == InventoryResultEnum.Close)
            {
                // インベントリウィンドウを閉じる。
                CloseInventoryWindow();
            }
            ...
        }
        ...
        // メインメニューで「閉じる」が実行された。
        else if (menuResult == MainMenuResultEnum.Close)
        {
            // メインメニューを閉じる
            CloseMainMenu();

            // タスクを終える。
            break;
        }
        ...
    }
    ...
}

このコードに向けて対応方法や問題点とその解決策を1つ1つ検討していく。

待機の検討

await前のコード

従来通りawait無しでキー押下イベント待機処理を実装すると次のようなコードになる。状態変数 State を読み書きすることで進行を制御している。このコードをベースに検討していく。

await無しでの待機記述例:

...
void MainLoop()
{
     while (true)
    {
        ...
        while (PollEvent(out var ev))
        {
            OnEvent(ev);
        }
        ...
}

// 一番上のイベントハンドラ処理。
void OnEvent(Event ev)
{
    ...
    // メインメニュー操作中
    else if (State == StateEnum.MainMenu)
    {
        OnEventMainMenu(ev);
    }
    ...
    // スタータス画面表示中
    else if (State == StateEnum.CharacterStatus)
    {
        OnEventCharacterStatusWindow(ev);
    }
    ...
}

// メインメニュー操作中のイベントハンドラ。
void OnEventMainMenu(Event ev)
{
    if (ev.Type == EventEnum.KeyDown)
    {
        // 上矢印キーが押された
        if (ev.KeyDown.KeyCode == KeyCode.ArrowUp)
        {
            menuCursor -= 1;
            if (menuCursor < 0)
            {
                menuCursor = 0;
            }
        }
        // 下矢印キーが押された
        else if (ev.KeyDown.KeyCode == KeyCode.ArrowDown)
        {
            menuCursor += 1;
            if (menuCursor >= menuSize)
            {
                menuCursor = menuSize - 1;
            }
        }
        // 決定キーが押された
        else if (ev.KeyDown.KeyCode == KeyCode.z)
        {
            if (menuCursor == 0)
            {
                // インベントリを開く
                OpenInventoryWindow();
                State = StateEnum.InventoryWindow;
            }
            else if (menuCursor == 1)
            {
                // キャラクターステータスを開く
                OpenCharacterStatusWindow();
                State = StateEnum.CharacterStatusWindow;
            }
            ...
        }
        // キャンセルキーが押された
        else if (ev.KeyDown.KeyCode == KeyCode.x)
        {
            // メインメニューを閉じる。
            CloseMainMenu();
            State = StateEnum.MainMenuClosed;
        }
        ...
    }
}

// キャラクターステータス画面表示中のイベントハンドラ
void OnEventCharacterStatusWindow(Event ev)
{
    if (ev.Type == EventEnum.KeyDown)
    {
        // 閉じるキーが押された
        if (ev.KeyDown.KeyCode == Key.x)
        {
            // キャラクターステータスウィンドウを閉じる
            CloseCharacterStatusWindow();

            // メインメニューに戻る。
            State = StateEnum.MainMenu;
        }
    }
}

分岐の無い待機

まずは待機後の分岐が無い単純なものから考える。await無しのイベントハンドラを使ったイベント待機処理では次の流れだった。

  1. イベントハンドラ起動まで待機。
  2. イベントを引数経由で受け取る。
  3. イベントがキー押下かつ目的のキーであるか判定。違うなら待機に戻る。
  4. 状態変数書き換えにより状態遷移。
  5. 次の状態用の処理が起動する。

async/await対応による単純化後を考えると次の流れなら成立しそうに思える。

  1. イベント受け取り用のasyncメソッド完了までawaitで待機
  2. イベントをawaitの戻り値で受け取る
  3. イベントがキー押下かつ目的のキーであるか判定。違うなら待機に戻る。
  4. asyncメソッド完了によってawaitが終わる。
  5. 次の状態用の処理が起動する。

awaitは2つ必要そう。この方向性でコードを書いて確かめてみる。

awaitを使ったイベント待機実装:

...
async Task SomeTask()
{
    ...
    // キャラクターステータスウィンドウが閉じられるまで待つ。
    await ReceiveEventOfCharacterStatusWindow();

    // キャラクターステータスウィンドウを閉じる
    CloseCharacterStatusWindow();
    ...
}
...

// キャラクターステータスウィンドウが閉じられるまで待つ。
async Task ReceiveEventOfCharacterStatusWindow()
{
    while (true)
    {
        // どうにかしてイベントを受け取る。
        var ev = await WaitEvent();

        // 閉じるキー押下判定
        bool pressed = IsEventPressedCloseKey(ev);
        if (pressed)
        {
            // 押されていたら呼び出し元に戻る。
            return;
        }
    }
}

// 閉じるキーが押されたか判定する。
bool IsEventPressedCloseKey(Event ev)
{
    if (ev.Type == EventEnum.KeyDown)
    {
        // 閉じるキーが押された
        if (ev.KeyDown.KeyCode == KeyCode.x)
        {
            return true;
        }
    }
    return false;
}

// イベントを受け取るまで待つ。
async Task<Event> WaitEvent()
{
    ...
}

awaitできるイベント取り出し関数 WaitEvent の中身が不明瞭だが他は問題なく成立しそうに思える。

WaitEvent自体の検討はすこし後回しにしてWaitEvent利用側の検討を続ける。

分岐のある待機

次はawait後に分岐のある処理としてメニュー操作処理を考える。仕様は上下カーソルキーで項目を切り替え、zキーで決定、xキーでキャンセル。async前のイベントハンドラ利用実装だと次の流れになる。

  1. イベントハンドラ起動まで待機。
  2. イベントを引数経由で受け取る。
  3. イベントがキー押下かつ目的のキーであるか判定。
    1. カーソルキーなら現在選択中の項目番号を更新して待機に戻る。
    2. 無関係なイベントなら待機に戻る。
    3. 決定キーなら現在選択中の項目番号に応じて状態変数を書き換え、待機を終える。
  4. 次の状態用の処理が起動する。

この流れで困るのは状態変数の書き換えを遷移元で行うこと。このために遷移前時点で次の遷移先が確定していないといけなくなり、処理流用がしにくい原因になっていた。async/await対応によってこの問題も解消したい。冒頭の目標コードのようにいったん選択結果だけを返して、次の遷移先状態を呼び出し元で変えられるようになるとうれしい。検討したところ次の流れなら実現できる。

  1. イベント受け取り用のasyncメソッド完了までawaitで待機
  2. イベントをawaitの戻り値で受け取る
  3. イベントがキー押下かつ目的のキーであるか判定。
    1. カーソルキーなら現在選択中の項目番号を更新して待機に戻る。
    2. 無関係なイベントなら待機に戻る。
    3. 決定キーなら現在選択中の項目番号を返して待機を終える。
  4. asyncメソッド完了によってawaitが終わる。戻り値で現在選択中の項目番号を受け取る。
  5. 項目番号に応じて次の処理を決める。
  6. 次の状態用の処理が起動する。

コードを書いてみる。

...
async Task SomeTask()
{
    ...
    // メインメニューの選択結果が出るまで待つ。
    var mainMenuResult = await ReceiveEventOfMainMenu();

    // メインメニュー選択結果による分岐1
    if (mainMenuResult == MainMenuResultEnum.Inventory)
    {
        // インベントリを開く
        OpenInventoryWindow();

        // インベントリ操作中
        var inventoryResult = await ReceiveEventOfInventoryWindow();

        // インベントリ操作結果に従って次の処理を決定する。
        if (inventoryResult == ...)
        {
            ...
    }
    // メインメニュー選択結果による分岐2
    else if (mainMenuResult == MainMenuResultEnum.CharacterStatus)
    {
        // キャラクターステータスを開く
        OpenCharacterStatusWindow();

        // キャラクターステータスウィンドウが閉じられるまで待つ。
        await ReceiveEventOfCharacterStatusWindow();

        // キャラクターステータスウィンドウを閉じる
        CloseCharacterStatusWindow();
    }
    // メインメニュー選択結果による分岐3
    else if (mainMenuResult == MainMenuResultEnum.Closed)
    {
        // メインメニューを閉じる。
        CloseMainMenu();
        return;
    }
    ...
}

// メインメニューで選択結果が決まるまで待つ。
async Task<MainMenuResultEnum> ReceiveEventOfMainMenu()
{
    while (true)
    {
        // どうにかしてイベントを受け取る。
        var ev = await WaitEvent();

        // キャンセルキーが押された
        if (IsKeyDownEvent(ev, KeyCode.x))
        {
            return MainMenuResultEnum.Closed;
        }
        // 上矢印キーが押された
        else if (IsKeyDownEvent(ev, KeyCode.ArrowUp))
        {
            menuCursor -= 1;
            if (menuCursor < 0)
            {
                menuCursor = 0;
            }
        }
        // 下矢印キーが押された
        else if (IsKeyDownEvent(ev, KeyCode.ArrowDown))
        {
            menuCursor += 1;
            if (menuCursor >= menuSize)
            {
                menuCursor = menuSize - 1;
            }
        }
        // 決定キーが押された
        else if (IsKeyDownEvent(ev, KeyCode.z))
        {
            if (menuCursor == 0)
            {
                return MainMenuResultEnum.Inventory;
            }
            else if (menuCursor == 1)
            {
                return MainMenuResultEnum.CharacterStatus;
            }
            ...
        }
    }
}

bool IsKeyDownEvent(Event ev, KeyCode keycode)
{
    return (ev.Type == EventEnum.KeyDown) &&
        (ev.KeyDown.KeyCode == keycode);
}

// インベントリ操作中
async Task<InventoryResultEnum> ReceiveEventOfInventoryWindow()
{
    while (true)
    {
        // どうにかしてイベントを受け取る。
        var ev = await WaitEvent();

        // キャンセルキーが押された
        if (IsKeyDownEvent(ev, KeyCode.x))
        {
            return InventoryResultEnum.Closed;
        }
        // 上矢印キーが押された
        else if (IsKeyDownEvent(ev, KeyCode.ArrowUp))
        {
            menuCursor -= 1;
            if (menuCursor < 0)
            {
                menuCursor = 0;
            }
        }
        // 下矢印キーが押された
        else if (IsKeyDownEvent(ev, KeyCode.ArrowDown))
        {
            menuCursor += 1;
            if (menuCursor >= menuSize)
            {
                menuCursor = menuSize - 1;
            }
        }
        // 決定キーが押された
        else if (IsKeyDownEvent(ev, KeyCode.z))
        {
            // Yes/Noダイアログを出してYesが選ばれた時のみアイテムを使う。
            var yesNo = await WaitYesNoDialog();
            if (yesNo)
            {
                return InventoryResultEnum.UseItem;
            }
        }
    }
}

asyncメソッドの戻り値としてEnumを返すことで結果を返し、呼び出し元でその結果に従って遷移先状態を切り替える。無理なく実現できている。遷移先情報を遷移元に持たせなくて済むようになったためコードを流用しやすくなった。

ReceiveEventOfInventoryWindow 中でYes/Noダイアログ表示を var yesNo = await WaitYesNoDialog(); として書いた。これの内部ではまた別途WaitEventを使う必要があるはずで、こうした使い方でも問題ないようWaitEvent検討時に注意する。

複数のasyncメソッド起動

複数のasyncメソッドを起動してそれぞれでWaitEventを呼べるようにしたい。想定用途は常駐する補助的な機能で、PrintScreenキーを押したらスクリーンショットを撮る、開発中だけデバッグ操作をキーに割り当てるといったもの。それらはゲームキャラクター操作やメニュー操作といった本筋のものとは分けて記述したい(下記コード)。

void InitFunc()
{
    ...
    // 複数のasyncメソッドを起動する。
    var t1 = SomeTaskA();
    var t2 = SomeTaskB();
    ...
}

async Task SomeTaskA()
{
    ...
    // メインメニューの選択結果が出るまで待つ。
    var mainMenuResult = await ReceiveEventOfMainMenu();
    ...
}

async Task SomeTaskB()
{
    while (true)
    {
        // どうにかしてイベントを受け取る。
        var ev = await WaitEvent();

        // スクリーンショットキー押下判定
        if (IsKeyDownEvent(ev, KeyCode.PrintScreen))
        {
            TakeScreenShot();
        }
        ...
    }
}

WaitEvent検討時にはこれが実現可能なようにする。

イベント取り出し関数WaitEvent

WaitEventの中身について検討する。

案、PollEvent関数直接呼出し

まずは単純に大本の PollEvent を直接呼んでみる。

直接PollEventを呼ぶコード:

...
// イベントを受け取る。
var ev = await WaitEvent();

// 閉じるキー押下判定
bool pressed = IsEventPressedCloseKey(ev);
...

// イベントを受け取るまで待つ。
async Task<Event> WaitEvent()
{
    while (true)
    {
        // メインスレッドへ切り替える。
        await mainThreadQueue.SwitchTo();

        // イベントを取り出す。
        if (PollEvent(out var ev))
        {
            return ev;
        }

        // すこし待つ。
        await Task.Delay(1);
    }
}

単独で呼ぶ分にはイベント受け取りできそうに思える。asyncメソッドをネストしてそれぞれでWaitEventを呼んでもおそらく対応できる。しかし2つ以上のasyncメソッドを起動すると正常に動かなくなるはず。どちらかのasyncメソッドがイベントを取り出したなら他のasyncメソッドではそのイベントを認識できなくなるため。また1ミリ秒毎にメインスレッドに割り込むタスクを複数起動するのは性能面で悪影響がありそうに思える。

※メインスレッドへの切り替え

PollEvent相当の関数はたいてい呼び出し元スレッド制限がある。このためPollEventの前に await mainThreadQueue.SwitchTo(); を入れて動作スレッドをメインスレッドへ切り替えている。スレッド切り替えについては他の記事で紹介しているためここでは詳しくは触れない。

※SynchronizationContextがnull

この記事中では前提として SynchronizationContext がnullになっている。つまり Task.ConfigureAwait(false) をつけなくてもTaskのawaitから返ってきた時点で動作スレッドが不定になる。理由は私が趣味で開発中のプログラムがコンソールプロジェクトで始まったためで、SDL&OpenGL実装後も特に困っていないのでそのままになっている。

案、イベントハンドラの完了を1つずつ待つ

await無しでのイベントハンドラ実装では1つずつ完了待機するために同時に実行されるものは1つだけだった。これをベースに考えてみる。

PollEventの呼び出し箇所は1か所に限定してそこからよそへ配る。イベントの配布はイベントハンドラ関数の呼び出しで実現する。イベントハンドラはリスト管理されてそこに追加削除することで動的にイベント配布先を変更できる。イベントハンドラ処理呼び出しはで1つずつ実行完了を待ってから次の呼び出しに移る。

コードを書くと次のようになる。await無しでのイベントハンドラ呼び出しを単純にasync/await対応させただけなので見覚えのあるコードフロー。

LinkedList<Func<Event, Task>> EventHandlerList;

async Task MainLoop()
{
    ...
     while (EnabledEventLoop)
    {
        ...
        // メインスレッドへ切り替える。PollEvent用。
        await mainThreadQueue.SwitchTo();

        while (PollEvent(out var ev))
        {
            // 他スレッドへ切り替える。イベントハンドラがメインスレッドを使うかもしれないので。
            await Task.Yield();

            // イベントハンドラを呼び出してその完了を待つ。
            await OnEvent(ev);

            // メインスレッドへ切り替える。PollEvent用。
            await mainThreadQueue.SwitchTo();
        }
        ...
}

// 一番上のイベントハンドラ処理。
async Task OnEvent(Event ev)
{
    // 登録リストを複製する。イベントハンドラ呼び出し中にリストが追加削除されることがありえるため。
    Func<Event, Task>[] copiedList;
    lock (this.EventHandlerList)
    {
        copiedList = this.EventHandlerList.ToArray();
    }

    // PollEventで取得したイベントを渡す。
    foreach (var eventHandler in copiedList)
    {
        // 1つずつイベントハンドラー関数の完了を待つ。
        await eventHandler(ev);
    }
}

案、イベントハンドラ関数内で WaitEvent へ橋渡し

イベントの抜け漏れ対策としてイベントハンドラ関数呼び出しが必要になった。しかしイベント利用側のコードではawaitで待ちたい。イベントハンドラと await WaitEvent(); の間をつなげる必要がある。下記のようにキューを通してやりとりすれば動くように思える。

  1. イベントハンドラがキューにイベントを入れる
  2. イベントハンドラからWaitEventへキュー追加を通知する。
  3. イベントハンドラは await eventHandler(ev); で待機を始める。
  4. var ev = await WaitEvent(); が完了する。
  5. イベントの取り扱いが完了したことをイベントハンドラに通知する。
  6. await eventHandler(ev); が完了する。

コードを書いて確かめてみる。

// イベントを1つだけ受け渡しできる。
// イベント受け渡し通知とイベントハンドラ完了通知をそれぞれawaitで待機できる。
public class EventRegister : IDisposable
{
    // ロック用オブジェクト。
    object LockObj = new object();

    // キューの代わり。最大1つまでしか要素追加されないので変数1つで済ませた。
    Event CurrentValue;

    // 値を読み出し中。
    bool Reading;

    // 値をセットした通知。awaitで待機できるフラグとして利用している。
    SemaphoreSlim SemSet = new SemaphoreSlim(0);

    // 値を消去した通知。awaitで待機できるフラグとして利用している。
    SemaphoreSlim SemClear   = new SemaphoreSlim(0);

    // イベントハンドラ登録リストから登録解除するためのハンドル。
    Action Handle;

    // 破棄済みフラグ。
    bool IsDisposed;

    // イベント受け取り開始。
    public EventRegister(LinkedList<Func<Event, Task>> eventHandlerList)
    {
        // 引数のイベントハンドラ登録リスト操作。
        // 本当はリスト直接操作よりもクラス化したりinterfaceを通した方が良い。
        lock (eventHandlerList)
        {
            var node = this.EventHandlerList.AddLast(async (ev) =>
            {
                await this.SetAndWaitClear(ev);
            });
            this.Handle = () =>
            {
                lock (eventHandlerList)
                {
                    this.EventHandlerList.Remove(node);
                }
            };
        }
    }

    // イベントをセットし、消去されるまで待つ。
    public async Task SetAndWaitClear(Event ev)
    {
        lock (this.LockObj)
        {
            // 破棄済みなら処理せず飛ばす。
            if (this.IsDisposed)
            {
                return;
            }
            // 値は消去済みのはず
            if (this.CurrentValue != null)
            {
                throw new Exception();
            }
            // 値は読み込み中ではないはず
            if (this.Reading)
            {
                throw new Exception();
            }
            // 値の消去通知は無いはず
            if (this.SemClear.CurrentCount != 0)
            {
                throw new Exception();
            }

            // 値をセットする。
            this.CurrentValue = ev;

            // 値をセットしたことを通知。
            this.SemSet.Release(1);
        }

        // セットした値が消去されるまで待つ。
        await this.SemClear.WaitAsync();
    }

    // セットされたイベントを取り出す。セットされていないならセットされるまで待つ。
    public async Task<Event> WaitEvent()
    {
        lock (this.LockObj)
        {
            // 破棄済みなら利用不可。
            if (this.IsDisposed)
            {
                throw new ObjectDisposedException(GetType().FullName);
            }

            // セットされた値があり、まだ読み込み中でもないならいったんWaitAsyncを呼ぶ。
            if ((this.CurrentValue != null) &&
                (!this.Reading))
            {
                // 何もしない。
            }
            // セットされた値があり、すでに読み込み中なら取り出し完了。
            else if ((this.CurrentValue != null) &&
                this.Reading)
            {
                this.CurrentValue = null;
                this.Reading = false;

                // 値を消去したことを通知する。
                this.SemClear.Release(1);
            }
            // セットされた値が無いならイベント未発生かつ初回WaitEvent呼び出しのはず。
            else
            {
                // 何もしない。
            }
        }

        // 値がセットされるまで待つ。
        await this.SemSet.WaitAsync();

        // セットされた値を返す。
        lock (this.LockObj)
        {
            this.Reading = true;
            return this.CurrentValue.Value;
        }
    }

    // イベント受け取りをやめる。
    public void Dispose()
    {
        lock (this.LockObj)
        {
            if (this.IsDisposed)
            {
                return;
            }
            this.IsDisposed = true;
            this.CurrentValue = null;
            this.Reading = false;
            this.SemClear.Release(1);
            this.SemClear.Dispose();
            this.SemSet.Dispose();
        }

        this.Handle.Invoke();
    }
}
...
void InitFunc()
{
    ...
    // 複数のasyncメソッドを並列動作させる。
    var t1 = SomeTaskA();
    var t2 = SomeTaskB();
    var t3 = SomeTaskC();
    ...
}

async Task SomeTaskA()
{
    ...
    // メインメニューの選択結果が出るまで待つ。
    var mainMenuResult = await ReceiveEventOfMainMenu();
    if (...)
    ...
    
    else if (mainMenuResult == MainMenuResultEnum.CharacterStatus)
    {
        ...
        // キャラクターステータスウィンドウが閉じられるまで待つ。
        await ReceiveEventOfCharacterStatusWindow();
    ...
}

// キャラクターステータスウィンドウが閉じられるまで待つ。
async Task ReceiveEventOfCharacterStatusWindow()
{
    // イベント受け取り開始。
    using var reg = new EventRegister(this.EventHandlerList);
    while (true)
    {
        // イベント受け取り待ち。
        var ev = await reg.WaitEvent();

        // 閉じるキー押下判定
        bool pressed = IsEventPressedCloseKey(ev);
        if (pressed)
        {
            // 押されていたら呼び出し元に戻る。
            return;
        }
    }
}

async Task SomeTaskB()
{
    // イベント受け取り開始。
    using var reg = new EventRegister(this.EventHandlerList);
    while (...)
    {
        // イベント受け取り待ち。
        var ev = await reg.WaitEvent();

        // スクリーンショットキー押下判定
        bool pressed = IsEventPressedScreenShotKey(ev);
        if (pressed)
        {
            TakeScreenShot();
        }
        ...
}

async Task SomeTaskC()
{
    // イベント受け取り開始。
    using var reg = new EventRegister(this.EventHandlerList);
    while (...)
    {
        ...
        // イベント取得。
        var ev = await reg.WaitEvent();

        // OSウィンドウ閉じる要求判定。
        if (ev.Type == EventEnum.AppQuit)
        {
            ...
}

動作しそうに思える。

※追加のコード例: https://github.com/tosh-coding/AsyncFiberWorks/blob/main/src/AsyncFiberWorks/Procedures/AsyncRegisterOfT.cs
 EventRegisterをジェネリクス型に変えて汎用化した。

WaitEvent利用中のネスト

EventRegisterの利用中はWaitEventを呼ぶ必要がある。そうしないとOnEventが詰まってしまう。これはたとえばメニュー処理の最中にYes/Noダイアログの入力結果を待機するような場合に問題になる。

プログラムが止まってしまう例:

// インベントリ操作中
async Task<InventoryResultEnum> ReceiveEventOfInventoryWindow()
{
    // ★イベント受け取り開始。1回目。
    using var reg = new EventRegister(this.EventHandlerList);
    while (true)
    {
        // イベント取得。
        // ★WaitEventが呼ばれないとそこでOnEventは止まる。
        var ev = await reg.WaitEvent();

        // キャンセルキーが押された
        if (IsKeyDownEvent(ev, KeyCode.x))
        ...
        // 決定キーが押された
        else if (IsKeyDownEvent(ev, KeyCode.z))
        {
            // Yes/Noダイアログを出してYesが選ばれた時のみアイテムを使う。
            // ★Yes/Noダイアログ内部でもイベントを取得する。
            var yesNo = await WaitYesNoDialog();
            if (yesNo)
            {
                return InventoryResultEnum.UseItem;
            }
        }
    }
}

// Yes/Noダイアログ表示中
async Task<bool> WaitYesNoDialog()
{
    using var dialog = OpenYesNoDialog();
    dialog.Show();

    // ★イベント受け取り開始。2回目。
    using var reg = new EventRegister(this.EventHandlerList);
    // ★ここでループに入ってしまった。
    while (true)
    {
        // イベント取得。
        // ★呼び出し元のWaitEventが呼ばれずこのWaitEventも返らない。結果プログラム進行が止まる。
        var ev = await reg.WaitEvent();

        // Yesが押された
        if (...)
        {
            return true;
        }
        // Noが押された
        else if (...)
        {
            return false;
        }
    }
}

WaitEventを必ず呼ぶためにはEventRegisterを新たに作らずに引数渡しするのが手っ取り早い。

// インベントリ操作中
async Task<InventoryResultEnum> ReceiveEventOfInventoryWindow()
{
    // ★イベント受け取り開始。
    using var reg = new EventRegister(this.EventHandlerList);
    while (true)
    {
        // イベント取得。
        // ★WaitEventが呼ばれないとそこでOnEventは止まる。
        var ev = await reg.WaitEvent();

        // キャンセルキーが押された
        if (IsKeyDownEvent(ev, KeyCode.x))
        ...
        // 決定キーが押された
        else if (IsKeyDownEvent(ev, KeyCode.z))
        {
            // Yes/Noダイアログを出してYesが選ばれた時のみアイテムを使う。
            // ★Yes/Noダイアログ内部でもイベントを取得する。
            // ★WaitEvent呼び出しを止めないために既存のEventRegisterを渡す。
            var yesNo = await WaitYesNoDialog(reg);
            if (yesNo)
            {
                return InventoryResultEnum.UseItem;
            }
        }
    }
}

// Yes/Noダイアログ表示中
// ★引数でEventRegisterを受け取る。
async Task<bool> WaitYesNoDialog(EventRegister reg)
{
    using var dialog = OpenYesNoDialog();
    dialog.Show();

    while (true)
    {
        // イベント取得。
        // ★呼び出し元のEventRegisterのWaitEventを呼ぶ。
        var ev = await reg.WaitEvent();
        ...
    }
}

これでWaitEventを使うasyncメソッドをネストさせても大丈夫になった。

他の方法も考えられる。読み捨てるためだけのasyncメソッドを起動してEventRegisterを渡しておく、EventRegister自体に読み捨てモードを追加する、EventRegisterをいったんDisposeして必要になったらまた新たに作るなど。今回は全体の成立性確認を優先するのでそれらには触れない。

イベント取り出し待機のキャンセル

awaitによるイベント取り出し待機を中断させたい場合にはCancellationTokenが利用できる。これは一般的なC# async/awaitで使われる仕組みそのまま。キャンセルすると例外OperationCanceledExceptionを発生させてawaitが終わる(下記コード)。

// イベントを1つだけ保持できる。
// イベント受け取り待ちとイベント利用完了待ちがawaitでできる。
public class EventRegister : IDisposable
{
    ...
    // セットされたイベントを取り出す。セットされていないならセットされるまで待つ。
    public async Task<Event> WaitEvent(CancellationToken cancelToken = default)
    {
        ...
        // イベントがセットされていないならセットされるまで待つ。
        // cancelTokenがキャンセル状態になるとここで例外OperationCanceledExceptionが発生する。
        await this.SemSet.WaitAsync(cancelToken);
        ...

void InitFunc()
{
    ...
    // キャンセル用ハンドルを作成。
    var cts = new CancellationTokenSource();

    // 複数のasyncメソッドを並列動作させる。
    var t1 = FuncA(cts.Token);
    var t2 = FuncB(cts.Token);
    var t3 = FuncC(cts.Token);
    ...
    // キャンセルする。
    cts.Cancel();
    ...
}

async Task FuncA(CancellationToken cancelToken)
{
    // イベント受け取り開始。
    using var reg = new EventRegister(this.EventHandlerList);
    while (...)
    {
        // イベント取得。
        // cancelTokenがキャンセル状態になるとここで例外が発生してFuncA自体も終了する。
        // その際にusing var regによって作成済みEventRegisterインスタンスのDisposeが
        // 呼ばれて、イベントハンドラから登録解除される。
        var ev = await reg.WaitEvent(cancelToken);
        ...

例外はキャッチすれば呼び出し元に伝搬させないこともできる(下記コード)。

// キャラクターステータスウィンドウが閉じられるまで待つ。
async Task ReceiveEventOfCharacterStatusWindow(CancellationToken cancelToken)
{
    // イベント受け取り開始。
    using var reg = new EventRegister(this.EventHandlerList);
    while (true)
    {
        // イベント受け取り待ち。
        Event ev;
        try
        {
            ev = await reg.WaitEvent(cancelToken);
        }
        // キャンセルされたら呼び出し元に戻る。
        catch (OperationCanceledException operationCanceled)
        {
            return;
        }

        // 閉じるキー押下判定
        bool pressed = IsEventPressedCloseKey(ev);
        if (pressed)
        {
            // 押されていたら呼び出し元に戻る。
            return;
        }
    }
}

基本的にキャンセルを使うのはアプリ終了時だけの想定。一度キャンセルしたEventRegisterは通知フラグ操作まわりがどうなるか現状未検討なので再利用は危険なはず。いったんDisposeして新たにnewしなおす方が安全だと考える。

ここまでで冒頭の目標コードを実現するために必要な個別要素は最低限出そろった。

ゲームループのasync/await対応

これまでイベントハンドラについてasync/await対応を検討してきたが、そこにゲームプログラムのもう1つの柱である定期更新処理を混ぜるとどうなるか確認する。まずはawait無しでのコードを挙げる。

await無しで同期型メソッドだけで構成されたゲームループのコード:

void MainLoop()
{
    ...
    while (...)
    {
        // イベント取得。
        while (PollEvent(out var ev))
        {
            // イベントハンドラ処理
            OnEvent(ev);
        }

        // ゲームループの次フレームまでの残り時間を求める。
        var remainMs = GetTimeRemainingUntilNextFrame();

        // 残り時間が0になったら定期処理実行。
        if (GameLoopEnabled && (remainMs <= 0))
        {
            // 定期更新処理
            OnEnterFrame();

            // 残り時間更新。
            ResetRemainTime();
        }

        // 何も処理が無かった場合に備えてちょっと休む。
        Thread.Sleep(1);
    }
    ...
}

// 定期更新処理
void OnEnterFrame()
{
    // 定期更新処理。
    Update();

    // GUI描画部品の更新
    UpdateGUIComponentAnimations();

    // グラフィクスリソースハンドルのフラッシュとレンダリング
    FlushHandleAndRender();
}

1つのループ中でPollEventと定期更新処理呼び出しを記述する。ゲームライブラリSDLのサイトにサンプルコードとして載っているくらいにありふれた形のコード。

定期更新処理OnEnterFrameについては現状書きにくさを感じていないこと、そもそも同期処理の方が向いている気がすることからそのままにしておく。イベントハンドラ回りについて前章までの検討内容を反映してみる(下記コード)。

async/await対応版のゲームループのコード:

async Task MainLoop()
{
    InitFunc();

     while (...)
    {
        // メインスレッドへ切り替える。
        await mainThreadQueue.SwitchTo();

        // イベント取得。
        while (PollEvent(out var ev))
        {
            // イベントハンドラ処理
            await OnEvent(ev);

            // メインスレッドへ切り替える。
            await mainThreadQueue.SwitchTo();
        }

        // ゲームループの次フレームまでの残り時間を求める。
        var remainMs = GetTimeRemainingUntilNextFrame();

        // メインスレッドへ切り替える。
        await mainThreadQueue.SwitchTo();

        // 残り時間が0になったら定期処理実行。
        if (GameLoopEnabled && (remainMs <= 0))
        {
            // 定期更新処理
            OnEnterFrame();

            // 残り時間更新。
            ResetRemainTime();
        }

        // 何も処理が無かった場合に備えてちょっと休む。
        await Task.Delay(1);
    }
    ...
}

// 初期化処理
void InitFunc()
{
    ...
    // 複数のasyncメソッドを並列動作させる。
    var t1 = SomeTaskA();
    var t2 = SomeTaskB();
    var t3 = SomeTaskC();
    ...
}

// 定期更新処理
void OnEnterFrame()
{
    // 定期更新処理。
    Update();

    // GUI描画部品の更新
    UpdateGUIComponentAnimations();

    // グラフィクスリソースハンドルのフラッシュとレンダリング
    FlushHandleAndRender();
}

全体的にも成立しそうに思える。

最大の変化はInitFuncで起動しているasyncメソッド=SomeTaskA/B/Cの存在。進行制御処理のコードにawaitを使うことができるようになり書きやすさを改善できた。

async/await導入の効能

ゲームプログラムにasync/awaitを導入することでコードが書きやすくなることを確認できた。進行制御のプログラム記述を飛び飛びにさせるイベント待機がawaitに置き換わって格段に楽になる。

async/awaitはやれることが多くメッセージパッシング的な使い方もできてしまうが、やりたいことはイベントハンドラと状態変数操作による分岐実装の排除だという意識を強く持てば最小限の機能だけでも便利に利用できる。C#依存になるが趣味ゲームフレームワークにも取り入れてよい機構だと感じた。

過去の記事

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1