今日开源[第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 + wranglercd 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.htmlphysStep(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,保证不同帧率下物理一致。
  • 发声核心变量 = 绳方向角速度 omegatheta = 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:交互相位(输入 → 锚点目标)

  • 指针画圈pointerdowndown 并捕获指针;触屏时锚点上移 lift(避免手挡);pointermove 更新 targetrelease 释放。多点触控互斥(已有手指在甩则忽略第二指)。
  • 自动甩setAutotargetauto.rpsAUTO_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 并广播在线变化。
  • 服务端 Counter Durable Object:所有在线玩家挂在同一 DO 的 WebSocket(Hibernation API),任何人哇,350ms 合并广播推给全场;计数内存自增、2s 合并落盘 SQLite。
  • 防刷:单连接 10s 滑动窗口限速、单条消息哇数上限(30)、并发连接上限(500)、按 IP 的每分钟连接/补报频控、单连接 hi 去重。

步骤 F:音频生命周期管理(省电 + 鲁棒)

  • 解锁:首次 touchend/click/keydownAC.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/vyropeLenropeDistomegaactivestickTilt),换算到世界单位后调用 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=30WAH_RATE_MAX=80MAX_SOCKETS=500IP_WINDOW_MS=60000IP_CONNECT_MAX=30IP_BEACON_MAX=10

7. worker/wrangler.jsonc(420B)— Worker 部署配置

name: zhuzhiliao-apimain: src/index.jscompatibility_date: 2026-07-15workers_dev: trueroutes: [{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: standaloneorientation: portraitbackground/theme_color: #0a1028icons: 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: monthlypriority: 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)。对应模块:工程配置


五、优势、不足、创新点与亮点

优势

  1. 真正零依赖、零构建、单文件:直接双击 index.htmlfile://)即可玩,断网可玩、长期可存档,符合「二十年后照样出声」的承诺;没有 npm/打包链路带来的脆弱性。
  2. 物理为唯一事实源:物理状态同时驱动声音、2D 绘制、3D 层,避免多套状态不同步;定步长 1/240s 积分保证不同帧率下行为一致。
  3. 发声还原度高:以真实竹知了录音采样为主音源(转速驱动回放速率 + 相位音高微摆),解码失败才回退到精心设计的 Web Audio 合成链(锯齿波→调幅→扫频带通→三共振峰),兜底后仍像玩具。
  4. 移动端优先做得扎实:安全区适配、绳长随屏缩放、触屏锚点上移、多点触控互斥、体感模式;音频解锁/挂起/僵尸重建等移动端坑都有专门处理(17 项审查修复)。
  5. 实时计数很省:WebSocket Hibernation + 350ms 合并广播 + 2s 合并落盘,挂机连接零费用;客户端批量上报 + sendBeacon 兜底 + 指数退避重连,免费套餐可跑。

不足 / 风险

  1. 单文件体积与可维护性:93KB 单文件把物理/声音/渲染/交互/计数全塞在一起,无模块化、无类型、无测试(CLAUDE.md 明确「没有构建、lint、测试」),后续多人维护与回归成本高。
  2. 计数后端强绑定 Cloudflare:实时统计依赖 Worker + Durable Object(SQLite),离线/自建部署需重写;且 wrangler deploy 需要 Cloudflare 账号,非平台无关。
  3. 3D 层 vendor 大文件three.module.min.js ~692KB 直接进仓库,使仓库体积主要由它贡献;虽按需动态 import 不影响 2D 启动,但不利于版本更新审计。
  4. 容错以「降级」为主:Web Audio 合成链、2D 回落、计数静默降级都是「退一步能用」,但单文件内缺乏监控/上报,真出问题时用户侧无可见诊断。
  5. 许可不明确:对外宣称开源,但 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 次)
posted @ 2026-08-04 17:53  zhang-yd  阅读(508)  评论(0)    收藏  举报