ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用HTML+JS实现俄罗斯方块:碰撞检测、旋转与消行的完整指南

用HTML+JS实现俄罗斯方块:碰撞检测、旋转与消行的完整指南 简介俄罗斯方块算法完整工程包基于MSP430F1611单片机以C语言实现面向嵌入式开发初学者及对经典游戏算法感兴趣的编程爱好者。资源覆盖方块生成、移动旋转、碰撞检测、消行计分等核心模块代码按功能拆分为C源文件与头文件配合CCS集成开发环境工程文件可直接导入查看编译适合学习有限状态机、定时器控制及数组矩阵处理。压缩包共44个文件含7个C源文件与6个头文件以及完整CCS工程配置整体仅70KB结构精简清晰其中obj、out等编译产物便于对照检查生成流程cmd与map等链接映射文件可辅助分析中断向量与内存布局。已有565人浏览学习足见其实用价值。通过阅读和调试该工程可掌握俄罗斯方块从游戏逻辑到硬件控制端的完整算法落地思路提升嵌入式项目实战能力。1. 俄罗斯方块不只是小游戏先盯住形状生成与碰撞判定这两个核心俄罗斯方块在很多人眼里是个怀旧小游戏但真把它当算法项目来写你会发现最难的从来不是“让方块掉下来”而是形状怎么定义、旋转之后位置怎么算、边缘碰到之后怎么判定、满行怎么清算。这些才是算法题里不会直接告诉你的部分。我用 HTML JS 把整套逻辑收进单个 tetris.html浏览器打开就能跑代码可以直接复制没有任何构建工具依赖。这篇文章适合想在简历里放一个完整前端小项目的人也适合拿俄罗斯方块练数据结构与算法的同学——把碰撞、旋转、消行都自己写一遍比刷几十道算法题更接近真实开发。2. 七种方块的形状定义与旋转系统用数据表代替一堆 if-else2.1 用 4×4 二维数组表达七种 Tetromino俄罗斯方块里七个基础方块业内叫 Tetromino。最稳的定义方式不是写七个类而是用二维数组做“格子位图”。棋盘是 10×20 的网格每个格子有值0 表示空非 0 表示已被方块占据。下落中的方块也用一个 4×4 的小矩阵表示非 0 的格子代表实心。const SHAPES { I: [ [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0] ], O: [ [1, 1], [1, 1] ], T: [ [0, 1, 0], [1, 1, 1], [0, 0, 0] ], S: [ [0, 1, 1], [1, 1, 0], [0, 0, 0] ], Z: [ [1, 1, 0], [0, 1, 1], [0, 0, 0] ], J: [ [1, 0, 0], [1, 1, 1], [0, 0, 0] ], L: [ [0, 0, 1], [1, 1, 1], [0, 0, 0] ] };这段代码定义了七个形状的最小占位矩阵。3×3 的形状放进 4×4 的容器里是为了原地旋转时不出界I 形用 4×4 是因为横躺时占满一整行。O 形用 2×2 就够因为它任何方向旋转长得都一样单独引导不会浪费性能。我一般会把颜色跟形状放在同一个表里用索引而不是字符串去匹配颜色避免后面渲染时对不上号。SHAPES的 key 是形状名value 是矩阵。遍历矩阵时碰到非 0 值就认为这个格子有实体。这么设计的核心原因是碰撞检测不需要像素级精度格子级判断就够了。每个方块的移动就是局部坐标在棋盘坐标上做加法省掉大量几何计算。2.2 旋转实现转置加水平翻转以及 S/Z/O 的特殊处理旋转是俄罗斯方块里最容易翻车的地方。直观看顺时针旋转就是把矩阵转置之后再左右镜像标准矩阵操作就能完成但实测中 S、Z、O 三种形状旋转后视觉中心会偏移。新手常见的错误是给每个形状单独写一套旋转分支结果代码长得没法维护。function rotateClockwise(matrix) { const size matrix.length; const rotated Array.from({ length: size }, () Array(size).fill(0)); for (let y 0; y size; y) { for (let x 0; x size; x) { rotated[y][x] matrix[size - 1 - x][y]; } } return rotated; }这个函数对任意 N×N 矩阵做顺时针 90° 旋转映射关系是rotated[y][x] matrix[size - 1 - x][y]。O 形矩阵本身是正方形且对称旋转后没变化。S 和 Z 在 3×3 容器里旋转会得到正确的镜像结果这个结论我自己跑了好几轮才确认——视觉上觉得“不对”其实是因为旋转后需要配合踢墙位移见第 5 章。参数说明size是矩阵边长I 传 4其余传 3。旋转后返回新矩阵原矩阵不修改这样方便做“旋转后位置不合法就撤销”的回退逻辑。2.3 从形状到棋盘局部坐标、游戏区坐标与渲染坐标三层转换棋盘用一个 10×20 的二维数组保存已落定方块。下落中的方块持有一个全局坐标(pieceX, pieceY)和一个形状矩阵。判定位置是否合法时把局部非 0 格子换算成棋盘坐标boardX pieceX localXboardY pieceY localY。function isPositionValid(board, piece, pieceX, pieceY) { for (let y 0; y piece.length; y) { for (let x 0; x piece[y].length; x) { if (!piece[y][x]) continue; const boardX pieceX x; const boardY pieceY y; if (boardX 0 || boardX COLS || boardY ROWS) return false; if (boardY 0 board[boardY][boardX]) return false; } } return true; }这里有个容易忽略的点boardY 0时不直接返回false因为方块刚生成时有一部分在棋盘顶上方是正常的只要还没落进可视区就不算碰撞。这种“允许负坐标进入”的处理能避免新方块一出现就被误判为游戏结束。坐标系分三层别混着写局部坐标是 4×4 矩阵内的索引游戏区坐标是方块相对棋盘左上角的位置渲染坐标是画布上的像素位置。每次移动或旋转只更新pieceX和pieceY最终碰撞检测统一走isPositionValid这一个入口。整个棋盘的合法性判断只依赖这一个函数后面 AI 验证部分也会复用它。3. 碰撞检测、落定与满行消除从落地到清行的完整执行路径3.1 下落与落定把“碰到底部或已有方块”变成一步操作方块每下落一格先计算newY pieceY 1再调isPositionValid(board, piece, pieceX, newY)。合法就更新坐标不合法说明方块已经到底立即把当前方块合并进棋盘。function mergePiece(board, piece, pieceX, pieceY) { for (let y 0; y piece.length; y) { for (let x 0; x piece[y].length; x) { if (!piece[y][x]) continue; const boardX pieceX x; const boardY pieceY y; if (boardY 0) continue; board[boardY][boardX] piece[y][x]; } } }合并时同样要跳过boardY 0的格子否则会把超出棋盘顶部的方块数据写进数组导致越界。这里的piece[y][x]存的是形状索引也就是第 2.1 节里提到的颜色标识。合并后立刻检查满分行然后生成下一个方块。注意生成新方块的位置需要再校验一次如果初始位置就撞到已累积的方块游戏结束。这步校验很多简化版本会漏掉导致玩家能看到方块堆出棋盘顶还继续玩。3.2 满行消除与重力下移从下往上扫别从上往下消行算法看起来简单找到所有元素非零的行删掉再在棋盘顶部补一行空行。但顺序有讲究——必须从数组尾部往前扫因为splice会改变数组索引从下往上处理才不会漏行。function clearFullRows(board) { let rowsCleared 0; for (let y ROWS - 1; y 0; y--) { if (board[y].every(cell cell ! 0)) { board.splice(y, 1); board.unshift(new Array(COLS).fill(0)); y; // splice 后当前位置被新行替换重新检查该索引 rowsCleared; } } return rowsCleared; }y这行是整个函数的眼睛。splice删掉一行后上面的行全部下移原来y位置变成了“新的一行”如果不加y这一步就会被跳过。实际调这个函数时我用console.table打印过棋盘发现漏消行的概率非常大几乎都是因为这段逻辑写成了从上往下扫。计分规则可以参考官方计分制消 1 行 100 分2 行 300 分3 行 500 分4 行 800 分。这个分段计分不是拍脑袋4 行消掉带来的收益远大于单行消四次表格式的阶梯能给玩家更积极的即时反馈。3.3 等级与下落速度用消行总数控制计时器参数下落速度不是一个固定值它要跟等级挂钩。常见做法是按消行总数划分等级每级缩短下落间隔但设一个下限防止第八级以后变成“瞬移”没法玩。等级累计消行下落间隔ms备注10-9800起步节奏慢适合熟悉操作210-19700轻微加速320-29600开始有压迫感430-39500需要预览下一块540-49400常见翻车区1090100接近硬上限1514080低于此值不可玩把levelFromRows写成一个单独函数返回当前的fallInterval。这样计时逻辑只读一个变量不会跟渲染循环打架。注意这个表只做参考具体数值你可以按自己的手感调——我写的版本里把下限设 100ms因为 80ms 的窗口对键盘输入来说已经接近“每次按键落一格都来不及反应”的程度。4. 渲染与输入控制把逻辑状态画进 Canvas并处理键盘抖动4.1 Canvas 网格绘制坐标换算与双缓冲思维画布尺寸设为COLS * cellSize宽、ROWS * cellSize高cellSize取 30px得到一个 300×600 的棋盘。绘制时先画背景网格再画已落定方块最后画当前下落方块。const canvas document.getElementById(tetris-canvas); const ctx canvas.getContext(2d); const cellSize 30; const COLS 10; const ROWS 20; canvas.width COLS * cellSize; canvas.height ROWS * cellSize; function drawBoard(board) { ctx.fillStyle #1a1a2e; ctx.fillRect(0, 0, canvas.width, canvas.height); for (let y 0; y ROWS; y) { for (let x 0; x COLS; x) { const value board[y][x]; if (value) { ctx.fillStyle COLORS[value]; ctx.fillRect(x * cellSize, y * cellSize, cellSize - 1, cellSize - 1); } } } }画布宽度固定每帧全量重绘。cellSize - 1让每个格子之间留 1px 缝隙视觉上能看出网格线比用手动描线省事。COLORS是颜色索引表跟SHAPES[key]的下标对应。渲染函数只读 board 这个数据源不直接读游戏状态这样逻辑层和渲染层解耦——后面接 AI 时只需要改逻辑层画布根本不用动。4.2 键盘事件与输入缓冲为什么不用 keydown 直接改坐标直接把keydown事件绑定到方块移动上会踩一个钢板按住方向键时系统自动连发 keydown方块会瞬间瞬移好几格玩家根本来不及反应。解决方案是引入一个输入缓冲数组每帧只消费一个方向命令。let inputQueue []; document.addEventListener(keydown, (e) { if ([ArrowLeft, ArrowRight, ArrowDown, ArrowUp].includes(e.key)) { e.preventDefault(); inputQueue.push(e.key); } }); function consumeInput() { if (inputQueue.length 0) return; const action inputQueue.shift(); if (action ArrowLeft) tryMove(-1, 0); if (action ArrowRight) tryMove(1, 0); if (action ArrowDown) tryMove(0, 1); if (action ArrowUp) tryRotate(); }e.preventDefault()会拦住页面滚动不然方向键按下去页面也跟着挪。输入队列用 shift 取队首保证一次操作只移动一格按住方向键时是“每秒多格”而不是“每帧多格”手感和自动连发完全不同。4.3 主循环换成 requestAnimationFrame别再 setInterval 了很多人写小游戏用setInterval(gameTick, fallInterval)结果一开其它标签页浏览器为了省电会直接把定时器频率降到最低回来时方块已经堆到顶。rAF 不存在这个问题它跟随显示器刷新率走节能模式下依然稳定触发。let lastTime 0; let dropAccumulator 0; function gameLoop(timestamp) { const delta timestamp - lastTime; lastTime timestamp; dropAccumulator delta; while (dropAccumulator fallInterval) { dropAccumulator - fallInterval; tick(); } consumeInput(); drawBoard(board); drawCurrentPiece(); requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);思路是用累计器dropAccumulator把时间碎片拼起来超过fallInterval就触发一次下落。while而不是if是为了处理切换标签页回来时积压的几百毫秒时间差一次性补掉多次下落而不是让它堆到下一秒突变。4.4 下一个方块预览队列别只存 1 个预览区通常放在棋盘右侧显示接下来要出现的三个方块。实现里用一个长度为 3 的队列nextQueue每次合并方块后从队列头部取一个成为当前方块再从随机池补一个到队尾。function generateNextQueue() { const keys Object.keys(SHAPES); return Array.from({ length: 3 }, () keys[Math.floor(Math.random() * keys.length)]); }随机生成时直接洗牌而不是每次独立随机可以降低连续出现同一种方块的概率。预览长度设 3 的原因玩家需要提前规划放方块的位置只预览 1 块只能看一步3 块刚好能给出一点策略空间又不增加渲染负担。5. 俄罗斯方块避坑指南旋转踢墙、输入缓冲与消行计数五个坑5.1 旋转时方块直接穿墙或消失现象按上箭头旋转方块一半嵌进墙体甚至直接消失。原因旋转后的新矩阵在原位置不合法但代码只做了“合法就保留不合法就恢复原状”的简单回退没有尝试往左右挪动。解决加入 wall kick 偏移表。旋转后遍历一组偏移量[0, -1, 1, -2, 2]把方块依次平移到偏移位置第一个合法位置就是旋转后的最终位置。这组偏移是经典俄罗斯方块旋转系统的标准参数能解决绝大多数墙边旋转的问题。const WALL_KICK_X [0, -1, 1, -2, 2]; function tryRotate() { const rotated rotateClockwise(currentPiece.matrix); for (const offset of WALL_KICK_X) { if (isPositionValid(board, rotated, currentPiece.x offset, currentPiece.y)) { currentPiece.matrix rotated; currentPiece.x offset; return; } } }5.2 按住方向键后方块不受控地连跳现象按住右键不到一秒方块直接瞬移到最右边。原因keydown会按系统重复率连发每发一次都触发一次tryMove移动频率远高于下落频率。解决用输入缓冲队列每帧只消费一个命令。按住方向键时的效果是每秒大约移动 10-15 格跟下落逐格匹配不会瞬移。血泪经验千万不要在keydown里直接调tryMove。5.3 一次消两行只加了 100 分现象消了两行分数只加了 100明显不对。原因clearFullRows从上往下扫splice后索引错位漏掉了其中一行或者计分函数按“单次调用只计一行”处理。解决从下往上扫且y补偿索引同时把返回的rowsCleared一次传给计分函数用 2 行 300 分、3 行 500 分这种阶梯规则。5.4 下一块预览和实际落下的方块颜色对不上现象预览区显示红色方块实际落下变成蓝色。原因颜色表COLORS的下标跟SHAPES的遍历顺序不一致比如SHAPES里排第 3 位的是 TCOLORS下标 3 却配成了 L 的颜色。解决用同一个数组索引驱动。把形状和颜色放进一个数组索引一一对应不要分开定义。预览和实际渲染走同一个取值函数。5.5 切走标签页回来方块已经堆到顶现象切出去聊了两分钟天切回来发现游戏结束。原因setInterval在后台被浏览器降频回调堆积恢复后一次性触发多次下落。解决改用requestAnimationFrame 时间戳delta累积。这个方案下切后台再回来方块会一下子往下掉几格但不会直接判输玩家还有几秒能操作。6. 给游戏接一个 AI 验证器用启发式评分让机器自己先玩一遍写完一个俄罗斯方块最有效的验证不是肉眼盯着玩而是让机器自己玩。这能一次性暴露碰撞、消行、旋转回退三处逻辑问题。做法是写一个价值函数对每一块都模拟“旋转 4 个方向 × 每一列可落位置”选评分最高的落点。function evaluateBoard(board) { let fullRows 0; let holes 0; let aggregateHeight 0; for (let x 0; x COLS; x) { let columnHeight 0; let columnHole 0; let foundBlock false; for (let y 0; y ROWS; y) { if (board[y][x]) { columnHeight ROWS - y; foundBlock true; } else if (foundBlock) { columnHole; } } aggregateHeight columnHeight; holes columnHole; if (columnHeight ROWS) fullRows; } return -aggregateHeight * 0.5 - holes * 1.5; }这个价值函数有两个权重参数聚合高度权重 0.5空洞数权重 1.5。空洞对后续成功率的破坏远大于高度所以权重更高。你可以跑一组对照实验把空洞权重改成 0.8会发现 AI 很快就堆出无法填补的坑。完整 AI 玩法是对每种旋转形态在每列尝试放到不能再落计算摆放后的棋盘价值取最大价值的位置执行。跑 1000 轮如果高度波动稳定在 15 行以下整个游戏逻辑基本可信如果出现“方块悬空”或“消行数为负”的异常值一定是合并或清除逻辑有 bug。从那以后我每写完一个带状态的小游戏都会先写一段这样的自验脚本再人肉玩希望帮到你。本文还有配套的精品资源点击获取
返回列表