Unity - PuzzleGame 5 (接水管接電線)

  接電線或是接水管類型的遊戲,大致上就是有一個可以調整的盤面,上面有許多格子可以旋轉,不一定是正方形,不過這邊簡單製作用正方形比較方便,格子上會有一些路徑,旋轉格子讓這些路徑連接起來。

  通常會有一個起點一個終點,既然這邊是接電線,起點就是電源,終點就用電燈,當路徑從起點到終點有達成連線的時候,遊戲就完成了。



  製作這個我覺得可以有幾種做法,一種就是亂數安排路徑,好處是每次結果都會不同,但是壞處就是難度比較無法掌控;另一種就是預先把版面設計幾種,好處就是可以掌控電線的路徑圖案跟難度,壞處就是要花時間把版面設定完畢,而且變化不大,重玩幾次就可以背下答案。

  這邊使用的亂數安排路徑,因為比較省時間,另外也可以使用之前做過的迷宮產生器來產生路線資料。



  實作測試 (WebGL build) (滑鼠點擊方塊讓方塊旋轉,有通電的電線會改變顏色,當電線從電池到電燈有連通,遊戲結束)





1、路線路徑

  這個版本是四方形棋盤格,明顯可以看出來類似迷宮,所以就把之前做過的迷宮產生器(簡易亂數迷宮產生1(Depth first search))拿來這邊產生版面資料使用,不過稍做點小修改只需要回傳的迷宮資料。

public class DepthFirstSearchMaze : MonoBehaviour
{
    public class Cell
    {
        public int[] wall = new int[4]; //n,e,s,w  0=wall, 1=passage
        public int x, y;
    }

    public Cell[][] CreateMaze(int width, int height)
    {
        //初始化陣列大小
        cellArr = new Cell[height][];
        for(int i = 0; i < height; ++i)
        {
            cellArr[i] = new Cell[width];
            for(int j = 0; j < width; ++j)
            {
                cellArr[i][j] = new Cell() {y = i, x = j};
            }
        }

        //亂數取一個格子作為起始點
        Cell startCell = cellArr[Random.Range(0,height)][Random.Range(0,width)];

        //開始建立迷宮
        CreatePath(startCell, new List<Cell>());
    }

    void CreatePath(Cell start, List<Cell> visitedList)
    {
        //把此格加入已踩過列表
        visitedList.Add(start);

        //取得可以使用的方向列表,該方向是牆面,並且沒有超出陣列大小
        List<int> directions = new List<int>();
        if(start.wall[0] == 0 && start.y != height-1) directions.Add(0);
        if(start.wall[1] == 0 && start.x != width-1) directions.Add(1);
        if(start.wall[2] == 0 && start.y != 0) directions.Add(2);
        if(start.wall[3] == 0 && start.x != 0) directions.Add(3);

        int count = directions.Count;
        for(int i = 0; i < count; ++i)
        {
            //從方向列表亂數取一個方向
            int dir = directions[Random.Range(0, directions.Count)];
            directions.Remove(dir); //把該方向移出列表

            //取得下一個方向的Cell資料
            int addY = (dir == 0) ? 1 : (dir == 2) ? -1 : 0;
            int addX = (dir == 1) ? 1 : (dir == 3) ? -1 : 0;
            int nextY = start.y + addY;
            int nextX = start.x + addX;
            Cell nextCell = cellArr[nextY][nextX];

            //檢查是否踩過
            if(visitedList.Contains(nextCell))
            {
                continue; //Skip this direction
            }

            //建立通道
            start.wall[dir] = 1;
            nextCell.wall[(dir+2)%4] = 1;

            //到下一個方向的Cell繼續
            CreatePath (nextCell, visitedList);
        }
    }
}





2、格子

  接著來製作格子用的Component,用來裝四周路徑跟四周方塊的資料,有些跟迷宮方塊的資料重複,也可以修改使用,不過這邊就獨立出來好了。

public class Block : MonoBehaviour
{
    public SpriteRenderer[] lineImages; //線段圖片
    public Block[] blockNeighbors; //此方塊四個方向的鄰居
    public int[] wirePath; //四個方向電線路徑,0沒有,1有

