您的第一个游戏¶
概览¶
本教程将指导您完成第一个Godot项目。您将学习Godot编辑器的工作原理、如何构建项目、以及如何构建2D游戏。
注解
该项目是Godot引擎的一个介绍。它假定您已经有一定的编程经验。如果您完全不熟悉编程,则应从这里开始: 编写脚本。
这个游戏叫做 Dodge the Creeps!
。您的角色必须尽可能长时间移动并避开敌人。这是最终结果的预览:

为什么是2D? 3D游戏比2D游戏复杂得多。在你充分掌握了游戏开发过程和Godot的使用方式之前,请坚持使用2D。
项目设置¶
启动Godot并创建一个新项目。然后,下载 dodge_assets.zip
——用于制作这个游戏的图像和声音。将这些文件解压缩到您的项目文件夹中。
注解
在本教程中,我们假设您已经熟悉了编辑器。如果您还没有阅读 场景与节点 ,请先阅读,学习如何设置项目与编辑器的使用方式。
这个游戏使用竖屏模式,所以我们需要调整游戏窗口的大小。点击项目->项目设置->显示->窗口,设置“宽度”为 480
,“高度”为 720
。
在本节中,在“拉伸”选项下,将“Mode”设置为“2d”,将“Aspect”设置为“keep”。这确保了游戏在不同大小的屏幕上的缩放一致。
组织项目¶
在这个项目中,我们将制作3个独立的场景:Player
、 Mob
、和 HUD
,之后将它们组合到游戏的 Main
场景中。在较大的项目中,创建文件夹来保存各种场景及其脚本可能会很有用,但是对于这个相对较小的游戏,你可以将场景和脚本保存在项目的根文件夹 res://
。您可以在左下角的文件系统停靠面板中看到您的项目文件夹:

Player 场景¶
第一个场景,我们会定义 Player
对象。单独创建Player场景的好处之一是,在游戏的其他部分做出来之前,我们就可以对其进行单独测试。
节点结构¶
首先,我们需要为player对象选择一个根节点。作为一般规则,场景的根节点应该反映对象所需的功能-对象*是什么*。单击“其他节点”按钮并将:ref:`Area2D<class_Area2D>`节点添加到场景中。

Godot将在场景树中的节点旁边显示警告图标。你现在可以忽略它。我们稍后再谈。
使用 Area2D
可以检测到与玩家重叠或进入玩家内的物体。通过双击节点名称将其名称更改为 Player
。我们已经设置好了场景的根节点,现在可以向该角色中添加其他节点来增加功能。
在将任何子级添加到 Player
节点之前,我们要确保不会通过点击它们而意外地移动它们或调整其大小。选择节点,然后点击锁右侧的图标;它的工具提示显示 确保对象的子级不可选择。

保存场景。点击场景 -> 保存,或者在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
面板:

左边是一个动画列表。点击 "defalult"动画,并将其重命名为 "walk"。然后点击 "新动画"按钮,创建另一个名为 "up "的动画。在 "文件系统 "选项卡中找到player图像-——它们应该在你之前解压的 art
文件夹中。将每个动画的两张图像,playerGrey_up[1/2]``和``playerGrey_walk[1/2]
,拖到对应动画的面板的 "动画帧 "处:

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

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

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

修改完成后请确保再次保存场景。
移动 Player
¶
现在我们需要添加一些内置节点所不具备的功能,因此要添加一个脚本。点击 Player
节点然后点击 附加脚本
按钮:

在脚本设置窗口中,您可以维持默认设置。点击 创建
即可:
注解
如果您要创建一个C#脚本或者其他语言的脚本,那就在创建之前在 语言 下拉菜单中选择语言。

注解
如果这是您第一次使用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面板,然后单击“ 构建项目”按钮,即可手动触发此构建。

当节点进入场景树时,_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)”面板中出现错误
``尝试在null实例的基类'null实例'中调用'play'函数
则可能意味着您拼错了 AnimatedSprite节点的名称。节点名称区分大小写,并且 $NodeName
或 get_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
可以发出的信号列表:

请注意自定义的 "hit "信号也在存在!由于敌人将是``RigidBody2D``节点,所以需要``body_entered(body: Node)``信号,当物体接触到玩家时,就会发出这个信号。点击 "连接...",出现 "连接一个信号 "窗口,不需要改变这些设置,再次点击 "连接",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_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
属性并去除第一个复选框的勾选。这会确保怪物不会彼此碰撞。

像设置玩家一样设置 AnimatedSprite。这一次, 我们有3个动画: fly
、 swim``和 ``walk
,每个动画在art文件夹中都有2张图片。
对于所有动画,将“速度(FPS)”调整为“3”。

将属性检查器中的 Playing
属性设置为“On”。
我们将随机选择其中一个动画,以便mobs有一些变化。
像 Player
图像一样,这些怪物的图像也要缩小。设置 AnimatedSprite
的 Scale
属性为 (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_speed
和 max_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"]
。
然后我们需要在 0
和 2
之间选取一个随机的数字,以在列表中选择一个名称(数组索引以 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
。

现在,将以下节点添加为 Main
的子节点,并按如下所示对其进行命名(值以秒为单位):
Timer (名为
MobTimer
)——控制怪物产生的频率Timer (名为
ScoreTimer
)——每秒增加分数Timer (名为
StartTimer
)——在开始之前给出延迟Position2D (名为
StartPosition
) - 表示玩家的起始位置
如下设置每个 Timer
节点的 Wait Time
属性:
MobTimer
:0.5
ScoreTimer
:1
StartTimer
:2
此外,将 StartTimer
的 One Shot
属性设置为 On
,并将 StartPosition
节点的 Position
设置为 (240, 450)
。
生成怪物¶
Main
节点将产生新的生物,我们希望它们出现在屏幕边缘的随机位置。添加一个名为 MobPath
的 Path2D 节点作为 Main
的子级。当您选择 Path2D
时,您将在编辑器顶部看到一些新按钮:

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

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

在图像上放置点 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;
}
}
单击``Main``节点,就可以在属性检查器(Inspector)的脚本变量区(Script Variables)看到``Mob``属性。
有两种方法来给这个属性赋值:
从“文件系统”面板中拖动
Mob.tscn
到Mob
属性中。单击“「空」”旁边的下拉箭头按钮,选择“载入”,接着选择``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
、 ScoreTimer
和 MobTimer
)的 timeout()
信号连接到 main
脚本。StartTimer
将启动其他两个计时器。ScoreTimer
将使得分以1的增速增加。
func _on_StartTimer_timeout():
$MobTimer.start()
$ScoreTimer.start()
func _on_ScoreTimer_timeout():
score += 1
public void OnStartTimerTimeout()
{
GetNode<Timer>("MobTimer").Start();
GetNode<Timer>("ScoreTimer").Start();
}
public void OnScoreTimerTimeout()
{
_score++;
}
在 _on_MobTimer_timeout()
中,我们将创建一个 mob
实例,沿着 Path2D
随机选择一个起始位置,然后让 mob
移动。PathFollow2D
节点将沿路径移动,因此会自动旋转,所以我们将使用它来选择怪物的方向及其位置。
注意,必须使用 add_child()
将新实例添加到场景中。
func _on_MobTimer_timeout():
# Choose a random location on Path2D.
$MobPath/MobSpawnLocation.offset = randi()
# Create a Mob instance and add it to the scene.
var mob = Mob.instance()
add_child(mob)
# Set the mob's direction perpendicular to the path direction.
var direction = $MobPath/MobSpawnLocation.rotation + PI / 2
# Set the mob's position to a random location.
mob.position = $MobPath/MobSpawnLocation.position
# Add some randomness to the direction.
direction += rand_range(-PI / 4, PI / 4)
mob.rotation = direction
# Set the velocity (speed & direction).
mob.linear_velocity = Vector2(rand_range(mob.min_speed, mob.max_speed), 0)
mob.linear_velocity = mob.linear_velocity.rotated(direction)
public void OnMobTimerTimeout()
{
// Choose a random location on Path2D.
var mobSpawnLocation = GetNode<PathFollow2D>("MobPath/MobSpawnLocation");
mobSpawnLocation.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 " 代表 "平视显示" ,是一种信息显示,以叠加的方式出现在游戏视图之上。
:ref:`CanvasLayer <class_CanvasLayer>`节点可以让我们在游戏的其他部分之上的一层绘制UI元素,这样它所显示的信息就不会被任何游戏元素(如玩家或暴徒)所覆盖。
HUD需要显示以下信息:
得分,由
ScoreTimer
更改。一条消息,例如
Game Over
或Get Ready!
一个
Start
按钮来开始游戏。
UI元素的基本节点是 Control。要创造UI,我们会使用 Control 的两种节点: Label 和 Button 。
创建以下节点作为 HUD
的子节点:
点击 ScoreLabel
并在属性检查器的 Text
字段中键入一个数字。Control
节点的默认字体很小,不能很好地缩放。游戏素材中包含一个字体文件( Xolonium-Regular.ttf
)。要使用此字体,需要执行以下操作:
在 “Custom Fonts” 的下拉选项中,选择
新建DynamicFont

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

在“ ScoreLabel”上完成此操作后,可以单击DynamicFont属性旁边的向下箭头,然后选择“复制”,然后将其“粘贴”到其他两个Control节点的相同位置。
注解
锚和边距: 控制
节点有一个位置和大小,但它们也有锚和边距。锚点定义了原点与节点边缘的参考点。当你移动或调整控制节点的大小时,边距会自动更新。它们表示从控制节点的边缘到其锚点的距离。更多细节请参见 使用 Control 节点设计界面。
按如下图所示排列节点。点击“布局”按钮以设置 一个Control 节点的布局:

您可以拖动节点以手动放置它们,或者要进行更精确的放置,请使用以下设置:
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
调用。
连接 MessageTimer
的 timeout()
信号和 StartButton
的 pressed()
信号并添加以下代码到新函数中:
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
场景。完整的场景树看起来应该像这样,确保您没有错过任何东西:

现在我们需要将 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”选项卡(在该位置可以找到节点的信号)。 在“信号”旁边,单击“分组”,然后可以输入新的组名称,然后单击“添加”。

现在,所有生物都将属于“生物”组。 然后,我们可以将以下行添加到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 属性。选择 新建Shortcut
,然后单击 Shortcut
项。将出现第二个 Shortcut 属性。选择 新建InputEventAction
,然后点击刚创建的 InputEventAction
。最后,在 Action 属性中,键入名称 ui_select
。这是与空格键关联的默认输入事件。

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