📑

ゲームづくりの経験の後書き

2023/05/21に公開

はじめに

私は、ゲームエンジンを趣味で作っています。まだ当たり判定とジャンプモーションとキー移動するだけのもので、作る事自体を始めてから4年程度かかりました。

ここではゲームエンジンの作成時に気をつけていたことや、発見、失敗と次にやりたいことを書いていきます。


ゲーム開発画面のgif

ゲームエンジンgithubのリポジトリ: https://github.com/kamiyamaeiji/game_engine_prototype

プログラミングのやり方

抜ける分岐をなくす

リアルタイム処理において、一番ネックだったのが通常なら抜けないような分岐で抜けていく処理があった。当たり判定を判定するのは毎時0.1秒のような単位でオブジェクトが当たっているかどうかを判定します。すると、RailsやDjangoで扱うようなウェブサイトでは求められないような速さで判定をします。

例えば、単純に当たり判定をつけたとします。ゲームオブジェクトはゲームでいう岩や木のようなオブジェクトで、当たり判定の関数をcolliderとします。

function game_life_cicle () {
    own_object = ${自機オブジェクトの要素}
    game_objects = [${ゲームオブジェクトの要素}, ${ゲームオブジェクトの要素} ...]
    for item of game_objects {
        if (collider(own_object, item) ) {
            ${game_life_cicleから抜ける}
        } else {
            ${game_life_cicleを続行する}
        }
    }
}

上記は、ゲームのライフサイクルを簡単にしたコードです。上記はown_objectがgame_objectのどれかに当たったらゲームを終了させる処理を書いています。
ウェブアプリのプログラミングの場合、このようなプログラムを組んでも高負荷をかけなければほぼ必ず正常に動くはずです。ですが、これがコンマ秒の間で判定するときに、own_objectとgame_objectsの間が狭まったり、game_objectsの数が多くなると当たってもないのに抜けることがあります。

これは私が経験したことで、どのように計算結果が狂ったのかはまだ詳しいことはわかっていないことをご容赦していただきたいです。この経験からわかることは、game_objectsすべての要素の当たり判定を取ってから当たったかどうかの判定をするべきでした。

計算途中の結果をもとに当たったかどうかを判定すると、計算時点の判定が全てとなってしまうので抜けると考えることができます。途中で計算結果がずれることがあります。無理やり計算している最中に計算を終了してしまって正しい結果を待たずに決定して結果的に計算が狂うことはあります。

これを避けるためには下記のようなコードを書きます。

function game_life_cicle () {

    // 変数宣言
    own_object = ${自機オブジェクトの要素}
    game_objects = [${ゲームオブジェクトの要素}, ${ゲームオブジェクトの要素} ...]
    collider_result = []

    // 当たり判定の結果を格納
    for item of game_objects {
        if (collider(own_object, item) ) {
            collider_result.push(true)
        } else {
            collider_result.push(false)
        }
    }

    // 当たっている判定があるかを検出
    if (collider_result.some{(item) => item === true}) {
        ${game_life_cicleから抜ける}
    } else {
        ${game_life_cicleを続行する}
    }
}

上記のコードは当たり判定の結果をcollider_resultに入れて、collider_resulttrueが入っているかで判定します。

やっていることは前のの処理と同じですが、抜けるタイミングが違います。前の処理はすべてのgame_objectの結果を見ずに、途中で当たり判定を検知したらゲームを終了させる処理でした。今回はすべての計算結果をみた結果、当たっている判定があったら終了させる方法を取っています。

この処理にした結果、own_objectがgame_objectsに当たったら当たり判定が発火します。基本的には計算結果をすべて算出した状態で判定をすればずれなく判定を取ることが可能になります。

「反復」でひとまとめにする

プログラミングの塊をfor文を一つの要素として捉えるのも効果的です。プログラミングにはさまざまなまとめ方があります。if文で一回分けて考えたり、関数として扱えそうな部分で分けたり、または一つのデータ群として分けたりすることはできます。一つの関数で一つ一つのプログラミングの単位を分けるの方法は、for文のような反復を一つの塊としてまとめています。

