【Rust】捕獲された自機を取り返してパワーアップするシューティングゲームを作ってみた

2020-09-17

Rustを習得したいんだけど、多少大きいプログラムじゃないとボローチェッカーなどのメリットがわからないだろう、 ということでシューティングゲームを作ってみた。 名付けて「Galangua」:

スクリーンショット

ブラウザ版をプレイ

実装の方針

元ゲームが発売されたのは約40年前ということで、できれば当時のテクノロジーで再現したい。 (実際に内部を知っているわけではないので単なる推測。)

座標の表現

敵が滑らかな曲線の軌道を描いて魅力的に動くのが特徴的。 整数で制御するとガタガタになってしまうが、当時は間違いなく浮動小数点数などというものは使えなかったのでどうするか。

座標値をある定数倍しておいて計算するという、固定小数点数を使う。 2のべき乗、とくに256倍とかにしておけば除算もビットシフトも必要なくて便利 (ただ縦は288ドットあるので、贅沢に2+1=3バイト使っていたかといわれると怪しい)。

回転

当時のスプライトに回転機能はなかったと思うので、回転のパターンを持っていたんだろうと予想。 見たところ回転パターンは24パターン、もし左右上下反転機能があったとすると7パターンで済む。

一周360度を256にすれば、8ビットのオーバーフローで数値が自動的にループできて便利。 (今回実装するにあたっては、さらに256倍して保持している。)

三角関数

敵の挙動を観察すると、移動は速度と角度で制御されているっぽい。 角度から移動量を求めるためのサイン、コサインは事前に計算して、配列参照すればよい。

問題は、特定の場所に向かう場合にどう求めるか。 隊列中の自分の位置に戻ったり自機に向かう際に、目的地への角度を計算する必要がある。 現在からすると角度を求めるには atan2 を使うものだと思うが、当時はどうしていたのか?

テーブル参照にするとしても、単純にXYそれぞれ256段階・角度を1バイトで保持すると64KBを使い切ってしまう。 各1ビット削って、0〜45度まででX > Y > 0とすると、6KB程度に抑えられるので、贅沢だけど許容範囲かな? (そもそも atan2 的に角度を求めていたのかどうか自体が不明だが…。)

自機のキャプチャ

元ゲームの最大の特徴として、敵によって自機が捕獲されて敵の一味にされてしまうが、 それを取り返すことによってパワーアップできる、というフィーチャーがある。 デュアルになると弾を2発同時に発射できて敵を倒しやすくなるが、横幅が広くなって敵にあたって死にやすくなる、 という単純なパワーアップじゃなくジレンマが含まれているのがゲーム性として面白いところだと思う。

キャプチャがあることによっていろいろ細かいフィーチャーがあって、

  • 捕獲される最中にも弾を撃てて、撃ち落とすと脱出できる
  • キャプチャを引き連れての攻撃中に撃ち落とすと取り返せるが、隊列内にいるときに撃ち落とすと寝返ったままになる
  • 寝返った自機が攻撃してきたのを撃ち落とさずに画面外に消えるとそのステージで再登場はせず、次のステージで再度キャプチャ済みとして登場する

このへんは地道に実装するしかない。

登場の軌道

敵が隊列を組んだ後の攻撃の軌道は速度と角度で動かしているっぽいが、登場時の軌道はどうもそうではないっぽい。 カーブする前に加速したり、2列で並んで飛ぶ場合には外側の動きが遅れたり、単純な速度や角度の操作には見えない。 またステージが進むと速度が速くなる、といったこともある。

登場の軌道をテーブルで持っているという可能性もある気がするが、目コピでは実装方法を推測できなかった…。 チャレンジングステージではさらにいろいろなバリエーションがあるので、そのへんも謎だ…。

敵の攻撃順の制御

隊列がそろった後、どの敵が攻撃してくるかという制御が必要になる。 同時に2,3組攻撃してくるようなんだけど、こちらも観察してみたが法則がわからなかった…。

Rustでの実装方法

C++などでプログラミングする場合と比べて、Rust特有に発生する実装方法など。

弾を発射するにはどうするか?

自機などのゲーム内のオブジェクトは struct で必要な情報を保持していて、 impl で関連するメソッドを実装することでオブジェクト指向っぽく実装することができる。 更新時に移動で座標を変更する場合には自分のフィールドをいじればよいので特に問題はない。 しかし弾を撃とうとしたとたんに借用の問題が発生する。

