手把手带你从零开始完整开发经典游戏【俄罗斯方块】,全部逻辑只用不到200行代码。


经典俄罗斯方块是经久难忘的游戏,可用电脑键盘快捷键或鼠标点击玩,也可用手机触屏玩;可设置初始行数和难度级别;会自动保存/复原上次玩的场景。

手把手带你从零开始完整开发经典游戏【俄罗斯方块】,全部逻辑只用不到200行代码。
整个过程在众触低代码应用平台进行,使用表达式描述游戏逻辑(高度简化版JS)。
本课程对数学抽象能力要求高,适合已基本掌握众触的开发者。

最终效果演示

先动手玩一玩(免注册):https://tetris.zc-app.cn/z

详尽的的教学请移步哔哩哔哩视频:https://www.bilibili.com/video/BV15B4y1T7EP

游戏规则

  1. 有一个用于摆放小型正方形的场地:宽10,高20。

  2. 一组由4个小型正方形组成的规则图形,共有7种,分别以S、Z、L、J、I、O、T这7个字母的形状来命名。

  3. 场地顶部随机地产生方块,以一定的规则进行移动、旋转、下落和摆放,锁定并填充到场地中。每次摆放如果将场地的一行或多行完全填满,则组成这些行的所有小正方形将被消除,并奖励一定的积分。而未被消除的方块会一直累积,并对后来的方块摆放造成各种影响。

  4. 随着积分的增加,方块的掉落速度也会逐渐增加,提升游戏难度和趣味性。

  5. 方块移到区域最下方或是落到其他方块上无法移动时,就会固定在该处。未被消除的方块堆放的高度超过场地所规定的最大高度时,游戏结束。

4种状态

就绪(ready)、下落(fall)、结束(over)、暂停(paused)。

得分规则

难度级别越高移动速度越快,下落和消除的得分也越高。
下落得分:1 * 级别;
消除得分:Math.pow(2, 消除行数) * 级别 * 10;

用二维数组矩阵控制场地的渲染

用二维数组表示方块所在的整个区域,$v.matrix = array(20, array(10)) 将得到一个10 * 20的全为0二维数组矩阵。0表示无方块,1表示有方块。
用两个嵌套的数据组件来渲染,外层的数据组件使用$v.matrix作为数据源,内层的使用外层提供的数据项$x作为数据源(本身是个一维数组),从上往下,从左往右输出到区域中。

比如这个矩阵数组:

[
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 1, 1, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 1, 1, 0, 0, 0, 0],
    [0, 0, 0, 0, 1, 1, 0, 0, 0, 0],
    [0, 0, 0, 1, 1, 1, 0, 0, 0, 0],
    [0, 0, 0, 1, 1, 1, 0, 0, 0, 0],
    [0, 0, 0, 1, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 1, 1, 1, 0, 0, 0, 0],
    [0, 0, 0, 1, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 1, 1, 1, 0, 0, 0, 0],
    [0, 0, 0, 1, 1, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 1, 1, 0, 0, 0, 0],
    [0, 0, 0, 0, 1, 0, 0, 0, 0, 0],
    [1, 0, 0, 0, 1, 1, 0, 0, 0, 0],
    [1, 0, 0, 0, 1, 1, 1, 1, 0, 0],
    [1, 0, 0, 1, 1, 1, 1, 1, 0, 0],
    [1, 1, 0, 1, 1, 1, 1, 1, 0, 1]
]

将渲染成:

注意观察矩阵里面的1和图中黑方块的位置对应关系。当数字1时给小方块一个类名($x ? "c" : ""),此处的c表示黑色color。

7种俄罗斯方块的形状

玩家操控的是有一定的形状方块,用一个4 * 2的二维数组来渲染方块的形状:

[
    [
        [0, 0, 0, 0],
        [1, 1, 1, 1]
    ],
    [
        [0, 0, 1, 0],
        [1, 1, 1, 0]
    ],
    [
        [1, 0, 0, 0],
        [1, 1, 1, 0]
    ],
    [
        [1, 1, 0, 0],
        [0, 1, 1, 0]
    ],
    [
        [0, 1, 1, 0],
        [1, 1, 0, 0]
    ],
    [
        [0, 1, 1, 0],
        [0, 1, 1, 0]
    ],
    [
        [0, 1, 0, 0],
        [1, 1, 1, 0]
    ]
]

