VR 入门教程第 2 部分

前言

../../../../_images/starter_vr_tutorial_sword.png

在这部分VR入门系列教程中, 我们将增加一些特殊的, 基于 RigidBody 的节点来用于VR中.

这将继续我们在上一个教程部分的内容, 我们刚刚完成了VR控制器的工作, 并定义了一个名为 VR_Interactable_Rigidbody 的自定义类.

小技巧

你可以在 OpenVR GitHub 仓库上找到完成的项目。

添加可销毁的目标

在我们制作任何一个特殊的以 RigidBody 为基础的节点之前, 我们需要一些东西来让它们去执行. 让我们做一个简单的球体目标, 当它被摧毁时, 会破裂成一堆碎片.

打开 Sphere_Target.tscn , 它在 Scenes 文件夹中. 这个场景相当简单, 只有一个 StaticBody 和一个球体形状的 CollisionShape , 一个显示球体网格的 MeshInstance 节点, 以及一个 AudioStreamPlayer3D 节点.

特殊的 RigidBody 节点将处理对球体的损坏, 这就是为什么我们使用 StaticBody 节点, 而不是诸如 AreaRigidBody 一类的节点. 除此以外, 就没什么好说的了, 所以让我们直接进入写代码的阶段.

选择 Sphere_Target_Root 节点, 制作一个名为 Sphere_Target.gd 的新脚本. 添加以下代码:

GDScript

  1. extends Spatial
  2. var destroyed = false
  3. var destroyed_timer = 0
  4. const DESTROY_WAIT_TIME = 80
  5. var health = 80
  6. const RIGID_BODY_TARGET = preload("res://Assets/RigidBody_Sphere.scn")
  7. func _ready():
  8. set_physics_process(false)
  9. func _physics_process(delta):
  10. destroyed_timer += delta
  11. if destroyed_timer >= DESTROY_WAIT_TIME:
  12. queue_free()
  13. func damage(damage):
  14. if destroyed == true:
  15. return
  16. health -= damage
  17. if health <= 0:
  18. get_node("CollisionShape").disabled = true
  19. get_node("Shpere_Target").visible = false
  20. var clone = RIGID_BODY_TARGET.instance()
  21. add_child(clone)
  22. clone.global_transform = global_transform
  23. destroyed = true
  24. set_physics_process(true)
  25. get_node("AudioStreamPlayer").play()
  26. get_tree().root.get_node("Game").remove_sphere()

让我们来看看这个脚本是如何工作的.

解释Sphere Target代码

首先, 让我们浏览一下脚本中所有的类变量:

  • destroyed: 一个变量, 用于跟踪球体目标是否已被销毁.

  • destroyed_timer: 一个变量, 用于跟踪球体目标被摧毁的时间.

  • DESTROY_WAIT_TIME: 一个常数, 用于定义目标在释放/删除自己之前可以被销毁的时间长度.

  • health: 目标所具有的健康量.

  • RIGID_BODY_TARGET : 一个常数, 用于储存被摧毁的球体目标的场景.

备注

你可以随意查看 RIGID_BODY_TARGET 场景. 它只是一堆 RigidBody 节点和一个破碎的球体模型.

我们将实例化这个场景, 所以当目标被摧毁时, 它看起来就像碎成了一堆碎片.

_ready 函数的逐步说明

_ready 函数所做的就是通过调用 set_physics_process 并传递 false 来阻止 _physics_process 被调用. 我们这样做的原因是, _physics_process 中的所有代码都是为了在足够长的时间内销毁这个节点, 而我们只想在目标被销毁时进行销毁.

_physics_process 函数分步说明

首先, 这个函数将时间 delta 添加到 destroyed_timer 变量中. 然后检查 destroyed_timer 是否大于或等于 DESTROY_WAIT_TIME . 如果 destroyed_timer 大于或等于 DESTROY_WAIT_TIME , 那么球体目标将通过调用 queue_free 函数来释放/删除自己.

damage 函数的分步说明

damage 函数将被特殊的 RigidBody 节点调用, 它将传递对目标造成的伤害量, 这是一个名为 damage 的函数参数变量. damage 变量将保存特殊的 RigidBody 节点对球体目标造成的伤害量.

首先, 这个函数通过检查 destroyed 变量是否等于 true 来检查目标是否已经被销毁. 如果 destroyed 等于 true , 那么这个函数就会调用 return , 所以其他代码都不会被调用. 这只是一个安全检查, 如果两个东西同时损坏目标, 则目标不能被破坏两次.

接下来, 该函数从目标的健康状况 health 中删除受到的伤害量 damage . 然后检查 health 是否等于零或更少, 这意味着目标刚刚已被摧毁.

如果目标刚刚被摧毁,那么我们通过将 disabled 属性设置为 true 来禁用该 CollisionShape。然后将 Sphere_Target MeshInstancevisible 属性设置为 false,使其不可见。这样做是为了让目标不能再影响物理世界,所以不可破碎的目标网格是不可见的。

之后, 函数将实例化 RIGID_BODY_TARGET 场景, 并将其添加为目标的子场景. 然后, 它将新实例化的场景的 global_transform``(称为 ``clone)设置为未被破坏的目标的 global_transform. 这使得被破坏的目标与未被破坏的目标在相同的位置开始, 并具有相同的旋转和比例.

然后该函数将 destroyed 变量设置为 true, 这样目标就知道它已经被破坏了, 并调用 set_physics_process 函数并传递 true. 这将开始执行 _physics_process 中的代码, 这样在 DESTROY_WAIT_TIME 秒过后, 球体目标将释放/销毁自己.

然后这个函数获得 AudioStreamPlayer3D 节点并调用 play 函数, 这样就可以播放它的声音.

最后, 在 Game.gd 中调用 remove_sphere 函数. 为了得到 Game.gd, 代码使用场景树, 从场景树的根部到 Game.tscn 场景的根部.

remove_sphere 函数添加到 Game.gd

