用 Unity 和 HTC Vive 实现高级 VR 机制(2)

原文:Advanced VR Mechanics With Unity and the HTC Vive – Part 2
作者:Eric Van de Kerckhove
译者:kmyhy

介绍

在第一部分教程中,我们学习李如何创建交互系统以及用它来抓取、握持和扔出东西。

在第二部分中,你将学习:

  • 制作一副功能完备的弓和箭
  • 创建一个虚拟背包

本教程针对高级读者,它会跳过许多细节,比如添加组件、创建新 GameObjecdt、脚本等。我们假定你知道如何完成这些工作。如果不,请阅读这里的 Unity 入门教程。

开始

下载开始项目,解压缩,用 Unity 打开解压缩后的文件夹。在项目窗口中的文件夹大致如下所示:

用 Unity 和 HTC Vive 实现高级 VR 机制(2)_第1张图片

  • Materials: 包含所有场景中用到的材质。
  • Models: 包含所有模型。
  • Prefabs: 包含所有在上一教程中创建的预制件。
  • Scenes: 游戏场景及一些灯光数据。
  • Scripts: 所有脚本。
  • Sounds: 包含射箭时弓箭所发出的声音。
  • SteamVR: SteamVR 创建及相关脚本,预制件和示例。
  • Textures: 为了简单起见,几乎本教程中的模型所共享的纹理图片都放在这里。

打开 Scenes 文件夹下的 Game 场景。

弓的制作

目前场景中还没有弓。

新建一个 GameObject,命名为 Bow。

将 Bow 的 position 设为 (X:-0.1, Y:4.5, Z:-1) ,rotation 设为 (X:0, Y:270, Z:80)。

将 Bow 模型从 Models 文件夹拖到结构视图的 Bow 对象,变成它的子对象。

将它改名为 BowMesh,设置 position 、rotation 和 scale 分别为 (X:0, Y:0, Z:0)、 (X:-90, Y:0, Z:-180) 和 (X:0.7, Y:0.7, Z:0.7) 。

看起来像这个样子:

用 Unity 和 HTC Vive 实现高级 VR 机制(2)_第2张图片

在继续之前,我需要演示一下这根弓弦要怎么用。

选中 BowMesh,找到它的 Skinned Mesh Renderer。展开 BlendShapes 字段,显示 Bend 即 blendshape 值。这就是重点。

用 Unity 和 HTC Vive 实现高级 VR 机制(2)_第3张图片

注意观察弓。在检视器的 Bend 处拖动鼠标,将 Bend 值从 0 - 100 之间来回拖动。

用 Unity 和 HTC Vive 实现高级 VR 机制(2)_第4张图片

将 Bend 恢复为 0。

从 BowMesh 上删除 Animator 组件,所有的动画都将通过 blendshape 来进行。

从 Prefabs 文件夹拖一个 RealArrow 实例到 Bow 上。

将它命名为 BowArrow ,修改 Transform 组件,让它的位置相对于 Bow。

这支箭不会被作为正常的箭来使用,因此删除它和预制件的连接——从顶部菜单中选择 GameObject\Break Prefab Instance 菜单。

用 Unity 和 HTC Vive 实现高级 VR 机制(2)_第5张图片

展开 BowArrow,删除它的 Trail 子对象。这个粒子系统只是用于一般的箭的。

从 BowArrow 上删除 Rigidbody,第二个 Box Collider 以及 RWVR_Snap To Controller 组件。

只留下一个 Transform 和一个 Box Collider 组件。

这支 Box Collider 的 center 为 (X:0, Y:0, Z:-0.28) ,设置 size 为 (X:0.1, Y:0.1, Z:0.2)。这将是玩家可以抓住和松开的部位。

用 Unity 和 HTC Vive 实现高级 VR 机制(2)_第6张图片

再次选择 Bow,为它添加一个刚性体和一个盒子碰撞体。这将允许它在未使用的时候拥有一个可见的真实形体。

将盒子碰撞体的 center 设置为 (X:0, Y:0, Z:-0.15) ,size设置为 (X:0.1, Y:1.45, Z:0.45) 。

为它添加一个 RWVR_Snap To Controller 组件。勾选 Hide Controller Model,将 Snap Position Offset 设为 (X:0, Y:0.08, Z:0) , Snap Rotation Offset 设为 (X:90, Y:0, Z:0)。

运行场景,试试看,能不能把弓拿起来?

用 Unity 和 HTC Vive 实现高级 VR 机制(2)_第7张图片

然后应该设置控制器的 tag,以便后面的脚本可以正常工作。

展开 [CameraRig],同时选中两个 controller,将它们的 tag 设置为 Controller。

用 Unity 和 HTC Vive 实现高级 VR 机制(2)_第8张图片

在下一节,我们将编写脚本让弓能正常工作。

箭的制作

我们制作的弓包含李 3 个主要部件:

  • 弓上的箭
  • 一个正常的射出去的箭

这些部件的每一个都需要编写脚本,这样弓才能完成射箭的动作。

首先,那支正常的箭需要一个能够射中物体并能随后捡起的脚本。

在 Scrits 目录下新建 C# 脚本,命名为 RealArrow。注意这个脚本不放在 RWVR 文件夹下,因为它不属于交互系统。

打开这个脚本,删除 Start() 和 Update() 方法。

添加下列变量:


public BoxCollider pickupCollider; // 1
private Rigidbody rb; // 2
private bool launched; // 3
private bool stuckInWall; // 4

代码很简单:

  1. 箭有两个碰撞体:一个在发射时用于检测碰撞,一个用于物理交互并在射出箭后将它捡起来。这个变量引用了后者。
  2. 引用箭的刚性体。
  3. 当箭射出后,这个变量标记为 true。
  4. 当箭射中某个固体对象时,这个变量标记为 true。

添加一个 Awake() 方法:


private void Awake()
{
    rb = GetComponent();
}

这个方法将箭的刚性体组件缓存起来。

然后是这个方法:

private void FixedUpdate()
{
    if (launched && !stuckInWall && rb.velocity != Vector3.zero) // 1
    {
        rb.rotation = Quaternion.LookRotation(rb.velocity); // 2
    }
}

这个方法确保箭始终保持方向为箭尖所指方向。这会产生某些好玩的效果,比如将箭射向天空,当它落到地上时,箭头会刺入土壤中。这会让某些东西变得更稳定,防止箭刺入的位置不太恰当。

这个方法分成两步:

  1. 如果箭已射出,没有刺入墙中,同时速度不为 0…
  2. 获取速度向量所指的方向。

然后是 FixedUpdate():

public void SetAllowPickup(bool allow) // 1
{
    pickupCollider.enabled = allow;
}

public void Launch() // 2
{
    launched = true;
    SetAllowPickup(false);
}

分别解释如下:

  1. 一个助手方法,开启/禁用 pickupCollider。
  2. 当箭从弓上射出调用,将 lanched 标志设置为 true,并且不允许箭能够被拾起。

然后是这个方法,确保箭射中一个固态物体后不再移动:

private void GetStuck(Collider other) // 1
{
    launched = false; 
    rb.isKinematic = true; // 2
    stuckInWall = true; // 3
    SetAllowPickup(true); // 4
    transform.SetParent(other.transform); // 5
}

代码解释如下:

  1. 参数是一个碰撞体。也就是箭身上的碰撞体。
  2. 开启箭的动力学特性,以便它不受物理引擎影响。
  3. 将 stuckInWall 设置为 true。
  4. 一旦箭停止移动,就可以允许它被拾起了。
  5. 将箭附着在所射中的对象上,这样哪怕那个物体是移动着的,箭也会牢牢地粘在它身上。

最后一段脚本是在 OnTriggerEnter() 方法中,当箭击中某个物体时调用这个方法:


private void OnTriggerEnter(Collider other)
{
    if (other.CompareTag("Controller") || other.GetComponent()) // 1
    {
        return;
    }

    if (launched && !stuckInWall) // 2
    {
        GetStuck(other);
    }
}

会报一个错给你,说 Bow 不存在。先忽略这个错误:我们后面会创建 Bow 这个脚本。

代码解释如下:

  1. 如果箭和控制器(手柄)或者弓发生碰撞,不要调用 GetStuck 方法(也就是不会发生”射入“事件)。这避免了某些异常的情况,否则箭在一射出之后立马就“粘”在弓上。
  2. 如果箭已射出,并且还没有出现“刺入”的情况,则将它“粘”在发生碰撞的物体上。

