俄罗斯方块是最经典的小游戏了,不实现一下怎么能证明我是个合格程序员。
顺便“嘲讽”下我的某个朋友,这人大三说要做个小游戏给我玩,结果时至今日我还没看到,只好自己动手了。
游戏介绍
俄罗斯方块中的每个方块都占四个格子,其完全可以用一个4x4矩阵表示。几种方块分别有自己的字母代号:I、J、L、O、S、T、Z。
游戏开始后一个随机方块从屏幕上方开始下落,玩家可以用键盘控制方块左右移动,或90度旋转。接触到地面或其他方块时固定在这里。完整的一行会被消除。当新出现的方块完全无法移动时,游戏结束。
游戏实现
方块表示
为了简便,我完全使用4x4矩阵来表示方块,不考虑多数方块其实完全可以用3x3矩阵,甚至2x2矩阵的情况。
因此,方块可以像以下方式一样表示:
private val brickArray = arrayOf(
/**
* □■□□
* □■□□
* □■□□
* □■□□
*/
arrayOf(
booleanArrayOf(false, true, false, false), // I
booleanArrayOf(false, true, false, false),
booleanArrayOf(false, true, false, false),
booleanArrayOf(false, true, false, false)
) to 0xf0f0,
...
)
当一个方块发生平移和旋转时,我不准备真的去修改这个数组的原始数据,而是记录这个方块进行过的操作。因为方块可以进行的操作很简单,无非两个方向平移和旋转90°的N倍。
class Brick(val blocks: Array<BooleanArray>, val color: Int) {
var transformC = 0
var transformR = 0
var rotate = 0
...
}
这样我们判断方块能否进行某个动作时,想要表示这个“变化后的状态”就简单很多了,因为只有这三个变量会发生变化。因此,封装一个“变化后的状态”:
class BrickState(private val brick: Brick, private val transformRProvider: () -> Int, private val transformCProvider: () -> Int, private val rotateProvider: () -> Int) {
private val transR get() = brick.transformR + transformRProvider()
private val transC get() = brick.transformC + transformCProvider()
private val rotate get() = (brick.rotate + rotateProvider()) % 4
operator fun get(r: Int, c: Int): Boolean {
// 应用平移变换
var originR = r - transR
var originC = c - transC
// 应用旋转操作
repeat(rotate) {
// i行j个元素,旋转后应该处于倒数i列j行
val (tmpR, tmpC) = originC to 3 - originR
originR = tmpR
originC = tmpC
}
return brick.blocks.getOrNull(originR)?.getOrNull(originC) ?: false
}
fun enumBlocks(block: (Int, Int) -> Boolean) {
for (i in transR .. transR + 4){
for (j in transC .. transC + 4) {
if (this[i, j]) {
if (!block(i, j)) {
return
}
}
}
}
}
}
我们可以对这个状态进行的操作也只有两个,一是提供某个坐标,判断这个砖块状态是否覆盖了这个坐标;二是遍历这个状态下砖块包含的所有小方块。
有了这个“变换后的状态”,就可以针对当前方块定义出一系列状态,方便后续动作的判断了。
class Brick(val blocks: Array<BooleanArray>, val color: Int) {
var transformC = 0
var transformR = 0
var rotate = 0
// 砖块重置到初始状态的状态,用于调试日志
val resetState = BrickState(this, { -transformR }, { -transformC }, { -rotate})
// 砖块当前状态
val currentState = BrickState(this, {0}, {0}, {0})
// 砖块向左移动状态
val leftState = BrickState(this, {0}, {-1}, {0})
// 砖块向右移动状态
val rightState = BrickState(this, {0}, {1}, {0})
// 砖块向下移动状态
val bottomState = BrickState(this, {1}, {0}, {0})
// 砖块旋转一下状态
val rotateState = BrickState(this, {0}, {0}, {1})
}
游戏区域
游戏区域定义为12x22的矩形区域,为了方便判断,给边界设置上一圈方块。
val data = Array(22) {
Array(12) { Block() }
}
fun initGame(updateCallback: () -> Unit) {
data.forEachIndexed { i, blocks ->
blocks.forEachIndexed { j, block ->
block.exist = i == 0 || i == data.size - 1 || j == 0 || j == blocks.size - 1 // 清空区域,边框设置为不可用
block.color = if (block.exist) 0x787878 else 0x0
}
}
...
}
移动和旋转
砖块的自然下落,或者玩家控制下的下落,以及左右运动甚至旋转变换操作,本质上都是差不多的实现方式。即,先检查下一个状态是否发生冲突,如果不发生冲突就将方块变换到下一个状态。
检查方块冲突的方式很简单,我们前面已经有了枚举砖块状态下所有坐标的能力,接下来用这个能力遍历状态的所有坐标,如果均不存在于固定方块中,就是不冲突。
private fun checkNextState(brickState: BrickState): Boolean {
var foundInvalid = false
brickState.enumBlocks { i, j ->
if (data.getOrNull(i)?.getOrNull(j)?.exist == true) {
foundInvalid = true
return@enumBlocks false
}
return@enumBlocks true
}
return !foundInvalid
}
实现了这个检查逻辑,上述变换操作的处理就比较简单了。以砖块的旋转为例,
fun rotate() {
if (checkNextState(fallingBrick.rotateState)) { // 旋转后的状态不冲突
fallingBrick.rotate++ // 当前砖块旋转一次
}
gameUpdateCallback?.invoke() // 游戏界面更新
}
固定落到底部的方块
上面没有用下落举例,是因为下落完成后要继续进行碰撞检测,检查是否落到底部,以及是否有可以消除的行,逻辑比较复杂。
方块落到底部,即方块再下落一次将会冲突。
timerJob = scope.launch {
while (true) {
delay(1000) // todo 逐渐增加难度
if (checkNextState(fallingBrick.bottomState)) {
fallingBrick.transformR++
gameUpdateCallback?.invoke()
continue
}
// 遍历区域,把brick写入area
fallingBrick.currentState.enumBlocks { i, j ->
data[i][j].exist = true
data[i][j].color = fallingBrick.color
true
}
...
}
}
检查消除行和执行消除
如果某一行完全是有方块的状态,那么这一行已经拼好可以消除。但是“消除”却不那么简单,因为首先需要让上方的行下落,还需要考虑连续消除的情况,以及注意不能把边界方块也给“消除”了。
这里我采用“双指针”拷贝的思路,两个“指针”ij指向最后一行,同步向上移动。如果遇到被消除的行,则只移动指针j,不断将j所在的行覆写到i所在的行,并特殊处理第一行。
private fun checkBingo(): Boolean {
// 检查是否有任何行已经达成
val bingoLines = mutableListOf<Int>()
data.forEachIndexed { index, blocks ->
if (index != 0 && index != data.size - 1 && blocks.all { it.exist }) {
bingoLines.add(index)
}
}
// 移除已经完成的行
if (bingoLines.isNotEmpty()) {
var i = data.size - 2
var j = data.size - 2
while (i > 0) {
if (i in bingoLines) {
while (j in bingoLines) {
j--
}
}
if (i != j) {
if (j > 0) {
// copy line j to line i
data[i].forEachIndexed { index, block ->
block.exist = data[j][index].exist
block.color = data[j][index].color
}
} else {
// fill line i with blank
data[i].forEachIndexed { index, block ->
if (index == 0 || index == data[i].size - 1) {
return@forEachIndexed
}
block.exist = false
block.color = 0x0
}
}
}
i--
j--
}
return true
}
return false
}
检查失败条件
新生成的砖块已经存在方块与固定方块重叠,则游戏结束。
if (!checkNextState(fallingBrick.currentState)) {
// 新生成的方块已经和下方方块重叠,游戏失败
// do something
break
}
源码和体验
源代码发布在了Github上。理论可以打包到Windows/Linux/安卓,和浏览器项目。这里我只打包了浏览器版本,反正打包也不是今天重点,你可以点击这里体验。
页面部署在Cloudflare Pages上,国内加载不算快。另外要求浏览器支持一个称作Wasm GC的特性才能打开——最新的Chrome和Firefox是没问题的。
最后修改于 2024-09-19