'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 — углы движения
' + '
Угол v₁
' + '
' + '
Угол v₂180°
' + '
' + '
Масса m₁4 кг
' + '
' + '
Масса m₂4 кг
' + '
' + '
|v₁|8 м/с
' + '
' + '
|v₂|8 м/с
' + '
' + '
Упругость e1.00
' + '
'; projPanel.appendChild(p2d); /* ── Multi-body panel ── */ const pmb = document.createElement('div'); pmb.id = 'coll-panel-multi'; pmb.style.display = 'none'; pmb.innerHTML = '
Multi-ball
' + '
Шаров N5
' + '
' + '
Упругость e1.00
' + '
' + '
' + '' + '
'; projPanel.appendChild(pmb); /* ── Billiard panel ── */ const pbl = document.createElement('div'); pbl.id = 'coll-panel-billiard'; pbl.style.display = 'none'; pbl.innerHTML = '
Бильярд
' + '
Сила удара15
' + '
' + '
' + '' + '
'; 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); }