Ваша Первая Игра

Обзор

Этот урок поможет вам сделать свой первый проект в Godot. Вы узнаете, как работает редактор Godot, как структурировать проект и как построить 2D-игру.

Примечание

Этот проект представляет собой введение в движок Godot. Предполагается, что у вас уже есть опыт программирования. Если вы новичок в программировании, вам нужно начать отсюда: Написание скриптов.

Игра называется "Увернись от Крипов!". Ваш персонаж должен двигаться и избегать врагов как можно дольше. Вот предварительная демонстрация финального результата:

../../_images/dodge_preview.gif

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

Настройка проекта

Запустите Godot и создайте новый проект. Затем загрузите dodge_assets.zip. Здесь содержатся изображения и звуки, которые вы будете использовать для создания игры. Разархивируйте эти файлы в папку вашего проекта.

Примечание

Для этого урока мы предположим, что вы уже знакомы с редактором Godot. Если вы еще не читали Сцены и узлы, то сделайте это сейчас для понимания настройки проекта и использования редактора.

Эта игра разработана под портретный формат, поэтому нам нужно настроить размер игрового окна. Выберите меню "Project -> Project Settings -> Display -> Window" и задайте полю "Width" (Ширина) значение 480, а полю "Height" (Высота) значение 720.

Также в этой секции, под опциями "Stretch", установите Mode на "2d" и Aspect на "keep". Это гарантирует, что игра последовательно масштабируется на экранах разного размера.

Организация Проекта

В этом проекте мы создадим 3 независимые сцены: Player, Mob и HUD, которые мы будем комбинировать со сценой Main. В более крупном проекте было бы полезно сделать папки для хранения различных сцен и их скриптов, но для этой, относительно небольшой игры, вы можете сохранять свои сцены и скрипты в корневой папке, называемой res://. Вы можете видеть папки проекта в окне FileSystem (Файловая Система) в нижнем левом углу:

../../_images/filesystem_dock.png

Сцена Игрока

The first scene will define the Player object. One of the benefits of creating a separate Player scene is that we can test it separately, even before we've created other parts of the game.

Структура узла

Для начала нам нужно выбрать корневой узел для объекта игрока. Как правило, корневой узел сцены должен отображать желаемую функциональность объекта - чем объект является. Нажмите кнопку "Другой узел" (Other Node) и добавьте узел Area2D в сцену.

../../_images/add_node.png

Godot отобразит значок предупреждения рядом с этим узлом в древе сцены. Пока что не обращайте внимание на это. Мы рассмотрим это позже.

С помощью Area2D мы можем обнаруживать объекты, которые перекрывают или сталкиваются с игроком. Измените имя узла на Player, дважды щёлкнув по нему. Теперь, когда мы установили корневой узел сцены, мы можем добавлять дополнительные узлы, чтобы привнести больше функционала.

Прежде чем мы добавим потомков на узел Player, мы хотим убедиться, что нажав на них, мы не переместим их и не изменим их размер. Выберите узел и нажмите на значок справа от блокировки; его всплывающая подсказка гласит: "Делает потомков объекта невыбираемыми."

../../_images/lock_children.png

Сохраните сцену. Нажмите "Сцена" -> "Сохранить сцену" в верхней панели или нажмите сочетание клавиш Ctrl + S на Windows/Linux или Cmd + S на Mac.

Примечание

Для этого проекта мы будем следовать правилам именования Godot.

  • GDScript: Классы (узлы) используют PascalCase, переменные и функции - snake_case, константы - ALL_CAPS (см. Руководство по стилю GDScript).
  • C#: Классы, экспортируемые переменные и методы используют PascalCase, приватные поля - _camelCase, локальные переменные и параметры - сamelCase (см. Руководство по стилю C#). Будьте осторожны: точно набирайте имена методов при подключении сигналов.

Анимация Спрайтов

Кликните на узел Player и добавьте дочерний узел AnimatedSprite. AnimatedSprite будет обрабатывать внешний вид и анимации для нашего игрока. Обратите внимание на символ предупреждения рядом с узлом. AnimatedSprite требует ресурс SpriteFrames, который представляет собой список отображаемых анимаций. Чтобы его создать, найдите свойство Frames в окне инспектора и кликните на «[пусто]» -> «Новый SpriteFrames». Снова кликните в том же месте на SpriteFrames, чтобы открыть панель:

../../_images/spriteframes_panel.png

Слева находится список анимаций. Нажмите на "default" и переименуйте на "walk". Затем щелкните по кнопке "Новая анимация" для создания второй анимации с именем "up". Найдите изображения игрока на вкладке "Файловая система" - они находятся в папке "art", которую вы разархивировали ранее. Перетащите два изображения с названиями playerGrey_up[1/2] и playerGrey_walk[1/2] на сторону панели "Кадры анимации" для каждой анимации соответственно:

../../_images/spriteframes_panel2.png

