おもちゃラボ

Unityで遊びを作ってます

【Visual C#でゲームを作る】ブロック崩し編 その2

前回はブロック崩しのゲームを作るため、まずVisual C#プロジェクトを作り、ボールの表示と移動、壁との当たり判定を作りました。

【Visual C#でゲームを作る】ブロック崩し編 その1 - おもちゃラボ
【Visual C#でゲームを作る】ブロック崩し編 その2 - おもちゃラボ ◀今回
【Visual C#でゲームを作る】ブロック崩し編その3 - おもちゃラボ

今回も引き続きC#を使って、プレイヤが移動させるパドルの表示と移動、パドルとボールの当たり判定を作っていきます。ゲームエンジンを使わずに、C#だけでゴリゴリ当たり判定をするのが結構大変なことが理解できると思います・・・

f:id:nn_hokuson:20170814142508g:plain:w350

プレイヤが動かすパドルを表示しよう

まずはパドルを表示するところから始めましょう。パドルは長方形を描画するだけなので、比較的簡単そうです。次のプログラムを入力して下さい(前回と同じくForm1クラスの中身を表示しています)。

    public partial class Form1 : Form
    {
        Vector ballPos;
        Vector ballSpeed;
        int ballRadius;
        Rectangle paddlePos;

        public Form1()
        {
            InitializeComponent();

            this.ballPos = new Vector( 200, 200 );
            this.ballSpeed = new Vector(-2, -4);
            this.ballRadius = 10;
            this.paddlePos = new Rectangle(100, this.Height - 50, 100, 5);

            Timer timer = new Timer();
            timer.Interval = 33;
            timer.Tick += new EventHandler(Update);
            timer.Start();
        }

        private void Update(object sender, EventArgs e)
        {
            // ボールの移動
            ballPos += ballSpeed;

            // 左右の壁でのバウンド
            if (ballPos.X + ballRadius > this.Bounds.Width || ballPos.X - ballRadius < 0)
            {
                ballSpeed.X *= -1;
            }

            // 上の壁でバウンド
            if (ballPos.Y - ballRadius < 0)
            {
                ballSpeed.Y *= -1;
            }

            // 再描画
            Invalidate();
        }

        private void Draw(object sender, PaintEventArgs e)
        {
            SolidBrush pinkbrush = new SolidBrush(Color.HotPink);
            SolidBrush grayBrush = new SolidBrush(Color.DimGray);

            float px = (float)this.ballPos.X - ballRadius;
            float py = (float)this.ballPos.Y - ballRadius;

            e.Graphics.FillEllipse(pinkbrush, px, py, this.ballRadius * 2, this.ballRadius * 2);
            e.Graphics.FillRectangle(grayBrush, paddlePos);
        }
    }

メンバ変数にパドルの位置を決めるpaddlePosを追加して、コンストラクタで位置を初期化しています。Drawメソッドの中では、ボールを描画したときと同様、パドル用のブラシを作成してフォーム上に描画しています。

ブラシの色を決めるにはSolidBrushのコンストラクタで色を指定します。色の見本はこちらが参考になりました。

arikalog.hateblo.jp

C#プログラムをビルドして実行すると、次のように画面下にパドルが表示されます。

f:id:nn_hokuson:20170814143405p:plain:w350

キー入力に応じてパドルを動かそう

続いて、キーの入力に応じてパドルが動くようにしてみましょう。「Aキー」を押している間はパドルを左方向に移動、「Sキー」を押している間はパドルを右方向に移動します。

f:id:nn_hokuson:20170815134455p:plain:w350

Visual C#にはキーが押されたときに呼ばれるKeyDownイベントハンドラが用意されています。イベントハンドラの詳細については「確かな力が身につくC#超入門」で紹介しています。

Form1.cs[デザイン]を開いてフォームを選択した状態で、プロパティウインドウからイベントタブを選択して下さい。KeyPressの欄に「KeyPressed」と入力してエンターキーを押して下さい。

f:id:nn_hokuson:20170815135208j:plain:w350

この記事では分かりやすさのためにイベントハンドラを使用しますが、アクションゲームなどシビアなタイミングが求められる場合は、自前でボタン入力を監視したほうが良いでしょう。

