三消類游戲一直是游戲市場上經(jīng)久不衰的休閑游戲,該類型也是源自于經(jīng)典的俄羅斯方塊玩法的一部分。三消游戲需要交換游戲中相鄰的兩個方格,以讓3個或更多相同的方格連成直線,一旦連線成功,則消除這些連成線的同色方格,并使用新的方格進(jìn)行填充,填充后如果還存在連線就可以達(dá)成Combo或多倍加分!

本教程就為大家分享如何在Unity中制作這樣一款三消游戲的完整過程,從創(chuàng)建底板填充方格開始,到統(tǒng)計步數(shù)并計算游戲得分,來自己做一款三消游戲。

準(zhǔn)備工作
將項目初始資源導(dǎo)入Unity項目,資源目錄如下:
?



其中分別包含要用于游戲的動畫、音效、字體、預(yù)制件、場景、腳本及圖片資源。

創(chuàng)建游戲底板
打開Game場景,新建空游戲?qū)ο竺麨锽oardManager,該對象將用于生成游戲底板,并填充方格。然后將Scripts/Board and Grid文件夾下的BoardManager腳本拖拽至剛剛創(chuàng)建的BoardManager游戲?qū)ο笊希?/span>
?



BoardManager腳本代碼如下:

public static BoardManager instance;     // 1
    public List<Sprite> characters = new List<Sprite>();     // 2
    public GameObject tile;      // 3
    public int xSize, ySize;     // 4
 
    private GameObject[,] tiles;      // 5
 
    public bool IsShifting { get; set; }     // 6
 
    void Start () {
        instance = GetComponent<BoardManager>();     // 7
 
        Vector2 offset = tile.GetComponent<SpriteRenderer>().bounds.size;
        CreateBoard(offset.x, offset.y);     // 8
    }
 
    private void CreateBoard (float xOffset, float yOffset) {
        tiles = new GameObject[xSize, ySize];     // 9
 
        float startX = transform.position.x;     // 10
        float startY = transform.position.y;
 
        for (int x = 0; x < xSize; x++) {      // 11
            for (int y = 0; y < ySize; y++) {
                GameObject newTile = Instantiate(tile, new Vector3(startX + (xOffset * x), startY +                                                                 (yOffset * y), 0), tile.transform.rotation);
                tiles[x, y] = newTile;
            }
        }
    }

?

  • BoardManager腳本聲明了一個單例名為instance,便于其它腳本訪問該腳本。
  • characters是方格需要用到的圖片列表。
  • tile是用于初始化方格底板的預(yù)制件。
  • xSize及ySize是底板橫向及縱向的數(shù)量。
  • tiles是保存底板方格的二維數(shù)組。
  • IsShifting函數(shù)用于檢測是否需要填充方格。
  • Start函數(shù)用于初始化BoardManager腳本實例。
  • CreateBoard函數(shù)用于創(chuàng)建底板,參數(shù)為方初始化格圖片的寬度及高度,根據(jù)此前定義的底板方格數(shù)量及底板方格預(yù)制件,來初始化整個底板。


在層級視圖中選中BoardManager對象,然后在檢視視圖中將BoardManager腳本的Characters元素數(shù)量設(shè)為7,然后將Sprites/Characters文件夾下的圖片綁定到數(shù)組元素。最后將Prefabs文件夾下的Tile預(yù)制件綁定到腳本的Tile字段,將BoardManager腳本的X Size與Y Size分別設(shè)為8、12。完成后如下圖:
?

然后運(yùn)行場景,可以看到底板能夠正常創(chuàng)建,但出現(xiàn)了偏移:
?


這是因為底板方格從左下角開始最先生成,而首個方格坐標(biāo)為BoardManager對象坐標(biāo)。下面調(diào)整BoardManager對象的坐標(biāo)為(-2.66, -3.83, 0),讓BoardManager坐標(biāo)位于屏幕左下角。
?


隨機(jī)生成底板

