feat(labs): planimetry locus + emfield merger + projectile graphs + UI cleanup
Геометрия (планиметрия): - Живые измерения как объекты: длина / угол / площадь — 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>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
ProjectileSim v2 — physics simulation
|
||||
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 {
|
||||
@@ -67,6 +68,24 @@ class ProjectileSim {
|
||||
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();
|
||||
});
|
||||
@@ -105,6 +124,7 @@ class ProjectileSim {
|
||||
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();
|
||||
@@ -118,6 +138,8 @@ class ProjectileSim {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -162,6 +184,363 @@ class ProjectileSim {
|
||||
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) */
|
||||
@@ -345,6 +724,7 @@ class ProjectileSim {
|
||||
|
||||
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();
|
||||
@@ -355,9 +735,24 @@ class ProjectileSim {
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -391,6 +786,13 @@ class ProjectileSim {
|
||||
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()); }
|
||||
@@ -521,6 +923,25 @@ class ProjectileSim {
|
||||
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);
|
||||
@@ -601,6 +1022,93 @@ class ProjectileSim {
|
||||
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;
|
||||
@@ -1073,8 +1581,11 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!pSim) {
|
||||
pSim = new ProjectileSim(document.getElementById('proj-canvas'));
|
||||
pSim.onUpdate = _projUpdateUI;
|
||||
pSim.onPlayPause = projPlayPause;
|
||||
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
|
||||
@@ -1226,6 +1737,91 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
|
||||
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, 'м');
|
||||
@@ -1243,7 +1839,17 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
|
||||
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 ── */
|
||||
|
||||
Reference in New Issue
Block a user