Unityで2Dブロック崩しを作ろう 2

前回のあらすじ

Unityで2Dブロック崩しを作ろう 1

ブロック崩しに必要な壁、ボール、自機を画面に作成しました。スクリプトを追加し、自機を動かせるようにしてボールを跳ね返すところまでできました。

今回の作業

  • ブロックの生成
  • ブロックの壊れたときの処理

ブロックの生成

ボールを落とさないように跳ね返すことができる状態ですが、壊す対象のブロックがまだありません。なのでブロックを配置します。画面上に固定でおいてもよいのですが、スクリプトから配置するようにします。

ブロックのPrefabを作成

まずは GameArea 以下に Blocks という名前で空のオブジェクトを作成します。ブロックは Blocks 以下に配置されるようにします。まとめられていた方が見やすいような気がしたのでこうします。

Blocksの下にBlockという名前で、Image を作成します。Hierarchy はこんな感じになります。

Blocks と Block はそれぞれ次のように Inspector で設定します。

Blocks は Anchor Presets を Stretch*Stretch とします。Blockのサイズは、100*20 の大きさとしていますが、適宜変更すると良いです。

ポジションは Top*Center 基準で posY=-80 としていますが、これで上部中央にブロックが配置されるはずです。以下のような感じです。

ブロックにも当たり判定が必要なので Collider を追加します。サイズはブロックの大きさと合わせます。

これで跳ね返るようになっているはずです。動かして確認してください。ただしブロックはまだ壊れませんが。

後はこれを複製して並べていってもいいのですが、面倒なのでスクリプトで生成されるようにします。そのために作成したブロックをPrefab化しておきます。Prefab化したら画面上のブロックを消しておきましょう。

最後にBlock.csを作成し、Prefabにを貼り付けておきます。実装はひとまず後回しにします。

BlockProvider の作成

BlockProviderは文字通り、ブロックを生成するためのクラスです。Prefabをインスタンス化し画面上に配置します。コンポーネントのBlockクラスを返します。

using UnityEngine;

public class BlockProvider : MonoBehaviour
{
    [SerializeField]
    private GameObject BlockPrefab;

    /// <summary>
    /// ブロックの生成処理
    /// </summary>
    /// <returns></returns>
    public Block Create(Transform parent, Vector2 position)
    {
        var go = Instantiate(this.BlockPrefab, parent) as GameObject;
        var block = go.GetComponent<Block>();
        go.GetComponent<RectTransform>().anchoredPosition = position;

        return block;
    }
}

Createメソッドのみを実装します。引数をもとにPrefabから生成したオブジェクトを配置します。生成したオブジェクトからBlockコンポーネントを取得し、これを戻り値としています。今のところBlockクラスは空っぽのクラスですが。

BlockPrefabは Inspector から、作成したBlockのPrefabを設定します。

このクラスは GameManager クラスから呼ばれるようにするので、新しくクラスを作成します。

GameManager

GameSceneの処理を統括するためのクラスです。ブロックの生成処理やスコア集計処理の呼び出しなどを行います。先ほど作成したBlockProviderクラスのメソッドを呼び出すのもこのクラスです。

オブジェクトとスクリプトの作成

GameManagerという名前の空のオブジェクトを作成します。同じくGameManagerという名前でスクリプトを作成し、GameManagerオブジェクトに追加します。ついでにBlockProviderもここに追加します。

GameManagerクラス

GameManagerクラスを実装し、Providerでブロックを作成できるようにします。

using System.Collections.Generic;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    public BlockProvider BlockProvider { get; private set; }
    public GameObject BlocksArea { get; private set; }

    void Start()
    {
        this.BlockProvider = GetComponent<BlockProvider>();
        this.BlocksArea = GameObject.Find("Blocks");

        // 配置の初期化
        InitializeStage();
    }

    /// <summary>
    /// ブロック配置の初期化
    /// </summary>
    private void InitializeStage()
    {
        var blocks = new List<Block>();

        // 開始位置とブロックのサイズ
        var startPosX = -200;
        var startPosY = -80;
        var width = 100;
        var height = 20;

        // 5*5 の25ブロックを生成
        // 各ブロックのポジションは開始位置からブロックサイズ分右下にずらす
        for (int i = 0; i < 5; i++)
        {
            var posY = startPosY - height * i;
            for (int j = 0; j < 5; j++)
            {
                var posX = startPosX + width * j;
                var position = new Vector2(posX, posY);
                var block = BlockProvider.Create(this.BlocksArea.GetComponent<RectTransform>(), position);
                blocks.Add(block);
            }
        }
    }
}

