'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 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(); 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: 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(); } } /* ── 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); } } /* ── 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(); }