Первая игра на Godot

68 minute read

В этой статье напишем простую казуальную игру под Android и опубликуем ее в RuStore. Эта игра нужна как пример. Я напишу обертки для RuStore SDK(подписки, пуши, отзывы), myTracker и myTarget. И в будущем постараюсь все их использовать в этой игре. Добавлю рекламу и подписки, попробую закупить трафик. Посмотрим, сколько можно заработать на игре в RuStore.

Игру будем писать на Godot. Это прекрасный движок для игр. Особенно, если вы начинающий разработчик. У него очень много плюсов: легковесность, простой скриптовый язык, достаточно подробная документация, бесплатность. И, самое главное, крутая идея построения всей игры из узлов. Как по мне, движок значительно проще Unity.

Godot еще сравнительно молодой, по нему написано не так много туториалов. Как ни странно, но это большой плюс - нет лишнего хлама. По Unity написана куча туториалов, в которых одни и те же вещи делаются по-разному и никогда не поймешь, как делать правильно. С Godot такой проблемы нет.

Сейчас наиболее популярны 3я и 4я версия движка. В 4 добавлено очень много оптимизаций и достаточно сильно переработан API движка. Все новые проекты имеет смысл создавать на 4ой версии.

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

Подготовка

Запускаем редактор и начинаем с настроек проекта. В первую очередь указываю размер окна 640x180 - подойдет для дебага.

Сразу можно настроить цвет фона. Это должно быть что-то темное, почти как космос. Прекрасно подойдет цвет #0f0f16

Готовим модели

Тут необходимо небольшое отступление про ключевые концепции Godot. Любая игра на Godot представляет собой дерево сцен. А сцена - это дерево узлов(node). Все строится из узлов и это строительные кирпичики вашей игры. Есть очень много разных видов узлов под разные задачи.

Если вы собираете из узлов, например, персонажа - то это уже будет сцена. Такую сцену можно сохранить в отдельный файл и потом использовать ее как еще один узел.

Нам нужно сделать две модели - врагов и героя. Все это простые разноцветные квадраты, которые собираются из узлов и их нужно сохранить как отдельные сцены.

Начнем с главной сцены, которую назовем game.tscn. Пока это просто пустая сцена, в дальнейшем добавим в нее героя и простой UI интерфейс.

Создаем новую сцену для героя. Добавляем в сцену родительский узел CharacterBody2D - это специальный узел для физических объектов, которые должны передвигаться через скрипт. Дочерним узлом добавим Sprite2D и укажем в этом элементе цвет для героя: #F2F3F4

Еще один дочерний узел - это CollisionShape2D. Он нужен для работы коллизий между врагами и героем. В настройках узла в параметре Shape нужно выбрать RectangleShape2D, потом настроить размеры зоны для коллизий.

Все коллизии будут происходить на 1 слое, поэтому все узлы должны быть на 1 слое.

Сохраняем героя отдельной сценой hero.tscn и потом добавляем героя на главную сцену игры как новый узел.

Теперь делаем все тоже самое для врагов. Только цвет для врага выбираю #F200A5 и сохраняем сцену как enemy.tscn.

Добавляем врагу коллизию и настраиваем ее так, чтобы коллизия происходила на 1 слое как и для героя. В отличии от героя, врагов не нужно добавлять на главную сцену - они будут добавляться скриптом.

Пишем скрипты

У вас есть возможность писать скрипты для игры на GDScript или C#. Кроме того, с версии 4.0 появилась улучшенная поддержка C++ через механизм GDExtension, который позволяет писать игры на C++ без рекомпиляции самого движка. А если вы совсем уж любитель экзотики и изотерики, то можно использовать биндинги для Rust от сообщества.

В этом простом туториале будем использовать стандартный GDScript. Для него больше всего документации и примеров. Это самый популярный язык разработки среди Godot разработчиков.

В Godot все скрипты прикрепляются узлу. Для создания нового скрипта или добавления к узлу уже существующего скрипта, нужно выбрать узел в дереве и нажать на иконку добавления скрипта:

Создаем первый скрипт Hero.gd. Godot при создании скрипта умеет генерировать полезный шаблонный код, который может нам частично пригодиться.

 1extends CharacterBody2D
 2
 3
 4const SPEED = 300.0
 5const JUMP_VELOCITY = -400.0
 6
 7# Get the gravity from the project settings to be synced with RigidBody nodes.
 8var gravity = ProjectSettings.get_setting("physics/2d/default_gravity")
 9