ブロックの位置の計算だけめんどくさい感じになっていますが、いい感じに表示されれば適宜調整すればよいです。

Blockクラスは後で使うのでリストに入れて保管しておきます。これでゲームを実行するとこんな感じに表示されます。白い大きなブロックに見えますが、実態は25個のブロックです。もちろんボールがぶつかれば跳ね返ります。

UniRxの導入

Blockクラスにボールがぶつかったときの処理や壊れたときの処理を記述するのですが、ここでUniRxというライブラリを使用します。なのでまずはAssetStoreからプロジェクトに導入します。

Asset Store(Window -> Asset Store)で “UniRx” と入力し検索すると出てくるのでこれをクリックします。

次の画面でインポートをクリックするとプロジェクトにライブラリをインポートできます。FREEとある通り、無償のライブラリです。

Allをクリックして、すべてにチェックを入れてから、Importをクリックします。するとプロジェクトへチェックしたファイル群が追加されます。サンプルファイルもいろいろあるので見てみるとよいでしょう。

Rxの考え方

基本的にはイベントの上位互換みたいな考え方でよいかと思います。例えば C# の event は、event 発生時に呼ばれるメソッドを登録しておくことで、event を発生したときにそれが呼ばれます。Rxでもまったく同じことができます。オブザーバーパターンと呼ばれる考え方です。

今回の例でいうと、「ボールがブロックにぶつかり壊れた」というイベントを管理する必要があります。これだけであればまあ、event でも管理できますがそれに加え、「すべてのブロックが壊れた」というイベントもゲームクリアの判定のために必要です。こうなると壊れたかどうかの判定のフラグを持ったりと、制御が大変です。

Rx(+Linq)を使用するとこれが簡単に書けるというわけです。

習うより慣れろで早速実装してみます。

Blockクラスの作成

Blockクラスでボールがぶつかったときの処理を記述します。

ブロック自体は耐久値(HitPoint)を持ちます。壊れるまでに何回ボールをぶつける必要があるかという数値です。この数値が0になった時点で壊れたと判定します。ぶつかっただけでは壊れません。

実装は次のようにします。

using System.Collections;
using System.Collections.Generic;
using UniRx;
using UniRx.Triggers;
using UnityEngine;

public class Block : MonoBehaviour
{
    /// <summary>
    /// ブロックが壊れたときのイベント
    /// </summary>
    public Subject<int> OnBroken = new Subject<int>();

    /// ヒットポイント(ブロックが壊れるまでの回数)
    /// </summary>
    public ReactiveProperty<int> HitPoint { get; private set; } = new ReactiveProperty<int>(1);

    /// <summary>
    /// ブロックが壊れたときに加算されるスコア
    /// </summary>
    public int Score { get; private set; } = 10;

    private void Awake()
    {
        // ボールがぶつかったときの処理
        this.OnCollisionEnter2DAsObservable()
            .Subscribe(
                collision =>
                {
                    // ポイントを減らす
                    this.HitPoint.Value--;
                }
            ).AddTo(this);

        // ヒットポイントが0になったら壊れた判定
        this.HitPoint
            .Where(hp => hp <= 0)
            .Subscribe(
                _ => 
                {
                    //this.IsBroken.Value = true;
                    // スコアを流してオブジェクトを破棄
                    this.OnBroken.OnNext(this.Score);
                    this.OnBroken.OnCompleted();
                    Destroy(this.gameObject);
                }
            ).AddTo(this);
    }
}

Subject<T>でイベント

