Розділ 7. Клітинні автомати

Поодинці ми одна крапля. Разом ми океан.

— Рюноске Сатору

Тканина кенте (фото ZSM)
Тканина кенте (фото ZSM)

Тканина кенте походить від народу Акан із Гани — це тканина, яка славиться своїми яскравими кольорами та заплутаними візерунками. Кожний дизайн, витканий у вигляді вузьких смужок, унікальний, а коли смужки з’єднуються, вони утворюють гобелен зі складних і емерджентних візерунків, які розповідають історію або несуть повідомлення. На зображенні показано три типові смужки кенте, що підкреслюють різноманітні ткацькі традиції, які відображають багату культуру гобеленів Гани.


У Розділі 5 я визначив складну систему як мережу елементів із короткочасними зв’язками, що працюють паралельно та демонструють емерджентну поведінку. Я створив симуляцію зграї, щоб продемонструвати, що складна система проявляє себе дещо більше, ніж проста сума її складових. У цьому розділі я збираюся розробити інші складні системи, відомі як клітинні автомати.

У деяких аспектах ці приклади можуть здатися кроком назад. Окремі елементи моїх систем більше не будуть членами фізичного світу, керуватися силами й векторами чи рухатися по полотну. Натомість я створюватиму системи з найпростішої можливої цифрової одиниці: одного біта. Цей біт називається клітиною, а його значення (0 або 1) називається його станом. Робота з такими простими елементами допоможе зрозуміти, як працюють складні системи й дасть можливість детальніше розібратися в деяких техніках програмування, які застосовуються до проєктів, заснованих на коді. Побудова клітинних автоматів також стане основою для решти книги, де я все більше зосереджуватимусь на системах і алгоритмах, а не на векторах і русі, хоча ці системи й алгоритми я можу і буду застосовувати до рухомих тіл.

Що таке клітинний автомат?

Клітинний автомат або скорочено КА — це модель системи клітинних об’єктів із наступними характеристиками:

  • Клітини живуть у сітці. (У цьому розділі я наведу приклади з одномірною та двомірною сітками, хоча КА може існувати у будь-якій вимірності.)
  • Кожна клітина має стан, який може змінюватися з часом. Кількість можливих станів, як правило, обмежена. У найпростішому прикладі їх два: 1 і 0 (інакше їх називають увімкнений і вимкнений, або живий та неживий).
  • Кожна клітинка має околицю. Вона може визначатися різними способами, але зазвичай маються на увазі усі клітини, суміжні з поточною.

Важливо підкреслити, що клітини в КА не належать до біологічних клітин (хоча ви побачите, як КА може імітувати реалістичну поведінку та мати застосування в біології). Натомість вони просто представляють окремі одиниці у сітці, подібно до клітинок в електронній таблиці (як у Microsoft Excel). На малюнку 7.1 показано КА та його різні характеристики.

Друга особливість КА, яку я назвав — це важлива ідея про те, що стан комірки може змінюватися з часом. Досі в цій книзі об’єкти (блукачі, частинки, боїди, тіла тощо) загалом існували лише в одному стані. Можливо, вони рухалися зі складною поведінкою та фізикою, але зрештою вони залишалися тим самим типом об’єктів протягом свого цифрового життя. Я натякав на можливість того, що ці сутності можуть змінюватися з часом (наприклад, вага певних поведінок може змінюватись), але я не повністю застосував це на практиці. Тепер за допомогою КА ви побачите, як стан об’єкта може змінюватися на основі системи правил.

Малюнок 7.1: 2D сі�тка клітинок, кожна зі станом увімкнено або вимкнено. Околиця — це частина великої сітки, яка зазвичай складається з усіх клітин, суміжних із поточною клітиною (обведено кружком)
Малюнок 7.1: 2D сітка клітин, кожна зі станом увімкнено або вимкнено. Околиця — це частина великої сітки, яка зазвичай складається з усіх клітин, суміжних із поточною клітиною (обведено кружком)

Розробку систем КА зазвичай приписують Станіславу Уламу та Джону фон Нейману — обидва були дослідниками в національній лабораторії Лос-Аламоса у Нью-Мексико в 1940-х роках. Улам вивчав ріст кристалів, а фон Нейман розглядав світ самовідтворюваних роботів. Ви правильно прочитали: роботів, які можуть створювати власні копії.

Оригінальні клітини фон Неймана мали 29 можливих станів, тому, можливо, ідея самовідтворюваних роботів буде надто складною для відправної точки. Замість цього уявіть ряд доміно. Кожне доміно може перебувати в одному з двох станів: стояти вертикально (1) або бути поваленим (0). Подібно до того, як доміно реагують на свої сусідні доміно, на поведінку кожної клітини у КА впливає стан сусідніх клітин.

У цьому розділі досліджується, як навіть найпростіші правила гри в доміно можуть призвести до широкого спектра складних моделей і поведінки, подібних до природних процесів, таких як біологічне розмноження та еволюція. Робота фон Неймана в галузі самовідтворення та КА концептуально схожа на, ймовірно, найвідоміший КА під назвою Гра Життя, який я детально обговорю пізніше в цьому розділі.

Мабуть, найзначніша (і тривала) наукова праця, присвячена дослідженню КА, була опублікована у 2002 році під авторством Стівена Вольфрама: A New Kind of Science (1280 сторінок). У книзі Вольфрама, безплатно доступній у повному обсязі в інтернеті, розповідається про те, що клітинні автомати це не просто хитрі трюки, а вони є актуальними для вивчення біології, хімії, фізики та всіх галузей науки. За мить я перейду до створення симуляції роботи Вольфрама, хоча я ледь торкнуся поверхні теорій, які він окреслює — я зосереджуся на реалізації коду, а не на філософських наслідках. Якщо ці приклади викликають вашу цікавість, ви знайдете ще багато про що прочитати в книзі Вольфрама, а також у його поточних дослідженнях у рамках Проєкту фізики Вольфрама.

Елементарні клітинні автомати

Який найпростіший КА ви можете собі уявити? Для Вольфрама елементарний КА має три ключові елементи:

  • Сітка
  • Стани
  • Околиця

Найпростішою сіткою буде одномірна: лінія клітин (малюнок 7.2).

Малюнок 7.2: 1D лінія клітин
Малюнок 7.2: Одномірна лінія клітин

Найпростішим набором станів (крім наявності лише одного стану) є два стани: 0 або 1 (малюнок 7.3). Початкові стани можуть встановлюватися випадковим чином.

Малюнок 7.3: 1D лінія клітин, позначених станами 0 або 1. Яка знайома структура даних програмування може представляти цю послідовність?
Малюнок 7.3: Одномірна лінія клітин, позначених станами 0 або 1. Яка знайома структура даних програмування може представляти цю послідовність?

Найпростішою околицею в одному вимірі для будь-якої даної клітини буде сама комірка та два її сусіди: один ліворуч і один праворуч (малюнок 7.4). Мені доведеться вирішити, що я хочу робити з крайніми комірками сітки ліворуч і праворуч, оскільки кожна з них має лише по одному сусіду, але я можу розібратися з цими питанням пізніше.

Малюнок 7.4: Околиця в одному вимірі складається з трьох клітинок
Малюнок 7.4: Околиця в одному вимірі складається з трьох клітинок

У мене є ряд клітин, кожна з яких має початковий стан і кожна з двома сусідами. Найцікавіше те, що навіть із цим найпростішим КА, який тільки можна собі уявити, можуть проявлятися властивості складних систем. Але я ще не обговорював, мабуть, найважливішу деталь того, як працює КА: його зміни з часом.

Я не говорю тут про реальний час, а скоріше про КА, що змінюється і розвивається через серію окремих часових кроків, які також можна назвати поколіннями. У випадку КА у програмі p5.js час, ймовірно, буде прив’язаний до кількості кадрів анімації. Питання, зображене на малюнку 7.5, наступне: якщо у початковій стадії (покоління 0) клітини мають певний стан, як мені обчислити стан для всіх клітин у наступному поколінні 1? І як тоді я можу перейти від покоління 1 до покоління 2 і так далі?

