系列文章目录
Godot4 Godot 4引擎调试以及游戏开发-前言
Godot4 你的第一个2d游戏
Godot4 你的第一个3D游戏 上
一、生成怪物
在这一部分中,我们将沿着一条路径随机刷怪。在最后,怪物们就会在游戏区域到处乱跑了。
双击文件系统面板中的 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 键选择两个圆柱体,并点击未选择的那个圆柱体,然后复制它们。
拖拽红色的 X 轴,将它们移动到右侧。
白色的有点难以看清是吧?让我们给它们一个全新的材质,让它们凸显出来。
在 3D 中,材质可以定义表面的外观属性,比如颜色、如何反射光照等。我们可以用材质来修改网格的颜色。
我们可以同时更新所有四个圆柱体。在场景面板中选中所有网格实例。要实现全选,可以先点击第一个,然后按住 Shift 点击最后一个。
在检查器中,展开 Material(材质)部分,为 0 号插槽分配一个 StandardMaterial3D。
点击球体图标来打开材质资源。你会看到材质的预览和一长串充满属性的部分。你可以用这些来创建各种表面,从金属到岩石或水。
展开 Albedo(反照率)部分。
将颜色设为与背景色存在对比的颜色,比如亮橙色。
我们现在可以使用圆柱体作为参考。点击它们旁边的灰箭头,将它们折叠在场景面板中。你也可以通过点击 Cylinders 旁边的眼睛图标来切换它们的可见性。
添加一个 Path3D 节点作为 Main 的子节点。在工具栏中会出现四个图标。点击添加点工具,即带有绿色“+”号的图标。
单击每个圆柱体的中心以创建一个点。然后,单击工具栏中的闭合曲线图标以关闭路径。如果有任何一点偏离,你可以单击并拖动它以重新定位它。
你的路径看起来应该类似这样。
为了对它的随机位置进行采样,我们需要一个 PathFollow3D 节点。将 PathFollow3D 添加为 Path3D 的子项。将这两个节点分别重命名为 SpawnLocation 和 SpawnPath。它更能描述我们将使用它们的用途。
这样,我们就可以着手编写刷怪机制了。
随机生成怪物
右键点击 Main 节点,为它附加一个新脚本。
我们首先将一个变量导出到检查器中,这样我们就可以把 mob.tscn 或者其他任何怪物赋值给它。
extends Node
@export var mob_scene: PackedScene
我们希望以固定的时间间隔生成生物。为此,我们需要返回场景中并添加计时器。但是,在此之前,我们需要将 mob.tscn 文件分配给 mob_scene 属性
回到 3D 屏幕,选中 Main 节点。将 mob.tscn 从文件系统面板拖到检查器的 Mob Scene 槽中。
为 Main 新建一个 Timer 节点作为子节点。将其命名为 MobTimer。
在检查器中,将其 Wait Time(等待时间)设为 0.5 秒,然后打开 Autostart(自动开始),这样我们运行游戏它就会自动开始。
计时器在每次到达 Wait Time 时都会发出 timeout 信号。计时器默认会自动重启,循环触发信号。我们可以将 Main 节点连接到这个信号,每 0.5 秒生成一只怪物。
保持选中 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 之间的随机值都代表着沿着视口边缘的随机位置!
这是目前完整的 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 即可测试该场景。你应该会看到有怪物刷了出来,然后会进行直线运动。
目前,它们会在路线的交叉点撞到一起滑来滑去。我们会在下一部分解决这个问题。
二、跳跃与踩扁怪物
在这一部分中,我们将添加跳跃、踩扁怪物的能力。在下一节课中,我们会让怪物在地面上击中玩家时让玩家死亡。
首先我们要修改一些物理交互相关的设置。请进入物理层的世界。
控制物理交互
物理实体可以访问两个互补的属性:层和遮罩。层(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”遮罩。
跳跃
跳跃机制本身只需要两行代码。打开 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”)。
我们使用向量点积 Vector3.UP.dot(collision.get_normal()) > 0.1 来检查我们是不是降落在怪物身上。碰撞法线(normal)是垂直于碰撞平面的 3D 向量。可以通过点积与上方向进行比较。
点积结果大于 0 时,两个向量的夹角小于 90 度。大于 0.1 表示我们大概位于怪物上方。
处理完踩扁和反弹逻辑后,我们通过 break 语句提前终止循环,以防止进一步重复调用 mob.squash() ,否则这可能会导致意外的错误,例如将一次击杀获得的分数算成好几倍。
我们调用了一个尚未定义的函数 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 节点制作攻击框
回到 player.tscn 场景,添加一个新的 Area3D 子节点。把它命名为 MobDetector(小怪检测器)。添加一个 CollisionShape3D 节点作为它的一个子节点。
在检查器中,给它指定一个圆柱体形状。
这里有一个技巧,你可以用它来使碰撞只发生在玩家在地面上或靠近地面时。你可以降低圆柱体的高度并将其向上移动到角色的顶部。这样,当玩家跳跃时,形状会太高,敌人无法与之碰撞。
你还希望圆柱体比球体更宽。这样一来,玩家在碰撞之前就会被击中,并被推到怪物的碰撞盒之上。
圆柱体越宽,玩家就越容易被杀死。
接下来,再次选择 MobDetector 节点,并在检查器中, 关闭 其 Monitorable 属性。这使得其他物理节点无法检测到这个区域。补充的 Monitoring 属性允许它检测碰撞。然后,清除 Collision -> Layer,并将掩码设置为“enemies”层。
当区域检测到碰撞时,它们会发出信号。我们要将一个信号连接到 Player 节点。在节点选项卡中,双击 body_entered 信号并将其连接到 Player
当一个 CharacterBody3D 或 RigidBody3D 节点进入它时,MobDetector 将发出 body_entered 信号。由于它只遮罩了“enemies”物理层,它将只检测 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 再试一下游戏。如果一切设置正确,角色在被敌人碰到时应该会死亡
var player_position = $Player.position
由于此处没有 $Player 导致的报错!
另外请注意,敌人与玩家碰撞并死亡取决于 Player 和 Mob 的碰撞形状的大小和位置。你可能需要移动它们,调整它们的大小,以达到紧凑的游戏感觉。
结束游戏
我们可以利用 Player 的 hit 信号来结束游戏。我们所要做的就是将它连接到 Main 节点上,在处理时停止 MobTimer。
打开 main.tscn 场景,选中 Player 节点,然后在节点面板中把 hit 信号连接到 Main 节点。
在 _on_player_hit() 函数中获取并停止计时器。
func _on_player_hit():
$MobTimer.stop()
如果你现在试玩游戏,你死亡后就会停止刷怪,现有的怪物会离开屏幕。
你可以鼓励鼓励自己了:你做出了完整 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 -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
# 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
在检查器中将该 Label 的 Text 设为类似“Score: 0”的占位内容。
并且,文本默认是白色的,和我们的游戏背景一样。我们需要修改它的颜色,才能在运行时看到。
向下滚动到 Theme Overrides(主题覆盖)然后展开 Colors(颜色)并点击 Font Color(字体颜色)旁边的黑框来为文字着色
最后单击视口中的文本,将其拖离左上角。
UserInterface 节点让我们可以将 UI 组合到场景树的一个分支上,并且也让主题资源能够传播到它的所有子节点上。我们将会用它来设置游戏的字体。
创建 UI 主题
再次选中 UserInterface 节点。在检查器中为 Theme -> Theme 创建一个新的主题资源。
单击这个资源就会在底部面板中打开主题编辑器。会展示使用你的主题资源时内置 UI 控件的外观。
默认情况下,主题只有一个属性,Default Font(默认字体)。
参见
你可以为主题资源添加更多属性,从而设计更复杂的用户界面,不过这就超出本系列的范畴了。要学习主题的创建和编辑,请参阅 GUI 皮肤简介。
这里需要的是一个字体文件,就是你电脑上用的那种。常见的字体文件格式有两种: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() 函数。
回到 ScoreLabel.gd 脚本,定义回调函数 _on_mob_squashed()。
这里我们将进行加分并更新显示的文本。
func _on_mob_squashed():
score += 1
text = "Score: %s" % score
第二行用 score 变量的值替换占位符 %s。使用此功能时,Godot 会自动将值转换为字符串文本,这在向标签中输出文本或者使用 print() 函数时非常方便。
你现在可以玩游戏,压死几个敌人,看看分数的增长。
重玩游戏
我们现在就要添加死亡后重玩的能力。玩家死亡后,我们会在屏幕上现实一条消息并等待输入。
回到 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,这样音乐就会在游戏开始时自动播放了。
将这个场景保存为 MusicPlayer.tscn。
我们需要将其注册为自动加载。前往菜单项目 -> 项目设置…,然后单击自动加载选项卡。
路径输入框中需要输入场景的路径。单击文件夹图标打开文件浏览器,然后双击 MusicPlayer.tscn。接下来,单击右侧的添加按钮,将该节点进行注册。
MusicPlayer.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 节点,然后添加一个 AnimationPlayer 节点。
动画停靠面板就会出现在底部面板中。
它的特点是顶部有一个工具栏和动画下拉菜单,中间有一个轨道编辑器,目前是空的,底部有过滤、捕捉和缩放选项。
让我们来创建一个动画。请点击动画 -> 新建。
将动画命名为“float”(漂浮)。
创建动画后,将显示时间轴,其中数字表示以秒为单位的时间。
我们希望让这个动画在游戏开始时自动开始播放,而且还应该循环播放。
要实现这个需求,可以单击动画工具栏上对应的“A+”图标和循环箭头。
你还可以单击右上角的图钉图标,将动画编辑器进行固定。这样它就不会在你点击视口取消选择节点时折叠。
在面板右上角将动画的时长设为 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 。
为这两个属性分别创建一个关键帧
现在开始在时间线上拖动,将位置(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 节点,所以我们可以在它们之间复用动画。
打开 Player 场景,选中动画播放器节点,打开“float”(漂浮)动画。然后点击动画 -> 复制。然后打开 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,你可以用它们来校对你的代码。
这是 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 -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