Изображения игрока немного великоваты для игрового окна, потому нам надо их слегка уменьшить. Нажмите на узел``AnimatedSprite`` и установите параметр Scale в (0.5, 0.5). Вы можете найти его в Инспекторе под заголовком Node2D.

../../_images/player_scale.png

Наконец, добавьте CollisionShape2D в качестве потомка в Player. Это определит "hitbox" игрока или, иначе говоря, границы его области столкновения. Для этого персонажа лучше всего подходит узел CapsuleShape2D, поэтому далее идем в Инспектор и в разделе "Shape" щелкаем "[пусто]" -> "Новый CapsuleShape2D". Используя указатели двух размеров, подгоните размер фигуры под размер изображения:

../../_images/player_coll_shape.png

Когда вы закончите, сцена с вашим Player будет выглядеть вот так:

../../_images/player_scene_nodes.png

После этих изменений обязательно сохраните сцену еще раз.

Движение Игрока

Теперь нам надо добавить немного функционала, который мы не можем получить с помощью встроенных узлов и поэтому мы добавим скрипт. Нажмите на узел Player и нажмите кнопку "Прикрепить скрипт":

../../_images/add_script_button.png

В окне настроек скрипта вы можете оставить все настройки по умолчанию. Просто нажмите "Создать":

Примечание

Если вы создаете скрипт на C# или другом языке, то перед созданием выберете этот язык в выпадающем меню Язык.

../../_images/attach_node_window.png

Примечание

Если вы первый раз столкнулись с GDScript, то, прежде чем продолжить, пожалуйста прочитайте Написание скриптов.

Начните с объявления переменных - членов, которые понадобятся этому объекту:

extends Area2D

export var speed = 400  # How fast the player will move (pixels/sec).
var screen_size  # Size of the game window.
public class Player : Area2D
{
    [Export]
    public int Speed = 400; // How fast the player will move (pixels/sec).

    private Vector2 _screenSize; // Size of the game window.
}

Использование ключевого слова export у первой переменной speed позволяет устанавливать ее значение в Инспекторе. Это может быть полезно, если вы хотите изменять значения точно так же как и встроенные свойства узла. Щелкните на узел Player и вы увидите, что свойство появилось в разделе "Script Variables" в Инспекторе. Помните, что если изменить значение здесь, то оно перезапишет значение, установленное в скрипте.

Предупреждение

Если вы используете C#, вам нужно (пере)собирать сборки проекта всякий раз, когда вы хотите увидеть новые экспортируемые переменные или сигналы. Эта сборка может быть запущена вручную путем нажатия на слово "Mono" в нижней части окна редактора, чтобы открыть Mono Panel, а затем на кнопку "Build Project".

../../_images/export_variable.png

Функция _ready () вызывается, когда узел входит в дерево сцены, что является хорошим моментом для определения размера игрового окна:

func _ready():
    screen_size = get_viewport_rect().size
public override void _Ready()
{
    _screenSize = GetViewport().Size;
}

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

  • Проверка ввода.
  • Перемещение в заданном направлении.
  • Воспроизведение соответствующей анимации.

Во-первых, нам нужно обрабатывать ввод с клавиатуры (нажимает ли игрок клавишу?). В этой игре у нас есть 4 направления движения, соответственно кнопок управляющих движением тоже будет 4. Назначить действия на определённые клавиши можно в настройках проекта в разделе "Input Map". Для данной демонстрации назначать клавиши не понадобится, потому что мы будем использовать действия назначенные по-умолчанию (стрелками на клавиатуре).

Вы можете определить, нажата ли клавиша с помощью функции Input.is_action_pressed(), которая возвращает true, если клавиша нажата, или false, если нет.

func _process(delta):
    var velocity = Vector2()  # The player's movement vector.
    if Input.is_action_pressed("ui_right"):
        velocity.x += 1
    if Input.is_action_pressed("ui_left"):
        velocity.x -= 1
    if Input.is_action_pressed("ui_down"):
        velocity.y += 1
    if Input.is_action_pressed("ui_up"):
        velocity.y -= 1
    if velocity.length() > 0:
        velocity = velocity.normalized() * speed
        $AnimatedSprite.play()
    else:
        $AnimatedSprite.stop()
public override void _Process(float delta)
{
    var velocity = new Vector2(); // The player's movement vector.

    if (Input.IsActionPressed("ui_right"))
    {
        velocity.x += 1;
    }

    if (Input.IsActionPressed("ui_left"))
    {
        velocity.x -= 1;
    }

    if (Input.IsActionPressed("ui_down"))
    {
        velocity.y += 1;
    }

    if (Input.IsActionPressed("ui_up"))
    {
        velocity.y -= 1;
    }

    var animatedSprite = GetNode<AnimatedSprite>("AnimatedSprite");

    if (velocity.Length() > 0)
    {
        velocity = velocity.Normalized() * Speed;
        animatedSprite.Play();
    }
    else
    {
        animatedSprite.Stop();
    }
}

Начнём с того, что установим значение velocity в``(0, 0)`` - по умолчанию игрок двигаться не должен. Затем, мы проверяем каждый ввод и добавляем/вычитаем значение из velocity, чтобы получить общее направление. Например, если вы одновременно удерживаете right и down, полученный вектор velocity будет (1, 1). В этом случае, поскольку мы добавляем горизонтальное и вертикальное движение, игрок будет двигаться быстрее, чем если бы он перемещался только по горизонтали.

Можно избежать этого, если мы нормализуем скорость, что означает, что мы устанавливаем ее длину на 1 и умножаем на желаемую скорость. Это означает отсутствие более быстрого диагонального движения.

Совет

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

Мы также проверяем, движется ли игрок, чтобы мы могли вызвать play() или stop() на AnimatedSprite.

$ является сокращением для get_node(). Поэтому в приведенном выше коде $AnimatedSprite.play() это то же самое, что и get_node("AnimatedSprite").play().

Совет

В GDScript, $ возвращает узел по относительному пути от текущего узла или возвращает null, если узел не найден. Поскольку AnimatedSprite является дочерним элементом текущего узла, мы можем использовать $AnimatedSprite.

Теперь, когда у нас есть направление движения, мы можем обновить позицию игрока. Мы также можем использовать clamp(), чтобы он не покинул экран. Clamping означает ограничение движения диапазоном. Добавьте следующее в конец функции _process (убедитесь, что он не имеет отступа под else):

position += velocity * delta
position.x = clamp(position.x, 0, screen_size.x)
position.y = clamp(position.y, 0, screen_size.y)
Position += velocity * delta;
Position = new Vector2(
    x: Mathf.Clamp(Position.x, 0, _screenSize.x),
    y: Mathf.Clamp(Position.y, 0, _screenSize.y)
);

Совет

Параметр "delta" в функции "_process()" означает длительность кадра, то есть время, потраченное на завершение обработки предыдущего кадра. Благодаря использованию этого значения скорость движения будет постоянной даже при изменении частоты кадров.

Нажмите "Запустить сцену" (F6) и удостоверьтесь, что вы можете перемещать игрока по экрану во всех направлениях.

Предупреждение

Если вы получаете ошибку в панели "Отладчик", которая говорит

Попытка вызова функции 'play' в основании 'null instance' на нулевом экземпляре

это, скорее всего, означает, что вы ввели название узла AnimatedSprite неверно. Имена узлов чувствительны к регистру, а $NodeName должен совпадать с именем, которое вы видите в дереве сцены.

Выбор анимации

Теперь, когда игрок может двигаться, нам нужно изменять анимацию AnimatedSprite в зависимости от направления движения. У нас есть анимация "walk", которая показывает игрока, идущего направо. Эту анимацию следует перевернуть горизонтально, используя свойство flip_h для движения влево. У нас также есть анимация "up", которую нужно перевернуть вертикально с помощью flip_v для движения вниз. Поместим этот код в конец функции _process ():

if velocity.x != 0:
    $AnimatedSprite.animation = "walk"
    $AnimatedSprite.flip_v = false
    # See the note below about boolean assignment
    $AnimatedSprite.flip_h = velocity.x < 0
elif velocity.y != 0:
    $AnimatedSprite.animation = "up"
    $AnimatedSprite.flip_v = velocity.y > 0
if (velocity.x != 0)
{
    animatedSprite.Animation = "walk";
    animatedSprite.FlipV = false;
    // See the note below about boolean assignment
    animatedSprite.FlipH = velocity.x < 0;
}
else if (velocity.y != 0)
{
    animatedSprite.Animation = "up";
    animatedSprite.FlipV = velocity.y > 0;
}

Примечание

Логические присваивания в коде выше являются общим сокращением для программистов. Поскольку мы проводим проверку сравнения (логическую, булеву), а также присваиваем булево значение, мы можем делать и то, и другое одновременно. Рассмотрим этот код в сравнении с однострочным логическим присваиванием выше:

if velocity.x < 0:
    $AnimatedSprite.flip_h = true
else:
    $AnimatedSprite.flip_h = false
if (velocity.x < 0)
{
    animatedSprite.FlipH = true;
}
else
{
    animatedSprite.FlipH = false;
}

Воспроизведите сцену еще раз и проверьте правильность анимации в каждом из направлений.

Совет

Общей ошибкой является неправильное именование анимаций. Имена анимаций в панели SpriteFrames должны совпадать с именами анимаций в вашем коде. Если вы назвали анимацию "Walk", вы должны также использовать заглавную букву "W" в коде.

Если вы уверены, что движение работает правильно, добавьте эту строку в _ready(), чтобы игрок был скрыт при запуске игры:

hide()
Hide();

Подготовка к столкновениям

Мы хотим, чтобы Player обнаруживал столкновение с врагом, но мы еще не сделали никаких врагов! Это нормально, потому что мы будем использовать такой функционал Godot, как сигнал.

