用代码控制游戏的UI

简介

在本教程中,您将生命条关联到一个角色,并动画化生命值损失。

../../_images/lifebar_tutorial_final_result.gif

这就是您要创建的内容:角色受到攻击时,条形图和计数器会进行动画处理。它死后会褪色。

您将学习:

  • 如何用信号将一个角色 连接 到GUI
  • 如何用GDscript 控制 一个GUI
  • 如何用 Tween 节点 动画化 生命条

如果您想学习如何设置界面,请查看渐进式UI教程:

在编写游戏代码时,您要首先构建核心游戏玩法:主要机制、玩家输入、获胜和失败条件。UI要晚一些。如果可能的话,您希望将组成项目的所有元素分开。每个角色都应该在自己的场景中,有自己的脚本,UI元素也应该如此。这可以防止bug,保持项目的可管理性,并允许不同的团队成员在游戏的不同部分工作。

一旦核心游戏玩法和UI准备就绪,您就需要以某种方式连接它们。在我们的示例中,我们的敌人以固定的时间间隔攻击玩家。我们希望生命条在玩家受到伤害时更新。

为此,我们将使用 信号

注解

信号是Godot观察者模式的版本。他们允许我们发出一些信息。其他节点可以连接到 发出 信号的对象并接收信息。它是我们在用户界面和成就系统中大量使用的强大工具。但是,您不希望在任何地方都使用它们。连接两个节点增加了它们之间的耦合。当存在大量连接时,它们变得难以管理。有关更多信息,请查看GDquest上的 信号视频教程

下载并探索起始项目

下载Godot项目:ui_code_life_bar.zip。它包含起始所需的所有资源和脚本。解压缩.zip存档以获得两个文件夹:startend

加载Godot中的 start 项目。在 FileSystem 停靠面板中双击 LevelMockup.tscn 打开它。这是一个RPG游戏的模型,2个角色面对面。粉色的敌人定期攻击并破坏绿色的正方形,直到它死亡。请随意尝试游戏:基本的战斗机制已经工作。但由于角色没有连接到生命条,GUI 什么也做不了。

注解

这是编写游戏代码的典型方式:首先实现核心游戏玩法,处理玩家的死亡,然后才添加界面。这是因为UI会监听游戏中发生的事情。因此,如果其他系统还没有到位,它将无法正常工作。如果您在原型设计和测试游戏玩法之前设计UI,则可能会无法正常工作,并且您必须从头开始重新创建它。

该场景包含一个背景精灵、一个GUI和两个角色。

../../_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 是一个组件风格的节点,它可以动画和控制来自任何其他节点的任何值或方法

注解

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

在项目的根目录中的 res:// 文件夹中,您将找到 LevelMockup。那是主要的游戏场景,也是我们将要使用的场景。构成游戏的所有组件都在 scenes/ 文件夹中。assets/ 文件夹包含游戏精灵和HP计数器的字体。在 scripts/ 文件夹中,您会找到敌人、游戏角色和GUI控制器脚本。

点击场景树中节点右侧的编辑场景图标以在编辑器中打开场景。您会看到 LifeBarEnergyBar 本身就是子场景。

../../_images/lifebar_tutorial_Player_with_editable_children_on.png

场景树,设置 Player 场景来显示它的子节点

使用 Playermax_health 设置生命条

我们必须以某种方式告诉GUI:游戏角色当前的健康状况,更新生命条的纹理,并在屏幕左上角的HP计数器中显示剩余的健康状况。为此,我们会在每次受到伤害时将玩家的生命值发送到GUI。然后,GUI将使用此值更新 LifebarNumber 节点。

我们可以在此处停止显示数字,但是我们需要初始化条形图的 max_value,以便按正确的比例进行更新。因此,第一步是告诉 GUI 绿色角色的 max_health 是什么。

小技巧

默认情况下,条形图 TextureProgressmax_value 值为 100。如果您不需要用数字显示角色的健康状况,则无需更改其 max_value 属性。可以从 PlayerGUI 发送一个百分比,而不是 health / max_health * 100

../../_images/lifebar_tutorial_TextureProgress_default_max_value.png

在场景停靠面板中单击 GUI 右侧的脚本图标以打开其脚本。在 _ready 函数中,我们将把 Playermax_health 存储在一个新变量中,并使用它来设置 barmax_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;
}

让我们把它分解一下。$"../Characters/Player" 是场景树中一个节点的一个快捷方式,从那里取回 Characters/Player 节点。它允许我们访问节点。陈述的第二部分,.max_health,访问 Player 节点上的 max_health

第二行将该值赋给 bar.max_value。您可以将这两行合并为一行,但是在本教程中,稍后我们需要再次使用 player_max_health

Player.gd 在游戏开始时设置 healthmax_health,所以我们可以使用它。为什么我们仍然使用 max_health?有两个原因:

我们不能保证 health 总是等于 max_health:未来版本的游戏可能会加载一个已经失去部分健康值的游戏角色的关卡。

注解

当您在游戏中打开场景时,Godot会按照场景停靠面板中的顺序,从上到下逐一创建节点。GUIPlayer 不是同一个节点分支的一部分。为了确保当彼此访问时它们都存在,我们必须使用 _ready 函数。Godot在加载所有节点之后,在游戏开始之前,立即调用 _ready。这是设置所有内容并准备游戏会话的完美功能。进一步了解 _ready编写脚本(续)

player 受到攻击时,用信号更新生命值

我们的GUI已经准备好接收来自 Playerhealth 值更新。为了实现这一点,我们将使用 信号

注解

有许多有用的内置信号,如 enter_treeexit_tree,当所有节点分别在被创建和销毁时,会发出这些信号。您也可以使用 signal 关键字创建您自己的信号。在 Player 节点上,您会发现我们为您创建的两个信号: diedhealth_changed

我们为什么不直接在 _process 函数中获取 Player 节点并查看健康值呢?以这种方式访问节点会在节点之间产生紧密耦合。如果您少做一点,它可能会起作用。当您的游戏越来越大,您可能会有更多的连接。如果以这种方式获取节点,它将很快变得复杂。不仅如此:您还需要在 _process 函数中不断监听状态变化。此检查每秒发生60次,由于代码的运行顺序,您可能会中断游戏。

在给定的帧上,您可能会在更新 之前 查看另一个节点的属性:您从上一帧获得了一个值。这会导致难以修复的模糊bug。另一方面,一个信号在发生变化后立即发出。它 保证 您得到了一个新的信息。发生变化后,您将在 之后立即 更新连接节点的状态。

注解

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.

考虑到这一点,让我们将 GUI 连接到 Player。点击场景停靠面板中的 Player 节点来选择它。转到属性检查器面板,点击 Node 选项卡。这是连接节点以监听您选择的节点的地方。

第一部分列出了在 Player.gd 中定义的自定义信号:

  • 角色死亡时会发出 died 信号。稍后我们将使用它来隐藏UI。
  • 当角色被击中时会发出 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. 在场景停靠面板中,一个发射器图标出现发出信号的节点旁边

在窗口的底部,您将找到所选节点的路径。我们对第二行“节点中的方法”感兴趣。这是发出信号时在 GUI 节点上调用的方法。该方法接收与信号一起发送的值,并让您对其进行处理。如果您向右看,默认情况下有一个 创建函数 单选按钮。点击窗口底部的连接按钮。Godot在 GUI 节点中创建该方法。脚本编辑器打开并将光标放在一个新的 _on_Player_health_changed 函数中。

注解

当您从编辑器连接节点时,Godot会使用以下模式生成方法名称: _on_EmitterName_signal_name。如果您已经编写了该方法,创建函数 选项将保留它。您可以将名称替换为任意名称。

../../_images/lifebar_tutorial_godot_generates_signal_callback.png

Godot为您编写回调方法并将您带到该方法中

在函数名后面的参数中,添加一个 player_health 参数。当 Player 发出 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 信号时,它也会发送其 health

_on_Player_health_changed 中,我们调用第二个名为 update_health 的函数,并将 player_health 变量传递给它。

注解

我们可以直接在 LifeBarNumber 上更新健康值。改为使用此方法的原因有两个:

  1. 这个名称可以让未来的自己和队友清楚地知道,当 Player 受到伤害时,我们会在GUI上更新生命值
  2. 我们稍后会重用此方法

_on_Player_health_changed 下创建一个新的 update_health 方法。它以 new_value 作为唯一参数:

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

该方法需要:

  • Number 节点的 text 设置为 new_value 转换的字符串
  • TextureProgressvalue 设置为 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 是一个内置函数,可将任何值转换为文本。Numbertext 属性需要一个字符串,因此我们不能将其直接指定为 new_value

Also call update_health at the end of the _ready function to initialize the Number node's text with the right value at the start of the game. Press F5 to test the game: the life bar updates with every attack!

../../_images/lifebar_tutorial_LifeBar_health_update_no_anim.gif

Player 受到攻击时,Number 节点和 TextureProgress 都会更新

使用 Tween 节点动画化生命损失

我们的界面可以使用,但是可以使用一些动画。这是介绍 Tween 节点的绝佳机会,该节点是动画化属性的基本工具。Tween 可以在从开始到结束一定的持续时间内,动画化你想要的任何东西。例如,当角色受到伤害时,它可以将 TextureProgress 上的生命值从当前级别的值动画化为 Player 的新的 health 值。

GUI 场景已经包含存储在 tween 变量中的 Tween 子节点。现在使用它。我们必须对 update_health 进行一些更改。

我们将使用 Tween 节点 interpolate_property 方法。它接受七个参数:

  1. 节点的引用,该节点拥有要动画化的属性
  2. 作为字符串的属性的标识符
  3. 起始值
  4. 结束值
  5. 动画的持续时间(以秒为单位)
  6. 过渡的类型
  7. 与方程结合使用的缓动方式。

最后两个参数组合起来对应一个缓动方程。这可以控制值从起点到终点的演变方式。

点击 GUI 节点旁边的脚本图标以将其再次打开。Number 节点需要文本来更新自身,Bar 则需要浮点数或整数。我们可以使用 interpolate_property 来动画化一个数字,但不能直接动画化文本。我们将使用它动画化一个名为 animated_health 的新 GUI 变量。

在脚本顶部,定义一个新变量,将其命名为 animated_health,并将其值设置为0。导航回到 update_health 方法并清除其内容。让我们动画化 animated_health 值。调用 Tween 节点的 interpolate_property 方法:

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", ...

我们将以 self,即 GUI 节点上的 animated_health 为目标。Tweeninterpolate_property 将以字符串接受属性名称。这是我们把它写成 "animated_health" 的原因。

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

起点是该条形图的当前值。我们仍然需要对这部分进行编码,但它将是 animated_health。动画的结束点是在 health_changed 之后的 Playerhealth:即 new_value0.6 是动画的持续时间,以秒为单位。

直到我们通过 tween.start() 激活了 Tween 节点后,动画才会播放。如果节点未被激活,则只需执行一次。在最后一行之后添加以下代码:

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

注解

尽管我们可以在 Player 上动画化 health 属性,但我们不应该这样做。角色受到打击时应该立即失去生命值。这使得管理他们的状态变得容易得多,比如知道他们何时死亡。您总是希望将动画存储在单独的数据容器或节点中。Tween 节点非常适合代码控制的动画。对于手工制作的动画,请查看 AnimationPlayer

animated_health 分配给 LifeBar

现在,animated_health 变量动画化,但是我们不再更新实际的 BarNumber 节点。让我们解决这个问题。

到目前为止,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 方法来动画化它。现在,像之前一样,在 _process 里面更新 NumberTextureProgress 节点:

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_labelbar 是变量,用于存储对 NumberTextureProgress 节点的引用。

玩这个游戏,可以看到条形图的动画是平滑的。但是文本显示的是十进制数字,看起来很乱。考虑到游戏的风格,生命条采用波动的动画效果会很不错。

../../_images/lifebar_tutorial_number_animation_messed_up.gif

动画很流畅,但是数字坏了

我们可以通过舍入 animated_health 来解决这两个问题。使用一个名为 round_value 的局部变量来存储四舍五入的 animated_health。然后将其赋值给 number_label.textbar.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,我们达到了一石二鸟的效果

小技巧

每当 Player 受到攻击时, GUI 就会调用 _on_Player_health_changed ,后者调用 update_health。这将更新动画以及 _process 后跟随的 number_labelbar。动画化的生命条显示健康值逐渐下降,这是一个技巧。它使GUI感觉更加生动。如果 Player 受到3点伤害,就会在瞬间发生。

Player 死亡时,淡出条形图

当绿色角色死亡时,它会播放死亡动画并淡出。此时,我们不应该再显示界面。当角色死亡时,让我们也淡出条形图。我们将重用相同的 Tween 节点,因为它为我们并行管理多个动画。

首先,GUI 需要连接到 Playerdied 信号,以知道它何时死亡。按 F1 跳转回2D工作区。在场景停靠面板中选择 Player 节点,然后单击属性检查器旁边的节点选项卡。

找到 died 信号,选择它,然后点击连接按钮。

../../_images/lifebar_tutorial_player_died_signal_enemy_connected.png

这个信号应该有 Enemy 已经连上了它

在连接信号窗口中,再次连接到 GUI 节点。节点的路径应该是 ../../GUI 并且节点中的方法应该显示 _on_Player_died。保留创建函数选项启用并点击窗口底部的连接。这将把您带到脚本工作区中的 GUI.gd 文件中。

../../_images/lifebar_tutorial_player_died_connecting_signal_window.png

您应该在连接信号窗口中获得这些值

注解

您现在应该可以看到一种模式:每当GUI需要新信息时,我们都会发出新信号。明智地使用它们:添加的连接越多,跟踪起来就越困难。

要动画化UI元素上的淡入淡出,我们必须使用其 modulate 属性。modulate 是一种将我们的纹理颜色相乘的 Color

注解

modulate 来自 CanvasItem 类,所有2D和UI节点都继承自 CanvasItem。它允许您切换节点的可见性,给它分配一个着色器,然后用一个带有 modulate 的颜色来修改它。

modulate 具有 Color 值,带有4个通道:红色、绿色、蓝色和alpha。如果我们使前三个通道中的任何一个变暗,则都会使界面变暗。如果降低Alpha通道,则界面会淡出。

我们将在两个颜色值之间进行渐变:从alpha值为 1 的白色,即完全不透明,到alpha值为 0 的纯白色,完全透明。让我们在 _on_Player_died 方法的顶部添加两个变量,并将它们命名为 start_colorend_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) 对应于白色。第四个参数是alpha通道,分别是 start_colorend_color 中的 1.00.0

然后,我们必须再次调用 Tween 节点的 interpolate_property 方法:

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

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'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)
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 中毒时,您可以改变条状图的颜色,当生命值降低时将条状图变成红色,当他们受到暴击时晃动UI……原理是一样的:发出信号将信息从 Player 转发到 GUI,然后让 GUI 对其进行处理。