Godot引擎 4.0 文档 - 第一个 3D 游戏

本文为Google Translate英译中结果,DrGraph在此基础上加了一些校正。英文原版页面:

Your first 3D game — Godot Engine (stable) documentation in English

你的第一个 3D 游戏

在这个循序渐进的教程系列中,您将使用 Godot 创建您的第一个完整的 3D 游戏。到本系列结束时,您将拥有自己的一个简单但已完成的项目,就像下面的动画 gif 一样。

我们将在此处编写的游戏类似于您的第一个 2D 游戏,但有一点不同:您现在可以跳跃,您的目标是压扁小兵。这样,您既可以识别在上一教程中学到的模式,又可以使用新代码和功能在这些模式的基础上进行构建。

您将学习:

  • 使用跳跃机制处理 3D 坐标。

  • 使用运动体移动 3D 角色并检测它们何时以及如何发生碰撞。

  • 使用物理层和一个组来检测与特定实体的交互。

  • 通过定期实例化怪物来编写基本的程序游戏。

  • 设计一个运动动画并在运行时改变它的速度。

  • 在 3D 游戏上绘制用户界面。

以及更多。

本教程适用于学习了完整入门系列的初学者。【开始时我们会慢一些,以进行详细说明】,并在后续执行类似步骤时简要一些。如果您是一位经验丰富的程序员,您可以在此处浏览完整演示的源代码:Squash the Creep 源代码

注:您可以在不完成 2D 系列的情况下关注本系列。但是,如果您不熟悉游戏开发,我们建议您从 2D 开始。3D 游戏代码总是更复杂,而 2D 系列将为您提供更舒适的基础。

我们准备了一些游戏资源,以便我们可以直接跳转到代码。您可以在这里下载它们:Squash the Creeps assets

我们将首先为玩家的动作制作一个基本原型。然后我们将添加我们将在屏幕周围随机生成的怪物。之后,我们将在用一些漂亮的动画改进游戏之前实现跳跃和挤压机制。我们将以得分和重试屏幕结束。

设置游戏区

在第一部分中,我们将设置游戏区域。让我们从导入开始资源和设置游戏场景开始吧。

我们已经准备了一个 Godot 项目,其中包含我们将在本教程中使用的 3D 模型和声音,链接在索引页面中。如果您还没有这样做,您可以在此处下载存档:Squash the Creeps assets

下载后,将 .zip 存档解压缩到您的计算机上。打开 Godot 项目管理器并单击导入按钮。

在导入弹出窗口中,输入新创建目录的完整路径 squash_the_creeps_start/。您可以单击右侧的浏览按钮打开文件浏览器并导航到文件夹包含的project.godot文件。

单击导入和编辑【Import & Edit以在编辑器中打开项目。

启动项目包含一个图标和两个文件夹:art/fonts/。在那里,您会找到我们将在游戏中使用的美术资源和音乐。

有两个 3D 模型,player.glbmob.glb,属于这些模型的一些材料,以及一个音乐曲目。

设置可玩区域

我们将以普通节点作为其根来创建我们的主场景。在 Scene dock 中,单击左上角“+”图标表示的Add Child Node按钮,然后双击Node。命名节点Main。重命名节点的另一种方法是右键单击节点并选择重命名(或F2)。或者,要将节点添加到场景,您可以按Ctrl + a(或在 macOS 上Cmd + a)。

按Ctrl + s(在 macOS 上Cmd + s)将场景另存为main.tscn

我们将从添加防止角色掉落的地板开始。要创建地板、墙壁或天花板等静态碰撞体,您可以使用StaticBody3D节点。它们需要CollisionShape3D子节点来定义碰撞区域。选择Main节点后,添加一个StaticBody3D 节点,然后添加一个CollisionShape3D将StaticBody3D重命名为Ground.

你的场景树应该是这样的

CollisionShape3D旁边会出现一个警告标志,因为我们还没有定义它的形状。如果单击该图标,将出现一个弹出窗口,为您提供更多信息。

要创建形状,请选择CollisionShape3D节点,前往Inspector 并单击Shape属性旁边的<empty>字段。创建一个新的BoxShape3D

盒子形状非常适合平坦的地面和墙壁。它的厚度使其能够可靠地阻挡快速移动的物体。

一个盒子的线框出现在视口中,带有三个橙色点。您可以单击并拖动它们以交互方式编辑形状的范围。我们还可以在检查器中精确设置尺寸。单击BoxShape3D以展开资源。将其大小设置为X 轴60、Y 轴2和Z 轴60

碰撞形状是不可见的。我们需要添加一个与之配套的视觉地板。选择该Ground节点并添加一个MeshInstance3D作为其子节点。

Inspector中,单击Mesh旁边的字段并创建一个BoxMesh 资源以创建一个可见的框。

再一次,默认情况下它太小了。单击方框图标展开资源并将其大小设置为60260

您应该会在视口中看到一个覆盖网格以及蓝色和红色轴的宽灰色平板。

我们要把地面向下移动,以便我们可以看到地板网格。选择 Ground节点,按住Ctrl键打开网格捕捉,然后单击并向下拖动 Y 轴。它是移动小工具中的绿色箭头。

注:如果您看不到上图所示的 3D 对象操纵器,请确保视图上方工具栏中的选择模式处于活动状态。

向下移动地面1米,以便有一个可见的编辑器网格。视口左下角的标签告诉您节点的平移程度。

注:向下移动Ground节点会同时移动两个子节点。确保移动Ground节点,不是MeshInstance3D或 CollisionShape3D

最终,Ground的 transform.position.y 应该是 -1

让我们添加一个定向光,这样我们的场景就不会全是灰色的。选择Main 节点并添加子节点DirectionalLight3D

我们需要移动和旋转DirectionalLight3D节点。通过单击并拖动操纵器的绿色箭头将其向上移动,然后单击并拖动红色圆弧使其绕 X 轴旋转,直到地面被点亮。

Inspector中,通过单击复选框打开Shadow -> Enabled 。

此时,您的项目应该如下所示。

这是我们的【新起点】。在下一部分中,我们将处理玩家场景和基地运动。

玩家场景和输入动作

在接下来的两节课中,我们将设计玩家场景、注册自定义输入操作以及编写玩家移动代码。到最后,您将拥有一个可以向八个方向移动的可玩角色。

通过转到左上角的 Scene 菜单并单击New Scene创建一个新场景。

创建一个CharacterBody3D节点作为根节点

将CharacterBody3D命名为Player。角色身体是对 2D 游戏教程中使用的区域和刚体的补充。像刚体一样,它们可以移动并与环境发生碰撞,但不是由物理引擎控制,而是决定它们的运动。在编写跳跃和挤压机制代码时,您将看到我们如何使用节点的独特功能。

参考:要了解有关不同物理节点类型的更多信息,请参阅 物理介绍

现在,我们将为角色的 3D 模型创建一个基本装备。这将允许我们稍后在播放动画时通过代码旋转模型。

添加一个Node3D节点作为Player的子节点并命名为Pivot

然后,在文件系统停靠栏中,通过双击展开文件夹art/并将其下player.glb文件拖放到Pivot.

这应该将模型实例化为Pivot. 您可以将其重命名为Character.

注:这些.glb文件包含基于开源 GLTF 2.0 规范的 3D 场景数据。它们是专有格式(如 Godot 也支持的 FBX)的现代且强大的替代品。为了生成这些文件,我们在Blender 3D中设计了模型并将其导出到 GLTF。

与各种物理节点一样,我们需要一个碰撞形状让我们的角色与环境发生碰撞。再次选择该Player节点并添加一个子节点 CollisionShape3D。在Inspector的Shape属性上,添加一个新的SphereShape3D

球体的线框出现在角色下方。

这将是物理引擎用来与环境碰撞的形状,所以我们希望它能更好地适应 3D 模型。通过在视口中拖动橙色点将其缩小一点。我的球体的半径约为0.8米。

然后,向上移动形状,使其底部与网格平面大致对齐。

您可以通过单击PivotCharacter节点旁边的眼睛图标来切换模型的可见性 。

将场景另存为player.tscn

准备好节点后,我们几乎可以开始编码了。但首先,我们还需要定义一些输入动作。

创建输入操作

要移动角色,我们将监听玩家的输入,例如按箭头键。在 Godot 中,虽然我们可以在代码中编写所有键绑定,但有一个强大的系统允许您为一组键和按钮分配标签。这简化了我们的脚本并使它们更具可读性。

该系统是输入映射。要访问其编辑器,请前往项目菜单并选择项目设置

在顶部,有多个选项卡。单击输入地图【Input Map】。此窗口允许您在顶部添加新操作;他们是你的标签。在底部,您可以将键绑定到这些操作。

Godot 项目带有一些为用户界面设计设计的预定义操作,我们可以在这里使用它们。但我们正在定义自己的游戏手柄。

我们将命名我们的动作move_leftmove_rightmove_forwardmove_back 和jump

要添加一个动作,请在顶部的栏中写下它的名称,然后按 Enter 键。

创建以下五个操作:

要将键或按钮绑定到操作,请单击其右侧的“+”按钮。这样做是为了move_left。按向左箭头键并单击确定

也将A键绑定到 move_left动作上。

现在让我们添加对游戏手柄左操纵杆的支持。再次单击“+”按钮,但这次选择Manual Selection -> Joypad Axes

选择左操纵杆的负 X 轴。

将其他值保留为默认值,然后按OK

注:如果您希望控制器具有不同的输入操作,您应该使用附加选项中的设备选项。设备 0 对应第一个插入的游戏手柄,设备 1 对应第二个插入的游戏手柄,依此类推。

对其他输入操作执行相同的操作。例如,将右箭头 D 和左摇杆的正轴绑定到move_right。绑定所有键后,您的界面应如下所示。

最后要设置的动作是jump动作。绑定Space键和游戏手柄的A键。

您的跳转输入操作应如下所示。

这就是我们在这个游戏中需要的所有动作。您可以使用此菜单来标记项目中的任何按键和按钮组。

在下一部分中,我们将编写代码并测试玩家的移动。

使用代码移动Player

是时候编码了!我们将使用在上一部分中创建的输入动作来移动角色。

右键单击该Player节点并选择附加脚本以向其添加新脚本。在弹出窗口中,在按下创建按钮之前将模板设置为 。

让我们从类的属性开始。我们将定义一个移动速度,一个代表重力的下落加速度,以及一个我们将用来移动角色的速度。

extends CharacterBody3D

# How fast the player moves in meters per second.
@export var speed = 14
# The downward acceleration when in the air, in meters per second squared.
@export var fall_acceleration = 75

var target_velocity = Vector3.ZERO

这些是移动物体的共同属性。target_velocity3D 矢量,它结合了速度和方向。在这里,我们将它定义为一个属性,因为我们想要跨帧更新和重用它的值。

注:这些值与二维码有很大不同,因为距离以米为单位。在 2D 中,一千个单位(像素)可能只对应于屏幕宽度的一半,而在 3D 中,它是一公里。

让我们对运动进行编码。我们首先在_physics_process()中使用全局对象计算输入方向向量Input

func _physics_process(delta):
    # We create a local variable to store the input direction.
    var direction = Vector3.ZERO

    # We check for each move input and update the direction accordingly.
    if Input.is_action_pressed("move_right"):
        direction.x += 1
    if Input.is_action_pressed("move_left"):
        direction.x -= 1
    if Input.is_action_pressed("move_back"):
        # Notice how we are working with the vector's x and z axes.
        # In 3D, the XZ plane is the ground plane.
        direction.z += 1
    if Input.is_action_pressed("move_forward"):
        direction.z -= 1

在这里,我们将使用_physics_process() 虚函数进行所有计算。就像 一样_process(),它允许您每帧更新节点,但它是专门为物理相关代码设计的,例如移动运动学或刚体。

参考:_process()要了解有关和 之间区别的更多信息_physics_process(),请参阅空闲和物理处理

我们首先将一个变量direction初始化为Vector3.ZERO. 然后,我们检查玩家是否按下了一个或多个输入move_*并相应地更新向量xz组件。这些对应于地平面的轴。

这四个条件给了我们八种可能,即八个可能的方向。

如果玩家同时按下 W 和 D,向量的长度约为1.4. 但是如果他们按下一个键,它的长度将是1. 我们希望向量的长度是一致的,而不是沿对角线移动得更快。为此,我们可以调用它的normalize()方法。

#func _physics_process(delta):
    #...

    if direction != Vector3.ZERO:
        direction = direction.normalized()
        $Pivot.look_at(position + direction, Vector3.UP)

在这里,我们仅在方向的长度大于零时才对向量进行归一化,这意味着玩家正在按下方向键。

在这种情况下,我们还获取Pivot节点并调用其look_at()方法。此方法在空间中获取一个位置,以在全局坐标和向上方向中查看。在这种情况下,我们可以使用Vector3.UP常量。

注:节点的局部坐标,如position,是相对于其父节点的。全局坐标,例如global_position,相对于您可以在视口中看到的世界主轴。

在 3D 中,包含节点位置的属性是position。通过向其中添加direction,我们得到一个距离 Player1 米位置来查看。

然后,我们更新速度。我们必须分别计算地面速度和下落速度。【一定要注意缩进】以便这些行在_physics_process()函数内部,但在我们上面刚刚写的条件之外。

func _physics_process(delta):
    #...
    if direction != Vector3.ZERO:
        #...

    # Ground Velocity
    target_velocity.x = direction.x * speed
    target_velocity.z = direction.z * speed

    # Vertical Velocity
    if not is_on_floor(): # If in the air, fall towards the floor. Literally gravity
        target_velocity.y = target_velocity.y - (fall_acceleration * delta)

    # Moving the Character
    velocity = target_velocity
    move_and_slide()

如果身体在此帧中与地板发生碰撞,则该CharacterBody3D.is_on_floor()函数返回true。这就是为什么我们只在Player在空中时才对其施加重力。

对于垂直速度,我们减去下降加速度乘以每帧的增量时间。这行代码将导致我们的角色在每一帧中掉落,只要它没有落在地板上或与地板发生碰撞。

如果发生移动和碰撞,物理引擎只能在给定帧期间检测与墙壁、地板或其他物体的交互。稍后我们将使用此属性来编写跳转代码。

在最后一行,我们调用CharacterBody3D.move_and_slide(),这CharacterBody3D类的一个强大方法,它允许您平滑地移动角色。如果它在运动中途撞到墙壁,引擎会尝试为您平滑它。它使用CharacterBody3D固有的速度值

这就是在地板上移动角色所需的全部代码。

这里是完整的Player.gd代码供参考。

extends CharacterBody3D

# How fast the player moves in meters per second.
@export var speed = 14
# The downward acceleration when in the air, in meters per second squared.
@export var fall_acceleration = 75

var target_velocity = Vector3.ZERO


func _physics_process(delta):
    var direction = Vector3.ZERO

    if Input.is_action_pressed("move_right"):
        direction.x += 1
    if Input.is_action_pressed("move_left"):
        direction.x -= 1
    if Input.is_action_pressed("move_back"):
        direction.z += 1
    if Input.is_action_pressed("move_forward"):
        direction.z -= 1

    if direction != Vector3.ZERO:
        direction = direction.normalized()
        $Pivot.look_at(position + direction, Vector3.UP)

    # Ground Velocity
    target_velocity.x = direction.x * speed
    target_velocity.z = direction.z * speed

    # Vertical Velocity
    if not is_on_floor(): # If in the air, fall towards the floor. Literally gravity
        target_velocity.y = target_velocity.y - (fall_acceleration * delta)

    # Moving the Character
    velocity = target_velocity
    move_and_slide()

测试我们玩家的动作

我们将把我们的Player放在Main场景中进行测试。为此,我们需要实例化播放器,然后添加摄像机。与 2D 不同,在 3D 中,如果您的视口没有相机指向某物,您将看不到任何东西。

保存Player场景并打开Main场景。您可以单击 编辑器顶部的Main选项卡来执行此操作。

如果您之前关闭了场景,请前往文件系统停靠栏并双击 main.tscn以重新打开它。

要实例化Player,请右键单击Main节点并选择Instance Child Scene

在弹出窗口中,双击player.tscn。角色应该出现在视口的中心。

添加相机

接下来让我们添加相机。就像我们对PlayerPivot所做的那样,我们将创建一个基本的装备。再次右键单击该Main节点并选择 添加子节点。创建一个新的Marker3D并命名CameraPivot。选择并向其CameraPivot添加一个子节点Camera3D 。您的场景树应如下所示。

当您选择了相机时,请注意左上角出现的预览复选框。您可以单击它来预览游戏中的相机投影。

我们将使用Pivot来旋转相机,就像它在起重机上一样。让我们首先拆分 3D 视图,以便能够自由浏览场景并查看相机所见。

在视口正上方的工具栏中,点击View,然后点击2 Viewports。您也可以按Ctrl + 2(在 macOS 上Cmd + 2)。

