ライツアウト


LightsOut(ライツアウト)とか点灯パズルゲームとか呼ばれる定番のパズルをMonoGameで作ってみましょう。
光っているマスと光っていないマスが並んでいて、クリックすると、クリックしたマスを中心とした5マス(クリックしたマスとその上下左右)の点灯/消灯が切り替わります。全てのマスを消灯させることができればゲームクリアです。
シンプルなルールですが、結構やりごたえがあります。

画像リソースをダウンロード


ここから画像リソースをダウンロードし、zipファイルを解凍しておいてください。
https://github.com/ymotoyama/Samples/raw/master/LightsOut%20Resources.zip


プロジェクト作成

新規プロジェクト(MonoGame Windows Project)を作成します。
本記事のサンプルコードでは、プロジェクト名を「LightsOut」にしました。

プロジェクトを作成したら、ダウンロードした画像をMonoGamePipeline Toolで登録してビルドしておいてください。

盤面が表示されるようにする

まずは、盤面が表示され、クリックしたらそのマスのみ点灯/消灯が切り替わるようなプログラムを書いてみます。
マスは5×5の2次元配列で管理します。マスは点灯と消灯の2種類の状態しかないので、bool型の配列にします。trueが点灯でfalseが消灯にしましょう。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace LightsOut
{
    /// <summary>
    /// This is the main type for your game.
    /// </summary>
    public class Game1 : Game
    {
        static readonly int ButtonSize = 64; // ボタンの大きさ(ピクセル数)
        static readonly int Rows = 5; // 行数
        static readonly int Columns = 5; // 列数

        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        Texture2D textureButtonOff;
        Texture2D textureButtonOn;
        Texture2D textureOrder;
        Texture2D textureGameClear;

        // 盤面の状況。true=光ってる、false=消えている
        bool[,] board = new bool[Rows, Columns];

        // 前フレームのマウスの状態
        MouseState prevMouse;

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

        /// <summary>
        /// Allows the game to perform any initialization it needs to before starting to run.
        /// This is where it can query for any required services and load any non-graphic
        /// related content.  Calling base.Initialize will enumerate through any components
        /// and initialize them as well.
        /// </summary>
        protected override void Initialize()
        {
            // TODO: Add your initialization logic here

            // マウスカーソルを表示する
            IsMouseVisible = true;

            base.Initialize();
        }

        /// <summary>
        /// LoadContent will be called once per game and is the place to load
        /// all of your content.
        /// </summary>
        protected override void LoadContent()
        {
            // Create a new SpriteBatch, which can be used to draw textures.
            spriteBatch = new SpriteBatch(GraphicsDevice);

            // TODO: use this.Content to load your game content here

            // 画像の読み込み
            textureButtonOff = Content.Load<Texture2D>("button_off");
            textureButtonOn  = Content.Load<Texture2D>("button_on");
            textureOrder     = Content.Load<Texture2D>("order");
            textureGameClear = Content.Load<Texture2D>("gameclear");
        }

        /// <summary>
        /// UnloadContent will be called once per game and is the place to unload
        /// game-specific content.
        /// </summary>
        protected override void UnloadContent()
        {
            // TODO: Unload any non ContentManager content here
        }

        /// <summary>
        /// Allows the game to run logic such as updating the world,
        /// checking for collisions, gathering input, and playing audio.
        /// </summary>
        /// <param name="gameTime">Provides a snapshot of timing values.</param>
        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();

            // TODO: Add your update logic here

            MouseState mouse = Mouse.GetState();

            // 左クリックされた場合(左ボタンが離された瞬間)
            if (mouse.LeftButton == ButtonState.Released && prevMouse.LeftButton == ButtonState.Pressed)
            {
                // マウスカーソルの位置をマス番号に変換する
                int x = mouse.X / ButtonSize;
                int y = mouse.Y / ButtonSize;

                if (IsInside(x, y))
                {
                    // とりあえず、クリックした場所を反転させてみる
                    board[x, y] = !board[x, y];
                }
            }

            prevMouse = mouse;

            base.Update(gameTime);
        }

        // 指定したマスが盤面の内側か?(はみ出してないか?)
        // 内側ならtrue, 盤面の外側ならfalseを返却する
        private bool IsInside(int x, int y)
        {
            return (x >= 0 && x < Columns && y >= 0 && y < Rows);
        }

        /// <summary>
        /// This is called when the game should draw itself.
        /// </summary>
        /// <param name="gameTime">Provides a snapshot of timing values.</param>
        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);

            // TODO: Add your drawing code here

            spriteBatch.Begin();
         
            // 盤面の状況を描画する。
            // 左から右へ1マスずつ描画していく。
            // 1行描画したら、1つ下の行へ。で、また左から右へ。
            for (int y = 0; y < Rows; y++)
            {
                for (int x = 0; x < Columns; x++)
                {
                    Texture2D texture;

                    // そのマスがtrueなら光ってる画像。falseなら光ってない画像。
                    if (board[x, y])
                    {
                        texture = textureButtonOn;
                    }
                    else
                    {
                        texture = textureButtonOff;
                    }

                    spriteBatch.Draw(texture, new Vector2(ButtonSize * x, ButtonSize * y), Color.White);
                }
            }

            spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}
動作確認してみてください。
とりあえず、 5×5のマスが表示され、クリックした箇所の点灯/消灯が切り替わります。

ここまで出来れば、もう半分完成したようなものです。

上下左右も連動して反転するようにする

上下左右も連動して反転させるには、単純に、クリックされたマスの上下左右にも同じような処理を適用してあげるだけです。ただ、いくつかコツがあります。

盤面の外をいじらないように注意する

たとえば、一番左のマスをクリックしたときに、そのさらに左のマスをいじろうとしてはいけません。そこにマスは存在しません。配列の範囲外にアクセスしようとするとエラーが発生してプログラムが強制終了します。なので、マスの中身を反転しようとする前に、そこが盤面の内側であることを確認する必要があります。

何度も行う処理をメソッドにまとめる

1度クリックが行われるたびに、「反転させたいマスが盤面の内側か調べ、内側であれば反転させる」という処理を5回行う必要があります。これを素直に5回書くとアホっぽいので、メソッドにまとめておくと、とてもスッキリします。

では、上記を踏まえて実装していきます。
        /// <summary>
        /// Allows the game to run logic such as updating the world,
        /// checking for collisions, gathering input, and playing audio.
        /// </summary>
        /// <param name="gameTime">Provides a snapshot of timing values.</param>
        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();

            // TODO: Add your update logic here

            MouseState mouse = Mouse.GetState();

            // 左クリックされた場合(左ボタンが離された瞬間)
            if (mouse.LeftButton == ButtonState.Released && prevMouse.LeftButton == ButtonState.Pressed)
            {
                // マウスカーソルの位置
                Point point = mouse.Position;

                // マウスカーソルの位置をマス番号に変換する
                int x = point.X / ButtonSize;
                int y = point.Y / ButtonSize;

                if (IsInside(x, y))
                {
                    Click(x, y);
                }
            }                      

            prevMouse = mouse;

            base.Update(gameTime);
        }

        // 指定したマスが盤面の内側か?(はみ出してないか?)
        // 内側ならtrue, 盤面の外側ならfalseを返却する
        private bool IsInside(int x, int y)
        {
            return (x >= 0 && x < Columns && y >= 0 && y < Rows);
        }

        // 指定した場所の内容を反転させる
        private void Flip(int x, int y)
        {
            // 盤面の内側ではない場合(外側である場合)は何もしない
            if (!IsInside(x, y))
                return;

            board[x, y] = !board[x, y];
        }

        // マスをクリックした時の処理。
        // クリックしたマスと、その上下左右の計5箇所を反転させる。
        private void Click(int x, int y)
        {
            Flip(x, y); // クリックした場所そのもの
            Flip(x - 1, y); // 左
            Flip(x + 1, y); // 右
            Flip(x, y - 1); // 上
            Flip(x, y + 1); // 下
        }

        /// <summary>
        /// This is called when the game should draw itself.
        /// </summary>
        /// <param name="gameTime">Provides a snapshot of timing values.</param>
        protected override void Draw(GameTime gameTime)
        {
だいぶ完成形に近づいてきました!
あと少しです。

盤面をランダムに初期化する

全てのマスを消灯させるのが、このゲームの目的です。現状、最初から全て消灯しているので、ゲームになりません。ゲーム開始時はランダムに点灯させたいと思います。
ここで注意なのが、本当に完全なランダムで点灯するマスを決めてしまうと、クリア不能なパターンになってしまうことがあるということです。
クリア不能パターンを防ぐために、先程作成したClickメソッドを、適当な回数、ランダムな場所に対して実行するという方法をとります。そうすれば、その逆順でクリックしていけば、必ずクリアはできるはずです。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System; // Randomを使うために必要
        /// <summary>
        /// Allows the game to perform any initialization it needs to before starting to run.
        /// This is where it can query for any required services and load any non-graphic
        /// related content.  Calling base.Initialize will enumerate through any components
        /// and initialize them as well.
        /// </summary>
        protected override void Initialize()
        {
            // TODO: Add your initialization logic here

            // マウスカーソルを表示する
            IsMouseVisible = true;

            // ランダムな盤面を生成する。
            // 本当にランダムに決めると、クリア不能になる可能性があるので、
            // 全て消灯の状態から、ランダムに疑似クリックしまくるという方法で
            // ランダムな盤面を生成する。

            Random rand = new Random();

            for (int i = 0; i < 100; i++)
            {
                int x = rand.Next(0, Columns);
                int y = rand.Next(0, Rows);
                Click(x, y);
            }

            base.Initialize();
        }
 実行してみてください。めちゃくちゃな盤面が表示されるはずです。

クリアの判定~完成

ゲームクリアの判定を追加します。全てのマスが消灯(false)であればクリアです。
また、「プレイ中」と「クリア後」という状態をゲームに持たせました。理由は2つあります。
  • プレイ中とクリア後では画面表示を変えたいため
  • クリア後は操作を受け付けないようにするため
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System; // Randomを使うために必要

namespace LightsOut
{
    // ゲームの状態種別
    enum State
    {
        Play,
        Clear,
    }

    /// <summary>
    /// This is the main type for your game.
    /// </summary>
    public class Game1 : Game
    {
        static readonly int ButtonSize = 64; // ボタンの大きさ(ピクセル数)
        static readonly int Rows = 5; // 行数
        static readonly int Columns = 5; // 列数

        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        Texture2D textureButtonOff;
        Texture2D textureButtonOn;
        Texture2D textureOrder;
        Texture2D textureGameClear;

        // 盤面の状況。true=光ってる、false=消えている
        bool[,] board = new bool[Rows, Columns];

        // 前フレームのマウスの状態
        MouseState prevMouse;

        // ゲームの状態
        State state = State.Play;

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

        /// <summary>
        /// Allows the game to perform any initialization it needs to before starting to run.
        /// This is where it can query for any required services and load any non-graphic
        /// related content.  Calling base.Initialize will enumerate through any components
        /// and initialize them as well.
        /// </summary>
        protected override void Initialize()
        {
            // TODO: Add your initialization logic here

            // マウスカーソルを表示する
            IsMouseVisible = true;

            // ランダムな盤面を生成する。
            // 本当にランダムに決めると、クリア不能になる可能性があるので、
            // 全て消灯の状態から、ランダムに疑似クリックしまくるという方法で
            // ランダムな盤面を生成する。

            Random rand = new Random();

            for (int i = 0; i < 100; i++)
            {
                int x = rand.Next(0, Columns);
                int y = rand.Next(0, Rows);
                Click(x, y);
            }

            base.Initialize();
        }

        /// <summary>
        /// LoadContent will be called once per game and is the place to load
        /// all of your content.
        /// </summary>
        protected override void LoadContent()
        {
            // Create a new SpriteBatch, which can be used to draw textures.
            spriteBatch = new SpriteBatch(GraphicsDevice);

            // TODO: use this.Content to load your game content here

            // 画像の読み込み
            textureButtonOff = Content.Load<Texture2D>("button_off");
            textureButtonOn  = Content.Load<Texture2D>("button_on");
            textureOrder     = Content.Load<Texture2D>("order");
            textureGameClear = Content.Load<Texture2D>("gameclear");
        }

        /// <summary>
        /// UnloadContent will be called once per game and is the place to unload
        /// game-specific content.
        /// </summary>
        protected override void UnloadContent()
        {
            // TODO: Unload any non ContentManager content here
        }

        /// <summary>
        /// Allows the game to run logic such as updating the world,
        /// checking for collisions, gathering input, and playing audio.
        /// </summary>
        /// <param name="gameTime">Provides a snapshot of timing values.</param>
        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();

            // TODO: Add your update logic here

            MouseState mouse = Mouse.GetState();

            if (state == State.Play)
            {
                // 左クリックされた場合(左ボタンが離された瞬間)
                if (mouse.LeftButton == ButtonState.Released && prevMouse.LeftButton == ButtonState.Pressed)
                {
                    // マウスカーソルの位置をマス番号に変換する
                    int x = mouse.X / ButtonSize;
                    int y = mouse.Y / ButtonSize;

                    if (IsInside(x, y))
                    {
                        Click(x, y);

                        // 全て消灯であれば、状態をClearにする
                        if (IsClear())
                        {
                            state = State.Clear;
                        }
                    }
                }
            }

            prevMouse = mouse;

            base.Update(gameTime);
        }

        // 指定したマスが盤面の内側か?(はみ出してないか?)
        // 内側ならtrue, 盤面の外側ならfalseを返却する
        private bool IsInside(int x, int y)
        {
            return (x >= 0 && x < Columns && y >= 0 && y < Rows);
        }

        // 指定した場所の内容を反転させる
        private void Flip(int x, int y)
        {
            // 盤面の外側であれば何もしない
            if (!IsInside(x, y))
                return;

            board[x, y] = !board[x, y];
        }

        // マスをクリックした時の処理。
        // クリックしたマスと、その上下左右の計5箇所を反転させる。
        private void Click(int x, int y)
        {
            Flip(x, y); // クリックした場所そのもの
            Flip(x - 1, y); // 左
            Flip(x + 1, y); // 右
            Flip(x, y - 1); // 上
            Flip(x, y + 1); // 下
        }

        // クリア(全て消灯した)か?
        // クリアならtrueを返却する。
        private bool IsClear()
        {
            for (int y = 0; y < Rows; y++)
            {
                for (int x = 0; x < Columns; x++)
                {
                    // 点灯しているものがあれば、クリアではないのでfalseを返却
                    if (board[x, y])
                    {
                        return false;
                    }
                }
            }

            // ここまで来たということは、点灯しているものが無い、
            // つまりクリアなのでtrueを返却
            return true;
        }

        /// <summary>
        /// This is called when the game should draw itself.
        /// </summary>
        /// <param name="gameTime">Provides a snapshot of timing values.</param>
        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);

            // TODO: Add your drawing code here

            spriteBatch.Begin();
            
            // 盤面の状況を描画する。
            // 左から右へ1マスずつ描画していく。
            // 1行描画したら、1つ下の行へ。で、また左から右へ。
            for (int y = 0; y < Rows; y++)
            {
                for (int x = 0; x < Columns; x++)
                {
                    Texture2D texture;

                    // そのマスがtrueなら光ってる画像。falseなら光ってない画像。
                    if (board[x, y])
                    {
                        texture = textureButtonOn;
                    }
                    else
                    {
                        texture = textureButtonOff;
                    }

                    spriteBatch.Draw(texture, new Vector2(ButtonSize * x, ButtonSize * y), Color.White);
                }
            }

            // ゲームの説明ないしクリアの表示
            if (state == State.Play)
            {
                spriteBatch.Draw(textureOrder, new Vector2(0, 350), Color.White);
            }
            else
            {
                spriteBatch.Draw(textureGameClear, new Vector2(0, 350), Color.White);
            }

            spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}
ゲームの完成です!お疲れ様でした!
伝統的なパズルゲームを楽しんでください。


しかしこのパズルゲーム、フツーに難しくて、実は私うまくクリアできません……
冒頭のキャプチャ動画は、攻略サイトを参考にしてクリアした時のものです……

0 件のコメント:

コメントを投稿