[Unity]シックスボールパズル再現への道

## はじめに
シックスボールパズルというゲームは、ポピュラーな落ちものパズルである割に再現されている作品があまり見受けられません。

https://qiita.com/GANTZ/items/e6b5e9168b595404d582

こちらの記事でアルゴリズムを研究されていたため、参考にさせていただきつつよりリアルに近づけたいと考えました。本記事では落下のアルゴリズムについて詳しく記載します。

## 主に説明するスクリプト
関数名や変数名がわかりづらい場所がたくさんあると思います...　未熟者をお許しください

<details><summary> GridObserver(グリッドの情報を管理　落下アルゴリズムなど)

</summary><div>

~~~cs:GridObeserver
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Linq;
using System;

public class GridObserver : MonoBehaviour
{
    // Start is called before the first frame update
    [System.NonSerialized]
    //現在のグリッドの様子が数字で入っている配列
    public int[] gameSphereInfoArray = new int[70 + 63];

    [System.NonSerialized]
    //オブジェクトの落下可能性が0~2で入っている配列
    public int[] gameSphereBoolArray = new int[70 + 63];

    GameObject[] ObjectCheckArray = new GameObject[3];


    public void ArrayInfoInit(int[] array)
    {
        for (int i = 0; i < array.Length; i++)
        {
            array[i] = 0;
        }
    }

    public void ArrayBoolInfoInit(int[] array)
    {
        for (int i = 0; i < array.Length; i++)
        {
            array[i] = 0;
        }
    }

    //sphereをグリッドに合わせて配置し、情報配列とオブジェクト配列にそれぞれ登録
    public void GridTrack(GameObject[] sphereList, float sphereRadius, LayerMask gridMask, int[] GSIArray, GameObject[] GSArray, GameObject[] GridObjArray, int ListMax)
    {
        List<int> nowSpheresNum = new List<int>();
        foreach (GameObject sphere in sphereList)
        {
            Collider[] targetsInSphere = Physics.OverlapSphere(sphere.transform.position, sphereRadius, gridMask);
            //一番距離の近いグリッドを調べる
            int U = GridIdentify(targetsInSphere, sphere, GridObjArray);
            GridObjArray[U].SetActive(false);
            nowSpheresNum.Add(U);
        }
        
        while (nowSpheresNum.Min() < ListMax)
        {
            //nowSpheresの中で一番小さい数字が何番目か (U1 U2 U3)
            int u = nowSpheresNum.IndexOf(nowSpheresNum.Min());

            int colorNum = 0;

            switch (sphereList[u].tag)
            {
                case "Red":
                    colorNum = 1;
                    break;

                case "Blue":
                    colorNum = 2;
                    break;

                case "Green":
                    colorNum = 3;
                    break;

                case "Purple":
                    colorNum = 4;
                    break;

                case "Yellow":
                    colorNum = 5;
                    break;
            }

            //Uが一番小さいボールをGridObjectArrayのUの位置に飛ばす
            sphereList[u].transform.position = GridObjArray[nowSpheresNum[u]].transform.position;

            GSIArray[nowSpheresNum[u]] = colorNum;
            GSArray[nowSpheresNum[u]] = sphereList[u];

            //リストの当該部分を最小の値に選ばれないような大きい数にする
            nowSpheresNum[u] = ListMax;
        }

    }


    int GridIdentify(Collider[] targetInSpheres, GameObject sphere, GameObject[] GridObjArray)
    {
        float[] lengthArray = new float[targetInSpheres.Length];
        for (int i = 0; i < targetInSpheres.Length; i++)
        {
            lengthArray[i] = Vector3.Magnitude(sphere.transform.position - targetInSpheres[i].transform.position);
        }

        //一番小さいのはlengthArrayのminIndex番目の数字(距離)　
        int minIndex = Array.IndexOf(lengthArray, lengthArray.Min());

        return Array.IndexOf(GridObjArray, targetInSpheres[minIndex].gameObject);
    }