你可能已经注意到我们在 Game.gd 中调用了一个函数, 叫做 remove_sphere , 而我们还没有定义. 打开 Game.gd , 添加以下其他的类变量:

GDScript

  1. var spheres_left = 10
  2. var sphere_ui = null
  • spheres_left. 游戏世界里剩余的球体目标数量. 在提供的 游戏 场景中, 有 10 个球体, 所以这是初始值.

  • sphere_ui: 对球体UI的引用. 我们之后会在教程中用到这个, 来显示世界中剩余球体的数量.

有了这些变量的定义, 我们现在可以添加 remove_sphere 函数. 在 Game.gd 中添加以下代码:

GDScript

  1. func remove_sphere():
  2. spheres_left -= 1
  3. if sphere_ui != null:
  4. sphere_ui.update_ui(spheres_left)

让我们快速浏览一下这个函数的作用:

首先, 它从 spheres_left 变量中删除一个. 然后检查 sphere_ui 变量是否不等于 null , 如果不等于 null , 则调用 sphere_uiupdate_ui 函数, 将球体的数量作为参数传给该函数.

备注

我们将在后面的教程中添加 sphere_ui 的代码!

现在 Sphere_Target 已经可以使用了, 但是我们没有办法破坏它. 让我们通过添加一些特殊的基于 RigidBody 的节点来解决这个问题, 这些节点可以破坏目标.

加一把手枪

让我们添加一把手枪作为第一个可交互的 RigidBody 节点. 在 Scenes 文件夹中找到并打开 Pistol.tscn .

在添加代码之前, 我们先来快速了解一下 Pistol.tscn 中的一些注意事项.

Pistol.tscn 中的所有节点(除了根节点)都是旋转的. 这是为了让手枪在拿起时相对于VR控制器处于正确的旋转状态. 根节点是一个 RigidBody 节点, 我们需要这个节点, 因为我们将使用我们在本系列教程最后一部分创建的 VR_Interactable_Rigidbody 类.

有一个 MeshInstance 节点叫做 Pistol_Flash, 这是一个简单的网格, 我们将用它来模拟手枪枪管末端的枪口闪光. 一个名为 LaserSightMeshInstance 节点用来作为手枪瞄准的指南, 它遵循 Raycast 节点的方向, 名为 Raycast, 手枪用来检测它的 ‘子弹’ 是否击中了什么东西. 最后, 在手枪的尾部有一个 AudioStreamPlayer3D 节点, 我们将用它来播放手枪射击的声音.

如果你想的话, 可以随意看看场景的其他部分. 场景的大部分是相当简单的, 只有上面提到的主要变化. 选择 RigidBody 节点, 称为 Pistol , 并制作一个新的脚本, 称为 Pistol.gd . 添加以下代码:

GDScript

  1. extends VR_Interactable_Rigidbody
  2. var flash_mesh
  3. const FLASH_TIME = 0.25
  4. var flash_timer = 0
  5. var laser_sight_mesh
  6. var pistol_fire_sound
  7. var raycast
  8. const BULLET_DAMAGE = 20
  9. const COLLISION_FORCE = 1.5
  10. func _ready():
  11. flash_mesh = get_node("Pistol_Flash")
  12. flash_mesh.visible = false
  13. laser_sight_mesh = get_node("LaserSight")
  14. laser_sight_mesh.visible = false
  15. raycast = get_node("RayCast")
  16. pistol_fire_sound = get_node("AudioStreamPlayer3D")
  17. func _physics_process(delta):
  18. if flash_timer > 0:
  19. flash_timer -= delta
  20. if flash_timer <= 0:
  21. flash_mesh.visible = false
  22. func interact():
  23. if flash_timer <= 0:
  24. flash_timer = FLASH_TIME
  25. flash_mesh.visible = true
  26. raycast.force_raycast_update()
  27. if raycast.is_colliding():
  28. var body = raycast.get_collider()
  29. var direction_vector = raycast.global_transform.basis.z.normalized()
  30. var raycast_distance = raycast.global_transform.origin.distance_to(raycast.get_collision_point())
  31. if body.has_method("damage"):
  32. body.damage(BULLET_DAMAGE)
  33. elif body is RigidBody:
  34. var collision_force = (COLLISION_FORCE / raycast_distance) * body.mass
  35. body.apply_impulse((raycast.global_transform.origin - body.global_transform.origin).normalized(), direction_vector * collision_force)
  36. pistol_fire_sound.play()
  37. if controller != null:
  38. controller.rumble = 0.25
  39. func picked_up():
  40. laser_sight_mesh.visible = true
  41. func dropped():
  42. laser_sight_mesh.visible = false

让我们来看看这个脚本是如何工作的.

解释手枪代码

首先, 请注意, 我们有 extends RigidBody, 而不是 extends VR_Interactable_Rigidbody. 这使得手枪脚本在哪里扩展了 VR_Interactable_Rigidbody 类, 这样VR控制器就知道这个对象可以被交互, 当这个对象被VR控制器持有时, VR_Interactable_Rigidbody 中定义的函数可以被调用.

接下来, 我们来看看类变量:

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

  • FLASH_TIME : 一个常数, 用于定义枪口闪光的可见时间. 这也将定义手枪的射击速度.

  • flash_timer: 一个变量, 用于保存枪口闪光的可见时间.

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

  • pistol_fire_sound: 一个变量, 用来保存用于手枪射击声音的 AudioStreamPlayer3D 节点.

  • raycast: 一个变量, 用来保存 Raycast 节点, 用于计算手枪发射时子弹的坐标和法线.

  • BULLET_DAMAGE: 一个常数, 用来定义手枪的一颗子弹的伤害量.

  • COLLISION_FORCE : 一个常数, 用于定义手枪子弹碰撞时对 RigidBody 节点施加的力.

_ready 函数的逐步说明

该函数获取节点并将其分配给适当的变量. 对于 flash_meshlaser_sight_mesh 节点, 它们的 visible 属性都设置为 false , 所以它们最初是不可见的.

_physics_process 函数分步说明

