6afe928c0d
ФУНДАМЕНТ (4 новых файла): - _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake - _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust) - _fx_motion.js: tween + 12 easings + critically-damped spring - _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API - Sound toggle в шапке lab.html с localStorage-persist UX МИКРО (CSS + JS): - Button states: hover scale+brightness, active scale-down, disabled grayscale - Slider polish: custom thumb с тенью, filled-track gradient, hover/active - Focus rings через :focus-visible - Tooltip system .tt-host data-tt= с 400ms hover, fade-in - Marching ants для selection - Loading skeleton с shimmer - Empty state .sim-empty-* паттерн - Toast: progress bar внизу, icons по типу - Cursor states utility classes - View Transitions API для smooth sim-switch, fallback на CSS fade PHASE 2 — визуальные эффекты для 33 симуляций: Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks) Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds) Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow) Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click) Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow) Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям) Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1694 lines
66 KiB
JavaScript
1694 lines
66 KiB
JavaScript
'use strict';
|
||
/* ════════════════════════════════════════════════════════════════
|
||
NewtonSim — три закона Ньютона
|
||
Закон I : A — скользящий блок, B — шар на нити
|
||
Закон II : A — один блок F=ma, B — сравнение масс
|
||
Закон III: A — пушка + откат, B — столкновение шаров, C — ракета
|
||
════════════════════════════════════════════════════════════════ */
|
||
|
||
class NewtonSim {
|
||
|
||
static SCALE = 58; // px per metre (visual)
|
||
static G = 9.81; // m/s²
|
||
|
||
/* ── Конструктор ─────────────────────────────────────────── */
|
||
|
||
constructor(canvas) {
|
||
this.canvas = canvas;
|
||
this.ctx = canvas.getContext('2d');
|
||
|
||
/* Пользовательские параметры */
|
||
this.law = 1;
|
||
this.scene = 'A';
|
||
this.mu = 0.20;
|
||
this.mass1 = 5; // кг — основной блок / шар / ядро
|
||
this.mass2 = 12; // кг — сравниваемый блок / пушка
|
||
this.force = 30; // Н — приложенная сила (закон II)
|
||
|
||
/* Состояние сцен */
|
||
this._1A = {};
|
||
this._1B = {};
|
||
this._2 = {};
|
||
this._3A = {};
|
||
this._3B = {};
|
||
this._3C = {};
|
||
|
||
/* Петля */
|
||
this._raf = null;
|
||
this._last = 0;
|
||
this._paused = false;
|
||
|
||
/* Геометрия */
|
||
this.W = 0; this.H = 0;
|
||
this._g = {};
|
||
|
||
this.onUpdate = null;
|
||
this.onModeChange = null;
|
||
|
||
this.fit();
|
||
this._bindEvents();
|
||
}
|
||
|
||
/* ── Геометрия ───────────────────────────────────────────── */
|
||
|
||
fit() {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const W = this.canvas.offsetWidth || 700;
|
||
const H = this.canvas.offsetHeight || 440;
|
||
this.canvas.width = Math.round(W * dpr);
|
||
this.canvas.height = Math.round(H * dpr);
|
||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
this.W = W; this.H = H;
|
||
this._g = {
|
||
gY: H * 0.73,
|
||
cx: W * 0.50,
|
||
cy: H * 0.48,
|
||
orbitR: Math.min(W, H) * 0.255,
|
||
};
|
||
this._resetAll();
|
||
}
|
||
|
||
_resetAll() {
|
||
this._reset1A(); this._reset1B();
|
||
this._reset2();
|
||
this._reset3A(); this._reset3B(); this._reset3C();
|
||
}
|
||
|
||
/* ── Сброс каждой сцены ──────────────────────────────────── */
|
||
|
||
_reset1A() {
|
||
const { W, _g: g } = this;
|
||
this._1A = {
|
||
bx: W * 0.15, by: g.gY, bvx: 0, bvy: 0,
|
||
BW: 56, BH: 46,
|
||
trail: [], inAir: false,
|
||
};
|
||
}
|
||
|
||
_reset1B() {
|
||
const { _g: g } = this;
|
||
const omega = 1.35;
|
||
this._1B = {
|
||
angle: 0, omega,
|
||
cut: false, cutTimer: 0,
|
||
bx: g.cx + g.orbitR, by: g.cy,
|
||
bvx: 0, bvy: -g.orbitR * omega,
|
||
};
|
||
}
|
||
|
||
_reset2() {
|
||
const { W, _g: g } = this;
|
||
this._2 = {
|
||
b1x: W * 0.12, b1vx: 0,
|
||
b2x: W * 0.12, b2vx: 0,
|
||
history: [], t: 0, running: false,
|
||
flash: '', // 'fin' message
|
||
};
|
||
}
|
||
|
||
_reset3A() {
|
||
const { W, _g: g } = this;
|
||
this._3A = {
|
||
cx: W * 0.38, cvx: 0,
|
||
ball: null, fired: false,
|
||
sparks: [], forceFlash: 0,
|
||
};
|
||
}
|
||
|
||
_reset3B() {
|
||
const { W, _g: g } = this;
|
||
const r1 = 16 + this.mass1 * 1.1;
|
||
const r2 = 16 + this.mass2 * 1.1;
|
||
this._3B = {
|
||
b1: { x: W * 0.18, vx: 160, mass: this.mass1, r: r1, color: '#EF476F' },
|
||
b2: { x: W * 0.82, vx: -100, mass: this.mass2, r: r2, color: '#4CC9F0' },
|
||
colFlash: 0, done: false,
|
||
};
|
||
}
|
||
|
||
_reset3C() {
|
||
const { W, H } = this;
|
||
this._3C = {
|
||
ry: H * 0.78, rvy: 0,
|
||
rmass: 10, fuel: 1,
|
||
particles: [], running: false,
|
||
stopped: false,
|
||
};
|
||
}
|
||
|
||
/* ── Запуск / остановка ──────────────────────────────────── */
|
||
|
||
start() {
|
||
if (this._raf) return;
|
||
this._last = performance.now();
|
||
const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
|
||
this._raf = requestAnimationFrame(loop);
|
||
}
|
||
|
||
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
|
||
|
||
/* ── Публичный API ───────────────────────────────────────── */
|
||
|
||
setLaw(n) { this.law = n; this.scene = 'A'; this._resetAll(); if (window.LabFX) LabFX.sound.play('click'); if (this.onModeChange) this.onModeChange(); }
|
||
setScene(s) { this.scene = s; this._resetAll(); if (window.LabFX) LabFX.sound.play('click'); }
|
||
setMu(v) { this.mu = v; }
|
||
setMass1(v) { this.mass1 = v; this._reset3B(); }
|
||
setMass2(v) { this.mass2 = v; this._reset3B(); }
|
||
setForce(v) { this.force = v; }
|
||
|
||
cutString() {
|
||
this._1B.cut = true;
|
||
this._1B.bvx = -Math.sin(this._1B.angle) * this._g.orbitR * this._1B.omega;
|
||
this._1B.bvy = Math.cos(this._1B.angle) * this._g.orbitR * this._1B.omega;
|
||
}
|
||
|
||
startL2() { this._2.running = true; }
|
||
resetL2() { this._reset2(); }
|
||
|
||
fireCannon() {
|
||
if (this._3A.fired) { this._reset3A(); return; }
|
||
const { _g: g } = this;
|
||
const S = NewtonSim.SCALE;
|
||
const vBall = 360; // px/s
|
||
const vCannon = -(this.mass1 / this.mass2) * vBall;
|
||
this._3A.ball = { x: this._3A.cx + 68, y: g.gY - 22, vx: vBall, vy: -160 };
|
||
this._3A.cvx = vCannon;
|
||
this._3A.fired = true;
|
||
this._3A.forceFlash = 0.55;
|
||
for (let i = 0; i < 24; i++) {
|
||
const a = (Math.random() - 0.5) * 1.1 - 0.05;
|
||
this._3A.sparks.push({
|
||
x: this._3A.cx + 68, y: g.gY - 22,
|
||
vx: Math.cos(a) * (180 + Math.random() * 220),
|
||
vy: Math.sin(a) * 140 - 80 - Math.random() * 120,
|
||
life: 1,
|
||
});
|
||
}
|
||
}
|
||
|
||
toggleRocket() {
|
||
if (this._3C.fuel <= 0) { this._reset3C(); return; }
|
||
this._3C.running = !this._3C.running;
|
||
if (this.onModeChange) this.onModeChange();
|
||
}
|
||
|
||
togglePause() { this._paused = !this._paused; }
|
||
|
||
preset(name) {
|
||
switch (name) {
|
||
case 'space': this.mu = 0; this._reset1A(); break;
|
||
case 'ice': this.mu = 0.04; this._reset1A(); break;
|
||
case 'asphalt': this.mu = 0.38; this._reset1A(); break;
|
||
case 'rubber': this.mu = 0.72; this._reset1A(); break;
|
||
case 'light': this.mass1 = 2; this.force = 20; this._reset2(); break;
|
||
case 'heavy': this.mass1 = 18; this.force = 20; this._reset2(); break;
|
||
case 'compare': this.mass1 = 2; this.mass2 = 16; this.scene = 'B'; this._reset2(); break;
|
||
case 'big_cannon': this.mass2 = 50; this.mass1 = 1; this._reset3A(); break;
|
||
case 'small_cannon': this.mass2 = 5; this.mass1 = 4; this._reset3A(); break;
|
||
case 'equal_balls': this.mass1 = 8; this.mass2 = 8; this._reset3B(); break;
|
||
}
|
||
if (this.onUpdate) this.onUpdate(this.info());
|
||
}
|
||
|
||
/* ── Тик ──────────────────────────────────────────────────── */
|
||
|
||
_tick(now) {
|
||
const dt = Math.min((now - this._last) / 1000, 0.05);
|
||
this._last = now;
|
||
if (window.LabFX) LabFX.particles.update(dt);
|
||
if (!this._paused) {
|
||
if (this.law === 1 && this.scene === 'A') this._step1A(dt);
|
||
else if (this.law === 1) this._step1B(dt);
|
||
else if (this.law === 2) this._step2(dt);
|
||
else if (this.scene === 'A') this._step3A(dt);
|
||
else if (this.scene === 'B') this._step3B(dt);
|
||
else this._step3C(dt);
|
||
}
|
||
this.draw();
|
||
if (this.onUpdate) this.onUpdate(this.info());
|
||
}
|
||
|
||
/* ── Физика I-A : блок с трением ────────────────────────── */
|
||
|
||
_step1A(dt) {
|
||
const b = this._1A;
|
||
const { W, _g: g } = this;
|
||
const S = NewtonSim.SCALE;
|
||
const GV = NewtonSim.G * S;
|
||
|
||
/* Гравитация */
|
||
if (b.by < g.gY || b.bvy < 0) {
|
||
b.bvy += GV * dt;
|
||
b.inAir = true;
|
||
}
|
||
|
||
/* Интеграция */
|
||
b.bx += b.bvx * dt;
|
||
b.by += b.bvy * dt;
|
||
|
||
/* Приземление */
|
||
if (b.by >= g.gY) {
|
||
b.by = g.gY;
|
||
b.bvy = Math.abs(b.bvy) > 60 ? -b.bvy * 0.42 : 0;
|
||
b.inAir = false;
|
||
}
|
||
|
||
/* Трение (только на земле) */
|
||
if (!b.inAir) {
|
||
const speed = Math.abs(b.bvx);
|
||
if (speed > 1) {
|
||
const dec = this.mu * GV * dt;
|
||
if (dec >= speed) b.bvx = 0;
|
||
else b.bvx -= Math.sign(b.bvx) * dec;
|
||
}
|
||
}
|
||
|
||
/* Стены (упругий отскок) */
|
||
const hw = b.BW / 2;
|
||
if (b.bx < hw) { b.bx = hw; b.bvx = Math.abs(b.bvx) * 0.65; }
|
||
if (b.bx > W - hw) { b.bx = W - hw; b.bvx = -Math.abs(b.bvx) * 0.65; }
|
||
|
||
/* След */
|
||
const speed = Math.hypot(b.bvx, b.bvy);
|
||
if (speed > 15) {
|
||
b.trail.push({ x: b.bx, y: Math.min(b.by, g.gY) });
|
||
if (b.trail.length > 90) b.trail.shift();
|
||
} else if (b.trail.length > 0) {
|
||
b.trail.shift();
|
||
}
|
||
}
|
||
|
||
/* ── Физика I-B : орбита → прямолинейное движение ────────── */
|
||
|
||
_step1B(dt) {
|
||
const s = this._1B;
|
||
const { _g: g } = this;
|
||
|
||
if (!s.cut) {
|
||
s.angle += s.omega * dt;
|
||
s.bx = g.cx + Math.cos(s.angle) * g.orbitR;
|
||
s.by = g.cy + Math.sin(s.angle) * g.orbitR;
|
||
s.bvx = -Math.sin(s.angle) * g.orbitR * s.omega;
|
||
s.bvy = Math.cos(s.angle) * g.orbitR * s.omega;
|
||
} else {
|
||
s.bx += s.bvx * dt;
|
||
s.by += s.bvy * dt;
|
||
s.cutTimer += dt;
|
||
if (s.cutTimer > 4.5) this._reset1B();
|
||
}
|
||
}
|
||
|
||
/* ── Физика II : F = m·a ──────────────────────────────────── */
|
||
|
||
_step2(dt) {
|
||
if (!this._2.running) return;
|
||
const { W } = this;
|
||
const S = NewtonSim.SCALE;
|
||
const a1 = (this.force / this.mass1) * S;
|
||
const a2 = (this.force / this.mass2) * S;
|
||
|
||
this._2.b1vx += a1 * dt; this._2.b1x += this._2.b1vx * dt;
|
||
this._2.b2vx += a2 * dt; this._2.b2x += this._2.b2vx * dt;
|
||
this._2.t += dt;
|
||
|
||
/* История для графика (примерно 20 точек/с) */
|
||
if (this._2.t % 0.05 < dt) {
|
||
this._2.history.push({ v1: this._2.b1vx / S, v2: this._2.b2vx / S });
|
||
if (this._2.history.length > 130) this._2.history.shift();
|
||
}
|
||
|
||
/* Сброс при достижении правого края */
|
||
if (this._2.b1x > W * 0.89 || this._2.b2x > W * 0.89) {
|
||
this._reset2(); this._2.running = true;
|
||
}
|
||
}
|
||
|
||
/* ── Физика III-A : пушка ─────────────────────────────────── */
|
||
|
||
_step3A(dt) {
|
||
const s = this._3A;
|
||
const { W, _g: g } = this;
|
||
const S = NewtonSim.SCALE;
|
||
const GV = NewtonSim.G * S;
|
||
|
||
if (!s.fired) return;
|
||
|
||
if (this._3A.forceFlash > 0) this._3A.forceFlash -= dt;
|
||
|
||
/* Пушка тормозит */
|
||
if (Math.abs(s.cvx) > 2) {
|
||
const dec = this.mu * GV * dt;
|
||
if (dec >= Math.abs(s.cvx)) s.cvx = 0;
|
||
else s.cvx -= Math.sign(s.cvx) * dec;
|
||
} else { s.cvx = 0; }
|
||
s.cx = Math.max(60, Math.min(W - 80, s.cx + s.cvx * dt));
|
||
|
||
/* Ядро (баллистика) */
|
||
if (s.ball) {
|
||
s.ball.vy += GV * dt;
|
||
s.ball.x += s.ball.vx * dt;
|
||
s.ball.y += s.ball.vy * dt;
|
||
if (s.ball.y > g.gY + 40 || s.ball.x > W + 120 || s.ball.x < -120) s.ball = null;
|
||
}
|
||
|
||
/* Искры */
|
||
for (const sp of s.sparks) {
|
||
sp.x += sp.vx * dt; sp.y += sp.vy * dt;
|
||
sp.vy += GV * 0.6 * dt; sp.life -= dt * 2;
|
||
}
|
||
s.sparks = s.sparks.filter(sp => sp.life > 0);
|
||
}
|
||
|
||
/* ── Физика III-B : столкновение ─────────────────────────── */
|
||
|
||
_step3B(dt) {
|
||
const { b1, b2 } = this._3B;
|
||
const { W, _g: g } = this;
|
||
const S = NewtonSim.SCALE;
|
||
|
||
b1.x += b1.vx * dt;
|
||
b2.x += b2.vx * dt;
|
||
|
||
if (this._3B.colFlash > 0) this._3B.colFlash -= dt;
|
||
|
||
/* Упругое столкновение */
|
||
if (!this._3B.done) {
|
||
const dist = b2.x - b1.x;
|
||
if (dist < b1.r + b2.r) {
|
||
const m1 = b1.mass, m2 = b2.mass;
|
||
const v1 = b1.vx, v2 = b2.vx;
|
||
b1.vx = ((m1 - m2) * v1 + 2 * m2 * v2) / (m1 + m2);
|
||
b2.vx = ((m2 - m1) * v2 + 2 * m1 * v1) / (m1 + m2);
|
||
const overlap = b1.r + b2.r - dist;
|
||
b1.x -= overlap * 0.5; b2.x += overlap * 0.5;
|
||
this._3B.colFlash = 0.55;
|
||
this._3B.done = true;
|
||
}
|
||
}
|
||
|
||
/* Минимальное трение поверхности (сохраняет видимость закона импульса) */
|
||
if (this._3B.done) {
|
||
[b1, b2].forEach(b => { b.vx *= (1 - dt * 0.04); });
|
||
}
|
||
|
||
/* Авто-сброс */
|
||
if (b1.x < -120 || b2.x > W + 120 || (this._3B.done && Math.abs(b1.vx) < 2 && Math.abs(b2.vx) < 2)) {
|
||
setTimeout(() => this._reset3B(), 1800);
|
||
this._3B.done = true; // prevent double reset
|
||
}
|
||
}
|
||
|
||
/* ── Физика III-C : ракета ────────────────────────────────── */
|
||
|
||
_step3C(dt) {
|
||
const s = this._3C;
|
||
const { W, H } = this;
|
||
const g_vis = NewtonSim.G * 0.42; // visual gravity (px/s²)
|
||
|
||
/* Gravity always acts — even after fuel is out */
|
||
if (!s.running && s.fuel <= 0 && !s.stopped) {
|
||
s.rvy += g_vis * dt;
|
||
s.ry += s.rvy * dt;
|
||
if (s.ry >= H * 0.78) { s.ry = H * 0.78; s.rvy = 0; s.stopped = true; }
|
||
/* exhaust smoke fading */
|
||
for (const p of s.particles) { p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt * 1.6; }
|
||
s.particles = s.particles.filter(p => p.life > 0 && p.y < H + 20);
|
||
return;
|
||
}
|
||
|
||
if (!s.running || s.fuel <= 0) {
|
||
if (s.fuel <= 0 && !s.stopped) { s.running = false; }
|
||
return;
|
||
}
|
||
|
||
const dmdt = 0.025; // кг/с — сгорание топлива
|
||
const F_thr = 220; // Н — тяга (визуальная)
|
||
s.fuel = Math.max(0, s.fuel - dmdt * dt);
|
||
s.rmass = Math.max(2, 10 * s.fuel + 2);
|
||
|
||
/* F_net = F_thrust - m·g */
|
||
const a_thrust = F_thr / s.rmass;
|
||
const a_net = a_thrust - g_vis;
|
||
s.rvy -= a_net * dt;
|
||
s.ry += s.rvy * dt;
|
||
if (s.ry < H * 0.08) { s.ry = H * 0.08; s.rvy = 0; }
|
||
if (s.ry >= H * 0.78) { s.ry = H * 0.78; s.rvy = 0; }
|
||
|
||
/* Частицы выхлопа */
|
||
if (Math.random() < 0.55) {
|
||
s.particles.push({
|
||
x: W * 0.5 + (Math.random() - 0.5) * 16,
|
||
y: s.ry + 48,
|
||
vx: (Math.random() - 0.5) * 38,
|
||
vy: 130 + Math.random() * 170,
|
||
r: 2 + Math.random() * 4,
|
||
life: 1,
|
||
});
|
||
}
|
||
for (const p of s.particles) {
|
||
p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt * 1.6;
|
||
}
|
||
s.particles = s.particles.filter(p => p.life > 0 && p.y < H + 20);
|
||
|
||
/* LabFX: rocket flame trail at nozzle */
|
||
if (window.LabFX) {
|
||
const nozzleX = W * 0.5;
|
||
const nozzleY = s.ry + 22;
|
||
LabFX.particles.emit({
|
||
ctx: this.ctx, x: nozzleX, y: nozzleY,
|
||
count: 3, color: ['#FFD166', '#FF6B35', '#EF476F'],
|
||
speed: 80, spread: Math.PI / 6, angle: Math.PI / 2,
|
||
gravity: -50, life: 400, glow: true, shape: 'spark',
|
||
});
|
||
}
|
||
}
|
||
|
||
/* ── Мышь ─────────────────────────────────────────────────── */
|
||
|
||
_bindEvents() {
|
||
this.canvas.addEventListener('click', e => {
|
||
if (this.law !== 1 || this.scene !== 'A') return;
|
||
const r = this.canvas.getBoundingClientRect();
|
||
const x = e.clientX - r.left;
|
||
const y = e.clientY - r.top;
|
||
const b = this._1A;
|
||
const dx = x - b.bx, dy = y - b.by;
|
||
const d = Math.hypot(dx, dy);
|
||
if (d < 4) return;
|
||
const spd = 340;
|
||
b.bvx = (dx / d) * spd;
|
||
b.bvy = (dy / d) * spd;
|
||
});
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════
|
||
РЕНДЕРИНГ
|
||
═══════════════════════════════════════════════════════════ */
|
||
|
||
draw() {
|
||
const ctx = this.ctx;
|
||
const { W, H } = this;
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
/* Фон */
|
||
const bg = ctx.createRadialGradient(W / 2, H * 0.3, 0, W / 2, H / 2, W * 0.82);
|
||
bg.addColorStop(0, '#0d1320'); bg.addColorStop(1, '#050810');
|
||
ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
|
||
|
||
/* Водяной знак — номер закона */
|
||
ctx.save();
|
||
ctx.font = 'bold 78px sans-serif';
|
||
ctx.fillStyle = 'rgba(255,255,255,0.023)';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(['', 'ЗАКОН I', 'ЗАКОН II', 'ЗАКОН III'][this.law], W / 2, H * 0.60);
|
||
ctx.textAlign = 'left';
|
||
ctx.restore();
|
||
|
||
if (this.law === 1 && this.scene === 'A') this._drawL1A(ctx);
|
||
else if (this.law === 1) this._drawL1B(ctx);
|
||
else if (this.law === 2) this._drawL2(ctx);
|
||
else if (this.scene === 'A') this._drawL3A(ctx);
|
||
else if (this.scene === 'B') this._drawL3B(ctx);
|
||
else this._drawL3C(ctx);
|
||
|
||
/* LabFX: particles overlay */
|
||
if (window.LabFX) LabFX.particles.draw(this.ctx);
|
||
}
|
||
|
||
/* ── Закон I — Сцена A ───────────────────────────────────── */
|
||
|
||
_drawL1A(ctx) {
|
||
const { W, H, _g: g } = this;
|
||
const b = this._1A;
|
||
const S = NewtonSim.SCALE;
|
||
const spd = Math.hypot(b.bvx, b.bvy);
|
||
|
||
/* Звёздный фон при μ≈0 */
|
||
if (this.mu < 0.02) {
|
||
this._stars(ctx);
|
||
ctx.font = 'bold 12px sans-serif';
|
||
ctx.fillStyle = '#7BF5A4';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Трения нет — тело движется вечно!', W / 2, H * 0.10);
|
||
ctx.textAlign = 'left';
|
||
} else {
|
||
this._ground(ctx, g.gY, W);
|
||
}
|
||
|
||
/* След */
|
||
if (b.trail.length > 2) {
|
||
for (let i = 2; i < b.trail.length; i++) {
|
||
const a = (i / b.trail.length) * 0.55;
|
||
ctx.strokeStyle = `rgba(255,209,102,${a})`;
|
||
ctx.lineWidth = 2.5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(b.trail[i-1].x, b.trail[i-1].y - 2);
|
||
ctx.lineTo(b.trail[i].x, b.trail[i].y - 2);
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
/* Блок */
|
||
const by = b.by - b.BH / 2;
|
||
this._block(ctx, b.bx, by, b.BW, b.BH, '#9B5DE5', `${this.mass1} кг`);
|
||
|
||
/* Вектор скорости */
|
||
if (spd > 8) {
|
||
const scale = 0.28;
|
||
this._arrow(ctx,
|
||
b.bx, by,
|
||
b.bx + b.bvx * scale, by + b.bvy * scale,
|
||
'#FFD166', 'v = ' + (spd / S).toFixed(1) + ' м/с', 2.5);
|
||
}
|
||
|
||
/* Сила трения (горизонтальная, только на земле) */
|
||
if (!b.inAir && Math.abs(b.bvx) > 8 && this.mu > 0.01) {
|
||
const fFr = this.mu * this.mass1 * NewtonSim.G;
|
||
this._arrow(ctx,
|
||
b.bx, by - 32,
|
||
b.bx - Math.sign(b.bvx) * 55, by - 32,
|
||
'#EF476F', `F тр = ${fFr.toFixed(1)} Н`, 2);
|
||
}
|
||
|
||
/* Подсказка */
|
||
if (spd < 4) {
|
||
ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Кликни куда угодно — придай импульс блоку', W / 2, g.gY + 28);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
/* μ и формула */
|
||
ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.75)';
|
||
ctx.fillText(`μ = ${this.mu.toFixed(2)} F тр = μ · m · g`, 18, 26);
|
||
|
||
this._caption(ctx, 'Тело продолжает движение\nпока не подействует сила', W, H);
|
||
}
|
||
|
||
/* ── Закон I — Сцена B ───────────────────────────────────── */
|
||
|
||
_drawL1B(ctx) {
|
||
const { W, H, _g: g } = this;
|
||
const s = this._1B;
|
||
|
||
this._stars(ctx);
|
||
|
||
/* Орбита */
|
||
ctx.save();
|
||
ctx.setLineDash([5, 9]);
|
||
ctx.strokeStyle = 'rgba(100,165,255,0.20)'; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.arc(g.cx, g.cy, g.orbitR, 0, Math.PI * 2); ctx.stroke();
|
||
ctx.setLineDash([]); ctx.restore();
|
||
|
||
/* Центральное тело */
|
||
ctx.save();
|
||
ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 22;
|
||
ctx.beginPath(); ctx.arc(g.cx, g.cy, 20, 0, Math.PI * 2);
|
||
const cg = ctx.createRadialGradient(g.cx - 5, g.cy - 5, 0, g.cx, g.cy, 20);
|
||
cg.addColorStop(0, '#FFEA70'); cg.addColorStop(1, '#FF9500');
|
||
ctx.fillStyle = cg; ctx.fill();
|
||
ctx.restore();
|
||
|
||
/* Нить */
|
||
if (!s.cut) {
|
||
ctx.strokeStyle = 'rgba(210,225,255,0.55)'; ctx.lineWidth = 1.8;
|
||
ctx.beginPath(); ctx.moveTo(g.cx, g.cy); ctx.lineTo(s.bx, s.by); ctx.stroke();
|
||
|
||
/* Стрелка натяжения (центростремительная) */
|
||
const len = g.orbitR;
|
||
const tx = (g.cx - s.bx) / len * 36;
|
||
const ty = (g.cy - s.by) / len * 36;
|
||
const F_c = (this.mass1 * g.orbitR * s.omega * s.omega * NewtonSim.SCALE / NewtonSim.SCALE).toFixed(1);
|
||
this._arrow(ctx, s.bx, s.by, s.bx + tx, s.by + ty, '#4CC9F0', `T = F ц`, 1.8);
|
||
} else {
|
||
ctx.font = 'bold 14px sans-serif'; ctx.fillStyle = '#7BF5A4';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('✂ Нить разрезана — тело летит прямолинейно!', W / 2, H * 0.10);
|
||
ctx.textAlign = 'left';
|
||
|
||
/* Вектор скорости по касательной */
|
||
this._arrow(ctx, s.bx, s.by,
|
||
s.bx + s.bvx * 0.22, s.by + s.bvy * 0.22,
|
||
'#FFD166', 'v = const', 2.8);
|
||
}
|
||
|
||
/* Шар */
|
||
ctx.save();
|
||
ctx.shadowColor = '#4CC9F0'; ctx.shadowBlur = 14;
|
||
ctx.beginPath(); ctx.arc(s.bx, s.by, 16, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#4CC9F0'; ctx.fill();
|
||
ctx.restore();
|
||
|
||
/* Вектор скорости (во время орбиты) */
|
||
if (!s.cut) {
|
||
this._arrow(ctx, s.bx, s.by,
|
||
s.bx + s.bvx * 0.18, s.by + s.bvy * 0.18,
|
||
'#FFD166', '', 2);
|
||
}
|
||
|
||
this._caption(ctx, 'Без силы тело движется прямолинейно\nравномерно (1-й закон Ньютона)', W, H);
|
||
}
|
||
|
||
/* ── Закон II ─────────────────────────────────────────────── */
|
||
|
||
_drawL2(ctx) {
|
||
const { W, H, _g: g } = this;
|
||
const S = NewtonSim.SCALE;
|
||
const a1 = this.force / this.mass1;
|
||
const a2 = this.force / this.mass2;
|
||
|
||
this._ground(ctx, g.gY, W);
|
||
|
||
/* Линия финиша */
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.13)'; ctx.lineWidth = 1;
|
||
ctx.setLineDash([6, 7]);
|
||
ctx.beginPath(); ctx.moveTo(W * 0.89, 0); ctx.lineTo(W * 0.89, g.gY + 8); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
const BW = 58, BH = 48;
|
||
|
||
if (this.scene === 'A') {
|
||
/* ── Один блок ── */
|
||
const { b1x: bx, b1vx: bvx } = this._2;
|
||
const by = g.gY - BH / 2;
|
||
|
||
this._block(ctx, bx, by, BW, BH, '#EF476F', `${this.mass1} кг`);
|
||
|
||
/* Сила F */
|
||
const _fTipX = bx + BW / 2 + 48 + this.force * 0.9;
|
||
const _fTipY = by;
|
||
this._arrow(ctx, bx + BW / 2, by,
|
||
_fTipX, _fTipY,
|
||
'#EF476F', `F = ${this.force} Н`, 2.5);
|
||
/* LabFX: spark at force arrow tip (scene II A, running) */
|
||
if (window.LabFX && this._2.running && Math.random() < 0.25) {
|
||
LabFX.particles.emit({
|
||
ctx, x: _fTipX, y: _fTipY,
|
||
count: 5, color: '#FFD166', speed: 40,
|
||
spread: Math.PI / 2, angle: 0, life: 200,
|
||
glow: true, shape: 'spark',
|
||
});
|
||
}
|
||
|
||
/* Ускорение a */
|
||
const aLen = 32 + a1 * 5;
|
||
this._arrow(ctx, bx + BW / 2, by - 32,
|
||
bx + BW / 2 + aLen, by - 32,
|
||
'#7BF5A4', `a = ${a1.toFixed(1)} м/с²`, 2.5);
|
||
|
||
/* Скорость v */
|
||
if (bvx > 8) {
|
||
const v = bvx / S;
|
||
this._arrow(ctx, bx + BW / 2, by + 32,
|
||
bx + BW / 2 + bvx * 0.28, by + 32,
|
||
'#FFD166', `v = ${v.toFixed(1)} м/с`, 2);
|
||
}
|
||
|
||
/* Уравнение F = m·a */
|
||
this._fma(ctx, this.force, this.mass1, a1, W / 2, H * 0.12);
|
||
|
||
/* Мини-график v(t) */
|
||
if (this._2.history.length > 3) {
|
||
this._graph(ctx, this._2.history.map(h => h.v1),
|
||
W * 0.72, 14, 145, 62, '#FFD166', 'v(t) м/с');
|
||
}
|
||
|
||
if (!this._2.running) {
|
||
ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Нажмите «Старт» чтобы запустить', W / 2, g.gY + 28);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
} else {
|
||
/* ── Сравнение двух масс ── */
|
||
const y1 = g.gY - BH - 6, y2 = g.gY + 4;
|
||
|
||
/* Разделитель дорожек */
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1;
|
||
ctx.setLineDash([4, 7]);
|
||
ctx.beginPath(); ctx.moveTo(0, g.gY - BH / 2 - 4); ctx.lineTo(W, g.gY - BH / 2 - 4); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
const bx1 = this._2.b1x, bx2 = this._2.b2x;
|
||
|
||
this._block(ctx, bx1, y1, BW, BH, '#EF476F', `${this.mass1} кг`);
|
||
this._block(ctx, bx2, y2, BW, BH, '#4CC9F0', `${this.mass2} кг`);
|
||
|
||
/* Силы (одинаковые) */
|
||
const fLen = 40 + this.force * 0.9;
|
||
this._arrow(ctx, bx1 + BW / 2, y1 + BH / 2, bx1 + BW / 2 + fLen, y1 + BH / 2, '#EF476F', `F=${this.force}Н`, 2);
|
||
this._arrow(ctx, bx2 + BW / 2, y2 + BH / 2, bx2 + BW / 2 + fLen, y2 + BH / 2, '#4CC9F0', `F=${this.force}Н`, 2);
|
||
|
||
/* Ускорения */
|
||
ctx.font = 'bold 11px monospace';
|
||
ctx.fillStyle = '#7BF5A4'; ctx.fillText(`a₁=${a1.toFixed(1)} м/с²`, bx1 + 2, y1 - 8);
|
||
ctx.fillStyle = '#06D6E0'; ctx.fillText(`a₂=${a2.toFixed(1)} м/с²`, bx2 + 2, y2 - 8);
|
||
|
||
/* Вывод */
|
||
if (this._2.running && bx1 > bx2 + 20) {
|
||
ctx.font = 'bold 13px sans-serif'; ctx.fillStyle = '#7BF5A4';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Меньше масса → больше ускорение!', W / 2, g.gY + 26);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
/* Графики */
|
||
if (this._2.history.length > 3) {
|
||
this._graph(ctx, this._2.history.map(h => h.v1), W * 0.68, 14, 130, 58, '#EF476F', 'v₁ м/с');
|
||
this._graph(ctx, this._2.history.map(h => h.v2), W * 0.68, 80, 130, 58, '#4CC9F0', 'v₂ м/с');
|
||
}
|
||
|
||
if (!this._2.running) {
|
||
ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Нажмите «Старт» чтобы запустить', W / 2, g.gY + 28);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
}
|
||
|
||
this._caption(ctx, 'F = m · a', W, H);
|
||
}
|
||
|
||
/* ── Закон III — Сцена A : пушка ────────────────────────── */
|
||
|
||
_drawL3A(ctx) {
|
||
const { W, H, _g: g } = this;
|
||
const s = this._3A;
|
||
const S = NewtonSim.SCALE;
|
||
const CW = 124, CH = 42;
|
||
|
||
this._ground(ctx, g.gY, W);
|
||
|
||
/* Корпус пушки */
|
||
ctx.save();
|
||
ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 14;
|
||
_nwt_rrect(ctx, s.cx - CW / 2, g.gY - CH - 4, CW, CH, 8);
|
||
const cg = ctx.createLinearGradient(0, g.gY - CH - 4, 0, g.gY - 4);
|
||
cg.addColorStop(0, '#5a3a7a'); cg.addColorStop(1, '#3a2260');
|
||
ctx.fillStyle = cg; ctx.fill();
|
||
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.8; ctx.stroke();
|
||
ctx.restore();
|
||
|
||
/* Ствол */
|
||
ctx.save();
|
||
ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 7;
|
||
_nwt_rrect(ctx, s.cx + CW / 2 - 8, g.gY - CH / 2 - 8 - 4, 58, 16, 4);
|
||
ctx.fillStyle = '#7340a0'; ctx.fill();
|
||
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.stroke();
|
||
ctx.restore();
|
||
|
||
/* Колёса */
|
||
[s.cx - CW / 2 + 18, s.cx + CW / 2 - 18].forEach(wx => {
|
||
ctx.save(); ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 6;
|
||
ctx.beginPath(); ctx.arc(wx, g.gY, 10, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#4a2870'; ctx.fill();
|
||
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.stroke();
|
||
ctx.restore();
|
||
});
|
||
|
||
/* Масса пушки */
|
||
ctx.font = 'bold 11px monospace'; ctx.fillStyle = 'rgba(200,180,255,0.9)';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(`M = ${this.mass2} кг`, s.cx, g.gY - CH - 16);
|
||
ctx.textAlign = 'left';
|
||
|
||
/* Ядро */
|
||
if (s.ball) {
|
||
ctx.save();
|
||
ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 12;
|
||
ctx.beginPath(); ctx.arc(s.ball.x, s.ball.y, 12, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#FFD166'; ctx.fill();
|
||
ctx.restore();
|
||
ctx.font = '10px monospace'; ctx.fillStyle = 'rgba(255,209,102,0.82)';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(`m = ${this.mass1} кг`, s.ball.x, s.ball.y + 26);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
/* Стрелки сил (сразу после выстрела) */
|
||
if (s.forceFlash > 0) {
|
||
const alpha = Math.min(1, s.forceFlash * 2.5);
|
||
const fScale = 72 * alpha;
|
||
const ny = g.gY - CH - 32;
|
||
/* Сила на ядро → вправо */
|
||
this._arrow(ctx, s.cx + CW / 2 + 20, ny, s.cx + CW / 2 + 20 + fScale, ny, '#EF476F', 'F→ядро', 2.5);
|
||
/* Реакция на пушку → влево */
|
||
this._arrow(ctx, s.cx - CW / 2 - 20, ny, s.cx - CW / 2 - 20 - fScale, ny, '#4CC9F0', 'F→пушка', 2.5);
|
||
|
||
ctx.save(); ctx.globalAlpha = alpha;
|
||
ctx.font = 'bold 12px sans-serif'; ctx.fillStyle = '#FFD166';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('|F→ядро| = |F→пушка|', s.cx, ny - 22);
|
||
ctx.restore();
|
||
}
|
||
|
||
/* Скорости (после выстрела, когда искры погасли) */
|
||
if (s.fired && s.sparks.length === 0 && s.forceFlash <= 0) {
|
||
if (s.cvx !== 0) {
|
||
this._arrow(ctx, s.cx, g.gY - CH / 2 - 4, s.cx + s.cvx * 0.35, g.gY - CH / 2 - 4,
|
||
'#4CC9F0', `V₂=${(s.cvx/S).toFixed(1)}м/с`, 2);
|
||
}
|
||
if (s.ball) {
|
||
this._arrow(ctx, s.ball.x, s.ball.y, s.ball.x + s.ball.vx * 0.12, s.ball.y + s.ball.vy * 0.12,
|
||
'#EF476F', `V₁=${(s.ball.vx/S).toFixed(1)}м/с`, 2);
|
||
}
|
||
/* Сохранение импульса */
|
||
const p1 = (this.mass1 * 360 / S).toFixed(1);
|
||
const p2 = Math.abs(this.mass2 * (this.mass1 / this.mass2) * 360 / S).toFixed(1);
|
||
ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.82)';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(`p₁ = ${p1} кг·м/с p₂ = ${p2} кг·м/с Δp_total = 0`, W / 2, H * 0.11);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
/* Искры */
|
||
for (const sp of s.sparks) {
|
||
ctx.save(); ctx.globalAlpha = sp.life;
|
||
ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 8;
|
||
ctx.beginPath(); ctx.arc(sp.x, sp.y, 3 * sp.life, 0, Math.PI * 2);
|
||
ctx.fillStyle = sp.life > 0.5 ? '#FFD166' : '#EF476F'; ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
if (!s.fired) {
|
||
ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Нажмите «Выстрел!» чтобы запустить ядро', W / 2, g.gY + 28);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
this._caption(ctx, 'Действие = Противодействие\nF₁ = −F₂', W, H);
|
||
}
|
||
|
||
/* ── Закон III — Сцена B : столкновение ─────────────────── */
|
||
|
||
_drawL3B(ctx) {
|
||
const { W, H, _g: g } = this;
|
||
const { b1, b2, colFlash } = this._3B;
|
||
const S = NewtonSim.SCALE;
|
||
const cy = g.cy + 15;
|
||
|
||
/* Дорожка */
|
||
ctx.fillStyle = 'rgba(60,90,160,0.07)';
|
||
ctx.fillRect(0, cy - 50, W, 100);
|
||
|
||
/* Тени-следы */
|
||
[b1, b2].forEach(b => {
|
||
if (Math.abs(b.vx) < 4) return;
|
||
ctx.save(); ctx.globalAlpha = 0.14;
|
||
for (let s2 = 1; s2 <= 3; s2++) {
|
||
ctx.beginPath();
|
||
ctx.arc(b.x - b.vx * 0.06 * s2, cy, b.r * (1 - s2 * 0.1), 0, Math.PI * 2);
|
||
ctx.fillStyle = b.color; ctx.fill();
|
||
}
|
||
ctx.restore();
|
||
});
|
||
|
||
/* Шары */
|
||
[b1, b2].forEach(b => {
|
||
ctx.save();
|
||
ctx.shadowColor = b.color; ctx.shadowBlur = 16;
|
||
ctx.beginPath(); ctx.arc(b.x, cy, b.r, 0, Math.PI * 2);
|
||
const bg2 = ctx.createRadialGradient(b.x - b.r * 0.3, cy - b.r * 0.3, 0, b.x, cy, b.r);
|
||
bg2.addColorStop(0, _nwt_lighten(b.color, 65));
|
||
bg2.addColorStop(1, b.color);
|
||
ctx.fillStyle = bg2; ctx.fill();
|
||
ctx.restore();
|
||
/* Масса и скорость */
|
||
ctx.font = 'bold 12px monospace'; ctx.fillStyle = 'rgba(225,235,255,0.9)';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(`${b.mass} кг`, b.x, cy + b.r + 19);
|
||
if (Math.abs(b.vx) > 6) {
|
||
ctx.fillStyle = '#FFD166';
|
||
ctx.fillText(`v=${( b.vx / S ).toFixed(1)}`, b.x, cy - b.r - 8);
|
||
}
|
||
ctx.textAlign = 'left';
|
||
});
|
||
|
||
/* Вспышка сил при ударе */
|
||
if (colFlash > 0.06) {
|
||
const mx = (b1.x + b2.x) / 2;
|
||
const fY = cy - b1.r - 28;
|
||
const a = Math.min(1, colFlash * 2.5);
|
||
const len = 65 * a;
|
||
this._arrow(ctx, mx, fY, mx - len, fY, '#EF476F', 'F₁₂', 2.5);
|
||
this._arrow(ctx, mx, fY + 8, mx + len, fY + 8, '#4CC9F0', 'F₂₁', 2.5);
|
||
ctx.save(); ctx.globalAlpha = a;
|
||
ctx.font = 'bold 12px sans-serif'; ctx.fillStyle = '#FFD166';
|
||
ctx.textAlign = 'center'; ctx.fillText('|F₁₂| = |F₂₁|', mx, fY - 18); ctx.restore();
|
||
}
|
||
|
||
/* Импульс */
|
||
const p1 = (b1.mass * b1.vx / S).toFixed(2);
|
||
const p2 = (b2.mass * b2.vx / S).toFixed(2);
|
||
const pt = ((b1.mass * b1.vx + b2.mass * b2.vx) / S).toFixed(2);
|
||
ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.82)';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(`p₁ = ${p1} p₂ = ${p2} p(сумм) = ${pt} кг·м/с`, W / 2, H * 0.12);
|
||
ctx.textAlign = 'left';
|
||
|
||
this._caption(ctx, 'Δp₁ = −Δp₂ (импульс сохраняется)', W, H);
|
||
}
|
||
|
||
/* ── Закон III — Сцена C : ракета ───────────────────────── */
|
||
|
||
_drawL3C(ctx) {
|
||
const { W, H } = this;
|
||
const s = this._3C;
|
||
const S = NewtonSim.SCALE;
|
||
const rx = W / 2;
|
||
|
||
this._stars(ctx);
|
||
|
||
/* Частицы выхлопа */
|
||
for (const p of s.particles) {
|
||
ctx.save();
|
||
ctx.globalAlpha = p.life * 0.85;
|
||
const col = p.life > 0.6 ? '#FFD166' : p.life > 0.3 ? '#FF6B35' : '#EF476F';
|
||
ctx.shadowColor = col; ctx.shadowBlur = 7;
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, p.r * p.life, 0, Math.PI * 2);
|
||
ctx.fillStyle = col; ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
/* Ракета */
|
||
const ry = s.ry;
|
||
ctx.save(); ctx.shadowColor = '#4CC9F0'; ctx.shadowBlur = 18;
|
||
|
||
/* Фюзеляж */
|
||
_nwt_rrect(ctx, rx - 17, ry - 50, 34, 62, 7);
|
||
const rg = ctx.createLinearGradient(rx - 17, 0, rx + 17, 0);
|
||
rg.addColorStop(0, '#1a3a5a'); rg.addColorStop(0.5, '#4CC9F0'); rg.addColorStop(1, '#1a3a5a');
|
||
ctx.fillStyle = rg; ctx.fill();
|
||
ctx.strokeStyle = '#4CC9F0'; ctx.lineWidth = 1.5; ctx.stroke();
|
||
|
||
/* Нос */
|
||
ctx.beginPath();
|
||
ctx.moveTo(rx, ry - 72); ctx.lineTo(rx - 15, ry - 50); ctx.lineTo(rx + 15, ry - 50);
|
||
ctx.closePath(); ctx.fillStyle = '#06D6E0'; ctx.fill();
|
||
|
||
/* Плавники */
|
||
[[-1], [1]].forEach(([dx]) => {
|
||
ctx.beginPath();
|
||
ctx.moveTo(rx + dx * 17, ry + 12);
|
||
ctx.lineTo(rx + dx * 32, ry + 32);
|
||
ctx.lineTo(rx + dx * 17, ry + 22);
|
||
ctx.closePath(); ctx.fillStyle = '#06D6E0'; ctx.fill();
|
||
});
|
||
/* Иллюминатор */
|
||
ctx.beginPath(); ctx.arc(rx, ry - 20, 7, 0, Math.PI * 2);
|
||
ctx.fillStyle = 'rgba(200,240,255,0.25)'; ctx.fill();
|
||
ctx.strokeStyle = '#4CC9F0'; ctx.lineWidth = 1; ctx.stroke();
|
||
ctx.restore();
|
||
|
||
/* Стрелки сил */
|
||
if (s.running) {
|
||
const g_vis = NewtonSim.G * 0.42;
|
||
const a_thrust = 220 / s.rmass;
|
||
const a_net = (a_thrust - g_vis).toFixed(1);
|
||
this._arrow(ctx, rx, ry - 55, rx, ry - 55 - 52, '#7BF5A4', `F тяга`, 2.5);
|
||
this._arrow(ctx, rx - 38, ry, rx - 38, ry + 36, '#FFD166', 'mg', 1.8);
|
||
this._arrow(ctx, rx, ry + 25, rx, ry + 80, '#EF476F', 'F газ', 2.5);
|
||
ctx.font = '12px monospace'; ctx.fillStyle = '#7BF5A4';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(`a = F/m − g = ${a_net} м/с²`, rx, ry - 110);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
/* Falling after fuel out — show gravity arrow */
|
||
if (s.fuel <= 0 && !s.stopped) {
|
||
this._arrow(ctx, rx, ry + 25, rx, ry + 65, '#EF476F', 'mg↓', 2.5);
|
||
ctx.font = 'bold 13px sans-serif'; ctx.fillStyle = '#EF476F';
|
||
ctx.textAlign = 'center'; ctx.fillText('Топливо кончилось — ракета падает!', W / 2, H * 0.15); ctx.textAlign = 'left';
|
||
}
|
||
|
||
/* Инфо */
|
||
ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.82)';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(`Масса: ${s.rmass.toFixed(1)} кг Топливо: ${(s.fuel * 100).toFixed(0)}%`, W / 2, H * 0.94);
|
||
ctx.textAlign = 'left';
|
||
|
||
if (s.fuel <= 0 && s.stopped) {
|
||
ctx.font = 'bold 13px sans-serif'; ctx.fillStyle = '#FFD166';
|
||
ctx.textAlign = 'center'; ctx.fillText('Ракета приземлилась. Нажмите «Запуск» для сброса.', W / 2, H * 0.15); ctx.textAlign = 'left';
|
||
} else if (!s.running && s.fuel > 0) {
|
||
ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)';
|
||
ctx.textAlign = 'center'; ctx.fillText('Нажмите «Запуск» для включения двигателя', W / 2, H * 0.50); ctx.textAlign = 'left';
|
||
}
|
||
|
||
this._caption(ctx, 'Газ вниз → ракета вверх\n(3-й закон Ньютона)', W, H);
|
||
}
|
||
|
||
/* ── Вспомогательные рисовалки ──────────────────────────── */
|
||
|
||
_ground(ctx, gY, W) {
|
||
const mu = this.mu;
|
||
/* Поверхность */
|
||
const gg = ctx.createLinearGradient(0, gY, 0, gY + 42);
|
||
gg.addColorStop(0, mu < 0.1 ? '#182535' : mu < 0.45 ? '#1c1f2d' : '#201420');
|
||
gg.addColorStop(1, '#0c101a');
|
||
ctx.fillStyle = gg; ctx.fillRect(0, gY, W, 55);
|
||
/* Линия */
|
||
ctx.strokeStyle = mu < 0.1 ? 'rgba(76,201,240,0.42)' : mu < 0.45 ? 'rgba(155,93,229,0.42)' : 'rgba(239,71,111,0.42)';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.moveTo(0, gY); ctx.lineTo(W, gY); ctx.stroke();
|
||
/* Штриховка */
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1;
|
||
for (let x = 0; x < W; x += 22) {
|
||
ctx.beginPath(); ctx.moveTo(x, gY); ctx.lineTo(x + 12, gY + 12); ctx.stroke();
|
||
}
|
||
/* Шероховатость (при высоком трении) */
|
||
if (mu > 0.06) {
|
||
ctx.fillStyle = `rgba(255,255,255,${mu * 0.055})`;
|
||
for (let x = 9; x < W; x += 20) {
|
||
ctx.beginPath(); ctx.arc(x, gY + 5, 2.5, 0, Math.PI * 2); ctx.fill();
|
||
}
|
||
}
|
||
}
|
||
|
||
_stars(ctx) {
|
||
const { W, H } = this;
|
||
for (let i = 0; i < 65; i++) {
|
||
const x = ((i * 139.5 + 7) % W);
|
||
const y = ((i * 97.3 + 5) % (H * 0.88));
|
||
const r = i % 8 === 0 ? 1.6 : 0.9;
|
||
ctx.fillStyle = `rgba(255,255,255,${0.35 + (i % 3) * 0.18})`;
|
||
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
||
}
|
||
}
|
||
|
||
_block(ctx, cx, cy, w, h, color, label) {
|
||
ctx.save();
|
||
ctx.shadowColor = color; ctx.shadowBlur = 10;
|
||
_nwt_rrect(ctx, cx - w / 2, cy - h / 2, w, h, 7);
|
||
const bg = ctx.createLinearGradient(cx - w/2, cy - h/2, cx + w/2, cy + h/2);
|
||
bg.addColorStop(0, _nwt_lighten(color, 45));
|
||
bg.addColorStop(1, color);
|
||
ctx.fillStyle = bg; ctx.fill();
|
||
ctx.strokeStyle = _nwt_lighten(color, 60); ctx.lineWidth = 1.5; ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
ctx.font = 'bold 11px monospace'; ctx.fillStyle = '#fff';
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.fillText(label, cx, cy);
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
|
||
ctx.restore();
|
||
}
|
||
|
||
_arrow(ctx, x1, y1, x2, y2, color, label, lw = 2) {
|
||
const dx = x2 - x1, dy = y2 - y1;
|
||
const len = Math.hypot(dx, dy);
|
||
if (len < 5) return;
|
||
const ux = dx / len, uy = dy / len;
|
||
const hw = 7, hl = 13;
|
||
ctx.save();
|
||
ctx.strokeStyle = color; ctx.lineWidth = lw;
|
||
ctx.shadowColor = color; ctx.shadowBlur = 6;
|
||
ctx.lineCap = 'round';
|
||
ctx.beginPath();
|
||
ctx.moveTo(x1, y1);
|
||
ctx.lineTo(x2 - ux * hl, y2 - uy * hl);
|
||
ctx.stroke();
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x2, y2);
|
||
ctx.lineTo(x2 - ux * hl - uy * hw, y2 - uy * hl + ux * hw);
|
||
ctx.lineTo(x2 - ux * hl + uy * hw, y2 - uy * hl - ux * hw);
|
||
ctx.closePath(); ctx.fill();
|
||
if (label) {
|
||
ctx.shadowBlur = 0; ctx.font = '11px monospace'; ctx.fillStyle = color;
|
||
const lx = (x1 + x2) / 2 - uy * 16;
|
||
const ly = (y1 + y2) / 2 + ux * 16;
|
||
ctx.textAlign = 'center'; ctx.fillText(label, lx, ly); ctx.textAlign = 'left';
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
_fma(ctx, F, m, a, cx, y) {
|
||
ctx.save();
|
||
ctx.font = 'bold 15px monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.shadowColor = '#7BF5A4'; ctx.shadowBlur = 14;
|
||
ctx.fillStyle = 'rgba(123,245,164,0.88)';
|
||
ctx.fillText(`F = m·a ${F} Н = ${m} кг × ${a.toFixed(1)} м/с²`, cx, y);
|
||
ctx.restore();
|
||
}
|
||
|
||
_graph(ctx, data, x, y, w, h, color, label) {
|
||
if (data.length < 2) return;
|
||
const max = Math.max(...data, 0.01);
|
||
_nwt_rrect(ctx, x, y, w, h, 4);
|
||
ctx.fillStyle = 'rgba(0,0,0,0.38)'; ctx.fill();
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1; ctx.stroke();
|
||
ctx.strokeStyle = color; ctx.lineWidth = 1.6;
|
||
ctx.beginPath();
|
||
data.forEach((v, i) => {
|
||
const px = x + (i / (data.length - 1)) * w;
|
||
const py = y + h - 3 - (v / max) * (h - 7);
|
||
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
|
||
});
|
||
ctx.stroke();
|
||
ctx.font = '9px monospace'; ctx.fillStyle = color;
|
||
ctx.fillText(label, x + 3, y + 11);
|
||
ctx.fillText(max.toFixed(1), x + 3, y + h - 3);
|
||
}
|
||
|
||
_caption(ctx, text, W, H) {
|
||
ctx.save();
|
||
ctx.font = 'italic 12px sans-serif';
|
||
ctx.fillStyle = 'rgba(185,210,255,0.35)';
|
||
ctx.textAlign = 'right';
|
||
text.split('\n').forEach((line, i) => ctx.fillText(line, W - 16, H * 0.90 + i * 18));
|
||
ctx.textAlign = 'left';
|
||
ctx.restore();
|
||
}
|
||
|
||
/* ── Info ──────────────────────────────────────────────────── */
|
||
|
||
info() {
|
||
const S = NewtonSim.SCALE;
|
||
const base = { law: this.law, scene: this.scene };
|
||
|
||
if (this.law === 1 && this.scene === 'A') {
|
||
const b = this._1A;
|
||
const spd = Math.hypot(b.bvx, b.bvy) / S;
|
||
const fFr = this.mu * this.mass1 * NewtonSim.G;
|
||
return { ...base, v: spd.toFixed(2), fFr: fFr.toFixed(2), mu: this.mu.toFixed(2), m: this.mass1 };
|
||
}
|
||
if (this.law === 1) {
|
||
const s = this._1B;
|
||
const spd = Math.hypot(s.bvx, s.bvy) / S;
|
||
return { ...base, v: spd.toFixed(2), cut: s.cut };
|
||
}
|
||
if (this.law === 2) {
|
||
const a = this.force / this.mass1;
|
||
const v = this._2.b1vx / S;
|
||
return { ...base, F: this.force, m: this.mass1, a: a.toFixed(2), v: v.toFixed(2) };
|
||
}
|
||
if (this.scene === 'A') {
|
||
const vBall = this._3A.ball ? (this._3A.ball.vx / S).toFixed(1) : '—';
|
||
const vCannon = (this._3A.cvx / S).toFixed(2);
|
||
return { ...base, vBall, vCannon, m1: this.mass1, m2: this.mass2 };
|
||
}
|
||
if (this.scene === 'B') {
|
||
const { b1, b2 } = this._3B;
|
||
return { ...base,
|
||
p1: (b1.mass * b1.vx / S).toFixed(2),
|
||
p2: (b2.mass * b2.vx / S).toFixed(2),
|
||
pt: ((b1.mass * b1.vx + b2.mass * b2.vx) / S).toFixed(2),
|
||
};
|
||
}
|
||
/* III-C rocket */
|
||
const s = this._3C;
|
||
const g_vis = NewtonSim.G * 0.42;
|
||
const a_net = s.running ? (220 / s.rmass - g_vis) : (s.stopped ? 0 : -g_vis);
|
||
return { ...base,
|
||
a: a_net.toFixed(1),
|
||
v: Math.abs(s.rvy / S).toFixed(2),
|
||
fuel: (s.fuel * 100).toFixed(0),
|
||
m: s.rmass.toFixed(1),
|
||
};
|
||
}
|
||
}
|
||
|
||
/* ── Утилиты ─────────────────────────────────────────────────── */
|
||
|
||
function _nwt_rrect(ctx, x, y, w, h, r) {
|
||
if (w <= 0 || h <= 0) return;
|
||
r = Math.min(r, w / 2, h / 2);
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + r, y);
|
||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||
ctx.arcTo(x, y + h, x, y, r);
|
||
ctx.arcTo(x, y, x + w, y, r);
|
||
ctx.closePath();
|
||
}
|
||
|
||
function _nwt_lighten(hex, d) {
|
||
const n = parseInt(hex.slice(1), 16);
|
||
const c = v => Math.max(0, Math.min(255, v));
|
||
return `rgb(${c((n >> 16) + d)},${c(((n >> 8) & 255) + d)},${c((n & 255) + d)})`;
|
||
}
|
||
|
||
/* ─── lab UI init ─────────────────────────────────── */
|
||
var newtonSim = null;
|
||
var sandboxSim = null;
|
||
let _dynMode = 'sandbox'; // current mode: 'sandbox' | 'law1' | 'law2' | 'law3'
|
||
|
||
function _openDynamics(preset) {
|
||
document.getElementById('sim-topbar-title').textContent = 'Динамика';
|
||
_simShow('sim-dynamics');
|
||
_simShow('ctrl-dynamics');
|
||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||
// init sandbox
|
||
const sbCanvas = document.getElementById('sandbox-canvas');
|
||
if (!sandboxSim) {
|
||
sandboxSim = new ForceSandboxSim(sbCanvas);
|
||
sandboxSim.onUpdate = _sbUpdateUI;
|
||
}
|
||
// init newton
|
||
const nwCanvas = document.getElementById('newton-canvas');
|
||
if (!newtonSim) {
|
||
newtonSim = new NewtonSim(nwCanvas);
|
||
newtonSim.onUpdate = _newtonUpdateUI;
|
||
}
|
||
// activate current mode
|
||
dynMode(_dynMode);
|
||
if (preset) setTimeout(() => sbPreset(preset), 120);
|
||
}));
|
||
}
|
||
|
||
function dynMode(mode, btn) {
|
||
_dynMode = mode;
|
||
const isSandbox = mode === 'sandbox';
|
||
|
||
// toggle mode buttons
|
||
document.querySelectorAll('.dyn-mode').forEach(b => b.classList.remove('active'));
|
||
const modeBtn = document.getElementById('dyn-mode-' + mode);
|
||
if (modeBtn) modeBtn.classList.add('active');
|
||
|
||
// toggle panels
|
||
document.getElementById('dyn-sandbox-panel').style.display = isSandbox ? '' : 'none';
|
||
document.getElementById('dyn-newton-panel').style.display = isSandbox ? 'none' : '';
|
||
|
||
// toggle canvases
|
||
document.getElementById('sandbox-canvas').style.display = isSandbox ? 'block' : 'none';
|
||
document.getElementById('newton-canvas').style.display = isSandbox ? 'none' : 'block';
|
||
|
||
// toggle topbar tool groups
|
||
document.getElementById('ctrl-dyn-sb').style.display = isSandbox ? 'contents' : 'none';
|
||
document.getElementById('ctrl-dyn-nw').style.display = isSandbox ? 'none' : 'contents';
|
||
|
||
if (isSandbox) {
|
||
// stop newton, start sandbox
|
||
if (newtonSim) newtonSim.stop();
|
||
if (sandboxSim) { sandboxSim.fit(); sandboxSim.start(); }
|
||
_sbUpdateUI(sandboxSim ? sandboxSim.info() : null);
|
||
} else {
|
||
// stop sandbox, switch newton law
|
||
if (sandboxSim) sandboxSim.stop();
|
||
const lawN = mode === 'law1' ? 1 : mode === 'law2' ? 2 : 3;
|
||
if (newtonSim) {
|
||
newtonSim.setLaw(lawN);
|
||
newtonSim.fit();
|
||
newtonSim.start();
|
||
_newtonSyncUI();
|
||
_newtonUpdateUI(newtonSim.info());
|
||
}
|
||
}
|
||
}
|
||
|
||
function dynPause() {
|
||
if (_dynMode === 'sandbox') {
|
||
if (sandboxSim) sandboxSim.togglePause();
|
||
} else {
|
||
if (newtonSim) newtonSim.togglePause();
|
||
}
|
||
}
|
||
|
||
function dynReset() {
|
||
if (_dynMode === 'sandbox') {
|
||
sbReset();
|
||
} else {
|
||
_resetNewtonScene();
|
||
}
|
||
}
|
||
|
||
const _NEWTON_SCENES = {
|
||
1: {
|
||
A: { desc: 'Закон инерции: тело скользит по поверхности. Нажми на canvas — толкни блок.', action: null },
|
||
B: { desc: 'Инерция в орбите: шар вращается на нити. Отруби нить — полетит по касательной!', action: '<svg class="ic" viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg> Отрубить нить' },
|
||
C: { desc: 'Инерция в космосе: тело движется равномерно, нет сил — нет ускорения.', action: null },
|
||
},
|
||
2: {
|
||
A: { desc: 'Второй закон: F = ma. Прикладывай силу и следи за ускорением и скоростью.', action: '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Запустить' },
|
||
B: { desc: 'Два тела, разные массы — одинаковая сила. Сравни ускорения!', action: '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Запустить' },
|
||
C: { desc: 'Второй закон: изменяй силу и массу ползунками, наблюдай в реальном времени.', action: '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Запустить' },
|
||
},
|
||
3: {
|
||
A: { desc: 'Третий закон: пушка выстрелила — отдача. Импульс сохраняется!', action: 'Выстрел' },
|
||
B: { desc: 'Третий закон: два шара сталкиваются — силы равны и противоположны.', action: '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Столкнуть' },
|
||
C: { desc: 'Реактивное движение: ракета выбрасывает газ — летит в обратную сторону.', action: 'Двигатель' },
|
||
},
|
||
};
|
||
|
||
const _NEWTON_PRESETS = {
|
||
1: [
|
||
{ label: 'Космос', fn: 'space' },
|
||
{ label: 'Лёд', fn: 'ice' },
|
||
{ label: 'Асфальт', fn: 'asphalt' },
|
||
{ label: 'Резина', fn: 'rubber' },
|
||
],
|
||
2: [
|
||
{ label: 'Лёгкий', fn: 'light' },
|
||
{ label: 'Тяжёлый', fn: 'heavy' },
|
||
{ label: 'Сравнить', fn: 'compare' },
|
||
],
|
||
3: [
|
||
{ label: 'Большая пушка', fn: 'big_cannon' },
|
||
{ label: 'Маленькая', fn: 'small_cannon' },
|
||
{ label: 'Равные шары', fn: 'equal_balls' },
|
||
],
|
||
};
|
||
|
||
// _openNewton is now handled by _openDynamics + dynMode
|
||
|
||
// newtonLaw is now handled by dynMode('law1'/'law2'/'law3')
|
||
|
||
function newtonScene(s, topBtn, panelBtn) {
|
||
if (!newtonSim) return;
|
||
newtonSim.setScene(s);
|
||
document.querySelectorAll('.nscene-btn').forEach(b => {
|
||
b.classList.toggle('active', b.id === 'nscn-' + s || b.id === 'nscn-panel-' + s);
|
||
});
|
||
_newtonSyncUI();
|
||
_newtonUpdateUI(newtonSim.info());
|
||
}
|
||
|
||
function _newtonSyncUI() {
|
||
if (!newtonSim) return;
|
||
const law = newtonSim.law;
|
||
const scene = newtonSim.scene;
|
||
const sceneData = (_NEWTON_SCENES[law] || {})[scene] || {};
|
||
|
||
// description
|
||
const desc = document.getElementById('newton-scene-desc');
|
||
if (desc) desc.textContent = sceneData.desc || '';
|
||
|
||
// action button label
|
||
const lbl = sceneData.action || (law === 1 ? '<svg class="ic" viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg> Нить' : '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Действие');
|
||
document.getElementById('newton-action-label').innerHTML = lbl;
|
||
document.getElementById('newton-action-top').innerHTML = lbl;
|
||
|
||
// show/hide sliders
|
||
document.getElementById('newton-mu-block').style.display = law === 1 && scene === 'A' ? '' : 'none';
|
||
document.getElementById('newton-mass1-block').style.display = (law === 2 || law === 3) ? '' : 'none';
|
||
document.getElementById('newton-mass2-block').style.display = law === 3 ? '' : 'none';
|
||
document.getElementById('newton-force-block').style.display = law === 2 ? '' : 'none';
|
||
|
||
// sync slider values from sim
|
||
document.getElementById('sl-newton-mu').value = newtonSim.mu;
|
||
document.getElementById('newton-mu-val').textContent = newtonSim.mu.toFixed(2);
|
||
document.getElementById('sl-newton-m1').value = newtonSim.mass1;
|
||
document.getElementById('newton-m1-val').textContent = newtonSim.mass1 + ' кг';
|
||
document.getElementById('sl-newton-m2').value = newtonSim.mass2;
|
||
document.getElementById('newton-m2-val').textContent = newtonSim.mass2 + ' кг';
|
||
document.getElementById('sl-newton-F').value = newtonSim.force;
|
||
document.getElementById('newton-F-val').textContent = newtonSim.force + ' Н';
|
||
|
||
// sync scene highlight buttons in both topbar and panel
|
||
['A','B','C'].forEach(s => {
|
||
const tb = document.getElementById('nscn-' + s);
|
||
const pb = document.getElementById('nscn-panel-' + s);
|
||
const on = s === scene;
|
||
if (tb) tb.classList.toggle('active', on);
|
||
if (pb) pb.classList.toggle('active', on);
|
||
});
|
||
|
||
// presets
|
||
const presetsEl = document.getElementById('newton-presets');
|
||
const presets = _NEWTON_PRESETS[law] || [];
|
||
presetsEl.innerHTML = presets.map(p =>
|
||
`<button class="proj-preset-chip" onclick="newtonPreset('${p.fn}')">${p.label}</button>`
|
||
).join('');
|
||
|
||
// scene B/C visibility for law I (B = orbital, C = space — but law I only has A,B)
|
||
// scene C doesn't exist for law I/II panel scene picker visibility
|
||
const cBtn = document.getElementById('nscn-panel-C');
|
||
const cTopBtn = document.getElementById('nscn-C');
|
||
const showC = law === 3;
|
||
if (cBtn) cBtn.style.display = showC ? '' : 'none';
|
||
if (cTopBtn) cTopBtn.style.display = showC ? '' : 'none';
|
||
const bBtn = document.getElementById('nscn-panel-B');
|
||
const bTopBtn = document.getElementById('nscn-B');
|
||
const showB = law !== 2 || true; // law 2 has compare scene B
|
||
if (bBtn) bBtn.style.display = '';
|
||
if (bTopBtn) bTopBtn.style.display = '';
|
||
}
|
||
|
||
function newtonAction() {
|
||
if (!newtonSim) return;
|
||
const law = newtonSim.law;
|
||
const scene = newtonSim.scene;
|
||
if (law === 1 && scene === 'B') newtonSim.cutString();
|
||
else if (law === 2) newtonSim.startL2();
|
||
else if (law === 3 && scene === 'A') newtonSim.fireCannon();
|
||
else if (law === 3 && scene === 'B') newtonSim._reset3B ? newtonSim._reset3B() : null;
|
||
else if (law === 3 && scene === 'C') newtonSim.toggleRocket();
|
||
_newtonUpdateUI(newtonSim.info());
|
||
}
|
||
|
||
function _resetNewtonScene() {
|
||
if (!newtonSim) return;
|
||
const law = newtonSim.law;
|
||
const scene = newtonSim.scene;
|
||
if (law === 1 && scene === 'A') newtonSim.preset('ice');
|
||
else if (law === 1) newtonSim.setScene(scene);
|
||
else if (law === 2) newtonSim.resetL2 ? newtonSim.resetL2() : newtonSim.setScene(scene);
|
||
else newtonSim.setScene(scene);
|
||
_newtonUpdateUI(newtonSim.info());
|
||
}
|
||
|
||
function newtonMuChange() {
|
||
const v = +document.getElementById('sl-newton-mu').value;
|
||
document.getElementById('newton-mu-val').textContent = v.toFixed(2);
|
||
if (newtonSim) newtonSim.setMu(v);
|
||
}
|
||
|
||
function newtonMass1Change() {
|
||
const v = +document.getElementById('sl-newton-m1').value;
|
||
document.getElementById('newton-m1-val').textContent = v + ' кг';
|
||
if (newtonSim) newtonSim.setMass1(v);
|
||
}
|
||
|
||
function newtonMass2Change() {
|
||
const v = +document.getElementById('sl-newton-m2').value;
|
||
document.getElementById('newton-m2-val').textContent = v + ' кг';
|
||
if (newtonSim) newtonSim.setMass2(v);
|
||
}
|
||
|
||
function newtonForceChange() {
|
||
const v = +document.getElementById('sl-newton-F').value;
|
||
document.getElementById('newton-F-val').textContent = v + ' Н';
|
||
if (newtonSim) newtonSim.setForce(v);
|
||
}
|
||
|
||
function newtonPreset(name) {
|
||
if (!newtonSim) return;
|
||
newtonSim.preset(name);
|
||
_newtonSyncUI();
|
||
_newtonUpdateUI(newtonSim.info());
|
||
}
|
||
|
||
function _newtonUpdateUI(info) {
|
||
if (!info) return;
|
||
const law = info.law;
|
||
const scene = info.scene;
|
||
|
||
if (law === 1 && scene === 'A') {
|
||
document.getElementById('dbar-l1').textContent = 'Закон I-A';
|
||
document.getElementById('dbar-v1').textContent = 'Скольжение';
|
||
document.getElementById('dbar-l2').textContent = 'Скорость';
|
||
document.getElementById('dbar-v2').textContent = info.v + ' м/с';
|
||
document.getElementById('dbar-l3').textContent = 'Сила трения';
|
||
document.getElementById('dbar-v3').textContent = info.fFr + ' Н';
|
||
document.getElementById('dbar-l4').textContent = 'Масса';
|
||
document.getElementById('dbar-v4').textContent = info.m + ' кг';
|
||
document.getElementById('dbar-l5').textContent = 'μ';
|
||
document.getElementById('dbar-v5').textContent = info.mu;
|
||
} else if (law === 1) {
|
||
document.getElementById('dbar-l1').textContent = 'Закон I-B';
|
||
document.getElementById('dbar-v1').textContent = info.cut ? 'Нить срублена' : 'Вращение';
|
||
document.getElementById('dbar-l2').textContent = 'Скорость';
|
||
document.getElementById('dbar-v2').textContent = info.v + ' м/с';
|
||
document.getElementById('dbar-l3').textContent = '';
|
||
document.getElementById('dbar-v3').textContent = '—';
|
||
document.getElementById('dbar-l4').textContent = '';
|
||
document.getElementById('dbar-v4').textContent = '—';
|
||
document.getElementById('dbar-l5').textContent = '';
|
||
document.getElementById('dbar-v5').textContent = '—';
|
||
} else if (law === 2) {
|
||
document.getElementById('dbar-l1').textContent = 'Закон II';
|
||
document.getElementById('dbar-v1').textContent = 'F = ma';
|
||
document.getElementById('dbar-l2').textContent = 'Сила F';
|
||
document.getElementById('dbar-v2').textContent = info.F + ' Н';
|
||
document.getElementById('dbar-l3').textContent = 'Масса m';
|
||
document.getElementById('dbar-v3').textContent = info.m + ' кг';
|
||
document.getElementById('dbar-l4').textContent = 'Ускор. a';
|
||
document.getElementById('dbar-v4').textContent = info.a + ' м/с²';
|
||
document.getElementById('dbar-l5').textContent = 'Скорость';
|
||
document.getElementById('dbar-v5').textContent = info.v + ' м/с';
|
||
} else if (scene === 'A') {
|
||
document.getElementById('dbar-l1').textContent = 'Закон III-A';
|
||
document.getElementById('dbar-v1').textContent = 'Пушка';
|
||
document.getElementById('dbar-l2').textContent = 'v снаряда';
|
||
document.getElementById('dbar-v2').textContent = info.vBall !== '—' ? info.vBall + ' м/с' : '—';
|
||
document.getElementById('dbar-l3').textContent = 'v пушки';
|
||
document.getElementById('dbar-v3').textContent = info.vCannon + ' м/с';
|
||
document.getElementById('dbar-l4').textContent = 'm снаряда';
|
||
document.getElementById('dbar-v4').textContent = info.m1 + ' кг';
|
||
document.getElementById('dbar-l5').textContent = 'm пушки';
|
||
document.getElementById('dbar-v5').textContent = info.m2 + ' кг';
|
||
} else if (scene === 'B') {
|
||
document.getElementById('dbar-l1').textContent = 'Закон III-B';
|
||
document.getElementById('dbar-v1').textContent = 'Удар';
|
||
document.getElementById('dbar-l2').textContent = 'p₁';
|
||
document.getElementById('dbar-v2').textContent = info.p1 + ' кг·м/с';
|
||
document.getElementById('dbar-l3').textContent = 'p₂';
|
||
document.getElementById('dbar-v3').textContent = info.p2 + ' кг·м/с';
|
||
document.getElementById('dbar-l4').textContent = 'p суммарный';
|
||
document.getElementById('dbar-v4').textContent = info.pt + ' кг·м/с';
|
||
document.getElementById('dbar-l5').textContent = '';
|
||
document.getElementById('dbar-v5').textContent = '—';
|
||
} else {
|
||
document.getElementById('dbar-l1').textContent = 'Закон III-C';
|
||
document.getElementById('dbar-v1').textContent = 'Ракета';
|
||
document.getElementById('dbar-l2').textContent = 'Ускорение';
|
||
document.getElementById('dbar-v2').textContent = info.a + ' м/с²';
|
||
document.getElementById('dbar-l3').textContent = 'Скорость';
|
||
document.getElementById('dbar-v3').textContent = info.v + ' м/с';
|
||
document.getElementById('dbar-l4').textContent = 'Масса';
|
||
document.getElementById('dbar-v4').textContent = info.m + ' кг';
|
||
document.getElementById('dbar-l5').textContent = 'Топливо';
|
||
document.getElementById('dbar-v5').textContent = info.fuel + '%';
|
||
}
|
||
}
|
||
|
||
// _openSandbox is now handled by _openDynamics + dynMode
|
||
|
||
function sbTool(t, btn) {
|
||
if (!sandboxSim) return;
|
||
sandboxSim.tool = t;
|
||
sandboxSim._springStart = null;
|
||
sandboxSim._ropeStart = null;
|
||
document.querySelectorAll('.sb-tool-btn').forEach(b => b.classList.toggle('active', b.id === 'sbt-' + t));
|
||
document.querySelectorAll('.sb-panel-tool').forEach(b => b.classList.toggle('active', b.id === 'sbpt-' + t));
|
||
const canvas = document.getElementById('sandbox-canvas');
|
||
canvas.style.cursor = t === 'erase' ? 'not-allowed'
|
||
: (t === 'spring' || t === 'rope') ? 'cell'
|
||
: t === 'anchor' ? 'copy'
|
||
: 'crosshair';
|
||
document.getElementById('sb-spring-block').style.display = t === 'spring' ? '' : 'none';
|
||
}
|
||
|
||
function sbSpringKChange() {
|
||
const v = +document.getElementById('sl-sb-springk').value;
|
||
document.getElementById('sb-springk-val').textContent = v + ' Н/м';
|
||
if (sandboxSim) sandboxSim.newSpringK = v;
|
||
}
|
||
|
||
function sbForceMode(m, btn) {
|
||
if (!sandboxSim) return;
|
||
sandboxSim.forceMode = m;
|
||
document.querySelectorAll('.sb-fmode').forEach(b => b.classList.toggle('active', b.id === 'sbfm-' + m));
|
||
}
|
||
|
||
function sbMassChange() {
|
||
const v = +document.getElementById('sl-sb-mass').value;
|
||
document.getElementById('sb-mass-val').textContent = v + ' кг';
|
||
if (sandboxSim) sandboxSim.newMass = v;
|
||
}
|
||
|
||
function sbRestChange() {
|
||
const v = +document.getElementById('sl-sb-rest').value;
|
||
document.getElementById('sb-rest-val').textContent = v.toFixed(2);
|
||
if (sandboxSim) sandboxSim.newRestitution = v;
|
||
}
|
||
|
||
function sbFloorMuChange() {
|
||
const v = +document.getElementById('sl-sb-floormu').value;
|
||
document.getElementById('sb-floormu-val').textContent = v.toFixed(2);
|
||
if (sandboxSim) sandboxSim.floorMu = v;
|
||
}
|
||
|
||
function sbWorldToggle() {
|
||
if (!sandboxSim) return;
|
||
sandboxSim.gravity = document.getElementById('sb-gravity').checked;
|
||
sandboxSim.hasFloor = document.getElementById('sb-floor').checked;
|
||
sandboxSim.hasWalls = document.getElementById('sb-walls').checked;
|
||
sandboxSim.airDrag = document.getElementById('sb-airdrag').checked;
|
||
}
|
||
|
||
function sbRampToggle() {
|
||
if (!sandboxSim) return;
|
||
const on = document.getElementById('sb-ramp').checked;
|
||
sandboxSim.setRamp(on);
|
||
document.getElementById('sb-ramp-block').style.display = on ? '' : 'none';
|
||
}
|
||
|
||
function sbAngleChange() {
|
||
const v = +document.getElementById('sl-sb-angle').value;
|
||
document.getElementById('sb-angle-val').textContent = v + '°';
|
||
if (sandboxSim) sandboxSim.setRampAngle(v);
|
||
}
|
||
|
||
function sbRampMuChange() {
|
||
const v = +document.getElementById('sl-sb-rampmu').value;
|
||
document.getElementById('sb-rampmu-val').textContent = v.toFixed(2);
|
||
if (sandboxSim) sandboxSim.setRampMu(v);
|
||
}
|
||
|
||
function sbDecompToggle() {
|
||
if (!sandboxSim) return;
|
||
sandboxSim.showDecomp = document.getElementById('sb-decomp').checked;
|
||
}
|
||
|
||
function sbDisplayToggle() {
|
||
if (!sandboxSim) return;
|
||
sandboxSim.showForces = document.getElementById('sb-forces').checked;
|
||
sandboxSim.showVelocity = document.getElementById('sb-vel').checked;
|
||
sandboxSim.showFBD = document.getElementById('sb-fbd').checked;
|
||
sandboxSim.showEnergy = document.getElementById('sb-energy').checked;
|
||
sandboxSim.showTrail = document.getElementById('sb-trail').checked;
|
||
}
|
||
|
||
function sbTimeScale(v, btn) {
|
||
if (!sandboxSim) return;
|
||
sandboxSim.timeScale = v;
|
||
document.querySelectorAll('.sb-time').forEach(b => b.classList.remove('active'));
|
||
if (btn) btn.classList.add('active');
|
||
}
|
||
|
||
function sbPreset(name) {
|
||
if (!sandboxSim) return;
|
||
sandboxSim.preset(name);
|
||
// sync world checkboxes
|
||
document.getElementById('sb-gravity').checked = sandboxSim.gravity;
|
||
document.getElementById('sb-floor').checked = sandboxSim.hasFloor;
|
||
document.getElementById('sb-walls').checked = sandboxSim.hasWalls;
|
||
document.getElementById('sb-airdrag').checked = sandboxSim.airDrag;
|
||
document.getElementById('sl-sb-floormu').value = sandboxSim.floorMu;
|
||
document.getElementById('sb-floormu-val').textContent = sandboxSim.floorMu.toFixed(2);
|
||
// sync ramp
|
||
document.getElementById('sb-ramp').checked = sandboxSim.ramp;
|
||
document.getElementById('sb-ramp-block').style.display = sandboxSim.ramp ? '' : 'none';
|
||
document.getElementById('sl-sb-angle').value = sandboxSim.rampAngle;
|
||
document.getElementById('sb-angle-val').textContent = sandboxSim.rampAngle + '°';
|
||
document.getElementById('sl-sb-rampmu').value = sandboxSim.rampMu;
|
||
document.getElementById('sb-rampmu-val').textContent = sandboxSim.rampMu.toFixed(2);
|
||
_sbUpdateUI(sandboxSim.info());
|
||
}
|
||
|
||
function sbReset() {
|
||
if (!sandboxSim) return;
|
||
sandboxSim.reset();
|
||
_sbUpdateUI(sandboxSim.info());
|
||
}
|
||
|
||
function _sbUpdateUI(info) {
|
||
if (!info) return;
|
||
document.getElementById('dbar-l1').textContent = 'Тел / связей';
|
||
document.getElementById('dbar-v1').textContent = info.bodies + ' / ' + (info.springs + info.ropes);
|
||
document.getElementById('dbar-l2').textContent = 'KE (Дж)';
|
||
document.getElementById('dbar-v2').textContent = info.KE;
|
||
document.getElementById('dbar-l3').textContent = 'PE (Дж)';
|
||
document.getElementById('dbar-v3').textContent = info.PE;
|
||
document.getElementById('dbar-l4').textContent = 'ΣF';
|
||
document.getElementById('dbar-v4').textContent = info.netF;
|
||
document.getElementById('dbar-l5').textContent = 'Время';
|
||
document.getElementById('dbar-v5').textContent = info.time + ' с';
|
||
}
|
||
|
||
/* ── chem sandbox ── */
|
||
|