在底部视图中,选择您的Camera3D并通过单击复选框打开相机预览。

在顶视图中,沿Z 轴(蓝色轴)向上移动相机19单位。

这就是魔法发生的地方。选择CameraPivot并 绕 X 轴旋转-45度(使用红色圆圈)。您会看到相机像挂在起重机上一样移动。

您可以通过按F6并按箭头键移动角色来运行场景。

由于透视投影,我们可以在角色周围看到一些空白空间。在此游戏中,我们将改用正交投影来更好地框定游戏区域并让玩家更容易读取距离。

再次选择Camera并在Inspector中,将Projection设置为 Orthogonal并将Size设置为19。角色现在应该看起来更平坦,地面应该填满背景。

注:在 Godot 4 中使用正交相机时,定向阴影质量取决于相机的Far值。Far值越高,相机能够看到的距离就越远。但是,较高的Far值也会降低阴影质量,因为阴影渲染必须覆盖更大的距离。

如果在切换到正交相机后定向阴影看起来太模糊,请将相机的Far属性减小到较低的值,例如 100. 不要将此Far属性减小太多,否则远处的对象将开始消失。

测试您的场景,您应该能够在所有 8 个方向上移动,并且不会在地板上出现故障!

最终,我们同时拥有玩家移动和视野。接下来,我们将处理怪物。

设计MOB场景

在这一部分中,您将为怪物编写代码,我们称之为mob。在下一课中,我们将在可玩区域周围随机生成它们。

让我们在新场景中设计怪物本身。节点结构将与player.tscn场景相似。

再次创建一个以CharacterBody3D节点作为其根的场景。命名它 Mob。添加一个子节点Node3D,命名为Pivot。并将文件mob.glbFileSystem停靠栏拖放到 以Pivot将怪物的 3D 模型添加到场景中。

您可以将新创建​​的mob节点重命名为Character.

我们需要一个碰撞形状来让我们的身体工作。右键单击Mob场景的根节点,然后单击添加子节点

添加CollisionShape3D

Inspector中,将BoxShape3D分配给Shape属性。

我们应该改变它的大小以更好地适应 3D 模型。您可以通过单击并拖动橙色点以交互方式执行此操作。

盒子应该接触地板并且比模型薄一点。物理引擎的工作方式是,如果玩家的球体接触到盒子的角,就会发生碰撞。如果盒子比 3D 模型大了一点,你可能会死在离怪物很远的地方,游戏会给玩家一种不公平的感觉。

请注意,我的盒子比怪物高。在这个游戏中没问题,因为我们是从上方观察场景并使用固定视角。碰撞形状不必与模型完全匹配。当你测试它时,游戏的感觉应该决定它们的形式和大小。

移除屏幕外的怪物

我们将在游戏关卡中定期生成怪物。如果我们不小心,它们的数量可能会增加到无穷大,而我们不希望这样。每个生物实例都有内存和处理成本,当生物在屏幕外时我们不想为此付费。

一旦一个怪物离开了屏幕,我们就不再需要它了,所以我们应该删除它。Godot 有一个节点VisibleOnScreenNotifier3D可以检测物体何时离开屏幕 ,我们将使用它来摧毁我们的生物。

注:当您不断实例化一个对象时,可以使用一种技术来避免一直创建和销毁实例的成本,称为池化。它包括预先创建一个对象数组并一遍又一遍地重复使用它们。