_physics_process 函数首先检查手枪的枪口闪光是否可见——检查 flash_timer 是否大于零. 如果 flash_timer 大于零, 那么我们就从中去掉时间 delta . 接下来, 我们检查 flash_timer 变量是否为零或更小, 因为我们已经从中减去了 delta . 如果是的话, 那么这说明手枪枪口闪光计时器刚刚结束, 所以我们需要将 flash_meshvisible 属性设置为 false , 使 flash_mesh 不可见.

interact 函数的分步说明

interact函数首先通过检查 flash_timer 是否小于或等于0来检查手枪的枪口闪光是否隐形. 我们这样做是为了将手枪的射击速度限制在枪口闪光可见的时间长度内, 这是限制玩家射击速度的一个简单解决方案.

如果 flash_timer 为0或更少, 我们就把 flash_timer 设置为 FLASH_TIME , 这样在手枪再次射击之前就会有一个延迟. 之后我们将 flash_mesh.visible 设置为 true , 这样在 flash_timer 大于零的时候, 就可以确保手枪尾部的枪口闪光是可见的.

接下来, 我们在 raycast 中的 Raycast 节点上调用 force_raycast_update 函数, 这样它就能从物理世界获得最新的碰撞信息. 然后我们通过检查 is_colliding 函数是否等于 true 来检查 raycast 是否撞到了什么东西.


如果 raycast 撞到了什么东西, 那么我们通过 get_collider 函数得到与它碰撞的 PhysicsBody . 我们把撞到的 PhysicsBody 分配给一个叫 body 的变量.

然后我们通过 raycast 节点 global_transformBasisZ 轴正方向得到该 Raycast 的方向。这将为我们提供射线 Z 轴的指向,这与在 Godot 编辑器中启用局部空间模式Spatial 小工具上的蓝色箭头的方向相同。我们将这个方向存储在一个名为 direction_vector 的变量中。

接下来我们要获取从 Raycast 原点到 Raycast 碰撞点的距离。使用 distance_to 函数计算 raycast 节点的全局位置 global_transform.originRaycast 的碰撞点 raycast.get_collision_point 的距离。这样得到的是 Raycast 在发生碰撞前所经过的距离,我们把它存储在一个名叫 raycast_distance 的变量里。

然后代码检查 PhysicsBody , body , 是否有一个叫做 damage 的函数/方法, 使用 has_method 函数. 如果 PhysicsBody 有一个叫做 damage 的函数/方法, 那么我们就调用 damage 函数, 并传递 BULLET_DAMAGE , 这样它就会受到子弹碰撞到它的伤害.

不管 PhysicsBody 是否有 damage 函数, 我们都要检查 body 是否是基于 RigidBody 的节点. 如果 body 是一个以 RigidBody 为基础的节点, 那么我们要在子弹碰撞时推它.

为了计算所施加的力的大小, 我们只需用 COLLISION_FORCE 除以 raycast_distance, 然后再乘以 body.mass. 我们把这个计算结果存储在一个叫做 collision_force 的变量中. 这将使短距离的碰撞比长距离的碰撞更容易受力, 从而使碰撞的反应 真实.

然后我们用 apply_impulse 函数推送 RigidBody, 其中位置是一个零向量的三维数组, 所以力是从中心开始施加的, 碰撞力是我们计算出来的 collision_force 变量.


不管 raycast 变量是否击中了什么东西, 我们都要通过调用 pistol_fire_sound 变量上的 play 函数来播放手枪的射击声.

最后, 我们通过检查 controller 变量是否不等于 null 来检查手枪是否被VR控制器所持有. 如果不等于 null , 我们就把VR控制器的 rumble 属性设置为 0.25 , 这样手枪射击时就会有轻微的响声.

picked_up 函数的分步说明

这个函数只是通过设置 visible 属性为 true , 使 laser_sight_mesh MeshInstance 可见.

dropped 函数的逐步说明

这个函数只是通过设置 visible 属性为 false , 使 laser_sight_mesh MeshInstance 不可见.

手枪完毕

../../../../_images/starter_vr_tutorial_pistol.png

这就是我们需要做的所有工作 手枪在项目中!继续运行项目吧. 如果你爬上楼梯, 拿起手枪, 你就可以用VR控制器上的扳机按钮向场景中的球体目标射击!如果你向目标射击的时间足够长, 它们就会碎裂成碎片.

添加霰弹枪

接下来让我们往VR项目中添加一杆霰弹枪.

添加一个特殊的霰弹枪 RigidBody 应该是相当简单,因为霰弹枪的一切几乎都和手枪一样。

Scenes 文件夹中找到并打开 Shotgun.tscn , 查看这个场景. 几乎所有的东西都和 Pistol.tscn 一样. 除了名称上的变化, 唯一不同的是, 没有一个 Raycast , 而是有五个 Raycast 节点. 这是因为霰弹枪一般是以锥形发射的, 所以我们要通过几个 Raycast 节点来模仿这种效果, 当霰弹枪发射时, 这些节点会以锥形随机旋转.

除此之外, 一切都和 Pistol.tscn 差不多.

让我们来编写霰弹枪的代码. 选择 RigidBody 节点, 名为 Shotgun , 并创建一个名为 Shotgun.gd 的新脚本. 添加以下代码:

