'use strict';
/* ═══════════════════════════════════════════════
CollisionSim — 2D elastic/inelastic ball collision
Conservation of momentum & energy demo
═══════════════════════════════════════════════ */
class CollisionSim {
constructor(canvas) {
this.c = canvas;
this.ctx = canvas.getContext('2d');
/* physics params */
this.m1 = 4;
this.m2 = 4;
this.v1 = 8;
this.v2 = 8;
this.angle = 0;
this.e = 1;
this.speed = 1; // sim speed multiplier (0.1 – 4)
/* runtime */
this.playing = false;
this._raf = null;
this._lastTs = null;
this._cooldown = 0;
this._colCount = 0;
/* balls */
this._b = [];
/* visual effects */
this._sparks = [];
this._rings = [];
this._dust = []; // tiny debris cloud
this._impactPt = null;
this._squish = [null, null]; // per-ball squish {ts, nx, ny}
this._launchTs = null; // when play() was called
/* stats */
this._snapBefore = null;
this._snapAfter = null;
/* perfectly-inelastic merge state */
this._merged = false;
this._mergeR = 0;
this._mergeNormal = null; // {nx, ny} at moment of merge
this.onUpdate = null;
this.onPlayPause = null; // canvas click callback
/* centre-of-mass trail */
this._cmTrail = [];
/* pre-collision ghost velocity arrows */
this._ghostArrows = [];
/* hover inspector */
this._hoverBall = null;
/* FBD toggle */
this._fbdOn = false;
/* ── Energy bars widget ── */
this._energyOn = false;
this._frictionWork = 0; // cumulative KE lost in inelastic collisions
this._energyScale = 0;
/* -- GraphPanel widget -- */
this._graphsOn = false;
this._graphUI = null;
/* ── TimeControl + MotionTrails ── */
this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : null;
this._tcTrailColors = ['#06D6E0', '#F15BB5'];
this.showTCTrails = false;
canvas.addEventListener('click', () => { if (this.onPlayPause) this.onPlayPause(); });
canvas.addEventListener('mousemove', e => this._onMouseMove(e));
canvas.addEventListener('mouseleave', () => { this._hoverBall = null; this.draw(); });
new ResizeObserver(() => { this.fit(); this._initBalls(); this.draw(); })
.observe(canvas.parentElement);
}
/* ═══ public API ═══ */
fit() {
const r = this.c.parentElement.getBoundingClientRect();
this.c.width = r.width || 700;
this.c.height = r.height || 420;
}
getParams() { return { m1: this.m1, m2: this.m2, v1: this.v1, v2: this.v2, angle: this.angle, e: this.e }; }
setParams(p) {
if (p.m1 !== undefined) this.m1 = +p.m1;
if (p.m2 !== undefined) this.m2 = +p.m2;
if (p.v1 !== undefined) this.v1 = +p.v1;
if (p.v2 !== undefined) this.v2 = +p.v2;
if (p.angle !== undefined) this.angle = +p.angle;
if (p.e !== undefined) this.e = +p.e;
this.reset();
}
setSpeed(s) {
this.speed = Math.max(0.1, Math.min(4, +s));
}
play() {
if (this.playing) return;
this.playing = true;
this._lastTs = null;
this._launchTs = performance.now();
this._spawnLaunchFx();
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
reset() {
this.pause();
this._sparks = [];
this._rings = [];
this._dust = [];
this._squish = [null, null];
this._impactPt = null;
this._launchTs = null;
this._merged = false;
this._mergeR = 0;
this._mergeNormal = null;
this._cmTrail = [];
this._ghostArrows = [];
this._initBalls();
this.draw();
this._emit();
}
stats() {
if (this._b.length < 2) return { v1:0, v2:0, ke:0, p:0, colCount:0, before:null, after:null };
const [b1, b2] = this._b;
const v1 = Math.hypot(b1.vx, b1.vy), v2 = Math.hypot(b2.vx, b2.vy);
const ke = 0.5 * b1.m * v1 * v1 + 0.5 * b2.m * v2 * v2;
const px = b1.m * b1.vx + b2.m * b2.vx;
const py = b1.m * b1.vy + b2.m * b2.vy;
const p = Math.hypot(px, py);
return { v1, v2, ke, p, colCount: this._colCount,
before: this._snapBefore, after: this._snapAfter };
}
/* ═══ init ═══ */
_r(m) { return Math.max(16, Math.min(42, 12 + m * 2.2)); }
_initBalls() {
const W = this.c.width || 700, H = this.c.height || 420;
const r1 = this._r(this.m1), r2 = this._r(this.m2);
const gap = Math.max(r1 + r2 + 70, W * 0.30);
const cx = W / 2, cy = H / 2;
const rad = (this.angle * Math.PI) / 180;
const dy2 = Math.tan(rad) * (gap / 2);
this._b = [
{ id:1, m:this.m1, r:r1, x:cx - gap/2, y:cy,
vx:this.v1, vy:0, angle: 0, angVel: 0,
color:'#9B5DE5', rgb:'155,93,229', trail:[],
_trail2: window.LSMotionTrail ? new window.LSMotionTrail({ color:'#9B5DE5', width:2.5, maxLen:120 }) : null },
{ id:2, m:this.m2, r:r2, x:cx + gap/2, y:cy + dy2,
vx:-this.v2 * Math.cos(rad), vy:-this.v2 * Math.sin(rad), angle: 0, angVel: 0,
color:'#06D6E0', rgb:'6,214,224', trail:[],
_trail2: window.LSMotionTrail ? new window.LSMotionTrail({ color:'#06D6E0', width:2.5, maxLen:120 }) : null },
];
this._cooldown = 0;
this._colCount = 0;
this._snapBefore = null;
this._snapAfter = null;
this._frictionWork = 0;
this._energyScale = 0;
}
/* ═══ launch visual burst ═══ */
_spawnLaunchFx() {
const now = performance.now();
for (const b of this._b) {
/* expanding ring per ball */
this._rings.push({ x:b.x, y:b.y, ts:now, kind:'launch',
life:700, maxR:b.r * 4, col:b.rgb });
/* radial spark burst */
for (let k = 0; k < 14; k++) {
this._sparks.push({
kind:'launch',
ang: (k / 14) * Math.PI * 2,
spd: 25 + Math.random() * 35,
x: b.x, y: b.y, ts: now,
col: b.rgb, life: 550,
});
}
}
}
/* ═══ tick / step ═══ */
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (!this.playing) return;
if (this._lastTs === null) this._lastTs = ts;
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
if (window.LabFX) LabFX.particles.update(rawDt);
let dt;
if (this._tc) {
dt = this._tc.advance(rawDt * this.speed);
if (dt === 0) { this.draw(); this._emit(); if (this.playing) this._tick(); return; }
} else {
dt = rawDt * this.speed;
}
this._tSim = (this._tSim || 0) + dt;
/* tick ball motion trails */
if (this.showTCTrails) {
for (const b of this._b) { if (b._trail2) b._trail2.tick(); }
}
this._step(dt);
this.draw();
this._emit();
if (window.LSGraphPanel && this._graphsOn && this._graphUI) {
const [b1, b2] = this._b;
this._graphUI.push(this._tSim || 0, [b1 ? Math.hypot(b1.vx, b1.vy) : 0, b2 ? Math.hypot(b2.vx, b2.vy) : 0, 0]);
}
if (this.playing) this._tick();
});
}
_step(dt) {
const W = this.c.width, H = this.c.height;
const [b1, b2] = this._b;
if (this._cooldown > 0) this._cooldown--;
/* trails */
for (const b of this._b) {
b.trail.push({ x: b.x, y: b.y, spd: Math.hypot(b.vx, b.vy) });
if (b.trail.length > 90) b.trail.shift();
if (this.showTCTrails && b._trail2) b._trail2.push(b.x, b.y);
}
/* centre-of-mass trail */
const M = b1.m + b2.m;
const cmx = (b1.m * b1.x + b2.m * b2.x) / M;
const cmy = (b1.m * b1.y + b2.m * b2.y) / M;
this._cmTrail.push({ x: cmx, y: cmy });
if (this._cmTrail.length > 180) this._cmTrail.shift();
/* integrate */
for (const b of this._b) {
b.x += b.vx * dt; b.y += b.vy * dt;
b.angle += (b.angVel || 0) * dt;
if (b.angVel) b.angVel *= 0.997; // rotational air drag
}
/* wall bounces — when merged, use combined radius and bounce both */
if (this._merged) {
const r = this._mergeR;
const fx = [
b1.x - r < 0 ? [b => { b.x = r; b.vx = Math.abs(b.vx); }, 'L'] : null,
b1.x + r > W ? [b => { b.x = W - r; b.vx = -Math.abs(b.vx); }, 'R'] : null,
b1.y - r < 0 ? [b => { b.y = r; b.vy = Math.abs(b.vy); }, 'T'] : null,
b1.y + r > H ? [b => { b.y = H - r; b.vy = -Math.abs(b.vy); }, 'B'] : null,
].filter(Boolean);
for (const [fn, side] of fx) {
fn(b1); fn(b2); this._wallFx(b1, side);
}
} else {
const eW = Math.max(0.5, this.e); // wall restitution (at least 0.5)
const wallMu = 0.18; // wall surface friction spin
for (const b of this._b) {
if (b.x - b.r < 0) { b.x = b.r; const vn = Math.abs(b.vx); b.vx = vn * eW; b.angVel -= b.vy * wallMu / b.r; this._wallFx(b, 'L'); }
if (b.x + b.r > W) { b.x = W - b.r; const vn = Math.abs(b.vx); b.vx = -vn * eW; b.angVel += b.vy * wallMu / b.r; this._wallFx(b, 'R'); }
if (b.y - b.r < 0) { b.y = b.r; const vn = Math.abs(b.vy); b.vy = vn * eW; b.angVel += b.vx * wallMu / b.r; this._wallFx(b, 'T'); }
if (b.y + b.r > H) { b.y = H - b.r; const vn = Math.abs(b.vy); b.vy = -vn * eW; b.angVel -= b.vx * wallMu / b.r; this._wallFx(b, 'B'); }
}
}
/* ball–ball collision — skip when already merged */
if (this._cooldown === 0 && !this._merged) {
const dx = b2.x - b1.x, dy = b2.y - b1.y;
const dist = Math.hypot(dx, dy);
const min = b1.r + b2.r;
if (dist < min && dist > 0.01) {
const nx = dx / dist, ny = dy / dist;
const dvn = (b1.vx - b2.vx) * nx + (b1.vy - b2.vy) * ny;
if (dvn > 0) {
if (this._colCount === 0) this._snapBefore = this._snapshot();
/* save pre-collision ghost arrows */
const _gnow = performance.now();
this._ghostArrows = this._b.map(b => ({
x: b.x, y: b.y, vx: b.vx, vy: b.vy, rgb: b.rgb, ts: _gnow,
}));
const isPerfectlyInelastic = this.e < 0.02;
if (isPerfectlyInelastic) {
/* ── MERGE: conservation of momentum, balls stick ── */
const M = b1.m + b2.m;
const mvx = (b1.m * b1.vx + b2.m * b2.vx) / M;
const mvy = (b1.m * b1.vy + b2.m * b2.vy) / M;
b1.vx = b2.vx = mvx;
b1.vy = b2.vy = mvy;
this._merged = true;
this._mergeR = Math.sqrt(b1.r * b1.r + b2.r * b2.r);
this._mergeNormal = { nx, ny };
} else {
const J = (1 + this.e) * dvn / (1 / b1.m + 1 / b2.m);
b1.vx -= J * nx / b1.m; b1.vy -= J * ny / b1.m;
b2.vx += J * nx / b2.m; b2.vy += J * ny / b2.m;
/* tangential friction spin */
const tx = -ny, ty = nx;
const vRelT = (b1.vx - b2.vx) * tx + (b1.vy - b2.vy) * ty;
if (Math.abs(vRelT) > 0.5) {
const frMu = 0.32;
const jtMax = frMu * Math.abs(J);
const jt = -Math.sign(vRelT) * Math.min(jtMax, Math.abs(vRelT) / (1/b1.m + 1/b2.m));
b1.vx += jt * tx / b1.m; b1.vy += jt * ty / b1.m;
b2.vx -= jt * tx / b2.m; b2.vy -= jt * ty / b2.m;
const I1 = 0.4 * b1.m * b1.r * b1.r;
const I2 = 0.4 * b2.m * b2.r * b2.r;
b1.angVel -= jt * b1.r / I1;
b2.angVel += jt * b2.r / I2;
}
}
this._snapAfter = this._snapshot();
/* track inelastic energy loss for energy bars */
if (this._energyOn && this._snapBefore && this._snapAfter) {
const sumKE = arr => arr.reduce((s, b) => s + b.ke, 0);
const dKE = sumKE(this._snapBefore) - sumKE(this._snapAfter);
if (dKE > 0) this._frictionWork += dKE;
}
this._colCount++;
this._cooldown = 8;
const ix = (b1.x + b2.x) / 2, iy = (b1.y + b2.y) / 2;
this._spawnCollisionFx(ix, iy, nx, ny, dvn);
/* squish (even on merge — visible one frame) */
this._squish[0] = { ts: performance.now(), nx, ny, dv: dvn };
this._squish[1] = { ts: performance.now(), nx: -nx, ny: -ny, dv: dvn };
}
/* overlap resolution */
const ov = min - dist;
b1.x -= nx * ov / 2; b1.y -= ny * ov / 2;
b2.x += nx * ov / 2; b2.y += ny * ov / 2;
}
}
/* if merged, lock both balls to centre-of-mass */
if (this._merged) {
const M = b1.m + b2.m;
const cmx = (b1.m * b1.x + b2.m * b2.x) / M;
const cmy = (b1.m * b1.y + b2.m * b2.y) / M;
b1.x = b2.x = cmx;
b1.y = b2.y = cmy;
b2.vx = b1.vx;
b2.vy = b1.vy;
/* sync rotation of merged body */
const avgAng = (b1.angVel * b1.m + b2.angVel * b2.m) / M;
b1.angVel = b2.angVel = avgAng;
b1.angle += avgAng * dt; b2.angle = b1.angle;
}
/* expire effects */
const now = performance.now();
this._rings = this._rings.filter(r => (now - r.ts) < (r.life || 900));
this._sparks = this._sparks.filter(sp => (now - sp.ts) < (sp.life || 800));
this._dust = this._dust.filter(d => (now - d.ts) < 1400);
for (let i = 0; i < 2; i++) {
if (this._squish[i] && (now - this._squish[i].ts) > 300) this._squish[i] = null;
}
}
_wallFx(b, side) {
const now = performance.now();
const count = 6;
const baseAng = { L: 0, R: Math.PI, T: Math.PI / 2, B: -Math.PI / 2 }[side] ?? 0;
for (let k = 0; k < count; k++) {
this._sparks.push({
kind: 'wall',
ang: baseAng + (Math.random() - 0.5) * Math.PI,
spd: 10 + Math.random() * 20,
x: b.x, y: b.y, ts: now,
col: b.rgb, life: 400,
});
}
}
_spawnCollisionFx(ix, iy, nx, ny, dvn) {
const now = performance.now();
const intensity = Math.min(1, dvn / 22);
this._impactPt = { x: ix, y: iy, ts: now, intensity };
/* LabFX: collision effects */
if (window.LabFX) {
LabFX.sound.play('bounce');
LabFX.particles.emit({
ctx: this.ctx, x: ix, y: iy,
count: 12, color: '#FFF', speed: 90,
spread: Math.PI * 2, life: 400,
glow: true, shape: 'spark', size: 2,
});
LabFX.shake(this.c, { intensity: 3, durMs: 150 });
}
/* 4 expanding rings (different radii, colors, lifetimes) */
const ringDefs = [
{ life:1400, col:'255,255,255', maxR:160 },
{ life:1000, col:'155,93,229', maxR: 90 },
{ life: 750, col:'6,214,224', maxR: 65 },
{ life: 500, col:'241,91,181', maxR: 42 },
];
for (const rd of ringDefs) {
this._rings.push({ x:ix, y:iy, ts:now, kind:'collision',
life:rd.life, col:rd.col, maxR:rd.maxR });
}
/* 40 sparks — multi-color, gravity arc */
const pal = ['255,255,255','255,220,70','155,93,229','6,214,224','241,91,181'];
for (let k = 0; k < 40; k++) {
this._sparks.push({
kind: 'collision',
ang: (k / 40) * Math.PI * 2 + (Math.random() - 0.5) * 0.35,
spd: (28 + Math.random() * 95) * (0.55 + intensity * 0.45),
len: 0.3 + Math.random() * 0.7,
x: ix, y: iy, ts: now,
col: pal[Math.floor(Math.random() * pal.length)],
grav: 35 + Math.random() * 65,
life: 900,
});
}
/* dust cloud — tiny slow particles */
for (let k = 0; k < 20; k++) {
this._dust.push({
ang: Math.random() * Math.PI * 2,
spd: 4 + Math.random() * 12,
x: ix + (Math.random() - 0.5) * 6,
y: iy + (Math.random() - 0.5) * 6,
r: 1 + Math.random() * 2.5,
ts: now,
grav: 8 + Math.random() * 18,
});
}
}
/* ═══ snapshot ═══ */
_snapshot() {
return this._b.map(b => {
const spd = Math.hypot(b.vx, b.vy);
return { m: b.m, vx: b.vx, vy: b.vy, spd, ke: 0.5 * b.m * spd * spd };
});
}
_emit() { if (this.onUpdate) this.onUpdate(this.stats()); }
toggleGraphs(canvasOuter) {
if (!window.LSGraphPanelUI) return false;
this._graphsOn = !this._graphsOn;
if (this._graphsOn) {
this._tSim = 0;
this._graphUI = new GraphPanelUI(canvasOuter, {
maxPoints: 400,
traces: ['v1', 'v2', 'cm'],
labels: ['|v1|', '|v2|', 'v_цм'],
units: ['м/с', 'м/с', 'м/с'],
colors: ['#9B5DE5', '#06D6E0', '#FFD166'],
toggleBtnId: 'btn-coll-graphs',
title: 'Скорости'
});
this._graphUI.isOn = true;
this._graphUI._build();
} else {
if (this._graphUI) { this._graphUI._destroy(); this._graphUI = null; }
}
return this._graphsOn;
}
/* ═══ RENDER ═══ */
draw() {
const W = this.c.width, H = this.c.height;
if (!W || !H || this._b.length < 2) return;
const ctx = this.ctx;
const now = performance.now();
/* ── 1. Background ── */
/* radial dark-violet base */
const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.hypot(W, H) / 1.7);
bg.addColorStop(0, '#130e22');
bg.addColorStop(1, '#080812');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
/* impact bg flash */
if (this._impactPt) {
const el = (now - this._impactPt.ts) / 220;
if (el < 1) {
const fa = Math.pow(1 - el, 2) * (this._impactPt.intensity || 0.5) * 0.22;
const fg = ctx.createRadialGradient(
this._impactPt.x, this._impactPt.y, 0,
this._impactPt.x, this._impactPt.y, Math.hypot(W, H));
fg.addColorStop(0, `rgba(255,255,255,${fa})`);
fg.addColorStop(0.4, `rgba(155,93,229,${fa * 0.5})`);
fg.addColorStop(1, 'transparent');
ctx.fillStyle = fg;
ctx.fillRect(0, 0, W, H);
}
}
/* ── 2. Grid ── */
ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1;
for (let x = 60; x < W; x += 60) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
for (let y = 60; y < H; y += 60) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
/* ── 3. Arena border (pulses on impact) ── */
let borderAlpha = 0.18;
if (this._impactPt) {
const el = (now - this._impactPt.ts) / 500;
if (el < 1) borderAlpha = 0.18 + (1 - el) * 0.55;
}
ctx.strokeStyle = `rgba(155,93,229,${borderAlpha})`;
ctx.lineWidth = 2;
ctx.strokeRect(2, 2, W - 4, H - 4);
/* ── 4. Center dashes ── */
ctx.strokeStyle = 'rgba(255,255,255,.06)'; ctx.lineWidth = 1;
ctx.setLineDash([6, 7]);
ctx.beginPath(); ctx.moveTo(W / 2, 0); ctx.lineTo(W / 2, H); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2); ctx.stroke();
ctx.setLineDash([]);
/* ── 5. Idle pulsing halos ── */
if (!this.playing && this._colCount === 0) {
const pulse = 0.5 + 0.5 * Math.sin(now / 420);
for (const b of this._b) {
const [r, g, bl] = b.rgb.split(',').map(Number);
for (let ring = 0; ring < 2; ring++) {
const ph = pulse * (ring === 0 ? 1 : 1 - pulse);
const hg = ctx.createRadialGradient(b.x, b.y, b.r * (1 + ring * 0.7),
b.x, b.y, b.r * (3.2 + ring));
hg.addColorStop(0, `rgba(${r},${g},${bl},${0.28 * ph})`);
hg.addColorStop(0.5, `rgba(${r},${g},${bl},${0.08 * ph})`);
hg.addColorStop(1, 'transparent');
ctx.fillStyle = hg;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r * (3.2 + ring), 0, Math.PI * 2);
ctx.fill();
}
}
/* dashed approach lines */
for (const b of this._b) {
const spd = Math.hypot(b.vx, b.vy);
if (spd < 0.01) continue;
const nx = b.vx / spd, ny = b.vy / spd;
const opa = 0.12 + 0.1 * Math.sin(now / 420);
ctx.strokeStyle = `rgba(255,255,255,${opa})`;
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 5]);
ctx.beginPath(); ctx.moveTo(b.x, b.y);
ctx.lineTo(b.x + nx * 60, b.y + ny * 60); ctx.stroke();
ctx.setLineDash([]);
}
}
/* ── 6. Launch burst (first 600 ms after play()) ── */
if (this._launchTs) {
const le = Math.min(1, (now - this._launchTs) / 600);
if (le < 1) {
for (const b of this._b) {
const [r, g, bl] = b.rgb.split(',').map(Number);
/* expanding ring */
const lR = le * b.r * 5.5;
const lAlph = (1 - le) * 0.75;
ctx.strokeStyle = `rgba(${r},${g},${bl},${lAlph})`;
ctx.lineWidth = 3 * (1 - le);
ctx.beginPath(); ctx.arc(b.x, b.y, lR, 0, Math.PI * 2); ctx.stroke();
/* 8 radial spokes */
for (let k = 0; k < 8; k++) {
const ang = (k / 8) * Math.PI * 2;
const inner = b.r + 3;
const outer = b.r + 3 + le * 48;
ctx.strokeStyle = `rgba(${r},${g},${bl},${(1 - le) * 0.65})`;
ctx.lineWidth = 1.8 * (1 - le * 0.6);
ctx.beginPath();
ctx.moveTo(b.x + Math.cos(ang) * inner, b.y + Math.sin(ang) * inner);
ctx.lineTo(b.x + Math.cos(ang) * outer, b.y + Math.sin(ang) * outer);
ctx.stroke();
}
}
}
}
/* ── 7. Trails (speed-colored: blueyellowred) ── */
const _maxSpd = Math.max(this.v1, this.v2, 0.1) * 1.6;
for (const b of this._b) {
for (let i = 1; i < b.trail.length; i++) {
const frac = i / b.trail.length;
const spd = b.trail[i].spd || 0;
const tr = frac * Math.min(8, 1.5 + spd * 0.18);
/* hue: 220 (blue) 60 (yellow) 0 (red) */
const t = Math.min(1, spd / _maxSpd);
const hue = 220 - t * 220;
const sat = 80 + t * 20;
ctx.fillStyle = `hsla(${hue},${sat}%,65%,${frac * 0.6})`;
ctx.beginPath();
ctx.arc(b.trail[i].x, b.trail[i].y, tr, 0, Math.PI * 2);
ctx.fill();
}
}
/* ── 7a. MotionTrail overlay ── */
if (this.showTCTrails) {
for (const b of this._b) { if (b._trail2) b._trail2.draw(ctx); }
}
/* ── 7b. Centre-of-mass trail ── */
for (let i = 1; i < this._cmTrail.length; i++) {
const frac = i / this._cmTrail.length;
const p = this._cmTrail[i];
ctx.fillStyle = `rgba(255,255,255,${frac * 0.35})`;
ctx.beginPath(); ctx.arc(p.x, p.y, 1.5 + frac, 0, Math.PI * 2); ctx.fill();
}
if (this._cmTrail.length > 0) {
const cm = this._cmTrail[this._cmTrail.length - 1];
ctx.strokeStyle = 'rgba(255,255,255,.65)'; ctx.lineWidth = 1.5;
const cs = 7;
ctx.beginPath();
ctx.moveTo(cm.x - cs, cm.y); ctx.lineTo(cm.x + cs, cm.y);
ctx.moveTo(cm.x, cm.y - cs); ctx.lineTo(cm.x, cm.y + cs);
ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,.25)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(cm.x, cm.y, 5, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,.35)';
ctx.font = '8px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('CM', cm.x, cm.y + 8);
}
/* ── 8. Impact rings ── */
for (const ring of this._rings) {
const el = (now - ring.ts) / ring.life;
if (el >= 1) continue;
const maxR = ring.maxR || 110;
const ra = Math.pow(1 - el, ring.kind === 'launch' ? 1.2 : 1.6);
ctx.strokeStyle = `rgba(${ring.col},${ra * 0.7})`;
ctx.lineWidth = (ring.kind === 'launch' ? 1.5 : 2.8) * (1 - el * 0.75);
ctx.beginPath(); ctx.arc(ring.x, ring.y, el * maxR, 0, Math.PI * 2); ctx.stroke();
}
/* ── 9. Sparks ── */
ctx.lineCap = 'round';
for (const sp of this._sparks) {
const el = (now - sp.ts) / sp.life;
if (el >= 1) continue;
const sa = Math.pow(1 - el, sp.kind === 'launch' ? 1.1 : 1.4);
const dist = sp.spd * el;
const grav = sp.grav ? sp.grav * el * el : 0;
const ex = sp.x + Math.cos(sp.ang) * dist;
const ey = sp.y + Math.sin(sp.ang) * dist + grav;
const sx = sp.x + Math.cos(sp.ang) * dist * 0.4;
const sy = sp.y + Math.sin(sp.ang) * dist * 0.4 + grav * 0.16;
ctx.strokeStyle = `rgba(${sp.col},${sa * 0.92})`;
ctx.lineWidth = (sp.len || 1) * (sp.kind === 'launch' ? 1.2 : 2) * (1 - el * 0.4);
ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
/* bright tip */
if (el < 0.45) {
ctx.fillStyle = `rgba(255,255,255,${sa * 0.75})`;
ctx.beginPath(); ctx.arc(ex, ey, 1.4, 0, Math.PI * 2); ctx.fill();
}
}
ctx.lineCap = 'butt';
/* ── 10. Dust cloud ── */
for (const d of this._dust) {
const el = (now - d.ts) / 1400;
if (el >= 1) continue;
const da = Math.pow(1 - el, 2) * 0.55;
const dist = d.spd * el;
const grav = d.grav * el * el;
ctx.fillStyle = `rgba(200,180,255,${da})`;
ctx.beginPath();
ctx.arc(d.x + Math.cos(d.ang) * dist,
d.y + Math.sin(d.ang) * dist + grav,
d.r * (1 + el * 0.5), 0, Math.PI * 2);
ctx.fill();
}
/* ── 11. Central impact flash ── */
if (this._impactPt) {
const el = (now - this._impactPt.ts) / 320;
if (el < 1) {
const fa = Math.pow(1 - el, 2.2);
const fgR = 90 * (1 + el * 0.6);
const fg = ctx.createRadialGradient(
this._impactPt.x, this._impactPt.y, 0,
this._impactPt.x, this._impactPt.y, fgR);
fg.addColorStop(0, `rgba(255,255,255,${fa})`);
fg.addColorStop(0.22, `rgba(255,215,80,${fa * 0.55})`);
fg.addColorStop(0.6, `rgba(155,93,229,${fa * 0.18})`);
fg.addColorStop(1, 'transparent');
ctx.fillStyle = fg;
ctx.beginPath(); ctx.arc(this._impactPt.x, this._impactPt.y, fgR, 0, Math.PI * 2);
ctx.fill();
}
}
/* ── 12. Velocity arrows ── */
for (const b of this._b) {
const [r, g, bl] = b.rgb.split(',').map(Number);
const spd = Math.hypot(b.vx, b.vy);
if (spd < 0.05) continue;
const pLen = Math.min(68, spd * b.m * 0.75 + 8);
const nx = b.vx / spd, ny = b.vy / spd;
const ox = nx * (b.r + 7), oy = ny * (b.r + 7);
_colArrow(ctx,
b.x + ox, b.y + oy,
b.x + ox + nx*pLen, b.y + oy + ny*pLen,
b.color, 2.5);
ctx.save();
ctx.shadowColor = b.color; ctx.shadowBlur = 5;
ctx.fillStyle = `rgba(${r},${g},${bl},.9)`;
ctx.font = 'bold 10px Manrope';
ctx.textAlign = nx > 0 ? 'left' : 'right';
ctx.textBaseline = 'middle';
ctx.fillText(spd.toFixed(1) + ' м/с',
b.x + ox + nx * (pLen + 8),
b.y + oy + ny * (pLen + 8));
ctx.restore();
}
/* ── 12b. Ghost velocity arrows (pre-collision, fade out 1.5 s) ── */
if (this._ghostArrows.length > 0) {
const ghostAge = (now - this._ghostArrows[0].ts) / 1500;
if (ghostAge < 1) {
const alpha = Math.pow(1 - ghostAge, 1.5) * 0.5;
for (const ga of this._ghostArrows) {
const spd = Math.hypot(ga.vx, ga.vy);
if (spd < 0.1) continue;
const pLen = Math.min(64, spd * 2.5 + 8);
const nx = ga.vx / spd, ny = ga.vy / spd;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = `rgba(${ga.rgb},.9)`;
ctx.lineWidth = 2; ctx.setLineDash([5, 4]);
ctx.beginPath();
ctx.moveTo(ga.x, ga.y);
ctx.lineTo(ga.x + nx * pLen, ga.y + ny * pLen);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
} else {
this._ghostArrows = [];
}
}
/* ── 12c. ΔKE loss badge near impact ── */
if (this._snapBefore && this._snapAfter && this._impactPt) {
const keBefore = this._snapBefore.reduce((s, b) => s + b.ke, 0);
const keAfter = this._snapAfter.reduce((s, b) => s + b.ke, 0);
const lossPct = keBefore > 0.1 ? Math.round((1 - keAfter / keBefore) * 100) : 0;
if (lossPct > 0) {
const ix = this._impactPt.x, iy = this._impactPt.y - 42;
const label = 'ΔKE −' + lossPct + '%';
ctx.font = 'bold 10px Manrope';
const tw = ctx.measureText(label).width;
ctx.fillStyle = 'rgba(239,71,111,.18)';
_roundRect(ctx, ix - tw / 2 - 8, iy - 10, tw + 16, 20, 6); ctx.fill();
ctx.strokeStyle = 'rgba(239,71,111,.4)'; ctx.lineWidth = 1;
_roundRect(ctx, ix - tw / 2 - 8, iy - 10, tw + 16, 20, 6); ctx.stroke();
ctx.fillStyle = '#EF476F';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(label, ix, iy);
} else if (lossPct === 0 && keBefore > 0.1) {
const ix = this._impactPt.x, iy = this._impactPt.y - 42;
const label = 'KE сохранена ✓';
ctx.font = 'bold 10px Manrope';
const tw = ctx.measureText(label).width;
ctx.fillStyle = 'rgba(123,245,164,.15)';
_roundRect(ctx, ix - tw / 2 - 8, iy - 10, tw + 16, 20, 6); ctx.fill();
ctx.strokeStyle = 'rgba(123,245,164,.4)'; ctx.lineWidth = 1;
_roundRect(ctx, ix - tw / 2 - 8, iy - 10, tw + 16, 20, 6); ctx.stroke();
ctx.fillStyle = '#7BF5A4';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(label, ix, iy);
}
}
/* ── 12d. Merged-body special rendering ── */
if (this._merged && this._mergeNormal) {
const mn = this._mergeNormal;
const b1_ = this._b[0], b2_ = this._b[1];
const cx = b1_.x, cy = b1_.y; // both at same CM pos
const off = (b1_.r + b2_.r) * 0.52; // visual separation
/* connection beam — shimmering gradient bar */
const bx1 = cx - mn.nx * off, by1 = cy - mn.ny * off;
const bx2 = cx + mn.nx * off, by2 = cy + mn.ny * off;
const pulse = 0.55 + 0.45 * Math.sin(now / 200);
const beamG = ctx.createLinearGradient(bx1, by1, bx2, by2);
beamG.addColorStop(0, `rgba(155,93,229,${pulse * 0.9})`);
beamG.addColorStop(0.5, `rgba(255,255,255,${pulse * 0.55})`);
beamG.addColorStop(1, `rgba(6,214,224,${pulse * 0.9})`);
ctx.save();
ctx.strokeStyle = beamG;
ctx.lineWidth = Math.max(b1_.r, b2_.r) * 0.7;
ctx.lineCap = 'round';
ctx.shadowColor = '#fff'; ctx.shadowBlur = 18 * pulse;
ctx.globalAlpha = 0.7;
ctx.beginPath(); ctx.moveTo(bx1, by1); ctx.lineTo(bx2, by2); ctx.stroke();
ctx.restore();
/* outer merged-body glow */
const gloBig = ctx.createRadialGradient(cx, cy, this._mergeR * 0.3,
cx, cy, this._mergeR * 3.2);
gloBig.addColorStop(0, `rgba(255,220,100,${0.2 * pulse})`);
gloBig.addColorStop(0.4, `rgba(155,93,229,${0.1 * pulse})`);
gloBig.addColorStop(1, 'transparent');
ctx.save(); ctx.fillStyle = gloBig;
ctx.beginPath(); ctx.arc(cx, cy, this._mergeR * 3.2, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
/* ── 13. Balls (with squish deform) ── */
for (let i = 0; i < this._b.length; i++) {
const b = this._b[i];
const [r, g, bl] = b.rgb.split(',').map(Number);
const sq = this._squish[i];
/* visual offset when merged — draw at slightly separated positions */
let drawX = b.x, drawY = b.y;
if (this._merged && this._mergeNormal) {
const mn = this._mergeNormal;
const off = (this._b[0].r + this._b[1].r) * 0.52;
const sign = i === 0 ? -1 : 1;
drawX = b.x + sign * mn.nx * off;
drawY = b.y + sign * mn.ny * off;
}
/* outer glow */
const glo = ctx.createRadialGradient(drawX, drawY, b.r * 0.2, drawX, drawY, b.r * 3.4);
glo.addColorStop(0, `rgba(${r},${g},${bl},.55)`);
glo.addColorStop(0.35,`rgba(${r},${g},${bl},.16)`);
glo.addColorStop(1, 'transparent');
ctx.fillStyle = glo;
ctx.beginPath(); ctx.arc(drawX, drawY, b.r * 3.4, 0, Math.PI * 2); ctx.fill();
/* squish transform */
let sqEl = 0;
let sqAngle = 0;
if (sq) {
sqEl = Math.min(1, (now - sq.ts) / 300);
sqAngle = Math.atan2(sq.ny, sq.nx);
}
ctx.save();
ctx.translate(drawX, drawY);
if (sqEl > 0 && sqEl < 1) {
const squeeze = 1 - 0.38 * Math.sin(sqEl * Math.PI);
ctx.rotate(sqAngle);
ctx.scale(1 + (1 - squeeze) * 0.5, squeeze);
ctx.rotate(-sqAngle);
}
/* body */
const bodyG = ctx.createRadialGradient(
-b.r * 0.33, -b.r * 0.33, b.r * 0.06,
0, 0, b.r);
bodyG.addColorStop(0, '#ffffff');
bodyG.addColorStop(0.18, b.color);
bodyG.addColorStop(1, `rgba(${Math.round(r*0.3)},${Math.round(g*0.3)},${Math.round(bl*0.3)},1)`);
ctx.fillStyle = bodyG;
ctx.beginPath(); ctx.arc(0, 0, b.r, 0, Math.PI * 2); ctx.fill();
/* rim */
ctx.strokeStyle = 'rgba(255,255,255,.5)'; ctx.lineWidth = 1.5;
ctx.stroke();
/* rotation indicator */
const bAng = b.angle || 0;
ctx.strokeStyle = 'rgba(255,255,255,.18)'; ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(Math.cos(bAng) * b.r * 0.78, Math.sin(bAng) * b.r * 0.78);
ctx.stroke();
ctx.beginPath();
ctx.arc(Math.cos(bAng) * b.r * 0.58, Math.sin(bAng) * b.r * 0.58, 2.2, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,.28)'; ctx.fill();
/* primary highlight */
ctx.fillStyle = 'rgba(255,255,255,.25)';
ctx.beginPath();
ctx.ellipse(-b.r * 0.3, -b.r * 0.3, b.r * 0.35, b.r * 0.2, -0.6, 0, Math.PI * 2);
ctx.fill();
/* secondary glint */
ctx.fillStyle = 'rgba(255,255,255,.1)';
ctx.beginPath();
ctx.ellipse(b.r * 0.22, b.r * 0.28, b.r * 0.14, b.r * 0.07, 0.9, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
/* mass label (not squished) */
ctx.fillStyle = 'rgba(255,255,255,.95)';
ctx.font = `bold ${Math.max(10, Math.round(b.r * 0.60))}px Manrope`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(b.m + ' кг', drawX, drawY);
}
/* ── 14. Total momentum vector from CoM ── */
const [b1, b2] = this._b;
const px = b1.m * b1.vx + b2.m * b2.vx;
const py = b1.m * b1.vy + b2.m * b2.vy;
const pMag = Math.hypot(px, py);
if (pMag > 0.1) {
const cmx = (b1.m * b1.x + b2.m * b2.x) / (b1.m + b2.m);
const cmy = (b1.m * b1.y + b2.m * b2.y) / (b1.m + b2.m);
const pLen = Math.min(68, pMag * 1.5);
const pnx = px / pMag, pny = py / pMag;
ctx.save(); ctx.globalAlpha = 0.42;
_colArrow(ctx, cmx, cmy, cmx + pnx * pLen, cmy + pny * pLen, '#F15BB5', 1.5);
ctx.restore();
ctx.fillStyle = 'rgba(241,91,181,.5)';
ctx.font = '9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('p⃗ = ' + pMag.toFixed(1), cmx + pnx * (pLen / 2 + 12), cmy + pny * (pLen / 2));
}
/* ── 15. Collision count badge ── */
if (this._colCount > 0) {
/* badge bg pill */
const txt = 'Столкновений: ' + this._colCount;
ctx.font = 'bold 11px Manrope';
const tw = ctx.measureText(txt).width;
const bx = W - 14 - tw, by = 8;
ctx.fillStyle = 'rgba(255,200,50,.15)';
_roundRect(ctx, bx - 8, by - 2, tw + 16, 20, 6);
ctx.fill();
ctx.strokeStyle = 'rgba(255,200,50,.3)'; ctx.lineWidth = 1;
_roundRect(ctx, bx - 8, by - 2, tw + 16, 20, 6);
ctx.stroke();
ctx.fillStyle = 'rgba(255,200,50,.95)';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText(txt, bx, by);
}
/* ── 15b. СЛИПАНИЕ badge ── */
if (this._merged) {
const pulse2 = 0.65 + 0.35 * Math.sin(now / 380);
const txt2 = 'СЛИПАНИЕ';
ctx.font = 'bold 12px Manrope';
const tw2 = ctx.measureText(txt2).width;
ctx.fillStyle = `rgba(255,220,100,${0.22 * pulse2})`;
_roundRect(ctx, W/2 - tw2/2 - 12, 8, tw2 + 24, 22, 8); ctx.fill();
ctx.strokeStyle = `rgba(255,220,100,${0.55 * pulse2})`; ctx.lineWidth = 1.5;
_roundRect(ctx, W/2 - tw2/2 - 12, 8, tw2 + 24, 22, 8); ctx.stroke();
ctx.fillStyle = `rgba(255,220,100,${pulse2})`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(txt2, W/2, 19);
}
/* ── 16. Speed badge (top-left when speed ≠ 1×) ── */
if (Math.abs(this.speed - 1) > 0.05) {
const label = this.speed.toFixed(2) + '×';
ctx.font = 'bold 11px Manrope';
const tw = ctx.measureText(label).width;
ctx.fillStyle = 'rgba(6,214,224,.12)';
_roundRect(ctx, 8, 8, tw + 14, 20, 6); ctx.fill();
ctx.strokeStyle = 'rgba(6,214,224,.3)'; ctx.lineWidth = 1;
_roundRect(ctx, 8, 8, tw + 14, 20, 6); ctx.stroke();
ctx.fillStyle = 'rgba(6,214,224,.9)';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText(label, 15, 10);
}
/* ── 17. Hover ball tooltip ── */
if (this._hoverBall) {
this._drawBallTooltip(ctx, this._hoverBall, W, H);
}
/* ── 18. FBD velocity / impulse overlays ── */
if (this._fbdOn && window.LSPhysFX) {
for (const b of this._b) {
const spd = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
if (spd > 0.5) {
const vLen = Math.min(60, spd * 3);
LSPhysFX.drawForceArrow(ctx, b.x, b.y - b.r - 8,
(b.vx / spd) * vLen, (b.vy / spd) * vLen,
'velocity', 'v=' + spd.toFixed(1));
}
}
/* impulse flash at impact */
if (this._impactPt) {
const iEl = (performance.now() - this._impactPt.ts) / 300;
if (iEl < 1) {
const ip = this._impactPt;
LSPhysFX.drawForceArrow(ctx, ip.x, ip.y, 0, -40 * (1 - iEl), 'impulse', 'J');
}
}
}
/* ── Energy bars overlay ── */
if (this._energyOn && window.LSPhysFX) {
this._drawEnergyBarsColl(ctx, W, H);
}
/* LabFX: particles overlay */
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
/* ── Energy bars: collision (1D) ── */
_drawEnergyBarsColl(ctx, W, H) {
var ke = 0;
for (var i = 0; i < this._b.length; i++) {
var b = this._b[i];
var spd = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
ke += 0.5 * b.m * spd * spd;
}
var tot = ke + this._frictionWork;
if (tot > this._energyScale) this._energyScale = tot;
var PW = 188, MARGIN = 12;
LSPhysFX.drawEnergyBars(ctx, W - PW - MARGIN, MARGIN, PW, 0,
{ ke: ke, friction: this._frictionWork, total: this._energyScale }, {});
}
/* ── hover inspector ── */
_onMouseMove(e) {
const r = this.c.getBoundingClientRect();
const mx = (e.clientX - r.left) * (this.c.width / r.width);
const my = (e.clientY - r.top) * (this.c.height / r.height);
let found = null;
for (const b of this._b) {
if (Math.hypot(mx - b.x, my - b.y) < b.r + 18) { found = b; break; }
}
if (found !== this._hoverBall) { this._hoverBall = found; this.draw(); }
}
_drawBallTooltip(ctx, b, W, H) {
const [r, g, bl] = b.rgb.split(',').map(Number);
const spd = Math.hypot(b.vx, b.vy);
const ke = 0.5 * b.m * spd * spd;
const p = b.m * spd;
const ang = Math.atan2(b.vy, b.vx) * 180 / Math.PI;
const rows = [
{ label: 'Масса m', val: b.m + ' кг', color: b.color },
{ label: '|v|', val: spd.toFixed(2) + ' м/с', color: '#ffffff' },
{ label: 'vx', val: b.vx.toFixed(2) + ' м/с', color: '#06D6E0' },
{ label: 'vy', val: b.vy.toFixed(2) + ' м/с', color: '#9B5DE5' },
{ label: 'KE', val: ke.toFixed(1) + ' Дж', color: '#FFD166' },
{ label: 'p = mv', val: p.toFixed(1) + ' кг·м/с', color: '#F15BB5' },
{ label: 'угол', val: ang.toFixed(1) + '°', color: 'rgba(255,255,255,.55)' },
{ label: 'ω', val: (b.angVel || 0).toFixed(1) + ' рад/с', color: '#F15BB5' },
];
const padX = 10, padY = 8, lineH = 17;
const tw = 148, th = padY * 2 + rows.length * lineH;
let tx = b.x + b.r + 14, ty = b.y - th / 2;
if (tx + tw > W - 8) tx = b.x - b.r - tw - 14;
if (ty < 8) ty = 8;
if (ty + th > H - 8) ty = H - th - 8;
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,.65)'; ctx.shadowBlur = 14;
ctx.fillStyle = 'rgba(8,8,18,.93)';
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 9); ctx.fill();
ctx.restore();
ctx.strokeStyle = `rgba(${r},${g},${bl},.5)`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 9); ctx.stroke();
ctx.strokeStyle = `rgba(${r},${g},${bl},.8)`; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(tx + 9, ty + 1); ctx.lineTo(tx + tw - 9, ty + 1); ctx.stroke();
ctx.font = '10px Manrope, sans-serif';
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const ry = ty + padY + i * lineH + lineH / 2;
if (i > 0) {
ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(tx + 8, ry - lineH / 2); ctx.lineTo(tx + tw - 8, ry - lineH / 2); ctx.stroke();
}
ctx.fillStyle = 'rgba(255,255,255,.35)'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(row.label, tx + padX, ry);
ctx.fillStyle = row.color; ctx.textAlign = 'right';
ctx.fillText(row.val, tx + tw - padX, ry);
}
}
}
/* ═══════════════════════════════════════════════
Collision2DSim — 2D angle collision (mode 2D)
═══════════════════════════════════════════════ */
class Collision2DSim {
constructor(canvas) {
this.c = canvas;
this.ctx = canvas.getContext('2d');
this.m1 = 4; this.m2 = 4;
this.v1 = 8; this.v2 = 8;
this.a1 = 0; this.a2 = 180; // angles in degrees
this.e = 1;
this.speed = 1;
this.cmFrame = false; // centre-of-mass reference frame toggle
this.playing = false;
this._raf = null;
this._lastTs = null;
this._b = [];
this._sparks = [];
this._rings = [];
this._colCount = 0;
this._cooldown = 0;
this._impactPt = null;
this._snapBefore = null;
this._snapAfter = null;
this.onUpdate = null;
this.onPlayPause = null;
this._activeMode = false;
canvas.addEventListener('click', () => { if (this._activeMode && this.onPlayPause) this.onPlayPause(); });
new ResizeObserver(() => { if (this._activeMode) { this.fit(); this._initBalls(); this.draw(); } }).observe(canvas.parentElement);
}
fit() {
const r = this.c.parentElement.getBoundingClientRect();
this.c.width = r.width || 700;
this.c.height = r.height || 420;
}
getParams() { return { m1:this.m1, m2:this.m2, v1:this.v1, v2:this.v2, a1:this.a1, a2:this.a2, e:this.e }; }
setParams(p) {
if (p.m1 !== undefined) this.m1 = +p.m1;
if (p.m2 !== undefined) this.m2 = +p.m2;
if (p.v1 !== undefined) this.v1 = +p.v1;
if (p.v2 !== undefined) this.v2 = +p.v2;
if (p.a1 !== undefined) this.a1 = +p.a1;
if (p.a2 !== undefined) this.a2 = +p.a2;
if (p.e !== undefined) this.e = +p.e;
this.reset();
}
setSpeed(s) { this.speed = Math.max(0.1, Math.min(4, +s)); }
_r(m) { return Math.max(16, Math.min(42, 12 + m * 2.2)); }
_initBalls() {
const W = this.c.width || 700, H = this.c.height || 420;
const r1 = this._r(this.m1), r2 = this._r(this.m2);
const cx = W / 2, cy = H / 2;
const gap = Math.max(r1 + r2 + 80, W * 0.35);
const rad1 = this.a1 * Math.PI / 180;
const rad2 = this.a2 * Math.PI / 180;
this._b = [
{ id:1, m:this.m1, r:r1,
x: cx - gap/2 * Math.cos(rad1), y: cy - gap/2 * Math.sin(rad1),
vx: this.v1 * Math.cos(rad1), vy: this.v1 * Math.sin(rad1),
color:'#9B5DE5', rgb:'155,93,229', trail:[] },
{ id:2, m:this.m2, r:r2,
x: cx - gap/2 * Math.cos(rad2), y: cy - gap/2 * Math.sin(rad2),
vx: this.v2 * Math.cos(rad2), vy: this.v2 * Math.sin(rad2),
color:'#06D6E0', rgb:'6,214,224', trail:[] },
];
this._cooldown = 0;
this._colCount = 0;
this._snapBefore = null;
this._snapAfter = null;
this._sparks = [];
this._rings = [];
this._impactPt = null;
}
play() {
if (this.playing) return;
this.playing = true; this._lastTs = null;
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
reset() {
this.pause();
this._sparks = []; this._rings = []; this._impactPt = null;
this._initBalls(); this.draw(); this._emit();
}
stats() {
if (this._b.length < 2) return { colCount:0, before:null, after:null };
return { colCount: this._colCount, before: this._snapBefore, after: this._snapAfter };
}
_emit() { if (this.onUpdate) this.onUpdate(this.stats()); }
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (!this.playing) return;
if (this._lastTs === null) this._lastTs = ts;
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
const dt = rawDt * this.speed;
if (window.LabFX) LabFX.particles.update(rawDt);
this._step(dt);
this.draw();
this._emit();
if (this.playing) this._tick();
});
}
_step(dt) {
const W = this.c.width, H = this.c.height;
if (this._cooldown > 0) this._cooldown--;
/* trails */
for (const b of this._b) {
b.trail.push({ x:b.x, y:b.y });
if (b.trail.length > 80) b.trail.shift();
}
/* integrate */
for (const b of this._b) { b.x += b.vx * dt; b.y += b.vy * dt; }
/* wall bounces */
const eW = Math.max(0.5, this.e);
for (const b of this._b) {
if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx) * eW; _c2dWallFx(this, b, 'L'); }
if (b.x + b.r > W) { b.x = W - b.r; b.vx = -Math.abs(b.vx) * eW; _c2dWallFx(this, b, 'R'); }
if (b.y - b.r < 0) { b.y = b.r; b.vy = Math.abs(b.vy) * eW; _c2dWallFx(this, b, 'T'); }
if (b.y + b.r > H) { b.y = H - b.r; b.vy = -Math.abs(b.vy) * eW; _c2dWallFx(this, b, 'B'); }
}
/* ball-ball collision */
if (this._cooldown === 0) {
const [b1, b2] = this._b;
const dx = b2.x - b1.x, dy = b2.y - b1.y;
const dist = Math.hypot(dx, dy);
const min = b1.r + b2.r;
if (dist < min && dist > 0.01) {
const nx = dx / dist, ny = dy / dist;
// relative velocity along normal
const dvn = (b1.vx - b2.vx) * nx + (b1.vy - b2.vy) * ny;
if (dvn > 0) {
if (this._colCount === 0) this._snapBefore = this._snapshot();
/* 2D elastic/inelastic: decompose into normal + tangential */
// v_n1, v_n2 (scalars along n̂)
const v1n = b1.vx * nx + b1.vy * ny;
const v2n = b2.vx * nx + b2.vy * ny;
// tangential components (unchanged)
const v1tx = b1.vx - v1n * nx, v1ty = b1.vy - v1n * ny;
const v2tx = b2.vx - v2n * nx, v2ty = b2.vy - v2n * ny;
// new normal velocities (1D formula with e)
const M = b1.m + b2.m;
const v1nNew = ((b1.m - b2.m * this.e) * v1n + b2.m * (1 + this.e) * v2n) / M;
const v2nNew = ((b2.m - b1.m * this.e) * v2n + b1.m * (1 + this.e) * v1n) / M;
b1.vx = v1tx + v1nNew * nx; b1.vy = v1ty + v1nNew * ny;
b2.vx = v2tx + v2nNew * nx; b2.vy = v2ty + v2nNew * ny;
this._snapAfter = this._snapshot();
this._colCount++;
this._cooldown = 8;
const ix = (b1.x + b2.x) / 2, iy = (b1.y + b2.y) / 2;
this._impactPt = { x:ix, y:iy, ts:performance.now() };
_c2dSpawnFx(this, ix, iy);
}
/* overlap fix */
const ov = min - dist;
b1.x -= nx * ov / 2; b1.y -= ny * ov / 2;
b2.x += nx * ov / 2; b2.y += ny * ov / 2;
}
}
const now = performance.now();
this._rings = this._rings.filter(r => (now - r.ts) < (r.life || 900));
this._sparks = this._sparks.filter(s => (now - s.ts) < (s.life || 800));
}
_snapshot() {
return this._b.map(b => {
const spd = Math.hypot(b.vx, b.vy);
return { m:b.m, vx:b.vx, vy:b.vy, spd, ke: 0.5 * b.m * spd * spd };
});
}
draw() {
const W = this.c.width, H = this.c.height;
if (!W || !H || this._b.length < 2) return;
const ctx = this.ctx;
const now = performance.now();
/* bg */
const bg = ctx.createRadialGradient(W/2,H/2,0, W/2,H/2, Math.hypot(W,H)/1.7);
bg.addColorStop(0,'#130e22'); bg.addColorStop(1,'#080812');
ctx.fillStyle = bg; ctx.fillRect(0,0,W,H);
/* grid */
ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1;
for (let x = 60; x < W; x += 60) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); }
for (let y = 60; y < H; y += 60) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
ctx.strokeStyle = 'rgba(155,93,229,.18)'; ctx.lineWidth = 2;
ctx.strokeRect(2,2,W-4,H-4);
/* rings */
for (const ring of this._rings) {
const el = (now - ring.ts) / ring.life;
if (el >= 1) continue;
ctx.strokeStyle = `rgba(${ring.col},${Math.pow(1-el,1.6) * 0.7})`;
ctx.lineWidth = 2.8 * (1 - el * 0.75);
ctx.beginPath(); ctx.arc(ring.x, ring.y, el * (ring.maxR || 90), 0, Math.PI*2); ctx.stroke();
}
/* sparks */
ctx.lineCap = 'round';
for (const sp of this._sparks) {
const el = (now - sp.ts) / sp.life;
if (el >= 1) continue;
const dist = sp.spd * el;
const grav = (sp.grav || 0) * el * el;
const ex = sp.x + Math.cos(sp.ang) * dist;
const ey = sp.y + Math.sin(sp.ang) * dist + grav;
ctx.strokeStyle = `rgba(${sp.col},${Math.pow(1-el,1.4) * 0.9})`;
ctx.lineWidth = 2 * (1 - el * 0.4);
ctx.beginPath(); ctx.moveTo(sp.x, sp.y); ctx.lineTo(ex, ey); ctx.stroke();
}
ctx.lineCap = 'butt';
/* trails */
for (const b of this._b) {
for (let i = 1; i < b.trail.length; i++) {
const frac = i / b.trail.length;
ctx.fillStyle = `rgba(${b.rgb},${frac * 0.45})`;
ctx.beginPath();
ctx.arc(b.trail[i].x, b.trail[i].y, frac * 4, 0, Math.PI*2);
ctx.fill();
}
}
/* balls */
for (const b of this._b) {
const [r, g, bl] = b.rgb.split(',').map(Number);
/* glow */
const glo = ctx.createRadialGradient(b.x,b.y, b.r*0.2, b.x,b.y, b.r*3.2);
glo.addColorStop(0, `rgba(${r},${g},${bl},.5)`);
glo.addColorStop(1, 'transparent');
ctx.fillStyle = glo; ctx.beginPath(); ctx.arc(b.x,b.y, b.r*3.2, 0, Math.PI*2); ctx.fill();
/* body */
const bodyG = ctx.createRadialGradient(-b.r*0.33+b.x, -b.r*0.33+b.y, b.r*0.06, b.x, b.y, b.r);
bodyG.addColorStop(0,'#ffffff'); bodyG.addColorStop(0.18, b.color);
bodyG.addColorStop(1, `rgba(${Math.round(r*0.3)},${Math.round(g*0.3)},${Math.round(bl*0.3)},1)`);
ctx.fillStyle = bodyG; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,.5)'; ctx.lineWidth=1.5; ctx.stroke();
/* label */
ctx.fillStyle='rgba(255,255,255,.95)';
ctx.font = `bold ${Math.max(10,Math.round(b.r*0.6))}px Manrope`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(b.m + ' кг', b.x, b.y);
/* velocity arrow */
const spd = Math.hypot(b.vx, b.vy);
if (spd > 0.05) {
const nx = b.vx/spd, ny = b.vy/spd;
const pLen = Math.min(68, spd * b.m * 0.75 + 8);
_colArrow(ctx, b.x+nx*(b.r+7), b.y+ny*(b.r+7),
b.x+nx*(b.r+7+pLen), b.y+ny*(b.r+7+pLen), b.color, 2.5);
}
}
/* HUD — momentum px,py + KE before/after */
_c2dDrawHUD(ctx, this, W, H, now);
/* CM frame label */
if (this.cmFrame) {
ctx.fillStyle = 'rgba(255,200,50,.7)';
ctx.font = 'bold 11px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('Система ЦМ', W/2, 8);
}
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
}
/* ═══ Collision2DSim helpers ═══ */
function _c2dWallFx(sim, b, side) {
const now = performance.now();
const baseAng = { L:0, R:Math.PI, T:Math.PI/2, B:-Math.PI/2 }[side] ?? 0;
for (let k = 0; k < 6; k++) {
sim._sparks.push({ ang: baseAng + (Math.random()-0.5)*Math.PI, spd: 10+Math.random()*20,
x:b.x, y:b.y, ts:now, col:b.rgb, life:400 });
}
}
function _c2dSpawnFx(sim, ix, iy) {
const now = performance.now();
if (window.LabFX) { LabFX.sound.play('bounce'); }
const pal = ['255,255,255','255,220,70','155,93,229','6,214,224','241,91,181'];
for (let k = 0; k < 30; k++) {
sim._sparks.push({ ang:(k/30)*Math.PI*2+(Math.random()-0.5)*0.4, spd:25+Math.random()*75,
x:ix, y:iy, ts:now, col:pal[k%pal.length], life:800, grav:30+Math.random()*50 });
}
const ringDefs = [
{life:1200,col:'255,255,255',maxR:130},{life:800,col:'155,93,229',maxR:80},{life:600,col:'6,214,224',maxR:55}
];
for (const rd of ringDefs) sim._rings.push({ x:ix, y:iy, ts:now, life:rd.life, col:rd.col, maxR:rd.maxR });
}
function _c2dDrawHUD(ctx, sim, W, H, now) {
const before = sim._snapBefore, after = sim._snapAfter;
const rows = [];
if (before) {
const px = before.reduce((s,b)=>s+b.m*b.vx,0), py = before.reduce((s,b)=>s+b.m*b.vy,0);
const ke = before.reduce((s,b)=>s+b.ke,0);
rows.push({ label:'p_x до:', val: px.toFixed(2) + ' кг·м/с', c:'#9B5DE5' });
rows.push({ label:'p_y до:', val: py.toFixed(2) + ' кг·м/с', c:'#06D6E0' });
rows.push({ label:'KE до:', val: ke.toFixed(2) + ' Дж', c:'#FFD166' });
}
if (after) {
const px = after.reduce((s,b)=>s+b.m*b.vx,0), py = after.reduce((s,b)=>s+b.m*b.vy,0);
const ke = after.reduce((s,b)=>s+b.ke,0);
rows.push({ label:'p_x после:', val: px.toFixed(2) + ' кг·м/с', c:'#9B5DE5' });
rows.push({ label:'p_y после:', val: py.toFixed(2) + ' кг·м/с', c:'#06D6E0' });
rows.push({ label:'KE после:', val: ke.toFixed(2) + ' Дж', c:'#FFD166' });
}
if (rows.length === 0) return;
const lh = 17, pad = 8, tw = 188, th = pad*2 + rows.length * lh;
const bx = W - tw - 10, by = H - th - 10;
ctx.fillStyle = 'rgba(8,8,18,.88)';
ctx.beginPath(); ctx.roundRect(bx, by, tw, th, 8); ctx.fill();
ctx.strokeStyle = 'rgba(155,93,229,.4)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, tw, th, 8); ctx.stroke();
ctx.font = '10px Manrope';
for (let i = 0; i < rows.length; i++) {
const ry = by + pad + i * lh + lh/2;
ctx.fillStyle = 'rgba(255,255,255,.4)'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(rows[i].label, bx + pad, ry);
ctx.fillStyle = rows[i].c; ctx.textAlign = 'right';
ctx.fillText(rows[i].val, bx + tw - pad, ry);
}
}
/* ═══════════════════════════════════════════════
MultiBodySim — N bodies (mode multi)
═══════════════════════════════════════════════ */
class MultiBodySim {
constructor(canvas) {
this.c = canvas;
this.ctx = canvas.getContext('2d');
this.N = 5;
this.e = 1;
this.speed = 1;
this.cmFrame = false;
this.playing = false;
this._raf = null;
this._lastTs = null;
this._b = [];
this._sparks = [];
this._rings = [];
this._colCount = 0;
this.onUpdate = null;
this.onPlayPause = null;
this._activeMode = false;
canvas.addEventListener('click', () => { if (this._activeMode && this.onPlayPause) this.onPlayPause(); });
new ResizeObserver(() => { if (this._activeMode) { this.fit(); this._initBalls(); this.draw(); } }).observe(canvas.parentElement);
}
fit() {
const r = this.c.parentElement.getBoundingClientRect();
this.c.width = r.width || 700;
this.c.height = r.height || 420;
}
setSpeed(s) { this.speed = Math.max(0.1, Math.min(4, +s)); }
_r(m) { return Math.max(14, Math.min(36, 12 + m * 2)); }
_initBalls() {
const W = this.c.width || 700, H = this.c.height || 420;
const COLORS = [
{ color:'#9B5DE5', rgb:'155,93,229' }, { color:'#06D6E0', rgb:'6,214,224' },
{ color:'#F15BB5', rgb:'241,91,181' }, { color:'#FFD166', rgb:'255,209,102' },
{ color:'#06D6A0', rgb:'6,214,160' }, { color:'#EF476F', rgb:'239,71,111' },
{ color:'#118AB2', rgb:'17,138,178' }, { color:'#FFB347', rgb:'255,179,71' },
{ color:'#B5EAD7', rgb:'181,234,215' }, { color:'#C7CEEA', rgb:'199,206,234' },
];
const masses = [3,4,5,6,5,4,3,5,4,6];
this._b = [];
for (let i = 0; i < this.N; i++) {
const m = masses[i % masses.length];
const r = this._r(m);
const col = COLORS[i % COLORS.length];
let x, y, tries = 0;
do {
x = r + Math.random() * (W - 2*r);
y = r + Math.random() * (H - 2*r);
tries++;
} while (tries < 50 && this._b.some(b => Math.hypot(b.x-x, b.y-y) < b.r + r + 5));
const ang = Math.random() * Math.PI * 2;
const spd = 5 + Math.random() * 10;
this._b.push({ id:i, m, r, x, y, vx:Math.cos(ang)*spd, vy:Math.sin(ang)*spd,
color:col.color, rgb:col.rgb, trail:[] });
}
this._colCount = 0;
this._sparks = [];
this._rings = [];
}
shuffle() {
this._initBalls(); this.draw(); if (this.onUpdate) this.onUpdate(this.stats());
}
play() {
if (this.playing) return;
this.playing = true; this._lastTs = null; this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
reset() { this.pause(); this._initBalls(); this.draw(); if (this.onUpdate) this.onUpdate(this.stats()); }
stats() {
const totalP = { x:0, y:0 }, totalKE = { v:0 };
for (const b of this._b) {
totalP.x += b.m * b.vx; totalP.y += b.m * b.vy;
totalKE.v += 0.5 * b.m * (b.vx*b.vx + b.vy*b.vy);
}
return { colCount: this._colCount, px: totalP.x, py: totalP.y, ke: totalKE.v };
}
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (!this.playing) return;
if (this._lastTs === null) this._lastTs = ts;
const rawDt = Math.min((ts - this._lastTs)/1000, 0.05);
this._lastTs = ts;
const dt = rawDt * this.speed;
if (window.LabFX) LabFX.particles.update(rawDt);
this._step(dt);
this.draw();
if (this.onUpdate) this.onUpdate(this.stats());
if (this.playing) this._tick();
});
}
_step(dt) {
const W = this.c.width, H = this.c.height;
/* apply CM frame: subtract v_cm from display velocities if toggled */
let vcmx = 0, vcmy = 0;
if (this.cmFrame) {
let M = 0;
for (const b of this._b) { M += b.m; vcmx += b.m*b.vx; vcmy += b.m*b.vy; }
vcmx /= M; vcmy /= M;
}
/* trails */
for (const b of this._b) {
b.trail.push({ x:b.x, y:b.y });
if (b.trail.length > 60) b.trail.shift();
}
/* integrate */
for (const b of this._b) { b.x += b.vx * dt; b.y += b.vy * dt; }
/* walls */
const eW = Math.max(0.5, this.e);
for (const b of this._b) {
if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx) * eW; }
if (b.x + b.r > W) { b.x = W - b.r; b.vx = -Math.abs(b.vx) * eW; }
if (b.y - b.r < 0) { b.y = b.r; b.vy = Math.abs(b.vy) * eW; }
if (b.y + b.r > H) { b.y = H - b.r; b.vy = -Math.abs(b.vy) * eW; }
}
/* pair collisions */
const now = performance.now();
for (let i = 0; i < this._b.length; i++) {
for (let j = i+1; j < this._b.length; j++) {
const b1 = this._b[i], b2 = this._b[j];
const dx = b2.x - b1.x, dy = b2.y - b1.y;
const dist = Math.hypot(dx, dy);
const min = b1.r + b2.r;
if (dist < min && dist > 0.01) {
const nx = dx/dist, ny = dy/dist;
const v1n = b1.vx*nx + b1.vy*ny;
const v2n = b2.vx*nx + b2.vy*ny;
const dvn = v1n - v2n;
if (dvn > 0) {
const v1tx = b1.vx - v1n*nx, v1ty = b1.vy - v1n*ny;
const v2tx = b2.vx - v2n*nx, v2ty = b2.vy - v2n*ny;
const M = b1.m + b2.m;
const v1nNew = ((b1.m - b2.m*this.e)*v1n + b2.m*(1+this.e)*v2n)/M;
const v2nNew = ((b2.m - b1.m*this.e)*v2n + b1.m*(1+this.e)*v1n)/M;
b1.vx = v1tx + v1nNew*nx; b1.vy = v1ty + v1nNew*ny;
b2.vx = v2tx + v2nNew*nx; b2.vy = v2ty + v2nNew*ny;
this._colCount++;
const ix = (b1.x+b2.x)/2, iy = (b1.y+b2.y)/2;
if (window.LabFX) LabFX.sound.play('bounce');
for (let k = 0; k < 3; k++) {
this._rings.push({ x:ix, y:iy, ts:now, life:600, col:'255,255,255', maxR:50 });
}
}
const ov = min - dist;
b1.x -= nx*ov/2; b1.y -= ny*ov/2;
b2.x += nx*ov/2; b2.y += ny*ov/2;
}
}
}
this._rings = this._rings.filter(r => (now - r.ts) < (r.life||900));
this._sparks = this._sparks.filter(s => (now - s.ts) < (s.life||800));
}
draw() {
const W = this.c.width, H = this.c.height;
if (!W || !H) return;
const ctx = this.ctx;
const now = performance.now();
/* bg */
const bg = ctx.createRadialGradient(W/2,H/2,0, W/2,H/2, Math.hypot(W,H)/1.7);
bg.addColorStop(0,'#0d0a1a'); bg.addColorStop(1,'#060610');
ctx.fillStyle = bg; ctx.fillRect(0,0,W,H);
ctx.strokeStyle='rgba(255,255,255,.04)'; ctx.lineWidth=1;
for (let x=60;x=1) continue;
ctx.strokeStyle=`rgba(${ring.col},${Math.pow(1-el,1.6)*0.6})`;
ctx.lineWidth=2*(1-el*0.7);
ctx.beginPath(); ctx.arc(ring.x,ring.y,el*(ring.maxR||50),0,Math.PI*2); ctx.stroke();
}
/* trails */
for (const b of this._b) {
for (let i=1;i 0.1) {
const nx=dvx/spd, ny=dvy/spd;
const pLen=Math.min(55,spd*1.8+6);
_colArrow(ctx, b.x+nx*(b.r+5), b.y+ny*(b.r+5), b.x+nx*(b.r+5+pLen), b.y+ny*(b.r+5+pLen), b.color, 2);
}
}
/* HUD: total p + KE */
const st = this.stats();
const pMag = Math.hypot(st.px, st.py);
const hudLines = [
{ l:'p суммарный:', v: pMag.toFixed(2)+' кг·м/с', c:'#F15BB5' },
{ l:'KE суммарная:', v: st.ke.toFixed(1)+' Дж', c:'#FFD166' },
{ l:'Столкновений:', v: String(st.colCount), c:'#fff' },
];
const tw=200, lh=18, pad=8, th=pad*2+hudLines.length*lh;
ctx.fillStyle='rgba(8,8,18,.88)';
ctx.beginPath(); ctx.roundRect(8, H-th-8, tw, th, 8); ctx.fill();
ctx.strokeStyle='rgba(241,91,181,.35)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.roundRect(8, H-th-8, tw, th, 8); ctx.stroke();
ctx.font='10px Manrope';
for (let i=0;i { if (this._activeMode) this._onDown(e); });
canvas.addEventListener('mousemove', e => { if (this._activeMode) this._onMove(e); });
canvas.addEventListener('mouseup', e => { if (this._activeMode) this._onUp(e); });
canvas.addEventListener('mouseleave',() => { if (this._activeMode) { this._drag = null; this._aimEnd = null; this.draw(); } });
new ResizeObserver(() => { if (this._activeMode) { this.fit(); this._initBalls(); this.draw(); } }).observe(canvas.parentElement);
}
fit() {
const r = this.c.parentElement.getBoundingClientRect();
this.c.width = r.width || 700;
this.c.height = r.height || 420;
}
setSpeed(s) { this.speed = Math.max(0.1, Math.min(4, +s)); }
/* Table geometry helpers */
_table() {
const W=this.c.width||700, H=this.c.height||420;
const pad = 36;
return { x:pad, y:pad, w:W-2*pad, h:H-2*pad, W, H, pad };
}
_pockets() {
const t = this._table();
return [
{ x:t.x, y:t.y },
{ x:t.x+t.w/2, y:t.y },
{ x:t.x+t.w, y:t.y },
{ x:t.x, y:t.y+t.h },
{ x:t.x+t.w/2, y:t.y+t.h },
{ x:t.x+t.w, y:t.y+t.h },
];
}
/* Build billiard rack */
_initBalls() {
const t = this._table();
const R = 11;
this._b = [];
this._pocketed = [];
this._rings = []; this._sparks = [];
this._drag = null; this._aimEnd = null; this._cueShot = false;
this._colCount = 0;
/* Cue ball */
this._b.push({ id:0, m:5, r:R, x:t.x+t.w*0.28, y:t.y+t.h*0.5,
vx:0, vy:0, color:'#FFFFFF', rgb:'255,255,255',
isCue:true, pocketed:false, trail:[] });
/* Triangle rack: 6 colored balls */
const COLORS_RACK = ['#F15BB5','#FFD166','#06D6A0','#EF476F','#118AB2','#9B5DE5'];
const RGBS = ['241,91,181','255,209,102','6,214,160','239,71,111','17,138,178','155,93,229'];
const apex = { x: t.x + t.w*0.65, y: t.y + t.h*0.5 };
const rows = [[0],[1,2],[3,4,5]];
let idx = 0;
for (let row = 0; row < rows.length; row++) {
for (let col = 0; col < rows[row].length; col++) {
const px = apex.x + row * (R*2 + 1);
const py = apex.y + (col - (rows[row].length-1)/2) * (R*2 + 1);
this._b.push({ id:idx+1, m:5, r:R, x:px, y:py, vx:0, vy:0,
color:COLORS_RACK[idx], rgb:RGBS[idx],
isCue:false, pocketed:false, trail:[] });
idx++;
}
}
}
reset() { this.pause(); this._initBalls(); this.draw(); if (this.onUpdate) this.onUpdate(this.stats()); }
stats() { return { colCount:this._colCount, pocketed:this._pocketed.length }; }
play() {
if (this.playing) return;
this.playing = true; this._lastTs = null; this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
_canvasXY(e) {
const r = this.c.getBoundingClientRect();
return { x: (e.clientX - r.left)*(this.c.width/r.width),
y: (e.clientY - r.top)*(this.c.height/r.height) };
}
_cueBall() { return this._b.find(b => b.isCue && !b.pocketed); }
_onDown(e) {
const cue = this._cueBall();
if (!cue) return;
const p = this._canvasXY(e);
if (Math.hypot(p.x - cue.x, p.y - cue.y) < cue.r + 20) {
this._drag = { x:cue.x, y:cue.y };
this._aimEnd = p;
this.draw();
}
}
_onMove(e) {
if (!this._drag) return;
this._aimEnd = this._canvasXY(e);
this.draw();
}
_onUp(e) {
if (!this._drag) return;
const cue = this._cueBall();
if (!cue) { this._drag = null; return; }
const p = this._canvasXY(e);
/* shoot in opposite direction of drag (pull-back mechanic) */
const dx = this._drag.x - p.x, dy = this._drag.y - p.y;
const dist = Math.hypot(dx, dy);
if (dist > 4) {
const forceMag = Math.min(dist * 0.35, this.cueForce * 1.2) * (this.cueForce / 15);
cue.vx = (dx / dist) * forceMag;
cue.vy = (dy / dist) * forceMag;
this._cueShot = true;
if (!this.playing) this.play();
}
this._drag = null; this._aimEnd = null;
this.draw();
}
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (!this.playing) return;
if (this._lastTs === null) this._lastTs = ts;
const rawDt = Math.min((ts - this._lastTs)/1000, 0.04);
this._lastTs = ts;
const dt = rawDt * this.speed;
if (window.LabFX) LabFX.particles.update(rawDt);
this._step(dt);
this.draw();
if (this.onUpdate) this.onUpdate(this.stats());
/* stop when all balls nearly still */
const moving = this._b.some(b => !b.pocketed && Math.hypot(b.vx,b.vy) > 0.3);
if (!moving) { this.pause(); this.draw(); }
else if (this.playing) this._tick();
});
}
_step(dt) {
const t = this._table();
const now = performance.now();
const friction = 0.985; // per frame rolling friction
/* trails */
for (const b of this._b) {
if (b.pocketed) continue;
b.trail.push({ x:b.x, y:b.y });
if (b.trail.length > 50) b.trail.shift();
}
/* integrate + friction */
for (const b of this._b) {
if (b.pocketed) continue;
b.x += b.vx * dt; b.y += b.vy * dt;
b.vx *= friction; b.vy *= friction;
if (Math.hypot(b.vx,b.vy) < 0.05) { b.vx = 0; b.vy = 0; }
}
/* wall bounces (cushion) */
for (const b of this._b) {
if (b.pocketed) continue;
if (b.x - b.r < t.x) { b.x = t.x + b.r; b.vx = Math.abs(b.vx)*0.8; }
if (b.x + b.r > t.x + t.w) { b.x = t.x+t.w - b.r; b.vx = -Math.abs(b.vx)*0.8; }
if (b.y - b.r < t.y) { b.y = t.y + b.r; b.vy = Math.abs(b.vy)*0.8; }
if (b.y + b.r > t.y + t.h) { b.y = t.y+t.h - b.r; b.vy = -Math.abs(b.vy)*0.8; }
}
/* ball-ball collisions */
const active = this._b.filter(b => !b.pocketed);
for (let i = 0; i < active.length; i++) {
for (let j = i+1; j < active.length; j++) {
const b1 = active[i], b2 = active[j];
const dx = b2.x-b1.x, dy = b2.y-b1.y;
const dist = Math.hypot(dx,dy);
const min = b1.r + b2.r;
if (dist < min && dist > 0.01) {
const nx=dx/dist, ny=dy/dist;
const dvn = (b1.vx-b2.vx)*nx + (b1.vy-b2.vy)*ny;
if (dvn > 0) {
const M=b1.m+b2.m;
const J = (1+0.85)*dvn / (1/b1.m + 1/b2.m);
b1.vx -= J*nx/b1.m; b1.vy -= J*ny/b1.m;
b2.vx += J*nx/b2.m; b2.vy += J*ny/b2.m;
this._colCount++;
if (window.LabFX) LabFX.sound.play('bounce');
const ix=(b1.x+b2.x)/2, iy=(b1.y+b2.y)/2;
this._rings.push({x:ix,y:iy,ts:now,life:500,col:'255,255,255',maxR:35});
}
const ov=min-dist;
b1.x-=nx*ov/2; b1.y-=ny*ov/2;
b2.x+=nx*ov/2; b2.y+=ny*ov/2;
}
}
}
/* pocket detection */
const pockets = this._pockets();
const PR = 14; // pocket radius
for (const b of this._b) {
if (b.pocketed) continue;
for (const p of pockets) {
if (Math.hypot(b.x-p.x, b.y-p.y) < PR) {
b.pocketed = true; b.vx=0; b.vy=0;
this._pocketed.push(b.id);
if (window.LabFX) LabFX.sound.play('chime');
this._rings.push({x:p.x,y:p.y,ts:now,life:700,col:'255,220,50',maxR:40});
break;
}
}
}
this._rings = this._rings.filter(r=>(now-r.ts)<(r.life||900));
this._sparks = this._sparks.filter(s=>(now-s.ts)<(s.life||800));
}
draw() {
const W=this.c.width||700, H=this.c.height||420;
const ctx=this.ctx, now=performance.now();
const t=this._table();
/* bg */
ctx.fillStyle='#1a1020'; ctx.fillRect(0,0,W,H);
/* table felt */
ctx.fillStyle='#1a5c2e';
ctx.beginPath(); ctx.roundRect(t.x,t.y,t.w,t.h,6); ctx.fill();
/* felt texture lines */
ctx.strokeStyle='rgba(255,255,255,.04)'; ctx.lineWidth=1;
for (let x=t.x+30;x=1) continue;
ctx.strokeStyle=`rgba(${ring.col},${Math.pow(1-el,1.6)*0.8})`;
ctx.lineWidth=2.5*(1-el*0.7);
ctx.beginPath(); ctx.arc(ring.x,ring.y,el*(ring.maxR||40),0,Math.PI*2); ctx.stroke();
}
/* trails */
for (const b of this._b) {
if (b.pocketed) continue;
for (let i=1;i 4) {
const nx=dx/dist, ny=dy/dist;
/* cue stick */
ctx.strokeStyle='rgba(210,160,80,.9)'; ctx.lineWidth=4; ctx.lineCap='round';
ctx.beginPath();
ctx.moveTo(cue.x + nx*(cue.r+8), cue.y + ny*(cue.r+8));
ctx.lineTo(cue.x + nx*(cue.r+8+Math.min(dist*1.2,120)), cue.y + ny*(cue.r+8+Math.min(dist*1.2,120)));
ctx.stroke(); ctx.lineCap='butt';
/* aim trajectory dash */
ctx.strokeStyle='rgba(255,255,255,.3)'; ctx.lineWidth=1; ctx.setLineDash([6,6]);
ctx.beginPath();
ctx.moveTo(cue.x - nx*(cue.r+4), cue.y - ny*(cue.r+4));
ctx.lineTo(cue.x - nx*180, cue.y - ny*180);
ctx.stroke(); ctx.setLineDash([]);
/* power indicator */
const power = Math.min(1, dist * 0.35 / (this.cueForce * 1.2) * (15/this.cueForce));
const pw = 80, ph = 8, px2 = 8, py2 = H - 28;
ctx.fillStyle='rgba(8,8,18,.75)';
ctx.beginPath(); ctx.roundRect(px2,py2,pw,ph,4); ctx.fill();
ctx.fillStyle=`hsl(${120-power*120},85%,55%)`;
ctx.beginPath(); ctx.roundRect(px2,py2,pw*power,ph,4); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,.2)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.roundRect(px2,py2,pw,ph,4); ctx.stroke();
ctx.fillStyle='rgba(255,255,255,.6)'; ctx.font='9px Manrope';
ctx.textAlign='left'; ctx.textBaseline='top';
ctx.fillText('Сила', px2, py2-12);
}
}
}
/* HUD */
const active = this._b.filter(b=>!b.pocketed&&!b.isCue).length;
const hudTxt = `Шаров в лузе: ${this._pocketed.length} | Осталось: ${active}`;
ctx.fillStyle='rgba(8,8,18,.75)';
const htw=ctx.measureText(hudTxt).width+20;
ctx.beginPath(); ctx.roundRect(W-htw-8,8,htw,24,6); ctx.fill();
ctx.fillStyle='rgba(255,255,255,.7)'; ctx.font='11px Manrope';
ctx.textAlign='right'; ctx.textBaseline='middle';
ctx.fillText(hudTxt, W-18, 20);
/* "drag cue" hint */
const cue=this._cueBall();
if (cue && !this.playing && !this._drag) {
ctx.fillStyle='rgba(255,255,255,.4)'; ctx.font='11px Manrope';
ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillText('Тяните от биткa для удара', W/2, t.y+t.h+6);
}
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
}
/* ═══ helpers ═══ */
function _colArrow(ctx, x1, y1, x2, y2, color, lw) {
const ang = Math.atan2(y2 - y1, x2 - x1);
ctx.save();
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lw;
ctx.shadowColor = color; ctx.shadowBlur = 9;
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - 9 * Math.cos(ang - 0.42), y2 - 9 * Math.sin(ang - 0.42));
ctx.lineTo(x2 - 9 * Math.cos(ang + 0.42), y2 - 9 * Math.sin(ang + 0.42));
ctx.closePath(); ctx.fill();
ctx.restore();
}
function _roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y); ctx.arcTo(x+w, y, x+w, y+r, r);
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x+w, y+h, x+w-r, y+h, r);
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y+h, x, y+h-r, r);
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x+r, y, r);
ctx.closePath();
}
/* ─── lab UI init ─────────────────────────────────── */
/* active collision simulation instances (cSim declared in lab-init.js) */
var cSim2D = null; /* 2D mode (Collision2DSim) */
var cSimMB = null; /* multi-body mode (MultiBodySim) */
var cSimBL = null; /* billiard mode (BilliardSim) */
var _collMode = '1d'; /* '1d' | '2d' | 'multi' | 'billiard' */
/* Return whichever sim is currently active */
function _activeSim() {
if (_collMode === '1d') return cSim;
if (_collMode === '2d') return cSim2D;
if (_collMode === 'multi') return cSimMB;
if (_collMode === 'billiard') return cSimBL;
return null;
}
function _openCollision() {
document.getElementById('sim-topbar-title').textContent = 'Столкновение шаров';
_simShow('sim-coll');
_simShow('ctrl-coll');
_registerSimState('collision', () => cSim?.getParams(), st => cSim?.setParams(st));
if (_embedMode) _startStateEmit('collision');
requestAnimationFrame(() => requestAnimationFrame(() => {
const canvas = document.getElementById('coll-canvas');
if (!cSim) {
cSim = new CollisionSim(canvas);
cSim.onUpdate = _collUpdateUI;
cSim.onPlayPause = collPlayPause;
}
if (!cSim2D) {
cSim2D = new Collision2DSim(canvas);
cSim2D.onUpdate = _collUpdateUI;
cSim2D.onPlayPause = collPlayPause;
}
if (!cSimMB) {
cSimMB = new MultiBodySim(canvas);
cSimMB.onUpdate = _collUpdateUI;
cSimMB.onPlayPause = collPlayPause;
}
if (!cSimBL) {
cSimBL = new BilliardSim(canvas);
cSimBL.onUpdate = _collUpdateUI;
cSimBL.onPlayPause = collPlayPause;
}
/* inject mode-selector + mode panels if not yet present */
if (!document.getElementById('coll-mode-bar')) {
_collInjectModeUI();
}
_collMode = '1d';
_collApplyMode();
}));
}
/* Build the mode selector row + mode-specific panels */
function _collInjectModeUI() {
const simBody = document.getElementById('sim-coll');
const bodyWrap = simBody.querySelector('.sim-body-wrap');
const projPanel = bodyWrap ? bodyWrap.querySelector('.proj-panel') : null;
if (!projPanel) return;
/* ── mode selector bar (injected above params) ── */
const modeBar = document.createElement('div');
modeBar.id = 'coll-mode-bar';
modeBar.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px';
modeBar.innerHTML =
'' +
'' +
'' +
'';
projPanel.insertBefore(modeBar, projPanel.firstChild);
/* ── CM-frame toggle (universal) ── */
const cmRow = document.createElement('div');
cmRow.id = 'coll-cm-row';
cmRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin:6px 0;';
cmRow.innerHTML =
'';
projPanel.insertBefore(cmRow, projPanel.firstChild.nextSibling);
/* ── 2D-specific panel ── */
const p2d = document.createElement('div');
p2d.id = 'coll-panel-2d';
p2d.style.display = 'none';
p2d.innerHTML =
'2D — углы движения
' +
'' +
'
' +
'' +
'
' +
'' +
'
' +
'' +
'
' +
'' +
'
' +
'' +
'
' +
'' +
'
';
projPanel.appendChild(p2d);
/* ── Multi-body panel ── */
const pmb = document.createElement('div');
pmb.id = 'coll-panel-multi';
pmb.style.display = 'none';
pmb.innerHTML =
'Multi-ball
' +
'' +
'
' +
'' +
'
' +
'' +
'' +
'
';
projPanel.appendChild(pmb);
/* ── Billiard panel ── */
const pbl = document.createElement('div');
pbl.id = 'coll-panel-billiard';
pbl.style.display = 'none';
pbl.innerHTML =
'Бильярд
' +
'' +
'
' +
'' +
'' +
'
';
projPanel.appendChild(pbl);
/* first fit + draw */
cSim.fit(); cSim.setSpeed(+document.getElementById('sl-speed').value);
collParam(); cSim.draw(); _collUpdateUI(cSim.stats());
/* ── Inject TimeControl bar ── */
_collInjectTCBar();
}
function _collInjectTCBar() {
if (!window.LSTimeControl || !window.LSBuildTimeControlUI) return;
var wrap = document.getElementById('sim-coll');
if (!wrap || wrap.querySelector('.tc-bar')) return;
/* Build a shared TC that controls whichever sim is active */
var proxyTC = {
scale: 1, paused: false,
advance: function(dt) { return this.paused ? 0 : dt * this.scale; },
setScale: function(s) {
this.scale = +s;
/* propagate to all sims */
[cSim, cSim2D, cSimMB, cSimBL].forEach(function(s) { if (s && s._tc) s._tc.setScale(proxyTC.scale); });
},
togglePause: function() {
this.paused = !this.paused;
[cSim, cSim2D, cSimMB, cSimBL].forEach(function(s) { if (s && s._tc) s._tc.paused = proxyTC.paused; });
return this.paused;
},
};
/* Trail toggle */
var trailBtn = document.createElement('button');
trailBtn.className = 'zoom-btn';
trailBtn.style.cssText = 'display:inline-flex;align-items:center;gap:3px;padding:3px 8px;border:1px solid rgba(255,255,255,0.15);border-radius:7px;background:rgba(28,18,48,0.8);color:#ccc;font-size:.68rem;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0;font-family:Manrope,sans-serif';
trailBtn.innerHTML = ' Следы';
trailBtn.title = 'Следы движения';
trailBtn.addEventListener('click', function() {
var on = !((cSim && cSim.showTCTrails));
[cSim, cSim2D, cSimMB, cSimBL].forEach(function(s) { if (s) s.showTCTrails = on; });
trailBtn.style.background = on ? 'rgba(6,214,224,0.2)' : 'rgba(28,18,48,0.8)';
trailBtn.style.color = on ? '#06D6E0' : '#ccc';
trailBtn.style.borderColor = on ? 'rgba(6,214,224,0.5)' : 'rgba(255,255,255,0.15)';
});
/* dummy sim adapter for LSBuildTimeControlUI */
var simProxy = { draw: function() { var a = cSim || cSim2D || cSimMB || cSimBL; if (a) a.draw(); } };
var tcBar = window.LSBuildTimeControlUI(simProxy, proxyTC, { scrubSupported: false });
var sep = document.createElement('div');
sep.style.cssText = 'width:1px;height:18px;background:rgba(255,255,255,0.12);flex-shrink:0';
tcBar.appendChild(sep);
tcBar.appendChild(trailBtn);
var statsBar = wrap.querySelector('.proj-stats-bar');
if (statsBar) wrap.insertBefore(tcBar, statsBar);
else wrap.appendChild(tcBar);
}
/* Switch active mode */
function collSetMode(mode, btn) {
/* pause all */
if (cSim) cSim.pause();
if (cSim2D) cSim2D.pause();
if (cSimMB) cSimMB.pause();
if (cSimBL) cSimBL.pause();
_collMode = mode;
/* update tab buttons */
document.querySelectorAll('.coll-mode-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
/* show/hide mode-specific panels */
const show = id => { const el=document.getElementById(id); if(el) el.style.display=''; };
const hide = id => { const el=document.getElementById(id); if(el) el.style.display='none'; };
const p1d = document.getElementById('coll-params-1d'); /* original param blocks */
const presets1d = document.getElementById('coll-presets-1d');
/* toggle visibility of original 1D controls */
const orig1d = ['sl-m1','sl-m2','sl-cv1','sl-cv2','sl-cangle','sl-e'].map(id => {
const el = document.getElementById(id);
return el ? el.closest('.param-block') : null;
}).filter(Boolean);
orig1d.forEach(el => el.style.display = (mode === '1d') ? '' : 'none');
/* hide presets section if not 1d (find by traversal) */
const presetsDiv = document.querySelector('#sim-coll .gp-section-title + div[style*="flex-wrap"]');
if (presetsDiv) presetsDiv.style.display = (mode === '1d') ? '' : 'none';
const presetsTitle = presetsDiv ? presetsDiv.previousElementSibling : null;
if (presetsTitle) presetsTitle.style.display = (mode === '1d') ? '' : 'none';
/* mode-specific panels */
hide('coll-panel-2d'); hide('coll-panel-multi'); hide('coll-panel-billiard');
if (mode === '2d') show('coll-panel-2d');
if (mode === 'multi') show('coll-panel-multi');
if (mode === 'billiard') show('coll-panel-billiard');
/* show/hide launch+reset buttons (not needed for billiard) */
const launchWrap = document.querySelector('#sim-coll .proj-launch-btn')?.parentElement;
if (launchWrap) launchWrap.style.display = (mode === 'billiard') ? 'none' : '';
/* CM toggle only for 1d/2d/multi */
const cmRow = document.getElementById('coll-cm-row');
if (cmRow) cmRow.style.display = (mode === 'billiard') ? 'none' : '';
_collApplyMode();
}
function _collApplyMode() {
const canvas = document.getElementById('coll-canvas');
if (!canvas) return;
const mode = _collMode;
/* Mark which sim is active so ResizeObserver callbacks skip inactive sims */
if (cSim) cSim._activeMode = (mode === '1d');
if (cSim2D) cSim2D._activeMode = (mode === '2d');
if (cSimMB) cSimMB._activeMode = (mode === 'multi');
if (cSimBL) cSimBL._activeMode = (mode === 'billiard');
if (mode === '1d') {
cSim.fit(); collParam(); cSim.draw(); _collUpdateUI(cSim.stats());
} else if (mode === '2d') {
cSim2D.fit(); coll2DParam(); cSim2D.draw(); _collUpdateUI(cSim2D.stats());
} else if (mode === 'multi') {
cSimMB.fit(); cSimMB._initBalls(); collMBParam(); cSimMB.draw(); _collUpdateUI(cSimMB.stats());
} else if (mode === 'billiard') {
cSimBL.fit(); cSimBL._initBalls(); cSimBL.draw(); _collUpdateUI(cSimBL.stats());
}
_collSyncBtn();
}
/* CM-frame toggle (universal) */
function collCMFrame(on) {
if (cSim2D) cSim2D.cmFrame = on;
if (cSimMB) cSimMB.cmFrame = on;
const act = _activeSim();
if (act && !act.playing) act.draw();
}
function collPlayPause() {
const sim = _activeSim();
if (!sim) return;
if (sim.playing) { sim.pause(); } else { sim.play(); }
_collSyncBtn();
}
function _collSyncBtn() {
const sim = _activeSim();
const playing = sim ? sim.playing : false;
const tb = document.getElementById('coll-play-btn');
const lb = document.getElementById('coll-launch-main');
const lbl = document.getElementById('coll-launch-label');
const lic = document.getElementById('coll-launch-icon');
if (tb) {
tb.innerHTML = playing
? ''
: '';
tb.title = playing ? 'Пауза' : 'Запустить';
tb.classList.toggle('active', playing);
}
if (lb && lbl && lic) {
lb.classList.toggle('paused', playing);
lb.classList.remove('done');
if (playing) {
lic.innerHTML = '';
lbl.textContent = 'Пауза';
} else {
lic.innerHTML = '';
lbl.textContent = 'Запустить';
}
}
}
/* ── 1D param handler (existing) ── */
function collParam() {
const m1 = +document.getElementById('sl-m1').value;
const m2 = +document.getElementById('sl-m2').value;
const v1 = +document.getElementById('sl-cv1').value;
const v2 = +document.getElementById('sl-cv2').value;
const angle = +document.getElementById('sl-cangle').value;
const e = +document.getElementById('sl-e').value;
const spd = +document.getElementById('sl-speed').value;
const el = id => document.getElementById(id);
if (el('c-m1')) el('c-m1').textContent = m1 + ' кг';
if (el('c-m2')) el('c-m2').textContent = m2 + ' кг';
if (el('c-v1')) el('c-v1').textContent = v1 + ' м/с';
if (el('c-v2')) el('c-v2').textContent = v2 + ' м/с';
if (el('c-angle')) el('c-angle').textContent = angle + '°';
if (el('c-e')) el('c-e').textContent = e.toFixed(2);
if (el('c-speed')) el('c-speed').textContent = spd.toFixed(2) + '×';
if (cSim) {
const speedChanged = Math.abs(cSim.speed - spd) > 0.001;
if (speedChanged) cSim.setSpeed(spd);
const physChanged = cSim.m1 !== m1 || cSim.m2 !== m2 ||
cSim.v1 !== v1 || cSim.v2 !== v2 ||
cSim.angle !== angle || cSim.e !== e;
if (physChanged) cSim.setParams({ m1, m2, v1, v2, angle, e });
_collSyncBtn();
}
}
/* ── 2D param handler ── */
function coll2DParam() {
if (!cSim2D) return;
const el = id => document.getElementById(id);
const a1 = +(el('sl-2da1')?.value ?? 0);
const a2 = +(el('sl-2da2')?.value ?? 180);
const m1 = +(el('sl-2dm1')?.value ?? 4);
const m2 = +(el('sl-2dm2')?.value ?? 4);
const v1 = +(el('sl-2dv1')?.value ?? 8);
const v2 = +(el('sl-2dv2')?.value ?? 8);
const e = +(el('sl-2de')?.value ?? 1);
const spd = +(el('sl-speed')?.value ?? 1);
if (el('c2d-a1')) el('c2d-a1').textContent = a1 + '°';
if (el('c2d-a2')) el('c2d-a2').textContent = a2 + '°';
if (el('c2d-m1')) el('c2d-m1').textContent = m1 + ' кг';
if (el('c2d-m2')) el('c2d-m2').textContent = m2 + ' кг';
if (el('c2d-v1')) el('c2d-v1').textContent = v1 + ' м/с';
if (el('c2d-v2')) el('c2d-v2').textContent = v2 + ' м/с';
if (el('c2d-e')) el('c2d-e').textContent = e.toFixed(2);
if (el('c-speed')) el('c-speed').textContent = spd.toFixed(2) + '×';
cSim2D.setSpeed(spd);
const changed = cSim2D.a1!==a1||cSim2D.a2!==a2||cSim2D.m1!==m1||cSim2D.m2!==m2||
cSim2D.v1!==v1||cSim2D.v2!==v2||cSim2D.e!==e;
if (changed) cSim2D.setParams({ a1, a2, m1, m2, v1, v2, e });
_collSyncBtn();
}
/* ── Multi-body param handler ── */
function collMBParam() {
if (!cSimMB) return;
const el = id => document.getElementById(id);
const N = +(el('sl-mb-n')?.value ?? 5);
const e = +(el('sl-mb-e')?.value ?? 1);
const spd = +(el('sl-speed')?.value ?? 1);
if (el('cmb-n')) el('cmb-n').textContent = N;
if (el('cmb-e')) el('cmb-e').textContent = e.toFixed(2);
if (el('c-speed')) el('c-speed').textContent = spd.toFixed(2) + '×';
cSimMB.setSpeed(spd);
const changed = cSimMB.N !== N || cSimMB.e !== e;
if (changed) { cSimMB.N = N; cSimMB.e = e; cSimMB.reset(); }
_collSyncBtn();
}
/* ── Billiard param handler ── */
function collBLParam() {
if (!cSimBL) return;
const el = id => document.getElementById(id);
const force = +(el('sl-bl-force')?.value ?? 15);
const spd = +(el('sl-speed')?.value ?? 1);
if (el('cbl-force')) el('cbl-force').textContent = force;
if (el('c-speed')) el('c-speed').textContent = spd.toFixed(2) + '×';
cSimBL.cueForce = force;
cSimBL.setSpeed(spd);
}
function collPreset(m1, m2, v1, v2, angle, e) {
document.getElementById('sl-m1').value = m1;
document.getElementById('sl-m2').value = m2;
document.getElementById('sl-cv1').value = v1;
document.getElementById('sl-cv2').value = v2;
document.getElementById('sl-cangle').value = angle;
document.getElementById('sl-e').value = e;
collParam();
}
function _collUpdateUI(s) {
if (_collMode === 'billiard') {
/* billiard: show pocketed count */
const el = id => document.getElementById(id);
if (el('cs-pbefore')) el('cs-pbefore').textContent = '—';
if (el('cs-pafter')) el('cs-pafter').textContent = '—';
if (el('cs-kebefore')) el('cs-kebefore').textContent = '—';
if (el('cs-keafter')) el('cs-keafter').textContent = '—';
if (el('cs-count')) el('cs-count').textContent = 'Луз: ' + (s.pocketed || 0);
_collSyncBtn();
return;
}
if (_collMode === 'multi') {
const el = id => document.getElementById(id);
if (el('cs-pbefore')) el('cs-pbefore').textContent = s.px ? s.px.toFixed(2)+' кг·м/с' : '—';
if (el('cs-pafter')) el('cs-pafter').textContent = s.py ? s.py.toFixed(2)+' кг·м/с' : '—';
if (el('cs-kebefore')) el('cs-kebefore').textContent = s.ke ? s.ke.toFixed(1)+' Дж' : '—';
if (el('cs-keafter')) el('cs-keafter').textContent = '—';
if (el('cs-count')) el('cs-count').textContent = s.colCount || 0;
_collSyncBtn();
return;
}
/* 1D / 2D: same before/after display */
function snapKE(arr) { return arr ? arr.reduce((t, b) => t + b.ke, 0) : null; }
function snapP(arr) {
if (!arr) return null;
return Math.hypot(arr.reduce((t, b) => t + b.m * b.vx, 0),
arr.reduce((t, b) => t + b.m * b.vy, 0));
}
const bKE = snapKE(s.before), bP = snapP(s.before);
const aKE = snapKE(s.after), aP = snapP(s.after);
const f2 = v => v !== null ? v.toFixed(2) : '—';
const el = id => document.getElementById(id);
if (el('cs-pbefore')) el('cs-pbefore').textContent = bP !== null ? f2(bP) + ' кг·м/с' : '—';
if (el('cs-pafter')) el('cs-pafter').textContent = aP !== null ? f2(aP) + ' кг·м/с' : '—';
if (el('cs-kebefore')) el('cs-kebefore').textContent = bKE !== null ? f2(bKE) + ' Дж' : '—';
if (el('cs-keafter')) el('cs-keafter').textContent = aKE !== null ? f2(aKE) + ' Дж' : '—';
if (el('cs-count')) el('cs-count').textContent = s.colCount || 0;
_collSyncBtn();
}
/* ── Energy toggle: collision ── */
function collToggleEnergy() {
const as = _activeSim && _activeSim();
if (!as) return;
as._energyOn = !as._energyOn;
const on = as._energyOn;
const btn = document.getElementById('coll-energy-btn');
if (btn) {
btn.classList.toggle('active', on);
btn.querySelector('span').textContent = on ? 'Энергия: Вкл' : 'Энергия: Выкл';
}
if (!on) { as._frictionWork = 0; as._energyScale = 0; }
as.draw();
}
/* ── magnetic ── */
function collToggleGraphs() {
const as = _activeSim && _activeSim();
if (!as || typeof as.toggleGraphs !== 'function') return;
const canvasOuter = document.querySelector('#sim-coll .proj-canvas-outer');
if (!canvasOuter) return;
const on = as.toggleGraphs(canvasOuter);
const btn = document.getElementById('btn-coll-graphs');
if (btn) btn.classList.toggle('active', on);
}