読者です 読者をやめる 読者になる 読者になる

スマゲ

スマートなゲームづくりを目指して日々精進

Unityでパズドラ風3Matchパズルを作る_その1_ロジック編

Unityでパズドラ風の3Matchパズルを作ってみます

■シリーズ全体での完成イメージ
f:id:sanukin39:20170504232701g:plain

■本編の完成イメージ
※演出は実装していないので一瞬で処理が終わります
f:id:sanukin39:20170423202823g:plain
※サクッとコードだけ確認したい方はこちら
github.com

■環境
Mac OS Sierra 10.12.4
Unity 5.6.0 f3
言語 C#

■プロジェクトの準備
Unityを立ち上げNewProjectを選択、プロジェクトを2Dにして新規作成を行う
f:id:sanukin39:20170423152427p:plain

■メインシーンの準備
[File] -> [New Scene]から新しくシーンを作成、セーブする
f:id:sanukin39:20170423152612p:plain

■背景の設定
シーン内にある[Main Camera]を選択、[Camera]スクリプトの[Clear Flags]を[Solid Color]、[Background]にて背景色を白にする
f:id:sanukin39:20170423152830p:plain

■ピースの定義
スクリプト[PieceKind.cs]を作成し、ゲーム内にて利用するピースの種類を定義する

public enum PieceKind // ゲーム内にて利用するピースの種類
{
    Red = 0,    // 火
    Blue,       // 水
    Green,      // 緑
    Yellow,     // 光
    Black,      // 闇
    Magenta,    // 回復
}

■ピース用のイメージをインポート
ピース表示に利用する画像をUnityにインポートする
f:id:sanukin39:20170423153938p:plain

■ピースオブジェクトを作成
[Hierarchy] -> [Create] -> [UI] -> [Image]を選択し、ゲームオブジェクトを作成、インポートした画像をアタッチする
作成したゲームオブジェクトの名前を[Piece]にリネームする。
f:id:sanukin39:20170423153735p:plain
f:id:sanukin39:20170423154043p:plain

■ピース用クラスを作成
スクリプト[Piece.cs]を作成、作成したオブジェクトにアタッチする
f:id:sanukin39:20170423154156p:plain

■ピース用プレハブを作成
作成したオブジェクトを[Project]タブにドラックドロップし、プレハブを生成する。その後、シーン内にあるオブジェクトを削除する
f:id:sanukin39:20170423154529p:plain

■Piece.csの作成
[Piece.cs]をエディタで開き、以下のコードを記述する

主な機能は削除フラグの管理、ピースの種類、色、サイズの設定となります

using UnityEngine;
using UnityEngine.UI;

// ピースクラス
public class Piece : MonoBehaviour
{
    // public.
    public bool deleteFlag;

    // private.
    private Image thisImage;
    private RectTransform thisRectTransform;
    private PieceKind kind;

    //-------------------------------------------------------
    // MonoBehaviour Function
    //-------------------------------------------------------
    // 初期化処理
    private void Awake()
    {
        // アタッチされている各コンポーネントを取得
        thisImage = GetComponent<Image>();
        thisRectTransform = GetComponent<RectTransform>();

        // フラグを初期化
        deleteFlag = false;
    }

    //-------------------------------------------------------
    // Public Function
    //-------------------------------------------------------
    // ピースの種類とそれに応じた色をセットする
    public void SetKind(PieceKind pieceKind)
    {
        kind = pieceKind;
        SetColor();
    }

    // ピースの種類を返す
    public PieceKind GetKind()
    {
        return kind;
    }

    // ピースのサイズをセットする
    public void SetSize(int size)
    {
        this.thisRectTransform.sizeDelta = Vector2.one * size;
    }

    //-------------------------------------------------------
    // Private Function
    //-------------------------------------------------------
    // ピースの色を自身の種類の物に変える
    private void SetColor()
    {
        switch (kind)
        {
            case PieceKind.Red:
                thisImage.color = Color.red;
                break;
            case PieceKind.Blue:
                thisImage.color = Color.blue;
                break;
            case PieceKind.Green:
                thisImage.color = Color.green;
                break;
            case PieceKind.Yellow:
                thisImage.color = Color.yellow;
                break;
            case PieceKind.Black:
                thisImage.color = Color.black;
                break;
            case PieceKind.Magenta:
                thisImage.color = Color.magenta;
                break;
            default:
                break;
        }
    }
}

■GameManager.csの作成
スクリプト[GameManager.cs]を作成、同時にシーン内にも空オブジェクトを作成し、アタッチする
f:id:sanukin39:20170423191250p:plain

■GameManager.csの編集
[GameManager.cs]をエディタで開き、以下のコードを記述する

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

// ゲーム管理クラス
public class GameManager : MonoBehaviour {

    // const.
    public const int MachingCount = 3;

    // enum.
    private enum GameState
    {
        Idle,
        PieceMove,
        MatchCheck,
        DeletePiece,
        FillPiece,
    }

    // serialize field.
    [SerializeField]
    private Board board;
    [SerializeField]
    private Text stateText;

    // private.
    private GameState currentState;
    private Piece selectedPiece;

    //-------------------------------------------------------
    // MonoBehaviour Function
    //-------------------------------------------------------
    // ゲームの初期化処理
    private void Start()
    {
        board.InitializeBoard(6, 5);

        currentState = GameState.Idle;
    }

    // ゲームのメインループ
    private void Update()
    {
        switch (currentState)
        {
            case GameState.Idle:
                Idle();
                break;
            case GameState.PieceMove:
                PieceMove();
                break;
            case GameState.MatchCheck:
                MatchCheck();
                break;
            case GameState.DeletePiece:
                DeletePiece();
                break;
            case GameState.FillPiece:
                FillPiece();
                break;
            default:
                break;
        }
        stateText.text = currentState.ToString();
    }

    //-------------------------------------------------------
    // Private Function
    //-------------------------------------------------------
    // プレイヤーの入力を検知し、ピースを選択状態にする
    private void Idle()
    {
        if (Input.GetMouseButtonDown(0))
        {
            selectedPiece = board.GetNearestPiece(Input.mousePosition);
            currentState = GameState.PieceMove;
        }
    }

    // プレイヤーがピースを選択しているときの処理、入力終了を検知したら盤面のチェックの状態に移行する
    private void PieceMove()
    {
        if (Input.GetMouseButton(0))
        {
            var piece = board.GetNearestPiece(Input.mousePosition);
            if (piece != selectedPiece)
            {
                board.SwitchPiece(selectedPiece, piece);
            }
        }
        else if (Input.GetMouseButtonUp(0)) {
            currentState = GameState.MatchCheck;
        }
    }

    // 盤面上にマッチングしているピースがあるかどうかを判断する
    private void MatchCheck()
    {
        if (board.HasMatch())
        {
            currentState = GameState.DeletePiece;
        }
        else
        {
            currentState = GameState.Idle;
        }
    }

    // マッチングしているピースを削除する
    private void DeletePiece()
    {
        board.DeleteMatchPiece();
        currentState = GameState.FillPiece;
    }

    // 盤面上のかけている部分にピースを補充する
    private void FillPiece()
    {
        board.FillPiece();
        currentState = GameState.MatchCheck;
    }
}

■Board.csの作成
スクリプト[Board.cs]を作成、同時にシーン内の[Canvas]に空の[RectTransform]オブジェクトを作成、このスクリプトをアタッチする
作成したゲームオブジェクトの名前を[Board]に変更する
f:id:sanukin39:20170423205027p:plain
f:id:sanukin39:20170423205031p:plain

■Board.csの編集
[Board.cs]をエディタで開き、以下のように記述する

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// 盤面クラス
public class Board : MonoBehaviour {

    // serialize field.
    [SerializeField]
    private GameObject piecePrefab;

    // private.
    private Piece[,] board;
    private int width;
    private int height;
    private int pieceWidth;
    private int randomSeed;

    //-------------------------------------------------------
    // Public Function
    //-------------------------------------------------------
    // 特定の幅と高さに盤面を初期化する
    public void InitializeBoard(int boardWidth, int boardHeight)
    {
        width = boardWidth;
        height = boardHeight;

        pieceWidth = Screen.width / boardWidth;

        board = new Piece[width, height];

        for (int i = 0; i < boardWidth; i++)
        {
            for (int j = 0; j < boardHeight; j++)
            {
                CreatePiece(new Vector2(i, j));
            }
        }
    }

    // 入力されたクリック(タップ)位置から最も近いピースの位置を返す
    public Piece GetNearestPiece(Vector3 input)
    {
        var minDist = float.MaxValue;
        Piece nearestPiece = null;

        // 入力値と盤面のピース位置との距離を計算し、一番距離が短いピースを探す
        foreach (var p in board)
        {
            var dist = Vector3.Distance(input, p.transform.position);
            if (dist < minDist)
            {
                minDist = dist;
                nearestPiece = p;
            }
        }

        return nearestPiece;
    }

    // 盤面上のピースを交換する
    public void SwitchPiece(Piece p1, Piece p2)
    {
        // 位置を移動する
        var p1Position = p1.transform.position;
        p1.transform.position = p2.transform.position;
        p2.transform.position = p1Position;

        // 盤面データを更新する
        var p1BoardPos = GetPieceBoardPos(p1);
        var p2BoardPos = GetPieceBoardPos(p2);
        board[(int)p1BoardPos.x, (int)p1BoardPos.y] = p2;
        board[(int)p2BoardPos.x, (int)p2BoardPos.y] = p1;
    }

