三维游戏开发其实并不难,跟着做下去,您也就会了。
在本篇3D游戏制作演绎中,您将会使用 Godot 创建你的第一个完整 3D 游戏。由此,您会完成属于自己的简单项目,没有谁天生就是“大佬”,咱们开始吧!
政安晨的个人主页:政安晨
欢迎 👍点赞✍评论⭐收藏
希望政安晨的博客能够对您有所裨益,如有不足之处,欢迎在评论区提出指正!
目录
目标
本次咱们游戏开发的目标:把敌人踩扁。
在本篇制作演绎中,您将会了解到:
使用 3D 坐标和跳跃机制。
使用运动学实体移动 3D 角色,检测何时何地发生了碰撞。
使用物理层和节点组,检测特定实体之间的交互。
编写基础的程序玩法,按照固定的时间间隔实例化怪物。
设计移动动画,在运行时改变播放速度。
在 3D 游戏中绘制用户界面。
当然,还有更多东西等待您去挖掘,这是一趟奇幻而有趣的旅行,会为您带来很多惊奇。
尽管您可以在没有尝试做过 2D 的情况下使用本篇文章。但是,如果你是游戏开发新手,建议您还是从 2D 开始。3D 游戏代码总是比较复杂,而上一篇2D游戏开发会给您打下基础,让您更轻松地跟上。
与上一篇一样,先下载这款游戏的资产:
Release Squash the Creeps 1.1.0 · godotengine/godot-3d-dodge-the-creeps · GitHubThis project was moved to https://github.com/godotengine/godot-demo-projects/tree/master/3d/squash_the_creeps - Release Squash the Creeps 1.1.0 · godotengine/godot-3d-dodge-the-creepshttps://github.com/godotengine/godot-3d-dodge-the-creeps/releases/tag/1.1.0我们将首先为玩家的移动制作一个基本的原型。然后,添加我们将在屏幕周围随机生成的怪物。之后,我们将实现跳跃和压扁机制,然后用一些漂亮的动画来完善游戏。我们将以分数和重玩屏幕结束。
前置知识
.material
文件在其中定义了游戏中 3D 物体的材质属性,比如物体的颜色、纹理、透明度、反射率等属性,控制着光线与 3D 物体的交互方式,决定了物体在游戏场景中的视觉表现。
.glb
文件以二进制格式存储数据,相比文本格式的 3D 模型文件(如.obj
),它能够更紧凑地存储大量的几何信息、材质属性和纹理数据等,从而减小文件体积,提高加载和传输速度。
设置游戏区域
在这一部分中,我们将设置游戏区域,让我们从导入初始资产、设置游戏场景入手。
新建一个游戏项目文件夹,比如gdgame,把刚才您下载的游戏资产全部解压拷贝进去。
这里我把字体文件中增加了黑体,用于在游戏里显示中文。
打开 Godot 项目管理器,然后点击导入按钮。
请在导入弹出框中输入刚才新建的目录 squash_the_creeps_start/
的完整路径。你也可以点击右侧的浏览按钮,打开文件浏览器并找到该文件夹所包含的 project.godot
文件。
点击导入并编辑在编辑器中打开该项目。
提示版本更新,选择转换完整项目:
起始项目中包含一个图标和两个文件夹:art/
和 fonts/
。你可以在里面找到游戏中我们会用到的艺术资产和音乐。
如果您转换后确实无法打开新版本引擎支撑下的项目,可以从头开始构建项目及场景,其实这也并不难,参考我以前的关于Godot一个2D游戏的文章。
创建好新项目如下:
如果出现下面情况,可以重新打开项目:
我们放大一下文件系统中的游戏资产部分:
里面有两个 3D 模型,player.glb
和 mob.glb
,一些模型使用的材质,以及一首音乐。
现在建立主场景:
官网文档原文:
We're going to create our main scene with a plain Node as its root. In the Scene dock, click the Add Child Node button represented by a "+" icon in the top-left and double-click on Node. Name the node
Main
. An alternate method to rename the node is to right-click on Node and choose Rename (or F2). Alternatively, to add a node to the scene, you can press Ctrl + A (Cmd + A on macOS).
您可以根据它的指引建立Node节点,并把它重命名为Main:
保存场景:Save the scene as main.tscn
by pressing Ctrl + S (Cmd + S on macOS).
我们先添加一个地板,以防止角色掉落。要创建地板、墙壁或天花板等静态碰撞器,可以使用 StaticBody3D 节点。它们需要 CollisionShape3D 子节点来定义碰撞区域。选择 Main 节点后,添加 StaticBody3D 节点,然后添加 CollisionShape3D。将 StaticBody3D 重命名为 Ground
。
在 CollisionShape3D 旁边会出现一个警告标志,因为我们还没有定义它的形状。如果你点击这个图标,就会弹出一个窗口,为你提供更多信息。
要创建形状,请选中 CollisionShape3D,转到检查器,然后单击 Shape(形状)属性旁边的 <空> 字段。创建一个新的 BoxShape3D。
盒子形状非常适合平坦的地面和墙壁。它的厚度使它能够可靠地阻挡甚至快速移动的物体。
一个框的线框图出现在带有三个橙色点的视口中。您可以单击并拖动这些以交互式编辑形状的范围。我们还可以在检查器中精确设置大小。单击 BoxShape3D 展开资源。在 X 轴上将其大小设置为 60,Y 轴为 2,Z 轴为 60。
英文原文是这样的:
A box's wireframe appears in the viewport with three orange dots. You can click and drag these to edit the shape's extents interactively. We can also precisely set the size in the inspector. Click on the BoxShape3D to expand the resource. Set its Size to
60
on the X-axis,2
for the Y-axis, and60
for the Z-axis.
碰撞形状是不可见的。我们需要添加一个与之配套的视觉层。选择 Ground
节点并添加一个 MeshInstance3D 作为其子节点。
在检查器中,点击 Mesh 旁边的字段,创建一个 BoxMesh 资源,创建一个可见的立方体。
再次设置大小,对于默认值来说它有点太小了。点击立方体图标展开资源,并将其 Size 设置为 60
、2
、60
。由于立方体资源使用的是大小(size)而不是范围(extents),我们需要使用这些值,以便它与我们的碰撞形状相匹配。
上图就是你应该会在视图中看到的:一个覆盖网格以及蓝色和红色轴的宽灰色平板。
我们要将地面向下移动,这样就能看到地板网格。 为此,可以使用网格捕捉功能。 在 3D 编辑器中,有两种方法可以激活网格捕捉功能。 第一种是按下 "使用捕捉 "按钮(或按 Y 键)。 第二种方法是选择一个节点,在小工具上拖动一个句柄,然后在点击的同时按住 Ctrl 键,这样只要按住 Ctrl 键就能启用捕捉。
首先使用您喜欢的方法设置套接。 然后使用 Y 轴(小工具上的绿色箭头)移动地面节点。
为了有一个可见的编辑器栅格,可以将地面往下移动 1 米。视口左下角的标签会显示你将该节点平移了多远。
子节点会跟随 Ground 节点一起往下移动。请确保你移动的是 Ground 节点,而不是 MeshInstance3D 和 CollisionShape3D。
现在来添加一个平行光,从而让我们的整个场景不全都是灰色的。选择 Main
节点,然后添加一个子节点 DirectionalLight3D。
们需要移动和旋转 DirectionalLight3D 节点。 点击并拖动操纵器上的绿色箭头将其向上移动,然后点击并拖动红色弧线将其绕 X 轴旋转,直到地面被点亮。
项目此时看起来是这个样子。
这就是我们的起点了,接下来,我们将处理玩家场景与基础移动。
Player 场景与输入事件
接下来,我们将会设计玩家场景、注册自定义输入动作、编写玩家移动代码。在最后,你将会得到一个可以八方向移动的可游玩角色。
在左上角的场景菜单中单击新建场景来创建一个新场景。
创建一个 CharacterBody3D 节点来当根节点:
将 CharacterBody3D 命名为``Player``。角色身体(Character body)对应的是 2D 游戏教程中的区域(Area)和刚体(Rigid Body)。与刚体类似,它可以移动并与环境发生碰撞,但它的运动并不是由物理引擎控制的,而是由*你*支配。当我们编写跳跃和踩踏机制时,你就会看到我们是如何使用这一该节点独有的特性的。
要学习更多关于不同物理节点类型的内容,请参阅 物理介绍。
现在,我们将为角色的 3D 模型创建一个基本的装备。稍后我们将在播放动画时通过代码旋转模型。
新建一个 Node3D 节点作为 Player
的子节点,并将其命名为 Pivot
然后在文件系统面板中,双击展开 art/
文件夹,将 player.glb
拖放到 Pivot
节点上。
这样应该就会把这个模型实例化为 Pivot
的子项。你可以将其重命名为 Character
。
.glb
文件包含基于开源的 GLTF 2.0 规范的 3D 场景数据。它是一种现代的、强大的并替代 FBX 等专有格式的文件,Godot 也支持这种格式。为了制作这些文件,我们在 Blender 3D 中设计了模型,并将其导出为 GLTF。
与所有类型的物理节点一样,我们的角色需要一个碰撞形状才能与环境相碰撞。再次选中 Player
节点并添加 CollisionShape3D 子节点。在检查器中,为 Shape 属性新建一个 SphereShape3D(3D 球体形状,旨在用于物理学)
球体的线框出现在角色的下面。
它将是物理引擎用来与环境碰撞的形状,因此我们希望它更适合 3D 模型。拖动视口中的橙色点,将其缩小一点。我的球体半径约为 0.8
米。
然后,向上移动碰撞体,使其底部与网格平面大致对齐。
为了使形状移动更容易,你可以通过点击“Character”或“Pivot”节点旁边的眼睛图标来切换模型的可见性。
节点准备就绪后,我们开始编写程序。但首先,我们需要定义一些输入动作。
创建输入动作
要移动角色,我们就要监听玩家的输入,比如按下方向键。在 Godot 中,我们能够使用代码来绑定按键,但还有一个非常强大的系统,可以让你为一系列按键和按钮设置标签。这样可以简化我们的脚本,让它们更易读。
这个系统是“按键映射”。可以在项目菜单中选择项目设置来打开编辑器。
这个绑定过程我在前面的2D游戏制作那一篇文章中有介绍。
打开项目设置:
选择输入映射:
顶部有许多标签。点击输入映射。
我们要把这些动作命名为 move_left
、move_right
、move_forward
、move_back
、jump
(向左移动、向右移动、向前移动、向后移动、跳跃)。
创建以下五个动作:
要为动作绑定按键或按钮,请点击右侧的“+”按钮。对
move_left
执行此操作,按下左方向键,然后单击确定。
这些就是这个游戏所需的所有动作了。你可以使用这个菜单来对项目中的任意按键和按钮组进行标记。
接下来,我们将为玩家的移动进行编程和测试。
使用代码移动玩家
该轮到编写代码了!我们将使用先前创建的输入动作来移动角色。
再次强调:
对于此项目,我们将遵循 Godot 的命名约定。
GDScript:类(节点)使用 PascalCase(大驼峰命名法),变量和函数使用 snake_case(蛇形命名法),常量使用 ALL_CAPS(全大写)(请参阅 GDScript 编写风格指南)。
C#:类、导出变量和方法使用 PascalCase(大驼峰命名法),私有字段使用 _camelCase(前缀下划线的小驼峰命名法),局部变量和参数使用 camelCase(小驼峰命名法)(请参阅 C# 风格指南)。连接信号时,请务必准确键入方法名称。
右键单击 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_velocity
是一个组合了速度和方向的 3D 向量。在这里,我们将其定义为属性,因为我们希望在帧之间更新并重用其值。
这些值与二维代码完全不同,因为距离以米为单位。在 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_*
输入,并相应地更新矢量的 x
和 z
分量。它们对应于地平面的轴。
这四个条件给了我们八个可能性和八个可能的方向。
如果玩家同时按下 W 键 和 D 键,这个向量长度大约为 1.4
。但如果他们只按一个键,则它的长度将为 1
。我们希望该向量的长度保持一致,而不是在对角线上移动得更快。为此,我们需调用其 normalize()
方法。
#func _physics_process(delta):
#...
if direction != Vector3.ZERO:
direction = direction.normalized()
# Setting the basis property will affect the rotation of the node.
$Pivot.basis = Basis.looking_at(direction)
在这里,我们只在方向的长度大于零的情况下对向量进行归一化,因为玩家正在按某个方向键。
通过创建一个朝 direction
方向搜寻的 Basis 来计算 $Pivot
所搜寻的方向。
然后,更新速度。需要分别计算地面速度和下降速度。请确保 tab 缩进,使行在 _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
施加重力。
对于垂直速度,在每一帧中减去下降加速度乘以增量时间(delta time,每个帧之间的时间,也称帧时间)。这条代码将使角色在没有在地板上或是碰撞地板的情况下,每帧都会下降。
物理引擎只有在运动和碰撞发生的情况下才能检测到在某一帧中与墙壁、地板或其他物体的相互作用。我们将在后面使用这个属性来编写跳跃的代码。
在最后一行,我们调用了 CharacterBody3D.move_and_slide()
,这是 CharacterBody3D
类的一个强大方法,可以让你顺利地移动一个角色。如果它在运动过程中撞到了墙,引擎会试着为你把它进行平滑处理。它使用的是 CharacterBody3D 自带的速度值
这就是你在地面上移动角色所需的所有代码。
测试玩家的移动
将玩家放在 Main
场景中进行测试,这时,需要先实例化玩家,然后添加相机。 3D 与 2D 不同,如果没有添加摄像机,你将无法看到任何物体。
保存 Player
场景,然后打开 Main
场景。可以点击编辑器顶部的 Main 选项卡切换。
如果场景之前已关闭,请转到 文件系统 面板,双击 main.tscn
文件重新打开。
要实例化 Player
,可右键单击 Main
节点,然后选择 实例化子场景 。
接下来让我们添加摄像机。 就像我们在制作 "玩家支点 "时一样,我们将创建一个基本的装备。 再次右键单击 "主节点",选择 "添加子节点"。 创建一个新的 Marker3D,并将其命名为 CameraPivot。 选择 CameraPivot 并为其添加一个子节点 Camera3D。 您的场景树应该与此相似。
官方原文
Let's add the camera next. Like we did with our Player's Pivot, we're going to create a basic rig. Right-click on the
Main
node again and select Add Child Node. Create a new Marker3D, and name itCameraPivot
. SelectCameraPivot
and add a child node Camera3D to it. Your scene tree should look similar to this.
请注意在选中 Camera 时,左上角会出现一个预览复选框。你可以单击预览游戏中的摄像机投影视角。
我们要使用 Pivot 来旋转摄像机,让他像被吊车吊起来一样。让我们先拆分 3D 视图,以便在进行自由移动的同时观察摄像机拍摄到的内容。
在视口上方的工具栏中,单击视图,然后单击2 个视口。你也可以按 Ctrl + 2(macOS 上则为 Cmd + 2)。
因为透视投影的缘故,我们会在角色的周围看到一些空白区域。在这个游戏中,我们要使用的是正交投影,从而更好地展示游戏区域,让玩家更易于识别距离。
再次选中 Camera,然后在检查器 中将 Projection(投影)设为 Orthogonal(正交)、将 Size(大小)设为 19
。角色现在看起来应该更加扁平,背景应该被地面充满。
当在 Godot 4 中使用正交相机时,方向阴影的质量取决于相机的 Far 值。Far 越高,相机能够看到的距离就更远。然而由于更高的 Far 值会使得阴影渲染必须覆盖到更远的距离,这个操作也会导致阴影质量下降。
如果在切换到正交相机后方向阴影看起来变得模糊,请减小相机的 Far 属性到更低的值,如 100
。请不要将 Far 属性减小得太多,否则远处的物体将会开始消失。
测试你的场景,你应该能够在所有 8 个方向上移动,并且不会穿过地板!
这样,我们就完成了玩家的移动以及视图。接下来,我们要来处理怪物。
设计小怪场景
在这一部分中,我们要为怪物编写代码,我们后续会称之为“mob”(小怪)。在2D游戏中,我们已经学会在游戏区域周围随机生成它们。
让我们在一个新场景中设计这些怪物。节点结构和 player.tscn
场景类似。
还是用 CharacterBody3D 节点作为根节点来创建场景。命名为 Mob。添加一个 Node3D 节点作为其子项,将其命名为 Pivot。将 mob.glb
文件从文件系统面板拖放到 Pivot 上,这样就把怪物的 3D 模型添加到了场景之中。
你可以将新创建的 mob
节点重命名成 Character
。
我们的实体要添加碰撞形状后才能正常工作。右键单击场景的根节点 Mob,然后单击添加子节点。
我们要调整一下它的大小,来更好地框住 3D 模型。可以单击并拖动橙色的小点来进行。
碰撞盒应该接触地面,并且比模型稍微瘦一点点。即便玩家的球体只接触了这个碰撞盒的角落,物理引擎也会判定发生了碰撞。如果盒子比 3D 模型要大一点,你可能距离怪物还有一定的距离就死了,玩家就会觉得不公平。
请注意,我的盒子要比怪物稍高。在这个游戏里是没问题的,因为我们是从游戏场景的上方用固定角度观察的。碰撞形状不必精确匹配模型。决定碰撞形状形式和大小的关键是你在试玩游戏时的手感。
移除离屏的怪物
我们要在游戏关卡中按照一定的时间间隔刷怪。如果你不小心,它们的数量可能就会无限地增长下去,我们可不想那样。每个小怪实例都需要付出一定的内存和处理代价,我们不希望让屏幕之外的小怪浪费资源。
怪物离开屏幕之后,我们就不再需要它了,所以我们可以把它删除。Godot 有一个可以检测对象离开屏幕的节点, VisibleOnScreenNotifier3D ,我们就要用它来销毁我们的小怪。
如果要在游戏中不断实例化同一种对象,可以通过一种叫“池化”(pooling)的技术来避免持续地创建和销毁实例。做法是预先创建一个该对象的数组,然后去不断地重用里面的元素。
使用 GDScript 时,你不必担心这个问题。用对象池的主要目的是避免 C# 或 Lua 等语言在进行垃圾回收(Garbage collection,GC)时所带来的停滞。GDScript 管理内存的技术和这些语言是不同的,用的是引用计数,不会产生那种问题。你可以在此了解更多相关内容:内存管理。
选中 Mob
节点,并为其添加一个 VisibleOnScreenNotifier3D 作为子项。这回出现的就是一个粉色的框。这个框完全离开屏幕后,该节点就会发出信号。
使用橙色的点来调整大小,让它覆盖住整个 3D 模型。
为小怪的移动编写代码
让我们来实现怪物的运动。我们要分两步来实现。首先,我们要为 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()
方法来移动小怪。这一回,我们不会再每帧更新 velocity
了:我们希望怪物匀速移动,然后离开屏幕,即便碰到障碍物也一样。
我们需要再定义一个函数来计算初始的速度。这个函数会让怪物面朝玩家,并将其运动角度和速度随机化。
这个函数接受小怪的生成位置 start_position
以及玩家的位置 player_position
作为参数。
我们首先将小怪定位在 start_position
并用 look_at_from_position()
方法将它转向玩家,并通过围绕 Y 轴旋转随机量来随机化角度。下面,rand_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 -45 and +45 degrees,
# so that it doesn't move directly towards the player.
rotate_y(randf_range(-PI / 4, PI / 4))
我们已经获取到了一个随机的位置,现在我们需要一个 random_speed
。randi_range()
可以给我们需要的随机整数,并且我们要使用 min_speed
和 max_speed
。random_speed
是一个整数,我们只是使用它与我们的 CharacterBody3D.velocity
相乘。在乘完 random_speed
之后,我们将 random_speed
旋转至朝向玩家的方向。
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)。
将信号连接到 Mob
这样会使你回到脚本编辑器,并且添加一个新的函数: _on_visible_on_screen_notifier_3d_screen_exited()
。请在里面调用 queue_free()
方法。这个函数会将调用它的实例销毁。
func _on_visible_on_screen_notifier_3d_screen_exited():
queue_free()
我们的怪物已经准备好进入游戏了!在下一部分,你将在游戏关卡中生成怪物。
Here is the complete mob.gd
script for reference.
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 -45 and +45 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(显示 -> 窗口)。在右侧将 Width(宽度)设为 720
、Height(高度)设为 540
。
创建生成路径
与 2D 游戏教程中所做的一样,你要设计一条路径,使用 PathFollow3D 节点在路径上随机取位置。
不过在 3D 中,路径绘制起来会有一点复杂。我们希望它是围绕着游戏视图的,这样怪物就会在屏幕外出现。但绘制的路径也同样不会在摄像机预览中出现。
我们可以用一些占位网格来确定视图的界限。你的视口应该还是分成两个部分的,底部是相机预览。如果不是的话,请按 Ctrl + 2(macOS 上则是 Cmd + 2)将视图一分为二。选中 Camera3D 节点,然后点击底部视口的预览复选框。
添加占位圆柱体
让我们来添加一些占位网格。为 Main
节点新建一个 Node3D 节点作为子项,命名为 Cylinders
。我们会用它将圆柱体进行分组。添加一个 MeshInstance3D 节点作为其子项
在检查器中,为 Mesh(网格)属性赋值 CylinderMesh(圆柱体网格)。
使用视口左上角的菜单,将上面的视口设为正交顶视图。或者你也可以按小键盘的 7。
地面栅格可能有一点分散注意力。你可以在工具栏的视图菜单中点击查看栅格进行开关。
你现在要沿着地平面移动圆柱体,看底部视口的相机预览。我推荐使用网格捕捉来做这件事。你可以通过点击工具栏上的磁铁图标或按 Y 键来切换。
将圆柱体移到相机视图的左上角,使其正好在视野之外。
我们将创建网格的副本,并将它们放置在游戏区域周围。按 Ctrl + D(在 macOS 上则为 Cmd + D)来复制节点。你也可以在场景面板中右击节点,选择制作副本。沿着蓝色 Z 轴向下移动副本,直到它正好在摄像机的预览范围之外。
按住 Shift 键选择两个圆柱体,并点击未选择的那个圆柱体,然后复制它们。
白色的有点难以看清是吧?让我们给它们一个全新的材质,让它们凸显出来。
在 3D 中,材质可以定义表面的外观属性,比如颜色、如何反射光照等。我们可以用材质来修改网格的颜色。
我们可以同时更新所有四个圆柱体。在场景面板中选中所有网格实例。要实现全选,可以先点击第一个,然后按住 Shift 点击最后一个。
在检查器中,展开 Material(材质)部分,为 0 号插槽分配一个 StandardMaterial3D。
点击球体图标来打开材质资源。你会看到材质的预览和一长串充满属性的部分。你可以用这些来创建各种表面,从金属到岩石或水。
展开 Albedo(反照率)部分。
将颜色设为与背景色存在对比的颜色,比如亮橙色。
我们现在可以使用圆柱体作为参考。点击它们旁边的灰箭头,将它们折叠在场景面板中。你也可以通过点击 Cylinders 旁边的眼睛图标来切换它们的可见性。
添加一个 Path3D 节点作为 Main
的子节点。在工具栏中会出现四个图标。点击添加点工具,即带有绿色“+”号的图标。
鼠标悬停在任意图标上,就可以看到描述该工具的工具提示。
单击每个圆柱体的中心以创建一个点。然后,单击工具栏中的闭合曲线图标以关闭路径。如果有任何一点偏离,你可以单击并拖动它以重新定位它。
你的路径看起来应该类似这样。
要对它的随机位置进行采样,我们需要一个 PathFollow3D 节点。
添加 PathFollow3D 作为 Path3D
的子项。将两个节点分别重命为 SpawnLocation
和 SpawnPath
。 这两个名字能够更明确地说明用途。
这样,我们就可以着手编写刷怪机制了。
随机生成怪物
右键点击 Main
节点,为它附加一个新脚本。
我们首先将一个变量导出到检查器中,这样我们就可以把 mob.tscn
或者其他任何怪物赋值给它。
我们希望以固定的时间间隔生成生物。为此,我们需要返回场景中并添加计时器。
但是,在此之前,我们需要将 mob.tscn
文件分配给 mob_scene
属性
回到 3D 屏幕,选中 Main
节点。将 mob.tscn
从文件系统面板拖到检查器的 Mob Scene 槽中。
为 Main
新建一个 Timer 节点作为子节点。将其命名为 MobTimer
。
在检查器中,将其 Wait Time(等待时间)设为 0.5
秒,然后打开 Autostart(自动开始),这样我们运行游戏它就会自动开始。
保持选中 MobTimer,在右侧的节点面板中双击 timeout
信号。
将它连接到 Main 节点。
这样你就会被带回脚本,其中新建了一个空的 _on_mob_timer_timeout()
函数。
让我们来编写刷怪的逻辑吧。我们要做的是:
实例化小怪的场景。
在生成路径上随机选取一个位置。
获取玩家的位置。
调用小怪的
initialize()
方法,传入随机位置和玩家的位置。将小怪添加为 Main 节点的子节点。
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 节点的 progress_ratio
属性所期望的:0 代表路径的开始点,1 代表路径的终点。 我们之前设置的路径是围绕着相机视口的,因此任何 0 到 1 之间的随机值都代表着沿着视口边缘的随机位置!
注意:如果你从主场景中移除了
Player
,那上述代码中,关于player的接下来的几行会报错!
var player_position = $Player.position
因为这个$Player缺失了。
下面是目前完整的 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 即可测试该场景。你应该会看到有怪物刷了出来,然后会进行直线运动。
如果对区域不是很满意,可以重新调整一下摄像机、光源、小怪的生成区域等。
运行一下:
角度效果基本合适,ok,那就继续下一步。
当然,到目前还是有很多问题的,比如,小怪们会在路线的交叉点撞到一起滑来滑去。接下来,咱们解决这些问题。
跳跃与踩扁怪物
在这一部分中,我们将添加跳跃、踩扁怪物的能力,后续,我们会让怪物在地面上击中玩家时让玩家死亡。
首先我们要修改一些物理交互相关的设置。请进入物理层的世界。
控制物理交互
物理实体可以访问两个互补的属性:层和遮罩。层(Layer)定义的是该对象位于哪些物理层上。
遮罩(Mask)控制的是该实体会监听并检测的层,会影响碰撞检测。希望两个实体能够发生交互时,你需要让其中至少一个的遮罩与另一个(的层)相对应。
可能有点绕,但请别担心,我们马上就会看到三个例子。
重要的知识点是,你能够使用层和遮罩来过滤物理交互、控制性能、让代码中不需要再做额外的条件检测。
默认情况下,所有物理体和区域的层和遮罩都被设成了 1
。也就是说它们会互相碰撞。
物理层由数字表示,但我们也可以为它们命名,记录什么是什么。
设置层名称
让我们来为物理层命名。打开项目 -> 项目设置。
在左侧的菜单中,找到 Layer Names -> 3D Physics(层名称 -> 3D 物理)。你可以在右侧看到层的列表,每一层右侧都有一个字段,可以用来设置名称。将前三层分别命名为“player”“enemies”“world”(玩家、敌人、世界)。
现在,我们就可以将它们分配给我们的物理节点了。
层和遮罩的分配
在 Main 场景中选中 Ground
节点。在检查器中展开 Collision(碰撞)部分。你可以看到,该节点的层和遮罩在这里以按钮网格的形式排列。
地面是世界的一部分,所以我们希望它属于第三层。点击 Layer 中的第一个点亮的按钮将其关闭,打开第三层。然后点击关闭 Mask。
上面说到过,Mask 属性可以让节点监听与其他物理对象的交互,但它不是实现碰撞所必须的。Ground
不需要监听任何东西;它存在的意义是防止生物下落。
请注意,点击右侧的“...”按钮会将该属性以带名称的复选框的形式展示。
接下来就是 Player
和 Mob
。在文件系统面板中双击打开 player.tscn
文件。
选中 Player 节点,将其 Collision -> Mask 设为“enemies”和“world”。Layer 属性可以保持默认,因为第一个层就是“player”层。
然后双击 mob.tscn
打开 Mob 场景,选中 Mob
节点。
将其 Collision -> Layer 设为“enemies”,然后取消 Collision -> Mask 的设置,让遮罩为空
这些设置意味着怪物可以互相穿越。如果你希望怪物之间会发生碰撞和滑动,请打开“enemies”遮罩。
小怪并不需要遮罩“world”层,因为它们只会沿着 XZ 平面移动。我们是故意不去为它们添加重力影响的。
在您进行上述这些操作的时候,还有个小细节要注意,万一不小心操作时动到了地面,一定要再次确认地面与游戏元素之间的位置,像如下这样:
有时候您的误操作会让地面抬高从而覆盖了游戏元素,你在调试时就可能会看到一片空白,这是新手容易懵圈的地方,就像下面这样:
这种情况下,您调试时将啥也看不到:
好,现在将地面调回正常情况,咱们继续:
小怪并不需要遮罩“world”层,因为它们只会沿着 XZ 平面移动。我们是故意不去为它们添加重力影响的。
跳跃
跳跃机制本身只需要两行代码。打开 Player 脚本。我们需要一个值来控制跳跃的强度,并更新 _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()
方法是来自 CharacterBody3D
类的工具。如果物体在这一帧中与地板发生碰撞返回 true
。这就是为什么我们要对 Player 施加重力的原因:这样我们就会与地板相撞,而不是像怪物一样漂浮在地板上。
如果角色在地板上并且玩家按下跳跃,立即给予角色较大的垂直速度,因为在游戏中,玩家通常希望控制能得到响应,就像这样提供的即时速度提升,虽然不切实际,但会令玩家感觉很好。
请注意,Y 轴的正方向是朝上的。这与 2D 有所不同, 2D的Y 轴的正方向是朝下的。
踩扁怪物
接下来让我们来添加踩扁机制。我们会让玩家在怪物身上弹起,并同时消灭它们。
我们需要检测与怪物的碰撞,并和与地板的碰撞相区分。要这么做,我们可以使用 Godot 的分组标签功能。
再次打开 mob.tscn
场景,选中 Mob 节点,就能在右侧的Node面板中看到信号的列表。Node面板有两个选项卡:你已经使用过的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
# Prevent further duplicate calls.
break
新函数很多。下面我们来进一步介绍一下。
函数 get_slide_collision_count()
和 get_slide_collision()
都来自于 CharacterBody3D 类,他们与 move_and_slide()
有关。
get_slide_collision()
返回的是 KinematicCollision3D 对象,包含碰撞在哪里发生、如何发生等信息。例如,我们对它的 get_collider
属性调用 is_in_group()
来检查我们是否是和“mob”发生了碰撞:collision.collider.is_in_group("mob")
。
每一个 Node 都可以使用
is_in_group()
方法。
我们使用向量点积 Vector3.UP.dot(collision.get_normal()) > 0.1
来检查我们是不是降落在怪物身上。碰撞法线(normal)是垂直于碰撞平面的 3D 向量。可以通过点积与上方向进行比较。
点积结果大于 0
时,两个向量的夹角小于 90 度。大于 0.1
表示我们大概位于怪物上方。
处理完踩扁和反弹逻辑后,我们通过 break
语句提前终止循环,以防止进一步重复调用 mob.squash()
,否则这可能会导致意外的错误,例如将一次击杀获得的分数算成好几倍。
我们调用了一个尚未定义的函数 mob.squash()
。所以我们需要把它加入到 Mob 类中。
英文原文:
Open the script
mob.gd
by double-clicking on it in the FileSystem dock. At the top of the script, we want to define a new signal namedsquashed
. And at the bottom, you can add the squash function, where we emit the signal and destroy the mob.
# Emitted when the player jumped on the mob.
signal squashed
# ...
func squash():
squashed.emit()
queue_free()
下一部分,我们会使用这个信号来加分数。
好了,你应该可以跳在怪物身上把它们消灭了。
你可以按 F5 试玩游戏,并把 main.tscn
设成项目的主场景。
不过玩家现在还不会死。我们会在下一部分实现。
杀死玩家
我们可以通过跳到敌人身上来杀死他们,但玩家仍然不能死亡。让我们来解决这个问题。
我们希望检测到被敌人击中与压扁敌人时的不同。我们希望玩家在地板上移动时死亡,但如果他们在空中,则不会死亡。我们可以使用向量数学来区分这两种碰撞。但是,我们将使用 Area3D 节点,该节点适用于命中框。
使用 Area 节点制作攻击框
回到 player.tscn
场景,添加一个新的 Area3D 子节点。把它命名为 MobDetector
(小怪检测器)。添加一个 CollisionShape3D 节点作为它的一个子节点。
在检查器中,给它指定一个圆柱体形状。
这里有一个技巧,你可以用它来使碰撞只发生在玩家在地面上或靠近地面时。你可以降低圆柱体的高度并将其向上移动到角色的顶部。这样,当玩家跳跃时,形状会太高,敌人无法与之碰撞。
你还希望圆柱体比球体更宽。这样一来,玩家在碰撞之前就会被击中,并被推到怪物的碰撞盒之上。
圆柱体越宽,玩家就越容易被杀死。
接下来,再次选择 MobDetector
节点,并在检查器中, 关闭 其 Monitorable 属性。
这使得其他物理节点无法检测到这个区域。补充的 Monitoring 属性允许它检测碰撞。然后,清除 Collision -> Layer,并将掩码设置为“enemies”层。
当区域检测到碰撞时,它们会发出信号。我们要将一个信号连接到 Player 节点。在节点选项卡中,双击 body_entered
信号并将其连接到 Player。
当一个 CharacterBody3D 或 RigidBody3D 节点进入它时,MobDetector 将发出 body_entered
信号。由于它只遮罩了“enemies”物理层,它将只检测 Mob 节点。
从代码上看,我们要做两件事:发出一个信号,我们以后会用来结束游戏,并销毁玩家。我们可以用 die()
函数来包装这些操作,帮助我们给代码贴上描述性标签。
结束游戏
我们可以利用 Player
的 hit
信号来结束游戏。我们所要做的就是将它连接到 Main
节点上,在处理时停止 MobTimer
。
打开 main.tscn
场景,选中 Player
节点,然后在节点面板中把 hit
信号连接到 Main
节点。
在 _on_player_hit()
函数中获取并停止计时器。
func _on_player_hit():
$MobTimer.stop()
如果你现在试玩游戏,你死亡后就会停止刷怪,现有的怪物会离开屏幕。
同时注意到在玩家死亡时,游戏不再崩溃或报错。 这是因为我们停止了 Mobtimer,也就不再触发 _on_mob_timer_timeout() 函数了.
另外请注意,敌人与玩家碰撞并死亡取决于 Player
和 Mob
的碰撞形状的大小和位置。你可能需要移动它们,调整它们的大小,以达到紧凑的游戏感觉。
你可以鼓励鼓励自己了:你做出了完整 3D 游戏的原型,虽说还有点粗糙。
在此基础上,我们将会添加计分、重启游戏的选项,你还会看到如何使用简单的动画让游戏变得更加活灵活现。
代码检查点
这些是 Main
、Mob
、Player
节点的完整脚本,仅供参考。你可以把它们和你的代码进行对比检查。
首先是 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 -45 and +45 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
# Prevent further duplicate calls.
break
# 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()
在下一部分中我们会添加计分和重试选项,再见。
分数与重玩
在这一部分中,我们会添加计分、播放音乐、重启游戏的能力。
我们要用一个变量来记录当前的分数,使用最简的界面在屏幕上显示。我们会用文本标签来实现。
在主场景中,添加一个新的 Control 节点作为 Main
的子项,命名为 UserInterface
。你会被自动切换到 2D 屏幕,可以在这里编辑你的用户界面 User Interface(UI)。
名为分数标签 ScoreLabel
的 Label
添加名为分数标签 ScoreLabel
的 Label节点:
在检查器中将该 Label 的 Text 设为类似“Score: 0”的占位内容。
并且,文本默认是白色的,和我们的游戏背景一样。我们需要修改它的颜色,才能在运行时看到。
向下滚动到 Theme Overrides(主题覆盖)然后展开 Colors(颜色)并点击 Font Color(字体颜色)旁边的黑框来为文字着色
UserInterface
节点让我们可以将 UI 组合到场景树的一个分支上,并且也让主题资源能够传播到它的所有子节点上。我们将会用它来设置游戏的字体。
创建 UI 主题
再次选中 UserInterface
节点。在检查器中为 Theme -> Theme 创建一个新的主题资源。
单击这个资源就会在底部面板中打开主题编辑器。会展示使用你的主题资源时内置 UI 控件的外观。
默认情况下,主题只有寥寥几个属性:Default Base Scale(默认基础缩放)、Default Font(默认字体)、Default Font Size(默认字体大小)。
你可以为主题资源添加更多属性,从而设计更复杂的用户界面,不过这就超出本系列的范畴了。要学习主题的创建和编辑,请参阅 GUI 皮肤简介。
这里的 Default Font 需要一个字体文件,就是你电脑上用的那种。常见的字体文件格式有两种:TrueType 字体(TTF)和 OpenType 字体(OTF)。
在文件系统面板中,展开 fonts
目录,单击我们包含在项目里的 Montserrat-Medium.ttf
文件并将其拖放到Default Font(默认字体)上。文本就又会出现在主题预览中了。
文本有一点小。将Default Font Size(默认字体大小)设置为 22
像素即可增大文本的大小。
跟踪得分
我们下一步是进行计分。为 ScoreLabel
附加一个新的脚本,并在其中定义 score
(分数)变量。
extends Label
var score = 0
每踩扁一只怪物,这个分数就应该加 1
。我们可以使用它们的 squashed
信号来得知发生的时间。不过,因为我们是用代码实例化的怪物,我们无法在编辑器中将怪物的信号连接到 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()
函数。
现在回到这个 score_label.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
(重试)。该节点会使用单一色彩填充矩形,我们用它来覆盖画面,达到变暗的效果。
要使其覆盖整个视口,可以使用工具栏中 锚点预设 菜单。
点击打开,并应用整个矩形命令。
什么都没发生。好吧,是几乎什么都没有;只有四个绿色的大头针移动到了选择框的四个角落。
这是因为 UI 节点(图标都是绿色)使用的是锚点和边距,它们都相对于它们父节点包围框。这里的 UserInterface
节点比较小,所以 Retry
会受限于它。
选中 UserInterface
然后也对其使用锚点预设 -> 整个矩形。Retry
节点就应该覆盖整个视口了。
让我们修改它的颜色,把游戏区域变暗。选中 Retry
,在检查器中将 Color(颜色)设置为透明的暗色。要实现整个效果,可以在取色器中将 A 滑动条拖到左边。它控制的是颜色的 Alpha 通道,也就是不透明度。
接下来,添加一个 Label 的节点作为 Retry
的子节点并且设置他的 Text 为“Press Enter to retry”。将其移动至屏幕中央,并且选择 Anchor Preset -> Center(锚点预设 > 居中)。
编写重试选项
我们现在就可以去编写代码,在玩家死亡时显示 Retry
节点,重玩时隐藏。
打开 main.gd
脚本。
首先,我们想要在游戏开始时隐藏覆盖层。将这一行加到 _ready()
函数中。
func _ready():
$UserInterface/Retry.hide()
然后在玩家受到攻击时,我们就显示这个覆盖层。
func _on_player_hit():
#...
$UserInterface/Retry.show()
最后,当 Retry
节点可见时,我们需要监听玩家的输入,按下回车键时让游戏重启。可以使用内置的 _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 在游戏开始时自动加载节点或场景,不依赖于当前场景。你还可以用它来创建能够全局访问的对象。
在场景菜单中单击新建场景,或者使用当前打开的场景旁边的 + 图标来创建一个新场景。
单击其他节点按钮,创建一个 AudioStreamPlayer 然后将其重命名为 MusicPlayer
(音乐播放器)。
我们在 art/
目录中包含了一条音乐音轨 House In a Forest Loop.ogg
。单击并把它拖放到检查器中的 Stream(流)属性上。同时要打开 Autoplay,这样音乐就会在游戏开始时自动播放了。
保存该音乐播放器场景。
我们需要将其注册为自动加载。前往菜单项目 -> 项目设置...,然后单击自动加载选项卡。
在路径字段中,输入场景的路径。
单击文件夹图标打开文件浏览器,然后双击 music_player.tscn。 然后,单击右侧的添加按钮注册节点。
现在,music_player.tscn 会加载到您打开或播放的任何场景中。 因此,如果现在运行游戏,音乐将在任何场景中自动播放。
在这一部分介绍前,我们来看一下在底层发生了什么。运行游戏时,你的场景面板会多出来两个选项卡:远程和本地。
你可以在远程选项卡中查看运行中的游戏的节点树。你会看到 Main 节点以及场景中所包含的所有东西,最底部是实例化的小怪。
顶部的是自动加载的 MusicPlayer
以及一个 root 节点,这是你的游戏的视口。
这一节课就是这样。在下一部分,我们会添加动画,让游戏更美观。
这是完整的 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场景,选中 Player
节点,然后添加一个 AnimationPlayer 节点。
(注意:需要在Player场景把我下面的操作做进去,而不是像我这样在主场景中做。)
动画停靠面板就会出现在底部面板中。
它的特点是顶部有一个工具栏和动画下拉菜单,中间有一个轨道编辑器,目前是空的,底部有过滤、捕捉和缩放选项。
让我们来创建一个动画。请点击动画 -> 新建。
将动画命名为“float”(漂浮)。
创建动画后,将显示时间轴,其中数字表示以秒为单位的时间。
我们希望让这个动画在游戏开始时自动开始播放,而且还应该循环播放。
要执行此操作,你可以单击动画工具栏上的自动播放按钮()和循环箭头。
你还可以单击右上角的图钉图标,将动画编辑器进行固定。这样它就不会在你点击视口取消选择节点时折叠。
在面板右上角将动画的时长设为 1.2
秒。
你应该看到灰色带子变宽了一点。它显示动画的开始和结束,垂直蓝线是你的时间光标。
单击并拖拽右下角的滑动条,即可将时间线进行缩放。
漂浮动画
使用动画播放器节点,你可以对所需任意数量的节点的大多数属性做动画。请注意检查器中属性旁的钥匙图标。在上面单击就可以创建一个关键帧,即对应属性的一对时间与值。关键帧会被插入到时间线上的时间光标处。
让我们来开始插入帧吧。这里,我们要为 Character
节点的位置(position)和旋转(rotation)做动画。
选中 Character
并在检查器中展开 Transform 栏。单击 Position 和 Rotation 旁的钥匙图标。
对于本文,我们只创建默认选择 RESET(重置)轨道
编辑器中会出现两个轨道,各有一个代表关键帧的菱形图标。
你可以在菱形滑块上单击并拖动,以移动它们的时间。
将位置(position )帧移动到 0.3
秒处,将旋转(rotation )帧移动到 0.1
秒处。
在灰色的时间线上单击并拖拽,将时间光标移动至 0.5
秒位置。
在 检查器 中,将 Position 的 Y 轴设置为 0.65
米,将 Rotation 的 X 轴设置为 8
。
如果你在检查器面板中没有看到属性,请在场景面板中再次点击 Character
节点。
为这两个属性分别创建一个关键帧
现在开始在时间线上拖动,将位置(position)的关键帧移动到 0.7
秒。
关于动画原理的讲解已经超出了本篇游戏制作的范围。请注意,你不想均匀地分配时间和空间。取而代之的是,动画师使用时间和间隔,这两个核心动画原则。你希望让它们存在一定的偏移,在角色的运动中产生对比,以使他们感觉生动。
将时间光标移动到动画结尾,即 1.2
秒。将 Y 平移量设为约 0.35
、X 旋转量设为 -9
度。再次为这两个属性添加帧。
单击播放按钮或者按 Shift + D 即可预览结果。单击停止按钮或者按 S 即可停止播放。
你可以看到引擎通过在关键帧之间插值来生成连续动画。不过目前,生成的动作非常机械。这是因为默认的插值是线性的,会导致持续的过渡,并且与现实世界中生物的移动方式不同。
我们可以使用缓动曲线来控制关键帧之间的过渡。
单击并拖拽,框选时间线上的前两个帧。
可以在检查器中同时编辑这两个帧的属性,其中就有一个属性叫做 Easing(缓动)。
再次播放动画以查看差异。前半部分应该已经感觉有点弹性了。
将缓动效果应用于旋转轨迹中的第二个关键帧。
对第二个平移关键帧执行相反操作,将其拖动到右侧。
动画制作好之后是这样的:
如果你运行游戏,玩家的生物就会漂浮起来!
如果这个生物离地面太近了,你可以将 Pivot
向上移动,达成偏移的目的。
使用代码控制动画
我们可以使用代码来根据玩家的输入控制动画的播放。让我们在角色移动时修改动画的速度吧。
点击 Player
旁的脚本图标打开其脚本。
在 _physics_process()
中检查 direction
向量的那一行之后添加如下代码。
func _physics_process(delta):
#...
if direction != Vector3.ZERO:
#...
$AnimationPlayer.speed_scale = 4
else:
$AnimationPlayer.speed_scale = 1
这段代码的作用是让玩家在移动时将播放速度乘以 4
。在停止移动时将其恢复原状。
我们提到 Pivot(轴心)可以在动画之上叠加变换。我们可以用下面这行代码使角色在跳跃时产生弧线。把它加在 _physics_process()
的最后。
func _physics_process(delta):
#...
$Pivot.rotation.x = PI / 6 * velocity.y / jump_impulse
为小怪制作动画
在 Godot 中还有一个很好的动画技巧:只要你使用类似的节点结构,你就可以把它们复制到不同的场景中。
例如,Mob
和 Player
场景都有 Pivot
和 Character
节点,所以我们可以在它们之间复用动画。
原文如下:
Open the Player scene, select the AnimationPlayer node and then click on Animation > Manage Animations.... Click the Copy animation to clipboard button (two small squares) alongside the float animation. Click OK to close the window.
打开 mob.tscn
,创建一个 AnimationPlayer 子节点并且选中它。点击 动画 > 管理动画 ,然后 新建库 ,你应该看到信息 "将创建全局库"。文本处留白然后点击OK。点击 粘贴 图标(剪贴板)然后它应当出现在窗口上。点击OK来关闭窗口。
原文如下:
Next, make sure that the autoplay button and the looping arrows (Animation looping) are also turned on in the animation editor in the bottom panel. That's it; all monsters will now play the float animation.
我们可以根据生物的 random_speed
来更改播放速度。打开 Mob 的脚本,在 initialize()
函数的末尾添加下面这行代码。
func initialize(start_position, player_position):
#...
$AnimationPlayer.speed_scale = random_speed / min_speed
这样,你就完成了你第一个完整 3D 游戏的编码。
恭喜!
附上完整的Player 脚本和Mob 脚本的代码:
Player:
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
# Prevent further duplicate calls.
break
# 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()
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 -45 and +45 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
终于结束了,好长的一篇,好长的一天!嘻嘻。