Electron + Vue 3 桌面打字游戏实战:从 VSCode 扩展到独立应用的架构改造

一个"警察追小偷"式打字对战游戏的完整开发复盘,核心思路是把纯游戏逻辑和渲染框架彻底拆开,这套分层在 Electron / Web / 小程序里都能复用。

先看成果

一个终端复古风的打字追逐游戏:屏幕上是被拦截的字符流,你扮演黑客 THIEF,每打对一个字符就前进一段,3 秒后 FIREWALL(防火墙)开始追击,在它追上你之前打完整个关卡文本即可"入侵成功"。

  • 技术栈:Electron 28 + Vue 3.5(<script setup> + TS)+ Vite + electron-builder
  • 玩法:4 关文本教材 × 4 档难度系数,实时统计 WPM / 准确率 / 最大连击,成绩存本地

更值得说的是这个项目的出身 :它最初是一个 VSCode 扩展里的打字练习,渲染层用 webview + 拼接 HTML 字符串 + postMessage 通信。改造为独立桌面应用时,我做了一次彻底的重构,本文重点讲这次重构的架构决策,而不是 UI 代码本身。


一、为什么从 VSCode 扩展改成 Electron 应用

原版 VSCode 扩展的痛点非常典型:

  1. 渲染层是字符串 :webview 的 HTML 是一整段拼出来的字符串,状态更新靠 postMessage 手动改 DOM,开发体验约等于"用 jQuery 写交互"。
  2. 逻辑和渲染强耦合:控制类直接操作 webview,想复用玩法逻辑去别的平台?没门。
  3. 发布依赖 VSCode:代码在扩展市场,别人得装 VSCode 才能玩。

改造成 Electron 应用后,最大的收益是:游戏逻辑一行没碰,只是换了渲染壳。这得益于第一版架构里就把"纯玩法逻辑"抽成了平台无关的 TS 类。


二、三层架构:逻辑层 / 桥接层 / 视图层

复制代码
┌─────────────────────────────────────────┐
│  视图层(Vue 3 SFC)                     │
│  MainMenuView / GameView / GameOverView  │
├─────────────────────────────────────────┤
│  桥接层(gameStore 全局单例)            │
│  持有 GameLogic、同步 HUD、管理定时器     │
├─────────────────────────────────────────┤
│  逻辑层(GameLogic 纯 TS 类,零依赖)     │
│  打字判定 / 进度 / 碰撞 / 难度系数        │
└─────────────────────────────────────────┘

1. 逻辑层:GameLogic ------ 纯 TS,不 import 任何框架

这是整个改造里最值钱的一行设计:游戏状态机(当前字/词、分数、生命、连击、警察进度)全部封装在 GameLogic 里,Vue、React 还是原生 DOM 它一概不知道。

ts 复制代码
// src/game/gameLogic.ts(节选)
export class GameLogic {
  inputChar(char: string): boolean {
    const expectedChar = this.getCurrentWord()[this.currentCharIndex];
    if (char === expectedChar) {
      this.correctChars++;
      this.currentCharIndex++;
      this.combo++;
      if (this.currentCharIndex >= this.getCurrentWord().length) {
        this.score += 10;          // 打完一个单词加分
        this.currentWordIndex++;
        this.currentCharIndex = 0;
      }
      return true;
    }
    this.combo = 0;
    this.lives--;
    return false;
  }

  checkCollision(): 'police_win' | 'thief_escape' | 'none' {
    // 打完 → 逃出生天;命没了 / 警察追上 → 被抓
  }
}

收益是什么?

  • 可单测 :纯类没有 DOM,Vitest 直接 new GameLogic(level, diff) 就能断言每个分支。
  • 可移植:同一套玩法以后做 Web 版、小程序版,逻辑层原样 import。
  • 改动安全:调平衡(警察速度、生命数)只动这一个文件,不碰任何组件。

2. 桥接层:gameStore ------ 用 Vue 的响应式"顶替" webview 通信

VSCode 版靠 postMessage 把状态推到 UI;Vue 版我建了一个全局单例 store ,持有 GameLogic 实例,再把 typingCharshud(分数/生命/连击/双方进度)暴露成响应式数据。视图组件只读 gameStore.state组件之间不需要任何 props/events 传递

ts 复制代码
// src/stores/gameStore.ts(节选)
const hud = reactive({ score: 0, lives: 3, combo: 0, police: 0, thief: 0, ... });
const typingChars = ref<TypingChar[]>([]);
let logic: GameLogic | null = null;

