3
3

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.

ニコ生ゲームをイベント駆動で実装して、長い main 関数からサヨナラする

Last updated at Posted at 2023-09-23

はじめに

ニコ生ゲームは自作できると知ってとりあえず Akashic Engine を使ってみたものの「後からコードが読み返しづらくなってしまった」「仕様変更時に地獄を見た」という方もいらっしゃるのではないでしょうか。そんな我流でコードを書き始めた方向けに、こういうコードの設計の仕方もあるよということをご紹介したいと思います。

想定読者

  • ニコ生ゲームを Akashic Engine を用いて作成したことがある
  • とりあえずゲームを我流で作ったけれど、正直設計手法とかよく分かってない

tick ベースの設計

ゲームが開始されてから「1 フレーム経過するごとに 1 増える時刻」のことを tick と呼びます。そして tick の値に応じて処理を切り分ける実装方法を、ここでは「tick ベースの設計」と勝手に呼ぶことにします。

tickベースのコードは下記のようなものを指します。Akashic Engineのサンプルコードもこんな感じかと思います。

export function main(param: GameMainParameterObject): void {
    const scene = new g.Scene(/* 引数略 */)
    scene.onLoad.add(() => {
        let tick = 0
        scene.onUpdate.add(() => {
            if (tick === 0) {
                // ゲーム開始直後はタイトル画像を表示する
                createTitleEntities() 
            } else if (tick === 5 * g.game.fps) {
                // 5秒経過したならばタイトル画像を消してゲームを開始する
                deleteTitleEntities()
                createGameEntities()
            } else if (tick === 55 * g.game.fps) {
                // 55秒経過したならばゲームを終了画像を表示する
                deleteGameEntities()
                createEndingEntities()
            }
            tick++
        })
    })
    g.game.pushScene(scene)
}

上記のように変数 tick の値に応じて処理を実装していることが特徴です。

tick ベース設計の問題点

もちろん tick ベースの設計でもゲームを作ることができます。ただ、tick ベースの設計だと以下のような問題が発生しがちです。

  • カウントダウンなど時刻に連動した処理を実装しようと思うと、 if (tick === ...) が大量発生する。その結果…
    • if 文の条件式だけだと何の処理か直感的に分かりづらく、どこでどの処理を行っているのか後から読み返しづらい。
    • タイミングに関する仕様を変更すると、多くのコード修正が必要になる場合がある。以下のような仕様変更を何気なくすると、場合によっては血の涙を流すことも…
      • 「ゲーム開始 5秒前 からカウントダウンしていたけど、テンポよくするために 3秒前 からにしよう!」
      • 「ゲーム時間を 60秒 にしてたけど、ボリュームが増えたので 90秒 にしよう!」
  • 連続した動作から構成される処理を実装しづらい。
    • たとえば、敵を倒すと、敵が 1 秒間かけてフェードアウトする。同時にゲットしたポイントがその場にポップアップし、画面右上のスコアに吸い込まれるように移動する(いわゆるイージング)。
    • 実装すると if (... < tick && tick < ...) のような条件式が乱立しがち。その結果、以下のような仕様変更が実装しづらくなるかも…
      • 「ポイント獲得演出を敵のフェードアウトと 同時に 開始させたけど、敵がフェードアウトした にしよう!」
      • 「倒した敵をそのままフェードアウトさせていたけど、 その前に 敵が『やられた~』と苦悶するアニメーションを追加しよう!」
      • 「ゲーム終了時刻を 過ぎた後 に獲得したポイントはスコアに反映させないようにしよう!」

そして出来上がるのが読み返すのも骨が折れるメチャクチャ長い main 関数 です。

問題が発生する原因と対策方針

なぜ、こんなにも処理が長くかつ仕様変更がしづらいのでしょうか。それは、本来 何かがきっかけとなって発生する事象を、ゲームが起動されてからの時間 (tick) でコーディングしようとしているため です。ここで、「『何かがきっかけ』となる出来事」のことを イベント と呼ぶことにします。イベントを中心に考えると、ゲーム中の処理は以下のように考えられるかと思います。

  • 特定の条件をみたすとあるイベントが発生する。
    • イベントには色々な種類がある。
  • あるイベントが発生すると特定の処理が実行される。
    • 「特定の処理」には別のイベントを発生させる場合も含む

イベント駆動設計

このような、「どのような状態になったらイベントを発生させるか、そしてイベントが発生したら何を実行させるか」という観点で設計することを イベント駆動設計 と呼んだりします。

実は Akashic Engine は既にイベント駆動設計で実装されています。たとえば以下のAPIをよく使っていると思います。

  • シーンがロードされた際の処理を定義する g.Scene.onLoad.add(...)
  • 1tick発生する度に呼び出される処理を定義する g.Scene.onUpdate.add(...)
  • ボタンが押下された際の処理を定義する g.E.onPointDown.add(...)

このような処理、実は自分でも定義できるのです。 g.Trigger<T> を使います。

イベント駆動設計の実装方法

g.Trigger<T> の定義

あるイベントが必要になった場合、 const onHoge = new g.Trigger<...>() と定義します。変数名はイベントを表現する言葉にしてください。たとえば敵を倒したときなら、 onKill などです。Akashic Engine では 「on + 動詞の現在形」で統一しているので、それに合わせるのもアリです。 new g.Trigger<...>()<...>ジェネリクス と呼ばれるもので、イベントオブジェクトの型を定義できます。特にイベントの詳細がないなら new g.Trigger() で大丈夫です。

