Jetpack Composeでスイカゲームは再現できる?

Advent Calendar 2023, Android, Jetpack Compose

この記事は レコチョク Advent Calendar 2023 の23日目の記事となります。

株式会社レコチョクでAndroidアプリ開発グループに所属している木村です。

今年、レコチョクはMaker Faire Tokyo 2023に出展しました。
様々な分野で「ものづくり」をしているメイカーの方々とお話しさせていただき、とても刺激を受けました。この機会に何か音楽に関するものづくりをやってみたいと思い、最近自作エフェクターについて勉強し始めています。

はじめに

2023年に流行ったゲームといえば、スイカゲームを思い浮かべる方も多いのではないでしょうか。シンプルなルールにも関わらず、高得点を出すのが難しいことと、ゲームオーバー後のリトライが簡単であるため、やめるタイミングを見失うほどの中毒性があり、非常に人気となっています。

同じく今年はChatGPTをはじめ生成AIが大幅に進歩を遂げ、もはやエンジニアの必須スキルとなりつつあります。

また、これらを組み合わせ生成AIを使ってスイカゲームを作ることが一部界隈で盛り上がっており、

ChatGPTにスイカゲームを作らせる方法【ずんだもん解説】という動画は4.6万再生を記録しています。

折角なので流行りに乗って自分もスイカゲームを作ってみようと思ったのですが、単純に作るだけなら既に知見が世に溢れている状態なので、今回はJetpack Composeを用いてスイカゲームを再現することができるか検証してみることにしました。

Jetpack Composeとは

Androidアプリ開発のための最新のUIツールキット。

宣言的UIフレームワークを採用しており、従来のビューベースのUI実装に比べて少ないコード量で直感的にUIを実装できることから、現在Android公式で推奨されている技術となっています。

目的

ゲーム用途で用いられることはあまり一般的ではないJetpack Composeを用いて、物理演算表現が必要なスイカゲームを再現できるのかを検証します。

やること

以下を満たすことができたら検証終了とします。

  • 配置したフルーツが重力に従って落下すること
  • 同じ種類のフルーツ同士が接触すると合体して1つ上の大きさのフルーツになること
  • 異なる種類のフルーツ同士が接触した場合は物理判定に基づき自然な動作をすること

やらないこと

あくまで実現性の検証が目的なのでやることに記載している要件以外は実装しません。

  • ゲームスタート
  • 次のフルーツの表示
  • 得点の計算及び表示
  • ゲームオーバー
  • リーダーボード

この記事では触れないこと

  • AndroidやJetpack Composeの基礎的な技術
  • スイカゲームの詳細なルール

検証環境

今回の記事は下記環境で検証しています。

  • PC : Macbook Pro M2
  • IDE : Android Studio Preview Iguana | 2023.2.1 Canary 14
  • 検証端末 : Pixel7 | Android 14

とりあえずAIに相談

出来るだけ楽に実装したいので生成AIを活用していきます。

雑にプロンプトを書いてみました。

スイカゲームについて教えるのは面倒だったのでまずはタップしたらフルーツが落下するところまでの実現方法を相談してみます。

最近Android Studioで使えるようになったStudio Botを使ってみました。

Studio Botから返ってきたコードは架空のクラスやメソッドが数多く含まれており、開発効率の向上にはかなり使いづらい印象です。また、質問の回答がまともに返ってこなかったり同じ内容を何度も繰り返し記載することがあったりとまだまだ発展途上といえる品質でした。

ということで今後の進化に期待しつつ、Studio Botウィンドウをそっと閉じました。

気を取り直し改めてChatGPTに相談しました。

一発で動くものが出てきたわけではないですが、数回試行したところでアプリがビルド出来るようになりました。Canvasで drawCircleしているだけの単純なコードです。

実際に動かしてみました。

まっすぐオブジェクトを落とす動きは問題ないですが、接触判定や接触時のオブジェクトの動きがとても不自然です。。。

衝突したそれぞれのオブジェクトを自然に動かすためにはやはり物理演算の実装が必要となることがわかりました。

物理エンジン導入

物理演算を実装することで自然な動きを実現できるとは思いますが、これまで物理演算に関する経験や知見がなく、AIを活用しても高い品質の者を作るのはかなり時間がかかりそうなので、手っ取り早く実現するために物理エンジンの導入を検討することにしました。

Jetpack Composeに対応した物理エンジンなんて都合の良いものはないだろうと思っていたのですが、調べたらすぐに出てきました。

https://github.com/KlassenKonstantin/ComposePhysicsLayout

このライブラリはdyn4jというJava向けの物理エンジンのCompose用ラッパーです。

Experimentalの記載があり、まだ正式リリース版ではないですが、サンプルアプリの動きを見るとオブジェクト同士の衝突時の動きが自然に再現されていていることがわかりました。

サンプルアプリを動かすだけでも楽しかったのでしばらく遊んでいたのですが、調子に乗ってオブジェクトを大量生成したらメモリが逼迫してANRが発生しました。

I don’t think Compose was made to display hundreds of Composables at the same time. So maybe it’s not a good idea to build a particle system out of this.

READMEにもComposeは大量のComposableを動かすものではないとの記載があります。

スイカゲームを実装したときのパフォーマンスがどうなるか俄然興味が湧いてきました。

Composable大量生成時のパフォーマンスはさておき、想定していたような自然な動きを実現できていたのでComposePhysicsLayoutを使ってみることにします。

導入するには下記をbuild.gradleに追加するだけです。

フルーツの定義

スイカゲームの再現にあたり最低限のフルーツの情報をenumで定義しました。

