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

広告

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
    今回はコードで描画するため、フォームに配置するコントロールはありません。Form1KeyPreviewtrue に、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. チームでの役割分担

クラス構成

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

ブランチ命名

ブランチ名担当・内容
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は他のクラスの形を知らなくても実装できましたが、ブロック崩しのCollisionCheckerGameViewは、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 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 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)
        {
            g.DrawString(text, new Font("Meiryo", 12), Brushes.Black, 10, 10);
        }

        public void DrawGameOver(Graphics g, Size clientSize)
        {
            g.DrawString("GAME OVER", new Font("Meiryo", 24, FontStyle.Bold),
                Brushes.Red, clientSize.Width / 2 - 100, clientSize.Height / 2 - 20);
        }

        public void DrawClear(Graphics g, Size clientSize)
        {
            g.DrawString("CLEAR!", new Font("Meiryo", 24, FontStyle.Bold),
                Brushes.Blue, clientSize.Width / 2 - 60, clientSize.Height / 2 - 20);
        }
    }
}

Form1.cs(リーダー統合)

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

namespace TeamBlockBreaker
{
    public partial class Form1 : Form
    {
        private Ball ball;
        private Paddle paddle;
        private List blocks;

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

        private bool leftPressed = false;
        private bool rightPressed = false;

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

        private void InitializeGame()
        {
            ball = new Ball { X = 200, Y = 300, VelocityX = 3, VelocityY = -3 };
            paddle = new Paddle { X = 160, Y = 360 };

            blocks = new List();
            for (int row = 0; row < 3; row++)
            {
                for (int col = 0; col < 6; col++)
                {
                    blocks.Add(new Block { X = col * 60 + 10, Y = row * 25 + 40 });
                }
            }

            score.Initialize(blocks.Count);
        }

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

            if (leftPressed) paddle.MoveLeft();
            if (rightPressed) paddle.MoveRight();

            ball.Move();

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

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

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

            this.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());

            if (score.IsGameOver) view.DrawGameOver(g, this.ClientSize);
            if (score.IsCleared) view.DrawClear(g, this.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(); を呼び出すこと、そして KeyDownKeyUpPaint の各イベントを、それぞれ Form1_KeyDownForm1_KeyUpForm1_Paint に設定しておきましょう。Timerの Tick イベントも gameTimer_Tick に設定します。

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人で行ったリファクタリングを、最初からチームの役割分担として設計したものと言えます。

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

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

広告

Git

Posted by hidepon