【學習筆記】Unity & C#異常處理(下)

二、【我隊友呢??】——針對非預期事件的安全校驗

2.1 管控非預期事件

上一章,我們討論了有關組件的各類異常的處理。雖然“組件丟失”之類的問題顯得十分具體,但它們實際也反映了更寬泛的情況——遊戲中可能時常會發生“非預期事件

一款出色的遊戲,應該是穩定,健壯而鮮見bug的;無論玩家作出何種複雜的操作,都應當能夠避免異常問題的出現。因此,我們在遊戲程序的設計與實現中,必須爲程序賦予一種“警覺性”,使其能夠隨時發現並處理遊戲內事物的非預期狀態。

從這裏開始,我們將會研究對遊戲內“非預期事件”的查驗和管控,這是保證遊戲程序穩定性的關鍵所在;這裏默認大家對Unity的操作和腳本編程具有一定經驗和熟練度,並且有一些具體遊戲功能的編碼經驗。如果你感到理解起來有難度,可以自行查閱相關資料,或者根據書本、視頻教程等嘗試寫一兩個小的遊戲demo,再回來閱讀下面的內容。

2.2 非預期狀態:不能對已經陣亡的士兵下達指令!

Talk is cheap,在編程問題上空談無益。我們直接來看一個情境。

在一款戰術策略遊戲中,我們希望將若干名我方士兵存儲爲一個列表,稱爲一個【編隊】;之後,玩家可以對此【編隊】內的所有士兵下達集體性指令。例如,玩家點擊地圖上某個地點,編隊內的所有士兵都開始移動,並向該地點進發;玩家點擊一名敵人,編隊內的所有士兵對其發動攻擊。

然而,在實際的遊戲中,這些士兵的狀態決不會是一成不變的。

例如,根據生命值是否爲零,可以將士兵的狀態分爲存活死亡

      一旦士兵在某一時刻生命值歸零,即判定爲戰死,此時該士兵僅剩的任務就是執行自身的死亡動畫,而與士兵相關的其它任何事件此後都與他無關了。此時,我們顯然不希望一名已經陣亡,正在執行倒地動畫的士兵重新收到一份攻擊指令,然後以“詐屍”的方式向敵人開出一槍——那可實在是太滑稽了。

    如果在程序中不加註意,類似的滑稽事件將會數不勝數。例如,我方牧師發射的一枚治療飛彈可能會打中一名死去的友軍,然後將其生命值從0加回正值,從而使友軍起死回生;我方士兵發射一枚鎖定敵人的導彈後,如果敵人在導彈命中之前就死亡並消失,導彈就會失去追蹤目標,繼而茫然無措地飄在半空。

是不是有夠滑稽?

至此,相信你已經充分認識到了遊戲內異常處理的複雜性!不過,我們可以從簡單的地方做起。

例如,如何防止一名死去的士兵重新收到作戰指令?很簡單,我們需要在有士兵死亡時,將陣亡的個體從編隊列表中移除。

到這裏,我們就遇到了一個具體的問題:

如何從編隊中查找並移除符合特定條件的成員?

2.3 反序遍歷——將陣亡者從編隊中除名

現在是遊戲開發時間!在Unity中建立新場景,使用3D Objects: Capsule創建5個模擬士兵物體,分別命名爲0/1/2/3/4。爲每個“士兵”掛載上HitPoint生命值組件(代碼見下文)。然後,將1號、2號、4號士兵的生命值設定爲0,表示已經“陣亡”的士兵。(在後面的展示圖中,“陣亡”的1/2/4號士兵將會呈躺倒姿態放置,便於直觀地區分)

此處使用的HitPoint組件代碼:(相比上一篇內容的HitPoint進行了簡化調整)

using UnityEngine;

public class HitPoint : MonoBehaviour
{
    public int HitPointLimit = 100;
    [SerializeField]
    private int Hitpoint = 30;

    public int GetHitPoint()
    {
        return Hitpoint;
    }

    public void ChangeHitPoint(int hp)
    {
        Hitpoint = Mathf.Clamp(Hitpoint + hp, 0, HitPointLimit);
    }
}

建立一個新腳本並命名爲Team(代碼見下文),該組件將會將全部士兵存儲爲一個List列表。按下鍵盤上的C鍵,Team組件會進行一項遍歷操作,試圖查找死亡的士兵,將其在遊戲中隱去,然後將其從編隊中移除。

Team組件代碼:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Team : MonoBehaviour {

public List<GameObject> Soldiers = new List<GameObject>();

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.C))
        {
            Check();
        }
    }

    void Check()
    {
        for (int i = 0; i < Soldiers.Count; i++)
        {
            if (Soldiers[i].GetComponent<HitPoint>().GetHitPoint() == 0)//如果列表中位置爲i的士兵的生命值爲0
            {
                Soldiers[i].SetActive(false);//立即隱去這個士兵
                Soldiers.RemoveAt(i);//將這個士兵從列表中移除
            }
        }
    }
}

將Team組件掛載到任意的非士兵物體上,然後在Inspector面板中操作Team組件,將五名士兵錄入Soldiers列表。

運行遊戲並按下C鍵,觀察五名士兵的變化。

奇怪的現象出現了:陣亡士兵中的1號和4號被正確移除,但是2號卻仍然存留。

怎麼會這樣?

我們來梳理一下上面那段“查刪”代碼的工作流程。

    void Check()
    {
        for (int i = 0; i < Soldiers.Count; i++)
        {
            if (Soldiers[i].GetComponent<HitPoint>().GetHitPoint() == 0)//如果列表中位置爲i的士兵的生命值爲0
            {
                Soldiers.RemoveAt(i);//將這個士兵從列表中移除
            }
        }
    }

Check()方法開始執行後:

-------------

i = 0;

檢查列表0號位的士兵;

不符合刪除條件;

-------------

i = 1;

檢查列表1號位的士兵;

符合刪除條件!刪除它;

此時,列表中原先的1號成員消失了,而原先的2號成員,即2號士兵則會立即回落一位,成爲列表新的1號成員。類似地,3號士兵成爲2號成員,4號士兵成爲3號成員。

------------

i = 2;

檢查列表2號位的士兵?

......

這時,我們發現問題出現了。for循環下一個將要檢查的是列表的2號成員,但由於先前1號士兵被刪除引起的補位現象,列表的2號成員已經不再是2號士兵,而是3號士兵。而真正的2號士兵則躲到了列表內的1號位——這個位置再也不會被檢查一遍了。於是,這個看似規範合理的遍歷過程就出現了錯誤,2號士兵堂而皇之地逃過了檢查,沒有從列表中被刪除。

搞清楚了嘛?有沒有如夢初醒的感覺?

那麼,怎樣纔算是正確的查刪操作呢?方法非常簡單,只要對列表進行反序遍歷即可——從列表最後一名成員開始,遍歷到第一名成員時結束。

修改Team組件的代碼,使用反序遍歷:

再次運行,發現躺倒的士兵全都被正確地移除了。

實際上,你以後不必一直記得前面講過的“補位現象”是怎麼回事。只要牢牢記住以下規則,並在編碼時死記硬背即可:

對於任何一項遍歷列表的操作,只要有可能在中途刪除成員,那麼必須進行反序遍歷:

for (int i = list.count - 1;  i >= 0;  i--)   {......}

至於反序遍歷是如何在校驗時避免遺漏的,請大家自行驗算——原理非常簡單。下面提供了一種比較清晰的理解方式:

如果將列表成員編號看成數軸,i看成一個查詢遊標的話,當我們進行反序遍歷時,刪除成員所引起的補位現象,只會發生在遊標i的右側。而在反序遍歷時,遊標是從右往左移動的,因此,遊標右側的成員都是已經被校驗過並確認保留的成員,它們在列表中的編號發生何種改變已經無關緊要。而對於尚未校驗過的成員,它們一直都會乖乖地待在遊標的左側,編號不發生改變,直到接受完校驗過程爲止。

此外,你還可以記住如下結論:

·如果使用正序遍歷來刪除成員,那麼每一名被刪除的成員的下一名成員將會逃過檢查;

·foreach的遍歷順序與for的正序循環相同,因此用foreach進行查刪會產生錯誤。根據編譯器的不同,編譯器可能會直接報錯,或者發出“遍歷結果可能不正確的警告。

2.4 實時校驗的解決方案

現在,我們掌握了針對【可能出現異常成員】的列表的校驗方法。在遊戲中,一名士兵的陣亡可能會發生在任何時候。那麼,爲了及時移除陣亡士兵,我們應當在何種時機,對編隊執行校驗操作呢?

比較容易想到的方案是,一刻不停地執行查刪。就好像,針對一羣士兵組成的編隊,軍官可以不停地進行點名,一旦發現處於異常狀態的成員,就令其離隊。

爲了演示實時校驗的效果,首先我們爲HitPoint組件補充一點內容,讓我們可以在遊戲運行時用鼠標左鍵單擊士兵,對其造成10點傷害。

using UnityEngine;

public class HitPoint : MonoBehaviour {

    public int HitPointLimit = 100;
    [SerializeField]
    private int Hitpoint = 30;

    public int GetHitPoint()
    {
        return Hitpoint;
    }

    public void ChangeHitPoint(int hp)
    {
        Hitpoint = Mathf.Clamp(Hitpoint + hp, 0, HitPointLimit);
    }

    private void OnMouseOver()//當鼠標指針停留在物體上時
    {
        if (Input.GetMouseButtonDown(0))//單擊鼠標左鍵
        {
            ChangeHitPoint(-10);//自身扣除10點生命值
        }
    }
}

然後,修改Team組件中的Update方法,使得Team每一幀都會對編隊進行校驗(而不需要按下C鍵),並試圖剔除陣亡的士兵。

(處於效果直觀的需要,使用OnGUI方法來實時顯示存活士兵及其生命值)

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Team : MonoBehaviour {

public List<GameObject> Soldiers = new List<GameObject>();
    
    void Update()
    {
        Check();//每一幀都會校驗
    }
    
    void Check()
    {
        for (int i = Soldiers.Count - 1; i >= 0; i--)//反序遍歷
        {
            if (Soldiers[i].GetComponent<HitPoint>().GetHitPoint() == 0)//如果列表中位置爲i的士兵的生命值爲0
            {
                Soldiers[i].SetActive(false);//立即隱去這個士兵
                Soldiers.RemoveAt(i);//將這個士兵從列表中移除
            }
        }
    }

    void OnGUI()
    {
        GUIStyle style = new GUIStyle();
        style.fontSize = 30;
        style.normal.textColor = Color.white;

        GUILayout.BeginArea(new Rect(0, 0, 500, 1000));
        for (int i = 0; i < Soldiers.Count; i++)
        {
            GameObject soldier = Soldiers[i];
            GUILayout.Label("HP of Soldier " + soldier.name + " :" + soldier.GetComponent<HitPoint>().GetHitPoint().ToString(), style);
        }
        GUILayout.EndArea();
    }
}

將前面場景中的三個“陣亡士兵”扶正,統一設置生命值爲30。運行遊戲,用鼠標左鍵點擊來對任意士兵造成傷害。可以看到,只要一名士兵的生命值降至0,它就會立刻消失不見,並從編隊中被剔除;這說明Update()中每幀都會執行的Check()方法發揮了作用,達到了對編隊進行實時校驗的效果。

至此,我們採用逐幀校驗的方式,初步實現了對編隊內成員的異常管控。

----------------------

當然,還有一項問題很容易發現:逐幀校驗是一種對系統性能的較大浪費。要想對校驗的性能進行優化,不妨採用我們熟悉的事件驅動方式。其大致思路是,每當一名士兵身上出現敏感事件時,例如——

·生命值歸零

·涉及自身功能的關鍵組件被移除

就觸發相應的事件,並通知遊戲的管理模塊(也就是Team組件),要求對自身的狀態進行覈查。

關於事件驅動的原理和用法,前面的文章中有過詳細介紹,所以這裏就留給大家自行嘗試。

如果你認真地嘗試了使用事件驅動來重構程序,那麼你很可能會發現,事件驅動架構下的代碼複雜程度,遠遠高於逐幀校驗的方案。於是,我想在這裏插一句嘴。

毋庸置疑,逐幀校驗是一種十分耗費性能的處理方式;然而在遊戲開發的背景下,我們並不應該在看到類似邏輯時,貿然斷定這是一個不好的方案。因爲運算性能的最優化,經常也會導致代碼風格和可理解性的劣化。

在遊戲中,好用&能玩永遠是第一位的;如果某個方法能簡明、直接地保證遊戲功能的正確性,那麼往往不必對性能錙銖必較。在某些情況下,遊戲開發者可以選擇不去照顧少數性能極差的玩家終端。

2.5 尋求簡潔的校驗邏輯

以上內容,我們討論瞭如何將處於非預期狀態的物體從數據結構中剔除;但在實際遊戲中,我們還需要在更多的合適時機,對一個物體是否處於預期狀態進行判斷。下面再來設想一個情境。

在射擊類遊戲中,一枚子彈擊中某個目標時,需要對目標進行扣血操作。但是,子彈擊中的不一定是一名活着的敵方單位,還有可能是已經陣亡,正在執行死亡動畫的單位;當項目中有很多環境物件時,子彈擊中的還可能是一塊石頭、一棵樹。顯然,扣血操作只應該應用於具有生命值,並且活着(生命值大於0)的目標;試圖扣減一塊路邊的石頭的生命值是徒勞無益的,而且會引發錯誤。

要想確保扣血操作應用於正確的目標,我們就需要對子彈命中的物體進行覈查,看看這個物體是否應當受到子彈的傷害。按照傳統的編程思路,我們的核查應該是由類似下面這樣的一連串判定組成的。

——該目標是否具有遊戲角色的標籤?(如果目標是石頭或樹,則子彈的命中無效)

——該目標是否具有生命值組件?(如果命中了一個不可傷害的單位,則子彈的命中無效)

——該目標的生命值是否爲零?(如果目標已經死了,則攻擊無效)

......

