您的第一个游戏

概览

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

注解

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

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

../../_images/dodge_preview.gif

为什么是2D? 3D游戏比2D游戏复杂得多。在你充分掌握了游戏开发过程和Godot的使用方式之前,请坚持使用2D。

项目设置

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

注解

在本教程中,我们假设您已经熟悉了编辑器。如果您还没有阅读 场景与节点 ,请先阅读,学习如何设置项目与编辑器的使用方式。

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

同时,在拉伸“Stretch”选项下,将模式 Mode 设置为“2d”,将纵横比 Aspect 设置为“keep”。这会确保游戏在不同大小的屏幕上的缩放一致。

组织项目

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

../../_images/filesystem_dock.png

Player 场景

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

节点结构

首先,我们需要为player对象选择一个根节点。作为一般规则,场景的根节点应该反映对象所需的功能-对象*是什么*。单击“其他节点”按钮并将:ref:`Area2D<class_Area2D>`节点添加到场景中。

../../_images/add_node.png

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

使用 Area2D 可以检测到与玩家重叠或进入玩家内的物体。通过双击节点名称将其名称更改为 Player。我们已经设置好了场景的根节点,现在可以向该角色中添加其他节点来增加功能。

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

../../_images/lock_children.png

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

注解

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

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

精灵动画

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

../../_images/spriteframes_panel.png

左边是一个动画列表。点击 "defalult"动画,并将其重命名为 "walk"。然后点击 "新动画"按钮,创建另一个名为 "up "的动画。在 "文件系统 "选项卡中找到player图像-——它们应该在你之前解压的 art 文件夹中。将每个动画的两张图像,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)——默认情况下玩家不应该移动。然后我们检查每个输入并从 velocity(速度) 中进行加/减以获得总方向。例如,如果您同时按住 right(向右)down(向下),则生成的 velocity(速度) 速度将为 (1, 1)。在这种情况下,由于我们同时向水平和垂直两个方向进行移动,因此玩家斜向移动的速度将会比水平移动要 更快

只要对速度进行 归一化(normalize),就可以防止这种情况,也就是将速度的 长度(length) 设置为 1 ,然后乘以想要的速度。这样就不会有过快的斜向运动了。

小技巧

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

我们也要检查玩家是否在移动,以便在AnimatedSprite上调用 play()stop()

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

小技巧

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

现在我们有了一个运动方向,我们可以更新玩家的位置了。我们也可以使用 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)并确认您能够在屏幕中沿任一方向移动玩家。

警告

如果在“调试器(Debugger)”面板中出现错误:

Attempt to call function 'play' in base 'null instance' on a null instance

则可能意味着您拼错了 AnimatedSprite节点的名称。节点名称区分大小写,并且 $NodeNameget_node("NodeName") 必须与您在场景树中看到的名称匹配。

选择动画

Now that the player can move, we need to change which animation the AnimatedSprite is playing based on its direction. We have the "walk" animation, which shows the player walking to the right. This animation should be flipped horizontally using the flip_h property for left movement. We also have the "up" animation, which should be flipped vertically with flip_v for downward movement. Let's place this code at the end of the _process() function:

if velocity.x != 0:
    $AnimatedSprite.animation = "walk"
    $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 = "walk";
    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;
}

注解

The boolean assignments in the code above are a common shorthand for programmers. Since we're doing a comparison test (boolean) and also assigning a boolean value, we can do both at the same time. Consider this code versus the one-line boolean assignment above:

if velocity.x < 0:
    $AnimatedSprite.flip_h = true
else:
    $AnimatedSprite.flip_h = false
if (velocity.x < 0)
{
    animatedSprite.FlipH = true;
}
else
{
    animatedSprite.FlipH = false;
}

再次播放场景并检查每个方向上的动画是否正确。

小技巧

这里一个常见的错误是把动画的名字打错了。“SpriteFrames”面板中的动画名称必须与在代码中键入的内容匹配。如果将动画命名为``"Walk"``,则还必须在代码中使用大写字母“W”。

当您确定移动正常工作时,请将此行添加到 _ready(),以便 Player 在游戏开始时会被隐藏:

hide()
Hide();

准备碰撞

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

Add the following at the top of the script, after 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

Notice our custom "hit" signal is there as well! Since our enemies are going to be RigidBody2D nodes, we want the body_entered(body: Node) signal. This signal will be emitted when a body contacts the player. Click "Connect.." and the "Connect a Signal" window appears. We don't need to change any of these settings so click "Connect" again. Godot will automatically create a function in your player's script.

../../_images/player_signal_connection.png

请注意函数名旁的绿色图标,这表示信号已经连接到这个函数。将以下代码添加到函数体中:

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 信号。

注解

Disabling the area's collision shape can cause an error if it happens in the middle of the engine's collision processing. Using set_deferred() tells Godot to wait to disable the shape until it's safe to do so.

最后再为``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 场景

Now it's time to make the enemies our player will have to dodge. Their behavior will not be very complex: mobs will spawn randomly at the edges of the screen, choose a random direction, and move in a straight line.

We'll create a Mob scene, which we can then instance to create any number of independent mobs in the game.

注解

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

节点设置

点击场景 -> 新建场景然后添加以下节点:

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

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

../../_images/set_collision_mask.png

Set up the AnimatedSprite like you did for the player. This time, we have 3 animations: fly, swim, and walk. There are two images for each animation in the art folder.

将所有动画的速度(FPS)修改为3。

../../_images/mob_animations.gif

Set the Playing property in the Inspector to “On”.

我们将从中随机选择一个动画,以便产生不同的动态物件。

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

As in the Player scene, add a CapsuleShape2D for the collision. To align the shape with the image, you'll need to set the Rotation Degrees property to 90 (under "Transform" in the Inspector).

保存该场景。

敌人的脚本

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

extends RigidBody2D

export var min_speed = 150  # Minimum speed range.
export var max_speed = 250  # Maximum speed range.
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.

}

When we spawn a mob, we'll pick a random value between min_speed and max_speed for how fast each mob will move (it would be boring if they were all moving at the same speed).

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

func _ready():
    var mob_types = $AnimatedSprite.frames.get_animation_names()
    $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()
{
    var animSprite = GetNode<AnimatedSprite>("AnimatedSprite");
    var mobTypes = animSprite.Frames.GetAnimationNames();
    animSprite.Animation = mobTypes[_random.Next(0, mobTypes.Length)];
}

首先,我们从AnimatedSprite的``frames``读取所有动画的名称列表。这个属性会返回一个数组,该数组包含三个元素:["walk", "swim", "fly"]

然后我们需要在 02 之间选取一个随机的数字,以在列表中选择一个名称(数组索引以 0 起始)。randi() % n 会在 0 and n-1 之中选择一个随机整数。

注解

You must use randomize() if you want your sequence of "random" numbers to be different every time you run the scene. We're going to use randomize() in our Main scene, so we won't need it here.

The last piece is to make the mobs delete themselves when they leave the screen. Connect the screen_exited() signal of the VisibilityNotifier2D node and add this code:

func _on_VisibilityNotifier2D_screen_exited():
    queue_free()
public void OnVisibilityNotifier2DScreenExited()
{
    QueueFree();
}

这样就完成了 Mob 场景。

Main 场景

Now it's time to bring it all together. Create a new scene and add a Node named Main. Ensure you create a Node, not a Node2D. Click the "Instance" button and select your saved 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" and "Use Snap" are both selected. These options can be found to the left of the "Lock" button, appearing as a magnet next to some dots and intersecting lines, respectively.

../../_images/grid_snap_button.png

重要

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

../../_images/draw_path2d.gif

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

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

Your scene should look like this:

../../_images/main_scene_nodes.png

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;
    }
}

单击``Main``节点,就可以在属性检查器(Inspector)的脚本变量区(Script Variables)看到``Mob``属性。

有两种方法来给这个属性赋值:

  • Drag Mob.tscn from the "FileSystem" panel and drop it in the Mob property .
  • 单击“「空」”旁边的下拉箭头按钮,选择“载入”,接着选择``Mob.tscn``。

Next, select the Player node in the Scene dock, and access the Node dock on the sidebar. Make sure to have the Signals tab selected in the Node dock.

You should see a list of the signals for the Player node. Find and double-click the hit signal in the list (or right-click it and select "Connect..."). This will open the signal connection dialog. 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 signal connection dialog and click "Connect". Add the following code to the new function, as well as a new_game function that will 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();
}

Now connect the timeout() signal of each of the Timer nodes (StartTimer, ScoreTimer , and MobTimer) to the main script. StartTimer will start the other two timers. ScoreTimer will increment the score by 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.Offset = _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.LinearVelocity = new Vector2(RandRange(150f, 250f), 0).Rotated(direction);
}

重要

Why PI? In functions requiring angles, GDScript uses radians, not degrees. If you're more comfortable working with degrees, you'll need to use the deg2rad() and rad2deg() functions to convert between the two.

Testing the scene

让我们测试这个场景,确保一切正常。将这段添加至 _ready()

func _ready():
    randomize()
    new_game()
    public override void _Ready()
    {
        NewGame();
    }
}

