分数与重玩
在这一部分中,我们会添加计分、播放音乐、重启游戏的能力。
我们要用一个变量来记录当前的分数,使用最简的界面在屏幕上显示。我们会用文本标签来实现。
在主场景中,添加一个新的 Control 节点作为 Main
的子项,命名为 UserInterface
。你会被自动切换到 2D 屏幕,可以在这里编辑你的用户界面 User Interface(UI)。
名为分数标签 ScoreLabel
的 Label
在检查器中将该 Label 的 Text 设为类似“Score: 0”的占位内容。
并且,文本默认是白色的,和我们的游戏背景一样。我们需要修改它的颜色,才能在运行时看到。
向下滚动到 Theme Overrides(主题覆盖)然后展开 Colors(颜色)并点击 Font Color(字体颜色)旁边的黑框来为文字着色
最后单击视口中的文本,将其拖离左上角。
UserInterface
节点让我们可以将 UI 组合到场景树的一个分支上,并且也让主题资源能够传播到它的所有子节点上。我们将会用它来设置游戏的字体。
创建 UI 主题
再次选中 UserInterface
节点。在检查器中为 Theme -> Theme 创建一个新的主题资源。
单击这个资源就会在底部面板中打开主题编辑器。会展示使用你的主题资源时内置 UI 控件的外观。
默认情况下,主题只有寥寥几个属性:Default Base Scale(默认基础缩放)、Default Font(默认字体)、Default Font Size(默认字体大小)。
参见
你可以为主题资源添加更多属性,从而设计更复杂的用户界面,不过这就超出本系列的范畴了。要学习主题的创建和编辑,请参阅 GUI 皮肤简介。
这里的 Default Font 需要一个字体文件,就是你电脑上用的那种。常见的字体文件格式有两种:TrueType 字体(TTF)和 OpenType 字体(OTF)。
在文件系统面板中,展开 fonts
目录,单击我们包含在项目里的 Montserrat-Medium.ttf
文件并将其拖放到Default Font(默认字体)上。文本就又会出现在主题预览中了。
文本有一点小。将Default Font Size(默认字体大小)设置为 22
像素即可增大文本的大小。
跟踪得分
我们下一步是进行计分。为 ScoreLabel
附加一个新的脚本,并在其中定义 score
(分数)变量。
GDScriptC#
extends Label
var score = 0
using Godot;
public partial class ScoreLabel : Label
{
private int _score = 0;
}
每踩扁一只怪物,这个分数就应该加 1
。我们可以使用它们的 squashed
信号来得知发生的时间。不过,因为我们是用代码实例化的怪物,我们无法在编辑器中将怪物的信号连接到 ScoreLabel
。
不过,我们可以在每次生成一只怪物时通过代码来进行连接。
打开 main.gd
脚本。如果它还开着,你可以在脚本编辑器左栏中点击它的名字。
另一种方法是在文件系统面板中双击 main.gd
文件。
在 _on_mob_timer_timeout()
函数的最后添加下面这行代码:
GDScriptC#
func _on_mob_timer_timeout():
#...
# We connect the mob to the score label to update the score upon squashing one.
mob.squashed.connect($UserInterface/ScoreLabel._on_mob_squashed.bind())
private void OnMobTimerTimeout()
{
// ...
// We connect the mob to the score label to update the score upon squashing one.
mob.Squashed += GetNode<ScoreLabel>("UserInterface/ScoreLabel").OnMobSquashed;
}
这一行的意思是,当小怪发出 squashed
信号时,ScoreLabel
节点就会接收到并调用 _on_mob_squashed()
函数。
Head back to the score_label.gd
script to define the _on_mob_squashed()
callback function.
这里我们将进行加分并更新显示的文本。
GDScriptC#
func _on_mob_squashed():
score += 1
text = "Score: %s" % score
public void OnMobSquashed()
{
_score += 1;
Text = $"Score: {_score}";
}
第二行用 score
变量的值替换占位符 %s
。使用此功能时,Godot 会自动将值转换为字符串文本,这在向标签中输出文本或者使用 print()
函数时非常方便。
参见
可以在 GDScript 格式字符串 学习字符串格式化相关的更多信息。在 C# 中请考虑使用“$”进行字符串插值。
你现在可以玩游戏,压死几个敌人,看看分数的增长。
备注
在一个复杂的游戏中,你可能想把你的用户界面与游戏世界完全分开。在这种情况下,你就不会在标签上记录分数了。相反,你可能想把它存储在一个单独的、专门的对象中。但当原型设计或你的项目很简单时,保持你的代码简单就可以了。编程总是一种平衡的行为。
重玩游戏
我们现在就要添加死亡后重玩的能力。玩家死亡后,我们会在屏幕上现实一条消息并等待输入。
回到 main.tscn
场景,选中 UserInterface
节点,添加 ColorRect 节点作为其子项并命名为 Retry
(重试)。该节点会使用单一色彩填充矩形,我们用它来覆盖画面,达到变暗的效果。
要使其覆盖整个视口,可以使用工具栏中 锚点预设 菜单。
点击打开,并应用整个矩形命令。
什么都没发生。好吧,是几乎什么都没有;只有四个绿色的大头针移动到了选择框的四个角落。
这是因为 UI 节点(图标都是绿色)使用的是锚点和边距,它们都相对于它们父节点包围框。这里的 UserInterface
节点比较小,所以 Retry
会受限于它。
选中 UserInterface
然后也对其使用锚点预设 -> 整个矩形。Retry
节点就应该覆盖整个视口了。
让我们修改它的颜色,把游戏区域变暗。选中 Retry
,在检查器中将 Color(颜色)设置为透明的暗色。要实现整个效果,可以在取色器中将 A 滑动条拖到左边。它控制的是颜色的 Alpha 通道,也就是不透明度。
接下来,添加一个 Label 的节点作为 Retry
的子节点并且设置他的 Text 为“Press Enter to retry”。将其移动至屏幕中央,并且选择 Anchor Preset -> Center(锚点预设 > 居中)。
编写重试选项
我们现在就可以去编写代码,在玩家死亡时显示 Retry
节点,重玩时隐藏。
打开 main.gd
脚本。首先,我们想要在游戏开始时隐藏覆盖层。将这一行加到 _ready()
函数中。
GDScriptC#
func _ready():
$UserInterface/Retry.hide()
public override void _Ready()
{
GetNode<Control>("UserInterface/Retry").Hide();
}
然后在玩家受到攻击时,我们就显示这个覆盖层。
GDScriptC#
func _on_player_hit():
#...
$UserInterface/Retry.show()
private void OnPlayerHit()
{
//...
GetNode<Control>("UserInterface/Retry").Show();
}
最后,当 Retry
节点可见时,我们需要监听玩家的输入,按下回车键时让游戏重启。可以使用内置的 _unhandled_input()
回调来实现,任何输入都会触发这个回调。
如果玩家按下了预设的 ui_accept
输入动作并且 Retry
是可见状态,我们就重新加载当前场景。
GDScriptC#
func _unhandled_input(event):
if event.is_action_pressed("ui_accept") and $UserInterface/Retry.visible:
# This restarts the current scene.
get_tree().reload_current_scene()
public override void _UnhandledInput(InputEvent @event)
{
if (@event.IsActionPressed("ui_accept") && GetNode<Control>("UserInterface/Retry").Visible)
{
// This restarts the current scene.
GetTree().ReloadCurrentScene();
}
}
我们可以通过 get_tree()
函数访问全局 SceneTree 对象,然后用它来重新加载并重启当前场景。
添加音乐
要添加音乐,让音乐在后台连续播放,我们就要用到 Godot 的另一项特性:自动加载。
要播放音频,只需往场景里添加一个 AudioStreamPlayer 节点,然后为它附加一个音频文件。启动场景时,就会自动播放。然而,如果重新加载了场景,比如我们在重玩的时候就这么干了,这些音频节点也会被重置,音乐也就会从头开始播放。
你可以使用自动加载功能来让 Godot 在游戏开始时自动加载节点或场景,不依赖于当前场景。你还可以用它来创建能够全局访问的对象。
在场景菜单中单击新建场景,或者使用当前打开的场景旁边的 + 图标来创建一个新场景。
单击其他节点按钮,创建一个 AudioStreamPlayer 然后将其重命名为 MusicPlayer
(音乐播放器)。
我们在 art/
目录中包含了一条音乐音轨 House In a Forest Loop.ogg
。单击并把它拖放到检查器中的 Stream(流)属性上。同时要打开 Autoplay,这样音乐就会在游戏开始时自动播放了。
Save the scene as music_player.tscn
.
我们需要将其注册为自动加载。前往菜单项目 -> 项目设置…,然后单击自动加载选项卡。
In the Path field, you want to enter the path to your scene. Click the folder icon to open the file browser and double-click on music_player.tscn
. Then, click the Add button on the right to register the node.
music_player.tscn
now loads into any scene you open or play. So if you run the game now, the music will play automatically in any scene.
在这一节课结束之前,我们来看一下在底层发生了什么。运行游戏时,你的场景面板会多出来两个选项卡:远程和本地。
你可以在远程选项卡中查看运行中的游戏的节点树。你会看到 Main 节点以及场景中所包含的所有东西,最底部是实例化的小怪。
顶部的是自动加载的 MusicPlayer
以及一个 root 节点,这是你的游戏的视口。
这一节课就是这样。在下一部分,我们会添加动画,让游戏更美观。
这是完整的 main.gd
脚本,仅供参考。
GDScriptC#
extends Node
@export var mob_scene: PackedScene
func _ready():
$UserInterface/Retry.hide()
func _on_mob_timer_timeout():
# Create a new instance of the Mob scene.
var mob = mob_scene.instantiate()
# Choose a random location on the SpawnPath.
# We store the reference to the SpawnLocation node.
var mob_spawn_location = get_node("SpawnPath/SpawnLocation")
# And give it a random offset.
mob_spawn_location.progress_ratio = randf()
var player_position = $Player.position
mob.initialize(mob_spawn_location.position, player_position)
# Spawn the mob by adding it to the Main scene.
add_child(mob)
# We connect the mob to the score label to update the score upon squashing one.
mob.squashed.connect($UserInterface/ScoreLabel._on_mob_squashed.bind())
func _on_player_hit():
$MobTimer.stop()
$UserInterface/Retry.show()
func _unhandled_input(event):
if event.is_action_pressed("ui_accept") and $UserInterface/Retry.visible:
# This restarts the current scene.
get_tree().reload_current_scene()
using Godot;
public partial class Main : Node
{
[Export]
public PackedScene MobScene { get; set; }
public override void _Ready()
{
GetNode<Control>("UserInterface/Retry").Hide();
}
public override void _UnhandledInput(InputEvent @event)
{
if (@event.IsActionPressed("ui_accept") && GetNode<Control>("UserInterface/Retry").Visible)
{
// This restarts the current scene.
GetTree().ReloadCurrentScene();
}
}
private void OnMobTimerTimeout()
{
// Create a new instance of the Mob scene.
Mob mob = MobScene.Instantiate<Mob>();
// Choose a random location on the SpawnPath.
// We store the reference to the SpawnLocation node.
var mobSpawnLocation = GetNode<PathFollow3D>("SpawnPath/SpawnLocation");
// And give it a random offset.
mobSpawnLocation.ProgressRatio = GD.Randf();
Vector3 playerPosition = GetNode<Player>("Player").position;
mob.Initialize(mobSpawnLocation.Position, playerPosition);
// Spawn the mob by adding it to the Main scene.
AddChild(mob);
// We connect the mob to the score label to update the score upon squashing one.
mob.Squashed += GetNode<ScoreLabel>("UserInterface/ScoreLabel").OnMobSquashed;
}
private void OnPlayerHit()
{
GetNode<Timer>("MobTimer").Stop();
GetNode<Control>("UserInterface/Retry").Show();
}
}