Вы хотели создать что-то веселое и простое, чтобы практиковать свои навыки работы с интерфейсом, но мысль о создании еще одного TODO-приложения заставляет вас отказаться от этого замечательного пути, на котором вы начали?

Вы можете найти готовый проект на моем Github здесь.

Что ж, не смотрите дальше, сегодня мы будем создавать простую (*барабанная дробь*) игру в крестики-нолики. Мы рассмотрим некоторые основы, такие как использование сетки CSS, селекторы запросов и структурирование нашего игрового процесса и логики.

Давайте сначала посмотрим на конечный продукт

Тогда главный вопрос… С чего начать?

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

  • заглавие
  • сетка 3x3

— сетка должна быть кликабельной

— в ячейках сетки должен отображаться правильный знак игрока

  • информационный дисплей

— должно отображать сообщение, информирующее текущего игрока о том, что настала его очередь

— должен показать нам, кто выиграл игру

— должен показать нам, закончилась ли игра вничью

  • кнопка перезагрузки

- перезапустит всю игру

Далее, давайте разберем ход игры для клика по ячейке:

  • нам нужно отслеживать любые клики, которые происходят на наших ячейках

— нам нужно проверить, был ли это допустимый ход

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

  • мы должны обновить наше игровое состояние
  • мы должны проверить состояние игры

— проверить, выиграл ли игрок

— проверить, закончилась ли игра вничью

  • либо остановить игру, либо сменить активного игрока, в зависимости от вышеуказанных проверок
  • убедитесь, что пользовательский интерфейс отражает сделанные обновления
  • промыть и повторить

Вот и все, ничего особенного или чрезмерно сложного, но все же отличная возможность попрактиковаться и улучшить.

Давайте перейдем к самой интересной части и построим что-нибудь!

Структура папок

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

Как я уже говорил, это простая игра, поэтому нет необходимости в сложных структурах папок.

Всего у вас должно быть три файла:

  • index.html (будет содержать нашу структуру пользовательского интерфейса и импортировать другие нужные нам файлы)
  • style.css (чтобы наша игра выглядела наполовину прилично)
  • script.js (будет содержать нашу игровую логику и обрабатывать все остальное, что нам нужно)

HTML

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Tic Tac Toe</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <section>
        <h1 class="game--title">Tic Tac Toe</h1>
        <div class="game--container">
            <div data-cell-index="0" class="cell"></div>
            <div data-cell-index="1" class="cell"></div>
            <div data-cell-index="2" class="cell"></div>
            <div data-cell-index="3" class="cell"></div>
            <div data-cell-index="4" class="cell"></div>
            <div data-cell-index="5" class="cell"></div>
            <div data-cell-index="6" class="cell"></div>
            <div data-cell-index="7" class="cell"></div>
            <div data-cell-index="8" class="cell"></div>
        </div>
        <h2 class="game--status"></h2>
        <button class="game--restart">Restart Game</button>
    </section>
<script src="script.js"></script>
</body>
</html>

Помимо обычного шаблона, мы включили нашу таблицу стилей в элемент ‹head ›, мы делаем это, чтобы убедиться, что таблица стилей всегда загружается перед фактическим HTML.

Мы также включили наш файл script.js чуть выше закрывающего тега ‹/body›, чтобы убедиться, что javascript всегда загружается после HTML.

Мы будем хранить игровые ячейки в обертке div, чтобы можно было использовать сетку CSS. Кроме того, каждая ячейка имеет атрибут «data-cell-index», который позволяет нам легко отслеживать, когда ячейка была нажата.

У нас также есть элемент ‹h2›, где мы будем отображать вышеупомянутую информацию об игре и кнопку перезапуска.

CSS

body {
    font-family: "Arial", sans-serif;
}
section {
    text-align: center;
}
.game--container {
    display: grid;
    grid-template-columns: repeat(3, auto);
    width: 306px;
    margin: 50px auto;
}
.cell {
    font-family: "Permanent Marker", cursive;
    width: 100px;
    height: 100px;
    box-shadow: 0 0 0 1px #333333;
    border: 1px solid #333333;
    cursor: pointer;
line-height: 100px;
    font-size: 60px;
}

