跳到主要内容俄罗斯方块游戏技术解析:从前端实现到工程化思考 | 极客日志HTML / CSS大前端算法
俄罗斯方块游戏技术解析:从前端实现到工程化思考
基于 HTML5+CSS3+JavaScript 原生技术栈实现的俄罗斯方块网页游戏解析。涵盖项目架构设计、HTML 语义化布局、CSS 现代特性应用、JavaScript 核心算法(碰撞检测、旋转、消行)、游戏循环及渲染系统。分析性能优化方向、设计模式应用及跨端适配方案。提供完整可运行代码示例,适合前端开发者学习游戏开发基础与工程化实践。
乱七八糟16 浏览 俄罗斯方块游戏技术解析:从前端实现到工程化思考
一、项目架构概述
在前端开发领域,经典小游戏的实现是检验技术综合应用能力的重要方式,而俄罗斯方块作为家喻户晓的经典游戏,其浏览器端实现更是涵盖了 HTML 结构设计、CSS 视觉呈现、JavaScript 逻辑开发等全维度的前端核心能力。本文将以一个完整的 HTML5 + CSS3 + JavaScript 实现的俄罗斯方块游戏为样本,从架构设计到细节实现,从核心算法到性能优化,进行深度技术解析。
该项目采用最简洁的前端技术栈组合,无任何框架依赖,完全基于原生 Web 技术构建,整体由三个核心文件构成,各司其职又高度协同:
- index.html:作为游戏的骨架,定义了所有界面元素的结构布局,包括游戏标题、计分面板、操作说明、Canvas 渲染区域、状态遮罩层等,是整个游戏的界面基础。
- style.css:负责游戏的视觉呈现,融合了现代 CSS 特性,实现了响应式布局、毛玻璃视觉效果、交互动画、多端适配等,为用户提供沉浸式的视觉体验。
- script.js:承载游戏的核心逻辑,包括数据结构设计、碰撞检测、方块旋转、消行计算、游戏循环、状态管理等,是游戏能够正常运行的'大脑'。
整个项目遵循'结构 - 样式 - 逻辑'分离的前端开发最佳实践,代码解耦度高,便于维护和扩展,同时兼顾了性能与用户体验,是前端游戏开发入门的绝佳案例。
二、HTML 结构分析:语义化与模块化的界面搭建
2.1 整体布局设计
HTML 结构的设计核心在于'模块化'与'语义化',既要保证界面元素的清晰分层,也要让结构具备良好的可读性和可维护性。该项目的 HTML 布局采用嵌套式容器结构,整体层级如下:
- Container(全局容器)
- Header(标题区域):展示游戏名称,强化视觉焦点
- Game-Wrapper(游戏主容器):承载所有游戏核心元素
- Left Sidebar(左侧功能面板)
- Info-panel(信息面板):展示得分、等级、消除行数
- Next-panel(预览面板):通过 Canvas 展示下一个方块
- Game Area(游戏核心区域)
- game-canvas(主画布):渲染游戏主界面(10 列×20 行)
- game-over(结束遮罩):游戏结束时的提示层
- pause-overlay(暂停遮罩):游戏暂停时的提示层
- Right Sidebar(右侧操作面板)
- Controls-panel(操作说明):展示键盘操作指南
- Button-group(控制按钮):开始、暂停、新游戏按钮
这种布局设计的优势在于:
- 分层清晰:将'信息展示''核心游戏区''操作控制'分离,符合用户的视觉和操作习惯;
- 职责单一:每个容器只承载一类功能,便于后续样式调整和逻辑绑定;
- 扩展灵活:如需新增功能(如音效开关、难度选择),可在对应侧边栏新增模块,不影响核心布局。
2.2 关键元素解析
2.2.1 Canvas 元素的双画布设计
项目中使用了两个 <canvas> 元素,分别承担不同的渲染职责:
- 主画布(game-canvas):尺寸为 300px×600px(对应 10 列×20 行,每个方块 30px),是游戏的核心渲染区域,负责绘制游戏面板、当前下落的方块、已放置的方块等;
- 预览画布(next-canvas):尺寸为 120px×120px,专门用于渲染下一个即将出现的方块,帮助玩家提前规划策略,提升游戏体验。
Canvas 元素的使用遵循'按需渲染'原则,通过 JavaScript 控制绘制逻辑,相比 DOM 元素渲染,具备更高的性能和更灵活的图形操作能力,适合像素级的游戏画面渲染。
2.2.2 状态遮罩层的设计
游戏的'暂停'和'结束'状态通过遮罩层实现,核心设计思路是:
遮罩层使用绝对定位覆盖在游戏画布之上,默认隐藏(display: none);当游戏状态切换为暂停/结束时,通过 CSS 类(.show)控制显示,无需修改 DOM 结构;遮罩层内部包含状态提示文本和操作按钮,与游戏核心逻辑解耦,仅通过事件绑定实现交互。
- 避免频繁创建/销毁 DOM 元素,减少浏览器回流重绘;
- 遮罩层使用半透明背景 + 毛玻璃效果,视觉上与游戏界面融合,提升体验;
- 状态切换仅需修改 CSS 类,性能开销极低。
2.2.3 响应式布局的结构基础
- 所有容器使用相对单位(%、max-width)而非固定像素,适配不同屏幕尺寸;
- 侧边栏、游戏区域等模块独立封装,便于 CSS 媒体查询时调整布局结构;
- 按钮、文本等元素使用弹性布局,保证在不同尺寸下的对齐和显示效果。
2.3 语义化与可访问性考量
虽然是小游戏项目,但 HTML 结构仍兼顾了基础的语义化设计:
- 使用
<h1>~<h3> 标签层级展示标题和子标题,符合文档结构规范;
- 按钮使用
<button> 原生元素,而非 <div> 模拟,保证键盘可聚焦、屏幕阅读器可识别;
- 操作说明使用清晰的文本描述,配合按键标识,提升可理解性。
这些细节看似微小,却是前端开发'用户体验至上'理念的体现,也让代码更符合 Web 标准。
三、CSS 样式技术特点:现代特性与视觉体验的融合
3.1 现代 CSS 特性的全面应用
3.1.1 Flexbox 布局的核心应用
Flexbox 是该项目布局的核心技术,几乎所有模块的对齐、分布都依赖 Flex 实现:
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.game-wrapper {
display: flex;
gap: 20px;
justify-content: center;
align-items: flex-start;
}
.button-group {
display: flex;
flex-direction: column;
gap: 10px;
}
- 轻松实现元素的水平/垂直居中,无需复杂的定位和计算;
gap 属性简化了模块间间距的设置,避免使用 margin 带来的塌陷问题;
- 支持灵活的方向切换(
flex-direction),为响应式布局提供了基础。
3.1.2 渐变与毛玻璃效果:提升视觉质感
项目通过 CSS 渐变和 backdrop-filter 实现了现代感的视觉效果:
body {
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
}
.info-panel,
.next-panel,
.controls-panel {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
- 线性渐变:使用 135 度角的蓝紫色渐变,营造深邃的游戏背景,符合经典俄罗斯方块的视觉风格;
- 毛玻璃效果:通过
backdrop-filter: blur(10px) 配合半透明背景(rgba(255,255,255,0.1)),让面板呈现出磨砂玻璃的质感,同时不遮挡背景渐变,提升视觉层次;
- 阴影效果:
box-shadow 为面板、按钮、画布添加投影,模拟真实的物理深度,让界面更具立体感。
3.1.3 过渡动画:增强交互反馈
所有可交互元素(按钮、按键提示)都添加了过渡动画,提升操作反馈:
.btn {
transition: all 0.3s;
}
.btn:hover {
background: #5aa0f2;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.btn:active {
transform: translateY(0);
}
- 按钮 hover 时轻微上移(
translateY(-2px))并加深阴影,模拟'按下前'的物理反馈;
- 按钮 active 时恢复原位,模拟'按下'的触感;
- 所有过渡效果使用
all 0.3s,保证动画的平滑性,避免卡顿。
3.1.4 方块高光效果:模拟 3D 质感
游戏方块的高光效果通过 CSS 绘制实现,无需额外图片资源:
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
ctx.fillRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE / 3, BLOCK_SIZE / 3);
在 Canvas 绘制方块时,在左上角绘制一个小的半透明矩形,模拟光线照射的高光效果,让 2D 方块呈现出 3D 质感,提升视觉体验。
3.2 响应式设计的完整实现
3.2.1 媒体查询的核心逻辑
针对移动端和桌面端的差异,项目通过媒体查询(@media)实现布局的自适应调整:
@media (max-width: 900px) {
.game-wrapper {
flex-direction: column;
align-items: center;
}
.sidebar {
width: 100%;
max-width: 300px;
flex-direction: row;
flex-wrap: wrap;
}
.sidebar.left {
order: 2;
}
.sidebar.right {
order: 3;
}
.game-area {
order: 1;
}
#game-canvas {
width: 100%;
max-width: 300px;
height: auto;
}
.header h1 {
font-size: 36px;
}
}
- 布局方向切换:桌面端横向排列(flex-direction: row)的游戏容器,在移动端切换为纵向排列(column),避免横向空间不足;
- 模块顺序调整:通过
order 属性,将游戏核心区域(game-area)移至最上方,优先展示核心内容;
- 尺寸自适应:侧边栏宽度改为 100%(最大 300px),画布宽度自适应,保证在小屏幕上的完整显示;
- 字体适配:标题字体从 48px 缩小为 36px,避免移动端文字溢出。
3.2.2 移动端交互适配
除了布局调整,CSS 还针对移动端的触控特性进行了优化:
- 按钮尺寸增大,保证触控的精准性;
- 画布使用
max-width: 300px,避免在小屏幕上超出可视区域;
- 遮罩层的按钮和文本尺寸适配,保证移动端的可读性。
3.3 CSS 代码的可维护性设计
3.3.1 类名的语义化命名
CSS 类名采用'功能 + 类型'的命名方式,如:
.info-panel:信息面板,明确功能;
.control-item:操作项,描述元素用途;
.game-over:游戏结束遮罩,关联游戏状态。
语义化的类名让代码可读性提升,即使不查看 HTML 结构,也能通过类名理解元素的功能和位置。
3.3.2 样式的模块化封装
每个功能模块的样式独立封装,如 .info-panel、.next-panel、.controls-panel 等,样式之间互不干扰,便于单独修改某个模块的样式而不影响其他部分。
3.3.3 颜色和尺寸的统一管理
虽然未使用 CSS 变量,但项目中颜色和尺寸的使用保持高度统一:
- 主色调为蓝紫色系,所有面板、按钮的颜色都基于该色系延伸;
- 方块尺寸固定为 30px,所有 Canvas 的尺寸都基于该单位计算;
- 间距统一使用 20px、10px 等固定值,保证界面的视觉一致性。
四、JavaScript 核心逻辑解析:游戏开发的核心原理
4.1 游戏数据结构设计:底层逻辑的基石
4.1.1 方块定义系统:二维矩阵的经典应用
俄罗斯方块的核心是 7 种经典形状,项目通过二维数组(矩阵)定义每种形状的结构:
const COLS = 10;
const ROWS = 20;
const BLOCK_SIZE = 30;
const COLORS = [
null,
'#FF0D72',
'#0DC2FF',
'#0DFF72',
'#F538FF',
'#FF8E0D',
'#FFE138',
'#3877FF'
];
const SHAPES = [
[[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
[[2, 2], [2, 2]],
[[0, 3, 0], [3, 3, 3], [0, 0, 0]],
[[0, 4, 4], [4, 4, 0], [0, 0, 0]],
[[5, 5, 0], [0, 5, 5], [0, 0, 0]],
[[6, 0, 0], [6, 6, 6], [0, 0, 0]],
[[0, 0, 7], [7, 7, 7], [0, 0, 0]]
];
- 数字标识:每个形状的非 0 数字对应 COLORS 数组的索引,既标识形状类型,又关联颜色,一举两得;
- 矩阵尺寸:根据形状大小选择合适的矩阵尺寸(2×2、3×3、4×4),避免空间浪费;
- 中心点设计:每种形状的矩阵都以'旋转中心'为核心设计,便于后续旋转算法的实现;
- 扩展性:如需新增形状,只需在 SHAPES 数组中添加新的二维矩阵,修改 COLORS 数组即可,无需调整核心逻辑。
4.1.2 游戏板数据结构:二维数组的状态管理
游戏板(Board)是存储已放置方块状态的核心数据结构,采用 20 行×10 列的二维数组:
function initBoard() {
board = Array(ROWS).fill(null).map(() => Array(COLS).fill(0));
}
- 值的含义:0 表示空单元格,1-7 表示对应类型的方块(与 SHAPES 和 COLORS 对应);
- 更新逻辑:当方块下落到底部或碰撞到其他方块时,将方块的矩阵值合并到游戏板中;
- 消行逻辑:遍历游戏板的每一行,检测是否全为非 0 值,若是则删除该行并在顶部添加新行。
- 结构简单,易于遍历和修改;
- 与 Canvas 的渲染逻辑一一对应,便于绘制;
- 内存占用低,20×10 的数组仅需存储 200 个数值,性能开销可忽略。
4.1.3 游戏状态变量:全局状态的统一管理
项目通过一组全局变量管理游戏的核心状态,构成简单的状态机:
let board = [];
let currentPiece = null;
let nextPiece = null;
let score = 0;
let level = 1;
let lines = 0;
let dropCounter = 0;
let dropInterval = 1000;
let lastTime = 0;
let gameRunning = false;
let gamePaused = false;
let gameOver = false;
状态变量的设计遵循'最小必要'原则,仅存储游戏运行所需的核心状态,避免冗余,同时所有状态变量都有清晰的命名和明确的用途,便于维护。
4.2 核心算法实现:游戏逻辑的核心
4.2.1 碰撞检测算法:游戏的边界控制
碰撞检测是俄罗斯方块的核心算法之一,决定了方块能否移动/旋转,是游戏规则的基础:
function collide(piece, dx = 0, dy = 0) {
const newX = piece.x + dx;
const newY = piece.y + dy;
for (let y = 0; y < piece.shape.length; y++) {
for (let x = 0; x < piece.shape[y].length; x++) {
if (piece.shape[y][x]) {
const boardX = newX + x;
const boardY = newY + y;
if (boardX < 0 || boardX >= COLS || boardY >= ROWS) {
return true;
}
if (boardY >= 0 && board[boardY][boardX]) {
return true;
}
}
}
}
return false;
}
- 参数设计:
piece 为当前方块对象,dx 和 dy 为拟移动的偏移量(默认 0);
- 遍历逻辑:遍历方块矩阵的每个非空单元格,计算移动后的位置(
boardX/boardY);
- 边界检测:检查是否超出游戏板的左右边界(0 ≤ boardX < COLS)或下边界(boardY ≥ ROWS);
- 碰撞检测:检查移动后的位置是否与游戏板中已放置的方块(非 0 值)重叠;
- 特殊处理:忽略方块在游戏板上方(boardY < 0)的碰撞,允许方块从顶部下落。
复杂度分析:方块矩阵的最大尺寸为 4×4,因此算法的时间复杂度为 O(1)(常数时间),即使最坏情况下也仅需遍历 16 个单元格,性能极高。
4.2.2 旋转算法:矩阵变换与墙踢机制
方块旋转是俄罗斯方块的核心操作,项目实现了顺时针旋转 90 度的算法,并加入了'墙踢'(Wall Kick)机制,符合经典游戏规则:
function rotate(piece) {
const rotated = piece.shape.map((_, i) => piece.shape.map(row => row[i]).reverse());
const originalShape = piece.shape;
piece.shape = rotated;
if (collide(piece)) {
piece.x -= 1;
if (collide(piece)) {
piece.x += 2;
if (collide(piece)) {
piece.x -= 1;
piece.shape = originalShape;
return false;
}
}
}
return true;
}
- 顺时针旋转 90 度的矩阵变换公式:
rotated[i][j] = original[shape.length - 1 - j][i];
- 项目中通过
map 和 reverse 方法实现该变换,代码简洁且易理解。
- 旋转后若发生碰撞,并非直接禁止旋转,而是尝试调整方块的水平位置,模拟真实的游戏体验;
- 先向左移动 1 格,若仍碰撞则向右移动 2 格(相当于原始位置右移 1 格);
- 若两次调整都失败,则恢复原始形状,旋转失败。
这种机制避免了'方块卡在墙边无法旋转'的问题,提升了游戏的可玩性。
4.2.3 消行算法:行检测与数组操作
消行是游戏得分的核心逻辑,算法的核心是检测满行并删除,同时在顶部添加新行:
function clearLines() {
let linesCleared = 0;
for (let y = ROWS - 1; y >= 0; y--) {
if (board[y].every(cell => cell !== 0)) {
board.splice(y, 1);
board.unshift(Array(COLS).fill(0));
linesCleared++;
y++;
}
}
if (linesCleared > 0) {
lines += linesCleared;
const points = [0, 100, 300, 500, 800];
score += points[linesCleared] * level;
level = Math.floor(lines / 10) + 1;
dropInterval = Math.max(100, 1000 - (level - 1) * 100);
updateUI();
}
}
- 遍历方向:从底部向上遍历(y 从 19 到 0),因为删除底部的行不会影响上方行的索引,若从上向下遍历会导致漏检;
- 满行检测:使用
every 方法检测当前行的所有单元格是否为非 0 值,简洁高效;
- 行操作:
splice(y, 1) 删除满行,unshift 在顶部添加空行,模拟方块下落的效果;
- 索引回退:删除行后,
y++ 回退索引,因为上方的行会下落至当前位置,需要重新检测;
- 得分计算:根据消除行数给予不同分值,连消行数越多,单分行值越高,符合经典规则;
- 等级调整:每消除 10 行提升 1 级,同时减少下落间隔(最快 0.1 秒/次),提升游戏难度。
复杂度分析:算法需要遍历 20 行,每行遍历 10 列,时间复杂度为 O(ROWS×COLS)=O(200),属于常数时间,性能无压力。
4.2.4 方块生成与合并算法
(1)方块生成
function createPiece(type) {
const shape = SHAPES[type];
return {
type: type + 1,
shape: shape,
x: Math.floor(COLS / 2) - Math.floor(shape[0].length / 2),
y: 0
};
}
function randomPiece() {
return createPiece(Math.floor(Math.random() * SHAPES.length));
}
function spawnPiece() {
currentPiece = nextPiece || randomPiece();
nextPiece = randomPiece();
drawNext();
if (collide(currentPiece)) {
gameOver = true;
gameRunning = false;
document.getElementById('final-score').textContent = score;
document.getElementById('game-over').classList.add('show');
}
}
createPiece 函数负责创建方块对象,包含类型、形状、位置等属性,位置默认水平居中、垂直顶部;
randomPiece 函数随机生成 7 种方块之一,保证游戏的随机性;
spawnPiece 函数负责切换当前方块和下一个方块,并检测游戏是否结束(新方块生成即碰撞,说明顶部已满)。
(2)方块合并
function merge() {
for (let y = 0; y < currentPiece.shape.length; y++) {
for (let x = 0; x < currentPiece.shape[y].length; x++) {
if (currentPiece.shape[y][x]) {
const boardY = currentPiece.y + y;
const boardX = currentPiece.x + x;
if (boardY >= 0) {
board[boardY][boardX] = currentPiece.type;
}
}
}
}
}
合并逻辑与碰撞检测逻辑对称,遍历方块的每个非空单元格,将其值写入游戏板对应的位置,完成方块的'放置'。
4.3 游戏循环与渲染系统:流畅的动画体验
4.3.1 游戏主循环:基于 requestAnimationFrame 的帧动画
游戏循环是所有动态游戏的核心,项目使用 requestAnimationFrame 实现平滑的帧动画:
function gameLoop(time = 0) {
const deltaTime = time - lastTime;
lastTime = time;
if (gameRunning && !gamePaused && !gameOver) {
dropCounter += deltaTime;
if (dropCounter > dropInterval) {
if (movePiece(0, 1)) {
} else {
merge();
clearLines();
spawnPiece();
}
dropCounter = 0;
}
}
drawBoard();
requestAnimationFrame(gameLoop);
}
gameLoop();
requestAnimationFrame 由浏览器提供,会在每次重绘前调用回调函数,通常帧率为 60fps(约 16.67ms/帧),相比 setInterval 更平滑,且能根据浏览器性能自动调整;
- 时间差控制:使用
deltaTime 计算两帧之间的时间差,累加至 dropCounter,当达到 dropInterval 时触发方块下落,避免固定帧率导致的'不同设备下落速度不一致'问题;
- 状态判断:仅在游戏运行、未暂停、未结束时执行下落逻辑,保证状态的正确性;
- 持续渲染:无论是否执行下落逻辑,每次循环都调用
drawBoard 绘制画面,保证界面的实时更新。
4.3.2 Canvas 渲染系统:分层绘制与视觉优化
(1)方块绘制函数
function drawBlock(ctx, x, y, color) {
ctx.fillStyle = color;
ctx.fillRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.strokeRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
ctx.fillRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE / 3, BLOCK_SIZE / 3);
}
- 先绘制方块主体(填充色),再绘制边框(黑色,2px 宽),最后绘制左上角的高光(半透明白色);
- 所有绘制都基于
BLOCK_SIZE(30px),保证尺寸的一致性;
- 高光的尺寸为方块的 1/3,位置固定在左上角,模拟光线从左上方照射的效果。
(2)游戏板绘制
function drawBoard() {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
if (board[y][x]) {
drawBlock(ctx, x, y, COLORS[board[y][x]]);
}
}
}
if (currentPiece) {
for (let y = 0; y < currentPiece.shape.length; y++) {
for (let x = 0; x < currentPiece.shape[y].length; x++) {
if (currentPiece.shape[y][x]) {
const blockX = currentPiece.x + x;
const blockY = currentPiece.y + y;
if (blockY >= 0) {
drawBlock(ctx, blockX, blockY, COLORS[currentPiece.type]);
}
}
}
}
}
}
- 先清空画布(黑色背景),再绘制已放置的方块,最后绘制当前下落的方块,保证层级正确;
- 绘制当前方块时,忽略
blockY < 0 的部分(即超出游戏板顶部的部分),避免无效绘制。
(3)下一个方块绘制
function drawNext() {
nextCtx.fillStyle = '#000';
nextCtx.fillRect(0, 0, nextCanvas.width, nextCanvas.height);
if (nextPiece) {
const shape = nextPiece.shape;
const offsetX = (nextCanvas.width / BLOCK_SIZE - shape[0].length) / 2;
const offsetY = (nextCanvas.height / BLOCK_SIZE - shape.length) / 2;
for (let y = 0; y < shape.length; y++) {
for (let x = 0; x < shape[y].length; x++) {
if (shape[y][x]) {
drawBlock(nextCtx, offsetX + x, offsetY + y, COLORS[nextPiece.type]);
}
}
}
}
}
- 通过计算画布尺寸与方块矩阵尺寸的差值,得到偏移量(
offsetX/offsetY),让方块在预览画布中居中显示,提升视觉体验。
4.4 输入处理与状态控制:交互逻辑的实现
4.4.1 键盘事件处理
项目通过监听键盘事件实现方块的操作,支持方向键、空格键等:
document.addEventListener('keydown', e => {
if (!gameRunning || gameOver) return;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
movePiece(-1, 0);
break;
case 'ArrowRight':
e.preventDefault();
movePiece(1, 0);
break;
case 'ArrowDown':
e.preventDefault();
movePiece(0, 1);
break;
case 'ArrowUp':
e.preventDefault();
if (currentPiece) {
rotate(currentPiece);
}
break;
case ' ':
e.preventDefault();
togglePause();
break;
}
});
- 状态判断:仅在游戏运行且未结束时处理键盘事件,避免无效操作;
- 防页面滚动:使用
e.preventDefault() 阻止方向键和空格键的默认行为(如页面滚动、空格翻页);
- 单一职责:每个按键仅对应一个操作,逻辑清晰,易于维护。
4.4.2 按钮事件处理
项目为所有控制按钮绑定了点击事件,实现鼠标/触控操作:
document.getElementById('start-btn').addEventListener('click', startGame);
document.getElementById('pause-btn').addEventListener('click', togglePause);
document.getElementById('new-game-btn').addEventListener('click', newGame);
document.getElementById('restart-btn').addEventListener('click', newGame);
document.getElementById('resume-btn').addEventListener('click', togglePause);
按钮事件与键盘事件最终调用相同的核心函数(startGame、togglePause、newGame),保证操作逻辑的一致性。
4.4.3 游戏状态控制函数
(1)开始游戏
function startGame() {
if (gameRunning && !gamePaused) return;
if (!gameRunning) {
initBoard();
score = 0;
level = 1;
lines = 0;
dropCounter = 0;
dropInterval = 1000;
gameOver = false;
gameRunning = true;
gamePaused = false;
document.getElementById('game-over').classList.remove('show');
document.getElementById('pause-overlay').classList.remove('show');
spawnPiece();
updateUI();
} else if (gamePaused) {
togglePause();
}
}
(2)暂停/继续
function togglePause() {
if (!gameRunning || gameOver) return;
gamePaused = !gamePaused;
document.getElementById('pause-overlay').classList.toggle('show', gamePaused);
}
(3)新游戏
function newGame() {
gameRunning = false;
gamePaused = false;
gameOver = false;
document.getElementById('game-over').classList.remove('show');
document.getElementById('pause-overlay').classList.remove('show');
startGame();
}
- 所有状态切换都通过修改全局状态变量实现,保证状态的唯一性;
- 状态切换时同步更新 UI(如显示/隐藏遮罩、更新得分),保证视图与逻辑的一致性;
- 函数职责单一,
startGame 负责启动游戏,togglePause 负责暂停/继续,newGame 负责重置游戏,便于调试和扩展。
4.5 UI 更新函数:数据与视图的同步
function updateUI() {
document.getElementById('score').textContent = score;
document.getElementById('level').textContent = level;
document.getElementById('lines').textContent = lines;
}
该函数负责将游戏的核心数据(得分、等级、消除行数)同步到 HTML 元素中,保证视图的实时更新。函数仅负责数据展示,不涉及任何业务逻辑,符合'视图与逻辑分离'的原则。
五、性能优化分析:从细节到架构的优化思路
5.1 现有实现的性能优势
该项目作为轻量级小游戏,在性能上已经做了很多优化,主要体现在以下方面:
5.1.1 Canvas 渲染的高效性
- 批量绘制:每次游戏循环仅清空画布一次,然后批量绘制所有方块,避免频繁的 Canvas 状态切换;
- 最小化绘制区域:仅绘制游戏板内的内容,忽略超出顶部的方块,减少无效绘制;
- 像素级控制:Canvas 直接操作像素,相比 DOM 元素渲染,减少了浏览器的布局和绘制开销。
5.1.2 算法的低复杂度
所有核心算法(碰撞检测、旋转、消行)的时间复杂度均为常数级(O(1))或线性级(O(n)),即使在低端设备上也能流畅运行:
- 碰撞检测:最多遍历 16 个单元格;
- 旋转算法:最多执行 3 次碰撞检测;
- 消行算法:固定遍历 20 行×10 列=200 个单元格。
5.1.3 事件处理的优化
- 事件委托:键盘事件仅绑定在
document 上,而非单个元素,减少事件监听器数量;
- 状态过滤:事件处理函数首先判断游戏状态,避免无效的逻辑执行;
- 默认行为阻止:仅在需要时阻止默认行为,减少不必要的性能开销。
5.1.4 内存管理的合理性
- 游戏板使用二维数组存储,内存占用极低(200 个数值,约 1.6KB);
- 方块对象复用,避免频繁创建/销毁对象;
- 全局变量仅存储必要的游戏状态,无内存泄漏风险。
5.2 潜在的性能优化方向
虽然现有实现性能良好,但仍有进一步优化的空间,适合作为进阶优化的学习方向:
5.2.1 脏矩形渲染:减少绘制区域
现有实现每次循环都清空并重新绘制整个画布,可优化为仅绘制变化的区域(脏矩形):
let dirtyRegions = [];
function markDirty(x, y, width, height) {
dirtyRegions.push({ x, y, width, height });
}
function drawBoard() {
if (dirtyRegions.length === 0) return;
dirtyRegions.forEach(region => {
ctx.clearRect(region.x, region.y, region.width, region.height);
});
dirtyRegions = [];
}
脏矩形渲染可减少 Canvas 的绘制面积,尤其在方块移动较小时,能显著提升性能。
5.2.2 对象池模式:减少对象创建
现有实现每次生成新方块时都会创建新对象,可通过对象池复用方块对象:
const piecePool = [];
function initPool() {
for (let i = 0; i < 5; i++) {
piecePool.push({ type: 0, shape: [], x: 0, y: 0 });
}
}
function getPieceFromPool(type) {
let piece = piecePool.pop() || {};
const shape = SHAPES[type];
piece.type = type + 1;
piece.shape = shape;
piece.x = Math.floor(COLS / 2) - Math.floor(shape[0].length / 2);
piece.y = 0;
return piece;
}
function returnPieceToPool(piece) {
piecePool.push(piece);
}
对象池可减少垃圾回收(GC)的频率,避免 GC 导致的游戏卡顿,尤其在长时间游戏时效果明显。
5.2.3 Web Workers:分离计算与渲染
将耗时的算法(如消行、碰撞检测)移至 Web Worker 中执行,避免阻塞主线程的渲染:
const worker = new Worker('game-worker.js');
worker.postMessage({ type: 'collide', piece: currentPiece, board: board });
worker.onmessage = e => {
if (e.data.type === 'collideResult') {
isCollided = e.data.result;
}
};
self.onmessage = e => {
if (e.data.type === 'collide') {
const result = collide(e.data.piece, 0, 0);
self.postMessage({ type: 'collideResult', result });
}
};
Web Workers 可利用多核 CPU,将计算逻辑与渲染逻辑分离,保证游戏的流畅性,尤其在复杂游戏中效果显著。
5.2.4 离屏 Canvas:预渲染静态内容
将固定的静态内容(如方块的高光、边框)预渲染到离屏 Canvas 中,避免每次绘制时重复计算:
const offscreenCanvas = document.createElement('canvas');
const offscreenCtx = offscreenCanvas.getContext('2d');
function preRenderBlocks() {
for (let i = 1; i < COLORS.length; i++) {
offscreenCtx.fillStyle = COLORS[i];
offscreenCtx.fillRect(0, 0, BLOCK_SIZE, BLOCK_SIZE);
blockCache[i] = offscreenCtx.getImageData(0, 0, BLOCK_SIZE, BLOCK_SIZE);
}
}
function drawBlock(ctx, x, y, type) {
ctx.putImageData(blockCache[type], x * BLOCK_SIZE, y * BLOCK_SIZE);
}
离屏 Canvas 可减少重复的绘制操作,提升渲染效率。
5.2.5 节流/防抖:优化输入处理
虽然现有输入处理已足够高效,但可通过节流优化频繁的按键操作(如长按方向键):
function throttle(fn, delay) {
let lastCall = 0;
return (...args) => {
const now = Date.now();
if (now - lastCall >= delay) {
fn(...args);
lastCall = now;
}
};
}
const throttledMove = throttle(movePiece, 50);
document.addEventListener('keydown', e => {
if (e.key === 'ArrowLeft') {
throttledMove(-1, 0);
}
});
节流可限制频繁的移动操作,减少不必要的碰撞检测和绘制,提升性能。
六、设计模式应用:代码组织的最佳实践
6.1 模块化设计:逻辑的分层与解耦
虽然项目未使用 ES6 模块(import/export),但通过函数和变量的组织,实现了模块化的设计思想:
- 数据层:包含游戏配置(COLS、ROWS、BLOCK_SIZE)、方块定义(SHAPES、COLORS)、游戏状态(board、score、level 等),负责存储游戏的核心数据;
- 控制层:包含游戏循环(gameLoop)、输入处理(keydown 事件、按钮事件)、核心算法(collide、rotate、clearLines 等),负责游戏的逻辑控制;
- 视图层:包含 Canvas 渲染(drawBoard、drawNext、drawBlock)、UI 更新(updateUI),负责游戏的视觉呈现。
- 各层职责单一,便于调试和修改;
- 层与层之间通过明确的接口交互(如控制层修改数据层,视图层读取数据层),降低耦合度;
- 便于扩展新功能,如新增音效模块,只需在控制层添加音效触发逻辑,不影响其他层。
6.2 观察者模式:事件驱动的交互
项目大量使用观察者模式(Observer Pattern)实现交互逻辑:
- 键盘事件:
document.addEventListener('keydown', handler),当用户按下键盘时,触发对应的操作;
- 按钮事件:
button.addEventListener('click', handler),当用户点击按钮时,触发游戏状态切换;
- 游戏状态变化:当游戏状态(score、level、gameOver)变化时,触发 UI 更新(updateUI)。
- 解耦事件源和事件处理逻辑,事件源无需知道谁会处理事件;
- 支持多个观察者监听同一个事件,如多个按钮可触发同一个
newGame 函数;
- 便于扩展新的事件处理逻辑,如新增'音效开关'按钮,只需添加新的事件监听,不影响现有逻辑。
6.3 状态模式:游戏状态的统一管理
项目通过状态变量(gameRunning、gamePaused、gameOver)实现了简单的状态模式(State Pattern):
- 状态定义:游戏有三种核心状态:未运行、运行中(未暂停)、运行中(暂停)、结束;
- 状态转换:状态之间的转换通过明确的函数(startGame、togglePause、newGame)实现,避免状态混乱;
- 状态行为:不同状态下,游戏的行为不同(如暂停状态下方块不下落,结束状态下不处理键盘事件)。
- 避免大量的 if/else 判断,代码结构更清晰;
- 状态转换逻辑集中管理,便于维护和扩展;
- 状态与行为分离,新增状态时只需添加对应的行为逻辑。
6.4 工厂模式:方块对象的创建
createPiece 和 randomPiece 函数实现了工厂模式(Factory Pattern):
- 工厂函数:
createPiece 负责创建指定类型的方块对象,封装了对象的创建逻辑;
- 抽象工厂:
randomPiece 负责创建随机类型的方块对象,提供了更高级的创建接口。
- 封装对象的创建细节,调用者无需知道对象的具体结构;
- 便于统一管理对象的创建逻辑,如修改方块的初始位置,只需修改
createPiece 函数;
- 支持创建不同类型的对象,符合'开闭原则'(对扩展开放,对修改关闭)。
七、浏览器兼容性与跨端适配
7.1 兼容性分析
| 技术特性 | 兼容浏览器版本 | 备注 |
|---|
| HTML5 Canvas | IE9+、Chrome 4+、Firefox 3.6+、Safari 4+、Edge 12+ | 核心渲染技术,兼容性良好 |
| ES6 语法(const/let、箭头函数、map/reduce) | Chrome 45+、Firefox 42+、Safari 10+、Edge 14+、IE 不支持 | IE 需转译(Babel) |
| CSS3 Flexbox | Chrome 29+、Firefox 28+、Safari 9+、Edge 12+、IE11(部分支持) | IE11 需前缀和兼容写法 |
| CSS3 backdrop-filter | Chrome 76+、Firefox 70+、Safari 9+、Edge 79+、IE 不支持 | 毛玻璃效果,不支持的浏览器显示半透明背景 |
| requestAnimationFrame | Chrome 10+、Firefox 4+、Safari 6+、Edge 12+、IE10+ | 游戏循环核心,IE10+ 支持 |
7.2 兼容性优化方案
7.2.1 ES6 语法转译
使用 Babel 将 ES6 语法转译为 ES5,兼容 IE11 等低版本浏览器:
npm install @babel/core @babel/cli @babel/preset-env --save-dev
{
"presets": [
["@babel/preset-env", { "targets": { "ie": "11", "chrome": "45" } }]
]
}
npx babel script.js --out-file script-es5.js
7.2.2 CSS 特性降级
- backdrop-filter 降级:为不支持的浏览器提供纯色背景替代;
.info-panel {
background: rgba(255, 255, 255, 0.1);
background: #2a5298\9;
backdrop-filter: blur(10px);
@supports not (backdrop-filter: blur(10px)) {
background: rgba(255, 255, 255, 0.2);
}
}
- Flexbox 降级:为 IE11 提供兼容写法,如使用
-ms-flex 前缀;
.game-wrapper {
display: -ms-flexbox;
display: flex;
-ms-flex-pack: center;
justify-content: center;
-ms-flex-align: start;
align-items: flex-start;
}
7.2.3 Canvas 兼容性
IE9+ 支持 Canvas,但部分 API(如 getImageData)需注意跨域问题,项目中无跨域图片,无需额外处理。
7.3 跨端适配细节
7.3.1 移动端触控适配
现有实现仅支持键盘和鼠标操作,可新增触控事件支持,提升移动端体验:
let touchStartX = 0;
let touchStartY = 0;
canvas.addEventListener('touchstart', e => {
e.preventDefault();
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
});
canvas.addEventListener('touchend', e => {
e.preventDefault();
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const dx = touchEndX - touchStartX;
const dy = touchEndY - touchStartY;
if (Math.abs(dx) > Math.abs(dy)) {
if (dx < -20) {
movePiece(-1, 0);
} else if (dx > 20) {
movePiece(1, 0);
}
} else {
if (dy < -20) {
rotate(currentPiece);
} else if (dy > 20) {
dropPiece();
}
}
});
7.3.2 屏幕方向适配
@media (orientation: portrait) {
.game-area {
width: 100%;
max-width: 300px;
}
}
@media (orientation: landscape) {
.game-wrapper {
flex-direction: row;
}
}
八、扩展性与维护性:从 demo 到产品的升级思路
8.1 功能扩展方向
现有项目是一个基础版的俄罗斯方块,可扩展以下功能,使其更接近产品级应用:
8.1.1 音效系统
添加背景音乐、方块移动/旋转/消行/游戏结束音效,提升沉浸感:
class AudioManager {
constructor() {
this.sounds = {
move: new Audio('sounds/move.mp3'),
rotate: new Audio('sounds/rotate.mp3'),
clear: new Audio('sounds/clear.mp3'),
gameOver: new Audio('sounds/game-over.mp3'),
bgm: new Audio('sounds/bgm.mp3')
};
this.sounds.bgm.loop = true;
}
playSound(name) {
this.sounds[name].currentTime = 0;
this.sounds[name].play().catch(e => console.log('Audio play failed:', e));
}
playBgm() {
this.sounds.bgm.play().catch(e => console.log('BGM play failed:', e));
}
pauseBgm() {
this.sounds.bgm.pause();
}
}
const audioManager = new AudioManager();
function movePiece(dx, dy) {
if (!collide(currentPiece, dx, dy)) {
currentPiece.x += dx;
currentPiece.y += dy;
audioManager.playSound('move');
return true;
}
return false;
}
8.1.2 最高分记录
使用 localStorage 存储最高分,跨会话保留游戏数据:
function saveHighScore() {
const highScore = localStorage.getItem('tetrisHighScore') || 0;
if (score > highScore) {
localStorage.setItem('tetrisHighScore', score);
document.getElementById('high-score').textContent = score;
}
}
function loadHighScore() {
const highScore = localStorage.getItem('tetrisHighScore') || 0;
document.getElementById('high-score').textContent = highScore;
}
function spawnPiece() {
if (collide(currentPiece)) {
saveHighScore();
}
}
8.1.3 难度选择
添加难度选择功能,不同难度对应不同的初始下落速度和得分倍率:
const DIFFICULTY = {
easy: { dropInterval: 1000, scoreMultiplier: 1 },
medium: { dropInterval: 800, scoreMultiplier: 1.5 },
hard: { dropInterval: 500, scoreMultiplier: 2 }
};
function selectDifficulty(diff) {
const config = DIFFICULTY[diff];
dropInterval = config.dropInterval;
scoreMultiplier = config.scoreMultiplier;
}
score += points[linesCleared] * level * scoreMultiplier;
8.1.4 皮肤系统
const SKINS = {
classic: [null, '#FF0D72', '#0DC2FF', '#0DFF72', '#F538FF', '#FF8E0D', '#FFE138', '#3877FF'],
neon: [null, '#FF00FF', '#00FFFF', '#FFFF00', '#00FF00', '#FF0000', '#0000FF', '#FF8800'],
pastel: [null, '#F8B195', '#F67280', '#C06C84', '#6C5B7B', '#355C7D', '#88C0D0', '#8FBCBB']
};
function changeSkin(skinName) {
COLORS = SKINS[skinName];
drawBoard();
drawNext();
}
8.2 代码维护性提升
8.2.1 代码重构:面向对象(OOP)改造
现有代码使用函数式编程,可重构为面向对象风格,提升代码的组织性和可维护性:
class TetrisGame {
constructor() {
this.COLS = 10;
this.ROWS = 20;
this.BLOCK_SIZE = 30;
this.COLORS = [];
this.SHAPES = [];
this.board = [];
this.currentPiece = null;
this.nextPiece = null;
this.score = 0;
this.level = 1;
this.lines = 0;
this.dropCounter = 0;
this.dropInterval = 1000;
this.gameRunning = false;
this.gamePaused = false;
this.gameOver = false;
this.canvas = document.getElementById('game-canvas');
this.ctx = this.canvas.getContext('2d');
this.nextCanvas = document.getElementById('next-canvas');
this.nextCtx = this.nextCanvas.getContext('2d');
this.bindEvents();
this.initBoard();
this.gameLoop();
}
initBoard() {
this.board = Array(this.ROWS).fill(null).map(() => Array(this.COLS).fill(0));
}
createPiece(type) {
}
collide(piece, dx = 0, dy = 0) {
}
}
const game = new TetrisGame();
- 将游戏的配置、状态、方法封装在一个类中,避免全局变量污染;
- 方法可通过
this 访问状态,无需传递大量参数;
- 便于继承和扩展,如新增
TetrisGameWithSound 子类,添加音效功能。
8.2.2 注释与文档
function collide(piece, dx = 0, dy = 0) {
}
8.2.3 错误处理
try {
this.ctx = this.canvas.getContext('2d');
if (!this.ctx) {
throw new Error('Canvas 2D context not supported');
}
} catch (e) {
console.error('Canvas initialization failed:', e);
document.getElementById('game-area').innerHTML = '<p>您的浏览器不支持 Canvas,请升级浏览器!</p>';
}
playSound(name) {
try {
this.sounds[name].currentTime = 0;
this.sounds[name].play();
} catch (e) {
console.warn(`Failed to play sound ${name}:`, e);
}
}
8.2.4 单元测试
describe('collide function', () => {
test('检测方块超出右边界', () => {
const game = new TetrisGame();
const piece = { type: 1, shape: [[1, 1, 1, 1]], x: 8, y: 0 };
expect(game.collide(piece, 1, 0)).toBe(true);
});
test('检测方块与已放置方块碰撞', () => {
const game = new TetrisGame();
game.board[19][5] = 1;
const piece = { type: 2, shape: [[2, 2], [2, 2]], x: 4, y: 18 };
expect(game.collide(piece, 0, 1)).toBe(true);
});
});
九、总结与思考
9.1 项目的技术价值
这个俄罗斯方块游戏虽然是一个小型前端项目,但涵盖了前端开发的核心技术和游戏开发的基础原理,其技术价值体现在:
- 原生 Web 技术的综合应用:HTML 语义化布局、CSS3 现代特性、JavaScript 核心语法和 DOM/Canvas API 的深度使用,是前端基础能力的全面实践;
- 游戏开发核心原理的落地:游戏循环、碰撞检测、状态管理、渲染系统等游戏开发的核心概念,通过简单的代码实现,易于理解和学习;
- 性能与体验的平衡:在保证功能完整的前提下,通过算法优化、渲染优化等手段,实现了流畅的游戏体验,体现了前端性能优化的核心思路;
- 工程化思维的体现:模块化设计、设计模式应用、兼容性考虑等,展示了从'写代码'到'做工程'的思维转变。
9.2 学习与进阶方向
对于前端开发者而言,这个项目是一个极佳的学习案例,可从以下方向进阶:
- 技术深度:深入研究 Canvas 渲染原理、游戏物理引擎、性能优化的底层逻辑;
- 工程化:学习使用 Webpack/Vite 构建项目,使用 ES6 模块、TypeScript 提升代码质量;
- 跨端开发:基于该项目,尝试使用 React/Vue 重构,或使用 Electron 打包为桌面应用,使用 Cordova 打包为移动端应用;
- 游戏开发进阶:学习 Phaser、PixiJS 等游戏引擎,开发更复杂的 2D 游戏。
9.3 最终思考
前端开发的核心是'解决问题'和'提升体验',这个俄罗斯方块项目虽然简单,但完美体现了这两个核心:通过简洁的代码解决了游戏逻辑的核心问题,通过现代 CSS 和交互设计提升了用户体验。对于前端开发者而言,无论项目大小,保持对技术的钻研和对体验的关注,才是持续进步的关键。
这个项目的完整实现,不仅是一个可运行的游戏,更是一份前端技术的实践指南,从基础的 HTML/CSS/JS 到进阶的算法、性能优化、设计模式,都能从中找到学习和思考的切入点,是前端学习道路上的一个优秀的'练手项目'。
十、代码
项目目录
项目代码
index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>俄罗斯方块</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<div class="header">
<h1>俄罗斯方块</h1>
</div>
<div class="game-wrapper">
<div class="sidebar left">
<div class="info-panel">
<div class="score-box">
<div class="label">得分</div>
<div class="value" id="score">0</div>
</div>
<div class="score-box">
<div class="label">等级</div>
<div class="value" id="level">1</div>
</div>
<div class="score-box">
<div class="label">消除行数</div>
<div class="value" id="lines">0</div>
</div>
</div>
<div class="next-panel">
<div class="label">下一个</div>
<canvas id="next-canvas" width="120" height="120"></canvas>
</div>
</div>
<div class="game-area">
<canvas id="game-canvas" width="300" height="600"></canvas>
<div class="game-over" id="game-over">
<div class="game-over-content">
<h2>游戏结束</h2>
<p>最终得分:<span id="final-score">0</span></p>
<button class="btn" id="restart-btn">重新开始</button>
</div>
</div>
<div class="pause-overlay" id="pause-overlay">
<div class="pause-content">
<h2>游戏暂停</h2>
<button class="btn" id="resume-btn">继续游戏</button>
</div>
</div>
</div>
<div class="sidebar right">
<div class="controls-panel">
<h3>操作说明</h3>
<div class="control-item">
<span class="key">← →</span>
<span>左右移动</span>
</div>
<div class="control-item">
<span class="key">↓</span>
<span>快速下落</span>
</div>
<div class="control-item">
<span class="key">↑</span>
<span>旋转</span>
</div>
<div class="control-item">
<span class="key">空格</span>
<span>暂停/继续</span>
</div>
</div>
<div class="button-group">
<button class="btn" id="start-btn">开始游戏</button>
<button class="btn" id="pause-btn">暂停</button>
<button class="btn" id="new-game-btn">新游戏</button>
</div>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
style.css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Arial', 'Microsoft YaHei', sans-serif;
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
color: #fff;
}
.container {
max-width: 1200px;
width: 100%;
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header h1 {
font-size: 48px;
font-weight: bold;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
color: #fff;
}
.game-wrapper {
display: flex;
gap: 20px;
justify-content: center;
align-items: flex-start;
}
.sidebar {
width: 200px;
display: flex;
flex-direction: column;
gap: 20px;
}
.info-panel,
.next-panel,
.controls-panel {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 20px;
backdrop-filter: blur(10px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.info-panel .label,
.next-panel .label {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 10px;
text-align: center;
}
.score-box {
margin-bottom: 15px;
}
.score-box:last-child {
margin-bottom: 0;
}
.score-box .label {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 5px;
}
.score-box .value {
font-size: 24px;
font-weight: bold;
color: #fff;
}
.next-panel {
text-align: center;
}
.next-panel canvas {
background: rgba(0, 0, 0, 0.3);
border-radius: 5px;
margin-top: 10px;
}
.controls-panel h3 {
font-size: 18px;
margin-bottom: 15px;
text-align: center;
}
.control-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
font-size: 14px;
}
.control-item:last-child {
margin-bottom: 0;
}
.key {
background: rgba(255, 255, 255, 0.2);
padding: 4px 8px;
border-radius: 4px;
font-weight: bold;
min-width: 60px;
text-align: center;
}
.game-area {
position: relative;
background: rgba(0, 0, 0, 0.3);
border-radius: 10px;
padding: 10px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
}
#game-canvas {
display: block;
background: #000;
border-radius: 5px;
border: 2px solid rgba(255, 255, 255, 0.2);
}
.game-over,
.pause-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: none;
justify-content: center;
align-items: center;
border-radius: 10px;
}
.game-over.show,
.pause-overlay.show {
display: flex;
}
.game-over-content,
.pause-content {
text-align: center;
background: rgba(255, 255, 255, 0.1);
padding: 40px;
border-radius: 10px;
backdrop-filter: blur(10px);
}
.game-over-content h2,
.pause-content h2 {
font-size: 32px;
margin-bottom: 20px;
}
.game-over-content p {
font-size: 18px;
margin-bottom: 30px;
}
.game-over-content #final-score {
font-size: 24px;
font-weight: bold;
color: #ffd700;
}
.button-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.btn {
background: #4a90e2;
color: #fff;
border: none;
border-radius: 6px;
padding: 12px 20px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s;
width: 100%;
}
.btn:hover {
background: #5aa0f2;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.btn:active {
transform: translateY(0);
}
.btn:disabled {
background: rgba(255, 255, 255, 0.2);
cursor: not-allowed;
opacity: 0.6;
}
@media (max-width: 900px) {
.game-wrapper {
flex-direction: column;
align-items: center;
}
.sidebar {
width: 100%;
max-width: 300px;
flex-direction: row;
flex-wrap: wrap;
}
.sidebar.left {
order: 2;
}
.sidebar.right {
order: 3;
}
.game-area {
order: 1;
}
#game-canvas {
width: 100%;
max-width: 300px;
height: auto;
}
.header h1 {
font-size: 36px;
}
}
script.js
const COLS = 10;
const ROWS = 20;
const BLOCK_SIZE = 30;
const COLORS = [
null,
'#FF0D72',
'#0DC2FF',
'#0DFF72',
'#F538FF',
'#FF8E0D',
'#FFE138',
'#3877FF'
];
const SHAPES = [
[[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
[[2, 2], [2, 2]],
[[0, 3, 0], [3, 3, 3], [0, 0, 0]],
[[0, 4, 4], [4, 4, 0], [0, 0, 0]],
[[5, 5, 0], [0, 5, 5], [0, 0, 0]],
[[6, 0, 0], [6, 6, 6], [0, 0, 0]],
[[0, 0, 7], [7, 7, 7], [0, 0, 0]]
];
let board = [];
let currentPiece = null;
let nextPiece = null;
let score = 0;
let level = 1;
let lines = 0;
let dropCounter = 0;
let dropInterval = 1000;
let lastTime = 0;
let gameRunning = false;
let gamePaused = false;
let gameOver = false;
const canvas = document.getElementById('game-canvas');
const ctx = canvas.getContext('2d');
const nextCanvas = document.getElementById('next-canvas');
const nextCtx = nextCanvas.getContext('2d');
function initBoard() {
board = Array(ROWS).fill(null).map(() => Array(COLS).fill(0));
}
function createPiece(type) {
const shape = SHAPES[type];
return {
type: type + 1,
shape: shape,
x: Math.floor(COLS / 2) - Math.floor(shape[0].length / 2),
y: 0
};
}
function randomPiece() {
return createPiece(Math.floor(Math.random() * SHAPES.length));
}
function drawBlock(ctx, x, y, color) {
ctx.fillStyle = color;
ctx.fillRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.strokeRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
ctx.fillRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE / 3, BLOCK_SIZE / 3);
}
function drawBoard() {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
if (board[y][x]) {
drawBlock(ctx, x, y, COLORS[board[y][x]]);
}
}
}
if (currentPiece) {
for (let y = 0; y < currentPiece.shape.length; y++) {
for (let x = 0; x < currentPiece.shape[y].length; x++) {
if (currentPiece.shape[y][x]) {
const blockX = currentPiece.x + x;
const blockY = currentPiece.y + y;
if (blockY >= 0) {
drawBlock(ctx, blockX, blockY, COLORS[currentPiece.type]);
}
}
}
}
}
}
function drawNext() {
nextCtx.fillStyle = '#000';
nextCtx.fillRect(0, 0, nextCanvas.width, nextCanvas.height);
if (nextPiece) {
const shape = nextPiece.shape;
const offsetX = (nextCanvas.width / BLOCK_SIZE - shape[0].length) / 2;
const offsetY = (nextCanvas.height / BLOCK_SIZE - shape.length) / 2;
for (let y = 0; y < shape.length; y++) {
for (let x = 0; x < shape[y].length; x++) {
if (shape[y][x]) {
drawBlock(nextCtx, offsetX + x, offsetY + y, COLORS[nextPiece.type]);
}
}
}
}
}
function collide(piece, dx = 0, dy = 0) {
const newX = piece.x + dx;
const newY = piece.y + dy;
for (let y = 0; y < piece.shape.length; y++) {
for (let x = 0; x < piece.shape[y].length; x++) {
if (piece.shape[y][x]) {
const boardX = newX + x;
const boardY = newY + y;
if (boardX < 0 || boardX >= COLS || boardY >= ROWS) {
return true;
}
if (boardY >= 0 && board[boardY][boardX]) {
return true;
}
}
}
}
return false;
}
function rotate(piece) {
const rotated = piece.shape.map((_, i) => piece.shape.map(row => row[i]).reverse());
const originalShape = piece.shape;
piece.shape = rotated;
if (collide(piece)) {
piece.x -= 1;
if (collide(piece)) {
piece.x += 2;
if (collide(piece)) {
piece.x -= 1;
piece.shape = originalShape;
return false;
}
}
}
return true;
}
function merge() {
for (let y = 0; y < currentPiece.shape.length; y++) {
for (let x = 0; x < currentPiece.shape[y].length; x++) {
if (currentPiece.shape[y][x]) {
const boardY = currentPiece.y + y;
const boardX = currentPiece.x + x;
if (boardY >= 0) {
board[boardY][boardX] = currentPiece.type;
}
}
}
}
}
function clearLines() {
let linesCleared = 0;
for (let y = ROWS - 1; y >= 0; y--) {
if (board[y].every(cell => cell !== 0)) {
board.splice(y, 1);
board.unshift(Array(COLS).fill(0));
linesCleared++;
y++;
}
}
if (linesCleared > 0) {
lines += linesCleared;
const points = [0, 100, 300, 500, 800];
score += points[linesCleared] * level;
level = Math.floor(lines / 10) + 1;
dropInterval = Math.max(100, 1000 - (level - 1) * 100);
updateUI();
}
}
function updateUI() {
document.getElementById('score').textContent = score;
document.getElementById('level').textContent = level;
document.getElementById('lines').textContent = lines;
}
function spawnPiece() {
currentPiece = nextPiece || randomPiece();
nextPiece = randomPiece();
drawNext();
if (collide(currentPiece)) {
gameOver = true;
gameRunning = false;
document.getElementById('final-score').textContent = score;
document.getElementById('game-over').classList.add('show');
}
}
function movePiece(dx, dy) {
if (!currentPiece || gamePaused || gameOver) return;
if (!collide(currentPiece, dx, dy)) {
currentPiece.x += dx;
currentPiece.y += dy;
return true;
}
return false;
}
function dropPiece() {
if (!currentPiece || gamePaused || gameOver) return;
while (movePiece(0, 1)) {
}
merge();
clearLines();
spawnPiece();
}
function gameLoop(time = 0) {
const deltaTime = time - lastTime;
lastTime = time;
if (gameRunning && !gamePaused && !gameOver) {
dropCounter += deltaTime;
if (dropCounter > dropInterval) {
if (movePiece(0, 1)) {
} else {
merge();
clearLines();
spawnPiece();
}
dropCounter = 0;
}
}
drawBoard();
requestAnimationFrame(gameLoop);
}
document.addEventListener('keydown', e => {
if (!gameRunning || gameOver) return;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
movePiece(-1, 0);
break;
case 'ArrowRight':
e.preventDefault();
movePiece(1, 0);
break;
case 'ArrowDown':
e.preventDefault();
movePiece(0, 1);
break;
case 'ArrowUp':
e.preventDefault();
if (currentPiece) {
rotate(currentPiece);
}
break;
case ' ':
e.preventDefault();
togglePause();
break;
}
});
function startGame() {
if (gameRunning && !gamePaused) return;
if (!gameRunning) {
initBoard();
score = 0;
level = 1;
lines = 0;
dropCounter = 0;
dropInterval = 1000;
gameOver = false;
gameRunning = true;
gamePaused = false;
document.getElementById('game-over').classList.remove('show');
document.getElementById('pause-overlay').classList.remove('show');
spawnPiece();
updateUI();
} else if (gamePaused) {
togglePause();
}
}
function togglePause() {
if (!gameRunning || gameOver) return;
gamePaused = !gamePaused;
document.getElementById('pause-overlay').classList.toggle('show', gamePaused);
}
function newGame() {
gameRunning = false;
gamePaused = false;
gameOver = false;
document.getElementById('game-over').classList.remove('show');
document.getElementById('pause-overlay').classList.remove('show');
startGame();
}
document.getElementById('start-btn').addEventListener('click', startGame);
document.getElementById('pause-btn').addEventListener('click', togglePause);
document.getElementById('new-game-btn').addEventListener('click', newGame);
document.getElementById('restart-btn').addEventListener('click', newGame);
document.getElementById('resume-btn').addEventListener('click', togglePause);
initBoard();
drawBoard();
drawNext();
gameLoop();
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Gemini 图片去水印
基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online