Делаем игру Quatro
hard

Делаем игру Quatro

Интеллектуальная игра для двоих

Давно не делали игрушек (только показывали красивые). Пора сделать. Проект будет большим, длинным и довольно сложным, потому что мы будем использовать продвинутые штуки: CSS-переменные, стрелочные функции и всякие локальные хаки. Но не всё же хеллоу ворлд. 

Правила игры Quatro звучат вкратце так: есть 16 фигур, которые отличаются друг от друга четырьмя признаками. Задача каждого игрока — первым собрать ряд фигур по вертикали, горизонтали или диагонали с каким-то общим признаком. Особенность этой игры в том, что соперник каждый раз сам выбирает другому игроку, чем ему ходить.

Например, можно собрать в ряд все закрашенные фигуры, или все маленькие фигуры, или сделать ряд из квадратов и так далее. Кто первый соберёт ряд по любому признаку — тот и выиграл.

Делаем игру Quatro
Поле для игры в Quatro с фигурами

Логика проекта

В HTML-файле мы соберём только каркас: расставим блоки на странице и дадим им названия, чтобы потом можно было к ним обращаться.

В CSS-стилях будет уже интереснее: в них мы будем использовать CSS-переменные, чтобы подстраивать размер игры под размер экрана и управлять положением фигур. Собственно, ради CSS-переменных мы сюда и пришли. 

В скрипте тоже отработаем новые знания — будем активно использовать стрелочные функции и подставлять значения CSS-переменных сразу в стили.

Получается, что за отрисовку и анимацию фигур отвечает CSS, а скрипт собирает всё в одно целое и устанавливает правила поведения.

Пишем HTML

Код страницы простой. Единственный нестандартный элемент, который там есть, это нормализатор стилей. Он нужен, чтобы игра в большинстве браузеров выглядела одинаково. Остальное — это простые блоки <div>:

<!DOCTYPE html>
<html lang="ru" >
<head>
  <meta charset="UTF-8">
  <title>Игра Quarto</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <!-- подключаем нормализатор стилей, чтобы игра везде выглядела одинаково -->
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.min.css">
  <!-- подключаем свой файл со стилями -->
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <!-- блок с доской -->
  <section><div id="board"></div></section>

  <!-- блок для оповещения, которое появится в конце игры -->
  <div id="alert"></div>
  
  <!-- основной скрипт игры -->
  <script  src="script.js"></script>

</body>
</html>

Создаём фигуры

Так как в игре у нас будет 16 объектов с одинаковым поведением, реакциями, но разными свойствами, то сделаем так: создадим класс для фигур, а все фигуры сделаем экземплярами этого класса. Такой подход позволит нам сразу запрограммировать все общие моменты, а уникальные свойства будем добавлять при создании.

Каждый признак мы будем задавать нулём или единицей и просто записывать их подряд, друг за другом. Например, сплошной (1) маленький (0) чёрный (0) круг (1) будет закодирован как 1010.

Чтобы фигуры появлялись на странице, будем оборачивать их в тег <span>, а координаты зададим через свойство стиля для каждой фигуры. Раз мы заранее не знаем, где какая фигура будет стоять, будем использовать в стилях CSS-переменные — при создании фигуры отправим туда нужные значения.

Ещё нам понадобится метод, который убирает фигуру с поля, если игрок ошибся и поставил её не туда, куда хотел. В нём мы обнулим записи в переменной поля и удалим координаты из стилей. 

Чтобы понять, какую мы выбрали фигуру в игре, нам нужно её как-то выделить. Для этого сделаем так: при нажатии на ней будем рисовать вокруг неё красный контур, а при повторном нажатии — убирать. Так мы сразу поймём, какая фигура отправится на доску на этом ходу.

Но главное — у нас будет отдельный статический метод, который раскодирует признак из чисел обратно в свойства и вернёт название признака, которое нам нужно.

👉 Статический метод — это такой метод, который можно использовать только внутри этого класса. Снаружи к нему обратиться нельзя ни из других классов, ни просто так, из самой программы.

Пока это только класс — сами фигуры мы ещё не создаём, а только лишь описываем их поведение и возможности:

// класс, который будет отвечать за все фигуры
class Piece {
  // что будет происходить при создании новой фигуры
  constructor(color, density, height, shape) {
    // собираем id из значений цвета, насыщенности, размера и формы фигуры
    this.id = [color, density, height, shape].join("");
    // получаем значения всех признаков фигуры
    this.color = Piece.valueFromTraitAndNumber("color", color);
    this.height = Piece.valueFromTraitAndNumber("height", height);
    this.shape = Piece.valueFromTraitAndNumber("shape", shape);
    this.density = Piece.valueFromTraitAndNumber("density", density);
    // на момент создания мы не знаем, где будет стоять фигура
    this.x = undefined;
    this.y = undefined;
    // каждую фигуру создаём в блоке <span>
    this.element = document.createElement("span");
    // каждой присваиваем уникальное имя класса
    this.element.className = ["piece", this.color, this.density, this.height, this.shape].join(" ");
  }

