Unityで簡単な2D脱出ゲームを作ってウェブサイトで公開してみよう 〜クラスの継承〜

この記事はだいぶ前に書かれたものなので情報が古いかもしれません
この場合はinheritと言った方が良いのかextendと言った方が良いのか

この記事を三行にまとめると

親のすねをかじるのは人間もプログラミングも変わらないってことですね
機会があればと言った場合にはだいたいその機会は永遠にやってこない
星新一賞に原稿送ろうと思ったら文字数オーバーで落とされるレベルだぞ
前回の最後でも言いましたが、今回はゲーム作りっていうよりプログラミングに関するお話がメインです。俺はもうC#を骨の髄までしゃぶり尽くしたぜって人にとっては特に参考になるような話ではないですが、よかったらおつき合いください。

動画はこちらです。



ちなみに動画の最初に出てくる「先帝の無念を晴らす!」ってのはロマサガ2の皇帝継承のあれです。クラスの継承に関する話だからってことで……。



クラスの継承

ゲームオブジェクト親子関係があるように、C#のスクリプトにも親子関係のような仕組みがあって、これをクラスの継承と言います。この継承を使うと親クラスの変数や関数を子供が使用できるようになります。「他のクラスの関数を使う方法って今までもやってなかったっけ?」と思うかもしれませんが、あれとは少し仕組みが異なります。

例えば前回や前々回でアイテムを取得したり使用するための変数や関数を作りました。あの時はアイテムごとにスクリプトファイルを作って、ほぼ同じ内容のコードをそれぞれに書くというやり方をしましたが、どうせほぼ同じ内容だったら一箇所にまとめて共通化できた方が楽ですよね。共通化しない場合、もし処理内容を変更したいとなったら全部のスクリプトの同じ部分を直したりしなきゃいけないわけで、効率が悪い上に自分では全部を修正したつもりでも漏れがあるかもしれない。そういうのを避けるためにもまとめられるものはまとめておくに越したことはない。そんな時に役立つのが継承システムです。

クラスを継承する方法はいたって簡単で、スクリプトのクラス名の横に親クラスの名前を書けば良いだけです。

例えば椅子を取得したり使用するためのスクリプトは以下のような内容になっています。

public class Chair : MonoBehaviour {
  public GameObject get;
  public GameObject item;
  public Dialog hint;
  public GameObject detail;
  public GameObject selected;
  public GameObject set;
  private bool used;

  public void GetItem() {
    get.SetActive(false);
    item.SetActive(true);
    hint.OpenDialog(detail);
  }

  public void SelectItem() {
    if(selected.activeSelf) {
      hint.OpenDialog(detail);
    else if(item.activeSelf && !used) {
      GameObject[] selects = GameObject.FindGameObjectsWithTag("Selected");
      foreach(GameObject select in selects) {
        select.SetActive(false);
      }

      selected.SetActive(true);
    }
  }

  public void SetChair() {
    if(selected.activeSelf) {
      set.SetActive(true);
    }
  }
}

一番上の行を見ると「public class Chair : MonoBehaviour」と書いてありますね。この「MonoBehaviour」というのが現在の親クラスです。スクリプトを新規に作成するとデフォルトではMonoBehaviourが親になっています。ここを自分で作った別のスクリプトのクラス名に変えればそのスクリプトを継承したことになります。

ではここで「Item」というスクリプトを作って継承してみましょう。

//Itemスクリプト
public class Item : MonoBehaviour {

}

//Chairスクリプト
public class Chair : Item {
  public GameObject get;
  public GameObject item;
  public Dialog hint;
  public GameObject detail;
  public GameObject selected;
  public GameObject set;
  private bool used;

  public void GetItem() {
    get.SetActive(false);
    item.SetActive(true);
    hint.OpenDialog(detail);
  }

  public void SelectItem() {
    if(selected.activeSelf) {
      hint.OpenDialog(detail);
    else if(item.activeSelf && !used) {
      GameObject[] selects = GameObject.FindGameObjectsWithTag("Selected");
      foreach(GameObject select in selects) {
        select.SetActive(false);
      }

      selected.SetActive(true);
    }
  }

  public void SetChair() {
    if(selected.activeSelf) {
      set.SetActive(true);
    }
  }
}

これだけです。これでChairクラスがItemクラスの子供になりました。

ここでChairクラスの中にある変数や関数の一部をItemクラスに移動させてみます。

//Itemスクリプト
public class Item : MonoBehaviour {
  public GameObject get;
  public GameObject item;
  public Dialog hint;
  public GameObject detail;
  public GameObject selected;
  private bool used;

