请选择 进入手机版 | 继续访问电脑版

查看: 32504|回复: 621

[原创] Unity教程 | 从零开始制作一款三消游戏

  [复制链接]

1040

主题

1724

帖子

2万

贡献

管理员

Rank: 9Rank: 9Rank: 9

积分
22405
QQ
发表于 2017-5-24 02:42:17 | 显示全部楼层 |阅读模式
三消类游戏一直是游戏市场上经久不衰的休闲游戏,该类型也是源自于经典的俄罗斯方块玩法的一部分。三消游戏需要交换游戏中相邻的两个方格,以让3个或更多相同的方格连成直线,一旦连线成功,则消除这些连成线的同色方格,并使用新的方格进行填充,填充后如果还存在连线就可以达成Combo或多倍加分!

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

准备工作

下载项目初始资源(请回复本帖哦~):
游客,如果您要查看本帖隐藏内容请回复



将项目初始资源导入Unity项目,资源目录如下:

image001.png


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

创建游戏底板

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

image003.png


BoardManager脚本代码如下:

[C#] 纯文本查看 复制代码
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。完成后如下图:

image007.png


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

image009.png


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

image011.png


随机生成底板

打开BoardManager脚本,在CreateBoard方法中新增以下代码:

[C#] 纯文本查看 复制代码
newTile.transform.parent = transform; // 1
Sprite newSprite = characters[Random.Range(0, characters.Count)]; // 2
newTile.GetComponent<SpriteRenderer>().sprite = newSprite; // 3


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

image015.png


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

避免初始化重复方格

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

image017.png


上图所示的循环会从左下方开始遍历方格,每次迭代都会获取当前方格左侧及下方的方格,然后通过随机选取这两个方格,来保证不会在初始化底板时出现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个方格了:

image021.png


交换方格

下面来实现选中并交换相邻的方格。打开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来生成围绕当前方格的列表,该循环将遍历各个方向与当前方格相邻的方格,并返回列表,以保证方格仅能与其相邻方格进行交换。

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

image021.gif


检测相同方格进行消除

消除可以拆解为几个步骤,首先判断是否出现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;

                ClearMatch(new Vector2[2] { Vector2.left, Vector2.right });
                ClearMatch(new Vector2[2] { Vector2.up, Vector2.down });
                if (matchFound) {
                        render.sprite = null;
                        matchFound = false;
                        StopCoroutine(BoardManager.instance.FindNullTiles()); //Add this line
                        StartCoroutine(BoardManager.instance.FindNullTiles()); //Add this line
                        SFXManager.instance.PlaySFX(Clip.Clear);
                }
        }


FindMatch方法接收一个Vector2参数,用于表示所有射线投射的方向,新建GameObject列表来保存所有匹配条件的方格,从方格朝参数方向投射射线,直至射线未碰撞到任何方格或与当前方格不一致时停止,然后返回匹配条件的Sprite列表。

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

运行游戏,效果如下:

image027.gif


填充空白方格

在消除方格后,还需要为其填充新的方格。在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方法将生成新的方块来填满整个底板。

image031.gif


连击

新填充的方格可能会再次出现符合条件的匹配,所以新填充底板后要再次进行判断。再找到匹配后再次匹配成功,就是一次连击。所以在上面的FindNullTiles方法中,通过以下代码循环判断是否出现匹配:

[C#] 纯文本查看 复制代码
        for (int x = 0; x < xSize; x++) {
                        for (int y = 0; y < ySize; y++) {
                                tiles[x, y].GetComponent<Tile>().ClearAllMatches();
                        }
                }


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

image035.gif


添加计步器与分数

下面实现玩家步数记录,并统计游戏分数。打开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方法中显示游戏结束面板。这里的等待是为了确保所有连击都被计算在总分内。

image039.gif image043.png


总结

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

下载完整项目(请回复本帖哦~):
游客,如果您要查看本帖隐藏内容请回复


原文链接:https://www.raywenderlich.com/152282/how-to-make-a-match-3-game-in-unity
转载请注明:来自Unity官方中文社区(forum.china.unity3d.com)


1

主题

2

帖子

40

贡献

初级UU族—1级

Rank: 1

积分
40
发表于 2017-5-26 11:09:49 | 显示全部楼层
回复才能看,这是啥年代的事。

0

主题

5

帖子

65

贡献

初级UU族—2级

Rank: 2

积分
65
发表于 2017-5-24 04:35:14 | 显示全部楼层
支持一下!!!!!!!!!!!!!

0

主题

2

帖子

25

贡献

初级UU族—1级

Rank: 1

积分
25
发表于 2017-5-24 04:36:46 | 显示全部楼层
一直喜欢三消游戏,想做三消游戏

2

主题

12

帖子

120

贡献

初级UU族—2级

Rank: 2

积分
120
发表于 2017-5-24 04:38:48 | 显示全部楼层
谢谢楼主分享

0

主题

7

帖子

75

贡献

初级UU族—2级

Rank: 2

积分
75
发表于 2017-5-24 04:41:13 | 显示全部楼层
dddddddddddddddd

2

主题

12

帖子

120

贡献

初级UU族—2级

Rank: 2

积分
120
发表于 2017-5-24 04:42:46 | 显示全部楼层
楼主,链接失效了,可以补一下吗

0

主题

5

帖子

65

贡献

初级UU族—2级

Rank: 2

积分
65
发表于 2017-5-24 04:43:11 | 显示全部楼层
网盘挂掉了。。。。。。。。。。。。。。。。。。。。。。

0

主题

7

帖子

75

贡献

初级UU族—2级

Rank: 2

积分
75
发表于 2017-5-24 04:45:47 | 显示全部楼层
楼主呢,咋回事,链接失效了呢

0

主题

7

帖子

85

贡献

初级UU族—2级

Rank: 2

积分
85
发表于 2017-5-24 04:54:50 | 显示全部楼层
支持一下,多谢分享

0

主题

3

帖子

25

贡献

初级UU族—1级

Rank: 1

积分
25
发表于 2017-5-24 04:56:17 | 显示全部楼层
谢谢,参考了
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

快速回复 返回顶部 返回列表