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

Введение

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

../../_images/lifebar_tutorial_final_result.gif

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

Вы узнаете:

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

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

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

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

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

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

Примечание

Сигналы — это своя версия шаблона Observer в Godot. Они позволяют нам отправить некоторый сигнал. Другие узлы могут подключаться к объекту, который излучает сигнал и получать информацию об этом. Это мощный инструмент мы используем много для UI и системы достижений. Вы не обязаны использовать их повсюду. Соединение двух узлов добавляет некоторую связь между ними. Когда есть много связей, ими становятся трудно управлять. Для получения дополнительной информации посмотрите signals video tutorial on GDquest.

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

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

Загрузите проект start в Godot. В Файловой системе дважды щелкните на LevelMockup.tscn, чтобы открыть её. Это макет RPG игры, где 2 персонажа сталкиваются друг с другом. Розовый враг атакует и наносит урон зелёному квадрату через регулярные промежутки времени, вплоть до его смерти. Вы можете опробовать игру: основная боевая механика уже работает. Но так как персонаж не подключен к шкале жизни GUI никак не изменяется.

Примечание

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

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

../../_images/lifebar_tutorial_life_bar_step_tut_LevelMockup_scene_tree.png

Дерево сцены, с GUI которая отображает своих детей

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

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 компонент узла, который может анимировать и контролировать любое значение или метод из любого другого узла

Примечание

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

В корне проекта, в папке 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 раз в секунду, и вы, вероятно, испортите игру из-за порядка, в котором выполняется код.

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

Примечание

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

С учетом этого давайте подключим GUI к Player. Щелкните на узле Player в панели сцен, чтобы выбрать его. Направляйтесь а инспектор и нажмите на вкладку Узел. Здесь подключаются узлы для прослушивания.

В первом разделе перечислены пользовательские сигналы, определенные в 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 может написать новые функции обратного вызова для вас в подключенном скрипте
  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 напишет метод обратного вызова для вас и доставит вас к нему

В скобках после имени функции добавьте аргумент 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# naming conventions.

../../_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. Название дает понять, для нашего будущего себя и напарников, что когда игрок получил урон, мы обновляем счетчик здоровья на 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 – это встроенная функция, которая преобразует значение в текст. Свойство text``узла ``Number требует строку, поэтому мы не можем назначить его в new_value напрямую

Также вызов 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. Легкость использования в сочетании с уравнением.

Два последних аргумента в совокупности соответствуют уравнению плавности. Этот параметр определяет, как изменяется значение от начала до конца.

Нажмите на иконку скрипта рядом с узлом GUI, чтобы открыть его снова. Узлу Number нужен текст для обновления, а узлу Bar - дробное или целое число. Мы можем использовать interpolate_property для анимирования числа, но не для текста напрямую. Мы собираемся использовать его для анимирования новой переменной GUI под названием 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 ...

Отправной точкой является текущее значение шкалы. Мы все еще должны закодировать эту часть, но это будет animated_health. Конечной точкой анимации является health игрока после health_changed: это new_value. А 0.6 - это длительность анимации в секундах.

...  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();
}

Примечание

Хотя мы могли бы анимировать свойство health на Player’e, но не будем. Персонажи должны терять жизнь мгновенно, когда они попадают под удар. Это значительно облегчает управление их состоянием, например, знать, когда человек умер. Вы всегда должны хранить анимацию в отдельном контейнере или узле данных. Узел tween идеально подходит для анимации с кодовым управлением. Анимацию ручной работы можно посмотреть в разделе «Анимационный проигрыватель».

Присоединение 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.

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

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

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

../../_images/lifebar_tutorial_player_died_signal_enemy_connected.png

Сигнал должен быть уже подключен к Врагу.

В окне Connecting Signal (Подключение сигнала) снова подключитесь к узлу GUI. Путь к узлу должен быть ../../GUI, а метод в узле должен показывать _on_Player_died. Оставьте опцию Сделать Функцию включенной и нажмите Подключить в нижней части окна. После этого вы перейдете в GUI.gd во вкладке Script.

../../_images/lifebar_tutorial_player_died_connecting_signal_window.png

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

Примечание

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

Чтобы анимировать исчезновение на элементе UI, мы должны использовать его свойство modulate. modulate - это Color, который умножает цвета наших текстур.

Примечание

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

modulate принимает значение Color с 4 каналами: красный, зеленый, синий и альфа-канал. Если мы сделаем темнее любой из первых трех каналов, то интерфейс будет темнее. Если мы снизим альфа-канал, наш интерфейс станет прозрачным.

Давайте анимировать переход от белого цвета с альфа-каналом 1, то есть при полной непрозрачности, к чисто белому со значением альфа 0, то есть полностью прозрачному. Добавим две переменные вверху метода _on_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) соответствует белому цвету. Четвертый аргумент, соответственно 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);

На этот раз мы меняем свойство modulate и анимируем его из start_color на end_color`. Продолжительность составляет одну секунду, с линейным переходом. Здесь опять же, поскольку переход линейный, плавность не имеет значения. Вот полный метод _on_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, 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 обработать их.