Привет, Хабр! Сегодня мы поговорим о том, как сделать код не просто красивым, но и живым. Звучит как научная фантастика, либо вы уже подготовились к очередной банальности про искусственный интеллект, но не в этом посте. В 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()

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

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

Комментарии (4)


  1. boschgmbh
    21.10.2024 11:51

    if previous_cell_states[neighbor_column][neighbor_row]:

    А с чем сравнивается это условие?


    1. Arkology
      21.10.2024 11:51

      Насколько я вижу, там boolean. То есть в условии проверка на равенство true.


      1. boschgmbh
        21.10.2024 11:51

        Спасибо!


  1. zloe-zlo
    21.10.2024 11:51

    Может быть сберу стоит уделять больше внимания своим продуктам, подумать над их красотой, весом, юзабилити? Ну и поправьте ссылку, на скачку сбердиска https://sberdisk.ru/sberdisk/android/topics/android_howdownload.html выдаёт 404