おもちゃラボ

Unityで遊びを作ってます

Unityで使える!C#の便利な機能7選

Unityでは基本的にC#を使ってプログラムを書くと思います。そんなときに知っておくと「チョットだけ便利」「チョットだけ楽できそう」なC#の文法をまとめました。現在Unityで使える最新のC#のバージョンはC#6になっています。使えるものはじゃんじゃん利用していきましょう!

null許容型

int型やfloat型などの値型には、通常nullを代入することはできません。でも、null許容型を使うことでnull値を代入できるようになります。

null許容型を宣言するには通常の値型の最後に「?」をつけます(なんでハテナやねんと思わなくもない)

int? a;
float? b;

みたいな感じです。

値型にnullが入れられると、どんな良いことがあるのでしょうか?例えばメソッドの中で何か処理をしてから、処理結果をint型で返したいとしましょう。処理が失敗した場合は-1を返します。

int hoge()
{
	// なんか処理

	if( success ){
		return result;
	} else {
		return -1;
	}
}

この場合、処理結果(result)が-1だった場合、メソッドの呼び出し元は返り値が処理結果なのかエラーか判別できません。そこでnull許容型の登場です。

int? hoge()
{
	// なんか処理

	if( success ){
		return result;
	} else {
		return null;
	}
}

void Start()
{
	int? result = hoge();
	if( result != null ){
	}
}

null許容型を使うことで、上のように返り値がnullだった場合のみエラーとして扱うことができるようになります。

数値を戻り値に使う場合は、たまに役に立つので覚えておくと、ちょっとだけプログラムが綺麗になるかもしれません!?

enum型

使っていますか?enum型。CやC++でゴリゴリプログラムを書いていた人にはお馴染みだと思いますが・・・。もちろんC#でもenum型を使うことができます。

    public enum PlayerState{
        WAIT,
        RUN,
        DAMAGE,
        DEAD,
    };
    public PlayerState type;
    
    void Start()
    {
    	this.type = PlayerState.WAIT;
    }

enum型はクラスや構造体と同じように型を作るための仕組みです。ただし、クラスのようにメソッドや変数を定義するのではなく、関連のある定数値を複数作るのに使用します。

例えば、上のC#プログラムのように、プレイヤのステート管理や、ゲームのステート管理をするのによく使われます。

定数値と書きましたが、enum型ではその中で宣言した順番に自動的に数値が割り振られます。上の例ではWAIT=0、RUN=1、DAMAGE=2、DEAD=3となります。

なので、次のようにenum型の最後にSTATE_NUMと宣言しておくと、自動的に「STATE_NUM=4」となって、ステートの数を宣言することができます(C言語では多様されていましたが、今でも使うのかなぁ・・・)

    public enum EnemyState{
        WAIT,
        ATTACK,
        DEAD,
        STATE_NUM,
    };

さらに、さらに!enum型の変数をpublicで宣言すると、Unityのインスペクタからドロップダウンで値を選べるようになる特典付き!これは結構便利です。

f:id:nn_hokuson:20170825184124p:plain:w300

テキストファイル読み込み

Unityでテキストファイルを読み込むのは非常に簡単です。と書くと、どうせ「usingとStringStreamを使うんでしょ〜〜〜」と言われそうです。

その方法は「確かな力が身につくC#超入門」で解説しているのでそちらを参考にしていただくとして・・・(笑)

UnityではTextAssetを使うとStringStreamを使うまでもなく、ファイルの内容を読み込むことができます。

 public TextAsset file;
 
 void Start()
 {
 	string data = file.text;
 } 

はい、これだけです!なんと簡単な!TextAsset型の変数をpublic変数で宣言し、Unity側でインスペクタからテキストファイルをセットすればあとはdata変数の中にファイルの全行が格納されます。

f:id:nn_hokuson:20170825163112j:plain

あとはSplitメソッドを使って一行一行パースするなり、焼くなり、好きなように調理すればOKです。ちなみにSplitメソッドを使ったパースは次のような感じになります。

 public TextAsset file;
 
 void Start()
 {
	string[] lines = data.text.Split ('\n');
	foreach (string line in lines)
	{
	    Debug.Log (line);
	}
 } 

拡張メソッド

拡張メソッドとは、すでにあるクラスにメソッドを追加することができる機能です。まさに、拡張するためのメソッド、略して拡張メソッドですね。

