第5部分

部分概述

在这部分, 我们要给玩家增加手榴弹, 让玩家拥有抓取和投掷物体的能力, 并添加炮塔!

../../../_images/PartFiveFinished.png

注解

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

让我们开始吧!

添加手榴弹

首先, 我们给玩家一些手榴弹, 打开 Grenade.tscn .

这里有几件事要注意, 首先是手榴弹要使用 RigidBody 节点. 我们要为手榴弹使用 RigidBody 节点, 这样它们就会以有种表现现实的方式在世界范围内弹跳.

第二点需要注意的是 Blast_Area. 这是一个 Area 节点, 它代表手榴弹的爆炸半径.

最后, 要注意的是 Explosion . 这是 Particles 节点, 当手雷爆炸时, 会发出爆炸效果. 需注意, 我们启用了 One shot , 是为了一次发射所有的粒子. 粒子使用世界坐标而不是局部坐标发射的, 所以没有选中 Local Coords .

注解

如果需要, 您可以通过查看粒子的 "过程材质" 和 "绘制过程" 来查看粒子是如何设置的.

让我们编写手榴弹所需的代码. 选择 Grenade 并制作一个名为 Grenade.gd 的新脚本. 添加以下内容:

extends RigidBody

const GRENADE_DAMAGE = 60

const GRENADE_TIME = 2
var grenade_timer = 0

const EXPLOSION_WAIT_TIME = 0.48
var explosion_wait_timer = 0

var rigid_shape
var grenade_mesh
var blast_area
var explosion_particles

func _ready():
    rigid_shape = $Collision_Shape
    grenade_mesh = $Grenade
    blast_area = $Blast_Area
    explosion_particles = $Explosion

    explosion_particles.emitting = false
    explosion_particles.one_shot = true

func _process(delta):

    if grenade_timer < GRENADE_TIME:
        grenade_timer += delta
        return
    else:
        if explosion_wait_timer <= 0:
            explosion_particles.emitting = true

            grenade_mesh.visible = false
            rigid_shape.disabled = true

            mode = RigidBody.MODE_STATIC

            var bodies = blast_area.get_overlapping_bodies()
            for body in bodies:
                if body.has_method("bullet_hit"):
                    body.bullet_hit(GRENADE_DAMAGE, body.global_transform.looking_at(global_transform.origin, Vector3(0, 1, 0)))

            # This would be the perfect place to play a sound!


        if explosion_wait_timer < EXPLOSION_WAIT_TIME:
            explosion_wait_timer += delta

            if explosion_wait_timer >= EXPLOSION_WAIT_TIME:
                queue_free()

让我们回顾一下正在发生的事情, 从类变量开始:

  • GRENADE DAMAGE: 手榴弹爆炸时造成的伤害量.

  • GRENADE_TIME: 手榴弹在创建/抛出后爆炸所需的时间(以秒为单位).

  • grenade_timer: 一个变量, 用于跟踪手榴弹被创建/抛出的时间.

  • EXPLOSION_WAIT_TIME: 爆炸后我们摧毁手榴弹场景所需的等待时间(以秒为单位)

  • explosion_wait_timer: 一个变量, 用于跟踪自手榴弹爆炸以来已经过了多少时间.

  • rigid_shape: CollisionShape 用于手榴弹 RigidBody.

  • grenade_mesh: 手榴弹的参考 MeshInstance .

  • blast_area: 爆炸 Area 用于在手榴弹爆炸时损坏东西.

  • explosion_particles: Particles , 当手榴弹爆炸时产生.

注意 EXPLOSION_WAIT_TIME 是一个相当奇怪的数字(0.48). 这是因为我们希望 EXPLOSION_WAIT_TIME 等于爆炸粒子发射的时间长度, 所以当粒子完成时我们会摧毁/释放手榴弹. 我们通过获取粒子的生命时间并将其除以粒子的速度刻度来计算 "EXPLOSION_WAIT_TIME". 这让我们得到了爆炸粒子持续的确切时间.


现在让我们把注意力转向 _ready .

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

我们需要得到 CollisionShapeMeshInstance 因为类似于 第4部分 中的目标, 我们将隐藏手榴弹的网格并禁用碰撞形状 手榴弹爆炸了.

我们需要获得爆炸的原因 Area 这样我们可以在手榴弹爆炸时损坏其内部的一切. 我们将使用与游戏角色中的刀代码类似的代码. 我们需要 Particles 所以我们可以在手榴弹爆炸时发射粒子.

当我们得到所有的节点并将它们分配给类变量后, 确保爆炸粒子不会发射, 并且它们被设置为一次发射. 这是为了确保粒子会以我们期望的方式运行.


现在让我们来看看 _process .

首先, 我们检查 grenade_timer 是否小于 GRENADE_TIME . 如果是, 我们加上 delta 并返回. 这是为了让手榴弹在爆炸前必须等待 GRENADE_TIME 秒, 让 RigidBody 移动.