これは構造化プログラミングのまとめ方を参考にしています。構造化プログラミングは「順序」、「選択」、「反復」で構成された考えたです。「順序」は上から順番に処理をしていくことを指しています。「選択」はif文で分岐をすることをさしています。「反復」はfor文のように繰り返し行う処理を指しています。

私はこの中で「反復」を一つの単位として捉えています。その単位の捉え方は下記のようにしています。

1. 反復を一つの塊として扱う。forに含まれていない選択も一つの塊として捉える。
2. 反復内では基本的にif文は1回しか使わない。
3. 性能に影響が出ない限り、多次元配列の二重ループは許可する。

上記の単位で捉えると、毎コンマ秒の処理をするときにデバッグがしやすくなります。当たり判定を作っているときに、webアプリでよくやる多少なりとも非効率でもいいから見通しのいいコードを書いていました。webアプリだとよほどの要件がないかぎりは少し効率が悪いから動かなくはなりません。ですが、ゲームだとこのように見通しのいいコードが必ずしも動くコードとは限らないのです。

反復を単位にすることで、コード設計時に見通しは良くなります。ゲームでは毎コンマ秒で判定を取る必要があるのですが、そのときに判定を取らなければなりません。そこで、見通しをよくするために上記のと単位でプログラムを組めば途中で間違っていたとしてもコード上でどこで間違っているかの判定も取りやすくなります。

複雑な処理は作らない

複雑な処理というのはfor文の中に別の処理のfor文を埋め込んだり、当たり判定を取りつつアニメーションの処理を書く、といったような処理は書かないようにします。

コードの見通しが悪くなり、コード自体がテストコードで正常に動いていたとしてもどこかで処理速度がネックになったせいで動かないことがあります。

これを防ぐためには、アニメーションはアニメーションだけ、当たり判定は当たり判定だけを行わせる。そうすることでアニメーションの問題と当たり判定の問題を分割することできます。さらに、当たり判定の処理の中でも、反復をひとまとめにすることでどこで効率の悪い処理になっているかがわかりやすくなります。

進め方

small start quick winで行う

small start quick winは「『アポロ13』に学ぶITサービスマネジメント ~映画を観るだけでITILの実践方法がわかる!」に書かれている言葉です。この本由来かどうかは定かではありません。この言葉は「早く初めて、すぐに結果をだす」という意味です。

ゲームを作成するときは非常に大きな目標があって進めるものです。RPGを作ると言っても、ドラクエのようなRPGか、FFのようなRPGを想像して頭で大きな想像を広げるはずです。ですが、工数的にも時間的にも明日からすぐにすべてを変えることは難しいです。一つ一つを進めるためには様々なことを実現する必要があります。絵の用意はもちろん、ゲームプログラムから音楽、またはストーリーを準備する必要があります。

明日からすぐにできるわけではありませんが、すぐに結果を出せる範囲を絞って作ることは可能です。

私の場合は、マリオのようなゲームを作りたくて、そのためにゲームの当たり判定を作成するところから始めました。そのほうが結果を出しやすいと思ったからです。

もちろん、すぐにできるという目論見は外れやすいです。ですが、外れたとしてもそこからすぐにできることを探して、いい結果も悪い結果もすぐに出す。そうすることで不明確なプロジェクトでも進めることができるはずです。

機能を分割する

機能の分割は作りたいものを一旦分解するところから始めます。マリオのようなアクションゲームを作りたいなら、その機能一回分解します。分解すると「当たり判定」、「アニメーション」、「描画」という分け方ができるはずです。いっかいこのように分割をします。

