フラッピーバード風ゲーム


2014年頃に突発的な大ブームを巻き起こしたゲーム「フラッピーバード」風のゲームをMonoGameで作ってみましょう。



フラッピーバードとは

フラッピーバードというのは、ベトナムのゲームクリエイターによって作られ、2014年頃にスマホアプリとしてリリースされたゲームです。


操作方法は、画面タップでジャンプして、土管を避けるだけ。
ゆるいグラフィックもあいまって、パッと見は完全にお手軽なカジュアルゲームなのですが、何故か難易度が異常に高く、普通の人は開始数秒で死にます。片手間にやっても数十万点などというスコアが飛び出して良い気分にさせてくれるツムツムなどのようなイマドキのアプリとは全く違い、フラッピーバードは、1時間くらい練習しても5点くらいしか取らせてくれません。
見た目と鬼畜さのギャップがウケたのか、人類は皆ドMなのか、理由は定かではありませんが、このゲームは世界中で大ブームを巻き起こし、中毒者が続出しました。
その結果、このゲームは広告付き無料アプリなのですが、最高で1日につき約500万円の収益があったとか……
しかし、開発者自身も予想していなかったこの大ブームに翻弄され、それまで通りの静かな生活を送ることが困難になり、また、このゲームにハマりすぎて社会生活を続けられなくなった中毒患者(真偽不明)を心配し、大ブームの最中に、このアプリをストアから削除するという決断に至りました。

そんな伝説的なゲームを、今回は真似して作ってみたいと思います。

リソースをダウンロード

今回のプロジェクトに使う画像や音をここからダウンロードし、zipファイルを解凍しておいてください。
https://github.com/ymotoyama/Samples/raw/master/FlappyUnko%20Resources.zip

プロジェクト作成

Visual Studioにて、新規プロジェクト(MonoGame Windows Project)を作成します。
この記事では、名前をFlappyUnkoとしています。

プロジェクトを作ったら、MonoGame Pipeline Toolを開き、ダウンロードしたリソースを追加してビルドしておいてください。

Game1.csがメインのロジックを書いていく場所ですが、Game1という名前のままではかわいそうなので、名前を変えましょうか。
ソリューションエクスプローラーからGame1.csの名前をFlappyUnko.csに変更します。(FlappyUnkoのほうがかわいそうではないかという指摘は受け付けません)
ファイル名を変えるとダイアログが出てくるかと思います。「はい」を押すと、ソースコード内のクラス名も変わります。

画面サイズの変更

あまり横長の画面はフラッピーバードっぽくないので、画面サイズを変更します。
namespace FlappyUnko
{
    public class FlappyUnko : Game
    {
        public static readonly int WindowWidth = 480; // ウィンドウの幅
        public static readonly int WindowHeight = 480; // ウィンドウの高さ

        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        public FlappyUnko()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            // ウィンドウサイズ変更
            graphics.PreferredBackBufferWidth = WindowWidth;
            graphics.PreferredBackBufferHeight = WindowHeight;
            graphics.ApplyChanges();
        }

テクスチャ管理クラスの作成

まずは、下回りの部分、テクスチャ管理クラスを作りましょう。「管理」というか、ただのテクスチャ置き場ですが…

今回のゲームは、主人公、土管、得点…といったように、登場人物がそこそこたくさんいるので、それぞれのクラスを作りたいと思います。で、それぞれのクラスからテクスチャを使いたいので、それが簡単にできるようなクラスを作っておきます。
TextureManagerというクラスを新規作成してください。
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;

namespace FlappyUnko
{
    // テクスチャー管理クラス
    static class TextureManager
    {
        public static Texture2D DokanBody; // 土管の中心部分
        public static Texture2D DokanEnd; // 土管の端
        public static Texture2D Floor; // 地面
        public static Texture2D GameOver; // GAMEOVER
        public static Texture2D Numbers; // 0~9の数字
        public static Texture2D PushSpace; // SPACEキーを押せ
        public static Texture2D Title; // タイトルロゴ
        public static Texture2D Unko; // うんこ

        public static void Load(ContentManager contentManager)
        {
            // 画像の読み込み
            DokanBody = contentManager.Load<Texture2D>("dokan_body");
            DokanEnd  = contentManager.Load<Texture2D>("dokan_end");
            Floor     = contentManager.Load<Texture2D>("floor");
            GameOver  = contentManager.Load<Texture2D>("gameover");
            Numbers   = contentManager.Load<Texture2D>("numbers");
            PushSpace = contentManager.Load<Texture2D>("push_space_to_start");
            Title     = contentManager.Load<Texture2D>("title");
            Unko      = contentManager.Load<Texture2D>("unko");
        }
    }
}
Loadメソッドが呼ばれたら全ての画像を読み込み、staticな変数に入れておくだけです。小規模な2Dゲームの場合、ゲーム開始時に全ての画像を読み込んで、ずっとメモリに格納しておいても、全く問題ないでしょう。大規模なゲームでは全く話が別ですが……例えば、FF15みたいな大作RPGで、ゲームを開始した瞬間から、ラストダンジョンやラスボスのデータまで読み込んでたら完全に無駄ですよね?無駄というか、ロード時間はかかるしメモリは足りないので、やりません。しかし小規模な2Dゲームなら、最初に全リソースを読み込んでも余裕なので、やっちゃいます。

FlappyUnko.csのLoadContentメソッドに、読み込み処理を追加してください。
        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);

            // 画像の読み込み
            TextureManager.Load(Content);
        }
あとは、画像の描画に使うSpriteBatchのインスタンスも、あちこちから使いたくて、その度に受け渡しするのもアホらしいので、publicでstaticにしましょう。
namespace FlappyUnko
{
    public class FlappyUnko : Game
    {
        public static readonly int WindowWidth = 480; // ウィンドウの幅
        public static readonly int WindowHeight = 480; // ウィンドウの高さ

        GraphicsDeviceManager graphics;
        public static SpriteBatch spriteBatch;

        public FlappyUnko()
        {
これでどこからでもSpriteBatchのインスタンスが参照できます。

地面の作成

まずは地味ですが、地面から作っていきたいと思います。
さて、今回のゲームは、うんこが右へ右へと飛んでいくゲームですが…
冷静に考えると、うんこは右に進んでないんです。同じ場所で上下に動いているだけで、地面や土管が動いているのです。そういう考えで作ったほうが、このゲームは簡単に作れると思います。

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace FlappyUnko
{
    public class FlappyUnko : Game
    {
        public static readonly int WindowWidth = 480; // ウィンドウの幅
        public static readonly int WindowHeight = 480; // ウィンドウの高さ
        public static readonly int FloorHeight = 32; // 地面の高さ
        public static readonly int FloorImageWidth = 32; // 地面の画像の幅
        public static readonly float ScrollSpeed = 2f; // スクロール速度

        GraphicsDeviceManager graphics;
        public static SpriteBatch spriteBatch;
        float scrollX = 0f; // スクロール位置

        public FlappyUnko()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            // ウィンドウサイズ変更
            graphics.PreferredBackBufferWidth = WindowWidth;
            graphics.PreferredBackBufferHeight = WindowHeight;
            graphics.ApplyChanges();
        }
        
        protected override void Initialize()
        {
            // TODO: Add your initialization logic here

            base.Initialize();
        }

        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);

            // 画像の読み込み
            TextureManager.Load(Content);
        }
        
        protected override void UnloadContent()
        {
        }
        
        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();

            // スクロール位置を進める
            scrollX += ScrollSpeed;

            base.Update(gameTime);
        }
        
        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);

            spriteBatch.Begin();

            // 地面を描画
            DrawFloor();

            spriteBatch.End();

            base.Draw(gameTime);
        }

        // 地面を描画する
        void DrawFloor()
        {
            float y = WindowHeight - FloorHeight;
            float x = scrollX % FloorImageWidth; // 32は地面の画像の横幅

            for (int i = 0; i < 16; i++)
            {
                spriteBatch.Draw(TextureManager.Floor, new Vector2(FloorImageWidth * i - x, y), Color.White);
            }
        }
    }
}
 これで無限にスクロールする地面の完成です。


今回は、用意された地面の画像がこれです。

