Jetpack Compose小游戏 | “上蹿下跳”超人兔疯狂吃月饼

前言

又是一年一度中秋佳节,在摸鱼时无意中看到掘金刚好有发文活动,于是一时兴致大发(想薅羊毛)用Compose简单撸了一个兔子吃月饼的小游戏,先上效果图:

如图所示,游戏玩法很简单,通过两个按钮去控制兔子左右移动吃月饼,如果月饼没有被及时吃到掉到了地上则游戏结束。

整个游戏主要可以分为以下的功能点:

  • 月饼会持续随机生成并掉下来;
  • 通过底部的按钮控制兔子左右移动吃月饼,吃到一个月饼分数+1,并移除被吃掉的月饼;
  • 如果月饼掉到底部前没有被吃到,则游戏结束;
  • 游戏结束后可以重新开始。

话不多说,正式开干!

设计思路

游戏区域类似一个棋盘,棋盘上每一个小格子的内容可以分为三种情况:月饼、兔子、空格子,如此一来月饼和兔子本质上可以通过坐标来在棋盘上绘制,而兔子的移动、月饼的掉落则可以通过改变坐标来实现。

游戏界面的吃月饼、兔子移动等逻辑用ViewModel去处理,通过flow向View层输出页面状态,View层用Jetpack Compose来搭建,并收集ViewModel吐出的状态流来更新页面。

功能实现

1、定义棋盘元素和页面状态

棋盘的大小不是重点,浅定个3x10的棋盘玩玩:

kotlin 复制代码
const val BOARD_WITH = 3
const val BOARD_HEIGHT = 10

val moonCakeBoard = buildList {
    for (x in 0 until BOARD_WITH) {
        for (y in 0 until BOARD_HEIGHT) {
            add(x to y)
        }
    }
}

兔子和单个月饼都用xy坐标来表示,也就是本质上是Pair<Int, Int>,为了提高代码的可读性,给棋盘上的坐标起个别名Coordinate,真实类型为Pair<Int, Int>,棋盘上所有的月饼也就是一个坐标的列表:

kotlin 复制代码
typealias Coordinate = Pair<Int, Int> //坐标
typealias MoonCakes = List<Coordinate> //月饼列表

整个游戏界面所需的界面状态类型简单设计如下:

kotlin 复制代码
data class MoonCakeState(
    val rabbit: Coordinate,
    val moonCakes: MoonCakes,
    val score: Int,
    val isGaming: Boolean
)

2、月饼的生成和掉落

由于月饼是自上而下掉落的,那每次生成月饼可以在棋盘上最顶部的格子里面随机选择一个作为新月饼的坐标,也就是新月饼的y必然为0:

kotlin 复制代码
fun getRandomMoonCake(): Coordinate {
    val availableCoordinates = moonCakeBoard.filter { it.second == 0 }
    val random = Random.nextInt(availableCoordinates.size)
    return availableCoordinates[random]
}

月饼掉落就是在垂直方向上的位置发生了变化,而水平方向不变,那么让原有的月饼y坐标都递增,最后把新生成的月饼添加到列表的最后,在视觉上也就产生了持续有月饼掉落的效果了:

kotlin 复制代码
private fun moonCakeDown() {
    currentMoonCakes = currentMoonCakes.map { (x, y) -> x to y + 1 }.toMutableList().apply {
        add(getRandomMoonCake())
    }
    _moonCakeStateFlow.update { it.copy(moonCakes = currentMoonCakes) }
}

3、兔子的移动

无论兔子是往哪个方向移动,其实也是改变兔子的坐标,于是乎定义了一个移动方向的接口MoveDirection,移动事件触发时即调用move方法来获取兔子的新坐标:

kotlin 复制代码
sealed interface MoveDirection {
    fun move(currentRabbit: Coordinate): Coordinate
}

//左移
object LEFT : MoveDirection {
    override fun move(currentRabbit: Coordinate): Coordinate {
        return currentRabbit.first - 1 to currentRabbit.second
    }
}

//右移
object RIGHT : MoveDirection {
    override fun move(currentRabbit: Coordinate): Coordinate {
        return currentRabbit.first + 1 to currentRabbit.second
    }
}

代码逻辑很简单,声明了左移LEFT和右移RIGHT两个对象去实现MoveDirection接口,左移即x坐标减一而y坐标不变,而右移则x坐标加一而y坐标不变。

左右移动的逻辑有了,但实际上在棋盘上移动还需要考虑边界问题,当兔子处于棋盘最左侧时应该不再响应左移事件,同理在棋盘最右侧时也不再响应右移事件,因此ViewModel中对View层暴露的兔子移动接口可以这样来实现:

kotlin 复制代码
fun moveRabbit(direction: MoveDirection) {
    when (direction) {
        LEFT -> {
            if (currentRabbit.first == 0) return
            currentRabbit = direction.move(currentRabbit)
        }

        else -> {
            if (currentRabbit.first == MOON_CAKE_BOARD_WITH - 1) return
            currentRabbit = direction.move(currentRabbit)
        }
    }
    _moonCakeStateFlow.update { it.copy(rabbit = currentRabbit) }
}

4、吃月饼判断

兔子有没有吃到月饼,判断一下兔子的坐标是否跟某一个月饼的坐标一样就完事,简单粗暴!

kotlin 复制代码
fun isFoodEaten() = currentMoonCakes.contains(currentRabbit)

如果有月饼被吃到了,接下来就需要更新游戏得分,并且把被吃掉的月饼从棋盘上移除:

kotlin 复制代码
fun refreshFood() {
        if (isFoodEaten()) {
            currentScore += 1
            currentMoonCakes = currentMoonCakes.toMutableList().apply {
                removeIf { it == currentRabbit }
            }
            _moonCakeStateFlow.update { it.copy(moonCakes = currentMoonCakes, score = currentScore) }
        }
    }

5、游戏是否结束

kotlin 复制代码
fun isGameOver() {
        if (!isGaming) return
        val head = currentMoonCakes.firstOrNull() ?: return
        if (head.second < BOARD_SIZE - 1) return
        if (head.first == currentRabbit.first) return
        isGaming = false
        _moonCakeStateFlow.update {
            it.copy(isGaming = isGaming)
        }
    }

判断游戏是否结束需要同时检测以下的条件:

  • 月饼列表的长度是否已达到棋盘高度的上限
  • 最底部的月饼有没有被吃到,也就是月饼坐标跟当前兔子的坐标不一致

如果以上条件同时满足,那么Game Over!

6、开始游戏

到现在为止吃月饼游戏的功能点都写完了,接下来就是对这些函数进行组装,如同堆积木一般把各功能堆砌起来,最终ViewModel中游戏开始的逻辑就如下所示:

kotlin 复制代码
fun startGame() {
        if (isGaming) return
        currentRabbit = MOON_CAKE_BOARD_WITH / 2 to BOARD_SIZE - 1
        currentMoonCakes = emptyList()
        currentScore = 0
        isGaming = true
        _moonCakeStateFlow.update { MoonCakeState(currentRabbit, currentMoonCakes, currentScore,isGaming) } 
        viewModelScope.launch {
            while (isGaming) {
                delay(1000)
                moonCakeDown()
                refreshFood()
                isGameOver()
            }
        }
    }

在游戏开始之前重置兔子的位置、月饼列表、游戏得分等状态信息,在游戏进行时以每秒一次的频率循环控制以下逻辑自动执行:

graph LR 月饼掉落 --> 吃月饼判断 --> 更新月饼列表和游戏得分 --> 游戏是否结束 --> |游戏继续|月饼掉落

慢着,那兔子移动的功能跑哪里去了??别急,月饼的掉落跟兔子的移动是两个完全独立的功能,相互之间不影响,而兔子的坐标发生改变后只会影响吃月饼判断的结果,不管兔子有没有吃到月饼都不会干扰月饼源源不断的生成。

如此在ViewModel初始化的时候调用一下startGame,游戏就能自动开始了,游戏结束后用户重新触发一下startGame,即能开始新一轮的游戏。

组装完游戏功能下面就轮到组装游戏界面了,不得不说Compose写界面我个人感受就一个:舒服!万物皆函数,各种控件随意堆砌组合,单纯的数据驱动UI,着实是爽!好了话不多说,直接上代码:

kotlin 复制代码
//整个游戏界面
@Composable
fun MoonCakeGamingScreen(viewModel: MoonCakeViewModel = viewModel()) {
    val state by viewModel.moonCakeStateFlow.collectAsState()

    BoxWithConstraints(
        modifier = Modifier
            .background(MaterialTheme.colorScheme.primary)
            .padding(20.dp)
    ) {
        Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
            Text(
                text = "Moon Cake",
                fontSize = 20.sp,
                fontWeight = FontWeight.Bold,
                color = Color.White,
                modifier = Modifier.padding(15.dp)
            )

            Text(
                text = "Score: ${state.score}",
                fontSize = 28.sp,
                fontWeight = FontWeight.Bold,
                color = Color.White,
                modifier = Modifier.padding(15.dp)
            )

            GameBody(state = state, this@BoxWithConstraints.maxWidth)

            DirectionController(
                onLeft = { viewModel.moveRabbit(LEFT) },
                onRight = { viewModel.moveRabbit(RIGHT) }
            )

            GameOverDialog(state.isGaming, state.score, viewModel::startGame)
        }
    }
}

//绘制棋盘的格子
@Composable
fun GameBody(state: MoonCakeViewModel.MoonCakeState, height: Dp) {
    LazyHorizontalGrid(
        rows = GridCells.Fixed(BOARD_SIZE),
        modifier = Modifier
            .width(240.dp)
            .height(height)
            .border(2.dp, MaterialTheme.colorScheme.outline)
            .background(MaterialTheme.colorScheme.secondary),
        contentPadding = PaddingValues(5.dp),
        horizontalArrangement = Arrangement.SpaceEvenly
    ) {
        items(moonCakeBoard) {
            when (it) {
                state.rabbit -> {
                    Image(
                        painter = painterResource(id = R.drawable.ic_rabbit),
                        contentDescription = "",
                        modifier = Modifier.height(60.dp).fillMaxWidth()
                    )
                }

                in state.moonCakes -> {
                    Image(
                        painter = painterResource(id = R.drawable.ic_moon_cake),
                        contentDescription = "",
                        modifier = Modifier.height(60.dp).fillMaxWidth()
                    )
                }

                else -> {
                    Box(
                        modifier = Modifier.aspectRatio(1f).background(Color.Transparent)
                    )
                }
            }
        }
    }
}

//方向控制盘
@Composable
fun DirectionController(
    onLeft: () -> Unit,
    onRight: () -> Unit
) {
    Box(
        Modifier
            .fillMaxSize()
            .background(MaterialTheme.colorScheme.primary)
            .padding(20.dp, 10.dp)
    ) {
        val iconSize = 50.dp
        val colors =
            ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)

        Button(
            onClick = onLeft,
            modifier = Modifier.align(Alignment.CenterStart),
            colors = colors
        ) {
            Icon(Icons.Default.KeyboardArrowLeft, contentDescription = "", Modifier.size(iconSize))
        }
        Button(onClick = onRight, modifier = Modifier.align(Alignment.CenterEnd), colors = colors) {
            Icon(Icons.Default.KeyboardArrowRight, contentDescription = "", Modifier.size(iconSize))
        }

    }
}

//游戏结束的dialog
@Composable
fun GameOverDialog(isGaming: Boolean, score: Int, onConfirm: () -> Unit) {
    if (!isGaming)
        AlertDialog(
            onDismissRequest = {},
            confirmButton = {
                TextButton(onClick = onConfirm) { Text(text = "Retry") }
            },
            properties = DialogProperties(
                dismissOnBackPress = false,
                dismissOnClickOutside = false
            ),
            title = {
                Text(text = "Congratulations!")
            },
            text = {
                Column {
                    Text(text = "Your Score: $score")
                }
            },
        )
}

玩法升级:上蹿下跳超人兔??

本来吃月饼的小游戏算是完结了,立马分享给小伙伴体验一波,结果小伙伴一脸嫌弃,宛如一个憋了一肚子坏水的PM来给我加需求:你这不得劲,给我弄个超人兔子玩玩,能上蹿下跳的同时还能继续左右移动的......

