パンどろぼう道場:C#とWinFormsを学ぶ5段階チュートリアル
「パンどろぼうを捕まえろ!」という自作のWinFormsゲームを教材に、C#の基礎とWinFormsの基本操作を学び終えた生徒向けに、1個の的を動かすだけの最小コードから、ライフ・LINQ・難易度カーブ・ウェーブ制の状態管理まで、5段階で少しずつ難易度を上げていく実践チュートリアルを作りました。各ステージは実際に動くゲームとして完成しており、Gitのタグ(stage1〜stage5)を切り替えるだけで前後の差分を見比べられます。
Stage 1:単一ターゲットを動かして捕まえる
ゴール:タイマーで毎フレーム座標を更新し、クリックで捕獲する「最小のプレイアブル」を作る。GameWorldのような設計はまだ考えず、Form1.cs に直接書く。
git checkout stage1
パンどろぼう(thiefBox)は、固定位置に置かれたパン(breadBox)へ向かって、毎フレーム少しずつ近づく。移動量は「目的地までの方向ベクトルを正規化して、速さ分だけ進める」という、ゲームの動きでは定番の計算になっている。
// 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);
}
- ThiefSpeed の値を変えて、動きの速さがどう変わるか確認する
- CatchRadius を極端に大きくすると、当たり判定がどう変わるか試す
- もし distance が 0 になったら?(0除算が起きない理由をコードから説明できるか考える)
Stage 2:ロジックをGameWorldへ分離する
ゴール:遊び方や挙動はStage1と1ミリも変えずに、状態と更新処理だけをGameWorldクラスへ引っ越す。「なぜUIとロジックを分けるのか」を体で覚える回。
git checkout stage2
Form1(UI)は、ボタンやタイマーのイベントが起きたら GameWorld に「更新して」と頼み、結果の座標を PictureBox に反映するだけの薄い層になる。GameWorld 側は Windows Forms を一切 using していない。
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) { /* ... */ }
}
- private set を外して public set にすると何が起きるか(Form1側から自由に書き換えられてしまう)を確認する
- GameWorldだけを取り出して、コンソールアプリからStartNewGame→Updateを呼び、座標が動くことを確認してみる(UIなしで動く=分離できている証拠)
Stage 3:複数ターゲット・ライフ制・LINQ
ゴール:パンを5個に増やし、ランダムに1個を狙わせる。3回盗まれたらゲームオーバーになる「ライフ制」と、リスタート導線を追加する。
git checkout stage3
パンの配置は playAreaWidth(実際の画面幅)から計算するので、ウィンドウ幅を変えても棚からはみ出さない。パンどろぼうの標的は、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);
}
private void LoseLife()
{
Lives--;
// 練習問題: Livesの初期値(3)とパンの個数(5)を見比べると、
// breadSpots.All(IsStolen) の方の条件は実際に成立しうるだろうか?
if (Lives <= 0 || breadSpots.All(b => b.IsStolen))
{
IsGameOver = true;
}
}
- コード中のコメントの問いに答える:breadSpots.All(IsStolen) は今の設定(ライフ3・パン5個)で本当に成立するか、紙に条件を書き出して確かめる
- Livesの初期値を6に変えて実行し、挙動がどう変わるか観察する
Stage 4:難易度カーブを追加する
ゴール:固定速度の定数を、スコアに応じて変化する計算プロパティに置き換える。「慣れると誰でも延々と遊べてしまう」問題への対処。
git checkout stage4
Stage3までは ThiefSpeed が定数だったため、捕まえ続けられる人には永久に難易度が上がらなかった。ここではスコアに連動する CurrentThiefSpeed という「計算されるだけのプロパティ」に置き換える。
private const int SpeedRampScoreStep = 30; // このスコア幅ごとに加速
private const float SpeedIncrement = 0.4f;
// スコアが伸びるほど速くなり、MaxThiefSpeedで頭打ちになる
public float CurrentThiefSpeed =>
Math.Min(BaseThiefSpeed + Score / SpeedRampScoreStep * SpeedIncrement, MaxThiefSpeed);
- SpeedRampScoreStepやSpeedIncrementを変えて、自分なりの難易度カーブに調整する
- Math.Minを外してMaxThiefSpeedの上限を無くすと、高スコア帯で何が起きるか試す
- 逆に「スコアが低いほど速い」カーブにするには、式をどう変えればよいか考える
注意:このステージでもStage3のLoseLifeはそのまま残っている。Livesとパンの個数の不一致は、まだ解消されていない。
Stage 5:ウェーブ制で状態遷移を設計する(完成形)
ゴール:「盗まれた」「捕まえて守った」の2状態でパンの決着をつけ、5個すべて決着したらライフ・スコアを持ち越したまま次のウェーブへ進む。ライフと棚のパン数を、それぞれ独立した意味を持つ値として扱えるようにする。
git checkout stage5
Stage3では「捕まえる」=「標的をやり直すだけ」で、同じパンが何度も標的に戻ってくることがあった。ここでは捕まえた瞬間にそのパンを IsProtected として確定させ、二度と狙われないようにする。盗まれる/守られるの決着処理と、ウェーブ進行の判定を ResolveBread という1箇所に一本化した。
// 標的だったパンの決着(盗まれた/守られた)をつけ、ウェーブが終わっていれば
// ライフを持ち越したまま次のウェーブへ進む
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);
}
- Stage3で考えた「Livesとパンの個数の不一致」が、Stage5ではどういう仕組みで解消されたか説明する
- ResolveBreadを読んで、なぜstolenのときだけ早期returnするのか考える
- 発展課題:ウェーブが進むごとにパンの個数を1個ずつ増やす(SetupBreadSpotsのcountをWave連動にする)
- 発展課題:ウェーブクリア時にボーナス得点を加算する仕組みを自分で設計してみる
Gitコマンド早見表
すべて tutorial ブランチ上のタグ。行き来しても手元の Main ブランチ(製品版)には影響しない。
git checkout stage1 # 最小プレイアブル
git checkout stage2 # GameWorldへ分離
git checkout stage3 # 複数ターゲット・ライフ制
git checkout stage4 # 難易度カーブ
git checkout stage5 # ウェーブ制(完成形)
git checkout Main # 製品版へ戻る
Mainブランチとの違いについて:製品版(Main)には、ウィンドウのリサイズに合わせて棚の幅を再計算する GamePanel_Resize が残っているが、これは Anchor 設定だけで同じ結果になる冗長な処理のため、この教材では学習のノイズを減らす目的で省略している。ゲームとしての挙動に違いはない。
各ステージのコードは Visual Studio のフォームデザイナーで開けるよう、Form1.Designer.cs にコントロール定義を、Form1.cs にロジックを分けて管理している。







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