(function() {
'use strict';
// === УПРАВЛЕНИЕ ПОПАПОМ ===
const popup = document.getElementById('volcano-game-popup');
const closeBtn = document.getElementById('vg-close');
const overlay = popup ? popup.querySelector('.vg-overlay') : null;
// Глобальная функция для открытия попапа
window.openVolcanoGame = function() {
if (!popup) return;
popup.classList.add('active');
popup.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
// Инициализируем игру при первом открытии
if (!window.vgGameInitialized) {
initVolcanoGame();
window.vgGameInitialized = true;
}
};
// Функция закрытия попапа
function closePopup() {
if (!popup) return;
popup.classList.remove('active');
popup.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
// Сбрасываем игру при закрытии
if (window.vgState === 'playing') {
window.vgState = 'intro';
if (window.vgRafId) {
cancelAnimationFrame(window.vgRafId);
window.vgRafId = null;
}
}
}
// Обработчики закрытия
if (closeBtn) {
closeBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
closePopup();
});
}
if (overlay) {
overlay.addEventListener('click', closePopup);
}
// Закрытие по Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && popup && popup.classList.contains('active')) {
closePopup();
}
});
// === ОРИГИНАЛЬНЫЙ SCRIPT.JS (с исправленными селекторами) ===
function initVolcanoGame() {
const DURATION_MS = 20000;
const THRESHOLD_MIN = 98;
const THRESHOLD_MAX = 106;
const BASE_PER_TAP = 0.38;
const BASE_CAP_RATIO = 0.56;
const MIN_TAP_GAP_MS = 80;
const TEMPO_WINDOW_MS = 1250;
const TEMPO_EMA = 0.35;
const TEMPO_LOW = 2.2;
const TEMPO_HIGH = 6;
const STREAK_GOOD_GAP_MS = 360;
const STREAK_SOFT_GAP_MS = 600;
const STREAK_MAX = 10;
const MOMENTUM_BASE = 0.28;
const MOMENTUM_TEMPO = 0.7;
const MOMENTUM_STREAK = 0.18;
const ERUPTION_DURATION_MS = 2600;
const STAGE_UP = [0.07, 0.28, 0.55, 0.8];
const STAGE_DOWN = [0.04, 0.24, 0.5, 0.75];
const STAGE_CAPTIONS = [
"Тапай быстрее!",
"Вулкан шевелится…",
"Он просыпается!",
"Не сбавляй темп!",
"Ещё немного!"
];
// ИСПРАВЛЕНО: убраны пробелы в конце селекторов
const app = document.getElementById("app");
const volcanoButton = document.getElementById("volcanoButton");
const volcanoBody = document.querySelector(".volcano-body");
const lightningBolts = Array.from(document.querySelectorAll(".lightning-bolt"));
const sideLightningBolts = Array.from(document.querySelectorAll(".lightning-bolt.bolt-side"));
const startScreen = document.getElementById("startScreen");
const startButton = document.getElementById("startButton");
const resultScreen = document.getElementById("resultScreen");
const resultTitle = document.getElementById("resultTitle");
const resultKicker = document.getElementById("resultKicker");
const resultDescription = document.getElementById("resultDescription");
const resultIcon = document.getElementById("resultIcon");
const replayButton = document.getElementById("replayButton");
const copyButton = document.getElementById("copyButton");
const copyButtonText = document.getElementById("copyButtonText");
const soundToggle = document.getElementById("soundToggle");
const timer = document.getElementById("timer");
const statusText = document.getElementById("statusText");
const announcement = document.getElementById("announcement");
const tapEffects = document.getElementById("tapEffects");
const eruptionParticles = document.getElementById("eruptionParticles");
const toast = document.getElementById("toast");
const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
let state = "intro";
let roundId = 0;
let threshold = THRESHOLD_MIN;
let startTime = 0;
let deadline = 0;
let lastFrame = 0;
let lastUpdate = 0;
let lastAcceptedTap = null;
let lastAcceptedSound = 0;
let lastShownSecond = 20;
let lastCaptionChange = 0;
let lastAudioUpdate = 0;
let totalTaps = 0;
let core = 0;
let momentum = 0;
let tempo = 0;
let streak = 0;
let recentTaps = [];
let visualHeat = 0;
let visualStage = 0;
let resultTime = null;
let lastOutcome = null;
let rafId = null;
let tapAnimation = null;
let roundTimers = new Set();
let nextInternalFlareAt = Infinity;
let nextHighLightningAt = Infinity;
let highLightningCount = 0;
let lightningEventId = 0;
let soundEnabled = true;
let audioContext = null;
let masterGain = null;
let rumbleGain = null;
let rumbleFilter = null;
let rumbleModGain = null;
let rumbleOscillators = [];
let eruptionNoiseBuffer = null;
const transientAudio = new Set();
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
// Сохраняем ссылки глобально для управления из попапа
window.vgState = state;
window.vgRafId = rafId;
const clamp = (value, min = 0, max = 1) => Math.min(max, Math.max(min, value));
function smoothstep(value) {
const x = clamp(value);
return x * x * (3 - 2 * x);
}
function schedule(callback, delay) {
const id = window.setTimeout(() => {
roundTimers.delete(id);
callback();
}, delay);
roundTimers.add(id);
return id;
}
function clearRoundTimers() {
roundTimers.forEach((id) => window.clearTimeout(id));
roundTimers.clear();
}
function formatSeconds(value) {
return value.toFixed(1).replace(".", ",");
}
function getRawHeat() {
return clamp((core + momentum) / threshold);
}
function coolingTotal(idleSeconds) {
const seconds = Math.max(0, idleSeconds);
return (
0.45 * Math.min(seconds, 0.28) +
2 * Math.max(0, Math.min(seconds, 0.7) - 0.28) +
4.2 * Math.max(0, Math.min(seconds, 1.4) - 0.7) +
7 * Math.max(0, seconds - 1.4)
);
}
function applyCooling(now) {
if (lastAcceptedTap === null) {
lastUpdate = now;
return;
}
const safeNow = Math.max(lastUpdate, now);
const before = Math.max(0, (lastUpdate - lastAcceptedTap) / 1000);
const after = Math.max(0, (safeNow - lastAcceptedTap) / 1000);
momentum = Math.max(0, momentum - (coolingTotal(after) - coolingTotal(before)));
lastUpdate = safeNow;
}
function setStatus(message, force = false) {
if (!force && statusText.textContent === message) return;
statusText.textContent = message;
lastCaptionChange = performance.now();
}
function updateCaption(now, stageChanged = false) {
if (state !== "playing") return;
const idleFor = lastAcceptedTap === null ? 0 : now - lastAcceptedTap;
const remaining = deadline - now;
let nextCaption = STAGE_CAPTIONS[visualStage];
if (idleFor > 700 && getRawHeat() > 0.18) {
nextCaption = "Он остывает! Тапай!";
} else if (remaining <= 5000 && visualStage < 4) {
nextCaption = "Последние секунды!";
}
if (stageChanged || now - lastCaptionChange > 900) {
setStatus(nextCaption);
}
}
function setVisualVariables(heat) {
const smoke = smoothstep((heat - 0.08) / 0.75);
const magma = smoothstep((heat - 0.11) / 0.78);
const crater = smoothstep((heat - 0.2) / 0.7);
const sparks = smoothstep((heat - 0.32) / 0.58);
const rocks = smoothstep((heat - 0.66) / 0.31);
const flare = smoothstep((heat - 0.42) / 0.5);
const lava = smoothstep((heat - 0.5) / 0.46) * 0.92;
const crackReveal = clamp((heat - 0.1) / 0.84);
const shake = clamp((heat - 0.22) / 0.76) * 2.6;
app.style.setProperty("--heat", heat.toFixed(4));
app.style.setProperty("--smoke", smoke.toFixed(4));
app.style.setProperty("--magma", magma.toFixed(4));
app.style.setProperty("--crater", crater.toFixed(4));
app.style.setProperty("--sparks", sparks.toFixed(4));
app.style.setProperty("--rocks", rocks.toFixed(4));
app.style.setProperty("--flare", flare.toFixed(4));
app.style.setProperty("--lava", lava.toFixed(4));
app.style.setProperty("--crack-reveal", crackReveal.toFixed(4));
app.style.setProperty("--shake", shake.toFixed(2) + "px");
}
function updateVisuals(now, announceStage = true) {
let stageChanged = false;
while (visualStage < 4 && visualHeat >= STAGE_UP[visualStage]) {
visualStage += 1;
stageChanged = true;
}
while (visualStage > 0 && visualHeat < STAGE_DOWN[visualStage - 1]) {
visualStage -= 1;
stageChanged = true;
}
if (app.dataset.stage !== String(visualStage)) {
app.dataset.stage = String(visualStage);
}
setVisualVariables(visualHeat);
if (stageChanged && announceStage) {
const stageMessage = STAGE_CAPTIONS[visualStage];
setStatus(stageMessage, true);
announcement.textContent = stageMessage;
if (!reducedMotionQuery.matches && (visualStage === 2 || visualStage === 4) && navigator.vibrate) {
navigator.vibrate(visualStage === 4 ? 28 : 14);
}
}
updateCaption(now, stageChanged);
if (now - lastAudioUpdate > 90) {
updateRumble(getRawHeat());
lastAudioUpdate = now;
}
updateAtmosphericEvents(now);
}
function triggerInternalFlare() {
if (state !== "playing" && state !== "erupting") return;
app.classList.remove("is-internal-flare");
void app.offsetWidth;
app.classList.add("is-internal-flare");
const localRound = roundId;
schedule(() => {
if (roundId === localRound) app.classList.remove("is-internal-flare");
}, 640);
}
function triggerLightning(preferredBolt = null, flashSky = true) {
if (!lightningBolts.length || (state !== "playing" && state !== "erupting")) return;
const duration = reducedMotionQuery.matches ? 240 : 210 + Math.round(Math.random() * 50);
const pool = lightningBolts;
const selected = preferredBolt || pool[Math.floor(Math.random() * pool.length)];
const bolts = Array.isArray(selected) ? selected.filter(Boolean) : [selected];
const localRound = roundId;
const localEvent = ++lightningEventId;
lightningBolts.forEach((item) => item.classList.remove("is-active"));
app.classList.remove("is-lightning");
bolts.forEach((bolt) => bolt.style.setProperty("--lightning-duration", duration + "ms"));
app.style.setProperty("--lightning-duration", duration + "ms");
void bolts[0].getBoundingClientRect();
bolts.forEach((bolt) => bolt.classList.add("is-active"));
if (flashSky) app.classList.add("is-lightning");
schedule(() => {
if (roundId !== localRound || lightningEventId !== localEvent) return;
bolts.forEach((bolt) => bolt.classList.remove("is-active"));
if (flashSky) app.classList.remove("is-lightning");
}, duration + 24);
}
function updateAtmosphericEvents(now) {
if (state !== "playing") return;
const heat = getRawHeat();
const pace = smoothstep((tempo - 3.2) / 4.2);
const recentlyActive = lastAcceptedTap !== null && now - lastAcceptedTap < 620;
if (visualStage >= 3 && heat > 0.55 && recentlyActive) {
if (!Number.isFinite(nextInternalFlareAt)) {
nextInternalFlareAt = now + 350 + Math.random() * 500;
} else if (now >= nextInternalFlareAt) {
triggerInternalFlare();
nextInternalFlareAt = now + 1150 - pace * 420 + Math.random() * 520;
}
} else {
nextInternalFlareAt = Infinity;
app.classList.remove("is-internal-flare");
}
const lightningReady = visualStage >= 4 && heat > 0.76 && recentlyActive && !reducedMotionQuery.matches;
if (lightningReady && highLightningCount < 3) {
if (!Number.isFinite(nextHighLightningAt)) {
nextHighLightningAt = now + 140 + Math.random() * 160;
} else if (now >= nextHighLightningAt) {
triggerLightning();
highLightningCount += 1;
nextHighLightningAt = highLightningCount < 3
? now + 1150 - pace * 250 + Math.random() * 550
: Infinity;
}
} else if (!lightningReady) {
nextHighLightningAt = Infinity;
}
}
function scheduleEruptionLightning() {
const reduced = reducedMotionQuery.matches;
const count = reduced ? 1 : 6 + Math.floor(Math.random() * 2);
const delays = [];
const sequence = [];
let cursor = reduced ? 720 : 420 + Math.random() * 10;
const localRound = roundId;
const leftBolt = lightningBolts.find((bolt) => bolt.classList.contains("bolt-left"));
const rightBolt = lightningBolts.find((bolt) => bolt.classList.contains("bolt-right"));
const innerBolts = lightningBolts.filter((bolt) => !bolt.classList.contains("bolt-side"));
if (reduced) {
sequence.push(sideLightningBolts[Math.floor(Math.random() * sideLightningBolts.length)] || lightningBolts[0]);
} else {
sequence.push(
[leftBolt || lightningBolts[0], rightBolt || lightningBolts[1] || lightningBolts[0]],
innerBolts[0] || lightningBolts[0],
innerBolts[1] || innerBolts[0] || lightningBolts[0],
innerBolts[2] || innerBolts[1] || innerBolts[0] || lightningBolts[0]
);
}
while (sequence.length < count) {
sequence.push(lightningBolts[Math.floor(Math.random() * lightningBolts.length)]);
}
for (let index = 0; index < count; index += 1) {
delays.push(cursor);
cursor += 295 + Math.random() * 10;
}
for (let index = 0; index < count; index += 1) {
schedule(() => {
if (roundId === localRound && state === "erupting") {
triggerLightning(sequence[index], !reduced && index % 2 === 0);
}
}, delays[index]);
}
}
function updateTimer(now) {
const remaining = Math.max(0, deadline - now);
const seconds = Math.ceil(remaining / 1000);
if (seconds !== lastShownSecond) {
lastShownSecond = seconds;
timer.textContent = "0:" + String(seconds).padStart(2, "0");
timer.setAttribute("datetime", "PT" + seconds + "S");
}
app.classList.toggle("timer-danger", seconds <= 5 && state === "playing");
}
function gameLoop(now) {
if (state !== "playing") return;
const effectiveNow = Math.min(now, deadline);
applyCooling(effectiveNow);
const deltaSeconds = Math.max(0, Math.min(1.5, (effectiveNow - lastFrame) / 1000));
lastFrame = effectiveNow;
const rawHeat = getRawHeat();
const timeConstant = rawHeat > visualHeat ? 0.1 : 0.45;
const smoothing = 1 - Math.exp(-deltaSeconds / timeConstant);
visualHeat += (rawHeat - visualHeat) * smoothing;
visualHeat = clamp(visualHeat);
updateTimer(now);
updateVisuals(now);
if (now >= deadline) {
finishLoss();
return;
}
rafId = window.requestAnimationFrame(gameLoop);
window.vgRafId = rafId;
}
function resetVisualState() {
visualHeat = 0;
visualStage = 0;
nextInternalFlareAt = Infinity;
nextHighLightningAt = Infinity;
highLightningCount = 0;
lightningEventId += 1;
app.dataset.stage = "0";
app.classList.remove("is-internal-flare", "is-lightning");
lightningBolts.forEach((bolt) => bolt.classList.remove("is-active"));
setVisualVariables(0);
tapEffects.replaceChildren();
eruptionParticles.replaceChildren();
}
function startGame() {
roundId += 1;
clearRoundTimers();
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
if (tapAnimation) {
tapAnimation.cancel();
tapAnimation = null;
}
stopTransientAudio();
updateRumble(0, true);
state = "playing";
window.vgState = state;
lastOutcome = null;
resultTime = null;
threshold = THRESHOLD_MIN + Math.random() * (THRESHOLD_MAX - THRESHOLD_MIN);
totalTaps = 0;
core = 0;
momentum = 0;
tempo = 0;
streak = 0;
recentTaps = [];
lastAcceptedTap = null;
lastAcceptedSound = 0;
lastAudioUpdate = 0;
lastCaptionChange = 0;
const now = performance.now();
startTime = now;
deadline = now + DURATION_MS;
lastFrame = now;
lastUpdate = now;
lastShownSecond = 20;
resetVisualState();
timer.textContent = "0:20";
timer.setAttribute("datetime", "PT20S");
statusText.textContent = STAGE_CAPTIONS[0];
announcement.textContent = "Игра началась. Тапай по вулкану быстро и без остановки.";
copyButtonText.textContent = "Скопировать результат";
app.classList.remove("is-intro", "is-erupting", "is-won", "is-lost", "has-tapped", "timer-danger");
void app.offsetWidth;
app.classList.add("is-playing");
startScreen.hidden = true;
resultScreen.classList.remove("is-visible");
resultScreen.hidden = true;
toast.classList.remove("is-visible");
toast.hidden = true;
volcanoButton.disabled = false;
rafId = window.requestAnimationFrame(gameLoop);
window.vgRafId = rafId;
}
function isPointOnVolcano(clientX, clientY) {
if (volcanoBody && typeof volcanoBody.isPointInFill === "function") {
try {
const matrix = volcanoBody.getScreenCTM();
const svg = volcanoBody.ownerSVGElement;
if (matrix && svg) {
const point = svg.createSVGPoint();
point.x = clientX;
point.y = clientY;
return volcanoBody.isPointInFill(point.matrixTransform(matrix.inverse()));
}
} catch (_error) {
// Fall through
}
}
const rect = volcanoButton.getBoundingClientRect();
const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height;
if (y < 0.35 || y > 1.02) return false;
const slope = clamp((y - 0.35) / 0.65);
const halfWidth = 0.095 + Math.pow(slope, 1.22) * 0.405;
return Math.abs(x - 0.52) <= halfWidth;
}
function handleTap(clientX, clientY) {
if (state !== "playing") return;
const now = performance.now();
if (now >= deadline) {
updateTimer(now);
finishLoss();
return;
}
applyCooling(now);
if (lastAcceptedTap !== null && now - lastAcceptedTap < MIN_TAP_GAP_MS) {
return;
}
const gap = lastAcceptedTap === null ? Infinity : now - lastAcceptedTap;
if (gap > STREAK_SOFT_GAP_MS) {
tempo = 0;
streak = 0;
recentTaps = [];
}
recentTaps = recentTaps.filter((tapTime) => now - tapTime <= TEMPO_WINDOW_MS);
recentTaps.push(now);
let rawTempo = 0;
if (recentTaps.length >= 2) {
const span = Math.max(MIN_TAP_GAP_MS, now - recentTaps[0]);
rawTempo = Math.min(12.5, (recentTaps.length - 1) * 1000 / span);
}
tempo += (rawTempo - tempo) * TEMPO_EMA;
if (gap <= STREAK_GOOD_GAP_MS) {
streak = Math.min(STREAK_MAX, streak + 1);
} else if (gap <= STREAK_SOFT_GAP_MS) {
streak = Math.max(0, streak - 3);
} else {
streak = 0;
}
const pacePosition = (tempo - TEMPO_LOW) / (TEMPO_HIGH - TEMPO_LOW);
const paceFactor = smoothstep(pacePosition);
const streakFactor = streak / STREAK_MAX;
core = Math.min(threshold * BASE_CAP_RATIO, core + BASE_PER_TAP);
momentum += MOMENTUM_BASE + MOMENTUM_TEMPO * paceFactor + MOMENTUM_STREAK * streakFactor;
totalTaps += 1;
lastAcceptedTap = now;
lastUpdate = now;
if (totalTaps === 1) app.classList.add("has-tapped");
spawnTapFeedback(clientX, clientY);
playTapPulse();
ensureAudio();
playTapSound(now, paceFactor);
const rawHeat = getRawHeat();
visualHeat += (rawHeat - visualHeat) * 0.22;
visualHeat = clamp(visualHeat);
updateVisuals(now);
if (core + momentum >= threshold) {
finishWin(now);
}
}
function playTapPulse() {
if (reducedMotionQuery.matches || typeof volcanoButton.animate !== "function") return;
if (tapAnimation) tapAnimation.cancel();
tapAnimation = volcanoButton.animate(
[
{ transform: "scale(1)" },
{ transform: "scale(.989)", offset: 0.42 },
{ transform: "scale(1)" }
],
{ duration: 115, easing: "ease-out" }
);
tapAnimation.onfinish = () => {
tapAnimation = null;
};
}
function spawnTapFeedback(clientX, clientY) {
const rect = app.getBoundingClientRect();
const x = clamp(clientX - rect.left, 8, rect.width - 8);
const y = clamp(clientY - rect.top, rect.height * 0.22, rect.height - 8);
const fragment = document.createDocumentFragment();
const ripple = document.createElement("span");
const localRound = roundId;
ripple.className = "tap-ripple";
ripple.style.setProperty("--x", x + "px");
ripple.style.setProperty("--y", y + "px");
fragment.appendChild(ripple);
const sparkCount = visualStage >= 3 ? 4 : visualStage >= 1 ? 3 : 2;
for (let index = 0; index < sparkCount; index += 1) {
const spark = document.createElement("span");
const angle = Math.random() * Math.PI * 2;
const distance = 15 + Math.random() * 25;
spark.className = "tap-spark";
spark.style.setProperty("--x", x + "px");
spark.style.setProperty("--y", y + "px");
spark.style.setProperty("--dx", Math.cos(angle) * distance + "px");
spark.style.setProperty("--dy", (Math.sin(angle) * distance - 8) + "px");
spark.style.setProperty("--size", (2 + Math.random() * 2.5) + "px");
fragment.appendChild(spark);
}
tapEffects.appendChild(fragment);
while (tapEffects.childElementCount > 70) {
tapEffects.firstElementChild.remove();
}
schedule(() => {
if (roundId !== localRound) return;
ripple.remove();
Array.from(tapEffects.children).forEach((node) => {
const animation = node.getAnimations ? node.getAnimations()[0] : null;
if (animation && animation.playState === "finished") node.remove();
});
}, 620);
}
function finishWin(now) {
if (state !== "playing") return;
state = "erupting";
window.vgState = state;
resultTime = Math.min(20, Math.max(0, (now - startTime) / 1000));
lastOutcome = "won";
volcanoButton.disabled = true;
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
rafId = null;
window.vgRafId = null;
}
updateTimer(now);
app.classList.remove("is-playing", "timer-danger");
app.classList.add("is-erupting");
app.dataset.stage = "5";
visualHeat = 1;
setVisualVariables(1);
setStatus("ПРОБУЖДЕНИЕ!", true);
announcement.textContent = "Вулкан проснулся. Началось извержение!";
updateRumble(0, false);
triggerInternalFlare();
scheduleEruptionLightning();
spawnEruptionParticles();
playEruptionSound();
if (!reducedMotionQuery.matches && navigator.vibrate) {
navigator.vibrate([45, 25, 90, 30, 170]);
}
const localRound = roundId;
schedule(() => {
if (roundId !== localRound || state !== "erupting") return;
showResult("won");
}, ERUPTION_DURATION_MS);
}
function finishLoss() {
if (state !== "playing") return;
state = "lost";
window.vgState = state;
lastOutcome = "lost";
resultTime = null;
volcanoButton.disabled = true;
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
rafId = null;
window.vgRafId = null;
}
timer.textContent = "0:00";
timer.setAttribute("datetime", "PT0S");
lastShownSecond = 0;
app.classList.remove("is-playing", "timer-danger");
app.classList.add("is-lost");
app.classList.remove("is-internal-flare", "is-lightning");
lightningBolts.forEach((bolt) => bolt.classList.remove("is-active"));
setStatus("Вулкан затих…", true);
announcement.textContent = "Время вышло. Вулкан не проснулся.";
updateRumble(0, true);
const localRound = roundId;
schedule(() => {
if (roundId !== localRound || state !== "lost") return;
showResult("lost");
}, 650);
}
function showResult(outcome) {
if (outcome === "won") {
state = "won";
window.vgState = state;
app.classList.add("is-won");
resultIcon.textContent = "🌋";
resultKicker.textContent = "Победа · Вулкан проснулся";
resultTitle.textContent = "Ты разбудил вулкан! 🌋";
resultDescription.textContent = "Извержение началось за " + formatSeconds(resultTime) + " сек.";
} else {
state = "lost";
window.vgState = state;
resultIcon.textContent = "💤";
resultKicker.textContent = "Экспедиция завершена";
resultTitle.textContent = "Вулкан не проснулся";
resultDescription.textContent = "Похоже, нужно тапать быстрее";
}
resultScreen.hidden = false;
window.requestAnimationFrame(() => resultScreen.classList.add("is-visible"));
const localRound = roundId;
schedule(() => {
if (roundId === localRound && !resultScreen.hidden) resultTitle.focus({ preventScroll: true });
}, 420);
}
function spawnEruptionParticles() {
eruptionParticles.replaceChildren();
const fragment = document.createDocumentFragment();
const reduced = reducedMotionQuery.matches;
const rockCount = reduced ? 5 : 16;
const lavaCount = reduced ? 3 : 14;
const ashCount = reduced ? 6 : 20;
const sparkCount = reduced ? 0 : 30;
for (let index = 0; index < rockCount; index += 1) {
const rock = document.createElement("i");
const direction = Math.random() < 0.5 ? -1 : 1;
const distance = direction * (42 + Math.random() * 160);
const rise = -(160 + Math.random() * 230);
const landing = 95 + Math.random() * 285;
const rotation = direction * (240 + Math.random() * 620);
rock.className = "eruption-rock";
rock.style.setProperty("--x", (47 + Math.random() * 6) + "%");
rock.style.setProperty("--size", (7 + Math.random() * 10) + "px");
rock.style.setProperty("--dx", distance + "px");
rock.style.setProperty("--dx-mid", (distance * 0.53) + "px");
rock.style.setProperty("--y-mid", rise + "px");
rock.style.setProperty("--y-end", landing + "px");
rock.style.setProperty("--rot-mid", (rotation * 0.46) + "deg");
rock.style.setProperty("--rot", rotation + "deg");
rock.style.setProperty("--duration", (1.25 + Math.random() * 0.75) + "s");
rock.style.setProperty("--delay", (Math.random() * 0.22) + "s");
fragment.appendChild(rock);
}
for (let index = 0; index < lavaCount; index += 1) {
const drop = document.createElement("i");
const direction = Math.random() < 0.5 ? -1 : 1;
const distance = direction * (30 + Math.random() * 125);
const rise = -(125 + Math.random() * 205);
drop.className = "lava-drop";
drop.style.setProperty("--x", (48 + Math.random() * 4) + "%");
drop.style.setProperty("--length", (13 + Math.random() * 18) + "px");
drop.style.setProperty("--dx", distance + "px");
drop.style.setProperty("--dx-mid", (distance * 0.5) + "px");
drop.style.setProperty("--y-mid", rise + "px");
drop.style.setProperty("--y-end", (100 + Math.random() * 250) + "px");
drop.style.setProperty("--tilt", (direction * (12 + Math.random() * 25)) + "deg");
drop.style.setProperty("--duration", (1 + Math.random() * 0.6) + "s");
drop.style.setProperty("--delay", (Math.random() * 0.2) + "s");
fragment.appendChild(drop);
}
for (let index = 0; index < sparkCount; index += 1) {
const spark = document.createElement("i");
const direction = Math.random() < 0.5 ? -1 : 1;
const distance = direction * (45 + Math.random() * 170);
const rise = -(90 + Math.random() * 250);
spark.className = "eruption-spark";
spark.style.setProperty("--x", (48 + Math.random() * 4) + "%");
spark.style.setProperty("--size", (1.2 + Math.random() * 1.6) + "px");
spark.style.setProperty("--length", (5 + Math.random() * 7) + "px");
spark.style.setProperty("--dx", distance + "px");
spark.style.setProperty("--dx-mid", (distance * 0.55) + "px");
spark.style.setProperty("--y-mid", rise + "px");
spark.style.setProperty("--y-end", (40 + Math.random() * 190) + "px");
spark.style.setProperty("--tilt", (direction * (10 + Math.random() * 25)) + "deg");
spark.style.setProperty("--duration", (0.75 + Math.random() * 0.5) + "s");
spark.style.setProperty("--delay", (Math.random() * 0.45) + "s");
fragment.appendChild(spark);
}
for (let index = 0; index < ashCount; index += 1) {
const ash = document.createElement("i");
ash.className = "eruption-ash";
ash.style.setProperty("--x", (Math.random() * 100) + "%");
ash.style.setProperty("--size", (2 + Math.random() * 6) + "px");
ash.style.setProperty("--drift", (-55 + Math.random() * 110) + "px");
ash.style.setProperty("--duration", (2.2 + Math.random() * 2.4) + "s");
ash.style.setProperty("--delay", (0.35 + Math.random() * 1.25) + "s");
fragment.appendChild(ash);
}
eruptionParticles.appendChild(fragment);
}
function ensureAudio() {
if (!soundEnabled || !AudioContextClass) return;
try {
if (!audioContext) {
audioContext = new AudioContextClass();
masterGain = audioContext.createGain();
rumbleGain = audioContext.createGain();
rumbleFilter = audioContext.createBiquadFilter();
rumbleModGain = audioContext.createGain();
const rumbleHighpass = audioContext.createBiquadFilter();
const compressor = audioContext.createDynamicsCompressor();
const rumbleLfo = audioContext.createOscillator();
masterGain.gain.value = 0.56;
rumbleGain.gain.value = 0.0001;
rumbleHighpass.type = "highpass";
rumbleHighpass.frequency.value = 28;
rumbleFilter.type = "lowpass";
rumbleFilter.frequency.value = 120;
rumbleFilter.Q.value = 0.75;
rumbleModGain.gain.value = 0;
compressor.threshold.value = -14;
compressor.knee.value = 16;
compressor.ratio.value = 6;
compressor.attack.value = 0.012;
compressor.release.value = 0.28;
rumbleGain.connect(rumbleHighpass);
rumbleHighpass.connect(rumbleFilter);
rumbleFilter.connect(masterGain);
masterGain.connect(compressor);
compressor.connect(audioContext.destination);
[42, 58].forEach((frequency, index) => {
const oscillator = audioContext.createOscillator();
const voiceGain = audioContext.createGain();
oscillator.type = index === 0 ? "sine" : "triangle";
oscillator.frequency.value = frequency;
oscillator.detune.value = index === 0 ? -3 : 4;
voiceGain.gain.value = index === 0 ? 0.64 : 0.32;
oscillator.connect(voiceGain);
voiceGain.connect(rumbleGain);
rumbleModGain.connect(oscillator.detune);
oscillator.start();
rumbleOscillators.push(oscillator);
});
rumbleLfo.type = "sine";
rumbleLfo.frequency.value = 0.55;
rumbleLfo.connect(rumbleModGain);
rumbleLfo.start();
rumbleOscillators.push(rumbleLfo);
eruptionNoiseBuffer = createEruptionNoiseBuffer();
}
if (audioContext.state === "suspended") {
audioContext.resume().catch(() => {});
}
setMasterVolume();
} catch (_error) {
audioContext = null;
masterGain = null;
rumbleGain = null;
rumbleFilter = null;
rumbleModGain = null;
rumbleOscillators = [];
eruptionNoiseBuffer = null;
}
}
function setMasterVolume() {
if (!audioContext || !masterGain) return;
const value = soundEnabled && !document.hidden ? 0.56 : 0.0001;
masterGain.gain.setTargetAtTime(value, audioContext.currentTime, 0.025);
}
function updateRumble(heat, immediate = false) {
if (!audioContext || !rumbleGain) return;
const normalized = smoothstep((heat - 0.48) / 0.52);
const value = soundEnabled && state === "playing" ? 0.0001 + normalized * 0.042 : 0.0001;
const now = audioContext.currentTime;
if (immediate) {
rumbleGain.gain.cancelScheduledValues(now);
rumbleGain.gain.setValueAtTime(value, now);
} else {
rumbleGain.gain.setTargetAtTime(value, now, 0.12);
}
if (rumbleFilter) {
rumbleFilter.frequency.setTargetAtTime(120 + normalized * 70, now, 0.14);
}
if (rumbleModGain) {
rumbleModGain.gain.setTargetAtTime(normalized * 6, now, 0.16);
}
}
function registerTransient(source) {
transientAudio.add(source);
source.onended = () => transientAudio.delete(source);
}
function stopTransientAudio() {
transientAudio.forEach((source) => {
try {
source.stop();
} catch (_error) {
// Node may already have stopped
}
});
transientAudio.clear();
}
function playTapSound(now, paceFactor) {
if (!soundEnabled || !audioContext || !masterGain || audioContext.state !== "running") return;
if (now - lastAcceptedSound < 62) return;
lastAcceptedSound = now;
try {
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
const startAt = audioContext.currentTime;
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(88 + paceFactor * 20, startAt);
oscillator.frequency.exponentialRampToValueAtTime(43, startAt + 0.095);
gain.gain.setValueAtTime(0.018 + paceFactor * 0.012, startAt);
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + 0.105);
oscillator.connect(gain);
gain.connect(masterGain);
registerTransient(oscillator);
oscillator.start(startAt);
oscillator.stop(startAt + 0.12);
} catch (_error) {
// Sound is optional
}
}
function createEruptionNoiseBuffer() {
if (!audioContext) return null;
const duration = 2.82;
const frameCount = Math.floor(audioContext.sampleRate * duration);
const buffer = audioContext.createBuffer(1, frameCount, audioContext.sampleRate);
const data = buffer.getChannelData(0);
let slow = 0;
let mid = 0;
for (let index = 0; index < frameCount; index += 1) {
const white = Math.random() * 2 - 1;
slow = slow * 0.985 + white * 0.015;
mid = mid * 0.9 + white * 0.1;
data[index] = clamp((slow * 0.68 + mid * 0.25 + white * 0.07) * 1.7, -1, 1);
}
return buffer;
}
function playEruptionSound() {
if (!soundEnabled || !audioContext || !masterGain || audioContext.state !== "running") return;
try {
const duration = 2.78;
if (!eruptionNoiseBuffer) eruptionNoiseBuffer = createEruptionNoiseBuffer();
const noise = audioContext.createBufferSource();
const lowHighpass = audioContext.createBiquadFilter();
const lowLowpass = audioContext.createBiquadFilter();
const lowGain = audioContext.createGain();
const ashHighpass = audioContext.createBiquadFilter();
const ashLowpass = audioContext.createBiquadFilter();
const ashGain = audioContext.createGain();
const sub = audioContext.createOscillator();
const subGain = audioContext.createGain();
const mass = audioContext.createOscillator();
const massGain = audioContext.createGain();
const vibration = audioContext.createOscillator();
const vibrationDepth = audioContext.createGain();
const eruptionBus = audioContext.createGain();
const eruptionFilter = audioContext.createBiquadFilter();
const startAt = audioContext.currentTime + 0.012;
noise.buffer = eruptionNoiseBuffer;
lowHighpass.type = "highpass";
lowHighpass.frequency.value = 28;
lowLowpass.type = "lowpass";
lowLowpass.frequency.setValueAtTime(360, startAt);
lowLowpass.frequency.exponentialRampToValueAtTime(190, startAt + duration);
lowLowpass.Q.value = 0.7;
ashHighpass.type = "highpass";
ashHighpass.frequency.value = 150;
ashLowpass.type = "lowpass";
ashLowpass.frequency.setValueAtTime(1800, startAt);
ashLowpass.frequency.exponentialRampToValueAtTime(720, startAt + duration);
ashLowpass.Q.value = 0.55;
lowGain.gain.setValueAtTime(0.0001, startAt);
lowGain.gain.linearRampToValueAtTime(0.02, startAt + 0.14);
lowGain.gain.linearRampToValueAtTime(0.34, startAt + 0.72);
lowGain.gain.linearRampToValueAtTime(0.29, startAt + 1.45);
lowGain.gain.exponentialRampToValueAtTime(0.0001, startAt + 2.76);
ashGain.gain.setValueAtTime(0.0001, startAt);
ashGain.gain.setValueAtTime(0.0001, startAt + 0.22);
ashGain.gain.linearRampToValueAtTime(0.18, startAt + 0.88);
ashGain.gain.linearRampToValueAtTime(0.16, startAt + 1.72);
ashGain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration);
sub.type = "triangle";
sub.frequency.setValueAtTime(46, startAt);
sub.frequency.exponentialRampToValueAtTime(34, startAt + 2.72);
subGain.gain.setValueAtTime(0.0001, startAt);
subGain.gain.linearRampToValueAtTime(0.13, startAt + 0.22);
subGain.gain.linearRampToValueAtTime(0.15, startAt + 0.72);
subGain.gain.linearRampToValueAtTime(0.1, startAt + 1.65);
subGain.gain.exponentialRampToValueAtTime(0.0001, startAt + 2.72);
mass.type = "sine";
mass.frequency.setValueAtTime(71, startAt);
mass.frequency.exponentialRampToValueAtTime(38, startAt + 2.28);
massGain.gain.setValueAtTime(0.0001, startAt);
massGain.gain.linearRampToValueAtTime(0.03, startAt + 0.14);
massGain.gain.linearRampToValueAtTime(0.17, startAt + 0.5);
massGain.gain.linearRampToValueAtTime(0.07, startAt + 1.45);
massGain.gain.exponentialRampToValueAtTime(0.0001, startAt + 2.28);
vibration.type = "sine";
vibration.frequency.value = 3.4;
vibrationDepth.gain.value = 8;
vibration.connect(vibrationDepth);
vibrationDepth.connect(sub.detune);
vibrationDepth.connect(mass.detune);
eruptionBus.gain.setValueAtTime(0.0001, startAt);
eruptionBus.gain.linearRampToValueAtTime(0.52, startAt + 0.18);
eruptionBus.gain.linearRampToValueAtTime(0.78, startAt + 0.52);
eruptionBus.gain.linearRampToValueAtTime(0.68, startAt + 1.35);
eruptionBus.gain.exponentialRampToValueAtTime(0.0001, startAt + duration);
eruptionFilter.type = "lowpass";
eruptionFilter.frequency.setValueAtTime(1500, startAt);
eruptionFilter.frequency.exponentialRampToValueAtTime(900, startAt + duration);
eruptionFilter.Q.value = 0.45;
noise.connect(lowHighpass);
lowHighpass.connect(lowLowpass);
lowLowpass.connect(lowGain);
lowGain.connect(eruptionBus);
noise.connect(ashHighpass);
ashHighpass.connect(ashLowpass);
ashLowpass.connect(ashGain);
ashGain.connect(eruptionBus);
sub.connect(subGain);
subGain.connect(eruptionBus);
mass.connect(massGain);
massGain.connect(eruptionBus);
eruptionBus.connect(eruptionFilter);
eruptionFilter.connect(masterGain);
registerTransient(noise);
registerTransient(sub);
registerTransient(mass);
registerTransient(vibration);
noise.start(startAt);
sub.start(startAt);
mass.start(startAt);
vibration.start(startAt);
noise.stop(startAt + duration + 0.05);
sub.stop(startAt + duration + 0.05);
mass.stop(startAt + duration + 0.05);
vibration.stop(startAt + duration + 0.05);
} catch (_error) {
// Sound is optional
}
}
function toggleSound() {
soundEnabled = !soundEnabled;
soundToggle.setAttribute("aria-pressed", String(soundEnabled));
soundToggle.setAttribute("aria-label", soundEnabled ? "Выключить звук" : "Включить звук");
if (soundEnabled) {
ensureAudio();
updateRumble(getRawHeat(), true);
} else {
updateRumble(0, true);
stopTransientAudio();
setMasterVolume();
}
}
function getShareText() {
if (lastOutcome === "won") {
return "Я разбудил вулкан за " + formatSeconds(resultTime) + " сек 🌋 А у тебя получится?";
}
return "Я пытался разбудить вулкан, но не успел за 20 секунд 🌋 А у тебя получится?";
}
function fallbackCopy(text) {
const previousFocus = document.activeElement;
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "0";
textarea.style.opacity = "0";
textarea.style.fontSize = "16px";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
let copied = false;
try {
copied = document.execCommand("copy");
} catch (_error) {
copied = false;
}
textarea.remove();
if (previousFocus && typeof previousFocus.focus === "function") {
previousFocus.focus({ preventScroll: true });
}
return copied;
}
async function copyResult() {
const text = getShareText();
const localRound = roundId;
let copied = false;
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
copied = true;
} catch (_error) {
copied = false;
}
}
if (!copied) copied = fallbackCopy(text);
if (roundId !== localRound) return;
if (copied) {
copyButtonText.textContent = "Скопировано!";
showToast("Результат скопирован");
schedule(() => {
if (roundId === localRound) copyButtonText.textContent = "Скопировать результат";
}, 1800);
} else {
window.prompt("Скопируй результат:", text);
showToast("Текст готов для копирования");
}
}
function showToast(message) {
toast.textContent = message;
toast.hidden = false;
window.requestAnimationFrame(() => toast.classList.add("is-visible"));
const localRound = roundId;
schedule(() => {
if (roundId !== localRound) return;
toast.classList.remove("is-visible");
schedule(() => {
if (roundId === localRound) toast.hidden = true;
}, 260);
}, 1700);
}
// === ОБРАБОТЧИКИ СОБЫТИЙ ===
startButton.addEventListener("click", () => {
ensureAudio();
startGame();
});
replayButton.addEventListener("click", () => {
ensureAudio();
startGame();
});
copyButton.addEventListener("click", copyResult);
soundToggle.addEventListener("click", toggleSound);
volcanoButton.addEventListener("pointerdown", (event) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
if (!isPointOnVolcano(event.clientX, event.clientY)) return;
event.preventDefault();
handleTap(event.clientX, event.clientY);
});
volcanoButton.addEventListener("keydown", (event) => {
if (event.repeat || (event.key !== "Enter" && event.key !== " ")) return;
event.preventDefault();
const rect = volcanoButton.getBoundingClientRect();
handleTap(rect.left + rect.width / 2, rect.top + rect.height * 0.57);
});
volcanoButton.addEventListener("contextmenu", (event) => event.preventDefault());
volcanoButton.addEventListener("dragstart", (event) => event.preventDefault());
document.addEventListener("visibilitychange", () => {
setMasterVolume();
if (!document.hidden && state === "playing" && performance.now() >= deadline) {
updateTimer(performance.now());
finishLoss();
}
});
window.addEventListener("pagehide", () => {
updateRumble(0, true);
setMasterVolume();
});
resetVisualState();
}
})();