feat(labs): wave 2 — depth features across 6 sims
Электрические цепи (circuit): - Индуктивность L как новый компонент (1–1000 мГн, шорт в DC, jωL в AC) - RLC preset для демонстрации резонанса - Осциллограф: U(t)/I(t) для выбранного компонента, 100 sample, dual-axis - Heatmap мощности: радиальный градиент halo от blue→red пропорционально P=UI Стереометрия 3D (stereo): - Сечение через 3 произвольные точки: pick на гранях/рёбрах/вершинах - Плоскость + полигон пересечения с авто-определением типа (3–6-угольник) и площадью - Step-by-step режим: визуализация P1→линия→P2→линия→P3→плоскость→сечение - Поддержка всех solids (включая cylinder/cone через sampling fallback) Планиметрия (geometry): - Задачник framework: CHALLENGES[] с setup/check функциями - 5 стартовых задач: серединный перпендикуляр, биссектриса, описанная окружность, ГМТ, касательная - Авто-checker: толерантности ±0.5° для углов, ±1–5% для расстояний - UI: collapsible панель с статус-иконками, конфетти + «Молодец!» на success Электромагнитные поля (emfield): - Preset «Тороид»: 16+16 проводов в концентрических кольцах - Поверхность Гаусса: draggable круг, считает Φ = q_enc/ε₀, подсвечивает охваченные заряды - Motional EMF: draggable rod, arrow-keys управление, считает ε = ∫(v×B)·dl Химическая песочница (chemsandbox): - Live-overlay с уравнением реакции: молекулярное / полное ионное / сокращённое ионное - Coverage: 49/49 молекулярных, 34/49 ионных, 36/49 сокращённых - Auto-hide через 5 сек, fade-in animation, цветовая кодировка типов Волны и звук (waves): - Doppler: source+observer drag, expanding wavefronts, f_obs формула, Mach cone при v>c - Beats: f1+f2, sum waveform с envelope, индикация f_beat=|f1-f2| - Spectrum (DFT): N=256 samples pure JS, bar-chart с пиками и labels, «Добавить гармонику» Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3240,3 +3240,464 @@ class GeoSim {
|
||||
|
||||
/* ── trig circle ── */
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
ЗАДАЧНИК — challenge framework
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* Helper: get two math-coordinate points on any line-like object.
|
||||
* Works for 'segment', 'line', 'ray', 'derived_line'.
|
||||
*/
|
||||
function _challTwoPts(eng, obj) {
|
||||
if (!obj) return null;
|
||||
if (obj.type === 'derived_line') {
|
||||
return [{ x: obj.ptX, y: obj.ptY },
|
||||
{ x: obj.ptX + obj.dirX, y: obj.ptY + obj.dirY }];
|
||||
}
|
||||
const p1 = eng.get(obj.p1Id), p2 = eng.get(obj.p2Id);
|
||||
if (!p1 || !p2) return null;
|
||||
return [{ x: p1.x, y: p1.y }, { x: p2.x, y: p2.y }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all "line-like" objects (line, ray, segment, derived_line).
|
||||
*/
|
||||
function _challLines(eng) {
|
||||
return eng.all().filter(o =>
|
||||
o.type === 'line' || o.type === 'ray' ||
|
||||
o.type === 'segment' || o.type === 'derived_line'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise direction: always returns { dx, dy } with dy >= 0
|
||||
* (or dx > 0 when dy == 0), for comparing line directions.
|
||||
*/
|
||||
function _challNormDir(dx, dy) {
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < 1e-12) return { dx: 0, dy: 0 };
|
||||
let nx = dx / len, ny = dy / len;
|
||||
if (ny < 0 || (Math.abs(ny) < 1e-9 && nx < 0)) { nx = -nx; ny = -ny; }
|
||||
return { dx: nx, dy: ny };
|
||||
}
|
||||
|
||||
const CHALLENGES = [
|
||||
/* ── C1: Серединный перпендикуляр ──────────────────────────────── */
|
||||
{
|
||||
id: 'C1',
|
||||
title: 'Серединный перпендикуляр к AB',
|
||||
desc: 'Постройте серединный перпендикуляр к отрезку AB. ' +
|
||||
'Используйте инструмент «⊥ биссект.» или постройте вручную ' +
|
||||
'(прямую через середину AB, перпендикулярную AB).',
|
||||
hint: 'Воспользуйтесь инструментом «⊥ биссект.» — кликните точки A и B.',
|
||||
setup(eng) {
|
||||
eng.clear();
|
||||
const A = eng.add({ type:'point', x:-2, y:0, label:'A' });
|
||||
const B = eng.add({ type:'point', x: 2, y:0, label:'B' });
|
||||
eng.add({ type:'segment', p1Id:A.id, p2Id:B.id });
|
||||
},
|
||||
check(eng) {
|
||||
// Find A and B by label
|
||||
const pts = eng.points();
|
||||
const A = pts.find(p => p.label === 'A');
|
||||
const B = pts.find(p => p.label === 'B');
|
||||
if (!A || !B) return { passed: false, hint: 'Не найдены точки A и B.' };
|
||||
|
||||
const mid = { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 };
|
||||
const segDx = B.x - A.x, segDy = B.y - A.y;
|
||||
const segLen = Math.hypot(segDx, segDy);
|
||||
if (segLen < 1e-9) return { passed: false };
|
||||
|
||||
// Perpendicular direction to AB
|
||||
const perpDx = -segDy / segLen, perpDy = segDx / segLen;
|
||||
|
||||
for (const obj of _challLines(eng)) {
|
||||
const pts2 = _challTwoPts(eng, obj);
|
||||
if (!pts2) continue;
|
||||
const [P1, P2] = pts2;
|
||||
const dx = P2.x - P1.x, dy = P2.y - P1.y;
|
||||
const len2 = Math.hypot(dx, dy);
|
||||
if (len2 < 1e-9) continue;
|
||||
|
||||
// Check direction is perpendicular to AB (dot product with AB dir ≈ 0)
|
||||
const dot = (dx / len2) * segDx / segLen + (dy / len2) * segDy / segLen;
|
||||
if (Math.abs(dot) > 0.02) continue; // not perpendicular
|
||||
|
||||
// Check passes through midpoint
|
||||
const distToMid = gDistToLine(mid, P1, P2);
|
||||
if (distToMid < 0.05) return { passed: true };
|
||||
}
|
||||
return { passed: false,
|
||||
hint: 'Нужна прямая, проходящая через середину AB и перпендикулярная AB.' };
|
||||
}
|
||||
},
|
||||
|
||||
/* ── C2: Биссектриса угла ───────────────────────────────────────── */
|
||||
{
|
||||
id: 'C2',
|
||||
title: 'Биссектриса угла',
|
||||
desc: 'Постройте биссектрису угла с вершиной V. ' +
|
||||
'Используйте инструмент «∠ биссект.» (три клика: A, вершина V, B).',
|
||||
hint: 'Инструмент «∠ биссект.»: кликните точку A, затем V (вершину), затем B.',
|
||||
setup(eng) {
|
||||
eng.clear();
|
||||
const V = eng.add({ type:'point', x:0, y:0, label:'V' });
|
||||
const A = eng.add({ type:'point', x:-3, y:0, label:'A' });
|
||||
const B = eng.add({ type:'point', x:0, y:3, label:'B' });
|
||||
eng.add({ type:'ray', p1Id:V.id, p2Id:A.id });
|
||||
eng.add({ type:'ray', p1Id:V.id, p2Id:B.id });
|
||||
},
|
||||
check(eng) {
|
||||
const pts = eng.points();
|
||||
const V = pts.find(p => p.label === 'V');
|
||||
const A = pts.find(p => p.label === 'A');
|
||||
const B = pts.find(p => p.label === 'B');
|
||||
if (!V || !A || !B) return { passed: false, hint: 'Не найдены точки V, A, B.' };
|
||||
|
||||
// Expected bisector direction
|
||||
const va = gNorm({ x: A.x - V.x, y: A.y - V.y });
|
||||
const vb = gNorm({ x: B.x - V.x, y: B.y - V.y });
|
||||
const bisDir = gNorm({ x: va.x + vb.x, y: va.y + vb.y });
|
||||
if (Math.hypot(bisDir.x, bisDir.y) < 1e-9)
|
||||
return { passed: false, hint: 'Угол вырожден.' };
|
||||
|
||||
// Half-angle for tolerance: ±0.5°
|
||||
const halfAngleDeg = gAngleDeg(A, V, B) / 2;
|
||||
const TOL_DEG = 0.5;
|
||||
|
||||
for (const obj of _challLines(eng)) {
|
||||
const pts2 = _challTwoPts(eng, obj);
|
||||
if (!pts2) continue;
|
||||
const [P1, P2] = pts2;
|
||||
|
||||
// Must pass through V
|
||||
const distV = gDistToLine({ x: V.x, y: V.y }, P1, P2);
|
||||
if (distV > 0.08) continue;
|
||||
|
||||
// Direction must match bisector
|
||||
const dx = P2.x - P1.x, dy = P2.y - P1.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < 1e-9) continue;
|
||||
const crossAbs = Math.abs((dx / len) * bisDir.y - (dy / len) * bisDir.x);
|
||||
// sin(angle between lines) = crossAbs; for ±0.5° sin(0.5°) ≈ 0.0087
|
||||
if (crossAbs < Math.sin(TOL_DEG * Math.PI / 180)) return { passed: true };
|
||||
}
|
||||
return { passed: false,
|
||||
hint: 'Нужен луч/прямая из V, делящая угол AVB пополам.' };
|
||||
}
|
||||
},
|
||||
|
||||
/* ── C3: Описанная окружность вокруг треугольника ───────────────── */
|
||||
{
|
||||
id: 'C3',
|
||||
title: 'Описанная окружность треугольника',
|
||||
desc: 'Постройте окружность, проходящую через все три вершины треугольника ABC. ' +
|
||||
'Используйте инструмент «Описанная» (circumcircle).',
|
||||
hint: 'Инструмент «Описанная»: кликните три вершины A, B, C — окружность строится автоматически.',
|
||||
setup(eng) {
|
||||
eng.clear();
|
||||
const A = eng.add({ type:'point', x:-2, y:-1.5, label:'A' });
|
||||
const B = eng.add({ type:'point', x: 2, y:-1.5, label:'B' });
|
||||
const C = eng.add({ type:'point', x: 0, y: 2, label:'C' });
|
||||
eng.add({ type:'polygon', pointIds:[A.id, B.id, C.id] });
|
||||
},
|
||||
check(eng) {
|
||||
const pts = eng.points();
|
||||
const A = pts.find(p => p.label === 'A');
|
||||
const B = pts.find(p => p.label === 'B');
|
||||
const C = pts.find(p => p.label === 'C');
|
||||
if (!A || !B || !C) return { passed: false, hint: 'Не найдены вершины A, B, C.' };
|
||||
|
||||
// Look for any circle passing through A, B, C within 1%
|
||||
for (const circ of eng.byType('circle')) {
|
||||
let cx, cy, r;
|
||||
if (circ.derived && circ.cx != null) {
|
||||
cx = circ.cx; cy = circ.cy; r = circ.r;
|
||||
} else {
|
||||
const ctr = eng.get(circ.centerId);
|
||||
const edg = eng.get(circ.edgeId);
|
||||
if (!ctr || !edg) continue;
|
||||
cx = ctr.x; cy = ctr.y;
|
||||
r = gDist({ x: cx, y: cy }, { x: edg.x, y: edg.y });
|
||||
}
|
||||
if (r < 1e-9) continue;
|
||||
const O = { x: cx, y: cy };
|
||||
const rA = gDist(O, A), rB = gDist(O, B), rC = gDist(O, C);
|
||||
const tol = r * 0.05; // 5% — generous for hand-built circumcircles
|
||||
if (Math.abs(rA - r) < tol && Math.abs(rB - r) < tol && Math.abs(rC - r) < tol)
|
||||
return { passed: true };
|
||||
}
|
||||
return { passed: false,
|
||||
hint: 'Постройте окружность, равноудалённую от A, B и C.' };
|
||||
}
|
||||
},
|
||||
|
||||
/* ── C4: ГМТ — множество точек, равноудалённых от A и B ────────── */
|
||||
{
|
||||
id: 'C4',
|
||||
title: 'ГМТ: равноудалённые от A и B',
|
||||
desc: 'Постройте геометрическое место точек, равноудалённых от точек A и B. ' +
|
||||
'Подсказка: это серединный перпендикуляр AB. ' +
|
||||
'Используйте инструмент «ГМТ» (locus): ' +
|
||||
'создайте скользящую точку на окружности или отрезке, ' +
|
||||
'затем постройте из неё точку-цель, равноудалённую от A и B.',
|
||||
hint: 'Самый простой способ: серединный перпендикуляр к AB — это и есть ГМТ. ' +
|
||||
'Используйте инструмент «⊥ биссект.» или locus.',
|
||||
setup(eng) {
|
||||
eng.clear();
|
||||
const A = eng.add({ type:'point', x:-2, y:0, label:'A' });
|
||||
const B = eng.add({ type:'point', x: 2, y:0, label:'B' });
|
||||
},
|
||||
check(eng) {
|
||||
// Accept: any locus object OR any line that is the perpendicular bisector of AB
|
||||
const pts = eng.points();
|
||||
const A = pts.find(p => p.label === 'A');
|
||||
const B = pts.find(p => p.label === 'B');
|
||||
if (!A || !B) return { passed: false };
|
||||
|
||||
// Accept a locus object (heuristic: it exists)
|
||||
if (eng.byType('locus').length > 0) return { passed: true };
|
||||
|
||||
// Accept a perpendicular bisector line/derived_line through midpoint
|
||||
const mid = { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 };
|
||||
const segLen = gDist(A, B);
|
||||
if (segLen < 1e-9) return { passed: false };
|
||||
const segDx = (B.x - A.x) / segLen, segDy = (B.y - A.y) / segLen;
|
||||
|
||||
for (const obj of _challLines(eng)) {
|
||||
const pts2 = _challTwoPts(eng, obj);
|
||||
if (!pts2) continue;
|
||||
const [P1, P2] = pts2;
|
||||
const dx = P2.x - P1.x, dy = P2.y - P1.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < 1e-9) continue;
|
||||
const dot = Math.abs((dx / len) * segDx + (dy / len) * segDy);
|
||||
if (dot > 0.02) continue; // not perpendicular to AB
|
||||
if (gDistToLine(mid, P1, P2) < 0.08) return { passed: true };
|
||||
}
|
||||
return { passed: false,
|
||||
hint: 'Постройте серединный перпендикуляр к AB или используйте инструмент ГМТ.' };
|
||||
}
|
||||
},
|
||||
|
||||
/* ── C5: Касательная к окружности ──────────────────────────────── */
|
||||
{
|
||||
id: 'C5',
|
||||
title: 'Касательная к окружности',
|
||||
desc: 'Постройте касательную к окружности из внешней точки P. ' +
|
||||
'Используйте инструмент «Касательные» (tangent): кликните на окружность, ' +
|
||||
'затем на внешнюю точку P.',
|
||||
hint: 'Инструмент «Касательные»: сначала кликните на окружность, потом на точку P.',
|
||||
setup(eng) {
|
||||
eng.clear();
|
||||
const center = eng.add({ type:'point', x: 0, y: 0, label:'O' });
|
||||
const edge = eng.add({ type:'point', x: 2, y: 0, label:'R' });
|
||||
eng.add({ type:'circle', centerId: center.id, edgeId: edge.id });
|
||||
eng.add({ type:'point', x: 5, y: 0, label:'P' });
|
||||
},
|
||||
check(eng) {
|
||||
// Find the setup circle and P
|
||||
const pts = eng.points();
|
||||
const O = pts.find(p => p.label === 'O');
|
||||
const P = pts.find(p => p.label === 'P');
|
||||
if (!O || !P) return { passed: false };
|
||||
|
||||
// Find circle with center O
|
||||
let circR = null;
|
||||
for (const circ of eng.byType('circle')) {
|
||||
const ctr = eng.get(circ.centerId);
|
||||
if (ctr && Math.abs(ctr.x - O.x) < 0.01 && Math.abs(ctr.y - O.y) < 0.01) {
|
||||
const edg = eng.get(circ.edgeId);
|
||||
if (edg) { circR = gDist({ x: O.x, y: O.y }, { x: edg.x, y: edg.y }); break; }
|
||||
}
|
||||
}
|
||||
if (!circR) return { passed: false, hint: 'Исходная окружность не найдена.' };
|
||||
|
||||
const Opt = { x: O.x, y: O.y };
|
||||
const TOL = circR * 0.05; // 5% of radius
|
||||
|
||||
// Look for any line/ray through P where distance from O to line ≈ radius
|
||||
for (const obj of _challLines(eng)) {
|
||||
const pts2 = _challTwoPts(eng, obj);
|
||||
if (!pts2) continue;
|
||||
const [P1, P2] = pts2;
|
||||
|
||||
// Must pass through P (within tolerance)
|
||||
const distP = gDistToLine({ x: P.x, y: P.y }, P1, P2);
|
||||
if (distP > 0.15) continue;
|
||||
|
||||
// Distance from center O to the line ≈ radius
|
||||
const distO = gDistToLine(Opt, P1, P2);
|
||||
if (Math.abs(distO - circR) < TOL) return { passed: true };
|
||||
}
|
||||
return { passed: false,
|
||||
hint: 'Постройте прямую через P, касающуюся окружности с центром O.' };
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Challenge state ─────────────────────────────────────────────── */
|
||||
let _challState = CHALLENGES.map(() => 'locked');
|
||||
_challState[0] = 'current'; // first challenge is unlocked
|
||||
let _challAttempts = CHALLENGES.map(() => 0); // fail attempt counter
|
||||
let _challPanelOpen = false;
|
||||
|
||||
function geoToggleChallengePanel() {
|
||||
_challPanelOpen = !_challPanelOpen;
|
||||
const panel = document.getElementById('geo-challenge-panel');
|
||||
if (panel) panel.classList.toggle('open', _challPanelOpen);
|
||||
if (_challPanelOpen) _geoChallRenderList();
|
||||
}
|
||||
|
||||
function _geoChallRenderList() {
|
||||
const list = document.getElementById('geo-chall-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
const doneCount = _challState.filter(s => s === 'done').length;
|
||||
const countEl = document.getElementById('geo-chall-count');
|
||||
if (countEl) countEl.textContent = doneCount + '/' + CHALLENGES.length;
|
||||
|
||||
CHALLENGES.forEach((ch, idx) => {
|
||||
const state = _challState[idx];
|
||||
const item = document.createElement('div');
|
||||
item.className = 'geo-chall-item chall-' + state;
|
||||
item.dataset.idx = idx;
|
||||
|
||||
// Status icon
|
||||
let statusContent = (idx + 1).toString();
|
||||
if (state === 'done') {
|
||||
statusContent = '<svg viewBox="0 0 24 24" fill="none" stroke="#4ADE80" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
} else if (state === 'locked') {
|
||||
statusContent = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:10px;height:10px"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
|
||||
}
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="geo-chall-head">
|
||||
<div class="geo-chall-status">${statusContent}</div>
|
||||
<div class="geo-chall-name">${ch.title}</div>
|
||||
</div>
|
||||
<div class="geo-chall-body">
|
||||
<div class="geo-chall-desc">${ch.desc}</div>
|
||||
<div class="geo-chall-actions">
|
||||
<button class="geo-chall-btn geo-chall-btn-check" onclick="geoChallCheck(${idx})">Проверить</button>
|
||||
<button class="geo-chall-btn geo-chall-btn-reset" onclick="geoChallSetup(${idx})" title="Начать заново">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="width:12px;height:12px"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.95"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="geo-chall-hint${_challAttempts[idx] >= 2 ? ' visible' : ''}" id="geo-chall-hint-${idx}">${ch.hint}</div>
|
||||
<div class="geo-chall-feedback" id="geo-chall-fb-${idx}"></div>
|
||||
</div>`;
|
||||
|
||||
// Allow expanding done items too
|
||||
item.querySelector('.geo-chall-head').addEventListener('click', () => {
|
||||
item.classList.toggle('geo-chall-expanded');
|
||||
});
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function geoChallSetup(idx) {
|
||||
if (!geomSim) return;
|
||||
const ch = CHALLENGES[idx];
|
||||
if (!ch) return;
|
||||
ch.setup(geomSim.eng);
|
||||
// Recompute all derived objects after setup
|
||||
for (const obj of geomSim.eng.all()) {
|
||||
if (obj.derived) geomSim.eng.recompute(obj.id);
|
||||
}
|
||||
geomSim.fit();
|
||||
geomSim.render();
|
||||
_geoUpdateStats();
|
||||
const fb = document.getElementById('geo-chall-fb-' + idx);
|
||||
if (fb) { fb.textContent = ''; fb.className = 'geo-chall-feedback'; }
|
||||
}
|
||||
|
||||
function geoChallCheck(idx) {
|
||||
if (!geomSim) return;
|
||||
const ch = CHALLENGES[idx];
|
||||
if (!ch || _challState[idx] === 'locked') return;
|
||||
|
||||
const result = ch.check(geomSim.eng);
|
||||
const fb = document.getElementById('geo-chall-fb-' + idx);
|
||||
|
||||
if (result.passed) {
|
||||
_challState[idx] = 'done';
|
||||
// Unlock next
|
||||
if (idx + 1 < CHALLENGES.length && _challState[idx + 1] === 'locked') {
|
||||
_challState[idx + 1] = 'current';
|
||||
}
|
||||
if (fb) { fb.textContent = 'Верно!'; fb.className = 'geo-chall-feedback ok'; }
|
||||
_geoChallSuccessBurst();
|
||||
_geoChallRenderList();
|
||||
} else {
|
||||
_challAttempts[idx]++;
|
||||
const msg = result.hint
|
||||
? result.hint
|
||||
: 'Не совсем. Попробуй ещё раз.';
|
||||
if (fb) { fb.textContent = msg; fb.className = 'geo-chall-feedback err'; }
|
||||
// Show hint after 2 fails
|
||||
if (_challAttempts[idx] >= 2) {
|
||||
const hintEl = document.getElementById('geo-chall-hint-' + idx);
|
||||
if (hintEl) hintEl.classList.add('visible');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _geoChallSuccessBurst() {
|
||||
const outer = document.querySelector('.geo-canvas-outer');
|
||||
if (!outer) return;
|
||||
|
||||
// "Молодец!" label
|
||||
const label = document.createElement('div');
|
||||
label.className = 'geo-chall-success-label';
|
||||
label.textContent = 'Молодец!';
|
||||
outer.appendChild(label);
|
||||
setTimeout(() => label.remove(), 2400);
|
||||
|
||||
// Confetti particles on canvas
|
||||
if (!geomSim) return;
|
||||
const canvas = geomSim.canvas;
|
||||
const ctx = geomSim.ctx;
|
||||
const W = canvas.width, H = canvas.height;
|
||||
const particles = [];
|
||||
const colors = ['#4ADE80', '#34D399', '#A78BFA', '#60A5FA', '#FBBF24', '#F472B6'];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
particles.push({
|
||||
x: W / 2 + (Math.random() - 0.5) * W * 0.4,
|
||||
y: H / 2 + (Math.random() - 0.5) * H * 0.3,
|
||||
vx: (Math.random() - 0.5) * 5,
|
||||
vy: (Math.random() - 0.6) * 6,
|
||||
r: 3 + Math.random() * 4,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
alpha: 1,
|
||||
rot: Math.random() * Math.PI * 2,
|
||||
rotV: (Math.random() - 0.5) * 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
const maxFrames = 60;
|
||||
function burst() {
|
||||
if (frame >= maxFrames) { geomSim.render(); return; }
|
||||
geomSim.render();
|
||||
for (const p of particles) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = p.alpha;
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.fillRect(-p.r / 2, -p.r / 2, p.r, p.r * 1.6);
|
||||
ctx.restore();
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy += 0.18; // gravity
|
||||
p.alpha -= 1 / maxFrames;
|
||||
p.rot += p.rotV;
|
||||
}
|
||||
frame++;
|
||||
requestAnimationFrame(burst);
|
||||
}
|
||||
requestAnimationFrame(burst);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user