一个"警察追小偷"式打字对战游戏的完整开发复盘,核心思路是把纯游戏逻辑和渲染框架彻底拆开,这套分层在 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 扩展的痛点非常典型:
- 渲染层是字符串 :webview 的 HTML 是一整段拼出来的字符串,状态更新靠
postMessage手动改 DOM,开发体验约等于"用 jQuery 写交互"。 - 逻辑和渲染强耦合:控制类直接操作 webview,想复用玩法逻辑去别的平台?没门。
- 发布依赖 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 实例,再把 typingChars、hud(分数/生命/连击/双方进度)暴露成响应式数据。视图组件只读 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.json 的 build 字段):
jsonc
"build": {
"appId": "com.hackertyper.game",
"files": ["dist/**/*", "dist-electron/**/*", "package.json"],
"win": { "target": [{ "target": "nsis", "arch": ["x64"] }] },
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
三个我实际踩过的坑:
-
base: './'必须设 :生产走file://协议,不设相对路径资源全部 404。 -
依赖二进制下载超时 :第一次
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/" -
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 # 构建产物
六、复盘:这套架构能带走什么
- 游戏/工具类项目,先写"框架无关的逻辑层"。它是你未来所有形态(Web / 桌面 / 小程序 / VSCode 扩展)复用的底座。
- Vue 的响应式天然适合做"状态桥":一个单例 store 顶替了传统复杂的消息协议,组件间零通信成本。
- 多定时器场景,统一入口创建、统一函数清理,否则重开对局必出幽灵状态。
- Electron 项目最容易翻车的不是代码,是打包链路 ,环境变量镜像、
base:'./'、.mts三个坑记好。
完整代码已开源:
- 源码:Hacker Typing Game
- Windows 安装包在仓库 Releases 页可直接下载体验
如果对"逻辑层与渲染层解耦""VSCode webview 迁移 Electron"这类话题有兴趣,欢迎评论区交流,也可以聊聊你自己的移植项目~
本文由「Hacker Typing Game」开发记录整理。Electron 28 · Vue 3.5 · TypeScript · electron-builder 26