1. 程式人生 > >用 Unity 和 HTC Vive 實現高階 VR 機制(2)

用 Unity 和 HTC Vive 實現高階 VR 機制(2)

介紹

第一部分教程中,我們學習李如何建立互動系統以及用它來抓取、握持和扔出東西。

在第二部分中,你將學習:

  • 製作一副功能完備的弓和箭
  • 建立一個虛擬揹包

本教程針對高階讀者,它會跳過許多細節,比如新增元件、建立新 GameObjecdt、指令碼等。我們假定你知道如何完成這些工作。如果不,請閱讀這裡的 Unity 入門教程

開始

下載開始專案,解壓縮,用 Unity 開啟解壓縮後的資料夾。在專案視窗中的資料夾大致如下所示:

  • 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) 。

看起來像這個樣子:

在繼續之前,我需要演示一下這根弓弦要怎麼用。

選中 BowMesh,找到它的 Skinned Mesh Renderer。展開 BlendShapes 欄位,顯示 Bend 即 blendshape 值。這就是重點。

注意觀察弓。在檢視器的 Bend 處拖動滑鼠,將 Bend 值從 0 - 100 之間來回拖動。

將 Bend 恢復為 0。

從 BowMesh 上刪除 Animator 元件,所有的動畫都將通過 blendshape 來進行。

從 Prefabs 資料夾拖一個 RealArrow 例項到 Bow 上。

將它命名為 BowArrow ,修改 Transform 元件,讓它的位置相對於 Bow。

這支箭不會被作為正常的箭來使用,因此刪除它和預製件的連線——從頂部選單中選擇 GameObject\Break Prefab Instance 選單。

展開 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)。這將是玩家可以抓住和鬆開的部位。

再次選擇 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)。

執行場景,試試看,能不能把弓拿起來?

然後應該設定控制器的 tag,以便後面的指令碼可以正常工作。

展開 [CameraRig],同時選中兩個 controller,將它們的 tag 設定為 Controller。

在下一節,我們將編寫指令碼讓弓能正常工作。

箭的製作

我們製作的弓包含李 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<Rigidbody>();
}

這個方法將箭的剛性體元件快取起來。

然後是這個方法:

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<Bow>()) // 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<RealArrow>() 
          && !other.GetComponent<RWVR_InteractionObject>().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<Rigidbody>().velocity = arrow.transform.forward * distance * maxShootSpeed; // 3
    AudioSource.PlayClipAtPoint(fireSound, transform.position); // 4
    GetComponent<RWVR_InteractionObject>().currentController.Vibrate(3500); // 5
    arrow.GetComponent<RealArrow>().Launch(); // 6

    Disarm(); // 7
}

程式碼有點多,但並不複雜:

  1. 用 RealArrow 預製件生成一支新的箭。設定它的 position 和 rotation 和弓相等。
  2. 計算弓與箭之間的距離,儲存到 distance 變數。
  3. 基於 distance 給 RealArrow 施加一個向前的加速度。弓弦向後拉動的動作越大,箭所獲得的加速度就越大。
  4. 播放“射箭”的聲音。
  5. 讓手柄振動,模擬真實的體驗。
  6. 呼叫 RealArrow 的 Launch() 方法。
  7. 將箭從弓上移除。

然後到檢視器中修改弓的設定!

儲存指令碼,回到編輯器。

在結構檢視中選中 Bow,然後新增一個 Bow 元件。

展開 Bow,顯示其子節點,將 BowArrow 拖到 Attached Arrow 欄位。

然後將 BowMesh 拖到 Bow Skinned Mesh 欄位,設定 Blend Multiplier 為 353。

從 Prefabs 資料夾拖一個 RealArrow 預製件到 Real Arrow Prefab 欄位,將 Sounds 資料夾下的 FireBow 聲音檔案拖到 Fire Sound 欄位。

做完後的 Bow 元件看起來是這個樣子:

還記得蒙皮網格是怎樣影響 bow 模型的嗎?在場景檢視中,拖動 BowArrow 的 local Z-axis 看一下滿弦後效果:

感覺不錯吧?

現在需要設定 RealArrow 讓它按照我們的意圖去運作。

在結構檢視中,選擇 RealArrow,為它新增一個 Real Arrow 元件。

將 Box Collider 下的 Is Trigger 禁用,然後將它拖進 Pickup Collider 欄位。

點選檢視器頂部的 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<Bow>().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<T>() // 1
{
    if (leftController.InteractionObject && leftController.InteractionObject.GetComponent<T>() != null) // 2
    {
        return true;
    }

    if (rightController.InteractionObject && rightController.InteractionObject.GetComponent<T>() != 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<GameObject> randomPrefabs = new List<GameObject>(); // 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<RWVR_InteractionObject>()); // 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<Bow>()) // 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) 放到玩家頭盔下面。

將它的 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。

現在新增一個 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 欄位。

儲存場景,試一下這個揹包。嘗試抓一下你背上的揹包,看看你能掏出什麼東西來!

本教程就到此結束了!一副功能完好的弓箭及一個易於擴充套件的互動系統就完成了。

結尾

最終完成的專案在此處下載

在本教程中,你學習瞭如何為你的 HTC Vive 遊戲建立和新增如下功能:

  • 對互動系統進行擴充套件。
  • 製作一副可用的弓箭。
  • 建立一個虛擬揹包。

如果你想學習更過使用 Unity 製作獵人遊戲的內容,請閱讀我們的《Unity 遊戲教程》。

在這本書中,你會從零開始製作 4 款遊戲:

  • 一款雙搖桿射擊遊戲
  • 一款第一人稱設計遊戲
  • 一款塔防遊戲(支援 VR)
  • 一款 2D 平臺遊戲

通過這本書,你將學會如何製作自己的 Windows、macOS、iOS 平臺遊戲!

這本書完全針對 Unity 初學者,以及準備將自己的 Unity 技能提升到專業水準的人。這本書假設你有一定的程式設計經驗(任何語言)。

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