フルーツの種類と代表色の列挙してもらえないかと期待してGPT-4Vにスイカゲームの画像を与えてみましたが、期待する結果は得られなかったのでカラースポイトを使って地道に色を抽出して設定しました。また、フルーツのサイズについては雰囲気で決めたのでかなり適当です。

このフルーツの情報に加え、Composableを一意に識別するID、初期位置を持たせたメタデータクラスをつくりました。

このメタデータを基にComposableを作ります。

外観をこだわるつもりはないのでフルーツの頭文字を表示し、背景色のみを設定した円をフルーツと言い張ることにします。

Modifierに physicsBody を設定することで物理エンジンの世界で物理判定を持つことができるようになります。衝突時に識別できるようにするためユニークなIDを渡しています。

フルーツを生成

早速作ったフルーツを画面上に生成してみることにします。

本来のスイカゲームは次に落とすフルーツがわかっているのですが、今回はその辺りの機能を割愛し、タップした位置にランダムでフルーツを生成するようにします。

タップ時にを与えたメタデータ作成し、PhysicsLayoutの子要素に physicsBody を設定したComposableを配置することで物理エンジンが適用されます。

衝突検出の実装

次はフルーツ同士がぶつかった際に合体させるため、衝突検出を調べました。

ComposePhysicsLayoutライブラリのREADMEを眺めているとこんな記述が。。。

Currently there is no way to observe bodies / collosions / etc.

ここにきてこのライブラリが衝突検出に対応していないことを知ります。

dyn4jのラッパーならてっきり衝突検出も使えると思ってたので完全に見切り発車でした。。。

諦めて別の方法を検討しようとしていたのですが、よく考えると本家dyn4jには衝突判定が実装されているため、ComposePhysicsLayout側で衝突検出を実装すればいいことに気づきます。

改めて、dyn4jのドキュンメントを読んでみると物理エンジンの世界(Worldクラス)にはContactListenerCollisionListenerがあり、これらを使えば衝突時のコールバックが受け取れることがわかりました。

今回、衝突検出をした際に欲しい情報としては下記となります。

  • 衝突した2つのオブジェクトの識別子(ID)
  • 2つのオブジェクトが衝突した接点の座標

Worldの振る舞いとしては接触する際、最初にCollisionListenerのコールバックが呼ばれ、その後ContactListenerのコールバックで接点が渡ってきます。今回のやりたいことを踏まえ、ContactListenerを利用するようにしました。

ComposePhysicsLayout側のコードを触る必要があったので、Git Submoduleとして組みこみ、ライブラリのコードを触りながら実装しました。

submodule化に伴う変更

ComposePhysicsLayoutをsubmoduleとして追加

app/build.gradle.ktsの変更

setting.gradle.ktsに参照を追加

ComposePhysicsLayout/build.gradleのプラグインが競合したため全てコメントアウト

ComposePhysicsLayout側の実装

Compose側の実装

衝突時のランクアップイベント

これで衝突検出が出来るようになったのでフルーツクラスに合体メソッドを実装します。

衝突検出時に渡された2つのIDに紐づくオブジェクトが同じ種類のフルーツであるかを判定し、消すことができるようになりました。

フルーツの合体

最後にフルーツをの合体を実装していきます。

スイカゲームでは合体したフルーツは衝突地点を中心に生成しているようなので衝突した接点に合体後のフルーツを生成します。

が、この実装ではフルーツが生成されなかったり、意図しない位置に生成されたりと座標設定がうまくいきませんでした。

原因はAndroidのレイアウトと物理エンジンの世界の原点異なるため、座標にずれが起きていました。Androidのレイアウトでは左上が原点であるのに対し、物理エンジンでは中心に原点を持つのが一般的だそうです。

これを解決するにはAndroid側にコールバックする前に画面サイズを考慮して再計算する必要があります。そのため、PhysicsLayoutで衝突を検出したらAndroid側の座標に変換して返却する処理を実装することで無事解決しました。

PhysicsLayoutの実装

Compose側の実装

完成

これでフルーツの落下から合体まで一通り実装することができました。

当初はAIに頼ってサクッと作っちゃおうと安易に考えていましたが、dyn4jのドキュメントを読んでライブラリの機能を拡張するなどしていたら思いの外時間がかかってしまいました。とはいえ当初の要件を実装できたので個人的には満足です。

結果としてはスイカゲーム程度のオブジェクト数であればほぼカクつくこともなく、快適にゲームプレイができる程度のパフォーマンスを保ちつつ実現することができました。

一般的なアプリでは物理エンジンを使う機会はあまりないかもしれませんが、複雑なUI表現の一つの選択肢として覚えておいても損はしないと思います。

おまけ

ComposePhyticsLayoutのサンプルアプリに実装されていた下記のコードをそのまま移植しました。

端末を傾けた方向に重力がかかるように

一度配置したフルーツをドラッグできるように

こうしてできたスイカゲームがこちらです。

オリジナル機能の追加により、ゲームオーバーがなくフルーツを動かし放題なスイカゲームができました。このおかげで、本家スイカゲームでは選ばれしものしか達成できないダブルスイカを誰でも達成できるという思わぬ副産物を得ることができました。

今回検証したコードはGitHubにあげたのでよかったらダブルスイカを体験してみてください。

https://github.com/misoca12/ComposeSuikaGame/tree/master

最後に

いかがだったでしょうか?

スイカゲームの実装は物理エンジンを利用できれば低コストで実現できるので、勉強がてら試すのにおすすめです。また、この記事がCompose × 物理エンジンの可能性の参考になればと思います。

最後まで読んでいただきありがとうございました。

明日のレコチョク Advent Calendar 2023 は24日目「気難しいBlockchainへの対処法」となります。お楽しみに!