Вернёмся к карточкам из урока об объектах. До этого у каждой была своя кнопка и отдельная функция. С двумя карточками это удобно, но набор из восьми заставил бы повторять HTML и JS. Построим поле по массиву: сколько объектов в данных, столько кнопок появится на странице.

Открыть результат

Возвращаемся к знакомым файлам

Нужны массивы, объекты, функции и обработчики. После игры на реакцию возвращаемся к карточкам: start здесь точно совпадает с final урока про независимые карточки. Скопируй эти три файла в новую рабочую папку. Свой набор из прошлого задания сохрани отдельно: позже перенесёшь его данные.

Открой index.html, проверь две карточки и сброс. Наша фабрика createInitialCards уже возвращает новый массив независимых объектов. Оставим её. Поменяем способ создания кнопок и отображения. Файлы запускаются открытием HTML; HTTP-сервер для этой версии пока не нужен.

Один цикл вместо одинаковых команд

Цикл повторяет блок кода, пока выполняется условие. В Console стартовой версии выполни:

for (let index = 0; index < cards.length; index = index + 1) {
  console.log(index, cards[index].id);
}

Первая часть выполняется один раз: создаётся счётчик index со значением 0. Перед каждой итерацией, то есть повторением блока, проверяется index < cards.length. После блока индекс увеличивается на 1. Для двух карточек выводятся 0 и 1; при 2 условие уже ложно. Знак <= здесь был бы ошибкой: элемента с индексом length нет.

Это перебор конечного набора данных, а не анимация и не таймер. Браузер выполняет итерации подряд. Не удаляй увеличение счётчика в таком цикле: если условие навсегда остаётся истинным, вкладка перестаёт отвечать.

Оставляем место для поля

В index.html убери две кнопки только из #card-field. Сам контейнер и кнопку сброса оставь:

<div id="card-field" class="card-field"></div>

В game.js удали объявления firstButton/secondButton, функции handleFirstCard/handleSecondCard и две строки, которые подключали их к кнопкам. Старую render и handleReset сейчас заменим. Сохрани createInitialCards, let cards = createInitialCards();, resetButton и statusElement. Рядом с объявлениями элементов добавь:

const cardField = document.querySelector("#card-field");

Заодно подготовим поле к большему набору на узком экране. В конец style.css добавь знакомое из урока Flexbox правило для маленькой ширины:

@media (max-width: 420px) {
  .card-field button {
    flex-basis: 90px;
    min-height: 96px;
    padding: 10px;
  }
}

Меньшее значение flex-basis позволяет двум карточкам помещаться рядом вместо длинной колонки. Высота и отступы оставляют достаточно места для нажатия. Проверь две карточки в узком окне; при переходе к шести это же правило сохранит компактное поле. Стартовая версия ещё содержит прежнее оформление, а step-01 и финал — новое.

Создаём элементы из данных

Ниже объявлений элементов добавь buildField. Она строит кнопки один раз для текущего набора:

function buildField() {
  cardField.textContent = "";
  for (let index = 0; index < cards.length; index = index + 1) {
    const card = cards[index];
    const button = document.createElement("button");
    button.type = "button";
    button.id = "card-" + card.id;
    button.dataset.cardId = card.id;
    button.addEventListener("click", function () {
      openCard(card);
    });
    cardField.appendChild(button);
  }
}

document.createElement("button") создаёт элемент в памяти; appendChild добавляет его в контейнер. До добавления мы задаём тип, идентификатор и обработчик. cardField.textContent = "" очищает прежние дочерние элементы. Поэтому сброс не допишет второй набор под первым.

Свойство dataset.cardId записывает строковый атрибут data-card-id. Его видно в инспекторе Elements; он помогает проверить связь кнопки с данными. HTML-id кнопки получаем как "card-" + card.id, чтобы затем найти её. У каждого объекта должен быть уникальный id: значение может повторяться, идентификатор — нет. Позиция в массиве может измениться, а имя карточки остаётся тем же.

Обработчик здесь — функция без имени, созданная внутри итерации и переданная в addEventListener. Она не выполняется при построении. При будущем клике она всё ещё имеет доступ к переменной card своей итерации: это называется замыканием. Каждая итерация создаёт свою const card, поэтому кнопка работает со своим объектом. Мы не сохраняем в обработчик общий меняющийся индекс.

Сразу после buildField добавь действие:

function openCard(card) {
  if (card.open) {
    return;
  }
  card.open = true;
  render();
}

Параметр card связан с тем же объектом, который лежит в массиве. Изменение свойства обновляет данные этой карточки. Повторное действие на уже открытой карточке прекращается через return.

Перебираем объекты и обновляем надписи

В старую render вместо ручного вывода двух кнопок поставь:

function render() {
  let openCount = 0;
  for (const card of cards) {
    const button = document.getElementById("card-" + card.id);
    if (card.open) {
      button.textContent = card.id + ": " + card.value;
      openCount = openCount + 1;
    } else {
      button.textContent = "Открыть " + card.id;
    }
    button.disabled = card.open;
  }
  statusElement.textContent = "Открыто: " + openCount + " из " + cards.length + ".";
  if (openCount === cards.length) {
    statusElement.textContent = "Все карточки открыты. Сбрось набор и попробуй ещё раз.";
  }
}

Запись for (const card of cards) по очереди даёт каждый элемент массива. Это for...of: индекс не нужен, поэтому запись короче. Слово of важно; for...in — другая конструкция, здесь она не используется.

