ヴァンサバ風ゲームの作り方#10|時間で強くなる敵のウェーブを作ろう

時間が進むにつれて敵の種類と数が増えていく様子

前回でゲームオーバーが作れました。でも今のところ、1分たっても10分たっても同じ敵が同じ数だけ出てきます。ヴァンサバ風のゲームが面白いのは、時間がたつほど画面が埋まっていって、さっきまで余裕だった場所が急に危なくなるところです。今回はヴァンサバ風サバイバーゲームのシリーズの10回目として、時間で敵の種類と数が変わるウェーブを作って、10分生き延びたらクリアにしてみたいと思います!

目次

今回作るもの

  • 画面に経過時間を05:32の形で出す
  • 敵を3種類に増やす(コゲクッキー・ドロドロチョコ・カチカチあめ)
  • 時間で出る敵・出る間隔・強さが変わる
  • 10分生き延びたらクリア
  • 確認用に、時間を早送りできるようにする

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

スクリプト役割付ける場所
EnemyData(新しく作る)敵1種類の性能(見た目・HP・速さ・触れたときのダメージ)データのアセット
WaveData(新しく作る)「何分から・どの敵・何秒に1体・強さ何倍」の表データのアセット
WaveManager(新しく作る)経過時間を数えて、今のウェーブを決める。10分でクリアWaveManager
GameClearView(新しく作る)クリアのパネルを出すCanvas
Enemy(直す)データを見て、見た目とHPを変える敵のプレハブ
EnemySpawner(直す)今のウェーブの敵を、今の間隔で出すEnemySpawner
StatusView(直す)経過時間を出すCanvas

前回の続きから

  • Unityのバージョンは6.3 LTS
    • 記事作成時点では6000.3.22f1で確認しています
  • #9の状態:敵に触れられるとHPが減り、0でゲームオーバーになる

ScriptableObjectは12-8 ScriptableObjectで紹介しています。

敵の種類をデータにしよう(EnemyData)

今は敵のプレハブが1つで、HPも速さもそこに直接書いてあります。種類を増やすたびにプレハブを作って数字を入れ直す・・・のは大変なので、#5の武器と同じように、敵1種類ぶんをScriptableObjectのデータにします。

  • SurvivorsLike/Scriptsに「EnemyData」を作る
using UnityEngine;

// 敵1種類ぶんの性能
[CreateAssetMenu(fileName = "EnemyData", menuName = "SurvivorsLike/Enemy Data")]
public class EnemyData : ScriptableObject
{
    public string enemyName = "敵";
    public Sprite sprite;            // 見た目
    public int maxHp = 20;           // 体力
    public float moveSpeed = 1.5f;   // 1秒に進むマス数
    public int touchDamage = 10;     // 触れたときに与えるダメージ
}

敵のプレハブは1つのままです。見た目もHPも出てくるときにデータから入れ直すので、プレハブは1種類で足ります。#3で作ったプール(ObjectPool)もそのまま使えて、種類ごとにプールを分けなくて済むのが、この作りの良いところです。

3種類ぶん作ろう

SurvivorsLike/DataにEnemiesフォルダを作って、右クリック>Create>SurvivorsLike>Enemy Dataで3つ作ります。

アセットEnemy NameSpriteMax HpMove SpeedTouch Damage
CookieDataコゲクッキーenemy_cookie201.510
ChocoDataドロドロチョコenemy_choco152.610
CandyDataカチカチあめenemy_candy600.920

3種類の性格をはっきり分けるのがコツです。ドロドロチョコは柔らかいけど速いので、離れていても追いつかれます。カチカチあめは硬くて遅いかわりに、触れると痛い。同じ「強い敵」でも、プレイヤーが取るべき行動が変わるようにしています。

SurvivorsLike/Data/Enemiesの3つのアセットと、CandyDataのInspector

Enemyにデータを読ませよう

Enemyに、出てくるときにデータを受け取る処理を足します。

    [SerializeField] private SpriteRenderer spriteRenderer;

    // 出す前に、EnemySpawnerから呼んでもらう。strength は時間による強さの倍率
    public void Setup(EnemyData data, float strength)
    {
        spriteRenderer.sprite = data.sprite;
        maxHp = Mathf.RoundToInt(data.maxHp * strength);
        moveSpeed = data.moveSpeed;
        touchDamage = Mathf.RoundToInt(data.touchDamage * strength);
    }
  • フィールドはそのまま
    • maxHp・moveSpeed・touchDamageは[SerializeField] privateのままでかまいません。同じクラスの中から書き換えています
  • spriteRendererの欄を足した
    • 見た目を種類ごとに差し替えるために、敵のSprite Rendererを持たせます