如果 grenade_timer 位于 GRENADE_TIMER 或更高, 那么我们需要检查手榴弹是否等待了足够长的时间并且需要爆炸. 我们通过检查 explosion_wait_timer 是否等于 0 或更少来做到这一点. 因为我们将立即将 delta 添加到 explosion_wait_timer , 所以检查下的任何代码只会被调用一次, 就在手榴弹等待足够长并且需要爆炸时.

如果手榴弹已经等待足够长的时间爆炸, 我们首先告诉 "爆炸_粒子" 发射. 然后我们使 grenade_mesh 不可见, 并禁用 rigid_shape , 有效地隐藏了手榴弹.

然后我们将 RigidBody 的模式设置为 MODE_STATIC , 这样手榴弹就不会移动了.

然后我们得到 blast_area 中的所有尸体, 检查它们是否有 bullet_hit 方法或函数, 如果有, 我们调用它, 并传入 GRENADE_DAMAGE 和从尸体查看手榴弹的变换. 这样就使得被手榴弹爆炸的尸体会从手榴弹的位置向外爆炸.

然后我们检查 explosion_wait_timer 是否小于 EXPLOSION_WAIT_TIME . 如果小于, 就在 explosion_wait_timer 上加上 delta .

接下来, 我们检查 explosion_wait_timer 是否大于或等于 EXPLOSION_WAIT_TIME . 因为我们添加了 delta , 所以只调用一次. 如果 explosion_wait_timer 大于或等于 EXPLOSION_WAIT_TIME , 说明手榴弹已经等待了足够长的时间, 让 Particles 播放, 可以释放或销毁手榴弹, 因为不再需要它了.


让我们快速设置粘性手榴弹. 打开 Sticky_Grenade.tscn .

Sticky_Grenade.tscn 几乎与 Grenade.tscn 相同, 只有一个小的补充. 我们现在有第二个 Area, 称为 Sticky_Area . 我们将使用 "Stick_Area" 来检测粘性手榴弹何时与环境相撞并需要粘在某物上.

选择 Sticky_Grenade 并制作一个名为 Sticky_Grenade.gd 的新脚本. 添加以下内容:

extends RigidBody

const GRENADE_DAMAGE = 40

const GRENADE_TIME = 3
var grenade_timer = 0

const EXPLOSION_WAIT_TIME = 0.48
var explosion_wait_timer = 0

var attached = false
var attach_point = null

var rigid_shape
var grenade_mesh
var blast_area
var explosion_particles

var player_body

func _ready():
    rigid_shape = $Collision_Shape
    grenade_mesh = $Sticky_Grenade
    blast_area = $Blast_Area
    explosion_particles = $Explosion

    explosion_particles.emitting = false
    explosion_particles.one_shot = true

    $Sticky_Area.connect("body_entered", self, "collided_with_body")


func collided_with_body(body):

    if body == self:
        return

    if player_body != null:
        if body == player_body:
            return

    if attached == false:
        attached = true
        attach_point = Spatial.new()
        body.add_child(attach_point)
        attach_point.global_transform.origin = global_transform.origin

        rigid_shape.disabled = true

        mode = RigidBody.MODE_STATIC


func _process(delta):

    if attached == true:
        if attach_point != null:
            global_transform.origin = attach_point.global_transform.origin

    if grenade_timer < GRENADE_TIME:
        grenade_timer += delta
        return
    else:
        if explosion_wait_timer <= 0:
            explosion_particles.emitting = true

            grenade_mesh.visible = false
            rigid_shape.disabled = true

            mode = RigidBody.MODE_STATIC

            var bodies = blast_area.get_overlapping_bodies()
            for body in bodies:
                if body.has_method("bullet_hit"):
                    body.bullet_hit(GRENADE_DAMAGE, body.global_transform.looking_at(global_transform.origin, Vector3(0, 1, 0)))

            # This would be the perfect place to play a sound!


        if explosion_wait_timer < EXPLOSION_WAIT_TIME:
            explosion_wait_timer += delta

            if explosion_wait_timer >= EXPLOSION_WAIT_TIME:
                if attach_point != null:
                    attach_point.queue_free()
                queue_free()

上面的代码几乎与 Grenade.gd 的代码完全相同, 所以让我们回顾一下已经改变的代码.

首先, 我们还有几个类变量:

  • 附加: 一个变量, 用于跟踪粘性手榴弹是否附加到 PhysicsBody.

  • attach_point: 一个变量, 用于保存 Spatial , 将位于粘性手榴弹碰撞的位置.

  • player_body: 游戏角色的 KinematicBody.

它们添加, 是为了让粘性手雷能够粘在任何可能击中的 PhysicsBody 上. 我们现在还需要玩家的 KinematicBody , 这样当玩家投掷手雷时, 粘性手雷就不会粘在玩家身上.


现在让我们来看看 _ready 中的小变化. 在 _ready 中我们添加了一行代码, 因此当任何物体进入 Stick_Area 时, 会调用 collided_with_body 函数.


接下来让我们来看看 collided_with_body .

首先, 我们要确保粘性手榴弹不与自己发生碰撞. 因为粘性的 Area 不知道自己是附着在手榴弹的 RigidBody 上的, 所以我们需要通过检查是否与自己碰撞的主体不是自己, 来确保它不会粘到自己身上. 如果我们与自己相撞了, 就通过返回来忽略它.

