0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ゲーム処理とUI処理の共存検討、その2

Last updated at Posted at 2024-01-08

背景

前回の検討内容では先に定期更新処理だけがあって後からUI処理を追加するという発想をしていた。しかし実際に実用的な量のUI処理を追加してみると処理の大半がUI処理と言えるほどに量が膨れ上がってしまった。一方でもともと大きな割合を占めていた定期更新処理、例えばキャラクターアクション操作処理はあまり増えず相対的に規模が縮小することになった。こうした状況の変化を踏まえると定期更新処理が設計の主体であるという暗黙の前提が誤りに思えてきた。そこで今回はUI処理が主、定期更新処理が従という設計について検討することにした。

変更点:イベントハンドラを主、定期更新処理を従へと入れ替える

前回記事では定期更新処理 OnEnterFrame メソッドが常時動作し、それが主となってイベントハンドラを切り替えることでUI処理の起動制御をしていた。今回はこの主従を逆転させてイベントハンドラ処理 OnEvent メソッドが常時動き、必要に応じて定期更新処理を一時停止、再開するという構造に変えてみる。

この考えを適用したコードを下記に示す。機能自体は前回記事のコードと同じで、キャラクター移動操作中にメニューボタンを押すことでメインメニュー操作に移る、メニュー操作中はゲーム進行が止まる、メニュー操作が完了したならキャラクター移動操作に戻るといったもの。

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

        var remainMs = GetRemainTime();
        if (GameLoopEnabled && (remainMs <= 0))
        {
            // 定期更新処理
            OnEnterFrame();
            ResetRemainTime();
        }
        Thread.Sleep(1);
    }
    ...
}