    //用Raycast去抓四周的方塊
    public void FindNeighbors()
    {
        //先把自己的Collider關掉
        blockNeighbors = new Block[4];
        BoxCollider2D b = transform.GetComponent<BoxCollider2D>();
        if (b != null) b.enabled = false;

        //往四個方向抓物件
        RaycastHit2D hit = Physics2D.Raycast(transform.position, Vector2.up);
        if (hit) blockNeighbors[0] = hit.transform.GetComponent<Block>();
        hit = Physics2D.Raycast(transform.position, Vector2.right);
        if (hit) blockNeighbors[1] = hit.transform.GetComponent<Block>();
        hit = Physics2D.Raycast(transform.position, Vector2.down);
        if (hit) blockNeighbors[2] = hit.transform.GetComponent<Block>();
        hit = Physics2D.Raycast(transform.position, Vector2.left);
        if (hit) blockNeighbors[3] = hit.transform.GetComponent<Block>();

        if (b != null) b.enabled = true;
    }

    //設定電線路徑
    public void SetPath(int[] path)
    {
        for (int i = 0; i < 4; ++i)
        {
            wirePath = path;
            if (i < lineImages.Length && lineImages[i] != null) lineImages[i].gameObject.SetActive(wirePath[i] == 1);
        }
    }
    
    //增加電線路徑
    public void AddPath(int[] path)
    {
        for (int i = 0; i < 4; ++i)
        {
            wirePath[i] |= path[i];
            if (i < lineImages.Length && lineImages[i] != null) lineImages[i].gameObject.SetActive(wirePath[i] == 1);
        }
    }

    //設定電線的圖片顏色,這邊就用暗紅跟亮紅來代表有無通電,這邊只有單純改Sprite的顏色,如果要有更好看的效果就需要自己加上電流等等
    public void SetConnect(bool toggle)
    {
        foreach (SpriteRenderer sr in lineImages)
        {
            if (sr != null) sr.color = (toggle) ? new Color32(255, 0, 0, 255) : new Color32(106, 0, 0, 255);
        }
    }

    //從local方向的群組,依據目前方塊旋轉的角度,轉換成目前顯示在畫面上的world方向
    public int[] GetWorldWireDir()
    {
        int rotate = Mathf.RoundToInt(this.transform.localEulerAngles.z) / 90;

        int[] wires = new int[4];
        for (int i = 0; i < 4; ++i)
        {
            int index = (i + rotate) % 4;
            wires[i] = (index >= wirePath.Length) ? 0 : wirePath[index];
        }
        return wires;
    }
}

  接著製作GameObject物件,這邊簡單使用一個單色圖片做背景,然後用四個方向的條狀方塊來代替電線,把這個物件做成Prefab,物件都加上一個BoxCollider2D跟Block的Component,把四條代表電線的圖案方塊拉到lineImages裡面。


  接著用這個Prefab拉物件到場景上,在畫面上排出5x5的陣列,這邊我是從左下角開始往右排,再一層一層往上增加,這邊因為物件已經做成Prefab了,所以基本上該有的Component跟設定也都不用額外再做了。

  




  最後再加上兩個做為起點跟終點的物件,這邊用個電池跟電燈,這兩個物件同樣加上BoxCollider2D跟Block的Component,其中沒有設定數值,到時候在板子運作的時候來做。





3、板面

  最後製作板面把系統完成,用來初始化整個迷宮資料跟畫面顯示的圖片,初始方塊跟終點方塊也在這邊設定,因為這兩個方塊獨立陣列之外,所以就在這邊一起設定了,這邊有些隨意做,寫死的陣列大小跟初始化資料,不過還好有達到目的。

public class Board : MonoBehaviour
{
    public Block start; //起始方塊,這邊是電池
    public Block end; //終點方塊,這邊是電燈
    public Block[] blocks; //中間所有的方塊,依序排列
    public DepthFirstSearchMaze mazeGenerator; //迷宮產生器的Component,記得把Component抓進來

    private bool isGameOver = false; //遊戲使否結束
    private bool animFinished = true; //是否有方塊在旋轉

    void Start()
    {
        //建立一張5x5迷宮
        DepthFirstSearchMaze.Cell[][] cellArr = mazeGenerator.CreateMaze(5, 5);
        for (int y = 0; y < 5; ++y)
        {
            for (int x = 0; x < 5; ++x)
            {
                //設定方塊的電線圖片
                int index = y * 5 + x;
                blocks[index].FindNeighbors(); //尋找跟此方塊四周相連的方塊
                blocks[index].SetPath(cellArr[y][x].wall); //依據迷宮資料設定方塊電線的路徑

                //亂數旋轉方塊角度
                int dir = Random.Range(0, 4);
                blocks[index].transform.localEulerAngles = new Vector3(0, 0, dir * 90);
            }
        }

        //起始點跟終點,這邊起點跟終點不是在陣列中,所以手動設定
        start.FindNeighbors();
        start.SetPath(new int[4] {0, 1, 0, 0}); //起點是往右連到迷宮方塊
        start.blockNeighbors[1].AddPath(new int[4] { 0, 0, 0, 1 }); //起點右邊的方塊需要增加一條往左連到起點的路徑
        end.FindNeighbors();
        end.SetPath(new int[4] {0, 0, 0, 1}); //終點是往左連到迷宮方塊
        end.blockNeighbors[3].AddPath(new int[4] { 0, 1, 0, 0 }); //終點左邊的方塊需要增加一條往右連到終點的路徑
        
        //檢查連線
        CheckConnect(start, new List<Block>());
    }