然后我们检查一下是否有东西分配给 player_body , 如果粘手榴弹碰撞的物体是投掷它的游戏角色. 如果粘手榴弹碰到的物体确实是 player_body, 我们会通过返回来忽略它.

接下来, 我们检查粘性手雷是否已经附着在某物上.

如果手榴弹被接触了, 我们将 attached 设置为 true , 这样我们就知道粘性手榴弹附着在某物上.

然后我们创建一个新的 Spatial 节点, 并使其成为粘性手榴弹与之碰撞的物体的子节点. 然后我们将 Spatial 的位置设置为粘性手榴弹当前的全球位置.

注解

因为我们已经添加了 Spatial 作为粘性手榴弹碰撞的物体的一个子节点, 它将跟随所述物体. 然后我们可以使用它 Spatial 来设置粘性手榴弹的位置, 因此它总是在相对于它碰撞的物体的相同位置.

然后我们禁用 rigid_shape , 这样粘手榴弹就不会一直移动它碰到的任何物体. 最后, 我们将模式设置为 "MODE_STATIC", 因此手榴弹不会移动.


最后, 让我们回顾一下 _process 中的一些变化.

现在我们正在检查粘性手榴弹是否附在 _process 的顶部.

如果连接了粘性手榴弹, 我们确保附加的点不等于 null . 如果附加的点不等于 null , 我们将粘性手榴弹的全局位置(使用其global Transform 的原点)设置为 Spatial 赋值给 attach_point (使用它的global Transform 的原点).

在我们释放/销毁粘性手榴弹之前, 现在唯一的另一个变化是检查粘性手榴弹是否有附着点. 如果是这样, 我们也在连接点上调用 queue_free , 因此它也被释放/销毁.

向游戏角色添加手榴弹

现在我们需要在 Player.gd 中添加一些代码, 以便我们可以使用手榴弹.

首先, 打开 Player.tscn 并展开节点树, 直到您找到 Rotation_Helper . 注意在 Rotation_Helper 中我们有一个名为 Grenade_Toss_Pos 的节点. 我们将在这里生成手榴弹.

还要注意它是如何在 "X" 轴上轻微旋转的, 它不是直指, 而是略微向上. 通过改变 "Grenade_Toss_Pos" 的旋转, 您可以改变投掷手榴弹的角度.

好的, 现在让我们开始让手榴弹与游戏角色一起工作. 将以下类变量添加到 Player.gd:

var grenade_amounts = {"Grenade":2, "Sticky Grenade":2}
var current_grenade = "Grenade"
var grenade_scene = preload("res://Grenade.tscn")
var sticky_grenade_scene = preload("res://Sticky_Grenade.tscn")
const GRENADE_THROW_FORCE = 50
  • grenade_amounts: 游戏角色当前携带的手榴弹数量(针对每种类型的手榴弹).

  • current_grenade: 游戏角色目前正在使用的手榴弹的名称.

  • grenade_scene: 我们之前工作的手榴弹场景.

  • sticky_grenade_scene: 我们之前工作过的粘手榴弹场景.

  • GRENADE_THROW_FORCE: 游戏角色投掷手榴弹的力量.

大多数这些变量与我们设置武器的方式类似.

小技巧

虽然可以制作更模块化的手榴弹系统, 但我发现仅仅两枚手榴弹的额外复杂性是不值得的. 如果您打算制造一个更复杂的FPS和更多的手榴弹, 您可能想要建立一个类似于我们如何设置武器的手榴弹系统.


现在我们需要在 _process_input 中添加一些代码. 将以下内容添加到 _process_input:

# ----------------------------------
# Changing and throwing grenades

if Input.is_action_just_pressed("change_grenade"):
    if current_grenade == "Grenade":
        current_grenade = "Sticky Grenade"
    elif current_grenade == "Sticky Grenade":
        current_grenade = "Grenade"

if Input.is_action_just_pressed("fire_grenade"):
    if grenade_amounts[current_grenade] > 0:
        grenade_amounts[current_grenade] -= 1

        var grenade_clone
        if current_grenade == "Grenade":
            grenade_clone = grenade_scene.instance()
        elif current_grenade == "Sticky Grenade":
            grenade_clone = sticky_grenade_scene.instance()
            # Sticky grenades will stick to the player if we do not pass ourselves
            grenade_clone.player_body = self

        get_tree().root.add_child(grenade_clone)
        grenade_clone.global_transform = $Rotation_Helper/Grenade_Toss_Pos.global_transform
        grenade_clone.apply_impulse(Vector3(0, 0, 0), grenade_clone.global_transform.basis.z * GRENADE_THROW_FORCE)
# ----------------------------------

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

首先, 我们检查 change_grenade 动作是否刚刚被按下. 如果按下了, 就检查玩家当前使用的手榴弹. 根据手榴弹名称, 将 current_grenade 改为相反的手榴弹名称.

接下来我们检查是否刚刚按下了 fire_grenade 动作. 如果有, 我们检查游戏角色是否有超过 "0" 的手榴弹, 用于当前选择的手榴弹类型.

如果游戏角色拥有超过 "0" 的手榴弹, 我们就会从当前手榴弹的手榴弹数量中移除一枚. 然后, 根据游戏角色当前正在使用的手榴弹, 我们实例化正确的手榴弹场景并将其分配给 "grenade_clone".

接下来, 我们将 grenade_clone 添加为根节点的子节点, 并将其global Transform 设置为 Grenade_Toss_Pos 的global Transform. 最后, 我们对手榴弹施加一个冲动, 使它相对于 grenade_cloneZ 方向向量向前发射.


现在游戏角色可以使用两种类型的手榴弹, 但在我们继续添加其他东西之前, 我们应该添加一些东西.

我们仍然需要一种方法向游戏角色展示剩下多少手榴弹, 我们应该在游戏角色拿起弹药时增加一种获得更多手榴弹的方法.

首先, 修改 Player.gd 中的一些代码, 以显示还剩多少颗手榴弹. 将 process_UI 改为如下:

func process_UI(delta):
    if current_weapon_name == "UNARMED" or current_weapon_name == "KNIFE":
        # First line: Health, second line: Grenades
        UI_status_label.text = "HEALTH: " + str(health) + \
                "\n" + current_grenade + ": " + str(grenade_amounts[current_grenade])
    else:
        var current_weapon = weapons[current_weapon_name]
        # First line: Health, second line: weapon and ammo, third line: grenades
        UI_status_label.text = "HEALTH: " + str(health) + \
                "\nAMMO: " + str(current_weapon.ammo_in_weapon) + "/" + str(current_weapon.spare_ammo) + \
                "\n" + current_grenade + ": " + str(grenade_amounts[current_grenade])

现在我们将展示游戏角色在UI中留下多少手榴弹.

当我们还在 Player.gd 时, 让我们添加一个向游戏角色添加手榴弹的功能. 将以下函数添加到 Player.gd:

func add_grenade(additional_grenade):
    grenade_amounts[current_grenade] += additional_grenade
    grenade_amounts[current_grenade] = clamp(grenade_amounts[current_grenade], 0, 4)

现在我们可以使用 add_grenade 添加一个手榴弹, 它会自动被夹到最大的 4 手榴弹.

小技巧

如果需要, 可以将 "4" 改为常量. 您需要创建一个新的全局常量, 比如 MAX_GRENADES , 然后将钳位从``clamp(grenade_amounts [current_grenade],0,4)``更改为``clamp(grenade_amounts [current_grenade], 0,MAX_GRENADES)``

如果您不想限制游戏角色可以携带多少手榴弹, 那就去掉完全夹住手榴弹的线!

现在我们有一个添加手榴弹的功能, 让我们打开 AmmoPickup.gd 并使用它!

打开 AmmoPickup.gd 并转到 trigger_body_entered 函数. 将其更改为以下内容:

func trigger_body_entered(body):
    if body.has_method("add_ammo"):
        body.add_ammo(AMMO_AMOUNTS[kit_size])
        respawn_timer = RESPAWN_TIME
        kit_size_change_values(kit_size, false)

    if body.has_method("add_grenade"):
        body.add_grenade(GRENADE_AMOUNTS[kit_size])
        respawn_timer = RESPAWN_TIME
        kit_size_change_values(kit_size, false)

现在我们还要检查主体是否有 add_grenade 函数. 如果是这样, 我们称之为 "add_ammo".

您可能已经注意到我们正在使用一个尚未定义的新常量, GRENADE_AMOUNTS . 我们加上吧! 使用其他类变量将以下类变量添加到 AmmoPickup.gd:

const GRENADE_AMOUNTS = [2, 0]
  • GRENADE_AMOUNTS: 每个拾取包含的手榴弹数量.

注意 GRENADE_AMOUNTS 中的第二个元素是 0 . 这是为了让小弹药拾取器不给玩家任何额外的手榴弹.


现在您应该可以投掷手榴弹了! 去尝试吧!

添加抓取并将RigidBody节点投射到游戏角色的功能

接下来, 让我们给玩家提供拾取和投掷 RigidBody 节点的能力.

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

var grabbed_object = null
const OBJECT_THROW_FORCE = 120
const OBJECT_GRAB_DISTANCE = 7
const OBJECT_GRAB_RAY_DISTANCE = 10
  • grabbed_object: 一个用于保存抓取的变量 RigidBody 节点.

  • OBJECT_THROW_FORCE: 玩家投掷被抓住物体的力量.

  • OBJECT_GRAB_DISTANCE: 玩家拿着被抓住物体时离相机的距离.

  • OBJECT_GRAB_RAY_DISTANCE: 距离 Raycast 去了. 这是游戏角色的抓地距离.

完成后, 我们需要做的就是在 process_input 中添加一些代码:

# ----------------------------------
# Grabbing and throwing objects

if Input.is_action_just_pressed("fire_grenade") and current_weapon_name == "UNARMED":
    if grabbed_object == null:
        var state = get_world().direct_space_state

        var center_position = get_viewport().size / 2
        var ray_from = camera.project_ray_origin(center_position)
        var ray_to = ray_from + camera.project_ray_normal(center_position) * OBJECT_GRAB_RAY_DISTANCE

        var ray_result = state.intersect_ray(ray_from, ray_to, [self, $Rotation_Helper/Gun_Fire_Points/Knife_Point/Area])
        if !ray_result.empty():
            if ray_result["collider"] is RigidBody:
                grabbed_object = ray_result["collider"]
                grabbed_object.mode = RigidBody.MODE_STATIC

                grabbed_object.collision_layer = 0
                grabbed_object.collision_mask = 0

    else:
        grabbed_object.mode = RigidBody.MODE_RIGID

        grabbed_object.apply_impulse(Vector3(0, 0, 0), -camera.global_transform.basis.z.normalized() * OBJECT_THROW_FORCE)

        grabbed_object.collision_layer = 1
        grabbed_object.collision_mask = 1

        grabbed_object = null

if grabbed_object != null:
    grabbed_object.global_transform.origin = camera.global_transform.origin + (-camera.global_transform.basis.z.normalized() * OBJECT_GRAB_DISTANCE)
# ----------------------------------

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

首先, 我们检查按下的是否是 fire 动作, 以及玩家是否使用了 UNARMED 'weapon' . 这是因为我们只希望玩家在没有使用任何武器的时候, 能够捡起和投掷物体. 这是一种设计上的选择, 但我觉得让 UNARMED 有了用途.

接下来我们检查 grabbed_object 是否为 null.


如果 grabbed_objectnull , 我们想看看我们是否可以选择 RigidBody.

我们首先从当前获得直接空间状态 World. 这样我们就可以完全从代码中投射光线, 而不必使用 Raycast 节点.

注解

参见 Ray-casting 了解更多关于Godot中的射线投射的信息.

然后我们通过将当前 Viewport 大小分成两半来得到屏幕的中心. 然后我们使用摄像机的 project_ray_originproject_ray_normal 获取射线的原点和终点. 如果您想了解有关这些函数如何工作的更多信息, 请参阅 Ray-casting.

接下来, 我们将光线发送到空间状态, 看看它是否得到了结果. 我们添加了游戏角色和刀子 Area 作为两个例外, 因此游戏角色无法携带自己或刀的碰撞 Area.

然后我们检查是否从射线上得到了一个结果. 如果没有对象与射线发生碰撞, 将返回一个空的Dictionary. 如果Dictionary不是空的, 即至少有一个对象发生了碰撞,, 我们再看看射线碰撞的碰撞器是否是一个 RigidBody .

如果光线与 RigidBody 相撞, 我们将 grabbed_object 设置为光线与光线相撞的对撞机. 然后我们将模式设置为 RigidBody 我们与 MODE_STATIC 相撞, 所以它不会在我们手中移动.

最后, 我们将抓取的 RigidBody 的碰撞层和碰撞掩码设置为 0 . 这将使得抓住 RigidBody 没有碰撞层或掩码, 这意味着只要我们拿着它就不会碰到任何东西.

注解

关于Godot碰撞掩码的更多信息, 请参见 物理介绍.


如果 grabbed_object 不是 null , 那么我们需要抛出游戏角色持有的 RigidBody .

我们首先将所持有的 RigidBody 的模式设置为 MODE_RIGID .

注解

这是一个相当大的假设, 即所有刚体都将使用 "MODE_RIGID". 虽然本教程系列就是这种情况, 但在其他项目中可能并非如此.

如果你有不同模式的刚体, 可能需要把你拾取的 RigidBody 的模式存储到一个类变量中, 这样就可以把它改回拾取它之前的模式.

然后我们施加冲动将它向前飞. 我们使用我们在 OBJECT_THROW_FORCE 变量中设置的力将它发送到相机朝向的方向.

然后我们将抓取的 RigidBody 的碰撞层和掩码设置为 1 , 这样它就可以再次与层 1 上的任何东西碰撞.

注解

这又是一个相当大的假设, 即所有刚体都只在碰撞层 "1" 上, 所有碰撞掩模都在层 "1" 上. 如果您在其他项目中使用此脚本, 您可能需要在变量中存储 RigidBody 的碰撞图层/掩码, 然后将它们更改为 "0", 这样您就可以 在您反转过程时为其设置的原始碰撞图层/蒙版.

最后, 我们将 grabbed_object 设置为 null , 因为游戏角色已经成功抛出了被保持的对象.


我们做的最后一件事是在所有抓取/投掷相关代码之外检查 grabbed_object 是否等于 null .

注解

虽然技术上没有输入相关, 但是将代码移动到此处的代码很容易, 因为它只有两行, 然后所有的抓取/抛出代码都在一个地方

如果游戏角色持有一个物体, 我们将其全局位置设置为相机的位置以及相机朝向的方向上的 "OBJECT_GRAB_DISTANCE".


在我们测试之前, 我们需要在 _physics_process 中改变一些东西. 当游戏角色持有一个物体时, 我们不希望游戏角色能够更换武器或重装, 所以将 _physics_process 改为:

func _physics_process(delta):
    process_input(delta)
    process_view_input(delta)
    process_movement(delta)

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

    # Process the UI
    process_UI(delta)

现在游戏角色在拿着物体时无法改变武器或重装.

现在, 当您处于 "UNARMED" 状态时, 您可以抓住并抛出RigidBody节点! 去尝试吧!

添加一个炮塔

接下来, 让我们制作一个炮塔射击游戏角色!

打开 Turret.tscn . 如果尚未展开, 请展开 Turret .

请注意炮塔是如何被分解成几个部分: Base , Head , Vision_AreaSmoke Particles 节点.

打开 Base , 你会发现是一个 StaticBody 和一个网格. 打开 Head , 你会发现有几个网格, 一个 StaticBody 和一个 Raycast 节点.

"头部" 的一个注意事项是, 如果我们使用光线投射, 光线投射将是炮塔射击的地方. 我们还有两个网格叫做 FlashFlash_2 . 这些将是枪口闪光, 简要显示炮塔开火时.

Vision_AreaArea 我们将用作炮塔的能力. 当某些东西进入 "Vision_Area" 时, 我们会认为炮塔可以看到它.

Smoke 是一个 Particles 节点将在炮塔被摧毁和修复时播放.


现在我们已经了解了如何设置场景, 让我们开始编写炮塔的代码. 选择 Turret 并创建一个名为 Turret.gd 的新脚本. 将以下内容添加到 Turret.gd:

extends Spatial

export (bool) var use_raycast = false

const TURRET_DAMAGE_BULLET = 20
const TURRET_DAMAGE_RAYCAST = 5

const FLASH_TIME = 0.1
var flash_timer = 0

const FIRE_TIME = 0.8
var fire_timer = 0

var node_turret_head = null
var node_raycast = null
var node_flash_one = null
var node_flash_two = null

var ammo_in_turret = 20
const AMMO_IN_FULL_TURRET = 20
const AMMO_RELOAD_TIME = 4
var ammo_reload_timer = 0

var current_target = null

var is_active = false

const PLAYER_HEIGHT = 3

var smoke_particles

var turret_health = 60
const MAX_TURRET_HEALTH = 60

const DESTROYED_TIME = 20
var destroyed_timer = 0

var bullet_scene = preload("Bullet_Scene.tscn")

func _ready():

    $Vision_Area.connect("body_entered", self, "body_entered_vision")
    $Vision_Area.connect("body_exited", self, "body_exited_vision")

    node_turret_head = $Head
    node_raycast = $Head/Ray_Cast
    node_flash_one = $Head/Flash
    node_flash_two = $Head/Flash_2

    node_raycast.add_exception(self)
    node_raycast.add_exception($Base/Static_Body)
    node_raycast.add_exception($Head/Static_Body)
    node_raycast.add_exception($Vision_Area)

    node_flash_one.visible = false
    node_flash_two.visible = false

    smoke_particles = $Smoke
    smoke_particles.emitting = false

    turret_health = MAX_TURRET_HEALTH


func _physics_process(delta):

    if is_active == true:

        if flash_timer > 0:
            flash_timer -= delta

            if flash_timer <= 0:
                node_flash_one.visible = false
                node_flash_two.visible = false

        if current_target != null:

            node_turret_head.look_at(current_target.global_transform.origin + Vector3(0, PLAYER_HEIGHT, 0), Vector3(0, 1, 0))

            if turret_health > 0:

                if ammo_in_turret > 0:
                    if fire_timer > 0:
                        fire_timer -= delta
                    else:
                        fire_bullet()
                else:
                    if ammo_reload_timer > 0:
                        ammo_reload_timer -= delta
                    else:
                        ammo_in_turret = AMMO_IN_FULL_TURRET

    if turret_health <= 0:
        if destroyed_timer > 0:
            destroyed_timer -= delta
        else:
            turret_health = MAX_TURRET_HEALTH
            smoke_particles.emitting = false


func fire_bullet():

    if use_raycast == true:
        node_raycast.look_at(current_target.global_transform.origin + Vector3(0, PLAYER_HEIGHT, 0), Vector3(0, 1, 0))

        node_raycast.force_raycast_update()

        if node_raycast.is_colliding():
            var body = node_raycast.get_collider()
            if body.has_method("bullet_hit"):
                body.bullet_hit(TURRET_DAMAGE_RAYCAST, node_raycast.get_collision_point())

        ammo_in_turret -= 1

    else:
        var clone = bullet_scene.instance()
        var scene_root = get_tree().root.get_children()[0]
        scene_root.add_child(clone)

        clone.global_transform = $Head/Barrel_End.global_transform
        clone.scale = Vector3(8, 8, 8)
        clone.BULLET_DAMAGE = TURRET_DAMAGE_BULLET
        clone.BULLET_SPEED = 60

        ammo_in_turret -= 1

    node_flash_one.visible = true
    node_flash_two.visible = true

    flash_timer = FLASH_TIME
    fire_timer = FIRE_TIME

    if ammo_in_turret <= 0:
        ammo_reload_timer = AMMO_RELOAD_TIME


