三消类游戏一直是游戏市场上经久不衰的休闲游戏,该类型也是源自于经典的俄罗斯方块玩法的一部分。三消游戏需要交换游戏中相邻的两个方格,以让3个或更多相同的方格连成直线,一旦连线成功,则消除这些连成线的同色方格,并使用新的方格进行填充,填充后如果还存在连线就可以达成Combo或多倍加分!

本教程就为大家分享如何在Unity中制作这样一款三消游戏的完整过程,从创建底板填充方格开始,到统计步数并计算游戏得分,来自己做一款三消游戏。

准备工作
将项目初始资源导入Unity项目,资源目录如下:
 



其中分别包含要用于游戏的动画、音效、字体、预制件、场景、脚本及图片资源。

创建游戏底板
打开Game场景,新建空游戏对象命名为BoardManager,该对象将用于生成游戏底板,并填充方格。然后将Scripts/Board and Grid文件夹下的BoardManager脚本拖拽至刚刚创建的BoardManager游戏对象上:
 



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是用于初始化方格底板的预制件。
  • xSize及ySize是底板横向及纵向的数量。
  • tiles是保存底板方格的二维数组。
  • IsShifting函数用于检测是否需要填充方格。
  • Start函数用于初始化BoardManager脚本实例。
  • CreateBoard函数用于创建底板,参数为方初始化格图片的宽度及高度,根据此前定义的底板方格数量及底板方格预制件,来初始化整个底板。


在层级视图中选中BoardManager对象,然后在检视视图中将BoardManager脚本的Characters元素数量设为7,然后将Sprites/Characters文件夹下的图片绑定到数组元素。最后将Prefabs文件夹下的Tile预制件绑定到脚本的Tile字段,将BoardManager脚本的X Size与Y Size分别设为8、12。完成后如下图:
 

然后运行场景,可以看到底板能够正常创建,但出现了偏移:
 


这是因为底板方格从左下角开始最先生成,而首个方格坐标为BoardManager对象坐标。下面调整BoardManager对象的坐标为(-2.66, -3.83, 0),让BoardManager坐标位于屏幕左下角。
 


随机生成底板

打开BoardManager脚本,在CreateBoard方法中新增以下代码:
 
[C#] 纯文本查看 复制代码
newTile.transform.parent = transform; // 1
Sprite newSprite = characters[Random.Range(0, characters.Count)]; // 2
newTile.GetComponent<SpriteRenderer>().sprite = newSprite; // 3

 以上代码的作用是将所有底板方格的父对象均设置为BoardManager,保持层级视图干净整洁,并从之前定义的数组中随机选取一张图片来初始化方格。现在运行游戏,效果如下:
 



上面生成的方格还有些小问题,就是一开始就出现了连续的可消除方格,下面就来解决这个问题。

避免初始化重复方格

底板方格按从下到上从左到右的顺序创建,所以在创建新方格前要对相邻的方格进行判断。
 


上图所示的循环会从左下方开始遍历方格,每次迭代都会获取当前方格左侧及下方的方格,然后通过随机选取这两个方格,来保证不会在初始化底板时出现3个及以上相连的同一方格。更改CreateBoard方法代码为如下:
 
[C#] 纯文本查看 复制代码
 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;
                    }
    }
}

 

运行游戏,不会出现重复相连的3个方格了:
 



交换方格

下面来实现选中并交换相邻的方格。打开Tile脚本,其中Select方法用于选中方格后替换方格图片并播放选中音效,Deselect方法用于恢复选中方格的图片,并提示当前未选中任意方格。SwapSprite方法用于交换两个相邻方格,即替换两个Sprite的纹理,然后播放交换音效。这里通过按下鼠标左键来操作方格,代码如下:
 
[C#] 纯文本查看 复制代码
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
        }

 

这里还需要保证仅相邻的方格才能进行交换,在Tile脚本中添加以下两个方法:
 

[C#] 纯文本查看 复制代码
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方法则调用GetAdjacent来生成围绕当前方格的列表,该循环将遍历各个方向与当前方格相邻的方格,并返回列表,以保证方格仅能与其相邻方格进行交换。

保存代码后运行场景,效果如下:
 



检测相同方格进行消除

消除可以拆解为几个步骤,首先判断是否出现3个及以上相连的同样方格,如果有,则消除已匹配的方格,并填充新方格。然后重复此步骤直至没有有效匹配。

在Tile脚本中新增以下代码:
 
[C#] 纯文本查看 复制代码
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参数,用于表示所有射线投射的方向,新建GameObject列表来保存所有匹配条件的方格,从方格朝参数方向投射射线,直至射线未碰撞到任何方格或与当前方格不一致时停止,然后返回匹配条件的Sprite列表。

ClearMatch方法会按照给定路径寻找相同的方格,并相应消除所有匹配的方格。即判断FindMatch方法返回的列表中,是否有相连为直线的3个及以上相同方格。如果有,则将matchFound设为True。ClearAllMatch方法会在找到满足条件的匹配后,删除所有匹配的方格。

运行游戏,效果如下:
 



填充空白方格

在消除方格后,还需要为其填充新的方格。在BoardManager脚本中加入以下代码:
 
[C#] 纯文本查看 复制代码
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方法用于查找是否存在空的方格,如果有,则调用ShiftTilesDown方法将周围的方格填充进来,该方法有三个参数,分别是X索引,Y索引以及延迟时间,X、Y决定了哪一块方格需要移动,这里仅实现向下填充,所以X值是固定了,仅Y值会变。GetNewSprite方法将生成新的方块来填满整个底板。
 



连击

新填充的方格可能会再次出现符合条件的匹配,所以新填充底板后要再次进行判断。再找到匹配后再次匹配成功,就是一次连击。所以在上面的FindNullTiles方法中,通过以下代码循环判断是否出现匹配:
 
[C#] 纯文本查看 复制代码
for (int x = 0; x < xSize; x++) {
                for (int y = 0; y < ySize; y++) {
                        tiles[x, y].GetComponent<Tile>().ClearAllMatches();
                }
        }

 现在运行游戏,效果如下:
 



添加计步器与分数

下面实现玩家步数记录,并统计游戏分数。打开Scripts/Managers文件夹下的GUIManager脚本,该脚本用于管理游戏UI,显示步数及分数文本。脚本代码如下:
 
[C#] 纯文本查看 复制代码
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中获取脚本引用,并初始化步数。Score及MoveCounter函数用于在每次更新分数值或步数时,UI界面上的文本也会同时更新。当步数减少至0时,游戏结束。此时会通过WaitForShifting协程在等待0.25秒后调用GameOver方法,并在GameOver方法中显示游戏结束面板。这里的等待是为了确保所有连击都被计算在总分内。
 



总结

到这里本篇教程就结束了,当然大家还可以在理解游戏机制后添加更多的玩法,包括限时结算模式、增加不同关卡与底板类型、连击的积分计算规则,或是为消除方格添加一些酷炫的粒子效果等等。后面就留给大家自行扩展与发挥了!