分数与重玩

在这一部分中,我们会添加计分、播放音乐、重启游戏的能力。

我们要用一个变量来记录当前的分数,使用最简的界面在屏幕上显示。我们会用文本标签来实现。

在主场景中,添加一个新的 Control 节点作为 Main 的子项,命名为 UserInterface。你会被自动切换到 2D 屏幕,可以在这里编辑你的用户界面 User Interface(UI)。

添加一个 Label 节点并重命名为 ScoreLabel

image0

检查器中将该 LabelText 设为类似“Score: 0”的占位内容。

image1

并且,文本默认是白色的,和我们的游戏背景一样。我们需要修改它的颜色,才能在运行时看到。

向下滚动到 Theme Overrides(主题覆盖)然后展开 Colors(颜色)并点击 Font Color(字体颜色)旁边的黑框来为文字着色。

image2

选择一个暗色调,与 3D 场景形成对比。

image3

最后单击视窗中的文本,将其拖离左上角。

image4

UserInterface 节点让我们可以将 UI 组合到场景树的一个分支上,并且也让主题资源能够传播到它的所有子节点上。我们将会用它来设置游戏的字体。

创建 UI 主题

再次选中 UserInterface 节点。在检查器中为 Theme -> Theme 创建一个新的主题资源。

image5

单击这个资源就会在底部面板中打开主题编辑器。会展示使用你的主题资源时内置 UI 控件的外观。

image6

默认情况下,主题只有一个属性,Default Font(默认字体)。

参见

你可以为主题资源添加更多属性,从而设计更复杂的用户界面,不过这就超出本系列的范畴了。要学习主题的创建和编辑,请参阅 GUI 皮肤的介绍

单击 Default Font(默认字体)属性,创建一个新的 DynamicFont

image7

单击展开 DynamicFont,然后展开 Font(字体)部分。在这里你会看到一个空的 Font Data(字体数据)字段。

image8

This one expects a font file like the ones you have on your computer. DynamicFont supports the following formats:

  • TrueType (.ttf)

  • OpenType (.otf)

  • Web Open Font Format 1 (.woff)

  • Web Open Font Format 2 (.woff2, since Godot 3.5)

In the FileSystem dock, expand the fonts directory and click and drag the Montserrat-Medium.ttf file we included in the project onto the Font Data. The text will reappear in the theme preview.

文本有一点小。将 Settings -> Size 设置为 22 像素即可增大文本的大小。

image9

跟踪得分

我们下一步是进行计分。为 ScoreLabel 附加一个新的脚本,并在其中定义 score(分数)变量。

GDScriptC#

  1. extends Label
  2. var score = 0
  1. public class ScoreLabel : Label
  2. {
  3. private int _score = 0;
  4. }

每踩扁一只怪物,这个分数就应该加 1。我们可以使用它们的 squashed 信号来得知发生的时间。不过,因为我们是用代码实例化的怪物,我们无法在编辑器中连接信号。

不过,我们可以在每次生成一只怪物时通过代码来进行连接。

打开 Main.gd 脚本。如果它还开着,你可以在脚本编辑器左栏中点击它的名字。

image10

或者,你也可以在文件系统面板中双击 Main.gd 文件。

_on_MobTimer_timeout() 函数的最后添加下面这一行代码。

GDScriptC#

  1. func _on_MobTimer_timeout():
  2. #...
  3. # We connect the mob to the score label to update the score upon squashing one.
  4. mob.connect("squashed", $UserInterface/ScoreLabel, "_on_Mob_squashed")
  1. public void OnMobTimerTimeout()
  2. {
  3. // ...
  4. // We connect the mob to the score label to update the score upon squashing one.
  5. mob.Connect(nameof(Mob.Squashed), GetNode<ScoreLabel>("UserInterface/ScoreLabel"), nameof(ScoreLabel.OnMobSquashed));
  6. }

这一行的意思是,当小怪发出 squashed 信号时,ScoreLabel 节点就会接收到并调用 _on_Mob_squashed() 函数。

回到 ScoreLabel.gd 脚本,定义回调函数 _on_Mob_squashed()

这里我们将进行加分并更新显示的文本。

