Chapter 04

クラスのデザイン (Class Design)

sh1ch
sh1ch
2020.09.23に更新

クラスのデザイン (Class Design)

25. インスペクターで操作できるフィールドの実装方法を決めて、標準化する

フィールドは、public にするか private にして「SerializeField」属性を与えるか2つの方法があります。

後者は「より正しい」方法ですが、便利ではありません。(Unity が普及させた方法でないことは確かです)どちらの方法を選ぶにしても、あなたのチームの開発者が public フィールドをどのように扱うのか知っているように、それを標準としてください。

  • インスペクターで操作できるフィールドが public のとき、そのフィールドは「実行時にデザイナーが変更しても安全であり、コードで値を設定しない」ことを意味します。
  • インスペクターで操作できるフィールドが private だけど SerializeFiled 属性を持つとき、public なフィールドは「コード中でこの変数を変更しても安全だ」という意味です。(なので、あまり多くは表示されないはずで、MonoBehaviourScriptable Object には public フィールドは存在しないはずです)

26. コンポーネントはインスペクターで調整するべきではない変数を決して公開しないこと

そうしないと、そのパラメーターがなにをするのか明確でない場合、デザイナーによって調整されてしまいます。レアケースとして、それが避けることができない場合があります。(たとえば、エディタースクリプトが取得する必要がある場合など)その場合は、HideInInspector 属性を使ってインスペクターの中から隠すことができます。

旧 Tips 30 と同じ内容だと思います。

27. インスペクターの独自 (Propery drawers) を利用して、フィールドをより使いやすくすること

インスペクターの独自 (Propery drawers) は、インスペクターのコントロールをカスタマイズするために使用することができます。

これによって、データの性質にあわせたコントロールを作成したり、特定のセーフガードを設置したりできます。(Range のような範囲を設定するなど)Header 属性を利用してフィールドを整理したり、ToolTips 属性を利用して追加のドキュメント(あんちょこ)を提供します。

例に挙がっているとおり、属性でインスペクターに追加するフィールドをわかりやすくすることだと思います。ただ、単純に属性を追加するカスタムエディターと違う点だけフォローする。

28. カスタムエディターよりも独自の (Propery drawers) を優先すること

独自の (Propery drawers) はフィールドタイプごとに実装されているため、実装の手間が大幅に軽減できます。また、再利用性も高いので、ある型のために一度実装すれば、どのクラスでも再適用できます。

カスタムエディターは MonoBehaviour ごとに実装されるため、再利用性が低く、実装の手間がかかります。

29. デフォルトでは MonoBehaviour を seal すること

一般的には、Unity の MonoBehaviour は継承にフレンドリーではありません:

  • Unity が Start() や Update() などのメソッドを呼び出す方法は、サブクラスからこれらのメソッドを扱うのは面倒です。注意しないと、間違ったものが呼ばれたり、ベースになるメソッドの呼び出しを忘れてしまったりします。
  • カスタムエディターを使う場合、通常はエディターの継承構造を複製している必要があります。クラスのひとつを拡張したいなら、独自のエディターを提供するか、提供されているものを利用します。

継承が必要な場合は、避けられる場合は Unity のメソッド(Start や Update)を提供しないようにしてください。もしも、それらを提供するなら仮想化してはいけません。必要であれば、メソッドから呼び出される空の virtual 関数を定義して、子クラスが override して追加の作業をすること。

public class MyBaseClass
{
   public sealed void Update()
   {
      CustomUpdate();
   }

   virtual public void CustomUpdate(){};
}

public class Child : MyBaseClass
{
   override public void CustomUpdate()
   {

   }
}

これによって、誤って override することを防ぎますが、それでも Unity のメッセージにフックされることがある。このパターンがよくと思わない理由のひとつは、物事の順序が問題になることです。上の例では、クラスが自身の更新をした後に、子クラスで更新があるかもしれません。

最後は、整合性がとれないという指摘だと思います。

30. インターフェースをゲームロジックから分離すること

インターフェースのコンポーネントは、通常だと使用されるゲームについて何も知らないはずです。
認識するために必要なデータを与え、イベントを購読して、ユーザーがそれらと相互に作用したときにわかるようにしておきます。

インターフェースのコンポーネントは、ゲームロジックを行うべきではないです。入力をフィルタリングして有効であることを確認することはできるけど、主なルールチェックは別のところですべきです。

多くのパズルゲームでは、コマはインターフェースの延長線上にあって、ルールを含むべきではありません。(例えば、チェスの駒は駒自体が次の手を計算してはいけない)

同様に、入力はその入力に基づいて動作するロジックから切り離されるべきです。入力コントローラーを使用して、手を動かす意図をだけをアクターに通知します。(コントローラーの操作自体ではない)

ユーザーがリストの中から武器を選択する UI コンポーネントを例にします。これらのクラスがゲームについて知っていることは Weapon クラスだけです。(Weapon クラスは、このコンテナに表示するデータ自身です)逆にゲームはコンテナーについて、何も知りません。

public WeaponSelector : MonoBehaviour
{
   public event Action OnWeaponSelect {add; remove; } 

   public void OnInit(List  weapons)
   {
      foreach(var weapon in weapons)
      {

          var button = ... //Instantiates a child button and add it to the hierarchy
 
          buttonOnInit(weapon, () => OnSelect(weapon)); 
          // child button displays the option, 
          // and sends a click-back to this component
      }
   }
   public void OnSelect(Weapon weapon)
  {
      if(OnWepaonSelect != null) OnWeponSelect(weapon);
   }
}

public class WeaponButton : MonoBehaviour
{
    private Action<> onClick;

    public void OnInit(Weapon weapon, Action onClick)
    {
        ... //set the sprite and text from weapon

        this.onClick = onClick;
    }

    public void OnClick() //Link this method in as the OnClick of the UI Button component
    {
       Assert.IsTrue(onClick != null);  //Should not happen

       onClick();
    }    
}

旧 Tips 31 と同じ内容だと思います。個人的には、GUI クラスをすべてクリアしたとしても、そのゲームはコンパイルできるべきだ、という文言が好きでした。疎結合感が伝わりやすい。

31. コンフィグ・ステート・記録を分離すること

  • コンフィグに関する変数
    これは、インスペクターで微調整される変数で、プロパティを通してオブジェクトを定義します。たとえば、maxHealth のような値です。
  • ステート(状態)に関する変数
    これは、オブジェクトの現在の状態を決定する変数で、ゲームがセーブをサポートしているならセーブする必要がある変数です。たとえば、currentHealth のような値です。
  • 記録 (Bookkeeping) に関数変数
    これは、スピード・利便性・移り変わる状態のために使用する変数です。それらはステートに関する変数から決定することができます。たとえば、previousHealth のような変数です。

これらのタイプの変数を分離することで、何を変更できるのか、何を保存する必要があるのか、何をネットワーク経由で送受信する必要があるのかを容易に知ることができ、(変数の管理に対して)ある程度の強制力を持つことができるようになります。単純な例を示します。

public class Player
{
   [Serializable]
   public class PlayerConfigurationData
   {
      public float maxHealth;
   }

   [Serializable]
   public class PlayerStateData
   {
      public float health;
   }

   public PlayerConfigurationData configuration;
   private PlayerState stateData;

   //book keeping
   private float previousHealth;

   public float Health
   {
      public get { return stateData.health; }
      private set { stateData.health = value; }
   }
}

旧 Tips 32 と同じ内容かもしれません。サンプルを見る限りだと Bookkeeping は Memento に近いようにも思いました。

32. インデックスで関連づけた public 配列の使用を避けること

たとえば、武器の配列、弾丸の配列、パーティクルの配列を定義してはいけません。

public void SelectWeapon(int index)
{ 
   currentWeaponIndex = index;
   Player.SwitchWeapon(weapons[currentWeapon]);
}
 
public void Shoot()
{
   Fire(bullets[currentWeapon]);
   FireParticles(particles[currentWeapon]);
}

