第6部分

Part overview

In this part, we're going to add a main menu and pause menu, add a respawn system for the player, and change/move the sound system so we can use it from any script.

This is the last part of the FPS tutorial; by the end of this, you will have a solid base to build amazing FPS games with Godot!

../../../_images/FinishedTutorialPicture.png

注解

You are assumed to have finished 第5部分 before moving on to this part of the tutorial. The finished project from 第5部分 will be the starting project for part 6

让我们开始吧!

添加主菜单

Firstly, open up Main_Menu.tscn and take a look at how the scene is set up.

主菜单分为三个不同的面板,每个面板代表主菜单的不同“屏幕”。

注解

``Background_Animation``节点就是这样,菜单的背景比纯色更有趣。 这是一个环顾天空盒的相机,没什么特别的。

Feel free to expand all the nodes and see how they're set up. Remember to keep only Start_Menu visible when you're done, as that's the screen we want to show first when we enter the main menu.

选择 Main_Menu (根节点)并创建一个名为 Main_Menu.gd 的新脚本。 添加以下内容:

extends Control

var start_menu
var level_select_menu
var options_menu

export (String, FILE) var testing_area_scene
export (String, FILE) var space_level_scene
export (String, FILE) var ruins_level_scene

func _ready():
    start_menu = $Start_Menu
    level_select_menu = $Level_Select_Menu
    options_menu = $Options_Menu

    $Start_Menu/Button_Start.connect("pressed", self, "start_menu_button_pressed", ["start"])
    $Start_Menu/Button_Open_Godot.connect("pressed", self, "start_menu_button_pressed", ["open_godot"])
    $Start_Menu/Button_Options.connect("pressed", self, "start_menu_button_pressed", ["options"])
    $Start_Menu/Button_Quit.connect("pressed", self, "start_menu_button_pressed", ["quit"])

    $Level_Select_Menu/Button_Back.connect("pressed", self, "level_select_menu_button_pressed", ["back"])
    $Level_Select_Menu/Button_Level_Testing_Area.connect("pressed", self, "level_select_menu_button_pressed", ["testing_scene"])
    $Level_Select_Menu/Button_Level_Space.connect("pressed", self, "level_select_menu_button_pressed", ["space_level"])
    $Level_Select_Menu/Button_Level_Ruins.connect("pressed", self, "level_select_menu_button_pressed", ["ruins_level"])

    $Options_Menu/Button_Back.connect("pressed", self, "options_menu_button_pressed", ["back"])
    $Options_Menu/Button_Fullscreen.connect("pressed", self, "options_menu_button_pressed", ["fullscreen"])
    $Options_Menu/Check_Button_VSync.connect("pressed", self, "options_menu_button_pressed", ["vsync"])
    $Options_Menu/Check_Button_Debug.connect("pressed", self, "options_menu_button_pressed", ["debug"])

    Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)

    var globals = get_node("/root/Globals")
    $Options_Menu/HSlider_Mouse_Sensitivity.value = globals.mouse_sensitivity
    $Options_Menu/HSlider_Joypad_Sensitivity.value = globals.joypad_sensitivity


func start_menu_button_pressed(button_name):
    if button_name == "start":
        level_select_menu.visible = true
        start_menu.visible = false
    elif button_name == "open_godot":
        OS.shell_open("https://godotengine.org/")
    elif button_name == "options":
        options_menu.visible = true
        start_menu.visible = false
    elif button_name == "quit":
        get_tree().quit()


func level_select_menu_button_pressed(button_name):
    if button_name == "back":
        start_menu.visible = true
        level_select_menu.visible = false
    elif button_name == "testing_scene":
        set_mouse_and_joypad_sensitivity()
        get_node("/root/Globals").load_new_scene(testing_area_scene)
    elif button_name == "space_level":
        set_mouse_and_joypad_sensitivity()
        get_node("/root/Globals").load_new_scene(space_level_scene)
    elif button_name == "ruins_level":
        set_mouse_and_joypad_sensitivity()
        get_node("/root/Globals").load_new_scene(ruins_level_scene)


func options_menu_button_pressed(button_name):
    if button_name == "back":
        start_menu.visible = true
        options_menu.visible = false
    elif button_name == "fullscreen":
        OS.window_fullscreen = !OS.window_fullscreen
    elif button_name == "vsync":
        OS.vsync_enabled = $Options_Menu/Check_Button_VSync.pressed
    elif button_name == "debug":
        pass


func set_mouse_and_joypad_sensitivity():
    var globals = get_node("/root/Globals")
    globals.mouse_sensitivity = $Options_Menu/HSlider_Mouse_Sensitivity.value
    globals.joypad_sensitivity = $Options_Menu/HSlider_Joypad_Sensitivity.value

这里的大多数代码都与制作UI有关,这超出了本教程系列的目的。 我们只是简要介绍一下与UI相关的代码。

小技巧

请参阅 :ref:`doc_ui_main_menu`以及以下教程,以获得更好的GUI和UI方法!

我们先来看看类变量。

  • start_menu:一个用于保存``Start_Menu``的变量 Panel
  • level_select_menu:一个用于保存``Level_Select_Menu``的变量 Panel
  • options_menu:一个变量来保存``Options_Menu`` Panel
  • testing_area_scene:``Testing_Area.tscn``文件的路径,所以我们可以从这个场景改变它。
  • space_level_scene:``Space_Level.tscn``文件的路径,所以我们可以从这个场景改变它。
  • ruins_level_scene:``Ruins_Level.tscn``文件的路径,所以我们可以从这个场景改变它。

警告

在测试此脚本之前,您必须在编辑器中设置正确文件的路径! 否则它将无法正常工作!


现在让我们回顾一下 _ready

Firstly, we get all the Panel nodes and assign them to the proper variables.

Next, we connect all the buttons pressed signals to their respective [panel_name_here]_button_pressed functions.

We then set the mouse mode to MOUSE_MODE_VISIBLE to ensure whenever the player returns to this scene, the mouse will be visible.

然后我们得到一个叫做“Globals”的单例人士。 然后我们设置 HSlider 节点的值,使它们的值与单例中的鼠标和游戏手柄灵敏度对齐。

注解

我们还没有制作 Globals 单例,所以不用担心! 我们很快就会成功!


In start_menu_button_pressed, we check to see which button is pressed.

根据按下的按钮,我们要么更改当前可见的面板,退出应用程序,要么打开Godot网站。


level_select_menu_button_pressed 中,我们检查按下了哪个按钮。

如果按下“后退”按钮,我们会更改当前可见的面板以返回主菜单。

If one of the scene changing buttons is pressed, we fist call set_mouse_and_joypad_sensitivity so the singleton (Globals.gd) has the values from the HSlider nodes. Then, we tell the singleton to change nodes using its load_new_scene function, passing in the file path of the scene the player has selected.

注解

不要担心单例人士,我们很快就会到达那里!


options_menu_button_pressed 中,我们检查按下了哪个按钮。

如果按下“后退”按钮,我们会更改当前可见的面板以返回主菜单。

If the fullscreen button is pressed, we toggle the OS's full screen mode by setting it to the flipped version of its current value.

If the vsync button is pressed, we set the OS's Vsync based on the state of the Vsync check button.


最后,让我们来看看 set_mouse_and_joypad_sensitivity

Firstly, we get the Globals singleton and assign it to a local variable.

然后我们将 mouse_sensitivity 和``joypad_sensitivity``变量设置为它们各自的值 HSlider 节点对应物。

使 Globals 单例

Now, for all this to work, we need to create the Globals singleton. Make a new script in the Script tab and call it Globals.gd.

注解

要制作 Globals 单例,请转到编辑器中的 Script 选项卡,然后单击 New 并出现一个``Create Script``框,除了`` 路径``您需要插入脚本名称``Globals.gd``。

将以下内容添加到 Globals.gd 中。

extends Node

var mouse_sensitivity = 0.08
var joypad_sensitivity = 2

func _ready():
    pass

func load_new_scene(new_scene_path):
    get_tree().change_scene(new_scene_path)

As you can see, it's quite small and simple. As this part progresses, we will keep adding more complex logic to Globals.gd, but for now, all it is doing is holding two class variables, and abstract defining how we change scenes.

  • mouse_sensitivity:我们鼠标的当前灵敏度,所以我们可以在 Player.gd 中加载它。
  • joypad_sensitivity:我们游戏手柄的当前灵敏度,所以我们可以在 Player.gd 中加载它。

Right now, all we will be using Globals.gd for is a way to carry variables across scenes. Because the sensitivities of our mouse and joypad are stored in Globals.gd, any changes we make in one scene (like in Options_Menu) will affect the sensitivity for the player.

我们在 load_new_scene 中所做的就是调用 SceneTreechange_scene 函数,传入 load_new_scene 中给出的场景路径。

That's all the code needed for Globals.gd right now! Before we can test the main menu, we first need to set Globals.gd as an autoload script.

打开``Project Settings``并单击 AutoLoad 选项卡。

../../../_images/AutoloadAddSingleton.png

然后通过单击旁边的按钮(`)选择``Path``字段中``Globals.gd``的路径。 确保``Node Name``字段中的名称是``Globals``。 如果您拥有上图所示的所有内容,请按“添加”!

这将使 Globals.gd 成为单例/自动加载脚本,这将允许我们从任何场景中的任何脚本访问它。

小技巧

有关单例/自动加载脚本的更多信息,请参阅 单例(自动加载)

现在 Globals.gd 是一个单例/自动加载脚本,您可以测试主菜单!

您可能希望将主场景从 Testing_Area.tscn 更改为 Main_Menu.tscn ,因此当我们导出游戏时,游戏角色将从主菜单开始。 您可以通过 General 选项卡下的``Project Settings``来完成此操作。 然后在 Application 类别中,单击 Run 子类别,您可以通过更改``Main Scene``中的值来更改主场景。

警告

在测试主菜单之前,您必须在编辑器中的 Main_Menu 中设置正确文件的路径! 否则,您将无法从级别选择菜单/屏幕更改场景。

添加调试菜单

Now, let's add a simple debugging scene so we can track things like FPS (Frames Per Second) in-game. Open up Debug_Display.tscn.

You can see it's a Panel positioned in the top right corner of the screen. It has three Labels, one for displaying the FPS at which the game is running, one for showing on what OS the game is running, and a label for showing with which Godot version the game is running.

让我们添加填充这些代码所需的代码 Labels。 选择 Debug_Display 并创建一个名为 Debug_Display.gd 的新脚本。 添加以下内容:

extends Control

func _ready():
    $OS_Label.text = "OS: " + OS.get_name()
    $Engine_Label.text = "Godot version: " + Engine.get_version_info()["string"]

func _process(delta):
    $FPS_Label.text = "FPS: " + str(Engine.get_frames_per_second())

我们来看看这个脚本的功能。


In _ready, we set the OS_Label's text to the name provided by OS using the get_name function. This will return the name of the OS (or Operating System) for which Godot was compiled. For example, when you are running Windows, it will return Windows, while when you are running Linux, it will return X11.

Then, we set the Engine_Label's text to the version info provided by Engine.get_version_info. Engine.get_version_info returns a dictionary full of useful information about the version of Godot which is currently running. We only care about the string version, for this label at least, so we get the string and assign that as the text in Engine_Label. See Engine for more information on the values get_version_info returns.

In _process, we set the text of the FPS_Label to Engine.get_frames_per_second, but because get_frames_per_second returns an integer, we have to cast it to a string using str before we can add it to the Label.


现在让我们跳回到 Main_Menu.gd 并在 options_menu_button_pressed 中更改以下内容:

elif button_name == "debug":
    pass

改为:

elif button_name == "debug":
    get_node("/root/Globals").set_debug_display($Options_Menu/Check_Button_Debug.pressed)

This will call a new function called set_debug_display in our singleton, so let's add that next!


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

# ------------------------------------
# All the GUI/UI-related variables

var canvas_layer = null

const DEBUG_DISPLAY_SCENE = preload("res://Debug_Display.tscn")
var debug_display = null

# ------------------------------------
  • canvas_layer:一个画布层,因此在 Globals.gd 中创建的GUI / UI总是在顶部绘制。
  • DEBUG_DISPLAY:我们之前处理过的调试显示场景。
  • debug_display:一个变量,用于在/如果存在时保持调试显示。

现在我们已经定义了类变量,我们需要在 _ready 中添加几行,以便 Globals.gd 将使用一个画布层(我们将存储在 canvas_layer 中)。 将 _ready 更改为以下内容:

func _ready():
    canvas_layer = CanvasLayer.new()
    add_child(canvas_layer)

Now in _ready, we create a new canvas layer, assign it to canvas_layer and add it as a child. Because Globals.gd is an autoload/singleton, Godot will make a Node when the game is launched, and it will have Globals.gd attached to it. Since Godot makes a Node, we can treat Globals.gd like any other node with regard to adding/removing children nodes.

The reason we're adding a CanvasLayer is so all our GUI and UI nodes we instance/spawn in Globals.gd are always drawn on top of everything else.

When adding nodes to a singleton/autoload, you have to be careful not to lose reference to any of the child nodes. This is because nodes will not be freed/destroyed when you change the active scene, meaning you can run into memory problems if you are instancing/spawning lots of nodes and you are not freeing them.


现在我们需要将 set_debug_display 添加到``Globals.gd``:

func set_debug_display(display_on):
    if display_on == false:
        if debug_display != null:
            debug_display.queue_free()
            debug_display = null
    else:
        if debug_display == null:
            debug_display = DEBUG_DISPLAY_SCENE.instance()
            canvas_layer.add_child(debug_display)

让我们回顾一下正在发生的事情。

首先,我们检查 Globals.gd 是否正在尝试打开调试显示,或者将其关闭。

如果 Globals.gd 正在关闭显示,我们检查 debug_display 是否不等于 null 。 如果 debug_display 不等于 null ,那么 Globals.gd 必须有一个当前有效的调试显示。 如果 Globals.gd 的调试显示处于活动状态,我们使用 queue_free 释放它,然后将 debug_display 分配给 null

如果 Globals.gd 打开显示器,我们检查以确保 Globals.gd 没有激活调试显示。 我们通过确保 debug_display 等于 null 来做到这一点。 如果 debug_display 是``null``,我们实例化一个新的 DEBUG_DISPLAY_SCENE ,并将其添加为 canvas_layer 的子节点。


完成后,我们现在可以通过在 Options_Menu 面板中切换 CheckButton 来打开和关闭调试显示。 去尝试吧!

请注意,即使将“Main_Menu.tscn`”中的场景更改为另一个场景(如“Testing_Area.tscn``),调试显示仍然保持不变。 这是单例/自动加载中实例化/生成节点的美妙之处,并将它们作为子元素添加到单例/自动加载中。 作为单件/自动加载的子节点添加的任何节点将在游戏运行期间保持不变,而我们没有任何额外的工作!

添加暂停菜单

让我们添加一个暂停菜单,这样当我们按下 ui_cancel 动作时我们可以返回主菜单。

打开 Pause_Popup.tscn

Notice how the root node in Pause_Popup is a WindowDialog; WindowDialog inherits from Popup, which means WindowDialog can act like a popup.

Select Pause_Popup and scroll down all the way till you get to the Pause menu in the inspector. Notice how the pause mode is set to process instead of inherit like it is normally set by default. This makes it so it will continue to process even when the game is paused, which we need in order to interact with the UI elements.

Now that we've looked at how Pause_Popup.tscn is set up, let's write the code to make it work. Normally, we'd attach a script to the root node of the scene, Pause_Popup in this case, but since we'll need to receive a couple of signals in Globals.gd, we'll write all the code for the popup there.

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

const MAIN_MENU_PATH = "res://Main_Menu.tscn"
const POPUP_SCENE = preload("res://Pause_Popup.tscn")
var popup = null
  • MAIN_MENU_PATH:主菜单场景的路径。
  • POPUP_SCENE:我们之前看过的弹出场景。
  • popup:一个用于保存弹出场景的变量。

现在我们需要将 _process 添加到 Globals.gd 中,这样当按下 ui_cancel 动作时它就会响应。 将以下内容添加到``_process``:

func _process(delta):
    if Input.is_action_just_pressed("ui_cancel"):
        if popup == null:
            popup = POPUP_SCENE.instance()

            popup.get_node("Button_quit").connect("pressed", self, "popup_quit")
            popup.connect("popup_hide", self, "popup_closed")
            popup.get_node("Button_resume").connect("pressed", self, "popup_closed")

            canvas_layer.add_child(popup)
            popup.popup_centered()

            Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)

            get_tree().paused = true

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


Firstly, we check to see if the ui_cancel action is pressed. Then, we check to make sure Globals.gd does not already have a popup open by checking to see if popup is equal to null.

If Globals.gd do not have a pop-up open, we instance POPUP_SCENE and assign it to popup.

然后我们得到退出按钮并将它的 pressed 信号分配给 popup_quit ,我们将很快添加。

Next, we assign both the popup_hide signal from the WindowDialog and the pressed signal from the resume button to popup_closed, which we will be adding shortly.

Then, we add popup as a child of canvas_layer so it's drawn on top. We then tell popup to pop up at the center of the screen using popup_centered.

Next, we make sure the mouse mode is MOUSE_MODE_VISIBLE so the player can interact with the pop-up. If we did not do this, the player would not be able to interact with the pop up in any scene where the mouse mode is MOUSE_MODE_CAPTURED.

最后,我们暂停整个 SceneTree

注解

有关在Godot中暂停的更多信息,请参阅 暂停游戏


Now, we need to add the functions to which we've connected the signals. Let's add popup_closed first.

将以下内容添加到``Globals.gd``:

func popup_closed():
    get_tree().paused = false

    if popup != null:
        popup.queue_free()
        popup = null

popup_closed will resume the game and destroy the pop-up if there is one.

``popup_quit``类似,但我们也确保鼠标可见并将场景更改为标题屏幕。

将以下内容添加到``Globals.gd``:

func popup_quit():
    get_tree().paused = false

    Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)

    if popup != null:
        popup.queue_free()
        popup = null

    load_new_scene(MAIN_MENU_PATH)

popup_quit will resume the game, set the mouse mode to MOUSE_MODE_VISIBLE to ensure the mouse is visible in the main menu, destroy the pop-up if there is one, and change scenes to the main menu.


Before we're ready to test the pop-up, we should change one thing in Player.gd.

打开 Player.gd 并在 process_input 中,将捕获/释放光标的代码更改为以下内容:

代替:

# Capturing/Freeing cursor
if Input.is_action_just_pressed("ui_cancel"):
    if Input.get_mouse_mode() == Input.MOUSE_MODE_VISIBLE:
        Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
    else:
        Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)

您只会离开:

# Capturing/Freeing cursor
if Input.get_mouse_mode() == Input.MOUSE_MODE_VISIBLE:
    Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)

Now, instead of capturing/freeing the mouse, we check whether the current mouse mode is MOUSE_MODE_VISIBLE. If it is, we set it back to MOUSE_MODE_CAPTURED.

Because the pop-up makes the mouse mode MOUSE_MODE_VISIBLE whenever you pause, we no longer have to worry about freeing and capturing the cursor in Player.gd.


Now the pause menu pop-up is finished. You can now pause at any point in the game and return to the main menu!

启动重生系统

由于游戏角色可以失去所有的健康,如果游戏角色死亡和重生,那将是理想的,所以让我们接下来添加!

Firstly, open up Player.tscn and expand HUD. Notice how there is a ColorRect called Death_Screen. When the player dies, we're going to make Death_Screen visible, and show them how long they have to wait before the player is able to respawn.

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

const RESPAWN_TIME = 4
var dead_time = 0
var is_dead = false

var globals
  • RESPAWN_TIME:重生的时间(以秒为单位)。
  • dead_time:一个跟踪游戏角色死亡时间的变量。
  • is_dead:一个跟踪游戏角色当前是否死亡的变量。
  • globals:一个变量来保存 Globals.gd 单例。

我们现在需要在 _ready 中添加几行,所以我们可以在 Player.gd 中使用 Globals.gd 。 将以下内容添加到 _ready:

globals = get_node("/root/Globals")
global_transform.origin = globals.get_respawn_position()

现在我们得到 Globals.gd 单例并将其分配给 globals。 我们还通过在游戏角色的全局中设置原点来设置游戏角色的全局位置 Transformglobals.get_respawn_position 返回的位置。

注解

别担心,我们将在下面添加“get_respawn_position`”!


Next, we need to make a few changes to _physics_process. Change _physics_process to the following:

func _physics_process(delta):

    if !is_dead:
        process_input(delta)
        process_view_input(delta)
        process_movement(delta)

    if (grabbed_object == null):
        process_changing_weapons(delta)
        process_reloading(delta)

    process_UI(delta)
    process_respawn(delta)

现在,当游戏角色死亡时,游戏角色将不会处理输入或移动输入。 我们现在也在调用 process_respawn

注解

The if !is_dead: expression is equivalent and works in the same way as the expression if is_dead == false:. And by removing the ! sign from the expression we obtain the opposite expression if is_dead == true:. It is just a shorter way of writing the same code functionality.

我们还没有制作 process_respawn ,所以让我们改变它。


让我们添加 process_respawn 。 将以下内容添加到``Player.gd``:

func process_respawn(delta):

    # If we've just died
    if health <= 0 and !is_dead:
        $Body_CollisionShape.disabled = true
        $Feet_CollisionShape.disabled = true

        changing_weapon = true
        changing_weapon_name = "UNARMED"

        $HUD/Death_Screen.visible = true

        $HUD/Panel.visible = false
        $HUD/Crosshair.visible = false

        dead_time = RESPAWN_TIME
        is_dead = true

        if grabbed_object != null:
            grabbed_object.mode = RigidBody.MODE_RIGID
            grabbed_object.apply_impulse(Vector3(0, 0, 0), -camera.global_transform.basis.z.normalized() * OBJECT_THROW_FORCE / 2)

            grabbed_object.collision_layer = 1
            grabbed_object.collision_mask = 1

            grabbed_object = null

    if is_dead:
        dead_time -= delta

        var dead_time_pretty = str(dead_time).left(3)
        $HUD/Death_Screen/Label.text = "You died\n" + dead_time_pretty + " seconds till respawn"

        if dead_time <= 0:
            global_transform.origin = globals.get_respawn_position()

            $Body_CollisionShape.disabled = false
            $Feet_CollisionShape.disabled = false

            $HUD/Death_Screen.visible = false

            $HUD/Panel.visible = true
            $HUD/Crosshair.visible = true

            for weapon in weapons:
                var weapon_node = weapons[weapon]
                if weapon_node != null:
                    weapon_node.reset_weapon()

            health = 100
            grenade_amounts = {"Grenade":2, "Sticky Grenade":2}
            current_grenade = "Grenade"

            is_dead = false

让我们来看看这个功能正在做什么。


首先,我们通过检查``health``是否等于或小于``0``并且``is_dead``是``false``来检查游戏角色是否刚刚死亡。

如果游戏角色刚刚去世,我们会禁用游戏角色的碰撞形状。 我们这样做是为了确保游戏角色不会用尸体挡住任何东西。

Next, we set changing_weapon to true and set changing_weapon_name to UNARMED. This is so, if the player is using a weapon, it is put away when they dies.

然后我们制作``Death_Screen`` ColorRect 可见,这样当游戏角色死亡时,游戏角色会得到漂亮的灰色覆盖。 然后我们制作UI的其余部分, Panel 和``Crosshair``节点,看不见。

Next, we set dead_time to RESPAWN_TIME so we can start counting down how long the player has been dead. We also set is_dead to true so we know the player has died.

If the player is holding an object when they died, we need to throw it. We first check whether the player is holding an object or not. If the player is holding a object, we throw it using the same code as the throwing code we added in 第5部分.

注解

表达式 You have died\n\n 组合是一个命令,用于在下面的新行上显示后面的文本。 当您用魔杖很好地将显示的文本分组为多行时,这总是很有用,因此它看起来更好,并且更容易被游戏游戏角色阅读。


Then we check whether the player is dead. If so, we then remove delta from dead_time.

然后我们创建一个名为 dead_time_pretty 的新变量,我们将 dead_time 转换为字符串,只使用从左边开始的前三个字符。 这为游戏角色提供了一个漂亮的字符串,显示游戏角色在游戏角色重生之前需要等待多长时间。

然后我们在``Death Screen``中更改 Label 来显示游戏角色离开的时间。

接下来我们检查游戏角色是否已经等待足够长时间并且可以重生。 我们通过检查 dead_time 是否为“0”或更少来做到这一点。

如果游戏角色等待足够长时间重生,我们将游戏角色的位置设置为“get_respawn_position”提供的新重生位置。

然后我们启用两个游戏角色的碰撞形状,以便游戏角色可以再次与环境发生碰撞。

Next, we make the Death_Screen invisible and make the rest of the UI, the Panel and Crosshair nodes, visible again.

然后我们通过每个武器并调用它的 reset_weapon 函数,我们将很快添加它。

Then, we reset health to 100, grenade_amounts to its default values, and change current_grenade to Grenade. This effectively resets these variables to their default values.

最后,我们将 is_dead 设置为 false


在我们离开 Player.gd 之前,我们需要在 _input 中添加一个快速的东西。 在`_input``的开头添加以下内容:

if is_dead:
    return

Now, when the player is dead, they cannot look around with the mouse.

完成重生系统

Firstly, let's open Weapon_Pistol.gd and add the reset_weapon function. Add the following:

func reset_weapon():
    ammo_in_weapon = 10
    spare_ammo = 20

Now, when we call reset_weapon, the ammo in the pistol and the ammo in the spares will be reset to their default values.

现在让我们在 Weapon_Rifle.gd 中添加``reset_weapon``:

func reset_weapon():
    ammo_in_weapon = 50
    spare_ammo = 100

并将以下内容添加到``Weapon_Knife.gd``:

func reset_weapon():
    ammo_in_weapon = 1
    spare_ammo = 1

Now all the weapons will reset when the player dies.


Now we need to add a few things to Globals.gd. Firstly, add the following class variable:

var respawn_points = null
  • respawn_points: A variable to hold all the respawn points in a level

因为我们每次都得到一个随机的衍生点,我们需要随机化这个数字生成器。在“_ready”中添加以下内容:

randomize()

randomize will get us a new random seed so we get a (relatively) random string of numbers when we use any of the random functions.

现在让我们将 get_respawn_position 添加到``Globals.gd``:

func get_respawn_position():
    if respawn_points == null:
        return Vector3(0, 0, 0)
    else:
        var respawn_point = rand_range(0, respawn_points.size() - 1)
        return respawn_points[respawn_point].global_transform.origin

让我们回顾一下这个功能的作用。


Firstly, we check if Globals.gd has any respawn_points by checking whether respawn_points is null or not.

如果 respawn_points 是``null``,我们返回一个空位置 Vector 3 ,位置为``(0,0,0)``。

如果' respawn_points ' '不是' null ' ',那么我们就会得到一个介于' 0 ' '和' respawn_points ' ' '中的元素数量之间的随机数,减去' 1 ' ',因为大多数编程语言,包括' GDScript ' ' ',在访问列表中的元素时,都是从' 0 ' '开始计数的。

然后,我们在 respawn_points 的``respawn_point``位置返回 Spatial 节点的位置。


Before we are done with Globals.gd, we need to add the following to load_new_scene:

respawn_points = null

我们将 respawn_points 设置为 null ,所以当/如果游戏角色达到没有重生点的等级时,我们不会在先前等级的重生点重生游戏角色。


现在我们需要的是一种设置重生点的方法。 打开 Ruins_Level.tscn 并选择 Spawn_Points 。 添加一个名为 Respawn_Point_Setter.gd 的新脚本,并将其附加到 Spawn_Points 。 将以下内容添加到``Respawn_Point_Setter.gd``:

extends Spatial

func _ready():
    var globals = get_node("/root/Globals")
    globals.respawn_points = get_children()

Now, when a node with Respawn_Point_Setter.gd has its _ready function called, all the children nodes of the node with Respawn_Point_Setter.gd, Spawn_Points in the case of Ruins_Level.tscn, will be added to respawn_points in Globals.gd.

警告

任何带有“Respawn_Point_Setter.gd`”的节点都必须位于 SceneTree <class_SceneTree>中的游戏角色上方,所以重新生成的点在游戏角色需要它们在游戏角色的 ``_ready` 函数之前设置。


Now, when the player dies, they will respawn after waiting 4 seconds!

注解

除了 Ruins_Level.tscn 之外,还没有为任何级别设置生成点! 将生成点添加到“Space_Level.tscn”将留给读者练习。

编写一个我们可以随处使用的音响系统

Finally, let's make a sound system so we can play sounds from anywhere, without having to use the player.

Firstly, open up SimpleAudioPlayer.gd and change it to the following:

extends Spatial

var audio_node = null
var should_loop = false
var globals = null

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

    globals = get_node("/root/Globals")


func play_sound(audio_stream, position=null):
    if audio_stream == null:
        print ("No audio stream passed; cannot play sound")
        globals.created_audio.remove(globals.created_audio.find(self))
        queue_free()
        return

    audio_node.stream = audio_stream

    # 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(0.0)


func sound_finished():
    if should_loop:
        audio_node.play(0.0)
    else:
        globals.created_audio.remove(globals.created_audio.find(self))
        audio_node.stop()
        queue_free()

旧版本有一些变化,首先是我们不再将声音文件存储在 SimpleAudioPlayer.gd 中。 这对性能要好得多,因为我们在创建声音时不再加载每个音频片段,而是强制将音频流传递到“play_sound”。

另一个变化是我们有一个名为 should_loop 的新类变量。 我们不是仅在每次完成时销毁音频播放器,而是要检查并查看音频播放器是否设置为循环播放。 这使得我们可以像循环背景音乐那样使用音频,而不必在旧音频播放完成后用音乐生成新的音频播放器。

最后,不是在 Player.gd 中实例化/生成,而是在“Globals.gd”中生成音频播放器,这样我们就可以从任何场景创建声音。 现在音频播放器存储了 Globals.gd 单例,所以当音频播放器被销毁时,我们也可以从 Globals.gd 中的列表中删除它。

让我们回顾一下这些变化。


For the class variables, we removed all the audio_[insert name here] variables since we will instead have these passed in from Globals.gd.

我们还添加了两个新的类变量 should_loopglobals 。 我们将使用 should_loop 来判断音频播放器是否应该在声音结束时循环,而 globals 将保持 Globals.gd 单例。

The only change in _ready is now audio player is getting the Globals.gd singleton and assigning it to globals.

play_sound``现在需要传入一个名为``audio_stream``的音频流,而不是 ``sound_name 。 我们不是检查声音名称和设置音频播放器的流,而是检查以确保传入音频流。如果未传入音频流,我们打印错误消息,从列表中删除音频播放器 在 Globals.gd 单例中称为 created_audio ,然后释放音频播放器。

最后,在 sound_finished 中,我们首先检查音频播放器是否应该使用 should_loop 循环。 如果音频播放器应该循环,我们将从头开始再次播放声音,位置为“0.0”。 如果音频播放器不应该循环,我们从名为 created_audio 的``Globals.gd``单曲列表中删除音频播放器,然后释放音频播放器。


现在我们已完成对 SimpleAudioPlayer.gd 的更改,现在我们需要将注意力转向 Globals.gd 。 首先,添加以下类变量:

# All the audio files.

# You will need to provide your own sound files.
var audio_clips = {
    "Pistol_shot": null, #preload("res://path_to_your_audio_here!")
    "Rifle_shot": null, #preload("res://path_to_your_audio_here!")
    "Gun_cock": null, #preload("res://path_to_your_audio_here!")
}

const SIMPLE_AUDIO_PLAYER_SCENE = preload("res://Simple_Audio_Player.tscn")
var created_audio = []

Let's go over these global variables.

  • audio_clips: A dictionary holding all the audio clips Globals.gd can play.
  • SIMPLE_AUDIO_PLAYER_SCENE:简单的音频播放器场景。
  • created_audio: A list to hold all the simple audio players Globals.gd has created.

注解

如果要添加其他音频,则需要将其添加到“audio_clips”。 本教程中未提供音频文件,因此您必须提供自己的音频文件。

我推荐的一个网站是** GameSounds.xyz **。 我正在使用2017年Sonniss'GDC游戏音频包中包含的Gamemaster音频枪声音包。我使用过的轨道(经过一些小编辑)如下:

  • gun_revolver_pistol_shot_04,
  • gun_semi_auto_rifle_cock_02,
  • gun_submachine_auto_shot_00_automatic_preview_01

现在我们需要在 Globals.gd 中添加一个名为 play_sound 的新函数:

func play_sound(sound_name, loop_sound=false, sound_position=null):
    if audio_clips.has(sound_name):
        var new_audio = SIMPLE_AUDIO_PLAYER_SCENE.instance()
        new_audio.should_loop = loop_sound

        add_child(new_audio)
        created_audio.append(new_audio)

        new_audio.play_sound(audio_clips[sound_name], sound_position)

    else:
        print ("ERROR: cannot play sound that does not exist in audio_clips!")

让我们回顾一下这个功能的作用。

Firstly, we check whether Globals.gd has an audio clip with the name sound_name in audio_clips. If it does not, we print an error message.

如果 Globals.gd 有一个名为 sound_name 的音频剪辑,我们然后实例/生成一个新的 SIMPLE_AUDIO_PLAYER_SCENE 并将其分配给 new_audio

然后我们设置 should_loop ,并添加 new_audio 作为 Globals.gd 的子节点。

注解

请记住,我们必须小心地将节点添加到单个节点,因为在更改场景时这些节点不会被销毁。

We add the new_audio into the created_audio list to hold all created audios.

然后我们调用 play_sound ,传入与 sound_name 相关的音频片段和声音位置。


Before we leave Globals.gd, we need to add a few lines of code to load_new_scene so when the player changes scenes, all the audio is destroyed.

将以下内容添加到``load_new_scene``:

for sound in created_audio:
    if (sound != null):
        sound.queue_free()
created_audio.clear()

Now, before Globals.gd changes scenes, it goes through each simple audio player in created_sounds and frees/destroys them. Once Globals.gd has gone through all the sounds in created_audio, we clear created_audio so it no longer holds any references to any (now freed/destroyed) simple audio players.


Let's change create_sound in Player.gd to use this new system. First, remove simple_audio_player from Player.gd's class variables since we will no longer be directly instancing/spawning sounds in Player.gd.

现在,将 create_sound 更改为以下内容:

func create_sound(sound_name, position=null):
    globals.play_sound(sound_name, false, position)

Now, whenever create_sound is called, we simply call play_sound in Globals.gd, passing in all the arguments received.


Now all the sounds in our FPS can be played from anywhere. All we have to do is get the Globals.gd singleton, and call play_sound, pass in the name of the sound we want to play, whether we want it to loop or not, and the position from which to play the sound.

For example, if you want to play an explosion sound when the grenade explodes you'd need to add a new sound to audio_clips in Globals.gd, get the Globals.gd singleton, and then you just need to add something like globals.play_sound("explosion", false, global_transform.origin) in the grenades _process function, right after the grenade damages all the bodies within its blast radius.

最后的笔记

../../../_images/FinishedTutorialPicture.png

现在您有一个完全工作的单人FPS!

在此之上,你已打下构建更复杂的FPS游戏的良好基础。

警告

如果您迷路了,请务必再次阅读代码!

您可以在这里下载整个教程的完成项目: Godot_FPS_Part_6.zip

注解

完成的项目源文件包含相同的代码,只是书写顺序稍有不同。因为本教程的撰写基于已完成的项目源文件。

完成的项目代码是按照创建功能的顺序编写的,不一定是理想的学习顺序。

除此之外,源代码完全相同,只是提供有用的评论,解释每个部分的作用。

小技巧

The finished project source is hosted on GitHub as well: https://github.com/TwistedTwigleg/Godot_FPS_Tutorial

Please note that the code in GitHub may or may not be in sync with the tutorial in the documentation.

文档中的代码可能会随时更新至最新版。如果您不确定使用哪个,请使用文档中提供的项目,因为它们是由Godot社区负责维护的。

You can download all the .blend files used in this tutorial here: Godot_FPS_BlenderFiles.zip

启动资源中提供的所有资源(除非另有说明) 最初由TwistedTwigleg创建,由Godot社区进行更改/添加。 本教程提供的所有原始资源均在 MIT 许可下发布。

您可以随意使用这些资源! 所有原始资源均属于Godot社区,其他资源属于以下列出的资源:

天空盒由** StumpyStrust **创建,可以在OpenGameArt.org找到。 https://opengameart.org/content/space-skyboxes-0。 天空盒根据“CC0”许可证授权。

使用的字体是** Titillium-Regular **,并根据``SIL Open Font License,Version 1.1`许可。

使用此工具将天空盒转换为360 equirectangular图像:https://www.360toolkit.co/convert-cubemap-to-spherical-equirectangular.html

虽然没有提供声音,但您可以在https://gamesounds.xyz/找到许多游戏就绪声音

警告

** OpenGameArt.org,360toolkit.co,Titillium-Regular,StumpyStrust和GameSounds.xyz的创建者都不参与本教程。**