Создание мини-игры с Кузей с использованием голосового бота. Шаг 2: Конфигурация Godot
Сегодня в качестве титульной иллюстрации к статье выступает предыстория / инструкция из мини-игры.
В первой части статьи мы с вами предались ностальгии по замечательной телепередаче «Позвоните Кузе». К сожалению, машину времени пока не изобрели и мы не сможем субботним утром позвонить обаятельным ведущим в надежде «попасть в телевизор».
Но зато мы можем написать свою мини-игру про кота Кузьму, в которой реализуем аналогичное управление персонажем с помощью любого телефона с функцией тонального набора.
Для этого в первой части статьи мы разработали небольшой веб-сервис, а также написали сценарий голосового бота 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 и дать друзьям в полевых условиях протестировать игру.

Понятно, что наша игра уступает аркадам из передачи «Позвоните Кузе», но надо же с чего-то начинать. Надеюсь, что обе части статьи вам понравились и вы вместе со мной погрузились в приятную ностальгию.
JoyToy выпустила по Warhammer: The Horus Heresy фигурку чемпиона-консула «Имперских Кулаков»
[Перевод] Самодельная портативная игровая консоль на базе процессора 486
Предзаказ новой статуи Ребекки из «Киберпанк: Бегущие по краю» от Prime 1 Studio откроется 10 ноября
BioWare пытаются спасти от «американского диктата», Sony неожиданно призвала владельцев PS5 продавать консоли — самое интересное за неделю
«Клэйфейс» вплотную приблизился к окупаемости
Sony отложила выпуск новинки для PlayStation за шесть дней до начала продаж
Создатель Super Smash Bros. объяснил, почему разработчикам стоит обратить внимание на ИИ
Сооснователь BioWare оценил, сколько может стоить возвращение Mass Effect 5