  public void GetItem() {
    get.SetActive(false);
    item.SetActive(true);
    hint.OpenDialog(detail);
  }

  public void SelectItem() {
    if(selected.activeSelf) {
      hint.OpenDialog(detail);
    else if(item.activeSelf && !used) {
      GameObject[] selects = GameObject.FindGameObjectsWithTag("Selected");
      foreach(GameObject select in selects) {
        select.SetActive(false);
      }

      selected.SetActive(true);
    }
  }
}

//Chairスクリプト
public class Chair : Item {
  public GameObject set;

  public void SetChair() {
    if(selected.activeSelf) {
      set.SetActive(true);
    }
  }
}

ChairクラスからGetItem関数やSelectItem関数がなくなりました。でも先ほど言ったように子供のクラスや親クラスの変数や関数を使用できるので、この状態でもChairクラスはGetItemやSelectItemを呼び出せます。インスペクターにItemスクリプトをセットする必要はありません。Chairスクリプトをセットすれば親クラスの関数もセットできるし、変数の入力欄も出てきます。

こうすると何が良いのかと言うと、Itemスクリプトを継承した子供たちは全員GetItem関数やSelectItem関数を使用できるようになる点です。

//Itemスクリプト
public class Item : MonoBehaviour {
  public GameObject get;
  public GameObject item;
  public Dialog hint;
  public GameObject detail;
  public GameObject selected;
  private bool used;

  public void GetItem() {
    get.SetActive(false);
    item.SetActive(true);
    hint.OpenDialog(detail);
  }

  public void SelectItem() {
    if(selected.activeSelf) {
      hint.OpenDialog(detail);
    else if(item.activeSelf && !used) {
      GameObject[] selects = GameObject.FindGameObjectsWithTag("Selected");
      foreach(GameObject select in selects) {
        select.SetActive(false);
      }

      selected.SetActive(true);
    }
  }
}

//椅子
public class Chair : Item {
  public GameObject set;

  public void SetChair() {
    if(selected.activeSelf) {
      set.SetActive(true);
      used = true;
      selected.SetActive(false);
      item.color = new Color(1, 1, 1, 0.4f);
    }
  }
}

//カッター
public class Cutter : Item {
  public GameObject open;

  public void OpenCardboard() {
    if(selected.activeSelf) {
      open.SetActive(true);
      used = true;
      selected.SetActive(false);
      item.color = new Color(1, 1, 1, 0.4f);
    }
  }
}

//リモコン
public class Controller : Item {
  public GameObject display;

  public void OnDisplay() {
    if(selected.activeSelf) {
      display.SetActive(true);
      used = true;
      selected.SetActive(false);
      item.color = new Color(1, 1, 1, 0.4f);
    }
  }
}

//ドライバー
public class Driver : Item {
  public GameObject cover;

  public void RemoveCover() {
    if(selected.activeSelf) {
      cover.SetActive(false);
      used = true;
      selected.SetActive(false);
      item.color = new Color(1, 1, 1, 0.4f);
    }
  }
}

椅子以外にもカッター、リモコン、ドライバーを全てItemスクリプトの子供にしてみました。これはコードを省略して書いてるのではなくて、これでどのクラスもGetItem関数やSelectItem関数が使用できる状態になっています。

この時、Itemクラスは変数の値を共有するわけではなく、それぞれのスクリプトの親として独立しています。だからitem変数にセットされるアイテムの中身も違うし、selectedのアクティブ状態やusedの値もアイテムごとに異なります。値が共有されるのはあくまでも同じオブジェクトにセットしたスクリプトを使用している場合です。上記の場合、ChairやCutter、Controller、Driverは別々のオブジェクトにセットされているので、親Itemも別扱いです。そこが変数にスクリプトをセットする場合との大きな違いですかね。

こうやって親クラスを作って関数を共通化すれば、例えばアイテム取得時の動きを変えようと思った時にItemスクリプトのGetItem関数だけ修正すれば良いので、作業がだいぶ楽になりますね。

もっと言うと上記のコードのうち、SetChairとかOpenCardboardとかも変数の名前が違うだけで処理内容は一緒ですね。だからこれもUseItemのような関数を親側に作って共通化しちゃうことも可能です。

//Itemスクリプト
public class Item : MonoBehaviour {
  public GameObject item;
  public GameObject selected;
  public GameObject target;
  private bool used;

