
В первой части статьи мы с вами предались ностальгии по замечательной телепередаче «Позвоните Кузе». К сожалению, машину времени пока не изобрели и мы не сможем субботним утром позвонить обаятельным ведущим в надежде «попасть в телевизор».
Но зато мы можем написать свою мини-игру про кота Кузьму, в которой реализуем аналогичное управление персонажем с помощью любого телефона с функцией тонального набора.
Для этого в первой части статьи мы разработали небольшой веб-сервис, а также написали сценарий голосового бота VoiceBox для управления с помощью телефона. Во второй части статьи мы разработаем мини-игру на движке Godot 4 и соберем все вместе.
Оглавление:
В предыдущей серии
Ранее мы разработали инфраструктуру для управления игровым персонажем по телефону.
Напомню схему взаимодействия компонентов.

План был такой:
Написать простой веб-сервис для управления персонажем через запросы к API.
Подготовить сценарий для голосового бота MTT VoiceBox, который будет по нажатию клавиш в тональном режиме вызывать API и передавать в него команду для движения вверх или вниз.
Написать мини-игру на движке Godot 4 и собрать всё вместе.
В прошлой статье мы выполнили первые два пункта, а значит пришла пора переходить к самому интересному.
Мини-игра на Godot
Для начала необходимо сказать, что я сам только недавно начал изучать Godot, поэтому этот пример не стоит считать эталоном. Я, по сути, модифицировал пример из туториала.
Но в этом есть и один большой плюс. Если вы прошли официальный туториал «Your first 2D game», то вы уже знаете 90% решений, реализованных в нашей игре. Поэтому я буду подробно останавливаться только на тех решениях, которые не рассмотрели в туториале.
Вы, наверное, уже догадались, что геймплей у нас будет очень простой.
По небу летает кот в ступе и старается уворачиваться от летящих в его сторону ворон.
Если игрок ни разу не столкнется с вороной в течение 61 секунды — это победа, ну а если столкнется, то наступит Гамовер. Вы можете доработать игру и разнообразить геймплей, например, добавить какой-нибудь Бонус.

Для разработки мы будем использовать версию движка Godot 4.0.3.
Исходный код игры и все ресурсы можно найти на GitHub.
Структура каталогов:
корень — основные файлы игры — сцены и скрипты;
art — изображения для спрайтов;
source — полезные вещи, напрямую не связанные с игрой. Исходники для спрайтов в формате Krita и файлы для веб-сервиса.
Игра по сути состоит из 5 сцен.
Main.tscn — главная сцена, в ней всё скомпоновано;
Cloud.tscn — отвечает за облака;
Crown.tscn — наши враги — вороны;
Player.tscn — наш персонаж кот Кузьма;
Hud.tscn — элементы интерфейса стартового экрана.
Cloud — сцена с облаками
Начнем с одной из самых простых сцен.
Общая логика сцены основывается на «Creating the enemy» туториала. Есть только одно отличие: для облаков мы не будем обрабатывать столкновения.
Сцена состоит из трех элементов:
Cloud (RigidBody) — корневой элемент. Я взял этот тип просто потому, что он описан в туториале, на самом деле нам не нужны его физические свойства. Не обращайте внимания на предупреждения. В данном случае не страшно, что у облака нет компонента определяющего его форму.
VisibleOnScreenNotifier2D — нужен для реализации логики удаления облака.
AnimatedSprite2D — непосредственно изображения облака. Мы берем именно анимированный спрайт и пример из туториала. Это поможет нам переключать три разных формы облачка так, будто это разные анимации.
Древо сцены:

Я не буду подробно останавливаться на всех параметрах объектов. Предлагаю скачать проект и посмотреть вживую. Но все же общий вид экрана оставлю для наглядности.

Важно: не забудьте поставить облаку отсутствие массы и гравитации.