Я хотел свести CSS для приложения к минимуму, поэтому единственное, на что я хотел бы обратить ваше внимание, — это стили для «.game — container», поскольку именно здесь мы реализуем нашу сетку CSS.

Поскольку мы хотим иметь сетку 3x3, мы используем свойство «grid-template-columns», установив для него значение repeat(3, auto);

В двух словах, это разбивает содержащиеся div (ячейки) на три столбца и позволяет ячейкам автоматически определять свою ширину.

JavaScript

Теперь мы подошли к самой интересной части!

Давайте запустим наш JS, структурировав некоторый псевдокод, чтобы разбить его на более мелкие части, используя наш ранее написанный шаблон игровой логики.

/*
We store our game status element here to allow us to more easily        use it later on 
*/
const statusDisplay = document.querySelector('.game--status');
/*
Here we declare some variables that we will use to track the game state throught the game. 
*/
/*
We will use gameActive to pause the game in case of an end scenario
*/
let gameActive = true;
/*
We will store our current player here, so we know whos turn 
*/
let currentPlayer = "X";
/*
We will store our current game state here, the form of empty strings in an array will allow us to easily track played cells and validate the game state later on
*/
let gameState = ["", "", "", "", "", "", "", "", ""];
/*
Here we have declared some messages we will display to the user during the game. Since we have some dynamic factors in those messages, namely the current player, we have declared them as functions, so that the actual message gets created with current data every time we need it.
*/
const winningMessage = () => `Player ${currentPlayer} has won!`;
const drawMessage = () => `Game ended in a draw!`;
const currentPlayerTurn = () => `It's ${currentPlayer}'s turn`;
/*
We set the inital message to let the players know whose turn it is
*/
statusDisplay.innerHTML = currentPlayerTurn();
function handleCellPlayed() {
   
}
function handlePlayerChange() {
    
}
function handleResultValidation() {
   
}
function handleCellClick() {
    
}
function handleRestartGame() {
    
}
/*
And finally we add our event listeners to the actual game cells, as well as our restart button
*/
document.querySelectorAll('.cell').forEach(cell => cell.addEventListener('click', handleCellClick));
document.querySelector('.game--restart').addEventListener('click', handleRestartGame);

Мы также наметили все функции, которые нам понадобятся для обработки нашей игровой логики, так что давайте напишем нашу логику!

дескрипторCellClick

В нашем обработчике щелчка по ячейке мы будем обрабатывать две вещи.

Во-первых, нам нужно проверить, была ли уже нажата ячейка, по которой щелкнули, и если нет, нам нужно продолжить наш игровой процесс оттуда.

Давайте посмотрим, как это выглядит в действии:

function handleCellClick(clickedCellEvent) {
/*
We will save the clicked html element in a variable for easier further use
*/    
    const clickedCell = clickedCellEvent.target;
/*
Here we will grab the 'data-cell-index' attribute from the clicked cell to identify where that cell is in our grid. Please note that the getAttribute will return a string value. Since we need an actual number we will parse it to an integer(number)
*/
    const clickedCellIndex = parseInt(
      clickedCell.getAttribute('data-cell-index')
    );
/* 
Next up we need to check whether the call has already been played, or if the game is paused. If either of those are true we will simply ignore the click.
*/
    if (gameState[clickedCellIndex] !== "" || !gameActive) {
        return;
    }
/* 
If everything if in order we will proceed with the game flow
*/    
    handleCellPlayed(clickedCell, clickedCellIndex);
    handleResultValidation();
}

Мы примем ClickEvent от нашего прослушивателя событий ячейки. Это позволит нам отслеживать, какая ячейка была нажата, и легче получить ее индексный атрибут.

дескрипторCellPlayed

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

function handleCellPlayed(clickedCell, clickedCellIndex) {
/*
We update our internal game state to reflect the played move, as well as update the user interface to reflect the played move
*/
    gameState[clickedCellIndex] = currentPlayer;
    clickedCell.innerHTML = currentPlayer;
}

