您的第一个游戏

概览

本教程将指导您完成第一个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个独立的场景:Player, Mob, 和 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_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 场景

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

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

注解

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

节点设置

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

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

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

../../_images/set_collision_mask.png

像设置玩家一样设置 AnimatedSprite. 这一次, 我们有3个动画: fly , swimwalk , 每个动画在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 节点( StartTimer , ScoreTimerMobTimer )的 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!

  • 对齐: "居中"

  • 自动换行:"开"

开始按钮

  • 文本: 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 来启动游戏.

项目文件

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