10
11func _physics_process(delta):
12	# Add the gravity.
13	if not is_on_floor():
14		velocity.y += gravity * delta
15
16	# Handle Jump.
17	if Input.is_action_just_pressed("ui_accept") and is_on_floor():
18		velocity.y = JUMP_VELOCITY
19
20	# Get the input direction and handle the movement/deceleration.
21	# As good practice, you should replace UI actions with custom gameplay actions.
22	var direction = Input.get_axis("ui_left", "ui_right")
23	if direction:
24		velocity.x = direction * SPEED
25	else:
26		velocity.x = move_toward(velocity.x, 0, SPEED)
27
28	move_and_slide()

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

Перемещение героя

Герой должен постоянно перемещаться от одного края экрана к другому. По тапу на экран, герой должен менять свое направление. Подправим шаблон и реализуем логику смены направления движения.

 1extends CharacterBody2D
 2
 3
 4const SPEED = 300.0
 5
 6var gravity = ProjectSettings.get_setting("physics/2d/default_gravity")
 7var direction = 1 
 8
 9func _physics_process(delta):
10	if Input.is_action_just_pressed("ui_accept"):
11		direction = direction * -1
12		
13	if get_global_transform_with_canvas().origin.x <= 0:
14		direction = 1 
15		
16	if get_global_transform_with_canvas().origin.x >= get_window().size.x:
17		direction = -1 
18	
19	velocity.x = direction * SPEED
20
21	move_and_slide()

Логика перемещения реализована в методе _physics_process(delta). В Godot есть два метода, которые вызываются автоматически для обновления состояния перед отрисовкой кадров: _process и _physics_process. Первый метод блокирующий и запускается так часто, насколько это возможно. Второй метод работает с фиксированной частотой, по умолчанию 60 раз в секунду. _physics_process обеспечивает плавную обработку физики, поэтому все задачи, связанные с обработкой любой физики, нужно производить именно в этом методе.

Готово, наш герой двигается от одного края экрана к другому. За направление отвечает переменная direction. Чтобы изменить направление, мы получаем текущее положение героя по оси x с помощью get_global_transform_with_canvas().origin.x. Если это значение меньше 0, то пора изменить направление героя чтобы он начал двигаться от левого края экрана к правому. А если это значение больше чем размер окна, то отправляем героя в путешествие от правого края окна к левому.

Падающие враги

Узел с героем у нас изначально был добавлен на главную сцену. С врагами так не получится, их надо создавать с помощью скриптов.

Определимся, в каком скрипте будет реализована логика создания. Логично добавить новый скрипт Game.gd для родительского узла главной сцены и уже в нем реализовать всю общую логику игры, в том числе создание врагов.

1extends Node2D
2
3# Called when the node enters the scene tree for the first time.
4func _ready():
5	var enemy = preload("res://scenes/enemy.tscn").instantiate()
6	enemy.add_to_group("Enemy")
7	enemy.position = Vector2(50, 50)
8	add_child(enemy)

Метод _ready() гарантирует, что сцена полностью загружена и все узлы в сцене уже доступны. Если вам нужно разово выполнить какой-нибудь код, то это отличное место для такой логики.

Каждый враг добавляется в группу Enemy. Это поможет нам правильно удалить всех врагов после столкновения.

Окей, одного врага создали. Сейчас нужно научится создавать врагов с некоторым таймаутом, который будет уменьшаться со временем. И для этого нужно перенести всю логику уже в метод _process

 1extends Node2D
 2
 3var max = 3.0
 4var timeout = max
 5
 6# Called when the node enters the scene tree for the first time.
 7func _ready():
 8	randomize()
 9
10# Called every frame. 'delta' is the elapsed time since the previous frame.
11func _process(delta):
12	timeout -= delta
13	if timeout < 0:
14		timeout = max
15		spawn()
16
17
18func spawn():
19	var enemy = preload("res://scenes/enemy.tscn").instantiate()
20	enemy.add_to_group("Enemy")
21	var pos = Vector2(randf_range(3, get_window().size.x/ProjectSettings.get_setting("display/window/stretch/scale") - 3), -5)
22	enemy.position = pos
23	add_child(enemy)

В метод _process передается параметр delta. Это разница по времени между отрисовкой двух кадров. Его нужно использовать для обратного отсчета до создания следующего врага.

В методе _ready вызывается метод randomize(). Он нужен для подготовки генератора псевдослучайных чисел, который будет использоваться для указания позиции нового врага на главной сцене: var pos = Vector2(randf_range(3, get_window().size.x/ProjectSettings.get_setting("display/window/stretch/scale") - 3), -5). Эта строка кода может показаться страшной, но тут нет ничего сложного. Разберем все по частям:

ProjectSettings.get_setting("display/window/stretch/scale") - это получение настроек проекта. На девайсе игра может выглядеть не так как ожидалось, все размеры могут оказаться слишком мелкими. Тогда нужно или изменить размер каждого элемента, или заскейлить все сразу через настройки проекта. Но в случае со скейлом, мы не сможем правильно рассчитать размеры окна, если не будем делить его на значения скейла.

get_window().size.x - значение ширины окна, в котором запущена игра

randf_range(a, b) - функция, которая генерирует случайное значение в диапазоне от a до b

var pos = Vector2(x, y) - создание двухмерного вектора со значениями x и y. Такой вектор используется для задания позиции узла на сцене.

Мы задаем позицию врагу на 5 пикселей выше верхнего края окна по оси y и случайное положение на оси x с отступами по 3 пикселя с левого и правого края окна.

Важно отметить, что в Godot, как и в большинстве современных движков, позиционирование задается от верхнего левого края окна.

В функции spawn мы инстанционируем врага preload("res://scenes/enemy.tscn").instantiate(). Метод preload загружает сцену врага, а instantiate создает узел. Метод add_child(enemy) добавляет новый узел в главную сцену.

Коллизии

Герой и враги находятся на одном слое с номером 1 и могут сталкиваться друг с другом. Это максимально простая логика. Можно разнести врагов и героя на отдельные слои - тогда враги не будут сталкиваться с врагами. Но в такой простой игре это лишнее и может быть задачей на будущее.

Для отслеживания коллизии будем перемещать героя и врага с помощью move_and_collide. Посмотрим как это сделано в скрипте Hero.gd:

 1signal collided
 2var active = true
 3
 4func _physics_process(delta):
 5	if !active:
 6		return
 7
 8	# ...
 9	
10	var collision = move_and_collide(velocity * delta)
11	if collision:
12		collided.emit()

Мы отлавливаем данные по коллизии и сохраняем их в переменную collision. Если переменная не null, значит коллизия произошла и можно выполнить какое ни будь действие. Мы будем бросать сигнал.

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

Посмотрим как мы используем сигналы в коде. Для начала создается signal collided - это и есть наш сигнал. В момент коллизии вызывается метод collided.emit() который “бросает” сигнал. Чуть позже напишем код, который будет перехватывать этот сигнал.

Обратите внимание на переменную active в коде выше. Она используется для остановки движения героя героя. Если она будет установлена в false, то вся обработка в функции _physics_process будет пропущена

Для врагов делаем все очень похожее как и у героя, но в скрипте Enemy.gd:

1signal collided
2
3
4func _physics_process(delta):
5	velocity.y = SPEED * delta
6
7	var collision = move_and_collide(velocity * delta)
8	if collision:
9		collided.emit()

Отлавливать сигналы о коллизиях будем в скрипте игры Game.gd. Для этого нужно подключится к сигналу героя и к сигналам врагов во время их создания с помощью вызова метода collided.connect(_on_collided)

 1var hero
 2var play = true
 3
 4
 5func _ready():
 6	randomize()
 7	hero = $Hero
 8	hero.collided.connect(_on_collided)
 9
10func _process(delta):
11	if !play:
12		return
13
14	timeout -= delta
15	if timeout < 0:
16		timeout = max
17		spawn()
18
19func spawn():
20	var enemy = preload("res://scenes/enemy.tscn").instantiate()
21	enemy.add_to_group("Enemy")
22	var pos = Vector2(randf_range(3, get_window().size.x/10 - 3), -5)
23	enemy.position = pos
24	enemy.collided.connect(_on_collided)
25	add_child(enemy)

В этом коде переменная play работает аналогично переменной active в скрипте героя. Только в этом случае перестает работать логика в методе _process, и новые враги перестают создаваться.

И осталось обработать сигнал от коллизий, который мы бросаем в скрипте героя. Функция _on_collided это обработчик, который мы подключили к нашим сигналам.

1func _on_collided():
2	hero.active = false
3	play = false
4	for ch in get_children():
5		if ch.is_in_group("Enemy"):
6			remove_child(ch)

Важно отметить, что при таком подходе нужно получать коллизии и от врагов, и от героя. При коллизии нам нужно остановить создание врагов. Для этого устанавливаем переменную play = false. Для героя выставляем hero.active = false - это остановит движение героя.

Интерфейс

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

Начнем с добавления элементов интерфейса

  • CanvasLayer - отвечает за весь слой, на котором будет отображаться интерфейс. Я назову этот узел UserInterface.
  • CenterContainer - специальный контейнер, который будет центрировать все элементы нашего интерфейса
  • Button - простая кнопка, при клике на которую игра запускается по новой. Узел будет называться ButtonRestart.

