Как сделать классическую игру «Жизнь» на хуках React +5


Задача разработчика — показать пользователю, как живут и умирают цифровые клетки. Автор воспользовался React и её хуками: управление состоянием и возможность абстрагироваться от логики, связанной с состоянием, позволяют легко читать и понимать проект. Подробностями реализации и кодом на Github делимся, пока у нас начинается курс по Frontend-разработке.


Правила игры

Вселенная игры — бесконечная двумерная ортогональная сетка квадратных клеток, каждая из которых находится в одном из двух возможных состояний: живая или мёртвая (или заселённая и незаселённая соответственно). Каждая клетка взаимодействует со своими восемью соседями — клетками, которые расположены рядом по горизонтали, по вертикали или по диагонали. На каждом шаге во времени происходят следующие переходы:

  • Любая живая клетка с менее чем двумя живыми соседями умирает от недонаселения.

  • Любая живая клетка с двумя или тремя живыми соседями переходит в следующее поколение.

  • Любая живая клетка с более чем тремя живыми соседями умирает, как при перенаселении.

  • Любая мёртвая клетка с тремя живыми соседями становится живой клеткой, как будто размножаясь.

Попробуйте моё приложение, а затем поговорим о его работе под капотом.

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

Этот массив объектов представляет нашу сетку, а каждый объект представляет отдельную ячейку. Свойство alive — ключевое в игре
Этот массив объектов представляет нашу сетку, а каждый объект представляет отдельную ячейку. Свойство alive — ключевое в игре

Создаём компонент отображения Grid, он накладывается на массив сетки и генерирует индивидуальную ячейку для каждого объекта в массиве сетки:

Grid создаёт для каждого объекта ячейки в массиве сетки ячейку — тег <div>
Grid создаёт для каждого объекта ячейки в массиве сетки ячейку — тег <div>

Видно, что и div Grid, — контейнер сетки, и div ячеек имеют встроенные стили, смотрите строки 9 и 17. Значение стиля задаётся вспомогательной функцией. Причина такого подхода заключается в том, что он позволяет динамически изменять стили сетки и ячеек на основе передаваемых в функции данных.

gridSize хранит размер сетки. У меня есть три размера по умолчанию: 15x15, 30x30 или 50x50. У разных размеров будут разные стили. Посмотрим на вспомогательные функции:

Вспомогательная функция динамически изменяет количество места, отведённого каждому столбцу и строке сетки
Вспомогательная функция динамически изменяет количество места, отведённого каждому столбцу и строке сетки
Функция в cellSize возвращает ширину и высоту отдельной ячейки на основе gridSize. Функция в cellDisplay создаёт 3 случайных цвета, а затем проверяет, жива или мертва переданная ячейка. Если она живая, то динамически устанавливает размер ячейки и затем даёт ей случайный цвет; если мёртвая, то динамически устанавливает размер ячейки и задаёт чёрный фон
Функция в cellSize возвращает ширину и высоту отдельной ячейки на основе gridSize. Функция в cellDisplay создаёт 3 случайных цвета, а затем проверяет, жива или мертва переданная ячейка. Если она живая, то динамически устанавливает размер ячейки и затем даёт ей случайный цвет; если мёртвая, то динамически устанавливает размер ячейки и задаёт чёрный фон

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

useGrid содержит несколько вызовов useState для отслеживания информации, которую мы используем и для итерации поколений, и для управления игрой:

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

Сначала нужно узнать, есть ли в сетке комбинация ячеек, которые можно изменить. Соответствующая логика располагается во вспомогательной функции stepThroughAutomata внутри useGrid. Я приступил к составлению плана функции, используя методы решения задач Джорджа Пойа.

План разработки stepThroughAutomata
План разработки stepThroughAutomata

Воплощаем план в жизнь:

