做这个数独游戏其实是个意外。
三个月前我妈给我推荐了个"老年版数独" app, 我装上发现:
启动 50MB → 看 5 秒开屏广告 → 弹窗引导登录注册 → 关掉弹窗后还要看一次
激励视频才能进入游戏。
我就想: 安安静静做道题而已, 真的需要这么复杂吗?
试了几个网页版, 体验更糟: 有的塞满 emoji 干扰你的判断, 有的计时器一进游戏就开始跑,
我点错一下就丢 5 秒。
于是我决定自己撸一个。
一、单文件 70KB · 0 依赖
第一个问题: 用什么技术栈?
-
React / Vue 太重, 70KB 的游戏塞个框架不合理
-
npm install 安装半小时, 部署上线再打包, 杀鸡用牛刀
-
所以最终方案: 纯 HTML5 + CSS3 + Vanilla JS, 单文件 70KB, 打开就能玩
部署到 GitHub Pages 之后, 用户在浏览器打开就能玩;
如果浏览器支持 PWA, "添加到主屏幕" 后跟原生 APP 没区别;
想装 Android 的可以从 GitHub Release 拉 66KB 的 APK。
整个仓库就一个 HTML 文件 + 一个 manifest.json + 两个图标 + 一个 LICENSE,
没有任何构建工具, 没有任何 npm 依赖。
完整仓库: github.com/GZTai/one-t…
二、数独生成器: 怎么保证每盘都有唯一解
数独看似简单, 真做起来才发现坑多。
第一版我直接挖洞: 生成完整数独 → 随机挖掉 30 个格子。结果发现
很多盘有多个解, 用户做到一半发现"咦我填的也是对的", 体验直接裂开。
于是加了唯一解校验:
function countSolutions(board, cap = 2) {
// 回溯找解, 找到 cap 个就停, 不求全解
// 关键: cap = 2 比 cap = ∞ 快 100 倍
const empty = findEmpty(board);
if (!empty) return 1;
let count = 0;
for (const n of candidates(board, empty)) {
board[empty] = n;
count += countSolutions(board, cap);
if (count >= cap) {
board[empty] = 0;
return count;
}
}
board[empty] = 0;
return count;
}
判断逻辑: 挖洞后跑一遍校验, 如果解数 > 1, 就把那个格子补回去。
这样能保证用户拿到的盘永远有且仅有一个解。
难度梯度怎么选提示数: 我做了一轮测试, 结论是:
- 45 个提示: 入坑级 (新手秒做)
- 38 个提示: 杂志级 (周末填填报纸那种)
- 32 个提示: App 中等难度
- 28 个提示: App 高级
- 25 个提示: App 大师 (硬核玩家上限)
踩坑警告: 17 个提示是数学极限, 我尝试过做到 17 提示 + 唯一解,
1000 盘里只有 3 盘能达到, 不适合做工程方案, 别瞎试。
三、移动端适配的 7 个细节
1. touch-action: manipulation —— 砍掉 300ms 延迟
移动浏览器为了"双击放大"会在第一次点击后等待 300ms,
加上这个 CSS 后浏览器知道这是个 web app, 直接响应:
.board-cell { touch-action: manipulation; }
- env(safe-area-inset-*) —— 兼容 iPhone 刘海
iPhone X 之后底部有条黑线, 不处理的话会被遮:
.app {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
- 100dvh —— 解决浏览器底栏跳动
100vh 在 Safari 上会算上底栏高度, 用户来回滑会跳。
改用 100dvh (dynamic viewport height) 后跟随实际可视区:
.app { height: 100dvh; }
- 数字单元格高亮: ::before + z-index:3
需求: 选中的格子要整个高亮, 但数字必须清晰可见。
错误做法: 数字和高亮同层, 高亮会盖住数字。
正确做法: 高亮用 ::before 伪元素 + z-index:2, 数字本身 z-index:3, 永远不被遮:
.cell { position: relative; }
.cell::before {
content: '';
position: absolute;
inset: 0;
background: var(--highlight);
z-index: 2;
}
.cell .num {
position: relative;
z-index: 3;
}
- 大写 PAUSE 居中遮罩
需求: 暂停时屏幕中央一个大写 "PAUSE", 用户绝对不漏看。
.pause-overlay {
position: fixed;
inset: 0;
display: grid;
place-items: center;
font-size: 8rem;
font-weight: 900;
text-transform: uppercase;
color: var(--accent);
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
- GO 启动屏: 防误计时
需求: 用户点开游戏 → 看到盘面 → 不要立刻开始计时。
我加了个 GO 启动屏, 用户必须主动点 GO 才计时。这避免了
"我刚进游戏还没看清就被计时 5 秒了" 的常见痛点。
- addEventListener 在 Android 16 WebView 不可靠
这是这次踩的最大坑:
*// ❌ 经常丢点击*
**button.addEventListener('click', handler);
解决: 改用 HTML 内联 onclick, 浏览器解析 HTML 时最高优先级:
<button onclick="window.dxStartGame()">GO</button>
实测稳定 100% 触发, 不会再丢点击。
四、Android 16 手动出 APK 踩坑全记录
数独跑通后我想到: Android 用户也想装一个啊, 但 PWA 在 Android 上
"添加到主屏幕" 体验还是不如原生, 不如手动出 APK。
第一步: 准备环境
apt install -y default-jdk-headless
*# 安装 Android cmdline-tools + platform-34 + build-tools-34.0.0*
第二步: 7 步出 APK
*# 1. 编译资源*
aapt2 compile --dir res -o compiled.zip
*# 2. 链接资源 + 生成 base.apk*
aapt2 link -I android.jar --manifest AndroidManifest.xml \
-R compiled.zip --auto-add-overlay --java gen -o base.apk
*# 3. 编译 Java (注意: 必须 source/target 1.8, 不能 11)*
javac -source 1.8 -target 1.8 -bootclasspath android.jar \
-d classes MainActivity.java R.java
*# 4. 生成 dex*
d8 --output bin --lib android.jar classes/...
*# 5. 关键: 把 dex 复制到根目录*
Agent: main | Model: MiniMax-M3 | Provider: minimax
cp bin/classes.dex classes.dex
*#6. 把 dex + 资源加进 APK
aapt add base.apk classes.dex
aapt add base.apk assets/index.html
*#7. 对齐 + 签名
zipalign -f 4 base.apk aligned.apk
apksigner sign --ks debug.keystore
--ks-pass pass:android --key-pass pass:android
--ks-key-alias androiddebugkey
--out final.apk aligned.apk
踩坑 1: bin/classes.dex 路径
第一次我按 Google 官方教程, 用 d8 --output bin/ + aapt add base.apk bin/classes.dex。
结果 Android 16 装不上, 提示"无效或不兼容"。
排查 3 天, unzip -l base.apk 才发现 dex 路径是 bin/classes.dex,
Android 安装器只认根目录的 classes.dex。
修复: 加一行 cp bin/classes.dex classes.dex, 用根目录的 dex 加进去。
踩坑 2: 外部存储权限
第二版我想: 数独用 localStorage 存排行榜, 那是不是应该申请
READ_EXTERNAL_STORAGE / WRITE_EXTERNAL_STORAGE?
实测 Android 16 拒装, 提示"无效或不兼容"。
原因: Android 16 对外部存储权限 + requestLegacyExternalStorage="true"
严格拦截。
正确做法: WebView 应用的 localStorage 在应用沙箱内
(/data/data//app_webview/), Android 4.4+ 根本不需要任何
外部存储权限。隐私协议里写明"数据仅存于本机沙箱"即可。
踩坑 3: 排错流程
-
unzip -l xxx.apk 查看 APK 内 dex 路径, 必须是 classes.dex
-
aapt dump badging xxx.apk 查看权限声明, 不能有外部存储
-
拒装提示"无效或不兼容" → 先看 APK 内部结构, 不要瞎猜
五、清单
做这个项目大概花了两个月零碎时间, 跨过年假和几个周末。
最终 deliverable:
-
1 个 HTML 文件 (~2000 行, 70KB)
-
1 个 manifest.json (PWA 配置)
-
2 个图标 (192/512)
-
1 个 APK (66KB)
-
0 个外部依赖
GitHub: github.com/GZTai/one-t…
在线玩: gztai.github.io/one-tap-sud…
求个 star, 有 bug / 新功能需求提 issue, 我慢慢加。
你们做单文件 Web 应用都遇到过什么坑? 评论区聊聊。