2017年12月18日 星期一

隨手札記-C# 屬性概念實作:複製元件、傳遞變數型別給泛型函式、繼承類別(Unity)

最近在開發「屬性」的概念。

一樣地、實作於Unity C#腳本上(因為先前的專案也都是這個環境)




「屬性」指的是類似神奇寶貝、或者一般RPG那種,每個單位都會有零至多個屬性。
這些屬性可能會造成某些數值的加成、特殊行動的行為改變,甚或進一步而言:彼此會互相作用
(舉例來說,木屬性物件碰到燃燒屬性物件、前者會生成新的燃燒屬性在其身上;凍結屬性物件碰到燃燒屬性物件會被抹消屬性;又或者光屬性的子彈打到暗屬性物件會造成額外傷害,如此這般。)

使用Draw.io稍微繪製一下自己要幹嘛。

幾點設計摘要:
  • 既然屬性可能會互相作用,每次都使用 GetComponent() 可能會造成效能低落,是故設立一個 PropertyManager 來記錄與管理所有屬性,並連帶提供附加/移除/清空屬性的功能。
  • 在判定上不希望屬性以外的東西用到這些功能,所以撰寫一個基底類別 UnitProperty 。往後的 Property 類別都是繼承於此類別;而在做屬性相關功能的時候,只要宣告使用的變數類別為 UnitProperty 即可做出恰當的限制。這是繼承的一大優勢:繼承的類別也可以被視為基底類別(白話一點,例如黑人也是人)
  • 設計概念上,同物件上不會存在多個同樣屬性。這也是需要 PropertyManager 的第二原因。(連帶地需要設計更新屬性資訊的功能....比方說弱的燃燒屬性碰到新的燃燒屬性,其數值可能會被更新)
  • 另外, PropertyGiver 是專案企畫(?)所需要的功能,可以設定為機關地板、為經過的物件賦予屬性。

Property Manager屬性管理器

簡單條列一下需求。
  • 這玩意會附加在每個有屬性的物件上。
    • 如果沒有,那就附加一個(這交給屬性去做)
  • 記錄功能:管理器會記錄被附加到此物件上的所有屬性。使用 List 型態實作完成。
    • 以及記錄一些常用的檔案,到時候可以直接指派給新屬性,節省效能(例如 Player 、 Controller 等)
  • 管理功能:管理器可以新增/覆寫、移除、清空屬性。
有了基本概念之後,就可以寫出簡單的程式碼結構了。

宣告 List propertyList;
宣告 一些要記錄的東西;

public bool ApplyProperty(){};
public bool RemoveProperty(){};
public bool ClearAllProperty(){};

public 屬性 GetProperty(){};

其實也就這樣而已,沒有想像中的複雜。(廢話,複雜的都是函式結構)

注意:List的使用方法因為下面篇幅太長就不贅述了,自己看吧,邊看邊學:
https://msdn.microsoft.com/zh-tw/library/6sh2ey19(v=vs.110).aspx
裡面會使用到泛型的概念喔!若不知道泛型是啥,往下滑到「泛型函式」。

ApplyProperty附加屬性

為什麼先從這個講起?因為GetProperty的需求太靠背了,所以我待會再解釋。
先來看看這個函式該做些什麼吧。
  • (如果不存在指定屬性,)依照指定屬性類別生成一個新屬性、附加於物件上。
  • (如果存在指定屬性,)(依照需求,)覆寫/更新屬性的數值。
說起來也很簡單。

public bool ApplyProperty(要附加的屬性, bool 更新資訊)
{
if ( [要附加的屬性存在] && 更新資訊 == false)
{
//記錄為取消
return false;
}
屬性 p;
if ( [要附加的屬性存在] )
{
p = 此屬性;
}
else
{
p = 新增屬性到此物件;
}
<<更新 p 的每個數值 >>

(把 p 新增到 propertyList 裡面)
return true;
}


會設定 ApplyProperty 為布林型態,純粹是為了記錄有沒有成功更新/附加。
實際上,考慮多功能使用,應該要設定為屬性型態比較好喔....?
不管了,反正就先這樣寫吧。

然後呢,我們就發現一件事:唉呀,需要判定要附加的屬性是否已經存在欸。
所以促成了 GetProperty 的誕生。
我們姑且假設這個函式可以順利運作好了,反正如果不能,頂多就是允許存在多個同屬性就好(爆)

複製元件

這裡探討複製一個元件的寫法。
為什麼需要探討這個?就是因為希望屬性可以一次寫好功能之後,就在Unity上面允許自訂數值,之後企畫要改的時候就不必動到程式碼,減少腳本壞掉的可能性
但如果要允許自訂數值、又要能夠執行時透過程式賦予這些自訂的屬性,那麼透過程式「複製」元件顯然就是必要的技術了。

尋尋覓覓,我找到了這個:
https://answers.unity.com/questions/458207/copy-a-component-at-runtime.html

這玩意真的有用!感恩讚嘆vladipus大大。
把它改寫成我們需要的版本,然後放置進 <<更新 p 的每個數值>> 區塊:

var dstProperty = GetProperty(propertyType) as UnitProperty;
var fields = propertyType.GetFields();
foreach (var field in fields)
{
if (field.IsStatic) continue;
if (field.Name == "propertyManager") continue;

//參照以上格式去排除不應該複製到的資訊,像是所屬的propertyManager就可能更換
field.SetValue(dstProperty, field.GetValue(property));
}

dstProperty.propertyManager = this;
//參照此格式去強制設定一些資訊。

Get Property獲取屬性

好啦,終究要來談這個機掰人比較複雜的功能。
  • 輸入一個元件(變數或型別),回傳該類別的屬性、或者回傳 null (以表示不存在)
簡單來說大概就是這樣吧。

public 屬性 GetProperty(指定屬性)

{

foreach ( 屬性p in propertyList ) 

{

if (屬性p == 指定屬性) return 屬性p;

}

return null;

}


問題來了:怎麼讀取「指定屬性」?
元件變數的話姑且沒問題,我知道可以用 GetType() 來獲取元件的類別。
但如果不想要輸入變數、而是只輸入型別就可以運作(例如只是要判斷是否存在的場合),那麼就得用到泛型函式的寫法。

注意: Unity C# 不允許 new 一個 Component 。 Component 一定要存在實體、並附加在物件上。

泛型函式

什麼是泛型方法 / 泛型函式( generic method / generic function )?
它允許你指定一個自訂型別,當指定後,程式會把所有型別都抽換掉,甚至是輸入或輸出的內容型別(像是現在要實作的GetProperty就需要可改變的輸入與輸出型別,怎麼這麼巧呀~)
舉例來說,一般Unity用到的 GetComponent() 就是泛型函式。你可以指定是哪種Component,系統會幫你Get,然後Get到的東西也會是那種Component(好像繞口令)

你也可以參照Unity教學:

簡單來說,泛型函式透過 <T> 寫法來做為識別。

void GenericFunction<T>()
{
//呼叫時 打入 GenericFunction<SomeType>() 即可使用
//do something
}

T MoreGenericFunction<T>() where T: UnitProperty
{
//你甚至可以把函式的回傳型別也抽換成自訂型別
//並且還可以指定自訂型別的類型
return somethingTypeIsT;
}

U MoreGenericFunction2<T, U>()
{
//注意:T只是個代稱、方便記憶,實際上就跟變數一樣可以讓你隨便取。
//你甚至也可以一次指派多個自訂型別
return somethingTypeIsU;
}


說起來,這其實不是Unity的專利,而是.NET C# 在CLR 2.0以後就蹦出的功能。參見:
https://docs.microsoft.com/zh-tw/dotnet/csharp/programming-guide/generics/index

瞭解泛型函式之後回到GetProperty本身,我想就會有頭緒了:

public T GetProperty<T>() where T : UnitProperty
{
foreach (UnitProperty p in propertyList)
{
if (p.GetType().Name == typeof(T).Name)
{
return p as T;
}
}
return null;
}

順帶一提,因為 p 不一定等於 T (明明兩者都是UnitProperty....莫名其妙),所以一定要加上明確轉換。聽起來好像很酷炫,其實就是 p as T 的部分。

傳遞變數型別給泛型函式

關關難過關關過,一山還有一山高,高得你衣衫不整(不好笑)
我本來想要做到類似 GetProperty< typeof(someProperty) > () 這樣的功能,也就是:判斷某個已經存在的屬性是否已經存在於指定物件上。(怎麼又是個繞口令)
結果就跳警告了。不允許執行。

查了查網路,發現實際上泛型函式不允許輸入(執行前)未明確定義的型別。例如上面那樣子,你要是隨便抓一個東西的type,管它定義上是什麼,C#都會怕怕。

參見:
https://stackoverflow.com/questions/2107845/generics-in-c-using-type-of-a-variable-as-parameter

到底是怎樣,都能夠自由指定型別了,你就不能讓我自由到底嗎(哭)

那麼,該怎麼辦呢.jpg

....

....

....

....某天靈光一閃,想到了一件事:
就算自訂型別不能用又怎樣,你不是還有生命自訂變數嗎?

--沒錯,Type也能當變數來使用唷!

寫一個多形並改寫一下,讓他吃Type當變數:

public UnitProperty GetProperty(System.Type type)
{
foreach (UnitProperty p in propertyList)
{
if (p.GetType().Name == type.Name) //比對Name只是以防萬一。理論上可以不用比對、直接相比。
{
return p;
}
}
return null;
}

然後實際上使用的時候只要打 GetProperty(someProperty.GetType()) 就好了。
雖然看起來有點繞路,但這也是目前最好的寫法了。

我真佩服我的腦袋啊,哇哈哈哈哈哈哈
.....去你的C#。

寫這段的時候才想到,或許也可以直接餵屬性元件進去:

public UnitProperty GetProperty(UnitProperty property)
{
foreach (UnitProperty p in propertyList)
{
if (p.GetType() == property.GetType())
{
return p;
}
}
return null;
}