SetupはSpawnより先に呼びます

Spawnの中でhp = maxHp;としているので、順番が逆だと使い回した敵が、前の種類のHPのまま出てきます。硬いカチカチあめのはずなのに一撃で倒れる・・・といった、原因の分かりにくいズレになるので注意してください。

プレハブに欄を入れる

  • ProjectウインドのSurvivorsLike/PrefabsでEnemyをダブルクリックして開く
  • Enemyコンポーネントに増えたSprite Rendererの欄に、同じオブジェクトのSprite Rendererをドラッグする
    • ここが空のままだと、敵を出すたびにNullReferenceExceptionのエラーが出て、敵がちゃんと出てきません

時間ごとの表を作ろう(WaveData)

「何分から・どの敵が・何秒に1体・強さ何倍」を、1つの表にまとめます。

  • SurvivorsLike/Scriptsに「WaveData」を作る
using UnityEngine;

// ウェーブ1つぶん(この時間から、この敵たちが、この間隔で出る)
[System.Serializable]
public class Wave
{
    public float startMinutes;        // 何分から
    public EnemyData[] enemies;       // 出る敵(この中からランダムに選ぶ)
    public float spawnInterval = 1f;  // 何秒に1体
    public float strength = 1f;       // HPと攻撃力の倍率
}

// ウェーブの表
[CreateAssetMenu(fileName = "WaveData", menuName = "SurvivorsLike/Wave Data")]
public class WaveData : ScriptableObject
{
    public Wave[] waves;

    // 経過時間(秒)から、今のウェーブを返す
    public Wave Current(float seconds)
    {
        Wave current = waves[0];
        foreach (Wave wave in waves)
        {
            if (seconds >= wave.startMinutes * 60f)
            {
                current = wave;
            }
        }
        return current;
    }
}
  • [System.Serializable]
    • MonoBehaviourでもScriptableObjectでもないただのクラスは、これを付けるとInspectorに表示できるようになります
    • 付け忘れると、Wavesの欄に何も出てこなくて悩むことになります
  • Currentの書き方
    • 上から順に見て、開始時間を過ぎているものを上書きしていくだけです
    • 最後に当てはまったものが選ばれるので、表は開始時間の早い順に並べておきます。1つ目(Element 0)は0分にしておくと、始まった直後から敵が出ます

表を作ろう

  • SurvivorsLike/Dataで右クリック>Create>SurvivorsLike>Wave Data、名前を「WaveData」にする
  • Wavesのサイズを4にして、次のように入れる
#Start MinutesEnemiesSpawn IntervalStrength
00CookieData11
13CookieData / ChocoData0.71.5
26CookieData / ChocoData / CandyData0.52
38CookieData / ChocoData / CandyData0.353
  • 敵の出る間隔は 1秒 → 0.7秒 → 0.5秒 → 0.35秒 と縮んでいきます。8分からは、最初の約3倍の数が出てくる計算です
  • StrengthはHPと触れたときのダメージにかかります。速さは種類ごとの性格なので、倍率をかけていません
WaveDataのInspector。4つのウェーブの開始時間・敵・間隔・強さ

これで、ゲームの難しさはスクリプトではなく、この表の数字で決まるようになりました。敵を強くしたい・数を増やしたいときも、スクリプトには触らずにWaveDataのInspectorを書き換えるだけです。

経過時間を数えよう(WaveManager)

  • SurvivorsLike/Scriptsに「WaveManager」を作る
using System;
using UnityEngine;

// 経過時間を数えて、今のウェーブを決める。10分たったらクリア
public class WaveManager : MonoBehaviour
{
    [SerializeField] private WaveData waveData;
    [SerializeField] private float clearMinutes = 10f;
    [SerializeField] private float testSpeed = 1f; // 確認用。10にすると10倍の速さで進む

    public float Elapsed { get; private set; }
    public Wave Current => waveData.Current(Elapsed);

    // 10分たったことを知らせる
    public event Action Cleared;

    private bool cleared;

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

        Elapsed += Time.deltaTime * testSpeed;

        if (Elapsed >= clearMinutes * 60f)
        {
            cleared = true;
            Cleared?.Invoke();
        }
    }
}
  • Clearedはeventで知らせる
    • #9のPlayerHealthのDiedと同じ形です。クリアしたときに何をするかは、聞いている側(このあと作るGameClearView)が決めます
  • testSpeedを入れておく
    • 10分ぶんを毎回遊んで確かめるのは、現実的ではありません
    • 10にすれば1分で10分ぶん進むので、後半のウェーブやクリアの確認がすぐ出来ます
  • Hierarchyで右クリック>Create Empty、名前を「WaveManager」にして、Add Component>WaveManager
    • Wave DataにWaveDataのアセットを入れる

Test Speedは、確認が終わったら1に戻す

10のままビルドすると、遊ぶ人のゲームも10倍の速さで時間が進み、1分でクリアになってしまいます。敵の出る間隔はTime.deltaTimeのままなので、時間の表示とウェーブだけが早送りになります。

敵の出し方を、ウェーブに合わせよう

EnemySpawnerを直します。間隔も敵の種類も、毎回ウェーブに聞くようにします。

    [SerializeField] private WaveManager waveManager;

Updateを次のようにします(spawnIntervalの欄は使わなくなるので消してかまいません)。

    private void Update()
    {
        Wave wave = waveManager.Current;

        timer += Time.deltaTime;
        if (timer < wave.spawnInterval)
        {
            return;
        }
        timer -= wave.spawnInterval;

        float angle = Random.value * Mathf.PI * 2f;
        Vector2 around = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
        Vector2 position = (Vector2)target.position + around * spawnDistance;

        EnemyData data = wave.enemies[Random.Range(0, wave.enemies.Length)];

        Enemy enemy = pool.Get();
        enemy.Setup(data, wave.strength);
        enemy.Spawn(position, target, pool, gemSpawner, damageTexts);
    }
  • HierarchyでEnemySpawnerを選び、Wave ManagerにHierarchyのWaveManagerを入れる
スライム

後半は画面が焦げたお菓子だらけになりますね・・・

フェアリー

そこが気持ちいいところ!最初の1分をわざとヒマにしておくと、増えたときの差がもっと出るんだ

経過時間を画面に出そう

StatusViewに足します。

    [SerializeField] private WaveManager waveManager;
    [SerializeField] private TextMeshProUGUI timeText;

Updateに1行足します(hpBarFillの行の下あたり)。

        timeText.text = TimeSpan.FromSeconds(waveManager.Elapsed).ToString(@"mm\:ss");
  • using System;を先頭に足してください
  • TimeSpanで05:32の形にする
    • 秒を60で割って・・・と自分で計算しなくても、TimeSpan.FromSecondsと書式指定でそのまま作れます
    • @"mm\:ss"の@は「\をそのままの文字として扱う」という意味です

画面に置く

  • CanvasのLevelTextを選んでCtrl+Dで複製し、名前を「TimeText」にする
    • Anchor Presetsでtop・center、Pos X:0、Pos Y:-110、Width:300、Height:70
    • Font Size:56、Alignment:中央・中央
  • HierarchyでCanvasを選び、StatusViewに増えた2つの欄を入れる
    • Wave Manager:WaveManager、Time Text:TimeText
画面の上に経験値バー・HPゲージと、経過時間が出ている

10分生き延びたらクリアッ!

#9のゲームオーバーとほとんど同じものを、クリア用に作ります。

  • CanvasのGameOverPanelを選んでCtrl+Dで複製し、名前を「ClearPanel」にする
    • 中のGameOverTextの名前を「ClearText」に、Textを「CLEAR!」、Colorを黄色(R:255、G:217、B:51 ぐらい)にする
    • 中のRetryButtonの名前は「ClearRetryButton」にする
    • チェックを外して非表示にしておく
  • SurvivorsLike/Scriptsに「GameClearView」を作る
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

// 10分たったらゲームを止めて、クリアのパネルを出す
public class GameClearView : MonoBehaviour
{
    [SerializeField] private WaveManager waveManager;
    [SerializeField] private GameObject panel;
    [SerializeField] private Button retryButton;

    private void OnEnable()
    {
        waveManager.Cleared += Show;
        retryButton.onClick.AddListener(Retry);
    }

    private void OnDisable()
    {
        waveManager.Cleared -= Show;
        retryButton.onClick.RemoveListener(Retry);
    }