Добавьте следующее в верх скрипта после extends Area2d:

signal hit
// Don't forget to rebuild the project so the editor knows about the new signal.

[Signal]
public delegate void Hit();

Это определяет пользовательский сигнал под названием "hit"("удар"), который наш игрок будет излучать (отправлять), когда он сталкивается с противником. Мы будем использовать Area2D для обнаружения столкновения. Выберите узел ``Player``("Игрок") и щелкните по вкладке "Узел" (Node) рядом с вкладкой "Инспектор" (Inspector), чтобы просмотреть список сигналов, которые игрок может посылать:

../../_images/player_signals.png

Обратите внимание, что наш пользовательский сигнал "hit" там также есть! Поскольку наши противники будут узлами RigidBody2D, нам нужен сигнал body_entered(body: Node). Он будет отправляться при контакте тела (body) с игроком. Нажмите "Присоединить..." - появится окно "Подключить сигнал к методу". Нам не нужно изменять какие-либо из этих настроек, поэтому еще раз нажмите "Присоединить". Godot автоматически создаст функцию в скрипте вашего игрока.

../../_images/player_signal_connection.png

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

func _on_Player_body_entered(body):
    hide()  # Player disappears after being hit.
    emit_signal("hit")
    $CollisionShape2D.set_deferred("disabled", true)
public void OnPlayerBodyEntered(PhysicsBody2D body)
{
    Hide(); // Player disappears after being hit.
    EmitSignal("Hit");
    GetNode<CollisionShape2D>("CollisionShape2D").SetDeferred("disabled", true);
}

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

Примечание

Отключение формы области столкновения может привести к ошибке, если это происходит во время обработки движком столкновений. Использование set_deferred() говорит Godot ждать отключения этой формы, пока это не будет безопасно.

Последняя деталь - добавить функцию, которую мы можем вызвать для перезагрузки игрока при запуске новой игры.

func start(pos):
    position = pos
    show()
    $CollisionShape2D.disabled = false
public void Start(Vector2 pos)
{
    Position = pos;
    Show();
    GetNode<CollisionShape2D>("CollisionShape2D").Disabled = false;
}

Сцена Врага

Теперь пришло время сделать врагов, от которых нашему игроку предстоит уклоняться. Их поведение будет не очень сложным: толпы (мобы) будут появляться случайно по краям экрана, выбирать случайное направление и двигаться по прямой линии.

Мы создадим сцену Mob, которую затем сможем инстанцировать, чтобы создать любое количество независимых мобов в игре.

Примечание

Посмотрите Интегрирование, чтобы узнать больше об инстанцировании.

Настройка узла

Нажмите "Сцена" -> "Новая сцена" и добавьте следующие узлы:

Не забудьте настроить дочерние узлы, чтобы они не могли быть выбраны, как вы это делали со сценой игрока.

В свойствах RigidBody2D установите в поле Gravity Scale значение 0, чтобы моб не упал вниз. Кроме того, в разделе PhysicsBody2D выберите свойство Mask и снимите первый флажок. Это гарантирует, что мобы не столкнутся друг с другом.

../../_images/set_collision_mask.png

Настройте AnimatedSprite, как вы сделали это для игрока. На этот раз у нас есть 3 анимации: fly (лететь), swim (плыть) и walk (ходить). В папке art есть две картинки для каждой анимации.

Установите "Speed (FPS)" на 3 для всех анимаций.

../../_images/mob_animations.gif

Установите свойство Playing в инспекторе на "Вкл".

Мы выберем одну из этих анимаций случайным образом, так что мобы будут иметь некоторое разнообразие.

Также как изображения игрока, изображения мобов нужно уменьшить. Установите для AnimatedSprite в свойстве Scale значение (0.75, 0.75).

Как и в сцене Player, добавьте форму CapsuleShape2D для расчета столкновений. Чтобы выровнять фигуру с изображением, вам нужно установить свойство Rotation Degrees на значение 90 (в разделе "Transform" в инспекторе).

Сохраните сцену.

Скрипт врага

Прикрепите скрипт к Mob и добавьте следующие переменные:

extends RigidBody2D

export var min_speed = 150  # Minimum speed range.
export var max_speed = 250  # Maximum speed range.
public class Mob : RigidBody2D
{
    // Don't forget to rebuild the project so the editor knows about the new export variables.

    [Export]
    public int MinSpeed = 150; // Minimum speed range.

    [Export]
    public int MaxSpeed = 250; // Maximum speed range.

}

Когда мы порождаем моба, мы выбираем случайное значение между min_speed и max_speed для того, как быстро будет двигаться каждый моб (было бы скучно, если бы все они двигались с одинаковой скоростью).

Теперь давайте посмотрим на остальную часть скрипта. В функции _ready() мы случайным образом выбираем один из трех типов анимации:

func _ready():
    var mob_types = $AnimatedSprite.frames.get_animation_names()
    $AnimatedSprite.animation = mob_types[randi() % mob_types.size()]