GDScript

  1. extends VR_Interactable_Rigidbody
  2. var flash_mesh
  3. const FLASH_TIME = 0.25
  4. var flash_timer = 0
  5. var laser_sight_mesh
  6. var shotgun_fire_sound
  7. var raycasts
  8. const BULLET_DAMAGE = 30
  9. const COLLISION_FORCE = 4
  10. func _ready():
  11. flash_mesh = get_node("Shotgun_Flash")
  12. flash_mesh.visible = false
  13. laser_sight_mesh = get_node("LaserSight")
  14. laser_sight_mesh.visible = false
  15. raycasts = get_node("Raycasts")
  16. shotgun_fire_sound = get_node("AudioStreamPlayer3D")
  17. func _physics_process(delta):
  18. if flash_timer > 0:
  19. flash_timer -= delta
  20. if flash_timer <= 0:
  21. flash_mesh.visible = false
  22. func interact():
  23. if flash_timer <= 0:
  24. flash_timer = FLASH_TIME
  25. flash_mesh.visible = true
  26. for raycast in raycasts.get_children():
  27. if not raycast is RayCast:
  28. continue
  29. raycast.rotation_degrees = Vector3(90 + rand_range(10, -10), 0, rand_range(10, -10))
  30. raycast.force_raycast_update()
  31. if raycast.is_colliding():
  32. var body = raycast.get_collider()
  33. var direction_vector = raycasts.global_transform.basis.z.normalized()
  34. var raycast_distance = raycasts.global_transform.origin.distance_to(raycast.get_collision_point())
  35. if body.has_method("damage"):
  36. body.damage(BULLET_DAMAGE)
  37. if body is RigidBody:
  38. var collision_force = (COLLISION_FORCE / raycast_distance) * body.mass
  39. body.apply_impulse((raycast.global_transform.origin - body.global_transform.origin).normalized(), direction_vector * collision_force)
  40. shotgun_fire_sound.play()
  41. if controller != null:
  42. controller.rumble = 0.25
  43. func picked_up():
  44. laser_sight_mesh.visible = true
  45. func dropped():
  46. laser_sight_mesh.visible = false

这段代码的大部分内容与手枪的代码完全相同, 只有一些 的改动, 主要是名称不同而已. 由于这些脚本的相似度很高, 我们只关注一下这些变化.

解释霰弹枪代码

和手枪一样, 霰弹枪也扩展了 VR_Interactable_Rigidbody , 所以VR控制器知道这个对象可以与之交互, 以及有哪些功能.

只有一个新的类变量:

  • raycasts: 一个变量, 用来保存拥有所有 Raycast 子节点的节点.

新的类变量取代了 Pistol.gd 中的 raycast 变量, 因为对于霰弹枪, 我们需要处理多个 Raycast 节点, 而不是只有一个. 所有其他类变量与 Pistol.gd 相同, 功能也相同, 只是有些变量被重新命名为非手枪专用变量.

interact 函数的分步说明

interact函数首先通过检查 flash_timer 是否小于或等于0来检查霰弹枪的枪口闪光是否隐形. 我们这样做是为了将霰弹枪的射击速度限制在枪口闪光可见的时间长度内, 这是限制玩家射击速度的一个简单解决方案.

如果 flash_timer 为0或更少, 我们再将 flash_timer 设置为 FLASH_TIME , 这样在霰弹枪再次开火之前就会有一个延迟. 之后我们将 flash_mesh.visible 设置为 true , 这样在 flash_timer 大于零的时候, 霰弹枪尾部的枪口闪光将是可见的.

接下来, 我们在 raycast 中的 Raycast 节点上调用 force_raycast_update 函数, 这样它就能从物理世界获得最新的碰撞信息. 然后我们通过检查 is_colliding 函数是否等于 true 来检查 raycast 是否撞到了什么东西.

接下来我们使用for循环来检查 raycasts 变量的每个子节点. 这样代码就会遍历 Raycast 的每一个节点, 这些节点都是 raycasts 变量的子节点.


对于每个节点, 我们检查 raycast 是否 不是一个 Raycast 节点. 如果这个节点不是 Raycast 节点, 我们就简单地使用 continue 跳过它.

接下来, 我们通过设置 raycastrotation_degrees 变量为Vector3, 其中X轴和Z轴为 -1010 的随机数, 将 raycast 节点围绕一个小于 10 度锥体随机旋转. 这个随机数是用 rand_range 函数选择的.

然后我们在 raycast 中的 Raycast 节点上调用 force_raycast_update 函数, 这样它就能从物理世界获得最新的碰撞信息. 然后我们通过检查 is_colliding 函数是否等于 true 来检查 raycast 是否撞到了什么东西.

其余的代码完全相同, 但这个过程对每个 raycast 节点进行重复, 该节点是 raycasts 变量的一个子节点.


如果 raycast 撞到了什么东西, 那么我们通过 get_collider 函数得到与它碰撞的 PhysicsBody . 我们把撞到的 PhysicsBody 分配给一个叫 body 的变量.

然后, 我们从 raycast 节点的 global_transform 上的 Basis 得到射线广播的正 Z 方向轴, 这提供raycast在Z轴上的指向, 与在Godot编辑器中启用 Local space mode [局部空间模式] 时 Spatial 小工具上的蓝色箭头的方向相同. 我们将这个方向存储在一个名为 direction_vector 的变量中.

接下来, 我们通过使用 distance_to 函数获取 raycast 节点的全局位置 global_transform.origin 到raycast的碰撞点 raycast.get_collision_point 的距离, 得到从raycast原点到raycast碰撞点的距离. 这提供了 Raycast 在碰撞前走过的距离, 将其存储在一个名为 raycast_distance 的变量中.

然后代码检查 PhysicsBody , body , 是否有一个叫做 damage 的函数/方法, 使用 has_method 函数. 如果 PhysicsBody 有一个叫做 damage 的函数/方法, 那么我们就调用 damage 函数, 并传递 BULLET_DAMAGE , 这样它就会受到子弹碰撞到它的伤害.

不管 PhysicsBody 是否有 damage 函数, 我们都要检查 body 是否是基于 RigidBody 的节点. 如果 body 是一个以 RigidBody 为基础的节点, 那么我们要在子弹碰撞时推它.

为了计算所施加的力的大小, 我们只需用 COLLISION_FORCE 除以 raycast_distance, 然后再乘以 body.mass. 我们把这个计算结果存储在一个叫做 collision_force 的变量中. 这将使短距离的碰撞比长距离的碰撞更容易受力, 从而使碰撞的反应 真实.

然后我们用 apply_impulse 函数推送 RigidBody, 其中位置是一个零向量的三维数组, 所以力是从中心开始施加的, 碰撞力是我们计算出来的 collision_force 变量.