Код метода короткий, поэтому не будем прятать его под спойлер.
extends RigidBody2D # Called when the node enters the scene tree for the first time. func _ready(): var cloud_types = $AnimatedSprite2D.sprite_frames.get_animation_names() $AnimatedSprite2D.play(cloud_types [randi() % cloud_types.size()]) # delete unused instance of cloud func _on_visible_on_screen_notifier_2d_screen_exited(): queue_free() # Called every frame. 'delta' is the elapsed time since the previous frame. func _process(delta): pass
В функции Ready в момент создания экземпляра мы выбираем одну из трех анимаций облака, чтобы они были разными.
В функции func _on_visible_on_screen_notifier_2d_screen_exited(), мы удаляем экземпляр облака, когда он вылетит за границы экрана.
Обработка сигналов у облака и вороны, можно подсмотреть на примере Mob в туториале. Поэтому я не буду уделять этому внимание.
Crown — сцена с вороной
Логика поведения вороны практически идентична логике сцены Mob из туториала.
Сцена с вороной практически такая же как и с облаком.
Но есть два отличия.
Я сделал всего одну анимацию вороны, но вы можете добавить и другие. Логика под это реализована в коде.
Добавляется нода CollisionShape2D, которая отвечает за обработку столкновений с котом.

Поскольку теперь нам важно отслеживать столкновения, для ноды Crown установите значение layer = 1 (мы еще вернемся к этому при настройке сцены игрока).

У вороны также, как и у облака, нет массы и гравитации.

Код сцены под спойлером.
Код сцены
extends RigidBody2D # Called when the node enters the scene tree for the first time. func _ready(): var cloud_types = $AnimatedSprite2D.sprite_frames.get_animation_names() $AnimatedSprite2D.play(cloud_types [randi() % cloud_types .size()]) # delete unused instance of cloud func _on_visible_on_screen_notifier_2d_screen_exited(): queue_free() # Called every frame. 'delta' is the elapsed time since the previous frame. func _process(delta): pass
Player — сцена с управляемым персонажем
А вот и сцена для нашего кота Кузьмы.
Общая логика сцены основывается на «Creating the player scene» и «Coding the player» туториала. Но безусловно тут есть отличия, как минимум в способе управления персонажем.
Сцена состоит из следующих элементов:
Player (Area2D) — корневой элемент. Тело без физики, потому что наш персонаж не подчиняется законам мироздания.
AnimatedSprite2D — аналогичен облаку, только теперь у нас настоящая анимация.
CollisionShape2D — зона обработки столкновений аналогично вороне.
HTTPRequest — нода, в которой реализованы методы и сигналы отправки http запроса к нашему API.
Request Timer (Timer) — таймер, по которому мы отправляем http запрос.
Давайте кратко пробежимся по настройкам.
В ноде Player надо поставить Mask = 1 чтобы мы отслеживали столкновения с воронами.

В AnimatedSprite2D создаем анимацию для спокойного состояния и анимацию перемещения.

В CollisionShape2D просто настраиваем форму для обработки столкновений.

В HTTPRequest я вроде оставил параметры по умолчанию.
В RequestTimer мы запускаем таймер каждую секунду, с автоматическим началом запуска. Можно привязать начало к нажатию кнопки «start», но я поленился.

Пришло время поговорить о настройках проекта.
Нам важны две группы параметров.
Первая — размер окна (у меня 760 х 480 пикселей).

Вторая — обработка клавиш. Поскольку во время тестов удобно управлять персонажем с клавиатуры.

