倉庫番

MonoGameで、往年の名作パズルゲーム「倉庫番」を作ってみましょう!






倉庫番の歴史



1982年にPCゲームとして発売されて人気を博し、その後、ありとあらゆるハードに移植されてきた定番パズルゲーム「倉庫番」。

どれくらい定番なのかは、こちらの公式サイトの年表を見るとわかります。

ありとあらゆるハードで発売され、数えきれないほどの作品数リリースされています・・・!!
定番だとは思ってたけど、まさかこれほどとは・・・

また、倉庫番を定番たらしめる理由としては他にも、RPGなどの他のゲームの中で倉庫番をパクっオマージュしたミニゲームが出てきたりすることも大きい気がします。

有名どころだとドラクエ3があります。


最近だとニーアオートマタにも出てきました。


倉庫番は何十年経っても、みんなにパクられる愛されるパズルゲームなのです。


倉庫番のルール

倉庫番のルールはとてもシンプル。

  • 倉庫内で、全ての荷物(箱)を目印のある場所に移動させればステージクリア。
  • プレイヤーは1マス単位の移動。
  • 箱は押せるけど、引くことはできない。
  • 1度に押せる箱は1つだけ。2個以上まとめて押すことはできない。

これだけです。
押せるのに引けないのがミソです。
一見簡単そうだけど、意外に思うようにいかなくて、歯がゆくて、面白いパズルゲームです。

シンプルなルールから、膨大なバリエーションのステージが生まれる、パズルゲームのお手本のようなゲームです。

今回はMonoGameでこの倉庫番を作っていきます。


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

ゲームに使う画像リソースをこちらからダウンロードしてください。

そしてzipファイルを解凍しておいてください。

今回使う画像はこの5種類。



プロジェクトの作成

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

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



マップについて

今回は、ステージのマップの情報はテキストで表現し、1つのステージにつき1つのテキストファイルを用意します。
ステージが大量にあるようなパズルゲームでは、わりと一般的な手法です。

で、マップの情報をどのようにテキストで表現するかなのですが、何やらXsokoban形式という業界標準(?)があるらしいので、それに従います。

例えば、

このようなマップを作りたい場合、以下のように書かれたテキストファイルを用意します。

########
#    . #
# @$   #
#### ###
#    $ #
#  .   #
########

これがXsokoban形式です。

半角スペース:床
#:壁
@:プレイヤー
$:荷物
.:ゴール
+:ゴールの上に人
*:ゴールの上に荷物

こちらから引用しました。

この手法でマップデータを用意しておくことによって、簡単にマップを編集・追加できます。



マップの作成~表示

マップデータの作成

まず、マップデータを格納するためのフォルダーを作ります。
ソリューションエクスプローラーのプロジェクト名のところを右クリックし、追加>新しいフォルダー を選択してください。
作成されたフォルダーをMapという名前にします。

作ったMapフォルダーを右クリックし、追加>新しい項目
「テキストファイル」を選び、名前は「map1.txt」として「追加」を押します。

こうなってますか?

次は、map1.txtを右クリックし、「プロパティ」を選択します。
プロパティ画面で、「出力ディレクトリにコピー」を「新しい場合はコピーする」にします。
これによって、map1.txtが、出力ディレクトリにコピーされるようになります。出力ディレクトリというのは、ビルド結果のexeファイルが出力される場所です。マップ情報のファイルもそこにある必要があります。でないと読めません。

設定がうまくいったか確認してみましょう。
F5で実行するか、「ソリューションのビルド」などを行ってください。
その後、Windowsのエクスプローラーで、プロジェクトのフォルダーを開いてください。(ソリューションエクスプローラーから右クリックして「エクスプローラーでフォルダーを開く」とすると早い)

プロジェクトフォルダーの中にbinフォルダーが生成されており、それを開いていくと、
bin>Windows>x86>Debugとなります。
Debugフォルダーの中身が、ゲームの実行に必要な全てです。exeファイルもここにありますね。
そして、そこにMapフォルダーがあり、その中にmap1.txtがあることを確認してください。

確認が取れたら、Visual Studioに戻って、マップを作りましょう。
ソリューションエクスプローラーから、map1.txtを開いてください。

まずは、確認用の簡単なマップを作りましょう。
次のテキストを入力してください(コピペでも良いです)

#######
# @   #
# $.. #
# $   #
#     #
#######


マップデータの読み込み&表示

ちょっと長いですが、マップデータを読み込んで、画面に表示するまでを一気に書いていきます。

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System.IO; // ファイル読み込みのために必要

namespace Sokoban
{
    public class Game1 : Game
    {
        // マップのマスの種別
        enum Map
        {
            None, // 壁でもゴールでもないマス
            Wall, // 壁
            Goal, // ゴール
        }

        // マスに箱があるかどうかの種別
        enum Box
        {
            None, // 箱が無い
            Exists, // 箱がある
        }

        const int MapWidth = 25; // マップの幅
        const int MapHeight = 15; // マップの高さ
        const int CellSize = 32; // 1マスの大きさ(ピクセル数)

        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        Map[,] map; // マップ情報(壁やゴールの情報)
        Box[,] box; // 箱がどこにあるかの情報

        int playerX; // プレイヤーのX座標
        int playerY; // プレイヤーのY座標

        Texture2D textureWall; // 壁のテクスチャ
        Texture2D textureBox; // 箱のテクスチャ
        Texture2D textureGoal; // ゴールのテクスチャ
        Texture2D texturePlayer; // プレイヤーのテクスチャ
        Texture2D textureClear; // 「クリア!」のテクスチャ

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";
        }
        
        protected override void Initialize()
        {
            // TODO: Add your initialization logic here

            base.Initialize();
        }
     
        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

            // テクスチャーの読み込み
            textureWall = Content.Load<Texture2D>("wall");
            textureBox = Content.Load<Texture2D>("box");
            textureGoal = Content.Load<Texture2D>("goal");
            texturePlayer = Content.Load<Texture2D>("pacchi");
            textureClear = Content.Load<Texture2D>("clear");

            LoadMap(1);
        }
     
        protected override void UnloadContent()
        {
        }

        // マップ情報をテキストファイルから読み込む
        // mapId : マップの番号
        void LoadMap(int mapId)
        {
            // マップ情報格納用の2次元配列を生成
            map = new Map[MapWidth, MapHeight];
            box = new Box[MapWidth, MapHeight];

            // ファイル名を指定して、マップデータを読み込む。
            // ReadAllLines()を使うと、1行ごとの配列で読み込まれる。
            string[] lines = File.ReadAllLines("Map/map" + mapId + ".txt");

            // 行のループ
            for (int y = 0; y < lines.Length; y++)
            {
                // 見たい1行分の文字列を取り出す
                string line = lines[y];

                // 文字のループ
                for (int x = 0; x < line.Length; x++)
                {
                    // 文字列からx文字目の文字を取り出す。
                    char c = line[x];

                    // 取り出した文字に応じて、マップ情報やプレイヤーの位置などをセットする
                    switch (c)
                    {
                        case ' ': // 何もない場所
                            map[x, y] = Map.None;
                            break;
                        case '#': // 壁
                            map[x, y] = Map.Wall;
                            break;
                        case '@': // プレイヤー
                            playerX = x;
                            playerY = y;
                            break;
                        case '$': // 箱
                            box[x, y] = Box.Exists;
                            break;
                        case '.': // ゴール
                            map[x, y] = Map.Goal;
                            break;
                        case '+': // ゴールの上にプレイヤーが乗ってる
                            map[x, y] = Map.Goal;
                            playerX = x;
                            playerY = y;
                            break;
                        case '*': // ゴールの上に箱が乗ってる
                            map[x, y] = Map.Goal;
                            box[x, y] = Box.Exists;
                            break;
                        default:
                            throw new System.Exception("不正な文字が混入してます:" + c);
                    }
                }
            }
        }

        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

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

            // TODO: Add your drawing code here

            spriteBatch.Begin();

            // マップ情報の描画。
            // 2次元配列を1マスずつ走査して、壁やゴールを描画していく
            for (int y = 0; y < MapHeight; y++)
            {
                for (int x = 0; x < MapWidth; x++)
                {
                    // 描画位置を計算
                    Vector2 position = new Vector2(CellSize * x, CellSize * y);

                    // 今調べているマスが壁だったら壁を描画
                    if (map[x, y] == Map.Wall)
                    {
                        spriteBatch.Draw(textureWall, position, Color.White);
                    }
                    // 今調べているマスがゴールだったらゴールを描画
                    else if (map[x, y] == Map.Goal)
                    {
                        spriteBatch.Draw(textureGoal, position, Color.White);
                    }

                    // 今調べているマスに箱があれば、箱を描画する
                    if (box[x, y] == Box.Exists)
                    {
                        spriteBatch.Draw(textureBox, position, Color.White);
                    }
                }
            }

            // プレイヤーの描画位置を計算
            Vector2 playerPosition = new Vector2(CellSize * playerX, CellSize * playerY);
            // プレイヤーを描画
            spriteBatch.Draw(texturePlayer, playerPosition, Color.White);

            spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}




解説

マップ情報を格納するために、2次元配列を2つ使っています。
        Map[,] map; // マップ情報(壁やゴールの情報)
        Box[,] box; // 箱がどこにあるかの情報
配列mapは、壁やゴールなどの地形データを格納するためのもの。
配列boxは、箱がどこにあるのかを格納するためのものです。

上記の全ての情報を1つの配列で表現することも可能ではありますが、そうするとプログラムが煩雑になるので、分けました。

配列の型はint型でも良かったっちゃあ良かったのですが、enumでMap型やBox型を作って、それを使っています。そのほうが凡ミスが防げます。int型だと無関係な値を誤って入れることができちゃうので。

箱は「ある」か「ない」の2択なのだから、わざわざenumで専用の型を作らなくても、bool型で良いのでは?と思うかもしれませんが、のちのちのためにenumにしておきました。

マップのテキストファイルの読み込みにはFile.ReadAllLines()を使っています。
string[] lines = File.ReadAllLines("Map/map" + mapId + ".txt");
これは、指定された名前のファイルを、行単位で読み取る機能です。結果はstring型の配列に行ごとに格納されます。
ファイルがフォルダーの中に入っている場合は、上述のようにスラッシュで区切ればOKです。

今回はマップデータを行ごとに読み取って、さらに1文字ずつ読み取りたいので、次のようにしています。
string[] lines = File.ReadAllLines("Map/map" + mapId + ".txt");

// 行のループ
for (int y = 0; y < lines.Length; y++)
{
    // 見たい1行分の文字列を取り出す
    string line = lines[y];

    // 文字のループ
    for (int x = 0; x < line.Length; x++)
    {
        // 文字列からx文字目の文字を取り出す。
        char c = line[x];

        // 取り出した文字に応じて、マップ情報やプレイヤーの位置などをセットする
        ...
    }
}
文字列から1文字だけ取り出したいときは、まるで配列から要素を取り出すように、文字列[index]で指定します。indexは何文字目かの指定です。これで文字(char型)が取り出せます。

あとは、取り出した文字に応じて、配列mapや配列boxにデータを格納しています。
なお、文字(char型)をソースコード中に記述するときは、シングルクォーテーションで囲みます。覚えておいてください。

下記の記述は、例外というものです。
throw new System.Exception("不正な文字が混入してます:" + c);
例外とは、おおざっぱにいうと、エラーのことです。
どの条件にも該当しない不正な文字がマップデータに混入していた場合は、エラーを発生させるということです。
試しに、map1.txtを改変し、Xsokoban形式に無い文字を書き加えてみてください。
実行時にエラーが発生します。

人間は絶対に間違いを犯すので、それを前提に、間違えたらすぐに気付けるようにプログラムを組むのは大事なことです。
初心者は、書いたプログラムが動かないことを恐れますが、本当に恐ろしいのは、間違っているのに動いてしまうプログラムです。
後になればなるほど、ミスの発見や修正が困難になります。

マップを増やしてみよう

せっかく外部からマップデータを読み取れるようにしたのに、データが1つしかないのでは、ありがたみがありません。もっとマップデータを追加してみましょう。

先ほどmap1.txtを追加したときと同様の手順で、map2.txtを追加してください。
(ソリューションエクスプローラーでMapフォルダーを右クリックし、追加>新しい項目>テキストファイル)

追加後は、map2.txtのプロパティを開き、次の設定を行うのをお忘れなく。


map2.txtには次のように記述してみましょう。
#######
# @   #
#  .$.#
####$ #
   #  #
   ####

そしたら、マップを読み込むLoadMapメソッドに渡している値(マップ番号)を2に変えて、実行してみましょう。
        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

            // テクスチャーの読み込み
            textureWall = Content.Load<Texture2D>("wall");
            textureBox = Content.Load<Texture2D>("box");
            textureGoal = Content.Load<Texture2D>("goal");
            texturePlayer = Content.Load<Texture2D>("pacchi");
            textureClear = Content.Load<Texture2D>("clear");

            LoadMap(2);
        }

先ほどとは違うマップが表示されます!
 




プレイヤーの動作の作成

次はプレイヤーの動作を作っていきましょう。
今回、プレイヤーの位置は、playerXとplayerYという変数に格納しています。なので、上下左右キーが押されたら、この変数を1ずつ動かしてあげれば良いです。
まずは壁とか箱とかは無視して移動させてみましょう。

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System.IO; // ファイル読み込みのために必要

namespace Sokoban
{
    public class Game1 : Game
    {
        // マップのマスの種別
        enum Map
        {
            None, // 壁でもゴールでもないマス
            Wall, // 壁
            Goal, // ゴール
        }

        // マスに箱があるかどうかの種別
        enum Box
        {
            None, // 箱が無い
            Exists, // 箱がある
        }

        const int MapWidth = 25; // マップの幅
        const int MapHeight = 15; // マップの高さ
        const int CellSize = 32; // 1マスの大きさ(ピクセル数)

        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        Map[,] map; // マップ情報(壁やゴールの情報)
        Box[,] box; // 箱がどこにあるかの情報

        int playerX; // プレイヤーのX座標
        int playerY; // プレイヤーのY座標

        Texture2D textureWall; // 壁のテクスチャ
        Texture2D textureBox; // 箱のテクスチャ
        Texture2D textureGoal; // ゴールのテクスチャ
        Texture2D texturePlayer; // プレイヤーのテクスチャ
        Texture2D textureClear; // 「クリア!」のテクスチャ

        KeyboardState prevKeyboardState; // 1フレーム前のキーボードの押下状況

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";
        }
        
        protected override void Initialize()
        {
            // TODO: Add your initialization logic here

            base.Initialize();
        }
        
        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

            // テクスチャーの読み込み
            textureWall = Content.Load<Texture2D>("wall");
            textureBox = Content.Load<Texture2D>("box");
            textureGoal = Content.Load<Texture2D>("goal");
            texturePlayer = Content.Load<Texture2D>("pacchi");
            textureClear = Content.Load<Texture2D>("clear");

            LoadMap(2);
        }
        
        protected override void UnloadContent()
        {
        }

        // マップ情報をテキストファイルから読み込む
        // mapId : マップの番号
        void LoadMap(int mapId)
        {
            // マップ情報格納用の2次元配列を生成
            map = new Map[MapWidth, MapHeight];
            box = new Box[MapWidth, MapHeight];

            // ファイル名を指定して、マップデータを読み込む。
            // ReadAllLines()を使うと、1行ごとの配列で読み込まれる。
            string[] lines = File.ReadAllLines("Map/map" + mapId + ".txt");

            // 行のループ
            for (int y = 0; y < lines.Length; y++)
            {
                // 見たい1行分の文字列を取り出す
                string line = lines[y];

                // 文字のループ
                for (int x = 0; x < line.Length; x++)
                {
                    // 文字列からx文字目の文字を取り出す。
                    char c = line[x];

                    // 取り出した文字に応じて、マップ情報やプレイヤーの位置などをセットする
                    switch (c)
                    {
                        case ' ': // 何もない場所
                            map[x, y] = Map.None;
                            break;
                        case '#': // 壁
                            map[x, y] = Map.Wall;
                            break;
                        case '@': // プレイヤー
                            playerX = x;
                            playerY = y;
                            break;
                        case '$': // 箱
                            box[x, y] = Box.Exists;
                            break;
                        case '.': // ゴール
                            map[x, y] = Map.Goal;
                            break;
                        case '+': // ゴールの上にプレイヤーが乗ってる
                            map[x, y] = Map.Goal;
                            playerX = x;
                            playerY = y;
                            break;
                        case '*': // ゴールの上に箱が乗ってる
                            map[x, y] = Map.Goal;
                            box[x, y] = Box.Exists;
                            break;
                        default:
                            throw new System.Exception("不正な文字が混入してます:" + c);
                    }
                }
            }
        }

        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

            // キーボードの押下状況を取得
            KeyboardState keyboardState = Keyboard.GetState();

            // 左が押されたら左へ移動
            if (prevKeyboardState.IsKeyUp(Keys.Left) && keyboardState.IsKeyDown(Keys.Left)) Move(-1, 0);
            // 右が押されたら右へ移動
            else if (prevKeyboardState.IsKeyUp(Keys.Right) && keyboardState.IsKeyDown(Keys.Right)) Move(1, 0);
            // 上が押されたら上へ移動
            else if (prevKeyboardState.IsKeyUp(Keys.Up) && keyboardState.IsKeyDown(Keys.Up)) Move(0, -1);
            // 下が押されたら下へ移動
            else if (prevKeyboardState.IsKeyUp(Keys.Down) && keyboardState.IsKeyDown(Keys.Down)) Move(0, 1);
         
            // 次のフレームのために、現在のキーボードの押下状況を保存しておく
            prevKeyboardState = keyboardState;

            base.Update(gameTime);
        }

        // プレイヤー移動処理
        // x : 横の移動量
        // y : 縦の移動量
        void Move(int x, int y)
        {
            // 仮処理
            playerX += x;
            playerY += y;
        }

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

            // TODO: Add your drawing code here

            spriteBatch.Begin();

            // マップ情報の描画。
            // 2次元配列を1マスずつ走査して、壁やゴールを描画していく
            for (int y = 0; y < MapHeight; y++)
            {
                for (int x = 0; x < MapWidth; x++)
                {
                    // 描画位置を計算
                    Vector2 position = new Vector2(CellSize * x, CellSize * y);

                    // 今調べているマスが壁だったら壁を描画
                    if (map[x, y] == Map.Wall)
                    {
                        spriteBatch.Draw(textureWall, position, Color.White);
                    }
                    // 今調べているマスがゴールだったらゴールを描画
                    else if (map[x, y] == Map.Goal)
                    {
                        spriteBatch.Draw(textureGoal, position, Color.White);
                    }

                    // 今調べているマスに箱があれば、箱を描画する
                    if (box[x, y] == Box.Exists)
                    {
                        spriteBatch.Draw(textureBox, position, Color.White);
                    }
                }
            }

            // プレイヤーの描画位置を計算
            Vector2 playerPosition = new Vector2(CellSize * playerX, CellSize * playerY);
            // プレイヤーを描画
            spriteBatch.Draw(texturePlayer, playerPosition, Color.White);

            spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}