なんか、難しそうな割に使う機会がなさそうな機能ですが、unityでは意外と役に立つやつなんですよ。例えば、Unityでは次のようにオブジェクトの持つpositionの値を1つだけ変える、ということができません。

gameObject.transform.position.x = 3; // エラー

xの値だけを変えたい時は次のように書かなくちゃいけないんですが、何はともあれ不恰好ですよね・・・

Vector3 pos = gameObject.transform.position;
pos.x = 3;
gameObject.transform.position = pos;

ということで拡張メソッドの出番です。 拡張メソッドを使ってTransformクラスにメソッドを追加するには次のように書きます。

public static class TransformEx
{
    static Vector3 v;

    public static void SetPosX(this Transform transform, float x) 
    {
        v.Set(x, transform.position.y, transform.position.z);
        transform.position = v;
    }
}

ここでは、拡張メソッドを使ってTransoformクラスにSetPosXというメソッドを追加しています。拡張メソッドを作るには、静的クラスを作成し、その中に追加したいメソッドを書きます。

このとき、第一引数にはthisキーワードに続けて拡張したいクラス名を書きます。ここではTransformクラスを拡張したいので、this Transform transform としています。

作成した拡張メソッドを呼び出す方法は、通常のメソッドと変わりありません。つぎのようにtransformに続けてメソッド名を書くだけです。

void Start()
{
    Debug.Log(transform.position);
    transform.SetPosX (10);
    Debug.Log(transform.position);
}

このように、拡張メソッドを使うと、あたかもTransformクラスに最初からSetPosXメソッドが用意されているかのように、シレッと使うことができるのです。なかなか便利でしょ?

プロパティ

メンバメソッドをカプセル化したい場合、C++では一つ一つアクセサを手書きする必要がありました。この煩雑さを解決する手段としてC#には簡単にアクセサを作成できる「プロパティ」という機能が追加されました。

プロパティの基礎的はお話は「確かな力が身につくC#超入門」で解説していますので、こちらも書籍を参照いただければとおもいます m(_ _)m

プロパティ、とても便利なんですが、C#のバージョンを重ねるごとにプロパティの仕様がコロコロと変わり・・・ここでは自動実装プロパティと初期化ができるようになったC# 6時点での最終形態だけを紹介したいと思います。

この機能を試すにあたってはUnityでメニューバーからEdit -> Project Settings -> Playerを選択し、Other Settingsの「Scripting Runtime Version」を「Experimental(.NET 4.6 Equivalent)」に設定して下さい。

f:id:nn_hokuson:20170825184416p:plain:w350

プロパティを使うと次のような書き方ができます。

public class Player
{
    public int Hp{ get; set; } = 100;
    public string Name {get;} = "hoge";
}

void Start()
{
	  Player player = new Player ();
      Debug.Log (player.Name);

      player.Hp -= 30;
      Debug.Log (player.Hp);
}

ここではPlayerクラスにHpとNameのプロパティを作っています。通常の変数宣言と同様にpublic int Hpと書き、続く{} の中にアクセス権限を記述します。

Hpの場合はset;とget;を指定しています。Nameは書き換えることはないのでget;のみを指定しています。最後に=に続けて値を書くことで、プロパティを初期化できます。

プロパティを使う場合は、通常のメンバ変数(メンバメソッドではなく)を使うように使えます。player.Hp -= 30のように直接プロパティ値を書き換えられることに注目してください!

メンバメソッドだと次のように冗長な書き方になってしまいますね・・・

int hp = palyer.GetHp();
hp -= 30;
player.SetHp(hp);

ということで、最新版のプロパティはかなり便利になっているので積極的に使っていきましょう!

タプル型

タプル型はかなりマイナーです、たぶん(笑)なぜなら、使いどころがいまいちないからだ、と思っていますがどうでしょう。ですが、戻り値を複数返したい場合には、タプルを使うとちょっと便利です(本当にチョットですよ)

 Tuple<string, int>GetStatus()
{
    string name = "hoge";
    int hp = 50;
    return Tuple.Create (name, hp);
}

void Start()
{
    var status = GetStatus ();
    Debug.Log ("Name=" + status.Item1 + ",HP=" + status.Item2);
}


ここではタプル型を使って複数の戻り値をメソッドの呼び出し元に返しています。ListやDictionaryのコレクションを使い慣れている人にはおなじみの書き方ですね。

Tupleに続けて<>の中にまとめたい型名を「,」で区切って書きます。ここではプレイヤの名前とHPを返り値として返したいのでTupleと書いています。

タプルを作るにはTuple.Createメソッドを使います。また、戻り値としてタプル型を受け取るにはvarを使います。タプルから値を取り出したい場合は「変数名.Item1」, 「変数名.Item2」のように書くことで、順番に値を取り出すことができます。

タプルの特徴をまとめると、わざわざ新しく戻り値に使う型を作らなくても、名前のないTuple型を使えば良い、ということになります。そういう意味ではラムダ式と似た構造なのですが、如何せん使いどころが・・・(笑)

まとめ

Unityで使えるC#の便利機能を7つ紹介しました。

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

前回はキー入力によるパドルの移動、パドルとボールの当たり判定を作成しました。

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

今回は、最後の仕上げとしてブロックを作成し、ボールとブロックの当たり判定をVisual C#を使って作りましょう。最終的な完成イメージは次のようになります。

f:id:nn_hokuson:20170816120653g:plain:w350

ブロックを作成する

まずはフォームにひとつだけブロックを表示するところから始めましょう。ブロックはパドルを描画したときと同じ手順で表示できます。だんだんC#のプログラムが長くなってきたので、必要な部分のみ載せます。C#プログラムの全文は下記のページで見ることができます。

DrawBlock · GitHub

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

    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);
        this.blockPos =  new Rectangle(100, 50, 80, 25);

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

    double DotProduct(Vector a, Vector b)
    {
        //変更なし
    }

    bool LineVsCircle(Vector p1, Vector p2, Vector center, float radius)
    {
        //変更なし
    }

    private void Update(object sender, EventArgs e)
    {
        //変更なし
    }

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

        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);
        e.Graphics.FillRectangle(blueBrush, blockPos);
    }

    private void KeyPressed(object sender, KeyPressEventArgs e)
    {
        //変更なし
    }
}

メンバ変数にブロックの位置と大きさを定義するblockPos変数を追加しています。また、このblockPos変数を使ってDrawメソッド内でブロックの描画をしています。

実行すると次のように画面上部にブロックが表示されます。

f:id:nn_hokuson:20170816101547p:plain:w350

ブロックとボールの当たり判定をする

続けてブロックとボールの当たり判定を作ります。ボールとブロックの当たり判定は「円と長方形の当たり判定」とみなせますね。

f:id:nn_hokuson:20170816132541p:plain:w500

そして円と長方形の当たり判定は、円と直線の当たり判定を4回繰り返すことで実現できます。単純ですが、実装は簡単なのでこの方法でプログラムを作りましょう。

f:id:nn_hokuson:20170816133106p:plain

次のプログラムを入力して下さい。こちらもプログラム全文は次のページを参照して下さい。

BlockVsCircle · GitHub

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

    public Form1()
    {
        //変更なし
    }

    double DotProduct(Vector a, Vector b)
    {
        //変更なし        }

        bool LineVsCircle(Vector p1, Vector p2, Vector center, float radius)
        {
            //変更なし
        }

        int BlockVsCircle(Rectangle block, Vector ball)
        {
            if (LineVsCircle(new Vector(block.Left, block.Top),
                new Vector(block.Right, block.Top), ball, ballRadius))
                return 1;

            if (LineVsCircle(new Vector(block.Left, block.Bottom),
                new Vector(block.Right, block.Bottom), ball, ballRadius))
                return 2;

            if (LineVsCircle(new Vector(block.Right, block.Top),
                new Vector(block.Right, block.Bottom), ball, ballRadius))
                return 3;

            if (LineVsCircle(new Vector(block.Left, block.Top), 
                new Vector(block.Left, block.Bottom), ball, ballRadius))
                return 4;

            return -1;
        }

        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;
            }

            // ブロックとのあたり判定
            int collision = BlockVsCircle(blockPos, ballPos);
            if( collision == 1 || collision == 2)
            {
                ballSpeed.Y *= -1;
            }
            else if(collision == 3 || collision == 4)
            {
                ballSpeed.X *= -1;
            }

            // 再描画
            Invalidate();
        }

        private void Draw(object sender, PaintEventArgs e)
        {
            //変更なし
        }

        private void KeyPressed(object sender, KeyPressEventArgs e)
        {
            //変更なし
        }
    }
}

ここでは長方形と円の当たり判定をするためのBlockVsCircleメソッドを定義しています。このメソッドはブロックの情報と円の中心座標を引数に取ります。