// C# doesn't implement GDScript's random methods, so we use 'System.Random' as an alternative.
static private Random _random = new Random();

public override void _Ready()
{
    var animSprite = GetNode<AnimatedSprite>("AnimatedSprite");
    var mobTypes = animSprite.Frames.GetAnimationNames();
    animSprite.Animation = mobTypes[_random.Next(0, mobTypes.Length)];
}

Сначала мы получаем список имён анимаций из свойства AnimatedSprite frames. Это возвращает массив, содержащий все три имени анимации: ["walk", "swim", "fly"].

Дальше нам нужно выбрать случайное число между 0 и 2, чтобы выбрать имя одной из анимаций из списка (индексы массива начинаются с 0). Функция randi() % n выбирает случайное целое число между 0 и n-1.

Примечание

Вы должны использовать randomize(), если вы хотите, чтобы ваша последовательность "случайных" чисел отличалась при каждом запуске сцены. Мы будем использовать randomize() в нашей сцене Main, поэтому здесь она нам не понадобится.

Последняя часть - заставить мобов удалять себя, когда они покидают экран. Подключите сигнал screen exited() узла Visibility Notifier 2D и добавьте этот код:

func _on_VisibilityNotifier2D_screen_exited():
    queue_free()
public void OnVisibilityNotifier2DScreenExited()
{
    QueueFree();
}

На этом заканчиваем работу над сценой Mob.

Главная сцена

Now it's time to bring it all together. Create a new scene and add a Node named Main. Ensure you create a Node, not a Node2D. Click the "Instance" button and select your saved Player.tscn.

../../_images/instance_scene.png

Теперь добавьте следующие узлы в виде дочерних элементов Main и назовите их как показано ниже (значения указаны в секундах):

  • Timer (назвать MobTimer) - чтобы контролировать частоту появления мобов
  • Timer (назвать ScoreTimer) - чтобы каждую секунду увеличивать счет
  • Timer (назвать StartTimer) - чтобы дать задержку перед стартом игры
  • Position2D (назвать StartPosition) - чтобы указать начальную позицию игрока

Задайте значение Wait Time для каждого из узлов Timer следующим образом:

  • MobTimer: 0.5
  • ScoreTimer: 1
  • StartTimer: 2

Кроме того, установите для свойства One Shot узла StartTimer значение "Вкл" и для свойства Position узла StartPosition установите значение (240, 450).

Добавление мобов

Узел Main будет порождать новых мобов, и мы хотим, чтобы они появлялись в случайном месте на краю экрана. Добавьте узел Path2D с именем MobPath как дочерний элемент узла Main. Когда вы выберете Path2D, вы увидите несколько новых кнопок в верхней части редактора:

../../_images/path2d_buttons.png

Select the middle one ("Add Point") and draw the path by clicking to add the points at the corners shown. To have the points snap to the grid, make sure "Use Grid Snap" and "Use Snap" are both selected. These options can be found to the left of the "Lock" button, appearing as a magnet next to some dots and intersecting lines, respectively.

../../_images/grid_snap_button.png

Важно

Нарисуйте путь в порядке по часовой стрелке, иначе ваши мобы будут появляться с направлением наружу, а не внутрь!

../../_images/draw_path2d.gif

Поместив точку "4" на изображение, нажмите кнопку "Сомкнуть кривую", и она будет завершена.

Теперь, когда путь определен, добавьте узел PathFollow2D как дочерний элемент MobPath и назовите его MobSpawnLocation. Этот узел будет автоматически вращаться и следовать по пути при его перемещении, поэтому мы можем использовать его для выбора случайной позиции и направления вдоль пути.

Ваша сцена должна выглядеть так:

../../_images/main_scene_nodes.png

Главный скрипт

Добавьте скрипт к узлу Main. В верхней части скрипта мы пишем export (PackedScene), что позволяет нам выбрать сцену Mob, экземпляр которой мы хотим сделать.

extends Node

export (PackedScene) var Mob
var score

func _ready():
    randomize()
public class Main : Node
{
    // Don't forget to rebuild the project so the editor knows about the new export variable.

    [Export]
    public PackedScene Mob;

    private int _score;

    // We use 'System.Random' as an alternative to GDScript's random methods.
    private Random _random = new Random();

    public override void _Ready()
    {
    }

    // We'll use this later because C# doesn't support GDScript's randi().
    private float RandRange(float min, float max)
    {
        return (float)_random.NextDouble() * (max - min) + min;
    }
}

Выберите узел Main и вы увидите свойство Mob в окне Инспектора под "Script Variables".

Значение этого свойства можно присвоить двумя способами:

  • Drag Mob.tscn from the "FileSystem" panel and drop it in the Mob property .
  • Нажмите стрелочку вниз рядом с "[пусто]"("[empty]") и выберите "Загрузить"("Load"). Затем выберите Mob.tscn.

Next, select the Player node in the Scene dock, and access the Node dock on the sidebar. Make sure to have the Signals tab selected in the Node dock.

Вы должны увидеть список сигналов для узла Player. В списке найдите и дважды щелкните по сигналу hit (или щелкните по нему правой кнопкой мыши и выберите "Присоединить..."). Это откроет диалоговое окно подключения сигнала. Мы хотим создать новую функцию с именем game_over, которая будет обрабатывать то, что должно произойти, когда игра заканчивается. Введите "game_over" в поле "Метод-приёмник" в нижней части диалогового окна подключения сигнала и нажмите "Присоединить". Добавьте следующий код в новую функцию, а также функцию new_game, которая настроит всё для новой игры:

func game_over():
    $ScoreTimer.stop()
    $MobTimer.stop()

func new_game():
    score = 0
    $Player.start($StartPosition.position)
    $StartTimer.start()
public void GameOver()
{
    GetNode<Timer>("MobTimer").Stop();
    GetNode<Timer>("ScoreTimer").Stop();
}

public void NewGame()
{
    _score = 0;

    var player = GetNode<Player>("Player");
    var startPosition = GetNode<Position2D>("StartPosition");
    player.Start(startPosition.Position);

    GetNode<Timer>("StartTimer").Start();
}

Теперь присоедините сигнал timeout() каждого из узлов Timer (StartTimer, ScoreTimer и MobTimer) к главному скрипту. StartTimer запустит два других таймера. ScoreTimer будет увеличивать счет на 1.

func _on_StartTimer_timeout():
    $MobTimer.start()
    $ScoreTimer.start()

func _on_ScoreTimer_timeout():
    score += 1
public void OnStartTimerTimeout()
{
    GetNode<Timer>("MobTimer").Start();
    GetNode<Timer>("ScoreTimer").Start();
}

public void OnScoreTimerTimeout()
{
    _score++;
}

В функции _on_MobTimer_timeout() мы создадим экземпляр моба, выберем случайное начальное местоположение вдоль Path2D и приведем его в движение. Узел PathFollow2D будет автоматически поворачивать его по направлению пути, поэтому мы воспользуемся этим, чтобы выбрать направление моба и его позицию.

Обратите внимание, что новый экземпляр должен быть добавлен в сцену с помощью функции add_child().

func _on_MobTimer_timeout():
    # Choose a random location on Path2D.
    $MobPath/MobSpawnLocation.offset = randi()
    # Create a Mob instance and add it to the scene.
    var mob = Mob.instance()
    add_child(mob)
    # Set the mob's direction perpendicular to the path direction.
    var direction = $MobPath/MobSpawnLocation.rotation + PI / 2
    # Set the mob's position to a random location.
    mob.position = $MobPath/MobSpawnLocation.position
    # Add some randomness to the direction.
    direction += rand_range(-PI / 4, PI / 4)
    mob.rotation = direction
    # Set the velocity (speed & direction).
    mob.linear_velocity = Vector2(rand_range(mob.min_speed, mob.max_speed), 0)
    mob.linear_velocity = mob.linear_velocity.rotated(direction)
public void OnMobTimerTimeout()
{
    // Choose a random location on Path2D.
    var mobSpawnLocation = GetNode<PathFollow2D>("MobPath/MobSpawnLocation");
    mobSpawnLocation.Offset = _random.Next();

    // Create a Mob instance and add it to the scene.
    var mobInstance = (RigidBody2D)Mob.Instance();
    AddChild(mobInstance);

    // Set the mob's direction perpendicular to the path direction.
    float direction = mobSpawnLocation.Rotation + Mathf.Pi / 2;

    // Set the mob's position to a random location.
    mobInstance.Position = mobSpawnLocation.Position;

    // Add some randomness to the direction.
    direction += RandRange(-Mathf.Pi / 4, Mathf.Pi / 4);
    mobInstance.Rotation = direction;

    // Choose the velocity.
    mobInstance.LinearVelocity = new Vector2(RandRange(150f, 250f), 0).Rotated(direction);
}

Важно

Почему PI? В функциях, требующих углы, GDScript использует радианы, а не градусы. Если вам удобнее работать с градусами, вам нужно использовать функции deg2rad() и rad2deg() для преобразования между ними.

Тестирование сцены

Давайте протестируем сцену, чтобы убедиться, что все работает. Добавьте в _ready() следующие строки:

func _ready():
    randomize()
    new_game()
    public override void _Ready()
    {
        NewGame();
    }
}

Также давайте назначим сцену Main в качестве нашей "Главной сцены", которая запускается автоматически при запуске игры. Нажмите кнопку "Play" и выберите Main.tscn при появлении запроса.

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

Когда вы убедитесь, что всё работает, удалите вызов new_game() из _ready().

HUD

Последняя часть, в которой нуждается наша игра, - это UI: интерфейс для отображения таких вещей, как игровой счет, сообщение "игра окончена" и кнопка перезапуска. Создайте новую сцену и добавьте узел CanvasLayer с именем HUD. "HUD" означает "heads-up display", информационный дисплей, отображающийся поверх игры.

Узел CanvasLayer позволяет нам прорисовывать элементы нашего UI на слое поверх всей остальной игры, поэтому отображаемая информация не перекрывается никакими игровыми элементами, такими как игрок или мобы.

HUD должен отображать следующую информацию:

  • Счет, измененный ScoreTimer.
  • Сообщение, например "Game Over" или "Get Ready!"
  • Кнопка "Start", чтобы начать игру.

Основной узел для элементов UI — это Control. Чтобы создать наш UI, мы будем использовать два типа узлов Control: Label и Button.

Создайте следующие узлы в качестве потомков узла '' HUD'':

  • Label с именем ScoreLabel.
  • Label с именем Message.
  • Button с именем StartButton.
  • Timer с именем MessageTimer.

Нажмите на ScoreLabel и введите число в поле Text в Инспекторе. Стандартный шрифт для узлов Control мал и плохо масштабируется. В ресурсы игры включен файл шрифта под названием "Xolonium-Regular.ttf". Чтобы использовать этот шрифт, сделайте следующее:

  1. Под "Custom Fonts" выберите "Новый DynamicFont"
../../_images/custom_font1.png
  1. Нажмите на только что добавленный "DynamicFont" и в разделе "Font/Font Data" выберите "Загрузить" и выберите файл "Xolonium-Regular.ttf" . Также вы должны установить размер шрифта Size. Значение 64 хорошо подходит.
../../_images/custom_font2.png

Как только вы проделали это с ScoreLabel, можете нажать стрелку вниз рядом со свойством DynamicFont и выбрать "Копировать", а затем "Вставить" его в том же месте на двух других узлах Control.

Примечание

Якоря и Отступы: Узлы Control имеют не только положение и размер, но также якоря и отступы. Якоря определяют начало координат - опорную точку для краев узла. Отступы обновляются автоматически при перемещении или изменении размера узла управления. Они представляют расстояние от краев Control-узла до его якоря. См. Дизайн интерфейса с помощью узлов контроля для получения более подробной информации.

Организуйте узлы, как показано ниже. Нажмите кнопку "Макет", чтобы задать макет для узла Control:

../../_images/ui_anchor.png

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

ScoreLabel

  • Макет : "Сверху по всей ширине"
  • Text : 0
  • Align : "Center"

Message

  • Макет: "По центру по всей ширине"
  • Text: Уворачивайся от крипов!
  • Align : "Center"
  • Autowrap : "Вкл"

StartButton

  • Text : Старт
  • Макет: "Внизу посередине"
  • Margin :
    • Top: -200
    • Bottom: -100

В MessageTimer установите параметр Wait Time на 2, а параметр One Shot на значение "Вкл".

Теперь добавьте этот скрипт в HUD:

extends CanvasLayer

signal start_game
public class HUD : CanvasLayer
{
    // Don't forget to rebuild the project so the editor knows about the new signal.

    [Signal]
    public delegate void StartGame();
}

Сигнал start_game сообщает узлу Main, что кнопка была нажата.

func show_message(text):
    $Message.text = text
    $Message.show()
    $MessageTimer.start()
public void ShowMessage(string text)
{
    var message = GetNode<Label>("Message");
    message.Text = text;
    message.Show();

    GetNode<Timer>("MessageTimer").Start();
}

Эта функция вызывается, когда мы хотим временно отобразить сообщение, такое как "Приготовьтесь".

func show_game_over():
    show_message("Game Over")
    # Wait until the MessageTimer has counted down.
    yield($MessageTimer, "timeout")

    $Message.text = "Dodge the\nCreeps!"
    $Message.show()
    # Make a one-shot timer and wait for it to finish.
    yield(get_tree().create_timer(1), "timeout")
    $StartButton.show()
async public void ShowGameOver()
{
    ShowMessage("Game Over");

    var messageTimer = GetNode<Timer>("MessageTimer");
    await ToSignal(messageTimer, "timeout");

    var message = GetNode<Label>("Message");
    message.Text = "Dodge the\nCreeps!";
    message.Show();

    await ToSignal(GetTree().CreateTimer(1), "timeout");
    GetNode<Button>("StartButton").Show();
}

Эта функция вызывается, когда игрок проигрывает. Она покажет надпись "Game Over" на 2 секунды, затем произойдет возврат к основному экрану, и после короткой паузы появится кнопка "Start".

Примечание

Если вам нужно сделать паузу на короткое время, то альтернативой использованию узла Timer является использование функции SceneTree create_timer(). Может быть очень полезно добавлять задержки наподобие таких, как в вышеприведенном коде, где нам хотелось бы подождать немного времени, прежде чем показывать кнопку "Start".

func update_score(score):
    $ScoreLabel.text = str(score)
public void UpdateScore(int score)
{
    GetNode<Label>("ScoreLabel").Text = score.ToString();
}

Эта функция вызывается из Main каждый раз, когда изменяется количество очков.

Присоедините сигнал timeout() из MessageTimer и сигнал pressed() из StartButton и добавьте следующий код к новым функциям:

func _on_StartButton_pressed():
    $StartButton.hide()
    emit_signal("start_game")

func _on_MessageTimer_timeout():
    $Message.hide()
public void OnStartButtonPressed()
{
    GetNode<Button>("StartButton").Hide();
    EmitSignal("StartGame");
}

public void OnMessageTimerTimeout()
{
    GetNode<Label>("Message").Hide();
}

Подключение HUD к Main

Теперь, когда мы закончили создание сцены HUD, вернитесь к Main. Инстанцируйте сцену HUD в Main подобно тому, как вы это делали со сценой Player. Дерево сцены должно выглядеть так, поэтому убедитесь, что вы ничего не упустили:

../../_images/completed_main_scene.png

Теперь нам нужно подключить функционал HUD в наш Main-скрипт. Для этого потребуются некоторые дополнения к сцене Main:

Во вкладке "Узел" присоедините сигнал HUD start_game к функции узла Main new_game(), введя "new_game" в поле "Метод-приёмник" в окне "Подключить сигнал к методу" . Убедитесь, что в скрипте рядом с функцией func new_game() теперь появилась зелёная иконка подключения.

В new_game() обновим отображение счёта и выведем сообщение "Get Ready":

$HUD.update_score(score)
$HUD.show_message("Get Ready")
var hud = GetNode<HUD>("HUD");
hud.UpdateScore(_score);
hud.ShowMessage("Get Ready!");

В game_over() нам нужно вызвать соответствующую функцию HUD:

$HUD.show_game_over()
GetNode<HUD>("HUD").ShowGameOver();

Наконец добавьте это в _on_ScoreTimer_timeout(), чтобы синхронизировать отображение с изменением количества очков:

$HUD.update_score(score)
GetNode<HUD>("HUD").UpdateScore(_score);

Теперь вы готовы к игре! Нажмите на кнопку "Запустить проект". Вам будет предложено выбрать основную сцену - выбирайте Main.tscn.

Удаляем старых крипов

Если вы играете до "Game Over", а затем сразу начинаете новую игру, то крипы из предыдущей игры могут все еще оставаться на экране. Было бы лучше, если бы все они исчезали в начале новой игры. Нам просто нужен способ сказать всем мобам, чтобы они удалились. Мы можем сделать это с помощью функции "group" ("группа").

В сцене Mob выберите корневой узел и нажмите вкладку "Узел" рядом с Инспектором (там же, где вы находите сигналы узла). Рядом с "Сигналы" нажмите "Группы", введите новое имя группы и нажмите "Добавить".

../../_images/group_tab.png

Теперь все мобы будут в группе "mobs". Затем мы можем добавить следующие строки к функции game_over() в Main:

get_tree().call_group("mobs", "queue_free")
GetTree().CallGroup("mobs", "queue_free");

Функция call_group() вызывает каждую именованную функцию на каждом узле в группе - в этом случае мы говорим каждому мобу удалять себя.

Завершающие штрихи

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

Фон

Серый фон по умолчанию не очень привлекателен, так что давайте поменяем его цвет. Один из способов сделать это - использовать узел ColorRect. Создайте его первым узлом под Main - таким образом он будет прорисовываться за другими узлами. ColorRect имеет только одно свойство: Color. Подберите цвет, который вам нравится, и выберите "Макет" -> "Полный прямоугольник" так, чтобы он покрывал экран.

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

Звуковые эффекты

Звук и музыка могут быть одним из самых эффективных способов придания игровому процессу большей привлекательности. В папке ресурсов вашей игры есть два звуковых файла: "House In a Forest Loop.ogg" для фоновой музыки и "gameover.wav" для случаев, когда игрок проигрывает.

Добавьте два узла AudioStreamPlayer как дочерние для Main. Назовите один из них Music, а другой - DeathSound. В каждом из них нажмите на свойство Stream, выберите "Загрузить" и добавьте соответствующий звуковой файл.

Для воспроизведения музыки добавьте $Music.play() в функцию new_game() и $Music.stop() в функцию game_over().

Наконец, добавьте $DeathSound.play() в функцию game_over().

Сочетание клавиш

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

В сцене HUD выберите StartButton и найдите его свойство Shortcut в Инспекторе. Выберите "Новый Shortcut" и нажмите на пункт "Shortcut". Появится второе свойство Shortcut. Выберите "Новый InputEventAction" и нажмите на вновь появившийся "InputEventAction". Наконец, в свойстве Action введите имя ui_select. Это событие ввода по умолчанию, связанное с пробелом.

../../_images/start_button_shortcut.png

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

Файлы проекта

Вы можете найти завершённую версию этого проекта по этим ссылкам: