从零剖析一个俄罗斯方块项目:Canvas 游戏引擎 + Electron 桌面封装
从零剖析一个俄罗斯方块项目:Canvas 游戏引擎 + Electron 桌面封装
一个"网页即产品、封装即交付"的完整案例:无框架、无构建步骤、零图片资源的游戏核心,加上一层薄薄的 Electron 外壳,就变成了一款可安装的 Windows 桌面应用。
一、项目定位:一鱼两吃的架构
这个项目最值得玩味的不是俄罗斯方块本身,而是它的交付形态:
┌─────────────────────────────────────────────┐
│ 核心层(纯前端,零依赖) │
│ index.html + styles.css + app.js │
│ ├─ 双击即玩(浏览器 file:// 协议直接打开) │
│ └─ 无构建、无打包、无框架 │
└─────────────────────────────────────────────┘
│ 被装载
▼
┌─────────────────────────────────────────────┐
│ 外壳层(Electron) │
│ main.js(主进程)+ preload.js(桥接) │
│ └─ electron-builder → NSIS 安装包/便携版 │
└─────────────────────────────────────────────┘
- 核心层:纯 HTML/CSS/JS,游戏逻辑与渲染完全与平台解耦,浏览器里打开
index.html就是完整产品。 - 外壳层:Electron 只是"包装纸"——负责开窗口、加载本地文件、提供系统菜单,游戏本身一行不改。
这种架构的优点很明显:一份代码,两种交付。网页版方便分享试玩,桌面版方便安装使用;而且游戏逻辑可以在 Node 环境里用 DOM shim 做自动化冒烟测试(后文会讲),开发效率很高。
二、项目文件结构
俄罗斯方块/
├── index.html # 页面骨架:棋盘、预览、暂存、统计面板、触屏按钮
├── styles.css # 深色主题 + 三档响应式布局(桌面/平板/手机)
├── app.js # 游戏核心:状态机 + 渲染 + 输入(954 行 IIFE)
├── main.js # Electron 主进程:创建窗口、系统菜单、IPC 发送
├── preload.js # contextBridge 安全桥:渲染进程收命令
├── package.json # electron + electron-builder 打包配置
├── build/icon.png # 应用图标
└── README.md # 运行/打包说明
几个值得注意的设计决策:
| 决策 | 选择 | 理由 |
|---|---|---|
| 文件组织 | 三文件分离(HTML/CSS/JS) | 可维护性优于单文件 |
| 渲染方式 | Canvas 2D | 棋盘格子绘制性能好、碰撞渲染简单 |
| 框架 | 无(原生 JS,IIFE 封装) | 零依赖、零构建、file:// 可直接运行 |
| 音频 | WebAudio 实时合成 | 不需要任何音频资源文件 |
三、游戏核心:一个自洽的"小引擎"
app.js 是一个 954 行的 IIFE(立即执行函数),内部封闭了全部状态,不污染全局。它本质上是一个最小可用的游戏引擎,包含四大部分:
3.1 数据模型:二维数组棋盘
棋盘是核心数据结构,简洁得有些优雅:
const COLS = 10, ROWS = 20;
function createRow() { return Array(COLS).fill(""); }
function createBoard() { return Array.from({ length: ROWS }, createRow); }
棋盘是 20×10 的二维数组,每个格子存方块类型字符串("I"、"O"、"T"…)或空字符串。类型即颜色——COLORS 表把类型映射到颜色,渲染时直接查表,数据与表现天然解耦。
方块本身(当前下落中的)则是一个对象:
{ type, matrix, x, y }
matrix:形状矩阵(如 T 型是[[0,1,0],[1,1,1],[0,0,0]])x/y:矩阵左上角在棋盘上的坐标- 注意 I 型方块初始
y = -1——允许方块部分悬在棋盘上方,这是俄罗斯方块的标准做法,因为 I 型在 10 列棋盘上需要从顶外落下
3.2 7-bag 随机:杜绝"方块荒"
随机系统用的是俄罗斯方块官方竞赛标准——7-bag 洗牌:
function makeBag() { return shuffle(["I","O","T","S","Z","J","L"]); }
// 队列常备 5 个预览
fillQueue() { while (this.queue.length < 5) this.queue.push(...makeBag()); }
7 种方块各出现一次才重新洗牌,保证任意窗口内七种方块都会到来,避免了纯随机的"憋方块"体验。
3.3 碰撞检测:一次遍历,边界与堆叠全管
canPlace 是整个游戏正确性的基石,逻辑只有十几行:
canPlace(matrix, x, y) {
for (let r = 0; r < matrix.length; r++)
for (let c = 0; c < matrix[r].length; c++) {
if (!matrix[r][c]) continue;
const bx = x + c, by = y + r;
if (bx < 0 || bx >= COLS || by >= ROWS) return false; // 出界
if (by >= 0 && this.board[by][bx]) return false; // 撞已落方块
}
return true;
}
两个细节值得学习:
1. by >= 0 的判断——允许方块在上方屏幕外(棋盘外)活动,只有落到棋盘内才检查堆叠;
2. !matrix[r][c] 提前跳过空位——形状矩阵的空白格不参与碰撞。
所有移动、旋转、下落、锁定前都要过这道检查,它就是整个游戏的"物理引擎"。
3.4 旋转 + 踢墙:手感的关键
旋转不只是矩阵转置,还带踢墙修正(wall kick):
const kicks = [[0,0],[-1,0],[1,0],[0,-1],[-2,0],[2,0],[0,1]];
for (const [dx, dy] of kicks) {
if (canPlace(rotated, x + dx, y + dy)) { /* 采用 */ }
}
旋转失败时依次尝试 7 个偏移量。没有这一步,贴墙旋转会"卡死",这是方块游戏手感的分水岭。
3.5 游戏循环:时间累积而非帧计数
主循环用 requestAnimationFrame,但下落节奏用的是时间差累积(delta accumulator):
update(delta) {
this.tickAcc += delta;
while (this.tickAcc >= this.getInterval() && running) {
this.tickAcc -= this.getInterval();
this.stepDown();
}
}
getInterval() { return Math.max(70, 760 * Math.pow(0.82, level - 1)); }
- 用
while而不是if:防止掉帧时方块"冻结",一次补多步; - delta 上限 120ms:切后台回来不会瞬间坠毁;
- 等级加速度是指数曲线(每级 ×0.82),后期节奏感强烈。
3.6 计分体系:给玩家即时反馈
消行:100 / 300 / 500 / 800(×等级),Tetris(4行) = 1200×等级
连击:combo × 50 × 等级(连续消行不断连)
软降:+1/格,硬降:+2/格
高分写入 localStorage,且用 250ms 防抖合并写入,避免高频落盘。
3.7 渲染:一次绘制,三层叠加
drawBoard 每帧做三件事:
- 画网格线(低透明度细线);
- 画已落定的方块(带内阴影 + 顶部高光 + 底部暗边的"3D 浮雕"效果);
- 画当前方块(带
shadowBlur同色辉光)和幽灵落点(半透明轮廓)。
// 幽灵落点:从当前位置往下探测到第一个碰撞点
getGhostY() {
let y = this.current.y;
while (this.canPlace(this.current.matrix, this.current.x, y + 1)) y += 1;
return y;
}
高 DPI 适配也做了:canvas.width = rect.width * devicePixelRatio,再用 setTransform(dpr,...) 统一坐标,Retina 屏上依然清晰。
3.8 输入系统:DAS 手感与多端覆盖
- 键盘:方向键 + WASD + Z/X(双向旋转)+ C(暂存)+ P(暂停)+ R(重开)+ M(静音),并阻止方向键/空格滚动页面;
- DAS(延迟自动移动):按住方向键 150ms 后以 56ms 间隔连续移动,模拟街机手感;
- 触屏/鼠标:按钮用
pointerdown+setInterval实现长按连发; - 自动暂停:窗口失焦或切走标签页时自动暂停,防止后台"偷跑"。
四、音频:零资源的 WebAudio 合成
整个游戏没有任何音频文件,音效全部用 WebAudio API 实时合成:
function tone(frequency, duration, type, volume) {
const osc = ctx.createOscillator();
osc.type = type; // square / triangle / sawtooth
osc.frequency.setValueAtTime(frequency, ctx.currentTime);
// gain 指数衰减 → 自然的"叮"声
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
}
- 移动是 210Hz 方波短音,消行是 520→660Hz 双音,Tetris 是 523→659→784Hz 三连音,游戏结束是低频锯齿波下行;
AudioContext延迟到首次用户交互才创建,天然规避了浏览器的自动播放策略;- 静音状态持久化到
localStorage。
这个思路值得借鉴:用代码生成音效,省掉资源文件,包体更小,还能动态调参。
五、Electron 封装:薄而安全
5.1 主进程(main.js)
职责单一:创建窗口 + 系统菜单。窗口安全配置到位:
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true, // 渲染进程与 Node 隔离
nodeIntegration: false, // 渲染进程无 Node
sandbox: true // 开启沙箱
}
菜单"游戏"里的"重新开始 / 暂停继续",通过 IPC 向渲染进程发命令。
5.2 桥接层(preload.js):安全的 IPC 通道
contextBridge.exposeInMainWorld("tetrisApp", {
onCommand(callback) {
const listener = (_e, command) => callback(command);
ipcRenderer.on("app-command", listener);
return () => ipcRenderer.removeListener("app-command", listener);
}
});
关键设计:
- 用 contextBridge 暴露最小 API——只有 onCommand 一个方法,不暴露整个 ipcRenderer;
- 返回取消订阅函数,调用方可清理监听;
- 游戏代码里用 if (window.tetrisApp) 守卫,同一份 app.js 在浏览器和 Electron 里都能跑——这就是"一鱼两吃"落地的关键一行。
5.3 打包(electron-builder)
package.json 的 build 配置:
files白名单只打包 5 个源文件 + 图标,node_modules由 builder 按需裁剪;win.target同时产出 NSIS 安装包和便携版两种形态;- NSIS 支持选择安装目录、创建桌面/开始菜单快捷方式。
六、工程实践亮点
6.1 无头验证:DOM shim 冒烟测试
没有浏览器也能测游戏逻辑——写一个最小 DOM shim(smoke-test.js):
// 用 Proxy 兜底所有 canvas 2D 方法
function makeCtx() { return new Proxy({}, { get(t, p) { /* 自动生成空函数 */ } }); }
// 模拟 document / window / localStorage / requestAnimationFrame ...
eval(fs.readFileSync("app.js", "utf8"));
// 派发 keydown 事件 → 开始游戏、移动、旋转、暂存、硬降、跑 600 帧
测试结果:运行中 → 移动/旋转/暂存/软降/硬降 → 600 帧无异常,计分正确。零浏览器环境也能做持续集成,这是把游戏逻辑与 DOM 解耦带来的红利。
6.2 状态机的清晰表达
游戏状态 ready → running ⇄ paused → over 用一个 statusMap 驱动全部 UI(状态栏文字、指示点颜色、覆盖层文案、按钮禁用态),单一状态源,UI 全量刷新,避免了分散的 if-else 拼 UI。
6.3 响应式布局
CSS 变量统一主题色;minmax() 网格 + 三个断点(980/760/520px)重排布局:桌面三栏 → 平板双栏 → 手机单栏且触屏按钮显现、桌面按钮隐藏。手机端棋盘尺寸用 calc((100dvh - 250px) / 2) 适配视口高度。
七、发现的两个小瑕疵
作为博客也聊聊代码里的小问题(都是非阻塞的):
- CSS 死规则(
styles.css:256):
css
.board-frame:has(+ .overlay.visible) { /* 红框光晕 */ }
用了相邻兄弟选择器 +,但 .overlay 是 .board-frame 的子元素,这条规则永远不会命中——"游戏结束红光"效果实际没生效。
- 隐藏按钮仍可被 Tab 聚焦:覆盖层用
opacity: 0+pointer-events: none隐藏,但没有visibility: hidden,键盘用户仍能 Tab 到不可见的开始按钮。
八、小结
这个项目是一个很好的"最小完整游戏产品"范本:
- 架构:核心与外壳分离,一份代码双端交付,零构建、零依赖;
- 引擎:7-bag 随机、时间累积循环、一次遍历碰撞检测、踢墙旋转、幽灵落点——麻雀虽小五脏俱全;
- 体验:WebAudio 合成音效、DAS 手感、自动暂停、高分持久化、三档响应式;
- 工程:无头冒烟测试、状态机驱动 UI、安全 Electron 配置、可复现的打包流程。
如果你想照着做一个类似的"网页 + 桌面"双形态产品,这个项目的骨架——纯前端核心 + 安全桥接 + electron-builder 打包——可以直接复用。
相关文件:app.js(游戏核心)、main.js / preload.js(Electron 外壳)、smoke-test.js(无头冒烟测试)
posted on 2026-08-14 12:57 fox_charon 阅读(9) 评论(0) 收藏 举报
浙公网安备 33010602011771号