コガネブログ

平日更新を目標に Unity や C#、Visual Studio、ReSharper などのゲーム開発アレコレを書いていきます

【Unity 入門】【チュートリアル】倉庫番を作る

はじめに

このチュートリアルでは、Unity で倉庫番を作成していきます

目次

開発環境

  • Unity 2017.4.0f1
  • Windows 10

完成図

f:id:baba_s:20180328202256g:plain

Unity プロジェクトの準備

まずは、倉庫番用の Unity プロジェクトを準備します

f:id:baba_s:20180328203625p:plain

Unity を起動して上記の画面が表示されたら、「New」を選択します

f:id:baba_s:20180328203632p:plain

次に、「Project name」に「sokoban」と入力し、「2D」にチェックを入れ、
「Location」の「…」を押して、Unity プロジェクトの保存先を選択して、
「Create project」ボタンを押します

f:id:baba_s:20180328203639p:plain

これで、倉庫番を作成するための Unity プロジェクトが立ち上がりました

エディタのレイアウトの変更

次は作業をしやすくするために Unity エディタのレイアウトを変更します

f:id:baba_s:20180328203648p:plain

エディタ右上のボタンを押して、

f:id:baba_s:20180328203654p:plain

「2 by 3」を選択します

f:id:baba_s:20180328203700p:plain

そして、「Project」と書かれているタブの右側にある三本線のアイコンを押して、

f:id:baba_s:20180328203705p:plain

「One Column Layout」を選択します
これでレイアウトの変更が完了しました

画像ファイルの用意

次は、倉庫番を作成するための画像ファイルを素材サイトから入手します

https://opengameart.org/content/sokoban-100-tiles

上記のページにアクセスして、

f:id:baba_s:20180328204921p:plain

「kenney_sokobanPack.zip」を選択します
そして、ダウンロードした「kenney_sokobanPack.zip」を展開して、

f:id:baba_s:20180328203916p:plain

「kenney_sokobanPack」フォルダ内の「PNG」フォルダを開き、
「Default size」フォルダを、Unity エディタの Project の欄にドラッグします

f:id:baba_s:20180328203949p:plain

これで、倉庫番を作成するための画像ファイルの用意が完了しました

ステージ構造を管理するテキストファイルの作成

今回、倉庫番のステージはテキストファイルで1つだけ作成します
メモ帳などのテキストエディタを使用して、
下記のコードが記述された「stage.txt」というファイルを作成します

1,1,1,1,1,1,1
1,4,1,0,1,2,1
0,2,1,3,1,1,0
1,1,1,4,1,4,1
1,1,2,0,1,1,1

テキストファイルには、0から4の数値が書かれており、
それぞれの数値は下記のような意味になります

数値 内容
0 何も無い場所
1 地面
2 目的地
3 プレイヤー
4 ブロック

f:id:baba_s:20180328204150p:plain

作成した「stage.txt」を、Unity エディタの Project の欄にドラッグします

f:id:baba_s:20180328204214p:plain

これで、倉庫番のステージ構造を管理するテキストファイルの用意が完了しました

倉庫番のステージの作成

今回は倉庫番を1つのスクリプトファイルで構築していきます

f:id:baba_s:20180328205955p:plain

Unity エディタの Project の欄の「Create」を押して

f:id:baba_s:20180328210002p:plain

「C# Script」を選択して、

f:id:baba_s:20180328210015p:plain

作成した C# のスクリプトファイルに「Sokoban」と名前を付けます

f:id:baba_s:20180328210026p:plain

これで、倉庫番を構築するスクリプトファイルの準備が完了しました

f:id:baba_s:20180328210259p:plain

C# のスクリプトは、作成しただけでは動作しないため、
Hierarchy の欄に表示されている「Main Camera」にドラッグして設定します

f:id:baba_s:20180328210456p:plain

「Main Camera」を選択して、Inspector の欄に
「Sokoban」が表示されていれば、正しく設定ができています

これで、ゲームを再生したら「Main Camera」が
「Sokoban」という名前のスクリプトを実行するようになりました

f:id:baba_s:20180328210758p:plain