    // 盤面上にマッチングしているピースがあるかどうかを判断する
    public bool HasMatch()
    {
        foreach (var piece in board)
        {
            if (IsMatchPiece(piece))
            {
                return true;
            }
        }
        return false;
    }

    // マッチングしているピースを削除する
    public void DeleteMatchPiece()
    {
        // マッチしているピースの削除フラグを立てる
        foreach (var piece in board)
        {
            piece.deleteFlag = IsMatchPiece(piece);
        }

        // 削除フラグが立っているオブジェクトを削除する
        foreach (var piece in board)
        {
            if (piece != null && piece.deleteFlag)
            {
                Destroy(piece.gameObject);
            }
        }
    }

    // ピースが消えている場所を詰めて、新しいピースを生成する
    public void FillPiece()
    {
        for (int i = 0; i < width; i++)
        {
            for (int j = 0; j < height; j++)
            {
                FillPiece(new Vector2(i, j));
            }
        }
    }

    //-------------------------------------------------------
    // Private Function
    //-------------------------------------------------------
    // 特定の位置にピースを作成する
    private void CreatePiece(Vector2 position)
    {
        // ピースの生成位置を求める
        var createPos = GetPieceWorldPos(position);

        // 生成するピースの種類をランダムに決める
        var kind = (PieceKind)UnityEngine.Random.Range(0, Enum.GetNames(typeof(PieceKind)).Length);

        // ピースを生成、ボードの子オブジェクトにする
        var piece = Instantiate(piecePrefab, createPos, Quaternion.identity).GetComponent<Piece>();
        piece.transform.SetParent(transform);
        piece.SetSize(pieceWidth);
        piece.SetKind(kind);

        // 盤面にピースの情報をセットする
        board[(int)position.x, (int)position.y] = piece;
    }

    // 盤面上の位置からピースオブジェクトのワールド座標での位置を返す
    private Vector3 GetPieceWorldPos(Vector2 boardPos)
    {
        return new Vector3(boardPos.x* pieceWidth + (pieceWidth / 2), boardPos.y* pieceWidth + (pieceWidth / 2), 0);
    }

    // ピースが盤面上のどの位置にあるのかを返す
    private Vector2 GetPieceBoardPos(Piece piece)
    {
        for (int i = 0; i < width; i++)
        {
            for (int j = 0; j < height; j++)
            {
                if (board[i, j] == piece)
                {
                    return new Vector2(i, j);
                }
            }
        }

        return Vector2.zero;
    }

    // 対象のピースがマッチしているかの判定を行う
    private bool IsMatchPiece(Piece piece)
    {
        // ピースの情報を取得
        var pos = GetPieceBoardPos(piece);
        var kind = piece.GetKind();

        // 縦方向にマッチするかの判定 MEMO: 自分自身をカウントするため +1 する
        var verticalMatchCount = GetSameKindPieceNum(kind, pos, Vector2.up) + GetSameKindPieceNum(kind, pos, Vector2.down) + 1;

        // 横方向にマッチするかの判定 MEMO: 自分自身をカウントするため +1 する
        var horizontalMatchCount = GetSameKindPieceNum(kind, pos, Vector2.right) + GetSameKindPieceNum(kind, pos, Vector2.left) + 1;

        return verticalMatchCount >= GameManager.MachingCount || horizontalMatchCount >= GameManager.MachingCount;
    }

    // 対象の方向に引数で指定したの種類のピースがいくつあるかを返す
    private int GetSameKindPieceNum(PieceKind kind, Vector2 piecePos, Vector2 searchDir)
    {
        var count = 0;
        while (true)
        {
            piecePos += searchDir;
            if (IsInBoard(piecePos) && board[(int)piecePos.x, (int)piecePos.y].GetKind() == kind)
            {
                count++;
            }
            else
            {
                break;
            }
        }
        return count;
    }

    // 対象の座標がボードに存在するか(ボードからはみ出していないか)を判定する
    private bool IsInBoard(Vector2 pos)
    {
        return pos.x >= 0 && pos.y >= 0 && pos.x < width && pos.y < height;
    }

