パンどろぼう道場:C#とWinFormsを学ぶ5段階チュートリアル

広告

「パンどろぼうを捕まえろ!」という自作のWinFormsゲームを教材に、C#の基礎とWinFormsの基本操作を学び終えた生徒向けに、1個の的を動かすだけの最小コードから、ライフ・LINQ・難易度カーブ・ウェーブ制の状態管理まで、5段階で少しずつ難易度を上げていく実践チュートリアルを作りました。各ステージは実際に動くゲームとして完成しており、Gitのタグ(stage1〜stage5)を切り替えるだけで前後の差分を見比べられます。

対象:C#の基礎とWinFormsの基本操作を学び終えた人 教材tutorial ブランチ / タグ stage1〜stage5 所要:1段階あたり30〜60分

Stage 1:単一ターゲットを動かして捕まえる

Timerゲームループベクトル計算クリックイベント

ゴール:タイマーで毎フレーム座標を更新し、クリックで捕獲する「最小のプレイアブル」を作る。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

List<T>LINQ Where / Allゲームオーバー設計

ゴール:パンを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:難易度カーブを追加する

計算プロパティ数式でのバランス調整Math.Min によるクランプ

ゴール:固定速度の定数を、スコアに応じて変化する計算プロパティに置き換える。「慣れると誰でも延々と遊べてしまう」問題への対処。

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:ウェーブ制で状態遷移を設計する(完成形)

状態遷移の設計責務の一本化Stage3の不一致を解消

ゴール:「盗まれた」「捕まえて守った」の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 にロジックを分けて管理している。

訪問数 4 回, 今日の訪問数 4回

広告

C#

Posted by hidepon