WinFormsブロック崩しで学ぶクラス分担とチーム開発

広告

チーム開発では、全員が同じファイルを同時に編集すると、作業がぶつかりやすくなります。

特に WinForms アプリでは、ゲームループを持つ Form1.cs に処理をすべて書いてしまうと、次のような問題が起こりやすくなります。

  • どこに何の処理があるのか分かりにくい
  • 複数人で同じファイルを編集してコンフリクトしやすい
  • 後から修正するときに影響範囲が広くなる
  • 位置の計算・衝突判定・描画・スコア管理が混ざって読みにくくなる

そこで今回は、ブロック崩しゲームを題材にして、処理をいくつかのクラスに分けて実装します。

目的は、単にブロック崩しを完成させることではありません。「1つのアプリを、役割ごとに分けて作る」という考え方を体験することが目的です。

今回作るアプリの概要

今回作るのは、パドルでボールを跳ね返してブロックを壊すブロック崩しゲームです。

アプリの基本的な流れは次の通りです。

  1. Timerが一定間隔でパドルとボールの位置を更新する
  2. 壁・パドル・ブロックとの衝突を判定する
  3. ブロックに当たったらブロックを消してスコアを加算する
  4. ボールが画面下端に出たらゲームオーバーにする
  5. すべてのブロックを壊したらクリアにする
  6. 画面を再描画する

クイズアプリは「呼ばれたら答えを返すだけ」の独立した部品に分けやすい構成でしたが、ブロック崩しはゲームループです。Timerが毎フレーム、ボールの位置・パドルの位置・ブロックの状態・スコアを同時に更新するため、役割分担には一工夫が必要になります。

クラス構成

今回のアプリでは、次のようにクラスを分けます。

クラス名役割
Ballボールの位置・速度を表す
Paddleパドルの位置・移動を表す
Blockブロック1個の位置・破壊状態を表す
CollisionChecker壁・パドル・ブロックとの衝突を判定する
ScoreManagerスコア・残りブロック数・クリア判定を管理する
GameView画面描画をまとめる
Form1全体の流れをまとめる(Presenter役)

ポイントは、1つのクラスに何でも書かないことです。位置の計算、衝突判定、スコア計算、描画処理をすべて Form1.cs に書くこともできますが、それでは Form1.cs が大きくなりすぎます。

Ball・Paddle・Blockクラス:状態を表すモデル

まず、位置や状態だけを持つデータクラスを作ります。ロジックは持たせません。

namespace TeamBlockBreaker
{
    public class Ball
    {
        public float X { get; set; }
        public float Y { get; set; }
        public float VelocityX { get; set; }
        public float VelocityY { get; set; }
        public int Size { get; set; } = 12;

        public void Move()
        {
            X += VelocityX;
            Y += VelocityY;
        }
    }
}
namespace TeamBlockBreaker
{
    public class Paddle
    {
        public float X { get; set; }
        public float Y { get; set; }
        public int Width { get; set; } = 80;
        public int Height { get; set; } = 12;
        public int Speed { get; set; } = 8;

        public void MoveLeft() => X -= Speed;
        public void MoveRight() => X += Speed;
    }
}
namespace TeamBlockBreaker
{
    public class Block
    {
        public float X { get; set; }
        public float Y { get; set; }
        public int Width { get; set; } = 50;
        public int Height { get; set; } = 20;
        public bool IsDestroyed { get; set; } = false;
    }
}

コードのポイント

  • ロジックを持たせない:Ball・Paddle・Blockは位置や状態を持つだけで、動かし方や判定のロジックは持ちません。PaddleのMoveLeft()MoveRight()のような単純な移動だけは例外的にクラス自身に持たせています。
  • 全員の土台になるクラス:CollisionCheckerやGameViewは、Ball・Paddle・Blockのプロパティ名が決まっていないと書き始められません。チーム開発では、この3クラスを最初にmainへマージすることが最優先になります。
  • 初期値を用意する:SizeWidthHeightに初期値を入れておくことで、newしただけで最低限の大きさを持ったオブジェクトになります。

CollisionCheckerクラス:衝突判定

CollisionChecker クラスは、壁・天井・パドル・ブロックとの衝突判定をすべて担当します。

using System.Collections.Generic;
using System.Drawing;

namespace TeamBlockBreaker
{
    public class CollisionChecker
    {
        public void CheckWallCollision(Ball ball, int formWidth)
        {
            if (ball.X <= 0 || ball.X + ball.Size >= formWidth)
            {
                ball.VelocityX = -ball.VelocityX;
            }
        }

        public void CheckCeilingCollision(Ball ball)
        {
            if (ball.Y <= 0)
            {
                ball.VelocityY = -ball.VelocityY;
            }
        }