  // метод, который устанавливает фигуру в заданное место
  place(x, y) {
    // получаем координаты на поле
    this.x = x;
    this.y = y;
    // добавляем через CSS-переменные новые значения координат в стиль фигуры
    this.element.style.setProperty("--x", x);
    this.element.style.setProperty("--y", y);
    // делаем фигуру неактивной с помощью другого метода этого класса
    this.deactivate();
  }
  
  // метод, который ставит фигуру на своё первоначальное место возде доски
  placeInitial(x, y) {
    // ставим фигуру по нужным координатам
    this.place(x, y);
    // устанавливаем соответствующее свойство в стилях фигуры 
    this.element.style.setProperty("--initial-x", x);
    this.element.style.setProperty("--initial-y", y);
  }
  
  // метод, который убирает фигуру с поля
  reset() {
    // убираем свойства из стилей
    this.element.style.removeProperty("--x");
    this.element.style.removeProperty("--y");
    // очищаем значения координат на поле
    this.x = undefined;
    this.y = undefined;
    this.deactivate();
  }

  // метод, который делает фигуру активной
  activate() {
    // добавлям класс в список классов фигуры
    this.element.classList.add("active");
  }

  // метод, который делает фигуру неактивной
  deactivate() {
    // убираем класс из списка классов фигуры
    this.element.classList.remove("active");
  }

  // статический метод для класса
  // этот метод можно использовать только в классе, а не в его объектах
  static valueFromTraitAndNumber(trait, number) {
    // метод получает на вход название свойства и число, которым оно закодировано
    // после этого он возвращает значение свойства в зависимости от кода числа
    if (trait === "color") return number ? "dark" : "light";
    if (trait === "height") return number ? "tall" : "short";
    if (trait === "shape") return number ? "square" : "round";
    if (trait === "density") return number ? "hollow" : "solid";
  }
}

Стили для фигур и поля

Раз мы в скрипте уже обращаемся к элементам стиля фигур, давайте сразу создадим и пропишем CSS-файл и соберём в него всё, что относится к игре. 

👉 Обратите внимание на размеры каждого элемента: они либо зависят от размера окна браузера, либо считаются  через переменные. Это сделано для того, чтобы игра адаптировалась под любые размеры экрана.

Чтобы было понятнее, что за что отвечает, мы добавили комментарии к каждому разделу:

/* задаём общие переменные для всех блоков */
:root {
  --color-dark: #444;
  --color-light: #ecc026;
  /* понадобится для того, чтобы сделать поле квадратным */
  --di: min(100vh, 100vw);
  /* размер поля */
  --board-di: calc(var(--di) * 0.66666666);
  /* толщина штриховки */
  --border: calc(var(--board-di) * 0.013333333);
  /* размеры больших и маленьких фигур */
  --dimension-lg: calc(var(--line) * 14); 
  --dimension-sm: calc(var(--line) * 7); 
  /* делаем толщину линии равной толщине штриховки */
  --line: var(--border);
}

/* свойства раздела для игрового поля */
/* всё подстраивается под размер экрана */
section {
  box-sizing: border-box;
  height: var(--di);  
  margin: calc((100vh - var(--di)) * 0.5) calc((100vw - var(--di)) * 0.5);
  padding: calc((var(--di) - var(--board-di)) * 0.5);
  width: var(--di);
}

/* свойства игровой доски */
#board {
  display: flex;
  flex-wrap: wrap;
  height: var(--board-di);
  position: relative;
  width: var(--board-di);
}

/* свойства клеток на доске */
#board .tile {
  display: block;
  /* каждая клетка занимает четверть от размера всего поля */
  height: 25%;
  position: relative;
  width: 25%;
}

/* настраиваем внешний вид текста на клетках с помощью псевдоэлемента :after */
#board .tile::after {
  align-items: center;
  background: #e9e9e9;
  border-radius: calc(var(--border) * 1.5);
  color: white;
  content: attr(label);
  display: flex;
  flex-direction: column;
  font-size: calc(var(--board-di) * 0.07);
  font-weight: bold;
  justify-content: center;
  height: 90%;
  left: 5%;
  position: absolute;
  top: 5%;
  width: 90%;
}

/* настраиваем внешний вид фигур в игре */
#board .piece {
  /* значения переменных по умолчанию */
  --initial-x: 0;
  --initial-y: -1;
  background-position: center;
  background-color: #fff;
  background-image: repeating-linear-gradient(
    45deg,
    var(--color-piece),
    var(--color-piece) var(--line),
    transparent var(--line),
    transparent calc(var(--line) * 2)
  );
  border: var(--border) solid transparent;
  box-sizing: border-box;
  height: var(--dimension);
  left: calc(var(--x, var(--initial-x)) * 25% + 12.5%);
  position: absolute;
  top: calc(var(--y, var(--initial-y)) * 25% + 12.5%);
  transform: translate(-50%, -50%);
  transition: all 150ms ease-in-out;
  width: var(--dimension);
}

/* рисуем рамку у выбранного элемента */
#board .piece.active {
  box-shadow: 0 0 0 var(--border) red;
}