Настроить параметры кнопки можно через отдельную тему:

Настройка тем в Godot заслуживает большего внимания, но не в этой статье. Пока достаточно изменить размер шрифта для кнопок. Для этого выберите кнопку на панели с примерами элементов и в параметрах поменяйте размер шрифта.

По умолчанию кнопка должна быть скрыта и отображаться только при завершении игры. Для этого узел необходимо скрыть в дереве сцены. Это можно сделать руками, но я стараюсь все по-максимуму переносить все в коде самой игры. Поэтому, скроем кнопку в методе _ready()

1var restart: Button
2
3func _ready():
4	# ...
5	
6	restart = $UserInterface/CenterContainer/ButtonRestart
7	restart.hide()
8	restart.pressed.connect(_on_restart_pressed)

А заодно добавлю обработку сигнала нажатия кнопки в отдельном методе. По нажатию на кнопку нужно перезапускать игру. Для этого перезагрузим всю нашу сцену с помощью метода reload_current_scene().

1func _on_restart_pressed():
2	get_tree().reload_current_scene()

Кнопка должна отображаться в момент завершения игры. Для этого есть подходящее место в функции _on_collided()

1func _on_collided():
2	restart.show()
3
4	# ...

Подсчет очков

В игру уже можно играть. Но ей не хватает хоть какого ни будь смысла. Будем считать очки за каждого врага, которого удалось избежать. Для этого необходимо понимать, когда враг ушел за край экрана. Для этого в скрипте Enemy в методе _process будем проверять позицию врага и если он оказался ниже края окна, то удалять врага queue_free() и бросать сигнал skip.emit().

1signal skip
2
3func _process(delta):
4	if get_global_transform_with_canvas().origin.y > (get_window().size.y + 10):
5		skip.emit()
6		queue_free()

И теперь нужно подключится к сигналу skip в скрипте Game.gd при создании врага и добавить обработчик этого сигнала _on_skip()

1func spawn():
2	# ...
3	enemy.skip.connect(_on_skip)
4
5
6func _on_skip():
7	points += 1
8	print(points)

Осталось научится отображать заработанные очки в интерфейсе игры. Для этого добавляем узел интерфейса Label, который назовем Points.

И будем записывать обновление счетчика в этот Label

 1var points: Label
 2var score = 0
 3
 4func _ready():
 5	# ...
 6	points = $UserInterface/MarginContainer/Points
 7
 8func _on_skip():
 9	score += 1
10	points.text = "%d" % score

Нарастающая сложность

Окей, у игры появился смысл, очки считаются, игра заканчивается и ее можно перезапустить в случае проигрыша. Но можно сделать еще интересней, если сложность будет увеличиваться со временем. Для этого сделаем так, чтобы периодичность создания врагов и их скорость возрастала с набором очков. Т.е. чем больше очков набрал игрок, тем больше врагов с большей скоростью появлялись бы на экране.

 1var score = 0
 2var speed = 100
 3
 4func spawn():
 5	var enemy: Enemy = preload("res://scenes/enemy.tscn").instantiate()
 6	enemy.speed = speed
 7	# ...
 8
 9
10func _on_skip():
11	score += 1
12	points.text = "%d" % score
13	
14	max -= score * 0.01
15	if max <= 0.5:
16		max = 0.5
17		
18	speed += score / 3

Тут max - это время между созданием врагов. В логике, описанной выше, это время будет уменьшатся. Можно сразу подумать над улучшением алгоритма - время может быть не фиксированным, его можно рандомно выбирать из промежутка. В таком случае, добавится элемент непредсказуемости.

Скорость возрастает с каждым новым заработанным очком. Сейчас скорость добавляется как speed += score / 3. Эту функцию можно менять, чтобы подобрать самый подходящий ритм игры и плавно наращивать сложность.

Чтобы красиво

Осталось добавить несколько штрихов и игра будет готова. Добавим рандомное вращение врагам и эффект столкновения. Начнем с вращения. Для этого в скрипте Enemy.gd в методе _physics_process() начнем вращать узел.

1var rotate = 1
2var angle = 1
3
4
5func _physics_process(delta):
6	velocity.y = speed * delta * 100
7	
8	rotation = angle * delta
9	angle += rotate
  • angle - это текущее значение угла, на который нужно повернуть узел.
  • rotate - это дефолтное значение приращения угла. Его нужно будет задать в скрипте Game.gd
1func spawn():
2	# ...
3	enemy.rotate = randf_range(1, 10)N

Осталось добавить красивый эффект при столкновении. Для этого будем использовать частицы. Пример настройки частиц для нашего героя ниже:

