Управление игровым интерфейсом из кода

Введение

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

../../_images/lifebar_tutorial_final_result.gif

Вот что вы создадите: анимацию шкалы и счетчика, когда персонаж получает урон. Они исчезают, когда он умирает.

Вы узнаете:

  • Как подключить персонажа к интерфейсу с помощью сигналов
  • Как управлять интерфейсом с помощью GDscript
  • Как анимировать шкалу здоровья с помощью узла Tween

Если вместо этого вы хотите узнать, как собственно настроить интерфейс, ознакомьтесь с уроками по UI:

  • Создание главного меню
  • Создание интерфейса игры

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

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

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

Примечание

Signals are Godot’s version of the Observer pattern. They allow us to send out some message. Other nodes can connect to the object that emits the signal and receive the information. It’s a powerful tool we use a lot for User Interface and achievement systems. You don’t want to use them everywhere, though. Connecting two nodes adds some coupling between them. When there’s a lot of connections, they become hard to manage. For more information, check out the signals video tutorial on GDquest.

Скачайте и изучите стартовый проект

Скачать проект Godot : ui_code_life_bar.zip. Он содержит все ресурсы и скрипты, необходимые для начала работы. Извлеките ZIP-архив, чтобы получить две папки: start и end.

Load the start project in Godot. In the FileSystem dock, double click on LevelMockup.tscn to open it. It’s an RPG game’s mockup where 2 characters face each other. The pink enemy attacks and damages the green square at regular time intervals, until its death. Feel free to try out the game: the basic combat mechanics already work. But as the character isn’t connected to the life bar, the GUI doesn’t do anything.

Примечание

Это типично для программирования игры: сначала вы реализуете основной геймплей, обрабатываете смерть игрока, и только после вы добавите интерфейс. Это потому, что UI «слушает», что происходит в игре. Поэтому он не может работать, если другие системы еще не готовы. Если вы разработали интерфейс перед созданием прототипа и последующего тестирования игры, скорее всего, он не будет работать хорошо, и вам придется заново создавать его с нуля.

Сцена содержит спрайт с фоном игры, графический интерфейс и два персонажа.

../../_images/lifebar_tutorial_life_bar_step_tut_LevelMockup_scene_tree.png

The scene tree, with the GUI scene set to display its children

The GUI scene encapsulates all of the game’s Graphical User Interface. It comes with a barebones script where we get the path to nodes that exist inside the scene:

onready var number_label = $Bars/LifeBar/Count/Background/Number
onready var bar = $Bars/LifeBar/TextureProgress
onready var tween = $Tween
public class Gui : MarginContainer
{
    private Tween _tween;
    private Label _numberLabel;
    private TextureProgress _bar;

    public override void _Ready()
    {
        // C# doesn't have an onready feature, this works just the same.
        _bar = (TextureProgress) GetNode("Bars/LifeBar/TextureProgress");
        _tween = (Tween) GetNode("Tween");
        _numberLabel = (Label) GetNode("Bars/LifeBar/Count/Background/Number");
    }
}
  • number_label отображает количество здоровья в числовом виде. Этот узел - Label
  • bar - это сама шкала здоровья. Этот узле - TextureProgress
  • tween is a component-style node that can animate and control any value or method from any other node

Примечание

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

В корне проекта, в папке res:// вы найдете LevelMockup. Это основная игровая сцена с которой мы будем работать. Все компоненты, составляющие игру, находятся в папке scenes/. Папка assets/ содержит игровые спрайты и шрифт для счетчика HP. В папке „ scripts/“ вы найдете скрипты для врага, игрока и GUI-контроллера.

Щелкните на значок редактирования сцены справа от узла в дереве сцены, чтобы открыть сцену в редакторе. Вы увидите, LifeBar и EnergyBar которые также являются сценами.

../../_images/lifebar_tutorial_Player_with_editable_children_on.png

Дерево сцены с Player и его детьми

Установка параметра Player max_health (Максимальное здоровье) для LifeBar’a

Мы должны передать в GUI текущее здоровье игрока, чтобы обновить текстуру LifeBar’a, и для отображения оставшегося здоровья в счетчике HP, который находиться в левом верхнем углу экрана. Для этого мы отправляем здоровье игрока в GUI каждый раз, когда они получают урон. GUI будет обновлять узлы Lifebar и Number с учетом этого значения.