打開BoardManager腳本,在CreateBoard方法中新增以下代碼:
?
[C#]?純文本查看?復(fù)制代碼
newTile.transform.parent = transform; // 1
Sprite newSprite = characters[Random.Range(0, characters.Count)]; // 2
newTile.GetComponent<SpriteRenderer>().sprite = newSprite; // 3

?以上代碼的作用是將所有底板方格的父對象均設(shè)置為BoardManager,保持層級視圖干凈整潔,并從之前定義的數(shù)組中隨機(jī)選取一張圖片來初始化方格。現(xiàn)在運(yùn)行游戲,效果如下:
?



上面生成的方格還有些小問題,就是一開始就出現(xiàn)了連續(xù)的可消除方格,下面就來解決這個問題。

避免初始化重復(fù)方格

底板方格按從下到上從左到右的順序創(chuàng)建,所以在創(chuàng)建新方格前要對相鄰的方格進(jìn)行判斷。
?


上圖所示的循環(huán)會從左下方開始遍歷方格,每次迭代都會獲取當(dāng)前方格左側(cè)及下方的方格,然后通過隨機(jī)選取這兩個方格,來保證不會在初始化底板時出現(xiàn)3個及以上相連的同一方格。更改CreateBoard方法代碼為如下:
?
[C#]?純文本查看?復(fù)制代碼
 private void CreateBoard (float xOffset, float yOffset) {
            tiles = new GameObject[xSize, ySize];
 
    float startX = transform.position.x;
            float startY = transform.position.y;
 
            Sprite[] previousLeft = new Sprite[ySize]; // Add this line
            Sprite previousBelow = null; // Add this line
 
            for (int x = 0; x < xSize; x++) {
                    for (int y = 0; y < ySize; y++) {
                            GameObject newTile = Instantiate(tile, new Vector3(startX + (xOffset * x), startY + (yOffset * y), 0), tile.transform.rotation);
                            tiles[x, y] = newTile;
                            newTile.transform.parent = transform; // Add this line
 
                            List<Sprite> possibleCharacters = new List<Sprite>();
                            possibleCharacters.AddRange(characters);
 
                            possibleCharacters.Remove(previousLeft[y]);
                            possibleCharacters.Remove(previousBelow);
 
                            Sprite newSprite = possibleCharacters[Random.Range(0, possibleCharacters.Count)];
                            newTile.GetComponent<SpriteRenderer>().sprite = newSprite;
                            previousLeft[y] = newSprite;
                            previousBelow = newSprite;
                    }
    }
}

?

運(yùn)行游戲,不會出現(xiàn)重復(fù)相連的3個方格了:
?



交換方格

下面來實現(xiàn)選中并交換相鄰的方格。打開Tile腳本,其中Select方法用于選中方格后替換方格圖片并播放選中音效,Deselect方法用于恢復(fù)選中方格的圖片,并提示當(dāng)前未選中任意方格。SwapSprite方法用于交換兩個相鄰方格,即替換兩個Sprite的紋理,然后播放交換音效。這里通過按下鼠標(biāo)左鍵來操作方格,代碼如下:
?
[C#]?純文本查看?復(fù)制代碼
void Awake() {
                render = GetComponent<SpriteRenderer>();
    }
 
        private void Select() {
                isSelected = true;
                render.color = selectedColor;
                previousSelected = gameObject.GetComponent<Tile>();
                SFXManager.instance.PlaySFX(Clip.Select);
        }
 
        private void Deselect() {
                isSelected = false;
                render.color = Color.white;
                previousSelected = null;
        }
 
        void OnMouseDown() {
                // Not Selectable conditions
                if (render.sprite == null || BoardManager.instance.IsShifting) {
                        return;
                }
 
                if (isSelected) { // Is it already selected?
                        Deselect();
                } else {
                        if (previousSelected == null) { // Is it the first tile selected?
                                Select();
                        } else {
                                if (GetAllAdjacentTiles().Contains(previousSelected.gameObject)) { // Is it an adjacent tile?
                                        SwapSprite(previousSelected.render);
                                        previousSelected.ClearAllMatches();
                                        previousSelected.Deselect();
                                        ClearAllMatches();
                                } else {
                                        previousSelected.GetComponent<Tile>().Deselect();
                                        Select();
                                }
                        }
                }
        }
 
        public void SwapSprite(SpriteRenderer render2) {
                if (render.sprite == render2.sprite) {
                        return;
                }
 
                Sprite tempSprite = render2.sprite;
                render2.sprite = render.sprite;
                render.sprite = tempSprite;
                SFXManager.instance.PlaySFX(Clip.Swap);
                GUIManager.instance.MoveCounter--; // Add this line here
        }

?

這里還需要保證僅相鄰的方格才能進(jìn)行交換,在Tile腳本中添加以下兩個方法:
?

[C#]?純文本查看?復(fù)制代碼
private GameObject GetAdjacent(Vector2 castDir) {
        RaycastHit2D hit = Physics2D.Raycast(transform.position, castDir);
        if (hit.collider != null) {
                return hit.collider.gameObject;
        }
        return null;
}
 
private List<GameObject> GetAllAdjacentTiles() {
        List<GameObject> adjacentTiles = new List<GameObject>();
        for (int i = 0; i < adjacentDirections.Length; i++) {
                adjacentTiles.Add(GetAdjacent(adjacentDirections[ i ]));
        }
        return adjacentTiles;
}

?

其中GetAdjacent方法用于檢測某個固定方向是否存在方格,如果有,則返回此方格。GetAllAdjacentTiles方法則調(diào)用GetAdjacent來生成圍繞當(dāng)前方格的列表,該循環(huán)將遍歷各個方向與當(dāng)前方格相鄰的方格,并返回列表,以保證方格僅能與其相鄰方格進(jìn)行交換。

保存代碼后運(yùn)行場景,效果如下:
?



檢測相同方格進(jìn)行消除

消除可以拆解為幾個步驟,首先判斷是否出現(xiàn)3個及以上相連的同樣方格,如果有,則消除已匹配的方格,并填充新方格。然后重復(fù)此步驟直至沒有有效匹配。

在Tile腳本中新增以下代碼:
?
[C#]?純文本查看?復(fù)制代碼
private List<GameObject> FindMatch(Vector2 castDir) {
        List<GameObject> matchingTiles = new List<GameObject>();
        RaycastHit2D hit = Physics2D.Raycast(transform.position, castDir);
        while (hit.collider != null && hit.collider.GetComponent<SpriteRenderer>().sprite == render.sprite) {
                matchingTiles.Add(hit.collider.gameObject);
                hit = Physics2D.Raycast(hit.collider.transform.position, castDir);
        }
        return matchingTiles;
}
 
private void ClearMatch(Vector2[] paths) {
        List<GameObject> matchingTiles = new List<GameObject>();
        for (int i = 0; i < paths.Length; i++) { matchingTiles.AddRange(FindMatch(paths[i])); }
        if (matchingTiles.Count >= 2) {
                for (int i = 0; i < matchingTiles.Count; i++) {
                        matchingTiles[i].GetComponent<SpriteRenderer>().sprite = null;
                }
                matchFound = true;
        }
}
 
private bool matchFound = false;
public void ClearAllMatches() {
        if (render.sprite == null)
                return;
 

?

FindMatch方法接收一個Vector2參數(shù),用于表示所有射線投射的方向,新建GameObject列表來保存所有匹配條件的方格,從方格朝參數(shù)方向投射射線,直至射線未碰撞到任何方格或與當(dāng)前方格不一致時停止,然后返回匹配條件的Sprite列表。

ClearMatch方法會按照給定路徑尋找相同的方格,并相應(yīng)消除所有匹配的方格。即判斷FindMatch方法返回的列表中,是否有相連為直線的3個及以上相同方格。如果有,則將matchFound設(shè)為True。ClearAllMatch方法會在找到滿足條件的匹配后,刪除所有匹配的方格。

運(yùn)行游戲,效果如下:
?



填充空白方格

在消除方格后,還需要為其填充新的方格。在BoardManager腳本中加入以下代碼:
?
[C#]?純文本查看?復(fù)制代碼
public IEnumerator FindNullTiles() {
        for (int x = 0; x < xSize; x++) {
                for (int y = 0; y < ySize; y++) {
                        if (tiles[x, y].GetComponent<SpriteRenderer>().sprite == null) {
                                yield return StartCoroutine(ShiftTilesDown(x, y));
                                break;
                        }
                }
        }
 
        for (int x = 0; x < xSize; x++) {
                for (int y = 0; y < ySize; y++) {
                        tiles[x, y].GetComponent<Tile>().ClearAllMatches();
                }
        }
}
 
private IEnumerator ShiftTilesDown(int x, int yStart, float shiftDelay = .03f) {
        IsShifting = true;
        List<SpriteRenderer> renders = new List<SpriteRenderer>();
        int nullCount = 0;
 
        for (int y = yStart; y < ySize; y++) {
                SpriteRenderer render = tiles[x, y].GetComponent<SpriteRenderer>();
                if (render.sprite == null) {
                        nullCount++;
                }
                renders.Add(render);
        }
 
        for (int i = 0; i < nullCount; i++) {
                GUIManager.instance.Score += 50; // Add this line here
                yield return new WaitForSeconds(shiftDelay);
                for (int k = 0; k < renders.Count - 1; k++) {
                        renders[k].sprite = renders[k + 1].sprite;
                        renders[k + 1].sprite = GetNewSprite(x, ySize - 1);
                }
        }
        IsShifting = false;
}
 
private Sprite GetNewSprite(int x, int y) {
        List<Sprite> possibleCharacters = new List<Sprite>();
        possibleCharacters.AddRange(characters);
 
        if (x > 0) {
                possibleCharacters.Remove(tiles[x - 1, y].GetComponent<SpriteRenderer>().sprite);
        }
        if (x < xSize - 1) {
                possibleCharacters.Remove(tiles[x + 1, y].GetComponent<SpriteRenderer>().sprite);
        }
        if (y > 0) {
                possibleCharacters.Remove(tiles[x, y - 1].GetComponent<SpriteRenderer>().sprite);
        }
 
        return possibleCharacters[Random.Range(0, possibleCharacters.Count)];
}

?

其中FindNullTiles方法用于查找是否存在空的方格,如果有,則調(diào)用ShiftTilesDown方法將周圍的方格填充進(jìn)來,該方法有三個參數(shù),分別是X索引,Y索引以及延遲時間,X、Y決定了哪一塊方格需要移動,這里僅實現(xiàn)向下填充,所以X值是固定了,僅Y值會變。GetNewSprite方法將生成新的方塊來填滿整個底板。
?



連擊

新填充的方格可能會再次出現(xiàn)符合條件的匹配,所以新填充底板后要再次進(jìn)行判斷。再找到匹配后再次匹配成功,就是一次連擊。所以在上面的FindNullTiles方法中,通過以下代碼循環(huán)判斷是否出現(xiàn)匹配:
?
[C#]?純文本查看?復(fù)制代碼
for (int x = 0; x < xSize; x++) {
                for (int y = 0; y < ySize; y++) {
                        tiles[x, y].GetComponent<Tile>().ClearAllMatches();
                }
        }

?現(xiàn)在運(yùn)行游戲,效果如下:
?



添加計步器與分?jǐn)?shù)

下面實現(xiàn)玩家步數(shù)記錄,并統(tǒng)計游戲分?jǐn)?shù)。打開Scripts/Managers文件夾下的GUIManager腳本,該腳本用于管理游戲UI,顯示步數(shù)及分?jǐn)?shù)文本。腳本代碼如下:
?
[C#]?純文本查看?復(fù)制代碼
public static GUIManager instance;
 
        public GameObject gameOverPanel;
        public Text yourScoreTxt;
        public Text highScoreTxt;
 
        public Text scoreTxt;
        public Text moveCounterTxt;
 
        private int score, moveCounter;
 
        void Awake() {
                instance = GetComponent<GUIManager>();
                moveCounter = 99;
        }
 
        // Show the game over panel
        public void GameOver() {
                GameManager.instance.gameOver = true;
 
                gameOverPanel.SetActive(true);
 
                if (score > PlayerPrefs.GetInt("HighScore")) {
                        PlayerPrefs.SetInt("HighScore", score);
                        highScoreTxt.text = "New Best: " + PlayerPrefs.GetInt("HighScore").ToString();
                } else {
                        highScoreTxt.text = "Best: " + PlayerPrefs.GetInt("HighScore").ToString();
                }
 
                yourScoreTxt.text = score.ToString();
        }
 
        public int Score {
                get {
                        return score;
                }
 
                set {
                        score = value;
                        scoreTxt.text = score.ToString();
                }
        }
 
        public int MoveCounter {
                get {
                        return moveCounter;
                }
 
                set {
                        moveCounter = value;
                        if (moveCounter <= 0) {
                                moveCounter = 0;
                                StartCoroutine(WaitForShifting());
                        }
                        moveCounterTxt.text = moveCounter.ToString();
                }
        }
 
        private IEnumerator WaitForShifting() {
                yield return new WaitUntil(() => !BoardManager.instance.IsShifting);
                yield return new WaitForSeconds(.25f);
                GameOver();
        }

?

在Awake中獲取腳本引用,并初始化步數(shù)。Score及MoveCounter函數(shù)用于在每次更新分?jǐn)?shù)值或步數(shù)時,UI界面上的文本也會同時更新。當(dāng)步數(shù)減少至0時,游戲結(jié)束。此時會通過WaitForShifting協(xié)程在等待0.25秒后調(diào)用GameOver方法,并在GameOver方法中顯示游戲結(jié)束面板。這里的等待是為了確保所有連擊都被計算在總分內(nèi)。
?



總結(jié)

到這里本篇教程就結(jié)束了,當(dāng)然大家還可以在理解游戲機(jī)制后添加更多的玩法,包括限時結(jié)算模式、增加不同關(guān)卡與底板類型、連擊的積分計算規(guī)則,或是為消除方格添加一些酷炫的粒子效果等等。后面就留給大家自行擴(kuò)展與發(fā)揮了!