ヴァンサバ風ゲームの作り方#5|武器のデータをScriptableObjectでまとめよう

スライムちゃんの周りをホイッパーが回りながら、計量スプーンも飛んでいく様子

前回で反撃出来るようになりましたが、攻撃力や間隔の数字はスクリプトの中に直接書いてある状態でした。武器が2種類、3種類と増えていくと、これでは手が回らなくなります。今回はヴァンサバ風サバイバーゲームのシリーズの5回目として、武器の性能をScriptableObjectにまとめ、2つ目の武器「ホイッパー」を追加してみたいと思います!

目次

今回作るもの

  • 武器の性能(名前・攻撃力・間隔など)をデータのアセットにまとめる
  • 計量スプーンを、そのデータを見て動くように直す
  • 2つ目の武器「ホイッパー」を追加する(プレイヤーの周りを回り、出たり引っこんだりする)
  • 数字を変えたいときは、スクリプトを触らずにデータだけを直す

作る・直すスクリプトは5つです。

スクリプト役割
WeaponData(新しく作る)武器1つ分の性能をまとめたScriptableObject
WhipperOrbit(新しく作る)ホイッパーを出し入れしながら、プレイヤーの周りで回す
Whipper(新しく作る)回っているホイッパー本体。触れた敵にダメージを与える
SpoonThrower(直す)数字をWeaponDataから読む
Spoon(直す)攻撃力と速さをWeaponDataから受け取る

前回の続きから

  • Unityのバージョンは6.3 LTS
    • 記事作成時点では6000.3.22f1で確認しています
  • #4の状態:1秒に1本、一番近い敵に計量スプーンが飛んでいく

ScriptableObjectの基本(アセットの作り方や、MonoBehaviourとの違い)は12-8 ScriptableObjectで紹介しています。

武器のデータを作ろう(WeaponData)

武器は種類によって、必要な数字が違います。計量スプーンには「飛ぶ速さ」が要りますが、ホイッパーには要りません。逆にホイッパーの「回る半径」は、スプーンには関係ありません。

これを1つのクラスに全部書いてしまうと、こうなります。

  • 武器を1種類増やすたびに、みんなが使っているクラスを直すことになる
  • SpoonDataのInspectorに、使いもしない「回る半径」の欄が並ぶ
  • どの欄がどの武器のものか、コメントを読まないと分からない

そこで、共通の部分だけを親のクラスにして、武器の種類ごとに子のクラスを作ります。

  • SurvivorsLike/Scriptsに「WeaponData」を作り、中身を次のように書き換える
using UnityEngine;

// どの武器にもある性能。これ自体のアセットは作らないので abstract
public abstract class WeaponData : ScriptableObject
{
    public string weaponName = "武器";
    public Sprite icon;          // #7の強化カードで使います
    public int damage = 10;      // 1回あたりのダメージ
    public float interval = 1f;  // 攻撃の間隔(秒)
}
  • 続けて「SpoonData」を作る
using UnityEngine;

// 投げる武器(計量スプーン)だけの性能
[CreateAssetMenu(fileName = "SpoonData", menuName = "SurvivorsLike/Spoon Data")]
public class SpoonData : WeaponData
{
    public Spoon bulletPrefab;       // 飛ばすもの
    public float bulletSpeed = 8f;   // 飛ぶ速さ
    public float searchRadius = 10f; // 狙う範囲
}
  • 同じように「WhipperData」も作る
using UnityEngine;

// 回る武器(ホイッパー)だけの性能
[CreateAssetMenu(fileName = "WhipperData", menuName = "SurvivorsLike/Whipper Data")]
public class WhipperData : WeaponData
{
    public float orbitRadius = 1.6f;   // プレイヤーから離れる距離
    public float orbitSeconds = 1.5f;  // 1周にかかる秒数
    public int orbitCount = 1;         // いくつ回すか
    public float activeSeconds = 3f;   // 出ている時間(秒)
    public float restSeconds = 3f;     // 引っこんでいる時間(秒)
}
親と子に分けたポイント
  • abstractにしている理由
    • WeaponDataは共通の入れ物なので、そのもののアセットは作りません。abstractを付けておくと、うっかり中身のない武器を作ってしまうことがなくなります
    • だから[CreateAssetMenu]が付いているのは、子のクラスのほうだけです
  • 武器を増やすときにすること
    • #7でストレーナーを足すときは、データの側はStrainerDataを新しく1つ書いて、アセットを1つ作るだけです(動かすスクリプトのStrainerAreaは別に書きます)
    • ストレーナーを足すために、SpoonDataやWhipperDataを書き換える必要はありません
  • 共通の部分を親に置くと何がうれしいか
    • #7の「攻撃力アップ」の強化が触るのは、どの武器にもある攻撃力だけです。親のWeaponData型で扱えば、武器の種類が何であっても同じ処理でまとめて強く出来ます