GDScriptC#

  1. func _on_Mob_squashed():
  2. score += 1
  3. text = "Score: %s" % score
  1. public void OnMobSquashed()
  2. {
  3. _score += 1;
  4. Text = string.Format("Score: {0}", _score);
  5. }

第二行用 score 变量的值替换占位符 %s。使用此功能时,Godot 会自动将值转换为文本,用来向标签中输出文本或者使用 print() 函数非常方便。

参见

你可以在这里了解更多关于字符串格式化的内容:GDScript 格式字符串

你现在可以玩游戏,压死几个敌人,看看分数的增长。

image11

备注

在一个复杂的游戏中,你可能想把你的用户界面与游戏世界完全分开。在这种情况下,你就不会在标签上记录分数了。相反,你可能想把它存储在一个单独的、专门的对象中。但当原型设计或你的项目很简单时,保持你的代码简单就可以了。编程总是一种平衡的行为。

重玩游戏

我们现在就要添加死亡后重玩的能力。玩家死亡后,我们会在屏幕上现实一条消息并等待输入。

回到 Main 场景,选中 UserInterface 节点,添加 ColorRect 节点作为其子项并命名为 Retry(重试)。该节点会使用单一色彩填充矩形,我们用它来覆盖画面,达到变暗的效果。

你可以使用工具栏上的布局菜单来让它覆盖整个视口。

image12

点击打开,并应用整个矩形命令。

image13

什么都没发生。好吧,是几乎什么都没有:只有四个绿色的大头针移动到了选择框的四个角落。

image14

这是因为 UI 节点(图标都是绿色)使用的是锚点和边距,它们都相对于它们父节点包围框。这里的 UserInterface 节点比较小,所以 Retry 会受限于它。

选中 UserInterface 然后也对其使用布局 -> 整个矩形Retry 节点就应该覆盖整个视口了。

让我们修改它的颜色,把游戏区域变暗。选中 Retry,在检查器中将 Color(颜色)设置为透明的暗色。要实现整个效果,可以在取色器中将 A 滑动条拖到左边。它控制的是颜色的 Alpha 通道,也就是不透明度。

image15

接下来,添加一个 Label 作为 Retry 的子项,将其 Text 设为“Press Enter to retry.”(按回车键重试。)

image16

要将其移动并锚定到屏幕中央,请对其使用局部 -> 居中

image17

编写重试选项

我们现在就可以去编写代码,在玩家死亡时显示 Retry 节点,重玩时隐藏。

打开 Main.gd 脚本。首先。我们想要在游戏开始时隐藏覆盖层。将这一行加到 _ready() 函数中。

GDScriptC#

  1. func _ready():
  2. #...
  3. $UserInterface/Retry.hide()
  1. public override void _Ready()
  2. {
  3. // ...
  4. GetNode<Control>("UserInterface/Retry").Hide();
  5. }

然后在玩家受到攻击时,我们就显示这个覆盖层。

GDScriptC#

  1. func _on_Player_hit():
  2. #...
  3. $UserInterface/Retry.show()
  1. public void OnPlayerHit()
  2. {
  3. //...
  4. GetNode<Control>("UserInterface/Retry").Show();
  5. }

最后,当 Retry 节点可见时,我们需要监听玩家的输入,按下回车键时让游戏重启。可以使用内置的 _unhandled_input() 回调来实现。

如果玩家按下了预设的 ui_accept 输入动作并且 Retry 是可见状态,我们就重新加载当前场景。

GDScriptC#

  1. func _unhandled_input(event):
  2. if event.is_action_pressed("ui_accept") and $UserInterface/Retry.visible:
  3. # This restarts the current scene.
  4. get_tree().reload_current_scene()
  1. public override void _UnhandledInput(InputEvent @event)
  2. {
  3. if (@event.IsActionPressed("ui_accept") && GetNode<Control>("UserInterface/Retry").Visible)
  4. {
  5. // This restarts the current scene.
  6. GetTree().ReloadCurrentScene();
  7. }
  8. }

我们可以通过 get_tree() 函数访问全局 SceneTree 对象,然后用它来重新加载并重启当前场景。

添加音乐

要添加音乐,让音乐在后台连续播放,我们就要用到 Godot 的另一项特性:自动加载

