您的第一个游戏

概览

本教程将指导您完成第一个Godot项目。您将学习Godot编辑器的工作原理、如何构建项目、以及如何构建2D游戏。

注解

该项目是Godot引擎的一个介绍。它假定您已经有一定的编程经验。如果您完全不熟悉编程,则应从这里开始: 编写脚本

这个游戏叫做 Dodge the Creeps!。您的角色必须尽可能长时间移动并避开敌人。这是最终结果的预览:

../../_images/dodge_preview.gif

为什么是2D游戏 3D游戏比2D游戏复杂得多。您应该坚持使用2D,直到您对游戏开发过程有了充分的了解。

项目设置

启动Godot并创建一个新项目。然后,下载 dodge_assets.zip——用于制作游戏的图像和声音。将这些文件解压缩到您的项目文件夹中。

注解

对于本教程,我们假设您熟悉编辑器。如果您还没有阅读 场景与节点 ,那么赶紧看看,以了解如何设置项目并使用编辑器。

这个游戏会使用竖屏模式,所以我们需要调整游戏窗口的大小。点击项目->项目设置->显示->窗口,设置“宽度”为 480 ,“高度”为 720

组织项目

在这个项目中,我们将制作3个独立的场景:PlayerMob、和 HUD,将它们组合到游戏的 Main 场景中。创建文件夹来保存各种场景及其脚本可能会很有用,但是对于这个相对较小的游戏,您可以将场景和脚本保存在项目的根文件夹 res:// 中。您可以在左下角的文件系统停靠面板中看到您的项目文件夹:

../../_images/filesystem_dock.png

Player 场景

第一个场景,我们会定义 Player 对象。创建单独的 Player 场景的好处之一是,即使在创建游戏的其他部分之前,我们仍可以对其进行单独测试。

节点结构

首先,单击 添加/创建新节点 按钮,然后向场景中添加一个 Area2D 节点。

../../_images/add_node.png

Godot将在场景树中的节点旁边显示警告图标。你现在可以忽略它。我们稍后再谈。

使用 Area2D 我们可以检测物体是否跑到 Player 之中或者与 Player 发生交叠。通过单击节点名称将其名称更改为 Player。它是场景的根节点。我们可以向该角色中添加其他节点以添加功能。

在将任何子级添加到 Player 节点之前,我们要确保不会通过点击它们而意外地移动它们或调整其大小。选择节点,然后点击锁右侧的图标;它的工具提示显示 确保对象的子级不可选择。

../../_images/lock_children.png

Save the scene. Click Scene -> Save, or press Ctrl + S on Windows/Linux or Cmd + S on macOS.

注解

对于此项目,我们将遵循Godot的命名约定。

  • GDScript:类(节点)使用Pascal命名法(PascalCase),变量和函数使用蛇形命名法(snake_case),常量使用全大写(ALL_CAPS)(请参阅 GDScript 风格指南)。
  • C#:类、导出变量和方法使用PascalCase,私有字段使用_camelCase,局部变量和参数使用camelCase(参见 C# 风格指南)。连接信号时,请务必准确键入方法名称。

精灵动画

点击 Player 节点并添加一个 AnimatedSprite 节点作为子节点。AnimatedSprite 将为我们的 Player 处理外观和动画。请注意,节点旁边有一个警告符号。一个 AnimatedSprite 需要一个 SpriteFrames 资源,它是一个可显示的动画列表。要创建它,在属性检查器面板中找到 Frames 属性,然后点击“[空白]” - >“新建SpriteFrames”。这将自动打开 SpriteFrames 面板。

../../_images/spriteframes_panel.png

左边是一个动画列表。点击 默认 动画,并将其重命名为 right。然后点击 添加 按钮,创建第二个动画,名为 up。将每个动画的两个图像(名为 playerGrey_up[1/2]playerGrey_walk[1/2] )拖到面板的 动画帧 一侧:

../../_images/spriteframes_panel2.png

Player 图像对于游戏窗口来说有点太大,所以我们需要缩小它们。点击 AnimatedSprite 节点并将 Scale 属性设置为 (0.5,0.5) 。您可以在属性检查器面板中的 Node2D 标题下找到它。

../../_images/player_scale.png