Шаг 1. Задайте переменную, чтобы определить, валидна ли клетка (возможна ли мутация на основе правил)
Шаг 1. Задайте переменную, чтобы определить, валидна ли клетка (возможна ли мутация на основе правил)
Шаг 2. Примените отображение к текущей сетке, сохранив результат как nextGeneration, и с помощью вспомогательной функции getNeighbors проверьте соседей текущей ячейки
Шаг 2. Примените отображение к текущей сетке, сохранив результат как nextGeneration, и с помощью вспомогательной функции getNeighbors проверьте соседей текущей ячейки
Шаг 3. Инициализируйте значение livingNeighbors значением 0, затем проверьте всех соседей текущей клетки, чтобы узнать, есть ли среди них живые. Для каждого живого соседа увеличьте значение livingNeighbors на 1
Шаг 3. Инициализируйте значение livingNeighbors значением 0, затем проверьте всех соседей текущей клетки, чтобы узнать, есть ли среди них живые. Для каждого живого соседа увеличьте значение livingNeighbors на 1
Шаг 4. Исходя из количества живых соседей текущей ячейки, проверьте правила игры и переключите текущую ячейку на живую, если она была мёртвой, на мёртвую, если она была живой, или на неизменную. Установите validGrid в true, если мутация выполнена: если это возможно, то сетка валидна
Шаг 4. Исходя из количества живых соседей текущей ячейки, проверьте правила игры и переключите текущую ячейку на живую, если она была мёртвой, на мёртвую, если она была живой, или на неизменную. Установите validGrid в true, если мутация выполнена: если это возможно, то сетка валидна
Шаг 5. Если сетка валидная, увеличьте значение счётчика поколений; в противном случае сообщите пользователю, что сетка невалидная. Наконец, установите сетку в качестве следующего поколения. Следующее поколение будет старой сеткой, но с изменениями на основе правил
Шаг 5. Если сетка валидная, увеличьте значение счётчика поколений; в противном случае сообщите пользователю, что сетка невалидная. Наконец, установите сетку в качестве следующего поколения. Следующее поколение будет старой сеткой, но с изменениями на основе правил

И это всё! Переходим к управлению.

Управление
Управление

Итак, первая кнопка здесь — «Step 1 Generation».

Реализовать кнопку довольно просто: у нас есть функция stepThroughAutomata. А ниже видим компонент Controls.

На строке 13 у нас есть первая кнопка. Мы просто добавляем свойство onClick к этой кнопке и передаём в него stepThroughAutomata:

На строке 22 у нас есть поле ввода, определяющее скорость итераций.

И, наконец, есть третья кнопка, значение которой — «Start» или «Stop» в зависимости от того, кликабельны ли отдельные ячейки. Если клетки кликабельны, то игра запущена. Если нет, игра не запущена.

Вы можете спросить: «Секундочку, когда я нажимаю кнопку запуска, функция stepThroughAutomata не запускается?». Да! Метод JS setInterval не очень хорошо работает с onClick. Поэтому для этой функциональности нужно было создать собственный хук. Посмотрим, как он работает:

GridContainer: чёрный ящик с магией
GridContainer: чёрный ящик с магией

Выше мы деструктурируем все данные из useGrid, но прямо под этим кодом вызываем другой пользовательский хук — useInterval с четырьмя параметрами. Это:

  1. Функция обратного вызова (здесь — stepThroughAutomata).

  2. Время между вызовами передаваемой функции в миллисекундах. Значение speedInput по умолчанию — 500.

  3. Текущая сетка.

  4. Булево значение, здесь clickable.

useInterval и магия хуков.
useInterval и магия хуков.

Мы создали хук useInterval потому, что встроенная функция setInterval не всегда хорошо сочетается с тем, как React перерисовывает компоненты. 

Нам нужен способ узнать, что сетка меняется, и, следовательно, сетка должна повторно отобразиться, а мы должны быть уверены, что она последовательно изменяется каждые n миллисекунд. Узнать это мы можем с помощью встроенного хука useRef. Сначала инициализируем savedCallback как ссылку.

Теперь воспользуемся useEffect, чтобы установить текущий savedCallback в качестве переданного обратного вызова. Это связано с тем, как setInterval подписывается на объект window и отписывается от него.

Будем обновлять savedCallback.current каждый раз, когда возвращаемое значение обратного вызова изменяется. Это должно происходить при каждом выполнении функции обратного вызова.

Второй вызов useEffect внутри useInterval.
Второй вызов useEffect внутри useInterval.

Переходим ко второму вызову useEffect. Сначала проверим, истинно ли clickable. Если это так, то не хочется запускать функцию внутри: такой запуск означает, что игра идёт прямо сейчас. Если clickable — ложно, это означает, что игра запускается впервые. Поэтому быстро инициализируем функцию tick, которая вызывает текущий сохранённый обратный вызов.

Сохраняем результат вызова setInterval, передавая tick и задержку, а затем сразу же отписываемся, используя анонимную функцию и выполняя clearInterval с передачей id.

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

Резюме:

  • Хуки позволяют писать чистый код, который можно использовать повторно.

  • Определение соседей путём сплющивания сетки в вектор и выполнение математических операций для поиска соседей дают пространственную сложность O(n).

  • Встроенная функция повторного рендеринга в React позволяет создать бесшовное представление UI игры «Жизнь».

Код на Github.

Продолжить изучение ReactJS вы сможете на наших курсах:

Другие профессии и курсы




К сожалению, не доступен сервер mySQL