实现一个俄罗斯方块游戏
俄罗斯方块是最经典的小游戏了,不实现一下怎么能证明我是个合格程序员

俄罗斯方块是最经典的小游戏了,不实现一下怎么能证明我是个合格程序员。

叉腰

顺便“嘲讽”下我的某个朋友,这人大三说要做个小游戏给我玩,结果时至今日我还没看到,只好自己动手了。

游戏介绍

俄罗斯方块中的几种砖块

俄罗斯方块中的每个方块都占四个格子,其完全可以用一个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