Играем с холстом в Angular

Game of Life — Реализация Angular 13

Простая реализация на холсте Conways «Game of Life» с использованием Angular на Stackblitz.

вступление

Обычно я пишу об Excel, Power BI и обо всем, что связано с данными здесь, на Meidum.com (если позволяет время). А еще я давно хотел продолжить серию советов по походам в северную Европу. Ну, время… 😃

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

О чем это?

Игра жизни Конвея — это клеточный автомат, разработанный британским математиком Джоном Хортоном Конвеем в 1970 году. […]

Игра на самом деле является игрой с нулевым игроком, а это означает, что ее эволюция определяется ее начальным состоянием и не требует участия игроков-людей. Человек взаимодействует с Игрой Жизни, создавая первоначальную конфигурацию и наблюдая, как она развивается. — https://conwaylife.com/wiki/Conway%27s_Game_of_Life

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

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

Правила

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

- Любая живая ячейка с менее чем двумя живыми соседями умирает (называется недостаточное население или заражение).

- Любая живая ячейка с более чем тремя живыми соседями умирает (называется перенаселением или перенаселением).

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

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

Исходный паттерн представляет собой семя системы. Первое поколение создается путем одновременного применения приведенных выше правил к каждой ячейке семени — рождение и смерть происходят одновременно, и дискретный момент, в который это происходит, иногда называют тиком. (Другими словами, каждое поколение является чистой функцией предыдущего.) Правила продолжают многократно применяться для создания новых поколений.
https://conwaylife.com/wiki/Conway%27s_Game_of_Life

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

Угловой с использованием StackBlitz

Внимание: эта статья не является руководством для начинающих по Angular и не является руководством для начинающих по StackBlitz. Я предполагаю, что у вас есть хотя бы базовые знания об Angular, базовые знания JS/TS, а также вы потратите несколько минут на ознакомление со StackBlitz.

Почему Angular и StackBlitz?

Нет особой причины использовать Angular для этой задачи, кроме: мне просто нравится Angular. 😄 В принципе, для этой простой «Игры в жизнь» достаточно простого JS-файла. Но Angular дает мне возможность впоследствии очень легко развить все это до более сложной игры.

Почему StackBlitz? Потому что мне не хотелось настраивать Angular на своем компьютере для этого небольшого проекта. Кроме того, со StackBlitz я могу кодировать свой проект онлайн из любого места и в любое время.

Настройка

Перейдите на Stackblitz.com и запустите новый проект Angular (или создайте локальный проект на своем компьютере)

Исходный проект должен выглядеть так, как показано ниже.

Сначала давайте очистим начальный шаблон и дадим ему осмысленное имя.

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

Теперь StackBlitz должен сгенерировать для вас компонент Angular, состоящий из трех файлов.

Удалите HelloComponent из и зарегистрируйте сгенерированный выше GameBoardComponent в файле AppModule.

В файле app.component.html мы также можем удалить компонент <hello> и добавить наш компонент <app-game-board>.

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

После тонкой настройки (см. выше) вы должны увидеть следующий результат.

Игровое поле — холст

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

  • игра-board.component.css
  • game-board.component.html
  • игра-board.component.ts

Сначала давайте создадим элемент <canvas> в нашем файле game-board.component.html.

В конечном счете, здесь мы определяем только простой элемент холста HTML5 и устанавливаем ссылочную переменную шаблона Angular #gameboard. Эталонная переменная — это функция Angular для работы с холстом в нашем файле .ts позже.

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

Логика программы

Angular способ манипулирования DOM…

В Angular мы не можем (лучше: не должны) манипулировать DOM напрямую. Однако нам нужен способ доступа к нашему холсту. Способ Angular сделать это показан ниже — см. файл game-board.component.ts.

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

В качестве следующего шага я определил некоторые постоянные значения вне нашего компонента (1). Поскольку мы собираемся использовать эти значения несколько раз и не хотим возиться с «магическими числами», я думаю, что было бы неплохо определить их там. При необходимости мы можем провести рефакторинг позже.

Наиболее важной частью здесь является декоратор @ViewChild (2). Используя декоратор @ViewChild, мы можем вставить ссылку на наше поле холста из шаблона (html-файла). Если мы хотим применить некоторый код инициализации к ссылке, введенной @ViewChild, это нужно сделать внутри хука жизненного цикла AfterViewInit. В этом случае мы определяем высоту и ширину нашего игрового поля.

Для использования холста необходимы еще две строки кода.

Вы также можете увидеть две необязательные строки кода (1)выше. Они здесь только для того, чтобы проверить наш холст и нарисовать круг на нашей игровой доске. Если вы видите круг, вы можете удалить эти две строки кода.

Теперь мы готовы начать с фактической логики программы. 😃

Спасибо…

На данный момент спасибо каналу Youtube Hungry Turtle Code. Существенные части следующей основной логики программы очень вдохновлены его видео "Игра жизни в Javascript и холсте". Так что, если вы уже устали от Angular и т. д., вы можете просто посмотреть это видео о том, как этот умный парень кодирует Game of Life на простом JS.

