
简介基于HTML、CSS、JavaScript开发的俄罗斯方块前端游戏设计项目以zip压缩包形式发布面向前端初学者、游戏开发爱好者及毕业设计选题者。压缩包按功能划分为html、css、js、img四个目录html仅含index.html负责游戏页面结构css中的index.css完成界面美化js目录则是核心逻辑所在包含const.js常量配置、tetris.js方块类型与旋转变换、controller.js控制下落及消行判定、view.js动态绘制游戏区和下一方块表格。整体模块划分清晰便于学习浏览器游戏开发中的DOM渲染、键盘事件和状态管理。包内共有54个文件以36张png素材、5个xml工程配置、4个js源码、2个java文件及html/css/README等为主压缩包体积仅8.92MB轻量易运行。目前已有244人学习下载。通过源码可快速掌握俄罗斯方块的完整实现流程了解项目目录组织、方块碰撞检测与界面绘制技巧适合在此基础上二次扩展或优化。1. 一谈到俄罗斯方块前端游戏设计很多人直接去抄渲染代码结果卡在碰撞检测和方块旋转上俄罗斯方块可能是最适合前端练手的游戏之一规则直观、状态有限但要把一套干净的HTMLCSSJS实现跑起来难点不在“画”而在“算”。很多第一次独立写这个游戏的人往往先从Canvas或DOM结构下手画完几个方块后发现旋转时形状越界消行后上面区域没下沉快速下落时键盘响应失控。这些问题都不是靠调整样式能解决的而是游戏核心逻辑——网格模型与状态机——没有做好。本文不依赖任何框架用纯HTMLCSSJS走一遍完整方案从数据结构、渲染方案、交互逻辑到性能排错拆开讲清楚。你不需要读懂C语言版本的俄罗斯方块源码也能在本地用浏览器打开一份单文件代码看到可玩的成品。正文从两个核心选择展开渲染方案这里给出Canvas与DOM两种和碰撞引擎设计。两者选型会直接影响后续所有代码结构值得一开始就想明白。2. 俄罗斯方块前端游戏的核心网格数据模型与碰撞检测的JS实现2.1 用二维数组定义游戏面板而不是直接用CSS像素定位俄罗斯方块的所有状态几乎都可以映射到一张宽10高20的网格上。常见做法是定义board Array.from({ length: 20 }, () Array(10).fill(0))非0值表示此处已被方块占据。注意这里出现的“写死数组”并不是偷懒而是后续所有逻辑包括碰撞检测、消行判定、幽灵方块绘制都要基于这个固定网格来查询如果直接用CSS像素或Canvas坐标计算会瞬间陷入坐标转换的逻辑混乱。之所以推荐数组模型是因为几个核心操作都可以映射为纯数组操作活动方块落点查询变成“在数组坐标系中做叠加判断”消行变成“过滤非0行并unshift补齐空行”而旋转则由矩阵转置算法完成。当board结构稳定后渲染层变得极其简单即做一个“数组到画面的单向同步”。2.2 定义七种方块的形状矩阵与旋转规则俄罗斯方块的标准七种方块I、J、L、O、S、T、Z可以用4x4矩阵表达也可以用占位坐标列表表达。推荐用坐标列表理由是旋转和碰撞检测时不需要遍历整个4x4矩阵而是直接操作坐标点代码量更少。const SHAPES { I: [[0, 1], [1, 1], [2, 1], [3, 1]], J: [[0, 0], [0, 1], [1, 1], [2, 1]], L: [[2, 0], [0, 1], [1, 1], [2, 1]], O: [[1, 0], [2, 0], [1, 1], [2, 1]], S: [[1, 0], [2, 0], [0, 1], [1, 1]], T: [[1, 0], [0, 1], [1, 1], [2, 1]], Z: [[0, 0], [1, 0], [1, 1], [2, 1]], };这段代码中每个坐标[x, y]表示一个单元格在以其所在小方块组包围盒左上角为原点的基础位置。注意O方块田字形的旋转没有任何视觉变化但在很多实现里却最容易踩坑因为它的包围盒偏移会影响到旋转后位置矫正。旋转操作的实现一致采用公式(x, y) - (y, -x)即绕原点顺时针旋转90度。再配合一个“墙踢”机制旋转后如果位置非法则尝试左右偏移1~2列直到找到合法落点。SRSSuper Rotation System标准对每类方块有不同的踢墙表前端游戏通常不必完整实现但至少要做到“旋转失败时尝试左移或右移”。2.3 碰撞检测的两种典型时机移动时与固定时碰撞检测在俄罗斯方块里不是一个单独函数而是贯穿移动、旋转、落底与消行四个流程。基于网格模型判断逻辑十分收敛function collides(shape, offsetX, offsetY, board) { for (let [x, y] of shape) { const nx x offsetX; const ny y offsetY; if (nx 0 || nx 10 || ny 20) return true; if (ny 0 board[ny][nx]) return true; } return false; }这段判断里有两个参数值得解释。offsetX与offsetY表示当前活动方块的左上角坐标而ny 0的条件是允许方块在尚未完全进入可见区域时先做预判避免顶部出生时误报碰撞。由于HTML中的Canvas坐标系是Y轴向下所以这里判断“是否超出底部边界”用的是 20而不是负数判断。移动时碰撞用于拦截非法操作固定时碰撞则意味着当前方块不能再下落触发“写回board并检查消行”。这里有个新手常见的误区移动时与固定时共用同一套collides没问题但是写回board必须在确认“下移一帧会碰撞”之后立刻执行而不是在键盘事件里直接落地否则玩家按键延迟就会导致方块悬空。2.4 消行操作与下落速度曲线free(帧数)不是固定的消行是整个游戏里最解压的瞬间实现也简单检测到某一行的每个单元格都非0后删除该行并在board顶部插入一个空数组。一个常见的优化是在一次下落结算里同时完成“多行消除”利用filter保留非满行再计算差值统一unshift。速度曲线则直接改变用户的紧张度。前端实现可以指定基础下落间隔每消除10行缩短一轮间隔也可以参考经典街机的NES规范来配置分级。常见参数是等级每格下落间隔(ms)触发条件1800初始2720消10行3630消20行4550消30行5470消40行注意这个表格只存储间隔数值不要在setInterval或requestAnimationFrame里动态读取。更稳妥的做法是每次重新调度计时器而不是修改变量后期望原计时器生效。3. 纯CSS配合Canvas实现俄罗斯方块的绘制与平滑重绘3.1 用Canvas绘制俄罗斯方块的每个单元格而不是直接用DOM很多HTMLCSSJS入门教程推荐用div铺网格这在小游戏里可行但遇到快速下落与整行消除时直接操作200个div的样式会触发大量layout与paint消耗远高于Canvas的一次重绘。所以这里选定Canvas方案一个canvas width300 height600内部逻辑用blockSize30像素来换算坐标。绘制代码不需要每帧重建board只需要区分重绘场景——方块移动时重绘当前活动块与静止块消行时重绘全屏。简单实现如下function draw() { ctx.clearRect(0, 0, 300, 600); ctx.fillStyle #111; ctx.fillRect(0, 0, 300, 600); board.forEach((row, y) { row.forEach((cell, x) { if (cell) { ctx.fillStyle COLORS[cell]; ctx.fillRect(x * 30 1, y * 30 1, 28, 28); } }); }); active.shape.forEach(([x, y]) { ctx.fillStyle COLORS[active.type]; ctx.fillRect((active.x x) * 30 1, (active.y y) * 30 1, 28, 28); }); }1与填充小块28x28是为了在单元格之间留下1像素缝隙形成经典的马赛克分隔视觉。COLORS是一个简单的色值映射表例如COLORS { I: #00f0f0, J: #0000f0, ... }在真正的游戏里这串CSS色值最好集中定义方便后续换主题或做“暗色模式适配”。绘制grid边框没有必要网格感靠缝隙即可。3.2 双缓冲与requestAnimationFrame别再用setInterval驱动游戏循环老式网页俄罗斯方块普遍使用setInterval(tick, 800)简单但有两个致命问题标签页切换时setInterval会被限频或暂停恢复时容易造成“方块瞬移”另外setInterval的固定间隔无法与浏览器绘制节律同步会出现拖影或卡顿。改用requestAnimationFrame是标准做法。let lastDrop 0; let dropInterval 800; function gameLoop(timestamp) { if (timestamp - lastDrop dropInterval) { moveDown(); lastDrop timestamp; } draw(); requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);代码里timestamp由浏览器传入单位为毫秒。lastDrop记录上一次执行下落的时间戳当时间差超过dropInterval时才执行“下落一步”。这样做的好处是即使浏览器掉帧方块也不会“跳步”移动依旧平滑。如果需要实现硬降到底只需在事件里把lastDrop归零或把间隔临时缩小就能让下一帧立即下落。3.3 用CSS处理游戏界面的整体布局与文字提示Canvas解决的是游戏区域内的渲染周边UI分数、下一个方块、操作说明则交给HTMLCSS。布局上用flex或grid都行建议用display: flex来并排游戏面板与右侧信息栏。下方信息栏包括当前分数、消除行数、等级、下一个方块预览。预览窗口的绘制可以直接复用主渲染逻辑。单独设置一个previewCanvas尺寸设为120px x 120px当新方块生成时把其shape数组按同样比例绘制进去。这样可以减少重复代码也能保证预览与实景风格一致。这一步有一个容易被忽略的细节CSS里要把Canvas元素设置为块级并margin: 0 auto避免父容器出现换行的内联间隙。常见代码是.container { display: flex; justify-content: center; align-items: flex-start; gap: 24px; } canvas { display: block; background: #000; border: 2px solid #444; }gap属性在老旧浏览器如IE里不兼容但现在前端开发环境下已基本不受限。如果还在意兼容改为.info-panel { margin-left: 20px; }更稳。4. 俄罗斯方块的交互逻辑键盘事件绑定、软硬降落与操作排队4.1 键盘监听只认keydown不认keypress并且要阻止默认行为前端实现俄罗斯方块只需要监听keydown因为游戏需要“持续按住左右移动”与“按一次旋转一次”两种模式。使用keypress的麻烦在于它不适用于非字符键如方向键的某些浏览器而keydown对方向键、空格键支持完全没问题。在监听回调中要调用event.preventDefault()否则Chrome会把空格键滚动页面方向键也会移动页面滚动条。document.addEventListener(keydown, (e) { if (e.code ArrowLeft) moveLeft(); else if (e.code ArrowRight) moveRight(); else if (e.code ArrowDown) softDrop(); else if (e.code ArrowUp) rotate(); else if (e.code Space) hardDrop(); });这段代码里的e.code比起e.keyCode有个明显优势它直接表示物理按键不随输入法或键盘布局变化。比如AZERTY键盘下用e.key a判断会让你无法区分“左移”与打字输入而e.code ArrowLeft就不会出错。如果你希望支持WASD操作则在代码里可以并列写上e.code KeyA等条件。4.2 软降落的频率控制按住下键不是每帧都移动俄罗斯方块有一个经典手感问题——“按住下键”到底应该多快下落如果你在keydown里直接moveDown()按住方向键时系统重复率决定速度且键盘重复率是操作系统层级的在不同设备上体验差异极大。正确实现是加入一个“软降落间隔”let softDropActive false; function softDrop() { if (!softDropActive) return; if (performance.now() - lastSoftDropMs 50) { moveDown(); lastSoftDropMs performance.now(); } }这个函数在游戏循环里调用而不是在键盘事件里。按下方向键时把softDropActive true松开置false。这样即使键盘重复率的触发很慢下落速度也是每秒20步左右的稳定节奏。对于需要更快速的操作党可以把50ms再缩短到30ms但不建议低于这个值因为对手眼反应人来说没有实际助益。4.3 硬降落的实现不是循环moveDown而是直接计算落点硬降落到最底大多数新手会写while(!collides(...)) moveDown()这没错但是要注意成绩计算的完整性。硬降得分需乘以降落格数因此必须在执行前记录降落起始Y坐标function hardDrop() { let distance 0; while (!collides(active.shape, active.x, active.y 1, board)) { active.y; distance; } lockPiece(); updateScore(distance * 2); spawnNewPiece(); }每次active.y都是一次简单的整数运算不存在性能难题。lockPiece()负责把活动小方块的形状写回board并调用消行与判定游戏结束。这里的游戏结束判断死亡判定同样是碰撞检测新方块生成时若与board已有格子发生冲突直接判定结束。4.4 操作队列与输入缓存解决“提前按键被吞”的问题有一定经验的俄罗斯方块玩家都会在方块落地前提前按住方向键希望方块一落地就自动移动到目标位置。但简单实现下这种提前量会被忽略因为键盘事件在锁定方块前就被消耗了。改善手感的方式是维护一个操作缓存let inputQueue []; function queueInput(action) { inputQueue.push(action); } setInterval(() { if (inputQueue.length 0 gameState playing) { inputQueue.shift()(); } }, 60);这个60ms的轮询是一个性能可接受的输入拾取方案。思路是方块落地并spawn新方块的同一帧里依然可以从队列里取出下一步操作保证操作流畅不丢。这个机制还有一个额外收益快速按两次旋转时不会被引擎因“位置暂时非法”而吞掉第二次。5. 方案演进与高级调优从单文件原型到组件化前端项目5.1 用localStorage接续游戏记录俄罗斯方块最早吸引人的一点是“再来一局”的欲望。纯前端实现天然适合把最高分存到localStorage但有两个常见坑一是直接设置localStorage.score 100存进去的是字符串 “100”后续做数值比较时很难发现类型问题二是每次分数变化都写localStorage会频繁触发序列化与磁盘写入。推荐做法是维护内存中的本次比分只在游戏结束、固定方块或更新最高分时才写入function saveBestScore() { const best parseInt(localStorage.getItem(tetris_best) || 0, 10); if (score best) { localStorage.setItem(tetris_best, String(score)); } }parseInt必须显式指定基数10否则旧数据若被其他脚本写成08解释器按老规范可能输出错误值。保存时机也不要放在draw()里否则每帧都在同步脏数据。5.2 面向对象的版本重构从函数堆叠到Class结构对于想要进一步学习的读者建议把附带的.zip项目从单文件函数式改写为ES6 Class结构会让代码更容易进行单元测试与功能扩展。游戏可以拆分为Game、Board、Piece、Renderer四个类依赖方向是Game控制流程Board持有网格数据Piece持活动形状Renderer只接收数据。class Game { constructor() { this.board new Board(20, 10); this.renderer new Renderer(); this.activePiece null; this.score 0; } spawn() { this.activePiece Piece.random(); } }这样拆分后新的“下一个方块预览”、“暂停菜单”、“音效播放”都能以模块形式插进对应生命周期而不用改所有函数。这也是从初学前端到工程化前端的一个关键门槛。5.3 样式进阶阴影与霓虹效果如何低成本融入CanvasCanvas的原生API适合基础填充但美术感仍能通过CSS滤镜与Canvas样式叠加提升。比如给整体画面加一层CSS drop-shadow会很直观但更推荐在绘制方块前设置ctx.shadowColor与ctx.shadowBlur代价是每帧性能会上升明显。对低端机有位兼容考虑的话建议在“消行动画”时才开启阴影其他时候关闭。5.4 性能排错与调试指南遇到掉帧与重叠时的检查顺序如果实际运行中发现重影或掉帧按顺序做三件事。先检查是否存在多个requestAnimationFrame在同时驱动常见的复制粘贴代码会不小心启动两份循环再检查Canvas的宽度与高度是否连CSS像素和实际像素混用例如canvas.height600但在CSS里缩放成300px生成了不必要的缩放开销最后看浏览器控制台的“Rendering”标签页有没有持续触发 layout/paint。俄罗斯方块的完整优化里核心原则永远只有一个——不要让内存里的数组状态和画面状态发生多次双向同步。另一种常见故障是方块重叠却仍能移动。这类bug几乎都源自没有在锁定瞬间做消行导致新块与已消行位置叠加。排查技巧是在消行后立刻打印一份board矩阵到控制台观察是否有一行的非0数量超过10即可定位。本文还有配套的精品资源点击获取