使用信号
在本课中,我们将介绍信号。它们是节点在发生特定事件时发出的消息,例如按下按钮。其他节点可以连接到该信号,并在事件发生时调用函数。
信号是 Godot 内置的委派机制,允许一个游戏对象对另一个游戏对象的变化做出反应,而无需相互引用。使用信号可以限制耦合,并保持代码的灵活性。
例如,你可能在屏幕上有一个代表玩家生命值的生命条。当玩家受到伤害或使用治疗药水时,你希望生命条反映变化。要做到这一点,在 Godot 中,你会使用到信号。
从 Godot 4.0 开始,信号和方法(Callable)一样,都成为了一等类型。这意味着你可以直接把信号当作方法的参数使用,无需以字符串的形式传参,这样能够更好地实现自动补全、更不容易出错。使用 Signal 类型能够直接实现的功能见 Signal 类参考手册。
参见
正如引言中提到的,信号是 Godot 版本的观察者模式。你可以在 Game Programming Patterns 了解更多相关信息。
现在,我们将使用信号来使上一节课(监听玩家的输入)中的 Godot 图标移动,并通过按下按钮来停止。
备注
对于此项目,我们将遵循 Godot 的命名约定。
GDScript:类(节点)使用 PascalCase(大驼峰命名法),变量和函数使用 snake_case(蛇形命名法),常量使用 ALL_CAPS(全大写)(请参阅 GDScript 编写风格指南)。
C#:类、导出变量和方法使用 PascalCase(大驼峰命名法),私有字段使用 _camelCase(前缀下划线的小驼峰命名法),局部变量和参数使用 camelCase(小驼峰命名法)(请参阅 C# 风格指南)。连接信号时,请务必准确键入方法名称。
场景设置
要为我们的游戏添加按钮,我们需要新建一个场景,包含一个按钮以及之前课程 创建第一个脚本 编写的 sprite_2d.tscn 场景。
主菜单选择 ,创建新场景。
在场景面板中,单击 按钮,即可添加一个 Node2D 作为场景根节点。
在文件系统面板中,单击之前保存的 sprite_2d.tscn 文件并将其拖动到 Node2D 上,对其进行实例化。
我们希望添加另一个节点作为 Sprite2D 的同级节点。为此,请右键点击 Node2D,然后选择 。
寻找并添加 Button 节点。
该节点默认比较小。在视口中,点击并拖拽该按钮右下角的手柄来调整大小。
如果看不到手柄,请确保工具栏中的选择工具处于活动状态。
点击并拖拽按钮使其更接近精灵。
你也可以在 Inspector 中编辑 Button 的 Text 属性来显示文字。请输入 Toggle motion。
你的场景树和视口应该是类似这样的。
如果你还没保存场景的话,保存新建的场景为 node_2d.tscn。然后你就可以使用 F6`(macOS 则为 :kbd:`Cmd + R)来运行。此时,你可以看到按钮,但是按下之后不会有任何反应。
在编辑器中连接信号
然后,我们希望将按钮的“pressed”信号连接到我们的 Sprite2D,并且我们想要调用一个新函数来打开和关闭其运动。我们需要像我们在上一课中所做的操作一样,将一个脚本附加到 Sprite2D 节点。
您可以在 Signals 面板中连接信号。选中 Button 节点,然后在编辑器右侧点击紧邻 Inspector 旁边的 Signals 。
停靠栏显示所选节点上可用的信号列表。
双击“pressed”信号,打开节点连接窗口。
然后,你可以将信号连接到 Sprite2D 节点。该节点需要一个用于接收按钮信号的函数,当按钮发出信号时,Godot 将调用该函数。编辑器会为你生成一个。按照规范,我们将这些回调方法命名为"_on_node_name_signal_name"。在这里,它被命名为"_on_button_pressed"。
备注
通过编辑器的节点面板连接信号时,可以使用两种模式。简单的一个只允许你连接到附加了脚本的节点,并在它们上面创建一个新的回调函数。
高级视图允许您连接到任意节点和内置函数,为回调添加参数并设置选项。您可以通过点击窗口右下角的 按钮来切换此模式。
备注
如果你在使用一个外部代码编辑器(例如VS Code),可能会没有自动代码生成。在这种情况下,你需要按照下一部分阐述的方法使用信号连接代码。
点击 按钮以完成信号连接,并跳转至 Script 工作区。您应在左侧边栏看到带有连接图标的新增方法。
如果单击该图标,将弹出一个窗口并显示有关连接的信息。此功能仅在编辑器中连接节点时可用。
让我们用代码替换带有 pass 关键字的一行,以切换节点的运动。
我们的 Sprite2D 由于 _process() 函数中的代码而移动。Godot 提供了一种打开和关闭处理的方法:Node.set_process() 。Node 的另一个方法 is_processing() ,如果空闲处理处于活动状态,则返回 true。我们可以使用 not 关键字来反转该值。
func _on_button_pressed():
set_process(not is_processing())
// We also specified this function name in PascalCase in the editor's connection window.
private void OnButtonPressed()
{
SetProcess(!IsProcessing());
}
此函数将切换处理,进而切换按下按钮时图标的移动。
在尝试游戏之前,我们需要简化 _process() 函数,以自动移动节点,而不是等待用户输入。将其替换为以下代码,这是我们在两课前看到的代码:
func _process(delta):
rotation += angular_speed * delta
var velocity = Vector2.UP.rotated(rotation) * speed
position += velocity * delta
public override void _Process(double delta)
{
Rotation += _angularSpeed * (float)delta;
var velocity = Vector2.Up.Rotated(Rotation) * _speed;
Position += velocity * (float)delta;
}
你的完整的 Sprite_2d.gd 代码应该是类似下面这样的。
extends Sprite2D
var speed = 400
var angular_speed = PI
func _process(delta):
rotation += angular_speed * delta
var velocity = Vector2.UP.rotated(rotation) * speed
position += velocity * delta
func _on_button_pressed():
set_process(not is_processing())
using Godot;
public partial class MySprite2D : Sprite2D
{
private float _speed = 400;
private float _angularSpeed = Mathf.Pi;
public override void _Process(double delta)
{
Rotation += _angularSpeed * (float)delta;
var velocity = Vector2.Up.Rotated(Rotation) * _speed;
Position += velocity * (float)delta;
}
// We also specified this function name in PascalCase in the editor's connection window.
private void OnButtonPressed()
{
SetProcess(!IsProcessing());
}
}
按下 F6 键运行当前场景(macOS为 Cmd + R ),然后点击按钮,就可以看到精灵开始或停止运动。
用代码连接信号
你可以通过代码连接信号,而不是使用编辑器。这在脚本中创建节点或实例化场景时是必需的。
让我们在这里使用一个不同的节点。Godot 有一个 Timer 节点,可用于实现技能冷却时间、武器重装等。
回到 2D 工作区。你可以点击窗口顶部的“2D”字样,或者按 Ctrl + F1(macOS 上则是 Ctrl + Cmd + 1)。
在“场景”面板中,右键点击 Sprite2D 节点并添加新的子节点。搜索 Timer 并添加对应节点。你的场景现在应该类似这样。
选中 Timer 节点后,前往 Inspector,并启用 Autostart 属性。
点击 Sprite2D 旁边的脚本图标,即可跳转回脚本工作区。
我们需要执行两个操作,通过代码将节点连接起来:
从 Sprite2D 获取对 Timer 的引用。
在 Timer 的 "timeout" 信号上调用
connect()方法。
备注
要通过代码连接信号,需要调用您希望监听的信号的 connect() 方法。在此例中,我们希望监听 Timer 的 "timeout" 信号。
我们想要在场景实例化时连接信号,我们可以使用 Node._ready() 内置函数来实现这一点,当节点完全实例化时,引擎会自动调用该函数。
为了获取相对于当前节点的引用,我们使用方法 Node.get_node()。我们可以将引用存储在变量中。
func _ready():
var timer = get_node("Timer")
public override void _Ready()
{
var timer = GetNode<Timer>("Timer");
}
get_node() 函数会查看 Sprite2D 的子节点,并按节点的名称获取节点。例如,如果在编辑器中将 Timer 节点重命名为“BlinkingTimer”,则必须将调用更改为 get_node("BlinkingTimer")。
现在,我们可以在 _ready() 函数中将Timer连接到Sprite2D。
func _ready():
var timer = get_node("Timer")
timer.timeout.connect(_on_timer_timeout)
public override void _Ready()
{
var timer = GetNode<Timer>("Timer");
timer.Timeout += OnTimerTimeout;
}
该行读起来是这样的:我们将计时器的“timeout”信号连接到脚本附加到的节点上。当计时器发出 timeout 时,去调用我们需要定义的函数 _on_timer_timeout()。让我们将其定义添加到脚本的底部,并使用它来切换精灵的可见性。
备注
按照惯例,我们将这些回调方法在 GDScript 中命名为“_on_node_name_signal_name”,在 C# 中命名为“OnNodeNameSignalName”。故此处的GDScript 为“_on_timer_timeout”,C# 为“OnTimerTimeout()”。
func _on_timer_timeout():
visible = not visible
private void OnTimerTimeout()
{
Visible = !Visible;
}
visible 属性是一个布尔值,用于控制节点的可见性。visible = not visible 行切换该值。如果 visible 是 true,它就会变成 false,反之亦然。
如果你现在运行 Node2D 场景,就会看到精灵在闪啊闪的,间隔为一秒。
完整脚本
这就是我们小小的 Godot 图标移动闪烁演示了!这是完整的 sprite_2d.gd 文件,仅供参考。
extends Sprite2D
var speed = 400
var angular_speed = PI
func _ready():
var timer = get_node("Timer")
timer.timeout.connect(_on_timer_timeout)
func _process(delta):
rotation += angular_speed * delta
var velocity = Vector2.UP.rotated(rotation) * speed
position += velocity * delta
func _on_button_pressed():
set_process(not is_processing())
func _on_timer_timeout():
visible = not visible
using Godot;
public partial class MySprite2D : Sprite2D
{
private float _speed = 400;
private float _angularSpeed = Mathf.Pi;
public override void _Ready()
{
var timer = GetNode<Timer>("Timer");
timer.Timeout += OnTimerTimeout;
}
public override void _Process(double delta)
{
Rotation += _angularSpeed * (float)delta;
var velocity = Vector2.Up.Rotated(Rotation) * _speed;
Position += velocity * (float)delta;
}
// We also specified this function name in PascalCase in the editor's connection window.
private void OnButtonPressed()
{
SetProcess(!IsProcessing());
}
private void OnTimerTimeout()
{
Visible = !Visible;
}
}
自定义信号
备注
本节介绍的是如何定义并使用你自己的信号,不依赖之前课程所创建的项目。
您可以在脚本中定义自定义信号。例如,假设您希望在玩家的生命值为零时通过屏幕显示游戏结束。为此,当他们的生命值达到 0 时,您可以定义一个名为“died”或“health_depleted”的信号。
extends Node2D
signal health_depleted
var health = 10
using Godot;
public partial class MyNode2D : Node2D
{
[Signal]
public delegate void HealthDepletedEventHandler();
private int _health = 10;
}
备注
由于信号表示刚刚发生的事件,我们通常在其名称中使用过去时态的动作动词。
您的自定义信号与内置信号的工作方式相同:它们会显示在 Signals 标签页中,并且可以像其他信号一样进行连接。
要通过代码发出信号,请调用信号的 emit() 方法。
func take_damage(amount):
health -= amount
if health <= 0:
health_depleted.emit()
public void TakeDamage(int amount)
{
_health -= amount;
if (_health <= 0)
{
EmitSignal(SignalName.HealthDepleted);
}
}
信号还可以选择声明一个或多个参数。在括号之间指定参数的名称:
extends Node2D
signal health_changed(old_value, new_value)
var health = 10
using Godot;
public partial class MyNode : Node
{
[Signal]
public delegate void HealthChangedEventHandler(int oldValue, int newValue);
private int _health = 10;
}
备注
这些信号参数显示在编辑器的节点停靠面板中,Godot 可以使用它们为你生成回调函数。但是,发出信号时仍然可以发出任意数量的参数;所以由你来决定是否发出正确的值。
要在发出信号的同时传值,请将它们添加为 emit() 函数的额外参数:
func take_damage(amount):
var old_health = health
health -= amount
health_changed.emit(old_health, health)
public void TakeDamage(int amount)
{
int oldHealth = _health;
_health -= amount;
EmitSignal(SignalName.HealthChanged, oldHealth, _health);
}
总结
Godot 中的任何节点都会在发生特定事件时发出信号,例如按下按钮。其他节点可以连接到单个信号并对所选事件做出反应。
信号有很多用途。有了它们,你可以对进入或退出游戏世界的节点、碰撞、角色进入或离开某个区域、界面元素的大小变化等等做出反应。
例如,代表金币的 Area2D 会在玩家的物理实体进入其碰撞形状时发出 body_entered 信号,让你知道玩家收集到了金币。
在下一节 你的第一个 2D 游戏 中,你将创建一个完整的 2D 游戏,使用目前为止学到的东西进行实战。