第3部分

部分概述

在这个部分, 我们将通过给玩家的武器提供弹药来限制他们. 还将赋予玩家重新装填的能力, 并且将添加武器开火时的声音.

../../../_images/PartThreeFinished.png

注解

在继续本教程的这一部分之前, 我们假设您已经完成了 第2部分. 完成的项目来自 第2部分 将成为第3部分的起始项目

让我们开始吧!

改变水平

现在我们已经有了一个完全可以运作的FPS, 让我们进入一个更像FPS的级别.

打开 Space_Level.tscn (assets / Space_Level_Objects / Space_Level.tscn)和/或 Ruins_Level.tscn (assets / Ruin_Level_Objects / Ruins_Level.tscn).

Space_Level.tscnRuins_Level.tscn 是为了本教程而制作的完整的自定义FPS关卡. 按 Play Current Scene 按钮, 或按键盘上的 F6, 分别尝试一下.

警告

Space_Level.tscn 对于GPU的图形要求比 Ruins_Level.tscn 更高. 如果您的计算机正在努力渲染 Space_Level.tscn , 请尝试使用 Ruins_Level.tscn 代替.

注解

由于本教程发布后Godot的更新, 如果您使用的是Godot 3.2或更高版本, 您可能需要对Space关卡和Ruins Level关卡场景进行以下更改:

  • 打开 res://assets/Space_Level_Objects/Space_Level.tscn .

  • 在场景树面板中, 选择 Floor_and_Celing 节点. 在(属性)检查器面板中, 如果GridMap下的Mesh Library字段为 [空], 则将其设置为 Space_Level_Mesh_Lib.tres, 将文件 res://assets/Space_Level_Objects/Space_Level_Mesh_Lib.tres 从文件系统面板拖放到该字段.

  • Walls 节点做同样的操作.

  • 打开 res://assets/Ruin_Level_Objects/Ruins_Level.tscn .

  • 在场景树面板中, 选择 Floor 节点. 在(属性)检查器面板中, 如果GridMap下的Mesh Library字段是 [空] , 则将其设置为 Ruin_Level_Mesh_Lib.tres , 将文件 res://assets/Ruin_Level_Objects/Ruin_Level_Mesh_Lib.tres 从文件系统拖放到该字段.

  • Walls 节点做同样的操作.

您可能已经注意到有几个 RigidBody 节点放在整个关卡中. 我们可以在它们上面放置 RigidBody_hit_test.gd 然后它们会对被子弹击中做出反应, 所以让我们这样做吧!

按照以下说明选择您要使用的场景中的任何一个(或两个)

Expand "Other_Objects" and then expand "Physics_Objects".

Expand one of the "Barrel_Group" nodes and then select "Barrel_Rigid_Body" and open it using
the "Open in Editor" button.
This will bring you to the "Barrel_Rigid_Body" scene. From there, select the root node and
scroll the inspector down to the bottom.
Select the drop down arrow under the "Node" tab, and then select "Load". Navigate to
"RigidBody_hit_test.gd" and select "Open".

Return back to "Space_Level.tscn".

Expand one of the "Box_Group" nodes and then select "Crate_Rigid_Body" and open it using the
"Open in Editor" button.
This will bring you to the "Crate_Rigid_Body" scene. From there, select the root node and
scroll the inspector down to the bottom.
Select the drop down arrow under the "Node" tab, and then select "Load". Navigate to
"RigidBody_hit_test.gd" and select "Open".

Return to "Space_Level.tscn".
Expand "Misc_Objects" and then expand "Physics_Objects".

Select all the "Stone_Cube" RigidBodies and then in the inspector scroll down to the bottom.
Select the drop down arrow under the "Node" tab, and then select "Load". Navigate to
"RigidBody_hit_test.gd" and select "Open".

Return to "Ruins_Level.tscn".

现在你可以向任一关卡中的所有刚体开火, 它们会对子弹击中而做出反应!

添加弹药

现在游戏角色有枪, 让我们给他们一些有限的弹药.

首先, 我们需要在每个武器脚本中定义一些变量.

打开 Weapon_Pistol.gd 并添加以下类变量:

var ammo_in_weapon = 10
var spare_ammo = 20
const AMMO_IN_MAG = 10
  • ammo_in_weapon: 手枪中弹药的数量

  • spare_ammo: 我们为手枪留下的弹药量

  • AMMO_IN_MAG: 武器或弹仓完全重装后所容纳的弹药数量

现在我们需要做的就是在 fire_weapon 中添加一行代码.

在``Clone.BULLET_DAMAGE = DAMAGE``下添加以下内容:ammo_in_weapon - = 1

这将在玩家每次开火时从 ammo_in_weapon 中删除一个. 请注意, 我们不是在 fire_weapon 中检查玩家是否有足够的弹药, 而是在 Player.gd 中检查玩家是否有足够的弹药.


现在我们需要为步枪和刀子添加弹药.

注解

你可能想知道为什么我们要为小刀添加弹药, 它不消耗任何弹药, 之所以要给小刀添加弹药, 是为了让我们的所有武器有一个一致的界面.

如果我们不为刀子添加弹药变量, 就必须为刀子添加检查. 通过给刀子添加弹药变量, 就不用担心武器是否都有相同的变量.

将以下类变量添加到 Weapon_Rifle.gd:

var ammo_in_weapon = 50
var spare_ammo = 100
const AMMO_IN_MAG = 50

然后将以下内容添加到 fire_weapon:ammo_in_weapon - = 1. 确保``ammo_in_weapon - = 1``在``if ray.is_colliding()``之后检查, 这样无论游戏角色是否击中某个东西, 游戏角色都会失去弹药.

现在剩下的就是刀. 将以下内容添加到 Weapon_Knife.gd:

var ammo_in_weapon = 1
var spare_ammo = 1
const AMMO_IN_MAG = 1

因为这把刀不消耗弹药, 所以我们只需要添加.


现在我们需要在" Player.gd"中更改一件事, 也就是说,

如何在 process_input 中发射武器. 将发射武器的代码改为:

# ----------------------------------
# Firing the weapons
if Input.is_action_pressed("fire"):
    if changing_weapon == false:
        var current_weapon = weapons[current_weapon_name]
        if current_weapon != null:
            if current_weapon.ammo_in_weapon > 0:
                if animation_manager.current_state == current_weapon.IDLE_ANIM_NAME:
                    animation_manager.set_animation(current_weapon.FIRE_ANIM_NAME)
# ----------------------------------

现在武器的弹药数量有限, 并且当游戏角色用尽时将停止射击.


理想情况下, 我们希望让玩家能够看到还剩多少弹药. 让我们做一个新的函数, 叫做 process_UI .

首先, 将 process_UI(delta) 添加到 _physics_process.

现在将以下内容添加到 Player.gd:

func process_UI(delta):
    if current_weapon_name == "UNARMED" or current_weapon_name == "KNIFE":
        UI_status_label.text = "HEALTH: " + str(health)
    else:
        var current_weapon = weapons[current_weapon_name]
        UI_status_label.text = "HEALTH: " + str(health) + \
                "\nAMMO: " + str(current_weapon.ammo_in_weapon) + "/" + str(current_weapon.spare_ammo)

让我们回顾一下发生的事情:

首先, 我们检查当前的武器是 UNARMED 还是 KNIFE . 如果是, 我们改变 UI_status_label 的文字, 只显示玩家的健康状况, 因为 UNARMEDKNIFE 不消耗弹药.

如果游戏角色正在使用消耗弹药的武器, 我们首先获得武器节点.

然后我们将 UI_status_label 的文字改为显示玩家的健康状况, 以及玩家在武器中的弹药数量和该武器的备用弹药数量.

现在我们可以看到游戏角色通过HUD获得了多少弹药.

添加重装到武器

现在游戏角色可以用尽弹药, 我们需要一种方法让游戏角色填补它们. 我们接下来再添加重装!

对于重装, 我们需要给每把武器增加一些变量和一个函数.

打开 Weapon_Pistol.gd 并添加以下类变量:

const CAN_RELOAD = true
const CAN_REFILL = true

const RELOADING_ANIM_NAME = "Pistol_reload"
  • CAN_RELOAD: 一个布尔值, 用于跟踪此武器是否具有重新加载的能力

  • CAN_REFILL: 一个布尔值, 用于跟踪我们是否可以重新填充此武器的备用弹药. 我们不会在这部分使用 CAN_REFILL , 但我们将在下一部分中使用!

  • RELOADING_ANIM_NAME: 此武器的重新加载动画的名称.

现在我们需要添加一个处理重载的函数. 将以下函数添加到 Weapon_Pistol.gd:

func reload_weapon():
    var can_reload = false

    if player_node.animation_manager.current_state == IDLE_ANIM_NAME:
        can_reload = true

    if spare_ammo <= 0 or ammo_in_weapon == AMMO_IN_MAG:
        can_reload = false

    if can_reload == true:
        var ammo_needed = AMMO_IN_MAG - ammo_in_weapon

        if spare_ammo >= ammo_needed:
            spare_ammo -= ammo_needed
            ammo_in_weapon = AMMO_IN_MAG
        else:
            ammo_in_weapon += spare_ammo
            spare_ammo = 0

        player_node.animation_manager.set_animation(RELOADING_ANIM_NAME)

        return true

    return false

让我们回顾一下发生的事情:

首先, 我们定义一个变量, 以查看此武器是否可以重新加载.

然后我们检查游戏角色是否处于这个武器的空闲动画状态, 因为我们只希望能够在游戏角色没有开火, 装备或无装备时重新加载.

接下来我们检查一下玩家是否有备用弹药, 以及武器中已有的弹药是否等于满载的武器. 这样我们就可以保证在玩家没有弹药或者武器已经装满弹药的情况下, 不能重新装填.

如果还能重装, 那么我们就计算一下重装武器所需要的弹药数量.

如果游戏角色有足够的弹药来填充武器, 我们从 spare_ammo 中移除所需的弹药, 然后将 ammo_in_weapon 设置为武器/弹仓的满载值.

如果玩家没有足够的弹药, 我们就把 spare_ammo 中剩余的弹药全部加进去, 然后把 spare_ammo 设为 0 .

接下来我们播放这个武器的重新加载动画, 然后返回 true .

如果游戏角色无法重新加载, 我们会返回 "false".


现在我们需要为步枪添加重装. 打开 Weapon_Rifle.gd 并添加以下类变量:

const CAN_RELOAD = true
const CAN_REFILL = true

const RELOADING_ANIM_NAME = "Rifle_reload"

这些变量与手枪完全相同, 只是将 "RELOADING_ANIM_NAME" 改为步枪的重装动画.

现在我们需要将 reload_weapon 添加到 Weapon_Rifle.gd:

func reload_weapon():
    var can_reload = false

    if player_node.animation_manager.current_state == IDLE_ANIM_NAME:
        can_reload = true

    if spare_ammo <= 0 or ammo_in_weapon == AMMO_IN_MAG:
        can_reload = false

    if can_reload == true:
        var ammo_needed = AMMO_IN_MAG - ammo_in_weapon

        if spare_ammo >= ammo_needed:
            spare_ammo -= ammo_needed
            ammo_in_weapon = AMMO_IN_MAG
        else:
            ammo_in_weapon += spare_ammo
            spare_ammo = 0

        player_node.animation_manager.set_animation(RELOADING_ANIM_NAME)

        return true

    return false

这个代码和手枪的代码完全一样.


我们需要为武器做的最后一点是向刀子添加 "重装". 将以下类变量添加到 Weapon_Knife.gd:

const CAN_RELOAD = false
const CAN_REFILL = false

const RELOADING_ANIM_NAME = ""

由于我们都无法重新加载或重新填充刀, 我们将两个常量都设置为 "false". 我们还将 RELOADING_ANIM_NAME 定义为空字符串, 因为该刀没有重新加载动画.

现在我们需要添加 reloading_weapon:

func reload_weapon():
    return false

由于我们无法重装刀, 我们总是返回 false .

添加重新加载到游戏角色

现在我们需要在 Player.gd 中添加一些内容. 首先, 我们需要定义一个新的类变量:

var reloading_weapon = false
  • reloading_weapon: 一个变量, 用于跟踪游戏角色当前是否正在尝试重新加载.

接下来我们需要为 _physics_process 添加另一个函数调用.

process_reloading(delta) 添加到 _physics_process . 现在 _physics_process 应该是这样的:

func _physics_process(delta):
    process_input(delta)
    process_movement(delta)
    process_changing_weapons(delta)
    process_reloading(delta)
    process_UI(delta)

现在我们需要添加 process_reloading . 将以下函数添加到 Player.gd:

func process_reloading(delta):
    if reloading_weapon == true:
        var current_weapon = weapons[current_weapon_name]
        if current_weapon != null:
            current_weapon.reload_weapon()
        reloading_weapon = false

让我们回顾一下这里发生的事情.

首先, 我们检查一下玩家是否在尝试重装.

如果游戏角色正在尝试重新加载, 我们将获得当前的武器. 如果当前武器不等于 null , 我们称之为 reload_weapon 函数.

注解

如果当前武器等于 "null", 则当前武器为 "UNARMED".

最后, 我们把 reloading_weapon 设置为 false , 因为无论玩家是否成功重装, 都已经尝试过重装, 不再需要继续尝试.


在我们让游戏角色重新加载之前, 我们需要在 process_input 中更改一些内容.

我们首先需要修改的是更换武器的代码. 需要增加一个额外的检查( if reloading_weapon == false: )来查看玩家是否在重装:

if changing_weapon == false:
    # New line of code here!
    if reloading_weapon == false:
        if WEAPON_NUMBER_TO_NAME[weapon_change_number] != current_weapon_name:
            changing_weapon_name = WEAPON_NUMBER_TO_NAME[weapon_change_number]
            changing_weapon = true

这使得如果游戏角色重新加载, 游戏角色无法改变武器.

现在我们需要添加代码以在游戏角色按下 "reload" 动作时触发重新加载. 将以下代码添加到 process_input:

# ----------------------------------
# Reloading
if reloading_weapon == false:
    if changing_weapon == false:
        if Input.is_action_just_pressed("reload"):
            var current_weapon = weapons[current_weapon_name]
            if current_weapon != null:
                if current_weapon.CAN_RELOAD == true:
                    var current_anim_state = animation_manager.current_state
                    var is_reloading = false
                    for weapon in weapons:
                        var weapon_node = weapons[weapon]
                        if weapon_node != null:
                            if current_anim_state == weapon_node.RELOADING_ANIM_NAME:
                                is_reloading = true
                    if is_reloading == false:
                        reloading_weapon = true
# ----------------------------------

让我们回顾一下这里发生的事情.

首先, 我们确保游戏角色没有重新加载, 游戏角色也不会尝试更换武器.

然后我们检查是否按下了 reload 动作.

如果玩家按了 reload , 我们就会得到当前的武器, 并检查它是否是 null . 然后使用常量 CAN_RELOAD 检查武器是否可以重装.

如果武器可以重新加载, 我们将获得当前动画状态, 并创建一个变量来跟踪游戏角色是否已经重新加载.

然后我们通过每一件武器来确保游戏角色还没有玩过那个武器的重装动画.

如果玩家没有重新装填任何武器, 我们将 reloading_weapon 设置为 true .


我想补充的一件事是, 如果您试图发射武器并且没有弹药, 那么武器会自动重装.

我们还需要增加一个额外的if检查( is_reloading_weapon == false: ), 这样玩家在重装时当前的武器就不能发射.

让我们在 process_input 中更改我们的触发代码, 以便在尝试触发空武器时重新加载:

# ----------------------------------
# Firing the weapons
if Input.is_action_pressed("fire"):
    if reloading_weapon == false:
        if changing_weapon == false:
            var current_weapon = weapons[current_weapon_name]
            if current_weapon != null:
                if current_weapon.ammo_in_weapon > 0:
                    if animation_manager.current_state == current_weapon.IDLE_ANIM_NAME:
                        animation_manager.set_animation(current_weapon.FIRE_ANIM_NAME)
                else:
                    reloading_weapon = true
# ----------------------------------

现在我们在发射武器之前, 先检查一下玩家是否有重新装填弹药, 当前武器中的弹药为 0 或更少时, 如果玩家试图发射, 就将 reloading_weapon 设置为 true .

这将使玩家在试图发射空武器时, 会尝试重新装填.


做好了这些, 玩家现在就可以重装了!试试吧!现在你可以把每把武器的备用弹药全部发射出去.

添加声音

最后, 让我们添加一些伴随玩家射击, 重装和更换武器的声音.

小技巧

本教程中没有提供游戏声音(出于法律原因). https://gamesounds.xyz/是 免版税或公共领域音乐和适合游戏的声音的集合 . 我使用了Gamemaster的Gun Sound Pack, 可以在Sonniss.com GDC 2017 Game Audio Bundle中找到.

