第6部分

部分概述

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

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

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

注解

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

让我们开始吧!

添加主菜单

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

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

注解

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

随意展开所有的节点,看看它们是如何设置的.记住,当你完成后,只保留 Start_Menu 可见,因为当我们进入主菜单时,希望首先显示的是这个屏幕.

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

  1. extends Control
  2. var start_menu
  3. var level_select_menu
  4. var options_menu
  5. export (String, FILE) var testing_area_scene
  6. export (String, FILE) var space_level_scene
  7. export (String, FILE) var ruins_level_scene
  8. func _ready():
  9. start_menu = $Start_Menu
  10. level_select_menu = $Level_Select_Menu
  11. options_menu = $Options_Menu
  12. $Start_Menu/Button_Start.connect("pressed", self, "start_menu_button_pressed", ["start"])
  13. $Start_Menu/Button_Open_Godot.connect("pressed", self, "start_menu_button_pressed", ["open_godot"])
  14. $Start_Menu/Button_Options.connect("pressed", self, "start_menu_button_pressed", ["options"])
  15. $Start_Menu/Button_Quit.connect("pressed", self, "start_menu_button_pressed", ["quit"])
  16. $Level_Select_Menu/Button_Back.connect("pressed", self, "level_select_menu_button_pressed", ["back"])
  17. $Level_Select_Menu/Button_Level_Testing_Area.connect("pressed", self, "level_select_menu_button_pressed", ["testing_scene"])
  18. $Level_Select_Menu/Button_Level_Space.connect("pressed", self, "level_select_menu_button_pressed", ["space_level"])
  19. $Level_Select_Menu/Button_Level_Ruins.connect("pressed", self, "level_select_menu_button_pressed", ["ruins_level"])
  20. $Options_Menu/Button_Back.connect("pressed", self, "options_menu_button_pressed", ["back"])
  21. $Options_Menu/Button_Fullscreen.connect("pressed", self, "options_menu_button_pressed", ["fullscreen"])
  22. $Options_Menu/Check_Button_VSync.connect("pressed", self, "options_menu_button_pressed", ["vsync"])
  23. $Options_Menu/Check_Button_Debug.connect("pressed", self, "options_menu_button_pressed", ["debug"])
  24. Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
  25. var globals = get_node("/root/Globals")
  26. $Options_Menu/HSlider_Mouse_Sensitivity.value = globals.mouse_sensitivity
  27. $Options_Menu/HSlider_Joypad_Sensitivity.value = globals.joypad_sensitivity
  28. func start_menu_button_pressed(button_name):
  29. if button_name == "start":
  30. level_select_menu.visible = true
  31. start_menu.visible = false
  32. elif button_name == "open_godot":
  33. OS.shell_open("https://godotengine.org/")
  34. elif button_name == "options":
  35. options_menu.visible = true
  36. start_menu.visible = false
  37. elif button_name == "quit":
  38. get_tree().quit()
  39. func level_select_menu_button_pressed(button_name):
  40. if button_name == "back":
  41. start_menu.visible = true
  42. level_select_menu.visible = false
  43. elif button_name == "testing_scene":
  44. set_mouse_and_joypad_sensitivity()
  45. get_node("/root/Globals").load_new_scene(testing_area_scene)
  46. elif button_name == "space_level":
  47. set_mouse_and_joypad_sensitivity()
  48. get_node("/root/Globals").load_new_scene(space_level_scene)
  49. elif button_name == "ruins_level":
  50. set_mouse_and_joypad_sensitivity()
  51. get_node("/root/Globals").load_new_scene(ruins_level_scene)
  52. func options_menu_button_pressed(button_name):
  53. if button_name == "back":
  54. start_menu.visible = true
  55. options_menu.visible = false
  56. elif button_name == "fullscreen":
  57. OS.window_fullscreen = !OS.window_fullscreen
  58. elif button_name == "vsync":
  59. OS.vsync_enabled = $Options_Menu/Check_Button_VSync.pressed
  60. elif button_name == "debug":
  61. pass
  62. func set_mouse_and_joypad_sensitivity():
  63. var globals = get_node("/root/Globals")
  64. globals.mouse_sensitivity = $Options_Menu/HSlider_Mouse_Sensitivity.value
  65. 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

首先,我们得到所有的 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_sensitivity 和``joypad_sensitivity``变量设置为它们各自的值 HSlider 节点对应物.

使 Globals 单例

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

注解

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

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

  1. extends Node
  2. var mouse_sensitivity = 0.08
  3. var joypad_sensitivity = 2
  4. func _ready():
  5. pass
  6. func load_new_scene(new_scene_path):
  7. 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 的新脚本. 添加以下内容:

  1. extends Control
  2. func _ready():
  3. $OS_Label.text = "OS: " + OS.get_name()
  4. $Engine_Label.text = "Godot version: " + Engine.get_version_info()["string"]
  5. func _process(delta):
  6. $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 中更改以下内容:

  1. elif button_name == "debug":
  2. pass

改为:

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

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


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

  1. # ------------------------------------
  2. # All the GUI/UI-related variables
  3. var canvas_layer = null
  4. const DEBUG_DISPLAY_SCENE = preload("res://Debug_Display.tscn")
  5. var debug_display = null
  6. # ------------------------------------
  • canvas_layer:一个画布层,因此在 Globals.gd 中创建的GUI / UI总是在顶部绘制.

  • DEBUG_DISPLAY:我们之前处理过的调试显示场景.

  • debug_display:一个变量,用于在/如果存在时保持调试显示.

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

  1. func _ready():
  2. canvas_layer = CanvasLayer.new()
  3. 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``:

  1. func set_debug_display(display_on):
  2. if display_on == false:
  3. if debug_display != null:
  4. debug_display.queue_free()
  5. debug_display = null
  6. else:
  7. if debug_display == null:
  8. debug_display = DEBUG_DISPLAY_SCENE.instance()
  9. 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 )改变场景时,调试显示也会保持.这就是在单例/自动加载中实例化/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 并添加以下类变量:

  1. const MAIN_MENU_PATH = "res://Main_Menu.tscn"
  2. const POPUP_SCENE = preload("res://Pause_Popup.tscn")
  3. var popup = null
  • MAIN_MENU_PATH:主菜单场景的路径.

  • POPUP_SCENE:我们之前看过的弹出场景.

  • popup:一个用于保存弹出场景的变量.

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

  1. func _process(delta):
  2. if Input.is_action_just_pressed("ui_cancel"):
  3. if popup == null:
  4. popup = POPUP_SCENE.instance()
  5. popup.get_node("Button_quit").connect("pressed", self, "popup_quit")
  6. popup.connect("popup_hide", self, "popup_closed")
  7. popup.get_node("Button_resume").connect("pressed", self, "popup_closed")
  8. canvas_layer.add_child(popup)
  9. popup.popup_centered()
  10. Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
  11. 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``:

  1. func popup_closed():
  2. get_tree().paused = false
  3. if popup != null:
  4. popup.queue_free()
  5. popup = null

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

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

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

  1. func popup_quit():
  2. get_tree().paused = false
  3. Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
  4. if popup != null:
  5. popup.queue_free()
  6. popup = null
  7. load_new_scene(MAIN_MENU_PATH)

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


在我们准备测试弹出窗口之前,应该在 Player.gd 中修改一件事.

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

代替:

  1. # Capturing/Freeing cursor
  2. if Input.is_action_just_pressed("ui_cancel"):
  3. if Input.get_mouse_mode() == Input.MOUSE_MODE_VISIBLE:
  4. Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
  5. else:
  6. Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)

您只会离开:

  1. # Capturing/Freeing cursor
  2. if Input.get_mouse_mode() == Input.MOUSE_MODE_VISIBLE:
  3. 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 并添加以下类变量:

  1. const RESPAWN_TIME = 4
  2. var dead_time = 0
  3. var is_dead = false
  4. var globals
  • RESPAWN_TIME:重生的时间(以秒为单位).

  • dead_time:一个跟踪游戏角色死亡时间的变量.

  • is_dead:一个跟踪游戏角色当前是否死亡的变量.

  • globals:一个变量来保存 Globals.gd 单例.


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

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

现在我们得到 Globals.gd 的单例,并将其分配给 globals .我们还设置了玩家的全局位置,将玩家的全局 Transform 中的原点设置为由 globals.get_respawn_position 返回的位置.

注解

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


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

  1. func _physics_process(delta):
  2. if !is_dead:
  3. process_input(delta)
  4. process_view_input(delta)
  5. process_movement(delta)
  6. if (grabbed_object == null):
  7. process_changing_weapons(delta)
  8. process_reloading(delta)
  9. process_UI(delta)
  10. process_respawn(delta)

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

注解

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

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


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

  1. func process_respawn(delta):
  2. # If we've just died
  3. if health <= 0 and !is_dead:
  4. $Body_CollisionShape.disabled = true
  5. $Feet_CollisionShape.disabled = true
  6. changing_weapon = true
  7. changing_weapon_name = "UNARMED"
  8. $HUD/Death_Screen.visible = true
  9. $HUD/Panel.visible = false
  10. $HUD/Crosshair.visible = false
  11. dead_time = RESPAWN_TIME
  12. is_dead = true
  13. if grabbed_object != null:
  14. grabbed_object.mode = RigidBody.MODE_RIGID
  15. grabbed_object.apply_impulse(Vector3(0, 0, 0), -camera.global_transform.basis.z.normalized() * OBJECT_THROW_FORCE / 2)
  16. grabbed_object.collision_layer = 1
  17. grabbed_object.collision_mask = 1
  18. grabbed_object = null
  19. if is_dead:
  20. dead_time -= delta
  21. var dead_time_pretty = str(dead_time).left(3)
  22. $HUD/Death_Screen/Label.text = "You died\n" + dead_time_pretty + " seconds till respawn"
  23. if dead_time <= 0:
  24. global_transform.origin = globals.get_respawn_position()
  25. $Body_CollisionShape.disabled = false
  26. $Feet_CollisionShape.disabled = false
  27. $HUD/Death_Screen.visible = false
  28. $HUD/Panel.visible = true
  29. $HUD/Crosshair.visible = true
  30. for weapon in weapons:
  31. var weapon_node = weapons[weapon]
  32. if weapon_node != null:
  33. weapon_node.reset_weapon()
  34. health = 100
  35. grenade_amounts = {"Grenade":2, "Sticky Grenade":2}
  36. current_grenade = "Grenade"
  37. is_dead = false

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


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

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

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

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

接下来,我们把 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 的开头添加以下内容:

  1. if is_dead:
  2. return

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

完成重生系统

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

  1. func reset_weapon():
  2. ammo_in_weapon = 10
  3. spare_ammo = 20

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

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

  1. func reset_weapon():
  2. ammo_in_weapon = 50
  3. spare_ammo = 100

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

  1. func reset_weapon():
  2. ammo_in_weapon = 1
  3. spare_ammo = 1

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


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

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

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

  1. randomize()

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

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

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

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


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

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

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

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


在我们完成 Globals.gd 之前. 我们需要将以下内容添加到 load_new_scene :

  1. respawn_points = null

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


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

  1. extends Spatial
  2. func _ready():
  3. var globals = get_node("/root/Globals")
  4. 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 并将其更改为以下内容:

  1. extends Spatial
  2. var audio_node = null
  3. var should_loop = false
  4. var globals = null
  5. func _ready():
  6. audio_node = $Audio_Stream_Player
  7. audio_node.connect("finished", self, "sound_finished")
  8. audio_node.stop()
  9. globals = get_node("/root/Globals")
  10. func play_sound(audio_stream, position=null):
  11. if audio_stream == null:
  12. print ("No audio stream passed; cannot play sound")
  13. globals.created_audio.remove(globals.created_audio.find(self))
  14. queue_free()
  15. return
  16. audio_node.stream = audio_stream
  17. # If you are using an AudioStreamPlayer3D, then uncomment these lines to set the position.
  18. #if audio_node is AudioStreamPlayer3D:
  19. # if position != null:
  20. # audio_node.global_transform.origin = position
  21. audio_node.play(0.0)
  22. func sound_finished():
  23. if should_loop:
  24. audio_node.play(0.0)
  25. else:
  26. globals.created_audio.remove(globals.created_audio.find(self))
  27. audio_node.stop()
  28. 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 . 首先,添加以下类变量:

  1. # All the audio files.
  2. # You will need to provide your own sound files.
  3. var audio_clips = {
  4. "Pistol_shot": null, #preload("res://path_to_your_audio_here!")
  5. "Rifle_shot": null, #preload("res://path_to_your_audio_here!")
  6. "Gun_cock": null, #preload("res://path_to_your_audio_here!")
  7. }
  8. const SIMPLE_AUDIO_PLAYER_SCENE = preload("res://Simple_Audio_Player.tscn")
  9. 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 的新函数:

  1. func play_sound(sound_name, loop_sound=false, sound_position=null):
  2. if audio_clips.has(sound_name):
  3. var new_audio = SIMPLE_AUDIO_PLAYER_SCENE.instance()
  4. new_audio.should_loop = loop_sound
  5. add_child(new_audio)
  6. created_audio.append(new_audio)
  7. new_audio.play_sound(audio_clips[sound_name], sound_position)
  8. else:
  9. 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``:

  1. for sound in created_audio:
  2. if (sound != null):
  3. sound.queue_free()
  4. 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 更改为以下内容:

  1. func create_sound(sound_name, position=null):
  2. 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的创建者都不参与本教程.