Немного забегу вперед и напомню, что управление с клавиатуры — не главная фишка игры. В прошлой статье мы разработали сценарий голосового бота VoiceBox, который позволит нам управлять Кузьмой с помощью клавиш 2 или 8 телефона прямо во время звонка. Правда, из-за ограничений сценария, после 12 нажатий звонок сбросится и придется перезвонить еще раз.
Перейдем к коду. Полный листинг спрятан под спойлером:
Код сцены
extends Area2D @export var speed = 5100; #player speed var screen_size # Size of the game windo enum sky_positions {UP, MIDLE, BOTTOM, GROUND} var row_size= 180 # size to screen cell for player movement in pixels var player_sky_pos = sky_positions.UP var moving = false var calling_key = "" var user_config = "" #config from file #signal for collision signal hit # collision logic func _on_body_entered(body): hide() # Player disappears after being hit. hit.emit() # Must be deferred as we can't change physics properties on a physics callback. $CollisionShape2D.set_deferred("disabled", true) # initiate player for game func start(pos): position = pos show() $CollisionShape2D.disabled = false # Called when the node enters the scene tree for the first time. func _ready(): screen_size = get_viewport_rect().size position.y = 0 moving = false $AnimatedSprite2D.animation = "stand" $AnimatedSprite2D.play() #get config from file user_config = fload() # read config from JSON file func fload(): var file = FileAccess.open("res://game-config.json", FileAccess.READ) var content = file.get_as_text() file.close() var result_json = JSON.parse_string(content) return result_json #logic for player's movement func go_fly(): moving = true $AnimatedSprite2D.animation = "walk" calling_key = "" # Called every frame. 'delta' is the elapsed time since the previous frame. func _process(delta): var velocity = Vector2.ZERO # The player's movement vector. # Player can't move before last movement ended if moving == false: # read keyboard keys or API command's state if Input.is_action_pressed("move_down") or calling_key == "down" : player_sky_pos +=1 if player_sky_pos > sky_positions.BOTTOM: player_sky_pos = sky_positions.BOTTOM go_fly() if Input.is_action_pressed("move_up") or calling_key == "up" : player_sky_pos -=1 if player_sky_pos < sky_positions.UP: player_sky_pos = sky_positions.UP go_fly() # check the position boundary for move player to current state if round(position.y) > ((player_sky_pos) * row_size) : velocity.y -= (speed * delta) elif round(position.y) < ((player_sky_pos ) * row_size) : velocity.y += (speed * delta) else: velocity.y =0 position.y = player_sky_pos * row_size moving = false $AnimatedSprite2D.animation = "stand" position += velocity * delta # axis x player boundary (it's not critical you may remove it) position.x = clamp(position.x, 0, screen_size.x) # parse response after request to API success ended func _on_http_request_request_completed(result, response_code, headers, body): var json = JSON.parse_string(body.get_string_from_utf8()) calling_key = json.key #regular calling API by timer func _on_request_timer_timeout(): $HTTPRequest.request(user_config.server_url+"/command.php?phone="+user_config.phone)
Поскольку основная структура кода взята из туториала, я остановлюсь подробнее только на различиях.
Переменные:
sky_positions — фиксированные позиции кота на экране;
row_size — примерно ⅓ экрана;
var player_sky_pos — текущая ячейка экрана, в которой находимся или к которой стремится;
var moving = false — флаг о том, выполняется сейчас движение или нет.
Этот блок параметров нужен потому, что мы ограничены возможностями управления.
Вместо того, чтобы двигать персонажа по чуть-чуть при нажатии клавиши, мы заставляем его перемещаться в одну из трех доступных позиций на экране. При этом пока персонаж не доберется до заданной ячейки мы не сможем его перенаправить.
Отчасти это похоже на логику управления домовенком в большинстве игр, которые были в передаче «Позвоните Кузе».
Разберем следующие куски кода:
func _ready(): … user_config = fload() # read config from JSON file func fload(): var file = FileAccess.open("res://game-config.json", FileAccess.READ) var content = file.get_as_text() file.close() var result_json = JSON.parse_string(content) return result_json
Тут мы читаем конфигурацию игры.
Поскольку глупо зашивать адрес сервера и номер телефона в сам код, мы вынесем все это в отдельный файл, дабы вы могли легко пересобрать проект под свои настройки.
Файл настроек game-config.json выглядит так:
{ "phone":"79001112233", "story_url":"https://github.com/bosonbeard/voicebox-godot/blob/main/art/story.png", "server_url":"http://some.domain/for_your_game" }
phone — телефон игрока
story_url — ссылка на картинку с историей Кузьмы
server_url — ссылка на ваш сервер
func go_fly(): moving = true " class="formula inline">AnimatedSprite2D.animation = "walk" calling_key = ""
Эта функция запускает режим перемещения персонажа в новую точку.
func _process(delta): … if moving == false: # read keyboard keys or API command's state if Input.is_action_pressed("move_down") or calling_key == "down" : player_sky_pos +=1 if player_sky_pos > sky_positions.BOTTOM: player_sky_pos = sky_positions.BOTTOM go_fly() if Input.is_action_pressed("move_up") or calling_key == "up" : player_sky_pos -=1 if player_sky_pos < sky_positions.UP: player_sky_pos = sky_positions.UP go_fly() …
Проверяем, стоит ли персонаж на месте. Если да, то обрабатываем новую команду на перемещение в одну из трех ячеек экрана.
func _process(delta): … if round(position.y) > ((player_sky_pos) * row_size) : velocity.y -= (speed * delta) elif round(position.y) < ((player_sky_pos ) * row_size) : velocity.y += (speed * delta) else: velocity.y =0 position.y = player_sky_pos * row_size moving = false …
Пока наш персонаж потихоньку движется в заданном направлении, мы проверяем не достиг ли он координат заданной ячейки. Если кот достиг заданной точки, то мы переводим его в состояние покоя.
# parse response after request to API success ended func _on_http_request_request_completed(result, response_code, headers, body): var json = JSON.parse_string(body.get_string_from_utf8()) calling_key = json.key #regular calling API by timer func _on_request_timer_timeout(): " class="formula inline">HTTPRequest.request(user_config.server_url+"/command.php?phone="+user_config.phone)
В первой функции мы получаем команду на перемещение из ответа API.
Во второй функции при истечении таймера делаем новый запрос к API. Параметры для запроса берутся из конфига (см. выше).
Для тех, кто не читал первую часть статьи напомню, что мы можем, отправлять команды на управление персонажем, вызывая POST-метод API (command.php) внутри сценария голосового бота VoiceBox. Хотя в принципе в целях тестирования запросы к API можно делать с помощью обычного Postman или cURL.
Обратите внимание, что обе функции привязаны к сигналам.
HTTPRequest

