用 Unity 和 HTC Vive 實現高階 VR 機制(1)
VR 從來沒有這樣時髦過,但是遊戲不是那麼好做的。為了提供真實的沉浸式體驗,遊戲內部機制和物理必須讓人覺得非常、非常的真實,尤其當你在和遊戲中的物件進行互動的時候。
在本教程的第一部分,你會學習如何建立一個可擴充套件的互動系統,並在系統中實現多種抓取虛擬物品的方式,並飛快地將它們扔出去。
學完本教程後,你可以擁有幾個靈活的互動系統並可以用在你自己的 VR 專案中。
注意:本教程適合於高階讀者,不會涉及如何新增元件、建立新的遊戲物件指令碼或者 C# 語法這樣的東西。如果你需要提升自己的 Unity 技能,請先閱讀我們的 getting started with Unity 和 introduction to Unity Scriptin
,然後在閱讀本文。
開始
在本教程中,你將必須具備下列條件:
- 一套帶手柄的、安裝好、電源開啟,準備就緒的 HTV View。
如果你之前沒有用過 HTC Vive,你可以去看我們之前的 HTC Vive tutorial,以瞭解如何在 Unity 中使用 HTC Vive。HTC Vive 是目前最好的頭戴式顯示器之一,它所支援的 room-scale 功能提供了精彩的沉浸式體驗。
下載開始專案,解壓縮,用 Unity 開啟專案資料夾。
在專案視窗中看一下目錄結構:
分別介紹如下:
- Materials: 場景中用到的材質。
- Models: 本文用到的所有模型。
- Prefabs: 目前只有一個預製件,用於關卡中隨處可見的柱子。
- Scenes:遊戲畫面和燈光資料。
- Scripts: 有幾個現成的指令碼;你自己的指令碼也會放到這裡。
- Sounds: 弓箭射出的聲音。
- SteamVR: 放置 SteamVR 外掛及其相關指令碼、預製件和示例。
- Textures: 包含了幾乎所有模型都共用的紋理(為了效率),以及 book 物件的紋理。
開啟 Scenes 資料夾下的 Game 場景。
看一下 Game 檢視,你會發現場景中缺少了相機:
在下一節,我們來解決這個問題,新增必要的東西,讓 HTC Vive 能夠工作。
場景設定
將 SteamVR\Prefabs 目錄中將 [CameraRig] 和 [SteamVR] 預製件拖進結構檢視。
攝像機現在應該是在地上,但要將它放在木塔上。將 [CameraRig] 的 position 修改為 (X:0, Y:3.35, Z:0) 。現在 Game 檢視應該是這個樣子:
儲存場景,按 Play 按鈕試一下是否順利。四處逛逛,起碼用一支手柄試試看能夠看到遊戲中的控制器。
如果手柄不工作,別擔心!在寫到此處的時候,最新版的 SteamVR 外掛(版本 1.2.1)在 Unity 5.6 中有一個 bug,導致手柄的動作沒有被註冊。
要解決這個問題,選擇 [CameraRig]/Camera (head) 下選擇的 Camera (eye),然後為它新增一個 SteamVR_Update_Poses 元件:
這個指令碼手動修改手柄的位置和角度。再次執行這個場景,問題解決了。
在編寫任何指令碼之前,看一下專案中的這幾個 tag:
這幾個 tag 允許我們更加容易判斷哪種種物件發生碰撞或者觸象。
互動系統:InteractionObject
互動系統允許場景中的玩家和物理用一種靈活的、模組化的方式進行互動。替代為每個物件和控制器編寫重複的程式碼,你將編寫幾個類給其它指令碼進行繼承。
第一個指令碼是 RWVR_InteractionObject 類;所有能夠被互動的物件都應該從此類繼承。這個基類中包含了幾個基本的變數和方法。
注意:為了避免和 SteamVR 建立衝突或者便於搜尋,本文中所有 VR 指令碼都使用 RWVR 字首。
新建資料夾 Scripts/RWVR。新建類 RWVR_InteractionObject。
開啟這個指令碼,刪除 Start() 和 Update() 方法。
新增下列變數,就在類宣告的下方:
protected Transform cachedTransform; // 1
[HideInInspector] // 2
public RWVR_InteractionController currentController; // 3
你可能會看到報錯 “RWVR_InteractionController couldn’t be found”。目前請忽略它,後面我們會建立這個類。
上面程式碼分別解釋如下:
- 為了改善效能,將 tranform 值快取。
- 這個屬性使下面的變數在檢視器視窗中不可見,哪怕它是public 的。
- 當前物件正在互動的手柄。後面我們會用到這個手柄。
儲存指令碼,回到編輯器。
在 RWVR 下面新建一個 C# 檔案 RWVR_InteractionController。開啟它,刪除 Start() 和 Update() 方法,儲存。
開啟 RWVR_InteractionObject ,之前的錯誤消失。
注意:如果錯誤仍然存在,關閉程式碼編輯器,點一下 Unity,然後再次開啟指令碼。
在剛剛新增的變數後面新增 3 個方法:
public virtual void OnTriggerWasPressed(RWVR_InteractionController controller)
{
currentController = controller;
}
public virtual void OnTriggerIsBeingPressed(RWVR_InteractionController controller)
{
}
public virtual void OnTriggerWasReleased(RWVR_InteractionController controller)
{
currentController = null;
}
這 3 個方法會在手柄的扳機按下、按住和放開時呼叫。當手柄被按下時,controller 被賦值,當它釋放時,controller 被移除。
所有方法都是虛方法,它們將在更復雜的指令碼中覆蓋,以便它們能使用這些控制器回撥方法。
在 OnTriggerWasReleased 方法後新增方法:
public virtual void Awake()
{
cachedTransform = transform; // 1
if (!gameObject.CompareTag("InteractionObject")) // 2
{
Debug.LogWarning("This InteractionObject does not have the correct tag, setting it now.", gameObject); // 3
gameObject.tag = "InteractionObject"; // 4
}
}
分別解釋如下:
- 快取 transform 以改善效能。
- 檢查 InteractionObjet 是否有指定的 tag 值。如果沒有,執行 if 後面的程式碼。
- 在檢視器中輸出一個警告,告訴開發者忘記設定 tag。
- 及時設定 tag,以便物件能夠像我們期望的工作。
這個互動系統嚴重依賴於 InteractionObject 和控制器的 tag 來區分特殊物件和其它物件。忘記設定 tag 是很可能的,所以我們專門為這個編寫了指令碼。這是一種“失效保險”的設計。小心使得萬年船。
最後,在 Awake() 方法後新增方法:
public bool IsFree() // 1
{
return currentController == null;
}
public virtual void OnDestroy() // 2
{
if (currentController)
{
OnTriggerWasReleased(currentController);
}
}
這些方法分別負責:
- 一個公有的 Boolean 方法,表示當前物件是否正在被控制器所用。
- 當物件被銷燬,將它從當前控制器(如果有的話)中釋放。這有助於解決一些莫名其妙的問題。
爆粗指令碼,開啟 RWVR_InteractionController。
現在它還是空的。我們馬上會充實它!
互動系統: Controller
控制器指令碼是最重要的部分,因為它是玩家和遊戲之間的直接聯絡。儘可能地接受輸入並返回使用者正確的反饋很重要。
首先,在類宣告下面新增變數:
public Transform snapColliderOrigin; // 1
public GameObject ControllerModel; // 2
[HideInInspector]
public Vector3 velocity; // 3
[HideInInspector]
public Vector3 angularVelocity; // 4
private RWVR_InteractionObject objectBeingInteractedWith; // 5
private SteamVR_TrackedObject trackedObj; // 6
分段解釋如下:
儲存對手柄尖端的引用。後面我們會新增一個透明的球,表示你能夠到觸控的位置以及距離你可以夠到的地方有多遠:
手柄的可見物件。上圖中白色的部分。
- 手柄的速度和方向。可以用於計算當你做拋擲時物體如何飛出。
- 手柄的角度,在拋擲時計算物體的移動也會用到它。
- 手柄當前正在互動的 InteractionObjecdt 物件。用它來向當前物件傳送事件。
- 用於獲得真實手柄的引用。
繼續在下面新增:
private SteamVR_Controller.Device Controller // 1
{
get { return SteamVR_Controller.Input((int)trackedObj.index); }
}
public RWVR_InteractionObject InteractionObject // 2
{
get { return objectBeingInteractedWith; }
}
void Awake() // 3
{
trackedObj = GetComponent<SteamVR_TrackedObject>();
}
程式碼解釋如下:
- 這個變數通過 trackedObj 獲得了一個對真實 SteamVR 手柄的引用。
- 返回和手柄進行互動的 InteractionObjecdt。對這個物件進行再次封裝,是為了對其他類保持只讀。
- 最後,保持一個和當前控制器相繫結的 TrackedObject 元件的引用,以便後面用到。
然後是這個方法:
private void CheckForInteractionObject()
{
Collider[] overlappedColliders = Physics.OverlapSphere(snapColliderOrigin.position, snapColliderOrigin.lossyScale.x / 2f); // 1
foreach (Collider overlappedCollider in overlappedColliders) // 2
{
if (overlappedCollider.CompareTag("InteractionObject") && overlappedCollider.GetComponent<RWVR_InteractionObject>().IsFree()) // 3
{
objectBeingInteractedWith = overlappedCollider.GetComponent<RWVR_InteractionObject>(); // 4
objectBeingInteractedWith.OnTriggerWasPressed(this); // 5
return; // 6
}
}
}
這個方法從控制器的碰撞體的某個範圍內查詢 InteractionObject。一旦找到一個,就將賦給 objectBeingInteractedWith。
程式碼解釋如下:
- 建立一個碰撞體的陣列,儲存 OverlapSpherer() 方法找到的所有碰撞體,查詢的位置和 scale 是 snapColliderOrigin,這是一個透明球體,如上圖所示,我們後面會新增它。
- 遍歷整個陣列。
- 如果找到的碰撞體 tag 值等於 InteractionObject,同時它又是自由的,繼續。
- 儲存碰撞體的 RWVR_InteractionObject 在 objectBeingInteractedWidth。
- 呼叫 objectedBeingInteractedWith 的 OnTriggerWasPressed 方法,將當前控制器傳遞給它。
- 退出迴圈,完成查詢。
新增方法,呼叫剛剛的這個方法:
void Update()
{
if (Controller.GetHairTriggerDown()) // 1
{
CheckForInteractionObject();
}
if (Controller.GetHairTrigger()) // 2
{
if (objectBeingInteractedWith)
{
objectBeingInteractedWith.OnTriggerIsBeingPressed(this);
}
}
if (Controller.GetHairTriggerUp()) // 3
{
if (objectBeingInteractedWith)
{
objectBeingInteractedWith.OnTriggerWasReleased(this);
objectBeingInteractedWith = null;
}
}
}
程式碼非常簡單:
- 當扳機被按下時,呼叫 CheckForInteractionObject() 方法,說明有可能發生了一次互動。
- 當扳機被按住時,同時有一個物件被抓住時,呼叫這個物件的 OnTriggerIsBeingPressed()。
- 當扳機被鬆開,同時有一個物件被抓住時,呼叫這個物件的 OnTriggerWasReleased() 方法,並停止互動。
這些檢查確保玩家的所有輸入都能被傳遞到正在和他們互動的 InteractionObject 物件。
新增兩個方法,記錄控制器的速度和角速度:
private void UpdateVelocity()
{
velocity = Controller.velocity;
angularVelocity = Controller.angularVelocity;
}
void FixedUpdate()
{
UpdateVelocity();
}
FixedUpdate() 以固定幀率呼叫 UpdateVelocity() ,後者更新 velocity 和 angularVelocity 變數。然後,你會將這兩個值傳遞給一個剛體,以確保扔出去的東西能夠更真實的移動。
有時候需要隱藏手柄,以確保體驗更加浸入式,避免遮住視線。再新增兩個方法:
public void HideControllerModel()
{
ControllerModel.SetActive(false);
}
public void ShowControllerModel()
{
ControllerModel.SetActive(true);
}
這些方法簡單地啟用或禁用代表了控制器的 GameObject。
最後加入這兩個方法:
public void Vibrate(ushort strength) // 1
{
Controller.TriggerHapticPulse(strength);
}
public void SwitchInteractionObjectTo(RWVR_InteractionObject interactionObject) // 2
{
objectBeingInteractedWith = interactionObject; // 3
objectBeingInteractedWith.OnTriggerWasPressed(this); // 4
}
程式碼解釋如下:
- 這個方法造成了控制器中的壓電線型驅動器(這個詞不是我編造的)振動多次。它振動的時間越長,震動感就越強烈。它的強度是 1-3999。
- 這個方法將啟用的 InteractionObject 換成引數指定的物件。
- 將指定的 InterationObject 變成啟用狀態。
- 在新的 InteractionObject 物件上呼叫 OnTriggerWasPressed() 方法,並傳入當前控制器。
儲存指令碼,回到編輯器。為了讓控制器按照我們的想法工作,還需要做一些調整。
在結構檢視中選中兩個控制器。它們都是[ CameraRig ]的子物件。
給它們各新增一個剛體。這允許它們使用固定連線,並和其它物體進行互動。
反選 Use Gravity,勾選 Is Kinematic。控制器不需要受物理的影響,因為在真實世界中,它們被你抓在手上。
將 RWVR_Interaction 控制器元件提交給兩個手柄。我們待會要配置它。
展開 Controller(left),右鍵點選它,選擇 3D Object > Sphere,為它新增一個球體。
選中球體,命名為 SnapOrigin,按 F 鍵讓它在場景檢視中居中。你會在地板中央看到一個巨大的白色半球體。
設定它的 Position 為 (X:0, Y:-0.045, Z:0.001) ,Scale 設為 (X:0.1, Y:0.1, Z:0.1)。這會將球放到控制器的前端。
刪除 Sphere Collider 元件,因為物理檢查通過程式碼進行。
最後,將它的 Mesh Renderer 修改為 Transparent 材質,讓球體透明。
複製 SnapOrigin,將 SnapOrigin(1) 拖到 Controller(right)上,變成右手柄的子物件。命名為 SnapOrigin。
最後一步是建立控制器,使用它們的模型和 SnapOrigin。
選擇並展開 Controller(left),將它的 SnapOrigin 子物件拖到 Snap Collider Origin 一欄中,將 Model 拖到 Controller Model 一欄。
在 Controller(right) 上重複同樣的動作。
現在來放鬆一下!開啟手柄電源,執行這個場景。
將手柄舉到頭盔前面,看看球體是否能夠看見並和控制器粘在一起。
測試完後,儲存場景,準備進入互動系統的使用!
用互動系統抓取物體
你可能看到附近有這些東西:
你只能看著它們,但無法把它們拿起來。你最好儘快解決這個問題,否則你怎麼去讀我們那本精彩的 Unity 教程呢?:]
為了和這些剛體進行互動,你需要建立一個新的 RWVR_InteractionObject 子類,用它來實現抓和扔的功能。
在 Scripts/RWVR 目錄下建立新的 c# 指令碼,名為 RWVR_SimpleGrab。
用程式碼編輯器開啟它,刪除裡面的 Start() 和 Update() 方法。
將這一句:
public class RWVR_SimpleGrab : MonoBehaviour
修改為:
public class RWVR_SimpleGrab : RWVR_InteractionObject
這樣這個類就繼承了 RWVR_InteractionObject,後者提供了獲得控制器輸入的鉤子,這樣它就能對輸入進行適當的處理。
在類宣告下面宣告幾個變數:
public bool hideControllerModelOnGrab; // 1
private Rigidbody rb; // 2
很簡單:
- 一個標誌,用於表示控制器模型是否應該在該物體被拿起時隱藏。
- 為了效能和簡單起見,快取了剛體元件。
在變數宣告之後新增方法:
public override void Awake()
{
base.Awake(); // 1
rb = GetComponent<Rigidbody>(); // 2
}
- 呼叫基類的 Awake() 方法。這會快取物件的 Transform 元件並檢查 InteractionObject 的 tag 是否賦值。
- 儲存剛體元件,以便後面使用。
然後是一些助手方法,用於將物件用 FixedJoint 附著在手柄上,或者從手柄上放開。
在 Awake() 方法後面新增:
private void AddFixedJointToController(RWVR_InteractionController controller) // 1
{
FixedJoint fx = controller.gameObject.AddComponent<FixedJoint>();
fx.breakForce = 20000;
fx.breakTorque = 20000;
fx.connectedBody = rb;
}
private void RemoveFixedJointFromController(RWVR_InteractionController controller) // 2
{
if (controller.gameObject.GetComponent<FixedJoint>())
{
FixedJoint fx = controller.gameObject.GetComponent<FixedJoint>();
fx.connectedBody = null;
Destroy(fx);
}
}
這兩個方法分別用於:
- 這個方法接收一個控制器作為引數,然後建立一個 FixedJoint 元件新增到手柄上,配置這個連線,使它不是那麼容易掉,最後連線上當前的 InteractionObjecdt。在連線上新增一個力是為了防止使用者將物件移過其他堅固的物體上,否則可能導致一些奇怪的物理問題。
- 將引數指定的控制器的 FixedJoint 元件(如果有的話)斷開。所連線的物件將被刪除,然後銷燬 FixedJoint。
寫完這些方法,我們可以實現來自於基類的幾個 OnTrigger 方法,以處理使用者輸入。首先新增 OnTriggerWasPressed() 方法:
public override void OnTriggerWasPressed(RWVR_InteractionController controller) // 1
{
base.OnTriggerWasPressed(controller); // 2
if (hideControllerModelOnGrab) // 3
{
controller.HideControllerModel();
}
AddFixedJointToController(controller); // 4
}
這個方法在玩家按下扳機抓住一個物件時新增 FixedJoint 連線。程式碼分為幾個階段:
- 覆蓋基類的 OnTriggerWasPressed() 方法。
- 如果 hideControllerModelOnGrab 標誌為 true,隱藏控制器模型。
- 新增一個 FixedJoint 到控制器。
最後一步是新增 OnTriggerWasReleased() 方法:
public override void OnTriggerWasReleased(RWVR_InteractionController controller) // 1
{
base.OnTriggerWasReleased(controller); //2
if (hideControllerModelOnGrab) // 3
{
controller.ShowControllerModel();
}
rb.velocity = controller.velocity; // 4
rb.angularVelocity = controller.angularVelocity;
RemoveFixedJointFromController(controller); // 5
}
這個方法移除引數指定的控制器的 FixedJoint,將控制器的速度傳遞給剛體,以實現真實的拋擲效果。程式碼解釋如下:
- 覆蓋基類的 OnTriggerWasReleased() 方法。
- 呼叫基類方法解綁控制器。
- 如果 hideControllerModelOnGrab 標誌為 true,再次顯示控制器模型。
- 將控制器的速度和角速度傳遞給物件的剛體。這樣當你放開物件時,物件會表現出真實的行為。例如,如果你扔出一個球,你會將手柄從後向前做一個拋物線動作。球應當獲得旋轉和向前的力,就像是在真實世界中你將動能傳遞給它一樣。
- 刪除 FixedJoint。
儲存指令碼,返回編輯器。
骰子和書在 Prefabs 資料夾中都有相應的預製件。在專案檢視中開啟這個資料夾:
選擇 Book 和 Die 預製件,將 RWVR_Simple Grab 元件新增到二者。同時開啟 Hide Controller Model。
儲存場景運行遊戲。嘗試拿起幾本書或骰子,扔到一邊。
在下一節,我將介紹另一種抓取物件的方法:吸附。
拿起物件和吸附物件
在手柄所在的位置和角度拿起東西是可以的,但有時候將手柄吸附到物體的某個位置可能更有用。例如,如果使用者看到一隻槍,當他們拿起槍時會希望槍被指向右邊。這就是 snapping (吸附)的意思。
為了吸附物件,你需要建立另外一個指令碼。在 Scripts/RWVR 目錄建立新的 C# 指令碼,命名為 RWVR_SnapToController。用程式碼編輯器開啟它,刪除 Start() 和 Update() 方法。
將這句:
public class RWVR_SnapToController : MonoBehaviour
改成:
public class RWVR_SnapToController : RWVR_InteractionObject
這允許指令碼具備所有 InteractionObject 的功能。
新增變數宣告:
public bool hideControllerModel; // 1
public Vector3 snapPositionOffset; // 2
public Vector3 snapRotationOffset; // 3
private Rigidbody rb; // 4
- 一個標誌,表示手柄模型是否要在玩家抓住物件時隱藏。
- 當抓住物件時新增的位置。該物件預設會用這個位置吸附到手柄上。
- 同上,只是這個變數用於表示角度。
- 引用了物件的剛體元件。
然後增加方法:
public override void Awake()
{
base.Awake();
rb = GetComponent<Rigidbody>();
}
和 SimpleGrab 指令碼一樣,覆蓋了基類的 Awake() 方法,然後儲存剛體元件。
接下來是幾個助手方法,這才算是這個指令碼的肉戲。
新增如下方法:
private void ConnectToController(RWVR_InteractionController controller) // 1
{
cachedTransform.SetParent(controller.transform); // 2
cachedTransform.rotation = controller.transform.rotation; // 3
cachedTransform.Rotate(snapRotationOffset);
cachedTransform.position = controller.snapColliderOrigin.position; // 4
cachedTransform.Translate(snapPositionOffset, Space.Self);
rb.useGravity = false; // 5
rb.isKinematic = true; // 6
}
這個方法和 SimpleGrab 指令碼中的方法不同,它不使用 FixedJoint 連線,而是將它自己作為控制器的子物件。也就是說控制器和所吸附的物件是無法被外力所打斷的。在這個教程中,這種方式會很穩定,但在你自己的專案中你更應該採取 FixedJoint 連線。
程式碼解釋如下:
- 接收一個控制器引數,用於連線它。
- 將物件的 parent 設定為該控制器。
- 讓物件的方向和控制器保持一定的偏移。
- 讓物件的位置和控制器保持一定的偏移。
- 關閉重力,否則它會從你的手上掉落。
- 開啟運動學特徵。當附著到手柄上後,這個物件不會受福利引擎的影響。
現在來新增放開物件的方法:
private void ReleaseFromController(RWVR_InteractionController controller) // 1
{
cachedTransform.SetParent(null); // 2
rb.useGravity = true; // 3
rb.isKinematic = false;
rb.velocity = controller.velocity; // 4
rb.angularVelocity = controller.angularVelocity;
}
這個方法簡單地將物件從父物件中解除,重置剛體並應用控制器的速度。詳細解釋一下:
- 方法引數指定要鬆開物件的控制器。
- 將物件的父物件解開。
- 重新開啟重力,並再次使物件再次變成非運動學的。
- 應用控制器的速度給物件。
覆蓋如下方法以實現 snapping 操作:
public override void OnTriggerWasPressed(RWVR_InteractionController controller) // 1
{
base.OnTriggerWasPressed(controller); // 2
if (hideControllerModel) // 3
{
controller.HideControllerModel();
}
ConnectToController(controller); // 4
}
程式碼非常簡單:
- 覆蓋 OnTriggerWasPressed(),以新增吸附邏輯。
- 呼叫機類方法。
- 如果 hideControllerModel 標誌為 true,隱藏控制器模型。
- 將物件連線到控制器。
然後是 release 方法:
public override void OnTriggerWasReleased(RWVR_InteractionController controller) // 1
{
base.OnTriggerWasReleased(controller); // 2
if (hideControllerModel) // 3
{
controller.ShowControllerModel();
}
ReleaseFromController(controller); // 4
}
同樣十分簡單:
- 覆蓋 OnTriggerWasReleased() 方法。
- 呼叫基類的方法。
- 如果 hideControllerModel 標誌為 true,重新顯示手柄的模型。
- 將物件從控制器上放開。
儲存指令碼返回編輯器。從 Prefabs 目錄中將 RealArrow 預製件拖到結構檢視。
選擇 arrow,設定它的 position 為 (X:0.5, Y:4.5, Z:-0.8)。它會懸浮在石板上方:
在結構檢視中,將 RWVR_Snap To Controller 元件附加到箭支上,這樣你就可以和它互動,同時將它的 Hide Controller Model 設為 true。最後點選檢視器視窗上方的 Apply 按鈕,將修改應用到該預製件。
對於這個物件,不需要修改 offset,預設它的握持部位就可以了。
儲存並執行場景。抓住箭支,然後扔出去。喚醒你內心野獸吧!
注意,箭支握在手上的位置總是固定的,不管你如何拿起它。
本教程的內容就到此為止了,試玩一下游戲,感受一下互動中的變化。
結束
從此處下載最終專案。
在本教程中,你學習瞭如何建立可擴充套件的互動系統,你已經通過這個互動式系統找出了幾種抓取物品的方法。
在第二部分的教程中,你將學習如何擴充套件這個系統,製作一套功能完備的弓和箭,以及一個功能完備的揹包。
在這本書中,你將建立 4 個完整的遊戲:
- 一個 twin-stick 射擊遊戲
- 一個第一人稱射擊遊戲
- 一個塔防遊戲(帶 VR 支援!)
- 一個 2D 平臺遊戲
學完這本書後,你將能夠編寫自己的遊戲執行在 Windows、macOS、iOS及更多平臺。
本書完全針對 Unity 初學者,將他們的 Unity 技能升級到專家水準。本書假設你有一定的程式設計經驗(任何語言)。
感謝你閱讀本教程!如果有任何意見和建議,請留言!