Unityで15パズルを作成しよう!初心者向け完全ガイド

2024年7月2日

こんにちは!ジェイです。前回、ブラックジャックとマインスイーパーについての解説をしました。今回は15パズル(スライディングパズル)を作っていきます!

何気に今までのミニゲーム制作で1番苦戦したので、注意点などもしっかりと説明していきます。

Unityプロジェクトの作成から必要なアセットの準備、基本的なコーディングまで、詳細な説明とコード例を交えて分かりやすく紹介します。

初心者でも迷わず進められるよう、各ステップごとに丁寧に解説しているので、ぜひチャレンジしてみてください。ゲーム開発の基本を学びながら、楽しい15パズルゲームを一緒に作りましょう。

準備

Unityプロジェクトの作成

Unityを起動し、新しい2Dプロジェクトを作成します。15パズルの絵となる画像を最低1枚は用意しておきましょう。アスペクト比はunityroomに対応している960✕540にしておきます。

GameManager.csの作成

まず、以下のスクリプトをコピペして空のオブジェクトを作成してGameManagerと名付けてアタッチしましょう。そして以下の4つのオブジェクトを準備してアタッチすれば、ゲームが機能します。

  • タイルのプレハブのtilePrefab
  • gridParent
  • fullImage
  • gameClearText
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class GameManager : MonoBehaviour
{
    public GameObject tilePrefab;  // タイルのプレハブ
    public Transform gridParent; // Grid Layout Groupがアタッチされているオブジェクト
    public Sprite fullImage;  // 1枚のスプライト画像
    public Text gameClearText; // ゲームクリアのテキスト表示
    private List<GameObject> tiles = new List<GameObject>();
    private int emptyTileIndex = 15;  // 空白タイルのインデックス
    private int gridSize = 4;
    private Sprite transparentSprite; // 透明なスプライト

    void Start()
    {
        CreateTransparentSprite();
        CreateTiles();
        StartCoroutine(ShuffleTilesWithDelay(0.1f)); // 0.1秒後にシャッフルを実行
        gameClearText.gameObject.SetActive(false); // ゲーム開始時は非表示
    }

    void CreateTransparentSprite()
    {
        // 1x1ピクセルの透明なテクスチャを作成
        Texture2D texture = new Texture2D(1, 1);
        texture.SetPixel(0, 0, new Color(0, 0, 0, 0));
        texture.Apply();
        
        // テクスチャからスプライトを作成
        transparentSprite = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f));
    }

    void CreateTiles()
    {
        Texture2D texture = fullImage.texture;
        int tileWidth = texture.width / gridSize;
        int tileHeight = texture.height / gridSize;
        float rx = gridParent.GetComponent<RectTransform>().sizeDelta.x;
        float ry = gridParent.GetComponent<RectTransform>().sizeDelta.y;
        float tileWidth2 = rx / gridSize;
        float tileHeight2 = ry / gridSize;
        for (int i = 0; i < gridSize * gridSize; i++)
        {
            GameObject tile = Instantiate(tilePrefab, gridParent); // gridParentの子としてタイルを生成
            tile.GetComponentInChildren<Text>().text = (i + 1).ToString();
            tile.GetComponent<Button>().onClick.AddListener(() => OnTileClick(tile));

            // スプライトを設定
            Image tileImage = tile.GetComponent<Image>();
            Rect spriteRect = new Rect((i % gridSize) * tileWidth, ((gridSize - 1) - (i / gridSize)) * tileHeight, tileWidth, tileHeight);
            Sprite tileSprite = Sprite.Create(texture, spriteRect, new Vector2(0.5f, 0.5f));
            tileImage.sprite = tileSprite;

            tiles.Add(tile);
        }
        
        // 空白タイルの設定
        Image emptyTileImage = tiles[emptyTileIndex].GetComponent<Image>();
        emptyTileImage.sprite = transparentSprite; // 透明なスプライトを設定
        tiles[emptyTileIndex].GetComponentInChildren<Text>().text = ""; // テキストを非表示
    }

    IEnumerator ShuffleTilesWithDelay(float delay)
    {
        yield return new WaitForSeconds(delay);
        ShuffleTiles();
    }

    void ShuffleTiles()
    {
        // タイルをシャッフルする回数
        int shuffleCount = 100;

        for (int i = 0; i < shuffleCount; i++)
        {
            int randomIndex = Random.Range(0, 15); // 0から14までのランダムなインデックスを取得(空白タイルを除く)
            if (IsValidMove(randomIndex))
            {
                SwapTiles(randomIndex, emptyTileIndex);
                emptyTileIndex = randomIndex;
            }
        }
    }

    public void OnTileClick(GameObject tile)
    {
        int tileIndex = tiles.IndexOf(tile);
        if (IsValidMove(tileIndex))
        {
            SwapTiles(tileIndex, emptyTileIndex);
            emptyTileIndex = tileIndex;
            if (CheckIfGameClear())
            {
                StartCoroutine(HandleGameClear());
            }
        }
    }

    bool IsValidMove(int tileIndex)
    {
        // 空白タイルのインデックス
        int emptyX = emptyTileIndex % gridSize;
        int emptyY = emptyTileIndex / gridSize;

        // クリックされたタイルのインデックス
        int tileX = tileIndex % gridSize;
        int tileY = tileIndex / gridSize;

        // タイルが空白タイルの隣にあるかどうかをチェック
        if ((Mathf.Abs(tileX - emptyX) == 1 && tileY == emptyY) || (Mathf.Abs(tileY - emptyY) == 1 && tileX == emptyX))
        {
            return true;
        }

        return false;
    }

    void SwapTiles(int tileIndex1, int tileIndex2)
    {
        Vector3 tempPos = tiles[tileIndex1].transform.position;
        tiles[tileIndex1].transform.position = tiles[tileIndex2].transform.position;
        tiles[tileIndex2].transform.position = tempPos;

        GameObject tempTile = tiles[tileIndex1];
        tiles[tileIndex1] = tiles[tileIndex2];
        tiles[tileIndex2] = tempTile;
    }

    bool CheckIfGameClear()
    {
        for (int i = 0; i < tiles.Count; i++)
        {
            if (tiles[i].GetComponentInChildren<Text>().text != (i + 1).ToString())
            {
                return false;
            }
        }
        return true;
    }

    IEnumerator HandleGameClear()
    {
        gameClearText.gameObject.SetActive(true);
        yield return new WaitForSeconds(5f);
        gameClearText.gameObject.SetActive(false);
        ResetGame();
    }

    void ResetGame()
    {
        foreach (var tile in tiles)
        {
            Destroy(tile);
        }
        tiles.Clear();
        CreateTiles();
        StartCoroutine(ShuffleTilesWithDelay(3f));
    }
}

GamaManager.csの説明

ステップ1: タイルの生成

Start関数内で、まずCreateTransparentSpriteを実行して、透明なスプライトを1枚作成します。次にスプライト画像をgridSize✕gridSize文だけ分割してタイルを生成し、gridParentの下に配置します。

ステップ2: タイルのシャッフル

ShuffleTilesWithDelayでは、タイルをランダムにシャッフルする機能を実行します。これにはコルーチンを使って、0.1秒後にシャッフルが開始されるようにします。この時間の感覚がないとシャッフルが上手くいかないので入れてます。

ステップ3: タイルのクリック操作

タイルをクリックするとOnTileClickが実行されて、IsValidMoveで移動可能と判定された時のみ、SwapTilesで空白タイルと交換する処理をしてます。

ステップ4: ゲームクリアの判定とリセット

パネルをクリックした後、CheckIfGameClearでゲームクリアの判定を行い、HandleGameClearが実行されて、クリア時にメッセージを表示して5秒後にゲームをリセットされます。

タイルのプレハブの作り方

それでは、GameManagerにアタッチする用のtilePrefabを作っていきましょう。これがパネルの1/16の画像が表示されます。

  1. 空のオブジェクトを作成して、ImageコンポーネントとButtonコンポーネントを追加します。
  2. Imageコンポーネントにスプライトを設定し、Buttonコンポーネントにクリックリスナーを追加します。
  3. ドラッグ&ドロップでプレハブ化しましょう。ヒエラルキーに残っているものは消してOKです。
  4. 最後にGameManagerのTilePrefabにアタッチしておきましょう。

TileParentを作る

  • Canvasの下に空のオブジェクトを作成して、TileParentと名付けます。
  • TileParentオブジェクトにGrid Layout Groupコンポーネントをアタッチします。

TileParentはTilePrefabのレイアウトを管理する役割です。RectTransformのWidthとHeightが全体の画像のサイズでCell Sizeが1枚あたりのサイズになります。15パズルでは、グリッドの数が4✕4なのでWidthとHeightの1/4の値を入れるとちょうどよく値になります。

また、忘れずにGameManagerのGridParentにTileParentをアタッチしておきましょう。

これで、基本的な15パズルゲームが完成です!

ゲームの開始時にタイルがシャッフルされ、タイルをクリックして空白タイルと交換し、すべてのタイルが正しい位置に並んだらゲームクリアと表示され、5秒後にゲームがリセットされます。

私は、最終的にはタイルプレハブの番号をTextでなくTileスクリプトを作って保存しておくようにしました。それに加えタイムも追加したので、最終的なスクリプトを以下に掲載しておきます。

using UnityEngine;

public class Tile : MonoBehaviour
{
    public int tileNumber; // タイルの番号
}
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.UIElements;
using unityroom.Api;

public class GameManager : MonoBehaviour
{
    public GameObject tilePrefab;  // タイルのプレハブ
    public Transform gridParent; // Grid Layout Groupがアタッチされているオブジェクト
    public Sprite[] CharaSprites;
    private Sprite fullImage; // 分割する画像を指定
    private List<GameObject> tiles = new List<GameObject>();
    private int emptyTileIndex = 15;  // 空白タイルのインデックス
    private int gridSize = 4;
    private Sprite transparentSprite; // 透明なスプライト
    public TextMeshProUGUI gameClearText; // ゲームクリアのテキスト表示
    public TextMeshProUGUI scoreText;
    float elpasedTime_;
    bool ClearFlag = false;
    Sprite temptySprite;
    void Start()
    {
        elpasedTime_ = 0.0f;
        int rand = Random.RandomRange(0, CharaSprites.Length);
        fullImage = CharaSprites[rand];
        //if (rand < 8)
        {
            float width = gridParent.GetComponent<RectTransform>().sizeDelta.x / gridSize;
            float height = Mathf.Abs(gridParent.GetComponent<RectTransform>().sizeDelta.y / gridSize);
            gridParent.GetComponent<GridLayoutGroup>().cellSize = new Vector2(width, height);
        }
        //else
        //{
        //    gridParent.GetComponent<RectTransform>().sizeDelta = new Vector2(960, 540);
        //    gridParent.GetComponent<RectTransform>().offsetMin = new Vector2(0, 540);
        //    gridParent.GetComponent<GridLayoutGroup>().cellSize = new Vector2(960/gridSize, 540/gridSize);
        //}
        CreateTransparentSprite();
        CreateTiles();
        StartCoroutine(ShuffleTilesWithDelay(0.01f));
        gameClearText.gameObject.SetActive(false);
    }
    void Update()
    {
        if (ClearFlag) return;
        // 1フレームの経過時間を計測
        elpasedTime_ += Time.deltaTime;
        scoreText.text = "Time " + Mathf.Floor(elpasedTime_).ToString();
    }
    void CreateTransparentSprite()
    {
        // 1x1ピクセルの透明なテクスチャを作成
        Texture2D texture = new Texture2D(1, 1);
        texture.SetPixel(0, 0, new Color(0, 0, 0, 0));
        texture.Apply();

        // テクスチャからスプライトを作成
        transparentSprite = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f));
    }
    void CreateTiles()
    {
        Texture2D texture = fullImage.texture;
        int tileWidth = texture.width / gridSize;
        int tileHeight = texture.height / gridSize;
        float rx = gridParent.GetComponent<RectTransform>().sizeDelta.x;
        float ry = gridParent.GetComponent<RectTransform>().sizeDelta.y;
        float tileWidth2 = rx / gridSize;
        float tileHeight2 = ry / gridSize;
        for (int i = 0; i < gridSize*gridSize; i++)
        {
            GameObject tile = Instantiate(tilePrefab, gridParent); // gridLayoutGroupの子としてタイルを生成
            tile.GetComponent<Tile>().tileNumber = i;
            tile.GetComponent<UnityEngine.UI.Button>().onClick.AddListener(() => OnTileClick(tile));

            // スプライトを設定
            UnityEngine.UI.Image tileImage = tile.GetComponent<UnityEngine.UI.Image>();
            Rect spriteRect = new Rect((i%gridSize) * tileWidth, ((gridSize - 1) - (i/gridSize)) * tileHeight, tileWidth, tileHeight);
            Sprite tileSprite = Sprite.Create(texture, spriteRect, new Vector2(0.5f, 0.5f));
            tileImage.sprite = tileSprite;
            
            tiles.Add(tile);
        }
        // 空白タイルの設定
        UnityEngine.UI.Image emptyTileImage = tiles[emptyTileIndex].GetComponent<UnityEngine.UI.Image>();
        temptySprite = emptyTileImage.sprite;
        emptyTileImage.sprite = transparentSprite; // 透明なスプライトを設定
    }
    IEnumerator ShuffleTilesWithDelay(float delay)
    {
        yield return new WaitForSeconds(delay);
        ShuffleTiles();
    }
    void ShuffleTiles()
    {
        // タイルをシャッフルする回数
        int shuffleCount = 1000;
        for (int i = 0; i < shuffleCount; i++)
        {
            int randomIndex = Random.Range(0, 15); // 0から14までのランダムなインデックスを取得(空白タイルを除く)
            if (IsValidMove(randomIndex))
            {
                SwapTiles(randomIndex, emptyTileIndex);
                emptyTileIndex = randomIndex;
            }
        }
    }
    public void OnTileClick(GameObject tile)
    {
        if (ClearFlag) return;

        int tileIndex = tiles.IndexOf(tile);
        if (IsValidMove(tileIndex))
        {
            SwapTiles(tileIndex, emptyTileIndex);
            emptyTileIndex = tileIndex;
            if (CheckIfGameClear())
            {
                StartCoroutine(HandleGameClear());
            }
        }
    }
    bool IsValidMove(int tileIndex)
    {
        // 空白タイルのインデックス
        int emptyX = emptyTileIndex % gridSize;
        int emptyY = emptyTileIndex / gridSize;

        // クリックされたタイルのインデックス
        int tileX = tileIndex % gridSize;
        int tileY = tileIndex / gridSize;

        // タイルが空白タイルの隣にあるかどうかをチェック
        if ((Mathf.Abs(tileX - emptyX) == 1 && tileY == emptyY) || (Mathf.Abs(tileY - emptyY) == 1 && tileX == emptyX))
        {
            return true;
        }
        return false;
    }
    void SwapTiles(int tileIndex1, int tileIndex2)
    {
        Vector3 tempPos = tiles[tileIndex1].transform.position;
        tiles[tileIndex1].transform.position = tiles[tileIndex2].transform.position;
        tiles[tileIndex2].transform.position = tempPos;

        GameObject tempTile = tiles[tileIndex1];
        tiles[tileIndex1] = tiles[tileIndex2];
        tiles[tileIndex2] = tempTile;
    }
    bool CheckIfGameClear()
    {
        for (int i = 0; i < tiles.Count; i++)
        {
            if (tiles[i].GetComponent<Tile>().tileNumber != i)
            {
                return false;
            }
        }
        return true;
    }
    IEnumerator HandleGameClear()
    {
        UnityEngine.UI.Image emptyTileImage = tiles[emptyTileIndex].GetComponent<UnityEngine.UI.Image>();
        emptyTileImage.sprite = temptySprite; // 透明なスプライトを元に戻す

        ClearFlag = true;
        gameClearText.gameObject.SetActive(true);
        UnityroomApiClient.Instance.SendScore(1, Mathf.Floor(elpasedTime_), ScoreboardWriteMode.Always);
        yield return new WaitForSeconds(5f);
        ClearFlag = false;
        elpasedTime_ = 0.0f;
        scoreText.text = "Time";
        gameClearText.gameObject.SetActive(false);
        ResetGame();
    }
    void ResetGame()
    {
        foreach (var tile in tiles)
        {
            Destroy(tile);
        }
        tiles.Clear();
        CreateTiles();
        StartCoroutine(ShuffleTilesWithDelay(0.01f));
    }
}

CharaSpritesを配列にしてる理由は、何枚か用意したうちの絵の中からランダムでパズルとして使えるようにするためです。

ここまでの作業でゲーム開始してからタイムカウントが始まりゲームクリアすると初期化して何度もプレイできるようになりました。

初心者でも迷わず進められるよう、各ステップを丁寧に解説しました。ぜひチャレンジしてみてください!