28db2de74f
План улучшения симуляций — 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>
229 lines
6.5 KiB
JavaScript
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);
|