Unity C# Interface, Inheritance and Component:以狀態血量設計為例
最近同事分享之前受訓所學的 Design patterns,加上看到社群有人詢問血量設計問題,因此整理關於在 Unity 中,設計血量系統的可能實作方法。
不得不說,剛從學校畢業開始接觸 Unity 時,最優先採用的是類別繼承 (class inheritance),但後來因為遊戲開發過程中,實際上需求變動相當快速與頻繁,繼承設計會變成非常難以維護與修改。因此漸漸的改用組件 (component) 方式去設計,以因應快速迭代修改的需求,其經驗談分享在:Unity C# Inheritance vs Composition 繼承與組件式設計之戰鬥系統經驗談。(注意:在某些情況下,繼承還是很好的設計)
以下分成幾種方式程式碼展示,介紹血量系統的設計 (HP, health point),需求是要能記錄一個遊戲物件的當前血量 (HP) 以及其最大血量 (Max HP),該遊戲物件可能是玩家 (Player)、敵人 (Enemy)、或是可破壞場景物件 (DestroyableObject) (例如障礙物)。
複製貼上 Copy and paste
最簡單直覺想到的方式,針對不同遊戲物件的類別 (Player, Enemy, and DestroyableObject) 實作程式碼,例如玩家類別 (class):
public partial class Player : MonoBehaviour {
public float HP { get; set; }
public float MaxHP { get; set; }
}
敵人類別:
public partial class Enemy : MonoBehaviour {
public float HP { get; set; }
public float MaxHP { get; set; }
}
以及可破壞場景物件類別:
public partial class DestroyableObject : MonoBehaviour {
public float HP { get; set; }
public float MaxHP { get; set; }
}
如果系統在更複雜些,有其他函數實作的話,如此的複製貼上必定會產生太多重覆的程式碼 (duplicate code),會造就之後難以維護問題,到時候一定會被資深工程師打槍。
且通用血量顯示功能,也會變得十分複雜,得根據不同型態進行轉換取得其血量值:
using UnityEngine;
using UnityEngine.UI;
public class HPDisplay : MonoBehaviour
{
[SerializeField]
MonoBehaviour hpObject;
[SerializeField]
Text uiText; // Unity GUI
void Update()
{
var inst = this.hpObject;
var hp = 0f;
var maxHp = 0f;
if (inst is Player)
{
hp = (inst as Player).HP;
maxHp = (inst as Player).MaxHP;
}
else if (inst is Enemy)
{
hp = (inst as Enemy).HP;
maxHp = (inst as Enemy).MaxHP;
}
else if (inst is DestroyableObject)
{
hp = (inst as DestroyableObject).HP;
maxHp = (inst as DestroyableObject).MaxHP;
}
else
{
return;
}
var percentage = Mathf.InverseLerp(0, maxHp, hp);
this.uiText.text = string.Format("HP Percentage: {0:.2f}", percentage);
}
}
如果之後又追加新的血量物件,HPDisplay.Update
程式碼還得調整一次,有點麻煩且程式碼不易維護。
Awake
,數值是否需要序列化可編輯 (SerializeField)
,以及在 Unity 編輯器編輯該些數值後的對應處理 (OnValidate)
,還得加入數值改變的事件宣告 (EventHandler)
,也許還要製作專屬的編輯器
(CustomEditor)
讓設計者編輯…
// Assets/Scripts/Status.cs
using System;
using UnityEngine;
public class Status : MonoBehaviour
{
public event EventHandler HPChanged;
public event EventHandler MaxHPChanged;
float hp = 0;
[SerializeField]
float maxHP = 100;
public float HP
{
get { return this.hp; }
set
{
value = Mathf.Clamp(value, 0, this.maxHP);
if (this.hp != value)
{
this.hp = value;
this.InvokeHPChanged();
}
}
}
public float MaxHP
{
get { return this.maxHP; }
set
{
value = Mathf.Clamp(value, 1, float.PositiveInfinity);
if (this.maxHP != value)
{
this.maxHP = value;
this.InvokeMaxHPChanged();
this.HP = Mathf.Clamp(this.hp, 0, this.maxHP);
}
}
}
void Awake()
{
this.hp = this.maxHP;
}
#if UNITY_EDITOR
void OnValidate()
{
// When designer change MaxHP on editor in playing mode..., and could not get old value, so just invoke event here
this.maxHP = Mathf.Clamp(this.maxHP, 1, float.PositiveInfinity);
this.InvokeMaxHPChanged();
this.hp = Mathf.Clamp(this.hp, 0, this.maxHP);
this.InvokeHPChanged();
}
#endif
void InvokeHPChanged()
{
if (this.HPChanged != null)
{
this.HPChanged(this, EventArgs.Empty);
}
}
void InvokeMaxHPChanged()
{
if (this.MaxHPChanged != null)
{
this.MaxHPChanged(this, EventArgs.Empty);
}
}
}
// Assets/Editor/StatusEditor.cs
using System.Linq;
using UnityEditor;
[CustomEditor(typeof(Status), true)]
[CanEditMultipleObjects]
public class StatusEditor : Editor
{
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
// Add HP field for editing in playing mode
if (EditorApplication.isPlaying)
{
var status = this.target as Status;
var mixedValue = !this.targets.All((v) => { return (v as Status).HP == status.HP; });
EditorGUI.showMixedValue = mixedValue;
EditorGUI.BeginChangeCheck();
var hp = EditorGUILayout.FloatField("HP", status.HP);
if (EditorGUI.EndChangeCheck())
{
foreach (Status target in this.targets)
{
target.HP = hp;
}
}
EditorGUI.showMixedValue = false;
}
}
}
介面 Interface
為了讓通用血量顯示功能的實作更加簡潔,考慮使用 interface:
public interface IStatusObject {
float HP { get; set; }
float MaxHP { get; set; }
}
上節的每個類別加入實作該介面 (implementing an interface) 的宣告:
public partial class Player : MonoBehaviour, IStatusObject { ... }
public partial class Enemy : MonoBehaviour, IStatusObject { ... }
public partial class DestroyableObject : MonoBehaviour, IStatusObject { ... }
因此血量顯示功能的實作 HPDisplay.Update
便可以調整成更簡潔的實作,未來如果有新的血量類別被建立,只要該類別有實作介面 IStatusObject
,血量顯示功能都可以正常運作而不需要調整:
void Update()
{
var inst = this.hpObject as IStatusObject;
if (inst != null)
{
var hp = inst.HP;
var maxHp = inst.MaxHP;
var percentage = Mathf.InverseLerp(0, maxHp, hp);
this.uiText.text = string.Format("HP Percentage: {0:.2f}", percentage);
}
}
但 interface 並不能與 Unity Editor 產生良好的結合,例如以下宣告是沒有辦法在 Inspector 上看到可編輯的欄位,原因是 Unity 無法序列化介面的參照 (interface reference):
public class HPDisplay : MonoBehaviour
{
[SerializeField]
IStatusObject status;
}
雖然可以使用 GetComponent<IStatusObject>()
在執行階段 (runtime) 取得其實體參照 (instacne reference),但使用上還是有點不方便,最好是能在 Unity Editor 設計階段,就能讓關卡設計者編輯好參照。
繼承 Inheritance
血量系統改用物件繼承方式設計,先建立 StatusObject
class:
public class StatusObject : MonoBehaviour
{
public float HP { get; set; }
public float MaxHP { get; set; }
}
其遊戲物件類別實作,改繼承 StatusObject
:
public partial class Player : StatusObject { ... }
public partial class Enemy : StatusObject { ... }
public partial class DestroyableObject : StatusObject { ... }
其通用的血量顯示功能的便可以改寫成以下版本:
using UnityEngine;
using UnityEngine.UI;
public class HPDisplay : MonoBehaviour
{
[SerializeField]
StatusObject status;
[SerializeField]
Text uiText; // Unity GUI
void Update()
{
if (this.status == null) {
return;
}
var hp = this.status.HP;
var maxHp = this.status.MaxHP;
var percentage = Mathf.InverseLerp(0, maxHp, hp)
this.uiText.text = string.Format("HP Percentage: {0:.2f}", );
}
}
組件 Component
另一種設計是將血量狀態組件化,獨立出單一個組件來管理,不使用繼承 (inheritance) 而是採用組合 (composition) 的概念來設計,先建立狀態組件類別 Status
:
public class Status : MonoBehaviour
{
public float HP { get; set; }
public float MaxHP { get; set; }
}
以玩家例子來說,玩家其 GameObject 會同時加入 Status
以及 Player
兩個組件來運作遊戲邏輯:
沒有 Status
組件的遊戲物件,表示就沒有狀態以及其附屬等功能。
在一開始的設計中,Player
一定要有血量狀態,因此可以使用 RequireComponent
來綁定,且為了能快速取得其狀態物件,會另外實作狀態屬性 (property):
[RequireComponent(typeof(Status))]
public partial class Player : MonoBehaviour
{
Status status = null; // Cache
public Status Status
{
get
{
if (this.status == null)
{
this.status = this.GetComponent<Status>();
}
return this.status;
}
}
}
繼承 (Inheritance) 與組合 (Composition) 設計如何選擇
看需求與設計,但若是身處在需求還不確定,且可能會非常頻繁變動與修改的環境下,要保持較佳的彈性還是使用組件 (Components) 來設計遊戲架構,新增或是移除功能時,只需要管理對應功能的組件即可(如果設計好的話),可以不用去調整繼承的程式碼實作。
那麼怎樣的功能需要拆成一個組件?每一個小功能都做成一個組件好嗎?這部分就要看當下專案的需求與未來展望,並且仰賴大量經驗與思考來決定了,如果功能拆分過細反而會掉入過度設計 (over-designed) 的陷阱,增加開發成本又白做工。
更多可以參考之前的文章:Unity C# Inheritance vs Composition 繼承與組件式設計之戰鬥系統經驗談。
沒有留言: