Files
Learn_System/frontend/js/labs/projectile.js
T
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:58:49 +03:00

1926 lines
70 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 v3 — 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
═══════════════════════════════════════════════════════════════════ */
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: [],
};
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 };
}
setParams({ v0, angle, h0, g, drag, Cd, mass, wind, bounce, restitution } = {}) {
if (v0 !== undefined) this.v0 = +v0;
if (angle !== undefined) this.angle = +angle;
if (h0 !== undefined) this.h0 = +h0;
if (g !== undefined) this.g = +g;
if (drag !== undefined) this.drag = !!drag;
if (Cd !== undefined) this.Cd = +Cd;
if (mass !== undefined) this.mass = Math.max(0.1, +mass);
if (wind !== undefined) this.wind = +wind;
if (bounce !== undefined) this.bounce = !!bounce;
if (restitution !== undefined) this.restitution = Math.max(0, Math.min(1, +restitution));
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.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 = 1.225, 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 rad = this.angle * Math.PI / 180;
const vx = this.v0 * Math.cos(rad);
const vy0 = this.v0 * Math.sin(rad);
return {
x: vx * t,
y: this.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 rad = this.angle * Math.PI / 180;
const vy0 = this.v0 * Math.sin(rad);
const disc = vy0 * vy0 + 2 * this.g * this.h0;
if (disc < 0) return 0;
return Math.max(0, (vy0 + Math.sqrt(disc)) / this.g);
}
_needsNumerical() {
return this.drag || this.wind !== 0 || this.bounce;
}
/* RK4 integration — handles drag, wind, bounce */
_computePath() {
if (!this._needsNumerical()) {
this._path = null;
this._pathTf = this._tFlightAnalytical();
return;
}
const rho = 1.225, A = 0.00785; // air density, ball cross-section
const k = this.drag ? 0.5 * this.Cd * rho * A / Math.max(0.1, this.mass) : 0;
const g = this.g;
const W = this.wind;
const e = this.restitution;
const maxBounces = this.bounce ? 7 : 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 }];
let bounceCount = 0;
const deriv = (sx, sy, svx, svy) => {
const rvx = svx - W; // velocity relative to wind
const rvy = svy;
const speed = Math.sqrt(rvx * rvx + rvy * rvy);
const dragF = speed > 0 ? k * speed : 0;
// wind-only pseudo-force when drag is off (simplified model)
const windAcc = (!this.drag && W !== 0) ? W * 0.05 : 0;
return {
dx: svx,
dy: svy,
dvx: -dragF * rvx + windAcc,
dvy: -g - dragF * rvy,
};
};
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 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 });
if (this.bounce && bounceCount < maxBounces && Math.abs(lvy) > 0.4) {
vy = -e * lvy;
vx = lvx * (1 - 0.04); // small horizontal friction
y = 0.001;
x = lx;
bounceCount++;
continue;
}
}
break;
}
path.push({ x, y, vx, vy, t });
}
this._path = path;
this._pathTf = path[path.length - 1].t;
}
_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++;
}
/* 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._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.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) {
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);
}
/* ── 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();
/* ── 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._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.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)');
}
/* 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();
}
}
/* ── 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();
}
/* ── collision ── */