關於 web service, unity, blogger 等軟體工程筆記

Unity C# Interface, Inheritance and Component:以狀態血量設計為例

Edit icon 沒有留言
Slime with sword and HP

最近同事分享之前受訓所學的 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 程式碼還得調整一次,有點麻煩且程式碼不易維護。

注意: 程式碼範例都是簡化版面的版本,實務上的血量狀態實作應該像以下的程式碼範例,良好的數值封裝 (property get/set),需考慮初始值的設定 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 兩個組件來運作遊戲邏輯:

玩家 PlayB 的組件架構示意圖

沒有 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 繼承與組件式設計之戰鬥系統經驗談

Reference

沒有留言: