简介:基于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. 一谈到俄罗斯方块前端游戏设计,很多人直接去抄渲染代码,结果卡在碰撞检测和方块旋转上
俄罗斯方块可能是最适合前端练手的游戏之一:规则直观、状态有限,但要把一套干净的HTML+CSS+JS实现跑起来,难点不在“画”而在“算”。很多第一次独立写这个游戏的人,往往先从Canvas或DOM结构下手,画完几个方块后发现:旋转时形状越界,消行后上面区域没下沉,快速下落时键盘响应失控。这些问题都不是靠调整样式能解决的,而是游戏核心逻辑——网格模型与状态机——没有做好。本文不依赖任何框架,用纯HTML+CSS+JS走一遍完整方案,从数据结构、渲染方案、交互逻辑到性能排错,拆开讲清楚。你不需要读懂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列,直到找到合法落点。SRS(Super 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) | 触发条件 |
|---|---|---|
| 1 | 800 | 初始 |
| 2 | 720 | 消10行 |
| 3 | 630 | 消20行 |
| 4 | 550 | 消30行 |
| 5 | 470 | 消40行 |
注意这个表格只存储间隔数值,不要在setInterval或requestAnimationFrame里动态读取。更稳妥的做法是每次重新调度计时器,而不是修改变量后期望原计时器生效。
3. 纯CSS配合Canvas实现俄罗斯方块的绘制与平滑重绘
3.1 用Canvas绘制俄罗斯方块的每个单元格,而不是直接用DOM
很多HTML+CSS+JS入门教程推荐用div铺网格,这在小游戏里可行,但遇到快速下落与整行消除时,直接操作200个div的样式会触发大量layout与paint,消耗远高于Canvas的一次重绘。所以这里选定Canvas方案:一个<canvas width="300" height="600">,内部逻辑用blockSize=30像素来换算坐标。
绘制代码不需要每帧重建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(分数、下一个方块、操作说明)则交给HTML+CSS。布局上用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 样式进阶:阴影与霓虹效果如何低成本融入Canvas
Canvas的原生API适合基础填充,但美术感仍能通过CSS滤镜与Canvas样式叠加提升。比如给整体画面加一层CSS drop-shadow会很直观,但更推荐在绘制方块前设置ctx.shadowColor与ctx.shadowBlur,代价是每帧性能会上升明显。对低端机有位兼容考虑的话,建议在“消行动画”时才开启阴影,其他时候关闭。
5.4 性能排错与调试指南:遇到掉帧与重叠时的检查顺序
如果实际运行中发现重影或掉帧,按顺序做三件事。先检查是否存在多个requestAnimationFrame在同时驱动,常见的复制粘贴代码会不小心启动两份循环;再检查Canvas的宽度与高度是否连CSS像素和实际像素混用,例如canvas.height=600但在CSS里缩放成300px,生成了不必要的缩放开销;最后看浏览器控制台的“Rendering”标签页有没有持续触发 layout/paint。俄罗斯方块的完整优化里,核心原则永远只有一个——不要让内存里的数组状态和画面状态发生多次双向同步。
另一种常见故障是方块重叠却仍能移动。这类bug几乎都源自没有在锁定瞬间做消行,导致新块与已消行位置叠加。排查技巧是在消行后立刻打印一份board矩阵到控制台,观察是否有一行的非0数量超过10,即可定位。
本文还有配套的精品资源,点击获取