この場合、コード中でやるというより、インスペクターで間違わないように設定するべきです。いっそのこと、3つの変数をカプセル化したクラスを定義して、それを配列にします。

[Serializable]
public class Weapon
{
   public GameObject prefab;
   public ParticleSystem particles;
   public Bullet bullet;
}

コードがすっきりしているように見えるけど、(大切なのは)インスペクターでデータを設定する際にミスをしづらくなります。

旧 Tips 35 と同じ内容だと思います。

33. シーケンス以外のデータ(連続性のないデータ)に配列を使用することを避けるべき

たとえば、プレイヤーは、3つの攻撃タイプを持っているとします。それぞれ、今に装備している武器を使用するけど、異なる弾を生成して、異なる動作をします。

3つの弾丸を配列に入れて、このようなロジックを使いたくなるかもしれませんが、避けるべきです。

public void FireAttack()
{
   /// behaviour
   Fire(bullets[0]);
}
 
public void IceAttack()
{
   /// behaviour
   Fire(bullets[1]);
}
 
public void WindAttack()
{
   /// behaviour
   Fire(bullets[2]);
}

列挙型はコードの見栄えをよくしますが、インスペクターではできません。

public void WindAttack()
{
   /// behaviour
   Fire(bullets[WeaponType.Wind]);
}

別々の変数を使ったほうが、どの内容を入れるのかを示すのに役立つように、名前は別の変数を使ったほうがよいでしょう。クラスを使ってすっきりさせましょう。

[Serializable]
public class Bullets
{
   public Bullet fireBullet;
   public Bullet iceBullet;
   public Bullet windBullet;
}

補足すると、これは3つの属性(火・氷・風)以外のデータがないことを前提にしています。(拡張性に留意)

旧 Tips 36 の内容と同じだと思います。

34. インスペクターの中をすっきりさせるために、Serializable なクラスにしてデータをグループ化すること

いくつかのエンティティに、微調節可能な変数がたくさんあるとき、インスペクターで適切な変数を調節するのはひどい悪夢みたいな作業になるかもしれません。これを簡単にするためには、次の手順に従ってみてください:

  • 変数のグループに対して個別のクラスを定義する
  • それらを public なクラスにして、Serializable 属性を付与する
  • プライマリークラス(項1,2のクラスを含むクラス)では、定義されたそれぞれのクラスを public 変数で定義する
  • これらの変数はシリアライズ可能なので Awake() や Start() で初期化しない
  • (コード中の)定義に値を代入することで、これまでと同じようにデフォルト値を指定することができる

これによってインスペクター内で折りたたみができるようになるので、変数のグループを扱いやすくなる。

[Serializable]
public class MovementProperties //Not a MonoBehaviour!
{
   public float movementSpeed;
   public float turnSpeed = 1; //default provided
}
 
public class HealthProperties //Not a MonoBehaviour!
{
   public float maxHealth;
   public float regenerationRate;
}
 
public class Player : MonoBehaviour
{
   public MovementProperties movementProeprties;
   public HealthPorperties healthProeprties;
}

旧 Tips 37 と同じ内容だと思います。

35. public フィールドを使用しておらず MonoBehaviour ではないクラスはシリアライズ可能なクラスにすること

インスペクターがデバッグモードになっているときに、インスペクターでクラスのフィールドを確認することができます。これは入れ子になっているクラス (private or public) でも動作します。

36. インスペクターの微調整できる変数は、コード内で変更を加えないようにすること

インスペクターで調整可能な変数は設定変数であるため、実行時定数として扱われ、状態に関する変数として二重に扱うべきではないです。

このルールに従うことで、コンポーネントの状態を初期状態にリセットするメソッドが書きやすくなり、変数はなにをするのか明確になります。

public class Actor : MonoBehaviour
{
   public float initialHealth = 100;
   
   private float currentHealth;

   public void Start()
   {
      ResetState();
   }   

   private void Respawn()
   {
      ResetState();
   } 

   private void ResetState()
   {
      currentHealth = initialHealth;
   }
}