// 定期更新処理
void OnEnterFrame()
{
    // キャラクター定期更新処理。ゲーム処理。
    UpdateCharacters();

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

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

// キャラクター定期更新処理。ゲーム処理。
void UpdateCharacters()
{
    // 停止中
    if (!UpdateCharactersInActionIsEnabled)
    {
        return;
    }
    // 実行中
    else
    {
        // キー押下状態読み取り
        byte[] statKeys = ReadKeys();

        // キー操作に応じてプレイヤーキャラクター位置操作やジャンプ
        UpdatePlayerCharacter(statKeys);

        // 敵キャラクター行動:近づいて攻撃
        UpdateEnemyCharacters();

        // キャラクターアニメーション状態更新
        UpdateCharacterAnimations();
    }
}

// 一番上のイベントハンドラ処理。
void OnEvent(Event ev)
{
    // 常に有効な処理
    OnEventAlways(ev);

    // キャラクター行動中
    if (State == StateEnum.CharacterInAction)
    {
        OnEventCharacterInAction(ev);
    }
    // メインメニュー操作中
    else if (State == StateEnum.MainMenu)
    {
        OnEventMainMenu(ev);
    }
    // メニュー閉じるアニメーション完了待ち
    else if (State == StateEnum.WaitingToCloseMenu)
    {
        OnEventWaitingToCloseMenu(ev);
    }
    // アイテムリスト操作中
    else if (State == StateEnum.ItemList)
    {
        OnEventItemList(ev);
    }
    // スタータス画面表示中
    else if (State == StateEnum.Status)
    {
        OnEventStatus(ev);
    }
    ...
}

// 常に有効な処理
void OnEventAlways(Event ev)
{
    if (ev.Type == EventEnum.AppQuit)
    {
        ...
    }
}

// キャラクター行動操作中
void OnEventCharacterInAction(Event ev)
{
    // メインメニューを開くキーが押された
    if (EnabledOpenMainMenu &&
        (ev.Type == EventType.KeyDown) &&
        (ev.KeyDown.KeySym == 'e'))
    {
        // ゲーム処理を一時停止
        UpdateCharactersInActionIsEnabled = false;
        CloseHud();

        // メインメニューを開く
        OpenMainMenu();
        State = StateEnum.MainMenu;
    }
}

// メインメニュー表示中
void OnEventMainMenu(Event ev)
{
    if (ev.Type == EventType.KeyDown)
    {
        // 閉じるボタンが押された
        if (ev.KeyDown.KeyCode == Key.x)
        {
            // メインメニューを閉じる
            CloseMainMenu();
            State = StateEnum.WaitingToCloseMenu;

            // 100ミリ秒後にアニメーション完了通知イベント発生。
            SetTimer(100, () =>
            {
                PostEvent(new Event(EventType.UserEventAnimationEnd));
            });
        }
        // 決定ボタンが押された
        else if (ev.KeyDown.KeyCode == Key.z)
        {
            // メニューカーソルが指すのはアイテムリスト表示
            if (MenuSelectingCursor == 0)
            {
                // メインメニューを閉じる
                CloseMainMenu();

                // アイテムリストを開く
                OpenItemList();
                State = StateEnum.ItemList;
            }
            // メニューカーソルが指すのはステータス画面表示
            else if (MenuSelectingCursor == 1)
            {
                // メインメニューを閉じる
                CloseMainMenu();

                // キャラクターステータス画面を開く
                OpenCharacterStatusPage();
                State = StateEnum.Status;
            }
        }
        // メインメニューのカーソルキー操作かもしれないイベント
        else
        {
            // メインメニューのカーソル操作
            UpdateMainMenuCursor(ev);
        }
    }
}

// メインメニューのカーソル操作
void UpdateMainMenuCursor(Event ev)
{
    if (ev.Type == EventType.KeyDown)
    {
        // 上カーソルキー
        if (ev.KeyDown.KeyCode == Key.ArrowUp)
        {
            MenuSelectingCursor -= 1;
            if (MenuSelectingCursor < 0)
            {
                MenuSelectingCursor = 0;
            }
        }
        // 下カーソルキー
        else if (ev.KeyDown.KeyCode == Key.ArrowDown)
        ...
}

// メニューが閉じる際のアニメーション再生の完了を待っている
void OnEventWaitingToCloseMenu(Event ev)
{
    if (ev.Type == EventType.UserEventAnimationEnd)
    {
        // ゲーム処理再開
        OpenHud();
        State = StateEnum.CharacterInAction;
        UpdateCharactersInActionIsEnabled = true;
    }
}

// アイテムリスト表示中
void OnEventItemList(Event ev)
{
    if (ev.Type == EventEnum.KeyDown)
    {
        // 閉じるボタンが押された
        if (ev.KeyDown.KeyCode == Key.x)
        {
            // アイテムリストを閉じる
            CloseItemList();

            // メインメニューに戻る。
            OpenMainMenu();
            State = StateEnum.MainMenu;
        }
        // 決定ボタンが押された
        else if (ev.KeyDown.KeyCode == Key.z)
        {
            // 選択中のアイテムを使用する
            if (CurrentSelectingItemId.HasValue)
            {
                // アイテムリストを閉じる
                CloseItemList();

                // メニューを閉じる
                State = StateEnum.WaitingToCloseMenu;

                // アイテムを使用する。アニメーション再生も行う。
                PlayerUseItem(CurrentSelectingItemId.Value);

                // 3000ミリ秒後にアニメーション完了通知イベント発生。
                SetTimer(3000, () =>
                {
                    PostEvent(new Event(EventType.UserEventAnimationEnd));
                });
            }
        }
        // アイテム選択操作かもしれない
        else
        {
            // アイテム選択カーソル操作
            UpdateItemListCursor(ev);
        }
    }
}

// アイテム選択カーソル操作
void UpdateItemListCursor()
{
    // 上下左右カーソルキーで項目選択。
    ...
}

// キャラクターステータス画面表示中
void OnEventStatus(Event ev)
{
    if (ev.Type == EventEnum.KeyDown)
    {
        // 閉じるボタンが押された
        if (ev.KeyDown.KeyCode == Key.x)
        {
            // ステータス画面を閉じる
            CloseStatus();

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

前回記事よりもUI処理が書きやすくなったように思う。効果がありそうな変更点を挙げる。

主な変更点:

  • 定期更新処理 OnEnterFrame メソッド中でイベントハンドラを切り替えていたのを廃止、OnEvent メソッド中でのみ行うように変えた。
  • 定期更新処理中のゲーム進行を一時停止できる機能を設けた。イベントハンドラ処理から操作可能。
  • 定期更新処理中かあら状態遷移制御を排した。代わりにイベントハンドラ処理がそれを担う。
  • イベントハンドラ処理中で時間経過を扱うためにタイマーとPostEvent(イベント手動生成関数)を利用した。
    • イベントハンドラ処理から定期更新処理への依存を避けるために経過フレーム数を数える方法は不採用。ただしタイマーと同程度に十分抽象化されたならありかもしれない。

イベントハンドラ処理切り替え方法の検討

前章では制御主体を定期更新処理からイベントハンドラ処理へと変えてもプログラム全体を成立させられそうなこと、ある程度以上の規模のUIを実装する場合はそうした方が仕組みを単純化できそうなことが確認できた。

その後さらにUI処理を追加しているとネストしたメニュー構造であったりYes/Noダイアログであったりが欲しくなった。しかし前章のコードを前提にするとダイアログを利用するたびにOnEventメソッドに条件文を追加する必要があり手間がかかる。Yes/Noダイアログなどのコンポーネントインスタンスを作ったら自動でイベントを受け付けて結果を受け取れると手間が減ってうれしい。この実現にはイベントハンドラの切り替えが必要と考えられるので検討する。

案1,状態変数とswitch文

状態変数とswitch文によって状態遷移を実装することで処理を切り替える。全ての状態遷移をswitch文に書き出せるほど小規模ならこれで対応できる。GUIコンポーネントには対応できないはず。

案2,現在有効なイベントハンドラ1つを変数で保持する

有効化したいイベントハンドラ関数のポインタを保持する変数を用意し、それを書き換えることで処理を切り替える。前回の記事で検討した。小規模なら対応できるがGUIコンポーネントには対応できないはず。複数のGUIコンポーネントを設置したら最後のどれか1つだけが有効になって他が無効になる。

案3,現在有効なイベントハンドラ複数をリストで保持する

複数のイベントハンドラを保持できるリスト変数を用意する。イベント受信時にはそのリストに登録されたものをすべて呼ぶ。ループ処理最中にもリスト書き換えがありえるため、リストを2つ用意して適宜コピーして使う。登録されたハンドラをすべてを呼ぶ都合上、同時にリストに保持されるイベントハンドラは競合しないイベントを待ち受ける使い方が適している。もし同一のイベントを2つ以上のイベントハンドラ関数が受け付けると登録順番によって振る舞いが変わるわかりづらい不具合の原因になる。

複数のGUIコンポーネント設置にも対応できそう。ただしYes/Noダイアログを設置した場合に他コンポーネントを無効化するといった使い方には対応できないように思える。

案4,現在有効なイベントハンドラ複数をスタックで保持する

複数のイベントハンドラを保持できるスタック型変数を用意する。イベント受信時にはそのリストに登録されたものの中から一番上のハンドラだけを呼びだす。Yes/No/Cancelダイアログを実装する場合に役立ちそうな気がする。特にキャンセルを押した場合に。しかしYesを押した場合にはメニュー自体をまとめて消す場合もあったりすること、スタックの後ろから2個目を登録抹消したい場合に対応しづらそうなどの懸念点もある。用途は限定的かもしれない。

案5,現在有効なイベントハンドラ複数を複数のリストで保持する

方法3をベースに、スタックのような機能を持たせるためにリストを複数用意する。優先度によってリストを使い分ける。普段は低優先度リストを使い、Yes/Noダイアログでは高優先度リストを使う。イベントディスパッチャはまず高優先度リストを参照して1つでも登録物があればそちらを呼び出す。登録物無しなら低優先度リストを呼び出す。

複数のGUIコンポーネント設置にも対応できそうだし、Yes/Noダイアログを設置した場合に他コンポーネントを無効化するといった使い方にも対応できる。登録順番を無視して登録抹消することもできる。大きな問題はなさそう。

コード例:

LinkedList<Action> AlwaysEventHandlerList;
LinkedList<Action> HighPriorityEventHandlerList;
LinkedList<Action> LowPriorityEventHandlerList;

void Init()
{
    AlwaysEventHandlerList.AddLast(OnEventAlways);
    LowPriorityEventHandlerList.Add(OnEventCharacterInAction);
}

// 一番上のイベントハンドラ処理。
void OnEvent(Event ev)
{
    // 常に有効な処理
    if (AlwaysEventHandlerList.Count > 0)
    {
        var eventHandlerList = AlwaysEventHandlerList.ToArray();
        foreach (var eventHandler in eventHandlerList)
        {
            eventHandler(ev);
        }
    }

    // 高優先度な処理
    if (HighPriorityEventHandlerList.Count > 0)
    {
        var eventHandlerList = HighPriorityEventHandlerList.ToArray();
        foreach (var eventHandler in eventHandlerList)
        {
            eventHandler(ev);
        }
    }
    // 低優先度な処理
    else if (LowPriorityEventHandlerList.Count > 0)
    {
        var eventHandlerList = LowPriorityEventHandlerList.ToArray();
        foreach (var eventHandler in eventHandlerList)
        {
            eventHandler(ev);
        }
    }
}

void OpenDialog()
{
    ...
    // ダイアログを開く
    HighPriorityEventHandlerList.AddLast(OnEventDialog);
    ...
}

案6,現在有効なイベントハンドラ複数を優先度付きリストで保持する

機能としては方法5と同じ。方法5は優先度の数だけリスト変数定義が必要で優先度がたくさん増えた場合に手間がかかる。対策として優先度を解釈できるリストを用意する。たとえば LinkedList<(int, LinkedList<Action<Event>>)> といったメンバ変数を持ったコレクションクラスを用意する。

案7,イベントハンドラに有効無効フラグを付ける

方法3と同様に一番上のイベントハンドラリストは1つだけ用意する。そこには有効無効フラグ付きのイベントハンドラを登録する。フラグが無効ならそのイベントハンドラは呼ばれない。ダイアログが出ている間はその他のGUIコンポーネントは無効フラグを返すことでいっせいに入力受付を停止できる。

有効無効フラグ付きのイベントハンドラコード例:

public interface IEnableEventHandler
{
    bool IsEnabled { get; }
    Action<Event> EventHandler { get; }
}

public class ButtonEnableEventHandler : IEnableEventHandler
{
    ...
    public bool IsEnabled
    {
        get
        {
            if (GuiRoot.HasDialog)
            {
                return false;
            }
            return true;
        }
    }
    ...
}

定期更新処理切り替え方法の検討

UI処理を追加していくとちょっとしたアニメーションをさせたくなる。1つ2つならタイマーで自前実装もできなくはないができれば既存アニメーションクラスを利用したい。またアニメーション再生のGUIコンポーネントを作ったら自動でアニメーション更新処理が定期更新処理から実行されるようにしたい。

案1,アニメーション専用のリストを用意する

UI処理のためのものなのでゲーム処理が一時停止中であっても動く必要がある。このためキャラクター定期更新処理とは別に更新関数を呼ぶようにする。

コード例:

LinkedList<Animation> AnimationList;

// 定期更新処理
void OnEnterFrame()
{
    // UI向けアニメーション更新
    UpdateUiAnimations();

    // キャラクター定期更新処理。ゲーム処理。
    UpdateCharacters();

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

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

// UI向けアニメーション更新
void UpdateUiAnimations()
{
    foreach (var anim in AnimationList)
    {
        anim.Update();
    }
}

案2,汎用の定期更新処理リストを用意する

アニメーションクラスそのものではなくActionを登録する。これにより定期更新処理からアニメーションクラスのUI向け更新処理への依存を避ける。

LinkedList<Action> UpdateListBeforeUpdateCharacters;

// 定期更新処理
void OnEnterFrame()
{
    // Updateの前のUpdate.
    UpdateBeforeUpdateCharacters();

    // キャラクター定期更新処理。ゲーム処理。
    UpdateCharacters();

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

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

// UpdateCharactersの前のUpdate.
void UpdateBeforeUpdateCharacters()
{
    foreach (var update in UpdateListBeforeUpdateCharacters)
    {
        update();
    }
}

void StartUiAnimationTest()
{
    var anim = new Animation(...);
    UpdateListBeforeUpdateCharacters.AddLast(anim.Update);
    ...
}

案3,実行順序指定が可能な有効無効フラグ付き定期更新処理リストを用意する

実行順序指定が可能な定期更新処理リストを1つ用意する。そこには有効無効フラグ付きの定期更新処理を登録する。フラグが無効ならその処理は実行されない。実行順序をたくさん分けたい場合向け。

LinkedList<(int, INodeUpdate)> UpdateList;

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

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

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

// 定期更新処理。
void Update()
{
    foreach (var node in UpdateList.OrderBy(x => x.Item1).Select(x => x.Item2))
    {
        if (node.IsEnabled)
        {
            node.Update();
        }
    }
}

...
TreeNodeUpdate CharactersUpdateList;

void Init()
{
    // キャラクター定期更新処理。
    AddUpdate(CharactersUpdateList, UpdateBeforeUpdateCharacters);
    CharactersUpdateList.IsEnabled = true;
    AddUpdate(UpdateList, CharactersUpdateList, 20);
    ...
}

// キャラクター定期更新処理。ゲーム処理。
void UpdateCharacters()
{
    // キー押下状態読み取り
    byte[] statKeys = ReadKeys();

    // キー操作に応じてプレイヤーキャラクター位置操作やジャンプ
    UpdatePlayerCharacter(statKeys);

    // 敵キャラクター行動:近づいて攻撃
    UpdateEnemyCharacters();

    // キャラクターアニメーション状態更新
    UpdateCharacterAnimations();
}

void StartUiAnimationTest()
{
    // キャラクター定期更新処理停止。
    CharactersUpdateList.IsEnabled = false;
...
    // UI向けアニメーション更新処理。
    var treeNode = new TreeNodeUpdate();
    var anim = new Animation(...);
    treeNode.Add(anim.Update);
    treeNode.IsEnabled = true;
    AddUpdate(UpdateList, treeNode, executionOrder: 10);
    ...
}

...
public interface IEnableUpdate
{
    bool IsEnabled { get; }
    Action Update { get; }
}

public class TreeNodeUpdate : IEnableUpdate
{
    private LinkedList<INodeUpdate> UpdateList { get; set; }

    public bool IsEnabled { get;set; }
    public Action Update
    {
        get
        {
            return () =>
            {
                foreach (var node in UpdateList.ToArray())
                {
                    if (node.IsEnabled)
                    {
                        node.Update();
                    }
                }
            };
        }
    }
    ...
}
0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?