まず、Subject<int> OnBroken についてですが、これはブロックが壊れたときのイベントを流すためのものです。例えば以下のように使います。

// 監視されるイベント
var subject = new Subject<string>();

// subjectを監視して、値(文字列)が流れてきたら出力する
// 最初の引数がOnNext時に呼ばれる匿名メソッド
// 2つ目のの引数がOnComplete時に呼ばれる匿名メソッド
subject.Subscribe(
    x => Debug.Log(x),
    () => Debug.Log("監視を終了します。")
    );

// subjectに値を流す(通知)
subject.OnNext("1番目の値");
subject.OnNext("2番目の値");

// 終了通知
subject.OnCompleted();

// 1番目の値
// 2番目の値
// 監視を終了します。

まず、Subject型を使うとイベントに値を流したり(OnNext)、値が流れた時に実行するメソッドが登録したり(Subscribe)することができます。

上記例では、stringを流すためのSubjectを定義し、それを Subscribe でメソッド(ログを出力)を登録しています。

OnNextで文字列を2回流しています。このOnNextが実行されるたびに、Subscribeで登録されているメソッドが、OnNextで指定した文字列を引数にして実行されます。つまり2回 OnNext が呼ばれているので、2回 Subscribe で登録されたメソッドが実行されているのが確認できます。

あといつまでも Subject を監視していても仕方がないので、もう監視しなくていいよという通知を OnComplete で行っています。OnComplete時に呼ばれるメソッドも Subscribe で登録できます。

これが Subject の基本です。Subjectにはいくつかの種類があるのですが、省略します。

ReactiveProperty<int> 変数とイベントのセット

Subjectはイベントを監視するためのものでしたが、変数の値が変わったかどうかを監視するということができるのが ReactiveProperty<T> です。

// 監視できるint型の変数
var reactiveProperty = new ReactiveProperty<int>();

// 変数の値が変わればログが出力されるようにする
reactiveProperty.Subscribe(x => Debug.Log($"変数の値が変わりました: x = {x}"));

// OnNextで値を変更する
reactiveProperty.Value = 1;
reactiveProperty.Value = 2;

// 変数の値が変わりました: x = 1
// 変数の値が変わりました: x = 2

例えば「ヒットポイントが0以下になったら死亡したという処理をしたい」ということをやるときに、ヒットポイントの変数を監視する場合に使えます。今回はブロックの耐久度をこれで監視しています。

OnCollisionEnter2DAsObservable

これも UniRx の提供する機能の一つなのですが、OnCollisionEnter2D イベントを IObservable 型として扱うことができるようになります。

Subject も ReactiveProperty も IObservable型を実装しています。そのため Subscribe で処理を登録できます。

よって Unity が提供しているイベントもそれぞれ IObservable型 として扱えるような機能があり、その一つが OnCollisionEnter2D です。ボールがぶつかったときの処理を Subscribe で登録しています。OnCollisionEnter2D メソッドを作成しても同じ機能を実現できますが、見やすさ等を考えて今回はこうしています。

まとめ

Blockクラスでは以下のようなことをしています。

  • ReactiveProperty<int>型の HitPoint(耐久度)を用意して値の変更を監視できるようにする
  • OnBroken で壊れたかどうかの監視できるようにする
  • OnCollisionEnter2DAsObservable でぶつかったときの処理を登録
    • ぶつかったらHitPointを1減らす
  • HitPointの値が変更され、HitPoint <= 0 の場合の処理を登録
    • OnBroken.OnNextで「壊れたよ」と通知
    • OnBroken.OnComplete で「もう監視しなくていいよ」と通知
    • ブロックを破棄(画面上から消す)

Subject や ReactiveProperty は Public で公開しておけば、外のクラスからも監視できるということになります。OnBroken は GameManager クラスから監視します。

まあ使えば慣れていきそうです。

スクリプトを修正するとブロックが壊れるようになっています。

スコアを表示したい

これでなんとなくゲームっぽく、ボールが跳ね返りブロックが壊れていくようになっています。ブロックを壊したときにスコアが加算され、表示されるようにします。

