Unity俯視角射擊游戲腳本實戰
譯文簡介
Unity的強大功能主要得益于其豐富的腳本語言。你可以使用腳本來處理用戶輸入、移動場景中的物體、檢測碰撞、使用預制對象以及沿場景四周投射定向光線來增強你的游戲邏輯等。這聽起來有點令人生畏,但由于Unity官方提供了良好的API文檔支持,所以完成上述任務變得輕而易舉——即使對于Unity開發新手亦然!
在本教程中,你將創建一個基于俯視角的Unity射擊游戲。游戲中,你將使用Unity #腳本來生成敵人、控制玩家、發射炮彈以及實現游戲其他重要方面的控制。
【提示】本文假設你有一個C#或類似的編程語言開發經驗。另外,本文示例游戲使用Unity 5.3+開發而成。
準備
首先,請下載本文示例啟動項目(http://www.raywenderlich.com/wp-content/uploads/2016/03/BlockBuster.zip)并解壓縮。為了在Unity中打開啟動器項目,你可以從【Start Up Wizard】下單擊【Open】命令,然后導航到項目文件夾。或者,您可以直接從路徑【BlockBuster/Assets/Scenes】下打開文件Main.unity。
下圖給出您的示例工程中場景的初始樣子。
首先,請觀察一下場景視圖周圍的情況。有一個小的場地,這將作為本示例游戲的戰場;還有一部相機,當玩家在戰場上走動時相機會跟隨他們。如果您的布局與截圖有所不同,你可以選擇右上角的下拉菜單,把其中的選項改為「2 by 3」。
沒有英雄存在,那算是什么游戲呢?因此,你的第一個任務是創建一個游戲對象,以表示戰場中的英雄。
創建玩家對象
在【Hierarchy】中,點擊【Create】按鈕,然后從「3D」部分選擇「Sphere」。將球體拖動到坐標位置(0,0.5,0),并將其命名為Player,如圖所示。
從現在起,你將引用這一個球體作為玩家(Player)對象。
Unity使用組件系統來構建它的游戲對象;這意味著,在一個場景中的所有對象都可以通過組件的任何組合來創建。這些組合包括:用來描述一個對象位置的變換(Transform);網格過濾器(Mesh Filter),其中包含圖形幾何體或者任何個數的腳本(Scripts)。
玩家(Player)對象需要響應與場景中的其他對象的碰撞。
要做到這一點,請從【Hierarchy】中選擇「Player」。然后,從【Inspector】選項卡下點擊【Add Component】按鈕。在【Physics】類別中,選擇【Rigidbody】組件。這將使Player對象為Unity的物理引擎所控制。
現在,請更改Rigidody的值,如下所示:
1.Drag:1
2.Angular Drag:0
3.Constraints: Freeze Position:Y
編寫玩家運動腳本
現在你有了一個Player對象。接下來,我們來編寫腳本以便接收鍵盤輸入,進而移動玩家。
在項目瀏覽器(Project Browser)中點擊【Create】按鈕,然后選擇「Folder」。命名新文件夾為「Scripts」并在名為「Player」的文件夾下創建一個子文件夾。接下來,在「Player」文件夾下,點擊【Create】按鈕,并選擇【C# Script】。命名新的腳本為PlayerMovement。順序大致如下圖所示:
【提示】Player對象將包含多個腳本,各自負責其行為的不同部分。在一個單獨的文件夾下保存所有相關的腳本,使項目中文件更容易管理,并減少混亂。
現在,請雙擊PlayerMovement.cs腳本。在Mac上,這將打開隨同Unity一起打包的MonoDevelop開發環境;在Windows上,它應該打開Visual Studio。本教程假設你使用MonoDevelop。
在類中聲明下面兩個公共變量:
public float acceleration;
public float maxSpeed;
其中,acceleration用于描述玩家的速度如何隨時間增加,而maxSpeed代表“速度極限”。制作一個public類型的變量將會使之顯示于【Inspector】之中,這樣你就可以通過Unity界面來設置它的值,并根據需要調整它。
緊接著上面的聲明,再聲明以下變量:
private Rigidbody rigidBody;
private KeyCode[] inputKeys;
private Vector3[] directionsForKeys;
注意,私有變量無法通過【Inspector】進行設置。因此,需要由程序員在適當的時候以完全手動方式對它們進行初始化。
接下來,把Start()函數修改成如下所示的代碼:
- void Start () {
- inputKeys = new KeyCode[] { KeyCode.W, KeyCode.A, KeyCode.S, KeyCode.D };
- directionsForKeys = new Vector3[] { Vector3.forward, Vector3.left, Vector3.back, Vector3.right };
- rigidBody = GetComponent<Rigidbody>();
- }
上述代碼中的inputKeys數組包含了您將用于移動玩家的鍵碼。directionsForKeys包含相應于每個鍵的方向;例如,按下W用于向前移動對象。至于最后一行代碼,你還記得前面添加的剛體嗎?這是可以得到對該組件的引用的一種方式。
要移動玩家,你就必須處理來自于鍵盤的輸入。
現在,請重命名函數Update()為FixedUpdate(),并給它添加以下代碼:
- // 1
- void FixedUpdate () {
- for (int i = 0; i < inputKeys.Length; i++){
- var key = inputKeys[i];
- // 2
- if(Input.GetKey(key)) {
- // 3
- Vector3 movement = directionsForKeys[i] * acceleration * Time.deltaTime;
- }
- }
- }
這里發生了幾件重要的事情:
1.FixedUpdate()函數是幀速率獨立的,在操作剛體時應該調用此函數。
2.這個循環檢查是否有任何輸入鍵被按下。
3.在這里,你得到按鍵的方向,并把它乘以加速度和完成最后一幀的修復所耗費的秒數。這將產生您創建一個矢量,正是使用它來移動Player對象。
注意,當您創建一個新的Unity腳本時,你實際上是創建一個新的MonoBehaviour對象。如果你熟悉iOS編程世界,那么你會知道它是一個UIViewController的等價物;也就是說,你可以使用這個對象來響應Unity內的事件,從而訪問你自己的數據對象。
MonoBehaviours有很多不同的方法,它們分別對各種事件作出響應。舉例來說,當MonoBehaviour實例化時如果你想初始化一些變量,那么你就可以實現方法Awake()。在MonoBehaviour被禁用時為了運行代碼,你可以實現方法OnDisable()。
【提示】如果你想研究這些事件的完整列表,請訪問Unity官方文檔,地址是 http://docs.unity3d.com/ScriptReference/MonoBehaviour.html。
如果你是游戲編程新手,你可能會問自己,為什么必須乘以Time.deltaTime?一般的規律是,當你每隔固定的時間幀數執行一個動作時,你需要乘以Time.deltaTime。在本例情況下,你想要沿按鍵方向加速移動玩家,加速數值為固定的更新時間。
接下來,在方法FixedUpdate()下面添加以下方法:
- void movePlayer(Vector3 movement) {
- if(rigidBody.velocity.magnitude * acceleration > maxSpeed) {
- rigidBody.AddForce(movement * -1);
- } else {
- rigidBody.AddForce(movement);
- }
- }
上述方法用于對剛體施加力作用,使其移動。如果當前速度超過maxSpeed,力會變成相反的方向......這有點像一個速度極限。
現在,請在方法FixedUpdate()中,在if語句的結束括號之前,添加以下行:
movePlayer(movement);
很好!回到Unity中。然后,在項目瀏覽器中,將PlayerMovement腳本拖動到【Hierarchy】中的Player對象上。然后,使用【Inspector】來把「Acceleration」的值設置為625并把最大速度(Max Speed)修改為4375:
現在,請運行一下游戲場景,并試著使用鍵盤上的WASD鍵移動玩家對象,觀察效果:
到目前,我們僅僅實現了幾行代碼,這已經算是一個相當不錯的結果了!
然而,現在有一個明顯的問題:玩家可以移出人們的視線之外,這在打壞人時是個麻煩事。
編寫攝相機腳本
在「Scripts」文件夾中,創建一個名為CameraRig的新的腳本,并將其附加到主攝像機(Main Camera)上。
【提示】在選擇【Scripts】文件夾情況下,點擊工程瀏覽器中的【Create】按鈕,然后選擇【C# Script】。命名新的腳本為「CameraRig」。最后,把此腳本拖動到「Main Camera」對象上即可。
現在,在新創建的CameraRig類中添加下列變量:
public float moveSpeed;
public GameObject target;
private Transform rigTransform;
正如你可能已經猜到的,moveSpeed代表了相機跟蹤目標的速度——這可能是場景里面的任何游戲對象。
接下來,在Start()函數中添加以下代碼行:
rigTransform= this.transform.parent;
此代碼獲取場景層次樹中的到父Camera對象的引用。場景中的每個對象具有一個變換(Transform),其中描述了一個對象的位置旋轉和縮放等信息。
然后,在與上面同一個腳本文件中添加下面的方法:
- void FixedUpdate () {
- if(target == null){
- return;
- }
- rigTransform.position = Vector3.Lerp(rigTransform.position, target.transform.position,
- Time.deltaTime * moveSpeed);
- }
這部分CameraRig移動代碼要比在PlayerMovement中的簡單一些。這是因為你不需要一個剛體;只需要在rigTransform的位置和目標之間進行插值就足夠了。
Vector3.Lerp()函數使用了空間中的兩個點,還有一個界于[0,1]范圍內的浮點數(它描述了沿兩個端點的中間的某一點)作參數。左端點為0,右側端點是1。于是,把0.5傳遞給Lerp()函數將正好返回位于兩個端點中間的一個點。
這會將rigTransform移到距目標位置更近一些,而且略有緩動效果。總之,相機跟隨玩家運動。
現在,返回到Unity。確保層次樹(Hierarchy)中的主攝像機(Main Camera)仍處于選中狀態。在【Inspector】中,把Move Speed(移動速度)設置為8,并把Target(目標)設置為Player:
再次運行游戲工程,沿場景四處移動;你會注意到,無論玩家走到哪里,相機都能夠平滑地跟隨目標變換。
創建敵人對象
一款沒有敵人的射擊游戲很容易被擊敗,當然也很無聊。所以,現在我們來通過單擊頂部菜單中的【GameObject\3D Object\Cube】創建一個用于表示敵人的立方體對象。然后,把此立方體重命名為「Enemy」,并添加一個Rigidbody(剛體)組件。
在【Inspector】中,首先設置立方體的變換為(0,0.5,4)。并在剛體組件的「Constraints」部分的「Freeze Position」類別下勾選「Y」選擇對應的復選框。
很好,現在使你的敵人氣勢洶洶地走動吧。然后,在【Scripts】文件夾下創建一個命名為「Enemy」的腳本。現在,你應該對這種操作很熟練了;恕不再贅述。
接下來,在類內部添加下列公共變量:
public float moveSpeed;
public int health;
public int damage;
public Transform targetTransform;
你也許可以很容易地確定出這些變量所代表的含義。你可以使用如前面一樣的moveSpeed變量技巧來操縱攝像機,而且它們的效果相同。Health和damage這兩個變量分別用于確定何時敵人死了以及他們死多少會傷害玩家。最后,變量targetTransform用于引用玩家對象對應的變換。
說到玩家對象,你需要創建一個類來描述敵人想破壞的所有玩家的健康值。
在項目瀏覽器中,選擇「Player」文件夾,并創建一個名為「Player」的新腳本。這個腳本會響應于碰撞,并跟蹤玩家的健康值。現在,我們通過雙擊此腳本來編輯它。
添加下列公共變量來保存玩家的健康值:
public int health = 3;
這樣便提供了玩家健康值的默認值,但它也可以通過【Inspector】進行修改。
為了處理沖突,添加以下方法:
- void collidedWithEnemy(Enemy enemy) {
- // Enemy attack code
- if(health <= 0) {
- // Todo
- }
- }
- void OnCollisionEnter (Collision col) {
- Enemy enemy = col.collider.gameObject.GetComponent<Enemy>();
- collidedWithEnemy(enemy);
- }
當兩個剛體發生碰撞時,OnCollisionEnter()即被觸發。其中,Collision參數中包含了諸如接觸點和沖擊速度相關的信息。在本示例情況下,我們只對碰撞物體中的Enemy組件感興趣,所以可以調用collidedWithEnemy()并執行攻擊邏輯——接下來就會實現這種邏輯。
切換回文件Enemy.cs,并添加以下方法:
- void FixedUpdate () {
- if(targetTransform != null) {
- this.transform.position = Vector3.MoveTowards(this.transform.position, targetTransform.transform.position, Time.deltaTime * moveSpeed);
- }
- }
- public void TakeDamage(int damage) {
- health -= damage;
- if(health <= 0) {
- Destroy(this.gameObject);
- }
- }
- public void Attack(Player player) {
- player.health -= this.damage;
- Destroy(this.gameObject);
- }
你已經熟悉了FixedUpdate()函數,略有不同的是現在使用的是MoveTowards()而不是Lerp()函數。這是因為敵人應該一直以相同的速度移動而不會在接近目標時出現快速移動。當敵人被彈丸擊中時,TakeDamage()即被調用;當敵人到達值為0的健康值時他會自我毀滅。Attack()函數的實現邏輯是與之很類似的——對玩家進行傷害,然后敵人破壞自身。
切換回Player.cs。然后,在函數collidedWithEnemy()中,使用下面代碼替換注釋// Enemy attack code:
enemy.Attack(this);
游戲中,玩家將受到傷害,而敵人在該過程中將自我毀滅。
切換回Unity。把Enemy腳本附加到 Enemy對象上;并在【Inspector】中,針對Enemy對象設置以下值:
1.Move Speed:5
2.Health:2
3.Damage:1
4.Target Transform:Player
現在,你應該能夠自己做這一切了。結束后,你可以與文后完整的工程源碼進行比較。
在游戲中,敵人與玩家碰撞,從而實現一種有效的敵對攻擊。使用Unity的物理碰撞檢測幾乎是一個很簡單的任務。
最后,在層次結構中把Player腳本附加到Player對象。
運行游戲工程,并留意在控制臺上輸出的結果:
當敵人接觸到玩家時,它能夠成功地進行攻擊,并把玩家的健康值變量降低到2。但是,現在在控制臺中拋出一個NullReferenceException異常,錯誤指向Player腳本:
哈哈,現在玩家不僅可以與敵人碰撞,也可能與游戲世界中的其他部分,如戰場,發生碰撞!這些游戲對象并沒有Enemy腳本,因此GetComponent()函數將返回null。
接下來,打開文件Player.cs。然后,在OnCollisionEnter()函數中,把collidedWithEnemy()函數調用使用一個if語句包括起來,如下所示:
- if(enemy) {
- collidedWithEnemy(enemy);
- }
此時,異常消失!
使用預制
只是簡單地在戰場上跑來跑去,而且避開敵人;這只能算是一個一邊倒的游戲。現在,我們來武裝一下玩家,使之能夠作戰。
單擊層次結構中的【Create】按鈕,并選擇【3D Object/Capsule】。命名它為Projectile,并給它指定下列變換值:
1. Position:(0, 0, 0)
2. Rotation:(90, 0, 0)
3. Scale:(0.075, 0.246, 0.075)
每當玩家射擊時,他就會發射Projectile(炮彈)的一個實例。要做到這一點,你需要創建一個預制(Prefab)。不像場景中你已經擁有的其他對象,預制對象是根據游戲邏輯需要而創建的。
現在,在文件夾「Assets」下創建一個新的文件夾,名為Prefabs。現在,把Projectile對象拖動到這個文件夾上。就是這樣:你創建了一個預制!
您的預制還需要一點腳本。現在,在【Scripts】文件夾內創建一個名為「Projectile」的新腳本,并添加下面的類變量:
public float speed;
public int damage;
Vector3 shootDirection;
就像目前為止在本教程中任何可移動的物體一樣,這個對象也會有速度和傷害對應的變量,因為它是戰斗邏輯的一部分。其中,shootDirection矢量決定了炮彈將向哪兒發射。
在類中實現下面的方法即可使這個矢量發揮作用:
- // 1
- void FixedUpdate () {
- this.transform.Translate(shootDirection * speed, Space.World);
- }
- // 2
- public void FireProjectile(Ray shootRay) {
- this.shootDirection = shootRay.direction;
- this.transform.position = shootRay.origin;
- }
- // 3
- void OnCollisionEnter (Collision col) {
- Enemy enemy = col.collider.gameObject.GetComponent<Enemy>();
- if(enemy) {
- enemy.TakeDamage(damage);
- }
- Destroy(this.gameObject);
- }
在上面的代碼中發生了下面的事情:
1.炮彈在游戲中的運動方式與其他對象不同。它不具有一個目標,或者一直對它施加一些力;相反,它在其整個生命周期中的按照預定方向進行運動。
2.在這里,我們設置了預制對象的起始位置和方向。Ray參數看上去似乎很神秘吧,但你很快就會知道它是如何計算出來的。
3.如果一個炮彈與敵人發生碰撞,它會調用TakeDamage(),并進行自我毀滅。
在場景層次中,把Projectile腳本附加到Projectile游戲對象上。設置它的速度為0.2,并把損壞值設置為1,然后點擊【Inspector】頂部的【Apply】按鈕。這將針對這個預制的所有實例保存剛才所做的更改。
現在,請從場景層次樹中刪除Projectile對象,因為我們不再需要它了。
發射炮彈
現在,你既然已經擁有了可以移動并施加傷害能力的預制對象,那么,接下來你就可以開始考慮實現發射炮彈相關的編程了。
在Player文件夾下,創建一個名為PlayerShooting的新腳本,并將其附加到場景中的Player游戲對象。然后,在Player類中,聲明以下變量:
public Projectile projectilePrefab;
public LayerMask mask;
第一個變量將包含對前面創建的Projectile預制對象的引用。每當玩家發射炮彈時,您將從這個預制創建一個新的實例。mask變量是用來篩選游戲對象(GameObject)的。
現在,我們要介紹一下光線投射的問題。何謂光線投射(casting Ray)?這是什么魔法?
其實,并不存在什么黑魔法。但是,有時候在你的游戲中,你的確需要知道是否在一個特定方向上存在碰撞。要做到這一點,Unity在您指定的方向上能夠從某一個點投出一條看不見的射線。你可能會遇到很多與射線相交的游戲對象;因此,使用篩選器可以過濾掉任何不需要參與碰撞的對象。
光線投射是非常有用的,并且可以用于各種用途。它們常用于測試是否另一名玩家已經被炮彈擊中;而且,你也可以使用它們來測試是否在鼠標指針下方存在任何的幾何形狀。要更多地了解關于光線投射的內容,請參考一下Unity官方網站提供的在線培訓視頻(https://unity3d.com/learn/tutorials/modules/beginner/physics/raycasting)。
下圖顯示了從一個立方體到一個錐體的光線投射情況。由于射線上有一個圖標掩碼,因此它忽略掉游戲對象而系統給出的提示是擊中了錐體:
接下來,我們需要創建自己的射線了。
把如下代碼添加到文件PlayerShooting.cs:
- void shoot(RaycastHit hit){
- // 1
- var projectile = Instantiate(projectilePrefab).GetComponent<Projectile>();
- // 2
- var pointAboveFloor = hit.point + new Vector3(0, this.transform.position.y, 0);
- // 3
- var direction = pointAboveFloor - transform.position;
- // 4
- var shootRay = new Ray(this.transform.position, direction);
- Debug.DrawRay(shootRay.origin, shootRay.direction * 100.1f, Color.green, 2);
- // 5
- Physics.IgnoreCollision(GetComponent<Collider>(), projectile.GetComponent<Collider>());
- // 6
- projectile.FireProjectile(shootRay);
- }
概括來看,上面的代碼主要實現如下功能:
1. 實例化一個炮彈預制并獲得它的Projectile組件,從而可以把它初始化。
2. 這個坐標點總是使用像(X,0.5,Z)這樣的格式。其中,X和Z坐標位于地面上,正好對應于射線投射擊中的鼠標點擊位置的坐標。這里的計算是很重要的,因為炮彈必須平行于地面;否則,你會向下射擊,而只有外行的玩家才會出現向地面射擊的情況。
3. 計算從游戲物體Player指向pointAboveFloor的方向。
4. 創建一條新的射線,并通過其原點和方向來共同描述炮彈軌跡。
5. 這行代碼告訴Unity的物理引擎忽略玩家與炮彈之間的碰撞。否則,在炮彈飛出去前將調用Projectile腳本中的OnCollisionEnter()方法。
6. 最后,設置炮彈的運動軌跡。
【注意】當光線投射不可見時,你可以使用Debug.DrawRay()方法來輔助調試程序,因為它可以幫助您更直觀地觀察光線的外觀和它所擊中的對象。
好,現在既然發射炮彈的邏輯已經實現,請繼續添加下面的方法來讓玩家真正扣動扳機:
- // 1
- void raycastOnMouseClick () {
- RaycastHit hit;
- Ray rayToFloor = Camera.main.ScreenPointToRay(Input.mousePosition);
- Debug.DrawRay(rayToFloor.origin, rayToFloor.direction * 100.1f, Color.red, 2);
- if(Physics.Raycast(rayToFloor, out hit, 100.0f, mask, QueryTriggerInteraction.Collide)) {
- shoot(hit);
- }
- }
- // 2
- void Update () {
- bool mouseButtonDown = Input.GetMouseButtonDown(0);
- if(mouseButtonDown) {
- raycastOnMouseClick();
- }
- }
讓我們按上面編號進行逐個解釋:
1.這個方法把射線從攝相機射向鼠標點擊的位置,然后檢查是否射線相交于符合給定LayerMask掩碼值的游戲對象。
2.在每次更新中,腳本都會檢查一下鼠標左鍵按下情況。如果發現存在按下的情況,就調用raycastOnMouseClick()方法。
現在,請返回到Unity中,并在【Inspector】中設置下列變量:
Projectile Prefab:引用文件夾prefab下的Projectile;
Mask:Floor
【注意】Unity使用數量有限的預定義掩碼——也稱為層。
你可以通過點擊一個游戲物體的【Layer】下拉菜單然后選擇【Add Layer】(添加圖層)來定義你自己的掩碼:
您也可以通過從【Layer】下拉菜單中選擇一個層來給游戲對象分配掩碼:
有關Unity3d引擎中層的更多的信息,請參考官方文檔,地址是http://docs.unity3d.com/Manual/Layers.html。
現在,請運行示例項目并隨意發射炮彈!你會注意到:炮彈按照希望的方向發射,但看起來還缺少點什么,不是嗎?
如果炮彈是沿著其發射的方向行進的,那將酷多了。為了解決這個問題,打開Projectile.cs腳本并添加下面的方法:
- void rotateInShootDirection() {
- Vector3 newRotation = Vector3.RotateTowards(transform.forward, shootDirection, 0.01f, 0.0f);
- transform.rotation = Quaternion.LookRotation(newRotation);
【注意】RotateTowards非常類似于MoveTowards,但它把矢量作為方向,而不是位置。此外,你并不需要一直改變旋轉;因此,使用一個接近零的步長值就足夠了。在Unity中實現旋轉變換是使用四元組實現的,這已超出了本教程的討論范圍。在本教程中,你只需要知道在涉及三維旋轉計算時使用四元組的優勢超過矢量即可。當然,如果你有興趣更多地了解關于四元組以及它們有何用處,請參考這篇優秀的文章,地址是http://developerblog.myo.com/quaternions/。
接下來,在FireProjectile()方法的結束處,添加對rotateInShootDirection()方法的調用。 現在,FireProjectile()方法看起來應該像下面這樣:
- public void FireProjectile(Ray shootRay) {
- this.shootDirection = shootRay.direction;
- this.transform.position = shootRay.origin;
- rotateInShootDirection();
- }
再次運行游戲,并沿幾個不同的方向發射炮彈。此時,炮彈將指向它們發射的方向。現在,你可以清除代碼中的Debug.DrawRay調用了,因為你不再需要它們了。
生成更多敵人對象
只有一個敵人的游戲并不具有挑戰性。但現在,你已經知道了預制的用法。于是,你可以生成任意數目的對手了!
為了讓玩家不斷猜想,你可以隨機地控制每個敵人的健康值、速度和位置等。
現在,使用命令【GameObject】-【Create Empty】創建一個空的游戲對象。命名它為「EnemyProducer」,并添加一個Box碰撞器組件。最后,在【Inspector】設置其值如下:
1. Position:(0, 0, 0)
2. Box Collider:
3. Is Trigger:true
4. Center:(0, 0.5, 0)
5. Size:(29, 1, 29)
上面你附加的這個碰撞器實際上在戰場中定義了一個特定的3D空間。為了看到這個對象,請從層次結構樹下選擇【Enemy Producer】游戲物體;于是,在場景視圖中你會看到這個對象,如下圖所示。
圖中用綠線框出的部分代表了一個碰撞器
現在,你要編寫一個腳本實現沿X軸和Z軸方向選取空間中的一個隨機位置并實例化一個敵人預制。
創建一個名為EnemyProducer的新腳本,并將其附加到游戲對象EnemyProducer。然后,在新設置的類內部,添加以下實例成員:
public bool shouldSpawn;
public Enemy[] enemyPrefabs;
public float[] moveSpeedRange;
public int[] healthRange;
private Bounds spawnArea;
private GameObject player;
第一個變量控制啟用還是禁用敵人對象的生成。該腳本將從enemyPrefabs中選擇一個隨機的敵人預制并創建其實例。接下來的兩個數組將分別指定速度和健康值的最小值和最大值。生成敵人的地方是你在場景視圖中看到的綠色框。最后,你需要一個到玩家Player的引用,并把它作為目標參數傳遞給敵人對象。
在腳本中,接著定義以下方法:
- public void SpawnEnemies(bool shouldSpawn) {
- if(shouldSpawn) {
- player = GameObject.FindGameObjectWithTag("Player");
- }
- this.shouldSpawn = shouldSpawn;
- }
- void Start () {
- spawnArea = this.GetComponent<BoxCollider>().bounds;
- SpawnEnemies(shouldSpawn);
- InvokeRepeating("spawnEnemy", 0.5f, 1.0f);
- }
SpawnEnemies()方法獲取到標簽為Player的游戲對象的引用,并確定是否應該生成一個敵人。
Start()方法初始化敵人生成的位置并在游戲開始0.5秒之后調用一個方法。每一秒它都會被反復調用。除了作為一個setter方法外,SpawnEnemies()方法還得到一個到標簽為「Player」的游戲對象的引用。
注意,到現在為止,玩家游戲對象尚未標記。現在,就要做這件事情。請從【Hierarchy】中選擇Player對象,然后在【Inspector】選項卡中從「Tag」下拉菜單中選擇Player,如下圖所示。
現在,你需要編寫實際的生成單個敵人的代碼。
打開Enemy腳本,并添加下面的方法:
- public void Initialize(Transform target, float moveSpeed, int health) {
- this.targetTransform = target;
- this.moveSpeed = moveSpeed;
- this.health = health;
- }
這個方法充當用于創建對象的setter方法。下一步:要編寫生成成群的敵人的代碼。打開EnemyProducer.cs文件,并添加以下方法:
- Vector3 randomSpawnPosition() {
- float x = Random.Range(spawnArea.min.x, spawnArea.max.x);
- float z = Random.Range(spawnArea.min.z, spawnArea.max.z);
- float y = 0.5f;
- return new Vector3(x, y, z);
- }
- void spawnEnemy() {
- if(shouldSpawn == false || player == null) {
- return;
- }
- int index = Random.Range(0, enemyPrefabs.Length);
- var newEnemy = Instantiate(enemyPrefabs[index], randomSpawnPosition(), Quaternion.identity) as Enemy;
- newEnemy.Initialize(player.transform,
- Random.Range(moveSpeedRange[0], moveSpeedRange[1]),
- Random.Range(healthRange[0], healthRange[1]));
- }
這個spawnEnemy()方法所做的就是選擇一個隨機的敵人預制,在隨機位置實例化并初始化腳本Enemy中的公共變量。
現在,腳本EnemyProducer.cs快要準備好了!
返回到Unity中。通過把Enemy對象從【Hierarchy】拖動到【Prefabs】文件夾創建一個Enemy預制。然后,從場景中移除Enemy對象——你不需要它了。接下來,設置Enemy Producer腳本中的公共變量:
1. Should Spawn:True
2. Enemy Prefabs:
Size:1
Element 0:引用敵人預制
3. Move Speed Range:
Size:2
Element 0:3
Element 1:8
4. Health Range:
Size:2
Element 0:2
Element 1:6
現在,運行游戲并注意觀察。你會注意到場景中無休止地出現成群的敵人!
好吧,這些立方體看起來還不算非常可怕。現在,我們再來添加一些細節修飾。
在場景中創建一個三維圓柱(Cylinder)和一個膠囊(Capsule)。分別命名為「Enemy2」和「Enemy3」。就像前面你針對第一個敵人所做的那樣,向這兩個對象分別都添加一個剛體組件和一個Enemy腳本。然后,選擇Enemy2,并在【Inspector】中像下面這樣更改它的配置:
1. Scale:(0, 0.5, 0)
2. Rigidbody:
Use Gravity:False
Freeze Position:Y
Freeze Rotation:X, Y, Z
3. Enemy Component:
Move Speed: 5
Health: 2
Damage: 1
Target Transform: None
現在,針對Enemy3也進行與上面同樣的設置,但是把它的Scale設置成0.7,如下圖所示。
接下來,把他們轉換成預制,就像你操作最開始的那個敵人那樣,并在「Enemy Producer」中引用它們。在【Inspector】中的值應該像下面這樣:
Enemy Prefabs:
Size: 3
Element 0: Enemy
Element 1: Enemy2
Element 2: Enemy3
再次運行游戲;現在,你會觀察到在場景中生成不同的預制。
其實,在你意識到你是不可戰勝的之前,不會花費太長的時間!
開發游戲控制器
現在,您已經能夠射擊、移動,而且能夠把敵人放在指定位置。在本節中,你將實現一個基本的游戲控制器。一旦玩家“死”了,它將重新啟動游戲。但首先,你必須建立一種機制以通知所有有關各方——玩家已達到0健康值。
現在,打開Player腳本,并在類聲明上方添加如下內容:
using System;
然后,在類中添加以下新的公共事件:
public event Action<Player> onPlayerDeath;
【提示】事件是C#語言中的重要功能之一,讓你向所有監聽者廣播對象中的變化。要了解如何使用事件,你可以參考一下官方的事件培訓視頻(https://unity3d.com/learn/tutorials/topics/scripting/events)。
接下來,編輯collidedWithEnemy()方法,使之最終看起來具有像下面這樣的代碼:
- void collidedWithEnemy(Enemy enemy) {
- enemy.Attack(this);
- if(health <= 0) {
- if(onPlayerDeath != null) {
- onPlayerDeath(this);
- }
- }
事件為對象之間的狀態變化通知提供了一種整潔的實現方案。游戲控制器對上述聲明的事件是很感興趣的。在Scripts文件夾中,創建一個名為GameController的新腳本。然后,雙擊該文件進行編輯,并給它添加下列變量:
public EnemyProducer enemyProducer;
public GameObject playerPrefab;
腳本在生成敵人時需要進行一定的控制,因為一旦玩家喪生再生成敵人是沒有任何意義的。此外,重新啟動游戲意味著你將不得不重新創建玩家,這意味著……是的,你要通過把玩家變成預制來更靈活地實現這一目的。
于是,請添加下列方法:
- void Start () {
- var player = GameObject.FindGameObjectWithTag("Player").GetComponent<Player>();
- player.onPlayerDeath += onPlayerDeath;
- }
- void onPlayerDeath(Player player) {
- enemyProducer.SpawnEnemies(false);
- Destroy(player.gameObject);
- Invoke("restartGame", 3);
- }
在Start()方法中,該腳本先獲取到Player腳本的引用,并訂閱你先前創建的事件。一旦玩家的健康值達到0, onPlayerDeath()方法即被調用,從而停止敵人的生成,從場景中移除Player對象和并在3秒鐘后調用restartGame()方法。
最后,重新啟動游戲的動作實現如下:
- void restartGame() {
- var enemies = GameObject.FindGameObjectsWithTag("Enemy");
- foreach (var enemy in enemies)
- {
- Destroy(enemy);
- }
- var playerObject = Instantiate(playerPrefab, new Vector3(0, 0.5f, 0), Quaternion.identity) as GameObject;
- var cameraRig = Camera.main.GetComponent<CameraRig>();
- cameraRig.target = playerObject;
- enemyProducer.SpawnEnemies(true);
- playerObject.GetComponent<Player>().onPlayerDeath += onPlayerDeath;
在這里,我們做了一些清理工作:摧毀場景中的所有敵人,并創建一個新的Player對象。然后,重新指定攝像機的目標為玩家對象,恢復敵人生成支持,并為游戲控制器訂閱玩家死亡的事件。
現在返回到Unity,打開Prefebs文件夾,更改所有敵人預制為標簽Enemy。接下來,通過拖動Player游戲對象到Prefebs文件夾使玩家變成預制。再創建一個空的游戲對象,將其命名為GameController,并將您剛剛創建的腳本附加到其上。綁定【Inspector】中所有對應的需要的引用。
現在,你應該很熟悉這種模式了。建議你試著自己實現引用,再次運行游戲。請觀察游戲控制器是如何實現游戲控制的。
故事至此結束;你已經成功地使用腳本實現了你的第一個Unity游戲!祝賀你!
小結
本文示例工程完整的下載地址是http://www.raywenderlich.com/wp-content/uploads/2016/03/BlockBusterFinal.zip。
現在,你應該對編寫一個簡單的動作游戲所需要的內容有了一個很好的理解。實際上,制作游戲決不是一個簡單的任務;它肯定需要大量的工作,而腳本只是把一個項目實現為一款真正的游戲所必需的要素之一。為了進一步添加游戲修飾效果,還需要將動畫和漂亮的UI及粒子效果等添加到您的游戲中。當然,要實現一款真正意義上的商業游戲,您還要克服更多的困難。