4人チームでブロック崩しを作る(Gitチーム開発 × MVPパターン発展)

2026年9月19日

広告

Gitチーム開発シリーズ:4人チームでクイズアプリを作る(Gitチーム開発発展)|4人チームでブロック崩しを作る(今ここ)

前提:以下の記事を読んでいるとスムーズです。

はじめに

クイズアプリのチーム開発では、担当ごとのクラス(QuestionLoader・AnswerChecker・ScoreManager・UiUpdater)が「呼ばれたら答えを返すだけ」の独立した部品だったので、比較的スムーズに分担できました。

一方、ブロック崩しはゲームループです。Timerが一定間隔(30〜50ms)ごとにボールの位置・パドルの位置・ブロックの状態・スコアを同時に更新します。クイズアプリのようにきれいには分かれないので、役割分担には一工夫が必要です。

本記事では、MVPパターンのModel・View・Presenterをそのままチームの役割分担に対応させ、4〜5人でブロック崩しを完成させる進め方を紹介します。

このチュートリアルを通じて、以下のスキルを習得できます:

  • WinForms でのゲームループの基本(Timer・Paint・KeyDown/KeyUp)
  • 密結合になりやすい処理を、責務ごとのクラスに分ける設計力
  • 「全員が同時に触る」状態をどう安全にチーム開発するかというGitの実践

Step 1. プロジェクト準備

リーダーが行う作業

  1. 新規 WinForms アプリを作成
    プロジェクト名:TeamBlockBreaker
    テンプレートは「Windows フォーム アプリ」を選びます(クイズアプリと同じ、.NET Framework版は選びません)。
  2. フォームに配置するUI
    描画はコードで行うので、配置するコントロールは Timer 1つだけです。ツールボックスの「コンポーネント」から Timer をフォームにドラッグし、名前を gameTimer、Interval を 16 にします(記事の本文では 30〜50ms でも動きます)。
    さらに、Form1 の KeyPreview を true に、DoubleBuffered(プロパティが出ない場合はコンストラクタで this.DoubleBuffered = true;)を、チカチカ防止のために設定しておきます。
  3. GitHub Desktopでリポジトリを作成し、初期Push。「Keep this code private」のチェックを外し、public で作成します。
  4. main ブランチを保護しておきます。GitHub Rulesetsでmainブランチを保護する を参考に、次の3つを設定します。
    Restrict deletions/Require a pull request before merging(Required approvals:1)/Block force pushes
  5. メンバー全員がCloneして開発スタート

Step 2. チームでの役割分担

クラス構成

  • モデル:Ball、Paddle、Block
  • 判定:CollisionChecker
  • スコア管理:ScoreManager
  • 画面描画:GameView
  • 統括制御:Form1(Presenter役)

※ MVP との対応について:この記事の構成では、Form1 が Presenter(ゲームループの司令塔)の役割を兼ねる簡略版です。記事5 では、この司令塔を GamePresenter として別クラスに切り出し、Form1 を View(入力と描画)に専念させています。ここではチームで分担しやすいよう、司令塔を Form1 に残しています。

ブランチ命名

ブランチ名担当・内容
feature/model-classesBall・Paddle・Block(担当A)
feature/collision-checker衝突判定(担当B)
feature/score-managerスコア・ゲームオーバー管理(担当C)
feature/game-view描画処理(担当D)
feature/presenter-integrationリーダー:Form1統合

役割別担当

役割担当内容難易度
リーダー(L)プロジェクト作成・統合Form1 でTimer制御と各クラスの接続、レビュー&統合高
担当AモデルBall・Paddle・Block クラスを作成、位置や状態だけを持つデータクラスを実装低
担当B衝突判定CollisionChecker.cs を作成、壁・パドル・ブロックとの衝突を判定高
担当Cスコア管理ScoreManager.cs を作成、得点・残りブロック数・クリア/ゲームオーバー判定中
担当D描画GameView.cs を作成、ボール・パドル・ブロック・スコアの描画を担当中

役割分担の目安

難易度の目安は、担当A(低)→担当C・D(中)→担当B・リーダー(高)です。クイズアプリと違い、担当Aのモデルクラスが全員の土台になるので、リーダーと担当Aは最初に取り掛かるのがおすすめです。

基本は5人チームです。

  • リーダー:プロジェクト作成・Timer制御・統合レビュー
  • 担当A〜D:それぞれの担当クラスを実装

4人チームでは、リーダーが担当A〜Dのいずれかを兼任します。3人チームでは、さらに1人が2つの担当を兼任します(例:担当C+担当D)。

同時作業を安全に進めるコツ

クイズアプリと大きく違うのはここです。QuestionLoaderやAnswerCheckerは他のクラスの形を知らなくても実装できましたが、ブロック崩しのCollisionCheckerやGameViewは、Ball・Paddle・Blockのプロパティ名が決まっていないと書き始められません。

  1. 最初にリーダー(または担当A)が、Ball・Paddle・Blockの3クラス(プロパティだけの空クラス)を先にmainへマージする
    クイズアプリでいう「questions.csvの列構成を最初に決める」のと同じ役割です。ここが決まるまで、担当B・C・Dは着手できません。
  2. モデルクラスが揃ったら、担当B・C・Dはそれぞれのブランチで並行して作業を始めます。
  3. 作業を始める前に、最新のmainを自分の作業ブランチへ取り込みます(手順はStep 4で確認)。
  4. 自分の担当ファイル以外は触らない。同じファイルを複数人で変更すると、競合(Conflict)が発生しやすくなります。

なお、リーダーが行うForm1.csの統合作業は、各担当のPull Requestがレビューされ、mainにMergeされたあとに行います。チーム全員の担当作業がmainに反映されたことを確認してから進めましょう。

Step 3. コード実装

Ball.cs / Paddle.cs / Block.cs(担当A)

  • 役割:位置・速度・状態だけを持つデータクラス。ロジックは持たせません。
  • ポイント:全員がこのクラスの形に合わせて実装するので、名前とプロパティを最初に決めて共有することが最重要です。
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;
    }
}

CollisionChecker.cs(担当B)

  • 役割:壁・天井・パドル・ブロックとの衝突判定をすべて担当する。
  • ポイント:判定結果に応じて速度を反転させるだけで、スコア加算やゲームオーバー処理はしません(担当Cの責務にする)。
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.cs(担当C)

  • 役割:得点・残りブロック数を記録し、クリア/ゲームオーバーを判定する。
  • ポイント:成績を管理する責務を一元化している(クイズアプリの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}";
        }
    }
}

GameView.cs(担当D)

  • 役割:ボール・パドル・ブロック・スコア表示の描画をまとめる。
  • ポイント:描画だけに専念し、位置の計算や判定はしません(Formのpaintイベントから呼ばれるだけ)。
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)
        {
            using 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)
        {
            using 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);
        }
    }
}

Form1.cs(リーダー統合)

  • 役割:全体の流れを制御する中核(Presenter)。
  • 内容:Timerで毎フレーム、パドル移動→ボール移動→各種衝突判定→スコア反映→再描画、の順に呼び出す。
  • ポイント:各部品(Model/CollisionChecker/ScoreManager/GameView)を組み合わせ、イベント駆動でゲームを動かす。
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();
        KeyPreview = true;
        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 };

        // ブロックを 3 行 × 6 列、画面の中央に並べる
        blocks = new List<Block>();
        const int rows = 3, cols = 6, gap = 10;
        int blockWidth = new Block().Width;
        int blockHeight = new Block().Height;
        float totalWidth = cols * blockWidth + (cols - 1) * gap;
        float startX = (ClientSize.Width - totalWidth) / 2f;
        for (int row = 0; row < rows; row++)
        {
            for (int col = 0; col < cols; col++)
            {
                blocks.Add(new Block
                {
                    X = startX + col * (blockWidth + gap),
                    Y = 40 + row * (blockHeight + gap)
                });
            }
        }

        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);
    }

    private void Form1_KeyDown(object sender, KeyEventArgs e)
    {
        if (e.KeyCode == Keys.Left) leftPressed = true;
        if (e.KeyCode == Keys.Right) rightPressed = true;
    }

    private void Form1_KeyUp(object sender, KeyEventArgs e)
    {
        if (e.KeyCode == Keys.Left) leftPressed = false;
        if (e.KeyCode == Keys.Right) rightPressed = false;
    }
}

重要:フォームの Load イベントで gameTimer.Start(); を呼び出すこと、そして KeyDown/KeyUp/Paint の各イベントを、それぞれ Form1_KeyDown/Form1_KeyUp/Form1_Paint に設定しておきましょう。Timerの Tick イベントも gameTimer_Tick に設定します。

実装時の補足:ありがちな不具合と対策

実際にチーム開発を進める中で見つかった、環境やDPI設定によって起こりやすい不具合と対策をまとめます。

①パドルが画面外に出てしまう:Paddle の MoveLeft() / MoveRight() には、範囲のチェックがありません。そのため Form1 の gameTimer_Tick で、パドルを動かした直後に Math.Clamp で画面内に収めています。この1行を忘れると、キーを押し続けるだけでパドルが画面外へ出てしまいます。

②Windowsの表示スケーリング(DPI設定)によって文字やブロックの見た目が環境ごとに変わる:カスタムサイズのウィンドウで固定ピクセル座標を使う場合は、.csprojの中でApplicationHighDpiModeプロパティをDpiUnawareに設定し、常に同じピクセルサイズで描画されるようにするのがおすすめです。

③GAME OVER/CLEARの文字が右端で切れる:clientSize.Width / 2 - 100 のような固定の数値は、フォントや環境によって文字の幅と合いません。GameView では Graphics.MeasureString で実際の幅を測り、(clientSize.Width - size.Width) / 2 のように中央揃えにしています。

Step 4. GitHub Desktopでの運用ルール

基本の流れ:main を最新化 → 作業ブランチへ反映 → 編集 → Commit → Push → PR

特に重要なのが、誰かのPRがmainにマージされた後の同期です。クイズアプリの回と同じ手順なので、まだの人はそちらも参考にしてください。

誰かのPRがmainにマージされたら

  1. 全員:mainを最新の状態にする(Fetch origin → Pull origin)
  2. 作業中のブランチがある人:作業ブランチに戻り、「Update from main」でmainの変更を取り込む
  3. 新しい作業を始める人:最新にしたmainから新しい作業ブランチを作る

特に今回はBall・Paddle・Blockのモデルクラスが全員の土台になるため、モデルがmainにマージされたタイミングで、全員が①〜②を必ず行ってから作業を再開してください。ここを飛ばすと、古いモデルの形のままコードを書いてしまい、あとで大きなコンフリクトになります。

Commit前に確認すること

  • クラスファイルを追加したら、すべて保存(Ctrl+Shift+S)してから Commit する
  • 自分の担当クラス以外は触らない
  • コミットメッセージは日本語で簡潔に(例:「CollisionChecker.csを追加(担当B)」)

Step 5. Rulesetsを試してみる(体験)

Step 1で設定したmain ブランチのRulesetsが、実際にどう働くのかをチームで体験してみましょう。クイズアプリの回で体験した人も、復習として試してみてください。

実験1:mainへ直接pushしてみる

誰か1人が、わざとmain ブランチのままファイルを1行変更し、Commit → Pushしてみます。Pushが拒否され、Pull Requestの作成を促すメッセージが表示されることを確認しましょう。

実験2:承認なしでMergeしてみる

誰かがPull Requestを作成した直後、まだ誰も承認していない状態でMergeボタンを押してみます。ボタンが押せない、または「At least 1 approving review is required」と表示されることを確認し、別のメンバーがApproveしたあとにボタンが押せるようになる様子を観察しましょう。

まとめ

  • ゲームは全クラスが同じ状態を毎フレーム共有するため、クイズアプリより並行作業が難しい
  • だからこそ、最初にModel(Ball・Paddle・Block)の形を決めて共有することが最優先
  • Model・CollisionChecker・ScoreManager・GameViewと責務を分けることで、オブジェクト指向の理解にもつながる
  • この「分けにくさ」を体験しておくと、Unityのコンポーネント指向がいかに疎結合で楽かを実感しやすくなる

参考(設計パターン)

このブロック崩しの構成では、以下の設計原則やパターンが関連しています。

単一責任の原則 (SRP)

各クラスがそれぞれ「位置と状態の保持」「衝突判定」「スコア管理」「描画」という明確な責務に集中しており、SRP に基づいて設計されています。

MVP(Model-View-Presenter)

Ball・Paddle・Block が Model、GameView が View、Form1(Timer制御)が Presenter にあたります。ブロック崩しMVPチュートリアルで1人で行ったリファクタリングを、最初からチームの役割分担として設計したものと言えます。

※ MVP との対応について:この記事の構成では、Form1 が Presenter(ゲームループの司令塔)の役割を兼ねる簡略版です。記事5 では、この司令塔を GamePresenter として別クラスに切り出し、Form1 を View(入力と描画)に専念させています。ここではチームで分担しやすいよう、司令塔を Form1 に残しています。

このアプリの元になった、1人用のWinFormsアプリで作るブロック崩し 目次もあわせてご覧ください。

訪問数 31 回, 今日の訪問数 1回

広告

Git

Posted by hidepon