最后,添加一个 CollisionShape2D 作为 Player 的子节点。它用于决定 Player 的“碰撞盒”,亦或者说是它碰撞区域的边界。对于该角色,CapsuleShape2D 节点最适合,因此,在属性检查器中的“形状”旁边,单击“ [空]””->“新建CapsuleShape2D”。使用两个尺寸手柄,调整形状,以覆盖住精灵:

../../_images/player_coll_shape.png

完成后,您的 Player 场景看起来应该像这样:

../../_images/player_scene_nodes.png

移动 Player

现在我们需要添加一些内置节点所不具备的功能,因此我们要添加一个脚本。点击 Player 节点然后点击 添加脚本 按钮:

../../_images/add_script_button.png

在脚本设置窗口中,您可以保留默认设置。只要点击 创建 就好:

注解

如果您要创建一个C#脚本或者其他语言的脚本,那就在创建之前在 语言 下拉菜单中选择语言。

../../_images/attach_node_window.png

注解

如果这是您第一次使用GDScript,请在继续之前阅读 编写脚本

首先声明该对象将需要的成员变量:

extends Area2D

export var speed = 400  # How fast the player will move (pixels/sec).
var screen_size  # Size of the game window.
public class Player : Area2D
{
    [Export]
    public int Speed = 400; // How fast the player will move (pixels/sec).

    private Vector2 _screenSize; // Size of the game window.
}

在第一个变量 speed 上使用 export 关键字,这样允许在属性检查器中设置其值。对于希望能够像节点的内置属性一样进行调整的值,这可能很方便。点击 Player 节点,您将看到该属性现在显示在属性检查器的“脚本变量”部分中。请记住,如果您在此处更改值,它将覆盖脚本中已写入的值。

警告

如果使用的是C#,则每当要查看新的导出变量或信号时,都需要(重新)构建项目程序集。点击编辑器窗口底部的“ Mono”一词以显示Mono面板,然后单击“ 构建项目”按钮,即可手动触发此构建。

../../_images/export_variable.png

当节点进入场景树时,_ready() 函数被调用,这是查找游戏窗口大小的好时机:

func _ready():
    screen_size = get_viewport_rect().size
public override void _Ready()
{
    _screenSize = GetViewport().Size;
}

现在我们可以使用 _process() 函数定义 Player 将执行的操作。_process() 在每一帧都被调用,因此我们将使用它,来更新我们希望会经常变化的游戏元素。对于 Player ,我们需要执行以下操作:

  • 检查输入。
  • 沿给定方向移动。
  • 播放适当的动画。

首先,我们需要检查输入—— Player 是否按下了键?对于这个游戏,我们有4个方向的输入要检查。输入动作在项目设置中的“输入映射”下定义。在这里,您可以定义自定义事件,并为其分配不同的键、鼠标事件、或其他输入。对于此演示项目,我们将使用分配给键盘上箭头键的默认事件。

您可以使用 Input.is_action_pressed() 来检测是否按下了键,如果按下则返回 true,否则返回 false

func _process(delta):
    var velocity = Vector2()  # The player's movement vector.
    if Input.is_action_pressed("ui_right"):
        velocity.x += 1
    if Input.is_action_pressed("ui_left"):
        velocity.x -= 1
    if Input.is_action_pressed("ui_down"):
        velocity.y += 1
    if Input.is_action_pressed("ui_up"):
        velocity.y -= 1
    if velocity.length() > 0:
        velocity = velocity.normalized() * speed
        $AnimatedSprite.play()
    else:
        $AnimatedSprite.stop()
public override void _Process(float delta)
{
    var velocity = new Vector2(); // The player's movement vector.

    if (Input.IsActionPressed("ui_right"))
    {
        velocity.x += 1;
    }

    if (Input.IsActionPressed("ui_left"))
    {
        velocity.x -= 1;
    }

    if (Input.IsActionPressed("ui_down"))
    {
        velocity.y += 1;
    }

    if (Input.IsActionPressed("ui_up"))
    {
        velocity.y -= 1;
    }

    var animatedSprite = GetNode<AnimatedSprite>("AnimatedSprite");

    if (velocity.Length() > 0)
    {
        velocity = velocity.Normalized() * Speed;
        animatedSprite.Play();
    }
    else
    {
        animatedSprite.Stop();
    }
}

