<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>赛博竹知了 · 儿时回忆</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
-webkit-tap-highlight-color: transparent;
}
body {
background-color: #f7f3eb;
color: #3e3228;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
min-height: 100vh;
overflow: hidden;
user-select: none;
touch-action: none;
}
/* 顶部标题区 */
.header {
text-align: center;
margin-top: 20px;
z-index: 10;
}
.header h1 {
font-size: 1.8rem;
letter-spacing: 2px;
color: #2c2219;
font-weight: 700;
}
.header p {
font-size: 0.85rem;
color: #8c7663;
margin-top: 4px;
}
/* 转速与状态仪表盘 */
.hud {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
background: rgba(255, 255, 255, 0.65);
backdrop-filter: blur(8px);
padding: 6px 18px;
border-radius: 30px;
border: 1px solid rgba(210, 195, 178, 0.5);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
z-index: 10;
}
.speed-val {
font-size: 1.4rem;
font-weight: bold;
color: #556b2f;
font-family: "Courier New", Courier, monospace;
min-width: 50px;
text-align: right;
}
.speed-unit {
font-size: 0.8rem;
color: #7a6858;
}
.btn-toggle {
background: #6b8e23;
color: #fff;
border: none;
padding: 4px 12px;
border-radius: 14px;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-toggle:active {
transform: scale(0.95);
opacity: 0.9;
}
.btn-toggle.active {
background: #a52a2a;
}
/* 画布交互区域 */
.canvas-container {
position: relative;
width: 100vw;
height: 55vh;
display: flex;
justify-content: center;
align-items: center;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
/* 音频开启覆盖提示 */
.audio-tip {
position: absolute;
top: 20px;
background: #d9534f;
color: white;
font-size: 0.8rem;
padding: 6px 14px;
border-radius: 20px;
pointer-events: none;
box-shadow: 0 4px 10px rgba(217, 83, 79, 0.3);
animation: pulse 1.5s infinite;
z-index: 15;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
/* 底部科普与情怀描述 */
.footer {
text-align: center;
margin-bottom: 25px;
padding: 0 20px;
max-width: 420px;
z-index: 10;
}
.principle-tag {
display: inline-block;
font-size: 0.75rem;
color: #8b5a2b;
background: #eae0d0;
padding: 3px 10px;
border-radius: 10px;
margin-bottom: 8px;
}
.desc {
font-size: 0.82rem;
line-height: 1.5;
color: #6e5d4f;
}
</style>
</head>
<body>
<div class="header">
<h1>赛博竹知了</h1>
<p>按住屏幕 · 画圈甩动 · 听听夏天的“哇哇”声</p>
<div class="hud">
<div>
<span class="speed-val" id="speedVal">0.0</span>
<span class="speed-unit">圈 / 秒</span>
</div>
<button class="btn-toggle" id="autoBtn">自动甩</button>
</div>
</div>
<div class="canvas-container">
<div class="audio-tip" id="audioTip">🔊 点击任意处开启声音</div>
<canvas id="stage"></canvas>
</div>
<div class="footer">
<div class="principle-tag">竹膜为鼓 · 松香为弦 · 摩擦生振 · 腔体共鸣</div>
<p class="desc">竹筒蒙膜,线系膜心涂松香。甩动时松香线产生“黏-滑”交替摩擦,振动经由竹膜放大并与竹筒共鸣,发出一声声清脆酷似蝉鸣的“哇——哇——”。</p>
</div>
<script>
// --- 全局变量与 DOM 获取 ---
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
const speedValEl = document.getElementById('speedVal');
const autoBtn = document.getElementById('autoBtn');
const audioTip = document.getElementById('audioTip');
let width, height, centerX, centerY;
let isInteracting = false;
let isAutoSpin = false;
// 运动物理参数
let pointerAngle = 0;
let prevPointerAngle = 0;
let currentRps = 0; // 圈/秒 (Rotations Per Second)
let smoothedRps = 0;
let autoAngle = 0;
// 竹知了形态参数
const stickRadius = 60; // 画圈轨迹半径
const stringLength = 110; // 麻绳长度
let drumPos = { x: 0, y: 0 };
let drumAngle = 0;
// --- 1. 高仿真音频合成引擎 (共鸣腔体物理模型) ---
class CicadaAudioEngine {
constructor() {
this.ctx = null;
this.isInitialized = false;
this.osc = null;
this.gainNode = null;
this.lfo = null;
this.filters = [];
}
init() {
if (this.isInitialized) return;
const AudioContext = window.AudioContext || window.webkitAudioContext;
this.ctx = new AudioContext();
// 主增益节点
this.gainNode = this.ctx.createGain();
this.gainNode.gain.value = 0;
// 锯齿波摩擦源 (模拟松香摩擦黏滑效应)
this.osc = this.ctx.createOscillator();
this.osc.type = 'sawtooth';
this.osc.frequency.value = 130;
// LFO (低频调幅,模拟蝉鸣的“哇-哇-哇”声音颗粒颤音)
this.lfo = this.ctx.createOscillator();
this.lfo.type = 'sine';
this.lfo.frequency.value = 8; // 8Hz 震荡
const lfoGain = this.ctx.createGain();
lfoGain.gain.value = 0.25;
this.lfo.connect(lfoGain.gain);
// 三组带通滤波器 (Formant Filters: 模拟竹筒与竹膜腔体特有的共振峰)
const frequencies = [420, 950, 1850]; // 竹筒共振频率点
const qValues = [3.5, 4.0, 5.0];
let lastNode = this.osc;
frequencies.forEach((freq, idx) => {
const filter = this.ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = freq;
filter.Q.value = qValues[idx];
lastNode.connect(filter);
this.filters.push(filter);
});
// 连接到主输出
this.filters[this.filters.length - 1].connect(this.gainNode);
this.gainNode.connect(this.ctx.destination);
this.osc.start();
this.lfo.start();
this.isInitialized = true;
audioTip.style.display = 'none';
}
update(rps) {
if (!this.isInitialized || !this.ctx) return;
if (this.ctx.state === 'suspended') {
this.ctx.resume();
}
if (rps > 0.1) {
// 随着转速提升,音高增加 (基频 120Hz - 380Hz)
const baseFreq = 120 + Math.min(rps, 6) * 45;
// 音量随转速增大
const volume = Math.min((rps - 0.1) * 0.28, 0.5);
// LFO 调幅频率随转速加快
const lfoFreq = 6 + rps * 3;
this.osc.frequency.setTargetAtTime(baseFreq, this.ctx.currentTime, 0.03);
this.lfo.frequency.setTargetAtTime(lfoFreq, this.ctx.currentTime, 0.03);
this.gainNode.gain.setTargetAtTime(volume, this.ctx.currentTime, 0.03);
// 动态微调竹筒共振峰
this.filters[0].frequency.setTargetAtTime(420 + rps * 20, this.ctx.currentTime, 0.03);
} else {
// 停止旋转,迅速切断声音
this.gainNode.gain.setTargetAtTime(0, this.ctx.currentTime, 0.05);
}
}
}
const audioEngine = new CicadaAudioEngine();
// --- 2. 屏幕适配与 Canvas 初始化 ---
function resizeCanvas() {
const rect = canvas.parentElement.getBoundingClientRect();
width = rect.width;
height = rect.height;
canvas.width = width * window.devicePixelRatio;
canvas.height = height * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
centerX = width / 2;
centerY = height / 2;
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
// --- 3. 交互事件处理 ---
function getAngle(x, y) {
return Math.atan2(y - centerY, x - centerX);
}
function handlePointerStart(x, y) {
audioEngine.init();
if (isAutoSpin) toggleAutoSpin(false);
isInteracting = true;
pointerAngle = getAngle(x, y);
prevPointerAngle = pointerAngle;
}
function handlePointerMove(x, y) {
if (!isInteracting) return;
const newAngle = getAngle(x, y);
let diff = newAngle - prevPointerAngle;
// 处理角度从 -π 到 π 的跨越
if (diff > Math.PI) diff -= Math.PI * 2;
if (diff < -Math.PI) diff += Math.PI * 2;
// 计算瞬时转速 (圈/秒)
const instantRps = Math.abs(diff) / (2 * Math.PI) * 60; // 假设 60fps
currentRps = instantRps;
pointerAngle = newAngle;
prevPointerAngle = newAngle;
}
function handlePointerEnd() {
isInteracting = false;
currentRps = 0;
}
// 绑定鼠标与触屏事件
canvas.addEventListener('mousedown', (e) => handlePointerStart(e.clientX, e.clientY));
window.addEventListener('mousemove', (e) => handlePointerMove(e.clientX, e.clientY));
window.addEventListener('mouseup', handlePointerEnd);
canvas.addEventListener('touchstart', (e) => {
if (e.touches.length > 0) handlePointerStart(e.touches[0].clientX, e.touches[0].clientY);
});
window.addEventListener('touchmove', (e) => {
if (e.touches.length > 0) handlePointerMove(e.touches[0].clientX, e.touches[0].clientY);
});
window.addEventListener('touchend', handlePointerEnd);
// 点击页面解锁音频
document.body.addEventListener('click', () => audioEngine.init(), { once: true });
// 自动甩开关
function toggleAutoSpin(forceState) {
isAutoSpin = forceState !== undefined ? forceState : !isAutoSpin;
if (isAutoSpin) {
audioEngine.init();
autoBtn.classList.add('active');
autoBtn.innerText = '停止甩';
} else {
autoBtn.classList.remove('active');
autoBtn.innerText = '自动甩';
currentRps = 0;
}
}
autoBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleAutoSpin();
});
// --- 4. Canvas 画面绘制 (竹筒、麻绳与竹签形态) ---
function drawBambooDrum(ctx, x, y, angle) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
const w = 46;
const h = 76;
// 1. 竹筒主体渐变
const drumGrad = ctx.createLinearGradient(-w / 2, 0, w / 2, 0);
drumGrad.addColorStop(0, '#3f5228');
drumGrad.addColorStop(0.2, '#6b8e3d');
drumGrad.addColorStop(0.5, '#a3c86d');
drumGrad.addColorStop(0.8, '#6b8e3d');
drumGrad.addColorStop(1, '#334320');
ctx.fillStyle = drumGrad;
ctx.beginPath();
ctx.roundRect(-w / 2, -h / 2, w, h, 6);
ctx.fill();
// 竹筒高光与阴影
ctx.strokeStyle = 'rgba(0,0,0,0.15)';
ctx.lineWidth = 1.5;
ctx.stroke();
// 2. 竹节 (上竹节凸起)
const nodeY = -h / 2 + 18;
ctx.fillStyle = 'rgba(45, 60, 28, 0.85)';
ctx.fillRect(-w / 2 - 2, nodeY, w + 4, 5);
ctx.fillStyle = 'rgba(180, 220, 130, 0.4)';
ctx.fillRect(-w / 2 - 2, nodeY + 5, w + 4, 2);
// 3. 竹膜包边 (底部牛皮纸/竹膜)
const skinH = 20;
const skinY = h / 2 - skinH;
const skinGrad = ctx.createLinearGradient(-w / 2, 0, w / 2, 0);
skinGrad.addColorStop(0, '#a67c42');
skinGrad.addColorStop(0.5, '#d4ab6a');
skinGrad.addColorStop(1, '#8c632d');
ctx.fillStyle = skinGrad;
ctx.beginPath();
ctx.roundRect(-w / 2, skinY, w, skinH, [0, 0, 5, 5]);
ctx.fill();
// 膜心松香结与固定线
ctx.fillStyle = '#4a2c11';
ctx.beginPath();
ctx.arc(0, skinY + skinH / 2, 3.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// --- 5. 主动画循环 (物理计算与音效同步) ---
function animate() {
ctx.clearRect(0, 0, width, height);
// 自动甩状态更新
if (isAutoSpin) {
autoAngle += 0.18;
pointerAngle = autoAngle;
currentRps = 2.8 + Math.sin(autoAngle * 0.5) * 0.5; // 模拟手甩波动
}
// 指数平滑平稳化转速 (EMA)
smoothedRps += (currentRps - smoothedRps) * 0.1;
if (smoothedRps < 0.05) smoothedRps = 0;
// 更新仪表盘文字
speedValEl.innerText = smoothedRps.toFixed(1);
// 音频引擎同步更新
audioEngine.update(smoothedRps);
// 计算中心手持点与运动位置
const stickX = centerX + Math.cos(pointerAngle) * stickRadius;
const stickY = centerY + Math.sin(pointerAngle) * stickRadius;
// 物理离心力与惯性计算:麻绳甩出方向
const centrifugalAngle = pointerAngle + Math.PI / 2 + (smoothedRps * 0.08);
// 计算竹筒位置
const targetDrumX = stickX + Math.cos(centrifugalAngle) * stringLength;
const targetDrumY = stickY + Math.sin(centrifugalAngle) * stringLength;
// 竹筒位置平滑跟随(体现绳子拉伸与重量感)
drumPos.x += (targetDrumX - drumPos.x) * 0.2;
drumPos.y += (targetDrumY - drumPos.y) * 0.2;
// 竹筒朝向角度
drumAngle = Math.atan2(drumPos.y - stickY, drumPos.x - stickX) + Math.PI / 2;
// --- 绘制旋转虚线轨迹圈 ---
ctx.save();
ctx.beginPath();
ctx.arc(centerX, centerY, stickRadius, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(160, 140, 120, 0.25)';
ctx.lineWidth = 2;
ctx.setLineDash([4, 6]);
ctx.stroke();
ctx.restore();
// --- 绘制中央手持竹签 ---
ctx.save();
ctx.translate(stickX, stickY);
ctx.rotate(pointerAngle);
const stickGrad = ctx.createLinearGradient(-4, 0, 4, 0);
stickGrad.addColorStop(0, '#c2a679');
stickGrad.addColorStop(1, '#8c7045');
ctx.fillStyle = stickGrad;
ctx.fillRect(-3, -25, 6, 50);
ctx.restore();
// --- 绘制涂松香的麻绳 (物理弯曲) ---
ctx.save();
ctx.beginPath();
ctx.moveTo(stickX, stickY);
// 贝塞尔曲线营造甩动中的松香线张力弧度
const controlX = (stickX + drumPos.x) / 2 + Math.cos(pointerAngle) * (smoothedRps * 5);
const controlY = (stickY + drumPos.y) / 2 + Math.sin(pointerAngle) * (smoothedRps * 5);
ctx.quadraticCurveTo(controlX, controlY, drumPos.x, drumPos.y);
ctx.strokeStyle = '#6e5843';
ctx.lineWidth = 2.2;
ctx.stroke();
ctx.restore();
// --- 绘制高仿真竹筒 ---
drawBambooDrum(ctx, drumPos.x, drumPos.y, drumAngle);
requestAnimationFrame(animate);
}
// 初始化启动
drumPos = { x: centerX + stickRadius + stringLength, y: centerY };
animate();
</script>
</body>
</html>