让我们同时指定 Main 作为我们的“主场景” —— 游戏启动时自动运行的场景。 按下“运行”按钮,当弹出提示时选择 Main.tscn

你应该可以四处移动游戏角色,看到可动对象生成,以及玩家被可动对象击中时会消失。

当你确定一切正常时,在``_ready()``中移除对``new_game()``的调用。

HUD

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

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

The HUD needs to display the following information:

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

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

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

  • Label 命名为 ScoreLabel
  • Label named Message.
  • Button 命名为 StartButton
  • Timer 命名为 MessageTimer

Click on the ScoreLabel and type a number into the Text field in the Inspector. The default font for Control nodes is small and doesn't scale well. There is a font file included in the game assets called "Xolonium-Regular.ttf". To use this font, do the following:

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

在“ ScoreLabel”上完成此操作后,可以单击DynamicFont属性旁边的向下箭头,然后选择“复制”,然后将其“粘贴”到其他两个Control节点的相同位置。

注解

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

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

../../_images/ui_anchor.png

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

ScoreLabel

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

Message

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

StartButton

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

On the MessageTimer, set the Wait Time to 2 and set the One Shot property to "On".

现将这个脚本添加到 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):
    $Message.text = text
    $Message.show()
    $MessageTimer.start()
public void ShowMessage(string text)
{
    var message = GetNode<Label>("Message");
    message.Text = text;
    message.Show();

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

This function is called when we want to display a message temporarily, such as "Get Ready".

func show_game_over():
    show_message("Game Over")
    # Wait until the MessageTimer has counted down.
    yield($MessageTimer, "timeout")

    $Message.text = "Dodge the\nCreeps!"
    $Message.show()
    # Make a one-shot timer and wait for it to finish.
    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 message = GetNode<Label>("Message");
    message.Text = "Dodge the\nCreeps!";
    message.Show();

    await ToSignal(GetTree().CreateTimer(1), "timeout");
    GetNode<Button>("StartButton").Show();
}

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

注解

When you need to pause for a brief time, an alternative to using a Timer node is to use the SceneTree's create_timer() function. This can be very useful to add delays such as in the above code, where we want to wait some time before showing the "Start" button.

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():
    $Message.hide()
public void OnStartButtonPressed()
{
    GetNode<Button>("StartButton").Hide();
    EmitSignal("StartGame");
}

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

将HUD场景连接到Main场景

Now that we're done creating the HUD scene, go back to Main. Instance the HUD scene in Main like you did the Player scene. The scene tree should look like this, so make sure you didn't miss anything:

../../_images/completed_main_scene.png

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

在“节点”选项卡中,通过在“连接信号”窗口的“接收器方法”中键入“ new_game”,将HUD的“ start_game”信号连接到主节点的“ new_game()”功能。 验证绿色的连接图标现在是否在脚本中的func 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

删除旧的小怪

If you play until "Game Over" and then start a new game right away, the creeps from the previous game may still be on the screen. It would be better if they all disappeared at the start of a new game. We just need a way to tell all the mobs to remove themselves. We can do this with the "group" feature.

在“ Mob”场景中,选择根节点,然后单击检查器旁边的“ Node”选项卡(在该位置可以找到节点的信号)。 在“信号”旁边,单击“组”,然后可以输入新的组名称,然后单击“添加”。

../../_images/group_tab.png

现在,所有生物都将属于“生物”组。 然后,我们可以将以下行添加到Main中的game_over()函数中:

get_tree().call_group("mobs", "queue_free")
GetTree().CallGroup("mobs", "queue_free");

call_group()函数在组中的每个节点上调用命名函数-在这种情况下,我们告诉每个生物都将其删除。

完成了

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

背景

The default gray background is not very appealing, so let's change its color. One way to do this is to use a ColorRect node. Make it the first node under Main so that it will be drawn behind the other nodes. ColorRect only has one property: Color. Choose a color you like and select "Layout" -> "Full Rect" so that it covers the screen.

You could also add a background image, if you have one, by using a TextureRect node instead.

音效

声音和音乐可能是增加游戏体验吸引力的最有效方法。在游戏素材文件夹中,您有两个声音文件: 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()

Keyboard shortcut

Since the game is played with keyboard controls, it would be convenient if we could also start the game by pressing a key on the keyboard. We can do this with the "Shortcut" property of the Button node.

In the HUD scene, select the StartButton and find its Shortcut property in the Inspector. Select "New Shortcut" and click on the "Shortcut" item. A second Shortcut property will appear. Select "New InputEventAction" and click the new "InputEventAction". Finally, in the Action property, type the name ui_select. This is the default input event associated with the spacebar.

../../_images/start_button_shortcut.png

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

项目文件

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