'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(); }