    private void Show()
    {
        panel.SetActive(true);
        Time.timeScale = 0f;
    }

    private void Retry()
    {
        Time.timeScale = 1f;
        SceneManager.LoadScene(SceneManager.GetActiveScene().name);
    }
}
  • HierarchyでCanvasを選び、Add Component>GameClearView。3つの欄を入れる
    • Wave Manager:WaveManager、Panel:ClearPanel、Retry Button:ClearRetryButton
GameOverViewとほとんど同じですが・・・

見比べると、違うのは購読するイベントと出すパネルだけです。「まとめたほうがいいのでは?」と思った方は、その感覚で合っています。

ただ、この2つはこれから違うものに育ちます。クリアには「生き延びた時間」「倒した数」を出したいし、ゲームオーバーには「もう少しだった」の演出を入れたくなります。無理に共通化するのは、違いがはっきりしてからで十分です。次回、タイトルとリザルトを作るときに、あらためて整理します。

動かして確認!

WaveManagerのTest Speedを10にして再生すると、1分で10分ぶん進みます。

コゲクッキー・ドロドロチョコ・カチカチあめの3種類が同時に出ている様子

#10の動作確認

  • 画面の上に経過時間が出て、00:05のように増えていく
  • 3分を過ぎるとドロドロチョコ(速い敵)が混ざり始める
  • 6分を過ぎるとカチカチあめ(硬い敵)が出てきて、倒すのに何発もかかる
  • 後半は敵の数が明らかに増える(0.35秒に1体)
  • 同じコゲクッキーでも、後半のほうがHPが高い(ダメージの数字を何回も当てる必要がある)
  • 10分で止まって「CLEAR!」が出る
  • Test Speedを1に戻すと、ふつうの速さに戻る
10分たって画面が暗くなり、黄色い「CLEAR!」と「もう一度」のボタンが出ている
フェアリー

「CLEAR!」が出たら成功!確認が終わったら、Test Speedを1に戻すのを忘れないでね

難易度の決め方

数字をどう決めたかを書いておきます。正解はないので、考え方だけ参考にしてください。

時間ねらい
0〜3分わざとヒマにする。強化を2〜3回選ばせて、武器を育てる時間
3〜6分速い敵を混ぜて、立ち止まれなくする
6〜8分硬い敵で火力が足りているかを試す。足りないと押し切られる
8〜10分数で押す。ここまで育っていれば耐えられるように

いちばん大事なのは最初の1分です。ここが忙しいと、強化を選ぶ余裕がないまま後半に入って、何も出来ずに終わります。最初はぬるすぎるぐらいでちょうどいいというのが、作ってみての実感でした。

スライム

数字をいじり始めると、気づいたら1時間たってます・・・

うまくいかないときの見直し方

症状から、どの数字を触ればいいかを決めると原因が絞りやすいです。

  • 後半に押し切られる → strengthを下げる(敵を弱くする)
  • 途中でヒマ → spawnIntervalを短くする(数を増やす)
  • 強化が間に合わない → 最初のウェーブを長くする(次のウェーブのstartMinutesを遅らせる)

一度に1つの数字だけ変えて、同じ遊び方で見比べるのがコツです。

ボスを出したい人へ

本家には、決まった時間に出てくる強い敵がいます。同じことをやるなら、EnemyDataをもう1つ作るだけでも「それっぽいもの」は作れます。

  • EnemyDataのアセットでBossDataを作って、Max Hpを2000ぐらいにする
  • WaveにbossAtMinutesのような欄を足して、その時間になったら1体だけ出す
  • 倒したら特別なものを落とす → Enemyの倒れる処理を分ける

ボスらしい動き(途中で止まる・取り巻きを呼ぶ)を入れ始めると、Enemy1つでは足りなくなります。Enemyを親にしてBossEnemyを作るのが、#5・#7でやってきたやり方(WeaponData・Weaponの親クラス)の続きになります。

あとがき

今回でゲームとして最初から最後まで遊べるようになりました。時間の表と数字を少し変えるだけで、難しさがガラッと変わるのが面白いところです。ぜひ自分が気持ちよく遊べる数字を探してみてください!次回はいよいよ最終回、タイトル画面とリザルトを付けて、Windowsで遊べる形にして配れるところまで作ります。

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

コメント

コメント一覧 (1件)

コメントする

目次