Хотите, покажу вам магию живого кода на p5py?

Вдохновившись статьёй, посвящённой написанию клеточного автомата на Godot и экспорту проекта в HTML, хочу показать вам, как использовать для этих целей модерновый онлайн-движок p5py. Код живой не только потому, что мы про игру «Жизнь», но и благодаря способу его разработки и запуска. Всё очень живо!

godot

Чёрный плащ

TL; DR: финальный проект вот здесь. Только кликните, и он появится.

В чем магия?

  1. Мы получим похожий результат, но намного быстрее и увлекательнее. Нам не придётся ничего настраивать и скачивать, что отлично подходит для новичков. Просто переходите по ссылкам в статье и запускайте рабочие примеры.

  2. Экспорт в HTML нам тоже не понадобится, поскольку код на Python, благодаря p5py и онлайн-IDE, запускается прямо в браузере.

  3. Более того, если у вас возникнет творческая идея, как улучшить код, и вы её реализуете, то: а) сразу увидите результат, б) нажав «Сохранить», получите готовую ссылку, которой можно поделиться с друзьями или в комментариях.

p5py

Это адаптация популярного Processing (p5.js) для Python. Я написал его для проведения занятий в детских кружках по программированию и для книги, про которую уже рассказывал в статье «Как я написал книгу для детей: «Мама, не отвлекай. Я Python учу!».

godot

Для него же разработана и онлайн-IDE, которая запускается по всем ссылкам в этой статье. Там всего две кнопки: «Запустить» и «Сохранить». Не перепутаете.

Получаем

  1. Вместо GDScript — самый стандартный Python (+модуль p5py).

  2. Не нужен отдельный экспорт — результат сразу доступен по ссылке онлайн.

  3. Живая песочница — код автоматически перезапускается в живом режиме по мере его написания (по желанию можно отключить).

  4. Размер не гигабайт, а 4,5 мегабайта.

А был он в Жизнь влюбленный…

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

Исходный код на Godot:

extends Node2D
@export var cell_scene : PackedScene
var row_count : int = 45
var column_count : int = 80
var cell_width: int = 15
var cell_matrix: Array = []
var previous_cell_states: Array = []
var is_game_running: bool = false
var is_interactive_mode: bool = false

Наш код на p5py:

row_count = 45
column_count = 80
cell_width = 15

Почему такой короткий? А потому, потому, потому…

…что мы используем проектно-ориентированный подход и не пишем строчки кода, которые не пригодятся на следующем шаге. Зачем их держать в голове? Только шум создают. А пока что, всё, что нам нужно знать, — это размер поля 45х80 и размер ячейки 15х15.

Полем, полем, полем…

Белым, белым сделаем пустое поле из клеточек.

from p5py import *
run()

row_count = 45
column_count = 80
cell_width = 15

size(column_count * cell_width, row_count * cell_width)

def draw():
    background(255)  # Белый фон
    draw_grid()

def draw_grid():
    stroke(0)  # Черные линии сетки
    for x in range(column_count + 1):
        line(x * cell_width, 0, x * cell_width, row_count * cell_width)
    for y in range(row_count + 1):
        line(0, y * cell_width, column_count * cell_width, y * cell_width)

 # Здесь мы просто отображаем пустое поле

empty

Нажмите здесь, чтобы запустить. Если вдруг выдало ошибку, напишите, пожалуйста, в личку. Версия всё ещё 0.XXX, могут быть баги.

Поэкспериментируйте! Поменяйте цвет фона и линий. Например, background(200, 100, 0) — это оттенок оранжевого, а stroke(255, 120, 0) — оттенок красного.

Случай решит, как заполнить ячейки

Улучшим оригинальный код:

  1. Сразу заполняем поле случайными значениями — так игроку интереснее наблюдать за процессом. При запуске сразу что-то происходит.

  1. А так как в online-IDE p5py легко нажать крестик, закрыв программу, и сразу же нажать RUN, снова её запустив, — можно играть с разными стартовыми условиями.

# Инициализация состояния клеток случайными значениями
cell_matrix = [[rand(0, 15) <= 1 for _ in range(row_count)] for _ in range(column_count)]

def draw_cells():
    for col in range(column_count):
        for row in range(row_count):
            if cell_matrix[col][row]:
                fill(0)  # Черный цвет для живых клеток
            else:
                no_fill()  # Без заливки для мертвых клеток
            rect(col * cell_width, row * cell_width, cell_width, cell_width)

random

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

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

Самое время перейти к…

Обновлению поля. Игра не по правилам

Добавим функцию update_game_state(), которая просто пройдёт по каждой клетке поля и запишет в неё новый статус. Который узнает, в свою очередь, у функции get_next_state()

def update_game_state():
    global cell_matrix

    for col in range(column_count):
        for row in range(row_count):
            cell_matrix[col][row] = get_next_state(col, row)

def get_next_state(column, row):
    return rand(0, 15) <= 1

change

А вот и ссылка на поиграться.

Да-да, пока игра не по правилам. Мы используем mock (заглушку), чтобы побыстрее увидеть результат и было интересно по шагам улучшать программу.

А теперь перейдем к…

Игровым правилам

А вот и самое важное — создание правил для игры «Жизнь». Пишем функции для подсчёта живых соседей и определения будущего состояния каждой клетки. Это классические правила Конвея: клетка становится живой, если у неё ровно три живых соседних клетки, и остаётся живой, если у неё две или три живых соседних клетки.

Исходный код на Godot:

func get_count_of_alive_neighbours(column, row):
    var count = 0
    for x in range(-1, 2):
        for y in range(-1, 2):
            if not (x == 0 and y == 0):
                var neighbor_column = column + x
                var neighbor_row = row + y
                if neighbor_column >= 0 and neighbor_column < column_count and neighbor_row >= 0 and neighbor_row < row_count:
                    if previous_cell_states[neighbor_column][neighbor_row]:
                        count += 1
    return count

func get_next_state(column, row):
    var current = previous_cell_states[column][row]
    var neighbours_alive = get_count_of_alive_neighbours(column, row)

    if current:
        return neighbours_alive == 2 or neighbours_alive == 3
    else:
        return neighbours_alive == 3

Практически такой же на p5py:

def get_next_state(column, row):
    alive_neighbors = count_alive_neighbors(column, row)
    current = previous_cell_states[column][row]

    if current:
        # Cell is alive, it stays alive if it has 2 or 3 neighbors
        return alive_neighbors == 2 or alive_neighbors == 3
    else:
        # Cell is dead, it becomes alive if it has exactly 3 neighbors
        return alive_neighbors == 3

def count_alive_neighbors(column, row):
    count = 0
    for x in range(-1, 2):
        for y in range(-1, 2):
            if x == 0 and y == 0:
                continue  # Skip the cell itself
            neighbor_col = column + x
            neighbor_row = row + y
            if 0 <= neighbor_col < column_count and 0 <= neighbor_row < row_count:
                if previous_cell_states[neighbor_col][neighbor_row]:
                    count += 1
    return count

Но и функцию update_game_state() нам тоже придётся немного поменять, чтобы сохранять предыдущее значение ячеек во временный массив.

Как думаете, для чего этот шаг?

def update_game_state():
    global previous_cell_states, cell_matrix

    # Copy current state to previous
    for col in range(column_count):
        for row in range(row_count):
            previous_cell_states[col][row] = cell_matrix[col][row]

    # Apply the Game of Life rules
    for col in range(column_count):
        for row in range(row_count):
            cell_matrix[col][row] = get_next_state(col, row)

А если хотите больше Python-стиля, то замените на previous_cell_states = [row.copy() for row in cell_matrix].

Парам-пам-пам

life4

Ну вот и всё! Вот он, наш готовый код.

NoDB: баг или фича

Вы уже заметили, что ссылка на наш код длинная, как предложения в книгах Хулио Кортасара. Но так задумано. Это современный подход NoDB (точнее, URL-based storage), когда небольшие программы немного сжимаются и сохраняются прямо в URL. Небольшие, поручик, я сказал, небольшие!

Вообще, за несколько часов перед публикацией предыдущей статьи сломалась основная база данных, и я временно посадил проект на запасной аэродром NoDB, который был готов раньше. Как появится время, верну сохранение в постоянной базе данных и короткие ссылки.

Добавим интерактивность

В оригинальной статье пользователь мог сам рисовать существ на поле. Давайте сделаем так же на p5py, но только немного улучшим использование исходного кода:

  1. Сразу включим режим «вмешательства». Пусть игрок сможет сразу добавлять новые фигуры: is_game_running = True.

  2. Уберём кнопку включения-выключения этого рисования, так как неясно, зачем она нужна. Только интерфейс перегружает.

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

Добавим в def draw():

…подсказку:

fill(255, 140)
    text_size(20)
    text("Вы можете нарисовать фигуру мышкой", 30, 30)

…и запуск/остановку игры:

if is_game_running:
    update_game_state()
if mouse_is_pressed:
    toggle_cell_at_mouse_position()
    is_game_running = False
def toggle_cell_at_mouse_position():
    col = int(mouse_x / cell_width)
    row = int(mouse_y / cell_width)

    if 0 <= col < column_count and 0 <= row < row_count:
        cell_matrix[col][row] = True

def mouse_released():
    global is_game_running
    is_game_running = True

The end. Рисуйте…

life7

Сылка на код.

Когда мобайл-друзья со мной

Если вы вдруг читаете эту статью на мобильном и кликнули по одной из ссылок выше, то результат оказался нехороший: поле в исходной статье фиксированной ширины и вылезает за границы. Но наш IDE для p5py адаптирован под мобильные. Давайте сразу поправим код. Просто добавим авторасчёт ширины и высоты под текущий экран. Заменим это:

cell_width = 15

column_count = 80
row_count = 45

size(column_count * cell_width, row_count * cell_width)

на это:

cell_width = 15

 # Проверяем условия для установки размеров окна
 # Предположим, что 600 — это ширина типичного мобильного устройства
if display_width < 600:
    w = display_width
    h = display_height * 2 // 3
else:
    w = display_width * 2 // 4
    h = display_height * 2 // 4

 # Рассчитываем количество строк и столбцов
column_count = w // cell_width
row_count = h // cell_width

w = column_count * cell_width
h = row_count * cell_width

size(w, h)

life8_1

life8_1

И вот, теперь можно кликать и с мобильных: сюда.

Что можно сделать еще?

Давайте улучшим usability.

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

Тому, кто только начал изучать Python, может быть интересно сделать такое задание самостоятельно. При желании делитесь своими решениями и улучшениями в комментариях.

Примечание

Модуль p5py имеет множество ограничений. В отличие от Godot, он не предназначен для больших и средних проектов. Зато хорош для маленьких демонстраций и в учебных целях.

Сообщество. Там, где трудно одному…

Справлюсь вместе с вами. Мини-IDE и p5py получились хорошими и добрыми. Решил потихоньку-понемногу собирать сообщество вокруг p5py. Вот мой старенький и почти пустой Telegram-канал: https://t.me/p4kids. Попробую собрать заинтересованных учителей, преподавателей и родителей вокруг этой технологии.

© Habrahabr.ru