    public void NumFallPossibilitySet(int number, int[] GSIArray, int[] GSBArray, bool pastDelete)
    {
        //配列に何もないもしくはその場所の落下可能性がない場合、リターン
        if (GSIArray[number] < 1)
        {
            return;
        }

        //一週前にボールの削除が起こっていない場合
        if (!pastDelete)
        {
            if (GSBArray[number] > 1)
            {
                return;
            }
        }

        if (           
            //最下層
            number < 10
            //右端でbにボールがある
            || (number % 19 == 9 && GSBArray[number - 10] > 1)
            //左端でcにボールがある場合
            || (number % 19 == 0 && GSBArray[number - 9] > 1)
            //b,cにボールがある
            || (GSBArray[number - 10] > 1 && GSBArray[number - 9] > 1)
            
            )
        {
            GSBArray[number] = 2;
        }
        //b,dにボールがある
        else if (GSBArray[number - 10] > 1 && GSIArray[number + 1] > 0)
        {
            //偶数行目右端ならば
            if (number % 19 == 18)
            {
                GSBArray[number] = 1;
            }
            //一つ右が右端ならば
            else if ((number + 1) % 19 == 9)
            {
                GSBArray[number] = 3;
            }
            //ヘキサゴン状でない
            else if (GSBArray[number - 8] < 2)
            {
                GSBArray[number] = 1;
            }
            //左端ならば
            else if (number % 19 == 0)
            {
                GSBArray[number] = 1;
            }
            //一つ右が右端ならば
            else if((number + 1) % 19 == 9)
            {
                GSBArray[number] = 3;
            }
            else
            {
                GSBArray[number] = 2;
            }
            
        }
        //a,cにボールがある
        else if (GSBArray[number - 9] > 1 && GSIArray[number - 1] > 0)
        {
            //偶数行目左端ならば
            if (number % 19 == 10)
            {
                GSBArray[number] = 1;
            }
            //一つ左が左端ならば
            else if ((number - 1) % 19 == 0)
            {
                GSBArray[number] = 1;
            }
            //ヘキサゴン状でない
            else if (GSBArray[number - 11] < 2)
            {
                GSBArray[number] = 1;
            }
            //右端ならば
            else if (number % 19 == 9)
            {
                GSBArray[number] = 1;
            }
            else
            {
                GSBArray[number] = 2;
            }
        }
        else
        {
            GSBArray[number] = 1;
        }
    }

    public void NumFall(int number, int numInto ,int[] GSIArray, int[] GSBArray)
    {   
        GSIArray[numInto] = GSIArray[number];
        GSIArray[number] = 0;
        GSBArray[number] = 0;
    }

    //条件分岐判定用
    public int ReturnNumFallInto(int number, int[] GSIArray)
    {
        int returnNum;
        //右端である場合
        if (number % 19 == 9)
        {
            //bにボールがない
            if (GSIArray[number - 10] < 1)
            {
                //真下にボールがある
                if (GSIArray[number - 19] > 0)
                {
                    returnNum = number - 10;
                }
                //ない場合
                else
                {
                    returnNum = number - 19;
                }
            }
            else
            {
                returnNum = number;
            }   
        }
        //左端である場合
        else if (number % 19 == 0)
        {
            //cにボールがない
            if (GSIArray[number - 9] < 1)
            {
                //真下に玉がある場合
                if (GSIArray[number - 19] > 0)
                {
                    returnNum = number - 9;
                }
                //ない場合
                else
                {
                    returnNum = number - 19;
                }
            }
            else
            {
                returnNum = number;
            }  
        }
        //b,cどちらにもボールがない
        else if (GSIArray[number - 10] < 1 && GSIArray[number - 9] < 1)
        {
            
            //aに玉がある場合
            if (GSIArray[number - 1] > 0)
            {
                returnNum = number - 9;
            }
            //aに玉がなくdに玉がある
            else if (GSIArray[number + 1] > 0)
            {
                returnNum = number - 10;
            }
            //どちらにもない
            else
            {
                //eに玉がある場合はランダム
                if (number < 19 || GSIArray[number - 19] > 0)
                {
                    returnNum = GetRand(number - 10, number - 9);
                }
                //eに玉がない
                else
                {
                    returnNum = number - 19;
                }
            }
        }
        //bのみにボールがない
        else if (GSIArray[number - 10] < 1)
        {
            returnNum = number - 10;
        }
        //cのみにボールがない
        else if (GSIArray[number - 9] < 1)
        {
            returnNum = number - 9;
        }
        //両方にボールがある
        else
        {
            returnNum = number;
        }

        return returnNum;
    }

    int GetRand(int min, int max)
    {
        return UnityEngine.Random.Range(min, max + 1);
    }
}
~~~

</div></details>

<details><summary> GridObjectManager(マス目にあたるオブジェクトの管理)

</summary><div>

~~~cs:SphereManager
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GridObjectManager : MonoBehaviour
{
    [System.NonSerialized]
    public GameObject[] gridObjectArray = new GameObject[70 + 63];

    [SerializeField]
    public GameObject GridSphere;
    // Start is called before the first frame update

    public void GridInit()
    {
        Vector3 shpereScale = GridSphere.transform.lossyScale;
        float l = shpereScale.x;

        int gridNumber = 0;
        for (int i = 0; i < 14; i++)
        {
            if (i % 2 == 0)
            {
                for (int m = 0; m < 10; m++)
                {
                    GridObjectInstantiate(l, 0, i, m, gridNumber);
                    gridNumber++;
                }
            }
            else
            {
                for (int n = 0; n < 9; n++)
                {
                    GridObjectInstantiate(l, l / 2, i, n, gridNumber);
                    gridNumber++;
                }
            }
        }
    }

    void GridObjectInstantiate(float sphereScaleFloat, float adjust, int line, int column, int number)
    {
        Vector3 gridPos = Vector3.zero + new Vector3(sphereScaleFloat * column + adjust, line * (Mathf.Sqrt(3) / 2), 0);
        GameObject instance = Instantiate(GridSphere, gridPos, Quaternion.identity);
        gridObjectArray[number] = instance;
        instance.transform.SetParent(transform);
    }
}

~~~

</div></details>

## 設計

<img width="400" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616364/3b187b0e-6562-61fa-67e7-8bc4321a6010.png">

この図のように長さ133の配列を左下から探索していきます。

用意するものは
**1.**<font color="#FB8B24">**n番目に何色が入っているのかを保持する配列。**</font>
この記事では情報配列と呼称します。
(配列が空 = 0, 赤 = 1, 青 = 2, 緑 = 3, 紫 = 4, 黄 = 5)

スクリプトではgameSphereInfoArray, GSIArrayと表記します。

**2.**<font color="#FB8B24">**n番目のボールが落下する可能性があるかを保持する配列。**</font>
この記事では落下可能性配列と呼称します。
(配列が空 = 0, 落下する可能性がある = 1, 落下する可能性がない = 2, 保留 = 3)

スクリプトではgameSphereBoolArray, GSBArrayと表記します。

**3.**<font color="#FB8B24">**n番目のボールのインスタンス自体を保持する配列**</font>
GameObjectが入る配列です。
スクリプトではgameSphereArray, GSArrayと表記します。

**4.**<font color="#FB8B24">**ボールのマス目の基準となるオブジェクト（Grid）のインスタンスを保持する配列**</font>
マス目については後に詳しく説明します。
スクリプトではgridObjectArray, GridObjArrayと表記します。


## 実装について
再現する上で外せないポイントは以下の通りです。

[・見えないマス目が存在している](#見えないマス目が存在している)
　上から降りてきたボールに対して、ボールがマス目に収まるように補正が入る</font>


[・玉が落ちる部分に物理挙動を使用していない](#玉が落ちる部分に物理挙動を使用していない)
　玉が自然かつスムーズに落ちるためのアルゴリズムが必要である


## 見えないマス目が存在している
操作が終了した段階で、あらかじめ決められたマス目に従うように補正が入ります。
（このあらかじめ決まったマス目のことを、ここではGridと称します。)

現在操作中のボール毎に、どこに収まれば良いのかを確認して補正する作業を行います。
#### 手順1
Gridの位置を示すオブジェクト(GridObject)を配置します。

>~~~cs:GridObjectManager
>void GridObjectInstantiate(float sphereScaleFloat, float adjust, int line, int column, int number)
>~~~
>・GridObjectの大きさ
・左端の補正(9個の列と10個の列では左端の座標が異なる)、
・下から何行目か
・左から何番目か
・GridObjectを格納する配列でのインデックス番号
を受け取って、
>~~~cs:GridObjectManager
>void GridInit()
>~~~
>奇数行目か偶数行目かによって個数と位置を分けて生成しています。



#### 手順2
現在操作中のボールにそれぞれに対し、接触しているGridObjectのうち一番近いものを取得しそのインデックスをリスト(nowSpheresNum)に保存します。
>~~~cs:GridObserver
>    
>    List<int> nowSpheresNum = new List<int>();
>    foreach (GameObject sphere in sphereList)
>    {
>        Collider[] targetsInSphere = Physics.OverlapSphere(sphere.transform.position, sphereRadius, gridMask);
>        //一番距離の近いGridを調べる
>        int U = GridIdentify(targetsInSphere, sphere, GridObjArray);
>        GridObjArray[U].SetActive(false);
>        nowSpheresNum.Add(U);
>    }       
>~~~
>Physics.OverlapSphereは接触しているオブジェクトをColliderのリスト形式で返す関数、便利！

これでボールがおさまるマス目（補正先）を取得することができました。

#### 手順3
先ほどのリストは(14,13,4)のように操作中のボールに近いマス目のインデックスが大小関係なく入っています。

計算は基本マス目のインデックスが小さい順番に進めるため、収まるマス目のインデックスが小さい順に、各種配列に登録およびインスタンスの座標移動を行います。

>~~~cs:GridObserver
>    
>    while (nowSpheresNum.Min() < ListMax)
>    {
>        //nowSpheresの中で一番小さい数字が何番目か (U1 U2 U3)
>        int u = nowSpheresNum.IndexOf(nowSpheresNum.Min());
>
>        int colorNum = 0;
>
>        switch (sphereList[u].tag)
>        {
>            case "Red":
>                colorNum = 1;
>                break;
>
>            case "Blue":
>                colorNum = 2;
>                break;
>
>            case "Green":
>                colorNum = 3;
>                break;
>
>            case "Purple":
>                colorNum = 4;
>                break;
>
>            case "Yellow":
>                colorNum = 5;
>                break;
>        }
>
>        //Uが一番小さいボールをGridObjectArrayのUの位置に飛ばす
>        sphereList[u].transform.position = GridObjArray[nowSpheresNum[u]].transform.position;      
>        GSIArray[nowSpheresNum[u]] = colorNum;
>        GSArray[nowSpheresNum[u]] = sphereList[u];
>        //リストの当該部分を最小の値に選ばれないような大きい数にする
>        nowSpheresNum[u] = ListMax;
>    }
>~~~

<img width="400" alt="ダウンロード (2).gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616364/2cc66da6-aac1-f05b-d87c-d485126c50d5.gif">

これで補正ができました！

***補正先を取得する計算の順番が公正ではありません、無念***
[目次に戻る](#実装について)

## 玉が落ちる部分に物理挙動を使用していない
ここが一番大変な部分です。
本家のシックスボールパズルは、自然に落下する一方で物理挙動特有のブレがなく滑らかな動きが特徴です。リアルタイムで他のプレイヤーとバトルすることも考えると物理挙動は重いですね。

https://www.youtube.com/watch?v=QtZwgPCc2zg

条件分岐を細かくして、本家のような挙動を目指します。

### 計算の流れ

「落下可能性」の計算を加えることで効率的に処理していきたいと思います。

全体の計算の流れとしては
**1.** <font color="#FB8B24">**情報配列のn番目に対して落下可能性を計算、落下可能性配列に登録**</font>

**2.**<font color="#FB8B24">**可能性がある(落下可能性配列[ n ] = 1)場合、移動先の計算**</font>


### 基本となる落下計算
参考にしました記事を再度ご紹介させていただきます。

https://qiita.com/GANTZ/items/e6b5e9168b595404d582

本記事では参考にさせていただいた部分の紹介のみ行いますので、詳細が気になる方はぜひこちらの記事をお読みください。

説明されていたアルゴリズム

1.ボールはb,cにしか動かない
2.bに動くにはa,bにボールがない
3.cに動くにはc,dにボールがない
4.b,cどちらにも動ける場合,aに球があるときはc,dに球があるときはb,
どちらにもないときはb,cランダム

に加えて、さらに真下の情報を確認することで、ボールがごっそり消えた場合でも自然に落ちるようにしたいと思います。

1.ボールはb,cにしか動かない
2.bに動くにはa,bにボールがない
3.cに動くにはc,dにボールがない
4.b,cどちらにも動ける場合,aに球があるときはc,dに球があるときはb,
<font color="#FB8B24">**どちらにもないときは e を参照し, e にないときは e , e にあるときは b,c ランダム**</font>

<img width="400" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616364/fa13bc83-7863-20ee-6d55-79fd183fb2d0.png">

右端と左端の場合を条件分岐に加えれば、落下先を決定する基本の計算は完了です。

~~~cs:GridObserver
   
    int returnNum;
    //右端である場合
    if (number % 19 == 9)
    {
        //bにボールがない
        if (GSIArray[number - 10] < 1)
        {
            //真下にボールがある
            if (GSIArray[number - 19] > 0)
            {
                returnNum = number - 10;
            }
            //ない場合
            else
            {
                returnNum = number - 19;
            }
        }
        else
        {
            returnNum = number;
        }   
    }
    //左端である場合
    else if (number % 19 == 0)
    {
        //cにボールがない
        if (GSIArray[number - 9] < 1)
        {
            //真下に玉がある場合
            if (GSIArray[number - 19] > 0)
            {
                returnNum = number - 9;
            }
            //ない場合
            else
            {
                returnNum = number - 19;
            }
        }
        else
        {
            returnNum = number;
        }  
    }
    //b,cどちらにもボールがない
    else if (GSIArray[number - 10] < 1 && GSIArray[number - 9] < 1)
    {
        
        //aに玉がある場合
        if (GSIArray[number - 1] > 0)
        {
            returnNum = number - 9;
        }
        //aに玉がなくdに玉がある
        else if (GSIArray[number + 1] > 0)
        {
            returnNum = number - 10;
        }
        //どちらにもない
        else
        {
            //eに玉がある場合はランダム
            if (number < 19 || GSIArray[number - 19] > 0)
            {
                returnNum = GetRand(number - 10, number - 9);
            }
            //eに玉がない
            else
            {
                returnNum = number - 19;
            }
        }
    }
    //bのみにボールがない
    else if (GSIArray[number - 10] < 1)
    {
        returnNum = number - 10;
    }
    //cのみにボールがない
    else if (GSIArray[number - 9] < 1)
    {
        returnNum = number - 9;
    }
    //両方にボールがある
    else
    {
        returnNum = number;
    }
    
    return returnNum;
~~~

<img width="400" alt="ダウンロード (1).gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616364/f983a4fd-0754-42e3-43f3-aeff7c64f497.gif">

### 六角形を作る

<img width="400" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616364/2ba1c0c2-3cfc-d038-3089-1ec45448366b.png">

b, dにボールがあり、b が崩れない場合に n は崩れません。

<img width="400" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616364/8fce9b38-db57-17df-d565-9fc9ccd1e650.png">

同じく、a, cにボールがあり、c が崩れない場合に n は崩れません。

右端と左端、一つ隣が右端、左端の場合のみ条件を外せば六角形が作成できます。

~~~cs:GridObserver

    //配列に何もないもしくはその場所の落下可能性がない場合、リターン
    if (GSIArray[number] < 1)
    {
        return;
    }

    //一週前にボールの削除が起こっていない場合
    if (!pastDelete)
    {
        if (GSBArray[number] > 1)
        {
            return;
        }
    }

    if (           
        //最下層
        number < 10
        //右端でbにボールがある
        || (number % 19 == 9 && GSBArray[number - 10] > 1)
        //左端でcにボールがある場合
        || (number % 19 == 0 && GSBArray[number - 9] > 1)
        //b,cにボールがある
        || (GSBArray[number - 10] > 1 && GSBArray[number - 9] > 1)
        )
    {
        GSBArray[number] = 2;
    }
    //b,dにボールがある
    else if (GSBArray[number - 10] > 1 && GSIArray[number + 1] > 0)
    {
        //偶数行目右端ならば
        if (number % 19 == 18)
        {
            GSBArray[number] = 1;
        }
        //一つ右が右端ならば
        else if ((number + 1) % 19 == 9)
        {
            GSBArray[number] = 3;
        }
        //ヘキサゴン状でない
        else if (GSBArray[number - 8] < 2)
        {
            GSBArray[number] = 1;
        }
        //左端ならば
        else if (number % 19 == 0)
        {
            GSBArray[number] = 1;
        }
        //一つ右が右端ならば
        else if((number + 1) % 19 == 9)
        {
            GSBArray[number] = 3;
        }
        else
        {
            GSBArray[number] = 2;
        }
        
    }
    //a,cにボールがある
    else if (GSBArray[number - 9] > 1 && GSIArray[number - 1] > 0)
    {
        //偶数行目左端ならば
        if (number % 19 == 10)
        {
            GSBArray[number] = 1;
        }
        //一つ左が左端ならば
        else if ((number - 1) % 19 == 0)
        {
            GSBArray[number] = 1;
        }
        //ヘキサゴン状でないならば
        else if (GSBArray[number - 11] < 2)
        {
            GSBArray[number] = 1;
        }
        //右端ならば
        else if (number % 19 == 9)
        {
            GSBArray[number] = 1;
        }
        else
        {
            GSBArray[number] = 2;
        }
    }
    else
    {
        GSBArray[number] = 1;
    }
~~~

<img width="400" alt="ダウンロード.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616364/74d8d391-bedd-0bb0-34b6-1a99e62c6395.gif">

これで落下処理ができました！

***まだ見つかっていない不具合が絶対ある気がする***

[目次に戻る](#実装について)

## 今後の課題
・見逃している挙動がありそう。
・現在はボールを移動先の座標に飛ばしているだけなので、落下用のアニメーションを作成する。
・~~現在は移動先が被ってしまわないようにしているはずだが、同じ座標にボールが2つ入って弾かれる挙動が観測されていた時期があった。まだバグが解消されていない可能性がある。~~
→　落下処理の見直しにより解決


## さいごに
ゴリゴリと原始的な場合分けで機能を作りましたが、もっと良い方法があるんでしょうか...?

~~判定に関する部分も完成していますが、そちらは共同で作っている人が担当しており別記事にするそうなので、記事が公開された暁にはこちらに引用したいと思います。~~

<font color="#FB8B24">判定に関する記事を公開しました!!!</font>
できたものがこちら

https://youtu.be/jjaDnBVi0ag?si=J9XtMWJ9d17L73Ch

https://qiita.com/theta_snufkin/items/cdd08ba740e664737e0b

### このシステムをベースに少し改変してゲームを公開しました
本家よりもゲーム性が落ちている...

https://unityroom.com/games/8squarepuzzle

<img width="400" alt="スクリーンショット 2024-02-18 10.26.55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616364/03c11ad2-3d2e-9d3d-776e-4d61d3ba706e.png">

<img width="400" alt="スクリーンショット 2024-02-18 10.27.37.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616364/847f63bf-d002-1d57-9347-f632715178de.png">