我们首先将 velocity(速度) 设置为 (0, 0)——默认情况下 Player 不应该移动。然后我们检查每次输入并从 velocity(速度) 中进行加/减以获得总方向。例如,如果您同时按住 right(向右)down(向下),则生成的 velocity(速度) 速度将为 (1, 1)。在这种情况下,由于我们要添加水平和垂直移动,因此 Player 的移动速度比水平移动要

如果对速度进行 归一化(normalize),我们可以防止这种情况,这意味着我们将其 长度(length) 设置为 1,然后乘以所需速度。这意味着不再有快速的对角运动。

小技巧

如果您以前从未使用过向量数学,或者需要复习,可以在Godot中的 向量数学 上查看向量用法的解释。最好了解一下,但对于本教程的其余部分而言,这不是必需的。

我们还检查 Player 是否在移动,以便我们可以启动或停止AnimatedSprite动画。

小技巧

在GDScript中,$ 返回在当前节点的相对路径处的节点,如果找不到该节点,则返回 null。由于AnimatedSprite是当前节点的子项,因此我们可以使用 $AnimatedSprite

$get_node() 的简写。因此在上面的代码中, $AnimatedSprite.play()get_node("AnimatedSprite").play() 相同。

现在我们有了一个运动方向,我们可以更新玩家的位置了。我们也可以使用 clamp() 来防止它离开屏幕。clamp 一个值意味着将其限制在给定范围内。将以下内容添加到 _process 函数的底部:

position += velocity * delta
position.x = clamp(position.x, 0, screen_size.x)
position.y = clamp(position.y, 0, screen_size.y)
Position += velocity * delta;
Position = new Vector2(
    x: Mathf.Clamp(Position.x, 0, _screenSize.x),
    y: Mathf.Clamp(Position.y, 0, _screenSize.y)
);

小技巧

_process() 函数的 delta 参数是*帧长度*——完成上一帧所花费的时间。使用这个值的话,可以保证你的移动不会被帧率的变化所影响。

点击“播放场景”(F6),并确认您可以在屏幕的各个方向上移动 Player 。可以通过点击底部面板左下方的 输出(Output) (应以蓝色突出显示),来关闭在播放场景时打开的控制台输出。

警告

如果在“调试器”面板中出现错误,且该错误提及“空实例”,则可能意味着您拼错了节点名称。节点名称区分大小写,并且 $NodeNameget_node("NodeName") 必须与您在场景树中看到的名称匹配。

选择动画

现在 Player 可以移动了,我们需要根据方向更改AnimatedSprite正在播放哪个动画。我们有一个 right 动画,应该使用 flip_h 属性将其水平翻转以向左移动;以及一个 up 动画,应该用 flip_v 垂直翻转以向下移动。让我们将这些代码放在 _process() 函数的末尾:

if velocity.x != 0:
    $AnimatedSprite.animation = "right"
    $AnimatedSprite.flip_v = false
    # See the note below about boolean assignment
    $AnimatedSprite.flip_h = velocity.x < 0
elif velocity.y != 0:
    $AnimatedSprite.animation = "up"
    $AnimatedSprite.flip_v = velocity.y > 0
if (velocity.x != 0)
{
    animatedSprite.Animation = "right";
    animatedSprite.FlipV = false;
    // See the note below about boolean assignment
    animatedSprite.FlipH = velocity.x < 0;
}
else if(velocity.y != 0)
{
    animatedSprite.Animation = "up";
    animatedSprite.FlipV = velocity.y > 0;
}

注解

上面代码中的布尔赋值是程序员的常用简写。考虑这段代码与上面缩短的布尔赋值:

if velocity.x < 0:
    $AnimatedSprite.flip_h = true
else:
    $AnimatedSprite.flip_h = false
if velocity.x < 0:
    animatedSprite.FlipH = true
else:
    animatedSprite.FlipH = false

再次运行场景,并检查各个方向的动画是否正确。当您确定移动正常工作时,请将此行添加到 _ready(),以便 Player 在游戏开始时会被隐藏:

hide()
Hide();

准备碰撞

我们希望 Player 能够检测到何时被敌人击中,但是我们还没有任何敌人!没关系,因为我们将使用Godot的 信号 功能来使其正常工作。

在脚本顶部,extends Area2d 之后,添加以下内容:

signal hit
// Don't forget to rebuild the project so the editor knows about the new signal.

[Signal]
public delegate void Hit();