/* задаём элементам 4 разных свойства */
#board .piece.dark { --color-piece: var(--color-dark); }
#board .piece.light { --color-piece: var(--color-light); }
#board .piece.tall { --dimension: var(--dimension-lg); }
#board .piece.short { --dimension: var(--dimension-sm); }
#board .piece.round { border-radius: 50%; }
#board .piece.square { border-radius: var(--border); }
#board .piece.solid { background-color: var(--color-piece); }
#board .piece.hollow { border-color: var(--color-piece); }

Создаём класс с игрой

Кажется, что в этом нет особого смысла: игра создаётся и запускается только один раз, зачем же тогда нужен класс?

Хитрость в том, что нам нужно обрабатывать нажатия на каждую фигуру, чтобы поставить её на поле, и ещё обрабатывать нажатия на поле, чтобы можно было фигуры оттуда убирать. Для этого нам нужно привязать к каждой фигуре свой обработчик нажатий или использовать один для всех. Но если делать один для всех и без класса, то нужно будет узнавать, на какую фигуру нажали, передавать это в обработчик и следить за тем, чтобы всё обработалось правильно.

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

Точно так же сделаем для двойного нажатия, чтобы вернуть фигуру с поля на место, и для постановки выбранной фигуры при нажатии на пустую клетку поля. 

👉 Такое гибкое управление логикой работы — это ещё один вариант применения классов, кроме стандартной подготовки к созданию объектов.

// класс с игрой
class Game {
  // что произойдёт при создании нового объекта с игрой
  constructor() {
    // получаем доступ к блоку с игровым полем
    this.board = document.getElementById("board");
    // рисуем поле
    this.generateMatrix();
    // расставляем фигуры перед игрой
    this.generatePieces();
  }
  
  // метод, который создаёт игровое поле
  generateMatrix() {
    // клетки поля на старте пустые
    this.matrix = [
      undefined, undefined, undefined, undefined,
      undefined, undefined, undefined, undefined,
      undefined, undefined, undefined, undefined,
      undefined, undefined, undefined, undefined,
    ];
    // перебираем каждую клетку
    this.matrix.forEach((_, i) => {
      // рассчитываем координаты для каждой клетки
      const y = Math.floor(i / 4);
      const x = i % 4;
      // оборачиваем её в тег <span>
      const tile = document.createElement("span");
      // сразу указываем класс для стилей
      tile.className = "tile";
      // подписываем клетку
      const xLabel = ["A", "B", "C", "D"][x];
      tile.setAttribute("label", `${xLabel}${y + 1}`);
      // добавляем обработчик клика на клетке
      tile.addEventListener("click", () => {
        this.onTileClick(x, y);
      });
      // добавляем клетку на доску
      this.board.appendChild(tile);
    });
  }

  // метод, который создаёт игровые фигуры
  generatePieces() {  
    // на старте пока ничего нет
    this.pieces = {};
    // распределяем 4 признака во всех комбинациях
    const pieces = [
      new Piece(0, 0, 0, 0), new Piece(0, 0, 0, 1), new Piece(0, 0, 1, 0), new Piece(0, 0, 1, 1),
      new Piece(0, 1, 0, 0), new Piece(0, 1, 0, 1), new Piece(0, 1, 1, 0), new Piece(0, 1, 1, 1),
      new Piece(1, 0, 0, 0), new Piece(1, 0, 0, 1), new Piece(1, 0, 1, 0), new Piece(1, 0, 1, 1),
      new Piece(1, 1, 0, 0), new Piece(1, 1, 0, 1), new Piece(1, 1, 1, 0), new Piece(1, 1, 1, 1),
    ];
    // перебираем каждую фигуру
    pieces.forEach((piece, i) => {
      // переносим фигуру из локальной переменной в метод
      this.pieces[piece.id] = piece;
      // обрабатываем координаты
      let x, y;
      // расставляем фигуры по краям игрового поля
      if (i < 4) {
        x = i;
        y = -1;
      } else if (i < 8) {
        x = 4;
        y = i % 4;
      } else if (i < 12) {
        x = 3 - (i % 4);
        y = 4;
      } else {
        x = -1;
        y = 3 - (i % 4);
      }
      // ставим фигуру на начальное место
      piece.placeInitial(x, y);
      // добавляем обработчики клика и двойного клика
      piece.element.addEventListener("click", () => this.onPieceClick(piece));
      piece.element.addEventListener("dblclick", () => this.onPieceDblClick(piece));
      // добавляем фигуру на виртуальное игровое пространство
      this.board.appendChild(piece.element);
    });
  }
 
  // метод-обработчик нажатия на фигуру
  onPieceClick(piece) {
    // если она уже была выбрана до этого
    if (this.selectedPieceId === piece.id) {
      // делаем её неактивной
      piece.deactivate();
      // убираем признак выбранной фигуры
      this.selectedPieceId = undefined;
      // а если она ещё не была выбрана
    } else {
      // делаем предыдущую фигуру неактивной, если такая у нас была
      if (this.selectedPieceId) {
        this.pieces[this.selectedPieceId].deactivate();
      }
      // и делаем активной текущую фигуру
      piece.activate();
      // запоминаем ID выбранной фигуры
      this.selectedPieceId = piece.id;
    }
  }