Массив игрового поля…

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

[
  [1,0,1,0,1,0],   //row 1 with columns 1 to 6
  [0,1,1,1,0,0]    //row 2 with columns 1 to 6
]

Поскольку мы используем Typescript, я предлагаю определить два вспомогательных типа для нашего массива.

Первая задача состоит в том, чтобы равномерно разделить игровое поле, то есть пространство на объекте холста. Итак, вопрос: сколько столбцов и строк нужно нашему массиву. Теперь полезно, что мы ранее определили BOARD_HEIGHT, BOARD_WIDTH и RESOLUTION как константы.

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

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

Отлично, мы только что создали массив из 40 массивов, каждый из которых содержит 40 ячеек со значением 0 (мертвая ячейка).

Но, конечно, наше исходное состояние должно состоять не только из мертвых клеток, нам нужны и живые клетки. В этой версии игры «Жизнь» я хотел бы генерировать начальное состояние случайным образом. Одно простое решение для этого:

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

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

Для этого добавим вызов метода рендеринга в хук жизненного цикла ngAfterViewInit(). А затем объявите этот метод рендеринга. В качестве единственного параметра метод получает массив массивов битов, то есть массив нашего игрового поля.

Ниже приведено предложение кода.

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

как нам это сделать? Вот код.

Для каждой ячейки мы используем контекст холста, чтобы начать операцию рисования (1).

Затем мы определяем прямоугольник (2) в расчетной позиции.

В строке 3 мы определяем стиль заливки. Если ячейка живая (значение == 1), мы используем черную заливку; Если ячейка мертвая (значение == 0), мы используем белую заливку. (3)

Наконец, мы заполняем прямоугольник (4) указанным выше стилем и рисуем вокруг него рамку (5). Последняя строка (5) является необязательной. Просто попробуйте, нравится ли вам результат больше с этой линией или без нее.

Результат со сплошной границей…

…и без сплошных границ.

И вот код, который у нас есть на данный момент.

Большой! 😄 Время просчитать следующий шаг игры (лучше сказать: создать следующее поколение клеток). Для этого сначала создадим новый метод.

Что нам в основном нужно сделать в этом методе, так это скопировать текущее состояние нашего игрового поля (массива), а затем выполнить итерацию по текущему полю. После этого нам предстоит оценить каждую клетку по правилам игры и решить, будет ли она живой или мертвой клеткой в ​​следующем поколении. Затем мы записываем это новое состояние в наш новый массив (игровое поле) и, наконец, возвращаем вычисленное игровое поле.

Давайте на мгновение вспомним правила.

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

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

Если мы имеем дело с мертвой клеткой, то эта клетка только (!) станет живой клеткой в ​​следующем поколении, если у нее будет ровно три живых соседа (см. пример ниже).

В таблице ниже я попытался визуализировать правила более наглядно.

Правила можно закодировать следующим образом.

Единственное, что нам осталось сделать, это посчитать живые соседние клетки (countOfLivingNeighbors).

В большинстве случаев это легко: возьмите строку выше, возьмите строку ниже, возьмите столбцы помимо… готово. Но также могут быть крайние случаи, как показано ниже.

Если мы посмотрим на красную ячейку в левом верхнем углу, то увидим, что там всего три соседние клетки (желтые). Нет ни строки выше, ни столбца слева от ячейки. Преимущество JS в таких случаях заключается в том, что у массивов нет исключений за пределами границ. Если мы используем недопустимый индекс, мы просто получим undefined в качестве возвращаемого значения.

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

Написав const rowAbove = board[rowIndex-1] || [], мы гарантируем, что получим итерируемый (возможно, пустой) массив в любом случае, даже если над текущей нет строки. Функция сокращения в строке 21 просто суммирует все значения соседних ячеек. Поскольку значения ячеек могут быть только 0 или 1, сумма всегда равна количеству соседних живых ячеек.

Петля

Все это неплохо, но не хватает одного последнего шага, верно?

Верно, нам нужен код, который будет непрерывно отображать и вычислять следующее поколение ячеек. Поэтому мы рефакторим наш код следующим образом. Убираем вызов метода рендеринга из метода ngAfterViewInit(). Вместо этого мы создаем новый метод animate(), который вызываем из ngAfterViewInit().

Метод animate() содержит вызов requestAnimationFrame(). Вы можете узнать больше о requestAnimationFrame в веб-документах MDN. В нашем случае мы позволяем requestAnimationFrame() вызывать animate(), чтобы получить что-то вроде цикла.

После небольшого рефакторинга методов render() и createNextGeneration() мы можем завершить наш код следующим образом.

И результат должен выглядеть как GIF ниже.

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

Вы можете просмотреть и попробовать код в StackBlitz (ссылка ниже).



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

https://gameoflifeextendedii.stackblitz.io