打开一个网页,不用安装,不用注册,八款经典游戏就在那里等着你。贪吃蛇、俄罗斯方块、打砖块、Flappy、2048、扫雷、数独、五子棋,它们共用同一套街机柜界面,键盘和触屏都能玩。
整个项目用纯 TypeScript 和 Canvas 2D 写成,零运行时依赖,构建产物压缩后大约 35 kB(不含字体)。配套 269 个单元测试和 28 个 Playwright 端到端测试,全部跑在 CI 里,MIT 协议开源。
![]()
作者把实现里值得写下来的部分整理了出来。
一个机柜,八款游戏
八款游戏共享同一个外壳:顶栏、屏幕区、结果浮层、按键提示条。每款游戏只实现一个接口,然后拿到一个 GameContext,里面是它被允许使用的一切——音效、存储、输入服务、结果浮层、屏幕上方和侧边的 DOM 插槽、触控板插槽、提示设置。
要弹出一张结果卡片,游戏只需要调用一次,外壳负责绘制。这样做的好处很直接:结果卡片、暂停、静音、返回大厅,在八款游戏里长得一样、行为也一样,而每款游戏的代码只管自己的规则。加第九款游戏,不用动外壳。
规则和渲染从不混在一起
每款游戏围绕 logic.ts 和 index.ts 构建,五子棋还多一个 AI 模块和一个 Worker。logic.ts 只管状态和规则:没有 DOM,没有 Canvas,随机源也作为参数传进来。index.ts 负责把状态画出来,把输入转成规则调用。
以 2048 为例,合并一行就是一个普通函数:把非零方块滤出来,相邻相同就翻倍并计分,跳过后一个方块,最后补零到长度 4。它的测试只断言输入和输出,在 Node 里就能跑,不需要浏览器。
这种拆分在项目进行到一半时证明了自己的价值。当时做了一次完整的视觉重设计,从暗色霓虹换成现在的暖纸质感。改动落在渲染代码、共享外壳和样式表上,八个 logic.ts 文件和它们的测试一行都没动。一条命令就能确认:对 logic.ts 和对应测试文件做 diff,结果必须是空的。
棋盘画在 Canvas,控件用真按钮
网格、方块和精灵是画出来的,棋盘本身在 Canvas 上接收点击。但辅助控件——数字键盘、难度菜单、撤销键——都是真正的 button 元素。
理由很实际:真按钮自带焦点环、键盘访问和容易调整大小的触控目标。画在 Canvas 上的按钮,这些都得手写,而且怎么写都不如原生。外壳提供三个 DOM 插槽,屏幕上方、屏幕侧边、屏幕下方的触控板,游戏声明自己需要哪些,外壳就把容器交过去。
五子棋 AI 跑在 Web Worker 里
五子棋 AI 用的是带 alpha-beta 剪枝的极小化极大算法,三个难度对应搜索深度 1、2、4。深度 4 放在主线程会冻住界面,所以搜索放进 Worker 执行。
把搜索挪到线程外,还剩一个问题:玩家可能在 AI 思考时重开一局,旧任务的答案不能落到新棋盘上。每次重开都会让 token 自增,Worker 的回复带着发送时的 token,主线程拿它和当前对局比对,一致才采用这步棋。这是丢弃过期结果,不是取消旧搜索。保留 Worker、忽略过期回复,省掉了取消再重建的逻辑,代价是旧搜索仍在消耗 CPU,可能拖慢下一次请求。
体积和首屏是两件事
不用 UI 框架、不用游戏引擎,意味着要发的运行时代码很少:整个构建压缩后约 35 kB,不含字体。首屏加载是另一回事:八款游戏通过动态 import() 按需加载。大厅只加载外壳和路由,打开某款游戏时才去取它的模块。
代码分割主要减少的是首屏加载,它本身并不会让总构建体积变小。唯一的第三方请求是 Google Fonts。没有后端,分数存在 localStorage 里。
和 AI 一起写代码
架构和大部分代码是和 Claude Code 一起写的。每一轮工作都从一份简短的设计说明开始,记录范围、取舍,以及结果和设计稿不一致的地方,这些说明放在仓库的 docs/design/ 目录里。踩过的坑收集在 CLAUDE.md 里,比如"CSS 的 inset 阴影盖不住 canvas"、"绝不要自动聚焦结果浮层的按钮"。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.