32×32ピクセルです。この画像で画面の左端から右端までを覆うために、位置を32ピクセルずつずらして16回描画しています。
scrollXという変数では、現在のスクロール位置を管理していて、その量だけ画像を左にずらして描画しています。ただし、100ピクセルスクロールしたら、画像も100ピクセルずらす必要があるかというとそうではなくて、画像は32ピクセルスクロールしたら、元の位置に戻って良いのです。具体的には、スクロール量を32で割った余りの分だけずらしています。繰り返し回数を1にして確認してみると、何が起きているかわかりやすいと思います。

入力管理クラスの作成

今回のゲームでは、最終的に、複数のクラスからキーの入力状況を知りたくなるため、それがやりやすいように、入力管理クラスをあらかじめ作っておきましょう。
Inputという名前のクラスを新規作成します。
using Microsoft.Xna.Framework.Input;

namespace FlappyUnko
{
    static class Input
    {
        static KeyboardState currentState; // 現在の状態
        static KeyboardState prevState; // 1フレーム前の状態

        public static void Update()
        {
            prevState = currentState;
            currentState = Keyboard.GetState();
        }

        // ジャンプボタン(スペースキー)が押された瞬間か?
        public static bool IsJumpButtonDown()
        {
            return currentState.IsKeyDown(Keys.Space) && prevState.IsKeyUp(Keys.Space);
        }
    }
}
機能としては、スペースキーが押された瞬間かどうかを調べるIsJumpButtonDownメソッドがあるだけです。
メソッド名を「IsSpaceKeyDown」などではなく「IsJumpButtonDown」にした理由としては、今のところ、スペースキーでジャンプさせる予定なのですが、後で変えたくなった場合や、ジョイパッドに対応させたい場合などに、簡単に対応するためです。

FlappyUnko.csのUpdateメソッドに、Inputクラスの更新処理を追加します。
        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();
         
            Input.Update();

            // スクロール位置を進める
            scrollX += ScrollSpeed;

            base.Update(gameTime);
        }
 これで、ジャンプボタンが押されたかどうかを簡単に調べられるようになりました!

うんこクラスの作成

プレイヤーキャラクターであるうんこのクラスを作りましょう。
Unkoクラスを新規作成してください。
using Microsoft.Xna.Framework;

namespace FlappyUnko
{
    class Unko
    {
        static readonly float JumpVelocity = 5f; // ジャンプ力
        static readonly float Gravity = 0.1f; // 重力

        Vector2 position = new Vector2(100, 200); // 位置
        float velocityY = 0f; // 垂直方向の移動速度

        public void Update()
        {
            // 下向きに加速
            velocityY += Gravity;

            // スペースキーでジャンプ
            if (Input.IsJumpButtonDown())
            {
                Jump();
            }

            // 上昇または下降
            position.Y += velocityY;
        }

        // ジャンプ
        void Jump()
        {
            // 速度を上向きに
            velocityY = -JumpVelocity;
        }

        // 描画処理
        public void Draw()
        {
            FlappyUnko.spriteBatch.Draw(TextureManager.Unko, position, Color.White);
        }
    }
}
前述の通り、うんこは横には移動しません。縦に上下移動するのみです。重力で下向きに加速していきます。ジャンプボタン(スペースキー)を押すと、飛び上がります。
FlappyUnko.csのほうを改修して、うんこクラスを使います。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace FlappyUnko
{
    public class FlappyUnko : Game
    {
        public static readonly int WindowWidth = 480; // ウィンドウの幅
        public static readonly int WindowHeight = 480; // ウィンドウの高さ
        public static readonly int FloorHeight = 32; // 地面の高さ
        public static readonly int FloorImageWidth = 32; // 地面の画像の幅
        public static readonly float ScrollSpeed = 2f; // スクロール速度

        GraphicsDeviceManager graphics;
        public static SpriteBatch spriteBatch;
        float scrollX = 0f; // スクロール位置
        Unko unko = new Unko(); // うんこオブジェクト

        public FlappyUnko()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            // ウィンドウサイズ変更
            graphics.PreferredBackBufferWidth = WindowWidth;
            graphics.PreferredBackBufferHeight = WindowHeight;
            graphics.ApplyChanges();
        }
        
        protected override void Initialize()
        {
            // TODO: Add your initialization logic here

            base.Initialize();
        }

        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);

            // 画像の読み込み
            TextureManager.Load(Content);
        }
        
        protected override void UnloadContent()
        {
        }
        
        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();
            
            Input.Update();

            // スクロール位置を進める
            scrollX += ScrollSpeed;

            // うんこの処理
            unko.Update();

            base.Update(gameTime);
        }
        
        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);

            spriteBatch.Begin();

            // 地面を描画
            DrawFloor();

            // うんこを描画
            unko.Draw();

            spriteBatch.End();

            base.Draw(gameTime);
        }

        // 地面を描画する
        void DrawFloor()
        {
            float y = WindowHeight - FloorHeight;
            float x = scrollX % FloorImageWidth; // 32は地面の画像の横幅

            for (int i = 0; i < 16; i++)
            {
                spriteBatch.Draw(TextureManager.Floor, new Vector2(FloorImageWidth * i - x, y), Color.White);
            }
        }
    }
}

スペースキーでうんこが元気にジャンプします。
Unkoクラスの定数JumpVelocityとGravityを変えると、挙動が変わります。色んなパターンを試してみてください。

これがいわゆる「調整」ですが、この作業は非常に大切です。執念深い調整作業により、言葉で表すことのできないゲームの味、良い手触りが生まれます。些細な差が、ときに神ゲーとクソゲーを分けるのです。フラッピーバードなどは、まさにその些細な差で神ゲー側となった例です。

土管クラスの作成

障害物である土管を作っていきます。
土管クラスは少々複雑です。理由は2つあります。
  • 上の土管と下の土管を合わせて1つのクラスとしたから
  • 画像をケチったから
1つ目の理由は、このゲームにおいて土管は、上の土管と下の土管が対になって現れ、一緒にスクロールするため、1つのクラスにまとめてしまったほうがトータルで楽だと判断したからです。
2つ目の理由は、どういうことかというと、土管の画像を次のように大きめに用意すれば多少楽だったのですが、

ケチって、次のような小さい画像のみで済ませようとしているからです。
 
左側の画像が土管のメインの部分で、右の画像が土管の端の部分です。これを組み合わせれば、上の土管も下の土管も作れます。SpriteBatchには画像を拡大・縮小する機能もあるので、それを使って、画像を長く引き伸ばして土管を描画します。

では、Dokanというクラスを新規作成してください。
using Microsoft.Xna.Framework;

namespace FlappyUnko
{
    // 土管クラス
    class Dokan
    {
        public static readonly int DokanVerticalSpace = 150; // 土管の上下の隙間の広さ
        public static readonly int Width = 64; // 土管の幅

        int upDokanHeight; // 上側の土管の高さ
        int downDokanHeight; // 下側の土管の高さ
        int downDokanTop; // 下側の土管の上端位置
        float positionX; // 左端位置

        // コンストラクタ
        // left : 土管の左端位置
        // offsetFromCenter : 標準的な位置から上下にずらす量
        public Dokan(int left, int offsetFromCenter)
        {
            positionX = left;
            upDokanHeight = (FlappyUnko.WindowHeight - FlappyUnko.FloorHeight) / 2 - DokanVerticalSpace / 2 + offsetFromCenter;

            downDokanTop = upDokanHeight + DokanVerticalSpace;
            downDokanHeight = FlappyUnko.WindowHeight - downDokanTop - FlappyUnko.FloorHeight;
        }

        // 更新処理
        public void Update()
        {
            // 左へ移動する
            positionX -= FlappyUnko.ScrollSpeed;
        }

        // 描画処理
        public void Draw()
        {
            // 上側土管の上端
            int upDokanTop = 0;
            // 上側土管の描画先範囲を計算
            Rectangle upDokanDest = new Rectangle((int)positionX, upDokanTop, Width, upDokanHeight - 16);
            // 描画先の範囲を指定して、拡大して描画
            FlappyUnko.spriteBatch.Draw(TextureManager.DokanBody, upDokanDest, Color.White);

            // 上側の土管の下端部分を描画
            FlappyUnko.spriteBatch.Draw(TextureManager.DokanEnd, new Vector2(positionX, upDokanHeight - 16), Color.White);

            // 下側の土管の上端部分を描画
            FlappyUnko.spriteBatch.Draw(TextureManager.DokanEnd, new Vector2(positionX, downDokanTop), Color.White);

            // 下側土管の描画先範囲を計算
            Rectangle downDokanDest = new Rectangle((int)positionX, downDokanTop + 16, Width, downDokanHeight - 16);
            // 描画先の範囲を指定して、拡大して描画
            FlappyUnko.spriteBatch.Draw(TextureManager.DokanBody, downDokanDest, Color.White);
        }
    }
}
クラス内の変数(用語)の説明をします。
上側の土管をupDokan、下側の土管をdownDokanと呼んでいます。


土管の胴体の部分をBody、橋の部分をEndと呼んでいます。


各種変数の意味です。


コンストラクタで指定するoffsetFromCenterは、標準位置からのズレの量です。例えば、offsetFromCenterが0の場合は、画面の標準的な位置に土管が出現します。offsetFromCenterが50の場合は、標準位置より50ピクセル下がった位置に、土管が出現します。


土管のBody部分の描画を行う際は、SpriteBatchのDrawメソッドにRectangleで描画先範囲を指定し、拡大描画しています。Rectangleは四角形を表すクラスです。コンストラクタの引数は左端、上端、幅、高さです。

 作成したDokanクラスを使うように、FlappyUnko.csを改修します。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System.Collections.Generic; // Listを使うのに必要
using System; // Randomを使うのに必要

namespace FlappyUnko
{
    public class FlappyUnko : Game
    {
        public static readonly int WindowWidth = 480; // ウィンドウの幅
        public static readonly int WindowHeight = 480; // ウィンドウの高さ
        public static readonly int FloorHeight = 32; // 地面の高さ
        public static readonly int FloorImageWidth = 32; // 地面の画像の幅
        public static readonly float ScrollSpeed = 2f; // スクロール速度
        static readonly int DokanInterval = 250; // 土管の出現間隔(ピクセル)

        GraphicsDeviceManager graphics;
        public static SpriteBatch spriteBatch;
        float scrollX = 0f; // スクロール位置
        Unko unko = new Unko(); // うんこオブジェクト
        List<Dokan> dokans = new List<Dokan>(); // 土管のリスト
        int nextDokanSpawn = 50; // 次に土管が発生するスクロール位置
        Random rand = new Random();

        public FlappyUnko()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            // ウィンドウサイズ変更
            graphics.PreferredBackBufferWidth = WindowWidth;
            graphics.PreferredBackBufferHeight = WindowHeight;
            graphics.ApplyChanges();
        }
        
        protected override void Initialize()
        {
            // TODO: Add your initialization logic here

            base.Initialize();
        }

        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);

            // 画像の読み込み
            TextureManager.Load(Content);
        }
        
        protected override void UnloadContent()
        {
        }
        
        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();
            
            Input.Update();

            // スクロール位置を進める
            scrollX += ScrollSpeed;

            // うんこの処理
            unko.Update();

            // 土管の処理
            foreach (Dokan dokan in dokans)
            {
                dokan.Update();
            }

            // 新しい土管の生成
            if (scrollX >= nextDokanSpawn)
            {
                Vector2 position = new Vector2(WindowWidth + Dokan.Width / 2, 240);
                dokans.Add(new Dokan(WindowWidth, rand.Next(-60, 61)));
                nextDokanSpawn += DokanInterval;
            }

            base.Update(gameTime);
        }
        
        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);

            spriteBatch.Begin();

            // 地面を描画
            DrawFloor();

            // 土管を描画
            foreach (Dokan dokan in dokans)
            {
                dokan.Draw();
            }

            // うんこを描画
            unko.Draw();

            spriteBatch.End();

            base.Draw(gameTime);
        }

        // 地面を描画する
        void DrawFloor()
        {
            float y = WindowHeight - FloorHeight;
            float x = scrollX % FloorImageWidth; // 32は地面の画像の横幅

            for (int i = 0; i < 16; i++)
            {
                spriteBatch.Draw(TextureManager.Floor, new Vector2(FloorImageWidth * i - x, y), Color.White);
            }
        }
    }
}
 
だいぶゲームっぽくなってきました!

画面外に出ていった土管を消す

大切なことを忘れていました。土管を出すだけ出して、消すのを忘れていました。画面の左側へ消えていった土管をちゃんと消してあげないと、画面には映らないものの、プログラム上には何百、何千という土管のデータが残り続けて、そのうちプログラムの動作に支障をきたすでしょう!!
まあぶっちゃけその前に確実にプレイヤーが死ぬので、現実的には問題無いっちゃ無いのですが、正しくないものは正しくない。不要になった土管は正しく削除しましょう。
土管に、画面外に消えたかどうかを判定するメソッドを追加します。
using Microsoft.Xna.Framework;

namespace FlappyUnko
{
    // 土管クラス
    class Dokan
    {
        public static readonly int DokanVerticalSpace = 150; // 土管の上下の隙間の広さ
        public static readonly int Width = 64; // 土管の幅

        int upDokanHeight; // 上側の土管の高さ
        int downDokanHeight; // 下側の土管の高さ
        int downDokanTop; // 下側の土管の上端位置
        float positionX; // 左端位置

        // コンストラクタ
        // left : 土管の左端位置
        // offsetFromCenter : 標準的な位置から上下にずらす量
        public Dokan(int left, int offsetFromCenter)
        {
            positionX = left;
            upDokanHeight = (FlappyUnko.WindowHeight - FlappyUnko.FloorHeight) / 2 - DokanVerticalSpace / 2 + offsetFromCenter;

            downDokanTop = upDokanHeight + DokanVerticalSpace;
            downDokanHeight = FlappyUnko.WindowHeight - downDokanTop - FlappyUnko.FloorHeight;
        }

        // 更新処理
        public void Update()
        {
            // 左へ移動する
            positionX -= FlappyUnko.ScrollSpeed;
        }

        // 描画処理
        public void Draw()
        {
            // 上側土管の上端
            int upDokanTop = 0;
            // 上側土管の描画先範囲を計算
            Rectangle upDokanDest = new Rectangle((int)positionX, upDokanTop, Width, upDokanHeight - 16);
            // 描画先の範囲を指定して、拡大して描画
            FlappyUnko.spriteBatch.Draw(TextureManager.DokanBody, upDokanDest, Color.White);

            // 上側の土管の下端部分を描画
            FlappyUnko.spriteBatch.Draw(TextureManager.DokanEnd, new Vector2(positionX, upDokanHeight - 16), Color.White);

            // 下側の土管の上端部分を描画
            FlappyUnko.spriteBatch.Draw(TextureManager.DokanEnd, new Vector2(positionX, downDokanTop), Color.White);

            // 下側土管の描画先範囲を計算
            Rectangle downDokanDest = new Rectangle((int)positionX, downDokanTop + 16, Width, downDokanHeight - 16);
            // 描画先の範囲を指定して、拡大して描画
            FlappyUnko.spriteBatch.Draw(TextureManager.DokanBody, downDokanDest, Color.White);
        }

        // 画面左へスクロールして出ていったか?
        // 出ていってたらtrueを返却する
        public bool ScrolledOut()
        {
            // 土管の右端が0より小さければ、画面外である
            return positionX + Width < 0;
        }
    }
}
FlyappyUnko.csのUpdateメソッドを改修します。
         protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();

            Input.Update();

            // スクロール位置を進める
            scrollX += ScrollSpeed;

            // うんこの処理
            unko.Update();

            // 土管の処理
            foreach (Dokan dokan in dokans)
            {
                dokan.Update();
            }

            // 新しい土管の生成
            if (scrollX >= nextDokanSpawn)
            {
                Vector2 position = new Vector2(WindowWidth + Dokan.Width / 2, 240);
                dokans.Add(new Dokan(WindowWidth, rand.Next(-60, 61)));
                nextDokanSpawn += DokanInterval;
            }

            // 画面左に出ていった土管の除去
            // 末尾から先頭に向かってチェックしていく
            for (int i = dokans.Count - 1; i >= 0; i--)
            {
                if (dokans[i].ScrolledOut())
                {
                    dokans.RemoveAt(i);
                }
            }

            base.Update(gameTime);
        }
Dokanのリストから、画面外に消えたものを除去するために、今回は、上記のように、末尾から先頭に向かって調べています。何故かというと、先頭から末尾に向かってチェック&削除を行うと、削除したときにインデックスがずれて面倒だからです。また、foreachは使えません。foreachの中でリストの要素の削除しようとすると、エラーになります。というわけで、forを使って末尾からチェック&削除を行っています。

