/* ============================================================ Виноград. Сезон любви — игровая логика (Vanilla JS) Чистая реализация Match-3 под наше ТЗ. Архитектурные идеи (генерация поля, swap, поиск совпадений, гравитация, рефилл, защита от тупика) — по мотивам публичных туториалов, но код написан с нуля под наши ассеты и правила. ============================================================ */ (() => { 'use strict'; // ---------- конфиг ---------- const N = 6; // поле 6×6 const HEARTS_TO_WIN = 3; const MIN_HEARTS_ON_BOARD = 2; // защита от «плохого рандома» const BOOK_URL = 'https://mtsbooks.onelink.me/zdZb/jvxj79xc'; // iOS не даёт стабильно скачивать dataURL по программному клику const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); const defaultSaveLabel = () => (isIOS ? 'Открыть PNG' : 'Скачать PNG'); const TYPES = { grape: 'assets/tiles/grape.png', heart: 'assets/tiles/heart.png', book: 'assets/tiles/book-season-love.png', wine: 'assets/tiles/glass.png', leaf: 'assets/tiles/grape-leaf.png', apricot: 'assets/tiles/apricot.png', }; const TYPE_KEYS = Object.keys(TYPES); const POSTCARDS = [ 'assets/cards/postcard-morning-book.png', 'assets/cards/postcard-seaside.png', 'assets/cards/postcard-vineyard-walk.png', 'assets/cards/postcard-sunset-dinner.png', 'assets/cards/postcard-starry-night.png', ]; // ---------- состояние ---------- let board = []; // board[r][c] = ключ типа let cells = []; // cells[r][c] = DOM .cell let heartsCollected = 0; let score = 0; let selected = null; // {r,c} let busy = false; // блокировка ввода во время анимаций // ---------- утилиты ---------- const $ = (id) => document.getElementById(id); const wait = (ms) => new Promise((res) => setTimeout(res, ms)); const rndType = () => TYPE_KEYS[(Math.random() * TYPE_KEYS.length) | 0]; const adjacent = (a, b) => Math.abs(a.r - b.r) + Math.abs(a.c - b.c) === 1; // ---------- аналитика: dataLayer + Яндекс.Метрика ---------- const METRIKA_ID = 89251519; // безопасная отправка цели в Метрику — не валит игру, если ym не загрузился function sendMetrikaGoal(goalName, params = {}) { try { if (typeof window.ym === 'function') { window.ym(METRIKA_ID, 'reachGoal', goalName, params); } } catch (e) { if (window.console) console.warn('[metrika] goal failed', goalName, e); } } // единая точка аналитики: пишет в dataLayer, шлёт цель в Метрику и логирует function track(event, params = {}) { window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event, ...params }); sendMetrikaGoal(event, params); if (window.console) console.log('[analytics]', event, params); } // ============================================================ // ПОСТРОЕНИЕ И РЕНДЕР ПОЛЯ // ============================================================ function buildGrid() { const boardEl = $('board'); boardEl.innerHTML = ''; cells = []; for (let r = 0; r < N; r++) { cells[r] = []; for (let c = 0; c < N; c++) { const cell = document.createElement('div'); cell.className = 'cell'; cell.setAttribute('role', 'gridcell'); cell.dataset.r = r; cell.dataset.c = c; const img = document.createElement('img'); img.alt = ''; cell.appendChild(img); boardEl.appendChild(cell); cells[r][c] = cell; } } attachInput(boardEl); } function render() { for (let r = 0; r < N; r++) { for (let c = 0; c < N; c++) { const t = board[r][c]; const img = cells[r][c].firstChild; if (t) { if (img.dataset.type !== t) { img.src = TYPES[t]; img.dataset.type = t; } img.style.visibility = 'visible'; } else { img.style.visibility = 'hidden'; } } } } // создать поле без стартовых совпадений, с ≥2 сердцами и доступным ходом function createBoard() { do { board = []; for (let r = 0; r < N; r++) { board[r] = []; for (let c = 0; c < N; c++) { let t; do { t = rndType(); } while (makesRunAt(r, c, t)); board[r][c] = t; } } ensureMinHearts(); } while (!hasPossibleMove()); } // создаст ли тип t совпадение 3+ с уже заполненными слева/сверху function makesRunAt(r, c, t) { if (c >= 2 && board[r][c - 1] === t && board[r][c - 2] === t) return true; if (r >= 2 && board[r - 1][c] === t && board[r - 2][c] === t) return true; return false; } // ============================================================ // ПОИСК СОВПАДЕНИЙ // ============================================================ // вернёт массив «прогонов»: { type, cells:[[r,c],...] } длиной ≥3 function findRuns() { const runs = []; // горизонтальные for (let r = 0; r < N; r++) { let run = [[r, 0]]; for (let c = 1; c < N; c++) { if (board[r][c] && board[r][c] === board[r][c - 1]) run.push([r, c]); else { if (run.length >= 3) runs.push({ type: board[r][c - 1], cells: run }); run = [[r, c]]; } } if (run.length >= 3) runs.push({ type: board[r][N - 1], cells: run }); } // вертикальные for (let c = 0; c < N; c++) { let run = [[0, c]]; for (let r = 1; r < N; r++) { if (board[r][c] && board[r][c] === board[r - 1][c]) run.push([r, c]); else { if (run.length >= 3) runs.push({ type: board[r - 1][c], cells: run }); run = [[r, c]]; } } if (run.length >= 3) runs.push({ type: board[N - 1][c], cells: run }); } return runs; } // есть ли хоть один результативный ход function hasPossibleMove() { const swapAndTest = (r1, c1, r2, c2) => { [board[r1][c1], board[r2][c2]] = [board[r2][c2], board[r1][c1]]; const ok = findRuns().length > 0; [board[r1][c1], board[r2][c2]] = [board[r2][c2], board[r1][c1]]; return ok; }; for (let r = 0; r < N; r++) { for (let c = 0; c < N; c++) { if (c < N - 1 && swapAndTest(r, c, r, c + 1)) return true; if (r < N - 1 && swapAndTest(r, c, r + 1, c)) return true; } } return false; } // ============================================================ // ГРАВИТАЦИЯ, РЕФИЛЛ, СЕРДЦА // ============================================================ function applyGravity() { const fresh = []; for (let c = 0; c < N; c++) { let write = N - 1; for (let r = N - 1; r >= 0; r--) { if (board[r][c]) { board[write][c] = board[r][c]; if (write !== r) board[r][c] = null; write--; } } for (let r = write; r >= 0; r--) { board[r][c] = rndType(); fresh.push([r, c]); } } return fresh; } // гарантировать минимум сердец на поле, не создавая мгновенного матча function ensureMinHearts() { const count = () => board.flat().filter((t) => t === 'heart').length; let guard = 40; while (count() < MIN_HEARTS_ON_BOARD && guard-- > 0) { const r = (Math.random() * N) | 0, c = (Math.random() * N) | 0; if (board[r][c] === 'heart') continue; const prev = board[r][c]; board[r][c] = 'heart'; // не допускаем, чтобы инъекция сердца сама собрала ряд if (runAroundIsThree(r, c)) board[r][c] = prev; } } function runAroundIsThree(r, c) { const t = board[r][c]; let h = 1; for (let i = c - 1; i >= 0 && board[r][i] === t; i--) h++; for (let i = c + 1; i < N && board[r][i] === t; i++) h++; let v = 1; for (let i = r - 1; i >= 0 && board[i][c] === t; i--) v++; for (let i = r + 1; i < N && board[i][c] === t; i++) v++; return h >= 3 || v >= 3; } function reshuffle() { const flat = board.flat(); for (let i = flat.length - 1; i > 0; i--) { const j = (Math.random() * (i + 1)) | 0; [flat[i], flat[j]] = [flat[j], flat[i]]; } let k = 0; for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) board[r][c] = flat[k++]; ensureMinHearts(); if (findRuns().length || !hasPossibleMove()) reshuffle(); } // ============================================================ // АНИМАЦИИ // ============================================================ function popCells(matched) { matched.forEach((k) => { const [r, c] = k.split(',').map(Number); cells[r][c].classList.add('pop'); }); return wait(220).then(() => { matched.forEach((k) => { const [r, c] = k.split(',').map(Number); cells[r][c].classList.remove('pop'); }); }); } function flyHearts(heartCoords, n) { const slots = document.querySelectorAll('.heart-slot'); const target = slots[Math.min(heartsCollected, HEARTS_TO_WIN - 1)]; const trect = target.getBoundingClientRect(); const made = Math.min(n, heartCoords.length); for (let i = 0; i < made; i++) { const [r, c] = heartCoords[i]; const src = cells[r][c].getBoundingClientRect(); const fly = document.createElement('div'); fly.className = 'fly-heart'; fly.style.left = src.left + src.width / 2 - 17 + 'px'; fly.style.top = src.top + src.height / 2 - 17 + 'px'; document.body.appendChild(fly); // запустить анимацию в следующий кадр requestAnimationFrame(() => requestAnimationFrame(() => { const dx = trect.left + trect.width / 2 - (src.left + src.width / 2); const dy = trect.top + trect.height / 2 - (src.top + src.height / 2); fly.style.transform = `translate(${dx}px,${dy}px) scale(.7)`; fly.style.opacity = '0.2'; })); setTimeout(() => fly.remove(), 700); } } function showToast(msg) { const t = $('game-toast'); t.textContent = msg; t.classList.add('show'); clearTimeout(showToast._t); showToast._t = setTimeout(() => t.classList.remove('show'), 1100); } function updateHeartUI() { document.querySelectorAll('.heart-slot').forEach((s, i) => { s.classList.toggle('filled', i < heartsCollected); }); } // ============================================================ // ОСНОВНОЙ ЦИКЛ РАЗРЕШЕНИЯ // ============================================================ async function resolve() { let chain = 0; while (true) { const runs = findRuns(); if (runs.length === 0) break; const matched = new Set(); const heartCoords = []; let heartRuns = 0; runs.forEach((run) => { if (run.type === 'heart') { heartRuns++; heartCoords.push(run.cells[(run.cells.length / 2) | 0]); // центр прогона } run.cells.forEach(([r, c]) => matched.add(r + ',' + c)); }); chain++; score += matched.size * 10 * chain; $('score').textContent = score; await popCells(matched); if (heartRuns > 0) { const remaining = HEARTS_TO_WIN - heartsCollected; const gained = Math.min(heartRuns, remaining); flyHearts(heartCoords, gained); await wait(320); const prevHearts = heartsCollected; heartsCollected += gained; updateHeartUI(); // аналитика: цель на каждое впервые собранное сердце (1/2/3), без дублей for (let h = prevHearts + 1; h <= heartsCollected && h <= HEARTS_TO_WIN; h++) { track('season_love_heart_' + h); } // тосты — прежнее поведение if (heartsCollected === 1) showToast('Первое сердце найдено'); else if (heartsCollected === 2) showToast('Ещё немного…'); else if (heartsCollected >= HEARTS_TO_WIN) showToast('Ваша страница готова'); } // удалить совпавшие matched.forEach((k) => { const [r, c] = k.split(',').map(Number); board[r][c] = null; }); // победа? if (heartsCollected >= HEARTS_TO_WIN) { render(); await wait(400); return finishGame(); } // гравитация + рефилл const fresh = applyGravity(); ensureMinHearts(); render(); fresh.forEach(([r, c]) => { const cell = cells[r][c]; cell.classList.add('drop'); setTimeout(() => cell.classList.remove('drop'), 240); }); await wait(180); } if (!hasPossibleMove()) { reshuffle(); render(); showToast('Поле перемешано'); } } // ============================================================ // ВВОД: тап-тап и свайп // ============================================================ function attachInput(boardEl) { let start = null; // {r,c,x,y} const cellFromEvent = (e) => { const el = (e.target.closest && e.target.closest('.cell')); if (!el) return null; return { r: +el.dataset.r, c: +el.dataset.c }; }; boardEl.addEventListener('pointerdown', (e) => { if (busy) return; const cell = cellFromEvent(e); if (!cell) return; start = { ...cell, x: e.clientX, y: e.clientY }; }); boardEl.addEventListener('pointerup', (e) => { if (busy || !start) { start = null; return; } const dx = e.clientX - start.x, dy = e.clientY - start.y; const dist = Math.hypot(dx, dy); const THRESH = 18; if (dist > THRESH) { // свайп → меняем со соседом в доминирующем направлении let nr = start.r, nc = start.c; if (Math.abs(dx) > Math.abs(dy)) nc += dx > 0 ? 1 : -1; else nr += dy > 0 ? 1 : -1; if (nr >= 0 && nr < N && nc >= 0 && nc < N) { clearSelection(); trySwap(start, { r: nr, c: nc }); } } else { // тап const cell = { r: start.r, c: start.c }; if (!selected) { setSelection(cell); } else if (selected.r === cell.r && selected.c === cell.c) { clearSelection(); } else if (adjacent(selected, cell)) { const a = selected; clearSelection(); trySwap(a, cell); } else { setSelection(cell); } } start = null; }); boardEl.addEventListener('pointercancel', () => { start = null; }); } function setSelection(cell) { clearSelection(); selected = cell; cells[cell.r][cell.c].classList.add('sel'); } function clearSelection() { if (selected) cells[selected.r][selected.c].classList.remove('sel'); selected = null; } async function trySwap(a, b) { if (busy) return; busy = true; swapCells(a, b); render(); if (findRuns().length === 0) { // нет матча — вернуть назад await wait(180); swapCells(a, b); render(); } else { await resolve(); } busy = false; } function swapCells(a, b) { [board[a.r][a.c], board[b.r][b.c]] = [board[b.r][b.c], board[a.r][a.c]]; } // ============================================================ // ЭКРАНЫ // ============================================================ function showScreen(id) { document.querySelectorAll('.screen').forEach((s) => s.classList.remove('is-active')); $(id).classList.add('is-active'); window.scrollTo(0, 0); } function startGame() { heartsCollected = 0; score = 0; selected = null; busy = false; $('score').textContent = '0'; updateHeartUI(); createBoard(); render(); showScreen('screen-game'); track('season_love_start'); } function finishGame() { track('season_love_complete'); renderResult(); showScreen('screen-result'); } // ============================================================ // РЕЗУЛЬТАТ // ============================================================ function renderResult() { const list = window.PREDICTIONS || []; const pred = list[(Math.random() * list.length) | 0] || { pageNumber: 17, text: '' }; const postcard = POSTCARDS[(Math.random() * POSTCARDS.length) | 0]; $('card-page').textContent = `Страница ${pred.pageNumber} из ${list.length || 55}`; $('card-prediction').textContent = pred.text; $('card-postcard').src = postcard; $('pages-left').textContent = (list.length || 55) - 1; // аналитика: предсказание и открытка уже выбраны track('season_love_prediction_open', { pageNumber: pred.pageNumber, predictionId: pred.id, postcard: postcard, }); // обновляем URL под расшаривание конкретной страницы (не ломает локальный запуск) try { const u = new URL(window.location.href); u.searchParams.set('p', pred.pageNumber); history.replaceState(null, '', u); } catch (_) {} } // ============================================================ // ДЕЙСТВИЯ НА ЭКРАНЕ РЕЗУЛЬТАТА // ============================================================ function initActions() { $('btn-read').addEventListener('click', () => { track('season_love_read_click'); window.open(BOOK_URL, '_blank', 'noopener'); }); $('btn-save').addEventListener('click', savePNG); $('btn-save').textContent = defaultSaveLabel(); $('btn-share').addEventListener('click', async () => { track('season_love_share_click'); const b = $('btn-share'); // ссылка на СТАРТ игры, без параметра выбранного предсказания const shareUrl = new URL(window.location.origin + window.location.pathname); shareUrl.searchParams.set('utm_source', 'game'); shareUrl.searchParams.set('utm_medium', 'share'); shareUrl.searchParams.set('utm_campaign', 'season_love_2026'); shareUrl.searchParams.set('utm_content', 'prediction'); const SHARE_URL = shareUrl.toString(); const SHARE_TITLE = 'Виноград. Сезон любви'; const SHARE_TEXT = 'Собери 3 сердца и открой своё летнее предсказание 🍇'; const restoreLabel = () => setTimeout(() => { b.textContent = 'Поделиться ссылкой'; b.classList.remove('is-done'); }, 1800); const feedback = (msg) => { b.textContent = msg; b.classList.add('is-done'); restoreLabel(); }; // 1) Web Share API (нативный лист на мобильных) if (navigator.share) { try { await navigator.share({ title: SHARE_TITLE, text: SHARE_TEXT, url: SHARE_URL }); track('season_love_share_success', { method: 'web_share' }); return; // системный UI уже дал понятный фидбек } catch (err) { // пользователь закрыл лист — молча выходим (это не ошибка) if (err && (err.name === 'AbortError' || err.name === 'NotAllowedError')) return; // иначе — падаем в копирование ссылки } } // 2) Clipboard API let ok = false; let method = null; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(SHARE_URL); ok = true; method = 'clipboard'; } } catch (_) { /* перейдём к fallback */ } // 3) Fallback: textarea + execCommand if (!ok) { ok = legacyCopy(SHARE_URL); if (ok) method = 'fallback'; } if (ok) track('season_love_share_success', { method }); else track('season_love_share_error'); feedback(ok ? 'Ссылка скопирована' : 'Скопируйте ссылку вручную'); }); $('btn-again').addEventListener('click', () => { track('season_love_play_again'); startGame(); }); } function legacyCopy(text) { try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.focus(); ta.select(); const ok = document.execCommand('copy'); ta.remove(); return ok; } catch (_) { return false; } } // дождаться, что реально загружен (или сломан — тогда не блокируем) function imgReady(img) { if (img.complete && img.naturalWidth > 0) return Promise.resolve(); return new Promise((res) => { img.addEventListener('load', res, { once: true }); img.addEventListener('error', res, { once: true }); }); } // карточка полностью готова к захвату: шрифты + все картинки + кадр на отрисовку async function waitForCardReady(node) { if (document.fonts && document.fonts.ready) { try { await document.fonts.ready; } catch (_) {} } const imgs = Array.from(node.querySelectorAll('img')); await Promise.all(imgs.map(imgReady)); // декодируем, чтобы пиксели были готовы к растеризации await Promise.all(imgs.map((i) => (i.decode ? i.decode().catch(() => {}) : Promise.resolve()))); // два кадра — дать браузеру завершить layout/paint await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); } // Модалка-превью для iOS, когда всплывающая вкладка заблокирована. // Строится на лету (инлайн-стили), вёрстку экранов не трогает. function showIOSPreview(dataUrl) { const ov = document.createElement('div'); ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(41,90,68,.55);' + 'backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);display:flex;' + 'align-items:center;justify-content:center;padding:20px;overflow:auto;'; const box = document.createElement('div'); box.style.cssText = 'max-width:420px;width:100%;background:#F7F3E8;border-radius:20px;' + 'padding:16px;box-shadow:0 18px 50px rgba(0,0,0,.35);text-align:center;'; const img = document.createElement('img'); img.src = dataUrl; img.alt = 'Открытка'; img.style.cssText = 'width:100%;height:auto;border-radius:12px;display:block;'; const hint = document.createElement('p'); hint.textContent = 'Чтобы сохранить открытку, зажмите изображение и выберите «Сохранить в Фото».'; hint.style.cssText = 'margin:14px 4px 4px;font:600 14px/1.4 Manrope,system-ui,sans-serif;color:#295A44;'; const close = document.createElement('button'); close.type = 'button'; close.textContent = 'Закрыть'; close.style.cssText = 'margin-top:14px;width:100%;min-height:48px;border:none;border-radius:16px;' + 'background:#5B9E45;color:#fff;font:700 16px Manrope,system-ui,sans-serif;cursor:pointer;'; const done = () => ov.remove(); close.addEventListener('click', done); ov.addEventListener('click', (e) => { if (e.target === ov) done(); }); box.appendChild(img); box.appendChild(hint); box.appendChild(close); ov.appendChild(box); document.body.appendChild(ov); } async function savePNG() { track('season_love_png_click'); const node = $('result-card'); const btn = $('btn-save'); if (!window.htmlToImage) { track('season_love_png_error'); btn.textContent = 'Не удалось сохранить'; setTimeout(() => (btn.textContent = defaultSaveLabel()), 1800); return; } // iOS: вкладку нужно открыть СИНХРОННО, внутри пользовательского жеста, // иначе после await popup будет заблокирован. Заполним её после генерации. let iosWin = null; if (isIOS) { try { iosWin = window.open('', '_blank'); } catch (_) {} } btn.textContent = 'Готовим PNG…'; try { await waitForCardReady(node); const opts = { pixelRatio: 2, backgroundColor: '#F7F3E8', // cacheBust НЕ используем: он форсирует повторную загрузку и роняет фон/postcard }; // Прогревочный проход: html-to-image на первом вызове не всегда успевает // вшить только что загруженные изображения (фон карточки и postcard). // Первый toPng наполняет внутренний кэш ресурсов, второй — отдаёт готовый PNG. await window.htmlToImage.toPng(node, opts); const dataUrl = await window.htmlToImage.toPng(node, opts); if (isIOS) { // Показываем PNG (скачивание dataURL по на iOS ненадёжно) let shown = false; if (iosWin && !iosWin.closed) { try { iosWin.document.open(); iosWin.document.write( '' + '' + 'Открытка — Виноград. Сезон любви' + '' + 'Открытка' + '

' + 'Чтобы сохранить открытку, зажмите изображение и выберите «Сохранить в Фото».

' + '' ); iosWin.document.close(); shown = true; } catch (_) { shown = false; } } // popup заблокирован → модальное окно с картинкой и инструкцией if (!shown) { if (iosWin) { try { iosWin.close(); } catch (_) {} } showIOSPreview(dataUrl); } track('season_love_png_success_ios_preview'); btn.textContent = 'Открытка готова'; } else { // desktop-flow без изменений:
const a = document.createElement('a'); a.download = 'vinograd-stranica-leta.png'; a.href = dataUrl; a.click(); track('season_love_png_success_desktop'); btn.textContent = 'Сохранено'; } } catch (err) { console.error(err); if (isIOS && iosWin) { try { iosWin.close(); } catch (_) {} } track('season_love_png_error'); btn.textContent = 'Не удалось сохранить'; } setTimeout(() => (btn.textContent = defaultSaveLabel()), 1800); } // ============================================================ // СТАРТ // ============================================================ function init() { track('season_love_open'); buildGrid(); initActions(); // старт по кнопке или по тапу в любом месте стартового экрана $('screen-start').addEventListener('click', () => startGame()); } document.addEventListener('DOMContentLoaded', init); })();