Малюнок 7.5: Стани для покоління 1 обчислюються з використанням станів клітин з покоління 0
Малюнок 7.5: Стани для покоління 1 обчислюються з використанням станів клітин з покоління 0

Скажімо, КА має окрему клітину, що називається cell\text{cell}. Формула для розрахунку стану клітини в будь-який момент часу tt (cellt\text{cell}_t) виглядає наступним чином:

cellt=f(cell neighborhoodt1)\text{cell}_t = f(\text{cell neighborhood}_{t-1})

Іншими словами, новий стан клітини є функцією всіх станів у сусідніх клітинах із попереднього покоління (що відповідає часу t1t - 1). Нове значення стану обчислюється шляхом перегляду сусідніх станів попереднього покоління (малюнок 7.6).

Малюнок 7.6: Стан клітини із покоління 1 є функцією сусідніх станів із попереднього покоління
Малюнок 7.6: Стан клітини із покоління 1 є функцією сусідніх станів із попереднього покоління

Ви можете обчислити стан клітинки на основі станів її сусідів різними способами. Розгляньте процес розмиття зображення. (Вгадайте що? Обробка зображень працює за правилами, подібними до КА!) Новий стан пікселя (його колір) є середнім значенням кольорів його сусідів. Подібним чином новий стан клітини може бути сумою станів усіх її сусідів. Однак в елементарному КА Вольфрама цей процес використовує інший підхід: замість математичних операцій нові стани визначаються заздалегідь визначеними правилами, які враховують кожну можливу конфігурацію клітини та її сусідів. Ці правила відомі під загальною назвою набір правил (ruleset).

Спочатку такий підхід може здатися сміховинним — чи не буде забагато можливостей для його практичності? Що ж, спробуймо. Околиця складається з трьох клітинок, кожна зі станом 0 або 1. Скількома можливими способами можна налаштувати стани околиці? Швидкий спосіб з’ясувати це — подумати про кожну конфігурацію сусідства як про бінарне число. Бінарні числа мають основу, що дорівнює 2, тобто вони представлені лише двома можливими цифрами (0 і 1). У цьому випадку кожна конфігурація сусідства відповідає 3-бітовому числу, а скільки значень можна представити за допомогою 3-х біт? Вісім, від 0 (000) до 7 (111). На малюнку 7.7 показано, як.

Малюнок 7.7: Підрахунок 3-х бітів у двійковій системі або вісім можливих конфігурацій оточення із трьох клітинок
Малюнок 7.7: Підрахунок 3-х бітів у двійковій системі або вісім можливих конфігурацій оточення із трьох клітинок

Після визначення всіх можливих комбінацій сусідства для кожної такої конфігурації вказується результат (нове значення стану: 0 або 1). В оригінальній нотації Вольфрама й інших поширених варіаціях ці конфігурації записані в порядку спадання. Малюнок 7.8 слідує цій умові, починаючи зі 111 і ведучи зворотний відлік до 000.

Малюнок 7.8: Набір правил показує результат для кожної можливої конфігурації з трьох клітин
Малюнок 7.8: Набір правил показує результат для кожної можливої конфігурації з трьох клітин

Майте на увазі, що на відміну від методів додавання або усереднення, набори правил в елементарному КА не дотримуються жодної арифметичної логіки — це лише довільне відображення вхідних і вихідних даних. Вхідними даними є поточна конфігурація околиці (одна з восьми можливостей), а виходом є наступний стан середньої клітини поточної околиці (0 або 1 — ви самі визначаєте це правило).

Отримавши набір правил, ви можете запустити КА у дію. Стандартна модель Вольфрама полягає в тому, щоб розпочати покоління 0 із того, що всі клітини мають стан 0, за винятком середньої, яка має мати стан 1. Ви можете зробити це із сіткою будь-якого розміру (довжини), але для ясності я використаю одномірний КА з дев’яти клітин, щоб легко було вибрати середину (див. малюнок 7.9).

Малюнок 7.9: Покоління 0 у КА Вольфрама з центральною клітиною із увімкненим станом
Малюнок 7.9: Покоління 0 у КА Вольфрама із центральною клітиною з увімкненим станом

Виходячи з набору правил на малюнку 7.8, як клітини змінюються від покоління 0 до покоління 1? На малюнку 7.10 показано, як центральна комірка з околицею 010 перемикається з 1 на 0. Спробуйте застосувати набір правил до решти клітин, щоб заповнити решту станів покоління 1.

Малюнок 7.10: Визначення стану для покоління 1 за допомогою набору правил КА
Малюнок 7.10: Визначення стану для покоління 1 за допомогою набору правил КА

Тепер невелика зміна: замість представлення станів клітин за допомогою 0 і 1, я позначатиму їх візуальними ознаками — білим для 0 і чорним для 1 (див. малюнок 7.11). Хоча це може здатися суперечливим, оскільки 0 зазвичай означає чорний колір у комп’ютерній графіці, я використовую цю конвенцію, оскільки приклади в цій книзі мають білий фон, тому “увімкнення” клітини відповідатиме зміні її кольору з білого на чорний.

Малюнок 7.11: Біла клітинка означає стан 0, а чорна — 1
Малюнок 7.11: Біла клітинка означає стан 0, а чорна — 1

З цим переходом від числових представлень до візуальних форм ви побачите захопливу динаміку та патерни КА! Щоб показати їх ще наочніше, замість того, щоб малювати лише одне покоління за раз, я почну складати їх у стопки, причому кожне нове покоління з’являтиметься під попереднім, як показано на малюнку 7.12.

Малюнок 7.12: Перетворення сітки з 0 і 1 на білі та чорні квадрати
Малюнок 7.12: Перетворення сітки з 0-ми й 1-ми на білі та чорні квадрати

Фігура з низькою роздільною здатністю, яка показана на малюнку 7.12, є трикутником Серпінського. Він названий на честь польського математика Вацлава Серпінського і це також відомий приклад фракталу. Я докладніше розгляну фрактали у Розділі 8, але, коротко кажучи, це моделі, в яких ті самі фігури повторюються в різних масштабах. Щоб краще зрозуміти це, на малюнку 7.13 показано КА з більшою кількістю поколінь і більшим розміром сітки.

Малюнок 7.13: Елементарний КА Вольфрама
Малюнок 7.13: Елементарний КА Вольфрама

На малюнку 7.14 знову показано КА, цього разу з клітинами шириною лише в один піксель і набагато вищою роздільною здатністю.

Малюн�ок 7.14: Елементарний КА Вольфрама з вищою роздільною здатністю
Малюнок 7.14: Елементарний КА Вольфрама з вищою роздільною здатністю

Візьміть хвилинку, щоб зрозуміти величність того, що ви щойно побачили. Використовуючи неймовірно просту систему нулів і одиниць з невеликими околицями з трьох клітинок, я зміг створити таку ж складну та детальну форму, як трикутник Серпінського. У цьому краса складних систем.

Звичайно, цей конкретний результат стався не випадково. Для малюнку 7.8 я вибрав конкретний набір правил, тому що знав який патерн він створить. Простий акт визначення набору правил не гарантує візуально захопливих результатів. Насправді для одномірного КА, в якому кожна клітина може мати два можливих стани, існує рівно 256 можливих наборів правил і лише кілька з них відповідають трикутнику Серпінського. Звідки я знаю, що існує 256 можливих наборів правил? Це можна порахувати за допомогою бінарної математики.

Визначення наборів правил

Погляньте на малюнок 7.7 і знову зверніть увагу на вісім можливих конфігурацій сусідства, від 000 до 111. Це вхідні конфігураційні дані для набору правил і вони залишаються постійними для різних наборів правил. Лише вихідні дані відрізняються від одного набору правил до іншого — індивідуальний 0 або 1 у парі з кожною конфігурацією сусідства. На малюнку 7.8 зображено набір правил, що містить нулі та одиниці. На малюнку 7.15 показано той самий набір правил, візуалізований білими й чорними квадратами.

Малюнок 7.15: Представлення набору правил (такого самого як на малюнку 7.8) з білими й чорними квадратами
Малюнок 7.15: Представлення набору правил (такого самого як на малюнку 7.8) з білими й чорними квадратами

Оскільки вісім можливих вхідних комбінацій є однаковими, незважаючи ні на що, можливим скороченням для позначення набору правил є визначення лише вихідних значень із записом їх у вигляді послідовності з восьми 0 або 1, іншими словами, 8-бітного бінарного числа. Наприклад, набір правил на малюнку 7.15 може бути записаний як 01011010. Правий 0 відповідає вхідній конфігурації 000, 1 поруч із нею відповідає до 001 і так далі. На вебсайті Вольфрама правила КА проілюстровано за допомогою комбінації цього двійкового скорочення та представлення чорно-білих квадратів, що дає зображення, як на малюнку 7.16.

Малюнок 7.16: Як вебсайт Вольфрама представляє набір правил
Малюнок 7.16: Як вебсайт Вольфрама представляє набір правил

Я вже казав, що кожен набір правил можна звести до 8-бітного числа, тож скільки тоді існує комбінацій із восьми 0 та 1? Їх рівно 282^8 або 256. Можливо, ви пам’ятаєте про таку саму кількість значень при налаштуванні каналів для RGB-кольорів у p5.js. Коли ви пишете background(r, g, b), кожен компонент кольору (червоний, зелений і синій) представляється 8-бітним числом у діапазоні від 0 до 255 у десятковій чи від 00000000 до 11111111 у двійковій системі числення.

Набір правил на малюнку 7.16 можна назвати правилом 01011010, але Вольфрам натомість називає його правилом 90. Звідки береться 90? Щоб зробити найменування набору правил ще більш лаконічним, Вольфрам використовує десяткове представлення чисел, а не двійкове. Щоб назвати правило, ви перетворюєте його 8-розрядне двійкове число в десяткове. Двійкове число 01011010 перетворюється на десяткове число 90, тому воно і називається правилом 90.

Оскільки існує 256 можливих комбінацій з восьми 0 і 1, існує також 256 унікальних наборів правил. Перевірмо ще один. Як щодо правила 11011110 або, інакше кажучи, правила 222? На малюнку 7.17 показано, як виглядає результат.

Малюнок 7.17: Елементарний КА Вольфрама, правило 222
Малюнок 7.17: Елементарний КА Вольфрама, правило 222
Малюнок 7.18: Черепашка конуса текстильного — конічного молюска (Conus textile), Великий Бар’єрний риф, Австралія. (фото Річарда Лінга)
Малюнок 7.18: Черепашка конуса текстильного — конічного молюска (Conus textile), Великий Бар’єрний риф, Австралія. (фото Річарда Лінга)

У результаті маємо впізнавану форму, хоча вона, звичайно, не така захоплива, як трикутник Серпінського. Як я вже говорив раніше, більшість із 256 елементарних наборів правил не дають привабливих результатів. Проте все одно неймовірно, що навіть кілька з цих наборів правил — простих систем клітин лише з двома можливими станами — можуть створювати захопливі візерунки, які можна побачити в природі щодня. Наприклад, на малюнку 7.18 показана черепашка молюска, що нагадує правило 30. Це демонструє, наскільки цінними можуть бути КА-ти у моделюванні та створенні патернів.

Перш ніж зайти надто далеко в опис результатів різних наборів правил, подивімось, як створити програму p5.js, яка генерує та візуалізує елементарний КА Вольфрама.

Програмування елементарного КА

Ви можете подумати: “Гаразд, у мене є клітина. І вона має такі властивості, як стан, покоління до якого вона належить, хто її сусіди та де вона знаходиться на екрані. І, можливо, вона має функції, для зображення себе та визначення свого нового стану”. Це чудовий напрямок думок і, ймовірно, він приведе до написання подібного коду:

class Cell {


}

Однак це не та дорога, якою я хочу піти. Далі у цьому розділі я обговорю, чому об’єктно-орієнтований підхід може виявитися цінним у розробці симуляції КА, але для початку легше працювати з більш елементарною структурою даних. Зрештою, що таке елементарний КА, як не список із 0 і 1? Чому б не описати створення одномірного КА за допомогою масиву?

let cells = [1, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0];

Цей масив відповідає рядку клітин, зображеному на малюнку 7.19.

Малюнок 7.19: Одне покоління одномірного КА
Малюнок 7.19: Одне покоління одномірного КА

Щоб зобразити цей масив, я перевіряю значення кожного елементу відповідно до 0 чи 1, вибираю відповідний колір заливки й малюю прямокутник:

for (let i = 0; i < cells.length; i++) {

Ітерація через кожну клітину.

  if (cells[i] === 0) {
    fill(255);
  } else {
    fill(0);
  }

Створення заливки залежно від стану клітини (0 або 1).

  stroke(0);

  rect(i * 50, 0, 50, 50);

}

Масив описує стан клітини у поточному поколінні. Тепер мені потрібен механізм для обчислення станів наступних поколінь. Ось псевдокод, який описує, що я хочу досягти:

Для кожної клітини у масиві:

  1. Перегляньте сусідні стани: ліворуч, посередині, праворуч.
  2. Знайдіть нове значення для стану клітини відповідно до набору правил.
  3. Установіть для клітини знайдене нове значення.

Цей псевдокод може навіяти написання наступного коду:

for (let i = 0; i < cells.length; i++) {

Для кожної клітини у масиві...

  let left   = cells[i - 1];
  let middle = cells[i];
  let right  = cells[i + 1];

...переглянути її околицю.

  let newstate = rules(left, middle, right);

Знайти нове значення відповідно до правил.

  cells[i] = newstate;

Установити для клітини знайдене нове значення.

}

Я досить близький до того, щоб зробити це правильно, але маю вирішити кілька проблем. По-перше, я передаю обчислення нового значення для стану функції під назвою rules(). Очевидно, що мені доведеться написати цю функцію, тому моя робота ще не закінчена, але я прагну тут до модульності. Мені потрібен цикл for, який забезпечує організацію для базового керування будь-яким КА, незалежно від конкретного набору правил. Якщо я хочу випробувати різні набори правил, мені взагалі не доведеться торкатися цієї частини. Я можу просто переписати функцію rules(), щоб по-іншому обчислювати нові стани.

Тож мені все ще потрібно написати функцію rules(), але важливіше те, що я припустився однієї незначної та однієї великої помилки в циклі for. Перегляньте код уважніше.

По-перше, зверніть увагу, як легко дивитися на сусідів клітини. Оскільки масив — це впорядкований список даних, я можу використовувати нумерацію індексів, щоб дізнатися, які клітини поруч із якими. Наприклад, я знаю, що клітина номер 15 ліворуч має клітину 14 і 16 — праворуч. Загалом я можу сказати, що для будь-якої клітини i її сусідами є i - 1 та i + 1.

То що я зробив не так? Подумайте про те, як буде виконуватися код. Під час першого проходження циклу індекс клітинки i дорівнює 0. Код хоче переглянути сусідів клітини під індексом 0. Зліва це i - 1 або -1. Ой! Масив за визначенням не має елемента з індексом -1. Він починається з 0.

Я згадував про цю проблему граничних випадків раніше в цьому розділі й сказав, що можу потурбуватися про це пізніше. Це пізніше вже зараз. Як мені обробляти клітини на межі, які не мають сусіда зліва чи справа? Ось три можливі вирішення цього питання:

  1. Краї залишаються незмінними. Це, мабуть, найпростіше рішення. Не турбуйтеся про переоцінку країв і завжди залишайте значення їх стану постійними (0 або 1).
  2. Краї згортаються один до одного. Уявіть КА як смужку паперу та перетворіть цю смужку на кільце. Клітина з лівого краю стає сусідом клітини з правого краю, і навпаки. Це може створити враження нескінченної сітки, і це, мабуть, найпоширеніше рішення.
  3. Краї мають різні околиці та правила. Якби я захотів, я міг би обробляти клітинки на краях по-іншому та створити правила для осередків, які мають околиці у дві, а не три клітини. Ви можете зробити це за деяких обставин, але в цьому випадку потрібно буде багато додаткових рядків коду з невеликою користю.

Щоб зробити код найпростішим для читання та розуміння прямо зараз, я виберу варіант 1 і пропущу крайні клітини, залишивши їх значення незмінними. Це можна зробити, якщо почати цикл на одну клітину пізніше і завершити його на одну клітину раніше:

for (let i = 1; i < cells.length - 1; i++) {
  let left   = cells[i - 1];
  let middle = cells[i];
  let right  = cells[i + 1];
  let newstate = rules(left, middle, right);
  cells[i] = newstate;
}

Цикл, який ігнорує першу та останню клітини.

Мені потрібно вирішити ще одну проблему і її ідентифікація є абсолютно фундаментальною для методів програмування моделювання КА. Проблема незначна і не викликає помилку, КА просто не працюватиме належним чином. Проблема знаходиться у цьому рядку коду:

  cells[i] = newstate;

Це може здатися абсолютно невинним. Зрештою, коли я обчислив нове значення стану, я хочу призначити комірці її новий стан. Але подумайте про наступну ітерацію циклу for. Припустімо, новий стан клітини 5 щойно обчислено, і цикл переходить до клітини 6. Що станеться далі?

Клітина 6, покоління 1 = функція станів клітин 5, 6 і 7 із покоління 0

Новий стан клітини є функцією попередніх сусідніх станів, тому в цьому випадку значення клітини 5 у поколінні 0 потрібне для обчислення нового стану клітини 6 у поколінні 1. Чи зберіг я значення клітини 5 із покоління 0? Ні! Пам’ятайте, що цей рядок коду було щойно виконано, коли i дорівнював 5:

  cells[i] = newstate;

Якщо це станеться, то стан клітини 5 у поколінні 0 зникне і cells[5] тепер зберігатиме значення для покоління 1. Я не можу перезаписувати значення в масиві під час обробки масиву, тому що мені потрібні ці значення для обчислення нових значень!

Розв’язання цієї проблеми полягає в тому, щоб мати два масиви, один для зберігання станів поточного покоління, а інший для станів наступного покоління. Щоб спростити собі повторну ініціалізацію масиву, я скористаюся методом масиву slice(), який створить копію масиву:

let newcells = cells.slice();

Створення іншого масиву для зберігання станів для наступного покоління.

for (let i = 1; i < cells.length - 1; i++) {

  let left   = cells[i - 1];
  let middle = cells[i];
  let right  = cells[i + 1];

Перегляд станів поточного масиву.

  let newstate = rules(left, middle, right);

  newcells[i] = newstate;

Збереження нового стану у новому масиві.

}

Після повної обробки масиву значень поточного покоління, для змінної cells можна призначити новий масив станів, фактично відкидаючи значення попереднього покоління:

cells = newcells;

Нове покоління стає поточним.

Я майже закінчив, але мені потрібно ще визначити функцію rules(), яка обчислюватиме нове значення стану на основі околиці (лівої, середньої й правої клітини). Я знаю, що функція має повертати ціле число (0 або 1), а також отримувати три аргументи (для трьох сусідів):

  function rules(a, b, c) { return _______ }

Сигнатура функції: отримує 3 цілі числа і повертає одне.

Я міг би написати цю функцію різними способами, але я хотів би почати з розгорнутого варіанту, який, сподіваюся, надасть чітку ілюстрацію того, що відбувається. Як зберігати набір правил? Пам’ятайте, що набір правил — це набір із 8 бітів (0 або 1), які визначають результат для кожної можливої конфігурації сусідства. Якщо вам потрібно пригадати, малюнок 7.20 показує нотацію Вольфрама для набору правил трикутника Серпінського, а також відповідні нулі та одиниці. Це повинно дати вам підказку щодо структури даних, яку я маю на увазі!

Малюнок 7.20: Візуальне представлення набору правил Вольфрама із цифровим кодуванням
Малюнок 7.20: Візуальне представлення набору правил Вольфрама із цифровим кодуванням

Я можу зберегти цей набір правил у масиві:

let ruleset = [0, 1, 0, 1, 1, 0, 1, 0];

А потім я можу написати, наприклад, наступне:

if (a === 1 && b === 1 && c === 1) return ruleset[0];

Якщо клітина ліворуч, посередині та праворуч одночасно мають стан 1, то це відповідає конфігурації 111, тому новий стан має дорівнювати першому значенню в масиві ruleset. Дублювання цієї стратегії для всіх восьми можливостей виглядає наступним чином:

  function rules(a, b, c) {

    if      (a === 1 && b === 1 && c === 1) return ruleset[0];

    else if (a === 1 && b === 1 && c === 0) return ruleset[1];

    else if (a === 1 && b === 0 && c === 1) return ruleset[2];

    else if (a === 1 && b === 0 && c === 0) return ruleset[3];

    else if (a === 0 && b === 1 && c === 1) return ruleset[4];

    else if (a === 0 && b === 1 && c === 0) return ruleset[5];

    else if (a === 0 && b === 0 && c === 1) return ruleset[6];

    else if (a === 0 && b === 0 && c === 0) return ruleset[7];

  }

Мені подобається писати функцію rules() таким чином, оскільки вона рядок за рядком точно описує, що відбувається для кожної конфігурації сусідства. Однак це не краще рішення. Зрештою, що, якщо замість двох станів КА матиме чотири можливі стани (від 0 до 3)? Раптом буде 64 можливі конфігурації сусідства. А якщо буде 10 можливих станів чи 1000 конфігурацій. Тільки уявіть собі програмування 29 можливих станів фон Неймана. Я б застряг, друкуючи тисячі й тисячі операторів else...if!

Іншим рішенням, хоча й не таким прозорим, є перетворення конфігурації сусідства (3-бітового числа) у звичайне ціле число та використання цього значення як індексу в масиві набору правил. Це можна зробити, використовуючи вбудовану JavaScript функцію parseInt():

  function rules(a, b, c) {

    let s = "" + a + b + c;

Швидкий спосіб об’єднати три числа у рядок.

    let index = parseInt(s, 2);

Двійка у другому аргументі вказує на те, що число має бути проаналізовано як двійкове (з основою 2).

    return ruleset[index];

  }

Однак у цього рішення є одна маленька проблема. Розглянемо правило 222:

let ruleset = [1, 1, 0, 1, 1, 1, 1, 0];

Скажімо, околиця, що перевіряється, має конфігурацію 111. Отриманий стан має дорівнювати індексу 0 із набору правил, виходячи з того, як я вперше написав функцію rules():

  if (a === 1 && b === 1 && c === 1) return ruleset[0];

Двійкове число 111 перетворюється на десяткове число 7. Але я не хочу ruleset[7], а хочу — ruleset[0]. Для цього мені потрібно інвертувати індекс перед пошуком стану в масиві ruleset:

  return ruleset[7 - index];

Інверсія індексу, щоб 0 перетворити на 7, 1 на 6 і так далі.

Тепер я маю все необхідне для обчислення поколінь елементарного КА Вольфрама. Ось як код виглядає разом:

let cells = [];

Масив для клітин.

let ruleset = [0, 1, 0, 1, 1, 0, 1, 0];

Довільно вибраний набір правил: 90.


function setup() {

  for (let i = 0; i < width; i++) {
    cells[i] = 0;
  }

Усі клітини починають зі стану 0...

  cells[floor(cells.length / 2)] = 1;

...за винятком центральної клітини зі станом 1.

}


function draw() {

  let nextgen = cells.slice();
  for (let i = 1; i < cells.length - 1; i++) {
    let left = cells[i - 1];
    let me = cells[i];
    let right = cells[i + 1];
    nextgen[i] = rules(left, me, right);
  }

Обчислення наступного покоління.

  cells = nextgen;

}


function rules(a, b, c) {
  let s = "" + a + b + c;
  let index = parseInt(s, 2);
  return ruleset[7 - index];
}

Знаходження нового стану із набору правил.

Це чудово, але не вистачає ще однієї деталі: яка користь від КА, якщо ви його не бачите?

Малювання елементарного КА

Стандартна техніка малювання елементарного КА полягає в тому, щоб розташувати покоління одне над одним та намалювати кожну клітину у вигляді чорного (для стану 1) або білого (для стану 0) квадрата, як на малюнку 7.21. Проте, перш ніж реалізувати цю конкретну візуалізацію, я хотів би зазначити дві речі.

Малюнок 7.21: Правило 90 візуалізовано у вигляді стека поколінь
Малюнок 7.21: Правило 90 візуалізовано у вигляді стека поколінь

По-перше, ця візуальна інтерпретація даних абсолютно буквальна. Це корисно для демонстрації алгоритмів і результатів елементарного КА Вольфрама, але це не обов’язково для вашої особистої роботи. Ви навряд чи створите проєкт, який потребує саме цього алгоритму з таким візуальним стилем. Тож хоча навчання малювати КА таким чином допоможе вам зрозуміти та реалізовувати системи КА, ця навичка має бути лише як основа.

По-друге, той факт, що одномірний КА візуалізується за допомогою 2D зображення, може вводити в оману. Дуже важливо пам’ятати, що це не двомірний КА. Я просто вирішив показати історію всіх поколінь у вертикальному стосі. Ця техніка створює 2D зображення з багатьох екземплярів 1D даних, але сама система одномірна. Пізніше я покажу вам справжній двовимірний КА (Гра Життя) і розповім, як візуалізувати таку систему.

Хороша новина у тому, що намалювати елементарний КА не особливо складно. Я почну з відтворення одного покоління. Скажімо, кожна клітинка має бути квадратом 10 на 10 пікселів:

let w = 10;

Якщо полотно має ширину 640 пікселів, КА матиме 64 клітинки. Звісно, я можу обчислити це значення динамічно, коли ініціалізую масив cells у функції setup():

let cells = new Array(floor(width / w));

Обчислення кількості клітин, що вміщується поперек певної ширини.

Малювання клітин тепер вимагає ітерації по масиву та малювання квадрата на основі стану кожної клітини:

for (let i = 0; i < cells.length; i++) {

  fill(cells[i] * 255);

Помноживши стан клітини (0 або 1) на 255, результатом буде 0 або 255.

  square(i * w, 0, w);

x-положення — це індекс клітини, помножений на ширину клітини: 0, 10, 20, 30 і так далі до 640.

}

Два аспекти цього коду не вирішені. По-перше, при множенні стану клітини на 255, клітини зі станом 1 будуть білими, а клітини з 0 будуть чорними, що є протилежністю тому, що я спочатку задумував! Хоча це, звичайно, нормально, оскільки представлення кольорів є довільним, я виправлю це у кінцевому прикладі.

Важливішою проблемою є те, що yy-положення для кожного квадрата жорстко закодована на 0. Якщо я хочу, щоб покоління були розташовані одне під одним, де кожний ряд клітин зображатиме нове покоління, мені також потрібно обчислювати yy-позицію на основі номера покоління. Я можу досягти цього, додавши змінну generation та збільшуючи її кожного разу у функції draw(). Тепер, завдяки цим доповненням, я можу переглянути всю програму.

let cells;

Масив клітин.

let generation = 0;

Лічильник поколінь.

let w = 10;

Розмір клітини.

let ruleset = [0, 1, 0, 1, 1, 0, 1, 0];

Правило 90.


function setup() {

  createCanvas(640, 240);

  background(255);

  cells = new Array(floor(width / w));
  for (let i = 0; i < cells.length; i++) {
    cells[i] = 0;
  }
  cells[floor(cells.length / 2)] = 1;

Масив нулів і одиниць.

}


function draw() {

  for (let i = 1; i < cells.length - 1; i++) {

    if (cells[i] === 1) {

Малювання лише клітин зі станом 1.

      fill(0);

      square(i * w, generation * w, w);

Встановлення y-положення відповідно до покоління.

    }

  }


  let nextgen = cells.slice();
  for (let i = 1; i < cells.length - 1; i++) {
    let left = cells[i - 1];
    let me = cells[i];
    let right = cells[i + 1];
    nextgen[i] = rules(left, me, right);
  }

Обчислення наступного покоління.

  cells = nextgen;

  generation++;

Збільшення лічильника поколінь.

}


function rules(a, b, c) {
  let s = "" + a + b + c;
  let index = parseInt(s, 2);
  return ruleset[7 - index];
}

Знаходження нового стану із набору правил.

Можливо, ви помітили оптимізацію, яку я зробив у цьому прикладі, щоб спростити малювання: я намалював суцільний білий фон і малював лише чорні квадрати, що зекономило роботу із малювання багатьох квадратів. Це рішення підходить не для всіх випадків, особливо якщо потрібні різноколірні клітини. Але конкретно в цьому простому випадку це забезпечує підвищення продуктивності.

Попри цю оптимізацію, інший аспект коду для малювання є надзвичайно неефективним: програма продовжує малювати покоління за поколінням, виходячи за межі нижньої частини полотна. Код на вебсайті книги містить просту умову для зупинки, але ви можете знайти інші підходи для розв'язання цієї проблеми (деякі згадуються у наступних вправах).

Вправа 7.1

Розширте приклад 7.1 з такою особливістю: коли КА досягне нижньої частини полотна, він має початися спочатку, але вже з новим, випадковим набором правил.

Вправа 7.2

Дослідіть патерни, які виникають, коли ви ініціалізуєте клітини в поколінні 0 випадковими станами.

Вправа 7.3

Візуалізуйте КА незвичним способом. Порушуйте всі правила, які тільки можете. Не прив’язуйтесь до використання квадратів на ідеальній сітці з чорними й білими кольорами.

Вправа 7.4

Створіть візуалізацію КА, яка прокручується вгору зі збільшенням поколінь, щоб ви могли переглядати покоління до “нескінченності”. Підказка: замість того, щоб відстежувати одне покоління за раз, вам потрібно буде зберігати історію поколінь, завжди додаючи нове та видаляючи найстаріше для кожного кадру анімації.

Класифікація Вольфрама

Тепер, коли у вас є програма для візуалізації елементарного КА, ви можете надати йому будь-який бажаний набір правил і побачити результати. Яких результатів можна очікувати? Як я зазначав раніше, переважна більшість елементарних наборів правил КА дають візуально не надто цікаві результати, тоді як деякі призводять до дивовижно складних патернів, подібних до тих, що зустрічаються у природі. Вольфрам розділив діапазон результатів на чотири класи.

Клас 1: Однорідний

Після певної кількості поколінь клітинні автомати класу 1 залишаються незмінними для кожної клітини. На них не дуже цікаво дивитися. Правило 222 є КА класу 1, якщо ви запустите його на достатню кількість поколінь, кожна клітинка зрештою стане і залишиться чорною (див. малюнок 7.22).

Малюнок 7.22: Правило 222
Малюнок 7.22: Правило 222

Клас 2: Повторюваний

Як і КА-ти класу 1, КА-ти класу 2 залишаються стабільними, але стани клітинок непостійні. Натомість вони коливаються за повторюваною схемою 0 і 1. У правилі 190 кожна клітина відповідає послідовності 11101110111011101110 (малюнок 7.23).

Малюнок 7.23: Правило 190
Малюнок 7.23: Правило 190

Клас 3: Випадковий

КА-ти класу 3 виглядають випадково і не мають легко помітного патерну. Насправді правило 30 (малюнок 7.24) використовується як генератор випадкових чисел у програмному забезпеченні Wolfram Mathematica. Знову ж таки, ви можете бути вражені тим, що така проста система з простими правилами може переходити у хаотичний і випадковий патерн.

Малюнок 7.24: Правило 30
Малюнок 7.24: Правило 30

Клас 4: Складний

КА-ти класу 4 можна розглядати як суміш між класом 2 і класом 3. Ви можете знайти повторювані, коливальні візерунки всередині КА, але де і коли ці патерни з’являються непередбачувано та, здається, випадково. Якщо КА класу 3 вас захопив, то клас 4 з правилом 110 має справді вразити (малюнок 7.25)!

Малюнок 7.25: Правило 110
Малюнок 7.25: Правило 110

У Розділі 5 я розглянув концепцію складної системи й використав флокінг, щоб продемонструвати, як прості правила можуть призвести до непередбачуваної поведінки. Клас 4 КА-тів чудово демонструє характеристики складних систем і є ключем до моделювання таких явищ, як лісові пожежі, схеми руху та поширення хвороб. Дослідження та застосування КА-тів постійно підкреслюють важливість класу 4 як мосту між КА та природою.

