单文件也能拟真?
一个零依赖 HTML 玩具为何能冲上 GitHub Trending?本文拆解竹知了的移动端体感授权、Web Audio 解锁与无缝循环、稳定物理积分、自定义音频和离线计数,并给出可直接复用的浏览器端代码。
源仓库: imsai-sh/zhuzhiliao
竹知了把“一转就哇哇叫”的传统玩具搬进浏览器:Canvas 2D 绘制竹筒与绳摆,Web Audio 播放真实录音,触摸、自动甩和手机体感共同驱动转速。项目 8 月初出现后迅速以 2288 Star 登上 Trending,走红不只靠怀旧梗,更靠打开即玩、断网可玩、核心实现集中在单个 HTML。对开发者而言,它还是一份移动 Web 边界样本:权限、音频、物理与本地状态都挤在首屏交互中。
1. 为什么甩手机没反应?
Issue #7、#51 都要求体感玩法。HTTP 页面不显示入口、iPhone 点击后不动,通常不是传感器坏了:运动事件要求安全上下文,iOS 还要求在用户点击中授权。先使用 HTTPS,再在按钮回调申请权限:
button.onclick=async()=>{
if(!isSecureContext||!window.DeviceMotionEvent) return;
if(DeviceMotionEvent.requestPermission &&
await DeviceMotionEvent.requestPermission()!=='granted') return;
addEventListener('devicemotion',onMotion);
};
onMotion 读取 accelerationIncludingGravity;以 base += (x-base)*.02 建立慢基线,再用 x-base 驱动画圈,可滤掉静止重力。本地使用 localhost,开启数秒仍没有事件就退出并提示“不可用”。
2. 为什么转起来却没声音?
根因多是 autoplay 策略:非用户手势创建的 AudioContext 会停在 suspended,Android 的有效激活可能落在 touchend,iOS 回到前台也可能中断音频。初始化应绑定真实手势,并在页面恢复时重试:
let ac;
async function unlock(){
ac ||= new (window.AudioContext||window.webkitAudioContext)();
if(ac.state!=='running') await ac.resume();
}
['touchend','click'].forEach(t=>addEventListener(t,unlock,{capture:true}));
前台若 currentTime 连续 1.5 秒不前进,可限频重建;静置数秒则 suspend() 省电。项目还在录音解码失败时回退到振荡器合成链,使“有声”和“还原音色”成为两级保障。
3. 自动甩为何忽快忽慢?
Issue #26 的波动来自两套时钟:杆梢按动画帧画圈,竹筒却受重力、弹性绳和阻力影响;丢帧或刷新率切换会放大积分误差。不要把竹筒焊死在圆轨道上,应拆开渲染与物理步长:
const H=1/240; let acc=0;
function update(dt){
acc+=Math.min(dt,.05);
while(acc>=H){ physicsStep(H); acc-=H; }
}
自动转速应指数渐近目标值,并沿绳方向加入速度阻尼;绳只在拉伸时施力,不产生推力。这样 60/120Hz 下都稳定,又保留真实玩具的轻微摆动。
4. 如何导入自己的音效?
Issue #28、#46 都提出自定义语音包。无需上传服务器:文件选择是用户手势,可直接读取、解码并替换循环声源;在项目中把输出接到 master 压限链:
input.onchange=async e=>{
await unlock();
const b=await ac.decodeAudioData(await e.target.files[0].arrayBuffer());
source?.stop(); source=ac.createBufferSource();
source.buffer=b; source.loop=true;
source.connect(master); source.start();
};
接缝“啪”声来自首尾振幅不连续。将末尾约 50ms 与开头做等功率交叉淡化后再生成 Buffer;同时限制大小、捕获解码错误;文件只留内存,刷新即清除。
5. 断网后全球哇数为何不涨?
Issue #2 对应早期全站计数;当前版已在 PR #94 删除 Worker、WebSocket、遥测和全球值,只保留个人哇数。因此这不是缓存故障,而是语义变更。本机状态应容错保存:
let wah=+(localStorage.getItem('zzl_mywah')||0);
function addWah(){
try{ localStorage.setItem('zzl_mywah',String(++wah)); }catch{}
}
无痕模式不可写时退化为会话计数,不能把本地增量冒充全球数据。还要注意,仓库是“源码公开”而非 OSI 开源:许可允许学习、本地运行和修改,但不授权再分发或公开部署。