从零剖析一个俄罗斯方块项目: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 每帧做三件事:

  1. 画网格线(低透明度细线);
  2. 画已落定的方块(带内阴影 + 顶部高光 + 底部暗边的"3D 浮雕"效果);
  3. 画当前方块(带 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) 适配视口高度。


七、发现的两个小瑕疵

作为博客也聊聊代码里的小问题(都是非阻塞的):

  1. CSS 死规则styles.css:256):

css .board-frame:has(+ .overlay.visible) { /* 红框光晕 */ }

用了相邻兄弟选择器 +,但 .overlay.board-frame子元素,这条规则永远不会命中——"游戏结束红光"效果实际没生效。

  1. 隐藏按钮仍可被 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)    收藏  举报

导航