'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 ZnCl₂ + H₂',
Zn_H2SO4: 'Zn + H₂SO₄ ZnSO₄ + H₂',
Fe_HCl: 'Fe + 2HCl FeCl₂ + H₂',
Mg_HCl: 'Mg + 2HCl MgCl₂ + H₂',
Mg_H2SO4: 'Mg + H₂SO₄ MgSO₄ + H₂',
Mg_H2O: 'Mg + 2H₂O Mg(OH)₂ + H₂',
Al_HCl: '2Al + 6HCl 2AlCl₃ + 3H₂',
Al_H2SO4: '2Al + 3H₂SO₄ Al₂(SO₄)₃ + 3H₂',
Na_HCl: '2Na + 2HCl 2NaCl + H₂',
Na_H2SO4: '2Na + H₂SO₄ Na₂SO₄ + H₂',
Na_H2O: '2Na + 2H₂O 2NaOH + H₂',
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;
/* product label animation */
this._prodLabelAge = -1;
this._prodLabelText = '';
this._prodLabelType = 'gas';
this._time = 0;
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;
if (window.LabFX) {
LabFX.sound.play('bounce', { pitch: 0.6 });
// Brief delay then fizz as metal hits acid
setTimeout(() => { if (window.LabFX) LabFX.sound.play('fizz'); }, 350);
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: nt - 5, count: 6,
color: '#FFFFFF', speed: 25, spread: 1.6, angle: -Math.PI / 2,
gravity: -60, life: 1500, shape: 'ring' });
}
}
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; if (window.LabFX) LabFX.sound.play('click'); }
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;
this._time += dt;
if (window.LabFX) LabFX.particles.update(dt);
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);
}
/* product label age */
if (this._prodLabelAge >= 0) {
this._prodLabelAge += dt / 3.0;
if (this._prodLabelAge >= 1.0) this._prodLabelAge = -1;
}
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--;
}
/* trigger H2 product label when reaction first picks up */
if (md.h2 > 0 && this._rxRate > 0.05 && this._prodLabelAge < 0 && window.ChemVisuals) {
this._prodLabelText = 'H₂ ';
this._prodLabelType = 'gas';
this._prodLabelAge = 0;
}
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,
});
}
if (window.LabFX) {
LabFX.sound.play('whoosh', { pitch: 1.5 });
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: nt - 10, count: 20,
color: '#FFA500', speed: 80, spread: 2.0, angle: -Math.PI / 2,
gravity: -100, life: 300, shape: 'spark', glow: true });
}
}
/* ════════════════════════════════════════════════════════════════
РЕНДЕРИНГ
════════════════════════════════════════════════════════════════ */
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;
if (window.ChemVisuals) {
ChemVisuals.drawDeskBackground(ctx, W, H, tableY);
ChemVisuals.drawVesselShadow(ctx, g.cx, tableY + 2, g.r);
} else {
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();
}
/* Spirit lamp under flask when flame is on */
if (window.ChemVisuals) {
const lampX = g.cx;
const lampY = tableY + 18;
ChemVisuals.drawSpiritLamp(ctx, lampX, lampY, this._flameOn, this._time);
}
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);
if (window.LabFX) LabFX.particles.draw(ctx);
/* animated product labels */
if (window.ChemVisuals && this._prodLabelAge >= 0) {
ChemVisuals.drawProductLabel(ctx, g.cx, g.nt - 10, this._prodLabelText, this._prodLabelType, this._prodLabelAge);
if (this._prodLabelType === 'gas') {
ChemVisuals.animateGasBubbles(ctx, g.cx, g.nt - 8, 'rgba(200,235,255,0.8)', this._time);
}
}
}
/* ── Тень/отражение колбы на столе ── */
_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();
}