今作成したKeyPressedイベントハンドラの中に次のプログラムを入力して下さい。

        private void KeyPressed(object sender, KeyPressEventArgs e)
        {
            if (e.KeyChar == 'a') // Aキーが押されたとき
            {
                this.paddlePos.X -= 20;
            }
            else if( e.KeyChar == 's') // Sキーが押されたとき
            {
                this.paddlePos.X += 20;
            }
        }

KeyPressイベントハンドラはキーが押されているときに呼び出されるイベントハンドラです。ここではKeyCharプロパティを使って押されているキーを調べ、Aキーならパドルの位置を左に、Sキーならパドルの位置を右に移動しています。

プログラムが入力できたら、実行してみて下さい。キーの入力に応じてパドルが移動します。

f:id:nn_hokuson:20170815135433g:plain:w350

パドルとボールの当たり判定をしよう

では、今回の記事の最大の山(?)である「C#を使ったパドルとボールの当たり判定」をしましょう。Unityなどのゲームエンジンを使うとColliderが自動的に当たりを計算してくれるので、自分で当たりを計算する必要はありません。

[asin:B01H4ONR86:detail]

今回はゲームエンジンを使わないので、自前で当たり判定を計算することになります。パドルは長方形をしていますが、直線とみなせますね。そこで今回はボールとパドルの当たり判定を「円と直線」の当たり判定として考えます。

f:id:nn_hokuson:20170815155255p:plain:w550

円と直線が当たっているかどうかを調べるには、横方向の当たりと縦方向の当たりの2つをチェックする必要があります。

  1. 円の中心座標がパドルの両端内にある
  2. 円からパドルに降ろした垂線の距離がボールの半径以下

これらの条件を2つとも満たす場合はパドルとボールが衝突しているとみなせます。それぞれの条件を調べる方法を考えていきましょう。

円の中心座標がパドルの両端内にある

今回はパドルが回転しないので パドルの左端のX座標 < 円の中心のX座標 < パドルの右端のX座標として調べてもよいのですが、ここではもう少し一般的な場合にも対応できる方法を紹介します。

パドルの両端座標をp1, p2、ボールの中心座標をpとします。また、パドル左端からボールへのベクトルをdir1、パドル右端からボールへのベクトルをdir2とし、 パドルの横方向のベクトル(p2 - p1)をlineDirとします。

f:id:nn_hokuson:20170815162011p:plain:w250

パドルの両端内部に円の中心座標があるということは、「lineDirとdir1のなす角度が鋭角」かつ「lineDirとdir2のなす角度も鋭角」と考えられます。

f:id:nn_hokuson:20170815162447p:plain:w520

2つのベクトルがなす角度が 鋭角かどうかを調べるには内積を計算します。ベクトルaとベクトルbの内積はa・b = |a||b|cosθで計算できます。なす角が鋭角の場合はcosθが0〜1の範囲になり、鈍角の場合にはcosθが0〜-1の範囲になります。したがって、内積がプラスなら鋭角、内積がマイナスなら鈍角と判断できます。

これをふまえると、円の中心座標がパドルの両端内にあるかどうかは、「dir1とlineDirの内積内積がプラス」かつ「dir2とlineDirの内積内積がプラス」であることを調べればよさそうです。両端の角度が同時に鈍角にはならないため、次の計算式でチェックできます。

(dir1・lineDir) * (dir2・lineDir) < 0  

円からパドルに引いた垂線の距離がボールの半径以下

円の中心からパドルに降ろした垂線の長さを計算するにはパドルの法線ベクトルを使います。パドルの左端からボールの中心座標へのベクトル(dir1)と、パドルの法線ベクトル(n)の内積をとることで、垂線の長さを計算できます。

f:id:nn_hokuson:20170815170134p:plain:w250

これは次式のように、法線ベクトルの長さ|n|が1であることと、|dir1|*cosθが垂線の長さになることを利用しています。

dir1・n = |dir1||n|cosθ = |dir1|cosθ 

線と円の当たり判定のアルゴリズムが理解できたところで、実際にプログラムを入力してみましょう。

    public partial class Form1 : Form
    {
        Vector ballPos;
        Vector ballSpeed;
        int ballRadius;
        Rectangle paddlePos;

        public Form1()
        {
            InitializeComponent();

            this.ballPos = new Vector( 200, 200 );
            this.ballSpeed = new Vector(0, 0);
            this.ballRadius = 10;
            this.paddlePos = new Rectangle(100, this.Height - 50, 100, 5);

            Timer timer = new Timer();
            timer.Interval = 33;
            timer.Tick += new EventHandler(Update);
            timer.Start();
        }

        double DotProduct(Vector a, Vector b)
        {
            return a.X * b.X + a.Y * b.Y; // 内積計算
        }

        bool LineVsCircle(Vector p1, Vector p2, Vector center, float radius)
        {
            Vector lineDir = (p2 - p1);                   // パドルの方向ベクトル
            Vector n = new Vector(lineDir.Y, -lineDir.X); // パドルの法線
            n.Normalize();

            Vector dir1 = center - p1;
            Vector dir2 = center - p2;

            double dist = Math.Abs(DotProduct(dir1, n));
            double a1 = DotProduct(dir1, lineDir);
            double a2 = DotProduct(dir2, lineDir);

            return (a1 * a2 < 0 && dist < radius) ? true : false;
        }

        private void Update(object sender, EventArgs e)
        {
            // ボールの移動
            ballPos += ballSpeed;

            // 左右の壁でのバウンド
            if (ballPos.X + ballRadius > this.Bounds.Width || ballPos.X - ballRadius < 0)
            {
                ballSpeed.X *= -1;
            }

            // 上の壁でバウンド
            if (ballPos.Y - ballRadius < 0)
            {
                ballSpeed.Y *= -1;
            }

            // パドルのあたり判定
            if (LineVsCircle(new Vector(this.paddlePos.Left, this.paddlePos.Top), 
                             new Vector(this.paddlePos.Right, this.paddlePos.Top),
                             ballPos, ballRadius))
            {
                ballSpeed.Y *= -1;
            }

            // 再描画
            Invalidate();
        }

        private void Draw(object sender, PaintEventArgs e)
        {
            SolidBrush pinkbrush = new SolidBrush(Color.HotPink);
            SolidBrush grayBrush = new SolidBrush(Color.DimGray);

            float px = (float)this.ballPos.X - ballRadius;
            float py = (float)this.ballPos.Y - ballRadius;

            e.Graphics.FillEllipse(pinkbrush, px, py, this.ballRadius * 2, this.ballRadius * 2);
            e.Graphics.FillRectangle(grayBrush, paddlePos);
        }

        private void KeyPressed(object sender, KeyPressEventArgs e)
        {
            if (e.KeyChar == 'a') // Aキーが押されたとき
            {
                this.paddlePos.X -= 20;
            }
            else if( e.KeyChar == 's') // Sキーが押されたとき
            {
                this.paddlePos.X += 20;
            }
        }
    }

直線と円の当たり判定をするためのLineVsCircleメソッドを定義しています。このメソッドは直線の両端座標と、円の中心座標、円の半径を引数に取ります。メソッド内では、まずパドルの法線ベクトルnを計算しています。長さ1のベクトルにするためNormalizeメソッドを使っていることに注意して下さい。

続けて、ボールの中心座標がパドルの両端内にあるかどうかを調べるため、内積の計算をしています。Visual C#のVectorクラスには内積計算用のメソッドが無いため、DotProductメソッドを作っています。また、ボールからパドルに降ろした垂線の距離がボールの半径以下かどうかを調べ、円と直線が当たっていた場合はtrueを、当たっていなかった場合はfalseを返します。

最後に、Updateメソッドの中で作成したLineVsCircleメソッドを使って当たり判定をしています。パドルの両端座標とボールの中心座標、半径を引数に渡しています。

プログラムが打ち込めたら、実行してみて下さい。ちゃんとパドルでボールが打ち返せるようになっているはずです。

f:id:nn_hokuson:20170814142508g:plain:w350

まとめ

今回は、キーボードを使ったパドルの移動と、パドルとボールの当たり判定を実装しました。パドルとボールの当たり判定はけっこう大変だったかもしれません。円と直線の当たり判定は今後のブロックとボールの当たり判定でも使う考え方なのでしっかりと理解しておきましょう。

ここまでブロック崩しを作っていたはずですが、ブロックがないとゲームになりません(笑)そこで、最終回はブロックを作ってボールが当たったら消えるようにして、ゲームを完成させましょう!

nn-hokuson.hatenablog.com


当たり判定に特化した有名な参考書がコチラ。あらゆる当たり判定、当たり判定の効率化などが非常に詳しく解説されています。が、むずかしい(笑)

「確かな力が身につくC#超入門」&「Unity5の教科書」もよろしくお願いします。