Играйте си с canvas в Angular

Game of Life — внедряване на Angular 13

Проста реализация на платно на Conways „Game of Life“ с помощта на Angular в Stackblitz

въведение

Обикновено пиша за Excel, Power BI и всякакви данни, свързани тук на Meidum.com (доколкото времето ми позволява). Освен това исках да продължа поредицата си със съвети за туризъм в Северна Европа за дълго време. Е, време… 😃

Но днес бих искал да се отклоня в съвсем различна посока, защото някои теми са толкова завладяващи, че просто трябва да пиша за тях.
Тъй като бях в карантина (докато пишех това) и имах нужда от разсейване, станах наясно с математика Джон Хортън Конуей и неговата Игра на живота», докато сърфирате в YouTube. И тази „Игра на живота“ беше толкова завладяваща, че трябваше да програмирам основна версия за мрежата в Angular 13.

За какво става дума?

Conway’s Game of Life е клетъчен автомат, създаден от британския математик John Horton Conway през 1970 г. […]

„Играта“ всъщност е игра с нулев играч, което означава, че нейната еволюция се определя от първоначалното й състояние, без да се нуждае от принос от човешки играчи. Човек взаимодейства с Играта на живота, като създава първоначална конфигурация и наблюдава как тя се развива. — https://conwaylife.com/wiki/Conway%27s_Game_of_Life

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

Разбирам, че има много математически аспекти зад играта на живота. Но признавам, че не ги разбирам и не искам да ги разбирам и не мога да ги обясня. Кодирането на играта обаче е относително лесно, тъй като има само четири основни правила. И така, нека започнем...

Правилата

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

- Всяка жива клетка с по-малко от двама живи съседи умира (наричано недостатъчно население или излагане).

- Всяка жива клетка с повече от три живи съседи умира (наричано пренаселеност или пренаселеност).

- Всяка жива клетка с двама или трима живи съседи живее непроменена до следващото поколение.

- Всяка мъртва клетка с точно трима живи съседи ще оживее.

Първоначалният модел представлява „семето“ на системата. Първото поколение се създава чрез прилагане на горните правила едновременно към всяка клетка в семето — ражданията и смъртта се случват едновременно и дискретният момент, в който това се случва, понякога се нарича тик. (С други думи, всяко поколение е чиста функция на предишното.) Правилата продължават да се прилагат многократно за създаване на следващи поколения.
https://conwaylife.com/wiki/Conway%27s_Game_of_Life

За да опростим нещата, нека променим едно правило, ние не използваме безкрайна мрежа, ние използваме крайна двуизмерна мрежа.

Angular с помощта на 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> компонент.

Съобщенията за грешка в десния прозорец на браузъра трябва да изчезнат. Ако не, просто натиснете бутона за опресняване на браузъра си и изчакайте малко.

След известна фина настройка (вижте по-горе) трябва да видите следния резултат.

Игралната дъска — платно

Отсега нататък можем да се съсредоточим върху нашата игрална дъска. Ще работим по файловете:

  • game-board.component.css
  • game-board.component.html
  • game-board.component.ts

Първо нека създадем елемент <canvas> в нашия файл game-board.component.html.

В крайна сметка тук дефинираме само прост HTML5 canvas елемент и задаваме референтна променлива за Angular шаблон #gameboard. Референтната променлива е функция на Angular за работа с платното в нашия .ts файл по-късно.

В css файла също няма какво да се прави. Определих черна граница около елемента платно, за да можем да видим по-добре какво се случва тук. Но можете да пропуснете тази стъпка или да приложите стил, който предпочитате, но не дефинирайте височина или ширина.

Логиката на програмата

Ъгловият начин за DOM-манипулация...

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

Какво става тук? Е, в горната част на нашия файл имаме някои импортирания, на Angular core интерфейси и класове, нищо особено.

Като следваща стъпка дефинирах някои постоянни стойности извън нашия компонент (1). Тъй като ще използваме тези стойности многократно и не искаме да се забъркваме с „магически числа“, мисля, че е добра идея да ги дефинираме там. Ако е необходимо, можем да го преработим по-късно.

Най-важната част тук е декораторът @ViewChild (2). С помощта на декоратора @ViewChild можем да инжектираме препратка към нашето поле на платното от шаблона (html файл). Ако искаме да приложим някакъв инициализиращ код към препратка, която е инжектирана от @ViewChild, това трябва да бъде направено в куката на жизнения цикъл AfterViewInit. В този случай ние определяме височината и ширината на нашата игрална дъска.

За да използвате платното, са необходими още два реда код.

Можете също така да видите два незадължителни реда код (1)по-горе. Те са просто там, за да тестват нашето платно и да начертаят кръг върху игралната ни дъска. Ако видите кръг, можете да изтриете тези два реда код.

Сега сме готови да започнем с действителната програмна логика. 😃

Благодаря на...

В този момент благодарение на Youtube канала „Код на гладна костенурка“. Основни части от следната основна програмна логика са силно вдъхновени от неговото видео The Game Of Life In Javascript and Canvas». Така че, ако вече ви е писнало от 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 (мъртва клетка).

Но, разбира се, нашето първоначално състояние не трябва да се състои само от мъртви клетки, ние също се нуждаем от живи клетки. В тази версия на Game of Life бих искал да генерирам първоначалното състояние на случаен принцип. Едно лесно решение за това е:

Събирайки всичко заедно, кодът трябва да изглежда по-долу:

Тъй като записите изглеждат доста добре, можем да опитаме да начертаем нашата първоначална игрална дъска върху платното.

За да направите това, нека добавим извикване към метод за рендиране в куката на жизнения цикъл 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 Web Docs. В нашия случай оставяме requestAnimationFrame() да извика animate(), така че да получим нещо като цикъл.

След малък рефакторинг на методите render() и createNextGeneration() можем да завършим нашия код, както следва.

И резултатът трябва да изглежда като GIF по-долу.

С увеличаването на височината и ширината на платното се увеличават шансовете за продължителни игри. В GIF-а по-долу играта работи повече от една минута в моя браузър.

Можете да видите и изпробвате кода в StackBlitz (връзка по-долу).



Създадох и друга версия, която има много повече опции и ви позволява сами да рисувате фигури на дъската.

https://gameoflifeextendedii.stackblitz.io