キーボードの矢印キーで1マスずつ移動できるようになりました。
ここまでは簡単ですね。

では、今から、以下のルールをちゃんと作り込んでいきます:

  • 壁は通れない
  • 箱は押せる
  • でも箱の奥に壁や箱がある場合は押せない

Moveメソッドを次のように改修します。
        // プレイヤー移動処理
        // x : 横の移動量
        // y : 縦の移動量
        void Move(int x, int y)
        {
            // 一歩先の座標
            int destX = playerX + x;
            int destY = playerY + y;

            // 一歩先が壁の場合
            if (map[destX, destY] == Map.Wall)
            {
                // 何もしない
                return;
            }

            // 一歩先が箱の場合
            if (box[destX, destY] == Box.Exists)
            {
                // 二歩先の座標
                int dest2X = destX + x;
                int dest2Y = destY + y;

                // 二歩先が空きスペースではない場合
                if (map[dest2X, dest2Y] == Map.Wall || box[dest2X, dest2Y] == Box.Exists)
                {
                    // 何もしない
                    return;
                }

                // 箱を元あった場所から除去
                box[destX, destY] = Box.None;

                // 箱を二歩先に移動させる
                box[dest2X, dest2Y] = Box.Exists;
            }

            // プレイヤーを移動させる
            playerX = destX;
            playerY = destY;
        }




倉庫番の基本的な動きができました。
これでもう8割がた完成したようなものです。



ゴールに置いた箱の色を変える

現状、箱をゴールの上に置くと、ゴールのしるしが見えなくなるため、箱がゴールに乗ってるのかどうか、よくわからなくなっちゃいますね。
そこで、ゴールに乗った箱の色を変えてあげましょう。

これは簡単です。
Drawメソッド内の、箱を描画している部分を少し書き換えます。

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

            // TODO: Add your drawing code here

            spriteBatch.Begin();

            // マップ情報の描画。
            // 2次元配列を1マスずつ走査して、壁やゴールを描画していく
            for (int y = 0; y < MapHeight; y++)
            {
                for (int x = 0; x < MapWidth; x++)
                {
                    // 描画位置を計算
                    Vector2 position = new Vector2(CellSize * x, CellSize * y);

                    // 今調べているマスが壁だったら壁を描画
                    if (map[x, y] == Map.Wall)
                    {
                        spriteBatch.Draw(textureWall, position, Color.White);
                    }
                    // 今調べているマスがゴールだったらゴールを描画
                    else if (map[x, y] == Map.Goal)
                    {
                        spriteBatch.Draw(textureGoal, position, Color.White);
                    }

                    // 今調べているマスに箱があれば、箱を描画する
                    if (box[x, y] == Box.Exists)
                    {
                        // 箱の下がゴールなら、黄色く描画
                        if (map[x, y] == Map.Goal)
                        {
                            spriteBatch.Draw(textureBox, position, Color.Yellow);
                        }
                        // そうでなければ普通に描画
                        else
                        {
                            spriteBatch.Draw(textureBox, position, Color.White);
                        }
                    }
                }
            }

            // プレイヤーの描画位置を計算
            Vector2 playerPosition = new Vector2(CellSize * playerX, CellSize * playerY);
            // プレイヤーを描画
            spriteBatch.Draw(texturePlayer, playerPosition, Color.White);
            
            // 「クリア!」の描画
            if (state == State.Solved)
            {
                spriteBatch.Draw(textureClear, new Vector2(20, 400), Color.White);
            }

            spriteBatch.End();

            base.Draw(gameTime);
        }





クリアの判定

ゲームクリアの判定を作りましょう。

クリア後も操作できちゃうと変なので、その制御も追加しました。

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System.IO; // ファイル読み込みのために必要

namespace Sokoban
{
    public class Game1 : Game
    {
        // マップのマスの種別
        enum Map
        {
            None, // 壁でもゴールでもないマス
            Wall, // 壁
            Goal, // ゴール
        }

        // マスに箱があるかどうかの種別
        enum Box
        {
            None, // 箱が無い
            Exists, // 箱がある
        }

        // ゲームの状態種別
        enum State
        {
            Play, // プレイ中
            Solved, // クリア
        }

        const int MapWidth = 25; // マップの幅
        const int MapHeight = 15; // マップの高さ
        const int CellSize = 32; // 1マスの大きさ(ピクセル数)

        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        Map[,] map; // マップ情報(壁やゴールの情報)
        Box[,] box; // 箱がどこにあるかの情報

        int playerX; // プレイヤーのX座標
        int playerY; // プレイヤーのY座標

        Texture2D textureWall; // 壁のテクスチャ
        Texture2D textureBox; // 箱のテクスチャ
        Texture2D textureGoal; // ゴールのテクスチャ
        Texture2D texturePlayer; // プレイヤーのテクスチャ
        Texture2D textureClear; // 「クリア!」のテクスチャ

        KeyboardState prevKeyboardState; // 1フレーム前のキーボードの押下状況
        State state = State.Play; // ゲームの状態

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

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

            base.Initialize();
        }

        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

            // テクスチャーの読み込み
            textureWall = Content.Load<Texture2D>("wall");
            textureBox = Content.Load<Texture2D>("box");
            textureGoal = Content.Load<Texture2D>("goal");
            texturePlayer = Content.Load<Texture2D>("pacchi");
            textureClear = Content.Load<Texture2D>("clear");

            LoadMap(2);
        }

        protected override void UnloadContent()
        {
        }

        // マップ情報をテキストファイルから読み込む
        // mapId : マップの番号
        void LoadMap(int mapId)
        {
            // マップ情報格納用の2次元配列を生成
            map = new Map[MapWidth, MapHeight];
            box = new Box[MapWidth, MapHeight];

            // ファイル名を指定して、マップデータを読み込む。
            // ReadAllLines()を使うと、1行ごとの配列で読み込まれる。
            string[] lines = File.ReadAllLines("Map/map" + mapId + ".txt");

            // 行のループ
            for (int y = 0; y < lines.Length; y++)
            {
                // 見たい1行分の文字列を取り出す
                string line = lines[y];

                // 文字のループ
                for (int x = 0; x < line.Length; x++)
                {
                    // 文字列からx文字目の文字を取り出す。
                    char c = line[x];

                    // 取り出した文字に応じて、マップ情報やプレイヤーの位置などをセットする
                    switch (c)
                    {
                        case ' ': // 何もない場所
                            map[x, y] = Map.None;
                            break;
                        case '#': // 壁
                            map[x, y] = Map.Wall;
                            break;
                        case '@': // プレイヤー
                            playerX = x;
                            playerY = y;
                            break;
                        case '$': // 箱
                            box[x, y] = Box.Exists;
                            break;
                        case '.': // ゴール
                            map[x, y] = Map.Goal;
                            break;
                        case '+': // ゴールの上にプレイヤーが乗ってる
                            map[x, y] = Map.Goal;
                            playerX = x;
                            playerY = y;
                            break;
                        case '*': // ゴールの上に箱が乗ってる
                            map[x, y] = Map.Goal;
                            box[x, y] = Box.Exists;
                            break;
                        default:
                            throw new System.Exception("不正な文字が混入してます:" + c);
                    }
                }
            }
        }

        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

            // キーボードの押下状況を取得
            KeyboardState keyboardState = Keyboard.GetState();

            // プレイ中か?
            if (state == State.Play)
            {
                // 左が押されたら左へ移動
                if (prevKeyboardState.IsKeyUp(Keys.Left) && keyboardState.IsKeyDown(Keys.Left)) Move(-1, 0);
                // 右が押されたら右へ移動
                else if (prevKeyboardState.IsKeyUp(Keys.Right) && keyboardState.IsKeyDown(Keys.Right)) Move(1, 0);
                // 上が押されたら上へ移動
                else if (prevKeyboardState.IsKeyUp(Keys.Up) && keyboardState.IsKeyDown(Keys.Up)) Move(0, -1);
                // 下が押されたら下へ移動
                else if (prevKeyboardState.IsKeyUp(Keys.Down) && keyboardState.IsKeyDown(Keys.Down)) Move(0, 1);

                if (IsSolved())
                {
                    state = State.Solved;
                }
            }

            // 次のフレームのために、現在のキーボードの押下状況を保存しておく
            prevKeyboardState = keyboardState;

            base.Update(gameTime);
        }

        // プレイヤー移動処理
        // x : 横の移動量
        // y : 縦の移動量
        void Move(int x, int y)
        {
            // 一歩先の座標
            int destX = playerX + x;
            int destY = playerY + y;

            // 一歩先が壁の場合
            if (map[destX, destY] == Map.Wall)
            {
                // 何もしない
                return;
            }

            // 一歩先が箱の場合
            if (box[destX, destY] == Box.Exists)
            {
                // 二歩先の座標
                int dest2X = destX + x;
                int dest2Y = destY + y;

                // 二歩先が空きスペースではない場合
                if (map[dest2X, dest2Y] == Map.Wall || box[dest2X, dest2Y] == Box.Exists)
                {
                    // 何もしない
                    return;
                }

                // 箱を元あった場所から除去
                box[destX, destY] = Box.None;

                // 箱を二歩先に移動させる
                box[dest2X, dest2Y] = Box.Exists;
            }

            // プレイヤーを移動させる
            playerX = destX;
            playerY = destY;
        }

        // クリアしたか判定する。クリアならtrueを返却する。
        bool IsSolved()
        {
            // 全てのマスを調査する
            for (int y = 0; y < MapHeight; y++)
            {
                for (int x = 0; x < MapWidth; x++)
                {
                    // 箱の下がゴールではない場合は、クリアではないのでfalseを返却
                    if (box[x, y] == Box.Exists && map[x, y] != Map.Goal)
                    {
                        return false;
                    }
                }
            }

            // ここまで到達したということは、全ての箱がゴールに乗っている、
            // つまりクリアということなのでtrueを返却。
            return true;
        }

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

            // TODO: Add your drawing code here

            spriteBatch.Begin();

            // マップ情報の描画。
            // 2次元配列を1マスずつ走査して、壁やゴールを描画していく
            for (int y = 0; y < MapHeight; y++)
            {
                for (int x = 0; x < MapWidth; x++)
                {
                    // 描画位置を計算
                    Vector2 position = new Vector2(CellSize * x, CellSize * y);

                    // 今調べているマスが壁だったら壁を描画
                    if (map[x, y] == Map.Wall)
                    {
                        spriteBatch.Draw(textureWall, position, Color.White);
                    }
                    // 今調べているマスがゴールだったらゴールを描画
                    else if (map[x, y] == Map.Goal)
                    {
                        spriteBatch.Draw(textureGoal, position, Color.White);
                    }

                    // 今調べているマスに箱があれば、箱を描画する
                    if (box[x, y] == Box.Exists)
                    {
                        // 箱の下がゴールなら、黄色く描画
                        if (map[x, y] == Map.Goal)
                        {
                            spriteBatch.Draw(textureBox, position, Color.Yellow);
                        }
                        // そうでなければ普通に描画
                        else
                        {
                            spriteBatch.Draw(textureBox, position, Color.White);
                        }
                    }
                }
            }

            // プレイヤーの描画位置を計算
            Vector2 playerPosition = new Vector2(CellSize * playerX, CellSize * playerY);
            // プレイヤーを描画
            spriteBatch.Draw(texturePlayer, playerPosition, Color.White);
            
            // 「クリア!」の描画
            if (state == State.Solved)
            {
                spriteBatch.Draw(textureClear, new Vector2(20, 400), Color.White);
            }

            spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}


「Solve」というのは、「解決する」とか「解く」とかいう意味の単語です。
パズルゲームでよく使います。

実行して、以下のことを確認しましょう。

  • 箱を全部ゴールに置くと、「クリア!」と表示されること
  • クリア後は動けないこと


まあ、これで倉庫番は基本的に完成なのですが、あとは機能をいくつか追加していきたいと思います。




ステージ切り替え機能

ステージを切り替えたり、同じステージを最初からやり直したりする機能を追加してみましょう。

まずはマップデータの追加

現状、マップデータは2個しか作っていないと思うので、まず全部で5個にしましょう。つまり、あと3個作りましょう。

下記にサンプルを載せます。そのまま使っても良いですし、自分のオリジナルステージを作っても良いですよ。


map3.txt
#########
#      ##
# # # # #
#  +$.  #
# #$#$# #
## .$.  #
# # # # #
##      #
#########


map4.txt
########
#    . #
# @$   #
#### ###
#    $ #
#  .   #
######## 


map5.txt
  #####
  #   #
### # #
# $$* #
# # . #
#@  .##
###  #
  ####


マップデータを追加したら、プロパティから「新しい場合はコピーする」にするのを忘れないようにしてください。


ステージ切り替え機能の追加

キーボードの1キーを押したら、前のステージ、
キーボードの2キーを押したら、現在のステージをやり直し、
キーボードの3キーを押したら、次のステージ
というふうにします。

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System.IO; // ファイル読み込みのために必要

namespace Sokoban
{
    public class Game1 : Game
    {
        // マップのマスの種別
        enum Map
        {
            None, // 壁でもゴールでもないマス
            Wall, // 壁
            Goal, // ゴール
        }

        // マスに箱があるかどうかの種別
        enum Box
        {
            None, // 箱が無い
            Exists, // 箱がある
        }

        // ゲームの状態種別
        enum State
        {
            Play, // プレイ中
            Solved, // クリア
        }

        const int MapWidth = 25; // マップの幅
        const int MapHeight = 15; // マップの高さ
        const int CellSize = 32; // 1マスの大きさ(ピクセル数)
        const int MapIdMax = 5; // マップ番号の最大値

        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        Map[,] map; // マップ情報(壁やゴールの情報)
        Box[,] box; // 箱がどこにあるかの情報

        int playerX; // プレイヤーのX座標
        int playerY; // プレイヤーのY座標

        Texture2D textureWall; // 壁のテクスチャ
        Texture2D textureBox; // 箱のテクスチャ
        Texture2D textureGoal; // ゴールのテクスチャ
        Texture2D texturePlayer; // プレイヤーのテクスチャ
        Texture2D textureClear; // 「クリア!」のテクスチャ

        KeyboardState prevKeyboardState; // 1フレーム前のキーボードの押下状況
        State state = State.Play; // ゲームの状態
        int currentMapId = 1; // 現在のマップ番号

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

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

            base.Initialize();
        }

        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

            // テクスチャーの読み込み
            textureWall = Content.Load<Texture2D>("wall");
            textureBox = Content.Load<Texture2D>("box");
            textureGoal = Content.Load<Texture2D>("goal");
            texturePlayer = Content.Load<Texture2D>("pacchi");
            textureClear = Content.Load<Texture2D>("clear");

            // LoadMap(2); ←削除
            StartGame(currentMapId);
        }

        // ゲーム開始
        void StartGame(int mapId)
        {
            state = State.Play;
            LoadMap(mapId);
        }

        protected override void UnloadContent()
        {
        }

        // マップ情報をテキストファイルから読み込む
        // mapId : マップの番号
        void LoadMap(int mapId)
        {
            // マップ情報格納用の2次元配列を生成
            map = new Map[MapWidth, MapHeight];
            box = new Box[MapWidth, MapHeight];

            // ファイル名を指定して、マップデータを読み込む。
            // ReadAllLines()を使うと、1行ごとの配列で読み込まれる。
            string[] lines = File.ReadAllLines("Map/map" + mapId + ".txt");

            // 行のループ
            for (int y = 0; y < lines.Length; y++)
            {
                // 見たい1行分の文字列を取り出す
                string line = lines[y];

                // 文字のループ
                for (int x = 0; x < line.Length; x++)
                {
                    // 文字列からx文字目の文字を取り出す。
                    char c = line[x];

                    // 取り出した文字に応じて、マップ情報やプレイヤーの位置などをセットする
                    switch (c)
                    {
                        case ' ': // 何もない場所
                            map[x, y] = Map.None;
                            break;
                        case '#': // 壁
                            map[x, y] = Map.Wall;
                            break;
                        case '@': // プレイヤー
                            playerX = x;
                            playerY = y;
                            break;
                        case '$': // 箱
                            box[x, y] = Box.Exists;
                            break;
                        case '.': // ゴール
                            map[x, y] = Map.Goal;
                            break;
                        case '+': // ゴールの上にプレイヤーが乗ってる
                            map[x, y] = Map.Goal;
                            playerX = x;
                            playerY = y;
                            break;
                        case '*': // ゴールの上に箱が乗ってる
                            map[x, y] = Map.Goal;
                            box[x, y] = Box.Exists;
                            break;
                        default:
                            throw new System.Exception("不正な文字が混入してます:" + c);
                    }
                }
            }
        }

        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

            // キーボードの押下状況を取得
            KeyboardState keyboardState = Keyboard.GetState();

            // プレイ中か?
            if (state == State.Play)
            {
                // 左が押されたら左へ移動
                if (prevKeyboardState.IsKeyUp(Keys.Left) && keyboardState.IsKeyDown(Keys.Left)) Move(-1, 0);
                // 右が押されたら右へ移動
                else if (prevKeyboardState.IsKeyUp(Keys.Right) && keyboardState.IsKeyDown(Keys.Right)) Move(1, 0);
                // 上が押されたら上へ移動
                else if (prevKeyboardState.IsKeyUp(Keys.Up) && keyboardState.IsKeyDown(Keys.Up)) Move(0, -1);
                // 下が押されたら下へ移動
                else if (prevKeyboardState.IsKeyUp(Keys.Down) && keyboardState.IsKeyDown(Keys.Down)) Move(0, 1);

                if (IsSolved())
                {
                    state = State.Solved;
                }
            }
         
            // 1キーを押すと、ひとつ前のステージを読み込む
            if (prevKeyboardState.IsKeyUp(Keys.D1) && keyboardState.IsKeyDown(Keys.D1))
            {
                if (currentMapId > 1)
                {
                    currentMapId--;
                    StartGame(currentMapId);
                }
            }
            // 2キーを押すと、現在のステージを読み込み直す
            if (prevKeyboardState.IsKeyUp(Keys.D2) && keyboardState.IsKeyDown(Keys.D2))
            {
                StartGame(currentMapId);
            }
            // 3キーを押すと、次のステージを読み込む
            if (prevKeyboardState.IsKeyUp(Keys.D3) && keyboardState.IsKeyDown(Keys.D3))
            {
                if (currentMapId < MapIdMax)
                {
                    currentMapId++;
                    StartGame(currentMapId);
                }
            }

            // 次のフレームのために、現在のキーボードの押下状況を保存しておく
            prevKeyboardState = keyboardState;

            base.Update(gameTime);
        }

        // プレイヤー移動処理
        // x : 横の移動量
        // y : 縦の移動量
        void Move(int x, int y)
        {
            // 一歩先の座標
            int destX = playerX + x;
            int destY = playerY + y;

            // 一歩先が壁の場合
            if (map[destX, destY] == Map.Wall)
            {
                // 何もしない
                return;
            }

            // 一歩先が箱の場合
            if (box[destX, destY] == Box.Exists)
            {
                // 二歩先の座標
                int dest2X = destX + x;
                int dest2Y = destY + y;

                // 二歩先が空きスペースではない場合
                if (map[dest2X, dest2Y] == Map.Wall || box[dest2X, dest2Y] == Box.Exists)
                {
                    // 何もしない
                    return;
                }

                // 箱を元あった場所から除去
                box[destX, destY] = Box.None;

                // 箱を二歩先に移動させる
                box[dest2X, dest2Y] = Box.Exists;
            }

            // プレイヤーを移動させる
            playerX = destX;
            playerY = destY;
        }

        // クリアしたか判定する。クリアならtrueを返却する。
        bool IsSolved()
        {
            // 全てのマスを調査する
            for (int y = 0; y < MapHeight; y++)
            {
                for (int x = 0; x < MapWidth; x++)
                {
                    // 箱の下がゴールではない場合は、クリアではないのでfalseを返却
                    if (box[x, y] == Box.Exists && map[x, y] != Map.Goal)
                    {
                        return false;
                    }
                }
            }

            // ここまで到達したということは、全ての箱がゴールに乗っている、
            // つまりクリアということなのでtrueを返却。
            return true;
        }

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

            // TODO: Add your drawing code here

            spriteBatch.Begin();

            // マップ情報の描画。
            // 2次元配列を1マスずつ走査して、壁やゴールを描画していく
            for (int y = 0; y < MapHeight; y++)
            {
                for (int x = 0; x < MapWidth; x++)
                {
                    // 描画位置を計算
                    Vector2 position = new Vector2(CellSize * x, CellSize * y);

                    // 今調べているマスが壁だったら壁を描画
                    if (map[x, y] == Map.Wall)
                    {
                        spriteBatch.Draw(textureWall, position, Color.White);
                    }
                    // 今調べているマスがゴールだったらゴールを描画
                    else if (map[x, y] == Map.Goal)
                    {
                        spriteBatch.Draw(textureGoal, position, Color.White);
                    }

                    // 今調べているマスに箱があれば、箱を描画する
                    if (box[x, y] == Box.Exists)
                    {
                        // 箱の下がゴールなら、黄色く描画
                        if (map[x, y] == Map.Goal)
                        {
                            spriteBatch.Draw(textureBox, position, Color.Yellow);
                        }
                        // そうでなければ普通に描画
                        else
                        {
                            spriteBatch.Draw(textureBox, position, Color.White);
                        }
                    }
                }
            }

            // プレイヤーの描画位置を計算
            Vector2 playerPosition = new Vector2(CellSize * playerX, CellSize * playerY);
            // プレイヤーを描画
            spriteBatch.Draw(texturePlayer, playerPosition, Color.White);
            
            // 「クリア!」の描画
            if (state == State.Solved)
            {
                spriteBatch.Draw(textureClear, new Vector2(20, 400), Color.White);
            }

            spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}







やり直しやステージ切り替えができるようになりました!



移動を滑らかにする

現状の移動処理は、1マスずつ瞬間移動するように動くため、味気ないです。
やっぱり、実際に歩いているように、スーっと動かしたいですよね。

やってみましょう!

色々なやり方があると思いますが、大きく分けて、2つのアプローチがあります。

  1. 移動速度を決めて、その速度で動かし続ける。目的地に着いたら、やめる。
  2. 移動時間を決めて、その時間で目的地に着くように、毎フレーム位置を計算しながら動かす

おそらく、多くの初心者の人が思いつくのは1の方法だと思いますが、今回は2の方法で実装します。
2のほうが、あとで速度を簡単に変えられるし、アレンジもしやすくて、便利なのです。決まりきった動作を行う場合は、2のほうが良いです。

2の方法は線形補間と言いますが、詳細は別の記事に書いたので、よくわからない場合はそちらを参照してください。


using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System.IO; // ファイル読み込みのために必要

namespace Sokoban
{
    public class Game1 : Game
    {
        // マップのマスの種別
        enum Map
        {
            None, // 壁でもゴールでもないマス
            Wall, // 壁
            Goal, // ゴール
        }

        // マスに箱があるかどうかの種別
        enum Box
        {
            None, // 箱が無い
            Exists, // 箱がある
            Moving, // 箱が移動中
        }

        // ゲームの状態種別
        enum State
        {
            Play, // プレイ中
            Solved, // クリア
            Moving, // 移動中
        }

        const int MapWidth = 25; // マップの幅
        const int MapHeight = 15; // マップの高さ
        const int CellSize = 32; // 1マスの大きさ(ピクセル数)
        const int MapIdMax = 5; // マップ番号の最大値
        const int MoveDuration = 60; // 移動にかける時間(フレーム数)

        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        Map[,] map; // マップ情報(壁やゴールの情報)
        Box[,] box; // 箱がどこにあるかの情報

        int playerX; // プレイヤーのX座標
        int playerY; // プレイヤーのY座標

        Texture2D textureWall; // 壁のテクスチャ
        Texture2D textureBox; // 箱のテクスチャ
        Texture2D textureGoal; // ゴールのテクスチャ
        Texture2D texturePlayer; // プレイヤーのテクスチャ
        Texture2D textureClear; // 「クリア!」のテクスチャ

        KeyboardState prevKeyboardState; // 1フレーム前のキーボードの押下状況
        State state = State.Play; // ゲームの状態
        int currentMapId = 1; // 現在のマップ番号
        int moveCount; // 移動のためのカウンター
        Vector2 moveDirection; // 移動方向
        Vector2 moveOffset = Vector2.Zero; // 移動中の、最終的な位置との差分

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

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

            base.Initialize();
        }

        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

            // テクスチャーの読み込み
            textureWall = Content.Load<Texture2D>("wall");
            textureBox = Content.Load<Texture2D>("box");
            textureGoal = Content.Load<Texture2D>("goal");
            texturePlayer = Content.Load<Texture2D>("pacchi");
            textureClear = Content.Load<Texture2D>("clear");

            // LoadMap(2); ←削除
            StartGame(currentMapId);
        }

        // ゲーム開始
        void StartGame(int mapId)
        {
            state = State.Play;
            LoadMap(mapId);
        }

        protected override void UnloadContent()
        {
        }

        // マップ情報をテキストファイルから読み込む
        // mapId : マップの番号
        void LoadMap(int mapId)
        {
            // マップ情報格納用の2次元配列を生成
            map = new Map[MapWidth, MapHeight];
            box = new Box[MapWidth, MapHeight];

            // ファイル名を指定して、マップデータを読み込む。
            // ReadAllLines()を使うと、1行ごとの配列で読み込まれる。
            string[] lines = File.ReadAllLines("Map/map" + mapId + ".txt");

            // 行のループ
            for (int y = 0; y < lines.Length; y++)
            {
                // 見たい1行分の文字列を取り出す
                string line = lines[y];

                // 文字のループ
                for (int x = 0; x < line.Length; x++)
                {
                    // 文字列からx文字目の文字を取り出す。
                    char c = line[x];

                    // 取り出した文字に応じて、マップ情報やプレイヤーの位置などをセットする
                    switch (c)
                    {
                        case ' ': // 何もない場所
                            map[x, y] = Map.None;
                            break;
                        case '#': // 壁
                            map[x, y] = Map.Wall;
                            break;
                        case '@': // プレイヤー
                            playerX = x;
                            playerY = y;
                            break;
                        case '$': // 箱
                            box[x, y] = Box.Exists;
                            break;
                        case '.': // ゴール
                            map[x, y] = Map.Goal;
                            break;
                        case '+': // ゴールの上にプレイヤーが乗ってる
                            map[x, y] = Map.Goal;
                            playerX = x;
                            playerY = y;
                            break;
                        case '*': // ゴールの上に箱が乗ってる
                            map[x, y] = Map.Goal;
                            box[x, y] = Box.Exists;
                            break;
                        default:
                            throw new System.Exception("不正な文字が混入してます:" + c);
                    }
                }
            }
        }

        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

            // キーボードの押下状況を取得
            KeyboardState keyboardState = Keyboard.GetState();

            // プレイ中か?
            if (state == State.Play)
            {
                // 左が押されたら左へ移動
                if (prevKeyboardState.IsKeyUp(Keys.Left) && keyboardState.IsKeyDown(Keys.Left)) Move(-1, 0);
                // 右が押されたら右へ移動
                else if (prevKeyboardState.IsKeyUp(Keys.Right) && keyboardState.IsKeyDown(Keys.Right)) Move(1, 0);
                // 上が押されたら上へ移動
                else if (prevKeyboardState.IsKeyUp(Keys.Up) && keyboardState.IsKeyDown(Keys.Up)) Move(0, -1);
                // 下が押されたら下へ移動
                else if (prevKeyboardState.IsKeyUp(Keys.Down) && keyboardState.IsKeyDown(Keys.Down)) Move(0, 1);

                // ↓削除
                //if (IsSolved())
                //{
                //    state = State.Solved;
                //}
            }

            // 移動中か?
            if (state == State.Moving)
            {
                moveCount++;

                // 移動中
                if (moveCount < MoveDuration)
                {
                    // 移動の進捗率(時間経過を0~1で表したもの)
                    float rate = (float)moveCount / MoveDuration;

                    // 最終的な位置との差分を計算
                    moveOffset = Vector2.Lerp(-moveDirection * CellSize, Vector2.Zero, rate);
                }
                // 移動完了
                else
                {
                    moveOffset = Vector2.Zero;

                    // 移動中になっている箱を通常状態に戻す
                    for (int y = 0; y < MapHeight; y++)
                    {
                        for (int x = 0; x < MapWidth; x++)
                        {
                            if (box[x, y] == Box.Moving) box[x, y] = Box.Exists;
                        }
                    }

                    // 状態を変更する(クリアしてたらSolved状態へ。クリアしてなければPlay状態へ)
                    if (IsSolved())
                    {
                        state = State.Solved;
                    }
                    else
                    {
                        state = State.Play;
                    }
                }
            }
            
            // 1キーを押すと、ひとつ前のステージを読み込む
            if (prevKeyboardState.IsKeyUp(Keys.D1) && keyboardState.IsKeyDown(Keys.D1))
            {
                if (currentMapId > 1)
                {
                    currentMapId--;
                    StartGame(currentMapId);
                }
            }
            // 2キーを押すと、現在のステージを読み込み直す
            if (prevKeyboardState.IsKeyUp(Keys.D2) && keyboardState.IsKeyDown(Keys.D2))
            {
                StartGame(currentMapId);
            }
            // 3キーを押すと、次のステージを読み込む
            if (prevKeyboardState.IsKeyUp(Keys.D3) && keyboardState.IsKeyDown(Keys.D3))
            {
                if (currentMapId < MapIdMax)
                {
                    currentMapId++;
                    StartGame(currentMapId);
                }
            }

            // 次のフレームのために、現在のキーボードの押下状況を保存しておく
            prevKeyboardState = keyboardState;

            base.Update(gameTime);
        }

        // プレイヤー移動処理
        // x : 横の移動量
        // y : 縦の移動量
        void Move(int x, int y)
        {
            // 一歩先の座標
            int destX = playerX + x;
            int destY = playerY + y;

            // 一歩先が壁の場合
            if (map[destX, destY] == Map.Wall)
            {
                // 何もしない
                return;
            }

            // 一歩先が箱の場合
            if (box[destX, destY] == Box.Exists)
            {
                // 二歩先の座標
                int dest2X = destX + x;
                int dest2Y = destY + y;

                // 二歩先が空きスペースではない場合
                if (map[dest2X, dest2Y] == Map.Wall || box[dest2X, dest2Y] == Box.Exists)
                {
                    // 何もしない
                    return;
                }

                // 箱を元あった場所から除去
                box[destX, destY] = Box.None;

                // 箱を二歩先に移動させる
                box[dest2X, dest2Y] = Box.Moving;
            }

            // プレイヤーを移動させる
            playerX = destX;
            playerY = destY;

            // 状態を移動中にする
            state = State.Moving;
            moveCount = 0;
            moveDirection = new Vector2(x, y);
        }

        // クリアしたか判定する。クリアならtrueを返却する。
        bool IsSolved()
        {
            // 全てのマスを調査する
            for (int y = 0; y < MapHeight; y++)
            {
                for (int x = 0; x < MapWidth; x++)
                {
                    // 箱の下がゴールではない場合は、クリアではないのでfalseを返却
                    if (box[x, y] == Box.Exists && map[x, y] != Map.Goal)
                    {
                        return false;
                    }
                }
            }

            // ここまで到達したということは、全ての箱がゴールに乗っている、
            // つまりクリアということなのでtrueを返却。
            return true;
        }

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

            // TODO: Add your drawing code here

            spriteBatch.Begin();

            // マップ情報の描画。
            // 2次元配列を1マスずつ走査して、壁やゴールを描画していく
            for (int y = 0; y < MapHeight; y++)
            {
                for (int x = 0; x < MapWidth; x++)
                {
                    // 描画位置を計算
                    Vector2 position = new Vector2(CellSize * x, CellSize * y);

                    // 今調べているマスが壁だったら壁を描画
                    if (map[x, y] == Map.Wall)
                    {
                        spriteBatch.Draw(textureWall, position, Color.White);
                    }
                    // 今調べているマスがゴールだったらゴールを描画
                    else if (map[x, y] == Map.Goal)
                    {
                        spriteBatch.Draw(textureGoal, position, Color.White);
                    }

                    // 今調べているマスに箱があれば、箱を描画する
                    if (box[x, y] == Box.Exists)
                    {
                        // 箱の下がゴールなら、黄色く描画
                        if (map[x, y] == Map.Goal)
                        {
                            spriteBatch.Draw(textureBox, position, Color.Yellow);
                        }
                        // そうでなければ普通に描画
                        else
                        {
                            spriteBatch.Draw(textureBox, position, Color.White);
                        }
                    }
                    // 今調べているマスに移動中の箱があれば、箱をずらして描画
                    else if (box[x, y] == Box.Moving)
                    {
                        spriteBatch.Draw(textureBox, position + moveOffset, Color.White);
                    }
                }
            }

            // プレイヤーの描画位置を計算
            Vector2 playerPosition = new Vector2(CellSize * playerX, CellSize * playerY) + moveOffset;
            // プレイヤーを描画
            spriteBatch.Draw(texturePlayer, playerPosition, Color.White);
            
            // 「クリア!」の描画
            if (state == State.Solved)
            {
                spriteBatch.Draw(textureClear, new Vector2(20, 400), Color.White);
            }

            spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}




スーっと動くようになりました!!



もっと簡単なプログラムで実現できれば良かったのですが、私にはこのあたりが限界でした・・・

moveCountという変数は、移動開始からどれくらいの時間(フレーム数)が経過したかを格納します。

MoveDurationという定数は、どのくらいの時間(フレーム数)をかけて移動するのかを表しています。現状、あえて大きな値になっています。自分好みの速さになるように調節してみましょう。


Vector2型のmoveOffsetという変数が、今回のキモです。
本来あるべき位置(最終的にあるべき位置)とのズレを表します。


どういうことなのかというと・・・

例えば、下図の状態で、右を押したとします。
つまり、①から②へ移動します。


このとき、一瞬で移動させてしまったのでは、瞬間移動です。


なので、プレイヤーはデータ上は②の位置に存在するのですが、②の位置には表示せず、①の位置に表示したままにし、少しずつ②に近づける必要があります。
つまり、本来の位置より、下図の赤矢印の分だけずらして表示する必要があります。

この赤矢印がmoveOffsetです。

この赤矢印のベクトルは、最初は (-32, 0) です。
MoveDurationが60の場合、
15フレームが経過すると (-24, 0) になります。
30フレームが経過すると (-16, 0) になります。
45フレームが経過すると (-8, 0) になります。
60フレームが経過すると (0, 0) になって、到着です。

この計算を簡単にやってくれるのが、Vector2.Lerp()です。
第1引数に今回でいうところの (-32, 0) を、
第2引数に最終的な値である (0, 0) を、
第3引数に時間がどれくらい経過したのかを 0~1 で渡してあげると、結果を教えてくれます。

例えば、第1引数に (-32, 0)
第2引数に (0, 0)
第3引数に 0.5 を渡すと、戻り値として (-16, 0) が返却されます。

・・・というか今回は別にVector2.Lerp()使わなくても良かったですね・・・

次の2つのプログラムは、同じ結果になります。
// 最終的な位置との差分を計算
moveOffset = Vector2.Lerp(-moveDirection * CellSize, Vector2.Zero, rate);
// 最終的な位置との差分を計算
moveOffset = -moveDirection * CellSize * (1f - rate);

どちらでも構いません。好きなほうでやってください。
とにかく、最終的な位置からのズレ(上図の赤矢印)が徐々に長さゼロになれば良いのです。





描画順の制御

実は、現状では、ひとつ問題があります。
MoveDurationを大きめの値にして、ゆっくり移動するようにするとわかりやすいですが、箱を移動させると、床のゴールの目印が箱の手前に描画されてしまうのです・・・!



これは直さねばなりません。

MonoGameでは、基本的に、先に描画命令を出したものが奥に、後に描画命令を出したものが手前に描画されます。
今回は、箱の描画命令を出した後にゴールの描画命令を出すことがあるため、ゴールが手前に描画されてしまうのです。

対処方法としては2通りあります。

1. 箱の描画命令を後にする

Drawメソッドを次のように改修します。
今まで、一緒に行っていた地形と箱の描画処理を分離して、先に地形のみ描画し、後から箱を描画するようにしています。

※赤い部分を削除、青い部分を追加
        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.Black);

            // TODO: Add your drawing code here

            spriteBatch.Begin();

            // マップ情報の描画。
            // 2次元配列を1マスずつ走査して、壁やゴールを描画していく
            for (int y = 0; y < MapHeight; y++)
            {
                for (int x = 0; x < MapWidth; x++)
                {
                    // 描画位置を計算
                    Vector2 position = new Vector2(CellSize * x, CellSize * y);

                    // 今調べているマスが壁だったら壁を描画
                    if (map[x, y] == Map.Wall)
                    {
                        spriteBatch.Draw(textureWall, position, Color.White);
                    }
                    // 今調べているマスがゴールだったらゴールを描画
                    else if (map[x, y] == Map.Goal)
                    {
                        spriteBatch.Draw(textureGoal, position, Color.White);
                    }
                    // ↓この部分を削除(というか下に移動)
                    //// 今調べているマスに箱があれば、箱を描画する
                    //if (box[x, y] == Box.Exists)
                    //{
                    //    // 箱の下がゴールなら、黄色く描画
                    //    if (map[x, y] == Map.Goal)
                    //    {
                    //        spriteBatch.Draw(textureBox, position, Color.Yellow);
                    //    }
                    //    // そうでなければ普通に描画
                    //    else
                    //    {
                    //        spriteBatch.Draw(textureBox, position, Color.White);
                    //    }
                    //}
                    //// 今調べているマスに移動中の箱があれば、箱をずらして描画
                    //else if (box[x, y] == Box.Moving)
                    //{
                    //    spriteBatch.Draw(textureBox, position + moveOffset, Color.White);
                    //}
                }
            }

            // 箱の描画
            for (int y = 0; y < MapHeight; y++)
            {
                for (int x = 0; x < MapWidth; x++)
                {
                    // 描画位置を計算
                    Vector2 position = new Vector2(CellSize * x, CellSize * y);

                    // 今調べているマスに箱があれば、箱を描画する
                    if (box[x, y] == Box.Exists)
                    {
                        // 箱の下がゴールなら、黄色く描画
                        if (map[x, y] == Map.Goal)
                        {
                            spriteBatch.Draw(textureBox, position, Color.Yellow);
                        }
                        // そうでなければ普通に描画
                        else
                        {
                            spriteBatch.Draw(textureBox, position, Color.White);
                        }
                    }
                    // 今調べているマスに移動中の箱があれば、箱をずらして描画
                    else if (box[x, y] == Box.Moving)
                    {
                        spriteBatch.Draw(textureBox, position + moveOffset, Color.White);
                    }
                }
            }

            // プレイヤーの描画位置を計算
            Vector2 playerPosition = new Vector2(CellSize * playerX, CellSize * playerY) + moveOffset;
            // プレイヤーを描画
            spriteBatch.Draw(texturePlayer, playerPosition, Color.White);
            
            // 「クリア!」の描画
            if (state == State.Solved)
            {
                spriteBatch.Draw(textureClear, new Vector2(20, 400), Color.White);
            }

            spriteBatch.End();

            base.Draw(gameTime);
        }


2. LayerDepthを指定する

描画順を制御するもう1つのやり方は、LayerDepthを指定するというものです。Depthとは深度、深さのことです。

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

            // TODO: Add your drawing code here

            spriteBatch.Begin(SpriteSortMode.BackToFront);

            // マップ情報の描画。
            // 2次元配列を1マスずつ走査して、壁やゴールを描画していく
            for (int y = 0; y < MapHeight; y++)
            {
                for (int x = 0; x < MapWidth; x++)
                {
                    // 描画位置を計算
                    Vector2 position = new Vector2(CellSize * x, CellSize * y);

                    // 今調べているマスが壁だったら壁を描画
                    if (map[x, y] == Map.Wall)
                    {
                        spriteBatch.Draw(textureWall, position, Color.White);
                    }
                    // 今調べているマスがゴールだったらゴールを描画
                    else if (map[x, y] == Map.Goal)
                    {
                        spriteBatch.Draw(textureGoal, position, null, Color.White, 0f, Vector2.Zero, 1f, SpriteEffects.None, 0.5f);
                    }

                    // 今調べているマスに箱があれば、箱を描画する
                    if (box[x, y] == Box.Exists)
                    {
                        // 箱の下がゴールなら、黄色く描画
                        if (map[x, y] == Map.Goal)
                        {
                            spriteBatch.Draw(textureBox, position, Color.White);
                        }
                        // そうでなければ普通に描画
                        else
                        {
                            spriteBatch.Draw(textureBox, position, Color.White);
                        }
                    }
                    // 今調べているマスに移動中の箱があれば、箱をずらして描画
                    else if (box[x, y] == Box.Moving)
                    {
                        spriteBatch.Draw(textureBox, position + moveOffset, Color.White);
                    }
                }
            }
            
            // プレイヤーの描画位置を計算
            Vector2 playerPosition = new Vector2(CellSize * playerX, CellSize * playerY) + moveOffset;
            // プレイヤーを描画
            spriteBatch.Draw(texturePlayer, playerPosition, Color.White);
            
            // 「クリア!」の描画
            if (state == State.Solved)
            {
                spriteBatch.Draw(textureClear, new Vector2(20, 400), Color.White);
            }

            spriteBatch.End();

            base.Draw(gameTime);
        }
    }

まず、SpriteBatchのBeginメソッドに、SpriteSortMode.BackToFrontを指定するようにしました。これを指定すると、LayerDepthが有効になります。

そして、ゴールを描画するためのSpriteBatch.Drawメソッドにて、引数をたくさん指定するようにしました。
本当は、追加で指定したいのはLayerDepthだけなのですが、そういう指定方法はなくて、もれなく回転の指定とか大きさの指定とかもついてきます・・・なので、今回は特に影響の無い値(角度=0とか大きさ=1とか)を指定しています。
最後の引数がLayerDepthです。
今回は0.5を指定しました。適当です。

LayerDepthは0~1の範囲で指定してください。
小さいほうが手前、大きいほうが奥に描画されます。

LayerDepthを指定しない場合のデフォルト値は0なので、指定していない箱は手前に描画され、0.5を指定したゴールは奥に描画されるようになりました。

前述の、単純に描画命令のプログラムを書く順番で手前・奥を制御する方法より、こちらの方法のほうがより柔軟な描画順の制御ができるでしょう。

なお実際にLayerDepthを使うときは、Drawメソッドに直値を書くのではなく、必ず定数で定義して使いましょう。

あと引数が多すぎて面倒なので、自分が指定したい引数のみを指定できるような描画の関数を作っておくと捗ります。

ちなみに、同じLayerDepthの場合は、先に描画命令を出したものが奥に描画されるようです。



押しっぱなしで移動し続けられるようにする

瞬間移動ではなくなったので、下記の部分は削除しても良いかもしれません。
ボタンを押した瞬間のみ反応するようにするための条件です。
                // 左が押されたら左へ移動
                if (prevKeyboardState.IsKeyUp(Keys.Left) && keyboardState.IsKeyDown(Keys.Left)) Move(-1, 0);
                // 右が押されたら右へ移動
                else if (prevKeyboardState.IsKeyUp(Keys.Right) && keyboardState.IsKeyDown(Keys.Right)) Move(1, 0);
                // 上が押されたら上へ移動
                else if (prevKeyboardState.IsKeyUp(Keys.Up) && keyboardState.IsKeyDown(Keys.Up)) Move(0, -1);
                // 下が押されたら下へ移動
                else if (prevKeyboardState.IsKeyUp(Keys.Down) && keyboardState.IsKeyDown(Keys.Down)) Move(0, 1);
瞬間移動方式の場合は、これがないと、すごい勢いで壁際まで飛んでいってしまいますが、もう不要です。むしろ、無いほうが、押しっぱなしで移動し続けられるので、ユーザーフレンドリーです。



移動に緩急をつける

現状のプレイヤーキャラクターは「スーーーーッ」と一定の速度で移動します。
これはこれで良いですが、緩急をつけてあげると、イマドキな感じになるかもしれません。

今回のように時間ベースで移動を行っている場合、緩急をつけるのはとても簡単です。
まず、次のようなメソッドを追加してください。

        // イージング関数
        float Ease(float t)
        {
            t -= 1f;
            return t * t * t + 1f;
        }

これは、0~1の値を引数で受け取ったら、それをちょっぴり変更して返却する関数です。
このあたりの詳細は別の記事で書いています。

で、Updateメソッドに1行だけ追加します。

        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

            // キーボードの押下状況を取得
            KeyboardState keyboardState = Keyboard.GetState();

            // プレイ中か?
            if (state == State.Play)
            {
                // 左が押されたら左へ移動
                if (keyboardState.IsKeyDown(Keys.Left)) Move(-1, 0);
                // 右が押されたら右へ移動
                else if (keyboardState.IsKeyDown(Keys.Right)) Move(1, 0);
                // 上が押されたら上へ移動
                else if (keyboardState.IsKeyDown(Keys.Up)) Move(0, -1);
                // 下が押されたら下へ移動
                else if (keyboardState.IsKeyDown(Keys.Down)) Move(0, 1);
            }

            // 移動中か?
            if (state == State.Moving)
            {
                moveCount++;

                // 移動中
                if (moveCount < MoveDuration)
                {
                    // 移動の進捗率(時間経過を0~1で表したもの)
                    float rate = (float)moveCount / MoveDuration;

                    // 値の変化に緩急をつける
                    rate = Ease(rate);

                    // 最終的な位置との差分を計算
                    moveOffset = Vector2.Lerp(-moveDirection * CellSize, Vector2.Zero, rate);
                }

実行してみてください!

動きに緩急がついて、カッコよくなったと思いませんか?








以上で倉庫番は終わりです!
最後にプログラム全文を掲載しておきます。

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System.IO; // ファイル読み込みのために必要

namespace Sokoban
{
    public class Game1 : Game
    {
        // マップのマスの種別
        enum Map
        {
            None, // 壁でもゴールでもないマス
            Wall, // 壁
            Goal, // ゴール
        }

        // マスに箱があるかどうかの種別
        enum Box
        {
            None, // 箱が無い
            Exists, // 箱がある
            Moving, // 箱が移動中
        }

        // ゲームの状態種別
        enum State
        {
            Play, // プレイ中
            Solved, // クリア
            Moving, // 移動中
        }

        const int MapWidth = 25; // マップの幅
        const int MapHeight = 15; // マップの高さ
        const int CellSize = 32; // 1マスの大きさ(ピクセル数)
        const int MapIdMax = 5; // マップ番号の最大値
        const int MoveDuration = 12; // 移動にかける時間(フレーム数)

        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        Map[,] map; // マップ情報(壁やゴールの情報)
        Box[,] box; // 箱がどこにあるかの情報

        int playerX; // プレイヤーのX座標
        int playerY; // プレイヤーのY座標

        Texture2D textureWall; // 壁のテクスチャ
        Texture2D textureBox; // 箱のテクスチャ
        Texture2D textureGoal; // ゴールのテクスチャ
        Texture2D texturePlayer; // プレイヤーのテクスチャ
        Texture2D textureClear; // 「クリア!」のテクスチャ

        KeyboardState prevKeyboardState; // 1フレーム前のキーボードの押下状況
        State state = State.Play; // ゲームの状態
        int currentMapId = 1; // 現在のマップ番号
        int moveCount; // 移動のためのカウンター
        Vector2 moveDirection; // 移動方向
        Vector2 moveOffset = Vector2.Zero; // 移動中の、最終的な位置との差分

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

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

            base.Initialize();
        }

        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

            // テクスチャーの読み込み
            textureWall = Content.Load<Texture2D>("wall");
            textureBox = Content.Load<Texture2D>("box");
            textureGoal = Content.Load<Texture2D>("goal");
            texturePlayer = Content.Load<Texture2D>("pacchi");
            textureClear = Content.Load<Texture2D>("clear");
         
            StartGame(currentMapId);
        }

        // ゲーム開始
        void StartGame(int mapId)
        {
            state = State.Play;
            LoadMap(mapId);
        }

        protected override void UnloadContent()
        {
        }

        // マップ情報をテキストファイルから読み込む
        // mapId : マップの番号
        void LoadMap(int mapId)
        {
            // マップ情報格納用の2次元配列を生成
            map = new Map[MapWidth, MapHeight];
            box = new Box[MapWidth, MapHeight];

            // ファイル名を指定して、マップデータを読み込む。
            // ReadAllLines()を使うと、1行ごとの配列で読み込まれる。
            string[] lines = File.ReadAllLines("Map/map" + mapId + ".txt");

            // 行のループ
            for (int y = 0; y < lines.Length; y++)
            {
                // 見たい1行分の文字列を取り出す
                string line = lines[y];

                // 文字のループ
                for (int x = 0; x < line.Length; x++)
                {
                    // 文字列からx文字目の文字を取り出す。
                    char c = line[x];

                    // 取り出した文字に応じて、マップ情報やプレイヤーの位置などをセットする
                    switch (c)
                    {
                        case ' ': // 何もない場所
                            map[x, y] = Map.None;
                            break;
                        case '#': // 壁
                            map[x, y] = Map.Wall;
                            break;
                        case '@': // プレイヤー
                            playerX = x;
                            playerY = y;
                            break;
                        case '$': // 箱
                            box[x, y] = Box.Exists;
                            break;
                        case '.': // ゴール
                            map[x, y] = Map.Goal;
                            break;
                        case '+': // ゴールの上にプレイヤーが乗ってる
                            map[x, y] = Map.Goal;
                            playerX = x;
                            playerY = y;
                            break;
                        case '*': // ゴールの上に箱が乗ってる
                            map[x, y] = Map.Goal;
                            box[x, y] = Box.Exists;
                            break;
                        default:
                            throw new System.Exception("不正な文字が混入してます:" + c);
                    }
                }
            }
        }

        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

            // キーボードの押下状況を取得
            KeyboardState keyboardState = Keyboard.GetState();

            // プレイ中か?
            if (state == State.Play)
            {
                // 左が押されたら左へ移動
                if (keyboardState.IsKeyDown(Keys.Left)) Move(-1, 0);
                // 右が押されたら右へ移動
                else if (keyboardState.IsKeyDown(Keys.Right)) Move(1, 0);
                // 上が押されたら上へ移動
                else if (keyboardState.IsKeyDown(Keys.Up)) Move(0, -1);
                // 下が押されたら下へ移動
                else if (keyboardState.IsKeyDown(Keys.Down)) Move(0, 1);
            }

            // 移動中か?
            if (state == State.Moving)
            {
                moveCount++;

                // 移動中
                if (moveCount < MoveDuration)
                {
                    // 移動の進捗率(時間経過を0~1で表したもの)
                    float rate = (float)moveCount / MoveDuration;

                    // 値の変化に緩急をつける
                    rate = Ease(rate);

                    // 最終的な位置との差分を計算
                    moveOffset = Vector2.Lerp(-moveDirection * CellSize, Vector2.Zero, rate);
                }
                // 移動完了
                else
                {
                    moveOffset = Vector2.Zero;

                    // 移動中になっている箱を通常状態に戻す
                    for (int y = 0; y < MapHeight; y++)
                    {
                        for (int x = 0; x < MapWidth; x++)
                        {
                            if (box[x, y] == Box.Moving) box[x, y] = Box.Exists;
                        }
                    }

                    // 状態を変更する(クリアしてたらSolved状態へ。クリアしてなければPlay状態へ)
                    if (IsSolved())
                    {
                        state = State.Solved;
                    }
                    else
                    {
                        state = State.Play;
                    }
                }
            }

            // 1キーを押すと、ひとつ前のステージを読み込む
            if (prevKeyboardState.IsKeyUp(Keys.D1) && keyboardState.IsKeyDown(Keys.D1))
            {
                if (currentMapId > 1)
                {
                    currentMapId--;
                    StartGame(currentMapId);
                }
            }
            // 2キーを押すと、現在のステージを読み込み直す
            if (prevKeyboardState.IsKeyUp(Keys.D2) && keyboardState.IsKeyDown(Keys.D2))
            {
                StartGame(currentMapId);
            }
            // 3キーを押すと、次のステージを読み込む
            if (prevKeyboardState.IsKeyUp(Keys.D3) && keyboardState.IsKeyDown(Keys.D3))
            {
                if (currentMapId < MapIdMax)
                {
                    currentMapId++;
                    StartGame(currentMapId);
                }
            }

            // 次のフレームのために、現在のキーボードの押下状況を保存しておく
            prevKeyboardState = keyboardState;

            base.Update(gameTime);
        }
     
        // イージング関数
        float Ease(float t)
        {
            t -= 1f;
            return t * t * t + 1f;
        }

        // プレイヤー移動処理
        // x : 横の移動量
        // y : 縦の移動量
        void Move(int x, int y)
        {
            // 一歩先の座標
            int destX = playerX + x;
            int destY = playerY + y;

            // 一歩先が壁の場合
            if (map[destX, destY] == Map.Wall)
            {
                // 何もしない
                return;
            }

            // 一歩先が箱の場合
            if (box[destX, destY] == Box.Exists)
            {
                // 二歩先の座標
                int dest2X = destX + x;
                int dest2Y = destY + y;

                // 二歩先が空きスペースではない場合
                if (map[dest2X, dest2Y] == Map.Wall || box[dest2X, dest2Y] == Box.Exists)
                {
                    // 何もしない
                    return;
                }

                // 箱を元あった場所から除去
                box[destX, destY] = Box.None;

                // 箱を二歩先に移動させる
                box[dest2X, dest2Y] = Box.Moving;
            }

            // プレイヤーを移動させる
            playerX = destX;
            playerY = destY;

            // 状態を移動中にする
            state = State.Moving;
            moveCount = 0;
            moveDirection = new Vector2(x, y);
        }

        // クリアしたか判定する。クリアならtrueを返却する。
        bool IsSolved()
        {
            // 全てのマスを調査する
            for (int y = 0; y < MapHeight; y++)
            {
                for (int x = 0; x < MapWidth; x++)
                {
                    // 箱の下がゴールではない場合は、クリアではないのでfalseを返却
                    if (box[x, y] == Box.Exists && map[x, y] != Map.Goal)
                    {
                        return false;
                    }
                }
            }

            // ここまで到達したということは、全ての箱がゴールに乗っている、
            // つまりクリアということなのでtrueを返却。
            return true;
        }

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

            // TODO: Add your drawing code here

            spriteBatch.Begin(SpriteSortMode.BackToFront);

            // マップ情報の描画。
            // 2次元配列を1マスずつ走査して、壁やゴールを描画していく
            for (int y = 0; y < MapHeight; y++)
            {
                for (int x = 0; x < MapWidth; x++)
                {
                    // 描画位置を計算
                    Vector2 position = new Vector2(CellSize * x, CellSize * y);

                    // 今調べているマスが壁だったら壁を描画
                    if (map[x, y] == Map.Wall)
                    {
                        spriteBatch.Draw(textureWall, position, Color.White);
                    }
                    // 今調べているマスがゴールだったらゴールを描画
                    else if (map[x, y] == Map.Goal)
                    {
                        spriteBatch.Draw(textureGoal, position, null, Color.White, 0f, Vector2.Zero, 1f, SpriteEffects.None, 0.5f);
                    }

                    // 今調べているマスに箱があれば、箱を描画する
                    if (box[x, y] == Box.Exists)
                    {
                        // 箱の下がゴールなら、黄色く描画
                        if (map[x, y] == Map.Goal)
                        {
                            spriteBatch.Draw(textureBox, position, Color.Yellow);
                        }
                        // そうでなければ普通に描画
                        else
                        {
                            spriteBatch.Draw(textureBox, position, Color.White);
                        }
                    }
                    // 今調べているマスに移動中の箱があれば、箱をずらして描画
                    else if (box[x, y] == Box.Moving)
                    {
                        spriteBatch.Draw(textureBox, position + moveOffset, Color.White);
                    }
                }
            }

            // プレイヤーの描画位置を計算
            Vector2 playerPosition = new Vector2(CellSize * playerX, CellSize * playerY) + moveOffset;
            // プレイヤーを描画
            spriteBatch.Draw(texturePlayer, playerPosition, Color.White);

            // 「クリア!」の描画
            if (state == State.Solved)
            {
                spriteBatch.Draw(textureClear, new Vector2(20, 400), Color.White);
            }

            spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}

0 件のコメント:

コメントを投稿