一旦所有的 Raycast 中的 raycast 变量被迭代过后,通过调用 playshotgun_fire_sound 变量来播放枪声。

最后, 我们通过检查 controller 变量是否不等于 null 来检查霰弹枪是否由VR控制器持有. 如果不等于 null , 我们就把VR控制器的 rumble 属性设置为 0.25 , 这样猎枪射击时就会有轻微的轰鸣声.

霰弹枪完毕

其他的一切都和手枪完全一样, 最多只是一些简单的名称变化.

现在,霰弹枪就已经完成了!你可以在样本场景中找到这把霰弹枪,在其中一面墙的后面寻找(不是在建筑物里面!)。

添加炸弹

好吧, 让我们添加一个不同的特殊 RigidBody . 与其添加一些会射击的东西, 不如添加一些我们可以投掷的东西—炸弹!

打开 Bomb.tscn,它位于 Scenes 文件夹中。

根节点是一个 RigidBody 节点,我们将扩展使用 VR_Interactable_Rigidbody,它有一个 CollisionShape,就像我们到目前为止所做的其他特殊 RigidBody 节点一样。同样,有一个 MeshInstance 叫做 Bomb,用来显示炸弹的网格。

然后我们有一个 Area 节点,简单地称为 Area,它有一个大的 CollisionShape 作为它的子节点。当炸弹爆炸时,将使用这个 Area 节点来影响其中的任何东西。基本上,这个 Area 节点将是炸弹的爆炸半径。

还有几个 Particles 节点, 其中一个 Particles 节点是炸弹引信中冒出的烟雾, 而另一个是爆炸. 如果你想看的话, 可以看一下 ParticlesMaterial 资源, 它定义了粒子工作方式. 不会在本教程中介绍粒子如何工作, 因为它不在本教程的范围内.

对于 Particles 节点, 有一件事我们需要注意. 如果你选择了 Explosion_Particles 节点, 你会发现它的 lifetime 属性被设置为 0.75 , 而且 one shot 复选框被启用. 这意味着粒子将只播放一次, 并且粒子将持续 0.75 秒. 我们需要知道这一点, 这样我们就可以在爆炸结束时移除炸弹 Particles.

让我们来编写炸弹的代码. 选择 Bomb RigidBody 节点, 制作一个新的脚本, 名为 Bomb.gd , 添加以下代码:

GDScript

  1. extends VR_Interactable_Rigidbody
  2. var bomb_mesh
  3. const FUSE_TIME = 4
  4. var fuse_timer = 0
  5. var explosion_area
  6. const EXPLOSION_DAMAGE = 100
  7. const EXPLOSION_TIME = 0.75
  8. var explosion_timer = 0
  9. var exploded = false
  10. const COLLISION_FORCE = 8
  11. var fuse_particles
  12. var explosion_particles
  13. var explosion_sound
  14. func _ready():
  15. bomb_mesh = get_node("Bomb")
  16. explosion_area = get_node("Area")
  17. fuse_particles = get_node("Fuse_Particles")
  18. explosion_particles = get_node("Explosion_Particles")
  19. explosion_sound = get_node("AudioStreamPlayer3D")
  20. set_physics_process(false)
  21. func _physics_process(delta):
  22. if fuse_timer < FUSE_TIME:
  23. fuse_timer += delta
  24. if fuse_timer >= FUSE_TIME:
  25. fuse_particles.emitting = false
  26. explosion_particles.one_shot = true
  27. explosion_particles.emitting = true
  28. bomb_mesh.visible = false
  29. collision_layer = 0
  30. collision_mask = 0
  31. mode = RigidBody.MODE_STATIC
  32. for body in explosion_area.get_overlapping_bodies():
  33. if body == self:
  34. pass
  35. else:
  36. if body.has_method("damage"):
  37. body.damage(EXPLOSION_DAMAGE)
  38. if body is RigidBody:
  39. var direction_vector = body.global_transform.origin - global_transform.origin
  40. var bomb_distance = direction_vector.length()
  41. var collision_force = (COLLISION_FORCE / bomb_distance) * body.mass
  42. body.apply_impulse(Vector3.ZERO, direction_vector.normalized() * collision_force)
  43. exploded = true
  44. explosion_sound.play()
  45. if exploded:
  46. explosion_timer += delta
  47. if explosion_timer >= EXPLOSION_TIME:
  48. explosion_area.monitoring = false
  49. if controller != null:
  50. controller.held_object = null
  51. controller.hand_mesh.visible = true
  52. if controller.grab_mode == "RAYCAST":
  53. controller.grab_raycast.visible = true
  54. queue_free()
  55. func interact():
  56. set_physics_process(true)
  57. fuse_particles.emitting = true

让我们来看看这个脚本是如何工作的.

解释炸弹代码

和其他特殊的 RigidBody 节点一样, 炸弹也扩展了 VR_Interactable_Rigidbody , 这样VR控制器就知道这个对象可以被交互, 当这个对象被VR控制器持有时, VR_Interactable_Rigidbody 中定义的函数可以被调用.

接下来, 我们来看看类变量:

  • bomb_mesh: 一个变量, 用来保存 MeshInstance 节点, 用于不爆炸的炸弹.

  • FUSE_TIME : 一个常数, 用于定义炸弹爆炸前引信将 ‘燃烧’ 多长时间

  • fuse_timer: 一个变量, 用于保存炸弹的引信开始燃烧后的时间长度.

  • explosion_area: 一个变量, 用来保存 Area 节点, 用于检测炸弹爆炸范围内的物体.

  • EXPLOSION_DAMAGE . 一个常数, 用于定义炸弹爆炸时的伤害程度.

  • EXPLOSION_TIME: 一个常数, 用于定义炸弹爆炸后在场景中持续时长, 该值应与爆炸的 lifetime 属性相同 Particles 节点.

  • explosion_timer 一个变量, 用于保存炸弹爆炸后的时间长度.

  • exploded . 一个变量, 用于保存炸弹是否爆炸.

  • COLLISION_FORCE: 一个常数, 定义炸弹爆炸时施加在 RigidBody 节点上的力的大小.

  • fuse_particles: 一个变量, 用于保存炸弹引信的 Particles 节点的引用.

  • explosion_particles: 一个变量, 用于保存对炸弹爆炸所用的 Particles 节点的引用.

  • explosion_sound: 一个变量, 用来保存对用于爆炸声的 AudioStreamPlayer3D 节点的引用.

_ready 函数的逐步说明

_ready 函数首先从炸弹场景中获取所有的节点, 并将它们分配给各自的类变量, 以便以后使用.

然后我们调用 set_physics_process 并传递 false , 这样 _physics_process 就不会被执行. 这样做是因为 _physics_process 中的代码会开始燃烧导火索并爆炸炸弹, 而我们只想在用户与炸弹交互时这样做. 如果我们不禁用 _physics_process , 炸弹的引信会在用户有机会接近炸弹之前就开始燃烧.

_physics_process 函数分步说明

_physics_process 函数首先检查 fuse_timer 是否小于 FUSE_TIME . 如果是, 则说明炸弹的引信仍在燃烧.

如果炸弹的引信仍在燃烧, 我们就在 fuse_timer 变量中加入时间 delta , 然后检查 fuse_timer 是否大于或等于 FUSE_TIME . 如果 fuse_timer 大于或等于 FUSE_TIME , 那么引信刚刚完成, 我们需要引爆炸弹.

为了使炸弹爆炸, 我们首先在 fuse_particles 上将 emitting 设置为 false , 停止为引信发射粒子. 然后告诉爆炸 Particles 节点 explosion_particles , 通过设置 one_shottrue , 让它一次发射所有的粒子. 之后, 在 explosion_particles 上设置 emittingtrue , 这样看起来就像炸弹已经爆炸. 为了让炸弹看起来像爆炸, 通过设置 bomb_mesh.visiblefalse 来隐藏炸弹 MeshInstance 节点.

为了防止炸弹与物理世界中的其他物体发生碰撞, 将炸弹的 collision_layercollision_mask 属性设置为 0 . 将 RigidBody 模式改为 MODE_STATIC , 这样炸弹 RigidBody 就不会移动.

然后我们需要获取 explosion_area 节点内的所有 PhysicsBody 节点。要做到这一点,在 for 循环中使用 get_overlapping_bodiesget_overlapping_bodies 函数将返回 Area 节点内 PhysicsBody 节点的数组,这正是我们要的。


对于每个 PhysicsBody 节点(我们将其存储在一个名为 body 的变量中), 我们检查它是否等于 self . 这样做是为了使炸弹不会意外地自己爆炸, 因为 explosion_area [爆炸区]可能会检测到 Bomb [炸弹] RigidBody 是爆炸区的一个物理体.

如果 PhysicsBody 节点 body 不是炸弹, 那么首先检查 PhysicsBody 节点是否有一个叫做 damage 的函数. 如果 PhysicsBody 节点有一个叫做 damage 的函数, 就调用它, 并把 EXPLOSION_DAMAGE 传给它, 使它受到爆炸的伤害.

接下来我们检查一下 PhysicsBody 节点是否是 RigidBody . 如果 body 是一个 RigidBody, 要在炸弹爆炸时移动它.

要在炸弹爆炸时移动 RigidBody 节点, 首先需要计算从炸弹到 RigidBody 节点的方向. 为此, 从 RigidBody 的全局位置中减去炸弹的全局位置 global_transform.origin . 这将给出一个 Vector3, 从炸弹指向 RigidBody 节点. 将这个 Vector3 存储在一个名为 direction_vector 的变量中.

然后, 使用 rediction_vector 上的 length 函数计算 RigidBody 离炸弹的距离. 将距离存储在一个名为 bomb_distance 的变量中.

然后, 计算炸弹爆炸时炸弹对 RigidBody 节点的作用力, 方法是将 COLLISION_FORCE 除以 bomb_distance , 再乘以 collision_force . 这样, 如果 RigidBody 节点离炸弹更近, 它就会被推得更远.

最后, 用 apply_impulse 函数推动 RigidBody 节点, Vector3 位置为零, collision_force 乘以 direction_vector.normalized 作为力. 这样, 当炸弹爆炸时, 就会把 RigidBody 节点炸飞.


当循环浏览了 explosion_area 内的所有 PhysicsBody 节点后, 将 exploded 变量设置为 true , 这样代码就知道炸弹爆炸了, 并调用 explosion_sound 上的 play , 播放爆炸声.


好了, 下一部分代码开始, 首先检查 exploded 是否等于 true .

如果 exploded 等于 true , 那么这意味着炸弹在释放或销毁自己之前, 正在等待爆炸粒子完成. 在 explosion_timer 中加入时间 delta , 这样就可以跟踪炸弹爆炸后的时间.

如果 explosion_timer 在与 delta 相加后, 大于或等于 EXPLOSION_TIME , 则爆炸定时器刚刚完成.

如果爆炸计时器刚刚结束, 将 explosion_area.monitoring 设置为 false . 这样做的原因是有一个bug, 当 monitoring 属性为真时, 当你释放或删除一个 Area 节点时, 会打印一个错误. 为了确保这种情况不会发生, 只需在 explosion_area 上将 monitoring 设置为false.

接下来检查炸弹是否被VR控制器持有, 检查 controller 变量是否不等于 null . 如果炸弹被VR控制器持有, 就把VR控制器的 controller 属性 held_object 设置为 null . 因为VR控制器不再持有任何东西, 所以将 controller.hand_mesh.visible 设置为 true, 使VR控制器的手部网格可见. 然后检查VR控制器的抓取模式是否是 RAYCAST , 如果是, 将 controller.grab_raycast.visible 设置为 true , 这样抓取raycast的 ‘激光瞄准器’ 就可见了.

最后, 不管炸弹是否被VR控制器持有, 调用 queue_free , 这样炸弹场景就会被释放或从场景中移除.

interact 函数的分步说明

首先, interact 函数调用 set_physics_process 并传递 true , 这样 _physics_process 中的代码就开始执行. 这将启动炸弹的引信, 最终导致炸弹爆炸.

最后, 通过将 fuse_particles.visible 设置为 true 来启动引信粒子.

炸弹完毕

现在炸弹已经准备好了!你可以在橙色建筑中找到炸弹.

由于我们在计算VR控制器的速度时, 最容易使用类似推力的动作来投掷炸弹, 而不是更自然的类似投掷的动作. 抛掷式运动的平滑曲线很难被我们用来计算VR控制器的速度的代码所追踪, 所以它并不总是正确的, 并可能导致不准确的计算速度.

加一把剑

让我们添加最后一个特殊的 RigidBody —— 能够破坏目标的基础节点. 让我们添加一把剑来砍穿目标!

打开 Sword.tscn , 你可以在 Scenes 文件夹中找到它.

这里并没有发生很多事情. 所有根 Sword 的子节点 RigidBody 节点都被旋转, 当VR控制器拾取它们时, 位置是正确的, 有一个 MeshInstance 节点用于显示剑, 还有一个 AudioStreamPlayer3D 节点用于保存剑与某物碰撞时的声音.

但有一点略有不同. 有一个 KinematicBody 节点叫做 Damage_Body . 如果你看看它, 你会发现它不在任何碰撞层上, 而只在一个碰撞掩码上. 这是为了让 KinematicBody 不会影响场景中的其他 PhysicsBody 节点, 但它仍然会被 PhysicsBody 节点影响.

使用 Damage_Body KinematicBody 节点来检测剑与场景中的东西碰撞时的碰撞点和法线.

小技巧

虽然从性能的角度来看, 这可能不是获得碰撞信息的最佳方式, 但它确实给了我们很多信息, 可以用来进行后期处理!使用 KinematicBody 这种方式意味着我们可以准确检测到剑与其他 PhysicsBody 节点碰撞的位置.

这确实是剑的场景中唯一值得注意的地方. 选择 Sword RigidBody 节点, 并制作一个名为 Sword.gd 的新脚本. 添加以下代码:

GDScript

  1. extends VR_Interactable_Rigidbody
  2. const SWORD_DAMAGE = 2
  3. const COLLISION_FORCE = 0.15
  4. var damage_body = null
  5. func _ready():
  6. damage_body = get_node("Damage_Body")
  7. damage_body.add_collision_exception_with(self)
  8. sword_noise = get_node("AudioStreamPlayer3D")
  9. func _physics_process(_delta):
  10. var collision_results = damage_body.move_and_collide(Vector3.ZERO, true, true, true);
  11. if (collision_results != null):
  12. if collision_results.collider.has_method("damage"):
  13. collision_results.collider.damage(SWORD_DAMAGE)
  14. if collision_results.collider is RigidBody:
  15. if controller == null:
  16. collision_results.collider.apply_impulse(
  17. collision_results.position,
  18. collision_results.normal * linear_velocity * COLLISION_FORCE)
  19. else:
  20. collision_results.collider.apply_impulse(
  21. collision_results.position,
  22. collision_results.normal * controller.controller_velocity * COLLISION_FORCE)
  23. sword_noise.play()

让我们回顾一下这个脚本是如何运作的!

解释剑代码

和其他特殊的 RigidBody 节点一样, 剑扩展了 VR_Interactable_Rigidbody , 这样VR控制器就知道这个对象可以被交互, 当这个对象被VR控制器持有时, VR_Interactable_Rigidbody 中定义的函数可以被调用.

接下来, 我们来看看类变量:

  • SWORD_DAMAGE :定义剑的伤害量的常数。每次 _physics_process 调用中,被剑碰到的对象就都会受到对应的伤害

  • COLLISION_FORCE : 一个常数, 定义当剑与 RigidBody 节点相撞时, 施加在 PhysicsBody 节点上的力的大小.

  • damage_body : 一个变量, 用于存放 KinematicBody 节点, 用于检测剑是否刺中了 PhysicsBody 节点.

  • sword_noise : 一个变量, 用来存放 AudioStreamPlayer3D 节点, 当剑与某物碰撞时, 用来播放声音.

_ready 函数的逐步说明

我们在 _ready 函数中所做的就是获取 Damage_Body KinematicBody 节点, 并将其分配给 damage_body . 因为我们不想让剑检测到与剑的根部 RigidBody 节点的碰撞, 所以我们在 damage_body 上调用 add_collision_exception_with , 并传递 self , 这样剑的根部就不会被检测到.

最后, 我们获得 AudioStreamPlayer3D 节点的剑碰撞声, 并将其应用于 sword_noise 变量.

_physics_process 函数分步说明

首先, 我们需要确定剑是否与某物相撞. 为此, 使用 damage_body 节点的 move_and_collide 函数. 与通常使用 move_and_collide 不同的是, 没有传递速度, 而是传递一个空的 Vector3. 因为不想让 damage_body 节点移动, 所以将 test_only 参数(第四个参数)设置为 true , 这样 KinematicBody 就会生成碰撞信息, 而不会在碰撞世界中造成任何碰撞.

move_and_collide 函数将返回一个 KinematicCollision 类, 它有我们检测剑上碰撞所需的所有信息. 我们将 move_and_collide 的返回值分配给一个叫做 collision_results 的变量.

接下来我们检查 collision_results 是否不等于 null . 如果 collision_results 不等于 null , 那么我们就知道这把剑与某物相撞了.

然后, 使用 has_method 函数检查与剑相撞的 PhysicsBody 是否有一个叫做 damage 的函数或方法. 如果 PhysicsBody 有一个叫做 damage_body 的函数, 就调用它, 并把剑的伤害量 SWORD_DAMAGE 传递给它.

接下来检查剑碰撞的 PhysicsBody 是否是一个 RigidBody. 如果剑碰撞的是一个 RigidBody 节点, 再通过检查 controller 是否等于 null 来查看剑是否被VR控制器所持有.

如果VR控制器没有握住剑, controller 等于 null , 那么就使用 apply_impulse 函数移动剑碰撞的 RigidBody 节点. 对于 apply_impulse 函数中的 position , 使用 collision_resultsKinematicCollision 类中存储的 collision_position 变量. 对于 apply_impulse 函数中的 velocity , 使用 collision_normal 乘以剑的 RigidBody 节点的 linear_velocity 乘以 COLLISION_FORCE .

如果剑被VR控制器握着, controller 不等于 null , 那么就使用 apply_impulse 函数移动剑碰撞的 RigidBody 节点. 对于 apply_impulse 函数中的 position , 使用 collision_resultsKinematicCollision 类中存储的 collision_position 变量. 对于 apply_impulse 函数的 velocity , 使用 collision_normal 乘以VR控制器的速度乘以 COLLISION_FORCE .

最后, 不管 PhysicsBody 是不是 RigidBody, 通过调用 sword_noise 上的 play 来播放剑与物品碰撞的声音.

剑完毕

../../../../_images/starter_vr_tutorial_sword.png

完成后, 您现在可以切入目标了! 您可以在霰弹枪和手枪之间的角落找到剑.

更新目标 UI

让我们在球体目标被摧毁时更新用户界面.

打开 Main_VR_GUI.tscn , 可以在 Scenes 文件夹中找到它. 如果想了解场景是如何设置的, 但为了不让本教程变得太长, 不在本教程中介绍.

展开 GUI Viewport 节点,然后选择 Base_Control 节点。添加名为 Base_Control.gd 的新脚本 ,并添加以下内容:

GDScript

  1. extends Control
  2. var sphere_count_label
  3. func _ready():
  4. sphere_count_label = get_node("Label_Sphere_Count")
  5. get_tree().root.get_node("Game").sphere_ui = self
  6. func update_ui(sphere_count):
  7. if sphere_count > 0:
  8. sphere_count_label.text = str(sphere_count) + " Spheres remaining"
  9. else:
  10. sphere_count_label.text = "No spheres remaining! Good job!"

我们来看看这个脚本是如何快速工作的.

首先, 在 _ready 中, 我们获得 Label , 显示还剩下多少个球体, 并将其分配给 sphere_count_label 类变量. 接下来, 我们通过使用 get_tree().root 获得 Game.gd 并将 sphere_ui 分配给这个脚本.

update_ui 中, 我们改变球体 Label 的文本. 如果至少还有一个球体, 我们改变文本以显示世界上还剩下多少球体. 如果没有剩余的球体了, 我们改变文本并祝贺玩家.

添加最终的特殊 RigidBody

最后,在我们完成本教程之前,让我们添加一种在 VR 中重置游戏的方法。

Scenes 中找到并打开 Reset_Box.tscn。选择 Reset_Box RigidBody 节点,并创建一个名为 Reset_Box.gd 的新脚本。添加以下代码:

GDScript

  1. extends VR_Interactable_Rigidbody
  2. var start_transform
  3. var reset_timer = 0
  4. const RESET_TIME = 10
  5. const RESET_MIN_DISTANCE = 1
  6. func _ready():
  7. start_transform = global_transform
  8. func _physics_process(delta):
  9. if start_transform.origin.distance_to(global_transform.origin) >= RESET_MIN_DISTANCE:
  10. reset_timer += delta
  11. if reset_timer >= RESET_TIME:
  12. global_transform = start_transform
  13. reset_timer = 0
  14. func interact():
  15. # (Ignore the unused variable warning)
  16. # warning-ignore:return_value_discarded
  17. get_tree().change_scene("res://Game.tscn")
  18. func dropped():
  19. global_transform = start_transform
  20. reset_timer = 0

让我们快速浏览一下这个脚本的工作原理。

解释重置盒子代码

就像我们创建的其他特殊的 RigidBody 的对象一样,重置框扩展了 VR_Interactable_Rigidbody

start_transform 类变量将存储游戏开始时重置框的全局变换, reset_timer 类变量保存重置框位置移动后的时长, RESET_TIME 常量定义了重置框在被重置前需要等待的时长, RESET_MIN_DISTANCE 常量定义了重置框在重置计时器启动前需要离开初始位置多远.

_ready 函数中, 我们所做的只是在场景开始时存储重置位置的 global_transform . 这样就可以在时间足够长时, 将重置框对象的位置, 旋转和比例重置为这个初始变换.

_physics_process 函数中, 代码检查重置框的初始位置到重置框的当前位置是否比 RESET_MIN_DISTANCE 远. 如果远, 那么它就开始增加 reset_timer 时间 delta . 一旦 reset_timer 大于或等于 reset_TIME , 就把 global_transform 重置为 start_transform , 这样复位框就回到了初始位置. 然后将 reset_timer 设置为 0 .

interact 函数只是使用 get_tree().change_scene 重新加载 Game.tscn 场景。这将重新加载游戏场景,把所有东西重置。

最后, dropped 函数将 global_transform 重设为 start_transform 中的初始变换, 这样复位框就有了初始位置旋转. 然后将 reset_timer 设置为 0 , 这样就复位了计时器.

重置盒子完成

完成这些后, 当你抓起复位盒并与之互动时, 整个场景将重置/重启, 你可以再次摧毁所有的目标!

备注

在没有任何形式过渡的情况下, 突然重置场景会导致VR中的不适感.

最后的说明

../../../../_images/starter_vr_tutorial_pistol.png

呼!工作量还不小。

现在你有一个完全可以工作的 VR 项目,有多种不同类型的特殊 RigidBody 的节点可供使用和扩展。希望这将有助于作为在 Godot 中制作功能齐全的 VR 游戏,本教程介绍中详述的代码和概念可以扩展到制作益智游戏、动作游戏、基于故事的游戏等!

警告

你可以在 OpenVR GitHub 仓库的 Release 选项卡中下载本系列教程的成品项目!