  // метод-обработчик двойного нажатия на фигуру
  onPieceDblClick(piece) {
    // получаем текущее положение фигуры
    const idx = piece.y * 4 + piece.x;
    // если она стояла на поле — очищаем клетку поля
    if (this.matrix[idx] === piece.id) {
      this.matrix[idx] = undefined;
    }
    // возвращаем фигуру на место
    piece.reset();
    // убираем у этой фигуры признак выбора
    this.selectedPieceId = undefined;
    
  }
  
  // метод-обработчик нажатия на клетку игрового поля
  onTileClick(x, y) {
    // если до этого была выбрана какая-то фигура
    if (this.selectedPieceId) {
      // ставим её на эту клетку
      this.placeSelectedPiece(x, y);
    }
  }
  
  // метод, который ставит выбранную фигуру на клетку поля
  placeSelectedPiece(x, y) {
    // получаем фигуру
    const piece = this.pieces[this.selectedPieceId];
    // узнаём её код положения
    const idx = piece.y * 4 + piece.x;
    // если фигура уже стояла на клетке поля
    if (this.matrix[idx] === piece.id) {
      // то помечаем эту клетку как пустую
      this.matrix[idx] = undefined;
    }
    // ставим фигуру по нужным координатам
    piece.place(x, y);
    // отправляем в переменную поля данные о фигуре, которую туда поставили
    this.matrix[y * 4 + x] = this.selectedPieceId;
    // делаем фигуру неактивной
    this.selectedPieceId = undefined;
    // проверяем, наступило ли выигрышное состояние
    this.detectGameOver(piece.color);
  }
}

Добавляем проверку на выигрыш

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

Сделаем это так:

  1. У каждой фигуры есть набор признаков из нолей и единиц.
  2. Они выглядят примерно так: 0110, 1011, 0011 и так далее, всего 16 вариантов.
  3. Каждую такую последовательность можно перевести из двоичной системы счисления в десятичную, чтобы получить числа от 0 до 15.
  4. Так как мы заранее знаем, в каком порядке какие признаки у фигур появляются при создании, то можем понять, какая последовательность фигур с какими числами будет иметь один и тот же признак.
  5. Запишем эти последовательности в переменную и будем проверять с её помощью все последовательности на поле из четырёх линий.

Добавим этот метод в класс с игрой:

// метод, который определяет, закончена игра или нет
detectGameOver(color) {
  // шаблон числовых состояний признаков, по которым можно понять, что игра окончена
  const checks = [
    [0, 1, 2, 3],   [4, 5, 6, 7],
    [8, 9, 10, 11], [12, 13, 14, 15],
    [0, 4, 8, 12],  [1, 5, 9, 13],
    [2, 6, 10, 14], [3, 7, 11, 15],
    [0, 5, 10, 15], [12, 9, 6, 3]
  ];
  // признаки фигур
  const traits = ["color", "density", "height", "shape"];
  // переменная для проверки совпадений
  const matches = [];
  // перебираем матрицу состояний с признаками конца игры
  checks.forEach((indexes) => {
    // создаём стрелочную функцию, которая проверит, есть ли сейчас на поле 4 фигуры в ряд
    const matrixValues = indexes.map((idx) => this.matrix[idx]).filter((v) => v !== undefined);
    // если есть
    if (matrixValues.length === 4) {
      // создаём стрелочную функцию, которая проверит совпадения с нашим шаблоном
      traits.forEach((trait, i) => {
        const distinct = [...new Set(matrixValues.map((str) => str.charAt(i)))];
        // если совпадение есть
        if (distinct.length === 1) {
          // получаем числовой код выигрышного признака
          const value = Piece.valueFromTraitAndNumber(trait, parseInt(distinct[0]));
          // отправляем найденный признак в переменную
          matches.push({ trait, indexes, value });
        }
      });    
    }
  });
  
  // если количество совпадений с выигрышной ситуацией больше нуля
  if (matches.length) {
    // вызываем метод, который покажет сообщение о конце игры
    this.onGameOver(matches, color);
  }
}

Показываем окошко с уведомлением

Теперь в том же классе с игрой напишем метод, который выведет на экран сообщение о выигрыше одного из участников. А чтобы было ещё понятнее, как именно он выиграл, — добавим в текст название признака, по которому построились фигуры в ряд.

👉 Название признака будем выводить на английском, потому что оно же используется в стилях для настройки внешнего вида каждой фигуры. Как только CSS начнёт понимать стили, написанные по-русски, — сразу переделаем текст уведомления. Но пока такого нет — пишем по-английски.

Сначала получаем доступ к элементу на странице:

// получаем доступ к блоку, где будут уведомление
const alert = document.getElementById("alert");
// добавляем ему обработчик событий
// при нажатии в любом месте экрана у уведомления очистится признак класса и он снова станет невидимым
alert.addEventListener("click", () => alert.className = "");

А затем добавляем этот метод в класс игры:

  // обрабатываем выигрыш одного из участников
  onGameOver(data, color) {
    // делаем первую букву в слове большой
    const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
    // показываем с задержкой 100 миллисекунд
    setTimeout(() => {
      // получаем выигрышные признаки
      const text = data.map(({ value }) => cap(value)).join(" / ");
      // устанавливаем такой же цвет всплывашки, что и в выигрышной последовательности
      alert.style.setProperty("--color", `var(--color-${color}`);
      // добавляем текст в блок и делаем его активным
      alert.innerHTML = `<div>Game Over!<br>${text}</div>`;
      alert.className = "active";
    }, 100);
  }

Задаём стили всплывающего сообщения

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

👉 Чтобы невидимая табличка не реагировала на мышь и не забирала себе часть нажатий на странице, используем свойство pointer-events: none; — оно отключает реакцию на нажатие. Получается элемент у нас не только будет невидимый, но и разрешит нажимать мышкой другие элементы сквозь себя.

/* общие свойства всплывающей таблички в конце игры */
#alert {
  --color: var(--color-dark);
  /* остальной фон делаем полупрозрачным белым */
  background-color: rgba(255, 255, 255, 0.8);
  bottom: 0;
  left: 0;
  opacity: 0;
  position: fixed;
  right: 0;
  top: 0;
  /* настраиваем плавное появление */
  transition: opacity 250ms ease-in-out;
}

/* если табличка неактивна */
#alert:not(.active) {
  /* то табличка не будет реагировать на нажатия мыши */
  pointer-events: none;
}

/* общие свойства блока с табличкой */
#alert div {
  background-color: var(--color);
  border-radius: var(--border);
  box-sizing: border-box;
  color: white;
  cursor: pointer;
  font-size: calc(var(--board-di) * 0.07);
  font-weight: bold;
  left: 50%;
  opacity: 0;
  padding: calc(var(--border) * 2);
  position: absolute;
  text-align: center;
  top: 50%;
  /* анимация появления */
  transform: scale(0.00001) translate(-50%, -50%);
  transform-origin: 0 0;
  transition: all 250ms ease-in-out;
  width: calc(var(--board-di) * 0.75);
}

/* если у таблички появляется этот признак, то она становится видимой */
#alert.active,
#alert.active div {
  opacity: 1;
}
/* дополнительная анимация активной таблички */
#alert.active div {
  transform: scale(1) translate(-50%, -50%);
}

Теперь собираем всё вместе и запускаем игру в скрипте командой 

const game = new Game();

Также в Quatro можно поиграть на странице проекта.

<!DOCTYPE html>
<html lang="ru" >
<head>
  <meta charset="UTF-8">
  <title>Игра Quarto</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <!-- подключаем нормализатор стилей, чтобы игра везде выглядела одинаково -->
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.min.css">
  <!-- подключаем свой файл со стилями -->
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <!-- блок с доской -->
  <section><div id="board"></div></section>

  <!-- блок для оповещения, которое появится в конце игры -->
  <div id="alert"></div>
  
  <!-- основной скрипт игры -->
  <script  src="script.js"></script>

</body>
</html>

/* задаём общие переменные для всех блоков */
:root {
  --color-dark: #444;
  --color-light: #ecc026;
  /* понадобится для того, чтобы сделать поле квадратным */
  --di: min(100vh, 100vw);
  /* размер поля */
  --board-di: calc(var(--di) * 0.66666666);
  /* толщина штриховки */
  --border: calc(var(--board-di) * 0.013333333);
  /* размеры больших и маленьких фигур */
  --dimension-lg: calc(var(--line) * 14); 
  --dimension-sm: calc(var(--line) * 7); 
  /* делаем толщину линии равной толщине штриховки */
  --line: var(--border);
}

/* общие свойства всплывающей таблички в конце игры */
#alert {
  --color: var(--color-dark);
  /* остальной фон делаем полупрозрачным белым */
  background-color: rgba(255, 255, 255, 0.8);
  bottom: 0;
  left: 0;
  opacity: 0;
  position: fixed;
  right: 0;
  top: 0;
  /* настраиваем плавное появление */
  transition: opacity 250ms ease-in-out;
}

/* если табличка неактивна */
#alert:not(.active) {
  /* то табличка не будет реагировать на нажатия мыши */
  pointer-events: none;
}

/* общие свойства блока с табличкой */
#alert div {
  background-color: var(--color);
  border-radius: var(--border);
  box-sizing: border-box;
  color: white;
  cursor: pointer;
  font-size: calc(var(--board-di) * 0.07);
  font-weight: bold;
  left: 50%;
  opacity: 0;
  padding: calc(var(--border) * 2);
  position: absolute;
  text-align: center;
  top: 50%;
  /* анимация появления */
  transform: scale(0.00001) translate(-50%, -50%);
  transform-origin: 0 0;
  transition: all 250ms ease-in-out;
  width: calc(var(--board-di) * 0.75);
}

/* если у таблички появляется этот признак, то она становится видимой */
#alert.active,
#alert.active div {
  opacity: 1;
}
/* дополнительная анимация активной таблички */
#alert.active div {
  transform: scale(1) translate(-50%, -50%);
}

/* свойства раздела для игрового поля */
/* всё подстраивается под размер экрана */
section {
  box-sizing: border-box;
  height: var(--di);  
  margin: calc((100vh - var(--di)) * 0.5) calc((100vw - var(--di)) * 0.5);
  padding: calc((var(--di) - var(--board-di)) * 0.5);
  width: var(--di);
}

