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