保存到$v.blocks变量中。留意数组里面的1所构成的形状(I、L、J、Z、S、O、T)。

键盘操控和点击操控

玩家可以通过键盘快捷键或鼠标点击(手机触摸)来控制游戏,所以一方面要捕捉按键事件一方面也要绑定click或touch事件,它们执行的是相同的函数。我们把这些逻辑表达式写在$c.exp全局变量里。
我们把按键编码($event.keyCode)和动作名称关联起来,放在$v.KEYS里:

{
    "32": "drop",
    "37": "moveLeft",
    "38": "rotate",
    "39": "moveRight",
    "40": "down",
    "80": "pause",
    "82": "replay",
    "83": "sound"
}

当按下按键(onKeyDown)或按下鼠标(onMouseDown)的时候执行:

$v.activeKey = $event.keyCode
$c.exp[$v.KEYS[$v.activeKey]].exc()

activeKey表示当前按下的按键,$v.KEYS[$v.activeKey]是上面绑定的要执行的动作名称,这些动作的定义都放在$c.exp中,会在下面的篇幅种逐个讲解。

当弹起按键(onKeyUp)或松开鼠标(onMouseUp)的时候执行:

$v.activeKey = undefined
$(".key > .active").removeClass("active")

游戏开始:start

$v.score = 0
$v.status = "fall"
$v.startline ? array($v.startline).forEach($c.exp.startline) : ""
$c.exp.next.exc()

得分设为0,状态设为下落(fall),如果起始行有设置那么初始化起始行,产生下一个俄罗斯方块。

初始化起始行:startline

$v.matrix[19 - $index] = array(10,1)
array(1 + $w.Math.floor($w.Math.random() * 5)).forEach('$v.matrix[19 - $ext.$index][$w.Math.floor($w.Math.random() * 9)] = 0')

把起始行的每一行的小方块先设置为1,然后随机挖空一些方块(设为0)

产生下一个俄罗斯方块:next

$v.currB = $v.nextB
$v.nextB = $v.blocks[$w.Math.floor($w.Math.random() * $v.blocks.length)]
$v.xy = [3, -1]
$c.exp.fall.exc()

$v.blocks里随机产生一个俄罗斯方块(currB表示当前方块current Block,nextB表示下一个方块next Block),并把它的位置设在最顶部(x起点为3,中间位置,y起点在-1,也就是还在矩阵外,看不见)。
通过$v.currB[$parent.$index - $v.xy[1]][$index - $v.xy[0]]) ? " c" : ""动态类名,新产生的方块也会在矩阵中渲染出来。

开始下落:fall

stopIf($v.status !== "fall" || ($l.fall && $l.fall !== $v.fall))
$c.exp.meetIf.exc()
$v.xy[1] = $v.xy[1] + 1
render()
timeout((10 - $v.level) * ($v.activeKey ? 200 : 100))
$c.exp.fall.exc()

如果不是处于下落状态或者正玩家正在进行速降操作时停止下落动作。
先碰撞检查,没问题的话,Y轴加一,渲染一下render(),这样就可以看到俄罗斯方块下落了一行。
根据玩家级别($v.level)来决定暂停时间(timeout),暂停时间越短下落速度越快,然后递归执行下落(自身)。

碰撞检查:meetIf

stopIf($v.status != "fall" || !$v.currB.length)
stopIf($v.currB.some('$x.some("$x && $v.matrix[$ext.$index + $v.xy[1] + 1][$index + $v.xy[0]] !== 0")'), $c.exp.meet)

如果状态不再是fall了(游戏结束了或玩家按了重玩),或者已经发生碰撞了($v.currB = [])不再继续检查。
假设当前俄罗斯方块的任何一个小方块(即$x == 1的情况)往下移动一行的位置上存在一个小方块,即表明会此次下落会发生碰撞。

发生碰撞了:meet