        public void CheckPaddleCollision(Ball ball, Paddle paddle)
        {
            var ballRect = new RectangleF(ball.X, ball.Y, ball.Size, ball.Size);
            var paddleRect = new RectangleF(paddle.X, paddle.Y, paddle.Width, paddle.Height);

            if (ballRect.IntersectsWith(paddleRect) && ball.VelocityY > 0)
            {
                ball.VelocityY = -ball.VelocityY;
            }
        }

        public Block? CheckBlockCollision(Ball ball, List<Block> blocks)
        {
            var ballRect = new RectangleF(ball.X, ball.Y, ball.Size, ball.Size);

            foreach (var block in blocks)
            {
                if (block.IsDestroyed) continue;

                var blockRect = new RectangleF(block.X, block.Y, block.Width, block.Height);
                if (ballRect.IntersectsWith(blockRect))
                {
                    block.IsDestroyed = true;
                    ball.VelocityY = -ball.VelocityY;
                    return block;
                }
            }

            return null;
        }
    }
}

コードのポイント

  • 判定と反射だけを行う:ぶつかったら速度を反転させるだけで、スコア加算やゲームオーバー処理はしません。それはScoreManagerとForm1の責務です。
  • RectangleF.IntersectsWith:ボールとパドル・ブロックをそれぞれ矩形とみなし、重なっているかどうかで衝突を判定しています。
  • 1回のヒットで抜ける:CheckBlockCollisionは最初に見つかった1個のブロックとだけ衝突処理をしてreturnします。1フレームで複数ブロックを同時に壊さないようにするためです。

ScoreManagerクラス:スコア管理

ScoreManager クラスは、得点・残りブロック数を記録し、クリア/ゲームオーバーを判定します。

namespace TeamBlockBreaker
{
    public class ScoreManager
    {
        public int Score { get; private set; }
        public int RemainingBlocks { get; private set; }
        public bool IsGameOver { get; private set; }
        public bool IsCleared { get; private set; }

        public void Initialize(int totalBlocks)
        {
            RemainingBlocks = totalBlocks;
            Score = 0;
            IsGameOver = false;
            IsCleared = false;
        }

        public void AddScore(int point = 100)
        {
            Score += point;
            RemainingBlocks--;
            if (RemainingBlocks <= 0) IsCleared = true;
        }

        public void SetGameOver()
        {
            IsGameOver = true;
        }

        public string GetStatusText()
        {
            return $"スコア: {Score} 残りブロック: {RemainingBlocks}";
        }
    }
}

コードのポイント

  • private set でカプセル化:ScoreRemainingBlocksは外から読めますが、書き換えられるのはAddScoreなどのメソッド経由だけです。外部から勝手にスコアを操作されないように守っています。
  • 状態遷移を1か所にまとめる:クリア判定(RemainingBlocks <= 0)もこのクラスの中で完結しています。「ゲームが終わったかどうか」を知りたいときは、常にScoreManagerを見ればよくなります。

GameViewクラス:描画

GameView クラスは、ボール・パドル・ブロック・スコア表示の描画だけをまとめます。

using System.Collections.Generic;
using System.Drawing;

namespace TeamBlockBreaker
{
    public class GameView
    {
        public void DrawBall(Graphics g, Ball ball)
        {
            g.FillEllipse(Brushes.OrangeRed, ball.X, ball.Y, ball.Size, ball.Size);
        }

        public void DrawPaddle(Graphics g, Paddle paddle)
        {
            g.FillRectangle(Brushes.SteelBlue, paddle.X, paddle.Y, paddle.Width, paddle.Height);
        }

        public void DrawBlocks(Graphics g, List<Block> blocks)
        {
            foreach (var block in blocks)
            {
                if (block.IsDestroyed) continue;
                g.FillRectangle(Brushes.MediumSeaGreen, block.X, block.Y, block.Width, block.Height);
                g.DrawRectangle(Pens.White, block.X, block.Y, block.Width, block.Height);
            }
        }

        public void DrawStatus(Graphics g, string text, Size clientSize)
        {
            var font = new Font("Meiryo", 12, FontStyle.Bold);
            var size = g.MeasureString(text, font);
            g.DrawString(text, font, Brushes.DimGray, (clientSize.Width - size.Width) / 2, 14);
        }

        public void DrawGameOver(Graphics g, Size clientSize)
        {
            DrawCenteredMessage(g, "GAME OVER", Brushes.Red, clientSize);
        }

        public void DrawClear(Graphics g, Size clientSize)
        {
            DrawCenteredMessage(g, "CLEAR!", Brushes.Blue, clientSize);
        }