/* свойства игровой доски */
#board {
  display: flex;
  flex-wrap: wrap;
  height: var(--board-di);
  position: relative;
  width: var(--board-di);
}

/* свойства клеток на доске */
#board .tile {
  display: block;
  /* каждая клетка занимает четверть от размера всего поля */
  height: 25%;
  position: relative;
  width: 25%;
}

/* настраиваем внешний вид текста на клетках с помощью псевдоэлемента :after */
#board .tile::after {
  align-items: center;
  background: #e9e9e9;
  border-radius: calc(var(--border) * 1.5);
  color: white;
  content: attr(label);
  display: flex;
  flex-direction: column;
  font-size: calc(var(--board-di) * 0.07);
  font-weight: bold;
  justify-content: center;
  height: 90%;
  left: 5%;
  position: absolute;
  top: 5%;
  width: 90%;
}

/* настраиваем внешний вид фигур в игре */
#board .piece {
  /* значения переменных по умолчанию */
  --initial-x: 0;
  --initial-y: -1;
  background-position: center;
  background-color: #fff;
  background-image: repeating-linear-gradient(
    45deg,
    var(--color-piece),
    var(--color-piece) var(--line),
    transparent var(--line),
    transparent calc(var(--line) * 2)
  );
  border: var(--border) solid transparent;
  box-sizing: border-box;
  height: var(--dimension);
  left: calc(var(--x, var(--initial-x)) * 25% + 12.5%);
  position: absolute;
  top: calc(var(--y, var(--initial-y)) * 25% + 12.5%);
  transform: translate(-50%, -50%);
  transition: all 150ms ease-in-out;
  width: var(--dimension);
}

/* рисуем рамку у выбранного элемента */
#board .piece.active {
  box-shadow: 0 0 0 var(--border) red;
}

/* задаём элементам 4 разных свойства */
#board .piece.dark { --color-piece: var(--color-dark); }
#board .piece.light { --color-piece: var(--color-light); }
#board .piece.tall { --dimension: var(--dimension-lg); }
#board .piece.short { --dimension: var(--dimension-sm); }
#board .piece.round { border-radius: 50%; }
#board .piece.square { border-radius: var(--border); }
#board .piece.solid { background-color: var(--color-piece); }
#board .piece.hollow { border-color: var(--color-piece); }

// получаем доступ к блоку, где будут уведомление
const alert = document.getElementById("alert");
// добавляем ему обработчик событий
// при нажатии в любом месте экрана у уведомления очистится признак класса и он снова станет невидимым
alert.addEventListener("click", () => alert.className = "");

// класс, который будет отвечать за все фигуры
class Piece {
  // что будет происходить при создании новой фигуры
  constructor(color, density, height, shape) {
    // собираем id из значений цвета, насыщенности, размера и формы фигуры
    this.id = [color, density, height, shape].join("");
    // получаем значения всех признаков фигуры
    this.color = Piece.valueFromTraitAndNumber("color", color);
    this.height = Piece.valueFromTraitAndNumber("height", height);
    this.shape = Piece.valueFromTraitAndNumber("shape", shape);
    this.density = Piece.valueFromTraitAndNumber("density", density);
    // на момент создания мы не знаем, где будет стоять фигура
    this.x = undefined;
    this.y = undefined;
    // каждую фигуру создаём в блоке <span>
    this.element = document.createElement("span");
    // каждой присваиваем уникальное имя класса
    this.element.className = ["piece", this.color, this.density, this.height, this.shape].join(" ");
  }

  // метод, который устанавливает фигуру в заданное место
  place(x, y) {
    // получаем координаты на поле
    this.x = x;
    this.y = y;
    // добавляем через CSS-переменные новые значения координат в стиль фигуры
    this.element.style.setProperty("--x", x);
    this.element.style.setProperty("--y", y);
    // делаем фигуру неактивной с помощью другого метода этого класса
    this.deactivate();
  }
  
  // метод, который ставит фигуру на своё первоначальное место возде доски
  placeInitial(x, y) {
    // ставим фигуру по нужным координатам
    this.place(x, y);
    // устанавливаем соответствующее свойство в стилях фигуры 
    this.element.style.setProperty("--initial-x", x);
    this.element.style.setProperty("--initial-y", y);
  }
  
  // метод, который убирает фигуру с поля
  reset() {
    // убираем свойства из стилей
    this.element.style.removeProperty("--x");
    this.element.style.removeProperty("--y");
    // очищаем значения координат на поле
    this.x = undefined;
    this.y = undefined;
    this.deactivate();
  }

  // метод, который делает фигуру активной
  activate() {
    // добавляем класс в список классов фигуры
    this.element.classList.add("active");
  }

  // метод, который делает фигуру неактивной
  deactivate() {
    // убираем класс из списка классов фигуры
    this.element.classList.remove("active");
  }

  // статический метод для класса
  // этот метод можно использовать только в классе, а не в его объектах
  static valueFromTraitAndNumber(trait, number) {
    // метод получает на вход название свойства и число, которым оно закодировано
    // после этого он возвращает значение свойства в зависимости от кода числа
    if (trait === "color") return number ? "dark" : "light";
    if (trait === "height") return number ? "tall" : "short";
    if (trait === "shape") return number ? "square" : "round";
    if (trait === "density") return number ? "hollow" : "solid";
  }
}

// класс с игрой
class Game {
  // что произойдёт при создании нового объекта с игрой
  constructor() {
    // получаем доступ к блоку с игровым полем
    this.board = document.getElementById("board");
    // рисуем поле
    this.generateMatrix();
    // расставляем фигуры перед игрой
    this.generatePieces();
  }
  
  // метод, который определяет, закончена игра или нет
  detectGameOver(color) {
    // шаблон числовых состояний признаков, по которым можно понять, что игра окончена
    const checks = [
      [0, 1, 2, 3],   [4, 5, 6, 7],
      [8, 9, 10, 11], [12, 13, 14, 15],
      [0, 4, 8, 12],  [1, 5, 9, 13],
      [2, 6, 10, 14], [3, 7, 11, 15],
      [0, 5, 10, 15], [12, 9, 6, 3]
    ];
    // признаки фигур
    const traits = ["color", "density", "height", "shape"];
    // переменная для проверки совпадений
    const matches = [];
    // перебираем матрицу состояний с признаками конца игры
    checks.forEach((indexes) => {
      // создаём стрелочную функцию, которая проверит, есть ли сейчас на поле 4 фигуры в ряд
      const matrixValues = indexes.map((idx) => this.matrix[idx]).filter((v) => v !== undefined);
      // если есть
      if (matrixValues.length === 4) {
        // создаём стрелочную функцию, которая проверит совпадения с нашим шаблоном
        traits.forEach((trait, i) => {
          const distinct = [...new Set(matrixValues.map((str) => str.charAt(i)))];
          // если совпадение есть
          if (distinct.length === 1) {
            // получаем числовой код выигрышного признака
            const value = Piece.valueFromTraitAndNumber(trait, parseInt(distinct[0]));
            // отправляем найденный признак в переменную
            matches.push({ trait, indexes, value });
          }
        });    
      }
    });
    
    // если количество совпадений с выигрышной ситуацией больше нуля
    if (matches.length) {
      // вызываем метод, который покажет сообщение о конце игры
      this.onGameOver(matches, color);
    }
  }
  
  // метод, который создаёт игровое поле
  generateMatrix() {
    // клетки поля на старте пустые
    this.matrix = [
      undefined, undefined, undefined, undefined,
      undefined, undefined, undefined, undefined,
      undefined, undefined, undefined, undefined,
      undefined, undefined, undefined, undefined,
    ];
    // перебираем каждую клетку
    this.matrix.forEach((_, i) => {
      // рассчитываем координаты для каждой клетки
      const y = Math.floor(i / 4);
      const x = i % 4;
      // оборачиваем её в тег <span>
      const tile = document.createElement("span");
      // сразу указываем класс для стилей
      tile.className = "tile";
      // подписываем клетку
      const xLabel = ["A", "B", "C", "D"][x];
      tile.setAttribute("label", `${xLabel}${y + 1}`);
      // добавляем обработчик клика на клетке
      tile.addEventListener("click", () => {
        this.onTileClick(x, y);
      });
      // добавляем клетку на доску
      this.board.appendChild(tile);
    });
  }

  // метод, который создаёт игровые фигуры
  generatePieces() {  
    // на старте пока ничего нет
    this.pieces = {};
    // распределяем 4 признака во всех комбинациях
    const pieces = [
      new Piece(0, 0, 0, 0), new Piece(0, 0, 0, 1), new Piece(0, 0, 1, 0), new Piece(0, 0, 1, 1),
      new Piece(0, 1, 0, 0), new Piece(0, 1, 0, 1), new Piece(0, 1, 1, 0), new Piece(0, 1, 1, 1),
      new Piece(1, 0, 0, 0), new Piece(1, 0, 0, 1), new Piece(1, 0, 1, 0), new Piece(1, 0, 1, 1),
      new Piece(1, 1, 0, 0), new Piece(1, 1, 0, 1), new Piece(1, 1, 1, 0), new Piece(1, 1, 1, 1),
    ];
    // перебираем каждую фигуру
    pieces.forEach((piece, i) => {
      // переносим фигуру из локальной переменной в метод
      this.pieces[piece.id] = piece;
      // обрабатываем координаты
      let x, y;
      // расставляем фигуры по краям игрового поля
      if (i < 4) {
        x = i;
        y = -1;
      } else if (i < 8) {
        x = 4;
        y = i % 4;
      } else if (i < 12) {
        x = 3 - (i % 4);
        y = 4;
      } else {
        x = -1;
        y = 3 - (i % 4);
      }
      // ставим фигуру на начальное место
      piece.placeInitial(x, y);
      // добавляем обработчики клика и двойного клика
      piece.element.addEventListener("click", () => this.onPieceClick(piece));
      piece.element.addEventListener("dblclick", () => this.onPieceDblClick(piece));
      // добвляем фигуру на виртуальное игровое пространство
      this.board.appendChild(piece.element);
    });
  }
  