とはいえ今の画面上にはスコア表示エリアがないので、画面上部にとりあえずスコアをテキストで表示するようにしていきます。まずはGameAreaのサイズを調整して、スペースを確保します。

GameAreaをの高さとポジションを調整すると上のようにいい塩梅で上部にスペースが空きます。Anchor Preset をきっちり設定して相対的に位置を指定しているため配置が辺になったりしません。たぶん …

この空いたスペースを ScoreArea とし、スコアを表示します。

ScoreArea と ScoreText の作成

Canvas以下に ScoreArea を空のオブジェクトで作成し、その子として ScoreText を作成します。ScoreText は Text です。

各設定は次のようにして、空きスペース一杯を利用します。

こんな感じで表示されるようになります。

ScoreManager

スコア管理用クラスを作成します。作成したスクリプトはGameManagerオブジェクトに貼り付けます。こいつは GameManager から使います。特に難しいことはないので説明は省略します。

using UnityEngine;
using UnityEngine.UI;

public class ScoreManager : MonoBehaviour
{
    public Text ScoreText { get; private set; }
    public int Score { get; private set; }

    void Awake()
    {
        this.ScoreText = GameObject.Find("ScoreArea").GetComponentInChildren<Text>();
    }

    public void UpdateScore(int score)
    {
        this.Score += score;
        this.ScoreText.text = $"Score: {this.Score}";
    }
}

GameManager

GameManagerクラスの内容を修正し、ブロックが壊れたときにスコアを加算するようにします。

using System.Linq;
using System.Collections.Generic;
using UniRx;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    public BlockProvider BlockProvider { get; private set; }
    public GameObject BlocksArea { get; private set; }
    public ScoreManager ScoreManager { get; private set; }

    void Start()
    {
        this.BlockProvider = GetComponent<BlockProvider>();
        this.BlocksArea = GameObject.Find("Blocks");
        this.ScoreManager = GetComponent<ScoreManager>();

        // 配置の初期化
        InitializeStage();
    }

    /// <summary>
    /// ブロック配置の初期化
    /// </summary>
    private void InitializeStage()
    {
        var blocks = new List<Block>();

        // 開始位置とブロックのサイズ
        var startPosX = -200;
        var startPosY = -80;
        var width = 100;
        var height = 20;

        // 5*5 の25ブロックを生成
        // 各ブロックのポジションは開始位置からブロックサイズ分右下にずらす
        for (int i = 0; i < 5; i++)
        {
            var posY = startPosY - height * i;
            for (int j = 0; j < 5; j++)
            {
                var posX = startPosX + width * j;
                var position = new Vector2(posX, posY);
                var block = BlockProvider.Create(this.BlocksArea.GetComponent<RectTransform>(), position);
                blocks.Add(block);

                // ブロックが壊れたときの処理を登録
                block.OnBroken.Subscribe(
                    score => this.ScoreManager.UpdateScore(score)
                    ).AddTo(block);
            }
        }

        // 全ブロックが壊れたらクリア
        var stream = blocks.Select(blcok => blcok.OnBroken);
        Observable.WhenAll(stream).Subscribe(_ => GameClear());
    }

    /// <summary>
    /// クリア時の処理
    /// </summary>
    private void GameClear()
    {
        Debug.Log("ゲームクリア ...");
    }
}

ついでにクリア処理も作成してます。

ブロックを監視

ブロックのもつOnBrokenサブジェクトを監視(Subscribe)して、壊れたときにスコアを加算する処理を呼んでいます。

Observable.WhenAll はまとめて監視できるようなメソッドです。全部に OnComplete が実行されると、渡されたメソッドを実行してくれます。今回の使い方だと、全部のブロックが壊れて監視終了したタイミングでメソッドが実行されます。このタイミングでクリアとします。

ゲームオーバー判定

ここまででブロックが壊れてスコアが更新されるようになってます。最後にボールが画面外に出たらゲームオーバーとするようにします。現状だと画面外に出てもひたすら遠くまで飛んで行ってしまいます。

Ground

自機の下に透明な地面を作ってあたり判定を持たせます。ここに当たったらゲームオーバーとします。

