分数与重玩

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

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

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

名为分数标签 ScoreLabelLabel

image1

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

image2

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

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

image3

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

image4

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

创建 UI 主题

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

image5

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

image6

默认情况下,主题只有寥寥几个属性: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 像素即可增大文本的大小。

image7

跟踪得分

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

GDScriptC#

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

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

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

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

image8

另一种方法是在文件系统面板中双击 main.gd 文件。

_on_mob_timer_timeout() 函数的最后添加下面这行代码:

GDScriptC#

  1. func _on_mob_timer_timeout():
  2. #...
  3. # We connect the mob to the score label to update the score upon squashing one.
  4. mob.squashed.connect($UserInterface/ScoreLabel._on_mob_squashed.bind())
  1. private void OnMobTimerTimeout()
  2. {
  3. // ...
  4. // We connect the mob to the score label to update the score upon squashing one.
  5. mob.Squashed += GetNode<ScoreLabel>("UserInterface/ScoreLabel").OnMobSquashed;
  6. }

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

Head back to the score_label.gd script to define the _on_mob_squashed() callback function.

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

GDScriptC#

  1. func _on_mob_squashed():
  2. score += 1
  3. text = "Score: %s" % score
  1. public void OnMobSquashed()
  2. {
  3. _score += 1;
  4. Text = $"Score: {_score}";
  5. }

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

参见

可以在 GDScript 格式字符串 学习字符串格式化相关的更多信息。在 C# 中请考虑使用“$”进行字符串插值

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

image9

备注

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

重玩游戏

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

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

要使其覆盖整个视口,可以使用工具栏中 锚点预设 菜单。

image10

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

image11

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

image12

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

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

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

image13

接下来,添加一个 Label 的节点作为 Retry 的子节点并且设置他的 Text 为“Press Enter to retry”。将其移动至屏幕中央,并且选择 Anchor Preset -> Center(锚点预设 > 居中)。

image14

编写重试选项

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

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

GDScriptC#

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

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

GDScriptC#

  1. func _on_player_hit():
  2. #...
  3. $UserInterface/Retry.show()
  1. private 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 在游戏开始时自动加载节点或场景,不依赖于当前场景。你还可以用它来创建能够全局访问的对象。

场景菜单中单击新建场景,或者使用当前打开的场景旁边的 + 图标来创建一个新场景。

image15

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

image16

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

image17

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.

image18

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.

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

image19

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

image20

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

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

这是完整的 main.gd 脚本,仅供参考。

GDScriptC#

  1. extends Node
  2. @export var mob_scene: PackedScene
  3. func _ready():
  4. $UserInterface/Retry.hide()
  5. func _on_mob_timer_timeout():
  6. # Create a new instance of the Mob scene.
  7. var mob = mob_scene.instantiate()
  8. # Choose a random location on the SpawnPath.
  9. # We store the reference to the SpawnLocation node.
  10. var mob_spawn_location = get_node("SpawnPath/SpawnLocation")
  11. # And give it a random offset.
  12. mob_spawn_location.progress_ratio = randf()
  13. var player_position = $Player.position
  14. mob.initialize(mob_spawn_location.position, player_position)
  15. # Spawn the mob by adding it to the Main scene.
  16. add_child(mob)
  17. # We connect the mob to the score label to update the score upon squashing one.
  18. mob.squashed.connect($UserInterface/ScoreLabel._on_mob_squashed.bind())
  19. func _on_player_hit():
  20. $MobTimer.stop()
  21. $UserInterface/Retry.show()
  22. func _unhandled_input(event):
  23. if event.is_action_pressed("ui_accept") and $UserInterface/Retry.visible:
  24. # This restarts the current scene.
  25. get_tree().reload_current_scene()
  1. using Godot;
  2. public partial class Main : Node
  3. {
  4. [Export]
  5. public PackedScene MobScene { get; set; }
  6. public override void _Ready()
  7. {
  8. GetNode<Control>("UserInterface/Retry").Hide();
  9. }
  10. public override void _UnhandledInput(InputEvent @event)
  11. {
  12. if (@event.IsActionPressed("ui_accept") && GetNode<Control>("UserInterface/Retry").Visible)
  13. {
  14. // This restarts the current scene.
  15. GetTree().ReloadCurrentScene();
  16. }
  17. }
  18. private void OnMobTimerTimeout()
  19. {
  20. // Create a new instance of the Mob scene.
  21. Mob mob = MobScene.Instantiate<Mob>();
  22. // Choose a random location on the SpawnPath.
  23. // We store the reference to the SpawnLocation node.
  24. var mobSpawnLocation = GetNode<PathFollow3D>("SpawnPath/SpawnLocation");
  25. // And give it a random offset.
  26. mobSpawnLocation.ProgressRatio = GD.Randf();
  27. Vector3 playerPosition = GetNode<Player>("Player").position;
  28. mob.Initialize(mobSpawnLocation.Position, playerPosition);
  29. // Spawn the mob by adding it to the Main scene.
  30. AddChild(mob);
  31. // We connect the mob to the score label to update the score upon squashing one.
  32. mob.Squashed += GetNode<ScoreLabel>("UserInterface/ScoreLabel").OnMobSquashed;
  33. }
  34. private void OnPlayerHit()
  35. {
  36. GetNode<Timer>("MobTimer").Stop();
  37. GetNode<Control>("UserInterface/Retry").Show();
  38. }
  39. }