您的第一个游戏

概览

本教程将指导您完成第一个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

保存场景。点击场景 -> 保存,或者在Windows/Linux平台上按下 Ctrl+S 或是Mac上按下 Command+S

注解

对于此项目,我们将遵循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

Select the middle one (“Add Point”) and draw the path by clicking to add the points at the corners shown. To have the points snap to the grid, make sure “Use Grid Snap” is selected. This option can be found to the left of the “Lock” button, appearing as a magnet next to some intersecting lines.

../../_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 属性中。

接下来,点击 Player 并连接 hit 信号。我们要创建一个名为 game_over 的新函数,该函数将处理游戏结束时需要发生的事情。在 连接信号 窗口底部的 节点中的方法 框中键入 game_over。添加以下代码,以及 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.set_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

现在,当开始按钮出现时,您可以点击它或按空格键来启动游戏。

项目文件

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