用 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
程式碼很簡單:
- 箭有兩個碰撞體:一個在發射時用於檢測碰撞,一個用於物理互動並在射出箭後將它撿起來。這個變數引用了後者。
- 引用箭的剛性體。
- 當箭射出後,這個變數標記為 true。
- 當箭射中某個固體物件時,這個變數標記為 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
}
}
這個方法確保箭始終保持方向為箭尖所指方向。這會產生某些好玩的效果,比如將箭射向天空,當它落到地上時,箭頭會刺入土壤中。這會讓某些東西變得更穩定,防止箭刺入的位置不太恰當。
這個方法分成兩步:
- 如果箭已射出,沒有刺入牆中,同時速度不為 0…
- 獲取速度向量所指的方向。
然後是 FixedUpdate():
public void SetAllowPickup(bool allow) // 1
{
pickupCollider.enabled = allow;
}
public void Launch() // 2
{
launched = true;
SetAllowPickup(false);
}
分別解釋如下:
- 一個助手方法,開啟/禁用 pickupCollider。
- 當箭從弓上射出呼叫,將 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
}
程式碼解釋如下:
- 引數是一個碰撞體。也就是箭身上的碰撞體。
- 開啟箭的動力學特性,以便它不受物理引擎影響。
- 將 stuckInWall 設定為 true。
- 一旦箭停止移動,就可以允許它被拾起了。
- 將箭附著在所射中的物件上,這樣哪怕那個物體是移動著的,箭也會牢牢地粘在它身上。
最後一段指令碼是在 OnTriggerEnter() 方法中,當箭擊中某個物體時呼叫這個方法:
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Controller") || other.GetComponent<Bow>()) // 1
{
return;
}
if (launched && !stuckInWall) // 2
{
GetStuck(other);
}
}
會報一個錯給你,說 Bow 不存在。先忽略這個錯誤:我們後面會建立 Bow 這個指令碼。
程式碼解釋如下:
- 如果箭和控制器(手柄)或者弓發生碰撞,不要呼叫 GetStuck 方法(也就是不會發生”射入“事件)。這避免了某些異常的情況,否則箭在一射出之後立馬就“粘”在弓上。
- 如果箭已射出,並且還沒有出現“刺入”的情況,則將它“粘”在發生碰撞的物體上。
儲存指令碼,在 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
這些變數分別用於:
- 一個對 BowArrow 的引用,它會作為弓的子物件。
- 引用了弓的蒙皮網格。這將在改變弓的彎曲度的時候用到。
- 箭和弓的距離乘以 blendMultiplier 就會得到這個彎曲度最終的 Bend 值。
- 引用了 RealArrow 預製件,當弓弦被拉起然後鬆開後,會生成一個 RealArrow 並射出。
- 當弓滿弦後箭射出時獲得的速度。
- 箭射出時播放的聲音。
在變數聲明後面加一個欄位:
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
解釋如下:
- 計算弓和箭之間的距離。
- 設定弓的彎曲度為前面計算出的距離乘以 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
}
這兩個方法用於將箭放到弓上和從弓上移除。
- 將箭上弦,弓上的箭設定為可用,使它可見。
- 重置弓的 bend 值,這會讓弦重新恢復成直線。
- 重置弓上的箭的位置。
- 將箭隱藏,通過將它設定為不可用。
在 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
}
}
當手柄碰到弓並按下扳機時呼叫這個方法。
- 方法引數是一個碰撞體。也就是碰到弓的扳機。
- 這個 if 判斷很長,當弓處於未上弦,並且和一個 RealArrow 發生碰撞時。有幾個判斷是為了確保它只會和玩家手中的箭發生互動。
- 銷燬 RealArrow。
- 將箭安裝到弓上。
這段程式碼允許玩家在第一次裝上的箭被射出後再次上弦。
最後是射箭的方法。在 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
}
程式碼有點多,但並不複雜:
- 用 RealArrow 預製件生成一支新的箭。設定它的 position 和 rotation 和弓相等。
- 計算弓與箭之間的距離,儲存到 distance 變數。
- 基於 distance 給 RealArrow 施加一個向前的加速度。弓弦向後拉動的動作越大,箭所獲得的加速度就越大。
- 播放“射箭”的聲音。
- 讓手柄振動,模擬真實的體驗。
- 呼叫 RealArrow 的 Launch() 方法。
- 將箭從弓上移除。
然後到檢視器中修改弓的設定!
儲存指令碼,回到編輯器。
在結構檢視中選中 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
它們的作用分別是:
- z 軸的最小值。
- z 軸的最大值。這個變數和上個變數一起,用於限制箭支的位置,使它無法被拉得太遠也不能推進到弓裡面。
- 引用了箭所在的弓的 Bow 物件。
- 用於矯正箭相對於弓的位置。
然後新增這個方法:
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
}
程式碼解釋如下:
- 覆蓋 OnTriggerIsBeingPressed() 方法,用正在和箭互動的手柄作為引數傳入。
- 呼叫基類方法。這其實沒有什麼作用,只不過是為了保持前後寫法一致而已。
- 呼叫 InverseTransformPoint() 方法,獲取箭相對於弓和手柄的最新位置。這使得箭能夠被正確地後拉,無論手柄是不是和弓的 z 軸對得很齊。
- 將箭移動到新位置,並在這個位置的 z 軸上新增 arrowCorrection 以進行矯正。
然後是這個方法:
public override void OnTriggerWasReleased(RWVR_InteractionController controller) // 1
{
attachedBow.GetComponent<Bow>().ShootArrow(); // 2
currentController.Vibrate(3500); // 3
base.OnTriggerWasReleased(controller); // 4
}
這個方法在箭被射出去之後呼叫。
- 覆蓋 OnTriggerWasRelease() 方法,用正在和箭互動的控制器作為引數。
- 射出箭支。
- 震動手柄。
- 呼叫父類方法以釋放 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
}
}
這個方法在每幀的最後呼叫。用這個方法對箭的位置和角度、手柄的震動進行限制,以便模擬向後拉箭的動作。
- 將箭的 z 座標儲存在 zPos。
- 將 zPos 限制在允許的最大值最小值區間。
- 將 zPos 應用到箭的位置上。
- 將箭的角度限制為 Vector3.zero。
- 震動手柄。箭被拉得越往後,震動的強度越大。
儲存腳本回到編輯器。
在結構檢視中,展開 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
每個變數的作用分別為下:
- 一個公有的、靜態的對本指令碼的引用,這樣你可以從任意指令碼中呼叫到它。
- 引用了左手柄。
- 引用了右手柄。
新增方法:
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
}
這個助手方法用於判斷是否某隻手柄中正在抓著一個元件:
- 這是一個泛型方法,接收任意型別。
- 如果左手柄正在和某個物件互動,並且它抓住的物件的元件型別就是泛型引數的型別,返回true。
- 如果右手柄正在和某個物件互動,並且它抓住的物件的元件型別就是泛型引數的型別,返回 true。
- 否則,返回 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。
- 一個對 RealArrow 預製件的引用。
- 一個 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
}
這個方法將一個物件附著在玩家的手柄上,就像玩家從背後掏出某件東西一樣。
- 有兩個引數,prefab 是將生成的 GameObject,controller 是用哪個手柄來抓住這個 GameObject。
- 在手柄相同的位置和方向,創建出一個新的 GameObject,然後儲存到 spawnedObject 變數。
- 將手柄的當前 InteractionObject 換成剛剛建立的物件。
- 放下揹包,將焦點集中在剛剛建立的物件上。
下面一個方法則決定當玩家在揹包上按下扳機時,能夠掏出的東西有哪些。
新增方法:
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);
}
}
程式碼解釋如下:
- 覆蓋父類的 OnTriggerWasPressed() 方法。
- 呼叫父類的 OnTriggerWasPressed() 方法。
- 如果任何一支手柄正在握著弓,生成一支箭。
- 否則,從 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 技能提升到專業水準的人。這本書假設你有一定的程式設計經驗(任何語言)。
如果你有任何看法和建議,請在下面留言!