这定义了一个称为 hit 的自定义信号,当 Player 与敌人碰撞时,我们将使其 Player 发射(发出)信号。我们将使用 Area2D 来检测碰撞。选择 Player 节点,然后点击属性检查器选项卡旁边的“节点”选项卡,以查看 Player 可以发出的信号列表:

../../_images/player_signals.png

请注意我们的自定义 hit 信号也在存在!由于我们的敌人将是 RigidBody2D 节点,因此我们需要 body_entered( Object body ) 信号;当身体与 Player 接触时会发出此信号。点击 连接...,然后在 连接信号 窗口再次 连接。我们不需要更改任何设置——Godot将在您的 Player 脚本中自动创建一个功能。每当发出信号时,都会调用此函数——用它 处理 信号。

小技巧

连接信号时,除了让Godot为您创建函数外,您还可以提供要将信号连接到的现有函数的名称。

将这些代码添加到函数中:

func _on_Player_body_entered(body):
    hide()  # Player disappears after being hit.
    emit_signal("hit")
    $CollisionShape2D.set_deferred("disabled", true)
public void OnPlayerBodyEntered(PhysicsBody2D body)
{
    Hide(); // Player disappears after being hit.
    EmitSignal("Hit");
    GetNode<CollisionShape2D>("CollisionShape2D").SetDeferred("disabled", true);
}

每次敌人击中 Player 时,都会发出信号。我们需要禁用 Player 的碰撞,以便我们不会多次触发 hit 信号。

注解

如果在引擎碰撞过程中禁用碰撞形状,则可能导致错误。使用 set_deferred() 允许我们让Godot等待直到安全为止,以去禁用形状。

Player 的最后一部分就是添加一个函数,用于在开始新游戏时调用来重置 Player

func start(pos):
    position = pos
    show()
    $CollisionShape2D.disabled = false
public void Start(Vector2 pos)
{
    Position = pos;
    Show();
    GetNode<CollisionShape2D>("CollisionShape2D").Disabled = false;
}

Enemy 场景

现在是时候去做一些 Player 必须躲避的敌人了。它们的行为不会很复杂:怪物将在屏幕的边缘随机生成,并在随机的方向上沿直线移动,然后在离开屏幕时消失。

我们会将其构建到 Mob 场景中,然后我们可以 实例化 该场景,以在游戏中创建任意数量的独立怪物。

节点设置

点击场景 -> 新建场景,然后我们就可以创建Mob了。

Mob场景将会使用如下节点:

  • RigidBody2D (名为 Mob
    • AnimatedSprite
    • CollisionShape2D
    • VisibilityNotifier2D (名为 Visibility

别忘了设置子项,使其无法被选中,就像您对 Player 场景所做的那样。

RigidBody2D 属性中,将 Gravity Scale 设置为 0,这样怪物就不会下坠。此外,在 PhysicsBody2D 部分下,点击 Mask 属性并去除第一个复选框的勾选。这会确保怪物不会彼此碰撞。

../../_images/set_collision_mask.png

像设置 Player 一样设置 AnimatedSprite。这一次, 我们有3动画: flyswim、和 walk。如下所示,将属性检查器面板中的 Playing 属性设置为 On 并调整 速度 (FPS) 设置。我们将随机选择这些动画之一,以使这些怪物物具有一定的多样性。

../../_images/mob_animations.gif

fly 应设置为3 FPS, swimwalk 应设置为4 FPS。

Player 图像一样,这些怪物的图像也要缩小。设置 AnimatedSpriteScale 属性为 (0.75, 0.75)

像在 Player 场景中一样,为碰撞添加一个 CapsuleShape2D。为了使形状与图像对齐,您需要在 Node2D 下将 Rotation Degrees 属性设置为 90

敌人的脚本

将脚本添加到 Mob 并添加以下成员变量:

extends RigidBody2D

export var min_speed = 150  # Minimum speed range.
export var max_speed = 250  # Maximum speed range.
var mob_types = ["walk", "swim", "fly"]
public class Mob : RigidBody2D
{
    // Don't forget to rebuild the project so the editor knows about the new export variables.

    [Export]
    public int MinSpeed = 150; // Minimum speed range.

    [Export]
    public int MaxSpeed = 250; // Maximum speed range.

    private String[] _mobTypes = {"walk", "swim", "fly"};
}

当我们生成怪物时,我们将在 min_speedmax_speed 之间选择一个随机值,以确定每个怪物的运动速度(如果它们都以相同的速度运动,那将很无聊)。我们还有一个数组,其中包含三个动画的名称,我们将使用它选择一个随机的动画。请确保您在脚本和 SpriteFrames 资源中拼写相同。

现在让我们看一下脚本的其余部分。在 _ready() 中,我们从三个动画类型中随机选择一个:

func _ready():
    $AnimatedSprite.animation = mob_types[randi() % mob_types.size()]
// C# doesn't implement GDScript's random methods, so we use 'System.Random' as an alternative.
static private Random _random = new Random();

public override void _Ready()
{
    GetNode<AnimatedSprite>("AnimatedSprite").Animation = _mobTypes[_random.Next(0, _mobTypes.Length)];
}

注解

如果希望每次运行场景时“随机”数字的顺序都不同,则必须使用 randomize()。我们将在 Main 场景中使用 randomize(),因此在这里我们不需要它。randi() % n 是获取介于 0n-1 之间的随机整数的标准方法。

最后一步是让怪物在离开屏幕时将自己删除。连接 Visibility 节点的 screen_exited() 信号并添加以下代码:

func _on_Visibility_screen_exited():
    queue_free()
public void OnVisibilityScreenExited()
{
    QueueFree();
}

这样就完成了 Mob 场景。

Main 场景

现在是时候将它们整合在一起了。创建一个新场景并添加一个名为 Main 的节点 Node。点击“实例”按钮,然后选择保存的 Player.tscn

../../_images/instance_scene.png

注解

请参阅 实例化 以了解有关实例化的更多信息。

现在,将以下节点添加为 Main 的子节点,并按如下所示对其进行命名(值以秒为单位):

  • Timer (名为 MobTimer)——控制怪物产生的频率
  • Timer (名为 ScoreTimer)——每秒增加分数
  • Timer (名为 StartTimer)——在开始之前给出延迟
  • Position2D (名为 StartPosition)——指示 Player 的起始位置

如下设置每个 Timer 节点的 Wait Time 属性:

  • MobTimer0.5
  • ScoreTimer1
  • StartTimer2

此外,将 StartTimerOne Shot 属性设置为 On,并将 StartPosition 节点的 Position 设置为 (240, 450)

生成怪物

Main 节点将产生新的生物,我们希望它们出现在屏幕边缘的随机位置。添加一个名为 MobPathPath2D 节点作为 Main 的子级。当您选择 Path2D 时,您将在编辑器顶部看到一些新按钮:

../../_images/path2d_buttons.png

选择中间的那个( 添加点 ),然后通过点击以在所示的角点处添加点来绘制路径。要使点吸附到网格,请确保选中 吸附到网格。该选项可以在 锁定 按钮左侧的 捕捉选项 按钮下找到,显示为一系列三个垂直点。

../../_images/draw_path2d.gif

重要

顺时针 的顺序绘制路径,否则小怪会 向外 而非 向内 生成!

在图像上放置点 4 后,点击 闭合曲线 按钮,您的曲线将完成。

现在已经定义了路径,添加一个 PathFollow2D 节点作为 MobPath 的子节点,并将其命名为 MobSpawnLocation。该节点在移动时,将自动旋转并沿着该路径,因此我们可以使用它沿路径来选择随机位置和方向。

Main 脚本

将脚本添加到 Main。在脚本的顶部,我们使用 export (PackedScene) 来允许我们选择要实例化的 Mob 场景。

extends Node

export (PackedScene) var Mob
var score

func _ready():
    randomize()
public class Main : Node
{
    // Don't forget to rebuild the project so the editor knows about the new export variable.

    [Export]
    public PackedScene Mob;

    private int _score;

    // We use 'System.Random' as an alternative to GDScript's random methods.
    private Random _random = new Random();

    public override void _Ready()
    {
    }

    // We'll use this later because C# doesn't support GDScript's randi().
    private float RandRange(float min, float max)
    {
        return (float)_random.NextDouble() * (max - min) + min;
    }
}

文件系统 面板中拖动 Mob.tscn,并将其放在 Main 节点的 脚本变量 下的 Mob 属性中。

Next, click on the Player and connect the hit signal. We want to make a new function named game_over, which will handle what needs to happen when a game ends. Type “game_over” in the “Receiver Method” box at the bottom of the “Connecting Signal” window. Add the following code, as well as a new_game function to set everything up for a new game:

func game_over():
    $ScoreTimer.stop()
    $MobTimer.stop()

func new_game():
    score = 0
    $Player.start($StartPosition.position)
    $StartTimer.start()
public void GameOver()
{
    GetNode<Timer>("MobTimer").Stop();
    GetNode<Timer>("ScoreTimer").Stop();
}

public void NewGame()
{
    _score = 0;

    var player = GetNode<Player>("Player");
    var startPosition = GetNode<Position2D>("StartPosition");
    player.Start(startPosition.Position);

    GetNode<Timer>("StartTimer").Start();
}

现在将每个 Timer 节点(StartTimerScoreTimer 、和 MobTimer)的 timeout() 信号连接到 main 脚本。StartTimer 将启动其他两个计时器。ScoreTimer 将使得分增加1。

func _on_StartTimer_timeout():
    $MobTimer.start()
    $ScoreTimer.start()

func _on_ScoreTimer_timeout():
    score += 1
public void OnStartTimerTimeout()
{
    GetNode<Timer>("MobTimer").Start();
    GetNode<Timer>("ScoreTimer").Start();
}

public void OnScoreTimerTimeout()
{
    _score++;
}

_on_MobTimer_timeout() 中,我们将创建一个 mob 实例,沿着 Path2D 随机选择一个起始位置,然后让 mob 移动。PathFollow2D 节点将沿路径移动,因此会自动旋转,所以我们将使用它来选择怪物的方向及其位置。

注意,必须使用 add_child() 将新实例添加到场景中。

func _on_MobTimer_timeout():
    # Choose a random location on Path2D.
    $MobPath/MobSpawnLocation.offset = randi()
    # Create a Mob instance and add it to the scene.
    var mob = Mob.instance()
    add_child(mob)
    # Set the mob's direction perpendicular to the path direction.
    var direction = $MobPath/MobSpawnLocation.rotation + PI / 2
    # Set the mob's position to a random location.
    mob.position = $MobPath/MobSpawnLocation.position
    # Add some randomness to the direction.
    direction += rand_range(-PI / 4, PI / 4)
    mob.rotation = direction
    # Set the velocity (speed & direction).
    mob.linear_velocity = Vector2(rand_range(mob.min_speed, mob.max_speed), 0)
    mob.linear_velocity = mob.linear_velocity.rotated(direction)
public void OnMobTimerTimeout()
{
    // Choose a random location on Path2D.
    var mobSpawnLocation = GetNode<PathFollow2D>("MobPath/MobSpawnLocation");
    mobSpawnLocation.SetOffset(_random.Next());

    // Create a Mob instance and add it to the scene.
    var mobInstance = (RigidBody2D)Mob.Instance();
    AddChild(mobInstance);

    // Set the mob's direction perpendicular to the path direction.
    float direction = mobSpawnLocation.Rotation + Mathf.Pi / 2;

    // Set the mob's position to a random location.
    mobInstance.Position = mobSpawnLocation.Position;

    // Add some randomness to the direction.
    direction += RandRange(-Mathf.Pi / 4, Mathf.Pi / 4);
    mobInstance.Rotation = direction;

    // Choose the velocity.
    mobInstance.SetLinearVelocity(new Vector2(RandRange(150f, 250f), 0).Rotated(direction));
}

重要

在需要角度的函数中,GDScript使用 弧度,而不是角度。如果您更喜欢使用角度,则需要使用 deg2rad()rad2deg() 函数在角度和弧度之间进行转换。

HUD

我们的游戏需要的最后一块是UI:一个界面,用于显示得分、“游戏结束”消息、和重新启动按钮。创建一个新场景,并添加一个名为 HUDCanvasLayer 节点。HUD 表示 平视显示(heads-up display),这是一种信息显示,显示为游戏视图顶部的叠加层。

CanvasLayer 节点允许我们在游戏其余部分上面的层上绘制UI元素, 以便其显示的信息不会被任何游戏元素覆盖,比如 Playermobs

HUD显示以下信息:

  • 得分,由 ScoreTimer 更改。
  • 一条消息,例如 Game OverGet Ready!
  • 一个 Start 按钮来开始游戏。

UI元素的基本节点是 Control。要创造UI,我们会使用 Control 的两种节点: LabelButton

创建以下节点作为 HUD 的子节点:

  • Label 命名为 ScoreLabel
  • Label 命名为 MessageLabel
  • Button 命名为 StartButton
  • Timer 命名为 MessageTimer

点击 ScoreLabel 并在属性检查器的 Text 字段中键入一个数字。Control 节点的默认字体很小,不能很好地缩放。游戏素材中包含一个名为 Xolonium-Regular.ttf 的字体文件。要使用此字体,请对三个 Control 节点中的每一个执行以下操作:

  1. 自定义字体 的下拉选项中,选择 新建DynamicFont
../../_images/custom_font1.png
  1. 点击您添加的 DynamicFont,然后在 Font/Font Data 的下拉选项中选择 Load 并选择 Xolonium-Regular.ttf 文件。您还必须设置字体的 Size 。设置为 64 就可以了。
../../_images/custom_font2.png

注解

锚点和边距Control 节点不仅有位置和大小,也有锚点和边距。锚点定义了原点——节点边缘的参考点。当您移动或调整 Control 节点大小时,边距会自动更新。它们表示从 Control 节点的边缘到其锚点的距离。详情请参阅 使用 Control 节点设计界面

按如下图所示排列节点。点击“布局”按钮以设置 一个Control 节点的布局:

../../_images/ui_anchor.png

您可以拖动节点以手动放置它们,或者要进行更精确的放置,请使用以下设置:

ScoreLabel

  • Text : 0
  • 布局: “顶部宽度”
  • 对齐: “居中”

MessageLabel

  • 文本Dodge the Creeps!
  • 布局: “水平中心宽”
  • 对齐: “居中”
  • 自动换行:“开”

StartButton

  • 文本Start
  • 布局: “中心底部”
  • 边距
    • 顶部: -200
    • 底部: -100

现将这个脚本添加到 HUD

extends CanvasLayer

signal start_game
public class HUD : CanvasLayer
{
    // Don't forget to rebuild the project so the editor knows about the new signal.

    [Signal]
    public delegate void StartGame();
}

start_game 信号通知 Main 节点,按钮已经被按下。

func show_message(text):
    $MessageLabel.text = text
    $MessageLabel.show()
    $MessageTimer.start()
public void ShowMessage(string text)
{
    var messageLabel = GetNode<Label>("MessageLabel");
    messageLabel.Text = text;
    messageLabel.Show();

    GetNode<Timer>("MessageTimer").Start();
}

当我们想要显示一条临时消息时,比如 Get Ready,就会调用这个函数。在 MessageTimer 中,将 Wait Time 设置为 2 并将 One Shot 属性设置为 On

func show_game_over():
    show_message("Game Over")

    yield($MessageTimer, "timeout")

    $MessageLabel.text = "Dodge the\nCreeps!"
    $MessageLabel.show()

    yield(get_tree().create_timer(1), "timeout")

    $StartButton.show()
async public void ShowGameOver()
{
    ShowMessage("Game Over");

    var messageTimer = GetNode<Timer>("MessageTimer");
    await ToSignal(messageTimer, "timeout");

    var messageLabel = GetNode<Label>("MessageLabel");
    messageLabel.Text = "Dodge the\nCreeps!";
    messageLabel.Show();

    GetNode<Button>("StartButton").Show();
}

Player 输掉时调用这个函数。它将显示 Game Over 2秒,然后返回标题屏幕并显示 Start 按钮。

注解

当您需要暂停很短的时间时,使用 Timer 节点的替代方法是使用场景树的 create_timer() 函数。这对于延迟非常有用,例如在上面的代码中,在这里我们需要等待一点时间,然后再显示 开始 按钮。

func update_score(score):
    $ScoreLabel.text = str(score)
public void UpdateScore(int score)
{
    GetNode<Label>("ScoreLabel").Text = score.ToString();
}

每当分数改变,这个函数会被 Main 调用。

连接 MessageTimertimeout() 信号和 StartButtonpressed() 信号。

func _on_StartButton_pressed():
    $StartButton.hide()
    emit_signal("start_game")

func _on_MessageTimer_timeout():
    $MessageLabel.hide()
public void OnStartButtonPressed()
{
    GetNode<Button>("StartButton").Hide();
    EmitSignal("StartGame");
}

public void OnMessageTimerTimeout()
{
    GetNode<Label>("MessageLabel").Hide();
}

将HUD场景连接到Main场景

现在我们完成了创建 HUD 场景,保存并返回 Main 场景。和 Player 场景的做法一样,在 Main 场景中实例化 HUD 场景, 并把它放置到场景树的底部。完整的场景树看起来应该像这样,所以确保您没有错过任何东西:

../../_images/completed_main_scene.png

现在我们需要将 HUD 功能与我们的 Main 脚本连接起来。这需要在 Main 场景中添加一些内容:

在 Node 选项卡中, 将HUD的 start_game 信号连接到主节点的 new_game() 函数。

new_game() 函数中, 更新分数显示并显示 Get Ready 消息:

$HUD.update_score(score)
$HUD.show_message("Get Ready")
var hud = GetNode<HUD>("HUD");
hud.UpdateScore(_score);
hud.ShowMessage("Get Ready!");

game_over() 中我们需要调用相应的 HUD 函数:

$HUD.show_game_over()
GetNode<HUD>("HUD").ShowGameOver();

最后,将下面的代码添加到 _on_ScoreTimer_timeout() 以保持不断变化的分数的同步显示:

$HUD.update_score(score)
GetNode<HUD>("HUD").UpdateScore(_score);

现在您可以开始游戏了!点击 开始项目 按钮。将要求您选择一个主场景,因此选择 Main.tscn

删除旧的小怪

如果你一直玩到 游戏结束,然后重新开始新游戏,上次游戏的小怪仍然显示在屏幕上。假如它们在新游戏开始时被清除,我们的游戏就会更完善。

我们将使用 HUD 节点已经发出的 start_game 信号来移除剩余的小怪。我们无法使用编辑器按照需要的方式将信号连接到 Mob,因为在运行游戏之前,Main 场景树中没有 Mob 节点。 因此,我们将使用代码。

首先向 Mob.gd 添加一个新函数。queue_free() 将在当前帧的末尾删除当前节点。

func _on_start_game():
    queue_free()
public void OnStartGame()
{
    QueueFree();
}

然后在 Main.gd 中,_on_MobTimer_timeout() 函数中的末尾,添加新的行。

$HUD.connect("start_game", mob, "_on_start_game")
GetNode("HUD").Connect("StartGame", mobInstance, "OnStartGame");

该行告诉新的 Mob 节点(由 mob 变量引用)通过运行它的 _on_start_game() 函数来响应 HUD 节点发出的任何 start_game 信号。

完成了

现在,我们已经完成了游戏的所有功能。以下是一些剩余的步骤,可以添加更多“果汁”以改善游戏体验。随心所欲地扩展游戏玩法。

背景

默认的灰色背景不是很吸引人,因此让我们更改其颜色。一种方法是使用 ColorRect 节点。将其设为 Main 下的第一个节点,以便将其绘制在其他节点之后。ColorRect 只有一个属性: Color。选择您喜欢的颜色,然后拖动 ColorRect 的大小,使其覆盖屏幕。

如果您有背景图片,您也可以通过使用 Sprite 节点来添加背景图片。

音效

声音和音乐可能是增加游戏体验吸引力的最有效方法。在游戏素材文件夹中,您有两个声音文件: House in a Forest Loop.ogg 用于背景音乐,而 gameover.wav 用于当玩家失败时。

添加两个 AudioStreamPlayer 节点作为 Main 的子节点。将其中一个命名为 Music,将另一个命名为 DeathSound。在每个节点选项上,点击 Stream 属性, 选择 加载,然后选择相应的音频文件。

要播放音乐,在 new_game() 函数中添加 $Music.play(),在 game_over() 函数中添加 $Music.stop()

最后, 在 game_over() 函数中添加 $DeathSound.play()

键盘快捷键

由于游戏是使用键盘控制运行的,因此如果我们也可以通过按键盘上的键来启动游戏,将非常方便。一种方法是使用 Button 节点的 快捷键 属性。

HUD 场景中,选择 StartButton,然后在属性检查器中找到其 快捷键 属性。选择 新建快捷键,然后单击 快捷键 项。将出现第二个 快捷键 属性。选择 新建InputEventAction,然后点击新的 InputEvent。最后,在 Action 属性中,键入名称 ui_select。这是与空格键关联的默认输入事件。

../../_images/start_button_shortcut.png

Now when the start button appears, you can either click it or press Space to start the game.

项目文件

您可以在以下位置找到该项目的完整版本: