今日开源[第49期]竹知了(zhuzhiliao)小玩具项目解读
竹知了(zhuzhiliao)项目源码解读
仓库地址:https://github.com/imsai-sh/zhuzhiliao
在线试玩:https://zhuzhiliao.imsai.cc
解读时间:2026-08-04(基于 main 分支,HEAD = 567508d)
一、项目概览:名称、作者、作用与背景
1. 名称与作者
- 项目名称:竹知了(别名:竹蝉、拉线蝉、知了哇呜)—— 一种「一转就哇哇叫」的传统民间玩具的 Web 模拟版。
- 作者:GitHub 用户 imsai-sh(GitHub ID 14907300),个人主页 https://www.imsai.cc/ 。仓库经 Co-Authored-By 标注有大量由 Claude(Anthropic)辅助提交的痕迹,属于「人机协作」式开发。
- 仓库规模:约 20 次提交,主体是一份 ~1521 行 / 93KB 的单文件
index.html,外加一个 Cloudflare Worker、一个可选 WebGL 3D 层与一组 SEO/PWA 静态资源。 - 开源声明:README 与结构化数据均标注「代码开源」,但在本次抓取的 git 树中未见独立的 LICENSE 文件(部署/分发以「免费、无广告、无需下载」为对外承诺)。
2. 作用
把一种几块钱的路边摊玩具搬进浏览器:竹筒一端蒙竹膜、膜心系一根涂松香的线,甩起来线在松香上「黏–滑」交替摩擦,脉冲传到竹膜与筒腔共鸣,发出那声「哇——哇——」。项目用 真实录音采样 + 绳系质点物理模拟,在网页上还原这个发声过程;支持三种玩法和实时全站计数。
3. 背景与动机
- 实物稀缺:竹知了是会被大人没收的怀旧玩具,现在越来越难买到;作者希望「让它继续能被随手甩响」。
- 长期可玩:核心承诺是 零依赖、单文件、无构建 —— 一个 HTML 文件存下来,断网也能玩,二十年后双击照样出声。
- 技术样本:作者把「真实录音采样 + 绳系质点物理 + Durable Object 实时计数」全部塞进单文件,本身也作为一套工程做法的样本。
4. 技术定位(一句话)
单文件
index.html(Canvas 2D + Web Audio API,零依赖) 承载全部玩法与物理;可选动态 import 的 three.js 3D 层只在支持时叠加;Cloudflare Worker + SQLite Durable Object 承载全站实时计数。移动端优先,PWA 可「装进桌面」。
二、安装与使用教程、依赖的软件/硬件条件
1. 客户端依赖(运行玩法本体)
| 项目 | 要求 |
|---|---|
| 浏览器 | 支持 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 规则),提供「点一下开声」兜底药丸 |
2. 本地运行(零安装)
直接用浏览器打开 index.html 即可(支持 file:// 直开),无需任何构建或依赖。局域网用手机试玩:
# 在项目根目录起静态服务,手机连同一 Wi-Fi 访问 http://<电脑IP>:8123
python3 -m http.server 8123
# 如需手机「甩手机」体感模式,需 HTTPS(证书在 .claude/tls/,不进 git):
python3 .claude/tls/serve-https.py # 默认 8443
3. 计数后端依赖(仅部署时才需要)
| 项目 | 要求 |
|---|---|
| 账号 | 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 并部署。
4. 玩法教程
- 按住屏幕画圈:像甩真玩具一样,转得越快叫得越响(触屏时锚点自动抬到指尖上方,避免手挡住小蝉)。
- 自动甩:点「自动甩」按钮或按空格键,让程序替你画圈。
- 甩手机(手机端):握住手机划圈,重力方向在机身坐标里转动直接驱动甩杆;自动甩不计入「哇」数,只有手动甩的每一圈记一哇。
三、工作流程与具体执行步骤
整个运行时是一个 requestAnimationFrame 主循环驱动「物理 → 声音 → 特效 → 绘制 → 交互/计数」的管线。下面按数据流拆解。
步骤 A:物理仿真(唯一事实源)
源文件 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,带上升快/衰减慢的非对称平滑)。
步骤 B:声音(真实采样为主,合成兜底)
源文件 ensureAudio() / startSampleVoice() / startSynthVoice() / updateAudio()。
- 主音源:内嵌 base64 AAC 真实录音
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。
步骤 C:视觉渲染(Canvas 2D 为主,可选 3D)
- 静态场景预合成:背景天空、月与竹影、暗角颗粒分别预渲染到离屏 canvas(
bgLayer/moonBambooLayer/fgLayer),每帧只drawImage,避免全屏重绘。 - 动态层:星(闪烁)、流萤(按真实 dt 漂移发光)、声波涟漪、竹筒运动残影、飘出的「哇」字(暖橙辉光、淡出慢)。
- 玩具主体:
use3d ? toy3d.render(...) : drawToy(...)。2D 手绘蝉形(红箍/眼睛/竹翼/红珠),绳子松则垂、紧则直(二次贝塞尔垂度)。 - 3D 层(
3d/boot3d.js+3d/model.js):主站通过动态import('./3d/boot3d.js')在 2D 画布上叠一层透明 WebGL;物理/声音/计数全部留在主站,3D 层每帧只接收物理状态摆位姿(drivePose)。file://或 WebGL 不可用时 import/init 静默返回null,主站自动回落 2D。
步骤 D:交互相位(输入 → 锚点目标)
- 指针画圈:
pointerdown置down并捕获指针;触屏时锚点上移lift(避免手挡);pointermove更新target;release释放。多点触控互斥(已有手指在甩则忽略第二指)。 - 自动甩:
setAuto让target按auto.rps在AUTO_R半径上匀速画圆。 - 体感(甩手机):
devicemotion事件取accelerationIncludingGravity,慢基线滤波得到随挥舞转动的分量,映射到target。2.5s 收不到传感器事件则自动退出并提示。
步骤 E:实时计数(WebSocket + Durable Object)
源文件主站 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 并广播在线变化。 - 服务端
CounterDurable Object:所有在线玩家挂在同一 DO 的 WebSocket(Hibernation API),任何人哇,350ms 合并广播推给全场;计数内存自增、2s 合并落盘 SQLite。 - 防刷:单连接 10s 滑动窗口限速、单条消息哇数上限(30)、并发连接上限(500)、按 IP 的每分钟连接/补报频控、单连接
hi去重。
步骤 F:音频生命周期管理(省电 + 鲁棒)
- 解锁:首次
touchend/click/keydown才AC.resume()(user activation);iOS 的interrupted非标准状态一并恢复;触屏且未出声时显示「点一下开声」药丸,解锁后自动消失。 - 挂起省电:完全静置 8s 后
AC.suspend()。 - 僵尸上下文心跳重建:iOS 切后台再回来后
AudioContext.state==='running'但currentTime停走(输出管线被系统回收)。心跳检测停走 1.5s 即close重建,采样声部自动重载;非 running 期间宽限期持续顺延,rAF 大间隙(>1s)也重置宽限期,避免误杀健康上下文;无手势自动重建 60s 限 3 次防拆建循环。
四、文件逐个介绍(内容、作用、对应模块)
1. 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); }
}
- DOM 元素:
<canvas id="cv">、按钮#autoBtn/#motionBtn/#installBtn、解锁药丸#unlock、统计区#stats(含#stOnline/#stVisitors/#stVisits/#stWahs/#stMine/#stMineWrap)。 - SEO:
<head>含 canonical/OG/Twitter 卡片、application/ld+json(WebSite + WebApplication/VideoGame + Person 组合),<noscript>内置静态玩具介绍供不执行 JS 的百度蜘蛛读取;内嵌 base64 favicon 与竹知了图标。
2. 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(...)。 - 含翅膀铰链弹簧-阻尼小动力学:气流随速度吹开、绳向角加速度让左右翅一先一后甩尾(跟随晃动有滞后与过冲)、静置轻微呼吸、高速叠加高频振翅。
3. 3d/model.js(16KB)— 程序化 3D 蝉模型
纯代码程序化 Three.js 重建,无任何外部网格/贴图资源(竹纹/粗糙度贴图用 Canvas 2D 运行时生成)。对应模块:3D 模型与动画。
- 比例按实物三视图测量,筒身高为 1 单位(竹筒 r=0.334、红漆顶圈、翅膀 0.30×1.02、甩杆 x=+0.63 串红珠/琥珀珠/红珠、松香线系在顶球与琥珀珠间)。
- 工厂
createZhuzhiliaoModel()用addMesh/addGroup层级挂载;松香线每帧按两端插孔世界坐标重建TubeGeometry(垂度由主站传sag)。 - 通过
root.userData暴露钩子:drivePose(pose,t)(外部物理接管摆位)、setSing(v)(发声强度→鼓膜emissiveIntensity = v*0.85透光)、setMode/tick/setExplode。
4. 3d/vendor/three.module.min.js(~692KB)— three.js(vendor 版)
直接 vendor 进仓库,由 importmap 映射到 three(不走 npm)。对应模块:3D 渲染引擎依赖。
5. 3d/vendor/OrbitControls.js(32KB)— 轨道控制器(vendor 版)
用于 3D 层相机控制(同样 vendor 进仓库)。对应模块:3D 相机交互。
6. 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。
7. 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。对应模块:后端部署。
8. sw.js(1.8KB)— Service Worker(PWA 门槛 + 离线可玩)
对应模块:PWA / 离线。策略:导航请求网络优先(部署即新版,断网回退缓存);静态资源 stale-while-revalidate(先出缓存秒开,后台自动更新,免手动版本号);/api/* 直连不缓存。缓存清单 CORE 包含本体与 3D 层资源。install 预缓存、activate 清旧缓存。
9. manifest.webmanifest(439B)— PWA 清单
name/short_name: 竹知了,display: standalone,orientation: portrait,background/theme_color: #0a1028,icons: 192/512。让 Safari「分享→添加到主屏幕」与安卓 Chrome 原生安装以独立全屏 app 运行。对应模块:PWA。
10. 404.html(1.2KB)— 真 404 页
Cloudflare Pages 默认任意路径返回 200 + 首页(soft-404);有此文件后未知路径才返回真 404。noindex + 墨色主题 + 「回去甩竹知了」链接。对应模块:SEO。
11. robots.txt(89B)— 爬虫规则
Allow: /、Disallow: /api/、Sitemap: .../sitemap.xml(屏蔽计数接口被抓取)。对应模块:SEO。
12. sitemap.xml(271B)— 站点地图
声明首页 https://zhuzhiliao.imsai.cc/,changefreq: monthly,priority: 1.0。对应模块:SEO。
13. og-image.jpg(189KB)— 社交分享卡片图
1200×630 页面实拍,供 OG/Twitter 分享卡片使用。对应模块:SEO/分享。
14. apple-touch-icon.png / icon-192.png / icon-512.png — PWA/书签图标
供 iOS 主屏图标、Android/桌面 PWA 安装图标。对应模块:PWA。
15. CLAUDE.md(4.2KB)— 仓库协作指南
给 AI 编程助手的项目约定:架构三块边界(index.html / 3d / worker)、禁止构建与 npm 依赖、移动端优先约束、3D 层接口不可变且失败必须静默回落 2D、常用命令(本地 http.server / HTTPS / wrangler deploy)、禁止擅自 push 的审核流程。对应模块:工程协作规范。
16. README.md(5.9KB)— 项目说明
面向用户的玩法、发声原理、声音/物理/技术/实时计数说明,与 CLAUDE.md 互补。对应模块:文档。
17. .gitignore(246B)— 忽略规则
忽略构建产物/依赖;补充 .claude/(本地 TLS 证书、AI 工作区不进 git)。对应模块:工程配置。
五、优势、不足、创新点与亮点
优势
- 真正零依赖、零构建、单文件:直接双击
index.html(file://)即可玩,断网可玩、长期可存档,符合「二十年后照样出声」的承诺;没有 npm/打包链路带来的脆弱性。 - 物理为唯一事实源:物理状态同时驱动声音、2D 绘制、3D 层,避免多套状态不同步;定步长 1/240s 积分保证不同帧率下行为一致。
- 发声还原度高:以真实竹知了录音采样为主音源(转速驱动回放速率 + 相位音高微摆),解码失败才回退到精心设计的 Web Audio 合成链(锯齿波→调幅→扫频带通→三共振峰),兜底后仍像玩具。
- 移动端优先做得扎实:安全区适配、绳长随屏缩放、触屏锚点上移、多点触控互斥、体感模式;音频解锁/挂起/僵尸重建等移动端坑都有专门处理(17 项审查修复)。
- 实时计数很省:WebSocket Hibernation + 350ms 合并广播 + 2s 合并落盘,挂机连接零费用;客户端批量上报 + sendBeacon 兜底 + 指数退避重连,免费套餐可跑。
不足 / 风险
- 单文件体积与可维护性:93KB 单文件把物理/声音/渲染/交互/计数全塞在一起,无模块化、无类型、无测试(CLAUDE.md 明确「没有构建、lint、测试」),后续多人维护与回归成本高。
- 计数后端强绑定 Cloudflare:实时统计依赖 Worker + Durable Object(SQLite),离线/自建部署需重写;且
wrangler deploy需要 Cloudflare 账号,非平台无关。 - 3D 层 vendor 大文件:
three.module.min.js~692KB 直接进仓库,使仓库体积主要由它贡献;虽按需动态 import 不影响 2D 启动,但不利于版本更新审计。 - 容错以「降级」为主:Web Audio 合成链、2D 回落、计数静默降级都是「退一步能用」,但单文件内缺乏监控/上报,真出问题时用户侧无可见诊断。
- 许可不明确:对外宣称开源,但 git 树中未见 LICENSE 文件,二次分发的法律边界不清。
创新点与亮点
- 「真实录音 + 物理耦合」的发声模型:不是简单循环音效,而是把录音回放速率绑到实时绳方向角速度,让「转得快→叫得急且高」从物理状态自然涌现,而非脚本硬编码。
- 僵尸 AudioContext 心跳检测与重建:针对 iOS 切后台返回的「state=running 但 currentTime 停走」这一隐蔽 bug,用心跳 + 宽限期 + 限频重建解决,是移动端 Web Audio 工程的实用范本。
- 单连接 Durable Object 全局广播:用单个 DO 承载全站计数与实时推送,Hibernation API 让空闲连接零成本,350ms/2s 两级合并在「实时感」与「免费额度」间取得平衡。
- 优雅的能力降级链:
file://直开 → 2D 手绘(无 3D)、无 WebGL → 2D、无音频解码 → 合成链、无联网 → 个人计数 localStorage,每一层失败都静默兜底,保证「开箱即响」。 - 程序化 3D + 物理驱动位姿:3D 模型 100% 代码生成(含 Canvas 贴图),且翅膀用弹簧-阻尼小动力学跟随晃动(滞后/过冲/呼吸/高频振翅),让 3D 层只是物理状态的「显示器」,不引入额外状态源。
附:关键技术参数速查
| 参数 | 值 | 含义 |
|---|---|---|
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 次) |

浙公网安备 33010602011771号