纯 HTML + CSS + JavaScript 的静态网页小游戏,零依赖、零构建、离线可玩。 物理引擎(PBD 位置约束求解)是自己写的,没有引入 matter.js 等任何第三方库。
https://yhsome.github.io/BigNaiWa/
(GitHub Pages 托管,手机浏览器打开就能玩,也可以「添加到主屏幕」当 App 用。)
- 鼠标:移动瞄准,点击即投放。
- 触屏:按住拖动瞄准,松手才投放——手指不会挡住落点,也方便微调。
- 棋盘右上角的「下一个」是下一颗;当前这颗画在准星位置(虚线顶端)。 投放后的冷却期间当前这颗会变淡显示(还不能投),这样两个位置始终各是各的,不会看混。
- 两个同级别的碰到一起就合成为高一级,并获得分数。
- 顶上虚线是警戒线:有水果卡在线的上方并且基本停住、累计超过 1.5 秒即判负 (被弹起来飞过线的不算,详见下面的「失败规则」)。
- 结束后自动上榜(进不了前 20 就不提交);没填昵称就记作「默认用户」, 昵称随时可以在排行榜弹窗里改。
←→微调位置,Space/Enter投放,R重新开始。 (结束后空格/回车不再重开,避免手快连开新局。)
合成链(11 级):
葡萄 → 樱桃 → 橘子 → 柠檬 → 猕猴桃 → 番茄 → 桃子 → 菠萝 → 椰子 → 半奶蛙 → 神奶蛙
合成得分采用三角数:1 / 3 / 6 / 10 / 15 / 21 / 28 / 36 / 45 / 55, 两只神奶蛙撞在一起会一起炸掉,额外加 500 分、并送一枚复活币(后期唯一的泄压阀:两只神奶蛙占满棋盘,不炸掉就没法继续玩)。
唯一判负条件:有水果卡在警戒线上方、且基本停住,累计超过 1.5 秒。
const DANGER_Y = 142; // 警戒线
const OVER_LIMIT = 1.5; // 累计秒数
const REST_SPEED = 140; // 低于这个速度才算“卡住了”(px/s)
for (const b of balls) {
if (b.dead || !b.landed) continue; // 还没落地的不算
if (b.y - b.r < DANGER_Y) { // 这只在线上面
if (b.vx*b.vx + b.vy*b.vy < REST_SPEED2) {
b.overTime += dt; // 停住的才计时
if (b.overTime > OVER_LIMIT) gameOver();
} else {
b.overTime -= dt * 2; // 正在飞 → 倒扣
}
} else {
b.overTime -= dt * 2; // 回到线下方 → 倒扣(2 倍速)
}
}几个容易被误解的点:
| 疑问 | 答案 |
|---|---|
| 好几只轮流在线上方,时间会加在一起吗? | 不会。 overTime 是每只各自的变量,必须同一只累计满 1.5 秒 |
| 被弹飞、路过线上的算吗? | 不算。 速度 ≥ 140px/s 时不计时,而且还在倒扣 |
| 贴着线上下反复弹会攒起来吗? | 得看比例:线下方按 2 倍速扣,净变化率 = 3f − 2(f = 在线上方的时间占比),只有 f > 2/3 才攒得起来 |
| 会不会永远输不了? | 不会。实测灌满棋盘第 161 次投放判负,那时堆顶 y=45、线上方 4 只里 3 只是静止的 |
| 除了这个还有别的失败条件吗? | 没有。不限步数、不限数量、不限时间 |
想调:DANGER_Y 调高低、OVER_LIMIT 调容忍时间、REST_SPEED 调“多慢才算卡住”。
node physics.test.js 的第 7 组会把这三条规则各验一遍。
线上直接开 https://yhsome.github.io/BigNaiWa/;
本地双击 index.html 即可(file:// 协议下也能跑,排行榜同样可用)。
也可以起个静态服务:
python -m http.server 8080
# 打开 http://localhost:8080部署:仓库打开 Settings → Pages → Source = Deploy from a branch → main / (root) 即可,
根目录已经放了 .nojekyll,静态文件原样发布。
| 文件 | 说明 |
|---|---|
index.html |
页面结构:棋盘、结束遮罩、侧边面板、排行榜弹窗 |
style.css |
全部样式:玻璃拟态面板、响应式布局、结束动画、排行榜 |
game.js |
游戏逻辑 + 自研物理 + Canvas 渲染 + WebAudio 音效 |
leaderboard.min.js |
在线排行榜的构建产物(TinyWebDB 接口 + 弹窗渲染),页面直接引用它 |
sponsor.js |
结算页「赞助作者」弹窗(展示微信收款码),纯静态、无网络请求 |
assets/fruits/ |
水果贴图:*.png 是 512×512 的源图,页面实际加载的是 *.webp;另有 parts.js 碰撞形状、blur.js 极模糊占位图 |
tools/normalize_assets.py |
素材统一脚本:抠底、去噪、统一画布、烤暗边 |
tools/optimize_sprites.py |
把源图压成 WebP 并裁到每级实际需要的尺寸(1.45 MB → 0.19 MB) |
tools/make_blur.py |
生成极模糊占位图 blur.js(11 张缩略图拼成一条、内联成 data URL,约 8 KB) |
tools/build_parts.py |
按贴图轮廓生成碰撞形状,产出 assets/fruits/parts.js |
src/ |
原始素材(11 张,格式/尺寸/底色都不统一),只作为脚本输入 |
physics.test.js |
物理手感自检脚本(node physics.test.js) |
gameplay.test.js |
复活 / 清场玩法自检(node gameplay.test.js) |
preview.png |
预览图 |
用的是 TinyWebDB(https://tinywebdb.appinventor.space/api)——一个给 App Inventor 用的
极简 key-value 云存储,靠 POST 表单调用,跨域头是 Access-Control-Allow-Origin: *,
所以纯静态页也能直接读写,不需要自建后端。
| 项 | 值 |
|---|---|
| 接口 | POST https://tinywebdb.appinventor.space/api |
| 账号 | user / secret 以编码形式存在,运行时还原 |
| 用到的 action | update(写)、search(按 tag 前缀读) |
| tag | dnw_<时间戳36进制>_<随机4位> |
| value | {"n":"昵称","s":分数,"t":时间戳} |
榜单内容 = 最近 20 次提交。服务端按提交时间倒序返回,所以只要拿最新两页
(no=1 和 no=101)就够覆盖这 20 条,再多请求也没意义;取回来之后在窗口内按分数排名展示。
这个设计的用意是:榜单是一条滚动的时间窗,不是历史最高分榜——分数只在自己还在窗口里的
那段时间有效,后来的人会不断把你挤出去,所以卡在榜首或者长期占位没有意义。
// 提交
POST user=<你的 user>&secret=<你的 secret>&action=update
&tag=dnw_mukuffr8_435q
&value={"n":"奶娃大王","s":4321,"t":1759000000000}
// 读榜(只要最新两页)
POST user=<你的 user>&secret=<你的 secret>&action=search&no=1&count=100&tag=dnw_&type=both
→ {"dnw_mukuffr8_435q":"{\"n\":\"奶娃大王\",\"s\":4321,\"t\":1759000000000}"}结算流程(打完自动上榜,不需要手动点提交):
- 游戏结束 → 直接提交,昵称取
localStorage里存的,没填就用「默认用户」; - 提交成功 → 重新读一次榜单,把窗口内的排名显示出来;
- 提交失败 → 显示错误并给一个「重试提交」按钮。
排行榜从头到尾只写自己那条记录,不读也不动别人的数据:没有任何 delete,
旧成绩是自然被挤出时间窗的,不是被清掉的。
昵称在排行榜弹窗顶部的「我的昵称」里随时能改(change / blur / 回车时落盘)。
实现上的几个小处理:
- 昵称存
localStorage(danaiwa.name),去掉控制字符、限长 12 字,空串一律当「默认用户」; - 防手抖:自动提交时校验「两次提交至少间隔 3 秒」,0 分不上榜, 分数明显离谱(> 99999999)的不收;
- 不抢按键:焦点在输入框里时,
game.js的键盘处理直接让路(不然打字会掉水果); - 容错:网络不通只是排行榜打不开,游戏照常玩;接口偶发 502 会自动重试一次; 返回不是 JSON 会给出可读的错误提示;
- XSS:榜单全部用
textContent渲染,不拼 HTML; - 榜单是最近 20 次提交(最新两页足够覆盖),弹窗底部有标注。
换账号:改排行榜源码顶部的 USER / SECRET / PREFIX 即可,改完跑一遍构建脚本产出
leaderboard.min.js(换 PREFIX 相当于另开一个榜)。
每个水果的碰撞箱按贴图轮廓来:tools/build_parts.py 读 assets/fruits/*.png 的 alpha,
用一组内接小圆铺满轮廓,写出 assets/fruits/parts.js:
window.SUIKA_PARTS = [ { rb: 1.083, parts: [[0.012,-0.31,0.42], ...] }, ... ];
// parts = [ox, oy, s],单位是「以 r 为 1」;rb = 包围圆半径(快速粗筛用)游戏里球的碰撞就是这些子圆:
| 环节 | 做法 |
|---|---|
| 粗筛 | 两个水果的包围圆 rb 不相交直接跳过 |
| 球球 | 子圆两两求交,取最深/最近的那一对产生法线与修正量,推力按逆质量分配到刚体中心 |
| 合成 | 任意一对子圆「贴上」(含 0.8px 容差)即合成 |
| 撞墙 | 每个子圆各自贴墙,取各方向最深穿透量一次性加到刚体中心上 |
| 旋转 | 子圆跟着刚体 angle 一起转,所以形状会随滚动改变落点 |
圆一律取内接(半径 = 到轮廓的欧氏距离,用 chamfer 距离变换算), 所以不会出现"看不见的碰撞";覆盖率不够的地方再贪心补圆,覆盖率到 97% 就提前收工。
实测贴合度(IoU,1.0 = 与图片完全一致):
tier 0 1 2 3 4 5 6 7 8 9 10
IoU .94 .96 .95 .92 .85 .90 .81 .96 .94 .96 .78 ← 平均 0.91
超出轮廓 ≤1.2%(几乎为零) 圆个数 9~16(形状简单自动用更少)
parts.js 缺失或某级没数据时,自动退回成单圆(半径 r),行为与旧版一致,不会白屏。
重新生成(换了贴图之后跑一次):
python tools/build_parts.py --max-parts 16 --preview
# --preview 会导出 _parts_preview.png:红=图片轮廓 绿=碰撞箱 黄=重合成本:1230 个水果在场时,整套物理每帧 **0.140.34 ms**(60fps 预算是 16.7ms)。
11 级各对应 assets/fruits/NN-<名字>.png 一张,棋盘、下一个预览、合成表、粒子全部复用同一张,
不需要做多倍图(不同尺寸由 canvas 缩放):
01-grape 02-cherry 03-orange 04-lemon 05-kiwi 06-tomato
07-peach 08-pineapple 09-coconut 10-halfmelon 11-watermelon
规格:正方形、透明底、主体居中占长边 92%,贴图会跟着水果一起滚动/旋转。
页面加载的是 .webp,.png 只是给工具用的源图。 源图统一是 512×512,但打包时会按每一级
在游戏里的实际显示尺寸裁到刚好(最小的葡萄只要 76×76),再用有损 WebP q88 压一遍:
1.45 MB → 0.19 MB。这一步不能省 —— 贴图一共才 11 张,原来在慢网下要好几秒才出图,
这几秒里玩家看到的是兜底的程序化水果(一堆卡通脸),反馈就是"图挂了"。
python tools/optimize_sprites.py # 默认 q88,会打印每一张的体积对比
python tools/optimize_sprites.py --lossless # 想完全无损就用这个(0.59 MB)换完贴图后注意两点:跑一次 optimize_sprites.py 生成新的 .webp;
碰撞形状是按源图 alpha 算的,所以还要跑一次 tools/build_parts.py。
另外 game.js 的 loadSprites() 对每张图失败会退避重试 3 次(弱网下一次拉不到很常见),
全部失败才回退,不影响玩。
回退顺序是三级,别让玩家看到"图挂了":
- 贴图到位 → 正常画
- 还没到 / 失败了 → 画
blur.js里那张极模糊的同形状缩略图(内联 data URL,零请求), 观感是"图正在慢慢变清晰" - 连
blur.js都没有(被拦了)→ 才画程序化的圆形水果(那堆卡通脸)
python tools/make_blur.py # 换了贴图之后跑一次,重新生成 blur.js换图的两种方式:
- 偷懒:直接把新图命名成上面的文件名丢进
assets/fruits/覆盖即可 (脚本已经统一过格式,直接可用)。 - 正规:把 11 张原始图按
src/1..11.*(jpg/png/webp 混着都行)放好,然后跑
python tools/normalize_assets.py脚本会:从四边向内区域生长抠底、丢掉零碎噪点、填掉主体内部小孔、
裁剪并按长边 92% 居中到统一画布、最后在主体底下烤一圈柔和暗边
(浅色角色在奶油色棋盘上才分得清,运行时零开销),并把每级的平均色打印出来
(可填进 FRUITS 的 pc1/pc2 作为粒子颜色)。
三道闸门可按图切换,tools/normalize_assets.py 顶部 CFG 里逐张配置:
| 闸门 | 作用 | 用在 |
|---|---|---|
sat_max |
饱和度高于它的像素永不算背景 | 浅底图(1~10) |
lum_min |
亮度高于它的像素永不算背景(深底反向) | 星空底(11) |
val_min |
最暗通道低于它的像素永不算背景 | 9 号「接近白但非纯白」的翅膀 |
9 号天使的白翅膀是 223241,背景是纯白 253255。只靠饱和度闸门时翅膀会被判成背景, 只剩一圈碎渣,缩放旋转后糊成一大块米色色块;加上
val_min=246后翅膀被完整保住。
缺图不影响游玩:任何一张加载失败都会自动回退成程序化绘制的圆形水果(FRUITS[i].c1/c2)。
- 物理:位置约束求解(PBD)。每帧 3 个子步 × 6 次迭代, 每个水果带一组按图片轮廓生成的子圆(见上一节), 速度由位置差反推 —— 堆叠稳定、不抖动,且能正确处理 124px 神奶蛙压在 17px 葡萄上的极端质量比。
- Q 弹回弹:位置约束会把法向速度吃掉,所以在每个子步末尾额外做一次弹性冲量,
把法向相对速度直接改写成
e × 碰撞前速度(球球e=0.38、墙e=0.45), 回弹量只由e决定、不受子步/迭代次数影响。 撞速低于REST_THRESHOLD时完全不弹,所以静止堆叠依然零抖动: 从顶部掉落的水果能弹起约 110px、连弹 5 次才停。 另外撞击时按法线做挤压变形(压扁 + 垂直拉伸,0.4 秒回弹),果冻感更强。 - 合成判定:接触容差 0.8px,同级别贴身即合,手感跟手; 合成只在每个子步的第 0 次迭代判定一次,避免重复合成。
- 渲染:整体按 devicePixelRatio 缩放。有贴图就画贴图(
box = 2r / 0.92, 保证视觉大小 = 物理直径,四边不会露馅),没贴图就画程序化水果。 贴图跟着b.angle一起旋转,和物理滚动一致;撞击时的挤压变形同样作用于贴图。 合成时有粒子爆裂、飘分文字与新品弹出动画。 - 素材加载:等
img.decode()完成才交给drawImage,避免画出没解码完的半成品。 - 音效:WebAudio 振荡器实时合成,无音频文件;可一键静音并记忆设置。
- 存档:最高分与静音状态存
localStorage。 - 调试:控制台可用
__SUIKA__.state、__SUIKA__.reset()、__SUIKA__.drop()、__SUIKA__.FRUITS、__SUIKA__.render()。
node physics.test.js用桩件模拟 DOM/Canvas,在 Node 里跑真物理(连 parts.js 一起加载,用的是真实碰撞形状),覆盖:
自由落体回弹高度、球对球弹起、12 秒堆叠稳定性(残余速度 / 漂移 / 穿墙 / NaN)、
静止后形状之间无穿透、自动投放 60 次不走样、
触屏「拖动瞄准 / 松手投放」与鼠标「按下即投」两套输入、
结束后空格/回车不再重开而 R 可以、输入框里不抢按键。
python tools/build_parts.py --max-parts 16 --preview # 换了贴图后重新生成碰撞形状
node physics.test.js # 物理自检| 项 | 做法 |
|---|---|
| 布局 | 窄屏(≤860px)下 .panel 用 order:-1 提到棋盘上方,横过来压成顶部信息条:左边分数、右边按钮,标题/合成表/提示收起 |
| 棋盘尺寸 | height: min(可用高度, 96vw × 700/420),宽度由 aspect-ratio 反推 —— 不拉伸、不溢出,任何机型都不出滚动条 |
| 视口高度 | 用 100dvh(带 100vh 回退),避开手机地址栏吃掉高度的问题 |
| 安全区 | padding 叠 env(safe-area-inset-*),刘海 / 小白条不会压住内容 |
| 触控 | 棋盘 touch-action:none;body 上 touch-action:manipulation + overscroll-behavior:none,干掉双击缩放和下拉刷新 |
| 按钮 | 最小 46px 高、带内边距,小屏(≤380px)再压一档;pointerdown 时 setPointerCapture,手指滑出棋盘也能收到 pointerup |
| 反馈 | 合成时按等级给 navigator.vibrate 轻震(跟随静音开关) |
| 性能 | canvas 内部分辨率按 devicePixelRatio 缩放但封顶 2×,高分屏不炸填充率 |
| 弹窗 | 结束弹窗按钮撑满宽度、加大触摸目标 |
横屏(orientation: landscape)自动切回左右布局:棋盘占满高度、信息条竖排在右侧。
| 常量 | 默认值 | 作用 |
|---|---|---|
GRAVITY |
2600 |
重力加速度 px/s² |
SUBSTEPS / ITER |
3 / 6 |
物理精度,调高更稳更费 CPU |
RESTITUTION |
0.38 |
球与球之间的弹性,调大更弹 |
WALL_RESTITUTION |
0.45 |
撞墙 / 撞地面的弹性 |
REST_THRESHOLD |
55 |
撞速低于此值不反弹(保堆叠稳定) |
FRICTION |
0.955 |
接触切向摩擦,调小更滑、调大更快停住 |
SQUASH_MAX / SQUASH_DECAY |
0.30 / 9 |
撞击挤压的最大变形与回弹速度 |
DROP_MS |
360 |
两次投放的最小间隔 |
OVER_LIMIT |
1.5 |
越线判负秒数 |
DANGER_Y |
142 |
警戒线高度 |
MERGE_PAD |
0.8 |
合成接触容差(子圆之间) |
OVER_LIMIT |
1.5 |
卡在警戒线上方多少秒判负 |
REST_SPEED |
140 |
低于这个速度才算“卡住了”,飞过去的不计时 |
AVOID_REPEAT |
true |
「下一个」避开与当前相同的等级(改成 false 恢复完全随机,会有 ~22% 概率两边同图) |
SPAWN_WEIGHTS |
[.28 .24 .20 .16 .12] |
前 5 级的掉落权重;开了 AVOID_REPEAT 后实测摊平为 25.6/23.2/20.6/17.2/13.4% |
ASSET_FILL |
0.92 |
贴图主体占画布比例,必须与生成脚本的 FILL 一致 |
FRUITS |
11 项 | 每级的半径 / 贴图路径 / 兜底配色,改这里即可换皮 |
FRUITS[i].c1/c2 |
11 组 | 贴图缺失时的程序化水果配色 |
FRUITS[i].pc1/pc2 |
11 组 | 粒子/汁水颜色(取自贴图主体平均色) |
本项目的玩法与水果链设计参考了以下开源实现(代码为本仓库原创,未复制其源码):
- Ikapricity/daxigua — 合成大西瓜未修改版本源码,可直接在浏览器运行
- CaptainAries/dxg
- moonfloof/suika-game — 使用 matter.js 的英文版克隆
- IceburgLettuce17/suika-game-js-beta
仅供学习娱乐使用。