Мы могли бы закончить здесь с отображением числа, но нам нужно инициализировать шкалу max_value для того, чтобы обновлять с правильными пропорциями. Первым делом говорим передаем GUI параметр зеленого персонажа (Игрока) max_health.

Совет

Шкала TextureProgress имеет значение max_value в 100 по-умолчанию. Если вам не нужно отображать здоровье персонажа с числом, вам не нужно менять его свойство max_value. Вместо этого Вы отправляете процент от Player в GUI: health / max_health * 100.

../../_images/lifebar_tutorial_TextureProgress_default_max_value.png

Нажмите на значок скрипта справа от GUI в панели сцен, чтобы открыть его скрипт. В функции _ready мы собираемся хранить параметр max_health узла Player в новой переменной и использовать его для установки значения max_value узла bar:

func _ready():
    var player_max_health = $"../Characters/Player".max_health
    bar.max_value = player_max_health
public override void _Ready()
{
    // Add this below _bar, _tween, and _numberLabel.
    var player = (Player) GetNode("../Characters/Player");
    _bar.MaxValue = player.MaxHealth;
}

Давайте рассмотрим его. $"../Characters/Player" является сокращением, которое поднимается на один узел вверх в дереве сцен, и извлекает Characters/Player оттуда. Это дает нам доступ к узлу. Вторая часть заявления, .max_health, обращается к параметру max_health узла Player.

Вторая строка присваивает это значение к bar.max_value. Вы могли бы объединить две строки в одну, но мы должны будем использовать player_max_health снова позже в этом уроке.

Player.gd устанавливает``health`` значение max_health в начале игры, поэтому мы могли бы работать с ним. Почему мы по-прежнему используем max_health? Есть две причины:

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

Примечание

Когда вы открываете сцену в игре, Godot создает узлы один за одним, следуя заказу в вашей панели сцен, сверху вниз. GUI и Player не являются частью той же ветви узла. Чтобы убедиться, что они оба существуют, когда мы получаем доступ к ним, мы должны использовать функцию _ready. Godot вызывает _ready сразу после того, как он загрузил все узлы, до начала игры. Это идеальная функция, чтобы установить все и подготовить сессию игры. Узнайте больше о _ready: Написание сценариев (продолжение)

Обновление здоровья с помощью сигнала при получении урона

Наш GUI готов к получению обновляемых значений health из Player. Для этого мы собираемся использовать сигналы.

Примечание

Есть много полезных встроенных сигналов, таких как enter_tree и exit_tree, они вызываются, когда узлы соответственно создаются и уничтожаются. Вы также можете создать свой собственный сигнал с помощью ключевого слова signal. На узле Player вы найдете два сигнала, которые мы создали для вас: died и health_changed.

Почему мы напрямую не получаем узел Player в функции _process и получаем на значение health? Из за этого доступ к узлам создает тесную связь между ними. Это может работать, но по мере того как ваша игра растет больше, у вас станет очень много сигналов. Если вы получаете узлы таким образом, все становится запутанным. Не только это: вы должны постоянно узнавать изменения в функции _process. Эта проверка происходит 60 раз в секунду, и вы, вероятно, испортите игру из-за порядка, в котором выполняется код.

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

Примечание

The Observer pattern, that signals derive from, still adds a bit of coupling between node branches. But it’s generally lighter and more secure than accessing nodes directly to communicate between two separate classes. It can be okay for a parent node to get values from its children. But you’ll want to favor signals if you’re working with two separate branches. Read Game Programming Patterns for more information on the Observer pattern. The full book is available online for free.

With this in mind, let’s connect the GUI to the Player. Click on the Player node in the scene dock to select it. Head down to the Inspector and click on the Node tab. This is the place to connect nodes to listen to the one you selected.

В первом разделе перечислены пользовательские сигналы, определенные в Player.gd:

  • died излучает сигнал, когда персонаж умер. Мы будем использовать его для скрытия интерфейса.
  • health_changed излучает, когда персонаж получил урон.
../../_images/lifebar_tutorial_health_changed_signal.png

Мы соединимся с сигналом health_changed

Выберите health_changed и кликните на кнопку «Соединить» в правом нижнем углу, чтобы открыть окно Соединения Сигнала. На левой стороне вы можете выбрать узел, который будет слушать этот сигнал. Выберите узел GUI. Правая сторона экрана позволит вам добавить дополнительные значения к сигналу. Мы уже позаботились об этом в Player.gd. В целом, я рекомендую не добавлять слишком много аргументов, используя это окно, так как делать это в нем менее удобно, чем в коде.

../../_images/lifebar_tutorial_connect_signal_window_health_changed.png

Окно подключения сигнала с выбранным узлом GUI

Совет

При необходимости можно подключить узлы из кода. Однако подключение из редактора имеет два преимущества:

  1. Godot can write new callback functions for you in the connected script
  2. Рядом с узлом, который излучает сигнал в панели сцен, появился значок эмиттера

В нижней части окна вы найдете путь к выбранному узлу. Мы заинтересованы во втором поле под названием «Method in Node». Это метод на узле GUI, который вызывается при излучении сигнала. Этот метод получает значения, отправленные с сигналом и позволяет обработать их. Если вы посмотрите правее, там есть переключатель «сделать функцию», который включен по умолчанию. Нажмите на кнопку подключения в нижней части окна. Godot создает метод внутри узла GUI. Откроется редактор скриптов с курсором внутри новой функции _on_Player_health_changed.

Примечание

При подключении узлов из редактора Godot создает имя метода по шаблону: _on_EmitterName_signal_name. Если вы уже написали этот метод, опция «Сделать функцию» будет сохранять его. Вы можете заменить имя на другое.

../../_images/lifebar_tutorial_godot_generates_signal_callback.png

Godot writes the callback method for you and takes you to it

Inside the parentheses after the function name, add a player_health argument. When the player emits the health_changed signal, it will send its current health alongside it. Your code should look like:

func _on_Player_health_changed(player_health):
    pass
public void OnPlayerHealthChanged(int playerHealth)
{
}

Примечание

The engine does not convert PascalCase to snake_case, for C# examples we’ll be using PascalCase for method names & camelCase for method parameters, which follows the official C# naming conventions.

../../_images/lifebar_tutorial_player_gd_emits_health_changed_code.png

В Player.gd, когда Player посылает сигнал health_changed, он также посылает значение своего здоровья

Inside _on_Player_health_changed, let’s call a second function called update_health and pass it the player_health variable.

Примечание

Мы можем напрямую изменить значение здоровья на LifeBar и Number. Есть две причины использовать этот метод:

  1. Название дает понять, для нашего будущего себя и напарников, что когда игрок получил урон, мы обновляем счетчик здоровья на GUI
  2. Мы будем использовать этот метод чуть позже

Создать новый метод update_health ниже _on_Player_health_changed. В качестве единственного аргумента требуется new_value:

func update_health(new_value):
    pass
public void UpdateHealth(int health)
{
}

Этот метод должен:

  • задайте свойству text узла Number значение new_value, приведенное к строковому типу
  • задайте свойству value узла TextureProgress значение new_value
func update_health(new_value):
    number_label.text = str(new_value)
    bar.value = new_value
public void UpdateHealth(int health)
{
    _numberLabel.Text = health.ToString();
    _bar.Value = health;
}

Совет

str is a built-in function that converts about any value to text. Number’s text property requires a string, so we can’t assign it to new_value directly

Также вызов update_health в конце функции _ready инициализировать text узла ``Number``с правильным значением в начале игры. Нажмите клавишу F5, чтобы протестировать игру: жизни обновляются при каждой атаке!

../../_images/lifebar_tutorial_LifeBar_health_update_no_anim.gif

Оба узла Number и TextureProgress обновляются когда игрок принимает удар

Анимировать смерть с помощью узла Tween

Интерфейс нашей игры уже вполне рабочий, однако не помешает добавить немного анимаций. В этом нам поможет узел Tween — незаменимый инструмент для создания анимаций. Tween может плавно менять значение любого свойства, которого вы только пожелаете. Например, когда игрок``Player`` получает урон, полоска здоровья TextureProgress уменьшается до текущего уровня здоровья health.

Сцена GUI уже содержит дочерний узел Tween, хрянящийся в переменной tween. Давайте используем их. Но сначала мы должны произвести некоторые изменения в update_health.

Мы будем использовать метод узла Tween, который называется``interpolate_property`` и принимает семь аргументов:

  1. Ссылка на узел, свойство которого мы будем анимировать
  2. Идентификатор свойства в виде строки
  3. Начальное значение
  4. Конечное значение
  5. Продолжительность анимации в секундах
  6. Тип перехода
  7. The easing to use in combination with the equation.

The last two arguments combined correspond to an easing equation. This controls how the value evolves from the start to the end point.

Click the script icon next to the GUI node to open it again. The Number node needs text to update itself, and the Bar needs a float or an integer. We can use interpolate_property to animate a number, but not to animate text directly. We’re going to use it to animate a new GUI variable named animated_health.

В верхней части скрипта определите новую переменную, назовите ее animated_health и установите ее значение на 0. Вернитесь к методу aupdate_health и очистите ее содержимое. Давайте анимируем animated_health. Вызываем метод узла Tween interpolate_property:

func update_health(new_value):
    tween.interpolate_property(self, "animated_health", animated_health, new_value, 0.6, Tween.TRANS_LINEAR, Tween.EASE_IN)
// Add this to the top of your class.
private float _animatedHealth = 0;

public void UpdateHealth(int health)
{
    _tween.InterpolateProperty(this, "_animatedHealth", _animatedHealth, health, 0.6f, Tween.TransitionType.Linear,
        Tween.EaseType.In);
}

Давайте разберем этот вызов поподробнее:

tween.interpolate_property(self, "animated_health", ...

Мы нацелены на animated_health на self, то есть на узел GUI. Второе свойство interpolate_property узла Tween принимается в виде строки. Вот почему мы пишем это как "animated_health".

... _health", animated_health, new_value, 0.6 ...

The starting point is the current value the bar’s at. We still have to code this part, but it’s going to be animated_health. The end point of the animation is the Player’s health after the health_changed: that’s new_value. And 0.6 is the animation’s duration in seconds.

...  0.6, tween.TRANS_LINEAR, Tween.EASE_IN)

Последние два аргумента - это константы из класса Tween. TRANS_LINEAR означает, что анимация должна быть линейной. EASE_IN ничего не делает с линейным переходом, но мы должны предоставить последний аргумент, иначе мы получим ошибку.

Анимация не будет воспроизводиться, пока мы не запустим узел Tween с помощью функции tween.start()`. Это нужно сделать только один раз, если узел не активен. Добавьте этот код после последней строки:

if not tween.is_active():
    tween.start()
if (!_tween.IsActive())
{
    _tween.Start();
}

Примечание

Although we could animate the health property on the Player, we shouldn’t. Characters should lose life instantly when they get hit. It makes it a lot easier to manage their state, like to know when one died. You always want to store animations in a separate data container or node. The tween node is perfect for code-controlled animations. For hand-made animations, check out AnimationPlayer.

Присоединение animated_health к шкале здоровья

Теперь переменная animated_health анимирована, но мы больше не обновляем Bar и Number узлы. Давай исправим это.

Пока метод update_health выглядит следующим образом:

func update_health(new_value):
    tween.interpolate_property(self, "animated_health", animated_health, new_value, 0.6, Tween.TRANS_LINEAR, Tween.EASE_IN)
    if not tween.is_active():
        tween.start()
public void UpdateHealth(int health)
{
    _tween.InterpolateProperty(this, "_animatedHealth", _animatedHealth, health, 0.6f, Tween.TransitionType.Linear,
        Tween.EaseType.In);

    if(!_tween.IsActive())
    {
        _tween.Start();
    }
}

В данном конкретном случае, поскольку number_label принимает текст, нам нужно использовать метод _process для его анимирования. Теперь обновим узлы Number и TextureProgress как и до этого, внутри метода _process:

func _process(delta):
    number_label.text = str(animated_health)
    bar.value = animated_health
public override void _Process(float delta)
{
    _numberLabel.Text = _animatedHealth.ToString();
    _bar.Value = _animatedHealth;
}

Примечание

Переменные number_label и bar хранят ссылки на узлы Number и TextureProgress.

Play the game to see the bar animate smoothly. But the text displays decimal number and looks like a mess. And considering the style of the game, it’d be nice for the life bar to animate in a choppier fashion.

../../_images/lifebar_tutorial_number_animation_messed_up.gif

Анимация гладкая, но число отображается криво

Мы можем решить обе проблемы, округлив animated_health. Используйте локальную переменную под названием round_value для хранения округленной animated_health. Затем присваиваются значения number_label.text и bar.value:

func _process(delta):
    var round_value = round(animated_health)
    number_label.text = str(round_value)
    bar.value = round_value
public override void _Process(float delta)
{
    var roundValue = Mathf.Round(_animatedHealth);
    _numberLabel.Text = roundValue.ToString();
    _bar.Value = roundValue;
}

Try the game again to see a nice blocky animation.

../../_images/lifebar_tutorial_number_animation_working.gif

Округляя animated_health, мы убиваем двух зайцев одним выстрелом

Совет

Every time the player takes a hit, the GUI calls _on_Player_health_changed, which in turn calls update_health. This updates the animation and the number_label and bar follow in _process. The animated life bar that shows the health going down gradually is a trick. It makes the GUI feel alive. If the Player takes 3 damage, it happens in an instant.

Исчезновение шкал, когда игрок умирает

When the green character dies, it plays a death animation and fades out. At this point, we shouldn’t show the interface anymore. Let’s fade the bar as well when the character died. We will reuse the same Tween node as it manages multiple animations in parallel for us.

Во-первых, GUI необходимо подключиться к сигналу died у``Player``, чтобы узнать, когда тот умер. Нажмите F1 для возврата в 2D-среду. Выберите вкладку Player в панели Сцен и щелкните на вкладку Узел рядом с Инспектором.

Найдите сигнал died, выберите его и нажмите кнопку Connect (Подключить).

../../_images/lifebar_tutorial_player_died_signal_enemy_connected.png

The signal should already have the Enemy connected to it

In the Connecting Signal window, connect to the GUI node again. The Path to Node should be ../../GUI and the Method in Node should show _on_Player_died. Leave the Make Function option on and click Connect at the bottom of the window. This will take you to the GUI.gd file in the Script Workspace.

../../_images/lifebar_tutorial_player_died_connecting_signal_window.png

Вы должны получить эти значения в окне Connecting Signal (Подключение сигнала)

Примечание

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

To animate a fade on a UI element, we have to use its modulate property. modulate is a Color that multiplies the colors of our textures.

Примечание

modulate наследуется из класса CanvasItem, от него наследуются все 2D и UI узлы. Он позволяет переключать видимость узла, назначать ему шейдер и изменять его цвет с помощью modulate.

modulate takes a Color value with 4 channels: red, green, blue and alpha. If we darken any of the first three channels it darkens the interface. If we lower the alpha channel, our interface fades out.

We’re going to tween between two color values: from a white with an alpha of 1, that is to say at full opacity, to a pure white with an alpha value of 0, completely transparent. Let’s add two variables at the top of the _on_Player_died method and name them start_color and end_color. Use the Color() constructor to build two Color values.

func _on_Player_died():
    var start_color = Color(1.0, 1.0, 1.0, 1.0)
    var end_color = Color(1.0, 1.0, 1.0, 0.0)
public void OnPlayerDied()
{
    var startColor = new Color(1.0f, 1.0f, 1.0f);
    var endColor = new Color(1.0f, 1.0f, 1.0f, 0.0f);
}

Color(1.0, 1.0, 1.0, 1.0) соответствует белому цвету. Четвертый аргумент, соответственно 1.0 и 0.0 в start_color и end_color, - это альфа-канал.

Затем снова вызываем метод interpolate_property узла Tween:

tween.interpolate_property(self, "modulate", start_color, end_color, 1.0, Tween.TRANS_LINEAR, Tween.EASE_IN)
_tween.InterpolateProperty(this, "modulate", startColor, endColor, 1.0f, Tween.TransitionType.Linear,
  Tween.EaseType.In);

This time, we change the modulate property and have it animate from start_color to the end_color. The duration is of one second, with a linear transition. Here again, because the transition is linear, the easing does not matter. Here’s the complete _on_Player_died method:

func _on_Player_died():
    var start_color = Color(1.0, 1.0, 1.0, 1.0)
    var end_color = Color(1.0, 1.0, 1.0, 0.0)
    tween.interpolate_property(self, "modulate", start_color, end_color, 1.0, Tween.TRANS_LINEAR, Tween.EASE_IN)
public void OnPlayerDied()
{
    var startColor = new Color(1.0f, 1.0f, 1.0f);
    var endColor = new Color(1.0f, 1.0f, 1.0f, 0.0f);

    _tween.InterpolateProperty(this, "modulate", startColor, endColor, 1.0f, Tween.TransitionType.Linear,
        Tween.EaseType.In);
}

И все. Теперь вы можете запустить игру, чтобы увидеть конечный результат!

../../_images/lifebar_tutorial_final_result.gif

Конечный результат. Поздравляю, что добрались сюда!

Примечание

Используя те же методы, вы можете изменить цвет бара, когда игрока отравляют, сделать бар красным, когда его здоровье падает, потрясти пользовательский интерфейс, когда он получает критический удар… принцип тот же самый: подать сигнал для передачи данных из Player’a в GUI и дать GUI обработать их.