使用 GDScript 时,您无需担心这一点。使用池的主要原因是避免使用垃圾收集语言(如 C# 或 Lua)冻结。GDScript 使用不同的技术来管理内存,即引用计数,它没有那个警告。您可以在此处了解更多相关信息:内存管理

选择该Mob节点并添加一个子节点VisibleOnScreenNotifier3D。出现另一个盒子,这次是粉红色的。当这个盒子完全离开屏幕时,节点会发出一个信号。

使用橙色点调整它的大小,直到它覆盖整个 3D 模型。

编码MOB的运动

让我们来实现怪物的动作。我们将分两步进行。首先,我们将在Mob上编写一个脚本,定义一个初始化怪物的函数。然后我们将在main.tscn场景中编写随机生成机制的代码并从那里调用函数。

将脚本附加到Mob.

这是开始的移动代码。我们定义了两个属性min_speed 和max_speed来定义一个随机速度范围,稍后我们将使用它来定义CharacterBody3D.velocity

extends CharacterBody3D

# Minimum speed of the mob in meters per second.
@export var min_speed = 10
# Maximum speed of the mob in meters per second.
@export var max_speed = 18


func _physics_process(_delta):
    move_and_slide()

与玩家类似,我们通过调用函数CharacterBody3D.move_and_slide()在每一帧移动mob。这次,我们在每一帧不更新velocity;我们希望怪物以恒定的速度移动并离开屏幕,即使它会撞到障碍物。

我们需要定义另一个函数来计算CharacterBody3D.velocity. 此函数会将怪物转向玩家并随机化其运动角度和速度。

该函数将把怪物的生成位置start_position和 player_position作为它的参数。

我们将怪物定位在start_position并使用方法look_at_from_position()将其转向玩家,并通过围绕 Y 轴随机旋转一个角度来随机化角度。下面代码,randf_range()输出一个介于弧度-PI/4和弧度PI/4之间的随机值。

# This function will be called from the Main scene.
func initialize(start_position, player_position):
    # We position the mob by placing it at start_position
    # and rotate it towards player_position, so it looks at the player.
    look_at_from_position(start_position, player_position, Vector3.UP)
    # Rotate this mob randomly within range of -90 and +90 degrees,
    # so that it doesn't move directly towards the player.
    rotate_y(randf_range(-PI / 4, PI / 4))

我们得到了一个随机位置,现在我们需要一个random_speed. 函数randi_range()将很有用,因为它提供随机 int 值,我们将使用min_speed与max_speed。 random_speed只是一个整数,我们只是用它来乘以我们的CharacterBody3D.velocity. 应用random_speed后,我们将Vector3速度向量CharacterBody3D.velocity向玩家旋转。

func initialize(start_position, player_position):
    # ...

    # We calculate a random speed (integer)
    var random_speed = randi_range(min_speed, max_speed)
    # We calculate a forward velocity that represents the speed.
    velocity = Vector3.FORWARD * random_speed
    # We then rotate the velocity vector based on the mob's Y rotation
    # in order to move in the direction the mob is looking.
    velocity = velocity.rotated(Vector3.UP, rotation.y)

离开屏幕

我们仍然需要在生物离开屏幕时消灭它们。为此,我们将VisibleOnScreenNotifier3D节点的screen_exited信号连接到Mob.

单击编辑器顶部的3D标签返回 3D 视口。您也可以按Ctrl + F2(在 macOS 上Alt + 2)。

选择VisibleOnScreenNotifier3D节点,然后在界面右侧导航到节点停靠栏。双击screen_exited()信号。

将信号连接到Mob

这会将您带回脚本编辑器并为您添加一个新函数 _on_visible_on_screen_notifier_3d_screen_exited(). 在该函数中调用queue_free() 方法。这个函数销毁它被调用的实例。

func _on_visible_on_screen_notifier_3d_screen_exited():
    queue_free()

我们的怪物已经准备好进入游戏了!在下一部分中,您将在游戏关卡中生成怪物。

这是完整的Mob.gd脚本以供参考。

extends CharacterBody3D

# Minimum speed of the mob in meters per second.
@export var min_speed = 10
# Maximum speed of the mob in meters per second.
@export var max_speed = 18

func _physics_process(_delta):
    move_and_slide()

# This function will be called from the Main scene.
func initialize(start_position, player_position):
    # We position the mob by placing it at start_position
    # and rotate it towards player_position, so it looks at the player.
    look_at_from_position(start_position, player_position, Vector3.UP)
    # Rotate this mob randomly within range of -90 and +90 degrees,
    # so that it doesn't move directly towards the player.
    rotate_y(randf_range(-PI / 4, PI / 4))

    # We calculate a random speed (integer)
    var random_speed = randi_range(min_speed, max_speed)
    # We calculate a forward velocity that represents the speed.
    velocity = Vector3.FORWARD * random_speed
    # We then rotate the velocity vector based on the mob's Y rotation
    # in order to move in the direction the mob is looking.
    velocity = velocity.rotated(Vector3.UP, rotation.y)

func _on_visible_on_screen_notifier_3d_screen_exited():
    queue_free()

生成怪物

在这一部分中,我们将沿着一条路径随机生成怪物。到最后,您将看到怪物在游戏板上漫游。

在文件系统中双击main.tscn打开Main场景

在绘制路径之前,我们要更改游戏分辨率。我们的游戏有一个默认的窗口大小1152x648。我们要把它设置为720x540,一个漂亮的小盒子。

转到项目 -> 项目设置

在左侧菜单中,向下导航至Display -> Window。在右侧,将 宽度设置为720,将高度设置为540

创建生成路径

就像您在 2D 游戏教程中所做的那样,您将设计一条路径并使用 PathFollow3D节点对其上的随机位置进行采样。

但是在 3D 中,绘制路径有点复杂。我们希望它围绕游戏视图,这样怪物就出现在屏幕外面。但是如果我们绘制一条路径,我们将不会从相机预览中看到它。

为了找到视图的限制,我们可以使用一些占位符网格。您的视口仍应分为两部分,相机预览位于底部。如果不是这种情况,请按Ctrl + 2(在 macOS 上Cmd + 2) 将视图一分为二。选择Camera3D节点并单击底部视口中的预览复选框。

添加占位圆柱体

让我们添加占位符网格。添加一个新的Node3D作为 Main节点的子节点并将其命名为Cylinders。我们将使用它来对圆柱体进行分组。选择Cylinders并添加子节点MeshInstance3D

Inspector中,将CylinderMesh分配给Mesh属性。

使用视口左上角的菜单将顶部视口设置为顶部正交视图。或者,您可以按键盘上的 7 键。

网格可能会分散注意力。您可以通过转到 工具栏中的“查看”菜单并单击“查看网格”来切换它。

您现在想要沿着地平面移动圆柱体,在底部视口中查看相机预览。我建议使用网格捕捉来这样做。您可以通过单击工具栏中的磁铁图标或按 Y 来切换它。

移动圆柱体,使其位于左上角相机视野之外。

我们将创建网格的副本并将它们放置在游戏区域周围。按Ctrl + D(在 macOS 上Cmd + D) 复制节点。您还可以右键单击场景停靠栏中的节点,然后选择复制。沿蓝色 Z 轴向下移动副本,直到它正好位于相机预览之外。

通过按下Shift键并单击未选择的圆柱体并复制它们来选择两个圆柱体。

通过拖动红色 X 轴将它们向右移动。

白色有点难看,不是吗?让我们通过给他们一种新材料让他们脱颖而出。

在 3D 中,材质定义了表面的视觉属性,例如颜色、反射光的方式等。我们可以使用它们来改变网格的颜色。

我们可以一次更新所有四个圆柱体。选择场景停靠栏中的所有网格实例 。为此,您可以单击第一个,然后按住 Shift 单击最后一个。

Inspector中,展开Material部分并将StandardMaterial3D分配给 slot 0

单击球体图标以打开材料资源。您可以预览材料和一长串填充有属性的部分。您可以使用它们来创建各种表面,从金属到岩石或水。

展开反照率部分。

将颜色设置为与背景形成对比的颜色,例如亮橙色。

我们现在可以使用圆柱体作为指南。单击它们旁边的灰色箭头,将它们折叠到场景停靠栏中。展望未来,您还可以通过单击Cylinders旁边的眼睛图标来切换它们的可见性。

添加子节点Path3DMain节点。在工具栏中,出现四个图标。单击添加点工具,即带有绿色“+”符号的图标。

您可以将鼠标悬停在任何图标上以查看描述该工具的工具提示。

单击每个圆柱体的中心以创建一个点。然后,单击工具栏中的关闭曲线图标以关闭路径。如果任何点有点偏离,您可以单击并拖动它以重新定位。

你的路径应该是这样的。

要对其上的随机位置进行采样,我们需要一个PathFollow3D节点。添加一个 PathFollow3D作为Path3D. 分别将这两个节点重命名为SpawnPath和 SpawnLocation。它更能描述我们将使用它们做什么。

这样,我们就可以编写生成机制的代码了。

随机生成怪物

右键单击该Main节点并将新脚本附加到它。

我们首先将一个变量导出到Inspector,以便我们可以mob.tscn 为其分配或任何其他怪物。

extends Node

@export var mob_scene: PackedScene

我们希望以固定的时间间隔生成生物。为此,我们需要回到场景并添加一个计时器。不过,在此之前,我们需要将 mob.tscn文件分配给mob_scene上面的属性(否则它为空!)

返回 3D 屏幕并选择Main节点。从FileSystem停靠栏拖动mob.tscnInspector中的Mob Scene插槽。

添加一个新的Timer节点作为 的子节点Main。命名它MobTimer

Inspector中,将其Wait Time设置为0.5秒并打开 Autostart以便它在我们运行游戏时自动启动。

定时器timeout每次到达等待时间结束时都会发出一个信号。默认情况下,它们会自动重启,并循环发出信号。我们可以从主节点连接到这个信号,每0.5秒生成一次怪物。

MobTimer仍处于选中状态的情况下,前往右侧的节点停靠栏,然后双击信号timeout

将其连接到节点。

这会将您带回脚本,并带有一个新的空 _on_mob_timer_timeout()函数。

让我们编写MOB生成逻辑。我们要:

  1. 实例化MOB场景。

  2. 在生成路径上的随机位置采样。

  3. 获取玩家的位置。

  4. 调用生物的initialize()方法,将随机位置和玩家的位置传递给它。

  5. 将生物添加为主节点的子节点。

func _on_mob_timer_timeout():
    # Create a new instance of the Mob scene.
    var mob = mob_scene.instantiate()

    # Choose a random location on the SpawnPath.
    # We store the reference to the SpawnLocation node.
    var mob_spawn_location = get_node("SpawnPath/SpawnLocation")
    # And give it a random offset.
    mob_spawn_location.progress_ratio = randf()

    var player_position = $Player.position
    mob.initialize(mob_spawn_location.position, player_position)

    # Spawn the mob by adding it to the Main scene.
    add_child(mob)

上面,randf()在0和1之间产生一个随机值,这是PathFollow节点所期望的:0 是路径的起点,1 是路径的终点。我们设置的路径围绕相机的视口,因此 0 到 1 之间的任何随机值progress_ratio都是视口边缘的随机位置!

main.gd这是到目前为止的完整脚本,供参考。

extends Node

@export var mob_scene: PackedScene


func _on_mob_timer_timeout():
    # Create a new instance of the Mob scene.
    var mob = mob_scene.instantiate()

    # Choose a random location on the SpawnPath.
    # We store the reference to the SpawnLocation node.
    var mob_spawn_location = get_node("SpawnPath/SpawnLocation")
    # And give it a random offset.
    mob_spawn_location.progress_ratio = randf()

    var player_position = $Player.position
    mob.initialize(mob_spawn_location.position, player_position)

    # Spawn the mob by adding it to the Main scene.
    add_child(mob)

您可以按F6测试场景。你应该看到怪物生成并直线移动。

现在,当它们的路径交叉时,它们会相互碰撞和滑动。我们将在下一部分解决这个问题。

跳跃和挤压怪物

在这一部分中,我们将添加跳跃和挤压怪物的能力。在下一课中,我们会让玩家在怪物击中地面时死亡。

首先,我们必须更改一些与物理交互相关的设置。进入物理层的世界。

控制物理相互作用

物理实体可以访问两个互补属性:图层【layers】和遮罩【mask】。图层定义对象位于哪个物理层上。

遮罩控制身体将聆听和检测的层次。这会影响碰撞检测。当你想让两个物体相互作用时,你至少需要一个物体有一个与另一个物体相对应的掩码。

如果这让您感到困惑,请不要担心,我们马上会看到三个示例。

重要的一点是您可以使用图层和遮罩来过滤物理交互、控制性能并消除代码中对额外条件的需求。

默认情况下,所有物理体和区域 layer 和 mask都设置为1。这意味着它们都相互碰撞。

物理层由数字表示,但我们可以给它们命名以跟踪什么是什么。

设置图层名称

让我们给物理层起个名字。转到项目 -> 项目设置

在左侧菜单中,向下导航至Layer Names -> 3D Physics。您可以在右侧看到一个图层列表,每个图层旁边都有一个字段。你可以在那里设置他们的名字。分别命名前三层playerenemyworld

现在,我们可以将它们分配给我们的物理节点。

分配图层和蒙版

场景中,选择Ground节点。在Inspector中,展开 Collision部分。在那里,您可以将节点的图层和蒙版视为按钮网格。

地面是世界的一部分,所以我们希望它成为第三层的一部分。单击亮起的按钮关闭第一打开第三层。然后,通过单击关闭版。

如前所述,Mask属性允许节点监听与其他物理对象的交互,但我们不需要它发生碰撞。Ground不需要听任何东西;它只是为了防止生物掉落。

请注意,您可以单击属性右侧的“...”按钮来查看已命名复选框的列表。

接下来是PlayerMob。通过双击文件系统停靠栏中的文件player.tscn打开。

选择Player节点并将其Collision -> Mask设置为“enemies”和“world”。您可以保留默认Layer属性不变,因为第一层是“播放器”层。

然后,双击打开Mobmob.tscn场景并选择 Mob节点。

将其Collision -> Layer设置为“enemies”并取消其Collision -> Mask 的设置,使遮罩为空。

这些设置意味着怪物将相互移动。如果你想让怪物相互碰撞和滑动,打开敌人”面具。

注:生物不需要屏蔽“世界”层,因为它们只在 XZ 平面上移动。我们不会通过设计对它们施加任何重力。

跳跃

跳跃机制本身只需要两行代码。打开播放器 脚本。我们需要一个值来控制跳跃的强度并更新 _physics_process()以对跳跃进行编码。

在定义fall_acceleration的行之后,在脚本的顶部,添加jump_impulse.

#...
# Vertical impulse applied to the character upon jumping in meters per second.
@export var jump_impulse = 20

在内部_physics_process(),在代码块之前添加以下代码move_and_slide()

func _physics_process(delta):
    #...

    # Jumping.
    if is_on_floor() and Input.is_action_just_pressed("jump"):
        target_velocity.y = jump_impulse

    #...

这就是你需要跳的全部!

is_on_floor()方法是来自该类的工具CharacterBody3Dtrue如果身体在此帧中与地板碰撞,它会返回。这就是为什么我们对Player施加重力:所以我们与地板碰撞而不是像怪物那样漂浮在地板上。

如果角色在地板上并且玩家按下“跳跃”,我们会立即给他们很大的垂直速度。在游戏中,您真的希望控件能够响应并提供像这样的即时速度提升,虽然不切实际,但感觉很棒。

请注意,Y 轴向上为正。这与 2D 不同,其中 Y 轴向下为正。

压扁怪物

接下来让我们添加壁球机制。我们要让角色在怪物身上弹跳并同时杀死它们。

我们需要检测与怪物的碰撞并将它们与与地板的碰撞区分开来。为此,我们可以使用 Godot 的标记功能。

再次打开场景mob.tscn并选择Mob节点。转到 右侧的节点停靠栏以查看信号列表Node dock 有两个选项卡: 您已经使用过的Signals和允许您将标签分配给节点的Groups 

单击它以显示一个字段,您可以在其中写入标签名称。在字段中输入“mob”,然后单击“添加”按钮。

场景停靠栏中会出现一个图标,指示该节点至少属于一个组。

我们现在可以使用代码中的组来区分与怪物的碰撞和与地板的碰撞。

编码挤压机制

回到Player脚本来编写挤压和弹跳的代码。

在脚本的顶部,我们需要另一个属性bounce_impulse. 压扁敌人时,我们不一定希望角色像跳跃时那样飞得那么高。

# Vertical impulse applied to the character upon bouncing over a mob in
# meters per second.
@export var bounce_impulse = 16

然后,在我们在上面_physics_process()添加的Jumping代码块之后,添加以下循环。使用 时 move_and_slide(),Godot 有时会连续多次移动身体以平滑角色的运动。所以我们必须遍历所有可能发生的碰撞。

在循环的每次迭代中,我们检查我们是否降落在暴徒身上。如果是这样,我们杀死它并反弹。

使用此代码,如果给定帧上没有发生碰撞,则循环不会运行。

func _physics_process(delta):
   #...

   # Iterate through all collisions that occurred this frame
   for index in range(get_slide_collision_count()):
       # We get one of the collisions with the player
       var collision = get_slide_collision(index)

       # If the collision is with ground
       if (collision.get_collider() == null):
           continue

       # If the collider is with a mob
       if collision.get_collider().is_in_group("mob"):
           var mob = collision.get_collider()
           # we check that we are hitting it from above.
           if Vector3.UP.dot(collision.get_normal()) > 0.1:
               # If so, we squash it and bounce.
               mob.squash()
               target_velocity.y = bounce_impulse

这是很多新功能。这里有一些关于它们的更多信息。

函数get_slide_collision_count()get_slide_collision()都来自CharacterBody3D类,并且与 move_and_slide()相关.

get_slide_collision()返回一个 KinematicCollision3D对象,该对象包含有关碰撞发生的位置和方式的信息。例如,我们使用它的get_collider属性,通过调用is_in_group()来检查我们是否与“暴徒”发生碰撞 : collision.get_collider().is_in_group("mob")

注:该is_in_group()方法在每个Node上都可用。

为了检查我们是否降落在怪物身上,我们使用向量点积:Vector3.UP.dot(collision.get_normal()) > 0.1。碰撞法线是垂直于发生碰撞的平面的 3D 矢量。点积允许我们将其与向上方向进行比较。

对于点积,当结果大于 时0,两个向量的夹角小于 90 度。高于的值0.1告诉我们,我们大致在怪物之上。

我们正在调用一个未定义的函数mob.squash(),因此我们必须将它添加到 Mob 类中。

通过在文件系统停靠栏中双击Mob.gd脚本来打开它。在脚本的顶部,我们要定义一个名为squashed的新信号。在底部,您可以添加 squash 函数,我们可以在其中发出信号并摧毁生物。

# Emitted when the player jumped on the mob.
signal squashed

# ...


func squash():
    squashed.emit()
    queue_free()

我们将在下一课中使用该信号为得分加分。

有了它,你应该能够通过跳上它们来杀死怪物。您可以按 F5尝试游戏并设置main.tscn为项目的主场景。

但是,玩家还不会死。我们将在下一部分进行处理。

杀死玩家

我们可以通过跳到敌人身上杀死他们,但玩家仍然不能死。让我们解决这个问题。

我们想要检测被敌人击中与压扁他们的方式不同。我们希望玩家在地板上移动时死亡,但在空中则不会。我们可以使用矢量数学来区分这两种碰撞。不过,我们将使用Area3D节点,它适用于碰撞盒。

带有 Area 节点的 Hitbox

回到player.tscn场景并添加一个新的子节点Area3D。将其命名 MobDetector 为添加一个CollisionShape3D节点作为它的子节点。

Inspector中,为其指定一个圆柱体形状。

这是一个技巧,您可以使用它来使碰撞仅在玩家在地面上或靠近地面时发生。您可以降低圆柱体的高度并将其向上移动到角色的顶部。这样,当玩家跳跃时,形状会高到敌人无法与其发生碰撞。

您还希望圆柱体比球体宽。这样,玩家在碰撞并被推到怪物的碰撞箱顶部之前就被击中了。

圆柱体越宽,玩家就越容易被杀死。

接下来,再次选择该MobDetector节点,并在Inspector中关闭其Monitorable属性。这使得其他物理节点无法检测到该区域。互补的Monitoring属性允许它检测碰撞。然后,移除Collision -> Layer并将蒙版设置为“enemies”层。

当区域检测到碰撞时,它们会发出信号。我们将把一个连接到Player节点。选择MobDetector并转到Inspector节点选项卡,双击 body_entered信号并将其连接到Player

MobDetector将在CharacterBody3D或 RigidBody3D节点进入发射body_entered。因为它只屏蔽“敌人”物理层,所以它只会检测Mob节点。​​​​​​​

在代码方面,我们要做两件事:发出一个信号,稍后我们将使用它来结束游戏并摧毁玩家。我们可以将这些操作包装在一个die()函数中,帮助我们在代码上贴上描述性标签。

# Emitted when the player was hit by a mob.
# Put this at the top of the script.
signal hit


# And this function at the bottom.
func die():
    hit.emit()
    queue_free()


func _on_mob_detector_body_entered(body):
    die()

按 再次尝试游戏F5。如果一切设置正确,角色应该在敌人撞上碰撞器时死亡。请注意,没有Player,以下行

var player_position = $Player.position

给出错误,因为没有 $Player!

另请注意,与玩家碰撞并死亡的敌人取决于 Player和 的Mob碰撞形状的大小和位置。您可能需要移动它们并调整它们的大小以获得紧凑的游戏感觉。

结束游戏

我们可以使用Playerhit信号来结束游戏。我们需要做的就是将它连接到Main节点并停止MobTimer反应。

打开main.tscn,选择Player节点,然后在Node dock 中,将其hit信号连接到Main节点。

在函数中获取计时器并停止它_on_player_hit()

func _on_player_hit():
    $MobTimer.stop()

如果你现在尝试游戏,怪物会在你死后停止生成,剩下的会离开屏幕。

您可以表扬一下自己:您制作了一个完整的 3D 游戏原型,即使它还有些粗糙。

从那里,我们将添加一个分数,重试游戏的选项,您将看到如何使用简约的动画让游戏感觉更加生动。

代码检查点

Main以下是、MobPlayer节点的完整脚本,供参考。您可以使用它们来比较和检查您的代码。

main.gd开始

extends Node

@export var mob_scene: PackedScene


func _on_mob_timer_timeout():
    # Create a new instance of the Mob scene.
    var mob = mob_scene.instantiate()

    # Choose a random location on the SpawnPath.
    # We store the reference to the SpawnLocation node.
    var mob_spawn_location = get_node("SpawnPath/SpawnLocation")
    # And give it a random offset.
    mob_spawn_location.progress_ratio = randf()

    var player_position = $Player.position
    mob.initialize(mob_spawn_location.position, player_position)

    # Spawn the mob by adding it to the Main scene.
    add_child(mob)

func _on_player_hit():
    $MobTimer.stop()

接下来是Mob.gd

extends CharacterBody3D

# Minimum speed of the mob in meters per second.
@export var min_speed = 10
# Maximum speed of the mob in meters per second.
@export var max_speed = 18

# Emitted when the player jumped on the mob
signal squashed

func _physics_process(_delta):
    move_and_slide()

# This function will be called from the Main scene.
func initialize(start_position, player_position):
    # We position the mob by placing it at start_position
    # and rotate it towards player_position, so it looks at the player.
    look_at_from_position(start_position, player_position, Vector3.UP)
    # Rotate this mob randomly within range of -90 and +90 degrees,
    # so that it doesn't move directly towards the player.
    rotate_y(randf_range(-PI / 4, PI / 4))

    # We calculate a random speed (integer)
    var random_speed = randi_range(min_speed, max_speed)
    # We calculate a forward velocity that represents the speed.
    velocity = Vector3.FORWARD * random_speed
    # We then rotate the velocity vector based on the mob's Y rotation
    # in order to move in the direction the mob is looking.
    velocity = velocity.rotated(Vector3.UP, rotation.y)

func _on_visible_on_screen_notifier_3d_screen_exited():
    queue_free()

func squash():
    squashed.emit()
    queue_free() # Destroy this node

最后,最长的脚本Player.gd

extends CharacterBody3D

signal hit

# How fast the player moves in meters per second
@export var speed = 14
# The downward acceleration while in the air, in meters per second squared.
@export var fall_acceleration = 75
# Vertical impulse applied to the character upon jumping in meters per second.
@export var jump_impulse = 20
# Vertical impulse applied to the character upon bouncing over a mob
# in meters per second.
@export var bounce_impulse = 16

var target_velocity = Vector3.ZERO


func _physics_process(delta):
    # We create a local variable to store the input direction
    var direction = Vector3.ZERO

    # We check for each move input and update the direction accordingly
    if Input.is_action_pressed("move_right"):
        direction.x = direction.x + 1
    if Input.is_action_pressed("move_left"):
        direction.x = direction.x - 1
    if Input.is_action_pressed("move_back"):
        # Notice how we are working with the vector's x and z axes.
        # In 3D, the XZ plane is the ground plane.
        direction.z = direction.z + 1
    if Input.is_action_pressed("move_forward"):
        direction.z = direction.z - 1

    # Prevent diagonal moving fast af
    if direction != Vector3.ZERO:
        direction = direction.normalized()
        $Pivot.look_at(position + direction, Vector3.UP)

    # Ground Velocity
    target_velocity.x = direction.x * speed
    target_velocity.z = direction.z * speed

    # Vertical Velocity
    if not is_on_floor(): # If in the air, fall towards the floor. Literally gravity
        target_velocity.y = target_velocity.y - (fall_acceleration * delta)

    # Jumping.
    if is_on_floor() and Input.is_action_just_pressed("jump"):
        target_velocity.y = jump_impulse

    # Iterate through all collisions that occurred this frame
    # in C this would be for(int i = 0; i < collisions.Count; i++)
    for index in range(get_slide_collision_count()):
        # We get one of the collisions with the player
        var collision = get_slide_collision(index)

        # If the collision is with ground
        if (collision.get_collider() == null):
            continue

        # If the collider is with a mob
        if collision.get_collider().is_in_group("mob"):
            var mob = collision.get_collider()
            # we check that we are hitting it from above.
            if Vector3.UP.dot(collision.get_normal()) > 0.1:
                # If so, we squash it and bounce.
                mob.squash()
                target_velocity.y = bounce_impulse

    # Moving the Character
    velocity = target_velocity
    move_and_slide()

# And this function at the bottom.
func die():
    hit.emit()
    queue_free()

func _on_mob_detector_body_entered(body):
    die()

下一节课见,添加分数和重玩选项。

得分和重玩

在这一部分,我们将添加得分、音乐播放和重新启动游戏的功能。

我们必须在变量中跟踪当前分数,并使用最小界面将其显示在屏幕上。我们将使用文本标签来做到这一点。

在主场景中,添加Main一个新的子节点Control并命名为UserInterface。您将自动进入 2D 屏幕,您可以在其中编辑用户界面 (UI)。

添加Label节点并命名ScoreLabel

Inspector中,将Label文本设置为占位符,如“Score: 0”。

此外,文本默认为白色,就像我们游戏的背景一样。我们需要改变它的颜色才能在运行时看到它。

向下滚动到Theme Overrides,展开Colors 并启用Font Color以便将文本着色为黑色(与白色 3D 场景形成鲜明对比)

最后,单击并拖动视口中的文本,将其从左上角移开。

UserInterface节点允许我们将 UI 分组到场景树的一个分支中,并使用将传播到其所有子节点的主题资源。我们将使用它来设置我们游戏的字体。

创建 UI 主题

再次选择UserInterface节点。在Inspector中,在Theme -> Theme中创建一个新的主题资源。

单击它以在底部面板中打开主题编辑器。它使您可以预览所有内置 UI 小部件与您的主题资源的外观。

默认情况下,一个主题只有一个属性,即Default Font

参阅:您可以向主题资源添加更多属性以设计复杂的用户界面,但这超出了本系列的范围。要了解有关创建和编辑主题的更多信息,请参阅GUI 皮肤简介

这需要一个字体文件,就像您计算机上的字体文件一样。两种常见的字体文件格式是 TrueType 字体 (TTF) 和 OpenType 字体 (OTF)。

FileSystem dock 中,展开fonts目录并单击我们包含在项目中的文件Montserrat-Medium.ttf并将其拖到 Default Font上。文本将重新出现在主题预览中。

文字有点小。将默认字体大小设置为22像素以增加文本的大小。

跟踪分数

接下来让我们研究分数。将新脚本附加到ScoreLabel并定义score变量。

extends Label

var score = 0

每次我们压扁一个怪物时,分数应该增加1。我们可以使用他们的squashed信号来知道什么时候发生。但是,因为我们从代码中实例化了怪物,所以我们通过编辑器无法将 mob 信号连接到ScoreLabel

相反,我们必须在每次生成怪物时从代码中建立连接。

打开脚本main.gd。如果它仍然打开,您可以在脚本编辑器的左栏中单击它的名称。

或者,您可以双击文件系统main.gd停靠栏中的文件 。

在函数的底部_on_mob_timer_timeout(),添加以下行:

func _on_mob_timer_timeout():
    #...
    # We connect the mob to the score label to update the score upon squashing one.
    mob.squashed.connect($UserInterface/ScoreLabel._on_mob_squashed.bind())

这一行的意思是,当生物发出信号时squashed, ScoreLabel节点将接收信号并调用函数_on_mob_squashed()

返回ScoreLabel.gd脚本以定义_on_mob_squashed() 回调函数。

在那里,我们增加分数并更新显示的文本。

func _on_mob_squashed():
    score += 1
    text = "Score: %s" % score

第二行使用变量的值score来代替占位符%s。使用该特性时,Godot 会自动将值转换为字符串文本,方便在标签中输出文本或使用函数时使用print()

参阅:您可以在此处了解有关字符串格式化的更多信息:GDScript 格式化字符串。在 C# 中,考虑使用带有 "$" 的字符串插值

您现在可以玩游戏并压扁一些敌人以查看得分增加。

注:在复杂的游戏中,您可能希望将用户界面与游戏世界完全分开。在那种情况下,您不会跟踪标签上的分数。相反,您可能希望将其存储在一个单独的专用对象中。但是当制作原型或当你的项目很简单时,让你的代码保持简单是很好的。编程始终是一种平衡行为。

重玩游戏

我们现在将添加死后再次播放的功能。当玩家死亡时,我们将在屏幕上显示一条消息并等待输入。

回到main.tscn场景,选择UserInterface节点,添加一个子节点ColorRect,并将其命名为Retry。该节点用统一的颜色填充一个矩形,并将用作使屏幕变暗的覆盖层。

要使其跨越整个视口,您可以使用工具栏中的“锚点预设”菜单。

打开它并应用Full Rect命令。

什么都没发生。好吧,几乎没有;只有四个绿色图钉移动到选择框的角落。

这是因为 UI 节点(所有带有绿色图标的节点)使用相对于其父边界框的锚点和边距。在这里,UserInterface节点的尺寸很小,并且Retry受其限制。

选择UserInterface并应用Anchor Preset -> Full Rect。该 Retry节点现在应该跨越整个视口。

让我们改变它的颜色,使游戏区域变暗。选择Retry并在 Inspector中,将其Color设置为深色和透明的颜色。为此,在颜色选择器中,将A滑块拖动到左侧。它控制颜色的 Alpha 通道,也就是说,它的不透明度/透明度。

接下来,添加一个Label作为子项Retry,并为其提供文本 “Press Enter to retry”。要移动它并将其锚定在屏幕中央,请 对其应用锚定预设 -> 中心。

编码重玩选项

我们现在可以使用代码来在Retry玩家死亡并再次玩游戏时显示和隐藏节点。

打开脚本main.gd。首先,我们想在游戏开始时隐藏叠加层。将此行添加到_ready()函数中。

func _ready():
    $UserInterface/Retry.hide()

然后,当玩家被击中时,我们会显示叠加层。

func _on_player_hit():
    #...
    $UserInterface/Retry.show()

最后,当Retry节点可见时,我们需要监听玩家的输入,如果他们按下 enter 则重新启动游戏。为此,我们使用内置 _unhandled_input()回调,它在任何输入时都会触发。

如果玩家按下预定义的ui_accept输入操作并且Retry可见,我们将重新加载当前场景。

func _unhandled_input(event):
    if event.is_action_pressed("ui_accept") and $UserInterface/Retry.visible:
        # This restarts the current scene.
        get_tree().reload_current_scene()

该函数get_tree()使我们能够访问全局SceneTree对象,这使我们能够重新加载和重新启动当前场景。

添加音乐

要添加在后台连续播放的音乐,我们将使用 Godot 中的另一个功能:自动加载

要播放音频,您需要做的就是将一个AudioStreamPlayer节点添加到您的场景并向其附加一个音频文件。当您启动场景时,它可以自动播放。但是,当您重新加载场景时,就像我们再次播放一样,音频节点也会重置,音乐会从头开始播放。

您可以使用自动加载功能让 Godot 在游戏开始时自动加载当前场景之外的节点或场景。您还可以使用它来创建全局可访问的对象。

通过转到“场景”菜单并单击“新建场景” 或使用当前打开的场景旁边的+图标来创建新场景。

单击Other Node按钮创建一个AudioStreamPlayer并将其重命名为 MusicPlayer.

art/我们在目录中包含了音乐配乐House In a Forest Loop.ogg,单击并将其拖到Inspector中的Stream属性上。此外,打开自动播放,以便在游戏开始时自动播放音乐。

将场景另存为MusicPlayer.tscn.

我们必须将其注册为自动加载。前往Project -> Project Settings…菜单并单击Autoload选项卡。

路径字段中,您要输入场景的路径。单击文件夹图标打开文件浏览器并双击MusicPlayer.tscn. 然后,点击右侧的添加按钮注册节点。

MusicPlayer.tscn现在加载到您打开或播放的任何场景中。所以如果你现在运行游戏,音乐会在任何场景中自动播放。

在我们结束本课之前,让我们快速了解一下它是如何工作的。当您运行游戏时,您的Scene dock 会更改为您提供两个选项卡: RemoteLocal

远程选项卡允许您可视化正在运行的游戏的节点树。在那里,您将看到节点和场景包含的所有内容以及底部的实例化生物。

顶部是自动加载节点MusicPlayer根节点,这是您游戏的视口。

这就是本课的内容。在下一部分中,我们将添加一个动画,使游戏的外观和感觉都更好。

这是完整的main.gd脚本以供参考。

extends Node

@export var mob_scene: PackedScene

func _ready():
    $UserInterface/Retry.hide()


func _on_mob_timer_timeout():
    # Create a new instance of the Mob scene.
    var mob = mob_scene.instantiate()

    # Choose a random location on the SpawnPath.
    # We store the reference to the SpawnLocation node.
    var mob_spawn_location = get_node("SpawnPath/SpawnLocation")
    # And give it a random offset.
    mob_spawn_location.progress_ratio = randf()

    var player_position = $Player.position
    mob.initialize(mob_spawn_location.position, player_position)

    # Spawn the mob by adding it to the Main scene.
    add_child(mob)

    # We connect the mob to the score label to update the score upon squashing one.
    mob.squashed.connect($UserInterface/ScoreLabel._on_mob_squashed.bind())

func _on_player_hit():
    $MobTimer.stop()
    $UserInterface/Retry.show()

func _unhandled_input(event):
    if event.is_action_pressed("ui_accept") and $UserInterface/Retry.visible:
        # This restarts the current scene.
        get_tree().reload_current_scene()

角色动画

在最后一课中,我们将使用 Godot 的内置动画工具让我们的角色漂浮和拍打。您将学习在编辑器中设计动画并使用代码让您的游戏充满活力。

我们将从介绍使用动画编辑器开始。

使用动画编辑器

该引擎带有在编辑器中创作动画的工具。然后您可以使用代码在运行时播放和控制它们。

打开播放器场景,选择Player节点,并添加一个AnimationPlayer节点。

动画停靠栏出现在底部面板中。

它的顶部有一个工具栏和动画下拉菜单,中间有一个当前为空的轨道编辑器,底部有过滤、捕捉和缩放选项。

让我们创建一个动画。单击动画 -> 新建

将动画命名为“浮动”。

一旦您创建了动画,时间线就会出现,其中的数字代表时间(以秒为单位)。

我们希望动画在游戏开始时自动开始播放。此外,它应该循环。

为此,您可以分别单击动画工具栏中带有“A+”图标的按钮和循环箭头。

您还可以通过单击右上角的固定图标来固定动画编辑器。这可以防止它在您单击视口并取消选择节点时折叠。

在停靠栏的右上角将动画持续时间设置为1.2秒。

你应该看到灰色丝带变宽了一点。它向您显示动画的开始和结束,垂直的蓝线是您的时间光标。

您可以单击并拖动右下角的滑块来放大和缩小时间线。

浮动动画

使用动画播放器节点,您可以根据需要为任意数量的节点上的大多数属性设置动画。请注意Inspector中属性旁边的钥匙图标。您可以单击其中任何一个来为相应的属性创建关键帧、时间和值对。关键帧将插入时间轴中时间光标所在的位置。

让我们插入我们的第一把钥匙。在这里,我们将为节点的位置和旋转设置动画Character

选择Character并在Inspector中展开Transform部分。单击PositionRotation旁边的钥匙图标。

对于本教程,只需创建默认选择的 RESET Track(s)

两个轨道出现在编辑器中,每个关键帧都有一个菱形图标。

您可以单击并拖动菱形以及时移动它们。将位置键移至0.2秒,将旋转键移至0.1秒。

通过在灰色时间线上单击并拖动,将时间光标移动到0.5秒。

Inspector中,将PositionY轴设置为0.65米,将RotationX轴设置为8.

为两个属性创建一个关键帧

0.7 现在,通过在时间轴上拖动将位置关键帧移动到秒。

注:关于动画原理的讲座超出了本教程的范围。请注意,您不想均匀地安排时间和空间。相反,动画师使用时间和间隔这两个核心动画原则。您想抵消和对比角色的动作,让他们感觉自己还活着。

将时间光标移动到动画的末尾,以1.2秒为单位。将 Y 位置设置为大约0.35,将 X 旋转设置为-9度数。再次为这两个属性创建一个键。

您可以通过单击播放按钮或按Shift + D来预览结果。单击停止按钮S或按停止播放。

您可以看到引擎在关键帧之间进行插值以生成连续的动画。不过目前,这个动作感觉非常机械化。这是因为默认插值是线性的,导致不断的过渡,这与现实世界中生物的移动方式不同。

我们可以使用缓动曲线来控制关键帧之间的过渡。

单击并拖动时间轴中的前两个键以框选它们。

您可以在Inspector中同时编辑两个键的属性,您可以在其中看到一个Easing属性。

单击并拖动曲线,将其向左拉。这将使它缓出,也就是说,最初过渡很快,随着时间光标到达下一个关键帧而减慢。

再次播放动画以查看不同之处。上半场应该已经感觉有点活泼了。

对旋转轨道中的第二个关键帧应用缓出。

对第二个位置关键帧执行相反的操作,将其拖到右侧。

你的动画应该看起来像这样。

注:动画每帧更新动画节点的属性,覆盖初始值。如果我们直接为Player节点设置动画,它会阻止我们在代码中移动它。这就是Pivot节点派上用场的地方:即使我们为Character设置了动画,我们仍然可以在脚本中移动和旋转Pivot以及在动画顶部更改图层。

如果您玩游戏,玩家的生物现在会漂浮!

如果该生物离地面有点太近,您可以Pivot向上移动以抵消它。

在代码中控制动画

我们可以使用代码根据玩家的输入来控制动画播放。让我们改变角色移动时的动画速度。

通过单击旁边的脚本图标打开Player的脚本。

在 中_physics_process(),在我们检查向量的行之后direction ,添加以下代码。

func _physics_process(delta):
    #...
    if direction != Vector3.ZERO:
        #...
        $AnimationPlayer.speed_scale = 4
    else:
        $AnimationPlayer.speed_scale = 1

这段代码使得当玩家移动时,我们将播放速度乘以 4。当他们停止时,我们将其重置为正常。

我们提到Pivotcould 层在动画之上进行变换。我们可以使用以下代码行在跳跃时制作角色弧线。在末尾添加它_physics_process()

func _physics_process(delta):
    #...
    $Pivot.rotation.x = PI / 6 * velocity.y / jump_impulse

动画生物

这是 Godot 中动画的另一个不错的技巧:只要使用类似的节点结构,就可以将它们复制到不同的场景中。

例如,场景MobPlayer场景都有Pivot一个 Character节点,所以我们可以在它们之间重用动画。

打开Player场景,选择 AnimationPlayer 节点并打开“浮动”动画。接下来,单击“动画”>“复制”。然后打开mob.tscn,创建一个AnimationPlayer子节点并选中它。单击“动画”>“粘贴” 并确保在底部面板的动画编辑器中也打开带有“A+”图标的按钮(加载时自动播放)和循环箭头(动画循环)。就是这样; 所有怪物现在都会播放浮动动画。

我们可以根据生物的 改变播放速度random_speed。打开Mob的脚本并在函数末尾initialize()添加以下行。

func initialize(start_position, player_position):
    #...
    $AnimationPlayer.speed_scale = random_speed / min_speed

这样,您就完成了第一个完整的 3D 游戏的编码。

恭喜

在下一部分中,我们将快速回顾您学到的内容,并为您提供一些链接以继续学习更多内容。但现在,这里是完整的Player.gd, Mob.gd因此您可以对照它们检查您的代码。

这是播放器脚本。

extends CharacterBody3D

signal hit

# How fast the player moves in meters per second.
@export var speed = 14
# The downward acceleration while in the air, in meters per second squared.
@export var fall_acceleration = 75
# Vertical impulse applied to the character upon jumping in meters per second.
@export var jump_impulse = 20
# Vertical impulse applied to the character upon bouncing over a mob
# in meters per second.
@export var bounce_impulse = 16

var target_velocity = Vector3.ZERO


func _physics_process(delta):
    # We create a local variable to store the input direction
    var direction = Vector3.ZERO

    # We check for each move input and update the direction accordingly
    if Input.is_action_pressed("move_right"):
        direction.x = direction.x + 1
    if Input.is_action_pressed("move_left"):
        direction.x = direction.x - 1
    if Input.is_action_pressed("move_back"):
        # Notice how we are working with the vector's x and z axes.
        # In 3D, the XZ plane is the ground plane.
        direction.z = direction.z + 1
    if Input.is_action_pressed("move_forward"):
        direction.z = direction.z - 1

    # Prevent diagonal movement being very fast
    if direction != Vector3.ZERO:
        direction = direction.normalized()
        $Pivot.look_at(position + direction,Vector3.UP)
        $AnimationPlayer.speed_scale = 4
    else:
        $AnimationPlayer.speed_scale = 1

    # Ground Velocity
    target_velocity.x = direction.x * speed
    target_velocity.z = direction.z * speed

    # Vertical Velocity
    if not is_on_floor(): # If in the air, fall towards the floor
        target_velocity.y = target_velocity.y - (fall_acceleration * delta)

    # Jumping.
    if is_on_floor() and Input.is_action_just_pressed("jump"):
        target_velocity.y = jump_impulse

    # Iterate through all collisions that occurred this frame
    # in C this would be for(int i = 0; i < collisions.Count; i++)
    for index in range(get_slide_collision_count()):
        # We get one of the collisions with the player
        var collision = get_slide_collision(index)

        # If the collision is with ground
        if (collision.get_collider() == null):
            continue

        # If the collider is with a mob
        if collision.get_collider().is_in_group("mob"):
            var mob = collision.get_collider()
            # we check that we are hitting it from above.
            if Vector3.UP.dot(collision.get_normal()) > 0.1:
                # If so, we squash it and bounce.
                mob.squash()
                target_velocity.y = bounce_impulse

    # Moving the Character
    velocity = target_velocity
    move_and_slide()

    $Pivot.rotation.x = PI / 6 * velocity.y / jump_impulse

# And this function at the bottom.
func die():
    hit.emit()
    queue_free()

func _on_mob_detector_body_entered(body):
    die()

还有 The Mob的脚本。

extends CharacterBody3D

# Minimum speed of the mob in meters per second.
@export var min_speed = 10
# Maximum speed of the mob in meters per second.
@export var max_speed = 18

# Emitted when the player jumped on the mob
signal squashed

func _physics_process(_delta):
    move_and_slide()

# This function will be called from the Main scene.
func initialize(start_position, player_position):
    # We position the mob by placing it at start_position
    # and rotate it towards player_position, so it looks at the player.
    look_at_from_position(start_position, player_position, Vector3.UP)
    # Rotate this mob randomly within range of -90 and +90 degrees,
    # so that it doesn't move directly towards the player.
    rotate_y(randf_range(-PI / 4, PI / 4))

    # We calculate a random speed (integer)
    var random_speed = randi_range(min_speed, max_speed)
    # We calculate a forward velocity that represents the speed.
    velocity = Vector3.FORWARD * random_speed
    # We then rotate the velocity vector based on the mob's Y rotation
    # in order to move in the direction the mob is looking.
    velocity = velocity.rotated(Vector3.UP, rotation.y)

    $AnimationPlayer.speed_scale = random_speed / min_speed

func _on_visible_on_screen_notifier_3d_screen_exited():
    queue_free()

func squash():
    squashed.emit()
    queue_free() # Destroy this node​​​​​​​

更进一步

您可以为自己完成了第一个使用 Godot 的 3D 游戏而感到欣慰。

在本系列中,我们介绍了广泛的技术和编辑器功能。希望您已经见证了 Godot 的场景系统是多么直观,并学到了一些可以在您的项目中应用的技巧。

但我们只是触及了表面:Godot 为您节省创建游戏的时间提供了更多。您可以通过浏览文档了解所有这些。

你应该从哪里开始?在下面,您将找到几页以开始探索和构建您目前所学的内容。

但在此之前,这里有一个下载项目完整版本的链接: https://github.com/godotengine/godot-3d-dodge-the-creeps

浏览手册

每当您有疑问或对某项功能感到好奇时,手册就是您的盟友。它不包含有关特定游戏类型或机制的教程。相反,它解释了 Godot 的一般工作原理。在其中,您会找到有关 2D、3D、物理、渲染和性能等方面的信息。

以下是我们建议您接下来探索的部分:

  1. 阅读脚本部分,了解您将在每个项目中使用的基本编程功能。

  2. 3D和物理部分将教您更多有关在引擎中创建 3D 游戏的信息

  3. 输入是任何游戏项目的另一个重要输入。

您可以从这些开始,或者,如果您愿意,可以查看左侧的侧边栏菜单并选择您的选项。

我们希望您喜欢这个教程系列,我们期待看到您使用 Godot 取得的成就。

猜你喜欢

转载自blog.csdn.net/drgraph/article/details/130814519