您的第一个游戏

概览

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

注解

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

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

../../_images/dodge_preview.gif

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

项目设置

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

注解

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

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

在本节中,在"拉伸"选项下,将"Mode"设置为"2d",将"Aspect"设置为"keep".这确保了游戏在不同大小的屏幕上的缩放一致.

组织项目

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

../../_images/filesystem_dock.png

Player 场景

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

节点结构

首先,我们需要为player对象选择一个根节点.作为一般规则,场景的根节点应该反映对象所需的功能——对象*是什么*.单击"其他节点"按钮并将 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 [尝试在基类为'null实例'的null实例中调用'play'函数]

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

选择动画

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

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

注解

上面代码中的布尔赋值是程序员常用的缩写.在做布尔比较同时,同时可 一个布尔值.参考这段代码与上面的单行布尔赋值:

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的 信号 功能来使其正常工作.

在脚本开头, 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(body: Node) 信号,当物体接触到玩家时,就会发出这个信号.点击 "连接...",出现 "连接一个信号 "窗口,不需要改变这些设置,再次点击 "连接",Godot会自动在你的玩家脚本中创建一个函数.

../../_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 信号.

注解

如果在引擎的碰撞处理过程中发生,禁用区域的碰撞形状可能会导致错误.使用 set_delayed() 告诉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 场景

是时候去做一些玩家必须躲避的敌人了.它们的行为很简单:怪物将随机生成在屏幕的边缘,沿着随机的方向直线移动.

我们将创建一个 Mob 的怪物场景,以便在游戏中独立 实例化 出任意数量的怪物.

注解

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

节点设置

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

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

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

../../_images/set_collision_mask.png

像设置玩家一样设置 AnimatedSprite.这一次, 我们有3个动画: flyswimwalk ,每个动画在art文件夹中都有2张图片.

对于所有动画,将"速度(FPS)"调整为 3 .

../../_images/mob_animations.gif

将属性检查器中的 Playing 属性设置为"On".

我们将随机选择其中一个动画,以便mobs有一些变化.

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

像在 Player 场景中一样,为碰撞添加一个 CapsuleShape2D .为了使形状与图像对齐,您需要将 Rotation Degrees (在属性检查器的"Transorm"下)属性设置为 90.

保存该场景.

敌人的脚本

将脚本添加到 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.

}

当我们生成怪物时,我们将在 min_speedmax_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 之中选择一个随机整数.

注解

如果希望每次运行场景时生成的"随机数"都不同,则必须使用 randomize() .我们将在 Main 场景中使用 randomize() ,因此在这里不需要添加.

最后一步是让怪物在超出屏幕时删除自己.连接 VisibilityNotifier2D 节点的 screen_exited() 信号并添加以下代码:

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

这样就完成了 Mob 场景.

Main 场景

现在是时候将它们整合在一起了.创建新场景并添加一个 Node 节点,命名为 Main .注意,确保你创建的是Node 而不是 Node2D.点击"实例化"按钮,然后选择保存的 Player.tscn.

../../_images/instance_scene.png

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

  • Timer (名为 MobTimer)——控制怪物产生的频率

  • Timer (名为 ScoreTimer)——每秒增加分数

  • Timer (名为 StartTimer)——在开始之前给出延迟

  • Position2D (名为 StartPosition) - 表示玩家的起始位置

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

  • MobTimer: 0.5

  • ScoreTimer: 1

  • StartTimer: 2

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

生成怪物

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

../../_images/path2d_buttons.png

选择中间的按钮( 添加点 ),然后通过点击给四角添加点来绘制路径.要使点吸附到网格,请确保同时选中"使用吸附"和"使用网格吸附".该选项可以在"锁定"按钮左侧找到,图标为一个磁铁加三个点或一些网格线.

../../_images/grid_snap_button.png

重要

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

../../_images/draw_path2d.gif

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

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

您的场景应如下所示:

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

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

  • 从"文件系统"面板中拖动 Mob.tscnMob 属性中.

  • 单击"「空」"旁边的下拉箭头按钮,选择"载入",接着选择 Mob.tscn .

在场景树中选择 Player 节点,然后选择 节点(Node) 选项卡(位于右侧属性旁),确保已选择 信号(Signals) .

你可以看到 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 节点( StartTimerScoreTimerMobTimer )的 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.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);
}

重要

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

测试场景

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

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

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

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

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

HUD

最后我们的游戏需要的是一个UI:一个显示诸如分数、 "游戏结束" 消息和重启按钮的界面.创建一个新的场景,并添加一个 CanvasLayer 节点,命名为 HUD . "HUD " 代表 "平视显示" ,是一种信息显示,以叠加的方式出现在游戏视图之上.

CanvasLayer 节点可以让我们在游戏的其他部分之上的一层绘制UI元素,这样它所显示的信息就不会被任何游戏元素(如玩家或暴徒)所覆盖.

HUD需要显示以下信息:

  • 得分,由 ScoreTimer 更改.

  • 一条消息,例如 Game OverGet Ready!

  • 一个 Start 按钮来开始游戏.

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

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

  • 名为 ScoreLabelLabel .

  • 名为 MessageLabel .

  • 名为 StartButtonButton .

  • 名为 MessageTimerTimer .

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

  1. 在 "Custom Fonts" 的下拉选项中,选择 新建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 节点的布局:

../../_images/ui_anchor.png

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

ScoreLabel

  • 布局: "顶部宽度"

  • Text : 0

  • 对齐: "居中"

Message

  • 布局: "水平中心宽"

  • 文本: Dodge the Creeps!

  • 对齐: "居中"

  • 自动换行:"开"

StartButton

  • 文本: Start

  • 布局: "中心底部"

  • 边距:

    • 顶部: -200

    • 底部: -100

MessageTimer 中,将 Wait Time 设置为 2 并将 One Shot 属性设置为 "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();
}

当我们想要显示一条临时消息时,比如 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 按钮.

注解

当您需要暂停片刻时,可以使用场景树的 create_timer() 函数替代使用 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():
    $Message.hide()
public void OnStartButtonPressed()
{
    GetNode<Button>("StartButton").Hide();
    EmitSignal("StartGame");
}

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

将HUD场景连接到Main场景

现在我们完成了 HUD 场景,保存并返回 Main 场景.和 Player 场景的做法一样,在 Main 场景中实例化 HUD 场景.完整的场景树看起来应该像这样,确保您没有错过任何东西:

../../_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.

删除旧的小怪

如果你一直玩到"游戏结束",然后重新开始新游戏,上局游戏的小怪仍然显示在屏幕上.更好的做法是在新游戏开始时清除它们.我们需要一个同时让*所有*小怪删除它自己的方法,为此可以使用"分组"功能.

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

../../_images/group_tab.png

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

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

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

完成了

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

背景

默认的灰色背景不是很吸引人,因此让我们更改其颜色.一种方法是使用 ColorRect 节点.将其设为 Main 下的第一个节点,以便将其绘制在其他节点之后. ColorRect 只有一个属性: Color .选择您喜欢的颜色,然后选择"布局"->"整个矩形"(位于主窗口上方工具条),使其覆盖屏幕.

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

音效

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

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

../../_images/start_button_shortcut.png

现在,当开始按钮出现时,您可以点击它或按 Space 来启动游戏.

项目文件

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