保存脚本,在 Scripts 文件夹新建另一个 C# 脚本 Bow。然后在编辑器中打开它。

编写 Bow

删除 Start() 方法,在类声明之前添加:

[ExecuteInEditMode]

这将允许这个脚本执行它的方法,就算是你正在编辑器中编辑的时候。你等会就会知道这是一个非常好用的技巧。

在 Update() 方法上面添加变量:

public Transform attachedArrow; // 1
public SkinnedMeshRenderer BowSkinnedMesh; // 2

public float blendMultiplier = 255f; // 3
public GameObject realArrowPrefab; // 4

public float maxShootSpeed = 50; // 5

public AudioClip fireSound; // 6

这些变量分别用于:

  1. 一个对 BowArrow 的引用,它会作为弓的子对象。
  2. 引用了弓的蒙皮网格。这将在改变弓的弯曲度的时候用到。
  3. 箭和弓的距离乘以 blendMultiplier 就会得到这个弯曲度最终的 Bend 值。
  4. 引用了 RealArrow 预制件,当弓弦被拉起然后松开后,会生成一个 RealArrow 并射出。
  5. 当弓满弦后箭射出时获得的速度。
  6. 箭射出时播放的声音。

在变量声明后面加一个字段:

bool IsArmed()
{
    return attachedArrow.gameObject.activeSelf;
}

如果箭可用时,返回 true。这是对 attachedArrow.gameObject.activeSelf 的一种缩写。

在 Update() 方法中添加:

float distance = Vector3.Distance(transform.position, attachedArrow.position); // 1
BowSkinnedMesh.SetBlendShapeWeight(0, Mathf.Max(0, distance * blendMultiplier)); // 2

解释如下:

  1. 计算弓和箭之间的距离。
  2. 设置弓的弯曲度为前面计算出的距离乘以 blendMultiplier。

然后,在 Update() 后添加:

private void Arm() // 1
{
    attachedArrow.gameObject.SetActive(true);
}

private void Disarm() 
{
    BowSkinnedMesh.SetBlendShapeWeight(0, 0); // 2
    attachedArrow.position = transform.position; // 3
    attachedArrow.gameObject.SetActive(false); // 4
}

这两个方法用于将箭放到弓上和从弓上移除。

  1. 将箭上弦,弓上的箭设置为可用,使它可见。
  2. 重置弓的 bend 值,这会让弦重新恢复成直线。
  3. 重置弓上的箭的位置。
  4. 将箭隐藏,通过将它设置为不可用。

在 Disarm() 后面添加 OnTriggerEnter() :

private void OnTriggerEnter(Collider other) // 1
{
    if (
        !IsArmed() 
          && other.CompareTag("InteractionObject") 
          && other.GetComponent() 
          && !other.GetComponent().IsFree() // 2
    ) {
        Destroy(other.gameObject); // 3
        Arm(); // 4
    }
}

当手柄碰到弓并按下扳机时调用这个方法。

  1. 方法参数是一个碰撞体。也就是碰到弓的扳机。
  2. 这个 if 判断很长,当弓处于未上弦,并且和一个 RealArrow 发生碰撞时。有几个判断是为了确保它只会和玩家手中的箭发生交互。
  3. 销毁 RealArrow。
  4. 将箭安装到弓上。

这段代码允许玩家在第一次装上的箭被射出后再次上弦。

最后是射箭的方法。在 OnTriggerEnter() 下方添加:

public void ShootArrow()
{
    GameObject arrow = Instantiate(realArrowPrefab, transform.position, transform.rotation); // 1
    float distance = Vector3.Distance(transform.position, attachedArrow.position); // 2

    arrow.GetComponent().velocity = arrow.transform.forward * distance * maxShootSpeed; // 3
    AudioSource.PlayClipAtPoint(fireSound, transform.position); // 4
    GetComponent().currentController.Vibrate(3500); // 5
    arrow.GetComponent().Launch(); // 6

    Disarm(); // 7
}

代码有点多,但并不复杂:

  1. 用 RealArrow 预制件生成一支新的箭。设置它的 position 和 rotation 和弓相等。
  2. 计算弓与箭之间的距离,保存到 distance 变量。
  3. 基于 distance 给 RealArrow 施加一个向前的加速度。弓弦向后拉动的动作越大,箭所获得的加速度就越大。
  4. 播放“射箭”的声音。
  5. 让手柄振动,模拟真实的体验。
  6. 调用 RealArrow 的 Launch() 方法。
  7. 将箭从弓上移除。

然后到检视器中修改弓的设置!

保存脚本,回到编辑器。

在结构视图中选中 Bow,然后添加一个 Bow 组件。

用 Unity 和 HTC Vive 实现高级 VR 机制(2)_第9张图片

展开 Bow,显示其子节点,将 BowArrow 拖到 Attached Arrow 字段。

然后将 BowMesh 拖到 Bow Skinned Mesh 字段,设置 Blend Multiplier 为 353。

从 Prefabs 文件夹拖一个 RealArrow 预制件到 Real Arrow Prefab 字段,将 Sounds 文件夹下的 FireBow 声音文件拖到 Fire Sound 字段。

做完后的 Bow 组件看起来是这个样子:

用 Unity 和 HTC Vive 实现高级 VR 机制(2)_第10张图片

还记得蒙皮网格是怎样影响 bow 模型的吗?在场景视图中,拖动 BowArrow 的 local Z-axis 看一下满弦后效果:

用 Unity 和 HTC Vive 实现高级 VR 机制(2)_第11张图片

感觉不错吧?

现在需要设置 RealArrow 让它按照我们的意图去运作。

在结构视图中,选择 RealArrow,为它添加一个 Real Arrow 组件。

将 Box Collider 下的 Is Trigger 禁用,然后将它拖进 Pickup Collider 字段。

用 Unity 和 HTC Vive 实现高级 VR 机制(2)_第12张图片

点击检视器顶部的 Apply 按钮,将修改应用到所有 RealArrow 预制件。

最后一个需要改的地方是安在弓上的“特别”箭支。

安在弓上的箭

安在弓上的箭弧被玩家向后拉、然后释放,才能射出去。

在 Scripts \ RWVR 文件夹下新建 C# 脚本 RWVR_ArrowInBow,删除它的 Start() 和 Update() 方法。

让这个类继承 RWVR_InteractionObject :

public class RWVR_ArrowInBow : RWVR_InteractionObject

增加几个变量声明:

public float minimumPosition; // 1
public float maximumPosition; // 2

private Transform attachedBow; // 3
private const float arrowCorrection = 0.3f; // 4

它们的作用分别是:

  1. z 轴的最小值。
  2. z 轴的最大值。这个变量和上个变量一起,用于限制箭支的位置,使它无法被拉得太远也不能推进到弓里面。
  3. 引用了箭所在的弓的 Bow 对象。
  4. 用于矫正箭相对于弓的位置。

然后添加这个方法:

public override void Awake()
{
    base.Awake();
    attachedBow = transform.parent;
}

这里调用了基类的 Awake() 方法,将 transform 缓存,然后将弓保存到 attachedBow 变量。

这个方法在用户按下扳机时调用:

public override void OnTriggerIsBeingPressed(RWVR_InteractionController controller) // 1
{
    base.OnTriggerIsBeingPressed(controller); // 2

    Vector3 arrowInBowSpace = attachedBow.InverseTransformPoint(controller.transform.position); // 3
    cachedTransform.localPosition = new Vector3(0, 0, arrowInBowSpace.z + arrowCorrection); // 4
}

代码解释如下:

  1. 覆盖 OnTriggerIsBeingPressed() 方法,用正在和箭交互的手柄作为参数传入。
  2. 调用基类方法。这其实没有什么作用,只不过是为了保持前后写法一致而已。
  3. 调用 InverseTransformPoint() 方法,获取箭相对于弓和手柄的最新位置。这使得箭能够被正确地后拉,无论手柄是不是和弓的 z 轴对得很齐。
  4. 将箭移动到新位置,并在这个位置的 z 轴上添加 arrowCorrection 以进行矫正。

然后是这个方法:

public override void OnTriggerWasReleased(RWVR_InteractionController controller) // 1
{
    attachedBow.GetComponent().ShootArrow(); // 2
    currentController.Vibrate(3500); // 3
    base.OnTriggerWasReleased(controller); // 4
}

