Красивый код — живой код. Делаем клеточный автомат на Godot и экспортируем в HTML

от автора

Привет, Хабр! Сегодня мы поговорим о том, как сделать код не просто красивым, но и живым. Звучит как научная фантастика, либо вы уже подготовились к очередной банальности про искусственный интеллект, но не в этом посте. В 1970 году британский математик Джон Хортон Конвей показал миру, что даже простейшие алгоритмы могут порождать сложные, живые системы, которые ещё и к тому же полные по Тьюрингу. И что код может быть не только красивым, но и живым.

К слову о красивом коде, слышали про «Конкурс красоты кода» от Сбера? Вот где можно развернуться по полной. Выбирайте из имеющихся заданий которое понравится и творите что вашей душе угодно. Главное — это ваши идеи и умение их воплотить в коде. Так что не стесняйтесь, регистрируйтесь на конкурс и покажите, как должен выглядеть по‑настоящему красивый код. А там, глядишь, и призы какие‑нибудь получите. Ну что, готовы принять вызов? Тогда вперёд, за красивым кодом!

А теперь вернёмся к «Живому коду». Конвей придумал «Игру жизни» — клеточный автомат, который при всей своей простоте способен генерировать невероятно сложные паттерны, имитирующие жизнь. И сегодня мы воссоздадим эту игру, используя современные инструменты. Но чтобы сделать это чуть менее банально, чем обычно — мы сделаем это на open-source движке Godot, который в последнее время набирает популярность как достойный конкурент Unity, особенно в мире инди‑разработки.

Почему Godot? Во‑первых, как уже сказали, он опенсорсный, что уже даёт +10 очков относительно проприетарного собрата, требующего лицензионных отчислений. А также потому что это как швейцарский нож в мире игровых движков: вроде и функций куча, а весит всего ничего, менее гигабайта. И веб‑экспорт у него, по идее, столь же прост и однокнопочен, как в Unity, что мы и проверим. А мы как раз хотим не просто сделать игру, но и сделать её пригодной для размещения на каком‑нибудь хостинге в качестве демки, чтобы каждый мог посмотреть на красоту живого кода в действии отрендеренной HTML‑страницы.

Подготовка: структура проекта

Для начала давайте разберёмся, что нам понадобится для создания нашей «Игры жизни». Открываем Godot и создаём новый проект. Вот что нам нужно сделать:

  1. Создать две сцены:

    • game.tscn (основная сцена игры);

    • cell.tscn (сцена для отдельной клетки);

  2. В game.tscn добавить следующие ноды:

    • Game (Node2D) (корневая нода);

    • CheckButton (для запуска и остановки симуляции);

    • InteractiveButton (тоже CheckButton но переименованная, для переключения интерактивного режима);

  3. В cell.tscn добавить только спрайт с текстурой для отображения живой клетки.

  4. В Editor → Manage Export Templates заранее нажмём «Download and Install» для шаблонов экспорта, что пригодится нам ближе к финалу.

Как можно заметить, это и близко не ракетостроение и не сложнее создания проекта что в Unity, что в Unreal.

Фундамент: базовая функциональность

Основной скрипт

Начнём с написания скрипта для основной сцены. Создаём файл game.gd и прикрепляем его к ноде Game. Вот базовая структура:

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

Что здесь происходит? Мы определяем основные переменные для нашей игры. @export var cell_scene : PackedScene — это особая фишка Godot, которая позволяет нам через инспектор связать сцену клетки с основной сценой. Весьма удобно, пусть и ничего необычного.

Но одних переменных мало. Нам нужно как-то инициализировать наше игровое поле. Добавляем функцию:

func initialize_game():     cell_matrix.clear()     previous_cell_states.clear()     for column in range(column_count):         cell_matrix.push_back([])         previous_cell_states.push_back([])         for row in range(row_count):             var cell = cell_scene.instantiate()             self.add_child(cell)             cell.position = Vector2(column * cell_width, row * cell_width)             cell.visible = false             previous_cell_states[column].push_back(false)             cell_matrix[column].push_back(cell)

Она создаёт двумерный массив клеток, аккуратно располагая их на игровом поле. Каждая клетка изначально невидима (считай, мертва). Но это пока.

Правила игры

Теперь самое интересное — реализация правил «Игры жизни». Добавляем функции для подсчёта живых соседей и определения следующего состояния клетки:

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

Эти функции — сердце нашей игры. Они реализуют классические правила Конвея: клетка оживает, если у неё ровно три живых соседа, и выживает, если у неё два или три соседа. Просто? Да. Эффективно? Безусловно!

Жизненный цикл: обновление состояния игры

Теперь нам нужна функция, которая будет обновлять состояние всего игрового поля. Добавляем:

func update_game_state():     for column in range(column_count):         for row in range(row_count):             previous_cell_states[column][row] = cell_matrix[column][row].visible          for column in range(column_count):         for row in range(row_count):             cell_matrix[column][row].visible = get_next_state(column, row)

Эта функция — дирижёр нашего клеточного оркестра. Сначала она сохраняет текущее состояние всех клеток, а затем обновляет их на основе правил игры.