  public void UseItem(bool active) {
    if(selected.activeSelf) {
      target.SetActive(active);
      used = true;
      selected.SetActive(false);
      item.color = new Color(1, 1, 1, 0.4f);
    }
  }
}

//椅子
public class Chair : Item {}

//カッター
public class Cutter : Item {}

//リモコン
public class Controller : Item {}

//ドライバー
public class Driver : Item {}

これで親の変数と関数だけでアイテムの取得も選択も使用もできる状態になりました。親の力で子供が活躍する……親のすねをかじるのは人間もプログラミングも変わらないってことですね。

UseItemに引数を一つ用意してますが、これはアイテムによって対象のオブエジェクトをアクティブにする場合と非アクティブにする場合があるからです(椅子やダンボールはアクティブになりますがドライバーを使った時はカバーが非アクティブになる)



親の関数を子供が使用する場合

継承してもGetItem、SelectItem、UseItemは今まで通りインスペクターでセットできると言いましたが、じゃあ子供のスクリプトから親の関数を呼び出すにはどうするか。

基本的にはそのまま呼び出せば良いです。

例えば本作では暖炉の奥にあるパネルを取得する際にトングが選択状態である必要があったので、GetItem関数の中にif文を一つ追加しました。

//Itemスクリプト
public class Item : MonoBehaviour {
  public GameObject get;
  public GameObject item;
  public Dialog hint;
  public GameObject detail;

  public void GetItem() {
    get.SetActive(false);
    item.SetActive(true);
    hint.OpenDialog(detail);
  }
}

//Panelスクリプト
public class Brazil : Item {
  public GameObject tongs_selected;

  public void GetPanel() {
    if(tongs_selected.activeSelf) {
       GetItem();
    }
  }
}

こんな感じでトングが選択状態であれば親のGetItemを呼び出すといったやり方もできます。

ここで注意が必要なのは、子供側にも同じ名前の関数があった場合。今は子供側の関数名をこっそり「GetPanel」に書き換えましたが、子供側にもGetItemという名前の関数があると親のGetItem関数は呼ばれません。

//Itemスクリプト
public class Item : MonoBehaviour {
  public GameObject get;
  public GameObject item;
  public Dialog hint;
  public GameObject detail;

  public void GetItem() {
    get.SetActive(false);
    item.SetActive(true);
    hint.OpenDialog(detail);
  }
}

//Panelスクリプト
public class Brazil : Item {
  public GameObject tongs_selected;

  public void GetItem() {
    if(tongs_selected.activeSelf) {
       GetItem();
    }
  }
}

同名の関数があると子供側の関数が呼び出されるので、この書き方だと自分自身を永遠に呼び続けるという無限ループが発生します。C#は無限ループを嫌うので(他の言語でもそうだけど)、このままだと「ちょ、メモリ不足でカストロの二の舞になる」みたいなエラーを出してきます。

もしも同じ名前の関数がある時は、「base」をつけることで親側の関数を呼び出せます。

//Itemスクリプト
public class Item : MonoBehaviour {
  public GameObject get;
  public GameObject item;
  public Dialog hint;
  public GameObject detail;

  public void GetItem() {
    get.SetActive(false);
    item.SetActive(true);
    hint.OpenDialog(detail);
  }
}

//Panelスクリプト
public class Brazil : Item {
  public GameObject tongs_selected;

  public void GetItem() {
    if(tongs_selected.activeSelf) {
       base.GetItem();
    }
  }
}

これで動作はします。ただ「同じ名前の関数があるけど大丈夫か?」という警告は出るので、それが気持ち悪いなら子供側の関数名に「new」をつけることで回避できます。

//Itemスクリプト
public class Item : MonoBehaviour {
  public GameObject get;
  public GameObject item;
  public Dialog hint;
  public GameObject detail;

  public void GetItem() {
    get.SetActive(false);
    item.SetActive(true);
    hint.OpenDialog(detail);
  }
}

//Panelスクリプト
public class Brazil : Item {
  public GameObject tongs_selected;

  public new void GetItem() {
    if(tongs_selected.activeSelf) {
       base.GetItem();
    }
  }
}

それかオーバーライドという機能を使っても良いです。

//Itemスクリプト
public class Item : MonoBehaviour {
  public GameObject get;
  public GameObject item;
  public Dialog hint;
  public GameObject detail;

  public virtual void GetItem() {
    get.SetActive(false);
    item.SetActive(true);
    hint.OpenDialog(detail);
  }
}

//Panelスクリプト
public class Brazil : Item {
  public GameObject tongs_selected;