しかし実は、C#の比較的新しい機能を使うと、もっと簡単に実現できます。
            // 画面左に出ていった土管の除去
            dokans.RemoveAll(dokan => dokan.ScrolledOut());
たったの一行です。リストのRemoveAllというメソッドを使うと、条件に該当する要素を全て削除してくれます。しかしその条件の書き方が、ラムダ式という書き方になっており、少しとっつきづらいです。気になる人は勉強してみてください。ラムダ式は、これからのプログラマーには必須の知識なので、遅かれ早かれ勉強することにはなります。しかしまあ初心者向けの機能ではないですね。今回は、forを使ってもRemoveAllを使っても、どちらでも構いません。

本当に画面左に出ていった土管が削除されているのか気になるという人は、ログでも出して確認してください。
Console.WriteLine("土管の数:" + dokans.Count);

衝突判定

うんこと障害物がぶつかっているかの判定、いわゆる「当たり判定」を作りましょう。
今回は基本的に、2Dゲームで最もオーソドックスな、四角形と四角形の判定で調べます。
UnkoクラスとDokanクラスに、それぞれの衝突範囲(四角形)を返却するメソッドを追加し、メインロジックの部分で判定を行います。

まずUnko.csを改修します。
using Microsoft.Xna.Framework;

namespace FlappyUnko
{
    class Unko
    {
        static readonly int Width = 48; // 幅
        static readonly int Height = 48; // 高さ
        static readonly int CollisionOffset = 8; // 当たり判定を少し小さくするための補正値
        static readonly float JumpVelocity = 10f; // ジャンプ力
        static readonly float Gravity = 0.6f; // 重力

        Vector2 position = new Vector2(100, 200); // 位置
        float velocityY = 0f; // 垂直方向の移動速度

        public void Update()
        {
            // 下向きに加速
            velocityY += Gravity;

            // スペースキーでジャンプ
            if (Input.IsJumpButtonDown())
            {
                Jump();
            }

            // 上昇または下降
            position.Y += velocityY;
        }

        // ジャンプ
        void Jump()
        {
            // 速度を上向きに
            velocityY = -JumpVelocity;
        }

        // 描画処理
        public void Draw()
        {
            FlappyUnko.spriteBatch.Draw(TextureManager.Unko, position, Color.White);
        }

        // うんこの当たり判定を返却する
        public Rectangle GetCollisionRect()
        {
            // 画像サイズそのものを使うと大きすぎるので、
            // CollisionOffset分だけ縮めている
            return new Rectangle(
                (int)position.X + CollisionOffset,
                (int)position.Y + CollisionOffset,
                Width - CollisionOffset * 2,
                Height - CollisionOffset * 2);
        }
    }
}
うんこの画像は48×48ピクセルなのですが、そのままそれを当たり判定の大きさとしてしまうと、見た目よりもぶつかりやすくなり、ストレスが溜まります。そのため、CollisionOffset分だけ小さくしています。

↑画像サイズをそのまま当たり判定領域にした場合(赤部分)


↑当たり判定領域を少し小さくした場合(赤部分)

続いて、Dokanクラスに衝突範囲を返却するメソッドを追加します。
using Microsoft.Xna.Framework;

namespace FlappyUnko
{
    // 土管クラス
    class Dokan
    {
        public static readonly int DokanVerticalSpace = 150; // 土管の上下の隙間の広さ
        public static readonly int Width = 64; // 土管の幅

        int upDokanHeight; // 上側の土管の高さ
        int downDokanHeight; // 下側の土管の高さ
        int downDokanTop; // 下側の土管の上端位置
        float positionX; // 左端位置

        // コンストラクタ
        // left : 土管の左端位置
        // offsetFromCenter : 標準的な位置から上下にずらす量
        public Dokan(int left, int offsetFromCenter)
        {
            positionX = left;
            upDokanHeight = (FlappyUnko.WindowHeight - FlappyUnko.FloorHeight) / 2 - DokanVerticalSpace / 2 + offsetFromCenter;

            downDokanTop = upDokanHeight + DokanVerticalSpace;
            downDokanHeight = FlappyUnko.WindowHeight - downDokanTop - FlappyUnko.FloorHeight;
        }

        // 更新処理
        public void Update()
        {
            // 左へ移動する
            positionX -= FlappyUnko.ScrollSpeed;
        }

        // 描画処理
        public void Draw()
        {
            // 上側土管の上端
            int upDokanTop = 0;
            // 上側土管の描画先範囲を計算
            Rectangle upDokanDest = new Rectangle((int)positionX, upDokanTop, Width, upDokanHeight - 16);
            // 描画先の範囲を指定して、拡大して描画
            FlappyUnko.spriteBatch.Draw(TextureManager.DokanBody, upDokanDest, Color.White);

            // 上側の土管の下端部分を描画
            FlappyUnko.spriteBatch.Draw(TextureManager.DokanEnd, new Vector2(positionX, upDokanHeight - 16), Color.White);

            // 下側の土管の上端部分を描画
            FlappyUnko.spriteBatch.Draw(TextureManager.DokanEnd, new Vector2(positionX, downDokanTop), Color.White);

            // 下側土管の描画先範囲を計算
            Rectangle downDokanDest = new Rectangle((int)positionX, downDokanTop + 16, Width, downDokanHeight - 16);
            // 描画先の範囲を指定して、拡大して描画
            FlappyUnko.spriteBatch.Draw(TextureManager.DokanBody, downDokanDest, Color.White);
        }

        // 画面左へスクロールして出ていったか?
        // 出ていってたらtrueを返却する
        public bool ScrolledOut()
        {
            // 土管の右端が0より小さければ、画面外である
            return positionX + Width < 0;
        }

        // 上側の土管の当たり判定を返却する
        public Rectangle GetUpCollisionRect()
        {
            return new Rectangle((int)positionX, 0, Width, upDokanHeight);
        }

        // 下側の土管の当たり判定を返却する
        public Rectangle GetDownCollisionRect()
        {
            return new Rectangle((int)positionX, downDokanTop, Width, downDokanHeight);
        }
    }
}

土管は上の土管と下の土管の2つがあるため、メソッドも2つ作りました。

では、FlappyUnko.csのメインロジックを改修し、衝突判定処理を作っていきます。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System.Collections.Generic; // Listを使うのに必要
using System; // Randomを使うのに必要

namespace FlappyUnko
{
    public class FlappyUnko : Game
    {
        public static readonly int WindowWidth = 480; // ウィンドウの幅
        public static readonly int WindowHeight = 480; // ウィンドウの高さ
        public static readonly int FloorHeight = 32; // 地面の高さ
        public static readonly int FloorImageWidth = 32; // 地面の画像の幅
        public static readonly float ScrollSpeed = 2f; // スクロール速度
        static readonly int DokanInterval = 250; // 土管の出現間隔(ピクセル)

        GraphicsDeviceManager graphics;
        public static SpriteBatch spriteBatch;
        float scrollX = 0f; // スクロール位置
        Unko unko = new Unko(); // うんこオブジェクト
        List<Dokan> dokans = new List<Dokan>(); // 土管のリスト
        int nextDokanSpawn = 50; // 次に土管が発生するスクロール位置
        Random rand = new Random();

        public FlappyUnko()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            // ウィンドウサイズ変更
            graphics.PreferredBackBufferWidth = WindowWidth;
            graphics.PreferredBackBufferHeight = WindowHeight;
            graphics.ApplyChanges();
        }
        
