feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла): - _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake - _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust) - _fx_motion.js: tween + 12 easings + critically-damped spring - _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API - Sound toggle в шапке lab.html с localStorage-persist UX МИКРО (CSS + JS): - Button states: hover scale+brightness, active scale-down, disabled grayscale - Slider polish: custom thumb с тенью, filled-track gradient, hover/active - Focus rings через :focus-visible - Tooltip system .tt-host data-tt= с 400ms hover, fade-in - Marching ants для selection - Loading skeleton с shimmer - Empty state .sim-empty-* паттерн - Toast: progress bar внизу, icons по типу - Cursor states utility classes - View Transitions API для smooth sim-switch, fallback на CSS fade PHASE 2 — визуальные эффекты для 33 симуляций: Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks) Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds) Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow) Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click) Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow) Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям) Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
'use strict';
|
||||
(function(global) {
|
||||
|
||||
global.LabFX = global.LabFX || {};
|
||||
|
||||
/* ── AudioContext lazy init ── */
|
||||
var _ctx = null;
|
||||
var _masterGain = null;
|
||||
var _enabled = (function() {
|
||||
var stored = localStorage.getItem('labfx-sound');
|
||||
return stored === null ? true : stored === 'true';
|
||||
})();
|
||||
|
||||
function getCtx() {
|
||||
if (!_ctx) {
|
||||
_ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
_masterGain = _ctx.createGain();
|
||||
_masterGain.gain.value = _enabled ? 1 : 0;
|
||||
_masterGain.connect(_ctx.destination);
|
||||
}
|
||||
if (_ctx.state === 'suspended') {
|
||||
_ctx.resume();
|
||||
}
|
||||
return _ctx;
|
||||
}
|
||||
|
||||
function master() {
|
||||
getCtx();
|
||||
return _masterGain;
|
||||
}
|
||||
|
||||
/* ── helper: connect chain to master ── */
|
||||
function chain(nodes) {
|
||||
/* nodes: array of AudioNode — each is connected to the next, last → master */
|
||||
for (var i = 0; i < nodes.length - 1; i++) {
|
||||
nodes[i].connect(nodes[i + 1]);
|
||||
}
|
||||
nodes[nodes.length - 1].connect(master());
|
||||
}
|
||||
|
||||
/* ── helper: create gain ── */
|
||||
function mkGain(value) {
|
||||
var g = getCtx().createGain();
|
||||
g.gain.value = value;
|
||||
return g;
|
||||
}
|
||||
|
||||
/* ── helper: create oscillator ── */
|
||||
function mkOsc(type, freq) {
|
||||
var o = getCtx().createOscillator();
|
||||
o.type = type;
|
||||
o.frequency.value = freq;
|
||||
return o;
|
||||
}
|
||||
|
||||
/* ── helper: create biquad ── */
|
||||
function mkFilter(type, freq, Q) {
|
||||
var f = getCtx().createBiquadFilter();
|
||||
f.type = type;
|
||||
f.frequency.value = freq;
|
||||
if (Q != null) f.Q.value = Q;
|
||||
return f;
|
||||
}
|
||||
|
||||
/* ── helper: noise buffer (white) ── */
|
||||
function makeNoise(color) {
|
||||
/* color: 'white' | 'pink' | 'brown' */
|
||||
var ac = getCtx();
|
||||
var length = ac.sampleRate * 2;
|
||||
var buffer = ac.createBuffer(1, length, ac.sampleRate);
|
||||
var data = buffer.getChannelData(0);
|
||||
var b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
|
||||
|
||||
for (var i = 0; i < length; i++) {
|
||||
var white = Math.random() * 2 - 1;
|
||||
if (color === 'pink') {
|
||||
b0 = 0.99886 * b0 + white * 0.0555179;
|
||||
b1 = 0.99332 * b1 + white * 0.0750759;
|
||||
b2 = 0.96900 * b2 + white * 0.1538520;
|
||||
b3 = 0.86650 * b3 + white * 0.3104856;
|
||||
b4 = 0.55000 * b4 + white * 0.5329522;
|
||||
b5 = -0.7616 * b5 - white * 0.0168980;
|
||||
data[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362) * 0.11;
|
||||
b6 = white * 0.115926;
|
||||
} else if (color === 'brown') {
|
||||
b0 = (b0 + 0.02 * white) / 1.02;
|
||||
data[i] = b0 * 3.5;
|
||||
} else {
|
||||
data[i] = white;
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/* ── noise source ── */
|
||||
function noiseSource(color) {
|
||||
var ac = getCtx();
|
||||
var src = ac.createBufferSource();
|
||||
src.buffer = makeNoise(color || 'white');
|
||||
src.loop = true;
|
||||
return src;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
SYNTH RECIPES
|
||||
───────────────────────────────────────────── */
|
||||
var recipes = {
|
||||
|
||||
click: function(opts) {
|
||||
/* short filtered noise burst, 30ms */
|
||||
var ac = getCtx();
|
||||
var now = ac.currentTime;
|
||||
var vol = (opts.volume || 0.5) * 0.6;
|
||||
var src = noiseSource('white');
|
||||
var flt = mkFilter('bandpass', 1200 * (opts.pitch || 1), 8);
|
||||
var g = mkGain(vol);
|
||||
g.gain.setValueAtTime(vol, now);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.030);
|
||||
chain([src, flt, g]);
|
||||
src.start(now);
|
||||
src.stop(now + 0.035);
|
||||
},
|
||||
|
||||
tick: function(opts) {
|
||||
/* Geiger tick: brown noise + very short envelope, 15ms */
|
||||
var ac = getCtx();
|
||||
var now = ac.currentTime;
|
||||
var vol = (opts.volume || 0.5) * 0.4;
|
||||
var src = noiseSource('brown');
|
||||
var flt = mkFilter('highpass', 800, 1);
|
||||
var g = mkGain(vol);
|
||||
g.gain.setValueAtTime(vol, now);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.015);
|
||||
chain([src, flt, g]);
|
||||
src.start(now);
|
||||
src.stop(now + 0.02);
|
||||
},
|
||||
|
||||
whoosh: function(opts) {
|
||||
/* descending swoosh: filtered noise sweep 800→200 Hz, 250ms */
|
||||
var ac = getCtx();
|
||||
var now = ac.currentTime;
|
||||
var vol = (opts.volume || 0.5) * 0.7;
|
||||
var src = noiseSource('pink');
|
||||
var flt = mkFilter('bandpass', 800 * (opts.pitch || 1), 3);
|
||||
var g = mkGain(vol);
|
||||
flt.frequency.setValueAtTime(800 * (opts.pitch || 1), now);
|
||||
flt.frequency.exponentialRampToValueAtTime(200 * (opts.pitch || 1), now + 0.25);
|
||||
g.gain.setValueAtTime(0.0001, now);
|
||||
g.gain.linearRampToValueAtTime(vol, now + 0.03);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.25);
|
||||
chain([src, flt, g]);
|
||||
src.start(now);
|
||||
src.stop(now + 0.28);
|
||||
},
|
||||
|
||||
chime: function(opts) {
|
||||
/* bright bell: additive sines 880+1320+1760 Hz, exp decay, 600ms */
|
||||
var ac = getCtx();
|
||||
var now = ac.currentTime;
|
||||
var vol = (opts.volume || 0.5);
|
||||
var pitch = opts.pitch || 1;
|
||||
var freqs = [880, 1320, 1760];
|
||||
var pan = opts.pan || 0;
|
||||
|
||||
freqs.forEach(function(f, idx) {
|
||||
var osc = mkOsc('sine', f * pitch);
|
||||
var g = mkGain(vol * (0.5 / (idx + 1)));
|
||||
var panner = ac.createStereoPanner ? ac.createStereoPanner() : null;
|
||||
g.gain.setValueAtTime(vol * (0.5 / (idx + 1)), now);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.6);
|
||||
if (panner) {
|
||||
panner.pan.value = pan;
|
||||
chain([osc, g, panner]);
|
||||
} else {
|
||||
chain([osc, g]);
|
||||
}
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.65);
|
||||
});
|
||||
},
|
||||
|
||||
fizz: function(opts) {
|
||||
/* pink noise, 300ms, low-pass sweep */
|
||||
var ac = getCtx();
|
||||
var now = ac.currentTime;
|
||||
var vol = (opts.volume || 0.5) * 0.6;
|
||||
var src = noiseSource('pink');
|
||||
var flt = mkFilter('lowpass', 3000 * (opts.pitch || 1), 1);
|
||||
var g = mkGain(vol);
|
||||
flt.frequency.setValueAtTime(3000 * (opts.pitch || 1), now);
|
||||
flt.frequency.exponentialRampToValueAtTime(400 * (opts.pitch || 1), now + 0.3);
|
||||
g.gain.setValueAtTime(vol, now);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.3);
|
||||
chain([src, flt, g]);
|
||||
src.start(now);
|
||||
src.stop(now + 0.35);
|
||||
},
|
||||
|
||||
spark: function(opts) {
|
||||
/* white noise + triangle 2000Hz, 50ms */
|
||||
var ac = getCtx();
|
||||
var now = ac.currentTime;
|
||||
var vol = (opts.volume || 0.5) * 0.7;
|
||||
var src = noiseSource('white');
|
||||
var osc = mkOsc('triangle', 2000 * (opts.pitch || 1));
|
||||
var gn = mkGain(vol * 0.5);
|
||||
var go = mkGain(vol * 0.5);
|
||||
var mix = mkGain(1);
|
||||
gn.gain.setValueAtTime(vol * 0.5, now);
|
||||
gn.gain.exponentialRampToValueAtTime(0.0001, now + 0.05);
|
||||
go.gain.setValueAtTime(vol * 0.5, now);
|
||||
go.gain.exponentialRampToValueAtTime(0.0001, now + 0.05);
|
||||
src.connect(gn); gn.connect(mix);
|
||||
osc.connect(go); go.connect(mix);
|
||||
mix.connect(master());
|
||||
src.start(now); osc.start(now);
|
||||
src.stop(now + 0.06); osc.stop(now + 0.06);
|
||||
},
|
||||
|
||||
bounce: function(opts) {
|
||||
/* sine sweep 600→300 Hz, 100ms */
|
||||
var ac = getCtx();
|
||||
var now = ac.currentTime;
|
||||
var vol = (opts.volume || 0.5) * 0.6;
|
||||
var osc = mkOsc('sine', 600 * (opts.pitch || 1));
|
||||
var g = mkGain(vol);
|
||||
osc.frequency.setValueAtTime(600 * (opts.pitch || 1), now);
|
||||
osc.frequency.exponentialRampToValueAtTime(300 * (opts.pitch || 1), now + 0.1);
|
||||
g.gain.setValueAtTime(vol, now);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.1);
|
||||
chain([osc, g]);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.12);
|
||||
},
|
||||
|
||||
pour: function(opts) {
|
||||
/* filtered noise with random pitch wobble, 200ms+ */
|
||||
var ac = getCtx();
|
||||
var now = ac.currentTime;
|
||||
var vol = (opts.volume || 0.5) * 0.5;
|
||||
var src = noiseSource('pink');
|
||||
var flt = mkFilter('bandpass', 400 * (opts.pitch || 1), 2);
|
||||
var g = mkGain(vol);
|
||||
/* wobble frequency */
|
||||
var steps = 8;
|
||||
for (var i = 0; i <= steps; i++) {
|
||||
var t = now + (i / steps) * 0.2;
|
||||
var freq = (350 + Math.random() * 150) * (opts.pitch || 1);
|
||||
flt.frequency.setValueAtTime(freq, t);
|
||||
}
|
||||
g.gain.setValueAtTime(0.0001, now);
|
||||
g.gain.linearRampToValueAtTime(vol, now + 0.04);
|
||||
g.gain.setValueAtTime(vol, now + 0.18);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.22);
|
||||
chain([src, flt, g]);
|
||||
src.start(now);
|
||||
src.stop(now + 0.25);
|
||||
}
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
DRONE — sustained sound (separate lifecycle)
|
||||
───────────────────────────────────────────── */
|
||||
var _droneNodes = {};
|
||||
|
||||
var droneRecipes = {
|
||||
drone: function(ac, vol) {
|
||||
var sine = mkOsc('sine', 80);
|
||||
var saw = mkOsc('sawtooth', 160);
|
||||
var gs = mkGain(vol * 0.5);
|
||||
var gw = mkGain(vol * 0.25);
|
||||
var mix = mkGain(0.05);
|
||||
sine.connect(gs); gs.connect(mix);
|
||||
saw.connect(gw); gw.connect(mix);
|
||||
mix.connect(master());
|
||||
sine.start();
|
||||
saw.start();
|
||||
return { nodes: [sine, saw, gs, gw, mix] };
|
||||
}
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
PUBLIC API
|
||||
───────────────────────────────────────────── */
|
||||
global.LabFX.sound = {
|
||||
|
||||
play: function(name, opts) {
|
||||
if (!_enabled) return;
|
||||
opts = opts || {};
|
||||
getCtx();
|
||||
var recipe = recipes[name];
|
||||
if (recipe) {
|
||||
try { recipe(opts); } catch(e) { /* silent */ }
|
||||
}
|
||||
},
|
||||
|
||||
startDrone: function(name) {
|
||||
if (_droneNodes[name]) return _droneNodes[name]; /* already running */
|
||||
var recipe = droneRecipes[name];
|
||||
if (!recipe) return null;
|
||||
var ac = getCtx();
|
||||
var vol = 0.5;
|
||||
var handle = recipe(ac, vol);
|
||||
_droneNodes[name] = handle;
|
||||
|
||||
handle.stop = function() {
|
||||
handle.nodes.forEach(function(n) {
|
||||
try { n.stop(); } catch(e) {}
|
||||
try { n.disconnect(); } catch(e) {}
|
||||
});
|
||||
delete _droneNodes[name];
|
||||
};
|
||||
|
||||
return handle;
|
||||
},
|
||||
|
||||
setEnabled: function(bool) {
|
||||
_enabled = !!bool;
|
||||
localStorage.setItem('labfx-sound', _enabled ? 'true' : 'false');
|
||||
if (_masterGain) {
|
||||
_masterGain.gain.cancelScheduledValues(0);
|
||||
_masterGain.gain.value = _enabled ? 1 : 0;
|
||||
}
|
||||
},
|
||||
|
||||
isEnabled: function() {
|
||||
return _enabled;
|
||||
}
|
||||
};
|
||||
|
||||
})(window);
|
||||
Reference in New Issue
Block a user