        private void DrawCenteredMessage(Graphics g, string text, Brush brush, Size clientSize)
        {
            var font = new Font("Meiryo", 24, FontStyle.Bold);
            var size = g.MeasureString(text, font);
            g.DrawString(text, font, brush,
                (clientSize.Width - size.Width) / 2,
                (clientSize.Height - size.Height) / 2);
        }
    }
}

コードのポイント

  • 描画だけに専念する:位置の計算や判定はせず、渡された値をそのまま描くだけです。Formのpaintイベントから呼ばれるだけの役割です。
  • 文字幅を実測して中央揃え:Graphics.MeasureStringで実際の文字幅を測ってから中央に配置しています。固定の数値(例:clientSize.Width / 2 - 100)で決め打ちすると、フォントや環境によって文字が右端で切れることがあるため、実測することが安全です。

Form1クラス:全体の流れをまとめる

Form1 は、Timerで毎フレーム、パドル移動→ボール移動→各種衝突判定→スコア反映→再描画、の順に呼び出す統括役(Presenter)です。

using System.Collections.Generic;

namespace TeamBlockBreaker;

public partial class Form1 : Form
{
    private Ball ball = null!;
    private Paddle paddle = null!;
    private List<Block> blocks = null!;

    private readonly CollisionChecker collision = new();
    private readonly ScoreManager score = new();
    private readonly GameView view = new();

    private bool leftPressed;
    private bool rightPressed;

    public Form1()
    {
        InitializeComponent();
        DoubleBuffered = true;
        InitializeGame();
    }

    private void InitializeGame()
    {
        paddle = new Paddle { Width = 80, Height = 12 };
        paddle.X = (ClientSize.Width - paddle.Width) / 2f;
        paddle.Y = ClientSize.Height - 72;

        ball = new Ball { X = ClientSize.Width / 2f - 6, Y = paddle.Y - 30, VelocityX = 3, VelocityY = -3 };

        blocks = new List<Block>();
        // 中略:列数・行数・余白からブロックを敷き詰める

        score.Initialize(blocks.Count);
    }

    private void gameTimer_Tick(object sender, EventArgs e)
    {
        if (score.IsGameOver || score.IsCleared) return;

        if (leftPressed) paddle.MoveLeft();
        if (rightPressed) paddle.MoveRight();
        paddle.X = Math.Clamp(paddle.X, 0f, ClientSize.Width - paddle.Width);

        ball.Move();
        collision.CheckWallCollision(ball, ClientSize.Width);
        collision.CheckCeilingCollision(ball);
        collision.CheckPaddleCollision(ball, paddle);

        var hitBlock = collision.CheckBlockCollision(ball, blocks);
        if (hitBlock != null) score.AddScore();

        if (ball.Y > ClientSize.Height) score.SetGameOver();

        Invalidate();
    }

    private void Form1_Paint(object sender, PaintEventArgs e)
    {
        var g = e.Graphics;
        view.DrawBall(g, ball);
        view.DrawPaddle(g, paddle);
        view.DrawBlocks(g, blocks);
        view.DrawStatus(g, score.GetStatusText(), ClientSize);

        if (score.IsGameOver) view.DrawGameOver(g, ClientSize);
        if (score.IsCleared) view.DrawClear(g, ClientSize);
    }
}

コードのポイント

  • Form1は「司令塔」:判定・スコア計算・描画のロジックは一切持たず、CollisionChecker・ScoreManager・GameViewを組み合わせて呼び出すだけです。
  • Math.Clampでパドルを画面内に収める:パドルを動かした直後に、0からClientSize.Width - paddle.Widthの範囲へ収めています。この1行を忘れると、パドルが画面外に出続けてしまいます。
  • DoubleBuffered = true:再描画のちらつきを防止するおまじないです。コンストラクタの早い段階で設定します。

Tickごとの処理の流れ

Timerが一定間隔(30〜50ms)でTickするたびに、内部では次の流れになります。

  1. キー入力状態からパドルを移動する
  2. パドルを画面内にクランプする
  3. ボールを移動する
  4. CollisionCheckerで壁・天井・パドル・ブロックとの衝突を判定する
  5. ブロックに当たっていたらScoreManagerに加点を依頼する
  6. ボールが画面下端に出ていたらゲームオーバーにする
  7. Invalidate()で再描画を要求する
  8. Form1_PaintでGameViewに描画を依頼する

この流れを文章にすると長く見えますが、クラスを分けておくと1つ1つの処理は数行で済みます。判定はCollisionChecker、加点はScoreManager、描画はGameViewというように、担当がはっきりしています。

なぜクラスを分けるのか

クラスを分ける理由は、コードをきれいに見せるためだけではありません。チーム開発では、次のようなメリットがあります。

1. 担当範囲が分かりやすい

担当Aは Ball・Paddle・Block、担当Bは CollisionChecker、担当Cは ScoreManager、担当Dは GameView というように、作業範囲を分けることができます。

2. 同時作業しやすい

Ball・Paddle・Blockという「土台」さえ最初に固めてしまえば、CollisionChecker・ScoreManager・GameViewは互いのファイルに触れずに並行して作業できます。

3. 修正箇所を見つけやすい

ブロックが壊れないなら CollisionChecker、スコアがおかしいなら ScoreManager、見た目が崩れるなら GameView、というように調べる場所を絞り込めます。

4. 役割ごとに理解できる

ゲーム全体を一度に理解するのは大変ですが、クラスごとに役割が分かれていれば、まずは自分の担当部分から理解できます。

初学者が注意したいポイント

ここで紹介する3つは、実際にこの構成でアプリを組んだときに遭遇した、ありがちな不具合です。

パドルのクランプ忘れ

PaddleクラスのMoveLeft()MoveRight()自体には範囲チェックがありません。Form1側で範囲を制限し忘れると、キーを押し続けるだけでパドルが画面外に出ていってしまいます。Math.Clampで範囲内に収める処理を、必ずどこかに書いておきましょう。

Windowsの表示スケーリング(DPI設定)

ウィンドウサイズをカスタマイズして、固定のピクセル座標で描画する場合、Windowsの表示スケーリング設定によって見た目が環境ごとに変わることがあります。.csprojApplicationHighDpiModeDpiUnawareとして追加しておくと、どの環境でも同じピクセルサイズで描画できます。

文字幅の決め打ちに注意

「GAME OVER」のような文字列を中央に表示したいとき、文字幅を目分量で決め打ちすると、ウィンドウが狭い場合や太字フォントの場合に右端が切れてしまいます。Graphics.MeasureStringで実測してから中央揃えの座標を計算する習慣をつけましょう。

実務との違い

実務では、各クラスを作ったあと、単体テストで動作を確認することがあります。しかし、学習用のチーム開発では、テストコードを追加すると、Gitの変更差分が分かりにくくなることがあります。

そのため、この教材では、各担当者が作成したクラスをリーダーが Form1 に接続し、実際にゲームを動かして確認する流れにしています。これは実務のやり方をすべて再現するためではなく、まずは次のことを学ぶためです。

  • 担当ごとにクラスを作る
  • 自分の作業範囲を意識する
  • 他の人のコードと統合する
  • 1つのアプリとして動かす

この課題で学べること

このブロック崩し制作では、C#の文法だけでなく、開発の考え方も学べます。特に重要なのは、次の3つです。

1. 「状態」と「ロジック」を分ける

Ball・Paddle・Blockは状態だけを持ち、CollisionCheckerやScoreManagerがロジックを持ちます。この分離が、ゲームのようにすべてが同時に動くアプリでも役割分担をしやすくしています。

2. Form1にすべてを書かない

ゲームループ、判定処理、スコア処理、描画処理を分けることで、Form1.csが肥大化するのを防げます。

3. 「土台」を最初に決める大切さ

ゲームは全クラスが同じ状態を毎フレーム共有するため、クイズアプリより並行作業が難しくなります。だからこそ、最初にBall・Paddle・Blockの形を決めて共有することが最優先になります。

発展課題

余裕がある場合は、次のような改良にも挑戦できます。

  • パドルに当たった位置によってボールの反射角を変える
  • ブロックごとに耐久値や得点を変える
  • 残機(ライフ)を追加する
  • レベルアップでブロック配置を変える
  • 一時停止・リスタート機能を追加する
  • 効果音を追加する

ただし、最初から発展課題を入れすぎると、チーム開発の目的がぼやけます。まずは基本形を完成させることを優先しましょう。

まとめ

今回のブロック崩しでは、1つのアプリを複数のクラスに分けて実装しました。重要なのは、次の考え方です。

Ball / Paddle / Block  → 状態(モデル)
CollisionChecker       → 衝突判定
ScoreManager           → スコア管理
GameView               → 画面描画
Form1                  → 全体の流れ(Presenter)

このように分けることで、コードの見通しがよくなり、チームでも作業しやすくなります。ゲームのように全クラスが同じ状態を共有するアプリだからこそ、最初に土台(モデル)の形を固め、責務を分けて実装するという考え方が生きてきます。

このブロック崩しをGitでのチーム開発の流れの中で使っている例として、4人チームでブロック崩しを作る(Gitチーム開発×MVPパターン発展)もご参考にしてください。

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

広告

C#,チーム開発

Posted by hidepon