分割をしたらそこでどこが一番ボトルネックになりそうかを考えます。ここは人それぞれなので自分の尺度で決めてください。私の場合はアクションゲームの性質上、当たり判定が一番ボトルネックだと考えました。当たり判定がなければ、アニメーションもただ壁をすり抜けて動くだけになってしまいますし、描画は使うライブラリ、フレームワークで備わっているので無視する。

機能の実装をしているときに新しい問題が出てきたら、そこで足を止めてその問題に切り替えるかどうかを決めます。例えば、当たり判定の作成中に速度の問題がネックになるのであれば一回仕切り直して注力する。別の新しい問題が出てきて、さほども当たり判定に関わっていなくて、あとに戻してもいいのであればメモを取って後回しにする。

その都度その都度計画を少しづつ変更してゴールに向かう。こうすることで、未知の問題に取り組むハードルが低くなるはずです。

アーキテクチャ

私は、このゲームエンジンの設計にatomic designとfluxアーキテクチャを採用しています。

atomic designは整理された考え方で、ゲームにおける処理の整理に役立つと思い採用しました。fluxアーキテクチャはゲームのライフサイクルを表現するのに参考にしました。

atomic designについて

atomic designはatoms, molecules, organisms, template, pagesと5層に分けて管理する手法です。atomsはどこにも非依存していて、moleculesはatomsのみに依存しています。あとのorganisms, templates, pagesもmoleculesに再帰した依存関係になっています。

上記のようなアーキテクチャを作れば、依存関係が一方通行になり処理が明確になり、どこでどんな処理が走っているのかが明確になります。何も方針を決めずに書けばどこでどの処理が走っているのかは書いている人しかわからなくなるリスクがあったので私の中で一番明確なアーキテクチャを採用しました。

失敗しそうになったこと

できてきて、方針転換しそうになった。

実はこのゲームエンジンちょっと失敗しそうになりました。それは、このエンジンが基本操作ができてきたときに私が欲をかいてしまって、ゲームエンジンそっちのけでゲームの構想をつくってしまったことがありました。

これを止めてくれたのはこれを聞いた友人でした。これを話したときに「それってやりたいことか外れていない?」と言われてはっと目が覚めました。

勢いに乗っているときには、こういうことが多々あるので耳を塞がずにきちんと聞いて軌道修正することも重要です。

今後やりたいこと

fluxのようなライフサイクルの導入

今ライフサイクルのような設計はしていますが、まだ実装レベルに至っていないのが現状です。

ライフサイクルは各段階で何をして、どのようなことをするのかを明確にしているものです。ゲームならどのタイミングで当たり判定を発火させるのか、描画のタイミングはどこで発火するのかをフロー図のようにします。


vueのライフサイクル

vueのライフサイクルはどの時点でどのイベントが発火するのかを明確にされています。見慣れない人が見たら、これをどう使うのかがわからないでしょうけれども、理解したらどこで何が起こっているのかがわかるようになります。

私のゲームエンジンも一応は作っていますが、急ごしらえで明確な設計レベルに至っていません。作りながら考えていこうと思っています。

tailwind cssの思想の導入

tailwind cssの思想を導入することで作りやすさ高めていこうとも思っています。

tailwind cssは色の指定や四角形の形に至るまでクラス化されています。例えばbg-red-600なら背景が赤色のものを出力しています。

これのメリットは覚えてしまえば迷わずに使えることです。背景を赤くする```bg-red-600``は背景がbg、色がred, 色の強さが600というように使える値を指定しています。tailwind cssはそもそも色の指定を制限しています。

これらはノーマルCSSを使っている人からだと自由度が足りない、覚えるのが面倒という意見があるそうです。Tailwind CSSとそのCSS設計思想とはの「CSSを書ける人ほど書きにくい?」でもCSSを使い慣れている人からは面倒という意見が出ていました。

しかし、初心者から見ればどちらを覚えるのも覚えるコストは変わりません。少なくとも、私がtailwind cssを使ってみた感想は画面をきれいにする知識がなくても自分が描きたいサイトがかけました。

参考資料

Discussion