イベントを発生させる処理

イベントを発生させたいときは、g.Trigger.fire(...) を実行します。先の例だと、敵を倒した際に onKill.fire(...) を実行します。 ... の部分にはイベントに関する付加情報を入力します。たとえば以下のイメージです。

onKill.fire({
    /* 倒した敵の座標 */
    x: 100, 
    y: 200,
    /* 獲得したスコア */
    score: 100
})

付加情報がないならば onKill.fire() だけでOKです。ちなみにイベントを発生させることを イベントを発火する と言ったりもします。

イベント発生時の処理

そしてイベントを受け取ったときの処理は g.Trigger.add(...) を使って定義します。たとえば以下のイメージです。

onKill.add(ev /* ← `fire(...)` したときの付加情報(=...のオブジェクト) */ => {
    // 敵がやられたことを表現する演出
    viewEnemyKilled({ x: ev.x, y: ev.y })
    // 獲得したスコアを表示する演出
    viewScoreGetting({ x: ev.x, y: ev.y, score: ev.score })
});

このようなイベントが発生した際の処理を イベントハンドラ と呼んだりします。注意点としては、イベントが発火する前にイベントハンドラを登録しておきましょう。そうしないと、イベントが発火したのに対応する処理が実行されないというバグを生んでしまいます。

サンプルコードを書き換えてみる

では冒頭に示した tick ベースの処理を イベント駆動っぽく書き換えてみましょう。

// g.Triggerの定義
//   ゲームが起動されたとき発生するイベント
//     titlesec はタイトル画面を何秒表示させるか
//     gamesec はゲーム本編を何秒とするか
const onTitleStart = new g.Trigger<{ titlesec: number, gamesec: number }>() 
//   ゲーム本編が開始したというイベント
//      gamesec はゲーム本編を何秒とするかを格納
const onGameStart  = new g.Trigger<{ gamesec: number }>()
//   ゲーム本編が終了したというイベント
const onGameEnd    = new g.Trigger()

export function main(param: GameMainParameterObject): void {
    const scene = new g.Scene(/* 引数略 */)
    scene.onLoad.add(() => {
        // イベントハンドラの登録
        //   ゲームが起動されたとき
        onTitleStart.add(ev => {
            // タイトル画像の表示
            createTitleEntities()
            // 指定秒数経過したならば、ゲーム本編開始イベントを発火
            scene.setTimeout(() => onGameStart.fire({ gamesec: ev.gamesec }), ev.titlesec * 1000)
        })
        //   ゲーム本編が開始したとき
        onGameStart.add(ev => {
            // タイトル画像を消してゲームを開始する
            deleteTitleEntities()
            createGameEntities()
            // 指定秒数経過したならば、ゲーム終了イベントを発火
            scene.setTimeout(() => onGameEnd.fire(), ev.gamesec * 1000)
        })
        //   ゲーム本編が終了したとき
        onGameEnd.add(() => {
            // ゲームを終了画像を表示する
            deleteGameEntities()
            createEndingEntities()
        })

        // ゲーム起動イベントの発火
        onTitleStart.fire({ titlesec: 5, gamesec: 50 })
    })
    g.game.pushScene(scene)
}

イベント駆動設計の良いところ

tick ベース設計に比べてイベント駆動設計の良いところは個人的に以下のように思っています。

  • イベント発生時の処理を開始何秒後というゲーム全体の整合性をとらずに書ける。いつイベントを発生させるか、イベント発生時に何をするかという処理の記述に専念できる
  • タイミングや連続した動作の追加・削除といった仕様変更に強い。tick ベース設計だと全体の整合性チェックに時間がかかるが、イベント駆動だとイベントとイベントハンドラの追加・削除ですむ。

イベント駆動設計のデメリット

一方、良い点ばかりではありません。

イベントが増えると、実際コードがどう動くのか追跡しづらくなります。一連の処理の流れが個々のイベントハンドラの実行という形になるので、コードを追うのが大変といえば大変です。ここらへんはIDEの補完機能を駆使して乗り切るしかありません。

まとめ

伝えたかったことをまとめると以下の通りです。

  • 最初は経過時間に応じで処理を実行させる実装方針で十分
  • ただ、タイミングによって決まったり、連続した動作を実装し始めると条件文が複雑になる。
  • 更に仕様変更すると全体の整合性をとる必要があり、書き換えるのが大変
  • 仕様変更に弱い原因は、イベントの発生に起因する処理を経過時間に応じて定義しているから
  • そこで、イベントに着目してイベントの発生時に何をするかという観点でコードを設計する イベント駆動設計 に着目
  • イベント駆動設計は イベントの発火 と、イベント発火時に何をするかという イベントハンドラ の2つから主に構成
  • イベントには付加情報を付け加えることができ、イベント発火時に渡すことができる
  • 連続した動作は複数のイベントで定義することで実現できる
  • 仕様変更しても付加情報に与える値の変更や、イベントの追加削除で済む場合があり、ゲーム全体の整合性をとらずに済む
  • ただし、イベントの種類が増えると実装どのようにコードが動いていくのか追跡しづらくなる。これはIDEの補完機能を使って何とかする…
3
3
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
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?