第6部分

部分概述

在这部分, 我们将添加一个主菜单和暂停菜单, 为玩家添加一个重生系统, 并改变和移动声音系统, 以便可以从任何脚本中使用它.

这是FPS教程的最后一部分;结束后, 你将有一个坚实的基础, 用Godot建立惊人的FPS游戏!

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

注解

假设您已经完成了 第5部分, 才会进入本部分教程. 从 第5部分 中完成的项目将是第六部分的起始项目

让我们开始吧!

添加主菜单

首先, 打开 Main_Menu.tscn , 看看场景是如何设置的.

主菜单分为三个不同的面板, 每个面板代表主菜单的不同 "屏幕".

注解

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

随意展开所有的节点, 看看它们是如何设置的. 记住, 当你完成后, 只保留 Start_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相关的代码.

小技巧

请参阅 设计标题画面 以及以下教程, 以获得更好的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

首先, 我们得到所有的 Panel 节点, 并将它们分配到合适的变量中.

接下来, 我们将所有的按钮 pressed 信号连接到各自的 [panel_name_here]_button_pressed 函数.

然后, 我们将鼠标模式设置为 MOUSE_MODE_VISIBLE , 以保证每当玩家回到这个场景时, 鼠标都是可见的.

然后我们得到一个叫 Globals 的单例. 然后设置 HSlider 节点的值, 使它们的值与单例中的鼠标和手柄的灵敏度一致.

注解

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


start_menu_button_pressed 中, 我们检查哪个按钮被按下.

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


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

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

如果有一个场景变化按钮被按下, 首先调用 set_mouse_and_joypad_sensitivity , 这样单例( Globals.gd )就有了 HSlider 节点的值. 然后, 用它的 load_new_scene 函数告诉单例改变节点, 并传入玩家选择的场景的文件路径.

注解

不要担心单例, 我们很快到了!


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

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

如果按下 fullscreen 按钮, 我们就会将 OS 的全屏模式设置为其当前值的翻转版本.

如果 vsync 按钮被按下, 我们根据Vsync检查按钮的状态来设置 OS 的Vsync.


最后, 让我们来看看 set_mouse_and_joypad_sensitivity .

首先, 我们得到 Globals 单例, 并将其分配给一个局部变量.

然后我们将 mouse_sensitivityjoypad_sensitivity 变量设置为它们各自的值 HSlider 节点对应物.

使 Globals 单例

现在, 为了让所有这些都能正常工作, 我们需要创建 Globals 单例. 在 Script 选项卡中创建一个新的脚本, 并将其命名为 Globals.gd .

注解

要制作 Globals 单例, 请转到编辑器中的 Script 选项卡, 然后单击 New 并出现一个 Create Script 框, 除了 Path 您需要插入脚本名称 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)

正如你所看到的, 它是相当小而简单的. 随着这部分的进展, 我们会不断给 Globals.gd 添加更复杂的逻辑, 但现在, 它所做的就是持有两个类变量, 以及抽象定义我们如何改变场景.

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

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

现在, 我们将使用 Globals.gd 来实现跨场景携带变量的功能. 因为鼠标和手柄的灵敏度都存储在 Globals.gd 中, 我们在一个场景中做的任何改变(比如在 Options_Menu 中)都会影响玩家的灵敏度.

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

这就是现在 Globals.gd 所需要的所有代码!在我们测试主菜单之前, 首先需要将 Globals.gd 设置为自动加载脚本.

打开``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 中设置正确文件的路径! 否则, 您将无法从级别选择菜单/屏幕更改场景.

添加调试菜单

现在, 让我们添加一个简单的调试场景, 这样就可以在游戏中跟踪FPS(每秒帧数)等内容. 打开 Debug_Display.tscn .

你可以看到它是一个 Panel, 位置在屏幕的右上角. 它有三个 Labels, 一个是显示游戏运行的FPS, 一个是显示游戏在什么操作系统上运行, 还有一个标签是显示游戏在哪个Godot版本上运行.

让我们添加填充这些代码所需的代码 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())

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


_ready 中, 我们使用 get_name 函数将 OS_Label 的文本设置为 OS 提供的名称. 这将返回Godot被编译的操作系统或操作系统的名称. 例如, 当你运行Windows时, 它将返回 Windows , 而当你运行Linux时, 它将返回 X11 .