  // обрабатываем выигрыш одного из участников
  onGameOver(data, color) {
    // делаем первую букву в слове большой
    const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
    // показываем с задержкой 100 миллисекунд
    setTimeout(() => {
      // получаем выигрышные признаки
      const text = data.map(({ value }) => cap(value)).join(" / ");
      // устанавливаем такой же цвет всплывашки, что и в выигрышной последовательности
      alert.style.setProperty("--color", `var(--color-${color}`);
      // добавляем текст в блок и делаем его активным
      alert.innerHTML = `<div>Game Over!<br>${text}</div>`;
      alert.className = "active";
    }, 100);
  }

  // метод-обработчик нажатия на фигуру
  onPieceClick(piece) {
    // если она уже была выбрана до этого
    if (this.selectedPieceId === piece.id) {
      // делаем её неактивной
      piece.deactivate();
      // убираем признак выбранной фигуры
      this.selectedPieceId = undefined;
      // а если она ещё не была выбрана
    } else {
      // делаем предыдущую фигуру неактивной, если такая у нас была
      if (this.selectedPieceId) {
        this.pieces[this.selectedPieceId].deactivate();
      }
      // и делаем активной текущую фигуру
      piece.activate();
      // запоминаем ID выбранной фигуры
      this.selectedPieceId = piece.id;
    }
  }

  // метод-обработчик двойного нажатия на фигуру
  onPieceDblClick(piece) {
    // получаем текущее положение фигуры
    const idx = piece.y * 4 + piece.x;
    // если она стояла на поле — очищаем клетку поля
    if (this.matrix[idx] === piece.id) {
      this.matrix[idx] = undefined;
    }
    // возвращаем фигуру на место
    piece.reset();
    // убираем у этой фигуры признак выбора
    this.selectedPieceId = undefined;
    
  }
  
  // метод-обработчик нажатия на клетку игрового поля
  onTileClick(x, y) {
    // если до этого была выбрана какая-то фигура
    if (this.selectedPieceId) {
      // ставим её на эту клетку
      this.placeSelectedPiece(x, y);
    }
  }
  
  // метод, который ставит выбранную фигуру на клетку поля
  placeSelectedPiece(x, y) {
    // получаем фигуру
    const piece = this.pieces[this.selectedPieceId];
    // узнаём её код положения
    const idx = piece.y * 4 + piece.x;
    // если фигура уже стояла на клетке поля
    if (this.matrix[idx] === piece.id) {
      // то помечаем эту клетку как пустую
      this.matrix[idx] = undefined;
    }
    // ставим фигуру по нужным координатам
    piece.place(x, y);
    // отправляем в переменную поля данные о фигуре, которую туда поставили
    this.matrix[y * 4 + x] = this.selectedPieceId;
    // делаем фигуру неактивной
    this.selectedPieceId = undefined;
    // проверяем, наступило ли выигрышное состояние
    this.detectGameOver(piece.color);
  }
}

const game = new Game();

Текст:

Михаил Полянин

Редактор:

Максим Ильяхов

Художник:

Даня Берковский

Корректор:

Ирина Михеева

Вёрстка:

Кирилл Климентьев

Соцсети:

Олег Вешкурцев

Получите ИТ-профессию
В «Яндекс Практикуме» можно стать разработчиком, тестировщиком, аналитиком и менеджером цифровых продуктов. Первая часть обучения всегда бесплатная, чтобы попробовать и найти то, что вам по душе. Дальше — программы трудоустройства.
Начать карьеру в ИТ
Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию
Еще по теме
Пишем код: как поменять местами значения переменных
Пишем код: как поменять местами значения переменных

Что делать, если третью переменную использовать нельзя.

medium
Как сделать таймер
Создаём собственный таймер-напоминалку

Таймеров и трекеров полно, но мы сделаем такой, какой нужен именно вам. Это легко.

medium
Как сделать свой сайт за 10 минут без программирования

Для некоторых это становится источником постоянного дохода, если подойти к процессу с умом.

easy
Что означает ошибка UnboundLocalError: local variable referenced before assignment
Что означает ошибка UnboundLocalError: local variable referenced before assignment

Одна из самых частых ошибок у начинающих в Python.

easy
Как вернуть нужную тему, если сломался Вордпресс
Как вернуть нужную тему, если сломался Вордпресс

Иногда сайты на Вордпрессе глючат и теряют внешний вид. Это можно исправить, но нужно кое-что знать

hard
Как сгенерировать нейросетью любые картинки
Как сгенерировать нейросетью любые картинки

А также каким будет Half-Life 3

medium
Тёмная тема на сайте: второй этап
Тёмная тема на сайте: второй этап

Сохраняем тему и добавляем переключатель

medium
Ещё 5 новых и красивых визуализаций, которые помогут при работе с биг-датой
Ещё 5 новых и красивых визуализаций, которые помогут при работе с биг-датой

Для аналитики и правильных выводов

Как быстро добавить логгер в проект на Python
Как быстро добавить логгер в проект на Python

Используем силу встроенных библиотек

easy
hard