Гра Життя

Наступним кроком є перехід від 1D КА до 2D: Гра Життя. Це створює додаткову складність, адже кожна клітина матиме більшу околицю, але зі складністю приходить ширший діапазон можливих застосувань. Зрештою, більшість того, що відбувається в комп’ютерній графіці, живе у двох вимірах, і цей розділ демонструє, як застосувати КА-мислення до 2D полотна p5.js.

У 1970 році Мартін Гарднер написав статтю в журналі Scientific American у якій описав нову Гру Життя математика Джона Конвея, описуючи її як розважальну математику: “Щоб грати в життя, вам потрібно мати досить велику шахову дошку та великий запас пласких фішок двох кольорів. Можна використовувати олівець і міліметровий папір, але набагато простіше, особливо для початківців, використовувати фішки та дошку”.

Гра Життя стала чимось на кшталт обчислювального кліше, оскільки безліч проєктів відображають гру на світлодіодах, екранах, проєкційних поверхнях тощо. Але практика побудови системи за допомогою коду все ще є цінною з кількох причин.

По-перше, Гра Життя надає гарну можливість попрактикуватися в роботі з 2D-масивами, вкладеними циклами тощо. Однак, можливо, більш важливим є те, що основні принципи цього КА безпосередньо пов’язані з основною метою цієї книги: моделювання природного світу за допомогою коду. Алгоритм Гри Життя і технічна реалізація дадуть вам натхнення та основу для створення симуляцій, які демонструють характеристики та поведінку біологічних систем відтворення.

На відміну від фон Неймана, який створив надзвичайно складну систему станів і правил, Конвей хотів досягти схожого реалістичного результату за допомогою найпростішого набору правил. Гарднер окреслив цілі Конвея наступним чином:

  1. Не повинно існувати початкового шаблону для якого є простий доказ того, що популяція може зростати без обмежень.
  2. Повинні існувати початкові шаблони, які очевидно, ростуть без обмежень.
  3. Повинні існувати прості початкові шаблони, які зростають і змінюються протягом значного періоду часу, перш ніж прийти до кінця трьома можливими способами: повністю зникнути (через перенаселеність або значну розрідженість), установитися у стабільну конфігурацію, яка після цього залишається незмінною, або війти у фазу коливань, у якій вони повторюють нескінченний цикл з двох або більше періодів.

Це може здатися загадковим, але по суті це опис КА класу 4 від Вольфрама. КА має бути шаблонним, але непередбачуваним з плином часу, зрештою установлюючись у рівномірний або коливальний стан. Іншими словами, хоча Конвей не використовував цю термінологію, Гра Життя повинна мати всі властивості складної системи.

Правила Гри

Подивімось, як працює Гра Життя. Це не займе багато часу чи місця, оскільки я можу будувати все, починаючи з елементарного КА Вольфрама. По-перше, замість лінії клітин я тепер матиму 2D матрицю клітин. Як і у випадку з елементарним КА, можливими станами є 0 або 1. Однак у цьому випадку, оскільки в системі йдеться про життя, 0 означатиме “мертвий”, а 1 означатиме “живий”.

Оскільки Гра Життя є двомірною, околиці кожної клітини тепер розширено. Околиці тепер складаються з дев’яти клітинок замість трьох, як показано на малюнку 7.26.

Малюнок 7.26: двомірний КА, що показує околицю з дев’яти клітин
Малюнок 7.26: двомірний КА, що показує околицю з дев’яти клітин

З трьома клітинами 3-бітове число мало вісім можливих конфігурацій. З дев’ятьма клітинами є 9 бітів, або 512 можливих комбінацій. У більшості випадків визначення результату для кожної окремої можливості було б непрактичним. Гра Життя вирішує цю проблему, визначаючи набір правил відповідно до загальних характеристик околиці: околиця може бути перенаселена життям або переважена смертю чи не так? Ось правила життя:

  1. Смерть: якщо клітина жива (стан = 1) вона гине (стан стане 0) за наступних обставин:
    • Перенаселення: якщо клітина має чотири або більше живих сусідів.
    • Самотність: якщо клітина має одного або менше живих сусідів.
  2. Народження: якщо клітина мертва (стан = 0) вона оживає (стан стане 1), коли в неї буде рівно три живі сусіди (ні більше, ні менше).
  3. Застій: у всіх інших випадках стан клітини не змінюється. Можливі два сценарії:
    • Залишитися живою: якщо клітина жива і має рівно двох або трьох живих сусідів вона залишається живою.
    • Залишитися мертвою: якщо клітина мертва і має околицю відмінну від трьох живих сусідів вона залишається мертвою.

На малюнку 7.27 показано кілька прикладів цих правил. Зосередьтеся на тому, що відбувається з центральною клітиною.

Малюнок 7.27: Приклади сценаріїв смерті та народження у Грі Життя
Малюнок 7.27: Приклади сценаріїв смерті та народження у Грі Життя

За допомогою елементарного КА я візуалізував багато поколінь одночасно, складених у вигляді рядків у 2D сітці. Однак у Грі Життя КА є двовимірним. Я міг би спробувати створити складну тривимірну візуалізацію результатів і скласти всі покоління в структуру куба (і насправді ви можете спробувати це як вправу), але більш типовий спосіб візуалізації Гри Життя полягає у розгляді кожного покоління як окремого кадру анімації. Таким чином, замість того, щоб переглядати всі покоління одночасно, ви будете бачити їх по одному, і результат нагадуватиме бактерії, що швидко розвиваються у чашці Петрі.

Одним із захопливих аспектів Гри Життя є те, що деякі відомі початкові шаблони дають результати, що інтригують. Наприклад, шаблони показані на малюнку 7.28 залишаються статичними й ніколи не змінюються.

Малюнок 7.28: Початкові конфігурації клітин, які залишаються стабільними
Малюнок 7.28: Початкові конфігурації клітин, які залишаються стабільними

Шаблони на малюнку 7.29 перемикаються вперед і назад між двома станами.

Малюнок 7.29: Початкові конфігурації клітин, які перемикаються між двома станами
Малюнок 7.29: Початкові конфігурації клітин, які перемикаються між двома станами

Шаблони на малюнку 7.30 будуть виглядати так, наче вони рухаються по сітці від покоління до покоління. Самі клітини насправді не рухатимуться, але ви будете бачити ілюзію руху в результаті ввімкнення і вимкнення сусідніх клітин.

Малюнок 7.30: Початкова конфігурація клітин, які наче рухаються від покоління до покоління
Малюнок 7.30: Початкова конфігурація клітин, які наче рухаються від покоління до покоління

Якщо вас цікавлять ці шаблони, кілька хороших готових онлайн-демонстрацій Гри Життя дозволять вам налаштувати початковий стан КА та спостерігати за його роботою на різних швидкостях. Ось два приклади:

Для прикладу, який я побудую у наступному розділі, я зосереджуся на випадковій ініціалізації станів для кожної клітини.

Імплементація

У мене вже є багато з того, що мені потрібно для реалізації Гри Життя у p5.js: здебільшого мені просто потрібно розширити код із програми КА Вольфрама до двох вимірів. Раніше я використовував 1D масив для зберігання списку станів клітин. Тепер я використаю 2D-масив:

let w = 8;

let columns = width / w;

let rows = height / w;

let board = new Array(columns);

for (let i = 0; i < columns; i++) {

  board[i] = new Array(rows);

}

Я почну з ініціалізації кожної клітини дошки випадковим станом, 0 або 1:

for (let i = 0; i < columns; i++) {

  for (let j = 0; j < rows; j++) {

    board[i][j] = floor(random(2));

Ініціалізація кожної клітини значенням 0 або 1.

  }

}

Як і раніше, мені потрібен додатковий 2D-масив для отримання станів наступного покоління, щоб я не перезаписував 2D-масив поточного покоління під час його обробки. Однак замість того, щоб писати всі кроки для створення 2D-масиву у функціях setup() і draw(), варто написати функцію, яка повертатиме 2D-масив на основі кількості стовпців і рядків. Я також ініціалізую кожен елемент масиву значенням 0, щоб він не заповнювався як undefined:

function create2DArray(columns, rows) {

  let arr = new Array(columns);

  for (let i = 0; i < columns; i++) {

    arr[i] = new Array(rows);

    for (let j = 0; j < rows; j++) {

      arr[i][j] = 0;

    }

  }

  return arr;

}

Тепер я можу просто викликати цю функцію, коли потрібен новий 2D-масив:

let next = create2DArray(columns, rows);


for (let i = 0; i < columns; i++) {

  for (let j = 0; j < rows; j++) {

    next[x][y] = _______________?;

Обчислення стану для кожної клітини.

  }

}

Далі мені потрібно розібратися, як обчислити новий стан кожної клітини. Для цього мені потрібно визначити, як зчитувати значення сусідніх клітин. У випадку одномірного КА це було просто: якщо індекс клітини був i, то її сусідами були i-1 та i+1. Але тепер кожна клітина має не єдиний індекс, а індекси стовпця і рядка: i,j. Як показано на малюнку 7.31, сусідами є i-1,j-1 , i,j-1, i+1,j-1, i-1,j, i+1,j, i-1,j+1, i,j+1 та i+1,j+1.

Малюнок 7.31: Значення індексів для сусідніх клітин
Малюнок 7.31: Значення індексів для сусідніх клітин

Правила Гри Життя можна задіяти, якщо знати скільки клітина має живих сусідів. Якщо я створю змінну neighborSum і збільшуватиму її для кожного сусіда зі станом 1, то матиму загальну кількість живих сусідів:

let neighborSum = 0;

if (board[i - 1][j - 1] === 1) neighborSum++;
if (board[i    ][j - 1] === 1) neighborSum++;
if (board[i + 1][j - 1] === 1) neighborSum++;

Верхній ряд сусідів.

if (board[i - 1][j    ] === 1) neighborSum++;
if (board[i + 1][j    ] === 1) neighborSum++;

Середній ряд сусідів (зауважте, що j залишається без змін).

if (board[i - 1][j + 1] === 1) neighborSum++;
if (board[i    ][j + 1] === 1) neighborSum++;
if (board[i + 1][j + 1] === 1) neighborSum++;

Нижній ряд сусідів.

Як і з КА Вольфрама, я пишу купу операторів if. Це ще одна ситуація, коли з метою навчання корисно та зрозуміло написати код таким чином, чітко зазначаючи кожен крок (кожного разу, коли сусід має стан 1, лічильник збільшується). Проте, трохи безглуздо говорити “Якщо стан клітини дорівнює 1, додайте 1 до лічильника”, тоді як замість цього я міг би просто сказати “Додайте стан клітини до лічильника”. Зрештою, якщо стан може бути тільки 0 або 1, сума всіх станів сусідів дасть загальну кількість живих клітин. Оскільки сусіди розташовані у мінісітці 3 на 3, я можу додати ще один вкладений цикл для більш ефективного обчислення суми:

let neighborSum = 0;

for (let k = -1; k <= 1; k++) {
  for (let l = -1; l <= 1; l++) {

Використання змінних k та l як лічильники, оскільки i та j уже використовуються!

    neighborSum += board[i + k][j + l];

Складання усіх сусідніх станів.

  }

}

Звісно, я зробив суттєву помилку. У Грі Життя поточна клітина не вважається сусідньою. Я міг би включити умову, щоб пропустити додавання стану, коли k та l дорівнюють 0, але іншим варіантом є віднімання стану центральної клітини після завершення циклу:

neighborSum -= board[i][j];

Після циклу віднімаємо стан поточної клітини!

Нарешті, коли мені відома загальна кількість живих сусідів я можу вирішити, яким має бути новий стан клітини згідно з правилами народження, смерті чи застою:

if (board[i][j] === 1 && neighborSum < 2) {
  next[i][j] = 0;

Якщо клітина жива і має менше двох живих сусідів вона гине від самотності.

} else if (board[x][y] === 1 && neighborSum > 3) {
  next[i][j] = 0;

Якщо клітина жива і має більш трьох живих сусідів вона гине від перенаселення.

} else if (board[x][y] === 0 && neighborSum === 3) {
  next[i][j] = 1;

Якщо клітина мертва і має рівно трьох живих сусідів вона народжується!

} else {
  next[i][j] = board[i][j];
}

У всіх інших випадках стан клітини залишається незмінним.

Зібравши все разом маємо:

let next = create2DArray(columns, rows);

Створення сітки.

for (let i = 1; i < columns - 1; i++) {
  for (let j = 1; j < rows - 1; j++) {

Прохід по всій сітці, але без крайніх клітин.

    let neighborSum = 0;
    for (let k = -1; k <= 1; k++) {
      for (let l = -1; l <= 1; l++) {
        neighborSum += board[i + k][j + l];
      }
    }

Додавання усіх сусідніх станів, щоб порахувати кількість живих сусідів.

    neighborSum -= board[i][j];

Корекція за рахунок віднімання стану центральної клітини.


    if (board[i][j] === 1 && neighborSum < 2) next[i][j] = 0;
    else if (board[i][j] === 1 && neighborSum > 3) next[i][j] = 0;
    else if (board[i][j] === 0 && neighborSum === 3) next[i][j] = 1;
    else next[i][j] = board[i][j];

Застосування правил життя!

  }

}

board = next;

Тепер мені просто потрібно намалювати сітку. Я намалюю квадрат для кожної клітини: білий — для живої, чорний — для неживої.

  for (let i = 0; i < columns; i++) {

    for (let j = 0; j < rows; j++) {

      fill(255 - board[i][j] * 255);

Коли стан клітини дорівнює 0, розраховуємо значення 255, інакше буде 0.

      stroke(0);

      square(i * w, j * w, w);

    }

  }

У цьому прикладі я представляю ще один спосіб малювання квадратів на основі стану клітини. Пам’ятайте, що помноження стану клітини на 255 дає білий колір заливки для ввімкненої й чорний колір для вимкненої клітини. Щоб інвертувати ці значення, я віднімаю від числа 255 значення клітини помножене на 255: виходить чорний колір для ввімкненої й білий для вимкненої клітин.

Вправа 7.5

Створіть симуляцію Гри Життя, яка дозволить вам вручну налаштувати сітку, жорстко закодувавши початкові стани клітин або намалювавши їх безпосередньо на полотні. Використовуйте симуляцію, щоб дослідити деякі з відомих шаблонів Гри Життя.

Вправа 7.6

Реалізуйте для дошки Гри Життя властивість переходу між її межами, щоб клітини по краях додатково вважали своїми сусідами й клітини з протилежних боків сітки.

Вправа 7.7

Код у прикладі 7.2 є зручним, але не особливо ефективним для пам’яті. Він створює новий 2D-масив для кожного кадру анімації! Для програми p5.js це мало має значення, але якщо ви реалізовуєте Гру Життя на мікроконтролері чи мобільному пристрої, то варто бути обережнішими. Одне з рішень полягає в тому, щоб мати лише два масиви та постійно міняти їх місцями, записуючи наступний набір станів у той із них, який не є поточним масивом. Реалізуйте це конкретне рішення.

Об’єктно-орієнтовані клітини

Впродовж цієї книги я створював приклади систем об’єктів, які мають властивості та рухаються по полотну. Хоча в цьому розділі я говорив про клітину як про об’єкт, я не використовував об’єктно-орієнтовані принципи у коді. Це спрацювало, тому що клітина надзвичайно простий об’єкт і його єдиною властивістю є його єдиний стан: 0 або 1. Однак я міг би далі розвивати системи КА багатьма способами, окрім простих моделей, які тут обговорюються, і часто це може включати відстеження кількох властивостей для кожної клітини. Наприклад, що, якщо клітині потрібно запам’ятати свою історію станів? Або що, якщо ви хочете застосувати рух і фізику до КА, щоб клітини рухалися по полотну, динамічно змінюючи своїх сусідів від кадру до кадру?

Щоб реалізувати будь-яку з цих ідей (і багато іншого), було б корисно розглянути кожну клітину у вигляді об’єкта, а не як окремий 0 чи 1 у масиві. Наприклад, у симуляції Гри Життя я більше не хочу ініціалізувати кожну клітину таким чином:

     board[i][j] = floor(random(2));    

Натомість я хочу щось на зразок цього:

     board[i][j] = new Cell(floor(random(2)));

Тут Cell — це новий клас, який я напишу. Які властивості потрібні для об’єкта Cell? У прикладі Гри Життя я міг би створити клітину в якій зберігається її позиція та розмір разом із її станом:

class Cell {

  constructor(state, x, y, w) {

    this.state = state;

Певний стан клітини.

    this.x = x;
    this.y = y;
    this.w = w;

Розташування і розмір.

У версії без ООП я використовував окремі 2D-масиви для відстеження станів для поточного та наступного покоління. Однак, зробивши клітину об’єктом, кожна клітина могла б відстежувати обидва стани, маючи для цього окрему змінну:

    this.previous = this.state;
  }

Який був попередній стан?

Несподівано з цими додатковими властивостями візуалізація клітини може включати більше інформації про стан. Наприклад, що, якщо кожну клітину фарбувати залежно від того, чи змінився її стан від одного кадру до іншого?

  show() {

    stroke(0);

    if (this.previous === 0 && this.state === 1) {
      fill(0, 0, 255);

Якщо клітина народжується, розфарбуємо її у синій колір!

    } else if (this.state === 1) {

      fill(0);

    } else if (this.previous === 1 && this.state === 0) {
      fill(255, 0, 0);

Якщо клітина гине, пофарбуємо її у червоний колір!

    } else {

      fill(255);

    }

    square(this.x, this.y, this.w);

  }

Більше нічого у коді не потрібно змінювати (принаймні для моїх поточних цілей). Сусідів можна рахувати так само, різниця в тому, що їх стани беруться у властивості previous, а новий стан оновлюється у властивості state. Цю логіку можна інкапсулювати у методі calculateState(), який міг би приймати аргумент дошки board. Я залишу це для вас як вправу.

Нижче наведено логіку Гри Життя, адаптовану для клітинних об’єктів, але без окремого методу calculateState():

  for (let x = 1; x < columns - 1; x++) {

    for (let y = 1; y < rows - 1; y++) {

      let neighborSum = 0;

      for (let i = -1; i <= 1; i++) {

        for (let j = -1; j <= 1; j++) {

          neighborSum += board[x + i][y + j].previous;

Використання попереднього стану для підрахунку сусідів.

        }

      }

      neighborSum -= board[x][y].previous;


      if (board[x][y].state === 1 && neighborSum < 2) {
        board[x][y].state = 0;
      } else if (board[x][y].state === 1 && neighborSum > 3) {
        board[x][y].state = 0;
      } else if (board[x][y].state === 0 && neighborSum === 3) {
        board[x][y].state = 1;
      }

Встановлення нового стану клітини на основі кількості сусідів.

    }

  }

}

Перетворюючи клітини на об’єкти, з’являються численні можливості для покращення властивостей і поведінки клітин. Наприклад, що, якби кожна клітина мала властивість lifespan, яка збільшується з кожним циклом і впливає на її колір або форму з часом? Або уявіть, якби клітина мала властивість для рельєфу місцевості terrain, яка могла б бути землею land, водою water, горою mountain або лісом forest. Як двомірний КА можна інтегрувати у стратегічну гру на плитці чи іншому контексті?

Варіації традиційного КА

Тепер, коли я розглянув основні поняття, алгоритми й стратегії програмування, що лежать в основі найвідоміших одномірних та двомірних КА-тів, настав час подумати про те, як ви можете взяти цю основу коду та будувати на ній, розробляючи креативні програми КА у власних роботах. У цьому розділі я розповім про деякі ідеї щодо розширення можливостей КА. Приклади відповідей до цих вправ можна знайти на вебсайті книги.

Варіант з непрямокутною сіткою

Немає особливих причин обмежувати себе розміщенням клітин у прямокутній сітці. Що станеться, якщо ви розробите КА з іншим типом форми?

Вправа 7.8

Створіть КА за допомогою сітки з шестикутників (як показано тут), кожен із шістьма сусідами.

Підказка: щоб знайти шість вершин гексагона, ви можете використовувати перетворення полярних координат у декартові!

function drawHexagon(x, y, r) {

  push();

  translate(x, y);

  stroke(0);

  beginShape();

  for (let angle = 0; angle < TWO_PI; angle += PI / 3) {

    let xoff = cos(angle) * r;

    let yoff = sin(angle) * r;

    vertex(xoff, yoff);

  }

  endShape(CLOSE);

  pop();

}

Варіант з імовірностями

Правила КА не обов’язково мають визначати точний результат.

Вправа 7.9

Перепишіть правила Гри Життя наступним чином:

  • Перенаселення: якщо клітина має чотири або більше живих сусідів, вона має 80-відсотковий шанс загинути.
  • Самотність: якщо у клітини є один чи менше живих сусідів, вона має 60-відсотковий шанс загинути.

Або складіть власні ймовірнісні правила!

Варіант з безперервністю

У цьому розділі ми зосередилися на прикладах із кінцевою кількістю дискретних станів клітини — 0 або 1. Що, якби стан клітини міг бути будь-яким числом із рухомою крапкою від 0 до 1?

Вправа 7.10

Адаптуйте елементарний КА Вольфрама, щоб він мав стан числа з рухомою крапкою. Ви можете визначити такі правила як “Якщо стан більше за 0.5” або “...менше ніж 0.2”.

Варіант з обробкою зображення

Я коротко торкався цього раніше, але багато алгоритмів обробки зображень працюють за правилами, подібними до КА. Наприклад, щоб розмити зображення, потрібно створити новий колір пікселя із середнім значенням кольорів сусідніх пікселів. Симуляції дисперсії чорнила на папері або брижі води на зображенні також можна досягти за допомогою правил КА.

Вправа 7.11

Створіть КА, у якому кожен піксель — це клітина, а колір пікселя — її стан.

Варіант з історією станів

У прикладі об’єктно-орієнтованої Гри Життя я використовував дві змінні, щоб відстежувати поточний і попередній стани клітини. Що, якщо ви будете використовувати масив для відстеження історії стану клітини протягом більш тривалого періоду? Це стосується ідеї складної адаптивної системи, яка має здатність змінювати свої правила з часом, вивчаючи свою історію. (Більше про цю концепцію буде у Розділі 9 і 10.)

Вправа 7.12

Візуалізуйте Гру Життя, розфарбувавши кожну клітину відповідно до часу, протягом якого вона була живою чи мертвою. Чи можете ви також використовувати історію клітини, для роботи з правилами?

Варіант з рухомими клітинами

У цих базових прикладах клітини мають фіксовану позицію на сітці, але ви можете побудувати КА з клітинами, які не мають фіксованої позиції й натомість рухаються по полотну.

Вправа 7.13

Використовуйте правила КА у системі флокування. Уявіть, що кожен боїд має стан (який можливо інформує його керувальну поведінку), а його околиці змінюються від кадру до кадру, коли він наближається до інших боїдів або віддаляється від них.

Варіант із вкладеннями

Як обговорювалося у Розділі 5, особливістю складних систем є те, що вони можуть бути вкладеними. Місто — це складна система людей, людина — це складна система органів, орган — це складна система клітин тощо. Подумайте як це можна застосувати до КА?

Вправа 7.14

Створіть КА у якому кожна клітина є меншим КА.

Проєкт “Екосистема”

Включіть у свою екосистему КА. Ось кілька можливостей:

  • Надайте кожному створінню стан. Як цей стан може керувати своєю поведінкою? Беручи натхнення з КА, як цей стан може змінюватися з часом відповідно до станів його сусідів?
  • Вважайте, що світ екосистеми є КА. Істоти переміщуються від комірки до комірки й кожна комірка має стан. Чи це буде земля? Вода? Їжа?
  • Використовуйте КА, щоб створити патерн для дизайну створіння вашої екосистеми.