20 декабря 2023

🐍🧫 Создаем игру «Жизнь» Джона Конвея на Python

Автор статей по блокчейну, криптовалюте, безопасности и общим темам
В этой статье напишем алгоритм эволюции клеточных автоматов и разработаем интерфейс командной строки (CLI) для взаимодействия с игрой.
🐍🧫 Создаем игру «Жизнь» Джона Конвея на Python
Данная статья является переводом. Ссылка на оригинал.

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

В этом руководстве вы научитесь:

  1. Реализовывать алгоритм игры «Жизнь» Конвея на Python.
  2. Использовать библиотеку curses для работы с сеткой игры «Жизнь».
  3. Создавать интерфейс командной строки argparse для игры.
  4. Настраивать приложение игры для установки и запуска.

Чтобы извлечь максимальную пользу из этого руководства, вы должны знать основы написания объектно-ориентированного кода на Python, создания приложений с интерфейсом командной строки (CLI) с помощью argparse и разработки проекта на Python.

Получите свой код: Нажмите здесь, чтобы загрузить исходный код (.zip) для создания игры «Жизнь» Конвея на Python.

Описание проекта

Игра «Жизнь» британского математика Джона Хортона Конвея не является игрой в традиционном смысле этого слова. С технической точки зрения, это клеточный автомат, но вы можете рассматривать игру «Жизнь» как симуляцию, развитие которой зависит от ее начального состояния и не требует дальнейшего участия игроков.

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

  1. Живая.
  2. Мертвая.

Каждая клетка эволюционирует в следующее поколение в зависимости от состояния ее самой и соседних клеток. Вот краткое описание правил эволюции:

  1. Живые клетки погибают, если у них меньше двух («малонаселенность») или больше трех живых соседей («перенаселение»).
  2. Живые клетки остаются жизнеспособными, если у них есть два или три живых соседа.
  3. Мертвые клетки, имеющие ровно три живых соседа, становятся живыми (размножение).

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

Задача этого проекта – запрограммировать алгоритм эволюции на Python, а затем разработать интерфейс командной строки (CLI) для запуска игры с различными моделями жизни.

🐍 Библиотека питониста
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека питониста»
🐍🎓 Библиотека собеса по Python
Подтянуть свои знания по Python вы можете на нашем телеграм-канале «Библиотека собеса по Python»
🧩🐍 Библиотека задач по Python
Интересные задачи по Python для практики можно найти на нашем телеграм-канале «Библиотека задач по Python»

Предварительные условия

Проект, который вы будете создавать в этом уроке, потребует от вас знакомства с общим программированием на Python и особенно с объектно-ориентированным программированием. Поэтому вы должны обладать базовыми знаниями по следующим темам:

  1. Работа с условными операторами.
  2. Написание циклов и генераторов.
  3. Работа со строками, кортежами, списками и множествами в Python.
  4. Создание регулярных классов и классов данных.
  5. Создание интерфейсов командной строки с помощью argparse.
  6. Работа с файлами TOML в Python.

Однако если у вас еще нет всех этих знаний – не беда! Вы можете узнать больше, начав работу над проектом.

После этого краткого обзора игры «Жизнь» и его предпосылок вы готовы приступить к работе. Получайте удовольствие от кодинга!

🎓 Статьи по теме

Шаг 1: Настройка проекта игры «Жизнь»

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

Для проекта на Python, реализующего игру «Жизнь» Конвея, у вас может быть много разных макетов. Поэтому лучше сначала подумать о том, что вы хотите или должны сделать. Вот краткое содержание:

  1. Реализовать алгоритм игры «Жизнь», включая сетку жизни и семена или образцы
  2. Обеспечить возможность визуализации сетки и ее эволюции
  3. Дать пользователю возможность выбрать шаблон и запустить игру на определенное количество поколений.

Следуя этим идеям, вы создадите следующую структуру каталогов для своей игры «Жизнь»:

        rplife/
│
├── rplife/
│   ├── __init__.py
│   ├── __main__.py
│   ├── cli.py
│   ├── grid.py
│   ├── patterns.py
│   ├── patterns.toml
│   └── views.py
│
├── README.md
└── pyproject.toml

    

В этом руководстве вы можете назвать проект rplife, что является комбинацией Real Python (rp) и life. Файл README.md будет содержать описание проекта и инструкции по установке и запуску приложения.

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

Файл pyproject.toml – это TOML-файл, который определяет процесс создания проекта и многие другие конфигурации. В современном Python этот файл заменяет скрипт setup.py, который вы могли использовать раньше. Таким образом, в этом проекте вы будете использовать pyproject.toml вместо setup.py.

Внутри каталога rplife/ находятся следующие файлы:

  1. __init__.py включает rplife/ как пакет Python.
  2. __main__.py работает как скрипт входа в игру.
  3. cli.py содержит интерфейс командной строки для игры.
  4. patterns.py и patterns.toml обрабатывают шаблоны игры.
  5. views.py реализует способ отображения сетки и ее эволюции.
  1. grid.py обеспечивает реализацию сетки.

Теперь создайте все эти файлы без какого-либо содержимого. Вы можете сделать это в вашем любимом редакторе кода или IDE. Как только вы закончите создание макета проекта, можно приступать к реализации правил игры «Жизнь» на Python.

💡Примечание
Когда вы начинаете новый проект Python, лучше всего создать для него специальную виртуальную среду Python. Это можно сделать, выполнив соответствующую команду для вашей текущей операционной системы:

Windows:

        PS> python -m venv venv
PS> venv\Scripts\activate
(venv) PS>

    

Linux + macOS:

        $ python -m venv venv
