クラスを受け継ごう:継承とabstract【Unityスタートガイド6-14】

左右に動くスライム、円を描いて飛ぶコウモリ、動かないゴーレムにまとめて3回ダメージを与えると、スライムとコウモリが消え、Consoleにダメージのログが並ぶ

スライム、コウモリ、ゴーレム・・・。敵の種類が増えてくると、「HPを持っていて、ダメージを受けて、0になったら消える」という同じ処理を、敵の数だけコピーすることになりがちです。コピーしたコードは、あとで直すときに全部を直さないといけません。今回は、共通の部分を1つのクラスにまとめて、違うところだけをそれぞれのクラスに書ける継承(けいしょう)と、abstract(アブストラクト)をご紹介します!

目次

この記事で学べること

  • 継承とは何か(いつも書いている: MonoBehaviourの意味)
  • 親クラスと子クラスの作り方と、protectedの使いどころ
  • virtualとoverrideで、子クラスごとに動きを変える方法
  • abstractで「形だけ決めて、中身は子クラスにまかせる」方法
  • 親クラスの型で、種類の違うものをまとめて扱う方法
  • 応用編:ScriptableObjectと組み合わせて、効果をアセットにする

前提知識

継承ってどんなもの?

実は、継承はいつも使っています。スクリプトを作ると最初に書かれている、この1行です。

public class Player : MonoBehaviour

: MonoBehaviourは「MonoBehaviourを受け継いだPlayerクラスを作る」という意味です。MonoBehaviourを受け継いでいるので、自分で書いていないtransformやGetComponent、Destroyが使えますし、StartやUpdateもUnityが呼んでくれます。

  • 親クラス(基底クラス):受け継がれる側。例:MonoBehaviour
  • 子クラス(派生クラス):受け継ぐ側。例:Player
  • 子クラスは、親クラスの変数とメソッドをそのまま使える。そこに自分だけの変数やメソッドを足せる

「親の持ち物を全部もらって、自分の分を書き足す」のが継承です。今回は、この「親」を自分で作ってみます。

親クラスと子クラスを作ってみよう

親クラス:Enemy

まずは、どの敵にもある「HP」と「ダメージを受ける」を持った親クラスを作りましょう。

  • Projectウインドで右クリック>Create>MonoBehaviour Script で「Enemy」を作り、中身を次のように書き換える
using UnityEngine;

// 敵の共通部分。HP とダメージの処理をまとめる
public class Enemy : MonoBehaviour
{
    [SerializeField] protected int hp = 30;

    public void TakeDamage(int damage)
    {
        hp -= damage;
        Debug.Log($"{name}に{damage}ダメージ!残りHP:{hp}");
        if (hp <= 0)
        {
            Destroy(gameObject);
        }
    }
}
  • protected
    • privateと同じく、ほかのクラスからは見えない。ただし、子クラスからは使える
    • 子クラスでもHPを触りたいので、privateではなくprotectedにする
書き方自分のクラス子クラスほかのクラス
private使える使えない使えない
protected使える使える使えない
public使える使える使える

迷ったらprivateにしておき、子クラスで使いたくなったときにprotectedへ変えればOKです。

子クラス:Slime

次に、Enemyを受け継いだSlimeを作ります。: MonoBehaviourのところを: Enemyにするだけです。

using UnityEngine;

// スライム。Enemy を受け継いで、左右にぷるぷる動く
public class Slime : Enemy
{
    private Vector3 startPosition;

    private void Start()
    {
        startPosition = transform.position;
    }

    private void Update()
    {
        float offset = Mathf.Sin(Time.time * 3f) * 0.5f;
        transform.position = startPosition + new Vector3(offset, 0f, 0f);
    }
}
  • : Enemy
    • EnemyはMonoBehaviourを受け継いでいるので、SlimeもMonoBehaviourとしてGameObjectに付けられる
  • HPとTakeDamageは書いていない
    • 親のEnemyに書いてあるので、Slimeにも最初からある
Slimeを選択したInspector。Slimeには書いていないHpの欄(30)が表示されている

InspectorにHpの欄が出ていれば成功!Slimeには書いていないのに、親のEnemyから受け継いでいるのが分かります。

子クラスごとに動きを変えよう:virtualとoverride

次は、硬い敵「ゴーレム」を作ります。ゴーレムは、受けるダメージが半分になるようにしたいと思います。

親のメソッドを子クラスで書き換えるには、親にvirtual、子にoverrideの2つをセットで使います。

  • 親のメソッドにvirtual(書き換えてもいいよ、の印)
  • 子のメソッドにoverride(書き換えます、の印)

