Top.Mail.Ru
Небольшой интерактивный тест подберет путешествие по вашему внутреннему ритму, запросу и ощущению момента.
Если пока не решили, куда ехать — начните с этого теста.
ВСЕ СВОИ ТРЕВЕЛ
2 минуты
7 вопросов

Путешествия, о которых вы будете рассказывать всем

ВСЕ СВОИ ТРЕВЕЛ
(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(); } })();

Подписывайтесь чтоб не пропустить

Анонсы туров и горящие предложения: билеты, туры и другое

нас выбирают за

Комфорт
Возим в туры «всё включено», вам остается только приехать
Душевность
Создаём неповторимую атмосферу среди людей близких по духу
Погружение
Помогаем прикоснуться к культуре региона: попробовать вкуснейшую национальную кухню, побывать на экскурсиях от душевных местных гидов
ЗаботУ
Всегда рядом и на связи — до, во время и даже после тура

Выбери путешествие, о котором будешь рассказывать всем

ДАГЕСТАН
Аулы, крепости, каньоны, горы, пустыня, море, водопады, пикники, чуду, кофе, самовар, национальные блюда, экраноплан, экскурсии, лезгинка, лошади, сап-борды, коньки, экстрим, высота 1650
ВСЕ СВОИ ТРЕВЕЛ
СЕЗОН
Группа
Впечатления
круглый год
до 12 человек
на всю жизнь

южный дагестан

Здесь горы простираются до самого горизонта, живописные долины открывают уникальные виды, а в аулах замедляется время. Мы увидим одну из самых южных точек страны — величественную гору Базардюзю, южный город Дербент и единственный лиановый лес в России, посетим музей в самом высокогорном селе Куруш.
ВСЕ СВОИ ТРЕВЕЛ
СЕЗОН
Группа
Тариф
Круглый год
до 12 человек
Всё включено

Териберка

Арктика, край света, океан, тундра, сопки, киты, олени, хаски, треска, гребешки, морские ежи, корабли, квадроциклы, снегоходы, качели, ветропарк, звездное небо, северное сияние
ВСЕ СВОИ ТРЕВЕЛ
СЕЗОН
Группа
Впечатления
Круглый год
до 12 человек
На всю жизнь

Алтай

Сибирь, тайга, горы, степи, марс, остров, перевалы, скалы, озера, реки, водопады, кедры, маральник, благородные олени, лошади, бурундуки, аилы, хариус, чай, мёд, катера, моторафтинг, вертолеты, высота 1717
ВСЕ СВОИ ТРЕВЕЛ
СЕЗОН
Группа
Впечатления
май - октябрь
до 8 человек
На всю жизнь

Байкал

Сибирь, лёд, остров, озеро, гроты, скалы, тайга, степи, лиственницы, шаманы, буддизм, коньки, хивус, баня, багги, квадроциклы, нерпы, загадывание желаний, священные места, традиции, юрты, этноэкскурсия, буузы, омуль, уха, настойка, пикники
ВСЕ СВОИ ТРЕВЕЛ
СЕЗОН
Группа
Впечатления
февраль - март, июль - сентябрь
до 12 человек
На всю жизнь

Карелия

Озёра, шхеры, острова, скалы, тайга, мхи, сосны, ельники, равнины, водопады, катер баня, карельские традиции, деревянные церкви, этноэкскурсии, калитки, уха, настойки, ягоды, грибы, пикник, знаменитый парк Рускеала, остров Кижи, Поонга, саамы, сказания
ВСЕ СВОИ ТРЕВЕЛ
СЕЗОН
Группа
Впечатления
Круглый год
до 12 человек
На всю жизнь

ОСЕТИЯ

Кавказ, горы, ущелья, перевалы, водопады, башни, скалы, древние святилища, минеральные источники, традиции, осетинские пироги, лывжа, чанах, конная прогулка, гостеприимство, осетинская музыка, фольклор, Дигорское ущелье, город мёртвых, горячие источники, Цейское ущелье
ВСЕ СВОИ ТРЕВЕЛ
СЕЗОН
Группа
Впечатления
маЙ - ОКтябрь
до 12 человек
На всю жизнь

п-ов рыбачий

Заполярье, скалы, тундра, Баренцево море, бухты, фьорды, каменистые пляжи, арктическая флора, ягель, северные олени, тюлени, вездеходы, юрты, полярный день, северные ветра, священные места, традиции поморов, история, копчёная рыба, уха, морские деликатесы, костры, тишина
ВСЕ СВОИ ТРЕВЕЛ
СЕЗОН
Группа
Впечатления
июнь - сентябрь
до 4 человек
На всю жизнь

КАМЧАТКА

Дальний Восток, вулканы, гейзеры, озёра, горячие источники, скалы, тундра, тайга, лиственницы, традиции коренных народов, медведи, нерпы, киты, треккинг, сёрфинг, священные места, панорамные виды, чумы, корякская кухня, морепродукты, крабы, красная икра, уха, пикники, восхождение на вулкан, лавовые поля, кристально чистые реки
ВСЕ СВОИ ТРЕВЕЛ
СЕЗОН
Группа
Впечатления
маЙ - ОКтябрь
до 8 человек
На всю жизнь

ЯМАЛ

Ямал влюбляет в себя с первого взгляда. Его суровая и первозданная красота завораживают — здесь хочется остановиться, забыть о повседневных заботах и просто вдохнуть кристально чистый воздух.
ВСЕ СВОИ ТРЕВЕЛ
СЕЗОН
Группа
Впечатления
Февраль
до 6 человек
На всю жизнь

отзывы о турах