Files
Learn_System/frontend/js/labs/_fx_particles.js
T
Maxim Dolgolyov 6afe928c0d 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>
2026-05-23 13:58:49 +03:00

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);