メソッドの中ではブロックの4辺について1辺ずつ「円と直線の当たり判定」を使って当たりを調べています。当たっていた場合は1〜4の値を返します。

Updateメソッドの中で作成したBlockVsCircleメソッドを使ってボールとブロックの当たり判定をしています。メソッドの返り値を使って当たった辺を調べ、辺によって跳ね返る方向を変化させています。

実行すると次のようにボールがブロックで跳ね返るようになります。

f:id:nn_hokuson:20170816111347g:plain:w350

複数のブロックを表示する

1つのブロックで当たり判定ができたので、次はフォームの上部に複数のブロックを表示しましょう。次のプログラムを入力して下さい。プログラム全文は次のページを参照下さい。

DrawBlocks · GitHub

public partial class Form1 : Form
{
    Vector ballPos;
    Vector ballSpeed;
    int ballRadius;
    Rectangle paddlePos;
    List<Rectangle> blockPos;

    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);

        this.blockPos = new List<Rectangle>();
        for (int x = 0; x <= this.Height; x += 100)
        {
            for (int y = 0; y <= 150; y += 40)
            {
                this.blockPos.Add(new Rectangle(25 + x, y, 80, 25));
            }
        }


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

    double DotProduct(Vector a, Vector b)
    {
            //変更なし    }

    bool LineVsCircle(Vector p1, Vector p2, Vector center, float radius)
    {
            //変更なし
    }

    int BlockVsCircle(Rectangle block, Vector ball)
    {
            //変更なし
    }

    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;
        }

        // ブロックとのあたり判定
        for (int i = 0; i < this.blockPos.Count; i++)
        {
            int collision = BlockVsCircle(blockPos[i], ballPos);
            if (collision == 1 || collision == 2)
            {
                ballSpeed.Y *= -1;
            }
            else if (collision == 3 || collision == 4)
            {
                ballSpeed.X *= -1;
            }
        }

        // 再描画
        Invalidate();
    }

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

        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);

        for (int i = 0; i < this.blockPos.Count; i++)
        {
            e.Graphics.FillRectangle(blueBrush, blockPos[i]);
        }
    }

    private void KeyPressed(object sender, KeyPressEventArgs e)
    {
            //変更なし
    }
}

ここでは複数のブロックを表示するため、blockPos変数をList型に変更しています。コンストラクタの中で必要な個数分ブロックを生成し、当たり判定と描画部分はListの個数分繰り返しています。

プログラムを実行すると次のように複数個のブロックが表示されます。

f:id:nn_hokuson:20170816115256p:plain:w350

当たった場合はブロックを消す

さて、長かったブロック崩し作りも最後の工程です!最後はボールがブロックに当たった場合、ブロックを消します。ブロックを消すにはブロックのリストblockPosから、当たったブロックの変数を削除します。Updateメソッドの中のブロックとの当たり判定の部分を次のように書き換えて下さい。

            // ブロックとのあたり判定
            for (int i = 0; i < this.blockPos.Count; i++)
            {
                int collision = BlockVsCircle(blockPos[i], ballPos);
                if (collision == 1 || collision == 2)
                {
                    ballSpeed.Y *= -1;
                    this.blockPos.Remove(blockPos[i]);  // 追加
                }
                else if (collision == 3 || collision == 4)
                {
                    ballSpeed.X *= -1;
                    this.blockPos.Remove(blockPos[i]); // 追加
                }
            }

ボールとブロックが当たった場合、ListのもつRemoveメソッドを使ってリストから当たったブロックの変数を削除しています。Removeメソッドに削除したい要素を渡すことで、リストの中から指定した要素を削除することができます。Listの持つメソッドについては「確かな力が身につくC#超入門」を参照下さい。

実行すると、次のようにボールが当たったブロックが消えるようになります。これでブロック崩しに必要な一通りの機能が作れました!

f:id:nn_hokuson:20170816120653g:plain:w350

まとめ

ようやくブロック崩しゲームは完成です!UnityやUnreal Engineといったゲームエンジンを使わなくてもVisual C#の機能だけでゲームを作ることができましたね。

ゲームづくりにはまだまだ学ばなければいけないことはたくさんありますが、基本的な考え方は押さえられたのではないでしょうか。もう少し踏み込んでゲームを作ってみたい方には次の書籍がおすすめです。

【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の教科書」もよろしくお願いします。