function typeCharacter(input: string) {
  if (screen.value !== 'playing' || !logic) return;
  const correct = logic.inputChar(input);
  syncAllHud();                                  // 逻辑层状态 → 响应式
  typingChars.value = logic.getTypingChars();    // 只读数据进渲染
  // 打完/被抓 → finishGame,否则闪红字提示
}

export const gameStore = { state, startGame, typeCharacter, /* ... */ };

视图层拿到的是一个 {char, state}[] 数组,每个字符只有三态,直接映射成 CSS 类,渲染极简:

vue 复制代码
<!-- GameView.vue(节选) -->
<span
  v-for="(c, i) in gameStore.state.typingChars"
  :key="i"
  class="char"
  :class="c.state"   <!-- completed / cursor / pending -->
>{{ c.char }}</span>

3. 视图层:三个 SFC + 一个壳

App.vue 只做一件事:根据 screen 状态换屏 ,并把唯一的 window keydown 监听在挂载时注册、卸载时移除。

vue 复制代码
<MainMenuView v-if="state.screen === 'menu'" />
<GameView v-else-if="state.screen === 'playing'" />
<GameOverView v-else />

三、几个值得说的工程细节

1. 全局键盘分发:把快捷键都收敛到一个函数里

打字游戏要求"全键盘可玩、无需鼠标"。所有按键逻辑集中在 gameStore.onKeydown,按屏幕状态分发,主进程不用碰:

场景 按键
菜单 S 开始
对局中 字符 = 打字;Backspace 回退;Esc 中止
结算 R 重玩 / N 下一关 / M 回菜单
ts 复制代码
function onKeydown(event: KeyboardEvent) {
  if (screen.value === 'playing') {
    if (event.key === 'Backspace') { ... }
    else if (event.key === 'Escape') { goMenu(); }
    else if (!event.ctrlKey && !event.metaKey && !event.altKey && event.key.length === 1) {
      typeCharacter(event.key);   // 过滤修饰键 + 组合键,只收单字符
    }
  } else if (screen.value === 'menu') {
    if (event.key.toLowerCase() === 's') startFromMenu();
  }
}

注意:真实发布前还应处理 event.isComposing(中文输入法合成)和 event.repeat(长按连发),否则中文输入法用户会被误判打字错误------这是我下一版要补的。

2. 三个定时器,各司其职 + 统一清理

游戏里并行了三个节奏完全不同的定时器,这是最容易写出"离开游戏还在跑"事故的地方:

ts 复制代码
// 800ms:警察追击步进(玩法核心,与移动速度 0.85/步耦合)
logicTimer = setInterval(() => {
  logic.moveOpponentAuto();
  if (logic.checkCollision() !== 'none') return finishGame(collision);
  syncHud();
}, 800);

// 200ms:倒计时 / 进度条刷新(纯展示,高频但无副作用)
uiTimer = setInterval(syncHud, 200);

// 3000ms:黑客风味文案轮换(氛围)
messageTimer = setInterval(rotateMessage, 3000);

所有入口(开始/结束/回菜单)必须先 clearTimers() 再开新局,否则连点重玩会出现多个警察叠加加速的幽灵 Bug。

3. 警察的"追击"是纯数值模拟

不引入游戏引擎,追逃用两个百分比数字模拟:

  • THIEF 进度 = 已打字符 / 总字符 × 100
  • FIREWALL 进度 = 每 800ms 累加 0.85 × 难度系数,前 3 秒倒计时不动

碰撞判定一句搞定:policePosition >= thiefProgress。整个"紧张感"其实是倒计时 + 进度条逼近 + 红字预警文案三个效果叠加出来的,纯 CSS 过渡就能做出平滑追赶。

4. 成绩统计:localStorage 一把梭

TypingStatsManager 是个静态类:WPM = 正确字符数 / 5(一个单词按 5 字符计)/ 分钟;准确率 = 正确 / 总输入;历史对比取最近 5 局均值算涨跌,最多保留 50 条。读写都包了 try/catch------Electron 里 localStorage 也可能被禁用,坏了不影响游戏主流程

5. Electron 双环境加载:一行环境变量切换 dev/prod

主进程开发时连 Vite dev server(支持 HMR),生产 loadFile 本地 HTML:

ts 复制代码
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
if (devServerUrl) {
  mainWindow.loadURL(devServerUrl);
} else {
  mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
}

配合 package.json

jsonc 复制代码
"dev": "concurrently -k -n renderer,main \"vite\" \"npm:dev:main\"",
"dev:main": "wait-on tcp:127.0.0.1:5173 && npm run build:main && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron ."

concurrently 同时起渲染进程和主进程,wait-on 等 Vite 端口就绪再启动 Electron,cross-env 注入 URL------这套模板直接抄走能用。

四、打包:electron-builder 与国内镜像坑

配置要点(package.jsonbuild 字段):

jsonc 复制代码
"build": {
  "appId": "com.hackertyper.game",
  "files": ["dist/**/*", "dist-electron/**/*", "package.json"],
  "win": { "target": [{ "target": "nsis", "arch": ["x64"] }] },
  "nsis": {
    "oneClick": false,
    "allowToChangeInstallationDirectory": true
  }
}

三个我实际踩过的坑:

  1. base: './' 必须设 :生产走 file:// 协议,不设相对路径资源全部 404。

  2. 依赖二进制下载超时 :第一次 electron-builder 要下载 Electron 和 winCodeSign,国内几乎必失败。设镜像:

    powershell 复制代码
    $env:ELECTRON_MIRROR = "https://npmmirror.com/mirrors/electron/"
    $env:ELECTRON_BUILDER_BINARIES_MIRROR = "https://npmmirror.com/mirrors/electron-builder-binaries/"
  3. Electron 28 + Vite 8 的 ESM 警告vite.config.mts 里用 ESM 语法会被提示"in a file loaded as CommonJS",把配置文件统一成 .mts 就能根治(顺带,tsconfig.json 的 include 别忘了同步改,我的就漏过)。

五、目录结构:一眼看懂职责边界

bash 复制代码
├── src/                  # 渲染进程
│   ├── game/             #   ★ 纯玩法逻辑(可测、可移植)
│   ├── stores/           #   桥接层:响应式单例
│   ├── views/            #   三个屏幕 SFC
│   └── styles/           #   全局 CRT 终端主题
├── src-electron/         # Electron 主进程 + preload
└── dist / dist-electron / release   # 构建产物

六、复盘:这套架构能带走什么

  1. 游戏/工具类项目,先写"框架无关的逻辑层"。它是你未来所有形态(Web / 桌面 / 小程序 / VSCode 扩展)复用的底座。
  2. Vue 的响应式天然适合做"状态桥":一个单例 store 顶替了传统复杂的消息协议,组件间零通信成本。
  3. 多定时器场景,统一入口创建、统一函数清理,否则重开对局必出幽灵状态。
  4. Electron 项目最容易翻车的不是代码,是打包链路 ,环境变量镜像、base:'./'.mts 三个坑记好。

完整代码已开源:

  • 源码:Hacker Typing Game
  • Windows 安装包在仓库 Releases 页可直接下载体验

如果对"逻辑层与渲染层解耦""VSCode webview 迁移 Electron"这类话题有兴趣,欢迎评论区交流,也可以聊聊你自己的移植项目~


本文由「Hacker Typing Game」开发记录整理。Electron 28 · Vue 3.5 · TypeScript · electron-builder 26

相关推荐
柚yuzumi2 小时前
别再猜 this:先看它属于谁,再看它指向谁
前端·javascript
汉堡大王95272 小时前
面试必考:手写代码 new 做了什么?从原理到实现全解析
前端·javascript·面试
BillKu2 小时前
TypeScript中,字符串字面量联合类型(Union Type)、enum的用法说明
前端·javascript·typescript
顶级自由人4 小时前
本地正常、线上正常,为什么一个 Hook 仍会报错?
前端·javascript·程序员
lerhxx4 小时前
R3F 第一人称漫游与碰撞检测:Pointer Lock + 不穿墙的滑墙秘诀(中)
前端·javascript·three.js
labixiong4 小时前
button按钮原生开关弹窗,零 JS 搞定80%交互场景
前端·javascript·html
隔岸观火烧连营4 小时前
如何用 WebCodecs 在浏览器里实现高清录屏 —— 无插件、无水印、直接导出 MP4
前端·javascript
Hilaku6 小时前
技术好就能升职是前端圈最大的谎言!
前端·javascript·程序员
光影少年6 小时前
react navite高频手写/实操题
前端·javascript·react native·react.js·前端框架