在上一部分中,我们为游戏创建了第一个关卡。在这一部分中,我们将通过创建第二个关卡来完成本教程的关卡创建部分,并在现有关卡中添加最后的润色。

  • 您将在本部分学习:

    如何使用定时器节点。
    如何创建移动平台。
    如何使用Tilemap层。

第1关:最后润色

目前,当我们运行关卡时,地图后方仍有默认的灰色背景。我们将借助Tilemap图层把这个背景改为平铺背景。图层能帮助我们区分前景图块和背景图块,让项目结构更清晰。

默认情况下,TileMap节点会自带一个预制图层。在主场景中,选中 “Level” 这个Tilemap节点,然后在检查器面板中找到 “Layers”(图层)属性。
1.jpg
你会看到已有一个图层,但它没有名字。目前,我们绘制在Tilemap上的所有图块都在这个图层上。我们要把这个图层重命名为 “Foreground”(前景)图层。这时你会发现,Tilemap面板中的 “Layers” 属性已经显示了这一更改。
2.jpg
我们希望背景显示在前景图层的后方,所以在 “Layers” 属性中添加一个新元素,并将其命名为 “Background”(背景)。现在你就可以在不同图层间切换了。
3.jpg
因为我们希望背景图层在前景图层之后,所以需要通过将 “Background” 图层拖到 “Foreground” 图层上方来重新排列图层顺序。你可以按住图层元素旁边的堆叠线图标进行拖动。
4.jpg
现在,在Tilemap面板的 “Layers” 选项中,确保选中了 “Background” 图层。当你选中一个图层时,其他图层上的所有图块都应该会变暗。
5.jpg
绘制你的背景。我用的是纯黑色图块,但你可以用任何你喜欢的图块。只是要确保所选图块没有添加任何碰撞体——因为这会阻挡玩家移动!
6.jpg
另外,注意不要在梯子上绘制背景。我们会添加另一个Tilemap节点来填充梯子后面的空间。还有一种解决方法,就是在主场景树中将 “Ladders”(梯子)节点拖到 “Tilemap” 节点上方——但我不喜欢梯子突出在地图上方的效果!
7.jpg
让我们来填补这个空白。在主场景中复制Tilemap节点,然后把它拖到 “Ladders” 节点后面。我们可以把这个复制的节点重命名为 “Background”。
8.jpg
9.jpg
删除这个Tilemap的 “Foreground” 图层,然后简单地绘制剩余的背景部分,去除那些难看的灰色空白区域!(或者直接从 “Level” 节点中删除现有的背景,然后在这里重新绘制)。
10.jpg
最终效果应该如下:
11.jpg

现在,我们给场景添加一些窗户作为装饰!在 “Level” Tilemap节点中,添加一个名为 “Decorations”(装饰)的新图层。
12.jpg
在下方的 “TileSet” 面板中,我们需要为 “Decorations” 创建一个新的图块集。找到 “res://Assets/Kings and Pigs/Sprites/14-TileSets/Decorations (32x32).png”,将这个图片拖到 “Tiles” 属性中。
13.jpg
在Tilemap面板中,在 “Decorations” 图层上绘制窗户。你也可以添加其他装饰,比如架子或蜡烛。你可以在 “res://Assets/Kings and Pigs/Sprites/7-Objects/” 目录下的资源中找到更多装饰物品。
14.jpg
我最终的效果如下:
[此处可能有图片,但文档未提供,可根据描述想象或参考原文]
15.jpg

第2关 + 移动平台

现在我们已经设置好了第一关,接下来可以创建第二关了。如果你愿意且有时间,也可以创建第3关和第4关。目前,我们只创建第2关。为此,我们可以直接复制主场景,将其命名为 “Main_2”。
16.jpg
在新关卡中,玩家实例已经存在。当我们切换场景进入下一关时,玩家会在这里生成,所以要确保玩家位于关卡起点。

以下是第二关的布局规划:

  • 我们会在顶部设置桶生成器。不过,这次生成器生成的不是桶,而是会沿特定路径滚动的炸弹。
  • 我们还会在两侧设置较小的障碍物生成器,它们会向玩家投掷箱子,增加游戏难度。这些箱子也会沿特定路径移动。
  • 关卡中还会随机分布生命值、攻击力和得分加成道具。
  • 会有上下移动的墙壁阻挡玩家前进。
  • 最后,会有玩家出生点和终点,到达终点即可通关。

以下是这一关可能的样子:
17.jpg
在这一关中,我们会在两侧增加更多炸弹生成器,让游戏更具挑战性,同时还会创建一个移动墙壁,每隔3秒左右阻挡玩家通过。在开始创建关卡之前,我们先创建可移动的墙壁或平台。为此,我们需要新建一个场景,以Area2D节点作为根节点。如果你愿意,也可以使用CharacterBody2D节点——我们只需要一个能处理碰撞和移动的根节点。
18.jpg
将这个节点重命名为 “Platform”(平台),并将场景保存在 “Scenes” 文件夹下。
19.jpg
给这个新场景添加一个CollisionShape2D节点,形状设为RectangleShape2D。之后我们会调整这个形状的宽度和高度。
20.jpg
现在,把主场景中的 “Level TileMap” 复制粘贴到 “Platform” 场景中。我们复制这个节点是为了使用之前创建的自动图块(地形)。删除我们添加的所有图层(“Background”、“Foreground” 和 “Decorations”),这样我们就可以重新绘制了。
21.jpg
绘制一个宽2格、高5格的墙壁,并调整碰撞形状,使其与墙壁轮廓匹配。
22.jpg
这里给大家一个小提示,确保你的Tilemap不要命名为 “Wall”(墙壁)或 “Level”(关卡),否则之后炸弹与这个平台交互时会爆炸。

现在,在 “Platform” 场景中连接一个新脚本,并将这个脚本保存在 “Scripts” 文件夹下。
23.jpg
我们还需要给场景添加最后一个节点——Timer(定时器)节点。定时器节点会创建一个倒计时器,倒计时结束时会发出信号。我们希望这个定时器在 “x” 秒后改变墙壁的移动状态。
24.jpg
我们还要将定时器节点的 “timeout()” 信号连接到脚本。每次定时器倒计时结束,这个信号就会发出。这样墙壁就会移动,倒计时结束后再次移动。定时器会触发这些移动状态的改变。在定时器节点的 “Signals”(信号)面板中,将 “timeout()” 信号连接到脚本。
25.jpg
这会在脚本中创建一个 “func _on_timer_timeout():” 函数。在这个函数中,我们将改变平台的移动状态。在此之前,我们首先需要定义移动状态。我们的平台会垂直移动,在底部等待,然后向上移动,在顶部等待,再向下移动,如此循环。

为了改变平台的移动状态,我们需要创建一个枚举来存储这些状态。枚举(也称为enumerations)是一种数据类型,包含一组固定的常量。

# Platform.gd
extends Area2D
# 平台移动状态
enum State {WAIT_AT_BOTTOM, MOVING_UP, WAIT_AT_TOP, MOVING_DOWN}

接下来,在 “_on_timer_timeout():” 函数中,我们可以改变状态。如果平台处于 “WAIT_AT_TOP” 状态,就应该将状态改为 “MOVING_DOWN”;如果处于 “WAIT_AT_BOTTOM” 状态,就应该将状态改为 “MOVING_UP”。我们还需要创建一个变量,用于记录游戏开始时以及状态改变时平台的状态。初始时,我们将其设为 “WAIT_AT_BOTTOM”。

# Platform.gd
extends Area2D
# 平台移动状态
enum State {WAIT_AT_BOTTOM, MOVING_UP, WAIT_AT_TOP, MOVING_DOWN}
# 记录平台当前状态
var current_state = State.WAIT_AT_BOTTOM
# 定时器超时时平台方向改变
func _on_timer_timeout():
    if current_state == State.WAIT_AT_TOP:
        switch_state(State.MOVING_DOWN)
    
    if current_state == State.WAIT_AT_BOTTOM:
        switch_state(State.MOVING_UP)

我们还需要记录游戏开始时平台的y坐标,因为当平台移动状态改变时,我们改变的就是y值。让我们创建一个新变量,用于在游戏加载时设置平台的y坐标。

# Platform.gd
extends Area2D
# 平台移动状态
enum State {WAIT_AT_BOTTOM, MOVING_UP, WAIT_AT_TOP, MOVING_DOWN}
# 记录平台当前状态
var current_state = State.WAIT_AT_BOTTOM
# 移动位置
var initial_position

我们将在内置的 “ready()” 函数中设置这个变量的值,因为当节点添加到场景中时,这个函数就会被调用。在这个函数中,我们将 “initial_position” 变量设为平台当前的y坐标,并将其状态设为 “MOVING_UP”,因为 “current_state” 是 “WAIT_AT_BOTTOM”——我们希望游戏一加载,状态就发生改变。我们可以通过访问节点的 “position” 属性来获取节点的位置。

# Platform.gd
# 旧代码
# 在游戏开始时设置平台的y坐标并切换状态
func _ready():
    initial_position = position.y
    switch_state(State.MOVING_UP)

现在,我们还需要给平台设置一些其他变量,用于定义其移动速度(移动的快慢)、移动范围(上下移动的距离)、进度(是否完成移动)以及顶部和底部的等待时间(上下移动前等待的时长)。我们将导出这些变量,这样就可以在检查器面板中进行修改。这使得我们能够单独改变每个实例化平台的移动值——也就是说,有些平台移动得更快,有些等待时间更长,还有些会移动得更高。

# Platform.gd
extends Area2D
# 平台移动状态
enum State {WAIT_AT_BOTTOM, MOVING_UP, WAIT_AT_TOP, MOVING_DOWN}
# 记录平台当前状态
var current_state = State.WAIT_AT_BOTTOM
# 移动位置和移动进度值
var initial_position
var progress = 0.0
# 平台移动速度和范围
@export var movement_speed = 50.0
@export var movement_range = 50
# 等待时间
@export  var wait_time_at_top = 3.0 # 在顶部等待的秒数
@export var wait_time_at_bottom = 3.0 # 在底部等待的秒数

我们需要创建一个函数,持续切换平台的状态。根据新状态,它应该重置平台的移动进度,设置定时器及其等待时间(倒计时所需的秒数),并更改 “current_state” 变量的值。我们将使用 “match” 语句来检查和更改平台的状态。“match” 语句用于分支程序的执行,它类似于许多其他语言中的 “switch” 语句,但提供了一些额外功能。

# Platform.gd
# 旧代码
# 更改平台的移动状态
func switch_state(new_state):
    current_state = new_state
    match new_state:
        # 如果状态为向上移动,重置进度
        State.MOVING_UP:
            progress = 0.0
        
        # 如果状态为在顶部等待,启动定时器以更改状态
        State.WAIT_AT_TOP:
            $Timer.wait_time = wait_time_at_top # 将等待x秒后再移动
            $Timer.start()
            
        # 如果状态为在底部等待,启动定时器以更改状态
        State.WAIT_AT_BOTTOM:
            $Timer.wait_time = wait_time_at_bottom # 将等待x秒后再移动
            $Timer.start()
            
        # 如果状态为向下移动,根据定义的速度和范围移动平台
        State.MOVING_DOWN:
            progress = movement_range / movement_speed

最后,我们可以在 “physics_process()” 函数中调用新创建的 “switch_state()” 函数。这个函数每一个物理帧都会被调用。我们将用它来检查平台的当前状态,并根据状态移动或等待。“Lerp” 函数用于在底部和顶部位置之间进行平滑插值。

# Platform.gd
# 旧代码
# 移动我们的平台
func _physics_process(delta):
    match current_state:
        # 如果正在向上移动
        State.MOVING_UP:
            progress += delta
            # 更改位置
            position.y = lerp(initial_position, initial_position - movement_range,   progress / (movement_range / movement_speed))
            if progress >= (movement_range / movement_speed):
                switch_state(State.WAIT_AT_TOP)
        
        # 如果正在向下移动
        State.MOVING_DOWN:
            progress -= delta
            # 更改位置
            position.y = lerp(initial_position, initial_position - movement_range, progress / (movement_range / movement_speed))
            if progress <= 0:
                switch_state(State.WAIT_AT_BOTTOM)

在这个脚本中,我们让平台在两个点之间垂直移动,在顶部和底部有等待时间。由于使用了线性插值(lerp),移动过程很平滑,并且状态机制确保了移动的不同阶段都能得到正确处理。

如果你在 “Main_2” 场景中实例化 “Platform”,你会发现可以在检查器面板中更改其值。确保将其放置在地面上,并设置移动范围,使其到达上方地面时停止——你需要让平台的边缘与上下方的地面连接。你可以按F6测试当前场景,来测试这些值。
26.jpg
它的放置位置应该如下:
27.jpg

并且它的移动范围应该调整为如下停止状态:
28.jpg

我将实例化的平台组织在一个Node2D节点中,并为每个平台设置了独特的速度、范围和等待时间。
29.jpg
现在你可以继续为第二关创建地图了。你可以通过在 “Level” 节点中绘制已有图块来完成。记住,要为墙壁上下移动留出足够空间,因为它会阻挡两层楼的区域。

这是我为第二关创建的地图:
30.jpg

如果你运行场景,玩家应该能够顺利到达顶部,并且平台也会移动!
31.jpg

故障排除:修复玩家跳跃的小故障

如果你测试过游戏,可能会注意到玩家跳跃并改变方向时会出现一个小故障。玩家跳跃时,如果按下向左或向右的输入,会在继续跳跃前稍微改变方向。我们希望玩家先完成跳跃动画,然后再向新方向奔跑,而不是在跳跃过程中改变方向。为此,我们需要修改 “player_animations()” 函数,使其仅在玩家不跳跃时运行。在此之前,我们先在 “Global” 脚本中创建一个新变量来跟踪跳跃状态。

# Global.gd
extends Node
# 移动状态
var is_attacking = false
var is_climbing = false
var is_jumping = false

然后,在 “player_animations()” 函数中,我们只希望在玩家不跳跃时触发奔跑动画。

# Player.gd
# 旧代码
# 动画
func player_animations():
    # 向左(添加is_action_just_released,这样跳跃后能继续奔跑)
    if Input.is_action_pressed("ui_left") && Global.is_jumping == false:
        $AnimatedSprite2D.flip_h = true
        $AnimatedSprite2D.play("run")
        $CollisionShape2D.position.x = 7
        
    # 向右(添加is_action_just_released,这样跳跃后能继续奔跑)
    if Input.is_action_pressed("ui_right") && Global.is_jumping == false:
        $AnimatedSprite2D.flip_h = false
        $AnimatedSprite2D.play("run")
        $CollisionShape2D.position.x = -7
    
    # 空闲状态,当没有按键按下时
    if !Input.is_anything_pressed():
        $AnimatedSprite2D.play("idle")

接下来,当玩家跳跃时,我们需要将 “is_jumping” 变量的状态改为 “true”。当重力值重置时,我们还需要将这个值重置为 “false”。

# Player.gd
# 旧代码
# 单个输入捕获
func _input(event):
    # 攻击
    if event.is_action_pressed("ui_attack"):
        Global.is_attacking = true
        $AnimatedSprite2D.play("attack")        
    # 跳跃
    if event.is_action_pressed("ui_jump") and is_on_floor():
        velocity.y = jump_height
        $AnimatedSprite2D.play("jump")
    
    # 爬梯子
    if Global.is_climbing == true:
        if Input.is_action_pressed("ui_up"):
            $AnimatedSprite2D.play("climb") 
            gravity = 100
            velocity.y = -160
            Global.is_jumping = true
            
    # 重置重力
    else:
        gravity = 200
        Global.is_climbing = false  
        Global.is_jumping = false

现在如果你运行场景,玩家应该能正常跳跃并正确切换奔跑动画!

恭喜你创建了带有移动平台的第二关!你还学习了图层的使用,希望你最终做出了一个很棒的地图!如果你愿意,还可以创建第3关和第4关,添加更多平台和敌人区域。说到敌人,我们将在下一部分创建炸弹和炸弹生成器。这个炸弹生成器会生成炸弹,炸弹会沿着特定路径移动,到达终点或与玩家碰撞时会爆炸。

现在是保存项目并备份的好时机,这样如果出现任何导致游戏崩溃的错误,你可以恢复到这一阶段。在继续学习本系列之前,回顾一下所学内容。准备好后,我们下一部分再见!

标签: Godot, 2D游戏, 游戏开发, 游戏自学, 横版游戏

添加新评论