$ source venv/bin/activate
(venv) $

    

С помощью этих двух команд вы создадите и активируете виртуальную среду Python под названием venv в своем рабочем каталоге.

Шаг 2: Создание сетки игры «Жизнь»

Как вы уже знаете, основным компонентом игры «Жизнь» является бесконечная двухмерная сетка из ячеек. Этот элемент может показаться сложным для реализации, потому что он бесконечен. Поэтому вам нужен способ абстрагироваться от этого требования. Для этого вы сосредоточитесь на живых клетках, а не на всех клетках сетки.

Чтобы отобразить начальный набор живых клеток, вы используете класс Pattern, который представляет собой начало игры. Затем вы создадите класс LifeGrid, который возьмет шаблон и разовьет его до следующего поколения живых клеток, применяя правила игры. Этот класс также предоставит строковое представление сетки, чтобы вы могли отобразить ее на экране.

Для загрузки кода этого шага перейдите по следующей ссылке и загляните в папку source_code_step_2/:

Набросок классов Pattern и LifeGrid

Чтобы приступить к реализации алгоритма игры, откройте редактор кода и перейдите к файлу patterns.py. Там создайте класс Pattern, используя декоратор @dataclass из модуля dataclasses:

        from dataclasses import dataclass

@dataclass
class Pattern:
    name: str
    alive_cells: set[tuple[int, int]]

    

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

Теперь вам нужен класс LifeGrid, который будет решать две конкретные задачи:

  1. Эволюцию сетки до следующего поколения
  2. Предоставление строкового отображения сетки

Итак, ваш класс будет иметь следующие атрибуты и методы. Не забывайте, что этот класс будет располагаться в модуле grid.py:

        class LifeGrid:
    def __init__(self, pattern):
        self.pattern = pattern

    def evolve(self):
        pass

    def as_string(self, bbox):
        pass

    def __str__(self):
        pass

    

Для создания базы LifeGrid вы использовали pass. Инициализатор класса принимает в качестве аргумента шаблон. Этим аргументом будет служить Pattern. Затем есть .evolve(), проверяющая текущие живые клетки и их соседей, для определения следующего поколения живых клеток.

Наконец, в .as_string() вы обеспечите способ представления сетки в виде строки, которую можно отобразить в терминале. Обратите внимание, что этот метод принимает аргумент, который предоставляет ограничительную рамку для сетки. Эта область будет определять, какую часть сетки вы отобразите в вашем терминале.

Переход от одного поколения сетки к другому

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

  1. Живые клетки погибают, если у них меньше двух («малонаселенность») или больше трех живых соседей («перенаселенность»).
  2. Живые клетки остаются живыми, если у них есть два или три живых соседа.
  3. Мертвые клетки, имеющие ровно три живых соседа, становятся живыми (размножение).

Подождите, у вас есть только координаты живых клеток. Как вы можете проверить соседей определенной живой клетки? Рассмотрим следующую диаграмму, которая представляет собой небольшую часть сетки:

Небольшая часть клетки
Небольшая часть клетки

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

  1. Для (0, 0) прибавьте (-1, -1) к (1, 1) значение за значением.
  2. Для (0, 1) добавьте (-1, 0) к (1, 1) по значению.
  3. Для (0, 2) прибавьте (-1, 1) к (1, 1) по значению.

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

С учетом этих идей вы готовы к реализации метода .evolve():

        import collections

class LifeGrid:
    # ...
    def evolve(self):
        neighbors = (
            (-1, -1),  # Above left
            (-1, 0),  # Above
            (-1, 1),  # Above right
            (0, -1),  # Left
            (0, 1),  # Right
            (1, -1),  # Below left
            (1, 0),  # Below
            (1, 1),  # Below right
        )
        num_neighbors = collections.defaultdict(int)
        for row, col in self.pattern.alive_cells:
            for drow, dcol in neighbors:
                num_neighbors[(row + drow, col + dcol)] += 1

        stay_alive = {
            cell for cell, num in num_neighbors.items() if num in {2, 3}
        } & self.pattern.alive_cells
        come_alive = {
            cell for cell, num in num_neighbors.items() if num == 3
        } - self.pattern.alive_cells

        self.pattern.alive_cells = stay_alive | come_alive

    

Вот разбор того, что делает этот код (построчно):

  1. Строки с 6 по 15 определяют дельта-координаты для соседей целевой ячейки.
  2. В строке 16 используется словарь для подсчета количества живых соседей. В этой строке вы задействуете класс defaultdict из модуля collections для создания счетчика с классом int в качестве основного объекта.
  3. В строке 17 выполняется цикл по живым ячейкам, которые хранятся в объекте .pattern. Этот цикл позволяет проверить соседей каждой живой клетки, чтобы определить следующее поколение.
  4. Строка 18 запускает цикл по смещениям соседних клеток. Этот внутренний цикл подсчитывает, со сколькими клетками соседствует текущая ячейка. Этот подсчет позволяет узнать количество соседей как для живых, так и для мертвых клеток.
  5. В строках 21-23 создается список, содержащий клетки, которые останутся в живых. Для этого сначала создается совокупность соседей, которые сами имеют двух или трех живых соседей. Затем находятся клетки, которые являются общими как для этого множества, так и для .alive_cells.
  6. В строках 24-26 создается совокупность клеток, которые будут возрождаться. В этом случае вы создаете группу клеток, у которых ровно три живых соседа. Затем вы определяете воскресшие клетки, исключая те, что уже есть в .alive_cells.
  7. Строка 28 обновляет .alive_cells множеством, которое получается в результате объединения клеток, которые остаются в живых, и клеток, которые возрождаются.