然而,在大型的項目中,爲了使代碼儘可能地簡潔可讀,我們希望此類囉裏囉嗦的判定流程在代碼裏出現得越少越好。我們能否找到一種便捷的方法,對一個物體是否處於預期狀態進行覈驗呢?

首先設想一下,我們心目中優雅的狀態覈驗代碼,應該是什麼樣子?

——當子彈命中【目標】時

——如果【目標】.可以被攻擊

——則命中有效!

對!就是這樣!我們希望爲【目標】物體加入一種簡潔的自檢機制,來直接反映出該物體當前是不是一個可攻擊的物體。

2.6 運用擴展方法

下面介紹C#提供的擴展方法功能。

【擴展方法】是一種在命名空間內的靜態類中定義的靜態方法;此類方法會指定一個現有類型,併爲該類型的所有實例在命名空間內提供新的方法成員。

現在,我們修改HitPoint.cs文件,添加名爲MyGame的命名空間。爲GameObject類寫入擴展方法InFlesh(),該方法用於直接表示一個GameObject是否:具有生命值組件,且爲存活狀態

修改後的HitPoint.cs內容如下:

using UnityEngine;

namespace MyGame
{
    public static class Checker//這個名字取什麼都可以,定義這個類只是爲了滿足擴展方法的格式要求,實際上不會用到
    {
        public static bool InFlesh(this GameObject obj)//擴展方法:此物體是不是一個處於存活狀態的士兵?
        {
            if (obj.GetComponent<HitPoint>())//判定1:具有生命值組件
            {
                if (obj.GetComponent<HitPoint>().GetHitPoint() > 0)//判定2:生命值大於0
                {
                    return true;//符合以上條件則返回true
                }
            }
            return false;//不全部符合則返回false
        }
    }

    public class HitPoint : MonoBehaviour
    {
        public int HitPointLimit = 100;
        [SerializeField]
        private int Hitpoint = 30;

        public int GetHitPoint()
        {
            return Hitpoint;
        }

        public void ChangeHitPoint(int hp)
        {
            Hitpoint = Mathf.Clamp(Hitpoint + hp, 0, HitPointLimit);
        }

        private void OnMouseOver()
        {
            if (Input.GetMouseButtonDown(0))
            {
                ChangeHitPoint(-10);
            }
        }
    }
}

然後,在其它所有代碼的開頭部分都加上如下語句,來應用命名空間:

using MyGame;

現在,當你在代碼中聲明任何GameObject實例時,你會發現該實例多出了一個InFlesh方法成員。

對於任何一個GameObject obj,只要obj.InFlesh()的返回值爲true,即說明該物體是一個可被攻擊的目標。

這下可就不一樣啦!現在修改Team組件中的Check方法,使用新鮮出爐的擴展方法來實現對士兵狀態的校驗。

結合InFlesh方法的內容,我們不難看出,修改後的Check()方法將會擁有真正完善的異常應對能力;它不僅用最少、最易理解的代碼實現了對士兵是否存活的檢查,而且還能夠抵禦士兵必要組件(HitPoint)丟失的狀況。

或者,還可以這樣理解——

在使用擴展方法之前,對一個物體進行校驗,代碼風格是這樣的:

-----------------------------

目標.生命值組件.存在?(這種表述是很囉嗦)

目標.生命值組件.生命值 > 0?(這種表述更加囉嗦)

-----------------------------

使用擴展方法之後,就變成了這樣:

目標.可以被攻擊? (極其簡潔!)

所以說——擴展方法可以提供一種代碼風格的轉變,使得關於某個類型的常用操作看起來像是該類型本身的自帶功能一樣。這種表述風格的轉變,能夠爲程序帶來極佳的可讀性,使原本複雜的邏輯變得簡單易懂。

在項目的開發中,一個諸如“士兵”這樣的遊戲物體,其構成可能相當複雜;其上往往掛載着動畫系統、導航系統等爲數衆多的組件,其物體本身和每個組件都可能會被不同的腳本模塊所調用,對代碼內部異常處理的要求更高。在這種情況下,擴展方法的價值更能得到體現:它可以避免繁瑣的異常處理流程在代碼中佔用大量篇幅,從而使代碼結構清晰,易於理解和調試。

2.7 未完待續

Unity代碼中的異常處理策略,講到這裏先告一段落;實際上,遊戲的類型花樣繁多,開發中可能會出現的異常更是千奇百怪,而上面講過的內容只能算是一些基礎級別的注意事項。

總體來說,要想在Unity中做到有效管控異常,既需要足夠的代碼量積累、豐富的Debug經驗,也需要身爲遊戲玩家的一些直覺和感性思維;此外,紮實的算法和邏輯能力也必不可少。這真的是開發過程中,一件既讓你焦頭爛額,也讓你樂此不疲的事情。

關於更多的異常處理策略和方法,我會在這裏隨時編輯補充。我們後面再會!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章