要播放音频,只需往场景里添加一个 AudioStreamPlayer 节点,然后为它附加一个音频文件。启动场景时,就会自动播放。然而,如果重新加载了场景,比如我们在重玩的时候就这么干了,这些音频节点也会被重置,音乐也就会从头开始播放。

你可以使用自动加载功能来让 Godot 在游戏开始时自动加载节点或场景,不依赖于当前场景。你还可以用它来创建能够全局访问的对象。

场景菜单中单击新建场景来创建一个新场景。

image18

单击其他节点按钮,创建一个 AudioStreamPlayer 然后将其重命名为 MusicPlayer(音乐播放器)。

image19

我们在 art/ 目录中包含了一条音乐音轨 House In a Forest Loop.ogg。单击并把它拖放到检查器中的 Stream(流)属性上。同时要打开 Autoplay,这样音乐就会在游戏开始时自动播放了。

image20

将这个场景保存为 MusicPlayer.tscn

我们需要将其注册为自动加载。前往菜单项目 -> 项目设置…,然后单击自动加载选项卡。

路径输入框中需要输入场景的路径。单击文件夹图标打开文件浏览器,然后双击 MusicPlayer.tscn。接下来,单击右侧的添加按钮,将该节点进行注册。

image21

现在再运行游戏,音乐就会自动播放了。而且即便你死了然后重试,它还是会在持续播放。

在这一节课结束之前,我们来看一下在底层发生了什么。运行游戏时,你的场景面板会多出来两个选项卡:远程本地

image22

你可以在远程选项卡中查看运行中的游戏的节点树。你会看到 Main 节点以及场景中所包含的所有东西,最底部是实例化的小怪。

image23

顶部的是自动加载的 MusicPlayer 以及一个 root 节点,这是你的游戏的视窗。

这一节课就是这样。在下一部分,我们会添加动画,让游戏更美观。

这是一个仅供参考的 Main.gd 脚本。

GDScriptC#

  1. extends Node
  2. export (PackedScene) var mob_scene
  3. func _ready():
  4. randomize()
  5. $UserInterface/Retry.hide()
  6. func _unhandled_input(event):
  7. if event.is_action_pressed("ui_accept") and $UserInterface/Retry.visible:
  8. get_tree().reload_current_scene()
  9. func _on_MobTimer_timeout():
  10. var mob = mob_scene.instance()
  11. var mob_spawn_location = get_node("SpawnPath/SpawnLocation")
  12. mob_spawn_location.unit_offset = randf()
  13. var player_position = $Player.transform.origin
  14. mob.initialize(mob_spawn_location.translation, player_position)
  15. add_child(mob)
  16. mob.connect("squashed", $UserInterface/ScoreLabel, "_on_Mob_squashed")
  17. func _on_Player_hit():
  18. $MobTimer.stop()
  19. $UserInterface/Retry.show()
  1. public class Main : Node
  2. {
  3. #pragma warning disable 649
  4. [Export]
  5. public PackedScene MobScene;
  6. #pragma warning restore 649
  7. public override void _Ready()
  8. {
  9. GD.Randomize();
  10. GetNode<Control>("UserInterface/Retry").Hide();
  11. }
  12. public override void _UnhandledInput(InputEvent @event)
  13. {
  14. if (@event.IsActionPressed("ui_accept") && GetNode<Control>("UserInterface/Retry").Visible)
  15. {
  16. GetTree().ReloadCurrentScene();
  17. }
  18. }
  19. public void OnMobTimerTimeout()
  20. {
  21. Mob mob = (Mob)MobScene.Instance();
  22. var mobSpawnLocation = GetNode<PathFollow>("SpawnPath/SpawnLocation");
  23. mobSpawnLocation.UnitOffset = GD.Randf();
  24. Vector3 playerPosition = GetNode<Player>("Player").Transform.origin;
  25. mob.Initialize(mobSpawnLocation.Translation, playerPosition);
  26. AddChild(mob);
  27. mob.Connect(nameof(Mob.Squashed), GetNode<ScoreLabel>("UserInterface/ScoreLabel"), nameof(ScoreLabel.OnMobSquashed));
  28. }
  29. public void OnPlayerHit()
  30. {
  31. GetNode<Timer>("MobTimer").Stop();
  32. GetNode<Control>("UserInterface/Retry").Show();
  33. }
  34. }