feat(labs): механика V2 — 4 ключевые симы школьной физики расширены

pendulum V2 (472 → 1651 строк):
- Математический (default, сохранён)
- Двойной маятник (Lagrangian RK4, ghost-копия для демо хаоса)
- Связанные маятники (биения, чарт θ₁/θ₂)
- Пружинный (вертикальный/горизонтальный, T=2π√(m/k))
- Физический (4 формы: стержень/обруч/диск/прямоугольник, с моментом инерции)
- Маятник Фуко (Кориолис, slider широты, период прецессии)
- Резонанс (внешняя F₀·cos(ω_d·t), резонансная кривая A(ω))
- Фазовый портрет (универсальный toggle для всех режимов)

collision V2 (~1000 → 2416 строк):
- 1D (default, сохранён)
- 2D под углом (импульс по осям, slider e, до/после стат)
- Multi-ball (N=2-10, стены с отскоками, перемешать)
- Бильярдный стол (6 луз, кий с прицелом, треугольник шаров, реалистичное трение)
- Реф.фрейм ЦМ (universal toggle)

newton V2 (1693 → 2585 строк):
- 4-й закон-таб «Классические задачи»
- Машина Атвуда (a=(m₂-m₁)g/(m₁+m₂), идеальный/массивный блок)
- Тело на наклонной плоскости (FBD, статика/скольжение, slider α/μ/F_app)
- Скатывание шара/цилиндра/обруча (момент инерции, гонка, наглядно почему обруч медленнее)

projectile V2 (1900 → 2400 строк):
- Парашют: F_d = ½C_d·ρ·A·v² с терминальной скоростью v_t = √(2mg/(C_d·ρ·A))
- C_d selector: парашют/куб/сфера/полусфера/диск; раскрытие парашюта на заданной высоте
- Горка-катапульта: v_0 = √(2gL(sinα-μcosα)) автомат
- 10 планет: Земля/Луна/Марс/Юпитер/Меркурий/Венера/Сатурн/Уран/Нептун/Плутон
  с реальными g + плотностью атмосферы (для drag)
- Сравнительный режим: 3 планеты одновременно с разными цветами

