Привет! Сегодня мы поговорим о том, как сделать код не просто красивым, но и живым. Звучит как научная фантастика, либо вы уже подготовились к очередной банальности про искусственный интеллект, но не в этом посте. В 1970 году британский математик Джон Хортон Конвей показал миру, что даже простейшие алгоритмы могут порождать сложные, живые системы, которые ещё и к тому же полные по Тьюрингу. И что код может быть не только красивым, но и живым.
К слову о красивом коде, слышали про «Конкурс красоты кода» от Сбера? Вот где можно развернуться по полной. Выбирайте из имеющихся заданий которое понравится и творите что вашей душе угодно. Главное — это ваши идеи и умение их воплотить в коде. Так что не стесняйтесь, регистрируйтесь на конкурс и покажите, как должен выглядеть по‑настоящему красивый код. А там, глядишь, и призы какие‑нибудь получите. Ну что, готовы принять вызов? Тогда вперёд, за красивым кодом!
А теперь вернёмся к «Живому коду». Конвей придумал «Игру жизни» — клеточный автомат, который при всей своей простоте способен генерировать невероятно сложные паттерны, имитирующие жизнь. И сегодня мы воссоздадим эту игру, используя современные инструменты. Но чтобы сделать это чуть менее банально, чем обычно — мы сделаем это на open-source движке Godot, который в последнее время набирает популярность как достойный конкурент Unity, особенно в мире инди‑разработки.
Почему Godot? Во‑первых, как уже сказали, он опенсорсный, что уже даёт +10 очков относительно проприетарного собрата, требующего лицензионных отчислений. А также потому что это как швейцарский нож в мире игровых движков: вроде и функций куча, а весит всего ничего, менее гигабайта. И веб‑экспорт у него, по идее, столь же прост и однокнопочен, как в Unity, что мы и проверим. А мы как раз хотим не просто сделать игру, но и сделать её пригодной для размещения на каком‑нибудь
Подготовка: структура проекта
Для начала давайте разберёмся, что нам понадобится для создания нашей «Игры жизни». Открываем Godot и создаём новый проект. Вот что нам нужно сделать:
-
Создать две сцены:
-
game.tscn (основная сцена игры);
-
cell.tscn (сцена для отдельной клетки);
-
-
В game.tscn добавить следующие ноды:
-
Game (Node2D) (корневая нода);
-
CheckButton (для запуска и остановки симуляции);
-
InteractiveButton (тоже CheckButton но переименованная, для переключения интерактивного режима);
-
-
В cell.tscn добавить только спрайт с текстурой для отображения живой клетки.
-
В 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 через ряд достаточно простых действий:
-
переходим в меню «Project» → «Export»;
-
нажимаем «Add» и выбираем «Web»;
-
настраиваем параметры, либо ничего не трогаем;
-
жмём «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()
Переходим далее по созданному адресу, и вуаля — игра готова!
Ну что, мы прошли путь от простых правил до живого организма на экране. Красота, не правда ли? И ведь это только верхушка айсберга того, на что способен по‑настоящему красивый код.
Автор: Sber