用代码控制游戏的UI
简介
在本教程中,您将生命条关联到一个角色,并动画化生命值损失。
这就是您要创建的内容:角色受到攻击时,条形图和计数器会进行动画处理。它死后会褪色。
您将学习:
- 如何用信号将一个角色 连接 到GUI
- 如何用GDscript 控制 一个GUI
- 如何用 Tween 节点 动画化 生命条
如果您想学习如何设置界面,请查看渐进式UI教程:
在编写游戏代码时,您要首先构建核心游戏玩法:主要机制、玩家输入、获胜和失败条件。UI要晚一些。如果可能的话,您希望将组成项目的所有元素分开。每个角色都应该在自己的场景中,有自己的脚本,UI元素也应该如此。这可以防止bug,保持项目的可管理性,并允许不同的团队成员在游戏的不同部分工作。
一旦核心游戏玩法和UI准备就绪,您就需要以某种方式连接它们。在我们的示例中,我们的敌人以固定的时间间隔攻击玩家。我们希望生命条在玩家受到伤害时更新。
为此,我们将使用 信号 。
注解
信号是Godot观察者模式的版本。他们允许我们发出一些信息。其他节点可以连接到 发出 信号的对象并接收信息。它是我们在用户界面和成就系统中大量使用的强大工具。但是,您不希望在任何地方都使用它们。连接两个节点增加了它们之间的耦合。当存在大量连接时,它们变得难以管理。有关更多信息,请查看GDquest上的 信号视频教程。
下载并探索起始项目
下载Godot项目:ui_code_life_bar.zip
。它包含起始所需的所有资源和脚本。解压缩.zip存档以获得两个文件夹:start
和 end
。
加载Godot中的 start
项目。在 FileSystem
停靠面板中双击 LevelMockup.tscn
打开它。这是一个RPG游戏的模型,2个角色面对面。粉色的敌人定期攻击并破坏绿色的正方形,直到它死亡。请随意尝试游戏:基本的战斗机制已经工作。但由于角色没有连接到生命条,GUI
什么也做不了。
注解
这是编写游戏代码的典型方式:首先实现核心游戏玩法,处理玩家的死亡,然后才添加界面。这是因为UI会监听游戏中发生的事情。因此,如果其他系统还没有到位,它将无法正常工作。如果您在原型设计和测试游戏玩法之前设计UI,则可能会无法正常工作,并且您必须从头开始重新创建它。
该场景包含一个背景精灵、一个GUI和两个角色。
场景树,其中GUI场景设置为显示其子级
GUI场景封装了游戏的所有图形用户界面。它带有一个准系统脚本,在该脚本中,我们获得了场景中存在的节点的路径:
GDScript
C#
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控制器脚本。
点击场景树中节点右侧的编辑场景图标以在编辑器中打开场景。您会看到 LifeBar
和 EnergyBar
本身就是子场景。
场景树,设置 Player
场景来显示它的子节点
使用 Player
的 max_health
设置生命条
我们必须以某种方式告诉GUI:游戏角色当前的健康状况,更新生命条的纹理,并在屏幕左上角的HP计数器中显示剩余的健康状况。为此,我们会在每次受到伤害时将玩家的生命值发送到GUI。然后,GUI将使用此值更新 Lifebar
和 Number
节点。
我们可以在此处停止显示数字,但是我们需要初始化条形图的 max_value
,以便按正确的比例进行更新。因此,第一步是告诉 GUI
绿色角色的 max_health
是什么。
小技巧
默认情况下,条形图 TextureProgress
的 max_value
值为 100
。如果您不需要用数字显示角色的健康状况,则无需更改其 max_value
属性。可以从 Player
向 GUI
发送一个百分比,而不是 health / max_health * 100
。
在场景停靠面板中单击 GUI
右侧的脚本图标以打开其脚本。在 _ready
函数中,我们将把 Player
的 max_health
存储在一个新变量中,并使用它来设置 bar
的 max_value
:
GDScript
C#
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
在游戏开始时设置 health
为 max_health
,所以我们可以使用它。为什么我们仍然使用 max_health
?有两个原因:
我们不能保证 health
总是等于 max_health
:未来版本的游戏可能会加载一个已经失去部分健康值的游戏角色的关卡。
注解
当您在游戏中打开场景时,Godot会按照场景停靠面板中的顺序,从上到下逐一创建节点。GUI
和 Player
不是同一个节点分支的一部分。为了确保当彼此访问时它们都存在,我们必须使用 _ready
函数。Godot在加载所有节点之后,在游戏开始之前,立即调用 _ready
。这是设置所有内容并准备游戏会话的完美功能。进一步了解 _ready
: 编写脚本(续)
当 player
受到攻击时,用信号更新生命值
我们的GUI已经准备好接收来自 Player
的 health
值更新。为了实现这一点,我们将使用 信号 。
注解
有许多有用的内置信号,如 enter_tree
和 exit_tree
,当所有节点分别在被创建和销毁时,会发出这些信号。您也可以使用 signal
关键字创建您自己的信号。在 Player
节点上,您会发现我们为您创建的两个信号: died
和 health_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
信号。
我们正连接到 health_changed
信号
选择 health_changed
并点击右下角的连接按钮以打开连接信号窗口。在左侧,您可以选择监听此信号的节点。选择 GUI
节点。屏幕的右侧允许您将可选值与信号打包。我们已经在 Player.gd
中处理过了。通常,建议不要在此窗口中添加过多的参数,因为它们不如从代码中添加方便。
选择了GUI节点的连接信号窗口
小技巧
您可以选择从代码中连接节点。从编辑器执行此操作有两个优点:
- 在连接的脚本中,Godot可以为您编写新的回调函数
- 在场景停靠面板中,一个发射器图标出现发出信号的节点旁边
在窗口的底部,您将找到所选节点的路径。我们对第二行“节点中的方法”感兴趣。这是发出信号时在 GUI
节点上调用的方法。该方法接收与信号一起发送的值,并让您对其进行处理。如果您向右看,默认情况下有一个 创建函数
单选按钮。点击窗口底部的连接按钮。Godot在 GUI
节点中创建该方法。脚本编辑器打开并将光标放在一个新的 _on_Player_health_changed
函数中。
注解
当您从编辑器连接节点时,Godot会使用以下模式生成方法名称: _on_EmitterName_signal_name
。如果您已经编写了该方法,创建函数
选项将保留它。您可以将名称替换为任意名称。
Godot为您编写回调方法并将您带到该方法中
在函数名后面的参数中,添加一个 player_health
参数。当 Player
发出 health_changed
信号时,它将在其旁边发送其当前的 health
。您的代码应该如下所示:
GDScript
C#
func _on_Player_health_changed(player_health):
pass
public void OnPlayerHealthChanged(int playerHealth)
{
}
注解
引擎不会将PascalCase转换成snake_case,对于C#示例,我们将使用PascalCase来表示方法名,而camelCase用于方法参数,这样遵循 C#命名约定
在 Player.gd
中。当 Player
发出 health_changed
信号时,它也会发送其 health
值
在 _on_Player_health_changed
中,我们调用第二个名为 update_health
的函数,并将 player_health
变量传递给它。
注解
我们可以直接在 LifeBar
和 Number
上更新健康值。改为使用此方法的原因有两个:
- 这个名称可以让未来的自己和队友清楚地知道,当
Player
受到伤害时,我们会在GUI上更新生命值 - 我们稍后会重用此方法
在 _on_Player_health_changed
下创建一个新的 update_health
方法。它以 new_value
作为唯一参数:
GDScript
C#
func update_health(new_value):
pass
public void UpdateHealth(int health)
{
}
该方法需要:
- 将
Number
节点的text
设置为new_value
转换的字符串 - 将
TextureProgress
的value
设置为new_value
GDScript
C#
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
是一个内置函数,可将任何值转换为文本。Number
的 text
属性需要一个字符串,因此我们不能将其直接指定为 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!
当 Player
受到攻击时,Number
节点和 TextureProgress
都会更新
使用 Tween
节点动画化生命损失
我们的界面可以使用,但是可以使用一些动画。这是介绍 Tween
节点的绝佳机会,该节点是动画化属性的基本工具。Tween
可以在从开始到结束一定的持续时间内,动画化你想要的任何东西。例如,当角色受到伤害时,它可以将 TextureProgress
上的生命值从当前级别的值动画化为 Player
的新的 health
值。
GUI
场景已经包含存储在 tween
变量中的 Tween
子节点。现在使用它。我们必须对 update_health
进行一些更改。
我们将使用 Tween
节点 interpolate_property
方法。它接受七个参数:
- 节点的引用,该节点拥有要动画化的属性
- 作为字符串的属性的标识符
- 起始值
- 结束值
- 动画的持续时间(以秒为单位)
- 过渡的类型
- 与方程结合使用的缓动方式。
最后两个参数组合起来对应一个缓动方程。这可以控制值从起点到终点的演变方式。
点击 GUI
节点旁边的脚本图标以将其再次打开。Number
节点需要文本来更新自身,Bar
则需要浮点数或整数。我们可以使用 interpolate_property
来动画化一个数字,但不能直接动画化文本。我们将使用它动画化一个名为 animated_health
的新 GUI
变量。
在脚本顶部,定义一个新变量,将其命名为 animated_health
,并将其值设置为0。导航回到 update_health
方法并清除其内容。让我们动画化 animated_health
值。调用 Tween
节点的 interpolate_property
方法:
GDScript
C#
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
为目标。Tween
的 interpolate_property
将以字符串接受属性名称。这是我们把它写成 "animated_health"
的原因。
... _health", animated_health, new_value, 0.6 ...
起点是该条形图的当前值。我们仍然需要对这部分进行编码,但它将是 animated_health
。动画的结束点是在 health_changed
之后的 Player
的 health
:即 new_value
。0.6
是动画的持续时间,以秒为单位。
直到我们通过 tween.start()
激活了 Tween
节点后,动画才会播放。如果节点未被激活,则只需执行一次。在最后一行之后添加以下代码:
GDScript
C#
if not tween.is_active():
tween.start()
if (!_tween.IsActive())
{
_tween.Start();
}
注解
尽管我们可以在 Player
上动画化 health
属性,但我们不应该这样做。角色受到打击时应该立即失去生命值。这使得管理他们的状态变得容易得多,比如知道他们何时死亡。您总是希望将动画存储在单独的数据容器或节点中。Tween
节点非常适合代码控制的动画。对于手工制作的动画,请查看 AnimationPlayer
。
将 animated_health
分配给 LifeBar
现在,animated_health
变量动画化,但是我们不再更新实际的 Bar
和 Number
节点。让我们解决这个问题。
到目前为止,update_health
方法看起来像这样:
GDScript
C#
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
里面更新 Number
和 TextureProgress
节点:
GDScript
C#
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
节点的引用。
玩这个游戏,可以看到条形图的动画是平滑的。但是文本显示的是十进制数字,看起来很乱。考虑到游戏的风格,生命条采用波动的动画效果会很不错。
动画很流畅,但是数字坏了
我们可以通过舍入 animated_health
来解决这两个问题。使用一个名为 round_value
的局部变量来存储四舍五入的 animated_health
。然后将其赋值给 number_label.text
和 bar.value
:
GDScript
C#
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;
}
再次尝试游戏,以查看漂亮的方块动画。
通过舍入 animated_health
,我们达到了一石二鸟的效果
小技巧
每当 Player
受到攻击时, GUI
就会调用 _on_Player_health_changed
,后者调用 update_health
。这将更新动画以及 _process
后跟随的 number_label
和 bar
。动画化的生命条显示健康值逐渐下降,这是一个技巧。它使GUI感觉更加生动。如果 Player
受到3点伤害,就会在瞬间发生。
当 Player
死亡时,淡出条形图
当绿色角色死亡时,它会播放死亡动画并淡出。此时,我们不应该再显示界面。当角色死亡时,让我们也淡出条形图。我们将重用相同的 Tween
节点,因为它为我们并行管理多个动画。
首先,GUI
需要连接到 Player
的 died
信号,以知道它何时死亡。按 F1 跳转回2D工作区。在场景停靠面板中选择 Player
节点,然后单击属性检查器旁边的节点选项卡。
找到 died
信号,选择它,然后点击连接按钮。
这个信号应该有 Enemy
已经连上了它
在连接信号窗口中,再次连接到 GUI
节点。节点的路径应该是 ../../GUI
并且节点中的方法应该显示 _on_Player_died
。保留创建函数选项启用并点击窗口底部的连接。这将把您带到脚本工作区中的 GUI.gd
文件中。
您应该在连接信号窗口中获得这些值
注解
您现在应该可以看到一种模式:每当GUI需要新信息时,我们都会发出新信号。明智地使用它们:添加的连接越多,跟踪起来就越困难。
要动画化UI元素上的淡入淡出,我们必须使用其 modulate
属性。modulate
是一种将我们的纹理颜色相乘的 Color
。
注解
modulate
来自 CanvasItem
类,所有2D和UI节点都继承自 CanvasItem
。它允许您切换节点的可见性,给它分配一个着色器,然后用一个带有 modulate
的颜色来修改它。
modulate
具有 Color
值,带有4个通道:红色、绿色、蓝色和alpha。如果我们使前三个通道中的任何一个变暗,则都会使界面变暗。如果降低Alpha通道,则界面会淡出。
我们将在两个颜色值之间进行渐变:从alpha值为 1
的白色,即完全不透明,到alpha值为 0
的纯白色,完全透明。让我们在 _on_Player_died
方法的顶部添加两个变量,并将它们命名为 start_color
和 end_color
。使用 Color()
构造函数构建两个 Color
值。
GDScript
C#
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_color
和 end_color
中的 1.0
和 0.0
。
然后,我们必须再次调用 Tween
节点的 interpolate_property
方法:
GDScript
C#
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:
GDScript
C#
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);
}
就是这样。您现在可以玩游戏以查看最终结果!
最终结果。恭喜您做到了!
注解
使用完全相同的技术,当 Player
中毒时,您可以改变条状图的颜色,当生命值降低时将条状图变成红色,当他们受到暴击时晃动UI……原理是一样的:发出信号将信息从 Player
转发到 GUI
,然后让 GUI
对其进行处理。