Керування користувацьким інтерфейсом з коду

Вступ

In this tutorial, you will connect a character to a life bar and animate the health loss.

../../_images/lifebar_tutorial_final_result.gif

Here's what you'll create: the bar and the counter animate when the character takes a hit. They fade when it dies.

Ви навчитеся:

  • How to connect a character to a GUI with signals
  • How to control a GUI with GDscript
  • How to animate a life bar with the Tween node

If you want to learn how to set up the interface instead, check out the step-by-step UI tutorials:

When you code a game, you want to build the core gameplay first: the main mechanics, player input, win and loss conditions. The UI comes a bit later. You want to keep all the elements that make up your project separate if possible. Each character should be in its own scene, with its own scripts, and so should the UI elements. This prevents bugs, keeps your project manageable, and allows different team members to work on different parts of the game.

Once the core gameplay and the UI are ready, you'll need to connect them somehow. In our example, we have the Enemy who attacks the Player at constant time intervals. We want the life bar to update when the Player takes damage.

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

Примітка

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.

Завантажте та вивчіть стартовий проект

Download the Godot project: ui_code_life_bar.zip. It contains all the assets and scripts you need to get started. Extract the .zip archive to get two folders: start and 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.

Примітка

This is typical of how you'd code a game: you implement the core gameplay first, handle the player's death, and only then you'll add the interface. That's because the UI listens to what's happening in the game. So it can't work if other systems aren't in place yet. If you design the UI before you prototype and test the gameplay, chances are it won't work well and you'll have to re-create it from scratch.

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

../../_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 displays a life count as a number. It's a Label node
  • bar is the life bar itself. It's a TextureProgress node
  • tween is a component-style node that can animate and control any value or method from any other node

Примітка

The project uses a simple organization that works for game jams and tiny games.

At the root of the project, in the res:// folder, you will find the LevelMockup. That's the main game scene and the one we will work with. All the components that make up the game are in the scenes/ folder. The assets/ folder contains the game sprites and the font for the HP counter. In the scripts/ folder you will find the enemy, the player, and the GUI controller scripts.

Click the edit scene icon to the right of the node in the scene tree to open the scene in the editor. You'll see the LifeBar and EnergyBar are sub-scenes themselves.

../../_images/lifebar_tutorial_Player_with_editable_children_on.png

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

Set up the Lifebar with the Player's max_health

We have to tell the GUI somehow what the player's current health is, to update the lifebar's texture, and to display the remaining health in the HP counter in the top left corner of the screen. To do this we send the player's health to the GUI every time they take damage. The GUI will then update the Lifebar and Number nodes with this value.

We could stop here to display the number, but we need to initialize the bar's max_value for it to update in the right proportions. The first step is thus to tell the GUI what the green character's max_health is.

Порада

The bar, a TextureProgress, has a max_value of 100 by default. If you don't need to display the character's health with a number, you don't need to change its max_value property. You send a percentage from the Player to the GUI instead: health / max_health * 100.

../../_images/lifebar_tutorial_TextureProgress_default_max_value.png

Click the script icon to the right of the GUI in the Scene dock to open its script. In the _ready function, we're going to store the Player's max_health in a new variable and use it to set the bar's max_value:

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;
}

Let's break it down. $"../Characters/Player" is a shorthand that goes one node up in the scene tree, and retrieves the Characters/Player node from there. It gives us access to the node. The second part of the statement, .max_health, accesses the max_health on the Player node.

The second line assigns this value to bar.max_value. You could combine the two lines into one, but we'll need to use player_max_health again later in the tutorial.

Player.gd sets the health to max_health at the start of the game, so we could work with this. Why do we still use max_health? There are two reasons:

We don't have the guarantee that health will always equal max_health: a future version of the game may load a level where the player already lost some health.

Примітка

When you open a scene in the game, Godot creates nodes one by one, following the order in your Scene dock, from top to bottom. GUI and Player are not part of the same node branch. To make sure they both exist when we access each other, we have to use the _ready function. Godot calls _ready right after it loaded all nodes, before the game starts. It's the perfect function to set everything up and prepare the game session. Learn more about _ready: Написання скриптів (продовження)

Update health with a signal when the player takes a hit

Our GUI is ready to receive the health value updates from the Player. To achieve this we're going to use signals.

Примітка

There are many useful built-in signals like enter_tree and exit_tree, that all nodes emit when they are respectively created and destroyed. You can also create your own using the signal keyword. On the Player node, you'll find two signals we created for you: died and health_changed.

Why don't we directly get the Player node in the _process function and look at the health value? Accessing nodes this way creates tight coupling between them. If you did it sparingly it may work. As your game grows bigger, you may have many more connections. If you get nodes this way it gets complex quickly. Not only that: you need to listen to the state change constantly in the _process function. This check happens 60 times a second and you'll likely break the game because of the order in which the code runs.

On a given frame you may look at another node's property before it was updated: you get a value from the last frame. This leads to obscure bugs that are hard to fix. On the other hand, a signal is emitted right after a change happened. It guarantees you're getting a fresh piece of information. And you will update the state of your connected node right after the change happened.

Примітка

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 is emitted when the character died. We will use it in a moment to hide the UI.
  • health_changed is emitted when the character got hit.
../../_images/lifebar_tutorial_health_changed_signal.png

We're connecting to the health_changed signal

Select health_changed and click on the Connect button in the bottom right corner to open the Connect Signal window. On the left side you can pick the node that will listen to this signal. Select the GUI node. The right side of the screen lets you pack optional values with the signal. We already took care of it in Player.gd. In general I recommend not to add too many arguments using this window as they're less convenient than doing it from the code.

../../_images/lifebar_tutorial_connect_signal_window_health_changed.png

The Connect Signal window with the GUI node selected

Порада

Ви можете з'єднати вузли з коду. Однак підключення з редактора має дві переваги:

  1. Godot can write new callback functions for you in the connected script
  2. An emitter icon appears next to the node that emits the signal in the Scene dock

At the bottom of the window you will find the path to the node you selected. We're interested in the second row called "Method in Node". This is the method on the GUI node that gets called when the signal is emitted. This method receives the values sent with the signal and lets you process them. If you look to the right, there is a "Make Function" radio button that is on by default. Click the connect button at the bottom of the window. Godot creates the method inside the GUI node. The script editor opens with the cursor inside a new _on_Player_health_changed function.

Примітка

When you connect nodes from the editor, Godot generates a method name with the following pattern: _on_EmitterName_signal_name. If you wrote the method already, the "Make Function" option will keep it. You may replace the name with anything you'd like.

../../_images/lifebar_tutorial_godot_generates_signal_callback.png

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

Всередині дужок після назви функції додайте аргумент player_health. Коли гравець випромінює сигнал health_changed, він надсилатиме разом із ним поточний health. Ваш код повинен виглядати так:

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

Примітка

Движок не перетворює PascalCase в snake_case, для прикладів C# ми будемо використовувати PascalCase для імен методів, і camelCase для параметрів методу, дотримуючись таким чином офіційних умов іменування в C#.

../../_images/lifebar_tutorial_player_gd_emits_health_changed_code.png

У Player.gd, коли Player випромінює сигнал health_changed, він також надсилає значення його здоров'я

Всередині _on_Player_health_changed викличемо другу функцію під назвою update_health і передамо їй змінну player_health.

Примітка

Ми могли б безпосередньо оновити значення здоров’я на LifeBar та Number. Існує дві причини використовувати цей метод:

  1. Назва дає зрозуміти нашим майбутнім колегам та товаришам по команді, що коли гравець отримав пошкодження, ми оновлюємо стан здоров'я на графічному інтерфейсі
  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 це вбудована функція, яка перетворює будь-яке значення в текст. Властивості text у Number``потрібний текст, тому ми не можемо призначити їй ``new_value безпосередньо

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

../../_images/lifebar_tutorial_LifeBar_health_update_no_anim.gif

І Number, і TextureProgress оновлюються коли Player отримує удар

Анімація втрати здоров'я за допомогою вузла Tween

Наш інтерфейс функціональний, але він може використовувати деяку анімацію. Це хороша можливість познайомитися з вузлом Tween, важливим інструментом для анімації властивостей. Tween анімує все, що завгодно, від початку до кінця, протягом певної тривалості. Наприклад, він може анімувати здоров'я TextureProgress від поточного до нового значення health пошкодженого Player.

Сцена GUI вже містить вузол Tween, який було збережено в змінній tween. Давайте зараз скористаємося цим. Ми повинні внести деякі зміни в update_health.

Ми будемо використовувати метод вузла Tween interpolate_property. Він бере сім аргументів:

  1. Посилання на вузол, властивість якого належить анімувати
  2. Ідентифікатор властивості в виді тексту
  3. Вихідне значення
  4. Кінцеве значення
  5. Тривалість анімації в секундах
  6. Тип переходу
  7. Послаблення для використання в поєднанні з рівнянням.

Останні два аргументи в поєднані являють собою спрощену рівність. Вони контролюють, як значення змінюється від початку до кінця.

Клацніть піктограму скрипту поруч із вузлом GUI, щоб відкрити його знову. Вузол Number потребує тексту, щоб оновитися, а Bar потребує десяткового, або цілого числа. Ми можемо використовувати interpolate_property для анімації числа, але не для тексту напряму. Ми будемо використовувати його для анімації нової змінної GUI під назвою animated_health.

У верхній частині скрипту визначте нову змінну, назвіть її animated_health та встановіть її значення на 0. Поверніться назад до методу update_health та очистіть його вміст. Давайте анімуємо значення animated_health. Викличте метод interpolate_property вузла Tween:

func update_health(new_value):
    tween.interpolate_property(self, "animated_health", animated_health, new_value, 0.6)
// 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 ...

Початковою точкою є поточне значення шкали. Нам ще належить кодувати цю частину, але це буде animated_health. Кінцевою точкою анімації є health Player (життя гравця) після health_changed (зміни_життя): це new_value. І 0.6 це тривалість анімації в секундах.

Анімація не буде відтворюватися, поки ми не активуємо вузол Tween``з допомогою ``tween.start(). Це потрібно зробити лише один раз, якщо вузол не активний. Додайте цей код після останнього рядка:

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

Примітка

Хоча ми могли б анімувати властивість health (здоров'я) на Player (гравцеві), ми не повинні цього робити. Персонажі повинні втрачати життя миттєво при попаданні під удар. Це набагато спрощує керування їх станами. Вам завжди краще зберігати анімацію в окремому контейнері даних, або вузлі. Вузол tween ідеально підходить для коду контролювання анімації. Для анімації ручної роботи перегляньте AnimationPlayer.

Призначення animated_health LifeBar

Тепер змінна animated_health анімована, але ми більше не оновлюємо вузли Bar та Number. Давайте виправимо це.

Поки що метод update_health виглядає приблизно так:

func update_health(new_value):
    tween.interpolate_property(self, "animated_health", animated_health, new_value, 0.6)
    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.

Запустіть гру, щоб бачити анімацію шкали. Але в тексті відображається десяткове число і це схоже на безлад. А зважаючи на стиль гри, було б непогано, щоб шкала здоров'я анімувалася поривчастим способом.

../../_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;
}

Спробуйте гру ще раз, щоб побачити приємну анімацію.

../../_images/lifebar_tutorial_number_animation_working.gif

Заокруглюючи animated_health ми вбиваємо двох зайців одним пострілом

Порада

Кожен раз, коли гравець приймає удар, GUI викликає _on_Player_health_changed, який в свою чергу викликає update_health. Це послідовно оновлює анімацію number_label та bar в _process. Анімація шкали життя, яка показує, що здоров'я поступово знижується, є хитрістю. Це оживляє GUI. Якщо Player отримує 3 пошкодження, гравець помирає, а шкала стає порожня.

Зникнення шкали при смерті гравця

Коли зелений персонаж вмирає, він відтворює анімацію смерті і згасає. На даний момент нам більше не треба показувати інтерфейс. Давайте, при смерті персонажа, шкалу також сховаємо. Ми повторно використаємо той самий вузол Tween, який паралельно управляє декількома анімаціями.

Спершу, GUI необхідно підключитися до сигналу Player died, щоб знати, коли він помер. Натисніть F1 щоб повернутися до робочої області 2D. Виберіть вузол Player на панелі Сцена та натисніть на вкладку Вузол біля Інспектора.

Знайдіть сигнал died, виберіть його та натисніть кнопку Приєднати.

../../_images/lifebar_tutorial_player_died_signal_enemy_connected.png

Сигнал вже має бути підключеним до Enemy

У вікні З'єднання сигналу знову підключіться до вузла GUI. В полі Метод-отримувач має бути _on_Player_died. Натисніть «З'єднати» внизу вікна. Це перенесе вас до файлу GUI.gd в робочій області скриптів.

../../_images/lifebar_tutorial_player_died_connecting_signal_window.png

Ви повинні отримати такі значення у вікні З'єднання сигналу

Примітка

Ви вже повинні простежити схему: щоразу, коли GUI потребує нової інформації, ми випромінюємо новий сигнал. Використовуйте їх розумно: чим більше ви додасте з'єднань, тим важче буде відстежувати їх.

Щоб анімувати зникнення елемента інтерфейсу, ми повинні використовувати його властивість modulate. modulate це Color, який множить кольори наших текстур.

Примітка

modulate походить від класу CanvasItem, від нього успадковуються всі 2D та UI-вузли. Він дозволяє перемикати видимість вузла, призначати йому шейдер і змінювати його за допомогою кольору modulate.

modulate приймає значення Color з 4-ох каналів: червоний, зелений, синій та альфа. Якщо затемнити будь-який з перших трьох каналів, він затемнить інтерфейс. Якщо ми знизимо альфа-канал, наш інтерфейс стане прозорим.

Ми збираємось анімувати значення кольорів: від білого з альфа 1, тобто з повною непрозорістю, до чисто білого із значенням альфа 0, повною прозорістю. Додамо дві змінні у верхній частині методу _on_Player_died та назвемо їх start_color та end_color. Використовуйте конструктор Color() для побудови двох значень Color.

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 і 0.0 в start_color і end_color, є альфа-канал.

Потім нам доведеться знову викликати метод interpolate_property вузла Tween:

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

Цього разу ми змінимо властивість modulate і попросимо її анімувати з start_color до end_color. Тривалість становить одну секунду, з лінійним переходом. Знову ж таки, оскільки перехід лінійний, плавність значення не має. Ось повний метод _on_Player_died:

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)
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

Кінцевий результат. Вітаємо з успіхом!

Примітка

Використовуючи ті самі методи, ви можете змінити колір шкали, коли гравець отруїться, перетворити шкалу на червону, коли його здоров'я знизиться, стрясти інтерфейс користувача, коли він отримує критичний удар ... принцип однаковий: випромінюйте сигнал, щоб переслати інформацію від гравця до графічного інтерфейсу та дозволити інтерфейсу обробити його.