        protected override void Initialize()
        {
            // TODO: Add your initialization logic here

            base.Initialize();
        }

        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);

            // 画像の読み込み
            TextureManager.Load(Content);
        }
        
        protected override void UnloadContent()
        {
        }
        
        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();
            
            Input.Update();

            // スクロール位置を進める
            scrollX += ScrollSpeed;

            // うんこの処理
            unko.Update();

            // 土管の処理
            foreach (Dokan dokan in dokans)
            {
                dokan.Update();
            }            

            // 新しい土管の生成
            if (scrollX >= nextDokanSpawn)
            {
                Vector2 position = new Vector2(WindowWidth + Dokan.Width / 2, 240);
                dokans.Add(new Dokan(WindowWidth, rand.Next(-60, 61)));
                nextDokanSpawn += DokanInterval;
            }

            // 画面左に出ていった土管の除去
            dokans.RemoveAll(dokan => dokan.ScrolledOut());
            
            base.Update(gameTime);
        }

        // うんこと各種障害物がぶつかっているか?
        // ぶつかってたらtrueを返却する
        bool IsCollision()
        {
            // うんこの当たり判定
            Rectangle unkoCollisionRect = unko.GetCollisionRect();

            // 天井にぶつかったか
            if (unkoCollisionRect.Top < 0)
                return true;

            // 地面にぶつかったか
            if (unkoCollisionRect.Bottom >= WindowHeight - FloorHeight)
                return true;

            // 土管とぶつかったか
            foreach (Dokan dokan in dokans)
            {
                // 上側の土管とぶつかったか
                if (dokan.GetUpCollisionRect().Intersects(unkoCollisionRect))
                    return true;

                // 下側の土管とぶつかったか
                if (dokan.GetDownCollisionRect().Intersects(unkoCollisionRect))
                    return true;
            }

            // 何にもぶつかってない
            return false;
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);

            // デバッグ用。ぶつかったら背景を赤くする。確認後、消すこと
            if (IsCollision())
            {
                GraphicsDevice.Clear(Color.Red);
            }

            spriteBatch.Begin();

            // 地面を描画
            DrawFloor();

            // 土管を描画
            foreach (Dokan dokan in dokans)
            {
                dokan.Draw();
            }

            // うんこを描画
            unko.Draw();

            spriteBatch.End();

            base.Draw(gameTime);
        }

        // 地面を描画する
        void DrawFloor()
        {
            float y = WindowHeight - FloorHeight;
            float x = scrollX % FloorImageWidth; // 32は地面の画像の横幅

            for (int i = 0; i < 16; i++)
            {
                spriteBatch.Draw(TextureManager.Floor, new Vector2(FloorImageWidth * i - x, y), Color.White);
            }
        }
    }
}

土管や天井、床にぶつかったかどうかを調べ、ぶつかっていたらとりあえず背景を赤くしています。確認してみてください。

当たり判定に問題が無いことが確認できたら、背景を赤くするコードは削除してください。

ゲームの状態遷移

タイトル→プレイ→ゲームオーバーの流れを作っていきます。
まずは、Unkoクラスから対応していきます。
using Microsoft.Xna.Framework;

namespace FlappyUnko
{
    class Unko
    {
        // うんこの状態
        enum State
        {
            Ready,
            Play,
            Dead,
        }

        static readonly int Width = 48; // 幅
        static readonly int Height = 48; // 高さ
        static readonly int CollisionOffset = 8; // 当たり判定を少し小さくするための補正値
        static readonly float JumpVelocity = 10f; // ジャンプ力
        static readonly float Gravity = 0.6f; // 重力

        Vector2 position = new Vector2(100, 200); // 位置
        float velocityY = 0f; // 垂直方向の移動速度
        State state = State.Ready; // 状態

        public void Update()
        {
            // Ready状態
            if (state == State.Ready)
            {
                // 何もしない
            }
            // Play状態
            else if (state == State.Play)
            {
                // 下向きに加速
                velocityY += Gravity;

                // スペースキーでジャンプ
                if (Input.IsJumpButtonDown())
                {
                    Jump();
                }

                // 上昇または下降
                position.Y += velocityY;
            }
            // Dead状態
            else if (state == State.Dead)
            {
                // 下向きに加速
                velocityY += Gravity;
                // 落下
                position.Y += velocityY;
            }
        }

        // 動き始める
        public void Start()
        {
            state = State.Play;
            Jump();
        }

        // 死ぬ
        public void Die()
        {
            state = State.Dead;
            velocityY = 0;
        }

        // ジャンプ
        void Jump()
        {
            // 速度を上向きに
            velocityY = -JumpVelocity;
        }

        // 描画処理
        public void Draw()
        {
            FlappyUnko.spriteBatch.Draw(TextureManager.Unko, position, Color.White);
        }

        // うんこの当たり判定を返却する
        public Rectangle GetCollisionRect()
        {
            // 画像サイズそのものを使うと大きすぎるので、
            // CollisionOffset分だけ縮めている
            return new Rectangle(
                (int)position.X + CollisionOffset,
                (int)position.Y + CollisionOffset,
                Width - CollisionOffset * 2,
                Height - CollisionOffset * 2);
        }
    }
}
Readyというのは、タイトルロゴ表示中の待機状態です。
Playはもちろんプレイ中を表す状態で、Play状態では、今までやっていた動作を行います。
Deadは死んだ状態で、ゲームオーバー時の状態です。

続いて、FlappyUnko.csを改修します。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System.Collections.Generic; // Listを使うのに必要
using System; // Randomを使うのに必要

namespace FlappyUnko
{
    // ゲームの状態種別
    enum GameState
    {
        Title,
        Play,
        GameOver,
    }

    public class FlappyUnko : Game
    {
        public static readonly int WindowWidth = 480; // ウィンドウの幅
        public static readonly int WindowHeight = 480; // ウィンドウの高さ
        public static readonly int FloorHeight = 32; // 地面の高さ
        public static readonly int FloorImageWidth = 32; // 地面の画像の幅
        public static readonly float ScrollSpeed = 2f; // スクロール速度
        static readonly int DokanInterval = 250; // 土管の出現間隔(ピクセル)

        GraphicsDeviceManager graphics;
        public static SpriteBatch spriteBatch;
        float scrollX = 0f; // スクロール位置
        Unko unko = new Unko(); // うんこオブジェクト
        List<Dokan> dokans = new List<Dokan>(); // 土管のリスト
        int nextDokanSpawn = 50; // 次に土管が発生するスクロール位置
        Random rand = new Random();
        GameState state = GameState.Title; // ゲームの状態

        public FlappyUnko()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            // ウィンドウサイズ変更
            graphics.PreferredBackBufferWidth = WindowWidth;
            graphics.PreferredBackBufferHeight = WindowHeight;
            graphics.ApplyChanges();
        }

        protected override void Initialize()
        {
            // TODO: Add your initialization logic here

            base.Initialize();
        }

        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);

            // 画像の読み込み
            TextureManager.Load(Content);
        }

        protected override void UnloadContent()
        {
        }

        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();

            Input.Update();

            // タイトル状態の処理
            if (state == GameState.Title)
            {
                // スクロール位置を進める
                scrollX += ScrollSpeed;

                // ジャンプボタンが押されたらゲーム開始
                if (Input.IsJumpButtonDown())
                {
                    unko.Start();
                    nextDokanSpawn = (int)scrollX + 50;
                    state = GameState.Play;
                }
            }
            // プレイ状態の処理
            else if (state == GameState.Play)
            {
                // スクロール位置を進める
                scrollX += ScrollSpeed;

                // うんこの処理
                unko.Update();

                // 土管の処理
                foreach (Dokan dokan in dokans)
                {
                    dokan.Update();
                }

                // うんこが障害物にぶつかってるか?
                if (IsCollision())
                {
                    state = GameState.GameOver;
                    unko.Die();
                }

                // 新しい土管の生成
                if (scrollX >= nextDokanSpawn)
                {
                    Vector2 position = new Vector2(WindowWidth + Dokan.Width / 2, 240);
                    dokans.Add(new Dokan(WindowWidth, rand.Next(-60, 61)));
                    nextDokanSpawn += DokanInterval;
                }

                // 画面左に出ていった土管の除去
                dokans.RemoveAll(dokan => dokan.ScrolledOut());
            }
            // ゲームオーバー状態の処理
            else
            {
                unko.Update();
            }

            base.Update(gameTime);
        }

        // うんこと各種障害物がぶつかっているか?
        // ぶつかってたらtrueを返却する
        bool IsCollision()
        {
            // うんこの当たり判定
            Rectangle unkoCollisionRect = unko.GetCollisionRect();

            // 天井にぶつかったか
            if (unkoCollisionRect.Top < 0)
                return true;

            // 地面にぶつかったか
            if (unkoCollisionRect.Bottom >= WindowHeight - FloorHeight)
                return true;

            // 土管とぶつかったか
            foreach (Dokan dokan in dokans)
            {
                // 上側の土管とぶつかったか
                if (dokan.GetUpCollisionRect().Intersects(unkoCollisionRect))
                    return true;

                // 下側の土管とぶつかったか
                if (dokan.GetDownCollisionRect().Intersects(unkoCollisionRect))
                    return true;
            }

            // 何にもぶつかってない
            return false;
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);
            
            spriteBatch.Begin();

            // 地面を描画
            DrawFloor();

            // 土管を描画
            foreach (Dokan dokan in dokans)
            {
                dokan.Draw();
            }

            // うんこを描画
            unko.Draw();
         
            // タイトル画面の場合
            if (state == GameState.Title)
            {
                // タイトルロゴを描画
                spriteBatch.Draw(TextureManager.Title, new Vector2(123, 100), Color.White);

                // 「PUSH SPACE TO START」を点滅させて描画
                if (gameTime.TotalGameTime.TotalSeconds % 1 < 0.5)
                {
                    spriteBatch.Draw(TextureManager.PushSpace, new Vector2(40, 300), Color.White);
                }
            }

            // ゲームオーバー状態では、「GAME OVER」と描画
            if (state == GameState.GameOver)
            {
                spriteBatch.Draw(TextureManager.GameOver, new Vector2(144, 200), Color.White);
            }

            spriteBatch.End();

            base.Draw(gameTime);
        }

        // 地面を描画する
        void DrawFloor()
        {
            float y = WindowHeight - FloorHeight;
            float x = scrollX % FloorImageWidth; // 32は地面の画像の横幅

            for (int i = 0; i < 16; i++)
            {
                spriteBatch.Draw(TextureManager.Floor, new Vector2(FloorImageWidth * i - x, y), Color.White);
            }
        }
    }
}
ゲームのひと通りの流れが実装できました。

「PUSH SPACE TO START」という表示を点滅させるために、今回はUpdateメソッドの引数で渡ってくるGameTimeを使っています。この中には、ゲーム開始時からの経過時間や、前フレームからの経過時間などが格納されています。
gameTime.TotalGameTime.TotalSecondsというのが、ゲーム開始時からの秒数(小数)です。それを%1すると、1で割った余り、つまり小数点以下のみが取り出せます。小数点以下が0.5未満なら表示、そうでなければ非表示としています。つまり、0.5秒おきに表示と非表示が切り替わります。

スコア表示

スコアの機能を作りましょう。スコアを計上する機能と表示する機能を作らなければなりませんが、今回は先に表示の方を作ってみましょう。

今回はこの0~9までの数字が並んだ画像を使って、スコアを表示します。
1文字は64×64ピクセルです。

ScoreManagerというクラスを新規作成してください。
using Microsoft.Xna.Framework;

namespace FlappyUnko
{
    class ScoreManager
    {
        static readonly int CharWidth = 64; // 1文字の幅
        static readonly int CharHeight = 64; // 1文字の高さ
        static readonly int positionY = 10; // 縦の表示位置

        // 得点
        public int Score;

        public void Draw()
        {
            // 描画対象の数
            int num = Score;

            // 桁数
            int digits = CountDigits(num);

            // 描画開始位置を計算する。今回は中央寄せしたい。
            // 描画は右側(下の位)から行うので、一番右の文字の描画位置(左上の座標)を求める。
            int positionX = (FlappyUnko.WindowWidth / 2) - CharWidth + (CharWidth / 2 * digits);
            Vector2 position = new Vector2(positionX, positionY);

            // 下の位から1文字ずつ描画する
            while (true)
            {
                // 下一桁のみ取り出す
                int singleDigit = num % 10;
                // 画像の中のどの範囲を描画するかを指定するためのRectangle。
                // コンストラクタの引数は 左端, 上端, 幅, 高さ。
                Rectangle rect = new Rectangle(CharWidth * singleDigit, 0, CharWidth, CharHeight);
                // 切り抜く範囲を指定して、数値1文字を描画
                FlappyUnko.spriteBatch.Draw(TextureManager.Numbers, position, rect, Color.White);

                // 描画位置を左へ1文字分ずらす
                position.X -= CharWidth;
                // 数を一桁小さくする(例えば、123は12になる)
                num /= 10;

                // 数が0になったら、これ以上描画するものがないので終了
                if (num == 0)
                {
                    break;
                }
            }
        }

        // 整数の桁数を数えて返却する
        // 例えば、150だったら3
        int CountDigits(int num)
        {
            int digits = 1;

            while (true)
            {
                num /= 10;

                if (num == 0)
                {
                    return digits;
                }

                digits++;
            }
        }
    }
}
FlappyUnko.csを改修して、とりあえず数字を画面に出してみる。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System.Collections.Generic; // Listを使うのに必要
using System; // Randomを使うのに必要

namespace FlappyUnko
{
    // ゲームの状態種別
    enum GameState
    {
        Title,
        Play,
        GameOver,
    }

    public class FlappyUnko : Game
    {
        public static readonly int WindowWidth = 480; // ウィンドウの幅
        public static readonly int WindowHeight = 480; // ウィンドウの高さ
        public static readonly int FloorHeight = 32; // 地面の高さ
        public static readonly int FloorImageWidth = 32; // 地面の画像の幅
        public static readonly float ScrollSpeed = 2f; // スクロール速度
        static readonly int DokanInterval = 250; // 土管の出現間隔(ピクセル)

        GraphicsDeviceManager graphics;
        public static SpriteBatch spriteBatch;
        float scrollX = 0f; // スクロール位置
        Unko unko = new Unko(); // うんこオブジェクト
        List<Dokan> dokans = new List<Dokan>(); // 土管のリスト
        int nextDokanSpawn = 50; // 次に土管が発生するスクロール位置
        Random rand = new Random();
        GameState state = GameState.Title; // ゲームの状態
        ScoreManager scoreManager = new ScoreManager(); // スコア管理オブジェクト

        public FlappyUnko()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            // ウィンドウサイズ変更
            graphics.PreferredBackBufferWidth = WindowWidth;
            graphics.PreferredBackBufferHeight = WindowHeight;
            graphics.ApplyChanges();
        }

        protected override void Initialize()
        {
            // TODO: Add your initialization logic here

            base.Initialize();
        }

        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);

            // 画像の読み込み
            TextureManager.Load(Content);
        }

        protected override void UnloadContent()
        {
        }

        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();

            Input.Update();

            // タイトル状態の処理
            if (state == GameState.Title)
            {
                // スクロール位置を進める
                scrollX += ScrollSpeed;

                // ジャンプボタンが押されたらゲーム開始
                if (Input.IsJumpButtonDown())
                {
                    unko.Start();
                    nextDokanSpawn = (int)scrollX + 50;
                    state = GameState.Play;
                }
            }
            // プレイ状態の処理
            else if (state == GameState.Play)
            {
                // スクロール位置を進める
                scrollX += ScrollSpeed;

                // うんこの処理
                unko.Update();

                // 土管の処理
                foreach (Dokan dokan in dokans)
                {
                    dokan.Update();
                }

                // うんこが障害物にぶつかってるか?
                if (IsCollision())
                {
                    state = GameState.GameOver;
                    unko.Die();
                }

                // 新しい土管の生成
                if (scrollX >= nextDokanSpawn)
                {
                    Vector2 position = new Vector2(WindowWidth + Dokan.Width / 2, 240);
                    dokans.Add(new Dokan(WindowWidth, rand.Next(-60, 61)));
                    nextDokanSpawn += DokanInterval;
                }

                // 画面左に出ていった土管の除去
                dokans.RemoveAll(dokan => dokan.ScrolledOut());
            }
            // ゲームオーバー状態の処理
            else
            {
                unko.Update();
            }

            // デバッグ用。とりあえずカウントアップ。動作確認後に消すこと
            scoreManager.Score++;

            base.Update(gameTime);
        }

        // うんこと各種障害物がぶつかっているか?
        // ぶつかってたらtrueを返却する
        bool IsCollision()
        {
            // うんこの当たり判定
            Rectangle unkoCollisionRect = unko.GetCollisionRect();

            // 天井にぶつかったか
            if (unkoCollisionRect.Top < 0)
                return true;

            // 地面にぶつかったか
            if (unkoCollisionRect.Bottom >= WindowHeight - FloorHeight)
                return true;

            // 土管とぶつかったか
            foreach (Dokan dokan in dokans)
            {
                // 上側の土管とぶつかったか
                if (dokan.GetUpCollisionRect().Intersects(unkoCollisionRect))
                    return true;

                // 下側の土管とぶつかったか
                if (dokan.GetDownCollisionRect().Intersects(unkoCollisionRect))
                    return true;
            }

            // 何にもぶつかってない
            return false;
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);
            
            spriteBatch.Begin();

            // 地面を描画
            DrawFloor();

            // 土管を描画
            foreach (Dokan dokan in dokans)
            {
                dokan.Draw();
            }

            // うんこを描画
            unko.Draw();
            
            // タイトル画面の場合
            if (state == GameState.Title)
            {
                // タイトルロゴを描画
                spriteBatch.Draw(TextureManager.Title, new Vector2(123, 100), Color.White);

                // 「PUSH SPACE TO START」を点滅させて描画
                if (gameTime.TotalGameTime.TotalSeconds % 1 < 0.5)
                {
                    spriteBatch.Draw(TextureManager.PushSpace, new Vector2(40, 300), Color.White);
                }
            }

            // ゲームオーバー状態では、「GAME OVER」と描画
            if (state == GameState.GameOver)
            {
                spriteBatch.Draw(TextureManager.GameOver, new Vector2(144, 200), Color.White);
            }

            scoreManager.Draw();

            spriteBatch.End();

            base.Draw(gameTime);
        }

        // 地面を描画する
        void DrawFloor()
        {
            float y = WindowHeight - FloorHeight;
            float x = scrollX % FloorImageWidth; // 32は地面の画像の横幅

            for (int i = 0; i < 16; i++)
            {
                spriteBatch.Draw(TextureManager.Floor, new Vector2(FloorImageWidth * i - x, y), Color.White);
            }
        }
    }
}

カウントアップされる数が正しく画面に表示されることを確認したら、デバッグ用にスコアをインクリメントしている部分は削除し、正しいスコアの計上処理を作りましょう。

スコアの計上処理

フラッピーバードは、土管をひとつ越えるごとに、1点が加算される。
同じ土管で何度もスコアが入らないように、土管にスコア計上済みかどうかを表すフラグ変数を持たせましょう。
using Microsoft.Xna.Framework;

namespace FlappyUnko
{
    // 土管クラス
    class Dokan
    {
        public static readonly int DokanVerticalSpace = 150; // 土管の上下の隙間の広さ
        public static readonly int Width = 64; // 土管の幅

        int upDokanHeight; // 上側の土管の高さ
        int downDokanHeight; // 下側の土管の高さ
        int downDokanTop; // 下側の土管の上端位置
        float positionX; // 左端位置

        public bool AddedScore = false; // 既にスコアを計上したか

        // コンストラクタ
        // left : 土管の左端位置
        // offsetFromCenter : 標準的な位置から上下にずらす量
        public Dokan(int left, int offsetFromCenter)
        {
FlappyUnko.csを改修し、土管を越えたときにスコアが加算されるようにします。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System.Collections.Generic; // Listを使うのに必要
using System; // Randomを使うのに必要

namespace FlappyUnko
{
    // ゲームの状態種別
    enum GameState
    {
        Title,
        Play,
        GameOver,
    }

    public class FlappyUnko : Game
    {
        public static readonly int WindowWidth = 480; // ウィンドウの幅
        public static readonly int WindowHeight = 480; // ウィンドウの高さ
        public static readonly int FloorHeight = 32; // 地面の高さ
        public static readonly int FloorImageWidth = 32; // 地面の画像の幅
        public static readonly float ScrollSpeed = 2f; // スクロール速度
        static readonly int DokanInterval = 250; // 土管の出現間隔(ピクセル)

        GraphicsDeviceManager graphics;
        public static SpriteBatch spriteBatch;
        float scrollX = 0f; // スクロール位置
        Unko unko = new Unko(); // うんこオブジェクト
        List<Dokan> dokans = new List<Dokan>(); // 土管のリスト
        int nextDokanSpawn = 50; // 次に土管が発生するスクロール位置
        Random rand = new Random();
        GameState state = GameState.Title; // ゲームの状態
        ScoreManager scoreManager = new ScoreManager(); // スコア管理オブジェクト

        public FlappyUnko()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            // ウィンドウサイズ変更
            graphics.PreferredBackBufferWidth = WindowWidth;
            graphics.PreferredBackBufferHeight = WindowHeight;
            graphics.ApplyChanges();
        }

        protected override void Initialize()
        {
            // TODO: Add your initialization logic here

            base.Initialize();
        }

        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);

            // 画像の読み込み
            TextureManager.Load(Content);
        }

        protected override void UnloadContent()
        {
        }

        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();

            Input.Update();

            // タイトル状態の処理
            if (state == GameState.Title)
            {
                // スクロール位置を進める
                scrollX += ScrollSpeed;

                // ジャンプボタンが押されたらゲーム開始
                if (Input.IsJumpButtonDown())
                {
                    unko.Start();
                    nextDokanSpawn = (int)scrollX + 50;
                    state = GameState.Play;
                }
            }
            // プレイ状態の処理
            else if (state == GameState.Play)
            {
                // スクロール位置を進める
                scrollX += ScrollSpeed;

                // うんこの処理
                unko.Update();

                // 土管の処理
                foreach (Dokan dokan in dokans)
                {
                    dokan.Update();
                }

                // うんこが障害物にぶつかってるか?
                if (IsCollision())
                {
                    state = GameState.GameOver;
                    unko.Die();
                }

                // 土管を越えてたら得点を計上する
                foreach (Dokan dokan in dokans)
                {
                    if (!dokan.AddedScore && dokan.GetUpCollisionRect().Right < unko.GetCollisionRect().Left)
                    {
                        // スコア加算
                        scoreManager.Score++;
                        // 土管のスコア計上済みフラグを立てる
                        dokan.AddedScore = true;
                    }
                }

                // 新しい土管の生成
                if (scrollX >= nextDokanSpawn)
                {
                    Vector2 position = new Vector2(WindowWidth + Dokan.Width / 2, 240);
                    dokans.Add(new Dokan(WindowWidth, rand.Next(-60, 61)));
                    nextDokanSpawn += DokanInterval;
                }

                // 画面左に出ていった土管の除去
                dokans.RemoveAll(dokan => dokan.ScrolledOut());
            }
            // ゲームオーバー状態の処理
            else
            {
                unko.Update();
            }

            base.Update(gameTime);
        }

        // うんこと各種障害物がぶつかっているか?
        // ぶつかってたらtrueを返却する
        bool IsCollision()
        {
            // うんこの当たり判定
            Rectangle unkoCollisionRect = unko.GetCollisionRect();

            // 天井にぶつかったか
            if (unkoCollisionRect.Top < 0)
                return true;

            // 地面にぶつかったか
            if (unkoCollisionRect.Bottom >= WindowHeight - FloorHeight)
                return true;

            // 土管とぶつかったか
            foreach (Dokan dokan in dokans)
            {
                // 上側の土管とぶつかったか
                if (dokan.GetUpCollisionRect().Intersects(unkoCollisionRect))
                    return true;

                // 下側の土管とぶつかったか
                if (dokan.GetDownCollisionRect().Intersects(unkoCollisionRect))
                    return true;
            }

            // 何にもぶつかってない
            return false;
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);
            
            spriteBatch.Begin();

            // 地面を描画
            DrawFloor();

            // 土管を描画
            foreach (Dokan dokan in dokans)
            {
                dokan.Draw();
            }

            // うんこを描画
            unko.Draw();
            
            // タイトル画面の場合
            if (state == GameState.Title)
            {
                // タイトルロゴを描画
                spriteBatch.Draw(TextureManager.Title, new Vector2(123, 100), Color.White);

                // 「PUSH SPACE TO START」を点滅させて描画
                if (gameTime.TotalGameTime.TotalSeconds % 1 < 0.5)
                {
                    spriteBatch.Draw(TextureManager.PushSpace, new Vector2(40, 300), Color.White);
                }
            }

            // ゲームオーバー状態では、「GAME OVER」と描画
            if (state == GameState.GameOver)
            {
                spriteBatch.Draw(TextureManager.GameOver, new Vector2(144, 200), Color.White);
            }

            // プレイ中とゲームオーバー状態ではスコアを描画
            if (state == GameState.Play || state == GameState.GameOver)
            {
                scoreManager.Draw();
            }

            spriteBatch.End();

            base.Draw(gameTime);
        }

        // 地面を描画する
        void DrawFloor()
        {
            float y = WindowHeight - FloorHeight;
            float x = scrollX % FloorImageWidth; // 32は地面の画像の横幅

            for (int i = 0; i < 16; i++)
            {
                spriteBatch.Draw(TextureManager.Floor, new Vector2(FloorImageWidth * i - x, y), Color.White);
            }
        }
    }
}
土管とうんこの当たり判定領域を取得し、土管の右端がうんこの左端より左である場合は越えたと判定しています。
今回のゲームは、一定間隔で土管が出現するので、こんな真面目に判定しなくても、「一定の量スクロールしたらスコアを加算」でも大丈夫ですが、もしかしたら、あとで、土管の間隔を変動させたくなるかもしれないですからね。

ゲームとしてはこれでほぼ完成です!

サウンドの追加

せっかくここまで作ったのだから、サウンドを追加して、ゲームとしての完成度を高めましょう。
サウンドは、あちこちから発生させるので、サウンドの機能へはどこからでもアクセスできたほうが便利です。そのような機能を作っていきます。
SoundManagerというクラスを新規作成してください。
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Content;

namespace FlappyUnko
{
    static class SoundManager
    {
        public static SoundEffect Jump;
        public static SoundEffect Score;
        public static SoundEffect Damage;

        public static void Load(ContentManager contentManager)
        {
            Jump   = contentManager.Load<SoundEffect>("se_jump");
            Score  = contentManager.Load<SoundEffect>("se_coin");
            Damage = contentManager.Load<SoundEffect>("se_noise");
        }
    }
}
 使っていきます。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System.Collections.Generic; // Listを使うのに必要
using System; // Randomを使うのに必要

namespace FlappyUnko
{
    // ゲームの状態種別
    enum GameState
    {
        Title,
        Play,
        GameOver,
    }

    public class FlappyUnko : Game
    {
        public static readonly int WindowWidth = 480; // ウィンドウの幅
        public static readonly int WindowHeight = 480; // ウィンドウの高さ
        public static readonly int FloorHeight = 32; // 地面の高さ
        public static readonly int FloorImageWidth = 32; // 地面の画像の幅
        public static readonly float ScrollSpeed = 2f; // スクロール速度
        static readonly int DokanInterval = 250; // 土管の出現間隔(ピクセル)

        GraphicsDeviceManager graphics;
        public static SpriteBatch spriteBatch;
        float scrollX = 0f; // スクロール位置
        Unko unko = new Unko(); // うんこオブジェクト
        List<Dokan> dokans = new List<Dokan>(); // 土管のリスト
        int nextDokanSpawn = 50; // 次に土管が発生するスクロール位置
        Random rand = new Random();
        GameState state = GameState.Title; // ゲームの状態
        ScoreManager scoreManager = new ScoreManager(); // スコア管理オブジェクト

        public FlappyUnko()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            // ウィンドウサイズ変更
            graphics.PreferredBackBufferWidth = WindowWidth;
            graphics.PreferredBackBufferHeight = WindowHeight;
            graphics.ApplyChanges();
        }

        protected override void Initialize()
        {
            // TODO: Add your initialization logic here

            base.Initialize();
        }

        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);

            // 画像の読み込み
            TextureManager.Load(Content);

            // 効果音の読み込み
            SoundManager.Load(Content);
        }

        protected override void UnloadContent()
        {
        }

        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();

            Input.Update();

            // タイトル状態の処理
            if (state == GameState.Title)
            {
                // スクロール位置を進める
                scrollX += ScrollSpeed;

                // ジャンプボタンが押されたらゲーム開始
                if (Input.IsJumpButtonDown())
                {
                    unko.Start();
                    nextDokanSpawn = (int)scrollX + 50;
                    state = GameState.Play;
                }
            }
            // プレイ状態の処理
            else if (state == GameState.Play)
            {
                // スクロール位置を進める
                scrollX += ScrollSpeed;

                // うんこの処理
                unko.Update();

                // 土管の処理
                foreach (Dokan dokan in dokans)
                {
                    dokan.Update();
                }

                // うんこが障害物にぶつかってるか?
                if (IsCollision())
                {
                    state = GameState.GameOver;
                    unko.Die();
                    // 音を鳴らす
                    SoundManager.Damage.Play();
                }

                // 土管を越えてたら得点を計上する
                foreach (Dokan dokan in dokans)
                {
                    if (!dokan.AddedScore && dokan.GetUpCollisionRect().Right < unko.GetCollisionRect().Left)
                    {
                        // スコア加算
                        scoreManager.Score++;
                        // 土管のスコア計上済みフラグを立てる
                        dokan.AddedScore = true;
                        // 音を鳴らす
                        SoundManager.Score.Play();
                    }
                }

                // 新しい土管の生成
                if (scrollX >= nextDokanSpawn)
                {
                    Vector2 position = new Vector2(WindowWidth + Dokan.Width / 2, 240);
                    dokans.Add(new Dokan(WindowWidth, rand.Next(-60, 61)));
                    nextDokanSpawn += DokanInterval;
                }

                // 画面左に出ていった土管の除去
                dokans.RemoveAll(dokan => dokan.ScrolledOut());
            }
            // ゲームオーバー状態の処理
            else
            {
                unko.Update();
            }

            base.Update(gameTime);
        }

        // うんこと各種障害物がぶつかっているか?
        // ぶつかってたらtrueを返却する
        bool IsCollision()
        {
            // うんこの当たり判定
            Rectangle unkoCollisionRect = unko.GetCollisionRect();

            // 天井にぶつかったか
            if (unkoCollisionRect.Top < 0)
                return true;

            // 地面にぶつかったか
            if (unkoCollisionRect.Bottom >= WindowHeight - FloorHeight)
                return true;

            // 土管とぶつかったか
            foreach (Dokan dokan in dokans)
            {
                // 上側の土管とぶつかったか
                if (dokan.GetUpCollisionRect().Intersects(unkoCollisionRect))
                    return true;

                // 下側の土管とぶつかったか
                if (dokan.GetDownCollisionRect().Intersects(unkoCollisionRect))
                    return true;
            }

            // 何にもぶつかってない
            return false;
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);
            
            spriteBatch.Begin();

            // 地面を描画
            DrawFloor();

            // 土管を描画
            foreach (Dokan dokan in dokans)
            {
                dokan.Draw();
            }

            // うんこを描画
            unko.Draw();
            
            // タイトル画面の場合
            if (state == GameState.Title)
            {
                // タイトルロゴを描画
                spriteBatch.Draw(TextureManager.Title, new Vector2(123, 100), Color.White);

                // 「PUSH SPACE TO START」を点滅させて描画
                if (gameTime.TotalGameTime.TotalSeconds % 1 < 0.5)
                {
                    spriteBatch.Draw(TextureManager.PushSpace, new Vector2(40, 300), Color.White);
                }
            }

            // ゲームオーバー状態では、「GAME OVER」と描画
            if (state == GameState.GameOver)
            {
                spriteBatch.Draw(TextureManager.GameOver, new Vector2(144, 200), Color.White);
            }

            // プレイ中とゲームオーバー状態ではスコアを描画
            if (state == GameState.Play || state == GameState.GameOver)
            {
                scoreManager.Draw();
            }

            spriteBatch.End();

            base.Draw(gameTime);
        }

        // 地面を描画する
        void DrawFloor()
        {
            float y = WindowHeight - FloorHeight;
            float x = scrollX % FloorImageWidth; // 32は地面の画像の横幅

            for (int i = 0; i < 16; i++)
            {
                spriteBatch.Draw(TextureManager.Floor, new Vector2(FloorImageWidth * i - x, y), Color.White);
            }
        }
    }
}
 Unkoクラスも改修し、ジャンプ時に音がなるようにします。
        // ジャンプ
        void Jump()
        {
            // 速度を上向きに
            velocityY = -JumpVelocity;

            // 音を鳴らす
            SoundManager.Jump.Play();
        }
これで完成です!
サウンドが鳴ることにより、ぐっとゲームで遊んでいる感じが強調されたのではないでしょうか?
「ボタンを押したら、音が鳴る」ただそれだけでも、結構気持ち良いものです。作ったゲームには是非、音を入れてあげてください。


フォントは美咲フォントをお借りしました。
うんこのイラストはいらすとやさんにお借りしました。

0 件のコメント:

コメントを投稿