be4d43105e
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1157 lines
44 KiB
JavaScript
1157 lines
44 KiB
JavaScript
'use strict';
|
||
/* ════════════════════════════════════════════════════════════════
|
||
FlaskSim v2 — «Химия в колбе»
|
||
• Реалистичная вода: 3 слоя волн, каустики, мениск, SSS
|
||
• Пар при нагреве, всплески пузырьков
|
||
• Толстое стекло, пульсирующий glow реакции
|
||
• Specular highlight металла, dissolution edge
|
||
════════════════════════════════════════════════════════════════ */
|
||
|
||
class FlaskSim {
|
||
|
||
/* ── Реагенты ─────────────────────────────────────────────────── */
|
||
|
||
static METALS = {
|
||
Zn: { name: 'Цинк', color: '#9BB8CC', k: 0.50, Ea: 0.9, rho: 7.13, dH: 155, h2: 1, acids: ['HCl','H2SO4'] },
|
||
Fe: { name: 'Железо', color: '#A08060', k: 0.08, Ea: 1.4, rho: 7.87, dH: 87, h2: 1, acids: ['HCl'], rust: true },
|
||
Mg: { name: 'Магний', color: '#D6D6D6', k: 1.50, Ea: 0.5, rho: 1.74, dH: 467, h2: 1, acids: ['HCl','H2SO4','H2O'] },
|
||
Cu: { name: 'Медь', color: '#C87840', k: 0, Ea: 99, rho: 8.96, dH: 0, h2: 0, acids: [] },
|
||
Na: { name: 'Натрий', color: '#F5F0C8', k: 6.00, Ea: 0.05, rho: 0.97, dH: 883, h2: 0.5, acids: ['HCl','H2SO4','H2O'], boom: true },
|
||
Al: { name: 'Алюминий', color: '#C0C0C0', k: 0.60, Ea: 1.0, rho: 2.70, dH: 300, h2: 1.5, acids: ['HCl','H2SO4'] },
|
||
};
|
||
|
||
static ACIDS = {
|
||
HCl: { name: 'Соляная кислота HCl', rgb: [120, 210, 120], pHf: 1.0, label: 'HCl' },
|
||
H2SO4: { name: 'Серная кислота H₂SO₄', rgb: [210, 195, 120], pHf: 1.2, label: 'H₂SO₄' },
|
||
H2O: { name: 'Вода H₂O', rgb: [110, 180, 215], pHf: 0.0, label: 'H₂O' },
|
||
};
|
||
|
||
static EQ = {
|
||
Zn_HCl: 'Zn + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ZnCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
|
||
Zn_H2SO4: 'Zn + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ZnSO₄ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
|
||
Fe_HCl: 'Fe + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> FeCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
|
||
Mg_HCl: 'Mg + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> MgCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
|
||
Mg_H2SO4: 'Mg + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> MgSO₄ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
|
||
Mg_H2O: 'Mg + 2H₂O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
|
||
Al_HCl: '2Al + 6HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2AlCl₃ + 3H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
|
||
Al_H2SO4: '2Al + 3H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Al₂(SO₄)₃ + 3H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
|
||
Na_HCl: '2Na + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2NaCl + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
|
||
Na_H2SO4: '2Na + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Na₂SO₄ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
|
||
Na_H2O: '2Na + 2H₂O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2NaOH + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
|
||
Cu_HCl: 'Cu + HCl — реакция не идёт',
|
||
Cu_H2SO4: 'Cu + H₂SO₄(разб.) — реакция не идёт',
|
||
Cu_H2O: 'Cu + H₂O — реакция не идёт',
|
||
Fe_H2SO4: 'Fe + H₂SO₄(конц.) — пассивация!',
|
||
Fe_H2O: 'Fe + H₂O — реакция не идёт при 20°C',
|
||
Al_H2O: 'Al + H₂O — не реагирует (оксидная плёнка)',
|
||
Zn_H2O: 'Zn + H₂O — реакция не идёт',
|
||
};
|
||
|
||
/* ── Конструктор ──────────────────────────────────────────────── */
|
||
|
||
constructor(canvas) {
|
||
this.canvas = canvas;
|
||
this.ctx = canvas.getContext('2d');
|
||
|
||
this.metalType = 'Zn';
|
||
this.acidType = 'HCl';
|
||
this.concLevel = 0.35;
|
||
this.envTemp = 20;
|
||
|
||
/* Частицы и волны */
|
||
this._metal = null;
|
||
this._bubbles = [];
|
||
this._dusts = [];
|
||
this._sparks = [];
|
||
this._steam = [];
|
||
this._splashes = [];
|
||
this._caustics = [];
|
||
|
||
/* Фазы волн (3 независимые) */
|
||
this._wave = 0;
|
||
this._wave2 = 0;
|
||
this._wave3 = 0;
|
||
|
||
/* Анимационные таймеры */
|
||
this._glowPulse = 0;
|
||
this._causticTmr = 0;
|
||
this._steamTmr = 0;
|
||
|
||
/* Физическое состояние */
|
||
this._passiv = false;
|
||
this._ignited = false;
|
||
this._flameOn = false;
|
||
this._boomCD = 0;
|
||
this._conc = this.concLevel;
|
||
this._temp = this.envTemp;
|
||
this._pH = 1.0;
|
||
this._rxRate = 0;
|
||
this._h2 = 0;
|
||
this._bubTmr = 0;
|
||
|
||
/* Анимация */
|
||
this._raf = null;
|
||
this._last = 0;
|
||
this._paused = false;
|
||
|
||
this.onUpdate = null;
|
||
this.W = 0; this.H = 0;
|
||
this._g = {};
|
||
|
||
this.fit();
|
||
}
|
||
|
||
/* ── Геометрия ────────────────────────────────────────────────── */
|
||
|
||
fit() {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const W = this.canvas.offsetWidth || 600;
|
||
const H = this.canvas.offsetHeight || 400;
|
||
this.canvas.width = Math.round(W * dpr);
|
||
this.canvas.height = Math.round(H * dpr);
|
||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
this.W = W; this.H = H;
|
||
this._calcGeom();
|
||
}
|
||
|
||
_calcGeom() {
|
||
const { W, H } = this;
|
||
const r = Math.min(W * 0.195, H * 0.285);
|
||
const cx = W * 0.50;
|
||
const cy = H * 0.615;
|
||
const nw = r * 0.26; // ширина горлышка
|
||
const nh = r * 1.05; // высота горлышка
|
||
const nt = cy - r - nh; // верх горлышка
|
||
const nb = cy - r * 0.80; // точка начала плеч (где шея переходит в колбу)
|
||
const liqTop = cy - r * 0.42;
|
||
this._g = { r, cx, cy, nw, nh, nt, nb, liqTop };
|
||
}
|
||
|
||
_flaskPath(ctx) {
|
||
const { r, cx, cy, nw, nt, nb } = this._g;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx - nw, nt);
|
||
ctx.lineTo(cx - nw, nb);
|
||
/* Левое плечо: плавная кривая Безье от шеи до экватора колбы */
|
||
ctx.bezierCurveTo(
|
||
cx - nw, cy - r * 0.42, // CP1: продолжаем вниз по шее
|
||
cx - r * 0.85, cy - r * 0.10, // CP2: выходим к экватору
|
||
cx - r, cy // конец: левый экватор окружности
|
||
);
|
||
/* Нижняя дуга колбы: слева направо через дно (anticlockwise=true в canvas = через низ) */
|
||
ctx.arc(cx, cy, r, Math.PI, 0, true);
|
||
/* Правое плечо: симметрично */
|
||
ctx.bezierCurveTo(
|
||
cx + r * 0.85, cy - r * 0.10,
|
||
cx + nw, cy - r * 0.42,
|
||
cx + nw, nb
|
||
);
|
||
ctx.lineTo(cx + nw, nt);
|
||
ctx.closePath();
|
||
}
|
||
|
||
/* ── Запуск / остановка ───────────────────────────────────────── */
|
||
|
||
start() {
|
||
if (this._raf) return;
|
||
this._last = performance.now();
|
||
const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
|
||
this._raf = requestAnimationFrame(loop);
|
||
}
|
||
|
||
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
|
||
|
||
/* ── Публичный API ────────────────────────────────────────────── */
|
||
|
||
dropMetal() {
|
||
const { cx, nt } = this._g;
|
||
const md = FlaskSim.METALS[this.metalType];
|
||
const mass = 5;
|
||
this._metal = {
|
||
type: this.metalType,
|
||
mass, init: mass,
|
||
x: cx + (Math.random() - 0.5) * 8,
|
||
y: nt - 32,
|
||
vx: (Math.random() - 0.5) * 22,
|
||
vy: 0,
|
||
r: this._m2r(mass),
|
||
_v: Array.from({ length: 10 }, (_, i) => ({
|
||
a: (i / 10) * Math.PI * 2,
|
||
j: 0.68 + Math.random() * 0.32,
|
||
})),
|
||
};
|
||
this._passiv = false;
|
||
this._ignited = false;
|
||
this._h2 = 0;
|
||
this._bubTmr = 0;
|
||
this._boomCD = 0;
|
||
}
|
||
|
||
reset() {
|
||
this._metal = null;
|
||
this._bubbles = [];
|
||
this._dusts = [];
|
||
this._sparks = [];
|
||
this._steam = [];
|
||
this._splashes = [];
|
||
this._caustics = [];
|
||
this._passiv = false;
|
||
this._ignited = false;
|
||
this._flameOn = false;
|
||
this._h2 = 0;
|
||
this._rxRate = 0;
|
||
this._boomCD = 0;
|
||
this._causticTmr = 0;
|
||
this._steamTmr = 0;
|
||
this._conc = this.concLevel;
|
||
this._temp = this.envTemp;
|
||
this._pH = this._startPH();
|
||
if (this.onUpdate) this.onUpdate(this.info());
|
||
this.draw();
|
||
}
|
||
|
||
togglePause() { this._paused = !this._paused; }
|
||
toggleFlame() { this._flameOn = !this._flameOn; }
|
||
setMetal(t) { this.metalType = t; }
|
||
setAcid(t) { this.acidType = t; this.reset(); }
|
||
setConc(v) { this.concLevel = v; this._conc = v; if (!this._metal) this._pH = this._startPH(); }
|
||
setEnvTemp(v) { this.envTemp = v; if (!this._metal) this._temp = v; }
|
||
|
||
_startPH() {
|
||
const a = FlaskSim.ACIDS[this.acidType];
|
||
if (a.pHf === 0) return 7.0;
|
||
return Math.max(0, -Math.log10(this.concLevel * 10 * a.pHf + 1e-10));
|
||
}
|
||
|
||
_m2r(mass) { return 8 + 24 * Math.cbrt(Math.max(0, mass) / 5); }
|
||
|
||
/* ── Тик физики ───────────────────────────────────────────────── */
|
||
|
||
_tick(now) {
|
||
const dt = Math.min((now - this._last) / 1000, 0.05);
|
||
this._last = now;
|
||
if (!this._paused) {
|
||
this._wave += dt * 1.7;
|
||
this._wave2 += dt * 2.3;
|
||
this._wave3 += dt * 0.88;
|
||
this._glowPulse += dt * 3.2;
|
||
this._stepMetal(dt);
|
||
this._stepBubbles(dt);
|
||
this._stepDusts(dt);
|
||
this._stepSparks(dt);
|
||
this._stepSteam(dt);
|
||
this._stepSplashes(dt);
|
||
this._stepCaustics(dt);
|
||
}
|
||
this.draw();
|
||
if (this.onUpdate) this.onUpdate(this.info());
|
||
}
|
||
|
||
/* ── Физика металла ───────────────────────────────────────────── */
|
||
|
||
_stepMetal(dt) {
|
||
const m = this._metal;
|
||
if (!m || m.mass <= 0.01) { if (m) m.mass = 0; return; }
|
||
|
||
const md = FlaskSim.METALS[m.type];
|
||
const { cy, r, liqTop, cx } = this._g;
|
||
|
||
const liqRho = 1.12;
|
||
const inLiq = m.y + m.r > liqTop;
|
||
const grav = 400;
|
||
const buoy = inLiq ? grav * (liqRho / md.rho) : 0;
|
||
const drag = inLiq ? 4.5 : 0.25;
|
||
|
||
m.vy += (grav - buoy) * dt;
|
||
m.vy -= drag * m.vy * dt;
|
||
m.vx -= drag * m.vx * dt;
|
||
m.y += m.vy * dt;
|
||
m.x += m.vx * dt;
|
||
|
||
const botY = cy + r - m.r;
|
||
if (m.y > botY) { m.y = botY; m.vy *= -0.22; }
|
||
const hw = Math.sqrt(Math.max(0, r * r - (m.y - cy) ** 2));
|
||
m.x = Math.max(cx - hw + m.r, Math.min(cx + hw - m.r, m.x));
|
||
|
||
if (md.rho < liqRho && inLiq) {
|
||
const sfY = liqTop - m.r;
|
||
if (m.y < sfY) { m.y = sfY; m.vy = Math.abs(m.vy) * 0.25; }
|
||
}
|
||
|
||
const reacts = md.acids.includes(this.acidType) && !this._passiv && this._conc > 4e-4;
|
||
if (!reacts) { this._rxRate = 0; return; }
|
||
|
||
const T_K = this._temp + 273.15;
|
||
const rate = md.k * this._conc * Math.exp(-md.Ea * 3000 / (8.314 * T_K));
|
||
this._rxRate = Math.min(1, rate * 4);
|
||
|
||
const surf = (m.r / 26) ** 2;
|
||
const dmdt = rate * surf * 0.95;
|
||
|
||
m.mass = Math.max(0, m.mass - dmdt * dt);
|
||
m.r = this._m2r(m.mass);
|
||
|
||
/* Слегка деформировать вершины при реакции */
|
||
if (this._rxRate > 0.1 && Math.random() < dt * 6) {
|
||
const vi = Math.floor(Math.random() * m._v.length);
|
||
m._v[vi].j = Math.max(0.45, Math.min(1.0, m._v[vi].j + (Math.random() - 0.5) * 0.08));
|
||
}
|
||
|
||
const heatW = md.dH * dmdt * 0.055;
|
||
const cool = 0.30 * (this._temp - this.envTemp);
|
||
this._temp = Math.min(150, Math.max(this.envTemp, this._temp + (heatW - cool) * dt));
|
||
|
||
this._conc = Math.max(0, this._conc - dmdt * 0.07 * dt);
|
||
|
||
const ad = FlaskSim.ACIDS[this.acidType];
|
||
if (ad.pHf > 0) {
|
||
this._pH = Math.min(7, Math.max(0, -Math.log10(this._conc * 10 * ad.pHf + 1e-10)));
|
||
} else {
|
||
this._pH = Math.min(14, 7 + Math.min(7, m.mass < 0.1 ? 7 : dmdt * 12));
|
||
}
|
||
|
||
this._h2 = Math.min(1, this._h2 + md.h2 * dmdt * 0.065 * dt);
|
||
|
||
this._bubTmr += rate * 32 * dt;
|
||
while (this._bubTmr > 1 && this._bubbles.length < 180) {
|
||
this._spawnBubble(m.x, m.y - m.r * 0.6);
|
||
this._bubTmr--;
|
||
}
|
||
|
||
if (md.rust && Math.random() < rate * dt * 14) {
|
||
this._spawnDust(m.x + (Math.random() - 0.5) * m.r, m.y + m.r * 0.3, '#8B3A0A', 0.65);
|
||
}
|
||
|
||
if (m.type === 'Fe' && this.acidType === 'H2SO4' && this.concLevel > 0.82) {
|
||
this._passiv = true;
|
||
}
|
||
|
||
if (md.boom && this._boomCD <= 0 && rate > 0.28) {
|
||
this._boom(m.x, m.y);
|
||
this._boomCD = 0.45;
|
||
}
|
||
if (this._boomCD > 0) this._boomCD -= dt;
|
||
|
||
if (this._flameOn && this._h2 > 0.22 && !this._ignited) {
|
||
this._igniteH2();
|
||
}
|
||
}
|
||
|
||
/* ── Частицы ─────────────────────────────────────────────────── */
|
||
|
||
_spawnBubble(x, y) {
|
||
this._bubbles.push({
|
||
x: x + (Math.random() - 0.5) * 14,
|
||
y,
|
||
r: 1.4 + Math.random() * 3.8,
|
||
vy: -(16 + Math.random() * 38),
|
||
vx: (Math.random() - 0.5) * 9,
|
||
wobble: Math.random() * Math.PI * 2,
|
||
wFreq: 3.5 + Math.random() * 3,
|
||
life: 1,
|
||
});
|
||
}
|
||
|
||
_stepBubbles(dt) {
|
||
const { liqTop } = this._g;
|
||
for (const b of this._bubbles) {
|
||
b.wobble += dt * b.wFreq;
|
||
b.x += b.vx * dt + Math.sin(b.wobble) * b.r * 0.35 * dt * 10;
|
||
b.y += b.vy * dt;
|
||
b.vx += (Math.random() - 0.5) * 28 * dt;
|
||
if (b.y - b.r < liqTop) {
|
||
this._spawnSplash(b.x, liqTop, b.r);
|
||
b.life = 0;
|
||
} else {
|
||
b.life -= dt * 0.14;
|
||
}
|
||
}
|
||
this._bubbles = this._bubbles.filter(b => b.life > 0);
|
||
}
|
||
|
||
_spawnSplash(x, y, r) {
|
||
if (r < 2) return;
|
||
const n = Math.floor(2 + r);
|
||
for (let i = 0; i < n; i++) {
|
||
const a = -Math.PI * 0.5 + (Math.random() - 0.5) * Math.PI * 1.2;
|
||
const s = 10 + r * 4 + Math.random() * 20;
|
||
this._splashes.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 12, r: 0.8 + Math.random() * 1.4, life: 1 });
|
||
}
|
||
}
|
||
|
||
_stepSplashes(dt) {
|
||
for (const s of this._splashes) {
|
||
s.x += s.vx * dt;
|
||
s.y += s.vy * dt;
|
||
s.vy += 55 * dt;
|
||
s.life -= dt * 4.0;
|
||
}
|
||
this._splashes = this._splashes.filter(s => s.life > 0);
|
||
}
|
||
|
||
_spawnSteam(x, y) {
|
||
this._steam.push({
|
||
x: x + (Math.random() - 0.5) * 16,
|
||
y,
|
||
vx: (Math.random() - 0.5) * 12,
|
||
vy: -(6 + Math.random() * 18),
|
||
r: 2.5 + Math.random() * 6,
|
||
life: 0.9 + Math.random() * 0.1,
|
||
});
|
||
}
|
||
|
||
_stepSteam(dt) {
|
||
if (this._temp > 70) {
|
||
this._steamTmr += (this._temp - 70) / 30 * dt * 7;
|
||
while (this._steamTmr > 1 && this._steam.length < 55) {
|
||
const { liqTop, cx, nw } = this._g;
|
||
this._spawnSteam(cx + (Math.random() - 0.5) * nw * 1.6, liqTop - 4);
|
||
this._steamTmr--;
|
||
}
|
||
}
|
||
for (const s of this._steam) {
|
||
s.x += s.vx * dt;
|
||
s.y += s.vy * dt;
|
||
s.vx += (Math.random() - 0.5) * 12 * dt;
|
||
s.r += dt * 5;
|
||
s.life -= dt * (0.7 + (1 - s.life) * 0.4);
|
||
}
|
||
this._steam = this._steam.filter(s => s.life > 0 && s.r < 50);
|
||
}
|
||
|
||
_spawnCaustic(x, y) {
|
||
this._caustics.push({
|
||
x, y,
|
||
r: 7 + Math.random() * 20,
|
||
vx: (Math.random() - 0.5) * 16,
|
||
vy: (Math.random() - 0.5) * 7,
|
||
life: 0.4 + Math.random() * 0.6,
|
||
a: 0.05 + Math.random() * 0.09,
|
||
});
|
||
}
|
||
|
||
_stepCaustics(dt) {
|
||
this._causticTmr += dt * 3.5;
|
||
while (this._causticTmr > 1 && this._caustics.length < 20) {
|
||
const { cx, cy, r, liqTop } = this._g;
|
||
const px = cx + (Math.random() - 0.5) * r * 1.5;
|
||
const py = liqTop + 8 + Math.random() * (cy + r * 0.6 - liqTop - 16);
|
||
this._spawnCaustic(px, py);
|
||
this._causticTmr--;
|
||
}
|
||
for (const c of this._caustics) {
|
||
c.x += c.vx * dt;
|
||
c.y += c.vy * dt;
|
||
c.r += dt * 4;
|
||
c.life -= dt * 0.38;
|
||
}
|
||
this._caustics = this._caustics.filter(c => c.life > 0);
|
||
}
|
||
|
||
_spawnDust(x, y, col, a) {
|
||
this._dusts.push({
|
||
x, y,
|
||
vx: (Math.random() - 0.5) * 14,
|
||
vy: 4 + Math.random() * 20,
|
||
r: 1.0 + Math.random() * 2.2,
|
||
col, a, life: 1,
|
||
});
|
||
}
|
||
|
||
_stepDusts(dt) {
|
||
for (const d of this._dusts) {
|
||
d.x += d.vx * dt;
|
||
d.y += d.vy * dt;
|
||
d.vy += 28 * dt;
|
||
d.vx *= 1 - dt * 2.2;
|
||
d.life -= dt * 0.22;
|
||
}
|
||
this._dusts = this._dusts.filter(d => d.life > 0);
|
||
if (this._dusts.length > 300) this._dusts.splice(0, 60);
|
||
}
|
||
|
||
_stepSparks(dt) {
|
||
for (const s of this._sparks) {
|
||
s.x += s.vx * dt;
|
||
s.y += s.vy * dt;
|
||
s.vy += 210 * dt;
|
||
s.vx *= 1 - dt * 0.8;
|
||
s.life -= dt * 2.0;
|
||
}
|
||
this._sparks = this._sparks.filter(s => s.life > 0);
|
||
}
|
||
|
||
_boom(x, y) {
|
||
for (let i = 0; i < 36; i++) {
|
||
const a = Math.random() * Math.PI * 2;
|
||
const s = 90 + Math.random() * 240;
|
||
this._sparks.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 90,
|
||
r: 2 + Math.random() * 4, col: Math.random() < 0.55 ? '#FFD166' : '#EF476F', life: 1 });
|
||
}
|
||
}
|
||
|
||
_igniteH2() {
|
||
this._ignited = true;
|
||
this._h2 = 0;
|
||
const { cx, nt } = this._g;
|
||
for (let i = 0; i < 60; i++) {
|
||
const a = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 0.9;
|
||
const s = 130 + Math.random() * 360;
|
||
this._sparks.push({
|
||
x: cx + (Math.random() - 0.5) * 18, y: nt - 10,
|
||
vx: Math.cos(a) * s, vy: Math.sin(a) * s,
|
||
r: 3 + Math.random() * 5, col: i < 30 ? '#FFD166' : '#FF6B35', life: 1,
|
||
});
|
||
}
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
РЕНДЕРИНГ
|
||
════════════════════════════════════════════════════════════════ */
|
||
|
||
draw() {
|
||
const ctx = this.ctx;
|
||
const { W, H, _g: g } = this;
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
/* Фон */
|
||
const bg = ctx.createRadialGradient(W * 0.5, H * 0.35, 0, W * 0.5, H * 0.5, W * 0.75);
|
||
bg.addColorStop(0, '#0d1320');
|
||
bg.addColorStop(1, '#05080f');
|
||
ctx.fillStyle = bg;
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
/* Сетка лаборатории */
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.018)'; ctx.lineWidth = 1;
|
||
for (let x = 0; x < W; x += 28) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
|
||
for (let y = 0; y < H; y += 28) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
|
||
|
||
/* Стол */
|
||
const tableY = g.cy + g.r + 8;
|
||
const tg = ctx.createLinearGradient(0, tableY, 0, tableY + 48);
|
||
tg.addColorStop(0, '#19223a'); tg.addColorStop(1, '#0c101e');
|
||
ctx.fillStyle = tg; ctx.fillRect(0, tableY, W, H - tableY);
|
||
ctx.strokeStyle = 'rgba(90,120,200,0.20)'; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(0, tableY); ctx.lineTo(W, tableY); ctx.stroke();
|
||
|
||
this._drawFlaskShadow(ctx, tableY);
|
||
this._drawLiquid(ctx);
|
||
this._drawCaustics(ctx);
|
||
this._drawDusts(ctx);
|
||
this._drawBubbles(ctx);
|
||
this._drawSplashes(ctx);
|
||
this._drawMetal(ctx);
|
||
this._drawFlaskGlass(ctx);
|
||
this._drawSteam(ctx);
|
||
this._drawSparks(ctx);
|
||
this._drawThermometer(ctx);
|
||
this._drawPHStrip(ctx);
|
||
this._drawH2Bar(ctx);
|
||
this._drawInfoPanel(ctx);
|
||
if (!this._metal || this._metal.mass <= 0.01) this._drawHint(ctx);
|
||
}
|
||
|
||
/* ── Тень/отражение колбы на столе ── */
|
||
_drawFlaskShadow(ctx, tableY) {
|
||
const { _g: g } = this;
|
||
ctx.save();
|
||
ctx.scale(1, 0.26);
|
||
const shadowGrad = ctx.createRadialGradient(g.cx, tableY / 0.26, 0, g.cx, tableY / 0.26, g.r * 1.15);
|
||
shadowGrad.addColorStop(0, 'rgba(0,0,0,0.50)');
|
||
shadowGrad.addColorStop(0.55, 'rgba(0,0,0,0.22)');
|
||
shadowGrad.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.fillStyle = shadowGrad;
|
||
ctx.beginPath(); ctx.arc(g.cx, tableY / 0.26, g.r * 1.15, 0, Math.PI * 2); ctx.fill();
|
||
ctx.restore();
|
||
|
||
/* Реакционный glow на столе */
|
||
if (this._rxRate > 0.05) {
|
||
const ad = FlaskSim.ACIDS[this.acidType];
|
||
const [ri, gi, bi] = ad.rgb;
|
||
ctx.save();
|
||
ctx.scale(1, 0.28);
|
||
const gg = ctx.createRadialGradient(g.cx, tableY / 0.28, 0, g.cx, tableY / 0.28, g.r * 0.9);
|
||
gg.addColorStop(0, `rgba(${ri},${gi},${bi},${this._rxRate * 0.18})`);
|
||
gg.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.fillStyle = gg;
|
||
ctx.beginPath(); ctx.arc(g.cx, tableY / 0.28, g.r * 0.9, 0, Math.PI * 2); ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
/* ── Жидкость: 3 волны + SSS + каустики + мениск ── */
|
||
_drawLiquid(ctx) {
|
||
const { _g: g } = this;
|
||
const ad = FlaskSim.ACIDS[this.acidType];
|
||
const [ri, gi, bi] = ad.rgb;
|
||
const heat = Math.min(1, (this._temp - 20) / 80);
|
||
const lr = Math.min(255, ri + heat * 80);
|
||
const lg = Math.max(0, gi - heat * 58);
|
||
const lb = Math.max(0, bi - heat * 58);
|
||
const al = 0.20 + this._conc * 0.40;
|
||
|
||
const amp = 1.8 + this._rxRate * 8;
|
||
|
||
/* Функция волновой поверхности */
|
||
const waveY = (x) => {
|
||
const wx = x - g.cx;
|
||
return g.liqTop
|
||
+ Math.sin(wx * 0.065 + this._wave) * amp
|
||
+ Math.sin(wx * 0.130 - this._wave2 * 1.38) * amp * 0.36
|
||
+ Math.sin(wx * 0.046 + this._wave3 * 0.75) * amp * 0.20;
|
||
};
|
||
|
||
const step = 2;
|
||
const x0 = g.cx - g.r - 2, x1 = g.cx + g.r + 2;
|
||
|
||
ctx.save();
|
||
this._flaskPath(ctx);
|
||
ctx.clip();
|
||
|
||
/* ── Слой 1: основное тело жидкости ── */
|
||
ctx.beginPath();
|
||
ctx.moveTo(x0, waveY(x0));
|
||
for (let x = x0; x <= x1; x += step) ctx.lineTo(x, waveY(x));
|
||
ctx.lineTo(x1, g.cy + g.r + 12);
|
||
ctx.lineTo(x0, g.cy + g.r + 12);
|
||
ctx.closePath();
|
||
|
||
const depthGrad = ctx.createLinearGradient(0, g.liqTop, 0, g.cy + g.r);
|
||
depthGrad.addColorStop(0, `rgba(${lr},${lg},${lb},${al * 0.40})`);
|
||
depthGrad.addColorStop(0.30, `rgba(${lr},${lg},${lb},${al * 0.65})`);
|
||
depthGrad.addColorStop(1, `rgba(${lr},${lg},${lb},${al})`);
|
||
ctx.fillStyle = depthGrad; ctx.fill();
|
||
|
||
/* ── Слой 2: subsurface scattering (22px полоса под поверхностью) ── */
|
||
ctx.beginPath();
|
||
ctx.moveTo(x0, waveY(x0));
|
||
for (let x = x0; x <= x1; x += step) ctx.lineTo(x, waveY(x));
|
||
for (let x = x1; x >= x0; x -= step) ctx.lineTo(x, waveY(x) + 22);
|
||
ctx.closePath();
|
||
const sssGrad = ctx.createLinearGradient(0, g.liqTop, 0, g.liqTop + 22);
|
||
sssGrad.addColorStop(0, `rgba(${Math.min(255,lr+90)},${Math.min(255,lg+80)},${Math.min(255,lb+80)},0.22)`);
|
||
sssGrad.addColorStop(1, `rgba(${lr},${lg},${lb},0)`);
|
||
ctx.fillStyle = sssGrad; ctx.fill();
|
||
|
||
/* ── Слой 3: радиальный тинт дна (имитация рассеяния) ── */
|
||
const botGrad = ctx.createRadialGradient(g.cx, g.cy + g.r * 0.55, 0, g.cx, g.cy + g.r * 0.55, g.r * 0.80);
|
||
botGrad.addColorStop(0, `rgba(${Math.min(255,lr+30)},${Math.min(255,lg+30)},${Math.min(255,lb+40)},0.14)`);
|
||
botGrad.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.fillStyle = botGrad;
|
||
ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, 0, Math.PI * 2); ctx.fill();
|
||
|
||
/* ── Основной блик поверхности ── */
|
||
ctx.beginPath();
|
||
ctx.moveTo(x0, waveY(x0));
|
||
for (let x = x0; x <= x1; x += step) ctx.lineTo(x, waveY(x));
|
||
ctx.strokeStyle = `rgba(${Math.min(255,lr+100)},${Math.min(255,lg+95)},${Math.min(255,lb+95)},0.50)`;
|
||
ctx.lineWidth = 1.6; ctx.stroke();
|
||
|
||
/* ── Вторая, более тонкая волна-блик (чуть ниже) ── */
|
||
ctx.beginPath();
|
||
for (let x = x0; x <= x1; x += step) {
|
||
const wy = waveY(x) + 3
|
||
+ Math.sin((x - g.cx) * 0.11 - this._wave2 * 1.1) * amp * 0.55;
|
||
if (x === x0) ctx.moveTo(x, wy); else ctx.lineTo(x, wy);
|
||
}
|
||
ctx.strokeStyle = `rgba(${Math.min(255,lr+55)},${Math.min(255,lg+55)},${Math.min(255,lb+55)},0.18)`;
|
||
ctx.lineWidth = 1; ctx.stroke();
|
||
|
||
/* ── Мениск у стенок колбы ── */
|
||
const mY = waveY(g.cx);
|
||
ctx.beginPath();
|
||
ctx.moveTo(g.cx - g.r + 2, waveY(g.cx - g.r) + 6);
|
||
ctx.quadraticCurveTo(g.cx - g.r + 14, mY - 4, g.cx - g.r * 0.38, mY);
|
||
ctx.strokeStyle = `rgba(${Math.min(255,lr+70)},${Math.min(255,lg+70)},${Math.min(255,lb+70)},0.32)`;
|
||
ctx.lineWidth = 2.2; ctx.stroke();
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(g.cx + g.r - 2, waveY(g.cx + g.r) + 6);
|
||
ctx.quadraticCurveTo(g.cx + g.r - 14, mY - 4, g.cx + g.r * 0.38, mY);
|
||
ctx.stroke();
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
/* ── Каустики (световые пятна в толще жидкости) ── */
|
||
_drawCaustics(ctx) {
|
||
if (this._caustics.length === 0) return;
|
||
ctx.save();
|
||
this._flaskPath(ctx); ctx.clip();
|
||
for (const c of this._caustics) {
|
||
const alpha = c.a * c.life;
|
||
const cg = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, c.r);
|
||
cg.addColorStop(0, `rgba(255,255,255,${alpha * 0.9})`);
|
||
cg.addColorStop(0.45,`rgba(210,235,255,${alpha * 0.4})`);
|
||
cg.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.fillStyle = cg;
|
||
ctx.beginPath(); ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2); ctx.fill();
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
ctx.restore();
|
||
}
|
||
|
||
/* ── Пузырьки с wobble, specular, вторичным бликом ── */
|
||
_drawBubbles(ctx) {
|
||
ctx.save();
|
||
this._flaskPath(ctx); ctx.clip();
|
||
for (const b of this._bubbles) {
|
||
const a = Math.min(1, b.life * 2.5);
|
||
|
||
/* Тело пузырька — градиент */
|
||
const bg = ctx.createRadialGradient(
|
||
b.x - b.r * 0.30, b.y - b.r * 0.30, 0,
|
||
b.x, b.y, b.r
|
||
);
|
||
bg.addColorStop(0, `rgba(220,240,255,${a * 0.18})`);
|
||
bg.addColorStop(0.65,`rgba(180,215,255,${a * 0.09})`);
|
||
bg.addColorStop(1, `rgba(130,185,255,${a * 0.04})`);
|
||
ctx.fillStyle = bg;
|
||
ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.fill();
|
||
|
||
/* Контур */
|
||
ctx.strokeStyle = `rgba(200,230,255,${a * 0.70})`;
|
||
ctx.lineWidth = 0.85;
|
||
ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.stroke();
|
||
|
||
/* Specular highlight (верхний левый) */
|
||
const hg = ctx.createRadialGradient(
|
||
b.x - b.r * 0.30, b.y - b.r * 0.32, 0,
|
||
b.x - b.r * 0.30, b.y - b.r * 0.32, b.r * 0.30
|
||
);
|
||
hg.addColorStop(0, `rgba(255,255,255,${a * 0.88})`);
|
||
hg.addColorStop(1, 'rgba(255,255,255,0)');
|
||
ctx.fillStyle = hg;
|
||
ctx.beginPath(); ctx.arc(b.x - b.r * 0.30, b.y - b.r * 0.32, b.r * 0.30, 0, Math.PI * 2); ctx.fill();
|
||
|
||
/* Малый блик (нижний правый) */
|
||
ctx.fillStyle = `rgba(200,225,255,${a * 0.28})`;
|
||
ctx.beginPath(); ctx.arc(b.x + b.r * 0.24, b.y + b.r * 0.30, b.r * 0.12, 0, Math.PI * 2); ctx.fill();
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
/* ── Всплески на поверхности ── */
|
||
_drawSplashes(ctx) {
|
||
if (this._splashes.length === 0) return;
|
||
ctx.save();
|
||
this._flaskPath(ctx); ctx.clip();
|
||
const ad = FlaskSim.ACIDS[this.acidType];
|
||
const [ri, gi, bi] = ad.rgb;
|
||
for (const s of this._splashes) {
|
||
ctx.globalAlpha = s.life * 0.75;
|
||
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgb(${Math.min(255,ri+70)},${Math.min(255,gi+70)},${Math.min(255,bi+70)})`;
|
||
ctx.fill();
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
ctx.restore();
|
||
}
|
||
|
||
/* ── Пар ── */
|
||
_drawSteam(ctx) {
|
||
if (this._steam.length === 0) return;
|
||
const { _g: g } = this;
|
||
for (const s of this._steam) {
|
||
/* Пар выходит только выше горлышка или через нагрев (внутри колбы у шейки) */
|
||
const inNeck = s.x > g.cx - g.nw - 6 && s.x < g.cx + g.nw + 6;
|
||
if (!inNeck && s.y > g.nt - 4) continue;
|
||
ctx.save();
|
||
ctx.globalAlpha = s.life * 0.38 * Math.min(1, s.r / 8);
|
||
const sg = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, s.r);
|
||
sg.addColorStop(0, 'rgba(200,215,255,0.9)');
|
||
sg.addColorStop(0.6,'rgba(180,200,240,0.4)');
|
||
sg.addColorStop(1, 'rgba(160,190,230,0)');
|
||
ctx.fillStyle = sg;
|
||
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
/* ── Колба: толстое стекло, glow реакции, highlights ── */
|
||
_drawFlaskGlass(ctx) {
|
||
const { _g: g } = this;
|
||
const { r, cx, cy, nw, nt, nb } = g;
|
||
const heat = Math.min(1, (this._temp - 20) / 80);
|
||
const ad = FlaskSim.ACIDS[this.acidType];
|
||
const [ri, gi, bi] = ad.rgb;
|
||
|
||
/* ── Reaction glow (пульсирующий) ── */
|
||
if (this._rxRate > 0.04) {
|
||
ctx.save();
|
||
const pulse = 0.5 + 0.5 * Math.sin(this._glowPulse);
|
||
ctx.shadowColor = `rgb(${ri},${gi},${bi})`;
|
||
ctx.shadowBlur = 10 + this._rxRate * 30 + pulse * 10;
|
||
this._flaskPath(ctx);
|
||
ctx.strokeStyle = `rgba(${ri},${gi},${bi},${this._rxRate * 0.50 + pulse * 0.12})`;
|
||
ctx.lineWidth = 1.2;
|
||
ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
ctx.restore();
|
||
}
|
||
|
||
/* ── Внешний контур ── */
|
||
this._flaskPath(ctx);
|
||
ctx.strokeStyle = 'rgba(100,165,255,0.68)';
|
||
ctx.lineWidth = 3.0;
|
||
ctx.stroke();
|
||
|
||
/* ── Внутренний контур (толщина стекла) ── */
|
||
const tk = 4;
|
||
const r_i = r - tk;
|
||
const nw_i = nw - tk * 0.70;
|
||
const nb_i = cy - r_i * 0.80;
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx - nw_i, nt + tk * 0.9);
|
||
ctx.lineTo(cx - nw_i, nb_i);
|
||
ctx.bezierCurveTo(
|
||
cx - nw_i, cy - r_i * 0.42,
|
||
cx - r_i * 0.85, cy - r_i * 0.10,
|
||
cx - r_i, cy
|
||
);
|
||
ctx.arc(cx, cy, r_i, Math.PI, 0, true);
|
||
ctx.bezierCurveTo(
|
||
cx + r_i * 0.85, cy - r_i * 0.10,
|
||
cx + nw_i, cy - r_i * 0.42,
|
||
cx + nw_i, nb_i
|
||
);
|
||
ctx.lineTo(cx + nw_i, nt + tk * 0.9);
|
||
ctx.strokeStyle = 'rgba(75,125,215,0.16)';
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
|
||
/* ── Большой левый блик (gradient arc) ── */
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx - nw * 0.50, nt + 10);
|
||
ctx.lineTo(cx - nw * 0.50, nb);
|
||
ctx.bezierCurveTo(
|
||
cx - nw * 0.50, nb + r * 0.18,
|
||
cx - r * 0.72, cy - r * 0.42,
|
||
cx - r * 0.74, cy - r * 0.05
|
||
);
|
||
const hlGrad = ctx.createLinearGradient(cx - r * 0.73, nt, cx - r * 0.73, cy);
|
||
hlGrad.addColorStop(0, 'rgba(225,242,255,0.40)');
|
||
hlGrad.addColorStop(0.40, 'rgba(215,235,255,0.22)');
|
||
hlGrad.addColorStop(0.75, 'rgba(200,225,255,0.10)');
|
||
hlGrad.addColorStop(1, 'rgba(200,225,255,0.02)');
|
||
ctx.strokeStyle = hlGrad;
|
||
ctx.lineWidth = 5;
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
|
||
/* ── Правый мягкий блик ── */
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx + r * 0.62, cy - r * 0.56);
|
||
ctx.quadraticCurveTo(cx + r * 0.83, cy - r * 0.18, cx + r * 0.78, cy + r * 0.20);
|
||
ctx.strokeStyle = 'rgba(200,225,255,0.11)';
|
||
ctx.lineWidth = 3;
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
|
||
/* ── Горлышко — ободок ── */
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx - nw - 5, nt);
|
||
ctx.lineTo(cx + nw + 5, nt);
|
||
ctx.strokeStyle = 'rgba(100,165,255,0.68)';
|
||
ctx.lineWidth = 3.2;
|
||
ctx.stroke();
|
||
|
||
/* ── Блик горлышка ── */
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx - nw * 0.42, nt + 4);
|
||
ctx.lineTo(cx - nw * 0.42, nb - 4);
|
||
ctx.strokeStyle = 'rgba(220,240,255,0.26)';
|
||
ctx.lineWidth = 2.2;
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
|
||
/* ── Тепловой тинт (стекло краснеет при нагреве) ── */
|
||
if (heat > 0.15) {
|
||
ctx.save();
|
||
this._flaskPath(ctx);
|
||
ctx.fillStyle = `rgba(255,${Math.round(155 - heat * 110)},40,${heat * 0.072})`;
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
/* ── Металл: specular, dissolution edge ── */
|
||
_drawMetal(ctx) {
|
||
const m = this._metal;
|
||
if (!m || m.mass <= 0.01) return;
|
||
const md = FlaskSim.METALS[m.type];
|
||
|
||
ctx.save();
|
||
|
||
/* Dissolution edge glow */
|
||
if (this._rxRate > 0.08) {
|
||
ctx.shadowColor = '#FFD166';
|
||
ctx.shadowBlur = 6 + this._rxRate * 22;
|
||
}
|
||
|
||
/* Тело */
|
||
ctx.beginPath();
|
||
for (let i = 0; i < m._v.length; i++) {
|
||
const v = m._v[i];
|
||
const px = m.x + Math.cos(v.a) * m.r * v.j;
|
||
const py = m.y + Math.sin(v.a) * m.r * v.j;
|
||
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
|
||
}
|
||
ctx.closePath();
|
||
|
||
const mg = ctx.createRadialGradient(m.x - m.r * 0.32, m.y - m.r * 0.30, 0, m.x, m.y, m.r);
|
||
mg.addColorStop(0, this._tint(md.color, 80));
|
||
mg.addColorStop(0.28,this._tint(md.color, 48));
|
||
mg.addColorStop(0.68,md.color);
|
||
mg.addColorStop(1, this._tint(md.color, -62));
|
||
ctx.fillStyle = mg; ctx.fill();
|
||
ctx.strokeStyle = this._tint(md.color, 32); ctx.lineWidth = 1.5; ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
|
||
/* Specular dot */
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.68;
|
||
const sg = ctx.createRadialGradient(
|
||
m.x - m.r * 0.28, m.y - m.r * 0.30, 0,
|
||
m.x - m.r * 0.28, m.y - m.r * 0.30, m.r * 0.40
|
||
);
|
||
sg.addColorStop(0, 'rgba(255,255,255,0.95)');
|
||
sg.addColorStop(0.5,'rgba(255,255,255,0.35)');
|
||
sg.addColorStop(1, 'rgba(255,255,255,0)');
|
||
ctx.fillStyle = sg;
|
||
ctx.beginPath(); ctx.arc(m.x - m.r * 0.28, m.y - m.r * 0.30, m.r * 0.40, 0, Math.PI * 2); ctx.fill();
|
||
ctx.restore();
|
||
|
||
/* Dissolution edge */
|
||
if (this._rxRate > 0.12) {
|
||
ctx.save();
|
||
ctx.globalAlpha = this._rxRate * 0.55;
|
||
ctx.beginPath();
|
||
for (let i = 0; i < m._v.length; i++) {
|
||
const v = m._v[i];
|
||
const px = m.x + Math.cos(v.a) * m.r * v.j;
|
||
const py = m.y + Math.sin(v.a) * m.r * v.j;
|
||
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
|
||
}
|
||
ctx.closePath();
|
||
ctx.strokeStyle = '#FFD166';
|
||
ctx.lineWidth = 1.5 + this._rxRate * 3.5;
|
||
ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 10;
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
/* Пассивирующая плёнка */
|
||
if (this._passiv) {
|
||
ctx.beginPath();
|
||
for (let i = 0; i < m._v.length; i++) {
|
||
const v = m._v[i];
|
||
if (i === 0) ctx.moveTo(m.x + Math.cos(v.a) * m.r * v.j, m.y + Math.sin(v.a) * m.r * v.j);
|
||
else ctx.lineTo(m.x + Math.cos(v.a) * m.r * v.j, m.y + Math.sin(v.a) * m.r * v.j);
|
||
}
|
||
ctx.closePath();
|
||
ctx.fillStyle = 'rgba(55,40,25,0.65)'; ctx.fill();
|
||
}
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawDusts(ctx) {
|
||
ctx.save();
|
||
this._flaskPath(ctx); ctx.clip();
|
||
for (const d of this._dusts) {
|
||
ctx.globalAlpha = d.a * d.life;
|
||
ctx.beginPath(); ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2);
|
||
ctx.fillStyle = d.col; ctx.fill();
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawSparks(ctx) {
|
||
for (const s of this._sparks) {
|
||
ctx.save();
|
||
ctx.globalAlpha = s.life;
|
||
ctx.shadowColor = s.col;
|
||
ctx.shadowBlur = 12;
|
||
ctx.beginPath(); ctx.arc(s.x, s.y, s.r * s.life, 0, Math.PI * 2);
|
||
ctx.fillStyle = s.col; ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
if (this._flameOn) {
|
||
const { cx, nt, nw } = this._g;
|
||
ctx.font = '22px serif';
|
||
ctx.fillText('*', cx + nw + 8, nt + 8);
|
||
}
|
||
}
|
||
|
||
/* ── Термометр ── */
|
||
_drawThermometer(ctx) {
|
||
const { _g: g } = this;
|
||
const tx = g.cx + g.r + 44;
|
||
const ty = g.nt + 8;
|
||
const th = g.cy + g.r - ty - 16;
|
||
const tw = 11;
|
||
const frac = Math.min(1, Math.max(0, (this._temp - 10) / 140));
|
||
const fillH = th * frac;
|
||
const col = `hsl(${Math.round(55 - frac * 55)},92%,56%)`;
|
||
|
||
_flask_rrect(ctx, tx - tw / 2, ty, tw, th, tw / 2);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.07)'; ctx.fill();
|
||
ctx.strokeStyle = 'rgba(120,175,255,0.38)'; ctx.lineWidth = 1.5; ctx.stroke();
|
||
|
||
if (fillH > 0) {
|
||
_flask_rrect(ctx, tx - tw / 2 + 2, ty + th - fillH, tw - 4, fillH, (tw - 4) / 2);
|
||
ctx.fillStyle = col; ctx.fill();
|
||
}
|
||
|
||
ctx.beginPath(); ctx.arc(tx, ty + th + tw * 0.68, tw * 0.74, 0, Math.PI * 2);
|
||
ctx.fillStyle = col; ctx.shadowColor = col; ctx.shadowBlur = 10; ctx.fill(); ctx.shadowBlur = 0;
|
||
|
||
ctx.strokeStyle = 'rgba(150,185,255,0.3)'; ctx.lineWidth = 1;
|
||
for (let deg = 20; deg <= 150; deg += 20) {
|
||
const fy = ty + th - th * (deg - 10) / 140;
|
||
ctx.beginPath(); ctx.moveTo(tx + tw / 2, fy); ctx.lineTo(tx + tw / 2 + 5, fy); ctx.stroke();
|
||
}
|
||
|
||
ctx.font = 'bold 10.5px monospace'; ctx.fillStyle = 'rgba(195,215,255,0.82)';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(Math.round(this._temp) + '°C', tx, ty + th + tw * 2 + 17);
|
||
ctx.fillText('T', tx, ty - 5);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
/* ── pH-полоска ── */
|
||
_drawPHStrip(ctx) {
|
||
const { _g: g } = this;
|
||
const px = g.cx - g.r - 44;
|
||
const py = g.liqTop - 6;
|
||
const pw = 14; const ph = 88;
|
||
const hue = Math.round(this._pH / 14 * 270);
|
||
const col = `hsl(${hue},80%,52%)`;
|
||
|
||
_flask_rrect(ctx, px - pw / 2, py, pw, ph, 3);
|
||
ctx.fillStyle = col; ctx.shadowColor = col; ctx.shadowBlur = 8; ctx.fill();
|
||
ctx.shadowBlur = 0;
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke();
|
||
|
||
ctx.strokeStyle = 'rgba(0,0,0,0.25)'; ctx.lineWidth = 0.8;
|
||
for (let i = 0; i <= 14; i += 2) {
|
||
const ry = py + ph * (1 - i / 14);
|
||
ctx.beginPath(); ctx.moveTo(px - pw / 2 + 2, ry); ctx.lineTo(px + pw / 2 - 2, ry); ctx.stroke();
|
||
}
|
||
|
||
ctx.font = 'bold 10.5px monospace'; ctx.fillStyle = 'rgba(195,215,255,0.82)';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('pH', px, py - 5);
|
||
ctx.fillText(this._pH.toFixed(1), px, py + ph + 15);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
/* ── Бар H₂ ── */
|
||
_drawH2Bar(ctx) {
|
||
const { _g: g } = this;
|
||
const bx = g.cx - 46, by = g.nt - 30;
|
||
const bw = 92, bh = 10;
|
||
|
||
_flask_rrect(ctx, bx, by, bw, bh, 4);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.07)'; ctx.fill();
|
||
|
||
if (this._h2 > 0) {
|
||
const col = this._ignited ? '#EF476F' : '#4CC9F0';
|
||
ctx.shadowColor = col; ctx.shadowBlur = this._h2 > 0.45 ? 10 : 4;
|
||
_flask_rrect(ctx, bx, by, bw * this._h2, bh, 4);
|
||
ctx.fillStyle = col; ctx.fill(); ctx.shadowBlur = 0;
|
||
}
|
||
|
||
ctx.font = '10px monospace'; ctx.fillStyle = 'rgba(190,215,255,0.72)';
|
||
ctx.textAlign = 'center'; ctx.fillText('H₂', g.cx, by - 5); ctx.textAlign = 'left';
|
||
|
||
if (this._h2 > 0.65 && !this._ignited) {
|
||
ctx.font = 'bold 10px sans-serif'; ctx.fillStyle = '#FFD166';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Поднести огонь!', g.cx, by - 16);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
if (this._ignited) {
|
||
ctx.font = 'bold 10px sans-serif'; ctx.fillStyle = '#EF476F';
|
||
ctx.textAlign = 'center'; ctx.fillText('H₂ воспламенился!', g.cx, by - 16); ctx.textAlign = 'left';
|
||
}
|
||
}
|
||
|
||
/* ── Информационная панель ── */
|
||
_drawInfoPanel(ctx) {
|
||
const { _g: g, W } = this;
|
||
const eq = FlaskSim.EQ[`${this.metalType}_${this.acidType}`] || '—';
|
||
const eqY = g.cy + g.r + 26;
|
||
|
||
ctx.font = '12.5px monospace'; ctx.fillStyle = 'rgba(185,215,255,0.78)';
|
||
ctx.textAlign = 'center'; ctx.fillText(eq, W * 0.44, eqY); ctx.textAlign = 'left';
|
||
|
||
if (this._passiv) {
|
||
ctx.font = 'bold 11px sans-serif'; ctx.fillStyle = '#FFD166';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Пассивация: Fe покрыт оксидной плёнкой — реакция прекратилась', W * 0.44, eqY + 19);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
if (this._metal && this._metal.mass > 0.1 && this._rxRate > 0) {
|
||
const bx = g.cx - g.r, by = g.cy + g.r - 6;
|
||
const bw = g.r * 2;
|
||
_flask_rrect(ctx, bx, by, bw, 5, 2);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.07)'; ctx.fill();
|
||
const col = this._rxRate > 0.6 ? '#EF476F' : this._rxRate > 0.3 ? '#FFD166' : '#7BF5A4';
|
||
_flask_rrect(ctx, bx, by, bw * this._rxRate, 5, 2);
|
||
ctx.fillStyle = col; ctx.shadowColor = col; ctx.shadowBlur = 4; ctx.fill(); ctx.shadowBlur = 0;
|
||
}
|
||
}
|
||
|
||
_drawHint(ctx) {
|
||
const { _g: g } = this;
|
||
ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.32)';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Нажмите «Бросить металл» для начала реакции', g.cx, g.cy + 14);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
/* ── Вспомогательные ──────────────────────────────────────────── */
|
||
|
||
_tint(hex, d) {
|
||
const n = parseInt(hex.slice(1), 16);
|
||
const c = v => Math.max(0, Math.min(255, v));
|
||
return `rgb(${c((n >> 16) + d)},${c(((n >> 8) & 255) + d)},${c((n & 255) + d)})`;
|
||
}
|
||
|
||
info() {
|
||
const m = this._metal;
|
||
const md = m ? FlaskSim.METALS[m.type] : null;
|
||
return {
|
||
metal: md?.name ?? '—',
|
||
mass: m ? m.mass.toFixed(2) : '0',
|
||
temp: this._temp.toFixed(1),
|
||
pH: this._pH.toFixed(2),
|
||
h2pct: (this._h2 * 100).toFixed(0),
|
||
rate: (this._rxRate * 100).toFixed(0),
|
||
reacts: md ? md.acids.includes(this.acidType) : false,
|
||
};
|
||
}
|
||
}
|
||
|
||
/* ── Util: скруглённый прямоугольник ─────────────────────────── */
|
||
function _flask_rrect(ctx, x, y, w, h, r) {
|
||
if (w <= 0 || h <= 0) return;
|
||
r = Math.min(r, w / 2, h / 2);
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + r, y);
|
||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||
ctx.arcTo(x, y + h, x, y, r);
|
||
ctx.arcTo(x, y, x + w, y, r);
|
||
ctx.closePath();
|
||
}
|