打开 Simple_Audio_Player.tscn . 它只是一个 Spatial , 其中 AudioStreamPlayer 作为它的子节点.

注解

这被称为 "简单" 音频播放器的原因是因为我们没有考虑性能, 因为代码旨在以最简单的方式提供声音.

如果您想使用3D音频, 所以它听起来像是来自3D空间中的一个位置, 右键单击 AudioStreamPlayer 并选择 "更改类型".

这将打开节点浏览器. 导航到 AudioStreamPlayer3D 并选择 "更改". 在本教程的源代码中, 我们将使用 AudioStreamPlayer, 但如果需要, 您可以选择使用 AudioStreamPlayer3D, 无论哪一个, 下面提供的代码都可以使用 您选择了.

创建一个新脚本并将其命名为 Simple_Audio_Player.gd . 将它附加到 Simple_Audio_Player.tscn 中的 Spatial 并插入以下代码:

extends Spatial

# All of the audio files.
# You will need to provide your own sound files.
var audio_pistol_shot = preload("res://path_to_your_audio_here")
var audio_gun_cock = preload("res://path_to_your_audio_here")
var audio_rifle_shot = preload("res://path_to_your_audio_here")

var audio_node = null

func _ready():
    audio_node = $Audio_Stream_Player
    audio_node.connect("finished", self, "destroy_self")
    audio_node.stop()


func play_sound(sound_name, position=null):

    if audio_pistol_shot == null or audio_rifle_shot == null or audio_gun_cock == null:
        print ("Audio not set!")
        queue_free()
        return

    if sound_name == "Pistol_shot":
        audio_node.stream = audio_pistol_shot
    elif sound_name == "Rifle_shot":
        audio_node.stream = audio_rifle_shot
    elif sound_name == "Gun_cock":
        audio_node.stream = audio_gun_cock
    else:
        print ("UNKNOWN STREAM")
        queue_free()
        return

    # If you are using an AudioStreamPlayer3D, then uncomment these lines to set the position.
    #if audio_node is AudioStreamPlayer3D:
    #    if position != null:
    #        audio_node.global_transform.origin = position

    audio_node.play()


func destroy_self():
    audio_node.stop()
    queue_free()

小技巧

play_sound 中默认将 position 设置为 null , 我们将它作为一个可选的参数, 这意味着 position 不一定要传入才能调用 play_sound .

让我们回顾一下这里发生的事情:


_ready 中我们得到 AudioStreamPlayer 并将其 finished 信号连接到 destroy_self 函数. 无论是否为 AudioStreamPlayerAudioStreamPlayer3D 节点, 因为它们都有finished信号. 为了确保它不播放任何声音, 我们在 AudioStreamPlayer 上调用 stop .

警告

确保您的声音文件 不是 设置为循环! 如果设置为循环, 声音将无限播放, 脚本将无法正常工作!

play_sound 函数是我们将从 Player.gd 调用的函数. 我们检查声音是否是三种可能的声音之一, 如果它是三种声音之一, 我们将音频流设置为 AudioStreamPlayer 到正确的声音.

如果是未知声音, 我们会向控制台输出错误消息并释放音频播放器.

如果您使用的是 AudioStreamPlayer3D , 请删除 # 以设置音频播放器节点的位置, 使其在正确的位置播放.

最后, 我们告诉 AudioStreamPlayer 来玩.

AudioStreamPlayer 播放声音时, 它将调用 destroy_self , 因为我们连接了 _ready 中的 finished 信号. 我们停止 AudioStreamPlayer 并释放音频播放器以节省资源.

注解

这个系统非常简单, 有一些重大缺陷:

有一个缺陷是我们必须传入一个字符串值来播放一个声音. 虽然记住这三个声音的名字相对简单, 但当你有更多的声音时, 它可能会越来越复杂. 理想的情况是, 我们将这些声音放在某种容器中, 并有暴露的变量, 这样我们就不必记住要播放的每个声音效果的名字.

另一个缺陷是我们无法使用此系统轻松播放循环音效, 也无法播放背景音乐. 因为我们无法播放循环声音, 某些效果(如脚步声)难以实现, 因为我们必须跟踪是否存在声音效果以及是否需要继续播放它.

