この記事は レコチョク 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アプリエンジニアです。
下記の要件を満たしたAndroidアプリをJetpack Composeを用いて実現する方法を考えてください。
- このアプリはJetpack Composeで実装されたAndroid向けのゲームである
- ゲームを開始すると画面下部に向かって重力がかかっている
- スマートフォンの下部及び左右は壁になっている
- ユーザは画面をドラッグするとタップした位置に円形のオブジェクト(フルーツ)を生成する
- ドラッグ中はタップ位置に追従してフルーツが移動する
- ドラッグ中はフルーツは落下しない
- 画面から指を離す(ドロップ操作を行う)とアイテムが重力にしたがって落下する
- 落下したフルーツは画面外にとどまり、物理法則に従った動きをする
- 一度ドロップを行うとまたドラッグ操作を行うことで新たなアイテムを生成して落下することができる
- この一連の流れを繰り返すことでアイテムを連続してフルーツを配置することが可能
最近Android Studioで使えるようになったStudio Botを使ってみました。
Studio Botから返ってきたコードは架空のクラスやメソッドが数多く含まれており、開発効率の向上にはかなり使いづらい印象です。また、質問の回答がまともに返ってこなかったり同じ内容を何度も繰り返し記載することがあったりとまだまだ発展途上といえる品質でした。
ということで今後の進化に期待しつつ、Studio Botウィンドウをそっと閉じました。
気を取り直し改めてChatGPTに相談しました。
一発で動くものが出てきたわけではないですが、数回試行したところでアプリがビルド出来るようになりました。CanvasでdrawCircle
しているだけの単純なコードです。
Canvas(modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
currentItemPosition = offset
},
onDragEnd = {
// 新しいアイテムを落下リストに追加
items += Item(currentItemPosition, Offset(0f, 0f))
currentItemPosition = Offset.Unspecified
},
onDrag = { _, dragAmount ->
currentItemPosition = currentItemPosition.copy(x = currentItemPosition.x + dragAmount.x)
}
)
}
) {
screenSize = size
// 壁となる下部の線を描画
drawLine(
color = Color.Black,
start = Offset(0f, size.height - bottomBarrierHeightPx),
end = Offset(size.width, size.height - bottomBarrierHeightPx),
strokeWidth = 5f
)
// 落下中のアイテムを描画
items.forEach { item ->
drawCircle(
color = Color.Blue,
center = item.position,
radius = itemRadiusPx
)
}
// 現在ドラッグ中のアイテムを描画
if (currentItemPosition != Offset.Unspecified) {
drawCircle(
color = Color.Red,
center = currentItemPosition,
radius = itemRadiusPx
)
}
}
実際に動かしてみました。
まっすぐオブジェクトを落とす動きは問題ないですが、接触判定や接触時のオブジェクトの動きがとても不自然です。。。
衝突したそれぞれのオブジェクトを自然に動かすためにはやはり物理演算の実装が必要となることがわかりました。
物理エンジン導入
物理演算を実装することで自然な動きを実現できるとは思いますが、これまで物理演算に関する経験や知見がなく、AIを活用しても高い品質のものを作るのはかなり時間がかかりそうなので、手っ取り早く実現するために物理エンジンの導入を検討することにしました。
Jetpack Composeに対応した物理エンジンなんて都合の良いものはないだろうと思っていたのですが、調べたらすぐに出てきました。
このライブラリは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に追加するだけです。
dependencies {
implementation 'io.github.klassenkonstantin:physics-layout:0.4.1'
}
フルーツの定義
スイカゲームの再現にあたり最低限のフルーツの情報をenumで定義しました。
enum class Fruit(
val displayName: String,
val color: Color,
val size: Dp,
val textSize: TextUnit
) {
WATERMELON("スイカ", Color(0xFF30671F), 280.dp, 140.sp),
MELON("メロン", Color(0xFF87B83D), 240.dp, 130.sp),
PINEAPPLE("パイナップル", Color(0xFFF1D248), 200.dp, 100.sp),
PEACH("モモ", Color(0xFFF5C9C1), 160.dp, 80.sp),
PEAR("ナシ", Color(0xFFFBF189), 120.dp, 60.sp),
APPLE("リンゴ", Color(0xFFE2372A), 100.dp, 50.sp),
KAKI("カキ", Color(0xFFEE8D39), 80.dp, 40.sp),
DEKOPON("デコポン", Color(0xFFF4BA40), 60.dp, 30.sp),
GRAPE("ブドウ", Color(0xFF5913E5), 48.dp, 20.sp),
STRAWBERRY("イチゴ", Color(0xFFEC7355), 36.dp, 14.sp),
CHERRY("サクランボ", Color(0xFFDF3325), 24.dp, 10.sp);
}
フルーツの種類と代表色の列挙してもらえないかと期待してGPT-4Vにスイカゲームの画像を与えてみましたが、期待する結果は得られなかったのでカラースポイトを使って地道に色を抽出して設定しました。また、フルーツのサイズについては雰囲気で決めたのでかなり適当です。
このフルーツの情報に加え、Composableを一意に識別するID、初期位置を持たせたメタデータクラスをつくりました。
@Immutable
data class FruitMeta(
val id: String,
val fruit: Fruit,
val offset: Offset
)
このメタデータを基にComposableを作ります。
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FruitObject(
fruitMeta: FruitMeta,
onClick: ((String) -> Unit)? = null
) {
val density = LocalDensity.current
Box(
Modifier
.offset(with(density) {fruitMeta.offset.x.toDp() - (fruitMeta.fruit.size / 2) }, with(density) {fruitMeta.offset.y.toDp() })
) {
Card(
modifier = Modifier
.physicsBody(
id = fruitMeta.id,
shape = CircleShape
)
.size(fruitMeta.fruit.size),
shape = CircleShape,
colors = CardDefaults.cardColors(containerColor = fruitMeta.fruit.color),
onClick = { onClick?.invoke(fruitMeta.id) }
) {
Box(
modifier = Modifier.size(fruitMeta.fruit.size),
contentAlignment = Alignment.Center
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = fruitMeta.fruit.displayName.substring(0, 1),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = fruitMeta.fruit.textSize,
color = Color.White
)
}
}
}
}
外観をこだわるつもりはないのでフルーツの頭文字を表示し、背景色のみを設定した円をフルーツと言い張ることにします。
Modifierに physicsBody
を設定することで物理エンジンの世界で物理判定を持つことができるようになります。衝突時に識別できるようにするためユニークなIDを渡しています。
フルーツを生成
早速作ったフルーツを画面上に生成してみることにします。
本来のスイカゲームは次に落とすフルーツがわかっているのですが、今回はその辺りの機能を割愛し、タップした位置にランダムでフルーツを生成するようにします。
fun Fruit.random(): Fruit {
// リンゴ以上は生成しない
val excluded = setOf(
Fruit.WATERMELON,
Fruit.MELON,
Fruit.PINEAPPLE,
Fruit.PEACH,
Fruit.PEAR,
Fruit.APPLE,
)
return Fruit.entries.filter { it !in excluded }.random()
}
タップ時にを与えたメタデータ作成し、PhysicsLayoutの子要素に physicsBody
を設定したComposableを配置することで物理エンジンが適用されます。
Surface(
modifier = Modifier.fillMaxSize().pointerInput(Unit) {
detectTapGestures(
onPress = {
fruits.add(FruitMeta("fruit-${fruitCounter++}", Fruit.random(), it))
}
)
},
color = MaterialTheme.colorScheme.background
) {
...
PhysicsLayout(
modifier = Modifier.systemBarsPadding(),
simulation = simulation,
content = {
// メタデータ分フルーツを生成する
fruits.forEach { fruitMeta ->
key(fruitMeta.id) {
FruitObject(
fruitMeta
)
}
}
}
)
...
}
衝突検出の実装
次はフルーツ同士がぶつかった際に合体させるため、衝突検出を調べました。
ComposePhysicsLayoutライブラリのREADMEを眺めているとこんな記述が。。。
Currently there is no way to observe bodies / collosions / etc.
ここにきてこのライブラリが衝突検出に対応していないことを知ります。
dyn4jのラッパーならてっきり衝突検出も使えると思ってたので完全に見切り発車でした。。。
諦めて別の方法を検討しようとしていたのですが、よく考えると本家dyn4jには衝突判定が実装されているため、ComposePhysicsLayout側で衝突検出を実装すればいいことに気づきます。
改めて、dyn4jのドキュンメントを読んでみると物理エンジンの世界(Worldクラス)にはContactListenerとCollisionListenerがあり、これらを使えば衝突時のコールバックが受け取れることがわかりました。
今回、衝突検出をした際に欲しい情報としては下記となります。
- 衝突した2つのオブジェクトの識別子(ID)
- 2つのオブジェクトが衝突した接点の座標
Worldの振る舞いとしては接触する際、最初にCollisionListenerのコールバックが呼ばれ、その後ContactListenerのコールバックで接点が渡ってきます。今回のやりたいことを踏まえ、ContactListenerを利用するようにしました。
ComposePhysicsLayout側のコードを触る必要があったので、Git Submoduleとして組みこみ、ライブラリのコードを触りながら実装しました。
submodule化に伴う変更
ComposePhysicsLayoutをsubmoduleとして追加
$ git submodule add https://github.com/KlassenKonstantin/ComposePhysicsLayout.git
app/build.gradle.ktsの変更
dependencies {
// implementation("io.github.klassenkonstantin:physics-layout:0.4.1")
implementation(project(":ComposePhysicsLayout:lib"))
}
setting.gradle.ktsに参照を追加
include(":ComposePhysicsLayout:lib")
ComposePhysicsLayout/build.gradleのプラグインが競合したため全てコメントアウト
plugins {
// id 'com.android.application' version '8.2.0' apply false
// id 'com.android.library' version '8.2.0' apply false
// id 'org.jetbrains.kotlin.android' version '1.8.10' apply false
}
ComposePhysicsLayout側の実装
// ContactListener実装クラス
internal class ContactHandler(private val onCollision: ((Pair<String, String>, Pair<Double, Double>) -> Unit)?) : ContactListener<SimulationEntity<*>> {
override fun begin(collision: ContactCollisionData<SimulationEntity<*>>?, contact: Contact?) {
collision?.pair?.run {
val firstId = (first.body as? SimulationEntity.Body)?.id ?: return
val secondId = (second.body as? SimulationEntity.Body)?.id ?: return
// 衝突した2つのオブジェクトのIDをPairにまとめる
val ids = firstId to secondId
// dyn4jのクラスをライブラリ外に返さないように衝突した接点の座標をPairにまとめる
val offset : Pair<Double, Double> = (contact?.point?.x ?: 0).toDouble() to ((contact?.point?.y) ?: 0).toDouble()
onCollision?.invoke(ids, offset)
}
}
...
}
@Stable
class Simulation internal constructor(
private val world: World<SimulationEntity<*>>,
private val clock: Clock,
) {
fun addContactListener(onCollision: ((Pair<String, String>, Pair<Double, Double>) -> Unit)?) {
val handler = ContactHandler(onCollision = onCollision)
world.addContactListener(handler)
}
...
}
Compose側の実装
// dyn4jとComposeを仲介しているSimulationに衝突時のコールバックを渡す
val simulation = rememberSimulation(
onCollision = { id1: String, id2: String ->
Log.d("MainActivity", "onCollision $id1 and $id2")
}
)
衝突時のランクアップイベント
これで衝突検出が出来るようになったのでフルーツクラスに合体メソッドを実装します。
enum class Fruit(
val displayName: String,
val color: Color,
val size: Dp,
val textSize: TextUnit
) {
...
fun rankup() = when(this) {
CHERRY -> STRAWBERRY
STRAWBERRY -> GRAPE
GRAPE -> DEKOPON
DEKOPON -> KAKI
KAKI -> APPLE
APPLE -> PEAR
PEAR -> PEACH
PEACH -> PINEAPPLE
PINEAPPLE -> MELON
MELON -> WATERMELON
WATERMELON -> null // スイカ同士がぶつかったら何も生成しない
}
}
衝突検出時に渡された2つのIDに紐づくオブジェクトが同じ種類のフルーツであるかを判定し、消すことができるようになりました。
onCollision = { ids: Pair<String, String>, offset: Pair<Double, Double> ->
val fruit1 = fruits.firstOrNull { it.id == ids.first } ?: return@PhysicsLayout
val fruit2 = fruits.firstOrNull { it.id == ids.second } ?: return@PhysicsLayout
if (fruit1.fruit == fruit2.fruit) {
fruits.removeIf { it.id == fruit1.id }
fruits.removeIf { it.id == fruit2.id }
}
}
フルーツの合体
最後にフルーツをの合体を実装していきます。
スイカゲームでは合体したフルーツは衝突地点を中心に生成しているようなので衝突した接点に合体後のフルーツを生成します。
onCollision = { ids: Pair<String, String>, offset: Pair<Double, Double> ->
val fruit1 = fruits.firstOrNull { it.id == ids.first } ?: return@PhysicsLayout
val fruit2 = fruits.firstOrNull { it.id == ids.second } ?: return@PhysicsLayout
if (fruit1.fruit == fruit2.fruit) {
fruits.removeIf { it.id == fruit1.id }
fruits.removeIf { it.id == fruit2.id }
// 合体したフルーツ
val merged = fruit1.fruit.rankup() ?: return@PhysicsLayout
// 衝突した接点の座標
val offset = Offset(offset.first.toFloat(), offset.second.toFloat())
fruits.add(FruitMeta("fruit-${fruitCounter++}", merged, offset))
}
}
が、この実装ではフルーツが生成されなかったり、意図しない位置に生成されたりと座標設定がうまくいきませんでした。
原因はAndroidのレイアウトと物理エンジンの世界の原点異なるため、座標にずれが起きていました。Androidのレイアウトでは左上が原点であるのに対し、物理エンジンでは中心に原点を持つのが一般的だそうです。
これを解決するにはAndroid側にコールバックする前に画面サイズを考慮して再計算する必要があります。そのため、PhysicsLayoutで衝突を検出したらAndroid側の座標に変換して返却する処理を実装することで無事解決しました。
PhysicsLayoutの実装
@Composable
fun PhysicsLayout(
modifier: Modifier = Modifier,
shape: Shape? = RectangleShape,
scale: Dp = DEFAULT_SCALE,
simulation: Simulation = rememberSimulation(),
content: @Composable BoxScope.() -> Unit,
onCollision: ((Pair<String, String>, Pair<Double, Double>) -> Unit)? = null
) {
val density = LocalDensity.current
val scalePx = density.run { scale.toPx().toDouble() }
simulation.addContactListener { ids: Pair<String, String>, offset: Pair<Double, Double> ->
// 親要素の画面サイズを取得
val size = layoutToSimulation.containerLayoutCoordinates.value?.size ?: return@addContactListener
// 左上原点に変換した座標をコールバックに渡す
val layoutOffset = offset.first * scalePx + size.width / 2 to offset.second * scalePx + size.height / 2
onCollision?.invoke(ids, layoutOffset)
}
...
}
Compose側の実装
PhysicsLayout(
...
onCollision = { ids: Pair<String, String>, offset: Pair<Double, Double> ->
val fruit1 = fruits.firstOrNull { it.id == ids.first } ?: return@PhysicsLayout
val fruit2 = fruits.firstOrNull { it.id == ids.second } ?: return@PhysicsLayout
if (fruit1.fruit == fruit2.fruit) {
fruits.removeIf { it.id == fruit1.id }
fruits.removeIf { it.id == fruit2.id }
val merged = fruit1.fruit.rankup() ?: return@PhysicsLayout
val sizePx = density.run {merged.size.toPx()}
// 渡ってきた座標をほぼそのまま使う
val layoutOffset = Offset(
offset.first.toFloat(),
offset.second.toFloat() - sizePx / 2
)
fruits.add(FruitMeta("fruit-${fruitCounter++}", merged, layoutOffset))
}
}
)
完成
これでフルーツの落下から合体まで一通り実装することができました。
当初はAIに頼ってサクッと作っちゃおうと安易に考えていましたが、dyn4jのドキュメントを読んでライブラリの機能を拡張するなどしていたら思いの外時間がかかってしまいました。とはいえ当初の要件を実装できたので個人的には満足です。
結果としてはスイカゲーム程度のオブジェクト数であればほぼカクつくこともなく、快適にゲームプレイができる程度のパフォーマンスを保ちつつ実現することができました。
一般的なアプリでは物理エンジンを使う機会はあまりないかもしれませんが、複雑なUI表現の一つの選択肢として覚えておいても損はしないと思います。
おまけ
ComposePhyticsLayoutのサンプルアプリに実装されていた下記のコードをそのまま移植しました。
端末を傾けた方向に重力がかかるように
GravitySensor { (x, y) ->
simulation.setGravity(Offset(-x, y).times(3f)) // 仲介クラスにジャイロセンサーの値を設定
}
一度配置したフルーツをドラッグできるように
Modifier.physicsBody(
id = fruitMeta.id,
shape = CircleShape,
dragConfig = DragConfig() // ドラッグできるようにする
)
こうしてできたスイカゲームがこちらです。
オリジナル機能の追加により、ゲームオーバーがなくフルーツを動かし放題なスイカゲームができました。このおかげで、本家スイカゲームでは選ばれしものしか達成できないダブルスイカを誰でも達成できるという思わぬ副産物を得ることができました。
今回検証したコードはGitHubにあげたのでよかったらダブルスイカを体験してみてください。
最後に
いかがだったでしょうか?
スイカゲームの実装は物理エンジンを利用できれば低コストで実現できるので、勉強がてら試すのにおすすめです。また、この記事がCompose × 物理エンジンの可能性の参考になればと思います。
最後まで読んでいただきありがとうございました。
明日のレコチョク Advent Calendar 2023 は24日目「気難しいBlockchainへの対処法」となります。お楽しみに!