










仓库地址:https://github.com/imsai-sh/zhuzhiliao
在线试玩:https://zhuzhiliao.imsai.cc
解读时间:2026-08-04(基于 main 分支,HEAD = 567508d)
index.html,外加一个 Cloudflare Worker、一个可选 WebGL 3D 层与一组 SEO/PWA 静态资源。把一种几块钱的路边摊玩具搬进浏览器:竹筒一端蒙竹膜、膜心系一根涂松香的线,甩起来线在松香上「黏–滑」交替摩擦,脉冲传到竹膜与筒腔共鸣,发出那声「哇——哇——」。项目用 真实录音采样 + 绳系质点物理模拟,在网页上还原这个发声过程;支持三种玩法和实时全站计数。
单文件
index.html(Canvas 2D + Web Audio API,零依赖) 承载全部玩法与物理;可选动态 import 的 three.js 3D 层只在支持时叠加;Cloudflare Worker + SQLite Durable Object 承载全站实时计数。移动端优先,PWA 可「装进桌面」。
| 项目 | 要求 |
|---|---|
| 浏览器 | 支持 JavaScript、HTML5 Canvas 2D、Web Audio API 的现代浏览器(Chrome/Edge/Safari/Firefox;Safari 16+ 才有原生 roundRect,旧内核已做 polyfill 兜底) |
| 设备 | 桌面 / 手机均可;移动端优先(安全区适配、绳长随屏缩放、触屏锚点上移、多点触控互斥) |
| 体感模式(甩手机) | 必须 HTTPS 或 file:// 安全上下文(普通 http 局域网下浏览器不派发 devicemotion,按钮自动隐藏);iOS 首次需用户授权动作传感器 |
| PWA 安装 | 需 secure context 且非 file://(sw.js 注册会被跳过),用于安卓 Chrome 安装提示与离线可玩 |
| 联网 | 可选:不联网也能本地玩;联网仅用于全站实时计数(失败则静默降级,个人哇数存 localStorage) |
| 音频 | 需用户首次触摸/点击/按键激活(浏览器 user activation 规则),提供「点一下开声」兜底药丸 |
直接用浏览器打开 index.html 即可(支持 file:// 直开),无需任何构建或依赖。局域网用手机试玩:
# 在项目根目录起静态服务,手机连同一 Wi-Fi 访问 http://<电脑IP>:8123
python3 -m http.server 8123
# 如需手机「甩手机」体感模式,需 HTTPS(证书在 .claude/tls/,不进 git):
python3 .claude/tls/serve-https.py # 默认 8443
| 项目 | 要求 |
|---|---|
| 账号 | Cloudflare 账号(免费套餐足够,依赖 Hibernation API 与 SQLite Durable Object 免费额度) |
| 运行时 | Node.js + wrangler(cd worker && npx wrangler deploy) |
| 静态托管 | Cloudflare Pages 部署页面本体,zhuzhiliao.imsai.cc/api/* 由 zone route 进 Worker |
开发约束(见 CLAUDE.md):禁止引入任何构建步骤或 npm 运行时依赖;新资源要么内嵌(base64)、要么 vendor 进仓库、要么走可静默失败的动态 import。禁止擅自 push 到远程;每个 worktree 用随机端口避免冲突,审核通过后才合并 main 并部署。
整个运行时是一个 requestAnimationFrame 主循环驱动「物理 → 声音 → 特效 → 绘制 → 交互/计数」的管线。下面按数据流拆解。
源文件 index.html 的 physStep(h) / update(dt)。
stick(锚点)、竹筒 tube(质点)。GRAV + 只拉不推的弹性绳(d > ROPE_LEN 时生效,Hooke 弹簧 + 径向阻尼 ROPE_D)+ 空气阻力 AIR_DRAG。while(acc > 1e-6){ s = min(h, acc); physStep(s); acc -= s; },固定 h = 1/240s,保证不同帧率下物理一致。omega:theta = atan2(tube - stick),omega 经低通滤波得到;rps = |omega|/TAU(圈/秒)。taut(绳张紧度)、drive = clamp((rps-1.1)/2.6,0,1)(低于 ~1.1 圈/秒或绳未张紧不发声)、active(声音驱动量 0~1,带上升快/衰减慢的非对称平滑)。源文件 ensureAudio() / startSampleVoice() / startSynthVoice() / updateAudio()。
ZZL_SAMPLE(录音甩速 RECORDED_RPS = 2.33 圈/秒)。解码后把尾部 50ms 等功率交叉淡化烘进头部,做成无缝循环 BufferSource。rate = clamp((rps/RECORDED_RPS)^0.7, 0.6, 1.5),再叠每圈相位的音高微摆(detune = 50*sin(theta+0.9)*active)。startSynthVoice):锯齿波振荡器(频率随转速 55~195Hz)+ tanh 软削波增谐波 → 24~45Hz 正弦调幅 + 带通摩擦噪声底 → 扫频带通(中心频率随转动相位变化)→ 三个并联带通共振峰(1050/2150/3350Hz,模拟膜腔共鸣)。master(Gain 0→总闸) → DynamicsCompressor(压限) → Analyser → destination。bgLayer/moonBambooLayer/fgLayer),每帧只 drawImage,避免全屏重绘。use3d ? toy3d.render(...) : drawToy(...)。2D 手绘蝉形(红箍/眼睛/竹翼/红珠),绳子松则垂、紧则直(二次贝塞尔垂度)。3d/boot3d.js + 3d/model.js):主站通过动态 import('./3d/boot3d.js') 在 2D 画布上叠一层透明 WebGL;物理/声音/计数全部留在主站,3D 层每帧只接收物理状态摆位姿(drivePose)。file:// 或 WebGL 不可用时 import/init 静默返回 null,主站自动回落 2D。pointerdown 置 down 并捕获指针;触屏时锚点上移 lift(避免手挡);pointermove 更新 target;release 释放。多点触控互斥(已有手指在甩则忽略第二指)。setAuto 让 target 按 auto.rps 在 AUTO_R 半径上匀速画圆。devicemotion 事件取 accelerationIncludingGravity,慢基线滤波得到随挥舞转动的分量,映射到 target。2.5s 收不到传感器事件则自动退出并提示。源文件主站 wsConnect() + worker/src/index.js。
pendingWah,每 1.2s 批量走 WebSocket 上报({t:'wah', n});页面关闭用 navigator.sendBeacon 兜底补报;断线指数退避重连(min(30000, 1000*2^retry)),重连发 v:0 不重复计访问。hi(带 localStorage 里的随机 uid 去重唯一访客),服务端立即回当前 stats 并广播在线变化。Counter Durable Object:所有在线玩家挂在同一 DO 的 WebSocket(Hibernation API),任何人哇,350ms 合并广播推给全场;计数内存自增、2s 合并落盘 SQLite。hi 去重。touchend/click/keydown 才 AC.resume()(user activation);iOS 的 interrupted 非标准状态一并恢复;触屏且未出声时显示「点一下开声」药丸,解锁后自动消失。AC.suspend()。AudioContext.state==='running' 但 currentTime 停走(输出管线被系统回收)。心跳检测停走 1.5s 即 close 重建,采样声部自动重载;非 running 期间宽限期持续顺延,rAF 大间隙(>1s)也重置宽限期,避免误杀健康上下文;无手势自动重建 60s 限 3 次防拆建循环。index.html(~1521 行 / 93KB)— 主体 / 全部玩法单文件包含全部 HTML/CSS/JS,按注释分节:物理 → 声音 → 视觉特效粒子 → 绘制 → 主循环 & 交互 → 计数。对应模块:物理仿真、声音引擎、2D 渲染、交互、实时计数客户端、PWA/SW 注册、调试钩子 window.__zzl。
function physStep(h){
const dx = tube.x - stick.x, dy = tube.y - stick.y;
const d = Math.hypot(dx, dy) || 1e-6;
const ux = dx/d, uy = dy/d;
let ax = 0, ay = GRAV;
if(d > ROPE_LEN){ // 绳只拉不推
const vrad = tube.vx*ux + tube.vy*uy;
const f = -ROPE_K*(d - ROPE_LEN) - ROPE_D*vrad;
ax += f*ux; ay += f*uy;
}
ax -= AIR_DRAG*tube.vx; ay -= AIR_DRAG*tube.vy;
tube.vx += ax*h; tube.vy += ay*h;
tube.x += tube.vx*h; tube.y += tube.vy*h;
}
theta = Math.atan2(tube.y - stick.y, tube.x - stick.x);
let dth = theta - prevTheta;
while(dth > Math.PI) dth -= TAU;
while(dth < -Math.PI) dth += TAU;
omega += (dth/dt - omega)*Math.min(1, dt*9);
// ...
if(!auto.on && active > 0.3){ // 自动甩不计哇
revAccum += Math.abs(dth);
if(revAccum >= TAU){ const n = Math.floor(revAccum/TAU); revAccum -= n*TAU; addWah(n); }
}
<canvas id="cv">、按钮 #autoBtn/#motionBtn/#installBtn、解锁药丸 #unlock、统计区 #stats(含 #stOnline/#stVisitors/#stVisits/#stWahs/#stMine/#stMineWrap)。<head> 含 canonical/OG/Twitter 卡片、application/ld+json(WebSite + WebApplication/VideoGame + Person 组合),<noscript> 内置静态玩具介绍供不执行 JS 的百度蜘蛛读取;内嵌 base64 favicon 与竹知了图标。3d/boot3d.js(5KB)— 可选 WebGL 渲染引导层主站通过 dynamic import('./3d/boot3d.js') 加载。导出 init(canvas) 返回 { resize, render, clear, dispose };WebGL 创建失败或 file:// 直开时返回 null,主站回落 2D。对应模块:3D 渲染层接入。
render(st) 接收主站物理量(stick/tube 像素坐标 + vx/vy、ropeLen、ropeDist、omega、active、stickTilt),换算到世界单位后调用 model.userData.drivePose(...)。3d/model.js(16KB)— 程序化 3D 蝉模型纯代码程序化 Three.js 重建,无任何外部网格/贴图资源(竹纹/粗糙度贴图用 Canvas 2D 运行时生成)。对应模块:3D 模型与动画。
createZhuzhiliaoModel() 用 addMesh/addGroup 层级挂载;松香线每帧按两端插孔世界坐标重建 TubeGeometry(垂度由主站传 sag)。root.userData 暴露钩子:drivePose(pose,t)(外部物理接管摆位)、setSing(v)(发声强度→鼓膜 emissiveIntensity = v*0.85 透光)、setMode/tick/setExplode。3d/vendor/three.module.min.js(~692KB)— three.js(vendor 版)直接 vendor 进仓库,由 importmap 映射到 three(不走 npm)。对应模块:3D 渲染引擎依赖。
3d/vendor/OrbitControls.js(32KB)— 轨道控制器(vendor 版)用于 3D 层相机控制(同样 vendor 进仓库)。对应模块:3D 相机交互。
worker/src/index.js(7.4KB)— 计数后端(Cloudflare Worker + Durable Object)单文件承载全站计数与实时推送。对应模块:实时计数服务端。
export class Counter extends DurableObject:建 visitors/meta 两张 SQLite 表;visits/wahs/uniques 内存自增 + 2s 合并落盘(persist/persistNow)。setWebSocketAutoResponse(ping/pong):心跳由运行时自动应答,不唤醒 DO(挂机零费用)。scheduleBroadcast():350ms 合并广播帧 {t:'stats', online, visitors, visits, wahs}。/api/ws(WebSocket 升级,含 MAX_SOCKETS 限流 + IP 连接频控)、/api/stats(REST 取数)、/api/wah(sendBeacon 兜底补报,IP 频控 + 429)、其余 404。webSocketMessage:处理 hi(每连接只认一次,防刷访问数;v===1 才计访问;uid 去重唯一访客)、wah(单连接 10s 窗口限速,超 WAH_RATE_MAX=80 丢弃)。/api/* 进 env.COUNTER.getByName('global'),其余返回占位。WAH_BATCH_MAX=30、WAH_RATE_MAX=80、MAX_SOCKETS=500、IP_WINDOW_MS=60000、IP_CONNECT_MAX=30、IP_BEACON_MAX=10。worker/wrangler.jsonc(420B)— Worker 部署配置name: zhuzhiliao-api,main: src/index.js,compatibility_date: 2026-07-15,workers_dev: true;routes: [{pattern:"zhuzhiliao.imsai.cc/api/*", zone_name:"imsai.cc"}];durable_objects.bindings: [{name:"COUNTER", class_name:"Counter"}] + migrations.new_sqlite_classes:["Counter"];observability.enabled。对应模块:后端部署。
sw.js(1.8KB)— Service Worker(PWA 门槛 + 离线可玩)对应模块:PWA / 离线。策略:导航请求网络优先(部署即新版,断网回退缓存);静态资源 stale-while-revalidate(先出缓存秒开,后台自动更新,免手动版本号);/api/* 直连不缓存。缓存清单 CORE 包含本体与 3D 层资源。install 预缓存、activate 清旧缓存。
manifest.webmanifest(439B)— PWA 清单name/short_name: 竹知了,display: standalone,orientation: portrait,background/theme_color: #0a1028,icons: 192/512。让 Safari「分享→添加到主屏幕」与安卓 Chrome 原生安装以独立全屏 app 运行。对应模块:PWA。
404.html(1.2KB)— 真 404 页Cloudflare Pages 默认任意路径返回 200 + 首页(soft-404);有此文件后未知路径才返回真 404。noindex + 墨色主题 + 「回去甩竹知了」链接。对应模块:SEO。
robots.txt(89B)— 爬虫规则Allow: /、Disallow: /api/、Sitemap: .../sitemap.xml(屏蔽计数接口被抓取)。对应模块:SEO。
sitemap.xml(271B)— 站点地图声明首页 https://zhuzhiliao.imsai.cc/,changefreq: monthly,priority: 1.0。对应模块:SEO。
og-image.jpg(189KB)— 社交分享卡片图1200×630 页面实拍,供 OG/Twitter 分享卡片使用。对应模块:SEO/分享。
apple-touch-icon.png / icon-192.png / icon-512.png — PWA/书签图标供 iOS 主屏图标、Android/桌面 PWA 安装图标。对应模块:PWA。
CLAUDE.md(4.2KB)— 仓库协作指南给 AI 编程助手的项目约定:架构三块边界(index.html / 3d / worker)、禁止构建与 npm 依赖、移动端优先约束、3D 层接口不可变且失败必须静默回落 2D、常用命令(本地 http.server / HTTPS / wrangler deploy)、禁止擅自 push 的审核流程。对应模块:工程协作规范。
README.md(5.9KB)— 项目说明面向用户的玩法、发声原理、声音/物理/技术/实时计数说明,与 CLAUDE.md 互补。对应模块:文档。
.gitignore(246B)— 忽略规则忽略构建产物/依赖;补充 .claude/(本地 TLS 证书、AI 工作区不进 git)。对应模块:工程配置。
index.html(file://)即可玩,断网可玩、长期可存档,符合「二十年后照样出声」的承诺;没有 npm/打包链路带来的脆弱性。wrangler deploy 需要 Cloudflare 账号,非平台无关。three.module.min.js ~692KB 直接进仓库,使仓库体积主要由它贡献;虽按需动态 import 不影响 2D 启动,但不利于版本更新审计。file:// 直开 → 2D 手绘(无 3D)、无 WebGL → 2D、无音频解码 → 合成链、无联网 → 个人计数 localStorage,每一层失败都静默兜底,保证「开箱即响」。| 参数 | 值 | 含义 |
|---|---|---|
ROPE_LEN |
90~150px(随屏缩放) | 绳长 |
ROPE_K / ROPE_D |
2600 / 14 | 绳弹性 / 径向阻尼 |
GRAV / AIR_DRAG |
1150 / 0.35 | 重力 / 空气阻力 |
积分步长 h |
1/240s | 物理定步长 |
| 发声阈值 | ~1.1 圈/秒 且绳张紧 | 低于此不发声 |
RECORDED_RPS |
2.33 | 录音甩速,回放速率基准 |
| 共振峰 | 1050/2150/3350 Hz | 膜腔共鸣 |
WAH_BATCH_MAX/WAH_RATE_MAX |
30 / 80 | 单条/单连接 10s 哇数上限 |
MAX_SOCKETS |
500 | 并发连接上限 |
| 广播合并 / 落盘合并 | 350ms / 2s | DO 成本与实时感平衡 |
| 静置挂起 | 8s | 音频线程省电 |
| 僵尸判定 | currentTime 停走 1.5s | 触发重建(60s 限 3 次) |
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。