这个系统最大的缺点之一是我们只能播放 "Player.gd" 中的声音. 理想情况下, 我们希望能够随时播放任何脚本中的声音.


做完这些, 我们再打开 Player.gd . 首先我们需要加载 Simple_Audio_Player.tscn . 在脚本的类变量部分放入以下代码:

var simple_audio_player = preload("res://Simple_Audio_Player.tscn")

现在我们需要在需要时实例化简单的音频播放器, 然后调用它的 play_sound 函数并传递我们想要播放的声音的名称. 为了简化这个过程, 让我们在 Player.gd 中创建一个 create_sound 函数:

func create_sound(sound_name, position=null):
    var audio_clone = simple_audio_player.instance()
    var scene_root = get_tree().root.get_children()[0]
    scene_root.add_child(audio_clone)
    audio_clone.play_sound(sound_name, position)

我们来介绍一下这个函数的作用:


第一行实例化 Simple_Audio_Player.tscn 场景, 并将其分配给一个名为 audio_clone 的变量.

第二行获取场景根, 这有一个很大(虽然安全)的假设.

我们首先得到这个节点 SceneTree, 然后访问根节点, 在这种情况下是 Viewport 这整个游戏正在运行. 然后我们得到了第一个子节点 Viewport, 在我们的示例中恰好是 Test_Area.tscn 中的根节点或任何其他提供的级别. 我们正在做出一个巨大的假设, 即根节点的第一个子节点是游戏角色所处的根场景, 这可能并非总是如此 .

如果这对你没有意义, 不要太担心. 第二行代码只有在你将多个场景作为根节点的子节点同时加载的情况下才会不可靠地运行, 这在大多数项目中很少发生, 在本系列教程中也不会发生. 这只是潜在的问题, 取决于你如何处理场景加载.

第三行将我们新创建的 Simple_Audio_Player 场景添加为场景根的子节点. 这与我们产生子弹时的工作方式完全相同.

最后, 我们调用 play_sound 函数并将传入的参数传递给 create_sound . 这将使用传入的参数调用 Simple_Audio_Player.gdplay_sound 函数.


现在剩下的就是在我们想要的时候播放声音. 让我们首先为手枪添加声音!

打开 Weapon_Pistol.gd .

现在, 我们想在游戏角色发射手枪时发出噪音, 所以将以下内容添加到 fire_weapon 函数的末尾:

player_node.create_sound("Pistol_shot", self.global_transform.origin)

现在, 当游戏角色发射手枪时, 我们将发出 "手枪射击" 的声音.

要在游戏角色重新加载时发出声音, 我们需要在 reload_weapon 函数中的 player_node.animation_manager.set_animation(RELOADING_ANIM_NAME) 下添加以下内容:

player_node.create_sound("Gun_cock", player_node.camera.global_transform.origin)

现在当游戏角色重新加载时, 我们将播放 Gun_cock 声音.


现在让我们为步枪添加声音. 打开 Weapon_Rifle.gd .

要在步枪被射击时发出声音, 请将以下内容添加到 fire_weapon 函数的末尾:

player_node.create_sound("Rifle_shot", ray.global_transform.origin)

现在, 当游戏角色发射步枪时, 我们将发出 "Rifle_shot" 声音.

要在游戏角色重新加载时发出声音, 我们需要在 reload_weapon 函数中的 player_node.animation_manager.set_animation(RELOADING_ANIM_NAME) 下添加以下内容:

player_node.create_sound("Gun_cock", player_node.camera.global_transform.origin)

现在当游戏角色重新加载时, 我们将播放 Gun_cock 声音.

最后的笔记

../../../_images/PartThreeFinished.png

现在您拥有有限弹药的武器, 当您开火时它们会播放声音!

至此, 我们已经具备了一个FPS游戏的所有基本功能. 还有一些东西可以添加, 将在接下来的三个部分中添加它们!

例如, 现在我们无法为我们的备件添加弹药, 所以我们最终会耗尽. 另外, 我们没有任何东西可以射击 RigidBody 节点.

在: 参考:doc_fps_tutorial_part_four 我们将添加一些射击目标, 以及一些健康和弹药拾取! 我们还将添加joypad支持, 因此我们可以使用有线Xbox 360控制器!

警告

如果你感到迷茫,请一定要再读一遍代码!

您可以在这里下载这个部分的完成项目: Godot_FPS_Part_3.zip