データのアセットを作ろう

  • ProjectウインドのSurvivorsLike/Dataフォルダで右クリック>Create>SurvivorsLike>Spoon Data
  • 名前を「SpoonData」にして、Inspectorで設定する
項目値
Weapon Name計量スプーン
Iconicon_spoon
Damage10
Interval1
Bullet PrefabSpoon(#4で作ったプレハブ)
Bullet Speed8
Search Radius10
SpoonDataのInspector。Weapon Name 計量スプーン、Damage 10、Interval 1、Bullet Prefab に Spoon

回る武器の欄(回る半径など)が出てこないことに注目してください。SpoonDataが持っているのは、共通の4つと投げる武器の3つだけです。ホイッパーのデータは、あとでホイッパーを作るときに用意します。

計量スプーンをデータで動かそう

SpoonThrowerとSpoonから数字を外して、データを見るようにします。

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Pool;

// 一定間隔で、一番近い敵に計量スプーンを投げる
public class SpoonThrower : MonoBehaviour
{
    [SerializeField] private SpoonData data;
    [SerializeField] private LayerMask enemyLayer;

    private ObjectPool<Spoon> pool;
    private ContactFilter2D filter;
    private readonly List<Collider2D> found = new List<Collider2D>();
    private float timer;

    private void Awake()
    {
        filter = new ContactFilter2D
        {
            useLayerMask = true,
            layerMask = enemyLayer,
            useTriggers = true,
        };

        pool = new ObjectPool<Spoon>(
            createFunc: () => Instantiate(data.bulletPrefab, transform.parent),
            actionOnGet: spoon => spoon.gameObject.SetActive(true),
            actionOnRelease: spoon => spoon.gameObject.SetActive(false),
            actionOnDestroy: spoon => { if (spoon != null) Destroy(spoon.gameObject); },
            defaultCapacity: 20,
            maxSize: 200);
    }

    private void Update()
    {
        timer += Time.deltaTime;
        if (timer < data.interval)
        {
            return;
        }
        timer -= data.interval;

        Transform nearest = FindNearestEnemy();
        if (nearest == null)
        {
            return;
        }

        Vector2 toEnemy = (Vector2)nearest.position - (Vector2)transform.position;
        if (toEnemy.sqrMagnitude < 0.0001f)
        {
            return;
        }

        Spoon spoon = pool.Get();
        spoon.Throw(transform.position, toEnemy.normalized, data, pool);
    }

    // まわりの敵を集めて、一番近いものを返す
    private Transform FindNearestEnemy()
    {
        Physics2D.OverlapCircle(transform.position, data.searchRadius, filter, found);

        Transform nearest = null;
        float nearestDistance = float.MaxValue;
        foreach (Collider2D collider in found)
        {
            float distance = ((Vector2)collider.transform.position - (Vector2)transform.position).sqrMagnitude;
            if (distance < nearestDistance)
            {
                nearestDistance = distance;
                nearest = collider.transform;
            }
        }
        return nearest;
    }
}

スプーン側も、投げられるときに性能を受け取る形にします。

using UnityEngine;
using UnityEngine.Pool;

// 投げた計量スプーン。まっすぐ飛んで、当たった敵にダメージを与える
[RequireComponent(typeof(Rigidbody2D))]
public class Spoon : MonoBehaviour
{
    [SerializeField] private float life = 3f; // 何秒で消えるか

    private Rigidbody2D body;
    private IObjectPool<Spoon> pool;
    private int damage;
    private float timer;
    private bool used;

    private void Awake()
    {
        body = GetComponent<Rigidbody2D>();
    }

    // 投げるときに、SpoonThrowerから呼んでもらう
    public void Throw(Vector2 position, Vector2 direction, SpoonData data, IObjectPool<Spoon> newPool)
    {
        transform.position = position;
        transform.right = direction;
        body.linearVelocity = direction * data.bulletSpeed;
        damage = data.damage;
        pool = newPool;
        timer = 0f;
        used = false;
    }

    private void Update()
    {
        if (used)
        {
            return;
        }

        timer += Time.deltaTime;
        if (timer >= life)
        {
            used = true;
            pool.Release(this);
        }
    }

    private void OnTriggerEnter2D(Collider2D other)
    {
        if (used)
        {
            return;
        }
        if (!other.TryGetComponent(out Enemy enemy))
        {
            return;
        }

        used = true;
        enemy.TakeDamage(damage);
        pool.Release(this);
    }
}
  • 弾は「性能を持たない」
    • スプーン自身は攻撃力を持たず、投げられるときに受け取るだけにしました
    • こうしておくと、#7で「攻撃力アップ」の強化を入れたときに、データを書き換えるだけで反映されます
  • PlayerのSpoon ThrowerのInspectorで、DataにSpoonDataをドラッグしてください
    • 数字の欄(IntervalやSearch Radius)は消えて、Dataの1行だけになります
    • Dataが空のまま再生すると、エラーが毎フレーム出続けます。スクリプトを書き換えた後は、まずここを確認してください

2つ目の武器・ホイッパー(WhipperOrbit)

プレイヤーの周りをクルクル回って、触れた敵にダメージを与える武器です。

この手の「周りを回る武器」は、出っぱなしにはしないのが定番です。本家のヴァンサバの聖書も、数秒回ったら消えて、少し休んでからまた出てきます。ここでも3秒回って、3秒引っこむようにします。

  • SurvivorsLike/Scriptsに「WhipperOrbit」を作る
using System.Collections.Generic;
using UnityEngine;

// プレイヤーの周りでホイッパーを回す(当たり判定は Whipper のほうが持つ)
public class WhipperOrbit : MonoBehaviour
{
    [SerializeField] private WhipperData data;
    [SerializeField] private Whipper headPrefab;

    private readonly List<Transform> heads = new List<Transform>();
    private float angle;
    private float timer;
    private bool isOut; // 今、出ているかどうか

    private void Start()
    {
        for (int i = 0; i < data.orbitCount; i++)
        {
            Whipper head = Instantiate(headPrefab, transform.parent);
            head.Setup(data);
            heads.Add(head.transform);
        }
        Switch(true);
    }

    private void Update()
    {
        timer -= Time.deltaTime;
        if (timer <= 0f) // 出ている⇔引っこむ を切り替える
        {
            Switch(!isOut);
        }
        if (!isOut)
        {
            return;
        }

        // 1周にかかる秒数から、1秒あたりに回る角度を出す
        angle += 360f / data.orbitSeconds * Time.deltaTime;

        for (int i = 0; i < heads.Count; i++)
        {
            float headAngle = angle + 360f / heads.Count * i;
            float radian = headAngle * Mathf.Deg2Rad;
            Vector2 offset = new Vector2(Mathf.Cos(radian), Mathf.Sin(radian)) * data.orbitRadius;

            heads[i].position = (Vector2)transform.position + offset;
            heads[i].up = -offset; // 持ち手をプレイヤー側に向ける
        }
    }

    // 出す・引っこめる。次に切り替わるまでの時間もここで決める
    private void Switch(bool next)
    {
        isOut = next;
        timer = next ? data.activeSeconds : data.restSeconds;
        angle = 0f; // 出てくるときは毎回おなじ位置から

        foreach (Transform head in heads)
        {
            head.gameObject.SetActive(next);
        }
    }
}

回っているホイッパー側に、当たり判定を持たせます。

using System.Collections.Generic;
using UnityEngine;

// 回っているホイッパー。触れている敵にダメージを与える
[RequireComponent(typeof(CircleCollider2D))]
public class Whipper : MonoBehaviour
{
    private WeaponData data;
    private readonly Dictionary<Enemy, float> lastHitTime = new Dictionary<Enemy, float>();

    // 出てくるときに、WhipperOrbit から性能を渡してもらう
    public void Setup(WeaponData newData)
    {
        data = newData;
    }

    // 引っこんで出てくるたびに、覚えていた当たり時刻を消す
    private void OnEnable()
    {
        lastHitTime.Clear();
    }

    private void OnTriggerStay2D(Collider2D other)
    {
        if (data == null)
        {
            return;
        }
        if (!other.TryGetComponent(out Enemy enemy))
        {
            return;
        }

        // 同じ敵に当て続けないよう、前に当ててから interval 秒あける
        if (lastHitTime.TryGetValue(enemy, out float last) && Time.time - last < data.interval)
        {
            return;
        }

        lastHitTime[enemy] = Time.time;
        enemy.TakeDamage(data.damage);
    }
}
ホイッパーのポイント
  • OnTriggerStay2Dを使う
    • スプーンは飛んでいって1回当たるだけなのでOnTriggerEnter2Dでした
    • ホイッパーは触れている間ずっと呼ばれるOnTriggerStay2Dを使います。回りながらなでるように当たります
  • 同じ敵に当て続けない工夫
    • OnTriggerStay2Dは触れている間、毎回呼ばれます。そのままだと1秒に何十回もダメージが入ってしまいます
    • 「どの敵にいつ当てたか」を覚えておいて、interval秒たつまでは当たらないようにしました
    • 倒された敵は#3のObjectPoolで使い回されるので、覚えたままだと別の敵になったのに「さっき当てた」と勘違いします。引っこむたびにOnEnableで消しているのはそのためです
  • 性能は親のWeaponData型で受け取る
    • ホイッパー本体が使うのは攻撃力と間隔だけで、どちらもどの武器にもあるものです。親の型で受け取っておくと、この当たり判定の仕組みを別の武器にも使い回せます
  • SetActiveで出し入れする
    • InstantiateとDestroyを3秒おきに繰り返すのは無駄なので、作るのは最初の1回だけにして、あとは表示・非表示を切り替えています
    • 非表示の間はColliderも働かないので、引っこんでいるときに当たってしまう心配もありません
  • heads[i].up = -offset;
    • ホイッパーの絵は持ち手が上なので、upにプレイヤー方向(-offset)を入れると、持ち手が内側・泡立て部分が外側を向きます

ホイッパーの見た目とデータを用意しよう

まず、絵の基準点(Pivot)を泡立て部分に移します。ここが当たり判定の中心になります。

  • ProjectウインドでSurvivorsLike/Spritesのwhipperを選ぶ
  • InspectorでPivotをCustomにして、X:0.5、Y:0.25を入れてApply
whipper.pngのInspector。PivotがCustomでX 0.5・Y 0.25になっている

基準点がずれていると、先端が敵に触れているのに当たりません

当たり判定(Collider)は絵の基準点を中心に付きます。基準点が絵の真ん中のままだと判定も真ん中に出来るので、泡立て部分が敵に重なっていても当たりません。基準点を泡立て部分に移すと、見た目と当たり方が一致します。

このあとCircle Collider 2Dを付けるときも同じで、Unityが絵に合わせて入れてくれるOffsetは0に戻します。せっかく移した基準点から、判定だけがずれてしまうためです。

スライム

自分も最初は基準点が真ん中のままで、泡立て部分が敵に刺さっているのに全然倒れなくて悩みました・・・

続けて、回す見た目を作ります。

  • whipperをHierarchyにドラッグ&ドロップ
  • 名前を「Whipper」にして、Sprite RendererのOrder in Layerを1にする(敵より手前)
  • Add Component>Circle Collider 2D
    • Is Trigger:チェックを入れる
    • Offset:X 0、Y 0(付けたときに自動で入る値を消す)
    • Radius:0.5
  • Add Component>Rigidbody2D
    • Body Type:Kinematic(自分では動かず、スクリプトで位置を決めるため)
  • Add Component>Whipper(さっき書いたスクリプト)
  • SurvivorsLike/Prefabsにドラッグ&ドロップしてプレハブにし、Hierarchyのものは削除する
Whipperのプレハブ。Circle Collider 2D(Is Trigger・Offset 0・Radius 0.5)とKinematicのRigidbody 2D、Whipperスクリプト

Colliderを付けておくと、Sceneビューで当たる範囲が緑の円として見えます。「当たっているはずなのに当たらない」ときに、目で確かめられるのが大きな利点です。計量スプーンと同じ作りにそろえたので、どちらの武器も同じ考え方で調整出来ます。

次にデータです。

  • SurvivorsLike/Dataで右クリック>Create>SurvivorsLike>Whipper Data
    • スプーンのときとは別のメニューです。クラスを分けたので、作れるアセットの種類も分かれています
  • 名前を「WhipperData」にして、Inspectorで設定する
項目値
Weapon Nameホイッパー
Iconicon_whipper
Damage10
Interval0.5
Orbit Radius1.6
Orbit Seconds1.5
Orbit Count1
Active Seconds3
Rest Seconds3
WhipperDataのInspector。Weapon Name ホイッパー、Damage 10、Interval 0.5、Orbit Radius 1.6、Active Seconds 3、Rest Seconds 3。投げる武器の欄は出ていない

SpoonDataと見比べると、欄がまるごと違うのが分かります。スプーンには「飛ぶ速さ」だけ、ホイッパーには「回る半径」だけが出ていて、使わない欄は並びません。

コゲクッキーのHPは10なので、ホイッパーが当たれば1発で倒せます。当たったかどうかが一目で分かるように、まずはこの強さにしておきます(#8でダメージの演出を入れるときに、あらためて調整します)。

Orbit Radiusは、体からはっきり離れて見える大きさにする

スライムちゃんの絵は横幅がだいたい1(ワールド単位)です。Orbit Radiusが1だとホイッパーが体にくっついて見えるので、1.6にして輪がはっきり分かるようにしました。

輪を広げると、立ち止まっているあいだに敵が輪の内側へ入り込んできます。これは直すべき不具合ではありません。動いて敵を輪に巻き込むのが、このジャンルの操作そのものだからです。#9でプレイヤーにHPを付けると、立ち止まっているとダメージを受け続けることになり、動く理由がはっきりします。

最後にプレイヤーに付けます。

  • HierarchyでPlayerを選び、Add Component>WhipperOrbit
  • Data:WhipperData、Head Prefab:Whipperのプレハブ
PlayerのWhipper Orbit。DataにWhipperData、Head PrefabにWhipperのプレハブが入っている

動かして確認!

ホイッパーの働きだけを見たいときは、計量スプーンを止めてみてください。

  • 再生中に、PlayerのSpoon Throwerの左のチェックを外す
  • ホイッパーだけでも、触れた敵がその場で倒れていくのが分かります
  • Sceneビューに切り替えると、ホイッパーの緑の円(当たる範囲)が見えます
Sceneビューで見たホイッパー。泡立て部分を包むように、当たり範囲の緑の円が出ている
Spoon Throwerを止めた状態。ホイッパーだけで近づいた敵が倒れていく様子

#5の動作確認

  • スライムちゃんの周りをホイッパーが1.5秒で1周する(体とのすき間が空いている)
  • 3秒回ったら引っこみ、3秒後にまた出てくる
  • 立ち止まっていると輪の内側に入り込まれるが、動くと輪に巻き込んで倒せる
  • ホイッパーの泡立て部分が触れた敵が、その場で倒れる(ダメージ10・HP10なので1発)
  • 引っこんでいる間は、触れても敵が倒れない
  • Spoon Throwerのチェックを外しても、ホイッパーだけで敵を倒せる
  • 計量スプーンも今までどおり飛んでいく
  • SpoonDataのIntervalを0.5にすると、データを直しただけで投げる間隔が変わる
スライムちゃんの周りを回るホイッパーと、押し寄せるコゲクッキー
スライム

ホイッパーが回っているだけで、近づいた敵が倒れていきます

フェアリー

近くを守るホイッパーと、遠くを狙うスプーン。役割が違う武器を組み合わせるのがこのジャンルの楽しさだよ!

武器を増やすときの考え方

データにまとめたので、武器を増やすときの手間はぐっと減りました。

やりたいことやること
スプーンを強くするSpoonDataのDamageを変えるだけ
投げる数を増やす#7で強化として追加します
ホイッパーを2本にするWhipperDataのOrbit Countを2にするだけ
ホイッパーを出っぱなしにするWhipperDataのRest Secondsを0にするだけ
ホイッパーが回る輪を大きくするWhipperDataのOrbit Radiusを変えるだけ
ホイッパーの当たる範囲を広げるWhipperのプレハブの Circle Collider 2D の Radius を変える
武器を1種類増やすWeaponDataを継承したデータのクラスと、それを動かすスクリプトを書いて、アセットを1つ作る(今ある武器のデータには触らない)
別の回る武器を足すWhipperDataのアセットをもう1つ作って、PlayerにWhipperOrbitをもう1つ付ける(スクリプトはそのまま)

Orbit Countを2にすると、ホイッパーが向かい合わせで2本回ります。3にすると120度ずつ。データの数字1つで見た目が変わるのが、ScriptableObjectにまとめた効果です。

回る武器のバリエーション(回りながら大きくなる、プレイヤーと逆方向に回るなど)は、Vampire Survivorsの聖書のような回転するものを作るでも紹介しています。あわせて読むと、回転の作り方の引き出しが増えると思います。

あとがき

今回は武器の性能をScriptableObjectにまとめて、2つ目の武器を追加しました。スクリプトを触らずに強さを変えられるようになったのが、今回いちばん大きな前進です!次回は倒した敵から金平糖の粒を落として、集めるとレベルアップする仕組みを作りたいと思います。

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

コメント

コメント一覧 (1件)

コメントする

目次