Groundを作成して上記のように、サイズやポジションを設定します。あたり判定が必要なので Collider を設定します。欲しいのはあたり判定だけなので、Is Trigger にチェックを入れておきます。

GameManagerの完成版

using System.Linq;
using System.Collections.Generic;
using UniRx;
using UnityEngine;
using UniRx.Triggers;

public class GameManager : MonoBehaviour
{
    public BlockProvider BlockProvider { get; private set; }
    public GameObject BlocksArea { get; private set; }
    public ScoreManager ScoreManager { get; private set; }
    public GameObject Ground { get; private set; }

    void Start()
    {
        this.BlockProvider = GetComponent<BlockProvider>();
        this.BlocksArea = GameObject.Find("Blocks");
        this.ScoreManager = GetComponent<ScoreManager>();
        this.Ground = GameObject.Find("Ground");

        // 配置の初期化
        InitializeStage();
    }

    /// <summary>
    /// ブロック配置の初期化
    /// </summary>
    private void InitializeStage()
    {
        var blocks = new List<Block>();

        // 開始位置とブロックのサイズ
        var startPosX = -200;
        var startPosY = -80;
        var width = 100;
        var height = 20;

        // 5*5 の25ブロックを生成
        // 各ブロックのポジションは開始位置からブロックサイズ分右下にずらす
        for (int i = 0; i < 5; i++)
        {
            var posY = startPosY - height * i;
            for (int j = 0; j < 5; j++)
            {
                var posX = startPosX + width * j;
                var position = new Vector2(posX, posY);
                var block = BlockProvider.Create(this.BlocksArea.GetComponent<RectTransform>(), position);
                blocks.Add(block);

                // ブロックが壊れたときの処理を登録
                block.OnBroken.Subscribe(
                    score => this.ScoreManager.UpdateScore(score)
                    ).AddTo(block);
            }
        }

        // 全ブロックが壊れたらクリア
        var stream = blocks.Select(blcok => blcok.OnBroken);
        Observable.WhenAll(stream).Subscribe(_ => GameClear());

        // ゲームオーバー判定
        this.Ground.OnTriggerEnter2DAsObservable()
            .Subscribe(
                collider =>
                {
                    // ぶつかったものを消してゲームオーバー
                    Destroy(collider.gameObject);
                    GameOver();
                }
            );
    }

    /// <summary>
    /// クリア時の処理
    /// </summary>
    private void GameClear()
    {
        Debug.Log("ゲームクリア ...");
    }

    /// <summary>
    /// ゲームオーバー処理
    /// </summary>
    private void GameOver()
    {
        Debug.Log("ゲームオーバー ...");
    }
}

作成したGroundに対して OnTriggerEnter2DAsObservable で Collider に入ってきたときにゲームオーバーとしてます。ゲームオーバー時の処理はひとまずログを出力しているだけです。

ぶつかったオブジェクトは消すようにしています。これでとりあえず完成とします。

問題点

このゲームですが、同じ角度で入ってきたボールは全て同じ角度で跳ね返ります。一応最初のボールの軌道をランダムにしているのでまだましですが、これを同じ軌道で動かし始めると、全く同じ展開になります。

一般的なブロック崩しでは、ボールが当たったバーの位置によって跳ね返る方向が決まるようです。Flashゲームで遊んだ経験に基づくとですが。

参考サイトにはその方法も書いてますが、ここでは省略します。

まとめ

適当な画面遷移を入れたゲームをWebGLでビルドして遊べるようにしました。以下のURLからどうぞ。

Unity WebGL Player | BlockBreak_dev_rx

ソースはGitHub上にあります。

gamegame-game/BlockBreak
Unity Game, BlockBreak. Contribute to gamegame-game/BlockBreak development by creating an account on GitHub.

ひとまず完成させて遊べるようになりました。今回初めて UniRx を初めて使ってみたのですが、かなり便利そうなので積極的に使っていこうと思います。

次は、マインスイーパーを作ってみようかなと思います。

リンク

参考URL

コメント