好吧,得亏这功能写得够解耦,问题不大哈哈哈哈!要让兔子上蹿下跳只需要在原来LEFT和RIGHT的基础上再多加垂直方向上的实现:

kotlin 复制代码
object UP : MoveDirection {
    override fun move(currentRabbit: Coordinate): Coordinate {
        return currentRabbit.first to currentRabbit.second - 1
    }
}

object DOWN : MoveDirection {
    override fun move(currentRabbit: Coordinate): Coordinate {
        return currentRabbit.first to currentRabbit.second + 1
    }
}

UP即上移,只需将y坐标减1,同样的道理下移DOWN则是y+1。

写完了上移和下移的逻辑,这下轮到我来给兔子加戏了,给用户提供一个跳跃的按钮,触发一次跳跃则兔子向上移三格,跳跃完成后自动让兔子回落到棋盘底部,这一整个流程形成"上蹿下跳"的闭环,在兔子上蹿下跳没有完成之前用户再次触发跳跃事件则不响应,而在水平方向上的移动则不受影响,流程图大致如下:

graph TD refrash["刷新兔子坐标"] UP["点击跳跃"] --> isMovingVertical{isMovingVertical?} -->|否| 上移三格 --> 下移三格 --> refrash 上移三格 --> refrash 点击左移/右移 --> 执行左移/右移事件 --> refrash

大体的思路捋出来了,下面来看代码:

kotlin 复制代码
fun moveRabbit(direction: MoveDirection) {
    when (direction) {
        UP -> {
            if (isMovingVertical) return
            isMovingVertical = true
            viewModelScope.launch {
                var count = 0
                while (count < 3) {
                    currentRabbit = direction.move(currentRabbit)
                    refreshFood()
                    _moonCakeStateFlow.update { it.copy(rabbit = currentRabbit) }
                    delay(300L)
                    count++
                }
                count = 0
                while (count < 3) {
                    moveRabbit(DOWN)
                    refreshFood()
                    delay(300L)
                    count++
                }
                isMovingVertical = false
            }
        }

        DOWN -> {
            if (currentRabbit.second == BOARD_SIZE - 1) return
            currentRabbit = direction.move(currentRabbit)
            _moonCakeStateFlow.update { it.copy(rabbit = currentRabbit) }
        }

        LEFT -> {
            ...
        }

        else -> {
            ...
        }
    }
}

如代码所示,在原本移动兔子的方法里面增加UP和DOWN的判断处理:

  • 判断是否在垂直移动中,如果是则不响应UP事件;
  • 在响应UP事件的开始将垂直移动的标志位isMovingVertical置为true,然后开始循环执行兔子的上移,每上移一格做一次吃食物判断,并刷新兔子的坐标,直到完成三次上移动作为止;
  • 完成跳跃后循环执行兔子下落,同样的每移一格则判断兔子有没有吃到月饼和刷新坐标,直到兔子回落到底部;
  • "上蹿下跳"完成,重置isMovingVertical为false。

结语

到这里这个利用Compose写的吃月饼小游戏真的结束了,贴个最终的效果:

欢迎各位读者点个赞鼓励一下hhh!最后祝愿各位中秋快乐,身体健康,早日升职加薪提钱退休!

相关推荐
老哥不老11 分钟前
MySQL安装教程
android·mysql·adb
xcLeigh2 小时前
html实现好看的多种风格手风琴折叠菜单效果合集(附源码)
android·java·html
图王大胜2 小时前
Android SystemUI组件(07)锁屏KeyguardViewMediator分析
android·framework·systemui·锁屏
InsightAndroid2 小时前
Android通知服务及相关概念
android
aqi004 小时前
FFmpeg开发笔记(五十四)使用EasyPusher实现移动端的RTSP直播
android·ffmpeg·音视频·直播·流媒体
Leoysq4 小时前
Unity实现原始的发射子弹效果
android
起司锅仔5 小时前
ActivityManagerService Activity的启动流程(2)
android·安卓
猿小蔡5 小时前
Android Bitmap 和Drawable的区别
android
峥嵘life5 小时前
Android14 手机蓝牙配对后阻塞问题解决
android·智能手机
猿小蔡5 小时前
Android混淆不要怕--一文搞定
android