Одни из самых долгоживущих, самых скрытных и самых древних организмов на Земле. Грибы. Существа в скрытом царстве под горой. Они меня всегда увлекали.
В 1998 году внимание биологов привлекла гибель деревьев, чьи корни были опутаны грибницей. Тогда-то они и определили, что скопления опёнка темного в Орегоне не отдельные грибницы, а единый организм. Крупнейшее живое существо на Земле: размером с 880 гектаров и старше 2,4 тысячи лет.
Хочется написать симуляцию этого великолепного царства (прямо в браузере на Python и p5py). Посадить электронные споры, понаблюдать за ростом мицелия и восшедшими плодовыми телами, и проследить за спорами-путешественниками, как они создают новые колонии.
Добро пожаловать в путешествие в Царство Грибов.
Основной план прост: мицелий как-то прорастает и развивается, добывая ресурсы из почвы.
-
Часть 1. Подготовка
-
Часть 2. Ускорение и рефакторинг
-
Часть 3. Окрестность Мура
-
Часть 4. Борьба кланов
Часть 1. Подготовка
Мицелий:
-
представлен сетью (графом) из узлов;
-
растёт, извлекая питательные вещества из окружающей среды;
-
распространяется по соседним клеткам, если хватает ресурсов;
-
носитель генома (в будущем, сейчас пока без него).
class Mycelium:
def __init__(self, x, y):
self.nodes = [(x, y)] # Координаты узлов
self.energy = 100 # Энергия для роста
self.age = 0
def grow(self, environment):
# Логика роста мицелия в зависимости от ресурсов и генома
new_nodes = []
for x, y in self.nodes:
if self.energy > 0:
neighbors = get_neighbors(x, y) # Соседние клетки
for nx, ny in neighbors:
if environment[nx][ny].resources > 0:
self.nodes.append((nx, ny))
environment[nx][ny].resources -= 1
self.energy -= 1
self.age += 1
Окружающая среда:
-
сетка клеток, каждая из которых имеет параметры: ресурсы, свет, влажность и т. д. (для начала просто абстрактные ресурсы);
-
каждый такой участок почвы содержит ресурсы, которые будут уменьшаться по мере потребления мицелием.
class Environment:
def __init__(self, width, height):
self.grid = [[Cell(random.randint(50, 150)) for _ in range(width)] for _ in range(height)]
self.width = width
self.height = height
class Cell:
def __init__(self, resources):
self.resources = resources
Визуализация в p5py:
def setup():
global environment, mycelia
size(800, 800)
environment = Environment(100, 100)
mycelium = Mycelium(5, 5) # Создаем мицелий в центре
def draw():
global environment, mycelium
background(0) # Черный фон
environment.display() # Отображаем среду
mycelium.grow(environment) # Рост мицелия
mycelium.display() # Отображение мицелия
Соединяем всё в рабочий код
С основными сущностями всё понятно, но как бы это всё наглядно увидеть? С чего бы начать? Было бы здорово увидеть мицелий в виде тонких нитей, прорастающих в среде.
Мицелий. Будем использовать прямые, соединяющие узлы мицелия. Каждый узел связан с соседними, создавая сеть.
self.links = [] # Связи между узлами
И отображаем:
def display(self):
stroke(200, 150) # Белые линии с прозрачностью
stroke_weight(1)
for (x1, y1), (x2, y2) in self.links:
line(x1 * 80 + 40, y1 * 80 + 40, x2 * 80 + 40, y2 * 80 + 40)
Да, у нас будет примитивная реализация хранения линий, просто, чтобы посмотреть. Если нам всё понравится, можно будет когда-нибудь пересмотреть структуру хранения гифов (нитей мицелия), хранить их в виде графа — это может быть полезно, если моделировать потом передачу полезных веществ с разных концов мицелия.
Среда. Отрисуем её в виде тепловой карты, где насыщенность цвета указывает на количество ресурсов.
def display(self):
for x in range(self.width):
for y in range(self.height):
cell = self.grid[x][y]
fill(0, cell.resources, 0, 100) # Зеленый цвет в зависимости от ресурсов
no_stroke()
rect(x * 80, y * 80, 80, 80)
Объединяем всё вместе и получаем:
Вроде первый шаг удался (хотя есть, что порефакторить). Есть земля и ресурсы, мицелий растёт. Запустите код по ссылке выше прямо в браузере. А для телефона потом сделаем адаптацию: размер экрана будет определяться автоматически, а ячейки будут поменьше.
Аниматоры, ваш выход!
Перезапускать код весело, но очень хочется, чтобы мицелий рос не скачком, а плавно. Анимируем его одной командой: добавим return
при обработке роста, чтобы за один ход обрабатывался только один узел. Неэффективно, но визуально всё быстро проверим.
def grow(self, environment):
if self.energy <= 0:
return # Если энергия закончилась, выходим
# Обрабатываем только один узел за вызов
for (x, y) in self.nodes:
neighbors = get_neighbors(x, y, environment.width, environment.height)
for nx, ny in neighbors:
if environment.grid[nx][ny].resources > 40 and (nx, ny) not in self.nodes:
self.nodes.append((nx, ny))
self.links.append(((x, y), (nx, ny)))
environment.grid[nx][ny].resources -= 1
self.energy -= 1
return # Выходим после обработки одного узла
self.age += 1
Да, тут не оптимально пока что, каждый раз заново пробегаем по всем узлам. На следующем шаге сделаем рефакторинг, а пока просто проверим анимацию — понравится она нам или нет. Хотя эта неоптимальность может быть и фичей, если окружающая среда меняется и нам нужно каждый раз заново пересматривать все узлы.
Добавим ещё точки на концах связей, временно, для наглядности.
Позапускайте, как у вас будет расти мицелий?
А ещё можно отнимать у земли больше ресурсов при росте:
environment.grid[nx][ny].resources -= 80
Получается более наглядно — ресурсы в почве уменьшаются, земля темнеет, мицелий захватывает мир:
Часть 2. Ускорение и рефакторинг
Слишком много магических чисел. Давайте вынесем в константы хотя бы размер ячейки:
# Константы
CELL_SIZE = 20 # Размер ячейки
И сделаем так, чтобы количество ячеек по горизонтали и вертикали рассчитывалось автоматически, исходя из ширины экрана и размера ячейки:
class Environment:
def __init__(self):
self.width = width // CELL_SIZE # Количество ячеек по ширине
self.height = height // CELL_SIZE # Количество ячеек по высоте
Теперь удобнее менять размеры экрана и ячейки. Можно сделать мир побольше:
Даже так интересно поэкспериментировать с количеством ресурсов в клетке, чтобы мицелий рос. Потребуем более богатую почву (было 40
, стало 100
):
if environment.grid[nx][ny].resources > 100
Мицелий растёт уже интереснее, более причудливо огибает бедную почву:
Ускоряемся
Но работает медленно. Наивная реализация роста нам не подходит. Нужно ускорить и оптимизировать поиск и расчёт новых узлов мицелия. 20 FPS в начале, плавно падает до 5 FPS, и в конце 15 FPS. Медленно.
Первый шаг
Самый простой способ: будем рисовать поле каждый десятый кадр. Визуально редкое обновление не особо заметно, но это сильно ускорит отображение:
if frame_count % 10 == 0:
background(0)
environment.display()
Начинается теперь с 60 FPS, затем снова падает до 5 FPS и заканчивает на 30 FPS. Это очевидное улучшение.
Второй шаг. Поиск в ширину
Все новые найденные узлы будем сохранять в nodes
. Чтобы визуализация была пошаговой, нам надо знать, какой узел сейчас обрабатываем. А раз обрабатываем мы по очереди, то будем сохранять индекс текущего узла в переменной:
self.current_index = 0 # Текущий индекс в массиве nodes
Заменим наш прошлый аляповатый цикл for
вместе с brake
:
for (x, y) in self.nodes:
на простое получение текущего узла:
x, y = self.nodes[self.current_index] # Получаем узел по индексу
self.current_index += 1 # Увеличиваем индекс для обработки следующего узла
Начинает теперь с 60 FPS и далее плавно падает до 30 FPS. Вот, уже неплохо.
— Немного алгоритмики этому господину.
Наше решение напоминает поиск в ширину ( BFS), в котором дополнительная память, обычно очередь, необходима для отслеживания дочерних узлов, которые были обнаружены, но ещё не исследованы. Но нам пока достаточно просто указателя. Мы ведь и так послойно сохраняем найденные, но не исследованные узлы.
Чёрный — исследовано, серый: поставлено в очередь на исследование.
Почему мы используем поиск в ширину, а не в глубину (DFS)? Очевидно, что грибнице не выгодно бесконечно исследовать мир одним отростком, если рядом есть много подходящих ресурсов. Транспортировка питательных веществ от слишком длинного отростка явно проигрывает коротким гифам.
Третий шаг
Заменим вложенные кортежи:
self.links.append(((x, y), (nx, ny)))
на более длинную строку:
self.links.append((x * self.size + self.size / 2, y * self.size + self.size / 2, nx * self.size + self.size / 2, ny * self.size + self.size / 2))
Зато отображение у нас станет совсем кратким:
for l in self.links:
x1, y1, x2, y2 = l
line(x1, y1, x2, y2)
Почему сделали такую замену? Дело в том, что Brython (онлайн-транслятор Python в JS) медленно работает с кортежами. И, кстати, от последнего кортежа тоже можно избавиться, это ускорит код ещё в полтора раза.
Стало стабильно 60-70 FPS.
Скрытый баг
Сейчас энергии у мицелия мало, и она заканчивается раньше, хотя ещё есть узлы, которые можно обойти. Но если энергии добавить до тысячи self.energy = 1220
, то получим ошибку IndexError: 'list index out of range'
в этой строке:
x, y = self.nodes[self.current_index]
Поэтому добавим проверочку:
if self.current_index >= len(self.nodes):
return
Исправили:
Код
Четвёртый шаг. Несколько тактов за раз
Можно визуализацию ещё ускорить, если обрабатывать несколько узлов за раз. Просто введём счетчик узлов:
step = 0
while self.current_index < len(self.nodes) and self.energy > 0 and step < 4:
step += 1
# ...
Гляди в корень
Добавим отображение растущих окончаний мицелия:
def display(self):
for x, y in self.nodes[self.current_index:]:
fill("red")
rect(x*self.size, y*self.size, self.size, self.size)
Пятый шаг. Ещё рефакторинг
Функция get_neighbors()
должна явно принадлежать классу Environment
. Она была такой:
def get_neighbors(x, y, width, height):
neighbors = []
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nx, ny = x + dx, y + dy
if 0 <= nx < width and 0 <= ny < height:
neighbors.append((nx, ny))
return neighbors
Станет такой:
def get_neighbors(self, x, y):
neighbors = []
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nx, ny = x + dx, y + dy
if 0 <= nx < self.width and 0 <= ny < self.height:
neighbors.append((nx, ny))
return neighbors
И строка neighbors = get_neighbors(x, y, environment.width, environment.height)
станет лаконичнее:neighbors = environment.get_neighbors(x, y)
А ещё добавим мелочь в виде адаптации к размеру экрана:
if window_width > 600:
size(800, 800)
else:
size(window_width, window_height)
Шестой шаг. Насыпем немного Dependency Injection
Мицелий у нас всегда растёт в определённой среде, поэтому сделаем внедрение зависимости сразу при создании его экземпляра. Для этого нам нужно модифицировать конструктор класса Mycelium
. Передадим объект среды в качестве аргумента в конструктор:
environment = Environment()
mycelium = Mycelium(environment.width // 2, environment.height // 2, environment) # Передаем среду
class Mycelium:
def __init__(self, x, y, environment):
self.environment = environment # Сохраняем ссылку на среду
И упростится вызов `grow()`. Вместо mycelium.grow(environment) станет просто mycelium.grow().
Часть 3. Окрестность Мура
А почему у нас всё такое квадратное? Больше напоминает схему. Можно же вместо окрестности Фон Неймана использовать окрестность Мура:
Вот так мы расширим перебор:
# for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (1, -1), (-1, 1), (1, 1)]:
И тогда наш мицелий выглядит более натурально:
Время кушать
Мицелий каждый ход будет высасывать ресурсы из завоёванных клеточек и пополнять свою энергию: self.energy += 0.034
. Ну и тратить энергию на каждый свой узел: self.energy -= 0.01
.
for nx, ny in self.nodes:
self.energy -= 0.01
if environment.grid[nx][ny].resources > 0:
environment.grid[nx][ny].resources -= 0.5
self.energy += 0.034
Этот баланс довольно требовательный. Попробуйте поэкспериментировать. Если получать хотя бы на 0,001 меньше энергии с ячейки, то мицелию уже не хватает энергии для роста. А если на 0,001 больше, то он уже неконтролируемо набирает десятки тысяч единиц энергии.
Можем даже начать отслеживать энергию мицелия в левом верхнем углу:
def show_energy(self):
fill(255)
rect(0, 0, 50, 10)
fill(0)
text(int(self.energy), 10, 10)
Ещё можно побольше ресурсов отнимать у ячейки, тогда почва быстрее истощается.
environment.grid[nx][ny].resources -= 1.5
И сначала мицелий растёт интенсивно, а потом еды начинает не хватать и грибница быстро загибается.
Часть 4. Борьба
Давайте запустим на площадку два мицелия. Просто напишем так:
from p5py import *
import random
run()
CELL_SIZE = 20
class Mycelium:
...
class Environment:
...
class Cell:
...
# Глобальные переменные
environment = None
mycelium1 = None
mycelium2 = None
def setup():
global environment, mycelium1, mycelium2
environment = Environment()
mycelium1 = Mycelium(environment.width // 3, environment.height // 3, environment)
mycelium2 = Mycelium(environment.width * 2 // 3, environment.height * 2 // 3, environment)
def draw():
if frame_count % 10 == 0:
background(0)
environment.display()
mycelium1.grow()
mycelium1.display()
mycelium2.grow()
mycelium2.display()
Лучше, конечно же, универсально использовать массивы. Если будет желание, вы сможете поэкспериментировать, благо в online-IDE не требуется установка и настройка.
Пусть у мицелия при инициализации будет собственный цвет:
def __init__(self, x, y, environment, color):
...
self.color = color # Уникальный цвет для мицелия
def setup():
...
mycelium1 = Mycelium(environment.width // 3, environment.height // 3, environment, color(255, 100, 100))
mycelium2 = Mycelium(environment.width * 2 // 3, environment.height * 2 // 3, environment, color(100, 100, 255))
Можно повысить требования к ресурсам:
if self.environment.grid[nx][ny].resources > 110
Тогда и гифы получаются более разреженные:
А что если сделать так, чтобы мицелии появлялись в случайном месте карты?
def setup():
global environment, mycelium1, mycelium2
environment = Environment()
# Случайные координаты для mycelium1 и mycelium2
mycelium1_x = rand(0, environment.width)
mycelium1_y = rand(0, environment.height)
mycelium2_x = rand(0, environment.width)
mycelium2_y = rand(0, environment.height)
mycelium1 = Mycelium(mycelium1_x, mycelium1_y, environment, color(255, 100, 100))
mycelium2 = Mycelium(mycelium2_x, mycelium2_y, environment, color(100, 100, 255))
Не совсем ещё битва, но синий явно успел окружить красного:
А здесь они появились рядом и сплелись:
Мы не запрещаем расти двум мицелиям на одном участке. Пока что.
У нас сейчас отображение энергии разных мицелиев наслаивается друг на друга. Надо бы исправить. Поменяем это фиксированное положение:
def show_energy(self):
fill(255)
rect(0, 0, 50, 10)
fill(0)
text(int(self.energy), 10, 10)
скажем, на такое:
def show_energy(self):
# Позиционирование энергии относительно мицелия
energy_x = self.nodes[0][0] * self.size + 10 # Позиция x для текста энергии
energy_y = self.nodes[0][1] * self.size + 10 # Позиция y для текста энергии
fill(255)
rect(energy_x, energy_y, 50, 10)
fill(0)
text(int(self.energy), energy_x + 5, energy_y + 8)
И вместо того чтобы всегда отображать энергию в одном и том же месте (0, 0), мы можем позиционировать текст в зависимости от положения мицелия на экране.
Количество энергии сейчас отображается некрасиво: белый фон отвлекает, давайте исправим: сделаем фон чёрным, а число — белым:
def show_energy(self):
energy_x = self.nodes[0][0] * self.size + 10
energy_y = self.nodes[0][1] * self.size + 10
fill(0)
no_stroke()
rect(energy_x, energy_y, 60, 20)
fill(255)
text_size(12)
text_align(CENTER)
text(int(self.energy), energy_x + 30, energy_y + 15) # Центрируем текст
В общем я за вас немного позапускал код 🙂 Здесь разные мицелии стали расти из одного угла. Интересно, кто победит:
Похоже, синий:
А здесь обратная ситуация, красный опережает:
Можно уменьшить размер ячеек, но если мицелии друг от друга далеко, то успеют загнуться самостоятельно:
А если вблизи, то начинается конкуренция:
Хорошо бы внести какие-то изменения в среду или сам мицелий.
Улучшения
Самое простое: сделаем цвета случайными:
mycelium1 = Mycelium(mycelium1_x, mycelium1_y, environment, color(rand(0, 255), rand(0, 255), rand(0, 255)))
mycelium2 = Mycelium(mycelium2_x, mycelium2_y, environment, color(rand(0, 255), rand(0, 255), rand(0, 255)))
Лучше бы перенести инициализацию в конструктор.
Исправляем ошибку
Кстати, с момента случайного расположения мицелия на карте он перестал находиться ровно в центре клетки. Может, вы это заметили раньше. Дело в том, что rand()
возвращает не целое число:
mycelium1_x = rand(0, environment.width)
print(mycelium1_x)
Поправим:
mycelium1_x = int(rand(0, environment.width))
mycelium1_y = int(rand(0, environment.height))
mycelium2_x = int(rand(0, environment.width))
mycelium2_y = int(rand(0, environment.height))
Хотя можно было бы считать это фичей, а не багом, так как теперь мицелии не так красиво сплетаются друг с другом, а просто упираются рогами (гифами):
Сейчас цвета двух мицелиев легко сливаются. Сделаем их более контрастными?
# Генерация более контрастных цветов
mycelium1_color = color(rand(200, 255), rand(0, 100), rand(0, 100)) # Теплые оттенки
mycelium2_color = color(rand(0, 100), rand(100, 255), rand(200, 255)) # Холодные оттенки
mycelium1 = Mycelium(mycelium1_x, mycelium1_y, environment, mycelium1_color)
mycelium2 = Mycelium(mycelium2_x, mycelium2_y, environment, mycelium2_color)
Выживание. Пожиратели
Давайте придумаем, как сделать так, чтобы разные мицелии сражались друг с другом за выживание. Пусть один мицелий может прорастать в другой. Например, при поиске следующего узла воспринимает ресурсом не только содержимое земли, но и местоположение узла «вражеского» мицелия. Если он найден, то нужно удалить вражеский узел и его связи, а самому там вырасти.
Перебирая узлы, добавим проверку:
if (self.environment.grid[nx][ny].resources > 110 or (nx, ny) in other_mycelium.nodes) and (nx, ny) not in self.nodes:
И сам акт поедания:
if (nx, ny) in other_mycelium.nodes: # Если находим узел другого мицелия
other_mycelium.nodes.remove((nx, ny)) # Удаляем узел
other_mycelium.energy -= 1 # Урезаем энергию
# Также удаляем все связи, которые имел этот узел
other_mycelium.links = [link for link in other_mycelium.links if link[0] != nx * self.size + self.size / 2 or link[1] != ny * self.size + self.size / 2]
Всё вместе:
for nx, ny in neighbors:
if (self.environment.grid[nx][ny].resources > 110 or (nx, ny) in other_mycelium.nodes) and (nx, ny) not in self.nodes:
self.nodes.append((nx, ny))
self.links.append((x * self.size + self.size / 2, y * self.size + self.size / 2, nx * self.size + self.size / 2, ny * self.size + self.size / 2))
self.environment.grid[nx][ny].resources -= 10
self.energy -= 1
if (nx, ny) in other_mycelium.nodes: # Если находим узел другого мицелия
other_mycelium.nodes.remove((nx, ny)) # Удаляем узел
other_mycelium.energy -= 1 # Урезаем энергию
# Также удаляем все связи, которые имел этот узел
other_mycelium.links = [link for link in other_mycelium.links if link[0] != nx * self.size + self.size / 2 or link[1] != ny * self.size + self.size / 2]
То есть мы проверяем, является ли соседний узел узлом другого мицелия. Если да, то удаляем этот узел и все его связи с чужим мицелием.
Но когда один полностью съедает другого, у нас возникает ошибка IndexError: 'list index out of range'
.
Она возникает в методе display()
класса Mycelium
из-за того, что когда один мицелий полностью «съедает» другой, то его узлы истощаются, и при попытке доступа к элементам списка узлов через self.nodes[0]
может возникнуть ситуация, когда этот самый self.nodes
оказывается пустым.
Чтобы избежать этой ошибки, добавим проверку на наличие узлов перед тем, как обращаться к self.nodes[0]
. Например, так:
def show_energy(self):
if not self.nodes: # Проверка на наличие узлов
return # Если нет узлов, ничего не делаем
energy_x = self.nodes[0][0] * self.size + 10
energy_y = self.nodes[0][1] * self.size + 10
fill(0)
no_stroke()
rect(energy_x, energy_y, 60, 20)
fill(255)
text_size(12)
text_align(CENTER)
text(int(self.energy), energy_x + 30, energy_y + 15)
Теперь бы отмершему мицелию почву удобрять. Да новым мицелиям по клику появляться. Попробуете это сделать?
Ну как же без редактора
А давайте напоследок сделаем так, чтобы мицелии хранились в массиве. И при клике мышки начинал развиваться новый мицелий.
Создадим массив для хранения мицелиев. Заменим отдельные переменные mycelium1
и mycelium2
на массив myceliums
:
myceliums = [] # Список для хранения мицелиев
# Создание и добавление двух начальных мицелиев
for _ in range(2):
mycelium_x = int(rand(0, environment.width))
mycelium_y = int(rand(0, environment.height))
mycelium_color = color(rand(200, 255), rand(0, 100), rand(0, 100))
myceliums.append(Mycelium(mycelium_x, mycelium_y, environment, mycelium_color))
Добавим обработчик события мыши: используем метод mouse_clicked()
, чтобы добавлять новый мицелий по координатам клика:
def mouse_clicked():
# Добавление нового мицелия в место клика
mycelium_x = mouse_x // CELL_SIZE
mycelium_y = mouse_y // CELL_SIZE
mycelium_color = color(rand(100, 200), rand(150, 255), rand(50, 150))
myceliums.append(Mycelium(mycelium_x, mycelium_y, environment, mycelium_color))
Обновим методы роста и отображения мицелиев: пройдём по массиву в методе draw()
и вызовем соответствующие методы роста и отображения для каждого мицелия:
def draw():
if frame_count % 10 == 0:
background(0)
environment.display()
# Рост и отображение всех мицелиев
for i in range(len(myceliums)):
mycelium = myceliums[i]
for other_mycelium in myceliums:
if other_mycelium != mycelium:
mycelium.grow(other_mycelium)
mycelium.display()
И получаем такие сражения:
Запускайте код по ссылке выше, чтобы поэкспериментировать.
Итого
-
Мы написали простую симуляцию мицелия, представленного сетью узлов, который живёт в окружающей среде из сетки клеток с ресурсами.
-
Отрефакторили код для улучшения производительности и ускорения визуализации.
-
Разнообразили визуализацию и адаптировали её к разным размерам экрана.
-
Реализовали конкуренцию между мицелиями, добавив возможность «поедания» друг друга.
-
Добавили разнообразия: случайные стартовые положения и цвета.
Получилось много гифок и кода, который вы можете запустить в один клик в браузере. Если вам было интересно, то это может быть хорошим фундаментом в дальнейших экспериментах с симуляцией Царства Грибов.
Если вам понравилось быстро тестировать гипотезы в браузере, то вот ещё статьи про p5py
:
-
Давайте-ка наваяем PumpKeen Game. Как Commander Keen, только про Pumpkin (тыкву). Хэллоуин же
-
Хотите, покажу вам магию живого кода на p5py? (Про Игру Жизнь)
-
📖 Как я написал книгу для детей: «Мама, не отвлекай. Я Python учу!»
Про темы, связанные с p5py иногда (редко) пишу в канале @p4kids