Интерактивность: пусть игрок тоже поучаствует

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

func _input(event):     if event is InputEventMouseButton:         if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:             var click_position = get_global_mouse_position()             var column = int(click_position.x / cell_width)             var row = int(click_position.y / cell_width)             if column >= 0 and column < column_count and row >= 0 and row < row_count:                 toggle_cell(column, row)  func toggle_cell(column, row):     if not is_game_running or is_interactive_mode:         cell_matrix[column][row].visible = not cell_matrix[column][row].visible         previous_cell_states[column][row] = cell_matrix[column][row].visible 

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

Управление: кнопки — наше всё

Помните, мы добавили в сцену две кнопки? Пора заставить их работать. Создадим для них отдельные скрипты.

check_button.gd:

extends CheckButton  signal game_state_changed(is_running: bool)  func _ready():     text = "Стоп"     toggled.connect(_on_toggled)  func _on_toggled(button_pressed: bool):     text = "Стоп" if button_pressed else "Старт"     emit_signal("game_state_changed", button_pressed)

Interactive_button.gd:

extends CheckButton  signal interactive_mode_changed(is_interactive: bool)  func _ready():     text = "Интерактив: Выкл"     toggled.connect(_on_toggled)  func _on_toggled(button_pressed: bool):     text = "Интерактив: Вкл" if button_pressed else "Интерактив: Выкл"     emit_signal("interactive_mode_changed", button_pressed)

Связываем всё воедино

Теперь нам нужно связать все эти части вместе. Дополняем наш основной скрипт:

game.gd
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 var is_mouse_pressed: bool = false var last_toggled_cell: Vector2 = Vector2(-1, -1)  @onready var check_button = $CheckButton @onready var interactive_button = $InteractiveButton @onready var update_timer = $UpdateTimer  # Константы для позиционирования и размера кнопок const BUTTON_MARGIN: int = 10 const BUTTON_WIDTH: int = 120 const BUTTON_HEIGHT: int = 40 const UPDATE_INTERVAL: float = 0.5  # Полсекунды  # Цвет линий сетки и цвет фона const GRID_COLOR: Color = Color.BLACK const BACKGROUND_COLOR: Color = Color.WEB_GRAY  # Отдельный узел для сетки var grid_node: Node2D  func _ready(): # Подключаем сигнал изменения размера окна get_tree().root.size_changed.connect(self.on_window_resize)  # Подключаем сигналы изменения состояния игры и интерактивного режима check_button.game_state_changed.connect(_on_game_state_changed) interactive_button.interactive_mode_changed.connect(_on_interactive_mode_changed)  # Устанавливаем свойства кнопок check_button.size = Vector2(BUTTON_WIDTH, BUTTON_HEIGHT) check_button.text = "Старт" interactive_button.size = Vector2(BUTTON_WIDTH, BUTTON_HEIGHT) interactive_button.text = "Интерактивный режим"  # Настраиваем таймер update_timer = Timer.new() add_child(update_timer) update_timer.connect("timeout", Callable(self, "_on_update_timer_timeout")) update_timer.set_wait_time(UPDATE_INTERVAL) update_timer.set_one_shot(false)  # Создаем узел сетки grid_node = Node2D.new() add_child(grid_node)  on_window_resize()  func draw_grid(): # Удаляем старый узел сетки и создаем новый grid_node.queue_free() grid_node = Node2D.new() add_child(grid_node) grid_node.draw.connect(self._on_grid_draw) grid_node.queue_redraw()  func _on_grid_draw(): # Рисуем белый фон var background_rect = Rect2( 0,  BUTTON_HEIGHT + BUTTON_MARGIN,  column_count * cell_width,  row_count * cell_width ) grid_node.draw_rect(background_rect, BACKGROUND_COLOR)  # Рисуем вертикальные линии сетки for x in range(column_count + 1): var start = Vector2(x * cell_width, BUTTON_HEIGHT + BUTTON_MARGIN) var end = Vector2(x * cell_width, BUTTON_HEIGHT + BUTTON_MARGIN + row_count * cell_width) grid_node.draw_line(start, end, GRID_COLOR)  # Рисуем горизонтальные линии сетки for y in range(row_count + 1): var start = Vector2(0, BUTTON_HEIGHT + BUTTON_MARGIN + y * cell_width) var end = Vector2(column_count * cell_width, BUTTON_HEIGHT + BUTTON_MARGIN + y * cell_width) grid_node.draw_line(start, end, GRID_COLOR)  func _on_game_state_changed(is_running: bool): is_game_running = is_running if is_game_running: update_timer.start() else: update_timer.stop()  func _on_interactive_mode_changed(is_interactive: bool): is_interactive_mode = is_interactive  func _on_update_timer_timeout(): update_game_state()  func initialize_game(): # Очищаем матрицы и удаляем старые ячейки cell_matrix.clear() previous_cell_states.clear() for child in get_children(): if child != check_button and child != interactive_button and child != update_timer and child != grid_node: child.queue_free()  # Рисуем сетку перед созданием ячеек draw_grid()  # Создаем новые ячейки for column in range(column_count): cell_matrix.push_back([]) previous_cell_states.push_back([]) for row in range(row_count): var cell = cell_scene.instantiate() self.add_child(cell) cell.position = Vector2(column * cell_width, row * cell_width + BUTTON_HEIGHT + BUTTON_MARGIN) cell.visible = false previous_cell_states[column].push_back(false) cell_matrix[column].push_back(cell)  # Включаем обработку ввода set_process_input(true)  func on_window_resize(): # Пересчитываем размеры игрового поля при изменении размера окна var window_size = get_viewport_rect().size column_count = int(window_size.x / cell_width) row_count = int((window_size.y - BUTTON_HEIGHT - BUTTON_MARGIN) / cell_width)  # Позиционируем кнопки check_button.position = Vector2(BUTTON_MARGIN, BUTTON_MARGIN) interactive_button.position = Vector2(BUTTON_MARGIN * 2 + BUTTON_WIDTH, BUTTON_MARGIN)  initialize_game()  func is_edge(column, row): # Проверяем, является ли ячейка краевой return row == 0 or column == 0 or row == row_count-1 or column == column_count-1  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): # Определяем следующее состояние ячейки согласно правилам игры if is_edge(column, row): return false 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  func update_game_state(): # Сохраняем текущее состояние ячеек for column in range(column_count): for row in range(row_count): previous_cell_states[column][row] = cell_matrix[column][row].visible  # Обновляем состояние ячеек for column in range(column_count): for row in range(row_count): cell_matrix[column][row].visible = get_next_state(column, row)  func _input(event): # Обрабатываем пользовательский ввод if event is InputEventMouseButton: if event.button_index == MOUSE_BUTTON_LEFT: is_mouse_pressed = event.pressed if is_mouse_pressed: toggle_cell_at_mouse_position() elif event is InputEventMouseMotion and is_mouse_pressed: toggle_cell_at_mouse_position()  func toggle_cell_at_mouse_position(): # Переключаем состояние ячейки под курсором мыши var click_position = get_global_mouse_position() var column = int((click_position.x) / cell_width) var row = int((click_position.y - BUTTON_HEIGHT - BUTTON_MARGIN) / cell_width)  if column >= 0 and column < column_count and row >= 0 and row < row_count: var current_cell = Vector2(column, row) if current_cell != last_toggled_cell: toggle_cell(column, row) last_toggled_cell = current_cell  func toggle_cell(column, row): # Переключаем состояние конкретной ячейки if not is_game_running or is_interactive_mode: cell_matrix[column][row].visible = not cell_matrix[column][row].visible previous_cell_states[column][row] = cell_matrix[column][row].visible

Теперь наша игра запускается, останавливается, позволяет взаимодействовать с клетками — всё начинает оживать.

Финальный штрих: экспорт в HTML5

Теперь, когда наша «Игра жизни» готова, давайте сделаем её живой и в браузере. Godot делает экспорт в HTML5 через ряд достаточно простых действий:

  1. переходим в меню «Project» → «Export»;

  2. нажимаем «Add» и выбираем «Web»;

  3. настраиваем параметры, либо ничего не трогаем;

  4. жмём «Export All».

Получившийся файл мы дальше переименовываем в index.html и нет, не запускаем, для этого нам потребуется HTTPS‑сервер. Запустим его с помощью следующего кода на Python (server.py), в той же папке, что и наш index.html:

import http.server, ssl, os  # Абсолютный путь до сервера thisScriptPath = os.path.dirname(os.path.abspath(__file__)) + '/'  # Создаём самоподписанный сертификат через openssl def generate_selfsigned_cert():     try:         OpenSslCommand = 'openssl req -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out ' + thisScriptPath + 'cert.pem -keyout ' + thisScriptPath + 'key.pem -subj "/C=IN/ST=Maharashtra/L=Satara/O=Wannabees/OU=KahiHiHa Department/CN=www.iamselfdepartment.com"'         os.system(OpenSslCommand)         print('<<<<Certificate Generated>>>>>>')     except Exception as e:         print(f'Error while generating certificate: {e}')  # Запускаем сервер на заданном порту def startServer(host, port):     server_address = (host, port)     httpd = http.server.HTTPServer(server_address, http.server.SimpleHTTPRequestHandler)      # Создаём SSL-сертификат     context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)     context.load_cert_chain(certfile=thisScriptPath + "cert.pem", keyfile=thisScriptPath + "key.pem")      # Оборачиваем сокет в SSL     httpd.socket = context.wrap_socket(httpd.socket, server_side=True)      print("File Server started at https://" + server_address[0] + ":" + str(server_address[1]))     httpd.serve_forever()  # запускаем скрипт def main():     try:         generate_selfsigned_cert()         # адрес и порт можно поменять         startServer('localhost', 8000)     except KeyboardInterrupt:         print("\nFile Server Stopped!")     except Exception as e:         print(f"Error starting server: {e}") # вызываем основную функцию main()

Переходим далее по созданному адресу, и вуаля — игра готова!

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


ссылка на оригинал статьи https://habr.com/ru/articles/852220/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *