Visual StudioのWindows フォーム アプリケーションを使用すると、C#で簡単にそれらしいゲームを作ることができます。
前回、C言語で作ったスネークゲームをC#で作り直してみたいと思います。
C言語のスネークゲームについては以下の記事を参照してください。
ほとんど同じ量のソースコードしか書いていませんが、出来上がるアプリケーションのクオリティは圧倒的にC#で作った方が高いことがわかります!
C#とは
C#はマイクロソフトが開発したオブジェクト指向プログラミング言語の一つで、WindowsアプリケーションやWebアプリケーションなどの開発に使用されます。
Windowsアプリケーションの開発には、統合開発環境であるVisual Studioを用いることが一般的です。
Visual StudioでC#を使う場合、同じくマイクロソフトが提供している.NET Frameworkというプラットフォームを活用することで、いろんな機能の実装がとても簡単にできるようになっています。
そのC#のメリット・デメリットについて、C言語と比較しながら見てみましょう。
スネークゲームとは
画面の中でヘビを動かして、餌を食べていき成長していくゲームです。
壁、もしくは自分の体に当たるとゲームオーバーです。
環境
OS | Windows11 |
統合開発環境 | Visual Studio 2022(Windows フォーム アプリケーション) |
.NET Framework | 4.7.2 |
ソースコード
ソースコードは以下のgitにて公開しています。
以下の手順で、ゲームを実行することができます。
解説
プロジェクトの作成
C#をインストールしたVisual Studioを起動して、「新しいプロジェクトを作成」をクリックする。
「Windows フォーム アプリケーション」を選択して「次へ」をクリックする。
プロジェクトに任意の名前をつけて「作成」を押下する。
以上でプロジェクトの作成は完了です。
Formの作成
スネークゲームは3つの画面で構成します。
仕様は以下の通りです。
メニューフォーム
プロジェクトを作成したときに、「Form1.cs」が自動で作成されていると思います。
まずはForm1を「メニュー」画面に設定します。
まずは自動で作成されたファイル「Form1.cs」のファイル名を変更しておきます。
ソリューションエクスプローラーから「Form1.cs」を右クリックして「名前の変更」を選択し、「Menu.cs」という名前に変更します。
次にForm1にボタンを2つ追加します。
左のタブにある「ツールボックス」から「Button」をForm1の上にドラッグ&ドロップすることでボタンを追加することができます。
これを2回繰り返してボタンを2個つくりましょう。
次にフォームとボタンのプロパティ、イベントを設定します。
まずは「Form1」を選択した状態で、右下の「プロパティ」タブから「プロパティ」アイコンを選択して、それぞれ下表の通り設定します。
●Form1
プロパティ名 | 設定値 |
(Name) | FMenu |
AutoScaleMode | Dpi |
Location | 0, 0 |
Size | 280, 171 |
Text | メニュー |
同様の方法でボタンのプロパティも以下の通りに設定します。
●Button1
プロパティ名 | 設定値 |
(Name) | BStart |
Location | 12, 12 |
Size | 240, 50 |
Text | スタート |
●Button2
プロパティ名 | 設定値 |
(Name) | BEnd |
Location | 12, 68 |
Size | 240, 50 |
Text | おわる |
完成系は以下のようになります。
スネークゲーム フォーム
2つ目のフォームを追加して、スネークゲームのメイン画面を作成します。
「プロジェクト」タブの「フォームの追加」をクリックして新しいフォームを追加します。
フォームに「SnakeGame.cs」という名前を付けて「追加」をクリックします。
作成したフォーム上に「パネル」を配置します。
パネルは、複数の部品をひとまとめにするのに便利です。
(後ほどパネルという親の部品に子の部品を紐づけていきます。)
フォームとパネルのプロパティをそれぞれ下表のように設定します。
●Form1
プロパティ名 | 設定値 |
(Name) | FSnakeGame |
AutoScaleMode | Dpi |
Location | 0, 0 |
Size | 599, 622 |
Text | スネークゲーム |
●panel1
プロパティ名 | 設定値 |
(Name) | PField |
BackColor | DarkKhaki |
Location | 12, 12 |
Size | 560, 560 |
完成系は以下のようになります。
リザルト フォーム
同様の手順で「Result.cs」のフォームを追加して、ボタンを2つ、ラベルを1つ追加します。
フォームとボタンのプロパティを下表のように設定します。
●Form1
プロパティ名 | 設定値 |
(Name) | FResult |
AutoScaleMode | Dpi |
Location | 0, 0 |
Size | 346, 184 |
Text | リザルト |
同様の方法でボタンのプロパティも以下の通りに設定します。
●Button1
プロパティ名 | 設定値 |
(Name) | BReplay |
Location | 12, 83 |
Size | 150, 50 |
Text | リプレイ |
●Button2
プロパティ名 | 設定値 |
(Name) | BEnd |
Location | 168, 83 |
Size | 150, 50 |
Text | おわる |
●Label1
プロパティ名 | 設定値 |
(Name) | LResult |
Location | 98, 28 |
Size | 137, 30 |
Text | GameOver |
完成系は以下のようになります。
これでフォームの作成は完了です。
メニュー フォームの実装
「Menu.cs[デザイン]」を開き、「スタート」ボタンを選択した状態で、「プロパティ」タブの「イベント」から「Click」の欄に「BStart_click」と入力します。
すると、「Menu.cs」ファイルが開かれて、「BStart_click」の関数が自動で作成されます。
「スタート」ボタンをクリックするとこの関数が呼び出されます。
ここにボタンをクリックしたときの処理を実装していきます。
同様に「おわる」ボタンにも「BEnd_click」関数を追加します。
それぞれの処理を実装すると、「Menu.cs」ファイルは以下のようになります。
private void BStart_click(object sender, EventArgs e)
{
// スネークゲーム フォームのインスタンスを生成
FSnakeGame f_SnakeGame = new FSnakeGame();
// Showで画面に表示
// 自分自身のポインタを引数で渡すことで
// スネークゲームフォームからメニューフォームを操作できる
f_SnakeGame.Show(this);
// メニューフォームを隠す(インスタンスは残る)
Hide();
}
private void BEnd_click(object sender, EventArgs e)
{
// メニューフォームを閉じる
Close();
}
これで、「スタート」ボタンを押すと「FSnakeGame」フォームが立ち上がり、「おわる」ボタンを押すとアプリケーションが終了する実装ができあがりました。
リザルト フォームの実装
追加する実装は以下の通りです。
private void BReplay_click(object sender, EventArgs e)
{
// 親(スネークゲーム フォーム)を開く
Owner.Show();
Hide();
}
private void BEnd_click(object sender, EventArgs e)
{
// アプリケーションを終了
// 残っている親のインスタンスも含めて全て閉じる
Application.Exit();
}
private void FResult_formClosed(object sender, FormClosedEventArgs e)
{
Application.Exit();
}
これで「リプレイ」ボタンを押すとスネークゲームフォームを開き、「おわる」ボタンを押すとアプリケーションが終了します。
また、フォームにイベントで「FormClosed」を追加しています。
「×」ボタンを押したときに親のフォームが残ったままリザルトフォームが消えてしまい、プロセスが残り続ける状態を防ぎます。
スネークゲーム フォームの実装
スネークゲーム フォームの実装では、以下のようなクラス関係で作りたいと思います。
CConstants
スネーク フォーム内で使用する定数を定義します。
C#ではマクロ(#define)を使用できないので、定数のみを集めたクラスを宣言することにします。
// 定数用クラス
static class CConstants
{
// フィールドの横幅と縦幅
public const int WIDTH = 10;
public const int HEIGHT = 10;
// スネークの頭の初期位置
public const int STARTX = WIDTH / 2;
public const int STARTY = HEIGHT / 2;
// スネークの最大長
public const int SNAKE_MAX_LENGTH = (WIDTH - 2) * (HEIGHT - 2);
// キー入力用定数
public enum Direction
{
left,
up,
right,
down,
non
};
}
FSnakeGame
コンストラクタとメンバ変数
コンストラクタは、インスタンスが生成されたときに必ず一度呼ばれる処理です。
基本的にメンバ変数の初期化処理を書くことが多いです。
スネークゲーム フォームクラスのコンストラクタではPictureBoxという部品を、デザイナーではなく手書きで実装します。
これは、PictureBoxを配列で宣言してプログラム中で扱いやすくするためです。
PictureBoxは定数のWIDTH × HEIGTH分だけパネルの上に作成して、状況に応じて壁・スネークの頭・スネークの胴体・餌のどれかを表示します。
public FSnakeGame()
{
// 自動で生成される関数
// デザイナー画面で追加した部品を初期化してくれます。
// この関数の中身を下手にいじるとデザイナーと不整合が起こるので
// 触らないでください。
InitializeComponent();
// フィールドの大きさに合わせてフォームサイズを調整
this.ClientSize = new System.Drawing.Size(20 + 55 * CConstants.WIDTH, 20 + 55 * CConstants.HEIGHT);
// フィールドの大きさに合わせてパネルサイズを調整
this.P_Field.Size = new System.Drawing.Size(55 * CConstants.WIDTH, 55 * CConstants.HEIGHT);
//ResumeLayout()が呼び出されるまで、複数のLayoutイベントが発生しないようにする
SuspendLayout();
//PictureBoxをフィールドのマスの分だけ生成する
for (int i = 0; i < CConstants.WIDTH; i++)
{
for (int j = 0; j < CConstants.HEIGHT; j++)
{
// インスタンスの生成
Pbox[i, j] = new PictureBox();
P_Field.Controls.Add(Pbox[i, j]);
Pbox[i, j].Name = "Pbox[" + i.ToString() + "][" + j.ToString() + "]";
Pbox[i, j].Location = new Point((55 * i), (55 * j));
Pbox[i, j].Size = new Size(50, 50);
}
}
ResumeLayout();
// 自分で定期的に初期化したい変数や処理をinit()関数にまとめて呼び出す。
init();
}
private void init()
{
m_inKeyDir = CConstants.Direction.left;
m_snake.initSnake();
m_food.makeFood(m_snake.getPos(), m_snake.getLength());
// 壁は最初に1度だけ描画して、以降そのままにしておく
for (int i = 0; i < CConstants.HEIGHT; i++)
{
for (int j = 0; j < CConstants.WIDTH; j++)
{
if (i == 0 || i == (CConstants.HEIGHT - 1) || j == 0 || j == (CConstants.WIDTH - 1)) m_Pbox[i, j].ImageLocation = @"wall.png";
}
}
// タイマーを有効にする
this.T_CycleTimer.Enabled = true;
}
// メンバ変数
private PictureBox[,] m_Pbox = new PictureBox[CConstants.WIDTH, CConstants.HEIGHT];
private CSnake m_snake = new CSnake();
// 入力されたキーを記憶しておき、タイマーが発火時に進める
private CConstants.Direction m_inKeyDir;
private CFood m_food = new CFood();
キーボード入力
ユーザーが入力したキーボードをプログラムが検知する処理です。
デザイナーの画面でフォームに対してイベントプロパティから「KeyDown」の欄に「FSnakeGame_KeyDown」と入力します。
すると、ソースコードに自動で「FSnakeGame_KeyDown」の関数が作成されます。
そこへ以下のように実装します。
private void FSnakeGame_keyDown(object sender, KeyEventArgs e)
{
switch (e.KeyCode)
{
// 入力されたキーに対応する定数を格納する
case Keys.Left:
m_inKeyDir = CConstants.Direction.left;
break;
case Keys.Up:
m_inKeyDir = CConstants.Direction.up;
break;
case Keys.Right:
m_inKeyDir = CConstants.Direction.right;
break;
case Keys.Down:
m_inKeyDir = CConstants.Direction.down;
break;
default:
// 何もしない
break;
}
キーが入力されると、引数の「e」に入力されたキーが格納された状態でこの関数が呼ばれます。
入力されたキーは「e.KeyCode」で参照することができるので、Switch文でカーソルキーの何が入力されたかを判断します。
入力されたキーはメンバー変数の「m_inKeyDir」に保存しておき、のちほどスネークを動かすときに使います。
一定時間が経過したときに最後に入力されたキーを元にスネークを動かしたいので、最後に入力したキーで「m_inKeyDir」を上書きします。
タイマー
デザイナー画面で「Timer」の部品を追加します。
デザイナー画面の左下にタイマー部品が追加されるので、プロパティとイベントを下表の通りに設定します。
●timer1
プロパティ名 | 設定値 |
(Name) | T_CycleTimer |
Enable | True |
GenerateMember | True |
Interval | 500 |
Modifiers | Private |
イベント名 | 設定値 |
Tick | T_CycleTimer_tick |
ソースコードに、タイマーが発火したときに呼ばれる関数が自動で追加されます。
ここに記載した実装が500[ms]の間隔で実行されます。
ここへ以下の実装を追加します。
private void T_CycleTimer_tick(object sender, EventArgs e)
{
// ヘビを最後に入力されたキーの方向へ進める
m_snake.move(m_inKeyDir);
// ヘビが餌や壁に当たったか判定する
judge();
// フィールドを描画する
viewField();
}
これによって、以下の処理を500[ms]ごとに繰り返します。
- 最後に入力された方向キーを元にスネークを動かす。
- 動いた先で状況を判定する。
(餌を食べたか、壁や体に当たったか、クリアか) - 現在の状況をフィールドに表示する。
判定処理
スネークが動いたときに、動いた先の状況を判定する処理です。
private void judge()
{
// 頭が壁に当たったらアウト
// リザルトフォームを表示する
if (m_snake.getBodyX(0) == 0 || m_snake.getBodyX(0) == (CConstants.WIDTH - 1) || m_snake.getBodyY(0) == 0 || m_snake.getBodyY(0) == (CConstants.HEIGHT - 1))
{
// タイマーをオフにして、リザルト画面を表示
this.T_CycleTimer.Enabled = false;
// リザルトフォームに文字列を渡す
FResult f_gameOver = new FResult("Game Over...");
f_gameOver.Show(this);
Hide();
}
// 頭が体に当たったらアウト
// リザルトフォームを表示する
if (m_snake.getLength() < 5); // 処理を飛ばす
else if (m_snake.getLength() >= 5)
{
for (int i = 4; i < m_snake.getLength(); i++)
{
if (m_snake.getBodyX(0) == m_snake.getBodyX(i) && m_snake.getBodyY(0) == m_snake.getBodyY(i))
{
this.T_CycleTimer.Enabled = false;
FResult f_gameOver = new FResult("Game Over...");
f_gameOver.Show(this);
Hide();
}
}
}
// 餌を食べた
if (m_snake.getBodyX(0) == m_food.getBodyX() && m_snake.getBodyY(0) == m_food.getBodyY())
{
// ヘビが最大長になったらクリア!
// リザルトフォームを表示する
if (m_snake.getLength() == (CConstants.SNAKE_MAX_LENGTH - 1))
{
this.T_CycleTimer.Enabled = false;
FResult f_gameOver = new FResult("Clear!!");
f_gameOver.Show(this);
Hide();
}
// 餌を移動する
m_food.makeFood(m_snake.getPos(), m_snake.getLength());
// スネークを延ばす
m_snake.growSnake();
}
}
画面表示処理
画面を表示する処理を実装します。
タイマーで500[ms]ごとにスネークや餌の位置が変わるので、その度に画面を最新の状態に表示する関数です。
private void viewField()
{
// フィールドの壁以外を初期化
for (int i = 0; i < CConstants.WIDTH; i++)
{
for (int j = 0; j < CConstants.HEIGHT; j++)
{
// 壁であれば初期化対象から除外する
if (i == 0 || i == (CConstants.HEIGHT - 1) || j == 0 || j == (CConstants.WIDTH - 1)) continue;
// 壁でなければ初期化する
m_Pbox[i, j].Image = null;
}
}
// スネークをフィールドに反映
for (int i = 0; i < m_snake.getLength(); i++)
{
if(i == 0) m_Pbox[m_snake.getBodyX(i), ((CConstants.HEIGHT - 1) - m_snake.getBodyY(i))].ImageLocation = @"SnakeHead.png";
else m_Pbox[m_snake.getBodyX(i), ((CConstants.HEIGHT - 1) - m_snake.getBodyY(i))].ImageLocation = @"SnakeBody.png";
}
// 餌をフィールドに反映
m_Pbox[m_food.getBodyX(), ((CConstants.HEIGHT - 1) - m_food.getBodyY())].ImageLocation = @"food.png";
}
CSnake
コンストラクタとメンバ変数
コンストラクタの中で独自の初期化関数であるinitSnake()を呼び出し、initSnake()の中でメンバ変数の初期化を行います。
スネークはリザルト画面で「リプレイ」ボタンを押したときなど、何度も初期化したいケースがあるので、このような作りにしました。
コンストラクタはクラスのインスタンスを作ったときに一度だけ呼ばれますが、initSnake()は外部から何度でも呼び出すことができます。
// コンストラクタ
public CSnake()
{
initSnake();
}
// ヘビを初期化する
public void initSnake()
{
m_snakeLen = 2;
// スネークの初期化
Array.Clear(m_bodyPos, 0, CConstants.SNAKE_MAX_LENGTH);
m_bodyPos[0].x = CConstants.STARTX;
m_bodyPos[0].y = CConstants.STARTY;
m_bodyPos[1].x = CConstants.STARTX + 1;
m_bodyPos[1].y = CConstants.STARTY;
}
// メンバ変数
private int m_snakeLen;
private Position[] m_bodyPos = new Position[CConstants.SNAKE_MAX_LENGTH];
スネークを動かす処理
move()は入力されたキーの方向を引数に渡せば、その方向へスネークを移動してくれる関数です。
ただし、首がある方向へキーが入力されれば無効として、入力と逆(進行方向)へスネークを進めます。
しっぽの方から順に配列の座標をシフトしていき、最後に頭の座標を書き換えます。
public void move(CConstants.Direction direction)
{
switch (direction)
{
case CConstants.Direction.left:
//首がある方向のキー入力だった場合は受け付けない
if (m_bodyPos[0].x == (m_bodyPos[1].x + 1))
{
shiftSnake(); // 首から下の座標を頭方向にシフトする
m_bodyPos[0].x += 1; // 頭を移動方向へシフトする
break;
}
shiftSnake();
m_bodyPos[0].x -= 1;
break;
case CConstants.Direction.up:
if (m_bodyPos[0].y == (m_bodyPos[1].y - 1))
{
shiftSnake();
m_bodyPos[0].y -= 1;
break;
}
shiftSnake();
m_bodyPos[0].y += 1;
break;
case CConstants.Direction.right:
if (m_bodyPos[0].x == (m_bodyPos[1].x - 1))
{
shiftSnake();
m_bodyPos[0].x -= 1;
break;
}
shiftSnake();
m_bodyPos[0].x += 1;
break;
case CConstants.Direction.down:
if (m_bodyPos[0].y == (m_bodyPos[1].y + 1))
{
shiftSnake();
m_bodyPos[0].y += 1;
break;
}
shiftSnake();
m_bodyPos[0].y -= 1;
break;
default:
// 何もしない
break;
}
// 首から下の座標を頭方向にシフトする
private void shiftSnake()
{
for (int i = 1; i < m_snakeLen; i++)
{
m_bodyPos[m_snakeLen - i].x = m_bodyPos[m_snakeLen - (i + 1)].x;
m_bodyPos[m_snakeLen - i].y = m_bodyPos[m_snakeLen - (i + 1)].y;
}
}
スネークを伸ばす処理
スネークが餌を食べたら、長さを1増やし、スネーク配列のしっぽを1つ伸ばし、座標は元のしっぽと同じものを代入します。
(しっぽが2つ重なった状態になります)
public void growSnake()
{
m_snakeLen += 1;
m_bodyPos[m_snakeLen - 1].x = m_bodyPos[m_snakeLen - 2].x;
m_bodyPos[m_snakeLen - 1].y = m_bodyPos[m_snakeLen - 2].y;
}
スネークの座標などを取得する処理
FSnakeGameクラスから、CSnakeクラスの座標や長さなどを取得する関数を用意します。
// 体の位置を格納した配列を返す
public Position[] getPos()
{
return m_bodyPos;
}
// 体の長さを返す
public int getLength()
{
return m_snakeLen;
}
// 指定されたインデックスの体のx座標を返す
public int getBodyX(int index)
{
return m_bodyPos[index].x;
}
// 指定されたインデックスの体のy座標を返す
public int getBodyY(int index)
{
return m_bodyPos[index].y;
}
CFood
コンストラクタとメンバ変数
餌は座標の情報のみあれば作ることができるので、メンバ変数は座標のみです。
// コンストラクタ
public CFood()
{
m_bodyPos.x = 0;
m_bodyPos.y = 0;
}
// メンバ変数
private Position m_bodyPos = new Position();
餌を作る処理
餌の座標は乱数で決定します。
ただし、スネークと重なっていた場合は別の場所に餌を移さないといけません。
このときにもう一度乱数を使うと、スネークが長くなって空きマスが少なくなった際に、運よく空きマスに餌が作られる確率が小さく処理時間がかかってしまいます。
そこで、スネークと重なった場合は横にずらす仕組みにしています。
public void makeFood(Position[] excludePos, int indexLen)
{
// 餌の座標を初期化
m_bodyPos.x = 0;
m_bodyPos.y = 0;
while (true)
{
// 餌の座標を1~8でランダムに生成する
Random r = new Random();
m_bodyPos.x = 1 + r.Next(CConstants.WIDTH - 3);
m_bodyPos.y = 1 + r.Next(CConstants.HEIGHT - 3);
// ヘビの頭からしっぽまでで重なっていないかを確認する
for (int i = 0; i < indexLen; i++)
{
// ヘビのどこかと重なっていた場合
if (excludePos[i].x == m_bodyPos.x && excludePos[i].y == m_bodyPos.y)
{
// 重なった場所から1マスずらす
// 右下角だったら左上角に移す
if (m_bodyPos.x == (CConstants.WIDTH - 2) && m_bodyPos.y == (CConstants.HEIGHT - 2))
{
m_bodyPos.x = 1;
m_bodyPos.y = 1;
}
// 一番右の列だったら一番左の列に移す
else if (m_bodyPos.x == CConstants.WIDTH - 2)
{
m_bodyPos.x = 1;
m_bodyPos.y += 1;
}
// その他の場合は右に一つずらす
else
{
m_bodyPos.x += 1;
}
// もう一度ヘビの頭から順に重なっていないか確認する
i = -1;
}
}
// 餌がヘビの体のどことも重なっていなければループを抜ける
break;
}
}
餌の座標を取得する処理
FSnakeGameクラスから餌の座標を取得する関数を用意します。
// 餌のx座標を返す
public int getBodyX()
{
return m_bodyPos.x;
}
// 餌のy座標を返す
public int getBodyY()
{
return m_bodyPos.y;
}
Position
x, y座標の構造体を宣言しておきます。
スネークと餌の座標を管理するために使います。
struct Position
{
public int x, y;
}
以上で実装はすべて完了です。
まとめ
これで簡単にそれらしいゲームを作成することができました。
C#で作り直したことで、C言語バージョンではできなかった画像の表示などができ、より高度なものになっています。
実際にC#を使用して作業してみることで、C#への理解も深められるはずです。
みなさんもぜひ一度作ってみてください。
コメント