资讯动态

俄罗斯方块算法实战:从碰撞检测到旋转踢墙与7-Bag随机

发布时间:2026/10/17 18:30:43 来源:尧图企业网站定制
简介俄罗斯方块算法完整工程源码基于MSP430单片机用C语言实现面向嵌入式初学者和电子竞赛备赛者也可作为游戏算法课程设计参考。工程完整覆盖方块随机生成、左右移动、加速下落、九十度旋转、碰撞检测、整行消除与得分逻辑并对游戏结束条件做了处理。源码按主控制、显示驱动、按键扫描、蜂鸣提示、全局变量管理等模块拆分配有头文件结构清晰便于阅读和移植。压缩包共四十四份文件以C源码、头文件、目标文件、链接脚本及CCS工程配置为主附makefile构建脚本可在CCS环境直接编译烧录整体七十KB。目前已有五百六十九人学习借助这份工程可直观理解方块旋转的坐标变换、碰撞检测的边界判断、消除整行时的数组平移操作以及多模块协作的嵌入式软件组织方式对单片机游戏开发和课程设计均有直接参考价值。1. 俄罗斯方块算法的真正难点旋转与碰撞而不是堆形状谁能想到俄罗斯方块最考验算法的不是堆得多整齐而是旋转那一下。你横向摆好一根长条按一下旋转却卡进墙体想做 T 旋明明有空位方块却怎么都转不进去。这份资源是一套完整可跑的俄罗斯方块算法实现把 7 种方块定义、碰撞检测、旋转踢墙、行消除、7-Bag 随机和基础计分全部收在同一个代码包里逻辑层与渲染层分开改参数就能观察手感变化。适合做课程设计、复刻经典玩法或者作为小游戏 Demo 的地基。尤其是被旋转系统坑过的人这份资源的踢墙表能直接省掉你半天试错时间。2. 数据结构先行0/1 矩阵、7 种方块与碰撞检测的落地写法2.1 为什么用 0/1 矩阵而不是对象数组很多仿写项目习惯把每个方块写成带坐标的格子数组比如[{x:3,y:0},{x:4,y:0},{x:5,y:0},{x:6,y:0}]然后靠数组遍历去判断碰撞。这种做法在方块种类少的时候没问题一旦接旋转、踢墙、下一块预览就会陷入坐标同步的泥潭。这份资源用的是 0/1 矩阵整个场地是20×10的二维数组当前下落方块是一个4×4的矩阵1 表示实心0 表示空。用矩阵的好处有三个。第一旋转可以直接做矩阵转置不需要推导每个格子旋转后的新坐标第二碰撞检测退化成两层循环和一个if性能开销极小第三渲染层读取时只要遍历矩阵画色块逻辑与表现完全解耦。缺点是内存稍有浪费4×4 里很多格子是 0但对现代设备完全不构成压力。2.2 7 种方块的形状表与 4×4 旋转矩阵7 种标准方块用矩阵定义每个都放进 4×4 的容器里这样旋转时不需要处理不同尺寸的边界情况const SHAPES { I: [ [0,0,0,0], [1,1,1,1], [0,0,0,0], [0,0,0,0] ], J: [ [1,0,0,0], [1,1,1,0], [0,0,0,0], [0,0,0,0] ], L: [ [0,0,1,0], [1,1,1,0], [0,0,0,0], [0,0,0,0] ], O: [ [0,1,1,0], [0,1,1,0], [0,0,0,0], [0,0,0,0] ], S: [ [0,1,1,0], [1,1,0,0], [0,0,0,0], [0,0,0,0] ], T: [ [0,1,0,0], [1,1,1,0], [0,0,0,0], [0,0,0,0] ], Z: [ [1,1,0,0], [0,1,1,0], [0,0,0,0], [0,0,0,0] ], };注意O方块也必须放进 4×4 里并居中处理否则旋转时会因为中心点偏移产生轻微抖动。实际落地时你只需要实现一个顺时针旋转函数对所有方块统一调用function rotateMatrix(matrix) { const n matrix.length; const rotated Array.from({ length: n }, () Array(n).fill(0)); for (let row 0; row n; row) { for (let col 0; col n; col) { rotated[col][n - 1 - row] matrix[row][col]; } } return rotated; }这里的逻辑是把原矩阵的第row行第col列元素放到新矩阵的第col行第n - 1 - row列即把整个矩阵绕左上到右下的对角线翻转后再水平翻转。参数n统一为 4不传也可以但保留这个参数方便以后扩展六边形或更大网格。旋转后要用新的矩阵坐标去参与碰撞检测不能原地改原矩阵否则踢墙回退时会失去原始方向。2.3 碰撞检测实现边界、底部与重叠碰撞检测是俄罗斯方块算法里被调用最频繁的函数每一帧移动、旋转、下落都要跑一遍。核心规则只有三条出左边界或右边界算碰撞出底边界算碰撞目标位置已有落地方块算碰撞。这份资源里的实现是这样的function collides(board, shape, px, py) { for (let y 0; y shape.length; y) { for (let x 0; x shape[y].length; x) { if (!shape[y][x]) continue; const boardX px x; const boardY py y; if (boardX 0 || boardX 10) return true; if (boardY 20) return true; if (boardY 0 board[boardY][boardX]) return true; } } return false; }你可能会问为什么boardY 0时不直接判定碰撞因为新方块出生点在上方有时候预览方块会先出现在可见区域外旋转时短暂越过顶边是允许的只要不落地、不重叠就行。boardY 0这个判断保证不会访问负数下标数组不会报错同时把顶部的越界情况放行为合法状态。px和py是方块矩阵左上角在场地坐标中的位置这个约定贯穿整个资源包。判断时跳过矩阵中的 0 格子只有 1 才参与检测这就是 0/1 矩阵带来的性能收益。如果你在移植到其他语言时遇到“方块一半在墙里却不报碰撞”的诡异问题先检查是否忘记continue跳过 0 格子这个细节最容易漏。3. 旋转系统与踢墙表两张偏位移表解决穿墙问题3.1 朴素旋转在贴墙时的两次翻车如果直接把上一章的rotateMatrix结果套到当前坐标上你会立刻遇到两个问题。第一方块贴左墙时矩阵旋转后实心格子的相对位置变了原来离墙还有一格转完直接超出左边界第二方块落在一堆已落地块旁边时旋转后矩阵可能与邻居重叠但肉眼看上去明明有缝隙。这就是经典的新手翻车场景方块“穿墙”了。穿墙的根源在于4×4矩阵旋转是围绕矩阵左上角进行数学变换而不是围绕方块自身的视觉中心。I方块横着时占据第 1 行所有列旋转后变成第 1 列所有行实心格子的包围盒从 4 格宽变为 4 格高但坐标点(px, py)没有跟着变于是视觉上整根竖条向左或向右“跳”了一格。T 旋做不出来也是因为旋转后被墙或堆叠块挡住缺少一个“推一下”的机制。3.2 踢墙表从直觉到两张偏移表解决办法是旋转后如果发现碰撞不要直接放弃而是尝试把方块在附近小幅平移若干次。每次尝试的偏移量按顺序排列就是踢墙表。这份资源里内置的简化踢墙表如下针对顺时针旋转尝试次序X 偏移Y 偏移适用场景100原地旋转通常无遮挡时生效2-10左移一格解决贴左墙问题310右移一格解决贴右墙问题40-1上移一格解决顶部堆叠问题5-20深贴墙时的大幅左移620深贴墙时的大幅右移这张表是简化版生产级还分J/L/S/T/Z与I两套不同序列因为I方块的 4 格长度让它的包围盒特性和其他方块差异很大。你在这个资源包里可以直接替换成更完整的 SRS 踢墙表表格结构不变只是偏移序列变长。注意踢墙是旋转方向的函数顺时针逆时针要各配一张表不能混用。3.3 旋转与踢墙的完整实现const KICKS { cw: [ [0,0], [-1,0], [1,0], [0,-1], [-2,0], [2,0] ], ccw: [ [0,0], [1,0], [-1,0], [0,-1], [2,0], [-2,0] ], }; function tryRotate(board, shape, px, py, dir) { const rotated dir cw ? rotateMatrix(shape) : rotateMatrix(shape).reverse(); for (const [kx, ky] of KICKS[dir]) { const nx px kx; const ny py ky; if (!collides(board, rotated, nx, ny)) { return { ok: true, shape: rotated, px: nx, py: ny }; } } return { ok: false, shape, px, py }; }为了让逆时针旋转复用同一套矩阵函数我在ccw分支里对旋转结果做了.reverse()这是一种省事的写法顺时针旋转一次再反转数组等价于逆时针旋转一次。但如果你把旋转后的矩阵存回状态要记住shape已经指向新矩阵下一次旋转基于新矩阵继续做方向才不会乱。调用方不需要关心内部踢墙过程只要读返回值里的ok字段决定是否更新当前方块状态。很多仿写项目在这里踩坑直接让外部修改px结果踢墙成功后状态只改了一半渲染层和逻辑层不一致。建议所有旋转都走这一个函数不要在外面手动补偿位移。我一般会额外加一个调试开关在return { ok: true }前打印kx, ky你能直观看到每次旋转到底挪了几格找手感问题时特别有用。4. 主循环、消行与计分手感参数与级数公式怎么定4.1 帧锁定 vs 时间累加为什么 rAF 不能直接当节拍器新手最容易犯的错误是让方块在每一帧直接下落一格。60Hz 屏幕下看起来没问题可一旦浏览器标签页被切到后台再切回来requestAnimationFrame的帧间隔会突然变大方块会瞬间坠落好几行玩家直接归零。这份资源用的是时间累加方案逻辑与帧率完全无关let lastTime 0; let fallAccumulator 0; const BASE_INTERVAL 1000; // 单位毫秒1 秒下落一格 function gameLoop(timestamp) { const delta timestamp - lastTime; lastTime timestamp; fallAccumulator delta; while (fallAccumulator BASE_INTERVAL) { moveDown(); fallAccumulator - BASE_INTERVAL; } render(); requestAnimationFrame(gameLoop); }这里BASE_INTERVAL是下落一格所需的时间等级提升时把它缩小即可比如 5 级除以 1.510 级除以 2。用while而不是if是为了处理长时间卡顿后的补帧如果后台挂起 3 秒一次性补 3 次下落方块不会瞬移。但如果补帧次数太多这个while会连续跑几十次所以要加一个上限比如一次不超过 5 次超出部分直接丢给下一帧避免死循环卡爆主线程。render函数不接收delta参数因为渲染只负责画当前状态动画插值时可以自己在渲染层用performance.now()做逻辑层和渲染层完全分开这是这份资源包里两条线的分界点。4.2 硬降与延迟输入 DAS/ARR手感参数怎么调硬降是玩家按上方向键后方块直接落底处理逻辑最直接循环执行向下移动直到碰撞然后立刻落定。延迟输入 DAS 决定按住左右键多久开始连续移动ARR 决定连续移动的间隔。这两个参数直接决定游戏手感默认值可以用 160ms 的 DAS 和 40ms 的 ARR想要“跟手”就缩到 120ms / 30ms想要“经典怀旧感”就拉大到 200ms / 50ms。let dasTimer 0; let arrTimer 0; let heldDir null; function handleHorizontalInput(dir, delta) { if (heldDir ! dir) { heldDir dir; dasTimer 0; arrTimer 0; moveHorizontal(dir); return; } dasTimer delta; if (dasTimer DAS_THRESHOLD) { arrTimer delta; while (arrTimer ARR_INTERVAL) { moveHorizontal(dir); arrTimer - ARR_INTERVAL; } } }这段代码只在按住方向键时生效heldDir记录当前按下的方向换方向时重置计时器。moveHorizontal内部会调用collides判断所以即使按住不松也不会穿墙。参数调整时要同时改感感和难度DAS 太短会导致玩家还没看清位置方块就滑走太长会让高等级节奏变得笨重。我自己调试时习惯先定 DAS再定 ARR不要两个参数一起改否则分不清是哪边造成的手感问题。4.3 行消除扫描与计分公式消行算法看起来简单写起来容易错。正确做法是自底向上扫描记录满行索引然后一次删掉再补空行function clearLines(board) { let linesCleared 0; for (let y 19; y 0; y--) { if (board[y].every((cell) cell 1)) { board.splice(y, 1); board.unshift(new Array(10).fill(0)); linesCleared; y; // 删掉一行后当前位置后面一行上移需要重新检查同一索引 } } return linesCleared; }注意这段代码里y是关键所在。如果用splice删掉当前行原本在更上面的行会落下来占据当前索引如果不把y加回去就可能漏掉连续的多行。自底向上的顺序保证了unshift补的新空行在最顶部不会影响尚未扫描的下方区域。这里返回的是消除的行数计分逻辑放在调用方处理保持函数单一职责方便写测试用例去验证。计分公式很多版本各不相同这份资源用的是最经典的级数倍数消除行数基础得分乘以等级1100level × 12300level × 23500level × 34800level × 4连消四行直接给 800 基础分再乘以当前等级倍数配合每消除 10 行升一级的规则难度曲线就出来了。计分时还要额外记录软降和硬降的格数每格 1 分这样高手的操作分也能体现出来。这个细节不写也不影响运行但写了对项目完成度有很大提升。5. 算法实战避坑手册四段血泪调试记录5.1 旋转后整体“跳”了一格坐标基准没对齐现象方块在场地中央悬空旋转时位置正常贴墙旋转后突然整个跳进墙里一格或者跳到旁边方块内部。原因4×4矩阵旋转后非零格子的包围盒变了比如L方块从“右上角为头”变成“左上角为头”但px, py没重新计算。解决旋转完成后求新矩阵非零格子的最小x和最小y把px加上minXpy加上minY让包围盒对齐原位置。也可以提前把 7 种方块的旋转状态全部预计算好每个状态存好基准偏移旋转时直接查表。5.2 消行时高位行集体错位splice 方向反了现象同时消两行以上中间会残留一两列或者删掉的行正好是上面那行。原因从顶到底扫描时删除第一行后索引变化后续行号全部错位。解决要么从底向上扫描要么先收集所有满行索引再统一从高到低删除。我在第 4 章给的实现里用的是“从底到顶 删完补行 重查当前索引”的组合拳三管齐下连续消四行也不会漏。5.3 方块卡在 0.99999把坐标当浮点存了现象偶尔出现方块明明看起来没碰到墙却在移动时被判定碰撞甚至卡在某个坐标抖动。原因坐标累加用了浮点长时间运行后 0.9 0.1 累加出 5.0000000001 这种值碰撞检测里和整数边界比较时永远不相等。解决所有坐标、矩阵下标、碰撞判断全用整数。如果有需要做平滑动画的场合把渲染坐标用浮点单独存逻辑坐标保持整数两者在渲染函数里转换绝不能混用。这条算是最隐蔽的坑排查了很久才发现是浮点误差在作怪。5.4 同一局连出六个 S随机数没有洗牌现象连续多次出现同一种方块玩家觉得这个游戏“有 bug”。原因直接用Math.random()每次独立生成下一个方块概率上确实会出现同一种方块连续出好几次虽然平均分布没问题但体验极差。解决用 7-Bag 随机算法把所有 7 种方块放进一个“袋子”每次从袋子里不放回抽取抽完一袋再重新洗牌保证每 7 个方块里恰好每种出现一次。这个算法在下一章展开。6. 进阶手感7-Bag 洗牌、幽灵方块与无头验证法6.1 7-Bag 的实现7-Bag 实现起来非常短核心是“洗牌一次抽一张”let bag []; function getNextShape() { if (bag.length 0) { bag [I,J,L,O,S,T,Z]; for (let i bag.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [bag[i], bag[j]] [bag[j], bag[i]]; } } return bag.pop(); }每次抽完一张就从袋子里移除直到袋空再重新洗牌。这样连续 7 个方块最多只会出两次同一种方块手感和公平性都会大幅提升。如果你不想展示下一块可以把从这个函数拿到的下一个方块直接作为当前方块如果要做next队列就多调几次存进数组。6.2 幽灵方块一条投影幽灵方块是当前方块下落路径的预览实现思路是在当前位置不断向下试探碰撞直到第一次碰撞为止把最后合法的位置渲染成半透明方块。伪逻辑是let ghostY py; while (!collides(board, shape, px, ghostY 1)) ghostY;然后遍历矩阵把为 1 的格子画成半透明。注意幽灵方块只需在渲染层做不参与任何逻辑判断否则会引发二次落定的 bug。6.3 无头验证法最后分享一个习惯把碰撞、旋转、消行这些函数写成不依赖 DOM 的纯函数然后在命令行跑断言测试。比如直接断言collides在贴墙时返回true在空场地中央返回false。这份资源的代码结构本身就适合这种做法主循环和渲染层分离核心算法全部是纯函数。从那以后我每次改旋转表或消行逻辑都会强制走一遍无头用例再开渲染这个习惯帮我至少抓出五六次回归 bug。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