'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; /* ── Energy bars widget ── */ this._energyOn = false; // toggle state this._frictionWork = 0; // cumulative J lost to drag this._energyScale = 0; // max observed total (for stable scale) /* 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 /* FBD toggle */ this._fbdOn = false; /* ── TimeControl (speed-only; projectile manages its own time) ── */ this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : null; 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 1–2.5 m const th = 1.0 + Math.random() * 1.5; // window height 1–2.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); /* TimeControl: if paused via TC, just redraw */ if (this._tc && this._tc.paused) { this.draw(); this._emit(); if (this.playing) this._tick(); return; } 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(); /* energy: accumulate drag work ΔW = F_drag · v · dt (approx) */ if (this._energyOn && (this.drag || this.parachute)) { const spd = Math.sqrt(cur.vx * cur.vx + cur.vy * cur.vy); const rho = this.rho; const mass = Math.max(0.1, this.mass); const A = this._chuteOpen ? this.chuteArea : 0.00785; const Cd = this._chuteOpen ? this.chuteCd : this.Cd; const Fd = 0.5 * Cd * rho * A * spd * spd; this._frictionWork += Fd * spd * rawDt * this.speed; } /* advance time; respect TC scale on top of existing speed multiplier */ const tcScale = (this._tc && !this._tc.paused) ? this._tc.scale : 1; this.t += rawDt * this.speed * tcScale; 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._frictionWork = 0; this._energyScale = 0; 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); // 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); } /* ── 20. FBD overlay ── */ if (this._fbdOn && window.LSPhysFX && this.t > 0) { const tf2 = this._curTFlight(); const cur2 = this._curState(Math.min(this.t, tf2)); const bx2 = tpx(cur2.x), by2 = tpy(Math.max(0, cur2.y)); const spd2 = Math.sqrt(cur2.vx * cur2.vx + cur2.vy * cur2.vy); const FLEN = 48; /* gravity — straight down */ const mg = this.mass * this.g; LSPhysFX.drawForceArrow(ctx, bx2, by2, 0, FLEN, 'gravity', 'mg=' + mg.toFixed(0) + 'Н'); /* drag — opposite velocity */ if (this.drag && spd2 > 0.2) { const rho2 = this.rho || 1.225; const Fd = 0.5 * rho2 * this.Cd * 0.1 * spd2 * spd2; const dragLen = Math.min(FLEN, Fd * 6); if (dragLen > 3) { const dnx = -cur2.vx / spd2, dny = -cur2.vy / spd2; LSPhysFX.drawForceArrow(ctx, bx2, by2, dnx * dragLen, dny * dragLen, 'drag', 'F_c=' + Fd.toFixed(1) + 'Н'); } } /* wind — horizontal */ if (this.wind !== 0) { const windLen = Math.min(FLEN, Math.abs(this.wind) * 4); LSPhysFX.drawForceArrow(ctx, bx2, by2, Math.sign(this.wind) * windLen, 0, 'applied', 'F_w=' + Math.abs(this.wind).toFixed(0) + 'м/с'); } /* elastic spring force — only when bounce just occurred */ if (this.bounce) { const bounceElapsed = (performance.now() - this._impactTs) / 1000; if (bounceElapsed >= 0 && bounceElapsed < 0.25 && cur2.y <= 0.5) { LSPhysFX.drawForceArrow(ctx, bx2, by2, 0, -FLEN, 'elastic', 'F_упр'); } } } /* ── Energy bars overlay ── */ if (this._energyOn && window.LSPhysFX) { this._drawEnergyBarsProj(ctx, W, H); } /* LabFX: particles overlay */ if (window.LabFX) LabFX.particles.draw(this.ctx); } /* ── Energy bars: projectile ── */ _drawEnergyBarsProj(ctx, W, H) { const tf = this._curTFlight(); const cur = this._curState(Math.min(this.t, tf)); const h = Math.max(0, cur.y); // height above launch (m) const spd = Math.sqrt(cur.vx * cur.vx + cur.vy * cur.vy); const m = Math.max(0.1, this.mass); const g = this.g; const ke = 0.5 * m * spd * spd; const pe = m * g * h; const fr = this._frictionWork; const tot = ke + pe + fr; /* stable scale */ if (tot > this._energyScale) this._energyScale = tot; const scaleTotal = this._energyScale; const PW = 188, MARGIN = 12; LSPhysFX.drawEnergyBars(ctx, W - PW - MARGIN, MARGIN, PW, 0, { ke, pe, friction: fr, total: scaleTotal }, {}); } /* ── 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); _projInjectTCBar(pSim); } pSim.fit(); projParam(); // sync sliders → sim pSim.draw(); _projUpdateUI(pSim.stats()); })); } function _projInjectTCBar(sim) { if (!window.LSTimeControl || !window.LSBuildTimeControlUI) return; var wrap = document.getElementById('sim-proj'); if (!wrap || wrap.querySelector('.tc-bar')) return; /* Only speed control — projectile uses analytical/pre-computed paths */ /* TC.paused is checked in _tick. TC.scale multiplies rawDt * speed */ var tc = sim._tc; var tcBar = window.LSBuildTimeControlUI(sim, tc, { scrubSupported: false }); /* Note: "Следы" already exist in the projectile panel — don't duplicate */ var statsBar = wrap.querySelector('.proj-stats-bar'); if (statsBar) wrap.insertBefore(tcBar, statsBar); else wrap.appendChild(tcBar); } 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 ? '' : ''; 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 = ''; lbl.textContent = 'Пауза'; } else if (done) { lic.innerHTML = ''; lbl.textContent = 'Повторить'; } else { lic.innerHTML = ''; 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(); } /* ── Energy toggle: projectile ── */ function projToggleEnergy() { if (!pSim) return; pSim._energyOn = !pSim._energyOn; const on = pSim._energyOn; const btn = document.getElementById('proj-energy-btn'); if (btn) { btn.classList.toggle('active', on); btn.querySelector('span').textContent = on ? 'Энергия: Вкл' : 'Энергия: Выкл'; } if (!on) { pSim._frictionWork = 0; pSim._energyScale = 0; } pSim.draw(); } /* ── collision ── */