6afe928c0d
ФУНДАМЕНТ (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>
226 lines
6.3 KiB
JavaScript
226 lines
6.3 KiB
JavaScript
'use strict';
|
|
(function(global) {
|
|
|
|
global.LabFX = global.LabFX || {};
|
|
|
|
/* ── pool ── */
|
|
var POOL_SIZE = 1500;
|
|
var pool = [];
|
|
|
|
(function buildPool() {
|
|
for (var i = 0; i < POOL_SIZE; i++) {
|
|
pool.push({
|
|
alive: false,
|
|
ctx: null,
|
|
x: 0, y: 0,
|
|
vx: 0, vy: 0,
|
|
ax: 0, ay: 0,
|
|
life: 1000,
|
|
age: 0,
|
|
color: '#fff',
|
|
size: 3,
|
|
baseSize: 3,
|
|
shape: 'dot',
|
|
glow: false,
|
|
fade: true,
|
|
sizeFade: true,
|
|
/* spark: previous position for motion-blur line */
|
|
px: 0, py: 0
|
|
});
|
|
}
|
|
})();
|
|
|
|
/* grab a dead particle from pool; returns null if pool exhausted */
|
|
function acquire() {
|
|
for (var i = 0; i < POOL_SIZE; i++) {
|
|
if (!pool[i].alive) return pool[i];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/* ── helpers ── */
|
|
function pickColor(color) {
|
|
if (Array.isArray(color)) {
|
|
return color[Math.floor(Math.random() * color.length)];
|
|
}
|
|
return color;
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────
|
|
PUBLIC API
|
|
───────────────────────────────────────────── */
|
|
global.LabFX.particles = {
|
|
|
|
/**
|
|
* Spawn N particles at canvas coords (x, y).
|
|
*/
|
|
emit: function(opts) {
|
|
opts = opts || {};
|
|
var ctx = opts.ctx;
|
|
var x = opts.x || 0;
|
|
var y = opts.y || 0;
|
|
var count = opts.count != null ? opts.count : 10;
|
|
var color = opts.color != null ? opts.color : '#fff';
|
|
var speed = opts.speed != null ? opts.speed : 60;
|
|
var spread = opts.spread != null ? opts.spread : Math.PI * 2;
|
|
var angle = opts.angle != null ? opts.angle : 0;
|
|
var gravity = opts.gravity != null ? opts.gravity : 0;
|
|
var life = opts.life != null ? opts.life : 1000;
|
|
var fade = opts.fade != null ? opts.fade : true;
|
|
var glow = opts.glow != null ? opts.glow : false;
|
|
var shape = opts.shape != null ? opts.shape : 'dot';
|
|
var size = opts.size != null ? opts.size : 3;
|
|
var sizeFade = opts.sizeFade != null ? opts.sizeFade : true;
|
|
|
|
for (var i = 0; i < count; i++) {
|
|
var p = acquire();
|
|
if (!p) break;
|
|
|
|
var dir = angle + (Math.random() - 0.5) * spread;
|
|
var spd = speed * (0.5 + Math.random() * 0.5);
|
|
|
|
p.alive = true;
|
|
p.ctx = ctx;
|
|
p.x = x;
|
|
p.y = y;
|
|
p.px = x;
|
|
p.py = y;
|
|
p.vx = Math.cos(dir) * spd;
|
|
p.vy = Math.sin(dir) * spd;
|
|
p.ax = 0;
|
|
p.ay = gravity;
|
|
p.life = life;
|
|
p.age = 0;
|
|
p.color = pickColor(color);
|
|
p.size = size * (0.7 + Math.random() * 0.6);
|
|
p.baseSize = p.size;
|
|
p.shape = shape;
|
|
p.glow = glow;
|
|
p.fade = fade;
|
|
p.sizeFade = sizeFade;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Advance all alive particles by dt seconds.
|
|
* Call from your sim RAF loop.
|
|
*/
|
|
update: function(dt) {
|
|
for (var i = 0; i < POOL_SIZE; i++) {
|
|
var p = pool[i];
|
|
if (!p.alive) continue;
|
|
|
|
p.age += dt * 1000; /* convert s → ms */
|
|
|
|
if (p.age >= p.life) {
|
|
p.alive = false;
|
|
continue;
|
|
}
|
|
|
|
p.px = p.x;
|
|
p.py = p.y;
|
|
|
|
p.vx += p.ax * dt;
|
|
p.vy += p.ay * dt;
|
|
p.x += p.vx * dt;
|
|
p.y += p.vy * dt;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Draw all live particles that belong to ctx.
|
|
* Call after update, inside your sim's draw/render fn.
|
|
*/
|
|
draw: function(ctx) {
|
|
for (var i = 0; i < POOL_SIZE; i++) {
|
|
var p = pool[i];
|
|
if (!p.alive || p.ctx !== ctx) continue;
|
|
|
|
var t = p.age / p.life; /* 0..1 */
|
|
var alpha = p.fade ? (1 - t) : 1;
|
|
var sz = p.sizeFade ? p.baseSize * (1 - t * 0.8) : p.baseSize;
|
|
|
|
ctx.save();
|
|
|
|
if (p.glow) {
|
|
ctx.globalCompositeOperation = 'lighter';
|
|
}
|
|
|
|
ctx.globalAlpha = alpha;
|
|
ctx.fillStyle = p.color;
|
|
ctx.strokeStyle = p.color;
|
|
|
|
switch (p.shape) {
|
|
case 'spark': {
|
|
/* line from old to current pos — motion blur */
|
|
var len = Math.max(2, sz * 3);
|
|
var dx = p.x - p.px;
|
|
var dy = p.y - p.py;
|
|
var d = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
var nx = dx / d * len;
|
|
var ny = dy / d * len;
|
|
ctx.lineWidth = Math.max(0.5, sz * 0.4);
|
|
ctx.beginPath();
|
|
ctx.moveTo(p.x - nx, p.y - ny);
|
|
ctx.lineTo(p.x, p.y);
|
|
ctx.stroke();
|
|
break;
|
|
}
|
|
|
|
case 'ring': {
|
|
var r = p.baseSize * (1 + t * 3);
|
|
ctx.lineWidth = Math.max(0.5, sz * 0.5);
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
break;
|
|
}
|
|
|
|
case 'smoke': {
|
|
var smokeAlpha = alpha * 0.25;
|
|
ctx.globalAlpha = smokeAlpha;
|
|
var smokeR = sz * (2 + t * 3);
|
|
var grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, smokeR);
|
|
grad.addColorStop(0, p.color);
|
|
grad.addColorStop(1, 'transparent');
|
|
ctx.fillStyle = grad;
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, smokeR, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
break;
|
|
}
|
|
|
|
case 'dust': {
|
|
ctx.globalAlpha = alpha * 0.6;
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, Math.max(0.5, sz * 0.5), 0, Math.PI * 2);
|
|
ctx.fill();
|
|
break;
|
|
}
|
|
|
|
case 'splash':
|
|
/* same as dot but gravity param makes it droop — handled in update */
|
|
/* fall-through */
|
|
case 'dot':
|
|
default: {
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, Math.max(0.5, sz), 0, Math.PI * 2);
|
|
ctx.fill();
|
|
break;
|
|
}
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
},
|
|
|
|
/** Remove all particles */
|
|
clear: function() {
|
|
for (var i = 0; i < POOL_SIZE; i++) {
|
|
pool[i].alive = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
})(window);
|