Чтобы проверить, работает ли ваш код так, как нужно, необходимо определить количество живых клеток в каждом поколении. Добавьте следующий метод в LifeGrid:

        import collections

class LifeGrid:
    # ...
    def evolve(self):
        # ...

    def __str__(self):
        return (
            f"{self.pattern.name}:\n"
            f"Alive cells -> {sorted(self.pattern.alive_cells)}"
        )

    

Специальный метод .__str__() позволяет представить содержимое объекта в удобном для пользователя виде. С помощью этого метода, когда вы используете встроенную функцию print() для вывода на экран элемента LifeGrid, вы получаете имя соответствующего шаблона и множество живых ячеек в следующей строке. Эта информация дает вам представление о текущем состоянии сетки.

Теперь вы готовы протестировать свой код. Откройте новый терминал в корневом каталоге проекта. Затем начните сессию Python REPL и запустите следующий код:

        >>> from rplife import grid, patterns

>>> blinker = patterns.Pattern("Blinker", {(2, 1), (2, 2), (2, 3)})
>>> grid = grid.LifeGrid(blinker)
>>> print(grid)
Blinker:
Alive cells -> [(2, 1), (2, 2), (2, 3)]

>>> grid.evolve()
>>> print(grid)
Blinker:
Alive cells -> [(1, 2), (2, 2), (3, 2)]

>>> grid.evolve()
>>> print(grid)
Blinker:
Alive cells -> [(2, 1), (2, 2), (2, 3)]


    

В этом фрагменте кода вы сначала импортируете модули grid и patterns из пакета rplife. Затем вы создаете образец Pattern. В ближайшее время вы познакомитесь с разнообразием паттернов. А пока в качестве примера вы используете шаблон Blinker.

Далее вы создаете объект LifeGrid. Обратите внимание, что при выводе на экран этого объекта вы получите имя паттерна и живые ячейки. У вас есть рабочая сетка с подходящим исходным материалом. Теперь вы можете развивать сетку, вызвав .evolve(). На этот раз вы получите другой набор живых клеток.

Если вы снова модифицируете сетку, то получите тот же набор живых клеток, который вы использовали в качестве начального материала для игры. Это происходит потому, что паттерн Blinker представляет собой осциллятор, который эволюционирует подобным образом:

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

Шаблон Blinker отображает три горизонтальные живые клетки в одном поколении и три вертикальные живые клетки в следующем поколении. Ваш код делает то же самое, так что все работает, как и предполагалось.

Представление сетки в виде строки

Теперь, когда вы реализовали .evolve(), чтобы перевести игру в следующее поколение, вам нужно реализовать .as_string(). Как вы уже узнали, этот метод предусматривает процесс создания представления сетки в виде строки, чтобы вы могли отобразить ее на экране.

Ниже приведен фрагмент кода, в котором вы используете этот метод:

        import collections

ALIVE = "♥"
DEAD = "‧"

class LifeGrid:
    # ...
    def as_string(self, bbox):
        start_col, start_row, end_col, end_row = bbox
        display = [self.pattern.name.center(2 * (end_col - start_col))]
        for row in range(start_row, end_row):
            display_row = [
                ALIVE if (row, col) in self.pattern.alive_cells else DEAD
                for col in range(start_col, end_col)
            ]
            display.append(" ".join(display_row))
        return "\n ".join(display)

    

В этом коде вы сначала определяете две константы, ALIVE и DEAD. Эти константы содержат символы, которые вы будете использовать для обозначения живых и мертвых клеток на сетке.

Внутри .as_strings() вы разворачиваете координаты ограничительной рамки в четыре переменные. Эти параметры определяют, какую часть бесконечной сетки ваша программа будет отображать на экране. Затем вы создаете переменную display в качестве списка, содержащего имя шаблона. Обратите внимание, что вы используете .center() для центрирования названия по ширине сетки.

В цикле for выполняется итерация по диапазону строк внутри окна. В цикле создается новый список, содержащий живые и мертвые ячейки в текущем ряду. Чтобы выяснить, жива ли данная ячейка, проверяется, есть ли ее координаты в наборе живых ячеек.

Затем вы добавляете строку в виде последовательности в список отображения. В конце цикла вы объединяете каждую строку с помощью символа новой строки (\n), чтобы создать сетку жизни в виде строки.

Для проверки ваших изменений выполните следующий код в интерактивной среде:

        >>> from rplife import grid, patterns

>>> blinker = patterns.Pattern("Blinker", {(2, 1), (2, 2), (2, 3)})
>>> grid = grid.LifeGrid(blinker)

>>> print(grid.as_string((0, 0, 5, 5)))
 Blinker
 ‧ ‧ ‧ ‧ ‧
 ‧ ‧ ‧ ‧ ‧
 ‧ ♥ ♥ ♥ ‧
 ‧ ‧ ‧ ‧ ‧
 ‧ ‧ ‧ ‧ ‧

>>> grid.evolve()
>>> print(grid.as_string((0, 0, 5, 5)))
 Blinker
 ‧ ‧ ‧ ‧ ‧
 ‧ ‧ ♥ ‧ ‧
 ‧ ‧ ♥ ‧ ‧
 ‧ ‧ ♥ ‧ ‧
 ‧ ‧ ‧ ‧ ‧

    

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

Шаг 3: Настройка и загрузка шаблонов для игры «Жизнь»

До этого момента вы использовали класс LifeGrid и первую половину класса данных Pattern. Ваш код успешно выполняется. Однако создание исходных данных вручную кажется слишком сложной задачей. Было бы неплохо иметь несколько предустановленных шаблонов и загружать их в процессе выполнения игры.