然后, 我们将 Engine_Label 的文本设置为 Engine.get_version_info 提供的版本信息. Engine.get_version_info 会返回一个字典, 里面有关于当前运行的Godot版本的有用信息. 我们只关心字符串的版本, 至少对这个标签来说是这样, 所以我们得到这个字符串, 并将其赋值为 Engine_Label 中的 text . 参见 Engine 了解更多关于 get_version_info 返回值的信息.

_process 中, 我们将 FPS_Label 的文本设置为 Engine.get_frames_per_second , 但由于 get_frames_per_second 返回的是一个整数, 所以我们必须先用 str 将其转变成一个字符串, 然后才能将其添加到 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)

这将会在单例中调用一个新的函数 set_debug_display , 所以接下来让我们添加这个函数吧!


打开 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)

现在在 _ready 中, 我们创建一个新的画布层, 将其分配给 canvas_layer , 并将其添加为子节点. 因为 Globals.gd 是一个自动加载单例, 所以当游戏启动时,Godot会做一个 Node , 它将有 Globals.gd 附在上面. 由于Godot制作了一个 Node , 我们可以像对待其他节点一样对待 Globals.gd 来添加或删除子节点.

我们添加一个 CanvasLayer 的原因是为了在 Globals.gd 中实例/spawn的所有GUI和UI节点总是绘制在其他所有东西之上.

当将节点添加到单例自动加载时, 你必须小心不要丢失对任何子节点的引用. 这是因为当你改变活动场景时, 节点不会被释放和销毁, 这意味着如果你在实例化和生成很多节点而没有释放它们, 可能会遇到内存问题.


现在我们需要将 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_displaynull, 我们实例化一个新的 DEBUG_DISPLAY_SCENE , 并将其添加为 canvas_layer 的子节点.


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

注意到即使你从 Main_Menu.tscn 到另一个场景(如 Testing_Area.tscn )改变场景时, 调试显示也会保持. 这就是在单例/自动加载中实例化/spawning节点并将它们作为子节点添加到单例/自动加载的好处. 只要游戏还在运行, 任何作为单例/自动加载的子节点都会一直存在, 而不需要我们做任何额外的工作!

添加暂停菜单

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

打开 Pause_Popup.tscn .

注意 Pause_Popup 中的根节点是一个 WindowDialogWindowDialog 继承自 Popup , 这意味着 WindowDialog 可以像弹出窗口一样运行.

选择 Pause_Popup , 然后一直向下滚动, 直到在检查器中找到 Pause 菜单. 请注意, 暂停模式被设置为 process , 而不是像通常默认设置的 inherit . 这使得它即使在游戏暂停时也会继续处理, 我们需要这样做才能与UI元素进行交互.

现在我们已经了解 Pause_Popup.tscn 是如何设置的, 让我们编写代码来使它工作. 通常, 我们会在场景的根节点上附加一个脚本, 在本例中是 Pause_Popup , 但由于需要在 Globals.gd 中接收一些信号, 我们将在那里写弹出窗口的所有代码.

打开 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

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


首先, 我们检查 ui_cancel 动作是否被按下. 然后, 我们通过检查 popup 是否等于 null 来确保 Globals.gd 没有打开 popup .

如果 Globals.gd 没有弹出窗口, 我们实例化 POPUP_SCENE 并将其分配给 popup .

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

接下来, 我们将 WindowDialogpopup_hide 信号和resume按钮的 pressed 信号都分配给 popup_closed , 将很快添加.

然后, 我们添加 popup 作为 canvas_layer 的子级, 这样它就会被绘制在上面. 然后用 popup_centered 通知 popup 在屏幕中心弹出.

接下来, 我们确保鼠标模式为 MOUSE_MODE_VISIBLE , 这样玩家就可以与弹出窗口进行交互. 如果我们不这样做, 玩家将无法在任何鼠标模式为 MOUSE_MODE_CAPTURED 的场景中与弹出窗口交互.

最后, 我们暂停整个 SceneTree.

注解

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


现在, 我们需要添加连接信号的函数. 先添加 popup_closed .

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

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

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

popup_closed 将恢复游戏, 并在有弹出窗口的情况下销毁.

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 将恢复游戏, 将鼠标模式设置为 MOUSE_MODE_VISIBLE 以确保鼠标在主菜单中可见, 如果有弹出窗口, 则销毁弹出窗口, 并将场景改为主菜单.


在我们准备测试弹出窗口之前, 应该在 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)

现在, 我们不是捕捉或释放鼠标, 而是检查当前的鼠标模式是否为 MOUSE_MODE_VISIBLE . 如果是, 就把它设置为 MOUSE_MODE_CAPTURED .

因为每当你暂停时, 弹出的窗口会使鼠标模式为 MOUSE_MODE_VISIBLE , 所以我们不再担心在 Player.gd 中释放和捕捉光标.


现在弹出的暂停菜单已经完成. 现在你可以在游戏中的任何一点暂停并返回主菜单!

启动重生系统

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

首先, 打开 Player.tscn , 展开 HUD . 注意有一个 ColorRectDeath_Screen . 当玩家死亡时, 我们将使 Death_Screen 可见, 并向他们展示在玩家能够重生之前他们需要等待多长时间.

打开 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 . 我们还设置了玩家的全局位置, 将玩家的全局 Transform 中的原点设置为由 globals.get_respawn_position 返回的位置.

注解

别担心, 我们将在下面添加 get_respawn_position


接下来, 我们需要对 _physics_process 进行一些修改. 将 _physics_process 改为:

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 .

注解

if !is_dead: 表达式与 if is_dead == false: 表达式是等价的, 工作方式相同. 而把表达式中的 ! 号去掉, 我们就可以得到相反的表达式 if is_dead == true: . 这只是用一种更短的方式来写同样的代码功能.

我们还没有制作 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_deadfalse 来检查游戏角色是否刚刚死亡.

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

接下来, 我们将 changing_weapon 设置为 true , 并将 changing_weapon_name 设置为 UNARMED . 这样一来, 如果玩家在使用武器, 当他们死亡时, 武器就会被收起来.

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

接下来, 我们把 dead_time 设置为 RESPAWN_TIME , 这样就可以开始倒数玩家已经死亡多久. 还将 is_dead 设置为 true , 这样就知道玩家已经死亡.

如果玩家死亡的时候手里持有一个东西, 我们就需要扔掉它. 我们首先检查玩家是否持有一个物体. 如果玩家持有一个物体, 使用与 第5部分 中添加的投掷代码相同的代码来投掷它.

注解

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


然后我们检查游戏角色是否死了. 如果游戏角色死了, 我们就从 dead_time 中删除 delta .

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

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

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

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

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

接下来, 我们使 Death_Screen 不可见, 并使UI的其余部分, PanelCrosshair 节点再次可见.

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

然后我们将 health 重置为 100 , 将 grenade_amounts 重置为默认值, 并将 current_grenade 改为 Grenade . 这有效地将这些变量重置为其默认值.

最后, 我们将 is_dead 设置为 false .


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

if is_dead:
    return

现在当游戏角色死了, 玩家无法用鼠标环顾四周.

完成重生系统

首先让我们打开 Weapon_Pistol.gd 脚本并添加 reset_weapon 函数. 添加以下内容:

func reset_weapon():
    ammo_in_weapon = 10
    spare_ammo = 20

现在当我们调用 reset_weapon 时, 手枪中的弹药和备用弹药将重置为默认值.

现在让我们在 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

现在, 当游戏角色死亡时, 所有武器都会重置.


现在我们需要在 Globals.gd 中添加一些东西. 首先, 添加以下类变量:

var respawn_points = null
  • respawn_points : 一个变量, 用于保存关卡中的所有重生点

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

randomize()

randomize 将给我们一个新的随机种子, 因此当我们使用任意一个随机函数时, 我们得到一个(相对的)随机数字字符串.

现在让我们将 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

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


首先, 我们通过检查 respawn_points 是否为 null 来检查 Globals.gd 是否有任何 respawn_points .

如果 respawn_pointsnull, 我们返回一个空位置 Vector 3 , 位置为 (0,0,0).

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

然后, 我们在 respawn_pointsrespawn_point 位置返回 Spatial 节点的位置.


在我们完成 Globals.gd 之前. 我们需要将以下内容添加到 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()

现在当一个带有 Respawn_Point_Setter.gd 的节点调用了它的 _ready 函数时, 该节点的所有子节点都带有 Respawn_Point_Setter.gd , Spawn_Points 在 `` Ruins_Level.tscn`` 将被添加到 Globals.gd 中的 respawn_points .

警告

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


现在当游戏角色死亡时, 他们会在等待 4 秒后重生!

注解

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

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

最后, 让我们制作一个音响系统, 这样我们就可以在任何地方播放声音, 而无需使用播放器.

首先, 打开 SimpleAudioPlayer.gd 并将其更改为以下内容:

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 中实例化/生成, 而是在 Player.gd 中生成音频播放器, 这样我们就可以从任何场景创建声音. 现在音频播放器存储了 Globals.gd 单例, 所以当音频播放器被销毁时, 我们也可以从 Globals.gd 中的列表中删除它.

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


对于类变量, 我们删除了所有的 audio_[insert name here] 变量, 因为我们将从 Globals.gd 中传入这些变量.

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

_ready 里的唯一变化是现在音频播放器正在获得 Globals.gd 单例并将其分配给 globals .

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

最后, 在 sound_finished 中, 我们首先检查音频播放器是否应该使用 should_loop 循环. 如果音频播放器应该循环, 我们将从头开始再次播放声音, 位置为 0.0 . 如果音频播放器不应该循环, 我们从名为 created_audioGlobals.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 = []

让我们来看看这些全局变量.

  • audio_clips : 一个 包含 Globals.gd 中可以播放的所有音频片段的字典.

  • SIMPLE_AUDIO_PLAYER_SCENE: 简单的音频播放器场景.

  • created_audio: 一个列表, 用于保存所有已创建的简单的音频播放器 Globals.gd.

注解

如果要添加其他音频, 则需要将其添加到 "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!")

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

首先, 我们检查 Globals.gd 是否在 audio_clips 中有一个名为 sound_name 的音频剪辑. 如果没有, 我们会打印一条错误消息.

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

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

注解

记住, 我们必须小心地将节点添加到单例中, 因为这些节点在改变场景时不会被销毁.

我们将 "new_audio" 添加到 "created_audio" 列表中, 以保存所有创建的音频.

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


在我们离开 Globals.gd 之前, 我们需要在 load_new_scene 中添加几行代码, 这样当播放器改变场景时, 所有的音频都会被销毁.

将以下内容添加到 load_new_scene:

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

现在, 在 Globals.gd 改变场景之前, 它会通过 created_sounds 中的每个简单音频播放器并释放/销毁它们. 一旦 Globals.gd 完成了 created_audio 中的所有声音, 我们就会清除 created_audio , 这样它就不再拥有对任何(现已释放/毁坏)简单音频播放器的引用.


让我们改变 Player.gd 中的 create_sound 来使用这个新系统. 首先, 从 Player.gd 的类变量中删除 simple_audio_player , 因为我们将不再直接在 Player.gd 中实例化/产生声音.

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

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

现在每当调用 create_sound 时, 我们只需在 Globals.gd 中调用 play_sound , 传入所有收到的参数.


现在我们的FPS中的所有声音都可以在任何地方播放. 我们所要做的就是得到 Globals.gd 单例, 并调用 play_sound , 传入我们想要播放的声音的名称, 无论我们是否想要它循环, 以及 播放声音.

例如, 如果您想在手榴弹爆炸时发出爆炸声, 您需要在 Globals.gdaudio_clips 中添加一个新的声音, 得到 Globals.gd 单例, 然后您只需要在手榴弹 _ process 函数中添加类似 globals.play_sound("explosion",false,global_transform.origin) 的东西, 就在手榴弹损坏其爆炸半径范围内的所有物体之后.

最后的笔记

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

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

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

警告

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

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

注解

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

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

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

小技巧

完整的项目源也托管在Github上: https://github.com/TwistedTwigleg/Godot_FPS_Tutorial

请注意,Github中的代码可能与文档中的教程同步也可能不同步 .

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

您可以在这里下载本教程中使用的所有 .blend 文件 : 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的创建者都不参与本教程.