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

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