ae31e4c4e8
lab-init.js: 4098 -> 543 lines (infrastructure + THEORY only) Each sim's _open*() + UI helpers moved to its engine file: graph.js, projectile.js, collision.js, magnetic.js, triangle.js, geometry.js, trigcircle.js, gas.js (molphys), coulomb.js, circuit.js, reactions.js (chemistry), newton.js (dynamics), chemsandbox.js, celldivision.js, photosynthesis.js, angrybirds.js, quadratic.js, normaldist.js, graphtransform.js, pendulum.js, equilibrium.js, thinlens.js, mirror.js, isoprocess.js, titration.js, refraction.js, probability.js, bohratom.js, electrolysis.js, waves.js, crystal.js, orbitals.js, stereo.js, hydrostatics.js All 34 engine files syntax-checked OK.
1131 lines
43 KiB
JavaScript
1131 lines
43 KiB
JavaScript
'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;
|
||
|
||
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:[] },
|
||
{ 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:[] },
|
||
];
|
||
|
||
this._cooldown = 0;
|
||
this._colCount = 0;
|
||
this._snapBefore = null;
|
||
this._snapAfter = null;
|
||
}
|
||
|
||
/* ═══ 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 dt = Math.min((ts - this._lastTs) / 1000, 0.05) * this.speed;
|
||
this._lastTs = ts;
|
||
this._step(dt);
|
||
this.draw();
|
||
this._emit();
|
||
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();
|
||
}
|
||
|
||
/* 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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 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();
|
||
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 };
|
||
|
||
/* 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()); }
|
||
|
||
/* ═══ 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: blue<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>yellow<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>red) ── */
|
||
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) <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 60 (yellow) <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 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();
|
||
}
|
||
}
|
||
|
||
/* ── 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 сохранена <svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>';
|
||
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);
|
||
}
|
||
}
|
||
|
||
/* ── 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ═══ 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 ─────────────────────────────────── */
|
||
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(() => {
|
||
if (!cSim) {
|
||
cSim = new CollisionSim(document.getElementById('coll-canvas'));
|
||
cSim.onUpdate = _collUpdateUI;
|
||
cSim.onPlayPause = collPlayPause;
|
||
}
|
||
cSim.fit();
|
||
cSim.setSpeed(+document.getElementById('sl-speed').value);
|
||
collParam();
|
||
cSim.draw();
|
||
_collUpdateUI(cSim.stats());
|
||
}));
|
||
}
|
||
|
||
function collPlayPause() {
|
||
if (!cSim) return;
|
||
if (cSim.playing) { cSim.pause(); } else { cSim.play(); }
|
||
_collSyncBtn();
|
||
}
|
||
|
||
function _collSyncBtn() {
|
||
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 (!cSim) return;
|
||
const playing = cSim.playing;
|
||
|
||
if (tb) {
|
||
tb.innerHTML = playing
|
||
? '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>'
|
||
: '<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
|
||
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 = '<rect x="5" y="3" width="4" height="18"/><rect x="15" y="3" width="4" height="18"/>';
|
||
lbl.textContent = 'Пауза';
|
||
} else {
|
||
lic.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
|
||
lbl.textContent = 'Запустить';
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
document.getElementById('c-m1').textContent = m1 + ' кг';
|
||
document.getElementById('c-m2').textContent = m2 + ' кг';
|
||
document.getElementById('c-v1').textContent = v1 + ' м/с';
|
||
document.getElementById('c-v2').textContent = v2 + ' м/с';
|
||
document.getElementById('c-angle').textContent = angle + '°';
|
||
document.getElementById('c-e').textContent = e.toFixed(2);
|
||
document.getElementById('c-speed').textContent = spd.toFixed(2) + '×';
|
||
|
||
if (cSim) {
|
||
/* speed change doesn't require a reset */
|
||
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();
|
||
}
|
||
}
|
||
|
||
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) {
|
||
// before/after are arrays [{m, vx, vy, ke}, ...]
|
||
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) : '—';
|
||
|
||
document.getElementById('cs-pbefore').textContent = bP !== null ? f2(bP) + ' кг·м/с' : '—';
|
||
document.getElementById('cs-pafter').textContent = aP !== null ? f2(aP) + ' кг·м/с' : '—';
|
||
document.getElementById('cs-kebefore').textContent = bKE !== null ? f2(bKE) + ' Дж' : '—';
|
||
document.getElementById('cs-keafter').textContent = aKE !== null ? f2(aKE) + ' Дж' : '—';
|
||
document.getElementById('cs-count').textContent = s.colCount;
|
||
_collSyncBtn();
|
||
}
|
||
|
||
/* ── magnetic ── */
|
||
|