7f75c96acd
Геометрия (планиметрия): - Живые измерения как объекты: длина / угол / площадь — auto-recompute, draggable chips - Инструмент ГМТ: sweep мовера через параметр, рисует кривую места точек - Новые типы точек: on_segment (скользит по отрезку, _t), on_circle (по окружности, _theta) - Toolbar: «Длина», «Угол», «Площадь», «ГМТ», «На отрезке», «На окружности» Электромагнитные поля (emfield): - Merge magnetic.js + coulomb.js в один EMFieldSim с 3 режимами (E / B / комбинированное) - Унифицированный pipeline: colormap, field lines, vectors, equipotentials, flux loop, test particle - Combined-режим: полная сила Лоренца F=q(E+v×B) - Backward compat: #coulomb и #magnetic хеши и ?sim= параметры редиректят в emfield - Удалены: magnetic.js, coulomb.js. Добавлен: emfield.js Бросок тела (projectile): - Режим целей: 3 окна, hit-детекция, HUD «Цели: N/M / Попыток: K» - Графики x(t), y(t), vx(t), vy(t) — 2×2 Canvas 2D, real-time - Двойной бросок: одновременно 2 траектории для сравнения (cyan vs gold) UI fixes (по результатам аудита): - Заменены emoji/unicode на inline SVG .ic: switch ⌇, spring 〜 (5 мест), download ⬇ (2), camera 📷 - Убраны декоративные символы ☉ ○ из geometry tool labels - Добавлены THEORY entries: geometry, hydrostatics (раньше показывали fallback) - Стандартизирована ширина panel для sim-proj и sim-coll (240px) - waves перенесён в физический блок SIMS catalog (был после биологии) - Очищен дефолтный sim-topbar-title (был «График функции») Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1857 lines
67 KiB
JavaScript
1857 lines
67 KiB
JavaScript
'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 = []; }
|
||
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();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
_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;
|
||
|
||
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 };
|
||
});
|
||
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);
|
||
}
|
||
|
||
/* ── 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);
|
||
}
|
||
}
|
||
|
||
/* ── 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 ── */
|
||
|