В следующих разделах вы создадите несколько типовых паттернов в файле TOML и напишете код, необходимый для загрузки шаблонов в объекты Pattern.

Щелкните по ссылке ниже для загрузки кода для этого этапа, чтобы вы могли следовать за проектом. Вы найдете все необходимое в папке source_code_step_3/:

Настройка шаблонов жизни в файле TOML

Для создания шаблона для игры «Жизнь» вам понадобится название паттерна и набор координат для живых клеток. К примеру, используя формат файла TOML, вы можете представить шаблон Blinker следующим образом:

        ["Blinker"]
alive_cells = [[2, 1], [2, 2], [2, 3]]

    

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

Следуя этой же конструкции, вы можете задать столько шаблонов, сколько захотите. Паттерны, которые вы будете использовать в этом уроке в файле patterns.toml:

patterns.toml
        ["Blinker"]
alive_cells = [[2, 1], [2, 2], [2, 3]]

["Toad"]
alive_cells = [[2, 2], [2, 3], [2, 4], [3, 1], [3, 2], [3, 3]]

["Beacon"]
alive_cells = [[1, 1], [1, 2], [2, 1], [4, 3], [4, 4], [3, 4]]

["Pulsar"]
alive_cells = [
    [2, 4],
    [2, 5],
    [2, 6],
    [2, 10],
    [2, 11],
    [2, 12],
    [4, 2],
    [5, 2],
    [6, 2],
    [4, 7],
    [5, 7],
    [6, 7],
    [4, 9],
    [5, 9],
    [6, 9],
    [4, 14],
    [5, 14],
    [6, 14],
    [7, 4],
    [7, 5],
    [7, 6],
    [7, 10],
    [7, 11],
    [7, 12],
    [9, 4],
    [9, 5],
    [9, 6],
    [9, 10],
    [9, 11],
    [9, 12],
    [10, 2],
    [11, 2],
    [12, 2],
    [10, 7],
    [11, 7],
    [12, 7],
    [10, 9],
    [11, 9],
    [12, 9],
    [10, 14],
    [11, 14],
    [12, 14],
    [14, 4],
    [14, 5],
    [14, 6],
    [14, 10],
    [14, 11],
    [14, 12]
]

["Penta Decathlon"]
alive_cells = [
    [5, 4],
    [6, 4],
    [7, 4],
    [8, 4],
    [9, 4],
    [10, 4],
    [11, 4],
    [12, 4],
    [5, 5],
    [7, 5],
    [8, 5],
    [9, 5],
    [10, 5],
    [12, 5],
    [5, 6],
    [6, 6],
    [7, 6],
    [8, 6],
    [9, 6],
    [10, 6],
    [11, 6],
    [12, 6]
]

["Glider"]
alive_cells = [[0, 2], [1, 0], [1, 2], [2, 1], [2, 2]]

["Glider Gun"]
alive_cells = [
    [0, 24],
    [1, 22],
    [1, 24],
    [2, 12],
    [2, 13],
    [2, 20],
    [2, 21],
    [2, 34],
    [2, 35],
    [3, 11],
    [3, 15],
    [3, 20],
    [3, 21],
    [3, 34],
    [3, 35],
    [4, 0],
    [4, 1],
    [4, 10],
    [4, 16],
    [4, 20],
    [4, 21],
    [5, 0],
    [5, 1],
    [5, 10],
    [5, 14],
    [5, 16],
    [5, 17],
    [5, 22],
    [5, 24],
    [6, 10],
    [6, 16],
    [6, 24],
    [7, 11],
    [7, 15],
    [8, 12],
    [8, 13]
]

["Bunnies"]
alive_cells = [
    [10, 10],
    [10, 16],
    [11, 12],
    [11, 16],
    [12, 12],
    [12, 15],
    [12, 17],
    [13, 11],
    [13, 13]
]
    

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

Загрузка паттернов жизни из TOML

У вас есть файл TOML с кучей паттернов для вашей игры «Жизнь». Теперь вам нужен способ для загрузки этих шаблонов в ваш код. Для начала включите альтернативный конструктор в класс Pattern, который позволит вам создавать шаблоны из данных TOML:

        from dataclasses import dataclass

@dataclass
class Pattern:
    name: str
    alive_cells: set[tuple[int, int]]

    @classmethod
    def from_toml(cls, name, toml_data):
        return cls(
            name,
            alive_cells={tuple(cell) for cell in toml_data["alive_cells"]},
        )

    

Метод .from_toml() является классовым, поскольку вы используете декоратор @classmethod. Классовые методы отлично справляются с задачей, когда вам нужно добавить альтернативный конструктор в состав класса. Методы этого типа получают текущий класс в качестве первого аргумента (cls).

Затем в качестве аргументов вы принимаете имя шаблона и данные TOML. Внутри метода вы создаете и возвращаете экземпляр класса, используя аргумент cls. Чтобы задать аргумент .alive_cells, вы используете генератор.

В нем вы создаете набор кортежей из перечня списков, полученных из файла TOML. Каждый кортеж будет содержать координаты живой клетки на сетке. Обратите внимание, что для доступа к живым клеткам в данных TOML можно использовать поиск по словарю с использованием имени целевого ключа в квадратных скобках.

Далее необходимо добавить две функции. Первая функция позволит загрузить все паттерны из файла TOML. Вторая функция будет подгружать по одной детали за раз.

Чтобы разобрать файл TOML и преобразовать его содержимое в объекты Python, вы можете использовать стандартный библиотечный модуль tomllib, если вы используете Python 3.11 или более позднюю версию. В противном случае вам следует использовать стороннюю библиотеку tomli, которая совместима с tomllib.

Чтобы ваш код работал с каждым из этих инструментов, вы можете включить операторы импорта библиотек TOML в блок try ... except:

        from dataclasses import dataclass

try:
    import tomllib
except ImportError:
    import tomli as tomllib

    

Импорт в строке try нацелен на стандартный библиотечный модуль tomllib. Если этот импорт вызывает исключение, потому что вы используете Python версии ниже 3.11, то оператор except импортирует стороннюю библиотеку tomli, которую необходимо установить в качестве внешней зависимости вашего проекта.

Когда библиотека TOML будет установлена, настанет время для написания необходимых функций. Добавьте get_pattern() в файл patterns.py:

        # ...

def get_pattern(name, filename=PATTERNS_FILE):
    data = tomllib.loads(filename.read_text(encoding="utf-8"))
    return Pattern.from_toml(name, toml_data=data[name])

    

Эта функция принимает в качестве аргументов название целевого паттерна и имя файла TOML и возвращает объект Pattern, представляющий шаблон, название которого совпадает с аргументом name.

В первой строке функции get_pattern() вы загружаете содержимое файла patterns.toml, используя выбранную вами библиотеку TOML. Метод .loads() возвращает словарь. Затем вы создаете объект Pattern с помощью конструктора .from_toml() и возвращаете полученный результат.

Обратите внимание, что аргумент filename имеет значение по умолчанию, которое вы задаете с помощью константы. Вот как можно определить эту константу после завершения процесса импорта:

        from dataclasses import dataclass
from pathlib import Path
# ...

PATTERNS_FILE = Path(__file__).parent / "patterns.toml"

# …


    

Эта константа содержит элемент pathlib.Path, который указывает на файл patterns.toml. Не забывайте, что этот файл находится в вашем каталоге rplife/. Чтобы получить адрес этого каталога, вы используете атрибут __file__, который содержит путь к файлу, из которого был загружен модуль patterns.py. Затем вы используете атрибут .parent в Path, чтобы получить необходимую директорию.

Функция get_pattern() извлекает один паттерн из файла TOML, используя имя шаблона. Эта функция будет полезна, когда вы хотите запустить свою игру «Жизнь», используя один паттерн. А если вы хотите запустить несколько шаблонов подряд? В этом случае вам понадобится функция, которая получит все образцы из файла TOML.

Вот как реализуется эта функция:

        # ...

def get_all_patterns(filename=PATTERNS_FILE):
    data = tomllib.loads(filename.read_text(encoding="utf-8"))
    return [
        Pattern.from_toml(name, toml_data) for name, toml_data in data.items()
    ]

    

Эта функция принимает в качестве аргумента путь к файлу TOML. Первая строка аналогична строке get_pattern(). Затем вы создаете список объектов Pattern с помощью генератора. При этом используется метод .items() для словаря, который возвращает .loads().

После того как эти две функции будут введены, вы можете испытать их в действии:

        >>> from rplife import patterns

>>> patterns.get_pattern("Blinker")
Pattern(name='Blinker', alive_cells={(2, 3), (2, 1), (2, 2)})

>>> patterns.get_all_patterns()
[
    Pattern(name='Blinker', alive_cells={(2, 3), (2, 1), (2, 2)}),
    ...
]

    

Великолепно! Обе функции работают как положено. В этом примере вы сначала получаете паттерн Blinker с помощью функции get_pattern(). Затем вы выводите полный список доступных шаблонов с помощью функции get_all_patterns().

Шаг 4: Пишем визуальное представление игры

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

Чтобы загрузить код для этого шага, перейдите по ссылке ниже, а затем откройте папку source_code_step_4/:

Чтобы начать работу, следует задать класс CursesView:

        class CursesView:
    def __init__(self, pattern, gen=10, frame_rate=7, bbox=(0, 0, 20, 20)):
        self.pattern = pattern
        self.gen = gen
        self.frame_rate = frame_rate
        self.bbox = bbox

    

Классовый инициализатор принимает несколько аргументов. Вот их описание и значения:

  1. pattern представляет собой модель жизни, которую вы хотите отобразить на экране. Это должен быть объект Pattern.
  2. gen – количество поколений, через которые будет проходить игра. По умолчанию задано 10 поколений.
  3. frame_rate представляет собой количество кадров в секунду, которое является показателем времени между отображением одного поколения и следующим. По умолчанию установлено значение 7 кадров в секунду.
  4. bbox – ограничительная рамка для сетки. Это кортеж, который определяет, какая часть сетки будет отображаться. Это должен быть кортеж определенного вида (start_col, start_row, end_col, end_row).

Этот класс будет иметь только один метод в своем общедоступном интерфейсе. Метод .show() будет отвечать за отображение сетки на экране:

        import curses

class CursesView:
    # ...
    def show(self):
        curses.wrapper(self._draw)


    

Метод .show() довольно краток. Он включает в себя только вызов функции wrapper() из curses. Эта функция инициализирует curses и вызывает другой объект. В данном случае вызываемым объектом является закрытый метод ._draw(), который отвечает за отображение последовательных генераций ячеек.

Вот возможная реализация метода ._draw():

        import curses
from time import sleep

from rplife.grid import LifeGrid

class CursesView:
    # ...
    def _draw(self, screen):
        current_grid = LifeGrid(self.pattern)
        curses.curs_set(0)
        screen.clear()

        try:
            screen.addstr(0, 0, current_grid.as_string(self.bbox))
        except curses.error:
            raise ValueError(
                f"Error: terminal too small for pattern '{self.pattern.name}'"
            )

        for _ in range(self.gen):
            current_grid.evolve()
            screen.addstr(0, 0, current_grid.as_string(self.bbox))
            screen.refresh()
            sleep(1 / self.frame_rate)

    