  public override void GetItem() {
    if(tongs_selected.activeSelf) {
       base.GetItem();
    }
  }
}

オーバーライドする場合は親側のクラスに「virtual」をつけて子供側のクラスに「override」をつけます。この時、親と子の関数はスコープが一緒じゃないといけません。親がpublicで子がprivateとかだとエラーになります。また親側の関数に引数がある場合は子供側にも同じ型の引数がないとやはりエラーになります。

newとオーバーライドの違いは何だって言われると実はよく分からんのですが……「C# new override 違い」とかで検索するともんのすげーたくさん情報は出てくるんだけど、いまいち理解しきれてないもんで、ここでは説明しないです。すみません。もうちょい理解が深まったらそのうち語る機会もあるかも……まあ今までの経験上、そう言って本当に語る機会が来たことって一度もないんですけど。



スコープについて

だいぶ前の回で「インスペクターで表示したい変数や関数はpublic、そうでなかったらprivateにしろ」みたいなことを言いましたが、スコープがprivateだとインスペクターに出てこないどころか、親子の間でも使用することができません。privateは自分自身のクラスの中で使用可能なスコープなので、例えばGetItemやSelectItemがprivateだと子供側から呼び出せなくなります。

もちろんpublicなら問題ないですが、もしインスペクターに出す必要はないけど親子間では使いたいみたいな時は「protected」というスコープにすればオッケーです。

protectedは継承しているクラス間では使用できますが、変数にセットしたスクリプトでは使用できません。

//Dialogスクリプト
public class Dialog : MonoBehaviour {
  protected void OpenDialog(GameObject detail) {
    dialog.SetActive(true);
    detail.SetActive(true);
  }
}

//Itemスクリプト
public class Item : MonoBehaviour {
  public GameObject get;
  public GameObject item;
  public Dialog hint;
  public GameObject detail;

  public virtual void GetItem() {
    get.SetActive(false);
    item.SetActive(true);
    hint.OpenDialog(detail);
  }
}

上記はItemスクリプトでDialogスクリプトのOpenDialog関数を使用しようとしている例ですが、ここでOpenDialog関数のスコープがprotectedだとエラーになります。こういう時はpublicでなければなりません。



子供たちの間で値を共有する

動画の4:40あたりで「子供たちで親の変数を共有する方法は紹介できる機会があればやります」と言っているのですが、さっきも言ったように機会があればと言った場合にはだいたいその機会は永遠にやってこないので、今ここで紹介します。

上で説明した通り、複数の子供が同じ親スクリプトを継承しても変数の値は共有されませんが、変数の前にstaticをつけるとその変数の値は共有されます。

//親スクリプト
public class Parent : MonoBehaviour {
  protected int not_share = 0;
  protected static int share = 0;

  public AddNumber() {
    not_share += 1;
    Debug.Log(not_share);

    share += 1;
    Debug.Log(share);
  }
}

//子供
public class Child1: Parent {}
public class Child2: Parent {}
public class Child3: Parent {}

例えばこんな感じのコードがあったとして、Child1、Child2、Child3からそれぞれ一回ずつAddNumber関数を呼び出した場合、not_shareの方は値が共有されないので三回とも1が出力されますが、shareの方は値を共有しているのでどの子供から呼び出しても1ずつ加算され、三回目の呼び出し時には3が出力されます。

今回はやってませんが、staticな変数ならどのアイテムが選択中かなどの情報も親のItemスクリプトで共有することができるので、その変数を使って他のアイテムが選択された時に現在選択中のものを解除する動きを作ったりもできます。

staticはスコープの前に書いても大丈夫です。

static protected int share = 0;






今回はここまでにしときましょう。コードを書きすぎちゃったせいで実はこの記事、10000文字を軽く超えてるのよ。10000文字って……星新一賞に原稿送ろうと思ったら文字数オーバーで落とされるレベルだぞ。まあ文字数制限を守ったところで、クオリティが低ければどのみち落とされてるんですがね。僕のように(泣)

動画的には次はテキストメッセージの表示とそれを自動で消す方法の回なんですが、記事の方はその前にインスペクターを使わずスクリプトの中で変数にゲームオブジェクトをセットする方法を紹介します。

それじゃあ次回もよろしくお願いしごにじゅう。



本シリーズの記事の一覧はこちら
Unityで簡単な2D脱出ゲームを作ってウェブサイトで公開してみよう 〜エピローグ〜
 もしかしたら何か関連しているかも? 
 質問や感想などお気軽にコメントしてください