stopIf($v.xy[1] === -1, $c.exp.replay)
$v.currB.forEach('$x.forEach("$x ? $c.exp.meet2.exc() : null")')
$v.currB = []
render()
timeout(300)
$$(".matrix .red").removeClass("red")
timeout(300)
$v.score = ($v.score || 0) + $v.level
$l.clearY = []
$v.matrix.forEach('$x.includes(0) ? "" : $l.clearY.push($index)')
$l.clearY.length ? $c.exp.clearline.exc() : timeout(100)
$c.exp.next.exc()

如果发生碰撞的位置的Y轴是-1,说明是刚刚产生俄罗斯小方块就撞上了,那就game over了,执行重玩逻辑replay。
执行碰撞动作

碰撞动作:meet2

$v.matrix[$ext.$index + $v.xy[1]][$index + $v.xy[0]] = 1
$v.el.matrix.children[$ext.$index + $v.xy[1]].children[$index + $v.xy[0]].addClass("red")

把当前俄罗斯方块的位置落实到场地矩阵中,然后给这些碰撞的小方块添加red类名,使它们的颜色变红高亮。

继续执行上一个“发生碰撞了“逻辑,清空$v.currB,渲染一下,暂停300微秒,移除碰撞的小方块们的红色高亮,再暂停300微秒,根据玩家水平加分,因为玩家等级越高下落速度越高,赚取的分值也越高嘛。

检查哪些行(不包含0的行)可以消除:循环检查矩阵的每一行,如果一行的数组里没有0的小方块,那么说明此行都是1,一行填满了,可以消除。然后继续执行next逻辑,产生下一个方块继续下落。

消行:clearline

$l.clearY.forEach('$v.el.matrix.children[$x].addClass("red")')
timeout(300)
$l.clearY.forEach('$v.el.matrix.children[$x].removeClass("red")')
timeout(310)
$l.clearY.forEach('$v.matrix.splice($x, 1); $v.matrix.splice(0, 0, array(10, 0))')
$v.score = ($v.score || 0) + $w.Math.pow(2, $l.clearY.length) * $v.level * 10
$v.lines = ($v.lines || 0) + $l.clearY.length

给待消的行red类名,300微秒后撤销类名,视觉上有个红色闪烁效果。
从矩阵中删除待消行,并从矩阵最上方添加一个空白行。
根据待消行数和玩家级别增加分值,一次性消除的行数越多得分越高,是几何级别(2次方)的增加。
累加已消除行数。

下落过程中玩家可以操控俄罗斯方块的运动:左移、右移、旋转、速降,掉落。

左移:moveLeft

$l.lr = -1
$c.exp.moveLR.exc()

右移:moveRight

$l.lr = 1
$c.exp.moveLR.exc()

移动逻辑:moveLR

stopIf(!$v.activeKey || $v.currB.some('$x.some("$x && $v.matrix[$ext.$index + $v.xy[1]][$index + $v.xy[0] + $l.lr] !== 0")'))
stopIf($v.status === "paused", '$v.status = "fall"; ' + $c.exp.fall)
$v.xy[0] = $v.xy[0] + $l.lr
render()
timeout(150 - $v.level * 10)
$c.exp.moveLR.exc()

左右移动时要进行边界检查,不能滑倒左右边界:假如当前俄罗斯方块的任何一个小方块的X轴加上移动方向上的一步(即$l.lr)的位置上有1的小方块,说明要越界了,停止移动逻辑。
真实向左或向右移动一步,渲染一下,等待一下,玩家级别越好等待时间越少,即速度会越快。
如果用户还按着按键或鼠标不放(即第一行的!$v.activeKey),继续执行此移动逻辑,直到玩家松手或越界、碰撞为止。

旋转:rotate

$l.currB = []
$v.currB.length === 2 ? $c.exp.rotate2to4.exc() :  $c.exp.rotate4to2.exc()
stopIf($l.currB.some('$x.some("$x && $v.matrix[$ext.$index + $l.xy[1]][$index + $l.xy[0]] !== 0")'))
$v.currB = $l.currB
$v.xy = $l.xy

如果当前俄罗斯方块横放的(只有2行),那就把它竖起来(rotate2to4),否则就横躺下去。
注意$l是临时变量,是旋转过程中试探是否能旋转用的,先旋转一下(临时行列对调,进行坐标的转换后放到$l.currB里),再摆正位置(更改一下临时位置$l.xy)。

竖起来:rotate2to4

array(4).forEach('$l.currB.push([$v.currB[1][$index], $v.currB[0][$index]])')
$l.xy = [$v.xy[0] + 1, $v.xy[1] - 1]

横躺:rotate4to2

array(2).forEach('$l.currB.push([$v.currB[3][$index], $v.currB[2][$index], $v.currB[1][$index], $v.currB[0][$index]])')
$l.xy = [$v.xy[0] - 1, $v.xy[1] + 1]

试探完了检查一下是否会碰撞,OK后才真正实施旋转:把临时变量$l赋给$v

开始速降:down

stopIf($v.status === "paused", '$v.status = "fall"; ' + $c.exp.fall)
$v.fall = date()
$l.fall = $v.fall
$c.exp.downLoop.exc()

如果目前处于暂停状态,把状态调到下落状态并开始执行下落动作,但不往下执行速降动作。
通过$v.fall和$l.fall两个变量来排他执行,即速降时暂停自然下落。

速降逻辑:downLoop

$c.exp.meetIf.exc()
$v.xy[1] = $v.xy[1] + 1
render()
timeout(20 - $v.level)
stopIf($v.activeKey, $c.exp.downLoop)
$c.exp.fall.exc()

检查碰撞情况,OK的话往下降落一行,渲染一下,根据玩家等级等待非常短的时间,如果玩家没有送水就继续速降。玩家松手了则变成自然下落。

开始掉落:drop

stopIf($v.status === "over")
stopIf($v.status === "ready", $c.exp.start)
stopIf($v.status === "paused", '$v.status = "fall"; ' + $c.exp.fall)
$v.el.top.addClass("dropping")
$v.fall = date()
timeout(9)
$v.el.top.removeClass("dropping")
$c.exp.dropLoop.exc()

over了也就不掉落了。
如果是就绪状态按下的键则不掉落,而是开始游戏。
如果是暂停状态按下的键也不掉落,而是开始下落动作。

掉落逻辑:dropLoop

$c.exp.meetIf.exc()
$v.xy[1] = $v.xy[1] + 1
$c.exp.dropLoop.exc()

掉落跟速降很像,都是循环一行行下落直到碰撞或触底,但掉落从人眼看来是一步到位,没有中间下落过程,那是因为掉落的下落过程中不暂停,也不进行渲染(即使下落一行了没有渲染人眼也看不到)。

 

除了操控俄罗斯方块积木,玩家还可以暂停游戏、开关音效、重玩。

暂停游戏:pause

stopIf($v.status === "over")
stopIf($v.status === "ready", $c.exp.start)
stopIf($v.status === "fall", '$v.status = "paused"; ' + $c.exp.pauseAnim)
$v.status = "fall"
$c.exp.fall.exc()

over了也就没必要暂停了。
如果是就绪状态按下的键则转为开始游戏。
如果是下落状态按下的键把状态设置为暂停,开始暂停动画,否则转变成下落动作(暂停状态下再按一次时)。

暂停动画:pauseAnim

toggleClass($v.el.paused, "c")
timeout(300)
$v.status === "paused" ? $c.exp.pauseAnim.exc() : ""

把右侧的暂停图标变成一闪一闪的,没300微秒变换一次。

开关音效:sound

$v.music = !$v.music

音效处于打开状态下好些动作都会播放声音,可以看到那些动作里会有下面表达式:

$v.music && $audio.start(when, offset, duration)

重玩:replay

stopIf($v.status === "ready", $c.exp.start)
$v.currB = []
$v.hiscore = $w.Math.max($v.hiscore, $v.score)
$v.lines = 0
$v.matrix = array(20, array(10))
$v.status = "over"
render()
array(20).forEach('$v.el.matrix.children[19 - $index].addClass("c"); timeout(30)')
timeout(100)
array(20).forEach('$v.el.matrix.children[$index].removeClass("c"); timeout(30)')
$v.status = "ready"
render()
$c.exp.readyAnim.exc()

