Android,jetpack compose实现俄罗斯方块,简单案例♦️
代码如下:
package com.example.tetrisimport android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import kotlinx.coroutines.delay
import kotlin.math.maxval CELL_SIZE: Dp = 24.dp
val GRID_WIDTH = 10
val GRID_HEIGHT = 20
val PREVIEW_SIZE = 4data class Block(val x: Int, val y: Int, val color: Color)sealed class Shape(val blocks: List<Block>) {object I : Shape(listOf(Block(0, 0, Color.Cyan), Block(1, 0, Color.Cyan),Block(2, 0, Color.Cyan), Block(3, 0, Color.Cyan))) {override fun center(): Offset = Offset(1.5f, 0.5f)}object O : Shape(listOf(Block(0, 0, Color.Yellow), Block(1, 0, Color.Yellow),Block(0, 1, Color.Yellow), Block(1, 1, Color.Yellow))) {override fun center(): Offset = Offset(0.5f, 0.5f)override fun rotate(): Shape = this}object T : Shape(listOf(Block(0, 0, Color.Magenta), Block(1, 0, Color.Magenta),Block(2, 0, Color.Magenta), Block(1, 1, Color.Magenta))) {override fun center(): Offset = Offset(1f, 1f)}object L : Shape(listOf(Block(0, 0, Color(0xFFFFA500)), Block(1, 0, Color(0xFFFFA500)),Block(2, 0, Color(0xFFFFA500)), Block(2, 1, Color(0xFFFFA500)))) {override fun center(): Offset = Offset(1f, 1f)}object J : Shape(listOf(Block(0, 0, Color.Blue), Block(1, 0, Color.Blue),Block(2, 0, Color.Blue), Block(0, 1, Color.Blue))) {override fun center(): Offset = Offset(1f, 1f)}object S : Shape(listOf(Block(1, 0, Color.Green), Block(2, 0, Color.Green),Block(0, 1, Color.Green), Block(1, 1, Color.Green))) {override fun center(): Offset = Offset(1f, 1f)}object Z : Shape(listOf(Block(0, 0, Color.Red), Block(1, 0, Color.Red),Block(1, 1, Color.Red), Block(2, 1, Color.Red))) {override fun center(): Offset = Offset(1f, 1f)}abstract fun center(): Offsetopen fun rotate(): Shape {val centerPoint = this.center()val rotatedBlocks = this.blocks.map { block ->val x = (block.x - centerPoint.x)val y = (block.y - centerPoint.y)val newX = (y + centerPoint.x).toInt()val newY = (-x + centerPoint.y).toInt()block.copy(x = newX, y = newY)}return ShapeImpl(rotatedBlocks, centerPoint)}companion object {fun random(): Shape = when ((0..6).random()) {0 -> I1 -> O2 -> T3 -> L4 -> J5 -> S6 -> Zelse -> I}}
}private class ShapeImpl(blocks: List<Block>, private val centerPoint: Offset) : Shape(blocks) {override fun center(): Offset = centerPointoverride fun rotate(): Shape = super.rotate()
}enum class GameState { PLAYING, PAUSED, GAME_OVER }enum class Difficulty(val initialDropDelay: Long) {EASY(1000L),MEDIUM(500L),HARD(200L)
}@Composable
fun SetupScreen(onStartGame: (Difficulty) -> Unit) {var selectedDifficulty by remember { mutableStateOf(Difficulty.MEDIUM) }val difficulties = Difficulty.values().toList()Column(modifier = Modifier.fillMaxSize().padding(16.dp),horizontalAlignment = Alignment.CenterHorizontally,verticalArrangement = Arrangement.Center) {Text("俄罗斯方块", style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(bottom = 32.dp))Text("选择难度:", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 8.dp))LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {items(difficulties.size) { index ->val difficulty = difficulties[index]Card(onClick = { selectedDifficulty = difficulty },modifier = Modifier.fillMaxWidth(),colors = if (selectedDifficulty == difficulty) {CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)} else {CardDefaults.cardColors()}) {Text(text = difficulty.name,modifier = Modifier.padding(16.dp),style = MaterialTheme.typography.bodyLarge)}}}Spacer(modifier = Modifier.height(32.dp))Button(onClick = { onStartGame(selectedDifficulty) },modifier = Modifier.fillMaxWidth()) {Text("开始游戏")}}
}@Composable
fun TetrisGameContent(initialDropDelay: Long, onExit: () -> Unit) {val density = LocalDensity.currentval canvasWidth = with(density) { CELL_SIZE.toPx() * GRID_WIDTH }val canvasHeight = with(density) { CELL_SIZE.toPx() * GRID_HEIGHT }val cellPx = with(density) { CELL_SIZE.toPx() }var grid by remember { mutableStateOf<List<Block>>(emptyList()) }var currentShape by remember { mutableStateOf(Shape.random()) }var currentPosition by remember { mutableStateOf(Offset(GRID_WIDTH / 2 - 1f, 0f)) }var nextShape by remember { mutableStateOf(Shape.random()) }var score by remember { mutableStateOf(0) }var level by remember { mutableStateOf(1) }var linesClearedTotal by remember { mutableStateOf(0) }var gameState by remember { mutableStateOf(GameState.PLAYING) }val baseDropDelay = initialDropDelayval dropDelay = (baseDropDelay - (level - 1) * 50L).coerceAtLeast(100L)fun clearLines(): Int {// 1. 找出需要消除的行val linesToClear = (0 until GRID_HEIGHT).filter { y ->(0 until GRID_WIDTH).all { x -> grid.any { it.x == x && it.y == y } }}if (linesToClear.isEmpty()) return 0// 2. 从网格中移除这些行的方块grid = grid.filterNot { it.y in linesToClear }// 3. 计算每一行需要下落的格数 (关键修复部分)val dropCounts = IntArray(GRID_HEIGHT) { 0 }var linesToDrop = 0// 从底部开始向上遍历for (y in GRID_HEIGHT - 1 downTo 0) {if (y in linesToClear) {linesToDrop++ // 遇到要消除的行,增加下落计数} else {dropCounts[y] = linesToDrop // 非消除行需要下落 linesToDrop 行}}// 4. 根据 dropCounts 更新方块的 Y 坐标grid = grid.map { block ->val drop = dropCounts[block.y]block.copy(y = block.y + drop)}return linesToClear.size}fun isValidPosition(dx: Float = 0f, dy: Float = 0f, shape: Shape = currentShape): Boolean {val newX = currentPosition.x + dxval newY = currentPosition.y + dyreturn shape.blocks.all { block ->val x = (newX + block.x).toInt()val y = (newY + block.y).toInt()x in 0 until GRID_WIDTH && y >= 0 && y < GRID_HEIGHT && grid.none { it.x == x && it.y == y }}}fun lockCurrentShape() {grid = grid + currentShape.blocks.map {Block(x = (currentPosition.x + it.x).toInt(),y = (currentPosition.y + it.y).toInt(),color = it.color)}val linesCleared = clearLines()linesClearedTotal += linesClearedscore += when (linesCleared) {1 -> 100 * level2 -> 300 * level3 -> 500 * level4 -> 800 * levelelse -> 0}level = (linesClearedTotal / 10) + 1currentShape = nextShapenextShape = Shape.random()currentPosition = Offset(GRID_WIDTH / 2 - 1f, 0f)if (!isValidPosition()) {gameState = GameState.GAME_OVER}}fun rotate() {if (gameState != GameState.PLAYING || currentShape is Shape.O) returnval rotatedShape = currentShape.rotate()val kicks = listOf(0f to 0f, -1f to 0f, 1f to 0f, 0f to -1f)for ((dx, dy) in kicks) {if (isValidPosition(dx = dx, dy = dy, shape = rotatedShape)) {currentShape = rotatedShapecurrentPosition = currentPosition.copy(x = currentPosition.x + dx, y = max(0f, currentPosition.y + dy))return}}}LaunchedEffect(gameState, dropDelay) {while (gameState == GameState.PLAYING) {delay(dropDelay)if (isValidPosition(dy = 1f)) {currentPosition = currentPosition.copy(y = currentPosition.y + 1f)} else {lockCurrentShape()}}}fun restart() {grid = emptyList()currentShape = Shape.random()nextShape = Shape.random()currentPosition = Offset(GRID_WIDTH / 2 - 1f, 0f)score = 0level = 1linesClearedTotal = 0gameState = GameState.PLAYING}fun endGame() {gameState = GameState.GAME_OVERonExit()}fun moveLeft() {if (gameState == GameState.PLAYING && isValidPosition(dx = -1f)) {currentPosition = currentPosition.copy(x = currentPosition.x - 1f)}}fun moveRight() {if (gameState == GameState.PLAYING && isValidPosition(dx = 1f)) {currentPosition = currentPosition.copy(x = currentPosition.x + 1f)}}fun moveDown() {if (gameState == GameState.PLAYING) {if (isValidPosition(dy = 1f)) {currentPosition = currentPosition.copy(y = currentPosition.y + 1f)} else {lockCurrentShape()}}}fun drop() {if (gameState == GameState.PLAYING) {while (isValidPosition(dy = 1f)) {currentPosition = currentPosition.copy(y = currentPosition.y + 1f)}lockCurrentShape()}}Column(modifier = Modifier.fillMaxSize().padding(top = 48.dp, start = 8.dp, end = 8.dp, bottom = 8.dp),horizontalAlignment = Alignment.CenterHorizontally) {Row(modifier = Modifier.fillMaxWidth(),horizontalArrangement = Arrangement.SpaceBetween) {Text("得分: $score", style = MaterialTheme.typography.titleMedium)Text("等级: $level", style = MaterialTheme.typography.titleMedium)Text("行数: $linesClearedTotal", style = MaterialTheme.typography.titleMedium)}Spacer(Modifier.height(8.dp))Row(modifier = Modifier.fillMaxWidth(),horizontalArrangement = Arrangement.Center,verticalAlignment = Alignment.Top) {Box {Canvas(modifier = Modifier.size(with(density) { CELL_SIZE * GRID_WIDTH }, with(density) { CELL_SIZE * GRID_HEIGHT }).padding(4.dp)) {for (i in 0..GRID_WIDTH) {drawLine(color = Color.Gray.copy(alpha = 0.3f),start = Offset(i * cellPx, 0f),end = Offset(i * cellPx, size.height),strokeWidth = 1f)}for (j in 0..GRID_HEIGHT) {drawLine(color = Color.Gray.copy(alpha = 0.3f),start = Offset(0f, j * cellPx),end = Offset(size.width, j * cellPx),strokeWidth = 1f)}grid.forEach { block ->drawRect(color = block.color,topLeft = Offset(block.x * cellPx, block.y * cellPx),size = Size(cellPx, cellPx))drawRect(color = Color.Black,topLeft = Offset(block.x * cellPx, block.y * cellPx),size = Size(cellPx, cellPx),style = Stroke(width = 1f))}if (gameState != GameState.GAME_OVER) {currentShape.blocks.forEach { block ->val x = (currentPosition.x + block.x) * cellPxval y = (currentPosition.y + block.y) * cellPxdrawRect(color = block.color,topLeft = Offset(x, y),size = Size(cellPx, cellPx))drawRect(color = Color.Black,topLeft = Offset(x, y),size = Size(cellPx, cellPx),style = Stroke(width = 1f))}}}when (gameState) {GameState.PAUSED -> {Box(modifier = Modifier.matchParentSize().background(Color.Black.copy(alpha = 0.7f)),contentAlignment = Alignment.Center) {Text("暂停", color = Color.White, style = MaterialTheme.typography.headlineMedium)}}GameState.GAME_OVER -> {Box(modifier = Modifier.matchParentSize().background(Color.Black.copy(alpha = 0.7f)),contentAlignment = Alignment.Center) {Text("游戏结束", color = Color.White, style = MaterialTheme.typography.headlineMedium)}}else -> {}}}Spacer(Modifier.width(16.dp))Column(modifier = Modifier.width(IntrinsicSize.Min),horizontalAlignment = Alignment.Start) {Text("下一个:", style = MaterialTheme.typography.titleMedium)Spacer(Modifier.height(4.dp))Canvas(modifier = Modifier.size(with(density) { CELL_SIZE * PREVIEW_SIZE })) {val offsetX = (PREVIEW_SIZE - nextShape.blocks.maxOfOrNull { it.x }!! - 1) / 2fval offsetY = (PREVIEW_SIZE - nextShape.blocks.maxOfOrNull { it.y }!! - 1) / 2fnextShape.blocks.forEach { block ->val cx = (block.x + offsetX) * cellPxval cy = (block.y + offsetY) * cellPxdrawRect(color = block.color,topLeft = Offset(cx, cy),size = Size(cellPx, cellPx))drawRect(color = Color.Black,topLeft = Offset(cx, cy),size = Size(cellPx, cellPx),style = Stroke(width = 1f))}}Spacer(Modifier.height(8.dp))Row {Button(onClick = {if (gameState == GameState.PLAYING) gameState = GameState.PAUSEDelse if (gameState == GameState.PAUSED) gameState = GameState.PLAYING},enabled = gameState != GameState.GAME_OVER,modifier = Modifier.weight(1f),colors = ButtonDefaults.buttonColors(contentColor = Color.Red)) {Text(if (gameState == GameState.PAUSED) "继续" else "暂停")}Spacer(Modifier.width(8.dp))Button(onClick = ::restart,modifier = Modifier.weight(1f),colors = ButtonDefaults.buttonColors(contentColor = Color.Red)) {Text("重新开始")}}Spacer(Modifier.height(8.dp))Button(onClick = ::endGame,enabled = gameState != GameState.GAME_OVER,modifier = Modifier.fillMaxWidth(),colors = ButtonDefaults.buttonColors(contentColor = Color.Red)) {Text("结束游戏")}}}Spacer(Modifier.height(16.dp))Column(modifier = Modifier.fillMaxWidth(),horizontalAlignment = Alignment.CenterHorizontally) {Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {Button(onClick = { moveLeft() },enabled = gameState == GameState.PLAYING,modifier = Modifier.size(80.dp, 50.dp)) {Text("左")}Button(onClick = { moveDown() },enabled = gameState == GameState.PLAYING,modifier = Modifier.size(80.dp, 50.dp)) {Text("下")}Button(onClick = { moveRight() },enabled = gameState == GameState.PLAYING,modifier = Modifier.size(80.dp, 50.dp)) {Text("右")}}Spacer(Modifier.height(16.dp))Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {Button(onClick = { rotate() },enabled = gameState == GameState.PLAYING,modifier = Modifier.size(120.dp, 50.dp)) {Text("旋转")}Button(onClick = { drop() },enabled = gameState == GameState.PLAYING,modifier = Modifier.size(120.dp, 50.dp)) {Text("DROP")}}}}if (gameState == GameState.GAME_OVER) {Dialog(onDismissRequest = { }) {Surface(shape = MaterialTheme.shapes.medium,color = MaterialTheme.colorScheme.surface,tonalElevation = 6.dp) {Column(modifier = Modifier.padding(24.dp),horizontalAlignment = Alignment.CenterHorizontally) {Text("游戏结束", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineSmall)Spacer(Modifier.height(12.dp))Text("你的得分:$score", style = MaterialTheme.typography.bodyMedium)Text("等级:$level", style = MaterialTheme.typography.bodyMedium)Text("消除行数:$linesClearedTotal", style = MaterialTheme.typography.bodyMedium)Spacer(Modifier.height(20.dp))Button(onClick = ::restart) {Text("重新开始")}}}}}
}@Preview(showBackground = true, device = "spec:width=1080px,height=2400px,dpi=440")
@Composable
fun PreviewSetupScreen() {MaterialTheme {SetupScreen {}}
}@Preview(showBackground = true, device = "spec:width=1080px,height=2400px,dpi=440")
@Composable
fun PreviewTetrisGame() {MaterialTheme {TetrisGameContent(Difficulty.MEDIUM.initialDropDelay, {})}
}class MainActivity : ComponentActivity() {override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)var showSetup by mutableStateOf(true)var selectedDifficulty by mutableStateOf<Difficulty?>(null)val goToSetupScreen = {showSetup = trueselectedDifficulty = null}setContent {MaterialTheme {if (showSetup) {SetupScreen { difficulty ->selectedDifficulty = difficultyshowSetup = false}} else {selectedDifficulty?.let { difficulty ->TetrisGameContent(difficulty.initialDropDelay, onExit = goToSetupScreen)} ?: run {Text("Error: Difficulty not selected")}}}}}
}
这段 Kotlin 代码使用 Jetpack Compose 框架实现了一个完整的俄罗斯方块(Tetris)游戏。
以下是代码逻辑的总结:
-
核心数据结构:
Block
: 代表一个方块,包含其在网格中的坐标 (x
,y
) 和颜色 (color
)。Shape
: 一个sealed class
,定义了七种不同的俄罗斯方块(I, O, T, L, J, S, Z)。每个形状由其构成的Block
列表定义,并提供了旋转中心点和旋转方法。O
形是特殊的,它不能旋转。ShapeImpl
是一个私有类,用于处理旋转后形状的创建。GameState
: 枚举类型,表示游戏的三种状态:PLAYING
(游戏中)、PAUSED
(暂停)、GAME_OVER
(游戏结束)。Difficulty
: 枚举类型,定义了三种难度(EASY
,MEDIUM
,HARD
),主要通过初始的方块下落速度(initialDropDelay
)来区分。
-
UI 组件:
SetupScreen
: 游戏开始前的设置界面,允许玩家选择难度(EASY
,MEDIUM
,HARD
)并点击“开始游戏”按钮。TetrisGameContent
: 游戏的主界面,包含了游戏逻辑和 UI 渲染。MainActivity
: Android 的主活动,负责管理SetupScreen
和TetrisGameContent
之间的切换。
-
游戏逻辑 (
TetrisGameContent
):- 状态管理: 使用
remember
和mutableStateOf
来管理游戏状态,如游戏网格 (grid
)、当前下落的方块 (currentShape
) 及其位置 (currentPosition
)、下一个方块 (nextShape
)、得分 (score
)、等级 (level
)、总消除行数 (linesClearedTotal
) 和游戏状态 (gameState
)。 - 游戏循环: 使用
LaunchedEffect
启动一个协程,根据当前等级计算出的dropDelay
(方块自动下落的时间间隔)来驱动游戏。在PLAYING
状态下,协程会定期检查方块是否可以向下移动,如果可以则移动,否则调用lockCurrentShape
。 - 移动与旋转:
moveLeft
,moveRight
,moveDown
: 处理左右移动和软降(手动下移)。它们会先检查移动后的位置是否有效(isValidPosition
),有效则执行移动。rotate
: 处理方块旋转。对于非O
形方块,它先计算旋转后的新形状,然后尝试在原位及几个偏移位置(踢墙)放置旋转后的方块,如果找到有效位置则执行旋转和可能的位移。drop
: 硬降(瞬间下落)。将当前方块尽可能地向下移动直到无法移动,然后锁定。
- 碰撞检测:
isValidPosition
函数检查给定位置(或移动/旋转后的位置)的方块是否与游戏边界或已锁定的方块发生冲突。 - 锁定与消行:
lockCurrentShape
: 当方块无法再下落时被调用。它将当前方块的所有Block
添加到grid
中。clearLines
: 检查grid
中是否有满行,如果有则移除这些行,并将上方的方块下移相应行数。同时根据消除的行数计算得分并更新等级。
- 得分与等级: 得分基于消除的行数和当前等级。等级随着总消除行数的增加而提升,等级越高,方块下落速度越快(
dropDelay
减小)。 - 游戏控制:
restart
: 重置所有游戏状态,开始新游戏。endGame
: 将游戏状态设为GAME_OVER
并返回设置界面。- 暂停/继续按钮可以切换
PLAYING
和PAUSED
状态。
- UI 渲染:
- 使用
Canvas
绘制游戏网格、已锁定的方块和当前下落的方块。 - 绘制下一个方块的预览。
- 显示得分、等级、行数。
- 根据
gameState
显示“暂停”或“游戏结束”的覆盖层。 - 游戏结束时弹出
Dialog
显示最终得分信息,并提供“重新开始”选项。
- 使用
- 状态管理: 使用
-
流程:
- 应用启动后,首先显示
SetupScreen
。 - 玩家选择难度并点击“开始游戏”。
MainActivity
切换到TetrisGameContent
,传入选定的难度对应的初始下落延迟。TetrisGameContent
初始化游戏状态并启动游戏循环。- 玩家通过按钮控制方块移动、旋转、下落。
- 游戏自动处理方块下落、碰撞检测、锁定、消行、得分计算和等级提升。
- 当新方块无法在顶部中间位置生成时(即游戏区域被填满),游戏结束。
- 游戏结束后显示
Dialog
,玩家可以选择“重新开始”(调用restart
)或通过Dialog
外部或“结束游戏”按钮返回设置界面。
- 应用启动后,首先显示
总的来说,这是一个功能相对完整的俄罗斯方块游戏实现,涵盖了核心玩法、用户交互、状态管理和 UI 渲染。
效果图