RequestTimer

HID — сцена для стартового экрана.
Реализация сцены во многом бьется с туториалом. Правда, мы не ведем подсчет очков, а еще у нас есть кнопка-ссылка на историю Кузьмы, которая заодно является инструкцией.
Интерфейс будет накладываться, поверх неба в главной сцене.

Сцена состоит из следующих элементов:
HUD (CanvasLayer) — корневой элемент. Холст на котором мы все разместим.
Message (Label) — текстовая метка с названием игры или другим игровым сообщенеим.
StartButton (Button) — кнопка для запуска игры
MessageTimer (Timer) — таймер, помогает нам показывать сообщения, а потом их скрывать.
LinkButton — кнопка-ссылка ведет на страницу с историей Кузьмы. Ссылка откроется в браузере. Пользователь увидит картинку, которая по счастливому совпадению стала иллюстрацией к этой статье.
Давайте посмотрим ключевые параметры нод.
HUD — не менял.
Message — обратите внимание на текст:

А также на размер дефолтного шрифта:

StartButton — то же, что и label, меняли текст и размер шрифта на 40px.
MessageTimer — установили на 2 секунды с одноразовым срабатыванием.
LinkButton — мы изменили: ссылку, текст и цвет шрифта.
Ссылка и текст:

Цвет шрифта:

Полный код сцены спрятан под спойлером.
Код сцены
extends CanvasLayer signal start_game # control the message on title screen func show_message(text): $Message.text = text $Message.show() $MessageTimer.start() # load config func fload(): var file = FileAccess.open("res://game-config.json", FileAccess.READ) var content = file.get_as_text() file.close() var result_json = JSON.parse_string(content) return result_json func show_game_over(): show_message("Game Over") # Wait until the MessageTimer has counted down. await $MessageTimer.timeout $Message.text = "Kuzma - the flying cat!" $Message.show() # Make a one-shot timer and wait for it to finish. await get_tree().create_timer(1.0).timeout $StartButton.show() $LinkButton.show() func show_victory(): show_message("You win!") # Wait until the MessageTimer has counted down. await $MessageTimer.timeout $Message.text = "Kuzma - the flying cat!" $Message.show() # Make a one-shot timer and wait for it to finish. await get_tree().create_timer(1.0).timeout $StartButton.show() $LinkButton.show() # Called when the node enters the scene tree for the first time. func _ready(): #read url to story link $LinkButton.uri=fload().story_url # Called every frame. 'delta' is the elapsed time since the previous frame. func _process(delta): pass func _on_message_timer_timeout(): $Message.hide() func _on_start_button_pressed(): $StartButton.hide() $LinkButton.hide() start_game.emit()
Код в целом не сильно отличается от того, что был в туториале.
Обратить внимание стоит только на то, что я добавил сюда функцию fload (см. сцену Player). С помощью неё мы в функции ready читаем из конфига ссылку на адрес страницы с историей Кузьмы.
Main — главная сцена
Осталось все собрать воедино.
Main — это главная сцена, которая отображается при запуске игры. Она также, как и остальные, похожа на главную сцену из туториала.
Вот так выглядит главная сцена в редакторе:

Сцена состоит из следующих элементов:
Main (Node) — корневой элемент. В нем мы разместим остальные сцены.
Background — задник с небом. В туториале это просто заливка, а в нашей игре текстура.
Player — сцена с игроком. Обратите внимание, что в инспекторе объектов нет сцен с вороной и облаком, потому что мы их инициализируем с помощью кода.
CloudPath (Path2D) и CloudSpawnLocation (PathFollow2D) — зона, в которой будут создаваться облака и вороны.
StartTimer (Timer) — таймер для запуска игры.
CloudTimer (Timer) — таймер для генерации следующего облака.
MobTimer (Timer) — таймер для генерации следующей вороны.
FinishTimer (Timer) — таймер для окончания игры.
HUD — сцена интерфейса.
Давайте пробежимся по параметрам вложенных сцен.
Background — устанавливаем текстуру из папки art.

Player — без изменений.
CloudPath — зона для спавна ворон и облаков (я кажется там сделал «кривую» кривую (простите за каламбур), но вроде работает.

CloudSpawnLocation — как я понимаю, нужна для того, чтобы рандомно получать позицию для облаков и ворон внутри CloudPath.

StartTimer — задержка 1 секунда, one shot = true.
CloudTimer — задержка около 2 секунд , остальное false.
MobTimer — задержка 5 секунд , остальное false.
FinishTimer — задержка 61 секунда, one shot = true, Autostart = false.
HUD — без изменений.
Несколько слов про сигналы.
Все таймеры кроме FinishTimer запускают сигналы, которые обрабатываются функциями вида _on_***_timer_timeout().
FinishTimer — по истечению вызывает функцию victory.
HUD — связывает нажатие кнопки старт и функцию запуска новой игры

Player — проверяет сигналы столкновения

Пришло время поближе познакомится с кодом. Листинг, как всегда спрятан под спойлером.
Код сцены
extends Node @export var cloud_scene: PackedScene @export var mob_scene: PackedScene var start_pos = Vector2(25,1) # start position for player # Called when the node enters the scene tree for the first time. func _ready(): pass # Called every frame. 'delta' is the elapsed time since the previous frame. func _process(delta): pass func _on_start_timer_timeout(): $CloudTimer.start() $MobTimer.start() $FinishTimer.start() # unsuccess game ending func game_over(): $MobTimer.stop() $CloudTimer.stop() $FinishTimer.stop() $HUD.show_game_over() # function for success end game func victory(): $MobTimer.stop() $CloudTimer.stop() $FinishTimer.stop() $HUD.show_victory() get_tree().call_group("mobs", "queue_free") $Player.player_sky_pos = $Player.sky_positions.GROUND $Player.get_node("AnimatedSprite2D").animation = "walk" func new_game(): $HUD.show_message("Get Ready") $Player.start(start_pos) $StartTimer.start() get_tree().call_group("mobs", "queue_free") $Player.player_sky_pos = $Player.sky_positions.UP # reset player pos to the top of the screen func _on_cloud_timer_timeout(): # Create a new instance of the Mob scene. var cloud = cloud_scene.instantiate() # Choose a random location on Path2D. var cloud_spawn_location = get_node("CloudPath/CloudSpawnLocation") cloud_spawn_location.progress_ratio = randf() # Set the mob's direction perpendicular to the path direction. # Set the mob's position to a random location. cloud.position = cloud_spawn_location.position # Add some randomness to the direction. # Choose the velocity for the mob. var velocity = Vector2(randf_range(-105.0, -205.0), 0.0) cloud.linear_velocity = velocity # Spawn the cloud by adding it to the Main scene. add_child(cloud) func _on_mob_timer_timeout(): # Create a new instance of the Mob scene. var mob = mob_scene.instantiate() # Choose a random location on Path2D. var mob_spawn_location = get_node("CloudPath/CloudSpawnLocation") mob_spawn_location.progress_ratio = randf() # Set the mob's direction perpendicular to the path direction. var direction = mob_spawn_location.rotation + PI / 2 # Set the mob's position to a random location. mob.position = mob_spawn_location.position # Add some randomness to the direction. # Choose the velocity for the mob. var velocity = Vector2(randf_range(-150.0, -160.0), 0.0) mob.linear_velocity = velocity # Spawn the mob by adding it to the Main scene. add_child(mob)
В основном, код не существенно отличается от прототипа из туториала.
Вместо одного моба у нас облако и ворона, которые работают похожим образом. Но мы их не разворачиваем случайным образом при создании, а всегда направляем строго по прямой справа налево.
Также немного изменена логика позиционирования игрока. Ведь у нас не свободное перемещение, а три строки (ячейки) экрана.
По-настоящему новая функция — victory(). Поскольку в туториале не было завершения игры.
# function for success end game func victory(): " class="formula inline">MobTimer.stop() " class="formula inline">FinishTimer.stop() get_tree().call_group("mobs", "queue_free") " class="formula inline">Player.player_sky_pos = " class="formula inline">Player.get_node("AnimatedSprite2D").animation = "walk"
Функция сработает, когда истечет таймаут FinishTimer.
Мы покажем игроку сообщение о победе и направим кота вниз за пределы экрана.
Мне бы хотелось закончить игру эпичнее, например вот так:

Но я очень устал в процессе подготовки статьи, поэтому вышло так:

Вы можете доработать финал игры самостоятельно.
Заключение
Ну вот вроде и всё. Осталось только запустить игру, набрать номер из настроек компании в ЛК VoiceBox MTT и наслаждаться игрой.
К сожалению, я еще не до конца разобрался с настройками сборки под разные платформы, поэтому не выкладывал бинарники на GitHub. Но вы можете просто скачать проект, импортировать его в Godot и собрать тестовую сборку. Мне даже удалось собрать проект на моем смартфоне под Android и дать друзьям в полевых условиях протестировать игру.

Понятно, что наша игра уступает аркадам из передачи «Позвоните Кузе», но надо же с чего-то начинать. Надеюсь, что обе части статьи вам понравились и вы вместе со мной погрузились в приятную ностальгию.