作成した「Sokoban」をダブルクリックすると、
お使いの環境に合わせて Visual Studio や MonoDevelop などの、
プログラムを書くためのコードエディタが起動します

ここからは、コードエディタを使用して
倉庫番を構築するプログラムを作成していきます

テキストファイルの読み込み

最初は、テキストファイルから、倉庫番のステージ構造を読み込み、
二次元配列に格納する機能を作成していきます

まず、タイルの種類を表す TileType 列挙型を定義します

// タイルの種類
private enum TileType
{
    NONE, // 何も無い
    GROUND, // 地面
    TARGET, // 目的地
    PLAYER, // プレイヤー
    BLOCK, // ブロック

    PLAYER_ON_TARGET, // プレイヤー(目的地の上)
    BLOCK_ON_TARGET, // ブロック(目的地の上)
}

列挙型は、各要素に0からの番号を割り当てていくため
それぞれの要素は下記のようになります

列挙値 番号 説明
NONE 0 何も無い場所
GROUND 1 地面
TARGET 2 目的地
PLAYER 3 プレイヤー
BLOCK 4 ブロック
PLAYER_ON_TARGET 5 プレイヤー(目的地に乗っている)
BLOCK_ON_TARGET 6 ブロック(目的地に乗っている)

これは、先ほど作成したテキストファイルの数値に対応しており、
プログラム上では、この TileType 列挙型を使用することで、
それぞれの数値が何を表しているのかをわかりやすくします

次は、ステージ構造を管理する変数を定義し、
テキストファイルからステージ構造を読み込む LoadTileData関数を作成します

public TextAsset stageFile; // ステージ構造が記述されたテキストファイル

private int rows; // 行数
private int columns; // 列数
private TileType[,] tileList; // タイル情報を管理する二次元配列
// タイルの情報を読み込む
private void LoadTileData()
{
    // タイルの情報を一行ごとに分割
    var lines = stageFile.text.Split
    (
        new[] { '\r', '\n' },
        System.StringSplitOptions.RemoveEmptyEntries
    );

    // タイルの列数を計算
    var nums = lines[ 0 ].Split( new[] { ',' } );

    // タイルの列数と行数を保持
    rows = lines.Length; // 行数
    columns = nums.Length; // 列数

    // タイル情報を int 型の2次元配列で保持
    tileList = new TileType[ columns, rows ];
    for ( int y = 0; y < rows; y++ )
    {
        // 一文字ずつ取得
        var st = lines[ y ];
        nums = st.Split( new[] { ',' } );
        for ( int x = 0; x < columns; x++ )
        {
            // 読み込んだ文字を数値に変換して保持
            tileList[ x, y ] = ( TileType )int.Parse( nums[ x ] );
        }
    }
}

テキストファイルから読み込んだ数値を TileType の二次元配列で管理し、
ステージの行数と列数も算出しています

stageFile は public 変数になっており、Unity エディタ上で編集可能なので、
後ほど、ステージ構造を管理するテキストファイルを
この stageFile 変数に設定します

ステージの作成

次は、ステージの作成に必要な変数を用意し、
ステージを作成する CreateStage 関数を定義していきます

public float tileSize; // タイルのサイズ

public Sprite groundSprite; // 地面のスプライト
public Sprite targetSprite; // 目的地のスプライト
public Sprite playerSprite; // プレイヤーのスプライト
public Sprite blockSprite; // ブロックのスプライト

private GameObject player; // プレイヤーのゲームオブジェクト
private Vector2 middleOffset; // 中心位置
private int blockCount; // ブロックの数

// 各位置に存在するゲームオブジェクトを管理する連想配列
private Dictionary<GameObject, Vector2Int> gameObjectPosTable = new Dictionary<GameObject, Vector2Int>();
// ステージを作成
private void CreateStage()
{
    // ステージの中心位置を計算
    middleOffset.x = columns * tileSize * 0.5f - tileSize * 0.5f;
    middleOffset.y = rows * tileSize * 0.5f - tileSize * 0.5f; ;

    for ( int y = 0; y < rows; y++ )
    {
        for ( int x = 0; x < columns; x++ )
        {
            var val = tileList[ x, y ];

            // 何も無い場所は無視
            if ( val == TileType.NONE ) continue;

            // タイルの名前に行番号と列番号を付与
            var name = "tile" + y + "_" + x;

            // タイルのゲームオブジェクトを作成
            var tile = new GameObject( name );

            // タイルにスプライトを描画する機能を追加
            var sr = tile.AddComponent<SpriteRenderer>();

            // タイルのスプライトを設定
            sr.sprite = groundSprite;

            // タイルの位置を設定
            tile.transform.position = GetDisplayPosition( x, y );

            // 目的地の場合
            if ( val == TileType.TARGET )
            {
                // 目的地のゲームオブジェクトを作成
                var destination = new GameObject( "destination" );

                // 目的地にスプライトを描画する機能を追加
                sr = destination.AddComponent<SpriteRenderer>();

                // 目的地のスプライトを設定
                sr.sprite = targetSprite;

                // 目的地の描画順を手前にする
                sr.sortingOrder = 1;

                // 目的地の位置を設定
                destination.transform.position = GetDisplayPosition( x, y );
            }
            // プレイヤーの場合
            if ( val == TileType.PLAYER )
            {
                // プレイヤーのゲームオブジェクトを作成
                player = new GameObject( "player" );

                // プレイヤーにスプライトを描画する機能を追加
                sr = player.AddComponent<SpriteRenderer>();

                // プレイヤーのスプライトを設定
                sr.sprite = playerSprite;

                // プレイヤーの描画順を手前にする
                sr.sortingOrder = 2;

                // プレイヤーの位置を設定
                player.transform.position = GetDisplayPosition( x, y );

                // プレイヤーを連想配列に追加
                gameObjectPosTable.Add( player, new Vector2Int( x, y ) );
            }
            // ブロックの場合
            else if ( val == TileType.BLOCK )
            {
                // ブロックの数を増やす
                blockCount++;

                // ブロックのゲームオブジェクトを作成
                var block = new GameObject( "block" + blockCount );

                // ブロックにスプライトを描画する機能を追加
                sr = block.AddComponent<SpriteRenderer>();

                // ブロックのスプライトを設定
                sr.sprite = blockSprite;

                // ブロックの描画順を手前にする
                sr.sortingOrder = 2;

                // ブロックの位置を設定
                block.transform.position = GetDisplayPosition( x, y );

                // ブロックを連想配列に追加
                gameObjectPosTable.Add( block, new Vector2Int( x, y ) );
            }
        }
    }
}

ループ構文を使用して、二次元配列の tileList に格納されている TileType を取得し、
TileType の値に応じて必要なゲームオブジェクトを作成します

また、ブロックを作成する時は、blockCount の値をインクリメントします
blockCount は、倉庫番をクリアしたかどうかの判定で使用します

タイルのサイズや、各ゲームオブジェクトで使用するスプライトは、
Unity エディタ上で後ほど設定します

作成したゲームオブジェクトは、正しい位置に表示する必要があります
これは、GetDisplayPosition 関数で行います

// 指定された行番号と列番号からスプライトの表示位置を計算して返す
private Vector2 GetDisplayPosition( int x, int y )
{
    return new Vector2
    (
        x *  tileSize - middleOffset.x,
        y * -tileSize + middleOffset.y
    );
}

タイルのサイズを管理する tileSize と、
ステージの中心位置を表す middleOffset を使用して、
ゲームオブジェクトの表示位置を計算しています

これで、ステージ作成する機能の用意ができたので
これらの関数をゲーム開始時に実行するように Start 関数を定義します

// ゲーム開始時に呼び出される
private void Start()
{
    LoadTileData(); // タイルの情報を読み込む
    CreateStage(); // ステージを作成
}

Start 関数は Unity でゲームを実行する時に自動で呼ばれる関数です
これで、ゲーム開始時にステージを作成するようになりました

f:id:baba_s:20180328213116p:plain

ここで、一旦スクリプトを保存して、Unity エディタに戻ります
そして、「Main Camera」を選択すると、Inspector の「Sokoban」の欄に、
先ほど定義した public 変数の項目が増えています

f:id:baba_s:20180328213527p:plain

まず、「Stage File」の欄に、「stage.txt」をドラッグして設定します

f:id:baba_s:20180328213611p:plain

次に、「Tile Size」にタイルの大きさを設定します
「Tile Size」には「画像ファイルの大きさ / 100」を入力する必要があります

f:id:baba_s:20180328213711p:plain

今回使用する画像ファイルの大きさは「64x64」なので、
「64 / 100」で「0.64」と入力します

f:id:baba_s:20180328213956p:plain

そして、地面、目的地、プレイヤー、ブロックの表示に使用するスプライトを設定します
これは、最初に用意した「Default size」フォルダ内から
好きな画像ファイルを設定していきます

f:id:baba_s:20180328214303p:plain

今回はこのように設定しました

f:id:baba_s:20180328214642p:plain

ここで、Unity エディタ上部の再生ボタンを押して、Unity を実行してみます

f:id:baba_s:20180328214752p:plain

すると、設定したスプライトを使用して、ステージが表示されることが確認できます

f:id:baba_s:20180328214918p:plain

ゲーム画面に表示されたステージのサイズが小さいので調整します
Unity の再生を止めてから、「Main Camera」を選択して、
Inspector で「Size」に「2」と入力します

f:id:baba_s:20180328214932p:plain

これで、もう一度 Unity エディタ上部の再生ボタンを押して Unity を実行すると
ステージが大きく表示されて見やすくなります

「Main Camera」の「Size」に小さい値を入力するほど
ステージが大きく表示されるので、
必要であれば好きな数値を入力してステージの大きさを調整してください

ステージの表示の確認が終わったら、
Unity の再生を停止することをわすれないように気をつけてください

倉庫番のロジックの作成

ステージの表示が完成したので、次は倉庫番のロジックを作成していきます

ユーザーが入力した矢印キーの向きに合わせてプレイヤーを移動します
移動する際には下記のようなことに気をつける必要があります

  • 移動先に地面があり、移動できるかどうか
  • 移動先にブロックがあるか
  • 移動先にブロックがある場合、ブロックを押すことができるか

チェック関数

前述した処理を記述するためには、
プレイヤーやブロックが現在どの位置に存在するか、という情報が必要になります

まずは、指定された位置に存在するゲームオブジェクトを取得できる
GetGameObjectAtPosition 関数を作成します

// 指定された位置に存在するゲームオブジェクトを返します
private GameObject GetGameObjectAtPosition( Vector2Int pos )
{
    foreach ( var pair in gameObjectPosTable )
    {
        // 指定された位置が見つかった場合
        if ( pair.Value == pos )
        {
            // その位置に存在するゲームオブジェクトを返す
            return pair.Key;
        }
    }
    return null;
}

次に、指定された位置に地面が存在し、
移動可能かどうかを確認するための IsValidPosition 関数を作成します

// 指定された位置がステージ内なら true を返す
private bool IsValidPosition( Vector2Int pos )
{
    if ( 0 <= pos.x && pos.x < columns && 0 <= pos.y && pos.y < rows )
    {
        return tileList[ pos.x, pos.y ] != TileType.NONE;
    }
    return false;
}

さらに、指定された位置にブロックが存在するかどうか確認する
IsBlock 関数を作成します

// 指定された位置のタイルがブロックなら true を返す
private bool IsBlock( Vector2Int pos )
{
    var cell = tileList[ pos.x, pos.y ];
    return cell == TileType.BLOCK || cell == TileType.BLOCK_ON_TARGET;
}

移動処理

ユーザーが入力した矢印キーに合わせてプレイヤーを移動させるために、
まずは、毎フレーム呼び出される Update 関数で、キー入力をチェックします

// 毎フレーム呼び出される
private void Update()
{
    // ゲームクリアしている場合は操作できないようにする
    if ( isClear ) return;

    // 上矢印が押された場合
    if ( Input.GetKeyDown( KeyCode.UpArrow ) )
    {
        // プレイヤーが上に移動できるか検証
        TryMovePlayer( DirectionType.UP );
    }
    // 右矢印が押された場合
    else if ( Input.GetKeyDown( KeyCode.RightArrow ) )
    {
        // プレイヤーが右に移動できるか検証
        TryMovePlayer( DirectionType.RIGHT );
    }
    // 下矢印が押された場合
    else if ( Input.GetKeyDown( KeyCode.DownArrow ) )
    {
        // プレイヤーが下に移動できるか検証
        TryMovePlayer( DirectionType.DOWN );
    }
    // 左矢印が押された場合
    else if ( Input.GetKeyDown( KeyCode.LeftArrow ) )
    {
        // プレイヤーが左に移動できるか検証
        TryMovePlayer( DirectionType.LEFT );
    }
}

この Update 関数では、ゲームをすでにクリアしている場合は
入力できないようにしています
ゲームをクリアしているかどうかは、isClear 変数で判定します

private bool isClear; // ゲームをクリアした場合 true

また、移動先は DirectionType 列挙型で表します

// 方向の種類
private enum DirectionType
{
    UP, // 上
    RIGHT, // 右
    DOWN, // 下
    LEFT, // 左
}

そして、倉庫番のロジックのコアな部分である移動処理を管理する
TryMovePlayer 関数を定義します

// 指定された方向にプレイヤーが移動できるか検証
// 移動できる場合は移動する
private void TryMovePlayer( DirectionType direction )
{
    // プレイヤーの現在地を取得
    var currentPlayerPos = gameObjectPosTable[ player ];

    // プレイヤーの移動先の位置を計算
    var nextPlayerPos = GetNextPositionAlong( currentPlayerPos, direction );

    // プレイヤーの移動先がステージ内ではない場合は無視
    if ( !IsValidPosition( nextPlayerPos ) ) return;

    // プレイヤーの移動先にブロックが存在する場合
    if ( IsBlock( nextPlayerPos ) )
    {
        // ブロックの移動先の位置を計算
        var nextBlockPos = GetNextPositionAlong( nextPlayerPos, direction );

        // ブロックの移動先がステージ内の場合かつ
        // ブロックの移動先にブロックが存在しない場合
        if ( IsValidPosition( nextBlockPos ) && !IsBlock( nextBlockPos ) )
        {
            // 移動するブロックを取得
            var block = GetGameObjectAtPosition( nextPlayerPos );

            // プレイヤーの移動先のタイルの情報を更新
            UpdateGameObjectPosition( nextPlayerPos );

            // ブロックを移動
            block.transform.position = GetDisplayPosition( nextBlockPos.x, nextBlockPos.y );

            // ブロックの位置を更新
            gameObjectPosTable[ block ] = nextBlockPos;

            // ブロックの移動先の番号を更新
            if ( tileList[ nextBlockPos.x, nextBlockPos.y ] == TileType.GROUND )
            {
                // 移動先が地面ならブロックの番号に更新
                tileList[ nextBlockPos.x, nextBlockPos.y ] = TileType.BLOCK;
            }
            else if ( tileList[ nextBlockPos.x, nextBlockPos.y ] == TileType.TARGET )
            {
                // 移動先が目的地ならブロック(目的地の上)の番号に更新
                tileList[ nextBlockPos.x, nextBlockPos.y ] = TileType.BLOCK_ON_TARGET;
            }

            // プレイヤーの現在地のタイルの情報を更新
            UpdateGameObjectPosition( currentPlayerPos );

            // プレイヤーを移動
            player.transform.position = GetDisplayPosition( nextPlayerPos.x, nextPlayerPos.y );

            // プレイヤーの位置を更新
            gameObjectPosTable[ player ] = nextPlayerPos;

            // プレイヤーの移動先の番号を更新
            if ( tileList[ nextPlayerPos.x, nextPlayerPos.y ] == TileType.GROUND )
            {
                // 移動先が地面ならプレイヤーの番号に更新
                tileList[ nextPlayerPos.x, nextPlayerPos.y ] = TileType.PLAYER;
            }
            else if ( tileList[ nextPlayerPos.x, nextPlayerPos.y ] == TileType.TARGET )
            {
                // 移動先が目的地ならプレイヤー(目的地の上)の番号に更新
                tileList[ nextPlayerPos.x, nextPlayerPos.y ] = TileType.PLAYER_ON_TARGET;
            }
        }
    }
    // プレイヤーの移動先にブロックが存在しない場合
    else
    {
        // プレイヤーの現在地のタイルの情報を更新
        UpdateGameObjectPosition( currentPlayerPos );

        // プレイヤーを移動
        player.transform.position = GetDisplayPosition( nextPlayerPos.x, nextPlayerPos.y );

        // プレイヤーの位置を更新
        gameObjectPosTable[ player ] = nextPlayerPos;

        // プレイヤーの移動先の番号を更新
        if ( tileList[ nextPlayerPos.x, nextPlayerPos.y ] == TileType.GROUND )
        {
            // 移動先が地面ならプレイヤーの番号に更新
            tileList[ nextPlayerPos.x, nextPlayerPos.y ] = TileType.PLAYER;
        }
        else if ( tileList[ nextPlayerPos.x, nextPlayerPos.y ] == TileType.TARGET )
        {
            // 移動先が目的地ならプレイヤー(目的地の上)の番号に更新
            tileList[ nextPlayerPos.x, nextPlayerPos.y ] = TileType.PLAYER_ON_TARGET;
        }
    }

    // ゲームをクリアしたかどうか確認
    CheckCompletion();
}

TryMovePlayer 関数で移動処理を実装するために、
他にも必要な関数を定義していきます

まずは、指定された位置と方向から、
移動先を返す GetNextPositionAlong 関数を定義します

// 指定された方向の位置を返す
private Vector2Int GetNextPositionAlong( Vector2Int pos, DirectionType direction )
{
    switch ( direction )
    {
        // 上
        case DirectionType.UP:
            pos.y -= 1;
            break;

        // 右
        case DirectionType.RIGHT:
            pos.x += 1;
            break;

        // 下
        case DirectionType.DOWN:
            pos.y += 1;
            break;

        // 左
        case DirectionType.LEFT:
            pos.x -= 1;
            break;
    }
    return pos;
}

次に、プレイヤーやブロックが移動した時に、
タイルの番号を更新する UpdateGameObjectPosition 関数を定義します

// 指定された位置のタイルを更新
private void UpdateGameObjectPosition( Vector2Int pos )
{
    // 指定された位置のタイルの番号を取得
    var cell = tileList[ pos.x, pos.y ];

    // プレイヤーもしくはブロックの場合
    if ( cell == TileType.PLAYER || cell == TileType.BLOCK )
    {
        // 地面に変更
        tileList[ pos.x, pos.y ] = TileType.GROUND;
    }
    // 目的地に乗っているプレイヤーもしくはブロックの場合
    else if ( cell == TileType.PLAYER_ON_TARGET || cell == TileType.BLOCK_ON_TARGET )
    {
        // 目的地に変更
        tileList[ pos.x, pos.y ] = TileType.TARGET;
    }
}

ステージクリア

最後に、すべてのブロックが目的地の上に乗っている場合、
ゲームクリアのフラグを立てる CheckCompletion 関数を定義します

// ゲームをクリアしたかどうか確認
private void CheckCompletion()
{
    // 目的地に乗っているブロックの数を計算
    int blockOnTargetCount = 0;

    for ( int y = 0; y < rows; y++ )
    {
        for ( int x = 0; x < columns; x++ )
        {
            if ( tileList[ x, y ] == TileType.BLOCK_ON_TARGET )
            {
                blockOnTargetCount++;
            }
        }
    }

    // すべてのブロックが目的地の上に乗っている場合
    if ( blockOnTargetCount == blockCount )
    {
        // ゲームクリア
        isClear = true;
    }
}

これで、Unity を再生すると、倉庫番が完成したことが確認できます

f:id:baba_s:20180328202256g:plain

まとめ

https://gist.github.com/baba-s/e4da98f6fce2b6cca36b0cf3bb91f9d0

Sokoban.cs の完成版のコードは上記のページで公開しています
必要であれば、こちらも参考にして頂ければと思います

今回は、倉庫番の基本的なゲームロジックのみを実装していきました
プレイヤーのアニメーションやゲームクリア演出を実装したり、
複数のステージを作成したりするなど、
興味がある方はそういったことにもチャレンジしてみて頂ければと思います

参考サイト様

今回紹介した、倉庫番のチュートリアルは、
上記のサイト様が公開されている記事を翻訳したモノになります

関連記事