Мы принимаем текущую ячейку, на которую нажали (.target нашего события клика), и индекс ячейки, на которую нажали.

дескрипторрезультатвалидация

А вот и суть нашей игры в крестики-нолики, проверка результатов. Здесь мы проверим, закончилась ли игра победой, ничьей или остались ли еще ходы.

Давайте начнем с проверки, выиграл ли текущий игрок игру.

const winningConditions = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6]
];
function handleResultValidation() {
    let roundWon = false;
    for (let i = 0; i <= 7; i++) {
        const winCondition = winningConditions[i];
        let a = gameState[winCondition[0]];
        let b = gameState[winCondition[1]];
        let c = gameState[winCondition[2]];
        if (a === '' || b === '' || c === '') {
            continue;
        }
        if (a === b && b === c) {
            roundWon = true;
            break
        }
    }
if (roundWon) {
        statusDisplay.innerHTML = winningMessage();
        gameActive = false;
        return;
    }
}

Потратьте минуту, чтобы сломать это, прежде чем продолжить в качестве упражнения.

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

В нашем цикле for мы проходим по каждому из них и проверяем, совпадают ли элементы нашего массива состояний игры под этими индексами. Если они совпадают, мы переходим к объявлению текущего игрока победителем и окончанию игры.

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

const winningConditions = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6]
];
function handleResultValidation() {
    let roundWon = false;
    for (let i = 0; i <= 7; i++) {
        const winCondition = winningConditions[i];
        let a = gameState[winCondition[0]];
        let b = gameState[winCondition[1]];
        let c = gameState[winCondition[2]];
        if (a === '' || b === '' || c === '') {
            continue;
        }
        if (a === b && b === c) {
            roundWon = true;
            break
        }
    }
if (roundWon) {
        statusDisplay.innerHTML = winningMessage();
        gameActive = false;
        return;
    }
/* 
We wil check weather there are any values in our game state array that are still not populated with a player sign
*/
    let roundDraw = !gameState.includes("");
    if (roundDraw) {
        statusDisplay.innerHTML = drawMessage();
        gameActive = false;
        return;
    }
/*
If we get to here we know that the no one won the game yet, and that there are still moves to be played, so we continue by changing the current player.
*/
    handlePlayerChange();
}

Поскольку у нас есть оператор return в нашей проверке roundWon, мы знаем, что если игрок выиграл этот раунд, наш скрипт остановится на этом. Это позволяет нам избежать использования условий else и сохранить наш код красивым и компактным.

дескриптор PlayerChange

Здесь мы просто изменим текущего игрока и обновим сообщение о статусе игры, чтобы отразить это изменение.

function handlePlayerChange() {
    currentPlayer = currentPlayer === "X" ? "O" : "X";
    statusDisplay.innerHTML = currentPlayerTurn();
}

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

Осталось только подключить наш функционал перезапуска игры.

ручкаRestartGame

Здесь мы вернем значения по умолчанию для всех наших переменных отслеживания игры, очистим игровое поле, удалив все знаки, а также обновим статус игры до текущего сообщения игрока.

function handleRestartGame() {
    gameActive = true;
    currentPlayer = "X";
    gameState = ["", "", "", "", "", "", "", "", ""];
    statusDisplay.innerHTML = currentPlayerTurn();
    document.querySelectorAll('.cell')
               .forEach(cell => cell.innerHTML = "");
}

Вывод

По сути, это все!
У вас есть работающая игра в крестики-нолики (*самодай пять*)

Конечно, здесь мы могли бы сделать гораздо больше, например, сделать игру многопользовательской, чтобы вы могли играть с другом, который находится на другом конце света. Или почему бы не написать алгоритм, который будет играть с вами в игру? Может быть, попробовать написать приложение на выбранном вами фреймворке, чтобы сравнить его с ванильным JavaScript?

Здесь есть много возможностей для изучения и развития, дайте мне знать, какая из них вам больше всего нравится, и я буду более чем счастлив сделать еще одно из этих руководств!

Как всегда, готовый проект можно найти на моем Github здесь.