Files
Learn_System/frontend/js/labs/projectile.js
T
Maxim Dolgolyov e46548d06b 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>
2026-05-26 14:14:42 +03:00

2401 lines
89 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ═══════════════════════════════════════════════════════════════════
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 {
constructor(canvas) {
this.c = canvas;
this.ctx = canvas.getContext('2d');
/* ── physics params ── */
this.v0 = 20;
this.angle = 45;
this.h0 = 2;
this.g = 9.81;
/* air resistance */
this.drag = false;
this.Cd = 0.3;
this.mass = 1; // kg
/* wind (m/s, positive = tailwind / right) */
this.wind = 0;
/* bounce */
this.bounce = false;
this.restitution = 0.7;
/* animation speed multiplier */
this.speed = 1;
/* computed trajectory (null = use analytical) */
this._path = null; // [{x, y, vx, vy, t}]
this._pathTf = 0;
/* animation state */
this.t = 0;
this.playing = false;
this._raf = null;
this._lastTs = null;
/* visual effects */
this._trail = [];
this._sparks = [];
this._impactTs = -999;
this._launchFlash = 0;
this._stars = this._genStars(90);
/* ghost trails for comparison */
this._ghosts = [];
this._ghostIdx = 0;
this._GHOST_COLORS = [
'rgba(255,214,102,.45)',
'rgba(6,214,224,.45)',
'rgba(123,245,164,.45)',
'rgba(255,140,66,.45)',
];
this.onUpdate = null;
this.onPlayPause = null; // called by canvas click
/* hover inspector */
this._hover = null; // { t, s } | null
this._viewParams = null; // coordinate transform params (set in draw)
/* ── Feature 1: target challenge mode ── */
this.targetMode = false;
this._targets = []; // [{x,y,w,h,hit,flashTs}]
this._targetAttempts = 0;
this.onTargetUpdate = null; // callback → ({hits, total, attempts})
/* ── Feature 2: graphs panel ── */
this._graphsCanvas = null; // set by attachGraphsCanvas()
this._graphsVisible = false;
/* ── Feature 3: dual throw ── */
this.dualMode = false;
this._p2 = { // second projectile params + live state
v0: 25, angle: 30, h0: 0,
path: null, pathTf: 0,
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();
});
canvas.addEventListener('mousemove', e => this._onMouseMove(e));
canvas.addEventListener('mouseleave', () => this._onMouseLeave());
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── public API ── */
fit() {
const dpr = window.devicePixelRatio || 1;
const r = this.c.parentElement.getBoundingClientRect();
const w = r.width || 600, h = r.height || 400;
this.c.width = w * dpr;
this.c.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this._cw = w; this._ch = h;
}
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,
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,
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();
this.draw();
this._emit();
}
setSpeed(s) { this.speed = +s; }
play() {
if (this.playing) return;
if (this._pathTf > 0 && this.t >= this._pathTf) this._resetFX();
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 */
if (this.dualMode) { this._p2.t = 0; this._p2.trail = []; }
/* LabFX: launch effects */
if (window.LabFX) {
const _vp = this._viewParams;
const _H = _vp ? _vp.H : (this._ch || this.c.height);
const _PL = _vp ? _vp.PL : 54, _PB = _vp ? _vp.PB : 44;
const launchX = _vp ? _PL : 54;
const launchY = _vp ? _H - _PB - (this.h0 || 0) * (_H - _PB - (_vp.PT || 26)) / _vp.yMax : _H - 44;
LabFX.sound.play('whoosh');
LabFX.particles.emit({
ctx: this.ctx, x: launchX, y: launchY,
count: 18, color: '#FFD166', speed: 120,
spread: Math.PI / 3, angle: -Math.PI / 3,
life: 500, glow: true, shape: 'spark',
});
}
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
reset() {
this.pause();
this._resetFX();
this.draw();
this._emit();
}
/* ghost trails */
saveGhost() {
if (this._pathTf <= 0) return;
const points = [];
if (this._path) {
for (const p of this._path) points.push({ x: p.x, y: p.y });
} else {
const tf = this._pathTf;
for (let i = 0; i <= 200; i++) {
const s = this._stateAnalytical((i / 200) * tf);
points.push({ x: s.x, y: s.y });
}
}
const st = this.stats();
const windStr = this.wind !== 0 ? ` ветер ${this.wind > 0 ? '+' : ''}${this.wind}` : '';
const label = `${this.angle}° ${this.v0}м/с${windStr}${this.drag ? ' +drag' : ''}${this.bounce ? ' ↩' : ''}`;
const color = this._GHOST_COLORS[this._ghostIdx % this._GHOST_COLORS.length];
this._ghostIdx++;
this._ghosts.push({ points, color, label, range: st.range, hMax: st.hMax });
if (this._ghosts.length > 4) this._ghosts.shift();
this.draw();
}
clearGhosts() {
this._ghosts = [];
this._ghostIdx = 0;
this.draw();
}
/* ── Feature 1: target mode ── */
toggleTargetMode() {
this.targetMode = !this.targetMode;
if (this.targetMode && this._targets.length === 0) this.genTargets();
this._emitTargets();
this.draw();
return this.targetMode;
}
genTargets() {
const st = this.stats();
const range = Math.max(st.range, 10);
const hMax = Math.max(st.hMax, 5);
const count = 3;
this._targets = [];
for (let i = 0; i < count; i++) {
const tw = 1.0 + Math.random() * 1.5; // window width 12.5 m
const th = 1.0 + Math.random() * 1.5; // window height 12.5 m
/* spread windows across [10%, 90%] of range so they're reachable */
const x = range * (0.1 + 0.8 * (i + Math.random() * 0.5) / count);
const y = 1.0 + Math.random() * Math.max(1, hMax * 0.7);
this._targets.push({ x, y, w: tw, h: th, hit: false, flashTs: -999 });
}
this._targetAttempts = 0;
this._emitTargets();
this.draw();
}
_checkTargetHits(prevT, nextT) {
if (!this.targetMode || this._targets.length === 0) return;
/* sample a few sub-steps between prevT and nextT for precision */
const steps = 8;
for (let s = 0; s <= steps; s++) {
const t = prevT + (nextT - prevT) * (s / steps);
const st = this._curState(t);
if (st.y < 0) break;
for (const tgt of this._targets) {
if (tgt.hit) continue;
if (st.x >= tgt.x && st.x <= tgt.x + tgt.w &&
st.y >= tgt.y && st.y <= tgt.y + tgt.h) {
tgt.hit = true;
tgt.flashTs = performance.now();
this._emitTargets();
/* LabFX: target hit effects */
if (window.LabFX) {
const hx = this.tx ? this.tx(st.x) : st.x;
const hy = this.ty ? this.ty(st.y) : st.y;
LabFX.sound.play('chime');
LabFX.particles.emit({
ctx: this.ctx, x: hx, y: hy,
count: 40, color: ['#FFD700', '#FFA500', '#FF6B35'],
speed: 140, spread: Math.PI * 2, life: 900,
glow: true, shape: 'spark',
});
LabFX.haptic([15, 30, 15]);
}
}
}
}
}
_emitTargets() {
if (!this.onTargetUpdate) return;
const hits = this._targets.filter(t => t.hit).length;
this.onTargetUpdate({ hits, total: this._targets.length, attempts: this._targetAttempts });
}
_drawTargets(ctx, tpx, tpy) {
if (!this.targetMode) return;
const now = performance.now();
for (const tgt of this._targets) {
const cx = tpx(tgt.x);
const cy = tpy(tgt.y + tgt.h); // top edge in canvas coords
const cw = tpx(tgt.x + tgt.w) - tpx(tgt.x);
const ch = tpy(tgt.y) - tpy(tgt.y + tgt.h); // positive height
const flashAge = (now - tgt.flashTs) / 1000;
const flashing = flashAge < 1.2;
if (tgt.hit) {
/* gold fill on hit */
const alpha = flashing ? 0.25 + 0.2 * Math.sin(flashAge * 18) : 0.18;
ctx.fillStyle = `rgba(255,214,102,${alpha})`;
ctx.fillRect(cx, cy, cw, ch);
ctx.strokeStyle = flashing
? `rgba(255,214,102,${0.7 + 0.3 * Math.sin(flashAge * 18)})`
: 'rgba(255,214,102,.6)';
ctx.lineWidth = 2.5;
ctx.strokeRect(cx, cy, cw, ch);
/* checkmark */
const mx = cx + cw / 2, my = cy + ch / 2;
ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(mx - cw * 0.22, my);
ctx.lineTo(mx - cw * 0.05, my + ch * 0.22);
ctx.lineTo(mx + cw * 0.28, my - ch * 0.28);
ctx.stroke();
} else {
/* inactive window: translucent blue rect with dashed border */
ctx.fillStyle = 'rgba(6,214,224,.06)';
ctx.fillRect(cx, cy, cw, ch);
ctx.strokeStyle = 'rgba(6,214,224,.5)';
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 3]);
ctx.strokeRect(cx, cy, cw, ch);
ctx.setLineDash([]);
/* small cross in top-right corner to look like a window frame */
ctx.strokeStyle = 'rgba(6,214,224,.3)'; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + cw / 2, cy); ctx.lineTo(cx + cw / 2, cy + ch);
ctx.moveTo(cx, cy + ch / 2); ctx.lineTo(cx + cw, cy + ch / 2);
ctx.stroke();
}
}
}
/* ── Feature 2: graphs canvas attachment ── */
attachGraphsCanvas(canvas) {
this._graphsCanvas = canvas;
}
drawGraphs() {
const gc = this._graphsCanvas;
if (!gc || !this._graphsVisible) return;
const dpr = window.devicePixelRatio || 1;
const W = gc.clientWidth || gc.width / dpr;
const H = gc.clientHeight || gc.height / dpr;
if (!W || !H) return;
/* keep physical pixel size in sync */
if (gc.width !== Math.round(W * dpr) || gc.height !== Math.round(H * dpr)) {
gc.width = Math.round(W * dpr);
gc.height = Math.round(H * dpr);
}
const gctx = gc.getContext('2d');
gctx.setTransform(dpr, 0, 0, dpr, 0, 0);
gctx.clearRect(0, 0, W, H);
const tf = this._curTFlight();
if (tf <= 0) {
gctx.fillStyle = 'rgba(255,255,255,.2)';
gctx.font = '11px Manrope, sans-serif';
gctx.textAlign = 'center'; gctx.textBaseline = 'middle';
gctx.fillText('Запустите симуляцию', W / 2, H / 2);
return;
}
/* collect full trajectory data for plotting */
const N = 200;
const pts = [];
for (let i = 0; i <= N; i++) {
const t = (i / N) * tf;
const s = this._curState(t);
pts.push({ t, x: s.x, y: Math.max(0, s.y), vx: s.vx, vy: s.vy });
}
const plots = [
{ key: 'x', label: 'x(t)', unit: 'м', color: '#06D6E0' },
{ key: 'y', label: 'y(t)', unit: 'м', color: '#7BF5A4' },
{ key: 'vx', label: 'vx(t)', unit: 'м/с', color: '#9B5DE5' },
{ key: 'vy', label: 'vy(t)', unit: 'м/с', color: '#F15BB5' },
];
const cols = 2, rows = 2;
const PL = 36, PR = 10, PT = 20, PB = 22;
const cw = W / cols, ch = H / rows;
const pw = cw - PL - PR, ph = ch - PT - PB;
/* current time marker fraction */
const curFrac = tf > 0 ? Math.min(1, this.t / tf) : 0;
for (let pi = 0; pi < plots.length; pi++) {
const col = pi % cols, row = Math.floor(pi / cols);
const ox = col * cw, oy = row * ch;
const plot = plots[pi];
const vals = pts.map(p => p[plot.key]);
const vMin = Math.min(...vals), vMax = Math.max(...vals);
const vRange = Math.max(vMax - vMin, 0.1);
const tx = t => ox + PL + (t / tf) * pw;
const ty = v => oy + PT + ph - ((v - vMin) / vRange) * ph;
/* background */
gctx.fillStyle = 'rgba(5,5,20,.85)';
gctx.fillRect(ox + PL, oy + PT, pw, ph);
/* grid lines */
gctx.strokeStyle = 'rgba(255,255,255,.05)'; gctx.lineWidth = 1;
for (let gi = 1; gi < 4; gi++) {
const gv = vMin + (gi / 4) * vRange;
const gy = ty(gv);
gctx.beginPath(); gctx.moveTo(ox + PL, gy); gctx.lineTo(ox + PL + pw, gy); gctx.stroke();
}
/* axes */
gctx.strokeStyle = 'rgba(255,255,255,.25)'; gctx.lineWidth = 1.2;
gctx.beginPath();
gctx.moveTo(ox + PL, oy + PT); gctx.lineTo(ox + PL, oy + PT + ph);
gctx.lineTo(ox + PL + pw, oy + PT + ph);
gctx.stroke();
/* axis labels */
gctx.font = '9px Manrope, sans-serif';
gctx.fillStyle = 'rgba(255,255,255,.35)';
gctx.textAlign = 'right'; gctx.textBaseline = 'middle';
gctx.fillText(_projFmt(vMax) + ' ' + plot.unit, ox + PL - 3, oy + PT + 4);
gctx.fillText(_projFmt(vMin) + ' ' + plot.unit, ox + PL - 3, oy + PT + ph - 2);
gctx.textAlign = 'center'; gctx.textBaseline = 'top';
gctx.fillText(_projFmt(tf) + ' с', ox + PL + pw, oy + PT + ph + 4);
/* data line */
gctx.strokeStyle = plot.color; gctx.lineWidth = 2;
gctx.beginPath();
for (let i = 0; i < pts.length; i++) {
const px = tx(pts[i].t), py = ty(pts[i][plot.key]);
i === 0 ? gctx.moveTo(px, py) : gctx.lineTo(px, py);
}
gctx.stroke();
/* second projectile overlay */
if (this.dualMode && this._p2.pathTf > 0) {
const tf2 = this._p2.pathTf;
const pts2 = [];
for (let i = 0; i <= N; i++) {
const t2 = (i / N) * tf2;
const s2 = this._p2.path ? this._p2PathStateAt(t2) : this._p2StateAnalytical(t2);
pts2.push({ t: t2, x: s2.x, y: Math.max(0, s2.y), vx: s2.vx, vy: s2.vy });
}
gctx.strokeStyle = 'rgba(0,230,255,.55)'; gctx.lineWidth = 1.5;
gctx.setLineDash([4, 3]);
gctx.beginPath();
for (let i = 0; i < pts2.length; i++) {
const px = tx(pts2[i].t), py = ty(pts2[i][plot.key]);
i === 0 ? gctx.moveTo(px, py) : gctx.lineTo(px, py);
}
gctx.stroke(); gctx.setLineDash([]);
}
/* current time indicator */
if (curFrac > 0) {
const curX = tx(this.t);
gctx.strokeStyle = 'rgba(255,214,102,.7)'; gctx.lineWidth = 1.5;
gctx.setLineDash([3, 3]);
gctx.beginPath(); gctx.moveTo(curX, oy + PT); gctx.lineTo(curX, oy + PT + ph); gctx.stroke();
gctx.setLineDash([]);
/* dot on the line */
const curV = this._curState(this.t)[plot.key];
const curVclamped = Math.min(vMax, Math.max(vMin, curV));
gctx.fillStyle = '#FFD166';
gctx.beginPath(); gctx.arc(curX, ty(curVclamped), 3.5, 0, Math.PI * 2); gctx.fill();
}
/* label */
gctx.font = 'bold 11px Manrope, sans-serif';
gctx.fillStyle = plot.color;
gctx.textAlign = 'left'; gctx.textBaseline = 'top';
gctx.fillText(plot.label, ox + PL + 5, oy + PT + 4);
}
}
/* ── Feature 3: second projectile helpers ── */
_p2StateAnalytical(t) {
const p2 = this._p2;
const rad = p2.angle * Math.PI / 180;
const vx = p2.v0 * Math.cos(rad);
const vy0 = p2.v0 * Math.sin(rad);
return {
x: vx * t,
y: p2.h0 + vy0 * t - 0.5 * this.g * t * t,
vx,
vy: vy0 - this.g * t,
};
}
_p2PathStateAt(t) {
const path = this._p2.path;
if (!path || path.length < 2) return { x: 0, y: this._p2.h0, vx: 0, vy: 0 };
if (t <= 0) return path[0];
if (t >= this._p2.pathTf) return path[path.length - 1];
let lo = 0, hi = path.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >> 1;
if (path[mid].t <= t) lo = mid; else hi = mid;
}
const a = path[lo], b = path[hi];
const frac = (t - a.t) / (b.t - a.t);
return {
x: a.x + (b.x - a.x) * frac,
y: a.y + (b.y - a.y) * frac,
vx: a.vx + (b.vx - a.vx) * frac,
vy: a.vy + (b.vy - a.vy) * frac,
};
}
_p2CurState(t) {
return this._p2.path ? this._p2PathStateAt(t) : this._p2StateAnalytical(t);
}
/* recompute second projectile path using same RK4 if drag/wind/bounce active */
_computeP2Path() {
const p2 = this._p2;
if (!this._needsNumerical()) {
const rad = p2.angle * Math.PI / 180;
const vy0 = p2.v0 * Math.sin(rad);
const disc = vy0 * vy0 + 2 * this.g * p2.h0;
p2.path = null;
p2.pathTf = disc < 0 ? 0 : Math.max(0, (vy0 + Math.sqrt(disc)) / this.g);
return;
}
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;
const rad = p2.angle * Math.PI / 180;
let x = 0, y = p2.h0;
let vx = p2.v0 * Math.cos(rad), vy = p2.v0 * Math.sin(rad);
const dt = 0.005;
const path = [{ x, y, vx, vy, t: 0 }];
let bounces = 0;
const deriv = (sx, sy, svx, svy) => {
const rvx = svx - W;
const speed = Math.sqrt(rvx * rvx + svy * svy);
const dragF = speed > 0 ? k * speed : 0;
const wAcc = (!this.drag && W !== 0) ? W * 0.05 : 0;
return { dx: svx, dy: svy, dvx: -dragF * rvx + wAcc, dvy: -g - dragF * svy };
};
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);
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 t2 = (step + 1) * dt;
if (y <= 0) {
const prev = path[path.length - 1];
if (prev && prev.y > 0) {
const frac = prev.y / (prev.y - y);
const lx = prev.x + (x - prev.x) * frac;
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 });
if (this.bounce && bounces < maxBounces && Math.abs(lvy) > 0.4) {
vy = -e * lvy; vx = lvx * 0.96; y = 0.001; x = lx;
bounces++;
continue;
}
}
break;
}
path.push({ x, y, vx, vy, t: t2 });
}
p2.path = path;
p2.pathTf = path[path.length - 1].t;
}
/* ── physics ── */
/* pure analytical solution (no drag/wind/bounce) */
_stateAnalytical(t) {
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: launch.h0 + vy0 * t - 0.5 * this.g * t * t,
vx,
vy: vy0 - this.g * t,
};
}
/* analytical flight time (for reference / no-effect comparison) */
_tFlightAnalytical() {
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.parachute || this.wind !== 0 || this.bounce || this.ramp;
}
/* 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;
this._pathTf = this._tFlightAnalytical();
return;
}
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);
/* 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, chuteOpen }];
let bounceCount = 0;
const deriv = (sx, sy, svx, svy, chute) => {
const rvx = svx - W;
const rvy = svy;
const speed = Math.sqrt(rvx * rvx + rvy * rvy);
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,
dvx: -dragF * rvx + windAcc,
dvy: -g - dragF * rvy,
};
};
for (let step = 0; step < 200000; step++) {
/* 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;
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);
const lx = prev.x + (x - prev.x) * frac;
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, chuteOpen });
if (this.bounce && bounceCount < maxBounces && Math.abs(lvy) > 0.4) {
vy = -e * lvy;
vx = lvx * (1 - 0.04);
y = 0.001;
x = lx;
bounceCount++;
continue;
}
}
break;
}
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 };
if (t <= 0) return path[0];
if (t >= this._pathTf) return path[path.length - 1];
let lo = 0, hi = path.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >> 1;
if (path[mid].t <= t) lo = mid; else hi = mid;
}
const a = path[lo], b = path[hi];
const frac = (t - a.t) / (b.t - a.t);
return {
x: a.x + (b.x - a.x) * frac,
y: a.y + (b.y - a.y) * frac,
vx: a.vx + (b.vx - a.vx) * frac,
vy: a.vy + (b.vy - a.vy) * frac,
};
}
_curState(t) {
return this._path ? this._pathStateAt(t) : this._stateAnalytical(t);
}
_curTFlight() { return this._pathTf; }
stats() {
const tf = this._pathTf;
const end = this._curState(tf);
let hMax = this.h0;
if (this._path) {
for (const p of this._path) if (p.y > hMax) hMax = p.y;
} else {
const rad = this.angle * Math.PI / 180;
const vy0 = this.v0 * Math.sin(rad);
const tMax = Math.max(0, vy0 / this.g);
hMax = Math.max(this.h0, this.h0 + vy0 * tMax - 0.5 * this.g * tMax * tMax);
}
const range = Math.max(0, end.x);
const vLand = Math.sqrt(end.vx ** 2 + end.vy ** 2);
const landAngle = vLand > 0.01
? Math.abs(Math.atan2(Math.abs(end.vy), Math.abs(end.vx)) * 180 / Math.PI)
: 0;
// range compared to pure analytical (no drag/wind/bounce)
let rangeLoss = 0;
if (this._needsNumerical()) {
const tfND = this._tFlightAnalytical();
const endND = this._stateAnalytical(tfND);
const rangeND = Math.max(0, endND.x);
if (rangeND > 0.1) rangeLoss = Math.round((range / rangeND - 1) * 100);
}
return {
tf, hMax, range, vLand, rangeLoss, landAngle,
t: this.t,
progress: tf > 0 ? Math.min(1, this.t / tf) : 0,
hasMod: this._needsNumerical(),
};
}
/* ── animation loop ── */
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (!this.playing) return;
if (this._lastTs === null) this._lastTs = ts;
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
if (window.LabFX) LabFX.particles.update(rawDt);
this._launchFlash = Math.max(0, this._launchFlash - rawDt * 2.5);
const prevT = this.t;
const cur = this._curState(this.t);
this._trail.push({ mx: cur.x, my: cur.y });
if (this._trail.length > 80) this._trail.shift();
this.t += rawDt * this.speed;
const tf = this._curTFlight();
if (this.t >= tf) {
this.t = tf;
this.playing = false;
this._triggerImpact();
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));
/* advance second projectile */
if (this.dualMode) {
const p2 = this._p2;
const p2cur = this._p2CurState(p2.t);
p2.trail.push({ mx: p2cur.x, my: p2cur.y });
if (p2.trail.length > 80) p2.trail.shift();
p2.t = Math.min(p2.t + rawDt * this.speed, p2.pathTf);
}
this.draw();
this._emit();
if (this._graphsVisible) this.drawGraphs();
if (this.playing) this._tick();
});
}
_triggerImpact() {
const end = this._curState(this._curTFlight());
this._impactTs = performance.now();
this._sparks = Array.from({ length: 18 }, (_, i) => {
const ang = (i / 18) * Math.PI * 2 + Math.random() * 0.3;
const spd = 40 + Math.random() * 80;
return { ang, spd, mx: end.x };
});
/* LabFX: landing effects */
if (window.LabFX) {
const _vp = this._viewParams;
const _W = _vp ? _vp.W : (this._cw || this.c.width);
const _H = _vp ? _vp.H : (this._ch || this.c.height);
const _PL = _vp ? _vp.PL : 54, _PB = _vp ? _vp.PB : 44;
const _scX = _vp ? (_W - _PL - (_vp.PR || 20)) / _vp.xMax : 1;
const _scY = _vp ? (_H - _PB - (_vp.PT || 26)) / _vp.yMax : 1;
const landX = _vp ? _PL + end.x * _scX : 54;
const landY = _vp ? _H - _PB : _H - 44;
LabFX.sound.play('bounce', { pitch: 0.6 });
LabFX.particles.emit({
ctx: this.ctx, x: landX, y: landY,
count: 30, color: '#8B7355', speed: 80,
spread: Math.PI, angle: -Math.PI / 2,
gravity: 200, life: 1200, shape: 'splash',
});
LabFX.shake(this.c, { intensity: 4, durMs: 200 });
LabFX.haptic(15);
}
this._tickFX();
}
_tickFX() {
const elapsed = (performance.now() - this._impactTs) / 1000;
if (elapsed < 1.8) {
this.draw(); this._emit();
requestAnimationFrame(() => this._tickFX());
} else {
this._sparks = [];
this.draw(); this._emit();
}
}
_resetFX() {
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;
this._p2.trail = [];
this._computeP2Path();
}
/* clear target hits so player can retry */
for (const tgt of this._targets) tgt.hit = false;
}
_emit() { if (this.onUpdate) this.onUpdate(this.stats()); }
/* ── stars ── */
_genStars(n) {
return Array.from({ length: n }, () => ({
rx: Math.random(), ry: Math.random(),
r: Math.random() * 1.1 + 0.2,
a: Math.random() * 0.55 + 0.15,
}));
}
/* ── main render ── */
draw() {
const W = this._cw || this.c.width, H = this._ch || this.c.height;
if (!W || !H) return;
const ctx = this.ctx;
const tf = this._curTFlight();
const st = this.stats();
const PL = 54, PR = 20, PT = 26, PB = 44;
const pw = W - PL - PR, ph = H - PT - PB;
let maxRange = Math.max(st.range, 1);
let maxH = Math.max(st.hMax, 1);
for (const gh of this._ghosts) {
if (gh.range > maxRange) maxRange = gh.range;
if (gh.hMax > maxH) maxH = gh.hMax;
}
const xMax = maxRange * 1.15;
const yMax = maxH * 1.35;
const scX = pw / xMax, scY = ph / yMax;
const tpx = mx => PL + mx * scX;
const tpy = my => H - PB - my * scY;
const gy = tpy(0);
/* store for hover inspector */
this._viewParams = { xMax, yMax, PL, PR, PT, PB, W, H };
/* ── 1. Sky ── */
const sky = ctx.createLinearGradient(0, 0, 0, gy);
sky.addColorStop(0, '#05050f');
sky.addColorStop(0.6, '#0d0d2a');
sky.addColorStop(1, '#141430');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, gy);
/* ── 2. Stars ── */
for (const s of this._stars) {
const sx = PL + s.rx * pw, sy = PT + s.ry * (gy - PT - 10);
ctx.fillStyle = `rgba(255,255,255,${s.a})`;
ctx.beginPath(); ctx.arc(sx, sy, s.r, 0, Math.PI * 2); ctx.fill();
}
/* ── 2.5. Wind streaks ── */
if (this.wind !== 0) {
this._drawWind(ctx, PL, PT, pw, gy - PT);
/* LabFX: wind dust particles */
if (window.LabFX && this.playing) {
const dir = this.wind > 0 ? 1 : -1;
const dustCount = Math.floor(3 + Math.random() * 3);
for (let _d = 0; _d < dustCount; _d++) {
const dustX = dir > 0 ? PL : PL + pw;
const dustY = PT + Math.random() * (gy - PT);
LabFX.particles.emit({
ctx, x: dustX, y: dustY,
count: 1, color: 'rgba(255,255,255,0.3)',
speed: 0, spread: 0, angle: 0,
life: 1500, shape: 'dust', gravity: 0,
_vx: this.wind * 5, _vy: -10,
});
}
}
}
/* ── 3. Ground ── */
const gnd = ctx.createLinearGradient(0, gy, 0, H - PB);
gnd.addColorStop(0, 'rgba(22,101,52,.35)');
gnd.addColorStop(1, 'rgba(15,23,42,.9)');
ctx.fillStyle = gnd;
ctx.fillRect(PL, gy, pw, H - PB - gy);
const gl = ctx.createLinearGradient(PL, 0, PL + pw, 0);
gl.addColorStop(0, 'rgba(34,197,94,.2)');
gl.addColorStop(0.15, 'rgba(74,222,128,.7)');
gl.addColorStop(1, 'rgba(34,197,94,.3)');
ctx.strokeStyle = gl; ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(PL, gy); ctx.lineTo(PL + pw, gy); ctx.stroke();
/* ── 4. Margin fills ── */
ctx.fillStyle = '#0A0A14';
ctx.fillRect(0, H - PB, W, PB);
ctx.fillRect(0, 0, PL, H);
ctx.fillRect(W - PR, 0, PR, H);
/* ── 5. Grid ── */
const stX = _projNiceStep(xMax, 6), stY = _projNiceStep(yMax, 5);
ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1;
for (let x = stX; x < xMax; x += stX) {
ctx.beginPath(); ctx.moveTo(tpx(x), PT); ctx.lineTo(tpx(x), H - PB); ctx.stroke();
}
for (let y = stY; y < yMax; y += stY) {
ctx.beginPath(); ctx.moveTo(PL, tpy(y)); ctx.lineTo(W - PR, tpy(y)); ctx.stroke();
}
/* ── 6. Axes + labels ── */
ctx.strokeStyle = 'rgba(255,255,255,.2)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(PL, PT); ctx.lineTo(PL, H - PB); ctx.stroke();
ctx.beginPath(); ctx.moveTo(PL, gy); ctx.lineTo(W - PR, gy); ctx.stroke();
ctx.font = '10px Manrope, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,.28)';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let x = stX; x < xMax * 0.97; x += stX)
ctx.fillText(_projFmt(x) + ' м', tpx(x), gy + 7);
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
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;
ctx.setLineDash([6, 4]);
ctx.beginPath();
for (let i = 0; i < gh.points.length; i++) {
const p = gh.points[i];
i === 0 ? ctx.moveTo(tpx(p.x), tpy(p.y)) : ctx.lineTo(tpx(p.x), tpy(p.y));
}
ctx.stroke(); ctx.setLineDash([]);
const last = gh.points[gh.points.length - 1];
const lx = tpx(last.x), ly = tpy(0);
ctx.strokeStyle = gh.color; ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(lx - 5, ly - 5); ctx.lineTo(lx + 5, ly + 5);
ctx.moveTo(lx + 5, ly - 5); ctx.lineTo(lx - 5, ly + 5);
ctx.stroke();
ctx.fillStyle = gh.color;
ctx.font = '9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(gh.label, lx, ly + 10);
}
/* ── 6.7. Target windows ── */
this._drawTargets(ctx, tpx, tpy);
/* ── 6.8. HUD: target counter (top-right inside canvas) ── */
if (this.targetMode && this._targets.length > 0) {
const hits = this._targets.filter(t => t.hit).length;
const hudText = `Цели: ${hits}/${this._targets.length} Попыток: ${this._targetAttempts}`;
ctx.font = 'bold 11px Manrope, sans-serif';
const tw = ctx.measureText(hudText).width;
const hx = W - PR - 8 - tw - 20, hy = PT + 30;
ctx.fillStyle = 'rgba(5,5,20,.75)';
ctx.beginPath(); ctx.roundRect(hx - 8, hy - 6, tw + 28, 26, 8); ctx.fill();
ctx.strokeStyle = 'rgba(255,214,102,.4)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(hx - 8, hy - 6, tw + 28, 26, 8); ctx.stroke();
ctx.fillStyle = '#FFD166';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(hudText, hx + 4, hy + 7);
}
/* ── 7. Launch platform ── */
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]);
ctx.beginPath(); ctx.moveTo(px0, py0); ctx.lineTo(px0, pyH); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(255,200,60,.25)';
ctx.fillRect(px0 - 12, pyH, 28, 4);
ctx.fillStyle = 'rgba(255,200,60,.5)';
ctx.font = '9px Manrope'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
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)
const noDragTf = this._tFlightAnalytical();
ctx.strokeStyle = 'rgba(155,93,229,.22)';
ctx.lineWidth = 1.5; ctx.setLineDash([7, 5]);
ctx.beginPath();
for (let i = 0; i <= 300; i++) {
const s = this._stateAnalytical((i / 300) * noDragTf);
i === 0 ? ctx.moveTo(tpx(s.x), tpy(s.y)) : ctx.lineTo(tpx(s.x), tpy(s.y));
}
ctx.stroke(); ctx.setLineDash([]);
// numerical path preview (if active)
if (this._path && this._path.length > 2) {
ctx.strokeStyle = this.drag ? 'rgba(239,71,111,.3)' : 'rgba(255,200,60,.35)';
ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]);
ctx.beginPath();
const step = Math.max(1, Math.floor(this._path.length / 300));
for (let i = 0; i < this._path.length; i += step) {
const p = this._path[i];
i === 0 ? ctx.moveTo(tpx(p.x), tpy(p.y)) : ctx.lineTo(tpx(p.x), tpy(p.y));
}
const last = this._path[this._path.length - 1];
ctx.lineTo(tpx(last.x), tpy(last.y));
ctx.stroke(); ctx.setLineDash([]);
}
}
/* ── 9. Flown path ── */
if (this.t > 0 && tf > 0) {
const s0 = this._curState(0), s1 = this._curState(Math.min(this.t, tf));
const grad = ctx.createLinearGradient(tpx(s0.x), tpy(s0.y), tpx(s1.x), tpy(s1.y));
grad.addColorStop(0, 'rgba(155,93,229,.4)');
grad.addColorStop(0.5, '#9B5DE5');
grad.addColorStop(1, '#F15BB5');
ctx.strokeStyle = grad; ctx.lineWidth = 3;
ctx.beginPath();
if (this._path) {
let first = true;
for (const p of this._path) {
if (p.t > this.t) break;
first ? (ctx.moveTo(tpx(p.x), tpy(p.y)), first = false)
: ctx.lineTo(tpx(p.x), tpy(p.y));
}
const cur = this._pathStateAt(this.t);
ctx.lineTo(tpx(cur.x), tpy(Math.max(0, cur.y)));
} else {
const steps = Math.max(2, Math.ceil(st.progress * 300));
for (let i = 0; i <= steps; i++) {
const s = this._stateAnalytical((i / 300) * tf);
i === 0 ? ctx.moveTo(tpx(s.x), tpy(s.y)) : ctx.lineTo(tpx(s.x), tpy(s.y));
}
}
ctx.stroke();
}
/* ── 10. Trail dots ── */
for (let i = 0; i < this._trail.length; i++) {
const frac = i / this._trail.length;
const tr = this._trail[i];
ctx.fillStyle = `rgba(241,91,181,${frac * 0.55})`;
ctx.beginPath(); ctx.arc(tpx(tr.mx), tpy(tr.my), frac * 5, 0, Math.PI * 2); ctx.fill();
}
/* ── 10.5. Dual throw — second projectile ── */
if (this.dualMode && this._p2.pathTf > 0) {
const p2 = this._p2;
const tf2 = p2.pathTf;
/* full reference trajectory */
ctx.strokeStyle = 'rgba(0,230,255,.25)'; ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]);
ctx.beginPath();
const step2 = Math.max(1, p2.path ? Math.floor(p2.path.length / 250) : 1);
if (p2.path) {
for (let i = 0; i < p2.path.length; i += step2) {
const pp = p2.path[i];
i === 0 ? ctx.moveTo(tpx(pp.x), tpy(pp.y)) : ctx.lineTo(tpx(pp.x), tpy(pp.y));
}
} else {
for (let i = 0; i <= 250; i++) {
const s2 = this._p2StateAnalytical((i / 250) * tf2);
i === 0 ? ctx.moveTo(tpx(s2.x), tpy(s2.y)) : ctx.lineTo(tpx(s2.x), tpy(s2.y));
}
}
ctx.stroke(); ctx.setLineDash([]);
/* flown path */
if (p2.t > 0) {
const s2_0 = this._p2CurState(0), s2_1 = this._p2CurState(Math.min(p2.t, tf2));
const g2 = ctx.createLinearGradient(tpx(s2_0.x), tpy(s2_0.y), tpx(s2_1.x), tpy(s2_1.y));
g2.addColorStop(0, 'rgba(0,230,255,.3)');
g2.addColorStop(1, '#00E6FF');
ctx.strokeStyle = g2; ctx.lineWidth = 3;
ctx.beginPath();
if (p2.path) {
let first = true;
for (const pp of p2.path) {
if (pp.t > p2.t) break;
first ? (ctx.moveTo(tpx(pp.x), tpy(pp.y)), first = false) : ctx.lineTo(tpx(pp.x), tpy(pp.y));
}
const ps2 = this._p2PathStateAt(p2.t);
ctx.lineTo(tpx(ps2.x), tpy(Math.max(0, ps2.y)));
} else {
const steps2 = Math.max(2, Math.ceil((p2.t / tf2) * 250));
for (let i = 0; i <= steps2; i++) {
const s2 = this._p2StateAnalytical((i / 250) * tf2);
i === 0 ? ctx.moveTo(tpx(s2.x), tpy(s2.y)) : ctx.lineTo(tpx(s2.x), tpy(s2.y));
}
}
ctx.stroke();
}
/* p2 trail dots */
for (let i = 0; i < p2.trail.length; i++) {
const frac = i / p2.trail.length;
const tr2 = p2.trail[i];
ctx.fillStyle = `rgba(0,230,255,${frac * 0.45})`;
ctx.beginPath(); ctx.arc(tpx(tr2.mx), tpy(tr2.my), frac * 4.5, 0, Math.PI * 2); ctx.fill();
}
/* p2 ball */
const c2 = this._p2CurState(Math.min(p2.t, tf2));
const b2x = tpx(c2.x), b2y = tpy(Math.max(0, c2.y));
const glo2 = ctx.createRadialGradient(b2x, b2y, 2, b2x, b2y, 28);
glo2.addColorStop(0, 'rgba(0,230,255,.45)');
glo2.addColorStop(1, 'transparent');
ctx.fillStyle = glo2;
ctx.beginPath(); ctx.arc(b2x, b2y, 28, 0, Math.PI * 2); ctx.fill();
const bg2 = ctx.createRadialGradient(b2x - 3, b2y - 3, 1, b2x, b2y, 10);
bg2.addColorStop(0, '#ffffff');
bg2.addColorStop(0.25, '#00E6FF');
bg2.addColorStop(1, '#0891b2');
ctx.fillStyle = bg2;
ctx.beginPath(); ctx.arc(b2x, b2y, 10, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,.6)'; ctx.lineWidth = 1.5; ctx.stroke();
/* p2 landing marker */
const end2 = this._p2CurState(tf2);
const lx2 = tpx(end2.x), ly2 = tpy(0);
ctx.strokeStyle = 'rgba(0,230,255,.6)'; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(lx2 - 6, ly2 - 6); ctx.lineTo(lx2 + 6, ly2 + 6);
ctx.moveTo(lx2 + 6, ly2 - 6); ctx.lineTo(lx2 - 6, ly2 + 6);
ctx.stroke();
ctx.fillStyle = 'rgba(0,230,255,.8)';
ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(_projFmt(end2.x) + ' м', lx2, ly2 + 8);
}
/* ── 11. Max height marker ── */
if (st.hMax > this.h0 + 0.2 && tf > 0) {
let mpx, mpy;
if (this._path) {
let best = this._path[0];
for (const p of this._path) if (p.y > best.y) best = p;
mpx = tpx(best.x); mpy = tpy(best.y);
} else {
const rad = this.angle * Math.PI / 180;
const vy0 = this.v0 * Math.sin(rad);
const tPk = vy0 / this.g;
const pk = this._stateAnalytical(Math.max(0, tPk));
mpx = tpx(pk.x); mpy = tpy(pk.y);
}
ctx.strokeStyle = 'rgba(255,200,60,.3)'; ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(PL, mpy); ctx.lineTo(mpx, mpy); ctx.stroke();
ctx.beginPath(); ctx.moveTo(mpx, mpy); ctx.lineTo(mpx, gy); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(255,200,60,.7)';
ctx.beginPath(); ctx.arc(mpx, mpy, 4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(255,200,60,.55)';
ctx.font = '10px Manrope'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText('↑ ' + _projFmt(st.hMax) + ' м', PL - 6, mpy);
}
/* ── 12. Landing marker + range arrow ── */
if (tf > 0) {
const lx = tpx(st.range), ly = tpy(0);
const elapsed = (performance.now() - this._impactTs) / 1000;
const pulse = (elapsed >= 0 && elapsed < 10) ? 0.7 + 0.3 * Math.sin(elapsed * 8) : 0.6;
// X mark
ctx.strokeStyle = `rgba(6,214,224,${pulse})`; ctx.lineWidth = 2;
const ms = 7;
ctx.beginPath();
ctx.moveTo(lx - ms, ly - ms); ctx.lineTo(lx + ms, ly + ms);
ctx.moveTo(lx + ms, ly - ms); ctx.lineTo(lx - ms, ly + ms);
ctx.stroke();
ctx.fillStyle = `rgba(6,214,224,${pulse * 0.8})`;
ctx.font = 'bold 10px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(_projFmt(st.range) + ' м', lx, ly + 9);
// <svg class="ic" viewBox="0 0 24 24"><line x1="3" y1="12" x2="21" y2="12"/><polyline points="8 17 3 12 8 7"/><polyline points="16 7 21 12 16 17"/></svg> range arrow
if (st.range > 0.5 && lx > PL + 30) {
const ay = gy + 20;
ctx.strokeStyle = 'rgba(6,214,224,.3)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(PL + 3, ay); ctx.lineTo(lx - 3, ay); ctx.stroke();
ctx.fillStyle = 'rgba(6,214,224,.3)';
ctx.beginPath(); ctx.moveTo(PL + 3, ay); ctx.lineTo(PL + 9, ay - 3); ctx.lineTo(PL + 9, ay + 3); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(lx - 3, ay); ctx.lineTo(lx - 9, ay - 3); ctx.lineTo(lx - 9, ay + 3); ctx.closePath(); ctx.fill();
}
if (st.hasMod && st.rangeLoss !== 0) {
const sign = st.rangeLoss > 0 ? '+' : '';
ctx.fillStyle = st.rangeLoss < 0 ? 'rgba(239,71,111,.7)' : 'rgba(123,245,164,.7)';
ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(sign + st.rangeLoss + '% от идеала', lx, ly + 22);
}
}
/* ── 13. Impact effects ── */
const impactElapsed = (performance.now() - this._impactTs) / 1000;
if (impactElapsed >= 0 && impactElapsed < 1.5 && tf > 0) {
const end = this._curState(tf);
const ix = tpx(end.x), iy = tpy(0);
const p = impactElapsed / 1.5;
for (let r = 0; r < 3; r++) {
const rp = Math.max(0, impactElapsed - r * 0.12);
if (rp <= 0) continue;
const rr = rp * 55 * (1 + r * 0.3);
const ra = Math.max(0, (0.5 - rp * 0.5) * (1 - r * 0.2));
ctx.strokeStyle = `rgba(6,214,224,${ra})`; ctx.lineWidth = 2 - r * 0.4;
ctx.beginPath(); ctx.ellipse(ix, iy, rr, rr * 0.28, 0, 0, Math.PI * 2); ctx.stroke();
}
if (impactElapsed < 0.6) {
const ca = (0.6 - impactElapsed) / 0.6;
const cg = ctx.createRadialGradient(ix, iy, 0, ix, iy, 30 + impactElapsed * 40);
cg.addColorStop(0, `rgba(255,230,100,${ca * 0.7})`);
cg.addColorStop(0.4, `rgba(241,91,181,${ca * 0.4})`);
cg.addColorStop(1, 'transparent');
ctx.fillStyle = cg;
ctx.beginPath(); ctx.arc(ix, iy, 60, 0, Math.PI * 2); ctx.fill();
}
for (const sp of this._sparks) {
if (impactElapsed > 1.0) continue;
const sa = Math.max(0, 1 - impactElapsed * 1.4);
const spd = sp.spd * impactElapsed;
const ex = ix + Math.cos(sp.ang) * spd;
const ey = iy + Math.sin(sp.ang) * spd * 0.4 - impactElapsed * impactElapsed * 120;
ctx.strokeStyle = `rgba(255,220,80,${sa})`; ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(ix + Math.cos(sp.ang) * spd * 0.6, iy + Math.sin(sp.ang) * spd * 0.6 * 0.4);
ctx.lineTo(ex, ey);
ctx.stroke();
}
const swR = impactElapsed * 120;
const swa = Math.max(0, 0.35 - p * 0.35);
ctx.strokeStyle = `rgba(255,255,255,${swa})`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(ix - swR, iy); ctx.lineTo(ix + swR, iy); ctx.stroke();
}
/* ── 14. Ball ── */
const cur = this._curState(Math.min(this.t, tf));
const bx = tpx(cur.x), by = tpy(Math.max(0, cur.y));
const speed = Math.sqrt(cur.vx ** 2 + cur.vy ** 2);
// shadow
const shadowX = tpx(cur.x);
const shadowA = Math.max(0, 0.25 - (by - gy) / (ph * 2));
if (shadowA > 0) {
const sh = ctx.createRadialGradient(shadowX, gy + 2, 0, shadowX, gy + 2, 18);
sh.addColorStop(0, `rgba(0,0,0,${shadowA})`);
sh.addColorStop(1, 'transparent');
ctx.fillStyle = sh;
ctx.beginPath(); ctx.ellipse(shadowX, gy + 3, 18, 5, 0, 0, Math.PI * 2); ctx.fill();
}
// glow
const glo = ctx.createRadialGradient(bx, by, 2, bx, by, 30);
glo.addColorStop(0, 'rgba(241,91,181,.5)');
glo.addColorStop(0.4, 'rgba(155,93,229,.25)');
glo.addColorStop(1, 'transparent');
ctx.fillStyle = glo;
ctx.beginPath(); ctx.arc(bx, by, 30, 0, Math.PI * 2); ctx.fill();
// ball body
const ballGrad = ctx.createRadialGradient(bx - 3, by - 3, 1, bx, by, 10);
ballGrad.addColorStop(0, '#ffffff');
ballGrad.addColorStop(0.25, '#F15BB5');
ballGrad.addColorStop(1, '#7c3aed');
ctx.fillStyle = ballGrad;
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));
const VY_LEN = Math.min(55, 50 * Math.abs(cur.vy) / Math.max(1, this.v0));
if (Math.abs(cur.vx) > 0.2) {
_projArrow(ctx, bx, by, bx + VX_LEN, by, '#06D6E0', 2);
ctx.fillStyle = '#06D6E0'; ctx.font = 'bold 9px Manrope';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(_projFmt(Math.abs(cur.vx)) + ' м/с', bx + VX_LEN / 2, by + 7);
}
if (Math.abs(cur.vy) > 0.2) {
const vyDir = cur.vy > 0 ? -1 : 1;
const vyCol = cur.vy > 0 ? '#9B5DE5' : '#F15BB5';
_projArrow(ctx, bx, by, bx, by + vyDir * VY_LEN, vyCol, 2);
ctx.fillStyle = vyCol; ctx.font = 'bold 9px Manrope';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(_projFmt(Math.abs(cur.vy)) + ' м/с', bx + 6, by + vyDir * VY_LEN / 2);
}
// total velocity arrow
const vLen = 48 * (speed / Math.max(1, this.v0));
_projArrow(ctx, bx, by,
bx + (cur.vx / speed) * vLen,
by - (cur.vy / speed) * vLen,
'#ffffff', 2.5);
}
/* ── 16. Launch flash ── */
if (this._launchFlash > 0) {
const f = this._launchFlash;
const rad = this.angle * Math.PI / 180;
for (let i = 0; i < 10; i++) {
const a = rad + (i / 10) * Math.PI * 2;
const len = f * (20 + i % 3 * 15);
ctx.strokeStyle = `rgba(255,230,100,${f * 0.8})`; ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(bx + Math.cos(a) * 12, by - Math.sin(a) * 12);
ctx.lineTo(bx + Math.cos(a) * len, by - Math.sin(a) * len);
ctx.stroke();
}
const halo = ctx.createRadialGradient(bx, by, 0, bx, by, f * 40);
halo.addColorStop(0, `rgba(255,230,100,${f * 0.5})`);
halo.addColorStop(0.5, `rgba(241,91,181,${f * 0.2})`);
halo.addColorStop(1, 'transparent');
ctx.fillStyle = halo;
ctx.beginPath(); ctx.arc(bx, by, f * 40, 0, Math.PI * 2); ctx.fill();
}
/* ── 17. Launch angle arc (idle) ── */
if (this.t < 0.04 && this.angle > 2 && !this.playing) {
const rad = this.angle * Math.PI / 180;
ctx.strokeStyle = 'rgba(255,200,60,.45)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(bx, by, 34, -rad, 0); ctx.stroke();
const ivLen = Math.min(70, 30 + this.v0 * 0.8);
ctx.strokeStyle = 'rgba(255,255,255,.35)'; ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]);
ctx.beginPath();
ctx.moveTo(bx, by);
ctx.lineTo(bx + Math.cos(rad) * ivLen, by - Math.sin(rad) * ivLen);
ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle = 'rgba(255,200,60,.75)';
ctx.font = 'bold 11px Manrope'; ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
ctx.fillText(this.angle + '°', bx + 38, by - 2);
}
/* ── 18. Info badges (top-right) ── */
let bRight = W - PR - 8;
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)');
bRight -= 130;
}
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 */
if (this.speed !== 1) {
this._drawBadge(ctx, W - PR - 8, H - PB - 28, '×' + this.speed, 'rgba(255,214,102,.12)', 'rgba(255,214,102,.8)');
}
/* ── 19. Hover inspector ── */
if (!this.playing && this._hover) {
this._drawInspector(ctx, tpx, tpy, PL, gy, W, H, PB, PT);
}
/* LabFX: particles overlay */
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
/* ── hover inspector ── */
_onMouseMove(e) {
if (this.playing) { this._hover = null; return; }
const tf = this._curTFlight();
if (tf <= 0 || !this._viewParams) { this._hover = null; return; }
const r = this.c.getBoundingClientRect();
const cw = this._cw || this.c.width, ch = this._ch || this.c.height;
const mx = (e.clientX - r.left) * (cw / r.width);
const my = (e.clientY - r.top) * (ch / r.height);
const { xMax, yMax, PL, PR, PT, PB, W, H } = this._viewParams;
const pw = W - PL - PR, ph = H - PT - PB;
const scX = pw / xMax, scY = ph / yMax;
const tpx = wx => PL + wx * scX;
const tpy = wy => H - PB - wy * scY;
let bestT = null, bestDist = Infinity;
const N = 400;
if (this._path) {
const step = Math.max(1, Math.floor(this._path.length / N));
for (let i = 0; i < this._path.length; i += step) {
const p = this._path[i];
const d = Math.hypot(tpx(p.x) - mx, tpy(Math.max(0, p.y)) - my);
if (d < bestDist) { bestDist = d; bestT = p.t; }
}
/* also check last point */
const last = this._path[this._path.length - 1];
const d = Math.hypot(tpx(last.x) - mx, tpy(Math.max(0, last.y)) - my);
if (d < bestDist) { bestDist = d; bestT = last.t; }
} else {
for (let i = 0; i <= N; i++) {
const t = (i / N) * tf;
const s = this._stateAnalytical(t);
const d = Math.hypot(tpx(s.x) - mx, tpy(Math.max(0, s.y)) - my);
if (d < bestDist) { bestDist = d; bestT = t; }
}
}
if (bestDist < 32 && bestT !== null) {
const s = this._curState(bestT);
this._hover = { t: bestT, s };
} else {
this._hover = null;
}
this.draw();
}
_onMouseLeave() {
this._hover = null;
this.draw();
}
_drawInspector(ctx, tpx, tpy, PL, gy, W, H, PB, PT) {
const { t, s } = this._hover;
const bx = tpx(s.x);
const by = tpy(Math.max(0, s.y));
const speed = Math.sqrt(s.vx ** 2 + s.vy ** 2);
const velAng = Math.atan2(s.vy, s.vx) * 180 / Math.PI;
/* ── crosshair lines ── */
ctx.save();
ctx.strokeStyle = 'rgba(255,214,102,.3)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx, gy); ctx.stroke();
ctx.beginPath(); ctx.moveTo(PL, by); ctx.lineTo(bx, by); ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
/* ── axis labels ── */
ctx.font = 'bold 9px Manrope';
ctx.fillStyle = 'rgba(255,214,102,.7)';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(_projFmt(Math.max(0, s.x)) + ' м', bx, gy + 6);
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(_projFmt(Math.max(0, s.y)) + ' м', PL - 4, by);
/* ── dot on trajectory ── */
const glow = ctx.createRadialGradient(bx, by, 0, bx, by, 14);
glow.addColorStop(0, 'rgba(255,214,102,.5)');
glow.addColorStop(1, 'transparent');
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(bx, by, 14, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#FFD166';
ctx.strokeStyle = 'rgba(255,255,255,.9)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(bx, by, 5.5, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
/* ── tooltip ── */
const rows = [
{ label: 't', val: t.toFixed(3) + ' с', color: '#FFD166' },
{ label: 'x', val: _projFmt(Math.max(0, s.x)) + ' м', color: '#06D6E0' },
{ label: 'y', val: _projFmt(Math.max(0, s.y)) + ' м', color: '#7BF5A4' },
{ label: '|v|', val: _projFmt(speed) + ' м/с', color: '#ffffff' },
{ label: 'vx', val: _projFmt(s.vx) + ' м/с', color: '#06D6E0' },
{ label: 'vy', val: _projFmt(s.vy) + ' м/с', color: '#9B5DE5' },
{ label: 'угол', val: velAng.toFixed(1) + '°', color: '#F15BB5' },
];
const padX = 10, padY = 8, lineH = 17;
const tw = 138, th = padY * 2 + rows.length * lineH;
/* position — avoid canvas edges */
let tx = bx + 16, ty = by - th / 2;
if (tx + tw > W - 22) tx = bx - tw - 16;
if (ty < PT + 4) ty = PT + 4;
if (ty + th > H - PB - 4) ty = H - PB - th - 4;
/* shadow */
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,.6)';
ctx.shadowBlur = 12;
ctx.fillStyle = 'rgba(8,8,18,.92)';
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 9); ctx.fill();
ctx.restore();
/* border */
ctx.strokeStyle = 'rgba(255,214,102,.35)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 9); ctx.stroke();
/* top accent line */
ctx.strokeStyle = 'rgba(255,214,102,.6)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(tx + 9, ty + 1);
ctx.lineTo(tx + tw - 9, ty + 1);
ctx.stroke();
/* rows */
ctx.font = '10px Manrope, sans-serif';
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const ry = ty + padY + i * lineH + lineH / 2;
/* separator */
if (i > 0) {
ctx.strokeStyle = 'rgba(255,255,255,.04)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(tx + 8, ry - lineH / 2); ctx.lineTo(tx + tw - 8, ry - lineH / 2); ctx.stroke();
}
ctx.fillStyle = 'rgba(255,255,255,.35)';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(row.label, tx + padX, ry);
ctx.fillStyle = row.color;
ctx.textAlign = 'right';
ctx.fillText(row.val, tx + tw - padX, ry);
}
/* connector dot */
ctx.fillStyle = '#FFD166';
ctx.strokeStyle = 'rgba(8,8,18,.9)';
ctx.lineWidth = 1.5;
const cx = tx < bx ? tx + tw : tx;
const cy = ty + th / 2;
ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
/* ── draw helpers ── */
_drawBadge(ctx, rightX, y, text, bg, fg) {
const bh = 20;
ctx.font = 'bold 9px Manrope';
const tw = ctx.measureText(text).width;
const bw = tw + 16;
const bx = rightX - bw;
ctx.fillStyle = bg;
ctx.beginPath(); ctx.roundRect(bx, y, bw, bh, 6); ctx.fill();
ctx.fillStyle = fg;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, bx + bw / 2, y + bh / 2);
}
_drawWind(ctx, x, y, w, h) {
const now = performance.now() / 1000;
const dir = this.wind > 0 ? 1 : -1;
const strength = Math.min(1, Math.abs(this.wind) / 20);
const count = Math.floor(4 + strength * 7);
const len = (18 + strength * 45) * dir;
ctx.save();
ctx.strokeStyle = '#06D6E0';
for (let i = 0; i < count; i++) {
const phase = ((i / count) + now * strength * 0.25) % 1;
const streak_x = dir > 0 ? x + phase * w : x + (1 - phase) * w;
const streak_y = y + (0.1 + (i / count) * 0.8) * h;
const alpha = 0.08 + strength * 0.15;
ctx.globalAlpha = alpha;
ctx.lineWidth = 0.8 + strength * 0.6;
ctx.beginPath(); ctx.moveTo(streak_x, streak_y); ctx.lineTo(streak_x + len, streak_y); ctx.stroke();
}
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 ── */
function _projNiceStep(range, n) {
const raw = range / n;
const p = Math.pow(10, Math.floor(Math.log10(raw)));
for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p;
return p;
}
function _projFmt(n) {
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
if (n >= 100) return Math.round(n).toString();
if (n >= 10) return n.toFixed(1);
return n.toFixed(2);
}
function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
const ang = Math.atan2(y2 - y1, x2 - x1);
ctx.save();
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lw;
ctx.shadowColor = color; ctx.shadowBlur = 6;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - 9 * Math.cos(ang - 0.4), y2 - 9 * Math.sin(ang - 0.4));
ctx.lineTo(x2 - 9 * Math.cos(ang + 0.4), y2 - 9 * Math.sin(ang + 0.4));
ctx.closePath(); ctx.fill();
ctx.restore();
}
/* ─── lab UI init ─────────────────────────────────── */
function _openProjectile() {
document.getElementById('sim-topbar-title').textContent = 'Бросок тела';
_simShow('sim-proj');
_simShow('ctrl-proj');
_registerSimState('projectile', () => pSim?.getParams(), st => pSim?.setParams(st));
if (_embedMode) _startStateEmit('projectile');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!pSim) {
pSim = new ProjectileSim(document.getElementById('proj-canvas'));
pSim.onUpdate = _projUpdateUI;
pSim.onPlayPause = projPlayPause;
pSim.onTargetUpdate = _projUpdateTargetHUD;
const gc = document.getElementById('proj-graphs-canvas');
if (gc) pSim.attachGraphsCanvas(gc);
}
pSim.fit();
projParam(); // sync sliders → sim
pSim.draw();
_projUpdateUI(pSim.stats());
}));
}
function projPlayPause() {
if (!pSim) return;
if (pSim.playing) {
pSim.pause();
} else {
pSim.play();
}
_projSyncPlayBtn();
}
function _projSyncPlayBtn() {
/* small topbar button */
const tb = document.getElementById('proj-play-btn');
/* big launch button */
const lb = document.getElementById('proj-launch-main');
const lbl = document.getElementById('proj-launch-label');
const lic = document.getElementById('proj-launch-icon');
if (!pSim) return;
const tf = pSim._curTFlight();
const done = !pSim.playing && pSim.t >= tf && pSim.t > 0;
const playing = pSim.playing;
/* topbar */
if (tb) {
tb.innerHTML = playing
? '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>'
: '<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
tb.title = playing ? 'Пауза' : 'Запустить';
tb.classList.toggle('active', playing);
}
/* big button */
if (lb && lbl && lic) {
lb.classList.toggle('paused', playing);
lb.classList.toggle('done', done && !playing);
if (playing) {
lic.innerHTML = '<rect x="5" y="3" width="4" height="18"/><rect x="15" y="3" width="4" height="18"/>';
lbl.textContent = 'Пауза';
} else if (done) {
lic.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
lbl.textContent = 'Повторить';
} else {
lic.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
lbl.textContent = 'Запустить';
}
}
}
function projParam() {
const v0 = +document.getElementById('sl-v0').value;
const angle = +document.getElementById('sl-angle').value;
const h0 = +document.getElementById('sl-h0').value;
const g = +document.getElementById('sl-g').value;
document.getElementById('p-v0').textContent = v0 + ' м/с';
document.getElementById('p-angle').textContent = angle + '°';
document.getElementById('p-h0').textContent = h0 + ' м';
document.getElementById('p-g').textContent = g.toFixed(2) + ' м/с²';
if (pSim) { pSim.setParams({ v0, angle, h0, g }); _projSyncPlayBtn(); }
}
function projPreset(v0, angle, h0, g) {
document.getElementById('sl-v0').value = v0;
document.getElementById('sl-angle').value = angle;
document.getElementById('sl-h0').value = h0;
document.getElementById('sl-g').value = g;
projParam();
}
function projToggleDrag(rowEl) {
if (!pSim) return;
pSim.drag = !pSim.drag;
const on = pSim.drag;
rowEl.classList.toggle('active', on);
const tog = document.getElementById('drag-toggle');
tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)';
tog.querySelector('span').style.marginLeft = on ? '14px' : '2px';
document.getElementById('drag-params').style.display = on ? '' : 'none';
document.getElementById('ps-loss-wrap').style.display = on ? '' : 'none';
if (on) {
const cd = +document.getElementById('sl-cd').value / 100;
const mass = +document.getElementById('sl-mass').value;
pSim.setParams({ drag: true, Cd: cd, mass });
} else {
pSim.setParams({ drag: false });
}
}
function projCdChange() {
const cd = +document.getElementById('sl-cd').value / 100;
document.getElementById('p-cd').textContent = cd.toFixed(2);
if (pSim) pSim.setParams({ Cd: cd });
}
function projMassChange() {
const mass = +document.getElementById('sl-mass').value;
document.getElementById('p-mass').textContent = mass + ' кг';
if (pSim) pSim.setParams({ mass });
}
function projWindChange() {
const wind = +document.getElementById('sl-wind').value;
const label = wind === 0 ? '0 м/с' : (wind > 0 ? '→ +' : '← ') + Math.abs(wind) + ' м/с';
document.getElementById('p-wind').textContent = label;
document.getElementById('ps-loss-wrap').style.display = wind !== 0 ? '' : (pSim && pSim.drag ? '' : 'none');
if (pSim) { pSim.setParams({ wind }); _projSyncPlayBtn(); }
}
function projToggleBounce(rowEl) {
if (!pSim) return;
pSim.bounce = !pSim.bounce;
const on = pSim.bounce;
rowEl.classList.toggle('active', on);
const tog = document.getElementById('bounce-toggle');
tog.style.background = on ? 'rgba(123,245,164,0.8)' : 'rgba(255,255,255,0.12)';
tog.querySelector('span').style.marginLeft = on ? '14px' : '2px';
document.getElementById('bounce-params').style.display = on ? '' : 'none';
const e = +document.getElementById('sl-restitution').value / 100;
pSim.setParams({ bounce: on, restitution: e });
}
function projRestitutionChange() {
const e = +document.getElementById('sl-restitution').value / 100;
document.getElementById('p-restitution').textContent = e.toFixed(2);
if (pSim) pSim.setParams({ restitution: e });
}
function projSetSpeed(s, el) {
if (pSim) pSim.setSpeed(s);
document.querySelectorAll('.proj-speed').forEach(b => b.classList.remove('active'));
if (el) el.classList.add('active');
}
function projSaveGhost() {
if (pSim) pSim.saveGhost();
}
function projClearGhosts() {
if (pSim) pSim.clearGhosts();
}
/* ── Feature 1: target mode UI ── */
function projToggleTargetMode() {
if (!pSim) return;
const on = pSim.toggleTargetMode();
const btn = document.getElementById('proj-target-btn');
if (btn) {
btn.classList.toggle('active', on);
btn.querySelector('span').textContent = on ? 'Режим целей: Вкл' : 'Режим целей: Выкл';
}
const panel = document.getElementById('proj-target-panel');
if (panel) panel.style.display = on ? '' : 'none';
_projUpdateTargetHUD({ hits: 0, total: pSim._targets.length, attempts: 0 });
}
function projGenTargets() {
if (!pSim) return;
pSim.genTargets();
_projUpdateTargetHUD({ hits: 0, total: pSim._targets.length, attempts: 0 });
}
function _projUpdateTargetHUD(info) {
const el = document.getElementById('proj-target-hud');
if (!el) return;
el.textContent = `Цели: ${info.hits}/${info.total} Попыток: ${info.attempts}`;
}
/* ── Feature 2: graphs panel UI ── */
function projToggleGraphs() {
if (!pSim) return;
pSim._graphsVisible = !pSim._graphsVisible;
const panel = document.getElementById('proj-graphs-panel');
const btn = document.getElementById('proj-graphs-btn');
if (panel) panel.style.display = pSim._graphsVisible ? '' : 'none';
if (btn) btn.classList.toggle('active', pSim._graphsVisible);
if (pSim._graphsVisible) {
if (!pSim._graphsCanvas) {
const gc = document.getElementById('proj-graphs-canvas');
if (gc) pSim.attachGraphsCanvas(gc);
}
pSim.drawGraphs();
}
}
/* ── Feature 3: dual throw UI ── */
function projToggleDual() {
if (!pSim) return;
pSim.dualMode = !pSim.dualMode;
const on = pSim.dualMode;
const btn = document.getElementById('proj-dual-btn');
if (btn) {
btn.classList.toggle('active', on);
btn.querySelector('span').textContent = on ? 'Двойной: Вкл' : 'Двойной: Выкл';
}
const panel = document.getElementById('proj-dual-panel');
if (panel) panel.style.display = on ? '' : 'none';
/* show/hide dual stats cells */
const w1 = document.getElementById('ps-p2-wrap');
const w2 = document.getElementById('ps-p2-tf-wrap');
if (w1) w1.style.display = on ? '' : 'none';
if (w2) w2.style.display = on ? '' : 'none';
if (on) {
pSim._computeP2Path();
projP2Param();
}
pSim.draw();
}
function projP2Param() {
if (!pSim) return;
const v0 = +document.getElementById('sl-p2-v0').value;
const angle = +document.getElementById('sl-p2-angle').value;
const h0 = +document.getElementById('sl-p2-h0').value;
document.getElementById('p2-v0').textContent = v0 + ' м/с';
document.getElementById('p2-angle').textContent = angle + '°';
document.getElementById('p2-h0').textContent = h0 + ' м';
pSim._p2.v0 = v0;
pSim._p2.angle = angle;
pSim._p2.h0 = h0;
pSim._computeP2Path();
pSim.draw();
}
function _projUpdateUI(s) {
const fmt = (n, unit) => n < 10000 ? n.toFixed(2) + ' ' + unit : (n/1000).toFixed(2) + ' к' + unit;
document.getElementById('ps-range').textContent = fmt(s.range, 'м');
document.getElementById('ps-hmax').textContent = fmt(s.hMax, 'м');
document.getElementById('ps-tf').textContent = s.tf.toFixed(2) + ' с';
document.getElementById('ps-vland').textContent = fmt(s.vLand, 'м/с');
document.getElementById('ps-t').textContent = s.t.toFixed(2) + ' с';
const laEl = document.getElementById('ps-land-angle');
if (laEl) laEl.textContent = s.landAngle > 0.5 ? s.landAngle.toFixed(1) + '°' : '—';
if (s.hasMod) {
const lossEl = document.getElementById('ps-loss');
if (lossEl) {
const sign = s.rangeLoss > 0 ? '+' : '';
lossEl.textContent = s.rangeLoss !== 0 ? sign + s.rangeLoss + '%' : '0%';
lossEl.style.color = s.rangeLoss < 0 ? '#EF476F' : '#7BF5A4';
}
}
/* update dual stats row */
if (pSim && pSim.dualMode && pSim._p2.pathTf > 0) {
const p2end = pSim._p2CurState(pSim._p2.pathTf);
const d2El = document.getElementById('ps-p2-range');
if (d2El) d2El.textContent = fmt(Math.max(0, p2end.x), 'м');
const d2tf = document.getElementById('ps-p2-tf');
if (d2tf) d2tf.textContent = pSim._p2.pathTf.toFixed(2) + ' с';
}
_projSyncPlayBtn();
/* redraw graphs if open and not in flight (flight loop handles it) */
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 ── */