忽略代码,忽略素材,忽略逻辑!
游戏的精髓是人性与思想,我一篇一篇地制作,不想动手的小伙伴看一看就可以,感受一下也不错,我们是有目的性的,这一切都是为今后的AI融合打基础,嗯,就是打基础。
政安晨的个人主页:政安晨
欢迎 👍点赞✍评论⭐收藏
希望政安晨的博客能够对您有所裨益,如有不足之处,欢迎在评论区提出指正!
目录
实践没有废话
设置项目
组织项目
创建玩家场景
节点结构
精灵动画
编写玩家代码
选择动画
准备碰撞
创建敌人
节点设置
敌人的脚本
游戏主场景
生成怪物
Main 脚本
测试场景
游戏信息显示
ScoreLabel
Message
StartButton
将 HUD 场景连接到 Main 场景
删除旧的小怪
完成了
背景
音效
键盘快捷键
实践没有废话
你将使用 Godot 创建你的第一个完整的 2D 游戏,在本篇结束时,你将拥有一个简单而完整的游戏作品。
先把游戏要用到的资源下载好(这虽然是Godot官方的一个例子,但值得我们演绎并讲解一遍):
https://github.com/godotengine/godot-docs-project-starters/releases/download/latest-4.x/dodge_the_creeps_2d_assets.ziphttps://github.com/godotengine/godot-docs-project-starters/releases/download/latest-4.x/dodge_the_creeps_2d_assets.zip
(政安晨在演绎这篇游戏制作的时候,将会忽略一些我认为不太重要的细节,仅关键点拎出来,让小伙伴们酣畅淋漓地读完,嘻嘻。)
建立一个项目文件夹,把刚才下载的资源拷贝进去并解压,比如我这个项目称为gdgame02,当然,以后是需要用git等工具来管理游戏代码的,那是以后的事,我们先开始,逐步建立您的工作流,本质的东西往往都是最简单的,别听很多人瞎忽悠,为了让自己有饭吃,刻意铸造壁垒墙。
素材+字体,这就是开发这款游戏所用到的全部东西了,没啥稀奇滴!
设置项目
启动 Godot 然后新建一个项目。
把刚才下载的资源拷贝到新建的项目文件夹中:
这个游戏是针对竖屏模式设计的,所以我们需要调整游戏窗口的大小。
点击项目 -> 项目设置打开项目设置窗口,然后在左栏中打开显示 -> 窗口选项卡,将“视口宽度”设置为 480
,并将“视口高度”设置为 720
。
另外,滚动到该小节的底部,在拉伸选项中,将模式设置为 canvas_items
,将比例设置为 keep
。这样就可以保证在不同大小的屏幕上,游戏都能够进行一致的比例缩放。
这个设置在屏幕适应上非常有用。
组织项目
在这个项目中,我们将制作 3 个独立的场景:Player
、Mob
以及 HUD
,我们将把这些场景合并成游戏的 Main
场景。
在更大的项目中,为各个场景及对应的脚本创建各自的文件夹会比较好。而这是一个相对小型的游戏,你可以把场景和脚本放在项目的根文件夹里,根文件夹用 res://
表示。可以在左下角的“文件系统”面板中查看项目文件夹:
一切就绪后,我们准备设计玩家场景。
创建玩家场景
项目设置到位后,我们可以开始处理玩家控制的角色。
第一个场景, 我们会定义 Player
对象. 单独创建Player场景的好处之一是, 在游戏的其他部分做出来之前, 我们就可以对其进行单独测试.
节点结构
首先,我们需要为玩家对象选择一个根节点。
一般而言,场景的根节点应该反映对象所需的功能——对象是什么?面向对象编程与面向过程编程是程序世界的两种基本思维方式。—— 概念模糊的小伙伴要仔细看一看。
单击“其他节点”按钮并将 Area2D 节点添加到场景中。
警告先放这里,咱们后续处理。
使用 Area2D
可以检测到与玩家重叠或进入玩家内的物体. 通过双击节点名称将其名称更改为 Player
. 我们已经设置好了场景的根节点, 现在可以向该角色中添加其他节点来增加功能.
在将任何子节点添加到 Player
节点之前,我们要确保不会通过点击它们来意外移动它们或调整它们的大小。选择该节点并单击锁右侧的图标。其工具提示显示“将所选节点与其子节点组合。这样在 2D 和 3D 视图中点击子节点就会选中父节点。” (选中它,呈现下图效果)
此时保存一下场景,名称自然就是Player。
对于此项目,我们将遵循 Godot 的命名约定。
GDScript:类(节点)使用 PascalCase(大驼峰命名法),变量和函数使用 snake_case(蛇形命名法),常量使用 ALL_CAPS(全大写)(请参阅 GDScript 编写风格指南)。
C#:类、导出变量和方法使用 PascalCase(大驼峰命名法),私有字段使用 _camelCase(前缀下划线的小驼峰命名法),局部变量和参数使用 camelCase(小驼峰命名法)(请参阅 C# 风格指南)。连接信号时,请务必准确键入方法名称。
精灵动画
点击 Player
节点并添加 (Ctrl + A 在 Windows/Linux 上或 Cmd + A 在 macOS 上) 一个子节点 AnimatedSprite2D。
AnimatedSprite2D
将处理我们玩家的外观和动画。注意节点旁边有一个警告符号。AnimatedSprite2D
需要一个 SpriteFrames 资源,这是它可以显示的动画列表。要创建一个,在检查器的 Animation
选项卡下找到 Sprite Frames
属性并点击 "[empty]" -> "New SpriteFrames"。点击刚刚创建的 SpriteFrames
以打开 "SpriteFrames" 面板:
在游戏的整个制作过程中,一定要注意警告符号。鼠标点上去看信息。
左边是一个动画列表。点击“defalult”动画并将其重命名为“walk”。然后点击“Add Animation”按钮,创建另一个名为“up”的动画。在“FileSystem”选项卡中找到玩家图像——它们应该在你之前解压的 art
文件夹中。将每个动画的两张图像, playerGrey_up[1/2]
和 playerGrey_walk[1/2]
,拖到对应动画的面板的“Animation Frames”处:
玩家图像对于游戏窗口来说有点过大,需要缩小它们。点击 AnimatedSprite2D
节点,可以在检查器 Node2D
标签中,将 Scale
属性设置为 (0.5, 0.5)
。
最后,在 Player
下添加一个 CollisionShape2D 作为子节点,以确定玩家的“攻击框”,或者说碰撞范围。CapsuleShape2D
节点最适合这个角色,那么就在检查器中“Shape”的旁边点击“[空]”->“新建 CapsuleShape2D”添加形状,使用两个控制柄,调整形状大小以覆盖精灵:
调整碰撞区域,让它们刚刚好覆盖精灵。
完成后, 你的 Player
场景看起来应该像这样:
此时,您会发现警告符号都没了吧,嘻嘻。修改完成后请确保再次保存场景.
编写玩家代码
我们将添加玩家的动作、动画,并将其设置为检测碰撞。
现在我们需要添加一些内置节点所不具备的功能,因此要添加一个脚本。
点击 Player
节点然后点击“附加脚本”
在脚本设置窗口中,你可以维持默认设置。点击“创建”即可:
脚本默认是GDScript,如果这是你第一次接触 GDScript,请在继续之前阅读 脚本语言。
(其实,也可以跟着我往下走 —— 政安晨)
上述代码第一行,我的Player节点是Area2D,所以继承Area2D类:
小伙伴们要有随时随地查看帮助的习惯。
extends Area2D
@export var speed = 400 # How fast the player will move (pixels/sec).
var screen_size # Size of the game window.
在第一个变量 speed
上使用 export
关键字,这样我们就可以在“检查器”中设置其值。对于希望能够像节点的内置属性一样进行调整的值,这可能很方便。
点击 Player
节点,你将看到该属性现在显示在“检查器”的“Script Variables”(脚本变量)部分。请记住,如果你在此处更改值,它将覆盖脚本中所写的值。
你的 player.gd
脚本应该已经包含一个 _ready()
和一个 _process()
函数。
如果你没有选择上面展示的默认模板,请在学习本课程的同时创建这些函数。
当节点进入场景树时,_ready()
函数被调用,这是查看游戏窗口大小的好时机:
func _ready():
screen_size = get_viewport_rect().size
现在我们可以使用 _process()
函数定义玩家将执行的操作。_process()
在每一帧都被调用,因此我们将使用它来更新我们希望会经常变化的游戏元素。对于玩家而言,我们需要执行以下操作:
检查输入。
沿给定方向移动。
播放合适的动画。
首先,我们需要检查输入——玩家是否正在按键?
对于这个游戏,我们有 4 个方向的输入要检查。输入动作在项目设置中的“输入映射”下定义。在这里,你可以定义自定义事件,并为其分配不同的按键、鼠标事件、或者其他输入。对于此游戏,我们将把方向键映射给四个方向。
点击项目 -> 项目设置打开项目设置窗口,然后单击顶部的输入映射选项卡。在顶部栏中键入“move_right”,然后单击“添加”按钮以添加该 move_right
动作。
我们需要为这个操作分配一个按键。单击右侧的“+”图标,打开事件管理器窗口。
会自动选中“监听输入...”区域。按下键盘上的“右方向”键,菜单应该像这样。
选择“确定”按钮。现在“右方向”键与 move_right
动作关联了。
重复这些步骤以再添加三个映射:
-
move_left
映射到左箭头键。 -
move_up
映射到向上箭头键。 -
move_down
映射到向下箭头键。
按键映射选项卡应该看起来类似这样:
单击“关闭”按钮关闭项目设置。
我们只将一个键映射到每个输入动作,但你可以将多个键、操纵杆按钮或鼠标按钮映射到同一个输入动作。
你可以使用 Input.is_action_pressed()
来检测是否按下了键, 如果按下会返回 true
, 否则返回 false
.
以下为代码:
func _process(delta):
var velocity = Vector2.ZERO # The player's movement vector.
if Input.is_action_pressed("move_right"):
velocity.x += 1
if Input.is_action_pressed("move_left"):
velocity.x -= 1
if Input.is_action_pressed("move_down"):
velocity.y += 1
if Input.is_action_pressed("move_up"):
velocity.y -= 1
if velocity.length() > 0:
velocity = velocity.normalized() * speed
$AnimatedSprite2D.play()
else:
$AnimatedSprite2D.stop()
我们首先将 velocity
设置为 (0, 0)
——默认情况下玩家不应该移动。然后我们检查每个输入并从 velocity
中进行加/减以获得总方向。例如,如果你同时按住 右
和 下
,则生成的 velocity
向量将为 (1, 1)
。此时,由于我们同时向水平和垂直两个方向进行移动,玩家斜向移动的速度将会比水平移动要更快。
只要对速度进行归一化就可以防止这种情况,也就是将速度的长度设置为
1
,然后乘以想要的速度。这样就不会有过快的斜向运动了。
如果你以前从未使用过向量数学,或者需要复习,可以在 Godot 中的 向量数学 上查看向量用法的解释。
另外,我们还会检查玩家是否正在移动,以便在 AnimatedSprite2D 上调用 play()
或 stop()
。
$
是get_node()
的简写。因此在上面的代码中,$AnimatedSprite2D.play()
与get_node("AnimatedSprite2D").play()
相同。在 GDScript 中,
$
返回从当前节点开始的相对路径上的节点,如果找不到该节点,则返回null
。当前 AnimatedSprite2D 是该节点子节点,因而可以使用$AnimatedSprite2D
以获取。
现在我们有了一个运动方向,我们可以更新玩家的位置了。我们也可以使用 clamp()
来防止它离开屏幕。 clamp 一个值意味着将其限制在给定范围内。将以下内容添加到 _process
函数的底部:
position += velocity * delta
position = position.clamp(Vector2.ZERO, screen_size)
另外,大家在开发游戏的过程中,还可以使用文生文大模型来辅助理解,比如对上面精灵动画的理解,大模型的回答如下,对于提高效率还是很有作用的:
$AnimatedSprite2D 可能是一种与动画精灵(Animated Sprite)相关的二维图形对象名称。通常在图形编程、游戏开发等领域中,AnimatedSprite2D 可能代表着一个可以播放动画的二维图像精灵对象,用于在屏幕上展示动态的图像效果。例如在一些游戏中,角色、怪物等可能会使用 AnimatedSprite2D 来呈现其动画效果。它可能包含了一系列的图像帧,通过按顺序播放这些帧来实现动画。具体的含义可能会根据其出现的特定技术环境和上下文而有所不同。
_process() 函数的 delta 参数是 帧长度 ——完成上一帧所花费的时间. 使用这个值的话, 可以保证你的移动不会被帧率的变化所影响.
点击“运行当前场景”(F6,macOS 上为 Cmd + R)并确认你能够在屏幕中沿任一方向移动玩家。
注:
如果在“调试器”面板中出现错误
Attempt to call function 'play' in base 'null instance' on a null instance
(尝试调用空实例在基类“空实例”上的“play”函数)这可能意味着你拼错了 AnimatedSprite2D 节点的名称。节点名称区分大小写,并且
$NodeName
必须与你在场景树中看到的名称匹配。
选择动画
现在玩家可以移动了,我们需要根据方向更改 AnimatedSprite2D 所播放的动画。我们的“walk”动画显示的是玩家向右走。向左移动时就应该使用 flip_h
属性将这个动画进行水平翻转。我们还有向上的“up”动画,向下移动时就应该使用 flip_v
将其进行垂直翻转。让我们把这段代码放在 _process()
函数的末尾:
if velocity.x != 0:
$AnimatedSprite2D.animation = "walk"
$AnimatedSprite2D.flip_v = false
# See the note below about the following boolean assignment.
$AnimatedSprite2D.flip_h = velocity.x < 0
elif velocity.y != 0:
$AnimatedSprite2D.animation = "up"
$AnimatedSprite2D.flip_v = velocity.y > 0
注:
上面代码中的布尔赋值是程序员常用的缩写. 在做布尔比较同时, 同时可 赋 一个布尔值. 参考这段代码与上面的单行布尔赋值:
if velocity.x < 0: $AnimatedSprite2D.flip_h = true else: $AnimatedSprite2D.flip_h = false
再次播放场景并检查每个方向上的动画是否正确.
当你确定移动正常工作时, 请将此行添加到 _ready()
中,在游戏开始时隐藏玩家:
hide()
准备碰撞
我们希望 Player
能够检测到何时被敌人击中, 但是我们还没有任何敌人!没关系, 因为我们将使用Godot的 信号 功能来使其正常工作.
在脚本顶部添加以下内容,请将其添加到 extends Area2D
之后。
signal hit
这定义了一个叫作“hit”的自定义信号,当玩家与敌人碰撞时,我们会让他发出这个信号。我们将使用 Area2D
来检测碰撞。选中 Player
节点,然后点击“检查器”选项卡旁边的“节点”选项卡,就可以查看玩家可以发出的信号列表:
请注意自定义的“hit”信号也在其中!
由于敌人将是 RigidBody2D
节点,所以需要 body_entered(body: Node2D)
信号。当物体接触到玩家时就会发出这个信号。点击“连接...”就会出现“连接信号”窗口。
Godot 将直接在脚本中为你创建一个具有确切名称的函数。现在你不需要更改默认设置。
一定要注意:
如果你使用外部文本编辑器(例如 Visual Studio Code),当前有一个错误会阻止 Godot 执行此操作。
你将被送到外部编辑器那边,但在那里并不会有新函数。
在这种情况下,你需要自己将该函数写入玩家的脚本文件中。
注意,绿色图标表示信号已连接到此函数;但这并不意味着该函数存在,只是信号将尝试连接到具有该名称的函数。因此请仔细检查该函数的拼写是否能完全匹配上!
接下来,将此代码添加到函数中:
func _on_body_entered(body):
hide() # Player disappears after being hit.
hit.emit()
# Must be deferred as we can't change physics properties on a physics callback.
$CollisionShape2D.set_deferred("disabled", true)
敌人每次击中 玩家时都会发出一个信号。我们需要禁用玩家的碰撞检测,确保我们不会多次触发 hit
信号。
如果在引擎的碰撞处理过程中禁用区域的碰撞形状可能会导致错误。使用 set_deferred()
告诉 Godot 等待可以安全地禁用形状时再这样做。
最后再为玩家添加一个函数,用于在开始新游戏时调用来重置玩家。
func start(pos):
position = pos
show()
$CollisionShape2D.disabled = false
至此,玩家部分的工作大致完成,下一步,该搞敌人了。
创建敌人
是时候去做一些玩家必须躲避的敌人了. 它们的行为很简单: 怪物将随机生成在屏幕的边缘, 沿着随机的方向直线移动.
我们将创建一个 Mob
的怪物场景,以便在游戏中独立实例化出任意数量的怪物。
节点设置
点击顶部菜单的“场景 -> 新建场景”,然后添加以下节点:
RigidBody2D(名为
Mob
)
AnimatedSprite2D
CollisionShape2D
VisibleOnScreenNotifier2D
别忘了设置子项,使其无法被选中,就像你对 Player 场景所做的那样。
保存场景:
如果您跟着做到这里蒙圈了,可以翻到本篇文章的前半部分,跟着做敌人这些子节点。(这也是我写这篇长文的用意,对于新手来说,一篇文章一个游戏,参照着做,很容易上手,嘻嘻。—— 政安晨)
别忘了设置子项,使其无法被选中,就像你对 Player 场景所做的那样。
选择 Mob
节点,并在检查器的 RigidBody2D 部分中把它的 Gravity Scale
属性设置为 0
。这样可以防止怪物向下坠落。
此外,在 RigidBody2D 部分下方的 CollisionObject2D 部分下,展开 Collision 分组并取消选中 Mask
属性里的 1
。这将确保怪物们不会相互碰撞。
另外一个子节点:
接下来,像设置玩家一样设置 AnimatedSprite2D。这一次,我们有 3 个动画:fly
、swim
、walk
,每个动画在 art 文件夹中都有两张图片。
必须为每个单独动画设置 动画速度
属性,将三个动画的对应动画速度值都调整为 3
。
你可以使用 动画速度
输入区域右侧的“播放动画”按钮预览动画。
我们将随机选择其中一个动画,以便小怪有一些变化。
像玩家的图像一样,这些小怪的图像也要缩小。请将 AnimatedSprite2D
的 Scale
属性设为 (0.75, 0.75)
。
像在 Player
场景中一样,为碰撞添加一个 CapsuleShape2D
。为了使形状与图像对齐,你需要将 Rotation
属性设为 90
(在“检查器”的“Transform”下)。
做好后,保存该场景。
敌人的脚本
像前面一样将脚本添加到 Mob
上。
现在让我们看一下脚本的其余部分。在 _ready()
中,我们从三个动画类型中随机选择一个播放:
func _ready():
var mob_types = $AnimatedSprite2D.sprite_frames.get_animation_names()
$AnimatedSprite2D.play(mob_types[randi() % mob_types.size()])
首先,我们从 AnimatedSprite2D 的 sprite_frames
属性中获取动画名称的列表。返回的是一个数组,该数组包含三个动画名称:["walk", "swim", "fly"]
。
然后我们需要在 0
和 2
之间选取一个随机的数字, 以在列表中选择一个名称(数组索引以 0
起始). randi() % n
会在 0
and n-1
之中选择一个随机整数.
最后一步是让怪物在超出屏幕时删除自己。
将 VisibleOnScreenNotifier2D
节点的 screen_exited()
信号连接到 Mob
上,然后添加如下代码:
func _on_visible_on_screen_notifier_2d_screen_exited():
queue_free()
这样就完成了 Mob 场景。
玩家和敌人已经准备就绪,接下来,我们将在一个新的场景中把他们放到一起。我们将使敌人在游戏板上随机生成并前进,我们的项目将变成一个能玩的游戏。
游戏主场景
现在是时候将我们所做的一切整合到一个可玩的游戏场景中了。
创建新场景并添加一个 Node 节点,命名为 Main
。
(我们之所以使用 Node 而不是 Node2D,是因为这个节点会作为处理游戏逻辑的容器使用。本身是不需要 2D 功能的。)
点击实例化按钮(由链条图标表示)并选择保存的 player.tscn
。
现在,将下列节点添加为 Main
的子节点,并按如下所示对它们进行命名:
-
Timer(名为
MobTimer
)——控制怪物产生的频率 -
Timer(名为
ScoreTimer
)——每秒增加分数 -
Timer(名为
StartTimer
)——在开始之前给出延迟 -
Marker2D(名为
StartPosition
)——表示玩家的起始位置
如下设置每个 Timer
节点的 Wait Time
属性(值以秒为单位):
MobTimer
:0.5
ScoreTimer
:1
StartTimer
:2
其它依次设置。
此外,将 StartTimer
的 One Shot
属性设置为“启用”,并将 StartPosition
节点的 Position
设置为 (240, 450)
。
生成怪物
Main
节点将产生新的生物, 我们希望它们出现在屏幕边缘的随机位置. 添加一个名为 MobPath
的 Path2D 节点作为 Main
的子级. 当你选择 Path2D
时, 你将在编辑器顶部看到一些新按钮:
选择添加点按钮,并单击以添加拐角点来绘制路径。可使用网格捕捉和用智能捕捉,使点对齐到网格。
现在已经定义了路径, 添加一个 PathFollow2D 节点作为 MobPath
的子节点, 并将其命名为 MobSpawnLocation
. 该节点在移动时, 将自动旋转并沿着该路径, 因此我们可以使用它沿路径来选择随机位置和方向.
你的场景应如下所示:
Main 脚本
将脚本添加到 Main
。在脚本的顶部,我们使用 @export var mob_scene: PackedScene
来允许我们选择要实例化的 Mob 场景。
extends Node
@export var mob_scene: PackedScene
var score
单击 Main
节点,就可以在“检查器”的“Script Variables”(脚本变量)下看到 Mob Scene
属性。
有两种方法来给这个属性赋值:
-
将
mob.tscn
从“文件系统”面板拖放到 Mob Scene 属性里。 -
单击“[空]”旁边的下拉箭头按钮,选择“加载”。选择
mob.tscn
。
然后选中“场景”面板中 Main
节点下的 Player
场景实例,切换到侧边栏的“节点”面板。请确保“节点”面板中的“信号”选项卡处于选中状态。
你可以看到 Player
的信号列表。
找到 hit
信号并双击(或右键选择 "Connect...")将会打开信号连接窗口。接下来创建用于在游戏结束时进行一些处理的 game_over
函数。在信号连接窗口底部的 “Receiver Method” 框中输入 “game_over”,并点击 “Connect”。 你的目标是从 Player
发出 hit
信号,并在 Main
脚本中进行处理。将以下代码添加到新函数中,以及一个 new_game
函数,该函数将为新游戏设置一切:
func game_over():
$ScoreTimer.stop()
$MobTimer.stop()
func new_game():
score = 0
$Player.start($StartPosition.position)
$StartTimer.start()
现在将每个 Timer
节点(StartTimer
,ScoreTimer
和 MobTimer
)的 timeout()
信号连接到 main
脚本。 StartTimer
将启动其他两个计时器。 ScoreTimer
将使得分加1。
func _on_score_timer_timeout():
score += 1
func _on_start_timer_timeout():
$MobTimer.start()
$ScoreTimer.start()
在 _on_mob_timer_timeout()
中, 我们先创建小怪实例,然后沿着 Path2D
路径随机选取起始位置,最后让小怪移动。PathFollow2D
节点将沿路径移动,并会自动旋转,所以我们将使用它来选择怪物的方位和朝向。生成小怪后,我们会在 150.0
和 250.0
之间选取随机值,表示每只小怪的移动速度(如果它们都以相同的速度移动,那么就太无聊了)。
注意,必须使用 add_child()
将新实例添加到场景中。
func _on_mob_timer_timeout():
# Create a new instance of the Mob scene.
var mob = mob_scene.instantiate()
# Choose a random location on Path2D.
var mob_spawn_location = $MobPath/MobSpawnLocation
mob_spawn_location.progress_ratio = randf()
# Set the mob's direction perpendicular to the path direction.
var direction = mob_spawn_location.rotation + PI / 2
# Set the mob's position to a random location.
mob.position = mob_spawn_location.position
# Add some randomness to the direction.
direction += randf_range(-PI / 4, PI / 4)
mob.rotation = direction
# Choose the velocity for the mob.
var velocity = Vector2(randf_range(150.0, 250.0), 0.0)
mob.linear_velocity = velocity.rotated(direction)
# Spawn the mob by adding it to the Main scene.
add_child(mob)
画个重点:
为什么要用
PI
?在需要传入角度的函数中,Godot 使用的是弧度而不是度数。圆周率(Pi)表示转半圈的弧度,约为3.1415
(还提供了等于2 * PI
的TAU
)。如果你更喜欢使用度数,则需使用deg_to_rad()
和rad_to_deg()
函数在这两种单位之间进行转换。
测试场景
让我们测试这个场景,确保一切正常。请将对 new_game
的调用添加至 _ready()
:
func _ready():
new_game()
让我们同时指定 Main
作为我们的“主场景”——游戏启动时自动运行的场景。按下“运行”按钮,当弹出提示时选择 main.tscn
。
如果你已经将别的场景设置为“主场景”了,你可以在“文件系统”面板上右键点击
main.tscn
并选择“设为主场景”。
使用F5运行项目,你应该可以四处移动游戏角色,观察敌人的生成,以及玩家被敌人击中时会消失。
当你确定一切正常时,在 _ready()
中删除对 new_game()
的调用,使用 pass
替代它。
我们的游戏还缺点啥?缺用户界面。接下来,我们将会添加标题界面并且显示玩家的分数。
游戏信息显示
我们的游戏最后还需要用户界面(User Interface,UI),显示分数、“游戏结束”信息、重启按钮。
创建新场景,点击“其他节点”按钮,然后添加一个 CanvasLayer 节点并命名为 HUD
。“HUD”是“heads-up display”(游戏信息显示)的缩写,是覆盖在游戏视图上显示的信息。
CanvasLayer 节点可以让我们在游戏的其他部分的上一层绘制 UI 元素,这样它所显示的信息就不会被任何游戏元素(如玩家或敌人)所覆盖。
HUD 中需要显示以下信息:
得分,由
ScoreTimer
更改。消息,例如“Game Over”或“Get Ready!”
“Start”按钮来开始游戏。
UI 元素的基本节点是 Control 。要创建 UI,我们需使用 Control 下的两种节点:Label 和 Button。
创建以下节点作为 HUD
的子节点:
名为分数标签
ScoreLabel
的 Label。名为消息
Message
的 Label。名为开始按钮
StartButton
的 Button。名为信息计数器
MessageTimer
的 Timer。
点击 ScoreLabel
并在“检查器”的 Text
字段中键入一个数字。
Control
节点的默认字体很小,不能很好地缩放。游戏资产包中有一个叫作“Xolonium-Regular.ttf”的字体文件。
使用此字体需要执行以下操作:
在“Theme Overrides > Fonts”(主题覆盖 > 字体)中选择“加载”,然后选中“Xolonium-Regular.ttf”文件。
字体尺寸仍然太小,请在“Theme Overrides > Font Sizes”(主题覆盖 > 字体大小)下将其增加到 64
。当 ScoreLabel
完成此操作后,请重复对 Message
和 StartButton
节点做同样的修改。
全部修改完毕后,下一步。
锚点:Control
节点具有位置和大小,但它也有锚点(Anchor)。锚点定义的是原点——节点边缘的参考点。
请将节点如下图排列。拖动节点可以手动放置,也可以使用“锚点预设(Anchor Preset)”进行更精确的定位。
ScoreLabel
-
添加文本
0
。 -
将“Horizontal Alignment”和“Vertical Alignment”设置为
Center
。 -
为“Anchor Preset”选择
Center Top
。锚点预设(Anchor Preset),顶部居中。
Message
-
添加文本
Dodge the Creeps!
。 -
将“Horizontal Alignment”和“Vertical Alignment”设置为
Center
。 -
将“Autowrap Mode”设置为
Word
,否则标签只会有一行。 -
在“Control - Layout/Transform”中将“Size X”设置为
480
,使用屏幕的完整宽度。 -
为“Anchor Preset”选择
Center
。
StartButton
-
添加文本
Start
。 -
在“Control - Layout/Transform”中将“Size X”设置为
200
、“Size Y”设置为100
,在边框和文本之间添加间距。 -
为“Anchor Preset”选择
Center Bottom
。 -
在“Control - Layout/Transform”中将“Position Y”设置为
580
。
在 MessageTimer
中,将 Wait Time
设置为 2
并将 One Shot
属性设置为“启用”。
现将为HUD创建脚本,并将如下代码添加到 HUD
:
extends CanvasLayer
# Notifies `Main` node that the button has been pressed
signal start_game
当想显示一条临时消息时,比如“Get Ready”,就会调用这个函数
func show_message(text):
$Message.text = text
$Message.show()
$MessageTimer.start()
我们还需要处理玩家死亡的情况。以下代码会显示 2 秒“Game Over”,然后返回标题屏幕,暂停一会儿之后再显示“Start”按钮。
func show_game_over():
show_message("Game Over")
# Wait until the MessageTimer has counted down.
await $MessageTimer.timeout
$Message.text = "Dodge the Creeps!"
$Message.show()
# Make a one-shot timer and wait for it to finish.
await get_tree().create_timer(1.0).timeout
$StartButton.show()
当玩家死亡时调用这个函数。将显示“Game Over”2 秒,然后返回标题屏幕并显示“Start”按钮。
当你需要暂停片刻时,可以使用场景树的
get_tree().create_timer(2)
函数替代使用Timer
节点。这对于延迟非常有用,例如在上述代码中,在这里我们需要在显示“开始”按钮前等待片刻。
将以下更新分数代码添加到 HUD
中:
func update_score(score):
$ScoreLabel.text = str(score)
将 StartButton
的 pressed()
信号与 MessageTimer
的 timeout()
信号连接到 HUD
节点上,然后在新函数中添加以下代码:
func _on_start_button_pressed():
$StartButton.hide()
start_game.emit()
func _on_message_timer_timeout():
$Message.hide()
将 HUD 场景连接到 Main 场景
现在我们完成了 HUD
场景,保存并返回 Main
场景。和 Player
场景的做法一样,在 Main
场景中实例化 HUD
场景。如果你没有错过任何东西,完整的场景树应该像这样:
现在我们需要将 HUD
功能与我们的 Main
脚本连接起来。
这需要在 Main
场景中添加一些内容:
目标:将HUD节点的start_game信号连接到Main节点的new_game()
方法上。
官方文档对于这一段操作的原文:
In the Node tab, connect the HUD's
start_game
signal to thenew_game()
function of the Main node by clicking the "Pick" button in the "Connect a Signal" window and selecting thenew_game()
method or type "new_game" below "Receiver Method" in the window. Verify that the green connection icon now appears next tofunc new_game()
in the script.
但官方对中文的翻译文档在这里的明确性不够,会导致很多新手小伙伴蒙圈,官方的中文翻译文档是这样说的:
在“节点”选项卡中,点击“连接信号”窗口中的“选取”按钮,选择
new_game()
方法或在窗口的“接收方法”下面输入“new_game”,将 HUD 的start_game
信号连接到 Main 节点的new_game()
函数。请确认脚本中func new_game()
的旁边出现了一个绿色的连接图标。
这里显然把主次搞颠倒了,新手小伙伴们找不到开始操作的方法,其实这一步操作要从连接start_game信号开始,看我下图:
而这里面还有一个细节,看到我这里的小伙伴会发现,如果按照上面操作的话将找不到new_game方法,因为您当前选择的场景没有在主场景中,像我下图这样,选择主场景之后,脚本切换到main,然后再来选择信号连接:
请确认脚本中 func new_game()
的旁边出现了一个绿色的连接图标。
在 new_game()
函数中,更新分数显示并显示“Get Ready”消息:
$HUD.update_score(score)
$HUD.show_message("Get Ready")
在 game_over()
中我们需要调用相应的 HUD
函数:
$HUD.show_game_over()
最后,将下面的代码添加到 _on_score_timer_timeout()
中,保持不断变化的分数的同步显示:
$HUD.update_score(score)
像这样:
再次强调:
请不要忘记在
_ready()
中移除对new_game()
的调用。否则你的游戏将自动开始。
就是这里:
现在你就可以开始游戏了!
点击“运行项目”按钮。此时会要求你选择一个主场景,选择 main.tscn
即可。
恭喜了,小伙伴们,如果您真得做到了这里,那么你已经可以Godot游戏了!
删除旧的小怪
如果你一直玩到“游戏结束”,然后重新开始新游戏,上局游戏的小怪仍然显示在屏幕上。更好的做法是在新游戏开始时清除它们。我们需要一个同时让所有小怪删除它自己的方法,为此可以使用“分组”功能。
在 Mob
场景中,选择根节点,然后单击检查器旁边的“节点”选项卡(在该位置可以找到节点的信号)。 点击“信号”旁边的“分组”,然后可以输入新的组名称,点击“添加”。
现在,所有小怪都将属于“mobs”(小怪)分组。
我们可以将以下行添加到 Main
中的 new_game()
函数中:
get_tree().call_group("mobs", "queue_free")
call_group()
函数调用组中每个节点上的删除函数——让每个怪物删除其自身。
再次运行游戏,发现难度有点大,不太玩得下去,可以把怪物的体积缩小:
同步的,碰撞区域也要改,直接在场景画布上调整即可。
您会发现,怪物们都变小了,你坚持的时间更长了。嘻嘻。
至此,游戏大部分已经完成,我们将通过添加背景,循环音乐和一些键盘快捷键来对其进行一些润色。
完成了
以下是一些剩余的步骤,为游戏加点“料”,改善游戏体验。随意用你自己的想法扩展游戏玩法。
背景
默认的灰色背景不是很吸引人,那么我们就来改一下颜色。
一种方法是使用 ColorRect 节点。将其设为 Main
下的第一个节点,这样这个节点就会绘制在其他节点之后。ColorRect
只有一个属性:Color
(颜色)。选择一个你喜欢的颜色,然后在视口顶部的工具栏或者检查器中选择“布局”->“锚点预设”->“整个矩形”(Layout -> Anchors Preset -> Full Rect),使其覆盖屏幕。
如果你有背景图片, 你也可以通过使用 TextureRect
节点来添加背景图片.
音效
声音和音乐可能是增强游戏吸引力的最有效方法。在游戏 art 文件夹中,有两个声音文件:“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()
.
像这样:
func game_over():
...
$Music.stop()
$DeathSound.play()
func new_game():
...
$Music.play()
键盘快捷键
当游戏使用键盘控制,可以方便地按键盘上的键来启动游戏。一种方法是使用 Button
节点的 “Shortcut”(快捷键)属性。
前面,我们创建了四个输入动作来移动角色。我们将创建一个类似的输入动作来映射到开始按钮。
选择“项目 -> 项目设置”,然后单击“输入映射”选项卡。与创建移动输入动作的方式相同,创建一个名为 start_game
的新输入操作,并为 Enter 添加按键映射。
如果你有一个手柄,现在可以添加一个手柄支持。连接上你的手柄,然后在每一个你想添加手柄支持的输入动作下,点击 "+" 按钮然后按下该输入动作对应的按钮,方向键或者摇杆。
在 HUD
场景中,选择 StartButton
并在检查器中找到它的 Shortcut(快捷方式)属性。通过在框中单击来创建一个新的 快捷键 资源,打开 Events(事件) 数组并通过单击 Array[InputEvent] (size 0) 向其添加一个新的数组元素。
这样,开始按钮出现后,你就可以点击它或按 Enter 来启动游戏。
就这样,你在 Godot 中完成了你的第一个 2D 游戏。
你已经能够制作由玩家控制的角色、在游戏区域内随机产生的敌人、计算分数、实现游戏结束和重玩、用户界面、声音,以及更多内容。祝贺!
还有很多东西需要学习,但你可以花点时间来欣赏你所取得的成就。
看到了吧,没有啥难的,你可以坚持下去,嘻嘻。