パンどろぼう道場:C#とWinFormsを学ぶ5段階チュートリアル
C#の基本文法や、Windows Formsのボタン・イベントの使い方を学んだら、今度はそれらを組み合わせて、小さなゲームを作ってみましょう。
今回の題材は「パンどろぼうを捕まえろ!」です。パンに近づいてくるどろぼうを、クリックして捕まえます。
最初は、どろぼうを1人動かすところから始めます。そこから、クラスに処理を分けたり、パンの数を増やしたりしながら、5段階でゲームを発展させていきます。
各段階では、コードを動かすだけでなく、「なぜこの書き方にするのか」も確認していきましょう。
今回の学習内容
- 対象:C#の基礎とWindows Formsの基本操作を学び終えた人
- 教材:
tutorialブランチで作成した各段階のコード(タグ:stage1〜stage5) - 所要時間の目安:1段階あたり30〜60分
- Stage 1:どろぼうを動かし、クリックして捕まえる
- Stage 2:ゲームの処理をGameWorldクラスに分ける
- Stage 3:パンを増やし、ライフとゲームオーバーを追加する
- Stage 4:スコアに応じて、どろぼうを速くする
- Stage 5:パンを守った結果を記録し、次のウェーブへ進む
本文では、各段階のポイントになるコードを抜粋しています。教材のプロジェクトで該当するタグを開き、全体のコードと見比べながら進めてください。
タグを切り替える前に:自分で変更したコードは保存し、コミットするか一時退避してから進めましょう。練習した変更を残す場合は、作業用のブランチを作成します。
Stage 1:どろぼうを動かして捕まえる
まずは、パンに近づいてくるどろぼうを、クリックして捕まえるゲームから始めます。この段階では、処理をForm1.csに書き、動きの仕組みを確認します。
git checkout stage1
どろぼうを動かすには、一定の間隔で位置を少しずつ変えます。ここでは、TimerのTickイベントが発生するたびに、パンへ向かう方向を計算し、座標を更新します。
// 1フレーム分、パンどろぼうをパンへ向かって移動させる
private void GameTimer_Tick(object? sender, EventArgs e)
{
var dx = breadPosition.X - thiefPosition.X;
var dy = breadPosition.Y - thiefPosition.Y;
var distance = MathF.Sqrt(dx * dx + dy * dy);
if (distance < ThiefSpeed + CatchRadius)
{
// パンにたどり着かれてしまった。今回は仕切り直すだけ
SpawnThief();
}
else
{
thiefPosition = new PointF(
thiefPosition.X + ThiefSpeed * dx / distance,
thiefPosition.Y + ThiefSpeed * dy / distance);
}
thiefBox.Location = ToBoxLocation(thiefPosition, thiefBox);
}
コードの確認ポイント
dxとdy:どろぼうからパンまでの、横方向と縦方向の差です。distance:どろぼうからパンまでの距離です。dx / distanceとdy / distance:移動する方向を、長さが1になるようにそろえています。これを「正規化」と呼びます。ThiefSpeed:1回の更新で進む量です。thiefBox.Location:計算した座標を、画面上の表示に反映します。
パンに十分近づいた場合は、SpawnThief()で仕切り直します。最初は、「座標を計算する」「画面に反映する」という流れを追ってみましょう。
やってみよう
ThiefSpeedの値を変え、動きの速さを確認してみましょう。CatchRadiusを大きくすると、パンに到達したと判断する位置はどう変わるでしょうか。- 現在の正の速度・半径の設定で、
distanceが0になったとき、どちらの分岐に進むか確認しましょう。割り算の処理は実行されるでしょうか。
Stage 2:ゲームの処理をGameWorldクラスに分ける
ゲームが動くようになったら、次はコードの置き場所を整理します。遊び方や動作を保ちながら、ゲームの状態と更新処理をGameWorldクラスに移します。
git checkout stage2
Form1にすべての処理を書き続けると、画面の操作とゲームのルールが混ざり、変更する場所を探しにくくなります。そこで、それぞれが担当する仕事を分けます。
Form1:クリックやタイマーのイベントを受け取り、ゲームの処理を呼び出して、結果を画面に表示します。GameWorld:パンやどろぼうの座標、スコアなどを管理し、ゲームを進めます。
GameWorldの構成を見てみましょう。以下では、各メソッドの中身を省略しています。
public class GameWorld
{
public const float ThiefSpeed = 2.5f;
public const int CatchRadius = 20;
public PointF BreadPosition { get; private set; }
public PointF ThiefPosition { get; private set; }
public int Score { get; private set; }
public void StartNewGame(int playAreaWidth, int playAreaHeight) { /* ... */ }
public void Update(int playAreaWidth, int playAreaHeight) { /* ... */ }
public void CatchThief(int playAreaWidth, int playAreaHeight) { /* ... */ }
}
コードの確認ポイント
これらのプロパティはpublicで公開され、get;があるため、Form1から座標やスコアを読み取れます。一方、private setが付いているため、値の書き換えはGameWorldの中に限定されます。
たとえば、Form1からスコアを直接書き換えるのではなく、CatchThief()を呼び出し、GameWorldのルールに従って更新します。このように、データの操作方法をクラスの中にまとめることは、カプセル化の一例です。
やってみよう
private set;をset;に変更すると、Form1から値を代入できるようになるか確認しましょう。確認後は元に戻します。- GameWorldをコンソールアプリに取り出し、必要な型や参照をそろえて、
StartNewGame()とUpdate()を呼び出してみましょう。画面を表示しなくても座標が変わるか確認します。
Stage 3:パンを増やし、ライフ制を追加する
次は、パンを5個に増やします。どろぼうは、その中からランダムに1個を狙います。また、ライフを3に設定し、3回盗まれたらゲームオーバーになるようにします。
git checkout stage3
複数のパンを扱うために、List<T>を使います。狙うパンを選ぶときは、すでに盗まれたパンを候補から外す必要があります。ここで使うのが、LINQのWhereです。
private void SpawnThief(int playAreaWidth, int playAreaHeight)
{
// LINQのWhereで「まだ盗まれていないパン」だけを候補にする
var remaining = breadSpots.Where(b => !b.IsStolen).ToList();
if (remaining.Count == 0) { return; }
int maxX = Math.Max(41, playAreaWidth - 40);
int spawnY = Math.Max(40, playAreaHeight - 40);
ThiefPosition = new PointF(random.Next(40, maxX), spawnY);
var target = remaining[random.Next(remaining.Count)];
TargetBreadIndex = breadSpots.IndexOf(target);
}
コードの確認ポイント
Where(b => !b.IsStolen)は、「まだ盗まれていないパン」を取り出す処理です。ToList()で候補をリストにまとめ、その中からランダムに1個を選びます。候補が0個なら、その先の処理には進みません。
続いて、パンを盗まれたときの処理を確認します。
private void LoseLife()
{
Lives--;
// 練習問題: Livesの初期値(3)とパンの個数(5)を見比べると、
// breadSpots.All(IsStolen) の方の条件は実際に成立しうるだろうか?
if (Lives <= 0 || breadSpots.All(b => b.IsStolen))
{
IsGameOver = true;
}
}
Lives--でライフを1減らします。その後、「ライフが0以下になった」または「すべてのパンが盗まれた」場合に、ゲームオーバーにします。Allは、すべての要素が条件を満たすかを調べる処理です。
ここで考えてみましょう。ライフは3、パンは5個です。通常の進行では、パンがすべて盗まれる前にライフがなくなります。これは個数が違うこと自体が誤りなのではなく、現在の設定では「すべて盗まれた」という終了条件を使う場面がない、ということです。
やってみよう
- ライフ3・パン5個の場合、どの条件で先にゲームオーバーになるか、紙に書いて確かめましょう。
- ライフの初期値を6に変更してみましょう。今度は、どの条件でゲームオーバーになるでしょうか。
Stage 4:スコアに応じて、どろぼうを速くする
同じ速さのままだと、操作に慣れた人には簡単になっていきます。そこで、スコアが増えるほど、どろぼうの動きを速くしてみましょう。
git checkout stage4
固定値のThiefSpeedを、スコアから速度を求めるCurrentThiefSpeedに置き換えます。これは、値を読み取るときに計算するプロパティです。
private const int SpeedRampScoreStep = 30; // このスコア幅ごとに加速
private const float SpeedIncrement = 0.4f;
// スコアが伸びるほど速くなり、MaxThiefSpeedで頭打ちになる
public float CurrentThiefSpeed =>
Math.Min(BaseThiefSpeed + Score / SpeedRampScoreStep * SpeedIncrement, MaxThiefSpeed);
コードの確認ポイント
ScoreとSpeedRampScoreStepが整数の場合、割り算の小数部分は切り捨てられます。そのため、この式ではスコアが30増えるごとに、速度が0.4ずつ段階的に上がります。
Math.Minは、2つの値のうち小さい方を返します。ここでは、計算結果がMaxThiefSpeedを超えないように、速度の上限を設けています。
やってみよう
SpeedRampScoreStepやSpeedIncrementを変更し、遊びやすい速さに調整してみましょう。Math.Minによる上限を外すと、高得点になったときにどうなるか確認しましょう。- 発展課題:「スコアが低いほど速い」動きにするには、式をどう変えればよいでしょうか。速度が負にならないようにする方法も考えます。
補足:この段階では、Stage 3のゲームオーバー条件を引き継いでいます。今回は、速度の変化に注目して確認しましょう。
Stage 5:パンを守り、次のウェーブへ進む
最後に、パンをひと通り守ると次のセットへ進む仕組みを追加します。この1セットを「ウェーブ」と呼びます。
git checkout stage5
Stage 3では、どろぼうを捕まえても、同じパンが再び狙われることがありました。Stage 5では、どろぼうを捕まえたとき、そのパンを「守った」と記録し、そのウェーブでは再び狙われないようにします。
- 未決着:まだ盗まれておらず、守り終えてもいないパン
- 盗まれた:
IsStolenがtrueのパン - 守った:
IsProtectedがtrueのパン
ライフが残っていて、5個すべてのパンが「盗まれた」または「守った」状態になったら、次のウェーブへ進みます。ライフとスコアは、そのまま引き継ぎます。
結果の記録と、次のウェーブに進むかどうかの判断を、ResolveBread()にまとめます。
// 標的だったパンの決着(盗まれた/守られた)をつけ、ウェーブが終わっていれば
// ライフを持ち越したまま次のウェーブへ進む
private void ResolveBread(int index, bool stolen, int playAreaWidth, int playAreaHeight)
{
var spot = breadSpots[index];
spot.IsStolen = stolen;
spot.IsProtected = !stolen;
if (stolen)
{
Lives--;
if (Lives <= 0)
{
IsGameOver = true;
return;
}
}
if (breadSpots.All(b => b.IsStolen || b.IsProtected))
{
Wave++;
SetupBreadSpots(playAreaWidth); // ライフ・スコアはそのまま、パンだけ再配置
}
SpawnThief(playAreaWidth, playAreaHeight);
}
コードの確認ポイント
まず、標的だったパンの結果を記録します。盗まれた場合はライフを減らし、0以下になったらゲームオーバーにして、returnで処理を終えます。
ライフが残っている場合は、Allですべてのパンの決着を確認します。すべて決着していれば、ウェーブを1増やし、パンを配置し直します。最後に、次のどろぼうを出現させます。
これで、パンの数は「1ウェーブで決着をつける数」、ライフは「ゲーム全体であと何回盗まれても続けられるか」を表す値になります。
やってみよう
- Stage 3とStage 5で、どろぼうを捕まえた後のパンの扱いがどう変わったか説明してみましょう。
- ライフが0以下になったときに
returnしないと、その後どの処理が実行されるか確認しましょう。 - 発展課題:ウェーブが進むごとにパンを1個ずつ増やしてみましょう。配置処理の個数と、画面に収まる間隔の両方を考えます。
- 発展課題:ウェーブをクリアしたときに、ボーナス得点を加算してみましょう。
各段階のコードを切り替える
教材のリポジトリでは、次のコマンドで各タグのコードを開けます。コマンドは、そのリポジトリのフォルダーで実行します。
git checkout stage1 # どろぼうを動かして捕まえる
git checkout stage2 # GameWorldへ分離
git checkout stage3 # 複数ターゲット・ライフ制
git checkout stage4 # 難易度カーブ
git checkout stage5 # ウェーブ制(完成形)
git checkout Main # 製品版へ戻る
タグを開くと、ブランチから離れて、その時点のコードを確認する状態になります(detached HEAD)。タグを見るだけならMainブランチの履歴は変わりませんが、切り替えによって作業フォルダーの内容は変わります。
練習した変更を残す場合は、タグを開いた後に作業用のブランチを作りましょう。たとえばStage 1なら、次のようにします。
git switch -c practice-stage1
ブランチ名は、まだ使っていない名前にします。変更をコミットしてから別のタグへ切り替えると、後から練習内容を見返せます。
フォームデザイナーで確認する
各段階では、コントロールの定義をForm1.Designer.csに、イベントに応じて動く処理をForm1.csに分けています。Visual Studioのフォームデザイナーとコードを見比べながら、画面と処理のつながりを確認しましょう。
教材版では、MainブランチにあるGamePanel_Resizeを省略しています。ウィンドウのサイズ変更を確認するときは、Anchorによるコントロールの配置と、ゲーム内の座標計算を分けて見てみましょう。
まとめ
今回は、1人のどろぼうを動かすところから始め、クラスの分割、複数のパンの管理、速度の調整、ウェーブの進行へと発展させました。
各段階を見比べると、機能が増えたときに、どのデータや処理が必要になるのかを確認できます。まずは1つ値を変えて動かし、「どこを変えたから、どう動いたのか」を自分の言葉で説明してみましょう。







ディスカッション
コメント一覧
まだ、コメントがありません