....那我前面到底是在忙三小。(這種時候就知道寫筆記的重要性了)

Remove Property 移除屬性

這東西相對於前述就簡單多了,需求如下:
  • 移除指定屬性。
  • 要是指定屬性本來就不存在,那就當沒發生過。
  • 輸入通常是型別而非實體(變數/元件)。

我的想法是這樣的:

public bool RemoveProperty<T>()
{
UnitProperty p = GetProperty<T>();
if (p == null)
{
//本來就沒東西,不用移除
return false;
}
<從清單上移除>;
Destroy(p);
return true;
}

因為GetProperty做得好,Remove這裡就不用太多功夫了。

Clear All Property 清除所有屬性

清除所有屬性 = 對每個屬性進行移除屬性。
不多說,直接實作。

public bool ClearProperty()
{
while (propertyList.Count != 0)
{
RemoveProperty(propertyList[0].GetType());
}
return true;
}

利用List第0項一定含有東西的特性,把所有List內的元素(屬性)都移除掉。
之所以不使用foreach,是因為List的元素會不斷往前替補。
(但你可以用for迴圈,從大到小剔除就是了)
移除是一定要從List上移除+Destroy兩者都做才對,所以我這裡直接選擇呼叫現有的RemoveProperty。當然,你還是可以改成執行動作以節省效能。

Unit Property單位屬性

這部分輕輕鬆鬆地過去了。
記得:因為要給後續其他類別做繼承用,重要公用函式(例如 Start() )要加上 protected virtual 關鍵字,才可以順利被繼承的類別 override (並執行裡面的內容)

倒是為了方便屬性實作,在UnitProperty內建可以呼叫PropertyManager以進行新增/移除的函式。
以下是示範片段,可以依照自己喜好增減基底屬性的功能:

public PropertyManager propertyManager;
protected virtual void Start()
{
if (propertyManager == null)
{
propertyManager = GetComponent<PropertyManager>();
if (propertyManager == null)
{
//report here in case of bug.
propertyManager = gameObject.AddComponent<PropertyManager>();
}
}
}

protected bool GivePropertyTo(GameObject obj, UnitProperty giveProperty, bool updateInfoIfPropertyExists)
{
PropertyManager objPropertyManager = obj.GetComponent<PropertyManager>();
if (objPropertyManager == null)
{
//report here in case of bug.
objPropertyManager = obj.AddComponent<PropertyManager>();
}
return objPropertyManager.ApplyProperty(giveProperty, updateInfoIfPropertyExists);

}
protected bool RemovePropertyFrom<T>(GameObject obj) where T: UnitProperty
{
PropertyManager objPropertyManager = obj.GetComponent<PropertyManager>();
if (objPropertyManager == null)
{
//report here in case of bug.
return false;
}
return objPropertyManager.RemoveProperty<T>();
}
protected T GetProperty<T>(GameObject obj) where T: UnitProperty
{
PropertyManager objPropertyManager = obj.GetComponent<PropertyManager>();
if (objPropertyManager == null)
{
//report here in case of bug.
return null;
}
return objPropertyManager.GetProperty<T>();
}

對特定物件GetComponent()以得到其PropertyManager似乎是很蠢、然而同時也似乎是最佳的辦法,畢竟你沒辦法確定你會接觸到什麼物件,對吧?
(如果你同一個Scene允許附加屬性的物件,而你又真的超想優化,是可以透過建立Static List、然後直接呼叫掛在其下的指定PropertyManager啦....但我覺得多此一舉就是了。)

繼承與覆寫

繼承現有類別來開發新類別的時候,大概有兩種寫法是你需要知道 / 最常用的: override 和 constructor 。

使用 override 覆寫函式的時候,只要補上 base.FunctionName(); ,執行時系統就會在這行自動帶入基底類別的程式碼,藉此達成「覆寫->加寫」的轉換。

而使用 constructor / 建構子的寫法有一些高階了。然而,若你想要透過程式更新現有變數的預設值(比方說週期傷害的改動、視覺效果的修訂等),這似乎是唯一解。

兩者結合起來,配合原有的繼承概念大概是這樣:

public class NewProperty : BaseProperty
{
    public NewProperty()
    {
        someAlreadyExistingVar = newValue;
    }

    protected override void Start()
    {
        base.Start();
        //do something you want to do in this property
    }
    //other new contents
}

其中上半段宣告類別名稱做為函式的寫法就是 constructor ,下半段則是用 Start() 函式示範如何 override 基底類別的函式。
請注意,有些繼承可能這兩者都不會用到。如果你只是單純想要擴增一些新功能,那可以直接繼承類別後寫下去就是。
記得,函式/變數一定要加 public 或 protected 關鍵字,才能在繼承的新類別中繼續使用。

Property Giver 屬性賦予器

老實說,這東西用到的概念跟前述 UnitProperty 展示的多數功能挺像的。一樣要對某項物件新增或移除屬性,只是這項物件來自於 Collider 或 Trigger 的Enter / Stay / Exit而已。

這裡就不贅述啦!

沒有留言:

張貼留言