这个方法在箭被射出去之后调用。

  1. 覆盖 OnTriggerWasRelease() 方法,用正在和箭交互的控制器作为参数。
  2. 射出箭支。
  3. 震动手柄。
  4. 调用父类方法以释放 currentController。

然后是这个方法:

void LateUpdate()
{
    // Limit position
    float zPos = cachedTransform.localPosition.z; // 1
    zPos = Mathf.Clamp(zPos, minimumPosition, maximumPosition); // 2
    cachedTransform.localPosition = new Vector3(0, 0, zPos); // 3

    //Limit rotation
    cachedTransform.localRotation = Quaternion.Euler(Vector3.zero); // 4

    if (currentController)
    {
        currentController.Vibrate(System.Convert.ToUInt16(500 * -zPos)); // 5
    }
}

这个方法在每帧的最后调用。用这个方法对箭的位置和角度、手柄的震动进行限制,以便模拟向后拉箭的动作。

  1. 将箭的 z 坐标保存在 zPos。
  2. 将 zPos 限制在允许的最大值最小值区间。
  3. 将 zPos 应用到箭的位置上。
  4. 将箭的角度限制为 Vector3.zero。
  5. 震动手柄。箭被拉得越往后,震动的强度越大。

保存脚本回到编辑器。

在结构视图中,展开 Bow,选择 BowArrow 子节点。在它上面添加一个 RWVR_Arrow In Bow 组件,设置 Minimum Position 为 -0.4。

保存场景,拿起你的头盔和手柄准备试玩游戏!

用一支手柄抓住弓,然后用另一只手柄向后拉箭。

放开手柄将箭放出,从桌子上拿起一支箭装到弓弦上。

最后一个工作是背包(对于本例而言,也叫箭囊),这样你就可以从中抓起新的箭支装到弓弦上。

这要创建一个新的脚本了。

创建虚拟背包

为了知道玩家的手柄上是否抓得有东西,你需要一个控制器管理器,用于引用两只手柄。

在 Script/RWVR 文件夹下新建 C# 脚本 RWVR_ControllerManager。用代码编辑器打开。

删除 Start() 和 Update() ,添加变量:

public static RWVR_ControllerManager Instance; // 1

public RWVR_InteractionController leftController; // 2
public RWVR_InteractionController rightController; // 3

每个变量的作用分别为下:

  1. 一个公有的、静态的对本脚本的引用,这样你可以从任意脚本中调用到它。
  2. 引用了左手柄。
  3. 引用了右手柄。

添加方法:

private void Awake()
{
    Instance = this;
}

将这个脚本的一个引用保存到 Instance 变量。

然后是这个方法:

public bool AnyControllerIsInteractingWith() // 1
{
    if (leftController.InteractionObject && leftController.InteractionObject.GetComponent() != null) // 2
    {
        return true;
    }

    if (rightController.InteractionObject && rightController.InteractionObject.GetComponent() != null) // 3
    {
        return true;
    }

    return false; // 4
}

这个助手方法用于判断是否某只手柄中正在抓着一个组件:

  1. 这是一个泛型方法,接收任意类型。
  2. 如果左手柄正在和某个对象交互,并且它抓住的对象的组件类型就是泛型参数的类型,返回true。
  3. 如果右手柄正在和某个对象交互,并且它抓住的对象的组件类型就是泛型参数的类型,返回 true。
  4. 否则,返回 false。

保存脚本,返回编辑器。

最后一个脚本是和背包对应的脚本。

在 Scripts\RWVR 目录下新建 C# 脚本 RWVR_SpecialObjectSpawner。

打开脚本,将这一句:

public class RWVR_SpecialObjectSpawner : MonoBehaviour

替换成:

public class RWVR_SpecialObjectSpawner : RWVR_InteractionObject

让我们的背包从 RWVR_InteractionObject 继承。

删除 Start() 和 Update() 方法,添加变量:

public GameObject arrowPrefab; // 1
public List randomPrefabs = new List(); // 2

它们将用于从背包中生出 GameObject。

  1. 一个对 RealArrow 预制件的引用。
  2. 一个 GameObjectd 数组,用于保存能够从背包中取出的东西。

添加这个方法:

private void SpawnObjectInHand(GameObject prefab, RWVR_InteractionController controller) // 1
{
    GameObject spawnedObject = Instantiate(prefab, controller.snapColliderOrigin.position, controller.transform.rotation); // 2
    controller.SwitchInteractionObjectTo(spawnedObject.GetComponent()); // 3
    OnTriggerWasReleased(controller); // 4
}

这个方法将一个对象附着在玩家的手柄上,就像玩家从背后掏出某件东西一样。

  1. 有两个参数,prefab 是将生成的 GameObject,controller 是用哪个手柄来抓住这个 GameObject。
  2. 在手柄相同的位置和方向,创建出一个新的 GameObject,然后保存到 spawnedObject 变量。
  3. 将手柄的当前 InteractionObject 换成刚刚创建的对象。
  4. 放下背包,将焦点集中在刚刚创建的对象上。

下面一个方法则决定当玩家在背包上按下扳机时,能够掏出的东西有哪些。

添加方法:

public override void OnTriggerWasPressed(RWVR_InteractionController controller) // 1
{
    base.OnTriggerWasPressed(controller); // 2

    if (RWVR_ControllerManager.Instance.AnyControllerIsInteractingWith()) // 3
    {
        SpawnObjectInHand(arrowPrefab, controller);
    }
    else // 4
    {
        SpawnObjectInHand(randomPrefabs[UnityEngine.Random.Range(0, randomPrefabs.Count)], controller);
    }
}

代码解释如下:

  1. 覆盖父类的 OnTriggerWasPressed() 方法。
  2. 调用父类的 OnTriggerWasPressed() 方法。
  3. 如果任何一支手柄正在握着弓,生成一支箭。
  4. 否则,从 randomPrefabs 列表中随机生成一个 GameObject。

保存脚本,返回编辑器。

在结构视图中新建一个 Cube,命名为 BackPark,将它拖到 [CameraRig]\ Camera (head) 放到玩家头盔下面。

用 Unity 和 HTC Vive 实现高级 VR 机制(2)_第13张图片

将它的 position 和 scale 分别设为 (X:0, Y:-0.25, Z:-0.45) 和 (X:0.6, Y:0.5, Z:0.5) 。

背包现在被放在了玩家脑袋的右后下方。

将 Box Collider 的 Is Trigger 设为 true。这个对象不需要和任何物体进行碰撞检测。

将 Cast Shadows 设为 Off,关闭 Mesh Renderer 的 Receive Shadows。

用 Unity 和 HTC Vive 实现高级 VR 机制(2)_第14张图片

现在添加一个 RWVR_Special Object Spawner 组件,从 Prefabs 文件夹拖一个 RealArrow 到 Arrow Prefab 字段。

最终,从同一个文件夹拖一个 Book 和一个 Die 预制件到 Radom Prefabs list。

然后,添加一个新的空白 GameObjecdt,命名为 ControllerManager,然后在它上面添加一个 RWVR_Controller Manager 组件。

展开 [CameraRig] ,拖 Controller (left) 到 Left Controller 字段,拖 Controller (right) 到 Right Controller 字段。

保存场景,试一下这个背包。尝试抓一下你背上的背包,看看你能掏出什么东西来!

用 Unity 和 HTC Vive 实现高级 VR 机制(2)_第15张图片

本教程就到此结束了!一副功能完好的弓箭及一个易于扩展的交互系统就完成了。

结尾

最终完成的项目在此处下载。

在本教程中,你学习了如何为你的 HTC Vive 游戏创建和添加如下功能:

  • 对交互系统进行扩展。
  • 制作一副可用的弓箭。
  • 创建一个虚拟背包。

如果你想学习更过使用 Unity 制作猎人游戏的内容,请阅读我们的《Unity 游戏教程》。

在这本书中,你会从零开始制作 4 款游戏:

  • 一款双摇杆射击游戏
  • 一款第一人称设计游戏
  • 一款塔防游戏(支持 VR)
  • 一款 2D 平台游戏

通过这本书,你将学会如何制作自己的 Windows、macOS、iOS 平台游戏!

这本书完全针对 Unity 初学者,以及准备将自己的 Unity 技能提升到专业水准的人。这本书假设你有一定的编程经验(任何语言)。

如果你有任何看法和建议,请在下面留言!

你可能感兴趣的:(游戏开发)