В этом фрагменте кода происходит много всего. Ниже приведен построчный разбор:

  1. В строке 2 импортируется функция sleep() из модуля time. Вы будете использовать эту функцию для управления количеством кадров в секунду вашего изображения.
  2. Строка 4 импортирует класс LifeGrid из модуля grid в пакете rplife.
  3. В строке 8 определена функция ._draw(), которая принимает в качестве аргумента объект screen или curses window. Этот объект автоматически создается при вызове curses.wrapper() с ._draw() в качестве аргумента.
  4. В строке 9 определяется сетка путем внедрения класса LifeGrid с текущим шаблоном в качестве аргумента.
  5. В строке 10 вызывается .curs_set() для установки видимости курсора. В данном случае в качестве аргумента используется 0, что означает, что курсор будет невидимым.
  6. В строках с 13 по 18 определен блок try ... except, который вызывает исключение ValueError, если в текущем терминале недостаточно места для отображения сетки. Обратите внимание, что эту проверку нужно выполнить только один раз, поэтому не нужно включать ее в цикл в строке 20.
  7. Строка 20 запускает цикл, который будет выполняться столько раз, сколько существует поколений.
  8. Строка 21 вызывает .evolve() на сетке для развития игры до следующего поколения.
  9. Строка 22 вызывает .addstr() на текущем объекте экрана. Первые два аргумента определяют строку и столбец, в которых вы собираетесь нарисовать сетку жизни. В этом уроке вы начнете рисовать с точки (0, 0), которая является верхним левым углом терминала.
  10. Строка 23 обновляет экран, вызывая .refresh(). Этот вызов немедленно обновляет экран, чтобы отразить изменения, произошедшие после предыдущего вызова .addstr().
  11. В строке 24 вызывается функция sleep(), чтобы установить частоту кадров, которую вы будете использовать для отображения последовательных поколений в сетке.

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

        >>> from rplife.views import CursesView
>>> from rplife.patterns import get_pattern

>>> CursesView(get_pattern("Glider Gun"), gen=100).show()

    

В этом фрагменте вы импортируете класс CursesView из views и функцию get_pattern() из patterns. Затем вы создаете новый объект CursesView, используя шаблон Glider Gun и сотню поколений. И наконец, вы вызываете метод .show() для отображения результата. Этот код запустит игру и отобразит ее эволюцию через сотню поколений жизни.

Ух ты! Здорово! Ваша игра «Жизнь» набирает обороты. В следующих двух шагах вы разработаете интерфейс командной строки (CLI) для взаимодействия пользователей с игрой, и, наконец, соберете все вместе в скрипте стартовой точки игры, __main__.py.

Шаг 5: Реализация игрового CLI

В этом разделе вы создадите интерфейс командной строки (CLI) для вашей игры «Жизнь». Этот интерфейс позволит вашим пользователям взаимодействовать с игрой и запускать ее с различными моделями жизни. Для создания CLI вы будете использовать модуль argparse из стандартной библиотеки. Он содержит следующие параметры командной строки:

  1. --version покажет номер версии программы и завершит работу.
  2. -p, --pattern будет задан шаблон для игры «Жизнь», по умолчанию – Blinker.
  3. -a, --all покажет все доступные шаблоны в определенной последовательности.
  4. -v, --view отобразит сетку жизни в заданном режиме, по умолчанию CursesView.
  5. -g, --gen выводит количество поколений, по умолчанию 10.
  6. -f, --fps количество кадров в секунду, по умолчанию 7.

Чтобы загрузить код для этого шага, перейдите по следующей ссылке и найдите папку source_code_step_5/:

Чтобы начать писать CLI, добавьте следующий код в cli.py:

        import argparse

from rplife import __version__, patterns, views

def get_command_line_args():
    parser = argparse.ArgumentParser(
        prog="rplife",
        description="Conway's Game of Life in your terminal",
    )


    

В этом фрагменте кода вы сначала импортируете модуль argparse. Затем вы импортируете некоторые необходимые объекты из пакета rplife.

До этого этапа вы не обозначали __version__, поэтому откройте файл __init__.py. Затем добавьте строку __version__ = "1.0.0" в начало файла. Этот атрибут активирует опцию командной строки --version, которая часто встречается в CLI-приложениях и позволяет отображать текущую версию приложения.

Далее вы задаете функцию get_command_line_args(), чтобы завершить определение CLI. Внутри функции get_command_line_args() вы создаете парсер аргументов, используя ArgumentParser. В этом примере в качестве аргументов в процессе создания класса вы указываете только имя и описание программы.

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

        # ...

def get_command_line_args():
    # ...
    parser.add_argument(
        "--version", action="version", version=f"%(prog)s v{__version__}"
    )
    parser.add_argument(
        "-p",
        "--pattern",
        choices=[pat.name for pat in patterns.get_all_patterns()],
        default="Blinker",
        help="take a pattern for the Game of Life (default: %(default)s)",
    )
    parser.add_argument(
        "-a",
        "--all",
        action="store_true",
        help="show all available patterns in a sequence",
    )
    parser.add_argument(
        "-v",
        "--view",
        choices=views.__all__,
        default="CursesView",
        help="display the life grid in a specific view (default: %(default)s)",
    )
    parser.add_argument(
        "-g",
        "--gen",
        metavar="NUM_GENERATIONS",
        type=int,
        default=10,
        help="number of generations (default: %(default)s)",
    )
    parser.add_argument(
        "-f",
        "--fps",
        metavar="FRAMES_PER_SECOND",
        type=int,
        default=7,
        help="frames per second (default: %(default)s)",
    )
    return parser.parse_args()
    

В этом фрагменте кода вы добавляете все необходимые опции в CLI вашей игры, вызывая .add_argument() на парсерном объекте. Каждая опция имеет свои аргументы в зависимости от желаемого функционала.

Важно отметить, что опция -p, --pattern является параметром выбора, что означает, что входное значение должно точно соответствовать имени доступного шаблона. Чтобы получить названия всех имеющихся паттернов, можно воспользоваться списком и функцией get_all_patterns().

Функция get_command_line_args() включает в себя диапазон имен, содержащий аргументы командной строки и соответствующие им значения. Вы можете получить доступ к аргументам и их значениям, используя точечную нумерацию в диапазоне имен. К примеру, если вам нужно получить доступ к значению аргумента --view, то вы можете сделать так: get_command_line_args().view.

Опция -v, --view также является параметром выбора. В этом случае вы получаете доступные варианты из атрибута __all__, заданного в модуле views. Конечно, вы еще не определили __all__, поэтому вам нужно сделать это прямо сейчас. Откройте файл views.py и добавьте следующий оператор присваивания сразу после вашего импорта:

        import curses
from time import sleep

from rplife.grid import LifeGrid

__all__ = ["CursesView"]

# …

    

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

Отлично! Теперь у вашей игры есть удобный пользовательский интерфейс командной строки. Однако нет возможности опробовать его. Для начала необходимо написать сценарий входа в игру. Именно этим вы и займетесь в следующем разделе.

Шаг 6: Написание скрипта начала игры

В Python выполняемые программы имеют скрипт или файл запуска. Как следует из названия, скрипт входа – это сценарий, содержащий код, который запускает выполнение программы. В этот файл обычно помещается программная функция main().

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

В современном Python, как правило, файл __main__.py является оптимальным местом для исходного кода. Поэтому откройте файл __main__.py в редакторе кода. Затем добавьте в него следующее:

        import sys

from rplife import patterns, views
from rplife.cli import get_command_line_args

def main():
    args = get_command_line_args()
    View = getattr(views, args.view)
    if args.all:
        for pattern in patterns.get_all_patterns():
            _show_pattern(View, pattern, args)
    else:
        _show_pattern(
            View,
            patterns.get_pattern(name=args.pattern),
            args
        )

    

Вот построчное объяснение приведенного выше кода:

  1. В строке 1 импортируется модуль sys из стандартной библиотеки. Вы будете использовать этот модуль для доступа к файлу sys.stderr, в который вы будете записывать все ошибки, возникающие во время выполнения программы.
  2. В строке 3 импортируются модули patterns и views из rplife. С их помощью вы определите, какой шаблон жизни и какой режим работы использовать.
  3. Строка 4 импортирует get_command_line_args() из модуля cli. Вы будете использовать ее для разбора аргументов и опций командной строки.
  4. В строке 6 определяется функция main().
  5. Строка 7 вызывает get_command_line_args() и сохраняет полученный объект диапазона имен в args.
  6. В строке 8 используется аргумент командной строки args.view для доступа к нужному изображению на модуле views. Чтобы получить доступ к данным, вы используете встроенную функцию getattr(). Таким образом, вы обеспечиваете масштабируемость кода, позволяя добавлять новые типы представлений без необходимости модифицировать main(). Обратите внимание, что поскольку вызов getattr() в данном случае использует класс, вы использовали заглавную букву в слове View, чтобы обозначить этот факт.
  7. В строке 9 определяется условный оператор, проверяющий, выбрал ли пользователь запуск всех доступных шаблонов подряд. Если это так, то строки 10 и 11 выполняют цикл по всем шаблонам и выводят их на экран с помощью вспомогательной функции _show_pattern(). Вы сможете задать эту функцию в ближайшее время.
  8. Строки с 13 по 17 выполняются каждый раз, когда пользователь выбирает определенный шаблон для запуска игры.

Вспомогательная функция _show_pattern() является важной частью main(). Вот ее формулировка:

        # ...

def _show_pattern(View, pattern, args):
    try:
        View(pattern=pattern, gen=args.gen, frame_rate=args.fps).show()
    except Exception as error:
        print(error, file=sys.stderr)

    

В этой функции вы используете текущий вид, шаблон и аргументы командной строки в качестве входных данных. Затем с помощью блока try ... except вы создаете объект view и запускаете его метод .show(). Этот блок перехватит и обработает любое исключение, которое может возникнуть в процессе развития игры, и выведет сообщение об ошибке в стандартный блок с ошибками. Таким образом, вы показываете удобное для пользователя сообщение об ошибке вместо сложной истории исключений.

Отлично! Скрипт входа почти готов. Осталось добавить одну маленькую деталь. Нужно вызвать main(), чтобы программа заработала:

        # ...

if __name__ == "__main__":
    main()


    

Способ запуска функции main() в исполняемом скрипте – это использование фразы name-main, как это сделано в приведенном выше фрагменте кода. Эта идиома гарантирует, что функция main() будет запущена только тогда, когда вы запустите файл как исполняемую программу.

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

        $ python -m rplife -a
    

Выглядит просто потрясающе, не правда ли? Вы можете изучить, как работают остальные опции командной строки. Попробуйте! Опция --help позволит вам узнать, как использовать CLI-приложения.

Шаг 7: Настройка игры для установки и запуска

До этого момента вы выполнили все необходимые действия, чтобы получить полнофункциональную реализацию игры «Жизнь» Конвея. Теперь у игры есть удобный интерфейс командной строки, который позволяет запускать ее с различными опциями. Вы можете запустить игру с одним шаблоном, со всеми доступными шаблонами и так далее.

Несмотря на то что игра «Жизнь» прекрасно работает, для запуска игры все равно нужно использовать команду python. Это немного раздражает и может создать ощущение, что игра не является настоящим приложением CLI.

В следующих разделах вы узнаете, как настроить игру «Жизнь» для установки с помощью файла pyproject.toml. Вы также узнаете, как установить игру в виртуальную среду Python, чтобы запустить ее как отдельное CLI-приложение.

Чтобы загрузить код для этого заключительного шага, перейдите по следующей ссылке и найдите папку source_code_step_7/:

Написание файла pyproject.toml

В последние годы сообщество Python переходит к использованию файлов pyproject.toml в качестве основного файла конфигурации для упаковки и дистрибуции проектов Python. В этом разделе вы узнаете, как написать стандартный файл pyproject.toml для вашей игры «Жизнь».

Как следует из расширения, pyproject.toml использует формат TOML. Полную информацию о написании файлов pyproject.toml вы можете найти в PEP 621. Следуя этой инструкции, ниже приведен файл pyproject.toml для вашей игры «Жизнь»:

        [build-system]
requires = ["setuptools>=64.0.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "rplife"
dynamic = ["version"]
description = "Conway's Game of Life in your terminal"
readme = "README.md"
authors = [{ name = "Real Python", email = "info@realpython.com" }]
dependencies = [
    'tomli; python_version < "3.11"',
]

[project.scripts]
rplife = "rplife.__main__:main"

[tool.setuptools.dynamic]
version = {attr = "rplife.__version__"}

    

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

В поле «Проект» вы указываете имя проекта – rplife. Затем вы задаете версию проекта как динамическую переменную, которая позже будет загружена в строку tool.setuptools.dynamic в конце файла.

Далее необходимо описать проект, предоставить путь к файлу README и указать авторов. Ключ dependencies содержит список внешних зависимостей для этого проекта. В данном случае необходимо установить библиотеку tomli для обработки файла TOML, если версия Python меньше 3.11.

В строке project.scripts вы определяете стартовую команду приложения, которой является функция main() из __main__.py пакета rplife.

Наконец, вы загружаете номер версии приложения из константы __version__ dunder, которую вы определили в файле __init__.py вашего пакета rplife.

Вот и все! У вас есть минимальный жизнеспособный файл pyproject.toml для вашей игры «Жизнь». Теперь вы и ваши пользователи можете установить программу и использовать ее как обычное приложение командной строки.

Установка и запуск игры «Жизнь»

После того как вы создали необходимый файл pyproject.toml для вашей игры «Жизнь», можно приступать к установке проекта в специальную виртуальную среду Python. Выполните следующие команды в терминале, если вы не сделали этого в первом шаге этого руководства:

Windows:

        PS> python -m venv venv
PS> venv\Scripts\activate
(venv) PS> python -m pip install -e .

    

Linux + macOS:

        $ python -m venv venv
$ source venv/bin/activate
(venv) $ python -m pip install -e .

    

С помощью этих команд вы создаете и активируете новую виртуальную среду. После того как среда будет активирована, вы можете установить проект в редактируемом режиме с помощью опции -e в pip install.

Режим редактирования очень удобен, когда вы работаете над проектом на Python. Он позволяет использовать проект как отдельное приложение и испытывать его, как это делается в производстве. В этом режиме вы можете продолжать добавлять функции и модифицировать код, тестируя его в режиме реального времени.

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

        $ rplife -p "Glider Gun" -g 100
    

В данном случае вы используете команду rplife напрямую, чтобы запустить игру с шаблоном Glider Gun на сто поколений. Обратите внимание, что вам больше не нужно использовать команду python. Теперь ваш проект работает как обычное приложение командной строки. Разве это не здорово?

Заключение

Вы разработали игру «Жизнь» Конвея с помощью Python и объектно-ориентированного программирования. Чтобы сделать игру более удобной для пользователей, вы позаботились о создании понятного интерфейса командной строки с помощью argparse. В процессе работы вы научились структурировать и оформлять CLI-приложение, а также настраивать его для дистрибуции и установки. Это отличный опыт для разработчика на Python.

В этом руководстве вы узнали, как:

  1. Реализовывать алгоритм игры «Жизнь» с помощью ООП
  2. Использовать curses для отображения сетки игры «Жизнь»
  3. Обеспечивать игру интерфейсом командной строки argparse
  4. Настраивать игру для установки и запуска

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

Опять же, вы можете загрузить полный исходный код и другие ресурсы для этого проекта по ссылке ниже:

Получите свой код: Нажмите здесь, чтобы загрузить исходный код (.zip) для создания игры «Жизнь» Конвея на Python.

Следующие шаги

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

Вот несколько идей для новых возможностей:

  1. Внедрить другие типы представлений: Наличие других вариантов, помимо основанного на curses, будет отличным дополнением к вашему проекту. К примеру, вы можете использовать Tkinter, в котором вы будете отображать сетку жизни в окне графического интерфейса.
  2. Добавьте новые интересные шаблоны жизни: Добавление новых шаблонов жизни в файл patterns.toml позволит вам исследовать другие варианты развития игры.
  3. Измените правила: До сих пор вы работали с традиционными правилами, согласно которым мертвые клетки с тремя живыми соседями перерождаются, а живые клетки с двумя или тремя живыми соседями выживают. Сокращенно это называется B3/S23, но существует несколько вариаций, использующих другие правила для эволюции нового поколения. Меняя правила, вы можете познакомиться с другими вселенными, похожими на человеческую жизнь.
***

Статьи по теме

Источники

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