代码如下:
package com.example.tetris
import 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.max
val CELL_SIZE: Dp = 24.dp
val GRID_WIDTH = 10
val GRID_HEIGHT = 20
val PREVIEW_SIZE = 4
data 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(): Offset
open 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 -> I
1 -> O
2 -> T
3 -> L
4 -> J
5 -> S
6 -> Z
else -> I
}
}
}
private class ShapeImpl(blocks: List<Block>, private val centerPoint: Offset) : Shape(blocks) {
override fun center(): Offset = centerPoint
override 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.current
val 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 = initialDropDelay
val 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 + dx
val newY = currentPosition.y + dy
return 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 += linesCleared
score += when (linesCleared) {
1 -> 100 * level
2 -> 300 * level
3 -> 500 * level
4 -> 800 * level
else -> 0
}
level = (linesClearedTotal / 10) + 1
currentShape = nextShape
nextShape = 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) return
val 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 = rotatedShape
currentPosition = 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 = 0
level = 1
linesClearedTotal = 0
gameState = GameState.PLAYING
}
fun endGame() {
gameState = GameState.GAME_OVER
onExit()
}
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) * cellPx
val y = (currentPosition.y + block.y) * cellPx
drawRect(
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) / 2f
val offsetY = (PREVIEW_SIZE - nextShape.blocks.maxOfOrNull { it.y }!! - 1) / 2f
nextShape.blocks.forEach { block ->
val cx = (block.x + offsetX) * cellPx
val cy = (block.y + offsetY) * cellPx
drawRect(
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.PAUSED
else 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 = true
selectedDifficulty = null
}
setContent {
MaterialTheme {
if (showSetup) {
SetupScreen { difficulty ->
selectedDifficulty = difficulty
showSetup = 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 渲染。
效果图