まずは、EnemyのTakeDamageにvirtualを付けます。

    public virtual void TakeDamage(int damage)

ゴーレムを作りましょう。

using UnityEngine;

// ゴーレム。硬いので、受けるダメージが半分になる
public class Golem : Enemy
{
    public override void TakeDamage(int damage)
    {
        base.TakeDamage(damage / 2);
    }
}
  • override
    • 親のTakeDamageの代わりに、こちらが呼ばれる
  • base.TakeDamage(…)
    • baseは親クラスのこと。親のTakeDamage(HPを減らして、0なら消える)を、半分にしたダメージで呼んでいる
    • 「違うところだけ書いて、残りは親にまかせる」のが継承のいいところ

base.を付けずにTakeDamage(damage / 2)と書くと、自分(GolemのTakeDamage)を呼び続けて止まらなくなります。親の処理を使いたいときは、必ずbase.を付けましょう。

形だけ決めて、中身は子にまかせる:abstract

どの敵も「動く」のは同じですが、動き方はバラバラです。こういうときはabstractを使って、「動くメソッドがあること」だけを親で決めておきます。

Enemyを次のように書き換えます。

using UnityEngine;

// 敵の共通部分。動き方は子クラスで決める
public abstract class Enemy : MonoBehaviour
{
    [SerializeField] protected int hp = 30;

    private void Update()
    {
        Move();
    }

    // 動き方。子クラスで必ず書く
    protected abstract void Move();

    public virtual void TakeDamage(int damage)
    {
        hp -= damage;
        Debug.Log($"{name}に{damage}ダメージ!残りHP:{hp}");
        if (hp <= 0)
        {
            Destroy(gameObject);
        }
    }
}
  • abstract class
    • 「そのままでは使わない、受け継がれるためのクラス」という印。EnemyそのものはGameObjectに付けられなくなる
  • protected abstract void Move();
    • 中身({ })を書かずに、;で終わる
    • 子クラスは、このメソッドをoverrideで必ず書かないといけない。書き忘れるとエラーになるので、作り忘れを防げる
  • Update
    • 親が毎フレームMoveを呼ぶ。子クラスは「どう動くか」だけを書けばいい

子クラスもそれぞれ書き換えます。Slimeは、UpdateをMoveにします。

using UnityEngine;

// スライム。左右にぷるぷる動く
public class Slime : Enemy
{
    private Vector3 startPosition;

    private void Start()
    {
        startPosition = transform.position;
    }

    protected override void Move()
    {
        float offset = Mathf.Sin(Time.time * 3f) * 0.5f;
        transform.position = startPosition + new Vector3(offset, 0f, 0f);
    }
}

コウモリは、円を描いて飛ぶようにします。

using UnityEngine;

// コウモリ。円を描いて飛ぶ
public class Bat : Enemy
{
    private Vector3 centerPosition;

    private void Start()
    {
        centerPosition = transform.position;
    }

    protected override void Move()
    {
        float angle = Time.time * 2f;
        Vector3 offset = new Vector3(Mathf.Cos(angle), Mathf.Sin(angle), 0f) * 0.8f;
        transform.position = centerPosition + offset;
    }
}

ゴーレムは動かないので、中身は空っぽでOK。それでもMoveは書く必要があります。

using UnityEngine;

// ゴーレム。動かない。硬いので、受けるダメージが半分になる
public class Golem : Enemy
{
    protected override void Move()
    {
        // 動かない
    }

    public override void TakeDamage(int damage)
    {
        base.TakeDamage(damage / 2);
    }
}

子クラスにUpdateを書くと、親のUpdateは呼ばれなくなります(Unityは子のUpdateだけを呼びます)。親がUpdateを使っているときは、子クラスではUpdateを書かずに、今回のMoveのように親から呼んでもらうメソッドに書きましょう。

スライム

子クラスにUpdateを書いたら、親のUpdateが動かなくなって、しばらく悩みました・・・

フェアリー

エラーが出ないから気づきにくいよね。「毎フレームの処理は親が呼ぶ」って決めておくと安心だよ!

親の型でまとめて扱おう

継承のもう1つのいいところは、「Enemyの仲間なら、全部Enemyとして扱える」ことです。スライムもコウモリもゴーレムも、List<Enemy>にまとめて入れられます。

クリックしたら、全部の敵にまとめて10ダメージを与えるスクリプトを作ってみましょう。

using System.Collections.Generic;
using UnityEngine;

// クリックすると、登録した敵全員に10ダメージを与える
public class AttackAll : MonoBehaviour
{
    [SerializeField] private List<Enemy> enemies = new List<Enemy>();
    [SerializeField] private int damage = 10;

    private InputSystem_Actions inputActions;

    private void Awake()
    {
        inputActions = new InputSystem_Actions();
    }

    private void OnEnable()
    {
        inputActions.Enable();
    }

    private void OnDisable()
    {
        inputActions.Disable();
    }

    private void Update()
    {
        if (inputActions.Player.Attack.WasPressedThisFrame())
        {
            foreach (Enemy enemy in enemies)
            {
                // 倒されて消えた敵は null になるので飛ばす
                if (enemy == null)
                {
                    continue;
                }
                enemy.TakeDamage(damage);
            }
        }
    }
}
  • List
    • 中身がSlimeでもBatでもGolemでも、Enemyの仲間なら入れられる
  • enemy.TakeDamage(damage)
    • 呼んでいるのは同じTakeDamageでも、ゴーレムだけは自分で書いた方(半分になる方)が呼ばれる
    • AttackAllは、敵の種類を知らなくていい。敵の種類が増えても、AttackAllを直す必要がない
  • Attack
    • InputSystem_ActionsのAttackは、最初からマウスの左クリックやEnterキーなどに割り当てられている
AttackerのAttack AllのEnemiesにSlime・Bat・Golemが入り、Damageは10

動かして確認してみよう!

  • Hierarchyで2D Object>Sprites>Circle を2つ、Square を1つ作り、名前をSlime・Bat・Golemにする(色を変えると分かりやすい)
  • それぞれにSlime・Bat・Golemのスクリプトを付ける
  • Golemは、InspectorでHpを60にする
  • 空のGameObject「Attacker」を作ってAttackAllを付け、Enemiesに3体をドラッグ&ドロップ
  • 再生して、Gameビューを左クリック
Slimeに10ダメージ、Batに10ダメージ、Golemに5ダメージのログ。Golemだけ半分になっている
  • スライムは左右に、コウモリは円を描いて動いている
  • クリックすると、Consoleにダメージのログが3行出る
  • ゴーレムだけダメージが5(半分)になっている
  • 3回クリックすると、スライムとコウモリが消える

応用編!ScriptableObjectと組み合わせる

継承は、12-8 ScriptableObjectでデータを作ろうと組み合わせると、もっと便利になります。ここでは「アイテムを使ったときの効果」をアセットにして、Inspectorで組み合わせられるようにしてみます。

  • 回復する
  • 攻撃力が上がる
  • 回復して、攻撃力も上がる(2つの効果を持ったアイテム)

プレイヤーのステータス

効果をかける相手として、プレイヤーのステータスを用意します。

using UnityEngine;

// プレイヤーのステータス
public class PlayerStatus : MonoBehaviour
{
    public int hp = 50;
    public int maxHp = 100;
    public int attack = 10;
}

効果の親クラス

効果の親クラスを、ScriptableObjectを受け継いだabstractクラスで作ります。

using UnityEngine;

// アイテムの効果の共通の形。中身は子クラスで決める
public abstract class ItemEffect : ScriptableObject
{
    public abstract void Apply(PlayerStatus player);
}

効果の子クラス

回復の効果と、攻撃力が上がる効果を作ります。ファイルは1つのクラスに1つずつ作り、ファイル名とクラス名をそろえます。

using UnityEngine;

// HP を回復する効果
[CreateAssetMenu(fileName = "HealEffect", menuName = "Game/Effect/Heal")]
public class HealEffect : ItemEffect
{
    [SerializeField] private int amount = 30;

    public override void Apply(PlayerStatus player)
    {
        player.hp = Mathf.Min(player.hp + amount, player.maxHp);
        Debug.Log($"HPが{amount}回復!HP:{player.hp}");
    }
}
using UnityEngine;

// 攻撃力が上がる効果
[CreateAssetMenu(fileName = "PowerUpEffect", menuName = "Game/Effect/Power Up")]
public class PowerUpEffect : ItemEffect
{
    [SerializeField] private int amount = 5;

    public override void Apply(PlayerStatus player)
    {
        player.attack += amount;
        Debug.Log($"攻撃力が{amount}上がった!攻撃力:{player.attack}");
    }
}
  • [CreateAssetMenu]は子クラスに付ける
    • abstractのItemEffectはアセットにできないので、メニューに出すのは子クラスだけ
  • Mathf.Min
    • 2つのうち小さい方を返す。回復しても最大HPを超えないようにしている

アイテムに効果を持たせる

12-8で作ったItemDataに、効果のListを1行足します。

using System.Collections.Generic;
using UnityEngine;

// アイテム1つ分のデータ。Project ウインドの Create>Game>Item Data でアセットを作れる
[CreateAssetMenu(fileName = "NewItem", menuName = "Game/Item Data")]
public class ItemData : ScriptableObject
{
    public string id;           // セーブデータに保存する名前(英数字で、ほかのアイテムと重ならないように)
    public string itemName;     // 画面に出す名前
    public Sprite icon;         // アイコン
    public int price;           // 値段
    [TextArea] public string description; // 説明文
    public List<ItemEffect> effects = new List<ItemEffect>(); // 使ったときの効果
}
  • List
    • HealEffectのアセットもPowerUpEffectのアセットも、ItemEffectの仲間なので入れられる

アセットを作りましょう。

  • Projectウインドで右クリック>Create>Game>Effect>Heal で「Heal30」を作る(Amount:30)
  • Create>Game>Effect>Power Up で「PowerUp5」を作る(Amount:5)
  • Item Dataの「Elixir」を作り、Item Name:エリクサー、Effectsの「+」で欄を2つ増やして、Heal30とPowerUp5をドラッグ&ドロップ
ElixirのInspector。EffectsにHeal30(Heal Effect)とPowerUp5(Power Up Effect)が入っている

アイテムを使う

最後に、アイテムを使うスクリプトです。

using UnityEngine;

// 再生したら、設定したアイテムを1回使う
public class ItemUser : MonoBehaviour
{
    [SerializeField] private ItemData item;
    [SerializeField] private PlayerStatus player;

    private void Start()
    {
        Debug.Log($"{item.itemName}を使った!");
        foreach (ItemEffect effect in item.effects)
        {
            effect.Apply(player);
        }
    }
}
  • 空のGameObject「Player」にPlayerStatusとItemUserを付ける
  • ItemUserのItemにElixir、PlayerにPlayer自身をドラッグ&ドロップ
  • 再生すると、Consoleに「エリクサーを使った!」「HPが30回復!HP:80」「攻撃力が5上がった!攻撃力:15」が出れば成功!
Consoleに「エリクサーを使った!」「HPが30回復!HP:80」「攻撃力が5上がった!攻撃力:15」。PlayerStatusのHpは80、Attackは15
この形のいいところ
  • 新しい効果(毒を治す、お金が増えるなど)は、ItemEffectの子クラスを1つ足すだけ。ItemUserもItemDataも直さなくていい
  • 効果の組み合わせは、Inspectorでアセットを並べるだけで作れる
  • カードゲームの「カードの効果」や、RPGの「スキルの効果」も同じ形で作れる

よくあるエラーと対処法

エラー:’Slime’ does not implement inherited abstract member ‘Enemy.Move()’

  • 原因:親のabstractメソッドを、子クラスで書いていない
  • 対処法:子クラスにprotected override void Move()を書く(何もしないなら中身は空でOK)

エラー:cannot override inherited member ‘Enemy.TakeDamage(int)’ because it is not marked virtual, abstract, or override

  • 原因:親のメソッドにvirtualを付けずに、子クラスでoverrideした
  • 対処法:親のメソッドにvirtualを付ける

エラー:cannot change access modifiers when overriding ‘protected’ inherited member

  • 原因:親がprotectedなのに、子クラスでpublic overrideやprivate overrideと書いた
  • 対処法:子クラスも親と同じprotected overrideにする

Can’t add script behaviour ‘Enemy’. The script class can’t be abstract!

  • 原因:abstractのクラスは、そのままGameObjectに付けられない
  • 対処法:Slimeなどの子クラスを付ける。これは正しい動きです

親のUpdateが動かない

  • 原因:子クラスにもUpdateを書いた(Unityは子のUpdateだけを呼ぶ)
  • 対処法:子クラスではUpdateを書かず、親から呼ばれるメソッド(今回のMove)に書く

まとめ

  • : 親クラスで、親の変数とメソッドを受け継げる。いつもの: MonoBehaviourも継承
  • 子クラスからも使いたい変数はprotectedにする
  • 子で書き換えたいメソッドは、親にvirtual、子にoverride。親の処理はbase.で呼べる
  • abstractは「形だけ決めて、中身は子にまかせる」。書き忘れはエラーで教えてくれる
  • 親の型(List<Enemy>など)で、種類の違うものをまとめて扱える
  • ScriptableObjectと組み合わせると、効果をアセットにしてInspectorで組み合わせられる

次のステップ

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

目次