getElementById находит элемент по его HTML-id, без знака #. Кнопки уже созданы, теперь мы меняем только текст и доступность. Не создаём новый элемент после каждого клика. openCount — временный счётчик открытых карточек: при каждом отображении начинаем с нуля и вычисляем его по данным.

Создаём новое поле при сбросе

На месте старой функции сброса добавь resetGame. В самом низу файла удали старые подключения и первый вызов render, затем оставь ровно три строки запуска из этого фрагмента:

function resetGame() {
  cards = createInitialCards();
  buildField();
  render();
}

resetButton.addEventListener("click", resetGame);
buildField();
render();

В этой версии порядок важен: сначала данные, затем элементы, затем их надписи. Обработчик кнопки сброса подключается один раз. При создании новых карточек прежние кнопки удаляются вместе со своими обработчиками; к каждой новой подключается один. render не подключает обработчики.

Сохрани и обнови страницу. Две карточки должны работать как раньше. После пяти сбросов на поле всё ещё две кнопки. Это step-01. Если экран пуст, сначала смотри Console: частая причина — оставшийся вызов удалённой функции или render, запущенная до buildField.

Сверить полный game.js промежуточной версии
function createInitialCards() {
  return [
    { id: "sun-1", value: "Солнце", open: false },
    { id: "moon-1", value: "Луна", open: false }
  ];
}

let cards = createInitialCards();
const cardField = document.querySelector("#card-field");
const resetButton = document.querySelector("#reset-button");
const statusElement = document.querySelector("#status");

function buildField() {
  cardField.textContent = "";
  for (let index = 0; index < cards.length; index = index + 1) {
    const card = cards[index];
    const button = document.createElement("button");
    button.type = "button";
    button.id = "card-" + card.id;
    button.dataset.cardId = card.id;
    button.addEventListener("click", function () {
      openCard(card);
    });
    cardField.appendChild(button);
  }
}

function openCard(card) {
  if (card.open) {
    return;
  }
  card.open = true;
  render();
}

function render() {
  let openCount = 0;
  for (const card of cards) {
    const button = document.getElementById("card-" + card.id);
    if (card.open) {
      button.textContent = card.id + ": " + card.value;
      openCount = openCount + 1;
    } else {
      button.textContent = "Открыть " + card.id;
    }
    button.disabled = card.open;
  }
  statusElement.textContent = "Открыто: " + openCount + " из " + cards.length + ".";
  if (openCount === cards.length) {
    statusElement.textContent = "Все карточки открыты. Сбрось набор и попробуй ещё раз.";
  }
}

function resetGame() {
  cards = createInitialCards();
  buildField();
  render();
}

resetButton.addEventListener("click", resetGame);
buildField();
render();

Добавляем карточки в массив

В game.js замени фабрику таким набором из шести объектов:

function createInitialCards() {
  return [
    { id: "sun-1", value: "Солнце", open: false },
    { id: "sun-2", value: "Солнце", open: false },
    { id: "moon-1", value: "Луна", open: false },
    { id: "moon-2", value: "Луна", open: false },
    { id: "star-1", value: "Звезда", open: false },
    { id: "star-2", value: "Звезда", open: false }
  ];
}

Открой страницу: появились шесть кнопок без шести новых HTML-строк. Две карточки «Солнце» — разные объекты, потому что это разные выражения { id: ..., value: ..., open: ... }. Их значения совпадают, но идентификаторы разные. Пока мы открываем все карточки; правила поиска пар появятся в отдельной игре.

В buildField индекс больше не используется нигде, кроме чтения объекта. Можно заменить начало цикла из двух строк на for (const card of cards) {. Весь остальной блок остаётся прежним. Так мы использовали for, когда нужен счётчик, и for...of, когда нужен сам объект. Это final.

Самостоятельно: добавь карточки и поменяй их порядок

Собери набор из восьми карточек с четырьмя повторяющимися значениями и восемью разными id. Меняй только фабрику: ни HTML, ни обработчики, ни render расширять не нужно. Поменяй местами два объекта в массиве. Карточки должны появиться в новом порядке, сохранив свои имена и значения. Затем временно оставь один объект: его открытие должно завершать набор.

Верни восемь объектов и проверь: открытие одного из одинаковых значений не открывает другое; после нескольких сбросов карточек ровно восемь, счёт начинается с нуля, один клик открывает одну карточку.

Подсказка 1: что управляет количеством

Построение и отображение перебирают cards. Ищи изменение в createInitialCards, а не в разметке. После правки обнови страницу либо нажми сброс.

Подсказка 2: одна пара — два объекта

Добавь два отдельных объекта с одинаковым value, но разными id. Не вставляй одну ссылку на объект дважды. Проверь запятые между объектами.

Решение после попытки

Пример набора из восьми карточек находится в solutions/eight-cards. Между ним и финалом отличается только фабрика. Затем попробуй составить свой набор по тому же принципу.

Проверяем результат

  • Ты объясняешь начальный индекс, условие продолжения и увеличение в for.
  • Различаешь перебор массива и действие с задержкой.
  • Знаешь, где создаётся кнопка, где добавляется в документ и когда выполняется её обработчик.
  • Ты меняешь число и порядок карточек через массив. Карточки с одинаковым содержимым открываются независимо друг от друга.
  • После повторного сброса нет дубликатов кнопок и лишних действий на один клик.

Карточка — данные, кнопка — её отображение. Сохрани эту версию: на том же поле можно строить правила поиска пар.

Материалы урока

Справочно: циклы, создание элементов и замыкания на MDN. Сначала проверь свой работающий пример, затем обращайся к нужной справке.