如果是就绪状态按下的键则转为开始游戏。
清空当前俄罗斯方块积木,计算最高分,累计行数归零,矩阵清零,状态设为over,渲染一下,此时看到是空场地。
然后做一个清屏动画:先从下网上逐个把所有小方块都变成黑色的,弄好一行暂停30微秒;全部弄好了暂停100微秒,从上往下又把前面弄的黑色清除,每清除一行暂停30微秒。

渲染一下后开始就绪状态下的恐龙动画

就绪动画:readyAnim

timeout(1000)
$(".readyscore").toggleClass("last")
$l.dragon = $(".dragon")
stopIf(!$l.dragon)
$l.dragonClass = "r"
array(40).forEach($c.exp.readyAnim2)
timeout(1000)
$l.dragonClass = "l"
array(40).forEach($c.exp.readyAnim2)
$v.status === "ready" ? $c.exp.readyAnim.exc() : ""

就绪状态下每隔一秒钟轮流现实最高分和上轮得分。
把恐龙的类名从右往左,从左往右每个隔一秒钟轮流一次,期间执行恐龙动画

恐龙动画:readyAnim2

$l.dragon.className = "dragon " + $l.dragonClass + ($index + 1) % 4
timeout(60)

每隔60微秒更换一次恐龙的类名,使它的眼镜和腿动起来。

场景保存/复原:visibilitychange

当网页关了、刷新了、切换窗口了,手机没电了,来电话中断了,都要保证下次回到游戏时可以从中断的地方继续玩。这些状态其实都是visibilitychange事件中的visibilityState的hidden状态。切换到hidden状态是将所有状态储存在localStorage。

$w.document.addEventListener('visibilitychange', func('$w.document.visibilityState === "hidden" ? $exp.save.exc() : $exp.restore.exc()'))

保存状态:save

localStorage("tetris", {music: $v.music, startline: $v.startline, level: $v.level, score: $v.score, lines: $v.lines, currB: $v.currB, nextB: $v.nextB, status: $v.status === "over" ? "ready" : $v.status, hiscore: $w.Math.max($v.hiscore, $v.score), matrix: $v.status === "over" ? array(20, array(10)) : $v.matrix, xy: $v.xy[1] > 19 ? [3, -1] : $v.xy})
$v.status === "fall" ? $v.status = "paused" : ""

不可见上(hidden)也把状态切换到暂停。

复原状态:restore

$l.LS = localStorage("tetris")
$v.score = $l.LS.score || 0
...
render()
$v.status === "fall" ? exc('timeout(2000); ' + $c.exp.fall) : ""

从localStorage读取状态数据后部分赋值给各个变量。
关键点是如果保存前是下落状态的话,稍等2秒等待用户反应后继续下落。

到此整个游戏逻辑就讲完了,大家可以用下面的问题复盘一下整个过程:

  • 怎样获取键盘输入?

  • 怎样控制方块的移动?

  • 游戏中的各种形状及整个游戏空间怎么用数据表示?

  • 游戏中怎么判断左右及向下移动的可能性?

  • 游戏中怎么判断某一形状旋转的可能性?

  • 按向下方向键时加速某一形状下落速度的处理?

  • 怎么判断某一形状已经到底?

  • 怎么判断某一行已经被填满?

  • 怎么消去某一形状落到底后能够消去的所有的行?

  • 怎样修改游戏板的状态?

  • 怎样统计分数?

  • 怎样处理升级后的加速问题?

  • 怎样判断游戏结束?

准备深入研究的同学请到https://www.zcappp.cn/course/tetris页面后,点击右侧的【克隆】按钮,把整个游戏复制一份随意玩弄更改。

更多教学视频请移步哔哩哔哩空间:https://space.bilibili.com/475645807,里面不仅有各种前端可视化案例演示和讲解,还有多个完整功能的网站应用案例的开发过程演示和讲解。

本案例于2021年6月25日仿自https://chvin.github.io/react-tetris,相关素材版权归属于原网站。本课程仅做教学用。