Научим готовое поле сравнивать две карточки. Совпавшая пара останется открытой, несовпавшая закроется после короткой задержки. Пока игрок запоминает две картинки, третью открыть нельзя. При начале новой партии отменим старый таймер, чтобы он не закрывал карточки в новой игре.
Продолжаем работу с карточками
start точно совпадает с финалом урока про циклы и карточки. Нужны объекты, массивы, условия, функции, обработчики и отмена setTimeout из урока о таймерах. Скопируй три файла старта в отдельную рабочую папку и открой index.html. Сейчас можно открывать все шесть карточек; сравнения ещё нет. Работает общий сброс.
Сначала будем сразу закрывать несовпавшие карточки. Затем добавим задержку, запретим третий выбор и перемешаем поле. Результат каждого законченного этапа лежит в отдельной папке. Не смешивай файлы разных версий.
Что считается попыткой
Попытка — выбор двух разных ещё не найденных карточек. Счётчик увеличится только после выбора второй карточки. Сравниваем value, а не id: у пары одинаковое содержимое, но два отдельных объекта. У найденной карточки появится matched: true. Это другое состояние, чем временное открытие.
В game.js замени фабрику: добавляем matched каждому объекту. Все значения встречаются ровно два раза:
function createInitialCards() {
return [
{ id: "sun-1", value: "Солнце", open: false, matched: false },
{ id: "sun-2", value: "Солнце", open: false, matched: false },
{ id: "moon-1", value: "Луна", open: false, matched: false },
{ id: "moon-2", value: "Луна", open: false, matched: false },
{ id: "star-1", value: "Звезда", open: false, matched: false },
{ id: "star-2", value: "Звезда", open: false, matched: false }
];
}
Вместо прежней строки let cards = createInitialCards(); добавь блок состояния:
let cards = createInitialCards();
let moves = 0;
let matchedCount = 0;
let firstCard = null;
let secondCard = null;
let gameState = "playing";
null означает, что карточка ещё не выбрана. Когда запишем объект в firstCard, это будет ссылка на настоящий элемент массива. Изменение его свойства изменит и данные поля. matchedCount считает найденные карточки, поэтому за пару прибавляется 2. Строка gameState отделяет идущую партию от завершённой.
Покажем попытки и найденные карточки
В index.html замени заголовок на «Найди три пары», а пояснение — на «Открывай по две карточки. Найди одинаковые значения за меньшее число попыток». Перед #card-field вставь:
<p>Попыток: <span id="moves">0</span>.
Найдено карточек: <span id="matched-count">0</span>
из <span id="card-total">6</span>.</p>
Кнопку #reset-button подпиши «Новая партия». В game.js рядом с остальными переменными элементов добавь:
const movesElement = document.querySelector("#moves");
const matchedElement = document.querySelector("#matched-count");
const totalElement = document.querySelector("#card-total");
buildField пока оставь прежней: она уже создаёт кнопки по массиву и передаёт нужный объект в openCard. Компактное размещение карточек на узком экране уже есть в style.css стартовой версии из предыдущего урока: две кнопки помещаются рядом. Здесь оформление менять не требуется.
Сравниваем выбранные карточки
Замени openCard целиком:
function openCard(card) {
if (gameState !== "playing" || card.open || card.matched) {
return;
}
card.open = true;
if (firstCard === null) {
firstCard = card;
render();
return;
}
secondCard = card;
moves = moves + 1;
if (firstCard.value === secondCard.value) {
firstCard.matched = true;
secondCard.matched = true;
matchedCount = matchedCount + 2;
firstCard = null;
secondCard = null;
if (matchedCount === cards.length) {
finishGame();
}
} else {
firstCard.open = false;
secondCard.open = false;
firstCard = null;
secondCard = null;
}
render();
}
Первая проверка — правило самой игры: нельзя играть после конца, выбирать открытую карточку или найденную пару. Поэтому двойной клик по одной карточке не становится парой. После первого выбора вызываем render и выходим. После второго увеличиваем число попыток и сравниваем значения.
При совпадении обе карточки получают matched: true. Ссылки выбора возвращаются в null, но сами найденные объекты остаются в массиве. При несовпадении эта промежуточная версия закрывает обе сразу. Игрок не успевает увидеть вторую карточку; задержку добавим отдельным этапом.
После openCard добавь:
function finishGame() {
gameState = "finished";
}
Завершение вызывается только когда найдены все карточки. Отдельная функция позже позволит сохранить результат. Пока она только меняет состояние партии.
Показываем найденные пары и победу
Замени render на эту версию:
function render() {
let position = 1;
for (const card of cards) {
const button = document.getElementById("card-" + card.id);
if (card.matched) {
button.textContent = card.value + " — пара найдена";
} else if (card.open) {
button.textContent = card.value;
} else {
button.textContent = "Карточка " + position;
}
button.disabled = card.open || card.matched || gameState === "finished";
position = position + 1;
}
movesElement.textContent = moves;
matchedElement.textContent = matchedCount;
totalElement.textContent = cards.length;
if (gameState === "finished") {
statusElement.textContent = "Все пары найдены! Попыток: " + moves + ".";
} else if (firstCard !== null) {
statusElement.textContent = "Выбери вторую карточку.";
} else {
statusElement.textContent = "Открой первую карточку.";
}
}
Закрытая кнопка теперь показывает только номер места, например «Карточка 3». Старые подписи с sun-1 выдали бы содержимое игроку. Устойчивый id остаётся в данных и атрибутах элемента, а номер — лишь подсказка для чтения поля. У найденных карточек явно написано «пара найдена»: цвет не является единственным признаком.
После неё замени resetGame; три строки запуска внизу остаются такими:
function resetGame() {
cards = createInitialCards();
moves = 0;
matchedCount = 0;
firstCard = null;
secondCard = null;
gameState = "playing";
buildField();
render();
}
resetButton.addEventListener("click", resetGame);
buildField();
render();
Проверь сохранённые файлы. В фиксированном порядке первые две карточки — пара, первая и третья — разные. Найденная пара остаётся открытой, разные карточки сразу закрываются, счётчик попыток увеличивается на один. Найди три пары: появится сообщение о победе. Начни новую партию: найдено 0, попыток 0. Это step-01. Паузы перед закрытием разных карточек пока нет.
Закрываем разные карточки после паузы
Теперь добавим ожидание. Рядом с firstCard и secondCard объяви:
let locked = false;
let mismatchTimer = null;
locked отмечает короткий период, пока игрок видит две несовпавшие карточки. Таймер не останавливает остальной JavaScript, поэтому без этого запрета игрок успеет заменить выбор третьим кликом. В начале openCard замени условие на:
if (gameState !== "playing" || locked || card.open || card.matched) {
return;
}
В этой же функции, в ветке else после сравнения value, удали четыре строки мгновенного закрытия. Вставь две:
locked = true;
mismatchTimer = setTimeout(closeMismatch, 1000);
Мы передаём имя функции, без (): выполнить её нужно позже. Вызов вернёт идентификатор таймера, который потребуется при сбросе. После openCard добавь:
function closeMismatch() {
mismatchTimer = null;
firstCard.open = false;
secondCard.open = false;
firstCard = null;
secondCard = null;
locked = false;
render();
}
Пока ждём, ссылки на две карточки сохраняются. При срабатывании закрываем именно их, очищаем выбор и снимаем блокировку. Указанные 1000 миллисекунд — минимальная запрошенная задержка, а не гарантия точного времени. Если вкладка скрыта, браузер может вызвать функцию позже. В этой игре нет таймера на победу: пара просто закроется при выполнении callback, после этого можно будет продолжить игру.
В render добавь locked к запрету кнопок:
button.disabled = locked || card.open || card.matched || gameState === "finished";
В конце render замени весь блок выбора сообщения, начинающийся с if (gameState === "finished"), на такой. Новая ветка ожидания стоит перед проверкой первой карточки:
if (gameState === "finished") {
statusElement.textContent = "Все пары найдены! Попыток: " + moves + ".";
} else if (locked) {
statusElement.textContent = "Не совпали. Запомни карточки, затем попробуй снова.";
} else if (firstCard !== null) {
statusElement.textContent = "Выбери вторую карточку.";
} else {
statusElement.textContent = "Открой первую карточку.";
}
Защита есть и в интерфейсе, и в openCard. Серый вид кнопки сам по себе не является игровым правилом.
Отменяем таймер при новой партии
Замени resetGame целиком. Сначала отменяется незавершённый callback, затем создаются новые данные:
function resetGame() {
if (mismatchTimer !== null) {
clearTimeout(mismatchTimer);
mismatchTimer = null;
}
locked = false;
cards = createInitialCards();
moves = 0;
matchedCount = 0;
firstCard = null;
secondCard = null;
gameState = "playing";
buildField();
render();
}
Если оставить старый таймер, он позже прочитает уже сброшенные firstCard/secondCard и либо изменит уже новую партию, либо получит ошибку при обращении к null. Поэтому при сбросе сначала отменяем таймер, а уже потом меняем данные. Обработчик кнопки «Новая партия» по-прежнему подключён один раз.
Открой разные значения: обе карточки видны около секунды, остальные кнопки временно недоступны. Попробуй нажать третью; попыток остаётся 1. После закрытия можно выбирать снова. Повтори несовпадение, сразу нажми «Новая партия», открой одну карточку и подожди: она не должна сама закрыться. Это step-02.
Перемешивание добавим последним
Правила уже работают на фиксированном наборе. Теперь изменим порядок объектов перед построением поля. Добавь перед buildField функцию:
function shuffleCards(deck) {
for (let index = deck.length - 1; index > 0; index = index - 1) {
const randomIndex = Math.floor(Math.random() * (index + 1));
const temporary = deck[index];
deck[index] = deck[randomIndex];
deck[randomIndex] = temporary;
}
}
Это перемешивание Фишера — Йетса. Идём с последнего индекса к первому. На каждом шаге выбираем позицию от 0 до текущего индекса включительно и меняем два элемента местами. index + 1 задаёт число доступных позиций. После обмена последняя из них больше не участвует в следующем шаге. Переменная temporary сохраняет старую ссылку, чтобы второе присваивание не потеряло её.
Функция меняет переданный массив. Сами объекты, их id и значения остаются прежними — меняется только порядок в массиве. Иногда случайный порядок совпадёт с прежним; требовать обязательного отличия от каждой предыдущей партии не нужно.
Сразу после верхней строки let cards = createInitialCards(); добавь shuffleCards(cards);. В resetGame добавь такой же вызов после cards = createInitialCards(); и до построения поля. Перемешивать внутри render нельзя: положения менялись бы после каждого клика. После обновления страницы и новой партии порядок может отличаться. Это final.
Девять проверок перед своей правкой
Для проверки совпадений удобно сначала использовать step-02 с известным порядком, затем повторить сценарии в финале. Запиши ожидание и фактический результат; победа один раз не проверяет все правила.
| Действие | Ожидаемое поведение |
|---|---|
| 1. Свежий старт, открыть одну карточку | Открыта одна; попыток 0, найдено 0 |
| 2. Ещё раз нажать эту же карточку | Второй выбор не появляется, попыток по-прежнему 0 |
| 3. Открыть подходящую вторую | Попыток 1, найдено 2, обе помечены найденными |
| 4. Нажать найденную карточку | Состояние и счёт не меняются |
| 5. Открыть разные значения | Попытка засчитана один раз; две видны до закрытия, найдено не растёт |
| 6. Нажать третью во время ожидания | Она не открывается, выбранная пара не заменяется |
| 7. Во время ожидания начать новую партию | Все карточки снова закрыты; старый callback не закрывает первую карточку новой партии |
| 8. Найти все пары и попробовать продолжить | Есть завершение; дальнейшие клики не меняют число попыток |
| 9. Несколько раз начать заново | Каждый раз ровно 6 закрытых карточек, нулевые счётчики и один результат на действие |
Также пройди игру клавишами Tab и Enter и в узком окне. Если ждёшь закрытия в скрытой вкладке, не оценивай точность секунды: проверь отсутствие ошибки и возможность продолжить.
Самостоятельно: четыре пары из своих данных
Собери другой набор из восьми карточек. У четырёх значений должно быть по два отдельных объекта; все восемь id различаются. Измени только фабрику и понятный заголовок страницы. Подсчёт найденных карточек, конец и перемешивание должны подстроиться под длину массива. Повтори девять проверок, заменив в последней 6 на 8.
До кода объясни себе: почему один первый выбор не увеличивает moves? Почему два объекта с равным value не могут иметь один общий id? Почему сумма найденных растёт на 2? Ответы должны совпадать с наблюдением в твоей версии.
Подсказка 1: данные и длина
Ищи массив в createInitialCards. Добавление пары не требует нового обработчика или восьмой вручную написанной кнопки. Условие конца уже использует cards.length.
Подсказка 2: проверь сам набор
Напиши на бумаге восемь строк: id, value, open=false, matched=false. Убедись, что каждое значение встречается дважды. Если у одного значения нет второй карточки, такую партию нельзя закончить.
Решение после собственной попытки
Пример на четыре пары лежит в solutions/four-pairs. Сверь структуру, но придумай свои значения. После сверки восстанови проверку повторного клика без копирования всей функции.
Проверь готовую игру
- Ты объясняешь каждое состояние карточки, зачем нужны ссылки на две выбранные карточки, блокировка и идентификатор таймера.
- Попытка считается при втором допустимом выборе; найденные пары больше не участвуют.
- Во время задержки нельзя заменить пару третьей карточкой.
- Новая партия отменяет ожидание и полностью сбрасывает данные.
- Игра с твоим набором из четырёх пар заканчивается победой и проходит все девять проверок выше.
Сохрани рабочую версию в Git. Ты уже отделил данные, действие, отображение, завершение и сброс — это пригодится, когда игра начнёт сохранять результат между запусками.
Материалы урока
Справка MDN: отложенный вызов и фактическая задержка, отмена ожидания, случайное число и границы диапазона.