Сегодня в качестве титульной иллюстрации к статье выступает предыстория / инструкция из мини-игры.
Сегодня в качестве титульной иллюстрации к статье выступает предыстория / инструкция из мини-игры.

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

Но зато мы можем написать свою мини-игру про кота Кузьму, в которой реализуем аналогичное управление персонажем с помощью любого телефона с функцией тонального набора.

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

Оглавление:

В предыдущей серии

Ранее мы разработали инфраструктуру для управления игровым персонажем по телефону.

Напомню схему взаимодействия компонентов.

Схема взаимодействия.
Схема взаимодействия.

План был такой:

  1. Написать простой веб-сервис для управления персонажем через запросы к API.

  2. Подготовить сценарий для голосового бота MTT VoiceBox, который будет по нажатию клавиш в тональном режиме вызывать API и передавать в него команду для движения вверх или вниз.

  3. Написать мини-игру на движке 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 из туториала.

Сцена с вороной практически такая же как и с облаком.

Но есть два отличия.

  1. Я сделал всего одну анимацию вороны, но вы можете добавить и другие. Логика под это реализована в коде.

  2. Добавляется нода 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 и дать друзьям в полевых условиях протестировать игру. 

Фрагмент геймплея
Фрагмент геймплея

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

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