Created
August 31, 2025 13:33
-
-
Save kujirahand/e0ee0d2e7e5ad09ba26bd0c6102429c4 to your computer and use it in GitHub Desktop.
風鈴
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="ja"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>風鈴</title> | |
| <style> | |
| :root { | |
| --bg1: #e6f2ff; | |
| --bg2: #fdfbff; | |
| --ink: #0b2242; | |
| --accent: #2f80ed; | |
| --bell: #89a7c2; | |
| --paper: #f8f3e6; | |
| --paper-edge: #c9b79a; | |
| } | |
| html, body { | |
| height: 100%; | |
| margin: 0; | |
| } | |
| body { | |
| font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Noto Sans JP, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; | |
| color: var(--ink); | |
| background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2)); | |
| overflow: hidden; | |
| user-select: none; | |
| } | |
| .wrap { | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| display: grid; | |
| place-items: center; | |
| } | |
| .hud { | |
| position: absolute; | |
| top: 12px; | |
| left: 12px; | |
| background: rgba(255,255,255,0.7); | |
| border: 1px solid rgba(0,0,0,0.05); | |
| border-radius: 10px; | |
| padding: 10px 12px; | |
| box-shadow: 0 6px 20px rgba(0,0,0,0.08); | |
| backdrop-filter: blur(6px); | |
| -webkit-backdrop-filter: blur(6px); | |
| min-width: 240px; | |
| } | |
| .hud h1 { | |
| margin: 0 0 6px 0; | |
| font-size: 16px; | |
| font-weight: 700; | |
| letter-spacing: .02em; | |
| } | |
| .row { display: flex; align-items: center; gap: 8px; margin: 6px 0; } | |
| .row label { font-size: 12px; width: 72px; color: #334155; } | |
| .row input[type="range"] { flex: 1; } | |
| .btns { display: flex; gap: 8px; margin-top: 6px; flex-wrap: wrap; } | |
| button { | |
| border: 1px solid #cbd5e1; | |
| background: #fff; | |
| color: #0f172a; | |
| padding: 6px 10px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| } | |
| button:active { transform: translateY(1px); } | |
| .hint { | |
| margin-top: 6px; | |
| font-size: 12px; | |
| color: #475569; | |
| } | |
| .center { | |
| width: 100vw; | |
| height: 100vh; | |
| display: block; | |
| } | |
| .tap-overlay { | |
| position: absolute; inset: 0; display: grid; place-items: center; | |
| pointer-events: none; color: #0b2242; font-weight: 700; | |
| opacity: 0; transition: opacity .25s ease; | |
| } | |
| .tap-overlay.show { opacity: .5; } | |
| </style> | |
| <!-- | |
| 風鈴アプリ (単一HTML) | |
| - SVGで風鈴を描画 | |
| - 物理スイング(減衰+ランダムな風力) | |
| - WebAudioで金属的な澄んだ「チリン」音を合成 | |
| - クリック/タップ/スペースで風を送る/自動風 | |
| --> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <div class="hud" role="group" aria-label="風鈴コントロール"> | |
| <h1>風鈴</h1> | |
| <div class="row"> | |
| <label for="wind">風力</label> | |
| <input id="wind" type="range" min="0" max="100" value="40" /> | |
| </div> | |
| <div class="row"> | |
| <label for="volume">音量</label> | |
| <input id="volume" type="range" min="0" max="100" value="70" /> | |
| </div> | |
| <div class="btns"> | |
| <button id="gust">ふく</button> | |
| <button id="auto">自動: オフ</button> | |
| <button id="mute">ミュート: オフ</button> | |
| </div> | |
| <div class="hint">クリック/スペースで風を送る。Aで自動切替。</div> | |
| </div> | |
| <svg id="scene" class="center" viewBox="0 0 600 520" aria-label="風鈴のアニメーション" role="img"> | |
| <!-- 背景のソフトな円光 --> | |
| <defs> | |
| <radialGradient id="halo" cx="50%" cy="0%" r="80%"> | |
| <stop offset="0%" stop-color="#ffffff" stop-opacity="0.8" /> | |
| <stop offset="100%" stop-color="#ffffff" stop-opacity="0" /> | |
| </radialGradient> | |
| <linearGradient id="bellGrad" x1="0%" y1="0%" x2="0%" y2="100%"> | |
| <stop offset="0%" stop-color="#b3c5d6" /> | |
| <stop offset="100%" stop-color="#6e8aa6" /> | |
| </linearGradient> | |
| <linearGradient id="paperGrad" x1="0%" y1="0%" x2="0%" y2="100%"> | |
| <stop offset="0%" stop-color="#ffffff" /> | |
| <stop offset="100%" stop-color="#f1e9d7" /> | |
| </linearGradient> | |
| </defs> | |
| <circle cx="300" cy="80" r="160" fill="url(#halo)" /> | |
| <!-- 風の流線エフェクト --> | |
| <g id="windfx"></g> | |
| <!-- 風鈴プロトタイプ(原点=支点(0,0)) --> | |
| <g id="proto" aria-hidden="true" style="display:none"> | |
| <!-- つり糸 --> | |
| <line x1="0" y1="-50" x2="0" y2="0" stroke="#64748b" stroke-width="2" /> | |
| <!-- 上部の輪 --> | |
| <circle cx="0" cy="-40" r="6" fill="#94a3b8" stroke="#475569" stroke-width="1" /> | |
| <!-- 笠(ガラス) --> | |
| <path d="M-35,0 C-30,-26 30,-26 35,0 C36,6 -36,6 -35,0 Z" | |
| fill="url(#bellGrad)" stroke="#4b647d" stroke-width="1.5" /> | |
| <!-- 舌(玉) --> | |
| <circle cx="0" cy="18" r="6" fill="#5b6b7a" stroke="#2f3f4f" stroke-width="1" /> | |
| <!-- 短冊(紙): 紐部分 --> | |
| <line x1="0" y1="24" x2="0" y2="50" stroke="#747f8d" stroke-width="2" /> | |
| <!-- 短冊本体 --> | |
| <rect x="-13" y="50" width="26" height="130" rx="2" ry="2" | |
| fill="url(#paperGrad)" stroke="var(--paper-edge)" /> | |
| <!-- ワンポイント --> | |
| <circle cx="0" cy="75" r="6" fill="#e66464" fill-opacity="0.7" /> | |
| </g> | |
| </svg> | |
| <div id="overlay" class="tap-overlay show">クリック/タップで音を有効化</div> | |
| </div> | |
| <script> | |
| (() => { | |
| // 幾何パラメータ(pivotは(150, 60)) | |
| const LENGTH = 90; // 有効長(短冊含む重心近似) | |
| // 物理定数 | |
| const g = 9.81; | |
| const k = g / LENGTH; // 小角近似 | |
| const damping = 0.04; // 減衰係数 | |
| let theta = 0; // 角度[rad] | |
| let omega = 0; // 角速度 | |
| // 複数インスタンス用: 個別に保持 | |
| const instances = []; | |
| const windFX = []; | |
| const impactPeakThreshold = 0.045; // より鳴りやすく | |
| const impactCooldown = 60; // ms 短めに | |
| // 風(トルク) | |
| let windTorque = 0; // 現在の外力(全体に適用) | |
| let windTarget = 0; // 入力レバーから計算される最大トルク | |
| let autoWind = false; | |
| let autoTimer = 0; | |
| let ambientTimer = 0.8 + Math.random()*1.6; // ランダム微風の間隔 | |
| // センタリング(風後の整列) | |
| let windActiveCount = 0; // 現在の突風アクティブ数 | |
| let settleActive = false; | |
| let settleT = 0; | |
| const SETTLE_DELAY = 0.25; | |
| const SETTLE_DURATION = 1.1; | |
| // DOM取得 | |
| const scene = document.getElementById('scene'); | |
| const proto = document.getElementById('proto'); | |
| const windfxGroup = document.getElementById('windfx'); | |
| const windRange = document.getElementById('wind'); | |
| const volRange = document.getElementById('volume'); | |
| const btnGust = document.getElementById('gust'); | |
| const btnAuto = document.getElementById('auto'); | |
| const btnMute = document.getElementById('mute'); | |
| const overlay = document.getElementById('overlay'); | |
| const defs = scene.querySelector('defs'); | |
| // 画面サイズをviewBoxに反映(初期) | |
| function setSceneToScreen() { | |
| const W = Math.max(320, window.innerWidth || 600); | |
| const H = Math.max(320, window.innerHeight || 520); | |
| scene.setAttribute('viewBox', `0 0 ${W} ${H}`); | |
| } | |
| setSceneToScreen(); | |
| function getSceneSize() { | |
| const vb = scene.viewBox.baseVal; | |
| return { width: vb && vb.width ? vb.width : 600, height: vb && vb.height ? vb.height : 520 }; | |
| } | |
| // 彩色ユーティリティ | |
| function hslToHex(h, s, l){ | |
| h = ((h%360)+360)%360; s = Math.max(0, Math.min(100, s)); l = Math.max(0, Math.min(100, l)); | |
| const c = (1 - Math.abs(2*l/100 - 1)) * (s/100); | |
| const x = c * (1 - Math.abs(((h/60)%2) - 1)); | |
| const m = l/100 - c/2; | |
| let r=0,g=0,b=0; | |
| if (0<=h && h<60){ r=c; g=x; b=0; } | |
| else if (60<=h && h<120){ r=x; g=c; b=0; } | |
| else if (120<=h && h<180){ r=0; g=c; b=x; } | |
| else if (180<=h && h<240){ r=0; g=x; b=c; } | |
| else if (240<=h && h<300){ r=x; g=0; b=c; } | |
| else { r=c; g=0; b=x; } | |
| const toHex = v => { const n = Math.round((v+m)*255); return ('0'+n.toString(16)).slice(-2); }; | |
| return '#' + toHex(r) + toHex(g) + toHex(b); | |
| } | |
| function addLinearGradient(id, cTop, cBottom){ | |
| const ns = 'http://www.w3.org/2000/svg'; | |
| const g = document.createElementNS(ns, 'linearGradient'); | |
| g.setAttribute('id', id); | |
| g.setAttribute('x1','0%'); g.setAttribute('y1','0%'); g.setAttribute('x2','0%'); g.setAttribute('y2','100%'); | |
| const s1 = document.createElementNS(ns, 'stop'); s1.setAttribute('offset', '0%'); s1.setAttribute('stop-color', cTop); | |
| const s2 = document.createElementNS(ns, 'stop'); s2.setAttribute('offset', '100%'); s2.setAttribute('stop-color', cBottom); | |
| g.appendChild(s1); g.appendChild(s2); defs.appendChild(g); | |
| return `url(#${id})`; | |
| } | |
| // 入力初期 | |
| const windFromUI = () => Number(windRange.value) / 100; // 0..1 | |
| const volumeFromUI = () => Number(volRange.value) / 100; // 0..1 | |
| windTarget = 0.7 * windFromUI(); | |
| // オーディオ | |
| let audioReady = false; | |
| let audioCtx = null; | |
| let masterGain = null; | |
| let muted = false; | |
| // 風音ノード | |
| let windGain = null; | |
| let windSrc = null; | |
| let windHP = null, windLP = null; | |
| // リバーブ | |
| let reverb = null; | |
| let reverbGain = null; | |
| function ensureAudio() { | |
| if (audioReady) return Promise.resolve(); | |
| const Ctx = window.AudioContext || window.webkitAudioContext; | |
| audioCtx = new Ctx(); | |
| masterGain = audioCtx.createGain(); | |
| masterGain.gain.value = volumeFromUI(); | |
| masterGain.connect(audioCtx.destination); | |
| // リバーブ初期化(ホール風) | |
| reverb = audioCtx.createConvolver(); | |
| reverbGain = audioCtx.createGain(); | |
| reverbGain.gain.value = 0.42; // ウェット強め | |
| reverb.buffer = makeImpulseResponse(audioCtx, 4.2, 3.4, 0.028); | |
| reverb.connect(reverbGain).connect(masterGain); | |
| // 風音チェーン作成(ループノイズ → HPF → LPF → windGain → master) | |
| windGain = audioCtx.createGain(); | |
| windGain.gain.value = 0.0001; | |
| windHP = audioCtx.createBiquadFilter(); windHP.type = 'highpass'; windHP.frequency.value = 250; | |
| windLP = audioCtx.createBiquadFilter(); windLP.type = 'lowpass'; windLP.frequency.value = 2200; windLP.Q.value = 0.7; | |
| // ノイズバッファ(2秒)をループ | |
| const len = Math.floor(audioCtx.sampleRate * 2.0); | |
| const buf = audioCtx.createBuffer(1, len, audioCtx.sampleRate); | |
| const data = buf.getChannelData(0); | |
| for (let i = 0; i < len; i++) data[i] = (Math.random()*2 - 1); | |
| windSrc = audioCtx.createBufferSource(); | |
| windSrc.buffer = buf; windSrc.loop = true; | |
| windSrc.connect(windHP).connect(windLP).connect(windGain).connect(masterGain); | |
| windSrc.start(audioCtx.currentTime + 0.01); | |
| audioReady = true; | |
| overlay.classList.remove('show'); | |
| // 初回確認の小さなチリン音(resumeを待つ) | |
| const resumeIfNeeded = () => { | |
| if (audioCtx.state === 'suspended') { | |
| return audioCtx.resume().catch(()=>{}); | |
| } | |
| return Promise.resolve(); | |
| }; | |
| return resumeIfNeeded().then(() => { try { ding(0.6, 0); } catch(_) {} }); | |
| } | |
| // ホール風IR(プリディレイ+拡散・長い減衰) | |
| function makeImpulseResponse(ctx, duration = 4.0, decay = 3.2, preDelay = 0.025) { | |
| const rate = ctx.sampleRate; | |
| const len = Math.floor(rate * duration); | |
| const pre = Math.floor(rate * preDelay); | |
| const buf = ctx.createBuffer(2, len, rate); | |
| for (let ch = 0; ch < 2; ch++) { | |
| const data = buf.getChannelData(ch); | |
| // プリディレイ(無音) | |
| for (let i = 0; i < pre && i < len; i++) data[i] = 0; | |
| // 初期反射(複数タップを左右で少し変える) | |
| const taps = [0.00, 0.013, 0.021, 0.033, 0.047, 0.062].map(t => t + (ch?0.0015:-0.0015)); | |
| taps.forEach((t, i) => { | |
| const idx = Math.min(len-1, Math.floor((preDelay + t) * rate)); | |
| if (idx < len) data[idx] += 0.55 / (i+1); | |
| }); | |
| // テイル(指数減衰ホワイトノイズ)+ ゆるいローパス(空気減衰) | |
| let lp = 0; | |
| const a = 0.06; // ローパス係数 | |
| for (let i = pre; i < len; i++) { | |
| const t = (i - pre) / (len - pre); | |
| const e = Math.exp(-decay * t); | |
| const n = (Math.random()*2 - 1) * e * 0.55; | |
| lp = lp*(1-a) + n*a; | |
| data[i] += lp; | |
| } | |
| // 軽い拡散(一次遅延和) | |
| const dSmp = Math.floor(rate * (0.011 + ch*0.002)); | |
| for (let i = dSmp; i < len; i++) data[i] += data[i - dSmp] * 0.25; | |
| } | |
| return buf; | |
| } | |
| function setMuted(flag) { | |
| muted = flag; | |
| if (!audioReady) return; | |
| masterGain.gain.cancelScheduledValues(audioCtx.currentTime); | |
| masterGain.gain.setTargetAtTime(muted ? 0.0001 : volumeFromUI(), audioCtx.currentTime, 0.01); | |
| } | |
| function setVolumeFromUI() { | |
| if (!audioReady) return; | |
| if (muted) return; | |
| masterGain.gain.cancelScheduledValues(audioCtx.currentTime); | |
| masterGain.gain.setTargetAtTime(volumeFromUI(), audioCtx.currentTime, 0.02); | |
| } | |
| // 風音のエンベロープ | |
| function windWhoosh(strength = 0.6, dur = 0.25) { | |
| if (!audioReady || muted || !windGain) return; | |
| const now = audioCtx.currentTime; | |
| const max = Math.min(0.22, 0.02 + strength * 0.18 + windFromUI()*0.05); | |
| windGain.gain.cancelScheduledValues(now); | |
| windGain.gain.setTargetAtTime(max, now, 0.02); | |
| windGain.gain.setTargetAtTime(0.004, now + Math.max(0.05, dur*0.7), 0.25); | |
| const f = 2200 + strength * 2400; | |
| windLP.frequency.setTargetAtTime(Math.min(6000, f), now, 0.05); | |
| } | |
| // 風の流線パーティクル | |
| function spawnWindFX(power, dur, dir) { | |
| const { width: W, height: H } = getSceneSize(); | |
| const ns = 'http://www.w3.org/2000/svg'; | |
| const count = Math.min(60, Math.floor(12 + power*90)); | |
| for (let i = 0; i < count; i++) { | |
| const y = 30 + Math.random() * (H - 60); | |
| const x = dir < 0 ? (W + 20 + Math.random()*30) : (-20 - Math.random()*30); | |
| const len = 12 + Math.random()*Math.max(26, W*0.03); | |
| const ang = (dir < 0 ? Math.PI : 0) + (Math.random()-0.5)*0.35; | |
| const x2 = x + Math.cos(ang) * len; | |
| const y2 = y + Math.sin(ang) * len; | |
| const line = document.createElementNS(ns, 'line'); | |
| line.setAttribute('x1', x.toFixed(1)); | |
| line.setAttribute('y1', y.toFixed(1)); | |
| line.setAttribute('x2', x2.toFixed(1)); | |
| line.setAttribute('y2', y2.toFixed(1)); | |
| line.setAttribute('stroke', 'rgba(80,120,200,0.5)'); | |
| line.setAttribute('stroke-width', String(1 + Math.random()*1.5)); | |
| windfxGroup.appendChild(line); | |
| const speed = 140 + power*220 + Math.random()*80; | |
| windFX.push({ el: line, x, y, vx: dir*speed, vy: (Math.random()-0.5)*26, life: 0, lifeMax: 0.6 + Math.random()*0.7, baseOpacity: 0.25 + Math.random()*0.35 }); | |
| } | |
| } | |
| // 風を一吹き | |
| function gust(strength = 1) { | |
| // 0..1 のstrengthをトルクへマップ | |
| const amp = windTarget * (0.6 + 0.4 * Math.random()) * strength; | |
| const push = (Math.random() < 0.5 ? -1 : 1) * amp; | |
| const dir = Math.sign(push) || 1; | |
| // パルス的に 180-400ms ほどトルクを与える | |
| const dur = 0.18 + Math.random() * 0.22; | |
| const t0 = performance.now(); | |
| const base = windTorque; | |
| const id = Symbol('gust'); | |
| // 新規ガスト開始 | |
| settleActive = false; | |
| windActiveCount++; | |
| const tick = () => { | |
| const t = (performance.now() - t0) / 1000; | |
| if (t < dur) { | |
| // ハンニャン窓みたいに滑らかなエンベロープ | |
| const e = Math.sin((t / dur) * Math.PI); | |
| windTorque = base + push * e; | |
| requestAnimationFrame(tick); | |
| } else { | |
| windTorque = base; | |
| windActiveCount = Math.max(0, windActiveCount - 1); | |
| if (windActiveCount === 0) { settleActive = true; settleT = -SETTLE_DELAY; } | |
| } | |
| }; | |
| tick(); | |
| windWhoosh(Math.abs(push) * 1.3, dur); | |
| spawnWindFX(Math.abs(push), dur, dir); | |
| } | |
| // 自動風のスケジューラ | |
| function scheduleAutoWind(dt) { | |
| if (!autoWind) return; | |
| autoTimer -= dt; | |
| if (autoTimer <= 0) { | |
| gust(1); | |
| autoTimer = 0.5 + Math.random() * 2.6; // 次の突風まで | |
| } | |
| } | |
| // 常時の微風(自動オン/オフに関係なく、弱いガストを稀に発生) | |
| function scheduleAmbientWind(dt) { | |
| ambientTimer -= dt; | |
| if (ambientTimer <= 0) { | |
| const s = 0.2 + Math.random()*0.5; // ちいさめ | |
| gust(s); | |
| ambientTimer = 1.5 + Math.random()*3.5; | |
| } | |
| } | |
| // 風鈴の音 | |
| function createPannerNode(pan) { | |
| let node; | |
| if (audioCtx.createStereoPanner) { | |
| node = audioCtx.createStereoPanner(); | |
| node.pan.value = Math.max(-1, Math.min(1, pan || 0)); | |
| } else { | |
| // Safari等の非対応時はパンなし | |
| node = audioCtx.createGain(); | |
| } | |
| return node; | |
| } | |
| function ding(intensity, panHint, delaySec = 0) { | |
| if (!audioReady || muted) return; | |
| const now = audioCtx.currentTime + (delaySec || 0); | |
| const g = audioCtx.createGain(); | |
| const p = createPannerNode(panHint || 0); | |
| // ベース周波数(風鈴らしい明るい倍音) | |
| const base = 920 * (0.985 + Math.random()*0.03); // 明るめ | |
| // ガラス系のインハーモニックも少し混ぜる | |
| const partials = [1.00, 1.52, 2.00, 2.67, 3.02]; | |
| let duration = 2.1 + Math.random()*1.0; // 余韻をやや長く | |
| const peak = 0.42 * Math.min(1, 0.5 + intensity); | |
| if (intensity < 0.33) duration = 0.9 + Math.random()*0.5; // 細かいチリリンは短め | |
| // 音色生成:各倍音を短いFM/AMで金属的に | |
| partials.forEach((mul, i) => { | |
| const o = audioCtx.createOscillator(); | |
| const og = audioCtx.createGain(); | |
| const f0 = base * mul; | |
| o.type = i === 0 ? 'sine' : 'triangle'; | |
| o.frequency.setValueAtTime(f0, now); | |
| // 微小デチューン | |
| o.detune.setValueAtTime((i-1)*3 + (Math.random()-0.5)*5, now); | |
| // エンベロープ | |
| const a0 = peak * (i === 0 ? 1.0 : 0.6/(i)); | |
| og.gain.setValueAtTime(0.0001, now); | |
| og.gain.exponentialRampToValueAtTime(Math.max(0.0002, a0), now + 0.01); | |
| og.gain.exponentialRampToValueAtTime(0.0001, now + duration * (0.6 + i*0.05)); | |
| // わずかにピッチダウン | |
| o.frequency.exponentialRampToValueAtTime(f0*0.985, now + duration); | |
| o.connect(og).connect(g); | |
| o.start(now); | |
| o.stop(now + duration + 0.1); | |
| }); | |
| // 軽い共鳴フィルタ | |
| const bp = audioCtx.createBiquadFilter(); | |
| bp.type = 'bandpass'; | |
| bp.Q.value = 12; | |
| bp.frequency.value = base * 1.2; | |
| // チェーンを明示的に構築(ドライ+リバーブ) | |
| g.connect(bp); | |
| bp.connect(p); | |
| if (reverb) bp.connect(reverb); | |
| // クリック成分(カラン感): 短いノイズバーストを高域で共鳴 | |
| const noiseBuf = audioCtx.createBuffer(1, Math.ceil(audioCtx.sampleRate * 0.12), audioCtx.sampleRate); | |
| const ch = noiseBuf.getChannelData(0); | |
| for (let i = 0; i < ch.length; i++) ch[i] = (Math.random()*2 - 1) * Math.exp(-i/ch.length*6); | |
| const nsrc = audioCtx.createBufferSource(); | |
| nsrc.buffer = noiseBuf; | |
| const hp = audioCtx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 1600; | |
| const nbp = audioCtx.createBiquadFilter(); nbp.type = 'bandpass'; nbp.Q.value = 8; nbp.frequency.value = 2800; | |
| const ng = audioCtx.createGain(); | |
| ng.gain.setValueAtTime(0.0001, now); | |
| ng.gain.exponentialRampToValueAtTime(0.18 * Math.min(1, 0.6 + intensity), now + 0.006); | |
| ng.gain.exponentialRampToValueAtTime(0.0001, now + 0.09); | |
| nsrc.connect(hp).connect(nbp).connect(ng).connect(p); | |
| nsrc.start(now); | |
| p.connect(masterGain); | |
| } | |
| // 入力イベント | |
| function handlePrimaryInteraction(e) { | |
| ensureAudio(); | |
| gust(1); | |
| } | |
| document.addEventListener('pointerdown', (e) => { ensureAudio().then(() => handlePrimaryInteraction(e)); }); | |
| document.addEventListener('keydown', (e) => { | |
| if (e.code === 'Space') { ensureAudio().then(() => gust(1)); } | |
| if (e.key === 'a' || e.key === 'A') { toggleAuto(); } | |
| }); | |
| btnGust.addEventListener('click', () => { ensureAudio().then(() => gust(1)); }); | |
| windRange.addEventListener('input', () => { | |
| windTarget = 0.7 * windFromUI(); | |
| }); | |
| volRange.addEventListener('input', setVolumeFromUI); | |
| function toggleAuto() { | |
| autoWind = !autoWind; | |
| btnAuto.textContent = `自動: ${autoWind ? 'オン' : 'オフ'}`; | |
| if (autoWind) autoTimer = 0.1; | |
| } | |
| btnAuto.addEventListener('click', toggleAuto); | |
| btnMute.addEventListener('click', () => { | |
| const flag = !muted; setMuted(flag); | |
| btnMute.textContent = `ミュート: ${flag ? 'オン' : 'オフ'}`; | |
| }); | |
| // 風鈴インスタンス生成 | |
| let gradSeq = 0; | |
| function createInstance(px, py) { | |
| const ns = 'http://www.w3.org/2000/svg'; | |
| const outer = document.createElementNS(ns, 'g'); | |
| outer.setAttribute('class', 'chime'); | |
| outer.setAttribute('transform', `translate(${px},${py})`); | |
| const rotor = document.createElementNS(ns, 'g'); | |
| rotor.setAttribute('class', 'rotor'); | |
| rotor.setAttribute('transform', 'rotate(0)'); | |
| // プロトタイプの子を複製 | |
| Array.from(proto.childNodes).forEach(n => { | |
| if (n.nodeType === 1) rotor.appendChild(n.cloneNode(true)); | |
| }); | |
| // 個体ごとに彩色 | |
| try { | |
| const bell = rotor.querySelector('path'); | |
| const paper = rotor.querySelector('rect'); | |
| const circles = rotor.querySelectorAll('circle'); | |
| const accent = circles[circles.length-1]; | |
| const h = Math.random()*360; | |
| const bellTop = hslToHex(h, 40, 82); | |
| const bellBottom = hslToHex(h-8, 55, 58); | |
| const paperTop = hslToHex(h+20, 65, 96); | |
| const paperBottom = hslToHex(h+20, 65, 86); | |
| const strokeBell = hslToHex(h-10, 35, 35); | |
| const strokePaper = hslToHex(h+15, 30, 48); | |
| const accentFill = hslToHex(h+35, 80, 58); | |
| const idB = `bellGrad_${++gradSeq}`; | |
| const idP = `paperGrad_${gradSeq}`; | |
| bell.setAttribute('fill', addLinearGradient(idB, bellTop, bellBottom)); | |
| bell.setAttribute('stroke', strokeBell); | |
| paper.setAttribute('fill', addLinearGradient(idP, paperTop, paperBottom)); | |
| paper.setAttribute('stroke', strokePaper); | |
| if (accent) { | |
| accent.setAttribute('fill', accentFill); | |
| accent.setAttribute('fill-opacity', '0.9'); | |
| } | |
| } catch(_) {} | |
| outer.appendChild(rotor); | |
| scene.appendChild(outer); | |
| // 個体差パラメータ | |
| const length = 80 + Math.random()*30; // 80..110 | |
| const k_i = g / length; | |
| const damping_i = 0.03 + Math.random()*0.03; // 0.03..0.06 | |
| const coupling = 0.7 + Math.random()*0.6; // 0.7..1.3 | |
| const noiseAmp = 0.03 + Math.random()*0.06; // 微小風 | |
| const noiseFreq = 0.6 + Math.random()*1.4; // Hz相当 | |
| const tilt = (Math.random()-0.5) * 0.16; // +-0.08 rad | |
| const soundStep = 0.18 + Math.random()*0.14; // 角度変化距離あたりの発音間隔 | |
| return { | |
| outer, rotor, | |
| theta: 0, omega: 0, | |
| lastOmega: 0, lastImpactAt: 0, | |
| x: px, y: py, | |
| length, k_i, damping_i, coupling, | |
| noiseAmp, noiseFreq, noisePhase: Math.random()*Math.PI*2, | |
| tilt, | |
| soundAcc: 0, soundStep, lastDing: 0 | |
| }; | |
| } | |
| // ランダム配置(簡易Poisson風) | |
| function randomLayout(n, W, H) { | |
| const placed = []; | |
| const minDist = Math.min(140, Math.max(95, Math.min(W, H) * 0.14)); | |
| const marginX = 50, marginYTop = 60, marginYBottom = 200; // 下には短冊が伸びる | |
| let guard = 0; | |
| while (placed.length < n && guard < 2000) { | |
| guard++; | |
| const x = marginX + Math.random() * (W - marginX*2); | |
| const y = marginYTop + Math.random() * (H - marginYTop - marginYBottom); | |
| let ok = true; | |
| for (const p of placed) { | |
| const dx = p.x - x, dy = p.y - y; | |
| if (Math.hypot(dx, dy) < minDist) { ok = false; break; } | |
| } | |
| if (ok) placed.push({x, y}); | |
| } | |
| return placed; | |
| } | |
| const { width: W0, height: H0 } = getSceneSize(); | |
| const positions = randomLayout(10, W0, H0); | |
| positions.forEach(({x,y}) => { | |
| const inst = createInstance(x, y); | |
| inst.theta = (Math.random() - 0.5) * 0.05; | |
| instances.push(inst); | |
| }); | |
| // メインループ(物理&描画) | |
| let lastTime = performance.now(); | |
| function loop(now) { | |
| const dt = Math.min(0.033, (now - lastTime)/1000); | |
| lastTime = now; | |
| scheduleAutoWind(dt); | |
| scheduleAmbientWind(dt); | |
| // 各インスタンス更新 | |
| const { width: curW } = getSceneSize(); | |
| const halfW = curW / 2; | |
| for (const inst of instances) { | |
| // 個別パラメータ版: theta'' + c_i*theta' + k_i*sin(theta-tilt) = wind*coupling + noise | |
| inst.noisePhase += inst.noiseFreq * dt * 2*Math.PI; | |
| const breeze = inst.noiseAmp * Math.sin(inst.noisePhase); | |
| const acc = -inst.k_i * Math.sin(inst.theta - inst.tilt) - inst.damping_i * inst.omega + windTorque * inst.coupling + breeze; | |
| inst.omega += acc * dt; | |
| inst.theta += inst.omega * dt; | |
| inst.theta = Math.max(-0.8, Math.min(0.8, inst.theta)); | |
| // 風後のセンタリング(緩やかに0へ収束) | |
| if (settleActive && windActiveCount === 0) { | |
| settleT += dt; | |
| if (settleT >= 0) { | |
| const e = Math.min(1, settleT / SETTLE_DURATION); | |
| const kSettle = 0.9 * e; // 経過とともに強める | |
| inst.theta *= (1 - kSettle); | |
| inst.omega *= (1 - kSettle); | |
| } | |
| } | |
| // 角度変化に応じた連続チリン(角速度に比例) | |
| inst.soundAcc += Math.abs(inst.omega) * dt; | |
| if (inst.soundAcc >= inst.soundStep) { | |
| const nowMs = performance.now(); | |
| if (nowMs - inst.lastDing > 35) { | |
| const pan = Math.max(-1, Math.min(1, (inst.x - halfW) / halfW)); | |
| const intensity = Math.min(1.0, 0.25 + Math.abs(inst.omega) * 2.4 + Math.abs(inst.theta) * 0.4); | |
| ding(intensity, pan); | |
| inst.lastDing = nowMs; | |
| } | |
| inst.soundAcc -= inst.soundStep; | |
| } | |
| if (inst.lastOmega !== 0 && inst.omega * inst.lastOmega < 0) { | |
| const peakAngle = Math.abs(inst.theta); | |
| const nowMs = performance.now(); | |
| if (peakAngle > impactPeakThreshold && (nowMs - inst.lastImpactAt) > impactCooldown) { | |
| const strength = Math.min(1.2, 0.45 + Math.min(1, Math.abs(inst.lastOmega) * 3) + peakAngle); | |
| const pan = Math.max(-1, Math.min(1, (inst.x - halfW) / halfW)); | |
| // マルチヒット(ホール残響に合う多重カラン) | |
| ding(strength, pan); | |
| if (strength > 0.30) { | |
| const d2 = 0.045 + Math.random() * 0.05; | |
| ding(Math.max(0.22, strength * 0.60), pan, d2); | |
| } | |
| if (strength > 0.40) { | |
| const d3 = 0.11 + Math.random() * 0.06; | |
| ding(Math.max(0.18, strength * 0.48), pan, d3); | |
| } | |
| if (strength > 0.55) { | |
| const d4 = 0.19 + Math.random() * 0.10; | |
| ding(Math.max(0.15, strength * 0.38), pan, d4); | |
| } | |
| inst.lastImpactAt = nowMs; | |
| } | |
| } | |
| inst.lastOmega = inst.omega; | |
| const deg = (inst.theta * 180 / Math.PI).toFixed(3); | |
| inst.rotor.setAttribute('transform', `rotate(${deg})`); | |
| } | |
| // センタリング終了判定 | |
| if (settleActive && settleT >= SETTLE_DURATION) { | |
| settleActive = false; | |
| } | |
| // 風パーティクル更新 | |
| for (let i = windFX.length - 1; i >= 0; i--) { | |
| const p = windFX[i]; | |
| p.life += dt; | |
| p.x += p.vx * dt; | |
| p.y += p.vy * dt; | |
| const a = Math.max(0, 1 - p.life / p.lifeMax) * p.baseOpacity; | |
| p.el.setAttribute('x1', p.x.toFixed(1)); | |
| p.el.setAttribute('y1', p.y.toFixed(1)); | |
| p.el.setAttribute('x2', (p.x - p.vx*0.06).toFixed(1)); | |
| p.el.setAttribute('y2', (p.y - p.vy*0.06).toFixed(1)); | |
| p.el.setAttribute('stroke', `rgba(80,120,200,${a.toFixed(3)})`); | |
| if (p.life > p.lifeMax) { | |
| windfxGroup.removeChild(p.el); | |
| windFX.splice(i, 1); | |
| } | |
| } | |
| requestAnimationFrame(loop); | |
| } | |
| requestAnimationFrame(loop); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment