【Kotlin】初心者がJetpack Composeでチンチロゲームを作ってみる

この記事は[レコチョク Advent Calendar 2024](https://qiita.com/advent-calendar/2024/recochoku)の20日目です。
株式会社レコチョクのNX開発推進部Androidアプリ開発グループの本多啓路です。

今年一番聴いていた曲はMyGO!!!!!さんが歌う[ノンブレス・オブリージュ(Cover)](https://www.youtube.com/watch?v=Y9T2H38G88A)で、555分聴いていたらしいです(YouTube Music調べ)。

# はじめに
自分はFY24新卒であり、10月から晴れてNX開発推進部Androidアプリ開発グループに配属になりました。
そして、OJTもいよいよ終盤となり、Eggsアプリの開発にアサインされることになった今日この頃です。

そんな時、レコチョクAdvent Calendar 2024のお話を頂きました。
学んできたことを復習できる良い機会だと考え、遊べるアプリを作成してみました。

それがこれから紹介する「チンチロアプリ」です！

# チンチロについて
まず、「チンチロ」って何？という方も多いと思いますので、ざっくりと説明します。

「チンチロ」は日本の伝統的な賭博遊びの一つで、三つのサイコロを使ったゲームです。
ルールは、三つのサイコロを丼に目掛けて振り、出た数字の組み合わせによって勝敗が決まる簡単なゲームです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939447/6bd34ded-f206-7167-4c30-9948cf16c17f.png" width="25%">

使用する出目の組み合わせの役を以下に示しています。

| 役 | 説明 | 例 | 
| - | - | - |
| ピンゾロ| 1のゾロ目 | 1 1 1 |
|  アラシ | 1以外のゾロ目 | 2 2 2 , 3 3 3 , 4 4 4 , 5 5 5 , 6 6 6 |
| シゴロ | 4から6の連番 | 4 5 6 |
| 1〜6 | 2つのサイコロが同じ数字のとき、<br>残り1つのサイコロが示す数字 | 1 1 3, 2 2 6...... |
| ヒフミ | 1から3の連番 | 1 2 3 |
| メナシ | どれにも当てはまらない組み合わせ | 2 3 5,  2 4 6...... |
| ションベン | 丼からサイコロが飛び出たとき | - |

使用するチンチロのルールを以下に示しています。
1. ロールはCPUが「親」、プレーヤーが「子」となる
2. 子(プレイヤー)が先にサイコロを振り、その後親(CPU)がサイコロを振る
3. メナシ以外の役が出るまで最大三回サイコロを振ることができる
4. 役の強さは`ピンゾロ` &gt; `アラシ` &gt; `シゴロ` &gt; `6` &gt; `5` &gt; `4` &gt; `3` &gt; `2` &gt; `1` &gt; `メナシ` &gt; `ヒフミ` &gt; `ションベン` とする 
5.  実際にサイコロを投げるわけではないため、ションベンは5%の確率で起こる要素とする
6. 引き分けの場合は親(CPU)の勝ちとする


# 完成品

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939447/c1eb1b5a-cbbe-d0c8-3248-d11260dba59a.gif" width="25%">

それでは作ってみましょう！

# アプリ作成

## 環境
今回は下記環境で作成しました。

| 項目 | 詳細 |
| - | - |
| PC | MacBook Pro |
| CPU | Apple M1 |
| IDE | Android Studio Koala / 2024.1.1 Patch 1 |
| 検証端末 | Pixel7 / Android 13 |
| ライブラリ | Jetpack Compose |


## 必要なデータとロジックの作成
今回はViewModelを使用して必要なデータやロジックを一元管理して作成しました。
AndroidのViewModelは、アプリケーションのUIデータを保持し、ライフサイクルを考慮して設計されたクラスになります。

実際にViewModel内で作成した主要な関数を見ていきます。

- rollDices
    - この関数を呼ぶと、サイコロを三個分振り、出目を更新します<br>ちゃんと乱数を使用しているのでイカサマはないはずです笑

```
private fun rollDices(dices: MutableList<MutableStateFlow<Dice>>) {
    dices.forEachIndexed { _, diceStateFlow ->
        val value = (1..6).random()        //1から6までの乱数
        diceStateFlow.value = Dice(value)
    }
}
```

- evaluateDiceRole
    - rollDices関数での出目がどの役に当てはまるかを評価しています

```
private fun evaluateDiceRole(dices: List<StateFlow<Dice>>): RoleType {
    val rollResults = dices.map { it.value.value }.sorted()        //評価がしやすいようにソート

    return when {
        rollResults == listOf(1, 1, 1) -> RoleType.ONLY_ONE
        rollResults == listOf(4, 5, 6) -> RoleType.F2_S
        rollResults == listOf(1, 2, 3) -> RoleType.O_T2
        rollResults.distinct().size == 1 -> RoleType.STORM
        rollResults.distinct().size == 2 -> {
            val uniqueNumbers = rollResults.groupBy { it }.filter { it.value.size == 1 }.keys
            RoleType.REGULAR(uniqueNumbers.first())
        }
        else -> RoleType.NO_ROLE
    }
}
```

ちなみにここではションベンの処理は行っていません。evaluateDiceRoleの呼び出しの前に5%の確率で発生するようにしています。

```
val role = if ((1..100).random() <= 5) RoleType.PEE
            else evaluateDiceRole(dices)
```

- determineWinner
    - プレイヤーの役と、CPUの役を比較して勝敗を決定します
    - 今回作成したアプリでのプレイヤーのロールは子なので、ピンゾロを出しても負ける可能性があります

```
fun determineWinner(childRole: RoleType, parentRole: RoleType): String {
    return when {
        childRole.strength > parentRole.strength -> "YOU WIN!!"
        childRole.strength < parentRole.strength -> "YOU LOSE!!"
        childRole is RoleType.REGULAR && parentRole is RoleType.REGULAR -> {
            when {
                childRole.number > parentRole.number -> "YOU WIN!!"
                else -> "YOU LOSE!!"
            }
        }
        else -> "YOU LOSE!!"        //引き分けは親の勝ち
    }
}
```

役の強さの比較はあらかじめ設定しておいたデータを使用しています。

```
sealed class RoleType(val strength: Int) {
    data object ONLY_ONE : RoleType(9) // もっとも強い
    data object STORM : RoleType(8)
    data object F2_S : RoleType(7)
    data class REGULAR(val number: Int) : RoleType(number)
    data object NO_ROLE : RoleType(0)
    data object O_T2 : RoleType(-1)
    data object PEE : RoleType(-2) // もっとも弱い
}
```

ここまでの実装により、サイコロを振って出た目の役を比較して勝敗を決定するという最低限のロジックが作れました！

## 画面遷移
最低限のロジックが作れたので、次は画面遷移を作っていきます！

今回作成するアプリの画面遷移を図で表すとこのような感じになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939447/c32056b5-3449-6ba6-814d-695c63c88b00.png" width="90%">

画面遷移はNavControllerで作成しました。NavControllerは、アプリ内ナビゲーションを容易にするために設計されており、複数の画面（フラグメントやアクティビティ）間の遷移をスムーズに行うことを可能にしています。
```
NavHost(navController = navController, startDestination = "main") {
    composable("main") {        //Top画面へのルート
        HomeScreen(
            modifier = modifier,
            navController = navController
        )
    }
    composable("child") {        //子(プレイヤー)がサイコロを振る画面へのルート
        ChildDiceRollScreen(
            modifier = modifier,
            navController = navController,
            diceRollViewModel = diceRollViewModel,
        )
    }
   composable("parent") {        //親(CPU)がサイコロを振る画面へのルート
        ParentDiceRollScreen(
            modifier = modifier,
            navController = navController,
            diceRollViewModel = diceRollViewModel,
        )
    }
    composable("game_result"){        //勝敗結果を示すページへのルート
        GameResultScreen(
            modifier = modifier,
            navController = navController,
            diceRollViewModel = diceRollViewModel,
        )
    }
}
```
画面遷移を行いたいタイミングで、たとえば`navController.navigate(route = "child")`を呼び出すと新しい画面で`composable("child")`内の処理を行ってくれる便利なシステムです。

これで画面遷移の準備もできました！
あとは、画面のUIを「Jetpack Compose」で作成していくだけです！

## 画面の作成
ロジックはすでに作成したので、あとは`Column`、`Text`、`Image`、`Button`、`Spacer`を使いつつ、ViewModelで操作していたデータを組み込むだけで完成です！

### 汎用的に使えるコンポーズを作成
使用する頻度が多いものは予め汎用化して使いまわせるようにしました。

```
//Textの設定をまとめて行える
@Preview
@Composable
fun TextFormat(
    text: String = stringResource(id = R.string.sample),
    fontSize: Int = 12,
    start: Int = 0
){
    Text(
        text = text,
        fontSize = fontSize.sp,
        modifier = Modifier.padding(start = start.dp)
    )
}

//丼とサイコロの描写
@Preview
@Composable
fun DiceInABowl(
    diceRollViewModel: DiceRollViewModel = DiceRollViewModel(),
    isChild: Boolean = false,
    modifier: Modifier = Modifier
) {
    val role by if (isChild) diceRollViewModel.childRoleName.collectAsState()
    else diceRollViewModel.parentRoleName.collectAsState()

    val diceImages = diceRollViewModel.diceImages.collectAsState().value
    val isPee = (role == RoleType.PEE)

    Box(modifier = modifier){
        Image(
            painter = painterResource(id = R.drawable.bowl),
            contentDescription = null,
        )
        diceImages.forEachIndexed { index, diceValue ->
            val diceImageRes = getDiceImageResource(diceValue)
            Image(
                painter = painterResource(id = diceImageRes),
                contentDescription = null,
                modifier = getOffsetForDice(index, isPee)
            )
        }
    }
}
```


### Top画面

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939447/a3b34f2d-6ebb-3310-5cb0-d933d711c5fb.png" width="25%">

```
Column(
    modifier = modifier,
    horizontalAlignment = Alignment.CenterHorizontally,
    verticalArrangement = Arrangement.Center
) {
    TextFormat(stringResource(R.string.chinchiro), 72)
    Spacer(modifier = Modifier.height(36.dp))
    Image(
        painter = painterResource(id = R.drawable.chinchiro),
        contentDescription = null
    )
    Spacer(modifier = Modifier.height(36.dp))
    Button(onClick = {
        navController.navigate(route = "child")
    }) {
        TextFormat(stringResource(R.string.start), 36)
    }
}

```

### サイコロを振る画面
メナシ以外の役が出るまで最大三回サイコロを振るフローを示します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939447/7e675486-3629-70e9-9825-9a2800c1a520.png" width="25%">

#### 子(プレイヤー)がサイコロを振る画面
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939447/b2b129fc-918e-5e6c-adcb-79089d10a571.png" width="25%">

```
@Composable
fun ChildDiceRollScreen(
    modifier: Modifier = Modifier,
    navController: NavController = rememberNavController(),
    diceRollViewModel: DiceRollViewModel = DiceRollViewModel(),
) {
    val buttonText by diceRollViewModel.buttonText.collectAsState()
    val childRole by diceRollViewModel.childRoleName.collectAsState()

    var isNavigating by remember { mutableStateOf(false) }

    if (!isNavigating) {
        Column(
            modifier = modifier,
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            DiceInABowl(diceRollViewModel, isChild = true)
            Spacer(modifier = Modifier.height(36.dp))
            Button(
                onClick = {
                    if (childRole != RoleType.NO_ROLE || diceRollViewModel.rollCount.value == diceRollViewModel.maxRolls) {
                        isNavigating = true
                        diceRollViewModel.rollCount.value = 0
                        navController.navigate("parent")
                    } else {
                        diceRollViewModel.childDiceRolling(
                            onRoleAchieved = {
                                if (childRole == RoleType.PEE) {
                                    isNavigating = true
                                    diceRollViewModel.rollCount.value = 0
                                    navController.navigate("parent")
                                }
                            },
                            updateButtonText = { newText ->
                                diceRollViewModel.buttonText.value = newText
                            }
                        )
                    }
                },
                modifier = Modifier.size(width = 256.dp, height = 72.dp)
            ) {
                TextFormat(buttonText,36)
            }
        }
    }
}
```

1. `childRole != RoleType.NO_ROLE`で何かしら役が出ている
2. `diceRollViewModel.rollCount.value == diceRollViewModel.maxRolls`で三回振ったかどうか

という条件式をボタンに仕込んでボタンを押した際の処理を分岐させています。

#### 親(CPU)がサイコロを振る画面
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939447/9df02e34-2bee-a7b3-cd9e-af3a923901ff.png" width="25%">

```
@Composable
fun ParentDiceRollScreen(
    modifier: Modifier = Modifier,
    navController: NavController,
    diceRollViewModel: DiceRollViewModel
) {
    val buttonText by diceRollViewModel.buttonText.collectAsState()

    LaunchedEffect(Unit) {        //UIの状態に基づいて自動的に再描画
        diceRollViewModel.parentDiceRolling(
            updateButtonText = { text ->
                diceRollViewModel.buttonText.value = text
            },
            onRoleAchieved = { navController.navigate("game_result") }
        )
    }

    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(top = 16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Top
    ) {
        HeaderText(text = "親のターン", fontSize = 42)
        Spacer(modifier = Modifier.height(80.dp))
        DiceInABowl(diceRollViewModel, isChild = false)
        Spacer(modifier = Modifier.height(36.dp))
        TextFormat(text = buttonText, 72)
    }
}
```

こちらは`LaunchedEffect`で自動的に再描写を行い、画面遷移までを自動的にしてくれるようにしています。

ちなみにサイコロを振るボタンを押したときに、ViewModelの中で非同期処理を使用してアニメーションっぽい表示をしています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939447/471c6b5f-a88b-fd59-220c-d0fb6dba5548.gif" width="25%">

```
viewModelScope.launch {
    repeat(15) {
        _diceImages.value = List(diceCount) { Random.nextInt(1, 7) }
        delay(40)
    }
}
```

### 勝敗結果を表示する画面
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939447/9c83ade9-6622-6ade-acaa-b5661eb216a9.png" width="25%">

```
@Composable
fun GameResultScreen(
    modifier: Modifier = Modifier,
    navController: NavController,
    diceRollViewModel: DiceRollViewModel
) {
    val childRole by diceRollViewModel.childRoleName.collectAsState()
    val parentRole by diceRollViewModel.parentRoleName.collectAsState()

    val gameResult = diceRollViewModel.determineWinner(childRole, parentRole)
    var isNavigating by remember { mutableStateOf(false) }

    if (!isNavigating) {
        Column(
            modifier = modifier,
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            TextFormat(stringResource(R.string.parent), 72)
            TextFormat(parentRole.getDisplayName(), 72)
            Spacer(modifier = Modifier.height(52.dp))
            TextFormat(stringResource(R.string.versus), 72)
            Spacer(modifier = Modifier.height(52.dp))
            TextFormat(stringResource(R.string.child), 72)
            TextFormat(childRole.getDisplayName(), 72)
            Spacer(modifier = Modifier.height(52.dp))
            TextFormat(gameResult, 64, 12)
            Spacer(modifier = Modifier.height(28.dp))
            Button(
                onClick = {
                    isNavigating = true
                    navController.navigate("child")
                    diceRollViewModel.resetStatus()
                },
                modifier = Modifier.size(width = 200.dp, height = 72.dp)
            ) {
                TextFormat(text = stringResource(R.string.one_more), 36)
            }
        }
    }
}
```

# 最後に

ここまでお読み頂きありがとうございました！
今回はOJTで学んだ「Jetpack Compose」を活かして「チンチロアプリ」を作成してみました！
色々と復習にもなり、良い経験ができたと思います。

明日の[レコチョク Advent Calendar 2024](https://qiita.com/advent-calendar/2024/recochoku)は21日目「【Kotlin】 Jetpack Composeを使ってりんご何個分？アプリを作ろう」となります。お楽しみに！

この記事はレコチョクのエンジニアブログの記事を転載したものとなります。

https://techblog.recochoku.jp/11530