Skip to content

Instantly share code, notes, and snippets.

@kujirahand
Created August 31, 2025 13:33
Show Gist options
  • Save kujirahand/e0ee0d2e7e5ad09ba26bd0c6102429c4 to your computer and use it in GitHub Desktop.
Save kujirahand/e0ee0d2e7e5ad09ba26bd0c6102429c4 to your computer and use it in GitHub Desktop.
風鈴
<!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