func body_entered_vision(body):
    if current_target == null:
        if body is KinematicBody:
            current_target = body
            is_active = true


func body_exited_vision(body):
    if current_target != null:
        if body == current_target:
            current_target = null
            is_active = false

            flash_timer = 0
            fire_timer = 0
            node_flash_one.visible = false
            node_flash_two.visible = false


func bullet_hit(damage, bullet_hit_pos):
    turret_health -= damage

    if turret_health <= 0:
        smoke_particles.emitting = true
        destroyed_timer = DESTROYED_TIME

这是相当多的代码, 所以让我们按功能分解它. 我们先来看一下类变量:

  • use_raycast: 一个导出的布尔值, 以便我们可以改变炮塔是使用对象还是使用射线发射子弹.

  • TURRET_DAMAGE_BULLET: 单个子弹场景造成的伤害量.

  • TURRET_DAMAGE_RAYCAST: 单个损坏的数量 Raycast bullet.

  • FLASH_TIME: 枪口闪光网格的可见时间(以秒为单位).

  • flash_timer: 一个变量, 用于跟踪枪口闪光网格的可见时间.

  • FIRE_TIME: 发射子弹所需的时间(以秒为单位).

  • fire_timer: 一个变量, 用于跟踪炮塔上次射击后经过的时间.

  • node_turret_head: 一个用于保存 Head 节点的变量.

  • node_raycast: 一个变量, 用于保存附加到炮塔头部的 Raycast 节点.

  • node_flash_one: 一个用于保存第一个枪口flash的变量 MeshInstance.

  • node_flash_two: 一个用于保存第二个枪口flash的变量 MeshInstance.

  • ammo_in_turret: 目前炮塔中的弹药数量.

  • AMMO_IN_FULL_TURRET: 完整炮塔中的弹药数量.

  • AMMO_RELOAD_TIME: 炮塔重装的时间.

  • ammo_reload_timer: 一个变量, 用于跟踪炮塔重装的时间.

  • current_target: 炮塔的当前目标.

  • is_active: 用于跟踪炮塔是否能够射向目标的变量.

  • PLAYER_HEIGHT: 我们添加到目标的高度, 所以我们不会在它的脚下射击.

  • smoke_particles: 用于保存烟雾粒子节点的变量.

  • turret_health: 炮塔目前的健康状况.

  • MAX_TURRET_HEALTH: 完全愈合的炮塔的健康量.

  • DESTROYED_TIME: 被毁坏的炮塔修复自己所花费的时间(以秒为单位).

  • destroyed_timer: 一个变量, 用于跟踪炮塔被摧毁的时间.

  • bullet_scene: 炮塔射击的子弹场景(与游戏角色的手枪相同的场景)

哇, 这是相当多的类变量!


接下来我们来看看 _ready .

首先, 我们得到视觉区域, 将 body_enteredbody_exited 信号分别连接到 body_entered_visionbody_exited_vision .

然后, 我们得到所有的节点, 并将它们分配到各自的变量中.

接下来, 我们给 Raycast 添加一些例外情况, 这样炮塔就不能伤害自己了.

然后我们在开始时使两个闪存网格都不可见, 因为我们不会在 "_ready" 期间触发.

然后我们得到烟雾粒子节点, 并将其分配给 smoke_particles 变量. 将 emitting 设置为 false , 以确保炮塔在被破坏之前, 粒子不会发射.

最后, 我们将炮塔的生命值设置为 "MAX_TURRET_HEALTH", 以便从完全健康状态开始.


现在让我们来看看 _physics_process .

首先, 我们检查炮塔是否处于激活状态. 如果炮塔处于激活状态, 则处理射击代码.

接下来, 如果 flash_timer 大于0, 意味着flash网格是可见的, 我们要从 flash_timer 中删除delta. 如果 flash_timer 减去 delta 后变为0或更小, 要隐藏两个flash网格.

接下来, 我们检查炮塔是否有目标. 如果炮塔有目标, 让炮塔的头部看向它, 加上 PLAYER_HEIGHT , 这样它就不会瞄准玩家的脚.

然后我们检查炮塔的健康状况是否大于零, 如果是, 就检查炮塔里是否有弹药.

如果有, 我们检查 fire_timer 是否大于0, 如果大于, 则炮塔不能发射, 需要从 fire_timer 中删除 delta . 如果 fire_timer 小于或等于零, 炮塔可以发射子弹, 则调用 fire_bullet 函数.

如果炮塔内没有任何弹药, 我们检查 ammo_reload_timer 是否大于零, 如果大于零, 我们从 ammo_reload_timer 中减去 delta . 如果 ammo_reload_timer 小于或等于零, 我们将 ammo_in_turret 设置为 AMMO_IN_FULL_TURRET , 因为炮塔已经等待了足够长的时间来补充弹药.

接下来, 我们检查炮塔的健康值是否小于或等于 0 , 而不是它是否处于活动状态. 如果炮塔的健康值为0或更少, 我们检查 destroyed_timer 是否大于0. 如果是, 我们从 destroyed_timer 中减去 delta .

如果 destroyed_timer 小于或等于零, 我们将 turret_health 设置为 MAX_TURRET_HEALTH 并通过将 smoke_particles.emitting 设置为 false 来停止冒烟.


接下来让我们来看看 fire_bullet .

首先, 我们检查炮塔是否使用了射线投射.

使用射线投射的代码与 第2部分 中步枪的代码几乎完全相同, 所以我只简单介绍一下.

我们首先让raycast看向目标, 确保raycast在没有任何障碍物的情况下能够击中目标. 然后我们强制raycast更新, 这样我们就能得到一帧完美的碰撞检查. 然后, 检查raycast是否与任何东西发生了碰撞. 如果有, 我们检查被碰撞的物体是否有 bullet_hit 方法. 如果有, 我们就调用它, 并将单颗raycast子弹造成的伤害和raycast的变换一起传递进来. 然后我们从 ammo_in_turret 中减去 1 .

如果炮塔没有使用光线投射, 我们会生成一个子弹对象. 这段代码几乎完全与手枪中的代码相同 第2部分, 所以与光线播放代码一样, 我只是简单地介绍一下.

我们首先制作一个子弹克隆, 并将其分配给 clone , 然后我们将其添加为根节点的一个子节点. 我们将子弹的全局变换设置为枪管末端, 由于它太小, 所以将其放大, 并使用炮塔的常量类变量设置其伤害和速度. 然后从 ammo_in_turret 中减去 1 .

然后, 不管我们使用的是哪种子弹方式, 都要让两个枪口闪光网格可见. 我们将 flash_timerfire_timer 分别设置为 FLASH_TIMEFIRE_TIME . 然后检查炮塔是否已经用完了最后一颗子弹. 如果用完了, 将 ammo_reload_timer 设置为 ammo_reload_TIME , 这样炮塔就会重新装弹.


让我们看看接下来的 body_entered_vision , 谢天谢地, 它很短.

我们首先通过检查 current_target 是否等于 null 来检查炮塔当前是否有目标. 如果炮塔没有目标, 我们就检查刚刚进入 Area 视野的物体是一个 KinematicBody .

注解

我们假设炮塔应该只对 KinematicBody 节点进行射击, 因为玩家使用的就是这个节点.

如果刚进入视觉的主体 AreaKinematicBody, 我们将 current_target 设置为body, 并将 is_active 设置为 true .


现在让我们来看看 body_exited_vision .

首先, 我们检查炮塔是否有目标. 如果有, 检查刚刚离开炮塔的视野的物体 Area 是否为炮塔的目标.

如果刚刚离开视野 Area 的机体是炮塔的当前目标, 将 current_target 设置为 null , 将 is_active 设置为 false , 并重置所有与发射炮塔有关的变量, 因为炮塔已经没有目标可以发射.


最后, 让我们看一下 bullet_hit .

我们先从炮塔的健康状况中减去子弹造成的多少伤害.

然后, 我们检查炮塔是否被摧毁, 健康值为零或更少, 如果被摧毁, 就开始发射烟雾粒子, 并将 destroyed_timer 设置为 DESTROYED_TIME , 这样炮塔在修复前就必须等待.


好了, 所有这些都完成了, 在炮塔准备使用之前, 我们只有最后一件事要做. 打开 Turret.tscn 如果还没有打开的话, 从 BaseHead 中选择一个 StaticBody 节点. 创建一个名为 TurretBodies.gd 的新脚本, 并将其附加到你选择的任何一个 StaticBody 节点上.

将以下代码添加到 TurretBodies.gd:

extends StaticBody

export (NodePath) var path_to_turret_root

func _ready():
    pass

func bullet_hit(damage, bullet_hit_pos):
    if path_to_turret_root != null:
        get_node(path_to_turret_root).bullet_hit(damage, bullet_hit_pos)

这段代码所做的就是在 path_to_turret_root 所指向的任何节点上调用 bullet_hit . 回到编辑器, 将 NodePath 分配给 Turret 节点.

现在选择另一个 StaticBody 节点(在 BodyHead 中)并为其指定 TurretBodies.gd 脚本. 附加脚本后, 再次将 NodePath 分配给 Turret 节点.


最后, 我们需要做的是给玩家增加一种伤害的方式. 由于所有的子弹都使用 bullet_hit 函数, 我们需要为玩家添加该函数.

打开 Player.gd 并添加以下内容:

func bullet_hit(damage, bullet_hit_pos):
    health -= damage

完成所有这些后, 您应该拥有完全可操作的炮塔! 在一个/两个/所有场景中进行几次尝试!

最后的笔记

../../../_images/PartFiveFinished.png

现在您可以拿起 RigidBody 节点并投掷手榴弹. 我们现在也有炮塔射击游戏角色.

第6部分 中, 我们将添加一个主菜单和一个暂停菜单, 为游戏角色添加重新生成的系统, 以及更改/移动声音系统, 以便我们可以从任何脚本中使用它.

警告

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

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