    void Update()
    {
        //滑鼠點左鍵,旋轉方塊,並檢查連線
        if (Input.GetKeyDown(KeyCode.Mouse0))
        {
            RaycastHit2D hit = Physics2D.Raycast(Camera.main.ScreenToWorldPoint(Input.mousePosition), Vector2.zero);
            if (hit)
            {
                Block target = hit.transform.GetComponent<Block>();
                if (target != null)
                {
                    if (target == start || target == end) return; //如果點到起點跟終點就不動作
                    //如果遊戲結束或是方塊還在旋轉就不動作
                    if (!isGameOver && animFinished) StartCoroutine(RotateAnim(target));
                }
            }
        }
    }

    private IEnumerator RotateAnim(Block target)
    {
        animFinished = false;
        float angleTo = target.transform.localEulerAngles.z + 90; //低機率偶而旋轉會有小錯誤轉一整圈,不過無所謂

        foreach (Block b in blocks) b.SetConnect(false); //重設所有的連線,讓所有的電線都先暗掉
        CheckConnect(start, new List<Block>(), target); //重新檢查連線,跳過正在旋轉的方塊

        while (true)
        {
            float angle = Mathf.MoveTowards(target.transform.localEulerAngles.z, angleTo, 270 * Time.deltaTime);
            target.transform.localRotation = Quaternion.Euler(new Vector3(0, 0, angle));
            if (angle == angleTo) break;
            yield return null;
        }

        //方塊旋轉完畢再檢查一次
        if (CheckConnect(start, new List<Block>()))
        {
            //連結成功,遊戲結束
            Debug.Log("Game Over");
            isGameOver = true;
        }
        animFinished = true;
    }

    private bool CheckConnect(Block block, List<Block> checkedList, Block skipBlock = null)
    {
        //從起點的Block開始檢查連結
        bool result = false;
        for (int i = 0; i < 4; ++i) //北東南西
        {
            if (i >= block.blockNeighbors.Length || block.blockNeighbors[i] == null) continue; //沒有該方向的鄰居格子
            if (checkedList.Contains(block.blockNeighbors[i])) continue; //這個格子檢查過了
            if (skipBlock != null && skipBlock == block.blockNeighbors[i]) continue; //跳過這個格子

            int groupID = block.GetWorldWireDir()[i]; //往下個方向的電線編號
            Block nextBlock = block.blockNeighbors[i]; //往下個方向的格子
            int invertDir = (i + 2) % 4; //相反方向

            //如果下個格子的反方向電線編號是0(沒有設定),或跟這個格子連過去的編號不同(這邊用flags檢查,如果有兩三種顏色的電線就可以用),就等於沒有連結成功
            int nextBlockInvertDirGroupID = nextBlock.GetWorldWireDir()[invertDir];
            if (groupID == 0 || nextBlockInvertDirGroupID == 0 || (nextBlockInvertDirGroupID & groupID) == 0) continue;

            //有連結成功,設定通電
            nextBlock.SetConnect(true);
            //只有連結成功的加入已經檢查過名單
            checkedList.Add(nextBlock);

            //如果這個格子是終點,代表頭尾連結完畢
            if (nextBlock == end) result = true;

            //繼續往下檢查
            if (CheckConnect(nextBlock, checkedList, skipBlock)) result = true;
        }
        return result; //就算中途有連通,也是全部檢查完後才會回傳結果
    }
}


  最後把Board物件設置好,把起點跟終點方塊配置上去,然後Block照順序排列,這邊同樣把所有物件都Parent到這個板子物件下,單純只是整理一下。


  到此便全部完成,同樣遊戲結束還需要製作結束畫面,以及這邊電線的方塊非常的陽春,也沒有任何特效,要讓整個畫面更好看還需要不少努力,同時也要調整一些Code來配合才行。





  目前用亂數跑起來感覺還算可以,不過當然還是比不上手工製作的板面,也比較沒有騙人的路徑,不過單純這樣玩玩還算堪用就是了。

  如果有任何錯誤歡迎提出。

No comments:

Post a Comment