ゲームを管理するゲームマネージャ構造体が自機や自機が発射する弾を所有・管理していて、自機の更新呼び出し時にミュータブル参照する。 そうするとゲームマネージャ自体がミュータブル借用済み扱いになり変更不可能になってしまうので、弾を追加することができなくなってしまう。 C++などの場合にはゲームマネージャのメソッド呼び出しのような形で処理すると思うが、Rustではそういうことができない。

そこでどうしたかというと、直接弾を発生させるのではなく、イベントキューというものを渡してそこにいったん書き込むようにした。 他にもスコア加算・エフェクト発生・自機死亡など、自分だけでは収まらない処理を(すべてではないが)イベントキューで処理するようにした。

ゲーム情報の受け渡しをどうするか?

敵の動作として、自機の方向に向かうなど敵自身以外の情報も必要になるがどうするか。 ゲームマネージャをシングルトンにすることもできず、かといって必要な情報を更新メソッドに受け渡すのも大変なので、以前書いたように unsafe で秘密裏に参照を取得して、トレイトとして渡してアクセスするようにした (状態管理や相互参照をどうするか )。

行儀がよろしくはないが、安全を考慮してオーバーエンジニアリングするよりもまずは実現することが大事だ、と嘯いて納得することにする。

Wasm版

普通にアプリをビルドすればデスクトップ上で実行することができるが、独自にビルドしてもらうまたはバイナリ配布+インストールしてもらうのは非常にハードルが高いことと思う。 ブラウザ上でも動かせたら手軽に動作を見せることができて便利。

RustのコードはあるOS用の実行ファイルにだけじゃなくて、ブラウザ上で動くWasm形式にもビルドすることができる (過去記事:【Rust】非同期処理でクロージャをうまく使う方法はあるんだろうか…)。

スタンドアロン版ではグラフィクス表示にSDL2を使用している。 emscriptenでSDL2自体もブラウザ上で動かすことができるのかどうかよく知らないんだけどあまりそういう大掛かりなことをする自信がないので、 ブラウザ版では描画ライブラリを切り替えることにした。 描画用のトレイトを用意して、アプリ側のコードではトレイト経由で呼び出すようにする。

ジェネリック境界 を使用すると、 コード自体はトレイトに依存するだけで、トレイトを実装する実際の型に直接依存せずにすみ、しかも静的にリンクされるという利点がある。 これがゼロコスト抽象化か、すごい…。

Rustはゲームプログラミングに向いているか?

Rustでゲームを作ってみて実際どうだったか? 個人的な意見だけど、あまり向いてないのではないか?と感じた (これが私があまりよくRustを理解してなくてうまい書き方を知らないからで、もっとうまい方法があるようなら知りたい)。

Rustの利点は散々色んな所で吹聴されていると思うので、デメリットと感じたところだけを列挙する。

参照を保持しておけない

借用の問題で、他のオブジェクトの参照を保持しておくというようなことができない。 Rc<RefCell<T>> として保持すればできるが、これは実行時にコストがかかるし記述がメンドイし、ちょっとやりすぎと思ってしまう。

クロージャが使えない

クロージャも借用の関係で使うのが難しい。 呼び出されるコールバックで借用を借りっぱなしにできないので、結果を反映させられないので実質使えない。

シングルトンが(気楽には)使えない

実際には使えないことはないが MutexRwLock でくくって、アクセス時にロックが必要になる。

クラス・継承方式のOOPからの転換が必要

C++などのクラス・継承方式のOOPにどっぷり慣れ親しんでいる身からすると、敵のベースクラスを用意して、敵の種類ごとにオーバーライドして…と考えるんだけど、 Rustの trait - impl 方式だと違うイディオムが必要な気がする (どういう方法が有効なのかよくわかってない)。

共通部分をくくりだしてコンポジションするにも委譲の呼び出しコードを書かなきゃいけないので記述がかさみ、 また特殊化ができないのでコードの重複が発生する。

unsafe使用の代償

機能実装のためには unsafe も躊躇なく使うと割り切ることにしたが、 一部でも使用すると完全に信頼できるコードパスがほとんどなくなるので、果たしてRustで書く意味あるんだろうかという疑問が湧いてくる。

数値型のキャストが面倒

数値型でも同じ型同士じゃないとエラーになるため、 usizeu32 などの型変換が必要で面倒。

締め

私のようなヌルグラマはGCつきの言語で高速化や安全性など気にせずにスパゲティを量産するのが性に合ってるな、と感じた。

元ゲームは滑らかな動きが謎でまさにオーパーツ。

参照