Тут важно указать, что это должно быть одноразовое “on shot” воспроизведение, которое мы будем включать сами во время столкновения. В скрипте Hero.gd добавляем работу с частицами.

1var particles: CPUParticles2D
2
3func _ready():
4	particles = $Boom
5
6func boom():
7	particles.restart()

А в скрипте Game.gd вызываем метод boom() для воспроизведения анимации частиц

1func _on_collided():
2	# ...
3	hero.boom()

Отлично! Теперь у нас все готово, и можно собирать игру под Android.

Сборка для Android

Первым делом нужно настроить шаблон для экспорта проекта. Этот шаблон будет использоваться для создания полноценного Android приложения. Это делается через меню Project

Шаблон нужно будет скачать. Это займет некоторое время.

После скачивания шаблона для Android - применяем его

После применения шаблона, в проекте появится папка android со всеми необходимыми файлами для настройки Android проекта. Это и есть Android проект, который можно открыть Android Studio и внести какие угодно изменения.

Переходим на экран экспорта через меню Project

И теперь можем экспортировать проект для Android.

Кроме того, теперь можно запускать и отлаживать игру на эмуляторе.

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

Ориентацию починить просто. Для этого переходим в настройки проекта Project -> Project Settings -> Display -> Window и выставляем ориентацию Portrait

Поправить логику движения нашего героя тоже можно через настройки проекта. Для этого на экране Project Settings переходим на вкладку Input Map, находим экшен ui_accept и добавляем событие клика для этого экшена.

И мне не нравится как выглядит игра на эмуляторе. Необходимо увеличить размер героя и врагов, поправить позиционирование героя. Это можно сделать, увеличивая в редакторе каждый узел. Но это долго, поэтому проще изменить значение scale на 2 в настройках проекта Project -> Project Settings -> Display -> Window -> Stretch -> Scale

Вот так выглядит итоговый вариант игры, запущенный на эмуляторе.

Теперь наша игра полностью готова для публикации в RuStore

Публикуем игру в RuStore

Есть неплохая документация, в которой можно найти ответы на многие вопросы. Но публикация игр и приложений в RuStore действительно очень простая.

Для публикации необходимо подписать нашу сборку. Для этого генерируем подпись через стандартные инструменты Android. Переходим в папку ./android и в ней генерируем подпись, которая будет использоваться для публикации.

1keytool -keyalg RSA \
2	-genkeypair \
3	 -alias proton \
4	-keypass proton \
5	-keystore proton.keystore \
6	-storepass proton

Теперь указываем подпись в настройках экспорта в секции Keystore. Я использую одну подпись для дебага и релиза. Так делать не хорошо, но кто меня остановит.

Нужно добавить кастомную иконку. Стандартная с логотипом Godot хороша, но хочется чего-то индивидуального. Для создания иконки воспользуемся приложением от Яндекс shedevrum.ai - нейронка, которая по запросу может генерировать картинку.

Ресайзим картинку под два размера: 192х192 для главной иконки и 432х432 для адаптивной. Указываем эти иконки в настройках экспорта. На самом деле, для Adaptive Background лучше использовать отдельную иконку, но тут немного срежем углы и вернемся к настройке иконок, когда понадобится.

Все готово для экспорта apk. На том же экране с настройками убираем галку Export With Debug, нажимаем кнопку Export Project и сохраняем готовый apk файл

Проверяем, что готовая игра работает нормально, устанавливаем ее через adb:

1adb install proton.apk

Запускаем и убеждаемся, что все окей.

Отлично! Настало время загрузки игры в RuStore. Создаем новое приложение в консоли разработчика, указываем название и выбираем тип “бесплатное”.

Загружаем первую версию apk, указываем комментарий для модератора.

Для каталога нам нужна еще одна иконка размером 512х512. Ресайзим нашу сгенерированную иконку и загружаем ее. Добавляем краткое и расширенное описание.

Осталось подготовить и загрузить скриншоты для игры. Я делаю скрины с эмулятора и загружаю их в консоль как есть.

Все загружено и вся необходимая информация добавлена. Игра готова. Отправляем ее на проверку. Модерация, как правило, проходит очень быстро. Курим, смотрим пару видосов на ютубе, и игра уже опубликована.

Игру можно скачать и установить из RuStore. Исходники игры доступны на gitflic.

Что дальше

В следующих сериях добавим аналитику, монетизацию, пуши и все остальное, что нужно любому современному приложению. Попробуем прорекламировать нашу игру, посмотрим сколько будет стоить конверсия и можно ли на этом заработать.

comments powered by Disqus