JS / DOM и события
Игра «Найди пару» на JavaScript: пошаговая разработка без библиотек
Создаём доступную игру на совпадение карточек: HTML-каркас, адаптивный CSS, перемешивание, состояния, счётчик ходов и перезапуск.
Содержание статьи
В игре на поле лежат закрытые карточки. Пользователь открывает две: совпавшая пара остаётся открытой, несовпавшая закрывается. На этом проекте удобно потренировать массивы, DOM, события и управление состоянием.
Соберём версию из трёх файлов. Карточки будут настоящими кнопками, игра — доступной с клавиатуры, а алгоритм перемешивания — равномерным Fisher–Yates.
Структура проекта
memory-game/├── index.html├── styles.css└── game.jsОткрывайте страницу через локальный HTTP-сервер, потому что JavaScript подключается как ES-модуль.
HTML-каркас
<!doctype html><html lang="ru"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Игра «Найди пару»</title> <link rel="stylesheet" href="styles.css"></head><body> <main class="game"> <h1 class="game__title">Найди пару</h1> <p class="game__rules">Открывайте по две карточки и запоминайте символы.</p> <div class="game__toolbar"> <p>Ходы: <output id="moves">0</output></p> <button id="restart" type="button">Начать заново</button> </div> <div class="game__board" id="board" aria-label="Поле игры" ></div> <p class="game__status" id="status" aria-live="polite"></p> </main> <script type="module" src="game.js"></script></body></html>output показывает счётчик, aria-live="polite" сообщает о завершении без резкого перехвата речи. Само поле не получает роль grid: набор обычных кнопок уже имеет понятную клавиатурную модель.
Адаптивные стили
*,*::before,*::after { box-sizing: border-box;}body { margin: 0; font-family: system-ui, sans-serif; line-height: 1.5; color: #f8f7ff; background-color: #171329;}.game { inline-size: min(100% - 2rem, 42rem); margin-inline: auto; padding-block: 2rem;}.game__title,.game__rules,.game__status { text-align: center;}.game__toolbar { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; justify-content: space-between; margin-block: 2rem 1rem;}.game__toolbar p { margin: 0;}.game__toolbar button { min-block-size: 2.75rem; padding-inline: 1rem; font: inherit;}.game__board { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: clamp(0.5rem, 2vw, 1rem);}.card { display: grid; place-items: center; aspect-ratio: 1; padding: 0; font: inherit; font-size: clamp(1.5rem, 8vw, 3rem); color: #ffffff; background-color: #5b21b6; border: 2px solid #8b5cf6; border-radius: 0.75rem; cursor: pointer; transition: background-color 160ms ease, transform 160ms ease;}.card:hover { background-color: #6d28d9;}.card:focus-visible { outline: 3px solid #fbbf24; outline-offset: 3px;}.card[data-state="open"],.card[data-state="matched"] { color: #24163f; background-color: #ede9fe; transform: rotateY(180deg);}.card[data-state="matched"] { border-color: #34d399; cursor: default;}@media (prefers-reduced-motion: reduce) { .card { transition: none; } .card[data-state="open"], .card[data-state="matched"] { transform: none; }}aspect-ratio создаёт квадратные карточки без фиксированной высоты. На очень узком экране четыре колонки остаются компактными; для большего набора можно менять число колонок медиазапросом.
Данные карточек
const symbols = [ { value: '🍎', name: 'яблоко' }, { value: '🚀', name: 'ракета' }, { value: '🎸', name: 'гитара' }, { value: '🌿', name: 'лист' }, { value: '⚽', name: 'мяч' }, { value: '☕', name: 'чашка кофе' }, { value: '💡', name: 'лампа' }, { value: '🐋', name: 'кит' },];У каждого символа есть текстовое название для доступного имени кнопки.
Перемешивание Fisher–Yates
Сортировка через array.sort(() => Math.random() - 0.5) не даёт равномерного распределения. Используем алгоритм Fisher–Yates:
function shuffle(items) { const result = [...items]; for (let index = result.length - 1; index > 0; index -= 1) { const randomIndex = Math.floor(Math.random() * (index + 1)); [result[index], result[randomIndex]] = [result[randomIndex], result[index]]; } return result;}Функция возвращает новый массив и не меняет аргумент.
Создание поля
const board = document.querySelector('#board');const movesOutput = document.querySelector('#moves');const restartButton = document.querySelector('#restart');const status = document.querySelector('#status');if (!board || !movesOutput || !restartButton || !status) { throw new Error('Не найдены обязательные элементы игры');}let openCards = [];let moves = 0;let isLocked = false;let mismatchTimer = null;function createDeck() { return shuffle([...symbols, ...symbols]);}function createCard(symbol, index) { const button = document.createElement('button'); const visibleSymbol = document.createElement('span'); button.type = 'button'; button.className = 'card'; button.dataset.state = 'closed'; button.dataset.symbol = symbol.value; button.setAttribute('aria-label', `Закрытая карточка ${index + 1}`); visibleSymbol.setAttribute('aria-hidden', 'true'); button.append(visibleSymbol); return button;}function renderBoard() { const fragment = document.createDocumentFragment(); createDeck().forEach((symbol, index) => { fragment.append(createCard(symbol, index)); }); board.replaceChildren(fragment);}Символ не записывается через innerHTML. DocumentFragment позволяет добавить поле одной операцией.
Открытие и закрытие
function getSymbolName(value) { return symbols.find((symbol) => symbol.value === value)?.name ?? 'неизвестный символ';}function openCard(card) { card.dataset.state = 'open'; card.firstElementChild.textContent = card.dataset.symbol; card.setAttribute('aria-label', `Открытая карточка: ${getSymbolName(card.dataset.symbol)}`);}function closeCard(card) { card.dataset.state = 'closed'; card.firstElementChild.textContent = ''; card.setAttribute('aria-label', 'Закрытая карточка');}function matchCard(card) { card.dataset.state = 'matched'; card.disabled = true; card.setAttribute('aria-label', `Найденная пара: ${getSymbolName(card.dataset.symbol)}`);}Совпавшая карточка становится disabled и больше не участвует в фокусе. При большом поле можно оставить её фокусируемой и блокировать только повторный выбор, но тогда нужно ясно сообщать состояние.
Проверка пары
function finishMove() { const [first, second] = openCards; const isMatch = first.dataset.symbol === second.dataset.symbol; moves += 1; movesOutput.value = String(moves); if (isMatch) { matchCard(first); matchCard(second); openCards = []; if (board.querySelectorAll('[data-state="matched"]').length === symbols.length * 2) { status.textContent = `Все пары найдены за ${moves} ходов.`; } return; } isLocked = true; mismatchTimer = window.setTimeout(() => { closeCard(first); closeCard(second); openCards = []; isLocked = false; mismatchTimer = null; }, 700);}Блокировка не позволяет открыть третью карточку, пока несовпавшая пара видна.
Обработчик клика и перезапуск
board.addEventListener('click', (event) => { const card = event.target.closest('.card'); if (!card || !board.contains(card) || isLocked || card.dataset.state !== 'closed') { return; } openCard(card); openCards.push(card); if (openCards.length === 2) { finishMove(); }});function startGame() { if (mismatchTimer !== null) { window.clearTimeout(mismatchTimer); mismatchTimer = null; } openCards = []; moves = 0; isLocked = false; movesOutput.value = '0'; status.textContent = ''; renderBoard();}restartButton.addEventListener('click', startGame);startGame();Таймер очищается при перезапуске, поэтому старый callback не изменит новое поле.
Что проверить
- каждая пара встречается ровно два раза;
- нельзя дважды выбрать одну карточку или открыть третью во время ожидания;
- быстрый перезапуск не вызывает ошибок от старого таймера;
- игра полностью управляется Tab, Enter и пробелом;
- фокус хорошо виден;
- счётчик увеличивается один раз на пару;
- reduced motion убирает переворот;
- раскладка не переполняет экран шириной 320 px.
Идеи для развития
Добавьте выбор размера поля, лучший результат в localStorage, таймер с возможностью паузы или наборы символов. Новые функции вводите по одной и сначала определяйте источник состояния: это предотвращает расхождение интерфейса и данных.