Files
Maxim Dolgolyov 28db2de74f feat(labs): Фаза0 — эконом-режим FX + выбор симуляции из списка в редакторе
План улучшения симуляций — plans/simulations-improvement/README.md.
- LabFX: reduced-motion/эконом-режим (prefers-reduced-motion + тумблер
  localStorage labfx-economy). Тряска отключается, частицы ×0.25 — доступность
  и экономия на слабых устройствах сразу для всех ~50 симуляций. Кнопка-тумблер
  в lab.html рядом со звуком.
- lesson-editor: блок «Симуляция» — выпадающий список из /api/lab/sims
  (сгруппирован по предметам) вместо сырого ввода simId; неизвестный id не
  теряется, помечается «(не найдена)». Закрывает хрупкую вставку в урок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:33:50 +03:00

229 lines
6.5 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;
// Эконом/reduced-motion — декоративных частиц в разы меньше
if (global.LabFX.reduced) count = Math.max(1, Math.round(count * 0.25));
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);