Все 4 симы — additive, существующая функциональность сохранена.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-26 14:14:42 +03:00
parent 7ffed45974
commit e46548d06b
5 changed files with 4485 additions and 349 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1374 -195
View File
File diff suppressed because it is too large Load Diff
+529 -54
View File
@@ -1,11 +1,12 @@
'use strict';
/* ═══════════════════════════════════════════════════════════════════
ProjectileSim v3 — physics simulation
ProjectileSim v4 — physics simulation
Features: air drag (RK4) · wind · bounce · speed multiplier
ghost trail comparison · velocity vector labels
range arrow · landing angle · canvas click play/pause
target challenge mode · x/y/vx/vy graphs · dual throw
parachute physics · ramp launch · multi-planet gravity
═══════════════════════════════════════════════════════════════════ */
class ProjectileSim {
@@ -86,6 +87,43 @@ class ProjectileSim {
t: 0, trail: [],
};
/* ── Feature 4: parachute ── */
this.parachute = false; // parachute mode on/off
this.chuteArea = 1.0; // A m² cross-section
this.chuteCd = 1.5; // drag coefficient (preset: parachute)
this.chuteOpenHeight = -1; // -1 = immediate; >=0 = open at this altitude
this._chuteOpen = false; // runtime: is chute deployed?
this._chuteOpenedTs = -999; // perf.now when deployed
this._chimeEmitted = false; // v_t chime fired once per run
/* ── Feature 5: ramp launch ── */
this.ramp = false; // ramp/slope mode on/off
this.rampAngle = 30; // degrees
this.rampLength = 10; // m
this.rampMu = 0.1; // friction coefficient
this._rampV0 = 0; // computed launch speed from ramp
/* ── Feature 6: planet gravity ── */
// planets table: { name, g, rho } (rho = atmospheric density kg/m³)
this.planets = [
{ id: 'earth', name: 'Земля', g: 9.81, rho: 1.225 },
{ id: 'moon', name: 'Луна', g: 1.62, rho: 0 },
{ id: 'mars', name: 'Марс', g: 3.71, rho: 0.020 },
{ id: 'venus', name: 'Венера', g: 8.87, rho: 65 },
{ id: 'jupiter', name: 'Юпитер', g: 24.79, rho: 1.3 },
{ id: 'mercury', name: 'Меркурий', g: 3.7, rho: 0 },
{ id: 'saturn', name: 'Сатурн', g: 10.44, rho: 0.19 },
{ id: 'uranus', name: 'Уран', g: 8.69, rho: 0.42 },
{ id: 'neptune', name: 'Нептун', g: 11.15, rho: 0.45 },
{ id: 'pluto', name: 'Плутон', g: 0.62, rho: 0.0001 },
];
this.planetId = 'earth'; // active planet
this.rho = 1.225; // air density (set by planet or override)
/* ── Feature 6b: multi-planet compare ── */
this.planetCompare = false; // show 3 planet trajectories simultaneously
this.comparePlanets = ['earth', 'moon', 'mars']; // which 3
canvas.addEventListener('click', () => {
if (this.onPlayPause) this.onPlayPause();
});
@@ -110,19 +148,42 @@ class ProjectileSim {
getParams() {
return { v0: this.v0, angle: this.angle, h0: this.h0, g: this.g,
drag: this.drag, Cd: this.Cd, mass: this.mass, wind: this.wind,
bounce: this.bounce, restitution: this.restitution };
bounce: this.bounce, restitution: this.restitution,
parachute: this.parachute, chuteArea: this.chuteArea, chuteCd: this.chuteCd,
chuteOpenHeight: this.chuteOpenHeight,
ramp: this.ramp, rampAngle: this.rampAngle, rampLength: this.rampLength, rampMu: this.rampMu,
planetId: this.planetId };
}
setParams({ v0, angle, h0, g, drag, Cd, mass, wind, bounce, restitution } = {}) {
if (v0 !== undefined) this.v0 = +v0;
if (angle !== undefined) this.angle = +angle;
if (h0 !== undefined) this.h0 = +h0;
if (g !== undefined) this.g = +g;
if (drag !== undefined) this.drag = !!drag;
if (Cd !== undefined) this.Cd = +Cd;
if (mass !== undefined) this.mass = Math.max(0.1, +mass);
if (wind !== undefined) this.wind = +wind;
if (bounce !== undefined) this.bounce = !!bounce;
if (restitution !== undefined) this.restitution = Math.max(0, Math.min(1, +restitution));
setParams({ v0, angle, h0, g, drag, Cd, mass, wind, bounce, restitution,
parachute, chuteArea, chuteCd, chuteOpenHeight,
ramp, rampAngle, rampLength, rampMu,
planetId } = {}) {
if (v0 !== undefined) this.v0 = +v0;
if (angle !== undefined) this.angle = +angle;
if (h0 !== undefined) this.h0 = +h0;
if (g !== undefined) this.g = +g;
if (drag !== undefined) this.drag = !!drag;
if (Cd !== undefined) this.Cd = +Cd;
if (mass !== undefined) this.mass = Math.max(0.1, +mass);
if (wind !== undefined) this.wind = +wind;
if (bounce !== undefined) this.bounce = !!bounce;
if (restitution !== undefined) this.restitution = Math.max(0, Math.min(1, +restitution));
if (parachute !== undefined) this.parachute = !!parachute;
if (chuteArea !== undefined) this.chuteArea = Math.max(0.1, +chuteArea);
if (chuteCd !== undefined) this.chuteCd = +chuteCd;
if (chuteOpenHeight !== undefined) this.chuteOpenHeight = +chuteOpenHeight;
if (ramp !== undefined) this.ramp = !!ramp;
if (rampAngle !== undefined) this.rampAngle = Math.max(1, Math.min(89, +rampAngle));
if (rampLength !== undefined) this.rampLength = Math.max(1, +rampLength);
if (rampMu !== undefined) this.rampMu = Math.max(0, Math.min(1, +rampMu));
if (planetId !== undefined) {
this.planetId = planetId;
const pl = this.planets.find(p => p.id === planetId);
if (pl) {
this.g = pl.g;
this.rho = pl.rho;
}
}
this._computePath();
if (this.dualMode) this._computeP2Path();
this._resetFX();
@@ -135,7 +196,10 @@ class ProjectileSim {
play() {
if (this.playing) return;
if (this._pathTf > 0 && this.t >= this._pathTf) this._resetFX();
this._launchFlash = 1;
this._launchFlash = 1;
this._chuteOpen = this.parachute && this.chuteOpenHeight < 0;
this._chuteOpenedTs = this._chuteOpen ? performance.now() : -999;
this._chimeEmitted = false;
this.playing = true;
this._lastTs = null;
/* reset p2 at launch so both start simultaneously */
@@ -517,7 +581,7 @@ class ProjectileSim {
return;
}
const rho = 1.225, A = 0.00785;
const rho = this.rho, A = 0.00785;
const k = this.drag ? 0.5 * this.Cd * rho * A / Math.max(0.1, this.mass) : 0;
const g = this.g, W = this.wind, e = this.restitution;
const maxBounces = this.bounce ? 7 : 0;
@@ -573,12 +637,13 @@ class ProjectileSim {
/* pure analytical solution (no drag/wind/bounce) */
_stateAnalytical(t) {
const rad = this.angle * Math.PI / 180;
const vx = this.v0 * Math.cos(rad);
const vy0 = this.v0 * Math.sin(rad);
const launch = this._effectiveLaunch();
const rad = launch.angle * Math.PI / 180;
const vx = launch.v0 * Math.cos(rad);
const vy0 = launch.v0 * Math.sin(rad);
return {
x: vx * t,
y: this.h0 + vy0 * t - 0.5 * this.g * t * t,
y: launch.h0 + vy0 * t - 0.5 * this.g * t * t,
vx,
vy: vy0 - this.g * t,
};
@@ -586,18 +651,43 @@ class ProjectileSim {
/* analytical flight time (for reference / no-effect comparison) */
_tFlightAnalytical() {
const rad = this.angle * Math.PI / 180;
const vy0 = this.v0 * Math.sin(rad);
const disc = vy0 * vy0 + 2 * this.g * this.h0;
const launch = this._effectiveLaunch();
const rad = launch.angle * Math.PI / 180;
const vy0 = launch.v0 * Math.sin(rad);
const disc = vy0 * vy0 + 2 * this.g * launch.h0;
if (disc < 0) return 0;
return Math.max(0, (vy0 + Math.sqrt(disc)) / this.g);
}
_needsNumerical() {
return this.drag || this.wind !== 0 || this.bounce;
return this.drag || this.parachute || this.wind !== 0 || this.bounce || this.ramp;
}
/* RK4 integration — handles drag, wind, bounce */
/* compute launch speed from ramp: v = sqrt(2·g·L·sinα·(1-μ·cosα/sinα))
v = sqrt(2·g·L·(sinα - μ·cosα)) assuming μ < tanα else no motion */
_rampComputeV0() {
const a = this.rampAngle * Math.PI / 180;
const sin = Math.sin(a), cos = Math.cos(a);
const net = sin - this.rampMu * cos;
if (net <= 0) return 0;
return Math.sqrt(2 * this.g * this.rampLength * net);
}
/* effective launch angle = ramp angle when ramp is active */
_effectiveLaunch() {
if (this.ramp) {
const v = this._rampComputeV0();
return { v0: v, angle: this.rampAngle, h0: this.h0 };
}
return { v0: this.v0, angle: this.angle, h0: this.h0 };
}
/* terminal velocity for current parachute config */
_terminalVelocity() {
return Math.sqrt(2 * this.mass * this.g / (this.chuteCd * this.rho * this.chuteArea));
}
/* RK4 integration — handles drag, parachute, wind, bounce, ramp */
_computePath() {
if (!this._needsNumerical()) {
this._path = null;
@@ -605,28 +695,47 @@ class ProjectileSim {
return;
}
const rho = 1.225, A = 0.00785; // air density, ball cross-section
const k = this.drag ? 0.5 * this.Cd * rho * A / Math.max(0.1, this.mass) : 0;
const g = this.g;
const W = this.wind;
const e = this.restitution;
const rho = this.rho; // air density (planet-aware)
const A_ball = 0.00785; // small ball cross-section m²
const g = this.g;
const W = this.wind;
const e = this.restitution;
const maxBounces = this.bounce ? 7 : 0;
const mass = Math.max(0.1, this.mass);
const rad = this.angle * Math.PI / 180;
let x = 0, y = this.h0;
let vx = this.v0 * Math.cos(rad);
let vy = this.v0 * Math.sin(rad);
/* simple-drag k factor (ball drag, legacy mode) */
const kBall = this.drag && !this.parachute
? 0.5 * this.Cd * rho * A_ball / mass
: 0;
/* parachute: open immediately if chuteOpenHeight < 0, else on altitude trigger */
const chuteAutoOpen = this.parachute && this.chuteOpenHeight < 0;
const chuteThreshold = this.parachute ? Math.max(0, this.chuteOpenHeight) : Infinity;
const launch = this._effectiveLaunch();
const rad = launch.angle * Math.PI / 180;
let x = 0, y = launch.h0;
let vx = launch.v0 * Math.cos(rad);
let vy = launch.v0 * Math.sin(rad);
let chuteOpen = chuteAutoOpen;
const dt = 0.005;
const path = [{ x, y, vx, vy, t: 0 }];
const path = [{ x, y, vx, vy, t: 0, chuteOpen }];
let bounceCount = 0;
const deriv = (sx, sy, svx, svy) => {
const rvx = svx - W; // velocity relative to wind
const rvy = svy;
const deriv = (sx, sy, svx, svy, chute) => {
const rvx = svx - W;
const rvy = svy;
const speed = Math.sqrt(rvx * rvx + rvy * rvy);
const dragF = speed > 0 ? k * speed : 0;
// wind-only pseudo-force when drag is off (simplified model)
const windAcc = (!this.drag && W !== 0) ? W * 0.05 : 0;
let dragF = 0;
if (chute) {
/* parachute: F_d = 0.5 * Cd * rho * A * v² / m → acceleration */
dragF = speed > 0
? 0.5 * this.chuteCd * rho * this.chuteArea * speed / mass
: 0;
} else if (kBall > 0) {
dragF = speed > 0 ? kBall * speed : 0;
}
const windAcc = (!this.drag && !chute && W !== 0) ? W * 0.05 : 0;
return {
dx: svx,
dy: svy,
@@ -636,10 +745,15 @@ class ProjectileSim {
};
for (let step = 0; step < 200000; step++) {
const k1 = deriv(x, y, vx, vy);
const k2 = deriv(x + k1.dx * dt / 2, y + k1.dy * dt / 2, vx + k1.dvx * dt / 2, vy + k1.dvy * dt / 2);
const k3 = deriv(x + k2.dx * dt / 2, y + k2.dy * dt / 2, vx + k2.dvx * dt / 2, vy + k2.dvy * dt / 2);
const k4 = deriv(x + k3.dx * dt, y + k3.dy * dt, vx + k3.dvx * dt, vy + k3.dvy * dt);
/* check if chute should open by altitude trigger */
if (this.parachute && !chuteOpen && y <= chuteThreshold && y > 0) {
chuteOpen = true;
}
const k1 = deriv(x, y, vx, vy, chuteOpen);
const k2 = deriv(x + k1.dx * dt / 2, y + k1.dy * dt / 2, vx + k1.dvx * dt / 2, vy + k1.dvy * dt / 2, chuteOpen);
const k3 = deriv(x + k2.dx * dt / 2, y + k2.dy * dt / 2, vx + k2.dvx * dt / 2, vy + k2.dvy * dt / 2, chuteOpen);
const k4 = deriv(x + k3.dx * dt, y + k3.dy * dt, vx + k3.dvx * dt, vy + k3.dvy * dt, chuteOpen);
x += (k1.dx + 2 * k2.dx + 2 * k3.dx + k4.dx) * dt / 6;
y += (k1.dy + 2 * k2.dy + 2 * k3.dy + k4.dy) * dt / 6;
@@ -655,11 +769,11 @@ class ProjectileSim {
const lvx = prev.vx + (vx - prev.vx) * frac;
const lvy = prev.vy + (vy - prev.vy) * frac;
const lt = prev.t + dt * frac;
path.push({ x: lx, y: 0, vx: lvx, vy: lvy, t: lt });
path.push({ x: lx, y: 0, vx: lvx, vy: lvy, t: lt, chuteOpen });
if (this.bounce && bounceCount < maxBounces && Math.abs(lvy) > 0.4) {
vy = -e * lvy;
vx = lvx * (1 - 0.04); // small horizontal friction
vx = lvx * (1 - 0.04);
y = 0.001;
x = lx;
bounceCount++;
@@ -669,13 +783,72 @@ class ProjectileSim {
break;
}
path.push({ x, y, vx, vy, t });
path.push({ x, y, vx, vy, t, chuteOpen });
}
this._path = path;
this._pathTf = path[path.length - 1].t;
}
/* compute a trajectory for a given planet (for compare mode) */
_computePlanetPath(planetId) {
const pl = this.planets.find(p => p.id === planetId) || this.planets[0];
const rho = pl.rho;
const g = pl.g;
const W = this.wind;
const mass = Math.max(0.1, this.mass);
const A_ball = 0.00785;
const kBall = this.drag ? 0.5 * this.Cd * rho * A_ball / mass : 0;
const rad = this.angle * Math.PI / 180;
let x = 0, y = this.h0;
let vx = this.v0 * Math.cos(rad);
let vy = this.v0 * Math.sin(rad);
const dt = 0.005;
const path = [{ x, y, vx, vy, t: 0 }];
const deriv2 = (sx, sy, svx, svy) => {
const rvx = svx - W;
const rvy = svy;
const speed = Math.sqrt(rvx * rvx + rvy * rvy);
const dragF = speed > 0 ? kBall * speed : 0;
const windAcc = (!this.drag && W !== 0) ? W * 0.05 : 0;
return {
dx: svx, dy: svy,
dvx: -dragF * rvx + windAcc,
dvy: -g - dragF * rvy,
};
};
for (let step = 0; step < 100000; step++) {
const k1 = deriv2(x, y, vx, vy);
const k2 = deriv2(x + k1.dx * dt / 2, y + k1.dy * dt / 2, vx + k1.dvx * dt / 2, vy + k1.dvy * dt / 2);
const k3 = deriv2(x + k2.dx * dt / 2, y + k2.dy * dt / 2, vx + k2.dvx * dt / 2, vy + k2.dvy * dt / 2);
const k4 = deriv2(x + k3.dx * dt, y + k3.dy * dt, vx + k3.dvx * dt, vy + k3.dvy * dt);
x += (k1.dx + 2 * k2.dx + 2 * k3.dx + k4.dx) * dt / 6;
y += (k1.dy + 2 * k2.dy + 2 * k3.dy + k4.dy) * dt / 6;
vx += (k1.dvx + 2 * k2.dvx + 2 * k3.dvx + k4.dvx) * dt / 6;
vy += (k1.dvy + 2 * k2.dvy + 2 * k3.dvy + k4.dvy) * dt / 6;
const t = (step + 1) * dt;
if (y <= 0) {
const prev = path[path.length - 1];
if (prev && prev.y > 0) {
const frac = prev.y / (prev.y - y);
path.push({
x: prev.x + (x - prev.x) * frac,
y: 0,
vx: prev.vx + (vx - prev.vx) * frac,
vy: prev.vy + (vy - prev.vy) * frac,
t: prev.t + dt * frac,
});
}
break;
}
path.push({ x, y, vx, vy, t });
}
return path;
}
_pathStateAt(t) {
const path = this._path;
if (!path || path.length < 2) return { x: 0, y: this.h0, vx: 0, vy: 0 };
@@ -768,6 +941,39 @@ class ProjectileSim {
if (this.targetMode) this._targetAttempts++;
}
/* parachute: check altitude-triggered deployment */
if (this.parachute && !this._chuteOpen && this.chuteOpenHeight >= 0) {
const cs = this._curState(this.t);
if (cs.y <= this.chuteOpenHeight && cs.y > 0) {
this._chuteOpen = true;
this._chuteOpenedTs = performance.now();
if (window.LabFX) {
LabFX.sound.play('whoosh');
const _vp = this._viewParams;
if (_vp) {
const scX = (_vp.W - _vp.PL - _vp.PR) / _vp.xMax;
const scY = (_vp.H - _vp.PB - _vp.PT) / _vp.yMax;
LabFX.particles.emit({
ctx: this.ctx,
x: _vp.PL + cs.x * scX, y: _vp.H - _vp.PB - cs.y * scY,
count: 30, color: ['#06D6E0', '#FFD166'], speed: 90,
spread: Math.PI, angle: -Math.PI / 2, life: 800, glow: true, shape: 'spark',
});
}
}
}
}
/* parachute: chime when ~90% terminal velocity reached */
if (this.parachute && this._chuteOpen && !this._chimeEmitted) {
const vt = this._terminalVelocity();
const spd = Math.sqrt(cur.vx ** 2 + cur.vy ** 2);
if (spd <= vt * 1.1) {
this._chimeEmitted = true;
if (window.LabFX) LabFX.sound.play('chime');
}
}
/* target hit detection on this step interval */
this._checkTargetHits(prevT, Math.min(this.t, tf));
@@ -830,11 +1036,14 @@ class ProjectileSim {
}
_resetFX() {
this.t = 0;
this._trail = [];
this._sparks = [];
this._impactTs = -999;
this._launchFlash = 0;
this.t = 0;
this._trail = [];
this._sparks = [];
this._impactTs = -999;
this._launchFlash = 0;
this._chuteOpen = this.parachute && this.chuteOpenHeight < 0;
this._chuteOpenedTs = -999;
this._chimeEmitted = false;
this._computePath();
if (this.dualMode) {
this._p2.t = 0;
@@ -966,6 +1175,33 @@ class ProjectileSim {
for (let y = stY; y < yMax * 0.97; y += stY)
ctx.fillText(_projFmt(y) + ' м', PL - 6, tpy(y));
/* ── 6.4. Planet compare trajectories ── */
if (this.planetCompare) {
const PCOLORS = ['#06D6E0', '#7BF5A4', '#F15BB5'];
for (let ci = 0; ci < this.comparePlanets.length; ci++) {
const pid = this.comparePlanets[ci];
const pl = this.planets.find(p => p.id === pid);
if (!pl) continue;
const ppath = this._computePlanetPath(pid);
const col = PCOLORS[ci % PCOLORS.length];
ctx.strokeStyle = col; ctx.lineWidth = 1.8; ctx.setLineDash([5, 3]);
ctx.beginPath();
for (let i = 0; i < ppath.length; i++) {
const pp = ppath[i];
i === 0 ? ctx.moveTo(tpx(pp.x), tpy(Math.max(0, pp.y)))
: ctx.lineTo(tpx(pp.x), tpy(Math.max(0, pp.y)));
}
ctx.stroke(); ctx.setLineDash([]);
/* label at landing */
const plast = ppath[ppath.length - 1];
const plx = tpx(plast.x), ply = tpy(0);
ctx.fillStyle = col;
ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(pl.name, plx, ply + 8);
ctx.fillText(_projFmt(plast.x) + ' м', plx, ply + 20);
}
}
/* ── 6.5. Ghost trails ── */
for (const gh of this._ghosts) {
ctx.strokeStyle = gh.color; ctx.lineWidth = 2;
@@ -1009,7 +1245,7 @@ class ProjectileSim {
}
/* ── 7. Launch platform ── */
if (this.h0 > 0.2) {
if (this.h0 > 0.2 && !this.ramp) {
const px0 = tpx(0), py0 = tpy(0), pyH = tpy(this.h0);
ctx.strokeStyle = 'rgba(255,200,60,.35)'; ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
@@ -1022,6 +1258,40 @@ class ProjectileSim {
ctx.fillText(_projFmt(this.h0) + ' м', px0 - 14, pyH);
}
/* ── 7.5. Ramp visualization ── */
if (this.ramp) {
const rA = this.rampAngle * Math.PI / 180;
const rL = this.rampLength;
/* ramp starts at (0, h0) going left-down at angle rA */
const rxStart = -rL * Math.cos(rA);
const ryStart = this.h0;
const rxEnd = 0;
const ryEnd = this.h0 + rL * Math.sin(rA); /* ramp bottom */
/* clamp start x to left edge */
const sx = Math.max(PL, tpx(rxStart));
const sy = tpy(ryStart);
const ex = tpx(rxEnd);
const ey = tpy(ryEnd);
/* ramp surface */
ctx.strokeStyle = 'rgba(255,180,50,.7)'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
/* angle arc */
ctx.strokeStyle = 'rgba(255,200,60,.5)'; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.arc(ex, ey, 22, -Math.PI / 2, -rA - Math.PI / 2, true); ctx.stroke();
ctx.fillStyle = 'rgba(255,200,60,.8)';
ctx.font = '9px Manrope'; ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
ctx.fillText(this.rampAngle + '°', ex + 25, ey - 2);
/* ramp speed label */
const rv = this._rampComputeV0();
ctx.fillStyle = 'rgba(255,214,102,.7)';
ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText('v = ' + rv.toFixed(1) + ' м/с', (sx + ex) / 2, (sy + ey) / 2 - 4);
}
/* ── 8. Reference / full trajectories ── */
if (tf > 0) {
// analytical reference (always shown as faint dashed)
@@ -1311,6 +1581,36 @@ class ProjectileSim {
ctx.beginPath(); ctx.arc(bx, by, 10, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,.6)'; ctx.lineWidth = 1.5; ctx.stroke();
/* ── 14.5. Parachute ── */
if (this.parachute && this._chuteOpen && this.t < tf && cur.y > 0) {
this._drawParachute(ctx, bx, by);
}
/* ── 14.6. Parachute HUD ── */
if (this.parachute) {
const vt = this._terminalVelocity();
const pct = Math.min(100, Math.round((1 - (speed - vt) / Math.max(vt, 0.01)) * 100));
const pctC = Math.min(100, Math.round(speed / (vt * 2 + 0.01) * 100));
const hudRows = [
'v = ' + speed.toFixed(1) + ' м/с',
'v_t = ' + vt.toFixed(1) + ' м/с',
(this._chuteOpen ? 'Откр' : 'Закр') + ' ' + Math.max(0, Math.min(100, Math.round(vt / Math.max(speed, 0.01) * 100))) + '%',
];
const hudX = W - PR - 8;
const hudY = PT + 34;
ctx.font = '9px Manrope, sans-serif';
const maxW = Math.max(...hudRows.map(r => ctx.measureText(r).width));
ctx.fillStyle = 'rgba(5,5,20,.8)';
ctx.beginPath(); ctx.roundRect(hudX - maxW - 20, hudY - 4, maxW + 24, hudRows.length * 16 + 8, 7); ctx.fill();
ctx.strokeStyle = 'rgba(6,214,224,.4)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(hudX - maxW - 20, hudY - 4, maxW + 24, hudRows.length * 16 + 8, 7); ctx.stroke();
for (let ri = 0; ri < hudRows.length; ri++) {
ctx.fillStyle = ri === 2 ? (this._chuteOpen ? '#7BF5A4' : '#FFD166') : '#06D6E0';
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
ctx.fillText(hudRows[ri], hudX - 6, hudY + ri * 16);
}
}
/* ── 15. Velocity arrows + labels ── */
if (speed > 0.3 && this.t < tf) {
const VX_LEN = Math.min(55, 50 * Math.abs(cur.vx) / Math.max(1, this.v0));
@@ -1380,10 +1680,14 @@ class ProjectileSim {
/* ── 18. Info badges (top-right) ── */
let bRight = W - PR - 8;
if (this.drag) {
if (this.drag && !this.parachute) {
this._drawBadge(ctx, bRight, PT + 6, 'Cd=' + this.Cd.toFixed(2) + ' m=' + this.mass + 'кг', 'rgba(239,71,111,.15)', 'rgba(239,71,111,.75)');
bRight -= 130;
}
if (this.parachute) {
this._drawBadge(ctx, bRight, PT + 6, 'A=' + this.chuteArea.toFixed(1) + 'м² Cd=' + this.chuteCd, 'rgba(6,214,224,.12)', 'rgba(6,214,224,.8)');
bRight -= 150;
}
if (this.wind !== 0) {
const dir = this.wind > 0 ? '→' : '←';
this._drawBadge(ctx, bRight, PT + 6, dir + ' ветер ' + Math.abs(this.wind) + 'м/с', 'rgba(6,214,224,.12)', 'rgba(6,214,224,.8)');
@@ -1391,6 +1695,17 @@ class ProjectileSim {
}
if (this.bounce) {
this._drawBadge(ctx, bRight, PT + 6, '↩ e=' + this.restitution.toFixed(2), 'rgba(123,245,164,.1)', 'rgba(123,245,164,.75)');
bRight -= 100;
}
if (this.ramp) {
this._drawBadge(ctx, bRight, PT + 6, 'Горка ' + this.rampAngle + '° L=' + this.rampLength + 'м', 'rgba(255,180,50,.12)', 'rgba(255,180,50,.85)');
bRight -= 140;
}
if (this.planetId !== 'earth') {
const pl = this.planets.find(p => p.id === this.planetId);
if (pl) {
this._drawBadge(ctx, bRight, PT + 6, pl.name + ' g=' + pl.g, 'rgba(123,245,164,.1)', 'rgba(123,245,164,.8)');
}
}
/* speed badge bottom-right */
@@ -1607,6 +1922,47 @@ class ProjectileSim {
}
ctx.restore();
}
/* Draw parachute dome above the ball */
_drawParachute(ctx, bx, by) {
const now = performance.now();
const age = (now - this._chuteOpenedTs) / 1000;
/* deploy animation: scale from 0 to 1 over 0.3 s */
const scale = Math.min(1, age / 0.3);
const R = 26 * scale; /* dome radius */
const cy = by - R - 12; /* centre of dome */
ctx.save();
/* dome fill */
const fill = ctx.createRadialGradient(bx, cy, 0, bx, cy, R);
fill.addColorStop(0, 'rgba(6,214,224,0.55)');
fill.addColorStop(0.7, 'rgba(6,214,224,0.25)');
fill.addColorStop(1, 'rgba(6,214,224,0.05)');
ctx.fillStyle = fill;
ctx.beginPath();
ctx.arc(bx, cy, R, Math.PI, 0);
ctx.lineTo(bx + R, cy);
ctx.closePath();
ctx.fill();
/* dome border */
ctx.strokeStyle = 'rgba(6,214,224,0.75)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(bx, cy, R, Math.PI, 0);
ctx.stroke();
/* suspension lines (4) */
ctx.strokeStyle = 'rgba(255,255,255,0.45)'; ctx.lineWidth = 0.8;
for (let li = 0; li < 4; li++) {
const a = Math.PI + (li + 0.5) / 4 * Math.PI;
ctx.beginPath();
ctx.moveTo(bx + Math.cos(a) * R, cy + Math.sin(a) * R);
ctx.lineTo(bx, by - 10);
ctx.stroke();
}
ctx.restore();
}
}
/* ── module helpers ── */
@@ -1921,5 +2277,124 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
if (pSim && pSim._graphsVisible && !pSim.playing) pSim.drawGraphs();
}
/* ── Feature 4: Parachute UI ── */
function projToggleParachute(rowEl) {
if (!pSim) return;
pSim.parachute = !pSim.parachute;
const on = pSim.parachute;
if (rowEl) rowEl.classList.toggle('active', on);
const tog = document.getElementById('chute-toggle');
if (tog) {
tog.style.background = on ? 'var(--cyan,#06D6E0)' : 'rgba(255,255,255,0.12)';
tog.querySelector('span').style.marginLeft = on ? '14px' : '2px';
}
document.getElementById('chute-params').style.display = on ? '' : 'none';
/* parachute and simple drag are mutually exclusive */
if (on) pSim.setParams({ parachute: true, drag: false });
else pSim.setParams({ parachute: false });
/* also reflect drag row */
const dragRow = document.getElementById('drag-row');
if (dragRow) dragRow.classList.toggle('active', false);
const dragTog = document.getElementById('drag-toggle');
if (dragTog) {
dragTog.style.background = 'rgba(255,255,255,0.12)';
dragTog.querySelector('span').style.marginLeft = '2px';
}
document.getElementById('drag-params').style.display = 'none';
}
function projChuteAreaChange() {
const A = +document.getElementById('sl-chute-area').value / 10;
document.getElementById('p-chute-area').textContent = A.toFixed(1) + ' м²';
if (pSim) pSim.setParams({ chuteArea: A });
}
function projChuteCdChange() {
const sel = document.getElementById('sel-chute-cd');
if (!sel || !pSim) return;
const cd = +sel.value;
pSim.setParams({ chuteCd: cd });
}
function projChuteHeightChange() {
const val = +document.getElementById('sl-chute-height').value;
const h = val <= 0 ? -1 : val;
const lbl = document.getElementById('p-chute-height');
if (lbl) lbl.textContent = h < 0 ? 'Сразу' : h.toFixed(0) + ' м';
if (pSim) pSim.setParams({ chuteOpenHeight: h });
}
/* ── Feature 5: Ramp UI ── */
function projToggleRamp(rowEl) {
if (!pSim) return;
pSim.ramp = !pSim.ramp;
const on = pSim.ramp;
if (rowEl) rowEl.classList.toggle('active', on);
const tog = document.getElementById('ramp-toggle');
if (tog) {
tog.style.background = on ? 'rgba(255,180,50,.9)' : 'rgba(255,255,255,0.12)';
tog.querySelector('span').style.marginLeft = on ? '14px' : '2px';
}
document.getElementById('ramp-params').style.display = on ? '' : 'none';
pSim.setParams({ ramp: on });
}
function projRampChange() {
const angle = +document.getElementById('sl-ramp-angle').value;
const length = +document.getElementById('sl-ramp-length').value;
const mu = +document.getElementById('sl-ramp-mu').value / 100;
document.getElementById('p-ramp-angle').textContent = angle + '°';
document.getElementById('p-ramp-length').textContent = length + ' м';
document.getElementById('p-ramp-mu').textContent = mu.toFixed(2);
if (pSim) pSim.setParams({ rampAngle: angle, rampLength: length, rampMu: mu });
}
/* ── Feature 6: Planet UI ── */
function projPlanetChange() {
const sel = document.getElementById('sel-planet');
if (!sel || !pSim) return;
const planetId = sel.value;
pSim.planetId = planetId;
const pl = pSim.planets.find(p => p.id === planetId);
if (pl) {
pSim.g = pl.g;
pSim.rho = pl.rho;
/* sync g slider */
const gSl = document.getElementById('sl-g');
if (gSl) {
gSl.value = Math.min(+gSl.max, pl.g);
document.getElementById('p-g').textContent = pl.g.toFixed(2) + ' м/с²';
}
}
pSim._computePath();
if (pSim.dualMode) pSim._computeP2Path();
pSim._resetFX();
pSim.draw();
pSim._emit();
}
function projTogglePlanetCompare() {
if (!pSim) return;
pSim.planetCompare = !pSim.planetCompare;
const on = pSim.planetCompare;
const btn = document.getElementById('proj-planet-compare-btn');
if (btn) {
btn.classList.toggle('active', on);
btn.querySelector('span').textContent = on ? 'Сравн.планет: Вкл' : 'Сравн.планет: Выкл';
}
const panel = document.getElementById('proj-planet-compare-panel');
if (panel) panel.style.display = on ? '' : 'none';
pSim.draw();
}
function projPlanetCompareChange(idx, val) {
if (!pSim) return;
pSim.comparePlanets[idx] = val;
pSim.draw();
}
/* ── collision ── */