    // 特定のピースのが削除されているかを判断し、削除されているなら詰めるか、それができなければ新しく生成する
    private void FillPiece(Vector2 pos)
    {
        var piece = board[(int)pos.x, (int)pos.y];
        if (piece != null && !piece.deleteFlag)
        {
            // ピースが削除されていなければ何もしない
            return;
        }

        // 対象のピースより上方向に有効なピースがあるかを確認、あるなら場所を移動させる
        var checkPos = pos + Vector2.up;
        while (IsInBoard(checkPos))
        {
            var checkPiece = board[(int)checkPos.x, (int)checkPos.y];
            if (checkPiece != null && !checkPiece.deleteFlag)
            {
                checkPiece.transform.position = GetPieceWorldPos(pos);
                board[(int)pos.x, (int)pos.y] = checkPiece;
                board[(int)checkPos.x, (int)checkPos.y] = null;
                return;
            }
            checkPos += Vector2.up;
        }

        // 有効なピースがなければ新しく作る
        CreatePiece(pos);
    }
}

■状態表示用テキストの作成
[Hierarchy] -> [Create] -> [UI] -> [Text] からTextオブジェクトを作成し、[StateText]と名前を変更する
その後画面上部の見やすい位置に移動、フォントサイズなどを調整する
f:id:sanukin39:20170423205433p:plain

■各スクリプトの関連付け
シーン内の[GameManager]オブジェクトを選択し、作成したBoardスクリプトとTextスクリプトを紐付ける
f:id:sanukin39:20170423205820p:plain
さらに、シーン内のBoardオブジェクトを選択肢、前に作成した[Piece]プレハブを紐付ける
f:id:sanukin39:20170423205824p:plain

■Hierarchyの構成
f:id:sanukin39:20170423205944p:plain

■各クラスの役割
・Piece.cs
盤面上のピースの振る舞いを定義する。ピースの種類を持ち、自身の色やサイズを変更することができる
・Board.cs
盤面そのものを管理する。盤面の初期化から、ピースの移動/削除/補充などを行う
・GameManager.cs
ゲームの状態を管理する。ユーザーの入力を検知し、Board.csに適切な命令を出し、ゲームを進行させる

■ゲームの流れ
1. GameManager.Startが呼ばれ、Board.InitializeBoardを呼び出し盤面を初期化する。その後ゲームの状態をIdle(ユーザーの入力待ち)にする

2. GameManager.UpdateによりGameManager.Idleが毎フレーム呼ばれる。ここではユーザーの入力を監視している

3. Idleの状態でユーザーが入力を行った場合、入力された場所から一番近いピースを検知し選択状態にする(Board.GetNearestPiece)。
その後ゲームの状態をMoving(ピース移動中)にする

4. GameManager.UpdateによりGameManager.Movingが毎フレーム呼ばれる
ここではユーザーがタッチしている場所から一番近いピースを探し出し(Board.GetNearestPiece)、それが選択されたピースと違うならば盤面のピースを交換する(Board.SwitchPiece)

5. GameManager.Moving内部でユーザーのタッチ終了を検知した場合ゲームの状態をMatchCheck(マッチ確認)へ移動する

6. GameManager.MatchCheck内部にて、マッチしているピースがあるかどうかを調べる(Board.HasMatch)。ないならばゲームの状態をIdleにする、あるならばDleletePiece(ピース削除)にする

7. GameManager.DeletePieceにて、マッチしているピースを削除する(Board.DeleteMathPiece)。その後、ゲームの状態をFillPiece(ピース補充)にする。

8. GameManager.FillPieceにて足りなくなったピースを補充する(Board.FillPiece)。その後ゲームの状態をMatchCheckにする

9. GameManager.MatchCheckにてマッチングしているピースがあればゲーム状態が再度DeletePieceになりピースの削除処理が開始される(落ちコン)。なければIdleになりユーザーの入力待ちに戻る

■その2_演出編
今回はパズルのメインとなる部分のロジック部分のみの実装となるため、ピースの削除処理と補充処理が同時に行われていしまい、挙動がよくわからなくなっています。
よって[その2_演出編]ではコールバックとコルーチンを用いて削除演出と補充演出を実装し、もっとゲームっぽく仕上げようと思います。
Unityでパズドラ風3Matchパズルを作る_その2_演出編1 - スマゲ

■おまけ
GameManage.csの初期化する盤面のマス数や、マッチする最低数を変更することによってさらに盤面の細かくしたり、難易度をあげたりすることができます。

board.InitializeBoard(10, 8);

f:id:sanukin39:20170423212411g:plain

github.com

■関連リンク
Unityでパズドラ風3Matchパズルを作る_その2_演出編1 - スマゲ
Unityでパズドラ風3Matchパズルを作る_その3_演出編2 - スマゲ
Unityでパズドラ風3Matchパズルを作る_その4_演出編3 - スマゲ
Unityでパズドラ風3Matchパズルを作る_その5_演出編4 - スマゲ
Unityでパズドラ風3Matchパズルを作る_その6_ステータス表示編 - スマゲ